Compare commits

...

19 Commits

Author SHA1 Message Date
Lucidiot af4fd6fb92
Add unit tests info to contribution docs 2019-10-03 06:42:59 +02:00
Lucidiot 0134ed4641
Enhance code coverage 2019-07-10 21:28:59 +02:00
Lucidiot df2664283c
Fix caching in CI 2019-07-06 20:20:40 +02:00
Lucidiot b668b0c2e6
Add doc8 2019-07-06 20:20:35 +02:00
Lucidiot 06a329274d Merge branch 'more-delivery-stuff' into 'master'
Much more exhaustive delivery data

See merge request Lucidiot/pyurbantz!3
2019-02-03 21:12:17 +00:00
Lucidiot 596675ec27 Much more exhaustive delivery data 2019-02-03 21:12:17 +00:00
Lucidiot b3bcce9203 Merge branch 'json-400' into 'master'
Handle JSON payloads from 4xx errors

Closes #2

See merge request Lucidiot/pyurbantz!2
2019-01-30 20:03:03 +00:00
Lucidiot f9acba3ec4
Handle JSON payloads from 4xx errors 2019-01-30 21:00:51 +01:00
Lucidiot 7b32dcabba
Add some tests for Delivery 2019-01-30 20:52:24 +01:00
Lucidiot f21dc2b9dc
Add JSONSerializable 2019-01-30 20:39:05 +01:00
Lucidiot 1e49fd05b5
Unit tests for APIError 2019-01-30 20:16:54 +01:00
Lucidiot 736ec62d3b
Full coverage on coordinates 2019-01-30 20:09:30 +01:00
Lucidiot 0e4c24046b
Configure flake8 2019-01-30 19:57:26 +01:00
Lucidiot 82d2e069ef
Try to add Codecov coverage in CI 2019-01-30 19:49:21 +01:00
Lucidiot 6e9dafe516
Setup unit tests 2019-01-30 08:36:16 +01:00
Lucidiot 7ac32e7d0f
Fix flake8 2019-01-30 08:05:20 +01:00
Lucidiot 6d9f724088
Offline loading and payload attribute 2019-01-30 08:03:13 +01:00
Lucidiot 298ff345c6
Bugfixes 2019-01-30 07:40:07 +01:00
Lucidiot 1d66928c2a
Update docs config 2018-10-27 15:42:09 +02:00
22 changed files with 1417 additions and 163 deletions

2
.coveragerc Normal file
View File

