A process that could not adopt a newly published dependency generation keeps importing the old one. restart_needed() names that case (and stays silent for dev venvs, Nix and anything not booted from a PM generation, so no false restart prompts); adopt_selected() lets callers move onto the selection before loading new code. The test helpers publish real generations and make the test process run from one. (cherry picked from commit 978abe8ec4b852778b8c63bcefdf141db51bc703) (cherry picked from commit 28be27334adc531db59bf37a02a64acaaf47a2ee)
197 lines
9.5 KiB
Python
197 lines
9.5 KiB
Python
"""Offline plugin command fixtures; only tool acquisition is substituted.
|
|
|
|
The actual client, worker, resolver, lock, publication callbacks and receipts
|
|
run unchanged. The prepared test interpreter hosts PM in a separate process;
|
|
fresh selected interpreters never inherit its site-packages.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import argparse
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
|
|
import pytest
|
|
import hermes_yaml as yaml
|
|
|
|
from tests.pm._fixtures import _wheel, isolated_python as isolated_python
|
|
|
|
|
|
def worker_command(worker: Path, uv: str, python: str, *, prelude: str = "", runtime_python: str | None = None) -> list[str]:
|
|
script = (
|
|
"import runpy, sys; from pathlib import Path; "
|
|
f"sys.path.insert(0, {str(worker.parent.parent)!r}); import pm._uv; "
|
|
f"pm._uv._toolchain = lambda **kwargs: (Path({uv!r}), Path({python!r}))\n"
|
|
+ prelude + "\n"
|
|
f"runpy.run_path({str(worker)!r}, run_name='__main__')"
|
|
)
|
|
return [runtime_python or python, "-I", "-B", "-c", script]
|
|
|
|
|
|
def git(repo: Path, *args: str) -> str:
|
|
env = {k: v for k, v in os.environ.items() if k not in {"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"}}
|
|
env.update(GIT_AUTHOR_NAME="fixture", GIT_AUTHOR_EMAIL="fixture@example.invalid",
|
|
GIT_COMMITTER_NAME="fixture", GIT_COMMITTER_EMAIL="fixture@example.invalid")
|
|
result = subprocess.run(["git", *args], cwd=repo, env=env, capture_output=True, text=True, timeout=30)
|
|
assert result.returncode == 0, result.stderr
|
|
return result.stdout.strip()
|
|
|
|
|
|
def version(repo: Path, *, name="plugin-worker-proof", surface="python_dependencies", suffix="yaml", pin="1.0", capabilities=(), dependency="plugin-proof-dep") -> str:
|
|
manifest: dict = {"name": name, "version": pin}
|
|
if capabilities:
|
|
manifest["capabilities"] = list(capabilities)
|
|
specs = [f"{dependency}=={pin}"]
|
|
if surface and surface != "pyproject":
|
|
manifest[surface] = specs
|
|
(repo / f"plugin.{suffix}").write_text(yaml.safe_dump(manifest), encoding="utf-8")
|
|
(repo / "__init__.py").write_text(
|
|
f"import {dependency.replace('-', '_')} as plugin_proof_dep\nVERSION = {pin!r}\n"
|
|
"def register(ctx):\n return (VERSION, plugin_proof_dep.__version__)\n", encoding="utf-8")
|
|
if surface == "pyproject":
|
|
(repo / "pyproject.toml").write_text(
|
|
f'[project]\nname={json.dumps(name)}\nversion="{pin}"\nrequires-python=">=3.11"\n'
|
|
f'dependencies={json.dumps(specs)}\n[tool.uv]\npackage=false\n', encoding="utf-8")
|
|
git(repo, "add", ".")
|
|
git(repo, "commit", "-qm", f"fixture {pin}")
|
|
return git(repo, "rev-parse", "HEAD")
|
|
|
|
|
|
class PluginWorld:
|
|
runtime_python: str
|
|
|
|
def __init__(self, root: Path):
|
|
self.root = root
|
|
self.home = root / "home"
|
|
self.core = root / "core"
|
|
|
|
def origin(self, **kwargs) -> tuple[Path, str]:
|
|
repo = self.root / (kwargs.get("name", "plugin-worker-proof") + "-origin")
|
|
repo.mkdir()
|
|
git(repo, "init", "-qb", "main")
|
|
return repo, version(repo, **kwargs)
|
|
|
|
def command(self, action: str, **kwargs) -> None:
|
|
from hermes_cli.plugins_cmd import plugins_command
|
|
from hermes_cli.subcommands.plugins import build_plugins_parser
|
|
|
|
parser = argparse.ArgumentParser()
|
|
build_plugins_parser(parser.add_subparsers(), cmd_plugins=plugins_command)
|
|
positional = {"install": ("identifier",), "enable": ("name",), "disable": ("name",),
|
|
"pack": ("pack_action", "source")}[action]
|
|
argv = ["plugins", action, *(str(kwargs.pop(key)) for key in positional)]
|
|
for key, value in kwargs.items():
|
|
if value is True:
|
|
argv.append("--" + key.replace("_", "-"))
|
|
elif value is not None and value is not False:
|
|
argv.extend(["--" + key.replace("_", "-"), str(value)])
|
|
plugins_command(parser.parse_args(argv))
|
|
|
|
def selected(self) -> Path:
|
|
from pm.environments import selected_venv
|
|
return selected_venv(self.core)
|
|
|
|
def enabled(self) -> list[str]:
|
|
return yaml.safe_load((self.home / "config.yaml").read_text(encoding="utf-8"))["plugins"]["enabled"]
|
|
|
|
def imports(self, name="plugin-worker-proof", pin="1.0") -> None:
|
|
selected = self.selected()
|
|
python = selected / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
|
code = (
|
|
"import importlib.util, pathlib; import plugin_core_dep, plugin_proof_dep; "
|
|
f"assert pathlib.Path(plugin_core_dep.__file__).is_relative_to({str(selected)!r}); "
|
|
f"assert pathlib.Path(plugin_proof_dep.__file__).is_relative_to({str(selected)!r}); "
|
|
f"spec = importlib.util.spec_from_file_location('loaded_plugin', {str(self.home / 'plugins' / name / '__init__.py')!r}); "
|
|
"plugin = importlib.util.module_from_spec(spec); spec.loader.exec_module(plugin); "
|
|
f"assert plugin.register(None) == ({pin!r}, {pin!r})"
|
|
)
|
|
result = subprocess.run([str(python), "-I", "-B", "-c", code], cwd=self.root,
|
|
capture_output=True, text=True, timeout=30)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def publish_plugins(world: PluginWorld, plugins: dict[str, list[str]]) -> Path:
|
|
"""Enable exactly ``plugins`` (name → declared requirements) and publish the generation PM
|
|
builds for them. Each plugin imports its requirements, leaves an ``imported`` marker, and
|
|
``request(specs)`` asks ``install_specs`` for more on its own behalf."""
|
|
from pm import client
|
|
|
|
for name, requirements in plugins.items():
|
|
plugin = world.home / "plugins" / name
|
|
if plugin.is_dir():
|
|
continue
|
|
plugin.mkdir(parents=True)
|
|
(plugin / "plugin.yaml").write_text(yaml.safe_dump(
|
|
{"name": name, "version": "1.0", "python_dependencies": requirements}), encoding="utf-8")
|
|
modules = [spec.split("=")[0].split("<")[0].split(">")[0].replace("-", "_") for spec in requirements]
|
|
(plugin / "__init__.py").write_text(
|
|
"".join(f"import {module}\n" for module in modules)
|
|
+ "from pathlib import Path\nPath(__file__).with_name('imported').touch()\n"
|
|
"def register(ctx):\n pass\n"
|
|
"def request(specs):\n from tools.lazy_deps import install_specs\n return install_specs(specs)\n",
|
|
encoding="utf-8")
|
|
(world.home / "config.yaml").write_text(yaml.safe_dump(
|
|
{"plugins": {"enabled": sorted(plugins), "disabled": []}}), encoding="utf-8")
|
|
client.sync_venv(explicit=True)
|
|
return world.selected()
|
|
|
|
|
|
@pytest.fixture
|
|
def boot(plugin_world, monkeypatch):
|
|
"""``boot(environment)``: this test process imports from ``environment`` the way a Hermes process
|
|
booted on it does. Adoption rewrites sys.path and PATH (monkeypatch restores both); modules
|
|
imported from the world are forgotten afterwards, so a later test imports its own."""
|
|
from pm.environments import site_packages, venv_bin_dir
|
|
|
|
def run_from(environment: Path) -> None:
|
|
monkeypatch.setenv("PATH", os.pathsep.join([str(venv_bin_dir(environment)), os.defpath]))
|
|
monkeypatch.syspath_prepend(str(site_packages(environment)))
|
|
|
|
yield run_from
|
|
for name, module in list(sys.modules.items()):
|
|
if str(getattr(module, "__file__", None) or "").startswith(str(plugin_world.root)):
|
|
del sys.modules[name]
|
|
|
|
|
|
@pytest.fixture
|
|
def plugin_world(tmp_path, monkeypatch, isolated_python):
|
|
from pm import client, paths
|
|
|
|
world = PluginWorld(tmp_path)
|
|
uv = shutil.which("uv")
|
|
assert uv, "real uv is required for the plugin lifecycle"
|
|
for key in ("HERMES_MANAGED_MODE", "HERMES_INSTALL_ROOT", "VIRTUAL_ENV"):
|
|
monkeypatch.delenv(key, raising=False)
|
|
for key, value in {"HOME": tmp_path, "USERPROFILE": tmp_path, "HERMES_HOME": world.home,
|
|
"HERMES_RUNTIME_DIR": tmp_path / "store", "UV_CACHE_DIR": tmp_path / "cache",
|
|
"XDG_CONFIG_HOME": tmp_path / "config", "XDG_CONFIG_DIRS": tmp_path / "config",
|
|
"HERMES_DISABLE_LAZY_INSTALLS": "1", "UV_OFFLINE": "1"}.items():
|
|
monkeypatch.setenv(key, str(value))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setattr(paths, "repo_root", lambda: world.core)
|
|
monkeypatch.setattr(paths, "lockfile_path", lambda: tmp_path / "lock.json")
|
|
world.runtime_python = str(isolated_python)
|
|
monkeypatch.setattr(client, "runtime_command", lambda worker, **kwargs:
|
|
worker_command(worker, uv, sys.executable, runtime_python=world.runtime_python))
|
|
assert not client.is_runtime(), "fixture must exercise the client/worker boundary"
|
|
wheels = tmp_path / "wheels"
|
|
wheels.mkdir()
|
|
_wheel(wheels, "plugin_core_dep")
|
|
_wheel(wheels, "plugin_proof_other")
|
|
for pin in ("1.0", "2.0"):
|
|
_wheel(wheels, "plugin_proof_dep", pin)
|
|
world.home.mkdir()
|
|
(world.home / "config.yaml").write_text("plugins:\n enabled: []\n disabled: []\n", encoding="utf-8")
|
|
world.core.mkdir()
|
|
(world.core / "pyproject.toml").write_text(
|
|
'[project]\nname="plugin-proof-core"\nversion="1"\nrequires-python=">=3.11"\n'
|
|
'dependencies=["plugin-core-dep==1.0"]\n[tool.uv]\npackage=false\nno-index=true\n'
|
|
f'find-links=[{json.dumps(wheels.as_posix())}]\n', encoding="utf-8")
|
|
result = subprocess.run([uv, "lock", "--python", sys.executable], cwd=world.core,
|
|
capture_output=True, text=True, timeout=60)
|
|
assert result.returncode == 0, result.stderr
|
|
return world |