Skip to content

Commit 5ce39d9

Browse files
committed
Add departure and arrival datetime to Direction
1 parent 53dfbeb commit 5ce39d9

File tree

8 files changed

+411
-77
lines changed

8 files changed

+411
-77
lines changed

routingpy/direction.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"""
1818
:class:`.Direction` returns directions results.
1919
"""
20+
import datetime
2021
from typing import List, Optional
2122

2223

@@ -65,7 +66,15 @@ class Direction(object):
6566
Contains a parsed directions' response. Access via properties ``geometry``, ``duration`` and ``distance``.
6667
"""
6768

68-
def __init__(self, geometry=None, duration=None, distance=None, raw=None):
69+
def __init__(
70+
self,
71+
geometry: List[List[float]] = None,
72+
duration: int = None,
73+
distance: int = None,
74+
departure_datetime: datetime.datetime = None,
75+
arrival_datetime: datetime.datetime = None,
76+
raw: dict = None,
77+
):
6978
"""
7079
Initialize a :class:`Direction` object to hold the properties of a directions request.
7180
@@ -78,13 +87,21 @@ def __init__(self, geometry=None, duration=None, distance=None, raw=None):
7887
:param distance: The distance of the direction in meters.
7988
:type distance: int
8089
90+
:param departure_datetime: The departure timezone aware date and time of the direction.
91+
:type departure_datetime: datetime.datetime
92+
93+
:param arrival_datetime: The arrival timezone aware date and time of the direction.
94+
:type arrival_datetime: datetime.datetime
95+
8196
:param raw: The raw response of an individual direction (for multiple alternative routes) or the whole direction
8297
response.
8398
:type raw: dict
8499
"""
85100
self._geometry = geometry
86101
self._duration = duration
87102
self._distance = distance
103+
self._departure_datetime = departure_datetime
104+
self._arrival_datetime = arrival_datetime
88105
self._raw = raw
89106

90107
@property
@@ -114,6 +131,24 @@ def distance(self) -> int:
114131
"""
115132
return self._distance
116133

