Skip to content

Commit 78677c5

Browse files
committed
Initial release
1 parent 59fb6d4 commit 78677c5

File tree

6 files changed

+339
-2
lines changed

6 files changed

+339
-2
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ suggestions = geouk.resources.Place.typeahead(client, 'wo...')
3030
# Fetch a list of `Place`s that match the given query string
3131
places = geouk.resources.Place.search(client, 'worcester')
3232

33-
print(places.humanized_name, places[0].geo_coords)
33+
print(places[0].humanized_name, places[0].geo_coords)
3434

3535
>> Worcester [52.18935, -2.22001]
3636

geouk/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .client import *
2+
3+
from . import exceptions
4+
from . import resources

geouk/client.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import io
2+
import json
3+
4+
import requests
5+
6+
from . import exceptions
7+
8+
__all__ = ['Client']
9+
10+
11+
class Client:
12+
"""
13+
A client for the GeoUK API.
14+
"""
15+
16+
def __init__(self, api_key, api_base_url='https://api.geouk.xyz'):
17+
18+
# A key used to authenticate API calls to an account
19+
self._api_key = api_key
20+
21+
# The base URL to use when calling the API
22+
self._api_base_url = api_base_url
23+
24+
# NOTE: Rate limiting information is only available after a request
25+
# has been made.
26+
27+
# The maximum number of requests per second that can be made with the
28+
# given API key.
29+
self._rate_limit = None
30+
31+
# The time (seconds since epoch) when the current rate limit will
32+
# reset.
33+
self._rate_limit_reset = None
34+
35+
# The number of requests remaining within the current limit before the
36+
# next reset.
37+
self._rate_limit_remaining = None
38+
39+
@property
40+
def rate_limit(self):
41+
return self._rate_limit
42+
43+
@property
44+
def rate_limit_reset(self):
45+
return self._rate_limit_reset
46+
47+
@property
48+
def rate_limit_remaining(self):
49+
return self._rate_limit_remaining
50+
51+
def __call__(self,
52+
method,
53+
path,
54+
params=None,
55+
data=None,
56+
json_type_body=None,
57+
files=None,
58+
download=False
59+
):
60+
"""Call the API"""
61+
62+
# Build headers
63+
headers = {'X-GeoUK-APIKey': self._api_key}
64+
65+
if json_type_body:
66+
headers['Content-Type'] = 'application/json'
67+
68+
if not download:
69+
headers['Accept'] = 'application/json'
70+
71+
if params:
72+
# Filter out parameters set to `None`
73+
params = {k: v for k, v in params.items() if v is not None}
74+
75+
# Make the request
76+
r = getattr(requests, method.lower())(
77+
f'{self._api_base_url}/{path}',
78+
headers=headers,
79+
params=params,
80+
data=data,
81+
json=json_type_body,
82+
files=files
83+
)
84+
85+
# Update the rate limit
86+
if 'X-GeoUK-RateLimit-Limit' in r.headers:
87+
self._rate_limit = int(r.headers['X-GeoUK-RateLimit-Limit'])
88+
self._rate_limit_reset \
89+
= float(r.headers['X-GeoUK-RateLimit-Reset'])
90+
self._rate_limit_remaining \
91+
= int(r.headers['X-GeoUK-RateLimit-Remaining'])
92+
93+
# Handle a successful response
94+
if r.status_code in [200, 204]:
95+
96+
if download:
97+
return io.BytesIO(r.content)
98+
99+
if r.headers.get('Content-Type', '')\
100+
.startswith('application/json'):
101+
102+
return r.json()
103+
104+
return None
105+
106+
# Raise an error related to the response
107+
try:
108+
error = r.json()
109+
110+
except json.decoder.JSONDecodeError:
111+
pass
112+
113+
finally:
114+
if not isinstance(error, dict):
115+
error = {}
116+
117+
error_cls = exceptions.GeoUKException.get_class_by_status_code(
118+
r.status_code
119+
)
120+
121+
raise error_cls(
122+
r.status_code,
123+
error.get('hint'),
124+
error.get('arg_errors')
125+
)
126+
127+

