From 3680707e60d9623f996a119d841026b060ded59b Mon Sep 17 00:00:00 2001 From: Garm Date: Wed, 22 Apr 2026 11:56:59 +0200 Subject: [PATCH 1/3] fix(cli): eagerly bind subcommand submodules to headroom.cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests that do `patch("headroom.cli..")` resolve the target by walking attributes on the `headroom.cli` package object. That lookup fails when `tests/test_cli/test_wrap_copilot.py` pops `headroom.cli` from `sys.modules` at import time and re-imports it with a fake `headroom.cli.main` — the re-imported package only has `.wrap` bound because `_register_commands()` in `main.py` never runs against the fake. Eagerly importing the subcommand submodules from `__init__.py` binds them as package attributes regardless of how `main.py` is loaded, so the patch lookup survives that kind of sys.modules mutation. Fixes #234 --- headroom/cli/__init__.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/headroom/cli/__init__.py b/headroom/cli/__init__.py index cc774f527..d4e20bc3c 100644 --- a/headroom/cli/__init__.py +++ b/headroom/cli/__init__.py @@ -1,5 +1,33 @@ -"""Headroom CLI - Command-line interface for memory and proxy management.""" +"""Headroom CLI - Command-line interface for memory and proxy management. +The subcommand submodules are imported eagerly below so they are bound as +attributes of `headroom.cli`. Click registration happens via side effects in +`main.py::_register_commands`, but that only binds them to the *main.py* +module. Tests that do `patch("headroom.cli..")` resolve the target +by walking attributes on the package object, and that lookup fails when a +prior test has popped `headroom.cli` from `sys.modules` and re-imported it +through a path other than `main.py` (e.g. a test that replaces +`sys.modules["headroom.cli.main"]` with a fake to isolate one subcommand). +Doing `from . import ...` here means the submodule attribute binding +survives that kind of sys.modules mutation. +""" + +from . import ( # noqa: F401 + evals, + init, + install, + learn, + mcp, + perf, + proxy, + tools, + wrap, +) from .main import main +try: + from . import memory # noqa: F401 +except ImportError: + pass + __all__ = ["main"] From 796afd085b69d026dc2cb34cf30f94050191fdc6 Mon Sep 17 00:00:00 2001 From: Garm Date: Wed, 22 Apr 2026 12:06:45 +0200 Subject: [PATCH 2/3] chore: apply ruff format to tests/test_release_workflows.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive-by: main is currently failing `ruff format --check .` because of two missing blank lines between two top-level functions in this file (introduced in 8bf11d2). Fixing it here so this PR's CI can go green — no other way to unblock the format check without landing a separate PR first. --- tests/test_release_workflows.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py index 2340b7260..c753ff882 100644 --- a/tests/test_release_workflows.py +++ b/tests/test_release_workflows.py @@ -53,6 +53,8 @@ def test_macos_native_wrapper_dependency_install_retries_pypi_downloads() -> Non content = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") assert "python -m pip install --retries 10 --timeout 60 pytest" in content + + def test_ci_commitlint_skips_default_github_merge_commits() -> None: content = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") From 1788d907f0c83f06eccf191f3e1193746104f477 Mon Sep 17 00:00:00 2001 From: Garm Date: Wed, 22 Apr 2026 12:37:22 +0200 Subject: [PATCH 3/3] fix(tests): scope sys.modules mutation and unpin hardcoded version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent pre-existing test-hygiene regressions on main, all surfaced as cascading CI failures: 1. tests/test_cli/test_wrap_copilot.py (from #229) mutated sys.modules["headroom.cli.main"] with a fake click.Group() at module-import time and never restored it. Any later test that did `from headroom.cli.main import main` got an empty group with no version option and no registered subcommands, breaking ~20 test_cli/* and test_cli_proxy_env.py tests. Rewrite to import the real `main` directly — the fake-group indirection served no purpose. 2. tests/test_proxy_copilot_auth_hooks.py (from #229) installed fake httpx / fastapi.responses / headroom.proxy.* modules into sys.modules inside a helper called from test functions, never cleaned up. Later tests that imported ASGITransport or JSONResponse hit the fakes and failed with ImportError. Switch the helper to monkeypatch.setitem so the fakes are scoped to the owning test. 3. tests/test_release_version.py hardcoded canonical=0.5.25 in the subprocess-output assertion; the project version in pyproject.toml has since bumped to 0.9.1. Compute the expected value dynamically via get_canonical_version(ROOT) so the test tracks pyproject. --- tests/test_cli/test_wrap_copilot.py | 15 ++------------- tests/test_proxy_copilot_auth_hooks.py | 14 ++++++++------ tests/test_release_version.py | 3 ++- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/tests/test_cli/test_wrap_copilot.py b/tests/test_cli/test_wrap_copilot.py index dc8bafbab..792feea84 100644 --- a/tests/test_cli/test_wrap_copilot.py +++ b/tests/test_cli/test_wrap_copilot.py @@ -2,27 +2,16 @@ from __future__ import annotations -import importlib -import sys -import types from pathlib import Path from unittest.mock import patch -import click import pytest from click.testing import CliRunner +from headroom.cli import wrap as wrap_cli +from headroom.cli.main import main from headroom.copilot_auth import DEFAULT_API_URL -fake_main_module = types.ModuleType("headroom.cli.main") -fake_main_module.main = click.Group() -sys.modules["headroom.cli.main"] = fake_main_module -sys.modules.pop("headroom.cli", None) -sys.modules.pop("headroom.cli.wrap", None) - -wrap_cli = importlib.import_module("headroom.cli.wrap") -main = fake_main_module.main - @pytest.fixture def runner() -> CliRunner: diff --git a/tests/test_proxy_copilot_auth_hooks.py b/tests/test_proxy_copilot_auth_hooks.py index 5a89d8017..f0191a7e3 100644 --- a/tests/test_proxy_copilot_auth_hooks.py +++ b/tests/test_proxy_copilot_auth_hooks.py @@ -11,20 +11,20 @@ import pytest ROOT = Path(__file__).resolve().parents[1] -def _load_handler_module(module_name: str, relative_path: str): +def _load_handler_module(monkeypatch: pytest.MonkeyPatch, module_name: str, relative_path: str): proxy_pkg = types.ModuleType("headroom.proxy") proxy_pkg.__path__ = [str(ROOT / "headroom" / "proxy")] - sys.modules["headroom.proxy"] = proxy_pkg + monkeypatch.setitem(sys.modules, "headroom.proxy", proxy_pkg) handlers_pkg = types.ModuleType("headroom.proxy.handlers") handlers_pkg.__path__ = [str(ROOT / "headroom" / "proxy" / "handlers")] - sys.modules["headroom.proxy.handlers"] = handlers_pkg + monkeypatch.setitem(sys.modules, "headroom.proxy.handlers", handlers_pkg) httpx_mod = types.ModuleType("httpx") httpx_mod.ConnectError = type("ConnectError", (Exception,), {}) httpx_mod.ConnectTimeout = type("ConnectTimeout", (Exception,), {}) httpx_mod.PoolTimeout = type("PoolTimeout", (Exception,), {}) - sys.modules["httpx"] = httpx_mod + monkeypatch.setitem(sys.modules, "httpx", httpx_mod) responses_mod = types.ModuleType("fastapi.responses") @@ -40,12 +40,12 @@ def _load_handler_module(module_name: str, relative_path: str): responses_mod.Response = Response responses_mod.StreamingResponse = StreamingResponse - sys.modules["fastapi.responses"] = responses_mod + monkeypatch.setitem(sys.modules, "fastapi.responses", responses_mod) spec = importlib.util.spec_from_file_location(module_name, ROOT / relative_path) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module + monkeypatch.setitem(sys.modules, module_name, module) spec.loader.exec_module(module) return module @@ -53,6 +53,7 @@ def _load_handler_module(module_name: str, relative_path: str): @pytest.mark.asyncio async def test_openai_passthrough_applies_copilot_auth(monkeypatch: pytest.MonkeyPatch) -> None: openai_mod = _load_handler_module( + monkeypatch, "tests.headroom_proxy_handlers_openai", "headroom/proxy/handlers/openai.py", ) @@ -110,6 +111,7 @@ async def test_openai_passthrough_applies_copilot_auth(monkeypatch: pytest.Monke @pytest.mark.asyncio async def test_streaming_response_applies_copilot_auth(monkeypatch: pytest.MonkeyPatch) -> None: streaming_mod = _load_handler_module( + monkeypatch, "tests.headroom_proxy_handlers_streaming", "headroom/proxy/handlers/streaming.py", ) diff --git a/tests/test_release_version.py b/tests/test_release_version.py index 52bb0fcac..461e0db45 100644 --- a/tests/test_release_version.py +++ b/tests/test_release_version.py @@ -14,6 +14,7 @@ from headroom.release_version import ( compute_release_version, determine_bump_level, find_latest_release_tag, + get_canonical_version, list_release_commits, normalize_release_tag, parse_release_tag, @@ -177,7 +178,7 @@ def test_release_version_script_runs_directly_without_importing_headroom_package assert output_path.read_text(encoding="utf-8").splitlines() == [ "version=0.6.0", "npm_version=0.6.0", - "canonical=0.5.25", + f"canonical={get_canonical_version(ROOT)}", "height=0", "bump=manual", "previous_tag=",