headroom/tests/test_cli_update.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

354 lines
13 KiB
Python
Raw Normal View History

feat(cli): add headroom update command and release banner (#1088) ## Description Adds a `headroom update` self-update command and a passive "update available" banner, so users no longer need to remember the right `pip`/`pipx`/`uv` incantation for their environment, and long-running proxies get nudged when they drift behind a release. Closes #1087 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - `headroom/cli/update.py` — `headroom update` command. `detect_install_method()` resolves the install (git checkout, editable, Docker, pipx, uv tool, venv/conda, `pip --user`, externally-managed system Python per PEP 668, writable global) and builds the matching upgrade. pip path always uses `sys.executable -m pip` so it can't touch the wrong interpreter. Refuses with guidance where self-update is unsafe. Flags: `--check`, `--yes`, `--pre`, `--extras`. - `headroom/update_check.py` — best-effort PyPI check (stdlib `urllib`, no new dep). Split into a daemon-thread probe that caches to `~/.headroom/update_check.json` (≤ once/day) and a cache-only `format_update_notice()`. Opt-out `HEADROOM_UPDATE_CHECK=off`; skipped in `--stateless`, CI, Docker, checkouts. - `headroom/cli/main.py`, `headroom/cli/__init__.py` — register `update`; fire the background check from the group callback (skipped for `update`). - `headroom/cli/proxy.py` — render the one-line notice after the startup banner (best-effort, never blocks). - `README.md` — "Updating" section + opt-out env var. - Tests: `tests/test_update_check.py`, `tests/test_cli_update.py`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_update_check.py tests/test_cli_update.py -q 42 passed in 1.63s $ ruff check headroom/cli/update.py headroom/update_check.py headroom/cli/main.py All checks passed! $ mypy headroom/update_check.py headroom/cli/update.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: macOS, Python 3.11, source checkout - Exact command / steps: `python -m headroom.cli update --help`; `detect_install_method()` in the checkout - Observed result: command + flags render; in a checkout `detect_install_method()` returns `kind=checkout, can_self_update=False` ("update with `git pull`") and `format_update_notice()` returns `None` (dev tree not nagged) - Not tested: live PyPI fetch and a real pipx/uv-tool upgrade on this machine (covered by unit tests with mocked `urllib`/`subprocess`) ## 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 - [x] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - CHANGELOG.md is release-please-managed, so it is intentionally not hand-edited (N/A above). - Update check uses stdlib `urllib` because `httpx` lives only in the `[proxy]` extra — the base CLI must stay dependency-light.
2026-06-18 18:22:20 +02:00
"""Tests for the `headroom update` command + install-method detection."""
from __future__ import annotations
import sys
import pytest
from click.testing import CliRunner
from headroom.cli import update as up
from headroom.cli.main import main
@pytest.fixture(autouse=True)
def _clean_env(tmp_path, monkeypatch):
monkeypatch.setenv("HEADROOM_WORKSPACE_DIR", str(tmp_path))
monkeypatch.delenv("PIPX_HOME", raising=False)
monkeypatch.delenv("UV_TOOL_DIR", raising=False)
monkeypatch.delenv("CONDA_PREFIX", raising=False)
# Default: not a checkout / editable / docker / managed install.
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, "_is_externally_managed", lambda: False)
# --------------------------------------------------------------------------- #
# detect_install_method
# --------------------------------------------------------------------------- #
def test_detect_checkout(monkeypatch):
monkeypatch.setattr(up, "_is_source_checkout", lambda: True)
m = up.detect_install_method()
assert m.kind == "checkout" and m.can_self_update is False and "git pull" in m.guidance
def test_detect_editable(monkeypatch):
monkeypatch.setattr(up, "_is_editable_install", lambda: True)
m = up.detect_install_method()
assert m.kind == "editable" and m.can_self_update is False
def test_detect_docker(monkeypatch):
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-12 03:53:29 +05:30
# A bare /.dockerenv container whose system interpreter owns the install
# (no venv / pipx / uv / user-site) still refuses. When an install method
# owns it, ownership wins over the container environment (#2816) -- see
# test_update_helpers.test_venv_inside_bare_dockerenv_still_self_updates.
monkeypatch.delenv("HEADROOM_IN_DOCKER", raising=False)
feat(cli): add headroom update command and release banner (#1088) ## Description Adds a `headroom update` self-update command and a passive "update available" banner, so users no longer need to remember the right `pip`/`pipx`/`uv` incantation for their environment, and long-running proxies get nudged when they drift behind a release. Closes #1087 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - `headroom/cli/update.py` — `headroom update` command. `detect_install_method()` resolves the install (git checkout, editable, Docker, pipx, uv tool, venv/conda, `pip --user`, externally-managed system Python per PEP 668, writable global) and builds the matching upgrade. pip path always uses `sys.executable -m pip` so it can't touch the wrong interpreter. Refuses with guidance where self-update is unsafe. Flags: `--check`, `--yes`, `--pre`, `--extras`. - `headroom/update_check.py` — best-effort PyPI check (stdlib `urllib`, no new dep). Split into a daemon-thread probe that caches to `~/.headroom/update_check.json` (≤ once/day) and a cache-only `format_update_notice()`. Opt-out `HEADROOM_UPDATE_CHECK=off`; skipped in `--stateless`, CI, Docker, checkouts. - `headroom/cli/main.py`, `headroom/cli/__init__.py` — register `update`; fire the background check from the group callback (skipped for `update`). - `headroom/cli/proxy.py` — render the one-line notice after the startup banner (best-effort, never blocks). - `README.md` — "Updating" section + opt-out env var. - Tests: `tests/test_update_check.py`, `tests/test_cli_update.py`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_update_check.py tests/test_cli_update.py -q 42 passed in 1.63s $ ruff check headroom/cli/update.py headroom/update_check.py headroom/cli/main.py All checks passed! $ mypy headroom/update_check.py headroom/cli/update.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: macOS, Python 3.11, source checkout - Exact command / steps: `python -m headroom.cli update --help`; `detect_install_method()` in the checkout - Observed result: command + flags render; in a checkout `detect_install_method()` returns `kind=checkout, can_self_update=False` ("update with `git pull`") and `format_update_notice()` returns `None` (dev tree not nagged) - Not tested: live PyPI fetch and a real pipx/uv-tool upgrade on this machine (covered by unit tests with mocked `urllib`/`subprocess`) ## 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 - [x] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - CHANGELOG.md is release-please-managed, so it is intentionally not hand-edited (N/A above). - Update check uses stdlib `urllib` because `httpx` lives only in the `[proxy]` extra — the base CLI must stay dependency-light.
2026-06-18 18:22:20 +02:00
monkeypatch.setattr(up, "_in_docker", lambda: True)
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-12 03:53:29 +05:30
monkeypatch.setattr(up, "_in_virtualenv", lambda: False)
monkeypatch.setattr(up, "_is_user_site_install", lambda loc: False)
monkeypatch.setattr(up.sys, "prefix", "/usr")
monkeypatch.setattr(up.sys, "executable", "/usr/bin/python3")
feat(cli): add headroom update command and release banner (#1088) ## Description Adds a `headroom update` self-update command and a passive "update available" banner, so users no longer need to remember the right `pip`/`pipx`/`uv` incantation for their environment, and long-running proxies get nudged when they drift behind a release. Closes #1087 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - `headroom/cli/update.py` — `headroom update` command. `detect_install_method()` resolves the install (git checkout, editable, Docker, pipx, uv tool, venv/conda, `pip --user`, externally-managed system Python per PEP 668, writable global) and builds the matching upgrade. pip path always uses `sys.executable -m pip` so it can't touch the wrong interpreter. Refuses with guidance where self-update is unsafe. Flags: `--check`, `--yes`, `--pre`, `--extras`. - `headroom/update_check.py` — best-effort PyPI check (stdlib `urllib`, no new dep). Split into a daemon-thread probe that caches to `~/.headroom/update_check.json` (≤ once/day) and a cache-only `format_update_notice()`. Opt-out `HEADROOM_UPDATE_CHECK=off`; skipped in `--stateless`, CI, Docker, checkouts. - `headroom/cli/main.py`, `headroom/cli/__init__.py` — register `update`; fire the background check from the group callback (skipped for `update`). - `headroom/cli/proxy.py` — render the one-line notice after the startup banner (best-effort, never blocks). - `README.md` — "Updating" section + opt-out env var. - Tests: `tests/test_update_check.py`, `tests/test_cli_update.py`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_update_check.py tests/test_cli_update.py -q 42 passed in 1.63s $ ruff check headroom/cli/update.py headroom/update_check.py headroom/cli/main.py All checks passed! $ mypy headroom/update_check.py headroom/cli/update.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: macOS, Python 3.11, source checkout - Exact command / steps: `python -m headroom.cli update --help`; `detect_install_method()` in the checkout - Observed result: command + flags render; in a checkout `detect_install_method()` returns `kind=checkout, can_self_update=False` ("update with `git pull`") and `format_update_notice()` returns `None` (dev tree not nagged) - Not tested: live PyPI fetch and a real pipx/uv-tool upgrade on this machine (covered by unit tests with mocked `urllib`/`subprocess`) ## 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 - [x] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - CHANGELOG.md is release-please-managed, so it is intentionally not hand-edited (N/A above). - Update check uses stdlib `urllib` because `httpx` lives only in the `[proxy]` extra — the base CLI must stay dependency-light.
2026-06-18 18:22:20 +02:00
m = up.detect_install_method()
assert m.kind == "docker" and m.can_self_update is False
def test_detect_pipx_by_path(monkeypatch):
monkeypatch.setattr(up.sys, "prefix", "/home/u/.local/pipx/venvs/headroom-ai")
m = up.detect_install_method()
assert m.kind == "pipx" and m.argv == ["pipx", "upgrade", "headroom-ai"]
def test_detect_pipx_windows_path(monkeypatch):
monkeypatch.setattr(up.sys, "prefix", r"C:\\Users\\u\\pipx\\venvs\\headroom-ai")
m = up.detect_install_method()
assert m.kind == "pipx"
def test_detect_uv_tool(monkeypatch):
monkeypatch.setattr(up.sys, "prefix", "/home/u/.local/share/uv/tools/headroom-ai")
m = up.detect_install_method()
assert m.kind == "uv-tool" and m.argv == ["uv", "tool", "upgrade", "headroom-ai"]
def test_detect_venv_uses_current_interpreter(monkeypatch):
monkeypatch.setattr(up, "_in_virtualenv", lambda: True)
m = up.detect_install_method()
assert m.kind == "pip"
assert m.argv[:4] == [sys.executable, "-m", "pip", "install"]
assert "-U" in m.argv and "headroom-ai" in m.argv
def test_detect_venv_with_extras(monkeypatch):
monkeypatch.setattr(up, "_in_virtualenv", lambda: True)
m = up.detect_install_method(extras="all")
assert "headroom-ai[all]" in m.argv
def test_detect_user_site(monkeypatch):
monkeypatch.setattr(up, "_in_virtualenv", lambda: False)
monkeypatch.setattr(up, "_package_location", lambda: "/home/u/.local/site")
monkeypatch.setattr(up, "_is_user_site_install", lambda loc: True)
m = up.detect_install_method()
assert m.kind == "pip-user" and "--user" in m.argv
def test_detect_externally_managed_refuses(monkeypatch):
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)
m = up.detect_install_method()
assert m.kind == "system" and m.can_self_update is False
assert "PEP 668" in m.guidance
# --------------------------------------------------------------------------- #
# `headroom update` command
# --------------------------------------------------------------------------- #
def test_update_already_current(monkeypatch):
monkeypatch.setattr(up, "installed_version", lambda: "0.26.0")
monkeypatch.setattr(up, "fetch_latest_version", lambda **k: "0.26.0")
res = CliRunner().invoke(main, ["update"])
assert res.exit_code == 0
assert "up to date" in res.output
def test_update_check_reports_command_without_running(monkeypatch):
monkeypatch.setattr(up, "installed_version", lambda: "0.26.0")
monkeypatch.setattr(up, "fetch_latest_version", lambda **k: "0.27.0")
monkeypatch.setattr(up, "_in_virtualenv", lambda: True)
def _no_run(*a, **k):
raise AssertionError("subprocess.run must not be called with --check")
monkeypatch.setattr(up.subprocess, "run", _no_run)
res = CliRunner().invoke(main, ["update", "--check"])
assert res.exit_code == 0
assert "Update available: 0.26.0 → 0.27.0" in res.output
assert "pip" in res.output and "install" in res.output
def test_update_runs_upgrade_with_yes(monkeypatch):
calls = {}
monkeypatch.setattr(up, "installed_version", lambda: "0.26.0")
monkeypatch.setattr(up, "fetch_latest_version", lambda **k: "0.27.0")
monkeypatch.setattr(up, "_in_virtualenv", lambda: True)
fix(update): let Windows self-update replace headroom.exe (#2016) ## Description `headroom update` currently runs pip from the same `headroom.exe` process pip needs to replace. On Windows that leaves the launcher locked and the upgrade fails mid-uninstall. This reroutes Windows pip-based self-update through a short delayed Python helper that replays the original pip argv after the current launcher exits. Other update paths stay synchronous, and the printed upgrade command stays exact for manual recovery. Closes #1941 ## 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 - Replace the Windows-only `cmd.exe /c` handoff for `pip` and `pip-user` self-update with a short Python helper that replays the original pip argv after the launcher exits. - Keep every pip value, including extras, as argv data through the delayed child and preserve the manual recovery output. - Keep pipx, uv-tool, and non-Windows behavior unchanged. - Add focused regression coverage for extras containing `&`, `|`, and `>`. ## Testing - [x] Unit tests pass - [x] Linting passes - [ ] Type checking passes - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_cli_update.py tests/test_update_helpers.py -q 66 passed uv run ruff check headroom/cli/update.py tests/test_cli_update.py tests/test_update_helpers.py All checks passed uv run ruff format --check headroom/cli/update.py tests/test_cli_update.py tests/test_update_helpers.py 3 files already formatted ``` ## Real Behavior Proof - Environment: Windows pip install - Exact command / steps: run `headroom update --extras "foo&calc"`, accept the prompt, and wait for the child pip output - Observed result: Windows pip and pip-user updates now launch the original pip command through a short delayed Python helper argv, so extras stay one argument end to end, while pipx and non-Windows paths remain synchronous in the focused test coverage - Not tested: live Windows run on this host - Scope: Windows pip self-update ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes The printed upgrade command stays unchanged so the manual recovery path remains exact and copy-pasteable. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-14 11:52:47 -04:00
monkeypatch.setattr(up.sys, "platform", "linux")
fix(update): prevent _core.pyd corruption on Windows when proxy is running (#1581) ## Description On Windows, running `headroom update` while `headroom proxy` is active can corrupt the installed package by leaving the native `_core.pyd` extension in a partially upgraded state. This PR adds a safer update path around the pip invocation. Closes #1580. ## 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 - Add `safe_update()` handling for Windows native-extension update safety. - Detect whether `_core.pyd` is locked before pip runs. - Create a proactive backup when the file is not locked, then restore atomically if import integrity fails. - Warn when the proxy is running and `_core.pyd` is locked, allowing pip to fail safely without replacing the loaded file. - Use atomic replacement for restore paths. ## Testing - [x] Unit tests pass - [x] New tests added for new functionality when applicable - [x] Manual testing performed ### Test Output ```text Focused update-path tests and reviewer approval were completed on this PR before the governance body cleanup. The current body update is documentation-only metadata for PR governance. ``` ## Real Behavior Proof - Environment: Windows-focused Headroom development/review context. - Exact command / steps: Reviewed the safe update flow for locked and unlocked `_core.pyd` cases, including backup, pip invocation, import validation, and restore behavior. - Observed result: The update path avoids replacing a loaded native extension and provides an atomic restore path when an unlocked update fails validation. - Not tested: End-to-end package publication/install from PyPI as part of this body cleanup. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Additional Notes This body was normalized by a maintainer after approval so the governance parser reflects the already-reviewed PR state. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 12:36:23 -03:00
monkeypatch.setattr(up, "_find_core_pyd", lambda: None) # Skip integrity checks
feat(cli): add headroom update command and release banner (#1088) ## Description Adds a `headroom update` self-update command and a passive "update available" banner, so users no longer need to remember the right `pip`/`pipx`/`uv` incantation for their environment, and long-running proxies get nudged when they drift behind a release. Closes #1087 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - `headroom/cli/update.py` — `headroom update` command. `detect_install_method()` resolves the install (git checkout, editable, Docker, pipx, uv tool, venv/conda, `pip --user`, externally-managed system Python per PEP 668, writable global) and builds the matching upgrade. pip path always uses `sys.executable -m pip` so it can't touch the wrong interpreter. Refuses with guidance where self-update is unsafe. Flags: `--check`, `--yes`, `--pre`, `--extras`. - `headroom/update_check.py` — best-effort PyPI check (stdlib `urllib`, no new dep). Split into a daemon-thread probe that caches to `~/.headroom/update_check.json` (≤ once/day) and a cache-only `format_update_notice()`. Opt-out `HEADROOM_UPDATE_CHECK=off`; skipped in `--stateless`, CI, Docker, checkouts. - `headroom/cli/main.py`, `headroom/cli/__init__.py` — register `update`; fire the background check from the group callback (skipped for `update`). - `headroom/cli/proxy.py` — render the one-line notice after the startup banner (best-effort, never blocks). - `README.md` — "Updating" section + opt-out env var. - Tests: `tests/test_update_check.py`, `tests/test_cli_update.py`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_update_check.py tests/test_cli_update.py -q 42 passed in 1.63s $ ruff check headroom/cli/update.py headroom/update_check.py headroom/cli/main.py All checks passed! $ mypy headroom/update_check.py headroom/cli/update.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: macOS, Python 3.11, source checkout - Exact command / steps: `python -m headroom.cli update --help`; `detect_install_method()` in the checkout - Observed result: command + flags render; in a checkout `detect_install_method()` returns `kind=checkout, can_self_update=False` ("update with `git pull`") and `format_update_notice()` returns `None` (dev tree not nagged) - Not tested: live PyPI fetch and a real pipx/uv-tool upgrade on this machine (covered by unit tests with mocked `urllib`/`subprocess`) ## 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 - [x] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - CHANGELOG.md is release-please-managed, so it is intentionally not hand-edited (N/A above). - Update check uses stdlib `urllib` because `httpx` lives only in the `[proxy]` extra — the base CLI must stay dependency-light.
2026-06-18 18:22:20 +02:00
class _Result:
returncode = 0
def _run(argv, *a, **k):
calls["argv"] = argv
return _Result()
monkeypatch.setattr(up.subprocess, "run", _run)
res = CliRunner().invoke(main, ["update", "--yes"])
assert res.exit_code == 0
assert calls["argv"][:4] == [sys.executable, "-m", "pip", "install"]
assert "upgraded to 0.27.0" in res.output
fix(update): let Windows self-update replace headroom.exe (#2016) ## Description `headroom update` currently runs pip from the same `headroom.exe` process pip needs to replace. On Windows that leaves the launcher locked and the upgrade fails mid-uninstall. This reroutes Windows pip-based self-update through a short delayed Python helper that replays the original pip argv after the current launcher exits. Other update paths stay synchronous, and the printed upgrade command stays exact for manual recovery. Closes #1941 ## 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 - Replace the Windows-only `cmd.exe /c` handoff for `pip` and `pip-user` self-update with a short Python helper that replays the original pip argv after the launcher exits. - Keep every pip value, including extras, as argv data through the delayed child and preserve the manual recovery output. - Keep pipx, uv-tool, and non-Windows behavior unchanged. - Add focused regression coverage for extras containing `&`, `|`, and `>`. ## Testing - [x] Unit tests pass - [x] Linting passes - [ ] Type checking passes - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_cli_update.py tests/test_update_helpers.py -q 66 passed uv run ruff check headroom/cli/update.py tests/test_cli_update.py tests/test_update_helpers.py All checks passed uv run ruff format --check headroom/cli/update.py tests/test_cli_update.py tests/test_update_helpers.py 3 files already formatted ``` ## Real Behavior Proof - Environment: Windows pip install - Exact command / steps: run `headroom update --extras "foo&calc"`, accept the prompt, and wait for the child pip output - Observed result: Windows pip and pip-user updates now launch the original pip command through a short delayed Python helper argv, so extras stay one argument end to end, while pipx and non-Windows paths remain synchronous in the focused test coverage - Not tested: live Windows run on this host - Scope: Windows pip self-update ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes The printed upgrade command stays unchanged so the manual recovery path remains exact and copy-pasteable. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-14 11:52:47 -04:00
def test_update_windows_pip_handoff_uses_popen(monkeypatch):
calls = {}
monkeypatch.setattr(up, "installed_version", lambda: "0.26.0")
monkeypatch.setattr(up, "fetch_latest_version", lambda **k: "0.27.0")
monkeypatch.setattr(up.sys, "platform", "win32")
def _detect(extras=None):
calls["extras"] = extras
return up.InstallMethod(
kind="pip",
can_self_update=True,
argv=[
r"C:\Python313\python.exe",
"-m",
"pip",
"install",
"-U",
"headroom-ai[foo&calc]",
],
)
monkeypatch.setattr(up, "detect_install_method", _detect)
def _run(*a, **k):
raise AssertionError("Windows pip handoff must not call subprocess.run")
def _popen(argv, *a, **k):
calls["argv"] = argv
return object()
monkeypatch.setattr(up.subprocess, "run", _run)
monkeypatch.setattr(up.subprocess, "Popen", _popen)
res = CliRunner().invoke(main, ["update", "--yes", "--extras", "foo&calc"])
assert res.exit_code == 0
assert calls["extras"] == "foo&calc"
assert calls["argv"][:2] == [sys.executable, "-c"]
assert "subprocess.run" in calls["argv"][2]
assert calls["argv"][3:] == [
"-m",
"pip",
"install",
"-U",
"headroom-ai[foo&calc]",
]
assert "headroom.exe" in res.output
def test_update_windows_non_pip_path_stays_synchronous(monkeypatch):
calls = {}
monkeypatch.setattr(up, "installed_version", lambda: "0.26.0")
monkeypatch.setattr(up, "fetch_latest_version", lambda **k: "0.27.0")
monkeypatch.setattr(up.sys, "platform", "win32")
monkeypatch.setattr(
up,
"detect_install_method",
lambda extras=None: up.InstallMethod(
kind="pipx",
can_self_update=True,
argv=["pipx", "upgrade", "headroom-ai"],
),
)
def _popen(*a, **k):
raise AssertionError("pipx must stay on the synchronous path")
monkeypatch.setattr(up, "safe_update", lambda argv: calls.setdefault("safe_update", argv) and 0)
monkeypatch.setattr(up.subprocess, "Popen", _popen)
res = CliRunner().invoke(main, ["update", "--yes"])
assert res.exit_code == 0
assert calls["safe_update"] == ["pipx", "upgrade", "headroom-ai"]
feat(cli): add headroom update command and release banner (#1088) ## Description Adds a `headroom update` self-update command and a passive "update available" banner, so users no longer need to remember the right `pip`/`pipx`/`uv` incantation for their environment, and long-running proxies get nudged when they drift behind a release. Closes #1087 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - `headroom/cli/update.py` — `headroom update` command. `detect_install_method()` resolves the install (git checkout, editable, Docker, pipx, uv tool, venv/conda, `pip --user`, externally-managed system Python per PEP 668, writable global) and builds the matching upgrade. pip path always uses `sys.executable -m pip` so it can't touch the wrong interpreter. Refuses with guidance where self-update is unsafe. Flags: `--check`, `--yes`, `--pre`, `--extras`. - `headroom/update_check.py` — best-effort PyPI check (stdlib `urllib`, no new dep). Split into a daemon-thread probe that caches to `~/.headroom/update_check.json` (≤ once/day) and a cache-only `format_update_notice()`. Opt-out `HEADROOM_UPDATE_CHECK=off`; skipped in `--stateless`, CI, Docker, checkouts. - `headroom/cli/main.py`, `headroom/cli/__init__.py` — register `update`; fire the background check from the group callback (skipped for `update`). - `headroom/cli/proxy.py` — render the one-line notice after the startup banner (best-effort, never blocks). - `README.md` — "Updating" section + opt-out env var. - Tests: `tests/test_update_check.py`, `tests/test_cli_update.py`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_update_check.py tests/test_cli_update.py -q 42 passed in 1.63s $ ruff check headroom/cli/update.py headroom/update_check.py headroom/cli/main.py All checks passed! $ mypy headroom/update_check.py headroom/cli/update.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: macOS, Python 3.11, source checkout - Exact command / steps: `python -m headroom.cli update --help`; `detect_install_method()` in the checkout - Observed result: command + flags render; in a checkout `detect_install_method()` returns `kind=checkout, can_self_update=False` ("update with `git pull`") and `format_update_notice()` returns `None` (dev tree not nagged) - Not tested: live PyPI fetch and a real pipx/uv-tool upgrade on this machine (covered by unit tests with mocked `urllib`/`subprocess`) ## 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 - [x] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - CHANGELOG.md is release-please-managed, so it is intentionally not hand-edited (N/A above). - Update check uses stdlib `urllib` because `httpx` lives only in the `[proxy]` extra — the base CLI must stay dependency-light.
2026-06-18 18:22:20 +02:00
def test_update_refuses_in_checkout(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: True)
def _no_run(*a, **k):
raise AssertionError("must not upgrade a checkout")
monkeypatch.setattr(up.subprocess, "run", _no_run)
res = CliRunner().invoke(main, ["update", "--yes"])
assert res.exit_code == 0
assert "git pull" in res.output
def test_update_network_failure(monkeypatch):
monkeypatch.setattr(up, "installed_version", lambda: "0.26.0")
monkeypatch.setattr(up, "fetch_latest_version", lambda **k: None)
res = CliRunner().invoke(main, ["update"])
assert res.exit_code != 0
assert "Could not reach PyPI" in res.output
def test_update_upgrade_failure_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, "_in_virtualenv", lambda: True)
fix(update): let Windows self-update replace headroom.exe (#2016) ## Description `headroom update` currently runs pip from the same `headroom.exe` process pip needs to replace. On Windows that leaves the launcher locked and the upgrade fails mid-uninstall. This reroutes Windows pip-based self-update through a short delayed Python helper that replays the original pip argv after the current launcher exits. Other update paths stay synchronous, and the printed upgrade command stays exact for manual recovery. Closes #1941 ## 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 - Replace the Windows-only `cmd.exe /c` handoff for `pip` and `pip-user` self-update with a short Python helper that replays the original pip argv after the launcher exits. - Keep every pip value, including extras, as argv data through the delayed child and preserve the manual recovery output. - Keep pipx, uv-tool, and non-Windows behavior unchanged. - Add focused regression coverage for extras containing `&`, `|`, and `>`. ## Testing - [x] Unit tests pass - [x] Linting passes - [ ] Type checking passes - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_cli_update.py tests/test_update_helpers.py -q 66 passed uv run ruff check headroom/cli/update.py tests/test_cli_update.py tests/test_update_helpers.py All checks passed uv run ruff format --check headroom/cli/update.py tests/test_cli_update.py tests/test_update_helpers.py 3 files already formatted ``` ## Real Behavior Proof - Environment: Windows pip install - Exact command / steps: run `headroom update --extras "foo&calc"`, accept the prompt, and wait for the child pip output - Observed result: Windows pip and pip-user updates now launch the original pip command through a short delayed Python helper argv, so extras stay one argument end to end, while pipx and non-Windows paths remain synchronous in the focused test coverage - Not tested: live Windows run on this host - Scope: Windows pip self-update ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes The printed upgrade command stays unchanged so the manual recovery path remains exact and copy-pasteable. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-14 11:52:47 -04:00
monkeypatch.setattr(up.sys, "platform", "linux")
fix(update): prevent _core.pyd corruption on Windows when proxy is running (#1581) ## Description On Windows, running `headroom update` while `headroom proxy` is active can corrupt the installed package by leaving the native `_core.pyd` extension in a partially upgraded state. This PR adds a safer update path around the pip invocation. Closes #1580. ## 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 - Add `safe_update()` handling for Windows native-extension update safety. - Detect whether `_core.pyd` is locked before pip runs. - Create a proactive backup when the file is not locked, then restore atomically if import integrity fails. - Warn when the proxy is running and `_core.pyd` is locked, allowing pip to fail safely without replacing the loaded file. - Use atomic replacement for restore paths. ## Testing - [x] Unit tests pass - [x] New tests added for new functionality when applicable - [x] Manual testing performed ### Test Output ```text Focused update-path tests and reviewer approval were completed on this PR before the governance body cleanup. The current body update is documentation-only metadata for PR governance. ``` ## Real Behavior Proof - Environment: Windows-focused Headroom development/review context. - Exact command / steps: Reviewed the safe update flow for locked and unlocked `_core.pyd` cases, including backup, pip invocation, import validation, and restore behavior. - Observed result: The update path avoids replacing a loaded native extension and provides an atomic restore path when an unlocked update fails validation. - Not tested: End-to-end package publication/install from PyPI as part of this body cleanup. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Additional Notes This body was normalized by a maintainer after approval so the governance parser reflects the already-reviewed PR state. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 12:36:23 -03:00
monkeypatch.setattr(up, "_find_core_pyd", lambda: None) # Skip file operations in test
feat(cli): add headroom update command and release banner (#1088) ## Description Adds a `headroom update` self-update command and a passive "update available" banner, so users no longer need to remember the right `pip`/`pipx`/`uv` incantation for their environment, and long-running proxies get nudged when they drift behind a release. Closes #1087 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - `headroom/cli/update.py` — `headroom update` command. `detect_install_method()` resolves the install (git checkout, editable, Docker, pipx, uv tool, venv/conda, `pip --user`, externally-managed system Python per PEP 668, writable global) and builds the matching upgrade. pip path always uses `sys.executable -m pip` so it can't touch the wrong interpreter. Refuses with guidance where self-update is unsafe. Flags: `--check`, `--yes`, `--pre`, `--extras`. - `headroom/update_check.py` — best-effort PyPI check (stdlib `urllib`, no new dep). Split into a daemon-thread probe that caches to `~/.headroom/update_check.json` (≤ once/day) and a cache-only `format_update_notice()`. Opt-out `HEADROOM_UPDATE_CHECK=off`; skipped in `--stateless`, CI, Docker, checkouts. - `headroom/cli/main.py`, `headroom/cli/__init__.py` — register `update`; fire the background check from the group callback (skipped for `update`). - `headroom/cli/proxy.py` — render the one-line notice after the startup banner (best-effort, never blocks). - `README.md` — "Updating" section + opt-out env var. - Tests: `tests/test_update_check.py`, `tests/test_cli_update.py`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_update_check.py tests/test_cli_update.py -q 42 passed in 1.63s $ ruff check headroom/cli/update.py headroom/update_check.py headroom/cli/main.py All checks passed! $ mypy headroom/update_check.py headroom/cli/update.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: macOS, Python 3.11, source checkout - Exact command / steps: `python -m headroom.cli update --help`; `detect_install_method()` in the checkout - Observed result: command + flags render; in a checkout `detect_install_method()` returns `kind=checkout, can_self_update=False` ("update with `git pull`") and `format_update_notice()` returns `None` (dev tree not nagged) - Not tested: live PyPI fetch and a real pipx/uv-tool upgrade on this machine (covered by unit tests with mocked `urllib`/`subprocess`) ## 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 - [x] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - CHANGELOG.md is release-please-managed, so it is intentionally not hand-edited (N/A above). - Update check uses stdlib `urllib` because `httpx` lives only in the `[proxy]` extra — the base CLI must stay dependency-light.
2026-06-18 18:22:20 +02:00
class _Result:
returncode = 1
monkeypatch.setattr(up.subprocess, "run", lambda *a, **k: _Result())
res = CliRunner().invoke(main, ["update", "--yes"])
assert res.exit_code != 0
assert "Upgrade failed" in res.output
fix(update): prevent _core.pyd corruption on Windows when proxy is running (#1581) ## Description On Windows, running `headroom update` while `headroom proxy` is active can corrupt the installed package by leaving the native `_core.pyd` extension in a partially upgraded state. This PR adds a safer update path around the pip invocation. Closes #1580. ## 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 - Add `safe_update()` handling for Windows native-extension update safety. - Detect whether `_core.pyd` is locked before pip runs. - Create a proactive backup when the file is not locked, then restore atomically if import integrity fails. - Warn when the proxy is running and `_core.pyd` is locked, allowing pip to fail safely without replacing the loaded file. - Use atomic replacement for restore paths. ## Testing - [x] Unit tests pass - [x] New tests added for new functionality when applicable - [x] Manual testing performed ### Test Output ```text Focused update-path tests and reviewer approval were completed on this PR before the governance body cleanup. The current body update is documentation-only metadata for PR governance. ``` ## Real Behavior Proof - Environment: Windows-focused Headroom development/review context. - Exact command / steps: Reviewed the safe update flow for locked and unlocked `_core.pyd` cases, including backup, pip invocation, import validation, and restore behavior. - Observed result: The update path avoids replacing a loaded native extension and provides an atomic restore path when an unlocked update fails validation. - Not tested: End-to-end package publication/install from PyPI as part of this body cleanup. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Additional Notes This body was normalized by a maintainer after approval so the governance parser reflects the already-reviewed PR state. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 12:36:23 -03:00
# --------------------------------------------------------------------------- #
# safe_update (Windows-specific backup/restore protection)
# --------------------------------------------------------------------------- #
def test_safe_update_success(monkeypatch):
"""Test safe_update returns 0 when the command succeeds."""
class _Result:
returncode = 0
monkeypatch.setattr(up, "_find_core_pyd", lambda: None)
monkeypatch.setattr(up.subprocess, "run", lambda *a, **k: _Result())
result = up.safe_update([sys.executable, "-m", "pip", "install", "-U", "headroom-ai"])
assert result == 0
def test_safe_update_handles_missing_pyd(monkeypatch):
"""Test safe_update when _core.pyd doesn't exist."""
class _Result:
returncode = 0
monkeypatch.setattr(up, "_find_core_pyd", lambda: None)
monkeypatch.setattr(up.subprocess, "run", lambda *a, **k: _Result())
result = up.safe_update([sys.executable, "-m", "pip", "install", "-U", "headroom-ai"])
assert result == 0
def test_safe_update_passes_through_failure(monkeypatch):
"""Test safe_update returns error code when pip fails."""
class _Result:
returncode = 1
monkeypatch.setattr(up, "_find_core_pyd", lambda: None)
monkeypatch.setattr(up.subprocess, "run", lambda *a, **k: _Result())
result = up.safe_update([sys.executable, "-m", "pip", "install", "-U", "headroom-ai"])
assert result == 1
def test_safe_update_warns_but_no_backup_when_locked(monkeypatch, tmp_path):
"""When the .pyd is locked, safe_update warns but does not make a backup."""
fake_pyd = tmp_path / "_core.pyd"
fake_pyd.write_bytes(b"fake pyd content")
monkeypatch.setattr(up.sys, "platform", "win32")
monkeypatch.setattr(up, "_find_core_pyd", lambda: fake_pyd)
monkeypatch.setattr(up, "_is_pyd_locked", lambda p: True) # locked!
class _Result:
returncode = 1 # pip fails (expected — file was locked)
monkeypatch.setattr(up.subprocess, "run", lambda *a, **k: _Result())
result = up.safe_update([sys.executable, "-m", "pip", "install", "-U", "headroom-ai"])
assert result == 1
# Backup should never be created — file is locked, pip fails without corruption
assert not (tmp_path / "_core.pyd.bak").exists()
def test_safe_update_backup_and_restore_on_integrity_failure(monkeypatch, tmp_path):
"""Test safe_update backs up and restores _core.pyd if integrity check fails."""
# Create a fake .pyd file
fake_pyd = tmp_path / "_core.pyd"
fake_pyd.write_bytes(b"fake pyd content")
# Mock Windows and _core.pyd detection
monkeypatch.setattr(up.sys, "platform", "win32")
monkeypatch.setattr(up, "_find_core_pyd", lambda: fake_pyd)
monkeypatch.setattr(up, "_is_pyd_locked", lambda p: False)
class _Result:
returncode = 0
# Simulate pip success but integrity test failure
monkeypatch.setattr(up.subprocess, "run", lambda *a, **k: _Result())
monkeypatch.setattr(up, "_test_core_integrity", lambda: False)
result = up.safe_update([sys.executable, "-m", "pip", "install", "-U", "headroom-ai"])
# Should return error due to integrity failure
assert result == 1
# Backup should be cleaned up
assert not (tmp_path / "_core.pyd.bak").exists()