@ -0,0 +1,2 @@
[run]
include=urbantz/*

View File

@ -1,16 +1,40 @@
image: python:3.7
stages:
- lint
- test
- deploy
flake8:
stage: lint
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
before_script:
- pip install .[dev]
cache:
paths:
- .cache/pip
- venv/
before_script:
- pip install virtualenv
- virtualenv venv
- source venv/bin/activate
- pip install .[dev]
tests:
stage: test
coverage: '/TOTAL[\s\d]+\s(\d+%)/'
script:
- coverage run setup.py test
- coverage report
- codecov
flake8:
stage: test
script:
- flake8
doc8:
stage: test
script:
- doc8
deploy-pypi:
stage: deploy
when: manual
@ -20,7 +44,7 @@ deploy-pypi:
name: pypi
url: https://pypi.org/project/pyurbantz
before_script:
script:
- pip install twine setuptools wheel
- echo "[distutils]" > ~/.pypirc
- echo "index-servers =" >> ~/.pypirc
@ -29,7 +53,6 @@ deploy-pypi:
- echo "repository=https://upload.pypi.org/legacy/" >> ~/.pypirc
- echo "username=$PYPI_DEPLOY_USERNAME" >> ~/.pypirc
- echo "password=$PYPI_DEPLOY_PASSWORD" >> ~/.pypirc
script:
- python setup.py sdist bdist_wheel
- twine upload dist/* -r pypi
@ -42,7 +65,7 @@ deploy-testpypi:
name: testpypi
url: https://test.pypi.org/project/pyurbantz
before_script:
script:
- pip install twine setuptools wheel
- echo "[distutils]" > ~/.pypirc
- echo "index-servers =" >> ~/.pypirc
@ -51,7 +74,6 @@ deploy-testpypi:
- echo "repository=https://test.pypi.org/legacy/" >> ~/.pypirc
- echo "username=$PYPI_DEPLOY_USERNAME" >> ~/.pypirc
- echo "password=$PYPI_DEPLOY_PASSWORD" >> ~/.pypirc
script:
- python setup.py sdist bdist_wheel
- twine upload dist/* -r testpypi
@ -64,8 +86,6 @@ pages:
paths:
- public
before_script:
- pip install .[dev]
script:
- cd docs
- make html

View File

@ -1 +1 @@
0.1.1
0.2.0

View File

@ -7,6 +7,12 @@ Deliveries
.. automodule:: urbantz.delivery
:members:
Delivery items
--------------
.. automodule:: urbantz.items
:members:
Exceptions
----------
@ -16,5 +22,8 @@ Exceptions
Helper classes
--------------
.. automodule:: urbantz.base
:members:
.. automodule:: urbantz.utils
:members:

View File

@ -83,7 +83,10 @@ html_theme = 'alabaster'
# further. For a list of options available for each theme, see the
# documentation.
#
# html_theme_options = {}
html_theme_options = {
'description': 'UrbanTZ API client',
'fixed_sidebar': 'true',
}
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,

View File

@ -1,7 +1,7 @@
Contributing
============
Contributions to the project are greatly appreciated.
Contributions to the project are greatly appreciated.
Bugs and suggestions
--------------------
@ -27,9 +27,40 @@ recommended::
mkvirtualenv -a . pyurbantz
pip install -e .[dev]
This will clone the repository, create a virtual environment named ``pyurbantz``,
then tell pip to let the package be editable (``-e``). The ``[dev]`` suffix adds
the extra requirements useful for development.
This will clone the repository, create a virtual environment named
``pyurbantz``, then tell pip to let the package be editable (``-e``).
The ``[dev]`` suffix adds the extra requirements useful for development.
Unit tests
^^^^^^^^^^
Unit tests use the standard ``unittest`` package; you may run them using the
standard ``setup.py`` command::
./setup.py test
Tests coverage
^^^^^^^^^^^^^^
I aim for 100% coverage on all of my Python packages whenever I add unit
tests to them; this package is no exception. CI checks use the `coverage`_
Python package and `codecov`_ to check for test coverage. To get test coverage
data locally, run::
coverage run setup.py test
You may then get a short coverage summary in your terminal::
coverage report
Or generate an HTML report in a ``htmlcov`` folder, which can be browsed
offline using your favorite web browser and shows line by line coverage::
coverage html
If you are having issues reaching 100% coverage, try to still add some tests,
and mention your issues when creating a pull request to the
`GitLab repository`_.
Linting
^^^^^^^
@ -45,9 +76,12 @@ The documentation you are reading is generated by the `Sphinx`_ tool.
The text files that hold the documentation's contents are written in
`reStructuredText`_ and are available under the ``/docs`` folder of the
`GitLab repository`_.
They are also subject to linting using the ``doc8`` tool.
.. _submit an issue: https://gitlab.com/Lucidiot/pyurbantz/issues/new
.. _virtualenvwrapper: https://virtualenvwrapper.readthedocs.io
.. _coverage: https://coverage.readthedocs.io/
.. _codecov: https://codecov.io/gl/Lucidiot/pyurbantz
.. _GitLab repository: https://gitlab.com/Lucidiot/pyurbantz
.. _Sphinx: http://www.sphinx-doc.org/
.. _reStructuredText: http://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html

View File

@ -1,2 +1,5 @@
flake8>=3.5
doc8>=0.8
Sphinx>=1.8.1
coverage>=4.5
codecov>=2.0

5
setup.cfg Normal file
View File

@ -0,0 +1,5 @@
[flake8]
exclude = .git,__pycache__,docs,*.pyc,venv
[doc8]
ignore-path=**/*.txt,*.txt,*.egg-info,docs/_build,venv,.git

