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,107 @@
1
+ """fdic_bankfind — FDIC BankFind Suite (US insured-bank institution master lookup; keyless).
2
+
3
+ Keyless at roughly ~1,000 req/hr/IP — we set 0.25 rps/burst 5, same tier as usaspending.
4
+ Live-shape check: **the host has moved to api.fdic.gov** (banks.data.fdic.gov 301) — new home /banks/institutions;
5
+ response data[].data.{NAME,CERT,CITY,STALP,ACTIVE,ID} (double-layered data).
6
+ Field-value findings confirmed by probing (2026-10-21): null fields are omitted upstream (absence = null, not
7
+ distinguishable); operating rows carry the ENDEFYMD='12/31/9999' sentinel (a flat export stamps every live row with a
8
+ 9999 end date) → null; the end-date sense covers closure/de-insurance/M&A exits (SVB 24735=03/10/2023).
9
+ Financials are not here (the fdic-edos corpus line already covers them); this face only answers "which bank is this,
10
+ is it still alive" against the master current values.
11
+ """
12
+ import re
13
+
14
+ provider = {
15
+ "id": "fdic-bankfind",
16
+ "baseUrl": "https://api.fdic.gov",
17
+ "rate": {"rps": 0.25, "burst": 5},
18
+ "ops": {
19
+ "institutions": {"path": "/banks/institutions"}, # params: search / filters / fields / limit (assembled query)
20
+ },
21
+ "ttl": {"institutions": 30 * 86400}, # registry master changes slowly
22
+ }
23
+
24
+ FIELDS = "NAME,CERT,CITY,STALP,ACTIVE,REPDTE,ESTYMD,ENDEFYMD"
25
+
26
+ MDY_RE = re.compile(r"^\d{2}/\d{2}/\d{4}$")
27
+ # active sentinel: a flat export stamps every live row with a 9999 end date
28
+ ACTIVE_SENTINEL = "12/31/9999"
29
+
30
+
31
+ def mdy_to_iso(v):
32
+ """'MM/DD/YYYY'→'YYYY-MM-DD' (MDY strings are easy to misread across regions; other shapes are returned unchanged)."""
33
+ if isinstance(v, str):
34
+ s = v.strip()
35
+ if MDY_RE.match(s):
36
+ return f"{s[6:10]}-{s[0:2]}-{s[3:5]}"
37
+ return v if v is not None else None
38
+
39
+
40
+ def institution_from_upstream(d):
41
+ """Institution card pure mapping (split out for offline testing; upstream omits null fields — absence = null, not
42
+ distinguishable; the ENDEFYMD sentinel '12/31/9999' = operating → null, only a real end date lands a value)."""
43
+ end = d.get("ENDEFYMD") if d.get("ENDEFYMD") and d.get("ENDEFYMD") != ACTIVE_SENTINEL else None
44
+ active = d.get("ACTIVE")
45
+ return {
46
+ "cert": d.get("CERT"),
47
+ "name": d.get("NAME"),
48
+ "city": d.get("CITY"),
49
+ "state": d.get("STALP"),
50
+ "active": True if active == 1 else False if active == 0 else None,
51
+ "established_date": mdy_to_iso(d.get("ESTYMD")), # opening date
52
+ "end_date": mdy_to_iso(end), # end of the insurance relationship (null while operating)
53
+ "report_date": mdy_to_iso(d.get("REPDTE")), # master current-value reporting period
54
+ }
55
+
56
+
57
+ def find_bank(*, name=None, cert=None, limit=5, cache=None, no_cache=False):
58
+ """→ {total, institutions[{cert,name,city,state,active,established_date,end_date,report_date}], _meta}.
59
+ Name fuzzy (ES phrase form) or CERT exact — exactly one of the two; active semantics 1=operating, 0=closed."""
60
+ from ..transport import call_provider
61
+ from ..errors import SdkError
62
+
63
+ params = {"fields": FIELDS, "limit": min(max(1, int(limit) or 5), 50)}
64
+ if cert is not None and str(cert).strip() != "":
65
+ try:
66
+ params["filters"] = f"CERT:{int(str(cert).strip())}" # CERT exact
67
+ except ValueError:
68
+ raise SdkError("usage_error", provider="fdic-bankfind",
69
+ detail=f"CERT must be numeric: {cert}")
70
+ elif name:
71
+ q = str(name).strip()
72
+ if not q:
73
+ raise SdkError("usage_error", provider="fdic-bankfind", detail="findBank requires either --name or --cert")
74
+ params["search"] = f'NAME:"{q}"' # ES phrase-form name match
75
+ else:
76
+ raise SdkError("usage_error", provider="fdic-bankfind", detail="findBank requires either --name or --cert")
77
+ r = call_provider(provider, "institutions", params, cache=cache, no_cache=no_cache)
78
+ rows = [institution_from_upstream(e.get("data") or {})
79
+ for e in (r["data"] or {}).get("data") or []]
80
+ total = (r["data"] or {}).get("meta", {}).get("total")
81
+ return {"total": total if total is not None else len(rows), "institutions": rows, "_meta": r}
82
+
83
+
84
+ def institutions_raw(*, search=None, filters=None, fields=None, limit=5,
85
+ cache=None, no_cache=False):
86
+ """→ {total, institutions[], params, _meta}; search/filters pass through with upstream names (combinable — ES full text × structured filter).
87
+ limit has no upstream wall: measured 2026-10-23, 1001 really returns 1001 rows and 10000 really returns 10000 rows
88
+ (whole database 27,835); the old find_bank 50 cap is this repo's politeness tier, not an upstream limit. fields defaults to
89
+ the eight FIELDS columns and can be overridden with a comma-separated string.
90
+ Zero hits is a legal empty set (no raise — the find_bank CERT face carries its own semantics at the client layer)."""
91
+ from ..transport import call_provider
92
+ from ..errors import SdkError
93
+
94
+ lim = int(limit)
95
+ if lim < 1:
96
+ raise SdkError("usage_error", provider="fdic-bankfind", detail="limit must be ≥1")
97
+ params = {"fields": str(fields).strip() if fields else FIELDS, "limit": lim}
98
+ if search:
99
+ params["search"] = str(search)
100
+ if filters:
101
+ params["filters"] = str(filters)
102
+ r = call_provider(provider, "institutions", params, cache=cache, no_cache=no_cache)
103
+ rows = [institution_from_upstream(e.get("data") or {})
104
+ for e in (r["data"] or {}).get("data") or []]
105
+ total = (r["data"] or {}).get("meta", {}).get("total")
106
+ return {"total": total if total is not None else len(rows), "institutions": rows,
107
+ "params": params, "_meta": r}
@@ -0,0 +1,89 @@
1
+ """fi_ytj — Finnish PRH YTJ business information system v3 (avoindata open API front door; keyless).
2
+
3
+ 2026-10-29 probed shape: GET /companies?name=|businessId= (one endpoint, two lookup shapes);
4
+ name is a contains-match that also hits historical names and secondary names (nokia hits the branch name Fysios Nokia);
5
+ **pagination params are ignored upstream — resultsFrom/maxResults silently have no effect (p63/p200 measured
6
+ byte-identical), page size is always ~63 rows, and totalResults is the real total** — so large name families cannot be
7
+ fetched in full; narrow with a more precise name or a businessId direct lookup.
8
+ Row shape: businessId is a nested object {value, registrationDate, source}; names[] = versioned name history
9
+ (type 1 main name / 3 branch name); mainBusinessLine/companyForms/companySituations carry fi/sv/en
10
+ descriptions (languageCode 1=fi 2=sv 3=en).
11
+ TTL: 7 days (registry current values); politeness tier 0.5 rps (no published quota).
12
+ """
13
+ from ..errors import SdkError
14
+
15
+ provider = {
16
+ "id": "fi_ytj",
17
+ "baseUrl": "https://avoindata.prh.fi/opendata-ytj-api/v3",
18
+ "rate": {"rps": 0.5, "burst": 2},
19
+ "ops": {
20
+ "companies": {"path": "/companies"}, # params: name | businessId (query; pagination params are not upstream, not exposed)
21
+ },
22
+ "ttl": {"companies": 7 * 86400},
23
+ }
24
+
25
+
26
+ def _desc(trilingual):
27
+ """[{languageCode, description}] → joined en/fi string (languageCode 3=en 1=fi; sv dropped — projection to reduce noise)."""
28
+ by = {d.get("languageCode"): d.get("description") for d in trilingual or []}
29
+ parts = [by.get(c) for c in ("3", "1")]
30
+ return " / ".join(x for x in parts if x) or None
31
+
32
+
33
+ def search_companies(name=None, business_id=None, *, cache=None, no_cache=False):
34
+ """→ {total_results, companies[], params, _meta}; name search XOR businessId direct lookup (one endpoint, two lookup shapes).
35
+ name also matches historical/branch names by contains; **page size is always ~63 rows (pagination params are silently
36
+ ignored upstream, pinned by measurement), total_results is the real total — large families must be narrowed with a more
37
+ precise name or a businessId**."""
38
+ from ..transport import call_provider
39
+
40
+ nm = str(name or "").strip() or None
41
+ bid = str(business_id or "").replace(" ", "").strip() or None
42
+ if bool(nm) == bool(bid):
43
+ raise SdkError("usage_error", provider="fi_ytj",
44
+ detail="name (search face) and business_id (direct lookup face) are mutually exclusive — provide exactly one of the two required")
45
+ params = {"name": nm, "businessId": bid}
46
+ r = call_provider(provider, "companies", params, cache=cache, no_cache=no_cache)
47
+ d = r["data"] or {}
48
+ return {"total_results": d.get("totalResults"),
49
+ "companies": [_norm(c) for c in d.get("companies") or []],
50
+ "params": {k: v for k, v in params.items() if v}, "_meta": r}
51
+
52
+
53
+ def _norm(c):
54
+ """Row projection: businessId split out of its nested object; full name history kept; trilingual fields joined en/fi; address takes the first row."""
55
+ bid = c.get("businessId") or {}
56
+ addr0 = (c.get("addresses") or [{}])[0] or {}
57
+ city = None
58
+ for po in addr0.get("postOffices") or []:
59
+ if po.get("languageCode") in ("1", "3"): # fi or en city name
60
+ city = po.get("city")
61
+ if po.get("languageCode") == "3":
62
+ break
63
+ return {
64
+ "business_id": bid.get("value"), # 0194099-3 shape (value split from the nested object)
65
+ "business_id_registration_date": bid.get("registrationDate"),
66
+ "latest_name": ((c.get("names") or [{}])[0]).get("name"), # names[0] = most recent row (upstream order)
67
+ "names": [{"name": n.get("name"), "type": n.get("type"), # full history (1 main name / 3 branch name)
68
+ "registration_date": n.get("registrationDate"), "end_date": n.get("endDate"),
69
+ "version": n.get("version")} for n in c.get("names") or []],
70
+ "main_business_line": {"code": (c.get("mainBusinessLine") or {}).get("type"),
71
+ "description": _desc((c.get("mainBusinessLine") or {}).get("descriptions")),
72
+ "codeset": (c.get("mainBusinessLine") or {}).get("typeCodeSet")}, # TOIMI4
73
+ "company_forms": [{"type": f.get("type"), "description": _desc(f.get("descriptions")),
74
+ "start_date": f.get("startDate"), "end_date": f.get("endDate")}
75
+ for f in c.get("companyForms") or []],
76
+ "company_situations": [{"description": _desc(s.get("descriptions")),
77
+ "start_date": s.get("startDate"), "end_date": s.get("endDate")}
78
+ for s in c.get("companySituations") or []],
79
+ "status": c.get("status"), # upstream numeric code (2 = registered/live is the common value)
80
+ "trade_register_status": c.get("tradeRegisterStatus"),
81
+ "registration_date": c.get("registrationDate"),
82
+ "end_date": c.get("endDate"),
83
+ "last_modified": c.get("lastModified"),
84
+ "address": {"street": addr0.get("street"), "building_number": addr0.get("buildingNumber"),
85
+ "post_code": addr0.get("postCode"), "city": city},
86
+ "registered_entries": [{"type": e.get("type"), "description": _desc(e.get("descriptions")),
87
+ "registration_date": e.get("registrationDate"), "end_date": e.get("endDate")}
88
+ for e in c.get("registeredEntries") or []], # commercial register entry history
89
+ }
@@ -0,0 +1,124 @@
1
+ """fr_recherche — French annuaire-entreprises official aggregate search (recherche-entreprises.api.gouv.fr; etalab/DINUM, keyless).
2
+
3
+ 2026-10-29 probed shape: GET /search?q=&page=&per_page=&etat_administratif=
4
+ (the API behind the etalab annuaire-entreprises search engine — api.insee.fr needs a key, this face is the keyless
5
+ search front door).
6
+ q takes company name / officer name / SIREN alike (BERNARD DE SAINT AFFRIQUE → 37 companies with mandates;
7
+ q=552032534 → total 1 exact); **per_page has an explicit wall of 1-25** (default 10; beyond it the upstream answers with a
8
+ self-describing French erreur — the local precheck saves one request); pagination is closed-form
9
+ page+total_pages+total_results. Rows are a rich 30-key shape: dirigeants with two slots (physique = person name +
10
+ year-month-granularity birth date; morale = {siren,denomination} company×company director edges)
11
+ + finances{year:{ca,resultat_net}} + tva + siren_conserve (predecessor key) + complements (est_* flag family).
12
+ Rate limiting is real: burst-style 429s with plain-text bodies and intermittent connection resets — backing off gets
13
+ through, politeness tier 0.5 rps. TTL: 7 days (registry current values; date_mise_a_jour measured updating at T-1).
14
+ """
15
+ from ..errors import SdkError
16
+
17
+ provider = {
18
+ "id": "fr_recherche",
19
+ "baseUrl": "https://recherche-entreprises.api.gouv.fr",
20
+ "rate": {"rps": 0.5, "burst": 2},
21
+ "ops": {
22
+ "search": {"path": "/search"}, # params: q/page/per_page/etat_administratif (A active / C ceased)
23
+ },
24
+ "ttl": {"search": 7 * 86400},
25
+ }
26
+
27
+
28
+ def search(q, *, page=None, per_page=10, etat_administratif=None, cache=None, no_cache=False):
29
+ """→ {total_results, total_pages, page, per_page, results[], params, _meta}.
30
+ q takes company name / officer name / SIREN alike (officer name = cross-company mandate search, SIREN = exact direct
31
+ lookup); per_page upstream hard wall 1-25 (default 10, prechecked locally); etat_administratif A/C is a true filter.
32
+ Zero hits is a legal answer (total_results:0)."""
33
+ from ..transport import call_provider
34
+
35
+ q = str(q or "").strip()
36
+ if not q:
37
+ raise SdkError("usage_error", provider="fr_recherche",
38
+ detail="search requires q (company name / officer name / SIREN)")
39
+ pp = int(per_page if per_page is not None else 10)
40
+ if not 1 <= pp <= 25:
41
+ raise SdkError("usage_error", provider="fr_recherche",
42
+ detail=f"per_page upstream hard wall is 1-25 (got {pp}; default 10 — beyond the wall upstream answers with a French erreur)")
43
+ params = {"q": q, "per_page": pp}
44
+ if page is not None:
45
+ pg = int(page)
46
+ if pg < 1:
47
+ raise SdkError("usage_error", provider="fr_recherche",
48
+ detail=f"page is 1-based (got {pg})")
49
+ params["page"] = pg
50
+ if etat_administratif is not None:
51
+ ea = str(etat_administratif).strip().upper()
52
+ if ea not in ("A", "C"):
53
+ raise SdkError("usage_error", provider="fr_recherche",
54
+ detail=f"etat_administratif only accepts A (active) / C (ceased), got \"{etat_administratif}\"")
55
+ params["etat_administratif"] = ea
56
+ r = call_provider(provider, "search", params, cache=cache, no_cache=no_cache)
57
+ d = r["data"] or {}
58
+ return {"total_results": d.get("total_results"),
59
+ "total_pages": d.get("total_pages"),
60
+ "page": d.get("page"), "per_page": d.get("per_page"),
61
+ "results": [_norm(x) for x in d.get("results") or []],
62
+ "params": params, "_meta": r}
63
+
64
+
65
+ def _norm_dir(d):
66
+ """dirigeants two slots: physique = person name + year-month-granularity birth date (public registry sense, no national
67
+ id number); morale = siren+denomination (company×company director edge; the statutory auditor slot has the same shape)."""
68
+ return {"type_dirigeant": d.get("type_dirigeant"),
69
+ "nom": d.get("nom"), "prenoms": d.get("prenoms"),
70
+ "date_de_naissance": d.get("date_de_naissance"), # year-month granularity ("1964-12")
71
+ "annee_de_naissance": d.get("annee_de_naissance"),
72
+ "nationalite": d.get("nationalite"),
73
+ "siren": d.get("siren"), # morale slot only
74
+ "denomination": d.get("denomination"),
75
+ "qualite": d.get("qualite")}
76
+
77
+
78
+ def _norm_etab(e):
79
+ """etablissement card (shared by siege/matching_etablissements): keeps the master/geographic/activity core columns."""
80
+ e = e or {}
81
+ return {"siret": e.get("siret"), "est_siege": e.get("est_siege"),
82
+ "ancien_siege": e.get("ancien_siege"),
83
+ "etat_administratif": e.get("etat_administratif"),
84
+ "adresse": e.get("adresse"), "code_postal": e.get("code_postal"),
85
+ "libelle_commune": e.get("libelle_commune"), "departement": e.get("departement"),
86
+ "region": e.get("region"), "latitude": e.get("latitude"), "longitude": e.get("longitude"),
87
+ "nom_commercial": e.get("nom_commercial"), "liste_enseignes": e.get("liste_enseignes"),
88
+ "activite_principale": e.get("activite_principale"),
89
+ "date_creation": e.get("date_creation"),
90
+ "date_debut_activite": e.get("date_debut_activite"),
91
+ "date_fermeture": e.get("date_fermeture")}
92
+
93
+
94
+ def _norm(r):
95
+ """Row projection, 30 keys: SIREN primary key + dirigeants two slots + finances yearly panel + tva + siren_conserve predecessor key."""
96
+ fin = r.get("finances") or {}
97
+ return {
98
+ "siren": r.get("siren"),
99
+ "nom_complet": r.get("nom_complet"), "nom_raison_sociale": r.get("nom_raison_sociale"),
100
+ "sigle": r.get("sigle"),
101
+ "etat_administratif": r.get("etat_administratif"), # A active / C ceased
102
+ "date_creation": r.get("date_creation"), "date_fermeture": r.get("date_fermeture"),
103
+ "nature_juridique": r.get("nature_juridique"), # legal form code (5599=SA)
104
+ "categorie_entreprise": r.get("categorie_entreprise"), # PME/ETI/GE
105
+ "annee_categorie_entreprise": r.get("annee_categorie_entreprise"),
106
+ "tranche_effectif_salarie": r.get("tranche_effectif_salarie"),
107
+ "caractere_employeur": r.get("caractere_employeur"),
108
+ "activite_principale": r.get("activite_principale"), # NAF code
109
+ "activite_principale_naf25": r.get("activite_principale_naf25"),
110
+ "section_activite_principale": r.get("section_activite_principale"),
111
+ "tva": (r.get("tva") or [None])[0], # "FR…" (the monthly corpus file lacks this column)
112
+ "dirigeants": [_norm_dir(x) for x in r.get("dirigeants") or []],
113
+ "finances": {y: {"ca": v.get("ca"), "resultat_net": v.get("resultat_net")}
114
+ for y, v in fin.items()}, # {year:{ca,resultat_net}}
115
+ "siege": _norm_etab(r.get("siege")),
116
+ "siren_conserve": r.get("siren_conserve"), # predecessor SIREN (surviving key after a merger)
117
+ "nombre_etablissements": r.get("nombre_etablissements"),
118
+ "nombre_etablissements_ouverts": r.get("nombre_etablissements_ouverts"),
119
+ "matching_etablissements": [_norm_etab(x) for x in r.get("matching_etablissements") or []],
120
+ "complements": r.get("complements"), # est_* flag family + liste_finess etc. (passed through)
121
+ "statut_diffusion": r.get("statut_diffusion"), # O=public / P=partially protected (name and address redacted)
122
+ "date_mise_a_jour": r.get("date_mise_a_jour"),
123
+ "date_mise_a_jour_rne": r.get("date_mise_a_jour_rne"),
124
+ }
@@ -0,0 +1,121 @@
1
+ """fts — UK Find a Tender Service OCDS provider (UK public procurement; keyless).
2
+
3
+ GET /api/1.0/ocdsReleasePackages → OCDS 1.1 release package: releases[] (stable ocid key /
4
+ buyer.name / awards[].suppliers[{id=GB-FTS-…, name}] / tag / date) + links.next cursor pagination.
5
+ Closed parameter set (400 enumerates them): stages, limit, cursor, updatedFrom, updatedTo — no name-search
6
+ param exists: name filtering is a client-side contains match (same sense as the lda client_name filter),
7
+ pagination continues via cursor.
8
+ Date pitfall: updatedFrom/updatedTo require full ISO timestamps ('2026-10-22T00:00:00Z' — bare dates 400);
9
+ a single end may be omitted (the server auto-fills now) — norm_win() appends the T timestamp to bare dates.
10
+ Note: the old direct Contracts Finder face (www/api.contractsfinder.service.gov.uk) now 404s/000s — CF has
11
+ merged into the FTS platform, and the OCP mirror is the historical backfill line; this provider only serves the live face. TTL: 24h.
12
+ """
13
+ import re
14
+ from urllib.parse import urlparse, parse_qs
15
+
16
+ from ..errors import SdkError
17
+
18
+ provider = {
19
+ "id": "fts",
20
+ "baseUrl": "https://www.find-tender.service.gov.uk/api/1.0",
21
+ "rate": {"rps": 0.5, "burst": 2},
22
+ "ops": {
23
+ "releases": {"path": "/ocdsReleasePackages", "ttl": 24 * 3600, "timeoutMs": 30000},
24
+ },
25
+ "ttl": {"releases": 24 * 3600},
26
+ }
27
+
28
+ _BARE_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
29
+
30
+
31
+ def norm_win(v, from_=True):
32
+ """Bare date '2026-10-01' → '2026-10-01T00:00:00Z' (the to end gets 23:59:59); full timestamps pass through."""
33
+ s = str(v or "").strip()
34
+ if not s:
35
+ return None
36
+ if _BARE_DATE.match(s):
37
+ return f"{s}T{'00:00:00' if from_ else '23:59:59'}Z"
38
+ return s
39
+
40
+
41
+ def fts_from_upstream(data, name="", limit=10):
42
+ """Upstream payload → {rows, next_cursor, has_more} (pure function + client-side name filter, offline-testable).
43
+ Match face: buyer.name ∨ awards[].suppliers[].name (a hit on either the buyer or supplier side keeps the row);
44
+ has_more = upstream has a next page (decoupled from the client-side filter — a filtered page that isn't full ≠ no more
45
+ matches; continue with next_cursor)."""
46
+ q = str(name or "").lower().strip()
47
+ hit = (lambda s: not q or q in str(s or "").lower())
48
+
49
+ def keep(rel):
50
+ if hit((rel or {}).get("buyer", {}).get("name")):
51
+ return True
52
+ return any(hit(sp.get("name")) for aw in (rel or {}).get("awards") or []
53
+ for sp in (aw or {}).get("suppliers") or [])
54
+
55
+ rows = []
56
+ for rel in [x for x in (data or {}).get("releases") or [] if keep(x)][:limit]:
57
+ rows.append({
58
+ "ocid": rel.get("ocid"), # OCDS stable key (ocds-h6vhtk-…)
59
+ "date": rel.get("date"),
60
+ "tags": rel.get("tag") or [],
61
+ "buyer": (rel.get("buyer") or {}).get("name"),
62
+ "suppliers": sorted({sp.get("name") for aw in rel.get("awards") or []
63
+ for sp in aw.get("suppliers") or [] if sp.get("name")}),
64
+ "supplier_ids": sorted({sp.get("id") for aw in rel.get("awards") or []
65
+ for sp in aw.get("suppliers") or [] if sp.get("id")}), # GB-FTS-… registry key
66
+ "award_values": [{"date": aw.get("date"), "amount": (aw.get("value") or {}).get("amount"),
67
+ "currency": (aw.get("value") or {}).get("currency")}
68
+ for aw in rel.get("awards") or [] if (aw.get("value") or {}).get("amount") is not None],
69
+ "tender_title": (rel.get("tender") or {}).get("title"),
70
+ })
71
+ nxt = (data or {}).get("links", {}).get("next")
72
+ cursor = (parse_qs(urlparse(nxt).query).get("cursor") or [None])[0] if nxt else None
73
+ return {"rows": rows, "next_cursor": cursor, "has_more": bool(nxt)}
74
+
75
+
76
+ def company_uk_tenders(name, *, stages="award", limit=10, cursor=None,
77
+ updated_from=None, updated_to=None, cache=None, no_cache=False):
78
+ """→ {rows, next_cursor, has_more, matched_on, _meta}; UK procurement release rows (client-side contains match)."""
79
+ from ..transport import call_provider
80
+
81
+ q = str(name or "").strip()
82
+ if not q:
83
+ raise SdkError("usage_error", provider="fts",
84
+ detail="name is required (the OCDS face has no name-search param — client-side contains match)")
85
+ lim = min(max(1, int(limit) or 10), 100)
86
+ # Fetch strategy: always fetch the full 100-row page (upstream cap) then filter and slice to lim — fetching the
87
+ # original page at lim then filtering is almost guaranteed to come up empty (measured before f5c4f6c)
88
+ p = {"stages": str(stages or "award"), "limit": 100}
89
+ if cursor:
90
+ p["cursor"] = str(cursor)
91
+ wf, wt = norm_win(updated_from, True), norm_win(updated_to, False)
92
+ if wf:
93
+ p["updatedFrom"] = wf
94
+ if wt:
95
+ p["updatedTo"] = wt
96
+ r = call_provider(provider, "releases", p, cache=cache, no_cache=no_cache)
97
+ return {**fts_from_upstream(r["data"], q, lim), "matched_on": "buyer|suppliers contains", "_meta": r}
98
+
99
+
100
+ def releases_raw(*, stages=None, limit=10, cursor=None, updated_from=None, updated_to=None,
101
+ cache=None, no_cache=False):
102
+ """→ {rows, next_cursor, has_more, params, _meta}; the five OCDS params pass through verbatim (no name filter — upstream has no such param).
103
+ limit 1-100 (upstream page cap — the name-filter line's "always fetch the full 100" is this number); updatedFrom/updatedTo
104
+ get the T timestamp appended to bare dates (bare 400 is empirically proven, same as norm_win). Name-contains filtering is a
105
+ client-layer convenience (uk_fts_tenders name)."""
106
+ from ..transport import call_provider
107
+ from ..errors import SdkError
108
+
109
+ lim = int(limit)
110
+ if not 1 <= lim <= 100:
111
+ raise SdkError("usage_error", provider="fts", detail="limit must be 1-100 (upstream page cap)")
112
+ params = {"stages": str(stages or "award"), "limit": lim}
113
+ if cursor:
114
+ params["cursor"] = str(cursor)
115
+ wf, wt = norm_win(updated_from, True), norm_win(updated_to, False)
116
+ if wf:
117
+ params["updatedFrom"] = wf
118
+ if wt:
119
+ params["updatedTo"] = wt
120
+ r = call_provider(provider, "releases", params, cache=cache, no_cache=no_cache)
121
+ return {**fts_from_upstream(r["data"], "", lim), "params": params, "_meta": r}
@@ -0,0 +1,178 @@
1
+ """gleif — GLEIF LEI API (LEI → entity identity card normalization + reverse name search + parent-company control chain; keyless).
2
+
3
+ Politeness tier 1 rps/burst 2; TTL: lookup/search 7 days, parents 30 days (ownership chains change infrequently).
4
+ Positioning boundary: exact LEI normalization — an LEI in, an entity identity card out; reverse name search is a
5
+ legal-name contains match (not fuzzy tolerance), and candidates are not an authoritative normalization.
6
+ 2026-10-21: parents moved to the dedicated endpoints /lei-records/{lei}/direct-parent|ultimate-parent
7
+ (each returns the full parent card; no parent → 404) — the old include= route, when a firm self-reports "no parent",
8
+ pointed relationships at reporting-exceptions and the exception id got laid into the result as if it were an LEI;
9
+ the dedicated endpoints are naturally honest nulls and the N+1 follow-up lookups disappeared.
10
+ 2026-10-30 param-fidelity round (0 new faces): search gains jurisdiction/status/page_number passthrough
11
+ (filter[entity.jurisdiction] country-or-region / filter[entity.status] ACTIVE-INACTIVE-NULL / page[number]
12
+ walking, bounded by meta.pagination.lastPage) and candidate rows surface registered_as (the cross-registry
13
+ join key); the lookup card surfaces bic/mic, legal_form{elf,other}, expiration{date,reason} and
14
+ successor_entities (LEI list — the M&A successor edge).
15
+ """
16
+ import re
17
+
18
+ provider = {
19
+ "id": "gleif",
20
+ "baseUrl": "https://api.gleif.org/api/v1",
21
+ "rate": {"rps": 1, "burst": 2},
22
+ "ops": {
23
+ "lookup": {"path": "/lei-records/{lei}", "notFound": [404]}, # params: lei (path template)
24
+ "search": {"path": "/lei-records"}, # params: name, limit, jurisdiction, status, page_number (query assembled as filter[entity.legalName]/page[size]/filter[entity.jurisdiction]/filter[entity.status]/page[number])
25
+ "direct_parent": {"path": "/lei-records/{lei}/direct-parent", "notFound": [404]}, # dedicated endpoint returns the full parent card; 404 = none
26
+ "ultimate_parent": {"path": "/lei-records/{lei}/ultimate-parent", "notFound": [404]}, # same (ultimate sense)
27
+ },
28
+ "ttl": {"lookup": 7 * 86400, "search": 7 * 86400, "direct_parent": 30 * 86400, "ultimate_parent": 30 * 86400},
29
+ }
30
+
31
+ LEI_RE = re.compile(r"^[A-Z0-9]{20}$")
32
+
33
+
34
+ def lookup_lei(lei: str, cache=None, no_cache: bool = False) -> dict:
35
+ from ..transport import call_provider
36
+ from ..errors import SdkError
37
+
38
+ norm = str(lei).strip().upper()
39
+ if not LEI_RE.match(norm):
40
+ raise SdkError("usage_error", provider="gleif",
41
+ detail=f"invalid LEI shape (ISO 17442: 20 alphanumeric chars): {lei}")
42
+ r = call_provider(provider, "lookup", {"lei": norm}, cache=cache, no_cache=no_cache)
43
+ a = ((r["data"] or {}).get("data") or {}).get("attributes") or {}
44
+ ent, reg = a.get("entity") or {}, a.get("registration") or {}
45
+ creation = ent.get("creationDate")
46
+ return {
47
+ "lei": a.get("lei") or norm,
48
+ "legal_name": (ent.get("legalName") or {}).get("name"),
49
+ "name_language": (ent.get("legalName") or {}).get("language"),
50
+ "entity_status": ent.get("status"),
51
+ "jurisdiction": ent.get("jurisdiction"),
52
+ "registration_country": (ent.get("legalAddress") or {}).get("country"),
53
+ "hq_country": (ent.get("headquartersAddress") or {}).get("country"),
54
+ "hq_city": (ent.get("headquartersAddress") or {}).get("city"),
55
+ "bic": a.get("bic"), # BIC/MIC are top-level record attributes (null unless the LOU captured them)
56
+ "mic": a.get("mic"),
57
+ "entity_category": ent.get("category"),
58
+ "legal_form": {"id": (ent.get("legalForm") or {}).get("id"), # GLEIF ELF code (e.g. 8888)
59
+ "other": (ent.get("legalForm") or {}).get("other")}, # raw local legal-form text (e.g. CREDIT_UNION)
60
+ "creation_date": creation[:10] if creation else None,
61
+ "expiration": {"date": (ent.get("expiration") or {}).get("date"),
62
+ "reason": (ent.get("expiration") or {}).get("reason")},
63
+ # successor LEIs (the M&A successor edge); upstream names are null in practice — resolve each via lookup_lei
64
+ "successor_entities": [s.get("lei") for s in (ent.get("successorEntities") or []) if (s or {}).get("lei")],
65
+ "registration": {
66
+ "status": reg.get("status"), # ISSUED / LAPSED / RETIRED… (issuance sense)
67
+ "last_update": reg.get("lastUpdateDate"), # upstream key lastUpdateDate (2026-10-21: the old read of lastUpdate was permanently null)
68
+ "next_renewal_date": reg.get("nextRenewalDate"),
69
+ "registrar_id": (ent.get("registeredAt") or {}).get("id"),
70
+ "registered_as": ent.get("registeredAs"),
71
+ },
72
+ "_meta": r,
73
+ }
74
+
75
+
76
+ def search_lei_by_name(name, *, jurisdiction=None, status=None, page_number=None, limit=10, cache=None, no_cache=False):
77
+ """→ {total, page, candidates[], _meta}; name → LEI reverse search. Legal-name contains match, not fuzzy tolerance —
78
+ candidates are not an authoritative normalization; re-verify with lookup_lei before persisting.
79
+ 2026-10-30 param-fidelity round: jurisdiction (filter[entity.jurisdiction] — ISO 3166-1 country or
80
+ ISO 3166-2 region code: CH→14 Nestlé hits, US-MO→1 hit = Nestlé Purina registeredAs=00007498; the sibling
81
+ filter[entity.jurisdictionCountry] is whole-filter whitelist-rejected 400, do not mirror it), status
82
+ (filter[entity.status] — ACTIVE/INACTIVE/NULL, entity lifecycle; LAPSED lives in registration.status, a
83
+ different field), page_number (page[number], 1-based — walk while page <= meta.pagination.lastPage)."""
84
+ from ..transport import call_provider
85
+ from ..errors import SdkError
86
+
87
+ q = str(name or "").strip()
88
+ if not q:
89
+ raise SdkError("usage_error", provider="gleif", detail="search requires --name")
90
+ params = {
91
+ "filter[entity.legalName]": q,
92
+ "page[size]": min(max(1, int(limit) or 10), 200), # real upstream limit 200 (probed: 201→400, 200 served in full; the earlier 100 clamp was a misrecord)
93
+ }
94
+ if jurisdiction is not None and str(jurisdiction).strip():
95
+ params["filter[entity.jurisdiction]"] = str(jurisdiction).strip().upper()
96
+ if status is not None and str(status).strip():
97
+ params["filter[entity.status]"] = str(status).strip().upper()
98
+ if page_number is not None:
99
+ if int(page_number) < 1:
100
+ raise SdkError("usage_error", provider="gleif", detail="page_number is 1-based (upstream page[number])")
101
+ params["page[number]"] = int(page_number)
102
+ r = call_provider(provider, "search", params, cache=cache, no_cache=no_cache)
103
+ rows = [{
104
+ "lei": (e.get("attributes") or {}).get("lei") or e.get("id"),
105
+ "legal_name": ((e.get("attributes") or {}).get("entity") or {}).get("legalName", {}).get("name"),
106
+ "entity_status": ((e.get("attributes") or {}).get("entity") or {}).get("status"),
107
+ "jurisdiction": ((e.get("attributes") or {}).get("entity") or {}).get("jurisdiction"),
108
+ "registration_country": (((e.get("attributes") or {}).get("entity") or {}).get("legalAddress") or {}).get("country"),
109
+ "registered_as": ((e.get("attributes") or {}).get("entity") or {}).get("registeredAs"), # national-registry id inside the registering LOU's jurisdiction — the cross-registry join key (e.g. US-MO Nestlé Purina 00007498, FR 74530)
110
+ } for e in (r["data"] or {}).get("data") or []]
111
+ pg = (((r["data"] or {}).get("meta") or {}).get("pagination")) or {}
112
+ total = pg.get("total")
113
+ return {"total": total if total is not None else len(rows),
114
+ "page": ({k: pg.get(k) for k in ("currentPage", "perPage", "from", "to", "total", "lastPage")}
115
+ if pg else None), # lastPage = honest walk bound (Nestlé bare name: total 140 @ perPage 15 → lastPage 10)
116
+ "candidates": rows, "_meta": r}
117
+
118
+
119
+ def _norm_lei(lei):
120
+ from ..errors import SdkError
121
+
122
+ norm = str(lei).strip().upper()
123
+ if not LEI_RE.match(norm):
124
+ raise SdkError("usage_error", provider="gleif",
125
+ detail=f"invalid LEI shape (ISO 17442: 20 alphanumeric chars): {lei}")
126
+ return norm
127
+
128
+
129
+ def _card(norm, op, cache, no_cache):
130
+ """Single-endpoint parent card extraction → (card|None, meta). 404 = no parent, honest null (including the self-reported
131
+ reporting-exception face). The dedicated endpoints /direct-parent|/ultimate-parent return the parent card directly — the old
132
+ include= route laid the exception id into the result as if it were an LEI."""
133
+ from ..transport import call_provider
134
+ from ..errors import SdkError
135
+ from datetime import datetime, timezone
136
+
137
+ try:
138
+ r = call_provider(provider, op, {"lei": norm}, cache=cache, no_cache=no_cache)
139
+ a = (((r["data"] or {}).get("data") or {}).get("attributes")) or {}
140
+ lei_id = a.get("lei")
141
+ if not lei_id:
142
+ return None, r # empty-face fallback
143
+ ent = a.get("entity") or {}
144
+ return ({"lei": lei_id,
145
+ "legal_name": (ent.get("legalName") or {}).get("name"),
146
+ "entity_status": ent.get("status"),
147
+ "jurisdiction": ent.get("jurisdiction")}), r
148
+ except SdkError as e:
149
+ if e.code == "upstream_not_found":
150
+ # 404 = no such parent — honest null; notFound is not cached, the meta is minted on the spot
151
+ return None, {"from_cache": False,
152
+ "fetched_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")}
153
+ raise
154
+
155
+
156
+ def get_direct_parent(lei, *, cache=None, no_cache=False):
157
+ """→ {lei, parent, _meta}; /lei-records/{lei}/direct-parent single-endpoint face (0.2.0 naming split: one command, one endpoint)."""
158
+ norm = _norm_lei(lei)
159
+ card, meta = _card(norm, "direct_parent", cache, no_cache)
160
+ return {"lei": norm, "parent": card, "_meta": meta}
161
+
162
+
163
+ def get_ultimate_parent(lei, *, cache=None, no_cache=False):
164
+ """→ {lei, parent, _meta}; /lei-records/{lei}/ultimate-parent single-endpoint face (ultimate sense)."""
165
+ norm = _norm_lei(lei)
166
+ card, meta = _card(norm, "ultimate_parent", cache, no_cache)
167
+ return {"lei": norm, "parent": card, "_meta": meta}
168
+
169
+
170
+ def get_parents(lei, *, cache=None, no_cache=False):
171
+ """→ {lei, direct_parent, ultimate_parent, _meta, _metas}; no parent = honest null, not an error.
172
+ (internal merge face: used by the related kind=parent internals; the command face is already split into the
173
+ gleif_direct_parent / gleif_ultimate_parent two endpoints.)"""
174
+ d = get_direct_parent(lei, cache=cache, no_cache=no_cache)
175
+ u = get_ultimate_parent(lei, cache=cache, no_cache=no_cache)
176
+ return {"lei": d["lei"], "direct_parent": d["parent"], "ultimate_parent": u["parent"],
177
+ "_meta": d["_meta"],
178
+ "_metas": [d["_meta"], u["_meta"]]} # full-chain metas (one per dedicated endpoint) — the client layer's enrich_source consumes them one by one