mirror of
https://github.com/akadeepesh/contextzip.git
synced 2026-10-20 08:36:20 +02:00
feat(cli): add contextzip update / cz update self-update command
Queries PyPI for the latest published version and compares it against the running __version__. When a newer release exists, re-invokes whichever installer matches how contextzip was installed (pip, pipx, or uv tool), always against the currently running interpreter rather than a bare 'pip' off PATH. cz update # check, then confirm before updating cz update --yes # update immediately if available cz update --check # report versions only, install nothing Never raises on network failure — a bad connection or PyPI hiccup degrades to a clear 'couldn't check' message instead of a traceback.
This commit is contained in:
@@ -74,6 +74,7 @@ from contextzip.cli_ai import (
|
||||
)
|
||||
from contextzip.cli_onboard import onboard_api_key
|
||||
from contextzip import cleanup as cleanup_mod
|
||||
from contextzip import updater as updater_mod
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -688,6 +689,90 @@ def cmd_config(
|
||||
console.print()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subcommand: update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@main.command("update")
|
||||
@click.option(
|
||||
"--check",
|
||||
"check_only",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Only check for a newer version — don't install anything.",
|
||||
)
|
||||
@click.option(
|
||||
"--yes",
|
||||
"-y",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip the confirmation prompt and update immediately if available.",
|
||||
)
|
||||
def cmd_update(check_only: bool, yes: bool) -> None:
|
||||
"""
|
||||
Check PyPI for a newer contextzip release and update in place.
|
||||
|
||||
\b
|
||||
EXAMPLES
|
||||
contextzip update # check, then confirm before updating
|
||||
contextzip update --yes # update immediately if one is available
|
||||
contextzip update --check # only report the current/latest version
|
||||
cz update # same command, short alias
|
||||
|
||||
\b
|
||||
HOW IT WORKS
|
||||
contextzip cuts frequent releases, so this beats remembering
|
||||
`pip install --upgrade contextzip` by hand. It detects whether this
|
||||
install came from pip, pipx, or uv tool and re-invokes the matching
|
||||
one, upgrading the same interpreter that's currently running —
|
||||
never a possibly-different "pip" found on PATH.
|
||||
"""
|
||||
console.print()
|
||||
with console.status("[cyan]Checking for updates…[/]", spinner="dots"):
|
||||
check = updater_mod.check_latest_version()
|
||||
|
||||
if check.latest is None:
|
||||
err(f"Could not check for updates: {check.error}")
|
||||
info(f"You're on {check.current}. Try again later, or update manually:")
|
||||
info(" pip install --upgrade contextzip")
|
||||
console.print()
|
||||
raise SystemExit(1)
|
||||
|
||||
if not check.update_available:
|
||||
ok(f"You're up to date — contextzip {check.current}")
|
||||
console.print()
|
||||
return
|
||||
|
||||
info(f"Update available: {check.current} → {check.latest}")
|
||||
|
||||
if check_only:
|
||||
info("Run contextzip update to install it.")
|
||||
console.print()
|
||||
return
|
||||
|
||||
if not yes:
|
||||
proceed = click.confirm(f" Update to {check.latest} now?", default=True)
|
||||
if not proceed:
|
||||
info("Not updating.")
|
||||
console.print()
|
||||
return
|
||||
|
||||
with console.status(f"[cyan]Updating to {check.latest}…[/]", spinner="dots"):
|
||||
result = updater_mod.run_update()
|
||||
|
||||
console.print()
|
||||
if result.ok:
|
||||
ok(f"Updated to contextzip {check.latest}", f"via {result.method}")
|
||||
info("Restart any running contextzip/watch sessions to pick it up.")
|
||||
else:
|
||||
err(f"Update via {result.method} failed")
|
||||
if result.output:
|
||||
console.print(f"[dim]{result.output}[/]")
|
||||
info("You can also update manually: pip install --upgrade contextzip")
|
||||
console.print()
|
||||
|
||||
|
||||
# Core execution logic (shared by main command + all subcommands)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
updater.py — Self-update support for `contextzip update` / `cz update`.
|
||||
|
||||
contextzip cuts frequent releases, so re-running
|
||||
`pip install --upgrade contextzip` by hand every time gets old fast.
|
||||
This module:
|
||||
|
||||
1. Asks PyPI's JSON API for the latest published version.
|
||||
2. Compares it against the version actually running (contextzip.__version__).
|
||||
3. If newer, re-invokes pip (or pipx, if that's how contextzip was
|
||||
installed) to upgrade in place.
|
||||
|
||||
Never raises on network failure — a broken connection or PyPI hiccup
|
||||
should degrade to "couldn't check right now", not crash the CLI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
from contextzip import __version__ as installed_version
|
||||
|
||||
PYPI_URL = "https://pypi.org/pypi/contextzip/json"
|
||||
_TIMEOUT_SECS = 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version comparison (no `packaging` dependency)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_version(v: str) -> tuple[int, ...]:
|
||||
"""
|
||||
Turn "1.2.3" into (1, 2, 3) for comparison. Ignores any pre-release /
|
||||
build suffix after the numeric core (e.g. "1.2.3rc1" -> (1, 2, 3)) —
|
||||
good enough for "is there a newer release" without pulling in the
|
||||
`packaging` library just for this.
|
||||
"""
|
||||
core = v.strip().lstrip("v")
|
||||
parts: list[int] = []
|
||||
for chunk in core.split("."):
|
||||
digits = ""
|
||||
for ch in chunk:
|
||||
if ch.isdigit():
|
||||
digits += ch
|
||||
else:
|
||||
break
|
||||
parts.append(int(digits) if digits else 0)
|
||||
return tuple(parts) if parts else (0,)
|
||||
|
||||
|
||||
def is_newer(latest: str, current: str) -> bool:
|
||||
"""True if *latest* is a strictly newer version than *current*."""
|
||||
a, b = _parse_version(latest), _parse_version(current)
|
||||
length = max(len(a), len(b))
|
||||
a = a + (0,) * (length - len(a))
|
||||
b = b + (0,) * (length - len(b))
|
||||
return a > b
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PyPI lookup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class VersionCheck:
|
||||
current: str
|
||||
latest: str | None # None if the lookup failed
|
||||
error: str | None = None
|
||||
|
||||
@property
|
||||
def update_available(self) -> bool:
|
||||
return bool(self.latest) and is_newer(self.latest, self.current)
|
||||
|
||||
|
||||
def check_latest_version(timeout: float = _TIMEOUT_SECS) -> VersionCheck:
|
||||
"""
|
||||
Query PyPI for the latest published contextzip version.
|
||||
|
||||
Uses httpx (already a hard dependency) but never lets a network
|
||||
problem escape as an exception — callers always get a VersionCheck
|
||||
back, with `.error` set on failure.
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(PYPI_URL, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
latest = data.get("info", {}).get("version")
|
||||
if not latest:
|
||||
return VersionCheck(
|
||||
current=installed_version, latest=None, error="Malformed PyPI response"
|
||||
)
|
||||
return VersionCheck(current=installed_version, latest=latest)
|
||||
except Exception as exc: # noqa: BLE001 — any network/parsing failure
|
||||
return VersionCheck(
|
||||
current=installed_version, latest=None, error=f"{type(exc).__name__}: {exc}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Install-method detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _installed_via_pipx() -> bool:
|
||||
"""
|
||||
Best-effort guess at whether this contextzip was installed with pipx
|
||||
rather than plain pip — pipx installs live under a `pipx/venvs/...`
|
||||
path, which shows up in the interpreter's own executable path.
|
||||
"""
|
||||
exe = sys.executable.replace("\\", "/").lower()
|
||||
return "pipx" in exe
|
||||
|
||||
|
||||
def _installed_via_uv_tool() -> bool:
|
||||
exe = sys.executable.replace("\\", "/").lower()
|
||||
return "/uv/tools/" in exe or "\\uv\\tools\\" in sys.executable.lower()
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateResult:
|
||||
ok: bool
|
||||
method: str
|
||||
output: str
|
||||
|
||||
|
||||
def run_update(timeout: float = 120) -> UpdateResult:
|
||||
"""
|
||||
Upgrade the installed contextzip package to the latest PyPI release.
|
||||
|
||||
Picks the tool that matches how contextzip was installed:
|
||||
- pipx, if this interpreter lives inside a pipx venv and the pipx
|
||||
executable is on PATH
|
||||
- uv tool, if this interpreter lives inside a uv tool install
|
||||
- otherwise `python -m pip install --upgrade contextzip`, using
|
||||
the *currently running* interpreter's pip — never a bare "pip"
|
||||
off PATH, which could silently target the wrong Python.
|
||||
"""
|
||||
if _installed_via_pipx() and shutil.which("pipx"):
|
||||
cmd = ["pipx", "upgrade", "contextzip"]
|
||||
method = "pipx"
|
||||
elif _installed_via_uv_tool() and shutil.which("uv"):
|
||||
cmd = ["uv", "tool", "upgrade", "contextzip"]
|
||||
method = "uv tool"
|
||||
else:
|
||||
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "contextzip"]
|
||||
method = "pip"
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
output = (proc.stdout or "") + (proc.stderr or "")
|
||||
return UpdateResult(ok=proc.returncode == 0, method=method, output=output.strip())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return UpdateResult(ok=False, method=method, output=f"{type(exc).__name__}: {exc}")
|
||||
Reference in New Issue
Block a user