form4api 0.4.2__tar.gz → 0.5.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.
Files changed (29) hide show
  1. {form4api-0.4.2 → form4api-0.5.0}/PKG-INFO +19 -14
  2. {form4api-0.4.2 → form4api-0.5.0}/README.md +18 -13
  3. {form4api-0.4.2 → form4api-0.5.0}/form4api/_client.py +294 -219
  4. {form4api-0.4.2 → form4api-0.5.0}/form4api/_errors.py +47 -30
  5. form4api-0.5.0/form4api/_generated.py +1853 -0
  6. form4api-0.5.0/form4api/resources/_companies.py +51 -0
  7. form4api-0.5.0/form4api/resources/_insiders.py +90 -0
  8. form4api-0.5.0/form4api/resources/_signals.py +117 -0
  9. form4api-0.5.0/form4api/resources/_transactions.py +225 -0
  10. form4api-0.5.0/form4api/resources/_webhooks.py +56 -0
  11. {form4api-0.4.2 → form4api-0.5.0}/form4api.egg-info/PKG-INFO +19 -14
  12. {form4api-0.4.2 → form4api-0.5.0}/form4api.egg-info/SOURCES.txt +3 -1
  13. {form4api-0.4.2 → form4api-0.5.0}/pyproject.toml +1 -1
  14. {form4api-0.4.2 → form4api-0.5.0}/tests/test_client.py +367 -321
  15. form4api-0.5.0/tests/test_generated.py +389 -0
  16. form4api-0.4.2/form4api/resources/_companies.py +0 -24
  17. form4api-0.4.2/form4api/resources/_insiders.py +0 -48
  18. form4api-0.4.2/form4api/resources/_signals.py +0 -57
  19. form4api-0.4.2/form4api/resources/_transactions.py +0 -120
  20. form4api-0.4.2/form4api/resources/_webhooks.py +0 -34
  21. {form4api-0.4.2 → form4api-0.5.0}/LICENSE +0 -0
  22. {form4api-0.4.2 → form4api-0.5.0}/form4api/__init__.py +0 -0
  23. {form4api-0.4.2 → form4api-0.5.0}/form4api/_types.py +0 -0
  24. {form4api-0.4.2 → form4api-0.5.0}/form4api/_webhook_utils.py +0 -0
  25. {form4api-0.4.2 → form4api-0.5.0}/form4api/resources/__init__.py +0 -0
  26. {form4api-0.4.2 → form4api-0.5.0}/form4api.egg-info/dependency_links.txt +0 -0
  27. {form4api-0.4.2 → form4api-0.5.0}/form4api.egg-info/requires.txt +0 -0
  28. {form4api-0.4.2 → form4api-0.5.0}/form4api.egg-info/top_level.txt +0 -0
  29. {form4api-0.4.2 → form4api-0.5.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: form4api
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Python client for the Form4API — real-time SEC Form 4 insider trading data
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://www.form4api.com
@@ -82,22 +82,27 @@ asyncio.run(main())
82
82
  | Resource | Methods |
83
83
  |---|---|
84
84
  | `client.transactions` | `.list(**params)`, `.paginate(**params)` |
85
- | `client.insiders` | `.search(name, **params)`, `.get(cik)`, `.transactions(cik, **params)` |
86
- | `client.companies` | `.get(ticker)`, `.insiders(ticker)` |
87
- | `client.signals` | `.list(**params)`, `.paginate(**params)` — Business plan |
85
+ | `client.insiders` | `.search(name, **params)`, `.get(cik)`, `.list(**params)`, `.transactions(cik, **params)`, `.summary(cik)` *(Pro)*, `.scorecard(cik)` *(Pro)*, `.leaderboard(**params)` *(Business)* |
86
+ | `client.companies` | `.get(ticker)`, `.insiders(ticker)`, `.list(**params)` |
87
+ | `client.signals` | `.list(**params)`, `.paginate(**params)`, `.explain(ticker)`, `.sentiment(ticker, **params)` — Business; `.convergence(**params)` — Pro |
88
+ | `client.congress` | `.trades(**params)`, `.politicians(**params)` *(Pro)*, `.politician(id_or_slug)` *(Pro)*, `.ticker(ticker)` *(Pro)* |
89
+ | `client.form144` | `.list(**params)` — Business plan |
90
+ | `client.holdings` | `.list(**params)`, `.managers(**params)` — Business plan |
91
+ | `client.filings` | `.recent(**params)`, `.get(accession_number)` |
92
+ | `client.stats` | `.get()` — public, no key required |
93
+ | `client.data_quality` | `.get()` — public, no key required |
94
+ | `client.status` | `.history(**params)` |
88
95
  | `client.webhooks` | `.create(url, event_types)`, `.list()`, `.delete(id)`, `.events(**params)` |
89
96
 
90
- ### Not yet in this SDK
97
+ Every plan-gated endpoint the API exposes has a typed method here. Calling one
98
+ your key isn't entitled to raises `PlanError` (HTTP 402) carrying
99
+ `required_plan`, `current_plan`, and `upgrade_url` rather than failing opaquely.
91
100
 
92
- The API surface is broader than the typed client. These backend features are **available via the REST API and the `form4api-mcp` server today, but don't have a typed SDK resource yet**:
101
+ An `AsyncForm4ApiClient` mirrors the whole surface with the same resources and
102
+ method names — `await client.insiders.leaderboard()`.
93
103
 
94
- - **Form 144** notice-of-proposed-sale `GET /v1/form144` *(Business)*
95
- - **Institutional holdings (13F-HR)** — `GET /v1/holdings`, **managers** `GET /v1/managers` *(Business)*
96
- - **Sentiment** (MSPR-style, 10b5-1-clean) — `GET /v1/signals/sentiment/{ticker}` *(Business)*
97
- - **Insider career summary** — `GET /v1/insiders/{cik}/summary` *(Pro)*
98
- - **Post-trade returns** (1d/1w/1m/3m/6m) + `min_return_*` screening filters on `/v1/transactions` *(visible free; screening Pro)*
99
-
100
- Until they land in the SDK, call them directly (`client._get("/v1/holdings", {...})`) or see the [full REST reference](https://form4api.com/docs). For LLM workflows, `form4api-mcp` exposes all of the above as tools.
104
+ For the full parameter reference see the [REST docs](https://form4api.com/docs).
105
+ For LLM workflows, `form4api-mcp` exposes the same endpoints as tools.
101
106
 
102
107
  ### Transaction filters
103
108
 
@@ -181,7 +186,7 @@ client.transactions.list(min_shares=10_000, max_shares=100_000)
181
186
  ```python
182
187
  # transactions.paginate() — yields one list per page automatically
183
188
  all_txns = []
184
- for batch in client.transactions.paginate(ticker="NVDA", exclude_10b5=True, per_page=500):
189
+ for batch in client.transactions.paginate(ticker="NVDA", exclude_10b5=True, per_page=100):
185
190
  all_txns.extend(batch)
186
191
 
187
192
  # signals.paginate()
@@ -63,22 +63,27 @@ asyncio.run(main())
63
63
  | Resource | Methods |
64
64
  |---|---|
65
65
  | `client.transactions` | `.list(**params)`, `.paginate(**params)` |
66
- | `client.insiders` | `.search(name, **params)`, `.get(cik)`, `.transactions(cik, **params)` |
67
- | `client.companies` | `.get(ticker)`, `.insiders(ticker)` |
68
- | `client.signals` | `.list(**params)`, `.paginate(**params)` — Business plan |
66
+ | `client.insiders` | `.search(name, **params)`, `.get(cik)`, `.list(**params)`, `.transactions(cik, **params)`, `.summary(cik)` *(Pro)*, `.scorecard(cik)` *(Pro)*, `.leaderboard(**params)` *(Business)* |
67
+ | `client.companies` | `.get(ticker)`, `.insiders(ticker)`, `.list(**params)` |
68
+ | `client.signals` | `.list(**params)`, `.paginate(**params)`, `.explain(ticker)`, `.sentiment(ticker, **params)` — Business; `.convergence(**params)` — Pro |
69
+ | `client.congress` | `.trades(**params)`, `.politicians(**params)` *(Pro)*, `.politician(id_or_slug)` *(Pro)*, `.ticker(ticker)` *(Pro)* |
70
+ | `client.form144` | `.list(**params)` — Business plan |
71
+ | `client.holdings` | `.list(**params)`, `.managers(**params)` — Business plan |
72
+ | `client.filings` | `.recent(**params)`, `.get(accession_number)` |
73
+ | `client.stats` | `.get()` — public, no key required |
74
+ | `client.data_quality` | `.get()` — public, no key required |
75
+ | `client.status` | `.history(**params)` |
69
76
  | `client.webhooks` | `.create(url, event_types)`, `.list()`, `.delete(id)`, `.events(**params)` |
70
77
 
71
- ### Not yet in this SDK
78
+ Every plan-gated endpoint the API exposes has a typed method here. Calling one
79
+ your key isn't entitled to raises `PlanError` (HTTP 402) carrying
80
+ `required_plan`, `current_plan`, and `upgrade_url` rather than failing opaquely.
72
81
 
73
- The API surface is broader than the typed client. These backend features are **available via the REST API and the `form4api-mcp` server today, but don't have a typed SDK resource yet**:
82
+ An `AsyncForm4ApiClient` mirrors the whole surface with the same resources and
83
+ method names — `await client.insiders.leaderboard()`.
74
84
 
75
- - **Form 144** notice-of-proposed-sale `GET /v1/form144` *(Business)*
76
- - **Institutional holdings (13F-HR)** — `GET /v1/holdings`, **managers** `GET /v1/managers` *(Business)*
77
- - **Sentiment** (MSPR-style, 10b5-1-clean) — `GET /v1/signals/sentiment/{ticker}` *(Business)*
78
- - **Insider career summary** — `GET /v1/insiders/{cik}/summary` *(Pro)*
79
- - **Post-trade returns** (1d/1w/1m/3m/6m) + `min_return_*` screening filters on `/v1/transactions` *(visible free; screening Pro)*
80
-
81
- Until they land in the SDK, call them directly (`client._get("/v1/holdings", {...})`) or see the [full REST reference](https://form4api.com/docs). For LLM workflows, `form4api-mcp` exposes all of the above as tools.
85
+ For the full parameter reference see the [REST docs](https://form4api.com/docs).
86
+ For LLM workflows, `form4api-mcp` exposes the same endpoints as tools.
82
87
 
83
88
  ### Transaction filters
84
89
 
@@ -162,7 +167,7 @@ client.transactions.list(min_shares=10_000, max_shares=100_000)
162
167
  ```python
163
168
  # transactions.paginate() — yields one list per page automatically
164
169
  all_txns = []
165
- for batch in client.transactions.paginate(ticker="NVDA", exclude_10b5=True, per_page=500):
170
+ for batch in client.transactions.paginate(ticker="NVDA", exclude_10b5=True, per_page=100):
166
171
  all_txns.extend(batch)
167
172
 
168
173
  # signals.paginate()
@@ -1,219 +1,294 @@
1
- from __future__ import annotations
2
-
3
- import asyncio
4
- import re
5
- import time
6
- from typing import Any, TypeVar
7
-
8
- import httpx
9
-
10
- from form4api._errors import AuthError, Form4ApiError, NotFoundError, PlanError, RateLimitError
11
- from form4api.resources._companies import CompaniesResource
12
- from form4api.resources._insiders import InsidersResource
13
- from form4api.resources._signals import SignalsResource
14
- from form4api.resources._transactions import TransactionsResource
15
- from form4api.resources._webhooks import WebhooksResource
16
-
17
- DEFAULT_BASE_URL = "https://api.form4api.com"
18
- _RETRY_DELAYS = [0.5, 1.0, 2.0]
19
-
20
- T = TypeVar("T")
21
-
22
-
23
- def _camel_to_snake(name: str) -> str:
24
- s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
25
- return re.sub(r"([a-z\d])([A-Z])", r"\1_\2", s).lower()
26
-
27
-
28
- def _normalise(obj: Any) -> Any:
29
- if isinstance(obj, dict):
30
- return {_camel_to_snake(k): _normalise(v) for k, v in obj.items()}
31
- if isinstance(obj, list):
32
- return [_normalise(i) for i in obj]
33
- return obj
34
-
35
-
36
- class Form4ApiClient:
37
- """Synchronous client for the Form4API."""
38
-
39
- def __init__(
40
- self,
41
- api_key: str,
42
- *,
43
- base_url: str = DEFAULT_BASE_URL,
44
- max_retries: int = 2,
45
- timeout: float = 30.0,
46
- ) -> None:
47
- self._api_key = api_key
48
- self._base_url = base_url.rstrip("/")
49
- self._max_retries = max_retries
50
- self._http = httpx.Client(
51
- timeout=timeout,
52
- headers={"X-Api-Key": api_key},
53
- )
54
- self.transactions = TransactionsResource(self)
55
- self.insiders = InsidersResource(self)
56
- self.companies = CompaniesResource(self)
57
- self.signals = SignalsResource(self)
58
- self.webhooks = WebhooksResource(self)
59
-
60
- def __enter__(self) -> Form4ApiClient:
61
- return self
62
-
63
- def __exit__(self, *_: object) -> None:
64
- self.close()
65
-
66
- def close(self) -> None:
67
- self._http.close()
68
-
69
- def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
70
- url = self._base_url + path
71
- last_exc: Exception | None = None
72
-
73
- for attempt in range(self._max_retries + 1):
74
- if attempt > 0:
75
- time.sleep(_RETRY_DELAYS[min(attempt - 1, len(_RETRY_DELAYS) - 1)])
76
- try:
77
- res = self._http.request(method, url, **kwargs)
78
- if res.status_code < 500:
79
- return res
80
- if attempt == self._max_retries:
81
- return res
82
- last_exc = None
83
- except httpx.TransportError as exc:
84
- if attempt == self._max_retries:
85
- raise
86
- last_exc = exc
87
-
88
- raise last_exc # type: ignore[misc]
89
-
90
- def _get(self, path: str, params: dict[str, str] | None = None) -> Any:
91
- res = self._request("GET", path, params=params)
92
- return _normalise(self._parse(res))
93
-
94
- def _post(self, path: str, body: Any = None) -> Any:
95
- res = self._request("POST", path, json=body)
96
- return _normalise(self._parse(res))
97
-
98
- def _delete(self, path: str) -> None:
99
- res = self._request("DELETE", path)
100
- if not res.is_success and res.status_code != 204:
101
- self._raise(res)
102
-
103
- def _parse(self, res: httpx.Response) -> Any:
104
- if res.is_success:
105
- return res.json()
106
- self._raise(res)
107
-
108
- def _raise(self, res: httpx.Response) -> None:
109
- try:
110
- body = res.json()
111
- except Exception:
112
- body = {}
113
- error = body.get("error", {}) if isinstance(body, dict) else {}
114
- code = error.get("code") if isinstance(error, dict) else None
115
- message = (error.get("message") if isinstance(error, dict) else None) or f"HTTP {res.status_code}"
116
-
117
- if res.status_code == 401:
118
- raise AuthError(message, code)
119
- if res.status_code == 402:
120
- raise PlanError(message, body.get("requiredPlan") if isinstance(body, dict) else None)
121
- if res.status_code == 404:
122
- raise NotFoundError(message, code)
123
- if res.status_code == 429:
124
- retry_after = res.headers.get("Retry-After")
125
- raise RateLimitError(message, int(retry_after) if retry_after else None)
126
- raise Form4ApiError(message, res.status_code, code)
127
-
128
-
129
- class AsyncForm4ApiClient:
130
- """Async client for the Form4API."""
131
-
132
- def __init__(
133
- self,
134
- api_key: str,
135
- *,
136
- base_url: str = DEFAULT_BASE_URL,
137
- max_retries: int = 2,
138
- timeout: float = 30.0,
139
- ) -> None:
140
- self._api_key = api_key
141
- self._base_url = base_url.rstrip("/")
142
- self._max_retries = max_retries
143
- self._http = httpx.AsyncClient(
144
- timeout=timeout,
145
- headers={"X-Api-Key": api_key},
146
- )
147
- self.transactions = TransactionsResource(self) # type: ignore[arg-type]
148
- self.insiders = InsidersResource(self) # type: ignore[arg-type]
149
- self.companies = CompaniesResource(self) # type: ignore[arg-type]
150
- self.signals = SignalsResource(self) # type: ignore[arg-type]
151
- self.webhooks = WebhooksResource(self) # type: ignore[arg-type]
152
-
153
- async def __aenter__(self) -> AsyncForm4ApiClient:
154
- return self
155
-
156
- async def __aexit__(self, *_: object) -> None:
157
- await self.close()
158
-
159
- async def close(self) -> None:
160
- await self._http.aclose()
161
-
162
- async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
163
- url = self._base_url + path
164
- last_exc: Exception | None = None
165
-
166
- for attempt in range(self._max_retries + 1):
167
- if attempt > 0:
168
- await asyncio.sleep(_RETRY_DELAYS[min(attempt - 1, len(_RETRY_DELAYS) - 1)])
169
- try:
170
- res = await self._http.request(method, url, **kwargs)
171
- if res.status_code < 500:
172
- return res
173
- if attempt == self._max_retries:
174
- return res
175
- last_exc = None
176
- except httpx.TransportError as exc:
177
- if attempt == self._max_retries:
178
- raise
179
- last_exc = exc
180
-
181
- raise last_exc # type: ignore[misc]
182
-
183
- async def _get(self, path: str, params: dict[str, str] | None = None) -> Any:
184
- res = await self._request("GET", path, params=params)
185
- return _normalise(self._parse(res))
186
-
187
- async def _post(self, path: str, body: Any = None) -> Any:
188
- res = await self._request("POST", path, json=body)
189
- return _normalise(self._parse(res))
190
-
191
- async def _delete(self, path: str) -> None:
192
- res = await self._request("DELETE", path)
193
- if not res.is_success and res.status_code != 204:
194
- self._raise(res)
195
-
196
- def _parse(self, res: httpx.Response) -> Any:
197
- if res.is_success:
198
- return res.json()
199
- self._raise(res)
200
-
201
- def _raise(self, res: httpx.Response) -> None:
202
- try:
203
- body = res.json()
204
- except Exception:
205
- body = {}
206
- error = body.get("error", {}) if isinstance(body, dict) else {}
207
- code = error.get("code") if isinstance(error, dict) else None
208
- message = (error.get("message") if isinstance(error, dict) else None) or f"HTTP {res.status_code}"
209
-
210
- if res.status_code == 401:
211
- raise AuthError(message, code)
212
- if res.status_code == 402:
213
- raise PlanError(message, body.get("requiredPlan") if isinstance(body, dict) else None)
214
- if res.status_code == 404:
215
- raise NotFoundError(message, code)
216
- if res.status_code == 429:
217
- retry_after = res.headers.get("Retry-After")
218
- raise RateLimitError(message, int(retry_after) if retry_after else None)
219
- raise Form4ApiError(message, res.status_code, code)
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import re
5
+ import time
6
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
7
+ from typing import Any, TypeVar
8
+
9
+ import httpx
10
+
11
+ from form4api._errors import AuthError, Form4ApiError, NotFoundError, PlanError, RateLimitError
12
+
13
+ # Sent as the User-Agent so the backend can attribute traffic to the Python SDK
14
+ # channel (the admin dashboard buckets by client). Read from installed package
15
+ # metadata so it never drifts from the pyproject version.
16
+ try:
17
+ _SDK_VERSION = _pkg_version("form4api")
18
+ except PackageNotFoundError: # not installed (e.g. running from a source tree)
19
+ _SDK_VERSION = "0.0.0"
20
+ _USER_AGENT = f"form4api-py/{_SDK_VERSION}"
21
+ from form4api.resources._companies import AsyncCompaniesResource, CompaniesResource
22
+ from form4api.resources._insiders import AsyncInsidersResource, InsidersResource
23
+ from form4api.resources._signals import AsyncSignalsResource, SignalsResource
24
+ from form4api.resources._transactions import AsyncTransactionsResource, TransactionsResource
25
+ from form4api.resources._webhooks import AsyncWebhooksResource, WebhooksResource
26
+
27
+ # Spec-derived families with no hand-written wrapper. Before these, all 6
28
+ # Pro-gated and 9 of the 10 Business-gated endpoints were unreachable from this
29
+ # SDK — a Business customer could not call Form 144 or 13F holdings at all.
30
+ # Sync and async variants are generated separately; see the note on
31
+ # AsyncForm4ApiClient below for why that matters here but not in the JS SDK.
32
+ from form4api._generated import (
33
+ GeneratedAsyncCongressResource,
34
+ GeneratedAsyncDataQualityResource,
35
+ GeneratedAsyncFilingsResource,
36
+ GeneratedAsyncForm144Resource,
37
+ GeneratedAsyncHoldingsResource,
38
+ GeneratedAsyncStatsResource,
39
+ GeneratedAsyncStatusResource,
40
+ GeneratedCongressResource,
41
+ GeneratedDataQualityResource,
42
+ GeneratedFilingsResource,
43
+ GeneratedForm144Resource,
44
+ GeneratedHoldingsResource,
45
+ GeneratedStatsResource,
46
+ GeneratedStatusResource,
47
+ )
48
+
49
+ DEFAULT_BASE_URL = "https://api.form4api.com"
50
+ _RETRY_DELAYS = [0.5, 1.0, 2.0]
51
+
52
+ T = TypeVar("T")
53
+
54
+
55
+ def _camel_to_snake(name: str) -> str:
56
+ s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
57
+ return re.sub(r"([a-z\d])([A-Z])", r"\1_\2", s).lower()
58
+
59
+
60
+ def _normalise(obj: Any) -> Any:
61
+ if isinstance(obj, dict):
62
+ return {_camel_to_snake(k): _normalise(v) for k, v in obj.items()}
63
+ if isinstance(obj, list):
64
+ return [_normalise(i) for i in obj]
65
+ return obj
66
+
67
+
68
+ class Form4ApiClient:
69
+ """Synchronous client for the Form4API."""
70
+
71
+ def __init__(
72
+ self,
73
+ api_key: str,
74
+ *,
75
+ base_url: str = DEFAULT_BASE_URL,
76
+ max_retries: int = 2,
77
+ timeout: float = 30.0,
78
+ ) -> None:
79
+ self._api_key = api_key
80
+ self._base_url = base_url.rstrip("/")
81
+ self._max_retries = max_retries
82
+ self._http = httpx.Client(
83
+ timeout=timeout,
84
+ headers={"X-Api-Key": api_key, "User-Agent": _USER_AGENT},
85
+ )
86
+ self.transactions = TransactionsResource(self)
87
+ self.insiders = InsidersResource(self)
88
+ self.companies = CompaniesResource(self)
89
+ self.signals = SignalsResource(self)
90
+ self.webhooks = WebhooksResource(self)
91
+ self.congress = GeneratedCongressResource(self)
92
+ self.filings = GeneratedFilingsResource(self)
93
+ self.form144 = GeneratedForm144Resource(self)
94
+ self.holdings = GeneratedHoldingsResource(self)
95
+ self.stats = GeneratedStatsResource(self)
96
+ self.status = GeneratedStatusResource(self)
97
+ self.data_quality = GeneratedDataQualityResource(self)
98
+
99
+ def __enter__(self) -> Form4ApiClient:
100
+ return self
101
+
102
+ def __exit__(self, *_: object) -> None:
103
+ self.close()
104
+
105
+ def close(self) -> None:
106
+ self._http.close()
107
+
108
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
109
+ url = self._base_url + path
110
+ last_exc: Exception | None = None
111
+
112
+ for attempt in range(self._max_retries + 1):
113
+ if attempt > 0:
114
+ time.sleep(_RETRY_DELAYS[min(attempt - 1, len(_RETRY_DELAYS) - 1)])
115
+ try:
116
+ res = self._http.request(method, url, **kwargs)
117
+ if res.status_code < 500:
118
+ return res
119
+ if attempt == self._max_retries:
120
+ return res
121
+ last_exc = None
122
+ except httpx.TransportError as exc:
123
+ if attempt == self._max_retries:
124
+ raise
125
+ last_exc = exc
126
+
127
+ raise last_exc # type: ignore[misc]
128
+
129
+ def _get(self, path: str, params: dict[str, str] | None = None) -> Any:
130
+ res = self._request("GET", path, params=params)
131
+ return _normalise(self._parse(res))
132
+
133
+ def _post(self, path: str, body: Any = None) -> Any:
134
+ res = self._request("POST", path, json=body)
135
+ return _normalise(self._parse(res))
136
+
137
+ def _delete(self, path: str) -> None:
138
+ res = self._request("DELETE", path)
139
+ if not res.is_success and res.status_code != 204:
140
+ self._raise(res)
141
+
142
+ def _parse(self, res: httpx.Response) -> Any:
143
+ if res.is_success:
144
+ return res.json()
145
+ self._raise(res)
146
+
147
+ def _raise(self, res: httpx.Response) -> None:
148
+ try:
149
+ body = res.json()
150
+ except Exception:
151
+ body = {}
152
+ error = body.get("error", {}) if isinstance(body, dict) else {}
153
+ code = error.get("code") if isinstance(error, dict) else None
154
+ message = (error.get("message") if isinstance(error, dict) else None) or f"HTTP {res.status_code}"
155
+
156
+ if res.status_code == 401:
157
+ raise AuthError(message, code)
158
+ if res.status_code == 402:
159
+ # Read from the nested `error` object, not the top level. This was
160
+ # reading body["requiredPlan"], which the API has never emitted at
161
+ # any level, so PlanError.required_plan was permanently None. The
162
+ # backend now returns requiredPlan/currentPlan/upgradeUrl inside the
163
+ # standard error envelope.
164
+ raise PlanError(
165
+ message,
166
+ error.get("requiredPlan") if isinstance(error, dict) else None,
167
+ error.get("currentPlan") if isinstance(error, dict) else None,
168
+ error.get("upgradeUrl") if isinstance(error, dict) else None,
169
+ )
170
+ if res.status_code == 404:
171
+ raise NotFoundError(message, code)
172
+ if res.status_code == 429:
173
+ retry_after = res.headers.get("Retry-After")
174
+ raise RateLimitError(message, int(retry_after) if retry_after else None)
175
+ raise Form4ApiError(message, res.status_code, code)
176
+
177
+
178
+ class AsyncForm4ApiClient:
179
+ """Async client for the Form4API."""
180
+
181
+ def __init__(
182
+ self,
183
+ api_key: str,
184
+ *,
185
+ base_url: str = DEFAULT_BASE_URL,
186
+ max_retries: int = 2,
187
+ timeout: float = 30.0,
188
+ ) -> None:
189
+ self._api_key = api_key
190
+ self._base_url = base_url.rstrip("/")
191
+ self._max_retries = max_retries
192
+ self._http = httpx.AsyncClient(
193
+ timeout=timeout,
194
+ headers={"X-Api-Key": api_key, "User-Agent": _USER_AGENT},
195
+ )
196
+ # Async twins, not the sync classes. Until 2026-08-06 this client reused
197
+ # the sync resources behind `# type: ignore[arg-type]`; because _get here
198
+ # is a coroutine, `Dataclass(**data)` was handed a coroutine and raised
199
+ # "argument after ** must be a mapping, not coroutine" on EVERY call, so
200
+ # every hand-written resource method was broken on the async client and
201
+ # always had been. There were no async tests, and the type: ignore
202
+ # suppressed the exact error that would have caught it. Both are fixed:
203
+ # real async classes, plus async coverage for each of them.
204
+ self.transactions = AsyncTransactionsResource(self)
205
+ self.insiders = AsyncInsidersResource(self)
206
+ self.companies = AsyncCompaniesResource(self)
207
+ self.signals = AsyncSignalsResource(self)
208
+ self.webhooks = AsyncWebhooksResource(self)
209
+ # Spec-derived families, async variants.
210
+ self.congress = GeneratedAsyncCongressResource(self)
211
+ self.filings = GeneratedAsyncFilingsResource(self)
212
+ self.form144 = GeneratedAsyncForm144Resource(self)
213
+ self.holdings = GeneratedAsyncHoldingsResource(self)
214
+ self.stats = GeneratedAsyncStatsResource(self)
215
+ self.status = GeneratedAsyncStatusResource(self)
216
+ self.data_quality = GeneratedAsyncDataQualityResource(self)
217
+
218
+ async def __aenter__(self) -> AsyncForm4ApiClient:
219
+ return self
220
+
221
+ async def __aexit__(self, *_: object) -> None:
222
+ await self.close()
223
+
224
+ async def close(self) -> None:
225
+ await self._http.aclose()
226
+
227
+ async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
228
+ url = self._base_url + path
229
+ last_exc: Exception | None = None
230
+
231
+ for attempt in range(self._max_retries + 1):
232
+ if attempt > 0:
233
+ await asyncio.sleep(_RETRY_DELAYS[min(attempt - 1, len(_RETRY_DELAYS) - 1)])
234
+ try:
235
+ res = await self._http.request(method, url, **kwargs)
236
+ if res.status_code < 500:
237
+ return res
238
+ if attempt == self._max_retries:
239
+ return res
240
+ last_exc = None
241
+ except httpx.TransportError as exc:
242
+ if attempt == self._max_retries:
243
+ raise
244
+ last_exc = exc
245
+
246
+ raise last_exc # type: ignore[misc]
247
+
248
+ async def _get(self, path: str, params: dict[str, str] | None = None) -> Any:
249
+ res = await self._request("GET", path, params=params)
250
+ return _normalise(self._parse(res))
251
+
252
+ async def _post(self, path: str, body: Any = None) -> Any:
253
+ res = await self._request("POST", path, json=body)
254
+ return _normalise(self._parse(res))
255
+
256
+ async def _delete(self, path: str) -> None:
257
+ res = await self._request("DELETE", path)
258
+ if not res.is_success and res.status_code != 204:
259
+ self._raise(res)
260
+
261
+ def _parse(self, res: httpx.Response) -> Any:
262
+ if res.is_success:
263
+ return res.json()
264
+ self._raise(res)
265
+
266
+ def _raise(self, res: httpx.Response) -> None:
267
+ try:
268
+ body = res.json()
269
+ except Exception:
270
+ body = {}
271
+ error = body.get("error", {}) if isinstance(body, dict) else {}
272
+ code = error.get("code") if isinstance(error, dict) else None
273
+ message = (error.get("message") if isinstance(error, dict) else None) or f"HTTP {res.status_code}"
274
+
275
+ if res.status_code == 401:
276
+ raise AuthError(message, code)
277
+ if res.status_code == 402:
278
+ # Read from the nested `error` object, not the top level. This was
279
+ # reading body["requiredPlan"], which the API has never emitted at
280
+ # any level, so PlanError.required_plan was permanently None. The
281
+ # backend now returns requiredPlan/currentPlan/upgradeUrl inside the
282
+ # standard error envelope.
283
+ raise PlanError(
284
+ message,
285
+ error.get("requiredPlan") if isinstance(error, dict) else None,
286
+ error.get("currentPlan") if isinstance(error, dict) else None,
287
+ error.get("upgradeUrl") if isinstance(error, dict) else None,
288
+ )
289
+ if res.status_code == 404:
290
+ raise NotFoundError(message, code)
291
+ if res.status_code == 429:
292
+ retry_after = res.headers.get("Retry-After")
293
+ raise RateLimitError(message, int(retry_after) if retry_after else None)
294
+ raise Form4ApiError(message, res.status_code, code)