Skip to content

Commit 3d3d0a9

Browse files
Merge pull request #229 from phenobarbital/bugfix-optional-list-str
supporting new converters and validations
2 parents ca710bf + ecfa638 commit 3d3d0a9

12 files changed

+432
-40
lines changed

datamodel/abstract.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
from typing import Optional, Any, List, Dict, get_args, get_origin
3+
from types import GenericAlias
34
from collections import OrderedDict
45
from collections.abc import Callable
56
import types
@@ -190,7 +191,8 @@ def _initialize_fields(attrs, annotations, strict):
190191
_default = df.default
191192
_is_dc = is_dataclass(_type)
192193
_is_prim = is_primitive(_type)
193-
_is_typing = hasattr(_type, '__module__') and _type.__module__ == 'typing'
194+
_is_alias = isinstance(_type, GenericAlias)
195+
_is_typing = hasattr(_type, '__module__') and _type.__module__ == 'typing' # noqa
194196

195197
# Store the type info in the field object:
196198
df.is_dc = _is_dc
@@ -207,12 +209,16 @@ def _initialize_fields(attrs, annotations, strict):
207209
# check type of field:
208210
if _is_prim:
209211
_type_category = 'primitive'
212+
elif origin == type:
213+
_type_category = 'typing'
210214
elif _is_dc:
211215
_type_category = 'dataclass'
212216
elif _is_typing: # noqa
213217
_type_category = 'typing'
214218
elif isclass(_type):
215219
_type_category = 'class'
220+
elif _is_alias:
221+
_type_category = 'typing'
216222
else:
217223
_type_category = 'complex'
218224
_types_local[field] = _type_category

