middle

Flexible, extensible Python data structures for general usage. Get data in and out, reliably, without boilerplate and with speed!

middle stands on the shoulders of attrs and aims to be as simple as possible to get data from complex objects to Python primitives and vice-versa, with validators, converters, a lot of sugar and other utilities! middle can be used with your preferred web framework, background job application, configuration parser and more!

Quick peak

The most simple example of middle and some of its features (using Python 3.6+ syntax):

>>> import typing
>>> import middle

>>> class Address(middle.Model):
...     street_name: str
...     number: typing.Optional[int]
...     city: str

>>> class Person(middle.Model):
...     name: str
...     age: int
...     address: typing.Dict[str, Address]

>>> data = {
...     "name": "John Doe",
...     "age": 42,
...     "address": {
...         "home": {
...             "street_name": "Foo St",
...             "number": None,
...             "city": "Python Park"
...         },
...         "work": {
...             "street_name": "Bar Blvd",
...             "number": "1337",
...             "city": "Park City"
...         }
...     }
... }

>>> person = Person(data)

>>> person
Person(name='John Doe', age=42, address={'home': Address(street_name='Foo St', number=None, city='Python Park'), 'work': Address(street_name='Bar Blvd', number=1337, city='Park City')})

>>> middle.asdict(person)
{'name': 'John Doe', 'age': 42, 'address': {'home': {'street_name': 'Foo St', 'number': None, 'city': 'Python Park'}, 'work': {'street_name': 'Bar Blvd', 'number': 1337, 'city': 'Park City'}}}

Wanted a more complex example, with Python 3.5 compatible syntax? For sure!

>>> from typing import Dict, List
>>> import middle

>>> class Game(middle.Model):
...     name = middle.field(type=str)
...     score = middle.field(type=float, minimum=0, maximum=10)
...     resolution_tested = middle.field(type=str, pattern="^\d+x\d+$")
...     genre = middle.field(type=List[str], unique_items=True)
...     rating = middle.field(type=Dict[str, float], max_properties=5)

>>> data = {
...     "name": "Cities: Skylines",
...     "score": 9.0,
...     "resolution_tested": "1920x1200",
...     "genre": ["Simulators", "City Building"],
...     "rating": {
...         "IGN": 8.5,
...         "Gamespot": 8.0,
...         "Steam": 4.5
...     }
... }

>>> game = Game(**data)

>>> game
Game(name='Cities: Skylines', score=9.0, resolution_tested='1920x1200', genre=['Simulators', 'City Building'], rating={'IGN': 8.5, 'Gamespot': 8.0, 'Steam': 4.5})

>>> middle.asdict(game)
{'name': 'Cities: Skylines', 'score': 9.0, 'resolution_tested': '1920x1200', 'genre': ['Simulators', 'City Building'], 'rating': {'IGN': 8.5, 'Gamespot': 8.0, 'Steam': 4.5}}

middle is flexible enough to understand Enum, nested models and a large variety of types declared on the typing module out of the box. Also, you can extend it to your own classes!

Warning

IMPORTANT: middle is in very early stages of development. There’s a lot of functionalities that needs to be implemented and some known misbehaviors to be addressed, not to mention it needs a lot of testing before moving to any other status rather than alpha.


About middle

middle is a library created to provide a pythonic way to describe your data structures in the most simple way by using typing hints (from PEP 484), not only providing better readability but also all the necessary boilerplate to create objects based on your data structures, validate and customize input and output.

middle is fast. Lightning fast. It can be used with your favourite web framework, background application, configuration parser or any other usage for highly customized input and/or output of primitive values.

Important

middle was designed with typing hints in mind. It’ll not work if no type is provided within your classes!

What to expect

middle stands on the shoulders of attrs and aims to be as simple as possible to get data from complex objects to Python primitives and vice-versa.

As for “primitives”, you can expect to have simple Python values (like list, dict, str, int, Enum and others) to convert from and to complex objects, containing even other nested objects from any type you can imagine (do not forget to read how to extend middle later on).

Another feature you can expect from middle is it speed. middle can convert from and to objects in a fraction of time compared to some of his “nearest” relatives, which makes it perfect if you need to handle lots of data. But you can use middle with any use case you (my dear friend developer) find fit, because it will do the job just fine.

Benchmarks

(Sneaky) author took a copy of pydantic benchmark suite and created a benchmark file for middle (mostly illustrative):

import typing
from datetime import datetime

import middle


class TestMiddle:
    package = "middle"

    def __init__(self, allow_extra):
        class Location(middle.Model):
            latitude: float = middle.field(default=None)
            longitude: float = middle.field(default=None)

        class Skill(middle.Model):
            subject: str = middle.field()
            subject_id: int = middle.field()
            category: str = middle.field()
            qual_level: str = middle.field()
            qual_level_id: int = middle.field()
            qual_level_ranking: float = middle.field(default=0)

        class Model(middle.Model):
            id: int = middle.field()
            client_name: str = middle.field(max_length=255)
            sort_index: float = middle.field()
            grecaptcha_response: str = middle.field(
                min_length=20, max_length=1000
            )
            client_phone: str = middle.field(max_length=255, default=None)
            location: Location = middle.field(default=None)
            contractor: int = middle.field(minimum=1, default=None)
            upstream_http_referrer: str = middle.field(
                max_length=1023, default=None
            )
            last_updated: datetime = middle.field(default=None)
            skills: typing.List[Skill] = middle.field(default=[])

        self.model = Model

    def validate(self, data):
        try:
            return True, self.model(**data)
        except Exception as e:
            return False, str(e)

