pybgpkit 0.8.0__tar.gz → 0.9.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 (29) hide show
  1. {pybgpkit-0.8.0/pybgpkit.egg-info → pybgpkit-0.9.0}/PKG-INFO +1 -1
  2. {pybgpkit-0.8.0 → pybgpkit-0.9.0}/bgpkit/__init__.py +1 -0
  3. pybgpkit-0.9.0/bgpkit/_utils.py +39 -0
  4. pybgpkit-0.9.0/bgpkit/bgpkit_asn.py +116 -0
  5. pybgpkit-0.9.0/bgpkit/bgpkit_broker.py +202 -0
  6. pybgpkit-0.9.0/bgpkit/bgpkit_community.py +90 -0
  7. pybgpkit-0.9.0/bgpkit/bgpkit_roas.py +85 -0
  8. {pybgpkit-0.8.0 → pybgpkit-0.9.0/pybgpkit.egg-info}/PKG-INFO +1 -1
  9. {pybgpkit-0.8.0 → pybgpkit-0.9.0}/pybgpkit.egg-info/SOURCES.txt +7 -1
  10. {pybgpkit-0.8.0 → pybgpkit-0.9.0}/pyproject.toml +1 -1
  11. pybgpkit-0.9.0/tests/test_asn_unit.py +87 -0
  12. pybgpkit-0.9.0/tests/test_broker_unit.py +237 -0
  13. pybgpkit-0.9.0/tests/test_community_unit.py +103 -0
  14. {pybgpkit-0.8.0 → pybgpkit-0.9.0}/tests/test_integration.py +23 -0
  15. pybgpkit-0.9.0/tests/test_roas_unit.py +90 -0
  16. pybgpkit-0.9.0/tests/test_utils_unit.py +65 -0
  17. pybgpkit-0.8.0/bgpkit/bgpkit_asn.py +0 -70
  18. pybgpkit-0.8.0/bgpkit/bgpkit_broker.py +0 -176
  19. pybgpkit-0.8.0/bgpkit/bgpkit_community.py +0 -63
  20. pybgpkit-0.8.0/bgpkit/bgpkit_roas.py +0 -64
  21. {pybgpkit-0.8.0 → pybgpkit-0.9.0}/LICENSE +0 -0
  22. {pybgpkit-0.8.0 → pybgpkit-0.9.0}/README.md +0 -0
  23. {pybgpkit-0.8.0 → pybgpkit-0.9.0}/bgpkit/bgpkit_ip.py +0 -0
  24. {pybgpkit-0.8.0 → pybgpkit-0.9.0}/bgpkit/bgpkit_parser.py +0 -0
  25. {pybgpkit-0.8.0 → pybgpkit-0.9.0}/pybgpkit.egg-info/dependency_links.txt +0 -0
  26. {pybgpkit-0.8.0 → pybgpkit-0.9.0}/pybgpkit.egg-info/requires.txt +0 -0
  27. {pybgpkit-0.8.0 → pybgpkit-0.9.0}/pybgpkit.egg-info/top_level.txt +0 -0
  28. {pybgpkit-0.8.0 → pybgpkit-0.9.0}/setup.cfg +0 -0
  29. {pybgpkit-0.8.0 → pybgpkit-0.9.0}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pybgpkit
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: BGPKIT tools Python bindings
5
5
  Author-email: Mingwei Zhang <mingwei@bgpkit.com>
6
6
  License-Expression: MIT
@@ -1,4 +1,5 @@
1
1
  from .bgpkit_parser import Filter, Parser, RouteElem, RouteParser
2
+ from ._utils import BGPKITApiError
2
3
  from .bgpkit_broker import Broker, BrokerItem, CollectorItem, PeerItem
3
4
  from .bgpkit_roas import Roas, RoasItem
4
5
  from .bgpkit_ip import IpLookup, IpInfo