1
setup.py Normal file → Executable file
View File

@ -1,3 +1,4 @@
#!/usr/bin/env python3
from setuptools import setup, find_packages

152
urbantz/base.py Normal file
View File

@ -0,0 +1,152 @@
from abc import ABC, abstractmethod
from math import radians, cos, sin, asin, sqrt, trunc, floor, ceil
EARTH_RADIUS_KM = 6371
class JSONSerializable(ABC):
@abstractmethod
def toJSON(self):
"""
Convert this instance to a JSON-serializable type.
:returns: An object that can be serialized to JSON.
"""
@classmethod
@abstractmethod
def fromJSON(cls, payload):
"""
Get an instance of the class from parsed JSON data.
:param payload: A JSON parsed payload.
:type payload: dict or list or str or int or float or bool or None
:returns: An instance of the class.
"""
class Coordinates(JSONSerializable):
"""
A helper class for GPS coordinates.
"""
def __init__(self, lat=0, lng=0):
"""
:param float lat: Latitude in decimal degrees.
:param float lng: Longitude in decimal degrees.
"""
self.lat = lat
self.lng = lng
def __repr__(self):
return '{}(lat={}, lng={})'.format(
self.__class__.__name__, self.lat, self.lng)
def __str__(self):
return ', '.join(map(str, tuple(self)))
def __hash__(self):
return hash(tuple(self))
def __len__(self):
return 2
def __length_hint__(self):
return len(self)
def __iter__(self):
return iter((self.lat, self.lng))
def __eq__(self, other):
return hasattr(other, 'lat') and hasattr(other, 'lng') and \
tuple(self) == tuple(other)
def __add__(self, other):
if not hasattr(other, 'lat') or not hasattr(other, 'lng'):
return NotImplemented
return self.__class__(
lat=self.lat + other.lat,
lng=self.lng + other.lng,
)
def __sub__(self, other):
if not hasattr(other, 'lat') or not hasattr(other, 'lng'):
return NotImplemented
return self.__class__(
lat=self.lat - other.lat,
lng=self.lng - other.lng,
)
def __pos__(self):
return self
def __neg__(self):
return self.__class__(lat=-self.lat, lng=-self.lng)
def __abs__(self):
return self.__class__(lat=abs(self.lat), lng=abs(self.lng))
def __round__(self, ndigits=None):
return self.__class__(
lat=round(self.lat, ndigits),
lng=round(self.lng, ndigits),
)
def __trunc__(self):
return self.__class__(lat=trunc(self.lat), lng=trunc(self.lng))
def __floor__(self):
return self.__class__(lat=floor(self.lat), lng=floor(self.lng))
def __ceil__(self):
return self.__class__(lat=ceil(self.lat), lng=ceil(self.lng))
def to_radians(self):
"""
Convert to a ``(lat, lng)`` tuple in radians.
:returns: Coordinates in radians.
:rtype: tuple(float, float)
"""
return tuple(map(radians, self))
def distance(self, other):
"""
Compute Haversine distance between two coordinates in meters.
:param other: Another pair of coordinates to compute distance against.
:type other: Coordinates
:returns: Distance between the two coordinates, in meters.
:rtype: float
"""
if not hasattr(other, 'to_radians'):
raise NotImplementedError(
'Distance requires a to_radians() method on both coordinates')
lat1, lon1 = self.to_radians()
lat2, lon2 = other.to_radians()
dlon, dlat = lon2 - lon1, lat2 - lat1
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
return EARTH_RADIUS_KM * 2000 * asin(sqrt(a))
def toJSON(self):
"""
Convert to UrbanTZ JSON geometry
:returns: UrbanTZ-compatible JSON geometry data
:rtype: list(float)
"""
return [self.lng, self.lat]
@classmethod
def fromJSON(cls, geometry):
"""
Get a Coordinates instance from parsed UrbanTZ JSON geometry data.
:param geometry: Parsed UrbanTZ geometry data: a list holding
``[lng, lat]`` in decimal degrees.
:type geometry: list(float)
:returns: A Coordinates instance.
:rtype: urbantz.base.Coordinates
"""
return cls(lng=geometry[0], lat=geometry[1])

