feat(pm): isolate developer test environment from runtime extras

This commit is contained in:
ethernet
2026-09-23 18:13:38 -04:00
parent 4e3684c993
commit babbec1c4c
50 changed files with 516 additions and 253 deletions

View File

@@ -10,17 +10,19 @@ not resolve a version range, install another setup action, or modify the lock.
- uses: ./.github/actions/setup-pm
with:
toolchain: all
extras: '["dev"]'
extras: '[]'
test-environment: 'true'
- run: python --version && node --version && npm --version
```
`toolchain` defaults to `python` (Python and PM's private installer). `node` installs Node and npm;
`all` installs both pairs. There are no version overrides. `extras` is a JSON
array because GitHub action inputs are strings. Omit it for tools only; `[]`
installs the core Python dependencies, and `["dev"]` adds the dev extra. PM
checks `uv.lock`, installs the requested dependencies, and validates the environment.
The dev extra uses an independent test environment including the test dependency
group; only non-test installs publish an application selection. It does not enable plugins.
installs the core Python dependencies. Set `test-environment: 'true'` to build an
independent interpreter with the `dev` and `test` dependency groups; any `extras`
then select runtime features in that interpreter. PM checks `uv.lock`, installs
the requested dependencies, and validates the environment. Only non-test installs
publish an application selection. It does not enable plugins.
Subsequent steps get `python`, `python3`, `node`, `npm` and `npx`
for the selected toolchain on PATH. The pinned npm precedes Node's bundled npm.
@@ -51,7 +53,7 @@ The official, SHA-pinned `actions/cache` transports three independent caches:
All restores use the exact primary key, without fallback prefixes, matching
setup-uv and setup-node's npm behavior. Successful jobs save at teardown;
exact hits are not saved again. Only dependency-carrying callers
(`extras` set) auto-save the uv cache: a tool-only job never runs a
(`extras` set or `test-environment: 'true'`) auto-save the uv cache: a tool-only job never runs a
dependency operation, so letting it save would freeze an empty cache under
the production key, where an immutable exact hit blocks real saves forever.
PM re-verifies restored tools before use.

View File

@@ -11,8 +11,11 @@ inputs:
description: Use protected R2 credentials to preserve and retrieve the selected toolchain before installation.
default: 'false'
extras:
description: 'JSON extras: omit for tools; [] installs core; dev selects an isolated test environment plus the test dependency group.'
description: 'JSON runtime extras: omit for tools; [] installs core. Used as test coverage extras with test-environment.'
default: ''
test-environment:
description: 'Build an isolated interpreter with the dev and test groups; never publish these to the runtime environment.'
default: 'false'
cache:
description: Cache the verified PM tool store.
default: 'true'
@@ -152,7 +155,7 @@ runs:
- name: Cache uv dependency downloads and builds
id: python-cache
if: inputs.toolchain != 'node' && inputs.cache-python == 'true' && inputs.save-python-cache == 'true' && inputs.extras != ''
if: inputs.toolchain != 'node' && inputs.cache-python == 'true' && inputs.save-python-cache == 'true' && (inputs.extras != '' || inputs.test-environment == 'true')
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.install.outputs.uv-cache-path }}
@@ -216,14 +219,17 @@ runs:
key: setup-pm-npm-v1-${{ inputs.cache-suffix || 'production' }}-${{ steps.prepare.outputs.target }}-${{ steps.prepare.outputs.npm-version }}-${{ hashFiles(inputs.node-cache-dependency-path) }}-${{ github.job }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: setup-pm-npm-v1-${{ inputs.cache-suffix || 'production' }}-${{ steps.prepare.outputs.target }}-${{ steps.prepare.outputs.npm-version }}-${{ hashFiles(inputs.node-cache-dependency-path) }}-${{ github.job }}-
- name: Install the requested Python extras through PM
- name: Install the requested Python dependencies through PM
id: dependencies
if: inputs.extras != ''
if: inputs.extras != '' || inputs.test-environment == 'true'
shell: bash
env:
_PM_ACTION: ${{ github.action_path }}
_PM_TOOLCHAIN: ${{ inputs.toolchain }}
_PM_EXTRAS: ${{ inputs.extras }}
_PM_TEST_ENVIRONMENT: ${{ inputs.test-environment }}
run: |
args=()
if [ "$_PM_TEST_ENVIRONMENT" = true ]; then args+=(--test-environment); fi
"$HERMES_PYTHON" -S "$_PM_ACTION/../../../scripts/ci/setup_toolchain.py" dependencies \
--toolchain "$_PM_TOOLCHAIN" --extras "$_PM_EXTRAS" --home "$HERMES_HOME"
--toolchain "$_PM_TOOLCHAIN" --extras "$_PM_EXTRAS" --home "$HERMES_HOME" "${args[@]}"

View File

@@ -238,7 +238,8 @@ jobs:
- name: Set up locked Python and test dependencies
uses: ./.github/actions/setup-pm
with:
extras: '["dev"]'
extras: '[]'
test-environment: 'true'
prune-python-cache: true
- name: Restore the image install stamp for the docker tests

View File

@@ -43,7 +43,8 @@ jobs:
uses: ./.github/actions/setup-pm
with:
toolchain: all
extras: '["all", "dev"]'
extras: '["all"]'
test-environment: 'true'
prune-python-cache: true
# Full npm ci (not --ignore-scripts): electron's postinstall

View File

@@ -26,7 +26,8 @@ jobs:
# the electron contracts drive real hermes_cli/pm code through
# HERMES_PYTHON, which needs the application dependencies.
toolchain: all
extras: '["dev"]'
extras: '[]'
test-environment: 'true'
- name: Install zsh and a virtual display for Electron
if: runner.os == 'Linux'

View File

@@ -51,7 +51,8 @@ jobs:
sudo apt-get install -y patchelf
- uses: ./.github/actions/setup-pm
with:
extras: '["dev"]'
extras: '[]'
test-environment: 'true'
cache-python: false
- name: Run the native linker and wheel contracts
run: |

View File

@@ -82,7 +82,8 @@ jobs:
uses: ./.github/actions/setup-pm
with:
packages: ripgrep
extras: '["all", "dev", "telegram", "anthropic", "mistral", "fal", "modal", "daytona", "hindsight", "parallel-web"]'
extras: '["all", "telegram", "anthropic", "mistral", "fal", "modal", "daytona", "hindsight", "parallel-web"]'
test-environment: 'true'
prune-python-cache: true
- name: Run ${{ matrix.marker }} tests

View File

@@ -44,7 +44,8 @@ jobs:
- name: Set up locked Python and test dependencies
uses: ./.github/actions/setup-pm
with:
extras: '["all", "dev", "anthropic", "bedrock", "mistral", "fal", "modal", "daytona", "hindsight", "parallel-web"]'
extras: '["all", "anthropic", "bedrock", "mistral", "fal", "modal", "daytona", "hindsight", "parallel-web"]'
test-environment: 'true'
prune-python-cache: true
- name: Restore per-file duration cache
@@ -137,7 +138,8 @@ jobs:
- name: Set up locked Python and test dependencies
uses: ./.github/actions/setup-pm
with:
extras: '["all", "dev", "anthropic", "bedrock", "mistral", "fal", "modal", "daytona", "hindsight", "parallel-web"]'
extras: '["all", "anthropic", "bedrock", "mistral", "fal", "modal", "daytona", "hindsight", "parallel-web"]'
test-environment: 'true'
prune-python-cache: true
- name: Run e2e tests

View File

@@ -51,7 +51,8 @@ jobs:
- name: Set up locked Python and test dependencies
uses: ./.github/actions/setup-pm
with:
extras: '["dev", "messaging"]'
extras: '["messaging"]'
test-environment: 'true'
prune-python-cache: true
- name: Run venv-holder live E2E

View File

@@ -367,7 +367,7 @@ with API keys set has caused repeated "works locally, fails in CI" incidents (an
Prepare a test interpreter with the checkout's bootstrapped Python:
```bash
python -m pm.build_env --source . --out .venv --extra dev --group test
python -m pm.build_env --source . --out .venv --group dev --group test
```
This is a fresh build, not an in-place sync. If the disposable output exists,

View File

@@ -94,7 +94,7 @@ hermes --version
Usa el Python preparado por PM para crear un entorno nuevo:
```bash
python -m pm.build_env --source . --out .venv --extra dev --group test
python -m pm.build_env --source . --out .venv --group dev --group test
scripts/run_tests.sh tests/agent/ -v
```

View File

@@ -153,7 +153,7 @@ architecture before building source dependencies.
Build an independent interpreter for tests and editor tools:
```bash
python -m pm.build_env --source . --out .venv --extra dev --group test
python -m pm.build_env --source . --out .venv --group dev --group test
```
PM builds from the committed lock and checks dependency consistency before

View File

@@ -279,7 +279,7 @@ RUN cd plugins/platforms/photon/sidecar && \
#
# `pm.build_env --no-install-project --extra all --extra messaging --extra otlp`
# installs the deps reachable through the composite `[all]` extra
# (handpicked set intended for the production image — excludes `[dev]`),
# (handpicked set intended for the production image; dependency groups are not selected),
# plus gateway messaging adapters that should work in the published image
# without a first-boot lazy install. We do NOT use `--all-extras`:
# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from

View File

@@ -18,15 +18,41 @@
# install` and `hermes update` keep that check.
# ============================================================================
_HERMES_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Options: `--test-extras a,b` selects the test environment's runtime extras
# (default: [all]); `--` ends options.
# A bare `source ./activate` inherits the caller's positional parameters.
# Ignore unrelated words so scripts with their own arguments can source it.
_hermes_test_env="--test-environment"
_hermes_expect_extras=""
for _hermes_arg in "$@"; do
if [ -n "$_hermes_expect_extras" ]; then
_hermes_test_env="--test-environment=$_hermes_arg"
_hermes_expect_extras=""
continue
fi
case "$_hermes_arg" in
--) break ;;
--test-extras) _hermes_expect_extras=1 ;;
--test-extras=*) _hermes_test_env="--test-environment=${_hermes_arg#--test-extras=}" ;;
*) : ;;
esac
done
if [ -n "$_hermes_expect_extras" ]; then
printf '%s\n' 'activate: --test-extras needs a comma-separated list' >&2
unset _hermes_arg _hermes_test_env _hermes_expect_extras
return 2 2>/dev/null || exit 2
fi
# Setup runs in a child: failures must not exit or partially activate the
# caller's shell, and activation must not republish launchers or shell config.
if ! (
unset PYTHONHOME PYTHONPATH VIRTUAL_ENV
bash "$_HERMES_REPO/setup-hermes.sh" --runtime-only
bash "$_HERMES_REPO/setup-hermes.sh" --runtime-only "$_hermes_test_env"
) >&2; then
printf '%s\n' 'activate: setup failed; shell environment unchanged' >&2
unset _hermes_arg _hermes_test_env _hermes_expect_extras
return 1 2>/dev/null || exit 1
fi
unset _hermes_arg _hermes_test_env _hermes_expect_extras
# Guard against double-sourcing: venv activate precedent (re-activating is a
# no-op re-save; we keep it idempotent by deactivating first). Key on the