I must say that the results were really encouraging considering this is pure Python code (on an old AMD machine), no Cython, no Jit:

Framework Comparison avg/iter stdev/iter
middle (alpha)   39.1μs 0.186μs
pydantic 1.6x slower 62.4μs 0.278μs
toasted-marshmallow 1.7x slower 64.9μs 0.352μs
marshmallow 2.0x slower 79.3μs 0.137μs
trafaret 2.5x slower 97.7μs 1.586μs
django-restful-framework 17.0x slower 662.8μs 1.649μs

Warning

Keep in mind that middle is still in alpha stage. This benchmark will must likely change as middle evolves, since there are some features missing yet (to get to version 1.0.0).

Installing

To install middle, use pip (or your favorite Python package manager), like:

pip install middle

And you’re ready to the next step. Hooray!

Warning

At this time, middle is only available for Python 3.5+.

Using middle

After installing middle, it is easy to use it in your project. Most of its functionalities are acessible within the middle module already:

import middle

middle consists basically in three parts:

  • middle.Model: the base class that needs to be inherited to declare all your models (except submodels);
  • middle.field: a function that is used to declare your field models; and
  • middle.asdict: a function required to convert your model instances to Python primitives.

middle.Model

The middle.Model class is the heart of middle. To have all middle functionality at your disposal, middle.Model needs to be subclassed when declaring your models:

class MyModel(middle.Model):
    name: str = middle.field()  # Python 3.6+ syntax
    name = middle.field(type=str)  # Python 3.5 syntax

In essence, middle.Model started as a syntactic sugar for the attr.s decorator but soon evolved to a more complex design, implementing its own metaclass to handle some aspects of its models and fields.

Note

Since middle.Model already implements its own metaclass, it should be wise not to mix it with other classes that have a metaclass different than type.

To create an instance of your model, you can:

  • Use keyword arguments:

    >>> MyModel(name="foo")
    MyModel(name='foo')
    
  • Use a dict:

    >>> MyModel({"name": "foo"})
    MyModel(name='foo')
    
  • Use a dict as **kwargs:

    >>> MyModel(**{"name": "foo"})
    MyModel(name='foo')
    
  • Use any object instance that have acessible attributes with the same name as the required ones from your model:

    >>> MyModel(some_obj_with_name_accessible)
    MyModel(name='foo')
    

middle.field

The middle.field function is used to declare your model’s fields, with support to the type definition and other options that can be used later to define your model behavior regarding converting input values, validating values and format values for Python primitives. middle.field makes heavy usage of attr.ib calls, specially to store information into the metadata dict.

There are three ways to declare your fields inside middle.Model, you don’t have to necessarily use middle.field, though it will be called under the hood to have a uniform model.

Declaring models, using middle.field and typing hints and annotations (PEP-526, for Python 3.6+):

class MyModel(middle.Model):
    id: int = middle.field()
    name: str = middle.field(min_length=5)
    active: bool = middle.field(default=False)
    created_on: datetime = middle.field(default=None)

Declaring models, using middle.field and type keyword (Python 3.5 compatible):

class MyModel(middle.Model):
    id = middle.field(type=int)
    name = middle.field(type=str, min_length=5)
    active = middle.field(type=bool, default=False)
    created_on = middle.field(type=datetime, default=None)

Declaring models, without middle.field, using typing hints, annotations (Python 3.6+ only) and a dict:

class MyModel(middle.Model):
    # id: int  # or ...
    id: int = {}
    name: str = {"min_length": 5}
    active: bool = {"default": False}
    created_on: datetime = {"default": None}

Declaring models, without middle.field, using only a dict (Python 3.5 compatible):

class MyModel(middle.Model):
    id = {"type": int}
    name = {"type": str, "min_length": 5}
    active = {"type": str, "default": False}
    created_on = {"type": datetime, "default": None}

Declaring models, without middle.field, using only typing hints and annotations (inspired by pydantic, works only with Python 3.6+):

class MyModel(middle.Model):
    id: int
    name: str
    active: str
    created_on: datetime

Warning

Declaring models using only typing hints annotations will not enable support for keyword embed validators.

Declaring models, the chaotic way (won’t work on Python 3.5):

class MyModel(middle.Model):
    id: int
    name = {"type": str, "min_length": 5}
    active: bool = middle.field(default=False)
    created_on = middle.field(type=datetime, default=None)

Tip

Developers are free to choose their preferred style (matching the Python version), although sticking to one can help readabilty.

middle.asdict

This method, provided with an instance of a middle.Model class, will return a dict of key-values that will reflect the data of the instance against the model typing hints only.

>>> instance = MyModel(
...     id=42,
...     name="foo bar",
...     created_on=datetime.utcnow()
... )

>>> instance
MyModel(id=42, name='foo bar', active=False, created_on=datetime.datetime(2018, 7, 5, 14, 14, 12, 319270))

>>> middle.asdict(instance)
{'id': 42, 'name': 'foo bar', 'active': False, 'created_on': '2018-07-05T17:14:12.319270+00:00'}

Types in middle

Typing hints (from PEP 484) are a major improvement to the Python language when dealing with documentation, code readability, static (and also runtime) code analysis and, in some cases, to provide extra logic to your code (which is, of course, the goal of middle).

