form4api 0.6.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {form4api-0.6.0 → form4api-0.7.0}/PKG-INFO +30 -5
  2. {form4api-0.6.0 → form4api-0.7.0}/README.md +29 -4
  3. {form4api-0.6.0 → form4api-0.7.0}/form4api/__init__.py +38 -30
  4. form4api-0.7.0/form4api/_errors.py +105 -0
  5. {form4api-0.6.0 → form4api-0.7.0}/form4api/resources/_signals.py +50 -8
  6. {form4api-0.6.0 → form4api-0.7.0}/form4api/resources/_transactions.py +65 -20
  7. {form4api-0.6.0 → form4api-0.7.0}/form4api.egg-info/PKG-INFO +30 -5
  8. {form4api-0.6.0 → form4api-0.7.0}/pyproject.toml +1 -1
  9. {form4api-0.6.0 → form4api-0.7.0}/tests/test_client.py +123 -0
  10. form4api-0.6.0/form4api/_errors.py +0 -47
  11. {form4api-0.6.0 → form4api-0.7.0}/LICENSE +0 -0
  12. {form4api-0.6.0 → form4api-0.7.0}/form4api/_client.py +0 -0
  13. {form4api-0.6.0 → form4api-0.7.0}/form4api/_generated.py +0 -0
  14. {form4api-0.6.0 → form4api-0.7.0}/form4api/_types.py +0 -0
  15. {form4api-0.6.0 → form4api-0.7.0}/form4api/_webhook_utils.py +0 -0
  16. {form4api-0.6.0 → form4api-0.7.0}/form4api/resources/__init__.py +0 -0
  17. {form4api-0.6.0 → form4api-0.7.0}/form4api/resources/_companies.py +0 -0
  18. {form4api-0.6.0 → form4api-0.7.0}/form4api/resources/_insiders.py +0 -0
  19. {form4api-0.6.0 → form4api-0.7.0}/form4api/resources/_webhooks.py +0 -0
  20. {form4api-0.6.0 → form4api-0.7.0}/form4api.egg-info/SOURCES.txt +0 -0
  21. {form4api-0.6.0 → form4api-0.7.0}/form4api.egg-info/dependency_links.txt +0 -0
  22. {form4api-0.6.0 → form4api-0.7.0}/form4api.egg-info/requires.txt +0 -0
  23. {form4api-0.6.0 → form4api-0.7.0}/form4api.egg-info/top_level.txt +0 -0
  24. {form4api-0.6.0 → form4api-0.7.0}/setup.cfg +0 -0
  25. {form4api-0.6.0 → form4api-0.7.0}/tests/test_generated.py +0 -0
  26. {form4api-0.6.0 → form4api-0.7.0}/tests/test_method_name.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: form4api
3
- Version: 0.6.0
3
+ Version: 0.7.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
@@ -183,27 +183,52 @@ client.transactions.list(min_shares=10_000, max_shares=100_000)
183
183
 
184
184
  ### Pagination
185
185
 
186
+ `paginate()` pages through the data until it runs out (a short or empty page)
187
+ or, since the backend's 2026-08-01 plan-gated pagination depth (Free: 20
188
+ pages, Starter: 100, Pro+: unlimited), the next page is rejected with a 402.
189
+ That 402 is not swallowed — it raises `PaginationLimitError` mid-iteration,
190
+ after every page already yielded has been delivered to your loop. Pages you
191
+ already received are real and complete; the error only means iteration
192
+ stopped early. Pass `max_pages` to stop deliberately before that ever
193
+ happens, or catch `PaginationLimitError` to know when a Free/Starter key ran
194
+ out of depth on a bulk pull:
195
+
186
196
  ```python
197
+ from form4api import PaginationLimitError
198
+
187
199
  # transactions.paginate() — yields one list per page automatically
188
200
  all_txns = []
189
- for batch in client.transactions.paginate(ticker="NVDA", exclude_10b5=True, per_page=100):
190
- all_txns.extend(batch)
201
+ try:
202
+ for batch in client.transactions.paginate(ticker="NVDA", exclude_10b5=True, per_page=100):
203
+ all_txns.extend(batch)
204
+ except PaginationLimitError as e:
205
+ print(f"Stopped after {e.pages_yielded} pages — {e}")
206
+ # all_txns still holds every page yielded before the limit hit
191
207
 
192
208
  # signals.paginate()
193
209
  all_signals = []
194
- for batch in client.signals.paginate(cluster_buy=True, per_page=100):
210
+ for batch in client.signals.paginate(cluster_buy=True, per_page=100, max_pages=10):
195
211
  all_signals.extend(batch)
196
212
  ```
