pytastic 0.0.4__py3-none-any.whl → 0.0.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytastic/codegen.py +245 -0
- pytastic/compiler.py +2 -0
- pytastic/core.py +14 -10
- pytastic/schema.py +9 -20
- pytastic/validators.py +49 -50
- {pytastic-0.0.4.dist-info → pytastic-0.0.6.dist-info}/METADATA +14 -1
- pytastic-0.0.6.dist-info/RECORD +12 -0
- pytastic-0.0.4.dist-info/RECORD +0 -11
- {pytastic-0.0.4.dist-info → pytastic-0.0.6.dist-info}/WHEEL +0 -0
- {pytastic-0.0.4.dist-info → pytastic-0.0.6.dist-info}/licenses/LICENSE +0 -0
pytastic/codegen.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
from typing import Any, Type, Dict, get_origin, get_args, Union, List, Tuple, Annotated, _TypedDictMeta, Literal
|
|
2
|
+
from typing import get_type_hints
|
|
3
|
+
from pytastic.exceptions import SchemaDefinitionError
|
|
4
|
+
from pytastic.utils import parse_constraints
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CodegenCompiler:
|
|
8
|
+
"""Generates optimized Python validation functions from type schemas."""
|
|
9
|
+
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self._cache: Dict[Type, Any] = {}
|
|
12
|
+
self._counter = 0
|
|
13
|
+
|
|
14
|
+
def compile(self, schema: Type) -> Any:
|
|
15
|
+
"""Compiles a schema into a fast validation function."""
|
|
16
|
+
if schema in self._cache:
|
|
17
|
+
return self._cache[schema]
|
|
18
|
+
|
|
19
|
+
schema_name = getattr(schema, '__name__', f'Schema{self._counter}')
|
|
20
|
+
self._counter += 1
|
|
21
|
+
|
|
22
|
+
code_lines = []
|
|
23
|
+
code_lines.append(f"def validate_{schema_name}(data, path=''):")
|
|
24
|
+
|
|
25
|
+
body_lines = self._generate_validator(schema, 'data', 'path', indent=1)
|
|
26
|
+
code_lines.extend(body_lines)
|
|
27
|
+
code_lines.append(" return data")
|
|
28
|
+
|
|
29
|
+
code = '\n'.join(code_lines)
|
|
30
|
+
|
|
31
|
+
namespace = {
|
|
32
|
+
'ValidationError': __import__('pytastic.exceptions', fromlist=['ValidationError']).ValidationError,
|
|
33
|
+
're': __import__('re'),
|
|
34
|
+
'math': __import__('math'),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
exec(code, namespace)
|
|
38
|
+
validator_func = namespace[f'validate_{schema_name}']
|
|
39
|
+
|
|
40
|
+
self._cache[schema] = validator_func
|
|
41
|
+
return validator_func
|
|
42
|
+
|
|
43
|
+
def _generate_validator(self, schema: Type, var_name: str, path_var: str, indent: int) -> List[str]:
|
|
44
|
+
"""Generate validation code for a schema type."""
|
|
45
|
+
origin = get_origin(schema)
|
|
46
|
+
args = get_args(schema)
|
|
47
|
+
ind = ' ' * indent
|
|
48
|
+
lines = []
|
|
49
|
+
|
|
50
|
+
if origin is Annotated:
|
|
51
|
+
base_type = args[0]
|
|
52
|
+
constraint_str = ''
|
|
53
|
+
for arg in args[1:]:
|
|
54
|
+
if isinstance(arg, str):
|
|
55
|
+
constraint_str += arg + ';'
|
|
56
|
+
constraints = parse_constraints(constraint_str)
|
|
57
|
+
|
|
58
|
+
base_origin = get_origin(base_type)
|
|
59
|
+
|
|
60
|
+
if base_type is int or base_type is float:
|
|
61
|
+
return self._gen_number(var_name, path_var, base_type, constraints, indent)
|
|
62
|
+
elif base_type is str:
|
|
63
|
+
return self._gen_string(var_name, path_var, constraints, indent)
|
|
64
|
+
elif self._is_typeddict(base_type):
|
|
65
|
+
return self._gen_object(base_type, var_name, path_var, constraints, indent)
|
|
66
|
+
elif base_origin is list or base_origin is List:
|
|
67
|
+
inner_type = get_args(base_type)[0] if get_args(base_type) else Any
|
|
68
|
+
return self._gen_list(var_name, path_var, inner_type, constraints, indent)
|
|
69
|
+
|
|
70
|
+
if self._is_typeddict(schema):
|
|
71
|
+
return self._gen_object(schema, var_name, path_var, {}, indent)
|
|
72
|
+
|
|
73
|
+
if origin is list or origin is List:
|
|
74
|
+
inner_type = args[0] if args else Any
|
|
75
|
+
return self._gen_list(var_name, path_var, inner_type, {}, indent)
|
|
76
|
+
|
|
77
|
+
if origin is Union:
|
|
78
|
+
return self._gen_union(var_name, path_var, args, indent)
|
|
79
|
+
|
|
80
|
+
if origin is Literal:
|
|
81
|
+
return self._gen_literal(var_name, path_var, args, indent)
|
|
82
|
+
|
|
83
|
+
if schema is int or schema is float:
|
|
84
|
+
return self._gen_number(var_name, path_var, schema, {}, indent)
|
|
85
|
+
|
|
86
|
+
if schema is str:
|
|
87
|
+
return self._gen_string(var_name, path_var, {}, indent)
|
|
88
|
+
|
|
89
|
+
if schema is bool:
|
|
90
|
+
lines.append(f"{ind}# bool validation (passthrough)")
|
|
91
|
+
|
|
92
|
+
return lines
|
|
93
|
+
|
|
94
|
+
def _gen_number(self, var: str, path: str, num_type: type, constraints: Dict, indent: int) -> List[str]:
|
|
95
|
+
"""Generate number validation code."""
|
|
96
|
+
ind = ' ' * indent
|
|
97
|
+
lines = []
|
|
98
|
+
|
|
99
|
+
type_name = 'int' if num_type is int else 'float'
|
|
100
|
+
lines.append(f"{ind}if not isinstance({var}, (int, float)):")
|
|
101
|
+
lines.append(f"{ind} raise ValidationError(f'Expected number at {{{path}}}', [{{'path': {path}, 'message': 'Invalid type'}}])")
|
|
102
|
+
|
|
103
|
+
if num_type is int:
|
|
104
|
+
lines.append(f"{ind}if isinstance({var}, float) and not {var}.is_integer():")
|
|
105
|
+
lines.append(f"{ind} raise ValidationError(f'Expected integer at {{{path}}}', [{{'path': {path}, 'message': 'Expected integer'}}])")
|
|
106
|
+
|
|
107
|
+
if 'min' in constraints:
|
|
108
|
+
min_val = constraints['min']
|
|
109
|
+
lines.append(f"{ind}if {var} < {min_val}:")
|
|
110
|
+
lines.append(f"{ind} raise ValidationError(f'Value {{{var}}} < {min_val} at {{{path}}}', [{{'path': {path}, 'message': 'Must be >= {min_val}'}}])")
|
|
111
|
+
|
|
112
|
+
if 'max' in constraints:
|
|
113
|
+
max_val = constraints['max']
|
|
114
|
+
lines.append(f"{ind}if {var} > {max_val}:")
|
|
115
|
+
lines.append(f"{ind} raise ValidationError(f'Value {{{var}}} > {max_val} at {{{path}}}', [{{'path': {path}, 'message': 'Must be <= {max_val}'}}])")
|
|
116
|
+
|
|
117
|
+
return lines
|
|
118
|
+
|
|
119
|
+
def _gen_string(self, var: str, path: str, constraints: Dict, indent: int) -> List[str]:
|
|
120
|
+
"""Generate string validation code."""
|
|
121
|
+
ind = ' ' * indent
|
|
122
|
+
lines = []
|
|
123
|
+
|
|
124
|
+
lines.append(f"{ind}if not isinstance({var}, str):")
|
|
125
|
+
lines.append(f"{ind} raise ValidationError(f'Expected string at {{{path}}}', [{{'path': {path}, 'message': 'Invalid type'}}])")
|
|
126
|
+
|
|
127
|
+
min_len = constraints.get('min_length') or constraints.get('min_len')
|
|
128
|
+
if min_len:
|
|
129
|
+
lines.append(f"{ind}if len({var}) < {min_len}:")
|
|
130
|
+
lines.append(f"{ind} raise ValidationError(f'String too short at {{{path}}}', [{{'path': {path}, 'message': 'Min length {min_len}'}}])")
|
|
131
|
+
|
|
132
|
+
max_len = constraints.get('max_length') or constraints.get('max_len')
|
|
133
|
+
if max_len:
|
|
134
|
+
lines.append(f"{ind}if len({var}) > {max_len}:")
|
|
135
|
+
lines.append(f"{ind} raise ValidationError(f'String too long at {{{path}}}', [{{'path': {path}, 'message': 'Max length {max_len}'}}])")
|
|
136
|
+
|
|
137
|
+
pattern = constraints.get('regex') or constraints.get('pattern')
|
|
138
|
+
if pattern:
|
|
139
|
+
escaped_pattern = pattern.replace("'", "\\'")
|
|
140
|
+
lines.append(f"{ind}if not re.search(r'{escaped_pattern}', {var}):")
|
|
141
|
+
lines.append(f"{ind} raise ValidationError(f'Pattern mismatch at {{{path}}}', [{{'path': {path}, 'message': 'Pattern mismatch'}}])")
|
|
142
|
+
|
|
143
|
+
return lines
|
|
144
|
+
|
|
145
|
+
def _gen_object(self, td_cls: Type, var: str, path: str, constraints: Dict, indent: int) -> List[str]:
|
|
146
|
+
"""Generate object/TypedDict validation code."""
|
|
147
|
+
ind = ' ' * indent
|
|
148
|
+
lines = []
|
|
149
|
+
|
|
150
|
+
lines.append(f"{ind}if not isinstance({var}, dict):")
|
|
151
|
+
lines.append(f"{ind} raise ValidationError(f'Expected dict at {{{path}}}', [{{'path': {path}, 'message': 'Invalid type'}}])")
|
|
152
|
+
|
|
153
|
+
type_hints = get_type_hints(td_cls, include_extras=True)
|
|
154
|
+
is_total = getattr(td_cls, '__total__', True)
|
|
155
|
+
required_keys = getattr(td_cls, '__required_keys__', set(type_hints.keys()) if is_total else set())
|
|
156
|
+
|
|
157
|
+
for key, field_type in type_hints.items():
|
|
158
|
+
if key == '_':
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
field_var = f"{var}__{key}"
|
|
162
|
+
is_required = key in required_keys
|
|
163
|
+
|
|
164
|
+
lines.append(f"{ind}{field_var} = {var}.get('{key}')")
|
|
165
|
+
|
|
166
|
+
if is_required:
|
|
167
|
+
lines.append(f"{ind}if {field_var} is None:")
|
|
168
|
+
lines.append(f"{ind} raise ValidationError(f'Missing field {key} at {{{path}}}', [{{'path': {path} + '.{key}', 'message': 'Required'}}])")
|
|
169
|
+
else:
|
|
170
|
+
lines.append(f"{ind}if {field_var} is not None:")
|
|
171
|
+
indent += 1
|
|
172
|
+
ind = ' ' * indent
|
|
173
|
+
|
|
174
|
+
field_path = f"{path} + '.{key}'" if path != "''" else f"'{key}'"
|
|
175
|
+
field_lines = self._generate_validator(field_type, field_var, field_path, indent)
|
|
176
|
+
lines.extend(field_lines)
|
|
177
|
+
|
|
178
|
+
if not is_required:
|
|
179
|
+
indent -= 1
|
|
180
|
+
|
|
181
|
+
return lines
|
|
182
|
+
|
|
183
|
+
def _gen_list(self, var: str, path: str, item_type: Type, constraints: Dict, indent: int) -> List[str]:
|
|
184
|
+
"""Generate list validation code."""
|
|
185
|
+
ind = ' ' * indent
|
|
186
|
+
lines = []
|
|
187
|
+
|
|
188
|
+
lines.append(f"{ind}if not isinstance({var}, list):")
|
|
189
|
+
lines.append(f"{ind} raise ValidationError(f'Expected list at {{{path}}}', [{{'path': {path}, 'message': 'Invalid type'}}])")
|
|
190
|
+
|
|
191
|
+
min_items = constraints.get('min_items')
|
|
192
|
+
if min_items:
|
|
193
|
+
lines.append(f"{ind}if len({var}) < {min_items}:")
|
|
194
|
+
lines.append(f"{ind} raise ValidationError(f'Too few items at {{{path}}}', [{{'path': {path}, 'message': 'Min {min_items} items'}}])")
|
|
195
|
+
|
|
196
|
+
max_items = constraints.get('max_items')
|
|
197
|
+
if max_items:
|
|
198
|
+
lines.append(f"{ind}if len({var}) > {max_items}:")
|
|
199
|
+
lines.append(f"{ind} raise ValidationError(f'Too many items at {{{path}}}', [{{'path': {path}, 'message': 'Max {max_items} items'}}])")
|
|
200
|
+
|
|
201
|
+
if constraints.get('unique'):
|
|
202
|
+
lines.append(f"{ind}if len({var}) != len(set({var})):")
|
|
203
|
+
lines.append(f"{ind} raise ValidationError(f'Duplicate items found at {{{path}}}', [{{'path': {path}, 'message': 'Duplicate items found'}}])")
|
|
204
|
+
|
|
205
|
+
lines.append(f"{ind}for _idx, _item in enumerate({var}):")
|
|
206
|
+
item_path = f"{path} + f'[{{_idx}}]'"
|
|
207
|
+
item_lines = self._generate_validator(item_type, '_item', item_path, indent + 1)
|
|
208
|
+
lines.extend(item_lines)
|
|
209
|
+
|
|
210
|
+
return lines
|
|
211
|
+
|
|
212
|
+
def _gen_union(self, var: str, path: str, types: Tuple, indent: int) -> List[str]:
|
|
213
|
+
"""Generate union validation code."""
|
|
214
|
+
ind = ' ' * indent
|
|
215
|
+
lines = []
|
|
216
|
+
|
|
217
|
+
lines.append(f"{ind}_union_errors = []")
|
|
218
|
+
for i, union_type in enumerate(types):
|
|
219
|
+
lines.append(f"{ind}try:")
|
|
220
|
+
type_lines = self._generate_validator(union_type, var, path, indent + 1)
|
|
221
|
+
lines.extend(type_lines)
|
|
222
|
+
if i < len(types) - 1:
|
|
223
|
+
lines.append(f"{ind}except ValidationError as _e:")
|
|
224
|
+
lines.append(f"{ind} _union_errors.append(_e)")
|
|
225
|
+
else:
|
|
226
|
+
lines.append(f"{ind}except ValidationError:")
|
|
227
|
+
lines.append(f"{ind} raise ValidationError(f'No union match at {{{path}}}', [{{'path': {path}, 'message': 'No match'}}])")
|
|
228
|
+
|
|
229
|
+
return lines
|
|
230
|
+
|
|
231
|
+
def _gen_literal(self, var: str, path: str, values: Tuple, indent: int) -> List[str]:
|
|
232
|
+
"""Generate literal validation code."""
|
|
233
|
+
ind = ' ' * indent
|
|
234
|
+
lines = []
|
|
235
|
+
|
|
236
|
+
allowed = list(values)
|
|
237
|
+
lines.append(f"{ind}_allowed = {repr(allowed)}")
|
|
238
|
+
lines.append(f"{ind}if {var} not in _allowed:")
|
|
239
|
+
lines.append(f"{ind} raise ValidationError(f'Invalid literal value at {{{path}}}', [{{'path': {path}, 'message': 'Must be one of ' + str(_allowed)}}])")
|
|
240
|
+
|
|
241
|
+
return lines
|
|
242
|
+
|
|
243
|
+
def _is_typeddict(self, t: Type) -> bool:
|
|
244
|
+
"""Check if type is a TypedDict."""
|
|
245
|
+
return isinstance(t, _TypedDictMeta) or (isinstance(t, type) and issubclass(t, dict) and hasattr(t, '__annotations__'))
|
pytastic/compiler.py
CHANGED
|
@@ -79,6 +79,8 @@ class SchemaCompiler:
|
|
|
79
79
|
return NumberValidator({}, number_type=schema)
|
|
80
80
|
if schema is str:
|
|
81
81
|
return StringValidator({})
|
|
82
|
+
if schema is bool:
|
|
83
|
+
return AnyValidator()
|
|
82
84
|
if schema is Any:
|
|
83
85
|
return AnyValidator()
|
|
84
86
|
raise SchemaDefinitionError(f"Unsupported type: {schema}")
|
pytastic/core.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from typing import Any, Type, Dict, TypeVar, Optional, Callable
|
|
2
|
-
from pytastic.
|
|
2
|
+
from pytastic.codegen import CodegenCompiler
|
|
3
3
|
from pytastic.exceptions import PytasticError
|
|
4
4
|
from pytastic.schema import JsonSchemaGenerator, to_json_string
|
|
5
|
+
from pytastic.compiler import SchemaCompiler
|
|
5
6
|
|
|
6
7
|
T = TypeVar("T")
|
|
7
8
|
|
|
@@ -11,34 +12,37 @@ class Pytastic:
|
|
|
11
12
|
Functions as a registry and factory for validators.
|
|
12
13
|
"""
|
|
13
14
|
def __init__(self):
|
|
15
|
+
self.codegen = CodegenCompiler()
|
|
14
16
|
self.compiler = SchemaCompiler()
|
|
15
17
|
self._registry: Dict[str, Any] = {}
|
|
18
|
+
self._attr_cache: Dict[str, Callable[[Any], Any]] = {}
|
|
16
19
|
|
|
17
20
|
def register(self, schema: Type[T]) -> None:
|
|
18
21
|
"""
|
|
19
22
|
Registers a schema (TypedDict) with Pytastic.
|
|
20
|
-
Compiles the schema immediately.
|
|
23
|
+
Compiles the schema immediately using code generation.
|
|
21
24
|
"""
|
|
22
|
-
validator = self.
|
|
25
|
+
validator = self.codegen.compile(schema)
|
|
23
26
|
self._registry[schema.__name__] = validator
|
|
24
27
|
|
|
25
28
|
def validate(self, schema: Type[T], data: Any) -> T:
|
|
26
29
|
"""
|
|
27
|
-
Validates data against a schema.
|
|
28
|
-
If the schema is not registered, it is compiled on the fly.
|
|
30
|
+
Validates data against a schema using code generation.
|
|
29
31
|
"""
|
|
30
|
-
validator = self.
|
|
31
|
-
return validator
|
|
32
|
+
validator = self.codegen.compile(schema)
|
|
33
|
+
return validator(data)
|
|
32
34
|
|
|
33
35
|
def __getattr__(self, name: str) -> Callable[[Any], Any]:
|
|
34
36
|
"""
|
|
35
37
|
Enables dynamic syntax: vx.UserSchema(data)
|
|
36
38
|
"""
|
|
39
|
+
if name in self._attr_cache:
|
|
40
|
+
return self._attr_cache[name]
|
|
41
|
+
|
|
37
42
|
if name in self._registry:
|
|
38
43
|
validator = self._registry[name]
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return validate_wrapper
|
|
44
|
+
self._attr_cache[name] = validator
|
|
45
|
+
return validator
|
|
42
46
|
|
|
43
47
|
raise AttributeError(f"'Pytastic' object has no attribute '{name}'. Did you forget to register the schema?")
|
|
44
48
|
|
pytastic/schema.py
CHANGED
|
@@ -42,33 +42,22 @@ class JsonSchemaGenerator:
|
|
|
42
42
|
|
|
43
43
|
def _visit_number(self, v: NumberValidator) -> Dict[str, Any]:
|
|
44
44
|
schema: Dict[str, Any] = {"type": "integer" if v.number_type is int else "number"}
|
|
45
|
-
c = v.constraints
|
|
46
|
-
|
|
47
|
-
if 'min' in c: schema['minimum'] = float(c['min'])
|
|
48
|
-
if 'max' in c: schema['maximum'] = float(c['max'])
|
|
49
|
-
if 'exclusive_min' in c: schema['exclusiveMinimum'] = float(c['exclusive_min'])
|
|
50
|
-
if 'exclusive_max' in c: schema['exclusiveMaximum'] = float(c['exclusive_max'])
|
|
51
45
|
|
|
52
|
-
|
|
53
|
-
if
|
|
46
|
+
if v.min_val is not None: schema['minimum'] = v.min_val
|
|
47
|
+
if v.max_val is not None: schema['maximum'] = v.max_val
|
|
48
|
+
if v.exclusive_min_val is not None: schema['exclusiveMinimum'] = v.exclusive_min_val
|
|
49
|
+
if v.exclusive_max_val is not None: schema['exclusiveMaximum'] = v.exclusive_max_val
|
|
50
|
+
if v.step_val is not None: schema['multipleOf'] = v.step_val
|
|
54
51
|
|
|
55
52
|
return schema
|
|
56
53
|
|
|
57
54
|
def _visit_string(self, v: StringValidator) -> Dict[str, Any]:
|
|
58
55
|
schema: Dict[str, Any] = {"type": "string"}
|
|
59
|
-
c = v.constraints
|
|
60
|
-
|
|
61
|
-
min_l = c.get('min_length') or c.get('min_len')
|
|
62
|
-
if min_l: schema['minLength'] = int(min_l)
|
|
63
|
-
|
|
64
|
-
max_l = c.get('max_length') or c.get('max_len')
|
|
65
|
-
if max_l: schema['maxLength'] = int(max_l)
|
|
66
|
-
|
|
67
|
-
pattern = c.get('regex') or c.get('pattern')
|
|
68
|
-
if pattern: schema['pattern'] = str(pattern)
|
|
69
56
|
|
|
70
|
-
|
|
71
|
-
if
|
|
57
|
+
if v.min_len is not None: schema['minLength'] = v.min_len
|
|
58
|
+
if v.max_len is not None: schema['maxLength'] = v.max_len
|
|
59
|
+
if v.pattern: schema['pattern'] = str(v.pattern)
|
|
60
|
+
if v.format: schema['format'] = str(v.format)
|
|
72
61
|
|
|
73
62
|
return schema
|
|
74
63
|
|
pytastic/validators.py
CHANGED
|
@@ -14,13 +14,22 @@ class Validator(ABC, Generic[T]):
|
|
|
14
14
|
pass
|
|
15
15
|
|
|
16
16
|
class AnyValidator(Validator[Any]):
|
|
17
|
+
__slots__ = ()
|
|
18
|
+
|
|
17
19
|
def validate(self, data: Any, path: str = "") -> Any:
|
|
18
20
|
return data
|
|
19
21
|
|
|
20
22
|
class NumberValidator(Validator[Union[int, float]]):
|
|
23
|
+
__slots__ = ('number_type', 'min_val', 'max_val', 'exclusive_min_val', 'exclusive_max_val', 'step_val')
|
|
24
|
+
|
|
21
25
|
def __init__(self, constraints: Dict[str, Any], number_type: type = int):
|
|
22
|
-
self.constraints = constraints
|
|
23
26
|
self.number_type = number_type
|
|
27
|
+
self.min_val = float(constraints['min']) if 'min' in constraints else None
|
|
28
|
+
self.max_val = float(constraints['max']) if 'max' in constraints else None
|
|
29
|
+
self.exclusive_min_val = float(constraints['exclusive_min']) if 'exclusive_min' in constraints else None
|
|
30
|
+
self.exclusive_max_val = float(constraints['exclusive_max']) if 'exclusive_max' in constraints else None
|
|
31
|
+
step = constraints.get('step') or constraints.get('multiple_of')
|
|
32
|
+
self.step_val = float(step) if step else None
|
|
24
33
|
|
|
25
34
|
def validate(self, data: Any, path: str = "") -> Union[int, float]:
|
|
26
35
|
if not isinstance(data, (int, float)):
|
|
@@ -31,39 +40,34 @@ class NumberValidator(Validator[Union[int, float]]):
|
|
|
31
40
|
|
|
32
41
|
val = data
|
|
33
42
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
limit = float(self.constraints['min'])
|
|
37
|
-
if val < limit:
|
|
38
|
-
raise ValidationError(f"Value {val} is less than minimum {limit}", [{"path": path, "message": f"Must be >= {limit}"}])
|
|
43
|
+
if self.min_val is not None and val < self.min_val:
|
|
44
|
+
raise ValidationError(f"Value {val} is less than minimum {self.min_val}", [{"path": path, "message": f"Must be >= {self.min_val}"}])
|
|
39
45
|
|
|
40
|
-
if
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
# Exclusive Min/Max
|
|
46
|
-
if 'exclusive_min' in self.constraints:
|
|
47
|
-
limit = float(self.constraints['exclusive_min'])
|
|
48
|
-
if val <= limit:
|
|
49
|
-
raise ValidationError(f"Value {val} must be greater than {limit}", [{"path": path, "message": f"Must be > {limit}"}])
|
|
46
|
+
if self.max_val is not None and val > self.max_val:
|
|
47
|
+
raise ValidationError(f"Value {val} is greater than maximum {self.max_val}", [{"path": path, "message": f"Must be <= {self.max_val}"}])
|
|
48
|
+
|
|
49
|
+
if self.exclusive_min_val is not None and val <= self.exclusive_min_val:
|
|
50
|
+
raise ValidationError(f"Value {val} must be greater than {self.exclusive_min_val}", [{"path": path, "message": f"Must be > {self.exclusive_min_val}"}])
|
|
50
51
|
|
|
51
|
-
if
|
|
52
|
-
|
|
53
|
-
if val >= limit:
|
|
54
|
-
raise ValidationError(f"Value {val} must be less than {limit}", [{"path": path, "message": f"Must be < {limit}"}])
|
|
52
|
+
if self.exclusive_max_val is not None and val >= self.exclusive_max_val:
|
|
53
|
+
raise ValidationError(f"Value {val} must be less than {self.exclusive_max_val}", [{"path": path, "message": f"Must be < {self.exclusive_max_val}"}])
|
|
55
54
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if not math.isclose(val % step_val, 0, abs_tol=1e-9) and not math.isclose(val % step_val, step_val, abs_tol=1e-9):
|
|
60
|
-
raise ValidationError(f"Value {val} is not a multiple of {step_val}", [{"path": path, "message": f"Must be multiple of {step_val}"}])
|
|
55
|
+
if self.step_val is not None:
|
|
56
|
+
if not math.isclose(val % self.step_val, 0, abs_tol=1e-9) and not math.isclose(val % self.step_val, self.step_val, abs_tol=1e-9):
|
|
57
|
+
raise ValidationError(f"Value {val} is not a multiple of {self.step_val}", [{"path": path, "message": f"Must be multiple of {self.step_val}"}])
|
|
61
58
|
|
|
62
59
|
return self.number_type(val)
|
|
63
60
|
|
|
64
61
|
class StringValidator(Validator[str]):
|
|
62
|
+
__slots__ = ('min_len', 'max_len', 'pattern', 'format')
|
|
63
|
+
|
|
65
64
|
def __init__(self, constraints: Dict[str, Any]):
|
|
66
|
-
|
|
65
|
+
min_l = constraints.get('min_length') or constraints.get('min_len')
|
|
66
|
+
self.min_len = int(min_l) if min_l else None
|
|
67
|
+
max_l = constraints.get('max_length') or constraints.get('max_len')
|
|
68
|
+
self.max_len = int(max_l) if max_l else None
|
|
69
|
+
self.pattern = constraints.get('regex') or constraints.get('pattern')
|
|
70
|
+
self.format = constraints.get('format')
|
|
67
71
|
|
|
68
72
|
def validate(self, data: Any, path: str = "") -> str:
|
|
69
73
|
if not isinstance(data, str):
|
|
@@ -71,32 +75,19 @@ class StringValidator(Validator[str]):
|
|
|
71
75
|
|
|
72
76
|
val = data
|
|
73
77
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
limit = int(val_limit)
|
|
85
|
-
if len(val) > limit:
|
|
86
|
-
raise ValidationError(f"String length {len(val)} is longer than max {limit}", [{"path": path, "message": f"Length must be <= {limit}"}])
|
|
87
|
-
|
|
88
|
-
# Regex
|
|
89
|
-
if 'regex' in self.constraints or 'pattern' in self.constraints:
|
|
90
|
-
pattern = self.constraints.get('regex') or self.constraints.get('pattern')
|
|
91
|
-
if isinstance(pattern, str) and not re.search(pattern, val):
|
|
92
|
-
raise ValidationError(f"String does not match pattern '{pattern}'", [{"path": path, "message": "Pattern mismatch"}])
|
|
93
|
-
|
|
94
|
-
# Format (Basic implementation)
|
|
95
|
-
fmt = self.constraints.get('format')
|
|
96
|
-
if fmt == 'email':
|
|
78
|
+
if self.min_len is not None and len(val) < self.min_len:
|
|
79
|
+
raise ValidationError(f"String length {len(val)} is shorter than min {self.min_len}", [{"path": path, "message": f"Length must be >= {self.min_len}"}])
|
|
80
|
+
|
|
81
|
+
if self.max_len is not None and len(val) > self.max_len:
|
|
82
|
+
raise ValidationError(f"String length {len(val)} is longer than max {self.max_len}", [{"path": path, "message": f"Length must be <= {self.max_len}"}])
|
|
83
|
+
|
|
84
|
+
if self.pattern and isinstance(self.pattern, str) and not re.search(self.pattern, val):
|
|
85
|
+
raise ValidationError(f"String does not match pattern '{self.pattern}'", [{"path": path, "message": "Pattern mismatch"}])
|
|
86
|
+
|
|
87
|
+
if self.format == 'email':
|
|
97
88
|
if '@' not in val: # Very basic check
|
|
98
89
|
raise ValidationError("Invalid email format", [{"path": path, "message": "Invalid email"}])
|
|
99
|
-
elif
|
|
90
|
+
elif self.format == 'uuid':
|
|
100
91
|
# Basic UUID pattern
|
|
101
92
|
if not re.match(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', val.lower()):
|
|
102
93
|
raise ValidationError("Invalid UUID format", [{"path": path, "message": "Invalid UUID"}])
|
|
@@ -105,6 +96,8 @@ class StringValidator(Validator[str]):
|
|
|
105
96
|
return val
|
|
106
97
|
|
|
107
98
|
class CollectionValidator(Validator[list]):
|
|
99
|
+
__slots__ = ('constraints', 'item_validator')
|
|
100
|
+
|
|
108
101
|
def __init__(self, constraints: Dict[str, Any], item_validator: Union[Validator, List[Validator], None] = None):
|
|
109
102
|
self.constraints = constraints
|
|
110
103
|
self.item_validator = item_validator # Can be a single validator (list) or list of validators (tuple)
|
|
@@ -157,6 +150,8 @@ class CollectionValidator(Validator[list]):
|
|
|
157
150
|
return validated_data
|
|
158
151
|
|
|
159
152
|
class UnionValidator(Validator[Any]):
|
|
153
|
+
__slots__ = ('validators', 'mode')
|
|
154
|
+
|
|
160
155
|
def __init__(self, validators: List[Validator], mode: str = "any_of"):
|
|
161
156
|
self.validators = validators
|
|
162
157
|
self.mode = mode # 'any_of' or 'one_of'
|
|
@@ -186,6 +181,8 @@ class UnionValidator(Validator[Any]):
|
|
|
186
181
|
raise ValidationError("Matches none of the allowed types", [{"path": path, "message": "No match for Union"}])
|
|
187
182
|
|
|
188
183
|
class ObjectValidator(Validator[Dict]):
|
|
184
|
+
__slots__ = ('fields', 'constraints', 'required_keys')
|
|
185
|
+
|
|
189
186
|
def __init__(self, fields: Dict[str, Validator], constraints: Dict[str, Any], required_keys: Set[str]):
|
|
190
187
|
self.fields = fields
|
|
191
188
|
self.constraints = constraints
|
|
@@ -230,6 +227,8 @@ class ObjectValidator(Validator[Dict]):
|
|
|
230
227
|
return final_data
|
|
231
228
|
|
|
232
229
|
class LiteralValidator(Validator[Any]):
|
|
230
|
+
__slots__ = ('allowed_values',)
|
|
231
|
+
|
|
233
232
|
def __init__(self, allowed_values: Tuple[Any, ...]):
|
|
234
233
|
self.allowed_values = allowed_values
|
|
235
234
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytastic
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.6
|
|
4
4
|
Summary: A dependency-free JSON validation library using TypedDict and Annotated
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Author: Tersoo
|
|
@@ -25,6 +25,19 @@ Pytastic is a lightweight validation layer that respects your standard Python ty
|
|
|
25
25
|
- **Zero Dependencies**: Pure Python standard library.
|
|
26
26
|
- **No Learning Curve**: It's just standard Python typing.
|
|
27
27
|
- **IDE Friendly**: We use standard types, so your IDE autocompletion works out of the box.
|
|
28
|
+
- **Fast**: Code generation makes Pytastic faster than Pydantic for common use cases.
|
|
29
|
+
|
|
30
|
+
## Performance
|
|
31
|
+
|
|
32
|
+
Benchmark results (100,000 validation iterations):
|
|
33
|
+
|
|
34
|
+
| Library | Time (s) | Ops/sec | Relative |
|
|
35
|
+
|---------|----------|---------|----------|
|
|
36
|
+
| msgspec | 0.0533 | 1,877,872 | 1.00x |
|
|
37
|
+
| **Pytastic** | **0.1794** | **557,277** | **3.37x** |
|
|
38
|
+
| Pydantic | 0.2002 | 499,381 | 3.76x |
|
|
39
|
+
|
|
40
|
+
**Pytastic is faster than Pydantic** Pure Python with zero dependencies!
|
|
28
41
|
|
|
29
42
|
## Installation
|
|
30
43
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pytastic/__init__.py,sha256=V8VDtL5XF_UMQ1rbR_s6XTffHD5INdZA3G37X4pHbm4,158
|
|
2
|
+
pytastic/codegen.py,sha256=V7rqBTzK_Bm27kN6-pnmfjfBpu-LPFiVRSSqrY4x6gw,11387
|
|
3
|
+
pytastic/compiler.py,sha256=sddyIsVxzEM6dKo-Zre52d6q3YiLlwGequKFdeKPwPA,5384
|
|
4
|
+
pytastic/core.py,sha256=uVSzLaG11ys1ivow57J869uo3KLXS3GtT4El0n-edgI,1982
|
|
5
|
+
pytastic/exceptions.py,sha256=Jp8i00Qo0dqbxO_mwQRO14qQFdzeBqqDsDK7h7dvWz0,721
|
|
6
|
+
pytastic/schema.py,sha256=N37aZBFsNIoWoH9wFAAbzR1DCET0UYg_eAChFl96xRw,4465
|
|
7
|
+
pytastic/utils.py,sha256=_HLPlVL3in2dm2lvGNRDYvmweQ93Fpgf6e4aRlS11Q0,1012
|
|
8
|
+
pytastic/validators.py,sha256=LmoQFzcYiElActlQZIb-1eSxKqvjRQOf1vXN4NCN9jM,11605
|
|
9
|
+
pytastic-0.0.6.dist-info/METADATA,sha256=fmsqOuUimTl55HzxRPM_iO1S_wSCDiBNR8UpAv_V8J8,2641
|
|
10
|
+
pytastic-0.0.6.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
11
|
+
pytastic-0.0.6.dist-info/licenses/LICENSE,sha256=IYQPqrAtRIRqyLsFwHdH9W-VdVfKOnZ2WZu1hrG_nLU,1063
|
|
12
|
+
pytastic-0.0.6.dist-info/RECORD,,
|
pytastic-0.0.4.dist-info/RECORD
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
pytastic/__init__.py,sha256=V8VDtL5XF_UMQ1rbR_s6XTffHD5INdZA3G37X4pHbm4,158
|
|
2
|
-
pytastic/compiler.py,sha256=IC3q1bSYm9WRLV8k_IPJo2hZ2GY6Vpz9FAAsjrIUyzw,5322
|
|
3
|
-
pytastic/core.py,sha256=M3sFf448VHelHK8dCG15XitINMStD_B0QQqccSLRUyk,1836
|
|
4
|
-
pytastic/exceptions.py,sha256=Jp8i00Qo0dqbxO_mwQRO14qQFdzeBqqDsDK7h7dvWz0,721
|
|
5
|
-
pytastic/schema.py,sha256=uMetYWRdYKrL6dUZfiZ9Q3FJGaVFA3W_l96JzU6t_6k,4720
|
|
6
|
-
pytastic/utils.py,sha256=_HLPlVL3in2dm2lvGNRDYvmweQ93Fpgf6e4aRlS11Q0,1012
|
|
7
|
-
pytastic/validators.py,sha256=W9Iw_efDWzsGz_0SJh37sT8CEo4Yd_ZUfzC3Bw_E9Pk,11290
|
|
8
|
-
pytastic-0.0.4.dist-info/METADATA,sha256=HROIiD__rRO9UsKi93y-9XP4V-YWCLZ1nWv-lqiDhBA,2187
|
|
9
|
-
pytastic-0.0.4.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
10
|
-
pytastic-0.0.4.dist-info/licenses/LICENSE,sha256=IYQPqrAtRIRqyLsFwHdH9W-VdVfKOnZ2WZu1hrG_nLU,1063
|
|
11
|
-
pytastic-0.0.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|