firmendata 0.1.0__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.
firmendata/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ """Official Python client for the firmendata German company-data API.
2
+
3
+ Autocomplete is free and needs no API key::
4
+
5
+ from firmendata import FirmenData
6
+
7
+ for hit in FirmenData().autocomplete("siemens")["data"]:
8
+ print(hit["display_name"], hit["eu_id"])
9
+
10
+ Everything else needs a key from https://firmendata.com/de/account/api-keys::
11
+
12
+ fd = FirmenData(api_key="firmendata_live_...")
13
+ company = fd.get_company("DEB1103R_HRB123456")
14
+ """
15
+
16
+ from ._version import __version__
17
+ from .async_client import AsyncFirmenData
18
+ from .client import FirmenData
19
+ from .errors import (
20
+ APIConnectionError,
21
+ APIError,
22
+ APITimeoutError,
23
+ AuthenticationError,
24
+ ConflictError,
25
+ FirmenDataError,
26
+ InsufficientCreditsError,
27
+ NotFoundError,
28
+ RateLimitError,
29
+ ServerError,
30
+ TokenExpiredError,
31
+ ValidationError,
32
+ )
33
+
34
+ __all__ = [
35
+ "__version__",
36
+ "FirmenData",
37
+ "AsyncFirmenData",
38
+ # errors
39
+ "FirmenDataError",
40
+ "APIError",
41
+ "APIConnectionError",
42
+ "APITimeoutError",
43
+ "AuthenticationError",
44
+ "TokenExpiredError",
45
+ "InsufficientCreditsError",
46
+ "NotFoundError",
47
+ "ConflictError",
48
+ "ValidationError",
49
+ "RateLimitError",
50
+ "ServerError",
51
+ ]
firmendata/_base.py ADDED
@@ -0,0 +1,97 @@
1
+ """Shared plumbing between the sync and async clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+ from collections.abc import Iterable, Mapping, Sequence
7
+ from typing import Any
8
+
9
+ from ._retry import DEFAULT_MAX_RETRIES
10
+ from ._version import __version__
11
+ from .errors import build_error
12
+
13
+ DEFAULT_BASE_URL = "https://api.firmendata.com"
14
+ DEFAULT_TIMEOUT = 30.0
15
+
16
+ ParamValue = str | int | float | bool | Sequence[str] | Sequence[int] | None
17
+
18
+ #: Exactly the element type httpx declares for `params`. We only ever emit
19
+ #: strings, but `list` is invariant, so a narrower `list[tuple[str, str]]`
20
+ #: would not be accepted where httpx wants this.
21
+ QueryParamPairs = list[tuple[str, str | int | float | bool | None]]
22
+
23
+
24
+ class _BaseClient:
25
+ """URL, header and parameter handling. No I/O lives here."""
26
+
27
+ def __init__(
28
+ self,
29
+ api_key: str | None = None,
30
+ *,
31
+ base_url: str = DEFAULT_BASE_URL,
32
+ timeout: float = DEFAULT_TIMEOUT,
33
+ max_retries: int = DEFAULT_MAX_RETRIES,
34
+ ) -> None:
35
+ #: ``None`` is legitimate: ``autocomplete()`` works without a key.
36
+ #: Every other endpoint will raise ``AuthenticationError``.
37
+ self.api_key = api_key
38
+ self.base_url = base_url.rstrip("/")
39
+ self.timeout = timeout
40
+ self.max_retries = max(0, int(max_retries))
41
+
42
+ # -- request shaping ---------------------------------------------------
43
+
44
+ def _url(self, path: str) -> str:
45
+ return f"{self.base_url}{path}"
46
+
47
+ def _headers(self) -> dict[str, str]:
48
+ headers = {
49
+ "Accept": "application/json",
50
+ "User-Agent": f"firmendata-python/{__version__}",
51
+ }
52
+ if self.api_key:
53
+ headers["Authorization"] = f"Bearer {self.api_key}"
54
+ return headers
55
+
56
+ @staticmethod
57
+ def _params(raw: Mapping[str, ParamValue]) -> QueryParamPairs:
58
+ """Flatten query parameters, dropping ``None``.
59
+
60
+ Returned as a list of pairs rather than a dict so array filters
61
+ (``city``, ``industry_slug``, …) repeat the key — ``?city=Berlin&
62
+ city=Hamburg`` — which is the form the API parses. Booleans are
63
+ lower-cased because ``str(True)`` is ``"True"``, which FastAPI's bool
64
+ coercion rejects.
65
+ """
66
+ out: QueryParamPairs = []
67
+ for key, value in raw.items():
68
+ if value is None:
69
+ continue
70
+ if isinstance(value, bool):
71
+ out.append((key, "true" if value else "false"))
72
+ elif isinstance(value, (str, int, float)):
73
+ out.append((key, str(value)))
74
+ elif isinstance(value, Iterable):
75
+ for item in value:
76
+ if item is None:
77
+ continue
78
+ out.append((key, str(item)))
79
+ else: # pragma: no cover - defensive
80
+ out.append((key, str(value)))
81
+ return out
82
+
83
+ # -- response handling -------------------------------------------------
84
+
85
+ @staticmethod
86
+ def _decode(status_code: int, content: bytes, headers: Mapping[str, str]) -> Any:
87
+ """Parse a response body, raising the mapped error for non-2xx."""
88
+ body: Any = None
89
+ if content:
90
+ try:
91
+ body = _json.loads(content)
92
+ except ValueError:
93
+ body = None
94
+
95
+ if 200 <= status_code < 300:
96
+ return body
97
+ raise build_error(status_code, body, headers)
firmendata/_retry.py ADDED
@@ -0,0 +1,72 @@
1
+ """Retry policy, kept as pure functions so sync and async share one brain.
2
+
3
+ Two rules do the real work:
4
+
5
+ * **429 is always safe to retry, on any method.** The server rejects a
6
+ rate-limited call *before* the handler runs — nothing happened, nothing was
7
+ billed. Its ``Retry-After`` is authoritative and is used verbatim.
8
+
9
+ * **5xx and connection failures are retried only for idempotent methods.**
10
+ A ``POST /v1/subscriptions`` that fails with a 502 may well have created the
11
+ subscription before the proxy gave up; retrying it would create a second
12
+ one. GET and DELETE can be repeated safely, POST cannot.
13
+
14
+ Backoff is exponential with **full jitter** (``random(0, base * 2**n)``)
15
+ rather than a fixed ramp: when many clients trip the same limit at once, an
16
+ undithered backoff simply reconvenes them all at the same instant.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import random
22
+
23
+ #: Methods that may be replayed after an ambiguous failure.
24
+ IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
25
+
26
+ DEFAULT_MAX_RETRIES = 2
27
+ _BASE_DELAY_SECONDS = 0.5
28
+ _MAX_DELAY_SECONDS = 20.0
29
+
30
+
31
+ def should_retry(
32
+ *,
33
+ method: str,
34
+ status_code: int | None,
35
+ attempt: int,
36
+ max_retries: int,
37
+ ) -> bool:
38
+ """Whether to make attempt ``attempt + 1``.
39
+
40
+ ``status_code`` is ``None`` for a transport failure (no response).
41
+ """
42
+ if attempt >= max_retries:
43
+ return False
44
+
45
+ idempotent = method.upper() in IDEMPOTENT_METHODS
46
+
47
+ if status_code is None: # connection reset, timeout, DNS
48
+ return idempotent
49
+ if status_code == 429: # never executed — always replayable
50
+ return True
51
+ if status_code == 408: # server-side timeout, same reasoning as 5xx
52
+ return idempotent
53
+ if status_code >= 500:
54
+ return idempotent
55
+ return False
56
+
57
+
58
+ def backoff_seconds(
59
+ attempt: int,
60
+ *,
61
+ retry_after: float | None = None,
62
+ rng: random.Random | None = None,
63
+ ) -> float:
64
+ """Delay before the next attempt. ``attempt`` is 0-based.
65
+
66
+ A server-supplied ``Retry-After`` wins outright — it knows when the window
67
+ actually resets, and guessing shorter just burns another rejection.
68
+ """
69
+ if retry_after is not None and retry_after >= 0:
70
+ return min(retry_after, _MAX_DELAY_SECONDS)
71
+ ceiling = min(_BASE_DELAY_SECONDS * (2 ** attempt), _MAX_DELAY_SECONDS)
72
+ return (rng or random).uniform(0.0, ceiling)
firmendata/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,229 @@
1
+ """Asynchronous client for the firmendata public API.
2
+
3
+ Mirrors :class:`firmendata.client.FirmenData` method for method. The
4
+ signatures and semantics are identical — only the transport differs — so the
5
+ docstrings there are the reference for both. Retry policy is shared verbatim
6
+ via :mod:`firmendata._retry`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from typing import Any, Unpack
13
+
14
+ import httpx
15
+
16
+ from ._base import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, _BaseClient
17
+ from ._retry import DEFAULT_MAX_RETRIES, backoff_seconds, should_retry
18
+ from .errors import APIConnectionError, APITimeoutError, RateLimitError
19
+ from .params import SearchFilters, SubscriptionFilters
20
+ from .types import (
21
+ AutocompleteResponse,
22
+ CompanyDetail,
23
+ CompanyDocumentDownload,
24
+ CompanyFinancials,
25
+ CompanyHistory,
26
+ SearchResponse,
27
+ ShareholdersReport,
28
+ Subscription,
29
+ SubscriptionCreated,
30
+ SubscriptionEvent,
31
+ SubscriptionEventList,
32
+ SubscriptionEventResendResponse,
33
+ SubscriptionList,
34
+ SubscriptionTestResponse,
35
+ UboReport,
36
+ )
37
+
38
+ __all__ = ["AsyncFirmenData"]
39
+
40
+
41
+ class AsyncFirmenData(_BaseClient):
42
+ """Async client for ``https://api.firmendata.com``.
43
+
44
+ >>> async with AsyncFirmenData() as fd:
45
+ ... hits = await fd.autocomplete("siemens")
46
+
47
+ The API key is optional; without one only :meth:`autocomplete` works.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ api_key: str | None = None,
53
+ *,
54
+ base_url: str = DEFAULT_BASE_URL,
55
+ timeout: float = DEFAULT_TIMEOUT,
56
+ max_retries: int = DEFAULT_MAX_RETRIES,
57
+ http_client: httpx.AsyncClient | None = None,
58
+ ) -> None:
59
+ super().__init__(
60
+ api_key, base_url=base_url, timeout=timeout, max_retries=max_retries,
61
+ )
62
+ self._owns_client = http_client is None
63
+ self._http = http_client or httpx.AsyncClient(timeout=timeout)
64
+
65
+ # -- lifecycle ---------------------------------------------------------
66
+
67
+ async def aclose(self) -> None:
68
+ if self._owns_client:
69
+ await self._http.aclose()
70
+
71
+ async def __aenter__(self) -> AsyncFirmenData:
72
+ return self
73
+
74
+ async def __aexit__(self, *exc: object) -> None:
75
+ await self.aclose()
76
+
77
+ # -- transport ---------------------------------------------------------
78
+
79
+ async def _request(
80
+ self,
81
+ method: str,
82
+ path: str,
83
+ *,
84
+ params: dict[str, Any] | None = None,
85
+ json: dict[str, Any] | None = None,
86
+ ) -> Any:
87
+ url = self._url(path)
88
+ query = self._params(params or {})
89
+ attempt = 0
90
+
91
+ while True:
92
+ status: int | None = None
93
+ retry_after: float | None = None
94
+ try:
95
+ response = await self._http.request(
96
+ method, url,
97
+ params=httpx.QueryParams(query) if query else None,
98
+ json=json,
99
+ headers=self._headers(),
100
+ timeout=self.timeout,
101
+ )
102
+ status = response.status_code
103
+ if 200 <= status < 300:
104
+ return self._decode(status, response.content, response.headers)
105
+
106
+ try:
107
+ self._decode(status, response.content, response.headers)
108
+ except RateLimitError as exc:
109
+ retry_after = exc.retry_after
110
+ error: Exception = exc
111
+ except Exception as exc: # noqa: BLE001 - re-raised below
112
+ error = exc
113
+ else: # pragma: no cover - _decode always raises here
114
+ raise AssertionError("non-2xx did not raise")
115
+
116
+ except (httpx.TimeoutException, httpx.TransportError) as exc:
117
+ error = APITimeoutError(str(exc)) if isinstance(
118
+ exc, httpx.TimeoutException
119
+ ) else APIConnectionError(str(exc))
120
+
121
+ if not should_retry(
122
+ method=method, status_code=status,
123
+ attempt=attempt, max_retries=self.max_retries,
124
+ ):
125
+ raise error
126
+
127
+ await asyncio.sleep(backoff_seconds(attempt, retry_after=retry_after))
128
+ attempt += 1
129
+
130
+ # -- companies ---------------------------------------------------------
131
+
132
+ async def autocomplete(
133
+ self, q: str, *, limit: int = 10, fetch_realtime: bool = False,
134
+ ) -> AutocompleteResponse:
135
+ """Free, keyless company-name suggestions. See the sync client."""
136
+ return await self._request(
137
+ "GET", "/v1/companies/autocomplete",
138
+ params={"q": q, "limit": limit, "fetch_realtime": fetch_realtime},
139
+ )
140
+
141
+ async def search(self, **filters: Unpack[SearchFilters]) -> SearchResponse:
142
+ """Advanced register search."""
143
+ return await self._request("GET", "/v1/companies/search", params=dict(filters))
144
+
145
+ async def get_company(
146
+ self, eu_id: str, *, fetch_realtime: bool = False,
147
+ ) -> CompanyDetail:
148
+ return await self._request(
149
+ "GET", f"/v1/companies/{eu_id}", params={"fetch_realtime": fetch_realtime},
150
+ )
151
+
152
+ async def get_financials(self, eu_id: str) -> CompanyFinancials:
153
+ return await self._request("GET", f"/v1/companies/{eu_id}/financials")
154
+
155
+ async def get_shareholders(
156
+ self, eu_id: str, *, fetch_realtime: bool = False,
157
+ ) -> ShareholdersReport:
158
+ return await self._request(
159
+ "GET", f"/v1/companies/{eu_id}/shareholders",
160
+ params={"fetch_realtime": fetch_realtime},
161
+ )
162
+
163
+ async def get_ubo(self, eu_id: str, *, fetch_realtime: bool = False) -> UboReport:
164
+ return await self._request(
165
+ "GET", f"/v1/companies/{eu_id}/ubo",
166
+ params={"fetch_realtime": fetch_realtime},
167
+ )
168
+
169
+ async def get_history(
170
+ self, eu_id: str, *, fetch_realtime: bool = False,
171
+ ) -> CompanyHistory:
172
+ return await self._request(
173
+ "GET", f"/v1/companies/{eu_id}/history",
174
+ params={"fetch_realtime": fetch_realtime},
175
+ )
176
+
177
+ async def download_document(
178
+ self,
179
+ eu_id: str,
180
+ *,
181
+ file_type: str,
182
+ file_id: str | None = None,
183
+ fetch_realtime: bool = False,
184
+ ) -> CompanyDocumentDownload:
185
+ return await self._request(
186
+ "GET", f"/v1/companies/{eu_id}/documents/download",
187
+ params={
188
+ "file_type": file_type,
189
+ "file_id": file_id,
190
+ "fetch_realtime": fetch_realtime,
191
+ },
192
+ )
193
+
194
+ # -- subscriptions -----------------------------------------------------
195
+
196
+ async def list_subscriptions(
197
+ self, **filters: Unpack[SubscriptionFilters],
198
+ ) -> SubscriptionList:
199
+ return await self._request("GET", "/v1/subscriptions", params=dict(filters))
200
+
201
+ async def create_subscription(self, **body: Any) -> SubscriptionCreated:
202
+ """Not retried on 5xx — a replay could create a duplicate."""
203
+ return await self._request("POST", "/v1/subscriptions", json=body)
204
+
205
+ async def get_subscription(self, subscription_id: str) -> Subscription:
206
+ return await self._request("GET", f"/v1/subscriptions/{subscription_id}")
207
+
208
+ async def delete_subscription(self, subscription_id: str) -> Any:
209
+ return await self._request("DELETE", f"/v1/subscriptions/{subscription_id}")
210
+
211
+ async def list_events(
212
+ self, subscription_id: str, *, limit: int | None = None,
213
+ offset: int | None = None,
214
+ ) -> SubscriptionEventList:
215
+ return await self._request(
216
+ "GET", f"/v1/subscriptions/{subscription_id}/events",
217
+ params={"limit": limit, "offset": offset},
218
+ )
219
+
220
+ async def get_event(self, event_id: str) -> SubscriptionEvent:
221
+ return await self._request("GET", f"/v1/subscriptions/events/{event_id}")
222
+
223
+ async def resend_event(self, event_id: str) -> SubscriptionEventResendResponse:
224
+ return await self._request(
225
+ "POST", f"/v1/subscriptions/events/{event_id}/resend",
226
+ )
227
+
228
+ async def test_delivery(self, **body: Any) -> SubscriptionTestResponse:
229
+ return await self._request("POST", "/v1/subscriptions/test", json=body)