import asyncio import collections.abc import enum import logging import numbers import random try: import ujson as json except ImportError: import json from urllib.parse import urlencode logger = logging.getLogger(__name__) try: import aiohttp except ImportError: aiohttp = None try: import requests except ImportError: requests = None if not (aiohttp or requests): logger.error('Could not import none of modules \'aiohttp\' or \'requests\'') class overview(enum.Enum): simplified = 'simplified' full = 'full' false = 'false' # alias for avoiding name collision osrm_overview = overview class geometries(enum.Enum): polyline = 'polyline' polyline6 = 'polyline6' geojson = 'geojson' # alias for avoiding name collision osrm_geometries = geometries class gaps(enum.Enum): split = 'split' ignore = 'ignore' # alias for avoiding name collision osrm_gaps = gaps class continue_straight(enum.Enum): default = 'default' true = 'true' false = 'false' # alias for avoiding name collision osrm_continue_straight = continue_straight class OSRMException(Exception): pass class OSRMServerException(OSRMException): pass class OSRMClientException(OSRMException): pass def _check_pairs(items): ''' checking that 'items' has format [[Number, Number], ...]''' return ( isinstance(items, collections.abc.Iterable) and all([isinstance(p, collections.abc.Iterable) for p in items]) and all([ isinstance(p[0], numbers.Number) and isinstance(p[1], numbers.Number) and len(p) == 2 for p in items])) class BaseRequest: def __init__(self, coordinates, radiuses=[], bearings=[], hints=[]): assert _check_pairs(coordinates), \ '''coordinates must be in format [[longitude, latitude],...]''' assert all([ -180 <= lon <= 180 and -90 <= lat <= 90 for lon, lat in coordinates]), \ ''''longitude' should be -180..180 and 'latitude' should be -90..90 (actual: {})'''.format(coordinates) assert _check_pairs(bearings), \ '''bearings must be in format [[value, range],...]''' assert all([ 0 <= bvalue <= 360 and 0 <= brange <= 180 for bvalue, brange in bearings]), \ '''bearing 'value' should be 0..360 and 'range' should be 0..180 (actual: {})'''.format(bearings) assert isinstance(radiuses, list) assert isinstance(bearings, list) assert isinstance(hints, list) self.coordinates = coordinates self.radiuses = radiuses self.bearings = bearings self.hints = hints def get_coordinates(self): return self._encode_pairs(self.coordinates) def get_options(self): return { 'radiuses': self._encode_array(self.radiuses), 'bearings': self._encode_pairs(self.bearings), 'hints': self._encode_array(self.hints) } def _encode_array(self, value): return ';'.join(map(lambda x: str(x) if x else "", value)) def _encode_bool(self, value): return 'true' if value else 'false' def _encode_pairs(self, coordinates): return ';'.join([','.join(map(str, coord)) for coord in coordinates]) def decode_response(self, url, status, response): if status == 200: return json.loads(response) elif status == 400: raise OSRMClientException(json.loads(response)) raise OSRMServerException(url, response) class NearestRequest(BaseRequest): service = 'nearest' def __init__(self, number=1, **kwargs): super().__init__(**kwargs) self.number = number def get_options(self): options = super().get_options() options['number'] = self.number return options class RouteRequest(BaseRequest): service = 'route' def __init__( self, alternatives=False, steps=False, annotations=False, geometries=geometries.geojson, overview=overview.simplified, continue_straight=continue_straight.default, **kwargs): super().__init__(**kwargs) assert isinstance(alternatives, bool) assert isinstance(steps, bool) assert isinstance(annotations, bool) assert isinstance(geometries, osrm_geometries) assert isinstance(overview, osrm_overview) assert isinstance(continue_straight, osrm_continue_straight) self.alternatives = alternatives self.steps = steps self.annotations = annotations self.geometries = geometries self.overview = overview self.continue_straight = continue_straight def get_options(self): options = super().get_options() options.update({ 'alternatives': self._encode_bool(self.alternatives), 'steps': self._encode_bool(self.steps), 'annotations': self._encode_bool(self.annotations), 'geometries': self.geometries.value, 'overview': self.overview.value }) if self.continue_straight != continue_straight.default: options['continue_straight'] = self.continue_straight.value return options class MatchRequest(RouteRequest): service = 'match' def __init__( self, timestamps=[], gaps=gaps.split, tidy=False, **kwargs): super().__init__(**kwargs) assert isinstance(timestamps, list) assert isinstance(gaps, osrm_gaps) assert isinstance(tidy, bool) self.timestamps = timestamps self.gaps = gaps self.tidy = tidy def get_options(self): options = super().get_options() options.pop('alternatives', None) options['timestamps'] = self._encode_array(self.timestamps) # Don't send default values (for compatibility with 5.6) if self.gaps.value != osrm_gaps.split: options['gaps'] = self.gaps.value if self.tidy: options['tidy'] = self._encode_bool(self.tidy) return options class BaseClient: def __init__( self, host='http://localhost:5000', version='v1', profile='driving', timeout=5, max_retries=5): assert isinstance(host, str) assert isinstance(version, str) assert isinstance(profile, str) assert isinstance(timeout, numbers.Number) assert isinstance(max_retries, int) and max_retries >= 1 self.host = host self.version = version self.profile = profile self.timeout = timeout self.max_retries = max_retries def _build_request(self, request): url = '{host}/{service}/{version}/{profile}/{coordinates}'.format( host=self.host, service=request.service, version=self.version, profile=self.profile, coordinates=request.get_coordinates()) params = { k: v for k, v in request.get_options().items() if v } logger.debug('request url=%s; params=%s', url, params) return (url, params) class Client(BaseClient): def __init__(self, *args, session=None, **kwargs): super().__init__(*args, **kwargs) if not requests: raise RuntimeError('Module \'requests\' is not available') self.session = session or requests.Session() self.a = requests.adapters.HTTPAdapter(max_retries=self.max_retries) self.session.mount('http://', self.a) def nearest(self, **kwargs): return self._request( NearestRequest(**kwargs) ) def route(self, **kwargs): return self._request( RouteRequest(**kwargs) ) def match(self, **kwargs): return self._request( MatchRequest(**kwargs) ) def _request(self, request): if not requests: raise RuntimeError('Module \'requests\' is not available') url, params = self._build_request(request) response = self.session.get(url, params=params, timeout=self.timeout) return request.decode_response(url, response.status_code, response.text) class AioHTTPClient(BaseClient): BACKOFF_MAX = 120 BACKOFF_FACTOR = 0.5 def __init__(self, *args, session=None, loop=None, **kwargs): super().__init__(*args, **kwargs) if not aiohttp: raise RuntimeError('Module \'aiohttp\' is not available') if not session: self.loop = loop or asyncio.get_event_loop() self.session = aiohttp.ClientSession(loop=self.loop) else: self.session = session async def nearest(self, **kwargs): return await self._request( NearestRequest(**kwargs) ) async def route(self, **kwargs): return await self._request( RouteRequest(**kwargs) ) async def match(self, **kwargs): return await self._request( MatchRequest(**kwargs) ) def exp_backoff(self, attempt): timeout = min(self.timeout * (2 ** attempt), self.BACKOFF_MAX) return timeout + random.uniform(0, self.BACKOFF_FACTOR * timeout) async def _request(self, request): url, params = self._build_request(request) attempt = 0 while attempt < self.max_retries: try: # This is a workaround for the https://github.com/aio-libs/aiohttp/issues/1901 request_url = "{}?{}".format(url, urlencode(params)) async with self.session.get( request_url, timeout=self.timeout) as response: body = await response.text() return request.decode_response( response.url, response.status, body) except asyncio.TimeoutError: timeout = self.exp_backoff(attempt) logger.info( 'Timeout error url=%s (remaining tries %s, sleeping %.2f secs)', url, self.max_retries - attempt, timeout) await asyncio.sleep(timeout) attempt += 1 raise OSRMServerException(url, 'server timeout') async def close(self): await self.session.close()