ipwhois-python 1.0.0__tar.gz → 1.0.2__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/CHANGELOG.md +85 -0
- {ipwhois_python-1.0.0 → ipwhois_python-1.0.2}/PKG-INFO +44 -46
- {ipwhois_python-1.0.0 → ipwhois_python-1.0.2}/README.md +43 -45
- {ipwhois_python-1.0.0 → ipwhois_python-1.0.2}/examples/basic.py +6 -6
- {ipwhois_python-1.0.0 → ipwhois_python-1.0.2}/examples/bulk.py +3 -3
- {ipwhois_python-1.0.0 → ipwhois_python-1.0.2}/examples/defaults.py +7 -7
- {ipwhois_python-1.0.0 → ipwhois_python-1.0.2}/pyproject.toml +1 -1
- ipwhois_python-1.0.2/src/ipwhois/__init__.py +11 -0
- ipwhois_python-1.0.0/src/ipwhois/client.py → ipwhois_python-1.0.2/src/ipwhois/ipwhois.py +35 -50
- ipwhois_python-1.0.0/tests/test_client.py → ipwhois_python-1.0.2/tests/test_ipwhois.py +99 -98
- ipwhois_python-1.0.0/CHANGELOG.md +0 -12
- ipwhois_python-1.0.0/src/ipwhois/__init__.py +0 -11
- {ipwhois_python-1.0.0 → ipwhois_python-1.0.2}/.gitignore +0 -0
- {ipwhois_python-1.0.0 → ipwhois_python-1.0.2}/LICENSE +0 -0
- {ipwhois_python-1.0.0 → ipwhois_python-1.0.2}/src/ipwhois/py.typed +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
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.2] - 2026-05-10
|
|
9
|
+
|
|
10
|
+
### Removed
|
|
11
|
+
|
|
12
|
+
- **The `output` option has been removed.** The library only ever processed
|
|
13
|
+
JSON responses meaningfully, so `output="xml"` and `output="csv"` were a
|
|
14
|
+
thin pass-through that returned the raw payload as a string. The option
|
|
15
|
+
has been dropped from `lookup()`, `bulk_lookup()`, and the constructor's
|
|
16
|
+
keyword arguments; the `IPWhois.SUPPORTED_OUTPUTS` constant is gone.
|
|
17
|
+
Passing `output=...` will silently no-op.
|
|
18
|
+
- The 2xx + non-JSON `{"success": True, "raw": ...}` fallback in the
|
|
19
|
+
response handler (which only existed to support the removed `output`
|
|
20
|
+
parameter) is gone. The API always returns JSON, so any non-JSON 2xx
|
|
21
|
+
body is now treated as a transport error and returned as a
|
|
22
|
+
`success: False` dict.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- `set_fields()` docstring now mentions that `"success"` should be included
|
|
27
|
+
in the field whitelist if you rely on `info["success"]` for error
|
|
28
|
+
checking — when `fields` is set, the API only returns the fields you list.
|
|
29
|
+
- README "Setting defaults once" section rewritten for clarity: the two
|
|
30
|
+
ways of passing options (per call vs. as defaults), the available
|
|
31
|
+
setters, and the `success`-in-`fields` gotcha are now spelled out
|
|
32
|
+
explicitly. The free/paid example pair was collapsed into a single
|
|
33
|
+
example, since the setters work identically on both plans.
|
|
34
|
+
- All examples that filter fields (`README.md`, `examples/basic.py`,
|
|
35
|
+
`examples/defaults.py`) now include `"success"` in the field list.
|
|
36
|
+
|
|
37
|
+
### Migration
|
|
38
|
+
|
|
39
|
+
If your code passes `output="json"` you can simply remove it — the library
|
|
40
|
+
always returns the decoded JSON anyway. If you were relying on
|
|
41
|
+
`output="xml"` or `output="csv"` to get the raw payload, that use case is
|
|
42
|
+
no longer supported; call the API directly with `urllib` for those formats.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
# Before (1.0.1):
|
|
46
|
+
info = ipwhois.lookup("8.8.8.8", output="json", fields=["country", "city"])
|
|
47
|
+
|
|
48
|
+
# After (1.0.2):
|
|
49
|
+
info = ipwhois.lookup("8.8.8.8", fields=["success", "country", "city"])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## [1.0.1] - 2026-05-09
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- **Renamed the main class `Client` to `IPWhois`** for consistency with the
|
|
57
|
+
package and brand. The recommended import is now
|
|
58
|
+
`from ipwhois import IPWhois`. The source module moved from
|
|
59
|
+
`src/ipwhois/client.py` to `src/ipwhois/ipwhois.py`, and the test module
|
|
60
|
+
from `tests/test_client.py` to `tests/test_ipwhois.py`. Public behaviour,
|
|
61
|
+
method signatures, constructor arguments, and return shapes are all
|
|
62
|
+
unchanged.
|
|
63
|
+
|
|
64
|
+
### Migration
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
# Before (1.0.0):
|
|
68
|
+
from ipwhois import Client
|
|
69
|
+
client = Client("YOUR_API_KEY")
|
|
70
|
+
info = client.lookup("8.8.8.8")
|
|
71
|
+
|
|
72
|
+
# After (1.0.1+):
|
|
73
|
+
from ipwhois import IPWhois
|
|
74
|
+
ipwhois = IPWhois("YOUR_API_KEY")
|
|
75
|
+
info = ipwhois.lookup("8.8.8.8")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The variable name (`client`, `ipwhois`, anything else) is up to you; only
|
|
79
|
+
the class identifier changed.
|
|
80
|
+
|
|
81
|
+
## [1.0.0] - 2026-05-08
|
|
82
|
+
|
|
83
|
+
### Added
|
|
84
|
+
|
|
85
|
+
- Initial release.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ipwhois-python
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
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
|
|
@@ -53,9 +53,9 @@ Description-Content-Type: text/markdown
|
|
|
53
53
|
|
|
54
54
|
# ipwhois-python
|
|
55
55
|
|
|
56
|
-
[](https://pypi.org/project/ipwhois-python/)
|
|
57
|
-
[](https://pypi.org/project/ipwhois-python/)
|
|
58
|
-
[](LICENSE)
|
|
56
|
+
[](https://pypi.org/project/ipwhois-python/)
|
|
57
|
+
[](https://pypi.org/project/ipwhois-python/)
|
|
58
|
+
[](LICENSE)
|
|
59
59
|
|
|
60
60
|
Official, dependency-free Python client for the [ipwhois.io](https://ipwhois.io) IP Geolocation API.
|
|
61
61
|
|
|
@@ -75,7 +75,7 @@ pip install ipwhois-python
|
|
|
75
75
|
|
|
76
76
|
## Free vs Paid plan
|
|
77
77
|
|
|
78
|
-
The same `
|
|
78
|
+
The same `IPWhois` class is used for both plans. The only difference is whether
|
|
79
79
|
you pass an API key:
|
|
80
80
|
|
|
81
81
|
- **Free plan** — create the client **without arguments**. No API key, no
|
|
@@ -85,10 +85,10 @@ you pass an API key:
|
|
|
85
85
|
threat-detection data.
|
|
86
86
|
|
|
87
87
|
```python
|
|
88
|
-
from ipwhois import
|
|
88
|
+
from ipwhois import IPWhois
|
|
89
89
|
|
|
90
|
-
free =
|
|
91
|
-
paid =
|
|
90
|
+
free = IPWhois() # Free plan — no API key
|
|
91
|
+
paid = IPWhois("YOUR_API_KEY") # Paid plan — with API key
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
Everything else (`lookup()`, options, error handling) is identical.
|
|
@@ -96,11 +96,11 @@ Everything else (`lookup()`, options, error handling) is identical.
|
|
|
96
96
|
## Quick start — Free plan (no API key)
|
|
97
97
|
|
|
98
98
|
```python
|
|
99
|
-
from ipwhois import
|
|
99
|
+
from ipwhois import IPWhois
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
ipwhois = IPWhois() # no API key
|
|
102
102
|
|
|
103
|
-
info =
|
|
103
|
+
info = ipwhois.lookup("8.8.8.8")
|
|
104
104
|
|
|
105
105
|
print(info["country"], info["flag"]["emoji"])
|
|
106
106
|
# → United States 🇺🇸
|
|
@@ -114,11 +114,11 @@ print(f"{info['city']}, {info['region']}")
|
|
|
114
114
|
Get an API key at <https://ipwhois.io> and pass it to the constructor:
|
|
115
115
|
|
|
116
116
|
```python
|
|
117
|
-
from ipwhois import
|
|
117
|
+
from ipwhois import IPWhois
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
ipwhois = IPWhois("YOUR_API_KEY") # with API key
|
|
120
120
|
|
|
121
|
-
info =
|
|
121
|
+
info = ipwhois.lookup("8.8.8.8")
|
|
122
122
|
|
|
123
123
|
print(info["country"], info["flag"]["emoji"])
|
|
124
124
|
# → United States 🇺🇸
|
|
@@ -127,7 +127,7 @@ print(f"{info['city']}, {info['region']}")
|
|
|
127
127
|
# → Mountain View, California
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
-
> ℹ️ Pass nothing to look up your own public IP: `
|
|
130
|
+
> ℹ️ Pass nothing to look up your own public IP: `ipwhois.lookup()` — works
|
|
131
131
|
> on both plans.
|
|
132
132
|
|
|
133
133
|
## Lookup options
|
|
@@ -139,42 +139,40 @@ 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.
|
|
149
151
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
Client()
|
|
154
|
-
.set_language("en")
|
|
155
|
-
.set_fields(["country", "city", "flag.emoji"])
|
|
156
|
-
.set_timeout(8)
|
|
157
|
-
)
|
|
158
|
-
|
|
159
|
-
client.lookup("8.8.8.8") # uses all of the above
|
|
160
|
-
client.lookup("1.1.1.1", lang="de") # per-call options override defaults
|
|
161
|
-
```
|
|
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:
|
|
162
155
|
|
|
163
156
|
```python
|
|
164
|
-
#
|
|
165
|
-
|
|
166
|
-
|
|
157
|
+
# Pass "YOUR_API_KEY" to the constructor for the paid plan; otherwise omit it.
|
|
158
|
+
ipwhois = (
|
|
159
|
+
IPWhois()
|
|
167
160
|
.set_language("en")
|
|
168
|
-
.set_fields(["country", "city", "flag.emoji"])
|
|
161
|
+
.set_fields(["success", "country", "city", "flag.emoji"])
|
|
169
162
|
.set_timeout(8)
|
|
170
163
|
)
|
|
171
164
|
|
|
172
|
-
|
|
173
|
-
|
|
165
|
+
ipwhois.lookup("8.8.8.8") # uses lang=en, the field whitelist, and timeout=8
|
|
166
|
+
ipwhois.lookup("1.1.1.1", lang="de") # overrides lang for this single call only
|
|
174
167
|
```
|
|
175
168
|
|
|
176
|
-
>
|
|
177
|
-
>
|
|
169
|
+
> ⚠️ When you restrict fields with `set_fields()` (or the per-call `fields=`
|
|
170
|
+
> keyword), the API only returns the fields you ask for. Always include
|
|
171
|
+
> `"success"` in the list if you rely on `info["success"]` for error
|
|
172
|
+
> checking — otherwise the field will be missing on responses.
|
|
173
|
+
|
|
174
|
+
> ℹ️ `set_security(True)` requires Business+ and `set_rate(True)` requires
|
|
175
|
+
> Basic+. See the table above for what's available where.
|
|
178
176
|
|
|
179
177
|
## HTTPS encryption
|
|
180
178
|
|
|
@@ -183,17 +181,17 @@ example, in environments without an up-to-date CA bundle), pass `ssl=False`
|
|
|
183
181
|
to the constructor:
|
|
184
182
|
|
|
185
183
|
```python
|
|
186
|
-
from ipwhois import
|
|
184
|
+
from ipwhois import IPWhois
|
|
187
185
|
|
|
188
186
|
# Free plan
|
|
189
|
-
|
|
187
|
+
ipwhois = IPWhois(ssl=False)
|
|
190
188
|
```
|
|
191
189
|
|
|
192
190
|
```python
|
|
193
|
-
from ipwhois import
|
|
191
|
+
from ipwhois import IPWhois
|
|
194
192
|
|
|
195
193
|
# Paid plan
|
|
196
|
-
|
|
194
|
+
ipwhois = IPWhois("YOUR_API_KEY", ssl=False)
|
|
197
195
|
```
|
|
198
196
|
|
|
199
197
|
> ℹ️ HTTPS is strongly recommended for production traffic — your API key is
|
|
@@ -206,11 +204,11 @@ address counts as one credit. Available on the **Business** and **Unlimited**
|
|
|
206
204
|
plans.
|
|
207
205
|
|
|
208
206
|
```python
|
|
209
|
-
from ipwhois import
|
|
207
|
+
from ipwhois import IPWhois
|
|
210
208
|
|
|
211
|
-
|
|
209
|
+
ipwhois = IPWhois("YOUR_API_KEY")
|
|
212
210
|
|
|
213
|
-
results =
|
|
211
|
+
results = ipwhois.bulk_lookup([
|
|
214
212
|
"8.8.8.8",
|
|
215
213
|
"1.1.1.1",
|
|
216
214
|
"208.67.222.222",
|
|
@@ -237,7 +235,7 @@ with `success` set to `False` and a `message`. Just check
|
|
|
237
235
|
`info["success"]` after every call:
|
|
238
236
|
|
|
239
237
|
```python
|
|
240
|
-
info =
|
|
238
|
+
info = ipwhois.lookup("8.8.8.8")
|
|
241
239
|
|
|
242
240
|
if not info["success"]:
|
|
243
241
|
print(f"Lookup failed: {info['message']}")
|
|
@@ -264,7 +262,7 @@ include extra fields you can branch on:
|
|
|
264
262
|
```python
|
|
265
263
|
import time
|
|
266
264
|
|
|
267
|
-
info =
|
|
265
|
+
info = ipwhois.lookup("8.8.8.8")
|
|
268
266
|
|
|
269
267
|
if not info["success"]:
|
|
270
268
|
if info.get("http_status") == 429:
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# ipwhois-python
|
|
2
2
|
|
|
3
|
-
[](https://pypi.org/project/ipwhois-python/)
|
|
4
|
-
[](https://pypi.org/project/ipwhois-python/)
|
|
5
|
-
[](LICENSE)
|
|
3
|
+
[](https://pypi.org/project/ipwhois-python/)
|
|
4
|
+
[](https://pypi.org/project/ipwhois-python/)
|
|
5
|
+
[](LICENSE)
|
|
6
6
|
|
|
7
7
|
Official, dependency-free Python client for the [ipwhois.io](https://ipwhois.io) IP Geolocation API.
|
|
8
8
|
|
|
@@ -22,7 +22,7 @@ pip install ipwhois-python
|
|
|
22
22
|
|
|
23
23
|
## Free vs Paid plan
|
|
24
24
|
|
|
25
|
-
The same `
|
|
25
|
+
The same `IPWhois` class is used for both plans. The only difference is whether
|
|
26
26
|
you pass an API key:
|
|
27
27
|
|
|
28
28
|
- **Free plan** — create the client **without arguments**. No API key, no
|
|
@@ -32,10 +32,10 @@ you pass an API key:
|
|
|
32
32
|
threat-detection data.
|
|
33
33
|
|
|
34
34
|
```python
|
|
35
|
-
from ipwhois import
|
|
35
|
+
from ipwhois import IPWhois
|
|
36
36
|
|
|
37
|
-
free =
|
|
38
|
-
paid =
|
|
37
|
+
free = IPWhois() # Free plan — no API key
|
|
38
|
+
paid = IPWhois("YOUR_API_KEY") # Paid plan — with API key
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
Everything else (`lookup()`, options, error handling) is identical.
|
|
@@ -43,11 +43,11 @@ Everything else (`lookup()`, options, error handling) is identical.
|
|
|
43
43
|
## Quick start — Free plan (no API key)
|
|
44
44
|
|
|
45
45
|
```python
|
|
46
|
-
from ipwhois import
|
|
46
|
+
from ipwhois import IPWhois
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
ipwhois = IPWhois() # no API key
|
|
49
49
|
|
|
50
|
-
info =
|
|
50
|
+
info = ipwhois.lookup("8.8.8.8")
|
|
51
51
|
|
|
52
52
|
print(info["country"], info["flag"]["emoji"])
|
|
53
53
|
# → United States 🇺🇸
|
|
@@ -61,11 +61,11 @@ print(f"{info['city']}, {info['region']}")
|
|
|
61
61
|
Get an API key at <https://ipwhois.io> and pass it to the constructor:
|
|
62
62
|
|
|
63
63
|
```python
|
|
64
|
-
from ipwhois import
|
|
64
|
+
from ipwhois import IPWhois
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
ipwhois = IPWhois("YOUR_API_KEY") # with API key
|
|
67
67
|
|
|
68
|
-
info =
|
|
68
|
+
info = ipwhois.lookup("8.8.8.8")
|
|
69
69
|
|
|
70
70
|
print(info["country"], info["flag"]["emoji"])
|
|
71
71
|
# → United States 🇺🇸
|
|
@@ -74,7 +74,7 @@ print(f"{info['city']}, {info['region']}")
|
|
|
74
74
|
# → Mountain View, California
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
> ℹ️ Pass nothing to look up your own public IP: `
|
|
77
|
+
> ℹ️ Pass nothing to look up your own public IP: `ipwhois.lookup()` — works
|
|
78
78
|
> on both plans.
|
|
79
79
|
|
|
80
80
|
## Lookup options
|
|
@@ -86,42 +86,40 @@ 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.
|
|
96
98
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
Client()
|
|
101
|
-
.set_language("en")
|
|
102
|
-
.set_fields(["country", "city", "flag.emoji"])
|
|
103
|
-
.set_timeout(8)
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
client.lookup("8.8.8.8") # uses all of the above
|
|
107
|
-
client.lookup("1.1.1.1", lang="de") # per-call options override defaults
|
|
108
|
-
```
|
|
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:
|
|
109
102
|
|
|
110
103
|
```python
|
|
111
|
-
#
|
|
112
|
-
|
|
113
|
-
|
|
104
|
+
# Pass "YOUR_API_KEY" to the constructor for the paid plan; otherwise omit it.
|
|
105
|
+
ipwhois = (
|
|
106
|
+
IPWhois()
|
|
114
107
|
.set_language("en")
|
|
115
|
-
.set_fields(["country", "city", "flag.emoji"])
|
|
108
|
+
.set_fields(["success", "country", "city", "flag.emoji"])
|
|
116
109
|
.set_timeout(8)
|
|
117
110
|
)
|
|
118
111
|
|
|
119
|
-
|
|
120
|
-
|
|
112
|
+
ipwhois.lookup("8.8.8.8") # uses lang=en, the field whitelist, and timeout=8
|
|
113
|
+
ipwhois.lookup("1.1.1.1", lang="de") # overrides lang for this single call only
|
|
121
114
|
```
|
|
122
115
|
|
|
123
|
-
>
|
|
124
|
-
>
|
|
116
|
+
> ⚠️ When you restrict fields with `set_fields()` (or the per-call `fields=`
|
|
117
|
+
> keyword), the API only returns the fields you ask for. Always include
|
|
118
|
+
> `"success"` in the list if you rely on `info["success"]` for error
|
|
119
|
+
> checking — otherwise the field will be missing on responses.
|
|
120
|
+
|
|
121
|
+
> ℹ️ `set_security(True)` requires Business+ and `set_rate(True)` requires
|
|
122
|
+
> Basic+. See the table above for what's available where.
|
|
125
123
|
|
|
126
124
|
## HTTPS encryption
|
|
127
125
|
|
|
@@ -130,17 +128,17 @@ example, in environments without an up-to-date CA bundle), pass `ssl=False`
|
|
|
130
128
|
to the constructor:
|
|
131
129
|
|
|
132
130
|
```python
|
|
133
|
-
from ipwhois import
|
|
131
|
+
from ipwhois import IPWhois
|
|
134
132
|
|
|
135
133
|
# Free plan
|
|
136
|
-
|
|
134
|
+
ipwhois = IPWhois(ssl=False)
|
|
137
135
|
```
|
|
138
136
|
|
|
139
137
|
```python
|
|
140
|
-
from ipwhois import
|
|
138
|
+
from ipwhois import IPWhois
|
|
141
139
|
|
|
142
140
|
# Paid plan
|
|
143
|
-
|
|
141
|
+
ipwhois = IPWhois("YOUR_API_KEY", ssl=False)
|
|
144
142
|
```
|
|
145
143
|
|
|
146
144
|
> ℹ️ HTTPS is strongly recommended for production traffic — your API key is
|
|
@@ -153,11 +151,11 @@ address counts as one credit. Available on the **Business** and **Unlimited**
|
|
|
153
151
|
plans.
|
|
154
152
|
|
|
155
153
|
```python
|
|
156
|
-
from ipwhois import
|
|
154
|
+
from ipwhois import IPWhois
|
|
157
155
|
|
|
158
|
-
|
|
156
|
+
ipwhois = IPWhois("YOUR_API_KEY")
|
|
159
157
|
|
|
160
|
-
results =
|
|
158
|
+
results = ipwhois.bulk_lookup([
|
|
161
159
|
"8.8.8.8",
|
|
162
160
|
"1.1.1.1",
|
|
163
161
|
"208.67.222.222",
|
|
@@ -184,7 +182,7 @@ with `success` set to `False` and a `message`. Just check
|
|
|
184
182
|
`info["success"]` after every call:
|
|
185
183
|
|
|
186
184
|
```python
|
|
187
|
-
info =
|
|
185
|
+
info = ipwhois.lookup("8.8.8.8")
|
|
188
186
|
|
|
189
187
|
if not info["success"]:
|
|
190
188
|
print(f"Lookup failed: {info['message']}")
|
|
@@ -211,7 +209,7 @@ include extra fields you can branch on:
|
|
|
211
209
|
```python
|
|
212
210
|
import time
|
|
213
211
|
|
|
214
|
-
info =
|
|
212
|
+
info = ipwhois.lookup("8.8.8.8")
|
|
215
213
|
|
|
216
214
|
if not info["success"]:
|
|
217
215
|
if info.get("http_status") == 429:
|
|
@@ -4,15 +4,15 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import sys
|
|
6
6
|
|
|
7
|
-
from ipwhois import
|
|
7
|
+
from ipwhois import IPWhois
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
# -----------------------------------------------------------------------
|
|
11
11
|
# 1) Free plan -- no API key, ~1 request/second per client IP.
|
|
12
12
|
# -----------------------------------------------------------------------
|
|
13
|
-
|
|
13
|
+
ipwhois = IPWhois()
|
|
14
14
|
|
|
15
|
-
info =
|
|
15
|
+
info = ipwhois.lookup("8.8.8.8")
|
|
16
16
|
|
|
17
17
|
# All errors -- invalid IP, network failure, bad options, ... -- come back
|
|
18
18
|
# here with success == False. The library never raises.
|
|
@@ -33,7 +33,7 @@ print(
|
|
|
33
33
|
# -----------------------------------------------------------------------
|
|
34
34
|
# 2) Look up the caller's own IP -- pass nothing (or None).
|
|
35
35
|
# -----------------------------------------------------------------------
|
|
36
|
-
me =
|
|
36
|
+
me = ipwhois.lookup()
|
|
37
37
|
if me["success"]:
|
|
38
38
|
print(f"My IP: {me['ip']} -- {me['country']}")
|
|
39
39
|
|
|
@@ -41,12 +41,12 @@ if me["success"]:
|
|
|
41
41
|
# -----------------------------------------------------------------------
|
|
42
42
|
# 3) Paid plan -- supply the API key.
|
|
43
43
|
# -----------------------------------------------------------------------
|
|
44
|
-
paid =
|
|
44
|
+
paid = IPWhois("YOUR_API_KEY")
|
|
45
45
|
|
|
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
|
)
|
|
@@ -13,10 +13,10 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import sys
|
|
15
15
|
|
|
16
|
-
from ipwhois import
|
|
16
|
+
from ipwhois import IPWhois
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
ipwhois = IPWhois("YOUR_API_KEY")
|
|
20
20
|
|
|
21
21
|
ips = [
|
|
22
22
|
"8.8.8.8",
|
|
@@ -25,7 +25,7 @@ ips = [
|
|
|
25
25
|
"2c0f:fb50:4003::", # IPv6 is fine too -- mix freely
|
|
26
26
|
]
|
|
27
27
|
|
|
28
|
-
results =
|
|
28
|
+
results = ipwhois.bulk_lookup(ips, lang="en", security=True)
|
|
29
29
|
|
|
30
30
|
# Whole-batch failure (network down, bad API key, rate limit, ...) -- the
|
|
31
31
|
# response is a single error dict instead of a list of per-IP results.
|
|
@@ -8,13 +8,13 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import sys
|
|
10
10
|
|
|
11
|
-
from ipwhois import
|
|
11
|
+
from ipwhois import IPWhois
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
ipwhois = (
|
|
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
|
)
|
|
@@ -30,12 +30,12 @@ def show(label: str, info: dict) -> None:
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
# Both calls below will use lang=en, the field whitelist, and security=1.
|
|
33
|
-
google =
|
|
34
|
-
cf =
|
|
33
|
+
google = ipwhois.lookup("8.8.8.8")
|
|
34
|
+
cf = ipwhois.lookup("1.1.1.1")
|
|
35
35
|
|
|
36
36
|
show("8.8.8.8", google)
|
|
37
37
|
show("1.1.1.1", cf)
|
|
38
38
|
|
|
39
39
|
# One-off override -- this single call uses German instead of English.
|
|
40
|
-
de_only =
|
|
40
|
+
de_only = ipwhois.lookup("8.8.4.4", lang="de")
|
|
41
41
|
show("8.8.4.4 (de)", de_only)
|
|
@@ -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.0.2"
|
|
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" }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Official Python client for the ipwhois.io IP Geolocation API.
|
|
2
|
+
|
|
3
|
+
See :class:`ipwhois.IPWhois` for usage. The library never raises -- every
|
|
4
|
+
failure comes back inside the response dict with ``success`` set to ``False``
|
|
5
|
+
and a ``message``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .ipwhois import IPWhois
|
|
9
|
+
|
|
10
|
+
__all__ = ["IPWhois", "__version__"]
|
|
11
|
+
__version__ = IPWhois.VERSION
|
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
Quick start
|
|
4
4
|
-----------
|
|
5
|
+
from ipwhois import IPWhois
|
|
6
|
+
|
|
5
7
|
# Free plan (no API key, ~1 request/second per client IP)
|
|
6
|
-
|
|
7
|
-
info
|
|
8
|
+
ipwhois = IPWhois()
|
|
9
|
+
info = ipwhois.lookup("8.8.8.8")
|
|
8
10
|
|
|
9
11
|
# Paid plan (with API key, higher limits, bulk, security data, ...)
|
|
10
|
-
|
|
11
|
-
info
|
|
12
|
+
ipwhois = IPWhois("YOUR_API_KEY")
|
|
13
|
+
info = ipwhois.lookup("8.8.8.8", lang="en", security=True)
|
|
12
14
|
|
|
13
15
|
# Bulk lookup -- up to 100 IPs in one call (paid only)
|
|
14
|
-
rows =
|
|
16
|
+
rows = ipwhois.bulk_lookup(["8.8.8.8", "1.1.1.1", "208.67.222.222"])
|
|
15
17
|
|
|
16
18
|
# HTTPS is enabled by default. Pass ssl=False to fall back to HTTP.
|
|
17
19
|
|
|
@@ -32,7 +34,7 @@ import urllib.parse
|
|
|
32
34
|
import urllib.request
|
|
33
35
|
from typing import Any, Dict, Iterable, List, Optional, Union
|
|
34
36
|
|
|
35
|
-
__all__ = ["
|
|
37
|
+
__all__ = ["IPWhois"]
|
|
36
38
|
|
|
37
39
|
# Public type alias: a single API response (lookup or whole-batch error).
|
|
38
40
|
Response = Dict[str, Any]
|
|
@@ -40,7 +42,7 @@ Response = Dict[str, Any]
|
|
|
40
42
|
BulkResponse = Union[List[Response], Response]
|
|
41
43
|
|
|
42
44
|
|
|
43
|
-
class
|
|
45
|
+
class IPWhois:
|
|
44
46
|
"""Client for the ipwhois.io IP Geolocation API.
|
|
45
47
|
|
|
46
48
|
The same class is used for both the Free and Paid plans -- the only
|
|
@@ -53,7 +55,7 @@ class Client:
|
|
|
53
55
|
"""
|
|
54
56
|
|
|
55
57
|
#: Library version, used in the default User-Agent header.
|
|
56
|
-
VERSION: str = "1.0.
|
|
58
|
+
VERSION: str = "1.0.2"
|
|
57
59
|
|
|
58
60
|
#: Free-plan endpoint host (used when no API key is provided).
|
|
59
61
|
HOST_FREE: str = "ipwho.is"
|
|
@@ -76,16 +78,13 @@ class Client:
|
|
|
76
78
|
"ja",
|
|
77
79
|
)
|
|
78
80
|
|
|
79
|
-
#: Output formats supported by the ``output`` parameter.
|
|
80
|
-
SUPPORTED_OUTPUTS: tuple = ("json", "xml", "csv")
|
|
81
|
-
|
|
82
81
|
def __init__(self, api_key: Optional[str] = None, **options: Any) -> None:
|
|
83
82
|
"""Create a new client.
|
|
84
83
|
|
|
85
84
|
:param api_key: Your ipwhois.io API key. Omit for the free plan.
|
|
86
85
|
:param options: Optional defaults applied to every request. Recognised
|
|
87
|
-
keys: ``lang``, ``fields``, ``security``, ``rate``, ``
|
|
88
|
-
``
|
|
86
|
+
keys: ``lang``, ``fields``, ``security``, ``rate``, ``ssl``,
|
|
87
|
+
``timeout``, ``connect_timeout``, ``user_agent``.
|
|
89
88
|
"""
|
|
90
89
|
self._api_key: Optional[str] = api_key
|
|
91
90
|
self._user_agent: str = str(
|
|
@@ -115,7 +114,7 @@ class Client:
|
|
|
115
114
|
|
|
116
115
|
:param ip: IPv4 or IPv6 address. ``None`` (default) = current IP.
|
|
117
116
|
:param options: Per-call options: ``lang``, ``fields``,
|
|
118
|
-
``security`` (bool), ``rate`` (bool)
|
|
117
|
+
``security`` (bool), ``rate`` (bool).
|
|
119
118
|
:returns: Decoded JSON response. On any error (API, network, bad
|
|
120
119
|
input) the dict contains ``success`` set to ``False`` and a
|
|
121
120
|
``message``. The library never raises.
|
|
@@ -217,7 +216,7 @@ class Client:
|
|
|
217
216
|
|
|
218
217
|
# -- Fluent setters ------------------------------------------------- #
|
|
219
218
|
|
|
220
|
-
def set_language(self, lang: str) -> "
|
|
219
|
+
def set_language(self, lang: str) -> "IPWhois":
|
|
221
220
|
"""Set the default language used when none is supplied per call.
|
|
222
221
|
|
|
223
222
|
:param lang: One of :attr:`SUPPORTED_LANGUAGES`.
|
|
@@ -227,13 +226,17 @@ class Client:
|
|
|
227
226
|
|
|
228
227
|
def set_fields(
|
|
229
228
|
self, fields: Union[str, Iterable[str], None]
|
|
230
|
-
) -> "
|
|
229
|
+
) -> "IPWhois":
|
|
231
230
|
"""Restrict every response to a fixed set of fields by default.
|
|
232
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
|
+
|
|
233
236
|
:param fields: An iterable of field names, e.g.
|
|
234
|
-
``["country", "city", "flag.emoji"]``. A pre-joined
|
|
235
|
-
string is also accepted and passed through
|
|
236
|
-
``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.
|
|
237
240
|
"""
|
|
238
241
|
# Strings are iterable in Python, so list("country,city") would
|
|
239
242
|
# explode into individual characters. Keep strings as strings.
|
|
@@ -251,17 +254,17 @@ class Client:
|
|
|
251
254
|
self._defaults["fields"] = str(fields)
|
|
252
255
|
return self
|
|
253
256
|
|
|
254
|
-
def set_security(self, enabled: bool) -> "
|
|
257
|
+
def set_security(self, enabled: bool) -> "IPWhois":
|
|
255
258
|
"""Enable or disable threat-detection data on every call by default."""
|
|
256
259
|
self._defaults["security"] = bool(enabled)
|
|
257
260
|
return self
|
|
258
261
|
|
|
259
|
-
def set_rate(self, enabled: bool) -> "
|
|
262
|
+
def set_rate(self, enabled: bool) -> "IPWhois":
|
|
260
263
|
"""Enable or disable the ``rate`` block in responses by default."""
|
|
261
264
|
self._defaults["rate"] = bool(enabled)
|
|
262
265
|
return self
|
|
263
266
|
|
|
264
|
-
def set_timeout(self, seconds: Any) -> "
|
|
267
|
+
def set_timeout(self, seconds: Any) -> "IPWhois":
|
|
265
268
|
"""Set the per-request total timeout in seconds (default: 10).
|
|
266
269
|
|
|
267
270
|
Bad values (non-numeric, negative) silently fall back to the default,
|
|
@@ -270,7 +273,7 @@ class Client:
|
|
|
270
273
|
self._timeout = _coerce_positive_int(seconds, self._timeout)
|
|
271
274
|
return self
|
|
272
275
|
|
|
273
|
-
def set_connect_timeout(self, seconds: Any) -> "
|
|
276
|
+
def set_connect_timeout(self, seconds: Any) -> "IPWhois":
|
|
274
277
|
"""Set the connection timeout in seconds (default: 5).
|
|
275
278
|
|
|
276
279
|
Note: Python's :mod:`urllib` exposes a single timeout that covers
|
|
@@ -287,7 +290,7 @@ class Client:
|
|
|
287
290
|
)
|
|
288
291
|
return self
|
|
289
292
|
|
|
290
|
-
def set_user_agent(self, user_agent: str) -> "
|
|
293
|
+
def set_user_agent(self, user_agent: str) -> "IPWhois":
|
|
291
294
|
"""Override the User-Agent header sent with every request."""
|
|
292
295
|
self._user_agent = str(user_agent)
|
|
293
296
|
return self
|
|
@@ -315,17 +318,6 @@ class Client:
|
|
|
315
318
|
"error_type": "invalid_argument",
|
|
316
319
|
}
|
|
317
320
|
|
|
318
|
-
output = merged.get("output")
|
|
319
|
-
if output is not None and output not in self.SUPPORTED_OUTPUTS:
|
|
320
|
-
return {
|
|
321
|
-
"success": False,
|
|
322
|
-
"message": (
|
|
323
|
-
f'Unsupported output format "{output}". Supported: '
|
|
324
|
-
f"{', '.join(self.SUPPORTED_OUTPUTS)}."
|
|
325
|
-
),
|
|
326
|
-
"error_type": "invalid_argument",
|
|
327
|
-
}
|
|
328
|
-
|
|
329
321
|
return None
|
|
330
322
|
|
|
331
323
|
def _build_url(self, path: str, options: Dict[str, Any]) -> str:
|
|
@@ -343,9 +335,6 @@ class Client:
|
|
|
343
335
|
if "lang" in merged and merged["lang"] is not None:
|
|
344
336
|
query.append(("lang", str(merged["lang"])))
|
|
345
337
|
|
|
346
|
-
if "output" in merged and merged["output"] is not None:
|
|
347
|
-
query.append(("output", str(merged["output"])))
|
|
348
|
-
|
|
349
338
|
if "fields" in merged and merged["fields"] is not None:
|
|
350
339
|
fields = merged["fields"]
|
|
351
340
|
if isinstance(fields, (list, tuple)):
|
|
@@ -430,23 +419,19 @@ class Client:
|
|
|
430
419
|
try:
|
|
431
420
|
decoded = json.loads(body)
|
|
432
421
|
except json.JSONDecodeError:
|
|
433
|
-
#
|
|
434
|
-
#
|
|
435
|
-
#
|
|
436
|
-
#
|
|
437
|
-
#
|
|
438
|
-
if 200 <= status < 300:
|
|
439
|
-
return {"success": True, "raw": body}
|
|
440
|
-
|
|
441
|
-
# Non-JSON 4xx/5xx -- synthesise an error dict so the caller
|
|
442
|
-
# 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.
|
|
443
427
|
snippet = " ".join(body.split())
|
|
444
428
|
if len(snippet) > 200:
|
|
445
429
|
snippet = snippet[:200] + "\u2026"
|
|
446
430
|
return {
|
|
447
431
|
"success": False,
|
|
448
432
|
"message": (
|
|
449
|
-
f"
|
|
433
|
+
f"Invalid JSON returned by ipwhois API "
|
|
434
|
+
f"(HTTP {status}): {snippet}"
|
|
450
435
|
),
|
|
451
436
|
"http_status": status,
|
|
452
437
|
}
|
|
@@ -502,7 +487,7 @@ def _coerce_positive_int(value: Any, default: int) -> int:
|
|
|
502
487
|
"""Coerce ``value`` to a positive int, falling back to ``default``.
|
|
503
488
|
|
|
504
489
|
Mirrors PHP's lenient ``(int)`` cast so that a stray ``"foo"`` passed
|
|
505
|
-
via constructor kwargs or :meth:`
|
|
490
|
+
via constructor kwargs or :meth:`IPWhois.set_timeout` doesn't blow up
|
|
506
491
|
the whole client. ``None``, non-numeric strings, ``True``/``False``
|
|
507
492
|
edge cases, negative numbers and zero all map to ``default``.
|
|
508
493
|
"""
|
|
@@ -6,34 +6,34 @@ the suite can be run anywhere without an API key or network access.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
from ipwhois import
|
|
9
|
+
from ipwhois import IPWhois
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
def _build_url(
|
|
13
|
-
return
|
|
12
|
+
def _build_url(ipwhois: IPWhois, path: str, **options) -> str:
|
|
13
|
+
return ipwhois._build_url(path, options)
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def test_free_endpoint_has_no_api_key() -> None:
|
|
17
|
-
|
|
18
|
-
assert _build_url(
|
|
17
|
+
ipwhois = IPWhois()
|
|
18
|
+
assert _build_url(ipwhois, "/8.8.8.8") == "https://ipwho.is/8.8.8.8"
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def test_paid_endpoint_appends_api_key() -> None:
|
|
22
|
-
|
|
23
|
-
url = _build_url(
|
|
22
|
+
ipwhois = IPWhois("TESTKEY")
|
|
23
|
+
url = _build_url(ipwhois, "/8.8.8.8")
|
|
24
24
|
|
|
25
25
|
assert url.startswith("https://ipwhois.pro/8.8.8.8?")
|
|
26
26
|
assert "key=TESTKEY" in url
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def test_https_is_always_used_by_default() -> None:
|
|
30
|
-
assert _build_url(
|
|
31
|
-
assert _build_url(
|
|
30
|
+
assert _build_url(IPWhois(), "/").startswith("https://")
|
|
31
|
+
assert _build_url(IPWhois("K"), "/").startswith("https://")
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
def test_ssl_can_be_disabled() -> None:
|
|
35
|
-
free =
|
|
36
|
-
paid =
|
|
35
|
+
free = IPWhois(ssl=False)
|
|
36
|
+
paid = IPWhois("K", ssl=False)
|
|
37
37
|
|
|
38
38
|
assert _build_url(free, "/").startswith("http://ipwho.is")
|
|
39
39
|
assert _build_url(paid, "/").startswith("http://ipwhois.pro")
|
|
@@ -41,13 +41,13 @@ def test_ssl_can_be_disabled() -> None:
|
|
|
41
41
|
|
|
42
42
|
def test_ssl_defaults_to_true_when_not_passed() -> None:
|
|
43
43
|
# Sanity check: omitting the option keeps HTTPS on.
|
|
44
|
-
assert _build_url(
|
|
44
|
+
assert _build_url(IPWhois("K"), "/").startswith("https://")
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
def test_fields_are_joined_with_commas() -> None:
|
|
48
|
-
|
|
48
|
+
ipwhois = IPWhois("K")
|
|
49
49
|
url = _build_url(
|
|
50
|
-
|
|
50
|
+
ipwhois, "/8.8.8.8", fields=["country", "city", "flag.emoji"]
|
|
51
51
|
)
|
|
52
52
|
|
|
53
53
|
# urlencode encodes commas as %2C -- both forms are valid HTTP.
|
|
@@ -55,52 +55,44 @@ def test_fields_are_joined_with_commas() -> None:
|
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
def test_fields_accepts_a_string_too() -> None:
|
|
58
|
-
|
|
59
|
-
url = _build_url(
|
|
58
|
+
ipwhois = IPWhois("K")
|
|
59
|
+
url = _build_url(ipwhois, "/8.8.8.8", fields="country,city")
|
|
60
60
|
assert "fields=country%2Ccity" in url
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
def test_security_and_rate_are_flags_not_values() -> None:
|
|
64
|
-
|
|
65
|
-
url = _build_url(
|
|
64
|
+
ipwhois = IPWhois("K")
|
|
65
|
+
url = _build_url(ipwhois, "/", security=True, rate=True)
|
|
66
66
|
|
|
67
67
|
assert "security=1" in url
|
|
68
68
|
assert "rate=1" in url
|
|
69
69
|
|
|
70
70
|
|
|
71
71
|
def test_security_false_is_omitted() -> None:
|
|
72
|
-
|
|
73
|
-
url = _build_url(
|
|
72
|
+
ipwhois = IPWhois("K")
|
|
73
|
+
url = _build_url(ipwhois, "/", security=False)
|
|
74
74
|
|
|
75
75
|
assert "security=" not in url
|
|
76
76
|
|
|
77
77
|
|
|
78
78
|
def test_per_call_options_override_defaults() -> None:
|
|
79
|
-
|
|
80
|
-
url = _build_url(
|
|
79
|
+
ipwhois = IPWhois("K", lang="ru")
|
|
80
|
+
url = _build_url(ipwhois, "/", lang="en")
|
|
81
81
|
|
|
82
82
|
assert "lang=en" in url
|
|
83
83
|
assert "lang=ru" not in url
|
|
84
84
|
|
|
85
85
|
|
|
86
86
|
def test_invalid_language_returns_error_dict() -> None:
|
|
87
|
-
result =
|
|
87
|
+
result = IPWhois().lookup("8.8.8.8", lang="klingon")
|
|
88
88
|
|
|
89
89
|
assert result["success"] is False
|
|
90
90
|
assert result.get("error_type") == "invalid_argument"
|
|
91
91
|
assert "klingon" in result.get("message", "")
|
|
92
92
|
|
|
93
93
|
|
|
94
|
-
def test_invalid_output_returns_error_dict() -> None:
|
|
95
|
-
result = Client().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
|
-
result =
|
|
95
|
+
result = IPWhois("K").bulk_lookup([])
|
|
104
96
|
|
|
105
97
|
assert isinstance(result, dict)
|
|
106
98
|
assert result["success"] is False
|
|
@@ -108,8 +100,8 @@ def test_bulk_lookup_refuses_empty_list() -> None:
|
|
|
108
100
|
|
|
109
101
|
|
|
110
102
|
def test_bulk_lookup_refuses_more_than_limit() -> None:
|
|
111
|
-
too_many = ["8.8.8.8"] * (
|
|
112
|
-
result =
|
|
103
|
+
too_many = ["8.8.8.8"] * (IPWhois.BULK_LIMIT + 1)
|
|
104
|
+
result = IPWhois("K").bulk_lookup(too_many)
|
|
113
105
|
|
|
114
106
|
assert isinstance(result, dict)
|
|
115
107
|
assert result["success"] is False
|
|
@@ -117,60 +109,60 @@ def test_bulk_lookup_refuses_more_than_limit() -> None:
|
|
|
117
109
|
|
|
118
110
|
|
|
119
111
|
def test_bulk_url_is_comma_separated() -> None:
|
|
120
|
-
|
|
121
|
-
url = _build_url(
|
|
112
|
+
ipwhois = IPWhois("K")
|
|
113
|
+
url = _build_url(ipwhois, "/bulk/" + ",".join(["8.8.8.8", "1.1.1.1"]))
|
|
122
114
|
|
|
123
115
|
assert "/bulk/8.8.8.8,1.1.1.1" in url
|
|
124
116
|
|
|
125
117
|
|
|
126
118
|
def test_fluent_setters_return_self() -> None:
|
|
127
|
-
|
|
119
|
+
ipwhois = IPWhois()
|
|
128
120
|
|
|
129
|
-
assert
|
|
130
|
-
assert
|
|
131
|
-
assert
|
|
132
|
-
assert
|
|
133
|
-
assert
|
|
134
|
-
assert
|
|
135
|
-
assert
|
|
121
|
+
assert ipwhois.set_language("en") is ipwhois
|
|
122
|
+
assert ipwhois.set_fields(["country"]) is ipwhois
|
|
123
|
+
assert ipwhois.set_security(True) is ipwhois
|
|
124
|
+
assert ipwhois.set_rate(False) is ipwhois
|
|
125
|
+
assert ipwhois.set_timeout(5) is ipwhois
|
|
126
|
+
assert ipwhois.set_connect_timeout(2) is ipwhois
|
|
127
|
+
assert ipwhois.set_user_agent("test/1.0") is ipwhois
|
|
136
128
|
|
|
137
129
|
|
|
138
130
|
def test_set_language_affects_subsequent_requests() -> None:
|
|
139
|
-
|
|
140
|
-
url = _build_url(
|
|
131
|
+
ipwhois = IPWhois("K").set_language("de")
|
|
132
|
+
url = _build_url(ipwhois, "/")
|
|
141
133
|
|
|
142
134
|
assert "lang=de" in url
|
|
143
135
|
|
|
144
136
|
|
|
145
137
|
def test_set_fields_affects_subsequent_requests() -> None:
|
|
146
|
-
|
|
147
|
-
url = _build_url(
|
|
138
|
+
ipwhois = IPWhois("K").set_fields(["country", "city"])
|
|
139
|
+
url = _build_url(ipwhois, "/8.8.8.8")
|
|
148
140
|
|
|
149
141
|
assert "fields=country%2Ccity" in url
|
|
150
142
|
|
|
151
143
|
|
|
152
144
|
def test_constructor_options_become_defaults() -> None:
|
|
153
|
-
|
|
154
|
-
url = _build_url(
|
|
145
|
+
ipwhois = IPWhois("K", lang="ru", security=True)
|
|
146
|
+
url = _build_url(ipwhois, "/8.8.8.8")
|
|
155
147
|
|
|
156
148
|
assert "lang=ru" in url
|
|
157
149
|
assert "security=1" in url
|
|
158
150
|
|
|
159
151
|
|
|
160
152
|
def test_user_agent_can_be_set_in_constructor() -> None:
|
|
161
|
-
|
|
162
|
-
assert
|
|
153
|
+
ipwhois = IPWhois(user_agent="my-app/2.0")
|
|
154
|
+
assert ipwhois._user_agent == "my-app/2.0"
|
|
163
155
|
|
|
164
156
|
|
|
165
157
|
def test_default_user_agent_includes_version() -> None:
|
|
166
|
-
|
|
167
|
-
assert
|
|
158
|
+
ipwhois = IPWhois()
|
|
159
|
+
assert ipwhois._user_agent == f"ipwhois-python/{IPWhois.VERSION}"
|
|
168
160
|
|
|
169
161
|
|
|
170
162
|
def test_ip_is_url_encoded_for_ipv6() -> None:
|
|
171
163
|
# IPv6 colons must be percent-encoded inside a path segment.
|
|
172
|
-
|
|
173
|
-
url = _build_url(
|
|
164
|
+
ipwhois = IPWhois()
|
|
165
|
+
url = _build_url(ipwhois, "/" + __import__("urllib.parse", fromlist=["quote"]).quote("2c0f:fb50:4003::", safe=""))
|
|
174
166
|
assert "%3A" in url
|
|
175
167
|
|
|
176
168
|
|
|
@@ -182,7 +174,7 @@ def test_ip_is_url_encoded_for_ipv6() -> None:
|
|
|
182
174
|
def test_bulk_lookup_rejects_a_single_string() -> None:
|
|
183
175
|
# Strings are iterable in Python; without the guard, "8.8.8.8" would
|
|
184
176
|
# be looked up character-by-character. Reject explicitly.
|
|
185
|
-
result =
|
|
177
|
+
result = IPWhois("K").bulk_lookup("8.8.8.8") # type: ignore[arg-type]
|
|
186
178
|
|
|
187
179
|
assert isinstance(result, dict)
|
|
188
180
|
assert result["success"] is False
|
|
@@ -191,7 +183,7 @@ def test_bulk_lookup_rejects_a_single_string() -> None:
|
|
|
191
183
|
|
|
192
184
|
|
|
193
185
|
def test_bulk_lookup_rejects_bytes() -> None:
|
|
194
|
-
result =
|
|
186
|
+
result = IPWhois("K").bulk_lookup(b"8.8.8.8") # type: ignore[arg-type]
|
|
195
187
|
|
|
196
188
|
assert isinstance(result, dict)
|
|
197
189
|
assert result["success"] is False
|
|
@@ -199,7 +191,7 @@ def test_bulk_lookup_rejects_bytes() -> None:
|
|
|
199
191
|
|
|
200
192
|
|
|
201
193
|
def test_bulk_lookup_handles_none_gracefully() -> None:
|
|
202
|
-
result =
|
|
194
|
+
result = IPWhois("K").bulk_lookup(None) # type: ignore[arg-type]
|
|
203
195
|
|
|
204
196
|
assert isinstance(result, dict)
|
|
205
197
|
assert result["success"] is False
|
|
@@ -207,7 +199,7 @@ def test_bulk_lookup_handles_none_gracefully() -> None:
|
|
|
207
199
|
|
|
208
200
|
|
|
209
201
|
def test_bulk_lookup_handles_non_iterable_gracefully() -> None:
|
|
210
|
-
result =
|
|
202
|
+
result = IPWhois("K").bulk_lookup(42) # type: ignore[arg-type]
|
|
211
203
|
|
|
212
204
|
assert isinstance(result, dict)
|
|
213
205
|
assert result["success"] is False
|
|
@@ -215,14 +207,14 @@ def test_bulk_lookup_handles_non_iterable_gracefully() -> None:
|
|
|
215
207
|
|
|
216
208
|
|
|
217
209
|
def test_bulk_lookup_accepts_a_generator() -> None:
|
|
218
|
-
|
|
210
|
+
ipwhois = IPWhois("K")
|
|
219
211
|
# A generator is an iterable that's not a list -- make sure it works.
|
|
220
212
|
gen = (ip for ip in ["8.8.8.8", "1.1.1.1"])
|
|
221
213
|
# Just check it gets through validation -- we don't actually hit the
|
|
222
214
|
# network. Validation passes if we don't get an `error_type` back.
|
|
223
215
|
# We trigger the guard *before* network by passing an empty generator:
|
|
224
216
|
empty = (x for x in [])
|
|
225
|
-
result =
|
|
217
|
+
result = ipwhois.bulk_lookup(empty)
|
|
226
218
|
assert isinstance(result, dict)
|
|
227
219
|
assert result["success"] is False
|
|
228
220
|
assert result.get("error_type") == "invalid_argument"
|
|
@@ -233,8 +225,8 @@ def test_bulk_lookup_accepts_a_generator() -> None:
|
|
|
233
225
|
|
|
234
226
|
def test_set_fields_keeps_string_intact() -> None:
|
|
235
227
|
# Without the str guard, list("country,city") explodes into characters.
|
|
236
|
-
|
|
237
|
-
url = _build_url(
|
|
228
|
+
ipwhois = IPWhois("K").set_fields("country,city")
|
|
229
|
+
url = _build_url(ipwhois, "/8.8.8.8")
|
|
238
230
|
|
|
239
231
|
assert "fields=country%2Ccity" in url
|
|
240
232
|
# Sanity: confirm we haven't accidentally produced the broken form.
|
|
@@ -243,63 +235,71 @@ def test_set_fields_keeps_string_intact() -> None:
|
|
|
243
235
|
|
|
244
236
|
def test_set_fields_tolerates_none() -> None:
|
|
245
237
|
# set_fields(None) must not raise; it clears any previously-set default.
|
|
246
|
-
|
|
247
|
-
url =
|
|
238
|
+
ipwhois = IPWhois().set_fields(["country"]).set_fields(None)
|
|
239
|
+
url = ipwhois._build_url("/8.8.8.8", {})
|
|
248
240
|
assert "fields=" not in url
|
|
249
241
|
|
|
250
242
|
|
|
251
243
|
def test_set_fields_tolerates_non_iterable() -> None:
|
|
252
244
|
# Non-iterable, non-string garbage falls back to str() rather than raising.
|
|
253
|
-
|
|
254
|
-
url = _build_url(
|
|
245
|
+
ipwhois = IPWhois("K").set_fields(42) # type: ignore[arg-type]
|
|
246
|
+
url = _build_url(ipwhois, "/8.8.8.8")
|
|
255
247
|
assert "fields=42" in url
|
|
256
248
|
|
|
257
249
|
|
|
258
250
|
def test_set_timeout_tolerates_garbage_input() -> None:
|
|
259
251
|
# "never raises" extends to setters: bad input falls back to previous
|
|
260
252
|
# value rather than raising ValueError.
|
|
261
|
-
|
|
262
|
-
previous =
|
|
253
|
+
ipwhois = IPWhois()
|
|
254
|
+
previous = ipwhois._timeout
|
|
263
255
|
|
|
264
|
-
|
|
265
|
-
assert
|
|
256
|
+
ipwhois.set_timeout("not a number") # type: ignore[arg-type]
|
|
257
|
+
assert ipwhois._timeout == previous
|
|
266
258
|
|
|
267
|
-
|
|
268
|
-
assert
|
|
259
|
+
ipwhois.set_timeout(None) # type: ignore[arg-type]
|
|
260
|
+
assert ipwhois._timeout == previous
|
|
269
261
|
|
|
270
|
-
|
|
271
|
-
assert
|
|
262
|
+
ipwhois.set_timeout(-5)
|
|
263
|
+
assert ipwhois._timeout == previous
|
|
272
264
|
|
|
273
265
|
# Numeric strings are accepted (matches PHP's `(int)` behaviour).
|
|
274
|
-
|
|
275
|
-
assert
|
|
266
|
+
ipwhois.set_timeout("15") # type: ignore[arg-type]
|
|
267
|
+
assert ipwhois._timeout == 15
|
|
276
268
|
|
|
277
269
|
|
|
278
270
|
def test_set_connect_timeout_tolerates_garbage_input() -> None:
|
|
279
|
-
|
|
280
|
-
previous =
|
|
271
|
+
ipwhois = IPWhois()
|
|
272
|
+
previous = ipwhois._connect_timeout
|
|
281
273
|
|
|
282
|
-
|
|
283
|
-
assert
|
|
274
|
+
ipwhois.set_connect_timeout("oops") # type: ignore[arg-type]
|
|
275
|
+
assert ipwhois._connect_timeout == previous
|
|
284
276
|
|
|
285
277
|
|
|
286
278
|
def test_constructor_tolerates_bad_timeout() -> None:
|
|
287
279
|
# Constructor garbage values fall back to defaults instead of raising.
|
|
288
|
-
|
|
289
|
-
assert
|
|
290
|
-
assert
|
|
280
|
+
ipwhois = IPWhois(timeout="not-a-number", connect_timeout=None)
|
|
281
|
+
assert ipwhois._timeout == 10
|
|
282
|
+
assert ipwhois._connect_timeout == 5
|
|
291
283
|
|
|
292
284
|
|
|
293
|
-
def
|
|
294
|
-
#
|
|
295
|
-
#
|
|
296
|
-
#
|
|
297
|
-
|
|
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,10 @@ 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
325
|
with patch("urllib.request.urlopen", return_value=fake):
|
|
326
|
-
result =
|
|
326
|
+
result = IPWhois().lookup("8.8.8.8")
|
|
327
327
|
|
|
328
|
-
assert result["success"] is
|
|
329
|
-
assert
|
|
328
|
+
assert result["success"] is False
|
|
329
|
+
assert "Invalid JSON" in result.get("message", "")
|
|
330
|
+
assert result.get("http_status") == 200
|
|
@@ -1,12 +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.0] - 2026-05-08
|
|
9
|
-
|
|
10
|
-
### Added
|
|
11
|
-
|
|
12
|
-
- Initial release.
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
"""Official Python client for the ipwhois.io IP Geolocation API.
|
|
2
|
-
|
|
3
|
-
See :class:`ipwhois.Client` for usage. The library never raises -- every
|
|
4
|
-
failure comes back inside the response dict with ``success`` set to ``False``
|
|
5
|
-
and a ``message``.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from .client import Client
|
|
9
|
-
|
|
10
|
-
__all__ = ["Client", "__version__"]
|
|
11
|
-
__version__ = Client.VERSION
|
|
File without changes
|
|
File without changes
|
|
File without changes
|