Merge pull request #122098 from liuhao1024/liuhao/cron-bugfix-122071

fix(pm): name virtual workspace members by their unique key
This commit is contained in:
ethernet
2026-09-24 22:11:02 -04:00
committed by GitHub
3 changed files with 69 additions and 2 deletions

View File

@@ -230,6 +230,14 @@ def _workspace_member(plugin_dir: Path, root: Path, *, identity: Path) -> Path:
shutil.copytree(plugin_dir, member, symlinks=True, shutil.copytree(plugin_dir, member, symlinks=True,
ignore=_member_ignored) ignore=_member_ignored)
document = tomllib.loads(pyproject.read_text(encoding="utf-8-sig")) 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 changed = declaration.install_requirements != declaration.requirements
if changed: if changed:
document["project"]["dependencies"] = list(declaration.install_requirements) 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. continue # The referenced tree was copied with this member.
spec["path"] = (identity / relative).resolve().as_posix() spec["path"] = (identity / relative).resolve().as_posix()
changed = True changed = True
if changed: if virtual:
document.setdefault("project", {})["name"] = f"hermes-plugin-{key}"
if virtual or changed:
import tomli_w import tomli_w
(member / "pyproject.toml").write_text(tomli_w.dumps(document), encoding="utf-8") (member / "pyproject.toml").write_text(tomli_w.dumps(document), encoding="utf-8")

View File

@@ -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"] assert document["project"] == tomllib.loads(project.read_text(encoding="utf-8-sig"))["project"]
[relative] = document["tool"]["uv"]["workspace"]["members"] [relative] = document["tool"]["uv"]["workspace"]["members"]
copied = recorded / relative / "pyproject.toml" 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()) assert copied.resolve().is_relative_to(recorded.resolve())
(original_member / "pyproject.toml").unlink() (original_member / "pyproject.toml").unlink()
assert workspace.members_stamp([original_member]) != stamp assert workspace.members_stamp([original_member]) != stamp

View File

@@ -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 all(name.startswith("hermes-plugin-my-plugin-") for name in names), names
assert names[0] != names[1] assert names[0] != names[1]
assert members[0].name != members[1].name 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")