Skip to content

Commit d947fda

Browse files
refactor: extract _version.py from __init__.py (PR-3/8) (#2550)
* 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.
1 parent 13c167e commit d947fda

5 files changed

Lines changed: 237 additions & 171 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 7 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,9 @@
3232
import shutil
3333
import json
3434
import shlex
35-
import urllib.error
36-
import urllib.request
3735
import yaml
3836
from pathlib import Path
3937

40-
from packaging.version import InvalidVersion, Version
4138
from typing import Any, Optional
4239

4340
import typer
@@ -95,8 +92,12 @@
9592
merge_json_files as merge_json_files,
9693
run_command as run_command,
9794
)
98-
99-
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
95+
from ._version import (
96+
GITHUB_API_LATEST as GITHUB_API_LATEST,
97+
self_app as _self_app,
98+
self_check as self_check,
99+
self_upgrade as self_upgrade,
100+
)
100101

101102
def _build_agent_config() -> dict[str, dict[str, Any]]:
102103
"""Derive AGENT_CONFIG from INTEGRATION_REGISTRY."""
@@ -1230,156 +1231,7 @@ def version(
12301231
console.print(panel)
12311232
console.print()
12321233

1233-
def _get_installed_version() -> str:
1234-
"""Return the installed specify-cli distribution version or 'unknown'.
1235-
1236-
Uses importlib.metadata so the value reflects what was actually installed
1237-
by pip/uv/pipx — not a value read from pyproject.toml. This is
1238-
intentional for `specify self check`, which should reason about the
1239-
installed distribution rather than a source-tree fallback. Callers must
1240-
treat the sentinel string 'unknown' as an indeterminate value (see FR-020).
1241-
"""
1242-
1243-
import importlib.metadata
1244-
1245-
metadata_errors = [importlib.metadata.PackageNotFoundError]
1246-
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
1247-
if invalid_metadata_error is not None:
1248-
metadata_errors.append(invalid_metadata_error)
1249-
1250-
try:
1251-
return importlib.metadata.version("specify-cli")
1252-
except tuple(metadata_errors):
1253-
return "unknown"
1254-
1255-
def _normalize_tag(tag: str) -> str:
1256-
"""Strip exactly one leading 'v' from a release tag.
1257-
1258-
Returns the rest of the string unchanged. This handles the common
1259-
'vX.Y.Z' tag convention in this repo; it MUST NOT strip more
1260-
aggressively (e.g., two leading 'v's keeps one).
1261-
"""
1262-
return tag[1:] if tag.startswith("v") else tag
1263-
1264-
def _is_newer(latest: str, current: str) -> bool:
1265-
"""Return True iff `latest` is strictly greater than `current` under PEP 440.
1266-
1267-
Returns False whenever either side is 'unknown' or fails to parse; this
1268-
keeps the comparison indeterminate (rather than crashing or falsely
1269-
recommending a downgrade) on edge inputs.
1270-
"""
1271-
if latest == "unknown" or current == "unknown":
1272-
return False
1273-
try:
1274-
return Version(latest) > Version(current)
1275-
except InvalidVersion:
1276-
return False
1277-
1278-
1279-
def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
1280-
"""Return (tag, failure_category). Exactly one outbound call, 5 s timeout.
1281-
1282-
On success: (tag_name, None).
1283-
On a documented network/HTTP failure (added in T029/T030): (None, category).
1284-
On anything else — including a malformed response body — the exception
1285-
propagates; there is no catch-all (research D-006).
1286-
"""
1287-
from .authentication.http import open_url
1288-
1289-
try:
1290-
with open_url(
1291-
GITHUB_API_LATEST,
1292-
timeout=5,
1293-
extra_headers={"Accept": "application/vnd.github+json"},
1294-
) as resp:
1295-
payload = json.loads(resp.read().decode("utf-8"))
1296-
tag = payload.get("tag_name")
1297-
if not isinstance(tag, str) or not tag:
1298-
raise ValueError("GitHub API response missing valid tag_name")
1299-
return tag, None
1300-
except urllib.error.HTTPError as e:
1301-
# Order matters: HTTPError is a subclass of URLError.
1302-
if e.code == 403:
1303-
return None, (
1304-
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
1305-
)
1306-
return None, f"HTTP {e.code}"
1307-
except (urllib.error.URLError, OSError):
1308-
return None, "offline or timeout"
1309-
1310-
1311-
# ===== Self Commands =====
1312-
self_app = typer.Typer(
1313-
name="self",
1314-
help="Manage the specify CLI itself (read-only check and reserved upgrade command).",
1315-
add_completion=False,
1316-
)
1317-
app.add_typer(self_app, name="self")
1318-
1319-
@self_app.command("check")
1320-
def self_check() -> None:
1321-
"""Check whether a newer specify-cli release is available. Read-only.
1322-
1323-
This command only checks for updates; it does not modify your installation.
1324-
The reserved (and currently non-destructive) `specify self upgrade` command
1325-
is the name that a future release will use for actual self-upgrade — its
1326-
behavior is not implemented in this release and is intentionally out of
1327-
scope here. See `specify self upgrade --help` for its current status.
1328-
"""
1329-
1330-
installed = _get_installed_version()
1331-
tag, failure_reason = _fetch_latest_release_tag()
1332-
1333-
if tag is None:
1334-
# Graceful-failure path (FR-008). `failure_reason` is one of the
1335-
# enumerated strings produced by _fetch_latest_release_tag() — it
1336-
# never contains a URL, headers, response body, or traceback.
1337-
assert failure_reason is not None
1338-
console.print(f"Installed: {installed}")
1339-
console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}")
1340-
return
1341-
1342-
latest_normalized = _normalize_tag(tag)
1343-
1344-
if installed == "unknown":
1345-
# FR-020: surface the latest release and the recovery action even
1346-
# when the local distribution metadata is unavailable.
1347-
console.print("Current version could not be determined.")
1348-
console.print(f"Latest release: {latest_normalized}")
1349-
console.print("\nTo reinstall:")
1350-
console.print(" uv tool install specify-cli --force \\")
1351-
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
1352-
return
1353-
1354-
if _is_newer(latest_normalized, installed):
1355-
console.print(f"[green]Update available:[/green] {installed}{latest_normalized}")
1356-
console.print("\nTo upgrade:")
1357-
console.print(" uv tool install specify-cli --force \\")
1358-
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
1359-
return
1360-
1361-
# Installed is parseable AND is >= latest → "up to date" (FR-006).
1362-
# Also reached when the tag is unparseable (InvalidVersion) → _is_newer
1363-
# returns False, and the up-to-date branch is the safer default per
1364-
# FR-004 / test T016.
1365-
console.print(f"[green]Up to date:[/green] {installed}")
1366-
1367-
1368-
@self_app.command("upgrade")
1369-
def self_upgrade() -> None:
1370-
"""Reserved command surface for self-upgrade; not implemented in this release.
1371-
1372-
This command is a documented non-destructive stub in this release: it
1373-
performs no outbound network request, no install-method detection, and
1374-
invokes no installer. It prints a three-line guidance message and exits 0.
1375-
Actual self-upgrade is planned as follow-up work.
1376-
1377-
Use `specify self check` today to see whether a newer release is available
1378-
and to get a copy-pasteable reinstall command.
1379-
"""
1380-
console.print("specify self upgrade is not implemented yet.")
1381-
console.print("Run 'specify self check' to see whether a newer release is available.")
1382-
console.print("Actual self-upgrade is planned as follow-up work.")
1234+
app.add_typer(_self_app, name="self")
13831235