197
213
 
198
214
  ## Error handling
199
215
 
200
216
  ```python
201
- from form4api import Form4ApiClient, AuthError, PlanError, RateLimitError, NotFoundError
217
+ from form4api import (
218
+ Form4ApiClient,
219
+ AuthError,
220
+ PlanError,
221
+ PaginationLimitError,
222
+ RateLimitError,
223
+ NotFoundError,
224
+ )
202
225
 
203
226
  client = Form4ApiClient("YOUR_API_KEY")
204
227
 
205
228
  try:
206
229
  signals = client.signals.list()
230
+ except PaginationLimitError as e:
231
+ print(f"Paginate stopped after {e.pages_yielded} pages — upgrade to go deeper")
207
232
  except PlanError as e:
208
233
  print(f"Upgrade required")
209
234
  except RateLimitError as e:
@@ -164,27 +164,52 @@ client.transactions.list(min_shares=10_000, max_shares=100_000)
164
164
 
165
165
  ### Pagination
166
166
 
167
+ `paginate()` pages through the data until it runs out (a short or empty page)
168
+ or, since the backend's 2026-08-01 plan-gated pagination depth (Free: 20
169
+ pages, Starter: 100, Pro+: unlimited), the next page is rejected with a 402.
170
+ That 402 is not swallowed — it raises `PaginationLimitError` mid-iteration,
171
+ after every page already yielded has been delivered to your loop. Pages you
172
+ already received are real and complete; the error only means iteration
173
+ stopped early. Pass `max_pages` to stop deliberately before that ever
174
+ happens, or catch `PaginationLimitError` to know when a Free/Starter key ran
175
+ out of depth on a bulk pull:
176
+
167
177
  ```python
178
+ from form4api import PaginationLimitError
179
+
168
180
  # transactions.paginate() — yields one list per page automatically
169
181
  all_txns = []
170
- for batch in client.transactions.paginate(ticker="NVDA", exclude_10b5=True, per_page=100):
171
- all_txns.extend(batch)
182
+ try:
183
+ for batch in client.transactions.paginate(ticker="NVDA", exclude_10b5=True, per_page=100):
184
+ all_txns.extend(batch)
185
+ except PaginationLimitError as e:
186
+ print(f"Stopped after {e.pages_yielded} pages — {e}")
187
+ # all_txns still holds every page yielded before the limit hit
172
188
 
173
189
  # signals.paginate()
174
190
  all_signals = []
175
- for batch in client.signals.paginate(cluster_buy=True, per_page=100):
191
+ for batch in client.signals.paginate(cluster_buy=True, per_page=100, max_pages=10):
176
192
  all_signals.extend(batch)
177
193
  ```
178
194
 
179
195
  ## Error handling
180
196
 
181
197
  ```python
182
- from form4api import Form4ApiClient, AuthError, PlanError, RateLimitError, NotFoundError
198
+ from form4api import (
199
+ Form4ApiClient,
200
+ AuthError,
201
+ PlanError,
202
+ PaginationLimitError,
203
+ RateLimitError,
204
+ NotFoundError,
205
+ )
183
206
 
184
207
  client = Form4ApiClient("YOUR_API_KEY")
185
208
 
186
209
  try:
187
210
  signals = client.signals.list()
211
+ except PaginationLimitError as e:
212
+ print(f"Paginate stopped after {e.pages_yielded} pages — upgrade to go deeper")
188
213
  except PlanError as e:
189
214
  print(f"Upgrade required")
190
215
  except RateLimitError as e:
