Retain shipped plugin adapters and extend the combined compatibility surface

This commit is contained in:
ethernet
2026-09-12 19:17:02 -04:00
parent 9e65206fe2
commit 2875e397b1
4 changed files with 225 additions and 6 deletions

View File

@@ -9,6 +9,28 @@ class AdmissionRefused(RuntimeError):
"""The candidate set was refused; config and environment untouched."""
def candidate_member_dirs(
candidate_enabled: Iterable[str],
candidate_disabled: Iterable[str] = (),
*,
active_plugins_dir: Optional[Path] = None,
extra_dirs: Iterable[Path] = (),
) -> list[Path]:
"""Shipped callers' member-list adapter; new discovery belongs to PM.
Without an active plugins dir, preserve every home's recorded selection.
"""
from pm.publication import candidate_members
active = Path(active_plugins_dir) if active_plugins_dir else None
return candidate_members(
extra_dirs,
proposed_home=active.parent if active else None,
enabled=candidate_enabled,
disabled=candidate_disabled,
)
def admit_plugin_set_change(
candidate_enabled: set,
candidate_disabled: set,

View File

@@ -5,6 +5,13 @@ from pathlib import Path
import shutil
def recover_plugin_publication(project: Path, row: dict, journal: Path) -> None:
"""Recover a shipped caller's row through the stdlib boot-journal owner."""
from hermes_cli.runtime_state import _recover_plugin_publication
_recover_plugin_publication(project, row, journal)
def publish_plugin(staged: Path, target: Path, old_metadata: dict, new_metadata: dict,
*, target_digest: str | None = None) -> None:
from pm.client import sync_venv

View File

@@ -640,7 +640,7 @@
"hermes_cli/auth.py",
"hermes_cli/backup.py",
"hermes_cli/backup_restore.py",
"hermes_cli/banner.py",
"hermes_cli/backup_sqlite.py",
"hermes_cli/cli_output.py",
"hermes_cli/colors.py",
"hermes_cli/config.py",
@@ -695,6 +695,7 @@
"hermes_cli/service_manager.py",
"hermes_cli/setup.py",
"hermes_cli/sizefmt.py",
"hermes_cli/source_check.py",
"hermes_cli/source_releases.py",
"hermes_cli/sqlite_runtime.py",
"hermes_cli/sqlite_safe_read.py",
@@ -752,6 +753,7 @@
"pm/packages.py",
"pm/paths.py",
"pm/plugins_state.py",
"pm/publication.py",
"pm/receipt.py",
"pm/recovery.py",
"pm/registry.py",
@@ -819,7 +821,7 @@
"hermes_cli/auth.py",
"hermes_cli/backup.py",
"hermes_cli/backup_restore.py",
"hermes_cli/banner.py",
"hermes_cli/backup_sqlite.py",
"hermes_cli/cli_output.py",
"hermes_cli/colors.py",
"hermes_cli/config.py",
@@ -873,6 +875,7 @@
"hermes_cli/service_manager.py",
"hermes_cli/setup.py",
"hermes_cli/sizefmt.py",
"hermes_cli/source_check.py",
"hermes_cli/source_releases.py",
"hermes_cli/sqlite_runtime.py",
"hermes_cli/sqlite_safe_read.py",
@@ -925,6 +928,7 @@
"pm/packages.py",
"pm/paths.py",
"pm/plugins_state.py",
"pm/publication.py",
"pm/receipt.py",
"pm/recovery.py",
"pm/registry.py",
@@ -949,10 +953,10 @@
"utils.py"
],
"commits_with_audited_changes": 1,
"revisions_read": 173,
"distinct_file_versions": 173,
"analysis_passes": 215,
"versions_prepared": 173,
"revisions_read": 175,
"distinct_file_versions": 175,
"analysis_passes": 218,
"versions_prepared": 175,
"mode": "tree"
},
"completion_union": "Preserved complete checked-in history and prior tree edges; unioned fresh current-tree audit."
@@ -1034,6 +1038,7 @@
"hermes_cli._launchers::ENTRY_POINTS",
"hermes_cli._launchers::ensure_install_launchers",
"hermes_cli._launchers::exe_is_venv_bound",
"hermes_cli._launchers::expose_cli",
"hermes_cli._launchers::installation_command",
"hermes_cli._launchers::mint_launcher",
"hermes_cli._launchers::resolve_store_python",
@@ -1044,6 +1049,7 @@
"hermes_cli._scan_venv_blockers::_is_pausable_gateway",
"hermes_cli._subprocess_compat::bounded_probe_run",
"hermes_cli._subprocess_compat::kill_process_tree",
"hermes_cli._subprocess_compat::noninteractive_git_env",
"hermes_cli._subprocess_compat::pid_is_hermes",
"hermes_cli._subprocess_compat::run",
"hermes_cli._subprocess_compat::split_command_line",
@@ -1265,11 +1271,14 @@
"hermes_cli.runtime_state::_digest",
"hermes_cli.runtime_state::_lock",
"hermes_cli.runtime_state::collect_generations",
"hermes_cli.runtime_state::finish_publication",
"hermes_cli.runtime_state::recover_publication",
"hermes_cli.runtime_state::runtime_lock",
"hermes_cli.service_manager::detect_service_manager",
"hermes_cli.setup::prompt_yes_no",
"hermes_cli.sizefmt::format_bytes",
"hermes_cli.source_check::_github_compare_behind",
"hermes_cli.source_check::source_git_env",
"hermes_cli.source_releases::resolve_source_release",
"hermes_cli.source_releases::source_repository",
"hermes_cli.sqlite_runtime::probe_sqlite_runtime",
@@ -1474,6 +1483,9 @@
"pm.paths::writable_store_root",
"pm.plugins_state::_is_directory",
"pm.plugins_state::enabled_plugins_ordered",
"pm.publication::PluginSelection",
"pm.publication::StagedPlugin",
"pm.publication::candidate_members",
"pm.recovery::validate_environment",
"pm.registry::all_packages",
"pm.registry::get_package",

