mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
The six document skills each carried a copy of a venv bootstrap that ran `python -m venv --clear` on a shared directory after an unlocked check, so two agents could erase each other's install, and the pdf skill's optional installs read a DOCS_ENV that was only set inside a branch. `hermes pm environment NAME --extra E | --requirement R` exposes PM's existing isolated environments (pm.ensure_environment) to shell callers: the identity is re-checked under the environment's install lock, a rebuild lands in a new generation published by an atomic selection, and the previous generation is never deleted. It prints only the interpreter path on stdout. --extra reads the extra's pins from pyproject.toml, so a Hermes update does not build a new generation unless those pins change. Every document skill now runs the same one-liner (`hermes pm environment documents --extra documents --extra documents-pdf`) when `python` lacks the libraries; the new documents-pdf extra carries pdfplumber and pypdfium2. OCR libraries get their own named environments. The no-tmux PTY recipe pauses 0.5 s between the text write and the Enter write, like the tmux recipe, so the paste guard cannot read Enter as a newline.
96 lines
3.7 KiB
Python
96 lines
3.7 KiB
Python
"""`hermes pm environment`: the shell entry skills use for a named side environment."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import pm
|
|
from pm import cli, runtime
|
|
|
|
PROJECT = Path(cli.__file__).resolve().parents[1]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _in_pm_runtime(monkeypatch):
|
|
# The command body as it runs once `hermes pm` is inside PM's runtime;
|
|
# repo_root() would consult the worktree manifest under the real home.
|
|
monkeypatch.setattr(runtime, "is_runtime", lambda: True)
|
|
monkeypatch.setattr(cli, "repo_root", lambda: PROJECT)
|
|
|
|
|
|
def _noisy(result: Path, calls: list):
|
|
def ensure(*args, **kwargs):
|
|
calls.append((args, kwargs))
|
|
# Progress from PM (and its uv children) is written to fd 1 directly.
|
|
os.write(1, b" -> Installing Python dependencies\n")
|
|
print(" ok Installing Python dependencies")
|
|
return result
|
|
return ensure
|
|
|
|
|
|
def test_stdout_is_only_the_interpreter_for_command_substitution(monkeypatch, capfd, tmp_path):
|
|
calls: list = []
|
|
python = tmp_path / "gen-x" / "venv" / "bin" / "python"
|
|
monkeypatch.setattr(pm, "ensure_environment", _noisy(python, calls))
|
|
|
|
assert cli.main(["environment", "documents", "--extra", "documents", "--extra", "documents-pdf"]) == 0
|
|
|
|
out, err = capfd.readouterr()
|
|
assert out == f"{python}\n"
|
|
assert "Installing Python dependencies" in err
|
|
(name, requirements), kwargs = calls[0]
|
|
assert name == "documents"
|
|
# An agent asking for it is an explicit install, even where lazy installs are off.
|
|
assert kwargs == {"explicit": True}
|
|
# Both extras' pins: every skill passing the same flags shares one generation.
|
|
pinned = {requirement.split("==")[0] for requirement in requirements}
|
|
assert {"python-docx", "openpyxl", "python-pptx", "reportlab", "pypdf", "pdfplumber", "pypdfium2"} <= pinned
|
|
assert all("==" in requirement for requirement in requirements)
|
|
|
|
|
|
def test_extra_expands_self_references_and_rejects_unknown(tmp_path):
|
|
(tmp_path / "pyproject.toml").write_text(
|
|
'[project]\nname = "demo"\nversion = "0"\n'
|
|
'[project.optional-dependencies]\n'
|
|
'a = ["pkg-a==1.0"]\nb = ["demo[a]", "pkg-b==2.0"]\n', encoding="utf-8")
|
|
|
|
assert sorted(cli._extra_requirements(tmp_path, ["b"])) == ["pkg-a==1.0", "pkg-b==2.0"]
|
|
with pytest.raises(ValueError, match="not an extra"):
|
|
cli._extra_requirements(tmp_path, ["missing"])
|
|
|
|
|
|
def test_requirements_select_an_ad_hoc_environment(monkeypatch, capfd, tmp_path):
|
|
calls: list = []
|
|
python = tmp_path / "python"
|
|
monkeypatch.setattr(pm, "ensure_environment", _noisy(python, calls))
|
|
|
|
assert cli.main(["environment", "pdf-pymupdf", "--requirement", "pymupdf>=1.26,<3"]) == 0
|
|
|
|
assert capfd.readouterr().out == f"{python}\n"
|
|
assert calls == [(("pdf-pymupdf", ["pymupdf>=1.26,<3"]), {"explicit": True})]
|
|
|
|
|
|
@pytest.mark.parametrize("flags", [[], ["--extra", "documents", "--requirement", "pypdf>=6,<7"]])
|
|
def test_exactly_one_source_of_requirements(monkeypatch, capfd, flags):
|
|
def refuse(*_args, **_kwargs):
|
|
raise AssertionError("must not build")
|
|
monkeypatch.setattr(pm, "ensure_environment", refuse)
|
|
|
|
assert cli.main(["environment", "documents", *flags]) == 2
|
|
assert capfd.readouterr().out == ""
|
|
|
|
|
|
def test_a_failed_build_reports_on_stderr_and_restores_stdout(monkeypatch, capfd):
|
|
def fail(*_args, **_kwargs):
|
|
raise pm.InstallError("documents", "no network")
|
|
monkeypatch.setattr(pm, "ensure_environment", fail)
|
|
|
|
assert cli.main(["environment", "documents", "--extra", "documents"]) == 1
|
|
print("after")
|
|
out, err = capfd.readouterr()
|
|
assert out == "after\n"
|
|
assert "no network" in err
|