@@ -1,30 +1,38 @@
1
- from form4api._client import AsyncForm4ApiClient, Form4ApiClient
2
- from form4api._errors import AuthError, Form4ApiError, NotFoundError, PlanError, RateLimitError
3
- from form4api._types import (
4
- Company,
5
- Insider,
6
- InsiderSignal,
7
- Transaction,
8
- WebhookCreated,
9
- WebhookEvent,
10
- WebhookSubscription,
11
- )
12
- from form4api._webhook_utils import verify_webhook
13
-
14
- __all__ = [
15
- "Form4ApiClient",
16
- "AsyncForm4ApiClient",
17
- "Form4ApiError",
18
- "AuthError",
19
- "PlanError",
20
- "NotFoundError",
21
- "RateLimitError",
22
- "Transaction",
23
- "Insider",
24
- "Company",
25
- "InsiderSignal",
26
- "WebhookCreated",
27
- "WebhookEvent",
28
- "WebhookSubscription",
29
- "verify_webhook",
30
- ]
1
+ from form4api._client import AsyncForm4ApiClient, Form4ApiClient
2
+ from form4api._errors import (
3
+ AuthError,
4
+ Form4ApiError,
5
+ NotFoundError,
6
+ PaginationLimitError,
7
+ PlanError,
8
+ RateLimitError,
9
+ )
10
+ from form4api._types import (
11
+ Company,
12
+ Insider,
13
+ InsiderSignal,
14
+ Transaction,
15
+ WebhookCreated,
16
+ WebhookEvent,
17
+ WebhookSubscription,
18
+ )
19
+ from form4api._webhook_utils import verify_webhook
20
+
21
+ __all__ = [
22
+ "Form4ApiClient",
23
+ "AsyncForm4ApiClient",
24
+ "Form4ApiError",
25
+ "AuthError",
26
+ "PlanError",
27
+ "PaginationLimitError",
28
+ "NotFoundError",
29
+ "RateLimitError",
30
+ "Transaction",
31
+ "Insider",
32
+ "Company",
33
+ "InsiderSignal",
34
+ "WebhookCreated",
35
+ "WebhookEvent",
36
+ "WebhookSubscription",
37
+ "verify_webhook",
38
+ ]
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+
6
+ class Form4ApiError(Exception):
7
+ def __init__(self, message: str, status_code: int, error_code: str | None = None) -> None:
8
+ super().__init__(message)
9
+ self.status_code = status_code
10
+ self.error_code = error_code
11
+
12
+
13
+ class AuthError(Form4ApiError):
14
+ def __init__(self, message: str, error_code: str | None = None) -> None:
15
+ super().__init__(message, 401, error_code)
16
+
17
+
18
+ class PlanError(Form4ApiError):
19
+ """Raised on 402 PLAN_REQUIRED.
20
+
21
+ ``required_plan`` is the minimum plan that unlocks the endpoint (e.g.
22
+ ``"Business"``), ``current_plan`` is the plan the calling key is on, and
23
+ ``upgrade_url`` is where to upgrade. All three may be ``None`` against
24
+ backends older than 2026-08-05, which carried the plan names only as prose
25
+ inside ``message``.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ message: str,
31
+ required_plan: str | None = None,
32
+ current_plan: str | None = None,
33
+ upgrade_url: str | None = None,
34
+ ) -> None:
35
+ super().__init__(message, 402, "PLAN_REQUIRED")
36
+ self.required_plan = required_plan
37
+ self.current_plan = current_plan
38
+ self.upgrade_url = upgrade_url
39
+
40
+
41
+ class NotFoundError(Form4ApiError):
42
+ def __init__(self, message: str, error_code: str | None = None) -> None:
43
+ super().__init__(message, 404, error_code)
44
+
45
+
46
+ class RateLimitError(Form4ApiError):
47
+ def __init__(self, message: str, retry_after: int | None = None) -> None:
48
+ super().__init__(message, 429, "RATE_LIMIT_EXCEEDED")
49
+ self.retry_after = retry_after
50
+
51
+
52
+ # Backend message shape for the plan-gated *pagination depth* 402 (see
53
+ # PaginationHelpers.MaxPageFor / TransactionsEndpoints.cs / CongressEndpoints.cs
54
+ # on the API). This is deliberately narrow: `error_code` is "PLAN_REQUIRED" for
55
+ # EVERY 402 the API returns — a whole-endpoint plan gate (e.g. GET /v1/signals
56
+ # on a sub-Business key) and a plan-gated query parameter both use the same
57
+ # code — so the message text is the only reliable signal that a given 402 is
58
+ # specifically the depth limit rather than some other plan gate. If the
59
+ # backend ever changes this message shape, the regex stops matching and
60
+ # `paginate()` re-raises the original `PlanError` untouched instead of
61
+ # mislabeling an unrelated 402.
62
+ _PAGINATION_DEPTH_MESSAGE_RE = re.compile(r"pagination depth on /v1/", re.IGNORECASE)
63
+
64
+
65
+ def is_pagination_depth_error(err: Exception) -> bool:
66
+ """True when `err` is specifically the plan-gated pagination-depth 402 that
67
+ `paginate()` knows how to turn into a `PaginationLimitError`."""
68
+ return isinstance(err, PlanError) and bool(_PAGINATION_DEPTH_MESSAGE_RE.search(str(err)))
69
+
70
+
71
+ class PaginationLimitError(PlanError):
72
+ """Raised by `paginate()` (on `transactions` and `signals`) when the backend
73
+ rejects the next page because the calling key's plan has reached its
74
+ pagination depth limit (Free: 20 pages, Starter: 100, Pro+: unlimited).
75
+
76
+ Pages already yielded before this point were real, complete pages — this
77
+ error only means iteration stopped early, not that any data already
78
+ delivered to the caller was wrong. `pages_yielded` tells you exactly how
79
+ many. The original `PlanError` is chained as `__cause__` (via `raise ... from err`).
80
+
81
+ **Subclasses PlanError deliberately.** Before this type existed, `paginate()`
82
+ raised a plain `PlanError` at the depth limit, so `except PlanError:` was the
83
+ documented way to handle it. Subclassing keeps every one of those handlers
84
+ working while letting new code catch the narrower type — and it is the
85
+ truthful relationship anyway, since this IS a 402 PLAN_REQUIRED. Making it a
86
+ sibling would break existing callers for no gain.
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ message: str,
92
+ pages_yielded: int,
93
+ cause: PlanError | None = None,
94
+ ) -> None:
95
+ # Carry the upgrade metadata through from the original 402 where the
96
+ # backend supplied it, so a caller can link straight to the upgrade page
97
+ # without unwrapping __cause__ themselves. The depth-limit branch
98
+ # populates upgrade_url but leaves required_plan/current_plan None.
99
+ super().__init__(
100
+ message,
101
+ cause.required_plan if cause else None,
102
+ cause.current_plan if cause else None,
103
+ cause.upgrade_url if cause else None,
104
+ )
105
+ self.pages_yielded = pages_yielded
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
5
5
 
