Skip to main content

Type-safe dictionary unpacking / JSON deserialization

Project description

logo

Build Status (License MIT 1.0)

undictify

Type-safe dictionary unpacking / JSON deserialization

Table of contents

Motivation

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

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:

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:

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:

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:

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:

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 by inspecting 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:

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 classes and NamedTuples, but it can do dictionary unpacking into arbitrary annotated functions. So it resembles something like a type-safe version of **kwargs.

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:

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.

python3 -m pip install undictify

Or, if you like to use latest version from this repository:

git clone https://github.com/Dobiasd/undictify
cd undictify
python3 -m pip install .

License

Distributed under the MIT License. (See accompanying file LICENSE or at https://opensource.org/licenses/MIT)

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

undictify-0.1.1.tar.gz (9.8 kB view hashes)

Uploaded source

Built Distribution

undictify-0.1.1-py3-none-any.whl (7.6 kB view hashes)

Uploaded py3

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page