ipwhois-python 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ipwhois/__init__.py +11 -0
- ipwhois/client.py +518 -0
- ipwhois/py.typed +0 -0
- ipwhois_python-1.0.0.dist-info/METADATA +370 -0
- ipwhois_python-1.0.0.dist-info/RECORD +7 -0
- ipwhois_python-1.0.0.dist-info/WHEEL +4 -0
- ipwhois_python-1.0.0.dist-info/licenses/LICENSE +21 -0
ipwhois/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
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
|
ipwhois/client.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"""Python client for the ipwhois.io IP Geolocation API.
|
|
2
|
+
|
|
3
|
+
Quick start
|
|
4
|
+
-----------
|
|
5
|
+
# Free plan (no API key, ~1 request/second per client IP)
|
|
6
|
+
client = ipwhois.Client()
|
|
7
|
+
info = client.lookup("8.8.8.8")
|
|
8
|
+
|
|
9
|
+
# 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
|
+
|
|
13
|
+
# 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"])
|
|
15
|
+
|
|
16
|
+
# HTTPS is enabled by default. Pass ssl=False to fall back to HTTP.
|
|
17
|
+
|
|
18
|
+
Error handling
|
|
19
|
+
--------------
|
|
20
|
+
The library never raises. All errors -- invalid input, network failure,
|
|
21
|
+
API-level errors (bad IP, bad key, rate limit, ...) -- are returned in the
|
|
22
|
+
response dict with ``success`` set to ``False`` and a ``message``. Just check
|
|
23
|
+
``info["success"]`` after every call.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import socket
|
|
30
|
+
import urllib.error
|
|
31
|
+
import urllib.parse
|
|
32
|
+
import urllib.request
|
|
33
|
+
from typing import Any, Dict, Iterable, List, Optional, Union
|
|
34
|
+
|
|
35
|
+
__all__ = ["Client"]
|
|
36
|
+
|
|
37
|
+
# Public type alias: a single API response (lookup or whole-batch error).
|
|
38
|
+
Response = Dict[str, Any]
|
|
39
|
+
# Public type alias: a bulk response -- list on success, error dict otherwise.
|
|
40
|
+
BulkResponse = Union[List[Response], Response]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Client:
|
|
44
|
+
"""Client for the ipwhois.io IP Geolocation API.
|
|
45
|
+
|
|
46
|
+
The same class is used for both the Free and Paid plans -- the only
|
|
47
|
+
difference is whether you pass an API key. See module docstring for
|
|
48
|
+
a quick start.
|
|
49
|
+
|
|
50
|
+
The library never raises. Every failure -- invalid IP, bad API key, rate
|
|
51
|
+
limit, network outage, bad options -- comes back inside the response dict
|
|
52
|
+
with ``success`` set to ``False`` and a ``message``.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
#: Library version, used in the default User-Agent header.
|
|
56
|
+
VERSION: str = "1.0.0"
|
|
57
|
+
|
|
58
|
+
#: Free-plan endpoint host (used when no API key is provided).
|
|
59
|
+
HOST_FREE: str = "ipwho.is"
|
|
60
|
+
|
|
61
|
+
#: Paid-plan endpoint host (used when an API key is provided).
|
|
62
|
+
HOST_PAID: str = "ipwhois.pro"
|
|
63
|
+
|
|
64
|
+
#: Maximum number of IP addresses allowed in a single bulk request.
|
|
65
|
+
BULK_LIMIT: int = 100
|
|
66
|
+
|
|
67
|
+
#: Languages supported by the ``lang`` parameter.
|
|
68
|
+
SUPPORTED_LANGUAGES: tuple = (
|
|
69
|
+
"en",
|
|
70
|
+
"ru",
|
|
71
|
+
"de",
|
|
72
|
+
"es",
|
|
73
|
+
"pt-BR",
|
|
74
|
+
"fr",
|
|
75
|
+
"zh-CN",
|
|
76
|
+
"ja",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
#: Output formats supported by the ``output`` parameter.
|
|
80
|
+
SUPPORTED_OUTPUTS: tuple = ("json", "xml", "csv")
|
|
81
|
+
|
|
82
|
+
def __init__(self, api_key: Optional[str] = None, **options: Any) -> None:
|
|
83
|
+
"""Create a new client.
|
|
84
|
+
|
|
85
|
+
:param api_key: Your ipwhois.io API key. Omit for the free plan.
|
|
86
|
+
:param options: Optional defaults applied to every request. Recognised
|
|
87
|
+
keys: ``lang``, ``fields``, ``security``, ``rate``, ``output``,
|
|
88
|
+
``ssl``, ``timeout``, ``connect_timeout``, ``user_agent``.
|
|
89
|
+
"""
|
|
90
|
+
self._api_key: Optional[str] = api_key
|
|
91
|
+
self._user_agent: str = str(
|
|
92
|
+
options.pop("user_agent", f"ipwhois-python/{self.VERSION}")
|
|
93
|
+
)
|
|
94
|
+
self._timeout: int = _coerce_positive_int(options.pop("timeout", 10), 10)
|
|
95
|
+
self._connect_timeout: int = _coerce_positive_int(
|
|
96
|
+
options.pop("connect_timeout", 5), 5
|
|
97
|
+
)
|
|
98
|
+
self._ssl: bool = bool(options.pop("ssl", True))
|
|
99
|
+
|
|
100
|
+
# Anything left is a request-level default (lang, fields, ...).
|
|
101
|
+
self._defaults: Dict[str, Any] = options
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------ #
|
|
104
|
+
# Public API #
|
|
105
|
+
# ------------------------------------------------------------------ #
|
|
106
|
+
|
|
107
|
+
def lookup(self, ip: Optional[str] = None, **options: Any) -> Response:
|
|
108
|
+
"""Look up information for a single IP address.
|
|
109
|
+
|
|
110
|
+
Pass ``None`` (or call without arguments) to look up the caller's own
|
|
111
|
+
public IP, as documented at https://ipwhois.io/documentation.
|
|
112
|
+
|
|
113
|
+
The library never raises -- check ``result["success"]`` after every
|
|
114
|
+
call.
|
|
115
|
+
|
|
116
|
+
:param ip: IPv4 or IPv6 address. ``None`` (default) = current IP.
|
|
117
|
+
:param options: Per-call options: ``lang``, ``fields``,
|
|
118
|
+
``security`` (bool), ``rate`` (bool), ``output``.
|
|
119
|
+
:returns: Decoded JSON response. On any error (API, network, bad
|
|
120
|
+
input) the dict contains ``success`` set to ``False`` and a
|
|
121
|
+
``message``. The library never raises.
|
|
122
|
+
"""
|
|
123
|
+
error = self._validate_options(options)
|
|
124
|
+
if error is not None:
|
|
125
|
+
return error
|
|
126
|
+
|
|
127
|
+
path = "/" + urllib.parse.quote(ip, safe="") if ip is not None else "/"
|
|
128
|
+
url = self._build_url(path, options)
|
|
129
|
+
|
|
130
|
+
result = self._request(url)
|
|
131
|
+
# Single lookup always parses to a dict in practice -- but in the
|
|
132
|
+
# unlikely case the API returns a list, normalise to an error dict
|
|
133
|
+
# so the caller's `result["success"]` check stays valid.
|
|
134
|
+
if isinstance(result, list):
|
|
135
|
+
return {
|
|
136
|
+
"success": False,
|
|
137
|
+
"message": "Unexpected list response from single lookup endpoint.",
|
|
138
|
+
"error_type": "api",
|
|
139
|
+
}
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
def bulk_lookup(
|
|
143
|
+
self, ips: Iterable[str], **options: Any
|
|
144
|
+
) -> BulkResponse:
|
|
145
|
+
"""Look up information for multiple IP addresses in a single request.
|
|
146
|
+
|
|
147
|
+
Uses the GET / comma-separated form documented at
|
|
148
|
+
https://ipwhois.io/documentation/bulk -- up to 100 addresses per call.
|
|
149
|
+
Each address counts as one credit.
|
|
150
|
+
|
|
151
|
+
Available on the Business and Unlimited plans only.
|
|
152
|
+
|
|
153
|
+
Per-IP errors are returned inline with ``success`` set to ``False``
|
|
154
|
+
for the affected entry; the rest of the batch is still usable. If
|
|
155
|
+
the whole call fails, the response is a single error dict with
|
|
156
|
+
``success`` set to ``False`` instead of a list.
|
|
157
|
+
|
|
158
|
+
:param ips: Up to 100 IPv4/IPv6 addresses (mixable).
|
|
159
|
+
:param options: Per-call options (same keys as :meth:`lookup`).
|
|
160
|
+
:returns: List of per-IP results on success; a single error dict on
|
|
161
|
+
whole-batch failure. The library never raises.
|
|
162
|
+
"""
|
|
163
|
+
# Strings and bytes are iterable in Python -- without this guard,
|
|
164
|
+
# bulk_lookup("8.8.8.8") would lookup each character. Reject them
|
|
165
|
+
# explicitly with a helpful message.
|
|
166
|
+
if ips is None:
|
|
167
|
+
return {
|
|
168
|
+
"success": False,
|
|
169
|
+
"message": "Bulk lookup requires an iterable of IP addresses.",
|
|
170
|
+
"error_type": "invalid_argument",
|
|
171
|
+
}
|
|
172
|
+
if isinstance(ips, (str, bytes, bytearray)):
|
|
173
|
+
return {
|
|
174
|
+
"success": False,
|
|
175
|
+
"message": (
|
|
176
|
+
"Bulk lookup requires an iterable of IP strings, not a "
|
|
177
|
+
"single string. Use lookup() for a single IP."
|
|
178
|
+
),
|
|
179
|
+
"error_type": "invalid_argument",
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
ip_list = [str(ip) for ip in ips]
|
|
184
|
+
except TypeError:
|
|
185
|
+
return {
|
|
186
|
+
"success": False,
|
|
187
|
+
"message": "Bulk lookup requires an iterable of IP addresses.",
|
|
188
|
+
"error_type": "invalid_argument",
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if not ip_list:
|
|
192
|
+
return {
|
|
193
|
+
"success": False,
|
|
194
|
+
"message": "Bulk lookup requires at least one IP address.",
|
|
195
|
+
"error_type": "invalid_argument",
|
|
196
|
+
}
|
|
197
|
+
if len(ip_list) > self.BULK_LIMIT:
|
|
198
|
+
return {
|
|
199
|
+
"success": False,
|
|
200
|
+
"message": (
|
|
201
|
+
f"Bulk lookup accepts at most {self.BULK_LIMIT} IP "
|
|
202
|
+
f"addresses per call, got {len(ip_list)}."
|
|
203
|
+
),
|
|
204
|
+
"error_type": "invalid_argument",
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
error = self._validate_options(options)
|
|
208
|
+
if error is not None:
|
|
209
|
+
return error
|
|
210
|
+
|
|
211
|
+
# The API accepts addresses joined by commas -- the commas themselves
|
|
212
|
+
# must NOT be URL-encoded, otherwise the path is misinterpreted.
|
|
213
|
+
joined = ",".join(urllib.parse.quote(ip, safe="") for ip in ip_list)
|
|
214
|
+
url = self._build_url("/bulk/" + joined, options)
|
|
215
|
+
|
|
216
|
+
return self._request(url)
|
|
217
|
+
|
|
218
|
+
# -- Fluent setters ------------------------------------------------- #
|
|
219
|
+
|
|
220
|
+
def set_language(self, lang: str) -> "Client":
|
|
221
|
+
"""Set the default language used when none is supplied per call.
|
|
222
|
+
|
|
223
|
+
:param lang: One of :attr:`SUPPORTED_LANGUAGES`.
|
|
224
|
+
"""
|
|
225
|
+
self._defaults["lang"] = lang
|
|
226
|
+
return self
|
|
227
|
+
|
|
228
|
+
def set_fields(
|
|
229
|
+
self, fields: Union[str, Iterable[str], None]
|
|
230
|
+
) -> "Client":
|
|
231
|
+
"""Restrict every response to a fixed set of fields by default.
|
|
232
|
+
|
|
233
|
+
: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
|
+
"""
|
|
238
|
+
# Strings are iterable in Python, so list("country,city") would
|
|
239
|
+
# explode into individual characters. Keep strings as strings.
|
|
240
|
+
# `None` clears the default (consistent with "never raises": calling
|
|
241
|
+
# set_fields(None) shouldn't blow up). Anything that's not iterable
|
|
242
|
+
# at all is stringified rather than raising.
|
|
243
|
+
if fields is None:
|
|
244
|
+
self._defaults.pop("fields", None)
|
|
245
|
+
elif isinstance(fields, str):
|
|
246
|
+
self._defaults["fields"] = fields
|
|
247
|
+
else:
|
|
248
|
+
try:
|
|
249
|
+
self._defaults["fields"] = list(fields)
|
|
250
|
+
except TypeError:
|
|
251
|
+
self._defaults["fields"] = str(fields)
|
|
252
|
+
return self
|
|
253
|
+
|
|
254
|
+
def set_security(self, enabled: bool) -> "Client":
|
|
255
|
+
"""Enable or disable threat-detection data on every call by default."""
|
|
256
|
+
self._defaults["security"] = bool(enabled)
|
|
257
|
+
return self
|
|
258
|
+
|
|
259
|
+
def set_rate(self, enabled: bool) -> "Client":
|
|
260
|
+
"""Enable or disable the ``rate`` block in responses by default."""
|
|
261
|
+
self._defaults["rate"] = bool(enabled)
|
|
262
|
+
return self
|
|
263
|
+
|
|
264
|
+
def set_timeout(self, seconds: Any) -> "Client":
|
|
265
|
+
"""Set the per-request total timeout in seconds (default: 10).
|
|
266
|
+
|
|
267
|
+
Bad values (non-numeric, negative) silently fall back to the default,
|
|
268
|
+
in keeping with the library's "never raises" contract.
|
|
269
|
+
"""
|
|
270
|
+
self._timeout = _coerce_positive_int(seconds, self._timeout)
|
|
271
|
+
return self
|
|
272
|
+
|
|
273
|
+
def set_connect_timeout(self, seconds: Any) -> "Client":
|
|
274
|
+
"""Set the connection timeout in seconds (default: 5).
|
|
275
|
+
|
|
276
|
+
Note: Python's :mod:`urllib` exposes a single timeout that covers
|
|
277
|
+
both the connect and read phases. This value is stored for API
|
|
278
|
+
parity with the PHP client; the effective ceiling for the whole
|
|
279
|
+
request is :meth:`set_timeout`.
|
|
280
|
+
|
|
281
|
+
Bad values (non-numeric, negative) silently fall back to the
|
|
282
|
+
previous value, in keeping with the library's "never raises"
|
|
283
|
+
contract.
|
|
284
|
+
"""
|
|
285
|
+
self._connect_timeout = _coerce_positive_int(
|
|
286
|
+
seconds, self._connect_timeout
|
|
287
|
+
)
|
|
288
|
+
return self
|
|
289
|
+
|
|
290
|
+
def set_user_agent(self, user_agent: str) -> "Client":
|
|
291
|
+
"""Override the User-Agent header sent with every request."""
|
|
292
|
+
self._user_agent = str(user_agent)
|
|
293
|
+
return self
|
|
294
|
+
|
|
295
|
+
# ------------------------------------------------------------------ #
|
|
296
|
+
# Internals #
|
|
297
|
+
# ------------------------------------------------------------------ #
|
|
298
|
+
|
|
299
|
+
def _validate_options(self, options: Dict[str, Any]) -> Optional[Response]:
|
|
300
|
+
"""Validate per-call options.
|
|
301
|
+
|
|
302
|
+
:returns: An error dict on the first invalid option, or ``None`` if
|
|
303
|
+
everything looks OK.
|
|
304
|
+
"""
|
|
305
|
+
merged = {**self._defaults, **options}
|
|
306
|
+
|
|
307
|
+
lang = merged.get("lang")
|
|
308
|
+
if lang is not None and lang not in self.SUPPORTED_LANGUAGES:
|
|
309
|
+
return {
|
|
310
|
+
"success": False,
|
|
311
|
+
"message": (
|
|
312
|
+
f'Unsupported language "{lang}". Supported: '
|
|
313
|
+
f"{', '.join(self.SUPPORTED_LANGUAGES)}."
|
|
314
|
+
),
|
|
315
|
+
"error_type": "invalid_argument",
|
|
316
|
+
}
|
|
317
|
+
|
|
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
|
+
return None
|
|
330
|
+
|
|
331
|
+
def _build_url(self, path: str, options: Dict[str, Any]) -> str:
|
|
332
|
+
"""Build the full URL for a given path + options."""
|
|
333
|
+
host = self.HOST_PAID if self._api_key is not None else self.HOST_FREE
|
|
334
|
+
|
|
335
|
+
# Per-call options win over defaults.
|
|
336
|
+
merged = {**self._defaults, **options}
|
|
337
|
+
|
|
338
|
+
query: List[tuple] = []
|
|
339
|
+
|
|
340
|
+
if self._api_key is not None:
|
|
341
|
+
query.append(("key", self._api_key))
|
|
342
|
+
|
|
343
|
+
if "lang" in merged and merged["lang"] is not None:
|
|
344
|
+
query.append(("lang", str(merged["lang"])))
|
|
345
|
+
|
|
346
|
+
if "output" in merged and merged["output"] is not None:
|
|
347
|
+
query.append(("output", str(merged["output"])))
|
|
348
|
+
|
|
349
|
+
if "fields" in merged and merged["fields"] is not None:
|
|
350
|
+
fields = merged["fields"]
|
|
351
|
+
if isinstance(fields, (list, tuple)):
|
|
352
|
+
fields = ",".join(str(f) for f in fields)
|
|
353
|
+
query.append(("fields", str(fields)))
|
|
354
|
+
|
|
355
|
+
if merged.get("security"):
|
|
356
|
+
query.append(("security", "1"))
|
|
357
|
+
|
|
358
|
+
if merged.get("rate"):
|
|
359
|
+
query.append(("rate", "1"))
|
|
360
|
+
|
|
361
|
+
scheme = "https" if self._ssl else "http"
|
|
362
|
+
url = f"{scheme}://{host}{path}"
|
|
363
|
+
if query:
|
|
364
|
+
url += "?" + urllib.parse.urlencode(query)
|
|
365
|
+
|
|
366
|
+
return url
|
|
367
|
+
|
|
368
|
+
def _request(self, url: str) -> Union[Response, List[Any]]:
|
|
369
|
+
"""Perform a GET request and return the decoded JSON body.
|
|
370
|
+
|
|
371
|
+
On any error returns an error dict with ``success`` set to ``False``;
|
|
372
|
+
on bulk success returns the parsed JSON list directly.
|
|
373
|
+
"""
|
|
374
|
+
req = urllib.request.Request(
|
|
375
|
+
url,
|
|
376
|
+
headers={
|
|
377
|
+
"Accept": "application/json",
|
|
378
|
+
"User-Agent": self._user_agent,
|
|
379
|
+
},
|
|
380
|
+
method="GET",
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
status: int
|
|
384
|
+
body: str
|
|
385
|
+
headers: Dict[str, str]
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
389
|
+
status = int(getattr(resp, "status", resp.getcode()))
|
|
390
|
+
headers = {k.lower(): v for k, v in resp.headers.items()}
|
|
391
|
+
raw = resp.read()
|
|
392
|
+
body = raw.decode("utf-8", errors="replace")
|
|
393
|
+
except urllib.error.HTTPError as e:
|
|
394
|
+
# 4xx / 5xx -- read the body and normalise into an error dict
|
|
395
|
+
# below, the same way a 2xx response with success=false is.
|
|
396
|
+
status = int(e.code)
|
|
397
|
+
try:
|
|
398
|
+
headers = {
|
|
399
|
+
k.lower(): v for k, v in (e.headers or {}).items()
|
|
400
|
+
}
|
|
401
|
+
except Exception:
|
|
402
|
+
headers = {}
|
|
403
|
+
try:
|
|
404
|
+
body = e.read().decode("utf-8", errors="replace")
|
|
405
|
+
except Exception:
|
|
406
|
+
body = ""
|
|
407
|
+
except urllib.error.URLError as e:
|
|
408
|
+
return {
|
|
409
|
+
"success": False,
|
|
410
|
+
"message": f"Network error: {e.reason}",
|
|
411
|
+
"error_type": "network",
|
|
412
|
+
}
|
|
413
|
+
except socket.timeout:
|
|
414
|
+
return {
|
|
415
|
+
"success": False,
|
|
416
|
+
"message": (
|
|
417
|
+
f"Network error: timed out after {self._timeout}s"
|
|
418
|
+
),
|
|
419
|
+
"error_type": "network",
|
|
420
|
+
}
|
|
421
|
+
except (TimeoutError, OSError) as e:
|
|
422
|
+
return {
|
|
423
|
+
"success": False,
|
|
424
|
+
"message": f"Network error: {e}",
|
|
425
|
+
"error_type": "network",
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
decoded: Any = None
|
|
429
|
+
if body:
|
|
430
|
+
try:
|
|
431
|
+
decoded = json.loads(body)
|
|
432
|
+
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.
|
|
443
|
+
snippet = " ".join(body.split())
|
|
444
|
+
if len(snippet) > 200:
|
|
445
|
+
snippet = snippet[:200] + "\u2026"
|
|
446
|
+
return {
|
|
447
|
+
"success": False,
|
|
448
|
+
"message": (
|
|
449
|
+
f"HTTP {status} returned by ipwhois API: {snippet}"
|
|
450
|
+
),
|
|
451
|
+
"http_status": status,
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if not isinstance(decoded, (dict, list)):
|
|
455
|
+
decoded = {} if decoded is None else {"value": decoded}
|
|
456
|
+
|
|
457
|
+
# For HTTP errors, normalise into a `success: False` dict so the
|
|
458
|
+
# caller doesn't have to inspect HTTP status separately.
|
|
459
|
+
if status >= 400:
|
|
460
|
+
if isinstance(decoded, dict):
|
|
461
|
+
if decoded.get("success") is False:
|
|
462
|
+
# The API already shaped the error correctly -- just enrich it.
|
|
463
|
+
decoded["http_status"] = status
|
|
464
|
+
else:
|
|
465
|
+
message = str(
|
|
466
|
+
decoded.get(
|
|
467
|
+
"message", f"HTTP {status} returned by ipwhois API"
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
decoded = {
|
|
471
|
+
"success": False,
|
|
472
|
+
"message": message,
|
|
473
|
+
"http_status": status,
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if status == 429 and "retry-after" in headers:
|
|
477
|
+
try:
|
|
478
|
+
decoded["retry_after"] = int(headers["retry-after"])
|
|
479
|
+
except (TypeError, ValueError):
|
|
480
|
+
pass
|
|
481
|
+
else:
|
|
482
|
+
# List response with error status -- wrap as an error dict.
|
|
483
|
+
decoded = {
|
|
484
|
+
"success": False,
|
|
485
|
+
"message": f"HTTP {status} returned by ipwhois API",
|
|
486
|
+
"http_status": status,
|
|
487
|
+
}
|
|
488
|
+
if status == 429 and "retry-after" in headers:
|
|
489
|
+
try:
|
|
490
|
+
decoded["retry_after"] = int(headers["retry-after"])
|
|
491
|
+
except (TypeError, ValueError):
|
|
492
|
+
pass
|
|
493
|
+
|
|
494
|
+
# For HTTP 2xx with `success: false` (e.g. "Invalid IP address",
|
|
495
|
+
# "Reserved range") we just pass the body through -- it is already
|
|
496
|
+
# shaped correctly by the API.
|
|
497
|
+
|
|
498
|
+
return decoded
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _coerce_positive_int(value: Any, default: int) -> int:
|
|
502
|
+
"""Coerce ``value`` to a positive int, falling back to ``default``.
|
|
503
|
+
|
|
504
|
+
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
|
|
506
|
+
the whole client. ``None``, non-numeric strings, ``True``/``False``
|
|
507
|
+
edge cases, negative numbers and zero all map to ``default``.
|
|
508
|
+
"""
|
|
509
|
+
if value is None or isinstance(value, bool):
|
|
510
|
+
return default
|
|
511
|
+
try:
|
|
512
|
+
coerced = int(value)
|
|
513
|
+
except (TypeError, ValueError):
|
|
514
|
+
return default
|
|
515
|
+
if coerced <= 0:
|
|
516
|
+
return default
|
|
517
|
+
return coerced
|
|
518
|
+
|
ipwhois/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ipwhois-python
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python client for the ipwhois.io IP Geolocation API. Simple, dependency-free, supports single and bulk IP lookups.
|
|
5
|
+
Project-URL: Homepage, https://ipwhois.io
|
|
6
|
+
Project-URL: Documentation, https://ipwhois.io/documentation
|
|
7
|
+
Project-URL: Source, https://github.com/IPWhois/ipwhois-python
|
|
8
|
+
Project-URL: Issues, https://github.com/IPWhois/ipwhois-python/issues
|
|
9
|
+
Author: ipwhois.io
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 ipwhois.io
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: geoip,geolocation,ip-api,ip-geolocation,ip-locator,ip-lookup,ipwhois
|
|
33
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Operating System :: OS Independent
|
|
37
|
+
Classifier: Programming Language :: Python
|
|
38
|
+
Classifier: Programming Language :: Python :: 3
|
|
39
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
45
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
46
|
+
Classifier: Topic :: Internet
|
|
47
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
48
|
+
Classifier: Typing :: Typed
|
|
49
|
+
Requires-Python: >=3.8
|
|
50
|
+
Provides-Extra: dev
|
|
51
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
52
|
+
Description-Content-Type: text/markdown
|
|
53
|
+
|
|
54
|
+
# ipwhois-python
|
|
55
|
+
|
|
56
|
+
[](https://pypi.org/project/ipwhois-python/)
|
|
57
|
+
[](https://pypi.org/project/ipwhois-python/)
|
|
58
|
+
[](LICENSE)
|
|
59
|
+
|
|
60
|
+
Official, dependency-free Python client for the [ipwhois.io](https://ipwhois.io) IP Geolocation API.
|
|
61
|
+
|
|
62
|
+
- ✅ Single and bulk IP lookups (IPv4 and IPv6)
|
|
63
|
+
- ✅ Works with both the **Free** and **Paid** plans
|
|
64
|
+
- ✅ HTTPS by default
|
|
65
|
+
- ✅ Localisation, field selection, threat detection, rate info
|
|
66
|
+
- ✅ Never raises — all errors returned as `success: False` dicts
|
|
67
|
+
- ✅ No external dependencies — only the Python standard library
|
|
68
|
+
- ✅ Python 3.8+
|
|
69
|
+
|
|
70
|
+
## Installation
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install ipwhois-python
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Free vs Paid plan
|
|
77
|
+
|
|
78
|
+
The same `Client` class is used for both plans. The only difference is whether
|
|
79
|
+
you pass an API key:
|
|
80
|
+
|
|
81
|
+
- **Free plan** — create the client **without arguments**. No API key, no
|
|
82
|
+
signup required. Suitable for low-traffic and non-commercial use.
|
|
83
|
+
- **Paid plan** — create the client **with your API key** from
|
|
84
|
+
<https://ipwhois.io>. Higher limits, plus access to bulk lookups and
|
|
85
|
+
threat-detection data.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from ipwhois import Client
|
|
89
|
+
|
|
90
|
+
free = Client() # Free plan — no API key
|
|
91
|
+
paid = Client("YOUR_API_KEY") # Paid plan — with API key
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Everything else (`lookup()`, options, error handling) is identical.
|
|
95
|
+
|
|
96
|
+
## Quick start — Free plan (no API key)
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from ipwhois import Client
|
|
100
|
+
|
|
101
|
+
client = Client() # no API key
|
|
102
|
+
|
|
103
|
+
info = client.lookup("8.8.8.8")
|
|
104
|
+
|
|
105
|
+
print(info["country"], info["flag"]["emoji"])
|
|
106
|
+
# → United States 🇺🇸
|
|
107
|
+
|
|
108
|
+
print(f"{info['city']}, {info['region']}")
|
|
109
|
+
# → Mountain View, California
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Quick start — Paid plan (with API key)
|
|
113
|
+
|
|
114
|
+
Get an API key at <https://ipwhois.io> and pass it to the constructor:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from ipwhois import Client
|
|
118
|
+
|
|
119
|
+
client = Client("YOUR_API_KEY") # with API key
|
|
120
|
+
|
|
121
|
+
info = client.lookup("8.8.8.8")
|
|
122
|
+
|
|
123
|
+
print(info["country"], info["flag"]["emoji"])
|
|
124
|
+
# → United States 🇺🇸
|
|
125
|
+
|
|
126
|
+
print(f"{info['city']}, {info['region']}")
|
|
127
|
+
# → Mountain View, California
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
> ℹ️ Pass nothing to look up your own public IP: `client.lookup()` — works
|
|
131
|
+
> on both plans.
|
|
132
|
+
|
|
133
|
+
## Lookup options
|
|
134
|
+
|
|
135
|
+
Every option below can be passed per call as a keyword argument, or set once
|
|
136
|
+
on the client as a default.
|
|
137
|
+
|
|
138
|
+
| Option | Type | Plans needed | Description |
|
|
139
|
+
| ------------ | ------- | -------------------- | ---------------------------------------------------------------------- |
|
|
140
|
+
| `lang` | str | Free + Paid | One of: `en`, `ru`, `de`, `es`, `pt-BR`, `fr`, `zh-CN`, `ja` |
|
|
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
|
+
| `rate` | bool | Basic and above | Include the `rate` block (`limit`, `remaining`) |
|
|
144
|
+
| `security` | bool | Business and above | Include the `security` block (proxy/vpn/tor/hosting) |
|
|
145
|
+
|
|
146
|
+
### Setting defaults once
|
|
147
|
+
|
|
148
|
+
If you make many calls with the same options, set them once and forget:
|
|
149
|
+
|
|
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
|
+
```
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
# Paid plan
|
|
165
|
+
client = (
|
|
166
|
+
Client("YOUR_API_KEY")
|
|
167
|
+
.set_language("en")
|
|
168
|
+
.set_fields(["country", "city", "flag.emoji"])
|
|
169
|
+
.set_timeout(8)
|
|
170
|
+
)
|
|
171
|
+
|
|
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
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
> ℹ️ Paid plans additionally support `set_security(True)` (Business+) and
|
|
177
|
+
> `set_rate(True)` (Basic+). See the table above for what's available where.
|
|
178
|
+
|
|
179
|
+
## HTTPS encryption
|
|
180
|
+
|
|
181
|
+
By default, all requests are sent over HTTPS. If you need to disable it (for
|
|
182
|
+
example, in environments without an up-to-date CA bundle), pass `ssl=False`
|
|
183
|
+
to the constructor:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from ipwhois import Client
|
|
187
|
+
|
|
188
|
+
# Free plan
|
|
189
|
+
client = Client(ssl=False)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from ipwhois import Client
|
|
194
|
+
|
|
195
|
+
# Paid plan
|
|
196
|
+
client = Client("YOUR_API_KEY", ssl=False)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
> ℹ️ HTTPS is strongly recommended for production traffic — your API key is
|
|
200
|
+
> sent in the query string and would otherwise travel in clear text.
|
|
201
|
+
|
|
202
|
+
## Bulk lookup (Paid plan only)
|
|
203
|
+
|
|
204
|
+
The bulk endpoint sends **up to 100 IPs** in a single GET request. Each
|
|
205
|
+
address counts as one credit. Available on the **Business** and **Unlimited**
|
|
206
|
+
plans.
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
from ipwhois import Client
|
|
210
|
+
|
|
211
|
+
client = Client("YOUR_API_KEY")
|
|
212
|
+
|
|
213
|
+
results = client.bulk_lookup([
|
|
214
|
+
"8.8.8.8",
|
|
215
|
+
"1.1.1.1",
|
|
216
|
+
"208.67.222.222",
|
|
217
|
+
"2c0f:fb50:4003::", # IPv6 is fine — mix freely
|
|
218
|
+
])
|
|
219
|
+
|
|
220
|
+
for row in results:
|
|
221
|
+
if row.get("success") is False:
|
|
222
|
+
# Per-IP errors (e.g. "Invalid IP address") are returned inline,
|
|
223
|
+
# they do NOT raise — the rest of the batch is still usable.
|
|
224
|
+
print(f"skip {row['ip']}: {row['message']}")
|
|
225
|
+
continue
|
|
226
|
+
print(f"{row['ip']} → {row['country']}")
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
> ℹ️ Bulk requires an API key. Calling `bulk_lookup()` without one will fail
|
|
230
|
+
> at the API level.
|
|
231
|
+
|
|
232
|
+
## Error handling
|
|
233
|
+
|
|
234
|
+
**The library never raises.** Every failure — invalid IP, bad API key, rate
|
|
235
|
+
limit, network outage, bad options — comes back inside the response dict
|
|
236
|
+
with `success` set to `False` and a `message`. Just check
|
|
237
|
+
`info["success"]` after every call:
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
info = client.lookup("8.8.8.8")
|
|
241
|
+
|
|
242
|
+
if not info["success"]:
|
|
243
|
+
print(f"Lookup failed: {info['message']}")
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
print(info["country"])
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
This means an outage of the ipwhois.io API (or of your machine's DNS,
|
|
250
|
+
connection, etc.) will never surface as an unhandled exception in your
|
|
251
|
+
application — you decide how to react.
|
|
252
|
+
|
|
253
|
+
### Error response fields
|
|
254
|
+
|
|
255
|
+
Every error response contains `success: False` and a `message`. Some errors
|
|
256
|
+
include extra fields you can branch on:
|
|
257
|
+
|
|
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 |
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
import time
|
|
266
|
+
|
|
267
|
+
info = client.lookup("8.8.8.8")
|
|
268
|
+
|
|
269
|
+
if not info["success"]:
|
|
270
|
+
if info.get("http_status") == 429:
|
|
271
|
+
time.sleep(info.get("retry_after", 60))
|
|
272
|
+
# ...retry
|
|
273
|
+
|
|
274
|
+
if info.get("error_type") == "network":
|
|
275
|
+
# DNS failure, connection refused, timeout, ...
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
print(f"Error: {info['message']}")
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## Response shape
|
|
282
|
+
|
|
283
|
+
A successful response includes (depending on your plan and selected options):
|
|
284
|
+
|
|
285
|
+
```jsonc
|
|
286
|
+
{
|
|
287
|
+
"ip": "8.8.4.4",
|
|
288
|
+
"success": true,
|
|
289
|
+
"type": "IPv4",
|
|
290
|
+
"continent": "North America",
|
|
291
|
+
"continent_code": "NA",
|
|
292
|
+
"country": "United States",
|
|
293
|
+
"country_code": "US",
|
|
294
|
+
"region": "California",
|
|
295
|
+
"region_code": "CA",
|
|
296
|
+
"city": "Mountain View",
|
|
297
|
+
"latitude": 37.3860517,
|
|
298
|
+
"longitude": -122.0838511,
|
|
299
|
+
"is_eu": false,
|
|
300
|
+
"postal": "94039",
|
|
301
|
+
"calling_code": "1",
|
|
302
|
+
"capital": "Washington D.C.",
|
|
303
|
+
"borders": "CA,MX",
|
|
304
|
+
"flag": {
|
|
305
|
+
"img": "https://cdn.ipwhois.io/flags/us.svg",
|
|
306
|
+
"emoji": "🇺🇸",
|
|
307
|
+
"emoji_unicode": "U+1F1FA U+1F1F8"
|
|
308
|
+
},
|
|
309
|
+
"connection": {
|
|
310
|
+
"asn": 15169,
|
|
311
|
+
"org": "Google LLC",
|
|
312
|
+
"isp": "Google LLC",
|
|
313
|
+
"domain": "google.com"
|
|
314
|
+
},
|
|
315
|
+
"timezone": {
|
|
316
|
+
"id": "America/Los_Angeles",
|
|
317
|
+
"abbr": "PDT",
|
|
318
|
+
"is_dst": true,
|
|
319
|
+
"offset": -25200,
|
|
320
|
+
"utc": "-07:00",
|
|
321
|
+
"current_time": "2026-05-08T14:31:48-07:00"
|
|
322
|
+
},
|
|
323
|
+
"currency": {
|
|
324
|
+
"name": "US Dollar",
|
|
325
|
+
"code": "USD",
|
|
326
|
+
"symbol": "$",
|
|
327
|
+
"plural": "US dollars",
|
|
328
|
+
"exchange_rate": 1
|
|
329
|
+
},
|
|
330
|
+
"security": {
|
|
331
|
+
"anonymous": false,
|
|
332
|
+
"proxy": false,
|
|
333
|
+
"vpn": false,
|
|
334
|
+
"tor": false,
|
|
335
|
+
"hosting": false
|
|
336
|
+
},
|
|
337
|
+
"rate": {
|
|
338
|
+
"limit": 250000,
|
|
339
|
+
"remaining": 50155
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
For the full field reference, see the [official documentation](https://ipwhois.io/documentation).
|
|
345
|
+
|
|
346
|
+
An **error** response looks like:
|
|
347
|
+
|
|
348
|
+
```jsonc
|
|
349
|
+
{
|
|
350
|
+
"success": false,
|
|
351
|
+
"message": "Invalid IP address",
|
|
352
|
+
"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'
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
## Requirements
|
|
359
|
+
|
|
360
|
+
- Python **3.8** or newer
|
|
361
|
+
- No third-party dependencies — only the standard library (`urllib`, `json`)
|
|
362
|
+
|
|
363
|
+
## Contributing
|
|
364
|
+
|
|
365
|
+
Issues and pull requests are welcome on
|
|
366
|
+
[GitHub](https://github.com/IPWhois/ipwhois-python).
|
|
367
|
+
|
|
368
|
+
## License
|
|
369
|
+
|
|
370
|
+
[MIT](LICENSE) © ipwhois.io
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
ipwhois/__init__.py,sha256=ZkvjwG4qYOnZ8djE9hALcn9T5Dha9Xtd492MiI44ljg,334
|
|
2
|
+
ipwhois/client.py,sha256=BZlW_5IWmouW56sb2P92k-3hvBRxkuyCGO3XiDX7RZ4,19817
|
|
3
|
+
ipwhois/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
ipwhois_python-1.0.0.dist-info/METADATA,sha256=g4FYt16U69-cbb6KJSUrW6F0YOFHWDKUb7-u-tF9Xcc,11950
|
|
5
|
+
ipwhois_python-1.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
6
|
+
ipwhois_python-1.0.0.dist-info/licenses/LICENSE,sha256=zgvmP294VITHu3lcEF0NYGFlRwktZ1Z9NaIC-88Jj-Q,1067
|
|
7
|
+
ipwhois_python-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ipwhois.io
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|