geouk/exceptions.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import copy
2+
import inspect
3+
4+
__all__ = [
5+
'GeoUKException',
6+
'GeoUKForbidden',
7+
'GeoUKInvalidRequest',
8+
'GeoUKNotFound',
9+
'GeoUKRequestLimitExceeded',
10+
'GeoUKUnauthorized'
11+
]
12+
13+
14+
class GeoUKException(Exception):
15+
"""
16+
An error occurred while processing the request.
17+
"""
18+
19+
def __init__(self, status_code, hint=None, arg_errors=None):
20+
super().__init__()
21+
22+
# The status code associated with the error
23+
self.status_code = status_code
24+
25+
# A hint providing additional information as to why this error
26+
# occurred.
27+
self._hint = hint
28+
29+
# A dictionary of errors relating to the arguments (parameters) sent
30+
# to the API endpoint (e.g `{'arg_name': ['error1', ...]}`).
31+
self._arg_errors = arg_errors
32+
33+
def __str__(self):
34+
35+
doc_str = inspect.cleandoc(self.__class__.__doc__)
36+
parts = [f'[{self.status_code}] {doc_str}']
37+
38+
if self._hint:
39+
parts.append(f'Hint: {self._hint}')
40+
41+
if self._arg_errors:
42+
parts.append(
43+
'Argument errors:\n- ' + '\n- '.join([
44+
f'{arg_name}: {" ".join(errors)}'
45+
for arg_name, errors in self._arg_errors.items()
46+
])
47+
)
48+
49+
return '\n---\n'.join(parts)
50+
51+
@property
52+
def arg_errors(self):
53+
if self._arg_errors:
54+
return copy.deepcopy(self._arg_errors)
55+
56+
@property
57+
def hint(self):
58+
return self._hint
59+
60+
@classmethod
61+
def get_class_by_status_code(cls, error_type, default=None):
62+
"""
63+
Return the exception class associated with the status code, if no
64+
class matches the given status code then the base `GeoUKException`
65+
class is returned.
66+
"""
67+
68+
class_map = {
69+
400: GeoUKInvalidRequest,
70+
401: GeoUKUnauthorized,
71+
403: GeoUKForbidden,
72+
405: GeoUKForbidden,
73+
404: GeoUKNotFound,
74+
429: GeoUKRequestLimitExceeded
75+
}
76+
77+
return class_map.get(error_type, default or GeoUKException)
78+
79+
80+
class GeoUKForbidden(GeoUKException):
81+
"""
82+
The request is not not allowed, most likely the HTTP method used to call
83+
the API endpoint is incorrect or the API key (via its associated account)
84+
does not have permission to call the endpoint and/or perform the action.
85+
"""
86+
87+
88+
class GeoUKInvalidRequest(GeoUKException):
89+
"""
90+
Not a valid request, most likely a missing or invalid parameter.
91+
"""
92+
93+
94+
class GeoUKNotFound(GeoUKException):
95+
"""
96+
The endpoint you are calling or the document you referenced doesn't exist.
97+
"""
98+
99+
100+
class GeoUKRequestLimitExceeded(GeoUKException):
101+
"""
102+
You have exceeded the number of API requests allowed per second.
103+
"""
104+
105+
106+
class GeoUKUnauthorized(GeoUKException):
107+
"""
108+
The API key provided is not valid.
109+
"""

geouk/resources.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
2+
# NOTE: The `Place` class provides a thin wrappers to data fetched from the
3+
# API by the API client and should not be initialized directly.
4+
5+
6+
class _BaseResource:
7+
"""
8+
A base resource used to wrap documents fetched from the API with dot
9+
notation access to attributes and methods for access to related API
10+
endpoints.
11+
"""
12+
13+
def __init__(self, client, document):
14+
15+
# The API client used to fetch the resource
16+
self._client = client
17+
18+
# The document representing the resource's data
19+
self._document = document
20+
21+
def __getattr__(self, name):
22+
23+
if '_document' in self.__dict__:
24+
return self.__dict__['_document'].get(name, None)
25+
26+
raise AttributeError(
27+
f"'{self.__class__.__name__}' has no attribute '{name}'"
28+
)
29+
30+
def __getitem__(self, name):
31+
return self.__dict__['_document'][name]
32+
33+
def __contains__(self, name):
34+
return name in self.__dict__['_document']
35+
36+
def get(self, name, default=None):
37+
return self.__dict__['_document'].get(name, default)
38+
39+
40+
41+
class Place(_BaseResource):
42+
"""
43+
A place within the UK or Republic of Ireland.
44+
"""
45+
46+
def __str__(self):
47+
return f'Place: {self.humanized_name}'
48+
49+
@classmethod
50+
def search(cls, client, q):
51+
"""
52+
Fetch a list of places that match the given query string.
53+
"""
54+
55+
# Fetch the matching places
56+
r = client(
57+
'get',
58+
'places/search',
59+
params={'q': q}
60+
)
61+
62+
return [cls(client, p) for p in r]
63+
64+
@classmethod
65+
def typeahead(cls, client, q):
66+
"""
67+
Return a list of place names and postcodes starting with the query
68+
string's first 2 characters
69+
"""
70+
71+
# Fetch the typeahead results
72+
r = client(
73+
'get',
74+
'places/typeahead',
75+
params={'q': q}
76+
)
77+
78+
return r
79+
80+
class Source(_BaseResource):
81+
"""
82+
A source for the data store against a place which provides attribution for
83+
the data.
84+
"""
85+
86+
def __str__(self):
87+
return f'Source: {self.name}'
88+
89+
@classmethod
90+
def all(cls, client):
91+
"""Fetch a list of all sources"""
92+
return [cls(client, s) for s in client('get', 'sources')]
93+
94+
@classmethod
95+
def one(cls, client, ref):
96+
"""Return a source matching the given reference"""
97+
return cls(client, client('get', f'sources/{ref}'))

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
Setup instuctions for h51.
2+
Setup instuctions for GeoUK.
33
"""
44

55
# Always prefer setuptools over distutils

0 commit comments

Comments
 (0)