chipfoundry-cli 2.5.3__tar.gz → 2.6.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.5.3
3
+ Version: 2.6.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
@@ -1,2 +1,2 @@
1
1
  """ChipFoundry CLI package: Automate project submission to SFTP."""
2
- __version__ = "2.5.3"
2
+ __version__ = "2.6.0"
@@ -0,0 +1,294 @@
1
+ """Shared LibreLane invocation helpers for `cf harden` and `cf preview`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Dict, List, Optional, Sequence, Tuple
13
+
14
+ CONFIG_EXTS = ("json", "yaml", "yml", "tcl")
15
+ OPENLANE_DIR_NAMES = ("openlane", "librelane")
16
+ SYNTH_TO_STEP = "Yosys.Synthesis"
17
+
18
+
19
+ def find_flow_root(project_root: Path) -> Optional[Path]:
20
+ """Return the first existing openlane/ or librelane/ directory."""
21
+ for name in OPENLANE_DIR_NAMES:
22
+ candidate = project_root / name
23
+ if candidate.is_dir():
24
+ return candidate
25
+ return None
26
+
27
+
28
+ def find_config_file(macro_dir: Path) -> Optional[Path]:
29
+ for ext in CONFIG_EXTS:
30
+ path = macro_dir / f"config.{ext}"
31
+ if path.is_file():
32
+ return path
33
+ return None
34
+
35
+
36
+ def list_macro_configs(project_root: Path) -> List[Dict[str, str]]:
37
+ """Discover LibreLane macros as ``{name, config_path, flow_root}``."""
38
+ found: List[Dict[str, str]] = []
39
+ seen = set()
40
+ for dir_name in OPENLANE_DIR_NAMES:
41
+ flow_root = project_root / dir_name
42
+ if not flow_root.is_dir():
43
+ continue
44
+ for child in sorted(flow_root.iterdir()):
45
+ if not child.is_dir() or child.name.startswith("."):
46
+ continue
47
+ config = find_config_file(child)
48
+ if config is None:
49
+ continue
50
+ rel = str(config.relative_to(project_root))
51
+ if child.name in seen:
52
+ continue
53
+ seen.add(child.name)
54
+ found.append(
55
+ {
56
+ "name": child.name,
57
+ "config_path": rel,
58
+ "flow_root": dir_name,
59
+ }
60
+ )
61
+ return found
62
+
63
+
64
+ def detect_pdk(project_root: Path, pdk_override: Optional[str] = None) -> str:
65
+ if pdk_override:
66
+ return pdk_override
67
+ project_json = project_root / ".cf" / "project.json"
68
+ if project_json.is_file():
69
+ data = json.loads(project_json.read_text())
70
+ pdk = data.get("pdk")
71
+ if isinstance(pdk, str) and pdk.strip():
72
+ return pdk.strip()
73
+ return "sky130A"
74
+
75
+
76
+ def pdk_root_dir(project_root: Path) -> Path:
77
+ return project_root / "dependencies" / "pdks"
78
+
79
+
80
+ def librelane_venv(flow_root: Path) -> Path:
81
+ return flow_root / ".venv"
82
+
83
+
84
+ def resolve_execution_backend(
85
+ *,
86
+ openlane_version: str,
87
+ force_nix: bool = False,
88
+ force_docker: bool = False,
89
+ ) -> Tuple[str, Optional[str]]:
90
+ """Return ``(\"nix\"|\"docker\", error)``."""
91
+ if force_nix and force_docker:
92
+ return "", "Cannot use both --use-nix and --use-docker"
93
+
94
+ use_nix = False
95
+ if force_nix or not force_docker:
96
+ if shutil.which("nix") is not None:
97
+ try:
98
+ result = subprocess.run(
99
+ [
100
+ "nix",
101
+ "flake",
102
+ "metadata",
103
+ f"github:chipfoundry/openlane-2/{openlane_version}",
104
+ "--json",
105
+ ],
106
+ capture_output=True,
107
+ timeout=5,
108
+ )
109
+ use_nix = result.returncode == 0
110
+ except (OSError, subprocess.TimeoutExpired):
111
+ use_nix = False
112
+ if force_nix and not use_nix:
113
+ return "", "Nix not available or cannot access LibreLane flake"
114
+
115
+ if not use_nix:
116
+ use_docker = False
117
+ try:
118
+ result = subprocess.run(
119
+ ["docker", "info"],
120
+ capture_output=True,
121
+ timeout=5,
122
+ )
123
+ use_docker = result.returncode == 0
124
+ except (OSError, subprocess.TimeoutExpired):
125
+ use_docker = False
126
+ if force_docker and not use_docker:
127
+ return "", "Docker not available"
128
+ if use_docker:
129
+ return "docker", None
130
+ if not force_nix:
131
+ return "", "Neither Nix nor Docker is available"
132
+
133
+ return "nix", None
134
+
135
+
136
+ def build_librelane_command(
137
+ *,
138
+ project_root: Path,
139
+ flow_root: Path,
140
+ config_file: Path,
141
+ pdk: str,
142
+ pdk_root: Path,
143
+ tag: str,
144
+ openlane_version: str,
145
+ backend: str,
146
+ from_step: Optional[str] = None,
147
+ to_step: Optional[str] = None,
148
+ overwrite: bool = True,
149
+ ) -> Tuple[List[str], Dict[str, str]]:
150
+ """Build a Nix or Docker LibreLane command. Does not include GUI flows."""
151
+ env = os.environ.copy()
152
+ env.update(
153
+ {
154
+ "PROJECT_ROOT": str(project_root),
155
+ "PDK_ROOT": str(pdk_root),
156
+ "PDK": pdk,
157
+ "LIBRELANE_RUN_TAG": tag,
158
+ }
159
+ )
160
+
161
+ if backend == "nix":
162
+ cmd = [
163
+ "nix",
164
+ "run",
165
+ f"github:chipfoundry/openlane-2/{openlane_version}",
166
+ "--",
167
+ "--manual-pdk",
168
+ "--pdk-root",
169
+ str(pdk_root),
170
+ "--pdk",
171
+ pdk,
172
+ "--ef-save-views-to",
173
+ str(project_root),
174
+ "--run-tag",
175
+ tag,
176
+ ]
177
+ else:
178
+ venv_bin = librelane_venv(flow_root) / "bin"
179
+ env["PATH"] = f"{venv_bin}:{env.get('PATH', '')}"
180
+ py_major = sys.version_info.major
181
+ py_minor = sys.version_info.minor
182
+ env["PYTHONPATH"] = str(
183
+ librelane_venv(flow_root)
184
+ / "lib"
185
+ / f"python{py_major}.{py_minor}"
186
+ / "site-packages"
187
+ )
188
+ cmd = [
189
+ str(venv_bin / "python3"),
190
+ "-m",
191
+ "librelane",
192
+ "-m",
193
+ str(project_root),
194
+ "-m",
195
+ str(pdk_root),
196
+ ]
197
+ try:
198
+ if not sys.stdin.isatty():
199
+ cmd.append("--docker-no-tty")
200
+ except Exception:
201
+ cmd.append("--docker-no-tty")
202
+ cmd.append("--dockerized")
203
+ cmd.extend(
204
+ [
205
+ "--manual-pdk",
206
+ "--pdk-root",
207
+ str(pdk_root),
208
+ "--pdk",
209
+ pdk,
210
+ "--ef-save-views-to",
211
+ str(project_root),
212
+ "--run-tag",
213
+ tag,
214
+ ]
215
+ )
216
+
217
+ if overwrite and not from_step:
218
+ cmd.append("--overwrite")
219
+ if from_step:
220
+ cmd.extend(["--from", from_step])
221
+ if to_step:
222
+ cmd.extend(["--to", to_step])
223
+ cmd.append(str(config_file))
224
+ return cmd, env
225
+
226
+
227
+ def new_run_tag() -> str:
228
+ return datetime.now().strftime("%y_%m_%d_%H_%M")
229
+
230
+
231
+ def find_synth_stat_json(flow_root: Path, macro: str, run_tag: str) -> Optional[Path]:
232
+ """Locate Yosys stat.json written by a synth-only or full LibreLane run."""
233
+ run_dir = flow_root / macro / "runs" / run_tag
234
+ candidates = [
235
+ run_dir / "Yosys.Synthesis" / "reports" / "stat.json",
236
+ run_dir / "05-yosys-synthesis" / "reports" / "stat.json",
237
+ run_dir / "final" / "metrics.json",
238
+ ]
239
+ if run_dir.is_dir():
240
+ for path in sorted(run_dir.rglob("stat.json")):
241
+ candidates.append(path)
242
+ for path in sorted(run_dir.rglob("metrics.json")):
243
+ candidates.append(path)
244
+ for path in candidates:
245
+ if path.is_file():
246
+ return path
247
+ return None
248
+
249
+
250
+ def parse_synth_metrics(stat_path: Path) -> Dict[str, object]:
251
+ """Parse Yosys stat.json or LibreLane metrics.json into gate/area fields.
252
+
253
+ Raises ValueError if the file cannot be parsed or has no cell count.
254
+ """
255
+ raw = json.loads(stat_path.read_text())
256
+ if not isinstance(raw, dict):
257
+ raise ValueError(f"Synth metrics at {stat_path} must be a JSON object")
258
+
259
+ gate_count: Optional[int] = None
260
+ area_um2: Optional[float] = None
261
+
262
+ design = raw.get("design")
263
+ if isinstance(design, dict):
264
+ cells = design.get("num_cells")
265
+ if cells is not None:
266
+ gate_count = int(cells)
267
+ area = design.get("area")
268
+ if area not in (None, 0, 0.0, "0"):
269
+ area_um2 = float(area)
270
+
271
+ if gate_count is None and "design__instance__count" in raw:
272
+ gate_count = int(float(raw["design__instance__count"]))
273
+ if area_um2 is None and raw.get("design__instance__area") not in (None, "", 0, 0.0):
274
+ area_um2 = float(raw["design__instance__area"])
275
+
276
+ if gate_count is None:
277
+ raise ValueError(f"No cell count in synth metrics at {stat_path}")
278
+
279
+ area_mm2 = (area_um2 / 1_000_000.0) if area_um2 is not None else None
280
+ return {
281
+ "gate_count": gate_count,
282
+ "estimated_area_mm2": area_mm2,
283
+ "metrics_path": str(stat_path),
284
+ }
285
+
286
+
287
+ def run_librelane(
288
+ cmd: Sequence[str],
289
+ *,
290
+ cwd: Path,
291
+ env: Dict[str, str],
292
+ ) -> int:
293
+ process = subprocess.Popen(list(cmd), cwd=str(cwd), env=env)
294
+ return process.wait()
@@ -1735,6 +1735,39 @@ def _sha256_file(path: Path) -> str:
1735
1735
  return h.hexdigest()
1736
1736
 
1737
1737
 
1738
+ def _ordered_sftp_uploads(upload_map: dict) -> list:
1739
+ """Return payload files first and project.json last as the commit marker."""
1740
+ config_path = upload_map.get(".cf/project.json")
1741
+ if not config_path:
1742
+ raise ValueError("SFTP push requires .cf/project.json")
1743
+ payloads = [
1744
+ (rel_path, local_path)
1745
+ for rel_path, local_path in upload_map.items()
1746
+ if rel_path != ".cf/project.json" and local_path
1747
+ ]
1748
+ payloads.append((".cf/project.json", config_path))
1749
+ return payloads
1750
+
1751
+
1752
+ def _prepared_wrapper_hash(project_json_path: str) -> str:
1753
+ with open(project_json_path, "r") as f:
1754
+ data = json.load(f)
1755
+ value = str((data.get("project") or {}).get("user_project_wrapper_hash") or "").strip()
1756
+ if not value:
1757
+ raise ValueError("project.json is missing project.user_project_wrapper_hash")
1758
+ return value
1759
+
1760
+
1761
+ def _assert_wrapper_hash_unchanged(wrapper_path: str, expected_hash: str) -> None:
1762
+ current_hash = _sha256_file(Path(wrapper_path))
1763
+ if current_hash != expected_hash:
1764
+ raise RuntimeError(
1765
+ "Wrapper GDS changed while it was being uploaded. "
1766
+ "project.json was not uploaded; stop any process modifying the GDS "
1767
+ "and run cf push --force-overwrite again."
1768
+ )
1769
+
1770
+
1738
1771
  def _collect_push_candidates(project_root: Path) -> List[Tuple[str, Path, int, str]]:
1739
1772
  """Return [(rel_path, abs_path, size, kind)] for files the platform
1740
1773
  accepts via --https.
@@ -2111,17 +2144,22 @@ def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_n
2111
2144
  cf_dir = ensure_cf_directory(project_root)
2112
2145
 
2113
2146
  # Find the GDS file path for hash calculation
2114
- gds_path = None
2115
- for gds_key, gds_path in collected.items():
2147
+ wrapper_path = None
2148
+ for gds_key, local_path in collected.items():
2116
2149
  if gds_key.startswith("gds/"):
2150
+ wrapper_path = local_path
2117
2151
  break
2152
+ if not wrapper_path:
2153
+ console.print("[red]No wrapper GDS found in collected project files.[/red]")
2154
+ raise click.Abort()
2118
2155
 
2119
2156
  project_json_path = update_or_create_project_json(
2120
2157
  cf_dir=str(cf_dir),
2121
- gds_path=gds_path,
2158
+ gds_path=wrapper_path,
2122
2159
  cli_overrides=cli_overrides,
2123
2160
  existing_json_path=collected.get(".cf/project.json")
2124
2161
  )
2162
+ prepared_wrapper_hash = _prepared_wrapper_hash(project_json_path)
2125
2163
 
2126
2164
  # SFTP upload or dry-run
2127
2165
  final_project_name = project_name or (
@@ -2135,16 +2173,14 @@ def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_n
2135
2173
  upload_map["verilog/rtl/user_defines.v"] = collected.get("verilog/rtl/user_defines.v")
2136
2174
 
2137
2175
  # Add the appropriate GDS file based on what was collected
2138
- for gds_key, gds_path in collected.items():
2176
+ for gds_key, local_path in collected.items():
2139
2177
  if gds_key.startswith("gds/"):
2140
- upload_map[gds_key] = gds_path
2178
+ upload_map[gds_key] = local_path
2141
2179
 
2142
2180
  if dry_run:
2143
2181
  console.print("[bold]Files to upload:[/bold]")
2144
- for rel_path, local_path in upload_map.items():
2145
- if local_path:
2146
- remote_path = os.path.join(sftp_base, rel_path)
2147
- console.print(f" {os.path.basename(local_path)} → {rel_path}")
2182
+ for rel_path, local_path in _ordered_sftp_uploads(upload_map):
2183
+ console.print(f" {os.path.basename(local_path)} → {rel_path}")
2148
2184
  return
2149
2185
 
2150
2186
  console.print(f"Connecting to {sftp_host}...")
@@ -2163,15 +2199,16 @@ def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_n
2163
2199
  raise click.Abort()
2164
2200
 
2165
2201
  try:
2166
- for rel_path, local_path in upload_map.items():
2167
- if local_path:
2168
- remote_path = os.path.join(sftp_base, rel_path)
2169
- upload_with_progress(
2170
- sftp,
2171
- local_path=local_path,
2172
- remote_path=remote_path,
2173
- force_overwrite=force_overwrite
2174
- )
2202
+ for rel_path, local_path in _ordered_sftp_uploads(upload_map):
2203
+ if rel_path == ".cf/project.json":
2204
+ _assert_wrapper_hash_unchanged(wrapper_path, prepared_wrapper_hash)
2205
+ remote_path = os.path.join(sftp_base, rel_path)
2206
+ upload_with_progress(
2207
+ sftp,
2208
+ local_path=local_path,
2209
+ remote_path=remote_path,
2210
+ force_overwrite=force_overwrite
2211
+ )
2175
2212
  console.print(f"[green]✓ Uploaded to {sftp_base}[/green]")
2176
2213
 
2177
2214
  except Exception as e:
@@ -3764,8 +3801,12 @@ def _queue_and_maybe_poll_remote_job(
3764
3801
  poll: bool,
3765
3802
  wait_timeout: int,
3766
3803
  label: str,
3767
- ) -> None:
3768
- """POST a remote platform job and optionally poll until terminal status."""
3804
+ ):
3805
+ """POST a remote platform job and optionally poll until terminal status.
3806
+
3807
+ Returns the create-response job dict, or the last GET payload when polling
3808
+ completes. Returns None for dry-run.
3809
+ """
3769
3810
  import time
3770
3811
  from urllib.parse import urlencode
3771
3812
 
@@ -3773,7 +3814,7 @@ def _queue_and_maybe_poll_remote_job(
3773
3814
 
3774
3815
  if dry_run:
3775
3816
  console.print(f"[cyan]Would POST[/cyan] {create_path}?" + urlencode(params))
3776
- return
3817
+ return None
3777
3818
  if poll and wait_timeout < 0:
3778
3819
  console.print(
3779
3820
  "[red]✗[/red] --wait-timeout must be >= 0 (0 means no limit while polling)."
@@ -3821,13 +3862,13 @@ def _queue_and_maybe_poll_remote_job(
3821
3862
  console.print(f"[green]✓[/green] {label} completed")
3822
3863
  if job.get("github_pr_url"):
3823
3864
  console.print(f" Pull request: {job['github_pr_url']}")
3824
- return
3865
+ return job
3825
3866
  if not poll:
3826
3867
  console.print(
3827
3868
  f"[dim]Not waiting: use [bold]--remote --poll[/bold] to stream progress "
3828
3869
  f"([bold]--wait-timeout 0[/bold] = no time limit while polling).[/dim]"
3829
3870
  )
3830
- return
3871
+ return job
3831
3872
  deadline = None if wait_timeout == 0 else time.monotonic() + wait_timeout
3832
3873
  if wait_timeout == 0:
3833
3874
  console.print("[dim]Polling until the job completes (no timeout).[/dim]")
@@ -3891,6 +3932,7 @@ def _queue_and_maybe_poll_remote_job(
3891
3932
  console.print(f"[green]✓[/green] {label} completed")
3892
3933
  if github_pr_url:
3893
3934
  console.print(f" Pull request: {github_pr_url}")
3935
+ return j2
3894
3936
  elif terminal == "failed":
3895
3937
  console.print(f"[red]✗[/red] {label} failed: {fail_message}")
3896
3938
  raise SystemExit(1)
@@ -5745,5 +5787,10 @@ def whoami_cmd():
5745
5787
  raise SystemExit(1)
5746
5788
 
5747
5789
 
5790
+ from chipfoundry_cli.preview import register_preview
5791
+
5792
+ register_preview(main)
5793
+
5794
+
5748
5795
  if __name__ == "__main__":
5749
5796
  main()