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/transport.py ADDED
@@ -0,0 +1,541 @@
1
+ """agentdata.transport — the upstream-call core: token bucket + dual channel (urllib / curl subprocess) + retries + cache weaving.
2
+
3
+ Python counterpart of the Node-side baseline transport (api.md section 9.5 is the single authority:
4
+ the Phase 2 Python implementation is the complete, authoritative local implementation; the bar to clear
5
+ is capability/error/cache vectors + real transport verification).
6
+
7
+ - Transport channel: urllib by default; a provider/op declaring transport='curl' goes through a system
8
+ curl subprocess — the countermeasure against WAF bot gates (the GUDID lesson: gating holds on both
9
+ the client stack and the egress IP, and curl only passes from this machine's egress; a 2xx non-JSON
10
+ response is normalized into an error carrying an egress-troubleshooting hint — verify the egress
11
+ first, only then consider pinning transport='curl').
12
+ - Three normalized error codes: upstream_unavailable / upstream_rate_limited (empty bucket or upstream
13
+ 429/503) / upstream_not_found (notFound declaration).
14
+ - Retries: 2 by default, exponential backoff + honouring Retry-After; only for retryable statuses
15
+ (408/429/500/502/503/504).
16
+ - POST read semantics (filled in by the company wave, aligned with the Node core's three small
17
+ extensions): when an op declares method='POST', non-path-template parameters go into the JSON body,
18
+ not the query string; it must also declare idempotent=True to take part in retries; the curl
19
+ subprocess channel has no JSON-body parameter surface, so POST+curl is intercepted ahead of
20
+ call_provider as a usage_error.
21
+ - form channel (filled in by the esg/legal wave, aligned with the Node core's fourth extension): when an
22
+ op declares form='<field name>', the POST body is a single-field multipart whose value is the JSON blob
23
+ of params[form] (same as the browser FormData.append(field, Blob): filename="blob" +
24
+ Content-Type: application/json); control parameters (apiKey/pageNumber and the like) go into the query
25
+ string. First used by dgcomp (webgate ES). The curl channel has no multipart surface, so the
26
+ POST+curl interception stands.
27
+ """
28
+ import json
29
+ import math
30
+ import os
31
+ import random
32
+ import subprocess
33
+ import threading
34
+ import time
35
+ import urllib.error
36
+ import urllib.parse
37
+ import urllib.request
38
+ import uuid
39
+ from datetime import datetime, timezone
40
+ from urllib.parse import urlsplit
41
+
42
+ UA = "agentdata/0.2.0" # compatibility constant; at runtime _ua() computes it fresh (an AGENTDATA_CONTACT env var set later still takes effect)
43
+ RETRYABLE = {408, 429, 500, 502, 503, 504}
44
+ MAX_BACKOFF = 30.0
45
+
46
+
47
+ def resolve_execution(requested=None) -> str:
48
+ from .errors import SdkError # function-level import (this module's cycle-avoidance style)
49
+
50
+ x = requested or "auto"
51
+ if x not in ("local", "hosted", "auto"):
52
+ raise SdkError("usage_error", detail=f"execution must be local|hosted|auto, got '{x}'")
53
+ if x == "hosted":
54
+ raise SdkError("capability_unavailable",
55
+ detail="Realtime queries support local execution only (direct connection to the upstream official public API, no credentials needed)")
56
+ return "local"
57
+
58
+
59
+ def enrich_source(provider_id: str, execution: str, *metas) -> dict:
60
+ metas = [m for m in metas if m]
61
+ from_cache = bool(metas) and all(m.get("from_cache") for m in metas)
62
+ last = metas[-1]
63
+ if from_cache:
64
+ return {"provider": provider_id, "execution": execution, "from_cache": True, "fetched_at": last["fetched_at"]}
65
+ return {"provider": provider_id, "execution": execution, "from_cache": False, "observed_at": last["fetched_at"]}
66
+
67
+
68
+ # RxNav tty (concept level) -> reason label: BN=brand name / IN=ingredient (the level decides whether ATC has a value)
69
+ _TTY_LABELS = {"BN": "brand-name level", "IN": "ingredient level"}
70
+
71
+
72
+ def _tty_tag(tty) -> str:
73
+ if not tty:
74
+ return ""
75
+ return f" ({_TTY_LABELS.get(tty, tty)})"
76
+
77
+ def _ua() -> str:
78
+ """Polite UA: agentdata/<version> (+<contact>) — the contact comes from env AGENTDATA_CONTACT (e.g. mailto:)."""
79
+ try:
80
+ from . import __version__ as _v
81
+ except ImportError: # mid package init (transport imported early by providers) — fall back to a hardcoded version
82
+ _v = "0.2.0"
83
+ contact = os.environ.get("AGENTDATA_CONTACT")
84
+ return f"agentdata/{_v} (+{contact})" if contact else f"agentdata/{_v}"
85
+
86
+
87
+ # 0.2.0 anti-ban rate-limiting six-pack (independent of the command surface):
88
+ # (1) 429/Retry-After aware exponential backoff + jitter (capped at 30s); (2) no blind retry on 401/403
89
+ # (a WAF/permission gate is not rate limiting; retrying hurts yourself);
90
+ # (3) sliding-window circuit breaker (3 consecutive fails or 5 fails in a 60s window -> 60s cooldown ->
91
+ # half-open with 1 probe -> a repeat fail extends to 5min; not_found = a clean round trip, not a fail);
92
+ # (4) provider+host two-layer token bucket (host default 5rps/burst10, provider["hostRate"] overrides)
93
+ # + (4b) family group bucket (a provider["rate"]["group"] name -> a GROUP_LIMITS group quota shared by
94
+ # multiple sources of the same official family — NLM was the first case in 0.2.8);
95
+ # (5) host concurrency cap (default 2, provider["concurrency"] overrides — fragile government sources pin 1);
96
+ # (6) polite UA agentdata/<ver> (+contact).
97
+ # State is in-process (not shared across processes; persisting severe cooldowns is a separate project).
98
+
99
+
100
+ def now_iso() -> str:
101
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
102
+
103
+
104
+ class TokenBucket:
105
+ """Per-provider token bucket: capacity=burst, refill rate=rps; waits when empty (30s cap -> upstream_rate_limited)."""
106
+
107
+ def __init__(self, rps: float, burst: int):
108
+ self.rps, self.burst = rps, burst
109
+ self.tokens, self.last = float(burst), time.monotonic()
110
+
111
+ def take(self):
112
+ deadline = time.monotonic() + 30
113
+ while True:
114
+ now = time.monotonic()
115
+ self.tokens = min(self.burst, self.tokens + (now - self.last) * self.rps)
116
+ self.last = now
117
+ if self.tokens >= 1:
118
+ self.tokens -= 1
119
+ return
120
+ if now > deadline:
121
+ raise TimeoutError("local token bucket wait exceeded")
122
+ time.sleep(max(0.05, (1 - self.tokens) / self.rps / 2))
123
+
124
+
125
+ _buckets = {}
126
+ _host_buckets = {} # (4) host-layer token bucket: hostname -> TokenBucket (multiple providers on one host share the polite quota)
127
+ _group_buckets = {} # (4b) family group bucket: rate.group name -> TokenBucket (multiple providers of one official family share the official quota)
128
+ _host_slots = {} # (5) host concurrency cap: hostname -> Semaphore
129
+ _state_lock = threading.Lock()
130
+
131
+ # Family group quota registry (0.2.8): key = the rate.group a provider declares; quota = a conservative
132
+ # window for the official family policy (quota semantics b + r x 1s <= the official limit). First case nlm:
133
+ # RxNav ToS 20/s per IP (top tier), group bucket 10+10=20 right at the line.
134
+ GROUP_LIMITS = {
135
+ "nlm": {"rps": 10.0, "burst": 10}, # NLM family (shared by rxnav/dailymed/mesh; gudid has no official number, not in the group)
136
+ "fda": {"rps": 3.9, "burst": 6}, # openFDA keyless family line 240/min per IP — one shared bucket across all
137
+ # api.fda.gov endpoints (recalls/crl/drugs/devices/shortages — rate-limits.md section 5-6 debt, 2026-10-30 ledger add,
138
+ # closed in the 0.3.20 audit round). Minute-window quota form b + r x 60s <= 240 exactly (6 + 3.9x60 = 240.0);
139
+ # the keyless 1,000/day cap is bucket-blind and stays a documented stance, not a local wall.
140
+ }
141
+
142
+
143
+ def _take_token(provider):
144
+ rps = provider["rate"]["rps"]
145
+ burst = provider["rate"].get("burst", max(2, int(rps * 2)))
146
+ b = _buckets.get(provider["id"])
147
+ if b is None:
148
+ b = _buckets[provider["id"]] = TokenBucket(rps, burst)
149
+ try:
150
+ b.take()
151
+ except TimeoutError as e: # an empty-bucket wait timing out is the local polite tier protecting itself, not an upstream error
152
+ from .errors import SdkError
153
+ raise SdkError("rate_limited", provider=provider["id"],
154
+ detail=f"Local token bucket wait exceeded (polite tier {rps} rps): {e}")
155
+
156
+
157
+ def _take_group_token(provider):
158
+ """(4b) family group bucket: when a provider declares rate.group, all providers in the group share the
159
+ GROUP_LIMITS group quota — after a speed-up this prevents "each source compliant, the family stacked
160
+ over the limit" (the NLM 20/s official family-policy scenario). An unknown/unregistered group is a
161
+ declaration error, intercepted on the spot."""
162
+ group = provider["rate"].get("group")
163
+ if not group:
164
+ return
165
+ gl = GROUP_LIMITS.get(group)
166
+ if gl is None:
167
+ from .errors import SdkError
168
+ raise SdkError("usage_error", provider=provider["id"],
169
+ detail=f"rate.group '{group}' is not registered in transport.GROUP_LIMITS — register the group quota before enabling it")
170
+ b = _group_buckets.get(group)
171
+ if b is None:
172
+ with _state_lock:
173
+ b = _group_buckets.get(group) or TokenBucket(float(gl["rps"]), int(gl["burst"]))
174
+ _group_buckets[group] = b
175
+ try:
176
+ b.take()
177
+ except TimeoutError as e:
178
+ from .errors import SdkError
179
+ raise SdkError("rate_limited", provider=provider["id"],
180
+ detail=f"Family group '{group}' token bucket wait exceeded (shared group quota {gl['rps']} rps): {e}")
181
+
182
+
183
+ def _host_of(provider) -> str:
184
+ return urlsplit(provider["baseUrl"]).hostname or provider["baseUrl"]
185
+
186
+
187
+ def _take_host_token(provider):
188
+ """(4) host-layer bucket: defaults 5rps/burst10 (a mere second gate when the provider bucket is tighter); provider["hostRate"] overrides."""
189
+ hr = provider.get("hostRate") or {"rps": 5.0, "burst": 10}
190
+ host = _host_of(provider)
191
+ b = _host_buckets.get(host)
192
+ if b is None:
193
+ with _state_lock:
194
+ b = _host_buckets.get(host) or TokenBucket(float(hr["rps"]), int(hr["burst"]))
195
+ _host_buckets[host] = b
196
+ try:
197
+ b.take()
198
+ except TimeoutError as e:
199
+ from .errors import SdkError
200
+ raise SdkError("rate_limited", provider=provider["id"],
201
+ detail=f"Host-layer token bucket wait exceeded ({host} polite tier {hr['rps']} rps): {e}")
202
+
203
+
204
+ def _host_slot(host: str, limit: int):
205
+ """(5) host concurrency semaphore: default 2 concurrent (provider["concurrency"] overrides); single-threaded callers always pass straight through."""
206
+ sem = _host_slots.get(host)
207
+ if sem is None:
208
+ with _state_lock:
209
+ sem = _host_slots.get(host) or threading.Semaphore(max(1, int(limit)))
210
+ _host_slots[host] = sem
211
+ return sem
212
+
213
+
214
+ def _backoff_wait(i: int, retry_after=None) -> float:
215
+ """(1) Backoff: max(1x2^i, Retry-After) capped at 30s, plus 0~1s jitter (staggering prevents an avalanche)."""
216
+ base = min(MAX_BACKOFF, float(1 << i))
217
+ wait = max(base, float(retry_after or 0.0))
218
+ return min(MAX_BACKOFF, wait) + random.uniform(0.0, 1.0)
219
+
220
+
221
+ class CircuitBreaker:
222
+ """(3) Sliding-window circuit breaker (in-process): 3 consecutive fails or 5 fails in a 60s window -> open 60s
223
+ -> half-open with 1 probe -> a repeat fail extends the open state to 5min, a success closes it."""
224
+
225
+ def __init__(self):
226
+ self.fails = [] # failure timestamps inside the 60s sliding window (monotonic)
227
+ self.consec = 0 # consecutive failure count
228
+ self.state = "closed" # closed | open | half_open
229
+ self.opened_at = 0.0
230
+ self.open_for = 0.0
231
+
232
+ def allow(self) -> float:
233
+ """-> 0.0 = pass (including the first probe right after switching to half-open); >0 = N seconds of cooldown left (the request should be refused)."""
234
+ if self.state == "open":
235
+ elapsed = time.monotonic() - self.opened_at
236
+ if elapsed >= self.open_for:
237
+ self.state = "half_open" # cooldown over: let one probe request through
238
+ return 0.0
239
+ return self.open_for - elapsed
240
+ return 0.0
241
+
242
+ def record(self, ok: bool):
243
+ now = time.monotonic()
244
+ self.fails = [t for t in self.fails if now - t <= 60.0]
245
+ if ok:
246
+ self.consec = 0
247
+ self.state = "closed"
248
+ return
249
+ was_half = self.state == "half_open"
250
+ self.consec += 1
251
+ self.fails.append(now)
252
+ if was_half or self.consec >= 3 or len(self.fails) >= 5:
253
+ self.state = "open"
254
+ self.opened_at = now
255
+ self.open_for = 300.0 if was_half else 60.0
256
+
257
+
258
+ _breakers = {}
259
+
260
+
261
+ def _breaker(provider_id: str) -> CircuitBreaker:
262
+ b = _breakers.get(provider_id)
263
+ if b is None:
264
+ with _state_lock:
265
+ b = _breakers.get(provider_id) or CircuitBreaker()
266
+ _breakers[provider_id] = b
267
+ return b
268
+
269
+
270
+ def _reset_rate_state():
271
+ """Test hook: clear the in-process state of the six-pack (buckets/semaphores/circuit breakers/group buckets)."""
272
+ with _state_lock:
273
+ _buckets.clear()
274
+ _host_buckets.clear()
275
+ _group_buckets.clear()
276
+ _host_slots.clear()
277
+ _breakers.clear()
278
+
279
+
280
+ def build_url(base: str, path_tmpl: str, params: dict, query: bool = True, path_safe: str = "") -> str:
281
+ """With query=False only the path template is substituted (POST read semantics: the remaining parameters go into the JSON body, not the query string). path_safe: characters kept unescaped for path params (default = full escaping; the TNA document_uri carries slashes and needs "/" — first use in 0.3.7). List param values explode into repeated keys (doseq; the TNA court/tribunal explode=true shape)."""
282
+ path = path_tmpl
283
+ qs = {}
284
+ for k, v in params.items():
285
+ if v is None:
286
+ continue
287
+ token = "{%s}" % k
288
+ if token in path:
289
+ path = path.replace(token, urllib.parse.quote(str(v), safe=path_safe))
290
+ elif query:
291
+ qs[k] = v
292
+ url = base.rstrip("/") + (path if path.startswith("/") else "/" + path)
293
+ if qs:
294
+ # when the path already carries a query string (the ukec shape with the full parameter set baked
295
+ # into the path) append with & — a second "?" would turn the query and everything after it into
296
+ # the value of the previous parameter (proven 2026-10-21: the UK EC name filter silently stopped
297
+ # working and always returned the whole register)
298
+ url += ("&" if "?" in path else "?") + urllib.parse.urlencode(qs, doseq=True)
299
+ return url
300
+
301
+
302
+ def multipart_json_field(field: str, obj) -> tuple[bytes, str]:
303
+ """Extension (4) form-channel body: single-field multipart whose field value is a JSON blob. -> (body, content_type)."""
304
+ boundary = "----agentdataForm" + uuid.uuid4().hex
305
+ payload = json.dumps(obj, ensure_ascii=False).encode("utf-8")
306
+ head = (f'--{boundary}\r\n'
307
+ f'Content-Disposition: form-data; name="{field}"; filename="blob"\r\n'
308
+ f'Content-Type: application/json\r\n\r\n').encode("utf-8")
309
+ tail = f"\r\n--{boundary}--\r\n".encode("utf-8")
310
+ return head + payload + tail, f"multipart/form-data; boundary={boundary}"
311
+
312
+
313
+ def http_json(url: str, timeout: float, body: dict | None = None, multipart=None, urlencoded=None,
314
+ extra_headers: dict | None = None):
315
+ """urllib channel -> {'ok', 'status', 'retry_after', 'json'} (a non-JSON body is left for the status code to judge).
316
+ body not None -> POST JSON; multipart=(bytes, content_type) -> extension (4) form-channel POST;
317
+ urlencoded=bytes -> extension (7) form-urlencoded channel POST (body = standard form encoding)
318
+ (a read-semantics POST only takes part in call_provider retries when the op declares idempotent).
319
+ extra_headers -> extension (8) per-request header overrides (merged over the default UA/Accept: UA-fingerprint-gate
320
+ sites override User-Agent, the ASP.NET anti-forgery pairing carries Cookie/RequestVerificationToken — first used
321
+ by the UNGM collection, 0.3.12); the response's set_cookie list comes back in full (the pairing bootstrap needs
322
+ the session cookie; urllib channel only)."""
323
+ headers = {"User-Agent": _ua(), "Accept": "application/json"}
324
+ if extra_headers:
325
+ headers.update(extra_headers)
326
+ data = None
327
+ if body is not None:
328
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
329
+ headers["Content-Type"] = "application/json"
330
+ elif multipart is not None:
331
+ data, ct = multipart
332
+ headers["Content-Type"] = ct
333
+ elif urlencoded is not None:
334
+ data = urlencoded
335
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
336
+ req = urllib.request.Request(url, data=data, headers=headers)
337
+ try:
338
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
339
+ status, raw, ra = resp.status, resp.read(), resp.headers.get("Retry-After")
340
+ sc = resp.headers.get_all("Set-Cookie") or []
341
+ except urllib.error.HTTPError as e: # 4xx/5xx land here (the body is still read)
342
+ status, raw, ra = e.code, e.read(), e.headers.get("Retry-After")
343
+ sc = e.headers.get_all("Set-Cookie") or []
344
+ e.close()
345
+ try:
346
+ doc = json.loads(raw.decode("utf-8", "replace"))
347
+ except (ValueError, UnicodeDecodeError):
348
+ doc = None
349
+ ra = float(ra) if ra is not None else None
350
+ # the full text is brought back: accept:'text' ops (HTML surfaces, e.g. TGA eBS Domino SearchView) pass through on this channel and the provider parses them itself
351
+ return {"ok": 200 <= status < 300, "status": status, "retry_after": ra, "json": doc,
352
+ "text": raw.decode("utf-8", "replace"),
353
+ "body_head": raw[:160].decode("utf-8", "replace"),
354
+ "set_cookie": sc}
355
+
356
+
357
+ def curl_json(url: str, timeout: float):
358
+ """curl subprocess channel (the trailer line carries the status code and Retry-After — same as the Node curlJson)."""
359
+ mark = "\n__META__"
360
+ p = subprocess.run(
361
+ ["curl", "-sS", "-m", str(math.ceil(timeout)),
362
+ "-H", f"User-Agent: {_ua()}", "-H", "Accept: application/json",
363
+ "-w", mark + "%{http_code}|%{retry_after}", url],
364
+ capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout + 10,
365
+ )
366
+ out = p.stdout
367
+ i = out.rfind(mark)
368
+ body = out if i == -1 else out[:i]
369
+ head = "0|" if i == -1 else out[i + len(mark):]
370
+ status_s, _, ra_s = head.partition("|")
371
+ try:
372
+ doc = json.loads(body)
373
+ except ValueError:
374
+ doc = None
375
+ status = int(status_s or 0)
376
+ return {"ok": 200 <= status < 300, "status": status,
377
+ "retry_after": float(ra_s) if ra_s else None, "json": doc, "text": body,
378
+ "body_head": body[:160]}
379
+
380
+
381
+ def _send(url: str, transport: str, timeout: float, body: dict | None = None, multipart=None,
382
+ urlencoded=None, extra_headers: dict | None = None):
383
+ # extra_headers (ext 8) rides the urllib channel only and is passed as a kwarg only when declared
384
+ # (absent = zero disturbance to the existing four-argument/http_json patch signature — same
385
+ # discipline as multipart/urlencoded); the curl subprocess has no per-request header surface
386
+ # (intercepted before call_provider)
387
+ kw = {"extra_headers": extra_headers} if extra_headers else {}
388
+ if body is not None: # POST only via the urllib channel (call_provider already intercepts curl+POST)
389
+ return http_json(url, timeout, body, **kw)
390
+ if multipart is not None: # extension (4) form channel (urllib only as well; curl+POST is intercepted)
391
+ return http_json(url, timeout, multipart=multipart, **kw)
392
+ if urlencoded is not None: # extension (7) form-urlencoded channel (urllib only as well)
393
+ return http_json(url, timeout, urlencoded=urlencoded, **kw)
394
+ if extra_headers: # ext 8 is mutually exclusive with the curl channel (no header surface; silently dropping headers = a wrong-fingerprint request); urllib passes straight through to http_json
395
+ if transport == "curl":
396
+ from .errors import SdkError
397
+ raise SdkError("usage_error", detail="extra_headers is urllib-channel only (the curl subprocess has no per-request header surface)")
398
+ return http_json(url, timeout, extra_headers=extra_headers)
399
+ return curl_json(url, timeout) if transport == "curl" else http_json(url, timeout)
400
+
401
+
402
+ def cache_key_for(op: str, params: dict) -> str:
403
+ """Cache key: exported so a provider can recompute it (dropping an empty-result entry must use the same key as inside call_provider — do not copy the format)."""
404
+ return json.dumps([op] + sorted(params.items()), ensure_ascii=False)
405
+
406
+
407
+ def call_provider(provider, op: str, params=None, cache=None, no_cache: bool = False,
408
+ extra_headers: dict | None = None):
409
+ """-> {'ok', 'data', 'from_cache', 'fetched_at', 'provider'}; a cache hit does zero network work; a pass-through is stored under the op TTL."""
410
+ params = {k: v for k, v in (params or {}).items() if v is not None}
411
+ opdef = provider["ops"].get(op)
412
+ if opdef is None:
413
+ from .errors import SdkError
414
+ raise SdkError("op_unknown", provider=provider["id"], detail=f"Undeclared op: {op}")
415
+
416
+ ttl = opdef.get("ttl", provider.get("ttl", {}).get(op))
417
+ cache_key = cache_key_for(op, params)
418
+ if not no_cache and cache is not None:
419
+ hit = cache.get(provider["id"], cache_key, ttl)
420
+ if hit:
421
+ return {"ok": True, "data": hit["payload"], "from_cache": True,
422
+ "fetched_at": hit["fetched_at_iso"], "provider": provider["id"]}
423
+
424
+ from .errors import SdkError
425
+
426
+ # POST read semantics (aligned with the Node core's third small extension): for a method=POST op the
427
+ # non-path parameters go into the JSON body; it takes part in retries only when idempotent=True is
428
+ # declared (a read-semantics POST is naturally idempotent; only flush-style write POSTs are barred from retrying).
429
+ # form channel (extension 4): when op.form='<field name>' is declared the body is a single-field
430
+ # multipart (JSON blob) and control parameters go into the query string.
431
+ # form-urlencoded channel (extension 7): when op.urlencoded=True is declared the body is standard form
432
+ # encoding (interfaces that read parameters as a form, e.g. the EMSA portal REST — a JSON body is
433
+ # silently dropped by the upstream, 200 with 0 rows for the whole window, measured on Paris MoU 2026-10-29).
434
+ # The curl subprocess channel has no body-parameter surface (none of JSON/multipart/urlencoded) ->
435
+ # intercepted directly as a usage error (aligned with Node: POST currently supports urllib only).
436
+ is_post = opdef.get("method") == "POST"
437
+ is_form = is_post and isinstance(opdef.get("form"), str)
438
+ if is_post and opdef.get("transport", "urllib") == "curl":
439
+ raise SdkError("usage_error", provider=provider["id"],
440
+ detail="POST read semantics currently support the urllib channel only (the curl subprocess has no JSON/multipart/urlencoded body-parameter surface)")
441
+ multipart = None
442
+ urlenc = None
443
+ if is_form:
444
+ url_params = {k: v for k, v in params.items() if k != opdef["form"]}
445
+ url = build_url(provider["baseUrl"], opdef["path"], url_params, query=True,
446
+ path_safe=opdef.get("path_safe", ""))
447
+ multipart = multipart_json_field(opdef["form"], params.get(opdef["form"]) or {})
448
+ body = None
449
+ else:
450
+ url = build_url(provider["baseUrl"], opdef["path"], params, query=not is_post,
451
+ path_safe=opdef.get("path_safe", ""))
452
+ if is_post and opdef.get("urlencoded") is True:
453
+ body = None
454
+ urlenc = urllib.parse.urlencode(params).encode("utf-8")
455
+ else:
456
+ body = {k: v for k, v in params.items() if ("{%s}" % k) not in opdef["path"]} if is_post else None
457
+ # (3) circuit-breaker gate: open = refused locally (prevents the IP being collectively banned), error
458
+ # code rate_limited (local protection, not an upstream response)
459
+ breaker = _breaker(provider["id"])
460
+ cooldown = breaker.allow()
461
+ if cooldown > 0:
462
+ raise SdkError("rate_limited", provider=provider["id"],
463
+ detail=f"Circuit breaker cooling down ({int(cooldown)}s left) — protection after consecutive recent failures; try again later")
464
+
465
+ attempts = opdef.get("retries", 2) if (not is_post or opdef.get("idempotent") is True) else 0
466
+ last_was_limited = False
467
+ last_err = None
468
+ clean_round_trip = False # a successful round trip (incl. not_found) — not counted as a breaker failure
469
+ host = _host_of(provider)
470
+ conc = int(provider.get("concurrency", 2))
471
+ try:
472
+ for i in range(attempts + 1):
473
+ _take_token(provider)
474
+ _take_host_token(provider) # (4) host-layer bucket (multiple providers on one host share the quota)
475
+ _take_group_token(provider) # (4b) family group bucket (multiple providers of one official family share the group quota; no group declared = pass through)
476
+ try:
477
+ # multipart is passed as the fifth argument only on the form-channel path, urlencoded as
478
+ # the sixth — every other path keeps the four-argument call shape (the golden vectors
479
+ # patch the four-argument _send signature); extra_headers (ext 8) rides the seventh
480
+ # argument only when declared (absent = zero disturbance — same discipline as above)
481
+ with _host_slot(host, conc): # (5) host concurrency cap
482
+ if extra_headers:
483
+ if opdef.get("transport", "urllib") == "curl":
484
+ raise SdkError("usage_error", provider=provider["id"],
485
+ detail="extra_headers is urllib-channel only for now (the curl subprocess has no per-request header surface)")
486
+ r = _send(url, "urllib", opdef.get("timeout", 20.0), body, multipart, urlenc, extra_headers)
487
+ elif multipart is not None:
488
+ r = _send(url, opdef.get("transport", "urllib"), opdef.get("timeout", 20.0), None, multipart)
489
+ elif urlenc is not None:
490
+ r = _send(url, opdef.get("transport", "urllib"), opdef.get("timeout", 20.0), None, None, urlenc)
491
+ else:
492
+ r = _send(url, opdef.get("transport", "urllib"), opdef.get("timeout", 20.0), body)
493
+ except (subprocess.TimeoutExpired, urllib.error.URLError, OSError) as e:
494
+ last_was_limited, last_err = False, e
495
+ if i < attempts:
496
+ time.sleep(_backoff_wait(i))
497
+ continue
498
+ if r["ok"] and (r["json"] is not None if opdef.get("accept") != "text" else isinstance(r.get("text"), str)):
499
+ # accept:'text': an HTML-surface op (a 2xx HTML page is the expected payload, not a WAF
500
+ # challenge); payload = the raw string, parsed at the provider layer (aligned with Node)
501
+ payload = r["text"] if opdef.get("accept") == "text" else r["json"]
502
+ clean_round_trip = True
503
+ if not no_cache and cache is not None:
504
+ cache.set(provider["id"], cache_key, payload, ttl)
505
+ # set_cookie rides the cache-miss response only (ext 8: session collection such as the ASP.NET
506
+ # anti-forgery bootstrap; the cache-hit path carries no key — the caller judges "session not fresh"
507
+ # from its absence and re-bootstraps itself)
508
+ return {"ok": True, "data": payload, "from_cache": False,
509
+ "fetched_at": now_iso(), "provider": provider["id"],
510
+ "set_cookie": r.get("set_cookie") or []}
511
+ if r["ok"]:
512
+ # 2xx but not JSON — the typical AWS WAF bot challenge (202 + x-amzn-waf-action HTML).
513
+ # The gating holds on both the client stack and the egress IP (measured 2026-10-16:
514
+ # undici from this machine is always 202; after switching the proxy egress, curl was
515
+ # all 202 too).
516
+ raise SdkError("upstream_unavailable", provider=provider["id"], status=r["status"],
517
+ detail=(f"Upstream {r['status']} returned non-JSON (body head {r['body_head'][:80]!r})"
518
+ " — suspected AWS WAF bot challenge (x-amzn-waf-action);"
519
+ " check the proxy egress IP / verify from another network first, do not blindly pin transport='curl'"))
520
+ if r["status"] in (opdef.get("notFound") or []):
521
+ clean_round_trip = True # 404 = a clean round trip (zero hits on a search face that declared notFound)
522
+ raise SdkError("upstream_not_found", provider=provider["id"], status=r["status"])
523
+ if r["status"] in (401, 403):
524
+ # (2) no blind retry for the gate/permission family: usually a WAF bot challenge or auth
525
+ # (not rate limiting) — a retry hits the same wall and may deepen the block
526
+ raise SdkError("upstream_unavailable", provider=provider["id"], status=r["status"],
527
+ detail=(f"Upstream {r['status']} (401/403 not retried — usually a WAF bot gate or auth rather than rate limiting; "
528
+ "verify via another network egress or check the parameters; blind retries may escalate into an IP block)"))
529
+ if r["status"] not in RETRYABLE:
530
+ raise SdkError("upstream_unavailable", provider=provider["id"], status=r["status"],
531
+ detail=f"Upstream {r['status']}")
532
+ last_was_limited = r["status"] in (429, 503)
533
+ last_err = OSError(f"Upstream {r['status']}")
534
+ if i < attempts:
535
+ time.sleep(_backoff_wait(i, r["retry_after"])) # (1) Retry-After first + exponential + jitter (capped at 30s)
536
+ if last_was_limited:
537
+ raise SdkError("upstream_rate_limited", provider=provider["id"], detail=str(last_err))
538
+ raise SdkError("upstream_unavailable", provider=provider["id"],
539
+ detail=str(last_err)[:200] or "upstream unreachable")
540
+ finally:
541
+ breaker.record(clean_round_trip)