fabricgate 0.1.0__py3-none-any.whl

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.
Files changed (68) hide show
  1. fabricgate/_version.py +4 -0
  2. fabricgate/cli/__init__.py +1 -0
  3. fabricgate/cli/commands/__init__.py +1 -0
  4. fabricgate/cli/commands/build.py +359 -0
  5. fabricgate/cli/commands/deprecate.py +77 -0
  6. fabricgate/cli/commands/diff.py +123 -0
  7. fabricgate/cli/commands/info.py +42 -0
  8. fabricgate/cli/commands/license_check.py +76 -0
  9. fabricgate/cli/commands/list_cmd.py +44 -0
  10. fabricgate/cli/commands/login.py +41 -0
  11. fabricgate/cli/commands/pull.py +80 -0
  12. fabricgate/cli/commands/push.py +162 -0
  13. fabricgate/cli/commands/quota.py +66 -0
  14. fabricgate/cli/commands/search.py +58 -0
  15. fabricgate/cli/commands/stats.py +95 -0
  16. fabricgate/cli/commands/token.py +106 -0
  17. fabricgate/cli/commands/verify.py +38 -0
  18. fabricgate/cli/commands/watch.py +58 -0
  19. fabricgate/cli/commands/webhook.py +166 -0
  20. fabricgate/cli/commands/yank.py +49 -0
  21. fabricgate/cli/exit_codes.py +28 -0
  22. fabricgate/cli/main.py +83 -0
  23. fabricgate/client/__init__.py +44 -0
  24. fabricgate/client/auth.py +108 -0
  25. fabricgate/client/cache.py +97 -0
  26. fabricgate/client/config.py +52 -0
  27. fabricgate/client/errors.py +27 -0
  28. fabricgate/client/publisher.py +147 -0
  29. fabricgate/client/puller.py +382 -0
  30. fabricgate/client/registry_client.py +649 -0
  31. fabricgate/client/resolver.py +93 -0
  32. fabricgate/client/validator.py +30 -0
  33. fabricgate/data/__init__.py +0 -0
  34. fabricgate/data/board-db/.gitkeep +0 -0
  35. fabricgate/data/board-db/official.yaml +79 -0
  36. fabricgate/models/__init__.py +10 -0
  37. fabricgate/models/api/__init__.py +1 -0
  38. fabricgate/models/api/errors.py +74 -0
  39. fabricgate/models/api/members.py +49 -0
  40. fabricgate/models/api/requests.py +211 -0
  41. fabricgate/models/api/responses.py +672 -0
  42. fabricgate/models/board_db.py +210 -0
  43. fabricgate/models/cli.py +312 -0
  44. fabricgate/models/common.py +390 -0
  45. fabricgate/models/design_index.py +110 -0
  46. fabricgate/models/platform_manifest.py +394 -0
  47. fabricgate/py.typed +1 -0
  48. fabricgate/sdk/__init__.py +5 -0
  49. fabricgate/sdk/_helpers.py +218 -0
  50. fabricgate/sdk/api.py +72 -0
  51. fabricgate/sdk/auth.py +231 -0
  52. fabricgate/sdk/designs/__init__.py +35 -0
  53. fabricgate/sdk/designs/diff.py +25 -0
  54. fabricgate/sdk/designs/license.py +69 -0
  55. fabricgate/sdk/designs/lifecycle.py +198 -0
  56. fabricgate/sdk/designs/pull.py +142 -0
  57. fabricgate/sdk/designs/push.py +163 -0
  58. fabricgate/sdk/designs/quota.py +41 -0
  59. fabricgate/sdk/designs/search.py +69 -0
  60. fabricgate/sdk/designs/stats.py +105 -0
  61. fabricgate/sdk/designs/watch.py +101 -0
  62. fabricgate/sdk/hwh_parser.py +152 -0
  63. fabricgate/sdk/webhooks.py +211 -0
  64. fabricgate-0.1.0.dist-info/METADATA +122 -0
  65. fabricgate-0.1.0.dist-info/RECORD +68 -0
  66. fabricgate-0.1.0.dist-info/WHEEL +4 -0
  67. fabricgate-0.1.0.dist-info/entry_points.txt +3 -0
  68. fabricgate-0.1.0.dist-info/licenses/LICENSE +21 -0
