Files
hermes-agent/tests/pm/test_workspace.py
ethernet 16652eea18 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	gateway/config.py
#	gateway/config_loader.py
#	gateway/readiness.py
#	hermes_cli/managed_scope.py
#	hermes_cli/plugin_python_deps.py
#	hermes_cli/plugins_cmd.py
#	hermes_cli/update_cmd_maint.py
#	plugin-catalog/hindsight.yaml
#	plugins/plugin_loader.py
#	providers/__init__.py
#	scripts/run_tests.sh
#	tests/gateway/test_control_socket_windows_live.py
#	tests/gateway/test_gateway_streaming_nested_config.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_plan_reconciliation_windows_live.py
#	tests/hermes_cli/test_update_apply_shallow_count.py
#	tests/hermes_cli/test_update_concurrent_quarantine.py
#	tests/hermes_cli/test_update_shim_self_lock.py
#	tests/hermes_cli/test_verify_console_scripts.py
#	tests/tools/test_lazy_deps.py
#	tests/tui_gateway/test_subprocess_encoding.py
#	tools/lazy_deps.py
2026-09-23 15:26:34 -04:00

147 lines
6.3 KiB
Python

"""pm.workspace: the generated uv-workspace root for plugin deps.
The workspace root is a pm-GENERATED project (never the committed
pyproject.toml — sealed installs are read-only and member lists are
machine-specific). Its pyproject = core's pyproject verbatim +
``[tool.uv.workspace] members`` pointing at each snapshotted plugin.
``uv lock`` unions core + plugin deps into ONE lock; conflict = loud refusal.
"""
from __future__ import annotations
import os
import subprocess
import shutil
import sys
from pathlib import Path
import pytest
import pm.workspace as ws
from pm.environment import managed_environment
from tests.pm.test_environment_build import locked_project # noqa: F401
@pytest.fixture
def layout(locked_project, tmp_path, monkeypatch):
core, uv, _ = locked_project
manifest = core / "pyproject.toml"
manifest.write_text(manifest.read_text().replace('[tool.uv.workspace]\nmembers=["member"]\n', ""))
monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (uv, Path(sys.executable)))
monkeypatch.setattr(ws.paths, "repo_root", lambda: core)
return tmp_path, core, core / "member", tmp_path / "store"
def test_preparation_refuses_existing_workspace_without_mutating_it(layout):
from pm.package import InstallError
tmp, core, plug_a, _ = layout
root = tmp / "workspace"
environment = managed_environment(tmp / "env")
kwargs = dict(root=root, source=core, seed_lock=None,
environment=environment)
ws.lock_and_sync([plug_a], [], **kwargs)
before = {p.relative_to(root): p.read_bytes() for p in root.rglob("*") if p.is_file()}
(plug_a / "pyproject.toml").write_text('changed after publication')
with pytest.raises(InstallError, match="fresh"):
ws.lock_and_sync([], [], **kwargs)
assert {p.relative_to(root): p.read_bytes() for p in root.rglob("*") if p.is_file()} == before
def test_missing_explicit_seed_cannot_silently_resolve_new_versions(layout):
tmp, core, plug_a, _ = layout
with pytest.raises(FileNotFoundError):
ws.lock_and_sync([plug_a], [], root=tmp / "workspace", source=core,
seed_lock=tmp / "missing.lock", environment=managed_environment(tmp / "env"))
assert not (tmp / "env").exists()
def test_core_quarantine_covers_core_packages_and_not_plugin_ones(layout, locked_project):
"""Regression for #120076: Hermes's 14-day cutoff must not filter a plugin's own deps.
A global ``exclude-newer`` in the generated root made a catalog pin floored on a fresh
release unresolvable. The cutoff now travels per package: every registry package in
core's lock keeps it (or core's explicit exemption), plugin-only packages follow the
plugin's policy, and the rewritten root still locks and syncs.
"""
import tomllib
tmp, core, plug_a, _ = layout
_, uv, env = locked_project
manifest = core / "pyproject.toml"
manifest.write_text(manifest.read_text().replace("[tool.uv]\n", '[tool.uv]\nexclude-newer="14 days"\n', 1)
+ '[tool.uv.exclude-newer-package]\nBase_Dep = false\n')
subprocess.run([str(uv), "lock", "--python", sys.executable], cwd=core, env=env, check=True,
capture_output=True)
core_packages = {p["name"] for p in tomllib.loads((core / "uv.lock").read_text())["package"]
if "registry" in p.get("source", {})}
assert {"base-dep", "chosen-dep"} <= core_packages and "member-dep" not in core_packages
root = tmp / "workspace"
ws.lock_and_sync([plug_a], [], root=root, source=core, seed_lock=core / "uv.lock",
environment=managed_environment(tmp / "env"))
policy = tomllib.loads((root / "pyproject.toml").read_text())["tool"]["uv"]
assert "exclude-newer" not in policy
per_package = policy["exclude-newer-package"]
assert per_package == {name: False if name == "base-dep" else "14 days" for name in core_packages}
locked = {p["name"] for p in tomllib.loads((root / "uv.lock").read_text())["package"]}
assert "member-dep" in locked and "member-dep" not in per_package
# --- classified failures + staging surface (FINAL-RUNTIME-CONTRACT) ---
def test_classify_network_failure_stays_generic():
from pm.workspace import ResolutionConflict, classify_uv_failure
err = classify_uv_failure("lock", 1, "error: Failed to fetch https://pypi.org (timed out)")
assert not isinstance(err, ResolutionConflict)
def test_sync_failure_is_never_a_conflict(layout, monkeypatch):
from pm.package import InstallError
tmp, core, _, _ = layout
environment = managed_environment(tmp / "candidate")
monkeypatch.setattr(subprocess, "run", lambda cmd, **kwargs:
subprocess.CompletedProcess(cmd, 1, "", "Failed to download wheel"))
with pytest.raises(InstallError) as excinfo:
ws.lock_and_sync([], [], root=tmp / "workspace", source=core,
seed_lock=None, environment=environment, frozen=True)
assert not isinstance(excinfo.value, ws.ResolutionConflict)
def test_staging_root_and_env_are_honored_without_live_mutation(layout, monkeypatch):
tmp, core, _, _ = layout
staging = tmp / "staging-ws"
monkeypatch.setenv("PM_WORKSPACE_TEST_SENTINEL", "live")
environment = managed_environment(tmp / "staging-venv", env={
"PATH": "/staged/bin", "PM_WORKSPACE_TEST_SENTINEL": "staged",
})
# The prepared environment is authoritative; workspace never discovers tools.
monkeypatch.setattr(shutil, "which", lambda *args, **kwargs: pytest.fail("PATH discovery"))
seen = []
def run(cmd, **kwargs):
seen.append((cmd, kwargs))
return subprocess.CompletedProcess(cmd, 0, "", "")
monkeypatch.setattr(subprocess, "run", run)
ws.lock_and_sync([], [], root=staging, source=core, seed_lock=None, environment=environment)
assert [cmd[1] for cmd, _ in seen] == ["lock", "sync"]
for cmd, kwargs in seen:
assert Path(cmd[0]) == environment.uv
assert Path(kwargs["cwd"]) == staging
assert kwargs["env"]["PM_WORKSPACE_TEST_SENTINEL"] == "staged"
assert kwargs["env"]["UV_CACHE_DIR"] == str(environment.cache)
assert kwargs["env"]["UV_PROJECT_ENVIRONMENT"] == str(environment.destination)
assert kwargs["env"]["UV_PYTHON"] == str(environment.python)
assert os.environ["PM_WORKSPACE_TEST_SENTINEL"] == "live"