ipwhois-python 1.0.1__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.2.0/CHANGELOG.md +116 -0
- {ipwhois_python-1.0.1 → ipwhois_python-1.2.0}/PKG-INFO +40 -21
- {ipwhois_python-1.0.1 → ipwhois_python-1.2.0}/README.md +39 -20
- {ipwhois_python-1.0.1 → ipwhois_python-1.2.0}/examples/basic.py +1 -1
- {ipwhois_python-1.0.1 → ipwhois_python-1.2.0}/examples/defaults.py +1 -1
- {ipwhois_python-1.0.1 → ipwhois_python-1.2.0}/pyproject.toml +1 -1
- {ipwhois_python-1.0.1 → ipwhois_python-1.2.0}/src/ipwhois/ipwhois.py +45 -46
- {ipwhois_python-1.0.1 → ipwhois_python-1.2.0}/tests/test_ipwhois.py +139 -21
- ipwhois_python-1.0.1/CHANGELOG.md +0 -41
- {ipwhois_python-1.0.1 → ipwhois_python-1.2.0}/.gitignore +0 -0
- {ipwhois_python-1.0.1 → ipwhois_python-1.2.0}/LICENSE +0 -0
- {ipwhois_python-1.0.1 → ipwhois_python-1.2.0}/examples/bulk.py +0 -0
- {ipwhois_python-1.0.1 → ipwhois_python-1.2.0}/src/ipwhois/__init__.py +0 -0
- {ipwhois_python-1.0.1 → ipwhois_python-1.2.0}/src/ipwhois/py.typed +0 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `ipwhois-python` will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
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
|
+
|
|
39
|
+
## [1.0.2] - 2026-05-10
|
|
40
|
+
|
|
41
|
+
### Removed
|
|
42
|
+
|
|
43
|
+
- **The `output` option has been removed.** The library only ever processed
|
|
44
|
+
JSON responses meaningfully, so `output="xml"` and `output="csv"` were a
|
|
45
|
+
thin pass-through that returned the raw payload as a string. The option
|
|
46
|
+
has been dropped from `lookup()`, `bulk_lookup()`, and the constructor's
|
|
47
|
+
keyword arguments; the `IPWhois.SUPPORTED_OUTPUTS` constant is gone.
|
|
48
|
+
Passing `output=...` will silently no-op.
|
|
49
|
+
- The 2xx + non-JSON `{"success": True, "raw": ...}` fallback in the
|
|
50
|
+
response handler (which only existed to support the removed `output`
|
|
51
|
+
parameter) is gone. The API always returns JSON, so any non-JSON 2xx
|
|
52
|
+
body is now treated as a transport error and returned as a
|
|
53
|
+
`success: False` dict.
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
|
|
57
|
+
- `set_fields()` docstring now mentions that `"success"` should be included
|
|
58
|
+
in the field whitelist if you rely on `info["success"]` for error
|
|
59
|
+
checking — when `fields` is set, the API only returns the fields you list.
|
|
60
|
+
- README "Setting defaults once" section rewritten for clarity: the two
|
|
61
|
+
ways of passing options (per call vs. as defaults), the available
|
|
62
|
+
setters, and the `success`-in-`fields` gotcha are now spelled out
|
|
63
|
+
explicitly. The free/paid example pair was collapsed into a single
|
|
64
|
+
example, since the setters work identically on both plans.
|
|
65
|
+
- All examples that filter fields (`README.md`, `examples/basic.py`,
|
|
66
|
+
`examples/defaults.py`) now include `"success"` in the field list.
|
|
67
|
+
|
|
68
|
+
### Migration
|
|
69
|
+
|
|
70
|
+
If your code passes `output="json"` you can simply remove it — the library
|
|
71
|
+
always returns the decoded JSON anyway. If you were relying on
|
|
72
|
+
`output="xml"` or `output="csv"` to get the raw payload, that use case is
|
|
73
|
+
no longer supported; call the API directly with `urllib` for those formats.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
# Before (1.0.1):
|
|
77
|
+
info = ipwhois.lookup("8.8.8.8", output="json", fields=["country", "city"])
|
|
78
|
+
|
|
79
|
+
# After (1.0.2):
|
|
80
|
+
info = ipwhois.lookup("8.8.8.8", fields=["success", "country", "city"])
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## [1.0.1] - 2026-05-09
|
|
84
|
+
|
|
85
|
+
### Changed
|
|
86
|
+
|
|
87
|
+
- **Renamed the main class `Client` to `IPWhois`** for consistency with the
|
|
88
|
+
package and brand. The recommended import is now
|
|
89
|
+
`from ipwhois import IPWhois`. The source module moved from
|
|
90
|
+
`src/ipwhois/client.py` to `src/ipwhois/ipwhois.py`, and the test module
|
|
91
|
+
from `tests/test_client.py` to `tests/test_ipwhois.py`. Public behaviour,
|
|
92
|
+
method signatures, constructor arguments, and return shapes are all
|
|
93
|
+
unchanged.
|
|
94
|
+
|
|
95
|
+
### Migration
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
# Before (1.0.0):
|
|
99
|
+
from ipwhois import Client
|
|
100
|
+
client = Client("YOUR_API_KEY")
|
|
101
|
+
info = client.lookup("8.8.8.8")
|
|
102
|
+
|
|
103
|
+
# After (1.0.1+):
|
|
104
|
+
from ipwhois import IPWhois
|
|
105
|
+
ipwhois = IPWhois("YOUR_API_KEY")
|
|
106
|
+
info = ipwhois.lookup("8.8.8.8")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The variable name (`client`, `ipwhois`, anything else) is up to you; only
|
|
110
|
+
the class identifier changed.
|
|
111
|
+
|
|
112
|
+
## [1.0.0] - 2026-05-08
|
|
113
|
+
|
|
114
|
+
### Added
|
|
115
|
+
|
|
116
|
+
- Initial release.
|
|
@@ -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
|
|
@@ -139,42 +139,59 @@ on the client as a default.
|
|
|
139
139
|
| ------------ | ------- | -------------------- | ---------------------------------------------------------------------- |
|
|
140
140
|
| `lang` | str | Free + Paid | One of: `en`, `ru`, `de`, `es`, `pt-BR`, `fr`, `zh-CN`, `ja` |
|
|
141
141
|
| `fields` | list | Free + Paid | Restrict the response to specific fields (e.g. `["country", "city"]`) |
|
|
142
|
-
| `output` | str | Free + Paid | `json` (default), `xml`, `csv` |
|
|
143
142
|
| `rate` | bool | Basic and above | Include the `rate` block (`limit`, `remaining`) |
|
|
144
143
|
| `security` | bool | Business and above | Include the `security` block (proxy/vpn/tor/hosting) |
|
|
145
144
|
|
|
146
145
|
### Setting defaults once
|
|
147
146
|
|
|
148
|
-
|
|
147
|
+
Every option can be passed two ways: **per call** (as a keyword argument to
|
|
148
|
+
`lookup()` / `bulk_lookup()`) or **once as a default** on the client. Per-call
|
|
149
|
+
options always override the defaults, so it's safe to set sensible defaults
|
|
150
|
+
and only override what differs for a specific call.
|
|
151
|
+
|
|
152
|
+
Defaults are set with fluent setters — `set_language()`, `set_fields()`,
|
|
153
|
+
`set_security()`, `set_rate()`, `set_timeout()`, `set_connect_timeout()`,
|
|
154
|
+
`set_user_agent()` — and can be chained:
|
|
149
155
|
|
|
150
156
|
```python
|
|
157
|
+
from ipwhois import IPWhois
|
|
158
|
+
|
|
151
159
|
# Free plan
|
|
152
160
|
ipwhois = (
|
|
153
161
|
IPWhois()
|
|
154
162
|
.set_language("en")
|
|
155
|
-
.set_fields(["country", "city", "flag.emoji"])
|
|
163
|
+
.set_fields(["success", "country", "city", "flag.emoji"])
|
|
156
164
|
.set_timeout(8)
|
|
157
165
|
)
|
|
158
|
-
|
|
159
|
-
ipwhois.lookup("8.8.8.8") # uses all of the above
|
|
160
|
-
ipwhois.lookup("1.1.1.1", lang="de") # per-call options override defaults
|
|
161
166
|
```
|
|
162
167
|
|
|
163
168
|
```python
|
|
169
|
+
from ipwhois import IPWhois
|
|
170
|
+
|
|
164
171
|
# Paid plan
|
|
165
172
|
ipwhois = (
|
|
166
173
|
IPWhois("YOUR_API_KEY")
|
|
167
174
|
.set_language("en")
|
|
168
|
-
.set_fields(["country", "city", "flag.emoji"])
|
|
175
|
+
.set_fields(["success", "country", "city", "flag.emoji"])
|
|
169
176
|
.set_timeout(8)
|
|
170
177
|
)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Either client behaves the same way at call time — per-call options always
|
|
181
|
+
win over the defaults:
|
|
171
182
|
|
|
172
|
-
|
|
173
|
-
ipwhois.lookup("
|
|
183
|
+
```python
|
|
184
|
+
ipwhois.lookup("8.8.8.8") # uses lang=en, the field whitelist, and timeout=8
|
|
185
|
+
ipwhois.lookup("1.1.1.1", lang="de") # overrides lang for this single call only
|
|
174
186
|
```
|
|
175
187
|
|
|
176
|
-
>
|
|
177
|
-
>
|
|
188
|
+
> ⚠️ When you restrict fields with `set_fields()` (or the per-call `fields=`
|
|
189
|
+
> keyword), the API only returns the fields you ask for. Always include
|
|
190
|
+
> `"success"` in the list if you rely on `info["success"]` for error
|
|
191
|
+
> checking — otherwise the field will be missing on responses.
|
|
192
|
+
|
|
193
|
+
> ℹ️ `set_security(True)` requires Business+ and `set_rate(True)` requires
|
|
194
|
+
> Basic+. See the table above for what's available where.
|
|
178
195
|
|
|
179
196
|
## HTTPS encryption
|
|
180
197
|
|
|
@@ -252,14 +269,16 @@ application — you decide how to react.
|
|
|
252
269
|
|
|
253
270
|
### Error response fields
|
|
254
271
|
|
|
255
|
-
Every error response contains `success: False
|
|
256
|
-
|
|
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:
|
|
257
275
|
|
|
258
|
-
| Field | When it's present
|
|
259
|
-
| -------------- |
|
|
260
|
-
| `
|
|
261
|
-
| `
|
|
262
|
-
| `
|
|
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) |
|
|
263
282
|
|
|
264
283
|
```python
|
|
265
284
|
import time
|
|
@@ -349,9 +368,9 @@ An **error** response looks like:
|
|
|
349
368
|
{
|
|
350
369
|
"success": false,
|
|
351
370
|
"message": "Invalid IP address",
|
|
371
|
+
"error_type": "api", // 'api' / 'network' / 'invalid_argument'
|
|
352
372
|
"http_status": 400 // present for HTTP 4xx / 5xx
|
|
353
|
-
// "retry_after": 60 // additionally present on HTTP 429
|
|
354
|
-
// "error_type": "network" // present for non-API errors: 'network', 'invalid_argument'
|
|
373
|
+
// "retry_after": 60 // additionally present on HTTP 429 — free plan only
|
|
355
374
|
}
|
|
356
375
|
```
|
|
357
376
|
|
|
@@ -86,42 +86,59 @@ on the client as a default.
|
|
|
86
86
|
| ------------ | ------- | -------------------- | ---------------------------------------------------------------------- |
|
|
87
87
|
| `lang` | str | Free + Paid | One of: `en`, `ru`, `de`, `es`, `pt-BR`, `fr`, `zh-CN`, `ja` |
|
|
88
88
|
| `fields` | list | Free + Paid | Restrict the response to specific fields (e.g. `["country", "city"]`) |
|
|
89
|
-
| `output` | str | Free + Paid | `json` (default), `xml`, `csv` |
|
|
90
89
|
| `rate` | bool | Basic and above | Include the `rate` block (`limit`, `remaining`) |
|
|
91
90
|
| `security` | bool | Business and above | Include the `security` block (proxy/vpn/tor/hosting) |
|
|
92
91
|
|
|
93
92
|
### Setting defaults once
|
|
94
93
|
|
|
95
|
-
|
|
94
|
+
Every option can be passed two ways: **per call** (as a keyword argument to
|
|
95
|
+
`lookup()` / `bulk_lookup()`) or **once as a default** on the client. Per-call
|
|
96
|
+
options always override the defaults, so it's safe to set sensible defaults
|
|
97
|
+
and only override what differs for a specific call.
|
|
98
|
+
|
|
99
|
+
Defaults are set with fluent setters — `set_language()`, `set_fields()`,
|
|
100
|
+
`set_security()`, `set_rate()`, `set_timeout()`, `set_connect_timeout()`,
|
|
101
|
+
`set_user_agent()` — and can be chained:
|
|
96
102
|
|
|
97
103
|
```python
|
|
104
|
+
from ipwhois import IPWhois
|
|
105
|
+
|
|
98
106
|
# Free plan
|
|
99
107
|
ipwhois = (
|
|
100
108
|
IPWhois()
|
|
101
109
|
.set_language("en")
|
|
102
|
-
.set_fields(["country", "city", "flag.emoji"])
|
|
110
|
+
.set_fields(["success", "country", "city", "flag.emoji"])
|
|
103
111
|
.set_timeout(8)
|
|
104
112
|
)
|
|
105
|
-
|
|
106
|
-
ipwhois.lookup("8.8.8.8") # uses all of the above
|
|
107
|
-
ipwhois.lookup("1.1.1.1", lang="de") # per-call options override defaults
|
|
108
113
|
```
|
|
109
114
|
|
|
110
115
|
```python
|
|
116
|
+
from ipwhois import IPWhois
|
|
117
|
+
|
|
111
118
|
# Paid plan
|
|
112
119
|
ipwhois = (
|
|
113
120
|
IPWhois("YOUR_API_KEY")
|
|
114
121
|
.set_language("en")
|
|
115
|
-
.set_fields(["country", "city", "flag.emoji"])
|
|
122
|
+
.set_fields(["success", "country", "city", "flag.emoji"])
|
|
116
123
|
.set_timeout(8)
|
|
117
124
|
)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Either client behaves the same way at call time — per-call options always
|
|
128
|
+
win over the defaults:
|
|
118
129
|
|
|
119
|
-
|
|
120
|
-
ipwhois.lookup("
|
|
130
|
+
```python
|
|
131
|
+
ipwhois.lookup("8.8.8.8") # uses lang=en, the field whitelist, and timeout=8
|
|
132
|
+
ipwhois.lookup("1.1.1.1", lang="de") # overrides lang for this single call only
|
|
121
133
|
```
|
|
122
134
|
|
|
123
|
-
>
|
|
124
|
-
>
|
|
135
|
+
> ⚠️ When you restrict fields with `set_fields()` (or the per-call `fields=`
|
|
136
|
+
> keyword), the API only returns the fields you ask for. Always include
|
|
137
|
+
> `"success"` in the list if you rely on `info["success"]` for error
|
|
138
|
+
> checking — otherwise the field will be missing on responses.
|
|
139
|
+
|
|
140
|
+
> ℹ️ `set_security(True)` requires Business+ and `set_rate(True)` requires
|
|
141
|
+
> Basic+. See the table above for what's available where.
|
|
125
142
|
|
|
126
143
|
## HTTPS encryption
|
|
127
144
|
|
|
@@ -199,14 +216,16 @@ application — you decide how to react.
|
|
|
199
216
|
|
|
200
217
|
### Error response fields
|
|
201
218
|
|
|
202
|
-
Every error response contains `success: False
|
|
203
|
-
|
|
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:
|
|
204
222
|
|
|
205
|
-
| Field | When it's present
|
|
206
|
-
| -------------- |
|
|
207
|
-
| `
|
|
208
|
-
| `
|
|
209
|
-
| `
|
|
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) |
|
|
210
229
|
|
|
211
230
|
```python
|
|
212
231
|
import time
|
|
@@ -296,9 +315,9 @@ An **error** response looks like:
|
|
|
296
315
|
{
|
|
297
316
|
"success": false,
|
|
298
317
|
"message": "Invalid IP address",
|
|
318
|
+
"error_type": "api", // 'api' / 'network' / 'invalid_argument'
|
|
299
319
|
"http_status": 400 // present for HTTP 4xx / 5xx
|
|
300
|
-
// "retry_after": 60 // additionally present on HTTP 429
|
|
301
|
-
// "error_type": "network" // present for non-API errors: 'network', 'invalid_argument'
|
|
320
|
+
// "retry_after": 60 // additionally present on HTTP 429 — free plan only
|
|
302
321
|
}
|
|
303
322
|
```
|
|
304
323
|
|
|
@@ -46,7 +46,7 @@ paid = IPWhois("YOUR_API_KEY")
|
|
|
46
46
|
info = paid.lookup(
|
|
47
47
|
"1.1.1.1",
|
|
48
48
|
lang="en", # localised country/city/...
|
|
49
|
-
fields=["country", "city", "connection.isp", "flag.emoji"],
|
|
49
|
+
fields=["success", "country", "city", "connection.isp", "flag.emoji"],
|
|
50
50
|
security=True, # include proxy/vpn/tor flags
|
|
51
51
|
rate=True, # include rate-limit info
|
|
52
52
|
)
|
|
@@ -14,7 +14,7 @@ from ipwhois import IPWhois
|
|
|
14
14
|
ipwhois = (
|
|
15
15
|
IPWhois("YOUR_API_KEY")
|
|
16
16
|
.set_language("en")
|
|
17
|
-
.set_fields(["country", "city", "flag.emoji", "connection.isp"])
|
|
17
|
+
.set_fields(["success", "country", "city", "flag.emoji", "connection.isp"])
|
|
18
18
|
.set_security(True)
|
|
19
19
|
.set_timeout(8)
|
|
20
20
|
)
|
|
@@ -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"
|
|
@@ -78,16 +78,13 @@ class IPWhois:
|
|
|
78
78
|
"ja",
|
|
79
79
|
)
|
|
80
80
|
|
|
81
|
-
#: Output formats supported by the ``output`` parameter.
|
|
82
|
-
SUPPORTED_OUTPUTS: tuple = ("json", "xml", "csv")
|
|
83
|
-
|
|
84
81
|
def __init__(self, api_key: Optional[str] = None, **options: Any) -> None:
|
|
85
82
|
"""Create a new client.
|
|
86
83
|
|
|
87
84
|
:param api_key: Your ipwhois.io API key. Omit for the free plan.
|
|
88
85
|
:param options: Optional defaults applied to every request. Recognised
|
|
89
|
-
keys: ``lang``, ``fields``, ``security``, ``rate``, ``
|
|
90
|
-
``
|
|
86
|
+
keys: ``lang``, ``fields``, ``security``, ``rate``, ``ssl``,
|
|
87
|
+
``timeout``, ``connect_timeout``, ``user_agent``.
|
|
91
88
|
"""
|
|
92
89
|
self._api_key: Optional[str] = api_key
|
|
93
90
|
self._user_agent: str = str(
|
|
@@ -117,7 +114,7 @@ class IPWhois:
|
|
|
117
114
|
|
|
118
115
|
:param ip: IPv4 or IPv6 address. ``None`` (default) = current IP.
|
|
119
116
|
:param options: Per-call options: ``lang``, ``fields``,
|
|
120
|
-
``security`` (bool), ``rate`` (bool)
|
|
117
|
+
``security`` (bool), ``rate`` (bool).
|
|
121
118
|
:returns: Decoded JSON response. On any error (API, network, bad
|
|
122
119
|
input) the dict contains ``success`` set to ``False`` and a
|
|
123
120
|
``message``. The library never raises.
|
|
@@ -232,10 +229,14 @@ class IPWhois:
|
|
|
232
229
|
) -> "IPWhois":
|
|
233
230
|
"""Restrict every response to a fixed set of fields by default.
|
|
234
231
|
|
|
232
|
+
Include ``"success"`` in the list if you rely on ``info["success"]``
|
|
233
|
+
for error checking -- when ``fields`` is set, the API only returns
|
|
234
|
+
the fields you ask for.
|
|
235
|
+
|
|
235
236
|
:param fields: An iterable of field names, e.g.
|
|
236
|
-
``["country", "city", "flag.emoji"]``. A pre-joined
|
|
237
|
-
string is also accepted and passed through
|
|
238
|
-
``None`` to clear any previously-set default.
|
|
237
|
+
``["success", "country", "city", "flag.emoji"]``. A pre-joined
|
|
238
|
+
comma-separated string is also accepted and passed through
|
|
239
|
+
unchanged. Pass ``None`` to clear any previously-set default.
|
|
239
240
|
"""
|
|
240
241
|
# Strings are iterable in Python, so list("country,city") would
|
|
241
242
|
# explode into individual characters. Keep strings as strings.
|
|
@@ -317,17 +318,6 @@ class IPWhois:
|
|
|
317
318
|
"error_type": "invalid_argument",
|
|
318
319
|
}
|
|
319
320
|
|
|
320
|
-
output = merged.get("output")
|
|
321
|
-
if output is not None and output not in self.SUPPORTED_OUTPUTS:
|
|
322
|
-
return {
|
|
323
|
-
"success": False,
|
|
324
|
-
"message": (
|
|
325
|
-
f'Unsupported output format "{output}". Supported: '
|
|
326
|
-
f"{', '.join(self.SUPPORTED_OUTPUTS)}."
|
|
327
|
-
),
|
|
328
|
-
"error_type": "invalid_argument",
|
|
329
|
-
}
|
|
330
|
-
|
|
331
321
|
return None
|
|
332
322
|
|
|
333
323
|
def _build_url(self, path: str, options: Dict[str, Any]) -> str:
|
|
@@ -345,9 +335,6 @@ class IPWhois:
|
|
|
345
335
|
if "lang" in merged and merged["lang"] is not None:
|
|
346
336
|
query.append(("lang", str(merged["lang"])))
|
|
347
337
|
|
|
348
|
-
if "output" in merged and merged["output"] is not None:
|
|
349
|
-
query.append(("output", str(merged["output"])))
|
|
350
|
-
|
|
351
338
|
if "fields" in merged and merged["fields"] is not None:
|
|
352
339
|
fields = merged["fields"]
|
|
353
340
|
if isinstance(fields, (list, tuple)):
|
|
@@ -432,25 +419,22 @@ class IPWhois:
|
|
|
432
419
|
try:
|
|
433
420
|
decoded = json.loads(body)
|
|
434
421
|
except json.JSONDecodeError:
|
|
435
|
-
#
|
|
436
|
-
#
|
|
437
|
-
#
|
|
438
|
-
#
|
|
439
|
-
#
|
|
440
|
-
if 200 <= status < 300:
|
|
441
|
-
return {"success": True, "raw": body}
|
|
442
|
-
|
|
443
|
-
# Non-JSON 4xx/5xx -- synthesise an error dict so the caller
|
|
444
|
-
# can handle it the same way as a normal API error.
|
|
422
|
+
# The ipwhois API always returns JSON. A non-JSON body means
|
|
423
|
+
# something went wrong upstream (gateway error page, captive
|
|
424
|
+
# portal, hijacked response, ...) -- synthesise an error dict
|
|
425
|
+
# so the caller can handle it the same way as a normal API
|
|
426
|
+
# error.
|
|
445
427
|
snippet = " ".join(body.split())
|
|
446
428
|
if len(snippet) > 200:
|
|
447
429
|
snippet = snippet[:200] + "\u2026"
|
|
448
430
|
return {
|
|
449
431
|
"success": False,
|
|
450
432
|
"message": (
|
|
451
|
-
f"
|
|
433
|
+
f"Invalid JSON returned by ipwhois API "
|
|
434
|
+
f"(HTTP {status}): {snippet}"
|
|
452
435
|
),
|
|
453
436
|
"http_status": status,
|
|
437
|
+
"error_type": "api",
|
|
454
438
|
}
|
|
455
439
|
|
|
456
440
|
if not isinstance(decoded, (dict, list)):
|
|
@@ -474,12 +458,6 @@ class IPWhois:
|
|
|
474
458
|
"message": message,
|
|
475
459
|
"http_status": status,
|
|
476
460
|
}
|
|
477
|
-
|
|
478
|
-
if status == 429 and "retry-after" in headers:
|
|
479
|
-
try:
|
|
480
|
-
decoded["retry_after"] = int(headers["retry-after"])
|
|
481
|
-
except (TypeError, ValueError):
|
|
482
|
-
pass
|
|
483
461
|
else:
|
|
484
462
|
# List response with error status -- wrap as an error dict.
|
|
485
463
|
decoded = {
|
|
@@ -487,11 +465,32 @@ class IPWhois:
|
|
|
487
465
|
"message": f"HTTP {status} returned by ipwhois API",
|
|
488
466
|
"http_status": status,
|
|
489
467
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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"
|
|
495
494
|
|
|
496
495
|
# For HTTP 2xx with `success: false` (e.g. "Invalid IP address",
|
|
497
496
|
# "Reserved range") we just pass the body through -- it is already
|
|
@@ -91,14 +91,6 @@ def test_invalid_language_returns_error_dict() -> None:
|
|
|
91
91
|
assert "klingon" in result.get("message", "")
|
|
92
92
|
|
|
93
93
|
|
|
94
|
-
def test_invalid_output_returns_error_dict() -> None:
|
|
95
|
-
result = IPWhois().lookup("8.8.8.8", output="yaml")
|
|
96
|
-
|
|
97
|
-
assert result["success"] is False
|
|
98
|
-
assert result.get("error_type") == "invalid_argument"
|
|
99
|
-
assert "yaml" in result.get("message", "")
|
|
100
|
-
|
|
101
|
-
|
|
102
94
|
def test_bulk_lookup_refuses_empty_list() -> None:
|
|
103
95
|
result = IPWhois("K").bulk_lookup([])
|
|
104
96
|
|
|
@@ -290,16 +282,24 @@ def test_constructor_tolerates_bad_timeout() -> None:
|
|
|
290
282
|
assert ipwhois._connect_timeout == 5
|
|
291
283
|
|
|
292
284
|
|
|
293
|
-
def
|
|
294
|
-
#
|
|
295
|
-
#
|
|
296
|
-
#
|
|
297
|
-
ipwhois = IPWhois()
|
|
285
|
+
def test_output_option_is_silently_dropped() -> None:
|
|
286
|
+
# The `output` parameter was removed in 1.0.2. Passing it must NOT raise
|
|
287
|
+
# or trip validation -- it's just ignored, and the resulting URL must
|
|
288
|
+
# not contain an `output=...` query string.
|
|
289
|
+
ipwhois = IPWhois("K")
|
|
290
|
+
url = ipwhois._build_url("/8.8.8.8", {"output": "xml", "lang": "en"})
|
|
298
291
|
|
|
299
|
-
|
|
300
|
-
#
|
|
301
|
-
|
|
302
|
-
|
|
292
|
+
assert "output=" not in url
|
|
293
|
+
# Other options next to it still work.
|
|
294
|
+
assert "lang=en" in url
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_non_json_response_is_treated_as_error() -> None:
|
|
298
|
+
# The API always returns JSON. A non-JSON 2xx body now indicates a
|
|
299
|
+
# transport problem (gateway error page, captive portal, ...) rather
|
|
300
|
+
# than legitimate XML/CSV output -- the `output` parameter was
|
|
301
|
+
# removed in 1.0.2. Expect a `success: False` error dict instead of
|
|
302
|
+
# the old `{"success": True, "raw": ...}` wrapper.
|
|
303
303
|
from unittest.mock import patch
|
|
304
304
|
|
|
305
305
|
class _FakeResp:
|
|
@@ -321,9 +321,127 @@ def test_raw_response_includes_success_true() -> None:
|
|
|
321
321
|
def getcode(self) -> int:
|
|
322
322
|
return 200
|
|
323
323
|
|
|
324
|
-
fake = _FakeResp(b"<
|
|
324
|
+
fake = _FakeResp(b"<html>captive portal</html>")
|
|
325
|
+
with patch("urllib.request.urlopen", return_value=fake):
|
|
326
|
+
result = IPWhois().lookup("8.8.8.8")
|
|
327
|
+
|
|
328
|
+
assert result["success"] is False
|
|
329
|
+
assert "Invalid JSON" in result.get("message", "")
|
|
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={})
|
|
325
393
|
with patch("urllib.request.urlopen", return_value=fake):
|
|
326
|
-
result =
|
|
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")
|
|
327
410
|
|
|
328
|
-
assert result["success"] is
|
|
329
|
-
assert result
|
|
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"
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
All notable changes to `ipwhois-python` will be documented in this file.
|
|
4
|
-
|
|
5
|
-
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
-
|
|
8
|
-
## [1.0.1] - 2026-05-09
|
|
9
|
-
|
|
10
|
-
### Changed
|
|
11
|
-
|
|
12
|
-
- **Renamed the main class `Client` to `IPWhois`** for consistency with the
|
|
13
|
-
package and brand. The recommended import is now
|
|
14
|
-
`from ipwhois import IPWhois`. The source module moved from
|
|
15
|
-
`src/ipwhois/client.py` to `src/ipwhois/ipwhois.py`, and the test module
|
|
16
|
-
from `tests/test_client.py` to `tests/test_ipwhois.py`. Public behaviour,
|
|
17
|
-
method signatures, constructor arguments, and return shapes are all
|
|
18
|
-
unchanged.
|
|
19
|
-
|
|
20
|
-
### Migration
|
|
21
|
-
|
|
22
|
-
```python
|
|
23
|
-
# Before (1.0.0):
|
|
24
|
-
from ipwhois import Client
|
|
25
|
-
client = Client("YOUR_API_KEY")
|
|
26
|
-
info = client.lookup("8.8.8.8")
|
|
27
|
-
|
|
28
|
-
# After (1.0.1+):
|
|
29
|
-
from ipwhois import IPWhois
|
|
30
|
-
ipwhois = IPWhois("YOUR_API_KEY")
|
|
31
|
-
info = ipwhois.lookup("8.8.8.8")
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
The variable name (`client`, `ipwhois`, anything else) is up to you; only
|
|
35
|
-
the class identifier changed.
|
|
36
|
-
|
|
37
|
-
## [1.0.0] - 2026-05-08
|
|
38
|
-
|
|
39
|
-
### Added
|
|
40
|
-
|
|
41
|
-
- Initial release.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|