Skip to content

Commit ada9e28

Browse files
committed
Add support for Plus bundle
1 parent cda39bb commit ada9e28

File tree

6 files changed

+1108
-0
lines changed

6 files changed

+1108
-0
lines changed

ipinfo/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from .handler_async import AsyncHandler
55
from .handler_core import HandlerCore
66
from .handler_core_async import AsyncHandlerCore
7+
from .handler_plus import HandlerPlus
8+
from .handler_plus_async import AsyncHandlerPlus
79

810

911
def getHandler(access_token=None, **kwargs):
@@ -21,6 +23,11 @@ def getHandlerCore(access_token=None, **kwargs):
2123
return HandlerCore(access_token, **kwargs)
2224

2325

26+
def getHandlerPlus(access_token=None, **kwargs):
27+
"""Create and return HandlerPlus object."""
28+
return HandlerPlus(access_token, **kwargs)
29+
30+
2431
def getHandlerAsync(access_token=None, **kwargs):
2532
"""Create an return an asynchronous Handler object."""
2633
return AsyncHandler(access_token, **kwargs)
@@ -34,3 +41,8 @@ def getHandlerAsyncLite(access_token=None, **kwargs):
3441
def getHandlerAsyncCore(access_token=None, **kwargs):
3542
"""Create and return asynchronous HandlerCore object."""
3643
return AsyncHandlerCore(access_token, **kwargs)
44+
45+
46+
def getHandlerAsyncPlus(access_token=None, **kwargs):
47+
"""Create and return asynchronous HandlerPlus object."""
48+
return AsyncHandlerPlus(access_token, **kwargs)