6
6
  from collections.abc import AsyncGenerator
7
7
 
8
+ from form4api._errors import PaginationLimitError, PlanError, is_pagination_depth_error
8
9
  from form4api._generated import GeneratedAsyncSignalsResource, GeneratedSignalsResource
9
10
  from form4api._types import InsiderSignal
10
11
 
@@ -58,16 +59,39 @@ class SignalsResource(GeneratedSignalsResource):
58
59
  cluster_buy: bool | None = None,
59
60
  cluster_sell: bool | None = None,
60
61
  per_page: int = 100,
62
+ max_pages: int | None = None,
61
63
  ) -> Generator[list[InsiderSignal], None, None]:
64
+ """Pages through /v1/signals until the data runs out (a short or empty
65
+ page) or the calling key's plan-gated pagination depth is exceeded —
66
+ see `TransactionsResource.paginate` for the full rationale. That 402
67
+ is NOT swallowed; it becomes a `PaginationLimitError` after every page
68
+ already yielded has been delivered to the caller. Pass `max_pages` to
69
+ stop deliberately before that happens.
70
+ """
62
71
  page = 1
72
+ pages_yielded = 0
63
73
  while True:
64
- batch = self.list(
65
- ticker=ticker, cluster_buy=cluster_buy, cluster_sell=cluster_sell,
66
- page=page, per_page=per_page,
67
- )
74
+ if max_pages is not None and pages_yielded >= max_pages:
75
+ break
76
+
77
+ try:
78
+ batch = self.list(
79
+ ticker=ticker, cluster_buy=cluster_buy, cluster_sell=cluster_sell,
80
+ page=page, per_page=per_page,
81
+ )
82
+ except PlanError as err:
83
+ if is_pagination_depth_error(err):
84
+ raise PaginationLimitError(
85
+ f"signals.paginate() stopped after yielding {pages_yielded} page(s) — {err}",
86
+ pages_yielded,
87
+ err,
88
+ ) from err
89
+ raise
90
+
68
91
  if not batch:
69
92
  break
70
93
  yield batch
94
+ pages_yielded += 1
71
95
  if len(batch) < per_page:
