diff --git a/.github/workflows/uv-lockfile-check.yml b/.github/workflows/uv-lockfile-check.yml index 6fba516a14..a864045ac5 100644 --- a/.github/workflows/uv-lockfile-check.yml +++ b/.github/workflows/uv-lockfile-check.yml @@ -30,12 +30,12 @@ name: uv.lock check # # git fetch origin main # git rebase origin/main # or merge, whatever the repo prefers -# python -m pm.build_env --source . --lock-only # regenerates uv.lock against new pyproject.toml +# hermes pm lock # regenerates uv.lock against new pyproject.toml # git add uv.lock # git commit -m "chore: refresh uv.lock after rebase onto main" # git push --force-with-lease # if you rebased # -# If you also changed pyproject.toml in your PR, `python -m pm.build_env --source . --lock-only` handles that +# If you also changed pyproject.toml in your PR, `hermes pm lock` handles that # at the same time — one regeneration covers both your changes and the # drift from main. # @@ -95,7 +95,7 @@ jobs: # registry, versus "lockfile needs to be updated" when the lock is # genuinely stale. Only the second is the contributor's to fix, so # an unreachable registry says so instead of sending them to - # `python -m pm.build_env --source . --lock-only` with nothing to regenerate. + # `hermes pm lock` with nothing to regenerate. ok=false net_fail=false delays=(5 15 45) @@ -141,13 +141,13 @@ jobs: ```bash git fetch origin main git rebase origin/main # or `git merge origin/main` - python -m pm.build_env --source . --lock-only # regenerate against new pyproject.toml + hermes pm lock # regenerate against new pyproject.toml git add uv.lock git commit -m "chore: refresh uv.lock after syncing with main" git push --force-with-lease # drop --force-with-lease if you merged ``` - **If you only changed pyproject.toml:** run `python -m pm.build_env --source . --lock-only` locally + **If you only changed pyproject.toml:** run `hermes pm lock` locally and commit the result. This check is blocking because the Docker image build uses @@ -155,8 +155,8 @@ jobs: — catching it here avoids a ~15 min failed docker run on `main` post-merge. EOF - echo "::error title=uv.lock out of sync::Run \`python -m pm.build_env --source . --lock-only\` locally and commit the result. If on a PR, sync with main first." - review_status='[{"source":"uv.lock check","results":[{"kind":"action_required","title":"uv.lock out of sync","summary":"uv.lock is out of sync with pyproject.toml.","how_to_fix":"Run `python -m pm.build_env --source . --lock-only` locally and commit the result. If on a PR, sync with main first:\n```\ngit fetch origin main\ngit rebase origin/main\npython -m pm.build_env --source . --lock-only\ngit add uv.lock\ngit commit -m \"chore: refresh uv.lock\"\n```\n"}]}]' + echo "::error title=uv.lock out of sync::Run \`hermes pm lock\` locally and commit the result. If on a PR, sync with main first." + review_status='[{"source":"uv.lock check","results":[{"kind":"action_required","title":"uv.lock out of sync","summary":"uv.lock is out of sync with pyproject.toml.","how_to_fix":"Run `hermes pm lock` locally and commit the result. If on a PR, sync with main first:\n```\ngit fetch origin main\ngit rebase origin/main\nhermes pm lock\ngit add uv.lock\ngit commit -m \"chore: refresh uv.lock\"\n```\n"}]}]' echo "review_status=${review_status}" >> "$GITHUB_OUTPUT" echo "review_status=${review_status}" > review-status.json exit 1 diff --git a/AGENTS.md b/AGENTS.md index 6ca0150d41..832df27767 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -326,8 +326,8 @@ All dependencies carry upper bounds (litellm compromise #2796/#2810; Mini Shai-H May 2026). PyPI: `>=floor,=0.28.1,<1"`); pre-1.0: `<0.(minor+2)` (`>=0.29,<0.32`). Git URLs: 40-char commit SHA. GitHub Actions: SHA + `# vN` comment. CI-only Python requirements: `==exact`. A bare `>=X.Y.Z` is rejected by CI and reviewers. -After changing `pyproject.toml`, run `python -m pm.build_env --source . --lock-only` -with the checkout's prepared Python. Reference: #2810 (bounds), #9801 (SHA pinning + audit CI). +After changing `pyproject.toml`, run `hermes pm lock`, re-source `./activate`, and commit +`pyproject.toml` with `uv.lock`. Reference: #2810 (bounds), #9801 (SHA pinning + audit CI). PM owns Hermes Python dependency changes. Use `pm.sync_venv(['extra'], explicit=True)` for declared runtime extras, `hermes pm install` for setup/sync, and `hermes pm repair` diff --git a/CONTRIBUTING.es.md b/CONTRIBUTING.es.md index fc1e0d7ea0..98f266707e 100644 --- a/CONTRIBUTING.es.md +++ b/CONTRIBUTING.es.md @@ -103,10 +103,12 @@ explícitamente solo ese entorno desechable. PM no elimina destinos existentes. No modifiques los entornos de Hermes con comandos directos de pip o uv. En Windows, ejecuta el script de pruebas mediante Bash. -Si cambias `pyproject.toml`, regenera el lock con: +Si cambias `pyproject.toml`, regenera el lock, vuelve a cargar la activación y +confirma `pyproject.toml` junto con `uv.lock`: ```bash -python -m pm.build_env --source . --lock-only +hermes pm lock +source ./activate ``` Para JavaScript, ejecuta `npm ci` en el workspace correspondiente. Consulta diff --git a/pm/cli.py b/pm/cli.py index 48da6ce0e8..9c1b3d3462 100644 --- a/pm/cli.py +++ b/pm/cli.py @@ -20,8 +20,96 @@ from pm.store import ALL_TARGETS, current_target, hash_url from pm.update import Resolved, resolve_package, reuse_index_responses -@reuse_index_responses() def cmd_lock(args) -> int: + """No arguments: relock uv.lock from pyproject.toml. --bump: pin a tool in pm/lock.json.""" + if args.name is None: + return _relock_project(repo_root()) + return _pin_tool(args) + + +def _relock_project(root: Path) -> int: + """The contributor's one step after editing pyproject.toml. + + The same PM operations as `python -m pm.build_env --source . --check-lock` + and `--lock-only`, so the exclude-newer quarantine and resolver settings + cannot drift between the two entry points. No environment changes here: + activation owns syncing, and a relock that also installed would hide + which of the two failed. + """ + import pm + + try: + pm.check_project_lock(root, explicit=True) + except InstallError: + # Stale is the expected case, and the check's own "✗ … failed" tail + # reads like an error, so say what happens next. An unreachable index + # fails the relock below too, and that error names the index knobs. + print("uv.lock is out of date with pyproject.toml; relocking") + else: + print("✓ uv.lock is already current with pyproject.toml; nothing written") + return 0 + before = _locked_extras(root) + try: + pm.lock_project(root, explicit=True) + except InstallError as exc: + print(f"✗ uv.lock refresh failed: {exc}") + return 1 + print("✓ uv.lock updated from pyproject.toml") + windows = sys.platform == "win32" + activate = r". .\activate.ps1" if windows else "source ./activate" + print(f"Next: re-source activation to sync the environments: {activate}") + opt_in = _new_opt_in_extras(root, before) + if opt_in: + # Activation syncs [all] plus extras already recorded, and --test-extras + # REPLACES the default rather than adding to it, so [all] stays listed. + names = ",".join(["all", *opt_in]) + flag = f"-TestExtras '{names}'" if windows else f"--test-extras {names}" + print(f"New extras outside [all] ({', '.join(opt_in)}) need: {activate} {flag}") + print("Then commit pyproject.toml and uv.lock together.") + return 0 + + +def _locked_extras(root: Path) -> set[str]: + """Extras the current uv.lock already resolves for the root project.""" + import tomllib + + try: + with (root / "uv.lock").open("rb") as f: + lock = tomllib.load(f) + with (root / "pyproject.toml").open("rb") as f: + name = tomllib.load(f)["project"]["name"] + except (OSError, ValueError, KeyError): + return set() + for package in lock.get("package", ()): + if package.get("name") == name: + return set(package.get("metadata", {}).get("provides-extras", ())) + return set() + + +def _new_opt_in_extras(root: Path, before: set[str]) -> list[str]: + """Extras this relock introduced that the default [all] closure does not reach.""" + import re + import tomllib + + with (root / "pyproject.toml").open("rb") as f: + project = tomllib.load(f)["project"] + extras = project.get("optional-dependencies", {}) + self_ref = re.compile(rf"\s*{re.escape(project['name'])}\s*\[([^\]]+)\]") + covered, pending = set(), ["all"] + while pending: + extra = pending.pop() + if extra in covered: + continue + covered.add(extra) + for requirement in extras.get(extra, ()): + match = self_ref.match(requirement) + if match: + pending.extend(part.strip() for part in match.group(1).split(",")) + return sorted(set(extras) - before - covered) + + +@reuse_index_responses() +def _pin_tool(args) -> int: """--bump : resolve every target's archives, hash them, write. A target with one archive pins the object; several pin a list. Target-independent urls collapse to one "any" artifact.""" @@ -646,9 +734,14 @@ def main(argv=None) -> int: parser = argparse.ArgumentParser(prog="hermes pm") sub = parser.add_subparsers(dest="cmd", required=True) - p = sub.add_parser("lock", help="write versions+hashes into pm/lock.json") - p.add_argument("--bump", dest="name", required=True) - p.add_argument("version") + lock_parser = p = sub.add_parser( + "lock", help="relock uv.lock from pyproject.toml (or --bump a tool pin in pm/lock.json)", + description="With no arguments: re-resolve uv.lock from pyproject.toml; changes no " + "environment and writes nothing when the lock is current. With --bump NAME " + "VERSION: pin a pm tool's artifacts in pm/lock.json; uv.lock is untouched.") + p.add_argument("--bump", dest="name", metavar="NAME", + help="pin tool NAME at VERSION in pm/lock.json instead of relocking uv.lock") + p.add_argument("version", nargs="?", metavar="VERSION", help="the tool version (only with --bump)") p.set_defaults(func=cmd_lock) p = sub.add_parser("install", help="install packages (default: all required)") @@ -704,6 +797,8 @@ def main(argv=None) -> int: p.set_defaults(func=cmd_update) args = parser.parse_args(argv) + if args.cmd == "lock" and (args.name is None) != (args.version is None): + lock_parser.error("--bump NAME and VERSION go together; run with neither to relock uv.lock") from pm.runtime import is_runtime, run_cli try: diff --git a/pyproject.toml b/pyproject.toml index e51d2be048..4bb23a0760 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,8 +27,8 @@ dependencies = [ # captured by `mistralai>=2.3.0,<3` rather than an exact pin, every # install in the hours before the quarantine would have pulled it. # - # After a pin change, run `python -m pm.build_env --source . --lock-only` - # so the transitive resolution stays consistent. Do not introduce ranges + # After a pin change, run `hermes pm lock` so the transitive resolution + # stays consistent. Do not introduce ranges # without a written justification. # # Scope rule: only packages used by EVERY hermes session belong here. diff --git a/skills/autonomous-ai-agents/hermes-agent/references/contributor-guide.md b/skills/autonomous-ai-agents/hermes-agent/references/contributor-guide.md index 0b308973e3..b23f07c176 100644 --- a/skills/autonomous-ai-agents/hermes-agent/references/contributor-guide.md +++ b/skills/autonomous-ai-agents/hermes-agent/references/contributor-guide.md @@ -103,7 +103,8 @@ scripts/run_tests.sh -v --tb=long # pass-through pytest flags - The runner probes repository `.venv`, `venv`, and the standard source-install venv before falling back to `HERMES_PYTHON`. Each candidate must contain pytest. - **Windows:** run the same wrapper through Git Bash. See `references/windows-quirks.md`. -- After dependency changes, run `python -m pm.build_env --source . --lock-only`. +- After editing `pyproject.toml`, run `hermes pm lock`, re-source `./activate`, and + commit `pyproject.toml` with `uv.lock`. Do not mutate Hermes environments with raw pip or uv commands. Host-specific tests run on the real host. Use one `@pytest.mark.platforms(...)` diff --git a/tests/pm/test_project_relock.py b/tests/pm/test_project_relock.py new file mode 100644 index 0000000000..d9a141998d --- /dev/null +++ b/tests/pm/test_project_relock.py @@ -0,0 +1,62 @@ +"""`hermes pm lock` with no arguments relocks uv.lock through PM and nothing else.""" +from __future__ import annotations + +from argparse import Namespace + +import pm +from pm import cli +from pm.package import InstallError + +PYPROJECT = """\ +[project] +name = "demo" +version = "0" + +[project.optional-dependencies] +alpha = ["six"] +beta = ["idna"] +all = ["demo[alpha]"] +""" +LOCK = """\ +version = 1 + +[[package]] +name = "demo" +version = "0" +source = { editable = "." } + +[package.metadata] +provides-extras = ["alpha", "all"] +""" + + +def test_relock_writes_nothing_when_current_and_names_new_opt_in_extras(tmp_path, monkeypatch, capsys): + (tmp_path / "pyproject.toml").write_text(PYPROJECT, encoding="utf-8") + (tmp_path / "uv.lock").write_text(LOCK, encoding="utf-8") + monkeypatch.setattr(cli, "repo_root", lambda: tmp_path) + stale = [] + relocks = [] + + def check(source, **kw): + assert (source, kw.get("explicit")) == (tmp_path, True) + if stale: + raise InstallError("venv", "The lockfile at `uv.lock` needs to be updated") + + monkeypatch.setattr(pm, "check_project_lock", check) + monkeypatch.setattr(pm, "lock_project", lambda source, **kw: relocks.append((source, kw))) + monkeypatch.setattr(pm, "sync_venv", lambda *a, **kw: relocks.append("sync")) + + before = (tmp_path / "uv.lock").read_bytes() + assert cli.cmd_lock(Namespace(name=None, version=None)) == 0 + assert relocks == [] + assert (tmp_path / "uv.lock").read_bytes() == before + assert "already current" in capsys.readouterr().out + + stale.append(True) + assert cli.cmd_lock(Namespace(name=None, version=None)) == 0 + # Relocking never syncs an environment; activation owns that. + assert relocks == [(tmp_path, {"explicit": True})] + out = capsys.readouterr().out + assert "activate" in out + # beta is new and outside [all]; alpha was already locked and is in [all]. + assert "all,beta" in out and "alpha" not in out diff --git a/website/docs/reference/package-management.md b/website/docs/reference/package-management.md index 231b0d9d36..837460ae12 100644 --- a/website/docs/reference/package-management.md +++ b/website/docs/reference/package-management.md @@ -413,16 +413,34 @@ sync. The `dev` and `test` dependency groups belong only to the separate test environment, not the selected application venv. After changing extras, reactivate before starting another Python process. -For a new project dependency, edit `pyproject.toml` and regenerate `uv.lock`: +### Syncing after you edit pyproject.toml -```bash -python -m pm.build_env --source . --lock-only -``` +1. Edit `pyproject.toml`. Pin every dependency as the + [Dependency Pinning Policy](https://github.com/NousResearch/hermes-agent/blob/main/AGENTS.md#dependency-pinning-policy) + requires. Express platform limits with PEP 508 markers, or gate a whole + extra in `[tool.hermes.extras-platforms]`. +2. Relock: -This resolves the source lock without creating or selecting an application -environment. Use the checkout's prepared Python. For JS dependencies, update -the owning package manifest and lock. Do not edit PM facts or generated -workspaces, and do not install packages directly into a selected generation. + ```bash + hermes pm lock + ``` + + This re-resolves `uv.lock` from `pyproject.toml` with the same settings CI + checks, including the 14-day `exclude-newer` quarantine. It changes no + environment. When the lock is already current, it says so and writes + nothing. (`hermes pm lock --bump NAME VERSION` is a different operation: it + pins a managed tool in `pm/lock.json` and does not touch `uv.lock`.) +3. Source the activation script again (`source ./activate`, or + `. .\activate.ps1` in PowerShell) to sync the application venv and the test + interpreter to the new lock. Activation covers `[all]`. If you added an + opt-in extra outside `[all]`, `hermes pm lock` prints the command that also + puts it in the test interpreter, for example + `source ./activate --test-extras all,NAME`. +4. Commit `pyproject.toml` and `uv.lock` together. + +For JS dependencies, update the owning package manifest and lock. Do not edit +PM facts or generated workspaces, and do not install packages directly into a +selected generation. ### Test and editor environments @@ -515,7 +533,8 @@ not substitutes for an installed application's update mechanism. | Command | Effect | |---|---| -| `pm lock --bump NAME VERSION` | Resolve and hash supported target artifacts, then write the tool pin. | +| `pm lock` | Relock `uv.lock` from `pyproject.toml`. Changes no environment; writes nothing when the lock is current. | +| `pm lock --bump NAME VERSION` | Resolve and hash supported target artifacts, then write the tool pin to `pm/lock.json`. | | `pm update [names...]` | Query upstream versions, change tool pins, and install changed tools. | | `pm update --check` | Query without writing. Exit 1 can mean updates exist; inspect output to distinguish an error. | | `pm update --target TARGET` | Resolve versions for the specified target. |