form4api 0.5.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.5.0 → form4api-0.7.0}/PKG-INFO +32 -7
  2. {form4api-0.5.0 → form4api-0.7.0}/README.md +31 -6
  3. {form4api-0.5.0 → form4api-0.7.0}/form4api/__init__.py +38 -30
  4. form4api-0.7.0/form4api/_errors.py +105 -0
  5. {form4api-0.5.0 → form4api-0.7.0}/form4api/_generated.py +164 -2
  6. {form4api-0.5.0 → form4api-0.7.0}/form4api/resources/_signals.py +50 -8
  7. {form4api-0.5.0 → form4api-0.7.0}/form4api/resources/_transactions.py +65 -20
  8. {form4api-0.5.0 → form4api-0.7.0}/form4api.egg-info/PKG-INFO +32 -7
  9. {form4api-0.5.0 → form4api-0.7.0}/form4api.egg-info/SOURCES.txt +2 -1
  10. {form4api-0.5.0 → form4api-0.7.0}/pyproject.toml +1 -1
  11. {form4api-0.5.0 → form4api-0.7.0}/tests/test_client.py +123 -0
  12. {form4api-0.5.0 → form4api-0.7.0}/tests/test_generated.py +44 -0
  13. form4api-0.7.0/tests/test_method_name.py +88 -0
  14. form4api-0.5.0/form4api/_errors.py +0 -47
  15. {form4api-0.5.0 → form4api-0.7.0}/LICENSE +0 -0
  16. {form4api-0.5.0 → form4api-0.7.0}/form4api/_client.py +0 -0
  17. {form4api-0.5.0 → form4api-0.7.0}/form4api/_types.py +0 -0
  18. {form4api-0.5.0 → form4api-0.7.0}/form4api/_webhook_utils.py +0 -0
  19. {form4api-0.5.0 → form4api-0.7.0}/form4api/resources/__init__.py +0 -0
  20. {form4api-0.5.0 → form4api-0.7.0}/form4api/resources/_companies.py +0 -0
  21. {form4api-0.5.0 → form4api-0.7.0}/form4api/resources/_insiders.py +0 -0
  22. {form4api-0.5.0 → form4api-0.7.0}/form4api/resources/_webhooks.py +0 -0
  23. {form4api-0.5.0 → form4api-0.7.0}/form4api.egg-info/dependency_links.txt +0 -0
  24. {form4api-0.5.0 → form4api-0.7.0}/form4api.egg-info/requires.txt +0 -0
  25. {form4api-0.5.0 → form4api-0.7.0}/form4api.egg-info/top_level.txt +0 -0
  26. {form4api-0.5.0 → form4api-0.7.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: form4api
3
- Version: 0.5.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
@@ -82,13 +82,13 @@ asyncio.run(main())
82
82
  | Resource | Methods |
83
83
  |---|---|
84
84
  | `client.transactions` | `.list(**params)`, `.paginate(**params)` |
85
- | `client.insiders` | `.search(name, **params)`, `.get(cik)`, `.list(**params)`, `.transactions(cik, **params)`, `.summary(cik)` *(Pro)*, `.scorecard(cik)` *(Pro)*, `.leaderboard(**params)` *(Business)* |
85
+ | `client.insiders` | `.search(name, **params)`, `.get(cik)`, `.list(**params)`, `.directory(**params)`, `.transactions(cik, **params)`, `.summary(cik)` *(Pro)*, `.scorecard(cik)` *(Pro)*, `.leaderboard(**params)` *(Business)* |
86
86
  | `client.companies` | `.get(ticker)`, `.insiders(ticker)`, `.list(**params)` |
87
87
  | `client.signals` | `.list(**params)`, `.paginate(**params)`, `.explain(ticker)`, `.sentiment(ticker, **params)` — Business; `.convergence(**params)` — Pro |
88
88
  | `client.congress` | `.trades(**params)`, `.politicians(**params)` *(Pro)*, `.politician(id_or_slug)` *(Pro)*, `.ticker(ticker)` *(Pro)* |
89
89
  | `client.form144` | `.list(**params)` — Business plan |
90
90
  | `client.holdings` | `.list(**params)`, `.managers(**params)` — Business plan |
91
- | `client.filings` | `.recent(**params)`, `.get(accession_number)` |
91
+ | `client.filings` | `.list(**params)`, `.recent(**params)`, `.get(accession_number)` |
92
92
  | `client.stats` | `.get()` — public, no key required |
93
93
  | `client.data_quality` | `.get()` — public, no key required |
94
94
  | `client.status` | `.history(**params)` |
@@ -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:
@@ -63,13 +63,13 @@ asyncio.run(main())
63
63
  | Resource | Methods |
64
64
  |---|---|
65
65
  | `client.transactions` | `.list(**params)`, `.paginate(**params)` |
66
- | `client.insiders` | `.search(name, **params)`, `.get(cik)`, `.list(**params)`, `.transactions(cik, **params)`, `.summary(cik)` *(Pro)*, `.scorecard(cik)` *(Pro)*, `.leaderboard(**params)` *(Business)* |
66
+ | `client.insiders` | `.search(name, **params)`, `.get(cik)`, `.list(**params)`, `.directory(**params)`, `.transactions(cik, **params)`, `.summary(cik)` *(Pro)*, `.scorecard(cik)` *(Pro)*, `.leaderboard(**params)` *(Business)* |
67
67
  | `client.companies` | `.get(ticker)`, `.insiders(ticker)`, `.list(**params)` |
68
68
  | `client.signals` | `.list(**params)`, `.paginate(**params)`, `.explain(ticker)`, `.sentiment(ticker, **params)` — Business; `.convergence(**params)` — Pro |
69
69
  | `client.congress` | `.trades(**params)`, `.politicians(**params)` *(Pro)*, `.politician(id_or_slug)` *(Pro)*, `.ticker(ticker)` *(Pro)* |
70
70
  | `client.form144` | `.list(**params)` — Business plan |
71
71
  | `client.holdings` | `.list(**params)`, `.managers(**params)` — Business plan |
72
- | `client.filings` | `.recent(**params)`, `.get(accession_number)` |
72
+ | `client.filings` | `.list(**params)`, `.recent(**params)`, `.get(accession_number)` |
73
73
  | `client.stats` | `.get()` — public, no key required |
74
74
  | `client.data_quality` | `.get()` — public, no key required |
75
75
  | `client.status` | `.history(**params)` |
@@ -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
@@ -427,6 +427,47 @@ class DataQualityResponse:
427
427
  return cls(**kwargs)
428
428
 
429
429
 
430
+ @dataclass
431
+ class DirectoryEntryResponse:
432
+ cik: str | None = None
433
+ filer_group_size: int | None = None
434
+ is_director: bool | None = None
435
+ is_officer: bool | None = None
436
+ is_ten_percent_owner: bool | None = None
437
+ last_filed_at: str | None = None
438
+ name: str | None = None
439
+ officer_title: str | None = None
440
+ primary_company_name: str | None = None
441
+ primary_ticker: str | None = None
442
+ transaction_count: int | None = None
443
+
444
+ @classmethod
445
+ def _from_dict(cls, data: dict) -> "DirectoryEntryResponse":
446
+ """Build from an API payload, ignoring unknown keys.
447
+
448
+ Constructing with **data directly (as the hand-written resources do)
449
+ means the SDK raises TypeError the moment the backend adds a field.
450
+ Filtering keeps older SDK versions working against a newer API."""
451
+ known = {f.name for f in fields(cls)}
452
+ return cls(**{k: v for k, v in data.items() if k in known})
453
+
454
+
455
+ @dataclass
456
+ class DirectoryLetter:
457
+ count: int | None = None
458
+ letter: str | None = None
459
+
460
+ @classmethod
461
+ def _from_dict(cls, data: dict) -> "DirectoryLetter":
462
+ """Build from an API payload, ignoring unknown keys.
463
+
464
+ Constructing with **data directly (as the hand-written resources do)
465
+ means the SDK raises TypeError the moment the backend adds a field.
466
+ Filtering keeps older SDK versions working against a newer API."""
467
+ known = {f.name for f in fields(cls)}
468
+ return cls(**{k: v for k, v in data.items() if k in known})
469
+
470
+
430
471
  @dataclass
431
472
  class ExcludedTradeEntry:
432
473
  code: str | None = None
@@ -678,6 +719,33 @@ class InsiderCompanyEntry:
678
719
  return cls(**{k: v for k, v in data.items() if k in known})
679
720
 
680
721
 
722
+ @dataclass
723
+ class InsiderDirectoryResponse:
724
+ entries: list[DirectoryEntryResponse] | None = None
725
+ letter: str | None = None
726
+ letter_total: int | None = None
727
+ letters: list[DirectoryLetter] | None = None
728
+ page: int | None = None
729
+ per_page: int | None = None
730
+ refreshed_at: str | None = None
731
+ total: int | None = None
732
+
733
+ @classmethod
734
+ def _from_dict(cls, data: dict) -> "InsiderDirectoryResponse":
735
+ """Build from an API payload, ignoring unknown keys.
736
+
737
+ Constructing with **data directly (as the hand-written resources do)
738
+ means the SDK raises TypeError the moment the backend adds a field.
739
+ Filtering keeps older SDK versions working against a newer API."""
740
+ known = {f.name for f in fields(cls)}
741
+ kwargs = {k: v for k, v in data.items() if k in known}
742
+ if isinstance(kwargs.get("entries"), list):
743
+ kwargs["entries"] = [DirectoryEntryResponse._from_dict(i) if isinstance(i, dict) else i for i in kwargs["entries"]]
744
+ if isinstance(kwargs.get("letters"), list):
745
+ kwargs["letters"] = [DirectoryLetter._from_dict(i) if isinstance(i, dict) else i for i in kwargs["letters"]]
746
+ return cls(**kwargs)
747
+
748
+
681
749
  @dataclass
682
750
  class InsiderLeaderboardResponse:
683
751
  insiders: list[LeaderboardEntry] | None = None
@@ -1302,7 +1370,7 @@ class GeneratedCongressResource:
1302
1370
  def trades(self, *, ticker: str | None = None, politician: str | None = None, party: str | None = None, chamber: str | None = None, state: str | None = None, transaction_type: str | None = None, min_amount: float | None = None, transaction_date_from: str | None = None, transaction_date_to: str | None = None, disclosure_date_from: str | None = None, disclosure_date_to: str | None = None, page: int | None = None, per_page: int | None = None) -> list[CongressTradeDto]:
1303
1371
  """Query congressional STOCK Act trades (Free+, plan-clamped disclosure window)
1304
1372
 
1305
- Returns a paginated JSON list of congressional periodic-transaction-report trades, most recently DISCLOSED first, with non-superseded rows only (amended-away rows never appear). PLAN-CLAMPED WINDOW: this endpoint is open to every plan, but how far back you can see is clamped on disclosureDate — Free sees only trades disclosed in the last 30 days, Starter the last 366 days, Pro/Business/Enterprise unlimited history. Passing an older disclosure_date_from than your plan allows does not extend the window — the floor always wins. Filters: ticker, politician (bioguideId, exact), party (free-text, case-insensitive exact match — not a fixed enum), chamber (House|Senate), state (2-letter code), transaction_type (purchase|sale|partial_sale|exchange), min_amount (range-aware — matches AmountLow >= value, never a fabricated midpoint), transaction_date_from/to, disclosure_date_from/to. Every row always carries BOTH amountLow and amountHigh (STOCK Act discloses ranges, never exact figures) and disclosureLagDays = (disclosureDate - transactionDate) — the STOCK Act allows up to 45 days of lag, so "real-time" here means minutes-after-disclosure, not minutes-after-trade. For per-politician or per-ticker rollups use GET /v1/congress/politicians, /v1/congress/politicians/{idOrSlug}, or /v1/congress/tickers/{ticker} (all Pro+). Query runs live against the database — no caching."""
1373
+ Returns a paginated JSON list of congressional periodic-transaction-report trades, most recently DISCLOSED first, with non-superseded rows only (amended-away rows never appear). COVERAGE — HOUSE ONLY TODAY: every trade in this dataset comes from the U.S. House Clerk's PTR index. Senate eFD (efdsearch.senate.gov) returns 403 to datacenter traffic, so no Senate filings are ingested yet. chamber=Senate remains a valid filter but matches nothing and returns the response header X-Coverage-Note: chamber-not-covered, so an empty result is never ambiguous. Scanning by chamber should treat that header as "not covered", not as "no trades". PLAN-CLAMPED WINDOW: this endpoint is open to every plan, but how far back you can see is clamped on disclosureDate — Free sees only trades disclosed in the last 30 days, Starter the last 366 days, Pro/Business/Enterprise unlimited history. Passing an older disclosure_date_from than your plan allows does not extend the window — the floor always wins. Filters: ticker, politician (bioguideId, exact), party (free-text, case-insensitive exact match — not a fixed enum), chamber (House|Senate — see the coverage note above), state (2-letter code), transaction_type (purchase|sale|partial_sale|exchange), min_amount (range-aware — matches AmountLow >= value, never a fabricated midpoint), transaction_date_from/to, disclosure_date_from/to. Every row always carries BOTH amountLow and amountHigh (STOCK Act discloses ranges, never exact figures) and disclosureLagDays = (disclosureDate - transactionDate) — the STOCK Act allows up to 45 days of lag, so "real-time" here means minutes-after-disclosure, not minutes-after-trade. For per-politician or per-ticker rollups use GET /v1/congress/politicians, /v1/congress/politicians/{idOrSlug}, or /v1/congress/tickers/{ticker} (all Pro+). Query runs live against the database — no caching."""
1306
1374
  params = {
1307
1375
  "ticker": ticker,
1308
1376
  "politician": politician,
@@ -1365,7 +1433,7 @@ class GeneratedAsyncCongressResource:
1365
1433
  async def trades(self, *, ticker: str | None = None, politician: str | None = None, party: str | None = None, chamber: str | None = None, state: str | None = None, transaction_type: str | None = None, min_amount: float | None = None, transaction_date_from: str | None = None, transaction_date_to: str | None = None, disclosure_date_from: str | None = None, disclosure_date_to: str | None = None, page: int | None = None, per_page: int | None = None) -> list[CongressTradeDto]:
1366
1434
  """Query congressional STOCK Act trades (Free+, plan-clamped disclosure window)
1367
1435
 
1368
- Returns a paginated JSON list of congressional periodic-transaction-report trades, most recently DISCLOSED first, with non-superseded rows only (amended-away rows never appear). PLAN-CLAMPED WINDOW: this endpoint is open to every plan, but how far back you can see is clamped on disclosureDate — Free sees only trades disclosed in the last 30 days, Starter the last 366 days, Pro/Business/Enterprise unlimited history. Passing an older disclosure_date_from than your plan allows does not extend the window — the floor always wins. Filters: ticker, politician (bioguideId, exact), party (free-text, case-insensitive exact match — not a fixed enum), chamber (House|Senate), state (2-letter code), transaction_type (purchase|sale|partial_sale|exchange), min_amount (range-aware — matches AmountLow >= value, never a fabricated midpoint), transaction_date_from/to, disclosure_date_from/to. Every row always carries BOTH amountLow and amountHigh (STOCK Act discloses ranges, never exact figures) and disclosureLagDays = (disclosureDate - transactionDate) — the STOCK Act allows up to 45 days of lag, so "real-time" here means minutes-after-disclosure, not minutes-after-trade. For per-politician or per-ticker rollups use GET /v1/congress/politicians, /v1/congress/politicians/{idOrSlug}, or /v1/congress/tickers/{ticker} (all Pro+). Query runs live against the database — no caching."""
1436
+ Returns a paginated JSON list of congressional periodic-transaction-report trades, most recently DISCLOSED first, with non-superseded rows only (amended-away rows never appear). COVERAGE — HOUSE ONLY TODAY: every trade in this dataset comes from the U.S. House Clerk's PTR index. Senate eFD (efdsearch.senate.gov) returns 403 to datacenter traffic, so no Senate filings are ingested yet. chamber=Senate remains a valid filter but matches nothing and returns the response header X-Coverage-Note: chamber-not-covered, so an empty result is never ambiguous. Scanning by chamber should treat that header as "not covered", not as "no trades". PLAN-CLAMPED WINDOW: this endpoint is open to every plan, but how far back you can see is clamped on disclosureDate — Free sees only trades disclosed in the last 30 days, Starter the last 366 days, Pro/Business/Enterprise unlimited history. Passing an older disclosure_date_from than your plan allows does not extend the window — the floor always wins. Filters: ticker, politician (bioguideId, exact), party (free-text, case-insensitive exact match — not a fixed enum), chamber (House|Senate — see the coverage note above), state (2-letter code), transaction_type (purchase|sale|partial_sale|exchange), min_amount (range-aware — matches AmountLow >= value, never a fabricated midpoint), transaction_date_from/to, disclosure_date_from/to. Every row always carries BOTH amountLow and amountHigh (STOCK Act discloses ranges, never exact figures) and disclosureLagDays = (disclosureDate - transactionDate) — the STOCK Act allows up to 45 days of lag, so "real-time" here means minutes-after-disclosure, not minutes-after-trade. For per-politician or per-ticker rollups use GET /v1/congress/politicians, /v1/congress/politicians/{idOrSlug}, or /v1/congress/tickers/{ticker} (all Pro+). Query runs live against the database — no caching."""
1369
1437
  params = {
1370
1438
  "ticker": ticker,
1371
1439
  "politician": politician,
@@ -1421,6 +1489,23 @@ class GeneratedFilingsResource:
1421
1489
  data = self._client._get(f"/v1/filings/{accession}")
1422
1490
  return FilingResponse._from_dict(data)
1423
1491
 
1492
+ def list(self, *, ticker: str | None = None, cik: str | None = None, from_: str | None = None, to: str | None = None, page: int | None = None, per_page: int | None = None, limit: int | None = None) -> list[FilingResponse]:
1493
+ """List Form 4 filings with optional ticker, CIK and date filters
1494
+
1495
+ Returns a paginated list of Form 4 filings, newest filed first. Filter by ticker, cik, and a from/to filed-date window. Each entry carries the accession number, company ticker/name, period of report, filed date, amendment type (Original/Amendment), and the count of non-superseded transactions in that filing. Use this for a company's filing HISTORY; use GET /v1/filings/recent for a live newest-first feed (it has no page parameter), and GET /v1/transactions when you want the individual trades rather than the filings that contain them. `limit` is accepted as an alias for `per_page`. Not plan-gated."""
1496
+ params = {
1497
+ "ticker": ticker,
1498
+ "cik": cik,
1499
+ "from": from_,
1500
+ "to": to,
1501
+ "page": page,
1502
+ "per_page": per_page,
1503
+ "limit": limit,
1504
+ }
1505
+ params = {k: str(v) for k, v in params.items() if v is not None}
1506
+ data = self._client._get(f"/v1/filings", params=params)
1507
+ return [FilingResponse._from_dict(item) for item in data]
1508
+
1424
1509
  def recent(self, *, ticker: str | None = None, per_page: int | None = None) -> list[FilingResponse]:
1425
1510
  """Get the most recently filed Form 4s, optionally filtered by ticker
1426
1511
 
@@ -1445,6 +1530,23 @@ class GeneratedAsyncFilingsResource:
1445
1530
  data = await self._client._get(f"/v1/filings/{accession}")
1446
1531
  return FilingResponse._from_dict(data)
1447
1532
 
1533
+ async def list(self, *, ticker: str | None = None, cik: str | None = None, from_: str | None = None, to: str | None = None, page: int | None = None, per_page: int | None = None, limit: int | None = None) -> list[FilingResponse]:
1534
+ """List Form 4 filings with optional ticker, CIK and date filters
1535
+
1536
+ Returns a paginated list of Form 4 filings, newest filed first. Filter by ticker, cik, and a from/to filed-date window. Each entry carries the accession number, company ticker/name, period of report, filed date, amendment type (Original/Amendment), and the count of non-superseded transactions in that filing. Use this for a company's filing HISTORY; use GET /v1/filings/recent for a live newest-first feed (it has no page parameter), and GET /v1/transactions when you want the individual trades rather than the filings that contain them. `limit` is accepted as an alias for `per_page`. Not plan-gated."""
1537
+ params = {
1538
+ "ticker": ticker,
1539
+ "cik": cik,
1540
+ "from": from_,
1541
+ "to": to,
1542
+ "page": page,
1543
+ "per_page": per_page,
1544
+ "limit": limit,
1545
+ }
1546
+ params = {k: str(v) for k, v in params.items() if v is not None}
1547
+ data = await self._client._get(f"/v1/filings", params=params)
1548
+ return [FilingResponse._from_dict(item) for item in data]
1549
+
1448
1550
  async def recent(self, *, ticker: str | None = None, per_page: int | None = None) -> list[FilingResponse]:
1449
1551
  """Get the most recently filed Form 4s, optionally filtered by ticker
1450
1552
 
@@ -1578,6 +1680,36 @@ class GeneratedInsidersResource:
1578
1680
  def __init__(self, client) -> None:
1579
1681
  self._client = client
1580
1682
 
1683
+ def directory(self, *, letter: str | None = None, page: int | None = None, per_page: int | None = None) -> InsiderDirectoryResponse:
1684
+ """Browse insiders alphabetically by surname
1685
+
1686
+ Returns the A-Z rail with a count per letter, plus one page of insiders under the
1687
+ requested letter. Omit `letter` to get the rail and totals with no rows.
1688
+
1689
+ Names come from EDGAR surname-first ("HENNEMAN JOHN B III"), so alphabetical order
1690
+ is order by surname. Casing in the source is inconsistent and is not normalised here.
1691
+
1692
+ This lists only insiders with at least 3 non-superseded transactions, capped at the
1693
+ 5,000 most active — the same set as the insiders sitemap shard, so the two cannot
1694
+ drift. To find someone outside that set, use GET /v1/insiders?name= which searches
1695
+ every filer. Rebuilt daily; `refreshedAt` reports when. Not plan-gated.
1696
+
1697
+ One row per FILER GROUP. A fund group files a single Form 4 listing several
1698
+ reporting owners — the fund, its GP, its management company — and each is a real
1699
+ EDGAR filer with its own CIK. Listing all of them spent about 11% of this capped
1700
+ surface describing the same actors more than once, so browse shows one per group
1701
+ and `filerGroupSize` says how many others share those exact transactions. The
1702
+ others are not hidden: each keeps its own profile and is still returned by
1703
+ GET /v1/insiders?name=."""
1704
+ params = {
1705
+ "letter": letter,
1706
+ "page": page,
1707
+ "per_page": per_page,
1708
+ }
1709
+ params = {k: str(v) for k, v in params.items() if v is not None}
1710
+ data = self._client._get(f"/v1/insiders/directory", params=params)
1711
+ return InsiderDirectoryResponse._from_dict(data)
1712
+
1581
1713
  def leaderboard(self, *, horizon: str | None = None, order: str | None = None, min_trades: int | None = None, limit: int | None = None) -> InsiderLeaderboardResponse:
1582
1714
  """Ranked leaderboard of insiders by buy track-record (Business plan+)
1583
1715
 
@@ -1651,6 +1783,36 @@ class GeneratedAsyncInsidersResource:
1651
1783
  def __init__(self, client) -> None:
1652
1784
  self._client = client
1653
1785
 
1786
+ async def directory(self, *, letter: str | None = None, page: int | None = None, per_page: int | None = None) -> InsiderDirectoryResponse:
1787
+ """Browse insiders alphabetically by surname
1788
+
1789
+ Returns the A-Z rail with a count per letter, plus one page of insiders under the
1790
+ requested letter. Omit `letter` to get the rail and totals with no rows.
1791
+
1792
+ Names come from EDGAR surname-first ("HENNEMAN JOHN B III"), so alphabetical order
1793
+ is order by surname. Casing in the source is inconsistent and is not normalised here.
1794
+
1795
+ This lists only insiders with at least 3 non-superseded transactions, capped at the
1796
+ 5,000 most active — the same set as the insiders sitemap shard, so the two cannot
1797
+ drift. To find someone outside that set, use GET /v1/insiders?name= which searches
1798
+ every filer. Rebuilt daily; `refreshedAt` reports when. Not plan-gated.
1799
+
1800
+ One row per FILER GROUP. A fund group files a single Form 4 listing several
1801
+ reporting owners — the fund, its GP, its management company — and each is a real
1802
+ EDGAR filer with its own CIK. Listing all of them spent about 11% of this capped
1803
+ surface describing the same actors more than once, so browse shows one per group
1804
+ and `filerGroupSize` says how many others share those exact transactions. The
1805
+ others are not hidden: each keeps its own profile and is still returned by
1806
+ GET /v1/insiders?name=."""
1807
+ params = {
1808
+ "letter": letter,
1809
+ "page": page,
1810
+ "per_page": per_page,
1811
+ }
1812
+ params = {k: str(v) for k, v in params.items() if v is not None}
1813
+ data = await self._client._get(f"/v1/insiders/directory", params=params)
1814
+ return InsiderDirectoryResponse._from_dict(data)
1815
+
1654
1816
  async def leaderboard(self, *, horizon: str | None = None, order: str | None = None, min_trades: int | None = None, limit: int | None = None) -> InsiderLeaderboardResponse:
1655
1817
  """Ranked leaderboard of insiders by buy track-record (Business plan+)
1656
1818
 
@@ -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.5.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
@@ -82,13 +82,13 @@ asyncio.run(main())
82
82
  | Resource | Methods |
83
83
  |---|---|
84
84
  | `client.transactions` | `.list(**params)`, `.paginate(**params)` |
85
- | `client.insiders` | `.search(name, **params)`, `.get(cik)`, `.list(**params)`, `.transactions(cik, **params)`, `.summary(cik)` *(Pro)*, `.scorecard(cik)` *(Pro)*, `.leaderboard(**params)` *(Business)* |
85
+ | `client.insiders` | `.search(name, **params)`, `.get(cik)`, `.list(**params)`, `.directory(**params)`, `.transactions(cik, **params)`, `.summary(cik)` *(Pro)*, `.scorecard(cik)` *(Pro)*, `.leaderboard(**params)` *(Business)* |
86
86
  | `client.companies` | `.get(ticker)`, `.insiders(ticker)`, `.list(**params)` |
87
87
  | `client.signals` | `.list(**params)`, `.paginate(**params)`, `.explain(ticker)`, `.sentiment(ticker, **params)` — Business; `.convergence(**params)` — Pro |
88
88
  | `client.congress` | `.trades(**params)`, `.politicians(**params)` *(Pro)*, `.politician(id_or_slug)` *(Pro)*, `.ticker(ticker)` *(Pro)* |
89
89
  | `client.form144` | `.list(**params)` — Business plan |
90
90
  | `client.holdings` | `.list(**params)`, `.managers(**params)` — Business plan |
91
- | `client.filings` | `.recent(**params)`, `.get(accession_number)` |
91
+ | `client.filings` | `.list(**params)`, `.recent(**params)`, `.get(accession_number)` |
92
92
  | `client.stats` | `.get()` — public, no key required |
93
93
  | `client.data_quality` | `.get()` — public, no key required |
94
94
  | `client.status` | `.history(**params)` |
@@ -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:
@@ -19,4 +19,5 @@ form4api/resources/_signals.py
19
19
  form4api/resources/_transactions.py
20
20
  form4api/resources/_webhooks.py
21
21
  tests/test_client.py
22
- tests/test_generated.py
22
+ tests/test_generated.py
23
+ tests/test_method_name.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "form4api"
7
- version = "0.5.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
@@ -387,3 +387,47 @@ def test_nested_objects_hydrate_all_the_way_down(client: Form4ApiClient) -> None
387
387
  assert result.career.returns.avg_return3m == 0.109
388
388
  # And through a list, not just a single object.
389
389
  assert result.career.companies[0].ticker == "AAPL"
390
+
391
+
392
+ # The two methods that arrived by derivation rather than by a hand-written
393
+ # METHOD_NAMES entry. Codegen could not run at all between 2026-08-04 and
394
+ # 2026-08-25, so these are the first endpoints to reach this SDK without anyone
395
+ # naming them — worth pinning that they are wired to the right paths and not
396
+ # just present on the class.
397
+ @respx.mock
398
+ def test_filings_list_hits_path_and_forwards_filters(client: Form4ApiClient) -> None:
399
+ route = respx.get(f"{BASE}/v1/filings").mock(return_value=httpx.Response(200, json=[]))
400
+ client.filings.list(ticker="AAPL", per_page=5)
401
+
402
+ request = route.calls.last.request
403
+ assert request.url.path == "/v1/filings"
404
+ assert request.url.params["ticker"] == "AAPL"
405
+ assert request.url.params["per_page"] == "5"
406
+
407
+
408
+ @respx.mock
409
+ def test_filings_list_is_distinct_from_filings_recent(client: Form4ApiClient) -> None:
410
+ # Derivation stripped the resource from both operationIds; if it had
411
+ # collapsed them, one would silently shadow the other.
412
+ route = respx.get(f"{BASE}/v1/filings/recent").mock(return_value=httpx.Response(200, json=[]))
413
+ client.filings.recent()
414
+ assert route.calls.last.request.url.path == "/v1/filings/recent"
415
+
416
+
417
+ @respx.mock
418
+ def test_insiders_directory_hits_path_and_forwards_letter(client: Form4ApiClient) -> None:
419
+ route = respx.get(f"{BASE}/v1/insiders/directory").mock(
420
+ return_value=httpx.Response(200, json={"letters": [], "insiders": []})
421
+ )
422
+ client.insiders.directory(letter="S", per_page=200)
423
+
424
+ request = route.calls.last.request
425
+ assert request.url.path == "/v1/insiders/directory"
426
+ assert request.url.params["letter"] == "S"
427
+
428
+
429
+ @respx.mock
430
+ def test_insiders_directory_does_not_shadow_insiders_list(client: Form4ApiClient) -> None:
431
+ route = respx.get(f"{BASE}/v1/insiders").mock(return_value=httpx.Response(200, json=[]))
432
+ client.insiders.list()
433
+ assert route.calls.last.request.url.path == "/v1/insiders"
@@ -0,0 +1,88 @@
1
+ """The generator derives a method name when no override is pinned.
2
+
3
+ It used to raise instead, which is why this SDK could not regenerate at all
4
+ between 2026-08-04 (/v1/filings) and 2026-08-25 (/v1/insiders/directory): a new
5
+ backend endpoint took codegen down until someone hand-added a line, and with CI
6
+ billing-blocked nobody saw it go red.
7
+
8
+ So the rule is load-bearing and pinned here. Deriving a name wrong is worse
9
+ than not deriving one — the method ships, someone imports it, and correcting it
10
+ afterwards is a breaking rename.
11
+
12
+ Mirrors tests/methodNames.test.ts in the JS SDK. The two derivations must agree
13
+ on which words are dropped; they differ only in casing.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "codegen"))
22
+
23
+ from method_name import derive_method_name # noqa: E402
24
+
25
+
26
+ def test_drops_the_resource_the_method_already_lives_on() -> None:
27
+ # filings.get_recent_filings() says "filings" twice.
28
+ assert derive_method_name("GetRecentFilings", "filings") == "recent"
29
+ assert derive_method_name("GetInsiderScorecard", "insiders") == "scorecard"
30
+ assert derive_method_name("GetConvergenceSignals", "signals") == "convergence"
31
+ assert derive_method_name("GetStatusHistory", "status") == "history"
32
+
33
+
34
+ def test_falls_back_to_the_verb_when_the_resource_was_the_whole_name() -> None:
35
+ # What makes companies.list() and filings.get() read correctly.
36
+ assert derive_method_name("ListCompanies", "companies") == "list"
37
+ assert derive_method_name("ListInsiders", "insiders") == "list"
38
+ assert derive_method_name("GetFiling", "filings") == "get"
39
+ assert derive_method_name("GetDataQuality", "data_quality") == "get"
40
+
41
+
42
+ def test_derives_the_two_endpoints_that_had_been_breaking_codegen() -> None:
43
+ assert derive_method_name("ListFilings", "filings") == "list"
44
+ assert derive_method_name("GetInsiderDirectory", "insiders") == "directory"
45
+
46
+
47
+ def test_matches_exact_or_simple_plural_never_a_prefix() -> None:
48
+ # A looser test would strip "Sentiment" for a resource called "signals"
49
+ # and collapse two different endpoints onto signals.get().
50
+ assert derive_method_name("GetSentiment", "signals") == "sentiment"
51
+ assert derive_method_name("ListManagers", "holdings") == "managers"
52
+
53
+
54
+ def test_handles_a_resource_carrying_digits() -> None:
55
+ # "Form144" must stay one word. Split into "Form" + "144", neither half
56
+ # matches the resource and this derives to form144() instead of list().
57
+ assert derive_method_name("ListForm144", "form144") == "list"
58
+
59
+
60
+ def test_snake_cases_a_multi_word_remainder() -> None:
61
+ # The one place the two SDKs differ: JS camelCases this to tickerRollup.
62
+ assert derive_method_name("GetCongressTickerRollup", "congress") == "ticker_rollup"
63
+
64
+
65
+ def test_already_published_names_stay_pinned() -> None:
66
+ """Every shipped name is kept as an explicit override even where the
67
+ derived value agrees, so no upstream operationId rename can quietly change
68
+ a method someone has already imported."""
69
+ source = (Path(__file__).resolve().parents[1] / "codegen" / "generate.py").read_text(
70
+ encoding="utf-8"
71
+ )
72
+ block = source[source.index("METHOD_NAME_OVERRIDES = {") :]
73
+ block = block[: block.index("\n}")]
74
+
75
+ # These two would derive to something else entirely — the case the
76
+ # override list exists for.
77
+ assert '"GetCongressTickerRollup": "ticker"' in block
78
+ assert '"GetPublicStats": "get"' in block
79
+
80
+ for operation_id in (
81
+ "ListCompanies",
82
+ "ListCongressTrades",
83
+ "GetInsiderLeaderboard",
84
+ "ListHoldings",
85
+ "ExplainSignal",
86
+ "GetSentiment",
87
+ ):
88
+ assert f'"{operation_id}":' in block, f"{operation_id} must stay pinned"
@@ -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