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,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
|
+
}
|
agentdata/company/fts.py
ADDED
|
@@ -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
|