fabricgate/_version.py ADDED
@@ -0,0 +1,4 @@
1
+ """Package version. `fabricgate` is a PEP 420 namespace package (no __init__.py)
2
+ so that the registry distribution can add `fabricgate.registry` on top."""
3
+
4
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """FabricGate CLI (Click)."""
@@ -0,0 +1 @@
1
+ """CLI command implementations."""
@@ -0,0 +1,359 @@
1
+ """fabricgate build command — generate Platform Manifest and Design Index from a HWH file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+ import yaml
10
+
11
+ from fabricgate.models.board_db import BoardDb
12
+ from fabricgate.models.platform_manifest import Interface
13
+ from fabricgate.sdk.hwh_parser import HwhInfo, HwhParseError, ParsedInterface, find_hwh_files, parse_hwh
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Board resolution helpers
17
+ # ---------------------------------------------------------------------------
18
+
19
+
20
+ def _stdin_is_tty() -> bool:
21
+ """Whether interactive prompts are possible (patched in tests)."""
22
+ return sys.stdin.isatty()
23
+
24
+
25
+ def _resolve_board_id(
26
+ hwh_info: HwhInfo,
27
+ db: BoardDb,
28
+ ) -> tuple[str, str | None]:
29
+ """Return ``(board_id, device_family)`` from *hwh_info* and *db*.
30
+
31
+ Resolution order:
32
+ 1. BOARDPART lookup in Board DB.
33
+ 2. Virtual board based on *design_portability*.
34
+ """
35
+ if hwh_info.boardpart:
36
+ entry = db.lookup_by_boardpart(hwh_info.boardpart)
37
+ if entry:
38
+ return entry.board_id, entry.device_family
39
+
40
+ portability = hwh_info.design_portability
41
+ df = hwh_info.device_family
42
+ pkg = hwh_info.package
43
+
44
+ if portability == "board-specific" and df and pkg:
45
+ return f"custom-{df}-{pkg}", df
46
+ if portability == "board-specific" and df:
47
+ return f"custom-{df}", df
48
+ # axi-only or io-constrained
49
+ if df:
50
+ return f"generic-{df}", df
51
+ return "generic-unknown", None
52
+
53
+
54
+ def _interfaces_to_manifest(parsed: list[ParsedInterface]) -> list[Interface]:
55
+ """Convert parsed HWH interfaces to manifest :class:`Interface` objects."""
56
+ result: list[Interface] = []
57
+ for p in parsed:
58
+ iface_type = "axi-lite"
59
+ if p.protocol:
60
+ proto = p.protocol.lower()
61
+ if "stream" in proto:
62
+ iface_type = "axi-stream"
63
+ elif "lite" in proto:
64
+ iface_type = "axi-lite"
65
+ elif "axi4" in proto:
66
+ iface_type = "axi"
67
+ elif "stream" in p.vlnv.lower() or "axis_rtl" in p.vlnv.lower():
68
+ iface_type = "axi-stream"
69
+ result.append(
70
+ Interface(
71
+ name=p.name,
72
+ type=iface_type,
73
+ base=p.base_address,
74
+ )
75
+ )
76
+ return result
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # YAML generation
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ def _make_manifest_stub(
85
+ *,
86
+ runtime: str,
87
+ board: str,
88
+ device_family: str | None,
89
+ design_ref: str,
90
+ interfaces: list[Interface],
91
+ design_portability: str,
92
+ ) -> str:
93
+ """Return a YAML string for a platform manifest stub.
94
+
95
+ Artifact hashes are placeholder values; the user must replace them
96
+ before running ``fabricgate push``.
97
+ """
98
+ placeholder = "a" * 64
99
+ data: dict[str, object] = {
100
+ "schema": "fabricgate-platform/v1",
101
+ "runtime": runtime,
102
+ "board": board,
103
+ "design_ref": design_ref,
104
+ "artifacts": {
105
+ "bitstream": {"file": "design.bit", "sha256": placeholder},
106
+ "hwh": {"file": "design.hwh", "sha256": placeholder},
107
+ },
108
+ }
109
+ if device_family:
110
+ data["device_family"] = device_family
111
+ data["design_portability"] = design_portability
112
+ if interfaces:
113
+ data["interfaces"] = [
114
+ {k: v for k, v in {"name": i.name, "type": i.type, "base": i.base}.items() if v is not None}
115
+ for i in interfaces
116
+ ]
117
+ return yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
118
+
119
+
120
+ def _make_index_stub(
121
+ *,
122
+ design_name: str,
123
+ version: str,
124
+ summary: str | None,
125
+ tags: list[str],
126
+ platform_id: str,
127
+ ) -> str:
128
+ """Return a YAML string for a design index stub."""
129
+ placeholder = "sha256:" + "a" * 64
130
+ data: dict[str, object] = {
131
+ "schema": "fabricgate-index/v1",
132
+ "name": design_name,
133
+ "version": version,
134
+ "platforms": [{"platform": platform_id, "digest": placeholder}],
135
+ }
136
+ if summary:
137
+ data["summary"] = summary
138
+ if tags:
139
+ data["tags"] = tags
140
+ return yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # CLI command
145
+ # ---------------------------------------------------------------------------
146
+
147
+
148
+ @click.command(name="build")
149
+ @click.argument("directory", type=click.Path(exists=True, file_okay=False, path_type=Path))
150
+ @click.option("--namespace", default=None, help="Registry namespace (interactive if omitted).")
151
+ @click.option("--version", "semver", default=None, help="SemVer version (interactive if omitted).")
152
+ @click.option("--tags", default=None, help="Comma-separated tags (e.g. dma,axi,zynq).")
153
+ @click.option("--dry-run", is_flag=True, help="Preview output without writing files.")
154
+ @click.option(
155
+ "--output",
156
+ "output_dir",
157
+ default=None,
158
+ type=click.Path(file_okay=False, path_type=Path),
159
+ help="Output directory (default: <design_slug>/ in current directory).",
160
+ )
161
+ @click.option(
162
+ "--hwh",
163
+ "hwh_path_opt",
164
+ default=None,
165
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
166
+ help="Explicit path to .hwh file (required when multiple .hwh files are found).",
167
+ )
168
+ def build_cmd(
169
+ directory: Path,
170
+ namespace: str | None,
171
+ semver: str | None,
172
+ tags: str | None,
173
+ dry_run: bool,
174
+ output_dir: Path | None,
175
+ hwh_path_opt: Path | None,
176
+ ) -> None:
177
+ """Generate Platform Manifest and Design Index from a Vivado HWH file."""
178
+ # --- Step 1: Find HWH files ---
179
+ click.echo("Scanning for HWH files ...")
180
+ hwh_files = find_hwh_files(directory)
181
+ if not hwh_files:
182
+ click.echo(f"Error: No .hwh files found in {directory}", err=True)
183
+ raise click.exceptions.Exit(1)
184
+ for f in hwh_files:
185
+ click.echo(f" Found: {f.relative_to(directory.parent)}")
186
+
187
+ if hwh_path_opt is not None:
188
+ hwh_path = hwh_path_opt
189
+ elif len(hwh_files) > 1:
190
+ if not _stdin_is_tty():
191
+ # Non-interactive: error — user must specify via --hwh
192
+ names = ", ".join(f.name for f in hwh_files)
193
+ click.echo(
194
+ f"\nError: Multiple HWH files found ({names}). Use --hwh to specify one.",
195
+ err=True,
196
+ )
197
+ raise click.exceptions.Exit(1)
198
+ else:
199
+ click.echo("\nMultiple HWH files found. Select one:")
200
+ for i, f in enumerate(hwh_files):
201
+ click.echo(f" [{i + 1}] {f.relative_to(directory.parent)}")
202
+ choice = click.prompt(
203
+ "Select HWH file",
204
+ type=click.IntRange(1, len(hwh_files)),
205
+ default=1,
206
+ )
207
+ hwh_path = hwh_files[choice - 1]
208
+ else:
209
+ hwh_path = hwh_files[0]
210
+
211
+ # --- Step 2: Parse HWH ---
212
+ click.echo("\nParsing HWH ...")
213
+ try:
214
+ hwh_info = parse_hwh(hwh_path)
215
+ except HwhParseError as exc:
216
+ click.echo(f"Error: {exc}", err=True)
217
+ raise click.exceptions.Exit(1) from exc
218
+
219
+ if hwh_info.boardpart:
220
+ click.echo(f" BOARDPART: {hwh_info.boardpart}")
221
+ else:
222
+ click.echo(" BOARDPART: (not found)")
223
+ if hwh_info.interfaces:
224
+ axi_count = sum(1 for i in hwh_info.interfaces if i.is_axi)
225
+ bs_count = sum(1 for i in hwh_info.interfaces if i.is_board_specific)
226
+ iface_summary = f"{len(hwh_info.interfaces)} interface(s)"
227
+ if axi_count:
228
+ iface_summary += f", {axi_count} AXI"
229
+ if bs_count:
230
+ bs_names = [i.vlnv.split(":")[-2] for i in hwh_info.interfaces if i.is_board_specific]
231
+ iface_summary += f", board-specific: {', '.join(bs_names)}"
232
+ click.echo(f" EXTERNALINTERFACES: {iface_summary}")
233
+ else:
234
+ click.echo(" EXTERNALINTERFACES: (none)")
235
+
236
+ portability = hwh_info.design_portability
237
+ click.echo(f" → design_portability: {portability}")
238
+
239
+ # --- Step 3: Board DB lookup ---
240
+ try:
241
+ db = BoardDb.load()
242
+ except Exception as exc:
243
+ click.echo(f"Error: Board DB load failed: {exc}", err=True)
244
+ # INFRA: failed to load local Board DB (ADR-008 exit-code standard).
245
+ raise click.exceptions.Exit(4) from exc
246
+
247
+ board_id: str
248
+ device_family: str | None
249
+
250
+ if hwh_info.boardpart:
251
+ entry = db.lookup_by_boardpart(hwh_info.boardpart)
252
+ if entry:
253
+ click.echo(f" → Board DB match: {entry.board_id} ({entry.display_name})")
254
+ board_id = entry.board_id
255
+ device_family = entry.device_family
256
+ else:
257
+ # Not in Board DB — propose virtual board
258
+ click.echo(" → Board DB: not found")
259
+ proposed_board_id, device_family = _resolve_board_id(hwh_info, db)
260
+ if portability == "axi-only":
261
+ click.echo(f" → Suggest: {proposed_board_id}")
262
+ if not click.confirm(
263
+ f"\n Board '{hwh_info.boardpart}' is not in the Board DB.\n"
264
+ f" Treat as {proposed_board_id} (AXI-only, any {device_family} board)?",
265
+ default=True,
266
+ ):
267
+ click.echo("Aborted.", err=True)
268
+ raise click.exceptions.Exit(1)
269
+ else:
270
+ click.echo(f" → Suggest: {proposed_board_id}")
271
+ if not click.confirm(
272
+ f"\n Platform will be registered as: {proposed_board_id}/<runtime>\n Continue?",
273
+ default=True,
274
+ ):
275
+ click.echo("Aborted.", err=True)
276
+ raise click.exceptions.Exit(1)
277
+ board_id = proposed_board_id
278
+ else:
279
+ click.echo(" → No BOARDPART; cannot look up board")
280
+ proposed_board_id, device_family = _resolve_board_id(hwh_info, db)
281
+ board_id = proposed_board_id
282
+
283
+ # --- Step 4: Interactive inputs ---
284
+ click.echo("")
285
+
286
+ ns_prompt = f"{namespace}/" if namespace else ""
287
+ raw_name = click.prompt(f"Design name [{ns_prompt}]", default=f"{ns_prompt}my-design")
288
+ design_name = raw_name.strip()
289
+
290
+ raw_version = click.prompt("Version", default=semver or "0.1.0")
291
+ version = raw_version.strip()
292
+
293
+ runtime = _prompt_runtime()
294
+
295
+ description = click.prompt("Description", default="", show_default=False).strip() or None
296
+
297
+ raw_tags = tags or click.prompt("Tags (comma-separated, optional)", default="", show_default=False)
298
+ tag_list = [t.strip() for t in raw_tags.split(",") if t.strip()] if raw_tags else []
299
+
300
+ # --- Step 5: Derive paths and build YAML ---
301
+ platform_id = f"{board_id}/{runtime}"
302
+ design_slug = design_name.split("/")[-1] if "/" in design_name else design_name
303
+ platform_dir_name = f"{board_id}-{runtime}"
304
+
305
+ design_ref = f"{design_name}:{version}"
306
+ manifest_interfaces = _interfaces_to_manifest(hwh_info.interfaces)
307
+
308
+ manifest_yaml = _make_manifest_stub(
309
+ runtime=runtime,
310
+ board=board_id,
311
+ device_family=device_family,
312
+ design_ref=design_ref,
313
+ interfaces=manifest_interfaces,
314
+ design_portability=portability,
315
+ )
316
+ index_yaml = _make_index_stub(
317
+ design_name=design_name,
318
+ version=version,
319
+ summary=description,
320
+ tags=tag_list,
321
+ platform_id=platform_id,
322
+ )
323
+
324
+ # --- Step 6: Write (or preview) ---
325
+ out_root = output_dir if output_dir is not None else Path(design_slug)
326
+ manifest_path = out_root / platform_dir_name / "manifest.yaml"
327
+ index_path = out_root / "fabricgate-index.yaml"
328
+
329
+ click.echo("\nGenerated:")
330
+ if dry_run:
331
+ click.echo(f" (dry-run) {index_path}")
332
+ click.echo(f" (dry-run) {manifest_path}")
333
+ click.echo("\n--- fabricgate-index.yaml ---")
334
+ click.echo(index_yaml)
335
+ click.echo(f"--- {platform_dir_name}/manifest.yaml ---")
336
+ click.echo(manifest_yaml)
337
+ return
338
+
339
+ try:
340
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
341
+ manifest_path.write_text(manifest_yaml, encoding="utf-8")
342
+ index_path.write_text(index_yaml, encoding="utf-8")
343
+ except OSError as exc:
344
+ click.echo(f"Error: Write failed: {exc}", err=True)
345
+ raise click.exceptions.Exit(4) from exc
346
+
347
+ click.echo(f" {index_path} ✓")
348
+ click.echo(f" {manifest_path} ✓")
349
+ click.echo(f"\nNote: Place bitstream files in {out_root / platform_dir_name}/ before publishing.")
350
+ click.echo(f"Run `fabricgate push {out_root}/` to publish.")
351
+
352
+
353
+ def _prompt_runtime() -> str:
354
+ result: str = click.prompt(
355
+ "Runtime",
356
+ type=click.Choice(["pynq", "linux-fpgamgr", "nanopynq"]),
357
+ default="pynq",
358
+ )
359
+ return result
@@ -0,0 +1,77 @@
1
+ """fabricgate deprecate command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import click
8
+
9
+ from fabricgate.cli.exit_codes import exit_code_for
10
+ from fabricgate.sdk import api as sdk_api
11
+
12
+
13
+ @click.command(name="deprecate")
14
+ @click.option("--undo", is_flag=True, help="Clear deprecated state instead of setting it.")
15
+ @click.option("--message", default="", help="Deprecation message.")
16
+ @click.option("--successor", default=None, help="Suggested successor design ref.")
17
+ @click.argument("design_ref")
18
+ @click.argument("versions", nargs=-1)
19
+ @click.pass_obj
20
+ def deprecate_cmd(
21
+ obj: dict[str, object],
22
+ undo: bool,
23
+ message: str,
24
+ successor: str | None,
25
+ design_ref: str,
26
+ versions: tuple[str, ...],
27
+ ) -> None:
28
+ """Deprecate or undeprecate one or more versions."""
29
+ reg: str = str(obj["registry"])
30
+ version_list = list(versions) if versions else None
31
+
32
+ if undo and message:
33
+ raise click.UsageError("--message cannot be used with --undo.")
34
+ if undo and successor:
35
+ raise click.UsageError("--successor cannot be used with --undo.")
36
+ if not undo and not message.strip():
37
+ raise click.UsageError("--message is required unless --undo is used.")
38
+
39
+ try:
40
+ if undo:
41
+ results = sdk_api.undeprecate(
42
+ design_ref,
43
+ version_list,
44
+ registry=reg,
45
+ )
46
+ else:
47
+ results = sdk_api.deprecate(
48
+ design_ref,
49
+ version_list,
50
+ message=message,
51
+ successor=successor,
52
+ registry=reg,
53
+ )
54
+ except sdk_api.SDKError as exc:
55
+ click.echo(str(exc), err=True)
56
+ raise click.exceptions.Exit(exit_code_for(exc)) from exc
57
+
58
+ if obj["json"]:
59
+ click.echo(json.dumps([r.model_dump(mode="json") for r in results], indent=2))
60
+ return
61
+
62
+ count = len(results)
63
+ action = "Undeprecating" if undo else "Deprecating"
64
+ if count == 1:
65
+ click.echo(f"\n{action} {results[0].name}:{results[0].version} ...")
66
+ else:
67
+ click.echo(f"\n{action} {count} versions of {results[0].name} ...")
68
+
69
+ marker = "undeprecated" if undo else "deprecated"
70
+ for result in results:
71
+ click.echo(f" ✓ {marker} {result.name}:{result.version}")
72
+
73
+ if not undo:
74
+ if message:
75
+ click.echo(f" Message: {message}")
76
+ if successor:
77
+ click.echo(f" Successor: {successor}")
@@ -0,0 +1,123 @@
1
+ """fabricgate diff command — compare metadata between two design versions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import string
7
+ from collections.abc import Iterable
8
+
9
+ import click
10
+
11
+ from fabricgate.cli.exit_codes import exit_code_for
12
+ from fabricgate.models.api.responses import VersionDiffField, VersionDiffResponse
13
+ from fabricgate.sdk import api as sdk_api
14
+
15
+
16
+ def _sanitize_label(value: str) -> str:
17
+ return "".join(ch if ch.isprintable() and ch not in "\r\n\t" else "?" for ch in value)
18
+
19
+
20
+ def _sanitize_labels(values: Iterable[str]) -> list[str]:
21
+ return [_sanitize_label(v) for v in values]
22
+
23
+
24
+ def _format_size(value: int) -> str:
25
+ size = float(value)
26
+ units = ["B", "KB", "MB", "GB", "TB"]
27
+ for unit in units:
28
+ if size < 1024 or unit == units[-1]:
29
+ if unit == "B":
30
+ return f"{int(size)} {unit}"
31
+ if size.is_integer():
32
+ return f"{int(size)} {unit}"
33
+ return f"{size:.1f} {unit}"
34
+ size /= 1024
35
+ return f"{int(value)} B" # pragma: no cover
36
+
37
+
38
+ def _format_value(field_name: str, value: object) -> str:
39
+ if value is None:
40
+ return "null"
41
+ if isinstance(value, bool):
42
+ return "true" if value else "false"
43
+ if isinstance(value, int):
44
+ if field_name.endswith("size"):
45
+ return _format_size(value)
46
+ return str(value)
47
+ if isinstance(value, float):
48
+ return str(value)
49
+ if isinstance(value, str):
50
+ if field_name.endswith("sha256") and all(char in string.hexdigits for char in value):
51
+ return value
52
+ return json.dumps(value, ensure_ascii=False)
53
+ return json.dumps(value, ensure_ascii=False, separators=(", ", ": "))
54
+
55
+
56
+ def _format_field(field: VersionDiffField, width: int) -> str:
57
+ before = _format_value(field.field, field.base)
58
+ after = _format_value(field.field, field.head)
59
+ safe_field = _sanitize_label(field.field)
60
+ return f" {safe_field.ljust(width)} {before} → {after}"
61
+
62
+
63
+ def _format_platforms(platforms: list[str]) -> str:
64
+ return ", ".join(_sanitize_labels(platforms)) if platforms else "(none)"
65
+
66
+
67
+ def _render_text(result: VersionDiffResponse, design_ref: str) -> None:
68
+ design_name = design_ref.split(":", 1)[0]
69
+ click.echo(f"Diff: {design_name} {result.base} → {result.head}")
70
+ click.echo()
71
+ click.echo(f" Platforms added: {_format_platforms(result.summary.platforms_added)}")
72
+ click.echo(f" Platforms removed: {_format_platforms(result.summary.platforms_removed)}")
73
+ click.echo(f" Platforms changed: {_format_platforms(result.summary.platforms_changed)}")
74
+
75
+ if not result.diff:
76
+ click.echo()
77
+ click.echo("No differences found.")
78
+ return
79
+
80
+ for entry in result.diff:
81
+ click.echo()
82
+ click.echo(f"{_sanitize_label(entry.platform)}:")
83
+ if entry.fields == "added":
84
+ click.echo(f" (added in {result.head})")
85
+ continue
86
+ if entry.fields == "removed":
87
+ click.echo(f" (removed from {result.base})")
88
+ continue
89
+
90
+ width = max(len(_sanitize_label(field.field)) for field in entry.fields)
91
+ for field in entry.fields:
92
+ click.echo(_format_field(field, width))
93
+
94
+
95
+ @click.command(name="diff")
96
+ @click.argument("design_ref")
97
+ @click.option("--base", "base_ref", required=True, help="Base version or namespace/design:version.")
98
+ @click.option("--platform", default=None, help="Compare only one platform (device/runtime).")
99
+ @click.pass_obj
100
+ def diff_cmd(
101
+ obj: dict[str, object],
102
+ design_ref: str,
103
+ base_ref: str,
104
+ platform: str | None,
105
+ ) -> None:
106
+ """Show metadata differences between two design versions."""
107
+ reg: str = str(obj["registry"])
108
+ try:
109
+ result = sdk_api.diff(
110
+ design_ref,
111
+ base=base_ref,
112
+ platform=platform,
113
+ registry=reg,
114
+ )
115
+ except sdk_api.SDKError as exc:
116
+ click.echo(str(exc), err=True)
117
+ raise click.exceptions.Exit(exit_code_for(exc)) from exc
118
+
119
+ if obj["json"]:
120
+ click.echo(json.dumps(result.model_dump(mode="json"), indent=2))
121
+ return
122
+
123
+ _render_text(result, design_ref)
@@ -0,0 +1,42 @@
1
+ """fabricgate info command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import click
8
+
9
+ from fabricgate.cli.exit_codes import exit_code_for
10
+ from fabricgate.sdk import api as sdk_api
11
+
12
+
13
+ @click.command(name="info")
14
+ @click.argument("design_ref")
15
+ @click.pass_obj
16
+ def info_cmd(obj: dict[str, object], design_ref: str) -> None:
17
+ """Show detailed metadata for a design reference."""
18
+ try:
19
+ result = sdk_api.info(design_ref, registry=str(obj["registry"]))
20
+ except sdk_api.SDKError as exc:
21
+ click.echo(str(exc), err=True)
22
+ raise click.exceptions.Exit(exit_code_for(exc)) from exc
23
+
24
+ if obj["json"]:
25
+ click.echo(json.dumps(result.model_dump(mode="json"), indent=2))
26
+ return
27
+
28
+ click.echo(f"Name: {result.name}")
29
+ click.echo(f"Version: {result.version}")
30
+ if result.summary:
31
+ click.echo(f"Summary: {result.summary}")
32
+ if result.license:
33
+ click.echo(f"License: {result.license}")
34
+ if result.author:
35
+ click.echo(f"Author: {result.author}")
36
+ if result.repository:
37
+ click.echo(f"Repository: {result.repository}")
38
+ if result.tags:
39
+ click.echo(f"Tags: {', '.join(result.tags)}")
40
+ click.echo("Platforms:")
41
+ for platform in result.platforms:
42
+ click.echo(f" {platform.platform}")
@@ -0,0 +1,76 @@
1
+ """fabricgate license-check command — tool requirements and compatibility check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import click
8
+
9
+ from fabricgate.cli.exit_codes import exit_code_for
10
+ from fabricgate.sdk import api as sdk_api
11
+
12
+
13
+ @click.command(name="license-check")
14
+ @click.argument("design_ref")
15
+ @click.option("--tool", "tools", multiple=True, help="Tool identifier to check (repeatable).")
16
+ @click.option("--edition", default=None, help="Edition to check (e.g. standard, enterprise).")
17
+ @click.pass_obj
18
+ def license_check_cmd(
19
+ obj: dict[str, object],
20
+ design_ref: str,
21
+ tools: tuple[str, ...],
22
+ edition: str | None,
23
+ ) -> None:
24
+ """Check tool requirements for a design version."""
25
+ reg: str = str(obj["registry"])
26
+ tool_list = list(tools) if tools else None
27
+ try:
28
+ result = sdk_api.license_check(
29
+ design_ref,
30
+ tool=tool_list,
31
+ edition=edition,
32
+ registry=reg,
33
+ )
34
+ except sdk_api.SDKError as exc:
35
+ click.echo(str(exc), err=True)
36
+ raise click.exceptions.Exit(exit_code_for(exc)) from exc
37
+
38
+ if obj["json"]:
39
+ click.echo(json.dumps(result.model_dump(mode="json"), indent=2))
40
+ return
41
+
42
+ click.echo(f"Design: {result.design}:{result.version}")
43
+
44
+ if not result.tool_requirements:
45
+ click.echo("\nNo tool requirements defined.")
46
+ click.echo(f"\nDisclaimer: {result.disclaimer}")
47
+ # GENERIC: no requirements to check (ADR-008). Exit 2 is reserved for
48
+ # PERMISSION; a missing-data signal must not collide with it.
49
+ raise click.exceptions.Exit(1)
50
+
51
+ if result.check is not None:
52
+ # Tool/edition check mode
53
+ click.echo(f"\ncheck: {result.check.tool}" + (f" ({result.check.edition})" if result.check.edition else ""))
54
+ symbol = "\u2713" if result.check.meets_requirements else "\u2717"
55
+ for note in result.check.notes:
56
+ click.echo(f" {symbol} {note}")
57
+ status = "MEETS REQUIREMENTS" if result.check.meets_requirements else "DOES NOT MEET REQUIREMENTS"
58
+ click.echo(f"\nResult: {status}")
59
+ else:
60
+ # List mode
61
+ click.echo("\nTool Requirements:")
62
+ for req in result.tool_requirements:
63
+ label = "required" if req.required else "optional"
64
+ click.echo(f" {req.tool} ({label})")
65
+ if req.min_version:
66
+ click.echo(f" Min version: {req.min_version}")
67
+ if req.edition:
68
+ click.echo(f" Edition: {req.edition}")
69
+ if req.note:
70
+ click.echo(f" Note: {req.note}")
71
+ click.echo()
72
+
73
+ click.echo(f"Disclaimer: {result.disclaimer}")
74
+
75
+ if result.check is not None and not result.check.meets_requirements:
76
+ raise click.exceptions.Exit(3)