diff --git a/pm/plugin_declarations.py b/pm/plugin_declarations.py index 169b7136437b8..8d38d636155f5 100644 --- a/pm/plugin_declarations.py +++ b/pm/plugin_declarations.py @@ -170,12 +170,14 @@ def read_python_declaration(plugin_dir: Path) -> PythonDeclaration: text = None if text is not None: files.append(project) - if "GENERATED by pm" not in text: - document = tomllib.loads(text) - specs = document.get("project", {}).get("dependencies", []) + document = tomllib.loads(text) if "GENERATED by pm" not in text else {} + # A pyproject with no [project] table only configures tools (ruff, pytest); + # the manifest still declares the dependencies. + if "project" in document: + specs = document["project"].get("dependencies", []) if not isinstance(specs, list) or any(not isinstance(v, str) for v in specs): raise ValueError(f"invalid project.dependencies: {project}") - requires_python = document.get("project", {}).get("requires-python") + requires_python = document["project"].get("requires-python") if requires_python is not None and not isinstance(requires_python, str): raise ValueError(f"invalid project.requires-python: {project}") return PythonDeclaration(tuple(files), project, tuple(specs), manifest, requires_python) diff --git a/pm/workspace.py b/pm/workspace.py index 2f54fcf43b221..82c12eb8763c4 100644 --- a/pm/workspace.py +++ b/pm/workspace.py @@ -248,6 +248,25 @@ def _member_key(identity: Path) -> str: return f"{name}-{digest}" +def _installable_project(document: dict) -> bool: + """Drop what Hermes never installs from a plugin's pyproject; True when it changed. + + Hermes never installs a plugin's extras, and uv syncs a member's default ``dev`` + group into Hermes's own environment. Resolving them anyway made a plugin's + pytest/ruff pins conflict with core's. + """ + project = document.get("project", {}) + changed = False + if "optional-dependencies" in project and "optional-dependencies" not in project.get("dynamic", []): + del project["optional-dependencies"] + changed = True + for table, key in ((document, "dependency-groups"), (document.get("tool", {}).get("uv", {}), "dev-dependencies")): + if key in table: + del table[key] + changed = True + return changed + + def _workspace_member(plugin_dir: Path, root: Path, *, identity: Path) -> Path: """Keep workspace members with their generation, not a temporary install clone.""" import json @@ -272,6 +291,7 @@ def _workspace_member(plugin_dir: Path, root: Path, *, identity: Path) -> Path: changed = declaration.install_requirements != declaration.requirements if changed: document["project"]["dependencies"] = list(declaration.install_requirements) + changed = _installable_project(document) or changed for sources in document.get("tool", {}).get("uv", {}).get("sources", {}).values(): for spec in sources if isinstance(sources, list) else [sources]: if not isinstance(spec, dict) or "path" not in spec: diff --git a/pyproject.toml b/pyproject.toml index a16898701ee23..8f8781aa8d16d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -787,6 +787,28 @@ uvicorn = false vercel = false websockets = false youtube-transcript-api = false +# Catalog plugins floored on a release younger than the 14-day window +# (temporary). Each cutoff is the upload time of the OLDEST release the +# plugin accepts, so exactly that release gets through and anything newer +# still waits. A line is dead once its date is 14 days old: delete it then. +# The lasting fix is upstream (exact pin + the plugin's own exemption, see +# the developer guide's "Dependency security policy"). +# birkin-mnemosyne 0.4.0: catalog birkin-mnemosyne; dead after 2026-10-14 +birkin-mnemosyne = "2026-09-30T10:47:28Z" +# cortexlayer 0.1.2: catalog cortexlayer; dead after 2026-10-09 +cortexlayer = "2026-09-25T14:28:59Z" +# evalroute 0.9.0: catalog evalroute; dead after 2026-10-19 +evalroute = "2026-10-05T04:51:25Z" +# gonogo-eval 0.3.0: catalog gonogo, thomas; dead after 2026-10-12 +gonogo-eval = "2026-09-28T21:07:00Z" +# hermes-mnemostack 1.0.3: catalog mnemostack; dead after 2026-10-11 +hermes-mnemostack = "2026-09-27T19:26:09Z" +# loreconvo 0.10.15: catalog loreconvo; dead after 2026-10-18 +loreconvo = "2026-10-04T17:35:24Z" +# mnemosyne-hermes 0.7.3: catalog mnemosyne; dead after 2026-10-07 +mnemosyne-hermes = "2026-09-23T23:42:51Z" +# mnemosyne-memory 4.0.0b3: catalog mnemosyne; dead after 2026-10-07 +mnemosyne-memory = "2026-09-23T23:41:58Z" [tool.setuptools] # Root single-file modules are derived by setup.py at build time from the diff --git a/tests/pm/test_workspace.py b/tests/pm/test_workspace.py index 21406f3819aeb..6c88679a02607 100644 --- a/tests/pm/test_workspace.py +++ b/tests/pm/test_workspace.py @@ -60,6 +60,51 @@ def test_missing_explicit_seed_cannot_silently_resolve_new_versions(layout): assert not (tmp / "env").exists() +def _plugin_pyproject(tmp: Path, name: str, body: str) -> Path: + directory = tmp / name + directory.mkdir() + (directory / "pyproject.toml").write_text(body, encoding="utf-8") + (directory / "plugin.yaml").write_text(f"name: {name}\n", encoding="utf-8") + return directory + + +def test_plugin_extras_and_dev_groups_never_constrain_hermes(layout): + """Hermes installs neither a plugin's extras nor its dev group, so their pins (here an + unsatisfiable one against core's ``base-dep==1.0``) must not make the plugin uninstallable.""" + import tomllib + + tmp, core, _, _ = layout + plugin = _plugin_pyproject(tmp, "pinned-dev", ( + '[project]\nname = "pinned-dev"\nversion = "1"\nrequires-python = ">=3.11"\n' + 'dependencies = ["member-dep==1.0"]\n' + '[project.optional-dependencies]\ndev = ["base-dep==9.9"]\n' + '[dependency-groups]\ndev = ["other-dep==9.9"]\n[tool.uv]\npackage = false\n')) + root = tmp / "workspace" + ws.lock_and_sync([plugin], [], root=root, source=core, seed_lock=core / "uv.lock", + environment=managed_environment(tmp / "env")) + locked = {p["name"]: p["version"] for p in tomllib.loads((root / "uv.lock").read_text())["package"]} + assert locked["base-dep"] == "1.0" and locked["member-dep"] == "1.0" + + +def test_tool_config_pyproject_leaves_the_manifest_in_charge_of_dependencies(layout): + """A pyproject holding only tool settings (ruff, pytest) is not a package definition: + the plugin's manifest dependencies still install, instead of uv refusing a [project] + table PM had to invent.""" + import tomllib + + tmp, core, _, _ = layout + plugin = tmp / "lint-only" + plugin.mkdir() + (plugin / "pyproject.toml").write_text("[tool.ruff]\nline-length = 100\n", encoding="utf-8") + (plugin / "plugin.yaml").write_text("name: lint-only\npython_dependencies:\n - member-dep==1.0\n", + encoding="utf-8") + root = tmp / "workspace" + ws.lock_and_sync([plugin], [], root=root, source=core, seed_lock=core / "uv.lock", + environment=managed_environment(tmp / "env")) + locked = {p["name"] for p in tomllib.loads((root / "uv.lock").read_text())["package"]} + assert "member-dep" in locked + + class _TimedIndex: """A PEP 691 JSON index with per-file ``upload-time``. diff --git a/uv.lock b/uv.lock index 5389dde571a2d..3b0d67814667b 100644 --- a/uv.lock +++ b/uv.lock @@ -14,106 +14,114 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P14D" [options.exclude-newer-package] -agent-client-protocol = false +elevenlabs = false +langfuse = "2026-09-09T16:01:26Z" +setuptools = false +modal = false +pyyaml = false +dingtalk-stream = false +maturin = false +numpy = false +pvporcupine = false +google-auth = false aiohttp = false -aiohttp-socks = false -aiosqlite = false -alibabacloud-dingtalk = false +starlette = false +mistralai = false +firecrawl-py = false +parallel-web = false +markdown = false +opentelemetry-sdk = false +prompt-toolkit = false +fastapi = false anthropic = false +pathspec = false +ruff = false +defusedxml = false +websockets = false +exa-py = false +mcp = false +python-olm = false +wheel = false +pytest = false +pillow = false +setuptools-rust = false +tenacity = false +sherpa-onnx-core = "2026-09-10T15:58:47Z" +pyjwt = false +slack-bolt = false +fal-client = false asyncpg = false -azure-identity = false boto3 = false -brotlicffi = false -browser-harness = false +ruamel-yaml = false +sherpa-onnx = "2026-09-10T17:00:04Z" +huggingface-hub = false +pytest-asyncio = false +google-cloud-pubsub = false +slack-sdk = false +discord-py = false +rich = false +faster-whisper = false certifi = false -concurrent-log-handler = false -croniter = false -cryptography = false -daytona = false -ddgs = "2026-08-26T21:52:34Z" debugpy = false -defusedxml = false -dingtalk-stream = false -discord-py = false +ddgs = "2026-08-26T21:52:34Z" +packaging = false +unpaddedbase64 = false +youtube-transcript-api = false +h2 = false +birkin-mnemosyne = "2026-09-30T10:47:28Z" +gonogo-eval = "2026-09-28T21:07:00Z" +mnemosyne-memory = "2026-09-23T23:41:58Z" edge-tts = false -elevenlabs = false -exa-py = false -fal-client = false -fastapi = false -faster-whisper = false -fire = false +microsoft-teams-apps = false +aiohttp-socks = false +sentencepiece = false +opentelemetry-exporter-otlp-proto-http = false +croniter = false +browser-harness = false +azure-identity = false +lark-oapi = false firecrawl-anydoc = false -firecrawl-py = false google-api-python-client = false -google-auth = false -google-auth-httplib2 = false -google-auth-oauthlib = false -google-cloud-pubsub = false -h2 = false -httplib2 = false -httpx = false -httpx2 = false -huggingface-hub = false -jinja2 = false -langfuse = "2026-09-09T16:01:26Z" -lark-oapi = false -markdown = false -maturin = false -mautrix = false -mcp = false +brotlicffi = false mem0ai = false -microsoft-teams-apps = false -mistralai = false -modal = false +qrcode = false +soundfile = "2026-06-06T08:58:48Z" +vercel = false +hermes-mnemostack = "2026-09-27T19:26:09Z" +evalroute = "2026-10-05T04:51:25Z" +aiosqlite = false +tzdata = false +python-dotenv = false +daytona = false +uvicorn = false +loreconvo = "2026-10-04T17:35:24Z" +mnemosyne-hermes = "2026-09-23T23:42:51Z" nemo-relay = false -neutts = "2026-07-22T14:54:23Z" -numpy = false -openai = false -opentelemetry-exporter-otlp-proto-http = false -opentelemetry-sdk = false -packaging = false -parallel-web = false -pathspec = false -pillow = false +requests = false +httplib2 = false +httpx = false piper-tts = "2026-09-04T16:47:32Z" -prompt-toolkit = false -psutil = false -pvporcupine = false pyasn1 = false +snowballstemmer = false +concurrent-log-handler = false +cryptography = false pydantic = false -pyjwt = false -pytest = false -pytest-asyncio = false -python-dotenv = false +agent-client-protocol = false +fire = false +httpx2 = false +google-auth-oauthlib = false python-multipart = false -python-olm = false +ty = false +openai = false +google-auth-httplib2 = false +neutts = "2026-07-22T14:54:23Z" +mautrix = false +psutil = false python-telegram-bot = false -pyyaml = false -qrcode = false -requests = false -rich = false -ruamel-yaml = false -ruff = false -sentencepiece = false -setuptools = false -setuptools-rust = false -sherpa-onnx = "2026-09-10T17:00:04Z" -sherpa-onnx-core = "2026-09-10T15:58:47Z" -slack-bolt = false -slack-sdk = false -snowballstemmer = false +jinja2 = false sounddevice = false -soundfile = "2026-06-06T08:58:48Z" -starlette = false -tenacity = false -ty = false -tzdata = false -unpaddedbase64 = false -uvicorn = false -vercel = false -websockets = false -wheel = false -youtube-transcript-api = false +alibabacloud-dingtalk = false +cortexlayer = "2026-09-25T14:28:59Z" [manifest] overrides = [