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.
- {form4api-0.4.2 → form4api-0.5.0}/PKG-INFO +19 -14
- {form4api-0.4.2 → form4api-0.5.0}/README.md +18 -13
- {form4api-0.4.2 → form4api-0.5.0}/form4api/_client.py +294 -219
- {form4api-0.4.2 → form4api-0.5.0}/form4api/_errors.py +47 -30
- form4api-0.5.0/form4api/_generated.py +1853 -0
- form4api-0.5.0/form4api/resources/_companies.py +51 -0
- form4api-0.5.0/form4api/resources/_insiders.py +90 -0
- form4api-0.5.0/form4api/resources/_signals.py +117 -0
- form4api-0.5.0/form4api/resources/_transactions.py +225 -0
- form4api-0.5.0/form4api/resources/_webhooks.py +56 -0
- {form4api-0.4.2 → form4api-0.5.0}/form4api.egg-info/PKG-INFO +19 -14
- {form4api-0.4.2 → form4api-0.5.0}/form4api.egg-info/SOURCES.txt +3 -1
- {form4api-0.4.2 → form4api-0.5.0}/pyproject.toml +1 -1
- {form4api-0.4.2 → form4api-0.5.0}/tests/test_client.py +367 -321
- form4api-0.5.0/tests/test_generated.py +389 -0
- form4api-0.4.2/form4api/resources/_companies.py +0 -24
- form4api-0.4.2/form4api/resources/_insiders.py +0 -48
- form4api-0.4.2/form4api/resources/_signals.py +0 -57
- form4api-0.4.2/form4api/resources/_transactions.py +0 -120
- form4api-0.4.2/form4api/resources/_webhooks.py +0 -34
- {form4api-0.4.2 → form4api-0.5.0}/LICENSE +0 -0
- {form4api-0.4.2 → form4api-0.5.0}/form4api/__init__.py +0 -0
- {form4api-0.4.2 → form4api-0.5.0}/form4api/_types.py +0 -0
- {form4api-0.4.2 → form4api-0.5.0}/form4api/_webhook_utils.py +0 -0
- {form4api-0.4.2 → form4api-0.5.0}/form4api/resources/__init__.py +0 -0
- {form4api-0.4.2 → form4api-0.5.0}/form4api.egg-info/dependency_links.txt +0 -0
- {form4api-0.4.2 → form4api-0.5.0}/form4api.egg-info/requires.txt +0 -0
- {form4api-0.4.2 → form4api-0.5.0}/form4api.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
101
|
+
An `AsyncForm4ApiClient` mirrors the whole surface with the same resources and
|
|
102
|
+
method names — `await client.insiders.leaderboard()`.
|
|
93
103
|
|
|
94
|
-
|
|
95
|
-
|
|
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=
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
82
|
+
An `AsyncForm4ApiClient` mirrors the whole surface with the same resources and
|
|
83
|
+
method names — `await client.insiders.leaderboard()`.
|
|
74
84
|
|
|
75
|
-
|
|
76
|
-
|
|
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=
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
from form4api.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
from
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
self.
|
|
107
|
-
|
|
108
|
-
def
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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)
|