diff --git a/design/cli.md b/design/cli.md index 9ee3c91a90..0829df74f1 100644 --- a/design/cli.md +++ b/design/cli.md @@ -167,6 +167,15 @@ Do not add a nested `_commands.py` merely for symmetry. Create one only when the nested group develops substantial shared command infrastructure that no longer fits cleanly in `__init__.py` and `_helpers.py`. +### Singular command groups + +Use the repository's plural command-package convention even when a user-facing +CLI namespace is singular. The `specify self` group therefore lives in +`specify_cli/selfs/`, while the established `specify_cli._version` module +remains the version-domain API and monkeypatch surface. The command adapters +resolve patch-owned `_version` attributes at execution time, and `_version` +re-exports the command symbols for compatibility. + Do not create a nested directory for an implementation phase that is not a CLI subcommand. For example, an `update/` directory would incorrectly suggest an `extension update ...` subcommand group. Use `_command_update_.py` diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1267135385..95e023eed0 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -70,10 +70,10 @@ ) 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, ) +from .selfs import self_app as _self_app +from .selfs import self_check as self_check +from .selfs import self_upgrade as self_upgrade from ._agent_config import ( AGENT_CONFIG as AGENT_CONFIG, DEFAULT_INIT_INTEGRATION as DEFAULT_INIT_INTEGRATION, diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 962e3adfff..93b4c6a618 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -1,8 +1,9 @@ -"""Version checking and self-update commands for specify_cli. +"""Version checking and self-update domain 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. +release tag. This module preserves the established ``specify_cli._version`` +import and monkeypatch surface while ``specify_cli.selfs`` owns the +``specify self`` command adapters. Dependencies: stdlib + packaging + ._console + ._download_security only (keeping this layer thin and circular-import-safe). @@ -27,8 +28,6 @@ import typer from packaging.version import InvalidVersion, Version -from rich.markup import escape as _escape_markup - from ._download_security import MAX_JSON_METADATA_BYTES, read_response_limited from ._console import console @@ -1136,312 +1135,12 @@ def _emit_failure( raise RuntimeError(f"Unknown failure category: {category!r}") -# ===== Self Commands ===== -self_app = typer.Typer( - name="self", - help=( - "Manage the specify CLI itself: check for newer releases, " - "preview upgrades with --dry-run, and upgrade in place." - ), - 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. - Use `specify self upgrade` to actually perform the upgrade once you've seen - the result here, or `specify self upgrade --dry-run` to preview the - installer command without running it. - """ - - 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 +def _command_exports(): + """Load the nested self command group after the domain API is defined.""" + from .selfs import self_app, self_check, self_upgrade - manual_tag = _manual_tag_or_placeholder(tag) - latest_display = manual_tag or _MANUAL_TAG_PLACEHOLDER + return self_app, self_check, self_upgrade - if manual_tag is None: - if installed == "unknown": - console.print("Current version could not be determined.") - console.print(f"Latest release: {latest_display}") - else: - console.print(f"Installed: {installed}") - console.print(f"Latest release: {latest_display}") - console.print("[yellow]Could not validate latest release tag from GitHub.[/yellow]") - console.print("\nManual fallback:") - console.print( - f" uv tool install specify-cli --force --from {_manual_source_spec(manual_tag)}" - ) - console.print(f" pipx install --force {_manual_source_spec(manual_tag)}") - return - 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_display}") - console.print("\nManual fallback:") - console.print( - f" uv tool install specify-cli --force --from {_manual_source_spec(manual_tag)}" - ) - console.print(f" pipx install --force {_manual_source_spec(manual_tag)}") - console.print("\nIf this install can still be detected:") - console.print(" specify self upgrade") - return - - latest_normalized = _normalize_tag(manual_tag) - if _is_newer(latest_normalized, installed): - console.print(f"[green]Update available:[/green] {installed} → {latest_display}") - console.print("\nTo upgrade:") - console.print(" specify self upgrade") - console.print("\nManual fallback:") - console.print( - f" uv tool install specify-cli --force --from {_manual_source_spec(manual_tag)}" - ) - console.print(f" pipx install --force {_manual_source_spec(manual_tag)}") - return - - # Reached only when manual_tag parsed cleanly — the unparseable-latest case - # already returned at the `manual_tag is None` branch above — and installed - # is parseable AND >= latest → "up to date" (FR-006). Do not reintroduce an - # InvalidVersion-fallback assumption here. - console.print(f"[green]Up to date:[/green] {installed}") - - -@self_app.command("upgrade") -def self_upgrade( - dry_run: bool = typer.Option( - False, - "--dry-run", - help="Print the preview (method, current, target, installer argv) and " - "exit 0 without launching the installer subprocess.", - ), - tag: str | None = typer.Option( - None, - "--tag", - # Typer renders help through Rich, so escape the literal bracket (\[) - # or `[suffix]` is parsed as a style tag and dropped -- `--help` then - # advertises only `(vX.Y.Z)`, contradicting docs/upgrade.md and README. - help="Pin the target version (vX.Y.Z\\[suffix]). Without --tag, the " - "latest stable release is resolved via GitHub Releases.", - ), -) -> None: - """Upgrade specify-cli to the latest release (or a pinned --tag). - - Bare invocation executes immediately with no confirmation prompt, matching - pip install -U / uv tool upgrade / npm update conventions. Use --dry-run - to preview without mutating anything. See `specify self check` for the - non-destructive read-only counterpart. - - Detection classifies the runtime into uv-tool / pipx / uvx (ephemeral) / - source-checkout / unsupported. Only uv-tool and pipx are upgraded - automatically; the other three paths print path-specific guidance and - exit 0. - - Exit codes: - 0 success or no-op-success (already on latest, --dry-run, or - non-upgradable path with guidance shown) - 1 target-tag resolution failure or --tag regex validation failure - 2 verification mismatch when the installer exited 0 but - `specify --version` does not resolve to the target tag; if the - installer itself exits 2, that installer failure code is - propagated verbatim - 3 installer binary not found on PATH, or resolved installer path is - missing / non-executable - 124 internal installer timeout when SPECIFY_UPGRADE_TIMEOUT_SECS is set, - or a real installer exit code 124 propagated verbatim; scripts - should treat 124 as ambiguous and inspect the failure message - other installer exit code propagated verbatim - - Environment variables: - SPECIFY_UPGRADE_TIMEOUT_SECS Optional integer/float seconds. Caps how - long the installer subprocess may run. Unset (default) means no - timeout — interrupt with Ctrl+C if the installer hangs. - """ - if tag is not None: - try: - tag = _validate_tag(tag) - except typer.BadParameter as exc: - # Escape at the print site rather than baking `\[` into - # _INVALID_TAG_MESSAGE: the message is also raised through - # typer.BadParameter, which Click renders without Rich, so the - # constant must stay plain text. Unescaped, Rich parses the literal - # `[suffix]` as a style tag and drops it, leaving the user with - # "expected vMAJOR.MINOR.PATCH" -- implying a bare vX.Y.Z is the only - # accepted form when -rc1 / .dev0 / +build.42 are all valid. - console.print(_escape_markup(str(exc)), soft_wrap=True) - raise typer.Exit(1) from exc - - plan, failure_reason = _build_upgrade_plan(target_tag_override=tag) - - # Resolver could not produce a tag → surface the categorized failure - # and exit non-zero so scripts notice (action-oriented unlike `self check`). - if plan is None: - if failure_reason is None: - # _build_upgrade_plan's contract: if plan is None, failure_reason - # is set. Defend explicitly so the guard survives `python -O`. - raise RuntimeError( - "internal contract violation: _build_upgrade_plan returned (None, None)" - ) - _emit_failure(failure_reason) - raise typer.Exit(1) - - if failure_reason is not None: - _emit_failure(failure_reason, plan=plan) - raise typer.Exit(1) - - # --dry-run preview path. Non-upgradable methods still emit guidance - # rather than a fake preview block — there is nothing to preview when - # there is nothing the CLI would launch. - if dry_run: - if plan.method in ( - _InstallMethod.UVX_EPHEMERAL, - _InstallMethod.SOURCE_CHECKOUT, - _InstallMethod.UNSUPPORTED, - ): - _emit_guidance(plan.method, plan.target_tag) - raise typer.Exit(0) - console.print("Dry run — no changes will be made.") - for line in plan.preview_summary.splitlines(): - console.print(line) - raise typer.Exit(0) - - # Non-upgradable runtime: never launch an installer regardless of flags. - if plan.method in ( - _InstallMethod.UVX_EPHEMERAL, - _InstallMethod.SOURCE_CHECKOUT, - _InstallMethod.UNSUPPORTED, - ): - _emit_guidance(plan.method, plan.target_tag) - raise typer.Exit(0) - - if plan.installer_argv is None: - _emit_failure( - _FAILURE_INSTALLER_MISSING, - plan=plan, - installer_name=_installer_binary_name(plan.method), - ) - raise typer.Exit(3) - - if plan.target_tag is None: - raise RuntimeError("Upgrade target tag is required for upgradable install methods") - target_tag = plan.target_tag - target_version = _parse_version_text(target_tag) - if target_version is None: - # _build_upgrade_plan() and _validate_tag() should reject bad targets - # before this point; keep this guard as a defensive invariant check. - _emit_failure(_FAILURE_TARGET_TAG_UNPARSEABLE, plan=plan) - raise typer.Exit(1) - if plan.current_version != "unknown": - current_version = _parse_version_text(plan.current_version) - # target_version and current_version are Version instances here, so use - # packaging's ordering/equality directly rather than comparing canonical - # strings: Version("1.0") == Version("1.0.0") yet their str() forms - # differ, so canonical-string equality would misreport equal versions as - # "or newer". The unparseable-current case stays explicit via the - # `current_version is not None` guard. - if tag is None and current_version is not None and not ( - target_version > current_version - ): - if target_version == current_version: - console.print(f"Already on latest release: {target_tag}") - else: - console.print(f"Already on latest release or newer: {plan.current_version}") - raise typer.Exit(0) - # Pinned upgrades are no-ops only on an exact parseable match — the same - # Version equality used by the unpinned branch above; an unparseable - # current version deliberately proceeds to installation. - if ( - tag is not None - and current_version is not None - and target_version == current_version - ): - console.print(f"Already on requested release: {target_tag}") - raise typer.Exit(0) - - # One-line pre-execution notice so the user sees exactly what will run - # before the installer's own output starts streaming. A pinned target older - # than the installed version is a downgrade — say so explicitly so - # `--tag ` does not masquerade as a forward upgrade. - installed_version = _parse_version_text(plan.current_version) - verb = ( - "Downgrading" - if tag is not None - and installed_version is not None - and target_version < installed_version - else "Upgrading" - ) - argv_str = _render_argv(plan.installer_argv) if plan.installer_argv else "" - console.print( - f"{verb} specify-cli {plan.current_version} → {plan.target_tag} " - f"via {_method_label(plan.method)}: {argv_str}", - soft_wrap=True, - ) - - # Launch the installer. Stdout/stderr stream through (no capture) so the - # user sees real-time progress. We never pass shell=True. - installer_result = _run_installer(plan) - installer_name = plan.installer_argv[0] if plan.installer_argv else None - - if installer_result.kind == _InstallerResultKind.MISSING: - _emit_failure(_FAILURE_INSTALLER_MISSING, plan=plan, installer_name=installer_name) - raise typer.Exit(3) - - if installer_result.kind == _InstallerResultKind.INVALID: - _emit_failure(_FAILURE_INSTALLER_INVALID, plan=plan, installer_name=installer_name) - raise typer.Exit(3) - - if installer_result.kind == _InstallerResultKind.TIMEOUT: - _emit_failure(_FAILURE_INSTALLER_TIMEOUT, plan=plan) - raise typer.Exit(124) - - if ( - installer_result.kind != _InstallerResultKind.EXITED - or installer_result.returncode is None - ): - raise RuntimeError(f"Unknown installer result: {installer_result!r}") - - if installer_result.returncode != 0: - _emit_failure( - _FAILURE_INSTALLER_FAILED, - plan=plan, - installer_exit=installer_result.returncode, - ) - raise typer.Exit(installer_result.returncode) - - # Verify in a child process: this Python process is still running the - # pre-upgrade module, so importlib.metadata would lie. A fresh `specify - # --version` is the only signal that the new binary is actually live. - verified = _verify_upgrade(plan) - # Compare as Version instances, not canonical strings: _canonicalize_version_text - # falls back to _normalize_tag() on unparseable input, so two raw strings could - # coincidentally match. Requiring a parseable verified version that equals the - # (already-parsed) target makes a non-version verifier result a mismatch (exit 2) - # rather than a silently-masked "success". - verified_version = _parse_version_text(verified) if verified is not None else None - if verified_version is None or verified_version != target_version: - _emit_failure( - _FAILURE_VERIFICATION_MISMATCH, - plan=plan, - verified_version=verified, - ) - raise typer.Exit(2) - - pre_upgrade_display = _canonicalize_version_text(plan.pre_upgrade_snapshot) - verified_display = _canonicalize_version_text(verified) - console.print( - f"Upgraded specify-cli: {pre_upgrade_display} → {verified_display}", - soft_wrap=True, - ) +self_app, self_check, self_upgrade = _command_exports() +del _command_exports diff --git a/src/specify_cli/selfs/__init__.py b/src/specify_cli/selfs/__init__.py new file mode 100644 index 0000000000..a35402a9dd --- /dev/null +++ b/src/specify_cli/selfs/__init__.py @@ -0,0 +1,28 @@ +"""Nested command group for ``specify self``. + +The plural Python package name follows the repository's command-hierarchy +convention even though the user-facing CLI namespace is singular. +""" + +import typer + +self_app = typer.Typer( + name="self", + help=( + "Manage the specify CLI itself: check for newer releases, " + "preview upgrades with --dry-run, and upgrade in place." + ), + add_completion=False, +) + + +def _register_commands(): + """Register command adapters and return compatibility exports.""" + from .command_check import self_check + from .command_upgrade import self_upgrade + + return self_check, self_upgrade + + +self_check, self_upgrade = _register_commands() +del _register_commands diff --git a/src/specify_cli/selfs/command_check.py b/src/specify_cli/selfs/command_check.py new file mode 100644 index 0000000000..cd6b25121d --- /dev/null +++ b/src/specify_cli/selfs/command_check.py @@ -0,0 +1,79 @@ +"""CLI adapter for ``specify self check``.""" + +from __future__ import annotations + +from . import self_app + + +@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. + Use `specify self upgrade` to actually perform the upgrade once you've seen + the result here, or `specify self upgrade --dry-run` to preview the + installer command without running it. + """ + from .._version import ( + _MANUAL_TAG_PLACEHOLDER, + _fetch_latest_release_tag, + _get_installed_version, + _is_newer, + _manual_source_spec, + _manual_tag_or_placeholder, + _normalize_tag, + console, + ) + + installed = _get_installed_version() + tag, failure_reason = _fetch_latest_release_tag() + + if tag is None: + assert failure_reason is not None + console.print(f"Installed: {installed}") + console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}") + return + + manual_tag = _manual_tag_or_placeholder(tag) + latest_display = manual_tag or _MANUAL_TAG_PLACEHOLDER + + if manual_tag is None: + if installed == "unknown": + console.print("Current version could not be determined.") + console.print(f"Latest release: {latest_display}") + else: + console.print(f"Installed: {installed}") + console.print(f"Latest release: {latest_display}") + console.print("[yellow]Could not validate latest release tag from GitHub.[/yellow]") + console.print("\nManual fallback:") + console.print( + f" uv tool install specify-cli --force --from {_manual_source_spec(manual_tag)}" + ) + console.print(f" pipx install --force {_manual_source_spec(manual_tag)}") + return + + if installed == "unknown": + console.print("Current version could not be determined.") + console.print(f"Latest release: {latest_display}") + console.print("\nManual fallback:") + console.print( + f" uv tool install specify-cli --force --from {_manual_source_spec(manual_tag)}" + ) + console.print(f" pipx install --force {_manual_source_spec(manual_tag)}") + console.print("\nIf this install can still be detected:") + console.print(" specify self upgrade") + return + + latest_normalized = _normalize_tag(manual_tag) + if _is_newer(latest_normalized, installed): + console.print(f"[green]Update available:[/green] {installed} → {latest_display}") + console.print("\nTo upgrade:") + console.print(" specify self upgrade") + console.print("\nManual fallback:") + console.print( + f" uv tool install specify-cli --force --from {_manual_source_spec(manual_tag)}" + ) + console.print(f" pipx install --force {_manual_source_spec(manual_tag)}") + return + + console.print(f"[green]Up to date:[/green] {installed}") diff --git a/src/specify_cli/selfs/command_upgrade.py b/src/specify_cli/selfs/command_upgrade.py new file mode 100644 index 0000000000..1f645c0e45 --- /dev/null +++ b/src/specify_cli/selfs/command_upgrade.py @@ -0,0 +1,215 @@ +"""CLI adapter for ``specify self upgrade``.""" + +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from . import self_app + + +@self_app.command("upgrade") +def self_upgrade( + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Print the preview (method, current, target, installer argv) and " + "exit 0 without launching the installer subprocess.", + ), + tag: str | None = typer.Option( + None, + "--tag", + help="Pin the target version (vX.Y.Z\\[suffix]). Without --tag, the " + "latest stable release is resolved via GitHub Releases.", + ), +) -> None: + """Upgrade specify-cli to the latest release (or a pinned --tag). + + Bare invocation executes immediately with no confirmation prompt, matching + pip install -U / uv tool upgrade / npm update conventions. Use --dry-run + to preview without mutating anything. See `specify self check` for the + non-destructive read-only counterpart. + + Detection classifies the runtime into uv-tool / pipx / uvx (ephemeral) / + source-checkout / unsupported. Only uv-tool and pipx are upgraded + automatically; the other three paths print path-specific guidance and + exit 0. + + Exit codes: + 0 success or no-op-success (already on latest, --dry-run, or + non-upgradable path with guidance shown) + 1 target-tag resolution failure or --tag regex validation failure + 2 verification mismatch when the installer exited 0 but + `specify --version` does not resolve to the target tag; if the + installer itself exits 2, that installer failure code is + propagated verbatim + 3 installer binary not found on PATH, or resolved installer path is + missing / non-executable + 124 internal installer timeout when SPECIFY_UPGRADE_TIMEOUT_SECS is set, + or a real installer exit code 124 propagated verbatim; scripts + should treat 124 as ambiguous and inspect the failure message + other installer exit code propagated verbatim + + Environment variables: + SPECIFY_UPGRADE_TIMEOUT_SECS Optional integer/float seconds. Caps how + long the installer subprocess may run. Unset (default) means no + timeout — interrupt with Ctrl+C if the installer hangs. + """ + from .._version import ( + _FAILURE_INSTALLER_FAILED, + _FAILURE_INSTALLER_INVALID, + _FAILURE_INSTALLER_MISSING, + _FAILURE_INSTALLER_TIMEOUT, + _FAILURE_TARGET_TAG_UNPARSEABLE, + _FAILURE_VERIFICATION_MISMATCH, + _InstallMethod, + _InstallerResultKind, + _build_upgrade_plan, + _canonicalize_version_text, + _emit_failure, + _emit_guidance, + _installer_binary_name, + _method_label, + _parse_version_text, + _render_argv, + _run_installer, + _validate_tag, + _verify_upgrade, + console, + ) + + if tag is not None: + try: + tag = _validate_tag(tag) + except typer.BadParameter as exc: + console.print(_escape_markup(str(exc)), soft_wrap=True) + raise typer.Exit(1) from exc + + plan, failure_reason = _build_upgrade_plan(target_tag_override=tag) + + if plan is None: + if failure_reason is None: + raise RuntimeError( + "internal contract violation: _build_upgrade_plan returned (None, None)" + ) + _emit_failure(failure_reason) + raise typer.Exit(1) + + if failure_reason is not None: + _emit_failure(failure_reason, plan=plan) + raise typer.Exit(1) + + if dry_run: + if plan.method in ( + _InstallMethod.UVX_EPHEMERAL, + _InstallMethod.SOURCE_CHECKOUT, + _InstallMethod.UNSUPPORTED, + ): + _emit_guidance(plan.method, plan.target_tag) + raise typer.Exit(0) + console.print("Dry run — no changes will be made.") + for line in plan.preview_summary.splitlines(): + console.print(line) + raise typer.Exit(0) + + if plan.method in ( + _InstallMethod.UVX_EPHEMERAL, + _InstallMethod.SOURCE_CHECKOUT, + _InstallMethod.UNSUPPORTED, + ): + _emit_guidance(plan.method, plan.target_tag) + raise typer.Exit(0) + + if plan.installer_argv is None: + _emit_failure( + _FAILURE_INSTALLER_MISSING, + plan=plan, + installer_name=_installer_binary_name(plan.method), + ) + raise typer.Exit(3) + + if plan.target_tag is None: + raise RuntimeError("Upgrade target tag is required for upgradable install methods") + target_tag = plan.target_tag + target_version = _parse_version_text(target_tag) + if target_version is None: + _emit_failure(_FAILURE_TARGET_TAG_UNPARSEABLE, plan=plan) + raise typer.Exit(1) + if plan.current_version != "unknown": + current_version = _parse_version_text(plan.current_version) + if tag is None and current_version is not None and not ( + target_version > current_version + ): + if target_version == current_version: + console.print(f"Already on latest release: {target_tag}") + else: + console.print(f"Already on latest release or newer: {plan.current_version}") + raise typer.Exit(0) + if ( + tag is not None + and current_version is not None + and target_version == current_version + ): + console.print(f"Already on requested release: {target_tag}") + raise typer.Exit(0) + + installed_version = _parse_version_text(plan.current_version) + verb = ( + "Downgrading" + if tag is not None + and installed_version is not None + and target_version < installed_version + else "Upgrading" + ) + argv_str = _render_argv(plan.installer_argv) if plan.installer_argv else "" + console.print( + f"{verb} specify-cli {plan.current_version} → {plan.target_tag} " + f"via {_method_label(plan.method)}: {argv_str}", + soft_wrap=True, + ) + + installer_result = _run_installer(plan) + installer_name = plan.installer_argv[0] if plan.installer_argv else None + + if installer_result.kind == _InstallerResultKind.MISSING: + _emit_failure(_FAILURE_INSTALLER_MISSING, plan=plan, installer_name=installer_name) + raise typer.Exit(3) + + if installer_result.kind == _InstallerResultKind.INVALID: + _emit_failure(_FAILURE_INSTALLER_INVALID, plan=plan, installer_name=installer_name) + raise typer.Exit(3) + + if installer_result.kind == _InstallerResultKind.TIMEOUT: + _emit_failure(_FAILURE_INSTALLER_TIMEOUT, plan=plan) + raise typer.Exit(124) + + if ( + installer_result.kind != _InstallerResultKind.EXITED + or installer_result.returncode is None + ): + raise RuntimeError(f"Unknown installer result: {installer_result!r}") + + if installer_result.returncode != 0: + _emit_failure( + _FAILURE_INSTALLER_FAILED, + plan=plan, + installer_exit=installer_result.returncode, + ) + raise typer.Exit(installer_result.returncode) + + verified = _verify_upgrade(plan) + verified_version = _parse_version_text(verified) if verified is not None else None + if verified_version is None or verified_version != target_version: + _emit_failure( + _FAILURE_VERIFICATION_MISMATCH, + plan=plan, + verified_version=verified, + ) + raise typer.Exit(2) + + pre_upgrade_display = _canonicalize_version_text(plan.pre_upgrade_snapshot) + verified_display = _canonicalize_version_text(verified) + console.print( + f"Upgraded specify-cli: {pre_upgrade_display} → {verified_display}", + soft_wrap=True, + ) diff --git a/tests/self_upgrade_helpers.py b/tests/specify_cli/self_upgrade_helpers.py similarity index 100% rename from tests/self_upgrade_helpers.py rename to tests/specify_cli/self_upgrade_helpers.py diff --git a/tests/specify_cli/selfs/__init__.py b/tests/specify_cli/selfs/__init__.py new file mode 100644 index 0000000000..df35e6484e --- /dev/null +++ b/tests/specify_cli/selfs/__init__.py @@ -0,0 +1 @@ +"""Command tests for the ``specify self`` hierarchy.""" diff --git a/tests/specify_cli/selfs/test_command_check.py b/tests/specify_cli/selfs/test_command_check.py new file mode 100644 index 0000000000..3bae9d97ba --- /dev/null +++ b/tests/specify_cli/selfs/test_command_check.py @@ -0,0 +1,187 @@ +"""Command tests for ``specify self check``.""" + +import urllib.error +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +from specify_cli import app +from tests.conftest import strip_ansi +from tests.http_helpers import ( + mock_urlopen_response, + route_opener_open_through_urlopen, # noqa: F401 (autouse fixture) +) + +runner = CliRunner() + +SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE" +SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE" +_RATE_LIMITED_REASON = ( + "rate limited (configure ~/.specify/auth.json with a GitHub token)" +) + + +def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError: + return urllib.error.HTTPError( + url="https://api.github.com/repos/github/spec-kit/releases/latest", + code=code, + msg=message, + hdrs={}, # type: ignore[arg-type] + fp=None, + ) + + +_FAILURE_CASES = [ + ("offline or timeout", urllib.error.URLError("down")), + (_RATE_LIMITED_REASON, _http_error(403)), + ("HTTP 500", _http_error(500)), +] + + +class TestUserStory1: + def test_newer_available_prints_update_and_install_command(self): + 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"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Update available" in output + assert "0.7.4" in output + assert "0.9.0" in output + 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._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"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Up to date: 0.9.0" in output + assert "Update available" not in output + assert "git+https://" not in output + + def test_dev_build_ahead_of_release_is_up_to_date(self): + 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"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Update available" not in output + assert "Up to date" in output + + def test_unknown_installed_still_prints_latest_and_reinstall(self): + 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"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Current version could not be determined" in output + assert "Latest release: v0.7.4" in output + assert "0.7.4" in output + assert "git+https://github.com/github/spec-kit.git@v0.7.4" in output + assert "specify self upgrade" in output + assert "pipx install --force git+https://github.com/github/spec-kit.git@v0.7.4" in output + + def test_unknown_installed_uses_placeholder_when_latest_tag_is_invalid(self): + 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.9.0;echo unsafe"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Latest release: vX.Y.Z" in output + assert "Could not validate latest release tag from GitHub." in output + assert "git+https://github.com/github/spec-kit.git@vX.Y.Z" in output + assert "v0.9.0;echo unsafe" not in output + + def test_unparseable_tag_reports_validation_failure_without_raw_tag(self): + 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"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Update available" not in output + assert "Up to date" not in output + assert "Could not validate latest release tag from GitHub." in output + assert "Latest release: vX.Y.Z" in output + assert "0.7.4" in output + assert "not-a-version" not in output + assert "git+https://github.com/github/spec-kit.git@vX.Y.Z" in output + + +class TestUserStory2: + @pytest.mark.parametrize("expected_reason, side_effect", _FAILURE_CASES) + def test_failure_prints_installed_plus_one_line_reason( + self, expected_reason, side_effect + ): + 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"]) + output = strip_ansi(result.output) + assert "Installed: 0.7.4" in output + if expected_reason == _RATE_LIMITED_REASON: + assert "Could not check latest release: rate limited" in output + assert "~/.specify/auth.json" in output + else: + assert f"Could not check latest release: {expected_reason}" in output + + @pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES) + def test_failure_exits_zero(self, _expected_reason, side_effect): + 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"]) + assert result.exit_code == 0 + + @pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES) + def test_failure_output_contains_no_traceback_no_url( + self, _expected_reason, side_effect + ): + 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"]) + combined = strip_ansi((result.output or "") + (result.stderr or "")) + assert "Traceback" not in combined + assert "https://api.github.com" not in combined + + +class TestUserStory3: + @pytest.mark.parametrize("_reason, side_effect", _FAILURE_CASES) + def test_gh_token_never_appears_in_failure_output( + self, _reason, side_effect, monkeypatch + ): + monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + 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"]) + combined = strip_ansi((result.output or "") + (result.stderr or "")) + assert SENTINEL_GH_TOKEN not in combined + + @pytest.mark.parametrize("_reason, side_effect", _FAILURE_CASES) + def test_github_token_never_appears_in_failure_output( + self, _reason, side_effect, monkeypatch + ): + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) + 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"]) + combined = strip_ansi((result.output or "") + (result.stderr or "")) + assert SENTINEL_GITHUB_TOKEN not in combined diff --git a/tests/specify_cli/selfs/test_command_upgrade.py b/tests/specify_cli/selfs/test_command_upgrade.py new file mode 100644 index 0000000000..5a39a9ddb7 --- /dev/null +++ b/tests/specify_cli/selfs/test_command_upgrade.py @@ -0,0 +1,128 @@ +"""Command tests for ``specify self upgrade``.""" + +from unittest.mock import patch + +import pytest + +from specify_cli import app +from tests.specify_cli.self_upgrade_helpers import runner, strip_ansi + + +class TestTagValidation: + """--tag option parsing and command help.""" + + def test_valid_stable_tag(self, uv_tool_argv0, clean_environ): + with patch("specify_cli._version.shutil.which", return_value="uv"), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + result = runner.invoke( + app, + ["self", "upgrade", "--dry-run", "--tag", "v0.7.6"], + ) + assert result.exit_code == 0 + + def test_valid_dev_suffix_tag(self, uv_tool_argv0, clean_environ): + with patch("specify_cli._version.shutil.which", return_value="uv"), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + result = runner.invoke( + app, + ["self", "upgrade", "--dry-run", "--tag", "v0.8.0.dev0"], + ) + assert result.exit_code == 0 + assert "Target version: v0.8.0.dev0" in strip_ansi(result.output) + + def test_valid_rc_tag(self, uv_tool_argv0, clean_environ): + with patch("specify_cli._version.shutil.which", return_value="uv"), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + result = runner.invoke( + app, + ["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1"], + ) + assert result.exit_code == 0 + + def test_valid_beta_dot_tag_uses_pep440_equivalent_for_noop( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.shutil.which", return_value="uv"), patch( + "specify_cli._version._get_installed_version", return_value="1.0.0b1" + ): + result = runner.invoke( + app, + ["self", "upgrade", "--tag", "v1.0.0-beta.1"], + ) + assert result.exit_code == 0 + assert "Already on requested release: v1.0.0-beta.1" in strip_ansi( + result.output + ) + + def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ): + with patch("specify_cli._version.shutil.which", return_value="uv"), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + result = runner.invoke( + app, + ["self", "upgrade", "--dry-run", "--tag", "v0.8.0+build.42"], + ) + assert result.exit_code == 0 + assert "Target version: v0.8.0+build.42" in strip_ansi(result.output) + + def test_uppercase_v_prefix_is_folded_to_lowercase( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.shutil.which", return_value="uv"), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + result = runner.invoke( + app, + ["self", "upgrade", "--dry-run", "--tag", "V0.7.6"], + ) + assert result.exit_code == 0 + assert "Target version: v0.7.6" in strip_ansi(result.output) + + def test_valid_prerelease_with_build_metadata_tag( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.shutil.which", return_value="uv"), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + result = runner.invoke( + app, + ["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1+build.42"], + ) + assert result.exit_code == 0 + assert "Target version: v1.0.0-rc1+build.42" in strip_ansi(result.output) + + @pytest.mark.parametrize( + "bad_tag", + [ + "latest", + "0.7.5", + "main", + "v7", + "", + "v1.2.3abc", + "v1.2.3...", + "v1.2.3++", + "v\uff11.2.3", + "v1.\u0662.3", + ], + ) + def test_invalid_tags_rejected(self, bad_tag, uv_tool_argv0, clean_environ): + result = runner.invoke(app, ["self", "upgrade", "--tag", bad_tag]) + assert result.exit_code == 1 + output = strip_ansi(result.output) + assert "Invalid --tag" in output or "expected vMAJOR.MINOR.PATCH" in output + + def test_rejection_message_keeps_the_suffix_token( + self, uv_tool_argv0, clean_environ + ): + result = runner.invoke(app, ["self", "upgrade", "--tag", "latest"]) + assert result.exit_code == 1 + assert "expected vMAJOR.MINOR.PATCH[suffix]" in strip_ansi(result.output) + + def test_tag_option_help_keeps_the_suffix_token(self): + result = runner.invoke(app, ["self", "upgrade", "--help"]) + assert result.exit_code == 0 + assert "[suffix]" in strip_ansi(result.output) diff --git a/tests/test_self_upgrade_detection.py b/tests/specify_cli/test_version_detection.py similarity index 99% rename from tests/test_self_upgrade_detection.py rename to tests/specify_cli/test_version_detection.py index 73b55ebb79..c769665920 100644 --- a/tests/test_self_upgrade_detection.py +++ b/tests/specify_cli/test_version_detection.py @@ -12,7 +12,7 @@ import specify_cli from specify_cli import app -from tests.self_upgrade_helpers import ( +from tests.specify_cli.self_upgrade_helpers import ( route_opener_open_through_urlopen, # noqa: F401 (autouse fixture) _InstallMethod, _assemble_installer_argv, diff --git a/tests/test_self_upgrade_execution.py b/tests/specify_cli/test_version_execution.py similarity index 99% rename from tests/test_self_upgrade_execution.py rename to tests/specify_cli/test_version_execution.py index 5c761014be..9e3f2514fe 100644 --- a/tests/test_self_upgrade_execution.py +++ b/tests/specify_cli/test_version_execution.py @@ -6,7 +6,7 @@ from specify_cli import app -from tests.self_upgrade_helpers import ( +from tests.specify_cli.self_upgrade_helpers import ( route_opener_open_through_urlopen, # noqa: F401 (autouse fixture) _completed_process, mock_urlopen_response, diff --git a/tests/test_self_upgrade_guidance.py b/tests/specify_cli/test_version_guidance.py similarity index 99% rename from tests/test_self_upgrade_guidance.py rename to tests/specify_cli/test_version_guidance.py index 5fcb6a5274..c1d57f9adf 100644 --- a/tests/test_self_upgrade_guidance.py +++ b/tests/specify_cli/test_version_guidance.py @@ -4,7 +4,7 @@ from specify_cli import app -from tests.self_upgrade_helpers import ( +from tests.specify_cli.self_upgrade_helpers import ( mock_urlopen_response, route_opener_open_through_urlopen, # noqa: F401 (autouse fixture) runner, diff --git a/tests/test_version_imports.py b/tests/specify_cli/test_version_imports.py similarity index 100% rename from tests/test_version_imports.py rename to tests/specify_cli/test_version_imports.py diff --git a/tests/test_upgrade.py b/tests/specify_cli/test_version_release.py similarity index 56% rename from tests/test_upgrade.py rename to tests/specify_cli/test_version_release.py index 513cd23430..d709914217 100644 --- a/tests/test_upgrade.py +++ b/tests/specify_cli/test_version_release.py @@ -1,13 +1,10 @@ -"""Tests for the `specify self` sub-app (`self check` and `self upgrade`). +"""Tests for release lookup and version comparison behavior. Network isolation contract (SC-004 / FR-014): every test that exercises -`specify self check` or `_fetch_latest_release_tag()` MUST mock the outbound -urllib path so no real call reaches api.github.com. Production always uses an -isolated `build_opener`; this module's autouse fixture routes its `open()` back -through the locally mocked `urlopen`. Tests for non-network `self upgrade` -behavior should keep that contract explicit with local mocks. Run this module -under `pytest-socket` (if installed) with `--disable-socket` as an extra safety -net. +`_fetch_latest_release_tag()` MUST mock the outbound urllib path so no real call +reaches api.github.com. Production always uses an isolated `build_opener`; this +module's autouse fixture routes its `open()` back through the locally mocked +`urlopen`. """ import urllib.error @@ -15,9 +12,6 @@ from unittest.mock import MagicMock, patch import pytest -from typer.testing import CliRunner - -from specify_cli import app from specify_cli._download_security import read_response_limited as _real_read_response_limited from specify_cli._version import ( _fetch_latest_release_tag, @@ -25,14 +19,11 @@ _is_newer, _normalize_tag, ) -from tests.conftest import strip_ansi from tests.http_helpers import ( mock_urlopen_response, route_opener_open_through_urlopen, # noqa: F401 (autouse fixture) ) -runner = CliRunner() - SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE" SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE" @@ -110,88 +101,6 @@ def test_empty_string_passthrough(self): assert _normalize_tag("") == "" -class TestUserStory1: - def test_newer_available_prints_update_and_install_command(self): - 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"}), - ): - result = runner.invoke(app, ["self", "check"]) - output = strip_ansi(result.output) - assert result.exit_code == 0 - assert "Update available" in output - assert "0.7.4" in output - assert "0.9.0" in output - 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._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"}), - ): - result = runner.invoke(app, ["self", "check"]) - output = strip_ansi(result.output) - assert result.exit_code == 0 - assert "Up to date: 0.9.0" in output - assert "Update available" not in output - assert "git+https://" not in output - - def test_dev_build_ahead_of_release_is_up_to_date(self): - 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"}), - ): - result = runner.invoke(app, ["self", "check"]) - output = strip_ansi(result.output) - assert result.exit_code == 0 - assert "Update available" not in output - assert "Up to date" in output - - def test_unknown_installed_still_prints_latest_and_reinstall(self): - 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"}), - ): - result = runner.invoke(app, ["self", "check"]) - output = strip_ansi(result.output) - assert result.exit_code == 0 - assert "Current version could not be determined" in output - assert "Latest release: v0.7.4" in output - assert "0.7.4" in output - assert "git+https://github.com/github/spec-kit.git@v0.7.4" in output - assert "specify self upgrade" in output - assert "pipx install --force git+https://github.com/github/spec-kit.git@v0.7.4" in output - - def test_unknown_installed_uses_placeholder_when_latest_tag_is_invalid(self): - 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.9.0;echo unsafe"}), - ): - result = runner.invoke(app, ["self", "check"]) - output = strip_ansi(result.output) - assert result.exit_code == 0 - assert "Latest release: vX.Y.Z" in output - assert "Could not validate latest release tag from GitHub." in output - assert "git+https://github.com/github/spec-kit.git@vX.Y.Z" in output - assert "v0.9.0;echo unsafe" not in output - - def test_unparseable_tag_reports_validation_failure_without_raw_tag(self): - 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"}), - ): - result = runner.invoke(app, ["self", "check"]) - output = strip_ansi(result.output) - assert result.exit_code == 0 - assert "Update available" not in output - assert "Up to date" not in output - assert "Could not validate latest release tag from GitHub." in output - assert "Latest release: vX.Y.Z" in output - assert "0.7.4" in output - assert "not-a-version" not in output - assert "git+https://github.com/github/spec-kit.git@vX.Y.Z" in output - - class TestFailureCategorization: def test_urlerror_maps_to_offline(self): with patch( @@ -280,52 +189,6 @@ def _spy(response, *, max_bytes: int, label: str, **kwargs): assert recorded["label"] == "GitHub latest release" -_FAILURE_CASES = [ - ("offline or timeout", urllib.error.URLError("down")), - (_RATE_LIMITED_REASON, _http_error(403)), - ("HTTP 500", _http_error(500)), -] - - -class TestUserStory2: - @pytest.mark.parametrize("expected_reason, side_effect", _FAILURE_CASES) - def test_failure_prints_installed_plus_one_line_reason( - self, expected_reason, side_effect - ): - 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"]) - output = strip_ansi(result.output) - assert "Installed: 0.7.4" in output - if expected_reason == _RATE_LIMITED_REASON: - assert "Could not check latest release: rate limited" in output - assert "~/.specify/auth.json" in output - else: - assert f"Could not check latest release: {expected_reason}" in output - - @pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES) - def test_failure_exits_zero(self, _expected_reason, side_effect): - 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"]) - assert result.exit_code == 0 - - @pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES) - def test_failure_output_contains_no_traceback_no_url( - self, _expected_reason, side_effect - ): - 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"]) - combined = (result.output or "") + (result.stderr or "") - combined = strip_ansi(combined) - assert "Traceback" not in combined - assert "https://api.github.com" not in combined - - def _capture_request_via_urlopen(): captured = {} @@ -421,29 +284,3 @@ def test_whitespace_only_gh_token_falls_back_to_github_token(self, monkeypatch): _fetch_latest_release_tag() req = captured["request"] assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}" - - @pytest.mark.parametrize("_reason, side_effect", _FAILURE_CASES) - def test_gh_token_never_appears_in_failure_output( - self, _reason, side_effect, monkeypatch - ): - monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN) - monkeypatch.delenv("GITHUB_TOKEN", raising=False) - 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"]) - combined = strip_ansi((result.output or "") + (result.stderr or "")) - assert SENTINEL_GH_TOKEN not in combined - - @pytest.mark.parametrize("_reason, side_effect", _FAILURE_CASES) - def test_github_token_never_appears_in_failure_output( - self, _reason, side_effect, monkeypatch - ): - monkeypatch.delenv("GH_TOKEN", raising=False) - monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) - 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"]) - combined = strip_ansi((result.output or "") + (result.stderr or "")) - assert SENTINEL_GITHUB_TOKEN not in combined diff --git a/tests/test_self_upgrade_verification.py b/tests/specify_cli/test_version_verification.py similarity index 80% rename from tests/test_self_upgrade_verification.py rename to tests/specify_cli/test_version_verification.py index f320cfe732..f3311bb23f 100644 --- a/tests/test_self_upgrade_verification.py +++ b/tests/specify_cli/test_version_verification.py @@ -7,7 +7,7 @@ import specify_cli from specify_cli import app -from tests.self_upgrade_helpers import ( +from tests.specify_cli.self_upgrade_helpers import ( route_opener_open_through_urlopen, # noqa: F401 (autouse fixture) SENTINEL_GH_TOKEN, SENTINEL_GITHUB_TOKEN, @@ -362,137 +362,6 @@ def test_unparseable_resolved_release_tag_exits_1_without_traceback( assert mock_run.call_count == 0 -class TestTagValidation: - """--tag regex enforcement.""" - - def test_valid_stable_tag(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.shutil.which", return_value="uv"), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - result = runner.invoke( - app, - ["self", "upgrade", "--dry-run", "--tag", "v0.7.6"], - ) - assert result.exit_code == 0 - - def test_valid_dev_suffix_tag(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.shutil.which", return_value="uv"), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - result = runner.invoke( - app, - ["self", "upgrade", "--dry-run", "--tag", "v0.8.0.dev0"], - ) - assert result.exit_code == 0 - assert "Target version: v0.8.0.dev0" in strip_ansi(result.output) - - def test_valid_rc_tag(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.shutil.which", return_value="uv"), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - result = runner.invoke( - app, - ["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1"], - ) - assert result.exit_code == 0 - - def test_valid_beta_dot_tag_uses_pep440_equivalent_for_noop( - self, uv_tool_argv0, clean_environ - ): - with patch("specify_cli._version.shutil.which", return_value="uv"), patch( - "specify_cli._version._get_installed_version", return_value="1.0.0b1" - ): - result = runner.invoke( - app, - ["self", "upgrade", "--tag", "v1.0.0-beta.1"], - ) - assert result.exit_code == 0 - assert "Already on requested release: v1.0.0-beta.1" in strip_ansi( - result.output - ) - - def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.shutil.which", return_value="uv"), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - result = runner.invoke( - app, - ["self", "upgrade", "--dry-run", "--tag", "v0.8.0+build.42"], - ) - assert result.exit_code == 0 - assert "Target version: v0.8.0+build.42" in strip_ansi(result.output) - - def test_uppercase_v_prefix_is_folded_to_lowercase( - self, uv_tool_argv0, clean_environ - ): - # A pasted uppercase `V` prefix is accepted and normalized to `v` so - # the git ref matches the canonical lowercase release tag. - with patch("specify_cli._version.shutil.which", return_value="uv"), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - result = runner.invoke( - app, - ["self", "upgrade", "--dry-run", "--tag", "V0.7.6"], - ) - assert result.exit_code == 0 - assert "Target version: v0.7.6" in strip_ansi(result.output) - - def test_valid_prerelease_with_build_metadata_tag( - self, uv_tool_argv0, clean_environ - ): - # Prerelease and build-metadata suffixes compose (PEP 440 / semver). - with patch("specify_cli._version.shutil.which", return_value="uv"), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - result = runner.invoke( - app, - ["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1+build.42"], - ) - assert result.exit_code == 0 - assert "Target version: v1.0.0-rc1+build.42" in strip_ansi(result.output) - - @pytest.mark.parametrize( - "bad_tag", - [ - "latest", - "0.7.5", - "main", - "v7", - "", - "v1.2.3abc", - "v1.2.3...", - "v1.2.3++", - "v\uff11.2.3", - "v1.\u0662.3", - ], - ) - def test_invalid_tags_rejected(self, bad_tag, uv_tool_argv0, clean_environ): - result = runner.invoke(app, ["self", "upgrade", "--tag", bad_tag]) - assert result.exit_code == 1 - output = strip_ansi(result.output) - assert "Invalid --tag" in output or "expected vMAJOR.MINOR.PATCH" in output - - def test_rejection_message_keeps_the_suffix_token( - self, uv_tool_argv0, clean_environ - ): - """Rich must not swallow the literal `[suffix]`. - - Unescaped it is parsed as a style tag and dropped, so the user is told - only "expected vMAJOR.MINOR.PATCH" -- implying a bare vX.Y.Z is the only - accepted form, when -rc1 / .dev0 / +build.42 are all valid and are - documented as such in docs/upgrade.md and README.md. - """ - result = runner.invoke(app, ["self", "upgrade", "--tag", "latest"]) - assert result.exit_code == 1 - assert "expected vMAJOR.MINOR.PATCH[suffix]" in strip_ansi(result.output) - - def test_tag_option_help_keeps_the_suffix_token(self): - """Typer renders option help through Rich, so `--help` dropped it too.""" - result = runner.invoke(app, ["self", "upgrade", "--help"]) - assert result.exit_code == 0 - assert "[suffix]" in strip_ansi(result.output) - - class TestUnknownCurrent: """'unknown' current version renders literally in notice and success message."""