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,86 @@
1
+ """fda_crl — FDA Complete Response Letters provider (openFDA transparency/crl.json; keyless).
2
+
3
+ Fourth of the four Wave 10 healthcare faces. A CRL is the closing letter of a drug application review: FDA's formal
4
+ "cannot approve" letter for an NDA/ANDA application — a **company × negative-approval event** stream (approval-risk face).
5
+ The contract is shared with the shortages/recalls family: GET /transparency/crl.json?search=&limit=&skip=&sort=,
6
+ zero hits = HTTP 404 NOT_FOUND is a legal answer (the empty envelope is not cached).
7
+ Row shape measured 2026-10-28: company_name:"InnoPharma" → 3 hits;
8
+ rows have 13 keys, and **company_rep (the letter recipient's representative) and approver_name (the issuing officer) are both person names and are not returned, nor is text (OCR full-text
9
+ noise)** — PII red line + noise reduction (aligned with the PhilGEPS Contact Person / UK IIR personal-face precedents).
10
+ TTL 30d (a historical letter face, near-zero increments); rate limit is the polite 2 rps shared by the same-host family.
11
+ """
12
+ from datetime import datetime, timezone
13
+
14
+ from ..errors import SdkError
15
+
16
+ provider = {
17
+ "id": "fda_crl",
18
+ "baseUrl": "https://api.fda.gov",
19
+ "rate": {"rps": 2, "burst": 5, "group": "fda"},
20
+ "ops": {
21
+ "search": {"path": "/transparency/crl.json", "notFound": [404]}, # params: search, limit, skip, sort
22
+ },
23
+ "ttl": {"search": 30 * 24 * 3600},
24
+ }
25
+
26
+
27
+ def rows_from_upstream(results):
28
+ """Upstream rows → CRL rows (pure function, testable offline; key order verified against the 13 keys seen on 2026-10-28).
29
+
30
+ company_rep (the letter recipient's representative) and approver_name (the issuing officer) are both person names and are not returned — PII red line;
31
+ text (OCR full-text noise) is not returned — noise reduction.
32
+ """
33
+ rows = results if isinstance(results, list) else []
34
+ out = []
35
+ for r in rows:
36
+ out.append({
37
+ "company_name": r.get("company_name"), # company edge, primary column
38
+ "company_address": r.get("company_address"),
39
+ "letter_date": r.get("letter_date"),
40
+ "letter_year": r.get("letter_year"),
41
+ "letter_type": r.get("letter_type"),
42
+ "application_number": r.get("application_number"), # links to the drugsfda application line
43
+ "approval_status": r.get("approval_status"), # application outcome after the CRL
44
+ "file_name": r.get("file_name"), # key of the original letter PDF
45
+ "approver_center": r.get("approver_center"), # issuing center (organizational side, e.g. CDER)
46
+ "approver_title": r.get("approver_title"), # issuing officer's title (organizational side)
47
+ })
48
+ return out
49
+
50
+
51
+ def search(search, *, limit=20, skip=0, sort="letter_date:desc", cache=None, no_cache=False):
52
+ """Born flat: the search expression is passed through verbatim (upstream param names search/limit/skip/sort, zero sugar).
53
+ limit 1-1000 (the openFDA search hard limit); search is required (local safety gate: a bare full-table query is served by the bulk harvest line).
54
+ → {rows[], total, count, skip, limit_applied, has_more, search, sort, _meta}; zero hits = an empty set that is not cached.
55
+ """
56
+ from ..transport import call_provider
57
+
58
+ q = str(search or "").strip()
59
+ if not q:
60
+ raise SdkError("usage_error", provider=provider["id"],
61
+ detail="search requires an expression (e.g. company_name:\"Firm Name\"; a bare full-table query is served by the bulk line)")
62
+ size = int(limit) if limit is not None else 20 # 0 is an explicitly bad value (do not let `or` swallow it into the default); the gate below rejects it
63
+ if not 1 <= size <= 1000:
64
+ raise SdkError("usage_error", provider=provider["id"],
65
+ detail=f"limit must be 1-1000 (the openFDA search hard limit, same as measured for shortages), got {size}")
66
+ sk = max(0, int(skip or 0))
67
+ so = str(sort or "").strip()
68
+ params = {"search": q, "limit": size}
69
+ if sk:
70
+ params["skip"] = sk
71
+ if so:
72
+ params["sort"] = so
73
+ try:
74
+ r = call_provider(provider, "search", params, cache=cache, no_cache=no_cache)
75
+ except SdkError as e:
76
+ if e.code == "upstream_not_found":
77
+ return {"rows": [], "total": 0, "count": 0, "skip": sk, "limit_applied": size,
78
+ "has_more": False, "search": q, "sort": so or None,
79
+ "_meta": {"from_cache": False,
80
+ "fetched_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")}}
81
+ raise
82
+ data = r.get("data") or {}
83
+ rows = rows_from_upstream(data.get("results"))
84
+ total = (data.get("meta") or {}).get("results", {}).get("total", len(rows))
85
+ return {"rows": rows, "total": total, "count": len(rows), "skip": sk, "limit_applied": size,
86
+ "has_more": total > sk + len(rows), "search": q, "sort": so or None, "_meta": r}
@@ -0,0 +1,199 @@
1
+ """fda_devices — FDA device provider, four faces (openFDA device/510k · pma · registrationlisting · classification; keyless).
2
+
3
+ Wave 10b of the healthcare domain (second batch; row shapes measured 2026-10-29).
4
+ The family contract is shared with fda_recalls/shortages/fda_drugs: GET /<ep>.json?search=&limit=&skip=&sort=,
5
+ zero hits = HTTP 404 {"error":{"code":"NOT_FOUND"}} is a legal answer (notFound:[404] → an empty envelope that is not cached).
6
+ Probing anchors on 2026-10-29 (field names verified by _exists_, not inferred from the docs):
7
+ device/510k (176,466 clearances): company field = **applicant** (the same name as pma; the docs once said applicant_name — overturned);
8
+ **contact (a person-name slot, e.g. Marybeth Gamber) is not returned — the same PII red line as additional_info_contact on Wave 10 device recalls**;
9
+ openfda.registration_number can carry 100+ entries on one row (row inflation), so the whole slot is not returned.
10
+ device/pma (12,896 approvals): company field = applicant; ao_statement (long approval text) is not returned (row slimming, same precedent as CRL text);
11
+ street_1/2+zip are not returned (street-level address; city/state are kept).
12
+ device/registrationlisting (336,946 registration rows, quarterly): company fields = **registration.name + registration.owner_operator.firm_name**
13
+ (three keys: registration_number/fei_number/owner_operator_number; the top-level firm_name/fei_number etc. all 404 under _exists_ —
14
+ everything lives in the registration.* namespace; the default sort on the nested key registration.reg_expiry_date_year:desc was measured to work).
15
+ **registration.us_agent (US agent name+phone+email+address) and owner_operator.official_correspondent (personal contact) are not returned — PII red line**.
16
+ device/classification (the product-code taxonomy face): no date field → no default sort; the openfda sub-object is small (k_number/registration_number
17
+ /fei_number cross keys), so it is kept whole.
18
+ Rate limit: the openFDA family shares a keyless quota (polite 2 rps per face, the family total must stay under ~4 rps).
19
+ TTL: 510k/pma 24h, registrations 7d (quarterly), classification 30d (the taxonomy is near-static).
20
+ """
21
+ from datetime import datetime, timezone
22
+
23
+ from ..errors import SdkError
24
+
25
+ provider = {
26
+ "id": "fda_devices",
27
+ "baseUrl": "https://api.fda.gov",
28
+ "rate": {"rps": 2, "burst": 5, "group": "fda"},
29
+ "ops": {
30
+ "clearance_510k": {"path": "/device/510k.json", "notFound": [404]},
31
+ "pma": {"path": "/device/pma.json", "notFound": [404]},
32
+ "registrations": {"path": "/device/registrationlisting.json", "notFound": [404]},
33
+ "classification": {"path": "/device/classification.json", "notFound": [404]},
34
+ },
35
+ "ttl": {"clearance_510k": 24 * 3600, "pma": 24 * 3600,
36
+ "registrations": 7 * 24 * 3600, "classification": 30 * 24 * 3600},
37
+ }
38
+
39
+ # Default sort per face (our default, not the upstream one; classification has no date field → None)
40
+ _DEFAULT_SORT = {"clearance_510k": "decision_date:desc",
41
+ "pma": "decision_date:desc",
42
+ "registrations": "registration.reg_expiry_date_year:desc"}
43
+
44
+
45
+ def _k510_rows(results):
46
+ """device/510k rows → premarket notification clearance rows (pure function, testable offline). Key order verified against the 2026-10-29 probe of k510.ex2.json.
47
+
48
+ contact (the applicant's contact, often a person name) and openfda (a registration_number list with 100+ entries per row) are not returned.
49
+ """
50
+ out = []
51
+ for r in results if isinstance(results, list) else []:
52
+ out.append({
53
+ "k_number": r.get("k_number"), # hub key (links to the recall k_numbers)
54
+ "applicant": r.get("applicant"), # company edge, primary column (the applicant)
55
+ "device_name": r.get("device_name"),
56
+ "clearance_type": r.get("clearance_type"),
57
+ "decision_code": r.get("decision_code"), # SESE/SE/…
58
+ "decision_description": r.get("decision_description"),
59
+ "decision_date": r.get("decision_date"), # ISO date
60
+ "date_received": r.get("date_received"),
61
+ "expedited_review_flag": r.get("expedited_review_flag"),
62
+ "third_party_flag": r.get("third_party_flag"),
63
+ "advisory_committee": r.get("advisory_committee"),
64
+ "advisory_committee_description": r.get("advisory_committee_description"),
65
+ "product_code": r.get("product_code"), # links to the classification face
66
+ "statement_or_summary": r.get("statement_or_summary"),
67
+ "city": r.get("city"), "state": r.get("state"), "country_code": r.get("country_code"),
68
+ })
69
+ return out
70
+
71
+
72
+ def _pma_rows(results):
73
+ """device/pma rows → premarket approval rows (pure function). The ao_statement long text and street/zip are not returned (row slimming + street-level address)."""
74
+ out = []
75
+ for r in results if isinstance(results, list) else []:
76
+ out.append({
77
+ "pma_number": r.get("pma_number"), # hub key
78
+ "supplement_number": r.get("supplement_number"),
79
+ "supplement_type": r.get("supplement_type"),
80
+ "supplement_reason": r.get("supplement_reason"),
81
+ "applicant": r.get("applicant"), # company edge, primary column
82
+ "trade_name": r.get("trade_name"),
83
+ "generic_name": r.get("generic_name"),
84
+ "decision_code": r.get("decision_code"), # APPR/…
85
+ "decision_date": r.get("decision_date"), # ISO date
86
+ "date_received": r.get("date_received"),
87
+ "expedited_review_flag": r.get("expedited_review_flag"),
88
+ "advisory_committee": r.get("advisory_committee"),
89
+ "advisory_committee_description": r.get("advisory_committee_description"),
90
+ "product_code": r.get("product_code"),
91
+ "docket_number": r.get("docket_number"),
92
+ "city": r.get("city"), "state": r.get("state"),
93
+ })
94
+ return out
95
+
96
+
97
+ def _reg_rows(results):
98
+ """device/registrationlisting rows → facility registration rows (pure function). Key order verified against the 2026-10-29 probe of reg.f2-registration.name.json.
99
+
100
+ registration.us_agent (US agent name/phone/email/address) and owner_operator.official_correspondent (personal contact)
101
+ are not returned — PII red line; address_line_1/2 (street-level) are not returned, city/iso_country_code/postal_code are kept.
102
+ registration.* is flattened to the top level (the namespace survives only inside the search expression).
103
+ """
104
+ out = []
105
+ for r in results if isinstance(results, list) else []:
106
+ reg = r.get("registration") or {}
107
+ own = reg.get("owner_operator") or {}
108
+ out.append({
109
+ "registration_number": reg.get("registration_number"), # hub key (links to 510k openfda / recalls)
110
+ "fei_number": reg.get("fei_number"), # FEI facility key (links to the recall firm_fei_number)
111
+ "firm_name": reg.get("name"), # company edge, primary column (the registered facility name)
112
+ "owner_operator_number": own.get("owner_operator_number"),
113
+ "owner_operator_name": own.get("firm_name"), # parent / responsible-operator name
114
+ "status_code": reg.get("status_code"),
115
+ "initial_importer_flag": reg.get("initial_importer_flag"),
116
+ "reg_expiry_date_year": reg.get("reg_expiry_date_year"),
117
+ "city": reg.get("city"), "state_code": reg.get("state_code"),
118
+ "iso_country_code": reg.get("iso_country_code"), "postal_code": reg.get("postal_code"),
119
+ "establishment_types": r.get("establishment_type"),
120
+ "proprietary_names": r.get("proprietary_name"),
121
+ "k_number": r.get("k_number"), "pma_number": r.get("pma_number"),
122
+ "products": [{"product_code": p.get("product_code"),
123
+ "created_date": p.get("created_date"),
124
+ "openfda": p.get("openfda")} # device_name/regulation_number/device_class
125
+ for p in (r.get("products") or []) if isinstance(p, dict)],
126
+ })
127
+ return out
128
+
129
+
130
+ def _cls_rows(results):
131
+ """device/classification rows → product-code taxonomy rows (pure function). The openfda cross keys are small, so they are kept whole."""
132
+ out = []
133
+ for r in results if isinstance(results, list) else []:
134
+ out.append({
135
+ "product_code": r.get("product_code"), # hub key (links to 510k/pma/recalls)
136
+ "device_name": r.get("device_name"),
137
+ "device_class": r.get("device_class"), # 1/2/3
138
+ "definition": r.get("definition"),
139
+ "regulation_number": r.get("regulation_number"),
140
+ "medical_specialty": r.get("medical_specialty"),
141
+ "medical_specialty_description": r.get("medical_specialty_description"),
142
+ "review_panel": r.get("review_panel"),
143
+ "review_code": r.get("review_code"),
144
+ "submission_type_id": r.get("submission_type_id"),
145
+ "implant_flag": r.get("implant_flag"),
146
+ "gmp_exempt_flag": r.get("gmp_exempt_flag"),
147
+ "third_party_flag": r.get("third_party_flag"),
148
+ "life_sustain_support_flag": r.get("life_sustain_support_flag"),
149
+ "summary_malfunction_reporting": r.get("summary_malfunction_reporting"),
150
+ "unclassified_reason": r.get("unclassified_reason"),
151
+ "openfda": r.get("openfda"), # k_number/registration_number/fei_number
152
+ })
153
+ return out
154
+
155
+
156
+ _ROWS = {"clearance_510k": _k510_rows, "pma": _pma_rows,
157
+ "registrations": _reg_rows, "classification": _cls_rows}
158
+
159
+
160
+ def search(face, search, *, limit=20, skip=0, sort=None, cache=None, no_cache=False):
161
+ """Born flat: the search expression is passed through verbatim (upstream param names search/limit/skip/sort, zero sugar).
162
+ face ∈ clearance_510k|pma|registrations|classification. limit 1-1000 (the openFDA search hard limit).
163
+ search is required (local safety gate: under a keyless quota a bare full-table query is served by the bulk harvest line).
164
+ → {rows[], total, count, skip, limit_applied, has_more, search, sort, _meta}; zero hits = an empty set that is not cached.
165
+ """
166
+ from ..transport import call_provider
167
+
168
+ if face not in provider["ops"]:
169
+ raise SdkError("usage_error", provider=provider["id"],
170
+ detail=f"face must be clearance_510k|pma|registrations|classification, got '{face}'")
171
+ q = str(search or "").strip()
172
+ if not q:
173
+ raise SdkError("usage_error", provider=provider["id"],
174
+ detail="search requires an expression (e.g. registration.name:\"Firm Name\" or applicant:\"Medtronic\"; a bare full-table query is served by the bulk line)")
175
+ size = int(limit) if limit is not None else 20 # 0 is an explicitly bad value (do not let `or` swallow it into the default); the gate below rejects it
176
+ if not 1 <= size <= 1000:
177
+ raise SdkError("usage_error", provider=provider["id"],
178
+ detail=f"limit must be 1-1000 (the openFDA search hard limit, same as shortages measured on 2026-10-23), got {size}")
179
+ sk = max(0, int(skip or 0))
180
+ so = str(sort).strip() if sort is not None else _DEFAULT_SORT.get(face)
181
+ params = {"search": q, "limit": size}
182
+ if sk:
183
+ params["skip"] = sk
184
+ if so:
185
+ params["sort"] = so
186
+ try:
187
+ r = call_provider(provider, face, params, cache=cache, no_cache=no_cache)
188
+ except SdkError as e:
189
+ if e.code == "upstream_not_found":
190
+ return {"rows": [], "total": 0, "count": 0, "skip": sk, "limit_applied": size,
191
+ "has_more": False, "search": q, "sort": so or None,
192
+ "_meta": {"from_cache": False,
193
+ "fetched_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")}}
194
+ raise
195
+ data = r.get("data") or {}
196
+ rows = _ROWS[face](data.get("results"))
197
+ total = (data.get("meta") or {}).get("results", {}).get("total", len(rows))
198
+ return {"rows": rows, "total": total, "count": len(rows), "skip": sk, "limit_applied": size,
199
+ "has_more": total > sk + len(rows), "search": q, "sort": so or None, "_meta": r}
@@ -0,0 +1,156 @@
1
+ """fda_drugs — FDA drug provider, three faces (openFDA drug/drugsfda · drug/ndc · drug/orangebook; keyless).
2
+
3
+ Wave 10b of the healthcare domain (second batch; row shapes measured 2026-10-29).
4
+ The family contract is shared with fda_recalls/shortages: GET /<ep>.json?search=&limit=&skip=&sort=, zero hits = HTTP 404
5
+ {"error":{"code":"NOT_FOUND"}} is a legal answer (notFound:[404] → an empty envelope that is not cached).
6
+ Probing anchors on 2026-10-29 (field names verified by _exists_, not inferred from the docs):
7
+ drug/drugsfda (29,420 applications): company field = **sponsor_name** (the docs once said applicant_name — overturned by _exists_);
8
+ a row = application_number × products[] × submissions[]; the default sort is the nested key submissions.submission_status_date:desc (measured to sort).
9
+ drug/ndc (NDC catalogue, 138,153+ rows, daily refresh): company field = labeler_name (plus the openfda.manufacturer_name normalization layer);
10
+ marketing_start/end_date + marketing_category + listing_expiration_date are in the columns — **the NSDE endpoint is dead (Cannot GET
11
+ /drug/nsde.json) and its content was merged into this face** (confirmed by probing on wave10b).
12
+ drug/orangebook (48,865 rows, the Orange Book's first API face): company field = products[].application_name/_full_name
13
+ (the licence holder); the RLD/reference_standard flags sit on the row; **it replaces the dead nsde as wave10b's 7th face**.
14
+ applicant/applicant_holder/holder etc. all 404 under _exists_ — the company key only exists inside products[].
15
+ Rate limit: a keyless quota shared with fda_recalls/shortages on the same host (polite 2 rps per face, the family total must stay under ~4 rps).
16
+ TTL 24h (all three faces refresh on a daily-to-weekly cadence).
17
+ """
18
+ from datetime import datetime, timezone
19
+
20
+ from ..errors import SdkError
21
+
22
+ provider = {
23
+ "id": "fda_drugs",
24
+ "baseUrl": "https://api.fda.gov",
25
+ "rate": {"rps": 2, "burst": 5, "group": "fda"},
26
+ "ops": {
27
+ "approvals": {"path": "/drug/drugsfda.json", "notFound": [404]},
28
+ "catalog": {"path": "/drug/ndc.json", "notFound": [404]},
29
+ "orange_book": {"path": "/drug/orangebook.json", "notFound": [404]},
30
+ },
31
+ "ttl": {"approvals": 24 * 3600, "catalog": 24 * 3600, "orange_book": 24 * 3600},
32
+ }
33
+
34
+ # Default sort per face (our default, not the upstream one; drugsfda by latest submission date, ndc by marketing start, the Orange Book by approval date; all measured to sort on 2026-10-29)
35
+ _DEFAULT_SORT = {"approvals": "submissions.submission_status_date:desc",
36
+ "catalog": "marketing_start_date:desc",
37
+ "orange_book": "approval_date:desc"}
38
+
39
+
40
+ def _approvals_rows(results):
41
+ """drug/drugsfda rows → application dossier rows (pure function, testable offline). Key order verified against the 2026-10-29 probe of drugsfda.exists.json."""
42
+ out = []
43
+ for r in results if isinstance(results, list) else []:
44
+ out.append({
45
+ "application_number": r.get("application_number"), # NDA/ANDA/BLA number (hub key linking to the Orange Book)
46
+ "sponsor_name": r.get("sponsor_name"), # company edge, primary column (the applicant)
47
+ "products": [{"product_number": p.get("product_number"),
48
+ "brand_name": p.get("brand_name"),
49
+ "active_ingredients": p.get("active_ingredients"),
50
+ "dosage_form": p.get("dosage_form"),
51
+ "route": p.get("route"),
52
+ "marketing_status": p.get("marketing_status"),
53
+ "reference_drug": p.get("reference_drug"),
54
+ "reference_standard": p.get("reference_standard")}
55
+ for p in (r.get("products") or []) if isinstance(p, dict)],
56
+ "submissions": [{"submission_type": s.get("submission_type"),
57
+ "submission_number": s.get("submission_number"),
58
+ "submission_status": s.get("submission_status"),
59
+ "submission_status_date": s.get("submission_status_date")}
60
+ for s in (r.get("submissions") or []) if isinstance(s, dict)],
61
+ })
62
+ return out
63
+
64
+
65
+ def _catalog_rows(results):
66
+ """drug/ndc rows → NDC catalogue rows (pure function). The openfda sub-object is kept whole (rxcui/spl_set_id/unii product metadata, no PII)."""
67
+ out = []
68
+ for r in results if isinstance(results, list) else []:
69
+ out.append({
70
+ "product_ndc": r.get("product_ndc"), # hub key (links to shortages/recall product_ndcs)
71
+ "brand_name": r.get("brand_name"),
72
+ "generic_name": r.get("generic_name"),
73
+ "labeler_name": r.get("labeler_name"), # company edge, primary column (the labeler)
74
+ "dosage_form": r.get("dosage_form"),
75
+ "route": r.get("route"),
76
+ "finished": r.get("finished"),
77
+ "marketing_category": r.get("marketing_category"),
78
+ "marketing_start_date": r.get("marketing_start_date"), # YYYYMMDD (field merged in from NSDE)
79
+ "marketing_end_date": r.get("marketing_end_date"),
80
+ "listing_expiration_date": r.get("listing_expiration_date"),
81
+ "application_number": r.get("application_number"),
82
+ "product_type": r.get("product_type"),
83
+ "active_ingredients": r.get("active_ingredients"),
84
+ "package_ndcs": [p.get("package_ndc") for p in (r.get("packaging") or [])
85
+ if isinstance(p, dict) and p.get("package_ndc")],
86
+ "openfda": r.get("openfda"), # manufacturer_name/rxcui/spl_set_id/unii
87
+ })
88
+ return out
89
+
90
+
91
+ def _orange_rows(results):
92
+ """drug/orangebook rows → Orange Book rows (pure function). The company key lives inside products[] (the top-level applicant fields all 404 under _exists_)."""
93
+ out = []
94
+ for r in results if isinstance(results, list) else []:
95
+ out.append({
96
+ "approval_date": r.get("approval_date"), # YYYYMMDD
97
+ "product_number": r.get("product_number"),
98
+ "products": [{"brand_name": p.get("brand_name"),
99
+ "active_ingredients": p.get("active_ingredients"),
100
+ "application_name": p.get("application_name"), # company edge, primary column (short name of the licence holder)
101
+ "application_full_name": p.get("application_full_name"), # the company's full name
102
+ "application_type": p.get("application_type"), # N/ANDA
103
+ "application_number": p.get("application_number"), # hub key linking to drugsfda
104
+ "reference_listed_drug": p.get("reference_listed_drug"),
105
+ "reference_standard": p.get("reference_standard"),
106
+ "marketing_status": p.get("marketing_status"),
107
+ "dosage_form": p.get("dosage_form"),
108
+ "route": p.get("route")}
109
+ for p in (r.get("products") or []) if isinstance(p, dict)],
110
+ })
111
+ return out
112
+
113
+
114
+ _ROWS = {"approvals": _approvals_rows, "catalog": _catalog_rows, "orange_book": _orange_rows}
115
+
116
+
117
+ def search(face, search, *, limit=20, skip=0, sort=None, cache=None, no_cache=False):
118
+ """Born flat: the search expression is passed through verbatim (upstream param names search/limit/skip/sort, zero sugar).
119
+ face ∈ approvals|catalog|orange_book. limit 1-1000 (the openFDA search hard limit, same as shortages measured 1001→400).
120
+ search is required (local safety gate: under a keyless quota a bare full-table query is served by the bulk harvest line).
121
+ → {rows[], total, count, skip, limit_applied, has_more, search, sort, _meta}; zero hits = an empty set that is not cached.
122
+ """
123
+ from ..transport import call_provider
124
+
125
+ if face not in provider["ops"]:
126
+ raise SdkError("usage_error", provider=provider["id"],
127
+ detail=f"face must be approvals|catalog|orange_book, got '{face}'")
128
+ q = str(search or "").strip()
129
+ if not q:
130
+ raise SdkError("usage_error", provider=provider["id"],
131
+ detail="search requires an expression (e.g. sponsor_name:\"TEVA\" — the keyword field is case-sensitive, so use the form stored in the rows; a bare full-table query is served by the bulk line)")
132
+ size = int(limit) if limit is not None else 20 # 0 is an explicitly bad value (do not let `or` swallow it into the default); the gate below rejects it
133
+ if not 1 <= size <= 1000:
134
+ raise SdkError("usage_error", provider=provider["id"],
135
+ detail=f"limit must be 1-1000 (the openFDA search hard limit, same as shortages measured on 2026-10-23), got {size}")
136
+ sk = max(0, int(skip or 0))
137
+ so = str(sort).strip() if sort is not None else _DEFAULT_SORT[face]
138
+ params = {"search": q, "limit": size}
139
+ if sk:
140
+ params["skip"] = sk
141
+ if so:
142
+ params["sort"] = so
143
+ try:
144
+ r = call_provider(provider, face, params, cache=cache, no_cache=no_cache)
145
+ except SdkError as e:
146
+ if e.code == "upstream_not_found":
147
+ return {"rows": [], "total": 0, "count": 0, "skip": sk, "limit_applied": size,
148
+ "has_more": False, "search": q, "sort": so or None,
149
+ "_meta": {"from_cache": False,
150
+ "fetched_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")}}
151
+ raise
152
+ data = r.get("data") or {}
153
+ rows = _ROWS[face](data.get("results"))
154
+ total = (data.get("meta") or {}).get("results", {}).get("total", len(rows))
155
+ return {"rows": rows, "total": total, "count": len(rows), "skip": sk, "limit_applied": size,
156
+ "has_more": total > sk + len(rows), "search": q, "sort": so or None, "_meta": r}
@@ -0,0 +1,137 @@
1
+ """fda_recalls — FDA recall provider, three faces (openFDA drug/enforcement · device/recall · food/enforcement; keyless).
2
+
3
+ Wave 10 of the healthcare domain (2026-10-28 decision to fill out the query faces with the openFDA family; third of the four opening faces).
4
+ The family contract is shared with shortages: GET /<ep>.json?search=&limit=&skip=&sort=, zero hits = HTTP 404
5
+ {"error":{"code":"NOT_FOUND"}} is a legal answer (notFound:[404] → an empty envelope that is not cached).
6
+ Row shapes measured 2026-10-28:
7
+ drug/enforcement (18,010 rows, weekly): recalling_firm.exact:"SV Labs Toronto Corporation" → 2 hits;
8
+ status was seen as Ongoing; **the drug line has no separate recall endpoint — enforcement is the recall face** (a press-release source).
9
+ device/recall (59,618 rows, the RES recall system): recalling_firm:"Sedecal" as a phrase → 62 hits; recall_status
10
+ was seen as Terminated/Ongoing; rows carry firm_fei_number + k_numbers — **this is a different system from device/enforcement (the
11
+ press-release face already bulk-loaded)**: RES is the newer authority (and carries the full Ongoing lifecycle). .exact is unverified, so the wrapped form is always a phrase.
12
+ food/enforcement (29,485 rows): recalling_firm.exact:"TODD'S SEEDS LLC" → 6 hits; status was seen as Ongoing.
13
+ Rate limit: a keyless quota shared with shortages on the same host (polite 2 rps per face, the family total must stay under ~4 rps).
14
+ TTL 24h (all three faces are on a weekly cadence).
15
+ """
16
+ from datetime import datetime, timezone
17
+
18
+ from ..errors import SdkError
19
+
20
+ provider = {
21
+ "id": "fda_recalls",
22
+ "baseUrl": "https://api.fda.gov",
23
+ "rate": {"rps": 2, "burst": 5, "group": "fda"},
24
+ "ops": {
25
+ "drug": {"path": "/drug/enforcement.json", "notFound": [404]},
26
+ "device": {"path": "/device/recall.json", "notFound": [404]},
27
+ "food": {"path": "/food/enforcement.json", "notFound": [404]},
28
+ },
29
+ "ttl": {"drug": 24 * 3600, "device": 24 * 3600, "food": 24 * 3600},
30
+ }
31
+
32
+ # Default sort per face (our default, not the upstream one; both enforcement faces use the same key report_date, the RES face uses the posted date)
33
+ _DEFAULT_SORT = {"drug": "report_date:desc", "food": "report_date:desc",
34
+ "device": "event_date_posted:desc"}
35
+
36
+
37
+ def _enf_rows(results):
38
+ """drug/food enforcement rows → recall record rows (pure function, testable offline). Key order verified against the 2026-10-28 probe."""
39
+ out = []
40
+ for r in results if isinstance(results, list) else []:
41
+ of = r.get("openfda") or {}
42
+ out.append({
43
+ "recall_number": r.get("recall_number"),
44
+ "event_id": r.get("event_id"), # stable key (links to the bulk events corpus line)
45
+ "recalling_firm": r.get("recalling_firm"), # company edge, primary column
46
+ "classification": r.get("classification"), # Class I/II/III
47
+ "status": r.get("status"), # Ongoing/Terminated/Completed
48
+ "product_type": r.get("product_type"),
49
+ "product_description": r.get("product_description"),
50
+ "product_quantity": r.get("product_quantity"),
51
+ "reason_for_recall": r.get("reason_for_recall"),
52
+ "code_info": r.get("code_info"),
53
+ "more_code_info": r.get("more_code_info"),
54
+ "distribution_pattern": r.get("distribution_pattern"),
55
+ "voluntary_mandated": r.get("voluntary_mandated"),
56
+ "initial_firm_notification": r.get("initial_firm_notification"),
57
+ "recalled_on": r.get("recall_initiation_date"), # YYYYMMDD
58
+ "reported_on": r.get("report_date"),
59
+ "city": r.get("city"), "state": r.get("state"), "country": r.get("country"),
60
+ "address": r.get("address_1"),
61
+ "applications": of.get("application_number"), # links to the drugsfda product line
62
+ "product_ndcs": of.get("product_ndc"),
63
+ })
64
+ return out
65
+
66
+
67
+ def _device_rows(results):
68
+ """device/recall (RES system) rows → recall record rows (pure function; FEI is the company-facility-product hub key).
69
+
70
+ additional_info_contact (the recalling firm's contact, often a person name) is not returned — the same PII red line as the PhilGEPS Contact Person.
71
+ """
72
+ out = []
73
+ for r in results if isinstance(results, list) else []:
74
+ out.append({
75
+ "res_event_number": r.get("res_event_number"),
76
+ "product_res_number": r.get("product_res_number"),
77
+ "cfres_id": r.get("cfres_id"),
78
+ "recalling_firm": r.get("recalling_firm"), # company edge, primary column
79
+ "firm_fei_number": r.get("firm_fei_number"), # FEI hub key (links to registrationlisting)
80
+ "k_numbers": r.get("k_numbers"), # links to the 510(k) device line
81
+ "product_code": r.get("product_code"),
82
+ "recall_status": r.get("recall_status"), # Ongoing/Terminated/…
83
+ "action": r.get("action"),
84
+ "product_description": r.get("product_description"),
85
+ "reason_for_recall": r.get("reason_for_recall"),
86
+ "root_cause_description": r.get("root_cause_description"),
87
+ "code_info": r.get("code_info"),
88
+ "product_quantity": r.get("product_quantity"),
89
+ "distribution_pattern": r.get("distribution_pattern"),
90
+ "initiated_on": r.get("event_date_initiated"), # ISO date
91
+ "posted_on": r.get("event_date_posted"),
92
+ "terminated_on": r.get("event_date_terminated"),
93
+ "address": r.get("address_1"),
94
+ "city": r.get("city"), "state": r.get("state"),
95
+ })
96
+ return out
97
+
98
+
99
+ def search(face, search, *, limit=20, skip=0, sort=None, cache=None, no_cache=False):
100
+ """Born flat: the search expression is passed through verbatim (upstream param names search/limit/skip/sort, zero sugar).
101
+ face ∈ drug|device|food. limit 1-1000 (the openFDA search hard limit, same as shortages measured 1001→400).
102
+ search is required (local safety gate: under a keyless quota a bare full-table query is served by the bulk harvest line).
103
+ → {rows[], total, count, skip, limit_applied, has_more, search, sort, _meta}; zero hits = an empty set that is not cached.
104
+ """
105
+ from ..transport import call_provider
106
+
107
+ if face not in provider["ops"]:
108
+ raise SdkError("usage_error", provider=provider["id"], detail=f"face must be drug|device|food, got '{face}'")
109
+ q = str(search or "").strip()
110
+ if not q:
111
+ raise SdkError("usage_error", provider=provider["id"],
112
+ detail="search requires an expression (e.g. recalling_firm.exact:\"Firm Name\"; a bare full-table query is served by the bulk line)")
113
+ size = int(limit) if limit is not None else 20 # 0 is an explicitly bad value (do not let `or` swallow it into the default); the gate below rejects it
114
+ if not 1 <= size <= 1000:
115
+ raise SdkError("usage_error", provider=provider["id"],
116
+ detail=f"limit must be 1-1000 (the openFDA search hard limit, same as shortages measured on 2026-10-23), got {size}")
117
+ sk = max(0, int(skip or 0))
118
+ so = str(sort).strip() if sort is not None else _DEFAULT_SORT[face]
119
+ params = {"search": q, "limit": size}
120
+ if sk:
121
+ params["skip"] = sk
122
+ if so:
123
+ params["sort"] = so
124
+ try:
125
+ r = call_provider(provider, face, params, cache=cache, no_cache=no_cache)
126
+ except SdkError as e:
127
+ if e.code == "upstream_not_found":
128
+ return {"rows": [], "total": 0, "count": 0, "skip": sk, "limit_applied": size,
129
+ "has_more": False, "search": q, "sort": so or None,
130
+ "_meta": {"from_cache": False,
131
+ "fetched_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")}}
132
+ raise
133
+ data = r.get("data") or {}
134
+ rows = (_device_rows if face == "device" else _enf_rows)(data.get("results"))
135
+ total = (data.get("meta") or {}).get("results", {}).get("total", len(rows))
136
+ return {"rows": rows, "total": total, "count": len(rows), "skip": sk, "limit_applied": size,
137
+ "has_more": total > sk + len(rows), "search": q, "sort": so or None, "_meta": r}