headroom/tests/test_update_helpers.py
Abhay Singh 7092b53c46
fix(cli/update): let install ownership win over bare /.dockerenv so venv installs self-update (#2830)
## Description

`headroom update` refuses to self-update for any install that happens to
run inside a container, including a plain `pip install` into a venv,
because `detect_install_method` checks `_in_docker()` before the pipx /
uv-tool / venv / user-site branches. The guidance it prints does not
apply: there is no Headroom image in the picture, the container is the
environment and Headroom was pip-installed into a venv inside it.

```console
$ headroom update --check
Update available: 0.32.0 -> 0.34.0
Running inside a container - pull a newer Headroom image instead of self-updating.
```

`_in_docker()` is purely environmental (`/.dockerenv` exists, or
`HEADROOM_IN_DOCKER` is set), with no reference to how the package was
installed, so `/.dockerenv` alone shadows a venv that clearly owns the
install. This hits devcontainers, GitHub Codespaces, docker/LXC
self-hosting, and dev images.

The fix splits the check by intent. An EXPLICIT `HEADROOM_IN_DOCKER`
(which the official image can set) is a deliberate opt-out and still
refuses up front, even over a venv, so the real-image behavior is
preserved. The bare `/.dockerenv` heuristic now runs after ownership
detection, so a venv / pipx / uv / user-site install self-updates and
only a container whose own system interpreter owns the install still
gets the pull-a-new-image guidance.

Fixes #2816

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/cli/update.py` (`detect_install_method`): replaced the
up-front `_in_docker()` refusal with an explicit
`os.environ.get("HEADROOM_IN_DOCKER")` refusal (the official image
opt-out), and added the bare `_in_docker()` refusal after the pipx /
uv-tool / venv / user-site branches so ownership wins over environment.
Updated the resolution-order docstring.
- `tests/test_update_helpers.py`: added
`test_venv_inside_bare_dockerenv_still_self_updates` (the fix),
`test_explicit_headroom_in_docker_still_refuses_over_venv` (image
opt-out preserved), and `test_bare_dockerenv_without_owner_refuses`
(system-interpreter container still refuses).
- `tests/test_cli_update.py` (`test_detect_docker`): updated to drive
the bare-`/.dockerenv`-no-owner path deterministically (mock ownership
to absent), since a real venv underneath now correctly wins.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
# Fail-before (source fix stashed, new test kept):
tests/test_update_helpers.py::test_venv_inside_bare_dockerenv_still_self_updates FAILED
  assert method.kind == "pip"
  AssertionError: assert 'docker' == 'pip'

# Pass-after (fix applied), all update suites:
tests/test_update_helpers.py tests/test_cli_update.py tests/test_update_check.py
95 passed

# uvx ruff@0.15.17 check  -> All checks passed!
# uvx mypy@1.20.2 headroom/cli/update.py -> Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.15.17 and mypy 1.20.2 via uvx.
- Exact command / steps: read `detect_install_method` to confirm
`_in_docker()` (line 354) preceded the pipx (377) / uv-tool (385) / venv
(392) branches, reproduced the issue's environment in a test (bare
`/.dockerenv` via `_in_docker` monkeypatched True, `HEADROOM_IN_DOCKER`
unset, a venv layout under `sys.prefix`), fail-before with `git stash
push headroom/cli/update.py` and `python -m pytest
tests/test_update_helpers.py -k venv_inside_bare_dockerenv` (the venv is
refused with `kind == "docker"`), then pass-after with `git stash pop`
and rerunning the full update suites (95 passed).
- Observed result: a venv/pip install inside a bare `/.dockerenv`
container now resolves to `kind="pip"`, `can_self_update=True`,
`argv=[sys.executable, "-m", "pip", "install", "-U", ...]`, matching the
manual command the issue reporter confirmed works. An explicit
`HEADROOM_IN_DOCKER=1` still resolves to `kind="docker"` even over a
venv, and a container whose system interpreter owns the install still
resolves to `kind="docker"`.
- Not tested: an end-to-end `headroom update` run inside a real
devcontainer against live PyPI (no container in this environment). The
resolution is a pure classification function verified directly, and the
actual upgrade command it builds is the existing, already-tested venv
path.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`: it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

The official image opt-out is preserved by design: the issue notes
`_in_docker()` already honors `HEADROOM_IN_DOCKER`, so the image can
keep refusing self-update by setting it, which this PR routes to the
explicit up-front check that wins even over a venv. Only the bare
`/.dockerenv` auto-detection was demoted below ownership.
2026-08-11 17:23:29 -05:00

403 lines
15 KiB
Python

"""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"