pytastic 0.0.3__tar.gz → 0.0.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytastic
3
- Version: 0.0.3
3
+ Version: 0.0.4
4
4
  Summary: A dependency-free JSON validation library using TypedDict and Annotated
5
5
  License-File: LICENSE
6
6
  Author: Tersoo
@@ -45,6 +45,7 @@ class User(TypedDict):
45
45
  username: Annotated[str, "min_len=3; regex=^[a-z_]+$"]
46
46
  age: Annotated[int, "min=18"]
47
47
  role: Literal["admin", "user"]
48
+ ```
48
49
 
49
50
  # 2. Usage Patterns
50
51
 
@@ -28,6 +28,7 @@ class User(TypedDict):
28
28
  username: Annotated[str, "min_len=3; regex=^[a-z_]+$"]
29
29
  age: Annotated[int, "min=18"]
30
30
  role: Literal["admin", "user"]
31
+ ```
31
32
 
32
33
  # 2. Usage Patterns
33
34
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pytastic"
3
- version = "0.0.3"
3
+ version = "0.0.4"
4
4
  description = "A dependency-free JSON validation library using TypedDict and Annotated"
5
5
  authors = ["Tersoo <tersoo@example.com>"]
6
6
  readme = "README.md"
@@ -1,8 +1,12 @@
1
1
  from abc import ABC, abstractmethod
2
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
3
+ from pytastic.validators import (
4
+ Validator, NumberValidator, StringValidator, CollectionValidator,
5
+ UnionValidator, ObjectValidator, LiteralValidator, AnyValidator
6
+ )
4
7
  from pytastic.exceptions import SchemaDefinitionError
5
8
  from pytastic.utils import parse_constraints, normalize_key
9
+ from typing import get_type_hints, NotRequired, Required
6
10
 
7
11
  class SchemaCompiler:
8
12
  """Compiles Python types (TypedDict, Annotated, etc.) into Validators."""
@@ -14,9 +18,7 @@ class SchemaCompiler:
14
18
  """
15
19
  Compiles a schema type into a Validator instance.
16
20
  """
17
- if schema in self._cache:
18
- return self._cache[schema]
19
-
21
+ if schema in self._cache: return self._cache[schema]
20
22
  validator = self._build_validator(schema)
21
23
  self._cache[schema] = validator
22
24
  return validator
@@ -25,21 +27,14 @@ class SchemaCompiler:
25
27
  origin = get_origin(schema)
26
28
  args = get_args(schema)
27
29
 
28
- # 1. Annotated[T, "constraints"]
29
30
  if origin is Annotated:
30
31
  base_type = args[0]
31
32
  constraint_str = ""
32
- # Combine all string annotations
33
33
  for arg in args[1:]:
34
34
  if isinstance(arg, str):
35
35
  constraint_str += arg + ";"
36
36
 
37
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
38
  base_origin = get_origin(base_type)
44
39
 
45
40
  if base_type is int or base_type is float:
@@ -49,7 +44,6 @@ class SchemaCompiler:
49
44
  return StringValidator(constraints)
50
45
 
51
46
  if base_origin is list or base_origin is List:
52
- # Annotated[list[int], "min_items=3"]
53
47
  inner_type = get_args(base_type)[0] if get_args(base_type) else Any
54
48
  item_validator = self.compile(inner_type)
55
49
  return CollectionValidator(constraints, item_validator=item_validator)
@@ -61,109 +55,52 @@ class SchemaCompiler:
61
55
  return CollectionValidator(constraints, item_validator=item_validators)
62
56
 
63
57
  if base_origin is Union:
64
- # Annotated[Union[A, B], "one_of"]
65
58
  union_mode = "one_of" if constraints.get("one_of") else "any_of"
66
59
  validators = [self.compile(t) for t in get_args(base_type)]
67
60
  return UnionValidator(validators, mode=union_mode)
68
61
 
69
- # Annotated[TypedDict, "..."]
70
62
  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
63
  return self._compile_typeddict(base_type, constraints)
75
64
 
76
- # Fallback: Just return compiled base type (ignoring constraints if unknown?)
77
- # Or raise error?
78
65
  return self.compile(base_type)
79
66
 
80
- # 2. TypedDict
81
- if self._is_typeddict(schema):
82
- return self._compile_typeddict(schema, {})
83
-
84
- # 3. List[T]
67
+ if self._is_typeddict(schema): return self._compile_typeddict(schema, {})
85
68
  if origin is list or origin is List:
86
69
  inner_type = args[0] if args else Any
87
70
  return CollectionValidator({}, item_validator=self.compile(inner_type))
88
71
 
89
- # 4. Tuple[T, ...]
90
72
  if origin is tuple or origin is Tuple:
