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,240 @@
1
+ """paris_mou — Paris MoU port state control (THETIS public REST, EMSA portal) provider (keyless).
2
+
3
+ esg port-state supervision axis (2026-10-29, 0.3.5 batch): parismou.org itself is all iframe shells; the real
4
+ data door is the EMSA Liferay portlet same-origin REST `portal.emsa.europa.eu/o/portlet-public/rest/*`
5
+ (contextRoot is emitted by the inline JS of the portlet page; the full endpoint table lives in the 160KB ExtJS
6
+ bundle thetis-public.js). Measured shape (2026-10-29):
7
+ - The three GET snapshot faces share the envelope `{results, total, success}`.
8
+ - Detentions: detention/getCurrentDetentions 53 rows (daily rolling — the 10-20 probe saw 48 rows).
9
+ - Bans: ban/getBanShips 486 rows ~1MB, with **ismCompany{id/imoNumber/name/address/city/countryCode}
10
+ embedded = the official ship x management-company edge** (310 rows carry a company IMO number).
11
+ - Low-performance ISM companies: companyperformance/getLowPerformance6MonthsISMCompanies 65 rows
12
+ (**100% carry an IMO company number**, a zero-padded 7-digit string; companyPerformance is mostly LOW with a few MEDIUM).
13
+ - Historical inspections: inspection/getPublicInspections **POST form-encoded** — a JSON body is silently
14
+ dropped upstream (200 + total=0, a poisoned empty-window response, measured 2026-10-29) -> the op declares
15
+ `urlencoded: True` (transport extension 7).
16
+ Params: pscInspectionRegime=1 is mandatory (PSC default); periodMin/periodMax dd/mm/yyyy, window <= 3 years;
17
+ **page is the paging switch** — without page the limit is ignored and the whole window is dumped (measured
18
+ limit=25 returned 1,202 rows); with page+start+limit it is start-offset paging (page1/page2 measured zero
19
+ overlap); ismCompanyName/ismCompanyImoNumber query a company directly (+resume window stats
20
+ ships/inspections/detentions/ismDeficiencies/bans).
21
+ TTL: detentions 1h (daily rolling); bans/low-performance 6h; inspections 30min (window query replay).
22
+ Politeness tier 0.5 rps.
23
+ """
24
+ from ..errors import SdkError
25
+
26
+ provider = {
27
+ "id": "paris_mou",
28
+ "baseUrl": "https://portal.emsa.europa.eu",
29
+ "rate": {"rps": 0.5, "burst": 2},
30
+ "ops": {
31
+ "detentions": {"path": "/o/portlet-public/rest/detention/getCurrentDetentions.json"},
32
+ "bans": {"path": "/o/portlet-public/rest/ban/getBanShips.json", "timeout": 45.0}, # ~1MB in one response, the default 20s timed out as measured
33
+ "low_performance": {"path": "/o/portlet-public/rest/companyperformance/getLowPerformance6MonthsISMCompanies.json"},
34
+ "inspections": {
35
+ "path": "/o/portlet-public/rest/inspection/getPublicInspections.json",
36
+ "method": "POST",
37
+ "urlencoded": True, # extension 7: the body is standard form encoding (a JSON body = silently 0 rows, a poisoned response)
38
+ "idempotent": True, # read-semantics POST (a search), retryable
39
+ },
40
+ },
41
+ "ttl": {"detentions": 3600, "bans": 21600, "low_performance": 21600, "inspections": 1800},
42
+ }
43
+
44
+
45
+ def _flag(d):
46
+ return {"code": d.get("code"), "name": d.get("description")} if isinstance(d, dict) else {"code": None, "name": None}
47
+
48
+
49
+ def _ism_company(c):
50
+ """Embedded ISM management company -> flat row keys (the zero-padded 7-digit official imoNumber string is kept as-is)."""
51
+ if not isinstance(c, dict):
52
+ return {"company_imo_number": None, "company_name": None, "company_address": None,
53
+ "company_city": None, "company_country_code": None, "company_country_name": None}
54
+ return {
55
+ "company_imo_number": c.get("imoNumber"),
56
+ "company_name": c.get("name"),
57
+ "company_address": c.get("address"),
58
+ "company_city": c.get("city"),
59
+ "company_country_code": c.get("countryCode"),
60
+ "company_country_name": c.get("countryDescription"),
61
+ }
62
+
63
+
64
+ def detentions_from_upstream(data):
65
+ """Detentions snapshot -> rows (pure function, offline-testable)."""
66
+ rows = [{
67
+ "id": d.get("id"),
68
+ "imo_number": d.get("imoNumber"), # ship IMO number (asset key)
69
+ "ship_name": d.get("shipName"),
70
+ "ship_type": (d.get("shipType") or {}).get("description") if isinstance(d.get("shipType"), dict) else None,
71
+ "flag_code": _flag(d.get("flag"))["code"],
72
+ "flag_name": _flag(d.get("flag"))["name"],
73
+ "detention_date": d.get("detentionDate"), # dd/mm/yyyy
74
+ "detention_port": (d.get("detentionPort") or {}).get("name") if isinstance(d.get("detentionPort"), dict) else None,
75
+ "detention_country_code": ((d.get("detentionPort") or {}).get("country") or {}).get("code") if isinstance(d.get("detentionPort"), dict) else None,
76
+ "reporting_authority": _flag(d.get("detentionReportingAuthority"))["name"],
77
+ } for d in ((data or {}).get("results") or []) if isinstance(d, dict)]
78
+ return {"rows": rows, "count": len(rows)}
79
+
80
+
81
+ def bans_from_upstream(data):
82
+ """Bans face -> rows (the embedded ismCompany is flattened into a ship x management-company edge)."""
83
+ rows = []
84
+ for b in ((data or {}).get("results") or []):
85
+ if not isinstance(b, dict):
86
+ continue
87
+ rows.append({
88
+ "id": b.get("id"),
89
+ "imo_number": b.get("imoNumber"),
90
+ "ship_name": b.get("shipName"),
91
+ "flag_code": _flag(b.get("flag"))["code"],
92
+ "flag_name": _flag(b.get("flag"))["name"],
93
+ "ban_date": b.get("banDate"), # dd/mm/yyyy
94
+ "ban_occurrence": b.get("banOccurrence"),
95
+ "ban_order_status": b.get("banOrderStatus"),
96
+ "ban_reason": b.get("banReason"),
97
+ "minimum_duration": b.get("minimumDuration"),
98
+ "banning_authority": _flag(b.get("banningAuthority"))["name"],
99
+ **_ism_company(b.get("ismCompany")),
100
+ })
101
+ return {"rows": rows, "count": len(rows)}
102
+
103
+
104
+ def low_performance_from_upstream(data):
105
+ """Low-performance ISM company list -> rows (rolling 6-month window; 100% carry an IMO company number)."""
106
+ rows = [{
107
+ "id": c.get("id"),
108
+ "imo_number": c.get("imoNumber"), # IMO company number (zero-padded 7-digit string)
109
+ "name": c.get("name"),
110
+ "address": c.get("address"),
111
+ "city": c.get("city"),
112
+ "country_code": c.get("countryCode"),
113
+ "country_name": c.get("countryDescription"),
114
+ "performance": c.get("companyPerformance"), # mostly LOW, with a few MEDIUM
115
+ "effect_date": c.get("effectDate"), # dd/mm/yyyy (listing date)
116
+ "expiry_date": c.get("expiryDate"),
117
+ "status": c.get("ismCompanyStatus"), # ACTIVE/null
118
+ } for c in ((data or {}).get("results") or []) if isinstance(c, dict)]
119
+ return {"rows": rows, "count": len(rows)}
120
+
121
+
122
+ def inspections_from_upstream(data, params):
123
+ """Historical inspections -> rows + resume window stats (envelope {results, total, resume, success})."""
124
+ rows = [{
125
+ "id": i.get("id"), # -> getInspectionDetail?inspectionId= key for the full per-inspection record
126
+ "imo_number": i.get("imoNumber"),
127
+ "ship_name": i.get("shipName"),
128
+ "ship_type": i.get("shipTypeDescription"),
129
+ "flag_code": _flag(i.get("flag"))["code"],
130
+ "flag_name": _flag(i.get("flag"))["name"],
131
+ "age": i.get("age"),
132
+ "inspection_date": i.get("inspectionDate"), # dd/mm/yyyy
133
+ "inspection_type": (i.get("inspectionType") or {}).get("description") if isinstance(i.get("inspectionType"), dict) else None,
134
+ "member_state": i.get("memberStateDescription"),
135
+ "port_name": i.get("portName"),
136
+ "port_and_ms": i.get("portAndMS"), # "Slovenia - Koper"
137
+ "ndeficiencies": i.get("ndeficiencies"),
138
+ "results_desc": i.get("resultsDesc"),
139
+ } for i in ((data or {}).get("results") or []) if isinstance(i, dict)]
140
+ resume = (data or {}).get("resume") or {}
141
+ return {
142
+ "rows": rows,
143
+ "count": len(rows),
144
+ "total": (data or {}).get("total"),
145
+ "resume": {
146
+ "ships": resume.get("ships"),
147
+ "inspections": resume.get("inspections"),
148
+ "detentions": resume.get("detentions"),
149
+ "ism_deficiencies": resume.get("ismDeficiencies"),
150
+ "non_ism_deficiencies": resume.get("nonISMDeficiencies"),
151
+ "bans": resume.get("bans"),
152
+ } if isinstance(resume, dict) else None,
153
+ "paging": {"page": params.get("page"), "start": params.get("start"), "limit": params.get("limit")},
154
+ }
155
+
156
+
157
+ def _check_date(s, label):
158
+ """Strict dd/mm/yyyy validation (the upstream format really is day/month/year — a wrong order silently returns empty)."""
159
+ from datetime import datetime
160
+ try:
161
+ return datetime.strptime(s, "%d/%m/%Y")
162
+ except (TypeError, ValueError):
163
+ raise SdkError("usage_error", provider=provider["id"],
164
+ detail=f"{label} must be dd/mm/yyyy (e.g. 01/01/2026) — {s!r} does not match as measured")
165
+
166
+
167
+ def detentions_raw(*, cache=None, no_cache=False):
168
+ """0.3.5 flat face internals: the current detentions snapshot in one request (upstream has no filter params).
169
+
170
+ -> {rows[], count, params, _meta} — params={} (zero upstream params)."""
171
+ from ..transport import call_provider
172
+
173
+ params = {}
174
+ r = call_provider(provider, "detentions", params, cache=cache, no_cache=no_cache)
175
+ return {**detentions_from_upstream(r["data"]), "params": params, "_meta": r}
176
+
177
+
178
+ def bans_raw(*, cache=None, no_cache=False):
179
+ """0.3.5 flat face internals: the bans register in one request (upstream has no filter params).
180
+
181
+ -> {rows[], count, params, _meta}."""
182
+ from ..transport import call_provider
183
+
184
+ params = {}
185
+ r = call_provider(provider, "bans", params, cache=cache, no_cache=no_cache)
186
+ return {**bans_from_upstream(r["data"]), "params": params, "_meta": r}
187
+
188
+
189
+ def low_performance_raw(*, cache=None, no_cache=False):
190
+ """0.3.5 flat face internals: the low-performance ISM company list in one request (rolling 6-month window, upstream has no filter params).
191
+
192
+ -> {rows[], count, params, _meta}."""
193
+ from ..transport import call_provider
194
+
195
+ params = {}
196
+ r = call_provider(provider, "low_performance", params, cache=cache, no_cache=no_cache)
197
+ return {**low_performance_from_upstream(r["data"]), "params": params, "_meta": r}
198
+
199
+
200
+ def inspections_raw(*, period_min, period_max, psc_inspection_regime=1, page=1, start=0, limit=50,
201
+ ism_company_name=None, ism_company_imo_number=None,
202
+ cache=None, no_cache=False):
203
+ """0.3.5 flat face internals: historical PSC inspection window search (form-encoded POST).
204
+
205
+ -> {rows[], count, total, resume, paging, params, _meta}. The <= 3 year window is validated locally first
206
+ (upstream silently returns empty beyond it); **page always travels with the request** (without page the whole
207
+ window is dumped and the limit is void — the face pins the paging semantics). 0.3.20 audit truths (probed
208
+ 2026-11-02): pscInspectionRegime=1 is the only live value (0 -> poisoned empty, 2 -> HTTP 500 — walled locally
209
+ with evidence); the 500 limit cap is a **local politeness tier** (upstream honors 1000 — measured 1,000 rows
210
+ of a 1,395-total Jan-2026 window); ismCompanyImoNumber accepts both the zero-padded 7-digit and the unpadded
211
+ form (measured 52193 and 0052193 both -> total=1) — passed verbatim, no normalization."""
212
+ from ..transport import call_provider
213
+
214
+ d1, d2 = _check_date(period_min, "period_min"), _check_date(period_max, "period_max")
215
+ if d2 < d1:
216
+ raise SdkError("usage_error", provider=provider["id"],
217
+ detail=f"period_max({period_max}) is earlier than period_min({period_min}) — inverted window")
218
+ if (d2 - d1).days > 366 * 3:
219
+ raise SdkError("usage_error", provider=provider["id"],
220
+ detail=f"the search window of {(d2 - d1).days} days exceeds the 3-year cap (an upstream limit) — split the search")
221
+ if int(psc_inspection_regime) != 1:
222
+ # 0.3.20 audit (probed 2026-11-02, Jan-2026 window): regime=1 is the only live value — 0 returns
223
+ # 200 success with total=0 (a poisoned empty), 2 returns HTTP 500. Wall it with the evidence instead
224
+ # of letting either silent shape through.
225
+ raise SdkError("usage_error", provider=provider["id"],
226
+ detail=f"psc_inspection_regime={psc_inspection_regime!r} is not a live upstream value — 1 (PSC) is the "
227
+ "only regime the public endpoint serves (measured: 0 -> 200 poisoned-empty total=0, "
228
+ "2 -> HTTP 500)")
229
+ params = {
230
+ "pscInspectionRegime": int(psc_inspection_regime), # mandatory (1 = PSC default regime; without it = 200 empty)
231
+ "periodMin": period_min,
232
+ "periodMax": period_max,
233
+ "page": max(1, int(page)),
234
+ "start": max(0, int(start)),
235
+ "limit": min(500, max(1, int(limit))),
236
+ "ismCompanyName": ism_company_name,
237
+ "ismCompanyImoNumber": ism_company_imo_number,
238
+ }
239
+ r = call_provider(provider, "inspections", params, cache=cache, no_cache=no_cache)
240
+ return {**inspections_from_upstream(r["data"], params), "params": params, "_meta": r}
@@ -0,0 +1,67 @@
1
+ """thetis_mrv — EU MRV ship emissions reporting (THETIS-MRV, EMSA) versioned bulk-file catalog provider (keyless).
2
+
3
+ esg port-state emissions axis (2026-10-29, 0.3.5 batch): mrv.emsa.europa.eu is an ExtJS shell (app.json ->
4
+ a 9.5MB app.js was reverse-engineered) that yielded the `/api/public-emission-report/` family of 19 endpoints;
5
+ this face adopts the **reporting-period x versioned-publication listing** query (the XLSX bodies, ~9.5MB per
6
+ file, still go through the batch pipeline; this SDK does not proxy large-file downloads — same posture as eutl).
7
+ Measured shape (2026-10-29):
8
+ - `reporting-periods` -> `[{value: 2018..2025}]`, eight years (2018 is the first reporting year).
9
+ - `downloadable-files` -> `{results: [{reportingPeriod, version, generationDate "DD-MM-YYYY HH:MM:SS",
10
+ fileName}], total}` — **versioned rolling regeneration** (the 2025 file was v76 at the 10-20 probe and had
11
+ already jumped to v76/v254 by 10-29; the 2024 file is v254) — reconcile against the listing, never guess
12
+ file names; generationDate is mostly an early-morning batch (05:2x).
13
+ - The download path is the template `reporting-period-document/binary/{year}/{version}` (an XLSX attachment;
14
+ Range is ignored and the full 200 body comes back — mind this in the batch pipeline). The file schema gains
15
+ columns year by year (no CH₄/N₂O columns before 2024).
16
+ TTL 1 day (the version ledger rolls with the daily batch); politeness tier 0.5 rps.
17
+ """
18
+
19
+ provider = {
20
+ "id": "thetis_mrv",
21
+ "baseUrl": "https://mrv.emsa.europa.eu",
22
+ "rate": {"rps": 0.5, "burst": 2},
23
+ "ops": {
24
+ "periods": {"path": "/api/public-emission-report/reporting-periods"}, # [{value: year}] in one response
25
+ "files": {"path": "/api/public-emission-report/downloadable-files"}, # {results, total} version ledger
26
+ },
27
+ "ttl": {"periods": 86400, "files": 86400},
28
+ }
29
+
30
+ _BINARY_PATH = "/api/public-emission-report/reporting-period-document/binary/{period}/{version}"
31
+
32
+
33
+ def datasets_from_upstream(periods_data, files_data):
34
+ """Two op responses -> datasets face result (pure function, offline-testable).
35
+
36
+ rows are sorted by reportingPeriod descending (newest reporting period first); download_path is a template
37
+ path (the batch pipeline assembles the download URL from it)."""
38
+ periods = [p.get("value") for p in (periods_data or []) if isinstance(p, dict) and p.get("value") is not None]
39
+ rows = []
40
+ for f in ((files_data or {}).get("results") or []):
41
+ if not isinstance(f, dict):
42
+ continue
43
+ period, version = f.get("reportingPeriod"), f.get("version")
44
+ rows.append({
45
+ "reporting_period": period,
46
+ "version": version,
47
+ "generation_date": f.get("generationDate"), # "DD-MM-YYYY HH:MM:SS" (early-morning batch timestamp)
48
+ "file_name": f.get("fileName"), # "2025-v76-29102026-EU MRV Publication of information"
49
+ "download_path": _BINARY_PATH.format(period=period, version=version) if period is not None and version is not None else None,
50
+ })
51
+ rows.sort(key=lambda r: (r["reporting_period"] or 0, r["version"] or 0), reverse=True)
52
+ return {"rows": rows, "count": len(rows), "reporting_periods": periods}
53
+
54
+
55
+ def datasets_raw(*, cache=None, no_cache=False):
56
+ """0.3.5 flat face internals: two requests merged into the reporting-period + version ledger (upstream has no filter params — the client filters rows locally).
57
+
58
+ -> {rows[], count, reporting_periods[], params, _meta} — params={} (zero upstream params);
59
+ _meta is the round trip of the files op (the main ledger surface); a failing periods op is a hard
60
+ usage-level error (the whole result is untrustworthy)."""
61
+ from ..transport import call_provider
62
+
63
+ params = {}
64
+ periods = call_provider(provider, "periods", params, cache=cache, no_cache=no_cache)
65
+ files = call_provider(provider, "files", params, cache=cache, no_cache=no_cache)
66
+ out = datasets_from_upstream(periods["data"], files["data"])
67
+ return {**out, "params": params, "_meta": files}
@@ -0,0 +1,7 @@
1
+ """healthcare domain package — flat provider adapters (one file per source) + faces/cli (domain reorg in 0.2.6).
2
+
3
+ One domain per package: provider adapters laid flat (one source per file) + faces/cli.
4
+ """
5
+ from . import (ctgov, dailymed, epmc, eudamed, gudid, mesh, # noqa: F401
6
+ fda_crl, fda_devices, fda_drugs, fda_recalls, rxnav, shortages, au_tga, gb_mhra,
7
+ ca_dpd, ca_mdall, rasff, safety_gate)
@@ -0,0 +1,142 @@
1
+ """au_tga — Australian TGA eBS public face provider (a Domino .nsf site family; keyless).
2
+
3
+ Wave 6 of the healthcare domain: related
4
+ kind=artg_register / mis_manufacturers — the Australian therapeutic goods register ARTG, 97,988 product × sponsor
5
+ company edges, plus the Australian manufacturer licence register MIS with 444 licence × manufacturer rows.
6
+ Shapes measured 2026-10-23:
7
+ Host gating is split by host: ebs.tga.gov.au is fully open (both the curl and undici stacks), www.tga.gov.au hangs after connecting from this egress (do not probe the main site).
8
+ ARTG search: GET /ebs/ANZTPAR/PublicWeb.nsf/luPortalRegisterPublic?SearchView=&Query="Sun Pharma ANZ Pty Ltd"&Count=N&Start=M&charset=utf-8
9
+ — an **HTML surface** (SearchView does not honour outputformat=json, it was measured returning text/html): the op passes the raw text through with accept:'text'.
10
+ Query semantics: a quoted phrase = an exact phrase (measured Sun Pharma ANZ 100/100 hits); bare words = AND across all fields.
11
+ Row columns Name|ARTG|PI/CMI|Type|Sponsor (rows carry an OpenDocument detail link, and the row also starts with a sort-icon td that is not a data column);
12
+ Count=100 gives a 100-row page; Start = the number of rows to skip (skip semantics). Field-level search ([Sponsor] CONTAINS …) is bounced back to the search form — not done.
13
+ ARTG enumeration (the raw bulk line): the same view ?ReadViewEntries&outputformat=json&Count≤15000&Start=N (count=100000 → 500 OOM).
14
+ MIS licences: GET /ebs/ANZTPAR/PublicWeb.nsf/luPortalManufacturers?ReadViewEntries&outputformat=json&count=500 → JSON,
15
+ @toplevelentries=444; row columns Issue Date|Licence Number|Manufacturer ID|Address|**SiteState**|Manufacturer
16
+ (the state column's real name is SiteState, not the portal config's ColTitles «State» — the column name is the viewentry @name).
17
+ TTL: the register is on a daily cadence → 24h; no published quota → polite tier 1 rps.
18
+ """
19
+ import re
20
+
21
+ from ..errors import SdkError
22
+
23
+ provider = {
24
+ "id": "au_tga",
25
+ "baseUrl": "https://www.ebs.tga.gov.au/ebs/ANZTPAR/PublicWeb.nsf",
26
+ "rate": {"rps": 1, "burst": 2},
27
+ "ops": {
28
+ "search": {"path": "/luPortalRegisterPublic", "accept": "text"}, # params: SearchView/Query/Count/Start/charset → HTML
29
+ "manufacturers": {"path": "/luPortalManufacturers"}, # params: ReadViewEntries/outputformat/count/charset → JSON
30
+ },
31
+ "ttl": {"search": 24 * 3600, "manufacturers": 24 * 3600},
32
+ }
33
+
34
+ _TR_RE = re.compile(r"<tr[\s\S]*?</tr>", re.I)
35
+ _TD_RE = re.compile(r"<td[^>]*>([\s\S]*?)</td>", re.I)
36
+ _DOC_RE = re.compile(r'href="([^"]*OpenDocument[^"]*)"', re.I)
37
+ _TAG_RE = re.compile(r"<[^>]+>")
38
+ _IMG_RE = re.compile(r"<img", re.I)
39
+ _A_RE = re.compile(r"<a[\s>]", re.I)
40
+
41
+
42
+ def _cell_text(h):
43
+ s = _TAG_RE.sub(" ", str(h or ""))
44
+ s = (s.replace("&amp;", "&").replace("&quot;", '"')
45
+ .replace("&#39;", "'").replace("&rsquo;", "'").replace("&nbsp;", " "))
46
+ return re.sub(r"\s+", " ", s).strip()
47
+
48
+
49
+ def rows_from_search_html(html):
50
+ """SearchView HTML → ARTG rows (pure function, testable offline): only rows with an OpenDocument detail link count; pure icon tds are stripped (measured 6 td → 5 data tds)."""
51
+ rows = []
52
+ for m in _TR_RE.finditer(str(html or "")):
53
+ tr = m.group(0)
54
+ if "OpenDocument" not in tr:
55
+ continue
56
+ cells = [c for c in _TD_RE.findall(tr)
57
+ if not (_IMG_RE.search(c) and not _A_RE.search(c) and not _TAG_RE.sub("", c).strip())]
58
+ if len(cells) < 5:
59
+ continue
60
+ href = _DOC_RE.search(tr)
61
+ rows.append({
62
+ "product_name": _cell_text(cells[0]) or None,
63
+ "artg_id": _cell_text(cells[1]) or None,
64
+ "pi_cmi": _cell_text(cells[2]) or None,
65
+ "product_type": _cell_text(cells[3]) or None,
66
+ "sponsor": _cell_text(cells[4]) or None, # company edge, primary column (the registrant = the entity accountable for the Australian market)
67
+ "doc_url": ("https://www.ebs.tga.gov.au" + href.group(1).replace("&amp;", "&")) if href else None,
68
+ })
69
+ return rows
70
+
71
+
72
+ def _entry_val(d):
73
+ if not d:
74
+ return None
75
+ # Domino value-object shape {"0": "..."} (mirrors the d.text[0] property access on the Node side)
76
+ if d.get("text"):
77
+ return d["text"].get("0") or None
78
+ if d.get("number") is not None:
79
+ return str(d["number"].get("0"))
80
+ if d.get("datetime") is not None:
81
+ return re.sub(r"^(\d{4})(\d{2})(\d{2}).*$", r"\1-\2-\3", str(d["datetime"].get("0")))
82
+ return None
83
+
84
+
85
+ def rows_from_view_json(data):
86
+ """ReadViewEntries JSON (the Domino viewentry shape) → MIS licence rows (pure function, testable offline); datetime→YYYY-MM-DD, the state column's real name is SiteState."""
87
+ out = []
88
+ for e in (data or {}).get("viewentry") or []:
89
+ col = {d.get("@name"): d for d in e.get("entrydata") or []}
90
+ out.append({
91
+ "issue_date": _entry_val(col.get("Issue Date")),
92
+ "licence_number": _entry_val(col.get("Licence Number")),
93
+ "manufacturer_id": _entry_val(col.get("Manufacturer ID")),
94
+ "address": _entry_val(col.get("Address")),
95
+ "state": _entry_val(col.get("SiteState")),
96
+ "manufacturer": _entry_val(col.get("Manufacturer")), # company edge, primary column (the licence holder)
97
+ })
98
+ return out
99
+
100
+
101
+ def build_search_query(sponsor=None, query=None):
102
+ """Domino Query assembly (pure function, testable offline): sponsor → a quoted phrase (measured exact);
103
+ query → passed through as given (a quoted phrase is upstream-native syntax; since 0.3.0 the quotes are not stripped — stripping them would make the exact Sponsor match unreachable)."""
104
+ sp, q = str(sponsor or "").strip(), str(query or "").strip()
105
+ if not sp and not q:
106
+ raise SdkError("usage_error", provider="au_tga", detail="sponsor or query is required (a bare query would enumerate the whole 98,000-row register, so it is refused)")
107
+ if sp:
108
+ return '"' + sp.replace('"', "") + '"'
109
+ return q
110
+
111
+
112
+ def search_art_register(sponsor=None, query=None, *, limit=20, skip=0, cache=None, no_cache=False):
113
+ """→ {rows[], count, skip, limit_applied, has_more, query, _meta}; has_more is a full-page heuristic (Domino does not return the hit total)."""
114
+ from ..transport import call_provider
115
+
116
+ q = build_search_query(sponsor, query)
117
+ size = min(max(1, int(limit or 20)), 100) # SearchView Count measured 100/page
118
+ sk = max(0, int(skip or 0))
119
+ params = {"SearchView": "", "Query": q, "Count": size, "charset": "utf-8"}
120
+ if sk:
121
+ params["Start"] = sk # Start = the number of rows to skip (skip semantics; measured Start=100 continues onto the next page)
122
+ r = call_provider(provider, "search", params, cache=cache, no_cache=no_cache)
123
+ rows = rows_from_search_html(r.get("data"))
124
+ return {"rows": rows, "count": len(rows), "skip": sk, "limit_applied": size,
125
+ "has_more": len(rows) == size, "query": q, "_meta": r}
126
+
127
+
128
+ def list_manufacturers(state=None, *, limit=100, cache=None, no_cache=False):
129
+ """→ {rows[], total, count, limit_applied, _meta} — the MIS licence register: 444 licences in one full shot (count=500 on a single page) + the state filter."""
130
+ from ..transport import call_provider
131
+
132
+ size = min(max(1, int(limit or 100)), 500)
133
+ r = call_provider(provider, "manufacturers",
134
+ {"ReadViewEntries": "", "outputformat": "json", "count": 500, "charset": "utf-8"},
135
+ cache=cache, no_cache=no_cache)
136
+ data = r.get("data") or {}
137
+ total = int(data.get("@toplevelentries") or 0)
138
+ rows = rows_from_view_json(data)
139
+ if state:
140
+ rows = [x for x in rows if (x.get("state") or "").upper() == str(state).strip().upper()]
141
+ return {"rows": rows[:size], "total": total, "count": min(len(rows), size),
142
+ "limit_applied": size, "_meta": r}
@@ -0,0 +1,123 @@
1
+ """ca_dpd — Health Canada Drug Product Database open API face provider
2
+ (health-products.canada.ca/api/drug/; 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 /api/drug/company/?lang=en&type=json
7
+ → the full company register in ONE shot (no pagination surface on this host — 5,253 rows
8
+ measured 2026-11-02; rows carry company_code (the DPD join key) + company_name +
9
+ company_type (DIN OWNER / …) + the full postal address).
10
+ **Pit nailed: the Read Me page (GET /api/drug/ — the HTML index of the API family) is the
11
+ only enum documentation; the naive guess "status codes endpoint" (/api/drug/status) returns
12
+ the per-drug status *history* table (10.3MB), not the enum.**
13
+ GET /api/drug/drugproduct/?status=<n>&lang=en&type=json
14
+ → drug products filtered by market status. **status is a closed set 1|2|3|4|6 per the Read Me
15
+ page — 1=Approved, 2=Marketed, 3=Cancelled Pre-Market, 4=Cancelled Post-Market, 6=Dormant
16
+ (no 5 in the official set); any other value is silently ignored and the endpoint dumps the
17
+ whole unfiltered register (15MB, ~55k rows — the silent-filter-ignored trap, same family as
18
+ the Socrata ones), so status is REQUIRED here and validated against the closed set.**
19
+ Rows carry drug_code + drug_identification_number (DIN) + brand_name + company_name inline
20
+ (company_code is NOT echoed on this face — join via company_name or run the company face);
21
+ the row does not echo the requested status either.
22
+ Measured: status=3 → 4,116 rows (2026-11-02).
23
+ Param-minimalism pitfall (this host publishes no per-endpoint docs beyond the Read Me links):
24
+ unprobed param combinations → either silent no-op or a full dump — this face accepts only the
25
+ documented lang/type/status family, do not widen it.
26
+ Local row slicing (limit/skip) is a post-slice over the single cached full shot — the upstream
27
+ has no paging, and the register itself is the payload (a monthly static extract).
28
+ TTL 7 days (the DPD monthly extract changes slowly); polite rate 1 rps (the host publishes no
29
+ quota — conservative; the same host also serves ca_mdall, the shared host bucket coordinates them).
30
+ """
31
+ from ..errors import SdkError
32
+
33
+ provider = {
34
+ "id": "ca_dpd",
35
+ "baseUrl": "https://health-products.canada.ca",
36
+ "rate": {"rps": 1, "burst": 2},
37
+ "ops": {
38
+ "companies": {"path": "/api/drug/company/"}, # params: lang/type fixed for this face
39
+ "drug_products": {"path": "/api/drug/drugproduct/"}, # params: lang/type fixed + status (required, closed set)
40
+ },
41
+ "ttl": {"companies": 7 * 86400, "drug_products": 7 * 86400},
42
+ }
43
+
44
+ _STATUS = {1: "Approved", 2: "Marketed", 3: "Cancelled Pre-Market",
45
+ 4: "Cancelled Post-Market", 6: "Dormant"} # the Read Me closed set (measured: no 5 in the family links)
46
+
47
+
48
+ def companies_from_upstream(data):
49
+ """Upstream company array → company rows (pure function, offline-testable)."""
50
+ rows = [{
51
+ "company_code": r.get("company_code"), # the DPD join key
52
+ "company_name": r.get("company_name"),
53
+ "company_type": r.get("company_type"), # e.g. DIN OWNER
54
+ "address": {k: r.get(k) for k in ("street_name", "city_name", "province_name",
55
+ "country_name", "postal_code")},
56
+ } for r in (data or []) if isinstance(r, dict)]
57
+ return {"rows": rows, "count": len(rows)}
58
+
59
+
60
+ def drug_products_from_upstream(data, status):
61
+ """Upstream drug-product array → product rows (pure function, offline-testable)."""
62
+ rows = [{
63
+ "drug_code": r.get("drug_code"),
64
+ "din": r.get("drug_identification_number"), # the 8-digit DIN (the dispensed-product key)
65
+ "brand_name": r.get("brand_name"),
66
+ "class_name": r.get("class_name"), # Human / Veterinary
67
+ "company_name": r.get("company_name"), # inline on this face; company_code is NOT echoed
68
+ "number_of_ais": r.get("number_of_ais"),
69
+ "ai_group_no": r.get("ai_group_no"),
70
+ "last_update_date": r.get("last_update_date"),
71
+ } for r in (data or []) if isinstance(r, dict)]
72
+ return {"rows": rows, "count": len(rows), "total": len(rows),
73
+ "status": int(status), "status_label": _STATUS[int(status)]}
74
+
75
+
76
+ def _slice(rows, limit, skip):
77
+ """Local post-slice over the single cached full shot (the upstream has no paging surface)."""
78
+ off = int(skip) if skip is not None else 0
79
+ if off < 0:
80
+ raise SdkError("usage_error", provider="ca_dpd", detail=f"skip must be >=0, got \"{skip}\"")
81
+ size = int(limit) if limit is not None else 200
82
+ if size < 1:
83
+ raise SdkError("usage_error", provider="ca_dpd", detail=f"limit must be >=1 (local post-slice, no upstream cap), got \"{limit}\"")
84
+ return rows[off:off + size]
85
+
86
+
87
+ def companies_raw(*, limit=200, skip=0, cache=None, no_cache=False):
88
+ """Flat-panel low-level face: the DPD company register, one full shot (5,253 rows measured
89
+ 2026-11-02) cached whole; limit/skip is a local post-slice.
90
+
91
+ → {rows[], count, total, params, _meta} — total = the whole register size (not the page)."""
92
+ from ..transport import call_provider
93
+
94
+ params = {"lang": "en", "type": "json"}
95
+ r = call_provider(provider, "companies", params, cache=cache, no_cache=no_cache)
96
+ out = companies_from_upstream(r["data"])
97
+ rows = _slice(out["rows"], limit, skip)
98
+ return {"rows": rows, "count": len(rows), "total": out["count"],
99
+ "params": params, "_meta": r}
100
+
101
+
102
+ def drug_products_raw(status, *, limit=200, skip=0, cache=None, no_cache=False):
103
+ """Flat-panel low-level face: DPD drug products by market status.
104
+
105
+ status = the Read Me closed set 1|2|3|4|6 (1=Approved 2=Marketed 3=Cancelled Pre-Market
106
+ 4=Cancelled Post-Market 6=Dormant) — REQUIRED and validated: any other value is silently
107
+ ignored upstream and dumps the whole 15MB register (the silent-filter trap).
108
+ → {rows[], count, total, status, status_label, params, _meta}."""
109
+ from ..transport import call_provider
110
+
111
+ try:
112
+ st = int(status)
113
+ except (TypeError, ValueError):
114
+ raise SdkError("usage_error", provider="ca_dpd",
115
+ detail=f"status must be one of the Read Me closed set {sorted(_STATUS)} (1=Approved 2=Marketed 3=Cancelled Pre-Market 4=Cancelled Post-Market 6=Dormant), got \"{status}\"")
116
+ if st not in _STATUS:
117
+ raise SdkError("usage_error", provider="ca_dpd",
118
+ detail=f"status must be one of {sorted(_STATUS)} ({'; '.join(f'{k}={v}' for k, v in sorted(_STATUS.items()))}) — an unknown value is silently ignored upstream and dumps the whole 15MB register, got \"{status}\"")
119
+ params = {"lang": "en", "type": "json", "status": st}
120
+ r = call_provider(provider, "drug_products", params, cache=cache, no_cache=no_cache)
121
+ out = drug_products_from_upstream(r["data"], st)
122
+ rows = _slice(out["rows"], limit, skip)
123
+ return {**out, "rows": rows, "count": len(rows), "params": params, "_meta": r}