Python Dict Wrapper
===================

This is a simple class that exposes a dictionary’s keys as class
attributes, making for less typing when accessing dictionary values.
This class also enforces that the dictionary’s overall **shape** is
maintained.

A common use of this class may be in retrieving and updating model
objects from web services (i.e. RESTful web services) where the shape of
the model object must be kept intact between when it is retrieved and
when it is saved.

For instance, if used with
`requests <https://github.com/psf/requests>`__, the output of a
request’s *json()* call can be wrapped and the resulting object will
behave in much the same manner as a real model object. The values can be
manipulated and later *unwrapped* to be sent back the server using a
requests *post()* call.

Using the python_dict_wrapper is pretty simple. You *wrap()* a
dictionary (or list). Then you manipulate and/or query it. Finally, you
can *unwrap()* to get the dictionary (or list) back.

A trivial example:

::

   import requests
   from python_dict_wrapper import wrap, unwrap

   actor_dict = requests.get('http://ficticious_actor_database_site.com/actors/c/carell_steve').json()

   # Returns:
   # {
   #    "name": "Steve Carell",
   #    "career": [{
   #        "medium": "TV",
   #        "title": "The Office"
   #    }, {
   #        "medium": "MOVIE",
   #        "title": "Bruce Almighty"
   #    }]
   #}

   actor = wrap(actor_dict)
   actor.career[1].title = "Despicable Me"
   unwrapped_actor = unwrap(actor)

   requests.post('http://ficticious_actor_database_site.com/actors/c/carell_steve', data=unwrapped_actor)

Installation
============

python_dict_wrapper is available on PyPi, so the easiest way to install
it is by using pip:

::

   $ pip install python-dict-wrapper

function make_wrapper(**kargs)
==============================

*make_wrapper* is a factory function for quickly instantiating a
*DictWrapper* from keyword arguments. It’s easier to demonstrate:

::

   >>> from python_dict_wrapper import make_wrapper
   >>>
   >>> person = make_wrapper(first_name='Steve', last_name='Carell', occupation='actor')
   >>> person
   <DictWrapper: {'first_name': 'Steve', 'last_name': 'Carell', 'occupation': 'actor'}>
   >>> person.last_name
   'Carell'

function wrap(data, strict=False, key_prefix=None, mutable=True)
================================================================

*wrap* is a factory function for generating either a DictWrapper or a
ListWrapper. It has one required argument and three optional ones:

-  data - A Python dictionary or a list of dictionaries that needs to be
   wrapped. If data is a dictionary, this method will return a
   DictWrapper instance. If it’s a list, the function will return a
   ListWrapper instance. This argument is required.
-  strict - An optional boolean that indicates if the wrapper should
   enforce types when setting attribute values.
-  key_prefix - A string or list of strings that contains characters
   that dictionary keys should be prefixed with before they become
   attributes.
-  mutable - A boolean indicating whether the DictWapper should be
   mutable or not.

This is a convenience function for when you have a data object and don’t
want to bother checking if it’s a dictionary or a list.

::

   >>> from python_dict_wrapper import wrap
   >>>
   >>> person_dict = {'first_name': 'Steve', 'last_name': 'Carell', 'occupation': 'actor'}
   >>>
   >>> person = wrap(person_dict)
   >>>
   >>> person
   <DictWrapper: {'first_name': 'Steve', 'last_name': 'Carell', 'occupation': 'actor'}>
   >>> person.occupation
   'actor'

function unwrap(wrapped_item)
=============================

The *unwrap* function will return the original item that was wrapped.

::

   >>> from python_dict_wrapper import wrap, unwrap
   >>> data_dict = {'first_name': 'Steve', 'last_name': 'Carell'}
   >>> id(data_dict)
   4497764480
   >>> wrapped_data_dict = wrap(data_dict)
   >>> id(wrapped_data_dict)
   4498248224
   >>> wrapped_data_dict
   <python_dict_wrapper.DictWrapper object at 0x10c1dd220>
   >>> unwrapped_data_dict = unwrap(wrapped_data_dict)
   >>> unwrapped_data_dict is data_dict
   True
   >>> unwrapped_data_dict
   {'first_name': 'Steve', 'last_name': 'Carell'}

