Skip to content

Commit

Permalink
Support generics, JsonString
Browse files Browse the repository at this point in the history
  • Loading branch information
Maxim Avanov committed Apr 18, 2020
1 parent 22d7d33 commit 2e983b6
Show file tree
Hide file tree
Showing 13 changed files with 307 additions and 101 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Expand Up @@ -2,6 +2,12 @@
CHANGELOG
=========

0.24.0
===============

* Support for classes deriving `Generic[T, U, ...]`;
* Added new type ``typeit.custom_types.JsonString`` - helpful when dealing with JSON strings encoded into JSON strings.


0.23.0
===============
Expand Down
4 changes: 2 additions & 2 deletions docs/conf.py
Expand Up @@ -51,9 +51,9 @@
# built documents.
#
# The short X.Y version.
version = '0.23'
version = '0.24'
# The full version, including alpha/beta/rc tags.
release = '0.23.0'
release = '0.24.0'

# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
Expand Down
6 changes: 4 additions & 2 deletions docs/quickstart_guide.rst
Expand Up @@ -196,13 +196,15 @@ Supported types
* ``set`` and ``frozenset``
* ``typing.Any`` passes any value as is
* ``typing.Union`` including nested structures
* ``typing.Sequence``, ``typing.List`` including generic collections with ``typing.TypeVar``.
* ``typing.Sequence``, ``typing.List`` including generic collections with ``typing.TypeVar``;
* ``typing.Set`` and ``typing.FrozenSet``
* ``typing.Tuple``
* ``typing.Dict``
* ``typing.Mapping``
* ``typing.Literal`` (``typing_extensions.Literal`` on Python prior 3.8)
* ``typing.Literal`` (``typing_extensions.Literal`` on Python prior 3.8);
* ``typing.Generic[T, U, ...]``
* ``typeit.sums.SumType``
* ``typeit.custom_types.JsonString`` - helpful when dealing with JSON strings encoded into JSON strings;
* ``enum.Enum`` derivatives
* ``pathlib.Path`` derivatives
* ``pyrsistent.typing.PVector``
Expand Down
10 changes: 7 additions & 3 deletions setup.py
@@ -1,8 +1,12 @@
import sys
from pathlib import Path
from setuptools import setup
from setuptools import find_packages


PY_VERSION = sys.version_info[:2]


here = Path(__file__).absolute().parent


Expand All @@ -27,7 +31,7 @@ def requirements(at_path: Path):
row = row.strip()
if row and not (row.startswith('#') or row.startswith('http')):
requires.append(row)
return requires
return requires


with (here / 'README.rst').open() as f:
Expand All @@ -38,7 +42,7 @@ def requirements(at_path: Path):
# ----------------------------