72
96
  break
73
97
  page += 1
@@ -102,16 +126,34 @@ class AsyncSignalsResource(GeneratedAsyncSignalsResource):
102
126
  cluster_buy: bool | None = None,
103
127
  cluster_sell: bool | None = None,
104
128
  per_page: int = 100,
129
+ max_pages: int | None = None,
105
130
  ) -> AsyncGenerator[list[InsiderSignal], None]:
131
+ """Async twin of `SignalsResource.paginate` — same depth-limit
132
+ semantics, see there for the full rationale."""
106
133
  page = 1
134
+ pages_yielded = 0
107
135
  while True:
108
- batch = await self.list(
109
- ticker=ticker, cluster_buy=cluster_buy, cluster_sell=cluster_sell,
110
- page=page, per_page=per_page,
111
- )
136
+ if max_pages is not None and pages_yielded >= max_pages:
137
+ break
138
+
139
+ try:
140
+ batch = await self.list(
141
+ ticker=ticker, cluster_buy=cluster_buy, cluster_sell=cluster_sell,
142
+ page=page, per_page=per_page,
143
+ )
144
+ except PlanError as err:
145
+ if is_pagination_depth_error(err):
146
+ raise PaginationLimitError(
147
+ f"signals.paginate() stopped after yielding {pages_yielded} page(s) — {err}",
148
+ pages_yielded,
149
+ err,
150
+ ) from err
151
+ raise
152
+
112
153
  if not batch:
113
154
  break
114
155
  yield batch
156
+ pages_yielded += 1
115
157
  if len(batch) < per_page:
116
158
  break
117
159
  page += 1
@@ -3,6 +3,7 @@
3
3
  from collections.abc import AsyncGenerator, Generator
4
4
  from typing import TYPE_CHECKING
5
5
 
6
+ from form4api._errors import PaginationLimitError, PlanError, is_pagination_depth_error
6
7
  from form4api._types import Transaction
7
8
 
8
9
  if TYPE_CHECKING:
@@ -121,23 +122,49 @@ class TransactionsResource:
121
122
  min_shares: float | None = None,
122
123
  max_shares: float | None = None,
123
124
  per_page: int = 50,
125
+ max_pages: int | None = None,
124
126
  ) -> Generator[list[Transaction], None, None]:
127
+ """Pages through /v1/transactions until the data runs out (a short or
128
+ empty page) or, since the backend's 2026-08-01 plan-gated pagination
129
+ depth (Free: 20 pages, Starter: 100, Pro+: unlimited), the next page is
130
+ rejected with a 402. That 402 is NOT swallowed — a scripted caller who
131
+ silently stopped there would see what looks like "no more data" and
132
+ never learn their dataset was truncated. Instead this raises
133
+ `PaginationLimitError` mid-iteration, after every page already yielded
134
+ has been delivered to the caller. Pass `max_pages` to stop deliberately
135
+ before that ever happens.
136
+ """
125
137
  page = 1
138
+ pages_yielded = 0
126
139
  while True:
127
- batch = self.list(
128
- ticker=ticker, cik=cik, insider_cik=insider_cik,
129
- code=code, from_date=from_date, to_date=to_date,
130
- exclude_10b5=exclude_10b5,
131
- codes=codes, exclude_codes=exclude_codes,
132
- category=category, exclude_category=exclude_category,
133
- exclude_derivative=exclude_derivative, significant=significant,
134
- min_value=min_value, max_value=max_value,
135
- min_shares=min_shares, max_shares=max_shares,
136
- page=page, per_page=per_page,
137
- )
140
+ if max_pages is not None and pages_yielded >= max_pages:
141
+ break
142
+
143
+ try:
144
+ batch = self.list(
145
+ ticker=ticker, cik=cik, insider_cik=insider_cik,
146
+ code=code, from_date=from_date, to_date=to_date,
147
+ exclude_10b5=exclude_10b5,
148
+ codes=codes, exclude_codes=exclude_codes,
149
+ category=category, exclude_category=exclude_category,
150
+ exclude_derivative=exclude_derivative, significant=significant,
151
+ min_value=min_value, max_value=max_value,
152
+ min_shares=min_shares, max_shares=max_shares,
153
+ page=page, per_page=per_page,
154
+ )
155
+ except PlanError as err:
156
+ if is_pagination_depth_error(err):
157
+ raise PaginationLimitError(
158
+ f"transactions.paginate() stopped after yielding {pages_yielded} page(s) — {err}",
159
+ pages_yielded,
160
+ err,
161
+ ) from err
162
+ raise
163
+
138
164
  if not batch:
