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