setup(name='typeit',
version='0.23.0',
version='0.24.0',
description='typeit brings typed data into your project',
long_description=README,
classifiers=[
Expand All @@ -59,7 +63,7 @@ def requirements(at_path: Path):
zip_safe=False,
test_suite='tests',
tests_require=['pytest', 'coverage'],
install_requires=requirements(here / 'requirements' / 'minimal.txt'),
install_requires=requirements(here / 'requirements' / 'minimal.txt') + (['dataclasses'] if PY_VERSION < (3, 7) else []),
extras_require=extras_require(),
entry_points={
'console_scripts': [
Expand Down
92 changes: 45 additions & 47 deletions tests/parser/test_dataclasses.py
@@ -1,66 +1,64 @@
import pytest

import typeit
from typeit.compat import PY_VERSION

if PY_VERSION >= (3, 7):
from dataclasses import dataclass

from dataclasses import dataclass


def test_dataclasses():
def test_dataclasses():

@dataclass
class InventoryItem:
name: str
unit_price: float
quantity_on_hand: int
@dataclass
class InventoryItem:
name: str
unit_price: float
quantity_on_hand: int

overrides = {
(InventoryItem, 'quantity_on_hand'): 'quantity'
}
overrides = {
(InventoryItem, 'quantity_on_hand'): 'quantity'
}

mk_inv, serialize_inv = typeit.TypeConstructor.override(overrides).apply_on(InventoryItem)
mk_inv, serialize_inv = typeit.TypeConstructor.override(overrides).apply_on(InventoryItem)

serialized = {
'name': 'test',
'unit_price': 1.0,
'quantity': 5,
}
x = mk_inv(serialized)
assert isinstance(x, InventoryItem)
assert serialize_inv(x) == serialized
serialized = {
'name': 'test',
'unit_price': 1.0,
'quantity': 5,
}
x = mk_inv(serialized)
assert isinstance(x, InventoryItem)
assert serialize_inv(x) == serialized

def test_with_default_values():
def test_with_default_values():

@dataclass
class X:
one: int
two: int = 2
three: int = 3
@dataclass
class X:
one: int
two: int = 2
three: int = 3

data = {'one': 1}
data = {'one': 1}

mk_x, serialize_x = typeit.TypeConstructor ^ X
x = mk_x(data)
assert serialize_x(x) == {'one': 1, 'two': 2, 'three': 3}
mk_x, serialize_x = typeit.TypeConstructor ^ X
x = mk_x(data)
assert serialize_x(x) == {'one': 1, 'two': 2, 'three': 3}

def test_inherited_dataclasses():
@dataclass
class X:
x: int
def test_inherited_dataclasses():
@dataclass
class X:
x: int

@dataclass
class Y(X):
y: str
@dataclass
class Y(X):
y: str

data_invalid = {'y': 'string'}
data_valid = {'x': 1, 'y': 'string'}
data_invalid = {'y': 'string'}
data_valid = {'x': 1, 'y': 'string'}

mk_y, serialize_y = typeit.TypeConstructor ^ Y
mk_y, serialize_y = typeit.TypeConstructor ^ Y

with pytest.raises(typeit.Error):
mk_y(data_invalid)
with pytest.raises(typeit.Error):
mk_y(data_invalid)

y = mk_y(data_valid)
assert isinstance(y, Y)
assert isinstance(y, X)
y = mk_y(data_valid)
assert isinstance(y, Y)
assert isinstance(y, X)
92 changes: 92 additions & 0 deletions tests/test_custom_types.py
@@ -0,0 +1,92 @@
import json
from typing import NamedTuple, Optional

from typeit.custom_types import JsonString
from typeit import TypeConstructor


def test_json_string_direct_application():
mk_js, serialize_js = TypeConstructor ^ JsonString[int]
js = mk_js("5")
assert isinstance(js, JsonString)
assert js.data == 5
assert serialize_js(js) == "5"


def test_json_string_structures():
class Z(NamedTuple):
z: int

class X(NamedTuple):
x: JsonString[str]
y: JsonString[int]
z: JsonString[Optional[Z]]

mk_x, serialize_x = TypeConstructor ^ X

data_x1 = dict(
x=json.dumps("1"),
y=json.dumps(2),
z=json.dumps(None)
)
x1 = mk_x(data_x1)
assert isinstance(x1, X)
assert x1.x.data == "1"
assert x1.y.data == 2
assert x1.z.data is None
assert serialize_x(x1) == data_x1

data_x2 = dict(
x=json.dumps("1"),
y=json.dumps(2),
z=json.dumps({'z': 3})
)
x2 = mk_x(data_x2)
assert isinstance(x2.z.data, Z)
assert x2.z.data.z == 3
assert serialize_x(x2) == data_x2


def test_nested_json_string():
class NestedOpt(NamedTuple):
opt: JsonString[JsonString[Optional[int]]]

mk_opt, serialize_opt = TypeConstructor ^ NestedOpt

data_1 = dict(
opt=json.dumps(json.dumps(None))
)
opt = mk_opt(data_1)
assert isinstance(opt, NestedOpt)
assert opt.opt.data.data is None
assert serialize_opt(opt) == data_1

data_2 = dict(
opt=json.dumps(json.dumps(1))
)
opt = mk_opt(data_2)
assert isinstance(opt, NestedOpt)
assert opt.opt.data.data == 1
assert serialize_opt(opt) == data_2


def test_nested_optional_json_string():
class NestedOpt(NamedTuple):
opt: JsonString[Optional[JsonString[Optional[int]]]]

mk_opt, serialize_opt = TypeConstructor ^ NestedOpt

data_1 = dict(
opt=json.dumps(None)
)
opt1 = mk_opt(data_1)
assert opt1.opt.data is None

data_2 = dict(
opt=json.dumps(json.dumps(1))
)
opt2 = mk_opt(data_2)
assert isinstance(opt2, NestedOpt)
assert opt2.opt.data is not None
assert opt2.opt.data.data == 1
assert serialize_opt(opt2) == data_2
3 changes: 2 additions & 1 deletion typeit/__init__.py
@@ -1,6 +1,7 @@
from . import flags
from . import sums
from . import custom_types
from .combinator.constructor import type_constructor, TypeConstructor
from .schema.errors import Error

__all__ = ('TypeConstructor', 'type_constructor', 'flags', 'sums', 'Error')
__all__ = ('TypeConstructor', 'type_constructor', 'flags', 'sums', 'Error', 'custom_types')
7 changes: 4 additions & 3 deletions typeit/combinator/constructor.py
@@ -1,11 +1,12 @@
from functools import partial
from typing import Tuple, Callable, Dict, Any, Union, List, Type, Mapping, Sequence
from typing import Tuple, Callable, Dict, Any, Union, Type, Mapping, Sequence

from pyrsistent import pmap
# this is different from pyrsistent.typing.PMap unfortunately
from pyrsistent import PMap as RealPMapType

from .. import schema, flags
from ..custom_types.json_string import JsonStringSchema, JsonString
from ..definitions import OverridesT, NO_OVERRIDES
from ..parser import T, decide_node_type, OverrideT
from .combinator import Combinator
Expand Down Expand Up @@ -33,7 +34,7 @@ def __call__(self,
except TypeError as e:
raise TypeError(
f'Cannot create a type constructor for {typ}: {e}'
)
) from e
self.memo = memo
return (
partial(schema.errors.errors_aware_constructor, schema_node.deserialize),
Expand Down Expand Up @@ -73,5 +74,5 @@ def __xor__(self, typ: Type[T]) -> TypeTools:
apply_on = __xor__


type_constructor = _TypeConstructor()
type_constructor = _TypeConstructor() & JsonStringSchema[JsonString]
TypeConstructor = type_constructor
3 changes: 3 additions & 0 deletions typeit/custom_types/__init__.py
@@ -0,0 +1,3 @@
from .json_string import JsonString

__all__ = ('JsonString', )
38 changes: 38 additions & 0 deletions typeit/custom_types/json_string.py
@@ -0,0 +1,38 @@
import json
from dataclasses import dataclass
from typing import Generic, TypeVar

from ..schema import Invalid
from ..schema.types import Structure


T = TypeVar('T')


@dataclass(frozen=True)
class JsonString(Generic[T]):
data: T


class JsonStringSchema(Structure):
def __init__(self, *args, json=json, **kwargs):
super().__init__(*args, **kwargs)
self.json = json

def deserialize(self, node, cstruct: str) -> JsonString:
""" Converts input string value ``cstruct`` to ``PortMapping``
"""
try:
data = self.json.loads(cstruct)
except Exception as e:
raise Invalid(node,
f'Value is not a JSON string',
cstruct
) from e
return super().deserialize(node, {'data': data})

def serialize(self, node, appstruct: JsonString) -> str:
""" Converts ``PortMapping`` back to string value suitable for YAML config
"""
serialized = super().serialize(node, appstruct)
return self.json.dumps(serialized['data'])

0 comments on commit 2e983b6

Please sign in to comment.