From a6e53861f159a4ebe6b4e60e902ef9f1c2065cf1 Mon Sep 17 00:00:00 2001 From: ulmentflam Date: Wed, 27 May 2026 18:55:52 -0400 Subject: [PATCH 1/2] Add cached, agent-facing update detection and recommendation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets the /autosentry skill (Claude, Cursor, Codex, OpenCode, Zed, Gemini) detect a newer release on every invocation and recommend the upgrade command, without hammering PyPI or upgrading unprompted. updater.py: - check() caches the PyPI "latest version" lookup on disk (XDG-aware, 24h TTL); use_cache=False forces a live query. Best-effort — a corrupt or unwritable cache is just a miss. - detect_install_method() recognizes Homebrew (Cellar) installs and perform_update() runs `brew upgrade autosentry` for them, falling back to install.sh only for --pre / pinned --version. cli update command: - --check now exits 0 and prints a recommendation ("→ update available — run autosentry update", or `brew upgrade autosentry` for Homebrew) so agent triage chains don't abort on a non-zero exit. - --json emits {"current","latest","is_outdated"}; --no-cache forces live. skills: AGENTS.md and the per-tool wrappers run `autosentry update --check` during triage and surface the recommendation. Tests: caching (hit/miss/ttl/allow_pre/corrupt), brew detection, and the CLI --check/--json behavior. Full suite green (206). Note: src/autosentry/cli.py is dead code — shadowed by the cli/ package — and is untouched here; flagging separately. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 18 +++++ README.md | 10 ++- src/autosentry/cli/commands/update.py | 72 +++++++++++++----- src/autosentry/templates/skills/AGENTS.md | 10 +++ src/autosentry/templates/skills/claude.md | 6 ++ src/autosentry/templates/skills/codex.md | 4 + src/autosentry/templates/skills/cursor.md | 5 ++ src/autosentry/templates/skills/gemini.toml | 4 + src/autosentry/templates/skills/opencode.md | 4 + src/autosentry/templates/skills/zed.md | 4 + src/autosentry/updater.py | 83 +++++++++++++++++++-- tests/test_cli.py | 40 ++++++++++ tests/test_updater.py | 68 ++++++++++++++++- 13 files changed, 301 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b41cdc..77453ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added — agent-driven update detection + +- **`autosentry update --check` now caches the PyPI lookup** for a day + (`~/.cache/autosentry`, XDG-aware; `--no-cache` forces a live query) and + **exits 0** whether or not you're behind, printing a recommendation + (`→ update available — run autosentry update`) when there's a newer + release. This lets the `/autosentry` skill run it on every invocation + without hammering PyPI or aborting triage chains. +- **`autosentry update --json`** emits `{"current","latest","is_outdated"}` + for scripts and agents that prefer to parse the result. +- **Homebrew installs are detected.** `update` recognizes a Cellar-based + install and runs `brew upgrade autosentry` (and recommends that command) + instead of falling back to `install.sh`. +- **Skill playbooks now nudge updates.** `AGENTS.md` and the per-tool + `/autosentry` wrappers (Claude, Cursor, Codex, OpenCode, Zed, Gemini) + run the version check during triage and recommend the upgrade command + when one is available, without upgrading unprompted. + ### Changed — default posture is agent-first - **`escalate_to_claude_after` default lowered** from `max_restarts // 2` diff --git a/README.md b/README.md index 14c61ac..4eca969 100644 --- a/README.md +++ b/README.md @@ -773,10 +773,18 @@ per-tool wrappers either embed it or reference it. ```bash autosentry update # update to latest stable -autosentry update --check # report current vs latest, no install +autosentry update --check # current vs latest; recommends how to upgrade +autosentry update --check --json # machine-readable: {"current","latest","is_outdated"} autosentry update --pre # allow pre-releases ``` +`autosentry update` auto-detects how it was installed — uv tool, pipx, +`pip --user`, or **Homebrew** — and runs the matching upgrade (`brew upgrade +autosentry` for tap installs). `--check` caches the PyPI lookup for a day +(pass `--no-cache` to force a live query) and always exits 0, so the +`/autosentry` skill can run it on every invocation and nudge you when a newer +release is out. + Or use the standalone updater (works for installs made by `install.sh` even when the CLI itself is broken): diff --git a/src/autosentry/cli/commands/update.py b/src/autosentry/cli/commands/update.py index bb6f05b..9df0743 100644 --- a/src/autosentry/cli/commands/update.py +++ b/src/autosentry/cli/commands/update.py @@ -2,12 +2,13 @@ from __future__ import annotations +import json from typing import cast import typer from autosentry.cli import app -from autosentry.cli.style import ERR, INFO, OK, WARN, console +from autosentry.cli.style import DIM, ERR, INFO, OK, WARN, console from autosentry.updater import ( Method, detect_install_method, @@ -18,9 +19,22 @@ ) +def _recommended_command() -> str: + """The upgrade command to suggest, tailored to how autosentry was installed.""" + if detect_install_method() == "brew": + return "brew upgrade autosentry" + return "autosentry update" + + @app.command() def update( check_only: bool = typer.Option(False, "--check", help="Check for an update; do not install."), + json_out: bool = typer.Option( + False, "--json", help="Machine-readable check output (implies --check)." + ), + no_cache: bool = typer.Option( + False, "--no-cache", help="Ignore the cached result and query PyPI live." + ), pre: bool = typer.Option(False, "--pre", help="Allow pre-release versions."), version: str = typer.Option( "", "--version", "-V", help="Pin to a specific version (otherwise: latest)." @@ -28,37 +42,61 @@ def update( method: str = typer.Option( "", "--method", - help="Force install method: uv | pipx | pip. Default: auto-detect.", + help="Force install method: uv | pipx | pip | brew. Default: auto-detect.", ), ) -> None: """Upgrade the installed autosentry CLI to the latest PyPI release. - Auto-detects the install method (uv tool, pipx, pip --user) and - delegates to the matching upgrade command. Falls back to running + Auto-detects the install method (uv tool, pipx, pip --user, Homebrew) + and delegates to the matching upgrade command. Falls back to running install.sh from GitHub when the install method can't be determined. - Use --check to query PyPI without installing — exits non-zero - when an update is available, useful in scripts that want to nag. - --pre allows pre-release versions; --version pins to a specific - release; --method forces the install backend. + Use --check to compare against PyPI without installing; it recommends + the right upgrade command when you're behind and exits 0 either way. + The lookup is cached for a day (--no-cache forces a live query), and + --json emits the result for scripts. --pre allows pre-releases, + --version pins a release, --method forces the install backend. """ - if check_only: + if check_only or json_out: try: - result = updater_check(allow_pre=pre) + result = updater_check(allow_pre=pre, use_cache=not no_cache) except RuntimeError as e: - console.print(f"[{ERR}]check failed:[/{ERR}] {e}") + if json_out: + typer.echo(json.dumps({"error": str(e)})) + else: + console.print(f"[{ERR}]check failed:[/{ERR}] {e}") raise typer.Exit(code=1) from e - line = f"current: {result.current} · latest: {result.latest}" + + if json_out: + typer.echo( + json.dumps( + { + "current": result.current, + "latest": result.latest, + "is_outdated": result.is_outdated, + } + ) + ) + return + if result.is_outdated: - console.print(f"[{WARN}]{line} · update available[/{WARN}]") - raise typer.Exit(code=1) - console.print(f"[{OK}]{line} · up to date[/{OK}]") + console.print( + f"[bold]autosentry[/bold] {result.current} " + f"[{DIM}](latest: {result.latest})[/{DIM}]" + ) + console.print( + f"[{WARN}]→ update available — run [bold]{_recommended_command()}[/bold][/{WARN}]" + ) + else: + console.print(f"[{OK}]autosentry {result.current} — up to date[/{OK}]") return chosen_str = method or detect_install_method() - valid_methods = {"uv", "pipx", "pip", "unknown"} + valid_methods = {"uv", "pipx", "pip", "brew", "unknown"} if chosen_str not in valid_methods: - console.print(f"[{ERR}]invalid --method '{chosen_str}'[/{ERR}] (choose: uv, pipx, pip)") + console.print( + f"[{ERR}]invalid --method '{chosen_str}'[/{ERR}] (choose: uv, pipx, pip, brew)" + ) raise typer.Exit(code=2) chosen: Method | None = None if chosen_str == "unknown" else cast(Method, chosen_str) console.print( diff --git a/src/autosentry/templates/skills/AGENTS.md b/src/autosentry/templates/skills/AGENTS.md index 3d71d8c..0272be2 100644 --- a/src/autosentry/templates/skills/AGENTS.md +++ b/src/autosentry/templates/skills/AGENTS.md @@ -34,6 +34,7 @@ command -v autosentry >/dev/null && autosentry --version # phase ≥ 1 [ -f .autosentry/state.json ] && cat .autosentry/state.json # phase ≥ 3 # phase 4 = phase 3 + the pid in state.json is alive ls -la .autosentry/recovery_request.md 2>/dev/null # phase 5? +autosentry update --check # newer release? ``` **Phase 5 is the highest-priority.** When the monitor escalates a @@ -41,6 +42,15 @@ detection in interactive mode it writes `.autosentry/recovery_request.md` and blocks waiting for `.autosentry/recovery_response.md`. Handle that *before* anything else. +**Staying current.** `autosentry update --check` compares the installed +version against PyPI (cached for a day, so it's cheap to run every time) +and exits 0 either way. If it reports `→ update available`, mention it to +the user once and recommend the exact command it prints — `autosentry +update` for pip/uv/pipx installs, or `brew upgrade autosentry` for +Homebrew. Don't run the upgrade yourself unless the user asks; it can +restart the CLI mid-session. Use `--json` if you'd rather parse the +result (`{"current","latest","is_outdated"}`). + ## Phase 5 — recovery request open When the request file is newer than the response file: diff --git a/src/autosentry/templates/skills/claude.md b/src/autosentry/templates/skills/claude.md index 27701ba..efcb8c8 100644 --- a/src/autosentry/templates/skills/claude.md +++ b/src/autosentry/templates/skills/claude.md @@ -12,8 +12,13 @@ Follow the full playbook in [`AGENTS.md`](../../AGENTS.md). Quick triage: command -v autosentry && autosentry --version [ -f autosentry.yaml ] && echo configured [ -f .autosentry/state.json ] && jq -r '{pid, restarts, last_heartbeat}' .autosentry/state.json +autosentry update --check # newer release? (cached daily) ``` +If `autosentry update --check` prints `→ update available`, tell the user +once and recommend the command it shows (`autosentry update`, or +`brew upgrade autosentry` for Homebrew). Don't upgrade unprompted. + Common entry points: - Install: `curl -fsSL https://raw.githubusercontent.com/ulmentflam/autosentry/main/install.sh | sh` @@ -22,6 +27,7 @@ Common entry points: - Live TUI: `autosentry watch` - Web viewer: `autosentry web` - Ledger summary: `autosentry analyze --since 24h` +- Check for updates: `autosentry update --check` (then `autosentry update`) - Last incident: `autosentry incidents show "$(ls -1t .autosentry/incidents | head -n1)"` If `$ARGUMENTS` was passed, route to the matching subcommand. Otherwise diff --git a/src/autosentry/templates/skills/codex.md b/src/autosentry/templates/skills/codex.md index 9ecd195..b342898 100644 --- a/src/autosentry/templates/skills/codex.md +++ b/src/autosentry/templates/skills/codex.md @@ -10,8 +10,12 @@ root — read it first. command -v autosentry && autosentry --version [ -f autosentry.yaml ] && echo configured [ -f .autosentry/state.json ] && cat .autosentry/state.json +autosentry update --check # newer release? (cached daily) ``` +- If `autosentry update --check` prints `→ update available` → mention it + once and recommend the command it shows (`autosentry update`, or + `brew upgrade autosentry` for Homebrew). Don't upgrade unprompted. - Missing CLI → suggest the install one-liner from the README. - Missing config → `autosentry init`, then configure `process.command`, `config_snapshots`, detectors, and rules. diff --git a/src/autosentry/templates/skills/cursor.md b/src/autosentry/templates/skills/cursor.md index ff85579..bfc0d28 100644 --- a/src/autosentry/templates/skills/cursor.md +++ b/src/autosentry/templates/skills/cursor.md @@ -9,8 +9,13 @@ playbook is in `AGENTS.md` at the repo root — load that first. command -v autosentry && autosentry --version # installed? [ -f autosentry.yaml ] # configured? [ -f .autosentry/state.json ] # running? +autosentry update --check # newer release? (cached daily) ``` +If `autosentry update --check` prints `→ update available`, mention it +once and recommend the command it shows (`autosentry update`, or +`brew upgrade autosentry` for Homebrew). Don't upgrade unprompted. + ## Routes - **Not installed.** Suggest: diff --git a/src/autosentry/templates/skills/gemini.toml b/src/autosentry/templates/skills/gemini.toml index fdf6de7..7bc8afd 100644 --- a/src/autosentry/templates/skills/gemini.toml +++ b/src/autosentry/templates/skills/gemini.toml @@ -32,6 +32,10 @@ at the repo root — load and follow it. Quick router: - `autosentry web` — incident browser - `autosentry incidents list` / `show ` - `autosentry analyze --since 24h` — ledger summary + - `autosentry update --check` — cached daily; if it prints + `→ update available`, mention it once and recommend the command it + shows (`autosentry update`, or `brew upgrade autosentry` for + Homebrew). Don't upgrade unprompted. Claude-driven fixes land on `autosentry/fix-` branches and only stick if the same detector doesn't re-fire inside the verify diff --git a/src/autosentry/templates/skills/opencode.md b/src/autosentry/templates/skills/opencode.md index 9d2eeb8..212c4b0 100644 --- a/src/autosentry/templates/skills/opencode.md +++ b/src/autosentry/templates/skills/opencode.md @@ -16,6 +16,10 @@ Full playbook lives in [`AGENTS.md`](../../AGENTS.md). Quick router: `.autosentry/logs/autosentry.log`. 4. Running → use `autosentry status`, `autosentry watch`, `autosentry incidents list/show`, `autosentry analyze`. +5. `autosentry update --check` (cached daily) → if it prints + `→ update available`, mention it once and recommend the command it + shows (`autosentry update`, or `brew upgrade autosentry` for + Homebrew). Don't upgrade unprompted. If three+ Claude-fixed incidents for the same detector all `kept`, propose a YAML rule that codifies the pattern. If a detector has a diff --git a/src/autosentry/templates/skills/zed.md b/src/autosentry/templates/skills/zed.md index 5a63d66..01e059b 100644 --- a/src/autosentry/templates/skills/zed.md +++ b/src/autosentry/templates/skills/zed.md @@ -19,6 +19,10 @@ root for the complete playbook. Quick router: 4. Running? Use `autosentry status`, `autosentry watch`, `autosentry web`, `autosentry incidents list/show`, `autosentry analyze --since 24h`. +5. `autosentry update --check` (cached daily) → if it prints + `→ update available`, mention it once and recommend the command it + shows (`autosentry update`, or `brew upgrade autosentry` for + Homebrew). Don't upgrade unprompted. Claude-driven fixes land on `autosentry/fix-` branches and only stick if the same detector doesn't re-fire inside the diff --git a/src/autosentry/updater.py b/src/autosentry/updater.py index 950b624..68dcd57 100644 --- a/src/autosentry/updater.py +++ b/src/autosentry/updater.py @@ -1,9 +1,10 @@ """Self-update logic for autosentry. -Detects how the CLI was installed (uv tool / pipx / pip --user / unknown) -and dispatches to the corresponding upgrade command. ``--check`` queries -PyPI for the latest version and reports current vs latest without -touching anything. +Detects how the CLI was installed (uv tool / pipx / pip --user / Homebrew / +unknown) and dispatches to the corresponding upgrade command. ``--check`` +queries PyPI for the latest version and reports current vs latest without +touching anything; the result is cached on disk so repeated agent-driven +checks don't re-hit PyPI. The detection is best-effort. If we can't tell how autosentry got here, we shell out to ``install.sh`` (downloaded fresh from GitHub) as the @@ -17,6 +18,7 @@ import shutil import subprocess import sys +import time import urllib.error import urllib.request from dataclasses import dataclass @@ -25,11 +27,16 @@ from autosentry import __version__ -Method = Literal["uv", "pipx", "pip", "unknown"] +Method = Literal["uv", "pipx", "pip", "brew", "unknown"] _PYPI_JSON = "https://pypi.org/pypi/autosentry/json" _INSTALL_SH_URL = "https://raw.githubusercontent.com/ulmentflam/autosentry/main/install.sh" +# How long a cached PyPI "latest version" stays fresh. Agents call +# ``autosentry update --check`` on every /autosentry invocation, so we +# re-query PyPI at most once a day and serve the rest from disk. +_CHECK_TTL_SECONDS = 24 * 60 * 60 + @dataclass(frozen=True) class UpdateCheck: @@ -48,6 +55,12 @@ def detect_install_method() -> Method: py = Path(sys.executable).resolve() home = Path.home() + # Homebrew installs the formula's virtualenv under + # /Cellar/autosentry//libexec; the bin/autosentry symlink + # resolves back into that Cellar path. Detect it so we recommend + # `brew upgrade` instead of clobbering the Cellar with install.sh. + if "/Cellar/autosentry/" in str(py): + return "brew" # uv tool installs land under: $HOME/.local/share/uv/tools//... if "uv/tools" in str(py) or "uv\\tools" in str(py): return "uv" @@ -103,8 +116,58 @@ def _version_key(v: str) -> tuple[int, ...]: return tuple(parts) -def check(*, allow_pre: bool = False) -> UpdateCheck: - latest = fetch_latest_version(allow_pre=allow_pre) +def _cache_path() -> Path: + """Where the last 'latest version' lookup is cached (XDG-aware).""" + base = os.environ.get("XDG_CACHE_HOME") + root = Path(base) if base else Path.home() / ".cache" + return root / "autosentry" / "update-check.json" + + +def _read_cache(*, allow_pre: bool, ttl: int) -> str | None: + """Return the cached latest version if fresh and matching, else None. + + Best-effort: any read/parse error or schema mismatch is a cache miss. + """ + try: + data = json.loads(_cache_path().read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + latest = data.get("latest") + checked_at = data.get("checked_at") + if data.get("allow_pre") != allow_pre or not isinstance(latest, str): + return None + if not isinstance(checked_at, (int, float)) or time.time() - checked_at > ttl: + return None + return latest + + +def _write_cache(*, latest: str, allow_pre: bool) -> None: + """Persist the lookup. Never raises — the cache is an optimization.""" + path = _cache_path() + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"checked_at": time.time(), "latest": latest, "allow_pre": allow_pre}), + encoding="utf-8", + ) + except OSError: + pass + + +def check( + *, allow_pre: bool = False, use_cache: bool = True, ttl: int = _CHECK_TTL_SECONDS +) -> UpdateCheck: + """Compare the running version against the latest on PyPI. + + With ``use_cache`` (the default) the PyPI lookup is served from a + disk cache for ``ttl`` seconds, so an agent can call this on every + turn without hammering PyPI. Pass ``use_cache=False`` to force a + live query. + """ + latest = _read_cache(allow_pre=allow_pre, ttl=ttl) if use_cache else None + if latest is None: + latest = fetch_latest_version(allow_pre=allow_pre) + _write_cache(latest=latest, allow_pre=allow_pre) return UpdateCheck( current=__version__, latest=latest, @@ -139,6 +202,12 @@ def perform_update( if chosen == "pip": py = os.environ.get("AUTOSENTRY_PYTHON") or sys.executable return _run([py, "-m", "pip", "install", "--user", "--upgrade", *pre, spec]) + if chosen == "brew": + # brew can't honor --pre or a pinned --version against our tap, so + # those cases fall through to install.sh. The plain case is a tap bump. + if shutil.which("brew") and not allow_pre and not version: + return _run(["brew", "upgrade", "autosentry"]) + return _fallback_to_install_sh(allow_pre=allow_pre, version=version) return _fallback_to_install_sh(allow_pre=allow_pre, version=version) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4aa2be8..f32c711 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -267,3 +267,43 @@ def test_banner_includes_version(): text = banner("9.9.9") rendered = str(text) assert "9.9.9" in rendered + + +def _stub_check(monkeypatch, *, current: str, latest: str, is_outdated: bool): + from autosentry.updater import UpdateCheck + + def fake(**_kwargs): + return UpdateCheck(current=current, latest=latest, is_outdated=is_outdated) + + monkeypatch.setattr("autosentry.cli.commands.update.updater_check", fake) + + +def test_update_check_outdated_recommends_command(runner: CliRunner, monkeypatch): + _stub_check(monkeypatch, current="0.1.0", latest="0.2.0", is_outdated=True) + result = runner.invoke(app, ["update", "--check"]) + assert result.exit_code == 0 # exits 0 so agent triage chains don't abort + out = _plain(result.output) + assert "0.2.0" in out + assert "autosentry update" in out # recommends the upgrade command + + +def test_update_check_up_to_date_is_quiet(runner: CliRunner, monkeypatch): + _stub_check(monkeypatch, current="0.2.0", latest="0.2.0", is_outdated=False) + result = runner.invoke(app, ["update", "--check"]) + assert result.exit_code == 0 + out = _plain(result.output) + assert "up to date" in out + assert "update available" not in out + + +def test_update_json_is_machine_readable(runner: CliRunner, monkeypatch): + import json + + _stub_check(monkeypatch, current="0.1.0", latest="0.2.0", is_outdated=True) + result = runner.invoke(app, ["update", "--json"]) + assert result.exit_code == 0 + assert json.loads(result.output) == { + "current": "0.1.0", + "latest": "0.2.0", + "is_outdated": True, + } diff --git a/tests/test_updater.py b/tests/test_updater.py index 6736168..fe15b78 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -8,10 +8,12 @@ from __future__ import annotations +from autosentry import updater from autosentry.updater import ( UpdateCheck, _is_stable, _version_key, + check, detect_install_method, ) @@ -49,5 +51,67 @@ def test_update_check_is_outdated_flag(): def test_detect_install_method_returns_known_value(): # We can't assert *which* method without staging a fake interpreter path, - # but the function should always return one of the four known options. - assert detect_install_method() in {"uv", "pipx", "pip", "unknown"} + # but the function should always return one of the known options. + assert detect_install_method() in {"uv", "pipx", "pip", "brew", "unknown"} + + +def test_detect_install_method_recognizes_homebrew(monkeypatch): + monkeypatch.setattr( + updater.sys, "executable", "/opt/homebrew/Cellar/autosentry/0.7.3/libexec/bin/python" + ) + assert detect_install_method() == "brew" + + +def _counting_fetch(counter: list[int], latest: str = "9.9.9"): + def fake(*, allow_pre: bool = False, timeout: int = 10) -> str: + counter.append(1) + return latest + + return fake + + +def test_check_serves_from_cache_within_ttl(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + calls: list[int] = [] + monkeypatch.setattr(updater, "fetch_latest_version", _counting_fetch(calls)) + first = check() + second = check() # fresh cache → no second PyPI hit + assert len(calls) == 1 + assert first.latest == second.latest == "9.9.9" + assert first.is_outdated # 9.9.9 > the running version + + +def test_check_no_cache_forces_live_query(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + calls: list[int] = [] + monkeypatch.setattr(updater, "fetch_latest_version", _counting_fetch(calls)) + check() + check(use_cache=False) + assert len(calls) == 2 + + +def test_check_refetches_after_ttl(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + calls: list[int] = [] + monkeypatch.setattr(updater, "fetch_latest_version", _counting_fetch(calls)) + check() + check(ttl=0) # cache instantly stale + assert len(calls) == 2 + + +def test_check_cache_keyed_on_allow_pre(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + calls: list[int] = [] + monkeypatch.setattr(updater, "fetch_latest_version", _counting_fetch(calls)) + check(allow_pre=False) + check(allow_pre=True) # different key → cache miss + assert len(calls) == 2 + + +def test_check_treats_corrupt_cache_as_miss(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + cache = tmp_path / "autosentry" / "update-check.json" + cache.parent.mkdir(parents=True) + cache.write_text("{ not json", encoding="utf-8") + monkeypatch.setattr(updater, "fetch_latest_version", _counting_fetch([])) + assert check().latest == "9.9.9" From 3537baa8bf4ca607e1721953279a1204e6da3927 Mon Sep 17 00:00:00 2001 From: ulmentflam Date: Wed, 27 May 2026 19:04:36 -0400 Subject: [PATCH 2/2] Treat non-dict cache payloads as a cache miss (CodeRabbit) _read_cache only guarded JSON parse errors and key mismatches; valid JSON of the wrong shape (e.g. `[]`) would hit data.get() and raise AttributeError, crashing check() instead of missing gracefully. Add an isinstance(data, dict) guard and a regression test. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/autosentry/updater.py | 2 ++ tests/test_updater.py | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/src/autosentry/updater.py b/src/autosentry/updater.py index 68dcd57..7a9452b 100644 --- a/src/autosentry/updater.py +++ b/src/autosentry/updater.py @@ -132,6 +132,8 @@ def _read_cache(*, allow_pre: bool, ttl: int) -> str | None: data = json.loads(_cache_path().read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return None + if not isinstance(data, dict): # valid JSON but wrong shape (e.g. []) → miss + return None latest = data.get("latest") checked_at = data.get("checked_at") if data.get("allow_pre") != allow_pre or not isinstance(latest, str): diff --git a/tests/test_updater.py b/tests/test_updater.py index fe15b78..6264f80 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -115,3 +115,13 @@ def test_check_treats_corrupt_cache_as_miss(tmp_path, monkeypatch): cache.write_text("{ not json", encoding="utf-8") monkeypatch.setattr(updater, "fetch_latest_version", _counting_fetch([])) assert check().latest == "9.9.9" + + +def test_check_treats_non_dict_cache_as_miss(tmp_path, monkeypatch): + # Valid JSON of the wrong shape ([] / "x") must miss, not crash check(). + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + cache = tmp_path / "autosentry" / "update-check.json" + cache.parent.mkdir(parents=True) + cache.write_text("[]", encoding="utf-8") + monkeypatch.setattr(updater, "fetch_latest_version", _counting_fetch([])) + assert check().latest == "9.9.9"