13841236

13851237
# ===== Extension Commands =====

src/specify_cli/_version.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Version checking and self-update commands for specify_cli.
2+
3+
Pure helpers for comparing PEP 440 versions and fetching the latest GitHub
4+
release tag. The ``self_app`` Typer sub-command group is co-located here so
5+
all version-related logic lives in one place.
6+
7+
Dependencies: stdlib + packaging + ._console only (no other internal imports
8+
at module level, keeping this layer thin and circular-import-safe).
9+
"""
10+
from __future__ import annotations
11+
12+
import json
13+
import urllib.error
14+
15+
import typer
16+
from packaging.version import InvalidVersion, Version
17+
18+
from ._console import console
19+
20+
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
21+
22+
23+
def _get_installed_version() -> str:
24+
"""Return the installed specify-cli distribution version or 'unknown'.
25+
26+
Uses importlib.metadata so the value reflects what was actually installed
27+
by pip/uv/pipx — not a value read from pyproject.toml. This is
28+
intentional for `specify self check`, which should reason about the
29+
installed distribution rather than a source-tree fallback. Callers must
30+
treat the sentinel string 'unknown' as an indeterminate value (see FR-020).
31+
"""
32+
import importlib.metadata
33+
34+
metadata_errors = [importlib.metadata.PackageNotFoundError]
35+
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
36+
if invalid_metadata_error is not None:
37+
metadata_errors.append(invalid_metadata_error)
38+
39+
try:
40+
return importlib.metadata.version("specify-cli")
41+
except tuple(metadata_errors):
42+
return "unknown"
43+
44+
45+
def _normalize_tag(tag: str) -> str:
46+
"""Strip exactly one leading 'v' from a release tag.
47+
48+
Returns the rest of the string unchanged. This handles the common
49+
'vX.Y.Z' tag convention in this repo; it MUST NOT strip more
50+
aggressively (e.g., two leading 'v's keeps one).
51+
"""
52+
return tag[1:] if tag.startswith("v") else tag
53+
54+
55+
def _is_newer(latest: str, current: str) -> bool:
56+
"""Return True iff `latest` is strictly greater than `current` under PEP 440.
57+
58+
Returns False whenever either side is 'unknown' or fails to parse; this
59+
keeps the comparison indeterminate (rather than crashing or falsely
60+
recommending a downgrade) on edge inputs.
61+
"""
62+
if latest == "unknown" or current == "unknown":
63+
return False
64+
try:
65+
return Version(latest) > Version(current)
66+
except InvalidVersion:
67+
return False
68+
69+
70+
def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
71+
"""Return (tag, failure_category). Exactly one outbound call, 5 s timeout.
72+
73+
On success: (tag_name, None).
74+
On a documented network/HTTP failure (added in T029/T030): (None, category).
75+
On anything else — including a malformed response body — the exception
76+
propagates; there is no catch-all (research D-006).
77+
"""
78+
from .authentication.http import open_url
79+
80+
try:
81+
with open_url(
82+
GITHUB_API_LATEST,
83+
timeout=5,
84+
extra_headers={"Accept": "application/vnd.github+json"},
85+
) as resp:
86+
payload = json.loads(resp.read().decode("utf-8"))
87+
tag = payload.get("tag_name")
88+
if not isinstance(tag, str) or not tag:
89+
raise ValueError("GitHub API response missing valid tag_name")
90+
return tag, None
91+
except urllib.error.HTTPError as e:
92+
# Order matters: HTTPError is a subclass of URLError.
93+
if e.code == 403:
94+
return None, (
95+
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
96+
)
97+
return None, f"HTTP {e.code}"
98+
except (urllib.error.URLError, OSError):
99+
return None, "offline or timeout"
100+
101+
102+
# ===== Self Commands =====
103+
104+
self_app = typer.Typer(
105+
name="self",
106+
help="Manage the specify CLI itself (read-only check and reserved upgrade command).",
107+
add_completion=False,
108+
)
109+
110+
111+
@self_app.command("check")
112+
def self_check() -> None:
113+
"""Check whether a newer specify-cli release is available. Read-only.
114+
115+
This command only checks for updates; it does not modify your installation.
116+
The reserved (and currently non-destructive) `specify self upgrade` command
117+
is the name that a future release will use for actual self-upgrade — its
118+
behavior is not implemented in this release and is intentionally out of
119+
scope here. See `specify self upgrade --help` for its current status.
120+
"""
121+
installed = _get_installed_version()
122+
tag, failure_reason = _fetch_latest_release_tag()
123+
124+
if tag is None:
125+
# Graceful-failure path (FR-008). `failure_reason` is one of the
126+
# enumerated strings produced by _fetch_latest_release_tag() — it
127+
# never contains a URL, headers, response body, or traceback.
128+
assert failure_reason is not None
129+
console.print(f"Installed: {installed}")
130+
console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}")
131+
return
132+
133+
latest_normalized = _normalize_tag(tag)
134+
135+
if installed == "unknown":
136+
# FR-020: surface the latest release and the recovery action even
137+
# when the local distribution metadata is unavailable.
138+
console.print("Current version could not be determined.")
139+
console.print(f"Latest release: {latest_normalized}")
140+
console.print("\nTo reinstall:")
141+
console.print(" uv tool install specify-cli --force \\")
142+
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
143+
return
144+
145+
if _is_newer(latest_normalized, installed):
146+
console.print(f"[green]Update available:[/green] {installed}{latest_normalized}")
147+
console.print("\nTo upgrade:")
148+
console.print(" uv tool install specify-cli --force \\")
149+
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
150+
return
151+
152+
# Installed is parseable AND is >= latest → "up to date" (FR-006).
153+
# Also reached when the tag is unparseable (InvalidVersion) → _is_newer
154+
# returns False, and the up-to-date branch is the safer default per
155+
# FR-004 / test T016.
156+
console.print(f"[green]Up to date:[/green] {installed}")
157+
158+
159+
@self_app.command("upgrade")
160+
def self_upgrade() -> None:
161+
"""Reserved command surface for self-upgrade; not implemented in this release.
162+
163+
This command is a documented non-destructive stub in this release: it
164+
performs no outbound network request, no install-method detection, and
165+
invokes no installer. It prints a three-line guidance message and exits 0.
166+
Actual self-upgrade is planned as follow-up work.
167+
168+
Use `specify self check` today to see whether a newer release is available
169+
and to get a copy-pasteable reinstall command.
170+
"""
171+
console.print("specify self upgrade is not implemented yet.")
172+
console.print("Run 'specify self check' to see whether a newer release is available.")
173+
console.print("Actual self-upgrade is planned as follow-up work.")

tests/test_authentication.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -832,7 +832,7 @@ def side_effect(req, timeout=None):
832832

833833
def test_gh_token_forwarded_when_configured(self, monkeypatch):
834834
from unittest.mock import MagicMock, patch
835-
from specify_cli import _fetch_latest_release_tag
835+
from specify_cli._version import _fetch_latest_release_tag
836836
monkeypatch.setenv("GH_TOKEN", "forwarded-sentinel")
837837
self._set_config(monkeypatch, [_github_entry()])
838838
captured, side_effect = self._capture_request()
@@ -843,7 +843,7 @@ def test_gh_token_forwarded_when_configured(self, monkeypatch):
843843

844844
def test_no_config_means_no_auth(self, monkeypatch):
845845
from unittest.mock import patch
846-
from specify_cli import _fetch_latest_release_tag
846+
from specify_cli._version import _fetch_latest_release_tag
847847
self._set_config(monkeypatch, [])
848848
captured, side_effect = self._capture_request()
849849
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):
852852

853853
def test_accept_header_present(self, monkeypatch):
854854
from unittest.mock import patch
855-
from specify_cli import _fetch_latest_release_tag
855+
from specify_cli._version import _fetch_latest_release_tag
856856
self._set_config(monkeypatch, [])
857857
captured, side_effect = self._capture_request()
858858
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):

0 commit comments

Comments
 (0)