Skip to content

Commit 0932516

Browse files
committed
Add is_satellite, is_anycast and blocklist date fields
The API now returns three additions: the top-level booleans is_satellite and is_anycast (siblings of is_eu, included only when true and omitted otherwise) and, on each threat.blocklists entry, the optional first_seen and last_seen UTC calendar dates (YYYY-MM-DD strings) and the days_seen integer. Some feeds, such as ipdata's own vpn feed, omit all three. The library is untyped and passes the JSON through unchanged, so the code change is to accept the two new names in IPData.valid_fields, which validates the `fields` and `select_field` parameters and feeds the CLI's -f / -e options. The dates stay strings and are not parsed. Tests built from the specification samples cover a satellite response, an anycast response with dated blocklist entries, a VPN exit whose third blocklist entry has no dates, the same shapes inside a bulk response, the None-when-absent behaviour of DotDict for the two flags, pass-through of unknown keys and field-name validation for both names. The README documents the five fields and the present-only-when-true rule. Claude-Session: https://claude.ai/code/session_01VQiRp4zEJKUXHrKkFaeUke Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 7c24765 commit 0932516

3 files changed

Lines changed: 338 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,18 @@ The `--fields` option is required to use CSV output.
565565
566566
A list of all the fields returned by the API is maintained at [Response Fields](https://docs.ipdata.co/api-reference/response-fields)
567567
568+
The following fields were added to the API in November 2026. The library passes them through unchanged, and both top-level flags are accepted by the `fields` and `select_field` parameters of `lookup()` and `bulk()` and by the CLI's `-f` and `-e` options.
569+
570+
| Field | Description |
571+
|-------|-------------|
572+
| `is_satellite` | `true` when the address belongs to a satellite internet provider (for example Starlink). Present only when true, omitted otherwise. |
573+
| `is_anycast` | `true` when the address is an anycast address announced from several locations at once (for example `8.8.8.8`). Present only when true, omitted otherwise. |
574+
| `threat.blocklists[].first_seen` | The UTC calendar date (a `YYYY-MM-DD` string) on which that blocklist first listed the address. Optional: some feeds, for example ipdata's own vpn feed, do not provide it. |
575+
| `threat.blocklists[].last_seen` | The UTC calendar date (a `YYYY-MM-DD` string) on which that blocklist last listed the address. Optional, as above. |
576+
| `threat.blocklists[].days_seen` | The number of days the address has been listed by that blocklist (an integer). Optional, as above. |
577+
578+
`is_satellite` and `is_anycast` are included in a response only when they are `true`. When absent they are missing from the dictionary and read as `None` through dot notation (`response.is_satellite`), so treat anything falsy as `false`. The dates are kept as strings and are not parsed. Fields the library does not know about yet are passed through as well.
579+
568580
## Geofeed tools
569581
570582
Geofeed publishers can use the `ipdata validate` command to validate their geofeeds before submission to ipdata. This will catch most but not all issues that might cause processing your geofeed to fail.

‎src/ipdata/ipdata.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ class IPData(object):
9898
valid_fields = {
9999
"ip",
100100
"is_eu",
101+
"is_satellite",
102+
"is_anycast",
101103
"city",
102104
"region",
103105
"region_code",

‎src/ipdata/test_response_fields.py‎

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
"""
2+
Offline tests for the response fields the API added in November 2026: the top-level booleans
3+
'is_satellite' and 'is_anycast', which the API includes only when they are true, and the optional
4+
'first_seen', 'last_seen' and 'days_seen' attributes of 'threat.blocklists' entries. The library passes
5+
the JSON through unchanged. The fixtures are built from the specification samples; nothing here calls
6+
the live API.
7+
"""
8+
import json
9+
10+
import httpx
11+
import pytest
12+
13+
from ipdata.ipdata import IPData
14+
15+
# Sample A: satellite ISP. All other fields keep today's shape.
16+
SATELLITE_RESPONSE = {
17+
"ip": "98.97.34.0",
18+
"is_satellite": True,
19+
"is_eu": False,
20+
"city": "Seattle",
21+
"country_code": "US",
22+
"asn": {
23+
"asn": "AS14593",
24+
"name": "Space Exploration Technologies Corporation",
25+
"domain": "spacex.com",
26+
"route": "98.97.32.0/21",
27+
"type": "business",
28+
},
29+
"threat": {
30+
"is_tor": False,
31+
"is_vpn": False,
32+
"is_icloud_relay": False,
33+
"is_proxy": False,
34+
"is_datacenter": False,
35+
"is_anonymous": False,
36+
"is_known_attacker": False,
37+
"is_known_abuser": False,
38+
"is_threat": False,
39+
"is_bogon": False,
40+
"blocklists": [],
41+
"scores": {"vpn_score": 0, "proxy_score": 1, "threat_score": 8, "trust_score": 97},
42+
},
43+
"count": "0",
44+
}
45+
46+
# Sample B: anycast address with dated blocklist entries (complete response).
47+
ANYCAST_RESPONSE = {
48+
"ip": "8.8.8.8",
49+
"is_anycast": True,
50+
"is_eu": False,
51+
"city": None,
52+
"region": None,
53+
"region_code": None,
54+
"region_type": None,
55+
"country_name": "United States",
56+
"country_code": "US",
57+
"continent_name": "North America",
58+
"continent_code": "NA",
59+
"latitude": 39.7599983215332,
60+
"longitude": -98.5,
61+
"postal": None,
62+
"calling_code": "1",
63+
"flag": "https://ipdata.co/flags/us.png",
64+
"emoji_flag": "\U0001F1FA\U0001F1F8",
65+
"emoji_unicode": "U+1F1FA U+1F1F8",
66+
"asn": {
67+
"asn": "AS15169",
68+
"name": "Google LLC",
69+
"domain": "about.google",
70+
"route": "8.8.8.0/24",
71+
"type": "hosting",
72+
},
73+
"company": {
74+
"name": "Google LLC",
75+
"domain": "about.google",
76+
"network": "8.8.8.0/24",
77+
"type": "hosting",
78+
},
79+
"languages": [{"name": "English", "native": "English", "code": "en"}],
80+
"currency": {
81+
"name": "US Dollar",
82+
"code": "USD",
83+
"symbol": "$",
84+
"native": "$",
85+
"plural": "US dollars",
86+
},
87+
"time_zone": {
88+
"name": None,
89+
"abbr": None,
90+
"offset": None,
91+
"is_dst": None,
92+
"current_time": None,
93+
},
94+
"threat": {
95+
"is_tor": False,
96+
"is_vpn": False,
97+
"is_icloud_relay": False,
98+
"is_proxy": False,
99+
"is_datacenter": True,
100+
"is_anonymous": False,
101+
"is_known_attacker": False,
102+
"is_known_abuser": False,
103+
"is_threat": False,
104+
"is_bogon": False,
105+
"blocklists": [
106+
{
107+
"days_seen": 1,
108+
"first_seen": "2026-11-02",
109+
"last_seen": "2026-11-02",
110+
"name": "ipdata",
111+
"site": "https://ipdata.co",
112+
"type": "cloud",
113+
},
114+
{
115+
"days_seen": 1,
116+
"first_seen": "2026-11-02",
117+
"last_seen": "2026-11-02",
118+
"name": "ipdata",
119+
"site": "https://ipdata.co",
120+
"type": "open_resolvers",
121+
},
122+
],
123+
"scores": {"vpn_score": 100, "proxy_score": 93, "threat_score": 100, "trust_score": 2},
124+
},
125+
"count": "0",
126+
}
127+
128+
# Sample C: VPN exit. Neither top-level flag is present and the third blocklist entry has no dates.
129+
VPN_RESPONSE = {
130+
"ip": "187.13.250.1",
131+
"is_eu": False,
132+
"threat": {
133+
"is_tor": False,
134+
"is_vpn": True,
135+
"is_icloud_relay": False,
136+
"is_proxy": False,
137+
"is_datacenter": True,
138+
"is_anonymous": True,
139+
"is_known_attacker": True,
140+
"is_known_abuser": True,
141+
"is_threat": True,
142+
"is_bogon": False,
143+
"blocklists": [
144+
{
145+
"days_seen": 2,
146+
"first_seen": "2026-10-31",
147+
"last_seen": "2026-11-02",
148+
"name": "Gluetun",
149+
"site": "https://github.com/qdm12/gluetun",
150+
"type": "vpn",
151+
},
152+
{
153+
"days_seen": 1,
154+
"first_seen": "2026-11-02",
155+
"last_seen": "2026-11-02",
156+
"name": "ipdata",
157+
"site": "https://ipdata.co",
158+
"type": "cloud",
159+
},
160+
{"name": "ipdata", "site": "https://ipdata.co", "type": "vpn"},
161+
{
162+
"days_seen": 2,
163+
"first_seen": "2026-10-31",
164+
"last_seen": "2026-11-02",
165+
"name": "NordVPN",
166+
"site": "https://nordvpn.com",
167+
"type": "vpn",
168+
},
169+
{
170+
"days_seen": 2,
171+
"first_seen": "2026-10-31",
172+
"last_seen": "2026-11-02",
173+
"name": "Wikimedia Global Blocklist",
174+
"site": "https://wikimedia.org",
175+
"type": "general",
176+
},
177+
{
178+
"days_seen": 2,
179+
"first_seen": "2026-10-31",
180+
"last_seen": "2026-11-02",
181+
"name": "X4BNet",
182+
"site": "https://github.com/X4BNet/lists_vpn",
183+
"type": "vpn",
184+
},
185+
],
186+
"scores": {"vpn_score": 100, "proxy_score": 99, "threat_score": 100, "trust_score": 0},
187+
},
188+
"count": "0",
189+
}
190+
191+
192+
def make_client(payload):
193+
"""Returns an IPData client whose mock transport answers every request with 'payload', plus the requests."""
194+
requests = []
195+
196+
def handler(request):
197+
requests.append(request)
198+
return httpx.Response(200, json=payload)
199+
200+
return IPData("test", transport=httpx.MockTransport(handler)), requests
201+
202+
203+
def test_is_satellite_is_passed_through_and_absent_flags_read_false():
204+
client, _ = make_client(SATELLITE_RESPONSE)
205+
206+
data = client.lookup("98.97.34.0")
207+
208+
assert data.is_satellite is True
209+
assert data["is_satellite"] is True
210+
assert data.is_eu is False
211+
# the API omits is_anycast when it is false; a missing key reads as None through dot notation
212+
assert "is_anycast" not in data
213+
assert data.is_anycast is None
214+
assert not data.is_anycast
215+
assert data.threat.blocklists == []
216+
assert data.threat.scores.trust_score == 97
217+
assert data.asn.name == "Space Exploration Technologies Corporation"
218+
assert data.status == 200
219+
220+
221+
def test_is_anycast_with_dated_blocklist_entries():
222+
client, _ = make_client(ANYCAST_RESPONSE)
223+
224+
data = client.lookup("8.8.8.8")
225+
226+
assert data.is_anycast is True
227+
assert "is_satellite" not in data
228+
assert data.is_satellite is None
229+
assert data.threat.is_datacenter is True
230+
entries = data.threat.blocklists
231+
assert [entry.type for entry in entries] == ["cloud", "open_resolvers"]
232+
for entry in entries:
233+
assert entry.name == "ipdata"
234+
assert entry.site == "https://ipdata.co"
235+
# the dates stay plain YYYY-MM-DD strings; they are not parsed into date objects
236+
assert entry.first_seen == "2026-11-02"
237+
assert entry.last_seen == "2026-11-02"
238+
assert entry.days_seen == 1
239+
assert data.threat.scores.trust_score == 2
240+
assert data.time_zone.current_time is None
241+
assert data.city is None
242+
243+
244+
def test_blocklist_entry_without_dates():
245+
client, _ = make_client(VPN_RESPONSE)
246+
247+
data = client.lookup("187.13.250.1")
248+
249+
assert "is_satellite" not in data
250+
assert "is_anycast" not in data
251+
assert data.threat.is_vpn is True
252+
entries = data.threat.blocklists
253+
assert len(entries) == 6
254+
undated = entries[2]
255+
assert undated.name == "ipdata"
256+
assert undated.type == "vpn"
257+
assert "first_seen" not in undated
258+
assert undated.first_seen is None
259+
assert undated.last_seen is None
260+
assert undated.days_seen is None
261+
assert entries[0].name == "Gluetun"
262+
assert entries[0].first_seen == "2026-10-31"
263+
assert entries[0].last_seen == "2026-11-02"
264+
assert [entry.get("days_seen") for entry in entries] == [2, 1, None, 2, 2, 2]
265+
266+
267+
def test_bulk_items_carry_the_same_shape():
268+
client, requests = make_client([SATELLITE_RESPONSE, ANYCAST_RESPONSE, VPN_RESPONSE])
269+
270+
data = client.bulk(["98.97.34.0", "8.8.8.8", "187.13.250.1"])
271+
272+
assert requests[0].method == "POST"
273+
assert data.status == 200
274+
satellite, anycast, vpn = data.responses
275+
assert satellite.is_satellite is True
276+
assert satellite.is_anycast is None
277+
assert anycast.is_anycast is True
278+
assert anycast.is_satellite is None
279+
assert anycast.threat.blocklists[1].type == "open_resolvers"
280+
assert anycast.threat.blocklists[1].first_seen == "2026-11-02"
281+
assert vpn.is_satellite is None
282+
assert vpn.is_anycast is None
283+
assert vpn.threat.blocklists[2].first_seen is None
284+
assert vpn.threat.blocklists[3].days_seen == 2
285+
286+
287+
def test_payload_passes_through_unchanged_and_tolerates_unknown_keys():
288+
payload = dict(ANYCAST_RESPONSE, brand_new_field={"nested": "value", "numbers": [1, 2, 3]})
289+
client, _ = make_client(payload)
290+
291+
data = client.lookup("8.8.8.8")
292+
293+
# DotDict is a plain dict; 'status' is the only key the library adds
294+
expected = dict(payload, status=200)
295+
assert dict(data) == expected
296+
assert json.loads(json.dumps(data)) == expected
297+
assert data.brand_new_field.nested == "value"
298+
assert data["brand_new_field"]["numbers"] == [1, 2, 3]
299+
300+
301+
@pytest.mark.parametrize("field", ["is_satellite", "is_anycast"])
302+
def test_new_field_names_are_accepted_for_field_selection(field):
303+
client, requests = make_client({"ip": "8.8.8.8", field: True})
304+
assert field in IPData.valid_fields
305+
client._validate_fields([field])
306+
client._validate_fields({field, "ip"})
307+
client._validate_fields((field,))
308+
309+
data = client.lookup("8.8.8.8", fields=[field])
310+
assert requests[-1].url.params["fields"] == field
311+
assert data[field] is True
312+
313+
client.lookup("8.8.8.8", select_field=field)
314+
assert requests[-1].url.params["fields"] == field
315+
316+
317+
def test_both_new_fields_in_one_fields_list_for_lookup_and_bulk():
318+
client, requests = make_client([SATELLITE_RESPONSE])
319+
320+
client.bulk(["98.97.34.0"], fields=["ip", "is_satellite", "is_anycast", "threat"])
321+
assert requests[-1].url.params["fields"] == "ip,is_satellite,is_anycast,threat"
322+
323+
with pytest.raises(ValueError):
324+
client.lookup("98.97.34.0", fields=["is_satelite"]) # typo is still rejected

0 commit comments

Comments
 (0)