Skip to content

Commit bfc81ed

Browse files
committed
Add support for Core bundle
1 parent 98f670a commit bfc81ed

File tree

6 files changed

+1074
-0
lines changed

6 files changed

+1074
-0
lines changed

ipinfo/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from .handler_lite_async import AsyncHandlerLite
33
from .handler import Handler
44
from .handler_async import AsyncHandler
5+
from .handler_core import HandlerCore
6+
from .handler_core_async import AsyncHandlerCore
57

68

79
def getHandler(access_token=None, **kwargs):
@@ -14,6 +16,11 @@ def getHandlerLite(access_token=None, **kwargs):
1416
return HandlerLite(access_token, **kwargs)
1517

1618

19+
def getHandlerCore(access_token=None, **kwargs):
20+
"""Create and return HandlerCore object."""
21+
return HandlerCore(access_token, **kwargs)
22+
23+
1724
def getHandlerAsync(access_token=None, **kwargs):
1825
"""Create an return an asynchronous Handler object."""
1926
return AsyncHandler(access_token, **kwargs)
@@ -22,3 +29,8 @@ def getHandlerAsync(access_token=None, **kwargs):
2229
def getHandlerAsyncLite(access_token=None, **kwargs):
2330
"""Create and return asynchronous HandlerLite object."""
2431
return AsyncHandlerLite(access_token, **kwargs)
32+
33+
34+
def getHandlerAsyncCore(access_token=None, **kwargs):
35+
"""Create and return asynchronous HandlerCore object."""
36+
return AsyncHandlerCore(access_token, **kwargs)

