ipwhois-python 1.0.2__tar.gz → 1.2.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.
- {ipwhois_python-1.0.2 → ipwhois_python-1.2.0}/CHANGELOG.md +31 -0
- {ipwhois_python-1.0.2 → ipwhois_python-1.2.0}/PKG-INFO +32 -11
- {ipwhois_python-1.0.2 → ipwhois_python-1.2.0}/README.md +31 -10
- {ipwhois_python-1.0.2 → ipwhois_python-1.2.0}/pyproject.toml +1 -1
- {ipwhois_python-1.0.2 → ipwhois_python-1.2.0}/src/ipwhois/ipwhois.py +28 -12
- {ipwhois_python-1.0.2 → ipwhois_python-1.2.0}/tests/test_ipwhois.py +117 -0
- {ipwhois_python-1.0.2 → ipwhois_python-1.2.0}/.gitignore +0 -0
- {ipwhois_python-1.0.2 → ipwhois_python-1.2.0}/LICENSE +0 -0
- {ipwhois_python-1.0.2 → ipwhois_python-1.2.0}/examples/basic.py +0 -0
- {ipwhois_python-1.0.2 → ipwhois_python-1.2.0}/examples/bulk.py +0 -0
- {ipwhois_python-1.0.2 → ipwhois_python-1.2.0}/examples/defaults.py +0 -0
- {ipwhois_python-1.0.2 → ipwhois_python-1.2.0}/src/ipwhois/__init__.py +0 -0
- {ipwhois_python-1.0.2 → ipwhois_python-1.2.0}/src/ipwhois/py.typed +0 -0
|
@@ -5,6 +5,37 @@ All notable changes to `ipwhois-python` will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.2.0] - 2026-05-10
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Every error response now carries an `error_type` field, including errors
|
|
13
|
+
returned by the API. The new value `'api'` joins the existing `'network'`
|
|
14
|
+
and `'invalid_argument'` codes, so callers can branch on the category of
|
|
15
|
+
any failure with a single `info["error_type"]` check — no need to combine
|
|
16
|
+
`success` with `http_status` to distinguish API vs. non-API errors.
|
|
17
|
+
Applies to HTTP 4xx / 5xx responses, malformed JSON bodies, and HTTP 2xx
|
|
18
|
+
responses where the API itself sets `success: false` (e.g. "Invalid IP
|
|
19
|
+
address", "Reserved range").
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- `retry_after` is now only attached to HTTP 429 responses on the **free
|
|
24
|
+
plan** (`ipwho.is`). The paid endpoint (`ipwhois.pro`) does not send a
|
|
25
|
+
`Retry-After` header, so reading it on paid plans is now skipped and the
|
|
26
|
+
field will not appear there. Behaviour on the free plan is unchanged.
|
|
27
|
+
- README "Setting defaults once" section now shows the Free and Paid plans
|
|
28
|
+
as two separate code blocks, matching the layout used in "Quick start"
|
|
29
|
+
and "HTTPS encryption". The setters work identically on both plans, so
|
|
30
|
+
the lookup-override snippet is shared underneath.
|
|
31
|
+
- README "Error response fields" table now lists `message` explicitly (it
|
|
32
|
+
has always been present on every error response) and the `error_type`
|
|
33
|
+
row covers the new `'api'` value as well.
|
|
34
|
+
- The `_request()` HTTP-error code path was lightly refactored so the
|
|
35
|
+
`Retry-After` header is parsed in one place instead of two (one for the
|
|
36
|
+
dict branch and one for the list branch). No behaviour change beyond the
|
|
37
|
+
free-plan gating noted above.
|
|
38
|
+
|
|
8
39
|
## [1.0.2] - 2026-05-10
|
|
9
40
|
|
|
10
41
|
### Removed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ipwhois-python
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Official Python client for the ipwhois.io IP Geolocation API. Simple, dependency-free, supports single and bulk IP lookups.
|
|
5
5
|
Project-URL: Homepage, https://ipwhois.io
|
|
6
6
|
Project-URL: Documentation, https://ipwhois.io/documentation
|
|
@@ -154,14 +154,33 @@ Defaults are set with fluent setters — `set_language()`, `set_fields()`,
|
|
|
154
154
|
`set_user_agent()` — and can be chained:
|
|
155
155
|
|
|
156
156
|
```python
|
|
157
|
-
|
|
157
|
+
from ipwhois import IPWhois
|
|
158
|
+
|
|
159
|
+
# Free plan
|
|
158
160
|
ipwhois = (
|
|
159
161
|
IPWhois()
|
|
160
162
|
.set_language("en")
|
|
161
163
|
.set_fields(["success", "country", "city", "flag.emoji"])
|
|
162
164
|
.set_timeout(8)
|
|
163
165
|
)
|
|
166
|
+
```
|
|
164
167
|
|
|
168
|
+
```python
|
|
169
|
+
from ipwhois import IPWhois
|
|
170
|
+
|
|
171
|
+
# Paid plan
|
|
172
|
+
ipwhois = (
|
|
173
|
+
IPWhois("YOUR_API_KEY")
|
|
174
|
+
.set_language("en")
|
|
175
|
+
.set_fields(["success", "country", "city", "flag.emoji"])
|
|
176
|
+
.set_timeout(8)
|
|
177
|
+
)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Either client behaves the same way at call time — per-call options always
|
|
181
|
+
win over the defaults:
|
|
182
|
+
|
|
183
|
+
```python
|
|
165
184
|
ipwhois.lookup("8.8.8.8") # uses lang=en, the field whitelist, and timeout=8
|
|
166
185
|
ipwhois.lookup("1.1.1.1", lang="de") # overrides lang for this single call only
|
|
167
186
|
```
|
|
@@ -250,14 +269,16 @@ application — you decide how to react.
|
|
|
250
269
|
|
|
251
270
|
### Error response fields
|
|
252
271
|
|
|
253
|
-
Every error response contains `success: False
|
|
254
|
-
|
|
272
|
+
Every error response contains `success: False`, a human-readable `message`,
|
|
273
|
+
and an `error_type` so you can branch on the category of the failure. Some
|
|
274
|
+
errors include extra fields you can branch on:
|
|
255
275
|
|
|
256
|
-
| Field | When it's present
|
|
257
|
-
| -------------- |
|
|
258
|
-
| `
|
|
259
|
-
| `
|
|
260
|
-
| `
|
|
276
|
+
| Field | When it's present |
|
|
277
|
+
| -------------- | -------------------------------------------------------------------------------------------- |
|
|
278
|
+
| `message` | Always — human-readable description of what went wrong |
|
|
279
|
+
| `error_type` | Always — one of `'api'`, `'network'`, or `'invalid_argument'` |
|
|
280
|
+
| `http_status` | On HTTP 4xx / 5xx responses |
|
|
281
|
+
| `retry_after` | On HTTP 429 — **free plan only** (the paid endpoint does not send a `Retry-After` header) |
|
|
261
282
|
|
|
262
283
|
```python
|
|
263
284
|
import time
|
|
@@ -347,9 +368,9 @@ An **error** response looks like:
|
|
|
347
368
|
{
|
|
348
369
|
"success": false,
|
|
349
370
|
"message": "Invalid IP address",
|
|
371
|
+
"error_type": "api", // 'api' / 'network' / 'invalid_argument'
|
|
350
372
|
"http_status": 400 // present for HTTP 4xx / 5xx
|
|
351
|
-
// "retry_after": 60 // additionally present on HTTP 429
|
|
352
|
-
// "error_type": "network" // present for non-API errors: 'network', 'invalid_argument'
|
|
373
|
+
// "retry_after": 60 // additionally present on HTTP 429 — free plan only
|
|
353
374
|
}
|
|
354
375
|
```
|
|
355
376
|
|
|
@@ -101,14 +101,33 @@ Defaults are set with fluent setters — `set_language()`, `set_fields()`,
|
|
|
101
101
|
`set_user_agent()` — and can be chained:
|
|
102
102
|
|
|
103
103
|
```python
|
|
104
|
-
|
|
104
|
+
from ipwhois import IPWhois
|
|
105
|
+
|
|
106
|
+
# Free plan
|
|
105
107
|
ipwhois = (
|
|
106
108
|
IPWhois()
|
|
107
109
|
.set_language("en")
|
|
108
110
|
.set_fields(["success", "country", "city", "flag.emoji"])
|
|
109
111
|
.set_timeout(8)
|
|
110
112
|
)
|
|
113
|
+
```
|
|
111
114
|
|
|
115
|
+
```python
|
|
116
|
+
from ipwhois import IPWhois
|
|
117
|
+
|
|
118
|
+
# Paid plan
|
|
119
|
+
ipwhois = (
|
|
120
|
+
IPWhois("YOUR_API_KEY")
|
|
121
|
+
.set_language("en")
|
|
122
|
+
.set_fields(["success", "country", "city", "flag.emoji"])
|
|
123
|
+
.set_timeout(8)
|
|
124
|
+
)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Either client behaves the same way at call time — per-call options always
|
|
128
|
+
win over the defaults:
|
|
129
|
+
|
|
130
|
+
```python
|
|
112
131
|
ipwhois.lookup("8.8.8.8") # uses lang=en, the field whitelist, and timeout=8
|
|
113
132
|
ipwhois.lookup("1.1.1.1", lang="de") # overrides lang for this single call only
|
|
114
133
|
```
|
|
@@ -197,14 +216,16 @@ application — you decide how to react.
|
|
|
197
216
|
|
|
198
217
|
### Error response fields
|
|
199
218
|
|
|
200
|
-
Every error response contains `success: False
|
|
201
|
-
|
|
219
|
+
Every error response contains `success: False`, a human-readable `message`,
|
|
220
|
+
and an `error_type` so you can branch on the category of the failure. Some
|
|
221
|
+
errors include extra fields you can branch on:
|
|
202
222
|
|
|
203
|
-
| Field | When it's present
|
|
204
|
-
| -------------- |
|
|
205
|
-
| `
|
|
206
|
-
| `
|
|
207
|
-
| `
|
|
223
|
+
| Field | When it's present |
|
|
224
|
+
| -------------- | -------------------------------------------------------------------------------------------- |
|
|
225
|
+
| `message` | Always — human-readable description of what went wrong |
|
|
226
|
+
| `error_type` | Always — one of `'api'`, `'network'`, or `'invalid_argument'` |
|
|
227
|
+
| `http_status` | On HTTP 4xx / 5xx responses |
|
|
228
|
+
| `retry_after` | On HTTP 429 — **free plan only** (the paid endpoint does not send a `Retry-After` header) |
|
|
208
229
|
|
|
209
230
|
```python
|
|
210
231
|
import time
|
|
@@ -294,9 +315,9 @@ An **error** response looks like:
|
|
|
294
315
|
{
|
|
295
316
|
"success": false,
|
|
296
317
|
"message": "Invalid IP address",
|
|
318
|
+
"error_type": "api", // 'api' / 'network' / 'invalid_argument'
|
|
297
319
|
"http_status": 400 // present for HTTP 4xx / 5xx
|
|
298
|
-
// "retry_after": 60 // additionally present on HTTP 429
|
|
299
|
-
// "error_type": "network" // present for non-API errors: 'network', 'invalid_argument'
|
|
320
|
+
// "retry_after": 60 // additionally present on HTTP 429 — free plan only
|
|
300
321
|
}
|
|
301
322
|
```
|
|
302
323
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ipwhois-python"
|
|
7
|
-
version = "1.0
|
|
7
|
+
version = "1.2.0"
|
|
8
8
|
description = "Official Python client for the ipwhois.io IP Geolocation API. Simple, dependency-free, supports single and bulk IP lookups."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -55,7 +55,7 @@ class IPWhois:
|
|
|
55
55
|
"""
|
|
56
56
|
|
|
57
57
|
#: Library version, used in the default User-Agent header.
|
|
58
|
-
VERSION: str = "1.0
|
|
58
|
+
VERSION: str = "1.2.0"
|
|
59
59
|
|
|
60
60
|
#: Free-plan endpoint host (used when no API key is provided).
|
|
61
61
|
HOST_FREE: str = "ipwho.is"
|
|
@@ -434,6 +434,7 @@ class IPWhois:
|
|
|
434
434
|
f"(HTTP {status}): {snippet}"
|
|
435
435
|
),
|
|
436
436
|
"http_status": status,
|
|
437
|
+
"error_type": "api",
|
|
437
438
|
}
|
|
438
439
|
|
|
439
440
|
if not isinstance(decoded, (dict, list)):
|
|
@@ -457,12 +458,6 @@ class IPWhois:
|
|
|
457
458
|
"message": message,
|
|
458
459
|
"http_status": status,
|
|
459
460
|
}
|
|
460
|
-
|
|
461
|
-
if status == 429 and "retry-after" in headers:
|
|
462
|
-
try:
|
|
463
|
-
decoded["retry_after"] = int(headers["retry-after"])
|
|
464
|
-
except (TypeError, ValueError):
|
|
465
|
-
pass
|
|
466
461
|
else:
|
|
467
462
|
# List response with error status -- wrap as an error dict.
|
|
468
463
|
decoded = {
|
|
@@ -470,11 +465,32 @@ class IPWhois:
|
|
|
470
465
|
"message": f"HTTP {status} returned by ipwhois API",
|
|
471
466
|
"http_status": status,
|
|
472
467
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
468
|
+
|
|
469
|
+
# `Retry-After` is only emitted by the free-plan endpoint
|
|
470
|
+
# (ipwho.is); the paid endpoint (ipwhois.pro) does not send the
|
|
471
|
+
# header, so don't try to read it there.
|
|
472
|
+
if (
|
|
473
|
+
status == 429
|
|
474
|
+
and self._api_key is None
|
|
475
|
+
and "retry-after" in headers
|
|
476
|
+
):
|
|
477
|
+
try:
|
|
478
|
+
decoded["retry_after"] = int(headers["retry-after"])
|
|
479
|
+
except (TypeError, ValueError):
|
|
480
|
+
pass
|
|
481
|
+
|
|
482
|
+
# Tag every API-shaped error (`success: False` returned by the API,
|
|
483
|
+
# on any HTTP status) with `error_type: 'api'` so callers can branch
|
|
484
|
+
# on the category alongside the non-API codes ('network',
|
|
485
|
+
# 'environment', 'invalid_argument'). HTTP 2xx + success=false bodies
|
|
486
|
+
# (e.g. "Invalid IP address", "Reserved range") are otherwise passed
|
|
487
|
+
# through untouched.
|
|
488
|
+
if (
|
|
489
|
+
isinstance(decoded, dict)
|
|
490
|
+
and decoded.get("success") is False
|
|
491
|
+
and "error_type" not in decoded
|
|
492
|
+
):
|
|
493
|
+
decoded["error_type"] = "api"
|
|
478
494
|
|
|
479
495
|
# For HTTP 2xx with `success: false` (e.g. "Invalid IP address",
|
|
480
496
|
# "Reserved range") we just pass the body through -- it is already
|
|
@@ -328,3 +328,120 @@ def test_non_json_response_is_treated_as_error() -> None:
|
|
|
328
328
|
assert result["success"] is False
|
|
329
329
|
assert "Invalid JSON" in result.get("message", "")
|
|
330
330
|
assert result.get("http_status") == 200
|
|
331
|
+
assert result.get("error_type") == "api"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# --------------------------------------------------------------------- #
|
|
335
|
+
# Response shaping -- the urllib layer is stubbed via mock.patch so #
|
|
336
|
+
# the suite can exercise error tagging without making real HTTP calls. #
|
|
337
|
+
# --------------------------------------------------------------------- #
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class _FakeOkResp:
|
|
341
|
+
"""Minimal stand-in for a urllib response context manager (HTTP 2xx)."""
|
|
342
|
+
|
|
343
|
+
def __init__(self, status: int, body: bytes, headers: dict) -> None:
|
|
344
|
+
self.status = status
|
|
345
|
+
self._body = body
|
|
346
|
+
self.headers = headers
|
|
347
|
+
|
|
348
|
+
def __enter__(self) -> "_FakeOkResp":
|
|
349
|
+
return self
|
|
350
|
+
|
|
351
|
+
def __exit__(self, *_: object) -> None:
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
def read(self) -> bytes:
|
|
355
|
+
return self._body
|
|
356
|
+
|
|
357
|
+
def getcode(self) -> int:
|
|
358
|
+
return self.status
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _fake_http_error(status: int, body: bytes, headers: dict):
|
|
362
|
+
"""Build a urllib.error.HTTPError for status >= 400.
|
|
363
|
+
|
|
364
|
+
The 4xx / 5xx code path inside ``_request`` is reached via this
|
|
365
|
+
exception, not via the success branch, so tests that target it have
|
|
366
|
+
to make ``urlopen`` raise.
|
|
367
|
+
"""
|
|
368
|
+
import io
|
|
369
|
+
import urllib.error
|
|
370
|
+
|
|
371
|
+
return urllib.error.HTTPError(
|
|
372
|
+
url="https://example.test",
|
|
373
|
+
code=status,
|
|
374
|
+
msg="error",
|
|
375
|
+
hdrs=headers, # type: ignore[arg-type]
|
|
376
|
+
fp=io.BytesIO(body),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def test_2xx_with_success_false_is_tagged_as_api_error() -> None:
|
|
381
|
+
# The API returns 200 with `success: False` for things like
|
|
382
|
+
# "Reserved range" or "Invalid IP address" -- these should be passed
|
|
383
|
+
# through without `http_status` (which is reserved for 4xx/5xx),
|
|
384
|
+
# but tagged with `error_type: 'api'` so callers can branch on the
|
|
385
|
+
# category the same way they branch on 'network' / 'environment' /
|
|
386
|
+
# 'invalid_argument'.
|
|
387
|
+
from unittest.mock import patch
|
|
388
|
+
|
|
389
|
+
body = (
|
|
390
|
+
b'{"success": false, "message": "Reserved range", "ip": "127.0.0.1"}'
|
|
391
|
+
)
|
|
392
|
+
fake = _FakeOkResp(200, body, headers={})
|
|
393
|
+
with patch("urllib.request.urlopen", return_value=fake):
|
|
394
|
+
result = IPWhois().lookup("127.0.0.1")
|
|
395
|
+
|
|
396
|
+
assert result["success"] is False
|
|
397
|
+
assert result.get("message") == "Reserved range"
|
|
398
|
+
assert result.get("ip") == "127.0.0.1"
|
|
399
|
+
assert "http_status" not in result
|
|
400
|
+
assert result.get("error_type") == "api"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def test_4xx_response_is_normalised_with_error_type_api() -> None:
|
|
404
|
+
from unittest.mock import patch
|
|
405
|
+
|
|
406
|
+
body = b'{"success": false, "message": "Invalid API key"}'
|
|
407
|
+
err = _fake_http_error(401, body, headers={})
|
|
408
|
+
with patch("urllib.request.urlopen", side_effect=err):
|
|
409
|
+
result = IPWhois("BAD").lookup("8.8.8.8")
|
|
410
|
+
|
|
411
|
+
assert result["success"] is False
|
|
412
|
+
assert result.get("http_status") == 401
|
|
413
|
+
assert result.get("message") == "Invalid API key"
|
|
414
|
+
assert result.get("error_type") == "api"
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def test_429_on_free_plan_attaches_retry_after() -> None:
|
|
418
|
+
# The free-plan endpoint (ipwho.is) sends `Retry-After` on rate-limit
|
|
419
|
+
# responses; the client surfaces it as `retry_after`.
|
|
420
|
+
from unittest.mock import patch
|
|
421
|
+
|
|
422
|
+
body = b'{"success": false, "message": "Rate limited"}'
|
|
423
|
+
err = _fake_http_error(429, body, headers={"retry-after": "42"})
|
|
424
|
+
with patch("urllib.request.urlopen", side_effect=err):
|
|
425
|
+
result = IPWhois().lookup("8.8.8.8") # free plan -- no API key
|
|
426
|
+
|
|
427
|
+
assert result["success"] is False
|
|
428
|
+
assert result.get("http_status") == 429
|
|
429
|
+
assert result.get("retry_after") == 42
|
|
430
|
+
assert result.get("error_type") == "api"
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def test_429_on_paid_plan_does_not_attach_retry_after() -> None:
|
|
434
|
+
# The paid endpoint (ipwhois.pro) does not send `Retry-After`. Even
|
|
435
|
+
# if a header is present (proxies, test stubs, ...), the client
|
|
436
|
+
# ignores it on paid plans so `retry_after` will not appear.
|
|
437
|
+
from unittest.mock import patch
|
|
438
|
+
|
|
439
|
+
body = b'{"success": false, "message": "Rate limited"}'
|
|
440
|
+
err = _fake_http_error(429, body, headers={"retry-after": "42"})
|
|
441
|
+
with patch("urllib.request.urlopen", side_effect=err):
|
|
442
|
+
result = IPWhois("KEY").lookup("8.8.8.8") # paid plan
|
|
443
|
+
|
|
444
|
+
assert result["success"] is False
|
|
445
|
+
assert result.get("http_status") == 429
|
|
446
|
+
assert "retry_after" not in result
|
|
447
|
+
assert result.get("error_type") == "api"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|