middle supports a lot of builtin types, some from regularly used modules. There’s also the possibility of customize middle to support many other types, including custom ones.

Supported types

  • str
  • int
  • float
  • bool
  • bytes
  • datetime.date
  • datetime.datetime
  • decimal.Decimal
  • enum.Enum
  • typing.Dict
  • typing.List
  • typing.Set
  • typing.Tuple
  • typing.Union

dict, list, set

Since dict, list and set can’t have a distinguished type, they will not be supported by middle. Instead, use typing.Dict, typing.List and typing.Set, respectively.

datetime.date and datetime.datetime

For now, middle depends on one extra requirements to properly handle date and datetime objects, which is python-dateutil (to properly format datetime string representations into datetime instances), and it is generally found on most Python projects / libraries already (that handles datetime string parsing).

Important

All naive datetime string or timestamp representations will be considered as UTC (and converted accordingly) by middle, thus any of this objects or representations that are not naive will be automatically converted to UTC for uniformity. Naive datetime objects will transit with the current machine timezone and converted to UTC for uniformity (this transition can be modified to be considered as UTC, see more about configuring middle).

Warning

Even though the datetime API provides us two methods for getting the current date and time, now and utcnow, both instances will be created without tzinfo, thus making them naive. Basically, it means that no one can determine if a datetime object is not naive if the timezone is not explicitly provided:

>>> from datetime import datetime

>>> x = datetime.now()
>>> x
datetime.datetime(2018, 7, 9, 14, 21, 44, 624833)

>>> x.tzinfo is None
True

>>> y = datetime.utcnow()
>>> y
datetime.datetime(2018, 7, 9, 17, 22, 12, 673103)

>>> y.tzinfo is None
True

The recomended way to get an aware UTC datetime object would be:

>>> from datetime import datetime, timezone

>>> datetime.now(timezone.utc)
datetime.datetime(2018, 7, 9, 17, 26, 8, 874805, tzinfo=datetime.timezone.utc)
Examples

Considering a machine configured to GMT-0300 timezone at 10:30 AM local time:

>>> import datetime
... import pytz

>>> from middle.dtutils import dt_convert_to_utc
... from middle.dtutils import dt_from_iso_string
... from middle.dtutils import dt_from_timestamp
... from middle.dtutils import dt_to_iso_string

>>> dt_to_iso_string(datetime.datetime.now())
'2018-07-10T13:30:00+00:00'

>>> dt_to_iso_string(datetime.datetime.utcnow())
'2018-07-10T16:30:00+00:00'

>>> dt_from_iso_string("2018-07-02T08:30:00+01:00")
datetime.datetime(2018, 7, 2, 7, 30, tzinfo=datetime.timezone.utc)

>>> dt_from_iso_string("2018-07-02T08:30:00")
datetime.datetime(2018, 7, 2, 8, 30, tzinfo=datetime.timezone.utc)

>>> dt_from_timestamp(1530520200)
datetime.datetime(2018, 7, 2, 8, 30, tzinfo=datetime.timezone.utc)

>>> dt_from_timestamp(1530520200.000123)
datetime.datetime(2018, 7, 2, 8, 30, 0, 123, tzinfo=datetime.timezone.utc)

>>> dt_convert_to_utc(datetime.datetime(2018, 7, 2, 8, 30, 0, 0, pytz.timezone("CET")))
datetime.datetime(2018, 7, 2, 7, 30, tzinfo=datetime.timezone.utc)

>>> dt_convert_to_utc(dt_from_iso_string("2018-07-02T08:30:00+01:00"))
datetime.datetime(2018, 7, 2, 7, 30, tzinfo=datetime.timezone.utc)

One plus of using datetime in middle is that it accepts a wide range of inputs, having in mind that we’re talking about Python here (see the datetime constructor to understand why):

>>> from datetime import datetime, timezone
>>> import middle

>>> class TestModel(middle.Model):
...     created_on: datetime = middle.field()  # for Python 3.6+
...     created_on = middle.field(type=datetime)  # for Python 3.5

>>> TestModel(created_on=datetime.now())
TestModel(created_on=datetime.datetime(2018, 7, 10, 15, 1, 6, 121325, tzinfo=datetime.timezone.utc))

>>> TestModel(created_on=datetime.now(timezone.utc))
TestModel(created_on=datetime.datetime(2018, 7, 10, 15, 1, 40, 769369, tzinfo=datetime.timezone.utc))

>>> TestModel(created_on="2018-7-7 4:42pm")
TestModel(created_on=datetime.datetime(2018, 7, 7, 16, 42, tzinfo=datetime.timezone.utc))

>>> TestModel(created_on=1530520200)
TestModel(created_on=datetime.datetime(2018, 7, 2, 8, 30, tzinfo=datetime.timezone.utc))

>>> TestModel(created_on=(2018, 7, 9, 10))
TestModel(created_on=datetime.datetime(2018, 7, 9, 13, 0, tzinfo=datetime.timezone.utc))

>>> TestModel(created_on=(2018, 7, 9, 10, 30, 0, 0, 1))
TestModel(created_on=datetime.datetime(2018, 7, 9, 9, 30, tzinfo=datetime.timezone.utc))

Important

In the last input (in the example above), where a tuple of 8 integers were given for the created_on parameter, the last value corresponds to the UTC offset in hours.

