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.
@@ -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.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
- [![PyPI Version](https://img.shields.io/pypi/v/ipwhois-python.svg)](https://pypi.org/project/ipwhois-python/)
57
- [![Python Versions](https://img.shields.io/pypi/pyversions/ipwhois-python.svg)](https://pypi.org/project/ipwhois-python/)
58
- [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
56
+ [![PyPI Version](https://img.shields.io/pypi/v/ipwhois-python.svg?v=1)](https://pypi.org/project/ipwhois-python/)
57
+ [![Python Versions](https://img.shields.io/pypi/pyversions/ipwhois-python.svg?v=1)](https://pypi.org/project/ipwhois-python/)
58
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg?v=1)](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 `Client` class is used for both plans. The only difference is whether
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 Client
88
+ from ipwhois import IPWhois
89
89
 
90
- free = Client() # Free plan — no API key
91
- paid = Client("YOUR_API_KEY") # Paid plan — with API key
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 Client
99
+ from ipwhois import IPWhois
100
100
 
101
- client = Client() # no API key
101
+ ipwhois = IPWhois() # no API key
102
102
 
103
- info = client.lookup("8.8.8.8")
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 Client
117
+ from ipwhois import IPWhois
118
118
 
119
- client = Client("YOUR_API_KEY") # with API key
119
+ ipwhois = IPWhois("YOUR_API_KEY") # with API key
120
120
 
121
- info = client.lookup("8.8.8.8")
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: `client.lookup()` — works
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
- If you make many calls with the same options, set them once and forget:
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
- ```python
151
- # Free plan
152
- client = (
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
- # Paid plan
165
- client = (
166
- Client("YOUR_API_KEY")
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
- client.lookup("8.8.8.8") # uses all of the above
173
- client.lookup("1.1.1.1", lang="de") # per-call options override defaults
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
- > ℹ️ Paid plans additionally support `set_security(True)` (Business+) and
177
- > `set_rate(True)` (Basic+). See the table above for what's available where.
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 Client
184
+ from ipwhois import IPWhois
187
185
 
188
186
  # Free plan
189
- client = Client(ssl=False)
187
+ ipwhois = IPWhois(ssl=False)
190
188
  ```
191
189
 
192
190
  ```python
193
- from ipwhois import Client
191
+ from ipwhois import IPWhois
194
192
 
195
193
  # Paid plan
196
- client = Client("YOUR_API_KEY", ssl=False)
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 Client
207
+ from ipwhois import IPWhois
210
208
 
211
- client = Client("YOUR_API_KEY")
209
+ ipwhois = IPWhois("YOUR_API_KEY")
212
210
 
213
- results = client.bulk_lookup([
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 = client.lookup("8.8.8.8")
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 = client.lookup("8.8.8.8")
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
- [![PyPI Version](https://img.shields.io/pypi/v/ipwhois-python.svg)](https://pypi.org/project/ipwhois-python/)
4
- [![Python Versions](https://img.shields.io/pypi/pyversions/ipwhois-python.svg)](https://pypi.org/project/ipwhois-python/)
5
- [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
3
+ [![PyPI Version](https://img.shields.io/pypi/v/ipwhois-python.svg?v=1)](https://pypi.org/project/ipwhois-python/)
4
+ [![Python Versions](https://img.shields.io/pypi/pyversions/ipwhois-python.svg?v=1)](https://pypi.org/project/ipwhois-python/)
5
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg?v=1)](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 `Client` class is used for both plans. The only difference is whether
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 Client
35
+ from ipwhois import IPWhois
36
36
 
37
- free = Client() # Free plan — no API key
38
- paid = Client("YOUR_API_KEY") # Paid plan — with API key
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 Client
46
+ from ipwhois import IPWhois
47
47
 
48
- client = Client() # no API key
48
+ ipwhois = IPWhois() # no API key
49
49
 
50
- info = client.lookup("8.8.8.8")
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 Client
64
+ from ipwhois import IPWhois
65
65
 
66
- client = Client("YOUR_API_KEY") # with API key
66
+ ipwhois = IPWhois("YOUR_API_KEY") # with API key
67
67
 
68
- info = client.lookup("8.8.8.8")
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: `client.lookup()` — works
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
- If you make many calls with the same options, set them once and forget:
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
- ```python
98
- # Free plan
99
- client = (
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
- # Paid plan
112
- client = (
113
- Client("YOUR_API_KEY")
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
- client.lookup("8.8.8.8") # uses all of the above
120
- client.lookup("1.1.1.1", lang="de") # per-call options override defaults
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
- > ℹ️ Paid plans additionally support `set_security(True)` (Business+) and
124
- > `set_rate(True)` (Basic+). See the table above for what's available where.
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 Client
131
+ from ipwhois import IPWhois
134
132
 
135
133
  # Free plan
136
- client = Client(ssl=False)
134
+ ipwhois = IPWhois(ssl=False)
137
135
  ```
138
136
 
139
137
  ```python
140
- from ipwhois import Client
138
+ from ipwhois import IPWhois
141
139
 
142
140
  # Paid plan
143
- client = Client("YOUR_API_KEY", ssl=False)
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 Client
154
+ from ipwhois import IPWhois
157
155
 
158
- client = Client("YOUR_API_KEY")
156
+ ipwhois = IPWhois("YOUR_API_KEY")
159
157
 
160
- results = client.bulk_lookup([
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 = client.lookup("8.8.8.8")
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 = client.lookup("8.8.8.8")
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 Client
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
- client = Client()
13
+ ipwhois = IPWhois()
14
14
 
15
- info = client.lookup("8.8.8.8")
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 = client.lookup()
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 = Client("YOUR_API_KEY")
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 Client
16
+ from ipwhois import IPWhois
17
17
 
18
18
 
19
- client = Client("YOUR_API_KEY")
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 = client.bulk_lookup(ips, lang="en", security=True)
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 Client
11
+ from ipwhois import IPWhois
12
12
 
13
13
 
14
- client = (
15
- Client("YOUR_API_KEY")
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 = client.lookup("8.8.8.8")
34
- cf = client.lookup("1.1.1.1")
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 = client.lookup("8.8.4.4", lang="de")
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.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
- client = ipwhois.Client()
7
- info = client.lookup("8.8.8.8")
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
- client = ipwhois.Client("YOUR_API_KEY")
11
- info = client.lookup("8.8.8.8", lang="en", security=True)
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 = client.bulk_lookup(["8.8.8.8", "1.1.1.1", "208.67.222.222"])
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__ = ["Client"]
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 Client:
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.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``, ``output``,
88
- ``ssl``, ``timeout``, ``connect_timeout``, ``user_agent``.
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), ``output``.
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) -> "Client":
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
- ) -> "Client":
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 comma-separated
235
- string is also accepted and passed through unchanged. Pass
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) -> "Client":
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) -> "Client":
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) -> "Client":
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) -> "Client":
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) -> "Client":
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
- # Non-JSON output is legitimate when output=xml or output=csv
434
- # was requested -- return a thin wrapper so the caller still
435
- # gets the raw payload. `success: True` is added so the
436
- # documented `if info["success"]` check stays valid for raw
437
- # responses too.
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"HTTP {status} returned by ipwhois API: {snippet}"
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:`Client.set_timeout` doesn't blow up
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 Client
9
+ from ipwhois import IPWhois
10
10
 
11
11
 
12
- def _build_url(client: Client, path: str, **options) -> str:
13
- return client._build_url(path, options)
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
- client = Client()
18
- assert _build_url(client, "/8.8.8.8") == "https://ipwho.is/8.8.8.8"
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
- client = Client("TESTKEY")
23
- url = _build_url(client, "/8.8.8.8")
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(Client(), "/").startswith("https://")
31
- assert _build_url(Client("K"), "/").startswith("https://")
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 = Client(ssl=False)
36
- paid = Client("K", ssl=False)
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(Client("K"), "/").startswith("https://")
44
+ assert _build_url(IPWhois("K"), "/").startswith("https://")
45
45
 
46
46
 
47
47
  def test_fields_are_joined_with_commas() -> None:
48
- client = Client("K")
48
+ ipwhois = IPWhois("K")
49
49
  url = _build_url(
50
- client, "/8.8.8.8", fields=["country", "city", "flag.emoji"]
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
- client = Client("K")
59
- url = _build_url(client, "/8.8.8.8", fields="country,city")
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
- client = Client("K")
65
- url = _build_url(client, "/", security=True, rate=True)
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
- client = Client("K")
73
- url = _build_url(client, "/", security=False)
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
- client = Client("K", lang="ru")
80
- url = _build_url(client, "/", lang="en")
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 = Client().lookup("8.8.8.8", lang="klingon")
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 = Client("K").bulk_lookup([])
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"] * (Client.BULK_LIMIT + 1)
112
- result = Client("K").bulk_lookup(too_many)
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
- client = Client("K")
121
- url = _build_url(client, "/bulk/" + ",".join(["8.8.8.8", "1.1.1.1"]))
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
- client = Client()
119
+ ipwhois = IPWhois()
128
120
 
129
- assert client.set_language("en") is client
130
- assert client.set_fields(["country"]) is client
131
- assert client.set_security(True) is client
132
- assert client.set_rate(False) is client
133
- assert client.set_timeout(5) is client
134
- assert client.set_connect_timeout(2) is client
135
- assert client.set_user_agent("test/1.0") is client
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
- client = Client("K").set_language("de")
140
- url = _build_url(client, "/")
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
- client = Client("K").set_fields(["country", "city"])
147
- url = _build_url(client, "/8.8.8.8")
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
- client = Client("K", lang="ru", security=True)
154
- url = _build_url(client, "/8.8.8.8")
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
- client = Client(user_agent="my-app/2.0")
162
- assert client._user_agent == "my-app/2.0"
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
- client = Client()
167
- assert client._user_agent == f"ipwhois-python/{Client.VERSION}"
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
- client = Client()
173
- url = _build_url(client, "/" + __import__("urllib.parse", fromlist=["quote"]).quote("2c0f:fb50:4003::", safe=""))
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 = Client("K").bulk_lookup("8.8.8.8") # type: ignore[arg-type]
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 = Client("K").bulk_lookup(b"8.8.8.8") # type: ignore[arg-type]
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 = Client("K").bulk_lookup(None) # type: ignore[arg-type]
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 = Client("K").bulk_lookup(42) # type: ignore[arg-type]
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
- client = Client("K")
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 = client.bulk_lookup(empty)
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
- client = Client("K").set_fields("country,city")
237
- url = _build_url(client, "/8.8.8.8")
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
- client = Client().set_fields(["country"]).set_fields(None)
247
- url = client._build_url("/8.8.8.8", {})
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
- client = Client("K").set_fields(42) # type: ignore[arg-type]
254
- url = _build_url(client, "/8.8.8.8")
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
- client = Client()
262
- previous = client._timeout
253
+ ipwhois = IPWhois()
254
+ previous = ipwhois._timeout
263
255
 
264
- client.set_timeout("not a number") # type: ignore[arg-type]
265
- assert client._timeout == previous
256
+ ipwhois.set_timeout("not a number") # type: ignore[arg-type]
257
+ assert ipwhois._timeout == previous
266
258
 
267
- client.set_timeout(None) # type: ignore[arg-type]
268
- assert client._timeout == previous
259
+ ipwhois.set_timeout(None) # type: ignore[arg-type]
260
+ assert ipwhois._timeout == previous
269
261
 
270
- client.set_timeout(-5)
271
- assert client._timeout == previous
262
+ ipwhois.set_timeout(-5)
263
+ assert ipwhois._timeout == previous
272
264
 
273
265
  # Numeric strings are accepted (matches PHP's `(int)` behaviour).
274
- client.set_timeout("15") # type: ignore[arg-type]
275
- assert client._timeout == 15
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
- client = Client()
280
- previous = client._connect_timeout
271
+ ipwhois = IPWhois()
272
+ previous = ipwhois._connect_timeout
281
273
 
282
- client.set_connect_timeout("oops") # type: ignore[arg-type]
283
- assert client._connect_timeout == previous
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
- client = Client(timeout="not-a-number", connect_timeout=None)
289
- assert client._timeout == 10
290
- assert client._connect_timeout == 5
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 test_raw_response_includes_success_true() -> None:
294
- # When the API returns non-JSON (output=xml/csv), the wrapper response
295
- # must include `success: True` so the documented `if info["success"]`
296
- # check from the README still works.
297
- client = Client()
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
- # Simulate the parsing branch by calling the JSON-decode path with
300
- # non-JSON input via a tiny direct test of the internal helper:
301
- # we patch _request to bypass the network and feed an XML body.
302
- import io
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"<xml><ip>8.8.8.8</ip></xml>")
324
+ fake = _FakeResp(b"<html>captive portal</html>")
325
325
  with patch("urllib.request.urlopen", return_value=fake):
326
- result = client.lookup("8.8.8.8", output="xml")
326
+ result = IPWhois().lookup("8.8.8.8")
327
327
 
328
- assert result["success"] is True
329
- assert result["raw"] == "<xml><ip>8.8.8.8</ip></xml>"
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