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 +51 -0
- firmendata/_base.py +97 -0
- firmendata/_retry.py +72 -0
- firmendata/_version.py +1 -0
- firmendata/async_client.py +229 -0
- firmendata/client.py +265 -0
- firmendata/errors.py +193 -0
- firmendata/params.py +98 -0
- firmendata/py.typed +0 -0
- firmendata/types.py +1003 -0
- firmendata-0.1.0.dist-info/METADATA +195 -0
- firmendata-0.1.0.dist-info/RECORD +14 -0
- firmendata-0.1.0.dist-info/WHEEL +4 -0
- firmendata-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|