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,101 @@
1
+ """ctgov — ClinicalTrials.gov API v2 (clinical-trial lookup face; keyless; polite tier 2 rps).
2
+
3
+ Positioning: fresh single lookups on top of the bulk line (us/clinicaltrials full harvest) — sponsor search / status filter / single-NCT semantics.
4
+ TTL: search 7 days (trial records change slowly; the published results page is authoritative).
5
+ Query construction: company → AREA[LeadSponsorName]("…"); status → AREA[OverallStatus](…); a free query expression is AND-joined.
6
+ """
7
+
8
+ provider = {
9
+ "id": "ctgov",
10
+ "baseUrl": "https://clinicaltrials.gov/api/v2",
11
+ "rate": {"rps": 2, "burst": 4},
12
+ "ops": {
13
+ "search": {"path": "/studies", "ttl": 7 * 86400},
14
+ },
15
+ }
16
+
17
+
18
+ def _row_from_study(s: dict) -> dict:
19
+ p = s.get("protocolSection") or {}
20
+ ident = p.get("identificationModule") or {}
21
+ st = s.get("statusModule") or {}
22
+ sponsor = ((p.get("sponsorCollaboratorsModule") or {}).get("leadSponsor") or {}).get("name")
23
+ phases = (p.get("designModule") or {}).get("phases") or []
24
+ nct = ident.get("nctId")
25
+ return {
26
+ "nct_id": nct,
27
+ "title": ident.get("briefTitle"),
28
+ "status": st.get("overallStatus"),
29
+ "phase": " + ".join(phases) or None,
30
+ "sponsor": sponsor,
31
+ "start_date": (st.get("startDateStruct") or {}).get("date"),
32
+ "url": f"https://clinicaltrials.gov/study/{nct}" if nct else None,
33
+ }
34
+
35
+
36
+ def search_trials(company=None, query=None, status=None, limit=10, cache=None, no_cache: bool = False) -> dict:
37
+ from ..transport import call_provider
38
+ from ..errors import SdkError
39
+
40
+ terms = []
41
+ if company:
42
+ terms.append(f'AREA[LeadSponsorName]("{company}")')
43
+ if status:
44
+ terms.append(f"AREA[OverallStatus]({status})")
45
+ if query:
46
+ terms.append(query)
47
+ if not terms:
48
+ raise SdkError("op_unknown", provider="ctgov", detail="trials needs at least one of company / query / status")
49
+ r = call_provider(provider, "search",
50
+ {"query.term": " AND ".join(terms),
51
+ "pageSize": min(50, max(1, int(limit))), "countTotal": "true"},
52
+ cache=cache, no_cache=no_cache)
53
+ studies = (r["data"] or {}).get("studies") or []
54
+ return {"total": (r["data"] or {}).get("totalCount"),
55
+ "rows": [_row_from_study(s) for s in studies], "_meta": r}
56
+
57
+
58
+ def search_raw(*, query_term=None, query_titles=None, query_intr=None, query_cond=None,
59
+ filter_advanced=None, agg_filters=None, fields=None, page_size=20,
60
+ page_token=None, sort=None, count_total=True, cache=None, no_cache: bool = False) -> dict:
61
+ """0.2.0 mirror face: upstream param names verbatim (query.term/query.titles/query.intr/query.cond/filter.advanced/
62
+ aggFilters/fields/pageSize/pageToken/sort/countTotal).
63
+
64
+ No cap: the pageSize docs say <=1000 but 1001 was measured returning all 200 rows (probed 2026-10-23, the limit is not enforced) — we only guarantee >=1;
65
+ a bare call is legal (the upstream default = page 1 of the whole database, countTotal gives the total); a free sort string passes through (measured EnrollmentCount:desc works).
66
+ 0.2.8 added four params (all verified by probing on 2026-10-28): query.titles = title-field search (aspirin 2983→967),
67
+ query.intr = intervention field (insulin 21109→8229), aggFilters = facet filter (phase:3 → 583; **the value domain is a numeric enum**,
68
+ spelling PHASE3 gives 0 hits, not an error), fields = row slimming (NCTId,BriefTitle → rows keep only those two keys).
69
+ → {total, rows[], next_page_token, page_size, params, _meta}.
70
+ """
71
+ from ..transport import call_provider
72
+ from ..errors import SdkError
73
+
74
+ ps = int(page_size)
75
+ if ps < 1:
76
+ raise SdkError("usage_error", provider="ctgov", detail=f"page_size must be >= 1, got {ps}")
77
+ params = {"pageSize": ps, "countTotal": "true" if count_total else "false"}
78
+ if query_term:
79
+ params["query.term"] = str(query_term)
80
+ if query_titles:
81
+ params["query.titles"] = str(query_titles)
82
+ if query_intr:
83
+ params["query.intr"] = str(query_intr)
84
+ if query_cond:
85
+ params["query.cond"] = str(query_cond)
86
+ if filter_advanced:
87
+ params["filter.advanced"] = str(filter_advanced)
88
+ if agg_filters:
89
+ params["aggFilters"] = str(agg_filters)
90
+ if fields:
91
+ params["fields"] = str(fields)
92
+ if page_token:
93
+ params["pageToken"] = str(page_token)
94
+ if sort:
95
+ params["sort"] = str(sort)
96
+ r = call_provider(provider, "search", params, cache=cache, no_cache=no_cache)
97
+ studies = (r["data"] or {}).get("studies") or []
98
+ return {"total": (r["data"] or {}).get("totalCount"),
99
+ "rows": [_row_from_study(s) for s in studies],
100
+ "next_page_token": (r["data"] or {}).get("nextPageToken"),
101
+ "page_size": ps, "params": params, "_meta": r}
@@ -0,0 +1,102 @@
1
+ """dailymed — NLM DailyMed v2 (SPL labeling metadata layer; keyless; NLM family 20/s — 0.2.8 sped up to 10 rps into the nlm group bucket).
2
+
3
+ TTL: the spls list 7 days (setid is immutable, the list changes slowly with each release). First-version scope: setid + title + labeling URL as the verbatim-text entry point;
4
+ the SPL XML full text is not parsed (let the agent fetch it along the URL).
5
+ Probed and confirmed (2026-10-22): metadata.total_elements = the full count (atorvastatin 406) — the old face lost it entirely;
6
+ the real pagesize limit is 100; published_date 'MMM dd, yyyy' is not ISO → normalized; empty pagination values in metadata are the string 'null', not JSON null.
7
+ """
8
+
9
+ import re
10
+
11
+ provider = {
12
+ "id": "dailymed",
13
+ "baseUrl": "https://dailymed.nlm.nih.gov/dailymed/services/v2",
14
+ # NLM family 20/s (tier B, DailyMed is not listed separately so it follows NLM policy); 0.2.8 sped up to 10/4 into the nlm group bucket (same note as rxnav)
15
+ "rate": {"rps": 10, "burst": 4, "group": "nlm"},
16
+ "ops": {
17
+ "spls": {"path": "/spls.json", "ttl": 7 * 86400},
18
+ },
19
+ }
20
+
21
+ _MONTHS = {"Jan": "01", "Feb": "02", "Mar": "03", "Apr": "04", "May": "05", "Jun": "06",
22
+ "Jul": "07", "Aug": "08", "Sep": "09", "Oct": "10", "Nov": "11", "Dec": "12"}
23
+ _US_DATE = re.compile(r"^([A-Z][a-z]{2}) (\d{1,2}), (\d{4})$")
24
+
25
+
26
+ def us_date_to_iso(v):
27
+ """'Oct 19, 2026' → '2026-10-19' (the same MDY-normalization family as the FDIC batch). Values in any other shape are returned unchanged."""
28
+ if not isinstance(v, str):
29
+ return None if v is None else v
30
+ m = _US_DATE.match(v.strip())
31
+ if m and m.group(1) in _MONTHS:
32
+ return f"{m.group(3)}-{_MONTHS[m.group(1)]}-{int(m.group(2)):02d}"
33
+ return v
34
+
35
+
36
+ def row_from_upstream(x: dict) -> dict:
37
+ return {
38
+ "setid": x.get("setid"),
39
+ "title": x.get("title"), # the title carries its own [labeler] bracket section — that is the upstream shape, passed through verbatim
40
+ "spl_version": x.get("spl_version"),
41
+ "published_date": us_date_to_iso(x.get("published_date")),
42
+ "url": f"https://dailymed.nlm.nih.gov/dailymed/drugInfo.cfm?setid={x['setid']}" if x.get("setid") else None,
43
+ }
44
+
45
+
46
+ def search_label(drug: str, limit: int = 5, cache=None, no_cache: bool = False) -> dict:
47
+ from ..transport import call_provider
48
+
49
+ r = call_provider(provider, "spls",
50
+ {"drug_name": drug, "pagesize": min(50, max(1, int(limit)))},
51
+ cache=cache, no_cache=no_cache)
52
+ data = r["data"] or {}
53
+ rows = data.get("data") or []
54
+ total = ((data.get("metadata") or {}).get("total_elements")) or len(rows)
55
+ return {
56
+ "total": total,
57
+ "rows": [row_from_upstream(x) for x in rows],
58
+ "_meta": r,
59
+ }
60
+
61
+
62
+ def spls_raw(*, drug_name=None, manufacturer=None, setid=None, page=None, pagesize=None,
63
+ sort=None, cache=None, no_cache: bool = False) -> dict:
64
+ """0.2.0 mirror face: all six spls.json params passed through under their upstream names (all verified by probing on 2026-10-23: manufacturer=Pfizer → Hospira rows,
65
+ setid filters exactly, page/pagesize/sort paginate and sort).
66
+
67
+ No hard cap: upstream silently clamps pagesize to 100 (101 → elements_per_page=100, no error) — the default None = omit (the upstream default is 100),
68
+ and the clamp is visible through the elements_per_page echo; a bare call is legal (full SPL enumeration, which upstream can express).
69
+ → {total, rows[], page, elements_per_page, params, _meta}; empty pagination values in metadata are the string 'null' and are normalized.
70
+ """
71
+ from ..transport import call_provider
72
+ from ..errors import SdkError
73
+
74
+ params = {}
75
+ if drug_name:
76
+ params["drug_name"] = str(drug_name)
77
+ if manufacturer:
78
+ params["manufacturer"] = str(manufacturer)
79
+ if setid:
80
+ params["setid"] = str(setid)
81
+ if page is not None:
82
+ params["page"] = int(page)
83
+ if pagesize is not None:
84
+ ps = int(pagesize)
85
+ if ps < 1:
86
+ raise SdkError("usage_error", provider="dailymed", detail=f"pagesize must be >= 1, got {ps}")
87
+ params["pagesize"] = ps
88
+ if sort:
89
+ params["sort"] = str(sort)
90
+ r = call_provider(provider, "spls", params, cache=cache, no_cache=no_cache)
91
+ data = r["data"] or {}
92
+ meta = data.get("metadata") or {}
93
+ rows = data.get("data") or []
94
+ total = meta.get("total_elements") or len(rows)
95
+ return {
96
+ "total": total,
97
+ "rows": [row_from_upstream(x) for x in rows],
98
+ "page": meta.get("current_page"),
99
+ "elements_per_page": meta.get("elements_per_page"),
100
+ "params": params,
101
+ "_meta": r,
102
+ }
@@ -0,0 +1,108 @@
1
+ """epmc — Europe PMC (literature evidence layer; keyless; polite tier 2 rps).
2
+
3
+ TTL: search result pages 24h. Query construction: company→AFF:"…" (author affiliation field search; do not use AUTH=author name —
4
+ company names via AUTH collapse the hit count — an external review on 2026-10-16 ruled 2 vs 37,108); focus appends an AND all-field term.
5
+ The real upstream pageSize limit is 1-1000 (1001 → errCode 404 «Invalid page size»; probed and confirmed) — polite tier 50, truncation visible via hitCount.
6
+ """
7
+
8
+ provider = {
9
+ "id": "epmc",
10
+ "baseUrl": "https://www.ebi.ac.uk/europepmc/webservices/rest",
11
+ "rate": {"rps": 2, "burst": 4},
12
+ "ops": {
13
+ "search": {"path": "/search", "ttl": 24 * 3600},
14
+ },
15
+ }
16
+
17
+
18
+ def row_from_upstream(x: dict) -> dict:
19
+ """Probed and confirmed (2026-10-22, three-source diff + self-describing core fullTextUrlList reconciliation):
20
+ the old url only recognised pmid → PMC-only rows (pmcid present, pmid null) and PPR preprint rows (id only) all came back null, breaking the evidence chain;
21
+ the official self-describing URLs: pmcid rows = articles/{pmcid} (same shape for MED/PMC), PPR = article/PPR/{id},
22
+ PAT has no EPMC page (it only gives SureChembl/EPO outbound links) → an honest None.
23
+ For new PMC-only records pubYear can be null while firstPublicationDate has a value → year fallback."""
24
+ year_fallback = (str(x.get("firstPublicationDate") or ""))[:4] or None
25
+ if x.get("pmcid"):
26
+ url = f"https://europepmc.org/articles/{x['pmcid']}"
27
+ elif x.get("pmid"):
28
+ url = f"https://europepmc.org/article/{x.get('source') or 'MED'}/{x['pmid']}"
29
+ elif x.get("source") and x.get("id") and x.get("source") != "PAT":
30
+ url = f"https://europepmc.org/article/{x['source']}/{x['id']}"
31
+ else:
32
+ url = None
33
+ return {
34
+ "id": x.get("id"), # a PPR preprint row only has this key filled in (both pmid and pmcid are null) — the evidence chain never breaks
35
+ "pmid": x.get("pmid"),
36
+ "source": x.get("source"),
37
+ "title": x.get("title"),
38
+ "authors": x.get("authorString"),
39
+ "journal": x.get("journalTitle"),
40
+ "pub_year": x.get("pubYear") or year_fallback,
41
+ "cited_by": x.get("citedByCount", 0),
42
+ "url": url,
43
+ }
44
+
45
+
46
+ def search_evidence(query=None, company=None, focus=None, limit=10, cache=None, no_cache=False) -> dict:
47
+ from ..transport import call_provider
48
+ from ..errors import SdkError
49
+
50
+ q = query
51
+ if not q and company:
52
+ q = f'AFF:"{company}"'
53
+ if focus:
54
+ q += f' AND "{focus}"'
55
+ if not q:
56
+ raise SdkError("op_unknown", provider="epmc", detail="evidence needs query or company (focus may be added)")
57
+ r = call_provider(provider, "search",
58
+ {"query": q, "format": "json", "resultType": "lite",
59
+ "pageSize": min(50, max(1, int(limit)))},
60
+ cache=cache, no_cache=no_cache)
61
+ rows = ((r["data"] or {}).get("resultList") or {}).get("result") or []
62
+ return {
63
+ "hitCount": (r["data"] or {}).get("hitCount", len(rows)),
64
+ "rows": [row_from_upstream(x) for x in rows],
65
+ "_meta": r,
66
+ }
67
+
68
+
69
+ def search_raw(query, *, result_type="lite", page_size=25, cursor_mark="*", sort=None,
70
+ fmt="json", cache=None, no_cache=False) -> dict:
71
+ """0.2.0 mirror face: upstream param names (query/resultType/pageSize/cursorMark/sort/format) + our defaults.
72
+
73
+ Hard limit (protocol, confirmed by probing on 2026-10-23): pageSize 1-1000 — 1001 → errCode 404 «Invalid page size»;
74
+ format supports only json (this stack parses JSON — a local safety gate; for the xml face the agent fetches along the evidence url itself).
75
+ resultType is the closed set lite|core|full|idlist; a free sort string passes through (measured "CITED desc" works, echoed back in nextPageUrl).
76
+ Defaults follow the upstream defaults: pageSize=25, cursorMark="*" (the first-page sentinel), sort=None (relevance).
77
+ → {hitCount, rows[], next_cursor_mark, page_size, query, _meta}.
78
+ """
79
+ from ..transport import call_provider
80
+ from ..errors import SdkError
81
+
82
+ q = str(query or "").strip()
83
+ if not q:
84
+ raise SdkError("usage_error", provider="epmc", detail="query requires a search expression (Europe PMC all-field syntax)")
85
+ rt = str(result_type or "lite").strip()
86
+ if rt not in ("lite", "core", "full", "idlist"):
87
+ raise SdkError("usage_error", provider="epmc",
88
+ detail=f"the resultType closed set is lite|core|full|idlist, got '{rt}'")
89
+ if str(fmt or "json").strip() != "json":
90
+ raise SdkError("usage_error", provider="epmc",
91
+ detail="format supports only json (this stack parses JSON; for the xml face fetch it yourself along the evidence url)")
92
+ ps = int(page_size)
93
+ if not 1 <= ps <= 1000:
94
+ raise SdkError("usage_error", provider="epmc",
95
+ detail=f"pageSize must be 1-1000 (upstream 1001 → errCode 404 Invalid page size, measured 2026-10-23), got {ps}")
96
+ params = {"query": q, "format": "json", "resultType": rt,
97
+ "pageSize": ps, "cursorMark": str(cursor_mark or "*")}
98
+ if sort:
99
+ params["sort"] = str(sort)
100
+ r = call_provider(provider, "search", params, cache=cache, no_cache=no_cache)
101
+ rows = ((r["data"] or {}).get("resultList") or {}).get("result") or []
102
+ return {
103
+ "hitCount": (r["data"] or {}).get("hitCount", len(rows)),
104
+ "rows": [row_from_upstream(x) for x in rows],
105
+ "next_cursor_mark": (r["data"] or {}).get("nextCursorMark"),
106
+ "page_size": ps, "query": q,
107
+ "_meta": r,
108
+ }
@@ -0,0 +1,124 @@
1
+ """eudamed — EUDAMED public device catalogue (EU UDI-DI → master record; keyless).
2
+
3
+ Endpoint = the SPA's same-origin REST (the api:{…} map is plaintext in the main bundle, not an officially documented face): GET /api/devices/udiDiData.
4
+ Rows carry manufacturerSrn (XX-MF-…) + authorisedRepresentativeSrn (XX-AR-…) — the EU counterpart of GUDID;
5
+ the AccessGUDID egress is intermittently gated by a TLS fingerprint gate (2026-10-16 case), so the EU face became its own edge.
6
+ Acceptance gate observations: probed 2026-10-17 and re-checked 2026-10-20 — the endpoint / param semantics / row shape are stable.
7
+ Channel note (measured 2026-10-20): the upstream exact DI query (primaryDi=) is unindexed — cold 50.8s / warm 11.2s,
8
+ so the op gets a 90s timeout and 0 retries (the slow path must not burn retries); tradeName= is the fast face but it is a search, not a normalization, so it does not enter this kind.
9
+ Pagination (all three params are sent together): page&pageSize&size + iso2Code=en; totalElements===0 = a structured not_found —
10
+ but an empty set is never authoritative for the cache: the unindexed face occasionally returns a wholly empty page (hit here on 2026-10-20: a 0-hit page poisoned the 7-day TTL), so an empty verdict discards the cached entry and refetches once.
11
+ """
12
+
13
+
14
+ def _tail(code):
15
+ """refdata.risk-class.class-iia → class-iia (non-string → None).
16
+
17
+ P1-2 (folded in 2026-10-22): the same batch of upstream fields comes in two shapes — riskClass/deviceStatusType/manufacturerStatus
18
+ are {code} objects (the real-machine shape), while issuingAgency on list pages can be an object or a bare string. Both shapes are collected here,
19
+ so call sites can pass the value straight through; the old implementation only accepted strings, so manufacturerStatus was always None.
20
+ """
21
+ if isinstance(code, dict):
22
+ code = code.get("code")
23
+ return code.split(".").pop() if isinstance(code, str) else None
24
+
25
+
26
+ def _params(v):
27
+ return {"primaryDi": v, "page": 0, "pageSize": 5, "size": 5, "iso2Code": "en"}
28
+
29
+
30
+ def _empty(d):
31
+ return not (d or {}).get("totalElements") or not (d or {}).get("content")
32
+
33
+
34
+ provider = {
35
+ "id": "eudamed",
36
+ "baseUrl": "https://ec.europa.eu/tools/eudamed",
37
+ "rate": {"rps": 1, "burst": 2},
38
+ "concurrency": 1, # ⑤ host concurrency pinned to 1: primaryDi is unindexed and cold ~50s, so more concurrency would only drag down the EU face
39
+ "ops": {
40
+ "lookup": {"path": "/api/devices/udiDiData", "timeout": 90.0, "retries": 0},
41
+ },
42
+ "ttl": {"lookup": 7 * 86400},
43
+ }
44
+
45
+
46
+ def _query(params, cache, no_cache):
47
+ """A single udiDiData query + the empty-page poison guard (an empty verdict discards the cached entry and refetches once; only a still-empty result counts as not_found)."""
48
+ from ..errors import SdkError
49
+ from ..transport import call_provider, cache_key_for
50
+
51
+ r = call_provider(provider, "lookup", params, cache=cache, no_cache=no_cache)
52
+ if _empty(r["data"]):
53
+ # An empty set is untrustworthy: discard the cached entry (including a stale empty one) and refetch once; only a still-empty result counts as not_found. The refetch goes through the normal read path
54
+ # (the entry was deleted, so it must miss) and a good result is written back — the same effect as "noCache only skips reads, not writes" on the Node side (both stacks align).
55
+ key = cache_key_for("lookup", params)
56
+ if cache is not None:
57
+ cache.delete("eudamed", key)
58
+ fresh = call_provider(provider, "lookup", params, cache=cache, no_cache=False)
59
+ if _empty(fresh["data"]):
60
+ if cache is not None:
61
+ cache.delete("eudamed", key)
62
+ raise SdkError("upstream_not_found", provider="eudamed",
63
+ detail=f"no such EUDAMED record (0 hits is a structured empty set): {params.get('primaryDi') or params}")
64
+ r = fresh
65
+ return r
66
+
67
+
68
+ def _card_from_row(row):
69
+ ia = row.get("issuingAgency") # on list pages it can be an object or a string (the detail face is richer) — both shapes are accepted
70
+ ia_code = ia.get("code") if isinstance(ia, dict) else ia
71
+ return {
72
+ "primary_di": row.get("primaryDi"),
73
+ "basic_udi": row.get("basicUdi"),
74
+ "trade_name": row.get("tradeName"),
75
+ "device_name": row.get("deviceName"),
76
+ "device_model": row.get("deviceModel"),
77
+ "risk_class": _tail((row.get("riskClass") or {}).get("code")),
78
+ "device_status": _tail((row.get("deviceStatusType") or {}).get("code")),
79
+ "version_number": row.get("versionNumber"),
80
+ "issuing_agency": _tail(ia_code),
81
+ "sterile": row.get("sterile"),
82
+ # manufacturerStatus is a {code} object (same shape as riskClass) — _tail accepts both shapes, so passing it straight yields active (P1-2)
83
+ "manufacturer": {"name": row.get("manufacturerName"), "srn": row.get("manufacturerSrn"),
84
+ "status": _tail(row.get("manufacturerStatus"))},
85
+ "authorised_representative": (
86
+ {"name": row.get("authorisedRepresentativeName"), "srn": row.get("authorisedRepresentativeSrn")}
87
+ if (row.get("authorisedRepresentativeSrn") or row.get("authorisedRepresentativeName")) else None),
88
+ "last_update_date": row.get("lastUpdateDate"),
89
+ "uuid": row.get("uuid"),
90
+ }
91
+
92
+
93
+ def lookup_di_eu(di, *, cache=None, no_cache=False):
94
+ """→ EU device identity card (manufacturer SRN + authorised-rep SRN edge + risk class/status/version) — the old verb face (resolve kind=device_eu)."""
95
+ from ..errors import SdkError
96
+
97
+ v = str(di or "").strip()
98
+ if not v:
99
+ raise SdkError("usage_error", provider="eudamed", detail="resolve kind=device_eu needs a DI code")
100
+ r = _query(_params(v), cache, no_cache)
101
+ row = (r["data"].get("content") or [None])[0]
102
+ return {**_card_from_row(row), "_meta": r}
103
+
104
+
105
+ def query_udi_di(primary_di=None, *, page=0, page_size=5, iso2_code="en", cache=None, no_cache=False):
106
+ """0.2.0 mirror face: the primaryDi exact lookup + the upstream param names page/pageSize/iso2Code (paging is unlocked this way).
107
+
108
+ size is sent together with pageSize (all three params go out together); a larger pageSize widens the rows (the unindexed face is cold ~50s, so widen with care).
109
+ → identity card (the first-page row) + pagination echo total_elements/page/page_size; paging past the end = an empty page → not_found (the single-record face is the detail lookup).
110
+ """
111
+ from ..errors import SdkError
112
+
113
+ v = str(primary_di or "").strip()
114
+ if not v:
115
+ raise SdkError("usage_error", provider="eudamed", detail="eudamed_device needs primary_di (the EU UDI-DI)")
116
+ if int(page_size) < 1:
117
+ raise SdkError("usage_error", provider="eudamed", detail="page_size must be >= 1")
118
+ params = {"primaryDi": v, "page": int(page), "pageSize": int(page_size),
119
+ "size": int(page_size), "iso2Code": str(iso2_code or "en")}
120
+ r = _query(params, cache, no_cache)
121
+ row = (r["data"].get("content") or [None])[0]
122
+ return {**_card_from_row(row),
123
+ "total_elements": (r["data"] or {}).get("totalElements"),
124
+ "page": int(page), "page_size": int(page_size), "_meta": r}