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.
@@ -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
+
@@ -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,4 @@
1
+ from pytastic.core import Pytastic
2
+ from pytastic.exceptions import PytasticError, ValidationError
3
+
4
+ __all__ = ["Pytastic", "PytasticError", "ValidationError"]
@@ -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