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.
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.1}/PKG-INFO +1 -1
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.1}/chipfoundry_cli/__init__.py +1 -1
- chipfoundry_cli-2.4.1/chipfoundry_cli/check_refs.py +42 -0
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.1}/chipfoundry_cli/main.py +158 -46
- chipfoundry_cli-2.4.1/chipfoundry_cli/version_check.py +204 -0
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.1}/pyproject.toml +1 -1
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.1}/LICENSE +0 -0
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.1}/README.md +0 -0
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.1}/chipfoundry_cli/remote_precheck_git.py +0 -0
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.1}/chipfoundry_cli/utils.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""ChipFoundry CLI package: Automate project submission to SFTP."""
|
|
2
|
-
__version__ = "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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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'] =
|
|
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(
|
|
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(
|
|
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
|
-
|
|
2083
|
-
|
|
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(
|
|
2086
|
-
|
|
2087
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
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={
|
|
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={
|
|
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(
|
|
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(
|
|
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={
|
|
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
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|