Reconcile plugin declarations and validation through PM's atomic generation publication; preserve external runtimes, target markers, and conflict refusal. Keep one source-update completion owner and port upstream lifecycle changes to the PM desktop/runtime paths.
101 lines
4.5 KiB
Python
101 lines
4.5 KiB
Python
"""Plugin declaration safeguards from #113851, through PM's real resolver."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tomllib
|
|
|
|
import pytest
|
|
|
|
from pm.environment import PythonEnvironment
|
|
from pm.plugin_declarations import read_python_declaration, unsupported_requirements
|
|
from pm.workspace import enabled_member_dirs, lock_and_sync
|
|
from tests.pm import _fixtures
|
|
|
|
|
|
@pytest.mark.parametrize("modern", [False, True])
|
|
def test_declaration_policy_survives_cross_profile_resolution(tmp_path, monkeypatch, modern):
|
|
home = tmp_path / "custom-home"
|
|
sibling = home / "profiles" / "work"
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(sibling))
|
|
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "runtime"))
|
|
core = tmp_path / "core"
|
|
core.mkdir()
|
|
wheels = tmp_path / "wheels"
|
|
wheels.mkdir()
|
|
_fixtures._wheel(wheels, "fixturedep", "1.0")
|
|
(core / "pyproject.toml").write_text(
|
|
'[project]\nname="core"\nversion="1"\nrequires-python=">=3.11"\n'
|
|
'[tool.uv]\npackage=false\nno-index=true\n'
|
|
f'find-links=[{json.dumps(wheels.as_posix())}]\n', encoding="utf-8",
|
|
)
|
|
plugin = home / "plugins" / "member"
|
|
external = sibling / "plugins" / "sidecar"
|
|
plugin.mkdir(parents=True)
|
|
external.mkdir(parents=True)
|
|
(home / "config.yaml").write_text("plugins:\n enabled: [member]\n", encoding="utf-8")
|
|
(sibling / "config.yaml").write_text("memory:\n provider: sidecar\n", encoding="utf-8")
|
|
(external / "plugin.yaml").write_text(
|
|
'name: sidecar\npython_runtime: external\npython_dependencies: ["impossible==99"]\n',
|
|
encoding="utf-8",
|
|
)
|
|
(external / "pyproject.toml").write_text(
|
|
'[project]\nname="sidecar"\nversion="1"\ndependencies=["impossible==99"]\n',
|
|
encoding="utf-8",
|
|
)
|
|
specs = ['fixturedep>=1,<2', 'hermes-agent>=0.1,<1',
|
|
'remote @ https://example.invalid/unreviewed.whl',
|
|
'missing-other-python; python_version < "3.0"']
|
|
(plugin / "plugin.yaml").write_text(
|
|
'name: member\npython_dependencies: ' + json.dumps(["ignored==99"] if modern else specs) + '\n',
|
|
encoding="utf-8",
|
|
)
|
|
if modern:
|
|
(plugin / "pyproject.toml").write_text(
|
|
'[project]\nname="member"\nversion="1"\nrequires-python=">=3.11"\n'
|
|
f'dependencies={json.dumps(specs)}\n[tool.uv]\npackage=false\n', encoding="utf-8",
|
|
)
|
|
before = {p: p.read_bytes() for directory in (plugin, external) for p in directory.iterdir()}
|
|
assert enabled_member_dirs() == [plugin]
|
|
assert read_python_declaration(external).external
|
|
declaration = read_python_declaration(plugin)
|
|
assert declaration.requirements == tuple(specs)
|
|
assert unsupported_requirements(declaration.requirements) == (specs[2],)
|
|
uv = shutil.which("uv")
|
|
assert uv is not None, "real resolver is required"
|
|
environment = PythonEnvironment(
|
|
uv=Path(uv), python=Path(sys.executable), destination=tmp_path / "env",
|
|
cache=tmp_path / "cache", env=dict(os.environ), offline=True,
|
|
)
|
|
root = tmp_path / "snapshot"
|
|
lock_and_sync(enabled_member_dirs(), [], root=root, source=core, seed_lock=None,
|
|
environment=environment)
|
|
probe = subprocess.run(
|
|
[str(environment.executable), "-I", "-c", "import fixturedep; print(fixturedep.__version__)"],
|
|
check=True, capture_output=True, text=True, timeout=30,
|
|
)
|
|
assert probe.stdout.strip() == "1.0"
|
|
[relative] = tomllib.loads((root / "pyproject.toml").read_text())["tool"]["uv"]["workspace"]["members"]
|
|
generated = tomllib.loads((root / relative / "pyproject.toml").read_text())["project"]["dependencies"]
|
|
assert generated == [specs[0], specs[3]], "target markers must reach uv, unsafe/self requirements must not"
|
|
assert all(p.read_bytes() == body for p, body in before.items())
|
|
|
|
|
|
@pytest.mark.parametrize("modern", [False, True])
|
|
def test_invalid_requirement_refused_at_declaration_boundary(tmp_path, modern):
|
|
if modern:
|
|
path = tmp_path / "pyproject.toml"
|
|
path.write_text('[project]\ndependencies=["not a requirement >="]\n', encoding="utf-8")
|
|
else:
|
|
path = tmp_path / "plugin.yaml"
|
|
path.write_text('python_dependencies: ["not a requirement >="]\n', encoding="utf-8")
|
|
before = path.read_bytes()
|
|
with pytest.raises(ValueError):
|
|
read_python_declaration(tmp_path)
|
|
assert path.read_bytes() == before
|