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
@@ -0,0 +1,137 @@
1
+ """gb_mhra — UK MHRA medicines & medical-device safety-alert face provider
2
+ (www.gov.uk search/content API; keyless — the same host and contract family as the
3
+ gb_cma case chain).
4
+
5
+ healthcare domain (0.3.16; admitted 2026-10-30, probed). Shape verified 2026-10-30:
6
+ GET /api/search.json?filter_format=medical_safety_alert&q=&count=&start=
7
+ → {results:[{title,link:'/drug-device-alerts/<slug>',_id≡base_path,public_timestamp,
8
+ organisations:[…],document_type:'medical_safety_alert',…}], total, start, aggregates}
9
+ — filter_format=medical_safety_alert is this face's semantics (a fixed param, not a
10
+ user param); q/count/start go through with upstream verbatim names. q= is a company-
11
+ name penetration channel into the alert corpus (measured: Abbott → 160 alerts across
12
+ 2019-2026). **Pit nailed: rows returned under a q= query drop the link field — the
13
+ base_path then lives only in _id (link and _id are both the base_path on this API);
14
+ the row mapper reads link first, falling back to _id.** total=1,462 measured
15
+ 2026-10-30.
16
+ GET /api/content/drug-device-alerts/{slug}
17
+ → {content_id(UUID stable key), title, description, first_published_at, public_updated_at,
18
+ updated_at, withdrawn_notice, details:{body(HTML), change_history:[{note,public_timestamp}],
19
+ attachments:[…], metadata:{alert_type, issued_date, medical_specialism:[…]}},
20
+ links:{organisations(MHRA),…}}
21
+ — **Pit nailed: the content API has no .json variant — /api/content<base_path>.json
22
+ returns the gov.uk 404 HTML page (a silent near-miss, not an error body); the op path
23
+ carries no suffix and a pasted slug with a trailing .json is stripped.**
24
+ — alert_type (e.g. medicines-recall-notification / device-field-safety-notice) and
25
+ issued_date from details.metadata are the MHRA-golden fields.
26
+ Param-minimalism pitfall (same host as gb_cma): unprobed param combinations → 422 — this
27
+ face accepts only q/count/start, do not widen it.
28
+ TTL: search 24h (the alert stream lands at daily-to-weekly cadence) / alert 7 days
29
+ (alert pages change slowly; change_history records the evolution); polite rate 1 rps
30
+ (gov.uk publishes no search-API quota — conservative, same as gb_cma).
31
+ """
32
+ from ..errors import SdkError
33
+
34
+ provider = {
35
+ "id": "gb_mhra",
36
+ "baseUrl": "https://www.gov.uk",
37
+ "rate": {"rps": 1, "burst": 2},
38
+ "ops": {
39
+ "alerts": {"path": "/api/search.json"}, # params: q, count, start (filter_format is fixed for this face)
40
+ "alert": {"path": "/api/content/drug-device-alerts/{slug}"}, # slug = the tail of the alert-page base_path (link or _id of the search row)
41
+ },
42
+ "ttl": {"alerts": 24 * 3600, "alert": 7 * 86400},
43
+ }
44
+
45
+
46
+ def alerts_from_upstream(data, size):
47
+ """Upstream search response → alert rows (pure function, offline-testable)."""
48
+ rows = [{
49
+ "slug": ((r.get("link") or r.get("_id") or "").removeprefix("/drug-device-alerts/") or None),
50
+ # ^ join key into the detail face; under q= the rows drop link — the base_path then
51
+ # lives only in _id (both ≡ base_path on this API; do not pass _id off as content_id)
52
+ "title": r.get("title"), # alert name = "Class 2 Medicines Recall: <firm>, <product>" / "MDA/2020/019 …" forms
53
+ "public_timestamp": r.get("public_timestamp"),
54
+ "organisations": [o.get("title") for o in (r.get("organisations") or []) if o.get("title")],
55
+ "url": ("https://www.gov.uk" + p) if (p := (r.get("link") or r.get("_id"))) else None,
56
+ } for r in ((data or {}).get("results") or [])]
57
+ return {
58
+ "rows": rows, "count": len(rows), "limit_applied": size,
59
+ "total": data.get("total") if isinstance(data, dict) and isinstance(data.get("total"), int) else None,
60
+ }
61
+
62
+
63
+ def alerts_raw(q=None, *, count=20, start=0, cache=None, no_cache=False):
64
+ """Flat-panel low-level face: q/count/start go through with verbatim names
65
+ (filter_format=medical_safety_alert fixed).
66
+
67
+ → {rows[], count, limit_applied, total, params, _meta}. A missing q = full-register
68
+ enumeration (1,462 live alerts counted 2026-10-30); count politeness wall 1-100
69
+ (same host contract as gb_cma — upstream walls unprobed, keep the family cap);
70
+ start = offset paging."""
71
+ from ..transport import call_provider
72
+
73
+ term = str(q or "").strip() or None
74
+ size = int(count) if count is not None else 20 # explicit None check — 0 being swallowed by `or` was a same-type bug caught by a test
75
+ if not 1 <= size <= 100:
76
+ raise SdkError("usage_error", provider="gb_mhra",
77
+ detail=f"count must be 1-100 (politeness cap; the gov.uk search API publishes no quota — same family cap as gb_cma), got \"{count}\"")
78
+ off = int(start) if start is not None else 0
79
+ if off < 0:
80
+ raise SdkError("usage_error", provider="gb_mhra", detail=f"start must be an offset ≥0, got \"{start}\"")
81
+ params = {"filter_format": "medical_safety_alert", "count": size, "start": off}
82
+ if term:
83
+ params["q"] = term
84
+ r = call_provider(provider, "alerts", params, cache=cache, no_cache=no_cache)
85
+ return {**alerts_from_upstream(r["data"], size), "params": params, "_meta": r}
86
+
87
+
88
+ def _clean_slug(slug):
89
+ s = str(slug or "").strip()
90
+ # Tolerance: a pasted full base_path (/drug-device-alerts/<slug> or https://www.gov.uk/drug-device-alerts/<slug>)
91
+ # gets its prefix stripped; a trailing .json (the 404 trap shape) is stripped too
92
+ for pre in ("https://www.gov.uk/drug-device-alerts/", "http://www.gov.uk/drug-device-alerts/", "/drug-device-alerts/"):
93
+ if s.startswith(pre):
94
+ s = s[len(pre):]
95
+ s = s.strip("/")
96
+ if s.endswith(".json"):
97
+ s = s[: -len(".json")]
98
+ if not s or "/" in s:
99
+ raise SdkError("usage_error", provider="gb_mhra",
100
+ detail=f"slug must be the alert-page tail segment (e.g. class-2-medicines-recall-xeal-pharma-clopidogrel-oral-liquids-all-strengths-el-26-a-slash-43, "
101
+ f"echoed verbatim from the search row's slug field): {slug}")
102
+ return s
103
+
104
+
105
+ def alert_raw(slug, *, cache=None, no_cache=False):
106
+ """→ the full single-alert dossier (content API, no .json suffix — with it gov.uk
107
+ serves the 404 HTML page; content_id = the UUID stable key).
108
+ alert_type/issued_date = the MHRA metadata fields; attachments = the notice PDF family."""
109
+ from ..transport import call_provider
110
+
111
+ s = _clean_slug(slug)
112
+ r = call_provider(provider, "alert", {"slug": s}, cache=cache, no_cache=no_cache)
113
+ x = r["data"] or {}
114
+ det = x.get("details") or {}
115
+ md = det.get("metadata") or {}
116
+ return {
117
+ "slug": s,
118
+ "content_id": x.get("content_id"),
119
+ "title": x.get("title"),
120
+ "description": x.get("description") or None,
121
+ "document_type": x.get("document_type"),
122
+ "alert_type": md.get("alert_type"), # e.g. medicines-recall-notification / device-field-safety-notice (MHRA metadata)
123
+ "issued_date": md.get("issued_date"), # e.g. 2026-10-26 (MHRA metadata)
124
+ "first_published_at": x.get("first_published_at"),
125
+ "public_updated_at": x.get("public_updated_at"),
126
+ "updated_at": x.get("updated_at"),
127
+ "withdrawn": bool(x.get("withdrawn_notice")),
128
+ "change_history": det.get("change_history") or [], # [{note, public_timestamp}] newest→oldest (last = First published)
129
+ "attachments": [{"title": a.get("title"), "url": a.get("url"),
130
+ "content_type": a.get("content_type"), "content_id": a.get("content_id")}
131
+ for a in (det.get("attachments") or [])],
132
+ "body_html": det.get("body") or "", # the alert-page body HTML (recall/action instructions)
133
+ "organisations": [o.get("title") for o in ((x.get("links") or {}).get("organisations") or [])
134
+ if o.get("title")],
135
+ "url": f"https://www.gov.uk/drug-device-alerts/{s}",
136
+ "_meta": r,
137
+ }
@@ -0,0 +1,84 @@
1
+ """gudid — AccessGUDID v3 (device DI → master record; keyless; TTL 7 days).
2
+
3
+ Migrated v2→v3 (2026-10-22): v2 carries an upstream 2021-07 deprecation notice (claiming removal after 2022-12-31, never executed and still 200);
4
+ v3 has the same shape and was probed — a field-by-field diff on the same DI only adds keys (gmdnTerms.gmdn gains gmdnCode/implantable/gmdnCodeStatus),
5
+ the 404 shape is the same, the WAF behaviour is the same; v3 also accepts the udi=/record_key= params (recorded, unused).
6
+
7
+ Channel note: upstream has an AWS WAF human gate, and on 2026-10-16 it was measured to hold **by client stack and by egress IP** — the local Node undici
8
+ always got 202 with an empty HTML body (the system curl always passed locally); after switching the egress proxy, curl/urllib also all got 202 + the x-amzn-waf-action challenge page.
9
+ The Python default is transport='urllib'; whether it is blocked must be verified on a real machine (live-transport verification, off by default); if blocked, check the egress IP / change network first
10
+ (the transport already raises an actionable error for a 2xx non-JSON response), and only pin 'curl' once the local egress confirms urllib is blocked. Data fallback: openFDA UDI, the same origin.
11
+ Note: the v1 endpoint is deprecated and 404s — do not step on it.
12
+ """
13
+
14
+ provider = {
15
+ "id": "gudid",
16
+ "baseUrl": "https://accessgudid.nlm.nih.gov",
17
+ "rate": {"rps": 2, "burst": 4},
18
+ "ops": {
19
+ "lookup": {"path": "/api/v3/devices/lookup.json", "notFound": [404], "ttl": 7 * 86400,
20
+ "transport": "urllib"},
21
+ },
22
+ }
23
+
24
+
25
+ def _pick(o, *keys):
26
+ for k in keys:
27
+ if o and o.get(k) is not None:
28
+ return o[k]
29
+ return None
30
+
31
+
32
+ def card_from_upstream(data):
33
+ """Pure mapper (testable offline) — v2/v3 have the same shape; only the top-level productCodes bare array is the strong shape (five fields),
34
+ gudid.device.productCodes.fdaProductCode is the weak shape (code+name only) — do not read them mixed.
35
+ The v2 response was verified field by field against a real 200 response for the DI
36
+ 10198459305313 (2026-10-20), and v3 was verified on 2026-10-22 to use the same names; the old names are kept as a fallback."""
37
+ dev = ((data or {}).get("gudid") or {}).get("device") or {}
38
+ pcs = (data or {}).get("productCodes") or []
39
+ return {
40
+ "brand_name": _pick(dev, "brandName", "brand_name"),
41
+ "company_name": _pick(dev, "companyName", "company_name"),
42
+ "device_description": _pick(dev, "deviceDescription", "device_description"),
43
+ "model": _pick(dev, "versionModelNumber", "version_or_model_number"),
44
+ "mr_safety": _pick(dev, "MRISafetyStatus", "mriSafetyInformation", "mri_safety"),
45
+ "record_status": _pick(dev, "deviceRecordStatus", "recordStatus", "record_status"),
46
+ "public_version": _pick(dev, "publicVersionNumber", "public_version_number"),
47
+ "product_codes": [
48
+ {
49
+ "code": c.get("productCode"),
50
+ "name": c.get("deviceName"),
51
+ "device_class": c.get("deviceClass"),
52
+ "regulation_number": c.get("regulationNumber"),
53
+ "medical_specialty": c.get("medicalSpecialty"),
54
+ }
55
+ for c in pcs
56
+ ],
57
+ }
58
+
59
+
60
+ def lookup_di(di: str, cache=None, no_cache: bool = False) -> dict:
61
+ from ..transport import call_provider
62
+
63
+ r = call_provider(provider, "lookup", {"di": str(di).strip()}, cache=cache, no_cache=no_cache)
64
+ return {"di": str(di).strip(), **card_from_upstream(r["data"]), "_meta": r}
65
+
66
+
67
+ def lookup_device(di=None, udi=None, record_key=None, *, cache=None, no_cache: bool = False) -> dict:
68
+ """0.2.0 mirror face: one v3 endpoint with three input params (di/udi/record_key — exactly one of the three required) — the udi=/record_key=
69
+ two-param shapes recorded by the audit are unlocked this way (on 2026-10-23 the missing-param 400 was judged by probing: this is the only positive v3 endpoint)."""
70
+ from ..errors import SdkError
71
+ from ..transport import call_provider
72
+
73
+ given = {k: str(v).strip() for k, v in (("di", di), ("udi", udi), ("record_key", record_key)) if v}
74
+ if len(given) != 1:
75
+ raise SdkError("usage_error", provider="gudid",
76
+ detail=f"exactly one of di/udi/record_key is required (the single-record lookup endpoint; got {sorted(given) or 'none'})")
77
+ k, v = next(iter(given.items()))
78
+ try:
79
+ r = call_provider(provider, "lookup", {k: v}, cache=cache, no_cache=no_cache)
80
+ except SdkError as e:
81
+ if e.code == "upstream_unavailable": # WAF/TLS fingerprint gate or gateway timeout — add the same-origin fallback pointer into the envelope (0.2.8)
82
+ e.detail = (f"{e.detail}; same-origin fallback: openFDA /device/udi.json (another public face of the DI master record, not covered by this SDK, callable directly)")
83
+ raise
84
+ return {"by": k, k: v, **card_from_upstream(r["data"]), "_meta": r}
@@ -0,0 +1,89 @@
1
+ """mesh — MeSH controlled-vocabulary provider (id.nlm.nih.gov RDF lookup API; keyless; NLM/UMLS family 20/s — 0.2.8 sped up to 10 rps into the nlm group bucket).
2
+
3
+ Wave 3 of the healthcare domain: resolve kind=mesh_term
4
+ — drug/disease name → the MeSH descriptor master record, the control-term anchor for literature search and corpus normalization. Shapes measured 2026-10-23 (the param names are very easy to get wrong):
5
+ GET /mesh/lookup/descriptor?label=<q>&match=contains&limit=N
6
+ → [{resource:"http://id.nlm.nih.gov/mesh/D001241", label:"Aspirin"}, …]
7
+ **the search param is label** (passing term/descriptor just gets a 400 {"label":["must not be empty"]});
8
+ the match closed set is contains|exact|startswith (an illegal value such as word gets a 400 listing the closed set).
9
+ GET /mesh/lookup/details?descriptor=D001241
10
+ → {descriptor, qualifiers[], seealso[], terms:[{resource,label,preferred}]}
11
+ — terms is the synonym-entry pool of that descriptor (the preferred:true slot is the authoritative name); qualifiers are the qualifier sub-anchors.
12
+ Every term resolution is a fixed two hops (search + details of the preferred record); on a miss "zero hits is a legal answer" — it is not treated as an error.
13
+ TTL: MeSH releases yearly → 30 days; polite tier 1 rps (NLM's keyless guidance says ≤3).
14
+ """
15
+ import re
16
+
17
+ from ..errors import SdkError
18
+
19
+ provider = {
20
+ "id": "mesh",
21
+ "baseUrl": "https://id.nlm.nih.gov",
22
+ # NLM/UMLS family 20/s (tier B, MeSH lookup is not listed separately); 0.2.8 sped up to 10/4 into the nlm group bucket (same note as rxnav)
23
+ "rate": {"rps": 10, "burst": 4, "group": "nlm"},
24
+ "ops": {
25
+ "lookup": {"path": "/mesh/lookup/descriptor"}, # params: label, match, limit
26
+ "details": {"path": "/mesh/lookup/details"}, # params: descriptor
27
+ },
28
+ "ttl": {"lookup": 30 * 86400, "details": 30 * 86400},
29
+ }
30
+
31
+ _UI = re.compile(r"([A-Z]\d{6})$")
32
+
33
+
34
+ def _ui_of(resource):
35
+ m = _UI.search(str(resource or ""))
36
+ return m.group(1) if m else None
37
+
38
+
39
+ def descriptors_from_upstream(data, size):
40
+ """Upstream payload → descriptor candidates (pure function, testable offline; upstream echoes no total — lookup returns a truncated list)."""
41
+ items = data if isinstance(data, list) else []
42
+ return {
43
+ "candidates": [{"mesh_ui": _ui_of(d.get("resource")),
44
+ "descriptor_uri": d.get("resource"),
45
+ "label": d.get("label")} for d in items],
46
+ "limit_applied": size,
47
+ }
48
+
49
+
50
+ def terms_from_upstream(details):
51
+ """details payload → the synonym pool (pure function, testable offline): the preferred entry comes first."""
52
+ terms = [{"mesh_ui": _ui_of(t.get("resource")), "label": t.get("label"), "preferred": t.get("preferred") is True}
53
+ for t in ((details or {}).get("terms") or [])]
54
+ preferred = next((t["label"] for t in terms if t["preferred"]), None) or (terms[0]["label"] if terms else None)
55
+ return {
56
+ "terms": terms,
57
+ "preferred_term": preferred,
58
+ "synonyms": [t["label"] for t in terms if not t["preferred"]],
59
+ }
60
+
61
+
62
+ def search_descriptors(q, *, match="contains", limit=10, cache=None, no_cache=False):
63
+ """→ {candidates[], limit_applied, _meta}; descriptor candidates (the match closed set is contains|exact|startswith)."""
64
+ from ..transport import call_provider
65
+
66
+ label = str(q or "").strip()
67
+ if not label:
68
+ raise SdkError("usage_error", provider="mesh", detail="search_descriptors needs value (a drug/disease name, etc.)")
69
+ if match not in ("contains", "exact", "startswith"):
70
+ raise SdkError("usage_error", provider="mesh", detail=f"match must be contains|exact|startswith, got '{match}'")
71
+ size = min(max(1, int(limit or 10)), 50)
72
+ r = call_provider(provider, "lookup", {"label": label, "match": match, "limit": size}, cache=cache, no_cache=no_cache)
73
+ return {**descriptors_from_upstream(r["data"], size), "_meta": r}
74
+
75
+
76
+ def descriptor_details(ui, *, cache=None, no_cache=False):
77
+ """→ {terms[], preferred_term, synonyms[], descriptor, qualifiers[], seealso[], _meta}; the descriptor master record."""
78
+ from ..transport import call_provider
79
+
80
+ descriptor = str(ui or "").strip()
81
+ if not re.fullmatch(r"[A-Z]\d{6}", descriptor):
82
+ raise SdkError("usage_error", provider="mesh", detail=f"not a MeSH UI shape (a letter + 6 digits): {ui}")
83
+ r = call_provider(provider, "details", {"descriptor": descriptor}, cache=cache, no_cache=no_cache)
84
+ d = r["data"][0] if isinstance(r["data"], list) and r["data"] else r["data"] # guard against array wrapping
85
+ return {**terms_from_upstream(d),
86
+ "descriptor": descriptor,
87
+ "qualifiers": [_ui_of(q) for q in ((d or {}).get("qualifiers") or [])],
88
+ "seealso": [_ui_of(s) for s in ((d or {}).get("seealso") or [])],
89
+ "_meta": r}
@@ -0,0 +1,164 @@
1
+ """rasff — EU RASFF Window food-safety alert search face provider
2
+ (webgate.ec.europa.eu/rasff-window/; keyless).
3
+
4
+ healthcare domain (0.3.19; admitted 2026-11-02, probed). Shapes verified by first-hand live probing
5
+ 2026-11-02 (the probe corpus lives in tmp/probe-ca-eu-faces/):
6
+ GET /rasff-window/screen/search
7
+ → the SPA shell — its GET mints the JSESSIONID session cookie (the two-step handshake).
8
+ POST /rasff-window/backend/public/notification/search/consolidated/en/
9
+ → body {"parameters": {...}}; the response envelope is {notifications[], totalPages, totalElements}.
10
+ **Pit nailed: the path must end with the language segment AND a trailing slash — dropping either
11
+ one returns 404 (not an empty set).**
12
+ **Pit nailed (Origin/Referer are mandatory): the POST carries Origin + Referer to the window
13
+ screen plus the session cookie — the probe's requests without them do not return the search
14
+ envelope.**
15
+ Verified filter shapes (all three confirmed by the totalElements oracle, 2026-11-02):
16
+ subject → free text on the alert subject (aflatoxin → 2,919 hits)
17
+ notificationReference → the reference number, exact (2026.9701 → 1 hit)
18
+ ecValidDateFrom/To → the window dates, format "dd-MM-yyyy 00:00:00" (01-09-2026 → 70 hits).
19
+ The format is the upstream's own (the SPA's formatDate helper: en-GB dd/MM/yyyy → dd-MM-yyyy
20
+ + " 00:00:00"); ISO input is rejected with 400.
21
+ pageNumber (1-based) + itemsPerPage (the browser default 25).
22
+ **Known limitation (documented, not guessed around): the enum-shaped filters — notifyingCountry /
23
+ originCountry / productCategory / notificationClassification / riskDecision / notificationType /
24
+ notificationStatus / notificationBasis / actionTaken / analyticalFinding — are NOT exposed here.
25
+ Their parameter names come from the SPA's search component, but every object shape probed
26
+ ({id,description} / {id} / plain string / full object) was rejected with the same generic
27
+ [ERR-1003] body, so the shapes stay unproven and guessing them would burn requests for a silent
28
+ near-miss. This face therefore ships the three proven filters + paging only.**
29
+ Row shape: notif_id, ecValidationDate ("30-10-2026 18:59:15"), reference ("2026.9701"),
30
+ notifyingCountry{organizationName, isoCode}, subject, productCategory{id,description},
31
+ productType{id,description}, notificationClassification{id,description}, riskDecision{id,description},
32
+ published (bool), originCountries[{organizationName, isoCode}].
33
+ Corpus: 33,256 notifications across 11,086 pages (measured 2026-11-02).
34
+ TTL 24h (the window is refreshed on the EU's own cadence); polite rate 1 rps (the portal publishes
35
+ no quota for this backend — conservative).
36
+ """
37
+ import re
38
+
39
+ from ..errors import SdkError
40
+
41
+ provider = {
42
+ "id": "rasff",
43
+ "baseUrl": "https://webgate.ec.europa.eu",
44
+ "rate": {"rps": 1, "burst": 2},
45
+ "ops": {
46
+ "screen": {"path": "/rasff-window/screen/search", "accept": "text"}, # the session bootstrap
47
+ "search": {"path": "/rasff-window/backend/public/notification/search/consolidated/en/",
48
+ "method": "POST", "idempotent": True}, # params: parameters{...}
49
+ },
50
+ "ttl": {"search": 24 * 3600},
51
+ }
52
+
53
+ _SESSION_TTL = 15 * 60 # the JSESSIONID pairing is reused inside its session window
54
+
55
+ _ORIGIN = "https://webgate.ec.europa.eu"
56
+ _REFERER = "https://webgate.ec.europa.eu/rasff-window/screen/search"
57
+ _DATE = re.compile(r"^(\d{2})-(\d{2})-(\d{4})(?:\s+00:00:00)?$")
58
+
59
+
60
+ def _fmt_date(v, field):
61
+ """'01-09-2026' (or the full 'dd-MM-yyyy 00:00:00' form) → the upstream date string.
62
+
63
+ The upstream format is fixed by the SPA's own formatDate helper (en-GB dd/MM/yyyy →
64
+ 'dd-MM-yyyy 00:00:00'); ISO input is rejected upstream with 400, so it is converted here
65
+ rather than passed through."""
66
+ s = str(v or "").strip()
67
+ if not s:
68
+ return None
69
+ m = _DATE.match(s)
70
+ if not m:
71
+ raise SdkError("usage_error", provider="rasff",
72
+ detail=f"{field} must be dd-MM-yyyy (the upstream date format, e.g. 01-09-2026 — ISO input is rejected upstream with 400), got \"{v}\"")
73
+ return f"{m.group(1)}-{m.group(2)}-{m.group(3)} 00:00:00"
74
+
75
+
76
+ def notifications_from_upstream(data):
77
+ """Upstream envelope → notification rows (pure function, offline-testable)."""
78
+ rows = []
79
+ for n in ((data or {}).get("notifications") or []):
80
+ rows.append({
81
+ "notif_id": n.get("notifId"),
82
+ "reference": n.get("reference"),
83
+ "ec_validation_date": n.get("ecValidationDate"),
84
+ "subject": n.get("subject"),
85
+ "notifying_country": (n.get("notifyingCountry") or {}).get("organizationName"),
86
+ "notifying_country_iso": (n.get("notifyingCountry") or {}).get("isoCode"),
87
+ "origin_countries": [{"name": (c or {}).get("organizationName"), "iso": (c or {}).get("isoCode")}
88
+ for c in (n.get("originCountries") or [])],
89
+ "product_category": (n.get("productCategory") or {}).get("description"),
90
+ "product_type": (n.get("productType") or {}).get("description"),
91
+ "classification": (n.get("notificationClassification") or {}).get("description"),
92
+ "risk_decision": (n.get("riskDecision") or {}).get("description"),
93
+ "published": n.get("published"),
94
+ })
95
+ return {"rows": rows, "count": len(rows),
96
+ "total_elements": (data or {}).get("totalElements"),
97
+ "total_pages": (data or {}).get("totalPages")}
98
+
99
+
100
+ def _session(cache, no_cache=False):
101
+ """-> the session cookie header (the JSESSIONID minted by the window shell GET); cached 15 minutes."""
102
+ if not no_cache and cache is not None:
103
+ hit = cache.get("rasff", "window_session", _SESSION_TTL)
104
+ if hit:
105
+ return hit["payload"]["cookie"]
106
+ from ..transport import call_provider
107
+ r = call_provider(provider, "screen", {}, cache=cache, no_cache=True)
108
+ cookies = r.get("set_cookie") or []
109
+ if not cookies:
110
+ raise SdkError("upstream_unavailable", provider="rasff",
111
+ detail="the window shell did not mint a session cookie (the handshake shape may have changed)")
112
+ cookie = "; ".join(c.split(";", 1)[0].strip() for c in cookies)
113
+ if cache is not None:
114
+ cache.set("rasff", "window_session", {"cookie": cookie}, _SESSION_TTL)
115
+ return cookie
116
+
117
+
118
+ def search_raw(subject=None, *, notification_reference=None, ec_valid_date_from=None,
119
+ ec_valid_date_to=None, page_number=1, items_per_page=25, cache=None, no_cache=False):
120
+ """Flat-panel low-level face: the RASFF notification search (the consolidated English window).
121
+
122
+ The three filters are the proven ones (subject / notificationReference / ecValidDateFrom-To);
123
+ paging is pageNumber (1-based) + itemsPerPage (the browser default 25, politeness cap 200).
124
+ → {rows[], count, total_elements, total_pages, params, _meta} — params = the upstream body
125
+ actually sent (the "parameters" object), replayable."""
126
+ from ..transport import call_provider
127
+
128
+ idx = int(page_number) if page_number is not None else 1
129
+ if idx < 1:
130
+ raise SdkError("usage_error", provider="rasff", detail=f"page_number must be >=1 (1-based, as the SPA sends it), got \"{page_number}\"")
131
+ size = int(items_per_page) if items_per_page is not None else 25
132
+ if not 1 <= size <= 200:
133
+ raise SdkError("usage_error", provider="rasff",
134
+ detail=f"items_per_page must be 1-200 (the browser default is 25; politeness cap — the portal publishes no quota), got \"{items_per_page}\"")
135
+ params = {"pageNumber": idx, "itemsPerPage": size}
136
+ subj = str(subject or "").strip()
137
+ if subj:
138
+ params["subject"] = subj
139
+ ref = str(notification_reference or "").strip()
140
+ if ref:
141
+ params["notificationReference"] = ref
142
+ dfrom = _fmt_date(ec_valid_date_from, "ec_valid_date_from")
143
+ if dfrom:
144
+ params["ecValidDateFrom"] = dfrom
145
+ dto = _fmt_date(ec_valid_date_to, "ec_valid_date_to")
146
+ if dto:
147
+ params["ecValidDateTo"] = dto
148
+
149
+ cookie = _session(cache, no_cache)
150
+ hdrs = {"Origin": _ORIGIN, "Referer": _REFERER, "Cookie": cookie,
151
+ "Accept": "application/json, text/plain, */*"}
152
+ try:
153
+ r = call_provider(provider, "search", {"parameters": params}, cache=cache, no_cache=no_cache,
154
+ extra_headers=hdrs)
155
+ except SdkError as e:
156
+ # a dead session cookie (the window re-mints it) → re-collect a FRESH pairing once and resend
157
+ # (the re-collection is itself uncached, so it runs under no_cache too)
158
+ if e.status not in (400, 403):
159
+ raise
160
+ cookie = _session(None)
161
+ r = call_provider(provider, "search", {"parameters": params}, cache=cache, no_cache=no_cache,
162
+ extra_headers={"Origin": _ORIGIN, "Referer": _REFERER, "Cookie": cookie,
163
+ "Accept": "application/json, text/plain, */*"})
164
+ return {**notifications_from_upstream(r["data"]), "params": params, "_meta": r}
@@ -0,0 +1,67 @@
1
+ """rxnav — NLM RxNav (drug name → RxCUI normalization + ATC property; keyless; official 20/s per IP — 0.2.8 sped up to 10 rps into the nlm group bucket).
2
+
3
+ TTL: drug name → RxCUI and the properties are permanent (concepts are immutable, the monthly release is a fallback replay).
4
+ Note: the interaction API is retired (on 2026-10-16 both list/interaction endpoints were measured 404 Not found, including the ONCHigh source) —
5
+ drug interactions are **not in the capability set**; the honest negative result is recorded.
6
+ """
7
+
8
+ provider = {
9
+ "id": "rxnav",
10
+ "baseUrl": "https://rxnav.nlm.nih.gov/REST",
11
+ # Official 20/s (RxNav ToS tier A); 10/4 per source (the window's upper bound 14<=20), and over-subscription inside the family is absorbed by the nlm group bucket (10+10=20) as a fallback
12
+ "rate": {"rps": 10, "burst": 4, "group": "nlm"},
13
+ "ops": {
14
+ "rxcuiByName": {"path": "/rxcui.json"},
15
+ "rxcuiProps": {"path": "/rxcui/{rxcui}/properties.json"},
16
+ "rxcuiAtc": {"path": "/rxcui/{rxcui}/property.json"},
17
+ },
18
+ "ttl": {"rxcuiByName": None, "rxcuiProps": None, "rxcuiAtc": None},
19
+ }
20
+
21
+
22
+ def card_from_props(p: dict = None) -> dict:
23
+ """Probed and confirmed (2026-10-22): synonym is often an empty string rather than null (Lipitor 153165 synonym:"") — an empty string becomes None;
24
+ tty is the concept-level key (BN=brand name / IN=ingredient name), suppress is the quality flag (N=normal) — tty is the gate on ATC semantics (see get_atc)."""
25
+ p = p or {}
26
+ return {
27
+ "normalized_name": p.get("name"),
28
+ "synonym": p.get("synonym") or None,
29
+ "tty": p.get("tty"),
30
+ "suppress": p.get("suppress"),
31
+ }
32
+
33
+
34
+ def atc_from_upstream(data: dict = None) -> list:
35
+ """Probed and confirmed: ATC only attaches to ingredient-level (IN) concepts — querying ATC for Lipitor 153165(BN) returns {} with the whole propConceptGroup missing,
36
+ while atorvastatin 83367(IN) gives C10AA05; a brand-name hit must be lowered to the ingredient level and queried again (the client layer hints at this in reason)."""
37
+ data = data or {}
38
+ concepts = ((data.get("propConceptGroup") or {}).get("propConcept")) or []
39
+ return [c.get("propValue") for c in concepts if c.get("propName") == "ATC" and c.get("propValue")]
40
+
41
+
42
+ def get_atc(rxcui: str, cache=None, no_cache: bool = False) -> dict:
43
+ """RxCUI → the list of ATC codes (a cross-jurisdiction drug name key, the two-key model «RxCUI(US)+ATC(global)»).
44
+ A concept with no ATC (typically a brand-name level BN concept) returns an empty list — an honest empty set, not an error."""
45
+ from ..transport import call_provider
46
+
47
+ r = call_provider(provider, "rxcuiAtc", {"rxcui": str(rxcui), "propName": "ATC"},
48
+ cache=cache, no_cache=no_cache)
49
+ return {"atc": atc_from_upstream(r.get("data")), "_meta": r}
50
+
51
+
52
+ def resolve_drug_name(name: str, cache=None, no_cache: bool = False) -> dict:
53
+ from ..transport import call_provider
54
+ from ..errors import SdkError
55
+
56
+ ids = call_provider(provider, "rxcuiByName", {"name": name}, cache=cache, no_cache=no_cache)
57
+ rxcuis = ((ids["data"] or {}).get("idGroup") or {}).get("rxnormId") or []
58
+ if not rxcuis:
59
+ raise SdkError("upstream_not_found", provider="rxnav", detail=f"RxNav has no RxCUI for this drug name: {name}")
60
+ rxcui = rxcuis[0]
61
+ props = call_provider(provider, "rxcuiProps", {"rxcui": rxcui}, cache=cache, no_cache=no_cache)
62
+ return {
63
+ "rxcui": rxcui,
64
+ **card_from_props((props["data"] or {}).get("properties")),
65
+ **({"all_matches": rxcuis} if len(rxcuis) > 1 else {}),
66
+ "_meta": {"lookup": ids, "props": props},
67
+ }