# 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
147 lines
6.3 KiB
Python
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"
|