form4api 0.4.2__tar.gz → 0.4.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: form4api
3
- Version: 0.4.2
3
+ Version: 0.4.3
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
@@ -1,219 +1,229 @@
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 CompaniesResource
22
+ from form4api.resources._insiders import InsidersResource
23
+ from form4api.resources._signals import SignalsResource
24
+ from form4api.resources._transactions import TransactionsResource
25
+ from form4api.resources._webhooks import WebhooksResource
26
+
27
+ DEFAULT_BASE_URL = "https://api.form4api.com"
28
+ _RETRY_DELAYS = [0.5, 1.0, 2.0]
29
+
30
+ T = TypeVar("T")
31
+
32
+
33
+ def _camel_to_snake(name: str) -> str:
34
+ s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
35
+ return re.sub(r"([a-z\d])([A-Z])", r"\1_\2", s).lower()
36
+
37
+
38
+ def _normalise(obj: Any) -> Any:
39
+ if isinstance(obj, dict):
40
+ return {_camel_to_snake(k): _normalise(v) for k, v in obj.items()}
41
+ if isinstance(obj, list):
42
+ return [_normalise(i) for i in obj]
43
+ return obj
44
+
45
+
46
+ class Form4ApiClient:
47
+ """Synchronous client for the Form4API."""
48
+
49
+ def __init__(
50
+ self,
51
+ api_key: str,
52
+ *,
53
+ base_url: str = DEFAULT_BASE_URL,
54
+ max_retries: int = 2,
55
+ timeout: float = 30.0,
56
+ ) -> None:
57
+ self._api_key = api_key
58
+ self._base_url = base_url.rstrip("/")
59
+ self._max_retries = max_retries
60
+ self._http = httpx.Client(
61
+ timeout=timeout,
62
+ headers={"X-Api-Key": api_key, "User-Agent": _USER_AGENT},
63
+ )
64
+ self.transactions = TransactionsResource(self)
65
+ self.insiders = InsidersResource(self)
66
+ self.companies = CompaniesResource(self)
67
+ self.signals = SignalsResource(self)
68
+ self.webhooks = WebhooksResource(self)
69
+
70
+ def __enter__(self) -> Form4ApiClient:
71
+ return self
72
+
73
+ def __exit__(self, *_: object) -> None:
74
+ self.close()
75
+
76
+ def close(self) -> None:
77
+ self._http.close()
78
+
79
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
80
+ url = self._base_url + path
81
+ last_exc: Exception | None = None
82
+
83
+ for attempt in range(self._max_retries + 1):
84
+ if attempt > 0:
85
+ time.sleep(_RETRY_DELAYS[min(attempt - 1, len(_RETRY_DELAYS) - 1)])
86
+ try:
87
+ res = self._http.request(method, url, **kwargs)
88
+ if res.status_code < 500:
89
+ return res
90
+ if attempt == self._max_retries:
91
+ return res
92
+ last_exc = None
93
+ except httpx.TransportError as exc:
94
+ if attempt == self._max_retries:
95
+ raise
96
+ last_exc = exc
97
+
98
+ raise last_exc # type: ignore[misc]
99
+
100
+ def _get(self, path: str, params: dict[str, str] | None = None) -> Any:
101
+ res = self._request("GET", path, params=params)
102
+ return _normalise(self._parse(res))
103
+
104
+ def _post(self, path: str, body: Any = None) -> Any:
105
+ res = self._request("POST", path, json=body)
106
+ return _normalise(self._parse(res))
107
+
108
+ def _delete(self, path: str) -> None:
109
+ res = self._request("DELETE", path)
110
+ if not res.is_success and res.status_code != 204:
111
+ self._raise(res)
112
+
113
+ def _parse(self, res: httpx.Response) -> Any:
114
+ if res.is_success:
115
+ return res.json()
116
+ self._raise(res)
117
+
118
+ def _raise(self, res: httpx.Response) -> None:
119
+ try:
120
+ body = res.json()
121
+ except Exception:
122
+ body = {}
123
+ error = body.get("error", {}) if isinstance(body, dict) else {}
124
+ code = error.get("code") if isinstance(error, dict) else None
125
+ message = (error.get("message") if isinstance(error, dict) else None) or f"HTTP {res.status_code}"
126
+
127
+ if res.status_code == 401:
128
+ raise AuthError(message, code)
129
+ if res.status_code == 402:
130
+ raise PlanError(message, body.get("requiredPlan") if isinstance(body, dict) else None)
131
+ if res.status_code == 404:
132
+ raise NotFoundError(message, code)
133
+ if res.status_code == 429:
134
+ retry_after = res.headers.get("Retry-After")
135
+ raise RateLimitError(message, int(retry_after) if retry_after else None)
136
+ raise Form4ApiError(message, res.status_code, code)
137
+
138
+
139
+ class AsyncForm4ApiClient:
140
+ """Async client for the Form4API."""
141
+
142
+ def __init__(
143
+ self,
144
+ api_key: str,
145
+ *,
146
+ base_url: str = DEFAULT_BASE_URL,
147
+ max_retries: int = 2,
148
+ timeout: float = 30.0,
149
+ ) -> None:
150
+ self._api_key = api_key
151
+ self._base_url = base_url.rstrip("/")
152
+ self._max_retries = max_retries
153
+ self._http = httpx.AsyncClient(
154
+ timeout=timeout,
155
+ headers={"X-Api-Key": api_key, "User-Agent": _USER_AGENT},
156
+ )
157
+ self.transactions = TransactionsResource(self) # type: ignore[arg-type]
158
+ self.insiders = InsidersResource(self) # type: ignore[arg-type]
159
+ self.companies = CompaniesResource(self) # type: ignore[arg-type]
160
+ self.signals = SignalsResource(self) # type: ignore[arg-type]
161
+ self.webhooks = WebhooksResource(self) # type: ignore[arg-type]
162
+
163
+ async def __aenter__(self) -> AsyncForm4ApiClient:
164
+ return self
165
+
166
+ async def __aexit__(self, *_: object) -> None:
167
+ await self.close()
168
+
169
+ async def close(self) -> None:
170
+ await self._http.aclose()
171
+
172
+ async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
173
+ url = self._base_url + path
174
+ last_exc: Exception | None = None
175
+
176
+ for attempt in range(self._max_retries + 1):
177
+ if attempt > 0:
178
+ await asyncio.sleep(_RETRY_DELAYS[min(attempt - 1, len(_RETRY_DELAYS) - 1)])
179
+ try:
180
+ res = await self._http.request(method, url, **kwargs)
181
+ if res.status_code < 500:
182
+ return res
183
+ if attempt == self._max_retries:
184
+ return res
185
+ last_exc = None
186
+ except httpx.TransportError as exc:
187
+ if attempt == self._max_retries:
188
+ raise
189
+ last_exc = exc
190
+
191
+ raise last_exc # type: ignore[misc]
192
+
193
+ async def _get(self, path: str, params: dict[str, str] | None = None) -> Any:
194
+ res = await self._request("GET", path, params=params)
195
+ return _normalise(self._parse(res))
196
+
197
+ async def _post(self, path: str, body: Any = None) -> Any:
198
+ res = await self._request("POST", path, json=body)
199
+ return _normalise(self._parse(res))
200
+
201
+ async def _delete(self, path: str) -> None:
202
+ res = await self._request("DELETE", path)
203
+ if not res.is_success and res.status_code != 204:
204
+ self._raise(res)
205
+
206
+ def _parse(self, res: httpx.Response) -> Any:
207
+ if res.is_success:
208
+ return res.json()
209
+ self._raise(res)
210
+
211
+ def _raise(self, res: httpx.Response) -> None:
212
+ try:
213
+ body = res.json()
214
+ except Exception:
215
+ body = {}
216
+ error = body.get("error", {}) if isinstance(body, dict) else {}
217
+ code = error.get("code") if isinstance(error, dict) else None
218
+ message = (error.get("message") if isinstance(error, dict) else None) or f"HTTP {res.status_code}"
219
+
220
+ if res.status_code == 401:
221
+ raise AuthError(message, code)
222
+ if res.status_code == 402:
223
+ raise PlanError(message, body.get("requiredPlan") if isinstance(body, dict) else None)
224
+ if res.status_code == 404:
225
+ raise NotFoundError(message, code)
226
+ if res.status_code == 429:
227
+ retry_after = res.headers.get("Retry-After")
228
+ raise RateLimitError(message, int(retry_after) if retry_after else None)
229
+ raise Form4ApiError(message, res.status_code, code)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: form4api
3
- Version: 0.4.2
3
+ Version: 0.4.3
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "form4api"
7
- version = "0.4.2"
7
+ version = "0.4.3"
8
8
  description = "Python client for the Form4API — real-time SEC Form 4 insider trading data"