@@ -0,0 +1,39 @@
1
+ """Shared helpers for the BGPKIT API clients."""
2
+
3
+ from dataclasses import fields
4
+ from typing import Optional
5
+
6
+ import requests
7
+
8
+ DEFAULT_TIMEOUT = 30.0
9
+
10
+
11
+ class BGPKITApiError(RuntimeError):
12
+ """Raised when an API request fails or returns an error response."""
13
+
14
+
15
+ def from_item(cls, item: dict):
16
+ """Build a dataclass from an API item, ignoring unknown fields."""
17
+ names = {field.name for field in fields(cls)}
18
+ return cls(**{key: value for key, value in item.items() if key in names})
19
+
20
+
21
+ def request_json(
22
+ url: str,
23
+ params: Optional[dict] = None,
24
+ verify: bool = True,
25
+ timeout: float = DEFAULT_TIMEOUT,
26
+ ) -> dict:
27
+ """GET a JSON API endpoint, raising BGPKITApiError on failures."""
28
+ try:
29
+ response = requests.get(url, params=params or {}, verify=verify, timeout=timeout)
30
+ except requests.RequestException as error:
31
+ raise BGPKITApiError(f"GET {url} failed: {error}") from error
32
+ if response.status_code >= 400:
33
+ raise BGPKITApiError(
34
+ f"GET {url} failed with HTTP {response.status_code}: {response.text[:200]}"
35
+ )
36
+ try:
37
+ return response.json()
38
+ except ValueError as error:
39
+ raise BGPKITApiError(f"GET {url} returned a non-JSON response") from error
@@ -0,0 +1,116 @@
1
+ from dataclasses import dataclass
2
+ from typing import List, Optional
3
+
4
+ from ._utils import DEFAULT_TIMEOUT, from_item, request_json
5
+
6
+
7
+ @dataclass
8
+ class Pagination:
9
+ page: int = 0
10
+ page_size: int = 0
11
+ total_pages: int = 0
12
+ total_count: int = 0
13
+ has_next: bool = False
14
+ has_prev: bool = False
15
+
16
+
17
+ @dataclass
18
+ class AsnInfo:
19
+ asn: int
20
+ name: str
21
+ country: str = ""
22
+ country_name: str = ""
23
+ org_name: str = ""
24
+ org_id: str = ""
25
+ as2org: Optional[dict] = None
26
+ hegemony: Optional[dict] = None
27
+ peeringdb: Optional[dict] = None
28
+ population: Optional[dict] = None
29
+
30
+
31
+ @dataclass
32
+ class AsnLookupResult:
33
+ data: List[AsnInfo]
34
+ count: int
35
+ page: int
36
+ page_size: int
37
+ updated_at: str = ""
38
+ pagination: Optional[Pagination] = None
39
+
40
+
41
+ class AsnLookup:
42
+ """BGPKIT ASN information lookup (v3/utils/asn)."""
43
+
44
+ def __init__(
45
+ self,
46
+ api_url: str = "https://api.bgpkit.com/v3/utils",
47
+ timeout: float = DEFAULT_TIMEOUT,
48
+ ):
49
+ self.base_url = api_url.rstrip("/")
50
+ self.timeout = timeout
51
+
52
+ def query(
53
+ self,
54
+ asn: str = None,
55
+ country: str = None,
56
+ search: str = None,
57
+ page: int = 0,
58
+ page_size: int = 100,
59
+ ) -> AsnLookupResult:
60
+ """Look up ASN information.
61
+
62
+ Args:
63
+ asn: AS number, or a comma-separated list of AS numbers.
64
+ country: Filter by ISO country code.
65
+ search: Free-text search over ASN names and organizations.
66
+ page: Page number to start from (0-indexed).
67
+ page_size: Results per page.
68
+
69
+ Returns:
70
+ AsnLookupResult with the parsed ASN entries and pagination info.
71
+ """
72
+ params = {}
73
+ if asn:
74
+ params["asn"] = asn
75
+ if country:
76
+ params["country"] = country
77
+ if search:
78
+ params["search"] = search
79
+ if page is not None:
80
+ params["page"] = page
81
+ if page_size:
82
+ params["page_size"] = min(page_size, 10000)
83
+
84
+ response = request_json(f"{self.base_url}/asn", params=params, timeout=self.timeout)
85
+
86
+ data = []
87
+ for item in response.get("data", []):
88
+ as2org = item.get("as2org") if isinstance(item.get("as2org"), dict) else {}
89
+ data.append(
90
+ AsnInfo(
91
+ asn=item.get("asn"),
92
+ name=item.get("name", ""),
93
+ country=item.get("country", ""),
94
+ country_name=item.get("country_name", ""),
95
+ org_name=as2org.get("org_name", ""),
96
+ org_id=as2org.get("org_id", ""),
97
+ as2org=as2org or None,
98
+ hegemony=item.get("hegemony"),
99
+ peeringdb=item.get("peeringdb"),
100
+ population=item.get("population"),
101
+ )
102
+ )
103
+
104
+ pagination_data = response.get("pagination")
105
+ pagination = (
106
+ from_item(Pagination, pagination_data) if isinstance(pagination_data, dict) else None
107
+ )
108
+
109
+ return AsnLookupResult(
110
+ data=data,
111
+ count=response.get("count", 0),
112
+ page=response.get("page", page),
113
+ page_size=response.get("page_size", page_size),
114
+ updated_at=response.get("updatedAt", ""),
115
+ pagination=pagination,
116
+ )
@@ -0,0 +1,202 @@
1
+ import json
2
+ import os
3
+ from dataclasses import dataclass
4
+ from typing import List, Optional
5
+
6
+ import urllib3
7
+
8
+ from ._utils import DEFAULT_TIMEOUT, BGPKITApiError, from_item, request_json
9
+
10
+ DEFAULT_API_URL = "https://api.bgpkit.com/v3/broker"
11
+
12
+
13
+ def check_type(value: any, ty: type) -> bool:
14
+ try:
15
+ ty(value)
16
+ return True
17
+ except (ValueError, TypeError):
18
+ raise ValueError("invalid option input")
19
+
20
+
21
+ @dataclass
22
+ class BrokerItem:
23
+ ts_start: str
24
+ ts_end: str
25
+ collector_id: str
26
+ data_type: str
27
+ url: str
28
+ rough_size: int
29
+ exact_size: int
30
+ delay: Optional[float] = None # seconds since the latest catalog update; /latest items only
31
+
32
+
33
+ @dataclass
34
+ class PeerItem:
35
+ date: str
36
+ collector: str
37
+ ip: str
38
+ asn: int
39
+ num_v4_pfxs: int
40
+ num_v6_pfxs: int
41
+ num_connected_asns: int
42
+
43
+ @property
44
+ def full_feed(self) -> bool:
45
+ """True for full-table peers (>700k IPv4 or >100k IPv6 prefixes)."""
46
+ return self.num_v4_pfxs > 700_000 or self.num_v6_pfxs > 100_000
47
+
48
+
49
+ @dataclass
50
+ class CollectorItem:
51
+ name: str
52
+ project: str
53
+ data_url: str
54
+ activated_on: str
55
+ deactivated_on: Optional[str]
56
+ country: str
57
+
58
+ @property
59
+ def active(self) -> bool:
60
+ return self.deactivated_on is None
61
+
62
+
63
+ class Broker:
64
+ """BGPKIT Broker v3 API wrapper.
65
+
66
+ Provides access to MRT data file search, peer information,
67
+ collector metadata, and latest file discovery.
68
+
69
+ The API base URL can be overridden per instance with ``api_url`` or for the
70
+ whole process with the ``BGPKIT_BROKER_URL`` environment variable.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ api_url: Optional[str] = None,
76
+ page_size: int = 100,
77
+ verify: bool = True,
78
+ timeout: float = DEFAULT_TIMEOUT,
79
+ ):
80
+ if api_url is None:
81
+ api_url = os.environ.get("BGPKIT_BROKER_URL") or DEFAULT_API_URL
82
+ self.base_url = api_url.rstrip("/")
83
+ self.page_size = int(page_size)
84
+ self.verify = verify
85
+ self.timeout = timeout
86
+ if not verify:
87
+ urllib3.disable_warnings()
88
+
89
+ def _request(self, endpoint: str, params: Optional[dict] = None) -> dict:
90
+ """Make a single request and return the parsed JSON body."""
91
+ return request_json(
92
+ f"{self.base_url}/{endpoint}",
93
+ params=params,
94
+ verify=self.verify,
95
+ timeout=self.timeout,
96
+ )
97
+
98
+ def _paginate(self, endpoint: str, params: dict) -> dict:
99
+ """Fetch every page of a paginated broker endpoint.
100
+
101
+ Stops when the reported total is reached, or when a page adds no new
102
+ items. The latter also terminates endpoints that ignore pagination
103
+ parameters instead of looping on the same full page forever.
104
+ """
105
+ page = 1
106
+ all_data: List[dict] = []
107
+ seen = set()
108
+ result = {}
109
+ while True:
110
+ page_params = dict(params)
111
+ page_params["page"] = page
112
+ page_params["page_size"] = self.page_size
113
+
114
+ result = self._request(endpoint, page_params)
115
+ data = result.get("data", [])
116
+ new_items = []
117
+ for item in data:
118
+ key = json.dumps(item, sort_keys=True, default=str)
119
+ if key not in seen:
120
+ seen.add(key)
121
+ new_items.append(item)
122
+ all_data.extend(new_items)
123
+
124
+ total = result.get("total")
125
+ if not data or not new_items:
126
+ break
127
+ if total is not None and len(all_data) >= int(total):
128
+ break
129
+ page += 1
130
+
131
+ result["data"] = all_data
132
+ return result
133
+
134
+ def query(
135
+ self,
136
+ ts_start: str = None,
137
+ ts_end: str = None,
138
+ collector_id: str = None,
139
+ project: str = None,
140
+ data_type: str = None,
141
+ ) -> List[BrokerItem]:
142
+ """Search for MRT data files matching the given criteria."""
143
+ params = {}
144
+ if ts_start:
145
+ params["ts_start"] = ts_start
146
+ if ts_end:
147
+ params["ts_end"] = ts_end
148
+ if collector_id:
149
+ params["collector_id"] = collector_id
150
+ if project:
151
+ check_type(project, str)
152
+ params["project"] = project
153
+ if data_type:
154
+ check_type(data_type, str)
155
+ params["data_type"] = data_type
156
+
157
+ result = self._paginate("search", params)
158
+ return [from_item(BrokerItem, item) for item in result.get("data", [])]
159
+
160
+ def latest(self) -> List[BrokerItem]:
161
+ """Get the latest MRT data file for every collector and data type."""
162
+ result = self._request("latest")
163
+ return [from_item(BrokerItem, item) for item in result.get("data", [])]
164
+
165
+ def peers(
166
+ self,
167
+ full_feed: bool = None,
168
+ ip: str = None,
169
+ asn: int = None,
170
+ collector: str = None,
171
+ ) -> List[PeerItem]:
172
+ """Query BGP peer information."""
173
+ params = {}
174
+ if full_feed is not None:
175
+ params["full_feed"] = str(full_feed).lower()
176
+ if ip:
177
+ params["ip"] = ip
178
+ if asn:
179
+ params["asn"] = asn
180
+ if collector:
181
+ params["collector"] = collector
182
+
183
+ result = self._request("peers", params)
184
+ return [from_item(PeerItem, item) for item in result.get("data", [])]
185
+
186
+ def collectors(
187
+ self,
188
+ project: str = None,
189
+ country: str = None,
190
+ active: bool = None,
191
+ ) -> List[CollectorItem]:
192
+ """Query MRT collector information."""
193
+ params = {}
194
+ if project:
195
+ params["project"] = project
196
+ if country:
197
+ params["country"] = country
198
+ if active is not None:
199
+ params["active"] = str(active).lower()
200
+
201
+ result = self._request("collectors", params)
202
+ return [from_item(CollectorItem, item) for item in result.get("data", [])]
@@ -0,0 +1,90 @@
1
+ from dataclasses import dataclass
2
+ from typing import List, Optional
3
+
4
+ from ._utils import DEFAULT_TIMEOUT, from_item, request_json
5
+
6
+
7
+ @dataclass
8
+ class CommunityEntry:
9
+ asn: int
10
+ value: str
11
+ description: str
12
+ as_name: str = ""
13
+ country: str = ""
14
+ source: str = ""
15
+ range: Optional[str] = None
16
+
17
+
18
+ @dataclass
19
+ class CommunitySource:
20
+ id: str
21
+ name: str
22
+ url: str = ""
23
+ last_updated: Optional[str] = None
24
+ stats: Optional[dict] = None
25
+
26
+
27
+ class CommunityLookup:
28
+ """BGPKIT community lookup (v3/communities)."""
29
+
30
+ def __init__(
31
+ self,
32
+ api_url: str = "https://api.bgpkit.com/v3/communities",
33
+ timeout: float = DEFAULT_TIMEOUT,
34
+ ):
35
+ self.base_url = api_url.rstrip("/")
36
+ self.timeout = timeout
37
+
38
+ def query(
39
+ self,
40
+ asn: str = None,
41
+ value: str = None,
42
+ description: str = None,
43
+ as_name: str = None,
44
+ country: str = None,
45
+ page: int = 0,
46
+ page_size: int = 10,
47
+ ) -> List[CommunityEntry]:
48
+ """Query BGP communities.
49
+
50
+ The API returns one entry per ASN with a nested list of communities;
51
+ this returns one CommunityEntry per community.
52
+ """
53
+ params = {}
54
+ if asn:
55
+ params["asn"] = asn
56
+ if value:
57
+ params["value"] = value
58
+ if description:
59
+ params["description"] = description
60
+ if as_name:
61
+ params["as_name"] = as_name
62
+ if country:
63
+ params["country"] = country
64
+ params["page"] = str(page)
65
+ params["page_size"] = str(min(page_size, 1000))
66
+
67
+ response = request_json(self.base_url, params=params, timeout=self.timeout)
68
+
69
+ entries = []
70
+ for item in response.get("data", []):
71
+ for community in item.get("communities", []) or []:
72
+ entries.append(
73
+ CommunityEntry(
74
+ asn=item.get("asn"),
75
+ value=community.get("community", ""),
76
+ description=community.get("description") or "",
77
+ as_name=item.get("name", ""),
78
+ country=item.get("country", ""),
79
+ source=community.get("source", ""),
80
+ range=community.get("range"),
81
+ )
82
+ )
83
+ return entries
84
+
85
+ def sources(self) -> List[CommunitySource]:
86
+ """List the community data sources."""
87
+ response = request_json(f"{self.base_url}/sources", timeout=self.timeout)
88
+ if isinstance(response, list):
89
+ return [from_item(CommunitySource, item) for item in response]
90
+ return [from_item(CommunitySource, item) for item in response.get("sources", [])]
@@ -0,0 +1,85 @@
1
+ from dataclasses import dataclass
2
+ from typing import List, Optional
3
+
4
+ from ._utils import DEFAULT_TIMEOUT, from_item, request_json
5
+
6
+ DEFAULT_API_URL = "https://api.bgpkit.com/v3/roas"
7
+
8
+
9
+ @dataclass
10
+ class RoasItem:
11
+ prefix: str
12
+ asn: int
13
+ max_len: Optional[int] = None
14
+ tal: Optional[str] = None
15
+ current: bool = False
16
+ date_ranges: Optional[List[List[str]]] = None
17
+
18
+
19
+ class Roas:
20
+ """BGPKIT ROAS lookup (v3/roas).
21
+
22
+ Queries the ROAS (Route Origin Authorization) database for historical and
23
+ current RPKI data. The base URL can be overridden per instance with
24
+ ``api_url``.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ api_url: str = DEFAULT_API_URL,
30
+ page_size: int = 100,
31
+ timeout: float = DEFAULT_TIMEOUT,
32
+ ):
33
+ self.base_url = api_url.rstrip("/")
34
+ self.page_size = int(page_size)
35
+ self.timeout = timeout
36
+
37
+ def query(
38
+ self,
39
+ asn: int = None,
40
+ prefix: str = None,
41
+ date: str = None,
42
+ current: bool = None,
43
+ page: int = 0,
44
+ page_size: int = None,
45
+ ) -> List[RoasItem]:
46
+ """Query the ROAS database, fetching every page from ``page`` onward.
47
+
48
+ Args:
49
+ asn: AS number to filter by.
50
+ prefix: IP prefix to filter by.
51
+ date: Date string (YYYY-MM-DD) for historical lookup.
52
+ current: If True, return only currently valid ROAs.
53
+ page: Page number to start from (0-indexed).
54
+ page_size: Results per page (defaults to the instance page size).
55
+
56
+ Returns:
57
+ List of RoasItem matching the query.
58
+ """
59
+ params = {}
60
+ if asn is not None:
61
+ params["asn"] = asn
62
+ if prefix:
63
+ params["prefix"] = prefix
64
+ if date:
65
+ params["date"] = date
66
+ if current is not None:
67
+ params["current"] = str(current).lower()
68
+
69
+ size = int(page_size) if page_size is not None else self.page_size
70
+ items: List[RoasItem] = []
71
+ while True:
72
+ page_params = dict(params)
73
+ page_params["page"] = page
74
+ page_params["page_size"] = size
75
+ response = request_json(f"{self.base_url}/search", params=page_params, timeout=self.timeout)
76
+
77
+ data = response.get("data", [])
78
+ items.extend(from_item(RoasItem, item) for item in data)
79
+
80
+ total = response.get("total")
81
+ if not data or (total is not None and len(items) >= int(total)):
82
+ break
83
+ page += 1
84
+
85
+ return items
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pybgpkit
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: BGPKIT tools Python bindings
5
5
  Author-email: Mingwei Zhang <mingwei@bgpkit.com>
