feat(pm): hermes pm lock relocks uv.lock after a pyproject edit

Contributors had to run `python -m pm.build_env --source . --lock-only`, then
re-source activate, then call sync_venv for opt-in extras, while `hermes pm
lock` only pinned tool artifacts in pm/lock.json. Now `hermes pm lock` with no
arguments is the one step: it runs the same PM check_project_lock /
lock_project operations as build_env's --check-lock / --lock-only (same
exclude-newer quarantine), writes nothing when the lock is current, changes
no environment, and prints the activate command to run next. Activation
syncs [all] plus recorded extras only, and --test-extras replaces the
default, so a newly added extra outside [all] gets
`source ./activate --test-extras all,NAME` (`-TestExtras` on PowerShell).

`hermes pm lock --bump NAME VERSION` keeps writing pm/lock.json; NAME and
VERSION are now only accepted together. build_env --lock-only is unchanged
for scripts. Contributor docs, AGENTS.md, CONTRIBUTING.es.md, the pyproject
comment, and the uv.lock CI remediation text point at `hermes pm lock`.
This commit is contained in:
ethernet
2026-09-24 15:54:24 -04:00
parent a5bd7e574e
commit 99768fd127
8 changed files with 206 additions and 27 deletions

View File

@@ -30,12 +30,12 @@ name: uv.lock check
# #
# git fetch origin main # git fetch origin main
# git rebase origin/main # or merge, whatever the repo prefers # 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 add uv.lock
# git commit -m "chore: refresh uv.lock after rebase onto main" # git commit -m "chore: refresh uv.lock after rebase onto main"
# git push --force-with-lease # if you rebased # 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 # at the same time — one regeneration covers both your changes and the
# drift from main. # drift from main.
# #
@@ -95,7 +95,7 @@ jobs:
# registry, versus "lockfile needs to be updated" when the lock is # registry, versus "lockfile needs to be updated" when the lock is
# genuinely stale. Only the second is the contributor's to fix, so # genuinely stale. Only the second is the contributor's to fix, so
# an unreachable registry says so instead of sending them to # 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 ok=false
net_fail=false net_fail=false
delays=(5 15 45) delays=(5 15 45)
@@ -141,13 +141,13 @@ jobs:
```bash ```bash
git fetch origin main git fetch origin main
git rebase origin/main # or `git merge 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 add uv.lock
git commit -m "chore: refresh uv.lock after syncing with main" git commit -m "chore: refresh uv.lock after syncing with main"
git push --force-with-lease # drop --force-with-lease if you merged 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. and commit the result.
This check is blocking because the Docker image build uses 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 — catching it here avoids a ~15 min failed docker run
on `main` post-merge. on `main` post-merge.
EOF 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." 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 `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"}]}]' 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}" >> "$GITHUB_OUTPUT"
echo "review_status=${review_status}" > review-status.json echo "review_status=${review_status}" > review-status.json
exit 1 exit 1

View File

@@ -326,8 +326,8 @@ All dependencies carry upper bounds (litellm compromise #2796/#2810; Mini Shai-H
May 2026). PyPI: `>=floor,<next_major` (`"httpx>=0.28.1,<1"`); pre-1.0: `<0.(minor+2)` May 2026). PyPI: `>=floor,<next_major` (`"httpx>=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 (`>=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. 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` After changing `pyproject.toml`, run `hermes pm lock`, re-source `./activate`, and commit
with the checkout's prepared Python. Reference: #2810 (bounds), #9801 (SHA pinning + audit CI). `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)` 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` for declared runtime extras, `hermes pm install` for setup/sync, and `hermes pm repair`

View File

@@ -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. No modifiques los entornos de Hermes con comandos directos de pip o uv.
En Windows, ejecuta el script de pruebas mediante Bash. 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 ```bash
python -m pm.build_env --source . --lock-only hermes pm lock
source ./activate
``` ```
Para JavaScript, ejecuta `npm ci` en el workspace correspondiente. Consulta Para JavaScript, ejecuta `npm ci` en el workspace correspondiente. Consulta

103
pm/cli.py
View File

@@ -20,8 +20,96 @@ from pm.store import ALL_TARGETS, current_target, hash_url
from pm.update import Resolved, resolve_package, reuse_index_responses from pm.update import Resolved, resolve_package, reuse_index_responses
@reuse_index_responses()
def cmd_lock(args) -> int: 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 <name> <version>: resolve every target's archives, hash them, """--bump <name> <version>: resolve every target's archives, hash them,
write. A target with one archive pins the object; several pin a list. write. A target with one archive pins the object; several pin a list.
Target-independent urls collapse to one "any" artifact.""" Target-independent urls collapse to one "any" artifact."""
@@ -646,9 +734,14 @@ def main(argv=None) -> int:
parser = argparse.ArgumentParser(prog="hermes pm") parser = argparse.ArgumentParser(prog="hermes pm")
sub = parser.add_subparsers(dest="cmd", required=True) sub = parser.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("lock", help="write versions+hashes into pm/lock.json") lock_parser = p = sub.add_parser(
p.add_argument("--bump", dest="name", required=True) "lock", help="relock uv.lock from pyproject.toml (or --bump a tool pin in pm/lock.json)",
p.add_argument("version") 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.set_defaults(func=cmd_lock)
p = sub.add_parser("install", help="install packages (default: all required)") 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) p.set_defaults(func=cmd_update)
args = parser.parse_args(argv) 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 from pm.runtime import is_runtime, run_cli
try: try:

View File

@@ -27,8 +27,8 @@ dependencies = [
# captured by `mistralai>=2.3.0,<3` rather than an exact pin, every # captured by `mistralai>=2.3.0,<3` rather than an exact pin, every
# install in the hours before the quarantine would have pulled it. # install in the hours before the quarantine would have pulled it.
# #
# After a pin change, run `python -m pm.build_env --source . --lock-only` # After a pin change, run `hermes pm lock` so the transitive resolution
# so the transitive resolution stays consistent. Do not introduce ranges # stays consistent. Do not introduce ranges
# without a written justification. # without a written justification.
# #
# Scope rule: only packages used by EVERY hermes session belong here. # Scope rule: only packages used by EVERY hermes session belong here.

View File

@@ -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 - The runner probes repository `.venv`, `venv`, and the standard source-install
venv before falling back to `HERMES_PYTHON`. Each candidate must contain pytest. 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`. - **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. Do not mutate Hermes environments with raw pip or uv commands.
Host-specific tests run on the real host. Use one `@pytest.mark.platforms(...)` Host-specific tests run on the real host. Use one `@pytest.mark.platforms(...)`

View File

@@ -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

View File

@@ -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 environment, not the selected application venv. After changing extras, reactivate
before starting another Python process. before starting another Python process.
For a new project dependency, edit `pyproject.toml` and regenerate `uv.lock`: ### Syncing after you edit pyproject.toml
```bash 1. Edit `pyproject.toml`. Pin every dependency as the
python -m pm.build_env --source . --lock-only [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 ```bash
environment. Use the checkout's prepared Python. For JS dependencies, update hermes pm lock
the owning package manifest and lock. Do not edit PM facts or generated ```
workspaces, and do not install packages directly into a selected generation.
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 ### Test and editor environments
@@ -515,7 +533,8 @@ not substitutes for an installed application's update mechanism.
| Command | Effect | | 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 [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 --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. | | `pm update --target TARGET` | Resolve versions for the specified target. |