9
9
  keywords = ["insider-trading", "sec", "sec-edgar", "edgar", "form-4", "form4", "form-144", "13f", "13f-hr", "financial-data", "stock-market", "stocks", "fintech", "api", "sdk", "webhooks", "form4api"]
10
10
  requires-python = ">=3.11"
@@ -1,321 +1,335 @@
1
- import pytest
2
- import httpx
3
- import respx
4
-
5
- from form4api import (
6
- Form4ApiClient,
7
- AuthError,
8
- NotFoundError,
9
- PlanError,
10
- RateLimitError,
11
- Form4ApiError,
12
- Transaction,
13
- Insider,
14
- Company,
15
- InsiderSignal,
16
- verify_webhook,
17
- )
18
-
19
- BASE = "https://api.form4api.com"
20
-
21
- TX = {
22
- "ticker": "AAPL",
23
- "companyName": "Apple Inc.",
24
- "insiderName": "Cook Timothy D",
25
- "insiderCik": "0001214156",
26
- "insiderTitle": "Chief Executive Officer",
27
- "isDirector": False,
28
- "isOfficer": True,
29
- "is10PctOwner": False,
30
- "accessionNumber": "0001234567-26-000001",
31
- "securityTitle": "Common Stock",
32
- "transactionCode": "P",
33
- "isOpenMarket": True,
34
- "is10b5Plan": False,
35
- "sharesAmount": 1000.0,
36
- "pricePerShare": 212.45,
37
- "totalValue": 212450.0,
38
- "sharesOwnedAfter": 5000.0,
39
- "directIndirect": "D",
40
- "isDerivative": False,
41
- "transactionDate": "2026-01-15T00:00:00Z",
42
- "periodOfReport": "2026-01-15T00:00:00Z",
43
- }
44
-
45
- INSIDER = {
46
- "cik": "0001214156",
47
- "name": "Cook Timothy D",
48
- "isDirector": False,
49
- "isOfficer": True,
50
- "isTenPercentOwner": False,
51
- "officerTitle": "CEO",
52
- "totalFilings": 42,
53
- }
54
-
55
- COMPANY = {
56
- "cik": "0000320193",
57
- "name": "Apple Inc.",
58
- "ticker": "AAPL",
59
- "exchange": "NASDAQ",
60
- "totalFilings": 100,
61
- "activeInsiders": 12,
62
- "sicDescription": "Electronic Computers",
63
- "stateOfIncorporation": "CA",
64
- "website": None,
65
- }
66
-
67
- SIGNAL = {
68
- "ticker": "AAPL",
69
- "companyName": "Apple Inc.",
70
- "signalDate": "2026-01-15",
71
- "buySellRatio": 2.5,
72
- "isClusterBuy": True,
73
- "isClusterSell": False,
74
- "insiderCount": 4,
75
- }
76
-
77
-
78
- @pytest.fixture
79
- def client():
80
- with Form4ApiClient("test-key", max_retries=0) as c:
81
- yield c
82
-
83
-
84
- # ── transactions ───────────────────────────────────────────────────────────────
85
-
86
- @respx.mock
87
- def test_transactions_list_returns_typed_objects(client):
88
- respx.get(f"{BASE}/v1/transactions").mock(return_value=httpx.Response(200, json=[TX]))
89
- results = client.transactions.list()
90
- assert len(results) == 1
91
- assert isinstance(results[0], Transaction)
92
- assert results[0].ticker == "AAPL"
93
- assert results[0].company_name == "Apple Inc."
94
- assert results[0].insider_cik == "0001214156"
95
- assert results[0].transaction_code == "P"
96
-
97
-
98
- @respx.mock
99
- def test_transactions_list_sends_filters(client):
100
- route = respx.get(f"{BASE}/v1/transactions").mock(return_value=httpx.Response(200, json=[]))
101
- client.transactions.list(ticker="AAPL", code="P", from_date="2026-01-01", per_page=10)
102
- assert route.called
103
- qs = dict(route.calls[0].request.url.params)
104
- assert qs["ticker"] == "AAPL"
105
- assert qs["code"] == "P"
106
- assert qs["from"] == "2026-01-01"
107
- assert qs["per_page"] == "10"
108
-
109
-
110
- @respx.mock
111
- def test_transactions_list_sends_granular_filters(client):
112
- route = respx.get(f"{BASE}/v1/transactions").mock(return_value=httpx.Response(200, json=[]))
113
- client.transactions.list(
114
- codes="P,S",
115
- exclude_codes="A,M",
116
- category="open_market",
117
- exclude_category="derivatives",
118
- exclude_derivative=True,
119
- significant=True,
120
- min_value=100000,
121
- max_value=5000000,
122
- min_shares=100,
123
- max_shares=10000,
124
- )
125
- assert route.called
126
- qs = dict(route.calls[0].request.url.params)
127
- assert qs["codes"] == "P,S"
128
- assert qs["exclude_codes"] == "A,M"
129
- assert qs["category"] == "open_market"
130
- assert qs["exclude_category"] == "derivatives"
131
- assert qs["exclude_derivative"] == "true"
132
- assert qs["significant"] == "true"
133
- assert qs["min_value"] == "100000"
134
- assert qs["max_value"] == "5000000"
135
- assert qs["min_shares"] == "100"
136
- assert qs["max_shares"] == "10000"
137
-
138
-
139
- @respx.mock
140
- def test_transactions_paginate_stops_on_short_page(client):
141
- respx.get(f"{BASE}/v1/transactions").mock(side_effect=[
142
- httpx.Response(200, json=[TX]),
143
- httpx.Response(200, json=[]),
144
- ])
145
- pages = list(client.transactions.paginate(per_page=1))
146
- assert len(pages) == 1
147
- assert pages[0][0].ticker == "AAPL"
148
-
149
-
150
- # ── insiders ──────────────────────────────────────────────────────────────────
151
-
152
- @respx.mock
153
- def test_insiders_search_returns_list(client):
154
- route = respx.get(f"{BASE}/v1/insiders").mock(return_value=httpx.Response(200, json=[INSIDER]))
155
- results = client.insiders.search("Cook")
156
- assert route.called
157
- qs = dict(route.calls[0].request.url.params)
158
- assert qs["name"] == "Cook"
159
- assert len(results) == 1
160
- assert isinstance(results[0], Insider)
161
- assert results[0].name == "Cook Timothy D"
162
-
163
-
164
- @respx.mock
165
- def test_insiders_get_returns_typed_object(client):
166
- respx.get(f"{BASE}/v1/insiders/0001214156").mock(return_value=httpx.Response(200, json=INSIDER))
167
- result = client.insiders.get("0001214156")
168
- assert isinstance(result, Insider)
169
- assert result.cik == "0001214156"
170
- assert result.is_officer is True
171
- assert result.total_filings == 42
172
-
173
-
174
- @respx.mock
175
- def test_insiders_transactions_returns_list(client):
176
- respx.get(f"{BASE}/v1/insiders/0001214156/transactions").mock(
177
- return_value=httpx.Response(200, json=[TX])
178
- )
179
- results = client.insiders.transactions("0001214156")
180
- assert len(results) == 1
181
- assert isinstance(results[0], Transaction)
182
-
183
-
184
- # ── companies ─────────────────────────────────────────────────────────────────
185
-
186
- @respx.mock
187
- def test_companies_get_returns_typed_object(client):
188
- respx.get(f"{BASE}/v1/companies/AAPL").mock(return_value=httpx.Response(200, json=COMPANY))
189
- result = client.companies.get("AAPL")
190
- assert isinstance(result, Company)
191
- assert result.ticker == "AAPL"
192
- assert result.total_filings == 100
193
- assert result.active_insiders == 12
194
-
195
-
196
- @respx.mock
197
- def test_companies_insiders_returns_list(client):
198
- respx.get(f"{BASE}/v1/companies/AAPL/insiders").mock(
199
- return_value=httpx.Response(200, json=[INSIDER])
200
- )
201
- results = client.companies.insiders("AAPL")
202
- assert len(results) == 1
203
- assert isinstance(results[0], Insider)
204
-
205
-
206
- # ── signals ───────────────────────────────────────────────────────────────────
207
-
208
- @respx.mock
209
- def test_signals_list_returns_typed_objects(client):
210
- respx.get(f"{BASE}/v1/signals").mock(return_value=httpx.Response(200, json=[SIGNAL]))
211
- results = client.signals.list(ticker="AAPL")
212
- assert len(results) == 1
213
- assert isinstance(results[0], InsiderSignal)
214
- assert results[0].is_cluster_buy is True
215
- assert results[0].buy_sell_ratio == 2.5
216
-
217
-
218
- # ── error handling ────────────────────────────────────────────────────────────
219
-
220
- @respx.mock
221
- def test_401_raises_auth_error(client):
222
- respx.get(f"{BASE}/v1/transactions").mock(
223
- return_value=httpx.Response(401, json={"error": {"code": "INVALID_API_KEY", "message": "Bad key"}})
224
- )
225
- with pytest.raises(AuthError) as exc:
226
- client.transactions.list()
227
- assert exc.value.status_code == 401
228
- assert exc.value.error_code == "INVALID_API_KEY"
229
-
230
-
231
- @respx.mock
232
- def test_402_raises_plan_error(client):
233
- respx.get(f"{BASE}/v1/signals").mock(
234
- return_value=httpx.Response(402, json={"error": {"code": "PLAN_REQUIRED", "message": "Upgrade"}, "requiredPlan": "Business"})
235
- )
236
- with pytest.raises(PlanError) as exc:
237
- client.signals.list()
238
- assert exc.value.required_plan == "Business"
239
-
240
-
241
- @respx.mock
242
- def test_404_raises_not_found_error(client):
243
- respx.get(f"{BASE}/v1/insiders/0000000000").mock(
244
- return_value=httpx.Response(404, json={"error": {"code": "NOT_FOUND", "message": "Not found"}})
245
- )
246
- with pytest.raises(NotFoundError):
247
- client.insiders.get("0000000000")
248
-
249
-
250
- @respx.mock
251
- def test_429_raises_rate_limit_error_with_retry_after(client):
252
- respx.get(f"{BASE}/v1/transactions").mock(
253
- return_value=httpx.Response(
254
- 429,
255
- headers={"Retry-After": "42"},
256
- json={"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "Slow down"}},
257
- )
258
- )
259
- with pytest.raises(RateLimitError) as exc:
260
- client.transactions.list()
261
- assert exc.value.retry_after == 42
262
-
263
-
264
- @respx.mock
265
- def test_500_raises_form4_api_error(client):
266
- respx.get(f"{BASE}/v1/transactions").mock(return_value=httpx.Response(500, json={}))
267
- with pytest.raises(Form4ApiError) as exc:
268
- client.transactions.list()
269
- assert exc.value.status_code == 500
270
-
271
-
272
- # ── retries ───────────────────────────────────────────────────────────────────
273
-
274
- @respx.mock
275
- def test_retries_on_5xx_then_succeeds():
276
- with Form4ApiClient("test-key", max_retries=1) as client:
277
- respx.get(f"{BASE}/v1/transactions").mock(side_effect=[
278
- httpx.Response(503, json={}),
279
- httpx.Response(200, json=[TX]),
280
- ])
281
- results = client.transactions.list()
282
- assert len(results) == 1
283
-
284
-
285
- @respx.mock
286
- def test_no_retry_on_4xx():
287
- call_count = 0
288
-
289
- def handler(_):
290
- nonlocal call_count
291
- call_count += 1
292
- return httpx.Response(401, json={"error": {"code": "INVALID_API_KEY", "message": "Bad"}})
293
-
294
- with Form4ApiClient("test-key", max_retries=2) as client:
295
- respx.get(f"{BASE}/v1/transactions").mock(side_effect=handler)
296
- with pytest.raises(AuthError):
297
- client.transactions.list()
298
-
299
- assert call_count == 1 # no retries on 401
300
-
301
-
302
- # ── webhook verification ──────────────────────────────────────────────────────
303
-
304
- def test_verify_webhook_valid_signature():
305
- import hashlib, hmac as _hmac
306
- payload = b'{"type":"TransactionFiled"}'
307
- secret = "mysecret"
308
- sig = "sha256=" + _hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
309
- assert verify_webhook(payload, sig, secret) is True
310
-
311
-
312
- def test_verify_webhook_invalid_signature():
313
- assert verify_webhook(b"payload", "sha256=badhash", "mysecret") is False
314
-
315
-
316
- def test_verify_webhook_accepts_str_payload():
317
- import hashlib, hmac as _hmac
318
- payload = '{"type":"TransactionFiled"}'
319
- secret = "mysecret"
320
- sig = "sha256=" + _hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
321
- assert verify_webhook(payload, sig, secret) is True
1
+ import pytest
2
+ import httpx
3
+ import respx
4
+
5
+ from form4api import (
6
+ Form4ApiClient,
7
+ AuthError,
8
+ NotFoundError,
9
+ PlanError,
10
+ RateLimitError,
11
+ Form4ApiError,
12
+ Transaction,
13
+ Insider,
14
+ Company,
15
+ InsiderSignal,
16
+ verify_webhook,
17
+ )
18
+
19
+ BASE = "https://api.form4api.com"
20
+
21
+ TX = {
22
+ "ticker": "AAPL",
23
+ "companyName": "Apple Inc.",
24
+ "insiderName": "Cook Timothy D",
25
+ "insiderCik": "0001214156",
26
+ "insiderTitle": "Chief Executive Officer",
27
+ "isDirector": False,
28
+ "isOfficer": True,
29
+ "is10PctOwner": False,
30
+ "accessionNumber": "0001234567-26-000001",
31
+ "securityTitle": "Common Stock",
32
+ "transactionCode": "P",
33
+ "isOpenMarket": True,
34
+ "is10b5Plan": False,
35
+ "sharesAmount": 1000.0,
36
+ "pricePerShare": 212.45,
37
+ "totalValue": 212450.0,
38
+ "sharesOwnedAfter": 5000.0,
39
+ "directIndirect": "D",
40
+ "isDerivative": False,
41
+ "transactionDate": "2026-01-15T00:00:00Z",
42
+ "periodOfReport": "2026-01-15T00:00:00Z",
43
+ }
44
+
45
+ INSIDER = {
46
+ "cik": "0001214156",
47
+ "name": "Cook Timothy D",
48
+ "isDirector": False,
49
+ "isOfficer": True,
50
+ "isTenPercentOwner": False,
51
+ "officerTitle": "CEO",
52
+ "totalFilings": 42,
53
+ }
54
+
55
+ COMPANY = {
56
+ "cik": "0000320193",
57
+ "name": "Apple Inc.",
58
+ "ticker": "AAPL",
59
+ "exchange": "NASDAQ",
60
+ "totalFilings": 100,
61
+ "activeInsiders": 12,
62
+ "sicDescription": "Electronic Computers",
63
+ "stateOfIncorporation": "CA",
64
+ "website": None,
65
+ }
66
+
67
+ SIGNAL = {
68
+ "ticker": "AAPL",
69
+ "companyName": "Apple Inc.",
70
+ "signalDate": "2026-01-15",
71
+ "buySellRatio": 2.5,
72
+ "isClusterBuy": True,
73
+ "isClusterSell": False,
74
+ "insiderCount": 4,
75
+ }
76
+
77
+
78
+ @pytest.fixture
79
+ def client():
80
+ with Form4ApiClient("test-key", max_retries=0) as c:
81
+ yield c
82
+
83
+
84
+ # ── headers ──────────────────────────────────────────────────────────────────
85
+
86
+ @respx.mock
87
+ def test_request_sends_branded_user_agent(client):
88
+ """The SDK sends User-Agent: form4api-py/<version> so the backend can
89
+ attribute traffic to the Python SDK channel."""
90
+ import re
91
+ route = respx.get(f"{BASE}/v1/transactions").mock(return_value=httpx.Response(200, json=[]))
92
+ client.transactions.list()
93
+ assert route.called
94
+ ua = route.calls[0].request.headers.get("user-agent")
95
+ assert re.match(r"^form4api-py/\d+\.\d+\.\d+$", ua or ""), ua
96
+
97
+
98
+ # ── transactions ───────────────────────────────────────────────────────────────
99
+
100
+ @respx.mock
101
+ def test_transactions_list_returns_typed_objects(client):
102
+ respx.get(f"{BASE}/v1/transactions").mock(return_value=httpx.Response(200, json=[TX]))
103
+ results = client.transactions.list()
104
+ assert len(results) == 1
105
+ assert isinstance(results[0], Transaction)
106
+ assert results[0].ticker == "AAPL"
107
+ assert results[0].company_name == "Apple Inc."
108
+ assert results[0].insider_cik == "0001214156"
109
+ assert results[0].transaction_code == "P"
110
+
111
+
112
+ @respx.mock
113
+ def test_transactions_list_sends_filters(client):
114
+ route = respx.get(f"{BASE}/v1/transactions").mock(return_value=httpx.Response(200, json=[]))
115
+ client.transactions.list(ticker="AAPL", code="P", from_date="2026-01-01", per_page=10)
116
+ assert route.called
117
+ qs = dict(route.calls[0].request.url.params)
118
+ assert qs["ticker"] == "AAPL"
119
+ assert qs["code"] == "P"
120
+ assert qs["from"] == "2026-01-01"
121
+ assert qs["per_page"] == "10"
122
+
123
+
124
+ @respx.mock
125
+ def test_transactions_list_sends_granular_filters(client):
126
+ route = respx.get(f"{BASE}/v1/transactions").mock(return_value=httpx.Response(200, json=[]))
127
+ client.transactions.list(
128
+ codes="P,S",
129
+ exclude_codes="A,M",
130
+ category="open_market",
131
+ exclude_category="derivatives",
132
+ exclude_derivative=True,
133
+ significant=True,
134
+ min_value=100000,
135
+ max_value=5000000,
136
+ min_shares=100,
137
+ max_shares=10000,
138
+ )
139
+ assert route.called
140
+ qs = dict(route.calls[0].request.url.params)
141
+ assert qs["codes"] == "P,S"
142
+ assert qs["exclude_codes"] == "A,M"
143
+ assert qs["category"] == "open_market"
144
+ assert qs["exclude_category"] == "derivatives"
145
+ assert qs["exclude_derivative"] == "true"
146
+ assert qs["significant"] == "true"
147
+ assert qs["min_value"] == "100000"
148
+ assert qs["max_value"] == "5000000"
149
+ assert qs["min_shares"] == "100"
150
+ assert qs["max_shares"] == "10000"
151
+
152
+
153
+ @respx.mock
154
+ def test_transactions_paginate_stops_on_short_page(client):
155
+ respx.get(f"{BASE}/v1/transactions").mock(side_effect=[
156
+ httpx.Response(200, json=[TX]),
157
+ httpx.Response(200, json=[]),
158
+ ])
159
+ pages = list(client.transactions.paginate(per_page=1))
160
+ assert len(pages) == 1
161
+ assert pages[0][0].ticker == "AAPL"
162
+
163
+
164
+ # ── insiders ──────────────────────────────────────────────────────────────────
165
+
166
+ @respx.mock
167
+ def test_insiders_search_returns_list(client):
168
+ route = respx.get(f"{BASE}/v1/insiders").mock(return_value=httpx.Response(200, json=[INSIDER]))
169
+ results = client.insiders.search("Cook")
170
+ assert route.called
171
+ qs = dict(route.calls[0].request.url.params)
172
+ assert qs["name"] == "Cook"
173
+ assert len(results) == 1
174
+ assert isinstance(results[0], Insider)
175
+ assert results[0].name == "Cook Timothy D"
176
+
177
+
178
+ @respx.mock
179
+ def test_insiders_get_returns_typed_object(client):
180
+ respx.get(f"{BASE}/v1/insiders/0001214156").mock(return_value=httpx.Response(200, json=INSIDER))
181
+ result = client.insiders.get("0001214156")
182
+ assert isinstance(result, Insider)
183
+ assert result.cik == "0001214156"
184
+ assert result.is_officer is True
185
+ assert result.total_filings == 42
186
+
187
+
188
+ @respx.mock
189
+ def test_insiders_transactions_returns_list(client):
190
+ respx.get(f"{BASE}/v1/insiders/0001214156/transactions").mock(
191
+ return_value=httpx.Response(200, json=[TX])
192
+ )
193
+ results = client.insiders.transactions("0001214156")
194
+ assert len(results) == 1
195
+ assert isinstance(results[0], Transaction)
196
+
197
+
198
+ # ── companies ─────────────────────────────────────────────────────────────────
199
+
200
+ @respx.mock
201
+ def test_companies_get_returns_typed_object(client):
202
+ respx.get(f"{BASE}/v1/companies/AAPL").mock(return_value=httpx.Response(200, json=COMPANY))
203
+ result = client.companies.get("AAPL")
204
+ assert isinstance(result, Company)
205
+ assert result.ticker == "AAPL"
206
+ assert result.total_filings == 100
207
+ assert result.active_insiders == 12
208
+
209
+
210
+ @respx.mock
211
+ def test_companies_insiders_returns_list(client):
212
+ respx.get(f"{BASE}/v1/companies/AAPL/insiders").mock(
213
+ return_value=httpx.Response(200, json=[INSIDER])
214
+ )
215
+ results = client.companies.insiders("AAPL")
216
+ assert len(results) == 1
217
+ assert isinstance(results[0], Insider)
218
+
219
+
220
+ # ── signals ───────────────────────────────────────────────────────────────────
221
+
222
+ @respx.mock
223
+ def test_signals_list_returns_typed_objects(client):
224
+ respx.get(f"{BASE}/v1/signals").mock(return_value=httpx.Response(200, json=[SIGNAL]))
225
+ results = client.signals.list(ticker="AAPL")
226
+ assert len(results) == 1
227
+ assert isinstance(results[0], InsiderSignal)
228
+ assert results[0].is_cluster_buy is True
229
+ assert results[0].buy_sell_ratio == 2.5
230
+
231
+
232
+ # ── error handling ────────────────────────────────────────────────────────────
233
+
234
+ @respx.mock
235
+ def test_401_raises_auth_error(client):
236
+ respx.get(f"{BASE}/v1/transactions").mock(
237
+ return_value=httpx.Response(401, json={"error": {"code": "INVALID_API_KEY", "message": "Bad key"}})
238
+ )
239
+ with pytest.raises(AuthError) as exc:
240
+ client.transactions.list()
241
+ assert exc.value.status_code == 401
242
+ assert exc.value.error_code == "INVALID_API_KEY"
243
+
244
+
245
+ @respx.mock
246
+ def test_402_raises_plan_error(client):
247
+ respx.get(f"{BASE}/v1/signals").mock(
248
+ return_value=httpx.Response(402, json={"error": {"code": "PLAN_REQUIRED", "message": "Upgrade"}, "requiredPlan": "Business"})
249
+ )
250
+ with pytest.raises(PlanError) as exc:
251
+ client.signals.list()
252
+ assert exc.value.required_plan == "Business"
253
+
254
+
255
+ @respx.mock
256
+ def test_404_raises_not_found_error(client):
257
+ respx.get(f"{BASE}/v1/insiders/0000000000").mock(
258
+ return_value=httpx.Response(404, json={"error": {"code": "NOT_FOUND", "message": "Not found"}})
259
+ )
260
+ with pytest.raises(NotFoundError):
261
+ client.insiders.get("0000000000")
262
+
263
+
264
+ @respx.mock
265
+ def test_429_raises_rate_limit_error_with_retry_after(client):
266
+ respx.get(f"{BASE}/v1/transactions").mock(
267
+ return_value=httpx.Response(
268
+ 429,
269
+ headers={"Retry-After": "42"},
270
+ json={"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "Slow down"}},
271
+ )
272
+ )
273
+ with pytest.raises(RateLimitError) as exc:
274
+ client.transactions.list()
275
+ assert exc.value.retry_after == 42
276
+
277
+
278
+ @respx.mock
279
+ def test_500_raises_form4_api_error(client):
280
+ respx.get(f"{BASE}/v1/transactions").mock(return_value=httpx.Response(500, json={}))
281
+ with pytest.raises(Form4ApiError) as exc:
282
+ client.transactions.list()
283
+ assert exc.value.status_code == 500
284
+
285
+
286
+ # ── retries ───────────────────────────────────────────────────────────────────
287
+
288
+ @respx.mock
289
+ def test_retries_on_5xx_then_succeeds():
290
+ with Form4ApiClient("test-key", max_retries=1) as client:
291
+ respx.get(f"{BASE}/v1/transactions").mock(side_effect=[
292
+ httpx.Response(503, json={}),
293
+ httpx.Response(200, json=[TX]),
294
+ ])
295
+ results = client.transactions.list()
296
+ assert len(results) == 1
297
+
298
+
299
+ @respx.mock
300
+ def test_no_retry_on_4xx():
301
+ call_count = 0
302
+
303
+ def handler(_):
304
+ nonlocal call_count
305
+ call_count += 1
306
+ return httpx.Response(401, json={"error": {"code": "INVALID_API_KEY", "message": "Bad"}})
307
+
308
+ with Form4ApiClient("test-key", max_retries=2) as client:
309
+ respx.get(f"{BASE}/v1/transactions").mock(side_effect=handler)
310
+ with pytest.raises(AuthError):
311
+ client.transactions.list()
312
+
313
+ assert call_count == 1 # no retries on 401
314
+
315
+
316
+ # ── webhook verification ──────────────────────────────────────────────────────
317
+
318
+ def test_verify_webhook_valid_signature():
319
+ import hashlib, hmac as _hmac
320
+ payload = b'{"type":"TransactionFiled"}'
321
+ secret = "mysecret"
322
+ sig = "sha256=" + _hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
323
+ assert verify_webhook(payload, sig, secret) is True
324
+
325
+
326
+ def test_verify_webhook_invalid_signature():
327
+ assert verify_webhook(b"payload", "sha256=badhash", "mysecret") is False
328
+
329
+
330
+ def test_verify_webhook_accepts_str_payload():
331
+ import hashlib, hmac as _hmac
332
+ payload = '{"type":"TransactionFiled"}'
333
+ secret = "mysecret"
334
+ sig = "sha256=" + _hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
335
+ assert verify_webhook(payload, sig, secret) is True
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes