Skip to content

Commit

Permalink
Merge pull request #28 from mdrachuk/ensure_frozen
Browse files Browse the repository at this point in the history
Ensure Frozen Model
  • Loading branch information
mdrachuk committed Aug 10, 2019
2 parents 881a2ad + 62d1e52 commit 0160b93
Show file tree
Hide file tree
Showing 13 changed files with 297 additions and 61 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![Supported Python](https://img.shields.io/pypi/pyversions/serious)][pypi]
[![Documentation](https://img.shields.io/readthedocs/serious)][docs]

Python dataclasses serialization and validation.
Define models in dataclasses: serialization, validation, and more.

[Documentation][docs]

Expand All @@ -16,7 +16,8 @@ Python dataclasses serialization and validation.
- Type annotations for all public-facing APIs.
- (Optionally) ensures immutability.
- Easily extensible.
- Documented for Humans.
- Made for people.
- Documented rigorously.

## Basics
### Installation
Expand Down
2 changes: 1 addition & 1 deletion config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def readme(self):

config = Config(
name='serious',
version='1.0.0.dev13',
version='1.0.0.dev14',
readme_path='README.md',
author='mdrachuk',
author_email='misha@drach.uk'
Expand Down
8 changes: 5 additions & 3 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![Test Coverage](https://img.shields.io/coveralls/github/mdrachuk/serious/master)](https://coveralls.io/github/mdrachuk/serious)
[![Supported Python](https://img.shields.io/pypi/pyversions/serious)](https://pypi.org/project/serious/)

One-- [preferable][zen] --way for object serialization and validation.
Define models in dataclasses: serialization, validation, and more.


# Get It Now
Expand All @@ -30,8 +30,10 @@ This introduces multiple benefits:
### Type annotations everywhere
Stay certain that no alien type will break production. Test correctness with [mypy][mypy].

### (Optionally) immutable dataclasses
TBD
### (Optionally) Immutable model
Immutable (or frozen) means that object’s state cannot be changed after it was created.
Although the main reason for this are multi-threaded environments, frozen objects still can make life easier.
With immutable model you can be sure that object’s state did not change unless reassigned it [explicitly][zen].

### Easily extensible
Plug in [custom field types][custom-serializers] and [output formats][custom-model].
Expand Down
41 changes: 20 additions & 21 deletions docs/zen.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
# PEP-20 — The Zen of Python, by Tim Peters
[Source](https://www.python.org/dev/peps/pep-0020/)
```plain
Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!
```

Beautiful is better than ugly.<br/>
Explicit is better than implicit.<br/>
Simple is better than complex.<br/>
Complex is better than complicated.<br/>
Flat is better than nested.<br/>
Sparse is better than dense.<br/>
Readability counts.<br/>
Special cases aren't special enough to break the rules.<br/>
Although practicality beats purity.<br/>
Errors should never pass silently.<br/>
Unless explicitly silenced.<br/>
In the face of ambiguity, refuse the temptation to guess.<br/>
There should be one-- and preferably only one --obvious way to do it.<br/>
Although that way may not be obvious at first unless you're Dutch.<br/>
Now is better than never.<br/>
Although never is often better than *right* now.<br/>
If the implementation is hard to explain, it's a bad idea.<br/>
If the implementation is easy to explain, it may be a good idea.<br/>
Namespaces are one honking great idea -- let's do more of those!
9 changes: 5 additions & 4 deletions serious/descriptors.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from collections import ChainMap
from dataclasses import dataclass, fields, is_dataclass
from typing import Type, Any, TypeVar, get_type_hints, Dict, Mapping, List, Union, Iterable
Expand All @@ -6,14 +8,13 @@

T = TypeVar('T')

FrozenGenericParams = FrozenDict[Any, 'TypeDescriptor']
GenericParams = Mapping[Any, 'TypeDescriptor']


@dataclass(frozen=True)
class TypeDescriptor:
_cls: Type
parameters: FrozenGenericParams
parameters: FrozenDict[Any, TypeDescriptor]
is_optional: bool = False
is_dataclass: bool = False

Expand All @@ -22,14 +23,14 @@ def cls(self): # Python fails when providing cls as a keyword parameter to data
return self._cls

@property
def fields(self) -> Mapping[str, 'TypeDescriptor']:
def fields(self) -> Mapping[str, TypeDescriptor]:
if not is_dataclass(self.cls):
return {}
types = get_type_hints(self.cls) # type: Dict[str, Type]
descriptors = {name: self.describe(type_) for name, type_ in types.items()}
return {f.name: descriptors[f.name] for f in fields(self.cls)}

def describe(self, type_: Type) -> 'TypeDescriptor':
def describe(self, type_: Type) -> TypeDescriptor:
return describe(type_, self.parameters)


Expand Down
10 changes: 7 additions & 3 deletions serious/dict/api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from typing import TypeVar, Type, Generic, List, Collection, Dict, Iterable, Any, Mapping
from typing import TypeVar, Type, Generic, List, Collection, Dict, Iterable, Any, Mapping, Union

from serious.descriptors import describe
from serious.preconditions import _check_is_instance
Expand All @@ -15,11 +15,12 @@ class DictModel(Generic[T]):
def __init__(
self,
cls: Type[T],
*,
serializers: Iterable[Type[FieldSerializer]] = field_serializers(),
*,
allow_any: bool = False,
allow_missing: bool = False,
allow_unexpected: bool = False,
ensure_frozen: Union[bool, Iterable[Type]] = False,
):
"""
@param cls the dataclass type to load/dump.
Expand All @@ -28,14 +29,17 @@ def __init__(
(this includes generics like `List[Any]`, or simply `list`).
@param allow_missing `False` to raise during load if data is missing the optional fields.
@param allow_unexpected `False` to raise during load if data contains some unknown fields.
@param ensure_frozen `False` to skip check of model immutability; `True` will perform the check
against built-in immutable types; a list of custom immutable types is added to built-ins.
"""
self._descriptor = describe(cls)
self._serializer: SeriousModel = SeriousModel(
self._descriptor,
serializers,
allow_any=allow_any,
allow_missing=allow_missing,
allow_unexpected=allow_unexpected
allow_unexpected=allow_unexpected,
ensure_frozen=ensure_frozen,
)

@property
Expand Down
32 changes: 30 additions & 2 deletions serious/errors.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from __future__ import annotations

from typing import Type, Mapping, Collection, TYPE_CHECKING
from typing import Type, Mapping, Collection, TYPE_CHECKING, Iterable

from .utils import DataclassType, class_path

if TYPE_CHECKING: # To reference in typings
from serious.serialization import SerializationStep
from .serialization.process import SerializationStep
from .descriptors import TypeDescriptor


class SerializationError(Exception):
Expand Down Expand Up @@ -78,6 +79,7 @@ def message(self):


class ModelError(Exception):

def __init__(self, cls: Type):
self.cls = cls

Expand All @@ -86,6 +88,18 @@ def message(self):
return f'Model error in class "{self.cls}ю"'


class FieldMissingSerializer(ModelError):

def __init__(self, cls: Type, desc: TypeDescriptor):
super().__init__(cls)
self.desc = desc

@property
def message(self):
return (f'{class_path(self.cls)} is has unserializable member: {self.desc}.'
f'Create a serializer fitting the descriptor and pass it to the model `serializers`.')


class ModelContainsAny(ModelError):

@property
Expand All @@ -104,6 +118,20 @@ def message(self):
f'Union types are not supported by serious.')


class MutableTypesInModel(ModelError):
def __init__(self, cls: Type, mutable_types: Iterable[Type]):
super().__init__(cls)
self.mutable_types = mutable_types

@property
def message(self):
return (f'{class_path(self.cls)} is has mutable members: {self.mutable_types}.'
f'If there are immutable types pass them to model as `ensure_frozen=[Type1, Type2]`.'
f'Replace mutable types with frozen ones. '
f'Set @dataclass(frozen=True). \n'
f'Alternatively, allow mutable fields by passing `ensure_frozen=False` to model. ')


class ValidationError(Exception):
def __init__(self, message='Failed validation'):
super().__init__(message)
10 changes: 7 additions & 3 deletions serious/json/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

import json
from typing import Optional, TypeVar, Type, Generic, List, MutableMapping, Collection, Iterable, Any, Dict
from typing import Optional, TypeVar, Type, Generic, List, MutableMapping, Collection, Iterable, Any, Dict, Union

from serious.descriptors import describe
from serious.preconditions import _check_is_instance
Expand All @@ -18,11 +18,12 @@ class JsonModel(Generic[T]):
def __init__(
self,
cls: Type[T],
*,
serializers: Iterable[Type[FieldSerializer]] = field_serializers(),
*,
allow_any: bool = False,
allow_missing: bool = False,
allow_unexpected: bool = False,
ensure_frozen: Union[bool, Iterable[Type]] = False,
camel_case: bool = True,
indent: Optional[int] = None,
):
Expand All @@ -33,6 +34,8 @@ def __init__(
(this includes generics like `List[Any]`, or simply `list`).
@param allow_missing `False` to raise during load if data is missing the optional fields.
@param allow_unexpected `False` to raise during load if data contains some unknown fields.
@param ensure_frozen `False` to skip check of model immutability; `True` will perform the check
against built-in immutable types; a list of custom immutable types is added to built-ins.
@param camel_case `True` to transform dataclass "snake_case" to JSON "camelCase".
@param indent number of spaces JSON output will be indented by; `None` for most compact representation.
"""
Expand All @@ -43,7 +46,8 @@ def __init__(
allow_any=allow_any,
allow_missing=allow_missing,
allow_unexpected=allow_unexpected,
key_mapper=JsonKeyMapper() if camel_case else None
ensure_frozen=ensure_frozen,
key_mapper=JsonKeyMapper() if camel_case else None,
)
self._dump_indentation = indent

Expand Down
14 changes: 1 addition & 13 deletions serious/preconditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
Think [Guava Precondition](https://github.com/google/guava/wiki/PreconditionsExplained).
"""
from dataclasses import is_dataclass
from typing import TypeVar, Type, Optional
from typing import TypeVar, Type

T = TypeVar('T')

Expand All @@ -12,14 +11,3 @@ def _check_is_instance(value: T, type_: Type[T], message: str = None) -> T:
message = message or f'Got "{value}" when expecting a "{type_}" instance.'
assert isinstance(value, type_), message
return value


def _check_is_dataclass(type_: Type[T], message: str = 'Not a dataclass') -> Type[T]:
assert is_dataclass(type_), message
return type_


def _check_present(optional: Optional[T], message: str = 'Value must be present') -> T:
assert optional is not None, message
value: T = optional
return value
2 changes: 1 addition & 1 deletion serious/serialization/field_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ def fits(cls, desc: TypeDescriptor) -> bool:
return (issubclass(desc.cls, (list, set, frozenset))
or (issubclass(desc.cls, tuple)
and len(desc.parameters) == 2
and desc.parameters[1] is Ellipsis))
and desc.parameters[1].cls is Ellipsis))

def load(self, value: list, ctx: Loading) -> Collection:
if not isinstance(value, list):
Expand Down

0 comments on commit 0160b93

Please sign in to comment.