From ef5a98ed57137a353cc171898eac1c2a5087ec56 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 3 Sep 2026 10:28:23 -0400 Subject: [PATCH] =?UTF-8?q?fix(pm):=20union=20sync=20must=20install=20memb?= =?UTF-8?q?er=20deps=20=E2=80=94=20add=20--all-packages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probed live 2026-09-03: plain `uv sync --frozen` installs only the ROOT project's dependencies — workspace-member deps are locked by `uv lock` but silently never reach site-packages (a member's pyfiglet stayed absent under plain --frozen, present under --all-packages). The union's whole contract is that plugin deps ride the venv; without the flag every member install is a green-looking lock over an empty site-packages. - lock_and_sync now passes --all-packages, with the probe rationale in place so the flag can never be 'simplified' away. - _uv_binary() falls back to shutil.which('uv') when the pm store has no provisioned uv — store-first, PATH-second (the activate() precedence), fixing 'uv is not installed' lies on dev machines and test envs that have uv on PATH but no store. - tests/pm/test_union_installs_members.py: two REAL end-to-end tests (mini workspace, real uv): member deps land in site-packages after lock_and_sync, and SURVIVE a second sync (the update-rebuild prune contract). These fail under the old flagless command by construction — the probe scenario can never silently return. Found while porting mnemosyne to the union (the manifest-less pip route prunes on resync; the union route needs member deps to actually install). tests/pm: 190 passed, 0 failed. --- pm/workspace.py | 18 ++++- tests/pm/test_union_installs_members.py | 101 ++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 tests/pm/test_union_installs_members.py diff --git a/pm/workspace.py b/pm/workspace.py index b20bdb033d..b26cb8a041 100644 --- a/pm/workspace.py +++ b/pm/workspace.py @@ -297,7 +297,14 @@ def lock_and_sync( if lock.returncode != 0: raise InstallError("venv", f"uv lock exited {lock.returncode}: {lock.stderr[-600:]}") - cmd = [uv_bin, "sync", "--frozen"] + # --all-packages is REQUIRED: plain `uv sync --frozen` installs only the + # ROOT project's deps — workspace-member deps are locked by `uv lock` + # but silently never reach site-packages (probed live 2026-09-03: a + # member's pyfiglet stayed absent from site-packages under plain + # --frozen, present under --all-packages). The union's whole contract + # is that plugin deps ride the venv; without this flag every member + # install is a silent no-op. + cmd = [uv_bin, "sync", "--frozen", "--all-packages"] for extra in sorted(set(extras or [])): cmd += ["--extra", extra] sync = subprocess.run(cmd, cwd=str(root), env=env, capture_output=True, text=True) @@ -372,7 +379,14 @@ def _default_venv_dir() -> Path: def _uv_binary() -> Optional[str]: + """Store uv first (pinned), PATH uv second (dev machines, test envs). + A dev machine with uv on PATH but no provisioned store is the normal + pre-`pm install` state — 'uv is not installed' there is a lie.""" from pm.ensure import uv as pm_uv uv_bin, _env = pm_uv(realize=False) - return uv_bin + if uv_bin: + return uv_bin + import shutil + + return shutil.which("uv") diff --git a/tests/pm/test_union_installs_members.py b/tests/pm/test_union_installs_members.py new file mode 100644 index 0000000000..9d98c60f26 --- /dev/null +++ b/tests/pm/test_union_installs_members.py @@ -0,0 +1,101 @@ +"""E2E: the workspace union must INSTALL member deps, not just lock them. + +Probed live 2026-09-03: plain `uv sync --frozen` installs only the ROOT +project's dependencies — workspace-member deps are locked by `uv lock` +but never reach site-packages. The union's whole contract is that plugin +deps ride the venv, so lock_and_sync must pass --all-packages. This test +builds a REAL mini-workspace and asserts the member's dep is importable +after lock_and_sync — the exact failure the probe caught (green-looking +lock, empty site-packages) can never silently return. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +import pm.workspace as ws + + +def _uv_available() -> bool: + import shutil + + return shutil.which("uv") is not None + + +@pytest.fixture +def mini_workspace(tmp_path, monkeypatch): + """A real generated workspace: fake core pyproject + a plugin member + whose dep (pyfiglet) is NOT a root dep. Store paths pointed at tmp.""" + core = tmp_path / "core" + core.mkdir() + (core / "pyproject.toml").write_text( + "[project]\n" + 'name = "fake-core"\n' + 'version = "0.1.0"\n' + 'requires-python = ">=3.11"\n' + 'dependencies = []\n', + encoding="utf-8", + ) + plug = tmp_path / "plugins" / "member-plug" + plug.mkdir(parents=True) + (plug / "pyproject.toml").write_text( + "[project]\n" + 'name = "member-plug"\n' + 'version = "0.1.0"\n' + 'requires-python = ">=3.11"\n' + 'dependencies = ["pyfiglet==1.0.2"]\n', + encoding="utf-8", + ) + store = tmp_path / "store" + store.mkdir() + venv = tmp_path / "venv" + + import pm.paths + + monkeypatch.setattr(pm.paths, "repo_root", lambda: core) + monkeypatch.setattr(pm.paths, "store_root", lambda: store) + monkeypatch.setattr(ws.paths, "repo_root", lambda: core) + monkeypatch.setattr(ws.paths, "store_root", lambda: store) + return tmp_path, plug, venv + + +@pytest.mark.skipif(_uv_available() is False, reason="uv not on PATH") +def test_lock_and_sync_installs_member_deps(mini_workspace): + """The probe scenario: after lock_and_sync, the member's dep MUST be + importable from the synced venv. Under plain `uv sync --frozen` the + lock contains the dep but site-packages does not — this fails.""" + _, plug, venv = mini_workspace + ws.lock_and_sync([plug], [], venv_dir=venv) + + site = venv / "Lib" if (venv / "Lib").exists() else venv / "lib" + packages = sorted(p.name for p in site.glob("*site-packages")) + assert packages, "no site-packages in the synced venv" + sp = site / packages[0] + + # the member's dep landed (this is the --all-packages contract) + assert (sp / "pyfiglet").is_dir() or any( + p.name.startswith("pyfiglet") for p in sp.iterdir() + ), ( + "member dep locked but NOT installed — lock_and_sync must pass " + "--all-packages (plain `uv sync --frozen` is root-only)" + ) + + +@pytest.mark.skipif(_uv_available() is False, reason="uv not on PATH") +def test_union_survives_a_resync(mini_workspace): + """The prune-proof contract: a second lock_and_sync (what an update + rebuild does) must NOT remove the member's deps — they are in the + union lock, not pip-guests.""" + _, plug, venv = mini_workspace + ws.lock_and_sync([plug], [], venv_dir=venv) + ws.lock_and_sync([plug], [], venv_dir=venv) + + site = venv / "Lib" if (venv / "Lib").exists() else venv / "lib" + sp = site / next(iter(sorted(p.name for p in site.glob("*site-packages")))) + assert any(p.name.startswith("pyfiglet") for p in sp.iterdir()), ( + "member deps were stripped by a re-sync — the union lock must own " + "them across rebuilds" + )