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.
Files changed (31) hide show
  1. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/PKG-INFO +5 -1
  2. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/README.md +2 -0
  3. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/__init__.py +1 -1
  4. fd_open_data_protocol-0.3.1/fd_open_data_protocol/registry.py +274 -0
  5. fd_open_data_protocol-0.3.1/fd_open_data_protocol/registry_cli.py +239 -0
  6. fd_open_data_protocol-0.3.1/fd_open_data_protocol/registry_store.py +458 -0
  7. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/schema.py +120 -1
  8. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol.egg-info/PKG-INFO +5 -1
  9. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol.egg-info/SOURCES.txt +6 -0
  10. fd_open_data_protocol-0.3.1/fd_open_data_protocol.egg-info/entry_points.txt +2 -0
  11. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol.egg-info/requires.txt +3 -0
  12. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/pyproject.toml +4 -0
  13. fd_open_data_protocol-0.3.1/tests/test_registry_pipeline.py +209 -0
  14. fd_open_data_protocol-0.3.1/tests/test_registry_schema.py +91 -0
  15. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/constants.py +0 -0
  16. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/loader.py +0 -0
  17. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/provider.py +0 -0
  18. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/vocabulary/concepts.yaml +0 -0
  19. {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
  20. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/vocabulary/entity_types.yaml +0 -0
  21. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/vocabulary/event_types.yaml +0 -0
  22. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/vocabulary/qualifiers.yaml +0 -0
  23. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/vocabulary/relation_types.yaml +0 -0
  24. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/vocabulary/units.yaml +0 -0
  25. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/fd_open_data_protocol/vocabulary.py +0 -0
  26. {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
  27. {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
  28. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/setup.cfg +0 -0
  29. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/tests/test_loader.py +0 -0
  30. {fd_open_data_protocol-0.3.0 → fd_open_data_protocol-0.3.1}/tests/test_schema_vocabulary.py +0 -0
  31. {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.0
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
@@ -5,4 +5,4 @@ concept hints + fetch reference) so that fd-open-data-mcp - or any consumer -
5
5
  can ingest it via ``register_datasource``. Ship one manifest file -> the
6
6
  datasource is added; no consumer-side wiring.
7
7
  """
8
- __version__ = "0.3.0"
8
+ __version__ = "0.3.1"
@@ -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())