View File

@ -1,23 +1,41 @@
from datetime import datetime
from urbantz.utils import Coordinates
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 Delivery(object):
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):
"""
A UrbanTZ delivery with a unique ID.
"""
def __init__(self, id):
def __init__(self, tracking_code=None):
"""
:param str id: A delivery ID.
:param tracking_code: A delivery public tracking code.
:type tracking_code: str or None
"""
self.id = id
self.tracking_code = tracking_code
"""
The delivery ID.
The delivery public tracking code.
:type: str
:type: str or None
"""
self.last_updated = None
@ -28,22 +46,169 @@ class Delivery(object):
:type: datetime or None
"""
self.payload = None
"""
Latest parsed JSON payload from the API. Is None if data has never
been fetched from the API or loaded via :meth:`Delivery.use`.
: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.utils.Coordinates
:type: urbantz.base.Coordinates or None
"""
self.destination = None
"""
Coordinates of the delivery destination.
The delivery's destination.
:type: urbantz.utils.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):
return '{}({})'.format(self.__class__.__name__, self.id)
return '{}({})'.format(
self.__class__.__name__, repr(self.tracking_code))
@property
def api_url(self):
@ -52,25 +217,111 @@ class Delivery(object):
:type: str
"""
return 'https://backend.urbantz.com/public/task/tracking/' + self.id
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.
:raises urbantz.exceptions.APIError: If the API returned a JSON error.
:raises requests.exceptions.HTTPError: If the response has an
HTTP 4xx or 5xx code.
:raises urbantz.exceptions.APIError: If the API returned an error.
HTTP 4xx or 5xx code, or an empty payload.
"""
resp = requests.get(self.api_url)
resp.raise_for_status()
data = resp.json()
data = {}
try:
data = resp.json()
except Exception:
pass
if 'error' in data:
raise APIError(data['error'])
self.position = Coordinates.fromJSON(data['position'])
self.destination = Coordinates.fromJSON(
data['location']['location']['geometry'])
resp.raise_for_status()
if not data:
# If requests does not raise anything and there is no data,
# raise our own error
raise APIError({'message': 'API returned an empty payload'})
self.use(data)
# TODO: See if the payload holds a last update value
self.last_updated = datetime.now()
def use(self, payload):
"""
Use a parsed JSON payload to update the properties.
: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 = 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):
"""
Create a Delivery instance from an existing payload.
:param payload: A parsed JSON payload.
:type payload: dict
"""
instance = cls()
instance.use(payload)
return instance
def toJSON(self):
return self.payload

View File

@ -1,4 +1,7 @@
class APIError(Exception):
from urbantz.base import JSONSerializable
class APIError(JSONSerializable, Exception):
"""
An error returned by the UrbanTZ API.
This does not include HTTP errors.
@ -13,7 +16,18 @@ class APIError(Exception):
self.code = error.get('code')
def __repr__(self):
return "<APIError '{}'>".format(str(self))
return "<APIError {}>".format(repr(str(self)))
def __str__(self):
return self.message or 'Unknown error'
def __eq__(self, other):
return isinstance(other, self.__class__) and \
self.code == other.code and self.message == other.message
@classmethod
def fromJSON(cls, payload):
return cls(payload)
def toJSON(self):
return {'message': self.message, 'code': self.code}

132
urbantz/items.py Normal file
View File

@ -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 '<Item {}>'.format(self.id)
def toJSON(self):
return self.payload
@classmethod
def fromJSON(cls, payload):
return cls(payload)

View File

View File