The *unwrap* function will work on both *DictWrapper* items as well as
*ListWrapper* items. If the item passed to *unwrap* is not a
*DictWrapper* or a *ListWrapper*, *unwrap* will just return the item
untouched.

*DictWrapper* objects manipulate the original dictionary that they wrap
so unwrapping is technically unnecessary. That said, unwrap is available
in the event a reference to the original dictionary is lost or goes out
of scope.

function add_attribute(wrapped_item, attribute_name, attribute_value)
=====================================================================

The *add_attribute* function can be used to add an attribute to a
DictWrapper after it has been instantiated. It can be used if the
original dictionary is no longer available.

::

   >>> from python_dict_wrapper import wrap, add_attribute
   >>> auth_config = wrap({'username': 'john@doe.com', 'password': 'itza!secret'})
   >>> add_attribute(auth_config, 'host', 'ldap.doe.com')
   >>> auth_config.host
   'ldap.doe.com'

function del_attribute(wrapped_item, attribute_name)
====================================================

Conversely, *del_attribute* removes an existing attribute from an
existing DictWrapper. The del_attribute will return what the attribute’s
last value was before being removed.

::

   >>> from python_dict_wrapper import wrap, del_attribute
   >>> auth_config = wrap({'username': 'john@doe.com', 'password': 'itza!secret'})
   >>> del_attribute(auth_config, 'password')
   'itza!secret'
   >>> hasattr(auth_config, 'password')
   False

class DictWrapper(data, strict=False, key_prefix=None, mutable=True)
====================================================================

Like the wrap function, each *DictWrapper* instance takes one required
argument and three optional ones:

-  dict - A Python dictionary that the wrapper will use as it’s source.
   This argument is required.
-  strict - An optional boolean that indicates if the wrapper should
   enforce types when setting attribute values.
-  key_prefix - A string or list of strings that contains characters
   that dictionary keys should be prefixed with before they become
   attributes.
-  mutable - A boolean indicating whether the DictWapper should be
   mutable or not.

Attributes
----------

Once a *DictWrapper* instance has been created, the keys of it’s source
dictionary will be exposed as attributes. So for example if a
*DictWrapper* is instantiated with the following dictionary:

::

   >>> from dict_wrapper import wrap
   >>> address_dict = {'street': '221B Baker Street', 'city': 'London', 'country': 'UK'}
   >>> address = wrap(address_dict)

The keys: *street*, *city*, and ‘country’ will be exposed as attributes
of *address*

::

   >>> address.street
   '221B Baker Street'
   >>> address.city
   'London'
   >>> address.country
   'UK'

The attributes are both readable and writeable, so you can update the
values simply by assigning to them:

::

   >>> address.country = "United Kingdom"
   >>> address.country
   'United Kingdom'

If the *strict* argument to the constructor was set to *True*, then the
*DictWrapper* will enforce that that when you assign a new value to an
attribute, it must be the same Type as the original dictionary value.

