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
97 changes: 97 additions & 0 deletions src/specify_cli/_operation_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Shared application operation for version information collection."""

from __future__ import annotations

import platform
from collections.abc import Callable
from dataclasses import dataclass

from ._assets import get_speckit_version


@dataclass(frozen=True)
class VersionRuntime:
"""Runtime versions reported by the version operation."""

python: str
openssl: str | None


@dataclass(frozen=True)
class VersionSystem:
"""Host system information reported by the version operation."""

platform: str
architecture: str
os_version: str


@dataclass(frozen=True)
class VersionResult:
"""Transport-neutral result of the version operation."""

cli_version: str
runtime: VersionRuntime | None
system: VersionSystem | None
features: dict[str, bool]


def _feature_capabilities() -> dict[str, bool]:
"""Return stable local CLI capability flags for humans and agents."""
return {
"controlled_multi_install_integrations": True,
"integration_use_command": True,
"multi_install_safe_registry_metadata": True,
"integration_upgrade_command": True,
"self_check_command": True,
"workflow_catalog": True,
"bundled_templates": True,
}


def _openssl_version() -> str | None:
"""Return the loaded OpenSSL version, or None when unavailable."""
try:
import ssl
except ImportError:
return None

value = getattr(ssl, "OPENSSL_VERSION", None)
return value if isinstance(value, str) and value else None


def collect_version_result(
*,
include_environment: bool = True,
cli_version_getter: Callable[[], str] | None = None,
openssl_version_getter: Callable[[], str | None] | None = None,
feature_capabilities_getter: Callable[[], dict[str, bool]] | None = None,
) -> VersionResult:
"""Collect semantic version information for any delivery adapter."""
get_cli_version = cli_version_getter or get_speckit_version
get_openssl_version = openssl_version_getter or _openssl_version
get_feature_capabilities = feature_capabilities_getter or _feature_capabilities

cli_version = get_cli_version()
features = get_feature_capabilities()
if not include_environment:
return VersionResult(
cli_version=cli_version,
runtime=None,
system=None,
features=features,
)

return VersionResult(
cli_version=cli_version,
runtime=VersionRuntime(
python=platform.python_version(),
openssl=get_openssl_version(),
),
system=VersionSystem(
platform=platform.system(),
architecture=platform.machine(),
os_version=platform.version(),
),
features=features,
)
97 changes: 55 additions & 42 deletions src/specify_cli/command_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,55 +3,52 @@
from __future__ import annotations

import json
import platform

import typer
from rich.panel import Panel
from rich.table import Table

from . import _operation_version
from ._console import console, show_banner
from ._operation_version import (
VersionResult,
_feature_capabilities,
_openssl_version,
collect_version_result,
)

_INTERNAL_ERROR_MESSAGE = "Unable to collect version information."

platform = _operation_version.platform

def _feature_capabilities() -> dict[str, bool]:
"""Return stable local CLI capability flags for humans and agents."""
return {
"controlled_multi_install_integrations": True,
"integration_use_command": True,
"multi_install_safe_registry_metadata": True,
"integration_upgrade_command": True,
"self_check_command": True,
"workflow_catalog": True,
"bundled_templates": True,
}
_INTERNAL_ERROR_MESSAGE = "Unable to collect version information."


def _openssl_version() -> str | None:
"""Return the loaded OpenSSL version, or None when unavailable."""
try:
import ssl
except ImportError:
return None
def _json_result(cli_version: str) -> dict[str, object]:
"""Collect the complete machine-readable version result."""
result = collect_version_result(
cli_version_getter=lambda: cli_version,
openssl_version_getter=_openssl_version,
feature_capabilities_getter=_feature_capabilities,
)
return _result_payload(result)

value = getattr(ssl, "OPENSSL_VERSION", None)
return value if isinstance(value, str) and value else None

def _result_payload(result: VersionResult) -> dict[str, object]:
"""Map a complete operation result to the established CLI JSON shape."""
if result.runtime is None or result.system is None:
raise ValueError("Complete version information is required.")

def _json_result(cli_version: str) -> dict[str, object]:
"""Collect the complete machine-readable version result."""
return {
"cli_version": cli_version,
"cli_version": result.cli_version,
"runtime": {
"python": platform.python_version(),
"openssl": _openssl_version(),
"python": result.runtime.python,
"openssl": result.runtime.openssl,
},
"system": {
"platform": platform.system(),
"architecture": platform.machine(),
"os_version": platform.version(),
"platform": result.system.platform,
"architecture": result.system.architecture,
"os_version": result.system.os_version,
},
"features": _feature_capabilities(),
"features": result.features,
}


Expand All @@ -77,7 +74,12 @@ def version(

if json_output:
try:
payload = _json_result(get_speckit_version())
result = collect_version_result(
cli_version_getter=get_speckit_version,
openssl_version_getter=_openssl_version,
feature_capabilities_getter=_feature_capabilities,
)
payload = _result_payload(result)
rendered = _serialize_json(payload)
except Exception: # noqa: BLE001
# JSON mode must normalize every unexpected command failure.
Expand All @@ -94,34 +96,45 @@ def version(
typer.echo(rendered)
return

cli_version = get_speckit_version()
if features:
capabilities = _feature_capabilities()
console.print(f"Spec Kit CLI: {cli_version}")
result = collect_version_result(
include_environment=False,
cli_version_getter=get_speckit_version,
feature_capabilities_getter=_feature_capabilities,
)
console.print(f"Spec Kit CLI: {result.cli_version}")
console.print()
console.print("Features:")
for key, enabled in capabilities.items():
for key, enabled in result.features.items():
label = key.replace("_", " ")
console.print(f"- {label}: {'yes' if enabled else 'no'}")
return

result = collect_version_result(
cli_version_getter=get_speckit_version,
openssl_version_getter=_openssl_version,
feature_capabilities_getter=_feature_capabilities,
)
if result.runtime is None or result.system is None:
raise RuntimeError("Complete version information is required.")

show_banner()

info_table = Table(show_header=False, box=None, padding=(0, 2))
info_table.add_column("Key", style="cyan", justify="right")
info_table.add_column("Value", style="white")

info_table.add_row("CLI Version", cli_version)
info_table.add_row("CLI Version", result.cli_version)
info_table.add_row("", "")
info_table.add_row("Python", platform.python_version())
info_table.add_row("Platform", platform.system())
info_table.add_row("Architecture", platform.machine())
info_table.add_row("OS Version", platform.version())
info_table.add_row("Python", result.runtime.python)
info_table.add_row("Platform", result.system.platform)
info_table.add_row("Architecture", result.system.architecture)
info_table.add_row("OS Version", result.system.os_version)
# The OpenSSL runtime the interpreter actually loaded. HTTPS failure
# reports (#4433) hinge on which OpenSSL is in play, and on Windows it is
# not obvious from the outside, so surface it here. An interpreter built
# without the ssl extension skips the row rather than failing the command.
openssl_version = _openssl_version()
openssl_version = result.runtime.openssl
if openssl_version:
info_table.add_row("OpenSSL", openssl_version)

Expand Down
42 changes: 42 additions & 0 deletions tests/specify_cli/test_command_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
from typer.testing import CliRunner

from specify_cli import app
from specify_cli._operation_version import (
VersionResult,
VersionRuntime,
VersionSystem,
)

runner = CliRunner()

Expand Down Expand Up @@ -83,6 +88,43 @@ def test_version_json_emits_complete_stable_payload(self):
assert result.stderr == ""
assert json.loads(result.stdout) == expected

def test_version_json_renders_shared_operation_result(self):
"""The CLI adapter maps the shared typed result to its JSON contract."""
operation_result = VersionResult(
cli_version="1.2.3",
runtime=VersionRuntime(
python="3.13.1",
openssl="OpenSSL 3.4.0",
),
system=VersionSystem(
platform="ExampleOS",
architecture="example64",
os_version="ExampleOS 4.5",
),
features=EXPECTED_FEATURES,
)
with patch(
"specify_cli.command_version.collect_version_result",
return_value=operation_result,
) as collect:
result = runner.invoke(app, ["version", "--json"])

assert result.exit_code == 0
assert json.loads(result.stdout) == {
"cli_version": operation_result.cli_version,
"runtime": {
"python": operation_result.runtime.python,
"openssl": operation_result.runtime.openssl,
},
"system": {
"platform": operation_result.system.platform,
"architecture": operation_result.system.architecture,
"os_version": operation_result.system.os_version,
},
"features": operation_result.features,
}
collect.assert_called_once()

def test_version_features_json_is_exact_alias(self):
"""--features does not filter JSON output or change its bytes."""
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
Expand Down
Loading
Loading