agentdata 0.3.26__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. agentdata/__init__.py +33 -0
  2. agentdata/__main__.py +4 -0
  3. agentdata/cache.py +115 -0
  4. agentdata/cli.py +173 -0
  5. agentdata/client.py +158 -0
  6. agentdata/company/__init__.py +3 -0
  7. agentdata/company/cf.py +145 -0
  8. agentdata/company/ch_zefix.py +100 -0
  9. agentdata/company/cli.py +243 -0
  10. agentdata/company/co_rues.py +72 -0
  11. agentdata/company/edgar_cik.py +84 -0
  12. agentdata/company/edgar_fts.py +101 -0
  13. agentdata/company/faces.py +746 -0
  14. agentdata/company/fdic_bankfind.py +107 -0
  15. agentdata/company/fi_ytj.py +89 -0
  16. agentdata/company/fr_recherche.py +124 -0
  17. agentdata/company/fts.py +121 -0
  18. agentdata/company/gleif.py +178 -0
  19. agentdata/company/kr_hometax.py +112 -0
  20. agentdata/company/no_brreg.py +124 -0
  21. agentdata/company/ror.py +105 -0
  22. agentdata/company/ted.py +117 -0
  23. agentdata/company/usaspending.py +124 -0
  24. agentdata/company/vies.py +66 -0
  25. agentdata/daterange.py +35 -0
  26. agentdata/economy/__init__.py +10 -0
  27. agentdata/economy/bis.py +132 -0
  28. agentdata/economy/cli.py +199 -0
  29. agentdata/economy/ecb.py +117 -0
  30. agentdata/economy/eurostat.py +163 -0
  31. agentdata/economy/faces.py +573 -0
  32. agentdata/economy/fred.py +92 -0
  33. agentdata/economy/ilo_stat.py +99 -0
  34. agentdata/economy/imf_sdmx.py +133 -0
  35. agentdata/economy/un_comtrade.py +199 -0
  36. agentdata/economy/unsd_sdg.py +154 -0
  37. agentdata/economy/wb.py +208 -0
  38. agentdata/economy/who_xmart.py +177 -0
  39. agentdata/entries.py +30 -0
  40. agentdata/errors.py +38 -0
  41. agentdata/esg/__init__.py +5 -0
  42. agentdata/esg/cli.py +180 -0
  43. agentdata/esg/climatetrace.py +260 -0
  44. agentdata/esg/eiti.py +187 -0
  45. agentdata/esg/epa_frs.py +138 -0
  46. agentdata/esg/euasl.py +75 -0
  47. agentdata/esg/eutl.py +66 -0
  48. agentdata/esg/faces.py +498 -0
  49. agentdata/esg/paris_mou.py +240 -0
  50. agentdata/esg/thetis_mrv.py +67 -0
  51. agentdata/healthcare/__init__.py +7 -0
  52. agentdata/healthcare/au_tga.py +142 -0
  53. agentdata/healthcare/ca_dpd.py +123 -0
  54. agentdata/healthcare/ca_mdall.py +155 -0
  55. agentdata/healthcare/cli.py +375 -0
  56. agentdata/healthcare/ctgov.py +101 -0
  57. agentdata/healthcare/dailymed.py +102 -0
  58. agentdata/healthcare/epmc.py +108 -0
  59. agentdata/healthcare/eudamed.py +124 -0
  60. agentdata/healthcare/faces.py +811 -0
  61. agentdata/healthcare/fda_crl.py +86 -0
  62. agentdata/healthcare/fda_devices.py +199 -0
  63. agentdata/healthcare/fda_drugs.py +156 -0
  64. agentdata/healthcare/fda_recalls.py +137 -0
  65. agentdata/healthcare/gb_mhra.py +137 -0
  66. agentdata/healthcare/gudid.py +84 -0
  67. agentdata/healthcare/mesh.py +89 -0
  68. agentdata/healthcare/rasff.py +164 -0
  69. agentdata/healthcare/rxnav.py +67 -0
  70. agentdata/healthcare/safety_gate.py +175 -0
  71. agentdata/healthcare/shortages.py +107 -0
  72. agentdata/legal/__init__.py +4 -0
  73. agentdata/legal/afdb.py +93 -0
  74. agentdata/legal/ca_sema.py +143 -0
  75. agentdata/legal/cli.py +312 -0
  76. agentdata/legal/co_sic.py +70 -0
  77. agentdata/legal/courtlistener.py +77 -0
  78. agentdata/legal/csl.py +139 -0
  79. agentdata/legal/dgcomp.py +125 -0
  80. agentdata/legal/ecfr.py +129 -0
  81. agentdata/legal/faces.py +700 -0
  82. agentdata/legal/federalregister.py +188 -0
  83. agentdata/legal/gb_cma.py +117 -0
  84. agentdata/legal/gb_tna_caselaw.py +245 -0
  85. agentdata/legal/idb.py +85 -0
  86. agentdata/legal/lda.py +147 -0
  87. agentdata/legal/sipri.py +178 -0
  88. agentdata/legal/ukec.py +148 -0
  89. agentdata/legal/wb.py +92 -0
  90. agentdata/procurement/__init__.py +5 -0
  91. agentdata/procurement/br_pncp.py +126 -0
  92. agentdata/procurement/cli.py +155 -0
  93. agentdata/procurement/co_secop.py +88 -0
  94. agentdata/procurement/faces.py +239 -0
  95. agentdata/procurement/ny_open_data.py +82 -0
  96. agentdata/procurement/ungm.py +137 -0
  97. agentdata/procurement/wb_proc.py +84 -0
  98. agentdata/research/__init__.py +4 -0
  99. agentdata/research/biorxiv.py +66 -0
  100. agentdata/research/cli.py +129 -0
  101. agentdata/research/crossref.py +121 -0
  102. agentdata/research/faces.py +349 -0
  103. agentdata/research/gb_gtr.py +58 -0
  104. agentdata/research/icite.py +52 -0
  105. agentdata/research/nih_reporter.py +143 -0
  106. agentdata/research/nsf.py +68 -0
  107. agentdata/research/openalex.py +145 -0
  108. agentdata/research/opencitations.py +50 -0
  109. agentdata/security/__init__.py +3 -0
  110. agentdata/security/cisa_kev.py +42 -0
  111. agentdata/security/cli.py +37 -0
  112. agentdata/security/faces.py +91 -0
  113. agentdata/security/osv.py +72 -0
  114. agentdata/transport.py +541 -0
  115. agentdata-0.3.26.dist-info/METADATA +266 -0
  116. agentdata-0.3.26.dist-info/RECORD +120 -0
  117. agentdata-0.3.26.dist-info/WHEEL +5 -0
  118. agentdata-0.3.26.dist-info/entry_points.txt +10 -0
  119. agentdata-0.3.26.dist-info/licenses/LICENSE +201 -0
  120. agentdata-0.3.26.dist-info/top_level.txt +1 -0