“But I only trust on [arrow|momentum|maya]”

Well, I don’t blame you. These operations regarding date and datetime were created for middle to provide an out-of-the-box solution for the most used types in Python, but, don’t worry, you can override these operations with your own. Just head out to extending and catch up some examples.

Enum

Most enum types will be directly available from and to primitives by acessing the .value attribute of each instance. A lot of complex examples can work out of the box:

>>> import enum
... import middle

>>> class AutoName(enum.Enum):
...     def _generate_next_value_(name, start, count, last_values):
...         return name

>>> class TestAutoEnum(AutoName):
...     FOO = enum.auto()
...     BAR = enum.auto()
...     BAZ = enum.auto()

>>> @enum.unique
... class TestStrEnum(str, enum.Enum):
...     CAT = "CAT"
...     DOG = "DOG"
...     BIRD = "BIRD"

>>> @enum.unique
... class TestIntEnum(enum.IntEnum):
...     FIRST = 1
...     SECOND = 2
...     THIRD = 3

>>> class TestFlagEnum(enum.IntFlag):
...     R = 4
...     W = 2
...     X = 1

>>> instance = TestModel(auto_enum=TestAutoEnum.FOO, str_enum=TestStrEnum.CAT, int_enum=TestIntEnum.FIRST, flg_enum=TestFlagEnum.R | TestFlagEnum.W)
>>> instance
TestModel(auto_enum=<TestAutoEnum.FOO: 'FOO'>, str_enum=<TestStrEnum.CAT: 'CAT'>, int_enum=<TestIntEnum.FIRST: 1>, flg_enum=<TestFlagEnum.R|W: 6>)

>>> data = middle.asdict(instance)
>>> data
{'auto_enum': 'FOO', 'str_enum': 'CAT', 'int_enum': 1, 'flg_enum': 6}

>>> TestModel(**data)  # to test if flg_enum=6 would work
TestModel(auto_enum=<TestAutoEnum.FOO: 'FOO'>, str_enum=<TestStrEnum.CAT: 'CAT'>, int_enum=<TestIntEnum.FIRST: 1>, flg_enum=<TestFlagEnum.R|W: 6>)

Future plans on types

There are some types in the Python stdlib that are planned to be part of middle in the near future:

  • uuid.uuid[1,3-5]

If there’s a type you would like to see on middle, feel free to open an issue or submit a PR.

Validating data

Some types on middle can be validated based on single or multiple keywords that can be available to the field method, but will only trigger validation based on the type of the field.

Tip

Validation on middle is based on rules declared in the OpenAPI specification, so most of them should not be strange to those familiar with web development.

Important

All types have at least one validator: asserting if the type of the value is the one defined in the class (or None, if typing.Optional or have the keyword default set to None).

Warning

All validators that corresponds to a certain type will be called. This behavior may change in the future because I simply don’t know if, in the OpenAPI specification, one (certain validator) may exclude another.

Range validators

There are some validators that works within a certain range that may contains a max and a min value (with keywords specific for a type). Given these values are mostly quantitatives, they cannot be negative (or ValueError will be raised), except for instances of type int or float; nor the upper bound value can be equal or less than the lower bound value. Some other keywords may have some effect on these values as well.

\[lower\_bound \lt value \lt upper\_bound\]

Important

Optionally range validators can also be used only with the lower_bound or upper_bound value if no bound is needed in one of the values.

String validators

There are some validators for the str type that can

Range: min_length and max_length
\[min\_length \leqslant len(value) \leqslant max\_length\]

Setting min_length keyword to an integer would require that the input value should have at least the given value of length:

>>> import middle

>>> class TestModel(middle.Model):
...     name = middle.field(type=str, min_length=3)

>>> TestModel(name="hello")
TestModel(name='hello')

>>> TestModel(name="hi")

Given the input value above, here’s the resulting Traceback:

Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/dev/middle/src/middle/model.py", line 80, in __call__
    return super().__call__(**kwargs)
File "<attrs generated init 70f3aaa3ccac019c22d47311619cd3804d1a9311>", line 4, in __init__
File "/home/dev/.pyenv/versions/middle-3.6.6/lib/python3.6/site-packages/attr/_make.py", line 1668, in __call__
    v(inst, attr, value)
File "/home/dev/middle/src/middle/validators/common.py", line 11, in wrapper
    return func(meta_value, instance, attribute, value)
File "/home/dev/middle/src/middle/validators/common.py", line 21, in min_str_len
    attribute.name, meta_value
middle.exceptions.ValidationError: 'name' must have a minimum length of 3 chars

Setting the max_length keyword to an integer would require that the input value should have no more than the given value of length:

>>> import middle

>>> class TestModel(middle.Model):
...     name: str = middle.field(max_length=5)

>>> TestModel(name="hello")
TestModel(name='hello')

>>> TestModel(name="hello, world")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/dev/middle/src/middle/model.py", line 80, in __call__
    return super().__call__(**kwargs)
File "<attrs generated init 9b3b7c0ce74ad8f645d202b99d5df010c034e2b0>", line 4, in __init__
File "/home/dev/.pyenv/versions/middle-3.6.6/lib/python3.6/site-packages/attr/_make.py", line 1668, in __call__
    v(inst, attr, value)
File "/home/dev/middle/src/middle/validators/common.py", line 11, in wrapper
    return func(meta_value, instance, attribute, value)