134+
@property
135+
def departure_datetime(self) -> Optional[datetime.datetime]:
136+
"""
137+
The departure timezone aware date and time of the direction.
138+
139+
:rtype: datetime.datetime or None
140+
"""
141+
return self._departure_datetime
142+
143+
@property
144+
def arrival_datetime(self) -> Optional[datetime.datetime]:
145+
"""
146+
The arrival timezone aware date and time of the direction.
147+
148+
:rtype: datetime.datetime or None
149+
"""
150+
return self._arrival_datetime
151+
117152
@property
118153
def raw(self) -> Optional[dict]:
119154
"""

routingpy/routers/google.py

Lines changed: 58 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@
1515
# the License.
1616
#
1717

18+
import datetime
1819
from operator import itemgetter
1920
from typing import List, Optional, Tuple, Union
2021

22+
import pytz
23+
2124
from .. import convert, utils
2225
from ..client_base import DEFAULT
2326
from ..client_default import Client
@@ -319,12 +322,42 @@ def directions( # noqa: C901
319322
if transit_routing_preference:
320323
params["transit_routing_preference"] = transit_routing_preference
321324

322-
return self.parse_direction_json(
325+
return self._parse_direction_json(
323326
self.client._request("/directions/json", get_params=params, dry_run=dry_run), alternatives
324327
)
325328

326-
@staticmethod
327-
def parse_direction_json(response, alternatives):
329+
def _time_object_to_aware_datetime(self, time_object):
330+
timestamp = time_object["value"]
331+
dt = datetime.datetime.fromtimestamp(timestamp)
332+
timezone = pytz.timezone(time_object["time_zone"])
333+
return dt.astimezone(timezone)
334+
335+
def _parse_legs(self, legs):
336+
duration = 0
337+
distance = 0
338+
geometry = []
339+
departure_datetime = None
340+
arrival_datetime = None
341+
342+
for leg in legs:
343+
departure_time = leg.get("departure_time")
344+
if departure_time:
345+
assert len(legs) == 1, "departure_time is only supported for single leg routes"
346+
departure_datetime = self._time_object_to_aware_datetime(departure_time)
347+
348+
arrival_time = leg.get("arrival_time")
349+
if arrival_time:
350+
assert len(legs) == 1, "arrival_time is only supported for single leg routes"
351+
arrival_datetime = self._time_object_to_aware_datetime(arrival_time)
352+
353+
duration += leg["duration"]["value"]
354+
distance += leg["distance"]["value"]
355+
for step in leg["steps"]:
356+
geometry.extend(utils.decode_polyline5(step["polyline"]["points"]))
357+
358+
return duration, distance, geometry, departure_datetime, arrival_datetime
359+
360+
def _parse_direction_json(self, response, alternatives):
328361
if response is None: # pragma: no cover
329362
if alternatives:
330363
return Directions()
@@ -345,33 +378,27 @@ def parse_direction_json(response, alternatives):
345378

346379
raise error(STATUS_CODES[status]["code"], STATUS_CODES[status]["message"])
347380

348-
if alternatives:
349-
routes = []
350-
for route in response["routes"]:
351-
geometry = []
352-
duration, distance = 0, 0
353-
for leg in route["legs"]:
354-
duration += leg["duration"]["value"]
355-
distance += leg["distance"]["value"]
356-
for step in leg["steps"]:
357-
geometry.extend(utils.decode_polyline5(step["polyline"]["points"]))
358-
359-
routes.append(
360-
Direction(
361-
geometry=geometry, duration=int(duration), distance=int(distance), raw=route
362-
)
381+
directions = []
382+
for route in response["routes"]:
383+
duration, distance, geometry, departure_datetime, arrival_datetime = self._parse_legs(
384+
route["legs"]
385+
)
386+
directions.append(
387+
Direction(
388+
geometry=geometry,
389+
duration=int(duration),
390+
distance=int(distance),
391+
departure_datetime=departure_datetime,
392+
arrival_datetime=arrival_datetime,
393+
raw=route,
363394
)
364-
return Directions(routes, response)
365-
else:
366-
geometry = []
367-
duration, distance = 0, 0
368-
for leg in response["routes"][0]["legs"]:
369-
duration += leg["duration"]["value"]
370-
distance += leg["distance"]["value"]
371-
for step in leg["steps"]:
372-
geometry.extend(utils.decode_polyline5(step["polyline"]["points"]))
373-
374-
return Direction(geometry=geometry, duration=duration, distance=distance, raw=response)
395+
)
396+
397+
if alternatives:
398+
return Directions(directions, raw=response)
399+
400+
elif directions:
401+
return directions[0]
375402

376403
def isochrones(self): # pragma: no cover
377404
raise NotImplementedError
@@ -506,12 +533,11 @@ def matrix( # noqa: C901
506533
if transit_routing_preference:
507534
params["transit_routing_preference"] = transit_routing_preference
508535

509-
return self.parse_matrix_json(
536+
return self._parse_matrix_json(
510537
self.client._request("/distancematrix/json", get_params=params, dry_run=dry_run)
511538
)
512539

513-
@staticmethod
514-
def parse_matrix_json(response):
540+
def _parse_matrix_json(self, response):
515541
if response is None: # pragma: no cover
516542
return Matrix()
517543

routingpy/routers/opentripplanner_v2.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# the License.
1616
#
1717
import datetime
18-
from typing import List, Optional # noqa: F401
18+
from typing import List, Optional
1919

2020
from .. import convert, utils
2121
from ..client_base import DEFAULT
@@ -167,30 +167,35 @@ def directions(
167167
)
168168
return self._parse_directions_response(response, num_itineraries)
169169

170+
def _timestamp_to_utc_datetime(self, timestamp):
171+
dt = datetime.datetime.fromtimestamp(timestamp / 1000)
172+
return dt.astimezone(datetime.timezone.utc)
173+
170174
def _parse_directions_response(self, response, num_itineraries):
171175
if response is None: # pragma: no cover
172176
return Directions() if num_itineraries > 1 else Direction()
173177

174-
routes = []
178+
directions = []
175179
for itinerary in response["data"]["plan"]["itineraries"]:
176-
geometry, distance = self._parse_legs(itinerary["legs"])
177-
routes.append(
180+
distance, geometry = self._parse_legs(itinerary["legs"])
181+
departure_datetime = self._timestamp_to_utc_datetime(itinerary["startTime"])
182+
arrival_datetime = self._timestamp_to_utc_datetime(itinerary["endTime"])
183+
directions.append(
178184
Direction(
179185
geometry=geometry,
180186
duration=int(itinerary["duration"]),
181187
distance=distance,
188+
departure_datetime=departure_datetime,
189+
arrival_datetime=arrival_datetime,
182190
raw=itinerary,
183191
)
184192
)
185193

186194
if num_itineraries > 1:
187-
return Directions(routes, raw=response)
188-
189-
elif routes:
190-
return routes[0]
195+
return Directions(directions, raw=response)
191196

192-
else:
193-
return Direction()
197+
elif directions:
198+
return directions[0]
194199

195200
def _parse_legs(self, legs):
196201
distance = 0
@@ -200,7 +205,7 @@ def _parse_legs(self, legs):
200205
geometry.extend(list(reversed(points)))
201206
distance += int(leg["distance"])
202207

203-
return geometry, distance
208+
return distance, geometry
204209

205210
def isochrones(
206211
self,

tests/test_base.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ def test_skip_api_error(self):
8484
)
8585

8686
client = ClientMock(base_url="https://httpbin.org", skip_api_error=False)
87-
print(client.skip_api_error)
8887
with self.assertRaises(routingpy.exceptions.RouterApiError):
8988
client.directions(url="/post", post_params=self.params)
9089

tests/test_google.py

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def test_full_directions(self):
4747
content_type="application/json",
4848
)
4949

50-
routes = self.client.directions(**query)
50+
directions = self.client.directions(**query)
5151
self.assertEqual(1, len(responses.calls))
5252
self.assertURLEqual(
5353
"https://maps.googleapis.com/maps/api/directions/json?alternatives=true&arrival_time=1567512000&"
@@ -57,12 +57,44 @@ def test_full_directions(self):
5757
responses.calls[0].request.url,
5858
)
5959

60-
self.assertIsInstance(routes, Directions)
61-
self.assertIsInstance(routes[0], Direction)
62-
self.assertIsInstance(routes[0].geometry, list)
63-
self.assertIsInstance(routes[0].distance, int)
64-
self.assertIsInstance(routes[0].duration, int)
65-
self.assertIsInstance(routes[0].raw, dict)
60+
self.assertIsInstance(directions, Directions)
61+
self.assertIsInstance(directions[0], Direction)
62+
self.assertIsInstance(directions[0].geometry, list)
63+
self.assertIsInstance(directions[0].distance, int)
64+
self.assertIsInstance(directions[0].duration, int)
65+
self.assertIsInstance(directions[0].raw, dict)
66+
67+
@responses.activate
68+
def test_directions_transit(self):
69+
query = ENDPOINTS_QUERIES[self.name]["directions_transit"]
70+
71+
responses.add(
72+
responses.GET,
73+
"https://maps.googleapis.com/maps/api/directions/json",
74+
status=200,
75+
json=ENDPOINTS_RESPONSES[self.name]["directions_transit"],
76+
content_type="application/json",
77+
)
78+
79+
direction = self.client.directions(**query)
80+
self.assertEqual(1, len(responses.calls))
81+
self.assertURLEqual(
82+
"https://maps.googleapis.com/maps/api/directions/json?arrival_time=1691064000&"
83+
"destination=49.415776%2C8.680916&key=sample_key&mode=transit&"
84+
"origin=49.420577%2C8.688641",
85+
responses.calls[0].request.url,
86+
)
87+
88+
self.assertIsInstance(direction, Direction)
89+
self.assertIsInstance(direction, Direction)
90+
self.assertIsInstance(direction.geometry, list)
91+
self.assertIsInstance(direction.distance, int)
92+
self.assertIsInstance(direction.duration, int)
93+
self.assertIsInstance(direction.departure_datetime, datetime.datetime)
94+
self.assertEqual(direction.departure_datetime.tzinfo.zone, "Europe/Berlin")
95+
self.assertIsInstance(direction.arrival_datetime, datetime.datetime)
96+
self.assertEqual(direction.arrival_datetime.tzinfo.zone, "Europe/Berlin")
97+
self.assertIsInstance(direction.raw, dict)
6698

6799
@responses.activate
68100
def test_full_directions_no_alternatives(self):
@@ -77,7 +109,7 @@ def test_full_directions_no_alternatives(self):
77109
content_type="application/json",
78110
)
79111

80-
routes = self.client.directions(**query)
112+
direction = self.client.directions(**query)
81113
self.assertEqual(1, len(responses.calls))
82114
self.assertURLEqual(
83115
"https://maps.googleapis.com/maps/api/directions/json?alternatives=false&arrival_time=1567512000&"
@@ -87,11 +119,11 @@ def test_full_directions_no_alternatives(self):
87119
responses.calls[0].request.url,
88120
)
89121

90-
self.assertIsInstance(routes, Direction)
91-
self.assertIsInstance(routes.geometry, list)
92-
self.assertIsInstance(routes.duration, int)
93-
self.assertIsInstance(routes.distance, int)
94-
self.assertIsInstance(routes.raw, dict)
122+
self.assertIsInstance(direction, Direction)
123+
self.assertIsInstance(direction.geometry, list)
124+
self.assertIsInstance(direction.duration, int)
125+
self.assertIsInstance(direction.distance, int)
126+
self.assertIsInstance(direction.raw, dict)
95127

96128
@responses.activate
97129
def test_waypoint_generator_directions(self):
@@ -240,11 +272,11 @@ def test_status_codes(self):
240272

241273
for alternatives in [True, False]:
242274
with self.assertRaises(RouterApiError):
243-
self.client.parse_direction_json(
275+
self.client._parse_direction_json(
244276
error_responses["ZERO_RESULTS"], alternatives=alternatives
245277
)
246278

247279
with self.assertRaises(RouterServerError):
248-
self.client.parse_direction_json(
280+
self.client._parse_direction_json(
249281
error_responses["UNKNOWN_ERROR"], alternatives=alternatives
250282
)

0 commit comments

Comments
 (0)