fix(cli): stop pytest's -p plugin flag being read as --profile - #346
Conversation
`_apply_profile_override()` runs at import of `hermes_cli.main` and scans raw
`sys.argv` for `-p`/`--profile`. pytest's `-p` selects a plugin, so under pytest
that scan reads the plugin name as a profile.
Step 1b already guards this — it was added for exactly this reason ("pytest's
-p no:xdist would be misread as profile no:xdist") — but the guard is purely
syntactic: it rejects values that cannot be profile ids. A plugin name is
usually a perfectly valid one. `no:logging` is rejected for its colon;
`anyio`, `cacheprovider`, `randomly` are not. Those reach
`resolve_profile_env()`, which exits 1 on a profile the user never asked for:
FileNotFoundError: Profile 'anyio' does not exist.
Create it with: hermes profile create anyio
SystemExit: 1
raised from the import, at setup of every test in any file that imports
`hermes_cli.main` directly or transitively (`tui_gateway.server` does).
Measured with `-p poller_probe`: `tests/hermes_cli/test_config.py` 183 errors,
`test_inventory.py` 51, `test_voice_wrapper.py` 44,
`tests/test_tui_gateway_server.py` 5 failures. The message names a profile
nobody typed and points at `hermes profile create`, and nothing in the
traceback mentions pytest's `-p`, so it reads as a broken environment.
Guard the import-time call rather than the function: under pytest this
process's argv is pytest's, never a hermes command line, so there is nothing
here to parse. The sticky `active_profile` branch is worth skipping there too —
it lets a developer's `hermes profile use` rewrite HERMES_HOME inside the suite.
The guard has to sit at the call site. `tests/hermes_cli/test_apply_profile_override.py`
calls `_apply_profile_override()` directly with a monkeypatched hermes argv, so
an early return inside the function would neuter its own coverage. Only the
import-time side effect is skipped; the function is unchanged.
`_under_pytest()` here is deliberately stronger than `managed_scope._under_pytest`,
which checks only `PYTEST_CURRENT_TEST`. That variable is set per-test, so it is
absent during collection — exactly when a test module's top-level
`import hermes_cli.main` runs. `sys.modules` covers collection, fixture setup,
and test bodies alike.
Four tests pin the regression, including the case the old guard misses: a plugin
name that IS a valid profile id (`cacheprovider`) must not exit on re-import.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Targeted baseline-vs-fix A/B — no regressionsThe run promised in the PR description has landed. Same 12 files, same runner, same env, run twice on the same machine: once on unmodified
New failures introduced by the fix: none. The failure sets are not merely the same size — they are the same four tests, byte-identical: These are pre-existing and unrelated: they fail on The +4 passed is exactly the four new tests in Files coveredChosen as the ones whose behaviour could plausibly depend on the import-time profile override — profile resolution, env loading, module re-import, and the largest transitive importer of
The two that matter most are Scope, stated honestlyThis is a targeted A/B, not the full suite. The full sweep over Reproduced with: scripts/run_tests.sh <the 12 files> -q --file-retries 0 --file-timeout 1800 -j 6run against |
What does this PR do?
_apply_profile_override()runs at import ofhermes_cli.mainand scans rawsys.argvfor-p/--profile. pytest's-pselects a plugin, so under pytest that scan reads the plugin name as a profile name.There is already a guard for this, and that's the interesting part. Step 1b was added for exactly this reason — its comment says "pytest's
-p no:xdistwould be misread as profileno:xdist". But it is purely syntactic: it rejects values that cannot be profile ids. A plugin name is usually a perfectly valid one.no:loggingis rejected for its colon;anyio,cacheprovider,randomlyare not. Those sail through toresolve_profile_env(), which exits 1 on a profile the user never asked for:raised from the import, at setup of every test in any file that imports
hermes_cli.maindirectly or transitively (tui_gateway.serverdoes). Measured with-p poller_probe:tests/hermes_cli/test_config.pytests/hermes_cli/test_inventory.pytests/hermes_cli/test_voice_wrapper.pytests/hermes_cli/test_custom_provider_identity.pytests/test_tui_gateway_server.pyThe failure is nastier than the count suggests: the message names a profile nobody typed and points at
hermes profile create, and nothing in the traceback mentions pytest's-p. It reads as a broken environment, not a flag collision. It cost me a full audit pass whose "errors" were entirely self-inflicted.The fix guards the import-time call rather than the function. Under pytest this process's argv is pytest's, never a hermes command line, so there is nothing here to parse. Skipping the sticky
active_profilebranch there is a bonus: it currently lets a developer'shermes profile userewriteHERMES_HOMEinside the test suite.Two details worth a reviewer's attention:
tests/hermes_cli/test_apply_profile_override.pycalls_apply_profile_override()directly with a monkeypatched hermes argv, so an early return inside the function would neuter its own coverage. Only the import-time side effect is skipped; the function is unchanged._under_pytest()here is deliberately stronger thanmanaged_scope._under_pytest, which checks onlyPYTEST_CURRENT_TEST. That variable is set per-test, so it is absent during collection — exactly when a test module's top-levelimport hermes_cli.mainruns.sys.modulescovers collection, fixture setup, and test bodies alike.Related Issue
No separate issue. Found while auditing notification-poller leaks after #335/#336.
Type of Change
Changes Made
hermes_cli/main.py— added_under_pytest(), and guarded the module-level_apply_profile_override()call with it. The function body is untouched.tests/hermes_cli/test_apply_profile_override.py— addedTestPytestArgvIsNotAHermesCommandLine(4 tests):test_reimport_with_pytest_plugin_flag_does_not_exit— the regression, usingcacheprovider: a plugin name that is a valid profile id, so it defeats the old step-1b regex.test_under_pytest_survives_unset_current_test_env— pins the collection-time case thatPYTEST_CURRENT_TESTalone would miss.test_under_pytest_is_true_during_a_test.test_explicit_call_still_reads_a_real_hermes_argv— full profile resolution still works when called directly, despite the suite running under pytest.How to Test
Reproduce on
main— any plugin name that is a valid profile id:6 errorsonmain,6 passedon this branch.Verified so far on this branch:
tests/hermes_cli/test_config.py -p poller_probe: 183 errors → 183 passed.tests/hermes_cli/test_apply_profile_override.py: 17 passed (13 existing + 4 new).sys.modules, a real hermes argv still resolves and strips the flag:Targeted baseline-vs-fix A/B — done, no regressions (full detail in this comment). Same 12 files, same runner and env, run twice on the same machine:
main)New failures from the fix: none. The failure sets are the same four tests byte-for-byte — all
test_gateway_service.py::TestGatewaySystemServiceRouting::test_systemd_restart_*, failing onsystemctl --user cannot reach the user D-Bus session(Linux-only tooling on a macOS host). The +4 passed is exactly the four new tests.Files:
test_apply_profile_override.py,test_profiles.py,test_env_loader.py,test_skills_subparser.py,test_container_aware_cli.py,test_web_server_profile_unification.py,test_desktop_spawn.py,test_gateway_service.py,test_setup.py,test_tui_gateway_server.py,test_profile_isolation_runtime.py,test_profile_db_session_sites.py— chosen as the ones that can actually reach the changed code path.test_env_loader.pyandtest_skills_subparser.pymatter most: they are the only two in the repo thatdel sys.modules['hermes_cli.main']and re-import, so they are the only ones that re-execute the guarded call. Both green.The residual risk this covers: the only behaviour the change removes under pytest, besides the bug, is the import-time sticky-
active_profileredirect. A test depending on that would be host-dependent, which is the class the hermetic runner exists to eliminate — worth proving rather than asserting.This is a targeted A/B, not the full suite: the full sweep is ~9,500 tests per phase, so I narrowed it to the files that can reach the change rather than report hours late. CI covers the rest.
Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — not run; a targeted 12-file baseline-vs-fix A/B was run instead (see above), identical failure setsDocumentation & Housekeeping
docs/, docstrings) — or N/Acli-config.yaml.exampleif I added/changed config keys — or N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/A