File "/home/dev/middle/src/middle/validators/common.py", line 31, in max_str_len
    attribute.name, meta_value
middle.exceptions.ValidationError: 'name' must have a maximum length of 5 chars
pattern

Setting the pattern keyword to a string representing a regular expression (or a regular expression object) would require that the input value should match the value given:

>>> import middle

>>> class TestModel(middle.Model):
...     serial = {"type": str, "pattern": "^[0-9]+$"}

>>> TestModel(serial="123456")
TestModel(serial='123456')

>>> TestModel(serial="hello")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/dev/middle/src/middle/model.py", line 80, in __call__
    return super().__call__(**kwargs)
File "<attrs generated init c36746f22b6ca0b15b44dff2665d92e7478d9031>", line 4, in __init__
File "/home/dev/.pyenv/versions/middle-3.6.6/lib/python3.6/site-packages/attr/_make.py", line 1668, in __call__
    v(inst, attr, value)
File "/home/dev/middle/src/middle/validators/common.py", line 11, in wrapper
    return func(meta_value, instance, attribute, value)
File "/home/dev/middle/src/middle/validators/common.py", line 41, in str_pattern
    attribute.name, meta_value
middle.exceptions.ValidationError: 'serial' did not match the given pattern: '^[0-9]+$'
format

To be developed.

Number validators

Range: minimum and maximum
\[minimum \leqslant value \leqslant maximum\]

Setting minimum keyword to an integer or float would require that the input value should have at least the required minimum value:

>>> import middle

>>> class TestModel(middle.Model):
...     value = {"type": int, "minimum": 5}

>>> TestModel(value=5)
TestModel(value=5)

>>> TestModel(value=20)
TestModel(value=20)

>>> TestModel(value=4)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/dev/middle/src/middle/model.py", line 105, in __call__
    return super().__call__(**kwargs)
File "<attrs generated init 6ed99d543406ed37c7405962f27f473476610ca9>", line 4, in __init__
File "/home/dev/.pyenv/versions/middle-3.6.6/lib/python3.6/site-packages/attr/_make.py", line 1668, in __call__
    v(inst, attr, value)
File "/home/dev/middle/src/middle/validators/common.py", line 10, in wrapper
    return func(meta_value, instance, attribute, value)
File "/home/dev/middle/src/middle/validators/common.py", line 60, in min_num_value
    attribute.name, meta_value
middle.exceptions.ValidationError: 'value' must have a minimum value of 5

Setting the maximum keyword to an integer or float would require that the input value shoud have no more than the maximum value:

>>> import middle

>>> class TestModel(middle.Model):
...     value: float = middle.field(maximum=3.14)

>>> TestModel(value=-5.0)
TestModel(value=-5.0)

>>> TestModel(value=3.141)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/dev/middle/src/middle/model.py", line 105, in __call__
    return super().__call__(**kwargs)
File "<attrs generated init 9fda1659ea481e0eb60414e362b3bd445d031dd3>", line 4, in __init__
File "/home/dev/.pyenv/versions/middle-3.6.6/lib/python3.6/site-packages/attr/_make.py", line 1668, in __call__
    v(inst, attr, value)
File "/home/dev/middle/src/middle/validators/common.py", line 10, in wrapper
    return func(meta_value, instance, attribute, value)
File "/home/dev/middle/src/middle/validators/common.py", line 79, in max_num_value
    attribute.name, meta_value
middle.exceptions.ValidationError: 'value' must have a maximum value of 3.14
exclusive_minimum
\[minimum \lt value\]

The exclusive_minimum keyword, being True, would exclude the minimum value from the validation:

>>> import middle

>>> class TestModel(middle.Model):
...     value = {"type": int, "minimum": 5, "exclusive_minimum": True}

>>> TestModel(value=6)
TestModel(value=6)

>>> TestModel(value=5)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/dev/middle/src/middle/model.py", line 105, in __call__
    return super().__call__(**kwargs)
File "<attrs generated init 29f97f0418f12486a7312929799ce2293fc24900>", line 4, in __init__
File "/home/dev/.pyenv/versions/middle-3.6.6/lib/python3.6/site-packages/attr/_make.py", line 1668, in __call__
    v(inst, attr, value)
File "/home/dev/middle/src/middle/validators/common.py", line 10, in wrapper
    return func(meta_value, instance, attribute, value)
File "/home/dev/middle/src/middle/validators/common.py", line 53, in min_num_value
    attribute.name, meta_value
middle.exceptions.ValidationError: 'value' must have a (exclusive) minimum value of 5
exclusive_maximum
\[value \lt maximum\]

The exclusive_maximum keyword, being True, would exclude the maximum value from the validation:

>>> import middle

>>> class TestModel(middle.Model):
...     value: float = middle.field(maximum=3.14, exclusive_maximum=True)

>>> TestModel(value=3.1)
TestModel(value=3.1)

>>> TestModel(value=3.14)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/dev/middle/src/middle/model.py", line 105, in __call__
    return super().__call__(**kwargs)
File "<attrs generated init 08e8fcc18b83475440f4c0239321aa61610c38b9>", line 4, in __init__
File "/home/dev/.pyenv/versions/middle-3.6.6/lib/python3.6/site-packages/attr/_make.py", line 1668, in __call__
    v(inst, attr, value)
