mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-09-29 17:23:59 +08:00
compat(plugins): tell users which installed plugins break on 2026-09-14, and stop loading them after
hermes_cli/plugin_compat.py is now the single source of truth for the compat window:
COMPAT_REMOVAL_DATE = 2026-09-14; scan_plugin() statically finds `from F import n`, `import F` + `F.n`,
alias forms and string targets against compat_manifest.json; compat_report() aggregates over the user's
ENABLED external (non-bundled) plugins; disable_reason() decides the loader's skip.
Surfaces (all read from that one report):
* CLI: yellow block under the banner naming plugins + date + `hermes plugins compat` (red + DISABLED after)
* `hermes plugins compat [--json] [path]`: file:line, old -> new per hit; exit 1 while anything remains;
`path` lets a plugin author scan their own checkout
* `hermes doctor`: "Plugin import paths (removed Sep 14, 2026)" section next to the xAI retirement check
* `hermes update`: post-update notice alongside the FTS/curator notices
* Desktop: compat_report() writes HERMES_HOME/.plugin-compat-report.json (deleted when clean); Electron
shows ONE warning dialog per distinct report after the backend is up and persists the dismissal in
userData/plugin-compat-dismissed.json. A new affected plugin, or the date passing, is a new report.
From the date, PluginManager skips a hitting external plugin before importing it, with the reason in
LoadedPlugin.error ("uses N import path(s) removed on 2026-09-14; run `hermes plugins compat` ...") — the
same path a plugin with a broken register() takes, so nothing else is affected. Escape hatch:
plugins.allow_deprecated_imports: true (config_defaults), which only helps until the compat commit is
actually reverted.
Docs: COMPAT_MANIFEST.md (removal date, what-happens table, author instructions), plugin dev guide section.
Tests: tests/test_plugin_compat_notice.py (scanner forms, report scope, date gate + escape hatch, summary
text, report file lifecycle, loader skip via a real PluginManager), electron/plugin-compat-notice.test.ts
(show once, re-show on a different set or on the date passing, malformed file ignored).
Live A/B on this box with a demo plugin on old paths: before the date it loads and the banner/doctor/report
name it; with today=2026-09-14 it is skipped with the reason and the banner turns red; with the escape
hatch it loads again.
This commit is contained in:
+16
-2
@@ -5,11 +5,25 @@ files. **Internal import paths are not a stable API**, and after that PR the nam
|
||||
longer defined where they used to be. To give external plugin authors time to update, every name
|
||||
is still importable from its OLD module through a `PLUGIN-COMPAT` block appended to that module.
|
||||
|
||||
**This layer is temporary.** It was added as a single commit and will be removed, on the announced
|
||||
date, by reverting that commit. Update your plugin to import from the `new location` column now.
|
||||
**This layer is temporary and removed on 2026-09-14.** It was added as a single commit and is removed
|
||||
by reverting that commit. Update your plugin to import from the `new location` column now.
|
||||
Nothing inside this repository is allowed to use these pointers (`scripts/check_compat_pointers.py`
|
||||
fails CI if it does).
|
||||
|
||||
**What happens to an affected plugin.**
|
||||
|
||||
| when | CLI banner / `hermes doctor` / `hermes update` | Desktop | the plugin |
|
||||
|---|---|---|---|
|
||||
| before 2026-09-14 | yellow notice naming the plugin, the date, and `hermes plugins compat` | one-time modal (per set of affected plugins) | loads; each old-path resolution emits `HermesPluginCompatWarning` once |
|
||||
| from 2026-09-14 | red notice: plugin **DISABLED** | one-time modal | **not loaded**; `hermes plugins list` shows the reason |
|
||||
| after the revert lands | same | same | not loaded (the old paths no longer exist) |
|
||||
|
||||
Escape hatch for users who cannot wait on an author: `plugins.allow_deprecated_imports: true` in
|
||||
`config.yaml` keeps affected plugins loading after the date, until the revert actually removes the paths.
|
||||
|
||||
**For plugin authors:** run `hermes plugins compat <path-to-your-plugin>` — it prints every `file:line`,
|
||||
old path → new path, and exits 1 while anything remains. Import from the `new location` column.
|
||||
|
||||
**You will see a warning.** The first time a process resolves a name through one of these blocks, Hermes emits a
|
||||
`HermesPluginCompatWarning` (a `FutureWarning`) naming the old path, the new path, and the removal target — once per
|
||||
name per process. Fix the import and it goes away. To silence during migration:
|
||||
|
||||
@@ -204,6 +204,10 @@ import { probeGatewayWebSocket } from './gateway-ws-probe'
|
||||
import { registerGitIpc } from './git-ipc'
|
||||
import { clearStaleGitLocks } from './gitlock'
|
||||
import { readAndConsumeHandoffResult } from './handoff-result'
|
||||
import {
|
||||
pendingNotice as pendingPluginCompatNotice,
|
||||
recordDismissed as recordPluginCompatDismissed
|
||||
} from './plugin-compat-notice'
|
||||
import {
|
||||
ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES,
|
||||
clampDataUrlReadMaxMb,
|
||||
@@ -6715,6 +6719,44 @@ function getAppIconPath() {
|
||||
}
|
||||
}
|
||||
|
||||
// One-time modal for plugins importing pre-decomposition module paths (see
|
||||
// electron/plugin-compat-notice.ts). The backend writes the report during plugin
|
||||
// discovery; we show each distinct report exactly once and remember the dismissal
|
||||
// in userData so the user is never nagged twice about the same set of plugins.
|
||||
let pluginCompatNoticeShown = false
|
||||
|
||||
async function showPluginCompatNoticeOnce() {
|
||||
if (pluginCompatNoticeShown) return
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
let notice
|
||||
try {
|
||||
notice = pendingPluginCompatNotice(HERMES_HOME, app.getPath('userData'))
|
||||
} catch (err) {
|
||||
rememberLog(`[plugins] compat notice check failed: ${err.message}`)
|
||||
return
|
||||
}
|
||||
if (!notice) return
|
||||
pluginCompatNoticeShown = true
|
||||
rememberLog(`[plugins] compat notice shown (${notice.key})`)
|
||||
try {
|
||||
await dialog.showMessageBox(mainWindow, {
|
||||
type: 'warning',
|
||||
title: notice.title,
|
||||
message: notice.message,
|
||||
detail: notice.detail,
|
||||
buttons: ['OK'],
|
||||
defaultId: 0,
|
||||
noLink: true
|
||||
})
|
||||
} finally {
|
||||
try {
|
||||
recordPluginCompatDismissed(app.getPath('userData'), notice.key)
|
||||
} catch (err) {
|
||||
rememberLog(`[plugins] could not persist compat notice dismissal: ${err.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendOpenUpdatesRequested() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
@@ -13049,6 +13091,10 @@ async function startHermes() {
|
||||
// accumulated count of the resolved episode.
|
||||
bootstrapRepairAttempt = 0
|
||||
|
||||
// The backend's plugin discovery just ran and refreshed HERMES_HOME/.plugin-compat-report.json.
|
||||
// Surface it once (per distinct set of affected plugins) after the window is up; never block boot.
|
||||
setTimeout(() => void showPluginCompatNoticeOnce(), 1500)
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'local',
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { test } from 'vitest'
|
||||
import { DISMISSED_FILE, REPORT_FILE, pendingNotice, recordDismissed, reportKey } from './plugin-compat-notice'
|
||||
|
||||
function tmp() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-compat-'))
|
||||
}
|
||||
|
||||
const REPORT = {
|
||||
removal_date: '2026-09-14',
|
||||
in_effect: false,
|
||||
plugins: {
|
||||
alpha: [{ file: '__init__.py', line: 3, old: 'tools.web_tools.prefers_gateway', new: 'tools.tool_backend_helpers.prefers_gateway' }],
|
||||
beta: [
|
||||
{ file: 'a.py', line: 1, old: 'hermes_cli.kanban_db.connect', new: 'hermes_cli.kanban_db_connect.connect' },
|
||||
{ file: 'a.py', line: 9, old: 'hermes_cli.kanban_db.connect_closing', new: 'hermes_cli.kanban_db_connect.connect_closing' }
|
||||
]
|
||||
},
|
||||
lines: ['2 plugins use import paths that stop working on 2026-09-14 (10 days): alpha (1), beta (2)', 'Details: hermes plugins compat']
|
||||
}
|
||||
|
||||
test('no report file → no notice', () => {
|
||||
assert.equal(pendingNotice(tmp(), tmp()), null)
|
||||
})
|
||||
|
||||
test('report → one notice naming plugins, date and the CLI command', () => {
|
||||
const home = tmp()
|
||||
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(REPORT))
|
||||
const n = pendingNotice(home, tmp())
|
||||
assert.ok(n)
|
||||
assert.equal(n.title, 'Plugins need an update')
|
||||
assert.match(n.message, /2 plugins import module paths that stop working on 2026-09-14/)
|
||||
assert.match(n.detail, /• alpha — 1 import \(e\.g\. tools\.web_tools\.prefers_gateway → tools\.tool_backend_helpers\.prefers_gateway\)/)
|
||||
assert.match(n.detail, /• beta — 2 imports/)
|
||||
assert.match(n.detail, /hermes plugins compat/)
|
||||
})
|
||||
|
||||
test('dismissal is remembered for the same report and forgotten for a different one', () => {
|
||||
const home = tmp()
|
||||
const userData = tmp()
|
||||
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(REPORT))
|
||||
const first = pendingNotice(home, userData)
|
||||
assert.ok(first)
|
||||
recordDismissed(userData, first.key)
|
||||
assert.ok(fs.existsSync(path.join(userData, DISMISSED_FILE)))
|
||||
assert.equal(pendingNotice(home, userData), null, 'same report must not show twice')
|
||||
|
||||
// a third affected plugin is new information
|
||||
const grown = { ...REPORT, plugins: { ...REPORT.plugins, gamma: [{ file: 'g.py', line: 1, old: 'x.y', new: 'z.y' }] } }
|
||||
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(grown))
|
||||
const second = pendingNotice(home, userData)
|
||||
assert.ok(second)
|
||||
assert.notEqual(second.key, first.key)
|
||||
|
||||
// the date passing (plugins now disabled) is new information too, with different wording
|
||||
const disabled = { ...REPORT, in_effect: true }
|
||||
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(disabled))
|
||||
const third = pendingNotice(home, userData)
|
||||
assert.ok(third)
|
||||
assert.equal(third.title, 'Some plugins were not loaded')
|
||||
assert.match(third.detail, /allow_deprecated_imports/)
|
||||
assert.notEqual(reportKey(disabled as any), reportKey(REPORT as any))
|
||||
})
|
||||
|
||||
test('empty or malformed report is ignored', () => {
|
||||
const home = tmp()
|
||||
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify({ ...REPORT, plugins: {} }))
|
||||
assert.equal(pendingNotice(home, tmp()), null)
|
||||
fs.writeFileSync(path.join(home, REPORT_FILE), '{not json')
|
||||
assert.equal(pendingNotice(home, tmp()), null)
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* One-time Desktop notice for plugins that import pre-decomposition module paths (PR #102117).
|
||||
*
|
||||
* The Python side (hermes_cli/plugin_compat.py) statically scans the user's enabled external plugins on
|
||||
* every CLI/gateway/TUI start and writes HERMES_HOME/.plugin-compat-report.json when any plugin imports a
|
||||
* path scheduled for removal on 2026-09-14 (deleting the file when none do). Desktop reads that file at
|
||||
* boot and shows ONE modal, then records the dismissal in userData so the same set of affected plugins is
|
||||
* never shown again. A *different* set (a new affected plugin, or the removal date passing so the plugins
|
||||
* are now disabled) is a new message and shows once more.
|
||||
*
|
||||
* Pure module: no Electron imports, so it is unit-testable; main.ts owns the dialog.
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export const REPORT_FILE = '.plugin-compat-report.json'
|
||||
export const DISMISSED_FILE = 'plugin-compat-dismissed.json'
|
||||
|
||||
export interface PluginCompatHit {
|
||||
file: string
|
||||
line: number
|
||||
old: string
|
||||
new: string
|
||||
}
|
||||
|
||||
export interface PluginCompatReport {
|
||||
removal_date: string
|
||||
in_effect: boolean
|
||||
written_at?: string
|
||||
plugins: Record<string, PluginCompatHit[]>
|
||||
lines: [string, string]
|
||||
}
|
||||
|
||||
/** Stable identity of a report: which plugins, how many hits each, and whether removal is in effect. */
|
||||
export function reportKey(report: PluginCompatReport): string {
|
||||
const parts = Object.keys(report.plugins)
|
||||
.sort()
|
||||
.map(name => `${name}:${report.plugins[name].length}`)
|
||||
return `${report.in_effect ? 'disabled' : 'pending'}|${parts.join(',')}`
|
||||
}
|
||||
|
||||
export function readReport(hermesHome: string): PluginCompatReport | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(hermesHome, REPORT_FILE), 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== 'object' || !parsed.plugins || !Array.isArray(parsed.lines)) return null
|
||||
if (Object.keys(parsed.plugins).length === 0) return null
|
||||
return parsed as PluginCompatReport
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function wasDismissed(userData: string, key: string): boolean {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(userData, DISMISSED_FILE), 'utf8'))
|
||||
return Array.isArray(raw?.keys) && raw.keys.includes(key)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function recordDismissed(userData: string, key: string): void {
|
||||
const file = path.join(userData, DISMISSED_FILE)
|
||||
let keys: string[] = []
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
if (Array.isArray(raw?.keys)) keys = raw.keys.filter((k: unknown) => typeof k === 'string')
|
||||
} catch {
|
||||
/* first dismissal */
|
||||
}
|
||||
if (!keys.includes(key)) keys.push(key)
|
||||
fs.mkdirSync(userData, { recursive: true })
|
||||
fs.writeFileSync(file, JSON.stringify({ keys: keys.slice(-20) }, null, 2))
|
||||
}
|
||||
|
||||
export interface PendingNotice {
|
||||
key: string
|
||||
title: string
|
||||
message: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
/** The modal to show this boot, or null (no report, or this exact report already dismissed). */
|
||||
export function pendingNotice(hermesHome: string, userData: string): PendingNotice | null {
|
||||
const report = readReport(hermesHome)
|
||||
if (!report) return null
|
||||
const key = reportKey(report)
|
||||
if (wasDismissed(userData, key)) return null
|
||||
const names = Object.keys(report.plugins).sort()
|
||||
const list = names
|
||||
.map(n => {
|
||||
const hits = report.plugins[n]
|
||||
const first = hits[0]
|
||||
return `• ${n} — ${hits.length} import${hits.length === 1 ? '' : 's'} (e.g. ${first.old} → ${first.new})`
|
||||
})
|
||||
.join('\n')
|
||||
const title = report.in_effect ? 'Some plugins were not loaded' : 'Plugins need an update'
|
||||
const message = report.in_effect
|
||||
? `${names.length} plugin${names.length === 1 ? '' : 's'} import${names.length === 1 ? 's' : ''} module paths that were removed on ${report.removal_date} and ${names.length === 1 ? 'was' : 'were'} not loaded.`
|
||||
: `${names.length} plugin${names.length === 1 ? '' : 's'} import${names.length === 1 ? 's' : ''} module paths that stop working on ${report.removal_date}.`
|
||||
const detail = report.in_effect
|
||||
? `${list}\n\nUpdate the plugin(s), or force-load them with plugins.allow_deprecated_imports: true in config.yaml (they will still break once the compatibility layer is removed).\n\nFull list: hermes plugins compat`
|
||||
: `${list}\n\nCheck for plugin updates or notify the author before ${report.removal_date}. After that date these plugins are not loaded.\n\nFull list: hermes plugins compat`
|
||||
return { key, title, message, detail }
|
||||
}
|
||||
@@ -74,6 +74,21 @@ class CLIInfoMixin:
|
||||
"""Informational views and reload flows for the interactive CLI: banner, help, tools, usage,
|
||||
insights, MCP/skills reload, bang shell."""
|
||||
|
||||
def _show_plugin_compat_notice(self) -> None:
|
||||
"""One yellow block under the banner when an enabled external plugin imports paths scheduled for
|
||||
removal (red once the date has passed and the plugin was skipped). Never raises."""
|
||||
try:
|
||||
from hermes_cli.plugin_compat import compat_report, removal_in_effect, summary_lines
|
||||
lines = summary_lines(compat_report())
|
||||
except Exception:
|
||||
return
|
||||
if not lines:
|
||||
return
|
||||
colour = "bold red" if removal_in_effect() else "bold yellow"
|
||||
self._console_print()
|
||||
self._console_print(f"[{colour}]⚠ {lines[0]}[/]")
|
||||
self._console_print(f"[dim] {lines[1]}[/]")
|
||||
|
||||
def show_banner(self):
|
||||
"""Display the welcome banner in Claude Code style."""
|
||||
from cli import _build_compact_banner, get_tool_definitions, logger
|
||||
@@ -154,6 +169,7 @@ class CLIInfoMixin:
|
||||
|
||||
# Low context warning — tied to the runtime guard so guidance cannot drift.
|
||||
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH
|
||||
self._show_plugin_compat_notice()
|
||||
if ctx_len and ctx_len < MINIMUM_CONTEXT_LENGTH:
|
||||
self._console_print()
|
||||
self._console_print(
|
||||
|
||||
@@ -1547,6 +1547,10 @@ DEFAULT_CONFIG = {
|
||||
# Wall-clock cap (seconds) for one in-process Python plugin hook callback; shell hooks keep
|
||||
# their own per-entry `timeout`. 0 = no cap (sync call on agent thread). Max 600.
|
||||
"hook_callback_timeout": 30,
|
||||
# Keep loading external plugins that still import pre-decomposition module paths after the
|
||||
# 2026-09-14 removal date (see COMPAT_MANIFEST.md, `hermes plugins compat`). Stopgap only: the
|
||||
# old paths raise ImportError once the compat layer is actually removed.
|
||||
"allow_deprecated_imports": False,
|
||||
},
|
||||
# Shell-script hooks: event name (pre_tool_call, post_tool_call, pre_llm_call, subagent_stop,
|
||||
# ...) -> list of {matcher, command, timeout}. First run of a new command prompts for consent;
|
||||
|
||||
@@ -30,6 +30,7 @@ from hermes_cli.doctor_config import (
|
||||
_check_env_file,
|
||||
_check_mcp_security,
|
||||
_check_xai_retirement,
|
||||
_check_plugin_compat,
|
||||
)
|
||||
from hermes_cli.doctor_platform import (
|
||||
_check_certificates,
|
||||
@@ -110,7 +111,8 @@ DOCTOR_CHECKS = (
|
||||
('Python Environment', _check_python_environment), ('SSL / CA Certificates', _check_certificates),
|
||||
('Required Packages', _check_required_packages), ('Configuration Files', _check_env_file),
|
||||
(None, _check_config_file), (None, _check_config_drift),
|
||||
('xAI Model Retirement (May 15, 2026)', _check_xai_retirement), ('Auth Providers', _check_auth_providers),
|
||||
('xAI Model Retirement (May 15, 2026)', _check_xai_retirement),
|
||||
('Plugin import paths (removed Sep 14, 2026)', _check_plugin_compat), ('Auth Providers', _check_auth_providers),
|
||||
('Directory Structure', _check_directory_structure), (None, _check_state_db),
|
||||
(None, _check_gateway_supervision), (None, _check_command_installation),
|
||||
('External Tools', _check_git_and_rg), (None, _check_terminal_backend), (None, _check_node_and_browser),
|
||||
|
||||
@@ -418,3 +418,20 @@ def _check_xai_retirement(should_fix: bool, f: Finding) -> None:
|
||||
check_warn(format_issue(ref))
|
||||
check_info(f"Migration guide: {MIGRATION_GUIDE_URL}")
|
||||
f.manual_issues.append(f"Update {len(retired_refs)} retired xAI model reference(s) in config.yaml — see {MIGRATION_GUIDE_URL}")
|
||||
|
||||
|
||||
@doctor_check("Plugin compat check skipped", "({e})")
|
||||
def _check_plugin_compat(should_fix: bool, f: Finding) -> None:
|
||||
from hermes_cli.plugin_compat import ALLOW_KEY, COMPAT_REMOVAL, compat_report, removal_in_effect
|
||||
report = compat_report()
|
||||
if not report:
|
||||
check_ok(f"No enabled plugin imports paths removed on {COMPAT_REMOVAL}")
|
||||
return
|
||||
for name, hits in sorted(report.items()):
|
||||
(check_fail if removal_in_effect() else check_warn)(
|
||||
f"{name}: {len(hits)} import(s) of paths removed on {COMPAT_REMOVAL}", f"{hits[0].old} -> {hits[0].new}")
|
||||
check_info("Details: hermes plugins compat")
|
||||
f.manual_issues.append(
|
||||
f"Update {len(report)} plugin(s) still importing pre-decomposition paths (hermes plugins compat) — "
|
||||
+ ("they are NOT being loaded" if removal_in_effect() else f"they stop loading on {COMPAT_REMOVAL}")
|
||||
+ f"; escape hatch: plugins.{ALLOW_KEY}: true")
|
||||
|
||||
+270
-10
@@ -1,27 +1,287 @@
|
||||
"""Once-per-process notice for external plugins still importing from pre-decomposition module paths.
|
||||
"""Plugin compatibility with the Sep 2026 decomposition: detect, warn, and (after the date) disable.
|
||||
|
||||
The Sep 2026 decomposition (PR #102117) moved most of Hermes's internals into ``<stem>_<topic>`` sibling
|
||||
modules. Old paths keep resolving through ``PLUGIN-COMPAT`` blocks so plugins have time to update; each
|
||||
resolution through such a block calls :func:`warn_once` so the plugin author sees, exactly once per process
|
||||
per name, where the code went and when the old path disappears. Internal Hermes code never reaches this
|
||||
(``scripts/check_compat_pointers.py`` fails CI if it does).
|
||||
The decomposition (PR #102117) moved most of Hermes's internals into ``<stem>_<topic>`` sibling modules.
|
||||
Old import paths keep resolving through ``PLUGIN-COMPAT`` blocks until :data:`COMPAT_REMOVAL_DATE`, when
|
||||
the commit that added them is reverted. This module is the single source of truth for everything that
|
||||
tells plugin authors and users about that:
|
||||
|
||||
* :func:`scan_plugin` — static AST scan of one plugin directory for imports of manifest names.
|
||||
* :func:`compat_report` — ``{plugin_name: [Hit, ...]}`` across the user's ENABLED external plugins, cached.
|
||||
* :func:`removal_in_effect` — True once today >= the removal date (or the layer is already gone).
|
||||
* :func:`warn_once` — the per-name runtime warning emitted by the PLUGIN-COMPAT ``__getattr__`` blocks.
|
||||
|
||||
Surfaces that read from here: the CLI banner, ``hermes plugins compat``, ``hermes doctor``, the post-update
|
||||
notices, the TUI/Desktop ``plugins.compat_report`` RPC, and ``PluginManager`` (which skips a hitting plugin
|
||||
after the date unless ``plugins.allow_deprecated_imports: true``).
|
||||
|
||||
This module is part of the compat layer and is removed with it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import datetime as _dt
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import warnings
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
# Set by the maintainers when the removal is scheduled; surfaces in every warning and in COMPAT_MANIFEST.md.
|
||||
COMPAT_REMOVAL = os.environ.get("HERMES_PLUGIN_COMPAT_REMOVAL", "the next minor release after the announced date (see COMPAT_MANIFEST.md)")
|
||||
COMPAT_REMOVAL_DATE = _dt.date(2026, 9, 14)
|
||||
COMPAT_REMOVAL = COMPAT_REMOVAL_DATE.isoformat()
|
||||
ALLOW_KEY = "allow_deprecated_imports" # under plugins: in config.yaml
|
||||
_MANIFEST_NAME = "compat_manifest.json"
|
||||
_SKIP_DIRS = {"__pycache__", "node_modules", ".git", "tests", "test", ".venv", "venv"}
|
||||
|
||||
|
||||
class HermesPluginCompatWarning(FutureWarning):
|
||||
"""A plugin imported a name from its pre-decomposition module path."""
|
||||
|
||||
|
||||
_seen: set[tuple[str, str]] = set()
|
||||
@dataclass(frozen=True)
|
||||
class Hit:
|
||||
file: str # path relative to the plugin dir
|
||||
line: int
|
||||
old: str # "facade.name"
|
||||
new: str # "target_module.name" (or the target module when the name is unchanged)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------- manifest
|
||||
|
||||
_manifest_lock = threading.Lock()
|
||||
_manifest_cache: Optional[Dict[str, Dict[str, str]]] = None # facade -> {name: new_path}
|
||||
|
||||
|
||||
def manifest_path() -> Path:
|
||||
return Path(__file__).resolve().parent.parent / _MANIFEST_NAME
|
||||
|
||||
|
||||
def load_manifest() -> Dict[str, Dict[str, str]]:
|
||||
"""``{facade_module: {name: new_dotted_path}}``; ``{}`` when the compat layer is gone."""
|
||||
global _manifest_cache
|
||||
with _manifest_lock:
|
||||
if _manifest_cache is not None:
|
||||
return _manifest_cache
|
||||
out: Dict[str, Dict[str, str]] = {}
|
||||
p = manifest_path()
|
||||
if p.exists():
|
||||
try:
|
||||
for e in json.loads(p.read_text(encoding="utf-8"))["entries"]:
|
||||
target = e.get("target") or ""
|
||||
if target.startswith("("): # restored-def etc.: no new home, just "gone later"
|
||||
new = f"{e['facade']}.{e['name']} (removed; no replacement — vendor a copy)"
|
||||
elif target.endswith("." + e["name"]):
|
||||
new = target
|
||||
else:
|
||||
new = f"{target}.{e['name']}"
|
||||
out.setdefault(e["facade"], {})[e["name"]] = new
|
||||
except Exception:
|
||||
out = {}
|
||||
_manifest_cache = out
|
||||
return out
|
||||
|
||||
|
||||
def removal_in_effect(today: Optional[_dt.date] = None) -> bool:
|
||||
"""True when hitting plugins must be disabled: the date has passed or the layer is already reverted."""
|
||||
if not manifest_path().exists():
|
||||
return True
|
||||
return (today or _dt.date.today()) >= COMPAT_REMOVAL_DATE
|
||||
|
||||
|
||||
def days_until_removal(today: Optional[_dt.date] = None) -> int:
|
||||
return (COMPAT_REMOVAL_DATE - (today or _dt.date.today())).days
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------- scanner
|
||||
|
||||
def _iter_py(root: Path) -> Iterable[Path]:
|
||||
for dp, dns, fns in os.walk(root):
|
||||
dns[:] = [d for d in dns if d not in _SKIP_DIRS and not d.startswith(".")]
|
||||
for f in fns:
|
||||
if f.endswith(".py"):
|
||||
yield Path(dp) / f
|
||||
|
||||
|
||||
def scan_source(src: str, rel: str, manifest: Dict[str, Dict[str, str]]) -> List[Hit]:
|
||||
"""Hits in one file: ``from F import n``, ``import F`` + ``F.n``, ``import F as a`` + ``a.n``,
|
||||
and string targets ``"F.n"`` (``patch``/``import_module``)."""
|
||||
try:
|
||||
tree = ast.parse(src)
|
||||
except SyntaxError:
|
||||
return []
|
||||
hits: List[Hit] = []
|
||||
aliases: Dict[str, str] = {} # local alias -> facade module
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module in manifest and node.level == 0:
|
||||
for a in node.names:
|
||||
if a.name in manifest[node.module]:
|
||||
hits.append(Hit(rel, node.lineno, f"{node.module}.{a.name}", manifest[node.module][a.name]))
|
||||
elif isinstance(node, ast.Import):
|
||||
for a in node.names:
|
||||
if a.name in manifest:
|
||||
aliases[a.asname or a.name] = a.name
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id in aliases:
|
||||
fac = aliases[node.value.id]
|
||||
if node.attr in manifest[fac]:
|
||||
hits.append(Hit(rel, node.lineno, f"{fac}.{node.attr}", manifest[fac][node.attr]))
|
||||
elif isinstance(node, ast.Attribute):
|
||||
# dotted: pkg.sub.name -> resolve the full module chain
|
||||
parts: List[str] = []
|
||||
cur: ast.AST = node
|
||||
while isinstance(cur, ast.Attribute):
|
||||
parts.append(cur.attr)
|
||||
cur = cur.value
|
||||
if isinstance(cur, ast.Name):
|
||||
parts.append(cur.id)
|
||||
parts.reverse()
|
||||
for i in range(1, len(parts)):
|
||||
mod, name = ".".join(parts[:i]), parts[i]
|
||||
if mod in manifest and name in manifest[mod]:
|
||||
hits.append(Hit(rel, node.lineno, f"{mod}.{name}", manifest[mod][name]))
|
||||
elif isinstance(node, ast.Constant) and isinstance(node.value, str) and "." in node.value:
|
||||
mod, _, name = node.value.rpartition(".")
|
||||
if mod in manifest and name in manifest[mod]:
|
||||
hits.append(Hit(rel, node.lineno, node.value, manifest[mod][name]))
|
||||
# dedupe (the two walks can see the same Attribute)
|
||||
return sorted(set(hits), key=lambda h: (h.file, h.line, h.old))
|
||||
|
||||
|
||||
def scan_plugin(plugin_dir: Path, manifest: Optional[Dict[str, Dict[str, str]]] = None) -> List[Hit]:
|
||||
manifest = load_manifest() if manifest is None else manifest
|
||||
if not manifest or not plugin_dir or not Path(plugin_dir).is_dir():
|
||||
return []
|
||||
hits: List[Hit] = []
|
||||
for p in _iter_py(Path(plugin_dir)):
|
||||
try:
|
||||
src = p.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
hits += scan_source(src, str(p.relative_to(plugin_dir)), manifest)
|
||||
return hits
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------- report
|
||||
|
||||
_report_lock = threading.Lock()
|
||||
_report_cache: Dict[Tuple[str, ...], Dict[str, List[Hit]]] = {}
|
||||
|
||||
|
||||
def compat_report(manifests=None, *, force: bool = False) -> Dict[str, List[Hit]]:
|
||||
"""``{plugin_name: hits}`` for every ENABLED external (non-bundled) plugin with at least one hit.
|
||||
|
||||
``manifests`` defaults to the current PluginManager's discovered manifests. Cached per manifest set.
|
||||
"""
|
||||
if manifests is None:
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
mgr = get_plugin_manager()
|
||||
mgr.discover_and_load()
|
||||
manifests = [lp.manifest for lp in mgr._plugins.values()]
|
||||
except Exception:
|
||||
return {}
|
||||
external = [m for m in manifests if getattr(m, "source", "") != "bundled" and getattr(m, "path", None)]
|
||||
key = tuple(sorted(f"{m.name}@{m.path}" for m in external))
|
||||
with _report_lock:
|
||||
if not force and key in _report_cache:
|
||||
return _report_cache[key]
|
||||
manifest = load_manifest()
|
||||
out: Dict[str, List[Hit]] = {}
|
||||
for m in external:
|
||||
d = Path(str(m.path).partition(":")[0])
|
||||
d = d if d.is_dir() else d.parent
|
||||
hits = scan_plugin(d, manifest)
|
||||
if hits:
|
||||
out[m.name] = hits
|
||||
with _report_lock:
|
||||
_report_cache[key] = out
|
||||
_write_report_file(out)
|
||||
return out
|
||||
|
||||
|
||||
REPORT_FILE = ".plugin-compat-report.json"
|
||||
|
||||
|
||||
def report_file_path() -> Path:
|
||||
from hermes_constants import get_hermes_home
|
||||
return get_hermes_home() / REPORT_FILE
|
||||
|
||||
|
||||
def _write_report_file(report: Dict[str, List[Hit]]) -> None:
|
||||
"""Persist the latest report for surfaces without a Python runtime handy (the Desktop boot modal).
|
||||
|
||||
Written on every scan so a fixed plugin clears the notice on the next start; removed outright when
|
||||
there is nothing to report so a stale file can never resurface a resolved warning.
|
||||
"""
|
||||
try:
|
||||
p = report_file_path()
|
||||
if not report:
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
return
|
||||
payload = {"removal_date": COMPAT_REMOVAL, "in_effect": removal_in_effect(),
|
||||
"written_at": _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds"),
|
||||
"plugins": {k: [h.__dict__ for h in v] for k, v in report.items()},
|
||||
"lines": summary_lines(report)}
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = p.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(payload, indent=1), encoding="utf-8")
|
||||
os.replace(tmp, p)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def plugin_hits(manifest) -> List[Hit]:
|
||||
"""Hits for ONE manifest (used by the loader before importing it)."""
|
||||
if getattr(manifest, "source", "") == "bundled" or not getattr(manifest, "path", None):
|
||||
return []
|
||||
d = Path(str(manifest.path).partition(":")[0])
|
||||
return scan_plugin(d if d.is_dir() else d.parent)
|
||||
|
||||
|
||||
def allow_deprecated_imports(config: Optional[dict] = None) -> bool:
|
||||
"""``plugins.allow_deprecated_imports: true`` keeps hitting plugins loading after the date."""
|
||||
try:
|
||||
if config is None:
|
||||
from hermes_cli.config import load_config_readonly
|
||||
config = load_config_readonly()
|
||||
return bool(((config or {}).get("plugins") or {}).get(ALLOW_KEY, False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def disable_reason(manifest, *, today: Optional[_dt.date] = None) -> Optional[str]:
|
||||
"""Why the loader must skip this plugin now, or None. Only ever non-None after the removal date."""
|
||||
if not removal_in_effect(today) or allow_deprecated_imports():
|
||||
return None
|
||||
hits = plugin_hits(manifest)
|
||||
if not hits:
|
||||
return None
|
||||
return (f"uses {len(hits)} import path(s) removed on {COMPAT_REMOVAL}; run `hermes plugins compat` "
|
||||
f"for the list, update the plugin, or set plugins.{ALLOW_KEY}: true to force-load")
|
||||
|
||||
|
||||
def summary_lines(report: Dict[str, List[Hit]], *, today: Optional[_dt.date] = None) -> List[str]:
|
||||
"""Plain-text lines for banners/notices; empty when there is nothing to say."""
|
||||
if not report:
|
||||
return []
|
||||
n = len(report)
|
||||
names = ", ".join(f"{k} ({len(v)})" for k, v in sorted(report.items()))
|
||||
if removal_in_effect(today):
|
||||
head = (f"{n} plugin{'s' if n != 1 else ''} DISABLED: they import paths removed on {COMPAT_REMOVAL}: {names}")
|
||||
tail = f"Update the plugin(s) or set plugins.{ALLOW_KEY}: true to force-load. Details: hermes plugins compat"
|
||||
else:
|
||||
d = days_until_removal(today)
|
||||
head = (f"{n} plugin{'s' if n != 1 else ''} use{'s' if n == 1 else ''} import paths that stop working on "
|
||||
f"{COMPAT_REMOVAL} ({d} day{'s' if d != 1 else ''}): {names}")
|
||||
tail = "Check for plugin updates or notify the author before then. Details: hermes plugins compat"
|
||||
return [head, tail]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------- runtime warn
|
||||
|
||||
_seen: set = set()
|
||||
|
||||
|
||||
def warn_once(facade: str, name: str, target_module: str, target_name: str) -> None:
|
||||
@@ -32,7 +292,7 @@ def warn_once(facade: str, name: str, target_module: str, target_name: str) -> N
|
||||
new = f"{target_module}.{target_name}" if target_name != name else f"{target_module}.{name}"
|
||||
warnings.warn(
|
||||
f"hermes plugin compat: `{facade}.{name}` moved to `{new}`. The old path is kept only for external "
|
||||
f"plugins and is removed in {COMPAT_REMOVAL}; update your import.",
|
||||
f"plugins and is removed on {COMPAT_REMOVAL}; update your import.",
|
||||
HermesPluginCompatWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
|
||||
@@ -1997,6 +1997,42 @@ def _action_pack(args):
|
||||
pack_command(args)
|
||||
|
||||
|
||||
def cmd_compat(args: Any | None = None) -> None:
|
||||
"""``hermes plugins compat`` — which installed plugins import paths scheduled for removal, and where."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from hermes_cli.plugin_compat import (
|
||||
ALLOW_KEY, COMPAT_REMOVAL, compat_report, removal_in_effect, scan_plugin, summary_lines)
|
||||
console = _console()
|
||||
path = getattr(args, "path", None)
|
||||
if path:
|
||||
hits = scan_plugin(Path(path).expanduser().resolve())
|
||||
report = {Path(path).name: hits} if hits else {}
|
||||
else:
|
||||
report = compat_report(force=True)
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps({"removal_date": COMPAT_REMOVAL, "in_effect": removal_in_effect(),
|
||||
"plugins": {k: [h.__dict__ for h in v] for k, v in report.items()}}, indent=2))
|
||||
sys.exit(1 if report else 0)
|
||||
if not report:
|
||||
console.print(f"[green]✓ No enabled plugin imports paths scheduled for removal on {COMPAT_REMOVAL}.[/green]")
|
||||
return
|
||||
head, tail = summary_lines(report)
|
||||
console.print(f"[bold {'red' if removal_in_effect() else 'yellow'}]{head}[/]")
|
||||
console.print(f"[dim]{tail}[/dim]")
|
||||
for name, hits in sorted(report.items()):
|
||||
table = _table(((f"{name} ({len(hits)} import{'s' if len(hits) != 1 else ''})", "bold"), ("old path", "yellow"), ("new path", "green")),
|
||||
title=None, show_lines=False)
|
||||
for h in hits:
|
||||
table.add_row(f"{h.file}:{h.line}", h.old, h.new)
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
console.print(f"[dim]After {COMPAT_REMOVAL} these plugins are not loaded. Update them, or force-load with "
|
||||
f"plugins.{ALLOW_KEY}: true in config.yaml (the old paths still break once the compat layer is reverted).[/dim]")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Tri-state flags: neither --x nor --no-x given == None == interactive prompt.
|
||||
_PLUGIN_ACTIONS = {
|
||||
"install": lambda args: cmd_install(
|
||||
@@ -2021,6 +2057,7 @@ _PLUGIN_ACTIONS = {
|
||||
"list": lambda args: cmd_list(args),
|
||||
"ls": lambda args: cmd_list(args),
|
||||
"doctor": lambda args: cmd_plugin_doctor(args.target, ci=getattr(args, "ci", False)),
|
||||
"compat": lambda args: cmd_compat(args),
|
||||
"pack": _action_pack,
|
||||
"show": lambda args: cmd_show(args.name),
|
||||
"info": lambda args: cmd_show(args.name),
|
||||
|
||||
@@ -278,6 +278,15 @@ class PluginLoaderMixin:
|
||||
if manifest.portable:
|
||||
self._load_portable_plugin(manifest, loaded)
|
||||
return
|
||||
# After the compat-removal date an external plugin that still imports pre-decomposition paths is
|
||||
# skipped with a clear reason instead of dying on ImportError mid-register (hermes_cli.plugin_compat).
|
||||
from hermes_cli.plugin_compat import disable_reason
|
||||
reason = disable_reason(manifest)
|
||||
if reason:
|
||||
loaded.error = reason
|
||||
logger.warning("Plugin '%s' not loaded: %s", manifest.name, reason)
|
||||
self._plugins[plugin_key] = loaded
|
||||
return
|
||||
registration_start = len(self._registration_order)
|
||||
module_name = self._policy_module_name(manifest)
|
||||
self._track_tool_override_policy(manifest, module_name)
|
||||
|
||||
@@ -101,6 +101,17 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
|
||||
plugins_doctor.add_argument(
|
||||
"--ci", action="store_true", help="Exit non-zero when validation reports an error")
|
||||
|
||||
plugins_compat = plugins_subparsers.add_parser(
|
||||
"compat",
|
||||
help="Show installed plugins that import paths removed by the Sep 2026 decomposition",
|
||||
description="Statically scans every enabled external plugin for imports of pre-decomposition "
|
||||
"module paths (see COMPAT_MANIFEST.md) and prints file:line, old path -> new path. "
|
||||
"Exits 1 when any plugin is affected. Plugins still affected on the removal date are "
|
||||
"not loaded (override: plugins.allow_deprecated_imports: true).")
|
||||
plugins_compat.add_argument("--json", action="store_true", help="Machine-readable output")
|
||||
plugins_compat.add_argument(
|
||||
"path", nargs="?", help="Scan one plugin directory instead of the installed set (for plugin authors)")
|
||||
|
||||
plugins_pack = plugins_subparsers.add_parser(
|
||||
"pack", help="Declarative, shareable plugin sets (hermes-pack.yaml)",
|
||||
description="Install, export, or inspect plugin packs — a single YAML file "
|
||||
|
||||
@@ -936,6 +936,16 @@ def _refresh_cua_driver_after_update() -> None:
|
||||
install_cua_driver(upgrade=True, require_confirmed_update=True, show_installer_progress=False)
|
||||
|
||||
|
||||
def _print_plugin_compat_notice() -> None:
|
||||
"""Installed plugins importing paths that the Sep 2026 decomposition scheduled for removal."""
|
||||
from hermes_cli.plugin_compat import compat_report, removal_in_effect, summary_lines
|
||||
lines = summary_lines(compat_report(force=True))
|
||||
if not lines:
|
||||
return
|
||||
colour = "\033[1;31m" if removal_in_effect() else "\033[1;33m"
|
||||
print(f"\n{colour}⚠ {lines[0]}\033[0m\n {lines[1]}")
|
||||
|
||||
|
||||
def _print_post_update_notices_and_self_heals() -> None:
|
||||
"""Best-effort notices (FTS optimize, curator) and self-heals (FHS PATH, ACP launcher,
|
||||
Windows bin launchers, cua-driver refresh) that run after the summary."""
|
||||
@@ -956,6 +966,7 @@ def _print_post_update_notices_and_self_heals() -> None:
|
||||
('hermes-acp launcher self-heal failed: %s', _ensure_acp_launcher),
|
||||
('Windows bin launcher migration failed: %s', _migrate_windows_bin_path),
|
||||
('cua-driver refresh failed: %s', _refresh_cua_driver_after_update),
|
||||
('Plugin compat notice failed: %s', _print_plugin_compat_notice),
|
||||
):
|
||||
with _best_effort(message):
|
||||
step()
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""hermes_cli.plugin_compat: detect plugins on old import paths, tell the user, disable after the date.
|
||||
|
||||
Kept with the compat layer (tests/test_compat_manifest_targets.py); deleted with it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import plugin_compat as pc
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
pytestmark = pytest.mark.skipif(not (ROOT / "compat_manifest.json").exists(), reason="compat layer removed")
|
||||
|
||||
MANIFEST = {"tools.web_tools": {"prefers_gateway": "tools.tool_backend_helpers.prefers_gateway"},
|
||||
"hermes_cli.kanban_db": {"connect": "hermes_cli.kanban_db_connect.connect"}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("src, expect", [
|
||||
("from tools.web_tools import prefers_gateway\n", ["tools.web_tools.prefers_gateway"]),
|
||||
("import tools.web_tools\nx = tools.web_tools.prefers_gateway()\n", ["tools.web_tools.prefers_gateway"]),
|
||||
("import tools.web_tools as wt\nwt.prefers_gateway()\n", ["tools.web_tools.prefers_gateway"]),
|
||||
("from unittest.mock import patch\npatch('hermes_cli.kanban_db.connect')\n", ["hermes_cli.kanban_db.connect"]),
|
||||
("from tools.web_tools import web_search\n", []), # live name: not a hit
|
||||
("from tools.tool_backend_helpers import prefers_gateway\n", []), # already migrated
|
||||
])
|
||||
def test_scan_source_finds_every_import_form(src, expect):
|
||||
hits = pc.scan_source(src, "p.py", MANIFEST)
|
||||
assert [h.old for h in hits] == expect
|
||||
for h in hits:
|
||||
assert h.new == MANIFEST[h.old.rsplit(".", 1)[0]][h.old.rsplit(".", 1)[1]]
|
||||
|
||||
|
||||
def test_scan_plugin_walks_dir_and_skips_tests(tmp_path):
|
||||
(tmp_path / "__init__.py").write_text("from tools.web_tools import prefers_gateway\n")
|
||||
(tmp_path / "sub").mkdir(); (tmp_path / "sub" / "m.py").write_text("import hermes_cli.kanban_db as k\nk.connect()\n")
|
||||
(tmp_path / "tests").mkdir(); (tmp_path / "tests" / "t.py").write_text("from tools.web_tools import prefers_gateway\n")
|
||||
hits = pc.scan_plugin(tmp_path, MANIFEST)
|
||||
assert sorted(h.file for h in hits) == ["__init__.py", "sub/m.py"]
|
||||
|
||||
|
||||
def _manifest(name, path, source="user"):
|
||||
return SimpleNamespace(name=name, path=str(path), source=source)
|
||||
|
||||
|
||||
def test_compat_report_only_external_plugins_with_hits(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(pc, "load_manifest", lambda: MANIFEST)
|
||||
monkeypatch.setattr(pc, "_write_report_file", lambda r: None)
|
||||
good = tmp_path / "good"; good.mkdir(); (good / "__init__.py").write_text("x = 1\n")
|
||||
bad = tmp_path / "bad"; bad.mkdir(); (bad / "__init__.py").write_text("from tools.web_tools import prefers_gateway\n")
|
||||
bundled = tmp_path / "bundled"; bundled.mkdir(); (bundled / "__init__.py").write_text("from tools.web_tools import prefers_gateway\n")
|
||||
report = pc.compat_report([_manifest("good", good), _manifest("bad", bad), _manifest("ours", bundled, "bundled")], force=True)
|
||||
assert list(report) == ["bad"] and report["bad"][0].old == "tools.web_tools.prefers_gateway"
|
||||
|
||||
|
||||
def test_disable_only_after_the_date_and_not_when_allowed(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(pc, "load_manifest", lambda: MANIFEST)
|
||||
bad = tmp_path / "bad"; bad.mkdir(); (bad / "__init__.py").write_text("from tools.web_tools import prefers_gateway\n")
|
||||
m = _manifest("bad", bad)
|
||||
before, after = pc.COMPAT_REMOVAL_DATE - dt.timedelta(days=1), pc.COMPAT_REMOVAL_DATE
|
||||
monkeypatch.setattr(pc, "allow_deprecated_imports", lambda config=None: False)
|
||||
assert pc.disable_reason(m, today=before) is None
|
||||
reason = pc.disable_reason(m, today=after)
|
||||
assert reason and pc.COMPAT_REMOVAL in reason and "hermes plugins compat" in reason
|
||||
monkeypatch.setattr(pc, "allow_deprecated_imports", lambda config=None: True)
|
||||
assert pc.disable_reason(m, today=after) is None
|
||||
good = tmp_path / "good"; good.mkdir(); (good / "__init__.py").write_text("x=1\n")
|
||||
monkeypatch.setattr(pc, "allow_deprecated_imports", lambda config=None: False)
|
||||
assert pc.disable_reason(_manifest("good", good), today=after) is None
|
||||
|
||||
|
||||
def test_summary_lines_name_plugins_and_the_date():
|
||||
report = {"alpha": [pc.Hit("a.py", 1, "x.y", "z.y")], "beta": [pc.Hit("b.py", 2, "x.y", "z.y"), pc.Hit("b.py", 3, "x.q", "z.q")]}
|
||||
before = pc.COMPAT_REMOVAL_DATE - dt.timedelta(days=3)
|
||||
head, tail = pc.summary_lines(report, today=before)
|
||||
assert "2 plugins" in head and "alpha (1)" in head and "beta (2)" in head and pc.COMPAT_REMOVAL in head and "3 days" in head
|
||||
assert "hermes plugins compat" in tail
|
||||
head_after, _ = pc.summary_lines(report, today=pc.COMPAT_REMOVAL_DATE)
|
||||
assert "DISABLED" in head_after
|
||||
assert pc.summary_lines({}) == []
|
||||
|
||||
|
||||
def test_report_file_written_and_removed(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(pc, "report_file_path", lambda: tmp_path / "r.json")
|
||||
pc._write_report_file({"p": [pc.Hit("a.py", 1, "x.y", "z.y")]})
|
||||
data = json.loads((tmp_path / "r.json").read_text())
|
||||
assert data["plugins"]["p"][0]["old"] == "x.y" and data["removal_date"] == pc.COMPAT_REMOVAL and len(data["lines"]) == 2
|
||||
pc._write_report_file({})
|
||||
assert not (tmp_path / "r.json").exists()
|
||||
|
||||
|
||||
def test_loader_skips_hitting_plugin_after_date(tmp_path, monkeypatch):
|
||||
"""PluginManager records the reason and never imports the plugin."""
|
||||
from hermes_cli.plugins import PluginManager
|
||||
monkeypatch.setattr(pc, "load_manifest", lambda: MANIFEST)
|
||||
monkeypatch.setattr(pc, "removal_in_effect", lambda today=None: True)
|
||||
monkeypatch.setattr(pc, "allow_deprecated_imports", lambda config=None: False)
|
||||
plugin = tmp_path / "plugins" / "oldpaths"; plugin.mkdir(parents=True)
|
||||
(plugin / "plugin.yaml").write_text("name: oldpaths\nversion: 0.1\ndescription: t\n")
|
||||
(plugin / "__init__.py").write_text(textwrap.dedent("""
|
||||
from tools.web_tools import prefers_gateway
|
||||
LOADED = True
|
||||
def register(ctx):
|
||||
raise AssertionError("must not be imported/registered")
|
||||
"""))
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
mgr = PluginManager(scope_key=str(tmp_path))
|
||||
from hermes_cli.plugins_manifest import PluginManifest
|
||||
real = PluginManifest(name="oldpaths", version="0.1", description="t", source="user", path=str(plugin))
|
||||
mgr._load_plugin(real)
|
||||
loaded = next(lp for lp in mgr._plugins.values() if lp.manifest.name == "oldpaths")
|
||||
assert not loaded.enabled and loaded.error and pc.COMPAT_REMOVAL in loaded.error
|
||||
@@ -32,7 +32,7 @@ def test_compat_resolution_warns_once_per_name_with_the_new_location():
|
||||
ours = [w for w in rec if issubclass(w.category, HermesPluginCompatWarning)]
|
||||
assert len(ours) == 1, [str(w.message) for w in rec]
|
||||
msg = str(ours[0].message)
|
||||
assert f"`{facade}.{name}` moved to `" in msg and "removed in" in msg
|
||||
assert f"`{facade}.{name}` moved to `" in msg and "removed on 2026-09-14" in msg
|
||||
|
||||
|
||||
def test_importing_the_facade_itself_does_not_warn():
|
||||
|
||||
@@ -162,6 +162,21 @@ from an isolated `HERMES_HOME`. Those tests load and invoke the plugin through
|
||||
`PluginManager`; they assert real registration and callback outcomes rather
|
||||
than internal symbol lists or source-code shape.
|
||||
|
||||
### Sep 2026 module decomposition: old import paths end 2026-09-14
|
||||
|
||||
Hermes's internals were split into `<stem>_<topic>` sibling modules in Sep 2026 (PR #102117). **Internal
|
||||
import paths were never part of the plugin contract** above, but many plugins used them. Every moved name
|
||||
still resolves from its old module until **2026-09-14**, then the compatibility layer is removed.
|
||||
|
||||
- **Check your plugin:** `hermes plugins compat /path/to/your/plugin` lists every `file:line` with the
|
||||
old path and the new one, and exits 1 while any remain. `COMPAT_MANIFEST.md` in the repo is the full map.
|
||||
- **What users see:** a notice under the CLI banner, in `hermes doctor` and after `hermes update`, and a
|
||||
one-time Desktop dialog naming the plugin. Each resolution through an old path also emits a
|
||||
`HermesPluginCompatWarning` once per process.
|
||||
- **From 2026-09-14:** plugins that still import old paths are **not loaded** (the reason shows in
|
||||
`hermes plugins list`). Users can force-load with `plugins.allow_deprecated_imports: true` until the
|
||||
layer is actually removed, at which point the old paths raise `ImportError`.
|
||||
|
||||
## What you're building
|
||||
|
||||
A **calculator** plugin with two tools:
|
||||
|
||||
Reference in New Issue
Block a user