91
73
  return CollectionValidator({}, item_validator=[self.compile(t) for t in args])
92
-
93
- # 5. Union[A, B]
94
74
  if origin is Union:
95
75
  return UnionValidator([self.compile(t) for t in args], mode="any_of")
96
-
97
- # 6. Literal[A, B]
98
76
  if origin is Literal:
99
77
  return LiteralValidator(args)
100
-
101
- # 7. Basic Types (No Annotations)
102
78
  if schema is int or schema is float:
103
79
  return NumberValidator({}, number_type=schema)
104
-
105
80
  if schema is str:
106
81
  return StringValidator({})
107
-
108
82
  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
83
  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
84
  raise SchemaDefinitionError(f"Unsupported type: {schema}")
120
85
 
121
86
  def _is_typeddict(self, t: Type) -> bool:
122
87
  return isinstance(t, _TypedDictMeta) or (isinstance(t, type) and issubclass(t, dict) and hasattr(t, '__annotations__'))
123
88
 
124
89
  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
-
90
+ # Python 3.9+ get_type_hints(include_extras=True) includes Annotated
130
91
  type_hints = get_type_hints(td_cls, include_extras=True)
131
92
  fields = {}
132
93
  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
94
  is_total = getattr(td_cls, '__total__', True)
139
95
 
140
96
  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
97
  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
-
98
+ if hasattr(td_cls, '__required_keys__'): is_required = key in td_cls.__required_keys__
99
+ if is_required: required_keys.add(key)
157
100
  fields[key] = self.compile(value)
158
101
 
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
-
102
+ # Handle metadata from `_: Annotated[...]` pattern
163
103
  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
104
  meta_annotation = td_cls.__annotations__.get('_', None)
168
105
  if meta_annotation and get_origin(meta_annotation) is Annotated:
169
106
  args = get_args(meta_annotation)
@@ -177,5 +114,4 @@ class SchemaCompiler:
177
114
  # Remove _ from fields and required keys checks
178
115
  fields.pop('_', None)
179
116
  required_keys.discard('_')
180
-
181
117
  return ObjectValidator(fields, constraints, required_keys)
@@ -12,7 +12,7 @@ class Pytastic:
12
12
  """
13
13
  def __init__(self):
14
14
  self.compiler = SchemaCompiler()
15
- self._registry: Dict[str, Any] = {} # Maps name -> Validator
15
+ self._registry: Dict[str, Any] = {}
16
16
 
17
17
  def register(self, schema: Type[T]) -> None:
18
18
  """
@@ -27,7 +27,6 @@ class Pytastic:
27
27
  Validates data against a schema.
28
28
  If the schema is not registered, it is compiled on the fly.
29
29
  """
30
- # Auto-compile if passed a class directly
31
30
  validator = self.compiler.compile(schema)
32
31
  return validator.validate(data)
33
32
 
@@ -37,7 +36,6 @@ class Pytastic:
37
36
  """
38
37
  if name in self._registry:
39
38
  validator = self._registry[name]
40
- # Return a callable that runs validation
41
39
  def validate_wrapper(data: Any) -> Any:
42
40
  return validator.validate(data)
43
41
  return validate_wrapper
@@ -41,7 +41,7 @@ class JsonSchemaGenerator:
41
41
  return {} # Fallback
42
42
 
43
43
  def _visit_number(self, v: NumberValidator) -> Dict[str, Any]:
44
- schema = {"type": "integer" if v.number_type is int else "number"}
44
+ schema: Dict[str, Any] = {"type": "integer" if v.number_type is int else "number"}
45
45
  c = v.constraints
46
46
 
47
47
  if 'min' in c: schema['minimum'] = float(c['min'])
@@ -55,7 +55,7 @@ class JsonSchemaGenerator:
55
55
  return schema
56
56
 
57
57
  def _visit_string(self, v: StringValidator) -> Dict[str, Any]:
58
- schema = {"type": "string"}
58
+ schema: Dict[str, Any] = {"type": "string"}
59
59
  c = v.constraints
60
60
 
61
61
  min_l = c.get('min_length') or c.get('min_len')
@@ -73,7 +73,7 @@ class JsonSchemaGenerator:
73
73
  return schema
74
74
 
75
75
  def _visit_collection(self, v: CollectionValidator) -> Dict[str, Any]:
76
- schema = {"type": "array"}
76
+ schema: Dict[str, Any] = {"type": "array"}
77
77
  c = v.constraints
78
78
 
79
79
  if 'min_items' in c: schema['minItems'] = int(c['min_items'])
@@ -81,13 +81,7 @@ class JsonSchemaGenerator:
81
81
  if c.get('unique') or c.get('unique_items'): schema['uniqueItems'] = True
