diff --git a/.gitignore b/.gitignore index b5e1977..e7fc30e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ __pycache__/ # Distribution / packaging .Python env/ +venv/ +.venv/ build/ develop-eggs/ dist/ diff --git a/chipfoundry_cli/check_refs.py b/chipfoundry_cli/check_refs.py new file mode 100644 index 0000000..5051eb5 --- /dev/null +++ b/chipfoundry_cli/check_refs.py @@ -0,0 +1,42 @@ +"""Known cf-precheck check names with display metadata. + +Must stay in sync with cf-precheck's ``ALL_CHECKS`` ordering +(see ``cf-precheck/src/cf_precheck/check_manager.py``). The backend mirrors +the ref keys in ``chipignite-backend-services/src/precheck_service/check_refs.py``. +""" + +from __future__ import annotations + +from typing import NamedTuple + + +class PrecheckCheck(NamedTuple): + ref: str + surname: str + optional: bool + + +PRECHECK_CHECKS: tuple[PrecheckCheck, ...] = ( + PrecheckCheck("topcell_check", "Top Cell", False), + PrecheckCheck("gpio_defines", "GPIO Defines", False), + PrecheckCheck("pdnmulti", "PDN Multi", False), + PrecheckCheck("metalcheck", "Metal Check", False), + PrecheckCheck("xor", "XOR", False), + PrecheckCheck("magic_drc", "Magic DRC", True), + PrecheckCheck("klayout_feol", "Klayout FEOL", False), + PrecheckCheck("klayout_beol", "Klayout BEOL", False), + PrecheckCheck("klayout_offgrid", "Klayout Offgrid", False), + PrecheckCheck("klayout_met_min_ca_density", "Klayout Metal Density", False), + PrecheckCheck( + "klayout_pin_label_purposes_overlapping_drawing", + "Klayout Pin Label", + False, + ), + PrecheckCheck("klayout_zeroarea", "Klayout ZeroArea", False), + PrecheckCheck("spike_check", "Spike Check", False), + PrecheckCheck("illegal_cellname_check", "Illegal Cellname", False), + PrecheckCheck("lvs", "LVS", False), + PrecheckCheck("oeb", "OEB", False), +) + +PRECHECK_CHECK_REFS: frozenset[str] = frozenset(c.ref for c in PRECHECK_CHECKS) diff --git a/chipfoundry_cli/main.py b/chipfoundry_cli/main.py index b575a7c..ce62fd8 100644 --- a/chipfoundry_cli/main.py +++ b/chipfoundry_cli/main.py @@ -1,7 +1,9 @@ import click import getpass from typing import Optional, List +from chipfoundry_cli.check_refs import PRECHECK_CHECKS from chipfoundry_cli.remote_precheck_git import RemotePrecheckGitError, verify_remote_precheck_repo +from chipfoundry_cli.version_check import maybe_warn_outdated from chipfoundry_cli.utils import ( collect_project_files, ensure_cf_directory, update_or_create_project_json, sftp_connect, upload_with_progress, sftp_ensure_dirs, sftp_download_recursive, @@ -174,7 +176,16 @@ def check_project_initialized(project_root_path: Path, command_name: str, dry_ru @click.group(help="ChipFoundry CLI: Automate project submission and management.") @click.version_option(importlib.metadata.version("chipfoundry-cli"), "-v", "--version", message="%(version)s") def main(): - pass + # Best-effort upgrade check. Cached on disk for CACHE_TTL_SECONDS and + # guarded by a short timeout so it never slows down a command. Runs + # only when a subcommand was dispatched — `cf --version` / `cf --help` + # exit before this callback fires. + try: + current = importlib.metadata.version("chipfoundry-cli") + maybe_warn_outdated(current, _get_api_url(), console, user_agent=_cf_user_agent()) + except Exception: + # Never let a version-check issue break the actual command. + pass @main.command('config') def config_cmd(): @@ -3553,11 +3564,62 @@ def _upload_precheck_results(project_json_path: Path): console.print("[yellow]⚠ Precheck results could not be synced to platform[/yellow]") -@main.command('precheck') +def _print_precheck_checks() -> None: + """Print the list of available precheck checks as a table.""" + table = Table(title="Available cf-precheck checks", show_lines=False) + table.add_column("Ref", style="cyan", no_wrap=True) + table.add_column("Name", style="white") + table.add_column("Default", style="green") + for c in PRECHECK_CHECKS: + default = "opt-in" if c.optional else "on" + table.add_row(c.ref, c.surname, default) + console.print(table) + console.print( + "\n[dim]Use [bold]--checks REF[/bold] to run only specific checks, " + "[bold]--skip-checks REF[/bold] to skip, or [bold]--magic-drc[/bold] " + "to include the optional Magic DRC check.[/dim]" + ) + + +def _build_precheck_help() -> str: + """Build the --help text, including the list of available checks.""" + lines = [ + "Run precheck validation on the project.", + "", + "This runs the cf-precheck tool to validate your design before submission.", + "", + "\b", + "Examples:", + " cf precheck # Run all checks", + " cf precheck --list-checks # List available checks and exit", + " cf precheck --skip-checks lvs # Skip LVS check", + " cf precheck --magic-drc # Include optional Magic DRC", + " cf precheck --checks topcell_check # Run specific checks only", + " cf precheck --remote # Queue on platform; exit when accepted", + " cf precheck --remote --poll # Wait and stream progress", + " cf precheck --remote --poll --wait-timeout 0 # Poll until done (no time limit)", + "", + "\b", + "Available checks (pass to --checks / --skip-checks):", + ] + for c in PRECHECK_CHECKS: + suffix = " (optional; opt in via --magic-drc)" if c.optional else "" + lines.append(f" {c.ref}{suffix}") + lines += [ + "", + "Remote precheck requires your local HEAD to match origin for --git-ref, and", + "precheck inputs (wrapper GDS, verilog/rtl/user_defines.v when the GPIO check", + "runs, and tracked .cf/project.json) to match that commit.", + ] + return "\n".join(lines) + + +@main.command('precheck', help=_build_precheck_help()) @click.option('--project-root', type=click.Path(exists=True, file_okay=False), help='Path to the project directory (defaults to current directory)') -@click.option('--skip-checks', multiple=True, help='Checks to skip (can be specified multiple times)') +@click.option('--skip-checks', multiple=True, help='Checks to skip (repeatable). See --list-checks for valid refs.') @click.option('--magic-drc', is_flag=True, help='Include Magic DRC check (optional, off by default)') -@click.option('--checks', multiple=True, help='Specific checks to run (can be specified multiple times)') +@click.option('--checks', multiple=True, help='Specific checks to run (repeatable). See --list-checks for valid refs.') +@click.option('--list-checks', 'list_checks', is_flag=True, help='List the available precheck checks and exit.') @click.option('--dry-run', is_flag=True, help='Show the command without running') @click.option('--remote', is_flag=True, help='Queue precheck on the ChipFoundry platform (requires cf login + linked project)') @click.option( @@ -3573,25 +3635,10 @@ def _upload_precheck_results(project_json_path: Path): show_default=True, help='With --remote --poll: max seconds to wait (0 = no limit). Ignored without --poll.', ) -def precheck(project_root, skip_checks, magic_drc, checks, dry_run, remote, poll, git_ref, wait_timeout): - """Run precheck validation on the project. - - This runs the cf-precheck tool to validate your design before - submission. - - Examples: - cf precheck # Run all checks - cf precheck --skip-checks lvs # Skip LVS check - cf precheck --magic-drc # Include optional Magic DRC - cf precheck --checks topcell_check # Run specific checks only - cf precheck --remote # Queue on platform; exit when accepted - cf precheck --remote --poll # Wait and stream progress - cf precheck --remote --poll --wait-timeout 0 # Poll until done (no time limit) - - Remote precheck requires your local HEAD to match origin for --git-ref, and precheck - inputs (wrapper GDS, verilog/rtl/user_defines.v when the GPIO check runs, and tracked - .cf/project.json) to match that commit. - """ +def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run, remote, poll, git_ref, wait_timeout): + if list_checks: + _print_precheck_checks() + return cwd_root, _ = get_project_json_from_cwd() if not project_root and cwd_root: project_root = cwd_root @@ -3659,7 +3706,10 @@ def precheck(project_root, skip_checks, magic_drc, checks, dry_run, remote, poll api_url = _get_api_url() client = httpx_remote.Client( base_url=f"{api_url}/api/v1", - headers={'Authorization': f'Bearer {api_key}'}, + headers={ + 'Authorization': f'Bearer {api_key}', + 'User-Agent': _cf_user_agent(), + }, timeout=120.0, ) try: @@ -4174,6 +4224,24 @@ def verify(test, project_root, sim, list_tests, run_all, tag, dry_run): PORTAL_BASE_URL = 'https://platform.chipfoundry.io' +def _cf_user_agent() -> str: + """User-Agent string for platform requests. + + Format: ``chipfoundry-cli/ python/ ``. + Lets the backend track which CLI versions are in the wild without a + dedicated telemetry endpoint. + """ + import platform as _platform + + try: + cli_version = importlib.metadata.version("chipfoundry-cli") + except importlib.metadata.PackageNotFoundError: + cli_version = "unknown" + py = _platform.python_version() + system = f"{_platform.system().lower()}-{_platform.machine().lower()}" + return f"chipfoundry-cli/{cli_version} python/{py} {system}" + + def _get_api_url() -> str: config = load_user_config() return config.get('api_url', DEFAULT_API_URL) @@ -4199,7 +4267,10 @@ def _api_client(): api_url = _get_api_url() client = httpx.Client( base_url=f"{api_url}/api/v1", - headers={'Authorization': f'Bearer {api_key}'}, + headers={ + 'Authorization': f'Bearer {api_key}', + 'User-Agent': _cf_user_agent(), + }, timeout=15, ) return client, api_url @@ -4435,7 +4506,11 @@ def login_cmd(test): console.print(f"Opening browser to authenticate with [bold]{api_url}[/bold]...\n") try: - resp = httpx.post(f"{api_url}/api/v1/auth/cli/sessions", timeout=10) + resp = httpx.post( + f"{api_url}/api/v1/auth/cli/sessions", + headers={'User-Agent': _cf_user_agent()}, + timeout=10, + ) resp.raise_for_status() data = resp.json() except httpx.HTTPError as e: @@ -4457,7 +4532,11 @@ def login_cmd(test): for _ in range(max_polls): time.sleep(poll_interval) try: - poll_resp = httpx.get(poll_url, timeout=10) + poll_resp = httpx.get( + poll_url, + headers={'User-Agent': _cf_user_agent()}, + timeout=10, + ) poll_resp.raise_for_status() poll_data = poll_resp.json() except httpx.HTTPError: @@ -4528,7 +4607,10 @@ def whoami_cmd(): try: resp = httpx.get( f"{api_url}/api/v1/auth/cli/whoami", - headers={'Authorization': f'Bearer {api_key}'}, + headers={ + 'Authorization': f'Bearer {api_key}', + 'User-Agent': _cf_user_agent(), + }, timeout=10, ) if resp.status_code == 401: diff --git a/chipfoundry_cli/version_check.py b/chipfoundry_cli/version_check.py new file mode 100644 index 0000000..3f9f0e5 --- /dev/null +++ b/chipfoundry_cli/version_check.py @@ -0,0 +1,204 @@ +"""Client-side upgrade check for the ``cf`` CLI. + +Polls ``GET /api/v1/cli/version`` on the public API with a short timeout +and caches the response on disk so we only hit the network a few times +per day per user. Prints a dim warning if the installed version is +behind the latest published release. All errors are swallowed — a +failed check must never block or delay a normal command. +""" + +from __future__ import annotations + +import json +import os +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from rich.console import Console + +from chipfoundry_cli.utils import get_config_path + + +CACHE_FILENAME = "version_check.json" +CACHE_TTL_SECONDS = 6 * 60 * 60 # 6 hours +NETWORK_TIMEOUT_SECONDS = 1.5 +ENDPOINT_PATH = "/api/v1/cli/version" +ENV_DISABLE = "CF_SKIP_VERSION_CHECK" + + +@dataclass(frozen=True) +class VersionInfo: + latest: str + minimum_supported: str + upgrade_command: str + release_notes_url: str + + +def _cache_path() -> Path: + return get_config_path().parent / CACHE_FILENAME + + +def _parse_semver(version: str) -> tuple[int, ...]: + """Parse ``X.Y.Z`` (optionally with pre-release suffix) into a tuple. + + Unknown or non-numeric components are treated as 0 so a malformed + version never crashes the CLI. This is intentionally lightweight — + we don't pull in ``packaging`` just for this. + """ + cleaned = version.strip().lstrip("v") + # Drop any pre-release / build-metadata suffix (e.g. ``1.2.3-rc1+sha``). + for sep in ("-", "+"): + if sep in cleaned: + cleaned = cleaned.split(sep, 1)[0] + parts: list[int] = [] + for piece in cleaned.split("."): + try: + parts.append(int(piece)) + except ValueError: + parts.append(0) + while len(parts) < 3: + parts.append(0) + return tuple(parts[:3]) + + +def _is_older(current: str, target: str) -> bool: + """Return True if ``current`` is strictly older than ``target``.""" + return _parse_semver(current) < _parse_semver(target) + + +def _read_cache() -> Optional[dict]: + path = _cache_path() + if not path.exists(): + return None + try: + with open(path, "r") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(data, dict): + return None + ts = data.get("fetched_at") + if not isinstance(ts, (int, float)): + return None + if (time.time() - ts) > CACHE_TTL_SECONDS: + return None + return data + + +def _write_cache(payload: dict) -> None: + path = _cache_path() + try: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".json.tmp") + with open(tmp, "w") as f: + json.dump(payload, f) + tmp.replace(path) + except OSError: + # Cache is an optimization; never fail the command because we + # couldn't write it. + pass + + +def _fetch_latest(api_url: str, user_agent: Optional[str] = None) -> Optional[VersionInfo]: + """Hit the platform endpoint. Returns None on any failure.""" + import httpx + + url = f"{api_url.rstrip('/')}{ENDPOINT_PATH}" + headers = {"User-Agent": user_agent} if user_agent else {} + try: + resp = httpx.get(url, headers=headers, timeout=NETWORK_TIMEOUT_SECONDS) + resp.raise_for_status() + body = resp.json() + except Exception: + return None + try: + return VersionInfo( + latest=str(body["latest"]), + minimum_supported=str(body["minimum_supported"]), + upgrade_command=str(body["upgrade_command"]), + release_notes_url=str(body.get("release_notes_url", "")), + ) + except (KeyError, TypeError): + return None + + +def _load_or_fetch(api_url: str, user_agent: Optional[str] = None) -> Optional[VersionInfo]: + cached = _read_cache() + if cached is not None and "info" in cached: + info = cached["info"] + try: + return VersionInfo( + latest=str(info["latest"]), + minimum_supported=str(info["minimum_supported"]), + upgrade_command=str(info["upgrade_command"]), + release_notes_url=str(info.get("release_notes_url", "")), + ) + except (KeyError, TypeError): + pass + fresh = _fetch_latest(api_url, user_agent=user_agent) + if fresh is not None: + _write_cache( + { + "fetched_at": time.time(), + "info": { + "latest": fresh.latest, + "minimum_supported": fresh.minimum_supported, + "upgrade_command": fresh.upgrade_command, + "release_notes_url": fresh.release_notes_url, + }, + } + ) + return fresh + + +def maybe_warn_outdated( + current_version: str, + api_url: str, + console: Console, + user_agent: Optional[str] = None, +) -> None: + """Print a warning if the installed CLI is behind. + + Two severity tiers: + + * **Below ``minimum_supported``** → prominent red warning. The + server will already reject these requests with HTTP 426 (see + :mod:`src.cli_version_service.hard_floor`); we surface the + upgrade instruction here so users learn *why* before they see the + error on their next real command. + * **Behind ``latest`` but at/above minimum** → dim yellow tip. + Purely informational. + + Never raises. + """ + if os.environ.get(ENV_DISABLE, "").strip() not in ("", "0", "false", "False"): + return + try: + info = _load_or_fetch(api_url, user_agent=user_agent) + except Exception: + return + if info is None: + return + + notes = ( + f" ({info.release_notes_url})" if info.release_notes_url else "" + ) + + if _is_older(current_version, info.minimum_supported): + console.print( + f"[red]✗ cf {current_version} is below the minimum supported " + f"version ({info.minimum_supported}).[/red] The platform will " + f"reject API calls from this install.\n" + f" Upgrade now: [cyan]{info.upgrade_command}[/cyan]{notes}" + ) + return + + if _is_older(current_version, info.latest): + console.print( + f"[yellow]⚠[/yellow] A newer [bold]cf[/bold] is available: " + f"[bold]{info.latest}[/bold] (you have {current_version}).\n" + f" Upgrade: [cyan]{info.upgrade_command}[/cyan]{notes}", + style="dim", + ) diff --git a/pyproject.toml b/pyproject.toml index 8515868..55f1189 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "chipfoundry-cli" -version = "2.3.19" +version = "2.4.0" description = "CLI tool to automate ChipFoundry project submission to SFTP server" authors = ["ChipFoundry "] readme = "README.md" diff --git a/tests/test_config_commands.py b/tests/test_config_commands.py index 080cabf..f965798 100644 --- a/tests/test_config_commands.py +++ b/tests/test_config_commands.py @@ -19,7 +19,7 @@ def test_config_help(self): result = runner.invoke(main, ['config', '--help']) assert result.exit_code == 0 - assert 'Configure user-level SFTP credentials' in result.output + assert 'Configure a custom SSH private key path for SFTP access' in result.output class TestKeygenCommand: diff --git a/tests/test_functional.py b/tests/test_functional.py index 56d5213..63f1689 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -173,9 +173,14 @@ def test_push_missing_required_files(self, temp_project_dir): '--dry-run' ]) - # Should fail with meaningful error about missing files + # Should fail with a meaningful error. On an unlinked, empty project the + # current behavior is to abort with a linking hint before checking files; + # older CLI versions aborted on missing artifacts. Accept either surface. assert result.exit_code != 0 - assert any(keyword in result.output.lower() for keyword in ['not found', 'missing', 'required', 'gds', 'verilog']) + assert any(keyword in result.output.lower() for keyword in [ + 'not found', 'missing', 'required', 'gds', 'verilog', + 'not linked', 'cf link', 'cf init', + ]) def test_harden_missing_openlane(self, temp_project_dir): """Test that harden fails gracefully when openlane is missing.""" diff --git a/tests/test_init_command.py b/tests/test_init_command.py index 4dea203..91bfe91 100644 --- a/tests/test_init_command.py +++ b/tests/test_init_command.py @@ -43,7 +43,7 @@ def test_init_help(self): result = runner.invoke(main, ['init', '--help']) assert result.exit_code == 0 - assert 'Initialize a new ChipFoundry project' in result.output + assert 'Initialize or refresh the local ChipFoundry project configuration' in result.output assert '--project-root' in result.output def test_init_with_project_root(self, temp_project_dir): diff --git a/tests/test_precheck_command.py b/tests/test_precheck_command.py index 1dc16fd..07a7eb0 100644 --- a/tests/test_precheck_command.py +++ b/tests/test_precheck_command.py @@ -37,6 +37,22 @@ def test_precheck_help(self): assert '--dry-run' in result.output assert '--poll' in result.output assert '--wait-timeout' in result.output + assert '--list-checks' in result.output + assert 'Available checks' in result.output + assert 'topcell_check' in result.output + assert 'lvs' in result.output + + def test_precheck_list_checks(self): + """--list-checks prints all known check refs and exits cleanly.""" + from chipfoundry_cli.check_refs import PRECHECK_CHECKS + + runner = CliRunner() + result = runner.invoke(main, ['precheck', '--list-checks']) + + assert result.exit_code == 0 + for check in PRECHECK_CHECKS: + assert check.ref in result.output + assert 'opt-in' in result.output def test_precheck_dry_run(self, temp_project_dir): """Test precheck command with --dry-run flag.""" diff --git a/tests/test_remote_precheck_git.py b/tests/test_remote_precheck_git.py index 60a372d..7f2e35c 100644 --- a/tests/test_remote_precheck_git.py +++ b/tests/test_remote_precheck_git.py @@ -1,5 +1,6 @@ """Tests for remote precheck git consistency checks.""" +import os import subprocess from pathlib import Path @@ -8,8 +9,26 @@ from chipfoundry_cli.remote_precheck_git import RemotePrecheckGitError, verify_remote_precheck_repo +# CI runners do not have a global git identity configured. Inject one via env +# so `git commit` in these throwaway repos never fails with "please tell me who +# you are" (exit 128). Keeping it in env (not --global config) avoids mutating +# the developer's machine when running the suite locally. +_GIT_ENV = { + **os.environ, + "GIT_AUTHOR_NAME": "cf-cli-test", + "GIT_AUTHOR_EMAIL": "cf-cli-test@example.invalid", + "GIT_COMMITTER_NAME": "cf-cli-test", + "GIT_COMMITTER_EMAIL": "cf-cli-test@example.invalid", +} + + def _git(cwd: Path, *args: str) -> None: - subprocess.run(["git", "-C", str(cwd), *args], check=True, capture_output=True) + subprocess.run( + ["git", "-C", str(cwd), *args], + check=True, + capture_output=True, + env=_GIT_ENV, + ) def _init_digital_project(work: Path) -> None: diff --git a/tests/test_status_command.py b/tests/test_status_command.py index 6128079..4433dd6 100644 --- a/tests/test_status_command.py +++ b/tests/test_status_command.py @@ -15,7 +15,7 @@ def test_status_help(self): result = runner.invoke(main, ['status', '--help']) assert result.exit_code == 0 - assert 'Show all projects and outputs' in result.output + assert 'Show project status' in result.output assert '--sftp-host' in result.output assert '--sftp-username' in result.output assert '--sftp-key' in result.output diff --git a/tests/test_version_check.py b/tests/test_version_check.py new file mode 100644 index 0000000..370f4a1 --- /dev/null +++ b/tests/test_version_check.py @@ -0,0 +1,255 @@ +"""Unit tests for the client-side version check (chipfoundry_cli.version_check).""" + +import json +import time +from unittest.mock import patch + +import httpx +import pytest +from rich.console import Console + +from chipfoundry_cli import version_check +from chipfoundry_cli.version_check import ( + CACHE_TTL_SECONDS, + ENV_DISABLE, + VersionInfo, + _is_older, + _parse_semver, + maybe_warn_outdated, +) + + +@pytest.fixture +def isolated_cache(tmp_path, monkeypatch): + """Route the on-disk cache to an ephemeral tmp dir.""" + fake_config = tmp_path / "config.toml" + monkeypatch.setattr( + "chipfoundry_cli.version_check.get_config_path", + lambda: fake_config, + ) + yield tmp_path + + +# ── parsing / comparison ──────────────────────────────────────────────────── + +class TestSemverParsing: + @pytest.mark.parametrize("raw,expected", [ + ("1.2.3", (1, 2, 3)), + ("v2.3.20", (2, 3, 20)), + ("1.0", (1, 0, 0)), + ("2", (2, 0, 0)), + ("1.2.3-rc1", (1, 2, 3)), + ("1.2.3+sha.abc", (1, 2, 3)), + ("1.2.3-rc1+sha.abc", (1, 2, 3)), + (" 2.3.20 ", (2, 3, 20)), + ]) + def test_parses_known_forms(self, raw, expected): + assert _parse_semver(raw) == expected + + def test_malformed_falls_back_to_zeros(self): + assert _parse_semver("not.a.version") == (0, 0, 0) + + def test_is_older_basic(self): + assert _is_older("2.3.19", "2.3.20") is True + assert _is_older("2.3.20", "2.3.20") is False + assert _is_older("2.3.21", "2.3.20") is False + assert _is_older("1.9.9", "2.0.0") is True + + +# ── warning behavior ──────────────────────────────────────────────────────── + +class TestMaybeWarnOutdated: + def _info(self, latest="2.3.25"): + return VersionInfo( + latest=latest, + minimum_supported="2.0.0", + upgrade_command="pip install --upgrade chipfoundry-cli", + release_notes_url="https://example.com/notes", + ) + + def test_warns_when_outdated(self, isolated_cache): + console = Console(record=True, force_terminal=False) + with patch.object(version_check, "_load_or_fetch", return_value=self._info()): + maybe_warn_outdated("2.3.19", "https://api.example.com", console) + output = console.export_text() + assert "newer" in output + assert "2.3.25" in output + assert "2.3.19" in output + assert "pip install --upgrade chipfoundry-cli" in output + + def test_silent_when_current(self, isolated_cache): + console = Console(record=True, force_terminal=False) + with patch.object(version_check, "_load_or_fetch", return_value=self._info()): + maybe_warn_outdated("2.3.25", "https://api.example.com", console) + assert console.export_text() == "" + + def test_silent_when_ahead(self, isolated_cache): + console = Console(record=True, force_terminal=False) + with patch.object(version_check, "_load_or_fetch", return_value=self._info()): + maybe_warn_outdated("9.9.9", "https://api.example.com", console) + assert console.export_text() == "" + + def test_silent_when_fetch_returns_none(self, isolated_cache): + console = Console(record=True, force_terminal=False) + with patch.object(version_check, "_load_or_fetch", return_value=None): + maybe_warn_outdated("1.0.0", "https://api.example.com", console) + assert console.export_text() == "" + + def test_network_errors_never_surface(self, isolated_cache): + """A transport error during fetch must be swallowed.""" + console = Console(record=True, force_terminal=False) + + def _boom(*_args, **_kwargs): + raise httpx.ConnectError("no network") + + with patch.object(version_check.httpx, "get", _boom) if hasattr(version_check, "httpx") else patch("httpx.get", _boom): + maybe_warn_outdated("1.0.0", "https://api.example.com", console) + assert console.export_text() == "" + + def test_below_minimum_uses_red_blocking_message(self, isolated_cache): + """When current < minimum_supported, surface a prominent (non-dim) + message that makes it clear the platform will reject requests.""" + console = Console(record=True, force_terminal=False) + info = VersionInfo( + latest="2.4.0", + minimum_supported="2.4.0", + upgrade_command="pip install --upgrade chipfoundry-cli", + release_notes_url="https://example.com/notes", + ) + with patch.object(version_check, "_load_or_fetch", return_value=info): + maybe_warn_outdated("2.3.19", "https://api.example.com", console) + output = console.export_text() + assert "below the minimum supported" in output + assert "reject" in output + assert "2.3.19" in output + assert "2.4.0" in output + # Should not also show the dim "newer available" warning. + assert "A newer" not in output + + def test_at_minimum_shows_no_warning_when_also_latest(self, isolated_cache): + console = Console(record=True, force_terminal=False) + info = VersionInfo( + latest="2.4.0", + minimum_supported="2.4.0", + upgrade_command="pip install --upgrade chipfoundry-cli", + release_notes_url="", + ) + with patch.object(version_check, "_load_or_fetch", return_value=info): + maybe_warn_outdated("2.4.0", "https://api.example.com", console) + assert console.export_text() == "" + + def test_at_minimum_but_behind_latest_shows_soft_tip(self, isolated_cache): + console = Console(record=True, force_terminal=False) + info = VersionInfo( + latest="2.5.0", + minimum_supported="2.4.0", + upgrade_command="pip install --upgrade chipfoundry-cli", + release_notes_url="", + ) + with patch.object(version_check, "_load_or_fetch", return_value=info): + maybe_warn_outdated("2.4.0", "https://api.example.com", console) + output = console.export_text() + assert "newer" in output + assert "below the minimum" not in output + + def test_disabled_by_env_var(self, isolated_cache, monkeypatch): + monkeypatch.setenv(ENV_DISABLE, "1") + console = Console(record=True, force_terminal=False) + called = {"n": 0} + + def _spy(*_a, **_kw): + called["n"] += 1 + return self._info() + + with patch.object(version_check, "_load_or_fetch", _spy): + maybe_warn_outdated("1.0.0", "https://api.example.com", console) + assert called["n"] == 0 + assert console.export_text() == "" + + +# ── on-disk cache ─────────────────────────────────────────────────────────── + +class TestCache: + def _response(self): + return { + "latest": "2.3.25", + "minimum_supported": "2.0.0", + "upgrade_command": "pip install --upgrade chipfoundry-cli", + "release_notes_url": "https://example.com/notes", + } + + def test_fresh_fetch_writes_cache(self, isolated_cache): + console = Console(record=True, force_terminal=False) + + class FakeResp: + def raise_for_status(self): + pass + + def json(self_inner): + return self._response() + + with patch("httpx.get", return_value=FakeResp()): + maybe_warn_outdated("2.3.19", "https://api.example.com", console) + + cache_path = isolated_cache / "version_check.json" + assert cache_path.exists() + with open(cache_path) as f: + data = json.load(f) + assert data["info"]["latest"] == "2.3.25" + assert isinstance(data["fetched_at"], (int, float)) + assert "2.3.25" in console.export_text() + + def test_fresh_cache_skips_network(self, isolated_cache): + cache_path = isolated_cache / "version_check.json" + cache_path.write_text(json.dumps({ + "fetched_at": time.time(), + "info": self._response(), + })) + + called = {"n": 0} + + def _boom(*_a, **_kw): + called["n"] += 1 + raise AssertionError("network should not be hit when cache is fresh") + + console = Console(record=True, force_terminal=False) + with patch("httpx.get", _boom): + maybe_warn_outdated("2.3.19", "https://api.example.com", console) + assert called["n"] == 0 + assert "2.3.25" in console.export_text() + + def test_stale_cache_triggers_refresh(self, isolated_cache): + cache_path = isolated_cache / "version_check.json" + cache_path.write_text(json.dumps({ + "fetched_at": time.time() - (CACHE_TTL_SECONDS + 60), + "info": {**self._response(), "latest": "0.0.1"}, + })) + + class FakeResp: + def raise_for_status(self): + pass + + def json(self_inner): + return self._response() + + console = Console(record=True, force_terminal=False) + with patch("httpx.get", return_value=FakeResp()): + maybe_warn_outdated("2.3.19", "https://api.example.com", console) + # The fresh network value (2.3.25), not the stale 0.0.1, should win. + assert "2.3.25" in console.export_text() + + def test_corrupt_cache_is_ignored(self, isolated_cache): + cache_path = isolated_cache / "version_check.json" + cache_path.write_text("{ not json") + + class FakeResp: + def raise_for_status(self): + pass + + def json(self_inner): + return self._response() + + console = Console(record=True, force_terminal=False) + with patch("httpx.get", return_value=FakeResp()): + maybe_warn_outdated("2.3.19", "https://api.example.com", console) + assert "2.3.25" in console.export_text()