fd-open-data-protocol 0.3.0__tar.gz → 0.3.1__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.
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/PKG-INFO +5 -1
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/README.md +2 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/__init__.py +1 -1
- fd_open_data_protocol-0.3.1/fd_open_data_protocol/registry.py +274 -0
- fd_open_data_protocol-0.3.1/fd_open_data_protocol/registry_cli.py +239 -0
- fd_open_data_protocol-0.3.1/fd_open_data_protocol/registry_store.py +458 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/schema.py +120 -1
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol.egg-info/PKG-INFO +5 -1
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol.egg-info/SOURCES.txt +6 -0
- fd_open_data_protocol-0.3.1/fd_open_data_protocol.egg-info/entry_points.txt +2 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol.egg-info/requires.txt +3 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/pyproject.toml +4 -0
- fd_open_data_protocol-0.3.1/tests/test_registry_pipeline.py +209 -0
- fd_open_data_protocol-0.3.1/tests/test_registry_schema.py +91 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/constants.py +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/loader.py +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/provider.py +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/vocabulary/concepts.yaml +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/vocabulary/crosswalks/datacommons-worldbank.yaml +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/vocabulary/entity_types.yaml +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/vocabulary/event_types.yaml +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/vocabulary/qualifiers.yaml +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/vocabulary/relation_types.yaml +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/vocabulary/units.yaml +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/vocabulary.py +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol.egg-info/dependency_links.txt +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol.egg-info/top_level.txt +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/setup.cfg +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/tests/test_loader.py +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/tests/test_schema_vocabulary.py +0 -0
- {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/tests/test_vocabulary.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fd-open-data-protocol
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: The open-data datasource protocol: a manifest contract a datasource exposes to be ingested by fd-open-data-mcp (or any consumer).
|
|
5
5
|
Author: FindDataOfficial
|
|
6
6
|
License: MIT
|
|
@@ -10,9 +10,13 @@ Requires-Dist: pydantic>=2.0
|
|
|
10
10
|
Requires-Dist: pyyaml>=6.0
|
|
11
11
|
Provides-Extra: dev
|
|
12
12
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
13
|
+
Provides-Extra: registry
|
|
14
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == "registry"
|
|
13
15
|
|
|
14
16
|
# fd-open-data-protocol
|
|
15
17
|
|
|
18
|
+
> **Wire (柏讯) product line** · the open-data supply line of [FindData](https://www.finddatatech.cloud/products/wire) — the datasource manifest contract
|
|
19
|
+
|
|
16
20
|
**English** | [中文](README.zh-CN.md)
|
|
17
21
|
|
|
18
22
|
The **open-data datasource protocol**: a manifest contract a datasource exposes
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# fd-open-data-protocol
|
|
2
2
|
|
|
3
|
+
> **Wire (柏讯) product line** · the open-data supply line of [FindData](https://www.finddatatech.cloud/products/wire) — the datasource manifest contract
|
|
4
|
+
|
|
3
5
|
**English** | [中文](README.zh-CN.md)
|
|
4
6
|
|
|
5
7
|
The **open-data datasource protocol**: a manifest contract a datasource exposes
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Declarative registry adapters and coverage computation.
|
|
2
|
+
|
|
3
|
+
An adapter is a small YAML file mapping a source table's rows onto
|
|
4
|
+
:class:`~fd_open_data_protocol.schema.RegistryEntry` fields - no core code
|
|
5
|
+
changes when a new source database is onboarded. The recipe (see REGISTRY.md):
|
|
6
|
+
|
|
7
|
+
写适配器 → 批量登记(register)→ 概念绑定(bind)→ 命名门核验(verify)
|
|
8
|
+
|
|
9
|
+
Row sources are independent of mapping: rows can come from a Postgres source
|
|
10
|
+
DSN or a CSV file (tests, dry runs, the shipped template example), so the
|
|
11
|
+
whole pipeline is exercisable without a database.
|
|
12
|
+
|
|
13
|
+
Coverage is asset-denominated: per source "registered / actual in source",
|
|
14
|
+
with the denominator taken from the source database (or supplied counts) -
|
|
15
|
+
never from an MCP concept dictionary.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import csv
|
|
20
|
+
import json
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Iterable, Optional, Sequence
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
from pydantic import BaseModel, Field, field_validator
|
|
26
|
+
|
|
27
|
+
from fd_open_data_protocol.schema import RegistryEntry
|
|
28
|
+
|
|
29
|
+
#: registry fields an adapter may map; anchors come from dedicated keys.
|
|
30
|
+
MAPPABLE_FIELDS = ("semantic_code", "name_zh", "name_en", "unit", "frequency", "domain")
|
|
31
|
+
|
|
32
|
+
#: sources known to the fleet but NOT in the first registration batch; the
|
|
33
|
+
#: coverage view must show them explicitly instead of silently omitting them.
|
|
34
|
+
DEFAULT_SCOPE: dict[str, dict[str, Any]] = {
|
|
35
|
+
"world_bank": {"phase": 1, "label": "世界银行 WDI(含宏观数据链路)"},
|
|
36
|
+
"yearbook_catalog": {"phase": 1, "label": "国家统计年鉴"},
|
|
37
|
+
"gta_panel": {"phase": 1, "label": "GTA 治理面板"},
|
|
38
|
+
"china_city_panel": {"phase": 1, "label": "中国城市面板"},
|
|
39
|
+
"law_db": {
|
|
40
|
+
"phase": 1,
|
|
41
|
+
"label": "法律库·文档域(法条/切片/指导案例为文档资产,非指标口径;登记 0 属预期,经 business-mcp 可查)",
|
|
42
|
+
},
|
|
43
|
+
"fd_open_data": {"phase": 1, "label": "fd_open_data 镜像概念字典"},
|
|
44
|
+
"ia35": {"phase": 2, "label": "工商注册 161G(实体为主,二期)"},
|
|
45
|
+
"wenshu_db": {"phase": 2, "label": "裁判文书(增量暂停,二期)"},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FieldSource(BaseModel):
|
|
50
|
+
"""Where one registry field's value comes from.
|
|
51
|
+
|
|
52
|
+
``from: column`` - a column of the source row (or CSV header)
|
|
53
|
+
``from: literal`` - a constant (e.g. ``domain: macro``)
|
|
54
|
+
``from: map_file``- a JSON ``{native_code: value}`` lookup, typically a
|
|
55
|
+
curated/LLM-drafted naming map (name translations,
|
|
56
|
+
semantic codes); misses fall back to ``missing``
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from_: str = Field(alias="from")
|
|
60
|
+
value: str
|
|
61
|
+
missing: Optional[str] = None # value to store on a miss; default None = absent
|
|
62
|
+
uppercase: bool = False
|
|
63
|
+
|
|
64
|
+
@field_validator("from_")
|
|
65
|
+
@classmethod
|
|
66
|
+
def _known_kind(cls, v: str) -> str:
|
|
67
|
+
if v not in ("column", "literal", "map_file"):
|
|
68
|
+
raise ValueError(f"unknown field source kind: {v!r} (column|literal|map_file)")
|
|
69
|
+
return v
|
|
70
|
+
|
|
71
|
+
model_config = {"populate_by_name": True}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AdapterConfig(BaseModel):
|
|
75
|
+
"""Declarative source-table → registry mapping."""
|
|
76
|
+
|
|
77
|
+
source_db: str
|
|
78
|
+
source_table: str
|
|
79
|
+
source_column: str = "" # anchor label: the column the native code was read from
|
|
80
|
+
native_code_column: str
|
|
81
|
+
fields: dict[str, FieldSource]
|
|
82
|
+
provenance_columns: list[str] = Field(default_factory=list)
|
|
83
|
+
source_query: Optional[str] = None # default: SELECT * FROM <source_table>
|
|
84
|
+
semantic_code_dedupe_suffix: str = "-%d" # in-batch collision suffix (marked needs_review)
|
|
85
|
+
|
|
86
|
+
@field_validator("fields")
|
|
87
|
+
@classmethod
|
|
88
|
+
def _fields_known(cls, v: dict[str, FieldSource]) -> dict[str, FieldSource]:
|
|
89
|
+
unknown = set(v) - set(MAPPABLE_FIELDS)
|
|
90
|
+
if unknown:
|
|
91
|
+
raise ValueError(f"unknown mappable field(s): {sorted(unknown)}")
|
|
92
|
+
return v
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_adapter(source: str | Path | dict) -> AdapterConfig:
|
|
96
|
+
"""Load an adapter from a YAML/JSON file path or an already-parsed dict."""
|
|
97
|
+
if isinstance(source, dict):
|
|
98
|
+
return AdapterConfig(**source)
|
|
99
|
+
p = Path(source)
|
|
100
|
+
data = yaml.safe_load(p.read_text(encoding="utf-8"))
|
|
101
|
+
if not isinstance(data, dict):
|
|
102
|
+
raise ValueError(f"adapter file did not parse to a mapping: {p}")
|
|
103
|
+
adapter = AdapterConfig(**data)
|
|
104
|
+
adapter._base_dir = p.parent # type: ignore[attr-defined] # resolve relative map_files
|
|
105
|
+
return adapter
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _lookup(
|
|
109
|
+
spec: FieldSource, row: dict, native_code: str, base_dir: Path
|
|
110
|
+
) -> Optional[str]:
|
|
111
|
+
value: Any
|
|
112
|
+
if spec.from_ == "literal":
|
|
113
|
+
value = spec.value
|
|
114
|
+
elif spec.from_ == "column":
|
|
115
|
+
value = row.get(spec.value)
|
|
116
|
+
else: # map_file
|
|
117
|
+
map_path = (base_dir / spec.value) if not Path(spec.value).is_absolute() else Path(spec.value)
|
|
118
|
+
try:
|
|
119
|
+
mapping = json.loads(map_path.read_text(encoding="utf-8"))
|
|
120
|
+
except FileNotFoundError:
|
|
121
|
+
value = spec.missing
|
|
122
|
+
mapping = None
|
|
123
|
+
else:
|
|
124
|
+
value = mapping.get(native_code, spec.missing)
|
|
125
|
+
if value is None:
|
|
126
|
+
return None
|
|
127
|
+
text = str(value).strip()
|
|
128
|
+
if not text:
|
|
129
|
+
return None
|
|
130
|
+
return text.upper() if spec.uppercase else text
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def map_rows(
|
|
134
|
+
adapter: AdapterConfig, rows: Iterable[dict[str, Any]]
|
|
135
|
+
) -> list[RegistryEntry]:
|
|
136
|
+
"""Map source rows to registry entries (pure; no store access).
|
|
137
|
+
|
|
138
|
+
In-batch semantic_code collisions get a numeric suffix and a
|
|
139
|
+
``needs_review: true`` provenance flag - never a silent merge.
|
|
140
|
+
"""
|
|
141
|
+
base_dir = getattr(adapter, "_base_dir", Path("."))
|
|
142
|
+
seen_codes: dict[str, int] = {}
|
|
143
|
+
entries: list[RegistryEntry] = []
|
|
144
|
+
for row in rows:
|
|
145
|
+
native_code = str(row.get(adapter.native_code_column, "") or "").strip()
|
|
146
|
+
if not native_code:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"row has empty {adapter.native_code_column!r}; adapter={adapter.source_db}.{adapter.source_table}"
|
|
149
|
+
)
|
|
150
|
+
fields: dict[str, Optional[str]] = {}
|
|
151
|
+
needs_review = False
|
|
152
|
+
for name, spec in adapter.fields.items():
|
|
153
|
+
value = _lookup(spec, row, native_code, base_dir)
|
|
154
|
+
if name == "semantic_code" and value is not None:
|
|
155
|
+
base = value
|
|
156
|
+
n = seen_codes.get(base, 0)
|
|
157
|
+
if n:
|
|
158
|
+
value = base + (adapter.semantic_code_dedupe_suffix % (n + 1))
|
|
159
|
+
needs_review = True
|
|
160
|
+
seen_codes[base] = n + 1
|
|
161
|
+
fields[name] = value
|
|
162
|
+
provenance = {c: row.get(c) for c in adapter.provenance_columns if c in row}
|
|
163
|
+
if needs_review:
|
|
164
|
+
provenance["needs_review"] = True
|
|
165
|
+
entries.append(
|
|
166
|
+
RegistryEntry(
|
|
167
|
+
source_db=adapter.source_db,
|
|
168
|
+
source_table=adapter.source_table,
|
|
169
|
+
source_column=adapter.source_column or adapter.native_code_column,
|
|
170
|
+
native_code=native_code,
|
|
171
|
+
provenance=provenance or None,
|
|
172
|
+
**fields,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return entries
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def fetch_rows(adapter: AdapterConfig, source_dsn: str) -> list[dict[str, Any]]:
|
|
179
|
+
"""Read all source rows via Postgres (psycopg3, dict rows)."""
|
|
180
|
+
import psycopg
|
|
181
|
+
|
|
182
|
+
sql = adapter.source_query or f"SELECT * FROM {adapter.source_table}"
|
|
183
|
+
with psycopg.connect(source_dsn) as conn:
|
|
184
|
+
with conn.cursor() as cur:
|
|
185
|
+
cur.execute(sql)
|
|
186
|
+
cols = [d.name for d in cur.description]
|
|
187
|
+
return [dict(zip(cols, r)) for r in cur.fetchall()]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def read_rows_csv(path: str | Path) -> list[dict[str, Any]]:
|
|
191
|
+
"""Read source rows from a CSV (headers = column names). DB-free path."""
|
|
192
|
+
with open(path, encoding="utf-8-sig", newline="") as fh:
|
|
193
|
+
return [{k: v for k, v in row.items() if k is not None} for row in csv.DictReader(fh)]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Coverage (asset-denominated)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def count_source_rows(adapter: AdapterConfig, source_dsn: str) -> int:
|
|
201
|
+
"""Actual indicator rows in the source table - the coverage denominator."""
|
|
202
|
+
import psycopg
|
|
203
|
+
|
|
204
|
+
table = adapter.source_table
|
|
205
|
+
with psycopg.connect(source_dsn) as conn:
|
|
206
|
+
with conn.cursor() as cur:
|
|
207
|
+
cur.execute(f"SELECT count(*) FROM {table}")
|
|
208
|
+
return cur.fetchone()[0]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def coverage_report(
|
|
212
|
+
store,
|
|
213
|
+
actual_counts: Optional[dict[str, int]] = None,
|
|
214
|
+
scope: Optional[dict[str, dict[str, Any]]] = None,
|
|
215
|
+
module_map: Optional[dict[str, dict[str, list[str]]]] = None,
|
|
216
|
+
) -> dict[str, Any]:
|
|
217
|
+
"""Per-source registered/actual coverage + phase-2 visibility + unmapped audit.
|
|
218
|
+
|
|
219
|
+
``actual_counts``: {source_db: actual rows} - from the source databases
|
|
220
|
+
themselves (``count_source_rows``) or supplied by the caller.
|
|
221
|
+
``module_map``: {"module": {"source_dbs": [...]}} - registered entries not
|
|
222
|
+
matched by any module rule are listed as unmapped.
|
|
223
|
+
"""
|
|
224
|
+
actual_counts = actual_counts or {}
|
|
225
|
+
scope = scope or DEFAULT_SCOPE
|
|
226
|
+
registered_by_db: dict[str, dict[str, int]] = {}
|
|
227
|
+
for (source_db, _source_table), counts in store.count_by_source().items():
|
|
228
|
+
bucket = registered_by_db.setdefault(source_db, {"registered": 0, "verified": 0})
|
|
229
|
+
bucket["registered"] += counts["registered"]
|
|
230
|
+
bucket["verified"] += counts["verified"]
|
|
231
|
+
|
|
232
|
+
sources: list[dict[str, Any]] = []
|
|
233
|
+
visible = (
|
|
234
|
+
set(registered_by_db)
|
|
235
|
+
| set(actual_counts)
|
|
236
|
+
| {s for s, v in scope.items() if v.get("phase") == 2}
|
|
237
|
+
)
|
|
238
|
+
for source_db in sorted(visible):
|
|
239
|
+
reg = registered_by_db.get(source_db, {"registered": 0, "verified": 0})
|
|
240
|
+
actual = actual_counts.get(source_db)
|
|
241
|
+
info = scope.get(source_db, {})
|
|
242
|
+
sources.append(
|
|
243
|
+
{
|
|
244
|
+
"source_db": source_db,
|
|
245
|
+
"label": info.get("label", ""),
|
|
246
|
+
"phase": info.get("phase", 1),
|
|
247
|
+
"registered": reg["registered"],
|
|
248
|
+
"verified": reg["verified"],
|
|
249
|
+
"unverified": reg["registered"] - reg["verified"],
|
|
250
|
+
"actual_in_source": actual,
|
|
251
|
+
"coverage": (
|
|
252
|
+
round(reg["registered"] / actual, 4) if actual else None
|
|
253
|
+
),
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
report: dict[str, Any] = {"sources": sources}
|
|
258
|
+
if module_map:
|
|
259
|
+
unmapped = []
|
|
260
|
+
covered_dbs = {db for m in module_map.values() for db in m.get("source_dbs", [])}
|
|
261
|
+
for entry in store.iter_entries():
|
|
262
|
+
if entry.source_db not in covered_dbs:
|
|
263
|
+
unmapped.append(
|
|
264
|
+
{
|
|
265
|
+
"source_db": entry.source_db,
|
|
266
|
+
"native_code": entry.native_code,
|
|
267
|
+
"semantic_code": entry.semantic_code,
|
|
268
|
+
"name_zh": entry.name_zh,
|
|
269
|
+
}
|
|
270
|
+
)
|
|
271
|
+
report["module_map"] = module_map
|
|
272
|
+
report["unmapped_count"] = len(unmapped)
|
|
273
|
+
report["unmapped_sample"] = unmapped[:200]
|
|
274
|
+
return report
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""CLI for the unified indicator registry.
|
|
2
|
+
|
|
3
|
+
python -m fd_open_data_protocol.registry_cli <command> ...
|
|
4
|
+
|
|
5
|
+
Commands:
|
|
6
|
+
init-db create the registry_entries table on the registry database
|
|
7
|
+
register map source rows through an adapter and batch-upsert (idempotent
|
|
8
|
+
by native-code anchor; re-runs report "unchanged", new rows are
|
|
9
|
+
counted as increments)
|
|
10
|
+
verify mark gate-passing entries verified (rule + printed sample)
|
|
11
|
+
coverage per-source registered/actual report (+ phase-2 visibility,
|
|
12
|
+
+ unmapped-by-module audit)
|
|
13
|
+
export dump entries to JSON (verified-only by default) for consumers
|
|
14
|
+
(fd-open-data-mcp catalog, official-web db_stats)
|
|
15
|
+
|
|
16
|
+
DSN defaults come from $FD_REGISTRY_DSN (fd_open_data on the data machine)
|
|
17
|
+
and $FD_SOURCE_DSN (the source database being registered). ``--rows CSV``
|
|
18
|
+
replaces the source database entirely - tests and the shipped template
|
|
19
|
+
example run the full pipeline without any DB.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import random
|
|
27
|
+
import sys
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Optional
|
|
31
|
+
|
|
32
|
+
from fd_open_data_protocol.registry import (
|
|
33
|
+
count_source_rows,
|
|
34
|
+
coverage_report,
|
|
35
|
+
fetch_rows,
|
|
36
|
+
load_adapter,
|
|
37
|
+
map_rows,
|
|
38
|
+
read_rows_csv,
|
|
39
|
+
)
|
|
40
|
+
from fd_open_data_protocol.registry_store import (
|
|
41
|
+
BaseRegistryStore,
|
|
42
|
+
MemoryRegistryStore,
|
|
43
|
+
PostgresRegistryStore,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _registry_store(args: argparse.Namespace) -> BaseRegistryStore:
|
|
48
|
+
if getattr(args, "dry_run", False):
|
|
49
|
+
return MemoryRegistryStore()
|
|
50
|
+
dsn = args.registry_dsn or os.environ.get("FD_REGISTRY_DSN")
|
|
51
|
+
if not dsn:
|
|
52
|
+
sys.exit("error: --registry-dsn or $FD_REGISTRY_DSN is required")
|
|
53
|
+
return PostgresRegistryStore(dsn)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _load_adapters(paths: list[str]):
|
|
57
|
+
adapters = [load_adapter(p) for p in paths]
|
|
58
|
+
for a in adapters:
|
|
59
|
+
print(f"adapter: {a.source_db}.{a.source_table} (code column: {a.native_code_column})")
|
|
60
|
+
return adapters
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _print_sample(store: BaseRegistryStore, source_db: str, sample: int) -> None:
|
|
64
|
+
"""The sampling half of the "rule + sample" verification record."""
|
|
65
|
+
entries = [e for e in store.iter_entries(verified_only=True, source_db=source_db)]
|
|
66
|
+
if not entries:
|
|
67
|
+
print(f"verify sample [{source_db}]: no verified entries to sample")
|
|
68
|
+
return
|
|
69
|
+
picked = random.sample(entries, min(sample, len(entries)))
|
|
70
|
+
print(f"verify sample [{source_db}] ({len(picked)}/{len(entries)} verified):")
|
|
71
|
+
for e in picked:
|
|
72
|
+
print(
|
|
73
|
+
f" {e.semantic_code} | {e.native_code} | zh={e.name_zh} | en={e.name_en}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cmd_init_db(args: argparse.Namespace) -> int:
|
|
78
|
+
store = _registry_store(args)
|
|
79
|
+
store.init_schema()
|
|
80
|
+
print("registry_entries table ready (CREATE TABLE IF NOT EXISTS applied)")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cmd_register(args: argparse.Namespace) -> int:
|
|
85
|
+
store = _registry_store(args)
|
|
86
|
+
adapters = _load_adapters(args.adapter)
|
|
87
|
+
grand = {"new": 0, "updated": 0, "unchanged": 0, "demoted": 0}
|
|
88
|
+
for adapter in adapters:
|
|
89
|
+
if args.rows:
|
|
90
|
+
rows = read_rows_csv(args.rows)
|
|
91
|
+
else:
|
|
92
|
+
dsn = args.source_dsn or os.environ.get("FD_SOURCE_DSN")
|
|
93
|
+
if not dsn:
|
|
94
|
+
sys.exit(
|
|
95
|
+
f"error: adapter {adapter.source_db}.{adapter.source_table} needs "
|
|
96
|
+
"--source-dsn or $FD_SOURCE_DSN (or --rows CSV)"
|
|
97
|
+
)
|
|
98
|
+
rows = fetch_rows(adapter, dsn)
|
|
99
|
+
entries = map_rows(adapter, rows)
|
|
100
|
+
stats = store.upsert_entries(entries)
|
|
101
|
+
gate_passing = sum(
|
|
102
|
+
1
|
|
103
|
+
for e in entries
|
|
104
|
+
if e.semantic_code and e.name_zh and e.name_en
|
|
105
|
+
)
|
|
106
|
+
verified_now = 0
|
|
107
|
+
if args.auto_verify and not args.dry_run:
|
|
108
|
+
verified_now = store.set_verified(
|
|
109
|
+
source_db=adapter.source_db, verified_by=f"rule:naming-gate"
|
|
110
|
+
)
|
|
111
|
+
print(
|
|
112
|
+
f"{adapter.source_db}.{adapter.source_table}: source_rows={len(rows)} "
|
|
113
|
+
f"registered={stats.new} updated={stats.updated} unchanged={stats.unchanged} "
|
|
114
|
+
f"demoted={stats.demoted} | gate_passing={gate_passing} "
|
|
115
|
+
f"verified_now={verified_now}"
|
|
116
|
+
)
|
|
117
|
+
for key in grand:
|
|
118
|
+
grand[key] += getattr(stats, key)
|
|
119
|
+
if args.auto_verify and not args.dry_run:
|
|
120
|
+
_print_sample(store, adapter.source_db, args.sample)
|
|
121
|
+
print(json.dumps(grand, ensure_ascii=False))
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cmd_verify(args: argparse.Namespace) -> int:
|
|
126
|
+
store = _registry_store(args)
|
|
127
|
+
count = store.set_verified(
|
|
128
|
+
source_db=args.source_db, verified_by=args.by or "rule:naming-gate"
|
|
129
|
+
)
|
|
130
|
+
print(f"newly verified: {count}" + (f" (source_db={args.source_db})" if args.source_db else ""))
|
|
131
|
+
if args.source_db:
|
|
132
|
+
_print_sample(store, args.source_db, args.sample)
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cmd_coverage(args: argparse.Namespace) -> int:
|
|
137
|
+
store = _registry_store(args)
|
|
138
|
+
actual_counts: dict[str, int] = {}
|
|
139
|
+
if args.actual_json:
|
|
140
|
+
actual_counts = json.loads(Path(args.actual_json).read_text(encoding="utf-8"))
|
|
141
|
+
elif args.adapter:
|
|
142
|
+
for adapter in _load_adapters(args.adapter):
|
|
143
|
+
dsn = args.source_dsn or os.environ.get("FD_SOURCE_DSN")
|
|
144
|
+
if not dsn:
|
|
145
|
+
sys.exit("error: coverage with --adapter needs --source-dsn or $FD_SOURCE_DSN")
|
|
146
|
+
actual_counts[adapter.source_db] = count_source_rows(adapter, dsn)
|
|
147
|
+
module_map = (
|
|
148
|
+
json.loads(Path(args.module_map).read_text(encoding="utf-8"))
|
|
149
|
+
if args.module_map
|
|
150
|
+
else None
|
|
151
|
+
)
|
|
152
|
+
report = coverage_report(store, actual_counts, module_map=module_map)
|
|
153
|
+
text = json.dumps(report, ensure_ascii=False, indent=2)
|
|
154
|
+
if args.out:
|
|
155
|
+
Path(args.out).write_text(text, encoding="utf-8")
|
|
156
|
+
print(f"coverage report written: {args.out}")
|
|
157
|
+
print(text)
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def cmd_export(args: argparse.Namespace) -> int:
|
|
162
|
+
store = _registry_store(args)
|
|
163
|
+
entries = [
|
|
164
|
+
e.model_dump()
|
|
165
|
+
for e in store.iter_entries(verified_only=not args.all, source_db=args.source_db)
|
|
166
|
+
]
|
|
167
|
+
payload = {
|
|
168
|
+
"as_of": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
169
|
+
"verified_only": not args.all,
|
|
170
|
+
"count": len(entries),
|
|
171
|
+
"entries": entries,
|
|
172
|
+
}
|
|
173
|
+
text = json.dumps(payload, ensure_ascii=False, indent=1)
|
|
174
|
+
if args.out:
|
|
175
|
+
Path(args.out).write_text(text, encoding="utf-8")
|
|
176
|
+
print(f"exported {len(entries)} entries -> {args.out}")
|
|
177
|
+
else:
|
|
178
|
+
print(text)
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
183
|
+
parser = argparse.ArgumentParser(
|
|
184
|
+
prog="fd-registry", description="Unified indicator registry CLI"
|
|
185
|
+
)
|
|
186
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
187
|
+
|
|
188
|
+
def common(p: argparse.ArgumentParser, registry: bool = True) -> None:
|
|
189
|
+
if registry:
|
|
190
|
+
p.add_argument("--registry-dsn", help="registry DB DSN (default $FD_REGISTRY_DSN)")
|
|
191
|
+
p.add_argument("--dry-run", action="store_true", help="use in-memory store, write nothing")
|
|
192
|
+
|
|
193
|
+
p = sub.add_parser("init-db", help="create registry_entries")
|
|
194
|
+
common(p)
|
|
195
|
+
p.set_defaults(func=cmd_init_db)
|
|
196
|
+
|
|
197
|
+
p = sub.add_parser("register", help="batch-register one or more adapters")
|
|
198
|
+
common(p)
|
|
199
|
+
p.add_argument("--adapter", action="append", required=True, help="adapter YAML (repeatable)")
|
|
200
|
+
p.add_argument("--source-dsn", help="source DB DSN (default $FD_SOURCE_DSN)")
|
|
201
|
+
p.add_argument("--rows", help="CSV rows file instead of a live source DB")
|
|
202
|
+
p.add_argument("--auto-verify", action="store_true",
|
|
203
|
+
help="after upsert, verify all gate-passing entries (rule:naming-gate)")
|
|
204
|
+
p.add_argument("--sample", type=int, default=5, help="sample size printed as verify record")
|
|
205
|
+
p.set_defaults(func=cmd_register)
|
|
206
|
+
|
|
207
|
+
p = sub.add_parser("verify", help="mark gate-passing entries verified")
|
|
208
|
+
common(p)
|
|
209
|
+
p.add_argument("--source-db", help="restrict to one source_db")
|
|
210
|
+
p.add_argument("--by", help="verification record (default rule:naming-gate)")
|
|
211
|
+
p.add_argument("--sample", type=int, default=5)
|
|
212
|
+
p.set_defaults(func=cmd_verify)
|
|
213
|
+
|
|
214
|
+
p = sub.add_parser("coverage", help="registered/actual coverage report")
|
|
215
|
+
common(p)
|
|
216
|
+
p.add_argument("--adapter", action="append", help="adapter(s) to count actual source rows")
|
|
217
|
+
p.add_argument("--source-dsn", help="source DB DSN for actual counts")
|
|
218
|
+
p.add_argument("--actual-json", help="JSON {source_db: count} instead of live counts")
|
|
219
|
+
p.add_argument("--module-map", help="JSON module map for unmapped audit")
|
|
220
|
+
p.add_argument("--out", help="also write the report to this file")
|
|
221
|
+
p.set_defaults(func=cmd_coverage)
|
|
222
|
+
|
|
223
|
+
p = sub.add_parser("export", help="dump entries (verified-only by default)")
|
|
224
|
+
common(p)
|
|
225
|
+
p.add_argument("--out", help="output JSON file (default stdout)")
|
|
226
|
+
p.add_argument("--all", action="store_true", help="include unverified entries")
|
|
227
|
+
p.add_argument("--source-db", help="restrict to one source_db")
|
|
228
|
+
p.set_defaults(func=cmd_export)
|
|
229
|
+
|
|
230
|
+
return parser
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def main(argv: Optional[list] = None) -> int:
|
|
234
|
+
args = build_parser().parse_args(argv)
|
|
235
|
+
return args.func(args)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
raise SystemExit(main())
|