ipinfo/handler_plus.py

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
"""
2+
Plus API client handler for fetching data from the IPinfo Plus service.
3+
"""
4+
5+
import time
6+
from ipaddress import IPv4Address, IPv6Address
7+
8+
import requests
9+
10+
from . import handler_utils
11+
from .bogon import is_bogon
12+
from .cache.default import DefaultCache
13+
from .data import (
14+
continents,
15+
countries,
16+
countries_currencies,
17+
countries_flags,
18+
eu_countries,
19+
)
20+
from .details import Details
21+
from .error import APIError
22+
from .exceptions import RequestQuotaExceededError, TimeoutExceededError
23+
from .handler_utils import (
24+
BATCH_MAX_SIZE,
25+
BATCH_REQ_TIMEOUT_DEFAULT,
26+
CACHE_MAXSIZE,
27+
CACHE_TTL,
28+
PLUS_API_URL,
29+
REQUEST_TIMEOUT_DEFAULT,
30+
cache_key,
31+
)
32+
33+
34+
class HandlerPlus:
35+
"""
36+
Allows client to request data for specified IP address using the Plus API.
37+
Plus API provides enhanced data including mobile carrier info and privacy detection.
38+
Instantiates and maintains access to cache.
39+
"""
40+
41+
def __init__(self, access_token=None, **kwargs):
42+
"""
43+
Initialize the HandlerPlus object with country name list and the
44+
cache initialized.
45+
"""
46+
self.access_token = access_token
47+
48+
# load countries file
49+
self.countries = kwargs.get("countries") or countries
50+
51+
# load eu countries file
52+
self.eu_countries = kwargs.get("eu_countries") or eu_countries
53+
54+
# load countries flags file
55+
self.countries_flags = kwargs.get("countries_flags") or countries_flags
56+
57+
# load countries currency file
58+
self.countries_currencies = (
59+
kwargs.get("countries_currencies") or countries_currencies
60+
)
61+
62+
# load continent file
63+
self.continents = kwargs.get("continent") or continents
64+
65+
# setup req opts
66+
self.request_options = kwargs.get("request_options", {})
67+
if "timeout" not in self.request_options:
68+
self.request_options["timeout"] = REQUEST_TIMEOUT_DEFAULT
69+
70+
# setup cache
71+
if "cache" in kwargs:
72+
self.cache = kwargs["cache"]
73+
else:
74+
cache_options = kwargs.get("cache_options", {})
75+
if "maxsize" not in cache_options:
76+
cache_options["maxsize"] = CACHE_MAXSIZE
77+
if "ttl" not in cache_options:
78+
cache_options["ttl"] = CACHE_TTL
79+
self.cache = DefaultCache(**cache_options)
80+
81+
# setup custom headers
82+
self.headers = kwargs.get("headers", None)
83+
84+
def getDetails(self, ip_address=None, timeout=None):
85+
"""
86+
Get Plus details for specified IP address as a Details object.
87+
88+
If `timeout` is not `None`, it will override the client-level timeout
89+
just for this operation.
90+
"""
91+
# If the supplied IP address uses the objects defined in the built-in
92+
# module ipaddress extract the appropriate string notation before
93+
# formatting the URL.
94+
if isinstance(ip_address, IPv4Address) or isinstance(ip_address, IPv6Address):
95+
ip_address = ip_address.exploded
96+
97+
# check if bogon.
98+
if ip_address and is_bogon(ip_address):
99+
details = {}
100+
details["ip"] = ip_address
101+
details["bogon"] = True
102+
return Details(details)
103+
104+
# check cache first.
105+
try:
106+
cached_data = self.cache[cache_key(ip_address)]
107+
return Details(cached_data)
108+
except KeyError:
109+
pass
110+
111+
# prepare req http opts
112+
req_opts = {**self.request_options}
113+
if timeout is not None:
114+
req_opts["timeout"] = timeout
115+
116+
# Build URL
117+
url = PLUS_API_URL
118+
if ip_address:
119+
url += "/" + ip_address
120+
121+
headers = handler_utils.get_headers(self.access_token, self.headers)
122+
response = requests.get(url, headers=headers, **req_opts)
123+
124+
if response.status_code == 429:
125+
raise RequestQuotaExceededError()
126+
if response.status_code >= 400:
127+
error_code = response.status_code
128+
content_type = response.headers.get("Content-Type")
129+
if content_type == "application/json":
130+
error_response = response.json()
131+
else:
132+
error_response = {"error": response.text}
133+
raise APIError(error_code, error_response)
134+
135+
details = response.json()
136+
137+
# Format and cache
138+
self._format_plus_details(details)
139+
self.cache[cache_key(ip_address)] = details
140+
141+
return Details(details)
142+
143+
def _format_plus_details(self, details):
144+
"""
145+
Format Plus response details.
146+
Plus has nested geo and as objects that need special formatting.
147+
"""
148+
# Format geo object if present
149+
if "geo" in details and details["geo"]:
150+
geo = details["geo"]
151+
if "country_code" in geo:
152+
country_code = geo["country_code"]
153+
geo["country_name"] = self.countries.get(country_code)
154+
geo["isEU"] = country_code in self.eu_countries
155+
geo["country_flag"] = self.countries_flags.get(country_code)
156+
geo["country_currency"] = self.countries_currencies.get(country_code)
157+
geo["continent"] = self.continents.get(country_code)
158+
geo["country_flag_url"] = (
159+
f"{handler_utils.COUNTRY_FLAGS_URL}{country_code}.svg"
160+
)
161+
162+
# Top-level country_code might also exist in some responses
163+
if "country_code" in details:
164+
country_code = details["country_code"]
165+
details["country_name"] = self.countries.get(country_code)
166+
details["isEU"] = country_code in self.eu_countries
167+
details["country_flag"] = self.countries_flags.get(country_code)
168+
details["country_currency"] = self.countries_currencies.get(country_code)
169+
details["continent"] = self.continents.get(country_code)
170+
details["country_flag_url"] = (
171+
f"{handler_utils.COUNTRY_FLAGS_URL}{country_code}.svg"
172+
)
173+
174+
def getBatchDetails(
175+
self,
176+
ip_addresses,
177+
batch_size=None,
178+
timeout_per_batch=BATCH_REQ_TIMEOUT_DEFAULT,
179+
timeout_total=None,
180+
raise_on_fail=True,
181+
):
182+
"""
183+
Get Plus details for a batch of IP addresses at once.
184+
185+
There is no specified limit to the number of IPs this function can
186+
accept; it can handle as much as the user can fit in RAM (along with
187+
all of the response data, which is at least a magnitude larger than the
188+
input list).
189+
190+
The input list is broken up into batches to abide by API requirements.
191+
The batch size can be adjusted with `batch_size` but is clipped to
192+
`BATCH_MAX_SIZE`.
193+
Defaults to `BATCH_MAX_SIZE`.
194+
195+
For each batch, `timeout_per_batch` indicates the maximum seconds to
196+
spend waiting for the HTTP request to complete. If any batch fails with
197+
this timeout, the whole operation fails.
198+
Defaults to `BATCH_REQ_TIMEOUT_DEFAULT` seconds.
199+
200+
`timeout_total` is a seconds-denominated hard-timeout for the time
201+
spent in HTTP operations; regardless of whether all batches have
202+
succeeded so far, if `timeout_total` is reached, the whole operation
203+
will fail by raising `TimeoutExceededError`.
204+
Defaults to being turned off.
205+
206+
`raise_on_fail`, if turned off, will return any result retrieved so far
207+
rather than raise an exception when errors occur, including timeout and
208+
quota errors.
209+
Defaults to on.
210+
"""
211+
if batch_size == None:
212+
batch_size = BATCH_MAX_SIZE
213+
214+
result = {}
215+
lookup_addresses = []
216+
217+
# pre-populate with anything we've got in the cache, and keep around
218+
# the IPs not in the cache.
219+
for ip_address in ip_addresses:
220+
# if the supplied IP address uses the objects defined in the
221+
# built-in module ipaddress extract the appropriate string notation
222+
# before formatting the URL.
223+
if isinstance(ip_address, IPv4Address) or isinstance(
224+
ip_address, IPv6Address
225+
):
226+
ip_address = ip_address.exploded
227+
228+
if ip_address and is_bogon(ip_address):
229+
details = {}
230+
details["ip"] = ip_address
231+
details["bogon"] = True
232+
result[ip_address] = Details(details)
233+
else:
234+
try:
235+
cached_data = self.cache[cache_key(ip_address)]
236+
result[ip_address] = Details(cached_data)
237+
except KeyError:
238+
lookup_addresses.append(ip_address)
239+
240+
# all in cache - return early.
241+
if len(lookup_addresses) == 0:
242+
return result
243+
244+
# do start timer if necessary
245+
if timeout_total is not None:
246+
start_time = time.time()
247+
248+
# prepare req http options
249+
req_opts = {**self.request_options, "timeout": timeout_per_batch}
250+
251+
# loop over batch chunks and do lookup for each.
252+
url = "https://api.ipinfo.io/batch"
253+
headers = handler_utils.get_headers(self.access_token, self.headers)
254+
headers["content-type"] = "application/json"
255+
256+
for i in range(0, len(lookup_addresses), batch_size):
257+
# quit if total timeout is reached.
258+
if timeout_total is not None and time.time() - start_time > timeout_total:
259+
return handler_utils.return_or_fail(
260+
raise_on_fail, TimeoutExceededError(), result
261+
)
262+
263+
chunk = lookup_addresses[i : i + batch_size]
264+
265+
# lookup
266+
try:
267+
response = requests.post(url, json=chunk, headers=headers, **req_opts)
268+
except Exception as e:
269+
return handler_utils.return_or_fail(raise_on_fail, e, result)
270+
271+
# fail on bad status codes
272+
try:
273+
if response.status_code == 429:
274+
raise RequestQuotaExceededError()
275+
response.raise_for_status()
276+
except Exception as e:
277+
return handler_utils.return_or_fail(raise_on_fail, e, result)
278+
279+
# Process batch response
280+
json_response = response.json()
281+
282+
for ip_address, data in json_response.items():
283+
# Cache and format the data
284+
if isinstance(data, dict) and not data.get("bogon"):
285+
self._format_plus_details(data)
286+
self.cache[cache_key(ip_address)] = data
287+
result[ip_address] = Details(data)
288+
289+
return result

0 commit comments

Comments
 (0)