Metadata-Version: 2.1
Name: undictify
Version: 0.1.0
Summary: Type-safe dictionary unpacking / JSON deserialization
Home-page: http://github.com/Dobiasd/undictify
Author: Tobias Hermann
Author-email: editgym@gmail.com
License: UNKNOWN
Platform: UNKNOWN
Classifier: Programming Language :: Python :: 3.6
Classifier: Programming Language :: Python :: 3.7
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Description-Content-Type: text/markdown

![logo](logo/undictify.png)

[![Build Status](https://travis-ci.org/Dobiasd/undictify.svg?branch=master)][travis]
[![(License MIT 1.0)](https://img.shields.io/badge/license-MIT%201.0-blue.svg)][license]

[travis]: https://travis-ci.org/Dobiasd/undictify
[license]: LICENSE


undictify
=========
**Type-safe dictionary unpacking / JSON deserialization**


Table of contents
-----------------
  * [Motivation](#motivation)
  * [Details](#details)
  * [Requirements and Installation](#requirements-and-installation)


Motivation
----------

Imagine your application receives a JSON string representing an entity you need to handle:

```python
import json

tobi = json.loads('''
    {
        "id": 1,
        "name": "Tobias",
        "nick": "Tobi",
        "heart": {
            "weight_in_kg": 0.31,
            "pulse_at_rest": 52
        },
        "friends": [2, 3, 4, 5]
    }''')

katrin = json.loads('''
    {
        "id": 2,
        "name": "Katrin",
        "heart": {
            "weight_in_kg": 0.28,
            "pulse_at_rest": 57
        },
        "friends": [1, 3, 6, 7, 8]
    }''')
```

Now you start to work with it. Somewhere deep in your business logic you have:
```python
name_length = len(tobi['name'])
```
But that's only fine if the original JSON string was well-behaved.
If it had `"name": "Tobias",` in it, you would get:
```
    name_length = len(tobi['name'])
TypeError: tuple indices must be integers or slices, not str
```
which is not nice. So you start to manually do type checking:
```python
if isinstance(tobi['name'], str):
    name_length = len(tobi['name'])
else:
    # todo: handle the situation somehow
```

You quickly realize that you need to separate concerns better,
in that case business logic and input data validation.

So you start to do all checks directly after receiving the data:
```python
tobi = json.loads(...
if isinstance(tobi['name'], str):
    ...
if isinstance(tobi['age'], int):
    ...
if isinstance(... # *yawn*
```

and then transfer it into a type-safe class instance:
```python
class Heart(NamedTuple):
    weight_in_kg: float
    pulse_at_rest: int

class Human(NamedTuple):
    id: int
    name: str
    nick: Optional[str]
    heart: Heart
    friends: List[int]
```

Having the safety provided by the static type annotations (and probably checking your code with `mypy`) is a great thing for avoiding errors in the rest of your application and for helping your IDE to help you.
But all this boilerplate for validation code is cumbersome to write. 

So you decide to use a library that does JSON schema validation for you.
But now you have to manually adjust the schema every time your entity structure changes, which is not really DRY.

This is where undictify comes into play:
```python
from undictify import unpack_json
tobi: Human = unpack_json(Human, json.loads(...))
```

It uses the type information of your target class to safely unpack the dictionary into an object.
It throws exceptions with meaningful details in their associated values in case of an error like:
- missing a field
- a field having the wrong type

It also supports:
- optional values being omitted instead of being `None` explicitly
- skipping superfluous fields in the source dict


Details
-------

Undictify achieves the feat mentioned in the [motivation](#motivation) by `inspect`ing the types of the constructor parameters of the target class, instead of checking the actual members.

This allows classes to not only be plain data structures, but to do stuff with the valued given to them:
```python
class Person:
    def __init__(self, age: str) -> None:
        self.msg: str = f'Hi, I am {age} years old.'
    def say_something(self) -> None:
        print(self.msg)

somebody: Person = unpack_dict(Person, {'age': 28})
somebody.say_something()
```

Thus it is not limited to `class`es and `NamedTuple`s,
but it can do dictionary unpacking into arbitrary annotated functions.
So it resembles something like a type-safe version of `**kwargs`.

```python
def process(some_int: int, some_str: str) -> None:
    pass

result = undictify.unpack_json(process, '{"some_int": 42, "some_str": "foo"}')
```

It also supports valid type conversions, if you explicitly set its `convert_types` parameter to `True`.
This allows you to also use it for processing the arguments of an HTTP request you receive for example in a `get` handler of a `flask_restful.Resource` class:
```python
from undictify import unpack_dict

def target_function(some_int: int, some_str: str)

class WebController(Resource):
    def get(self) -> Any:
        result = unpack_dict(target_function, flask.request.args,
                             convert_types=True)
        # request.args is something like {"some_int": "4", "some_str": "hi"}
```

The values in the `MultiDict` `request.args` are all strings, but `unpack_dict` tries to convert them with `convert_types == True`.

This way the a request to `http://.../foo?some_int=4&some_str=hi` would be handled normally,
but `http://.../foo?some_int=four&some_str=hi` would raise an appropriate `TypeError`.


Requirements and Installation
-----------------------------


You need Python 3.6.5 or higher.

```bash
python3 -m pip install undictify
```

Or, if you like to use latest version from this repository:
```bash
git clone https://github.com/Dobiasd/undictify
cd undictify
python3 -m pip install .
```


License
-------
Distributed under the MIT License.
(See accompanying file [`LICENSE`](https://github.com/Dobiasd/undictify/blob/master/LICENSE) or at
[https://opensource.org/licenses/MIT](https://opensource.org/licenses/MIT))


