akbridge 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.
akbridge/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """MCP access to all public AKShare functions."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ PRODUCT_NAME = "AKBridge"
6
+ SERVER_NAME = "akbridge"
akbridge/acceptance.py ADDED
@@ -0,0 +1,679 @@
1
+ """Generate and execute the per-interface AKShare acceptance ledger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import csv
7
+ import datetime as dt
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ import xml.etree.ElementTree as ET
14
+ from concurrent.futures import ThreadPoolExecutor, as_completed
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import akshare
19
+
20
+ from .catalog import ApiFunction, discover_functions
21
+
22
+ UPSTREAM_TRANSPORT_ERRORS = {
23
+ "ConnectionError",
24
+ "HTTPError",
25
+ "SSLError",
26
+ "TimeoutError",
27
+ }
28
+ UPSTREAM_RESPONSE_ERRORS = {
29
+ "APIError",
30
+ "ArrowInvalid",
31
+ "BadZipFile",
32
+ "JSONDecodeError",
33
+ }
34
+
35
+ SVG_NAMESPACE = "http://www.w3.org/2000/svg"
36
+ STATUS_ORDER = (
37
+ "passed",
38
+ "passed_empty",
39
+ "adapter_passed",
40
+ "failed",
41
+ "timeout",
42
+ "fixture_required",
43
+ "worker_failed",
44
+ "adapter_failed",
45
+ )
46
+ STATUS_LABELS = {
47
+ "passed": "成功返回",
48
+ "passed_empty": "正常空结果",
49
+ "adapter_passed": "适配通过",
50
+ "failed": "运行失败",
51
+ "timeout": "超时",
52
+ "fixture_required": "缺少验收参数",
53
+ "worker_failed": "隔离进程失败",
54
+ "adapter_failed": "适配失败",
55
+ }
56
+ STATUS_COLORS = {
57
+ "passed": "#2da44e",
58
+ "passed_empty": "#0969da",
59
+ "adapter_passed": "#2da44e",
60
+ "failed": "#cf222e",
61
+ "timeout": "#bf8700",
62
+ "fixture_required": "#8250df",
63
+ "worker_failed": "#cf222e",
64
+ "adapter_failed": "#a40e26",
65
+ }
66
+
67
+
68
+ def build_manifest(catalog: dict[str, ApiFunction]) -> dict[str, Any]:
69
+ interfaces = []
70
+ for api in catalog.values():
71
+ required = api.input_schema.get("required", [])
72
+ schema_hash = hashlib.sha256(
73
+ json.dumps(
74
+ api.input_schema, ensure_ascii=False, sort_keys=True, separators=(",", ":")
75
+ ).encode("utf-8")
76
+ ).hexdigest()
77
+ metadata = api.as_metadata(include_schema=False)
78
+ interfaces.append(
79
+ {
80
+ "name": api.name,
81
+ "signature": api.signature,
82
+ "display_name": api.display_name or api.name,
83
+ "category": api.category,
84
+ "aliases": list(api.aliases),
85
+ "use_cases": list(api.use_cases),
86
+ "examples": list(api.examples),
87
+ "return": api.return_metadata,
88
+ "parameters": api.parameter_metadata,
89
+ "side_effect": api.side_effect,
90
+ "source_url": api.source_url,
91
+ "description": api.description,
92
+ "schema_hash": schema_hash,
93
+ "metadata_hash": hashlib.sha256(
94
+ json.dumps(
95
+ metadata,
96
+ ensure_ascii=False,
97
+ sort_keys=True,
98
+ default=str,
99
+ separators=(",", ":"),
100
+ ).encode("utf-8")
101
+ ).hexdigest(),
102
+ "required_parameters": required,
103
+ "schema_status": "passed",
104
+ "call_status": "pending" if not required else "fixture_required",
105
+ }
106
+ )
107
+ return {
108
+ "akshare_version": getattr(akshare, "__version__", "unknown"),
109
+ "generated_at": dt.datetime.now(dt.UTC).isoformat(),
110
+ "interface_count": len(interfaces),
111
+ "interfaces": interfaces,
112
+ }
113
+
114
+
115
+ def write_manifest(path: Path) -> dict[str, Any]:
116
+ manifest = build_manifest(discover_functions())
117
+ path.parent.mkdir(parents=True, exist_ok=True)
118
+ path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
119
+ return manifest
120
+
121
+
122
+ def _load_json(path: Path, default: Any) -> Any:
123
+ if not path.exists():
124
+ return default
125
+ return json.loads(path.read_text(encoding="utf-8"))
126
+
127
+
128
+ def _write_report(path: Path, report: dict[str, Any]) -> None:
129
+ path.parent.mkdir(parents=True, exist_ok=True)
130
+ temporary = path.with_suffix(path.suffix + ".tmp")
131
+ temporary.write_text(
132
+ json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n",
133
+ encoding="utf-8",
134
+ )
135
+ temporary.replace(path)
136
+
137
+
138
+ def _adapter_contract_result(api: ApiFunction) -> dict[str, Any]:
139
+ """Validate one generated MCP adapter without invoking its provider."""
140
+ try:
141
+ schema = api.input_schema
142
+ if schema.get("type") != "object":
143
+ raise ValueError("input schema root must be an object")
144
+ properties = schema.get("properties", {})
145
+ if not isinstance(properties, dict):
146
+ raise ValueError("input schema properties must be an object")
147
+ required = schema.get("required", [])
148
+ if not isinstance(required, list) or not set(required) <= set(properties):
149
+ raise ValueError("required parameters must be declared in properties")
150
+ if not api.description or not api.category:
151
+ raise ValueError("adapter metadata is incomplete")
152
+
153
+ # Import lazily so acceptance helpers remain usable during maintenance
154
+ # module startup, while still exercising the exact MCP model boundary.
155
+ from .server import build_mcp_tools
156
+
157
+ tools = build_mcp_tools({api.name: api}, mode="all")
158
+ if len(tools) != 1 or tools[0].name != api.name:
159
+ raise ValueError("MCP tool construction returned an unexpected tool")
160
+ except Exception as exc: # Contract failures must be machine-readable.
161
+ return {
162
+ "name": api.name,
163
+ "status": "adapter_failed",
164
+ "duration_seconds": 0.0,
165
+ "arguments": {},
166
+ "error": f"{type(exc).__name__}: {exc}",
167
+ "worker_logs": None,
168
+ "preview": None,
169
+ }
170
+ return {
171
+ "name": api.name,
172
+ "status": "adapter_passed",
173
+ "duration_seconds": 0.0,
174
+ "arguments": {},
175
+ "error": None,
176
+ "worker_logs": None,
177
+ "preview": None,
178
+ }
179
+
180
+
181
+ def classify_result(result: dict[str, Any]) -> tuple[str, str | None]:
182
+ """Return the failure scope and exception type for a call result."""
183
+ status = result.get("status")
184
+ if status in {"passed", "passed_empty"}:
185
+ return "provider_success", None
186
+ if status == "adapter_passed":
187
+ return "adapter_contract", None
188
+ if status == "adapter_failed":
189
+ return "mcp_adapter", "AdapterContractError"
190
+ if status == "timeout":
191
+ return "upstream_timeout", "Timeout"
192
+ if status == "fixture_required":
193
+ return "fixture", None
194
+ if status == "worker_failed":
195
+ return "mcp_adapter", None
196
+ error_type = (result.get("error") or "UnknownError").split(":", 1)[0]
197
+ if error_type in UPSTREAM_TRANSPORT_ERRORS:
198
+ return "upstream_transport", error_type
199
+ if error_type in UPSTREAM_RESPONSE_ERRORS:
200
+ return "upstream_response", error_type
201
+ return "akshare_runtime", error_type
202
+
203
+
204
+ def _write_status_svg(summary: dict[str, Any], output_path: Path) -> None:
205
+ """Render a transparent availability-stripe chart for repository READMEs."""
206
+ ET.register_namespace("", SVG_NAMESPACE)
207
+ status_counts = summary["status_counts"]
208
+ interface_count = summary["interface_count"]
209
+ ordered_statuses = [key for key in STATUS_ORDER if key in status_counts]
210
+ ordered_statuses.extend(sorted(set(status_counts) - set(ordered_statuses)))
211
+ scope_counts = summary["scope_counts"]
212
+ provider_report = any(
213
+ key in scope_counts
214
+ for key in (
215
+ "provider_success",
216
+ "upstream_transport",
217
+ "upstream_response",
218
+ "upstream_timeout",
219
+ "akshare_runtime",
220
+ )
221
+ )
222
+ legend_rows = max(1, (len(ordered_statuses) + 3) // 4)
223
+ legend_y = 164 if provider_report else 96
224
+ height = legend_y + 26 + (legend_rows - 1) * 34
225
+
226
+ root = ET.Element(
227
+ f"{{{SVG_NAMESPACE}}}svg",
228
+ {
229
+ "width": "860",
230
+ "height": str(height),
231
+ "viewBox": f"0 0 860 {height}",
232
+ "role": "img",
233
+ "aria-labelledby": "title description",
234
+ },
235
+ )
236
+ ET.SubElement(root, f"{{{SVG_NAMESPACE}}}title", {"id": "title"}).text = "AKBridge 最新验收状态"
237
+ ET.SubElement(
238
+ root, f"{{{SVG_NAMESPACE}}}desc", {"id": "description"}
239
+ ).text = "使用短竖线表示 MCP 适配通过率和数据源动态验收组分。"
240
+ style = ET.SubElement(root, f"{{{SVG_NAMESPACE}}}style")
241
+ style.text = """
242
+ .primary { fill: #1f2328; }
243
+ .muted { fill: #656d76; }
244
+ text { font-family: "Segoe UI", "Microsoft YaHei", sans-serif; }
245
+ @media (prefers-color-scheme: dark) {
246
+ .primary { fill: #f0f6fc; }
247
+ .muted { fill: #8b949e; }
248
+ }
249
+ """
250
+
251
+ def add_text(
252
+ value: str,
253
+ x: int,
254
+ y: int,
255
+ *,
256
+ css_class: str = "primary",
257
+ size: int = 13,
258
+ weight: int = 400,
259
+ anchor: str | None = None,
260
+ ) -> None:
261
+ attributes = {
262
+ "class": css_class,
263
+ "x": str(x),
264
+ "y": str(y),
265
+ "font-size": str(size),
266
+ "font-weight": str(weight),
267
+ }
268
+ if anchor:
269
+ attributes["text-anchor"] = anchor
270
+ ET.SubElement(root, f"{{{SVG_NAMESPACE}}}text", attributes).text = value
271
+
272
+ def ratio(count: int) -> float:
273
+ return count / interface_count if interface_count else 0.0
274
+
275
+ stripe_count = 64
276
+
277
+ adapter_accepted = summary["mcp_adapter_accepted"]
278
+ add_text(
279
+ f"MCP 适配通过率 {ratio(adapter_accepted):.2%}",
280
+ 16,
281
+ 26,
282
+ size=22,
283
+ weight=600,
284
+ )
285
+ add_text(
286
+ f"{adapter_accepted} / {interface_count}",
287
+ 844,
288
+ 26,
289
+ css_class="muted",
290
+ size=18,
291
+ anchor="end",
292
+ )
293
+ for index in range(stripe_count):
294
+ target = ((index + 0.5) / stripe_count) * interface_count
295
+ color = "#2da44e" if target <= adapter_accepted else "#cf222e"
296
+ x = 20 + index * (820 / (stripe_count - 1))
297
+ ET.SubElement(
298
+ root,
299
+ f"{{{SVG_NAMESPACE}}}line",
300
+ {
301
+ "class": "mcp-line",
302
+ "x1": f"{x:.2f}",
303
+ "y1": "36",
304
+ "x2": f"{x:.2f}",
305
+ "y2": "58",
306
+ "stroke": color,
307
+ "stroke-width": "4",
308
+ "stroke-linecap": "round",
309
+ },
310
+ )
311
+
312
+ availability_label = "数据源可用性" if provider_report else "离线契约通过率"
313
+ available_count = (
314
+ scope_counts.get("provider_success", 0)
315
+ if provider_report
316
+ else scope_counts.get("adapter_contract", summary["mcp_adapter_accepted"])
317
+ )
318
+ if provider_report:
319
+ add_text(
320
+ f"{availability_label} {ratio(available_count):.2%}",
321
+ 16,
322
+ 94,
323
+ size=22,
324
+ weight=600,
325
+ )
326
+ add_text(
327
+ f"{available_count} / {interface_count}",
328
+ 844,
329
+ 94,
330
+ css_class="muted",
331
+ size=18,
332
+ anchor="end",
333
+ )
334
+
335
+ cumulative: list[tuple[int, str]] = []
336
+ running_total = 0
337
+ for status in ordered_statuses:
338
+ running_total += status_counts[status]
339
+ cumulative.append((running_total, status))
340
+ if provider_report:
341
+ for index in range(stripe_count):
342
+ target = ((index + 0.5) / stripe_count) * interface_count
343
+ status = next(
344
+ (key for boundary, key in cumulative if target <= boundary),
345
+ ordered_statuses[-1] if ordered_statuses else "unknown",
346
+ )
347
+ x = 20 + index * (820 / (stripe_count - 1))
348
+ ET.SubElement(
349
+ root,
350
+ f"{{{SVG_NAMESPACE}}}line",
351
+ {
352
+ "class": "availability-line",
353
+ "x1": f"{x:.2f}",
354
+ "y1": "108",
355
+ "x2": f"{x:.2f}",
356
+ "y2": "130",
357
+ "stroke": STATUS_COLORS.get(status, "#6e7781"),
358
+ "stroke-width": "4",
359
+ "stroke-linecap": "round",
360
+ },
361
+ )
362
+
363
+ for index, status in enumerate(ordered_statuses):
364
+ column = index % 4
365
+ row = index // 4
366
+ x = 17 + column * 207
367
+ y = legend_y + row * 34
368
+ ET.SubElement(
369
+ root,
370
+ f"{{{SVG_NAMESPACE}}}line",
371
+ {
372
+ "class": "legend-line",
373
+ "x1": str(x),
374
+ "y1": str(y - 17),
375
+ "x2": str(x),
376
+ "y2": str(y + 2),
377
+ "stroke": STATUS_COLORS.get(status, "#6e7781"),
378
+ "stroke-width": "5",
379
+ "stroke-linecap": "round",
380
+ },
381
+ )
382
+ count = status_counts[status]
383
+ label = STATUS_LABELS.get(status, status)
384
+ add_text(f"{label} {count}({ratio(count):.2%})", x + 14, y, size=17)
385
+
386
+ tree = ET.ElementTree(root)
387
+ ET.indent(tree, space=" ")
388
+ output_path.write_text(ET.tostring(root, encoding="unicode") + "\n", encoding="utf-8")
389
+
390
+
391
+ def write_acceptance_artifacts(run_path: Path, output_dir: Path) -> dict[str, Any]:
392
+ """Write a compact per-interface ledger and aggregate Markdown summary."""
393
+ report = _load_json(run_path, {})
394
+ results = report.get("results", [])
395
+ output_dir.mkdir(parents=True, exist_ok=True)
396
+ ledger_path = output_dir / "ledger.csv"
397
+ with ledger_path.open("w", encoding="utf-8-sig", newline="") as handle:
398
+ writer = csv.DictWriter(
399
+ handle,
400
+ fieldnames=[
401
+ "name",
402
+ "status",
403
+ "scope",
404
+ "error_type",
405
+ "duration_seconds",
406
+ "error",
407
+ ],
408
+ )
409
+ writer.writeheader()
410
+ for result in sorted(results, key=lambda item: item["name"]):
411
+ scope, error_type = classify_result(result)
412
+ writer.writerow(
413
+ {
414
+ "name": result["name"],
415
+ "status": result["status"],
416
+ "scope": scope,
417
+ "error_type": error_type or "",
418
+ "duration_seconds": result.get("duration_seconds", ""),
419
+ "error": (result.get("error") or "")[:4000]
420
+ .replace("\r", " ")
421
+ .replace("\n", " "),
422
+ }
423
+ )
424
+
425
+ status_counts: dict[str, int] = {}
426
+ scope_counts: dict[str, int] = {}
427
+ for result in results:
428
+ status_counts[result["status"]] = status_counts.get(result["status"], 0) + 1
429
+ scope, _ = classify_result(result)
430
+ scope_counts[scope] = scope_counts.get(scope, 0) + 1
431
+ adapter_accepted = sum(
432
+ count
433
+ for status, count in status_counts.items()
434
+ if status not in {"fixture_required", "worker_failed", "adapter_failed"}
435
+ )
436
+ interface_count = len(results)
437
+
438
+ def ratio(count: int) -> float:
439
+ return count / interface_count if interface_count else 0.0
440
+
441
+ def percentage(count: int) -> str:
442
+ return f"{ratio(count):.2%}"
443
+
444
+ summary = {
445
+ "akshare_version": report.get("akshare_version", "unknown"),
446
+ "generated_at": report.get("generated_at"),
447
+ "interface_count": interface_count,
448
+ "mcp_adapter_accepted": adapter_accepted,
449
+ "mcp_adapter_acceptance_rate": ratio(adapter_accepted),
450
+ "status_counts": status_counts,
451
+ "status_rates": {key: ratio(value) for key, value in status_counts.items()},
452
+ "scope_counts": scope_counts,
453
+ "scope_rates": {key: ratio(value) for key, value in scope_counts.items()},
454
+ }
455
+ (output_dir / "summary.json").write_text(
456
+ json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
457
+ encoding="utf-8",
458
+ )
459
+ lines = [
460
+ "# AKBridge 验收汇总",
461
+ "",
462
+ f"- AKShare 版本:`{summary['akshare_version']}`",
463
+ f"- 生成时间:`{summary['generated_at']}`",
464
+ f"- 已发现接口:**{summary['interface_count']}**",
465
+ (
466
+ f"- MCP 适配通过:**{adapter_accepted} / {interface_count}"
467
+ f"({percentage(adapter_accepted)})**"
468
+ ),
469
+ "",
470
+ "`MCP 适配通过`表示接口已被发现、已生成输入 Schema,并且没有因缺少验收参数",
471
+ "或隔离进程故障而中断。数据源成功情况单独统计,上游故障和 AKShare 运行错误不会被隐藏。",
472
+ "",
473
+ "## 调用状态",
474
+ "",
475
+ "| 状态 | 数量 | 占比 |",
476
+ "| --- | ---: | ---: |",
477
+ *[
478
+ f"| `{key}` | {value} / {interface_count} | {percentage(value)} |"
479
+ for key, value in sorted(status_counts.items())
480
+ ],
481
+ "",
482
+ "## 结果范围",
483
+ "",
484
+ "| 范围 | 数量 | 占比 |",
485
+ "| --- | ---: | ---: |",
486
+ *[
487
+ f"| `{key}` | {value} / {interface_count} | {percentage(value)} |"
488
+ for key, value in sorted(scope_counts.items())
489
+ ],
490
+ "",
491
+ "每个接口的精确结果见 `ledger.csv`。",
492
+ ]
493
+ (output_dir / "SUMMARY.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
494
+ _write_status_svg(summary, output_dir / "status.svg")
495
+ return summary
496
+
497
+
498
+ def _run_one(api: ApiFunction, arguments: dict[str, Any], timeout: float) -> dict[str, Any]:
499
+ command = [
500
+ sys.executable,
501
+ "-m",
502
+ "akbridge.acceptance_worker",
503
+ api.name,
504
+ json.dumps(arguments, ensure_ascii=False),
505
+ ]
506
+ environment = os.environ.copy()
507
+ environment["PYTHONIOENCODING"] = "utf-8"
508
+ environment["PYTHONUTF8"] = "1"
509
+ try:
510
+ completed = subprocess.run(
511
+ command,
512
+ capture_output=True,
513
+ text=True,
514
+ encoding="utf-8",
515
+ errors="replace",
516
+ env=environment,
517
+ timeout=timeout,
518
+ check=False,
519
+ )
520
+ except subprocess.TimeoutExpired:
521
+ return {"name": api.name, "status": "timeout", "error": f"exceeded {timeout}s"}
522
+
523
+ if completed.returncode != 0:
524
+ detail = completed.stderr.strip() or completed.stdout.strip()
525
+ return {"name": api.name, "status": "worker_failed", "error": detail[-4000:]}
526
+ try:
527
+ return json.loads(completed.stdout)
528
+ except json.JSONDecodeError:
529
+ return {
530
+ "name": api.name,
531
+ "status": "worker_failed",
532
+ "error": f"invalid worker output: {completed.stdout[-4000:]}",
533
+ }
534
+
535
+
536
+ def run_acceptance(
537
+ catalog: dict[str, ApiFunction],
538
+ output: Path,
539
+ fixtures: dict[str, dict[str, Any]],
540
+ *,
541
+ names: list[str] | None = None,
542
+ limit: int | None = None,
543
+ timeout: float = 30,
544
+ resume: bool = False,
545
+ retry_statuses: set[str] | None = None,
546
+ workers: int = 1,
547
+ adapter_only: bool = False,
548
+ ) -> dict[str, Any]:
549
+ if workers < 1:
550
+ raise ValueError("workers must be at least 1")
551
+ existing = _load_json(output, {}) if resume else {}
552
+ results_by_name = {item["name"]: item for item in existing.get("results", [])}
553
+ if retry_statuses:
554
+ results_by_name = {
555
+ name: item
556
+ for name, item in results_by_name.items()
557
+ if item.get("status") not in retry_statuses
558
+ }
559
+ selected = names or list(catalog)
560
+ unknown = sorted(set(selected) - set(catalog))
561
+ if unknown:
562
+ raise ValueError(f"Unknown AKShare APIs: {', '.join(unknown)}")
563
+ pending = [name for name in selected if name not in results_by_name]
564
+ if limit is not None:
565
+ pending = pending[:limit]
566
+
567
+ report = {
568
+ "akshare_version": getattr(akshare, "__version__", "unknown"),
569
+ "generated_at": dt.datetime.now(dt.UTC).isoformat(),
570
+ "results": list(results_by_name.values()),
571
+ }
572
+
573
+ def accept(name: str) -> dict[str, Any]:
574
+ api = catalog[name]
575
+ arguments = fixtures.get(name, {})
576
+ if adapter_only:
577
+ return _adapter_contract_result(api)
578
+ missing = sorted(set(api.input_schema.get("required", [])) - set(arguments))
579
+ if missing:
580
+ return {
581
+ "name": name,
582
+ "status": "fixture_required",
583
+ "error": f"missing required parameters: {', '.join(missing)}",
584
+ }
585
+ return _run_one(api, arguments, timeout)
586
+
587
+ def record(name: str, result: dict[str, Any]) -> None:
588
+ results_by_name[name] = result
589
+ report["generated_at"] = dt.datetime.now(dt.UTC).isoformat()
590
+ report["results"] = list(results_by_name.values())
591
+ _write_report(output, report)
592
+ print(f"{name}: {result['status']}", flush=True)
593
+
594
+ if workers == 1:
595
+ for name in pending:
596
+ record(name, accept(name))
597
+ else:
598
+ with ThreadPoolExecutor(max_workers=workers) as executor:
599
+ futures = {executor.submit(accept, name): name for name in pending}
600
+ for future in as_completed(futures):
601
+ name = futures[future]
602
+ try:
603
+ result = future.result()
604
+ except Exception as exc:
605
+ result = {
606
+ "name": name,
607
+ "status": "worker_failed",
608
+ "error": f"{type(exc).__name__}: {exc}",
609
+ }
610
+ record(name, result)
611
+ return report
612
+
613
+
614
+ def main() -> None:
615
+ parser = argparse.ArgumentParser(description="Build or run AKShare interface acceptance")
616
+ subparsers = parser.add_subparsers(dest="command", required=True)
617
+ manifest_parser = subparsers.add_parser("manifest")
618
+ manifest_parser.add_argument(
619
+ "--output", type=Path, default=Path("artifacts/acceptance/manifest.json")
620
+ )
621
+ run_parser = subparsers.add_parser("run")
622
+ run_parser.add_argument(
623
+ "--output", type=Path, default=Path("artifacts/acceptance/runs/all.json")
624
+ )
625
+ run_parser.add_argument(
626
+ "--fixtures", type=Path, default=Path("artifacts/acceptance/fixtures.json")
627
+ )
628
+ run_parser.add_argument("--name", action="append", dest="names")
629
+ run_parser.add_argument("--limit", type=int)
630
+ run_parser.add_argument("--timeout", type=float, default=30)
631
+ run_parser.add_argument("--resume", action="store_true")
632
+ run_parser.add_argument("--retry-status", action="append", default=[])
633
+ run_parser.add_argument("--workers", type=int, default=1)
634
+ run_parser.add_argument(
635
+ "--offline",
636
+ "--adapter-only",
637
+ dest="adapter_only",
638
+ action="store_true",
639
+ help="validate discovery and schemas without calling third-party providers",
640
+ )
641
+ report_parser = subparsers.add_parser("report")
642
+ report_parser.add_argument(
643
+ "--run", type=Path, default=Path("artifacts/acceptance/runs/all.json")
644
+ )
645
+ report_parser.add_argument("--output-dir", type=Path, default=Path("artifacts/acceptance"))
646
+ args = parser.parse_args()
647
+
648
+ if args.command == "manifest":
649
+ manifest = write_manifest(args.output)
650
+ print(f"Wrote {manifest['interface_count']} interfaces to {args.output}")
651
+ elif args.command == "run":
652
+ fixtures = _load_json(args.fixtures, {})
653
+ report = run_acceptance(
654
+ discover_functions(),
655
+ args.output,
656
+ fixtures,
657
+ names=args.names,
658
+ limit=args.limit,
659
+ timeout=args.timeout,
660
+ resume=args.resume,
661
+ retry_statuses=set(args.retry_status),
662
+ workers=args.workers,
663
+ adapter_only=args.adapter_only,
664
+ )
665
+ counts: dict[str, int] = {}
666
+ for result in report["results"]:
667
+ counts[result["status"]] = counts.get(result["status"], 0) + 1
668
+ print(json.dumps(counts, ensure_ascii=False, sort_keys=True))
669
+ if args.adapter_only and any(
670
+ item.get("status") != "adapter_passed" for item in report["results"]
671
+ ):
672
+ raise SystemExit(1)
673
+ elif args.command == "report":
674
+ summary = write_acceptance_artifacts(args.run, args.output_dir)
675
+ print(json.dumps(summary, ensure_ascii=False, sort_keys=True))
676
+
677
+
678
+ if __name__ == "__main__":
679
+ main()