diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b47a98b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +dist: xenial +language: python +python: + - "2.7" + - "3.4" + - "3.5" + - "3.6" + - "3.7" + +install: + - pip install mock coverage flake8 +script: + - coverage run test.py + - coverage report --show-missing + - flake8 ggeocoder.py diff --git a/README.rst b/README.rst index 1567d39..5dd2687 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,8 @@ .. _geocoder_api: +.. image:: https://travis-ci.org/imtapps/ggeocoder.svg?branch=master + :target: https://travis-ci.org/imtapps/ggeocoder + ******************* Google Geocoder API ******************* diff --git a/ggeocoder.py b/ggeocoder.py index 1ece7fd..6e62744 100755 --- a/ggeocoder.py +++ b/ggeocoder.py @@ -8,9 +8,15 @@ import base64 import hashlib import hmac -import urllib -import urllib2 -import urlparse +import sys + +if sys.version_info > (3, 0): + from urllib.parse import urlparse, urlencode + from urllib.request import urlopen +else: + from urllib import urlencode + from urllib2 import urlopen + from urlparse import urlparse try: import json @@ -18,18 +24,19 @@ import simplejson as json -VERSION = '1.0.2' +VERSION = '2.0.0' + +__all__ = ['Geocoder', 'GeocoderResult', 'GeoResult', 'GeocodeError'] -__all__ = ['Geocoder', 'GeocoderResult', 'GeoResult', 'GeocodeError',] class GeocodeError(Exception): """ Base class for errors in the :mod:`ggeocoder` module. - + Methods of the :class:`Geocoder` raise this when the Google Maps API returns a status of anything other than 'OK'. - - See http://code.google.com/apis/maps/documentation/geocoding/index.html#StatusCodes + + See http://code.google.com/apis/maps/documentation/geocoding/index.html#StatusCodes # noqa: E501 for status codes and their meanings. """ G_GEO_OK = "OK" @@ -37,10 +44,10 @@ class GeocodeError(Exception): G_GEO_OVER_QUERY_LIMIT = "OVER_QUERY_LIMIT" G_GEO_REQUEST_DENIED = "REQUEST_DENIED" G_GEO_MISSING_QUERY = "INVALID_REQUEST" - + def __init__(self, status, url=None, response=None): """Create an exception with a status and optional full response. - + :param status: Either a ``G_GEO_`` code or a string explaining the exception. :type status: int or string @@ -48,20 +55,16 @@ def __init__(self, status, url=None, response=None): :type url: string :param response: The actual response returned from Google, if any. :type response: dict - """ super(GeocodeError, self).__init__(status) self.status = status self.url = url self.response = response - + def __str__(self): """Return a string representation of this :exc:`GeocoderError`.""" return 'Error: {0}\nQuery: {1}'.format(self.status, self.url) - - def __unicode__(self, *args, **kwargs): - """Return a unicode representation of this :exc:`GeocoderError`.""" - return unicode(self.__str__()) + class GeoResult(object): """ @@ -84,11 +87,8 @@ def __eq__(self, other): return False def __str__(self): - return unicode(self).encode('utf-8') - - def __unicode__(self): return self.formatted_address - + def __getattr__(self, name): attr, prop = self.get_property_components(name) @@ -142,7 +142,6 @@ def raw(self): return self.data - class GeocoderResult(object): """ Helps process all the results returned from Google Maps API @@ -156,7 +155,6 @@ class GeocoderResult(object): print result.formatted_address, result.coordinates You can also customize the result_class created if you'd like. - """ def __init__(self, data, result_class): @@ -172,17 +170,17 @@ def __getitem__(self, idx): class Geocoder(object): """ Interface for interacting with Google's Geocoding V3's API. - http://code.google.com/apis/maps/documentation/geocoding/ + https://developers.google.com/maps/documentation/geocoding/start?csw=1 If you have a Google Maps Premier account, you can supply your client_id and private_key and the :class:`Geocoder` will make the request with a properly signed url """ - PREMIER_CREDENTIALS_ERROR = "You must provide both a client_id and private_key to use Premier Account." - GOOGLE_API_URL = 'http://maps.googleapis.com/maps/api/geocode/json?' + PREMIER_CREDENTIALS_ERROR = "You must provide both a client_id and private_key to use Premier Account." # noqa: E501 + GOOGLE_API_URL = 'https://maps.googleapis.com/maps/api/geocode/json?' TIMEOUT_SECONDS = 3 - def __init__(self, client_id=None, private_key=None): + def __init__(self, client_id=None, private_key=None, api_key=None): """ Google Maps API Premier users can provide credentials to make 100,000 requests a day vs the standard 2,500 requests a day without @@ -194,23 +192,27 @@ def __init__(self, client_id=None, private_key=None): :type client_id: str :param private_key: private key used to sign urls :type private_key: str - + :param api_key: Google Maps API key. + this will trump if it is present. Google has moved away from the + "premier accounts" and now just requires an API key + :type api_key: str """ self.credentials = (client_id, private_key) + self.api_key = api_key if any(self.credentials) and not all(self.credentials): raise GeocodeError(self.PREMIER_CREDENTIALS_ERROR) def geocode(self, address, **params): """ | Params may be any valid parameter accepted by Google's API. - | http://code.google.com/apis/maps/documentation/geocoding/#GeocodingRequests + | http://code.google.com/apis/maps/documentation/geocoding/#GeocodingRequests # noqa: E501 """ return self._get_geocoder_result(address=address, **params) def reverse_geocode(self, lat, lng, **params): """ | Params may be any valid parameter accepted by Google's API. - | http://code.google.com/apis/maps/documentation/geocoding/#GeocodingRequests + | http://code.google.com/apis/maps/documentation/geocoding/#GeocodingRequests # noqa: E501 """ return self._get_geocoder_result(latlng="%s,%s" % (lat, lng), **params) @@ -219,13 +221,11 @@ def use_premier_key(self): return all(self.credentials) def _get_geocoder_result(self, result_class=GeoResult, **params): - geo_params = {'sensor': 'false'} # API says sensor must always have a value - geo_params.update(params) - return GeocoderResult(self._get_results(params=geo_params), result_class) + return GeocoderResult(self._get_results(params=params), result_class) def _get_results(self, params=None): url = self._get_request_url(params or {}) - response = urllib2.urlopen(url, timeout=self.TIMEOUT_SECONDS) + response = urlopen(url, timeout=self.TIMEOUT_SECONDS) return self._process_response(response, url) def _process_response(self, response, url): @@ -235,9 +235,11 @@ def _process_response(self, response, url): return j['results'] def _get_request_url(self, params): - encoded_params = urllib.urlencode(params) + encoded_params = urlencode(params) url = self.GOOGLE_API_URL + encoded_params - if self.use_premier_key: + if self.api_key: + url = url + "&key={}".format(self.api_key) + elif self.use_premier_key: url = self._get_premier_url(url) return url @@ -252,52 +254,54 @@ def _generate_signature(self, base_url, private_key): """ http://code.google.com/apis/maps/documentation/webservices/index.html#PythonSignatureExample """ - url = urlparse.urlparse(base_url) + url = urlparse(base_url) url_to_sign = url.path + '?' + url.query decoded_key = base64.urlsafe_b64decode(private_key) - signature = hmac.new(decoded_key, url_to_sign, hashlib.sha1) - return base64.urlsafe_b64encode(signature.digest()) + signature = hmac.new(decoded_key, url_to_sign.encode(), hashlib.sha1) + return base64.urlsafe_b64encode(signature.digest()).decode('utf-8') if __name__ == "__main__": import sys from optparse import OptionParser - + def main(): """ Geocodes a location given on the command line. - + Usage: ggeocoder.py "1600 amphitheatre mountain view ca" [YOUR_API_KEY] ggeocoder.py 37.4218272,-122.0842409 [YOUR_API_KEY] - + When providing a latitude and longitude on the command line, ensure they are separated by a comma and no space. - + """ usage = "usage: %prog [options] address" parser = OptionParser(usage, version=VERSION) parser.add_option("-c", "--client_id", - dest="client_id", help="Your Google Maps Client Id key") + dest="client_id", + help="Your Google Maps Client Id key") parser.add_option("-k", "--private_key", - dest="private_key", help="Your Google Maps Private Signature key") + dest="private_key", + help="Your Google Maps Private Signature key") (options, args) = parser.parse_args() - + if len(args) != 1: parser.print_usage() sys.exit(1) - + query = args[0] gcoder = Geocoder(options.client_id, options.private_key) - + try: result = gcoder.geocode(query) - except GeocodeError, err: + except GeocodeError as err: sys.stderr.write('%s\n%s\nResponse:\n' % (err.url, err)) json.dump(err.response, sys.stderr, indent=4) sys.exit(1) - + for r in result: - print r - print r.coordinates + print(r) + print(r.coordinates) main() diff --git a/setup.py b/setup.py index d68214f..73b84cf 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ import os -from distutils.core import setup +from setuptools import setup import ggeocoder @@ -12,7 +12,7 @@ author='Aaron Madison', url='https://github.com/imtapps/ggeocoder', description='A Python library for working with Google Geocoding API V3.', - long_description=file( + long_description=open( os.path.join(os.path.dirname(__file__), 'README.rst') ).read(), py_modules=['ggeocoder'], @@ -23,7 +23,11 @@ 'Intended Audience :: Developers', 'Natural Language :: English', 'Operating System :: OS Independent', - 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', 'License :: OSI Approved :: BSD License', 'Topic :: Internet', 'Topic :: Internet :: WWW/HTTP', @@ -32,4 +36,4 @@ ], keywords='google maps api v3 geocode geocoding geocoder reverse', license='BSD License', -) \ No newline at end of file +) diff --git a/test.py b/test.py old mode 100644 new mode 100755 index 926bedc..facc62d --- a/test.py +++ b/test.py @@ -1,7 +1,8 @@ #!/usr/bin/env python import mock -from StringIO import StringIO +import sys + import unittest try: @@ -9,6 +10,14 @@ except ImportError: import simplejson as json +if sys.version_info > (3, 0): + from urllib.parse import urlencode + from io import StringIO +else: + from urllib import urlencode + from cStringIO import StringIO + + from ggeocoder import Geocoder, GeoResult, GeocoderResult, GeocodeError no_results_response = """{ @@ -94,7 +103,8 @@ def test_geocode_error_raises_status_for_message(self): with self.assertRaises(GeocodeError) as ctx: raise GeocodeError(status, url=query_url) - self.assertEqual(status, ctx.exception.message) + self.assertEqual(status, ctx.exception.args[0]) + class GeoResultTests(unittest.TestCase): @@ -158,9 +168,9 @@ class TestResult(GeoResult): } t = TestResult(self.data) - self.assertEqual(u'94043', t.ZIP) - self.assertEqual(u'California', t.state) - self.assertEqual(u'CA', t.state__short_name) + self.assertEqual('94043', t.ZIP) + self.assertEqual('California', t.state) + self.assertEqual('CA', t.state__short_name) def test_custom_mapped_attrs_support_dunder_lookup(self): class TestResult(GeoResult): @@ -169,7 +179,7 @@ class TestResult(GeoResult): } t = TestResult(self.data) - self.assertEqual(u'CA', t.state) + self.assertEqual('CA', t.state) class GeocoderResultTests(unittest.TestCase): @@ -205,13 +215,14 @@ def test_iterates_through_data_items_on_iter(self): result = GeocoderResult(self.data, self.result_class) self.assertEqual([GeoResult(self.data[0]), GeoResult(self.data[1])], list(result)) + class GeocoderTests(unittest.TestCase): def setUp(self): self.private_key = 'vNIXE0xscrmjlyV-12Nj_BvUPaw=' self.client_id = 'clientID' - self.base_url = 'http://maps.googleapis.com/maps/api/geocode/json?address=New+York&sensor=false' - self.known_signature = 'KrU1TzVQM7Ur0i8i7K3huiw3MsA=' # signature generated for above credentials + self.base_url = 'https://maps.googleapis.com/maps/api/geocode/json?address=New+York' + self.known_signature = 'chaRF2hTJKOScPr-RQCEhZbSzIE=' # signature generated for above credentials def test_accepts_client_id_and_private_key_on_initialization(self): g = Geocoder(client_id=self.client_id, private_key=self.private_key) @@ -221,13 +232,13 @@ def test_raises_exception_when_client_id_provided_without_private_key(self): with self.assertRaises(GeocodeError) as ctx: Geocoder(client_id=self.client_id) msg = "You must provide both a client_id and private_key to use Premier Account." - self.assertEqual(msg, ctx.exception.message) + self.assertEqual(msg, ctx.exception.args[0]) def test_raises_exception_when_private_key_provided_without_client_id(self): with self.assertRaises(GeocodeError) as ctx: Geocoder(private_key=self.private_key) msg = "You must provide both a client_id and private_key to use Premier Account." - self.assertEqual(msg, ctx.exception.message) + self.assertEqual(msg, ctx.exception.args[0]) def test_allows_initialization_with_no_credentials(self): g = Geocoder() @@ -254,47 +265,47 @@ def test_get_premier_url_adds_client_and_signature_to_query_string(self): expected_url = self.base_url + '&client={0}&signature={1}'.format(self.client_id, self.known_signature) self.assertEqual(expected_url, premier_url) - @mock.patch('urllib.urlencode') - def test_get_request_url_returns_url_with_params_when_not_premier(self, urlencode): - params = dict(address='New York', sensor='false') - urlencode.return_value = 'address=New+York&sensor=false' + def test_get_request_url_returns_url_with_params_when_not_premier(self): + params = dict(address='New York') g = Geocoder() request_url = g._get_request_url(params) - self.assertEqual(g.GOOGLE_API_URL + urlencode.return_value, request_url) - urlencode.assert_called_once_with(params) + self.assertEqual(g.GOOGLE_API_URL + urlencode(params), request_url) - @mock.patch('urllib.urlencode') - @mock.patch.object(Geocoder, '_get_premier_url') - def test_gets_premier_url_when_supplied_credentials(self, get_premier_url, urlencode): - params = dict(address='New York', sensor='false') - urlencode.return_value = 'address=New+York&sensor=false' + def test_get_request_url_returns_url_uses_api_key_when_present(self): + params = dict(address='New York') - g = Geocoder(client_id=self.client_id, private_key=self.private_key) + g = Geocoder(api_key="FAKE-API-KEY") request_url = g._get_request_url(params) - self.assertEqual(get_premier_url.return_value, request_url) + self.assertEqual(g.GOOGLE_API_URL + urlencode(params) + "&key=FAKE-API-KEY", request_url) + + def test_gets_premier_url_when_supplied_credentials(self): + params = dict(address='New York') - expected_url_to_pass = g.GOOGLE_API_URL + urlencode.return_value - get_premier_url.assert_called_once_with(expected_url_to_pass) + g = Geocoder(client_id=self.client_id, private_key=self.private_key) + request_url = g._get_request_url(params) + + expected_url = g.GOOGLE_API_URL + urlencode(params) + '&client={0}&signature={1}'.format(self.client_id, self.known_signature) + self.assertEqual(expected_url, request_url) @mock.patch.object(Geocoder, '_process_response', mock.Mock()) - @mock.patch('urllib2.urlopen') + @mock.patch('ggeocoder.urlopen') @mock.patch.object(Geocoder, '_get_request_url') def test_get_results_opens_url_with_request_url_and_timeout(self, get_url, urlopen): - params = dict(address='New York', sensor='false') + params = dict(address='New York') g = Geocoder(client_id=self.client_id, private_key=self.private_key) g._get_results(params) get_url.assert_called_once_with(params) urlopen.assert_called_once_with(get_url.return_value, timeout=g.TIMEOUT_SECONDS) - @mock.patch('urllib2.urlopen') + @mock.patch('ggeocoder.urlopen') @mock.patch.object(Geocoder, '_get_request_url') @mock.patch.object(Geocoder, '_process_response') def test_get_results_returns_processed_response(self, process_response, get_url, urlopen): - params = dict(address='New York', sensor='false') + params = dict(address='New York') g = Geocoder(client_id=self.client_id, private_key=self.private_key) results = g._get_results(params) @@ -331,8 +342,8 @@ def test_process_response_returns_results_when_status_is_ok(self): def test_geocode_returns_result_by_address_and_additional_params(self, get_results, geocoder_result_class): mock_result_class = mock.Mock() address = "1600 Amphitheatre Pkwy" - result = Geocoder().geocode(address, sensor='true', result_class=mock_result_class) - get_results.assert_called_once_with(params=dict(address=address, sensor='true')) + result = Geocoder().geocode(address, result_class=mock_result_class) + get_results.assert_called_once_with(params=dict(address=address)) geocoder_result_class.assert_called_once_with(get_results.return_value, mock_result_class) self.assertEqual(geocoder_result_class.return_value, result) @@ -342,7 +353,7 @@ def test_geocode_adds_sensor_parameter_when_not_supplied(self, get_results): address = "1600 Amphitheatre Pkwy" Geocoder().geocode(address) - get_results.assert_called_once_with(params=dict(address=address, sensor='false')) + get_results.assert_called_once_with(params=dict(address=address)) @mock.patch('ggeocoder.GeocoderResult') @mock.patch.object(Geocoder, '_get_results') @@ -351,18 +362,11 @@ def test_reverse_geocode_returns_result_by_latlng_and_additional_params(self, ge mock_result_class = mock.Mock() - result = Geocoder().reverse_geocode(lat, lng, sensor='true', language='fr', result_class=mock_result_class) - get_results.assert_called_once_with(params=dict(latlng='37.421827,-122.084241', sensor='true', language='fr')) + result = Geocoder().reverse_geocode(lat, lng, language='fr', result_class=mock_result_class) + get_results.assert_called_once_with(params=dict(latlng='37.421827,-122.084241', language='fr')) geocoder_result_class.assert_called_once_with(get_results.return_value, mock_result_class) self.assertEqual(geocoder_result_class.return_value, result) - @mock.patch('ggeocoder.GeocoderResult', mock.Mock(spec_set=GeocoderResult)) - @mock.patch.object(Geocoder, '_get_results') - def test_reverse_geocode_adds_sensor_parameter_when_not_supplied(self, get_results): - lat, lng = 37.4218270, -122.0842409 - - Geocoder().reverse_geocode(lat, lng) - get_results.assert_called_once_with(params=dict(latlng='37.421827,-122.0842409', sensor='false')) if __name__ == "__main__": unittest.main()