ipinfo/handler_core.py

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
"""
2+
Core API client handler for fetching data from the IPinfo Core 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+
CORE_API_URL,
29+
REQUEST_TIMEOUT_DEFAULT,
30+
cache_key,
31+
)
32+
33+
34+
class HandlerCore:
35+
"""
36+
Allows client to request data for specified IP address using the Core API.
37+
Core API provides city-level geolocation with nested geo and AS objects.
38+
Instantiates and maintains access to cache.
39+
"""
40+
41+
def __init__(self, access_token=None, **kwargs):
42+
"""
43+
Initialize the HandlerCore 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 Core 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(
95+
ip_address, IPv6Address
96+
):
97+
ip_address = ip_address.exploded
98+
99+
# check if bogon.
100+
if ip_address and is_bogon(ip_address):
101+
details = {}
102+
details["ip"] = ip_address
103+
details["bogon"] = True
104+
return Details(details)
105+
106+
# check cache first.
107+
try:
108+
cached_data = self.cache[cache_key(ip_address)]
109+
return Details(cached_data)
110+
except KeyError:
111+
pass
112+
113+
# prepare req http opts
114+
req_opts = {**self.request_options}
115+
if timeout is not None:
116+
req_opts["timeout"] = timeout
117+
118+
# Build URL
119+
url = CORE_API_URL
120+
if ip_address:
121+
url += "/" + ip_address
122+
123+
headers = handler_utils.get_headers(self.access_token, self.headers)
124+
response = requests.get(url, headers=headers, **req_opts)
125+
126+
if response.status_code == 429:
127+
raise RequestQuotaExceededError()
128+
if response.status_code >= 400:
129+
error_code = response.status_code
130+
content_type = response.headers.get("Content-Type")
131+
if content_type == "application/json":
132+
error_response = response.json()
133+
else:
134+
error_response = {"error": response.text}
135+
raise APIError(error_code, error_response)
136+
137+
details = response.json()
138+
139+
# Format and cache
140+
self._format_core_details(details)
141+
self.cache[cache_key(ip_address)] = details
142+
143+
return Details(details)
144+
145+
def _format_core_details(self, details):
146+
"""
147+
Format Core response details.
148+
Core has nested geo and as objects that need special formatting.
149+
"""
150+
# Format geo object if present
151+
if "geo" in details and details["geo"]:
152+
geo = details["geo"]
153+
if "country_code" in geo:
154+
country_code = geo["country_code"]
155+
geo["country_name"] = self.countries.get(country_code)
156+
geo["isEU"] = country_code in self.eu_countries
157+
geo["country_flag"] = self.countries_flags.get(country_code)
158+
geo["country_currency"] = self.countries_currencies.get(
159+
country_code
160+
)
161+
geo["continent"] = self.continents.get(country_code)
162+
geo["country_flag_url"] = (
163+
f"{handler_utils.COUNTRY_FLAGS_URL}{country_code}.svg"
164+
)
165+
166+
# Top-level country_code might also exist in some responses
167+
if "country_code" in details:
168+
country_code = details["country_code"]
169+
details["country_name"] = self.countries.get(country_code)
170+
details["isEU"] = country_code in self.eu_countries
171+
details["country_flag"] = self.countries_flags.get(country_code)
172+
details["country_currency"] = self.countries_currencies.get(
173+
country_code
174+
)
175+
details["continent"] = self.continents.get(country_code)
176+
details["country_flag_url"] = (
177+
f"{handler_utils.COUNTRY_FLAGS_URL}{country_code}.svg"
178+
)
179+
180+
def getBatchDetails(
181+
self,
182+
ip_addresses,
183+
batch_size=None,
184+
timeout_per_batch=BATCH_REQ_TIMEOUT_DEFAULT,
185+
timeout_total=None,
186+
raise_on_fail=True,
187+
):
188+
"""
189+
Get Core details for a batch of IP addresses at once.
190+
191+
There is no specified limit to the number of IPs this function can
192+
accept; it can handle as much as the user can fit in RAM (along with
193+
all of the response data, which is at least a magnitude larger than the
194+
input list).
195+
196+
The input list is broken up into batches to abide by API requirements.
197+
The batch size can be adjusted with `batch_size` but is clipped to
198+
`BATCH_MAX_SIZE`.
199+
Defaults to `BATCH_MAX_SIZE`.
200+
201+
For each batch, `timeout_per_batch` indicates the maximum seconds to
202+
spend waiting for the HTTP request to complete. If any batch fails with
203+
this timeout, the whole operation fails.
204+
Defaults to `BATCH_REQ_TIMEOUT_DEFAULT` seconds.
205+
206+
`timeout_total` is a seconds-denominated hard-timeout for the time
207+
spent in HTTP operations; regardless of whether all batches have
208+
succeeded so far, if `timeout_total` is reached, the whole operation
209+
will fail by raising `TimeoutExceededError`.
210+
Defaults to being turned off.
211+
212+
`raise_on_fail`, if turned off, will return any result retrieved so far
213+
rather than raise an exception when errors occur, including timeout and
214+
quota errors.
215+
Defaults to on.
216+
"""
217+
if batch_size == None:
218+
batch_size = BATCH_MAX_SIZE
219+
220+
result = {}
221+
lookup_addresses = []
222+
223+
# pre-populate with anything we've got in the cache, and keep around
224+
# the IPs not in the cache.
225+
for ip_address in ip_addresses:
226+
# if the supplied IP address uses the objects defined in the
227+
# built-in module ipaddress extract the appropriate string notation
228+
# before formatting the URL.
229+
if isinstance(ip_address, IPv4Address) or isinstance(
230+
ip_address, IPv6Address
231+
):
232+
ip_address = ip_address.exploded
233+
234+
if ip_address and is_bogon(ip_address):
235+
details = {}
236+
details["ip"] = ip_address
237+
details["bogon"] = True
238+
result[ip_address] = Details(details)
239+
else:
240+
try:
241+
cached_data = self.cache[cache_key(ip_address)]
242+
result[ip_address] = Details(cached_data)
243+
except KeyError:
244+
lookup_addresses.append(ip_address)
245+
246+
# all in cache - return early.
247+
if len(lookup_addresses) == 0:
248+
return result
249+
250+
# do start timer if necessary
251+
if timeout_total is not None:
252+
start_time = time.time()
253+
254+
# prepare req http options
255+
req_opts = {**self.request_options, "timeout": timeout_per_batch}
256+
257+
# loop over batch chunks and do lookup for each.
258+
url = "https://api.ipinfo.io/batch"
259+
headers = handler_utils.get_headers(self.access_token, self.headers)
260+
headers["content-type"] = "application/json"
261+
262+
for i in range(0, len(lookup_addresses), batch_size):
263+
# quit if total timeout is reached.
264+
if (
265+
timeout_total is not None
266+
and time.time() - start_time > timeout_total
267+
):
268+
return handler_utils.return_or_fail(
269+
raise_on_fail, TimeoutExceededError(), result
270+
)
271+
272+
chunk = lookup_addresses[i : i + batch_size]
273+
274+
# lookup
275+
try:
276+
response = requests.post(
277+
url, json=chunk, headers=headers, **req_opts
278+
)
279+
except Exception as e:
280+
return handler_utils.return_or_fail(raise_on_fail, e, result)
281+
282+
# fail on bad status codes
283+
try:
284+
if response.status_code == 429:
285+
raise RequestQuotaExceededError()
286+
response.raise_for_status()
287+
except Exception as e:
288+
return handler_utils.return_or_fail(raise_on_fail, e, result)
289+
290+
# Process batch response
291+
json_response = response.json()
292+
293+
for ip_address, data in json_response.items():
294+
# Cache and format the data
295+
if isinstance(data, dict) and not data.get("bogon"):
296+
self._format_core_details(data)
297+
self.cache[cache_key(ip_address)] = data
298+
result[ip_address] = Details(data)
299+
300+
return result

0 commit comments

Comments
 (0)