@ -0,0 +1,91 @@
from unittest import TestCase
from math import trunc, floor, ceil, radians
from urbantz.base import Coordinates
class TestCoordinates(TestCase):
@classmethod
def setUpClass(cls):
cls.coords = Coordinates(lat=12.57, lng=-13.49)
def test_init(self):
self.assertEqual(self.coords.lat, 12.57)
self.assertEqual(self.coords.lng, -13.49)
def test_repr(self):
self.assertEqual(
repr(self.coords),
'Coordinates(lat=12.57, lng=-13.49)',
)
def test_str(self):
self.assertEqual(str(self.coords), '12.57, -13.49')
def test_hash(self):
self.assertEqual(hash(self.coords), hash((12.57, -13.49)))
def test_tuple(self):
self.assertTupleEqual(tuple(self.coords), (12.57, -13.49))
def test_len(self):
self.assertEqual(len(self.coords), 2)
def test_length_hint(self):
self.assertEqual(self.coords.__length_hint__(), 2)
def test_pos(self):
self.assertEqual(+self.coords, self.coords)
def test_neg(self):
self.assertTupleEqual(tuple(-self.coords), (-12.57, 13.49))
def test_abs(self):
self.assertTupleEqual(tuple(abs(self.coords)), (12.57, 13.49))
def test_round(self):
self.assertTupleEqual(tuple(round(self.coords)), (13, -13))
self.assertTupleEqual(tuple(round(self.coords, 1)), (12.6, -13.5))
def test_trunc(self):
self.assertTupleEqual(tuple(trunc(self.coords)), (12, -13))
def test_floor(self):
self.assertTupleEqual(tuple(floor(self.coords)), (12, -14))
def test_ceil(self):
self.assertTupleEqual(tuple(ceil(self.coords)), (13, -13))
def test_add(self):
other = Coordinates(lat=7.43, lng=-6.51)
self.assertEqual(self.coords + other, Coordinates(lat=20, lng=-20))
with self.assertRaises(TypeError):
self.coords + 4
def test_sub(self):
other = Coordinates(lat=2.57, lng=-3.49)
self.assertEqual(self.coords - other, Coordinates(lat=10, lng=-10))
with self.assertRaises(TypeError):
self.coords - "lol"
def test_toJSON(self):
self.assertListEqual(self.coords.toJSON(), [-13.49, 12.57])
def test_fromJSON(self):
self.assertEqual(Coordinates.fromJSON([-13.49, 12.57]), self.coords)
def test_to_radians(self):
self.assertTupleEqual(
self.coords.to_radians(),
(radians(12.57), radians(-13.49)),
)
def test_distance(self):
other = Coordinates(lat=2.57, lng=-3.49)
self.assertEqual(
self.coords.distance(other),
other.distance(self.coords),
)
self.assertEqual(self.coords.distance(other), 1564640.3229974532)
with self.assertRaises(NotImplementedError):
self.coords.distance("lol")

View File

