mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
* refactor: extract _version.py from __init__.py (PR-3/8) Move version-checking helpers and `specify self` sub-commands into a focused `_version.py` module. Moved symbols: - GITHUB_API_LATEST — GitHub releases API endpoint constant - _get_installed_version — importlib.metadata-based version lookup - _normalize_tag — strip leading 'v' from release tag strings - _is_newer — PEP 440 version comparison - _fetch_latest_release_tag — single outbound call to GitHub API - self_app — Typer sub-app for `specify self` - self_check, self_upgrade — `specify self check/upgrade` commands Dependency rule: _version.py imports only stdlib + packaging + ._console. Backward compatibility: GITHUB_API_LATEST, self_check, self_upgrade remain importable from specify_cli via re-exports in __init__.py. Update test_upgrade.py to import helpers from specify_cli._version and patch at the correct module path (specify_cli._version.*). Add test_version_imports.py as regression guard. * fix(tests): update _fetch_latest_release_tag import path in test_authentication.py PR-3 moved _fetch_latest_release_tag from specify_cli into specify_cli._version. test_upgrade.py was updated at the time, but test_authentication.py::TestFetchLatestReleaseTagDelegation still imported from the old location, causing ImportError on all three delegation tests. Update all three inline imports to the correct module path.
174 lines
6.8 KiB
Python
174 lines
6.8 KiB
Python
"""Version checking and self-update commands for specify_cli.
|
|
|
|
Pure helpers for comparing PEP 440 versions and fetching the latest GitHub
|
|
release tag. The ``self_app`` Typer sub-command group is co-located here so
|
|
all version-related logic lives in one place.
|
|
|
|
Dependencies: stdlib + packaging + ._console only (no other internal imports
|
|
at module level, keeping this layer thin and circular-import-safe).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import urllib.error
|
|
|
|
import typer
|
|
from packaging.version import InvalidVersion, Version
|
|
|
|
from ._console import console
|
|
|
|
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
|
|
|
|
|
|
def _get_installed_version() -> str:
|
|
"""Return the installed specify-cli distribution version or 'unknown'.
|
|
|
|
Uses importlib.metadata so the value reflects what was actually installed
|
|
by pip/uv/pipx — not a value read from pyproject.toml. This is
|
|
intentional for `specify self check`, which should reason about the
|
|
installed distribution rather than a source-tree fallback. Callers must
|
|
treat the sentinel string 'unknown' as an indeterminate value (see FR-020).
|
|
"""
|
|
import importlib.metadata
|
|
|
|
metadata_errors = [importlib.metadata.PackageNotFoundError]
|
|
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
|
|
if invalid_metadata_error is not None:
|
|
metadata_errors.append(invalid_metadata_error)
|
|
|
|
try:
|
|
return importlib.metadata.version("specify-cli")
|
|
except tuple(metadata_errors):
|
|
return "unknown"
|
|
|
|
|
|
def _normalize_tag(tag: str) -> str:
|
|
"""Strip exactly one leading 'v' from a release tag.
|
|
|
|
Returns the rest of the string unchanged. This handles the common
|
|
'vX.Y.Z' tag convention in this repo; it MUST NOT strip more
|
|
aggressively (e.g., two leading 'v's keeps one).
|
|
"""
|
|
return tag[1:] if tag.startswith("v") else tag
|
|
|
|
|
|
def _is_newer(latest: str, current: str) -> bool:
|
|
"""Return True iff `latest` is strictly greater than `current` under PEP 440.
|
|
|
|
Returns False whenever either side is 'unknown' or fails to parse; this
|
|
keeps the comparison indeterminate (rather than crashing or falsely
|
|
recommending a downgrade) on edge inputs.
|
|
"""
|
|
if latest == "unknown" or current == "unknown":
|
|
return False
|
|
try:
|
|
return Version(latest) > Version(current)
|
|
except InvalidVersion:
|
|
return False
|
|
|
|
|
|
def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
|
|
"""Return (tag, failure_category). Exactly one outbound call, 5 s timeout.
|
|
|
|
On success: (tag_name, None).
|
|
On a documented network/HTTP failure (added in T029/T030): (None, category).
|
|
On anything else — including a malformed response body — the exception
|
|
propagates; there is no catch-all (research D-006).
|
|
"""
|
|
from .authentication.http import open_url
|
|
|
|
try:
|
|
with open_url(
|
|
GITHUB_API_LATEST,
|
|
timeout=5,
|
|
extra_headers={"Accept": "application/vnd.github+json"},
|
|
) as resp:
|
|
payload = json.loads(resp.read().decode("utf-8"))
|
|
tag = payload.get("tag_name")
|
|
if not isinstance(tag, str) or not tag:
|
|
raise ValueError("GitHub API response missing valid tag_name")
|
|
return tag, None
|
|
except urllib.error.HTTPError as e:
|
|
# Order matters: HTTPError is a subclass of URLError.
|
|
if e.code == 403:
|
|
return None, (
|
|
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
|
|
)
|
|
return None, f"HTTP {e.code}"
|
|
except (urllib.error.URLError, OSError):
|
|
return None, "offline or timeout"
|
|
|
|
|
|
# ===== Self Commands =====
|
|
|
|
self_app = typer.Typer(
|
|
name="self",
|
|
help="Manage the specify CLI itself (read-only check and reserved upgrade command).",
|
|
add_completion=False,
|
|
)
|
|
|
|
|
|
@self_app.command("check")
|
|
def self_check() -> None:
|
|
"""Check whether a newer specify-cli release is available. Read-only.
|
|
|
|
This command only checks for updates; it does not modify your installation.
|
|
The reserved (and currently non-destructive) `specify self upgrade` command
|
|
is the name that a future release will use for actual self-upgrade — its
|
|
behavior is not implemented in this release and is intentionally out of
|
|
scope here. See `specify self upgrade --help` for its current status.
|
|
"""
|
|
installed = _get_installed_version()
|
|
tag, failure_reason = _fetch_latest_release_tag()
|
|
|
|
if tag is None:
|
|
# Graceful-failure path (FR-008). `failure_reason` is one of the
|
|
# enumerated strings produced by _fetch_latest_release_tag() — it
|
|
# never contains a URL, headers, response body, or traceback.
|
|
assert failure_reason is not None
|
|
console.print(f"Installed: {installed}")
|
|
console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}")
|
|
return
|
|
|
|
latest_normalized = _normalize_tag(tag)
|
|
|
|
if installed == "unknown":
|
|
# FR-020: surface the latest release and the recovery action even
|
|
# when the local distribution metadata is unavailable.
|
|
console.print("Current version could not be determined.")
|
|
console.print(f"Latest release: {latest_normalized}")
|
|
console.print("\nTo reinstall:")
|
|
console.print(" uv tool install specify-cli --force \\")
|
|
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
|
|
return
|
|
|
|
if _is_newer(latest_normalized, installed):
|
|
console.print(f"[green]Update available:[/green] {installed} → {latest_normalized}")
|
|
console.print("\nTo upgrade:")
|
|
console.print(" uv tool install specify-cli --force \\")
|
|
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
|
|
return
|
|
|
|
# Installed is parseable AND is >= latest → "up to date" (FR-006).
|
|
# Also reached when the tag is unparseable (InvalidVersion) → _is_newer
|
|
# returns False, and the up-to-date branch is the safer default per
|
|
# FR-004 / test T016.
|
|
console.print(f"[green]Up to date:[/green] {installed}")
|
|
|
|
|
|
@self_app.command("upgrade")
|
|
def self_upgrade() -> None:
|
|
"""Reserved command surface for self-upgrade; not implemented in this release.
|
|
|
|
This command is a documented non-destructive stub in this release: it
|
|
performs no outbound network request, no install-method detection, and
|
|
invokes no installer. It prints a three-line guidance message and exits 0.
|
|
Actual self-upgrade is planned as follow-up work.
|
|
|
|
Use `specify self check` today to see whether a newer release is available
|
|
and to get a copy-pasteable reinstall command.
|
|
"""
|
|
console.print("specify self upgrade is not implemented yet.")
|
|
console.print("Run 'specify self check' to see whether a newer release is available.")
|
|
console.print("Actual self-upgrade is planned as follow-up work.")
|