Much more exhaustive delivery data

This commit is contained in:
Lucidiot 2019-02-03 21:12:17 +00:00
parent b3bcce9203
commit 596675ec27
10 changed files with 858 additions and 10 deletions

View File

@ -1 +1 @@
0.1.1
0.2.0

View File

@ -7,6 +7,12 @@ Deliveries
.. automodule:: urbantz.delivery
:members:
Delivery items
--------------
.. automodule:: urbantz.items
:members:
Exceptions
----------
@ -18,3 +24,6 @@ Helper classes
.. automodule:: urbantz.base
:members:
.. automodule:: urbantz.utils
:members:

View File

@ -33,8 +33,6 @@ class Coordinates(JSONSerializable):
def __init__(self, lat=0, lng=0):
"""
Get coordinates from decimal degrees.
:param float lat: Latitude in decimal degrees.
:param float lng: Longitude in decimal degrees.
"""

View File

@ -1,8 +1,25 @@
from datetime import datetime
from datetime import datetime, timedelta
from urbantz.base import JSONSerializable, Coordinates
from urbantz.utils import DictObject
from urbantz.items import LogEntry, Item
from urbantz.exceptions import APIError
import requests
DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'
class Location(DictObject):
"""
A delivery destination.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'location' not in self:
return
point = self.pop('location')['geometry']
self['coordinates'] = Coordinates.fromJSON(point)
class Delivery(JSONSerializable):
"""
@ -37,18 +54,156 @@ class Delivery(JSONSerializable):
:type: dict or None
"""
self.id = None
"""
Identifier for this delivery.
:type: str or None
"""
self.date = None
"""
Date of the delivery.
:type: date or None
"""
self.task_id = None
"""
Task identifier for this delivery.
:type: str or None
"""
self.platform_id = None
"""
Platform identifier for this delivery.
:type: str or None
"""
self.driver_id = None
"""
Driver identifier for this delivery.
:type: str or None
"""
self.round_id = None
"""
Driver round identifier for this delivery.
:type: str or None
"""
self.instructions = None
"""
Delivery instructions given to the driver.
:type: str or None
"""
self.progress = None
"""
The delivery order progress.
:type: str or None
"""
self.status = None
"""
The delivery status.
:type: str or None
"""
self.arrival_time = None
"""
Estimated or actual arrival time.
:type: datetime or None
"""
self.eta_margin = None
"""
Margin, in minutes, for the estimated time of arrival.
:type: int or None
"""
self.eta_rounding = None
"""
Rounding, in minutes, for the estimated time of arrival.
:type: int or None
"""
self.time_window = None
"""
Planned delivery time window (start and end datetimes)
:type: list(datetime) or None
"""
self.position = None
"""
Coordinates of the delivery truck's position.
:type: urbantz.base.Coordinates
:type: urbantz.base.Coordinates or None
"""
self.destination = None
"""
Coordinates of the delivery destination.
The delivery's destination.
:type: urbantz.base.Coordinates
:type: urbantz.delivery.Location or None
"""
self.recipient = None
"""
Informations about the recipient (name, language, phone number, etc.)
Does not contain the destination location information.
:type: urbantz.utils.DictObject or None
"""
self.features = None
"""
Dictionary of booleans indicating which features of the UrbanTZ
tracking software are enabled on this delivery.
For example, ``consumerModifyInstructions`` will indicate whether
the client is allowed to update the delivery instructions after the
driver departs for its round.
:type: urbantz.utils.DictObject or None
"""
self.items = None
"""
List of delivery items.
:type: list(urbantz.items.Item)
"""
self.logs = None
"""
List of status update logs for the delivery.
:type: list(urbantz.items.LogEntry)
"""
self.theme = None
"""
Front-end theming information for the delivery tracking page.
:type: urbantz.utils.DictObject or None
"""
self.template = None
"""
Front-end template information for the delivery tracking page.
:type: urbantz.utils.DictObject or None
"""
def __repr__(self):
@ -65,6 +220,25 @@ class Delivery(JSONSerializable):
return 'https://backend.urbantz.com/public/task/tracking/{}'.format(
self.tracking_code)
@property
def eta(self):
"""
Estimated time of arrival: start and end datetimes, computed from the
arrival time and rounding settings
:type: list(datetime) or None
"""
if not (self.eta_margin and self.eta_rounding and self.arrival_time):
return
start = self.arrival_time - timedelta(minutes=self.eta_margin / 2)
start -= timedelta(
minutes=start.minute % self.eta_rounding,
seconds=start.second,
microseconds=start.microsecond,
)
end = start + timedelta(minutes=self.eta_margin)
return [start, end]
def update(self):
"""
Fetch the latest delivery information from the API.
@ -101,9 +275,41 @@ class Delivery(JSONSerializable):
:param dict payload: A parsed JSON payload from the API.
"""
self.payload = payload
self.id = self.payload['_id']
self.date = datetime.strptime(
self.payload['date'][:10], "%Y-%m-%d").date()
self.task_id = self.payload['taskId']
self.platform_id = self.payload['platform']
self.driver_id = self.payload['driver']
self.round_id = self.payload['round']
self.by = self.payload['by']
self.instructions = self.payload['instructions']
self.progress = self.payload['progress']
self.status = self.payload['status']
self.arrival_time = datetime.strptime(
self.payload['arriveTime'], DATE_FORMAT)
self.eta_margin = self.payload['eta']['margin']
self.eta_rounding = self.payload['eta']['rounding']
self.time_window = [
datetime.strptime(
self.payload['timeWindow']['start'],
DATE_FORMAT,
),
datetime.strptime(
self.payload['timeWindow']['stop'],
DATE_FORMAT,
),
]
self.position = Coordinates.fromJSON(self.payload['position'])
self.destination = Coordinates.fromJSON(
self.payload['location']['location']['geometry'])
self.destination = Location.fromJSON(self.payload['location'])
self.recipient = DictObject(self.payload['contact'])
self.features = DictObject(self.payload['features'])
self.template = DictObject(self.payload['template'])
self.theme = DictObject(self.payload['theme'])
self.logs = list(map(LogEntry.fromJSON, self.payload['log']))
self.items = list(map(Item.fromJSON, self.payload['items']))
@classmethod
def fromJSON(cls, payload):

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)

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

@ -0,0 +1,202 @@
from datetime import datetime, date
from unittest import TestCase
from urbantz.base import Coordinates
from urbantz.delivery import Location, Delivery
from urbantz.items import LogEntry, Item
from urbantz.utils import DictObject
import os.path
import json
FOLDER = os.path.dirname(os.path.realpath(__file__))
class TestFull(TestCase):
"""
Unit tests with a full delivery payload
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
with open(os.path.join(FOLDER, 'test_payload.json')) as f:
cls.delivery = Delivery.fromJSON(json.load(f))
def test_types(self):
self.assertIsNone(self.delivery.tracking_code)
self.assertIsNone(self.delivery.last_updated)
self.assertIsInstance(self.delivery.payload, dict)
self.assertIsInstance(self.delivery.id, str)
self.assertIsInstance(self.delivery.date, date)
self.assertIsInstance(self.delivery.task_id, str)
self.assertIsInstance(self.delivery.platform_id, str)
self.assertIsInstance(self.delivery.driver_id, str)
self.assertIsInstance(self.delivery.round_id, str)
self.assertIsInstance(self.delivery.instructions, str)
self.assertIsInstance(self.delivery.progress, str)
self.assertIsInstance(self.delivery.status, str)
self.assertIsInstance(self.delivery.arrival_time, datetime)
self.assertIsInstance(self.delivery.eta_margin, int)
self.assertIsInstance(self.delivery.eta_rounding, int)
self.assertIsInstance(self.delivery.time_window, list)
for dt in self.delivery.time_window:
self.assertIsInstance(dt, datetime)
self.assertIsInstance(self.delivery.position, Coordinates)
self.assertIsInstance(self.delivery.destination, Location)
self.assertIsInstance(self.delivery.recipient, DictObject)
self.assertIsInstance(self.delivery.features, DictObject)
self.assertIsInstance(self.delivery.items, list)
for item in self.delivery.items:
self.assertIsInstance(item, Item)
self.assertIsInstance(self.delivery.logs, list)
for log in self.delivery.logs:
self.assertIsInstance(log, LogEntry)
self.assertIsInstance(self.delivery.theme, DictObject)
self.assertIsInstance(self.delivery.template, DictObject)
def test_attributes(self):
self.assertEqual(self.delivery.id, "cafe1234")
self.assertEqual(self.delivery.date, date(2019, 2, 3))
self.assertEqual(self.delivery.task_id, "123456789")
self.assertEqual(self.delivery.platform_id, "934")
self.assertEqual(self.delivery.driver_id, "987654e")
self.assertEqual(self.delivery.round_id, "552bc6f")
self.assertEqual(self.delivery.instructions, "Third floor")
self.assertEqual(self.delivery.progress, "COMPLETED")
self.assertEqual(self.delivery.status, "DELIVERED")
self.assertEqual(self.delivery.arrival_time,
datetime(2019, 2, 3, 6, 45, 7))
self.assertEqual(self.delivery.eta_margin, 20)
self.assertEqual(self.delivery.eta_rounding, 5)
self.assertEqual(self.delivery.time_window, [
datetime(2019, 2, 3, 8, 0, 0),
datetime(2019, 2, 3, 10, 0, 0),
])
def test_dictobjects(self):
self.assertEqual(self.delivery.recipient.account, "54321")
self.assertIsNone(self.delivery.recipient.name)
self.assertEqual(self.delivery.recipient.person, "M. Chuck Norris")
self.assertEqual(self.delivery.recipient.phone, "33800772424")
self.assertEqual(self.delivery.recipient.language, "fr")
self.assertTrue(self.delivery.features.driver_round_resequence)
self.assertTrue(self.delivery.features.driver_ratings)
self.assertFalse(self.delivery.features.consumer_modify_instructions)
self.assertTrue(self.delivery.features.allow_virtual_fleet)
self.assertFalse(self.delivery.features.cancel_optimization)
self.assertEqual(self.delivery.theme.primary, "#efefef")
self.assertEqual(self.delivery.theme.secondary, "rgb(30, 40, 50)")
self.assertEqual(self.delivery.template.map_availability, 60)
self.assertTrue(self.delivery.template.enabled)
self.assertEqual(self.delivery.template.email, "contact@example.com")
self.assertEqual(self.delivery.template.phone, "0999999999")
self.assertEqual(self.delivery.template.background_color,
"rgba(50, 40, 20, 0.95)")
self.assertEqual(self.delivery.template.primary_color, "#ffffff")
self.assertEqual(self.delivery.template.secondary_color, "#731246")
self.assertEqual(self.delivery.template.background_picture, "bg.png")
self.assertEqual(self.delivery.template.logo, "logo.png")
self.assertEqual(
self.delivery.template.image_path,
"https://backend.urbantz.com/pictures/platforms/934/",
)
self.assertEqual(self.delivery.template.name, "Something")
self.assertEqual(self.delivery.template.icon, "icon.png")
self.assertEqual(self.delivery.destination.address_lines, [
"Baker Street", "742", "GU16 7HF", "Nowhere", "UK",
])
self.assertEqual(self.delivery.destination.geocode_score, 98)
self.assertEqual(self.delivery.destination.clean_score, 0)
self.assertEqual(self.delivery.destination.number, "742")
self.assertEqual(self.delivery.destination.street, "Baker Street")
self.assertEqual(self.delivery.destination.city, "Nowhere")
self.assertEqual(self.delivery.destination.zip, "GU16 7HF")
self.assertEqual(self.delivery.destination.country, "UK")
self.assertEqual(self.delivery.destination.origin, "ARCGIS")
self.assertEqual(self.delivery.destination.precision, "point")
def test_objects(self):
self.assertEqual(self.delivery.position,
Coordinates(lat=32.2135467, lng=0.1234567))
self.assertEqual(self.delivery.destination.coordinates, Coordinates(
lat=42.18906577220902, lng=2.21154054884875))
self.assertListEqual(self.delivery.logs, [
LogEntry(
to="GEOCODED",
by=None,
when="2019-02-01T23:07:22.997Z",
),
LogEntry(
to="ASSIGNED",
by="65dc8",
when="2019-02-02T05:16:22.557Z",
),
LogEntry(
to="ONGOING",
by="f3251c86513d",
when="2019-02-03T07:08:32.107Z",
),
LogEntry(
_id="5416a321",
to="COMPLETED",
by="6513d54e",
when="2019-02-03T07:56:47.814Z",
),
])
self.assertEqual(len(self.delivery.items), 1)
item = self.delivery.items[0]
self.assertFalse(item.damage_confirmed)
self.assertListEqual(item.damage_pictures, [])
self.assertEqual(item.status, "DELIVERED")
self.assertEqual(item.quantity, 1)
self.assertListEqual(item.labels, [])
self.assertListEqual(item.skills, [])
self.assertEqual(item.id, "cafe1")
self.assertEqual(item.type, "COFFEE")
self.assertEqual(item.barcode, "133742133742")
self.assertEqual(item.barcode_encoding, "upc")
self.assertIsInstance(item.dimensions, DictObject)
self.assertEqual(item.dimensions.weight, 19.01)
self.assertEqual(item.dimensions.cardboard, 2)
self.assertIsInstance(item.metadata, DictObject)
self.assertIsNone(item.metadata.package_number)
self.assertEqual(item.metadata.quantity, 0)
self.assertEqual(item.metadata.order_id_ext, "5255887")
self.assertEqual(item.metadata.new_client, "No")
self.assertListEqual(item.logs, [
LogEntry(
by=None,
when="2019-02-02T10:32:27.621Z",
to="ARRIVED",
),
LogEntry(
by="dead",
when="2019-02-03T07:08:32.107Z",
to="DEPARTED",
),
LogEntry(
_id="106",
by="c0fe",
when="2019-02-03T07:56:47.814Z",
to="DELIVERED",
),
])
def test_properties(self):
self.assertListEqual(self.delivery.eta, [
datetime(2019, 2, 3, 6, 35, 0),
datetime(2019, 2, 3, 6, 55, 0),
])
self.assertListEqual([log.datetime for log in self.delivery.logs], [
datetime(2019, 2, 1, 23, 7, 22, 997000),
datetime(2019, 2, 2, 5, 16, 22, 557000),
datetime(2019, 2, 3, 7, 8, 32, 107000),
datetime(2019, 2, 3, 7, 56, 47, 814000),
])
self.assertListEqual(
[log.datetime for log in self.delivery.items[0].logs],
[
datetime(2019, 2, 2, 10, 32, 27, 621000),
datetime(2019, 2, 3, 7, 8, 32, 107000),
datetime(2019, 2, 3, 7, 56, 47, 814000),
]
)

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

