chipfoundry-cli 2.3.19__tar.gz → 2.4.0__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.0
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
@@ -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():
@@ -3553,11 +3564,62 @@ def _upload_precheck_results(project_json_path: Path):
3553
3564
  console.print("[yellow]⚠ Precheck results could not be synced to platform[/yellow]")
3554
3565
 
3555
3566
 
3556
- @main.command('precheck')
3567
+ def _print_precheck_checks() -> None:
3568
+ """Print the list of available precheck checks as a table."""
3569
+ table = Table(title="Available cf-precheck checks", show_lines=False)
3570
+ table.add_column("Ref", style="cyan", no_wrap=True)
3571
+ table.add_column("Name", style="white")
3572
+ table.add_column("Default", style="green")
3573
+ for c in PRECHECK_CHECKS:
3574
+ default = "opt-in" if c.optional else "on"
3575
+ table.add_row(c.ref, c.surname, default)
3576
+ console.print(table)
3577
+ console.print(
3578
+ "\n[dim]Use [bold]--checks REF[/bold] to run only specific checks, "
3579
+ "[bold]--skip-checks REF[/bold] to skip, or [bold]--magic-drc[/bold] "
3580
+ "to include the optional Magic DRC check.[/dim]"
3581
+ )
3582
+
3583
+
3584
+ def _build_precheck_help() -> str:
3585
+ """Build the --help text, including the list of available checks."""
3586
+ lines = [
3587
+ "Run precheck validation on the project.",
3588
+ "",
3589
+ "This runs the cf-precheck tool to validate your design before submission.",
3590
+ "",
3591
+ "\b",
3592
+ "Examples:",
3593
+ " cf precheck # Run all checks",
3594
+ " cf precheck --list-checks # List available checks and exit",
3595
+ " cf precheck --skip-checks lvs # Skip LVS check",
3596
+ " cf precheck --magic-drc # Include optional Magic DRC",
3597
+ " cf precheck --checks topcell_check # Run specific checks only",
3598
+ " cf precheck --remote # Queue on platform; exit when accepted",
3599
+ " cf precheck --remote --poll # Wait and stream progress",
3600
+ " cf precheck --remote --poll --wait-timeout 0 # Poll until done (no time limit)",
3601
+ "",
3602
+ "\b",
3603
+ "Available checks (pass to --checks / --skip-checks):",
3604
+ ]
3605
+ for c in PRECHECK_CHECKS:
3606
+ suffix = " (optional; opt in via --magic-drc)" if c.optional else ""
3607
+ lines.append(f" {c.ref}{suffix}")
3608
+ lines += [
3609
+ "",
3610
+ "Remote precheck requires your local HEAD to match origin for --git-ref, and",
3611
+ "precheck inputs (wrapper GDS, verilog/rtl/user_defines.v when the GPIO check",
3612
+ "runs, and tracked .cf/project.json) to match that commit.",
3613
+ ]
3614
+ return "\n".join(lines)
3615
+
3616
+
3617
+ @main.command('precheck', help=_build_precheck_help())
3557
3618
  @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)')
3619
+ @click.option('--skip-checks', multiple=True, help='Checks to skip (repeatable). See --list-checks for valid refs.')
3559
3620
  @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)')
3621
+ @click.option('--checks', multiple=True, help='Specific checks to run (repeatable). See --list-checks for valid refs.')
3622
+ @click.option('--list-checks', 'list_checks', is_flag=True, help='List the available precheck checks and exit.')
3561
3623
  @click.option('--dry-run', is_flag=True, help='Show the command without running')
3562
3624
  @click.option('--remote', is_flag=True, help='Queue precheck on the ChipFoundry platform (requires cf login + linked project)')