File "/home/dev/middle/src/middle/validators/common.py", line 10, in wrapper
    return func(meta_value, instance, attribute, value)
File "/home/dev/middle/src/middle/validators/common.py", line 72, in max_num_value
    attribute.name, meta_value
middle.exceptions.ValidationError: 'value' must have a (exclusive) maximum value of 3.14
multiple_of
\[value {\rm\ mod\ }multiple\_of = 0\]

The multiple_of keyword specifies the multiple value that a value must have in order to have no remainder in a division operation. It works with int and float as well.

>>> import middle

>>> class TestModel(middle.Model):
...     value = {"type": int, "multiple_of": 3}

>>> TestModel(value=21)
TestModel(value=21)

>>> TestModel(value=22)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/dev/middle/src/middle/model.py", line 105, in __call__
    return super().__call__(**kwargs)
File "<attrs generated init c24a18c0830f91f6079d00bbafff7e05f204e5a4>", line 4, in __init__
File "/home/dev/.pyenv/versions/middle-3.6.6/lib/python3.6/site-packages/attr/_make.py", line 1668, in __call__
    v(inst, attr, value)
File "/home/dev/middle/src/middle/validators/common.py", line 10, in wrapper
    return func(meta_value, instance, attribute, value)
File "/home/dev/middle/src/middle/validators/common.py", line 96, in num_multiple_of
    "'{}' must be multiple of {}".format(attribute.name, meta_value)
middle.exceptions.ValidationError: 'value' must be multiple of 3

List and Set validators

Range: min_items and max_items
\[min\_items \leqslant len(value) \leqslant max\_items\]

Setting min_items keyword to a List or Set would require that the input value should have at least the required number of items:

>>> import middle

>>> class TestModel(middle.Model):
...     value = {"type": List[int], "min_items": 3}

>>> TestModel(value=[1, 2, 3, 4])
TestModel(value=[1, 2, 3, 4])

>>> TestModel(value=[1, 2])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/dev/middle/src/middle/model.py", line 105, in __call__
    return super().__call__(**kwargs)
File "<attrs generated init e1f1466567f03e1497438574116bf161e9400995>", line 4, in __init__
File "/home/dev/.pyenv/versions/middle-3.6.6/lib/python3.6/site-packages/attr/_make.py", line 1668, in __call__
    v(inst, attr, value)
File "/home/dev/middle/src/middle/validators/common.py", line 10, in wrapper
    return func(meta_value, instance, attribute, value)
File "/home/dev/middle/src/middle/validators/common.py", line 104, in list_min_items
    "'{}' has no enough items of {}".format(attribute.name, meta_value)
middle.exceptions.ValidationError: 'value' has no enough items of 3

Setting the max_items keyword to a List or Set would require that the input value shoud have no more than the required number of items:

>>> import middle

>>> class TestModel(middle.Model):
...     value: List[int] = middle.field(max_items=5)

>>> TestModel(value=[1, 2, 3, 4])
TestModel(value=[1, 2, 3, 4])

>>> TestModel(value=[1, 2, 3, 4, 5, 6])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/dev/middle/src/middle/model.py", line 105, in __call__
    return super().__call__(**kwargs)
File "<attrs generated init 5180f70ad2fab18f88a0d4d0079cfcf36c2eb02a>", line 4, in __init__
File "/home/dev/.pyenv/versions/middle-3.6.6/lib/python3.6/site-packages/attr/_make.py", line 1668, in __call__
    v(inst, attr, value)
File "/home/dev/middle/src/middle/validators/common.py", line 10, in wrapper
    return func(meta_value, instance, attribute, value)
File "/home/dev/middle/src/middle/validators/common.py", line 113, in list_max_items
    attribute.name, meta_value
middle.exceptions.ValidationError: 'value' has more items than the limit of 5
unique_items

The unique_items keyword specifies that all values in the input value should be unique.

>>> import middle

>>> class TestModel(middle.Model):
...     value: List[int] = middle.field(unique_items=True)

>>> TestModel(value=[1, 2, 3, 4])
TestModel(value=[1, 2, 3, 4])

>>> TestModel(value=[1, 2, 3, 2])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/dev/middle/src/middle/model.py", line 105, in __call__
    return super().__call__(**kwargs)
File "<attrs generated init 1687613eb0214900b54beb0d1c7a7831de866678>", line 4, in __init__
File "/home/dev/.pyenv/versions/middle-3.6.6/lib/python3.6/site-packages/attr/_make.py", line 1668, in __call__
    v(inst, attr, value)
File "/home/dev/middle/src/middle/validators/common.py", line 10, in wrapper
    return func(meta_value, instance, attribute, value)
File "/home/dev/middle/src/middle/validators/common.py", line 126, in list_unique_items
    "'{}' must only have unique items".format(attribute.name)
middle.exceptions.ValidationError: 'value' must only have unique items

Important

Remember that, to be unique, one value should be comparable with another. If you are comparing instances of models created with middle, please take a look on some tips regarding attrs.

Dict validators

Range: min_properties and max_properties
\[min\_properties \leqslant len(value) \leqslant max\_properties\]

Setting min_properties keyword to a Dict would require that the input value should have at least the required number of keys and values:

>>> import middle

>>> class TestModel(middle.Model):
...     value = {"type": Dict[str, int], "min_properties": 2}

>>> TestModel(value={"hello": 1, "world": 2})
TestModel(value={'hello': 1, 'world': 2})