View File

@ -0,0 +1,82 @@
from unittest import TestCase
from urbantz.utils import to_camel_case, DictObject
class TestUtils(TestCase):
test_data = {
'first_name': 'Chuck',
'lastName': 'Norris',
'nested_dict': {
'key': 'value',
},
'nested_list': ['a', 'b', 'c'],
}
def test_to_camel_case(self):
self.assertEqual(to_camel_case('abcdef'), 'abcdef')
self.assertEqual(to_camel_case('some_thing'), 'someThing')
self.assertEqual(to_camel_case('a_b_c_d_e_f'), 'aBCDEF')
self.assertEqual(to_camel_case('hEllO_wOrLd'), 'hEllOWOrLd')
def test_dictobject_is_dict(self):
"""
Test the DictObject at least acts like a dict
"""
d = DictObject(self.test_data)
self.assertIsInstance(d, dict)
self.assertTrue(d)
# Dict comparison
self.assertEqual(d, self.test_data)
self.assertEqual(dict(d), d)
# Get, set, delete items
self.assertEqual(d['first_name'], 'Chuck')
self.assertIn('first_name', d)
del d['first_name']
self.assertNotIn('first_name', d)
d['first_name'] = 'Clutch'
self.assertEqual(d['first_name'], 'Clutch')
self.assertListEqual(
list(d.keys()),
['lastName', 'nested_dict', 'nested_list', 'first_name'],
)
self.assertListEqual(
list(d.values()),
['Norris', {'key': 'value'}, ['a', 'b', 'c'], 'Clutch'],
)
self.assertListEqual(list(d.items()), [
('lastName', 'Norris'),
('nested_dict', {'key': 'value'}),
('nested_list', ['a', 'b', 'c']),
('first_name', 'Clutch'),
])
def test_dictobject_attributes(self):
"""
Test DictObject translates attributes to items
"""
d = DictObject(self.test_data)
self.assertEqual(d.first_name, 'Chuck')
self.assertEqual(d.lastName, 'Norris')
del d.first_name
self.assertNotIn('first_name', d)
with self.assertRaises(AttributeError):
d.nope
def test_dictobject_camel_case(self):
"""
Test DictObject turns snake_cased attribute or item names into
camelCased names and tries again when not found
"""
d = DictObject(self.test_data)
self.assertEqual(d['lastName'], 'Norris')
self.assertEqual(d['last_name'], 'Norris')
self.assertEqual(d.lastName, 'Norris')
self.assertEqual(d.last_name, 'Norris')
self.assertIn('lastName', d)
self.assertIn('last_name', d)
del d.last_name
self.assertNotIn('lastName', d)

