From 9416442215ba5e49c42414758cc3e9e8883f1c8f Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Thu, 14 May 2026 10:50:10 +0800 Subject: [PATCH 1/2] refactor: extract _version.py from __init__.py (PR-3/8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/specify_cli/__init__.py | 162 ++----------------------------- src/specify_cli/_version.py | 173 ++++++++++++++++++++++++++++++++++ tests/test_upgrade.py | 26 ++--- tests/test_version_imports.py | 41 ++++++++ 4 files changed, 234 insertions(+), 168 deletions(-) create mode 100644 src/specify_cli/_version.py create mode 100644 tests/test_version_imports.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d4e8632215..a04b5147e0 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -32,12 +32,9 @@ import shutil import json import shlex -import urllib.error -import urllib.request import yaml from pathlib import Path -from packaging.version import InvalidVersion, Version from typing import Any, Optional import typer @@ -95,8 +92,12 @@ merge_json_files as merge_json_files, run_command as run_command, ) - -GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest" +from ._version import ( + GITHUB_API_LATEST as GITHUB_API_LATEST, + self_app as _self_app, + self_check as self_check, + self_upgrade as self_upgrade, +) def _build_agent_config() -> dict[str, dict[str, Any]]: """Derive AGENT_CONFIG from INTEGRATION_REGISTRY.""" @@ -1185,156 +1186,7 @@ def version(): console.print(panel) console.print() -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, -) -app.add_typer(self_app, name="self") - -@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.") +app.add_typer(_self_app, name="self") # ===== Extension Commands ===== diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py new file mode 100644 index 0000000000..0a52ac7e80 --- /dev/null +++ b/src/specify_cli/_version.py @@ -0,0 +1,173 @@ +"""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.") diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 7169c44df0..4da392c2c9 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -16,12 +16,12 @@ import pytest from typer.testing import CliRunner -from specify_cli import ( - _get_installed_version, +from specify_cli import app +from specify_cli._version import ( _fetch_latest_release_tag, + _get_installed_version, _is_newer, _normalize_tag, - app, ) from tests.conftest import strip_ansi @@ -149,7 +149,7 @@ def test_empty_string_passthrough(self): class TestUserStory1: def test_newer_available_prints_update_and_install_command(self): - with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch( "specify_cli.authentication.http.urllib.request.urlopen", return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}), ): @@ -162,7 +162,7 @@ def test_newer_available_prints_update_and_install_command(self): assert "git+https://github.com/github/spec-kit.git@v0.9.0" in output def test_up_to_date_prints_current_only(self): - with patch("specify_cli._get_installed_version", return_value="0.9.0"), patch( + with patch("specify_cli._version._get_installed_version", return_value="0.9.0"), patch( "specify_cli.authentication.http.urllib.request.urlopen", return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}), ): @@ -174,7 +174,7 @@ def test_up_to_date_prints_current_only(self): assert "git+https://" not in output def test_dev_build_ahead_of_release_is_up_to_date(self): - with patch("specify_cli._get_installed_version", return_value="0.7.5.dev0"), patch( + with patch("specify_cli._version._get_installed_version", return_value="0.7.5.dev0"), patch( "specify_cli.authentication.http.urllib.request.urlopen", return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}), ): @@ -185,7 +185,7 @@ def test_dev_build_ahead_of_release_is_up_to_date(self): assert "Up to date" in output def test_unknown_installed_still_prints_latest_and_reinstall(self): - with patch("specify_cli._get_installed_version", return_value="unknown"), patch( + with patch("specify_cli._version._get_installed_version", return_value="unknown"), patch( "specify_cli.authentication.http.urllib.request.urlopen", return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}), ): @@ -197,7 +197,7 @@ def test_unknown_installed_still_prints_latest_and_reinstall(self): assert "git+https://github.com/github/spec-kit.git@v0.7.4" in output def test_unparseable_tag_routes_to_indeterminate(self): - with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch( "specify_cli.authentication.http.urllib.request.urlopen", return_value=_mock_urlopen_response({"tag_name": "not-a-version"}), ): @@ -269,7 +269,7 @@ class TestUserStory2: def test_failure_prints_installed_plus_one_line_reason( self, expected_reason, side_effect ): - with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch( "specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect ): result = runner.invoke(app, ["self", "check"]) @@ -283,7 +283,7 @@ def test_failure_prints_installed_plus_one_line_reason( @pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES) def test_failure_exits_zero(self, _expected_reason, side_effect): - with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch( "specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect ): result = runner.invoke(app, ["self", "check"]) @@ -293,7 +293,7 @@ def test_failure_exits_zero(self, _expected_reason, side_effect): def test_failure_output_contains_no_traceback_no_url( self, _expected_reason, side_effect ): - with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch( "specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect ): result = runner.invoke(app, ["self", "check"]) @@ -390,7 +390,7 @@ def test_gh_token_never_appears_in_failure_output( ): monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN) monkeypatch.delenv("GITHUB_TOKEN", raising=False) - with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch( "specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect ): result = runner.invoke(app, ["self", "check"]) @@ -403,7 +403,7 @@ def test_github_token_never_appears_in_failure_output( ): monkeypatch.delenv("GH_TOKEN", raising=False) monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) - with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch( "specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect ): result = runner.invoke(app, ["self", "check"]) diff --git a/tests/test_version_imports.py b/tests/test_version_imports.py new file mode 100644 index 0000000000..e242a93d25 --- /dev/null +++ b/tests/test_version_imports.py @@ -0,0 +1,41 @@ +"""Regression guard: version symbols must remain importable from specify_cli.""" +from specify_cli import ( + GITHUB_API_LATEST, + self_check, + self_upgrade, +) + + +def test_version_symbols_importable(): + assert isinstance(GITHUB_API_LATEST, str) + assert GITHUB_API_LATEST.startswith("https://") + assert callable(self_check) + assert callable(self_upgrade) + + +def test_version_symbols_available_from_star_import(): + namespace = {} + exec("from specify_cli import *", namespace) + + for symbol in ("GITHUB_API_LATEST", "self_check", "self_upgrade"): + assert symbol in namespace + + +def test_version_module_symbols_directly_importable(): + from specify_cli._version import ( + GITHUB_API_LATEST, + _fetch_latest_release_tag, + _get_installed_version, + _is_newer, + _normalize_tag, + self_app, + self_check, + self_upgrade, + ) + assert callable(_get_installed_version) + assert callable(_normalize_tag) + assert callable(_is_newer) + assert callable(_fetch_latest_release_tag) + assert callable(self_check) + assert callable(self_upgrade) + assert self_app is not None From 25ff3519fd678b6443a27129506a36a7d02b3516 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Fri, 15 May 2026 11:00:26 +0800 Subject: [PATCH 2/2] 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. --- tests/test_authentication.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 938cb87650..213f8625d8 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -832,7 +832,7 @@ def side_effect(req, timeout=None): def test_gh_token_forwarded_when_configured(self, monkeypatch): from unittest.mock import MagicMock, patch - from specify_cli import _fetch_latest_release_tag + from specify_cli._version import _fetch_latest_release_tag monkeypatch.setenv("GH_TOKEN", "forwarded-sentinel") self._set_config(monkeypatch, [_github_entry()]) captured, side_effect = self._capture_request() @@ -843,7 +843,7 @@ def test_gh_token_forwarded_when_configured(self, monkeypatch): def test_no_config_means_no_auth(self, monkeypatch): from unittest.mock import patch - from specify_cli import _fetch_latest_release_tag + from specify_cli._version import _fetch_latest_release_tag self._set_config(monkeypatch, []) captured, side_effect = self._capture_request() with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect): @@ -852,7 +852,7 @@ def test_no_config_means_no_auth(self, monkeypatch): def test_accept_header_present(self, monkeypatch): from unittest.mock import patch - from specify_cli import _fetch_latest_release_tag + from specify_cli._version import _fetch_latest_release_tag self._set_config(monkeypatch, []) captured, side_effect = self._capture_request() with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):