Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions design/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<phase>.py`
Expand Down
6 changes: 3 additions & 3 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
321 changes: 10 additions & 311 deletions src/specify_cli/_version.py
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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

Expand Down Expand Up @@ -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 <older>` 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
28 changes: 28 additions & 0 deletions src/specify_cli/selfs/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading