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 +6 -0
- akbridge/acceptance.py +679 -0
- akbridge/acceptance_worker.py +61 -0
- akbridge/catalog.py +481 -0
- akbridge/credentials.py +40 -0
- akbridge/maintenance.py +463 -0
- akbridge/observability.py +45 -0
- akbridge/reliability.py +448 -0
- akbridge/router.py +275 -0
- akbridge/serialization.py +298 -0
- akbridge/server.py +456 -0
- akbridge/skill.py +18 -0
- akbridge-0.1.0.dist-info/METADATA +475 -0
- akbridge-0.1.0.dist-info/RECORD +17 -0
- akbridge-0.1.0.dist-info/WHEEL +4 -0
- akbridge-0.1.0.dist-info/entry_points.txt +4 -0
- akbridge-0.1.0.dist-info/licenses/LICENSE +21 -0
akbridge/__init__.py
ADDED
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()
|