Compare commits
19 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
af4fd6fb92 | ||
|
0134ed4641 | ||
|
df2664283c | ||
|
b668b0c2e6 | ||
|
06a329274d | ||
|
596675ec27 | ||
|
b3bcce9203 | ||
|
f9acba3ec4 | ||
|
7b32dcabba | ||
|
f21dc2b9dc | ||
|
1e49fd05b5 | ||
|
736ec62d3b | ||
|
0e4c24046b | ||
|
82d2e069ef | ||
|
6e9dafe516 | ||
|
7ac32e7d0f | ||
|
6d9f724088 | ||
|
298ff345c6 | ||
|
1d66928c2a |
2
.coveragerc
Normal file
2
.coveragerc
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[run]
|
||||||
|
include=urbantz/*
|
|
@ -1,16 +1,40 @@
|
||||||
image: python:3.7
|
image: python:3.7
|
||||||
stages:
|
stages:
|
||||||
- lint
|
- test
|
||||||
- deploy
|
- deploy
|
||||||
|
|
||||||
flake8:
|
variables:
|
||||||
stage: lint
|
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
|
||||||
|
|
||||||
before_script:
|
cache:
|
||||||
- pip install .[dev]
|
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:
|
script:
|
||||||
- flake8
|
- flake8
|
||||||
|
|
||||||
|
doc8:
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- doc8
|
||||||
|
|
||||||
deploy-pypi:
|
deploy-pypi:
|
||||||
stage: deploy
|
stage: deploy
|
||||||
when: manual
|
when: manual
|
||||||
|
@ -20,7 +44,7 @@ deploy-pypi:
|
||||||
name: pypi
|
name: pypi
|
||||||
url: https://pypi.org/project/pyurbantz
|
url: https://pypi.org/project/pyurbantz
|
||||||
|
|
||||||
before_script:
|
script:
|
||||||
- pip install twine setuptools wheel
|
- pip install twine setuptools wheel
|
||||||
- echo "[distutils]" > ~/.pypirc
|
- echo "[distutils]" > ~/.pypirc
|
||||||
- echo "index-servers =" >> ~/.pypirc
|
- echo "index-servers =" >> ~/.pypirc
|
||||||
|
@ -29,7 +53,6 @@ deploy-pypi:
|
||||||
- echo "repository=https://upload.pypi.org/legacy/" >> ~/.pypirc
|
- echo "repository=https://upload.pypi.org/legacy/" >> ~/.pypirc
|
||||||
- echo "username=$PYPI_DEPLOY_USERNAME" >> ~/.pypirc
|
- echo "username=$PYPI_DEPLOY_USERNAME" >> ~/.pypirc
|
||||||
- echo "password=$PYPI_DEPLOY_PASSWORD" >> ~/.pypirc
|
- echo "password=$PYPI_DEPLOY_PASSWORD" >> ~/.pypirc
|
||||||
script:
|
|
||||||
- python setup.py sdist bdist_wheel
|
- python setup.py sdist bdist_wheel
|
||||||
- twine upload dist/* -r pypi
|
- twine upload dist/* -r pypi
|
||||||
|
|
||||||
|
@ -42,7 +65,7 @@ deploy-testpypi:
|
||||||
name: testpypi
|
name: testpypi
|
||||||
url: https://test.pypi.org/project/pyurbantz
|
url: https://test.pypi.org/project/pyurbantz
|
||||||
|
|
||||||
before_script:
|
script:
|
||||||
- pip install twine setuptools wheel
|
- pip install twine setuptools wheel
|
||||||
- echo "[distutils]" > ~/.pypirc
|
- echo "[distutils]" > ~/.pypirc
|
||||||
- echo "index-servers =" >> ~/.pypirc
|
- echo "index-servers =" >> ~/.pypirc
|
||||||
|
@ -51,7 +74,6 @@ deploy-testpypi:
|
||||||
- echo "repository=https://test.pypi.org/legacy/" >> ~/.pypirc
|
- echo "repository=https://test.pypi.org/legacy/" >> ~/.pypirc
|
||||||
- echo "username=$PYPI_DEPLOY_USERNAME" >> ~/.pypirc
|
- echo "username=$PYPI_DEPLOY_USERNAME" >> ~/.pypirc
|
||||||
- echo "password=$PYPI_DEPLOY_PASSWORD" >> ~/.pypirc
|
- echo "password=$PYPI_DEPLOY_PASSWORD" >> ~/.pypirc
|
||||||
script:
|
|
||||||
- python setup.py sdist bdist_wheel
|
- python setup.py sdist bdist_wheel
|
||||||
- twine upload dist/* -r testpypi
|
- twine upload dist/* -r testpypi
|
||||||
|
|
||||||
|
@ -64,8 +86,6 @@ pages:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
|
|
||||||
before_script:
|
|
||||||
- pip install .[dev]
|
|
||||||
script:
|
script:
|
||||||
- cd docs
|
- cd docs
|
||||||
- make html
|
- make html
|
||||||
|
|
|
@ -7,6 +7,12 @@ Deliveries
|
||||||
.. automodule:: urbantz.delivery
|
.. automodule:: urbantz.delivery
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
|
Delivery items
|
||||||
|
--------------
|
||||||
|
|
||||||
|
.. automodule:: urbantz.items
|
||||||
|
:members:
|
||||||
|
|
||||||
Exceptions
|
Exceptions
|
||||||
----------
|
----------
|
||||||
|
|
||||||
|
@ -16,5 +22,8 @@ Exceptions
|
||||||
Helper classes
|
Helper classes
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
|
.. automodule:: urbantz.base
|
||||||
|
:members:
|
||||||
|
|
||||||
.. automodule:: urbantz.utils
|
.. automodule:: urbantz.utils
|
||||||
:members:
|
:members:
|
||||||
|
|
|
@ -83,7 +83,10 @@ html_theme = 'alabaster'
|
||||||
# further. For a list of options available for each theme, see the
|
# further. For a list of options available for each theme, see the
|
||||||
# documentation.
|
# 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,
|
# 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,
|
# relative to this directory. They are copied after the builtin static files,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
Contributing
|
Contributing
|
||||||
============
|
============
|
||||||
|
|
||||||
Contributions to the project are greatly appreciated.
|
Contributions to the project are greatly appreciated.
|
||||||
|
|
||||||
Bugs and suggestions
|
Bugs and suggestions
|
||||||
--------------------
|
--------------------
|
||||||
|
@ -27,9 +27,40 @@ recommended::
|
||||||
mkvirtualenv -a . pyurbantz
|
mkvirtualenv -a . pyurbantz
|
||||||
pip install -e .[dev]
|
pip install -e .[dev]
|
||||||
|
|
||||||
This will clone the repository, create a virtual environment named ``pyurbantz``,
|
This will clone the repository, create a virtual environment named
|
||||||
then tell pip to let the package be editable (``-e``). The ``[dev]`` suffix adds
|
``pyurbantz``, then tell pip to let the package be editable (``-e``).
|
||||||
the extra requirements useful for development.
|
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
|
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
|
The text files that hold the documentation's contents are written in
|
||||||
`reStructuredText`_ and are available under the ``/docs`` folder of the
|
`reStructuredText`_ and are available under the ``/docs`` folder of the
|
||||||
`GitLab repository`_.
|
`GitLab repository`_.
|
||||||
|
They are also subject to linting using the ``doc8`` tool.
|
||||||
|
|
||||||
.. _submit an issue: https://gitlab.com/Lucidiot/pyurbantz/issues/new
|
.. _submit an issue: https://gitlab.com/Lucidiot/pyurbantz/issues/new
|
||||||
.. _virtualenvwrapper: https://virtualenvwrapper.readthedocs.io
|
.. _virtualenvwrapper: https://virtualenvwrapper.readthedocs.io
|
||||||
|
.. _coverage: https://coverage.readthedocs.io/
|
||||||
|
.. _codecov: https://codecov.io/gl/Lucidiot/pyurbantz
|
||||||
.. _GitLab repository: https://gitlab.com/Lucidiot/pyurbantz
|
.. _GitLab repository: https://gitlab.com/Lucidiot/pyurbantz
|
||||||
.. _Sphinx: http://www.sphinx-doc.org/
|
.. _Sphinx: http://www.sphinx-doc.org/
|
||||||
.. _reStructuredText: http://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html
|
.. _reStructuredText: http://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html
|
||||||
|
|
|
@ -1,2 +1,5 @@
|
||||||
flake8>=3.5
|
flake8>=3.5
|
||||||
|
doc8>=0.8
|
||||||
Sphinx>=1.8.1
|
Sphinx>=1.8.1
|
||||||
|
coverage>=4.5
|
||||||
|
codecov>=2.0
|
||||||
|
|
5
setup.cfg
Normal file
5
setup.cfg
Normal 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
1
setup.py
Normal file → Executable file
|
@ -1,3 +1,4 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
from setuptools import setup, find_packages
|
from setuptools import setup, find_packages
|
||||||
|
|
||||||
|
|
||||||
|
|
152
urbantz/base.py
Normal file
152
urbantz/base.py
Normal 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])
|
|
@ -1,23 +1,41 @@
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from urbantz.utils import Coordinates
|
from urbantz.base import JSONSerializable, Coordinates
|
||||||
|
from urbantz.utils import DictObject
|
||||||
|
from urbantz.items import LogEntry, Item
|
||||||
from urbantz.exceptions import APIError
|
from urbantz.exceptions import APIError
|
||||||
import requests
|
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.
|
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
|
self.last_updated = None
|
||||||
|
@ -28,22 +46,169 @@ class Delivery(object):
|
||||||
:type: datetime or None
|
: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
|
self.position = None
|
||||||
"""
|
"""
|
||||||
Coordinates of the delivery truck's position.
|
Coordinates of the delivery truck's position.
|
||||||
|
|
||||||
:type: urbantz.utils.Coordinates
|
:type: urbantz.base.Coordinates or None
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.destination = 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):
|
def __repr__(self):
|
||||||
return '{}({})'.format(self.__class__.__name__, self.id)
|
return '{}({})'.format(
|
||||||
|
self.__class__.__name__, repr(self.tracking_code))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def api_url(self):
|
def api_url(self):
|
||||||
|
@ -52,25 +217,111 @@ class Delivery(object):
|
||||||
|
|
||||||
:type: str
|
: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):
|
def update(self):
|
||||||
"""
|
"""
|
||||||
Fetch the latest delivery information from the API.
|
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
|
:raises requests.exceptions.HTTPError: If the response has an
|
||||||
HTTP 4xx or 5xx code.
|
HTTP 4xx or 5xx code, or an empty payload.
|
||||||
:raises urbantz.exceptions.APIError: If the API returned an error.
|
|
||||||
"""
|
"""
|
||||||
resp = requests.get(self.api_url)
|
resp = requests.get(self.api_url)
|
||||||
resp.raise_for_status()
|
data = {}
|
||||||
data = resp.json()
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if 'error' in data:
|
if 'error' in data:
|
||||||
raise APIError(data['error'])
|
raise APIError(data['error'])
|
||||||
|
|
||||||
self.position = Coordinates.fromJSON(data['position'])
|
resp.raise_for_status()
|
||||||
self.destination = Coordinates.fromJSON(
|
if not data:
|
||||||
data['location']['location']['geometry'])
|
# 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()
|
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
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
class APIError(Exception):
|
from urbantz.base import JSONSerializable
|
||||||
|
|
||||||
|
|
||||||
|
class APIError(JSONSerializable, Exception):
|
||||||
"""
|
"""
|
||||||
An error returned by the UrbanTZ API.
|
An error returned by the UrbanTZ API.
|
||||||
This does not include HTTP errors.
|
This does not include HTTP errors.
|
||||||
|
@ -13,7 +16,18 @@ class APIError(Exception):
|
||||||
self.code = error.get('code')
|
self.code = error.get('code')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<APIError '{}'>".format(str(self))
|
return "<APIError {}>".format(repr(str(self)))
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.message or 'Unknown error'
|
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
132
urbantz/items.py
Normal 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)
|
0
urbantz/tests/__init__.py
Normal file
0
urbantz/tests/__init__.py
Normal file
91
urbantz/tests/test_coordinates.py
Normal file
91
urbantz/tests/test_coordinates.py
Normal 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")
|
95
urbantz/tests/test_delivery.py
Normal file
95
urbantz/tests/test_delivery.py
Normal 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
|
47
urbantz/tests/test_exceptions.py
Normal file
47
urbantz/tests/test_exceptions.py
Normal 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
206
urbantz/tests/test_full.py
Normal 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)
|
152
urbantz/tests/test_payload.json
Normal file
152
urbantz/tests/test_payload.json
Normal 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
102
urbantz/tests/test_utils.py
Normal 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,
|
||||||
|
)
|
|
@ -22,7 +22,7 @@ def main():
|
||||||
default=10,
|
default=10,
|
||||||
help='Update frequency in seconds',
|
help='Update frequency in seconds',
|
||||||
)
|
)
|
||||||
options = parser.parse_args()
|
options = vars(parser.parse_args())
|
||||||
delivery = options['delivery']
|
delivery = options['delivery']
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
@ -33,9 +33,10 @@ def main():
|
||||||
raise SystemExit('Invalid delivery ID')
|
raise SystemExit('Invalid delivery ID')
|
||||||
print('Error while fetching data:', str(e))
|
print('Error while fetching data:', str(e))
|
||||||
|
|
||||||
|
distance = delivery.position.distance(delivery.destination.coordinates)
|
||||||
print("{} {} meters".format(
|
print("{} {} meters".format(
|
||||||
delivery.last_updated.isoformat(),
|
delivery.last_updated.isoformat(),
|
||||||
round(delivery.position.distance(delivery.destination), 1),
|
round(distance, 1),
|
||||||
))
|
))
|
||||||
sleep(options['frequency'])
|
sleep(options['frequency'])
|
||||||
|
|
||||||
|
|
178
urbantz/utils.py
178
urbantz/utils.py
|
@ -1,132 +1,66 @@
|
||||||
from math import radians, cos, sin, asin, sqrt, trunc, floor, ceil
|
from urbantz.base import JSONSerializable
|
||||||
|
import re
|
||||||
EARTH_RADIUS_KM = 6371
|
|
||||||
|
|
||||||
|
|
||||||
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):
|
def __init__(self, *args, **kwargs):
|
||||||
"""
|
super().__init__(*args, **kwargs)
|
||||||
Get coordinates from decimal degrees.
|
self.__dict__.update(self)
|
||||||
|
|
||||||
: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 toJSON(self):
|
def toJSON(self):
|
||||||
"""
|
return dict(self)
|
||||||
Convert to UrbanTZ JSON geometry
|
|
||||||
|
|
||||||
:returns: UrbanTZ-compatible JSON geometry data
|
@classmethod
|
||||||
:rtype: list(float)
|
def fromJSON(cls, payload):
|
||||||
"""
|
return cls(payload)
|
||||||
return [self.lng, self.lat]
|
|
||||||
|
|
||||||
@staticmethod
|
def __getattr__(self, name):
|
||||||
def fromJSON(geometry):
|
try:
|
||||||
"""
|
return self.__getitem__(name)
|
||||||
Get a Coordinates instance from parsed UrbanTZ JSON geometry data.
|
except KeyError:
|
||||||
|
return super().__getattr__(name)
|
||||||
|
|
||||||
:param geometry: Parsed UrbanTZ geometry data: a list holding
|
def __missing__(self, name):
|
||||||
``[lng, lat]`` in decimal degrees.
|
camel = to_camel_case(name)
|
||||||
:type geometry: list(float)
|
if name == camel: # Prevent recursion
|
||||||
"""
|
raise KeyError(name)
|
||||||
return Coordinates(lng=geometry[0], lat=geometry[1])
|
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))
|
||||||
|
|
Reference in New Issue
Block a user