agentdata/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """AgentData SDK — two API families over domain data: corpus (hosted) + third-party realtime enrichment (local, keyless).
2
+
3
+ Contract: docs/domains/api.md (cross-domain charter) + per-domain API docs. Zero third-party dependencies (stdlib-only, floor 3.10).
4
+
5
+ from agentdata import Client
6
+ c = Client() # zero config: enrichment runs locally, usable immediately
7
+ c.healthcare.rxnav_rxcui_by_name(name="atorvastatin") # flat panel command (the only surface since 0.3.0)
8
+ c.healthcare.fda_drug_shortages(search='openfda.manufacturer_name.exact:"Fresenius Kabi"')
9
+ c.company.gleif_lei_records(lei="5493001KJTIIGC8Y1R12") # company likewise (gleif parent face split in two)
10
+ c.company.gleif_direct_parent(lei="5493001KJTIIGC8Y1R12")
11
+ c.company.ted_notices_search(query="organisation-name-buyer=Siemens* AND organisation-country-buyer=DEU")
12
+ c.esg.eiti_payments(eiti_id="11111111-…") # esg likewise
13
+ c.esg.climatetrace_sources(owner_ids="E100000001492", year=2023)
14
+ c.legal.gb_ec_donations(query="Keystone") # legal likewise (UK EC donations/loans split in two + TNA case law)
15
+ c.legal.dgcomp_cases_search(name="Google", instrument="SA")
16
+ c.legal.lda_filings(client_name="Google LLC", limit=25) # page_size hard-clamped to 25 upstream
17
+ c.procurement.br_pncp_contracts(data_inicial="20260101", cnpj="…") # procurement, seventh domain (founding pair + wb/NY trio + UNGM/NY OGS)
18
+ c.economy.wb_wdi_values(country="CHN", indicator="NY.GDP.MKTP.CD", date="2020:2023") # economy, eighth domain (country denominators)
19
+ c2 = Client(base_url="https://…", api_key="mk_live_…") # corpus family: hosted
20
+ c2.company.search(filters={"qtype": "专精特新小巨人"}, limit=100)
21
+
22
+ CLI: eight domains executable (`agentdata healthcare fda_drug_shortages --search …`
23
+ `agentdata company ted_notices_search --query 'organisation-name-buyer=Siemens*'`,
24
+ `agentdata esg climatetrace_sources --owner-ids E…`, `agentdata procurement br_pncp_contracts --data-inicial 20260101`,
25
+ `agentdata economy wb_wdi_values --country CHN --indicator NY.GDP.MKTP.CD`).
26
+ Upstream parameter names verbatim + our defaults.
27
+ """
28
+ from .client import Client
29
+ from .errors import SdkError
30
+
31
+ __version__ = "0.3.26" # version bumps with each release; per-batch narratives live in the internal ledger (MAPPING.md) and MR descriptions (not exported)
32
+
33
+ __all__ = ["Client", "SdkError", "__version__"]
agentdata/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
agentdata/cache.py ADDED
@@ -0,0 +1,115 @@
1
+ """agentdata.cache — per-user local query cache (SQLite; TTL declared per op, api.md section 9.3).
2
+
3
+ Location ruling: defaults to the OS user cache directory
4
+ Windows %LOCALAPPDATA%\\agentdata\\cache.db (falls back to ~/AppData/Local without LOCALAPPDATA)
5
+ POSIX ~/.cache/agentdata/cache.db
6
+ env AGENTDATA_CACHE_DIR overrides; an unwritable directory falls back to the system temp dir;
7
+ if temp is unwritable too, an actionable error is raised (hinting --no-cache or
8
+ AGENTDATA_CACHE_DIR). The SDK cache is independent of any internal datastore.
9
+
10
+ TTL semantics: None=permanent (immutable keys: drug name -> RxCUI); a positive number=seconds
11
+ (mutable records: GUDID 7 days). Expired entries are evicted lazily (an expired read counts
12
+ as a miss).
13
+ """
14
+ import json
15
+ import os
16
+ import pathlib
17
+ import sqlite3
18
+ import tempfile
19
+ import time
20
+ from datetime import datetime, timezone
21
+
22
+ DDL = """
23
+ CREATE TABLE IF NOT EXISTS thirdparty_cache (
24
+ provider TEXT NOT NULL,
25
+ cache_key TEXT NOT NULL,
26
+ payload TEXT NOT NULL,
27
+ fetched_at INTEGER NOT NULL, -- epoch ms
28
+ ttl_sec INTEGER, -- NULL = permanent
29
+ PRIMARY KEY (provider, cache_key)
30
+ )
31
+ """
32
+
33
+
34
+ def default_cache_dir() -> pathlib.Path:
35
+ env = os.environ.get("AGENTDATA_CACHE_DIR")
36
+ if env:
37
+ return pathlib.Path(env)
38
+ if os.name == "nt":
39
+ base = os.environ.get("LOCALAPPDATA") or str(pathlib.Path.home() / "AppData" / "Local")
40
+ return pathlib.Path(base) / "agentdata"
41
+ return pathlib.Path.home() / ".cache" / "agentdata"
42
+
43
+
44
+ def _open_or_none(dir_path: pathlib.Path):
45
+ """Open cache.db under dir_path; return None on failure (the caller falls back to the next candidate)."""
46
+ try:
47
+ dir_path.mkdir(parents=True, exist_ok=True)
48
+ p = dir_path / "cache.db"
49
+ conn = sqlite3.connect(str(p), timeout=5)
50
+ conn.execute("PRAGMA busy_timeout = 5000")
51
+ conn.execute(DDL)
52
+ conn.execute("INSERT OR REPLACE INTO thirdparty_cache (provider, cache_key, payload, fetched_at, ttl_sec) VALUES ('__probe','__probe','1',?,NULL)", (int(time.time() * 1000),))
53
+ conn.commit()
54
+ return conn
55
+ except OSError:
56
+ return None
57
+
58
+
59
+ class Cache:
60
+ def __init__(self, cache_dir=None, enabled=True):
61
+ self.enabled = enabled and True
62
+ self._conn = None
63
+ if not enabled:
64
+ return
65
+ cands = [pathlib.Path(cache_dir) if cache_dir else default_cache_dir(),
66
+ pathlib.Path(tempfile.gettempdir()) / "agentdata"]
67
+ last_err = None
68
+ for c in cands:
69
+ self._conn = _open_or_none(c)
70
+ if self._conn is not None:
71
+ return
72
+ last_err = c
73
+ raise RuntimeError(
74
+ f"Cache directory not writable (tried {cands}). Run with --no-cache, or point AGENTDATA_CACHE_DIR at a writable directory. (Last failed at {last_err})"
75
+ )
76
+
77
+ # —— hit -> {'payload': JSON as stored, 'fetched_at': epoch ms, 'fetched_at_iso': ISO8601Z}; miss/expired -> None ——
78
+ def get(self, provider: str, cache_key: str, ttl_sec):
79
+ if not self.enabled:
80
+ return None
81
+ row = self._conn.execute(
82
+ "SELECT payload, fetched_at, ttl_sec FROM thirdparty_cache WHERE provider=? AND cache_key=?",
83
+ (provider, cache_key),
84
+ ).fetchone()
85
+ if row is None:
86
+ return None
87
+ payload, fetched_at, row_ttl = row
88
+ if row_ttl is not None and time.time() * 1000 - fetched_at >= row_ttl * 1000:
89
+ return None
90
+ return {
91
+ "payload": json.loads(payload),
92
+ "fetched_at": fetched_at,
93
+ "fetched_at_iso": datetime.fromtimestamp(fetched_at / 1000, tz=timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
94
+ }
95
+
96
+ def set(self, provider: str, cache_key: str, payload, ttl_sec):
97
+ if not self.enabled:
98
+ return
99
+ self._conn.execute(
100
+ "INSERT OR REPLACE INTO thirdparty_cache (provider, cache_key, payload, fetched_at, ttl_sec) VALUES (?,?,?,?,?)",
101
+ (provider, cache_key, json.dumps(payload, ensure_ascii=False), int(time.time() * 1000), ttl_sec),
102
+ )
103
+ self._conn.commit()
104
+
105
+ def delete(self, provider: str, cache_key: str):
106
+ """Single-key delete: an occasional upstream "200 empty set" must not stand as cache authority
107
+ (EUDAMED primaryDi un-indexed face); the provider drops the entry as soon as it sees emptiness."""
108
+ if not self.enabled:
109
+ return
110
+ cur = self._conn.execute(
111
+ "DELETE FROM thirdparty_cache WHERE provider=? AND cache_key=?", (provider, cache_key)
112
+ )
113
+ self._conn.commit()
114
+ return cur.rowcount
115
+
agentdata/cli.py ADDED
@@ -0,0 +1,173 @@
1
+ """agentdata.cli — the dual command-line entry (console_scripts `agentdata` + `python -m agentdata`).
2
+
3
+ Exit-code contract: 0 success / 1 upstream-availability family / 2 usage family (api.md section 9.4).
4
+ enrichment works with zero config; the corpus family needs --base-url/--api-key (or env
5
+ AGENTDATA_BASE_URL / AGENTDATA_API_KEY).
6
+ """
7
+ import argparse
8
+ import json
9
+ import sys
10
+ import uuid
11
+
12
+ from . import __version__
13
+ from .client import Client
14
+ from .company import cli as company_cli
15
+ from .economy import cli as economy_cli
16
+ from .esg import cli as esg_cli
17
+ from .healthcare import cli as healthcare_cli
18
+ from .legal import cli as legal_cli
19
+ from .procurement import cli as procurement_cli
20
+ from .research import cli as research_cli
21
+ from .security import cli as security_cli
22
+
23
+ # 0.2.6 per-domain split: the domain command registrars (register(v, g)) live in each domain's cli.py;
24
+ # the root keeps only the shared parts plus the search/read dispatch
25
+ # 0.3.10 domain order = lexicographic (DOMAIN_ORDER is the source of truth — the build_parser loop, per-domain face order, and site navigation all follow it; the sort key is the English identifier)
26
+ DOMAIN_ORDER = ("company", "economy", "esg", "healthcare", "legal", "procurement", "research", "security")
27
+ _CLI = {"company": company_cli, "economy": economy_cli, "esg": esg_cli,
28
+ "healthcare": healthcare_cli, "legal": legal_cli, "procurement": procurement_cli,
29
+ "research": research_cli, "security": security_cli}
30
+ from .errors import SdkError
31
+
32
+
33
+ def _filter_arg(pairs):
34
+ """--filter 'k=v' / 'k=v1|v2' / 'k>=v' / 'k>v' / 'k<=v' / 'k<v' -> a DSL node."""
35
+ filters = {}
36
+ for raw in pairs or []:
37
+ for op_key, op in ((">=", "gte"), ("<=", "lte"), (">", "gt"), ("<", "lt")):
38
+ if op_key in raw:
39
+ k, _, v = raw.partition(op_key)
40
+ filters.setdefault(k, {"range": {}})["range"][op] = _cast(v)
41
+ break
42
+ else:
43
+ k, _, v = raw.partition("=")
44
+ if "|" in v:
45
+ filters[k] = {"any": v.split("|")}
46
+ else:
47
+ filters[k] = {"eq": _cast(v)}
48
+ return filters
49
+
50
+
51
+ def _cast(v):
52
+ if v.lower() in ("true", "false"):
53
+ return v.lower() == "true"
54
+ try:
55
+ return int(v)
56
+ except ValueError:
57
+ return v
58
+
59
+
60
+ def _leaf_globals():
61
+ """Global flags placed after a subcommand (the README example shape `agentdata company search … --api-key x`).
62
+ The dest gets a sub_ prefix + default=argparse.SUPPRESS: when argparse enters a subparser it opens
63
+ a separate namespace, parses there and writes the whole thing back into the parent namespace, so a
64
+ dest that carries a default overwrites the value the parent just parsed —
65
+ a global flag between the domain and the verb (`agentdata healthcare --no-cache epmc_search …`)
66
+ is therefore swallowed silently (reproduced by measurement on 2026-10-20). Under SUPPRESS a dest
67
+ that never appeared does not land in the namespace, so the parent's value survives; every read
68
+ uses getattr(…, default)."""
69
+ g = argparse.ArgumentParser(add_help=False)
70
+ g.add_argument("--base-url", dest="sub_base_url", default=argparse.SUPPRESS, help="Hosted API base URL (env AGENTDATA_BASE_URL)")
71
+ g.add_argument("--api-key", dest="sub_api_key", default=argparse.SUPPRESS, help="Bearer PAT (env AGENTDATA_API_KEY)")
72
+ g.add_argument("--cache-dir", dest="sub_cache_dir", default=argparse.SUPPRESS, help="Cache directory (env AGENTDATA_CACHE_DIR)")
73
+ g.add_argument("--no-cache", dest="sub_no_cache", action="store_true", default=argparse.SUPPRESS, help="Bypass the cache for this call")
74
+ g.add_argument("--execution", dest="sub_execution", default=argparse.SUPPRESS, choices=["local", "hosted", "auto"], help="Execution location (default auto: realtime queries run locally)")
75
+ return g
76
+
77
+
78
+ def build_parser():
79
+ p = argparse.ArgumentParser(prog="agentdata", description="AgentData SDK CLI (corpus queries via the hosted API + realtime queries executed locally)")
80
+ p.add_argument("--version", action="version", version=f"agentdata {__version__}")
81
+ p.add_argument("--base-url", default=None, help="Hosted API base URL (env AGENTDATA_BASE_URL)")
82
+ p.add_argument("--api-key", default=None, help="Bearer PAT (env AGENTDATA_API_KEY)")
83
+ p.add_argument("--cache-dir", default=None, help="Cache directory (env AGENTDATA_CACHE_DIR)")
84
+ p.add_argument("--no-cache", action="store_true", help="Bypass the cache for this call")
85
+ p.add_argument("--execution", default="auto", choices=["local", "hosted", "auto"])
86
+ sub = p.add_subparsers(dest="domain", required=True)
87
+ for domain in DOMAIN_ORDER:
88
+ d = sub.add_parser(domain, parents=[_leaf_globals()])
89
+ v = d.add_subparsers(dest="verb", required=True)
90
+ _CLI[domain].register(v, _leaf_globals)
91
+
92
+ s = v.add_parser("search", parents=[_leaf_globals()], help="Corpus search (hosted)",
93
+ description="Corpus search (hosted, PAT required): filters/record_kind/level/pagination")
94
+ s.add_argument("--filter", action="append", default=[], metavar="K=V", help="Filter K=V (repeatable)")
95
+ s.add_argument("--record-kind", default=None, help="Record kind (closed set from the domain spec)")
96
+ s.add_argument("--facets", default=None, help="Comma-separated fields (facet counts)")
97
+ s.add_argument("--level", default="tier1", choices=["tier1", "tier2", "tier3"], help="T-layer depth (default tier1)")
98
+ s.add_argument("--limit", type=int, default=50, help="Page size (default 50, ≤1000)")
99
+ s.add_argument("--cursor", default=None, help="Pagination cursor (feed next_cursor from the response)")
100
+ s.add_argument("--mode", default=None, choices=["screen"], help="Special mode (screen: batch screening; requires --input)")
101
+ s.add_argument("--input", default=None, help="Input JSONL file for batch-screening mode (--mode screen)")
102
+
103
+ rd = v.add_parser("read", parents=[_leaf_globals()], help="Corpus record (hosted)",
104
+ description="Corpus single-record read (hosted): id + T-layer depth")
105
+ rd.add_argument("--id", required=True, help="Subject id")
106
+ rd.add_argument("--level", default="tier1", choices=["tier1", "tier2", "tier3"], help="T-layer depth (default tier1)")
107
+ return p
108
+
109
+
110
+ # 0.2.6 per-domain split: the source of truth for the flat dispatch table is each domain's cli.FLAT,
111
+ # merged here (the search/read dispatch stays in this module's main)
112
+ _FLAT_DISPATCH = {}
113
+ for _m in _CLI.values():
114
+ _FLAT_DISPATCH.update(_m.FLAT)
115
+ assert len(_FLAT_DISPATCH) == 117, f"flat dispatch {len(_FLAT_DISPATCH)} != 117 (117 faces; 0.3.2 esg +2 + 0.3.3 European registries +5 + 0.3.4 FR recherche +1 + 0.3.5 esg port-state +5 + 0.3.6 World Bank/NY +3 + 0.3.8 legal TNA caselaw +2 + 0.3.12 UNGM/NY OGS collection +2 + 0.3.14 CO RUES/KR hometax +2 + 0.3.16 legal co_sic +1 + healthcare gb_mhra +2 + 0.3.19 healthcare CA DPD/MDALL +4 + rasff/safety_gate +3 + 0.3.21 legal debarment rosters afdb/ca_sema/idb/wb +4 + 0.3.22 transport compliance euasl_datasets +1 + 0.3.23 economy domain (wb/eurostat/ecb/fred/who_xmart/un_comtrade/ilo_stat) +10 + 0.3.24 economy expansion (imf_sdmx/bis/unsd_sdg/wb_ids) +5; the csl_screening alias was removed in 0.3.0)"
116
+ # 0.3.10 jurisdiction chapter: face -> jurisdiction (default = the issuing authority's jurisdiction; institutions covering global phenomena = GLOBAL; vocabulary = ISO-3166 alpha-2 + EU + GLOBAL).
117
+ # The per-domain source of truth is each domain cli.py's COUNTRY (same face set as FLAT); the root only merges them; the success envelope carries a jurisdiction chapter (search/read are hosted mixed-jurisdiction corpus services — unstamped)
118
+ _JURISDICTION = {f: cc for _m in _CLI.values() for f, cc in _m.COUNTRY.items()}
119
+ assert set(_JURISDICTION) == set(_FLAT_DISPATCH), f"jurisdiction coverage {len(_JURISDICTION)} != dispatch faces {len(_FLAT_DISPATCH)}"
120
+ def _merge_globals(a):
121
+ """Merge global flags: the ones placed after the subcommand (sub_*) win, the top level is the
122
+ fallback — both placements work (the README example shape). sub_* are all SUPPRESS: if one never
123
+ appeared the attribute does not exist, so every read falls back via getattr."""
124
+ a.base_url = getattr(a, "sub_base_url", None) or a.base_url
125
+ a.api_key = getattr(a, "sub_api_key", None) or a.api_key
126
+ a.cache_dir = getattr(a, "sub_cache_dir", None) or a.cache_dir
127
+ a.no_cache = a.no_cache or getattr(a, "sub_no_cache", False)
128
+ a.execution = getattr(a, "sub_execution", None) or a.execution
129
+ for k in ("sub_base_url", "sub_api_key", "sub_cache_dir", "sub_no_cache", "sub_execution"):
130
+ if hasattr(a, k):
131
+ delattr(a, k)
132
+ return a
133
+
134
+
135
+
136
+
137
+ def main(argv=None) -> int:
138
+ # Force UTF-8 for piped/redirected output: on Chinese Windows the pipe defaults to CP936 and agents
139
+ # or redirected consumers would receive bad bytes; the interactive console is already UTF-8 under
140
+ # PEP 528, so reconfigure is imperceptible there.
141
+ sys.stdout.reconfigure(encoding="utf-8")
142
+ sys.stderr.reconfigure(encoding="utf-8")
143
+ a = _merge_globals(build_parser().parse_args(argv))
144
+ try:
145
+ c = Client(a.base_url, a.api_key, cache_dir=a.cache_dir, no_cache=a.no_cache,
146
+ execution=a.execution)
147
+ d = getattr(c, a.domain)
148
+ flat = _FLAT_DISPATCH.get(a.verb)
149
+ if flat is not None:
150
+ out = getattr(d, a.verb)(**flat(a))
151
+ out["jurisdiction"] = _JURISDICTION[a.verb] # 0.3.10 jurisdiction chapter: downstream can route by country without digging through docs
152
+ elif a.verb == "search":
153
+ screen_inputs = None
154
+ if a.mode == "screen":
155
+ if not a.input:
156
+ raise SdkError("usage_error", detail="--mode screen requires --input x.jsonl")
157
+ with open(a.input, encoding="utf-8") as f:
158
+ screen_inputs = [json.loads(line) for line in f if line.strip()]
159
+ out = d.search(filters=_filter_arg(a.filter), record_kind=a.record_kind,
160
+ facets=a.facets.split(",") if a.facets else None, level=a.level,
161
+ limit=a.limit, cursor=a.cursor, mode=a.mode, screen_inputs=screen_inputs)
162
+ else:
163
+ out = d.read(a.id, level=a.level)
164
+ print(json.dumps(out, ensure_ascii=False, indent=2))
165
+ return 0
166
+ except SdkError as e:
167
+ # api.md section 9.5: request_id runs through both the success and error states (mirroring the
168
+ # Node cli.mjs randomUUID emitted in both states)
169
+ print(json.dumps({"request_id": str(uuid.uuid4()), **e.to_dict()}, ensure_ascii=False, indent=2), file=sys.stderr)
170
+ return e.exit_code
171
+ except RuntimeError as e: # actionable config errors such as an unwritable cache directory
172
+ print(f"Config error: {e}", file=sys.stderr)
173
+ return 2
agentdata/client.py ADDED
@@ -0,0 +1,158 @@
1
+ """agentdata.client — one object, both families share a shape: enrichment (local, zero config) + corpus (hosted, base_url+api_key).
2
+
3
+ Contract: docs/domains/api.md (section 8 corpus / section 9 enrichment capability axis).
4
+ - The quartet is mandatory: result / reason / evidence / source.
5
+ - Enrichment rows carry source={provider, execution, from_cache, observed_at|fetched_at} (the cache is not a backend).
6
+ - execution axis local|hosted|auto: auto->local (all current capabilities are keyless); requesting a keyless
7
+ capability as hosted -> capability_unavailable, never a silent rerouting (api.md section 9.2).
8
+ - request_id: for local execution the SDK generates one (uuid4) and puts it on the envelope; for hosted corpus
9
+ the server-side request_id wins.
10
+ """
11
+ import os
12
+ import uuid
13
+ import json
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+
18
+ from .company.faces import CompanyFaces
19
+ from .economy.faces import EconomyFaces
20
+ from .esg.faces import EsgFaces
21
+ from .healthcare.faces import HealthcareFaces
22
+ from .legal.faces import LegalFaces
23
+ from .procurement.faces import ProcurementFaces
24
+ from .research.faces import ResearchFaces
25
+ from .security.faces import SecurityFaces
26
+ from .errors import SdkError
27
+ from .cache import Cache
28
+ from .transport import enrich_source, resolve_execution # three helpers live in transport (0.3.0 verb-layer removal; _tty_tag now domain-internal)
29
+
30
+ CONTRACT = "docs/domains/api.md"
31
+
32
+
33
+ class Client:
34
+ def __init__(self, base_url=None, api_key=None, *, execution="auto", cache_dir=None,
35
+ no_cache=False, timeout=20.0):
36
+ if execution not in ("local", "hosted", "auto"):
37
+ raise SdkError("usage_error", detail=f"execution must be local|hosted|auto, got '{execution}'")
38
+ self.default_execution = execution
39
+ self.no_cache = bool(no_cache)
40
+ self.timeout = timeout
41
+ self._cache = Cache(cache_dir, enabled=not no_cache)
42
+ self.base_url = (base_url or os.environ.get("AGENTDATA_BASE_URL") or "").rstrip("/") or None
43
+ self.api_key = api_key or os.environ.get("AGENTDATA_API_KEY") or None
44
+ self.healthcare = _Domain(self, "healthcare")
45
+ self.company = _Domain(self, "company")
46
+ self.esg = _Domain(self, "esg")
47
+ self.legal = _Domain(self, "legal")
48
+ self.procurement = _Domain(self, "procurement") # 0.3.0: the seventh domain
49
+ self.research = _Domain(self, "research")
50
+ self.security = _Domain(self, "security")
51
+ self.economy = _Domain(self, "economy") # 0.3.23: the eighth domain (country denominators)
52
+
53
+ def _request_id(self) -> str:
54
+ return str(uuid.uuid4())
55
+
56
+ # —— corpus family: hosted HTTP (Bearer PAT; the closed error-envelope set passes straight through) ——
57
+ def _corpus(self, domain: str, verb: str, payload: dict) -> dict:
58
+ if not self.base_url or not self.api_key:
59
+ raise SdkError("usage_error",
60
+ detail="The corpus family uses hosted HTTP: Client(base_url=…, api_key=…) (enrichment works with zero config; ask us to issue you a PAT)")
61
+ url = f"{self.base_url}/v1/{domain}/{verb}"
62
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
63
+ req = urllib.request.Request(url, data=data, method="POST", headers={
64
+ "Authorization": f"Bearer {self.api_key}",
65
+ "Content-Type": "application/json",
66
+ "Accept": "application/json",
67
+ })
68
+ try:
69
+ with urllib.request.urlopen(req, timeout=max(self.timeout, 120)) as resp:
70
+ status, raw, headers = resp.status, resp.read(), resp.headers
71
+ except urllib.error.HTTPError as e:
72
+ status, raw, headers = e.code, e.read(), e.headers
73
+ except (urllib.error.URLError, OSError) as e:
74
+ raise SdkError("capability_unavailable", detail=f"Gateway unreachable: {e}") from e
75
+ try:
76
+ doc = json.loads(raw.decode("utf-8", "replace"))
77
+ except ValueError:
78
+ raise SdkError("capability_unavailable", status=status, detail=f"Gateway returned a non-JSON response (HTTP {status})")
79
+ if status >= 400:
80
+ err = doc.get("error") or {}
81
+ ra_hdr = headers.get("Retry-After") if headers else None
82
+ try:
83
+ ra = float(ra_hdr) if ra_hdr else None
84
+ except (TypeError, ValueError):
85
+ ra = None
86
+ raise SdkError(err.get("code", "internal"), status=status,
87
+ detail=err.get("message") or f"HTTP {status}", retry_after=ra)
88
+ return doc
89
+
90
+
91
+ class _Domain(HealthcareFaces, CompanyFaces, EsgFaces, LegalFaces, ProcurementFaces,
92
+ ResearchFaces, SecurityFaces, EconomyFaces):
93
+ """Domain namespace object — the flat faces are mixed in from each domain's faces; the corpus-family
94
+ search/read stay on the base class (hosted, api.md section 8 living contract). In 0.3.0 the verb layer
95
+ (resolve/evidence/trials/label/related/fulltext), the csl_screening frozen alias and the grants domain
96
+ alias were removed per the established contract."""
97
+ def __init__(self, client: Client, name: str):
98
+ self._c = client
99
+ self.name = name
100
+
101
+ # ---------- quartet envelope (shared by every domain's faces) ----------
102
+ # A single upstream request passes through; we may add defaults/validation/rate limiting/caching/
103
+ # error normalization/row shaping, but must never permanently hide a capability the upstream can
104
+ # express; a cap is allowed only for a hard protocol limit or local safety. Quartet envelope + the
105
+ # query echo quartet.
106
+
107
+ def _flat_env(self, *, tool, input, reason, result, evidence, provider_id, metas,
108
+ query=None, deprecations=None):
109
+ ex = resolve_execution(self._c.default_execution)
110
+ metas = [m for m in metas if m]
111
+ out = {
112
+ "ok": True, "domain": self.name, "tool": tool,
113
+ "input": input,
114
+ "from_cache": bool(metas) and all(m.get("from_cache") for m in metas),
115
+ "reason": reason,
116
+ "result": result,
117
+ "evidence": evidence,
118
+ "source": enrich_source(provider_id, ex, *metas),
119
+ "request_id": self._c._request_id(),
120
+ }
121
+ if query is not None:
122
+ out["query"] = query
123
+ if deprecations:
124
+ out["deprecations"] = deprecations
125
+ return out
126
+
127
+ # ---------- corpus (hosted) ----------
128
+
129
+ def search(self, *, filters=None, record_kind=None, facets=None, level="tier1",
130
+ limit=50, cursor=None, mode=None, screen_inputs=None):
131
+ """Corpus search (hosted API). mode="screen" + screen_inputs is the batch-screening mode."""
132
+ payload = {"level": level, "limit": limit}
133
+ if filters:
134
+ payload["filters"] = filters
135
+ if record_kind:
136
+ payload["record_kind"] = record_kind
137
+ if facets:
138
+ payload["facets"] = facets if isinstance(facets, list) else [facets]
139
+ if cursor:
140
+ payload["cursor"] = cursor
141
+ if mode:
142
+ payload["mode"] = mode
143
+ if screen_inputs is not None:
144
+ payload["screen_inputs"] = screen_inputs
145
+ out = self._c._corpus(self.name, "search", payload)
146
+ out["request_id"] = out.get("request_id") or self._c._request_id()
147
+ return out
148
+
149
+ def read(self, id=None, *, target=None, level="tier1"):
150
+ """Corpus single-record read (hosted API): id (or target) + T-layer depth."""
151
+ payload = {"level": level}
152
+ if id is not None:
153
+ payload["id"] = id
154
+ elif target is not None:
155
+ payload["target"] = target
156
+ out = self._c._corpus(self.name, "read", payload)
157
+ out["request_id"] = out.get("request_id") or self._c._request_id()
158
+ return out
@@ -0,0 +1,3 @@
1
+ """company domain package — provider adapters flat layout (one source per file) + faces/cli (0.2.6 per-domain reorg)."""
2
+ from . import (cf, ch_zefix, co_rues, edgar_cik, edgar_fts, fdic_bankfind, fi_ytj, # noqa: F401
3
+ fr_recherche, fts, gleif, kr_hometax, no_brreg, ror, ted, usaspending, vies)
@@ -0,0 +1,145 @@
1
+ """cf — UK Contracts Finder OCDS provider (UK below-threshold public procurement; keyless).
2
+
3
+ 2026-10-23 probing chain: bare path /api/1.0/ocdsReleasePackages 404s, host api.contractsfinder
4
+ gives 000 — the real working face is
5
+ www.contractsfinder.service.gov.uk/Published/Notices/OCDS/Search (verified 2026-10-20, re-verified
6
+ alive 2026-10-23). Division of labor with FTS: FTS = WTO-line above-threshold, CF = below-threshold
7
+ small contracts — the two faces complement each other.
8
+ Parameter face: publishedFrom/publishedTo (bare dates are legal here — opposite of FTS's full
9
+ timestamp requirement) + limit(≤100) + cursor + stages(tender|award, added in the 0.2.9 audit —
10
+ symmetric with the FTS face);
11
+ without a window releases are always empty — default falls back to a rolling "last 7 days" window.
12
+ Identifier face: suppliers[].id is GB-COH-<Companies House number> (same key as the companies-house
13
+ batch corpus line). TTL: 24h.
14
+ """
15
+ import re
16
+ from datetime import timedelta, datetime, timezone
17
+ from urllib.parse import urlparse, parse_qs
18
+
19
+ from ..errors import SdkError
20
+
21
+ provider = {
22
+ "id": "cf",
23
+ "baseUrl": "https://www.contractsfinder.service.gov.uk",
24
+ "rate": {"rps": 0.5, "burst": 2},
25
+ "ops": {
26
+ "search": {"path": "/Published/Notices/OCDS/Search", "ttl": 24 * 3600, "timeoutMs": 30000},
27
+ },
28
+ "ttl": {"search": 24 * 3600},
29
+ }
30
+
31
+ _BARE_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
32
+
33
+
34
+ def cf_from_upstream(data, name="", limit=10):
35
+ """Upstream payload → {rows, next_cursor, has_more} (pure function + client-side name filter, offline-testable; same shape as fts)."""
36
+ q = str(name or "").lower().strip()
37
+ hit = (lambda s: not q or q in str(s or "").lower())
38
+
39
+ def keep(rel):
40
+ if hit((rel or {}).get("buyer", {}).get("name")):
41
+ return True
42
+ return any(hit(sp.get("name")) for aw in (rel or {}).get("awards") or []
43
+ for sp in (aw or {}).get("suppliers") or [])
44
+
45
+ rows = []
46
+ for rel in [x for x in (data or {}).get("releases") or [] if keep(x)][:limit]:
47
+ rows.append({
48
+ "ocid": rel.get("ocid"),
49
+ "date": rel.get("date"),
50
+ "tags": rel.get("tag") or [],
51
+ "buyer": (rel.get("buyer") or {}).get("name"),
52
+ "suppliers": sorted({sp.get("name") for aw in rel.get("awards") or []
53
+ for sp in aw.get("suppliers") or [] if sp.get("name")}),
54
+ "supplier_ids": sorted({sp.get("id") for aw in rel.get("awards") or []
55
+ for sp in aw.get("suppliers") or [] if sp.get("id")}), # GB-COH-… (CH number) / GB-CFS-… (CF registry key), two forms
56
+ "award_values": [{"date": aw.get("date"), "amount": (aw.get("value") or {}).get("amount"),
57
+ "currency": (aw.get("value") or {}).get("currency")}
58
+ for aw in rel.get("awards") or [] if (aw.get("value") or {}).get("amount") is not None],
59
+ "tender_title": (rel.get("tender") or {}).get("title"),
60
+ })
61
+ nxt = (data or {}).get("links", {}).get("next")
62
+ cursor = (parse_qs(urlparse(nxt).query).get("cursor") or [None])[0] if nxt else None
63
+ return {"rows": rows, "next_cursor": cursor, "has_more": bool(nxt)}
64
+
65
+
66
+ def company_uk_cf_tenders(name, *, limit=10, cursor=None, published_from=None, published_to=None,
67
+ cache=None, no_cache=False):
68
+ """→ {rows, next_cursor, has_more, window, matched_on, _meta}; CF release rows (client-side contains match).
69
+ Always fetches the full 100-row page then filters and slices to limit; window default = rolling last 7 days (upstream is always empty without one)."""
70
+ from ..transport import call_provider
71
+
72
+ q = str(name or "").strip()
73
+ if not q:
74
+ raise SdkError("usage_error", provider="cf",
75
+ detail="name is required (the OCDS face has no name-search param — client-side contains match)")
76
+ lim = min(max(1, int(limit) or 10), 100)
77
+ p = {"limit": 100}
78
+ if cursor:
79
+ p["cursor"] = str(cursor)
80
+
81
+ def iso(s):
82
+ s = str(s or "").strip()
83
+ return s if _BARE_DATE.match(s) else None
84
+
85
+ today = datetime.now(timezone.utc).date()
86
+
87
+ def d(n):
88
+ return (today + timedelta(days=n)).isoformat()
89
+
90
+ frm, to = iso(published_from), iso(published_to)
91
+ if not frm and not to:
92
+ frm, to = d(-7), d(0) # both default: rolling 7-day window
93
+ elif not frm:
94
+ frm = d(-7) # single end: the other end filled from the 7-day window
95
+ elif not to:
96
+ to = d(0)
97
+ p["publishedFrom"], p["publishedTo"] = frm, to
98
+ r = call_provider(provider, "search", p, cache=cache, no_cache=no_cache)
99
+ return {**cf_from_upstream(r["data"], q, lim),
100
+ "window": {"published_from": frm, "published_to": to},
101
+ "matched_on": "buyer|suppliers contains", "_meta": r}
102
+
103
+
104
+ def search_raw(*, published_from=None, published_to=None, limit=100, cursor=None,
105
+ stages=None, cache=None, no_cache=False):
106
+ """→ {rows, next_cursor, has_more, window, params, _meta}; CF upstream-verbatim params (no name filter).
107
+ publishedFrom/publishedTo accept bare dates (opposite of FTS's full-timestamp requirement — evidence the two faces differ);
108
+ both-default = rolling 7-day window (**our default, not upstream's** — upstream is always empty without a window); limit 1-100.
109
+ stages (0.2.9 audit addition): tender|award passed through verbatim — invalid values get a self-describing upstream 400
110
+ (2026-10-28 probe: stages=BOGUS → code 3200 "BOGUS is not a valid OCDS stage", self-validating, so no local wall on this param);
111
+ absent = not sent → mixed feed (same-window baseline 100 rows = award 88 + tender 2 + planning 3; stages=tender gives 59 pure
112
+ tender/tenderAmendment rows, stages=award 100 pure award/awardUpdate rows — a real filter, not an echo)."""
113
+ from ..transport import call_provider
114
+ from ..errors import SdkError
115
+
116
+ lim = int(limit)
117
+ if not 1 <= lim <= 100:
118
+ raise SdkError("usage_error", provider="cf", detail="limit must be 1-100 (upstream page cap)")
119
+ params = {"limit": lim}
120
+ if cursor:
121
+ params["cursor"] = str(cursor)
122
+ if stages:
123
+ params["stages"] = str(stages).strip()
124
+
125
+ def iso(v):
126
+ s = str(v or "").strip()
127
+ return s if _BARE_DATE.match(s) else None
128
+
129
+ today = datetime.now(timezone.utc).date()
130
+
131
+ def d(n):
132
+ return (today + timedelta(days=n)).isoformat()
133
+
134
+ frm, to = iso(published_from), iso(published_to)
135
+ if not frm and not to:
136
+ frm, to = d(-7), d(0) # our default: upstream is always empty without a window (probed), rolling 7-day fallback
137
+ elif not frm:
138
+ frm = d(-7)
139
+ elif not to:
140
+ to = d(0)
141
+ params["publishedFrom"], params["publishedTo"] = frm, to
142
+ r = call_provider(provider, "search", params, cache=cache, no_cache=no_cache)
143
+ return {**cf_from_upstream(r["data"], "", lim),
144
+ "window": {"published_from": frm, "published_to": to},
145
+ "params": params, "_meta": r}