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.
- agentdata/__init__.py +33 -0
- agentdata/__main__.py +4 -0
- agentdata/cache.py +115 -0
- agentdata/cli.py +173 -0
- agentdata/client.py +158 -0
- agentdata/company/__init__.py +3 -0
- agentdata/company/cf.py +145 -0
- agentdata/company/ch_zefix.py +100 -0
- agentdata/company/cli.py +243 -0
- agentdata/company/co_rues.py +72 -0
- agentdata/company/edgar_cik.py +84 -0
- agentdata/company/edgar_fts.py +101 -0
- agentdata/company/faces.py +746 -0
- agentdata/company/fdic_bankfind.py +107 -0
- agentdata/company/fi_ytj.py +89 -0
- agentdata/company/fr_recherche.py +124 -0
- agentdata/company/fts.py +121 -0
- agentdata/company/gleif.py +178 -0
- agentdata/company/kr_hometax.py +112 -0
- agentdata/company/no_brreg.py +124 -0
- agentdata/company/ror.py +105 -0
- agentdata/company/ted.py +117 -0
- agentdata/company/usaspending.py +124 -0
- agentdata/company/vies.py +66 -0
- agentdata/daterange.py +35 -0
- agentdata/economy/__init__.py +10 -0
- agentdata/economy/bis.py +132 -0
- agentdata/economy/cli.py +199 -0
- agentdata/economy/ecb.py +117 -0
- agentdata/economy/eurostat.py +163 -0
- agentdata/economy/faces.py +573 -0
- agentdata/economy/fred.py +92 -0
- agentdata/economy/ilo_stat.py +99 -0
- agentdata/economy/imf_sdmx.py +133 -0
- agentdata/economy/un_comtrade.py +199 -0
- agentdata/economy/unsd_sdg.py +154 -0
- agentdata/economy/wb.py +208 -0
- agentdata/economy/who_xmart.py +177 -0
- agentdata/entries.py +30 -0
- agentdata/errors.py +38 -0
- agentdata/esg/__init__.py +5 -0
- agentdata/esg/cli.py +180 -0
- agentdata/esg/climatetrace.py +260 -0
- agentdata/esg/eiti.py +187 -0
- agentdata/esg/epa_frs.py +138 -0
- agentdata/esg/euasl.py +75 -0
- agentdata/esg/eutl.py +66 -0
- agentdata/esg/faces.py +498 -0
- agentdata/esg/paris_mou.py +240 -0
- agentdata/esg/thetis_mrv.py +67 -0
- agentdata/healthcare/__init__.py +7 -0
- agentdata/healthcare/au_tga.py +142 -0
- agentdata/healthcare/ca_dpd.py +123 -0
- agentdata/healthcare/ca_mdall.py +155 -0
- agentdata/healthcare/cli.py +375 -0
- agentdata/healthcare/ctgov.py +101 -0
- agentdata/healthcare/dailymed.py +102 -0
- agentdata/healthcare/epmc.py +108 -0
- agentdata/healthcare/eudamed.py +124 -0
- agentdata/healthcare/faces.py +811 -0
- agentdata/healthcare/fda_crl.py +86 -0
- agentdata/healthcare/fda_devices.py +199 -0
- agentdata/healthcare/fda_drugs.py +156 -0
- agentdata/healthcare/fda_recalls.py +137 -0
- agentdata/healthcare/gb_mhra.py +137 -0
- agentdata/healthcare/gudid.py +84 -0
- agentdata/healthcare/mesh.py +89 -0
- agentdata/healthcare/rasff.py +164 -0
- agentdata/healthcare/rxnav.py +67 -0
- agentdata/healthcare/safety_gate.py +175 -0
- agentdata/healthcare/shortages.py +107 -0
- agentdata/legal/__init__.py +4 -0
- agentdata/legal/afdb.py +93 -0
- agentdata/legal/ca_sema.py +143 -0
- agentdata/legal/cli.py +312 -0
- agentdata/legal/co_sic.py +70 -0
- agentdata/legal/courtlistener.py +77 -0
- agentdata/legal/csl.py +139 -0
- agentdata/legal/dgcomp.py +125 -0
- agentdata/legal/ecfr.py +129 -0
- agentdata/legal/faces.py +700 -0
- agentdata/legal/federalregister.py +188 -0
- agentdata/legal/gb_cma.py +117 -0
- agentdata/legal/gb_tna_caselaw.py +245 -0
- agentdata/legal/idb.py +85 -0
- agentdata/legal/lda.py +147 -0
- agentdata/legal/sipri.py +178 -0
- agentdata/legal/ukec.py +148 -0
- agentdata/legal/wb.py +92 -0
- agentdata/procurement/__init__.py +5 -0
- agentdata/procurement/br_pncp.py +126 -0
- agentdata/procurement/cli.py +155 -0
- agentdata/procurement/co_secop.py +88 -0
- agentdata/procurement/faces.py +239 -0
- agentdata/procurement/ny_open_data.py +82 -0
- agentdata/procurement/ungm.py +137 -0
- agentdata/procurement/wb_proc.py +84 -0
- agentdata/research/__init__.py +4 -0
- agentdata/research/biorxiv.py +66 -0
- agentdata/research/cli.py +129 -0
- agentdata/research/crossref.py +121 -0
- agentdata/research/faces.py +349 -0
- agentdata/research/gb_gtr.py +58 -0
- agentdata/research/icite.py +52 -0
- agentdata/research/nih_reporter.py +143 -0
- agentdata/research/nsf.py +68 -0
- agentdata/research/openalex.py +145 -0
- agentdata/research/opencitations.py +50 -0
- agentdata/security/__init__.py +3 -0
- agentdata/security/cisa_kev.py +42 -0
- agentdata/security/cli.py +37 -0
- agentdata/security/faces.py +91 -0
- agentdata/security/osv.py +72 -0
- agentdata/transport.py +541 -0
- agentdata-0.3.26.dist-info/METADATA +266 -0
- agentdata-0.3.26.dist-info/RECORD +120 -0
- agentdata-0.3.26.dist-info/WHEEL +5 -0
- agentdata-0.3.26.dist-info/entry_points.txt +10 -0
- agentdata-0.3.26.dist-info/licenses/LICENSE +201 -0
- 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}
|