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.
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.0}/PKG-INFO +1 -1
- chipfoundry_cli-2.4.0/chipfoundry_cli/check_refs.py +42 -0
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.0}/chipfoundry_cli/main.py +110 -28
- chipfoundry_cli-2.4.0/chipfoundry_cli/version_check.py +204 -0
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.0}/pyproject.toml +1 -1
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.0}/LICENSE +0 -0
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.0}/README.md +0 -0
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.0}/chipfoundry_cli/__init__.py +0 -0
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.0}/chipfoundry_cli/remote_precheck_git.py +0 -0
- {chipfoundry_cli-2.3.19 → chipfoundry_cli-2.4.0}/chipfoundry_cli/utils.py +0 -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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
"""
|
|
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={
|
|
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={
|
|
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(
|
|
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(
|
|
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={
|
|
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
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|