6
6
  License-Expression: MIT
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  bgpkit/__init__.py
5
+ bgpkit/_utils.py
5
6
  bgpkit/bgpkit_asn.py
6
7
  bgpkit/bgpkit_broker.py
7
8
  bgpkit/bgpkit_community.py
@@ -13,5 +14,10 @@ pybgpkit.egg-info/SOURCES.txt
13
14
  pybgpkit.egg-info/dependency_links.txt
14
15
  pybgpkit.egg-info/requires.txt
15
16
  pybgpkit.egg-info/top_level.txt
17
+ tests/test_asn_unit.py
18
+ tests/test_broker_unit.py
19
+ tests/test_community_unit.py
16
20
  tests/test_integration.py
17
- tests/test_smoke.py
21
+ tests/test_roas_unit.py
22
+ tests/test_smoke.py
23
+ tests/test_utils_unit.py
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "pybgpkit"
7
7
  dynamic = []
8
- version = "0.8.0"
8
+ version = "0.9.0"
9
9
  description = "BGPKIT tools Python bindings"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.9"
@@ -0,0 +1,87 @@
1
+ """Unit tests for AsnLookup, using recorded v3 API response shapes."""
2
+
3
+ import json
4
+ import unittest
5
+ from unittest import mock
6
+
7
+ from bgpkit import BGPKITApiError, AsnInfo, AsnLookup
8
+
9
+
10
+ class FakeResponse:
11
+ def __init__(self, payload, status_code=200):
12
+ self.status_code = status_code
13
+ self._payload = payload
14
+ self.text = json.dumps(payload) if payload is not None else ""
15
+
16
+ def json(self):
17
+ if self._payload is None:
18
+ raise ValueError("body is not JSON")
19
+ return self._payload
20
+
21
+
22
+ def mock_get(response):
23
+ return mock.patch("bgpkit._utils.requests.get", return_value=response)
24
+
25
+
26
+ ASN_PAYLOAD = {
27
+ "count": 1,
28
+ "data": [
29
+ {
30
+ "as2org": {
31
+ "country": "US",
32
+ "name": "GOOGLE",
33
+ "org_id": "GOGL-ARIN",
34
+ "org_name": "Google LLC",
35
+ },
36
+ "asn": 15169,
37
+ "country": "US",
38
+ "country_name": "United States",
39
+ "hegemony": {"asn": 15169, "ipv4": 0.0079, "ipv6": 0.0027},
40
+ "name": "GOOGLE - Google LLC",
41
+ "peeringdb": {"asn": 15169, "name": "Google LLC", "irr_as_set": "RADB::AS-GOOGLE"},
42
+ "population": {"percent_country": 0, "percent_global": 0, "user_count": 82},
43
+ }
44
+ ],
45
+ "page": 0,
46
+ "page_size": 1,
47
+ "pagination": {
48
+ "page": 0,
49
+ "page_size": 1,
50
+ "total_pages": 1,
51
+ "total_count": 1,
52
+ "has_next": False,
53
+ "has_prev": False,
54
+ },
55
+ "updatedAt": "2026-10-07T12:00:00",
56
+ }
57
+
58
+
59
+ class TestAsnLookup(unittest.TestCase):
60
+ def test_parses_nested_asn_item(self):
61
+ with mock_get(FakeResponse(ASN_PAYLOAD)) as get:
62
+ result = AsnLookup().query(asn="15169")
63
+ # the v3 ASN endpoint uses a 0-indexed page parameter
64
+ self.assertEqual(get.call_args.kwargs["params"]["page"], 0)
65
+ self.assertEqual(result.count, 1)
66
+ info = result.data[0]
67
+ self.assertIsInstance(info, AsnInfo)
68
+ self.assertEqual(info.asn, 15169)
69
+ self.assertEqual(info.name, "GOOGLE - Google LLC")
70
+ self.assertEqual(info.country_name, "United States")
71
+ self.assertEqual(info.org_name, "Google LLC")
72
+ self.assertEqual(info.org_id, "GOGL-ARIN")
73
+ self.assertEqual(info.hegemony, {"asn": 15169, "ipv4": 0.0079, "ipv6": 0.0027})
74
+ self.assertEqual(result.updated_at, "2026-10-07T12:00:00")
75
+ self.assertIsNotNone(result.pagination)
76
+ self.assertEqual(result.pagination.total_count, 1)
77
+ self.assertFalse(result.pagination.has_next)
78
+
79
+ def test_raises_on_http_error(self):
80
+ with mock_get(FakeResponse({"error": "boom"}, status_code=500)):
81
+ with self.assertRaises(BGPKITApiError) as context:
82
+ AsnLookup().query(asn="15169")
83
+ self.assertIn("HTTP 500", str(context.exception))
84
+
85
+
86
+ if __name__ == "__main__":
87
+ unittest.main()