pytastic 0.0.1__tar.gz
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-0.0.1/PKG-INFO +65 -0
- pytastic-0.0.1/README.md +48 -0
- pytastic-0.0.1/pyproject.toml +14 -0
- pytastic-0.0.1/pytastic/__init__.py +4 -0
- pytastic-0.0.1/pytastic/compiler.py +181 -0
- pytastic-0.0.1/pytastic/core.py +54 -0
- pytastic-0.0.1/pytastic/exceptions.py +22 -0
- pytastic-0.0.1/pytastic/schema.py +126 -0
- pytastic-0.0.1/pytastic/utils.py +46 -0
- pytastic-0.0.1/pytastic/validators.py +251 -0
pytastic-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytastic
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A dependency-free JSON validation library using TypedDict and Annotated
|
|
5
|
+
Author: Tersoo
|
|
6
|
+
Author-email: tersoo@example.com
|
|
7
|
+
Requires-Python: >=3.9,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Pytastic
|
|
18
|
+
|
|
19
|
+
A dependency-free, high-performance JSON validation library using Python's native `TypedDict` and `Annotated`.
|
|
20
|
+
|
|
21
|
+
## Highlights
|
|
22
|
+
- **Zero Dependencies**: Pure Python standard library.
|
|
23
|
+
- **Dual API**:
|
|
24
|
+
- `vx.User(data)` for clean, dynamic usage.
|
|
25
|
+
- `vx.validate(User, data)` for full IDE type safety.
|
|
26
|
+
- **Rich Constraints**: `min`, `max`, `regex`, `unique`, `one_of`, `Literal`, and nested validation.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install pytastic
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from pytastic import Pytastic
|
|
38
|
+
from typing import TypedDict, Annotated, List, Literal
|
|
39
|
+
|
|
40
|
+
vx = Pytastic()
|
|
41
|
+
|
|
42
|
+
# 1. Define Schema
|
|
43
|
+
class User(TypedDict):
|
|
44
|
+
username: Annotated[str, "min_len=3; regex=^[a-z_]+$"]
|
|
45
|
+
age: Annotated[int, "min=18"]
|
|
46
|
+
role: Literal["admin", "user"]
|
|
47
|
+
|
|
48
|
+
vx.register(User)
|
|
49
|
+
|
|
50
|
+
# 2. Validate
|
|
51
|
+
try:
|
|
52
|
+
# Typed validation (IDE friendly)
|
|
53
|
+
user = vx.validate(User, {"username": "tersoo", "age": 25, "role": "admin"})
|
|
54
|
+
print(user)
|
|
55
|
+
|
|
56
|
+
# 3. Export JSON Schema
|
|
57
|
+
print(vx.schema(User))
|
|
58
|
+
except Exception as e:
|
|
59
|
+
print(e)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
|
65
|
+
|
pytastic-0.0.1/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Pytastic
|
|
2
|
+
|
|
3
|
+
A dependency-free, high-performance JSON validation library using Python's native `TypedDict` and `Annotated`.
|
|
4
|
+
|
|
5
|
+
## Highlights
|
|
6
|
+
- **Zero Dependencies**: Pure Python standard library.
|
|
7
|
+
- **Dual API**:
|
|
8
|
+
- `vx.User(data)` for clean, dynamic usage.
|
|
9
|
+
- `vx.validate(User, data)` for full IDE type safety.
|
|
10
|
+
- **Rich Constraints**: `min`, `max`, `regex`, `unique`, `one_of`, `Literal`, and nested validation.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install pytastic
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from pytastic import Pytastic
|
|
22
|
+
from typing import TypedDict, Annotated, List, Literal
|
|
23
|
+
|
|
24
|
+
vx = Pytastic()
|
|
25
|
+
|
|
26
|
+
# 1. Define Schema
|
|
27
|
+
class User(TypedDict):
|
|
28
|
+
username: Annotated[str, "min_len=3; regex=^[a-z_]+$"]
|
|
29
|
+
age: Annotated[int, "min=18"]
|
|
30
|
+
role: Literal["admin", "user"]
|
|
31
|
+
|
|
32
|
+
vx.register(User)
|
|
33
|
+
|
|
34
|
+
# 2. Validate
|
|
35
|
+
try:
|
|
36
|
+
# Typed validation (IDE friendly)
|
|
37
|
+
user = vx.validate(User, {"username": "tersoo", "age": 25, "role": "admin"})
|
|
38
|
+
print(user)
|
|
39
|
+
|
|
40
|
+
# 3. Export JSON Schema
|
|
41
|
+
print(vx.schema(User))
|
|
42
|
+
except Exception as e:
|
|
43
|
+
print(e)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "pytastic"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "A dependency-free JSON validation library using TypedDict and Annotated"
|
|
5
|
+
authors = ["Tersoo <tersoo@example.com>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
packages = [{include = "pytastic"}]
|
|
8
|
+
|
|
9
|
+
[tool.poetry.dependencies]
|
|
10
|
+
python = "^3.9"
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["poetry-core"]
|
|
14
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, Type, Dict, get_origin, get_args, Union, List, Tuple, Annotated, _TypedDictMeta, Literal, cast, Generic, TypeVar, Set # type: ignore
|
|
3
|
+
from pytastic.validators import Validator, NumberValidator, StringValidator, CollectionValidator, UnionValidator, ObjectValidator, LiteralValidator
|
|
4
|
+
from pytastic.exceptions import SchemaDefinitionError
|
|
5
|
+
from pytastic.utils import parse_constraints, normalize_key
|
|
6
|
+
|
|
7
|
+
class SchemaCompiler:
|
|
8
|
+
"""Compiles Python types (TypedDict, Annotated, etc.) into Validators."""
|
|
9
|
+
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self._cache: Dict[Type, Validator] = {}
|
|
12
|
+
|
|
13
|
+
def compile(self, schema: Type) -> Validator:
|
|
14
|
+
"""
|
|
15
|
+
Compiles a schema type into a Validator instance.
|
|
16
|
+
"""
|
|
17
|
+
if schema in self._cache:
|
|
18
|
+
return self._cache[schema]
|
|
19
|
+
|
|
20
|
+
validator = self._build_validator(schema)
|
|
21
|
+
self._cache[schema] = validator
|
|
22
|
+
return validator
|
|
23
|
+
|
|
24
|
+
def _build_validator(self, schema: Type) -> Validator:
|
|
25
|
+
origin = get_origin(schema)
|
|
26
|
+
args = get_args(schema)
|
|
27
|
+
|
|
28
|
+
# 1. Annotated[T, "constraints"]
|
|
29
|
+
if origin is Annotated:
|
|
30
|
+
base_type = args[0]
|
|
31
|
+
constraint_str = ""
|
|
32
|
+
# Combine all string annotations
|
|
33
|
+
for arg in args[1:]:
|
|
34
|
+
if isinstance(arg, str):
|
|
35
|
+
constraint_str += arg + ";"
|
|
36
|
+
|
|
37
|
+
constraints = parse_constraints(constraint_str)
|
|
38
|
+
|
|
39
|
+
# Recurse on base type but PASS DOWN constraints?
|
|
40
|
+
# Actually, most validators take constraints in __init__.
|
|
41
|
+
# We need to peek at base type to know which Validator to create.
|
|
42
|
+
|
|
43
|
+
base_origin = get_origin(base_type)
|
|
44
|
+
|
|
45
|
+
if base_type is int or base_type is float:
|
|
46
|
+
return NumberValidator(constraints, number_type=base_type)
|
|
47
|
+
|
|
48
|
+
if base_type is str:
|
|
49
|
+
return StringValidator(constraints)
|
|
50
|
+
|
|
51
|
+
if base_origin is list or base_origin is List:
|
|
52
|
+
# Annotated[list[int], "min_items=3"]
|
|
53
|
+
inner_type = get_args(base_type)[0] if get_args(base_type) else Any
|
|
54
|
+
item_validator = self.compile(inner_type)
|
|
55
|
+
return CollectionValidator(constraints, item_validator=item_validator)
|
|
56
|
+
|
|
57
|
+
if base_origin is tuple or base_origin is Tuple:
|
|
58
|
+
# Annotated[tuple[int, str], "..."]
|
|
59
|
+
inner_types = get_args(base_type)
|
|
60
|
+
item_validators = [self.compile(t) for t in inner_types]
|
|
61
|
+
return CollectionValidator(constraints, item_validator=item_validators)
|
|
62
|
+
|
|
63
|
+
if base_origin is Union:
|
|
64
|
+
# Annotated[Union[A, B], "one_of"]
|
|
65
|
+
union_mode = "one_of" if constraints.get("one_of") else "any_of"
|
|
66
|
+
validators = [self.compile(t) for t in get_args(base_type)]
|
|
67
|
+
return UnionValidator(validators, mode=union_mode)
|
|
68
|
+
|
|
69
|
+
# Annotated[TypedDict, "..."]
|
|
70
|
+
if self._is_typeddict(base_type):
|
|
71
|
+
# Constraints on the object itself (e.g. min_properties)
|
|
72
|
+
# We need to compile the TypedDict logic first, then inject constraints?
|
|
73
|
+
# Or pass constraints to ObjectValidator?
|
|
74
|
+
return self._compile_typeddict(base_type, constraints)
|
|
75
|
+
|
|
76
|
+
# Fallback: Just return compiled base type (ignoring constraints if unknown?)
|
|
77
|
+
# Or raise error?
|
|
78
|
+
return self.compile(base_type)
|
|
79
|
+
|
|
80
|
+
# 2. TypedDict
|
|
81
|
+
if self._is_typeddict(schema):
|
|
82
|
+
return self._compile_typeddict(schema, {})
|
|
83
|
+
|
|
84
|
+
# 3. List[T]
|
|
85
|
+
if origin is list or origin is List:
|
|
86
|
+
inner_type = args[0] if args else Any
|
|
87
|
+
return CollectionValidator({}, item_validator=self.compile(inner_type))
|
|
88
|
+
|
|
89
|
+
# 4. Tuple[T, ...]
|
|
90
|
+
if origin is tuple or origin is Tuple:
|
|
91
|
+
return CollectionValidator({}, item_validator=[self.compile(t) for t in args])
|
|
92
|
+
|
|
93
|
+
# 5. Union[A, B]
|
|
94
|
+
if origin is Union:
|
|
95
|
+
return UnionValidator([self.compile(t) for t in args], mode="any_of")
|
|
96
|
+
|
|
97
|
+
# 6. Literal[A, B]
|
|
98
|
+
if origin is Literal:
|
|
99
|
+
return LiteralValidator(args)
|
|
100
|
+
|
|
101
|
+
# 7. Basic Types (No Annotations)
|
|
102
|
+
if schema is int or schema is float:
|
|
103
|
+
return NumberValidator({}, number_type=schema)
|
|
104
|
+
|
|
105
|
+
if schema is str:
|
|
106
|
+
return StringValidator({})
|
|
107
|
+
|
|
108
|
+
if schema is Any:
|
|
109
|
+
# Pass-through validator
|
|
110
|
+
from pytastic.validators import Validator
|
|
111
|
+
class AnyValidator(Validator):
|
|
112
|
+
def validate(self, data, path=""): return data
|
|
113
|
+
return AnyValidator()
|
|
114
|
+
|
|
115
|
+
# Raise unknown type error
|
|
116
|
+
# Check for NotRequired? NotRequired is stripped by get_type_hints usually,
|
|
117
|
+
# or handled at TypedDict level.
|
|
118
|
+
|
|
119
|
+
raise SchemaDefinitionError(f"Unsupported type: {schema}")
|
|
120
|
+
|
|
121
|
+
def _is_typeddict(self, t: Type) -> bool:
|
|
122
|
+
return isinstance(t, _TypedDictMeta) or (isinstance(t, type) and issubclass(t, dict) and hasattr(t, '__annotations__'))
|
|
123
|
+
|
|
124
|
+
def _compile_typeddict(self, td_cls: Type, constraints: Dict[str, Any]) -> ObjectValidator:
|
|
125
|
+
from typing import get_type_hints, NotRequired, Required
|
|
126
|
+
|
|
127
|
+
# Python 3.9+ get_type_hints(include_extras=True)
|
|
128
|
+
# But for TypedDict, we check key presence.
|
|
129
|
+
|
|
130
|
+
type_hints = get_type_hints(td_cls, include_extras=True)
|
|
131
|
+
fields = {}
|
|
132
|
+
required_keys = set()
|
|
133
|
+
|
|
134
|
+
# Inspect for Required/NotRequired
|
|
135
|
+
# Note: In TypedDict, strict means only defined keys.
|
|
136
|
+
# Checking required status depends on 'total=True/False' and individual generic modifiers.
|
|
137
|
+
|
|
138
|
+
is_total = getattr(td_cls, '__total__', True)
|
|
139
|
+
|
|
140
|
+
for key, value in type_hints.items():
|
|
141
|
+
# Check if value is wrapped in NotRequired or Required
|
|
142
|
+
# This is tricky because get_type_hints might strip it or parse it.
|
|
143
|
+
# get_type_hints with include_extras=True preserves Annotated, but NotRequired handling varies.
|
|
144
|
+
|
|
145
|
+
# Simple heuristic:
|
|
146
|
+
is_required = is_total
|
|
147
|
+
|
|
148
|
+
# TODO: Robust NotRequired detection requires inspecting __annotations__ directly sometimes if get_type_hints strips it?
|
|
149
|
+
# Actually get_type_hints usually resolves it.
|
|
150
|
+
# Using basic TypedDict inspection:
|
|
151
|
+
if hasattr(td_cls, '__required_keys__'):
|
|
152
|
+
is_required = key in td_cls.__required_keys__
|
|
153
|
+
|
|
154
|
+
if is_required:
|
|
155
|
+
required_keys.add(key)
|
|
156
|
+
|
|
157
|
+
fields[key] = self.compile(value)
|
|
158
|
+
|
|
159
|
+
# Constraints from `_: Annotated[None, ...]` dummy key?
|
|
160
|
+
# User example: _: Annotated[None, "strict; min_properties=2"]
|
|
161
|
+
# This key is meant for metadata, not a real field. We should check if it exists in type_hints.
|
|
162
|
+
|
|
163
|
+
if '_' in fields:
|
|
164
|
+
# Extract constraints from wildcard field
|
|
165
|
+
# It's compiled as a AnyValidator or NoneValidator, but we need the Annotated constraints.
|
|
166
|
+
# Easier to check __annotations__ directly for '_'
|
|
167
|
+
meta_annotation = td_cls.__annotations__.get('_', None)
|
|
168
|
+
if meta_annotation and get_origin(meta_annotation) is Annotated:
|
|
169
|
+
args = get_args(meta_annotation)
|
|
170
|
+
constraint_str = ""
|
|
171
|
+
for arg in args[1:]:
|
|
172
|
+
if isinstance(arg, str):
|
|
173
|
+
constraint_str += arg + ";"
|
|
174
|
+
meta_constraints = parse_constraints(constraint_str)
|
|
175
|
+
constraints.update(meta_constraints)
|
|
176
|
+
|
|
177
|
+
# Remove _ from fields and required keys checks
|
|
178
|
+
fields.pop('_', None)
|
|
179
|
+
required_keys.discard('_')
|
|
180
|
+
|
|
181
|
+
return ObjectValidator(fields, constraints, required_keys)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from typing import Any, Type, Dict, TypeVar, Optional, Callable
|
|
2
|
+
from pytastic.compiler import SchemaCompiler
|
|
3
|
+
from pytastic.exceptions import PytasticError
|
|
4
|
+
from pytastic.schema import JsonSchemaGenerator, to_json_string
|
|
5
|
+
|
|
6
|
+
T = TypeVar("T")
|
|
7
|
+
|
|
8
|
+
class Pytastic:
|
|
9
|
+
"""
|
|
10
|
+
Main entry point for Pytastic validation.
|
|
11
|
+
Functions as a registry and factory for validators.
|
|
12
|
+
"""
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.compiler = SchemaCompiler()
|
|
15
|
+
self._registry: Dict[str, Any] = {} # Maps name -> Validator
|
|
16
|
+
|
|
17
|
+
def register(self, schema: Type[T]) -> None:
|
|
18
|
+
"""
|
|
19
|
+
Registers a schema (TypedDict) with Pytastic.
|
|
20
|
+
Compiles the schema immediately.
|
|
21
|
+
"""
|
|
22
|
+
validator = self.compiler.compile(schema)
|
|
23
|
+
self._registry[schema.__name__] = validator
|
|
24
|
+
|
|
25
|
+
def validate(self, schema: Type[T], data: Any) -> T:
|
|
26
|
+
"""
|
|
27
|
+
Validates data against a schema.
|
|
28
|
+
If the schema is not registered, it is compiled on the fly.
|
|
29
|
+
"""
|
|
30
|
+
# Auto-compile if passed a class directly
|
|
31
|
+
validator = self.compiler.compile(schema)
|
|
32
|
+
return validator.validate(data)
|
|
33
|
+
|
|
34
|
+
def __getattr__(self, name: str) -> Callable[[Any], Any]:
|
|
35
|
+
"""
|
|
36
|
+
Enables dynamic syntax: vx.UserSchema(data)
|
|
37
|
+
"""
|
|
38
|
+
if name in self._registry:
|
|
39
|
+
validator = self._registry[name]
|
|
40
|
+
# Return a callable that runs validation
|
|
41
|
+
def validate_wrapper(data: Any) -> Any:
|
|
42
|
+
return validator.validate(data)
|
|
43
|
+
return validate_wrapper
|
|
44
|
+
|
|
45
|
+
raise AttributeError(f"'Pytastic' object has no attribute '{name}'. Did you forget to register the schema?")
|
|
46
|
+
|
|
47
|
+
def schema(self, schema: Type[T]) -> str:
|
|
48
|
+
"""
|
|
49
|
+
Returns the JSON Schema definition for a model as a string.
|
|
50
|
+
"""
|
|
51
|
+
validator = self.compiler.compile(schema)
|
|
52
|
+
generator = JsonSchemaGenerator()
|
|
53
|
+
json_dict = generator.generate(validator)
|
|
54
|
+
return to_json_string(json_dict)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from typing import Any, List, Dict
|
|
2
|
+
|
|
3
|
+
class PytasticError(Exception):
|
|
4
|
+
"""Base exception for all Pytastic errors."""
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
class SchemaDefinitionError(PytasticError):
|
|
8
|
+
"""Raised when a schema definition is invalid."""
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
class ValidationError(PytasticError):
|
|
12
|
+
"""Raised when data validation fails."""
|
|
13
|
+
def __init__(self, message: str, errors: List[Dict[str, Any]] = None):
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.errors = errors if errors is not None else []
|
|
16
|
+
|
|
17
|
+
def __str__(self):
|
|
18
|
+
if not self.errors:
|
|
19
|
+
return super().__str__()
|
|
20
|
+
|
|
21
|
+
details = "\n".join([f" - {e['path']}: {e['message']}" for e in self.errors])
|
|
22
|
+
return f"{super().__str__()}\n{details}"
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Dict, Type, List, Optional
|
|
3
|
+
from pytastic.validators import (
|
|
4
|
+
Validator, NumberValidator, StringValidator,
|
|
5
|
+
CollectionValidator, ObjectValidator,
|
|
6
|
+
UnionValidator, LiteralValidator
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
class JsonSchemaGenerator:
|
|
10
|
+
"""Generates JSON Schema from Pytastic Validators."""
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self.definitions: Dict[str, Any] = {}
|
|
14
|
+
|
|
15
|
+
def generate(self, validator: Validator, root_name: str = "Root") -> Dict[str, Any]:
|
|
16
|
+
"""
|
|
17
|
+
Generates a full JSON schema object.
|
|
18
|
+
"""
|
|
19
|
+
self.definitions = {}
|
|
20
|
+
schema = self._visit(validator)
|
|
21
|
+
|
|
22
|
+
if self.definitions:
|
|
23
|
+
schema["$defs"] = self.definitions
|
|
24
|
+
|
|
25
|
+
return schema
|
|
26
|
+
|
|
27
|
+
def _visit(self, validator: Validator) -> Dict[str, Any]:
|
|
28
|
+
if isinstance(validator, NumberValidator):
|
|
29
|
+
return self._visit_number(validator)
|
|
30
|
+
elif isinstance(validator, StringValidator):
|
|
31
|
+
return self._visit_string(validator)
|
|
32
|
+
elif isinstance(validator, CollectionValidator):
|
|
33
|
+
return self._visit_collection(validator)
|
|
34
|
+
elif isinstance(validator, ObjectValidator):
|
|
35
|
+
return self._visit_object(validator)
|
|
36
|
+
elif isinstance(validator, UnionValidator):
|
|
37
|
+
return self._visit_union(validator)
|
|
38
|
+
elif isinstance(validator, LiteralValidator):
|
|
39
|
+
return self._visit_literal(validator)
|
|
40
|
+
|
|
41
|
+
return {} # Fallback
|
|
42
|
+
|
|
43
|
+
def _visit_number(self, v: NumberValidator) -> Dict[str, Any]:
|
|
44
|
+
schema = {"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
|
+
|
|
52
|
+
step = c.get('step') or c.get('multiple_of')
|
|
53
|
+
if step: schema['multipleOf'] = float(step)
|
|
54
|
+
|
|
55
|
+
return schema
|
|
56
|
+
|
|
57
|
+
def _visit_string(self, v: StringValidator) -> Dict[str, Any]:
|
|
58
|
+
schema = {"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
|
+
|
|
70
|
+
fmt = c.get('format')
|
|
71
|
+
if fmt: schema['format'] = str(fmt)
|
|
72
|
+
|
|
73
|
+
return schema
|
|
74
|
+
|
|
75
|
+
def _visit_collection(self, v: CollectionValidator) -> Dict[str, Any]:
|
|
76
|
+
schema = {"type": "array"}
|
|
77
|
+
c = v.constraints
|
|
78
|
+
|
|
79
|
+
if 'min_items' in c: schema['minItems'] = int(c['min_items'])
|
|
80
|
+
if 'max_items' in c: schema['maxItems'] = int(c['max_items'])
|
|
81
|
+
if c.get('unique') or c.get('unique_items'): schema['uniqueItems'] = True
|
|
82
|
+
|
|
83
|
+
if isinstance(v.item_validator, list):
|
|
84
|
+
# Tuple conversion -> prefixItems (Draft 2020-12) or items array (older drafts)
|
|
85
|
+
# We'll use prefixItems style logic but map to 'items' array + 'minItems' match for simple tuples?
|
|
86
|
+
# Actually standard JSON schema uses "prefixItems" for positionals now.
|
|
87
|
+
# Let's use generic "items": [...] if supported or use prefixItems.
|
|
88
|
+
# To be safe for older parsers, tuple validation usually maps to:
|
|
89
|
+
# "type": "array", "items": [s1, s2], "minItems": X, "maxItems": X
|
|
90
|
+
schema['type'] = 'array'
|
|
91
|
+
schema['prefixItems'] = [self._visit(iv) for iv in v.item_validator]
|
|
92
|
+
schema['minItems'] = len(v.item_validator)
|
|
93
|
+
schema['maxItems'] = len(v.item_validator)
|
|
94
|
+
schema['items'] = False # No extra items allowed
|
|
95
|
+
elif isinstance(v.item_validator, Validator):
|
|
96
|
+
schema['items'] = self._visit(v.item_validator)
|
|
97
|
+
|
|
98
|
+
return schema
|
|
99
|
+
|
|
100
|
+
def _visit_object(self, v: ObjectValidator) -> Dict[str, Any]:
|
|
101
|
+
schema = {"type": "object", "properties": {}, "required": [], "additionalProperties": True}
|
|
102
|
+
|
|
103
|
+
for name, field_validator in v.fields.items():
|
|
104
|
+
schema["properties"][name] = self._visit(field_validator)
|
|
105
|
+
|
|
106
|
+
schema["required"] = list(v.required_keys)
|
|
107
|
+
|
|
108
|
+
c = v.constraints
|
|
109
|
+
if c.get('strict') or c.get('additional_properties') is False:
|
|
110
|
+
schema['additionalProperties'] = False
|
|
111
|
+
|
|
112
|
+
min_p = c.get('min_properties') or c.get('min_props')
|
|
113
|
+
if min_p: schema['minProperties'] = int(min_p)
|
|
114
|
+
|
|
115
|
+
return schema
|
|
116
|
+
|
|
117
|
+
def _visit_union(self, v: UnionValidator) -> Dict[str, Any]:
|
|
118
|
+
schemas = [self._visit(sv) for sv in v.validators]
|
|
119
|
+
key = "oneOf" if v.mode == "one_of" else "anyOf"
|
|
120
|
+
return {key: schemas}
|
|
121
|
+
|
|
122
|
+
def _visit_literal(self, v: LiteralValidator) -> Dict[str, Any]:
|
|
123
|
+
return {"enum": list(v.allowed_values)}
|
|
124
|
+
|
|
125
|
+
def to_json_string(schema: Dict[str, Any]) -> str:
|
|
126
|
+
return json.dumps(schema, indent=2)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from typing import Dict, Any, Union
|
|
2
|
+
|
|
3
|
+
def parse_constraints(annotation_str: str) -> Dict[str, Any]:
|
|
4
|
+
"""
|
|
5
|
+
Parses a constraint string into a dictionary.
|
|
6
|
+
Example: "min=10; max=20; unique" -> {'min': '10', 'max': '20', 'unique': True}
|
|
7
|
+
"""
|
|
8
|
+
if not annotation_str:
|
|
9
|
+
return {}
|
|
10
|
+
|
|
11
|
+
constraints = {}
|
|
12
|
+
# Split by semicolon
|
|
13
|
+
parts = annotation_str.split(';')
|
|
14
|
+
|
|
15
|
+
for part in parts:
|
|
16
|
+
part = part.strip()
|
|
17
|
+
if not part:
|
|
18
|
+
continue
|
|
19
|
+
|
|
20
|
+
if '=' in part:
|
|
21
|
+
key, value = part.split('=', 1)
|
|
22
|
+
key = key.strip()
|
|
23
|
+
value = value.strip()
|
|
24
|
+
|
|
25
|
+
# Basic type inference
|
|
26
|
+
if value.lower() == 'true':
|
|
27
|
+
value = True
|
|
28
|
+
elif value.lower() == 'false':
|
|
29
|
+
value = False
|
|
30
|
+
# We treat numbers as strings here, they will be cast by the specific validators
|
|
31
|
+
# because we don't know if 'min=10' is for an int or a float yet.
|
|
32
|
+
|
|
33
|
+
constraints[key] = value
|
|
34
|
+
else:
|
|
35
|
+
# Boolean flag (e.g., "unique")
|
|
36
|
+
constraints[part] = True
|
|
37
|
+
|
|
38
|
+
return constraints
|
|
39
|
+
|
|
40
|
+
def normalize_key(key: str) -> str:
|
|
41
|
+
"""
|
|
42
|
+
Normalizes keys to snake_case.
|
|
43
|
+
e.g. 'minItems' -> 'min_items' (if we supported camelInput)
|
|
44
|
+
But our spec focuses on snake_case input.
|
|
45
|
+
"""
|
|
46
|
+
return key.lower().replace(" ", "_")
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, List, Dict, Generic, TypeVar, Union, Set, Tuple
|
|
3
|
+
import re
|
|
4
|
+
import math
|
|
5
|
+
from pytastic.exceptions import ValidationError
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
class Validator(ABC, Generic[T]):
|
|
10
|
+
"""Abstract base class for all validators."""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def validate(self, data: Any, path: str = "") -> T:
|
|
14
|
+
"""
|
|
15
|
+
Validate data against the schema.
|
|
16
|
+
Raises ValidationError if validation fails.
|
|
17
|
+
Returns the validated (and possibly coerced) data.
|
|
18
|
+
"""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
class NumberValidator(Validator[Union[int, float]]):
|
|
22
|
+
def __init__(self, constraints: Dict[str, Any], number_type: type = int):
|
|
23
|
+
self.constraints = constraints
|
|
24
|
+
self.number_type = number_type
|
|
25
|
+
|
|
26
|
+
def validate(self, data: Any, path: str = "") -> Union[int, float]:
|
|
27
|
+
if not isinstance(data, (int, float)):
|
|
28
|
+
raise ValidationError(f"Expected number, got {type(data).__name__}", [{"path": path, "message": "Invalid type"}])
|
|
29
|
+
|
|
30
|
+
# Type check (int vs float strictly?)
|
|
31
|
+
# If strict int is needed:
|
|
32
|
+
if self.number_type is int and isinstance(data, float) and not data.is_integer():
|
|
33
|
+
raise ValidationError(f"Expected integer, got float", [{"path": path, "message": "Expected integer"}])
|
|
34
|
+
|
|
35
|
+
val = data
|
|
36
|
+
|
|
37
|
+
# Min/Max
|
|
38
|
+
if 'min' in self.constraints:
|
|
39
|
+
limit = float(self.constraints['min'])
|
|
40
|
+
if val < limit:
|
|
41
|
+
raise ValidationError(f"Value {val} is less than minimum {limit}", [{"path": path, "message": f"Must be >= {limit}"}])
|
|
42
|
+
|
|
43
|
+
if 'max' in self.constraints:
|
|
44
|
+
limit = float(self.constraints['max'])
|
|
45
|
+
if val > limit:
|
|
46
|
+
raise ValidationError(f"Value {val} is greater than maximum {limit}", [{"path": path, "message": f"Must be <= {limit}"}])
|
|
47
|
+
|
|
48
|
+
# Exclusive Min/Max
|
|
49
|
+
if 'exclusive_min' in self.constraints:
|
|
50
|
+
limit = float(self.constraints['exclusive_min'])
|
|
51
|
+
if val <= limit:
|
|
52
|
+
raise ValidationError(f"Value {val} must be greater than {limit}", [{"path": path, "message": f"Must be > {limit}"}])
|
|
53
|
+
|
|
54
|
+
if 'exclusive_max' in self.constraints:
|
|
55
|
+
limit = float(self.constraints['exclusive_max'])
|
|
56
|
+
if val >= limit:
|
|
57
|
+
raise ValidationError(f"Value {val} must be less than {limit}", [{"path": path, "message": f"Must be < {limit}"}])
|
|
58
|
+
|
|
59
|
+
# Step / Multiple Of
|
|
60
|
+
step = self.constraints.get('step') or self.constraints.get('multiple_of')
|
|
61
|
+
if step:
|
|
62
|
+
step_val = float(step)
|
|
63
|
+
# Use a small epsilon for float comparison logic if needed, but simple mod usually works for basic cases
|
|
64
|
+
# For floats, direct modulo can be problematic due to precision. math.isclose is better.
|
|
65
|
+
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):
|
|
66
|
+
raise ValidationError(f"Value {val} is not a multiple of {step_val}", [{"path": path, "message": f"Must be multiple of {step_val}"}])
|
|
67
|
+
|
|
68
|
+
return self.number_type(val)
|
|
69
|
+
|
|
70
|
+
class StringValidator(Validator[str]):
|
|
71
|
+
def __init__(self, constraints: Dict[str, Any]):
|
|
72
|
+
self.constraints = constraints
|
|
73
|
+
|
|
74
|
+
def validate(self, data: Any, path: str = "") -> str:
|
|
75
|
+
if not isinstance(data, str):
|
|
76
|
+
raise ValidationError(f"Expected string, got {type(data).__name__}", [{"path": path, "message": "Invalid type"}])
|
|
77
|
+
|
|
78
|
+
val = data
|
|
79
|
+
|
|
80
|
+
# Length
|
|
81
|
+
if 'min_length' in self.constraints or 'min_len' in self.constraints:
|
|
82
|
+
val_limit = self.constraints.get('min_length') or self.constraints.get('min_len')
|
|
83
|
+
limit = int(val_limit) if val_limit is not None else 0
|
|
84
|
+
if len(val) < limit:
|
|
85
|
+
raise ValidationError(f"String length {len(val)} is shorter than min {limit}", [{"path": path, "message": f"Length must be >= {limit}"}])
|
|
86
|
+
|
|
87
|
+
if 'max_length' in self.constraints or 'max_len' in self.constraints:
|
|
88
|
+
val_limit = self.constraints.get('max_length') or self.constraints.get('max_len')
|
|
89
|
+
if val_limit is not None:
|
|
90
|
+
limit = int(val_limit)
|
|
91
|
+
if len(val) > limit:
|
|
92
|
+
raise ValidationError(f"String length {len(val)} is longer than max {limit}", [{"path": path, "message": f"Length must be <= {limit}"}])
|
|
93
|
+
|
|
94
|
+
# Regex
|
|
95
|
+
if 'regex' in self.constraints or 'pattern' in self.constraints:
|
|
96
|
+
pattern = self.constraints.get('regex') or self.constraints.get('pattern')
|
|
97
|
+
if isinstance(pattern, str) and not re.search(pattern, val):
|
|
98
|
+
raise ValidationError(f"String does not match pattern '{pattern}'", [{"path": path, "message": "Pattern mismatch"}])
|
|
99
|
+
|
|
100
|
+
# Format (Basic implementation)
|
|
101
|
+
fmt = self.constraints.get('format')
|
|
102
|
+
if fmt == 'email':
|
|
103
|
+
if '@' not in val: # Very basic check
|
|
104
|
+
raise ValidationError("Invalid email format", [{"path": path, "message": "Invalid email"}])
|
|
105
|
+
elif fmt == 'uuid':
|
|
106
|
+
# Basic UUID pattern
|
|
107
|
+
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()):
|
|
108
|
+
raise ValidationError("Invalid UUID format", [{"path": path, "message": "Invalid UUID"}])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
return val
|
|
112
|
+
|
|
113
|
+
class CollectionValidator(Validator[list]):
|
|
114
|
+
def __init__(self, constraints: Dict[str, Any], item_validator: Union[Validator, List[Validator], None] = None):
|
|
115
|
+
self.constraints = constraints
|
|
116
|
+
self.item_validator = item_validator # Can be a single validator (list) or list of validators (tuple)
|
|
117
|
+
|
|
118
|
+
def validate(self, data: Any, path: str = "") -> list:
|
|
119
|
+
if not isinstance(data, (list, tuple)):
|
|
120
|
+
raise ValidationError(f"Expected list/tuple, got {type(data).__name__}", [{"path": path, "message": "Invalid type"}])
|
|
121
|
+
|
|
122
|
+
# Min Items
|
|
123
|
+
if 'min_items' in self.constraints:
|
|
124
|
+
limit = int(self.constraints['min_items'])
|
|
125
|
+
if len(data) < limit:
|
|
126
|
+
raise ValidationError(f"List has fewer items than {limit}", [{"path": path, "message": f"Min items: {limit}"}])
|
|
127
|
+
|
|
128
|
+
# Max Items
|
|
129
|
+
if 'max_items' in self.constraints:
|
|
130
|
+
limit = int(self.constraints['max_items'])
|
|
131
|
+
if len(data) > limit:
|
|
132
|
+
raise ValidationError(f"List has more items than {limit}", [{"path": path, "message": f"Max items: {limit}"}])
|
|
133
|
+
|
|
134
|
+
# Unique Items
|
|
135
|
+
if self.constraints.get('unique') or self.constraints.get('unique_items'):
|
|
136
|
+
# This is complex for unhashable types (dicts/lists), but simplistic set check for primitives
|
|
137
|
+
try:
|
|
138
|
+
if len(set(data)) != len(data):
|
|
139
|
+
raise ValidationError("List items must be unique", [{"path": path, "message": "Duplicate items found"}])
|
|
140
|
+
except TypeError:
|
|
141
|
+
# Fallback for unhashables (O(N^2))
|
|
142
|
+
for i in range(len(data)):
|
|
143
|
+
for j in range(i + 1, len(data)):
|
|
144
|
+
if data[i] == data[j]:
|
|
145
|
+
raise ValidationError("List items must be unique", [{"path": path, "message": "Duplicate items found"}])
|
|
146
|
+
|
|
147
|
+
validated_data = []
|
|
148
|
+
|
|
149
|
+
# Tuple validation (positional)
|
|
150
|
+
if isinstance(self.item_validator, list):
|
|
151
|
+
if len(data) != len(self.item_validator):
|
|
152
|
+
# Strict tuple length? Python tuples usually fixed.
|
|
153
|
+
raise ValidationError(f"Expected {len(self.item_validator)} items, got {len(data)}", [{"path": path, "message": f"Expected {len(self.item_validator)} items"}])
|
|
154
|
+
|
|
155
|
+
for i, (item, validator) in enumerate(zip(data, self.item_validator)):
|
|
156
|
+
validated_data.append(validator.validate(item, path=f"{path}[{i}]"))
|
|
157
|
+
|
|
158
|
+
# List validation (homogeneous)
|
|
159
|
+
elif isinstance(self.item_validator, Validator):
|
|
160
|
+
for i, item in enumerate(data):
|
|
161
|
+
validated_data.append(self.item_validator.validate(item, path=f"{path}[{i}]"))
|
|
162
|
+
else:
|
|
163
|
+
validated_data = list(data)
|
|
164
|
+
|
|
165
|
+
return validated_data
|
|
166
|
+
|
|
167
|
+
class UnionValidator(Validator[Any]):
|
|
168
|
+
def __init__(self, validators: List[Validator], mode: str = "any_of"):
|
|
169
|
+
self.validators = validators
|
|
170
|
+
self.mode = mode # 'any_of' or 'one_of'
|
|
171
|
+
|
|
172
|
+
def validate(self, data: Any, path: str = "") -> Any:
|
|
173
|
+
valid_results = []
|
|
174
|
+
errors = []
|
|
175
|
+
|
|
176
|
+
for v in self.validators:
|
|
177
|
+
try:
|
|
178
|
+
valid_results.append(v.validate(data, path))
|
|
179
|
+
except ValidationError as e:
|
|
180
|
+
errors.append(e)
|
|
181
|
+
|
|
182
|
+
if self.mode == "one_of":
|
|
183
|
+
if len(valid_results) == 1:
|
|
184
|
+
return valid_results[0]
|
|
185
|
+
elif len(valid_results) == 0:
|
|
186
|
+
raise ValidationError("Matches none of the allowed types", [{"path": path, "message": "No match for OneOf"}])
|
|
187
|
+
else:
|
|
188
|
+
raise ValidationError("Matches multiple types in OneOf", [{"path": path, "message": "Multiple matches for OneOf"}])
|
|
189
|
+
|
|
190
|
+
# Default: any_of (return first match)
|
|
191
|
+
if valid_results:
|
|
192
|
+
return valid_results[0]
|
|
193
|
+
|
|
194
|
+
raise ValidationError("Matches none of the allowed types", [{"path": path, "message": "No match for Union"}])
|
|
195
|
+
|
|
196
|
+
class ObjectValidator(Validator[Dict]):
|
|
197
|
+
def __init__(self, fields: Dict[str, Validator], constraints: Dict[str, Any], required_keys: Set[str]):
|
|
198
|
+
self.fields = fields
|
|
199
|
+
self.constraints = constraints
|
|
200
|
+
self.required_keys = required_keys # Keys that MUST be present
|
|
201
|
+
|
|
202
|
+
def validate(self, data: Any, path: str = "") -> Dict:
|
|
203
|
+
if not isinstance(data, dict):
|
|
204
|
+
raise ValidationError(f"Expected object, got {type(data).__name__}", [{"path": path, "message": "Invalid type"}])
|
|
205
|
+
|
|
206
|
+
final_data = {}
|
|
207
|
+
errors = []
|
|
208
|
+
|
|
209
|
+
# Check required keys
|
|
210
|
+
missing = self.required_keys - data.keys()
|
|
211
|
+
if missing:
|
|
212
|
+
# Check if missing keys have defaults? TypedDict doesn't support defaults easily at runtime without extra inspecting.
|
|
213
|
+
# For now, strict required check.
|
|
214
|
+
for k in missing:
|
|
215
|
+
errors.append({"path": f"{path}.{k}" if path else k, "message": "Field is required"})
|
|
216
|
+
|
|
217
|
+
# Validate Fields
|
|
218
|
+
for key, value in data.items():
|
|
219
|
+
if key in self.fields:
|
|
220
|
+
try:
|
|
221
|
+
final_data[key] = self.fields[key].validate(value, path=f"{path}.{key}" if path else key)
|
|
222
|
+
except ValidationError as e:
|
|
223
|
+
errors.extend(e.errors)
|
|
224
|
+
else:
|
|
225
|
+
# Extra keys
|
|
226
|
+
if self.constraints.get('additional_properties') is False or self.constraints.get('strict'):
|
|
227
|
+
errors.append({"path": f"{path}.{key}" if path else key, "message": "Extra field not allowed"})
|
|
228
|
+
else:
|
|
229
|
+
final_data[key] = value
|
|
230
|
+
|
|
231
|
+
# Min/Max Properties
|
|
232
|
+
if 'min_properties' in self.constraints or 'min_props' in self.constraints:
|
|
233
|
+
limit = int(self.constraints.get('min_properties') or self.constraints.get('min_props') or 0)
|
|
234
|
+
if len(data) < limit:
|
|
235
|
+
errors.append({"path": path, "message": f"Too few properties (min {limit})"})
|
|
236
|
+
|
|
237
|
+
if errors:
|
|
238
|
+
raise ValidationError("Validation failed", errors)
|
|
239
|
+
|
|
240
|
+
return final_data
|
|
241
|
+
|
|
242
|
+
class LiteralValidator(Validator[Any]):
|
|
243
|
+
def __init__(self, allowed_values: Tuple[Any, ...]):
|
|
244
|
+
self.allowed_values = allowed_values
|
|
245
|
+
|
|
246
|
+
def validate(self, data: Any, path: str = "") -> Any:
|
|
247
|
+
if data not in self.allowed_values:
|
|
248
|
+
# Format expected values for friendly error
|
|
249
|
+
allowed = ", ".join(repr(v) for v in self.allowed_values)
|
|
250
|
+
raise ValidationError(f"Value must be one of: {allowed}", [{"path": path, "message": f"Expected one of: {allowed}"}])
|
|
251
|
+
return data
|