::

   >>> address = wrap(address_dict, strict=True)
   >>> address.street = 221
   Traceback (most recent call last):
     File "<stdin>", line 1, in <module>
     File "dict_wrapper.py", line 62, in __setattr__
       raise TypeError("Value for %s must be a %s, not %s" % (
   TypeError: Value for street must be a str, not int

If the *key_prefix* argument to the constructor is set to a string or
list of strings, attributes in the dictionary are searched without their
prefixes. This is typically used for dictionaries that have keys that
cannot be represented in attributes. Here’s an example:

::

   >>> the_dict = {'@timestamp': '2020-04-19 05:00:00', 'author': 'Arthur Conan Doyle'}
   >>>
   >>> entry = wrap(the_dict)
   >>> entry.timestamp
   Traceback (most recent call last):
     File "<stdin>", line 1, in <module>
     File "python_dict_wrapper.py", line 49, in __getattr__
       self._check_for_bad_attribute(key)
     File "python_dict_wrapper.py", line 87, in _check_for_bad_attribute
       raise AttributeError("'%s' object has no attribute '%s'" % (self.__class__.__name__, key))
   AttributeError: 'DictWrapper' object has no attribute 'timestamp'
   >>>
   >>>
   >>> entry = DictWrapper(the_dict, key_prefix='@')
   >>> entry.timestamp
   '2020-04-19 05:00:00'

Methods
-------

*DictWrapper* instances have two methods: *to_json()* and *to_dict()*.

to_json(pretty=False)
~~~~~~~~~~~~~~~~~~~~~

Converts the dictionary values to a JSON string. If the *pretty*
argument is set to *True*, the returned JSON will be multi-lined and
indented with 4 characters. If it’s false, the returned JSON will a
single-line of text.

to_dict()
~~~~~~~~~

Converts the *DictWrapper* back to a Python dictionary.

Nesting
-------

*DictWrapper* instances should be able to handle nested dictionaries and
lists without issue. It automatically wraps any nested dictionaries in
their own *DictWrapper* instances for you.

::

   >>> shelock_dict = {
   ...     'name': 'Sherlock Holmes',
   ...     'address': {
   ...             'street': '221B Baker Street',
   ...             'city': 'London',
   ...             'country': 'UK'
   ...     }
   ... }
   >>> sherlock = DictWrapper(sherlock_dict)
   >>> sherlock.address.country = 'United Kingdom'
   >>> print(sherlock.to_json(pretty=True))
   {
       "name": "Sherlock Holmes",
       "address": {
           "street": "221B Baker Street",
           "city": "London",
           "country": "United Kingdom"
       }
   }

class ListWrapper(data, strict=False, key_prefix=None, mutable=True)
====================================================================

The *ListWrapper* is a “list” version of the *DictWrapper*. It is used
by the *DictWrapper* when nesting lists within dictionary values. The
*ListWrapper* is a subclass of a built-in Python list and behaves almost
exactly like a Python list with one exception. When retrieving items out
of the list if the item is a dictionary, it will wrap it in a
*DictWrapper*. If the item in question is a Python list, it will wrap it
in another ListWrapper.

::

   >>> from python_dict_wrapper import ListWrapper
   >>> the_list = [
   ...     'one',
   ...     [1, 2, 3],
   ...     {'color': 'blue'}
   ... ]
   >>> wrapped_list = ListWrapper(the_list)
   >>> wrapped_list[0]
   'one'
   >>> wrapped_list[1]
   <ListWrapper: [1, 2, 3]>
   >>> wrapped_list[1][2]
   3
   >>> wrapped_list[2]
   <DictWrapper: {'color': 'blue'}>
   >>> wrapped_list[2].color
   'blue'

Mutability
==========

If the *DictWrapper* is instantiated with *mutable* set to True
(default), the *DictWrapper* will be mutable, meaning the attribute can
be changed. However, if *mutable* is set to False when the DictWrapper
is instantiated, it will be immutable. You will not be able to change
any of the attributes (or nested attributes). Any ListWrappers that
result from lists within the underlying dict will also be immutable. You
will not be able to add/remove from them.

::

   >>> from python_dict_wrapper import wrap
   >>> auth_config = wrap({'username': 'john@doe.com', 'password': 'itza!secret'}, mutable=False)
   >>> auth_config.password
   'itza!secret'
   >>> auth_config.password = 'super!secret'
   Traceback (most recent call last):
     File "<stdin>", line 1, in <module>
     File "python_dict_wrapper.py", line 78, in __setattr__
       raise AttributeError("can't set attribute")
   AttributeError: can't set attribute

Performance
===========

*DictWrapper* and *ListWrapper* instances lazy evaluate on the original
dicts/lists that they are given when wrapped. As a result performance of
these classes should be roughly the same as their native counterparts.
