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
agentdata/legal/lda.py ADDED
@@ -0,0 +1,147 @@
1
+ """lda — US LDA lobbying-disclosure API v1 provider (company name → lobbying filing face; keyless).
2
+
3
+ The official OpenAPI (lda.gov/api/openapi/v1/) is anonymously usable but rate-limited harder: the ToS anonymous
4
+ tier is 15/min (API key 120/min) — the 0.2.5 fix runs at 14/60 rps with burst 1 (the earlier 0.5 rps = 30/min
5
+ exceeded the limit by 2×; compliance fixed; recorded as an exception in docs/domains/rate-limits.md).
6
+ Channel note (real-machine Python test 2026-10-20): lda.gov fronts with Akamai gating on the **client stack** —
7
+ Node undici always gets 403 and needs the curl subprocess channel; **Python urllib connects directly with 200**
8
+ (machine-local probe: filings count=70,545), so the default urllib path is used
9
+ (gating is stack-dependent — verify per stack on the real machine, do not copy the transport declaration from the Node side).
10
+ DRF requires the trailing slash (without it, 301). Amount pitfall: income/expenses only carry values on
11
+ quarterly reports (Q1-Q4); registration filings (RR/RA) are always null.
12
+ page_size is hard-clamped to 25: verified 2026-10-26, page_size 26/50/100/500 all silently return only 25 rows
13
+ (an earlier note claiming "no published ceiling, 100 = polite cap" was a mis-record — the old verb face's
14
+ limit_applied=100 never actually got more than 25 rows); paging goes through the page param (true offset paging,
15
+ page=2 has a different first row and next/previous are echoed back, verified 2026-10-26).
16
+ """
17
+ from ..errors import SdkError
18
+
19
+ provider = {
20
+ "id": "lda",
21
+ "baseUrl": "https://lda.gov/api/v1",
22
+ "rate": {"rps": 14 / 60, "burst": 1}, # anonymous tier 15/min, hugging the line (quota semantics): the upper bound in any 60s window is 1+14=15≤15 (burst counts against the budget)
23
+ "ops": {
24
+ "filings": {"path": "/filings/"}, # DRF trailing slash; params: registrant_name|client_name|filing_year + page_size/ordering
25
+ "filing": {"path": "/filings/{filing_uuid}/", "notFound": [404]},
26
+ "registrant": {"path": "/registrants/{id}/", "notFound": [404]}, # id=house_registrant_id (official key)
27
+ "client": {"path": "/clients/{id}/", "notFound": [404]}, # id=client_id
28
+ },
29
+ "ttl": {"filings": 7 * 86400, "filing": 7 * 86400, "registrant": 30 * 86400, "client": 30 * 86400},
30
+ }
31
+
32
+
33
+ def search_filings(client_name=None, registrant_name=None, filing_year=None, *,
34
+ limit=20, cache=None, no_cache=False):
35
+ """→ {total, rows[], _meta}; the client=lobbied company / registrant=lobbying firm two-name face.
36
+ Amounts are fetched per filing_type (only quarterly reports carry them) — do not judge liveness by
37
+ "has an amount or not"."""
38
+ from ..transport import call_provider
39
+ from datetime import date
40
+
41
+ filters = {}
42
+ if registrant_name:
43
+ filters["registrant_name"] = str(registrant_name).strip()
44
+ if client_name:
45
+ filters["client_name"] = str(client_name).strip()
46
+ if filing_year is not None:
47
+ y = int(filing_year)
48
+ if y < 1999 or y > date.today().year + 1:
49
+ raise SdkError("usage_error", provider="lda", detail=f"filing_year must be a year from 1999 on, got \"{filing_year}\"")
50
+ filters["filing_year"] = y
51
+ if not filters:
52
+ raise SdkError("usage_error", provider="lda",
53
+ detail="search_filings requires registrant_name / client_name / filing_year — at least one of the three")
54
+ size = min(max(1, int(limit or 20)), 100) # the verb face keeps the inherited behaviour (upstream hard-clamps to 25, see the module header — overturned 2026-10-26)
55
+ r = call_provider(provider, "filings", {
56
+ **filters,
57
+ "page_size": size,
58
+ "ordering": "-dt_posted",
59
+ }, cache=cache, no_cache=no_cache)
60
+ return {**_rows_from(r["data"]), "total": (r["data"] or {}).get("count"),
61
+ "limit_applied": size, "_meta": r}
62
+
63
+
64
+ def _rows_from(data):
65
+ """Upstream response → {"rows": [...]} (pure function, offline-testable; shared by both faces)."""
66
+ return {"rows": [{
67
+ "filing_uuid": f.get("filing_uuid"),
68
+ "filing_type": f.get("filing_type"), # RR registration / Q1-Q4 quarterly report (amounts only on quarterly reports)
69
+ "filing_type_display": f.get("filing_type_display"), # upstream human-readable type (reconciled and added in the 2026-10-21 audit)
70
+ "filing_year": f.get("filing_year"),
71
+ "period": f.get("filing_period_display") or f.get("filing_period"),
72
+ "dt_posted": f.get("dt_posted"),
73
+ "income": f.get("income"), # lobbying income (quarterly reports)
74
+ "expenses": f.get("expenses"), # lobbying expenses (quarterly reports)
75
+ "termination_date": f.get("termination_date"), # termination filing date (Q Termination reports)
76
+ "registrant": ({"house_registrant_id": f["registrant"].get("house_registrant_id"),
77
+ "name": f["registrant"].get("name"), "state": f["registrant"].get("state")}
78
+ if f.get("registrant") else None),
79
+ "client": ({"client_id": f["client"].get("client_id"), "name": f["client"].get("name"),
80
+ "state": f["client"].get("state"), "country": f["client"].get("country")}
81
+ if f.get("client") else None),
82
+ "issues": [a.get("general_issue_code_display") or a.get("general_issue_code")
83
+ for a in f.get("lobbying_activities") or []
84
+ if a.get("general_issue_code_display") or a.get("general_issue_code")],
85
+ "document_url": f.get("filing_document_url"), # direct link to the filing PDF/print view
86
+ } for f in (data or {}).get("results") or []]}
87
+
88
+
89
+ def search_raw(client_name=None, registrant_name=None, filing_year=None, *,
90
+ limit=20, page=None, ordering="-dt_posted", cache=None, no_cache=False):
91
+ """Flat-panel low-level face: client_name/registrant_name/filing_year/page_size/ordering/page all go through
92
+ with verbatim names.
93
+
94
+ → {total, rows[], limit_applied, params, _meta}. limit wall 1-25: verified 2026-10-26,
95
+ page_size 26/50/100/500 all silently return only 25 rows (upstream hard clamp); page through page (true offset paging).
96
+ client_name (the lobbied company) and registrant_name (the lobbying firm) can be combined (upstream ANDs them)."""
97
+ from ..transport import call_provider
98
+ from datetime import date
99
+
100
+ params = {}
101
+ if registrant_name:
102
+ params["registrant_name"] = str(registrant_name).strip()
103
+ if client_name:
104
+ params["client_name"] = str(client_name).strip()
105
+ if filing_year is not None:
106
+ y = int(filing_year)
107
+ if y < 1999 or y > date.today().year + 1:
108
+ raise SdkError("usage_error", provider="lda", detail=f"filing_year must be a year from 1999 on, got \"{filing_year}\"")
109
+ params["filing_year"] = y
110
+ if not params:
111
+ raise SdkError("usage_error", provider="lda",
112
+ detail="search_raw requires client_name / registrant_name / filing_year — at least one of the three"
113
+ " (local safety wall: enumerating the full 70,545+ row corpus belongs to the batch line)")
114
+ lim = int(limit or 20)
115
+ if not 1 <= lim <= 25:
116
+ raise SdkError("usage_error", provider="lda",
117
+ detail=f"limit must be 1-25 (upstream page_size hard clamp: verified 2026-10-26, "
118
+ f"26/50/100/500 silently return only 25 rows), page through page — got \"{limit}\"")
119
+ params.update({"page_size": lim, "ordering": str(ordering or "-dt_posted")})
120
+ if page is not None:
121
+ params["page"] = max(1, int(page))
122
+ r = call_provider(provider, "filings", params, cache=cache, no_cache=no_cache)
123
+ return {**_rows_from(r["data"]), "total": (r["data"] or {}).get("count"),
124
+ "limit_applied": lim, "params": params, "_meta": r}
125
+
126
+
127
+ def lookup_registrant(registrant_id, *, cache=None, no_cache=False):
128
+ """→ the lobbying firm's master record (house_registrant_id official key, normalized)."""
129
+ from ..transport import call_provider
130
+
131
+ try:
132
+ n = int(registrant_id)
133
+ except (TypeError, ValueError):
134
+ n = 0
135
+ if n <= 0:
136
+ raise SdkError("usage_error", provider="lda",
137
+ detail=f"registrant id must be a positive integer (house_registrant_id), got \"{registrant_id}\"")
138
+ r = call_provider(provider, "registrant", {"id": n}, cache=cache, no_cache=no_cache)
139
+ d = r["data"] or {}
140
+ address = ", ".join(x for x in (d.get("address_1"), d.get("city"), d.get("state"), d.get("country")) if x)
141
+ return {
142
+ "house_registrant_id": d.get("house_registrant_id") or n,
143
+ "name": d.get("name"),
144
+ "description": d.get("description"),
145
+ "address": address or None,
146
+ "_meta": r,
147
+ }
@@ -0,0 +1,178 @@
1
+ """sipri — SIPRI arms-transfer database provider (country-level arms-trade face; keyless anonymous /api/p).
2
+
3
+ Shape verified 2026-10-20: POST JSON body {filters:[…], logic:"AND", type:"delivered"|"orders", status:"0",
4
+ pageNo, pageSize, sorts:{}}; **country filters need a numeric EntityId in listData** (putting a country name in
5
+ value1 → 500 NumberFormatException); the EntityId table = GET /countries/getAllCountriesTrimmed (385 countries,
6
+ quasi-static); the response is a **bare array** (no wrapper key); sorts must be a map ({}), not an array; pageSize is
7
+ capped at 100; a wrong prefix /api/v1 = 401.
8
+ Note: trades is a country-level face (buyer/seller = country names), not a company-level one — the company-level
9
+ universe lives in the Top-100 XLSX (batch line).
10
+ TTL: trades 30 days (the historical database changes slowly) / countries 30 days (quasi-static table); polite rate 0.5 rps.
11
+ """
12
+ from ..errors import SdkError
13
+
14
+ provider = {
15
+ "id": "sipri",
16
+ "baseUrl": "https://atbackend.sipri.org/api/p",
17
+ "rate": {"rps": 0.5, "burst": 2},
18
+ "ops": {
19
+ "trades": {"path": "/trades/search", "method": "POST", "idempotent": True}, # params: the whole body (filters/logic/type/status/pageNo/pageSize/sorts)
20
+ "countries": {"path": "/countries/getAllCountriesTrimmed"}, # params: none (the full 385-country EntityId table)
21
+ },
22
+ "ttl": {"trades": 30 * 86400, "countries": 30 * 86400},
23
+ }
24
+
25
+
26
+ def _mk_filter(field, entity_id):
27
+ return {"field": field, "oldField": "", "condition": "contains", "value1": "", "value2": "",
28
+ "logic": "AND", "listData": [entity_id]}
29
+
30
+
31
+ def trades_body(supplier_id=None, recipient_id=None, *, type="delivered", page=1, limit=20):
32
+ """→ the POST body (pure function so the body is offline-testable); a country name in value1 → upstream 500,
33
+ it needs a numeric EntityId in listData."""
34
+ if type not in ("delivered", "orders"):
35
+ raise SdkError("usage_error", provider="sipri", detail=f"type must be delivered|orders, got \"{type}\"")
36
+ filters = []
37
+ if supplier_id:
38
+ filters.append(_mk_filter("Supplier", supplier_id))
39
+ if recipient_id:
40
+ filters.append(_mk_filter("Recipient", recipient_id))
41
+ if not filters:
42
+ raise SdkError("usage_error", provider="sipri",
43
+ detail="search_trades requires supplier / recipient — at least one of the two")
44
+ return {
45
+ "filters": filters, "logic": "AND", "type": type, "status": "0",
46
+ "pageNo": max(1, int(page or 1)), "pageSize": min(max(1, int(limit or 20)), 100), "sorts": {},
47
+ }
48
+
49
+
50
+ def _resolve_country(name, cache=None, no_cache=False):
51
+ """Country name → EntityId (exact match, case-insensitive; the table is cached for 30 days).
52
+ On a miss it offers near matches."""
53
+ from ..transport import call_provider
54
+
55
+ r = call_provider(provider, "countries", {}, cache=cache, no_cache=no_cache)
56
+ lst = r["data"] if isinstance(r["data"], list) else []
57
+ low = str(name).lower()
58
+ hit = next((c for c in lst if str(c.get("Name") or "").lower() == low), None)
59
+ if not hit:
60
+ near = [c.get("Name") for c in lst if low in str(c.get("Name") or "").lower()][:5]
61
+ hint = f" (near: {' / '.join(near)})" if near else " (use the full English name, e.g. United States / Angola)"
62
+ raise SdkError("usage_error", provider="sipri", detail=f"country name is not in the SIPRI table: \"{name}\"{hint}")
63
+ return {"entity_id": hit["EntityId"], "name": hit["Name"], "code": hit.get("CountryCode"), "_meta": r}
64
+
65
+
66
+ def search_trades(supplier=None, recipient=None, *, type=None, page=1, limit=20,
67
+ cache=None, no_cache=False):
68
+ """→ {rows[], _meta, _metas}; arms-transfer rows (country × model × quantity).
69
+ Each country-name → EntityId resolution costs one cached call (30-day table); the trades call passes through,
70
+ and metas keeps the from_cache flags accurate for the whole chain."""
71
+ from ..transport import call_provider
72
+
73
+ if not supplier and not recipient:
74
+ raise SdkError("usage_error", provider="sipri",
75
+ detail="search_trades requires supplier / recipient — at least one of the two")
76
+ size = min(max(1, int(limit or 20)), 100) # upstream pageSize caps at 100; truncation must stay visible (limit_applied)
77
+ metas = []
78
+ supplier_id = recipient_id = None
79
+ if supplier:
80
+ s = _resolve_country(supplier, cache, no_cache)
81
+ supplier_id, _ = s["entity_id"], metas.append(s["_meta"])
82
+ if recipient:
83
+ s = _resolve_country(recipient, cache, no_cache)
84
+ recipient_id, _ = s["entity_id"], metas.append(s["_meta"])
85
+ r = call_provider(provider, "trades",
86
+ trades_body(supplier_id, recipient_id, type=type or "delivered",
87
+ page=page, limit=limit),
88
+ cache=cache, no_cache=no_cache)
89
+ data = r["data"]
90
+ trades = data if isinstance(data, list) else (data or {}).get("trades") or []
91
+ rows = [{
92
+ "trade_id": t.get("tradeId") or t.get("id"),
93
+ "supplier": t.get("seller"), # exporting country (country name)
94
+ "recipient": t.get("buyer"), # importing country (country name)
95
+ "order_year": t.get("orderYr"),
96
+ "order_year_est": t.get("orderYrEst"), # estimated flag (data-quality flag)
97
+ "delivery_year": t.get("deliveryYr"),
98
+ "units": t.get("units"),
99
+ "units_est": t.get("unitsEst"),
100
+ "designation": t.get("desg"), # model (An-26 etc.)
101
+ "description": t.get("desc"), # equipment description (transport aircraft)
102
+ "category": t.get("category"), # major category (Aircraft etc.)
103
+ "sub_category": t.get("subCategory"),
104
+ } for t in trades]
105
+ return {"rows": rows, "limit_applied": size, "_meta": r, "_metas": metas + [r]}
106
+
107
+
108
+ def search_raw(filters=None, *, supplier=None, recipient=None, type="delivered",
109
+ page=1, limit=20, cache=None, no_cache=False):
110
+ """Flat-panel low-level face: filters (the raw POST body, verbatim-name channel: [{field, listData:[EntityId], …}])
111
+ XOR supplier/recipient (sugar: country name → EntityId resolved against the 30-day cached table first).
112
+
113
+ → {rows[], limit_applied, resolved_countries, params, _meta, _metas} — params is the exact POST body sent.
114
+ A country name in value1 → upstream 500 NumberFormatException (verified 2026-10-20): the sugar channel
115
+ guarantees that what lands in listData is a numeric EntityId; pageSize wall 1-100 (upstream cap,
116
+ verified 2026-10-20)."""
117
+ from ..transport import call_provider
118
+
119
+ if filters is not None and (str(supplier or "").strip() or str(recipient or "").strip()):
120
+ raise SdkError("usage_error", provider="sipri",
121
+ detail="filters (verbatim-name channel: numeric EntityId in listData) and supplier/recipient "
122
+ "(sugar: country names resolved to EntityId first) are mutually exclusive — give only one")
123
+ lim = int(limit or 20)
124
+ if not 1 <= lim <= 100:
125
+ raise SdkError("usage_error", provider="sipri",
126
+ detail=f"limit must be 1-100 (upstream pageSize caps at 100, verified 2026-10-20), got \"{limit}\"")
127
+ if type not in ("delivered", "orders"):
128
+ raise SdkError("usage_error", provider="sipri", detail=f"type must be delivered|orders, got \"{type}\"")
129
+ metas, resolved = [], {}
130
+ if filters is not None:
131
+ if not isinstance(filters, list) or not filters:
132
+ raise SdkError("usage_error", provider="sipri",
133
+ detail="filters must be a non-empty list ([{field:'Supplier'|'Recipient', listData:[EntityId], …}])")
134
+ for f in filters:
135
+ if not isinstance(f, dict) or f.get("field") not in ("Supplier", "Recipient") \
136
+ or not (isinstance(f.get("listData"), list) and f["listData"]):
137
+ raise SdkError("usage_error", provider="sipri",
138
+ detail="filters elements look like {\"field\": \"Supplier\", \"listData\": [<numeric EntityId>]} — "
139
+ "a country name in value1 causes a 500 (verified 2026-10-20)")
140
+ for eid in f["listData"]:
141
+ if not str(eid).isdigit():
142
+ raise SdkError("usage_error", provider="sipri",
143
+ detail=f"listData must hold numeric EntityIds (resolve country names first via the supplier/recipient sugar), got \"{eid}\"")
144
+ body = {"filters": filters, "logic": "AND", "type": type, "status": "0",
145
+ "pageNo": max(1, int(page or 1)), "pageSize": lim, "sorts": {}}
146
+ else:
147
+ if not supplier and not recipient:
148
+ raise SdkError("usage_error", provider="sipri",
149
+ detail="search_raw requires supplier / recipient (country names) or filters (the raw EntityId table) — at least one of the two")
150
+ sid = rid = None
151
+ if supplier:
152
+ s = _resolve_country(supplier, cache, no_cache)
153
+ sid, resolved["supplier"] = s["entity_id"], {"entity_id": s["entity_id"], "name": s["name"]}
154
+ metas.append(s["_meta"])
155
+ if recipient:
156
+ s = _resolve_country(recipient, cache, no_cache)
157
+ rid, resolved["recipient"] = s["entity_id"], {"entity_id": s["entity_id"], "name": s["name"]}
158
+ metas.append(s["_meta"])
159
+ body = trades_body(sid, rid, type=type, page=page, limit=lim)
160
+ r = call_provider(provider, "trades", body, cache=cache, no_cache=no_cache)
161
+ data = r["data"]
162
+ trades = data if isinstance(data, list) else (data or {}).get("trades") or []
163
+ rows = [{
164
+ "trade_id": t.get("tradeId") or t.get("id"),
165
+ "supplier": t.get("seller"), # exporting country (country name)
166
+ "recipient": t.get("buyer"), # importing country (country name)
167
+ "order_year": t.get("orderYr"),
168
+ "order_year_est": t.get("orderYrEst"), # estimated flag (data-quality flag)
169
+ "delivery_year": t.get("deliveryYr"),
170
+ "units": t.get("units"),
171
+ "units_est": t.get("unitsEst"),
172
+ "designation": t.get("desg"), # model (An-26 etc.)
173
+ "description": t.get("desc"), # equipment description (transport aircraft)
174
+ "category": t.get("category"), # major category (Aircraft etc.)
175
+ "sub_category": t.get("subCategory"),
176
+ } for t in trades]
177
+ return {"rows": rows, "limit_applied": lim, "resolved_countries": resolved or None,
178
+ "params": body, "_meta": r, "_metas": metas + [r]}
@@ -0,0 +1,148 @@
1
+ """ukec — UK Electoral Commission political funding provider (donations + loans two faces; keyless).
2
+
3
+ search.electoralcommission.org.uk/api/search/. Shape verified 2026-10-20: donations carry the full param set
4
+ sort/order/et/date/rptPd/prePoll + from/to; loans need the triplicate params loanStatus×2 + register×3 +
5
+ isIrishSource×2 — missing params → Total=-1 (fake success); the response is a .NET-family shape —
6
+ rows live in **Result (singular)**, the amount key is Value, dates are `/Date(epoch)/`.
7
+ Channel note (real-machine Python test 2026-10-20): Node undici hits a connection-level timeout and needs the
8
+ curl channel; **Python urllib connects directly with 200** (machine-local probe: donations 200), so the default
9
+ urllib path is used — gating is client-stack dependent, verify each stack on the real machine.
10
+ urllib cannot assemble repeated query keys → the full param set is baked into op.path (the dynamic keys
11
+ query/start/rows/from/to are appended by core).
12
+ rows is hard-clamped to 50: verified 2026-10-26, rows=51→50 and 200→50 (an earlier note saying "100 = polite cap"
13
+ never actually got 50+ rows — the old verb face's limit_applied=100 only ever got 50 upstream).
14
+ TTL 24h (quarterly cadence, rows change slowly); polite rate 0.5 rps.
15
+ """
16
+ import re
17
+ from datetime import date
18
+
19
+ from ..errors import SdkError
20
+
21
+ provider = {
22
+ "id": "ukec",
23
+ "baseUrl": "https://search.electoralcommission.org.uk/api/search",
24
+ "rate": {"rps": 0.5, "burst": 2},
25
+ "ops": {
26
+ "donations": {"path": "/donations?sort=AcceptedDate&order=desc&et=pp&date=Accepted&rptPd=&prePoll=false"},
27
+ "loans": {"path": "/loans?sort=StartDate&order=desc&loanStatus=outstanding&loanStatus=ended&register=gb&register=ni&register=none&isIrishSourceYes=true&isIrishSourceNo=true"},
28
+ },
29
+ "ttl": {"donations": 24 * 3600, "loans": 24 * 3600},
30
+ }
31
+
32
+ REGISTER_START = "2001-02-16" # PPERA 2000 in force (register origin) — the default for the full-history window
33
+
34
+
35
+ def date_window(_from=None, to=None):
36
+ """Complete the ends into a full range (guards against the -1 fake success caused by donations' from/to
37
+ defaults; the edgar-fts end-completion has the same semantics)."""
38
+ f = str(_from or REGISTER_START)
39
+ t = str(to or date.today().isoformat())
40
+ return {"params": {"from": f, "to": t}, "date_range": {"from": f, "to": t}}
41
+
42
+
43
+ def _dotnet_date(v):
44
+ """/Date(1762214400000)/ → ISO date; non-.NET date values pass through unchanged."""
45
+ m = re.search(r"/Date\((\d+)\)/", str(v or ""))
46
+ if m:
47
+ return date.fromtimestamp(int(m.group(1)) // 1000).isoformat()
48
+ return v
49
+
50
+
51
+ def _pick(row, *keys):
52
+ for k in keys:
53
+ if row.get(k) is not None:
54
+ return row[k]
55
+ return None
56
+
57
+
58
+ def _to_rows(data):
59
+ """Row normalization: .NET PascalCase keys (Result, singular, array); the donations and loans faces share
60
+ the same shape (same Value key).
61
+ Loan rows have no LenderName key — the counterparty is in LoanParticipantName (probed in the 2026-10-21 audit,
62
+ fixing the bug where the lender name on the loans face was always null); the loan type key is LoanType,
63
+ not TypeDescription."""
64
+ return [{
65
+ "donor_or_lender": _pick(d, "DonorName", "LenderName", "LoanParticipantName"),
66
+ "recipient": _pick(d, "RegulatedEntityName", "RecipientName"),
67
+ "recipient_registration": _pick(d, "CompanyRegistrationNumber"),
68
+ "value_gbp": _pick(d, "Value", "AcceptedValue"),
69
+ "date": _dotnet_date(_pick(d, "AcceptedDate", "StartDate")),
70
+ "type_description": _pick(d, "DonationType", "TypeDescription", "LoanType"),
71
+ "donation_action": _pick(d, "DonationAction"),
72
+ "ec_ref": _pick(d, "ECRef"),
73
+ } for d in ((data or {}).get("Result") or (data or {}).get("Results") or [])[:100]]
74
+
75
+
76
+ def search_donations(query, *, _from=None, to=None, limit=20, cache=None, no_cache=False):
77
+ """→ {total, rows[], date_range, _meta}; query = donor/regulated-entity name, contains-match."""
78
+ from ..transport import call_provider
79
+
80
+ q = str(query or "").strip()
81
+ if not q:
82
+ raise SdkError("usage_error", provider="ukec", detail="donations requires query (donor / regulated-entity name)")
83
+ win = date_window(_from, to)
84
+ size = min(max(1, int(limit or 20)), 100) # the verb face keeps the inherited behaviour (upstream hard-clamps to 50, see the module header — overturned 2026-10-26)
85
+ r = call_provider(provider, "donations", {
86
+ "query": q, "start": 0, "rows": size, **win["params"],
87
+ }, cache=cache, no_cache=no_cache)
88
+ return {"total": (r["data"] or {}).get("Total"), "rows": _to_rows(r["data"]),
89
+ "limit_applied": size, "date_range": win["date_range"], "_meta": r}
90
+
91
+
92
+ def search_loans(query, *, limit=20, cache=None, no_cache=False):
93
+ """→ {total, rows[], _meta}; the loans face (the regulatory params are baked into the path)."""
94
+ from ..transport import call_provider
95
+
96
+ q = str(query or "").strip()
97
+ if not q:
98
+ raise SdkError("usage_error", provider="ukec", detail="loans requires query (lender / borrower name)")
99
+ size = min(max(1, int(limit or 20)), 100) # the verb face keeps the inherited behaviour (upstream hard-clamps to 50, see the module header — overturned 2026-10-26)
100
+ r = call_provider(provider, "loans", {
101
+ "query": q, "start": 0, "rows": size,
102
+ }, cache=cache, no_cache=no_cache)
103
+ return {"total": (r["data"] or {}).get("Total"), "rows": _to_rows(r["data"]), "limit_applied": size, "_meta": r}
104
+
105
+
106
+ def _wall_rows_limit(limit):
107
+ lim = int(limit or 20)
108
+ if not 1 <= lim <= 50:
109
+ raise SdkError("usage_error", provider="ukec",
110
+ detail=f"limit must be 1-50 (upstream rows hard clamp: verified 2026-10-26, 51→50 and 200→50), got \"{limit}\"")
111
+ return lim
112
+
113
+
114
+ def donations_raw(query, *, _from=None, to=None, limit=20, start=0, cache=None, no_cache=False):
115
+ """Flat-panel low-level face: query/from/to/start/rows go through with upstream verbatim names (donations face).
116
+
117
+ → {total, rows[], limit_applied, date_range, params, _meta}. When the window is missing we complete the ends
118
+ (a missing from/to → upstream Total=-1 fake success — we fill from the 2001-02-16 register origin to today);
119
+ limit wall 1-50 (upstream rows hard clamp of 50, verified 2026-10-26)."""
120
+ from ..transport import call_provider
121
+
122
+ q = str(query or "").strip()
123
+ if not q:
124
+ raise SdkError("usage_error", provider="ukec", detail="donations requires query (donor / regulated-entity name)")
125
+ lim = _wall_rows_limit(limit)
126
+ win = date_window(_from, to)
127
+ params = {"query": q, "start": max(0, int(start or 0)), "rows": lim, **win["params"]}
128
+ r = call_provider(provider, "donations", params, cache=cache, no_cache=no_cache)
129
+ return {"total": (r["data"] or {}).get("Total"), "rows": _to_rows(r["data"]),
130
+ "limit_applied": lim, "date_range": win["date_range"], "params": params, "_meta": r}
131
+
132
+
133
+ def loans_raw(query, *, limit=20, start=0, cache=None, no_cache=False):
134
+ """Flat-panel low-level face: query/start/rows go through with upstream verbatim names (loans face; the
135
+ regulatory triplicate is baked into op.path).
136
+
137
+ → {total, rows[], limit_applied, params, _meta}; the limit wall is the same as for donations
138
+ (upstream hard clamp 50)."""
139
+ from ..transport import call_provider
140
+
141
+ q = str(query or "").strip()
142
+ if not q:
143
+ raise SdkError("usage_error", provider="ukec", detail="loans requires query (lender / borrower name)")
144
+ lim = _wall_rows_limit(limit)
145
+ params = {"query": q, "start": max(0, int(start or 0)), "rows": lim}
146
+ r = call_provider(provider, "loans", params, cache=cache, no_cache=no_cache)
147
+ return {"total": (r["data"] or {}).get("Total"), "rows": _to_rows(r["data"]),
148
+ "limit_applied": lim, "params": params, "_meta": r}
agentdata/legal/wb.py ADDED
@@ -0,0 +1,92 @@
1
+ """wb — World Bank debarred-firms roster provider (apigwext SAP gateway; keyless via the page-embedded public apikey).
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 /dvsvc/v1.0/json/APPLICATION/ADOBE_EXPRNCE_MGR/FIRM/SANCTIONED_FIRM + header apikey
5
+ → {"response": {"ZPROCSUPP": [1,546 rows × 45 SAP fields]}} — a single-shot full dump, no paging, 1.77MB
6
+ (the page self-reports a 3-hour refresh; the row count drifts as the live register is regenerated).
7
+ Golden fields: SUPP_ID (the WB supplier key) × SUPP_NAME × SUPP_TYPE_CODE (F firm 1,268 / I individual 269 /
8
+ U 8 / C 1) × LAND1+COUNTRY_NAME × address quartet × SUPP_ELIG_STAT (DEBARRED 890 / X-DEBARRED 656) ×
9
+ DEBAR_FROM_DATE~DEBAR_TO_DATE (**2999-12-31 = indefinite**, measured) × DEBAR_REASON (procurement-guidelines
10
+ clause citation, "Procurement Guidelines, 1.14(a)(ii)") × INELIGIBLY_STATUS ("Ongoing"). China leads the
11
+ roster at 392 firms (LAND1=CN, measured 2026-11-02); DNB/REGISTER identifier fields exist in the schema but
12
+ measure empty — name walk is the join channel (name × country narrows well).
13
+ The gateway apikey is a **browser-public constant embedded in the debarred-firms page inline script** (every
14
+ browser visitor sends the same key; not a registration credential — the same class as dgcomp's CS_PROD_ODSE_PROD
15
+ public config key); pinned live 2026-11-02: plain agentdata UA + this key = 200, no UA override needed.
16
+ Screening = **local full-register scan** (upstream has no query surface) — pull the whole dump once, cache 24h,
17
+ then contains-match scans run with zero network (same contract as csl_screening_list).
18
+ TTL: 24h (upstream refreshes every 3h; a day-old screening register is fine); polite tier 0.2 rps
19
+ (single-endpoint face, one pull per day suffices).
20
+ """
21
+ from ..errors import SdkError
22
+
23
+ provider = {
24
+ "id": "wb",
25
+ "baseUrl": "https://apigwext.worldbank.org",
26
+ "rate": {"rps": 0.2, "burst": 1},
27
+ "ops": {
28
+ "sanctioned_firms": { # single-shot full dump (no paging, no query surface)
29
+ "path": "/dvsvc/v1.0/json/APPLICATION/ADOBE_EXPRNCE_MGR/FIRM/SANCTIONED_FIRM",
30
+ "timeoutMs": 120000, # 1.77MB JSON
31
+ },
32
+ },
33
+ "ttl": {"sanctioned_firms": 24 * 3600},
34
+ }
35
+
36
+ # browser-public apikey embedded in the debarred-firms page inline script (pinned 2026-11-02;
37
+ # the same constant every browser visitor sends — not a .env credential, see module docstring)
38
+ _PUBLIC_APIKEY = {"apikey": "z9duUaFUiEUYSHs97CU38fcZO7ipOPvm"}
39
+
40
+ # row narrowing: of the 45 SAP fields, the twelve golden ones (identity × jurisdiction × window × reason)
41
+ _FIELDS = ("SUPP_ID", "SUPP_NAME", "SUPP_TYPE_CODE", "LAND1", "COUNTRY_NAME", "SUPP_CITY", "SUPP_ADDR",
42
+ "SUPP_ELIG_STAT", "DEBAR_FROM_DATE", "DEBAR_TO_DATE", "DEBAR_REASON", "INELIGIBLY_STATUS")
43
+
44
+
45
+ def rows_from_upstream(data):
46
+ """Upstream ZPROCSUPP array → debarred-firm rows (pure function, offline-testable; SAP field names verbatim,
47
+ narrowed to the golden twelve — the DNB/REGISTER identifier fields measure empty and are dropped)."""
48
+ payload = (data or {}).get("response") or {}
49
+ rows = [{k: r.get(k) for k in _FIELDS} for r in (payload.get("ZPROCSUPP") or [])]
50
+ return rows
51
+
52
+
53
+ def filter_rows(rows, name=None, country=None, type=None):
54
+ """Local register scan (pure function for offline regression). name: contains-match (case-insensitive)
55
+ over SUPP_NAME; country: contains over COUNTRY_NAME **or** the LAND1 code ("China" and "CN" both hit);
56
+ type: exact over SUPP_TYPE_CODE (F/I/U/C, case-insensitive)."""
57
+ needle = str(name or "").strip().lower() or None
58
+ cn_needle = str(country or "").strip().lower() or None
59
+ ty_needle = str(type or "").strip().upper() or None
60
+ hits = []
61
+ for r in rows:
62
+ if cn_needle and cn_needle not in str(r.get("COUNTRY_NAME") or "").lower() \
63
+ and cn_needle != str(r.get("LAND1") or "").lower():
64
+ continue
65
+ if ty_needle and str(r.get("SUPP_TYPE_CODE") or "").strip().upper() != ty_needle:
66
+ continue
67
+ if needle and needle not in str(r.get("SUPP_NAME") or "").lower():
68
+ continue
69
+ hits.append(r)
70
+ return hits
71
+
72
+
73
+ def debarred_firms_raw(name=None, *, country=None, type=None, limit=20, cache=None, no_cache=False):
74
+ """Full-register scan (pulls the whole dump, caches 24h, then scans locally)
75
+ → {rows, total_matched, scanned_rows, limit_applied, params, _meta}.
76
+
77
+ name = contains-match over SUPP_NAME; country = contains over country name or the LAND1 code;
78
+ type = F/I/U/C exact. No filter → enumerate from the register head (the local payload wall keeps
79
+ truncation visible: total_matched echoes the exact hit count)."""
80
+ from ..transport import call_provider
81
+
82
+ size = int(limit) if limit is not None else 20 # explicit None check — an `or default` swallows 0, the 0.2.10 trap
83
+ if not 1 <= size <= 100:
84
+ raise SdkError("usage_error", provider="wb",
85
+ detail=f"limit must be 1-100 (local payload wall), got '{limit}'")
86
+ r = call_provider(provider, "sanctioned_firms", {}, cache=cache, no_cache=no_cache,
87
+ extra_headers=_PUBLIC_APIKEY)
88
+ rows = rows_from_upstream(r["data"])
89
+ hits = filter_rows(rows, name=name, country=country, type=type)
90
+ return {"rows": hits[:size], "total_matched": len(hits), "scanned_rows": len(rows),
91
+ "limit_applied": size,
92
+ "params": {"name": name, "country": country, "type": type}, "_meta": r}
@@ -0,0 +1,5 @@
1
+ """procurement domain package — flat provider adapters (one file per source) + faces/cli (0.3.0, seventh package).
2
+
3
+ PNCP/SECOP passed the admission gate, ruled 2026-10-29.
4
+ """
5
+ from . import br_pncp, co_secop # noqa: F401