From 286a182b8ac3030353af88b2eafa26828d4d4d9b Mon Sep 17 00:00:00 2001 From: jdicorpo Date: Mon, 20 Apr 2026 15:28:41 -0700 Subject: [PATCH 1/3] feat: cf precheck --list-checks, client-side version check, User-Agent header (2.4.0) New user-facing features ------------------------ * cf precheck --list-checks (and an expanded --help) prints the canonical list of precheck references, sourced from the new chipfoundry_cli.check_refs module so CLI help, validation, and future tooling share one definition. * Client-side version check: every invocation polls GET /api/v1/cli/version (6h on-disk cache at ~/.chipfoundry-cli/version_check.json) and prints a dim yellow tip when a newer cf is available. A second, louder tier prints a red warning when the installed version is below the server's advertised minimum_supported. All failures are swallowed -- a flaky network must never block a real command. Gate-offable via CF_SKIP_VERSION_CHECK=1. * User-Agent on every outbound API call is now "chipfoundry-cli/ python/ " so the backend can attribute traffic and (post-May 13) apply hard-floor rejection to pre-2.4.0 installs without affecting browser/portal/curl clients. Bumps cf-cli to 2.4.0. Notes ----- The backend (chipignite-backend-services 1.18.1) already serves the version endpoint and has the nudge middleware live. The hard-floor middleware is parked there until after the May 13 shuttle deadline, at which point flipping MINIMUM_SUPPORTED_CLI_VERSION to 2.4.0 will start rejecting older installs. This release is the first one customers can upgrade to in order to get ahead of that flip. Not yet published to PyPI; ship when ready. Made-with: Cursor --- .gitignore | 2 + chipfoundry_cli/check_refs.py | 42 +++++ chipfoundry_cli/main.py | 138 +++++++++++++---- chipfoundry_cli/version_check.py | 204 +++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_precheck_command.py | 16 ++ tests/test_version_check.py | 255 +++++++++++++++++++++++++++++++ 7 files changed, 630 insertions(+), 29 deletions(-) create mode 100644 chipfoundry_cli/check_refs.py create mode 100644 chipfoundry_cli/version_check.py create mode 100644 tests/test_version_check.py 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_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_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() From 617a7167a6afe1ec078bb828ecabd42d297498a8 Mon Sep 17 00:00:00 2001 From: jdicorpo Date: Mon, 20 Apr 2026 15:49:10 -0700 Subject: [PATCH 2/3] test: update stale help/error assertions to match current CLI output Four tests were asserting on help/error strings that have drifted over time (unrelated to this branch). Refresh them to match the current copy so CI can verify the version-check / list-checks work in this PR without riding on pre-existing red. - test_config_help: SSH private key path wording - test_init_help: "Initialize or refresh..." wording - test_status_help: "Show project status" wording - test_push_missing_required_files: accept the newer unlinked-project abort path alongside the legacy missing-file keywords Made-with: Cursor --- tests/test_config_commands.py | 2 +- tests/test_functional.py | 9 +++++++-- tests/test_init_command.py | 2 +- tests/test_status_command.py | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) 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_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 From 7833bf2c1118eb0a7c95d324c6ec8abb7b28e117 Mon Sep 17 00:00:00 2001 From: jdicorpo Date: Mon, 20 Apr 2026 15:51:15 -0700 Subject: [PATCH 3/3] test(remote-precheck-git): inject author env so commits succeed on clean CI CI runners have no global git identity, so `git commit -m init` was exiting 128 ("please tell me who you are") and failing four remote-precheck-git tests on every matrix run. Pass GIT_AUTHOR_* / GIT_COMMITTER_* via env rather than mutating `git config --global` so local runs on developer machines remain untouched. Made-with: Cursor --- tests/test_remote_precheck_git.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) 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: