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,188 @@
1
+ """federalregister — Federal Register API provider (federalregister.gov; keyless).
2
+
3
+ Federal regulations/notices full-text search over the daily release stream (rules, proposed rules, notices).
4
+ Shape verified 2026-10-23:
5
+ GET /api/v1/documents.json?conditions[term]=<q>&conditions[publication_date][gte|lte]=YYYY-MM-DD
6
+ &per_page=N&page=1&order=newest|relevance
7
+ → {count, total_pages, next_page_url, results:[{document_number, type:RULE|PRORULE|NOTICE|PRESDOCU,
8
+ title, abstract, publication_date, agencies:[{name,raw_name,url}], citation, comments_close_on, …}]}
9
+ — omitting fields[] returns every field (a few KB per record; no problem observed at per_page 50 this round).
10
+ GET /api/v1/documents/{document_number}.json → a single record with raw_text_url/body_html_url (full-text anchors,
11
+ used by the corpus batch line to fetch documents).
12
+ The date range must carry both ends to actually filter (the EDGAR FTS trap of the same kind: a single end is
13
+ silently ignored — this provider always sends both ends).
14
+ TTL: search 24h (daily release stream) / single record 30 days; polite rate 1 rps (official no-key guidance ≤5,
15
+ we take the conservative value).
16
+ """
17
+ import re
18
+ from datetime import date
19
+
20
+ from ..errors import SdkError
21
+
22
+ provider = {
23
+ "id": "federalregister",
24
+ "baseUrl": "https://www.federalregister.gov",
25
+ "rate": {"rps": 1, "burst": 2},
26
+ "ops": {
27
+ "search": {"path": "/api/v1/documents.json"}, # params: conditions[term], conditions[publication_date][gte/lte], per_page, page, order
28
+ "document": {"path": "/api/v1/documents/{doc}.json"}, # doc = document_number (e.g. 2026-12345)
29
+ },
30
+ "ttl": {"search": 24 * 3600, "document": 30 * 86400},
31
+ }
32
+
33
+ _DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
34
+
35
+ # 0.2.9 audit (2026-10-28): order has a fourth official value executive_order_number (listed in the official
36
+ # documentation.json, verified 200 in a live probe) — the old whitelist missed it and blocked it locally by mistake.
37
+ # The type closed set has four values (official enumeration); **an invalid type silently returns
38
+ # 0 rows upstream** (conditions[type][]=BOGUS → 200 count=0, no 400 — a false-negative trap), so it is blocked
39
+ # locally here (osv/sectors have the same shape).
40
+ ORDERS = ("relevance", "newest", "oldest", "executive_order_number")
41
+ DOC_TYPES = ("RULE", "PRORULE", "NOTICE", "PRESDOCU")
42
+
43
+
44
+ def docs_from_upstream(data, size):
45
+ """Upstream response → hit rows (pure function, offline-testable)."""
46
+ rows = [{
47
+ "document_number": d.get("document_number"), # stable key (used by the corpus batch line to fetch full text)
48
+ "title": d.get("title"),
49
+ "type": d.get("type"), # RULE|PRORULE|NOTICE|PRESDOCU
50
+ "publication_date": d.get("publication_date"),
51
+ "abstract": d.get("abstract") or None, # upstream often returns an empty string — normalize to None
52
+ "citation": d.get("citation"),
53
+ "agencies": [a.get("name") or a.get("raw_name") for a in (d.get("agencies") or [])
54
+ if a.get("name") or a.get("raw_name")],
55
+ "docket_ids": d.get("docket_ids") or [],
56
+ "comments_close_on": d.get("comments_close_on") or None,
57
+ "url": d.get("html_url"),
58
+ } for d in ((data or {}).get("results") or [])]
59
+ return {
60
+ "rows": rows, "count": len(rows),
61
+ "limit_applied": size,
62
+ "total": data.get("count") if isinstance(data, dict) and isinstance(data.get("count"), int) else None,
63
+ "total_pages": data.get("total_pages") if isinstance(data, dict) and isinstance(data.get("total_pages"), int) else None,
64
+ }
65
+
66
+
67
+ def search_docs(q=None, *, startdt=None, enddt=None, order="relevance", page=1, limit=20,
68
+ cache=None, no_cache=False):
69
+ """→ {rows[], count, limit_applied, total, total_pages, _meta}; full-text search hits (q goes through
70
+ conditions[term] at full-text level).
71
+ The date range requires startdt/enddt as a pair (a single end is silently ignored upstream — reject it
72
+ outright rather than pass it silently)."""
73
+ from ..transport import call_provider
74
+
75
+ term = str(q or "").strip()
76
+ if not term:
77
+ raise SdkError("usage_error", provider="federalregister",
78
+ detail="search_docs requires q (quote phrases for exact-phrase matching)")
79
+ if (startdt is None) != (enddt is None):
80
+ raise SdkError("usage_error", provider="federalregister",
81
+ detail="the date range requires startdt and enddt as a pair (a single end is silently ignored upstream)")
82
+ for d in (startdt, enddt):
83
+ if d is not None and not _DATE.match(d):
84
+ raise SdkError("usage_error", provider="federalregister", detail=f"date must be YYYY-MM-DD: {d}")
85
+ if order not in ORDERS:
86
+ raise SdkError("usage_error", provider="federalregister", detail=f"order must be one of {'|'.join(ORDERS)}, got \"{order}\"")
87
+ size = min(max(1, int(limit or 20)), 50) # upstream per_page ceiling is 1000 — our polite cap is 50 (realtime face)
88
+ params = {"conditions[term]": term, "per_page": size, "page": max(1, int(page or 1)), "order": order}
89
+ if startdt:
90
+ params["conditions[publication_date][gte]"] = startdt
91
+ params["conditions[publication_date][lte]"] = enddt
92
+ r = call_provider(provider, "search", params, cache=cache, no_cache=no_cache)
93
+ return {**docs_from_upstream(r["data"], size), "_meta": r}
94
+
95
+
96
+ DATA_START = "1994-01-01" # Federal Register data start (1994+ daily release stream) — the default when one end is missing
97
+
98
+
99
+ def search_raw(q, *, startdt=None, enddt=None, order="relevance", page=1, limit=20,
100
+ doc_type=None, agencies=None, cfr_title=None, cfr_part=None,
101
+ cache=None, no_cache=False):
102
+ """Flat-panel low-level face + 0.2.9 audit param additions: conditions[term]/[publication_date][gte|lte]/
103
+ [type][]/[agencies][]/[cfr][title|part]/per_page/page/order go through with verbatim names.
104
+
105
+ → {rows[], count, limit_applied, total, total_pages, date_range, params, _meta}.
106
+ Missing end is auto-completed (same semantics as the full-text face): giving either end completes the other
107
+ (missing startdt → 1994-01-01, missing enddt → today), after which both ends are always sent — a single end is
108
+ silently ignored upstream (verified 2026-10-23). limit polite cap 50 (upstream per_page ceiling 1000).
109
+ 0.2.9 new params (reconciled against the official documentation.json and probed live 2026-10-28):
110
+ - order's fourth value executive_order_number (listed officially, verified 200) — the old whitelist's false block is fixed.
111
+ - doc_type→conditions[type][]: the closed set of four values RULE/PRORULE/NOTICE/PRESDOCU is validated locally —
112
+ **an invalid value silently returns 0 rows upstream** (type=BOGUS → 200 count=0, no 400 — a false-negative trap);
113
+ valid values really filter (asbestos+PRORULE=426 vs 2551 unfiltered, the response description says
114
+ "of type Proposed Rule"). Single value/param (upstream array members need repeated keys, the transport dict does
115
+ not support repeated keys — the multi-value OR is not exposed for now, recorded).
116
+ - agencies→conditions[agencies][]: agency slug (value domain from GET /api/v1/agencies; environmental-protection-agency
117
+ verified working, the description says "from Environmental Protection Agency").
118
+ - cfr_title/cfr_part→conditions[cfr][title]/[part]: code-anchored search (part may be a range form "763" or "1-50"
119
+ per the official wording, and the official text states that part requires the CFR title — this face rejects cp
120
+ without ct; title=40&part=763 verified count=33, all Rules — the TSCA section; cfr alone (no term) also works,
121
+ while q is required here because that is the search-face semantics, not an upstream restriction)."""
122
+ from ..transport import call_provider
123
+
124
+ term = str(q or "").strip()
125
+ if not term:
126
+ raise SdkError("usage_error", provider="federalregister",
127
+ detail="search_raw requires q (quote phrases for exact-phrase matching)")
128
+ for d in (startdt, enddt):
129
+ if d is not None and not _DATE.match(d):
130
+ raise SdkError("usage_error", provider="federalregister", detail=f"date must be YYYY-MM-DD: {d}")
131
+ if order not in ORDERS:
132
+ raise SdkError("usage_error", provider="federalregister",
133
+ detail=f"order must be one of {'|'.join(ORDERS)}, got \"{order}\"")
134
+ dt = str(doc_type or "").strip() or None
135
+ if dt is not None and dt not in DOC_TYPES:
136
+ raise SdkError("usage_error", provider="federalregister",
137
+ detail=f"doc_type \"{dt}\" is not in the closed set ({'|'.join(DOC_TYPES)}; "
138
+ "invalid values silently return 0 rows upstream — the false-negative guard lives here; "
139
+ "official enumeration documentation.json reconciled 2026-10-28)")
140
+ ag = str(agencies or "").strip() or None
141
+ ct = str(cfr_title or "").strip() or None
142
+ cp = str(cfr_part or "").strip() or None
143
+ if cp and not ct:
144
+ raise SdkError("usage_error", provider="federalregister",
145
+ detail="cfr_part must be used together with cfr_title (official semantics: part requires the title anchor)")
146
+ size = min(max(1, int(limit or 20)), 50) # polite cap 50 (upstream ceiling 1000) — realtime single-query face
147
+ dr = None
148
+ if startdt or enddt:
149
+ s = startdt or DATA_START
150
+ e = enddt or date.today().isoformat()
151
+ dr = {"startdt": s, "enddt": e}
152
+ params = {"conditions[term]": term, "per_page": size, "page": max(1, int(page or 1)), "order": order}
153
+ if dr:
154
+ params["conditions[publication_date][gte]"] = dr["startdt"]
155
+ params["conditions[publication_date][lte]"] = dr["enddt"]
156
+ if dt:
157
+ params["conditions[type][]"] = dt
158
+ if ag:
159
+ params["conditions[agencies][]"] = ag
160
+ if ct:
161
+ params["conditions[cfr][title]"] = ct
162
+ if cp:
163
+ params["conditions[cfr][part]"] = cp
164
+ r = call_provider(provider, "search", params, cache=cache, no_cache=no_cache)
165
+ return {**docs_from_upstream(r["data"], size), "date_range": dr, "params": params, "_meta": r}
166
+
167
+
168
+ def get_document(doc, *, cache=None, no_cache=False):
169
+ """→ a single record with all fields (including the raw_text_url / body_html_url full-text anchors)."""
170
+ from ..transport import call_provider
171
+
172
+ d = str(doc or "").strip()
173
+ if not re.fullmatch(r"\d{2,4}-\d{1,6}", d):
174
+ raise SdkError("usage_error", provider="federalregister",
175
+ detail=f"not in document_number form (e.g. 2026-12345): {doc}")
176
+ r = call_provider(provider, "document", {"doc": d}, cache=cache, no_cache=no_cache)
177
+ x = r["data"] or {}
178
+ return {
179
+ "document_number": x.get("document_number") or d, "title": x.get("title"), "type": x.get("type"),
180
+ "publication_date": x.get("publication_date"), "abstract": x.get("abstract") or None,
181
+ "citation": x.get("citation"),
182
+ "agencies": [a.get("name") or a.get("raw_name") for a in (x.get("agencies") or [])
183
+ if a.get("name") or a.get("raw_name")],
184
+ "docket_ids": x.get("docket_ids") or [],
185
+ "body_html_url": x.get("body_html_url"), "raw_text_url": x.get("raw_text_url"),
186
+ "comments_close_on": x.get("comments_close_on") or None, "url": x.get("html_url"),
187
+ "_meta": r,
188
+ }
@@ -0,0 +1,117 @@
1
+ """gb_cma — UK Competition and Markets Authority case face provider (www.gov.uk content/search API; keyless).
2
+
3
+ Shape verified 2026-10-29:
4
+ GET /api/search.json?filter_format=cma_case&q=&count=&start=
5
+ → {results:[{title,link:'/cma-cases/<slug>',_id=content_id,public_timestamp,description,
6
+ organisations:[…],format:'cma_case',…}], total, start, aggregates, …}
7
+ — filter_format=cma_case is this face's semantics (a fixed param, not a user param); q/count/start go through
8
+ with upstream verbatim names.
9
+ count has no upstream wall (verified 2026-10-29: count=1000 → the full 1000 rows/76KB in one shot) — this face
10
+ uses a politeness cap of 100.
11
+ GET /api/content/cma-cases/{slug}
12
+ → {content_id(UUID stable key), title, first_published_at, public_updated_at, updated_at,
13
+ withdrawn_notice, details:{body(HTML), change_history:[{note,public_timestamp}],
14
+ attachments:[{title,url,content_type,content_id,…}]}, links:{organisations,…}}
15
+ Param-minimalism pitfall: combined params like organisations/order → 422 — this face accepts only q/count/start,
16
+ do not widen it.
17
+ atom incremental feed: /cma-cases.atom (the increment anchor for the corpus batch line; not used by this realtime face).
18
+ TTL: search 24h (the case list drifts at daily cadence) / case 7 days (case cards change slowly; change_history
19
+ records the evolution); polite rate 1 rps (gov.uk publishes no search-API quota — conservative).
20
+ """
21
+ from ..errors import SdkError
22
+
23
+ provider = {
24
+ "id": "gb_cma",
25
+ "baseUrl": "https://www.gov.uk",
26
+ "rate": {"rps": 1, "burst": 2},
27
+ "ops": {
28
+ "search": {"path": "/api/search.json"}, # params: q, count, start (filter_format is fixed for this face)
29
+ "case": {"path": "/api/content/cma-cases/{slug}"}, # slug = the tail segment of the case-page base_path (the search row's link field)
30
+ },
31
+ "ttl": {"search": 24 * 3600, "case": 7 * 86400},
32
+ }
33
+
34
+
35
+ def cases_from_upstream(data, size):
36
+ """Upstream search response → case rows (pure function, offline-testable)."""
37
+ rows = [{
38
+ "slug": (r.get("link") or "").removeprefix("/cma-cases/") or None, # join key into the detail face (link=/cma-cases/<slug>;
39
+ # the search row's _id ≡ base_path, not a UUID — do not pass it off as content_id)
40
+ "title": r.get("title"), # case name = the party pair "A / B" form
41
+ "public_timestamp": r.get("public_timestamp"),
42
+ "description": r.get("description") or None,
43
+ "organisations": [o.get("title") for o in (r.get("organisations") or []) if o.get("title")],
44
+ "url": ("https://www.gov.uk" + r["link"]) if r.get("link") else None,
45
+ } for r in ((data or {}).get("results") or [])]
46
+ return {
47
+ "rows": rows, "count": len(rows), "limit_applied": size,
48
+ "total": data.get("total") if isinstance(data, dict) and isinstance(data.get("total"), int) else None,
49
+ }
50
+
51
+
52
+ def search_raw(q=None, *, count=20, start=0, cache=None, no_cache=False):
53
+ """Flat-panel low-level face: q/count/start go through with verbatim names (filter_format=cma_case fixed).
54
+
55
+ → {rows[], count, limit_applied, total, params, _meta}. A missing q = full-register enumeration (2,591 live
56
+ cases counted 2026-10-29); count politeness wall 1-100 (upstream has no wall — count=1000 was verified to
57
+ deliver in full); start = offset paging."""
58
+ from ..transport import call_provider
59
+
60
+ term = str(q or "").strip() or None
61
+ size = int(count) if count is not None else 20 # explicit None check — 0 being swallowed by `or` was a same-type bug caught by a test
62
+ if not 1 <= size <= 100:
63
+ raise SdkError("usage_error", provider="gb_cma",
64
+ detail=f"count must be 1-100 (politeness cap; upstream has no wall — verified 2026-10-29, count=1000 delivers in full), got \"{count}\"")
65
+ off = int(start) if start is not None else 0
66
+ if off < 0:
67
+ raise SdkError("usage_error", provider="gb_cma", detail=f"start must be an offset ≥0, got \"{start}\"")
68
+ params = {"filter_format": "cma_case", "count": size, "start": off}
69
+ if term:
70
+ params["q"] = term
71
+ r = call_provider(provider, "search", params, cache=cache, no_cache=no_cache)
72
+ return {**cases_from_upstream(r["data"], size), "params": params, "_meta": r}
73
+
74
+
75
+ def _clean_slug(slug):
76
+ s = str(slug or "").strip()
77
+ # Tolerance: a pasted full base_path (/cma-cases/<slug> or https://www.gov.uk/cma-cases/<slug>) gets its prefix stripped
78
+ for pre in ("https://www.gov.uk/cma-cases/", "http://www.gov.uk/cma-cases/", "/cma-cases/"):
79
+ if s.startswith(pre):
80
+ s = s[len(pre):]
81
+ s = s.strip("/")
82
+ if not s or "/" in s:
83
+ raise SdkError("usage_error", provider="gb_cma",
84
+ detail=f"slug must be the case-page tail segment (e.g. nexfibre-slash-substantial-merger-inquiry, "
85
+ f"echoed verbatim from the search row's link field): {slug}")
86
+ return s
87
+
88
+
89
+ def case_raw(slug, *, cache=None, no_cache=False):
90
+ """→ the full single-case dossier (content API; content_id = the UUID stable key).
91
+ attachments = the decision/notice PDF family."""
92
+ from ..transport import call_provider
93
+
94
+ s = _clean_slug(slug)
95
+ r = call_provider(provider, "case", {"slug": s}, cache=cache, no_cache=no_cache)
96
+ x = r["data"] or {}
97
+ det = x.get("details") or {}
98
+ return {
99
+ "slug": s,
100
+ "content_id": x.get("content_id"),
101
+ "title": x.get("title"),
102
+ "description": x.get("description") or None,
103
+ "document_type": x.get("document_type"),
104
+ "first_published_at": x.get("first_published_at"),
105
+ "public_updated_at": x.get("public_updated_at"),
106
+ "updated_at": x.get("updated_at"),
107
+ "withdrawn": bool(x.get("withdrawn_notice")),
108
+ "change_history": det.get("change_history") or [], # [{note, public_timestamp}] newest→oldest (last = First published)
109
+ "attachments": [{"title": a.get("title"), "url": a.get("url"),
110
+ "content_type": a.get("content_type"), "content_id": a.get("content_id")}
111
+ for a in (det.get("attachments") or [])],
112
+ "body_html": det.get("body") or "", # the case-page body HTML (investigation/decision summary)
113
+ "organisations": [o.get("title") for o in ((x.get("links") or {}).get("organisations") or [])
114
+ if o.get("title")],
115
+ "url": f"https://www.gov.uk/cma-cases/{s}",
116
+ "_meta": r,
117
+ }
@@ -0,0 +1,245 @@
1
+ """gb_tna_caselaw — UK National Archives caselaw library provider (caselaw.nationalarchives.gov.uk; keyless).
2
+
3
+ The legal register's judgment line P0 (roadmapped 2026-10-29; born 0.3.8, ISO-prefixed 0.3.13, Englished in the
4
+ 0.3.14 collection round). Shapes verified by first-hand live probing 2026-10-29 (tmp/probe-tna/):
5
+ GET /atom.xml?party=&judge=&court=&tribunal=&query=&order=&page=&per_page=&minimum_availability=
6
+ → Atom feed (official OpenAPI: the feed header's rel=service-desc links nationalarchives/ds-find-caselaw-docs)
7
+ — entries carry tna:uri (the machine-stable document URI) / tna:identifier (ukncn = the neutral-citation
8
+ human-readable key + fclid, a dual key) / tna:contenthash (body SHA256, cross-format change detection) /
9
+ author=name = the court name; link rel=last = total pages.
10
+ per_page defaults to 50 (polite tier 1-50); order enum ±date/±updated/±transformation;
11
+ court/tribunal are arrays with explode=true (repeated keys); minimum_availability enum metadata/document/full-text.
12
+ GET /{document_uri}/data.xml → Akoma Ntoso (LegalDocML):
13
+ meta/proprietary = court/year/number/cite/caseNumber/hash; FRBRWork = FRBRname case name + FRBRdate handed-down date;
14
+ header/<party as="#claimant|#defendant" refersTo="#eId"> = the official party→role edge (companies + individuals);
15
+ judgmentBody = the body (~32k chars per document).
16
+ Two document-URI generations coexist (per the official contract, never parse attributes out of the URI): before
17
+ 2025-04 court/year/seq (with slashes), after that d-<uuid>; identifiers additionally carry an fclid slug form
18
+ (tna.xxxx) — all three forms are accepted by the judgment face.
19
+ Rate: official 1000 req/rolling 5 min (≈3.3 rps) — polite tier 0.5 rps.
20
+ **The licence gate (first case of keyless-but-licence)**: the Open Justice Licence allows commercial reuse and
21
+ expressly forbids bulk computational analysis (free on application) — this face is directed search /
22
+ single-document reading (rolling-monitor compliant); do not enumerate-harvest the corpus.
23
+ TTL: search 24h (the feed drifts at daily cadence) / judgment 7 days (judgments are slow-moving; the contenthash
24
+ detects text changes).
25
+ """
26
+ import re
27
+ import xml.etree.ElementTree as ET
28
+
29
+ from ..errors import SdkError
30
+
31
+ _ATOM = "{http://www.w3.org/2005/Atom}"
32
+ _TNA = "{https://caselaw.nationalarchives.gov.uk}"
33
+ _AKN = "{http://docs.oasis-open.org/legaldocml/ns/akn/3.0}"
34
+
35
+ _ORDERS = ("date", "-date", "updated", "-updated", "transformation", "-transformation")
36
+ _AVAILABILITY = ("metadata", "document", "full-text")
37
+
38
+ provider = {
39
+ "id": "gb_tna_caselaw",
40
+ "baseUrl": "https://caselaw.nationalarchives.gov.uk",
41
+ "rate": {"rps": 0.5, "burst": 1},
42
+ "ops": {
43
+ "search": {"path": "/atom.xml", "accept": "text"}, # params: query/court/tribunal/party/judge/order/page/per_page/minimum_availability
44
+ "judgment": {"path": "/{document_uri}/data.xml", "accept": "text",
45
+ "path_safe": "/"}, # document_uri contains slashes (ewhc/admin/2026/2298) — keep / in path params
46
+ },
47
+ "ttl": {"search": 24 * 3600, "judgment": 7 * 86400},
48
+ }
49
+
50
+
51
+ def _as_list(v):
52
+ """court/tribunal explode arrays: a str is treated as a single element (a CLI --court repeat builds a list passed straight through)."""
53
+ if v is None:
54
+ return None
55
+ return v if isinstance(v, (list, tuple)) else [v]
56
+
57
+
58
+ def entries_from_atom(text):
59
+ """Upstream Atom feed → judgment rows + feed-level ledger (pure function, offline-testable)."""
60
+ try:
61
+ root = ET.fromstring(text)
62
+ except ET.ParseError as e:
63
+ raise SdkError("upstream_error", provider="gb_tna_caselaw",
64
+ detail=f"atom feed XML parse failed: {e}")
65
+ if root.tag != _ATOM + "feed":
66
+ raise SdkError("upstream_error", provider="gb_tna_caselaw",
67
+ detail=f"unexpected root element {root.tag} (expected an Atom feed)")
68
+
69
+ def _page(href):
70
+ m = re.search(r"[?&]page=(\d+)", href or "")
71
+ return int(m.group(1)) if m else None
72
+
73
+ total_pages, feed_updated, license_url = None, None, None
74
+ for el in root:
75
+ if el.tag == _ATOM + "link" and el.get("rel") == "last":
76
+ total_pages = _page(el.get("href"))
77
+ elif el.tag == _ATOM + "updated":
78
+ feed_updated = (el.text or "").strip() or None
79
+ elif el.tag == _ATOM + "rights":
80
+ license_url = (el.text or "").strip() or None
81
+
82
+ rows = []
83
+ for e in root.findall(_ATOM + "entry"):
84
+ neutral_citation = fcid = None
85
+ for ident in e.findall(_TNA + "identifier"):
86
+ if ident.get("type") == "ukncn":
87
+ neutral_citation = (ident.text or "").strip() or None
88
+ elif ident.get("type") == "fclid":
89
+ fcid = (ident.text or "").strip() or None
90
+ links = {((l.get("rel") or ""), (l.get("type") or "")): l.get("href") for l in e.findall(_ATOM + "link")}
91
+ rows.append({
92
+ "uri": next((t.text.strip() for t in e.findall(_TNA + "uri") if t.text), None),
93
+ "neutral_citation": neutral_citation, # ukncn human-readable official key ([2026] EWHC 2298 (Admin))
94
+ "fcid": fcid, # Find Case Law Identifier (source of the tna.xxxx slug)
95
+ "title": (e.findtext(_ATOM + "title") or "").strip() or None, # case name = the party string
96
+ "court": (e.findtext(_ATOM + "author/" + _ATOM + "name") or "").strip() or None,
97
+ "published": e.findtext(_ATOM + "published"), # handed-down date
98
+ "updated": e.findtext(_ATOM + "updated"), # XML last-updated (includes markup touch-ups)
99
+ "contenthash": next((t.text.strip() for t in e.findall(_TNA + "contenthash") if t.text), None),
100
+ "html_url": links.get(("alternate", "")),
101
+ "data_url": links.get(("alternate", "application/akn+xml")),
102
+ "pdf_url": links.get(("alternate", "application/pdf")),
103
+ })
104
+ return {"rows": rows, "count": len(rows), "total_pages": total_pages,
105
+ "feed_updated": feed_updated, "license": license_url}
106
+
107
+
108
+ def search_raw(query=None, *, court=None, tribunal=None, party=None, judge=None,
109
+ order=None, page=1, per_page=50, minimum_availability=None,
110
+ cache=None, no_cache=False):
111
+ """Flat-panel plumbing: eight params under their upstream names, straight through (GET /atom.xml).
112
+
113
+ → {rows[], count, page, per_page, total_pages, feed_updated, params, _meta}.
114
+ party/judge = party/judge names with whole-word matching; court/tribunal = court codes (repeatable, explode);
115
+ query = full-text search; total_pages comes straight from the feed header's link rel=last.
116
+ The licence forbids bulk — a directed-search face."""
117
+ from ..transport import call_provider
118
+
119
+ params = {}
120
+ if query is not None and str(query).strip():
121
+ params["query"] = str(query).strip()
122
+ for k, v in (("party", party), ("judge", judge)):
123
+ if v is not None and str(v).strip():
124
+ params[k] = str(v).strip()
125
+ for k, v in (("court", _as_list(court)), ("tribunal", _as_list(tribunal))):
126
+ if v:
127
+ params[k] = [str(x).strip() for x in v if str(x).strip()]
128
+ if not params[k]:
129
+ del params[k]
130
+ if order is not None:
131
+ if order not in _ORDERS:
132
+ raise SdkError("usage_error", provider="gb_tna_caselaw",
133
+ detail=f"order must be one of {'/'.join(_ORDERS)}, got {order!r}")
134
+ params["order"] = order
135
+ pg = int(page) if page is not None else 1
136
+ if pg < 1:
137
+ raise SdkError("usage_error", provider="gb_tna_caselaw", detail=f"page must be >=1, got {page!r}")
138
+ size = int(per_page) if per_page is not None else 50
139
+ if not 1 <= size <= 50:
140
+ raise SdkError("usage_error", provider="gb_tna_caselaw",
141
+ detail=f"per_page must be 1-50 (polite tier = the upstream default page size; the licence "
142
+ f"forbids bulk — do not pull larger pages), got {per_page!r}")
143
+ params["page"], params["per_page"] = pg, size
144
+ if minimum_availability is not None:
145
+ if minimum_availability not in _AVAILABILITY:
146
+ raise SdkError("usage_error", provider="gb_tna_caselaw",
147
+ detail=f"minimum_availability must be one of {'/'.join(_AVAILABILITY)}, got "
148
+ f"{minimum_availability!r}")
149
+ params["minimum_availability"] = minimum_availability
150
+ r = call_provider(provider, "search", params, cache=cache, no_cache=no_cache)
151
+ out = entries_from_atom(r["data"])
152
+ return {**out, "page": pg, "per_page": size, "params": params, "_meta": r}
153
+
154
+
155
+ _URI_CHARS = re.compile(r"^[a-z0-9][a-z0-9/.\-]*$")
156
+
157
+
158
+ def _clean_uri(document_uri):
159
+ s = str(document_uri or "").strip()
160
+ # tolerance: a pasted full URL (…/<uri> or …/<uri>/data.xml) is shelled; all three forms accepted —
161
+ # the tna.xxxx slug, the modern d-uuid, and the legacy court/year/seq
162
+ for pre in ("https://caselaw.nationalarchives.gov.uk/", "http://caselaw.nationalarchives.gov.uk/", "/"):
163
+ if s.startswith(pre):
164
+ s = s[len(pre):]
165
+ if s.endswith("/data.xml"):
166
+ s = s[: -len("/data.xml")]
167
+ s = s.strip("/")
168
+ if not s or not _URI_CHARS.match(s):
169
+ raise SdkError("usage_error", provider="gb_tna_caselaw",
170
+ detail=f"document_uri must be a document URI (legacy ewhc/admin/2026/2298, modern d-<uuid>, "
171
+ f"or tna.<fclid>; the search rows' uri/neutral_citation slugs carry them verbatim): "
172
+ f"{document_uri}")
173
+ return s
174
+
175
+
176
+ def _txt(el):
177
+ return " ".join((el.text or "").split()) if el is not None and el.text else None
178
+
179
+
180
+ def judgment_from_akn(text):
181
+ """Akoma Ntoso data.xml → a single structured judgment dossier (pure function, offline-testable)."""
182
+ try:
183
+ root = ET.fromstring(text)
184
+ except ET.ParseError as e:
185
+ raise SdkError("upstream_error", provider="gb_tna_caselaw",
186
+ detail=f"judgment XML parse failed: {e}")
187
+ j = root.find(_AKN + "judgment") # an Element with no children is falsy — always test `is not None`, never an `or` chain
188
+ if j is None:
189
+ j = root.find(_AKN + "doc")
190
+ if j is None:
191
+ raise SdkError("upstream_error", provider="gb_tna_caselaw",
192
+ detail=f"unexpected root structure (expected an Akoma Ntoso judgment, got {root.tag})")
193
+ prop = {}
194
+ p = j.find(_AKN + "meta/" + _AKN + "proprietary")
195
+ if p is not None:
196
+ for el in p:
197
+ tag = el.tag.split("}")[-1]
198
+ if tag in ("court", "year", "number", "cite", "caseNumber", "hash"):
199
+ prop[tag] = (el.text or "").strip() or None
200
+ frbr = j.find(_AKN + "meta/" + _AKN + "identification/" + _AKN + "FRBRWork")
201
+ name = date = frbr_uri = None
202
+ if frbr is not None:
203
+ n = frbr.find(_AKN + "FRBRname") # the case name lives in the value attribute, not element text (Akn attribute-bearing convention)
204
+ name = n.get("value") if n is not None else None
205
+ d = frbr.find(_AKN + "FRBRdate")
206
+ date = d.get("date") if d is not None else None
207
+ this = frbr.find(_AKN + "FRBRthis")
208
+ frbr_uri = this.get("value") if this is not None else None
209
+ # the official party→role edge: header/<party as="#claimant" refersTo="#eId">NAME</party>
210
+ parties = []
211
+ hdr = j.find(_AKN + "header")
212
+ if hdr is not None:
213
+ for pty in hdr.iter(_AKN + "party"):
214
+ parties.append({"name": _txt(pty),
215
+ "role": ((pty.get("as") or "").lstrip("#") or None)})
216
+ judges = []
217
+ for jd in (hdr.iter(_AKN + "judge") if hdr is not None else []):
218
+ nm = _txt(jd)
219
+ if nm and nm not in judges:
220
+ judges.append(nm)
221
+ body = j.find(_AKN + "judgmentBody")
222
+ text_out = " ".join(body.itertext()).strip() if body is not None else None
223
+ return {
224
+ "neutral_citation": prop.get("cite"),
225
+ "court_code": prop.get("court"), # EWHC-KBD-Admin (a proprietary code)
226
+ "case_number": prop.get("caseNumber"), # docket number (e.g. AC-2024-LON-003318)
227
+ "title": name, # FRBRname = the case name (the party string)
228
+ "date": date, # FRBRdate = handed-down date
229
+ "judges": judges,
230
+ "parties": parties, # [{name, role}] the official edge for company + individual parties
231
+ "contenthash": prop.get("hash"),
232
+ "frbr_uri": frbr_uri, # canonical document URI (id/ form)
233
+ "text": text_out, # judgment body (plain-text extraction)
234
+ "text_length": len(text_out) if text_out else 0,
235
+ }
236
+
237
+
238
+ def judgment_raw(document_uri, *, cache=None, no_cache=False):
239
+ """→ a single judgment's full dossier (Akoma Ntoso structured: citation/court/party-role edges/judges/body)."""
240
+ from ..transport import call_provider
241
+
242
+ s = _clean_uri(document_uri)
243
+ r = call_provider(provider, "judgment", {"document_uri": s}, cache=cache, no_cache=no_cache)
244
+ out = judgment_from_akn(r["data"])
245
+ return {"uri": s, "url": f"https://caselaw.nationalarchives.gov.uk/{s}", **out, "_meta": r}
agentdata/legal/idb.py ADDED
@@ -0,0 +1,85 @@
1
+ """idb — Inter-American Development Bank sanctioned-firms roster provider (data.iadb.org CKAN DataStore; keyless).
2
+
3
+ legal debarment-roster face (0.3.21; admitted 2026-11-02, probed tmp/probe-debar-0321). Shape measured 2026-11-02:
4
+ GET /api/action/datastore_search?resource_id=cd0bd9ac-18c6-44bc-8592-9be468c2efd9&… → CKAN envelope
5
+ {result: {records[], total}} — **live total 1,343 rows** (the census-era 2,603 counted cumulative history; the
6
+ live DataStore holds the current register). Rows carry 13 columns verbatim: _id / Title (firm or individual name) /
7
+ Entity (Firm/Individual) / Nationality / Country (a **multi-country cell exists**: " Kenya, United Republic of
8
+ Tanzania, Zambia" — and values carry leading spaces, trimmed at the mapping layer) / From~To (**"Ongoing" or a
9
+ datetime**; From arrives as "2009-07-10 00:00:00.000000000" nanosecond text, kept verbatim) / Prohibited Practice
10
+ ("Fraud, Extortion" — the violation family) / Source (**"IDB" or the cross-debarment marker "ADB cross debarment"
11
+ / "WBG cross debarment"**) / "Tipo de sancion del BID" (Spanish column = the sanction type, "Debarment") /
12
+ IDB Sanction Source (SCOM = Sanctions Committee) / Other Name.
13
+ Query semantics (verified live 2026-11-02): q = full-text across all fields (returns a rank); filters = JSON
14
+ field→value **exact** match (e.g. {"To": "Ongoing"}); sort = "From desc" style; offset paging; include_total=true
15
+ echoes the exact total. The plain agentdata UA passes (no UA gate).
16
+ TTL: 24h; polite tier 0.5 rps (conservative pick; the CKAN publishes no quota).
17
+ """
18
+ import json
19
+
20
+ from ..errors import SdkError
21
+
22
+ provider = {
23
+ "id": "idb",
24
+ "baseUrl": "https://data.iadb.org",
25
+ "rate": {"rps": 0.5, "burst": 2},
26
+ "ops": {
27
+ "debarred": {"path": "/api/action/datastore_search", "timeoutMs": 30000},
28
+ },
29
+ "ttl": {"debarred": 24 * 3600},
30
+ }
31
+
32
+ _RESOURCE_ID = "cd0bd9ac-18c6-44bc-8592-9be468c2efd9" # the "Dataset of Sanctioned firms and individuals" resource
33
+
34
+
35
+ def rows_from_upstream(data):
36
+ """CKAN result → sanction rows (pure function; the 13 upstream columns verbatim, string values trimmed —
37
+ the register ships leading spaces in Country/Prohibited Practice, measured 2026-11-02)."""
38
+ result = (data or {}).get("result") or {}
39
+ rows = []
40
+ for r in result.get("records") or []:
41
+ row = {}
42
+ for k, v in r.items():
43
+ row[k] = v.strip() if isinstance(v, str) else v
44
+ rows.append(row)
45
+ return rows, result.get("total")
46
+
47
+
48
+ def debarred_entities_raw(q=None, *, filters=None, limit=20, offset=0, sort=None, cache=None, no_cache=False):
49
+ """→ {rows, total, limit_applied, offset, params, _meta} (server-side search; include_total=true always sent).
50
+
51
+ q = full-text across all fields (upstream param verbatim); filters = JSON field→value exact-match dict in
52
+ JSON text form (upstream param verbatim — {"To": "Ongoing"}, {"Entity": "Firm"}); limit 1-100 politeness
53
+ cap; offset paging; sort = "From desc" style (upstream param verbatim)."""
54
+ from ..transport import call_provider
55
+
56
+ size = int(limit) if limit is not None else 20
57
+ if not 1 <= size <= 100:
58
+ raise SdkError("usage_error", provider="idb",
59
+ detail=f"limit must be 1-100 (politeness cap), got '{limit}'")
60
+ off = int(offset) if offset is not None else 0
61
+ if off < 0:
62
+ raise SdkError("usage_error", provider="idb", detail=f"offset must be ≥0, got '{offset}'")
63
+ if isinstance(filters, str): # the CLI face hands us JSON text — normalize to the dict channel
64
+ try:
65
+ filters = json.loads(filters) if filters.strip() else None
66
+ except ValueError:
67
+ raise SdkError("usage_error", provider="idb",
68
+ detail="filters must be a JSON object of field→value exact matches, "
69
+ 'e.g. \'{"To": "Ongoing"}\'')
70
+ if filters is not None and not isinstance(filters, dict):
71
+ raise SdkError("usage_error", provider="idb",
72
+ detail="filters must be dict-shaped (field→value exact match), e.g. {\"Entity\": \"Firm\"}")
73
+ qq = str(q or "").strip() or None
74
+ ss = str(sort or "").strip() or None
75
+ params = {"resource_id": _RESOURCE_ID, "limit": size, "offset": off, "include_total": "true"}
76
+ if qq:
77
+ params["q"] = qq
78
+ if filters:
79
+ params["filters"] = json.dumps(filters, ensure_ascii=False)
80
+ if ss:
81
+ params["sort"] = ss
82
+ r = call_provider(provider, "debarred", params, cache=cache, no_cache=no_cache)
83
+ rows, total = rows_from_upstream(r["data"])
84
+ return {"rows": rows, "total": total, "limit_applied": size, "offset": off,
85
+ "params": params, "_meta": r}