139
165
  break
140
166
  yield batch
167
+ pages_yielded += 1
141
168
  if len(batch) < per_page:
142
169
  break
143
170
  page += 1
@@ -205,21 +232,39 @@ class AsyncTransactionsResource:
205
232
  min_shares: float | None = None,
206
233
  max_shares: float | None = None,
207
234
  per_page: int = 50,
235
+ max_pages: int | None = None,
208
236
  ) -> AsyncGenerator[list[Transaction], None]:
237
+ """Async twin of `TransactionsResource.paginate` — same depth-limit
238
+ semantics, see there for the full rationale."""
209
239
  page = 1
240
+ pages_yielded = 0
210
241
  while True:
211
- batch = await self.list(
212
- ticker=ticker, cik=cik, insider_cik=insider_cik, code=code,
213
- from_date=from_date, to_date=to_date, exclude_10b5=exclude_10b5,
214
- codes=codes, exclude_codes=exclude_codes, category=category,
215
- exclude_category=exclude_category, exclude_derivative=exclude_derivative,
216
- significant=significant, min_value=min_value, max_value=max_value,
217
- min_shares=min_shares, max_shares=max_shares,
218
- page=page, per_page=per_page,
219
- )
242
+ if max_pages is not None and pages_yielded >= max_pages:
243
+ break
244
+
245
+ try:
246
+ batch = await self.list(
247
+ ticker=ticker, cik=cik, insider_cik=insider_cik, code=code,
248
+ from_date=from_date, to_date=to_date, exclude_10b5=exclude_10b5,
249
+ codes=codes, exclude_codes=exclude_codes, category=category,
250
+ exclude_category=exclude_category, exclude_derivative=exclude_derivative,
251
+ significant=significant, min_value=min_value, max_value=max_value,
252
+ min_shares=min_shares, max_shares=max_shares,
253
+ page=page, per_page=per_page,
254
+ )
255
+ except PlanError as err:
256
+ if is_pagination_depth_error(err):
257
+ raise PaginationLimitError(
258
+ f"transactions.paginate() stopped after yielding {pages_yielded} page(s) — {err}",
259
+ pages_yielded,
260
+ err,
261
+ ) from err
262
+ raise
263
+
220
264
  if not batch:
221
265
  break
222
266
  yield batch
267
+ pages_yielded += 1
223
268
  if len(batch) < per_page:
224
269
  break
225
270
  page += 1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: form4api
3
- Version: 0.6.0
3
+ Version: 0.7.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
@@ -183,27 +183,52 @@ client.transactions.list(min_shares=10_000, max_shares=100_000)
183
183
 
184
184
  ### Pagination
185
185
 
186
+ `paginate()` pages through the data until it runs out (a short or empty page)
187
+ or, since the backend's 2026-08-01 plan-gated pagination depth (Free: 20
188
+ pages, Starter: 100, Pro+: unlimited), the next page is rejected with a 402.
189
+ That 402 is not swallowed — it raises `PaginationLimitError` mid-iteration,
190
+ after every page already yielded has been delivered to your loop. Pages you
191
+ already received are real and complete; the error only means iteration
192
+ stopped early. Pass `max_pages` to stop deliberately before that ever
193
+ happens, or catch `PaginationLimitError` to know when a Free/Starter key ran
194
+ out of depth on a bulk pull:
195
+
186
196
  ```python
197
+ from form4api import PaginationLimitError
198
+
187
199
  # transactions.paginate() — yields one list per page automatically
188
200
  all_txns = []
189
- for batch in client.transactions.paginate(ticker="NVDA", exclude_10b5=True, per_page=100):
190
- all_txns.extend(batch)
201
+ try:
202
+ for batch in client.transactions.paginate(ticker="NVDA", exclude_10b5=True, per_page=100):
203
+ all_txns.extend(batch)
204
+ except PaginationLimitError as e:
205
+ print(f"Stopped after {e.pages_yielded} pages — {e}")
206
+ # all_txns still holds every page yielded before the limit hit
191
207
 
192
208
  # signals.paginate()
193
209
  all_signals = []
194
- for batch in client.signals.paginate(cluster_buy=True, per_page=100):
210
+ for batch in client.signals.paginate(cluster_buy=True, per_page=100, max_pages=10):
195
211
  all_signals.extend(batch)
196
212
  ```
