From 892313d9890486e74f6bc3e03b9aed7788d0ce07 Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Wed, 7 Oct 2026 07:18:43 -0700 Subject: [PATCH 1/3] fix: update Broker client to the current v3 API responses latest() failed to parse because latest items carry a delay field; peers() and collectors() used outdated field shapes and could loop forever on endpoints that ignore pagination parameters; request errors now surface as BrokerApiError. Adds unit tests over recorded response shapes and BGPKIT_BROKER_URL support for the base URL. --- bgpkit/__init__.py | 2 +- bgpkit/bgpkit_broker.py | 141 +++++++++++++++-------- tests/test_broker_unit.py | 237 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 331 insertions(+), 49 deletions(-) create mode 100644 tests/test_broker_unit.py diff --git a/bgpkit/__init__.py b/bgpkit/__init__.py index 73f3bd0..53b9416 100644 --- a/bgpkit/__init__.py +++ b/bgpkit/__init__.py @@ -1,5 +1,5 @@ from .bgpkit_parser import Filter, Parser, RouteElem, RouteParser -from .bgpkit_broker import Broker, BrokerItem, CollectorItem, PeerItem +from .bgpkit_broker import Broker, BrokerApiError, BrokerItem, CollectorItem, PeerItem from .bgpkit_roas import Roas, RoasItem from .bgpkit_ip import IpLookup, IpInfo from .bgpkit_asn import AsnLookup, AsnInfo, AsnLookupResult diff --git a/bgpkit/bgpkit_broker.py b/bgpkit/bgpkit_broker.py index b69cb93..0790a50 100644 --- a/bgpkit/bgpkit_broker.py +++ b/bgpkit/bgpkit_broker.py @@ -1,9 +1,18 @@ -from dataclasses import dataclass, field +import json +import os +from dataclasses import dataclass, fields from typing import List, Optional import requests as requests import urllib3 +DEFAULT_API_URL = "https://api.bgpkit.com/v3/broker" +DEFAULT_TIMEOUT = 30.0 + + +class BrokerApiError(RuntimeError): + """Raised when a broker API request fails or returns an error response.""" + def check_type(value: any, ty: type) -> bool: try: @@ -13,6 +22,12 @@ def check_type(value: any, ty: type) -> bool: raise ValueError("invalid option input") +def _from_item(cls, item: dict): + """Build a dataclass from an API item, ignoring unknown fields.""" + names = {field.name for field in fields(cls)} + return cls(**{key: value for key, value in item.items() if key in names}) + + @dataclass class BrokerItem: ts_start: str @@ -22,31 +37,37 @@ class BrokerItem: url: str rough_size: int exact_size: int + delay: Optional[float] = None # seconds since the latest catalog update; /latest items only @dataclass class PeerItem: + date: str + collector: str ip: str asn: int - collector: str - full_feed: bool = False + num_v4_pfxs: int + num_v6_pfxs: int + num_connected_asns: int + + @property + def full_feed(self) -> bool: + """True for full-table peers (>700k IPv4 or >100k IPv6 prefixes).""" + return self.num_v4_pfxs > 700_000 or self.num_v6_pfxs > 100_000 @dataclass class CollectorItem: - id: str name: str project: str + data_url: str + activated_on: str + deactivated_on: Optional[str] country: str - active: bool = True - latitude: Optional[float] = None - longitude: Optional[float] = None - -@dataclass -class LatestResult: - """Result of the /v3/broker/latest endpoint.""" - items: List[BrokerItem] = field(default_factory=list) + @property + def active(self) -> bool: + return self.deactivated_on is None class Broker: @@ -54,48 +75,78 @@ class Broker: Provides access to MRT data file search, peer information, collector metadata, and latest file discovery. + + The API base URL can be overridden per instance with ``api_url`` or for the + whole process with the ``BGPKIT_BROKER_URL`` environment variable. """ def __init__( self, - api_url: str = "https://api.bgpkit.com/v3/broker", + api_url: Optional[str] = None, page_size: int = 100, verify: bool = True, + timeout: float = DEFAULT_TIMEOUT, ): + if api_url is None: + api_url = os.environ.get("BGPKIT_BROKER_URL") or DEFAULT_API_URL self.base_url = api_url.rstrip("/") self.page_size = int(page_size) self.verify = verify + self.timeout = timeout if not verify: urllib3.disable_warnings() + def _request(self, endpoint: str, params: Optional[dict] = None) -> dict: + """Make a single request and return the parsed JSON body.""" + url = f"{self.base_url}/{endpoint}" + response = requests.get( + url, + params=params or {}, + verify=self.verify, + timeout=self.timeout, + ) + if response.status_code >= 400: + raise BrokerApiError( + f"GET {url} failed with HTTP {response.status_code}: {response.text[:200]}" + ) + try: + return response.json() + except ValueError as error: + raise BrokerApiError(f"GET {url} returned a non-JSON response") from error + def _paginate(self, endpoint: str, params: dict) -> dict: - """Fetch all pages of a paginated broker endpoint.""" - page = 1 - all_data = [] + """Fetch every page of a paginated broker endpoint. + Stops when the reported total is reached, or when a page adds no new + items. The latter also terminates endpoints that ignore pagination + parameters instead of looping on the same full page forever. + """ + page = 1 + all_data: List[dict] = [] + seen = set() + result = {} while True: - params["page"] = page - params["page_size"] = self.page_size - res = requests.get( - f"{self.base_url}/{endpoint}", - params=params, - verify=self.verify, - ).json() - - if isinstance(res, dict): - data = res.get("data", []) - all_data.extend(data) - if len(data) < self.page_size: - break - elif isinstance(res, list): - all_data.extend(res) + page_params = dict(params) + page_params["page"] = page + page_params["page_size"] = self.page_size + + result = self._request(endpoint, page_params) + data = result.get("data", []) + new_items = [] + for item in data: + key = json.dumps(item, sort_keys=True, default=str) + if key not in seen: + seen.add(key) + new_items.append(item) + all_data.extend(new_items) + + total = result.get("total") + if not data or not new_items: break - else: + if total is not None and len(all_data) >= int(total): break - page += 1 - result = res if isinstance(res, dict) else {"data": all_data} result["data"] = all_data return result @@ -123,18 +174,12 @@ def query( params["data_type"] = data_type result = self._paginate("search", params) - return [BrokerItem(**item) for item in result.get("data", [])] + return [_from_item(BrokerItem, item) for item in result.get("data", [])] def latest(self) -> List[BrokerItem]: - """Get latest MRT data files across projects.""" - res = requests.get( - f"{self.base_url}/latest", - verify=self.verify, - ).json() - if isinstance(res, list): - return [BrokerItem(**item) for item in res] - data = res.get("data", []) - return [BrokerItem(**item) for item in data] + """Get the latest MRT data file for every collector and data type.""" + result = self._request("latest") + return [_from_item(BrokerItem, item) for item in result.get("data", [])] def peers( self, @@ -154,8 +199,8 @@ def peers( if collector: params["collector"] = collector - result = self._paginate("peers", params) - return [PeerItem(**item) for item in result.get("data", [])] + result = self._request("peers", params) + return [_from_item(PeerItem, item) for item in result.get("data", [])] def collectors( self, @@ -172,5 +217,5 @@ def collectors( if active is not None: params["active"] = str(active).lower() - result = self._paginate("collectors", params) - return [CollectorItem(**item) for item in result.get("data", [])] + result = self._request("collectors", params) + return [_from_item(CollectorItem, item) for item in result.get("data", [])] diff --git a/tests/test_broker_unit.py b/tests/test_broker_unit.py new file mode 100644 index 0000000..8abacde --- /dev/null +++ b/tests/test_broker_unit.py @@ -0,0 +1,237 @@ +"""Unit tests for the Broker client, using recorded v3 API response shapes.""" + +import json +import os +import unittest +from unittest import mock + +from bgpkit import Broker, BrokerItem, CollectorItem, PeerItem +from bgpkit.bgpkit_broker import DEFAULT_API_URL, BrokerApiError + + +class FakeResponse: + def __init__(self, payload, status_code=200): + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload) if payload is not None else "" + + def json(self): + if self._payload is None: + raise ValueError("body is not JSON") + return self._payload + + +def mock_get(*responses): + return mock.patch("bgpkit.bgpkit_broker.requests.get", side_effect=list(responses)) + + +LATEST_PAYLOAD = { + "count": 2, + "data": [ + { + "ts_start": "2026-10-07T14:00:00", + "ts_end": "2026-10-07T14:00:00", + "collector_id": "route-views.amsix", + "data_type": "rib", + "url": "http://archive.routeviews.org/route-views.amsix/bgpdata/2026.10/RIBS/rib.20261007.1400.bz2", + "rough_size": 57344000, + "exact_size": 0, + "delay": 533, + }, + { + "ts_start": "2026-10-07T13:45:00", + "ts_end": "2026-10-07T14:00:00", + "collector_id": "route-views.amsix", + "data_type": "updates", + "url": "http://archive.routeviews.org/route-views.amsix/bgpdata/2026.10/UPDATES/updates.20261007.1345.bz2", + "rough_size": 2185216, + "exact_size": 0, + "delay": 533, + }, + ], + "meta": {"latest_update_ts": "2026-10-07T14:08:53", "latest_update_duration": 1}, +} + +PEERS_PAYLOAD = { + "only_full_feed": False, + "count": 2, + "last_updated_at": "2026-10-07T14:00:00Z", + "data": [ + { + "date": "2026-10-06", + "collector": "rrc00", + "ip": "102.208.105.2", + "asn": 328840, + "num_v4_pfxs": 1062923, + "num_v6_pfxs": 0, + "num_connected_asns": 1, + }, + { + "date": "2026-10-06", + "collector": "rrc00", + "ip": "102.217.156.3", + "asn": 328977, + "num_v4_pfxs": 21163, + "num_v6_pfxs": 0, + "num_connected_asns": 120, + }, + ], +} + +COLLECTORS_PAYLOAD = { + "count": 2, + "last_updated_at": "2026-10-07T14:00:00.000Z", + "data": [ + { + "name": "route-views2", + "project": "routeviews", + "data_url": "http://archive.routeviews.org/bgpdata", + "activated_on": "2003-01-01T00:00:00.000Z", + "deactivated_on": None, + "country": "US", + }, + { + "name": "route-views.chile", + "project": "routeviews", + "data_url": "http://archive.routeviews.org/route-views.chile/bgpdata", + "activated_on": "2018-01-31T20:00:00.000Z", + "deactivated_on": "2026-06-01T21:27:00.000Z", + "country": "CL", + }, + ], +} + +SEARCH_ITEM_A = { + "ts_start": "2026-10-05T00:00:00", + "ts_end": "2026-10-05T00:00:00", + "collector_id": "rrc00", + "data_type": "rib", + "url": "https://data.ris.ripe.net/rrc00/2026.10/bview.20261005.0000.gz", + "rough_size": 146800640, + "exact_size": 0, +} +SEARCH_ITEM_B = { + "ts_start": "2026-10-05T00:00:00", + "ts_end": "2026-10-05T00:05:00", + "collector_id": "rrc00", + "data_type": "updates", + "url": "https://data.ris.ripe.net/rrc00/2026.10/updates.20261005.0000.gz", + "rough_size": 835584, + "exact_size": 0, +} +SEARCH_ITEM_C = { + "ts_start": "2026-10-05T00:05:00", + "ts_end": "2026-10-05T00:10:00", + "collector_id": "rrc00", + "data_type": "updates", + "url": "https://data.ris.ripe.net/rrc00/2026.10/updates.20261005.0005.gz", + "rough_size": 790528, + "exact_size": 0, +} + +SEARCH_PAGE_1 = { + "total": 3, + "count": 2, + "page": 1, + "page_size": 2, + "error": None, + "data": [SEARCH_ITEM_A, SEARCH_ITEM_B], + "meta": {"latest_update_ts": "2026-10-07T14:08:53", "latest_update_duration": 1}, +} +SEARCH_PAGE_2 = { + "total": 3, + "count": 1, + "page": 2, + "page_size": 2, + "error": None, + "data": [SEARCH_ITEM_C], + "meta": {"latest_update_ts": "2026-10-07T14:08:53", "latest_update_duration": 1}, +} +SEARCH_PAGE_WITHOUT_TOTAL = { + "count": 2, + "data": [SEARCH_ITEM_A, SEARCH_ITEM_B], +} + + +class TestBrokerLatest(unittest.TestCase): + def test_parses_items_with_delay_and_ignores_unknown_fields(self): + payload = json.loads(json.dumps(LATEST_PAYLOAD)) + payload["data"][0]["future_field"] = True + with mock_get(FakeResponse(payload)) as get: + items = Broker().latest() + self.assertEqual(len(items), 2) + self.assertIsInstance(items[0], BrokerItem) + self.assertEqual(items[0].collector_id, "route-views.amsix") + self.assertEqual(items[0].delay, 533) + self.assertEqual(get.call_count, 1) + + +class TestBrokerPeers(unittest.TestCase): + def test_parses_peer_items(self): + with mock_get(FakeResponse(PEERS_PAYLOAD)) as get: + peers = Broker(page_size=10).peers(collector="rrc00") + self.assertEqual(len(peers), 2) + self.assertIsInstance(peers[0], PeerItem) + self.assertEqual(peers[0].asn, 328840) + self.assertTrue(peers[0].full_feed) + self.assertFalse(peers[1].full_feed) + # the peers endpoint returns every match in one response + self.assertEqual(get.call_count, 1) + + +class TestBrokerCollectors(unittest.TestCase): + def test_parses_collector_items(self): + with mock_get(FakeResponse(COLLECTORS_PAYLOAD)) as get: + collectors = Broker(page_size=10).collectors(project="routeviews") + self.assertEqual(len(collectors), 2) + self.assertIsInstance(collectors[0], CollectorItem) + self.assertEqual(collectors[0].name, "route-views2") + self.assertTrue(collectors[0].active) + self.assertFalse(collectors[1].active) + self.assertEqual(collectors[1].deactivated_on, "2026-06-01T21:27:00.000Z") + self.assertEqual(get.call_count, 1) + + +class TestBrokerQuery(unittest.TestCase): + def test_paginates_until_total_is_reached(self): + responses = [FakeResponse(SEARCH_PAGE_1), FakeResponse(SEARCH_PAGE_2)] + with mock_get(*responses) as get: + items = Broker(page_size=2).query(collector_id="rrc00") + self.assertEqual([item.url for item in items], [SEARCH_ITEM_A["url"], SEARCH_ITEM_B["url"], SEARCH_ITEM_C["url"]]) + self.assertEqual(get.call_count, 2) + self.assertEqual(get.call_args_list[1].kwargs["params"]["page"], 2) + + def test_stops_when_a_page_adds_no_new_items(self): + responses = [FakeResponse(SEARCH_PAGE_WITHOUT_TOTAL), FakeResponse(SEARCH_PAGE_WITHOUT_TOTAL)] + with mock_get(*responses) as get: + items = Broker(page_size=2).query() + self.assertEqual(len(items), 2) + self.assertEqual(get.call_count, 2) + + def test_raises_on_http_error(self): + with mock_get(FakeResponse({"error": "boom"}, status_code=500)): + with self.assertRaises(BrokerApiError) as context: + Broker().query(collector_id="rrc00") + self.assertIn("HTTP 500", str(context.exception)) + + +class TestBrokerConfiguration(unittest.TestCase): + def test_default_api_url(self): + environment = {key: value for key, value in os.environ.items() if key != "BGPKIT_BROKER_URL"} + with mock.patch.dict(os.environ, environment, clear=True): + self.assertEqual(Broker().base_url, DEFAULT_API_URL) + + def test_environment_variable_overrides_default(self): + with mock.patch.dict(os.environ, {"BGPKIT_BROKER_URL": "https://broker.example.com/v2/broker/"}): + self.assertEqual(Broker().base_url, "https://broker.example.com/v2/broker") + + def test_explicit_argument_wins_over_environment(self): + with mock.patch.dict(os.environ, {"BGPKIT_BROKER_URL": "https://broker.example.com/v2/broker"}): + self.assertEqual( + Broker(api_url="https://other.example.com/broker").base_url, + "https://other.example.com/broker", + ) + + +if __name__ == "__main__": + unittest.main() From 07c431a4842b8d53a5c3605d8fe405d974c5a247 Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Wed, 7 Oct 2026 07:21:37 -0700 Subject: [PATCH 2/3] fix: align AsnLookup, CommunityLookup, and Roas with the v3 API; add unit-test CI AsnLookup parsed a flat item shape; v3 items are nested (as2org, hegemony, peeringdb, population). AsnInfo now models the nested fields and Pagination matches the API object. CommunityLookup described items that do not exist: /communities is per-ASN with a nested community list, so query() returns one entry per community and sources() reads the sources key. Roas now targets the v3 endpoint, paginates to the reported total, and uses the endpoint's 0-indexed page parameter. Shared parsing and HTTP error handling move to bgpkit/_utils (BGPKITApiError, from_item, request_json); the Broker client uses the same helpers. Adds unit tests for every client and a workflow that runs them on push and pull request. --- .github/workflows/test.yml | 25 +++++++++ bgpkit/__init__.py | 3 +- bgpkit/_utils.py | 36 ++++++++++++ bgpkit/bgpkit_asn.py | 74 ++++++++++++++++++++----- bgpkit/bgpkit_broker.py | 39 ++++--------- bgpkit/bgpkit_community.py | 47 ++++++++++++---- bgpkit/bgpkit_roas.py | 51 ++++++++++++----- tests/test_asn_unit.py | 85 +++++++++++++++++++++++++++++ tests/test_broker_unit.py | 8 +-- tests/test_community_unit.py | 103 +++++++++++++++++++++++++++++++++++ tests/test_integration.py | 23 ++++++++ tests/test_roas_unit.py | 90 ++++++++++++++++++++++++++++++ 12 files changed, 511 insertions(+), 73 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 bgpkit/_utils.py create mode 100644 tests/test_asn_unit.py create mode 100644 tests/test_community_unit.py create mode 100644 tests/test_roas_unit.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7822c40 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Tests + +on: + push: + branches: [ main ] + pull_request: + +jobs: + unit-tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [ "3.9", "3.12" ] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install package + run: | + python -m pip install --upgrade pip + python -m pip install . + - name: Run unit tests + run: python -m unittest discover -s tests -p "test_*_unit.py" -v diff --git a/bgpkit/__init__.py b/bgpkit/__init__.py index 53b9416..cccda11 100644 --- a/bgpkit/__init__.py +++ b/bgpkit/__init__.py @@ -1,5 +1,6 @@ from .bgpkit_parser import Filter, Parser, RouteElem, RouteParser -from .bgpkit_broker import Broker, BrokerApiError, BrokerItem, CollectorItem, PeerItem +from ._utils import BGPKITApiError +from .bgpkit_broker import Broker, BrokerItem, CollectorItem, PeerItem from .bgpkit_roas import Roas, RoasItem from .bgpkit_ip import IpLookup, IpInfo from .bgpkit_asn import AsnLookup, AsnInfo, AsnLookupResult diff --git a/bgpkit/_utils.py b/bgpkit/_utils.py new file mode 100644 index 0000000..e3538fe --- /dev/null +++ b/bgpkit/_utils.py @@ -0,0 +1,36 @@ +"""Shared helpers for the BGPKIT API clients.""" + +from dataclasses import fields +from typing import Optional + +import requests + +DEFAULT_TIMEOUT = 30.0 + + +class BGPKITApiError(RuntimeError): + """Raised when an API request fails or returns an error response.""" + + +def from_item(cls, item: dict): + """Build a dataclass from an API item, ignoring unknown fields.""" + names = {field.name for field in fields(cls)} + return cls(**{key: value for key, value in item.items() if key in names}) + + +def request_json( + url: str, + params: Optional[dict] = None, + verify: bool = True, + timeout: float = DEFAULT_TIMEOUT, +) -> dict: + """GET a JSON API endpoint, raising BGPKITApiError on failures.""" + response = requests.get(url, params=params or {}, verify=verify, timeout=timeout) + if response.status_code >= 400: + raise BGPKITApiError( + f"GET {url} failed with HTTP {response.status_code}: {response.text[:200]}" + ) + try: + return response.json() + except ValueError as error: + raise BGPKITApiError(f"GET {url} returned a non-JSON response") from error diff --git a/bgpkit/bgpkit_asn.py b/bgpkit/bgpkit_asn.py index 94ffd93..82183ce 100644 --- a/bgpkit/bgpkit_asn.py +++ b/bgpkit/bgpkit_asn.py @@ -1,13 +1,17 @@ from dataclasses import dataclass from typing import List, Optional -import requests +from ._utils import DEFAULT_TIMEOUT, from_item, request_json @dataclass class Pagination: - next: Optional[str] = None - previous: Optional[str] = None + page: int = 0 + page_size: int = 0 + total_pages: int = 0 + total_count: int = 0 + has_next: bool = False + has_prev: bool = False @dataclass @@ -15,10 +19,13 @@ class AsnInfo: asn: int name: str country: str = "" + country_name: str = "" org_name: str = "" org_id: str = "" - description: str = "" - website: str = "" + as2org: Optional[dict] = None + hegemony: Optional[dict] = None + peeringdb: Optional[dict] = None + population: Optional[dict] = None @dataclass @@ -34,8 +41,13 @@ class AsnLookupResult: class AsnLookup: """BGPKIT ASN information lookup (v3/utils/asn).""" - def __init__(self, api_url: str = "https://api.bgpkit.com/v3/utils"): + def __init__( + self, + api_url: str = "https://api.bgpkit.com/v3/utils", + timeout: float = DEFAULT_TIMEOUT, + ): self.base_url = api_url.rstrip("/") + self.timeout = timeout def query( self, @@ -45,6 +57,18 @@ def query( page: int = 1, page_size: int = 100, ) -> AsnLookupResult: + """Look up ASN information. + + Args: + asn: AS number, or a comma-separated list of AS numbers. + country: Filter by ISO country code. + search: Free-text search over ASN names and organizations. + page: Page number (1-indexed). + page_size: Results per page. + + Returns: + AsnLookupResult with the parsed ASN entries and pagination info. + """ params = {} if asn: params["asn"] = asn @@ -57,14 +81,36 @@ def query( if page_size: params["page_size"] = min(page_size, 10000) - res = requests.get(f"{self.base_url}/asn", params=params).json() - data = [AsnInfo(**item) for item in res.get("data", [])] - pag = res.get("pagination", {}) + response = request_json(f"{self.base_url}/asn", params=params, timeout=self.timeout) + + data = [] + for item in response.get("data", []): + as2org = item.get("as2org") if isinstance(item.get("as2org"), dict) else {} + data.append( + AsnInfo( + asn=item.get("asn"), + name=item.get("name", ""), + country=item.get("country", ""), + country_name=item.get("country_name", ""), + org_name=as2org.get("org_name", ""), + org_id=as2org.get("org_id", ""), + as2org=as2org or None, + hegemony=item.get("hegemony"), + peeringdb=item.get("peeringdb"), + population=item.get("population"), + ) + ) + + pagination_data = response.get("pagination") + pagination = ( + from_item(Pagination, pagination_data) if isinstance(pagination_data, dict) else None + ) + return AsnLookupResult( data=data, - count=res.get("count", 0), - page=res.get("page", page), - page_size=res.get("page_size", page_size), - updated_at=res.get("updatedAt", ""), - pagination=Pagination(**pag) if pag else None, + count=response.get("count", 0), + page=response.get("page", page), + page_size=response.get("page_size", page_size), + updated_at=response.get("updatedAt", ""), + pagination=pagination, ) diff --git a/bgpkit/bgpkit_broker.py b/bgpkit/bgpkit_broker.py index 0790a50..8520098 100644 --- a/bgpkit/bgpkit_broker.py +++ b/bgpkit/bgpkit_broker.py @@ -1,17 +1,13 @@ import json import os -from dataclasses import dataclass, fields +from dataclasses import dataclass from typing import List, Optional -import requests as requests import urllib3 -DEFAULT_API_URL = "https://api.bgpkit.com/v3/broker" -DEFAULT_TIMEOUT = 30.0 - +from ._utils import DEFAULT_TIMEOUT, BGPKITApiError, from_item, request_json -class BrokerApiError(RuntimeError): - """Raised when a broker API request fails or returns an error response.""" +DEFAULT_API_URL = "https://api.bgpkit.com/v3/broker" def check_type(value: any, ty: type) -> bool: @@ -22,12 +18,6 @@ def check_type(value: any, ty: type) -> bool: raise ValueError("invalid option input") -def _from_item(cls, item: dict): - """Build a dataclass from an API item, ignoring unknown fields.""" - names = {field.name for field in fields(cls)} - return cls(**{key: value for key, value in item.items() if key in names}) - - @dataclass class BrokerItem: ts_start: str @@ -98,21 +88,12 @@ def __init__( def _request(self, endpoint: str, params: Optional[dict] = None) -> dict: """Make a single request and return the parsed JSON body.""" - url = f"{self.base_url}/{endpoint}" - response = requests.get( - url, - params=params or {}, + return request_json( + f"{self.base_url}/{endpoint}", + params=params, verify=self.verify, timeout=self.timeout, ) - if response.status_code >= 400: - raise BrokerApiError( - f"GET {url} failed with HTTP {response.status_code}: {response.text[:200]}" - ) - try: - return response.json() - except ValueError as error: - raise BrokerApiError(f"GET {url} returned a non-JSON response") from error def _paginate(self, endpoint: str, params: dict) -> dict: """Fetch every page of a paginated broker endpoint. @@ -174,12 +155,12 @@ def query( params["data_type"] = data_type result = self._paginate("search", params) - return [_from_item(BrokerItem, item) for item in result.get("data", [])] + return [from_item(BrokerItem, item) for item in result.get("data", [])] def latest(self) -> List[BrokerItem]: """Get the latest MRT data file for every collector and data type.""" result = self._request("latest") - return [_from_item(BrokerItem, item) for item in result.get("data", [])] + return [from_item(BrokerItem, item) for item in result.get("data", [])] def peers( self, @@ -200,7 +181,7 @@ def peers( params["collector"] = collector result = self._request("peers", params) - return [_from_item(PeerItem, item) for item in result.get("data", [])] + return [from_item(PeerItem, item) for item in result.get("data", [])] def collectors( self, @@ -218,4 +199,4 @@ def collectors( params["active"] = str(active).lower() result = self._request("collectors", params) - return [_from_item(CollectorItem, item) for item in result.get("data", [])] + return [from_item(CollectorItem, item) for item in result.get("data", [])] diff --git a/bgpkit/bgpkit_community.py b/bgpkit/bgpkit_community.py index 05cd89b..d41ddd2 100644 --- a/bgpkit/bgpkit_community.py +++ b/bgpkit/bgpkit_community.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import List, Optional -import requests +from ._utils import DEFAULT_TIMEOUT, from_item, request_json @dataclass @@ -12,6 +12,7 @@ class CommunityEntry: as_name: str = "" country: str = "" source: str = "" + range: Optional[str] = None @dataclass @@ -19,13 +20,20 @@ class CommunitySource: id: str name: str url: str = "" + last_updated: Optional[str] = None + stats: Optional[dict] = None class CommunityLookup: """BGPKIT community lookup (v3/communities).""" - def __init__(self, api_url: str = "https://api.bgpkit.com/v3/communities"): + def __init__( + self, + api_url: str = "https://api.bgpkit.com/v3/communities", + timeout: float = DEFAULT_TIMEOUT, + ): self.base_url = api_url.rstrip("/") + self.timeout = timeout def query( self, @@ -37,6 +45,11 @@ def query( page: int = 0, page_size: int = 10, ) -> List[CommunityEntry]: + """Query BGP communities. + + The API returns one entry per ASN with a nested list of communities; + this returns one CommunityEntry per community. + """ params = {} if asn: params["asn"] = asn @@ -51,13 +64,27 @@ def query( params["page"] = str(page) params["page_size"] = str(min(page_size, 1000)) - res = requests.get(self.base_url, params=params).json() - if isinstance(res, list): - return [CommunityEntry(**item) for item in res] - return [CommunityEntry(**item) for item in res.get("data", [])] + response = request_json(self.base_url, params=params, timeout=self.timeout) + + entries = [] + for item in response.get("data", []): + for community in item.get("communities", []) or []: + entries.append( + CommunityEntry( + asn=item.get("asn"), + value=community.get("community", ""), + description=community.get("description") or "", + as_name=item.get("name", ""), + country=item.get("country", ""), + source=community.get("source", ""), + range=community.get("range"), + ) + ) + return entries def sources(self) -> List[CommunitySource]: - res = requests.get(f"{self.base_url}/sources").json() - if isinstance(res, list): - return [CommunitySource(**item) for item in res] - return [CommunitySource(**item) for item in res.get("data", [])] + """List the community data sources.""" + response = request_json(f"{self.base_url}/sources", timeout=self.timeout) + if isinstance(response, list): + return [from_item(CommunitySource, item) for item in response] + return [from_item(CommunitySource, item) for item in response.get("sources", [])] diff --git a/bgpkit/bgpkit_roas.py b/bgpkit/bgpkit_roas.py index a8dde84..452fae4 100644 --- a/bgpkit/bgpkit_roas.py +++ b/bgpkit/bgpkit_roas.py @@ -1,7 +1,9 @@ from dataclasses import dataclass from typing import List, Optional -import requests +from ._utils import DEFAULT_TIMEOUT, from_item, request_json + +DEFAULT_API_URL = "https://api.bgpkit.com/v3/roas" @dataclass @@ -15,14 +17,22 @@ class RoasItem: class Roas: - """BGPKIT ROAS lookup (alpha API). + """BGPKIT ROAS lookup (v3/roas). - Queries the ROAS (Route Origination Authorization) database - for historical and current RPKI data. + Queries the ROAS (Route Origin Authorization) database for historical and + current RPKI data. The base URL can be overridden per instance with + ``api_url``. """ - def __init__(self, api_url: str = "https://alpha.api.bgpkit.com"): + def __init__( + self, + api_url: str = DEFAULT_API_URL, + page_size: int = 100, + timeout: float = DEFAULT_TIMEOUT, + ): self.base_url = api_url.rstrip("/") + self.page_size = int(page_size) + self.timeout = timeout def query( self, @@ -30,18 +40,18 @@ def query( prefix: str = None, date: str = None, current: bool = None, - page: int = 1, + page: int = 0, page_size: int = None, ) -> List[RoasItem]: - """Query ROAS database. + """Query the ROAS database, fetching every page from ``page`` onward. Args: asn: AS number to filter by. prefix: IP prefix to filter by. date: Date string (YYYY-MM-DD) for historical lookup. current: If True, return only currently valid ROAs. - page: Page number (1-indexed). - page_size: Results per page. Defaults to 5 (alpha API limitation). + page: Page number to start from (0-indexed). + page_size: Results per page (defaults to the instance page size). Returns: List of RoasItem matching the query. @@ -55,10 +65,21 @@ def query( params["date"] = date if current is not None: params["current"] = str(current).lower() - params["page"] = str(page) - params["page_size"] = str(page_size if page_size is not None else 5) - res = requests.get(f"{self.base_url}/roas", params=params).json() - if isinstance(res, list): - return [RoasItem(**item) for item in res] - return [RoasItem(**item) for item in res.get("data", [])] + size = int(page_size) if page_size is not None else self.page_size + items: List[RoasItem] = [] + while True: + page_params = dict(params) + page_params["page"] = page + page_params["page_size"] = size + response = request_json(f"{self.base_url}/search", params=page_params, timeout=self.timeout) + + data = response.get("data", []) + items.extend(from_item(RoasItem, item) for item in data) + + total = response.get("total") + if not data or (total is not None and len(items) >= int(total)): + break + page += 1 + + return items diff --git a/tests/test_asn_unit.py b/tests/test_asn_unit.py new file mode 100644 index 0000000..734b6b1 --- /dev/null +++ b/tests/test_asn_unit.py @@ -0,0 +1,85 @@ +"""Unit tests for AsnLookup, using recorded v3 API response shapes.""" + +import json +import unittest +from unittest import mock + +from bgpkit import BGPKITApiError, AsnInfo, AsnLookup + + +class FakeResponse: + def __init__(self, payload, status_code=200): + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload) if payload is not None else "" + + def json(self): + if self._payload is None: + raise ValueError("body is not JSON") + return self._payload + + +def mock_get(response): + return mock.patch("bgpkit._utils.requests.get", return_value=response) + + +ASN_PAYLOAD = { + "count": 1, + "data": [ + { + "as2org": { + "country": "US", + "name": "GOOGLE", + "org_id": "GOGL-ARIN", + "org_name": "Google LLC", + }, + "asn": 15169, + "country": "US", + "country_name": "United States", + "hegemony": {"asn": 15169, "ipv4": 0.0079, "ipv6": 0.0027}, + "name": "GOOGLE - Google LLC", + "peeringdb": {"asn": 15169, "name": "Google LLC", "irr_as_set": "RADB::AS-GOOGLE"}, + "population": {"percent_country": 0, "percent_global": 0, "user_count": 82}, + } + ], + "page": 0, + "page_size": 1, + "pagination": { + "page": 0, + "page_size": 1, + "total_pages": 1, + "total_count": 1, + "has_next": False, + "has_prev": False, + }, + "updatedAt": "2026-10-07T12:00:00", +} + + +class TestAsnLookup(unittest.TestCase): + def test_parses_nested_asn_item(self): + with mock_get(FakeResponse(ASN_PAYLOAD)): + result = AsnLookup().query(asn="15169") + self.assertEqual(result.count, 1) + info = result.data[0] + self.assertIsInstance(info, AsnInfo) + self.assertEqual(info.asn, 15169) + self.assertEqual(info.name, "GOOGLE - Google LLC") + self.assertEqual(info.country_name, "United States") + self.assertEqual(info.org_name, "Google LLC") + self.assertEqual(info.org_id, "GOGL-ARIN") + self.assertEqual(info.hegemony, {"asn": 15169, "ipv4": 0.0079, "ipv6": 0.0027}) + self.assertEqual(result.updated_at, "2026-10-07T12:00:00") + self.assertIsNotNone(result.pagination) + self.assertEqual(result.pagination.total_count, 1) + self.assertFalse(result.pagination.has_next) + + def test_raises_on_http_error(self): + with mock_get(FakeResponse({"error": "boom"}, status_code=500)): + with self.assertRaises(BGPKITApiError) as context: + AsnLookup().query(asn="15169") + self.assertIn("HTTP 500", str(context.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_broker_unit.py b/tests/test_broker_unit.py index 8abacde..93ce273 100644 --- a/tests/test_broker_unit.py +++ b/tests/test_broker_unit.py @@ -5,8 +5,8 @@ import unittest from unittest import mock -from bgpkit import Broker, BrokerItem, CollectorItem, PeerItem -from bgpkit.bgpkit_broker import DEFAULT_API_URL, BrokerApiError +from bgpkit import BGPKITApiError, Broker, BrokerItem, CollectorItem, PeerItem +from bgpkit.bgpkit_broker import DEFAULT_API_URL class FakeResponse: @@ -22,7 +22,7 @@ def json(self): def mock_get(*responses): - return mock.patch("bgpkit.bgpkit_broker.requests.get", side_effect=list(responses)) + return mock.patch("bgpkit._utils.requests.get", side_effect=list(responses)) LATEST_PAYLOAD = { @@ -210,7 +210,7 @@ def test_stops_when_a_page_adds_no_new_items(self): def test_raises_on_http_error(self): with mock_get(FakeResponse({"error": "boom"}, status_code=500)): - with self.assertRaises(BrokerApiError) as context: + with self.assertRaises(BGPKITApiError) as context: Broker().query(collector_id="rrc00") self.assertIn("HTTP 500", str(context.exception)) diff --git a/tests/test_community_unit.py b/tests/test_community_unit.py new file mode 100644 index 0000000..1808612 --- /dev/null +++ b/tests/test_community_unit.py @@ -0,0 +1,103 @@ +"""Unit tests for CommunityLookup, using recorded v3 API response shapes.""" + +import json +import unittest +from unittest import mock + +from bgpkit import BGPKITApiError, CommunityEntry, CommunityLookup, CommunitySource + + +class FakeResponse: + def __init__(self, payload, status_code=200): + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload) if payload is not None else "" + + def json(self): + if self._payload is None: + raise ValueError("body is not JSON") + return self._payload + + +def mock_get(response): + return mock.patch("bgpkit._utils.requests.get", return_value=response) + + +COMMUNITIES_PAYLOAD = { + "total_communities_all": 2, + "total_communities_on_page": 2, + "total_asns_on_page": 1, + "page": 0, + "page_size": 10, + "data": [ + { + "asn": 15169, + "name": "GOOGLE - Google LLC", + "country": "US", + "source": "asn_lookup", + "communities": [ + { + "community": "15169:10010", + "source": "nlnog", + "description": "GPRS Mobile Broadband", + "range": None, + }, + { + "community": "15169:10020", + "source": "nlnog", + "description": "2.5G/Edge Mobile Broadband", + "range": "10.0.0.0/8", + }, + ], + } + ], +} + +SOURCES_PAYLOAD = { + "sources": [ + { + "id": "nlnog", + "name": "NLNOG Looking Glass", + "url": "https://github.com/NLNOG/lg.ring.nlnog.net", + "last_updated": "2026-10-06T03:00:39.293Z", + "stats": {"total_asns": 107, "total_communities": 7104, "total_wildcards": 255}, + } + ] +} + + +class TestCommunityLookup(unittest.TestCase): + def test_flattens_communities_per_asn(self): + with mock_get(FakeResponse(COMMUNITIES_PAYLOAD)): + entries = CommunityLookup().query(asn="15169") + self.assertEqual(len(entries), 2) + entry = entries[0] + self.assertIsInstance(entry, CommunityEntry) + self.assertEqual(entry.asn, 15169) + self.assertEqual(entry.value, "15169:10010") + self.assertEqual(entry.description, "GPRS Mobile Broadband") + self.assertEqual(entry.as_name, "GOOGLE - Google LLC") + self.assertEqual(entry.country, "US") + self.assertEqual(entry.source, "nlnog") + self.assertIsNone(entry.range) + self.assertEqual(entries[1].range, "10.0.0.0/8") + + def test_parses_sources(self): + with mock_get(FakeResponse(SOURCES_PAYLOAD)): + sources = CommunityLookup().sources() + self.assertEqual(len(sources), 1) + source = sources[0] + self.assertIsInstance(source, CommunitySource) + self.assertEqual(source.id, "nlnog") + self.assertEqual(source.name, "NLNOG Looking Glass") + self.assertEqual(source.stats["total_communities"], 7104) + + def test_raises_on_http_error(self): + with mock_get(FakeResponse({"error": "boom"}, status_code=500)): + with self.assertRaises(BGPKITApiError) as context: + CommunityLookup().query(asn="15169") + self.assertIn("HTTP 500", str(context.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_integration.py b/tests/test_integration.py index a4e7e01..ba53047 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -33,11 +33,34 @@ def test_broker(self): collector_id="rrc00") self.assertGreater(len(items), 0) + latest = broker.latest() + self.assertGreater(len(latest), 100) + self.assertTrue(all(item.delay is not None for item in latest)) + + peers = broker.peers(collector="rrc00") + self.assertGreater(len(peers), 0) + + collectors = broker.collectors(project="routeviews") + self.assertGreater(len(collectors), 0) + def test_broker_no_verify(self): broker = bgpkit.Broker(verify=False) items = broker.query(ts_start="1643760000", ts_end="2022-02-02T00:20:00", collector_id="rrc00") self.assertGreater(len(items), 0) + def test_asn_lookup(self): + result = bgpkit.AsnLookup().query(asn="15169") + self.assertEqual(result.count, 1) + self.assertEqual(result.data[0].asn, 15169) + self.assertGreater(len(result.data[0].name), 0) + + def test_community_lookup(self): + lookup = bgpkit.CommunityLookup() + entries = lookup.query(asn="15169", page_size=1) + self.assertGreater(len(entries), 0) + sources = lookup.sources() + self.assertGreater(len(sources), 0) + def test_roas(self): roas = bgpkit.Roas() data = roas.query(asn=3333, date="2018-01-01") diff --git a/tests/test_roas_unit.py b/tests/test_roas_unit.py new file mode 100644 index 0000000..2c7c617 --- /dev/null +++ b/tests/test_roas_unit.py @@ -0,0 +1,90 @@ +"""Unit tests for Roas, using recorded v3 API response shapes.""" + +import json +import unittest +from unittest import mock + +from bgpkit import BGPKITApiError, Roas, RoasItem + + +class FakeResponse: + def __init__(self, payload, status_code=200): + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload) if payload is not None else "" + + def json(self): + if self._payload is None: + raise ValueError("body is not JSON") + return self._payload + + +def mock_get(*responses): + return mock.patch("bgpkit._utils.requests.get", side_effect=list(responses)) + + +ROAS_ITEM_A = { + "prefix": "193.0.20.0/23", + "max_len": 23, + "asn": 3333, + "date_ranges": [["2015-03-10", "2016-01-26"]], + "current": False, +} +ROAS_ITEM_B = { + "prefix": "193.0.22.0/23", + "max_len": 23, + "asn": 3333, + "date_ranges": [["2015-03-10", "2024-01-01"], ["2024-06-01", "2026-10-07"]], + "current": True, +} +ROAS_ITEM_C = { + "prefix": "193.0.24.0/23", + "max_len": 23, + "asn": 3333, + "date_ranges": [["2015-03-10", "2024-01-01"]], + "current": False, +} + +ROAS_PAGE_1 = { + "total": 3, + "page": 1, + "page_size": 2, + "error": None, + "data": [ROAS_ITEM_A, ROAS_ITEM_B], + "meta": {"latest_date": "2026-10-07"}, +} +ROAS_PAGE_2 = { + "total": 3, + "page": 2, + "page_size": 2, + "error": None, + "data": [ROAS_ITEM_C], + "meta": {"latest_date": "2026-10-07"}, +} + + +class TestRoas(unittest.TestCase): + def test_default_api_url_is_v3(self): + self.assertEqual(Roas().base_url, "https://api.bgpkit.com/v3/roas") + + def test_paginates_until_total_is_reached(self): + with mock_get(FakeResponse(ROAS_PAGE_1), FakeResponse(ROAS_PAGE_2)) as get: + items = Roas(page_size=2).query(asn=3333) + self.assertEqual([item.prefix for item in items], [ROAS_ITEM_A["prefix"], ROAS_ITEM_B["prefix"], ROAS_ITEM_C["prefix"]]) + self.assertIsInstance(items[0], RoasItem) + self.assertEqual(items[1].current, True) + self.assertEqual(get.call_count, 2) + self.assertTrue(get.call_args_list[0].args[0].endswith("/search")) + # the ROAS endpoint uses a 0-indexed page parameter + self.assertEqual(get.call_args_list[0].kwargs["params"]["page"], 0) + self.assertEqual(get.call_args_list[1].kwargs["params"]["page"], 1) + + def test_raises_on_http_error(self): + with mock_get(FakeResponse({"error": "boom"}, status_code=500)): + with self.assertRaises(BGPKITApiError) as context: + Roas().query(asn=3333) + self.assertIn("HTTP 500", str(context.exception)) + + +if __name__ == "__main__": + unittest.main() From ece6a0716cfb670d5ac5e411d1a23a356ce1759e Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Wed, 7 Oct 2026 07:33:14 -0700 Subject: [PATCH 3/3] fix: address review findings on ASN pagination and transport errors Default AsnLookup to page 0: the v3 ASN endpoint paginates from zero (verified live: page=0 returns the first page, page=1 the second), so the previous default skipped the first page of any broad query. request_json now wraps requests transport failures (timeouts, connection errors) in BGPKITApiError so all request failures surface through the exported exception. Adds assertions for both. --- bgpkit/_utils.py | 5 +++- bgpkit/bgpkit_asn.py | 6 ++-- tests/test_asn_unit.py | 4 ++- tests/test_utils_unit.py | 65 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 tests/test_utils_unit.py diff --git a/bgpkit/_utils.py b/bgpkit/_utils.py index e3538fe..f53611b 100644 --- a/bgpkit/_utils.py +++ b/bgpkit/_utils.py @@ -25,7 +25,10 @@ def request_json( timeout: float = DEFAULT_TIMEOUT, ) -> dict: """GET a JSON API endpoint, raising BGPKITApiError on failures.""" - response = requests.get(url, params=params or {}, verify=verify, timeout=timeout) + try: + response = requests.get(url, params=params or {}, verify=verify, timeout=timeout) + except requests.RequestException as error: + raise BGPKITApiError(f"GET {url} failed: {error}") from error if response.status_code >= 400: raise BGPKITApiError( f"GET {url} failed with HTTP {response.status_code}: {response.text[:200]}" diff --git a/bgpkit/bgpkit_asn.py b/bgpkit/bgpkit_asn.py index 82183ce..d972557 100644 --- a/bgpkit/bgpkit_asn.py +++ b/bgpkit/bgpkit_asn.py @@ -54,7 +54,7 @@ def query( asn: str = None, country: str = None, search: str = None, - page: int = 1, + page: int = 0, page_size: int = 100, ) -> AsnLookupResult: """Look up ASN information. @@ -63,7 +63,7 @@ def query( asn: AS number, or a comma-separated list of AS numbers. country: Filter by ISO country code. search: Free-text search over ASN names and organizations. - page: Page number (1-indexed). + page: Page number to start from (0-indexed). page_size: Results per page. Returns: @@ -76,7 +76,7 @@ def query( params["country"] = country if search: params["search"] = search - if page: + if page is not None: params["page"] = page if page_size: params["page_size"] = min(page_size, 10000) diff --git a/tests/test_asn_unit.py b/tests/test_asn_unit.py index 734b6b1..4f4ca8f 100644 --- a/tests/test_asn_unit.py +++ b/tests/test_asn_unit.py @@ -58,8 +58,10 @@ def mock_get(response): class TestAsnLookup(unittest.TestCase): def test_parses_nested_asn_item(self): - with mock_get(FakeResponse(ASN_PAYLOAD)): + with mock_get(FakeResponse(ASN_PAYLOAD)) as get: result = AsnLookup().query(asn="15169") + # the v3 ASN endpoint uses a 0-indexed page parameter + self.assertEqual(get.call_args.kwargs["params"]["page"], 0) self.assertEqual(result.count, 1) info = result.data[0] self.assertIsInstance(info, AsnInfo) diff --git a/tests/test_utils_unit.py b/tests/test_utils_unit.py new file mode 100644 index 0000000..3ba1605 --- /dev/null +++ b/tests/test_utils_unit.py @@ -0,0 +1,65 @@ +"""Unit tests for the shared API helpers.""" + +import json +import unittest +from dataclasses import dataclass +from unittest import mock + +import requests + +from bgpkit._utils import BGPKITApiError, from_item, request_json + + +class FakeResponse: + def __init__(self, payload, status_code=200): + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload) if payload is not None else "" + + def json(self): + if self._payload is None: + raise ValueError("body is not JSON") + return self._payload + + +class TestRequestJson(unittest.TestCase): + def test_returns_parsed_json(self): + with mock.patch( + "bgpkit._utils.requests.get", return_value=FakeResponse({"ok": True}) + ) as get: + result = request_json("https://api.example.com/x", params={"a": 1}) + self.assertEqual(result, {"ok": True}) + self.assertEqual(get.call_args.kwargs["params"], {"a": 1}) + + def test_wraps_transport_failures(self): + with mock.patch( + "bgpkit._utils.requests.get", side_effect=requests.ConnectionError("boom") + ): + with self.assertRaises(BGPKITApiError) as context: + request_json("https://api.example.com/x") + self.assertIn("failed", str(context.exception)) + + def test_wraps_timeouts(self): + with mock.patch("bgpkit._utils.requests.get", side_effect=requests.Timeout("too slow")): + with self.assertRaises(BGPKITApiError): + request_json("https://api.example.com/x") + + def test_wraps_non_json_bodies(self): + with mock.patch("bgpkit._utils.requests.get", return_value=FakeResponse(None)): + with self.assertRaises(BGPKITApiError) as context: + request_json("https://api.example.com/x") + self.assertIn("non-JSON", str(context.exception)) + + +class TestFromItem(unittest.TestCase): + def test_ignores_unknown_fields(self): + @dataclass + class Item: + a: int + b: str = "" + + self.assertEqual(from_item(Item, {"a": 1, "b": "x", "c": 2}), Item(a=1, b="x")) + + +if __name__ == "__main__": + unittest.main()