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:
14
.github/workflows/uv-lockfile-check.yml
vendored
14
.github/workflows/uv-lockfile-check.yml
vendored
@@ -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
|
||||
|
||||
@@ -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)`
|
||||
(`>=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`
|
||||
|
||||
@@ -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
|
||||
|
||||
103
pm/cli.py
103
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 <name> <version>: 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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(...)`
|
||||
|
||||
62
tests/pm/test_project_relock.py
Normal file
62
tests/pm/test_project_relock.py
Normal 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
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user