prometiam-risk-api 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Prometiam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: prometiam-risk-api
3
+ Version: 0.1.0
4
+ Summary: Official Python client for the Prometiam Risk Intelligence API: EU company registry data (ES, FR, GB, IE, PL, NO, FI, SE, HR, BE, DK), corporate insolvency across twelve markets, sanctions screening, VAT and LEI lookups.
5
+ Author-email: Prometiam <support@prometiam.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://www.prometiam.com/risk-api/docs
8
+ Project-URL: Documentation, https://www.prometiam.com/risk-api/docs
9
+ Keywords: prometiam,kyb,company-registry,sanctions-screening,insolvency,borme,bodacc,companies-house
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Office/Business :: Financial
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: httpx>=0.25
18
+ Dynamic: license-file
19
+
20
+ # prometiam-risk-api (Python)
21
+
22
+ Official Python client for the [Prometiam Risk Intelligence API](https://www.prometiam.com/risk-api/docs): company registry data for Spain, France, the UK, Ireland, Poland, Norway, Finland, Sweden, Croatia, Belgium and Denmark, corporate insolvency notices across twelve markets, sanctions screening, VAT and LEI lookups.
23
+
24
+ ```bash
25
+ pip install prometiam-risk-api
26
+ ```
27
+
28
+ ## Quick start
29
+
30
+ ```python
31
+ import os
32
+ from prometiam_risk_api import Client
33
+
34
+ api = Client(api_key=os.environ["PROMETIAM_API_KEY"])
35
+
36
+ page = api.companies_search(name="Inditex", country="ES") # fuzzy, best match first (match_score 0-100)
37
+ first = page["data"][0]
38
+ print(first["company_name"], first["company_number"])
39
+
40
+ detail = api.company(first["id"], include="risk_flags,insolvency")
41
+ ```
42
+
43
+ Every method returns the API envelope `{"data": ..., "pagination": ..., "meta": ...}` exactly as documented.
44
+
45
+ ## Batch calls
46
+
47
+ One HTTP call for a whole list. Every item counts as one request against your plan limits; a batch that does not fit raises `RateLimitError` whose `max_items_now` says how many items would fit.
48
+
49
+ ```python
50
+ lookup = api.companies_lookup([
51
+ {"country": "ES", "company_number": "A46103834"},
52
+ {"country": "FR", "siren": "552032534"},
53
+ {"country": "ES", "name": "Inditex"}, # best fuzzy match, with match_score
54
+ ])
55
+ for r in lookup["data"]:
56
+ print(r["status"], (r["company"] or {}).get("company_name"))
57
+
58
+ screen = api.sanctions_screen_batch(["Vladimir Putin", "Acme Trading Ltd"], group="entity")
59
+ flagged = [r for r in screen["data"] if r["status"] == "match"]
60
+
61
+ distress = api.insolvency_check([
62
+ {"country": "FR", "company_number": "917499519"},
63
+ {"country": "DE", "name": "Wirecard"},
64
+ ])
65
+ ```
66
+
67
+ ## Pagination
68
+
69
+ ```python
70
+ for page in api.paginate("/people/search", {"name": "garcia", "country": "ES", "limit": 50}):
71
+ for person in page:
72
+ print(person["full_name"])
73
+ ```
74
+
75
+ ## Errors
76
+
77
+ | Error | When |
78
+ |---|---|
79
+ | `AuthError` | 401, or 403 for a country the key cannot access |
80
+ | `UpgradeRequiredError` | 403 `upgrade_required`: scope not in your plan or the free trial allowance is used up. Carries `included_in`, `price`, `upgrade_url`, `trial_allowance` |
81
+ | `RateLimitError` | 429, retried automatically up to `max_retries` (default 2) honouring `Retry-After`; carries `retry_after` and `max_items_now` |
82
+ | `ValidationError` | 400 |
83
+ | `NotFoundError` | 404 |
84
+ | `PrometiamError` | anything else; `status`, `code`, `body` on every error |
85
+
86
+ ## Options
87
+
88
+ ```python
89
+ Client(api_key, base_url="https://api.prometiam.com/functions/v1/risk-api", timeout=30.0, max_retries=2, transport=None)
90
+ ```
91
+
92
+ Use `with Client(...) as api:` to close the connection pool, and `api.request("GET", "/coverage")` for anything not wrapped.
93
+
94
+ ## Rate limits
95
+
96
+ Per-key limits by plan (requests/minute, requests/day, requests/month):
97
+
98
+ | Plan | Price | RPM | Daily | Monthly |
99
+ |---|---|---|---|---|
100
+ | Free (14-day trial) | €0 | 10 | 200 | 1,000 |
101
+ | Starter | €9.99/mo | 60 | 2,000 | 10,000 |
102
+ | Professional | €29.99/mo | 300 | 20,000 | 100,000 |
103
+ | Scale | €99.99/mo | 600 | 200,000 | 1,000,000 |
104
+ | Enterprise | Custom | Custom | Custom | Custom |
105
+
106
+ `api.account()` returns your key's current limits and usage (`rate_limit.rpm_remaining`, `daily_remaining`, `monthly_remaining`). A 429 raises `RateLimitError`, retried automatically up to `max_retries` honouring `Retry-After`. Batch calls (`companies_lookup`, `sanctions_screen_batch`, `insolvency_check`) count every item in the list as one request against these limits; a batch that would not fit in the remaining quota raises `RateLimitError` up front (`code == "batch_exceeds_quota"`, `max_items_now` set).
107
+
108
+ ## Scope limits
109
+
110
+ The API's coverage has real edges — every method above reflects them, not a marketing claim:
111
+
112
+ - **Company registry (`companies_search`, `company`, `people_search`, `events_*`, `directors_network`):** Spain, France, the UK, Ireland, Poland, Norway, Finland, Sweden, Croatia, Belgium, Denmark. Ireland and Poland are company-level today (no officers yet). Finland, Sweden, Croatia, Belgium and Denmark are company records only, with register-change events (name, status, legal form and registered address; share capital for Croatia; dated when the change first appears in the register data, not gazette notices, none before 2026-09-30) and monitoring, but no officers and no registry-compliance signal. Corporate insolvency notices are linked by business ID (Finland), organisationsnummer (Sweden), MBS (Croatia) and CVR number (Denmark); Belgium has none (its bankruptcy shows in the company status and in its register-change events). Sole traders (enskild näringsverksamhet, trgovac pojedinac), enterprises of natural persons (eenmanszaak / entreprise individuelle), sole proprietorships (enkeltmandsvirksomhed) and estates are never served. Denmark has no share capital, and its status includes the register's bankruptcy state.
113
+ - **Insolvency (`insolvency_search`, `insolvency_check`):** corporate only, twelve markets (FR, DE, GB, AT, CH, NO, FI, US, NL, DK, HR, SE). Personal/consumer insolvency is never returned. Spain's concursal data is on `records_search` (`GET /search`), not `insolvency_search`.
114
+ - **Monitoring (`monitors`, `monitor`, `monitor_create`, `monitor_delete`):** ES, IE, PL, FI, SE, BE, HR, DK; alerts on status changes, dissolutions, sanctions matches and corporate events (ES and FI/SE/BE/HR/DK).
115
+ - **Sanctions screening (`sanctions_screen*`):** beta.
116
+ - **PEP hits (`sanctions_screen(name, include_pep=True)`):** beta, Spain only.
117
+ - **Norway:** no corporate-event stream (`events_*` returns empty for NO, and for IE and PL).
118
+ - **Finland:** company records only (`people_search` returns empty for FI; `events_*` returns register-change events, dated when first seen in the register data and none before 2026-09-30; `monitor_create` accepts it). Its insolvency notices are linked by business ID.
119
+ - **Sweden:** company records only (`people_search` returns empty for SE; `events_*` returns register-change events; `monitor_create` accepts it). Corporate insolvency notices (konkurs, företagsrekonstruktion, ackordsförhandling; updated weekly, no court, case number or source link, proceedings that ended before collection not included) are on `insolvency`, linked by organisationsnummer.
120
+ - **Croatia:** company records only (`people_search` returns empty for HR; `events_*` returns register-change events, including share capital; `monitor_create` accepts it). Corporate insolvency notices (court decisions on bankruptcy, shortened bankruptcy and pre-bankruptcy proceedings, with court and case number where the register states them; only companies still on the register) are linked by MBS. A Croatian company is found by MBS (`company_number`), OIB (`oib`) or the VAT alias (`vat=HR` plus the OIB).
121
+ - **Belgium:** company records only (`people_search` returns empty for BE; `events_*` returns register-change events; `monitor_create` accepts it). Belgium is not one of the twelve insolvency markets: its bankruptcy shows in the company status and in its register-change events. A Belgian company is found by enterprise number (`company_number`, the dots and spaces optional) or the VAT alias (`vat=BE` plus the ten digits).
122
+ - **Denmark:** company records only (`people_search` returns empty for DK; `events_*` returns register-change events; `monitor_create` accepts it). Corporate insolvency notices (konkurs and tvangsakkord decisions from CVR credit information, dated by the decision, no court or case number) are linked by CVR number. Contains CVR data (Erhvervsstyrelsen), CC BY 4.0. A Danish company is found by CVR number (`company_number`, spaces and hyphens optional; the mod-11 check is advisory) or the VAT alias (`vat=DK` plus the eight digits).
123
+
124
+ ## Get a key
125
+
126
+ Free at https://www.prometiam.com/signup, no card required. Docs: https://www.prometiam.com/risk-api/docs. Support: support@prometiam.com.
@@ -0,0 +1,107 @@
1
+ # prometiam-risk-api (Python)
2
+
3
+ Official Python client for the [Prometiam Risk Intelligence API](https://www.prometiam.com/risk-api/docs): company registry data for Spain, France, the UK, Ireland, Poland, Norway, Finland, Sweden, Croatia, Belgium and Denmark, corporate insolvency notices across twelve markets, sanctions screening, VAT and LEI lookups.
4
+
5
+ ```bash
6
+ pip install prometiam-risk-api
7
+ ```
8
+
9
+ ## Quick start
10
+
11
+ ```python
12
+ import os
13
+ from prometiam_risk_api import Client
14
+
15
+ api = Client(api_key=os.environ["PROMETIAM_API_KEY"])
16
+
17
+ page = api.companies_search(name="Inditex", country="ES") # fuzzy, best match first (match_score 0-100)
18
+ first = page["data"][0]
19
+ print(first["company_name"], first["company_number"])
20
+
21
+ detail = api.company(first["id"], include="risk_flags,insolvency")
22
+ ```
23
+
24
+ Every method returns the API envelope `{"data": ..., "pagination": ..., "meta": ...}` exactly as documented.
25
+
26
+ ## Batch calls
27
+
28
+ One HTTP call for a whole list. Every item counts as one request against your plan limits; a batch that does not fit raises `RateLimitError` whose `max_items_now` says how many items would fit.
29
+
30
+ ```python
31
+ lookup = api.companies_lookup([
32
+ {"country": "ES", "company_number": "A46103834"},
33
+ {"country": "FR", "siren": "552032534"},
34
+ {"country": "ES", "name": "Inditex"}, # best fuzzy match, with match_score
35
+ ])
36
+ for r in lookup["data"]:
37
+ print(r["status"], (r["company"] or {}).get("company_name"))
38
+
39
+ screen = api.sanctions_screen_batch(["Vladimir Putin", "Acme Trading Ltd"], group="entity")
40
+ flagged = [r for r in screen["data"] if r["status"] == "match"]
41
+
42
+ distress = api.insolvency_check([
43
+ {"country": "FR", "company_number": "917499519"},
44
+ {"country": "DE", "name": "Wirecard"},
45
+ ])
46
+ ```
47
+
48
+ ## Pagination
49
+
50
+ ```python
51
+ for page in api.paginate("/people/search", {"name": "garcia", "country": "ES", "limit": 50}):
52
+ for person in page:
53
+ print(person["full_name"])
54
+ ```
55
+
56
+ ## Errors
57
+
58
+ | Error | When |
59
+ |---|---|
60
+ | `AuthError` | 401, or 403 for a country the key cannot access |
61
+ | `UpgradeRequiredError` | 403 `upgrade_required`: scope not in your plan or the free trial allowance is used up. Carries `included_in`, `price`, `upgrade_url`, `trial_allowance` |
62
+ | `RateLimitError` | 429, retried automatically up to `max_retries` (default 2) honouring `Retry-After`; carries `retry_after` and `max_items_now` |
63
+ | `ValidationError` | 400 |
64
+ | `NotFoundError` | 404 |
65
+ | `PrometiamError` | anything else; `status`, `code`, `body` on every error |
66
+
67
+ ## Options
68
+
69
+ ```python
70
+ Client(api_key, base_url="https://api.prometiam.com/functions/v1/risk-api", timeout=30.0, max_retries=2, transport=None)
71
+ ```
72
+
73
+ Use `with Client(...) as api:` to close the connection pool, and `api.request("GET", "/coverage")` for anything not wrapped.
74
+
75
+ ## Rate limits
76
+
77
+ Per-key limits by plan (requests/minute, requests/day, requests/month):
78
+
79
+ | Plan | Price | RPM | Daily | Monthly |
80
+ |---|---|---|---|---|
81
+ | Free (14-day trial) | €0 | 10 | 200 | 1,000 |
82
+ | Starter | €9.99/mo | 60 | 2,000 | 10,000 |
83
+ | Professional | €29.99/mo | 300 | 20,000 | 100,000 |
84
+ | Scale | €99.99/mo | 600 | 200,000 | 1,000,000 |
85
+ | Enterprise | Custom | Custom | Custom | Custom |
86
+
87
+ `api.account()` returns your key's current limits and usage (`rate_limit.rpm_remaining`, `daily_remaining`, `monthly_remaining`). A 429 raises `RateLimitError`, retried automatically up to `max_retries` honouring `Retry-After`. Batch calls (`companies_lookup`, `sanctions_screen_batch`, `insolvency_check`) count every item in the list as one request against these limits; a batch that would not fit in the remaining quota raises `RateLimitError` up front (`code == "batch_exceeds_quota"`, `max_items_now` set).
88
+
89
+ ## Scope limits
90
+
91
+ The API's coverage has real edges — every method above reflects them, not a marketing claim:
92
+
93
+ - **Company registry (`companies_search`, `company`, `people_search`, `events_*`, `directors_network`):** Spain, France, the UK, Ireland, Poland, Norway, Finland, Sweden, Croatia, Belgium, Denmark. Ireland and Poland are company-level today (no officers yet). Finland, Sweden, Croatia, Belgium and Denmark are company records only, with register-change events (name, status, legal form and registered address; share capital for Croatia; dated when the change first appears in the register data, not gazette notices, none before 2026-09-30) and monitoring, but no officers and no registry-compliance signal. Corporate insolvency notices are linked by business ID (Finland), organisationsnummer (Sweden), MBS (Croatia) and CVR number (Denmark); Belgium has none (its bankruptcy shows in the company status and in its register-change events). Sole traders (enskild näringsverksamhet, trgovac pojedinac), enterprises of natural persons (eenmanszaak / entreprise individuelle), sole proprietorships (enkeltmandsvirksomhed) and estates are never served. Denmark has no share capital, and its status includes the register's bankruptcy state.
94
+ - **Insolvency (`insolvency_search`, `insolvency_check`):** corporate only, twelve markets (FR, DE, GB, AT, CH, NO, FI, US, NL, DK, HR, SE). Personal/consumer insolvency is never returned. Spain's concursal data is on `records_search` (`GET /search`), not `insolvency_search`.
95
+ - **Monitoring (`monitors`, `monitor`, `monitor_create`, `monitor_delete`):** ES, IE, PL, FI, SE, BE, HR, DK; alerts on status changes, dissolutions, sanctions matches and corporate events (ES and FI/SE/BE/HR/DK).
96
+ - **Sanctions screening (`sanctions_screen*`):** beta.
97
+ - **PEP hits (`sanctions_screen(name, include_pep=True)`):** beta, Spain only.
98
+ - **Norway:** no corporate-event stream (`events_*` returns empty for NO, and for IE and PL).
99
+ - **Finland:** company records only (`people_search` returns empty for FI; `events_*` returns register-change events, dated when first seen in the register data and none before 2026-09-30; `monitor_create` accepts it). Its insolvency notices are linked by business ID.
100
+ - **Sweden:** company records only (`people_search` returns empty for SE; `events_*` returns register-change events; `monitor_create` accepts it). Corporate insolvency notices (konkurs, företagsrekonstruktion, ackordsförhandling; updated weekly, no court, case number or source link, proceedings that ended before collection not included) are on `insolvency`, linked by organisationsnummer.
101
+ - **Croatia:** company records only (`people_search` returns empty for HR; `events_*` returns register-change events, including share capital; `monitor_create` accepts it). Corporate insolvency notices (court decisions on bankruptcy, shortened bankruptcy and pre-bankruptcy proceedings, with court and case number where the register states them; only companies still on the register) are linked by MBS. A Croatian company is found by MBS (`company_number`), OIB (`oib`) or the VAT alias (`vat=HR` plus the OIB).
102
+ - **Belgium:** company records only (`people_search` returns empty for BE; `events_*` returns register-change events; `monitor_create` accepts it). Belgium is not one of the twelve insolvency markets: its bankruptcy shows in the company status and in its register-change events. A Belgian company is found by enterprise number (`company_number`, the dots and spaces optional) or the VAT alias (`vat=BE` plus the ten digits).
103
+ - **Denmark:** company records only (`people_search` returns empty for DK; `events_*` returns register-change events; `monitor_create` accepts it). Corporate insolvency notices (konkurs and tvangsakkord decisions from CVR credit information, dated by the decision, no court or case number) are linked by CVR number. Contains CVR data (Erhvervsstyrelsen), CC BY 4.0. A Danish company is found by CVR number (`company_number`, spaces and hyphens optional; the mod-11 check is advisory) or the VAT alias (`vat=DK` plus the eight digits).
104
+
105
+ ## Get a key
106
+
107
+ Free at https://www.prometiam.com/signup, no card required. Docs: https://www.prometiam.com/risk-api/docs. Support: support@prometiam.com.
@@ -0,0 +1,23 @@
1
+ """Official Python client for the Prometiam Risk Intelligence API."""
2
+ from .client import DEFAULT_BASE_URL, SDK_VERSION, Client
3
+ from .errors import (
4
+ AuthError,
5
+ NotFoundError,
6
+ PrometiamError,
7
+ RateLimitError,
8
+ UpgradeRequiredError,
9
+ ValidationError,
10
+ )
11
+
12
+ __all__ = [
13
+ "Client",
14
+ "DEFAULT_BASE_URL",
15
+ "SDK_VERSION",
16
+ "PrometiamError",
17
+ "AuthError",
18
+ "ValidationError",
19
+ "NotFoundError",
20
+ "RateLimitError",
21
+ "UpgradeRequiredError",
22
+ ]
23
+ __version__ = SDK_VERSION
@@ -0,0 +1,245 @@
1
+ """Prometiam Risk Intelligence API client.
2
+
3
+ from prometiam_risk_api import Client
4
+ api = Client(api_key=os.environ["PROMETIAM_API_KEY"])
5
+ page = api.companies_search(name="Inditex", country="ES")
6
+ print(page["data"][0]["company_name"])
7
+
8
+ Every method returns the API envelope ``{"data": ..., "pagination": ..., "meta": ...}`` untouched, so the
9
+ fields documented at https://www.prometiam.com/risk-api/docs are exactly what you get. Failures raise typed
10
+ errors (see errors.py); 429 and 5xx responses are retried automatically, honouring Retry-After.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import time
16
+ from typing import Any, Dict, Iterable, Iterator, List, Optional, Union
17
+
18
+ import httpx
19
+
20
+ from .errors import RateLimitError, error_from_response
21
+
22
+ DEFAULT_BASE_URL = "https://api.prometiam.com/functions/v1/risk-api"
23
+ SDK_VERSION = "0.1.0"
24
+
25
+ Params = Dict[str, Any]
26
+
27
+
28
+ class Client:
29
+ def __init__(
30
+ self,
31
+ api_key: str,
32
+ base_url: str = DEFAULT_BASE_URL,
33
+ timeout: float = 30.0,
34
+ max_retries: int = 2,
35
+ transport: Optional[httpx.BaseTransport] = None,
36
+ ):
37
+ if not api_key:
38
+ raise ValueError("api_key is required (rk_live_...)")
39
+ self.base_url = base_url.rstrip("/")
40
+ self.max_retries = max_retries
41
+ self._http = httpx.Client(
42
+ base_url=self.base_url,
43
+ timeout=timeout,
44
+ transport=transport,
45
+ headers={
46
+ "Authorization": f"Bearer {api_key}",
47
+ "Accept": "application/json",
48
+ "User-Agent": f"prometiam-risk-api-python/{SDK_VERSION}",
49
+ },
50
+ )
51
+
52
+ # ── plumbing ─────────────────────────────────────────────────────────
53
+ def close(self) -> None:
54
+ self._http.close()
55
+
56
+ def __enter__(self) -> "Client":
57
+ return self
58
+
59
+ def __exit__(self, *exc: Any) -> None:
60
+ self.close()
61
+
62
+ @staticmethod
63
+ def _clean(params: Optional[Params]) -> Params:
64
+ return {k: v for k, v in (params or {}).items() if v is not None and v != ""}
65
+
66
+ def request(
67
+ self,
68
+ method: str,
69
+ path: str,
70
+ params: Optional[Params] = None,
71
+ json_body: Any = None,
72
+ idempotency_key: Optional[str] = None,
73
+ ) -> Dict[str, Any]:
74
+ """Low-level call. Sends the key, retries 429/5xx, raises typed errors.
75
+
76
+ ``idempotency_key`` (batch POST endpoints only) is sent as the ``Idempotency-Key`` header:
77
+ a replay of the same key with the same request within 24h returns the stored response with
78
+ ``Idempotent-Replayed: true`` and costs no quota. See docs #idempotency-keys.
79
+ """
80
+ attempt = 0
81
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
82
+ while True:
83
+ res = self._http.request(method, path, params=self._clean(params), json=json_body, headers=headers)
84
+ text = res.text
85
+ try:
86
+ body = json.loads(text) if text else None
87
+ except ValueError:
88
+ body = None
89
+ if res.is_success:
90
+ return body if isinstance(body, dict) else {"data": body}
91
+ err = error_from_response(res.status_code, res.headers.get("retry-after"), body, text)
92
+ retryable = res.status_code == 429 or res.status_code >= 500
93
+ if retryable and attempt < self.max_retries:
94
+ attempt += 1
95
+ wait = err.retry_after if isinstance(err, RateLimitError) else 0.5 * (2 ** attempt)
96
+ time.sleep(min(wait, 60.0))
97
+ continue
98
+ raise err
99
+
100
+ def paginate(self, path: str, params: Optional[Params] = None, max_pages: Optional[int] = None) -> Iterator[List[Any]]:
101
+ """Yield each page's ``data`` of a cursor-paginated GET endpoint."""
102
+ params = dict(params or {})
103
+ cursor = params.pop("cursor", None)
104
+ pages = 0
105
+ while True:
106
+ page = self.request("GET", path, params={**params, "cursor": cursor})
107
+ yield page.get("data") or []
108
+ pages += 1
109
+ pag = page.get("pagination") or {}
110
+ cursor = pag.get("next_cursor") if pag.get("has_more") else None
111
+ if not cursor or (max_pages is not None and pages >= max_pages):
112
+ return
113
+
114
+ # ── companies ────────────────────────────────────────────────────────
115
+ def companies_search(self, **params: Any) -> Dict[str, Any]:
116
+ """GET /companies/search: name (fuzzy, match_score), company_number/nif/siren/vat, founded_after, cnae, objeto..."""
117
+ return self.request("GET", "/companies/search", params)
118
+
119
+ def company(self, company_id: Union[int, str], **params: Any) -> Dict[str, Any]:
120
+ """GET /companies/{id}: full detail; include=risk_flags,insolvency,procurement,lei or all."""
121
+ return self.request("GET", f"/companies/{company_id}", params)
122
+
123
+ def companies_lookup(self, items: Iterable[Dict[str, Any]], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
124
+ """POST /companies/lookup: up to 100 identifiers or names in one call; every item counts as one request.
125
+ ``idempotency_key`` makes a retry of the exact same batch free and safe to repeat (docs #idempotency-keys)."""
126
+ return self.request("POST", "/companies/lookup", json_body={"items": list(items)}, idempotency_key=idempotency_key)
127
+
128
+ # ── people / events ──────────────────────────────────────────────────
129
+ def people_search(self, **params: Any) -> Dict[str, Any]:
130
+ return self.request("GET", "/people/search", params)
131
+
132
+ def person(self, person_id: Union[int, str], **params: Any) -> Dict[str, Any]:
133
+ return self.request("GET", f"/people/{person_id}", params)
134
+
135
+ def events_search(self, **params: Any) -> Dict[str, Any]:
136
+ return self.request("GET", "/company-events/search", params)
137
+
138
+ def events_timeline(self, **params: Any) -> Dict[str, Any]:
139
+ return self.request("GET", "/company-events/timeline", params)
140
+
141
+ def event(self, event_id: Union[int, str]) -> Dict[str, Any]:
142
+ return self.request("GET", f"/company-events/records/{event_id}")
143
+
144
+ # ── sanctions ────────────────────────────────────────────────────────
145
+ def sanctions_screen(self, name: str, **params: Any) -> Dict[str, Any]:
146
+ """GET /sanctions/screen: fuzzy screen of one name; group='entity' folds hits per listed person or company."""
147
+ return self.request("GET", "/sanctions/screen", {"name": name, **params})
148
+
149
+ def sanctions_screen_batch(
150
+ self,
151
+ items: Iterable[Union[str, Dict[str, Any]]],
152
+ idempotency_key: Optional[str] = None,
153
+ **options: Any,
154
+ ) -> Dict[str, Any]:
155
+ """POST /sanctions/screen: up to 50 names in one call (sanctions scope; no trial allowance).
156
+ Options: threshold, list, active_only, group='entity', limit (hits per name).
157
+ ``idempotency_key`` makes a retry of the exact same batch free and safe to repeat (docs #idempotency-keys)."""
158
+ norm = [{"name": i} if isinstance(i, str) else i for i in items]
159
+ return self.request("POST", "/sanctions/screen", json_body={**options, "items": norm}, idempotency_key=idempotency_key)
160
+
161
+ def sanctions_entity(self, entity_id: Union[int, str]) -> Dict[str, Any]:
162
+ return self.request("GET", f"/sanctions/entity/{entity_id}")
163
+
164
+ def sanctions_changes(self, **params: Any) -> Dict[str, Any]:
165
+ return self.request("GET", "/sanctions/changes", params)
166
+
167
+ def watchlist(self) -> Dict[str, Any]:
168
+ return self.request("GET", "/sanctions/watchlist")
169
+
170
+ def watchlist_add(self, **body: Any) -> Dict[str, Any]:
171
+ return self.request("POST", "/sanctions/watchlist", json_body=body)
172
+
173
+ def watchlist_remove(self, entry_id: Union[int, str]) -> Dict[str, Any]:
174
+ return self.request("DELETE", f"/sanctions/watchlist/{entry_id}")
175
+
176
+ # ── insolvency / notices ─────────────────────────────────────────────
177
+ def procurement_awards(self, **params: Any) -> Dict[str, Any]:
178
+ """GET /procurement/awards: public-contract awards (ES, FR, GB, IE, PL, NO) by supplier, buyer, nif, cpv or date; one row per award to one supplier."""
179
+ return self.request("GET", "/procurement/awards", params)
180
+
181
+ def procurement_buyer(self, buyer_id: str, **params: Any) -> Dict[str, Any]:
182
+ """GET /procurement/buyer/{id}: public-buyer risk profile (beta, ES + FR); scores about the contracting body."""
183
+ return self.request("GET", f"/procurement/buyer/{buyer_id}", params)
184
+
185
+ def procurement_buyers(self, **params: Any) -> Dict[str, Any]:
186
+ """GET /procurement/buyers: scored public buyers ordered by score (beta, ES + FR)."""
187
+ return self.request("GET", "/procurement/buyers", params)
188
+
189
+ def procurement_relationship(self, **params: Any) -> Dict[str, Any]:
190
+ """GET /procurement/relationship: a buyer and one of its five largest suppliers (beta, ES + FR)."""
191
+ return self.request("GET", "/procurement/relationship", params)
192
+
193
+ def insolvency_search(self, **params: Any) -> Dict[str, Any]:
194
+ """GET /insolvency/search: corporate notices across FR, DE, GB, AT, CH, NO, FI, US, NL, DK, HR, SE."""
195
+ return self.request("GET", "/insolvency/search", params)
196
+
197
+ def insolvency_check(self, items: Iterable[Dict[str, Any]], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
198
+ """POST /insolvency/check: up to 100 counterparties in one call.
199
+ ``idempotency_key`` makes a retry of the exact same batch free and safe to repeat (docs #idempotency-keys)."""
200
+ return self.request("POST", "/insolvency/check", json_body={"items": list(items)}, idempotency_key=idempotency_key)
201
+
202
+ def records_search(self, **params: Any) -> Dict[str, Any]:
203
+ """GET /search: Spanish risk and concursal notices."""
204
+ return self.request("GET", "/search", params)
205
+
206
+ def record(self, record_id: Union[int, str]) -> Dict[str, Any]:
207
+ return self.request("GET", f"/records/{record_id}")
208
+
209
+ def notice(self, notice_id: Union[int, str], **params: Any) -> Dict[str, Any]:
210
+ return self.request("GET", f"/notices/{notice_id}", params)
211
+
212
+ # ── graph / registries ───────────────────────────────────────────────
213
+ def directors_network(self, **params: Any) -> Dict[str, Any]:
214
+ return self.request("GET", "/directors/network", params)
215
+
216
+ def vat(self, vat_number: str) -> Dict[str, Any]:
217
+ return self.request("GET", f"/vat/{vat_number}")
218
+
219
+ def lei(self, lei: str) -> Dict[str, Any]:
220
+ return self.request("GET", f"/lei/{lei}")
221
+
222
+ def lei_relationships(self, lei: str) -> Dict[str, Any]:
223
+ return self.request("GET", f"/lei/{lei}/relationships")
224
+
225
+ def lei_search(self, **params: Any) -> Dict[str, Any]:
226
+ return self.request("GET", "/lei/search", params)
227
+
228
+ # ── monitoring / account ─────────────────────────────────────────────
229
+ def monitors(self) -> Dict[str, Any]:
230
+ return self.request("GET", "/monitor")
231
+
232
+ def monitor(self, monitor_id: Union[int, str]) -> Dict[str, Any]:
233
+ return self.request("GET", f"/monitor/{monitor_id}")
234
+
235
+ def monitor_create(self, **body: Any) -> Dict[str, Any]:
236
+ return self.request("POST", "/monitor", json_body=body)
237
+
238
+ def monitor_delete(self, monitor_id: Union[int, str]) -> Dict[str, Any]:
239
+ return self.request("DELETE", f"/monitor/{monitor_id}")
240
+
241
+ def coverage(self) -> Dict[str, Any]:
242
+ return self.request("GET", "/coverage")
243
+
244
+ def account(self) -> Dict[str, Any]:
245
+ return self.request("GET", "/account")
@@ -0,0 +1,82 @@
1
+ """Errors raised by the Prometiam client. Every one carries the HTTP status, the API's error code and the raw body."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Optional
5
+
6
+
7
+ class PrometiamError(Exception):
8
+ def __init__(self, message: str, status: int, code: str, body: Any):
9
+ super().__init__(message)
10
+ self.message = message
11
+ self.status = status
12
+ self.code = code
13
+ self.body = body
14
+
15
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
16
+ return f"{self.__class__.__name__}(status={self.status}, code={self.code!r}, message={self.message!r})"
17
+
18
+
19
+ class AuthError(PrometiamError):
20
+ """401, or 403 for a country the key cannot access."""
21
+
22
+
23
+ class ValidationError(PrometiamError):
24
+ """400 / 422."""
25
+
26
+
27
+ class NotFoundError(PrometiamError):
28
+ """404."""
29
+
30
+
31
+ class RateLimitError(PrometiamError):
32
+ """429: per-minute, daily or monthly limit, or a batch larger than the remaining quota."""
33
+
34
+ def __init__(self, message: str, status: int, code: str, body: Any, retry_after: float):
35
+ super().__init__(message, status, code, body)
36
+ self.retry_after = retry_after
37
+ b = body if isinstance(body, dict) else {}
38
+ self.max_items_now: Optional[int] = b.get("max_items_now") if isinstance(b.get("max_items_now"), int) else None
39
+
40
+
41
+ class UpgradeRequiredError(PrometiamError):
42
+ """403 upgrade_required: the scope is not in the plan, or the free trial allowance for it is used up."""
43
+
44
+ def __init__(self, message: str, status: int, code: str, body: Any):
45
+ super().__init__(message, status, code, body)
46
+ b = body if isinstance(body, dict) else {}
47
+ self.required_scope: Optional[str] = b.get("required_scope")
48
+ self.included_in: Optional[str] = b.get("included_in")
49
+ self.price: Optional[str] = b.get("price")
50
+ self.upgrade_url: Optional[str] = b.get("upgrade_url")
51
+ self.trial_allowance: Optional[dict] = b.get("trial_allowance")
52
+
53
+
54
+ def error_from_response(status: int, retry_after_header: Optional[str], body: Any, raw_text: str) -> PrometiamError:
55
+ b = body if isinstance(body, dict) else {}
56
+ err = b.get("error") if isinstance(b.get("error"), dict) else {}
57
+ code = str(err.get("code") or b.get("code") or ("rate_limit_exceeded" if status == 429 else "server_error" if status >= 500 else "error"))
58
+ message = str(err.get("message") or b.get("message") or (raw_text[:200] if raw_text else f"HTTP {status}"))
59
+ if status == 401:
60
+ return AuthError(message, status, code, body)
61
+ if status == 403 and code == "upgrade_required":
62
+ return UpgradeRequiredError(message, status, code, body)
63
+ if status == 403:
64
+ return AuthError(message, status, code, body)
65
+ if status == 404:
66
+ return NotFoundError(message, status, code, body)
67
+ if status in (400, 422):
68
+ return ValidationError(message, status, code, body)
69
+ if status == 429:
70
+ retry_after: Optional[float] = None
71
+ for candidate in (retry_after_header, b.get("retry_after_seconds")):
72
+ if candidate is None or candidate == "":
73
+ continue
74
+ try:
75
+ v = float(candidate)
76
+ except (TypeError, ValueError):
77
+ continue
78
+ if v >= 0:
79
+ retry_after = v
80
+ break
81
+ return RateLimitError(message, status, code, body, 60.0 if retry_after is None else retry_after)
82
+ return PrometiamError(message, status, code, body)
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: prometiam-risk-api
3
+ Version: 0.1.0
4
+ Summary: Official Python client for the Prometiam Risk Intelligence API: EU company registry data (ES, FR, GB, IE, PL, NO, FI, SE, HR, BE, DK), corporate insolvency across twelve markets, sanctions screening, VAT and LEI lookups.
5
+ Author-email: Prometiam <support@prometiam.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://www.prometiam.com/risk-api/docs
8
+ Project-URL: Documentation, https://www.prometiam.com/risk-api/docs
9
+ Keywords: prometiam,kyb,company-registry,sanctions-screening,insolvency,borme,bodacc,companies-house
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Office/Business :: Financial
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: httpx>=0.25
18
+ Dynamic: license-file
19
+
20
+ # prometiam-risk-api (Python)
21
+
22
+ Official Python client for the [Prometiam Risk Intelligence API](https://www.prometiam.com/risk-api/docs): company registry data for Spain, France, the UK, Ireland, Poland, Norway, Finland, Sweden, Croatia, Belgium and Denmark, corporate insolvency notices across twelve markets, sanctions screening, VAT and LEI lookups.
23
+
24
+ ```bash
25
+ pip install prometiam-risk-api
26
+ ```
27
+
28
+ ## Quick start
29
+
30
+ ```python
31
+ import os
32
+ from prometiam_risk_api import Client
33
+
34
+ api = Client(api_key=os.environ["PROMETIAM_API_KEY"])
35
+
36
+ page = api.companies_search(name="Inditex", country="ES") # fuzzy, best match first (match_score 0-100)
37
+ first = page["data"][0]
38
+ print(first["company_name"], first["company_number"])
39
+
40
+ detail = api.company(first["id"], include="risk_flags,insolvency")
41
+ ```
42
+
43
+ Every method returns the API envelope `{"data": ..., "pagination": ..., "meta": ...}` exactly as documented.
44
+
45
+ ## Batch calls
46
+
47
+ One HTTP call for a whole list. Every item counts as one request against your plan limits; a batch that does not fit raises `RateLimitError` whose `max_items_now` says how many items would fit.
48
+
49
+ ```python
50
+ lookup = api.companies_lookup([
51
+ {"country": "ES", "company_number": "A46103834"},
52
+ {"country": "FR", "siren": "552032534"},
53
+ {"country": "ES", "name": "Inditex"}, # best fuzzy match, with match_score
54
+ ])
55
+ for r in lookup["data"]:
56
+ print(r["status"], (r["company"] or {}).get("company_name"))
57
+
58
+ screen = api.sanctions_screen_batch(["Vladimir Putin", "Acme Trading Ltd"], group="entity")
59
+ flagged = [r for r in screen["data"] if r["status"] == "match"]
60
+
61
+ distress = api.insolvency_check([
62
+ {"country": "FR", "company_number": "917499519"},
63
+ {"country": "DE", "name": "Wirecard"},
64
+ ])
65
+ ```
66
+
67
+ ## Pagination
68
+
69
+ ```python
70
+ for page in api.paginate("/people/search", {"name": "garcia", "country": "ES", "limit": 50}):
71
+ for person in page:
72
+ print(person["full_name"])
73
+ ```
74
+
75
+ ## Errors
76
+
77
+ | Error | When |
78
+ |---|---|
79
+ | `AuthError` | 401, or 403 for a country the key cannot access |
80
+ | `UpgradeRequiredError` | 403 `upgrade_required`: scope not in your plan or the free trial allowance is used up. Carries `included_in`, `price`, `upgrade_url`, `trial_allowance` |
81
+ | `RateLimitError` | 429, retried automatically up to `max_retries` (default 2) honouring `Retry-After`; carries `retry_after` and `max_items_now` |
82
+ | `ValidationError` | 400 |
83
+ | `NotFoundError` | 404 |
84
+ | `PrometiamError` | anything else; `status`, `code`, `body` on every error |
85
+
86
+ ## Options
87
+
88
+ ```python
89
+ Client(api_key, base_url="https://api.prometiam.com/functions/v1/risk-api", timeout=30.0, max_retries=2, transport=None)
90
+ ```
91
+
92
+ Use `with Client(...) as api:` to close the connection pool, and `api.request("GET", "/coverage")` for anything not wrapped.
93
+
94
+ ## Rate limits
95
+
96
+ Per-key limits by plan (requests/minute, requests/day, requests/month):
97
+
98
+ | Plan | Price | RPM | Daily | Monthly |
99
+ |---|---|---|---|---|
100
+ | Free (14-day trial) | €0 | 10 | 200 | 1,000 |
101
+ | Starter | €9.99/mo | 60 | 2,000 | 10,000 |
102
+ | Professional | €29.99/mo | 300 | 20,000 | 100,000 |
103
+ | Scale | €99.99/mo | 600 | 200,000 | 1,000,000 |
104
+ | Enterprise | Custom | Custom | Custom | Custom |
105
+
106
+ `api.account()` returns your key's current limits and usage (`rate_limit.rpm_remaining`, `daily_remaining`, `monthly_remaining`). A 429 raises `RateLimitError`, retried automatically up to `max_retries` honouring `Retry-After`. Batch calls (`companies_lookup`, `sanctions_screen_batch`, `insolvency_check`) count every item in the list as one request against these limits; a batch that would not fit in the remaining quota raises `RateLimitError` up front (`code == "batch_exceeds_quota"`, `max_items_now` set).
107
+
108
+ ## Scope limits
109
+
110
+ The API's coverage has real edges — every method above reflects them, not a marketing claim:
111
+
112
+ - **Company registry (`companies_search`, `company`, `people_search`, `events_*`, `directors_network`):** Spain, France, the UK, Ireland, Poland, Norway, Finland, Sweden, Croatia, Belgium, Denmark. Ireland and Poland are company-level today (no officers yet). Finland, Sweden, Croatia, Belgium and Denmark are company records only, with register-change events (name, status, legal form and registered address; share capital for Croatia; dated when the change first appears in the register data, not gazette notices, none before 2026-09-30) and monitoring, but no officers and no registry-compliance signal. Corporate insolvency notices are linked by business ID (Finland), organisationsnummer (Sweden), MBS (Croatia) and CVR number (Denmark); Belgium has none (its bankruptcy shows in the company status and in its register-change events). Sole traders (enskild näringsverksamhet, trgovac pojedinac), enterprises of natural persons (eenmanszaak / entreprise individuelle), sole proprietorships (enkeltmandsvirksomhed) and estates are never served. Denmark has no share capital, and its status includes the register's bankruptcy state.
113
+ - **Insolvency (`insolvency_search`, `insolvency_check`):** corporate only, twelve markets (FR, DE, GB, AT, CH, NO, FI, US, NL, DK, HR, SE). Personal/consumer insolvency is never returned. Spain's concursal data is on `records_search` (`GET /search`), not `insolvency_search`.
114
+ - **Monitoring (`monitors`, `monitor`, `monitor_create`, `monitor_delete`):** ES, IE, PL, FI, SE, BE, HR, DK; alerts on status changes, dissolutions, sanctions matches and corporate events (ES and FI/SE/BE/HR/DK).
115
+ - **Sanctions screening (`sanctions_screen*`):** beta.
116
+ - **PEP hits (`sanctions_screen(name, include_pep=True)`):** beta, Spain only.
117
+ - **Norway:** no corporate-event stream (`events_*` returns empty for NO, and for IE and PL).
118
+ - **Finland:** company records only (`people_search` returns empty for FI; `events_*` returns register-change events, dated when first seen in the register data and none before 2026-09-30; `monitor_create` accepts it). Its insolvency notices are linked by business ID.
119
+ - **Sweden:** company records only (`people_search` returns empty for SE; `events_*` returns register-change events; `monitor_create` accepts it). Corporate insolvency notices (konkurs, företagsrekonstruktion, ackordsförhandling; updated weekly, no court, case number or source link, proceedings that ended before collection not included) are on `insolvency`, linked by organisationsnummer.
120
+ - **Croatia:** company records only (`people_search` returns empty for HR; `events_*` returns register-change events, including share capital; `monitor_create` accepts it). Corporate insolvency notices (court decisions on bankruptcy, shortened bankruptcy and pre-bankruptcy proceedings, with court and case number where the register states them; only companies still on the register) are linked by MBS. A Croatian company is found by MBS (`company_number`), OIB (`oib`) or the VAT alias (`vat=HR` plus the OIB).
121
+ - **Belgium:** company records only (`people_search` returns empty for BE; `events_*` returns register-change events; `monitor_create` accepts it). Belgium is not one of the twelve insolvency markets: its bankruptcy shows in the company status and in its register-change events. A Belgian company is found by enterprise number (`company_number`, the dots and spaces optional) or the VAT alias (`vat=BE` plus the ten digits).
122
+ - **Denmark:** company records only (`people_search` returns empty for DK; `events_*` returns register-change events; `monitor_create` accepts it). Corporate insolvency notices (konkurs and tvangsakkord decisions from CVR credit information, dated by the decision, no court or case number) are linked by CVR number. Contains CVR data (Erhvervsstyrelsen), CC BY 4.0. A Danish company is found by CVR number (`company_number`, spaces and hyphens optional; the mod-11 check is advisory) or the VAT alias (`vat=DK` plus the eight digits).
123
+
124
+ ## Get a key
125
+
126
+ Free at https://www.prometiam.com/signup, no card required. Docs: https://www.prometiam.com/risk-api/docs. Support: support@prometiam.com.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ prometiam_risk_api/__init__.py
5
+ prometiam_risk_api/client.py
6
+ prometiam_risk_api/errors.py
7
+ prometiam_risk_api.egg-info/PKG-INFO
8
+ prometiam_risk_api.egg-info/SOURCES.txt
9
+ prometiam_risk_api.egg-info/dependency_links.txt
10
+ prometiam_risk_api.egg-info/requires.txt
11
+ prometiam_risk_api.egg-info/top_level.txt
12
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ prometiam_risk_api
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "prometiam-risk-api"
7
+ version = "0.1.0"
8
+ description = "Official Python client for the Prometiam Risk Intelligence API: EU company registry data (ES, FR, GB, IE, PL, NO, FI, SE, HR, BE, DK), corporate insolvency across twelve markets, sanctions screening, VAT and LEI lookups."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Prometiam", email = "support@prometiam.com" }]
12
+ requires-python = ">=3.9"
13
+ dependencies = ["httpx>=0.25"]
14
+ keywords = ["prometiam", "kyb", "company-registry", "sanctions-screening", "insolvency", "borme", "bodacc", "companies-house"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Intended Audience :: Developers",
19
+ "Topic :: Office/Business :: Financial",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://www.prometiam.com/risk-api/docs"
24
+ Documentation = "https://www.prometiam.com/risk-api/docs"
25
+
26
+ [tool.setuptools]
27
+ packages = ["prometiam_risk_api"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,122 @@
1
+ import json
2
+ import sys
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ import httpx
7
+
8
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
9
+
10
+ from prometiam_risk_api import AuthError, Client, RateLimitError, UpgradeRequiredError # noqa: E402
11
+
12
+
13
+ def make_client(handler, **kw):
14
+ calls = []
15
+
16
+ def _handler(request: httpx.Request) -> httpx.Response:
17
+ calls.append(request)
18
+ return handler(request, len(calls))
19
+
20
+ return Client(api_key="rk_live_test", transport=httpx.MockTransport(_handler), **kw), calls
21
+
22
+
23
+ class ClientTests(unittest.TestCase):
24
+ def test_sends_key_and_query(self):
25
+ api, calls = make_client(lambda r, n: httpx.Response(200, json={"data": [{"company_name": "INDITEX SA"}], "meta": {"query_ms": 5}}))
26
+ res = api.companies_search(name="Inditex", country="ES", limit=1, empty=None)
27
+ self.assertEqual(res["data"][0]["company_name"], "INDITEX SA")
28
+ self.assertEqual(calls[0].headers["authorization"], "Bearer rk_live_test")
29
+ self.assertEqual(calls[0].url.path, "/functions/v1/risk-api/companies/search")
30
+ self.assertEqual(calls[0].url.params["name"], "Inditex")
31
+ self.assertNotIn("empty", calls[0].url.params)
32
+
33
+ def test_batch_lookup_posts_items(self):
34
+ def h(r, n):
35
+ body = json.loads(r.content)
36
+ return httpx.Response(200, json={"data": [{"index": i, "status": "found"} for i, _ in enumerate(body["items"])], "meta": {"found": len(body["items"])}})
37
+ api, calls = make_client(h)
38
+ res = api.companies_lookup([{"country": "ES", "company_number": "A46103834"}, {"country": "FR", "siren": "552032534"}])
39
+ self.assertEqual(calls[0].method, "POST")
40
+ self.assertEqual(res["meta"]["found"], 2)
41
+
42
+ def test_sanctions_batch_normalises_strings(self):
43
+ api, calls = make_client(lambda r, n: httpx.Response(200, json={"data": []}))
44
+ api.sanctions_screen_batch(["Vladimir Putin", {"name": "Acme", "entity_type": "company"}], group="entity", threshold=85)
45
+ body = json.loads(calls[0].content)
46
+ self.assertEqual(body["items"], [{"name": "Vladimir Putin"}, {"name": "Acme", "entity_type": "company"}])
47
+ self.assertEqual(body["group"], "entity")
48
+
49
+ def test_idempotency_key_sent_as_header_on_batch_methods(self):
50
+ api, calls = make_client(lambda r, n: httpx.Response(200, json={"data": [], "meta": {}}))
51
+ api.companies_lookup([{"country": "ES", "company_number": "A46103834"}], idempotency_key="key-1")
52
+ self.assertEqual(calls[0].headers["idempotency-key"], "key-1")
53
+
54
+ api.sanctions_screen_batch(["Vladimir Putin"], idempotency_key="key-2")
55
+ self.assertEqual(calls[1].headers["idempotency-key"], "key-2")
56
+
57
+ api.insolvency_check([{"country": "FR", "siren": "552032534"}], idempotency_key="key-3")
58
+ self.assertEqual(calls[2].headers["idempotency-key"], "key-3")
59
+
60
+ api.companies_lookup([{"country": "ES", "company_number": "A46103834"}])
61
+ self.assertNotIn("idempotency-key", calls[3].headers)
62
+
63
+ def test_idempotency_key_reuse_and_in_progress_raise_typed_errors(self):
64
+ api422, _ = make_client(lambda r, n: httpx.Response(
65
+ 422, json={"error": {"code": "idempotency_key_reuse", "message": "reused with a different body", "status": 422}}))
66
+ with self.assertRaises(Exception) as ctx:
67
+ api422.companies_lookup([{"country": "ES", "company_number": "A46103834"}], idempotency_key="dup")
68
+ self.assertEqual(ctx.exception.code, "idempotency_key_reuse")
69
+ self.assertEqual(ctx.exception.status, 422)
70
+
71
+ api409, _ = make_client(
72
+ lambda r, n: httpx.Response(409, headers={"Retry-After": "2"}, json={"error": {"code": "idempotency_key_in_progress", "message": "still running", "status": 409}}),
73
+ max_retries=0,
74
+ )
75
+ with self.assertRaises(Exception) as ctx:
76
+ api409.companies_lookup([{"country": "ES", "company_number": "A46103834"}], idempotency_key="inflight")
77
+ self.assertEqual(ctx.exception.code, "idempotency_key_in_progress")
78
+ self.assertEqual(ctx.exception.status, 409)
79
+
80
+ def test_retries_429_then_succeeds(self):
81
+ def h(r, n):
82
+ if n == 1:
83
+ return httpx.Response(429, headers={"Retry-After": "0"}, json={"error": {"code": "rate_limit_exceeded", "message": "slow", "status": 429}})
84
+ return httpx.Response(200, json={"data": {"ok": True}})
85
+ api, calls = make_client(h, max_retries=2)
86
+ self.assertTrue(api.coverage()["data"]["ok"])
87
+ self.assertEqual(len(calls), 2)
88
+
89
+ def test_rate_limit_error_after_retries(self):
90
+ api, _ = make_client(lambda r, n: httpx.Response(429, headers={"Retry-After": "0"}, json={"error": {"code": "batch_exceeds_quota", "message": "too big", "status": 429}, "max_items_now": 8}), max_retries=1)
91
+ with self.assertRaises(RateLimitError) as cm:
92
+ api.coverage()
93
+ self.assertEqual(cm.exception.max_items_now, 8)
94
+
95
+ def test_upgrade_required(self):
96
+ api, _ = make_client(lambda r, n: httpx.Response(403, json={"error": {"code": "upgrade_required", "message": "no", "status": 403}, "required_scope": "sanctions", "included_in": "professional", "price": "EUR 29.99/month", "trial_allowance": {"scope": "sanctions", "used": 25, "limit": 25, "resets_on": "2026-10-01"}}))
97
+ with self.assertRaises(UpgradeRequiredError) as cm:
98
+ api.sanctions_screen("x")
99
+ self.assertEqual(cm.exception.included_in, "professional")
100
+ self.assertEqual(cm.exception.trial_allowance["limit"], 25)
101
+
102
+ def test_auth_error(self):
103
+ api, _ = make_client(lambda r, n: httpx.Response(401, json={"error": {"code": "unauthorized", "message": "bad key", "status": 401}}))
104
+ with self.assertRaises(AuthError):
105
+ api.account()
106
+
107
+ def test_paginate_follows_cursor(self):
108
+ def h(r, n):
109
+ c = r.url.params.get("cursor")
110
+ if not c:
111
+ return httpx.Response(200, json={"data": [1, 2], "pagination": {"count": 2, "has_more": True, "next_cursor": "o:2"}})
112
+ if c == "o:2":
113
+ return httpx.Response(200, json={"data": [3], "pagination": {"count": 1, "has_more": False, "next_cursor": None}})
114
+ raise AssertionError("unexpected cursor")
115
+ api, calls = make_client(h)
116
+ pages = list(api.paginate("/people/search", {"name": "garcia", "limit": 2}))
117
+ self.assertEqual(pages, [[1, 2], [3]])
118
+ self.assertEqual(len(calls), 2)
119
+
120
+
121
+ if __name__ == "__main__":
122
+ unittest.main()