>>> TestModel(value={"foo": 99})
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/dev/middle/src/middle/model.py", line 105, in __call__
    return super().__call__(**kwargs)
File "<attrs generated init 95407a54d787ed82415f9e4fc5762a3d1642f501>", line 4, in __init__
File "/home/dev/.pyenv/versions/middle-3.6.6/lib/python3.6/site-packages/attr/_make.py", line 1668, in __call__
    v(inst, attr, value)
File "/home/dev/middle/src/middle/validators/common.py", line 10, in wrapper
    return func(meta_value, instance, attribute, value)
File "/home/dev/middle/src/middle/validators/common.py", line 136, in dict_min_properties
    attribute.name, meta_value
middle.exceptions.ValidationError: 'value' has no enough properties of 2

Setting the max_properties keyword to a Dict would require that the input value shoud have no more than the required number of keys and values:

>>> import middle

>>> class TestModel(middle.Model):
...     value: Dict[str, int] = middle.field(max_properties=3)

>>> TestModel(value={"hello": 1, "world": 2})
TestModel(value={'hello': 1, 'world': 2})

>>> TestModel(value={"hello": 1, "world": 2, "foo": 3, "bar": 4})
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/dev/middle/src/middle/model.py", line 105, in __call__
    return super().__call__(**kwargs)
File "<attrs generated init dc8195b57024445a6dc3a09749cde9f62b46333d>", line 4, in __init__
File "/home/dev/.pyenv/versions/middle-3.6.6/lib/python3.6/site-packages/attr/_make.py", line 1668, in __call__
    v(inst, attr, value)
File "/home/dev/middle/src/middle/validators/common.py", line 10, in wrapper
    return func(meta_value, instance, attribute, value)
File "/home/dev/middle/src/middle/validators/common.py", line 147, in dict_max_properties
    attribute.name, meta_value
middle.exceptions.ValidationError: 'value' has more properties than the limit of 3

Configuring

Configuring middle is straightforward, since it has just a few options, all of them regarding how the input data should be treated or converted when creating your models.

String input

For an input string value, there are two options that can modify the way certain value can be turned into a str instance (or not).

force_str

Default: False

By using force_str (as a bool), every input value for str fields will be forced to use the str(value) function:

>>> import middle

>>> class TestModel(middle.Model):
...     value = middle.field(type=str)

>>> middle.config.force_str = True

>>> TestModel(value=3.14)
TestModel(value='3.14')

>>> TestModel(value=object)
TestModel(value="<class 'object'>")
str_method

Default: True

By using str_method (as a bool), every input value for str fields will be checked for the existence of a __str__ method and, if found, str(value) will be called:

>>> import middle

>>> class TestModel(middle.Model):
...     value = middle.field(type=str)

>>> TestModel(value=3.14)
TestModel(value='3.14')

>>> middle.config.str_method = False

>>> TestModel(value=3.14)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/dev/middle/src/middle/model.py", line 105, in __call__
    return super().__call__(**kwargs)
File "<attrs generated init b2f9a9c2c12524cd8fd8cd7557d4ba62494b3007>", line 2, in __init__
File "/home/dev/.pyenv/versions/middle-3.6.6/lib/python3.6/site-packages/attr/converters.py", line 22, in optional_converter
    return converter(val)
File "/home/dev/middle/src/middle/converters.py", line 46, in _str_converter
    'the value "{!s}" given should not be converted to str'.format(value)
TypeError: the value "3.14" given should not be converted to str

Datetime input

no_transit_local_dtime

Default: False

By using no_transit_local_dtime (as a bool), every datetime input value that doesn’t have a timezone (naive instances) set will be treated as using the current machine timezone and automatically converted to UTC. If set to True, all naive datetime instances will be already set as UTC. The examples bellow ran in a machine configured with the GMT-0300 timezone:

>>> from datetime import datetime
>>> import middle

>>> class TestModel(middle.Model):
...     value = middle.field(type=datetime)

>>> TestModel(value=(2018, 7, 18, 14, 0))
TestModel(value=datetime.datetime(2018, 7, 18, 17, 0, tzinfo=datetime.timezone.utc))

>>> TestModel(value=datetime(2018, 7, 18, 14))
TestModel(value=datetime.datetime(2018, 7, 18, 17, 0, tzinfo=datetime.timezone.utc))

>>> TestModel(value="2018-07-18T14:00:00")
TestModel(value=datetime.datetime(2018, 7, 18, 17, 0, tzinfo=datetime.timezone.utc))

>>> middle.config.no_transit_local_dtime = True

>>> TestModel(value=(2018, 7, 18, 14, 0))
TestModel(value=datetime.datetime(2018, 7, 18, 14, 0, tzinfo=datetime.timezone.utc))

>>> TestModel(value=datetime(2018, 7, 18, 14))
TestModel(value=datetime.datetime(2018, 7, 18, 14, 0, tzinfo=datetime.timezone.utc))

>>> TestModel(value="2018-07-18T14:00:00")
TestModel(value=datetime.datetime(2018, 7, 18, 14, 0, tzinfo=datetime.timezone.utc))

If an input is not naive, it will be transformed to UTC regardless the value of no_transit_local_dtime:

>>> from datetime import datetime, timezone
>>> import middle

>>> class TestModel(middle.Model):
...     value = middle.field(type=datetime)