View File

@ -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'])

66
urbantz/utils.py Normal file
View File

@ -0,0 +1,66 @@
from urbantz.base import JSONSerializable
import re
def to_camel_case(value):
return re.sub(r'_(.)', lambda m: m.group(1).upper(), value)
class DictObject(JSONSerializable, dict):
"""
A utility class that turns a dict's items into object attributes.
This also performs snake to camel case conversion:
if a key is missing in the original dict, the key gets converted
to camel case.
>>> d = DictObject(first_name='Chuck', lastName='Norris')
>>> d.first_name
'Chuck'
>>> d.last_name
'Norris'
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.__dict__.update(self)
def toJSON(self):
return dict(self)
@classmethod
def fromJSON(cls, payload):
return cls(payload)
def __getattr__(self, name):
try:
return self.__getitem__(name)
except KeyError:
return super().__getattr__(name)
def __missing__(self, name):
camel = to_camel_case(name)
if name == camel: # Prevent recursion
raise KeyError(name)
return self.__getitem__(camel)
def __delattr__(self, name):
if name in self:
try:
self.__delitem__(name)
return
except KeyError:
pass
super().__delattr__(self, name)
def __delitem__(self, name):
try:
super().__delitem__(name)
except KeyError:
camel = to_camel_case(name)
if name == camel:
raise
super().__delitem__(camel)
def __contains__(self, name):
return super().__contains__(name) \
or super().__contains__(to_camel_case(name))