ipwhois-python 1.0.1__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,116 @@
1
+ # Changelog
2
+
3
+ All notable changes to `ipwhois-python` will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.2.0] - 2026-05-10
9
+
10
+ ### Added
11
+
12
+ - Every error response now carries an `error_type` field, including errors
13
+ returned by the API. The new value `'api'` joins the existing `'network'`
14
+ and `'invalid_argument'` codes, so callers can branch on the category of
15
+ any failure with a single `info["error_type"]` check — no need to combine
16
+ `success` with `http_status` to distinguish API vs. non-API errors.
17
+ Applies to HTTP 4xx / 5xx responses, malformed JSON bodies, and HTTP 2xx
18
+ responses where the API itself sets `success: false` (e.g. "Invalid IP
19
+ address", "Reserved range").
20
+
21
+ ### Changed
22
+
23
+ - `retry_after` is now only attached to HTTP 429 responses on the **free
24
+ plan** (`ipwho.is`). The paid endpoint (`ipwhois.pro`) does not send a
25
+ `Retry-After` header, so reading it on paid plans is now skipped and the
26
+ field will not appear there. Behaviour on the free plan is unchanged.
27
+ - README "Setting defaults once" section now shows the Free and Paid plans
28
+ as two separate code blocks, matching the layout used in "Quick start"
29
+ and "HTTPS encryption". The setters work identically on both plans, so
30
+ the lookup-override snippet is shared underneath.
31
+ - README "Error response fields" table now lists `message` explicitly (it
32
+ has always been present on every error response) and the `error_type`
33
+ row covers the new `'api'` value as well.
34
+ - The `_request()` HTTP-error code path was lightly refactored so the
35
+ `Retry-After` header is parsed in one place instead of two (one for the
36
+ dict branch and one for the list branch). No behaviour change beyond the
37
+ free-plan gating noted above.
38
+
39
+ ## [1.0.2] - 2026-05-10
40
+
41
+ ### Removed
42
+
43
+ - **The `output` option has been removed.** The library only ever processed
44
+ JSON responses meaningfully, so `output="xml"` and `output="csv"` were a
45
+ thin pass-through that returned the raw payload as a string. The option
46
+ has been dropped from `lookup()`, `bulk_lookup()`, and the constructor's
47
+ keyword arguments; the `IPWhois.SUPPORTED_OUTPUTS` constant is gone.
48
+ Passing `output=...` will silently no-op.
49
+ - The 2xx + non-JSON `{"success": True, "raw": ...}` fallback in the
50
+ response handler (which only existed to support the removed `output`
51
+ parameter) is gone. The API always returns JSON, so any non-JSON 2xx
52
+ body is now treated as a transport error and returned as a
53
+ `success: False` dict.
54
+
55
+ ### Changed
56
+
57
+ - `set_fields()` docstring now mentions that `"success"` should be included
58
+ in the field whitelist if you rely on `info["success"]` for error
59
+ checking — when `fields` is set, the API only returns the fields you list.
60
+ - README "Setting defaults once" section rewritten for clarity: the two
61
+ ways of passing options (per call vs. as defaults), the available
62
+ setters, and the `success`-in-`fields` gotcha are now spelled out
63
+ explicitly. The free/paid example pair was collapsed into a single
64
+ example, since the setters work identically on both plans.
65
+ - All examples that filter fields (`README.md`, `examples/basic.py`,
66
+ `examples/defaults.py`) now include `"success"` in the field list.
67
+
68
+ ### Migration
69
+
70
+ If your code passes `output="json"` you can simply remove it — the library
71
+ always returns the decoded JSON anyway. If you were relying on
72
+ `output="xml"` or `output="csv"` to get the raw payload, that use case is
73
+ no longer supported; call the API directly with `urllib` for those formats.
74
+
75
+ ```python
76
+ # Before (1.0.1):
77
+ info = ipwhois.lookup("8.8.8.8", output="json", fields=["country", "city"])
78
+
79
+ # After (1.0.2):
80
+ info = ipwhois.lookup("8.8.8.8", fields=["success", "country", "city"])
81
+ ```
82
+
83
+ ## [1.0.1] - 2026-05-09
84
+
85
+ ### Changed
86
+
87
+ - **Renamed the main class `Client` to `IPWhois`** for consistency with the
88
+ package and brand. The recommended import is now
89
+ `from ipwhois import IPWhois`. The source module moved from
90
+ `src/ipwhois/client.py` to `src/ipwhois/ipwhois.py`, and the test module
91
+ from `tests/test_client.py` to `tests/test_ipwhois.py`. Public behaviour,
92
+ method signatures, constructor arguments, and return shapes are all
93
+ unchanged.
94
+
95
+ ### Migration
96
+
97
+ ```python
98
+ # Before (1.0.0):
99
+ from ipwhois import Client
100
+ client = Client("YOUR_API_KEY")
101
+ info = client.lookup("8.8.8.8")
102
+
103
+ # After (1.0.1+):
104
+ from ipwhois import IPWhois
105
+ ipwhois = IPWhois("YOUR_API_KEY")
106
+ info = ipwhois.lookup("8.8.8.8")
107
+ ```
108
+
109
+ The variable name (`client`, `ipwhois`, anything else) is up to you; only
110
+ the class identifier changed.
111
+
112
+ ## [1.0.0] - 2026-05-08
113
+
114
+ ### Added
115
+
116
+ - Initial release.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ipwhois-python
3
- Version: 1.0.1
3
+ Version: 1.2.0
4
4
  Summary: Official Python client for the ipwhois.io IP Geolocation API. Simple, dependency-free, supports single and bulk IP lookups.
5
5
  Project-URL: Homepage, https://ipwhois.io
6
6
  Project-URL: Documentation, https://ipwhois.io/documentation
@@ -139,42 +139,59 @@ on the client as a default.
139
139
  | ------------ | ------- | -------------------- | ---------------------------------------------------------------------- |
140
140
  | `lang` | str | Free + Paid | One of: `en`, `ru`, `de`, `es`, `pt-BR`, `fr`, `zh-CN`, `ja` |
141
141
  | `fields` | list | Free + Paid | Restrict the response to specific fields (e.g. `["country", "city"]`) |
142
- | `output` | str | Free + Paid | `json` (default), `xml`, `csv` |
143
142
  | `rate` | bool | Basic and above | Include the `rate` block (`limit`, `remaining`) |
144
143
  | `security` | bool | Business and above | Include the `security` block (proxy/vpn/tor/hosting) |
145
144
 
146
145
  ### Setting defaults once
147
146
 
148
- 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.
151
+
152
+ Defaults are set with fluent setters — `set_language()`, `set_fields()`,
153
+ `set_security()`, `set_rate()`, `set_timeout()`, `set_connect_timeout()`,
154
+ `set_user_agent()` — and can be chained:
149
155
 
150
156
  ```python
157
+ from ipwhois import IPWhois
158
+
151
159
  # Free plan
152
160
  ipwhois = (
153
161
  IPWhois()
154
162
  .set_language("en")
155
- .set_fields(["country", "city", "flag.emoji"])
163
+ .set_fields(["success", "country", "city", "flag.emoji"])
156
164
  .set_timeout(8)
157
165
  )
158
-
159
- ipwhois.lookup("8.8.8.8") # uses all of the above
160
- ipwhois.lookup("1.1.1.1", lang="de") # per-call options override defaults
161
166
  ```
162
167
 
163
168
  ```python
169
+ from ipwhois import IPWhois
170
+
164
171
  # Paid plan
165
172
  ipwhois = (
166
173
  IPWhois("YOUR_API_KEY")
167
174
  .set_language("en")
168
- .set_fields(["country", "city", "flag.emoji"])
175
+ .set_fields(["success", "country", "city", "flag.emoji"])
169
176
  .set_timeout(8)
170
177
  )
178
+ ```
179
+
180
+ Either client behaves the same way at call time — per-call options always
181
+ win over the defaults:
171
182
 
172
- ipwhois.lookup("8.8.8.8") # uses all of the above
173
- ipwhois.lookup("1.1.1.1", lang="de") # per-call options override defaults
183
+ ```python
184
+ ipwhois.lookup("8.8.8.8") # uses lang=en, the field whitelist, and timeout=8
185
+ ipwhois.lookup("1.1.1.1", lang="de") # overrides lang for this single call only
174
186
  ```
175
187
 
176
- > ℹ️ Paid plans additionally support `set_security(True)` (Business+) and
177
- > `set_rate(True)` (Basic+). See the table above for what's available where.
188
+ > ⚠️ When you restrict fields with `set_fields()` (or the per-call `fields=`
189
+ > keyword), the API only returns the fields you ask for. Always include
190
+ > `"success"` in the list if you rely on `info["success"]` for error
191
+ > checking — otherwise the field will be missing on responses.
192
+
193
+ > ℹ️ `set_security(True)` requires Business+ and `set_rate(True)` requires
194
+ > Basic+. See the table above for what's available where.
178
195
 
179
196
  ## HTTPS encryption
180
197
 
@@ -252,14 +269,16 @@ application — you decide how to react.
252
269
 
253
270
  ### Error response fields
254
271
 
255
- Every error response contains `success: False` and a `message`. Some errors
256
- include extra fields you can branch on:
272
+ Every error response contains `success: False`, a human-readable `message`,
273
+ and an `error_type` so you can branch on the category of the failure. Some
274
+ errors include extra fields you can branch on:
257
275
 
258
- | Field | When it's present |
259
- | -------------- | ---------------------------------------------------------------------------- |
260
- | `error_type` | `'network'` or `'invalid_argument'` for non-API errors |
261
- | `http_status` | On HTTP 4xx / 5xx responses |
262
- | `retry_after` | On HTTP 429 if the API sent a `Retry-After` header |
276
+ | Field | When it's present |
277
+ | -------------- | -------------------------------------------------------------------------------------------- |
278
+ | `message` | Always human-readable description of what went wrong |
279
+ | `error_type` | Always one of `'api'`, `'network'`, or `'invalid_argument'` |
280
+ | `http_status` | On HTTP 4xx / 5xx responses |
281
+ | `retry_after` | On HTTP 429 — **free plan only** (the paid endpoint does not send a `Retry-After` header) |
263
282
 
264
283
  ```python
265
284
  import time
@@ -349,9 +368,9 @@ An **error** response looks like:
349
368
  {
350
369
  "success": false,
351
370
  "message": "Invalid IP address",
371
+ "error_type": "api", // 'api' / 'network' / 'invalid_argument'
352
372
  "http_status": 400 // present for HTTP 4xx / 5xx
353
- // "retry_after": 60 // additionally present on HTTP 429 if the API sent a Retry-After header
354
- // "error_type": "network" // present for non-API errors: 'network', 'invalid_argument'
373
+ // "retry_after": 60 // additionally present on HTTP 429 free plan only
355
374
  }
356
375
  ```
357
376
 
@@ -86,42 +86,59 @@ on the client as a default.
86
86
  | ------------ | ------- | -------------------- | ---------------------------------------------------------------------- |
87
87
  | `lang` | str | Free + Paid | One of: `en`, `ru`, `de`, `es`, `pt-BR`, `fr`, `zh-CN`, `ja` |
88
88
  | `fields` | list | Free + Paid | Restrict the response to specific fields (e.g. `["country", "city"]`) |
89
- | `output` | str | Free + Paid | `json` (default), `xml`, `csv` |
90
89
  | `rate` | bool | Basic and above | Include the `rate` block (`limit`, `remaining`) |
91
90
  | `security` | bool | Business and above | Include the `security` block (proxy/vpn/tor/hosting) |
92
91
 
93
92
  ### Setting defaults once
94
93
 
95
- 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.
98
+
99
+ Defaults are set with fluent setters — `set_language()`, `set_fields()`,
100
+ `set_security()`, `set_rate()`, `set_timeout()`, `set_connect_timeout()`,
101
+ `set_user_agent()` — and can be chained:
96
102
 
97
103
  ```python
104
+ from ipwhois import IPWhois
105
+
98
106
  # Free plan
99
107
  ipwhois = (
100
108
  IPWhois()
101
109
  .set_language("en")
102
- .set_fields(["country", "city", "flag.emoji"])
110
+ .set_fields(["success", "country", "city", "flag.emoji"])
103
111
  .set_timeout(8)
104
112
  )
105
-
106
- ipwhois.lookup("8.8.8.8") # uses all of the above
107
- ipwhois.lookup("1.1.1.1", lang="de") # per-call options override defaults
108
113
  ```
109
114
 
110
115
  ```python
116
+ from ipwhois import IPWhois
117
+
111
118
  # Paid plan
112
119
  ipwhois = (
113
120
  IPWhois("YOUR_API_KEY")
114
121
  .set_language("en")
115
- .set_fields(["country", "city", "flag.emoji"])
122
+ .set_fields(["success", "country", "city", "flag.emoji"])
116
123
  .set_timeout(8)
117
124
  )
125
+ ```
126
+
127
+ Either client behaves the same way at call time — per-call options always
128
+ win over the defaults:
118
129
 
119
- ipwhois.lookup("8.8.8.8") # uses all of the above
120
- ipwhois.lookup("1.1.1.1", lang="de") # per-call options override defaults
130
+ ```python
131
+ ipwhois.lookup("8.8.8.8") # uses lang=en, the field whitelist, and timeout=8
132
+ ipwhois.lookup("1.1.1.1", lang="de") # overrides lang for this single call only
121
133
  ```
122
134
 
123
- > ℹ️ Paid plans additionally support `set_security(True)` (Business+) and
124
- > `set_rate(True)` (Basic+). See the table above for what's available where.
135
+ > ⚠️ When you restrict fields with `set_fields()` (or the per-call `fields=`
136
+ > keyword), the API only returns the fields you ask for. Always include
137
+ > `"success"` in the list if you rely on `info["success"]` for error
138
+ > checking — otherwise the field will be missing on responses.
139
+
140
+ > ℹ️ `set_security(True)` requires Business+ and `set_rate(True)` requires
141
+ > Basic+. See the table above for what's available where.
125
142
 
126
143
  ## HTTPS encryption
127
144
 
@@ -199,14 +216,16 @@ application — you decide how to react.
199
216
 
200
217
  ### Error response fields
201
218
 
202
- Every error response contains `success: False` and a `message`. Some errors
203
- include extra fields you can branch on:
219
+ Every error response contains `success: False`, a human-readable `message`,
220
+ and an `error_type` so you can branch on the category of the failure. Some
221
+ errors include extra fields you can branch on:
204
222
 
205
- | Field | When it's present |
206
- | -------------- | ---------------------------------------------------------------------------- |
207
- | `error_type` | `'network'` or `'invalid_argument'` for non-API errors |
208
- | `http_status` | On HTTP 4xx / 5xx responses |
209
- | `retry_after` | On HTTP 429 if the API sent a `Retry-After` header |
223
+ | Field | When it's present |
224
+ | -------------- | -------------------------------------------------------------------------------------------- |
225
+ | `message` | Always human-readable description of what went wrong |
226
+ | `error_type` | Always one of `'api'`, `'network'`, or `'invalid_argument'` |
227
+ | `http_status` | On HTTP 4xx / 5xx responses |
228
+ | `retry_after` | On HTTP 429 — **free plan only** (the paid endpoint does not send a `Retry-After` header) |
210
229
 
211
230
  ```python
212
231
  import time
@@ -296,9 +315,9 @@ An **error** response looks like:
296
315
  {
297
316
  "success": false,
298
317
  "message": "Invalid IP address",
318
+ "error_type": "api", // 'api' / 'network' / 'invalid_argument'
299
319
  "http_status": 400 // present for HTTP 4xx / 5xx
300
- // "retry_after": 60 // additionally present on HTTP 429 if the API sent a Retry-After header
301
- // "error_type": "network" // present for non-API errors: 'network', 'invalid_argument'
320
+ // "retry_after": 60 // additionally present on HTTP 429 free plan only
302
321
  }
303
322
  ```
304
323
 
@@ -46,7 +46,7 @@ paid = IPWhois("YOUR_API_KEY")
46
46
  info = paid.lookup(
47
47
  "1.1.1.1",
48
48
  lang="en", # localised country/city/...
49
- fields=["country", "city", "connection.isp", "flag.emoji"],
49
+ fields=["success", "country", "city", "connection.isp", "flag.emoji"],
50
50
  security=True, # include proxy/vpn/tor flags
51
51
  rate=True, # include rate-limit info
52
52
  )
@@ -14,7 +14,7 @@ from ipwhois import IPWhois
14
14
  ipwhois = (
15
15
  IPWhois("YOUR_API_KEY")
16
16
  .set_language("en")
17
- .set_fields(["country", "city", "flag.emoji", "connection.isp"])
17
+ .set_fields(["success", "country", "city", "flag.emoji", "connection.isp"])
18
18
  .set_security(True)
19
19
  .set_timeout(8)
20
20
  )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ipwhois-python"
7
- version = "1.0.1"
7
+ version = "1.2.0"
8
8
  description = "Official Python client for the ipwhois.io IP Geolocation API. Simple, dependency-free, supports single and bulk IP lookups."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -55,7 +55,7 @@ class IPWhois:
55
55
  """
56
56
 
57
57
  #: Library version, used in the default User-Agent header.
58
- VERSION: str = "1.0.1"
58
+ VERSION: str = "1.2.0"
59
59
 
60
60
  #: Free-plan endpoint host (used when no API key is provided).
61
61
  HOST_FREE: str = "ipwho.is"
@@ -78,16 +78,13 @@ class IPWhois:
78
78
  "ja",
79
79
  )
80
80
 
81
- #: Output formats supported by the ``output`` parameter.
82
- SUPPORTED_OUTPUTS: tuple = ("json", "xml", "csv")
83
-
84
81
  def __init__(self, api_key: Optional[str] = None, **options: Any) -> None:
85
82
  """Create a new client.
86
83
 
87
84
  :param api_key: Your ipwhois.io API key. Omit for the free plan.
88
85
  :param options: Optional defaults applied to every request. Recognised
89
- keys: ``lang``, ``fields``, ``security``, ``rate``, ``output``,
90
- ``ssl``, ``timeout``, ``connect_timeout``, ``user_agent``.
86
+ keys: ``lang``, ``fields``, ``security``, ``rate``, ``ssl``,
87
+ ``timeout``, ``connect_timeout``, ``user_agent``.
91
88
  """
92
89
  self._api_key: Optional[str] = api_key
93
90
  self._user_agent: str = str(
@@ -117,7 +114,7 @@ class IPWhois:
117
114
 
118
115
  :param ip: IPv4 or IPv6 address. ``None`` (default) = current IP.
119
116
  :param options: Per-call options: ``lang``, ``fields``,
120
- ``security`` (bool), ``rate`` (bool), ``output``.
117
+ ``security`` (bool), ``rate`` (bool).
121
118
  :returns: Decoded JSON response. On any error (API, network, bad
122
119
  input) the dict contains ``success`` set to ``False`` and a
123
120
  ``message``. The library never raises.
@@ -232,10 +229,14 @@ class IPWhois:
232
229
  ) -> "IPWhois":
233
230
  """Restrict every response to a fixed set of fields by default.
234
231
 
232
+ Include ``"success"`` in the list if you rely on ``info["success"]``
233
+ for error checking -- when ``fields`` is set, the API only returns
234
+ the fields you ask for.
235
+
235
236
  :param fields: An iterable of field names, e.g.
236
- ``["country", "city", "flag.emoji"]``. A pre-joined comma-separated
237
- string is also accepted and passed through unchanged. Pass
238
- ``None`` to clear any previously-set default.
237
+ ``["success", "country", "city", "flag.emoji"]``. A pre-joined
238
+ comma-separated string is also accepted and passed through
239
+ unchanged. Pass ``None`` to clear any previously-set default.
239
240
  """
240
241
  # Strings are iterable in Python, so list("country,city") would
241
242
  # explode into individual characters. Keep strings as strings.
@@ -317,17 +318,6 @@ class IPWhois:
317
318
  "error_type": "invalid_argument",
318
319
  }
319
320
 
320
- output = merged.get("output")
321
- if output is not None and output not in self.SUPPORTED_OUTPUTS:
322
- return {
323
- "success": False,
324
- "message": (
325
- f'Unsupported output format "{output}". Supported: '
326
- f"{', '.join(self.SUPPORTED_OUTPUTS)}."
327
- ),
328
- "error_type": "invalid_argument",
329
- }
330
-
331
321
  return None
332
322
 
333
323
  def _build_url(self, path: str, options: Dict[str, Any]) -> str:
@@ -345,9 +335,6 @@ class IPWhois:
345
335
  if "lang" in merged and merged["lang"] is not None:
346
336
  query.append(("lang", str(merged["lang"])))
347
337
 
348
- if "output" in merged and merged["output"] is not None:
349
- query.append(("output", str(merged["output"])))
350
-
351
338
  if "fields" in merged and merged["fields"] is not None:
352
339
  fields = merged["fields"]
353
340
  if isinstance(fields, (list, tuple)):
@@ -432,25 +419,22 @@ class IPWhois:
432
419
  try:
433
420
  decoded = json.loads(body)
434
421
  except json.JSONDecodeError:
435
- # Non-JSON output is legitimate when output=xml or output=csv
436
- # was requested -- return a thin wrapper so the caller still
437
- # gets the raw payload. `success: True` is added so the
438
- # documented `if info["success"]` check stays valid for raw
439
- # responses too.
440
- if 200 <= status < 300:
441
- return {"success": True, "raw": body}
442
-
443
- # Non-JSON 4xx/5xx -- synthesise an error dict so the caller
444
- # can handle it the same way as a normal API error.
422
+ # The ipwhois API always returns JSON. A non-JSON body means
423
+ # something went wrong upstream (gateway error page, captive
424
+ # portal, hijacked response, ...) -- synthesise an error dict
425
+ # so the caller can handle it the same way as a normal API
426
+ # error.
445
427
  snippet = " ".join(body.split())
446
428
  if len(snippet) > 200:
447
429
  snippet = snippet[:200] + "\u2026"
448
430
  return {
449
431
  "success": False,
450
432
  "message": (
451
- f"HTTP {status} returned by ipwhois API: {snippet}"
433
+ f"Invalid JSON returned by ipwhois API "
434
+ f"(HTTP {status}): {snippet}"
452
435
  ),
453
436
  "http_status": status,
437
+ "error_type": "api",
454
438
  }
455
439
 
456
440
  if not isinstance(decoded, (dict, list)):
@@ -474,12 +458,6 @@ class IPWhois:
474
458
  "message": message,
475
459
  "http_status": status,
476
460
  }
477
-
478
- if status == 429 and "retry-after" in headers:
479
- try:
480
- decoded["retry_after"] = int(headers["retry-after"])
481
- except (TypeError, ValueError):
482
- pass
483
461
  else:
484
462
  # List response with error status -- wrap as an error dict.
485
463
  decoded = {
@@ -487,11 +465,32 @@ class IPWhois:
487
465
  "message": f"HTTP {status} returned by ipwhois API",
488
466
  "http_status": status,
489
467
  }
490
- if status == 429 and "retry-after" in headers:
491
- try:
492
- decoded["retry_after"] = int(headers["retry-after"])
493
- except (TypeError, ValueError):
494
- pass
468
+
469
+ # `Retry-After` is only emitted by the free-plan endpoint
470
+ # (ipwho.is); the paid endpoint (ipwhois.pro) does not send the
471
+ # header, so don't try to read it there.
472
+ if (
473
+ status == 429
474
+ and self._api_key is None
475
+ and "retry-after" in headers
476
+ ):
477
+ try:
478
+ decoded["retry_after"] = int(headers["retry-after"])
479
+ except (TypeError, ValueError):
480
+ pass
481
+
482
+ # Tag every API-shaped error (`success: False` returned by the API,
483
+ # on any HTTP status) with `error_type: 'api'` so callers can branch
484
+ # on the category alongside the non-API codes ('network',
485
+ # 'environment', 'invalid_argument'). HTTP 2xx + success=false bodies
486
+ # (e.g. "Invalid IP address", "Reserved range") are otherwise passed
487
+ # through untouched.
488
+ if (
489
+ isinstance(decoded, dict)
490
+ and decoded.get("success") is False
491
+ and "error_type" not in decoded
492
+ ):
493
+ decoded["error_type"] = "api"
495
494
 
496
495
  # For HTTP 2xx with `success: false` (e.g. "Invalid IP address",
497
496
  # "Reserved range") we just pass the body through -- it is already
@@ -91,14 +91,6 @@ def test_invalid_language_returns_error_dict() -> None:
91
91
  assert "klingon" in result.get("message", "")
92
92
 
93
93
 
94
- def test_invalid_output_returns_error_dict() -> None:
95
- result = IPWhois().lookup("8.8.8.8", output="yaml")
96
-
97
- assert result["success"] is False
98
- assert result.get("error_type") == "invalid_argument"
99
- assert "yaml" in result.get("message", "")
100
-
101
-
102
94
  def test_bulk_lookup_refuses_empty_list() -> None:
103
95
  result = IPWhois("K").bulk_lookup([])
104
96
 
@@ -290,16 +282,24 @@ def test_constructor_tolerates_bad_timeout() -> None:
290
282
  assert ipwhois._connect_timeout == 5
291
283
 
292
284
 
293
- def 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
- ipwhois = IPWhois()
285
+ def test_output_option_is_silently_dropped() -> None:
286
+ # The `output` parameter was removed in 1.0.2. Passing it must NOT raise
287
+ # or trip validation -- it's just ignored, and the resulting URL must
288
+ # not contain an `output=...` query string.
289
+ ipwhois = IPWhois("K")
290
+ url = ipwhois._build_url("/8.8.8.8", {"output": "xml", "lang": "en"})
298
291
 
299
- # 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,127 @@ def test_raw_response_includes_success_true() -> None:
321
321
  def getcode(self) -> int:
322
322
  return 200
323
323
 
324
- fake = _FakeResp(b"<xml><ip>8.8.8.8</ip></xml>")
324
+ fake = _FakeResp(b"<html>captive portal</html>")
325
+ with patch("urllib.request.urlopen", return_value=fake):
326
+ result = IPWhois().lookup("8.8.8.8")
327
+
328
+ assert result["success"] is False
329
+ assert "Invalid JSON" in result.get("message", "")
330
+ assert result.get("http_status") == 200
331
+ assert result.get("error_type") == "api"
332
+
333
+
334
+ # --------------------------------------------------------------------- #
335
+ # Response shaping -- the urllib layer is stubbed via mock.patch so #
336
+ # the suite can exercise error tagging without making real HTTP calls. #
337
+ # --------------------------------------------------------------------- #
338
+
339
+
340
+ class _FakeOkResp:
341
+ """Minimal stand-in for a urllib response context manager (HTTP 2xx)."""
342
+
343
+ def __init__(self, status: int, body: bytes, headers: dict) -> None:
344
+ self.status = status
345
+ self._body = body
346
+ self.headers = headers
347
+
348
+ def __enter__(self) -> "_FakeOkResp":
349
+ return self
350
+
351
+ def __exit__(self, *_: object) -> None:
352
+ return None
353
+
354
+ def read(self) -> bytes:
355
+ return self._body
356
+
357
+ def getcode(self) -> int:
358
+ return self.status
359
+
360
+
361
+ def _fake_http_error(status: int, body: bytes, headers: dict):
362
+ """Build a urllib.error.HTTPError for status >= 400.
363
+
364
+ The 4xx / 5xx code path inside ``_request`` is reached via this
365
+ exception, not via the success branch, so tests that target it have
366
+ to make ``urlopen`` raise.
367
+ """
368
+ import io
369
+ import urllib.error
370
+
371
+ return urllib.error.HTTPError(
372
+ url="https://example.test",
373
+ code=status,
374
+ msg="error",
375
+ hdrs=headers, # type: ignore[arg-type]
376
+ fp=io.BytesIO(body),
377
+ )
378
+
379
+
380
+ def test_2xx_with_success_false_is_tagged_as_api_error() -> None:
381
+ # The API returns 200 with `success: False` for things like
382
+ # "Reserved range" or "Invalid IP address" -- these should be passed
383
+ # through without `http_status` (which is reserved for 4xx/5xx),
384
+ # but tagged with `error_type: 'api'` so callers can branch on the
385
+ # category the same way they branch on 'network' / 'environment' /
386
+ # 'invalid_argument'.
387
+ from unittest.mock import patch
388
+
389
+ body = (
390
+ b'{"success": false, "message": "Reserved range", "ip": "127.0.0.1"}'
391
+ )
392
+ fake = _FakeOkResp(200, body, headers={})
325
393
  with patch("urllib.request.urlopen", return_value=fake):
326
- result = ipwhois.lookup("8.8.8.8", output="xml")
394
+ result = IPWhois().lookup("127.0.0.1")
395
+
396
+ assert result["success"] is False
397
+ assert result.get("message") == "Reserved range"
398
+ assert result.get("ip") == "127.0.0.1"
399
+ assert "http_status" not in result
400
+ assert result.get("error_type") == "api"
401
+
402
+
403
+ def test_4xx_response_is_normalised_with_error_type_api() -> None:
404
+ from unittest.mock import patch
405
+
406
+ body = b'{"success": false, "message": "Invalid API key"}'
407
+ err = _fake_http_error(401, body, headers={})
408
+ with patch("urllib.request.urlopen", side_effect=err):
409
+ result = IPWhois("BAD").lookup("8.8.8.8")
327
410
 
328
- assert result["success"] is True
329
- assert result["raw"] == "<xml><ip>8.8.8.8</ip></xml>"
411
+ assert result["success"] is False
412
+ assert result.get("http_status") == 401
413
+ assert result.get("message") == "Invalid API key"
414
+ assert result.get("error_type") == "api"
415
+
416
+
417
+ def test_429_on_free_plan_attaches_retry_after() -> None:
418
+ # The free-plan endpoint (ipwho.is) sends `Retry-After` on rate-limit
419
+ # responses; the client surfaces it as `retry_after`.
420
+ from unittest.mock import patch
421
+
422
+ body = b'{"success": false, "message": "Rate limited"}'
423
+ err = _fake_http_error(429, body, headers={"retry-after": "42"})
424
+ with patch("urllib.request.urlopen", side_effect=err):
425
+ result = IPWhois().lookup("8.8.8.8") # free plan -- no API key
426
+
427
+ assert result["success"] is False
428
+ assert result.get("http_status") == 429
429
+ assert result.get("retry_after") == 42
430
+ assert result.get("error_type") == "api"
431
+
432
+
433
+ def test_429_on_paid_plan_does_not_attach_retry_after() -> None:
434
+ # The paid endpoint (ipwhois.pro) does not send `Retry-After`. Even
435
+ # if a header is present (proxies, test stubs, ...), the client
436
+ # ignores it on paid plans so `retry_after` will not appear.
437
+ from unittest.mock import patch
438
+
439
+ body = b'{"success": false, "message": "Rate limited"}'
440
+ err = _fake_http_error(429, body, headers={"retry-after": "42"})
441
+ with patch("urllib.request.urlopen", side_effect=err):
442
+ result = IPWhois("KEY").lookup("8.8.8.8") # paid plan
443
+
444
+ assert result["success"] is False
445
+ assert result.get("http_status") == 429
446
+ assert "retry_after" not in result
447
+ assert result.get("error_type") == "api"
@@ -1,41 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to `ipwhois-python` will be documented in this file.
4
-
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
-
8
- ## [1.0.1] - 2026-05-09
9
-
10
- ### Changed
11
-
12
- - **Renamed the main class `Client` to `IPWhois`** for consistency with the
13
- package and brand. The recommended import is now
14
- `from ipwhois import IPWhois`. The source module moved from
15
- `src/ipwhois/client.py` to `src/ipwhois/ipwhois.py`, and the test module
16
- from `tests/test_client.py` to `tests/test_ipwhois.py`. Public behaviour,
17
- method signatures, constructor arguments, and return shapes are all
18
- unchanged.
19
-
20
- ### Migration
21
-
22
- ```python
23
- # Before (1.0.0):
24
- from ipwhois import Client
25
- client = Client("YOUR_API_KEY")
26
- info = client.lookup("8.8.8.8")
27
-
28
- # After (1.0.1+):
29
- from ipwhois import IPWhois
30
- ipwhois = IPWhois("YOUR_API_KEY")
31
- info = ipwhois.lookup("8.8.8.8")
32
- ```
33
-
34
- The variable name (`client`, `ipwhois`, anything else) is up to you; only
35
- the class identifier changed.
36
-
37
- ## [1.0.0] - 2026-05-08
38
-
39
- ### Added
40
-
41
- - Initial release.
File without changes