datamodel/converters.pyx

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#
44
import re
55
from typing import get_args, get_origin, Union, Optional, List
6+
from collections.abc import Sequence, Mapping, Callable, Awaitable
67
from dataclasses import _MISSING_TYPE, _FIELDS, fields
78
import ciso8601
89
import orjson
@@ -446,22 +447,6 @@ cdef object _parse_dict_type(
446447
new_dict[k] = parse_typing(field, val_type, v, encoder, False)
447448
return new_dict
448449

449-
cdef object _unwrap_optional(object T):
450-
"""
451-
If T is a Union that includes NoneType (i.e. Optional[T]),
452-
return the non-None type, else T unchanged.
453-
"""
454-
cdef object orig = get_origin(T)
455-
cdef tuple args = None
456-
cdef list non_none = []
457-
if orig is Union:
458-
args = get_args(T)
459-
# If exactly one type is not NoneType, return it.
460-
non_none = [a for a in args if a is not type(None)]
461-
if len(non_none) == 1:
462-
return non_none[0]
463-
return T, args
464-
465450
cdef object _parse_list_type(
466451
object field,
467452
object T,
@@ -601,13 +586,30 @@ cdef object _parse_typing_type(
601586
"""
602587
cdef tuple type_args = getattr(T, '__args__', ())
603588

604-
if name == 'Dict' and isinstance(data, dict):
605-
if type_args:
606-
# e.g. Dict[K, V]
607-
return {k: _parse_type(field, type_args[1], v, None, False) for k, v in data.items()}
608-
return data
589+
if field.origin in {dict, Mapping} or name in {'Dict', 'Mapping'}:
590+
if isinstance(data, dict):
591+
if type_args:
592+
# e.g. Dict[K, V]
593+
return {k: _parse_type(field, type_args[1], v, None, False) for k, v in data.items()}
594+
return data
595+
596+
if name == 'Tuple' or field.origin == tuple:
597+
if isinstance(data, (list, tuple)):
598+
if len(data) == len(type_args):
599+
return tuple(
600+
_parse_type(field, typ, datum, encoder, False)
601+
for typ, datum in zip(type_args, data)
602+
)
603+
else:
604+
if len(type_args) == 2 and type_args[1] is Ellipsis:
605+
# e.g. Tuple[str, ...]
606+
return tuple(
607+
_parse_type(field, type_args[0], datum, None, False)
608+
for datum in data
609+
)
610+
return tuple(data)
609611

610-
if name == 'List':
612+
if name in {'List', 'Sequence'} or field.origin in {list, Sequence}:
611613
if not isinstance(data, (list, tuple)):
612614
data = [data]
613615
return _parse_list_typing(

datamodel/functions.pyx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ cpdef bool_t is_empty(object value):
6868
result = True
6969
elif isinstance(value, (int, float)) and value == 0:
7070
result = False
71+
elif isinstance(value, dict) and value == {}:
72+
result = False
73+
elif isinstance(value, (list, tuple, set)) and value == []:
74+
result = False
7175
elif not value:
7276
result = True
7377
return result

datamodel/validation.pyx

Lines changed: 80 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
# Copyright (C) 2018-present Jesus Lara
33
#
44
from typing import get_args, get_origin, Union, Optional
5+
from collections.abc import Callable, Awaitable
56
import typing
7+
import asyncio
68
import inspect
79
from libcpp cimport bool as bool_t
810
from enum import Enum
@@ -50,20 +52,19 @@ cpdef list _validation(object F, str name, object value, object annotated_type,
5052

5153
# first: calling (if exists) custom validator:
5254
fn = F.metadata.get('validator', None)
53-
if fn is not None:
54-
if is_callable(fn):
55-
try:
56-
result = fn(F, value)
57-
if not result:
58-
error_msg = f"Validator {fn!r} Failed: {result}"
59-
errors.append(
60-
_create_error(name, value, error_msg, val_type, annotated_type)
61-
)
62-
except (ValueError, AttributeError, TypeError) as e:
55+
if fn is not None and callable(fn):
56+
try:
57+
result = fn(F, value)
58+
if not result:
6359
error_msg = f"Validator {fn!r} Failed: {result}"
6460
errors.append(
65-
_create_error(name, value, error_msg, val_type, annotated_type, e)
61+
_create_error(name, value, error_msg, val_type, annotated_type)
6662
)
63+
except (ValueError, AttributeError, TypeError) as e:
64+
error_msg = f"Validator {fn!r} Failed: {result}"
65+
errors.append(
66+
_create_error(name, value, error_msg, val_type, annotated_type, e)
67+
)
6768
# check: data type hint
6869
try:
6970
# If field_type is known, short-circuit certain checks
@@ -88,8 +89,74 @@ cpdef list _validation(object F, str name, object value, object annotated_type,
8889
errors.append(
8990
_create_error(name, value, f'invalid type for {annotated_type}.{name}, expected {annotated_type}', val_type, annotated_type)
9091
)
91-
elif hasattr(annotated_type, '__module__') and annotated_type.__module__ == 'typing':
92-
# TODO: validation of annotated types
92+
elif F.origin is Callable:
93+
if not is_callable(value):
94+
errors.append(
95+
_create_error(name, value, f'Invalid function type, expected {annotated_type}', val_type, annotated_type)
96+
)
97+
elif F.origin is Awaitable:
98+
if asyncio.iscoroutinefunction(value):
99+
errors.append(
100+
f"Field '{name}': provided coroutine function is not awaitable; call it to obtain a coroutine object."
101+
)
102+
# Otherwise, check if it is awaitable
103+
elif not hasattr(value, '__await__'):
104+
errors.append(
105+
f"Field '{name}': expected an awaitable, but got {type(value)}."
106+
)
107+
elif field_type == 'typing' or hasattr(annotated_type, '__module__') and annotated_type.__module__ == 'typing':
108+
if F.origin is type:
109+
for allowed in F.args:
110+
if isinstance(value, allowed):
111+
break
112+
else:
113+
expected = ', '.join([str(t) for t in F.args])
114+
errors.append(
115+
_create_error(name, value, f'Invalid type for {annotated_type}.{name}, expected a subclass of {expected}', val_type, annotated_type)
116+
)
117+
if F.origin is tuple:
118+
# Check if we are in the homogeneous case: Tuple[T, ...]
119+
if len(F.args) == 2 and F.args[1] is Ellipsis:
120+
for i, elem in enumerate(value):
121+
if not isinstance(elem, F.args[0]):
122+
errors.append(
123+
_create_error(f"{name}[{i}]", elem,
124+
f"Invalid type at index {i}: expected {F.args[0]}",
125+
type(elem), F.args[0])
126+
)
127+
else:
128+
if len(value) != len(F.args):
129+
errors.append(
130+
_create_error(name, value,
131+
f"Invalid number of elements: expected {len(F.args)}, got {len(value)}",
132+
len(value), len(F.args))
133+
)
134+
else:
135+
for i, elem in enumerate(value):
136+
if not isinstance(elem, F.args[i]):
137+
errors.append(
138+
_create_error(f"{name}[{i}]", elem,
139+
f"Invalid type at index {i}: expected {F.args[i]}",
140+
type(elem), F.args[i])
141+
)
142+
# Handle Optional Types:
143+
elif F.origin is Union and type(None) in F.args:
144+
inner_types = [t for t in F.args if t is not type(None)]
145+
# If value is None then that is valid:
146+
if value is None:
147+
return errors
148+
# Otherwise check that value is an instance of at least one inner type:
149+
for t in inner_types:
150+
base_type = get_origin(t) or t
151+
if not isinstance(value, base_type):
152+
errors.append(
153+
_create_error(
154+
name,
155+
value,
156+
f"Invalid type for Optional field; expected one of {inner_types}",
157+
val_type, annotated_type
158+
)
159+
)
93160
return errors
94161
# elif type(annotated_type) is ModelMeta:
95162
elif type(annotated_type).__name__ == "ModelMeta":

datamodel/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
'simple library based on python +3.8 to use Dataclass-syntax'
77
'for interacting with Data'
88
)
9-
__version__ = '0.8.12'
9+
__version__ = '0.8.13'
1010
__copyright__ = 'Copyright (c) 2020-2024 Jesus Lara'
1111
__author__ = 'Jesus Lara'
1212
__author_email__ = '[email protected]'

examples/test_callables.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# test_example_model.py
2+
from typing import Callable, Awaitable
3+
import asyncio
4+
import pytest
5+
from datamodel import BaseModel, Field
6+
from datamodel.exceptions import ValidationError
7+
8+
# --- Valid Cases ---
9+
10+
# Define a simple synchronous callback function.
11+
def my_callback(x: int) -> str:
12+
return f"Value: {x}"
13+
14+
# Define an asynchronous function.
15+
async def my_async_func() -> int:
16+
await asyncio.sleep(0.01)
17+
return 42
18+
19+
# Define the ExampleModel using your DataModel framework:
20+
class ExampleModel(BaseModel):
21+
callback: Callable[[int], str] = Field(required=True)
22+
async_result: Awaitable[int] = Field(required=True)
23+
24+
try:
25+
instance = ExampleModel(
26+
callback=my_callback,
27+
async_result=my_async_func() # note: a coroutine object
28+
)
29+
except ValidationError as e:
30+
print(e.payload)

examples/test_qsmodel.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import List, Optional
1+
from typing import List, Optional, Mapping, Sequence, Callable, Tuple, Union
22
from datetime import datetime
33
from datamodel import BaseModel, Field
44
from datamodel.exceptions import ValidationError
@@ -9,8 +9,11 @@
99
'description': 'walmart_mtd_postpaid_to_goal',
1010
'conditions': {'filterdate': 'POSTPAID_DATE', 'store_tier': 'null', 'launch_group': 'null'},
1111
'cond_definition': {'filterdate': 'date', 'store_tier': 'string', 'launch_group': 'string'},
12+
'attributes': {"example": "value"},
1213
'fields': ["client_id", "client_name"], 'ordering': [],
1314
'h_filtering': False,
15+
'directives': ('23.1', 12.8),
16+
'supported': ('1.0', 2.0, '3.0'),
1417
'query_raw': 'SELECT {fields}\nFROM walmart.postpaid_metrics({filterdate}, {launch_group}, {store_tier})\n{where_cond}',
1518
'is_raw': False,
1619
'is_cached': True,
@@ -22,7 +25,11 @@
2225
'program_id': 3,
2326
'program_slug': 'walmart', 'dwh': False, 'dwh_scheduler': {},
2427
'created_at': datetime(2022, 11, 18, 1, 10, 8, 872163),
25-
'updated_at': datetime(2023, 4, 18, 1, 38, 44, 466221)
28+
'updated_at': datetime(2023, 4, 18, 1, 38, 44, 466221),
29+
'host_info': ('host', 4568.1),
30+
'created_by': (1, 33),
31+
"example": {"a": "hello", "b": 123},
32+
"more_nested": {"a": "hello", "b": 123}
2633
}
2734

2835
def rigth_now(obj) -> datetime:
@@ -57,24 +64,29 @@ class QueryModel(BaseModel):
5764
## Program Information:
5865
program_id: int = Field(required=True, default=1)
5966
program_slug: str = Field(required=True, default='default')
67+
directives: Tuple[float, float] = Field(required=False)
68+
supported: Tuple[float, ...] = Field(required=False)
6069
# DWH information
6170
dwh: bool = Field(required=True, default=False)
6271
dwh_driver: str = Field(required=False, default=None)
6372
dwh_info: Optional[dict] = Field(required=False, db_type='jsonb')
6473
dwh_scheduler: Optional[dict] = Field(required=False, db_type='jsonb')
74+
host_info: Tuple[str, int] = Field(required=True)
6575
# Creation Information:
6676
created_at: datetime = Field(
6777
required=False,
6878
default=datetime.now,
6979
db_default='now()'
7080
)
71-
created_by: int = Field(required=False) # TODO: validation for valid user
81+
created_by: tuple[int, str] = Field(required=False) # TODO: validation for valid user
7282
updated_at: datetime = Field(
7383
required=False,
7484
default=datetime.now,
7585
encoder=rigth_now
7686
)
7787
updated_by: int = Field(required=False) # TODO: validation for valid user
88+
example: Mapping[str, str | int] = Field(required=False)
89+
more_nested: Mapping[str, Union[str, str]] = Field(required=False)
7890

7991
class Meta:
8092
driver = 'pg'

examples/test_type_user.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from typing import Type, Union
2+
from datamodel import BaseModel, Field
3+
from datamodel.exceptions import ValidationError
4+
5+
# Define a basic user hierarchy.
6+
class User(BaseModel):
7+
username: str = Field(required=True)
8+
email: str = Field(required=True)
9+
10+
class BasicUser(User):
11+
level: str = Field(default="basic")
12+
13+
class ProUser(User):
14+
level: str = Field(default="pro")
15+
perks: list = Field(default_factory=list)
16+
17+
# Employee model whose user_class field must be a type (class)
18+
# that is either BasicUser or ProUser.
19+
class Employee(BaseModel):
20+
# The type hint below means: user_class must be a type (class)
21+
# that is a subclass of either BasicUser or ProUser.
22+
user_class: type[BasicUser | ProUser] = Field(required=True)
23+
24+
25+
try:
26+
# This should raise a ValidationError because the type hint
27+
# specifies that user_class must be a type (class), not an instance.
28+
user = BasicUser(username="user", email="email")
29+
employee = Employee(user_class=user)
30+
print(employee)
31+
except ValidationError as e:
32+
print(e.payload)
33+
34+
try:
35+
# This should raise a ValidationError because the type hint
36+
# specifies that user_class must be a type (class), not an instance.
37+
user = User(username="user", email="email")
38+
employee = Employee(user_class=user)
39+
print(employee)
40+
except ValidationError as e:
41+
print(e.payload)

0 commit comments

Comments
 (0)