From 596675ec27c3a5439b1a4ec382908dd7f8efb083 Mon Sep 17 00:00:00 2001 From: Lucidiot Date: Sun, 3 Feb 2019 21:12:17 +0000 Subject: [PATCH] Much more exhaustive delivery data --- VERSION | 2 +- docs/api.rst | 9 ++ urbantz/base.py | 2 - urbantz/delivery.py | 218 +++++++++++++++++++++++++++++++- urbantz/items.py | 132 +++++++++++++++++++ urbantz/tests/test_full.py | 202 +++++++++++++++++++++++++++++ urbantz/tests/test_payload.json | 152 ++++++++++++++++++++++ urbantz/tests/test_utils.py | 82 ++++++++++++ urbantz/tracker.py | 3 +- urbantz/utils.py | 66 ++++++++++ 10 files changed, 858 insertions(+), 10 deletions(-) create mode 100644 urbantz/items.py create mode 100644 urbantz/tests/test_full.py create mode 100644 urbantz/tests/test_payload.json create mode 100644 urbantz/tests/test_utils.py create mode 100644 urbantz/utils.py diff --git a/VERSION b/VERSION index 17e51c3..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.1 +0.2.0 diff --git a/docs/api.rst b/docs/api.rst index 4b6378f..0f3dea3 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -7,6 +7,12 @@ Deliveries .. automodule:: urbantz.delivery :members: +Delivery items +-------------- + +.. automodule:: urbantz.items + :members: + Exceptions ---------- @@ -18,3 +24,6 @@ Helper classes .. automodule:: urbantz.base :members: + +.. automodule:: urbantz.utils + :members: diff --git a/urbantz/base.py b/urbantz/base.py index c323980..6f9a109 100644 --- a/urbantz/base.py +++ b/urbantz/base.py @@ -33,8 +33,6 @@ class Coordinates(JSONSerializable): def __init__(self, lat=0, lng=0): """ - Get coordinates from decimal degrees. - :param float lat: Latitude in decimal degrees. :param float lng: Longitude in decimal degrees. """ diff --git a/urbantz/delivery.py b/urbantz/delivery.py index e548805..fccc555 100644 --- a/urbantz/delivery.py +++ b/urbantz/delivery.py @@ -1,8 +1,25 @@ -from datetime import datetime +from datetime import datetime, timedelta from urbantz.base import JSONSerializable, Coordinates +from urbantz.utils import DictObject +from urbantz.items import LogEntry, Item from urbantz.exceptions import APIError import requests +DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' + + +class Location(DictObject): + """ + A delivery destination. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if 'location' not in self: + return + point = self.pop('location')['geometry'] + self['coordinates'] = Coordinates.fromJSON(point) + class Delivery(JSONSerializable): """ @@ -37,18 +54,156 @@ class Delivery(JSONSerializable): :type: dict or None """ + self.id = None + """ + Identifier for this delivery. + + :type: str or None + """ + + self.date = None + """ + Date of the delivery. + + :type: date or None + """ + + self.task_id = None + """ + Task identifier for this delivery. + + :type: str or None + """ + + self.platform_id = None + """ + Platform identifier for this delivery. + + :type: str or None + """ + + self.driver_id = None + """ + Driver identifier for this delivery. + + :type: str or None + """ + + self.round_id = None + """ + Driver round identifier for this delivery. + + :type: str or None + """ + + self.instructions = None + """ + Delivery instructions given to the driver. + + :type: str or None + """ + + self.progress = None + """ + The delivery order progress. + + :type: str or None + """ + + self.status = None + """ + The delivery status. + + :type: str or None + """ + + self.arrival_time = None + """ + Estimated or actual arrival time. + + :type: datetime or None + """ + + self.eta_margin = None + """ + Margin, in minutes, for the estimated time of arrival. + + :type: int or None + """ + + self.eta_rounding = None + """ + Rounding, in minutes, for the estimated time of arrival. + + :type: int or None + """ + + self.time_window = None + """ + Planned delivery time window (start and end datetimes) + + :type: list(datetime) or None + """ + self.position = None """ Coordinates of the delivery truck's position. - :type: urbantz.base.Coordinates + :type: urbantz.base.Coordinates or None """ self.destination = None """ - Coordinates of the delivery destination. + The delivery's destination. - :type: urbantz.base.Coordinates + :type: urbantz.delivery.Location or None + """ + + self.recipient = None + """ + Informations about the recipient (name, language, phone number, etc.) + Does not contain the destination location information. + + :type: urbantz.utils.DictObject or None + """ + + self.features = None + """ + Dictionary of booleans indicating which features of the UrbanTZ + tracking software are enabled on this delivery. + For example, ``consumerModifyInstructions`` will indicate whether + the client is allowed to update the delivery instructions after the + driver departs for its round. + + :type: urbantz.utils.DictObject or None + """ + + self.items = None + """ + List of delivery items. + + :type: list(urbantz.items.Item) + """ + + self.logs = None + """ + List of status update logs for the delivery. + + :type: list(urbantz.items.LogEntry) + """ + + self.theme = None + """ + Front-end theming information for the delivery tracking page. + + :type: urbantz.utils.DictObject or None + """ + + self.template = None + """ + Front-end template information for the delivery tracking page. + + :type: urbantz.utils.DictObject or None """ def __repr__(self): @@ -65,6 +220,25 @@ class Delivery(JSONSerializable): return 'https://backend.urbantz.com/public/task/tracking/{}'.format( self.tracking_code) + @property + def eta(self): + """ + Estimated time of arrival: start and end datetimes, computed from the + arrival time and rounding settings + + :type: list(datetime) or None + """ + if not (self.eta_margin and self.eta_rounding and self.arrival_time): + return + start = self.arrival_time - timedelta(minutes=self.eta_margin / 2) + start -= timedelta( + minutes=start.minute % self.eta_rounding, + seconds=start.second, + microseconds=start.microsecond, + ) + end = start + timedelta(minutes=self.eta_margin) + return [start, end] + def update(self): """ Fetch the latest delivery information from the API. @@ -101,9 +275,41 @@ class Delivery(JSONSerializable): :param dict payload: A parsed JSON payload from the API. """ self.payload = payload + self.id = self.payload['_id'] + self.date = datetime.strptime( + self.payload['date'][:10], "%Y-%m-%d").date() + self.task_id = self.payload['taskId'] + self.platform_id = self.payload['platform'] + self.driver_id = self.payload['driver'] + self.round_id = self.payload['round'] + self.by = self.payload['by'] + self.instructions = self.payload['instructions'] + self.progress = self.payload['progress'] + self.status = self.payload['status'] + + self.arrival_time = datetime.strptime( + self.payload['arriveTime'], DATE_FORMAT) + self.eta_margin = self.payload['eta']['margin'] + self.eta_rounding = self.payload['eta']['rounding'] + self.time_window = [ + datetime.strptime( + self.payload['timeWindow']['start'], + DATE_FORMAT, + ), + datetime.strptime( + self.payload['timeWindow']['stop'], + DATE_FORMAT, + ), + ] + self.position = Coordinates.fromJSON(self.payload['position']) - self.destination = Coordinates.fromJSON( - self.payload['location']['location']['geometry']) + self.destination = Location.fromJSON(self.payload['location']) + self.recipient = DictObject(self.payload['contact']) + self.features = DictObject(self.payload['features']) + self.template = DictObject(self.payload['template']) + self.theme = DictObject(self.payload['theme']) + self.logs = list(map(LogEntry.fromJSON, self.payload['log'])) + self.items = list(map(Item.fromJSON, self.payload['items'])) @classmethod def fromJSON(cls, payload): diff --git a/urbantz/items.py b/urbantz/items.py new file mode 100644 index 0000000..2919f3a --- /dev/null +++ b/urbantz/items.py @@ -0,0 +1,132 @@ +from datetime import datetime +from urbantz.base import JSONSerializable +from urbantz.utils import DictObject + + +class LogEntry(DictObject): + """ + A log entry for a delivery or a delivery item that logs status changes. + + - ``id`` : Unique identifier of the log entry + - ``by`` : Unique identifier of the author of the change. Nullable. + - ``when`` : Date/time of the log entry as a ISO 8601 string + - ``to`` : Updated status + """ + + @property + def datetime(self): + """ + Datetime for the log entry. + """ + from urbantz.delivery import DATE_FORMAT + return datetime.strptime(self.when, DATE_FORMAT) + + +class Item(JSONSerializable): + """ + Describes a delivery item. + """ + + def __init__(self, payload): + self.payload = payload + """ + The original JSON payload for the item. + + :type: dict + """ + + self.id = self.payload['_id'] + """ + Unique identifier of the delivery item. + + :type: str + """ + + self.type = self.payload['type'] + """ + Type of the delivery item. Types vary with each company. + + :type: str + """ + + self.barcode = self.payload['barcode'] + """ + Barcode of the delivery item. See :attr:`Item.barcode_encoding` to + know the barcode's encoding. + """ + + self.barcode_encoding = self.payload['barcodeEncoding'] + """ + Encoding method of the barcode (EAN 13, UPC, etc.) + """ + + self.damage_confirmed = self.payload['damaged']['confirmed'] + """ + Indicates whether the item has been damaged. + + :type: bool + """ + + self.damage_pictures = self.payload['damaged']['pictures'] + """ + Pictures of the damages. + """ + + self.status = self.payload['status'] + """ + Status of the delivery item. + + :type: str + """ + + self.quantity = self.payload['quantity'] + """ + Quantity of the given item. + + :type: int + """ + + self.labels = self.payload['labels'] + """ + Custom labels given to the item. + + :type: list + """ + + self.skills = self.payload['skills'] + """ + Required skills to handle the delivery item. + + :type: list + """ + + self.dimensions = DictObject(self.payload['dimensions']) + """ + Dimensions of the item, as custom settings set by the shipment company. + + :type: urbantz.utils.DictObject + """ + + self.metadata = DictObject(self.payload['metadata']) + """ + Metadata about the delivery that seems to be set by UrbanTZ themselves. + + :type: urbantz.utils.DictObject + """ + + self.logs = list(map(LogEntry.fromJSON, self.payload['log'])) + """ + Status update logs. + + :type: urbantz.items.LogEntry + """ + + def __repr__(self): + return ''.format(self.id) + + def toJSON(self): + return self.payload + + @classmethod + def fromJSON(cls, payload): + return cls(payload) diff --git a/urbantz/tests/test_full.py b/urbantz/tests/test_full.py new file mode 100644 index 0000000..262ba3a --- /dev/null +++ b/urbantz/tests/test_full.py @@ -0,0 +1,202 @@ +from datetime import datetime, date +from unittest import TestCase +from urbantz.base import Coordinates +from urbantz.delivery import Location, Delivery +from urbantz.items import LogEntry, Item +from urbantz.utils import DictObject +import os.path +import json + +FOLDER = os.path.dirname(os.path.realpath(__file__)) + + +class TestFull(TestCase): + """ + Unit tests with a full delivery payload + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + with open(os.path.join(FOLDER, 'test_payload.json')) as f: + cls.delivery = Delivery.fromJSON(json.load(f)) + + def test_types(self): + self.assertIsNone(self.delivery.tracking_code) + self.assertIsNone(self.delivery.last_updated) + self.assertIsInstance(self.delivery.payload, dict) + self.assertIsInstance(self.delivery.id, str) + self.assertIsInstance(self.delivery.date, date) + self.assertIsInstance(self.delivery.task_id, str) + self.assertIsInstance(self.delivery.platform_id, str) + self.assertIsInstance(self.delivery.driver_id, str) + self.assertIsInstance(self.delivery.round_id, str) + self.assertIsInstance(self.delivery.instructions, str) + self.assertIsInstance(self.delivery.progress, str) + self.assertIsInstance(self.delivery.status, str) + self.assertIsInstance(self.delivery.arrival_time, datetime) + self.assertIsInstance(self.delivery.eta_margin, int) + self.assertIsInstance(self.delivery.eta_rounding, int) + self.assertIsInstance(self.delivery.time_window, list) + for dt in self.delivery.time_window: + self.assertIsInstance(dt, datetime) + self.assertIsInstance(self.delivery.position, Coordinates) + self.assertIsInstance(self.delivery.destination, Location) + self.assertIsInstance(self.delivery.recipient, DictObject) + self.assertIsInstance(self.delivery.features, DictObject) + self.assertIsInstance(self.delivery.items, list) + for item in self.delivery.items: + self.assertIsInstance(item, Item) + self.assertIsInstance(self.delivery.logs, list) + for log in self.delivery.logs: + self.assertIsInstance(log, LogEntry) + self.assertIsInstance(self.delivery.theme, DictObject) + self.assertIsInstance(self.delivery.template, DictObject) + + def test_attributes(self): + self.assertEqual(self.delivery.id, "cafe1234") + self.assertEqual(self.delivery.date, date(2019, 2, 3)) + self.assertEqual(self.delivery.task_id, "123456789") + self.assertEqual(self.delivery.platform_id, "934") + self.assertEqual(self.delivery.driver_id, "987654e") + self.assertEqual(self.delivery.round_id, "552bc6f") + self.assertEqual(self.delivery.instructions, "Third floor") + self.assertEqual(self.delivery.progress, "COMPLETED") + self.assertEqual(self.delivery.status, "DELIVERED") + self.assertEqual(self.delivery.arrival_time, + datetime(2019, 2, 3, 6, 45, 7)) + self.assertEqual(self.delivery.eta_margin, 20) + self.assertEqual(self.delivery.eta_rounding, 5) + self.assertEqual(self.delivery.time_window, [ + datetime(2019, 2, 3, 8, 0, 0), + datetime(2019, 2, 3, 10, 0, 0), + ]) + + def test_dictobjects(self): + self.assertEqual(self.delivery.recipient.account, "54321") + self.assertIsNone(self.delivery.recipient.name) + self.assertEqual(self.delivery.recipient.person, "M. Chuck Norris") + self.assertEqual(self.delivery.recipient.phone, "33800772424") + self.assertEqual(self.delivery.recipient.language, "fr") + self.assertTrue(self.delivery.features.driver_round_resequence) + self.assertTrue(self.delivery.features.driver_ratings) + self.assertFalse(self.delivery.features.consumer_modify_instructions) + self.assertTrue(self.delivery.features.allow_virtual_fleet) + self.assertFalse(self.delivery.features.cancel_optimization) + self.assertEqual(self.delivery.theme.primary, "#efefef") + self.assertEqual(self.delivery.theme.secondary, "rgb(30, 40, 50)") + self.assertEqual(self.delivery.template.map_availability, 60) + self.assertTrue(self.delivery.template.enabled) + self.assertEqual(self.delivery.template.email, "contact@example.com") + self.assertEqual(self.delivery.template.phone, "0999999999") + self.assertEqual(self.delivery.template.background_color, + "rgba(50, 40, 20, 0.95)") + self.assertEqual(self.delivery.template.primary_color, "#ffffff") + self.assertEqual(self.delivery.template.secondary_color, "#731246") + self.assertEqual(self.delivery.template.background_picture, "bg.png") + self.assertEqual(self.delivery.template.logo, "logo.png") + self.assertEqual( + self.delivery.template.image_path, + "https://backend.urbantz.com/pictures/platforms/934/", + ) + self.assertEqual(self.delivery.template.name, "Something") + self.assertEqual(self.delivery.template.icon, "icon.png") + self.assertEqual(self.delivery.destination.address_lines, [ + "Baker Street", "742", "GU16 7HF", "Nowhere", "UK", + ]) + self.assertEqual(self.delivery.destination.geocode_score, 98) + self.assertEqual(self.delivery.destination.clean_score, 0) + self.assertEqual(self.delivery.destination.number, "742") + self.assertEqual(self.delivery.destination.street, "Baker Street") + self.assertEqual(self.delivery.destination.city, "Nowhere") + self.assertEqual(self.delivery.destination.zip, "GU16 7HF") + self.assertEqual(self.delivery.destination.country, "UK") + self.assertEqual(self.delivery.destination.origin, "ARCGIS") + self.assertEqual(self.delivery.destination.precision, "point") + + def test_objects(self): + self.assertEqual(self.delivery.position, + Coordinates(lat=32.2135467, lng=0.1234567)) + self.assertEqual(self.delivery.destination.coordinates, Coordinates( + lat=42.18906577220902, lng=2.21154054884875)) + self.assertListEqual(self.delivery.logs, [ + LogEntry( + to="GEOCODED", + by=None, + when="2019-02-01T23:07:22.997Z", + ), + LogEntry( + to="ASSIGNED", + by="65dc8", + when="2019-02-02T05:16:22.557Z", + ), + LogEntry( + to="ONGOING", + by="f3251c86513d", + when="2019-02-03T07:08:32.107Z", + ), + LogEntry( + _id="5416a321", + to="COMPLETED", + by="6513d54e", + when="2019-02-03T07:56:47.814Z", + ), + ]) + self.assertEqual(len(self.delivery.items), 1) + item = self.delivery.items[0] + self.assertFalse(item.damage_confirmed) + self.assertListEqual(item.damage_pictures, []) + self.assertEqual(item.status, "DELIVERED") + self.assertEqual(item.quantity, 1) + self.assertListEqual(item.labels, []) + self.assertListEqual(item.skills, []) + self.assertEqual(item.id, "cafe1") + self.assertEqual(item.type, "COFFEE") + self.assertEqual(item.barcode, "133742133742") + self.assertEqual(item.barcode_encoding, "upc") + self.assertIsInstance(item.dimensions, DictObject) + self.assertEqual(item.dimensions.weight, 19.01) + self.assertEqual(item.dimensions.cardboard, 2) + self.assertIsInstance(item.metadata, DictObject) + self.assertIsNone(item.metadata.package_number) + self.assertEqual(item.metadata.quantity, 0) + self.assertEqual(item.metadata.order_id_ext, "5255887") + self.assertEqual(item.metadata.new_client, "No") + self.assertListEqual(item.logs, [ + LogEntry( + by=None, + when="2019-02-02T10:32:27.621Z", + to="ARRIVED", + ), + LogEntry( + by="dead", + when="2019-02-03T07:08:32.107Z", + to="DEPARTED", + ), + LogEntry( + _id="106", + by="c0fe", + when="2019-02-03T07:56:47.814Z", + to="DELIVERED", + ), + ]) + + def test_properties(self): + self.assertListEqual(self.delivery.eta, [ + datetime(2019, 2, 3, 6, 35, 0), + datetime(2019, 2, 3, 6, 55, 0), + ]) + self.assertListEqual([log.datetime for log in self.delivery.logs], [ + datetime(2019, 2, 1, 23, 7, 22, 997000), + datetime(2019, 2, 2, 5, 16, 22, 557000), + datetime(2019, 2, 3, 7, 8, 32, 107000), + datetime(2019, 2, 3, 7, 56, 47, 814000), + ]) + self.assertListEqual( + [log.datetime for log in self.delivery.items[0].logs], + [ + datetime(2019, 2, 2, 10, 32, 27, 621000), + datetime(2019, 2, 3, 7, 8, 32, 107000), + datetime(2019, 2, 3, 7, 56, 47, 814000), + ] + ) diff --git a/urbantz/tests/test_payload.json b/urbantz/tests/test_payload.json new file mode 100644 index 0000000..f3ea8c8 --- /dev/null +++ b/urbantz/tests/test_payload.json @@ -0,0 +1,152 @@ +{ + "_id": "cafe1234", + "date": "2019-02-03T00:00:00.000Z", + "taskId": "123456789", + "type": "delivery", + "by": "beef", + "contact": { + "account": "54321", + "name": null, + "person": "M. Chuck Norris", + "phone": "33800772424", + "language": "fr" + }, + "instructions": "Third floor", + "items": [ + { + "damaged": { + "confirmed": false, + "pictures": [] + }, + "status": "DELIVERED", + "quantity": 1, + "labels": [], + "skills": [], + "_id": "cafe1", + "type": "COFFEE", + "barcode": "133742133742", + "barcodeEncoding": "upc", + "dimensions": { + "weight": 19.01, + "cardboard": 2 + }, + "log": [ + { + "by": null, + "when": "2019-02-02T10:32:27.621Z", + "to": "ARRIVED" + }, + { + "by": "dead", + "when": "2019-02-03T07:08:32.107Z", + "to": "DEPARTED" + }, + { + "_id": "106", + "by": "c0fe", + "when": "2019-02-03T07:56:47.814Z", + "to": "DELIVERED" + } + ], + "metadata": { + "packageNumber": null, + "quantity": 0, + "orderIdExt": "5255887", + "newClient": "No" + } + } + ], + "location": { + "location": { + "type": "Point", + "geometry": [ + 2.21154054884875, + 42.18906577220902 + ] + }, + "addressLines": [ + "Baker Street", + "742", + "GU16 7HF", + "Nowhere", + "UK" + ], + "geocodeScore": 98, + "cleanScore": 0, + "number": "742", + "street": "Baker Street", + "city": "Nowhere", + "zip": "GU16 7HF", + "country": "UK", + "origin": "ARCGIS", + "precision": "point" + }, + "log": [ + { + "to": "GEOCODED", + "when": "2019-02-01T23:07:22.997Z", + "by": null + }, + { + "by": "65dc8", + "when": "2019-02-02T05:16:22.557Z", + "to": "ASSIGNED" + }, + { + "by": "f3251c86513d", + "when": "2019-02-03T07:08:32.107Z", + "to": "ONGOING" + }, + { + "_id": "5416a321", + "by": "6513d54e", + "when": "2019-02-03T07:56:47.814Z", + "to": "COMPLETED" + } + ], + "platform": "934", + "progress": "COMPLETED", + "serviceTime": 13, + "status": "DELIVERED", + "timeWindow": { + "start": "2019-02-03T08:00:00.000Z", + "stop": "2019-02-03T10:00:00.000Z" + }, + "when": "2019-02-01T23:07:16.734Z", + "arriveTime": "2019-02-03T06:45:07.000Z", + "driver": "987654e", + "round": "552bc6f", + "position": [ + 0.1234567, + 32.2135467 + ], + "template": { + "mapAvailability": 60, + "enabled": true, + "email": "contact@example.com", + "phone": "0999999999", + "backgroundColor": "rgba(50, 40, 20, 0.95)", + "primaryColor": "#ffffff", + "secondaryColor": "#731246", + "backgroundPicture": "bg.png", + "logo": "logo.png", + "imagePath": "https://backend.urbantz.com/pictures/platforms/934/", + "name": "Something", + "icon": "icon.png" + }, + "features": { + "driverRoundResequence": true, + "driverRatings": true, + "consumerModifyInstructions": false, + "allowVirtualFleet": true, + "cancelOptimization": false + }, + "eta": { + "margin": 20, + "rounding": 5 + }, + "theme": { + "primary": "#efefef", + "secondary": "rgb(30, 40, 50)" + } +} diff --git a/urbantz/tests/test_utils.py b/urbantz/tests/test_utils.py new file mode 100644 index 0000000..e36d243 --- /dev/null +++ b/urbantz/tests/test_utils.py @@ -0,0 +1,82 @@ +from unittest import TestCase +from urbantz.utils import to_camel_case, DictObject + + +class TestUtils(TestCase): + + test_data = { + 'first_name': 'Chuck', + 'lastName': 'Norris', + 'nested_dict': { + 'key': 'value', + }, + 'nested_list': ['a', 'b', 'c'], + } + + def test_to_camel_case(self): + self.assertEqual(to_camel_case('abcdef'), 'abcdef') + self.assertEqual(to_camel_case('some_thing'), 'someThing') + self.assertEqual(to_camel_case('a_b_c_d_e_f'), 'aBCDEF') + self.assertEqual(to_camel_case('hEllO_wOrLd'), 'hEllOWOrLd') + + def test_dictobject_is_dict(self): + """ + Test the DictObject at least acts like a dict + """ + d = DictObject(self.test_data) + self.assertIsInstance(d, dict) + self.assertTrue(d) + + # Dict comparison + self.assertEqual(d, self.test_data) + self.assertEqual(dict(d), d) + + # Get, set, delete items + self.assertEqual(d['first_name'], 'Chuck') + self.assertIn('first_name', d) + del d['first_name'] + self.assertNotIn('first_name', d) + d['first_name'] = 'Clutch' + self.assertEqual(d['first_name'], 'Clutch') + self.assertListEqual( + list(d.keys()), + ['lastName', 'nested_dict', 'nested_list', 'first_name'], + ) + self.assertListEqual( + list(d.values()), + ['Norris', {'key': 'value'}, ['a', 'b', 'c'], 'Clutch'], + ) + self.assertListEqual(list(d.items()), [ + ('lastName', 'Norris'), + ('nested_dict', {'key': 'value'}), + ('nested_list', ['a', 'b', 'c']), + ('first_name', 'Clutch'), + ]) + + def test_dictobject_attributes(self): + """ + Test DictObject translates attributes to items + """ + + d = DictObject(self.test_data) + self.assertEqual(d.first_name, 'Chuck') + self.assertEqual(d.lastName, 'Norris') + del d.first_name + self.assertNotIn('first_name', d) + with self.assertRaises(AttributeError): + d.nope + + def test_dictobject_camel_case(self): + """ + Test DictObject turns snake_cased attribute or item names into + camelCased names and tries again when not found + """ + d = DictObject(self.test_data) + self.assertEqual(d['lastName'], 'Norris') + self.assertEqual(d['last_name'], 'Norris') + self.assertEqual(d.lastName, 'Norris') + self.assertEqual(d.last_name, 'Norris') + self.assertIn('lastName', d) + self.assertIn('last_name', d) + del d.last_name + self.assertNotIn('lastName', d) diff --git a/urbantz/tracker.py b/urbantz/tracker.py index fed35d8..6875ffe 100755 --- a/urbantz/tracker.py +++ b/urbantz/tracker.py @@ -33,9 +33,10 @@ def main(): raise SystemExit('Invalid delivery ID') print('Error while fetching data:', str(e)) + distance = delivery.position.distance(delivery.destination.coordinates) print("{} {} meters".format( delivery.last_updated.isoformat(), - round(delivery.position.distance(delivery.destination), 1), + round(distance, 1), )) sleep(options['frequency']) diff --git a/urbantz/utils.py b/urbantz/utils.py new file mode 100644 index 0000000..ef9b554 --- /dev/null +++ b/urbantz/utils.py @@ -0,0 +1,66 @@ +from urbantz.base import JSONSerializable +import re + + +def to_camel_case(value): + return re.sub(r'_(.)', lambda m: m.group(1).upper(), value) + + +class DictObject(JSONSerializable, dict): + """ + A utility class that turns a dict's items into object attributes. + This also performs snake to camel case conversion: + if a key is missing in the original dict, the key gets converted + to camel case. + + >>> d = DictObject(first_name='Chuck', lastName='Norris') + >>> d.first_name + 'Chuck' + >>> d.last_name + 'Norris' + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__dict__.update(self) + + def toJSON(self): + return dict(self) + + @classmethod + def fromJSON(cls, payload): + return cls(payload) + + def __getattr__(self, name): + try: + return self.__getitem__(name) + except KeyError: + return super().__getattr__(name) + + def __missing__(self, name): + camel = to_camel_case(name) + if name == camel: # Prevent recursion + raise KeyError(name) + return self.__getitem__(camel) + + def __delattr__(self, name): + if name in self: + try: + self.__delitem__(name) + return + except KeyError: + pass + super().__delattr__(self, name) + + def __delitem__(self, name): + try: + super().__delitem__(name) + except KeyError: + camel = to_camel_case(name) + if name == camel: + raise + super().__delitem__(camel) + + def __contains__(self, name): + return super().__contains__(name) \ + or super().__contains__(to_camel_case(name))