Skip to content

Commit a138d2f

Browse files
committed
Initial work on async handler.
Mostly copies code, some optimization, brings up tests cases. Not actually fully async yet because ultimately calls sync code via the requests library.
1 parent bba2f0e commit a138d2f

File tree

10 files changed

+343
-39
lines changed

10 files changed

+343
-39
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
.vscode/
2+
.vim/
3+
.idea/
4+
15
# Byte-compiled / optimized / DLL files
26
__pycache__/
37
*.py[cod]

ipinfo/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
from .handler import Handler
2+
from .handler_async import AsyncHandler
23

34

45
def getHandler(access_token=None, **kwargs):
56
"""Create and return Handler object."""
67
return Handler(access_token, **kwargs)
8+
9+
10+
def getHandlerAsync(access_token=None, **kwargs):
11+
"""Create an return an asynchronous Handler object."""
12+
return AsyncHandler(access_token, **kwargs)

ipinfo/handler.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616

1717
class Handler:
1818
"""
19-
Allows client to request data for specified IP address. Instantiates and
20-
and maintains access to cache.
19+
Allows client to request data for specified IP address.
20+
Instantiates and maintains access to cache.
2121
"""
2222

2323
API_URL = "https://ipinfo.io"
@@ -120,7 +120,7 @@ def _requestDetails(self, ip_address=None):
120120
def _get_headers(self):
121121
"""Built headers for request to IPinfo API."""
122122
headers = {
123-
"user-agent": "IPinfoClient/Python{version}/2.0.0".format(
123+
"user-agent": "IPinfoClient/Python{version}/3.0.0".format(
124124
version=sys.version_info[0]
125125
),
126126
"accept": "application/json",

ipinfo/handler_async.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""
2+
Main API client asynchronous handler for fetching data from the IPinfo service.
3+
"""
4+
5+
from ipaddress import IPv4Address, IPv6Address
6+
import json
7+
import os
8+
import sys
9+
10+
import requests
11+
12+
from .cache.default import DefaultCache
13+
from .details import Details
14+
from .exceptions import RequestQuotaExceededError
15+
16+
17+
class AsyncHandler:
18+
"""
19+
Allows client to request data for specified IP address asynchronously.
20+
Instantiates and maintains access to cache.
21+
"""
22+
23+
API_URL = "https://ipinfo.io"
24+
CACHE_MAXSIZE = 4096
25+
CACHE_TTL = 60 * 60 * 24
26+
COUNTRY_FILE_DEFAULT = "countries.json"
27+
REQUEST_TIMEOUT_DEFAULT = 2
28+
29+
def __init__(self, access_token=None, **kwargs):
30+
"""Initialize the Handler object with country name list and the cache initialized."""
31+
self.access_token = access_token
32+
self.countries = self._read_country_names(kwargs.get("countries_file"))
33+
self.request_options = kwargs.get("request_options", {})
34+
if "timeout" not in self.request_options:
35+
self.request_options["timeout"] = self.REQUEST_TIMEOUT_DEFAULT
36+
37+
if "cache" in kwargs:
38+
self.cache = kwargs["cache"]
39+
else:
40+
cache_options = kwargs.get("cache_options", {})
41+
if "maxsize" not in cache_options:
42+
cache_options["maxsize"] = self.CACHE_MAXSIZE
43+
if "ttl" not in cache_options:
44+
cache_options["ttl"] = self.CACHE_TTL
45+
self.cache = DefaultCache(**cache_options)
46+
47+
async def getDetails(self, ip_address=None):
48+
"""Get details for specified IP address as a Details object."""
49+
# If the supplied IP address uses the objects defined in the built-in
50+
# module ipaddress, extract the appropriate string notation before
51+
# formatting the URL.
52+
if isinstance(ip_address, IPv4Address) or isinstance(ip_address, IPv6Address):
53+
ip_address = ip_address.exploded
54+
55+
if ip_address in self.cache:
56+
return Details(self.cache[ip_address])
57+
58+
# not in cache; get result, format it, and put in cache.
59+
url = self.API_URL
60+
if ip_address:
61+
url += "/" + ip_address
62+
response = requests.get(
63+
url, headers=self._get_headers(), **self.request_options
64+
)
65+
if response.status_code == 429:
66+
raise RequestQuotaExceededError()
67+
response.raise_for_status()
68+
raw_details = response.json()
69+
self._format_details(raw_details)
70+
self.cache[ip_address] = raw_details
71+
return Details(raw_details)
72+
73+
async def getBatchDetails(self, ip_addresses):
74+
"""Get details for a batch of IP addresses at once."""
75+
result = {}
76+
77+
# Pre-populate with anything we've got in the cache, and keep around
78+
# the IPs not in the cache.
79+
lookup_addresses = []
80+
for ip_address in ip_addresses:
81+
# If the supplied IP address uses the objects defined in the
82+
# built-in module ipaddress extract the appropriate string notation
83+
# before formatting the URL.
84+
if isinstance(ip_address, IPv4Address) or isinstance(ip_address, IPv6Address):
85+
ip_address = ip_address.exploded
86+
87+
if ip_address in self.cache:
88+
result[ip_address] = self.cache[ip_address]
89+
else:
90+
lookup_addresses.append(ip_address)
91+
92+
# all in cache - return early.
93+
if len(lookup_addresses) == 0:
94+
return result
95+
96+
# Do the lookup
97+
url = self.API_URL + "/batch"
98+
headers = self._get_headers()
99+
headers["content-type"] = "application/json"
100+
response = requests.post(
101+
url, json=lookup_addresses, headers=headers, **self.request_options
102+
)
103+
if response.status_code == 429:
104+
raise RequestQuotaExceededError()
105+
response.raise_for_status()
106+
107+
# Format & fill up cache
108+
json_response = response.json()
109+
for ip_address, details in json_response.items():
110+
if isinstance(details, dict):
111+
self._format_details(details)
112+
self.cache[ip_address] = details
113+
114+
# Merge cached results with new lookup
115+
result.update(json_response)
116+
117+
return result
118+
119+
def _get_headers(self):
120+
"""Built headers for request to IPinfo API."""
121+
headers = {
122+
"user-agent": "IPinfoClient/Python{version}/3.0.0".format(
123+
version=sys.version_info[0]
124+
),
125+
"accept": "application/json",
126+
}
127+
128+
if self.access_token:
129+
headers["authorization"] = "Bearer {}".format(self.access_token)
130+
131+
return headers
132+
133+
def _format_details(self, details):
134+
details["country_name"] = self.countries.get(details.get("country"))
135+
details["latitude"], details["longitude"] = self._read_coords(
136+
details.get("loc")
137+
)
138+
139+
def _read_coords(self, location):
140+
lat, lon = None, None
141+
coords = tuple(location.split(",")) if location else ""
142+
if len(coords) == 2 and coords[0] and coords[1]:
143+
lat, lon = coords[0], coords[1]
144+
return lat, lon
145+
146+
def _read_country_names(self, countries_file=None):
147+
"""Read list of countries from specified country file or default file."""
148+
if not countries_file:
149+
countries_file = os.path.join(
150+
os.path.dirname(__file__), self.COUNTRY_FILE_DEFAULT
151+
)
152+
with open(countries_file) as f:
153+
countries_json = f.read()
154+
155+
return json.loads(countries_json)

requirements.in

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
# For app
1+
# base
22
requests>=2.18.4
3-
cachetools==3.1.1
3+
cachetools==4.1.1
4+
aiohttp<=4
45

5-
# For dev
6-
pytest==4.5.0
7-
pip-tools==3.7.0
8-
black==19.3b0
6+
# dev
7+
pytest==6.1.2
8+
pytest-asyncio==0.14.0
9+
pip-tools==5.3.1
10+
black==20.8b1

requirements.txt

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,37 @@
22
# This file is autogenerated by pip-compile
33
# To update, run:
44
#
5-
# pip-compile --no-emit-trusted-host --no-index
5+
# pip-compile --no-emit-index-url --no-emit-trusted-host
66
#
7+
aiohttp==3.7.2 # via -r requirements.in
78
appdirs==1.4.3 # via black
8-
atomicwrites==1.3.0 # via pytest
9-
attrs==19.3.0 # via black, pytest
10-
black==19.3b0
11-
cachetools==3.1.1
9+
async-timeout==3.0.1 # via aiohttp
10+
attrs==19.3.0 # via aiohttp, pytest
11+
black==20.8b1 # via -r requirements.in
12+
cachetools==4.1.1 # via -r requirements.in
1213
certifi==2019.9.11 # via requests
13-
chardet==3.0.4 # via requests
14-
click==7.0 # via black, pip-tools
15-
idna==2.8 # via requests
16-
importlib-metadata==0.23 # via pluggy
17-
more-itertools==7.2.0 # via pytest, zipp
18-
pip-tools==3.7.0
14+
chardet==3.0.4 # via aiohttp, requests
15+
click==7.1.2 # via black, pip-tools
16+
idna==2.8 # via requests, yarl
17+
iniconfig==1.1.1 # via pytest
18+
multidict==5.0.0 # via aiohttp, yarl
19+
mypy-extensions==0.4.3 # via black
20+
packaging==20.4 # via pytest
21+
pathspec==0.8.0 # via black
22+
pip-tools==5.3.1 # via -r requirements.in
1923
pluggy==0.13.0 # via pytest
20-
py==1.8.0 # via pytest
21-
pytest==4.5.0
22-
requests==2.22.0
23-
six==1.12.0 # via pip-tools, pytest
24-
toml==0.10.0 # via black
24+
py==1.9.0 # via pytest
25+
pyparsing==2.4.7 # via packaging
26+
pytest-asyncio==0.14.0 # via -r requirements.in
27+
pytest==6.1.2 # via -r requirements.in, pytest-asyncio
28+
regex==2020.10.28 # via black
29+
requests==2.22.0 # via -r requirements.in
30+
six==1.12.0 # via packaging, pip-tools
31+
toml==0.10.2 # via black, pytest
32+
typed-ast==1.4.1 # via black
33+
typing-extensions==3.7.4.3 # via aiohttp, black
2534
urllib3==1.25.6 # via requests
26-
wcwidth==0.1.7 # via pytest
27-
zipp==0.6.0 # via importlib-metadata
35+
yarl==1.6.2 # via aiohttp
2836

2937
# The following packages are considered to be unsafe in a requirements file:
30-
# setuptools==41.6.0 # via pytest
38+
# pip

scripts/ctags.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/bin/bash
2+
3+
# Regenerate ctags.
4+
5+
ctags \
6+
--recurse=yes \
7+
--exclude=node_modules \
8+
--exclude=dist \
9+
--exclude=build \
10+
--exclude=target \
11+
-f .vim/tags \
12+
--tag-relative=never \
13+
--totals=yes \
14+
./ipinfo \
15+
./tests

tests/handler_async_test.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import os
2+
3+
from ipinfo.cache.default import DefaultCache
4+
from ipinfo.details import Details
5+
from ipinfo.handler_async import AsyncHandler
6+
import pytest
7+
8+
@pytest.mark.asyncio
9+
async def test_init():
10+
token = "mytesttoken"
11+
handler = AsyncHandler(token)
12+
assert handler.access_token == token
13+
assert isinstance(handler.cache, DefaultCache)
14+
assert "PK" in handler.countries
15+
16+
17+
@pytest.mark.asyncio
18+
async def test_headers():
19+
token = "mytesttoken"
20+
handler = AsyncHandler(token)
21+
headers = handler._get_headers()
22+
23+
assert "user-agent" in headers
24+
assert "accept" in headers
25+
assert "authorization" in headers
26+
27+
28+
@pytest.mark.asyncio
29+
async def test_get_details():
30+
token = os.environ.get('IPINFO_TOKEN', '')
31+
handler = AsyncHandler(token)
32+
details = await handler.getDetails("8.8.8.8")
33+
assert isinstance(details, Details)
34+
assert details.ip == "8.8.8.8"
35+
assert details.hostname == "dns.google"
36+
assert details.city == "Mountain View"
37+
assert details.region == "California"
38+
assert details.country == "US"
39+
assert details.country_name == "United States"
40+
assert details.loc == "37.4056,-122.0775"
41+
assert details.latitude == "37.4056"
42+
assert details.longitude == "-122.0775"
43+
assert details.postal == "94043"
44+
assert details.timezone == "America/Los_Angeles"
45+
if token:
46+
asn = details.asn
47+
assert asn["asn"] == "AS15169"
48+
assert asn["name"] == "Google LLC"
49+
assert asn["domain"] == "google.com"
50+
assert asn["route"] == "8.8.8.0/24"
51+
assert asn["type"] == "business"
52+
53+
company = details.company
54+
assert company["name"] == "Google LLC"
55+
assert company["domain"] == "google.com"
56+
assert company["type"] == "business"
57+
58+
privacy = details.privacy
59+
assert privacy["vpn"] == False
60+
assert privacy["proxy"] == False
61+
assert privacy["tor"] == False
62+
assert privacy["hosting"] == False
63+
64+
abuse = details.abuse
65+
assert abuse["address"] == "US, CA, Mountain View, 1600 Amphitheatre Parkway, 94043"
66+
assert abuse["country"] == "US"
67+
assert abuse["email"] == "[email protected]"
68+
assert abuse["name"] == "Abuse"
69+
assert abuse["network"] == "8.8.8.0/24"
70+
assert abuse["phone"] == "+1-650-253-0000"
71+
72+
domains = details.domains
73+
assert domains["ip"] == "8.8.8.8"
74+
assert domains["total"] == 12988
75+
assert len(domains["domains"]) == 5

0 commit comments

Comments
 (0)