/simple/ agreement check
Make the tray's "Update available" promise truthful: only surface a new version when uv can actually install it. Probe both /pypi/json AND /simple/ in version_check.py, treat the lesser as the installable latest.
Add one function (get_latest_from_simple_index) that queries /simple/<pkg>/ via PEP 691 JSON. Modify check_version_cached to consult both endpoints. outdated only fires when both agree. Cache schema bumps to store both versions. Tray UI surface unchanged.
urllib.request (no new deps)unittest.mock; existing tests/unit/test_version_check.py patternsuv run python -m pytest| File | Change |
|---|---|
jacked/version_check.py | Add filename regex + _parse_version_tuple helper + get_latest_from_simple_index; modify check_version_cached to consume both endpoints + new cache schema |
tests/unit/test_version_check.py | 9 new tests (per spec) |
jacked/__init__.py | Version bump 0.45.3 → 0.45.4 |
_parse_version_tuple helperThe existing is_newer has a nested parse closure. Extract it as a module-level helper so it can be reused by the new max-version logic.
Files:
jacked/version_check.pytests/unit/test_version_check.py# tests/unit/test_version_check.py — add to top of file or appropriate class
from jacked.version_check import _parse_version_tuple
def test_parse_version_tuple_basic():
assert _parse_version_tuple("0.45.3") == (0, 45, 3)
def test_parse_version_tuple_strips_local():
assert _parse_version_tuple("0.45.3+local.dev") == (0, 45, 3)
def test_parse_version_tuple_strips_dash_suffix():
assert _parse_version_tuple("0.45.3-beta") == (0, 45, 3)
def test_parse_version_tuple_stops_at_non_numeric():
assert _parse_version_tuple("0.45.3.dev1") == (0, 45, 3)
def test_parse_version_tuple_unparseable_returns_empty():
assert _parse_version_tuple("xyz") == ()
uv run python -m pytest tests/unit/test_version_check.py -k _parse_version_tuple -v — expect ImportError / 5 failures.jacked/version_check.py (insert above is_newer):
def _parse_version_tuple(v: str) -> tuple:
"""Parse a PEP 440-ish version string into a tuple of leading numeric parts.
>>> _parse_version_tuple("0.45.3")
(0, 45, 3)
>>> _parse_version_tuple("0.45.3+local")
(0, 45, 3)
>>> _parse_version_tuple("0.45.3-beta")
(0, 45, 3)
>>> _parse_version_tuple("0.45.3.dev1")
(0, 45, 3)
>>> _parse_version_tuple("xyz")
()
"""
v = v.split("+")[0].split("-")[0]
parts = []
for x in v.split("."):
try:
parts.append(int(x))
except ValueError:
break
return tuple(parts)
Then refactor is_newer to delegate to it:
def is_newer(latest: str, current: str) -> bool:
"""True if latest > current using tuple comparison. No packaging dependency."""
try:
p_latest, p_current = _parse_version_tuple(latest), _parse_version_tuple(current)
if not p_latest or not p_current:
return False
return p_latest > p_current
except (ValueError, AttributeError):
return False
uv run python -m pytest tests/unit/test_version_check.py -v — all _parse_version_tuple tests pass, existing is_newer tests still pass.git add jacked/version_check.py tests/unit/test_version_check.py
git commit -m "refactor(version_check): extract _parse_version_tuple helper
Preparing for get_latest_from_simple_index which needs to pick the max
version from a list of filenames — same parser is_newer uses."get_latest_from_simple_indexFiles: jacked/version_check.py, tests/unit/test_version_check.py
# tests/unit/test_version_check.py
from unittest.mock import patch, MagicMock
import json as _json
from jacked.version_check import get_latest_from_simple_index
# Real-shaped PEP 691 JSON fixture (trimmed)
SIMPLE_FIXTURE = {
"name": "claude-jacked",
"files": [
{"filename": "claude_jacked-0.45.0-py3-none-any.whl", "url": "..."},
{"filename": "claude_jacked-0.45.0.tar.gz", "url": "..."},
{"filename": "claude_jacked-0.45.1-py3-none-any.whl", "url": "..."},
{"filename": "claude_jacked-0.45.2-py3-none-any.whl", "url": "..."},
{"filename": "claude_jacked-0.45.3-py3-none-any.whl", "url": "..."},
],
}
def _mock_urlopen_response(payload):
"""Build a context-manager-compatible urlopen mock returning JSON bytes."""
resp = MagicMock()
resp.read.return_value = _json.dumps(payload).encode("utf-8")
cm = MagicMock()
cm.__enter__.return_value = resp
cm.__exit__.return_value = False
return cm
def test_get_latest_from_simple_parses_pep691():
with patch("urllib.request.urlopen", return_value=_mock_urlopen_response(SIMPLE_FIXTURE)):
assert get_latest_from_simple_index("claude-jacked") == "0.45.3"
def test_get_latest_from_simple_picks_max_not_first():
"""Files listed in arbitrary order — must pick semver max."""
shuffled = {"name": "claude-jacked", "files": [
{"filename": "claude_jacked-0.10.0-py3-none-any.whl"},
{"filename": "claude_jacked-0.45.3-py3-none-any.whl"},
{"filename": "claude_jacked-0.45.0-py3-none-any.whl"},
{"filename": "claude_jacked-0.9.0-py3-none-any.whl"},
]}
with patch("urllib.request.urlopen", return_value=_mock_urlopen_response(shuffled)):
assert get_latest_from_simple_index("claude-jacked") == "0.45.3"
def test_get_latest_from_simple_handles_network_error():
with patch("urllib.request.urlopen", side_effect=OSError("boom")):
assert get_latest_from_simple_index("claude-jacked") is None
def test_get_latest_from_simple_handles_bad_json():
resp = MagicMock(); resp.read.return_value = b"not json {{"
cm = MagicMock(); cm.__enter__.return_value = resp; cm.__exit__.return_value = False
with patch("urllib.request.urlopen", return_value=cm):
assert get_latest_from_simple_index("claude-jacked") is None
def test_get_latest_from_simple_empty_files_returns_none():
with patch("urllib.request.urlopen",
return_value=_mock_urlopen_response({"name": "claude-jacked", "files": []})):
assert get_latest_from_simple_index("claude-jacked") is None
def test_get_latest_from_simple_returns_exact_version_string():
"""Regression: regex must capture ONLY the version, not '-py3-none-any' tail.
Without this assertion the bug stays silent because parse_version_tuple
recovers a clean tuple."""
payload = {"name": "claude-jacked", "files": [
{"filename": "claude_jacked-0.45.3-py3-none-any.whl"},
{"filename": "claude_jacked-0.45.3.tar.gz"},
]}
with patch("urllib.request.urlopen", return_value=_mock_urlopen_response(payload)):
result = get_latest_from_simple_index("claude-jacked")
assert result == "0.45.3", f"Expected exact '0.45.3', got {result!r}"
def test_get_latest_from_simple_skips_yanked():
"""Yanked releases must NOT be advertised as available — uv refuses them."""
payload = {"name": "claude-jacked", "files": [
{"filename": "claude_jacked-0.45.0-py3-none-any.whl", "yanked": False},
{"filename": "claude_jacked-0.45.3-py3-none-any.whl", "yanked": True},
{"filename": "claude_jacked-0.45.3.tar.gz", "yanked": "broken release"},
{"filename": "claude_jacked-0.45.2-py3-none-any.whl", "yanked": False},
]}
with patch("urllib.request.urlopen", return_value=_mock_urlopen_response(payload)):
assert get_latest_from_simple_index("claude-jacked") == "0.45.2"
uv run python -m pytest tests/unit/test_version_check.py -k get_latest_from_simple -v — expect 5 failures (ImportError or function not defined).jacked/version_check.py. Add the regex at module top alongside other constants:
import re
# Matches both wheel and sdist filenames:
# claude_jacked-0.45.3-py3-none-any.whl
# claude_jacked-0.45.3.tar.gz
# PEP 503 normalizes the package name to lowercase + underscores in filenames.
_VERSION_FROM_FILENAME = re.compile(
r"^[\w.]+?-(\d+(?:\.\d+)*(?:(?:a|b|rc|\.?dev|\.?post)\d+)?)"
r"(?:-py|\.tar\.gz|\.zip|\.whl)"
)
# Captures ONLY the version segment, stopping at "-py..." for wheels or the
# archive suffix for sdists. Without the tight terminator, `-py3-none-any` would
# bleed into the capture group and the cache would store strings like
# "0.45.3-py3-none" instead of "0.45.3".
Then add the function below get_latest_pypi_version:
def get_latest_from_simple_index(package: str = "claude-jacked", timeout: float = 3.0) -> str | None:
"""Query the PEP 691 JSON variant of /simple/<package>/. Returns max non-yanked version or None.
This is THE SAME index uv reads. A version's presence here guarantees
`uv tool install --refresh` can resolve to it.
"""
try:
url = f"https://pypi.org/simple/{package}/"
req = urllib.request.Request(
url, headers={"Accept": "application/vnd.pypi.simple.v1+json"}
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read())
versions = set()
for entry in data.get("files", []):
if not isinstance(entry, dict):
continue
# Skip yanked releases — uv won't install them, so we shouldn't
# advertise them as "available". PEP 691: yanked is either bool
# or non-empty string (reason); falsy means not yanked.
if entry.get("yanked"):
continue
fn = entry.get("filename", "")
m = _VERSION_FROM_FILENAME.match(fn)
if m:
versions.add(m.group(1))
if not versions:
return None
return max(versions, key=_parse_version_tuple)
except Exception:
return None
uv run python -m pytest tests/unit/test_version_check.py -k get_latest_from_simple -v — all 5 pass.git add jacked/version_check.py tests/unit/test_version_check.py
git commit -m "feat(version_check): probe /simple/ index via PEP 691 JSON
Adds get_latest_from_simple_index() which reads the same PyPI index uv
consumes. Foundation for the pre-flight agreement check next commit."check_version_cached to consult both endpointsFiles: jacked/version_check.py, tests/unit/test_version_check.py
check_version_cached in full to understand the cache-read shortcut, the network-probe block, and the cache-write block. Identify the lines around get_latest_pypi_version() and the dict written to cache.# tests/unit/test_version_check.py
import time
from jacked.version_check import check_version_cached, VERSION_CACHE
def _isolate_cache(tmp_path, monkeypatch):
"""Point VERSION_CACHE to a tmp file for the duration of the test."""
cache = tmp_path / "vcache.json"
monkeypatch.setattr("jacked.version_check.VERSION_CACHE", cache)
return cache
def test_outdated_false_when_simple_lags_pypi(tmp_path, monkeypatch):
"""Regression: v0.45.2 -> v0.45.3 propagation lag must NOT show outdated=True."""
_isolate_cache(tmp_path, monkeypatch)
with patch("jacked.version_check.get_latest_pypi_version", return_value="0.45.3"), \
patch("jacked.version_check.get_latest_from_simple_index", return_value="0.45.2"):
result = check_version_cached("0.45.2", force=True)
assert result is not None
assert result["outdated"] is False
assert result["installable_latest"] == "0.45.2"
assert result["pypi_latest"] == "0.45.3"
assert result["simple_latest"] == "0.45.2"
def test_outdated_true_when_both_endpoints_agree(tmp_path, monkeypatch):
_isolate_cache(tmp_path, monkeypatch)
with patch("jacked.version_check.get_latest_pypi_version", return_value="0.45.3"), \
patch("jacked.version_check.get_latest_from_simple_index", return_value="0.45.3"):
result = check_version_cached("0.45.2", force=True)
assert result["outdated"] is True
assert result["installable_latest"] == "0.45.3"
def test_outdated_false_when_simple_unreachable(tmp_path, monkeypatch):
_isolate_cache(tmp_path, monkeypatch)
with patch("jacked.version_check.get_latest_pypi_version", return_value="0.45.3"), \
patch("jacked.version_check.get_latest_from_simple_index", return_value=None):
result = check_version_cached("0.45.2", force=True)
assert result["outdated"] is False
def test_outdated_false_when_pypi_unreachable(tmp_path, monkeypatch):
_isolate_cache(tmp_path, monkeypatch)
with patch("jacked.version_check.get_latest_pypi_version", return_value=None), \
patch("jacked.version_check.get_latest_from_simple_index", return_value="0.45.3"):
result = check_version_cached("0.45.2", force=True)
assert result["outdated"] is False
def test_outdated_false_when_simple_returns_garbage_version(tmp_path, monkeypatch):
"""Defensive: if /simple/ probe somehow returns an unparseable string,
fall back to outdated=false instead of caching garbage as installable_latest."""
_isolate_cache(tmp_path, monkeypatch)
with patch("jacked.version_check.get_latest_pypi_version", return_value="0.45.3"), \
patch("jacked.version_check.get_latest_from_simple_index", return_value="garbage-version-string"):
result = check_version_cached("0.45.2", force=True)
assert result["outdated"] is False
assert result["installable_latest"] == "0.45.2" # current_version, not garbage
def test_old_cache_schema_triggers_refetch(tmp_path, monkeypatch):
"""Old cache file only had 'latest' key. New schema needs pypi_latest+simple_latest.
Reader must treat the old format as a cache miss and re-probe."""
import json as _json
cache = _isolate_cache(tmp_path, monkeypatch)
cache.write_text(_json.dumps({
"latest": "0.45.3",
"checked_at": time.time(), # fresh — would normally short-circuit
}))
with patch("jacked.version_check.get_latest_pypi_version", return_value="0.45.3") as p_pypi, \
patch("jacked.version_check.get_latest_from_simple_index", return_value="0.45.3") as p_simple:
result = check_version_cached("0.45.2", force=False)
# Must have re-probed both endpoints despite fresh cache
assert p_pypi.called and p_simple.called
assert result["installable_latest"] == "0.45.3"
uv run python -m pytest tests/unit/test_version_check.py -k "outdated_ or old_cache_schema" -v — expect 5 failures.check_version_cached in jacked/version_check.py. Final shape:
def check_version_cached(current_version: str, force: bool = False) -> dict | None:
"""Check PyPI with 24h cache. Returns dict with keys:
latest (alias for installable_latest, kept for back-compat),
installable_latest, pypi_latest, simple_latest, outdated, checked_at, next_check_at.
Both /pypi/json and /simple/ are probed; outdated is true only when the LESSER
of the two (== what uv would actually install) is newer than current_version.
This eliminates phantom 'Update available' badges when PyPI's /simple/ index
is still propagating a newly-published release.
"""
try:
now = time.time()
if not force:
try:
if VERSION_CACHE.exists():
cache = json.loads(VERSION_CACHE.read_text(encoding="utf-8"))
checked_at = cache.get("checked_at", 0)
age = now - checked_at
if 0 <= age < CACHE_TTL:
# New-schema cache hit
installable = cache.get("installable_latest")
if installable:
return {
"latest": installable, # back-compat alias
"installable_latest": installable,
"pypi_latest": cache.get("pypi_latest"),
"simple_latest": cache.get("simple_latest"),
"outdated": is_newer(installable, current_version),
"ahead": is_newer(current_version, installable),
"checked_at": checked_at,
"next_check_at": checked_at + CACHE_TTL,
}
# Old schema (just 'latest') — treat as miss, re-fetch
except Exception:
pass
pypi_latest = get_latest_pypi_version()
simple_latest = get_latest_from_simple_index()
# Conservative: if either probe failed OR returned an unparseable
# version (defensive — PyPI shouldn't ever do this, but a corrupted
# cache/proxy could), do not surface an update.
if not pypi_latest or not simple_latest:
installable = current_version
else:
p_pypi = _parse_version_tuple(pypi_latest)
p_simple = _parse_version_tuple(simple_latest)
if not p_pypi or not p_simple:
installable = current_version
else:
# The LESSER of the two — what uv would actually resolve to.
installable = pypi_latest if p_pypi <= p_simple else simple_latest
outdated = is_newer(installable, current_version)
cache_data = {
"checked_at": now,
"pypi_latest": pypi_latest,
"simple_latest": simple_latest,
"installable_latest": installable,
"latest": installable, # back-compat for downgrade to pre-v0.45.4
"current": current_version,
"outdated": outdated,
}
# Atomic write — tempfile + os.replace — preserves the crash-safety
# guarantee the prior implementation had. Half-written cache files
# otherwise survive Ctrl-C / OOM during write and silently break
# subsequent reads.
try:
import tempfile, os
VERSION_CACHE.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=str(VERSION_CACHE.parent),
prefix=".vcache-", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(cache_data, fh)
os.replace(tmp_path, VERSION_CACHE)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
except Exception:
pass
return {
"latest": installable,
"installable_latest": installable,
"pypi_latest": pypi_latest,
"simple_latest": simple_latest,
"outdated": outdated,
"ahead": is_newer(current_version, installable),
"checked_at": now,
"next_check_at": now + CACHE_TTL,
}
except Exception:
return None
The min-via-tuple-compare line is the single source of truth for picking the installable version. Empty-tuple parses sort smallest (handled by the fallthrough when either probe is None).uv run python -m pytest tests/unit/test_version_check.py -v — all new tests pass + every prior test still passes.git add jacked/version_check.py tests/unit/test_version_check.py
git commit -m "fix(version_check): require /pypi/json AND /simple/ to agree before flagging outdated
Closes the 'phantom Update available' UX bug: when a new release lands on
/pypi/json (instant) but /simple/ is still propagating (seconds to minutes
of Fastly cache lag), the tray would show an Update button that uv can't
actually install — leading to 'Update complete — now v0.45.2' when the
user clicked Update expecting 0.45.3.
Now: outdated=true only when the LESSER of the two endpoints (== what uv
will resolve to) is newer than the installed version. If either endpoint
fails, default to outdated=false (conservative — never surface a phantom).
Cache schema gained pypi_latest + simple_latest + installable_latest.
Old-schema cache files trigger a re-fetch on first read.
Refs: docs/superpowers/specs/2026-05-20-update-ux-pre-flight-simple-index.html"jacked/__init__.py: __version__ = "0.45.4"uv run python -m pytest tests/unit/test_version_check.py -v — all pass.git add jacked/__init__.py
git commit -m "chore: bump version to 0.45.4"
git tag v0.45.4
git push origin master --tagsuntil curl -s https://pypi.org/simple/claude-jacked/ | grep -q "claude_jacked-0.45.4"; do sleep 5; donerm ~/.claude/jacked-version-cache.jsonuv run python -m pytest tests/unit/test_version_check.py -v — must show all new tests pass + existing tests unaffecteduv run python -m pytest -q --tb=line — full suite; 5 pre-existing analytics/dual_token failures remain (unrelated)/simple/ propagates; verify the actual landed version matches the advertised target| Risk | Mitigation |
|---|---|
| PEP 691 JSON not enabled at PyPI for some mirror | Falls into the network-error path → returns None → outdated=false. Conservative. |
| Filename regex misses an exotic distribution format | Versions parsed from filenames — if regex misses ALL files, returns None → outdated=false. |
| min() of two version strings — wrong if one parses to empty tuple | Use _parse_version_tuple consistently; empty tuple sorts as the smallest, so an unparseable side never wins as "max" and the lesser of (parseable, empty) is empty → installable=current → outdated=false. |
| Old cache invalidation churns through one extra PyPI fetch per user on upgrade | Acceptable — happens once per user; no operational issue. PyPI tolerates this easily. |
| Discovery slower by ~100-200ms (one extra HTTP call) | Cache absorbs all but first poll. First poll runs in tray's background thread. |