197
213
 
198
214
  ## Error handling
199
215
 
200
216
  ```python
201
- from form4api import Form4ApiClient, AuthError, PlanError, RateLimitError, NotFoundError
217
+ from form4api import (
218
+ Form4ApiClient,
219
+ AuthError,
220
+ PlanError,
221
+ PaginationLimitError,
222
+ RateLimitError,
223
+ NotFoundError,
224
+ )
202
225
 
203
226
  client = Form4ApiClient("YOUR_API_KEY")
204
227
 
205
228
  try:
206
229
  signals = client.signals.list()
230
+ except PaginationLimitError as e:
231
+ print(f"Paginate stopped after {e.pages_yielded} pages — upgrade to go deeper")
207
232
  except PlanError as e:
208
233
  print(f"Upgrade required")
209
234
  except RateLimitError as e:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "form4api"
7
- version = "0.6.0"
7
+ version = "0.7.0"
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"
@@ -6,6 +6,7 @@ from form4api import (
6
6
  Form4ApiClient,
7
7
  AuthError,
8
8
  NotFoundError,
9
+ PaginationLimitError,
9
10
  PlanError,
10
11
  RateLimitError,
11
12
  Form4ApiError,
@@ -161,6 +162,128 @@ def test_transactions_paginate_stops_on_short_page(client):
161
162
  assert pages[0][0].ticker == "AAPL"
162
163
 
163
164
 
165
+ @respx.mock
166
+ def test_transactions_paginate_raises_pagination_limit_error_after_delivering_prior_pages(client):
167
+ """The plan-gated pagination-depth 402 is not swallowed: it becomes a
168
+ PaginationLimitError raised mid-iteration, after every page already
169
+ yielded has been delivered to the caller."""
170
+ call = {"n": 0}
171
+
172
+ def handler(_request):
173
+ call["n"] += 1
174
+ if call["n"] <= 2:
175
+ return httpx.Response(200, json=[TX, TX])
176
+ return httpx.Response(
177
+ 402,
178
+ json={
179
+ "error": {
180
+ "code": "PLAN_REQUIRED",
181
+ "message": (
182
+ "Page 3 is beyond the Free plan's pagination depth on /v1/transactions "
183
+ "(2 pages). The Starter plan reaches 100 pages and Pro removes the limit "
184
+ "— upgrade at https://form4api.com/dashboard/billing?from=page_depth_402. "
185
+ "For a bulk historical pull, GET /v1/transactions/export (Business plan) "
186
+ "streams the full filtered set as CSV instead of paging."
187
+ ),
188
+ "requestId": "req_test",
189
+ }
190
+ },
191
+ )
192
+
193
+ respx.get(f"{BASE}/v1/transactions").mock(side_effect=handler)
194
+
195
+ pages = []
196
+ caught = None
197
+ try:
198
+ for page in client.transactions.paginate(per_page=2):
199
+ pages.append(page)
200
+ except PaginationLimitError as err:
201
+ caught = err
202
+
203
+ assert len(pages) == 2
204
+ assert caught is not None
205
+ assert caught.pages_yielded == 2
206
+ assert "yielding 2 page(s)" in str(caught)
207
+ assert "/v1/transactions/export" in str(caught)
208
+ assert isinstance(caught.__cause__, PlanError)
209
+
210
+ # Backward compatibility, and the reason PaginationLimitError subclasses
211
+ # PlanError rather than sitting beside it. Before this type existed,
212
+ # paginate() raised a plain PlanError here, so `except PlanError:` was the
213
+ # documented way to handle the depth limit. If this assertion ever fails,
214
+ # every caller written against the old behaviour is silently no longer
215
+ # catching this — an uncaught exception rather than a handled upgrade path.
216
+ assert isinstance(caught, PlanError)
217
+ # The upgrade metadata is carried through from the original 402 so callers
218
+ # do not have to unwrap __cause__ to find it.
219
+ assert caught.upgrade_url == caught.__cause__.upgrade_url
220
+
221
+
222
+ @respx.mock
223
+ def test_transactions_paginate_max_pages_stops_before_depth_limit(client):
224
+ respx.get(f"{BASE}/v1/transactions").mock(return_value=httpx.Response(200, json=[TX, TX]))
225
+ pages = list(client.transactions.paginate(per_page=2, max_pages=3))
226
+ assert len(pages) == 3
227
+
228
+
229
+ @respx.mock
230
+ def test_transactions_paginate_does_not_swallow_or_mislabel_non_402_error(client):
231
+ call = {"n": 0}
232
+
233
+ def handler(_request):
234
+ call["n"] += 1
235
+ if call["n"] == 1:
236
+ return httpx.Response(200, json=[TX, TX])
237
+ return httpx.Response(500, json={})
238
+
239
+ respx.get(f"{BASE}/v1/transactions").mock(side_effect=handler)
240
+
241
+ pages = []
242
+ caught = None
243
+ try:
244
+ for page in client.transactions.paginate(per_page=2):
245
+ pages.append(page)
246
+ except Form4ApiError as err:
247
+ caught = err
248
+
249
+ assert len(pages) == 1
250
+ assert caught is not None
251
+ assert not isinstance(caught, PaginationLimitError)
252
+ assert caught.status_code == 500
253
+
254
+
255
+ @respx.mock
256
+ def test_signals_paginate_reraises_non_depth_402_as_plan_error(client):
257
+ """GET /v1/signals is gated at the whole-endpoint level (Business plan) —
258
+ this 402 has nothing to do with pagination depth, and must not be
259
+ rewritten as though it were the depth limit."""
260
+ respx.get(f"{BASE}/v1/signals").mock(
261
+ return_value=httpx.Response(
262
+ 402,
263
+ json={
264
+ "error": {
265
+ "code": "PLAN_REQUIRED",
266
+ "message": "This endpoint requires the Business plan or higher. Your current plan is Free.",
267
+ "requestId": "req_test",
268
+ "requiredPlan": "Business",
269
+ "currentPlan": "Free",
270
+ }
271
+ },
272
+ )
273
+ )
274
+
275
+ caught = None
276
+ try:
277
+ for _page in client.signals.paginate():
278
+ pass
279
+ except PlanError as err:
280
+ caught = err
281
+
282
+ assert caught is not None
283
+ assert not isinstance(caught, PaginationLimitError)
284
+ assert caught.required_plan == "Business"
285
+
286
+
164
287
  # ── insiders ──────────────────────────────────────────────────────────────────
165
288
 
166
289
  @respx.mock
@@ -1,47 +0,0 @@
1
- from __future__ import annotations
2
-
3
-
4
- class Form4ApiError(Exception):
5
- def __init__(self, message: str, status_code: int, error_code: str | None = None) -> None:
6
- super().__init__(message)
7
- self.status_code = status_code
8
- self.error_code = error_code
9
-
10
-
11
- class AuthError(Form4ApiError):
12
- def __init__(self, message: str, error_code: str | None = None) -> None:
13
- super().__init__(message, 401, error_code)
14
-
15
-
16
- class PlanError(Form4ApiError):
17
- """Raised on 402 PLAN_REQUIRED.
18
-
19
- ``required_plan`` is the minimum plan that unlocks the endpoint (e.g.
20
- ``"Business"``), ``current_plan`` is the plan the calling key is on, and
21
- ``upgrade_url`` is where to upgrade. All three may be ``None`` against
22
- backends older than 2026-08-05, which carried the plan names only as prose
23
- inside ``message``.
24
- """
25
-
26
- def __init__(
27
- self,
28
- message: str,
29
- required_plan: str | None = None,
30
- current_plan: str | None = None,
31
- upgrade_url: str | None = None,
32
- ) -> None:
33
- super().__init__(message, 402, "PLAN_REQUIRED")
34
- self.required_plan = required_plan
35
- self.current_plan = current_plan
36
- self.upgrade_url = upgrade_url
37
-
38
-
39
- class NotFoundError(Form4ApiError):
40
- def __init__(self, message: str, error_code: str | None = None) -> None:
41
- super().__init__(message, 404, error_code)
42
-
43
-
44
- class RateLimitError(Form4ApiError):
45
- def __init__(self, message: str, retry_after: int | None = None) -> None:
46
- super().__init__(message, 429, "RATE_LIMIT_EXCEEDED")
47
- self.retry_after = retry_after
File without changes
File without changes
File without changes
File without changes