chipfoundry-cli 2.3.19__tar.gz → 2.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: chipfoundry-cli
3
- Version: 2.3.19
3
+ Version: 2.4.1
4
4
  Summary: CLI tool to automate ChipFoundry project submission to SFTP server
5
5
  Home-page: https://chipfoundry.io
6
6
  License: Apache-2.0
@@ -1,2 +1,2 @@
1
1
  """ChipFoundry CLI package: Automate project submission to SFTP."""
2
- __version__ = "2.3.2"
2
+ __version__ = "2.4.1"
@@ -0,0 +1,42 @@
1
+ """Known cf-precheck check names with display metadata.
2
+
3
+ Must stay in sync with cf-precheck's ``ALL_CHECKS`` ordering
4
+ (see ``cf-precheck/src/cf_precheck/check_manager.py``). The backend mirrors
5
+ the ref keys in ``chipignite-backend-services/src/precheck_service/check_refs.py``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import NamedTuple
11
+
12
+
13
+ class PrecheckCheck(NamedTuple):
14
+ ref: str
15
+ surname: str
16
+ optional: bool
17
+
18
+
19
+ PRECHECK_CHECKS: tuple[PrecheckCheck, ...] = (
20
+ PrecheckCheck("topcell_check", "Top Cell", False),
21
+ PrecheckCheck("gpio_defines", "GPIO Defines", False),
22
+ PrecheckCheck("pdnmulti", "PDN Multi", False),
23
+ PrecheckCheck("metalcheck", "Metal Check", False),
24
+ PrecheckCheck("xor", "XOR", False),
25
+ PrecheckCheck("magic_drc", "Magic DRC", True),
26
+ PrecheckCheck("klayout_feol", "Klayout FEOL", False),
27
+ PrecheckCheck("klayout_beol", "Klayout BEOL", False),
28
+ PrecheckCheck("klayout_offgrid", "Klayout Offgrid", False),
29
+ PrecheckCheck("klayout_met_min_ca_density", "Klayout Metal Density", False),
30
+ PrecheckCheck(
31
+ "klayout_pin_label_purposes_overlapping_drawing",
32
+ "Klayout Pin Label",
33
+ False,
34
+ ),
35
+ PrecheckCheck("klayout_zeroarea", "Klayout ZeroArea", False),
36
+ PrecheckCheck("spike_check", "Spike Check", False),
37
+ PrecheckCheck("illegal_cellname_check", "Illegal Cellname", False),
38
+ PrecheckCheck("lvs", "LVS", False),
39
+ PrecheckCheck("oeb", "OEB", False),
40
+ )
41
+
42
+ PRECHECK_CHECK_REFS: frozenset[str] = frozenset(c.ref for c in PRECHECK_CHECKS)
@@ -1,7 +1,9 @@
1
1
  import click
2
2
  import getpass
3
3
  from typing import Optional, List
4
+ from chipfoundry_cli.check_refs import PRECHECK_CHECKS
4
5
  from chipfoundry_cli.remote_precheck_git import RemotePrecheckGitError, verify_remote_precheck_repo
6
+ from chipfoundry_cli.version_check import maybe_warn_outdated
5
7
  from chipfoundry_cli.utils import (
6
8
  collect_project_files, ensure_cf_directory, update_or_create_project_json,
7
9
  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
174
176
  @click.group(help="ChipFoundry CLI: Automate project submission and management.")
175
177
  @click.version_option(importlib.metadata.version("chipfoundry-cli"), "-v", "--version", message="%(version)s")
176
178
  def main():
177
- pass
179
+ # Best-effort upgrade check. Cached on disk for CACHE_TTL_SECONDS and
180
+ # guarded by a short timeout so it never slows down a command. Runs
181
+ # only when a subcommand was dispatched — `cf --version` / `cf --help`
182
+ # exit before this callback fires.
183
+ try:
184
+ current = importlib.metadata.version("chipfoundry-cli")
185
+ maybe_warn_outdated(current, _get_api_url(), console, user_agent=_cf_user_agent())
186
+ except Exception:
187
+ # Never let a version-check issue break the actual command.
188
+ pass
178
189
 
179
190
  @main.command('config')
180
191
  def config_cmd():
@@ -391,8 +402,13 @@ def init(project_root, shuttle, description):
391
402
  local_proj = local_data.get('project', {}) if isinstance(local_data, dict) else {}
392
403
 
393
404
  config = load_user_config()
405
+ api_key = config.get('api_key')
394
406
  username = config.get("sftp_username")
395
- if not username:
407
+ # Try to refresh sftp_username from the platform, but don't block init on it.
408
+ # SFTP accounts are only auto-provisioned once a project deposit is paid/waived/
409
+ # sponsored and the user has an SSH key on their profile; init must work before
410
+ # that so users can configure locally and use `cf precheck` / `cf push --remote`.
411
+ if not username and api_key:
396
412
  try:
397
413
  me = _api_get("/auth/cli/whoami")
398
414
  username = me.get("sftp_username")
@@ -401,11 +417,10 @@ def init(project_root, shuttle, description):
401
417
  save_user_config(config)
402
418
  except SystemExit:
403
419
  pass
404
- if not username:
405
- console.print("[bold red]No SFTP account linked to your platform account. Please run 'cf login' first.[/bold red]")
406
- raise click.Abort()
407
-
408
- api_key = config.get('api_key')
420
+ # Fall back to email (or 'unknown') purely as a label in .cf/project.json.
421
+ # This field is metadata only: SFTP routing uses the live session identity,
422
+ # and the backend stores cli_project_json as an opaque blob.
423
+ user_label = username or config.get("user_email") or "unknown"
409
424
  platform_id = local_proj.get('platform_project_id')
410
425
  platform_proj: Optional[dict] = None
411
426
  if platform_id and api_key:
@@ -458,7 +473,7 @@ def init(project_root, shuttle, description):
458
473
  proj = data.setdefault('project', {})
459
474
  proj['name'] = name
460
475
  proj['type'] = project_type
461
- proj['user'] = username
476
+ proj['user'] = user_label
462
477
  proj.setdefault('version', local_proj.get('version') or "1")
463
478
  proj.setdefault('user_project_wrapper_hash', local_proj.get('user_project_wrapper_hash', ""))
464
479
  proj.setdefault('submission_state', local_proj.get('submission_state', "Draft"))
@@ -1608,7 +1623,11 @@ def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_n
1608
1623
  sftp_username = me.get("sftp_username")
1609
1624
  if not sftp_username:
1610
1625
  console.print("[bold red]No SFTP account linked to your platform account.[/bold red]")
1611
- console.print("Contact support or provide --sftp-username.")
1626
+ console.print(
1627
+ "An SFTP account is provisioned once a project deposit is paid/waived/sponsored "
1628
+ "and an SSH public key is on your profile."
1629
+ )
1630
+ console.print("Override with --sftp-username if you already know yours, or contact support.")
1612
1631
  raise click.Abort()
1613
1632
  config["sftp_username"] = sftp_username
1614
1633
  save_user_config(config)
@@ -1794,7 +1813,11 @@ def pull(project_name, output_dir, sftp_host, sftp_username, sftp_key):
1794
1813
  sftp_username = me.get("sftp_username")
1795
1814
  if not sftp_username:
1796
1815
  console.print("[bold red]No SFTP account linked to your platform account.[/bold red]")
1797
- console.print("Contact support or provide --sftp-username.")
1816
+ console.print(
1817
+ "An SFTP account is provisioned once a project deposit is paid/waived/sponsored "
1818
+ "and an SSH public key is on your profile."
1819
+ )
1820
+ console.print("Override with --sftp-username if you already know yours, or contact support.")
1798
1821
  raise click.Abort()
1799
1822
  config["sftp_username"] = sftp_username
1800
1823
  save_user_config(config)
@@ -2078,24 +2101,34 @@ def status(sftp_host, sftp_username, sftp_key, json_output, show_all):
2078
2101
  platform_id = _load_project_platform_id(os.getcwd())
2079
2102
  if not platform_id:
2080
2103
  console.print("[dim]Tip: Run [bold]cf link[/bold] to connect this project to the platform.[/dim]\n")
2104
+ # SFTP listing is a best-effort extra on top of the platform status above.
2105
+ # Skip it quietly when the user has no SFTP account yet (auto-provisioned
2106
+ # after a project deposit is paid/waived/sponsored + an SSH key is on file).
2081
2107
  if not sftp_username:
2082
- me = _api_get("/auth/cli/whoami")
2083
- sftp_username = me.get("sftp_username")
2108
+ if config.get("api_key"):
2109
+ try:
2110
+ me = _api_get("/auth/cli/whoami")
2111
+ sftp_username = me.get("sftp_username")
2112
+ except SystemExit:
2113
+ sftp_username = None
2084
2114
  if not sftp_username:
2085
- console.print("[red]No SFTP account linked to your platform account.[/red]")
2086
- console.print("Contact support or provide --sftp-username.")
2087
- raise click.Abort()
2115
+ console.print(
2116
+ "[dim]SFTP listing skipped no SFTP account linked yet. "
2117
+ "An account is provisioned once a project deposit is paid/waived/sponsored "
2118
+ "and an SSH public key is on your profile.[/dim]"
2119
+ )
2120
+ return
2088
2121
  config["sftp_username"] = sftp_username
2089
2122
  save_user_config(config)
2090
2123
  if not sftp_key:
2091
2124
  sftp_key = config.get("sftp_key")
2092
-
2125
+
2093
2126
  # Always resolve key_path to absolute path if set
2094
2127
  if sftp_key:
2095
2128
  key_path = os.path.abspath(os.path.expanduser(sftp_key))
2096
2129
  else:
2097
2130
  key_path = DEFAULT_SSH_KEY
2098
-
2131
+
2099
2132
  if not os.path.exists(key_path):
2100
2133
  console.print(f"[red]SFTP key file not found: {key_path}[/red]")
2101
2134
  console.print("[yellow]Please run 'cf keygen' to generate a key or 'cf config' to set a custom key path.[/yellow]")
@@ -2221,7 +2254,11 @@ def tapeouts(sftp_host, sftp_username, sftp_key, limit, days):
2221
2254
  sftp_username = me.get("sftp_username")
2222
2255
  if not sftp_username:
2223
2256
  console.print("[red]No SFTP account linked to your platform account.[/red]")
2224
- console.print("Contact support or provide --sftp-username.")
2257
+ console.print(
2258
+ "An SFTP account is provisioned once a project deposit is paid/waived/sponsored "
2259
+ "and an SSH public key is on your profile."
2260
+ )
2261
+ console.print("Override with --sftp-username if you already know yours, or contact support.")
2225
2262
  raise click.Abort()
2226
2263
  config["sftp_username"] = sftp_username
2227
2264
  save_user_config(config)
@@ -2411,7 +2448,11 @@ def confirm(project_root, sftp_host, sftp_username, sftp_key, project_name):
2411
2448
  sftp_username = me.get("sftp_username")
2412
2449
  if not sftp_username:
2413
2450
  console.print("[bold red]No SFTP account linked to your platform account.[/bold red]")
2414
- console.print("Contact support or provide --sftp-username.")
2451
+ console.print(
2452
+ "An SFTP account is provisioned once a project deposit is paid/waived/sponsored "
2453
+ "and an SSH public key is on your profile."
2454
+ )
2455
+ console.print("Override with --sftp-username if you already know yours, or contact support.")
2415
2456
  raise click.Abort()
2416
2457
  config["sftp_username"] = sftp_username
2417
2458
  save_user_config(config)
@@ -3553,11 +3594,62 @@ def _upload_precheck_results(project_json_path: Path):
3553
3594
  console.print("[yellow]⚠ Precheck results could not be synced to platform[/yellow]")
3554
3595
 
3555
3596
 
3556
- @main.command('precheck')
3597
+ def _print_precheck_checks() -> None:
3598
+ """Print the list of available precheck checks as a table."""
3599
+ table = Table(title="Available cf-precheck checks", show_lines=False)
3600
+ table.add_column("Ref", style="cyan", no_wrap=True)
3601
+ table.add_column("Name", style="white")
3602
+ table.add_column("Default", style="green")
3603
+ for c in PRECHECK_CHECKS:
3604
+ default = "opt-in" if c.optional else "on"
3605
+ table.add_row(c.ref, c.surname, default)
3606
+ console.print(table)
3607
+ console.print(
3608
+ "\n[dim]Use [bold]--checks REF[/bold] to run only specific checks, "
3609
+ "[bold]--skip-checks REF[/bold] to skip, or [bold]--magic-drc[/bold] "
3610
+ "to include the optional Magic DRC check.[/dim]"
3611
+ )
3612
+
3613
+
3614
+ def _build_precheck_help() -> str:
3615
+ """Build the --help text, including the list of available checks."""
3616
+ lines = [
3617
+ "Run precheck validation on the project.",
3618
+ "",
3619
+ "This runs the cf-precheck tool to validate your design before submission.",
3620
+ "",
3621
+ "\b",
3622
+ "Examples:",
3623
+ " cf precheck # Run all checks",
3624
+ " cf precheck --list-checks # List available checks and exit",
3625
+ " cf precheck --skip-checks lvs # Skip LVS check",
3626
+ " cf precheck --magic-drc # Include optional Magic DRC",
3627
+ " cf precheck --checks topcell_check # Run specific checks only",
3628
+ " cf precheck --remote # Queue on platform; exit when accepted",
3629
+ " cf precheck --remote --poll # Wait and stream progress",
3630
+ " cf precheck --remote --poll --wait-timeout 0 # Poll until done (no time limit)",
3631
+ "",
3632
+ "\b",
3633
+ "Available checks (pass to --checks / --skip-checks):",
3634
+ ]
3635
+ for c in PRECHECK_CHECKS:
3636
+ suffix = " (optional; opt in via --magic-drc)" if c.optional else ""
3637
+ lines.append(f" {c.ref}{suffix}")
3638
+ lines += [
3639
+ "",
3640
+ "Remote precheck requires your local HEAD to match origin for --git-ref, and",
3641
+ "precheck inputs (wrapper GDS, verilog/rtl/user_defines.v when the GPIO check",
3642
+ "runs, and tracked .cf/project.json) to match that commit.",
3643
+ ]
3644
+ return "\n".join(lines)
3645
+
3646
+
3647
+ @main.command('precheck', help=_build_precheck_help())
3557
3648
  @click.option('--project-root', type=click.Path(exists=True, file_okay=False), help='Path to the project directory (defaults to current directory)')
3558
- @click.option('--skip-checks', multiple=True, help='Checks to skip (can be specified multiple times)')
3649
+ @click.option('--skip-checks', multiple=True, help='Checks to skip (repeatable). See --list-checks for valid refs.')
3559
3650
  @click.option('--magic-drc', is_flag=True, help='Include Magic DRC check (optional, off by default)')
3560
- @click.option('--checks', multiple=True, help='Specific checks to run (can be specified multiple times)')
3651
+ @click.option('--checks', multiple=True, help='Specific checks to run (repeatable). See --list-checks for valid refs.')
3652
+ @click.option('--list-checks', 'list_checks', is_flag=True, help='List the available precheck checks and exit.')
3561
3653
  @click.option('--dry-run', is_flag=True, help='Show the command without running')
3562
3654
  @click.option('--remote', is_flag=True, help='Queue precheck on the ChipFoundry platform (requires cf login + linked project)')
3563
3655
  @click.option(
@@ -3573,25 +3665,10 @@ def _upload_precheck_results(project_json_path: Path):
3573
3665
  show_default=True,
3574
3666
  help='With --remote --poll: max seconds to wait (0 = no limit). Ignored without --poll.',
3575
3667
  )
3576
- def precheck(project_root, skip_checks, magic_drc, checks, dry_run, remote, poll, git_ref, wait_timeout):
3577
- """Run precheck validation on the project.
3578
-
3579
- This runs the cf-precheck tool to validate your design before
3580
- submission.
3581
-
3582
- Examples:
3583
- cf precheck # Run all checks
3584
- cf precheck --skip-checks lvs # Skip LVS check
3585
- cf precheck --magic-drc # Include optional Magic DRC
3586
- cf precheck --checks topcell_check # Run specific checks only
3587
- cf precheck --remote # Queue on platform; exit when accepted
3588
- cf precheck --remote --poll # Wait and stream progress
3589
- cf precheck --remote --poll --wait-timeout 0 # Poll until done (no time limit)
3590
-
3591
- Remote precheck requires your local HEAD to match origin for --git-ref, and precheck
3592
- inputs (wrapper GDS, verilog/rtl/user_defines.v when the GPIO check runs, and tracked
3593
- .cf/project.json) to match that commit.
3594
- """
3668
+ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run, remote, poll, git_ref, wait_timeout):
3669
+ if list_checks:
3670
+ _print_precheck_checks()
3671
+ return
3595
3672
  cwd_root, _ = get_project_json_from_cwd()
3596
3673
  if not project_root and cwd_root:
3597
3674
  project_root = cwd_root
@@ -3659,7 +3736,10 @@ def precheck(project_root, skip_checks, magic_drc, checks, dry_run, remote, poll
3659
3736
  api_url = _get_api_url()
3660
3737
  client = httpx_remote.Client(
3661
3738
  base_url=f"{api_url}/api/v1",
3662
- headers={'Authorization': f'Bearer {api_key}'},
3739
+ headers={
3740
+ 'Authorization': f'Bearer {api_key}',
3741
+ 'User-Agent': _cf_user_agent(),
3742
+ },
3663
3743
  timeout=120.0,
3664
3744
  )
3665
3745
  try:
@@ -4174,6 +4254,24 @@ DEFAULT_API_URL = 'https://api.chipfoundry.io'
4174
4254
  PORTAL_BASE_URL = 'https://platform.chipfoundry.io'
4175
4255
 
4176
4256
 
4257
+ def _cf_user_agent() -> str:
4258
+ """User-Agent string for platform requests.
4259
+
4260
+ Format: ``chipfoundry-cli/<cli-version> python/<py-version> <platform>``.
4261
+ Lets the backend track which CLI versions are in the wild without a
4262
+ dedicated telemetry endpoint.
4263
+ """
4264
+ import platform as _platform
4265
+
4266
+ try:
4267
+ cli_version = importlib.metadata.version("chipfoundry-cli")
4268
+ except importlib.metadata.PackageNotFoundError:
4269
+ cli_version = "unknown"
4270
+ py = _platform.python_version()
4271
+ system = f"{_platform.system().lower()}-{_platform.machine().lower()}"
4272
+ return f"chipfoundry-cli/{cli_version} python/{py} {system}"
4273
+
4274
+
4177
4275
  def _get_api_url() -> str:
4178
4276
  config = load_user_config()
4179
4277
  return config.get('api_url', DEFAULT_API_URL)
@@ -4199,7 +4297,10 @@ def _api_client():
4199
4297
  api_url = _get_api_url()
4200
4298
  client = httpx.Client(
4201
4299
  base_url=f"{api_url}/api/v1",
4202
- headers={'Authorization': f'Bearer {api_key}'},
4300
+ headers={
4301
+ 'Authorization': f'Bearer {api_key}',
4302
+ 'User-Agent': _cf_user_agent(),
4303
+ },
4203
4304
  timeout=15,
4204
4305
  )
4205
4306
  return client, api_url
@@ -4435,7 +4536,11 @@ def login_cmd(test):
4435
4536
  console.print(f"Opening browser to authenticate with [bold]{api_url}[/bold]...\n")
4436
4537
 
4437
4538
  try:
4438
- resp = httpx.post(f"{api_url}/api/v1/auth/cli/sessions", timeout=10)
4539
+ resp = httpx.post(
4540
+ f"{api_url}/api/v1/auth/cli/sessions",
4541
+ headers={'User-Agent': _cf_user_agent()},
4542
+ timeout=10,
4543
+ )
4439
4544
  resp.raise_for_status()
4440
4545
  data = resp.json()
4441
4546
  except httpx.HTTPError as e:
@@ -4457,7 +4562,11 @@ def login_cmd(test):
4457
4562
  for _ in range(max_polls):
4458
4563
  time.sleep(poll_interval)
4459
4564
  try:
4460
- poll_resp = httpx.get(poll_url, timeout=10)
4565
+ poll_resp = httpx.get(
4566
+ poll_url,
4567
+ headers={'User-Agent': _cf_user_agent()},
4568
+ timeout=10,
4569
+ )
4461
4570
  poll_resp.raise_for_status()
4462
4571
  poll_data = poll_resp.json()
4463
4572
  except httpx.HTTPError:
@@ -4528,7 +4637,10 @@ def whoami_cmd():
4528
4637
  try:
4529
4638
  resp = httpx.get(
4530
4639
  f"{api_url}/api/v1/auth/cli/whoami",
4531
- headers={'Authorization': f'Bearer {api_key}'},
4640
+ headers={
4641
+ 'Authorization': f'Bearer {api_key}',
4642
+ 'User-Agent': _cf_user_agent(),
4643
+ },
4532
4644
  timeout=10,
4533
4645
  )
4534
4646
  if resp.status_code == 401:
@@ -0,0 +1,204 @@
1
+ """Client-side upgrade check for the ``cf`` CLI.
2
+
3
+ Polls ``GET /api/v1/cli/version`` on the public API with a short timeout
4
+ and caches the response on disk so we only hit the network a few times
5
+ per day per user. Prints a dim warning if the installed version is
6
+ behind the latest published release. All errors are swallowed — a
7
+ failed check must never block or delay a normal command.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import time
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ from rich.console import Console
20
+
21
+ from chipfoundry_cli.utils import get_config_path
22
+
23
+
24
+ CACHE_FILENAME = "version_check.json"
25
+ CACHE_TTL_SECONDS = 6 * 60 * 60 # 6 hours
26
+ NETWORK_TIMEOUT_SECONDS = 1.5
27
+ ENDPOINT_PATH = "/api/v1/cli/version"
28
+ ENV_DISABLE = "CF_SKIP_VERSION_CHECK"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class VersionInfo:
33
+ latest: str
34
+ minimum_supported: str
35
+ upgrade_command: str
36
+ release_notes_url: str
37
+
38
+
39
+ def _cache_path() -> Path:
40
+ return get_config_path().parent / CACHE_FILENAME
41
+
42
+
43
+ def _parse_semver(version: str) -> tuple[int, ...]:
44
+ """Parse ``X.Y.Z`` (optionally with pre-release suffix) into a tuple.
45
+
46
+ Unknown or non-numeric components are treated as 0 so a malformed
47
+ version never crashes the CLI. This is intentionally lightweight —
48
+ we don't pull in ``packaging`` just for this.
49
+ """
50
+ cleaned = version.strip().lstrip("v")
51
+ # Drop any pre-release / build-metadata suffix (e.g. ``1.2.3-rc1+sha``).
52
+ for sep in ("-", "+"):
53
+ if sep in cleaned:
54
+ cleaned = cleaned.split(sep, 1)[0]
55
+ parts: list[int] = []
56
+ for piece in cleaned.split("."):
57
+ try:
58
+ parts.append(int(piece))
59
+ except ValueError:
60
+ parts.append(0)
61
+ while len(parts) < 3:
62
+ parts.append(0)
63
+ return tuple(parts[:3])
64
+
65
+
66
+ def _is_older(current: str, target: str) -> bool:
67
+ """Return True if ``current`` is strictly older than ``target``."""
68
+ return _parse_semver(current) < _parse_semver(target)
69
+
70
+
71
+ def _read_cache() -> Optional[dict]:
72
+ path = _cache_path()
73
+ if not path.exists():
74
+ return None
75
+ try:
76
+ with open(path, "r") as f:
77
+ data = json.load(f)
78
+ except (OSError, json.JSONDecodeError):
79
+ return None
80
+ if not isinstance(data, dict):
81
+ return None
82
+ ts = data.get("fetched_at")
83
+ if not isinstance(ts, (int, float)):
84
+ return None
85
+ if (time.time() - ts) > CACHE_TTL_SECONDS:
86
+ return None
87
+ return data
88
+
89
+
90
+ def _write_cache(payload: dict) -> None:
91
+ path = _cache_path()
92
+ try:
93
+ path.parent.mkdir(parents=True, exist_ok=True)
94
+ tmp = path.with_suffix(".json.tmp")
95
+ with open(tmp, "w") as f:
96
+ json.dump(payload, f)
97
+ tmp.replace(path)
98
+ except OSError:
99
+ # Cache is an optimization; never fail the command because we
100
+ # couldn't write it.
101
+ pass
102
+
103
+
104
+ def _fetch_latest(api_url: str, user_agent: Optional[str] = None) -> Optional[VersionInfo]:
105
+ """Hit the platform endpoint. Returns None on any failure."""
106
+ import httpx
107
+
108
+ url = f"{api_url.rstrip('/')}{ENDPOINT_PATH}"
109
+ headers = {"User-Agent": user_agent} if user_agent else {}
110
+ try:
111
+ resp = httpx.get(url, headers=headers, timeout=NETWORK_TIMEOUT_SECONDS)
112
+ resp.raise_for_status()
113
+ body = resp.json()
114
+ except Exception:
115
+ return None
116
+ try:
117
+ return VersionInfo(
118
+ latest=str(body["latest"]),
119
+ minimum_supported=str(body["minimum_supported"]),
120
+ upgrade_command=str(body["upgrade_command"]),
121
+ release_notes_url=str(body.get("release_notes_url", "")),
122
+ )
123
+ except (KeyError, TypeError):
124
+ return None
125
+
126
+
127
+ def _load_or_fetch(api_url: str, user_agent: Optional[str] = None) -> Optional[VersionInfo]:
128
+ cached = _read_cache()
129
+ if cached is not None and "info" in cached:
130
+ info = cached["info"]
131
+ try:
132
+ return VersionInfo(
133
+ latest=str(info["latest"]),
134
+ minimum_supported=str(info["minimum_supported"]),
135
+ upgrade_command=str(info["upgrade_command"]),
136
+ release_notes_url=str(info.get("release_notes_url", "")),
137
+ )
138
+ except (KeyError, TypeError):
139
+ pass
140
+ fresh = _fetch_latest(api_url, user_agent=user_agent)
141
+ if fresh is not None:
142
+ _write_cache(
143
+ {
144
+ "fetched_at": time.time(),
145
+ "info": {
146
+ "latest": fresh.latest,
147
+ "minimum_supported": fresh.minimum_supported,
148
+ "upgrade_command": fresh.upgrade_command,
149
+ "release_notes_url": fresh.release_notes_url,
150
+ },
151
+ }
152
+ )
153
+ return fresh
154
+
155
+
156
+ def maybe_warn_outdated(
157
+ current_version: str,
158
+ api_url: str,
159
+ console: Console,
160
+ user_agent: Optional[str] = None,
161
+ ) -> None:
162
+ """Print a warning if the installed CLI is behind.
163
+
164
+ Two severity tiers:
165
+
166
+ * **Below ``minimum_supported``** → prominent red warning. The
167
+ server will already reject these requests with HTTP 426 (see
168
+ :mod:`src.cli_version_service.hard_floor`); we surface the
169
+ upgrade instruction here so users learn *why* before they see the
170
+ error on their next real command.
171
+ * **Behind ``latest`` but at/above minimum** → dim yellow tip.
172
+ Purely informational.
173
+
174
+ Never raises.
175
+ """
176
+ if os.environ.get(ENV_DISABLE, "").strip() not in ("", "0", "false", "False"):
177
+ return
178
+ try:
179
+ info = _load_or_fetch(api_url, user_agent=user_agent)
180
+ except Exception:
181
+ return
182
+ if info is None:
183
+ return
184
+
185
+ notes = (
186
+ f" ({info.release_notes_url})" if info.release_notes_url else ""
187
+ )
188
+
189
+ if _is_older(current_version, info.minimum_supported):
190
+ console.print(
191
+ f"[red]✗ cf {current_version} is below the minimum supported "
192
+ f"version ({info.minimum_supported}).[/red] The platform will "
193
+ f"reject API calls from this install.\n"
194
+ f" Upgrade now: [cyan]{info.upgrade_command}[/cyan]{notes}"
195
+ )
196
+ return
197
+
198
+ if _is_older(current_version, info.latest):
199
+ console.print(
200
+ f"[yellow]⚠[/yellow] A newer [bold]cf[/bold] is available: "
201
+ f"[bold]{info.latest}[/bold] (you have {current_version}).\n"
202
+ f" Upgrade: [cyan]{info.upgrade_command}[/cyan]{notes}",
203
+ style="dim",
204
+ )
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "chipfoundry-cli"
3
- version = "2.3.19"
3
+ version = "2.4.1"
4
4
  description = "CLI tool to automate ChipFoundry project submission to SFTP server"
5
5
  authors = ["ChipFoundry <marwan.abbas@chipfoundry.io>"]
6
6
  readme = "README.md"