Compare commits

...

22 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
Lucidiot d47d49a3d8
Bump to 0.1.1 2018-10-25 19:14:31 +02:00
Lucidiot 7a993a469b Merge branch 'add-docs' into 'master'
Sphinx documentation

See merge request Lucidiot/pyurbantz!1
2018-10-25 17:10:49 +00:00
Lucidiot 6ccba1c9e1 Sphinx documentation 2018-10-25 17:10:49 +00:00
26 changed files with 1815 additions and 173 deletions

2
.coveragerc Normal file
View File

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

3
.gitignore vendored
View File

@ -21,6 +21,7 @@ wheels/
.installed.cfg
*.egg
MANIFEST
docs/_build/
htmlcov/
.tox/
@ -58,4 +59,4 @@ Session.vim
tags
[._]*.un~
.vscode/*
.vscode/*

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,19 @@ 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
pages:
stage: deploy
when: manual
only:
- master@Lucidiot/pyurbantz
artifacts:
paths:
- public
script:
- cd docs
- make html
- mv _build/html ../public

View File

@ -1,48 +1,3 @@
# pyurbantz
A Python package to help with an undocumented API of UrbanTZ.
The UrbanTZ company provides a delivery management platform of the same name
for other companies. To provide delivery tracking to their customers, those
companies can send links to a tracking page on UrbanTZ's website, which uses
a unique delivery ID in the URL to show tracking information.
Those tracking pages perform requests to an undocumented API endpoint with this
tracking ID. The endpoint provides much more information than what is actually
used in the pages; this package aims to provide a Python interface to help
creating better tracking interfaces.
## Requirements
This package just needs [requests](https://python-requests.org). That's it.
## Scripts
This package provides a simple tracker script, `urbantz.tracker`, that can be
invoked like this:
``` bash
python -m urbantz.tracker <ID> [-f|--frequency <seconds>]
```
The script will perform a request every 10 seconds (by default) to the
UrbanTZ API, then print the current date, time, and distance between the
delivery truck and the destination.
## Development
### Setup
Sample setup using
[`virtualenvwrapper`](https://virtualenvwrapper.readthedocs.io/):
```
mkvirtualenv pyurbantz -a .
pip install -e .[dev]
```
### Linting
The source code follows the PEP 8 code style and performs CI checks using the
`flake8` tool. To perform the same checks locally, run `flake8` on the root
directory of this repository.
A Python package to help with an undocumented API of UrbanTZ. [Browse documentation](https://lucidiot.gitlab.io/pyurbantz)

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.2.0

19
docs/Makefile Normal file
View File

@ -0,0 +1,19 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

29
docs/api.rst Normal file
View File

@ -0,0 +1,29 @@
API Reference
=============
Deliveries
----------
.. automodule:: urbantz.delivery
:members:
Delivery items
--------------
.. automodule:: urbantz.items
:members:
Exceptions
----------
.. automodule:: urbantz.exceptions
:members:
Helper classes
--------------
.. automodule:: urbantz.base
:members:
.. automodule:: urbantz.utils
:members:

185
docs/conf.py Normal file
View File

@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
#
# Configuration file for the Sphinx documentation builder.
#
# This file does only contain a selection of the most common options. For a
# full list see the documentation:
# http://www.sphinx-doc.org/en/master/config
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os
import sys
sys.path.insert(0, os.path.abspath('..'))
# -- Project information -----------------------------------------------------
project = 'pyurbantz'
copyright = '2018, Lucidiot and contributors'
author = 'Lucidiot and contributors'
# The short X.Y version
version = ''
# The full version, including alpha/beta/rc tags
release = open('../VERSION').read().strip()
# -- General configuration ---------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#
# needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.coverage',
'sphinx.ext.viewcode',
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'
# The master toctree document.
master_doc = 'index'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'default'
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'alabaster'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#
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,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# Custom sidebar templates, must be a dictionary that maps document names
# to template names.
#
# The default sidebars (for documents that don't match any pattern) are
# defined by theme itself. Builtin themes are using these templates by
# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
# 'searchbox.html']``.
#
# html_sidebars = {}
# -- Options for HTMLHelp output ---------------------------------------------
# Output file base name for HTML help builder.
htmlhelp_basename = 'pyurbantzdoc'
# -- Options for LaTeX output ------------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, 'pyurbantz.tex', 'pyurbantz Documentation',
'Lucidiot and contributors', 'manual'),
]
# -- Options for manual page output ------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, 'pyurbantz', 'pyurbantz Documentation',
[author], 1)
]
# -- Options for Texinfo output ----------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(master_doc, 'pyurbantz', 'pyurbantz Documentation',
author, 'pyurbantz', 'One line description of project.',
'Miscellaneous'),
]
# -- Options for Epub output -------------------------------------------------
# Bibliographic Dublin Core info.
epub_title = project
# The unique identifier of the text. This can be a ISBN number
# or the project homepage.
#
# epub_identifier = ''
# A unique identification for the text.
#
# epub_uid = ''
# A list of files that should not be packed into the epub file.
epub_exclude_files = ['search.html']
# -- Extension configuration -------------------------------------------------
# Concatenate the class' and __init__'s docstrings when documenting a class
autoclass_content = 'both'

87
docs/contributing.rst Normal file
View File

@ -0,0 +1,87 @@
Contributing
============
Contributions to the project are greatly appreciated.
Bugs and suggestions
--------------------
You may `submit an issue`_ to GitLab to warn of any bugs, ask for new features,
or ask any questions that are not answered in this documentation.
When reporting a bug, do not forget to put in your version of Python and your
version of *pyurbantz*. This will greatly help when troubleshooting, as most
errors often come from version incompatibilities.
Development
-----------
Setup
^^^^^
You will need a virtual envionment to work properly. `virtualenvwrapper`_ is
recommended::
git clone https://gitlab.com/Lucidiot/pyurbantz
cd pyurbantz
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.
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
^^^^^^^
The source code follows the PEP 8 code style and performs CI checks using the
``flake8`` tool. To perform the same checks locally, run ``flake8`` on the root
directory of this repository.
Documentation
-------------
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

70
docs/index.rst Normal file
View File

@ -0,0 +1,70 @@
Python UrbanTZ client
=====================
:ref:`genindex` - :ref:`modindex` - :ref:`search`
.. image:: https://img.shields.io/pypi/v/pyurbantz.svg
:target: https://pypi.org/project/pyurbantz
.. image:: https://img.shields.io/pypi/l/pyurbantz.svg
:target: https://pypi.org/project/pyurbantz
.. image:: https://img.shields.io/pypi/format/pyurbantz.svg
:target: https://pypi.org/project/pyurbantz
.. image:: https://img.shields.io/pypi/pyversions/pyurbantz.svg
:target: https://pypi.org/project/pyurbantz
.. image:: https://img.shields.io/pypi/status/pyurbantz.svg
:target: https://pypi.org/project/pyurbantz
.. image:: https://gitlab.com/Lucidiot/pyurbantz/badges/master/pipeline.svg
:target: https://gitlab.com/Lucidiot/pyurbantz/pipelines
.. image:: https://requires.io/github/Lucidiot/pyurbantz/requirements.svg?branch=master
:target: https://requires.io/github/Lucidiot/pyurbantz/requirements/?branch=master
.. image:: https://img.shields.io/github/last-commit/Lucidiot/pyurbantz.svg
:target: https://gitlab.com/Lucidiot/pyurbantz/commits
.. image:: https://img.shields.io/badge/badge%20count-9-brightgreen.svg
:target: https://gitlab.com/Lucidiot/pyurbantz
A Python package to help with an undocumented API of
`UrbanTZ <https://www.urbantz.com/>`_.
The UrbanTZ company provides a delivery management platform of the same name
for other companies. To provide delivery tracking to their customers, those
companies can send links to a tracking page on UrbanTZ's website, which uses
a unique delivery ID in the URL to show tracking information.
Those tracking pages perform requests to an undocumented API endpoint with this
tracking ID. The endpoint provides much more information than what is actually
used in the pages; this package aims to provide a Python interface to help
creating better tracking interfaces.
Requirements
------------
This package just needs `requests <https://python-requests.org>`_. That's it.
Scripts
-------
This package provides a simple tracker script, ``urbantz.tracker``, that can be
invoked like this::
python -m urbantz.tracker <ID> [-f|--frequency <seconds>]
The script will perform a request every 10 seconds (by default) to the
UrbanTZ API, then print the current date, time and distance between the
delivery truck and the destination.
Other topics
------------
.. toctree::
:maxdepth: 2
contributing
api

View File

@ -1 +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

3
setup.py Normal file → Executable file
View File

@ -1,3 +1,4 @@
#!/usr/bin/env python3
from setuptools import setup, find_packages
@ -7,7 +8,7 @@ def read_requirements(filename):
setup(
name='pyurbantz',
version='0.1.0',
version=open('VERSION').read().strip(),
author='Lucidiot',
packages=find_packages(
exclude=["*.tests", "*.tests.*", "tests.*", "tests"]),

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,37 +1,327 @@
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):
self.id = id
def __init__(self, tracking_code=None):
"""
:param tracking_code: A delivery public tracking code.
:type tracking_code: str or None
"""
self.tracking_code = tracking_code
"""
The delivery public tracking code.
:type: str or None
"""
self.last_updated = None
"""
Last API update date/time. Is None if data has never been fetched
from the API.
: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.base.Coordinates or None
"""
self.destination = None
"""
The delivery's destination.
: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):
return 'https://backend.urbantz.com/public/task/tracking/' + self.id
"""
URL pointing to the API endpoint to use for the specific delivery.
:type: str
"""
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, 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,11 +1,33 @@
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.
"""
def __init__(self, error):
"""
:param error: Parsed JSON error from the API.
:type error: dict
"""
self.message = error.get('message')
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,108 +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):
"""
Coordinates from 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):
return tuple(map(radians, self))
def distance(self, other):
"""
Compute Haversine distance between two coordinates.
"""
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 [self.lng, self.lat]
return dict(self)
@staticmethod
def fromJSON(geometry):
return Coordinates(lng=geometry[0], lat=geometry[1])
@classmethod
def fromJSON(cls, payload):
return cls(payload)
def __getattr__(self, name):
try:
return self.__getitem__(name)
except KeyError:
return super().__getattr__(name)
def __missing__(self, name):
camel = to_camel_case(name)
if name == camel: # Prevent recursion
raise KeyError(name)
return self.__getitem__(camel)
def __delattr__(self, name):
if name in self:
try:
self.__delitem__(name)
return
except KeyError:
pass
super().__delattr__(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))