Retain shipped plugin adapters and extend the combined compatibility surface
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
178
tests/test_plugin_historic_compat.py
Normal file
178
tests/test_plugin_historic_compat.py
Normal 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()
|
||||
Reference in New Issue
Block a user