"""Coverage for update_check / cli.update helper functions and branches.""" from __future__ import annotations import importlib.metadata as md import sys import sysconfig import pytest from click.testing import CliRunner from headroom import update_check as uc from headroom.cli import update as up from headroom.cli.main import main @pytest.fixture(autouse=True) def _env(tmp_path, monkeypatch): monkeypatch.setenv("HEADROOM_WORKSPACE_DIR", str(tmp_path)) monkeypatch.setenv("HEADROOM_UPDATE_CHECK", "on") monkeypatch.delenv("HEADROOM_STATELESS", raising=False) monkeypatch.delenv("CI", raising=False) monkeypatch.delenv("HEADROOM_IN_DOCKER", raising=False) monkeypatch.delenv("CONDA_PREFIX", raising=False) # --------------------------------------------------------------------------- # # cli.update helpers # --------------------------------------------------------------------------- # def test_norm_normalizes_and_lowercases(): assert up._norm("/Foo/Bar").endswith("/foo/bar") assert up._norm(None) == "" assert up._norm(r"C:\\X\\Y").count("\\") == 0 def test_in_virtualenv_via_prefix(monkeypatch): monkeypatch.setattr(up.sys, "prefix", "/venv") monkeypatch.setattr(up.sys, "base_prefix", "/usr") assert up._in_virtualenv() is True def test_in_virtualenv_via_conda(monkeypatch): monkeypatch.setattr(up.sys, "prefix", "/x") monkeypatch.setattr(up.sys, "base_prefix", "/x") monkeypatch.setenv("CONDA_PREFIX", "/opt/conda") assert up._in_virtualenv() is True def test_in_virtualenv_false(monkeypatch): monkeypatch.setattr(up.sys, "prefix", "/x") monkeypatch.setattr(up.sys, "base_prefix", "/x") assert up._in_virtualenv() is False def test_in_docker_env_flag(monkeypatch): monkeypatch.setenv("HEADROOM_IN_DOCKER", "1") assert up._in_docker() is True def test_in_docker_default_false(): # No HEADROOM_IN_DOCKER and (almost certainly) no /.dockerenv on the runner. assert isinstance(up._in_docker(), bool) def test_editable_install_true(monkeypatch): class _D: def read_text(self, name): return '{"dir_info": {"editable": true}}' monkeypatch.setattr(md, "distribution", lambda name: _D()) assert up._is_editable_install() is True def test_editable_install_false_when_not_editable(monkeypatch): class _D: def read_text(self, name): return '{"url": "https://pypi.org", "archive_info": {}}' monkeypatch.setattr(md, "distribution", lambda name: _D()) assert up._is_editable_install() is False def test_editable_install_false_when_no_direct_url(monkeypatch): class _D: def read_text(self, name): return None monkeypatch.setattr(md, "distribution", lambda name: _D()) assert up._is_editable_install() is False def test_editable_install_swallows_errors(monkeypatch): def _boom(name): raise RuntimeError("nope") monkeypatch.setattr(md, "distribution", _boom) assert up._is_editable_install() is False def test_package_location_handles_missing(monkeypatch): def _boom(name): raise md.PackageNotFoundError(name) monkeypatch.setattr(md, "distribution", _boom) assert up._package_location() is None def test_user_site_and_membership(monkeypatch): monkeypatch.setattr(up, "_user_site", lambda: "/home/u/.local/site") assert up._is_user_site_install("/home/u/.local/site/headroom_ai") is True assert up._is_user_site_install("/home/u/.local/site") is True assert up._is_user_site_install("/usr/lib/python3/site") is False assert up._is_user_site_install(None) is False def test_user_site_no_sibling_prefix_match(monkeypatch): # Path-segment containment: "/.../site" must NOT match "/.../site-packages". monkeypatch.setattr(up, "_user_site", lambda: "/home/u/.local/site") assert up._is_user_site_install("/home/u/.local/site-packages/x") is False def test_format_cmd_quotes_spaces(monkeypatch): monkeypatch.setattr(up.sys, "platform", "linux") out = up._format_cmd(["/path with space/python", "-m", "pip", "install", "-U", "headroom-ai"]) assert "'/path with space/python'" in out def test_format_cmd_windows(monkeypatch): monkeypatch.setattr(up.sys, "platform", "win32") out = up._format_cmd([r"C:\\Program Files\\Python\\python.exe", "-m", "pip"]) assert "Program Files" in out and out.endswith("-m pip") def test_windows_pip_update_needs_handoff(monkeypatch): monkeypatch.setattr(up.sys, "platform", "win32") assert up._windows_pip_update_needs_handoff( up.InstallMethod(kind="pip", can_self_update=True, argv=["python"]) ) assert up._windows_pip_update_needs_handoff( up.InstallMethod(kind="pip-user", can_self_update=True, argv=["python"]) ) assert not up._windows_pip_update_needs_handoff( up.InstallMethod(kind="pipx", can_self_update=True, argv=["pipx"]) ) def test_windows_pip_update_handoff_false_off_windows(monkeypatch): monkeypatch.setattr(up.sys, "platform", "linux") assert not up._windows_pip_update_needs_handoff( up.InstallMethod(kind="pip", can_self_update=True, argv=["python"]) ) @pytest.mark.parametrize("operator", ["&", "|", ">"]) def test_build_windows_handoff_argv_preserves_pip_argv(monkeypatch, operator): monkeypatch.setattr(up.sys, "platform", "win32") pip_argv = [ r"C:\\Program Files\\Python\\python.exe", "-m", "pip", "install", "-U", f"headroom-ai[foo{operator}calc]", ] handoff_argv = up._build_windows_handoff_argv(pip_argv) assert handoff_argv[:2] == [sys.executable, "-c"] assert "subprocess.run" in handoff_argv[2] assert "cmd.exe" not in handoff_argv assert handoff_argv[3:] == pip_argv[1:] def test_externally_managed_true(tmp_path, monkeypatch): (tmp_path / "EXTERNALLY-MANAGED").write_text("[externally-managed]") monkeypatch.setattr(sysconfig, "get_path", lambda key: str(tmp_path)) assert up._is_externally_managed() is True def test_externally_managed_false(tmp_path, monkeypatch): monkeypatch.setattr(sysconfig, "get_path", lambda key: str(tmp_path)) assert up._is_externally_managed() is False def test_user_site_real_returns_str_or_empty(): assert isinstance(up._user_site(), str) def test_source_checkout_real_is_bool(): assert isinstance(up._is_source_checkout(), bool) def test_package_location_real_runs(): # Either a normalized path string or None, but the call must not raise. assert up._package_location() is None or isinstance(up._package_location(), str) @pytest.mark.parametrize( "platform,needle", [("darwin", "brew"), ("win32", "pipx"), ("linux", "distro")], ) def test_managed_env_guidance(monkeypatch, platform, needle): monkeypatch.setattr(up.sys, "platform", platform) assert needle in up._managed_env_guidance() def test_spec_with_and_without_extras(): assert up._spec(None) == "headroom-ai" assert up._spec("all") == "headroom-ai[all]" assert up._spec("[proxy]") == "headroom-ai[proxy]" # --------------------------------------------------------------------------- # # cli.update command branches # --------------------------------------------------------------------------- # def test_update_aborts_on_decline(monkeypatch): monkeypatch.setattr(up, "installed_version", lambda: "0.26.0") monkeypatch.setattr(up, "fetch_latest_version", lambda **k: "0.27.0") monkeypatch.setattr(up, "_is_source_checkout", lambda: False) monkeypatch.setattr(up, "_is_editable_install", lambda: False) monkeypatch.setattr(up, "_in_docker", lambda: False) monkeypatch.setattr(up, "_in_virtualenv", lambda: True) def _no_run(*a, **k): raise AssertionError("should not run after decline") monkeypatch.setattr(up.subprocess, "run", _no_run) res = CliRunner().invoke(main, ["update"], input="n\n") assert res.exit_code == 0 assert "Aborted" in res.output def test_update_missing_tool_surfaces_command(monkeypatch): monkeypatch.setattr(up, "installed_version", lambda: "0.26.0") monkeypatch.setattr(up, "fetch_latest_version", lambda **k: "0.27.0") monkeypatch.setattr(up, "_is_source_checkout", lambda: False) monkeypatch.setattr(up, "_is_editable_install", lambda: False) monkeypatch.setattr(up, "_in_docker", lambda: False) monkeypatch.setattr( up, "detect_install_method", lambda extras=None: up.InstallMethod( kind="pipx", can_self_update=True, argv=["pipx", "upgrade", "headroom-ai"] ), ) def _missing(*a, **k): raise FileNotFoundError("pipx") monkeypatch.setattr(up.subprocess, "run", _missing) res = CliRunner().invoke(main, ["update", "--yes"]) assert res.exit_code != 0 assert "not found on PATH" in res.output def test_update_externally_managed_refuses_via_command(monkeypatch): monkeypatch.setattr(up, "installed_version", lambda: "0.26.0") monkeypatch.setattr(up, "fetch_latest_version", lambda **k: "0.27.0") monkeypatch.setattr(up, "_is_source_checkout", lambda: False) monkeypatch.setattr(up, "_is_editable_install", lambda: False) monkeypatch.setattr(up, "_in_docker", lambda: False) monkeypatch.setattr(up, "_in_virtualenv", lambda: False) monkeypatch.setattr(up, "_is_user_site_install", lambda loc: False) monkeypatch.setattr(up, "_is_externally_managed", lambda: True) res = CliRunner().invoke(main, ["update", "--yes"]) assert res.exit_code == 0 assert "PEP 668" in res.output def test_venv_inside_bare_dockerenv_still_self_updates(monkeypatch): """A venv/pip install inside a container (bare /.dockerenv, no explicit HEADROOM_IN_DOCKER) must self-update, not be refused with image guidance (#2816). """ monkeypatch.setattr(up, "_is_source_checkout", lambda: False) monkeypatch.setattr(up, "_is_editable_install", lambda: False) monkeypatch.delenv("HEADROOM_IN_DOCKER", raising=False) # Deterministic, pipx/uv-free venv layout (mirrors the issue's environment). monkeypatch.setenv("PIPX_HOME", "") monkeypatch.setenv("UV_TOOL_DIR", "") monkeypatch.setattr(up.sys, "executable", "/config/.headroom-venv/bin/python") monkeypatch.setattr(up.sys, "prefix", "/config/.headroom-venv") monkeypatch.setattr( up, "_package_location", lambda: "/config/.headroom-venv/lib/python3.12/headroom" ) # The container is real (/.dockerenv), but a venv owns the install. monkeypatch.setattr(up, "_in_docker", lambda: True) monkeypatch.setattr(up, "_in_virtualenv", lambda: True) method = up.detect_install_method() assert method.kind == "pip" assert method.can_self_update is True assert method.argv[:4] == [up.sys.executable, "-m", "pip", "install"] def test_explicit_headroom_in_docker_still_refuses_over_venv(monkeypatch): """The official image's explicit HEADROOM_IN_DOCKER opt-out wins up front, even when a venv owns the install.""" monkeypatch.setattr(up, "_is_source_checkout", lambda: False) monkeypatch.setattr(up, "_is_editable_install", lambda: False) monkeypatch.setenv("HEADROOM_IN_DOCKER", "1") monkeypatch.setattr(up, "_in_virtualenv", lambda: True) method = up.detect_install_method() assert method.kind == "docker" assert method.can_self_update is False def test_bare_dockerenv_without_owner_refuses(monkeypatch): """A container whose system interpreter owns the install (bare /.dockerenv, no venv/pipx/uv/user-site) still refuses with the pull-a-new-image guidance.""" monkeypatch.setattr(up, "_is_source_checkout", lambda: False) monkeypatch.setattr(up, "_is_editable_install", lambda: False) monkeypatch.delenv("HEADROOM_IN_DOCKER", raising=False) monkeypatch.setenv("PIPX_HOME", "") monkeypatch.setenv("UV_TOOL_DIR", "") monkeypatch.setattr(up.sys, "executable", "/usr/bin/python3") monkeypatch.setattr(up.sys, "prefix", "/usr") monkeypatch.setattr(up, "_package_location", lambda: "/usr/lib/python3.12/headroom") monkeypatch.setattr(up, "_in_docker", lambda: True) monkeypatch.setattr(up, "_in_virtualenv", lambda: False) monkeypatch.setattr(up, "_is_user_site_install", lambda loc: False) method = up.detect_install_method() assert method.kind == "docker" assert method.can_self_update is False # --------------------------------------------------------------------------- # # update_check helpers / branches # --------------------------------------------------------------------------- # def test_installed_version_present(monkeypatch): monkeypatch.setattr(md, "version", lambda name: "0.26.0") assert uc.installed_version() == "0.26.0" def test_installed_version_not_found(monkeypatch): def _boom(name): raise md.PackageNotFoundError(name) monkeypatch.setattr(md, "version", _boom) assert uc.installed_version() is None def test_is_source_checkout_real_is_bool(): assert isinstance(uc._is_source_checkout(), bool) def test_in_docker_real_is_bool(): assert isinstance(uc._in_docker(), bool) def test_select_latest_skips_invalid_versions(): data = {"releases": {"not-a-version": [{}], "0.26.0": [{}]}} assert uc._select_latest(data, allow_pre=False) == "0.26.0" def test_select_latest_info_fallback_invalid_returns_none(): data = {"releases": {}, "info": {"version": "not-a-version"}} assert uc._select_latest(data, allow_pre=False) is None def test_select_latest_info_fallback_prerelease_filtered(): data = {"releases": {}, "info": {"version": "1.0.0rc1"}} assert uc._select_latest(data, allow_pre=False) is None assert uc._select_latest(data, allow_pre=True) == "1.0.0rc1" def test_run_check_disabled_returns_none(monkeypatch): monkeypatch.setenv("HEADROOM_UPDATE_CHECK", "off") monkeypatch.setattr(uc, "fetch_latest_version", lambda **k: pytest.fail("no fetch")) assert uc.run_check() is None def test_maybe_check_async_disabled_returns_none(monkeypatch): monkeypatch.setenv("HEADROOM_UPDATE_CHECK", "off") assert uc.maybe_check_async() is None def test_format_update_notice_invalid_versions(monkeypatch): uc.write_cache("not-a-version") monkeypatch.setattr(uc, "_is_source_checkout", lambda: False) monkeypatch.setattr(uc, "_in_docker", lambda: False) monkeypatch.setattr(uc, "installed_version", lambda: "0.26.0") assert uc.format_update_notice() is None def test_fetch_latest_version_info_only(monkeypatch): import json payload = json.dumps({"releases": {}, "info": {"version": "0.30.0"}}).encode() class _Resp: def __enter__(self): return self def __exit__(self, *a): return False def read(self): return payload monkeypatch.setattr(uc.urllib.request, "urlopen", lambda *a, **k: _Resp()) assert uc.fetch_latest_version() == "0.30.0"