>>> # quick hack to get the local timezone
... current_tz = datetime.now(timezone.utc).astimezone().tzinfo

>>> current_tz
datetime.timezone(datetime.timedelta(-1, 75600), '-03')

>>> middle.config.no_transit_local_dtime
False

>>> TestModel(value=datetime(2018, 7, 18, 14, 0, tzinfo=current_tz))
TestModel(value=datetime.datetime(2018, 7, 18, 17, 0, tzinfo=datetime.timezone.utc))

>>> TestModel(value=(2018, 7, 18, 14, 0, 0, 0, -3))
TestModel(value=datetime.datetime(2018, 7, 18, 17, 0, tzinfo=datetime.timezone.utc))

>>> TestModel(value="2018-07-18T14:00:00-03:00")
TestModel(value=datetime.datetime(2018, 7, 18, 17, 0, tzinfo=datetime.timezone.utc))

>>> middle.config.no_transit_local_dtime = True

>>> TestModel(value=datetime(2018, 7, 18, 14, 0, tzinfo=current_tz))
TestModel(value=datetime.datetime(2018, 7, 18, 17, 0, tzinfo=datetime.timezone.utc))

>>> TestModel(value=(2018, 7, 18, 14, 0, 0, 0, -3))
TestModel(value=datetime.datetime(2018, 7, 18, 17, 0, tzinfo=datetime.timezone.utc))

>>> TestModel(value="2018-07-18T14:00:00-03:00")
TestModel(value=datetime.datetime(2018, 7, 18, 17, 0, tzinfo=datetime.timezone.utc))

Temporary options

middle.config offers a context manager, called temp, to provide all options as keywords inside the context for convenience:

>>> from datetime import datetime
>>> import middle

>>> class TestModel(middle.Model):
...     value = middle.field(type=datetime)

>>> TestModel(value=(2018, 7, 18, 14, 0))
TestModel(value=datetime.datetime(2018, 7, 18, 17, 0, tzinfo=datetime.timezone.utc))

>>> with middle.config.temp(no_transit_local_dtime=True):
...     TestModel(value=(2018, 7, 18, 14, 0))

TestModel(value=datetime.datetime(2018, 7, 18, 14, 0, tzinfo=datetime.timezone.utc))

>>> middle.config.no_transit_local_dtime
False

Extending and customizations

Todo.

attrs tips

Todo.

Troubleshooting

Todo.

Reference

middle

Contributing

Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given.

Bug reports

When reporting a bug please include:

  • Your operating system name and version.
  • Any details about your local setup that might be helpful in troubleshooting.
  • Detailed steps to reproduce the bug.

Documentation improvements

middle could always use more documentation, whether as part of the official middle docs, in docstrings, or even on the web in blog posts, articles, and such.

Feature requests and feedback

The best way to send feedback is to file an issue at https://github.com/vltr/middle/issues.

If you are proposing a feature:

  • Explain in detail how it would work.
  • Keep the scope as narrow as possible, to make it easier to implement.
  • Remember that this is a volunteer-driven project, and that code contributions are welcome :)

Development

To set up middle for local development:

  1. Fork middle (look for the “Fork” button).

  2. Clone your fork locally:

    git clone git@github.com:your_name_here/middle.git
    
  3. Create a branch for local development:

    git checkout -b name-of-your-bugfix-or-feature
    

    Now you can make your changes locally.

  4. When you’re done making changes, run all the checks, doc builder and spell checker with tox one command:

    tox
    
  5. Commit your changes and push your branch to GitHub:

    git add .
    git commit -m "Your detailed description of your changes."
    git push origin name-of-your-bugfix-or-feature
    
  6. Submit a pull request through the GitHub website.

Pull Request Guidelines

If you need some code review or feedback while you’re developing the code just make the pull request.

For merging, you should:

  1. Include passing tests (run tox) [1].
  2. Update documentation when there’s new API, functionality etc.
  3. Add a note to CHANGELOG.rst about the changes.
  4. Add yourself to AUTHORS.rst.
[1]

If you don’t have all the necessary python versions available locally you can rely on Travis - it will run the tests for each change you add in the pull request.

It will be slower though …

Tips

To run a subset of tests:

tox -e envname -- pytest -k test_myfeature

To run all the test environments in parallel (you need to pip install detox):

detox

Changelog

v0.2.1 on 2018-07-26

  • Quick fix related to the change log and MANIFEST.in files

v0.2.0 on 2018-07-26

  • Released (part) of the documentation
  • Got 99% coverage (finally combined)
  • Python 3.5 support added

v0.1.1 on 2018-07-02

  • Add proper unit testing and support for Python 3.6 and 3.7
  • Made the API a bit more flexible
  • Code format and check done with black

v0.1.0 on 2018-06-21

  • First release on PyPI.

Authors

Inspirations and thanks

Some libs that inspired the creation of middle:

  • attrs: how such a simple library can be such flexible, extendable and fast?
  • cattrs: for its speed on creating attrs instances from dict and to instances again;
  • pydantic: for such pythonic and beautiful approach on creating classes using typing hints;
  • mashmallow: it is one of the most feature rich modelling APIs I’ve seen;
  • apistar: it’s almost magical!
  • Sanic: “Gotta go fast!
  • ionelmc/cookiecutter-pylibrary: The most complete (or interesting) cookiecutter template I found so far (make sure to read this article too);

License

middle is a free software distributed under the MIT license.