3563
3625
  @click.option(
@@ -3573,25 +3635,10 @@ def _upload_precheck_results(project_json_path: Path):
3573
3635
  show_default=True,
3574
3636
  help='With --remote --poll: max seconds to wait (0 = no limit). Ignored without --poll.',
3575
3637
  )
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
- """
3638
+ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run, remote, poll, git_ref, wait_timeout):
3639
+ if list_checks:
3640
+ _print_precheck_checks()
3641
+ return
3595
3642
  cwd_root, _ = get_project_json_from_cwd()
3596
3643
  if not project_root and cwd_root:
3597
3644
  project_root = cwd_root
@@ -3659,7 +3706,10 @@ def precheck(project_root, skip_checks, magic_drc, checks, dry_run, remote, poll
3659
3706
  api_url = _get_api_url()
3660
3707
  client = httpx_remote.Client(
3661
3708
  base_url=f"{api_url}/api/v1",
3662
- headers={'Authorization': f'Bearer {api_key}'},
3709
+ headers={
3710
+ 'Authorization': f'Bearer {api_key}',
3711
+ 'User-Agent': _cf_user_agent(),
3712
+ },
3663
3713
  timeout=120.0,
3664
3714
  )
3665
3715
  try:
@@ -4174,6 +4224,24 @@ DEFAULT_API_URL = 'https://api.chipfoundry.io'
4174
4224
  PORTAL_BASE_URL = 'https://platform.chipfoundry.io'
4175
4225
 
4176
4226
 
4227
+ def _cf_user_agent() -> str:
4228
+ """User-Agent string for platform requests.
4229
+
4230
+ Format: ``chipfoundry-cli/<cli-version> python/<py-version> <platform>``.
4231
+ Lets the backend track which CLI versions are in the wild without a
4232
+ dedicated telemetry endpoint.
4233
+ """
4234
+ import platform as _platform
4235
+
4236
+ try:
4237
+ cli_version = importlib.metadata.version("chipfoundry-cli")
4238
+ except importlib.metadata.PackageNotFoundError:
4239
+ cli_version = "unknown"
4240
+ py = _platform.python_version()
4241
+ system = f"{_platform.system().lower()}-{_platform.machine().lower()}"
4242
+ return f"chipfoundry-cli/{cli_version} python/{py} {system}"
4243
+
4244
+
4177
4245
  def _get_api_url() -> str:
4178
4246
  config = load_user_config()
4179
4247
  return config.get('api_url', DEFAULT_API_URL)
@@ -4199,7 +4267,10 @@ def _api_client():
4199
4267
  api_url = _get_api_url()
4200
4268
  client = httpx.Client(
4201
4269
  base_url=f"{api_url}/api/v1",
4202
- headers={'Authorization': f'Bearer {api_key}'},
4270
+ headers={
4271
+ 'Authorization': f'Bearer {api_key}',
4272
+ 'User-Agent': _cf_user_agent(),
4273
+ },
4203
4274
  timeout=15,
4204
4275
  )
4205
4276
  return client, api_url
@@ -4435,7 +4506,11 @@ def login_cmd(test):
4435
4506
  console.print(f"Opening browser to authenticate with [bold]{api_url}[/bold]...\n")
4436
4507
 
4437
4508
  try:
4438
- resp = httpx.post(f"{api_url}/api/v1/auth/cli/sessions", timeout=10)
4509
+ resp = httpx.post(
4510
+ f"{api_url}/api/v1/auth/cli/sessions",
4511
+ headers={'User-Agent': _cf_user_agent()},
4512
+ timeout=10,
4513
+ )
4439
4514
  resp.raise_for_status()
4440
4515
  data = resp.json()
4441
4516
  except httpx.HTTPError as e:
@@ -4457,7 +4532,11 @@ def login_cmd(test):
4457
4532
  for _ in range(max_polls):
4458
4533
  time.sleep(poll_interval)
4459
4534
  try:
4460
- poll_resp = httpx.get(poll_url, timeout=10)
4535
+ poll_resp = httpx.get(
4536
+ poll_url,
4537
+ headers={'User-Agent': _cf_user_agent()},
4538
+ timeout=10,
4539
+ )
4461
4540
  poll_resp.raise_for_status()
4462
4541
  poll_data = poll_resp.json()
4463
4542
  except httpx.HTTPError:
@@ -4528,7 +4607,10 @@ def whoami_cmd():
4528
4607
  try:
4529
4608
  resp = httpx.get(
4530
4609
  f"{api_url}/api/v1/auth/cli/whoami",
4531
- headers={'Authorization': f'Bearer {api_key}'},
4610
+ headers={
4611
+ 'Authorization': f'Bearer {api_key}',
4612
+ 'User-Agent': _cf_user_agent(),
4613
+ },
4532
4614
  timeout=10,
4533
4615
  )
4534
4616
  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.0"
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"