View File

@@ -1,5 +1,7 @@
# Source this file to sync and apply the PM environment; deactivate restores it.
# Trusts the recorded tool digest. `hermes pm install` re-checks the bytes.
# -TestExtras a,b selects runtime extras in the test environment (default: [all]).
param([string]$TestExtras = '')
$ErrorActionPreference = 'Stop'
$OutputEncoding = [System.Console]::OutputEncoding = [System.Console]::InputEncoding = [System.Text.Encoding]::UTF8
@@ -14,7 +16,9 @@ foreach ($key in @('PYTHONPATH', 'PYTHONHOME', 'VIRTUAL_ENV')) {
try {
# Run separately so setup's exit/failure cannot terminate the sourced shell.
$shell = (Get-Process -Id $PID).Path
& $shell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$repo\setup-hermes.ps1" -RuntimeOnly | Out-Host
$testArgs = @()
if ($TestExtras) { $testArgs = @('-TestExtras', $TestExtras) }
& $shell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$repo\setup-hermes.ps1" -RuntimeOnly @testArgs | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'activate: setup failed; shell environment unchanged' }
} finally {
foreach ($key in $bootstrapSaved.Keys) {

View File

@@ -51,7 +51,7 @@ NAV_OUT=out/ python evals/codebase_navigability/runtime_bench.py . head
Use a fresh benchmark path; do not replace an existing environment. Runtime and
pytest-collection measurements also require the target tree's application/test
dependencies. Prepare those in a separate caller-owned output with
`python -m pm.build_env --source <tree> --out <fresh-output> --extra dev --group test`
`python -m pm.build_env --source <tree> --out <fresh-output> --group dev --group test`
from a PM-prepared checkout, rather than injecting benchmark packages into Hermes.
`bench.py` and `static_metrics.py` take ~2 min each on a 1M-line tree; `lookup_sim.py` ~10 min for

View File

@@ -26,7 +26,7 @@ _EXPORTS = {
),
"pm.client": (
"ensure", "sync_venv", "build_environment", "lock_project", "stage_manager_runtime",
"ensure_environment", "ensure_python_tool", "venv_is_current", "check_project_lock",
"ensure_environment", "ensure_project_environment", "ensure_python_tool", "venv_is_current", "check_project_lock",
"export_requirements", "build_requirements_environment", "prune_cache", "stage_tools",
"prepare_tools",
),

View File

@@ -167,6 +167,10 @@ def cmd_install(args) -> int:
extras = list(dict.fromkeys(getattr(args, "extra", None) or ()))
tools_only = bool(getattr(args, "tools_only", False))
trust_recorded = bool(getattr(args, "trust_recorded", False))
test_environment = getattr(args, "test_environment", None)
if test_environment is not None and (extras or cross_target or args.names or tools_only):
print("✗ --test-environment builds beside the default closure; it does not take names, --extra, --target, or --tools-only")
return 1
if tools_only and (extras or cross_target or args.names):
print("✗ --tools-only installs the tool closure and then stops; it does not take names, --extra, or --target")
return 1
@@ -214,10 +218,24 @@ def cmd_install(args) -> int:
except InstallError as e:
print(f"✗ {e}")
failed += 1
if test_environment is not None and not failed:
from pm import check_project_lock
from pm.testenv import ensure_testenv, parse_extras
# Before the input stamps: they then cover this environment too, so
# the shebang/run_tests.sh staleness check rebuilds it when it drifts.
try:
check_project_lock(repo_root(), explicit=True)
ensure_testenv(repo_root(), parse_extras(test_environment))
print("✓ test environment")
except InstallError as e:
print(f"✗ {e}")
failed += 1
if full_closure and not failed:
from pm.environments import activation_inputs_dir, record_activation_inputs
record_activation_inputs(activation_inputs_dir(repo_root()), input_mtimes)
record_activation_inputs(activation_inputs_dir(repo_root()), input_mtimes, repo_root(),
test_environment=test_environment is not None)
return 1 if failed else 0
@@ -588,6 +606,9 @@ def main(argv=None) -> int:
p.add_argument("--trust-recorded", action="store_true",
help="trust the recorded tool digest instead of re-hashing every entry. "
"shell activation only. a deliberate install re-checks the bytes")
p.add_argument("--test-environment", nargs="?", const="", default=None, metavar="EXTRAS",
help="also make this checkout's isolated test environment current (activation). "
"EXTRAS is comma-separated; omitted selects [all]")
p.add_argument(
"--target",
help="stage for a cross target (e.g. linux-arm64-bionic on a glibc "

View File

@@ -290,6 +290,17 @@ def ensure_environment(
}))
def ensure_project_environment(
name: str, project: Path, *, extras: Sequence[str] = (), groups: Sequence[str] = (),
root: Path | None = None, explicit: bool = False, timeout: int = 1800,
) -> Path:
"""Select an isolated environment of the project's locked dependencies."""
return Path(_python_operation("ensure_project_environment", {
"name": name, "project": Path(project), "extras": list(extras), "groups": list(groups),
"root": root, "explicit": explicit, "timeout": timeout,
}))
def ensure_python_tool(
name: str, requirements: Sequence[str], executable: str, *, root: Path | None = None,
explicit: bool = False, timeout: int = 1800,

View File

@@ -56,7 +56,7 @@ def activation_input_mtimes(project_root: Path) -> dict[str, int]:
return {name: (root / name).stat().st_mtime_ns for name in ACTIVATION_INPUTS if (root / name).is_file()}
def record_activation_inputs(stamps: Path, mtimes: dict[str, int]) -> None:
def record_activation_inputs(stamps: Path, mtimes: dict[str, int], project_root: Path, *, test_environment: bool) -> None:
"""Give each stamp the exact mtime of the input the install was verified against.
Recorded on every successful install, including no-op syncs: a checkout that
@@ -67,6 +67,12 @@ def record_activation_inputs(stamps: Path, mtimes: dict[str, int]) -> None:
import shutil
shutil.rmtree(stamps, ignore_errors=True)
# The sentinel is inherited by child shells; equal input mtimes in another
# checkout must never make their test interpreter appear current here.
stamps.mkdir(parents=True, exist_ok=True)
(stamps / ".project-root").write_text(str(Path(project_root).resolve()), encoding="utf-8")
if test_environment:
(stamps / ".test-environment").touch()
for name, mtime in mtimes.items():
stamp = stamps / name
stamp.parent.mkdir(parents=True, exist_ok=True)
@@ -293,6 +299,13 @@ def activation_environment(project_root: Path) -> dict[str, str]:
# directory also holds activation_inputs_dir, the input-mtime stamps
# `scripts/_hermes-python` compares against to decide staleness.
env["__HERMES_ACTIVATED"] = str(runtime_facts_path(project_root))
# The suite's interpreter (pm.testenv): an isolated side environment, so it
# never appears on PYTHONPATH/PATH above. scripts/run_tests.sh reads it.
from pm.testenv import testenv_python
test_python = testenv_python(project_root)
if test_python is not None:
env["__HERMES_TEST_PYTHON"] = str(test_python)
return env

View File

@@ -5,7 +5,7 @@ Only the private environment engine knows how to obtain or invoke uv.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Mapping, Sequence
import hashlib
import json
import os
@@ -189,9 +189,69 @@ def ensure_environment(
) -> Path:
"""Make an isolated dependency set current, then atomically select it.
An optional tool entrypoint is validated before publication, not afterwards.
"""
root = _environment_root(name, root)
requirements = _requirements(requirements)
if executable is not None:
_tool(Path("unused/python"), executable) # Validate before any write.
def build(generation: Path) -> Path:
(generation / "pyproject.toml").write_text(
'[project]\nname = "hermes-side-environment"\nversion = "0"\n'
'requires-python = ">=3.11"\ndependencies = '
+ json.dumps(requirements) + '\n[tool.uv]\npackage = false\n', encoding="utf-8",
)
previous = _selection(root)
if previous:
seed = root / previous["generation"] / "uv.lock"
if seed.is_file():
shutil.copyfile(seed, generation / "uv.lock")
return build_environment(source=generation, out=generation / "venv", frozen=False,
explicit=explicit, timeout=timeout)
return _ensure_generation(name, root, {"requirements": requirements}, build,
record={"requirements": requirements}, explicit=explicit,
executable=executable)
def ensure_project_environment(
name: str, project: Path, *, extras: Sequence[str] = (), groups: Sequence[str] = (),
root: Path | None = None, explicit: bool = False, timeout: int = 1800,
) -> Path:
"""Make an isolated environment of a project's LOCKED dependencies current.
The project itself is not installed, and nothing reaches PM facts or the
selected application generation: this serves side environments such as the
test suite's. The identity covers the lock and manifest bytes, so any edit
that can change the resolved set selects a fresh generation.
"""
root = _environment_root(name, root)
project = Path(project).absolute()
extras, groups = sorted(set(extras)), sorted(set(groups))
manifests = {}
for manifest in ("pyproject.toml", "uv.lock"):
try:
manifests[manifest] = hashlib.sha256((project / manifest).read_bytes()).hexdigest()
except FileNotFoundError as exc:
raise InstallError("venv", f"locked project environment needs {project / manifest}") from exc
def build(generation: Path) -> Path:
return build_environment(source=project, out=generation / "venv", extras=extras, groups=groups,
no_install_project=True, frozen=True, explicit=explicit, timeout=timeout)
return _ensure_generation(name, root, {"manifests": manifests, "extras": extras, "groups": groups},
build, record={"extras": extras, "groups": groups}, explicit=explicit)
def _ensure_generation(
name: str, root: Path, inputs: dict, build: Callable[[Path], Path], *, record: dict, explicit: bool,
executable: str | None = None,
) -> Path:
"""Select the generation whose inputs match, building one only when none does.
Build at the final path: Windows launchers and scripts embed that path.
The prior generation survives both successful replacement and failed builds.
An optional tool entrypoint is validated before publication, not afterwards.
"""
from hermes_cli.runtime_state import _lock
from pm.install import _refuse_lazy, lazy_installs_allowed
@@ -199,20 +259,16 @@ def ensure_environment(
from pm import paths
from pm.store import current_target
root = _environment_root(name, root)
requirements = _requirements(requirements)
if executable is not None:
_tool(Path("unused/python"), executable) # Validate before any write.
lock = Lockfile(paths.lockfile_path())
target = current_target()
inputs = {"requirements": requirements, "python": lock.version("python"), "target": target,
inputs = {**inputs, "python": lock.version("python"), "target": target,
"artifacts": [item["sha256"] for item in lock.artifacts("python", target)]}
identity = hashlib.sha256(json.dumps(inputs, sort_keys=True).encode()).hexdigest()
def current() -> Path | None:
record = _selection(root)
selected = _selection(root)
python = environment_python(name, root=root)
if (record.get("inputs") == identity and python is not None
if (selected.get("inputs") == identity and python is not None
and (executable is None or _tool(python, executable) is not None)):
return python
return None
@@ -231,22 +287,10 @@ def ensure_environment(
generation = root / f"gen-{uuid.uuid4().hex}"
generation.mkdir()
try:
(generation / "pyproject.toml").write_text(
'[project]\nname = "hermes-side-environment"\nversion = "0"\n'
'requires-python = ">=3.11"\ndependencies = '
+ json.dumps(requirements) + '\n[tool.uv]\npackage = false\n', encoding="utf-8",
)
previous = _selection(root)
if previous:
seed = root / previous["generation"] / "uv.lock"
if seed.is_file():
shutil.copyfile(seed, generation / "uv.lock")
python = build_environment(source=generation, out=generation / "venv", frozen=False,
explicit=explicit, timeout=timeout)
python = build(generation)
if executable is not None and _tool(python, executable) is None:
raise InstallError(name, f"installed requirements do not provide {executable!r}")
_write(root / "active.json", {"generation": generation.name, "inputs": identity,
"requirements": requirements})
_write(root / "active.json", {"generation": generation.name, "inputs": identity, **record})
except BaseException:
shutil.rmtree(generation, ignore_errors=True)
raise

45
pm/testenv.py Normal file
View File

@@ -0,0 +1,45 @@
"""The isolated environment a checkout's test suite runs under.
Activation builds it beside the checkout's install state, and CI builds it the
same way. It carries the locked project dependencies plus the ``dev`` and
``test`` groups. It is a side environment
(``ensure_project_environment``), never the selected application generation:
test-only dependencies must not enter PM facts or anything that ships.
"""
from __future__ import annotations
from collections.abc import Sequence
from pathlib import Path
from pm.environments import install_state_dir
NAME = "test-environment"
# The default developer environment includes the app's normal features.
# CI lanes pass their wider provider matrix explicitly.
DEFAULT_TEST_EXTRAS = ("all",)
GROUPS = ("dev", "test")
def testenv_root(project_root: Path) -> Path:
return install_state_dir(project_root) / NAME
def ensure_testenv(project_root: Path, extras: Sequence[str] | None = None) -> Path:
"""Build the test environment unless its locked inputs are unchanged; return its python."""
from pm import ensure_project_environment
chosen = sorted(set(DEFAULT_TEST_EXTRAS if extras is None else extras))
return ensure_project_environment(NAME, project_root, extras=chosen, groups=GROUPS,
root=testenv_root(project_root), explicit=True)
def testenv_python(project_root: Path) -> Path | None:
"""The selected test interpreter, read without acquiring tools or writing state."""
from pm.operations import environment_python
return environment_python(NAME, root=testenv_root(project_root))
def parse_extras(value: str) -> list[str] | None:
"""An empty value selects default extras; commas and spaces separate overrides."""
return value.replace(",", " ").split() or None

View File

@@ -30,6 +30,7 @@ OPERATIONS = {
"build_environment": Operation("pm.operations", ("uv",), "policy"),
"lock_project": Operation("pm.operations", ("uv",), "policy"),
"ensure_environment": Operation("pm.operations", ("uv",), "policy"),
"ensure_project_environment": Operation("pm.operations", ("uv",), "policy"),
"ensure_python_tool": Operation("pm.operations", ("uv",), "policy"),
"check_project_lock": Operation("pm.build_operations", ("uv",), "policy"),
"export_requirements": Operation("pm.build_operations", ("uv",), "policy"),

View File

@@ -241,18 +241,6 @@ daytona = ["daytona==0.155.0"]
vercel = ["vercel==0.7.2"]
hindsight = ["hindsight-client==0.6.1"]
google-meet = ["playwright==1.62.0", "websockets==15.0.1"]
dev = [
"debugpy==1.8.20",
"pytest==9.1.1",
"pytest-asyncio==1.3.0",
"mcp==2.0.0",
"httpx2==2.7.0",
"starlette==1.3.1",
"ty==0.0.82",
"ruff==0.15.10",
"setuptools==83.0.0",
] # starlette: CVE-2026-48710; setuptools: 83 (torch >=2.13 requires setuptools 83)
messaging = [
"python-telegram-bot[webhooks]==22.8",
"discord.py[voice]==2.7.1",
@@ -531,7 +519,18 @@ all = [
]
[dependency-groups]
# Build tooling is not a runtime extra: payloads select --all-extras.
# Build and test tooling never ships in runtime payloads.
dev = [
"debugpy==1.8.20",
"pytest==9.1.1",
"pytest-asyncio==1.3.0",
"mcp==2.0.0",
"httpx2==2.7.0",
"starlette==1.3.1",
"ty==0.0.82",
"ruff==0.15.10",
"setuptools==83.0.0",
] # starlette: CVE-2026-48710; setuptools: 83 (torch >=2.13 requires setuptools 83)
# --only-group supplies Pillow without installing the Hermes application.
icon-build = ["Pillow==12.3.0", "resvg-py==0.4.0"]
# Native launcher acceptance is required in test environments, not payloads.
@@ -579,6 +578,8 @@ kittentts = "python_version < '3.13'"
piper = "(platform_machine != 'ARM64' or sys_platform != 'win32') and (platform_machine != 'x86_64' or sys_platform != 'darwin')"
[tool.uv]
# uv otherwise enables the dev group by default, including in payload builds.
default-groups = []
# 3.11 is an install bridge for pre-PM updaters, never a runtime, so the lock
# covers 3.14 only. Without this the lock must resolve every supported version,
# where extras that only ever coexist on 3.14 (kittentts vs neutts) conflict.

50
scripts/_activation.sh Normal file
View File

@@ -0,0 +1,50 @@
# Sourced, never executed: the activation staleness check shared by
# scripts/_hermes-python (the shebang prologue) and scripts/run_tests.sh.
#
# pm records, beside the installed-state file named by __HERMES_ACTIVATED, one
# stamp per dependency input carrying the exact mtime that input had when the
# install was last verified against it (pm.environments.record_activation_inputs).
# Any input whose mtime DIFFERS from its stamp (newer or older, since a branch
# switch can move it either way) means the inherited environment may not match
# its inputs. `-nt`/`-ot` are bash builtins, so the check costs no process
# spawn, and every successful activation re-records, so one re-activation
# settles it. Missing stamps or inputs, a dangling sentinel or a literal "1"
# all read as stale.
# hermes_activation_current REPO: status 0 when the inherited environment
# matches REPO's current inputs.
hermes_activation_current() {
local repo="$1" sentinel stamps stamp input stamped=0
[ -n "${__HERMES_ACTIVATED:-}" ] && [ -e "${__HERMES_ACTIVATED}" ] || return 1
# activate.ps1 records a Windows path; Git Bash accepts it with slashes.
sentinel="${__HERMES_ACTIVATED//\\//}"
stamps="${sentinel%/*}/inputs"
[ -f "$stamps/.project-root" ] || return 1
local owner="$repo" recorded
recorded="$(< "$stamps/.project-root")"
if command -v cygpath >/dev/null 2>&1; then
owner="$(cygpath -am "$repo")" || return 1
recorded="${recorded//\\//}"
[ "${owner,,}" = "${recorded,,}" ] || return 1
else
[ "$(cd "$owner" && pwd -P)" = "$recorded" ] || return 1
fi
for stamp in "$stamps"/* "$stamps"/*/*; do
[ -f "$stamp" ] || continue
stamped=1
input="$repo/${stamp#"$stamps"/}"
if [ ! -e "$input" ] || [ "$input" -nt "$stamp" ] || [ "$input" -ot "$stamp" ]; then
return 1
fi
done
[ "$stamped" = 1 ]
}
# hermes_ensure_activated REPO: source REPO/activate unless the inherited
# environment is current. `--` passes no options: `source` without arguments
# would hand activate the CALLER's positional parameters.
hermes_ensure_activated() {
hermes_activation_current "$1" && return 0
# shellcheck source=/dev/null
. "$1/activate" --
}

View File

@@ -6,8 +6,9 @@
#
# The kernel appends the invoking script, so `$1` is the Python file to run
# and `$@` its arguments. Activates when there is no environment to inherit, or
# when the inherited one predates its inputs; then execs the interpreter on the
# same file, so tracebacks and __file__ point at the real script.
# when the inherited one no longer matches its inputs (scripts/_activation.sh);
# then execs the interpreter on the same file, so tracebacks and __file__ point
# at the real script.
set -u
target=${1:-}
@@ -24,35 +25,8 @@ if [ ! -f "$_here/activate" ]; then
exit 1
fi
# pm records, beside the installed-state file named by __HERMES_ACTIVATED, one
# stamp per dependency input carrying the exact mtime that input had when the
# install was last verified against it (pm.environments.record_activation_inputs).
# Any input whose mtime DIFFERS from its stamp — newer or older, since a branch
# switch can move it either way — means the inherited environment may not match
# its inputs. `-nt`/`-ot` are bash builtins, so this costs no process spawn, and
# every successful activation re-records, so one re-activation settles it. No
# stamps (an install from before stamps existed), a missing input, a dangling
# sentinel or a legacy literal "1" all activate.
_needs_activation=1
if [ -n "${__HERMES_ACTIVATED:-}" ] && [ -e "${__HERMES_ACTIVATED}" ]; then
_stamps="${__HERMES_ACTIVATED%/*}/inputs"
_needs_activation=0
_stamped=0
for _stamp in "$_stamps"/* "$_stamps"/*/*; do
[ -f "$_stamp" ] || continue
_stamped=1
_input="$_here/${_stamp#"$_stamps"/}"
if [ ! -e "$_input" ] || [ "$_input" -nt "$_stamp" ] || [ "$_input" -ot "$_stamp" ]; then
_needs_activation=1
break
fi
done
[ "$_stamped" = 1 ] || _needs_activation=1
fi
if [ "$_needs_activation" = 1 ]; then
# shellcheck source=/dev/null
. "$_here/activate" || exit 1
fi
# shellcheck source=scripts/_activation.sh
. "$_here/scripts/_activation.sh"
hermes_ensure_activated "$_here" || exit 1
exec python3 "$target" "$@"

View File

@@ -183,28 +183,30 @@ def install(args) -> None:
def dependencies(args) -> None:
if args.extras is None:
if args.extras is None and not args.test_environment:
return
import tomllib
from pm.environments import selected_venv
from pm import build_environment, check_project_lock, sync_venv
from pm import check_project_lock, sync_venv
from pm.paths import repo_root
project = repo_root()
metadata = tomllib.loads((project / "pyproject.toml").read_text(encoding="utf-8-sig"))
unknown = set(args.extras) - metadata["project"]["optional-dependencies"].keys()
extras = args.extras or []
unknown = set(extras) - metadata["project"].get("optional-dependencies", {}).keys()
if unknown:
raise ValueError(f"unknown project extras: {sorted(unknown)}")
# Frozen sync must not turn a stale project lock into a green job.
check_project_lock(project, explicit=True)
if "dev" in args.extras:
# Test-only groups must not enter PM facts or a shipped generation.
venv = args.home.resolve() / "test-environment"
build_environment(source=project, out=venv, extras=args.extras,
groups=["test"], no_install_project=True, explicit=True)
if args.test_environment:
from pm.testenv import ensure_testenv
# CI and activation select the same side environment, never PM facts.
python = ensure_testenv(project, extras)
venv = python.parent.parent
else:
sync_venv(args.extras, explicit=True, plugin_dirs=[])
sync_venv(extras, explicit=True, plugin_dirs=[])
venv = selected_venv(project)
bindir = venv / ("Scripts" if os.name == "nt" else "bin")
python = bindir / ("python.exe" if os.name == "nt" else "python")
@@ -226,10 +228,11 @@ def main() -> None:
parser.add_argument("--toolchain", choices=["python", "node", "all"], default="python")
parser.add_argument("--home", type=Path, required=True)
parser.add_argument("--extras", type=parse_extras, default="")
parser.add_argument("--test-environment", action="store_true")
parser.add_argument("--packages", type=parse_package_list, default=[], help="extra PM tools beyond the toolchain roots (e.g. ffmpeg)")
args = parser.parse_args()
if args.toolchain == "node" and args.extras is not None:
parser.error("extras require the python or all toolchain")
if args.toolchain == "node" and (args.extras is not None or args.test_environment):
parser.error("Python dependencies require the python or all toolchain")
os.environ["HERMES_HOME"] = str(args.home.resolve())
os.environ["HERMES_RUNTIME_DIR"] = str(args.home.resolve() / "tools")
phases[args.phase](args)

View File

@@ -11,7 +11,7 @@
# * Env vars blanked (conftest.py also does this, but this
# is belt-and-suspenders for anyone running pytest outside our
# conftest path — e.g. on a single file)
# * Proper venv activation (probes .venv, venv, then ~/.hermes/...)
# * The activated checkout's test environment (activates when needed)
#
# Usage:
# scripts/run_tests.sh # full suite
@@ -38,64 +38,45 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# ── Locate python ───────────────────────────────────────────────────────────
# Probe local venvs first; fall back to the Nix devShell's editable venv
# (HERMES_PYTHON is exported by the devShell hook and ships [dev] extras:
# pytest, pytest-asyncio, pytest-timeout, ruff, ty).
# The suite runs under the activated checkout's isolated test environment
# (pm.testenv: `activate` builds it beside the checkout's install state, and CI
# activates the same way). An inherited activation is re-checked against its
# inputs (scripts/_activation.sh) and re-sourced when stale, so a branch switch
# or lock edit never runs the suite against the previous dependency set.
#
# A candidate must have pytest INSTALLED, not merely exist. The release venv
# at ~/.hermes/hermes-agent/venv has bin/activate but no pytest, so an
# existence-only probe selected it in checkouts/worktrees without a local
# .venv — every file then died with "No module named pytest" and the run
# reported "0 tests passed" (which reads green at a glance even though the
# exit code is 1). Skip such a venv and keep probing instead.
VENV=""
VENV_PYTHON=""
SKIPPED_VENVS=""
for candidate in "$REPO_ROOT/.venv" "$REPO_ROOT/venv" "$HOME/.hermes/hermes-agent/venv"; do
if [ -f "$candidate/bin/activate" ]; then
if "$candidate/bin/python" -c 'import pytest' 2>/dev/null; then
VENV="$candidate"
VENV_PYTHON="$candidate/bin/python"
break
fi
SKIPPED_VENVS="$SKIPPED_VENVS $candidate"
fi
# Native Windows venv layout: python.exe and activate live under
# Scripts/, and there is no bin/. Anyone running this script from
# Git Bash / MSYS with a `python -m venv`- or uv-created venv hits
# this branch — without it the canonical runner refuses to start.
if [ -f "$candidate/Scripts/activate" ]; then
if "$candidate/Scripts/python.exe" -c 'import pytest' 2>/dev/null; then
VENV="$candidate"
VENV_PYTHON="$candidate/Scripts/python.exe"
break
fi
SKIPPED_VENVS="$SKIPPED_VENVS $candidate"
fi
done
if [ -n "$SKIPPED_VENVS" ]; then
for skipped in $SKIPPED_VENVS; do
echo "▶ skipping venv without pytest: $skipped" >&2
done
fi
if [ -n "$VENV" ]; then
PYTHON="$VENV_PYTHON"
elif [ -n "${HERMES_PYTHON:-}" ] && [ -x "$HERMES_PYTHON" ] \
&& "$HERMES_PYTHON" -c 'import pytest' 2>/dev/null; then
# Guard with an import check: HERMES_PYTHON may point at the RELEASE
# venv (no pytest) when inherited from a wrapped `hermes` binary rather
# than the devShell hook.
# Without an activation, an explicit HERMES_PYTHON that has pytest is honored:
# the Nix devShell's editable venv and CI's minimal installer lanes provide
# one on purpose. The import check matters: a wrapped `hermes` binary exports
# HERMES_PYTHON pointing at a release venv without pytest.
_has_pytest() { [ -n "$1" ] && [ -x "$1" ] && "$1" -c 'import pytest' 2>/dev/null; }
# shellcheck source=scripts/_activation.sh
. "$SCRIPT_DIR/_activation.sh"
if [ -z "${__HERMES_ACTIVATED:-}" ] && _has_pytest "${HERMES_PYTHON:-}"; then
PYTHON="$HERMES_PYTHON"
echo "▶ no local venv — using Nix dev venv via HERMES_PYTHON: $PYTHON"
echo "▶ not activated — using HERMES_PYTHON: $PYTHON"
else
echo "error: no virtualenv with pytest found in $REPO_ROOT/.venv or $REPO_ROOT/venv," >&2
echo " and HERMES_PYTHON is not a python with pytest (enter the Nix devShell or create a venv)" >&2
if [ -n "$SKIPPED_VENVS" ]; then
echo " (skipped for missing pytest:$SKIPPED_VENVS — install dev extras there, or create $REPO_ROOT/.venv)" >&2
test_stamp="${__HERMES_ACTIVATED:-}"
test_stamp="${test_stamp//\\//}"
if ! hermes_activation_current "$REPO_ROOT" ||
[ ! -f "${test_stamp%/*}/inputs/.test-environment" ] ||
! _has_pytest "${__HERMES_TEST_PYTHON:-}"; then
echo "▶ activating $REPO_ROOT (environment missing or stale)" >&2
# activate is written for interactive shells, not errexit/nounset.
set +euo pipefail
# shellcheck source=/dev/null
. "$REPO_ROOT/activate" --
activated=$?
set -euo pipefail
if [ "$activated" != 0 ]; then
echo "error: activation failed (see above)" >&2
exit 1
fi
fi
PYTHON="${__HERMES_TEST_PYTHON:-}"
if ! _has_pytest "$PYTHON"; then
echo "error: activation provided no test interpreter with pytest (__HERMES_TEST_PYTHON=${PYTHON:-unset})" >&2
exit 1
fi
exit 1
fi

View File

@@ -10,7 +10,9 @@
# 3. Point you at `.\activate.ps1` - the venv-style way to put the pm env
# (PATH + tool vars) into your current session.
# ============================================================================
param([switch]$RuntimeOnly)
# Setup and activation both prepare the isolated test interpreter; installers
# invoke pm.cli directly and do not select it. -TestExtras overrides coverage.
param([switch]$RuntimeOnly, [string]$TestExtras = '')
$ErrorActionPreference = 'Stop'
Write-Host ''
@@ -84,7 +86,7 @@ try {
if ($LASTEXITCODE -ne 0) { throw 'bootstrap Python installation failed' }
$bootPy = (& $uv python find --managed-python $pyVersion) -join "`n"
if ($LASTEXITCODE -ne 0 -or -not $bootPy) { throw 'bootstrap Python lookup failed' }
& $bootPy.Trim() -m pm.cli install $(if ($RuntimeOnly) { '--trust-recorded' })
& $bootPy.Trim() -m pm.cli install $(if ($RuntimeOnly) { '--trust-recorded' }) "--test-environment=$TestExtras"
if ($LASTEXITCODE -ne 0) { throw 'pm install failed - see output above.' }
} finally {
Pop-Location

View File

@@ -15,14 +15,17 @@
set -e
# Activation needs only provisioning, not user-facing installation side effects.
# Setup and activation prepare the isolated test environment. Installers call
# pm.cli directly and never select it.
runtime_only=false
case "${1:-}" in
--runtime-only) runtime_only=true ;;
'') ;;
*) printf 'Unknown setup option: %s\n' "$1" >&2; exit 2 ;;
esac
test_environment="--test-environment"
for option in "$@"; do
case "$option" in
--runtime-only) runtime_only=true ;;
--test-environment|--test-environment=*) test_environment="$option" ;;
*) printf 'Unknown setup option: %s\n' "$option" >&2; exit 2 ;;
esac
done
# Colors
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
@@ -164,7 +167,11 @@ echo -e "${CYAN}→${NC} (first run on a fresh checkout can take 1-5 minutes)"
"$uv" python install --no-bin --no-registry "$py_version"
boot_py="$("$uv" python find --managed-python "$py_version")"
boot_py="${boot_py%$'\r'}"
if ! "$boot_py" -m pm.cli install ${runtime_only:+--trust-recorded}; then
# Activation trusts the recorded tool digest; a direct setup re-checks it
# (setup-hermes.ps1 draws the same line).
pm_args=("$test_environment")
[ "$runtime_only" = true ] && pm_args+=(--trust-recorded)
if ! "$boot_py" -m pm.cli install "${pm_args[@]}"; then
echo -e "${RED}✗${NC} pm install failed — see output above."
exit 1
fi

View File

@@ -97,7 +97,7 @@ scripts/run_tests.sh -v --tb=long # pass-through pytest flags
- Tests auto-redirect `HERMES_HOME` to temp dirs — never touch real `~/.hermes/`.
- Prepare Python through the PM developer workflow before building a test environment.
- Run `python -m pm.build_env --source . --out .venv --extra dev --group test`.
- Run `python -m pm.build_env --source . --out .venv --group dev --group test`.
The output must not exist. Stop its processes and intentionally remove only
that disposable environment before regeneration.
- The runner probes repository `.venv`, `venv`, and the standard source-install

View File

@@ -35,7 +35,7 @@ Prepare the checkout through PM first. With its Python 3.14, build an independen
test environment at a fresh path:
```powershell
python -m pm.build_env --source . --out .venv --extra dev --group test
python -m pm.build_env --source . --out .venv --group dev --group test
```
The output must not exist. Before regeneration, stop its processes and explicitly

View File

@@ -154,7 +154,7 @@ prepared checkout's Python:
```bash
source ./activate
python -m pm.build_env --source . --out .venv --extra dev --group test
python -m pm.build_env --source . --out .venv --group dev --group test
.venv/bin/python -c "import debugpy; print(debugpy.__file__)"
```

View File

@@ -518,8 +518,8 @@ def pytest_configure(config):
# Concurrent subprocesses all hit pytest_configure simultaneously;
# without a lock they'd all find no cache and all run the scan.
#
# NOTE: filelock is NOT in CI's dependency closure (`uv sync --extra all
# --extra dev ...` does not pull it), so on CI the _NoLock fallback is
# NOTE: filelock is NOT in CI's app and dev/test dependency closure,
# so on CI the _NoLock fallback is
# what actually runs. Correctness therefore cannot depend on the lock:
# the cache write must be atomic and the read must tolerate a
# not-yet-visible cache. Before the atomic-write fix, a reader could

View File

@@ -192,7 +192,7 @@ def test_failed_launch_keeps_previous_completion_and_retries(tmp_path, monkeypat
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
fact = runtime_facts_path(root)
fact.parent.mkdir(parents=True)
previous = '{"packages":{"venv":{"stamp":"previous","extras":["all","dev"]}}}'
previous = '{"packages":{"venv":{"stamp":"previous","extras":["all","anthropic"]}}}'
fact.write_text(previous)
monkeypatch.setattr(pm, "venv_is_current", lambda **kw: False)
calls = []

View File

@@ -32,7 +32,7 @@ def _sync_checkout(tmp_path: Path):
with (root / "calls.jsonl").open("a", encoding="utf-8") as stream:
stream.write(json.dumps(record) + "\\n")
assert all(value is None for value in record["python_env"].values()), record
assert sys.argv[1:] == ["runtime-only"], record
assert sys.argv[1:] == ["runtime-only", "--trust-recorded", "--test-environment"], record
print("setup progress")
if (root / "fail").exists():
sys.exit(42)
@@ -63,9 +63,9 @@ def _sync_checkout(tmp_path: Path):
# Activation's contract with setup is the runtime-only switch; setup
# itself maps that to PM's --trust-recorded install.
(root / "setup-hermes.sh").write_text(
'test "$#" = 1 && test "$1" = --runtime-only || exit 2\n'
'test "$#" = 2 && test "$1" = --runtime-only && test "$2" = --test-environment || exit 2\n'
f'cd {shlex.quote(str(root))} || exit 3\n'
f'exec {shlex.quote(str(python))} sync.py runtime-only\n', encoding="utf-8",
f'exec {shlex.quote(str(python))} sync.py runtime-only --trust-recorded --test-environment\n', encoding="utf-8",
)
(root / "setup-hermes.ps1").write_text(
"param([switch]$RuntimeOnly)\n"
@@ -75,14 +75,14 @@ def _sync_checkout(tmp_path: Path):
"argv=[Environment]::GetCommandLineArgs()}\n"
"$record | ConvertTo-Json -Compress | Add-Content -LiteralPath \"$PSScriptRoot\\ps-calls.jsonl\"\n"
"Set-Location -LiteralPath $PSScriptRoot\n"
f"& '{python}' sync.py runtime-only\nexit $LASTEXITCODE\n", encoding="utf-8",
f"& '{python}' sync.py runtime-only --trust-recorded --test-environment\nexit $LASTEXITCODE\n", encoding="utf-8",
)
return root, env
def _assert_syncs(root: Path):
calls = [json.loads(line) for line in (root / "calls.jsonl").read_text(encoding="utf-8").splitlines()]
assert calls == [{"argv": ["runtime-only"], "python_env": {
assert calls == [{"argv": ["runtime-only", "--trust-recorded", "--test-environment"], "python_env": {
"PYTHONHOME": None, "PYTHONPATH": None, "VIRTUAL_ENV": None,
}}] * 3
assert (root / "builds").read_text(encoding="utf-8").splitlines() == ["first", "second"]

View File

@@ -85,10 +85,13 @@ def test_activation_real_setup_pm_lifecycle(tmp_path, served):
wheels = tmp_path / "wheels"
wheels.mkdir()
_wheel(wheels, "activation_dep", "1.0")
_wheel(wheels, "dev_fixture", "1.0")
_wheel(wheels, "test_fixture", "1.0")
(core / "pyproject.toml").write_text(
'[project]\nname="activation-proof"\nversion="1"\nrequires-python=">=3.11"\n'
'dependencies=["activation-dep==1.0"]\n[project.optional-dependencies]\nall=[]\n'
'[tool.uv]\npackage=false\nno-index=true\n'
'[dependency-groups]\ndev=["dev-fixture==1.0"]\ntest=["test-fixture==1.0"]\n'
'[tool.uv]\npackage=false\nno-index=true\ndefault-groups=[]\n'
f'find-links=[{json.dumps(wheels.as_posix())}]\n', encoding="utf-8",
)
locked = subprocess.run(
@@ -160,7 +163,7 @@ def test_activation_real_setup_pm_lifecycle(tmp_path, served):
script = '''
prior_path=$PATH
prior_pythonpath=${PYTHONPATH-}
source "$1/activate"
source "$1/activate" --test-extras=all
status=$?
if [ "$status" != 0 ]; then
test "$PATH" = "$prior_path" || exit 91
@@ -170,6 +173,8 @@ if [ "$status" != 0 ]; then
exit "$status"
fi
python3 -c 'import activation_dep, json, os; print(json.dumps({"version": activation_dep.__version__, "module": activation_dep.__file__, "pythonpath": os.environ["PYTHONPATH"]}))' || exit 94
"$__HERMES_TEST_PYTHON" -c 'import dev_fixture, test_fixture' || exit 97
python3 -c 'import importlib.util; assert importlib.util.find_spec("dev_fixture") is None' || exit 98
deactivate
test "$PATH" = "$prior_path" || exit 95
test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 96
@@ -191,7 +196,8 @@ test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 96
return [line for line in calls.read_text().splitlines() if line.split()[0] == name]
def app_syncs():
return [line for line in operations("sync") if "--frozen --all-packages" in line]
return [line for line in operations("sync")
if "--frozen --all-packages" in line and "--group dev" not in line]
cold = activate()
first = selection()

View File

@@ -80,7 +80,7 @@ def test_trust_recorded_skips_the_byte_check_and_still_syncs(install_spy):
@pytest.mark.parametrize("kwargs, message", [
({"names": ["ripgrep"], "extra": [], "target": None, "tools_only": False, "trust_recorded": True},
"--trust-recorded"),
({"names": None, "extra": ["dev"], "target": None, "tools_only": False, "trust_recorded": True},
({"names": None, "extra": ["anthropic"], "target": None, "tools_only": False, "trust_recorded": True},
"--trust-recorded"),
({"names": None, "extra": [], "target": "linux-x64", "tools_only": False, "trust_recorded": True},
"--target"),

View File

@@ -33,24 +33,25 @@ def test_ci_dependency_phase_uses_isolated_runtime(tmp_path, monkeypatch):
project = tmp_path / "source"
project.mkdir()
(project / "pyproject.toml").write_text('[project]\nname="ci-proof"\nversion="1"\n[project.optional-dependencies]\ndev=[]\n')
(project / "pyproject.toml").write_text('[project]\nname="ci-proof"\nversion="1"\n'
'[dependency-groups]\ndev=[]\ntest=[]\n')
monkeypatch.setattr(paths, "repo_root", lambda: project)
monkeypatch.setattr(client, "is_runtime", lambda: False)
calls = []
def request(operation, arguments, **kwargs):
calls.append((operation, arguments))
return str(tmp_path / "test-environment/bin/python") if operation == "build_environment" else None
return str(tmp_path / "test-environment/bin/python") if operation == "ensure_project_environment" else None
monkeypatch.setattr(client, "_request", request)
monkeypatch.setattr(setup_toolchain, "python3_alias", lambda _: None)
monkeypatch.setattr(setup_toolchain, "file_commands", lambda *args: None)
monkeypatch.setattr(setup_toolchain, "add_path", lambda *args: None)
setup_toolchain.dependencies(SimpleNamespace(home=tmp_path, extras=["dev"], toolchain="all"))
assert [operation for operation, _ in calls] == ["check_project_lock", "build_environment"]
setup_toolchain.dependencies(SimpleNamespace(home=tmp_path, extras=[], test_environment=True, toolchain="all"))
assert [operation for operation, _ in calls] == ["check_project_lock", "ensure_project_environment"]
assert calls[0][1]["source"] == str(project)
assert calls[1][1]["extras"] == ["dev"]
assert calls[1][1]["groups"] == ["test"]
assert calls[1][1]["extras"] == []
assert calls[1][1]["groups"] == ["dev", "test"]
assert all(arguments["explicit"] for _, arguments in calls)

View File

@@ -67,6 +67,7 @@ def checkout(tmp_path: Path) -> Path:
(root / "shim").mkdir()
(root / "state").mkdir()
shutil.copy2(PROLOGUE, root / "scripts" / PROLOGUE.name)
shutil.copy2(REPO_ROOT / "scripts" / "_activation.sh", root / "scripts" / "_activation.sh")
for name in ACTIVATION_INPUTS:
(root / name).parent.mkdir(parents=True, exist_ok=True)
(root / name).touch()
@@ -94,9 +95,10 @@ def checkout(tmp_path: Path) -> Path:
return root
def _record(root: Path) -> None:
def _record(root: Path, *, test_environment: bool = True) -> None:
"""What a successful ``pm install`` leaves behind."""
record_activation_inputs(root / "state" / "inputs", activation_input_mtimes(root))
record_activation_inputs(root / "state" / "inputs", activation_input_mtimes(root), root,
test_environment=test_environment)
def _run(root: Path, sentinel: str | None = "{root}/state/facts.json") -> str:
@@ -114,6 +116,13 @@ def _run(root: Path, sentinel: str | None = "{root}/state/facts.json") -> str:
return result.stderr
def test_foreign_checkout_with_matching_input_times_reactivates(checkout: Path):
_record(checkout)
other = checkout.parent / "other"
shutil.copytree(checkout, other, copy_function=shutil.copy2)
assert "ACTIVATED" in _run(other, str(checkout / "state" / "facts.json"))
def test_recorded_inputs_are_left_alone(checkout: Path):
"""A checkout rewrote every input after facts.json was last written, then a
no-op sync recorded them: the environment is current. Re-syncing on every
@@ -124,6 +133,15 @@ def test_recorded_inputs_are_left_alone(checkout: Path):
assert "ACTIVATED" not in _run(checkout)
def test_runtime_only_install_cannot_mark_test_environment_current(checkout: Path):
marker = checkout / "state" / "inputs" / ".test-environment"
_record(checkout)
assert marker.is_file()
_record(checkout, test_environment=False)
assert not marker.exists()
assert "ACTIVATED" not in _run(checkout) # The app is current; the test env isn't.
@pytest.mark.parametrize("moved_to", [JUST_AFTER, EARLIER], ids=["newer", "older"])
@pytest.mark.parametrize("input_name", ACTIVATION_INPUTS)
def test_input_mtime_differing_from_its_stamp_reactivates(checkout: Path, input_name: str, moved_to: str):
@@ -137,7 +155,5 @@ def test_input_mtime_differing_from_its_stamp_reactivates(checkout: Path, input_
@pytest.mark.parametrize("sentinel", [None, "{root}/gone", "1", "{root}/state/facts.json"],
ids=["cold", "dangling", "legacy-1", "no-stamps"])
def test_unusable_sentinel_activates(checkout: Path, sentinel: str | None):
"""Cold, dangling stamp, the bare ``1`` an older activate exported, or an
install from before stamps existed — each must activate rather than read
as current."""
"""Cold, dangling, or incomplete activation state must not read as current."""
assert "ACTIVATED" in _run(checkout, sentinel)

View File

@@ -15,8 +15,8 @@ from pm.store import current_target
from tests.pm._fixtures import build_worker, client, isolated_python # noqa: F401
@pytest.mark.parametrize("extras", [[], ["dev"]], ids=["runtime", "tests"])
def test_development_setup_keeps_test_groups_out_of_the_runtime(tmp_path, monkeypatch, extras, build_worker):
@pytest.mark.parametrize("test_environment", [False, True], ids=["runtime", "tests"])
def test_development_setup_keeps_test_groups_out_of_the_runtime(tmp_path, monkeypatch, test_environment, build_worker):
from types import SimpleNamespace
import shutil
@@ -30,11 +30,12 @@ def test_development_setup_keeps_test_groups_out_of_the_runtime(tmp_path, monkey
wheels = tmp_path / "wheels"
wheels.mkdir()
_wheel(wheels, "test_only_dep", "1.0")
_wheel(wheels, "dev_only_dep", "1.0")
(core / "pyproject.toml").write_text(
'[project]\nname="ci-test-environment"\nversion="1"\nrequires-python=">=3.11"\n'
'[project.optional-dependencies]\ndev=[]\n'
'[dependency-groups]\ntest=["test-only-dep==1.0"]\n'
'[tool.uv]\npackage=false\nno-index=true\n'
'[project.optional-dependencies]\nall=[]\n'
'[dependency-groups]\ndev=["dev-only-dep==1.0"]\ntest=["test-only-dep==1.0"]\n'
'[tool.uv]\npackage=false\nno-index=true\ndefault-groups=[]\n'
f'find-links=[{json.dumps(wheels.as_posix())}]\n', encoding="utf-8",
)
lock_project(core, python=Path(sys.executable), offline=True, explicit=True)
@@ -45,7 +46,7 @@ def test_development_setup_keeps_test_groups_out_of_the_runtime(tmp_path, monkey
for name, file in files.items():
monkeypatch.setenv(name, str(file))
setup_toolchain.dependencies(SimpleNamespace(extras=extras, home=home))
setup_toolchain.dependencies(SimpleNamespace(extras=[], home=home, test_environment=test_environment))
outputs = dict(line.split("=", 1) for line in files["GITHUB_OUTPUT"].read_text(encoding="utf-8").splitlines())
result = subprocess.run(
@@ -53,11 +54,30 @@ def test_development_setup_keeps_test_groups_out_of_the_runtime(tmp_path, monkey
cwd=tmp_path, capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == str("dev" in extras)
assert result.stdout.strip() == str(test_environment)
probe = subprocess.run(
[outputs["python-path"], "-I", "-c",
"import importlib.util; print(importlib.util.find_spec('dev_only_dep') is not None)"],
cwd=tmp_path, capture_output=True, text=True, timeout=30,
)
assert probe.returncode == 0, probe.stderr
assert probe.stdout.strip() == str(test_environment)
assert Path(outputs["venv"]).is_relative_to(home)
assert not (core / ".venv").exists()
from pm.environments import runtime_facts_path
assert runtime_facts_path(core).exists() == ("dev" not in extras)
assert runtime_facts_path(core).exists() != test_environment
if not test_environment:
from pm import build_environment
bundle_python = build_environment(source=core, out=tmp_path / "bundle-env",
all_extras=True, no_install_project=True, explicit=True)
bundle = subprocess.run(
[bundle_python, "-I", "-c",
"import importlib.util; assert importlib.util.find_spec('dev_only_dep') is None; "
"assert importlib.util.find_spec('test_only_dep') is None"],
cwd=tmp_path, capture_output=True, text=True, timeout=30,
)
assert bundle.returncode == 0, bundle.stderr
@pytest.mark.parametrize("toolchain,names", [

View File

@@ -8,6 +8,17 @@ from packaging.version import Version
REPO_ROOT = Path(__file__).resolve().parents[1]
def test_test_dependencies_are_group_only_in_manifest_and_lock():
manifest = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
lock = tomllib.loads((REPO_ROOT / "uv.lock").read_text(encoding="utf-8"))
hermes = next(package for package in lock["package"] if package["name"] == manifest["project"]["name"])
assert manifest["tool"]["uv"]["default-groups"] == []
assert "dev" in manifest["dependency-groups"]
assert "dev" not in manifest["project"]["optional-dependencies"]
assert "dev" in hermes["dev-dependencies"]
assert "dev" not in hermes.get("optional-dependencies", {})
def test_core_and_optional_speech_dependencies():
project = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
core = {Requirement(dep).name for dep in project["dependencies"]}
@@ -32,6 +43,11 @@ def test_starlette_server_pins_and_lock_exclude_cve_2026_48710():
assert len(pins) == 1 and pins[0].operator == "==", (extra, requirement)
assert Version(pins[0].version) >= floor, (extra, requirement)
found.add(extra)
assert {"web", "mcp", "computer-use", "dev"} <= found
assert {"web", "mcp", "computer-use"} <= found
dev = [req for req in map(Requirement, metadata["dependency-groups"]["dev"])
if req.name == "starlette"]
assert len(dev) == 1
pins = list(dev[0].specifier)
assert len(pins) == 1 and pins[0].operator == "==" and Version(pins[0].version) >= floor
versions = [Version(row["version"]) for row in lock["package"] if row["name"] == "starlette"]
assert versions and all(version >= floor for version in versions)

44
uv.lock generated
View File

@@ -2103,17 +2103,6 @@ daytona = [
ddgs = [
{ name = "ddgs" },
]
dev = [
{ name = "debugpy" },
{ name = "httpx2" },
{ name = "mcp" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
{ name = "setuptools" },
{ name = "starlette" },
{ name = "ty" },
]
dingtalk = [
{ name = "alibabacloud-dingtalk" },
{ name = "dingtalk-stream" },
@@ -2314,6 +2303,17 @@ youtube = [
]
[package.dev-dependencies]
dev = [
{ name = "debugpy" },
{ name = "httpx2" },
{ name = "mcp" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
{ name = "setuptools" },
{ name = "starlette" },
{ name = "ty" },
]
icon-build = [
{ name = "pillow" },
{ name = "resvg-py" },
@@ -2345,7 +2345,6 @@ requires-dist = [
{ name = "cryptography", marker = "python_full_version >= '3.14'", specifier = "==50.0.1" },
{ name = "daytona", marker = "extra == 'daytona'", specifier = "==0.155.0" },
{ name = "ddgs", marker = "extra == 'ddgs'", specifier = "==9.16.0" },
{ name = "debugpy", marker = "extra == 'dev'", specifier = "==1.8.20" },
{ name = "defusedxml", marker = "extra == 'wecom'", specifier = "==0.7.1" },
{ name = "dingtalk-stream", marker = "extra == 'dingtalk'", specifier = "==0.24.3" },
{ name = "discord-py", extras = ["voice"], marker = "extra == 'discord'", specifier = "==2.7.1" },
@@ -2394,7 +2393,6 @@ requires-dist = [
{ name = "httptools", marker = "python_full_version >= '3.14'", specifier = ">=0.6.3,<0.9" },
{ name = "httpx", extras = ["socks"], marker = "python_full_version >= '3.14'", specifier = "==0.28.1" },
{ name = "httpx2", marker = "extra == 'computer-use'", specifier = "==2.7.0" },
{ name = "httpx2", marker = "extra == 'dev'", specifier = "==2.7.0" },
{ name = "httpx2", marker = "extra == 'mcp'", specifier = "==2.7.0" },
{ name = "huggingface-hub", marker = "extra == 'trace-upload'", specifier = "==1.24.0" },
{ name = "jinja2", marker = "python_full_version >= '3.14'", specifier = "==3.1.6" },
@@ -2404,7 +2402,6 @@ requires-dist = [
{ name = "markdown", marker = "python_full_version >= '3.14'", specifier = "==3.10.2" },
{ name = "mautrix", extras = ["encryption"], marker = "sys_platform == 'linux' and extra == 'matrix'", specifier = "==0.21.1" },
{ name = "mcp", marker = "extra == 'computer-use'", specifier = "==2.0.0" },
{ name = "mcp", marker = "extra == 'dev'", specifier = "==2.0.0" },
{ name = "mcp", marker = "extra == 'mcp'", specifier = "==2.0.0" },
{ name = "mem0ai", marker = "(platform_machine != 'ARM64' and extra == 'mem0') or (sys_platform != 'win32' and extra == 'mem0')", specifier = "==2.0.10" },
{ name = "microsoft-teams-apps", marker = "extra == 'teams'", specifier = "==2.0.13.4" },
@@ -2439,8 +2436,6 @@ requires-dist = [
{ name = "pyopen-wakeword", marker = "(platform_machine != 'x86_64' and sys_platform == 'darwin' and extra == 'wake-openwakeword') or (platform_machine != 'ARM64' and sys_platform == 'win32' and extra == 'wake-openwakeword') or (sys_platform != 'darwin' and sys_platform != 'win32' and extra == 'wake-openwakeword')", specifier = "==1.1.0" },
{ name = "pypinyin", marker = "extra == 'wake'", specifier = "==0.55.0" },
{ name = "pypinyin", marker = "extra == 'wake-sherpa'", specifier = "==0.55.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.1.1" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" },
{ name = "python-dotenv", marker = "python_full_version >= '3.14'", specifier = "==1.2.2" },
{ name = "python-multipart", marker = "python_full_version >= '3.14'", specifier = ">=0.0.9,<1" },
{ name = "python-multipart", marker = "extra == 'web'", specifier = "==0.0.32" },
@@ -2455,10 +2450,8 @@ requires-dist = [
{ name = "requests", marker = "python_full_version >= '3.14'", specifier = "==2.33.0" },
{ name = "rich", marker = "python_full_version >= '3.14'", specifier = "==14.3.3" },
{ name = "ruamel-yaml", marker = "python_full_version >= '3.14'", specifier = "==0.18.16" },
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.10" },
{ name = "sentencepiece", marker = "extra == 'wake'", specifier = "==0.2.2" },
{ name = "sentencepiece", marker = "extra == 'wake-sherpa'", specifier = "==0.2.2" },
{ name = "setuptools", marker = "extra == 'dev'", specifier = "==83.0.0" },
{ name = "sherpa-onnx", marker = "extra == 'wake'", specifier = "==1.13.8" },
{ name = "sherpa-onnx", marker = "extra == 'wake-sherpa'", specifier = "==1.13.8" },
{ name = "sherpa-onnx-core", marker = "extra == 'wake'", specifier = "==1.13.8" },
@@ -2473,14 +2466,12 @@ requires-dist = [
{ name = "sounddevice", marker = "extra == 'wake'", specifier = "==0.5.5" },
{ name = "soundfile", marker = "python_full_version < '3.13' and extra == 'kittentts'", specifier = "==0.14.0" },
{ name = "starlette", marker = "extra == 'computer-use'", specifier = "==1.3.1" },
{ name = "starlette", marker = "extra == 'dev'", specifier = "==1.3.1" },
{ name = "starlette", marker = "extra == 'mcp'", specifier = "==1.3.1" },
{ name = "starlette", marker = "extra == 'web'", specifier = "==1.3.1" },
{ name = "supermemory", marker = "extra == 'supermemory'", specifier = "==3.50.0" },
{ name = "tenacity", marker = "python_full_version >= '3.14'", specifier = "==9.1.4" },
{ name = "tomli-w", marker = "python_full_version >= '3.14'", specifier = "==1.2.0" },
{ name = "truststore", marker = "python_full_version >= '3.14'", specifier = ">=0.10.4,<0.11" },
{ name = "ty", marker = "extra == 'dev'", specifier = "==0.0.82" },
{ name = "tzdata", marker = "python_full_version >= '3.14' and sys_platform == 'win32'", specifier = "==2025.3" },
{ name = "urllib3", marker = "python_full_version >= '3.14'", specifier = ">=2.7.0,<3" },
{ name = "uvicorn", marker = "python_full_version >= '3.14'", specifier = ">=0.31.0,<1" },
@@ -2496,9 +2487,20 @@ requires-dist = [
{ name = "winrt-windows-services-store", marker = "python_full_version >= '3.14' and sys_platform == 'win32'", specifier = ">=3.2.1,<4" },
{ name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" },
]
provides-extras = ["uvloop", "anthropic", "exa", "firecrawl", "parallel-web", "ddgs", "fal", "edge-tts", "neutts", "kittentts", "piper", "modal", "daytona", "vercel", "hindsight", "google-meet", "dev", "messaging", "cron", "slack", "matrix", "wecom", "tts-premium", "voice", "wake", "honcho", "telegram", "discord", "stt-whisper", "audio-io", "silk", "wake-openwakeword", "wake-sherpa", "wake-porcupine", "google-chat", "doc-extract", "trace-upload", "supermemory", "mem0", "vision", "pty", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "otlp", "langfuse", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"]
provides-extras = ["uvloop", "anthropic", "exa", "firecrawl", "parallel-web", "ddgs", "fal", "edge-tts", "neutts", "kittentts", "piper", "modal", "daytona", "vercel", "hindsight", "google-meet", "messaging", "cron", "slack", "matrix", "wecom", "tts-premium", "voice", "wake", "honcho", "telegram", "discord", "stt-whisper", "audio-io", "silk", "wake-openwakeword", "wake-sherpa", "wake-porcupine", "google-chat", "doc-extract", "trace-upload", "supermemory", "mem0", "vision", "pty", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "otlp", "langfuse", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"]
[package.metadata.requires-dev]
dev = [
{ name = "debugpy", specifier = "==1.8.20" },
{ name = "httpx2", specifier = "==2.7.0" },
{ name = "mcp", specifier = "==2.0.0" },
{ name = "pytest", specifier = "==9.1.1" },
{ name = "pytest-asyncio", specifier = "==1.3.0" },
{ name = "ruff", specifier = "==0.15.10" },
{ name = "setuptools", specifier = "==83.0.0" },
{ name = "starlette", specifier = "==1.3.1" },
{ name = "ty", specifier = "==0.0.82" },
]
icon-build = [
{ name = "pillow", specifier = "==12.3.0" },
{ name = "resvg-py", specifier = "==0.4.0" },

View File

@@ -76,7 +76,7 @@ architecture before building source dependencies.
Build an independent interpreter for tests and editor tools:
```bash
python -m pm.build_env --source . --out .venv --extra dev --group test
python -m pm.build_env --source . --out .venv --group dev --group test
```
PM builds from the committed lock and checks dependency consistency before

View File

@@ -919,7 +919,7 @@ A build-time collision check prevents plugin packages from shadowing core hermes
### Dev Shell
The flake provides an editable Python environment with the lock-derived interpreter
and the `dev` extra. `HERMES_PYTHON` points to its interpreter. It does not install
and the `dev` dependency group. `HERMES_PYTHON` points to its interpreter. It does not install
Python dependencies into a repository-local `.venv`. The shell also provides
Node.js and runtime tools. Its npm hook refreshes JS workspaces when their inputs change.

View File

@@ -404,13 +404,14 @@ Managed tool names and Python extra names are different interfaces:
```bash
python -m pm.cli install chromium
python -c "from pm import sync_venv; sync_venv(['dev'], explicit=True)"
python -c "from pm import sync_venv; sync_venv(['anthropic'], explicit=True)"
```
The first command installs a tool. The second adds the declared `dev` extra
The first command installs a tool. The second adds a declared runtime extra
to this installation's existing Python selection. Extras accumulate through PM
sync. `pm install dev` is not a supported command: `dev` is an extra, not a tool.
After changing extras, reactivate before starting another Python process.
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`:
@@ -425,25 +426,27 @@ workspaces, and do not install packages directly into a selected generation.
### Test and editor environments
PM's `dev` extra does not make a bare store Python suitable for the canonical
test runner. The runner clears `PYTHONPATH` and needs an interpreter with pytest
installed in its own environment. Use the contributor guide's
[independent test environment](../developer-guide/contributing.md#manual-development-and-test-environment)
with this command from the prepared checkout:
`source ./activate` (or `. .\activate.ps1` in PowerShell) and both direct
`setup-hermes` scripts prepare an isolated test interpreter from the locked
`dev` and `test` dependency groups. `scripts/run_tests.sh` uses that interpreter,
re-activating if the checkout or its dependency inputs changed. The application
venv, installers, and bundles select neither group. The developer default
covers `[all]`; to change test coverage, pass `--test-extras=anthropic` to POSIX
activation or `-TestExtras anthropic` to PowerShell;
those arguments select runtime extras *in the test interpreter only*.
In an isolated environment where activation is unavailable (for example, a Nix
dev shell), a caller can explicitly supply `HERMES_PYTHON` with pytest, or build
an independent disposable environment:
```bash
python -m pm.build_env --source . --out .venv --extra dev --group test
python -m pm.build_env --source . --out .venv --group dev --group test
```
The output must not exist. To regenerate it, stop its processes and intentionally
remove only that disposable environment first. PM never deletes an existing
output. Then run `scripts/run_tests.sh` (through Bash on Windows). The `test`
dependency group includes native launcher tests and does not enter a packaged runtime.
The runner checks repository `.venv`, repository `venv`, and the standard
source-install venv before using `HERMES_PYTHON` as a fallback. Read its startup
message to confirm which interpreter it selected. A worktree without a local
venv can use the independent test interpreter through that variable.
remove only that disposable environment first. Then run `scripts/run_tests.sh`
(through Bash on Windows). The test dependency group includes native launcher
tests and never enters a packaged runtime.
For editor debugging, select that independent interpreter, set the working
directory to this checkout, and launch `hermes` as the script. Keep its

View File

@@ -172,7 +172,7 @@ prepared checkout's Python:
```bash
source ./activate
python -m pm.build_env --source . --out .venv --extra dev --group test
python -m pm.build_env --source . --out .venv --group dev --group test
.venv/bin/python -c "import debugpy; print(debugpy.__file__)"
```

View File

@@ -68,7 +68,7 @@ PowerShell 开头的点和空格用于 dot-source,不能省略。
PM 必须能够启动,才能构建独立测试环境:
```bash
python -m pm.build_env --source . --out .venv --extra dev --group test
python -m pm.build_env --source . --out .venv --group dev --group test
```
此命令使用提交的锁文件,创建新环境并检查依赖一致性。输出路径必须不存在。

View File

@@ -727,7 +727,7 @@ services.hermes-agent.settings.plugins.enabled = [
### 开发 Shell
该 flake 提供包含 `dev` extra 的可编辑 Python 环境,解释器主/次版本来自 PM 锁文件。
该 flake 提供包含 `dev` 依赖组的可编辑 Python 环境,解释器主/次版本来自 PM 锁文件。
`HERMES_PYTHON` 指向该解释器,不会把 Python 依赖安装到仓库内的 `.venv`。
shell 还提供 Node.js 和运行时工具。npm hook 根据输入变更刷新 JS workspaces。

View File

@@ -168,7 +168,7 @@ Python 构建全新的调试/测试环境:
```bash
source ./activate
python -m pm.build_env --source . --out .venv --extra dev --group test
python -m pm.build_env --source . --out .venv --group dev --group test
.venv/bin/python -c "import debugpy; print(debugpy.__file__)"
```