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.
- {form4api-0.4.2 → form4api-0.4.3}/PKG-INFO +1 -1
- {form4api-0.4.2 → form4api-0.4.3}/form4api/_client.py +229 -219
- {form4api-0.4.2 → form4api-0.4.3}/form4api.egg-info/PKG-INFO +1 -1
- {form4api-0.4.2 → form4api-0.4.3}/pyproject.toml +1 -1
- {form4api-0.4.2 → form4api-0.4.3}/tests/test_client.py +335 -321
- {form4api-0.4.2 → form4api-0.4.3}/LICENSE +0 -0
- {form4api-0.4.2 → form4api-0.4.3}/README.md +0 -0
- {form4api-0.4.2 → form4api-0.4.3}/form4api/__init__.py +0 -0
- {form4api-0.4.2 → form4api-0.4.3}/form4api/_errors.py +0 -0
- {form4api-0.4.2 → form4api-0.4.3}/form4api/_types.py +0 -0
- {form4api-0.4.2 → form4api-0.4.3}/form4api/_webhook_utils.py +0 -0
- {form4api-0.4.2 → form4api-0.4.3}/form4api/resources/__init__.py +0 -0
- {form4api-0.4.2 → form4api-0.4.3}/form4api/resources/_companies.py +0 -0
- {form4api-0.4.2 → form4api-0.4.3}/form4api/resources/_insiders.py +0 -0
- {form4api-0.4.2 → form4api-0.4.3}/form4api/resources/_signals.py +0 -0
- {form4api-0.4.2 → form4api-0.4.3}/form4api/resources/_transactions.py +0 -0
- {form4api-0.4.2 → form4api-0.4.3}/form4api/resources/_webhooks.py +0 -0
- {form4api-0.4.2 → form4api-0.4.3}/form4api.egg-info/SOURCES.txt +0 -0
- {form4api-0.4.2 → form4api-0.4.3}/form4api.egg-info/dependency_links.txt +0 -0
- {form4api-0.4.2 → form4api-0.4.3}/form4api.egg-info/requires.txt +0 -0
- {form4api-0.4.2 → form4api-0.4.3}/form4api.egg-info/top_level.txt +0 -0
- {form4api-0.4.2 → form4api-0.4.3}/setup.cfg +0 -0
|
@@ -1,219 +1,229 @@
|
|
|
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
|
-
self
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
self.
|
|
58
|
-
self.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
self.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
self.
|
|
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
|
-
self
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
self.
|
|
151
|
-
self.
|
|
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
|
-
self.
|
|
200
|
-
|
|
201
|
-
def
|
|
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 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)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "form4api"
|
|
7
|
-
version = "0.4.
|
|
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
|
-
# ──
|
|
85
|
-
|
|
86
|
-
@respx.mock
|
|
87
|
-
def
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
assert
|
|
94
|
-
|
|
95
|
-
assert
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
assert
|
|
105
|
-
assert
|
|
106
|
-
assert
|
|
107
|
-
assert
|
|
108
|
-
|
|
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
|
-
assert
|
|
147
|
-
assert
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
assert
|
|
161
|
-
assert
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
assert
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
assert
|
|
228
|
-
assert
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
@respx.mock
|
|
265
|
-
def
|
|
266
|
-
respx.get(f"{BASE}/v1/transactions").mock(
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
assert
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|