diff --git a/server/lib/python/cartodb_services/cartodb_services/mapzen/__init__.py b/server/lib/python/cartodb_services/cartodb_services/mapzen/__init__.py new file mode 100644 index 0000000..c1a8c1f --- /dev/null +++ b/server/lib/python/cartodb_services/cartodb_services/mapzen/__init__.py @@ -0,0 +1 @@ +from routing import MapzenRouting, MapzenRoutingResponse diff --git a/server/lib/python/cartodb_services/cartodb_services/mapzen/exceptions.py b/server/lib/python/cartodb_services/cartodb_services/mapzen/exceptions.py new file mode 100644 index 0000000..92912ec --- /dev/null +++ b/server/lib/python/cartodb_services/cartodb_services/mapzen/exceptions.py @@ -0,0 +1,16 @@ +#!/usr/local/bin/python +# -*- coding: utf-8 -*- +import json + + +class WrongParams(Exception): + def __init__(self, value): + self.value = value + + def __str__(self): + return repr('Wrong parameters passed: ' + json.dumps(self.value)) + + +class MalformedResult(Exception): + def __str__(self): + return repr('Result structure is malformed') diff --git a/server/lib/python/cartodb_services/cartodb_services/mapzen/routing.py b/server/lib/python/cartodb_services/cartodb_services/mapzen/routing.py new file mode 100644 index 0000000..2b08d46 --- /dev/null +++ b/server/lib/python/cartodb_services/cartodb_services/mapzen/routing.py @@ -0,0 +1,118 @@ +import requests +import json +import re +from polyline.codec import PolylineCodec + +from exceptions import WrongParams +from cartodb_services.tools import Coordinate + + +class MapzenRouting: + 'A Mapzen Routing wrapper for python' + + PRODUCTION_ROUTING_BASE_URL = 'https://valhalla.mapzen.com/route' + + ACCEPTED_MODES = { + "walk": "pedestrian", + "car": "auto", + "public_transport": "bus", + "bicycle": "bicycle" + } + + AUTO_SHORTEST = 'auto_shortest' + + OPTIONAL_PARAMS = [ + 'mode_type', + ] + + METRICS_UNITS = 'kilometers' + IMPERIAL_UNITS = 'miles' + + def __init__(self, app_key, base_url=PRODUCTION_ROUTING_BASE_URL): + self._app_key = app_key + self._url = base_url + + def calculate_route_point_to_point(self, origin, destination, mode, + options=[], units=METRICS_UNITS): + parsed_options = self.__parse_options(options) + mode_param = self.__parse_mode_param(mode, parsed_options) + directions = self.__parse_directions(origin, destination) + json_request_params = self.__parse_json_parameters(directions, + mode_param, + units) + request_params = self.__parse_request_parameters(json_request_params) + response = requests.get(self._url, params=request_params) + if response.status_code == requests.codes.ok: + return self.__parse_routing_response(response.text) + else: + response.raise_for_status() + + def __parse_options(self, options): + return dict(option.split('=') for option in options) + + def __parse_request_parameters(self, json_request): + request_options = {"json": json_request} + request_options.update({'api_key': self._app_key}) + + return request_options + + def __parse_json_parameters(self, directions, mode, units): + json_options = directions + json_options.update({'costing': self.ACCEPTED_MODES[mode]}) + json_options.update({"directions_options": {'units': units, + 'narrative': False}}) + + return json.dumps(json_options) + + def __parse_directions(self, origin, destination): + return {"locations": [ + {"lon": origin.longitude, "lat": origin.latitude}, + {"lon": destination.longitude, "lat": destination.latitude} + ]} + + def __parse_routing_response(self, response): + try: + parsed_json_response = json.loads(response) + legs = parsed_json_response['trip']['legs'][0] + shape = PolylineCodec().decode(legs['shape']) + length = legs['summary']['length'] + duration = legs['summary']['time'] + routing_response = MapzenRoutingResponse(shape, length, duration) + + return routing_response + except IndexError: + return [] + except KeyError: + raise MalformedResult() + + def __parse_mode_param(self, mode, options): + if mode in self.ACCEPTED_MODES: + mode_source = self.ACCEPTED_MODES[mode] + else: + raise WrongParams("{0} is not an accepted mode type".format(mode)) + + if mode == self.ACCEPTED_MODES['car'] and 'mode_type' in options and \ + options['mode_type'] == 'shortest': + mode = self.AUTO_SHORTEST + + return mode + + +class MapzenRoutingResponse: + + def __init__(self, shape, length, duration): + self._shape = shape + self._length = length + self._duration = duration + + @property + def shape(self): + return self._shape + + @property + def length(self): + return self._length + + @property + def duration(self): + return self._duration diff --git a/server/lib/python/cartodb_services/cartodb_services/mapzen/types.py b/server/lib/python/cartodb_services/cartodb_services/mapzen/types.py new file mode 100644 index 0000000..bfaef3b --- /dev/null +++ b/server/lib/python/cartodb_services/cartodb_services/mapzen/types.py @@ -0,0 +1,16 @@ +import plpy + + +def polyline_to_linestring(polyline): + """Convert a Mapzen polyline shape to a PostGIS multipolygon""" + coordinates = [] + for point in polyline: + # Divide by 10 because mapzen uses one more decimal than the + # google standard (https://mapzen.com/documentation/turn-by-turn/decoding/) + coordinates.append("%s %s" % (point[1]/10, point[0]/10)) + wkt_coordinates = ','.join(coordinates) + + sql = "SELECT ST_GeomFromText('LINESTRING({0})', 4326) as geom".format(wkt_coordinates) + geometry = plpy.execute(sql, 1)[0]['geom'] + + return geometry diff --git a/server/lib/python/cartodb_services/cartodb_services/metrics/__init__.py b/server/lib/python/cartodb_services/cartodb_services/metrics/__init__.py index 208b2be..af7bed5 100644 --- a/server/lib/python/cartodb_services/cartodb_services/metrics/__init__.py +++ b/server/lib/python/cartodb_services/cartodb_services/metrics/__init__.py @@ -1,3 +1,3 @@ -from config import GeocoderConfig, IsolinesRoutingConfig, InternalGeocoderConfig, ConfigException +from config import GeocoderConfig, IsolinesRoutingConfig, InternalGeocoderConfig, RoutingConfig, ConfigException from quota import QuotaService from user import UserMetricsService diff --git a/server/lib/python/cartodb_services/cartodb_services/metrics/config.py b/server/lib/python/cartodb_services/cartodb_services/metrics/config.py index 2e395c5..d2b3b80 100644 --- a/server/lib/python/cartodb_services/cartodb_services/metrics/config.py +++ b/server/lib/python/cartodb_services/cartodb_services/metrics/config.py @@ -27,6 +27,27 @@ class ServiceConfig(object): def organization(self): return self._orgname +class RoutingConfig(ServiceConfig): + + ROUTING_CONFIG_KEYS = ['username', 'orgname', 'mapzen_app_key'] + MAPZEN_APP_KEY = 'mapzen_app_key' + USERNAME_KEY = 'username' + ORGNAME_KEY = 'orgname' + + def __init__(self, redis_connection, username, orgname=None, + mapzen_app_key=None): + super(RoutingConfig, self).__init__(redis_connection, username, + orgname) + self._mapzen_app_key = mapzen_app_key + + @property + def service_type(self): + return 'routing_mapzen' + + @property + def mapzen_app_key(self): + return self._mapzen_app_key + class IsolinesRoutingConfig(ServiceConfig): diff --git a/server/lib/python/cartodb_services/cartodb_services/tools/__init__.py b/server/lib/python/cartodb_services/cartodb_services/tools/__init__.py index dc49770..200dcd4 100644 --- a/server/lib/python/cartodb_services/cartodb_services/tools/__init__.py +++ b/server/lib/python/cartodb_services/cartodb_services/tools/__init__.py @@ -1 +1,2 @@ from redis_tools import RedisConnection +from coordinates import Coordinate \ No newline at end of file diff --git a/server/lib/python/cartodb_services/cartodb_services/tools/coordinates.py b/server/lib/python/cartodb_services/cartodb_services/tools/coordinates.py new file mode 100644 index 0000000..c8c0748 --- /dev/null +++ b/server/lib/python/cartodb_services/cartodb_services/tools/coordinates.py @@ -0,0 +1,19 @@ +class Coordinate: + """Class that represents a generic form of coordinates to be used + by the services """ + + def __init__(self, longitude, latitude): + self._longitude = longitude + self._latitude = latitude + + @property + def latitude(self): + return self._latitude + + @property + def longitude(self): + return self._longitude + + def to_json(self): + return "{{\"lon\": {0},\"lat\": {1}}}".format(self._longitude, + self._latitude) diff --git a/server/lib/python/cartodb_services/cartodb_services/tools/redis_tools.py b/server/lib/python/cartodb_services/cartodb_services/tools/redis_tools.py index 24888dd..cc04f9b 100644 --- a/server/lib/python/cartodb_services/cartodb_services/tools/redis_tools.py +++ b/server/lib/python/cartodb_services/cartodb_services/tools/redis_tools.py @@ -6,8 +6,6 @@ class RedisConnection: REDIS_DEFAULT_USER_DB = 5 REDIS_DEFAULT_TIMEOUT = 2 #seconds - #REDIS_SENTINEL_DEFAULT_PORT = 26379 - #REDIS_DEFAULT_PORT = 6379 def __init__(self, sentinel_master_id, redis_host, redis_port, redis_db=REDIS_DEFAULT_USER_DB, **kwargs): diff --git a/server/lib/python/cartodb_services/requirements.txt b/server/lib/python/cartodb_services/requirements.txt index 17e41c8..1363d69 100644 --- a/server/lib/python/cartodb_services/requirements.txt +++ b/server/lib/python/cartodb_services/requirements.txt @@ -5,6 +5,7 @@ python-dateutil==2.2 googlemaps==2.4.2 # Dependency for googlemaps package requests<=2.9.1 +polyline==1.1 # Test mock==1.3.0 diff --git a/server/lib/python/cartodb_services/setup.py b/server/lib/python/cartodb_services/setup.py index 3774847..408f147 100644 --- a/server/lib/python/cartodb_services/setup.py +++ b/server/lib/python/cartodb_services/setup.py @@ -10,7 +10,7 @@ from setuptools import setup, find_packages setup( name='cartodb_services', - version='0.2.0', + version='0.3.0', description='CartoDB Services API Python Library', diff --git a/server/lib/python/cartodb_services/test/test_heremapsrouting.py b/server/lib/python/cartodb_services/test/test_heremapsrouting.py index 1d27f30..3b30b7b 100644 --- a/server/lib/python/cartodb_services/test/test_heremapsrouting.py +++ b/server/lib/python/cartodb_services/test/test_heremapsrouting.py @@ -148,7 +148,6 @@ class HereMapsRoutingIsolineTestCase(unittest.TestCase): u'32.9699707,0.9462833']) def test_calculate_isochrone_with_valid_params(self, req_mock): - print self.isoline_url url = "{0}?start=geo%2133.0%2C1.0&mode=shortest%3Bcar".format(self.isoline_url) req_mock.register_uri('GET', url, text=self.GOOD_RESPONSE) response = self.routing.calculate_isochrone('geo!33.0,1.0', 'car', diff --git a/server/lib/python/cartodb_services/test/test_mapzenrouting.py b/server/lib/python/cartodb_services/test/test_mapzenrouting.py new file mode 100644 index 0000000..5e76536 --- /dev/null +++ b/server/lib/python/cartodb_services/test/test_mapzenrouting.py @@ -0,0 +1,77 @@ +#!/usr/local/bin/python +# -*- coding: utf-8 -*- + +import unittest +import requests_mock +import re +from nose.tools import assert_raises +from urlparse import urlparse, parse_qs + +from cartodb_services.mapzen import MapzenRouting, MapzenRoutingResponse +from cartodb_services.mapzen.exceptions import WrongParams +from cartodb_services.tools import Coordinate + +requests_mock.Mocker.TEST_PREFIX = 'test_' + + +@requests_mock.Mocker() +class MapzenRoutingTestCase(unittest.TestCase): + + GOOD_SHAPE = [(38.5, -120.2), (43.2, -126.4)] + + GOOD_RESPONSE = """{{ + "id": "ethervoid-route", + "trip": {{ + "status": 0, + "status_message": "Found route between points", + "legs": [ + {{ + "shape": "_p~iF~ps|U_~t[~|yd@", + "summary": {{ + "length": 444.59, + "time": 16969 + }} + }} + ], + "units": "kilometers", + "summary": {{ + "length": 444.59, + "time": 16969 + }}, + "locations": [ + {{ + "lon": -120.2, + "lat": 38.5, + "type": "break" + }}, + {{ + "lon": -126.4, + "lat": 43.2, + "type": "break" + }} + ] + }} + }}""".format(GOOD_SHAPE) + + MALFORMED_RESPONSE = """{"manolo": "escobar"}""" + + def setUp(self): + self.routing = MapzenRouting('api_key') + self.url = MapzenRouting.PRODUCTION_ROUTING_BASE_URL + + def test_calculate_simple_routing_with_valid_params(self, req_mock): + req_mock.register_uri('GET', requests_mock.ANY, text=self.GOOD_RESPONSE) + origin = Coordinate('-120.2','38.5') + destination = Coordinate('-126.4','43.2') + response = self.routing.calculate_route_point_to_point(origin, destination,'car') + + self.assertEqual(response.shape, self.GOOD_SHAPE) + self.assertEqual(response.length, 444.59) + self.assertEqual(response.duration, 16969) + + def test_uknown_mode_raise_exception(self, req_mock): + req_mock.register_uri('GET', requests_mock.ANY, text=self.GOOD_RESPONSE) + origin = Coordinate('-120.2','38.5') + destination = Coordinate('-126.4','43.2') + + assert_raises(WrongParams, self.routing.calculate_route_point_to_point, origin, destination, 'unknown')