ipwhois-python 1.0.1__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.1
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
@@ -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.
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
151
- # Free plan
157
+ # Pass "YOUR_API_KEY" to the constructor for the paid plan; otherwise omit it.
152
158
  ipwhois = (
153
159
  IPWhois()
154
160
  .set_language("en")
155
- .set_fields(["country", "city", "flag.emoji"])
161
+ .set_fields(["success", "country", "city", "flag.emoji"])
156
162
  .set_timeout(8)
157
163
  )
158
164
 
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
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
161
167
  ```
162
168
 
163
- ```python
164
- # Paid plan
165
- ipwhois = (
166
- IPWhois("YOUR_API_KEY")
167
- .set_language("en")
168
- .set_fields(["country", "city", "flag.emoji"])
169
- .set_timeout(8)
170
- )
171
-
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
174
- ```
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.
175
173
 
176
- > ℹ️ Paid plans additionally support `set_security(True)` (Business+) and
177
- > `set_rate(True)` (Basic+). See the table above for what's available where.
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
 
@@ -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.
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
98
- # Free plan
104
+ # Pass "YOUR_API_KEY" to the constructor for the paid plan; otherwise omit it.
99
105
  ipwhois = (
100
106
  IPWhois()
101
107
  .set_language("en")
102
- .set_fields(["country", "city", "flag.emoji"])
108
+ .set_fields(["success", "country", "city", "flag.emoji"])
103
109
  .set_timeout(8)
104
110
  )
105
111
 
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
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
108
114
  ```
109
115
 
110
- ```python
111
- # Paid plan
112
- ipwhois = (
113
- IPWhois("YOUR_API_KEY")
114
- .set_language("en")
115
- .set_fields(["country", "city", "flag.emoji"])
116
- .set_timeout(8)
117
- )
118
-
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
121
- ```
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.
122
120
 
123
- > ℹ️ Paid plans additionally support `set_security(True)` (Business+) and
124
- > `set_rate(True)` (Basic+). See the table above for what's available where.
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
 
@@ -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.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" }
@@ -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.0.2"
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,23 +419,19 @@ 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,
454
437
  }
@@ -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"})
291
+
292
+ assert "output=" not in url
293
+ # Other options next to it still work.
294
+ assert "lang=en" in url
298
295
 
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
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 = ipwhois.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,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