From fcee19537cae602b8fbefa93871528bafdc08008 Mon Sep 17 00:00:00 2001 From: Theodor Date: Mon, 5 Oct 2026 19:42:46 +0200 Subject: [PATCH] Ignore unknown response fields in companies, insiders, signals and webhooks Sixteen hand-written methods built dataclasses with Model(**data), which raises TypeError on the first field a newer API version adds. They now go through a shared _from_dict that keeps only declared fields, the pattern the transactions, search and generated models already use. Public signatures and return types are unchanged. --- CHANGELOG.md | 4 + form4api/_types.py | 27 +++- form4api/resources/_companies.py | 8 +- form4api/resources/_insiders.py | 8 +- form4api/resources/_signals.py | 4 +- form4api/resources/_webhooks.py | 12 +- tests/test_unknown_fields.py | 257 +++++++++++++++++++++++++++++++ 7 files changed, 298 insertions(+), 22 deletions(-) create mode 100644 tests/test_unknown_fields.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ac580d..ffacc30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ - Added `form4api.__version__` (read from the installed package metadata, so it always equals the pyproject version). +- Responses with fields added by newer API versions no longer raise + `TypeError` in companies/insiders/signals/webhooks methods (sync and async). + Unknown keys are now ignored, as the transactions, search and generated + models already did. ## 0.9.0 — 2026-10-05 diff --git a/form4api/_types.py b/form4api/_types.py index 34e7ec8..d27b7ab 100644 --- a/form4api/_types.py +++ b/form4api/_types.py @@ -3,6 +3,21 @@ from dataclasses import dataclass, fields +class _FromDictMixin: + """Tolerant construction for flat dataclasses built from API payloads. + + The API only ever adds fields, and a dataclass constructor rejects unknown + keyword arguments, so `Model(**data)` crashes on the first new field. + `_from_dict` drops keys the dataclass does not declare. Key conversion + (camelCase to snake_case) already happened in the client before this runs. + Models with nested dataclasses override `_from_dict` to hydrate them.""" + + @classmethod + def _from_dict(cls, data: dict): + known = {f.name for f in fields(cls)} # type: ignore[arg-type] + return cls(**{k: v for k, v in data.items() if k in known}) + + @dataclass class TopHolder: manager_cik: str | None = None @@ -88,7 +103,7 @@ def _from_dict(cls, data: dict) -> "Transaction": @dataclass -class Insider: +class Insider(_FromDictMixin): cik: str name: str is_director: bool @@ -99,7 +114,7 @@ class Insider: @dataclass -class Company: +class Company(_FromDictMixin): cik: str name: str ticker: str | None @@ -155,7 +170,7 @@ def _from_dict(cls, data: dict) -> "SearchResults": @dataclass -class InsiderSignal: +class InsiderSignal(_FromDictMixin): ticker: str | None company_name: str signal_date: str @@ -166,7 +181,7 @@ class InsiderSignal: @dataclass -class WebhookCreated: +class WebhookCreated(_FromDictMixin): subscription_id: int url: str event_types: list[str] @@ -176,7 +191,7 @@ class WebhookCreated: @dataclass -class WebhookSubscription: +class WebhookSubscription(_FromDictMixin): subscription_id: int url: str event_types: list[str] @@ -185,7 +200,7 @@ class WebhookSubscription: @dataclass -class WebhookEvent: +class WebhookEvent(_FromDictMixin): delivery_id: int subscription_id: int event_type: str diff --git a/form4api/resources/_companies.py b/form4api/resources/_companies.py index 16d6d04..36c8626 100644 --- a/form4api/resources/_companies.py +++ b/form4api/resources/_companies.py @@ -19,11 +19,11 @@ def __init__(self, client: Form4ApiClient) -> None: def get(self, ticker: str) -> Company: data = self._client._get(f"/v1/companies/{ticker}") - return Company(**data) + return Company._from_dict(data) def insiders(self, ticker: str) -> list[Insider]: data = self._client._get(f"/v1/companies/{ticker}/insiders") - return [Insider(**item) for item in data] + return [Insider._from_dict(item) for item in data] class AsyncCompaniesResource(GeneratedAsyncCompaniesResource): @@ -41,11 +41,11 @@ def __init__(self, client: AsyncForm4ApiClient) -> None: async def get(self, ticker: str) -> Company: data = await self._client._get(f"/v1/companies/{ticker}") - return Company(**data) + return Company._from_dict(data) async def insiders(self, ticker: str) -> list[Insider]: data = await self._client._get(f"/v1/companies/{ticker}/insiders") - return [Insider(**item) for item in data] + return [Insider._from_dict(item) for item in data] diff --git a/form4api/resources/_insiders.py b/form4api/resources/_insiders.py index d494af6..b3fd1d1 100644 --- a/form4api/resources/_insiders.py +++ b/form4api/resources/_insiders.py @@ -39,11 +39,11 @@ def search( per_page: int = 20, ) -> list[Insider]: data = self._client._get("/v1/insiders", _search_params(name, page, per_page)) - return [Insider(**item) for item in data] + return [Insider._from_dict(item) for item in data] def get(self, cik: str) -> Insider: data = self._client._get(f"/v1/insiders/{cik}") - return Insider(**data) + return Insider._from_dict(data) def transactions( self, @@ -67,11 +67,11 @@ def __init__(self, client: AsyncForm4ApiClient) -> None: async def search(self, name: str, *, page: int = 1, per_page: int = 20) -> list[Insider]: data = await self._client._get("/v1/insiders", _search_params(name, page, per_page)) - return [Insider(**item) for item in data] + return [Insider._from_dict(item) for item in data] async def get(self, cik: str) -> Insider: data = await self._client._get(f"/v1/insiders/{cik}") - return Insider(**data) + return Insider._from_dict(data) async def transactions( self, diff --git a/form4api/resources/_signals.py b/form4api/resources/_signals.py index 537c94a..47cb0b6 100644 --- a/form4api/resources/_signals.py +++ b/form4api/resources/_signals.py @@ -50,7 +50,7 @@ def list( ) -> list[InsiderSignal]: params = _list_params(ticker, cluster_buy, cluster_sell, page, per_page) data = self._client._get("/v1/signals", params) - return [InsiderSignal(**item) for item in data] + return [InsiderSignal._from_dict(item) for item in data] def paginate( self, @@ -117,7 +117,7 @@ async def list( ) -> list[InsiderSignal]: params = _list_params(ticker, cluster_buy, cluster_sell, page, per_page) data = await self._client._get("/v1/signals", params) - return [InsiderSignal(**item) for item in data] + return [InsiderSignal._from_dict(item) for item in data] async def paginate( self, diff --git a/form4api/resources/_webhooks.py b/form4api/resources/_webhooks.py index fee5b08..bb2f7b9 100644 --- a/form4api/resources/_webhooks.py +++ b/form4api/resources/_webhooks.py @@ -14,11 +14,11 @@ def __init__(self, client: Form4ApiClient) -> None: def create(self, url: str, event_types: list[str]) -> WebhookCreated: data = self._client._post("/v1/webhooks", {"url": url, "eventTypes": event_types}) - return WebhookCreated(**data) + return WebhookCreated._from_dict(data) def list(self) -> list[WebhookSubscription]: data = self._client._get("/v1/webhooks") - return [WebhookSubscription(**item) for item in data] + return [WebhookSubscription._from_dict(item) for item in data] def delete(self, subscription_id: int) -> None: self._client._delete(f"/v1/webhooks/{subscription_id}") @@ -28,7 +28,7 @@ def events(self, *, since: str | None = None) -> list[WebhookEvent]: if since is not None: params["since"] = since data = self._client._get("/v1/webhooks/events", params or None) - return [WebhookEvent(**item) for item in data] + return [WebhookEvent._from_dict(item) for item in data] class AsyncWebhooksResource: @@ -39,11 +39,11 @@ def __init__(self, client: AsyncForm4ApiClient) -> None: async def create(self, url: str, event_types: list[str]) -> WebhookCreated: data = await self._client._post("/v1/webhooks", {"url": url, "eventTypes": event_types}) - return WebhookCreated(**data) + return WebhookCreated._from_dict(data) async def list(self) -> list[WebhookSubscription]: data = await self._client._get("/v1/webhooks") - return [WebhookSubscription(**item) for item in data] + return [WebhookSubscription._from_dict(item) for item in data] async def delete(self, subscription_id: int) -> None: await self._client._delete(f"/v1/webhooks/{subscription_id}") @@ -53,4 +53,4 @@ async def events(self, *, since: str | None = None) -> list[WebhookEvent]: if since is not None: params["since"] = since data = await self._client._get("/v1/webhooks/events", params or None) - return [WebhookEvent(**item) for item in data] + return [WebhookEvent._from_dict(item) for item in data] diff --git a/tests/test_unknown_fields.py b/tests/test_unknown_fields.py new file mode 100644 index 0000000..81c294c --- /dev/null +++ b/tests/test_unknown_fields.py @@ -0,0 +1,257 @@ +"""Responses carrying fields a newer API version added must not raise. + +Every hand-written resource method is exercised through the real client with +respx, sync and async, with an extra unknown key injected into the payload. +""" +import asyncio + +import httpx +import pytest +import respx + +from form4api import Company, Form4ApiClient, Insider, InsiderSignal +from form4api._client import AsyncForm4ApiClient +from form4api._types import WebhookCreated, WebhookEvent, WebhookSubscription + +BASE = "https://api.form4api.com" +NEW = {"somethingNewFromTheBackend": 1, "anotherNewThing": {"nested": [1, 2]}} + +INSIDER = { + "cik": "0001214156", + "name": "Cook Timothy D", + "isDirector": False, + "isOfficer": True, + "isTenPercentOwner": False, + "officerTitle": "CEO", + "totalFilings": 42, + **NEW, +} +COMPANY = { + "cik": "0000320193", + "name": "Apple Inc.", + "ticker": "AAPL", + "exchange": "NASDAQ", + "totalFilings": 100, + "activeInsiders": 12, + "sicDescription": "Electronic Computers", + "stateOfIncorporation": "CA", + "website": None, + **NEW, +} +SIGNAL = { + "ticker": "AAPL", + "companyName": "Apple Inc.", + "signalDate": "2026-01-15", + "buySellRatio": 2.5, + "isClusterBuy": True, + "isClusterSell": False, + "insiderCount": 4, + **NEW, +} +WH_CREATED = { + "subscriptionId": 7, + "url": "https://example.com/hook", + "eventTypes": ["Form4Filed"], + "secret": "s3cret", + "createdAt": "2026-01-15T00:00:00Z", + "warning": None, + **NEW, +} +WH_SUB = { + "subscriptionId": 7, + "url": "https://example.com/hook", + "eventTypes": ["Form4Filed"], + "createdAt": "2026-01-15T00:00:00Z", + "isActive": True, + **NEW, +} +WH_EVENT = { + "deliveryId": 9, + "subscriptionId": 7, + "eventType": "Form4Filed", + "attemptCount": 1, + "deliveredAt": None, + "nextRetryAt": None, + "lastStatusCode": 500, + "isDead": False, + "payload": "{}", + **NEW, +} + + +@pytest.fixture +def client(): + with Form4ApiClient("test-key", max_retries=0) as c: + yield c + + +def run_async(fn): + async def go(): + async with AsyncForm4ApiClient("test-key", max_retries=0) as c: + return await fn(c) + + return asyncio.run(go()) + + +def check_insider(i): + assert isinstance(i, Insider) + assert i.cik == "0001214156" and i.name == "Cook Timothy D" + assert i.is_officer is True and i.officer_title == "CEO" and i.total_filings == 42 + assert not hasattr(i, "something_new_from_the_backend") + + +def check_company(c): + assert isinstance(c, Company) + assert c.ticker == "AAPL" and c.active_insiders == 12 and c.total_filings == 100 + assert c.sic_description == "Electronic Computers" + + +def check_signal(s): + assert isinstance(s, InsiderSignal) + assert s.ticker == "AAPL" and s.buy_sell_ratio == 2.5 and s.is_cluster_buy is True + assert s.insider_count == 4 + + +def check_created(w): + assert isinstance(w, WebhookCreated) + assert w.subscription_id == 7 and w.secret == "s3cret" and w.event_types == ["Form4Filed"] + + +def check_sub(w): + assert isinstance(w, WebhookSubscription) + assert w.subscription_id == 7 and w.is_active is True and w.created_at.startswith("2026") + + +def check_event(e): + assert isinstance(e, WebhookEvent) + assert e.delivery_id == 9 and e.last_status_code == 500 and e.payload == "{}" + + +# ── sync ───────────────────────────────────────────────────────────────────── + +@respx.mock +def test_companies_get_ignores_unknown_fields(client): + respx.get(f"{BASE}/v1/companies/AAPL").mock(return_value=httpx.Response(200, json=COMPANY)) + check_company(client.companies.get("AAPL")) + + +@respx.mock +def test_companies_insiders_ignores_unknown_fields(client): + respx.get(f"{BASE}/v1/companies/AAPL/insiders").mock(return_value=httpx.Response(200, json=[INSIDER])) + (i,) = client.companies.insiders("AAPL") + check_insider(i) + + +@respx.mock +def test_insiders_search_ignores_unknown_fields(client): + respx.get(f"{BASE}/v1/insiders").mock(return_value=httpx.Response(200, json=[INSIDER])) + (i,) = client.insiders.search("cook") + check_insider(i) + + +@respx.mock +def test_insiders_get_ignores_unknown_fields(client): + respx.get(f"{BASE}/v1/insiders/0001214156").mock(return_value=httpx.Response(200, json=INSIDER)) + check_insider(client.insiders.get("0001214156")) + + +@respx.mock +def test_signals_list_ignores_unknown_fields(client): + respx.get(f"{BASE}/v1/signals").mock(return_value=httpx.Response(200, json=[SIGNAL])) + (s,) = client.signals.list() + check_signal(s) + + +@respx.mock +def test_signals_paginate_ignores_unknown_fields(client): + respx.get(f"{BASE}/v1/signals").mock(return_value=httpx.Response(200, json=[SIGNAL])) + (page,) = list(client.signals.paginate(per_page=100, max_pages=1)) + (s,) = page + check_signal(s) + + +@respx.mock +def test_webhooks_create_ignores_unknown_fields(client): + respx.post(f"{BASE}/v1/webhooks").mock(return_value=httpx.Response(201, json=WH_CREATED)) + check_created(client.webhooks.create("https://example.com/hook", ["Form4Filed"])) + + +@respx.mock +def test_webhooks_list_ignores_unknown_fields(client): + respx.get(f"{BASE}/v1/webhooks").mock(return_value=httpx.Response(200, json=[WH_SUB])) + (w,) = client.webhooks.list() + check_sub(w) + + +@respx.mock +def test_webhooks_events_ignores_unknown_fields(client): + respx.get(f"{BASE}/v1/webhooks/events").mock(return_value=httpx.Response(200, json=[WH_EVENT])) + (e,) = client.webhooks.events() + check_event(e) + + +# ── async ──────────────────────────────────────────────────────────────────── + +@respx.mock +def test_async_companies_get_ignores_unknown_fields(): + respx.get(f"{BASE}/v1/companies/AAPL").mock(return_value=httpx.Response(200, json=COMPANY)) + check_company(run_async(lambda c: c.companies.get("AAPL"))) + + +@respx.mock +def test_async_companies_insiders_ignores_unknown_fields(): + respx.get(f"{BASE}/v1/companies/AAPL/insiders").mock(return_value=httpx.Response(200, json=[INSIDER])) + (i,) = run_async(lambda c: c.companies.insiders("AAPL")) + check_insider(i) + + +@respx.mock +def test_async_insiders_search_ignores_unknown_fields(): + respx.get(f"{BASE}/v1/insiders").mock(return_value=httpx.Response(200, json=[INSIDER])) + (i,) = run_async(lambda c: c.insiders.search("cook")) + check_insider(i) + + +@respx.mock +def test_async_insiders_get_ignores_unknown_fields(): + respx.get(f"{BASE}/v1/insiders/0001214156").mock(return_value=httpx.Response(200, json=INSIDER)) + check_insider(run_async(lambda c: c.insiders.get("0001214156"))) + + +@respx.mock +def test_async_signals_list_ignores_unknown_fields(): + respx.get(f"{BASE}/v1/signals").mock(return_value=httpx.Response(200, json=[SIGNAL])) + (s,) = run_async(lambda c: c.signals.list()) + check_signal(s) + + +@respx.mock +def test_async_signals_paginate_ignores_unknown_fields(): + respx.get(f"{BASE}/v1/signals").mock(return_value=httpx.Response(200, json=[SIGNAL])) + + async def go(c): + return [s async for s in c.signals.paginate(per_page=100, max_pages=1)] + + (page,) = run_async(go) + (s,) = page + check_signal(s) + + +@respx.mock +def test_async_webhooks_create_ignores_unknown_fields(): + respx.post(f"{BASE}/v1/webhooks").mock(return_value=httpx.Response(201, json=WH_CREATED)) + check_created(run_async(lambda c: c.webhooks.create("https://example.com/hook", ["Form4Filed"]))) + + +@respx.mock +def test_async_webhooks_list_ignores_unknown_fields(): + respx.get(f"{BASE}/v1/webhooks").mock(return_value=httpx.Response(200, json=[WH_SUB])) + (w,) = run_async(lambda c: c.webhooks.list()) + check_sub(w) + + +@respx.mock +def test_async_webhooks_events_ignores_unknown_fields(): + respx.get(f"{BASE}/v1/webhooks/events").mock(return_value=httpx.Response(200, json=[WH_EVENT])) + (e,) = run_async(lambda c: c.webhooks.events()) + check_event(e)