@ -0,0 +1,95 @@
from unittest import TestCase
from unittest.mock import patch, call
from datetime import datetime
from urbantz import Delivery
from urbantz.delivery import Location
from urbantz.exceptions import APIError
class TestDelivery(TestCase):
@classmethod
def setUpClass(cls):
cls.delivery = Delivery('123456')
def test_repr(self):
self.assertEqual(repr(self.delivery), "Delivery('123456')")
def test_api_url(self):
self.assertEqual(
self.delivery.api_url,
'https://backend.urbantz.com/public/task/tracking/123456',
)
@patch('urbantz.delivery.Delivery.use')
@patch('urbantz.delivery.requests')
def test_update(self, requests_mock, use_mock):
requests_mock.get.return_value.json.return_value = {'some': 'payload'}
self.assertIsNone(self.delivery.last_updated)
self.delivery.update()
self.assertEqual(requests_mock.get.call_count, 1)
self.assertEqual(
requests_mock.get.call_args,
call(self.delivery.api_url),
)
self.assertEqual(requests_mock.get().raise_for_status.call_count, 1)
self.assertEqual(requests_mock.get().json.call_count, 1)
self.assertEqual(use_mock.call_count, 1)
self.assertEqual(use_mock.call_args, call({'some': 'payload'}))
self.assertIsInstance(self.delivery.last_updated, datetime)
@patch('urbantz.delivery.requests')
def test_update_error(self, requests_mock):
requests_mock.get.return_value.json.return_value = {
'error': {
'code': 42,
'message': 'Oh snap!',
}
}
with self.assertRaises(APIError) as excinfo:
self.delivery.update()
self.assertEqual(requests_mock.get.call_count, 1)
self.assertEqual(
requests_mock.get.call_args,
call(self.delivery.api_url),
)
self.assertEqual(requests_mock.get().json.call_count, 1)
self.assertEqual(
excinfo.exception,
APIError({'code': 42, 'message': 'Oh snap!'}),
)
@patch('urbantz.delivery.requests')
def test_update_error_empty(self, requests_mock):
"""
Test that a Delivery will raise an APIError with a failed JSON parsing
"""
requests_mock.get.return_value.json.side_effect = ValueError
with self.assertRaises(APIError) as excinfo:
self.delivery.update()
self.assertEqual(requests_mock.get.call_count, 1)
self.assertEqual(
requests_mock.get.call_args,
call(self.delivery.api_url),
)
self.assertEqual(requests_mock.get().raise_for_status.call_count, 1)
self.assertEqual(requests_mock.get().json.call_count, 1)
self.assertEqual(
excinfo.exception,
APIError({'message': 'API returned an empty payload'}),
)
def test_empty_eta(self):
self.delivery.eta_margin = None
self.delivery.eta_rounding = None
self.delivery.arrival_time = None
self.assertIsNone(self.delivery.eta)
def test_empty_location(self):
loc = Location()
with self.assertRaises(AttributeError):
loc.coordinates

View File

@ -0,0 +1,47 @@
from unittest import TestCase
from urbantz.exceptions import APIError
class TestExceptions(TestCase):
@classmethod
def setUpClass(self):
self.error = APIError({
'code': 9000,
'message': "I'm afraid I can't do that, Dave."
})
def test_init(self):
self.assertEqual(self.error.code, 9000)
self.assertEqual(
self.error.message,
"I'm afraid I can't do that, Dave.",
)
def test_empty(self):
error = APIError({})
self.assertIsNone(error.message)
self.assertIsNone(error.code)
self.assertEqual(str(error), 'Unknown error')
self.assertEqual(repr(error), "<APIError 'Unknown error'>")
def test_repr(self):
self.assertEqual(
repr(self.error),
'<APIError "I\'m afraid I can\'t do that, Dave.">',
)
def test_str(self):
self.assertEqual(str(self.error), "I'm afraid I can't do that, Dave.")
def test_fromJSON(self):
self.assertEqual(APIError.fromJSON({
'code': 9000,
'message': "I'm afraid I can't do that, Dave.",
}), self.error)
def test_toJSON(self):
self.assertDictEqual(self.error.toJSON(), {
'code': 9000,
'message': "I'm afraid I can't do that, Dave.",
})

206
urbantz/tests/test_full.py Normal file
View File

@ -0,0 +1,206 @@
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.payload = json.load(f)
cls.delivery = Delivery.fromJSON(cls.payload)
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.assertEqual(repr(item), '<Item cafe1>')
self.assertEqual(item.toJSON(), self.payload['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),
]
)
self.assertEqual(self.delivery.toJSON(), self.payload)

View File

@ -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)"
}
}

102
urbantz/tests/test_utils.py Normal file
View File

@ -0,0 +1,102 @@
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)
with self.assertRaises(AttributeError):
del d.last_name
with self.assertRaises(KeyError):
del d['lastName']
def test_dictobject_edge_case(self):
d = DictObject(self.test_data)
def errored_delitem(*args, **kwargs):
raise KeyError
d.__delitem__ = errored_delitem
del d.lastName
self.assertEqual(d.lastName, 'Norris')
def test_dictobject_reverse(self):
self.assertEqual(
DictObject(self.test_data).toJSON(),
self.test_data,
)

View File

@ -22,7 +22,7 @@ def main():
default=10,
help='Update frequency in seconds',
)
options = parser.parse_args()
options = vars(parser.parse_args())
delivery = options['delivery']
while True:
@ -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'])