View File

@@ -0,0 +1,178 @@
"""Shipped plugin callers keep their contract without owning PM publication."""
from __future__ import annotations
import base64
import json
import os
from pathlib import Path
import subprocess
import sys
import pytest
@pytest.fixture
def isolated_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
return home
def _plugin(home, name, *, dependencies=True):
path = home / "plugins" / name
path.mkdir(parents=True)
(path / "plugin.yaml").write_text(f"name: {name}\n", encoding="utf-8")
if dependencies:
(path / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
return path
@pytest.mark.parametrize("active", [None, "default", "profile"])
def test_candidate_member_dirs_preserves_proposed_home_order_and_extras(isolated_home, monkeypatch, active):
from hermes_cli import plugins_admission
assert callable(getattr(plugins_admission, "candidate_member_dirs", None))
home = isolated_home
profile = home / "profiles" / "coder"
profile.mkdir(parents=True)
for directory in (home, profile):
(directory / "config.yaml").write_text("plugins:\n enabled: [old]\n", encoding="utf-8")
_plugin(directory, "old")
_plugin(directory, "new")
_plugin(directory, "blocked")
_plugin(directory, "plain", dependencies=False)
extra = _plugin(home, "extra")
selected_home = profile if active == "profile" else home
expected = [home / "plugins" / "old", profile / "plugins" / "old"]
if active is not None:
expected[active == "profile"] = selected_home / "plugins" / "new"
extra_existing = expected[0]
before = {p: p.read_bytes() for p in home.rglob("*") if p.is_file()}
# Discovery must use PM's independent manifest reader, not application config/UI.
monkeypatch.setitem(sys.modules, "hermes_cli.config", None)
monkeypatch.setitem(sys.modules, "hermes_cli.plugins_cmd", None)
result = plugins_admission.candidate_member_dirs(
iter(["new", "blocked", "plain", "new"]), iter(["blocked"]),
active_plugins_dir=str(selected_home / "plugins") if active else None,
extra_dirs=iter([str(extra_existing), str(extra), str(extra), home / "plugins" / "plain", home / "missing"]),
)
assert isinstance(result, list)
assert result == [*expected, extra]
assert {p: p.read_bytes() for p in home.rglob("*") if p.is_file()} == before
def test_candidate_member_dirs_historical_defaults_do_not_replace_active_selection(isolated_home):
from hermes_cli import plugins_admission
assert callable(getattr(plugins_admission, "candidate_member_dirs", None))
old = _plugin(isolated_home, "old")
_plugin(isolated_home, "new")
(isolated_home / "config.yaml").write_text("plugins:\n enabled: [old]\n", encoding="utf-8")
assert plugins_admission.candidate_member_dirs(["new"]) == [old]
@pytest.fixture
def publication(isolated_home, tmp_path):
from hermes_cli.runtime_paths import install_state_dir
project = tmp_path / "checkout"
project.mkdir()
target = _plugin(isolated_home, "published")
(target / "version").write_bytes(b"new code")
backup = _plugin(isolated_home, ".previous-historic")
(backup / "version").write_bytes(b"old code")
metadata = target.parent / ".install-metadata.json"
metadata.write_bytes(b'{"published": "new"}\n')
# Old callers pass the already-decoded row and exact journal path. The
# adapter must not rediscover/reparse a different current journal.
journal = tmp_path / "historic-publication.json"
journal.write_bytes(b"already decoded by the old caller")
canonical = install_state_dir(project) / "publication.json"
canonical.parent.mkdir(parents=True)
canonical.write_bytes(b"unrelated publication; do not read or remove")
row = {
"kind": "plugin", "target": str(target), "backup": str(backup),
"metadata": str(metadata), "target_existed": True, "facts_before": None,
"metadata_before": base64.b64encode(b'{"published": "old"}\n').decode(),
"metadata_after": base64.b64encode(metadata.read_bytes()).decode(),
}
return project, row, journal, canonical
def _recover_in_stdlib(publication):
project, row, journal, _ = publication
script = """
import json
from pathlib import Path
import sys
sys.path.insert(0, sys.argv[1])
# No selected application environment, no PM, and no CLI config/UI reader.
for module in ('pm', 'hermes_cli.config', 'hermes_cli.plugins_cmd'):
sys.modules[module] = None
from hermes_cli import plugins_transaction
assert callable(getattr(plugins_transaction, 'recover_plugin_publication', None))
row = json.loads(sys.stdin.read())
plugins_transaction.recover_plugin_publication(
project=Path(sys.argv[2]), row=row, journal=Path(sys.argv[3]),
)
"""
return subprocess.run(
[sys.executable, "-I", "-S", "-c", script, str(Path(__file__).resolve().parents[1]), str(project), str(journal)],
input=json.dumps(row), text=True, capture_output=True, check=False,
env={**os.environ, "HERMES_HOME": str(Path(row["metadata"]).parent.parent)},
)
@pytest.mark.parametrize("commit", ["rollback", "explicit", "facts-changed"])
def test_old_publication_recovers_in_stdlib_using_supplied_row_and_journal(publication, commit):
from hermes_cli.runtime_paths import runtime_facts_path
project, row, journal, canonical = publication
target, backup, metadata = (Path(row[key]) for key in ("target", "backup", "metadata"))
if commit == "explicit":
row["committed"] = True
elif commit == "facts-changed":
runtime_facts_path(project).write_bytes(b"new facts")
result = _recover_in_stdlib(publication)
assert result.returncode == 0, result.stderr
assert (target / "version").read_bytes() == (b"old code" if commit == "rollback" else b"new code")
expected = row["metadata_before"] if commit == "rollback" else row["metadata_after"]
assert metadata.read_bytes() == base64.b64decode(expected)
assert not backup.exists()
assert not journal.exists()
assert canonical.read_bytes() == b"unrelated publication; do not read or remove"
def test_old_publication_rolls_back_a_first_install(publication):
from hermes_cli import plugins_transaction
import shutil
assert callable(getattr(plugins_transaction, "recover_plugin_publication", None))
project, row, journal, _ = publication
shutil.rmtree(row["backup"])
row.update(target_existed=False, metadata_before=None)
plugins_transaction.recover_plugin_publication(project, row, journal)
assert not Path(row["target"]).exists()
assert not Path(row["metadata"]).exists()
assert not journal.exists()
@pytest.mark.parametrize("invalid", ["target", "backup", "metadata", "edited-metadata"])
def test_old_publication_refuses_unsafe_or_changed_state_without_writes(publication, tmp_path, invalid):
from hermes_cli import plugins_transaction
assert callable(getattr(plugins_transaction, "recover_plugin_publication", None))
project, row, journal, _ = publication
if invalid == "edited-metadata":
Path(row["metadata"]).write_bytes(b"independent user edit")
else:
outside = tmp_path / "outside" / "plugins"
outside.mkdir(parents=True)
row[invalid] = str(outside / Path(row[invalid]).name)
before = {p: p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()}
with pytest.raises(ValueError, match="paths escape|metadata changed"):
plugins_transaction.recover_plugin_publication(project, row, journal)
assert {p: p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} == before
assert journal.exists()