Skip to content
This repository was archived by the owner on Aug 19, 2023. It is now read-only.

Commit 00b9db2

Browse files
authored
Add support for PEP585 & PEP604 type defs (#154)
Unpin fastjsonschema Remove mypy_extensions
1 parent 56b6264 commit 00b9db2

File tree

7 files changed

+196
-28
lines changed

7 files changed

+196
-28
lines changed

dataclasses_jsonschema/__init__.py

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import functools
55
from decimal import Decimal
66
from ipaddress import IPv4Address, IPv6Address
7-
from typing import Optional, Type, Union, Any, Dict, Tuple, List, TypeVar, get_type_hints, Callable
7+
from typing import Optional, Type, Union, Any, Dict, Tuple, List, Callable, TypeVar
88
import re
99
from datetime import datetime, date
1010
from dataclasses import fields, is_dataclass, Field, MISSING, dataclass, asdict
@@ -18,11 +18,12 @@
1818
from typing_extensions import Final, Literal # type: ignore
1919

2020

21-
from dataclasses_jsonschema.field_types import ( # noqa: F401
21+
from .field_types import ( # noqa: F401
2222
FieldEncoder, DateFieldEncoder, DateTimeFieldEncoder, UuidField, DecimalField,
23-
IPv4AddressField, IPv6AddressField, DateTimeField
23+
IPv4AddressField, IPv6AddressField, DateTimeField, UUID_REGEX
2424
)
25-
from dataclasses_jsonschema.type_defs import JsonDict, SchemaType, JsonSchemaMeta, _NULL_TYPE, NULL # noqa: F401
25+
from .type_defs import JsonDict, SchemaType, JsonSchemaMeta, _NULL_TYPE, NULL # noqa: F401
26+
from .type_hints import get_class_type_hints
2627

2728
try:
2829
import fastjsonschema
@@ -46,8 +47,12 @@ def validate_func(data, schema):
4647
SEQUENCE_TYPES = {
4748
'Sequence': list,
4849
'List': list,
49-
'Set': set
50+
'Set': set,
51+
'set': set,
52+
'list': list
5053
}
54+
MAPPING_TYPES = ('Dict', 'Mapping', 'dict')
55+
TUPLE_TYPES = ('Tuple', 'tuple')
5156

5257

5358
class ValidationError(Exception):
@@ -310,15 +315,15 @@ def encoder(_, v, __):
310315
if encoded is None:
311316
raise TypeError("No variant of '{}' matched the type '{}'".format(field_type, type(value)))
312317
return encoded
313-
elif field_type_name in ('Mapping', 'Dict'):
318+
elif field_type_name in MAPPING_TYPES:
314319
def encoder(ft, val, o):
315320
return {
316321
cls._encode_field(ft.__args__[0], k, o): cls._encode_field(ft.__args__[1], v, o)
317322
for k, v in val.items()
318323
}
319-
elif field_type_name in SEQUENCE_TYPES or (field_type_name == "Tuple" and ... in field_type.__args__):
324+
elif field_type_name in SEQUENCE_TYPES or (field_type_name in TUPLE_TYPES and ... in field_type.__args__):
320325
def encoder(ft, val, o): return [cls._encode_field(ft.__args__[0], v, o) for v in val]
321-
elif field_type_name == 'Tuple':
326+
elif field_type_name in TUPLE_TYPES:
322327
def encoder(ft, val, o):
323328
return [cls._encode_field(ft.__args__[idx], v, o) for idx, v in enumerate(val)]
324329
elif cls._is_json_schema_subclass(field_type):
@@ -344,7 +349,7 @@ def _get_fields_uncached():
344349
base_fields_types |= {(f.name, f.type) for f in fields(base)}
345350

346351
mapped_fields = []
347-
type_hints = get_type_hints(cls)
352+
type_hints = get_class_type_hints(cls)
348353
for f in fields(cls):
349354
# Skip internal fields
350355
if f.name.startswith("__") or (not base_fields and (f.name, f.type) in base_fields_types):
@@ -433,18 +438,18 @@ def decoder(f, ft, val): return cls._decode_field(f, unwrap_final(ft), val)
433438
continue
434439
if decoded is not None:
435440
return decoded
436-
elif field_type_name in ('Mapping', 'Dict'):
441+
elif field_type_name in MAPPING_TYPES:
437442
def decoder(f, ft, val):
438443
return {
439444
cls._decode_field(f, ft.__args__[0], k): cls._decode_field(f, ft.__args__[1], v)
440445
for k, v in val.items()
441446
}
442-
elif field_type_name in SEQUENCE_TYPES or (field_type_name == "Tuple" and ... in field_type.__args__):
443-
seq_type = tuple if field_type_name == "Tuple" else SEQUENCE_TYPES[field_type_name]
447+
elif field_type_name in SEQUENCE_TYPES or (field_type_name in TUPLE_TYPES and ... in field_type.__args__):
448+
seq_type = tuple if field_type_name in TUPLE_TYPES else SEQUENCE_TYPES[field_type_name]
444449

445450
def decoder(f, ft, val):
446451
return seq_type(cls._decode_field(f, ft.__args__[0], v) for v in val)
447-
elif field_type_name == "Tuple":
452+
elif field_type_name in TUPLE_TYPES:
448453
def decoder(f, ft, val):
449454
return tuple(cls._decode_field(f, ft.__args__[idx], v) for idx, v in enumerate(val))
450455
elif field_type in cls._field_encoders:
@@ -469,7 +474,9 @@ def _validate(cls, data: JsonDict, validate_enums: bool = True):
469474
# TODO: Support validating with other schema types
470475
schema_validator = cls.__compiled_schema.get(SchemaOptions(DEFAULT_SCHEMA_TYPE, validate_enums))
471476
if schema_validator is None:
472-
schema_validator = fastjsonschema.compile(cls.json_schema(validate_enums=validate_enums))
477+
schema_validator = fastjsonschema.compile(
478+
cls.json_schema(validate_enums=validate_enums), formats={'uuid': UUID_REGEX}
479+
)
473480
cls.__compiled_schema[SchemaOptions(DEFAULT_SCHEMA_TYPE, validate_enums)] = schema_validator
474481
schema_validator(data)
475482
else:
@@ -545,7 +552,7 @@ def from_object(cls: Type[T], obj: Any, exclude: FieldExcludeList = tuple()) ->
545552
values[f.field.name] = ft.from_object(from_value, exclude=sub_exclude)
546553
elif is_enum(ft):
547554
values[f.field.name] = ft(from_value)
548-
elif field_type_name == "List" and cls._is_json_schema_subclass(ft.__args__[0]):
555+
elif field_type_name in ("List", "list") and cls._is_json_schema_subclass(ft.__args__[0]):
549556
values[f.field.name] = [
550557
ft.__args__[0].from_object(v, exclude=sub_exclude) for v in from_value
551558
]
@@ -653,19 +660,19 @@ def _get_field_schema(cls, field: Union[Field, Type], schema_options: SchemaOpti
653660
field_schema = {
654661
'oneOf': [cls._get_field_schema(variant, schema_options)[0] for variant in field_type.__args__]
655662
}
656-
elif field_type_name in ('Dict', 'Mapping'):
663+
elif field_type_name in MAPPING_TYPES:
657664
field_schema = {'type': 'object'}
658665
if field_type.__args__[1] is not Any:
659666
field_schema['additionalProperties'] = cls._get_field_schema(
660667
field_type.__args__[1], schema_options
661668
)[0]
662-
elif field_type_name in SEQUENCE_TYPES or (field_type_name == "Tuple" and ... in field_type.__args__):
669+
elif field_type_name in SEQUENCE_TYPES or (field_type_name in TUPLE_TYPES and ... in field_type.__args__):
663670
field_schema = {'type': 'array'}
664671
if field_type.__args__[0] is not Any:
665672
field_schema['items'] = cls._get_field_schema(field_type.__args__[0], schema_options)[0]
666-
if field_type_name == "Set":
673+
if field_type_name in ("Set", "set"):
667674
field_schema['uniqueItems'] = True
668-
elif field_type_name == "Tuple":
675+
elif field_type_name in TUPLE_TYPES:
669676
tuple_len = len(field_type.__args__)
670677
# TODO: How do we handle Optional type within lists / tuples
671678
field_schema = {
@@ -691,9 +698,9 @@ def _get_field_definitions(cls, field_type: Any, definitions: JsonDict,
691698
field_type_name = cls._get_field_type_name(field_type)
692699
if is_optional(field_type):
693700
cls._get_field_definitions(unwrap_optional(field_type), definitions, schema_options)
694-
elif field_type_name in ('Sequence', 'List', 'Tuple'):
701+
elif field_type_name in SEQUENCE_TYPES:
695702
cls._get_field_definitions(field_type.__args__[0], definitions, schema_options)
696-
elif field_type_name in ('Dict', 'Mapping'):
703+
elif field_type_name in MAPPING_TYPES:
697704
cls._get_field_definitions(field_type.__args__[1], definitions, schema_options)
698705
elif field_type_name == 'Union':
699706
for variant in field_type.__args__:
@@ -760,7 +767,7 @@ def json_schema(
760767
else:
761768
definitions = cls.__definitions[schema_options]
762769

763-
if cls.__schema is not None and schema_options in cls.__schema:
770+
if schema_options in cls.__schema:
764771
schema = cls.__schema[schema_options]
765772
else:
766773
properties = {}

dataclasses_jsonschema/field_types.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def json_schema(self) -> JsonDict:
6363

6464
# Alias for backwards compat
6565
DateTimeField = DateTimeFieldEncoder
66+
UUID_REGEX = '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
6667

6768

6869
class UuidField(FieldEncoder[UUID, str]):
@@ -78,7 +79,7 @@ def json_schema(self):
7879
return {
7980
'type': 'string',
8081
'format': 'uuid',
81-
'pattern': '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
82+
'pattern': UUID_REGEX
8283
}
8384

8485

dataclasses_jsonschema/type_defs.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55
# Supported in future python versions
66
from typing import TypedDict # type: ignore
77
except ImportError:
8-
from mypy_extensions import TypedDict
8+
from typing_extensions import TypedDict
99

1010

1111
JsonEncodable = Union[int, float, str, bool]
1212
JsonDict = Dict[str, Any]
1313

1414

15-
class JsonSchemaMeta(TypedDict):
15+
# This issue still seems to be present for python < 3.8: https://github.com/python/mypy/issues/7722
16+
class JsonSchemaMeta(TypedDict, total=False): # type: ignore
1617
"""JSON schema field definitions. Example usage:
1718
1819
>>> foo = field(metadata=JsonSchemaMeta(description="A foo that foos"))
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import ast
2+
import sys
3+
4+
try:
5+
from typing import ForwardRef # type: ignore
6+
except ImportError:
7+
# Python 3.6
8+
from typing import _ForwardRef as ForwardRef # type: ignore
9+
10+
from typing import Type, Dict, Any, Union, Tuple, Set, List
11+
from typing import _eval_type # type: ignore
12+
13+
14+
def get_elts(op: ast.BinOp):
15+
for arg in (op.left, op.right):
16+
if isinstance(arg, ast.BinOp) and isinstance(arg.op, ast.BitOr):
17+
for n in get_elts(arg):
18+
yield n
19+
else:
20+
yield arg
21+
22+
23+
class RewriteUnionTypes(ast.NodeTransformer):
24+
25+
def __init__(self):
26+
self.rewritten = False
27+
28+
def visit_BinOp(self, node: ast.BinOp) -> Union[ast.BinOp, ast.Subscript]:
29+
if isinstance(node.op, ast.BitOr):
30+
self.rewritten = True
31+
return ast.Subscript(
32+
value=ast.Name(id='_Union', ctx=ast.Load(), lineno=1, col_offset=1),
33+
slice=ast.Index(
34+
value=ast.Tuple(elts=list(get_elts(node)), ctx=ast.Load(), lineno=1, col_offset=1),
35+
ctx=ast.Load(),
36+
lineno=1,
37+
col_offset=1,
38+
),
39+
lineno=1,
40+
col_offset=1,
41+
ctx=ast.Load(),
42+
)
43+
else:
44+
return node
45+
46+
47+
class RewriteBuiltinGenerics(ast.NodeTransformer):
48+
49+
def __init__(self):
50+
self.rewritten = False
51+
# Collections are prefixed with _ to prevent any potential name clashes
52+
self.replacements = {
53+
'list': '_List',
54+
'set': '_Set',
55+
'frozenset': '_Frozenset',
56+
'dict': '_Dict',
57+
'type': '_Type',
58+
'tuple': '_Tuple',
59+
}
60+
61+
def visit_Name(self, node: ast.Name) -> ast.Name:
62+
if node.id in self.replacements:
63+
self.rewritten = True
64+
return ast.Name(id=self.replacements[node.id], ctx=node.ctx, lineno=1, col_offset=1)
65+
else:
66+
return node
67+
68+
69+
def get_class_type_hints(klass: Type, localns=None) -> Dict[str, Any]:
70+
"""Return type hints for a class. Adapted from `typing.get_type_hints`, adds support for PEP 585 & PEP 604"""
71+
hints = {}
72+
for base in reversed(klass.__mro__):
73+
base_globals = sys.modules[base.__module__].__dict__
74+
base_globals['_Union'] = Union
75+
if sys.version_info < (3, 9):
76+
base_globals['_List'] = List
77+
base_globals['_Set'] = Set
78+
base_globals['_Type'] = Type
79+
base_globals['_Tuple'] = Tuple
80+
base_globals['_Dict'] = Dict
81+
ann = base.__dict__.get('__annotations__', {})
82+
for name, value in ann.items():
83+
if value is None:
84+
value = type(None)
85+
if isinstance(value, str):
86+
t = ast.parse(value, '<unknown>', 'eval')
87+
union_transformer = RewriteUnionTypes()
88+
t = union_transformer.visit(t)
89+
builtin_generics_transformer = RewriteBuiltinGenerics()
90+
if sys.version_info < (3, 9):
91+
t = builtin_generics_transformer.visit(t)
92+
if builtin_generics_transformer.rewritten or union_transformer.rewritten:
93+
# Note: ForwardRef raises a TypeError when given anything that isn't a string, so we need
94+
# to compile & eval the ast here
95+
code = compile(t, '<unknown>', 'eval')
96+
hints[name] = eval(code, base_globals, localns)
97+
continue
98+
else:
99+
value = ForwardRef(value, is_argument=False)
100+
value = _eval_type(value, base_globals, localns)
101+
hints[name] = value
102+
return hints

setup.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
requires = [
44
'python-dateutil',
55
'jsonschema',
6-
'mypy_extensions',
76
'typing_extensions;python_version<"3.8"',
87
'dataclasses;python_version<"3.7"'
98
]
@@ -24,7 +23,7 @@ def read(f):
2423
url='https://github.com/s-knibbs/dataclasses-jsonschema',
2524
install_requires=requires,
2625
extras_require={
27-
'fast-validation': ["fastjsonschema==2.13"],
26+
'fast-validation': ["fastjsonschema"],
2827
'apispec': ["apispec"]
2928
},
3029
setup_requires=['pytest-runner', 'setuptools_scm'],

tests/test_peps.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
5+
from typing import List
6+
7+
from dataclasses_jsonschema import JsonSchemaMixin
8+
9+
10+
def test_pep_604_types():
11+
@dataclass
12+
class Post(JsonSchemaMixin):
13+
body: str
14+
tags: str | List[str] | None
15+
metadata: List[str | int]
16+
17+
schema = Post.json_schema()
18+
assert schema['properties']['tags'] == {
19+
'oneOf': [{'type': 'string'}, {'type': 'array', 'items': {'type': 'string'}}]
20+
}
21+
assert schema['properties']['metadata'] == {
22+
'type': 'array', 'items': {'oneOf': [{'type': 'string'}, {'type': 'integer'}]}
23+
}
24+
assert schema['required'] == ['body', 'metadata']
25+
26+
27+
def test_pep_585_types():
28+
29+
@dataclass
30+
class Collections(JsonSchemaMixin):
31+
a: list[str]
32+
b: dict[str, int]
33+
c: set[int]
34+
d: tuple[int, str]
35+
36+
schema = Collections.json_schema()
37+
assert schema['properties'] == {
38+
'a': {'type': 'array', 'items': {'type': 'string'}},
39+
'b': {'additionalProperties': {'type': 'integer'}, 'type': 'object'},
40+
'c': {'type': 'array', 'items': {'type': 'integer'}, 'uniqueItems': True},
41+
'd': {'type': 'array', 'items': [{'type': 'integer'}, {'type': 'string'}], 'maxItems': 2, 'minItems': 2}
42+
}
43+
assert Collections(a=['foo'], b={'bar': 123}, c={4, 5}, d=(6, 'baz')).to_dict() == {
44+
'a': ['foo'], 'b': {'bar': 123}, 'c': [4, 5], 'd': [6, 'baz']
45+
}

tox.ini

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,17 @@ deps =
2222
apispec[yaml]
2323
apispec_webframeworks
2424
flask
25-
fastvalidation: fastjsonschema==2.13
25+
fastvalidation: fastjsonschema
26+
27+
[testenv:py36]
28+
# Exclude test_peps from py 3.6 since this is not supported
29+
commands =
30+
pytest tests/test_core.py tests/test_apispec_plugin.py
31+
flake8 dataclasses_jsonschema
32+
mypy dataclasses_jsonschema
33+
34+
[testenv:py36-fastvalidation]
35+
commands =
36+
pytest tests/test_core.py tests/test_apispec_plugin.py
37+
flake8 dataclasses_jsonschema
38+
mypy dataclasses_jsonschema

0 commit comments

Comments
 (0)