82
82
 
83
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'
84
+ # Tuple validation using 'prefixItems' (JSON Schema 2020-12+)
91
85
  schema['prefixItems'] = [self._visit(iv) for iv in v.item_validator]
92
86
  schema['minItems'] = len(v.item_validator)
93
87
  schema['maxItems'] = len(v.item_validator)
@@ -98,7 +92,7 @@ class JsonSchemaGenerator:
98
92
  return schema
99
93
 
100
94
  def _visit_object(self, v: ObjectValidator) -> Dict[str, Any]:
101
- schema = {"type": "object", "properties": {}, "required": [], "additionalProperties": True}
95
+ schema: Dict[str, Any] = {"type": "object", "properties": {}, "required": [], "additionalProperties": True}
102
96
 
103
97
  for name, field_validator in v.fields.items():
104
98
  schema["properties"][name] = self._visit(field_validator)
@@ -5,36 +5,21 @@ def parse_constraints(annotation_str: str) -> Dict[str, Any]:
5
5
  Parses a constraint string into a dictionary.
6
6
  Example: "min=10; max=20; unique" -> {'min': '10', 'max': '20', 'unique': True}
7
7
  """
8
- if not annotation_str:
9
- return {}
10
-
8
+ if not annotation_str: return {}
11
9
  constraints = {}
12
- # Split by semicolon
13
10
  parts = annotation_str.split(';')
14
11
 
15
12
  for part in parts:
16
13
  part = part.strip()
17
- if not part:
18
- continue
19
-
14
+ if not part: continue
20
15
  if '=' in part:
21
16
  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.
17
+ key, value = key.strip(), value.strip()
32
18
 
19
+ if value.lower() == 'true': value = True
20
+ elif value.lower() == 'false': value = False
33
21
  constraints[key] = value
34
- else:
35
- # Boolean flag (e.g., "unique")
36
- constraints[part] = True
37
-
22
+ else: constraints[part] = True
38
23
  return constraints
39
24
 
40
25
  def normalize_key(key: str) -> str:
@@ -11,13 +11,12 @@ class Validator(ABC, Generic[T]):
11
11
 
12
12
  @abstractmethod
13
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
14
  pass
20
15
 
16
+ class AnyValidator(Validator[Any]):
17
+ def validate(self, data: Any, path: str = "") -> Any:
18
+ return data
19
+
21
20
  class NumberValidator(Validator[Union[int, float]]):
22
21
  def __init__(self, constraints: Dict[str, Any], number_type: type = int):
23
22
  self.constraints = constraints
@@ -27,8 +26,6 @@ class NumberValidator(Validator[Union[int, float]]):
27
26
  if not isinstance(data, (int, float)):
28
27
  raise ValidationError(f"Expected number, got {type(data).__name__}", [{"path": path, "message": "Invalid type"}])
29
28
 
30
- # Type check (int vs float strictly?)
31
- # If strict int is needed:
32
29
  if self.number_type is int and isinstance(data, float) and not data.is_integer():
33
30
  raise ValidationError(f"Expected integer, got float", [{"path": path, "message": "Expected integer"}])
34
31
 
@@ -56,12 +53,9 @@ class NumberValidator(Validator[Union[int, float]]):
56
53
  if val >= limit:
57
54
  raise ValidationError(f"Value {val} must be less than {limit}", [{"path": path, "message": f"Must be < {limit}"}])
58
55
 
59
- # Step / Multiple Of
60
56
  step = self.constraints.get('step') or self.constraints.get('multiple_of')
61
57
  if step:
62
58
  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
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):
66
60
  raise ValidationError(f"Value {val} is not a multiple of {step_val}", [{"path": path, "message": f"Must be multiple of {step_val}"}])
67
61
 
@@ -133,12 +127,10 @@ class CollectionValidator(Validator[list]):
133
127
 
134
128
  # Unique Items
135
129
  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
130
  try:
138
131
  if len(set(data)) != len(data):
139
132
  raise ValidationError("List items must be unique", [{"path": path, "message": "Duplicate items found"}])
140
133
  except TypeError:
141
- # Fallback for unhashables (O(N^2))
142
134
  for i in range(len(data)):
143
135
  for j in range(i + 1, len(data)):
144
136
  if data[i] == data[j]:
@@ -209,8 +201,6 @@ class ObjectValidator(Validator[Dict]):
209
201
  # Check required keys
210
202
  missing = self.required_keys - data.keys()
211
203
  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
204
  for k in missing:
215
205
  errors.append({"path": f"{path}.{k}" if path else k, "message": "Field is required"})
216
206
 
File without changes
File without changes