diff --git a/pm/workspace.py b/pm/workspace.py index 5890c0e93c..435deddfc9 100644 --- a/pm/workspace.py +++ b/pm/workspace.py @@ -230,6 +230,14 @@ def _workspace_member(plugin_dir: Path, root: Path, *, identity: Path) -> Path: shutil.copytree(plugin_dir, member, symlinks=True, ignore=_member_ignored) document = tomllib.loads(pyproject.read_text(encoding="utf-8-sig")) + # uv identifies a workspace member by [project].name, so the same virtual + # plugin enabled in two profiles would declare one name twice and fail + # `uv lock`. A member with no build backend is metadata-only: it can carry + # the unique key in its name, as manifest-only members already do. A + # buildable member keeps its declared name — uv verifies it against the + # package metadata its backend produces. + # tool.uv.package = true opts into uv package mode: real build metadata, so buildable. + virtual = "build-system" not in document and document.get("tool", {}).get("uv", {}).get("package") is not True changed = declaration.install_requirements != declaration.requirements if changed: document["project"]["dependencies"] = list(declaration.install_requirements) @@ -245,7 +253,9 @@ def _workspace_member(plugin_dir: Path, root: Path, *, identity: Path) -> Path: continue # The referenced tree was copied with this member. spec["path"] = (identity / relative).resolve().as_posix() changed = True - if changed: + if virtual: + document.setdefault("project", {})["name"] = f"hermes-plugin-{key}" + if virtual or changed: import tomli_w (member / "pyproject.toml").write_text(tomli_w.dumps(document), encoding="utf-8") diff --git a/tests/pm/test_environment_build.py b/tests/pm/test_environment_build.py index 8780eaa17f..500b1292b6 100644 --- a/tests/pm/test_environment_build.py +++ b/tests/pm/test_environment_build.py @@ -677,7 +677,14 @@ def test_explicit_workspace_preserves_seed_and_replays_copied_members(locked_pro assert document["project"] == tomllib.loads(project.read_text(encoding="utf-8-sig"))["project"] [relative] = document["tool"]["uv"]["workspace"]["members"] copied = recorded / relative / "pyproject.toml" - assert copied.read_bytes() == before_member + copied_document = tomllib.loads(copied.read_text(encoding="utf-8-sig")) + expected = tomllib.loads(before_member.decode("utf-8")) + # A virtual member is renamed to its unique key (uv rejects two members with + # one [project].name); everything the plugin declared must survive verbatim. + assert copied_document["project"]["name"].startswith("hermes-plugin-member-") + del copied_document["project"]["name"] + del expected["project"]["name"] + assert copied_document == expected assert copied.resolve().is_relative_to(recorded.resolve()) (original_member / "pyproject.toml").unlink() assert workspace.members_stamp([original_member]) != stamp diff --git a/tests/pm/test_member_selection.py b/tests/pm/test_member_selection.py index c1dd3d4aa1..e0230b0186 100644 --- a/tests/pm/test_member_selection.py +++ b/tests/pm/test_member_selection.py @@ -37,3 +37,53 @@ def test_manifest_only_member_is_named_after_its_plugin_dir_and_stays_unique(tmp assert all(name.startswith("hermes-plugin-my-plugin-") for name in names), names assert names[0] != names[1] assert members[0].name != members[1].name + + +def test_pyproject_member_is_renamed_by_its_key_and_stays_unique(tmp_path): + """A plugin that ships its own pyproject declares its real [project].name, and + uv identifies workspace members by that name — enabling the plugin in two + profiles then fails ``uv lock`` with "Two workspace members are both named + …". Rename metadata-only members by their unique key, like manifest-only + ones; a buildable member keeps the name its package metadata reports.""" + import tomllib + from pm.workspace import _workspace_member + + members = [] + for home in ("home-a", "home-b"): + plugin = tmp_path / home / "plugins" / "hindsight" + plugin.mkdir(parents=True) + (plugin / "pyproject.toml").write_text( + '[project]\nname = "hermes-plugin-hindsight"\nversion = "1.0.0"\n' + 'dependencies = ["hindsight-client>=0.10.1"]\n', + encoding="utf-8", + ) + root = tmp_path / f"gen-{home}" + root.mkdir() + members.append(_workspace_member(plugin, root, identity=plugin)) + names = [tomllib.loads((m / "pyproject.toml").read_text(encoding="utf-8"))["project"]["name"] for m in members] + assert all(name.startswith("hermes-plugin-hindsight-") for name in names), names + assert names[0] != names[1] + document = tomllib.loads((members[0] / "pyproject.toml").read_text(encoding="utf-8")) + assert document["project"]["version"] == "1.0.0" + assert document["project"]["dependencies"] == ["hindsight-client>=0.10.1"] + + +def test_buildable_pyproject_member_keeps_its_declared_name(tmp_path): + """uv verifies a buildable member's [project].name against the package metadata + its backend produces, so renaming it breaks the build ("Package metadata name + … does not match given name"); only metadata-only members may be renamed.""" + import tomllib + from pm.workspace import _workspace_member + + plugin = tmp_path / "home" / "plugins" / "replay" + plugin.mkdir(parents=True) + (plugin / "pyproject.toml").write_text( + '[project]\nname = "replay-plugin"\nversion = "1.0"\n' + '[build-system]\nrequires = []\nbuild-backend = "backend"\n', + encoding="utf-8", + ) + root = tmp_path / "gen" + root.mkdir() + member = _workspace_member(plugin, root, identity=plugin) + assert (member / "pyproject.toml").read_text(encoding="utf-8") == ( + plugin / "pyproject.toml").read_text(encoding="utf-8")