View File

@ -1,132 +1,66 @@
from math import radians, cos, sin, asin, sqrt, trunc, floor, ceil
EARTH_RADIUS_KM = 6371
from urbantz.base import JSONSerializable
import re
class Coordinates(object):
def to_camel_case(value):
return re.sub(r'_(.)', lambda m: m.group(1).upper(), value)
class DictObject(JSONSerializable, dict):
"""
A helper class for GPS coordinates.
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, lat=0, lng=0):
"""
Get coordinates from decimal degrees.
:param float lat: Latitude in decimal degrees.
:param float lng: Longitude in decimal degrees.
"""
self.lat = lat
self.lng = lng
def __repr__(self):
return '{}(lat={}, lng={})'.format(
self.__class__.__name__, self.lat, self.lng)
def __str__(self):
return ', '.join(tuple(self))
def __hash__(self):
return hash(tuple(self))
def __len__(self):
return 2
def __length_hint__(self):
return len(self)
def __iter__(self):
return (self.lat, self.lng)
def __dict__(self):
return {'lat': self.lat, 'lng': self.lng}
def __eq__(self, other):
return hasattr(other, 'lat') and hasattr(other, 'lng') and \
tuple(self) == tuple(other)
def __add__(self, other):
if not hasattr(other, 'lat') or hasattr(other, 'lng'):
return NotImplemented
return self.__class__(
lat=self.lat + other.lat,
lng=self.lng + other.lng,
)
def __sub__(self, other):
if not hasattr(other, 'lat') or hasattr(other, 'lng'):
return NotImplemented
return self.__class__(
lat=self.lat - other.lat,
lng=self.lng - other.lng,
)
def __pos__(self):
return self
def __neg__(self):
return self.__class__(lat=-self.lat, lng=-self.lng)
def __abs__(self):
return self.__class__(lat=abs(self.lat), lng=abs(self.lng))
def __round__(self, ndigits=None):
return self.__class__(
lat=round(self.lat, ndigits),
lng=round(self.lng, ndigits),
)
def __trunc__(self):
return self.__class__(lat=trunc(self.lat), lng=trunc(self.lng))
def __floor__(self):
return self.__class__(lat=floor(self.lat), lng=floor(self.lng))
def __ceil__(self):
return self.__class__(lat=ceil(self.lat), lng=ceil(self.lng))
def to_radians(self):
"""
Convert to a ``(lat, lng)`` tuple in radians.
:returns: Coordinates in radians.
:rtype: tuple(float, float)
"""
return tuple(map(radians, self))
def distance(self, other):
"""
Compute Haversine distance between two coordinates in meters.
:param other: Another pair of coordinates to compute distance against.
:type other: Coordinates
:returns: Distance between the two coordinates, in meters.
:rtype: float
"""
if not hasattr(other, 'to_radians'):
raise NotImplementedError(
'Distance requires a to_radians() method on both coordinates')
lat1, lon1 = self.to_radians()
lat2, lon2 = other.to_radians()
dlon, dlat = lon2 - lon1, lat2 - lat1
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
return EARTH_RADIUS_KM * 2000 * asin(sqrt(a))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.__dict__.update(self)
def toJSON(self):
"""
Convert to UrbanTZ JSON geometry
return dict(self)
:returns: UrbanTZ-compatible JSON geometry data
:rtype: list(float)
"""
return [self.lng, self.lat]
@classmethod
def fromJSON(cls, payload):
return cls(payload)
@staticmethod
def fromJSON(geometry):
"""
Get a Coordinates instance from parsed UrbanTZ JSON geometry data.
def __getattr__(self, name):
try:
return self.__getitem__(name)
except KeyError:
return super().__getattr__(name)
:param geometry: Parsed UrbanTZ geometry data: a list holding
``[lng, lat]`` in decimal degrees.
:type geometry: list(float)
"""
return Coordinates(lng=geometry[0], lat=geometry[1])
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__(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))