typely 0.1.0__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.
- typely-0.1.0/PKG-INFO +11 -0
- typely-0.1.0/README.md +36 -0
- typely-0.1.0/pyproject.toml +14 -0
- typely-0.1.0/setup.cfg +4 -0
- typely-0.1.0/src/typely/__init__.py +21 -0
- typely-0.1.0/src/typely/check.py +22 -0
- typely-0.1.0/src/typely/coerce.py +8 -0
- typely-0.1.0/src/typely/errors.py +23 -0
- typely-0.1.0/src/typely/schema.py +75 -0
- typely-0.1.0/src/typely/types.py +320 -0
- typely-0.1.0/src/typely/validate.py +141 -0
- typely-0.1.0/src/typely/validators.py +45 -0
- typely-0.1.0/src/typely.egg-info/PKG-INFO +11 -0
- typely-0.1.0/src/typely.egg-info/SOURCES.txt +24 -0
- typely-0.1.0/src/typely.egg-info/dependency_links.txt +1 -0
- typely-0.1.0/src/typely.egg-info/requires.txt +5 -0
- typely-0.1.0/src/typely.egg-info/top_level.txt +1 -0
- typely-0.1.0/tests/test_adversarial.py +262 -0
- typely-0.1.0/tests/test_check.py +88 -0
- typely-0.1.0/tests/test_coerce.py +16 -0
- typely-0.1.0/tests/test_integration.py +150 -0
- typely-0.1.0/tests/test_round2.py +181 -0
- typely-0.1.0/tests/test_schema.py +122 -0
- typely-0.1.0/tests/test_types.py +192 -0
- typely-0.1.0/tests/test_validate.py +365 -0
- typely-0.1.0/tests/test_validators.py +123 -0
typely-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: typely
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Runtime type validation for Python — lightweight, fast, decorator-based
|
|
5
|
+
Author: Teja
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
10
|
+
Requires-Dist: hypothesis; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
typely-0.1.0/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# typely
|
|
2
|
+
|
|
3
|
+
Runtime type validation for Python — lightweight, fast, decorator-based.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install -e .
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from typely import validate
|
|
15
|
+
|
|
16
|
+
@validate
|
|
17
|
+
def create_user(name: str, age: int) -> dict:
|
|
18
|
+
return {"name": name, "age": age}
|
|
19
|
+
|
|
20
|
+
create_user("Alice", 30) # ✅
|
|
21
|
+
create_user("Alice", "30") # TypeError: age must be int, got str
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- **Decorator validation** — `@validate` validates args and return values at call time
|
|
27
|
+
- **Advanced types** — `Optional`, `Union`, `Literal`, generics (`list[int]`, `dict[str, float]`)
|
|
28
|
+
- **Custom validators** — `@validator(int)` to add custom checks
|
|
29
|
+
- **Coercion** — `Coerce.SAFE` for str→int, str→float, etc.
|
|
30
|
+
- **Schema validation** — `Schema({field: Field(...)})` for dict validation
|
|
31
|
+
- **Standalone checks** — `typely.check(value, type_hint)` and `typely.is_valid()`
|
|
32
|
+
- **Zero dependencies** — stdlib only
|
|
33
|
+
|
|
34
|
+
## License
|
|
35
|
+
|
|
36
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "typely"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Runtime type validation for Python — lightweight, fast, decorator-based"
|
|
5
|
+
license = {text = "MIT"}
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
authors = [{name = "Teja"}]
|
|
8
|
+
dependencies = []
|
|
9
|
+
|
|
10
|
+
[project.optional-dependencies]
|
|
11
|
+
dev = ["pytest>=7.0", "hypothesis", "pytest-cov"]
|
|
12
|
+
|
|
13
|
+
[tool.setuptools.packages.find]
|
|
14
|
+
where = ["src"]
|
typely-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""typely — Runtime type validation for Python."""
|
|
2
|
+
|
|
3
|
+
from typely.errors import ValidationError, SchemaError
|
|
4
|
+
from typely.validate import validate
|
|
5
|
+
from typely.validators import validator
|
|
6
|
+
from typely.check import check, is_valid
|
|
7
|
+
from typely.schema import Schema, Field
|
|
8
|
+
from typely.coerce import Coerce
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"validate",
|
|
12
|
+
"validator",
|
|
13
|
+
"check",
|
|
14
|
+
"is_valid",
|
|
15
|
+
"Schema",
|
|
16
|
+
"Field",
|
|
17
|
+
"ValidationError",
|
|
18
|
+
"SchemaError",
|
|
19
|
+
"Coerce",
|
|
20
|
+
]
|
|
21
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Type checking utilities."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from typely.types import _check_type
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def check(value: Any, type_hint: Any) -> bool:
|
|
8
|
+
"""Check if value matches type_hint. Returns bool."""
|
|
9
|
+
errors = []
|
|
10
|
+
try:
|
|
11
|
+
_check_type(value, type_hint, field="", errors=errors)
|
|
12
|
+
except Exception:
|
|
13
|
+
return False
|
|
14
|
+
return len(errors) == 0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_valid(value: Any, type_hint: Any) -> bool:
|
|
18
|
+
"""Check if value matches type_hint. Never raises. Returns bool."""
|
|
19
|
+
try:
|
|
20
|
+
return check(value, type_hint)
|
|
21
|
+
except Exception:
|
|
22
|
+
return False
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Validation errors."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ValidationError(TypeError):
|
|
5
|
+
"""Raised when type validation fails."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, errors):
|
|
8
|
+
self.errors = errors
|
|
9
|
+
parts = [e.get("message", str(e)) for e in errors]
|
|
10
|
+
super().__init__("\n".join(parts))
|
|
11
|
+
|
|
12
|
+
def pretty(self):
|
|
13
|
+
lines = []
|
|
14
|
+
for e in self.errors:
|
|
15
|
+
field = e.get("field", "?")
|
|
16
|
+
msg = e.get("message", "")
|
|
17
|
+
lines.append(f"{field}: {msg}")
|
|
18
|
+
return "\n".join(lines)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SchemaError(ValidationError):
|
|
22
|
+
"""Raised when schema validation fails."""
|
|
23
|
+
pass
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Schema and Field for dict validation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Optional
|
|
4
|
+
from typely.errors import SchemaError
|
|
5
|
+
from typely.types import _check_type, _type_name
|
|
6
|
+
from typely.coerce import Coerce
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Field:
|
|
10
|
+
"""A field definition for Schema validation."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, type_hint: Any, *, required: bool = False, default: Any = None,
|
|
13
|
+
validators: list = None):
|
|
14
|
+
self.type_hint = type_hint
|
|
15
|
+
self.required = required
|
|
16
|
+
self.default = default
|
|
17
|
+
self.validators = validators or []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Schema:
|
|
21
|
+
"""Dict/data validation schema."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, fields: dict, *, coerce=Coerce.STRICT):
|
|
24
|
+
self.fields = fields # name -> Field
|
|
25
|
+
self.coerce = coerce
|
|
26
|
+
|
|
27
|
+
def validate(self, data: dict) -> dict:
|
|
28
|
+
"""Validate data against schema. Returns (possibly coerced) data dict."""
|
|
29
|
+
if not isinstance(data, dict):
|
|
30
|
+
raise SchemaError([{
|
|
31
|
+
"field": "data",
|
|
32
|
+
"expected": "dict",
|
|
33
|
+
"got": type(data).__name__,
|
|
34
|
+
"value": data,
|
|
35
|
+
"message": f"expected dict, got {type(data).__name__}",
|
|
36
|
+
}])
|
|
37
|
+
|
|
38
|
+
errors = []
|
|
39
|
+
result = {}
|
|
40
|
+
|
|
41
|
+
# Check required fields and validate present fields
|
|
42
|
+
for name, field in self.fields.items():
|
|
43
|
+
if name not in data:
|
|
44
|
+
if field.required:
|
|
45
|
+
errors.append({
|
|
46
|
+
"field": name,
|
|
47
|
+
"expected": _type_name(field.type_hint),
|
|
48
|
+
"got": "missing",
|
|
49
|
+
"value": None,
|
|
50
|
+
"message": f"required field '{name}' is missing",
|
|
51
|
+
})
|
|
52
|
+
else:
|
|
53
|
+
result[name] = field.default
|
|
54
|
+
else:
|
|
55
|
+
value = data[name]
|
|
56
|
+
checked = _check_type(value, field.type_hint, name, errors, self.coerce)
|
|
57
|
+
if not errors:
|
|
58
|
+
for vfn in field.validators:
|
|
59
|
+
try:
|
|
60
|
+
checked = vfn(checked)
|
|
61
|
+
except (ValueError, TypeError) as e:
|
|
62
|
+
errors.append({
|
|
63
|
+
"field": name,
|
|
64
|
+
"expected": vfn.__name__,
|
|
65
|
+
"got": type(checked).__name__,
|
|
66
|
+
"value": checked,
|
|
67
|
+
"message": str(e),
|
|
68
|
+
})
|
|
69
|
+
break
|
|
70
|
+
result[name] = checked
|
|
71
|
+
|
|
72
|
+
if errors:
|
|
73
|
+
raise SchemaError(errors)
|
|
74
|
+
|
|
75
|
+
return result
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Type handling: Union, Optional, Literal, generics."""
|
|
2
|
+
|
|
3
|
+
import types
|
|
4
|
+
from typing import Any, Union, get_origin, get_args
|
|
5
|
+
|
|
6
|
+
# Python 3.10+ these are builtins, but we need the typing module versions too
|
|
7
|
+
try:
|
|
8
|
+
from types import UnionType
|
|
9
|
+
except ImportError:
|
|
10
|
+
UnionType = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _type_name(type_hint: Any) -> str:
|
|
14
|
+
"""Get a readable name for a type hint."""
|
|
15
|
+
if type_hint is Any:
|
|
16
|
+
return "Any"
|
|
17
|
+
if type_hint is None or type_hint is type(None):
|
|
18
|
+
return "None"
|
|
19
|
+
if type_hint is int:
|
|
20
|
+
return "int"
|
|
21
|
+
if type_hint is float:
|
|
22
|
+
return "float"
|
|
23
|
+
if type_hint is str:
|
|
24
|
+
return "str"
|
|
25
|
+
if type_hint is bool:
|
|
26
|
+
return "bool"
|
|
27
|
+
if type_hint is list:
|
|
28
|
+
return "list"
|
|
29
|
+
if type_hint is dict:
|
|
30
|
+
return "dict"
|
|
31
|
+
if type_hint is tuple:
|
|
32
|
+
return "tuple"
|
|
33
|
+
if type_hint is set:
|
|
34
|
+
return "set"
|
|
35
|
+
|
|
36
|
+
origin = get_origin(type_hint)
|
|
37
|
+
if origin is Union:
|
|
38
|
+
args = get_args(type_hint)
|
|
39
|
+
return " | ".join(_type_name(a) for a in args)
|
|
40
|
+
if origin is type(None):
|
|
41
|
+
return "None"
|
|
42
|
+
if origin is list:
|
|
43
|
+
args = get_args(type_hint)
|
|
44
|
+
if args:
|
|
45
|
+
return f"list[{', '.join(_type_name(a) for a in args)}]"
|
|
46
|
+
return "list"
|
|
47
|
+
if origin is dict:
|
|
48
|
+
args = get_args(type_hint)
|
|
49
|
+
if args:
|
|
50
|
+
return f"dict[{', '.join(_type_name(a) for a in args)}]"
|
|
51
|
+
return "dict"
|
|
52
|
+
if origin is tuple:
|
|
53
|
+
args = get_args(type_hint)
|
|
54
|
+
if args:
|
|
55
|
+
names = [_type_name(a) if a is not ... else "..." for a in args]
|
|
56
|
+
return f"tuple[{', '.join(names)}]"
|
|
57
|
+
return "tuple"
|
|
58
|
+
if origin is set:
|
|
59
|
+
args = get_args(type_hint)
|
|
60
|
+
if args:
|
|
61
|
+
return f"set[{', '.join(_type_name(a) for a in args)}]"
|
|
62
|
+
return "set"
|
|
63
|
+
try:
|
|
64
|
+
return getattr(type_hint, "__name__", str(type_hint))
|
|
65
|
+
except Exception:
|
|
66
|
+
return str(type_hint)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_optional(type_hint: Any) -> bool:
|
|
70
|
+
"""Check if a type hint allows None."""
|
|
71
|
+
origin = get_origin(type_hint)
|
|
72
|
+
if origin is Union:
|
|
73
|
+
args = get_args(type_hint)
|
|
74
|
+
return type(None) in args
|
|
75
|
+
if UnionType is not None:
|
|
76
|
+
if isinstance(type_hint, UnionType):
|
|
77
|
+
args = type_hint.__args__
|
|
78
|
+
return type(None) in args
|
|
79
|
+
return type_hint is type(None) or type_hint is None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_union(type_hint: Any) -> bool:
|
|
83
|
+
origin = get_origin(type_hint)
|
|
84
|
+
if origin is Union:
|
|
85
|
+
return True
|
|
86
|
+
if UnionType is not None:
|
|
87
|
+
return isinstance(type_hint, UnionType)
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _union_args(type_hint: Any) -> tuple:
|
|
92
|
+
if UnionType is not None and isinstance(type_hint, UnionType):
|
|
93
|
+
return type_hint.__args__
|
|
94
|
+
return get_args(type_hint)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _is_literal(type_hint: Any) -> bool:
|
|
98
|
+
origin = get_origin(type_hint)
|
|
99
|
+
return origin is not None and getattr(origin, "__name__", "") == "Literal"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _literal_values(type_hint: Any) -> tuple:
|
|
103
|
+
return get_args(type_hint)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _check_type(value: Any, type_hint: Any, field: str, errors: list, coerce: int = 0) -> Any:
|
|
107
|
+
"""
|
|
108
|
+
Check if value matches type_hint.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
value: The value to check
|
|
112
|
+
type_hint: The type hint to check against
|
|
113
|
+
field: Field name for error messages
|
|
114
|
+
errors: List to append errors to
|
|
115
|
+
coerce: Coercion mode (0=STRICT, 1=SAFE)
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
The (possibly coerced) value
|
|
119
|
+
"""
|
|
120
|
+
if type_hint is Any:
|
|
121
|
+
return value
|
|
122
|
+
|
|
123
|
+
# None check
|
|
124
|
+
if value is None:
|
|
125
|
+
if _is_optional(type_hint):
|
|
126
|
+
return None
|
|
127
|
+
errors.append({
|
|
128
|
+
"field": field,
|
|
129
|
+
"expected": _type_name(type_hint),
|
|
130
|
+
"got": type(None).__name__,
|
|
131
|
+
"value": None,
|
|
132
|
+
"message": f"expected {_type_name(type_hint)}, got None",
|
|
133
|
+
})
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
# Union
|
|
137
|
+
if _is_union(type_hint):
|
|
138
|
+
args = _union_args(type_hint)
|
|
139
|
+
for arg in args:
|
|
140
|
+
test_errors = []
|
|
141
|
+
try:
|
|
142
|
+
_check_type(value, arg, field, test_errors, coerce)
|
|
143
|
+
if not test_errors:
|
|
144
|
+
return value
|
|
145
|
+
except Exception:
|
|
146
|
+
continue
|
|
147
|
+
errors.append({
|
|
148
|
+
"field": field,
|
|
149
|
+
"expected": _type_name(type_hint),
|
|
150
|
+
"got": type(value).__name__,
|
|
151
|
+
"value": value,
|
|
152
|
+
"message": f"expected {_type_name(type_hint)}, got {type(value).__name__}",
|
|
153
|
+
})
|
|
154
|
+
return value
|
|
155
|
+
|
|
156
|
+
# Literal
|
|
157
|
+
if _is_literal(type_hint):
|
|
158
|
+
allowed = _literal_values(type_hint)
|
|
159
|
+
if value in allowed:
|
|
160
|
+
return value
|
|
161
|
+
errors.append({
|
|
162
|
+
"field": field,
|
|
163
|
+
"expected": _type_name(type_hint),
|
|
164
|
+
"got": type(value).__name__,
|
|
165
|
+
"value": value,
|
|
166
|
+
"message": f"expected {_type_name(type_hint)}, got {value!r}",
|
|
167
|
+
})
|
|
168
|
+
return value
|
|
169
|
+
|
|
170
|
+
# Primitives
|
|
171
|
+
if type_hint is int:
|
|
172
|
+
if isinstance(value, bool):
|
|
173
|
+
errors.append({"field": field, "expected": "int", "got": "bool", "value": value,
|
|
174
|
+
"message": f"expected int, got bool ({value!r})"})
|
|
175
|
+
return value
|
|
176
|
+
if isinstance(value, int):
|
|
177
|
+
return value
|
|
178
|
+
if coerce and isinstance(value, str):
|
|
179
|
+
try:
|
|
180
|
+
return int(value)
|
|
181
|
+
except (ValueError, TypeError):
|
|
182
|
+
pass
|
|
183
|
+
if coerce and isinstance(value, float):
|
|
184
|
+
return int(value)
|
|
185
|
+
errors.append({"field": field, "expected": "int", "got": type(value).__name__, "value": value,
|
|
186
|
+
"message": f"expected int, got {type(value).__name__} ({value!r})"})
|
|
187
|
+
return value
|
|
188
|
+
|
|
189
|
+
if type_hint is float:
|
|
190
|
+
if isinstance(value, bool):
|
|
191
|
+
errors.append({"field": field, "expected": "float", "got": "bool", "value": value,
|
|
192
|
+
"message": f"expected float, got bool ({value!r})"})
|
|
193
|
+
return value
|
|
194
|
+
if isinstance(value, float):
|
|
195
|
+
return value
|
|
196
|
+
if isinstance(value, int):
|
|
197
|
+
return float(value)
|
|
198
|
+
if coerce and isinstance(value, str):
|
|
199
|
+
try:
|
|
200
|
+
return float(value)
|
|
201
|
+
except (ValueError, TypeError):
|
|
202
|
+
pass
|
|
203
|
+
errors.append({"field": field, "expected": "float", "got": type(value).__name__, "value": value,
|
|
204
|
+
"message": f"expected float, got {type(value).__name__} ({value!r})"})
|
|
205
|
+
return value
|
|
206
|
+
|
|
207
|
+
if type_hint is str:
|
|
208
|
+
if isinstance(value, str):
|
|
209
|
+
return value
|
|
210
|
+
if coerce and isinstance(value, (int, float, bool)):
|
|
211
|
+
return str(value)
|
|
212
|
+
errors.append({"field": field, "expected": "str", "got": type(value).__name__, "value": value,
|
|
213
|
+
"message": f"expected str, got {type(value).__name__} ({value!r})"})
|
|
214
|
+
return value
|
|
215
|
+
|
|
216
|
+
if type_hint is bool:
|
|
217
|
+
if isinstance(value, bool):
|
|
218
|
+
return value
|
|
219
|
+
if coerce and isinstance(value, str):
|
|
220
|
+
if value.lower() in ("true", "1", "yes"):
|
|
221
|
+
return True
|
|
222
|
+
if value.lower() in ("false", "0", "no"):
|
|
223
|
+
return False
|
|
224
|
+
errors.append({"field": field, "expected": "bool", "got": type(value).__name__, "value": value,
|
|
225
|
+
"message": f"expected bool, got {type(value).__name__} ({value!r})"})
|
|
226
|
+
return value
|
|
227
|
+
|
|
228
|
+
# Collections
|
|
229
|
+
origin = get_origin(type_hint)
|
|
230
|
+
|
|
231
|
+
if type_hint is list or origin is list:
|
|
232
|
+
if not isinstance(value, list):
|
|
233
|
+
errors.append({"field": field, "expected": _type_name(type_hint), "got": type(value).__name__,
|
|
234
|
+
"value": value, "message": f"expected {_type_name(type_hint)}, got {type(value).__name__}"})
|
|
235
|
+
return value
|
|
236
|
+
args = get_args(type_hint)
|
|
237
|
+
if args:
|
|
238
|
+
item_hint = args[0]
|
|
239
|
+
result = []
|
|
240
|
+
for i, item in enumerate(value):
|
|
241
|
+
checked = _check_type(item, item_hint, f"{field}[{i}]", errors, coerce)
|
|
242
|
+
result.append(checked)
|
|
243
|
+
return result
|
|
244
|
+
return value
|
|
245
|
+
|
|
246
|
+
if type_hint is dict or origin is dict:
|
|
247
|
+
if not isinstance(value, dict):
|
|
248
|
+
errors.append({"field": field, "expected": _type_name(type_hint), "got": type(value).__name__,
|
|
249
|
+
"value": value, "message": f"expected {_type_name(type_hint)}, got {type(value).__name__}"})
|
|
250
|
+
return value
|
|
251
|
+
args = get_args(type_hint)
|
|
252
|
+
if args and len(args) == 2:
|
|
253
|
+
key_hint, val_hint = args
|
|
254
|
+
result = {}
|
|
255
|
+
for k, v in value.items():
|
|
256
|
+
_check_type(k, key_hint, f"{field}.key({k!r})", errors, coerce)
|
|
257
|
+
checked_v = _check_type(v, val_hint, f"{field}[{k!r}]", errors, coerce)
|
|
258
|
+
result[k] = checked_v
|
|
259
|
+
return result
|
|
260
|
+
return value
|
|
261
|
+
|
|
262
|
+
if type_hint is tuple or origin is tuple:
|
|
263
|
+
if not isinstance(value, tuple):
|
|
264
|
+
errors.append({"field": field, "expected": _type_name(type_hint), "got": type(value).__name__,
|
|
265
|
+
"value": value, "message": f"expected {_type_name(type_hint)}, got {type(value).__name__}"})
|
|
266
|
+
return value
|
|
267
|
+
args = get_args(type_hint)
|
|
268
|
+
if args:
|
|
269
|
+
if len(args) == 2 and args[1] is ...:
|
|
270
|
+
# tuple[T, ...]
|
|
271
|
+
item_hint = args[0]
|
|
272
|
+
result = []
|
|
273
|
+
for i, item in enumerate(value):
|
|
274
|
+
checked = _check_type(item, item_hint, f"{field}[{i}]", errors, coerce)
|
|
275
|
+
result.append(checked)
|
|
276
|
+
return tuple(result)
|
|
277
|
+
else:
|
|
278
|
+
# Fixed-length tuple
|
|
279
|
+
if len(value) != len(args):
|
|
280
|
+
errors.append({"field": field, "expected": f"tuple of length {len(args)}",
|
|
281
|
+
"got": f"tuple of length {len(value)}", "value": value,
|
|
282
|
+
"message": f"expected tuple of length {len(args)}, got {len(value)}"})
|
|
283
|
+
return value
|
|
284
|
+
result = []
|
|
285
|
+
for i, (item, hint) in enumerate(zip(value, args)):
|
|
286
|
+
checked = _check_type(item, hint, f"{field}[{i}]", errors, coerce)
|
|
287
|
+
result.append(checked)
|
|
288
|
+
return tuple(result)
|
|
289
|
+
return value
|
|
290
|
+
|
|
291
|
+
if type_hint is set or origin is set:
|
|
292
|
+
if not isinstance(value, set):
|
|
293
|
+
errors.append({"field": field, "expected": _type_name(type_hint), "got": type(value).__name__,
|
|
294
|
+
"value": value, "message": f"expected {_type_name(type_hint)}, got {type(value).__name__}"})
|
|
295
|
+
return value
|
|
296
|
+
args = get_args(type_hint)
|
|
297
|
+
if args:
|
|
298
|
+
item_hint = args[0]
|
|
299
|
+
result = set()
|
|
300
|
+
for item in value:
|
|
301
|
+
checked = _check_type(item, item_hint, f"{field}.item", errors, coerce)
|
|
302
|
+
result.add(checked)
|
|
303
|
+
return result
|
|
304
|
+
return value
|
|
305
|
+
|
|
306
|
+
# Fallback: isinstance check
|
|
307
|
+
if isinstance(type_hint, type):
|
|
308
|
+
if isinstance(value, type_hint):
|
|
309
|
+
return value
|
|
310
|
+
if isinstance(value, bool) and type_hint is not bool:
|
|
311
|
+
errors.append({"field": field, "expected": type_hint.__name__, "got": "bool", "value": value,
|
|
312
|
+
"message": f"expected {type_hint.__name__}, got bool ({value!r})"})
|
|
313
|
+
return value
|
|
314
|
+
errors.append({"field": field, "expected": type_hint.__name__, "got": type(value).__name__,
|
|
315
|
+
"value": value, "message": f"expected {type_hint.__name__}, got {type(value).__name__}"})
|
|
316
|
+
return value
|
|
317
|
+
|
|
318
|
+
errors.append({"field": field, "expected": _type_name(type_hint), "got": type(value).__name__,
|
|
319
|
+
"value": value, "message": f"expected {_type_name(type_hint)}, got {type(value).__name__}"})
|
|
320
|
+
return value
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""@validate decorator."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import inspect
|
|
5
|
+
from typing import get_type_hints, Any
|
|
6
|
+
from typely.errors import ValidationError
|
|
7
|
+
from typely.types import _check_type, _is_optional, get_origin, get_args
|
|
8
|
+
from typely.validators import run_validators, get_validators
|
|
9
|
+
from typely.coerce import Coerce
|
|
10
|
+
|
|
11
|
+
_ANNOTATED_MARKER = "__typely_validators__"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _extract_hint_and_validators(annotation, localns=None):
|
|
15
|
+
"""Extract (base_type_hint, [validators]) from an annotation.
|
|
16
|
+
|
|
17
|
+
Supports typing.Annotated[X, validator1, validator2] (Python 3.9+)
|
|
18
|
+
and plain type hints.
|
|
19
|
+
"""
|
|
20
|
+
origin = get_origin(annotation)
|
|
21
|
+
if origin is not None and hasattr(origin, "__name__") and origin.__name__ == "Annotated":
|
|
22
|
+
args = get_args(annotation)
|
|
23
|
+
base_type = args[0]
|
|
24
|
+
validator_fns = [a for a in args[1:] if callable(a)]
|
|
25
|
+
return base_type, validator_fns
|
|
26
|
+
return annotation, []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _validate_return(result, annotations, coerce):
|
|
30
|
+
"""Validate return value against annotations. Returns validated result."""
|
|
31
|
+
ret_hint = annotations.get("return")
|
|
32
|
+
if ret_hint is None:
|
|
33
|
+
return result
|
|
34
|
+
base_hint, validators = _extract_hint_and_validators(ret_hint)
|
|
35
|
+
ret_errors = []
|
|
36
|
+
coerced_result = _check_type(result, base_hint, "return", ret_errors, coerce)
|
|
37
|
+
if ret_errors:
|
|
38
|
+
raise ValidationError(ret_errors)
|
|
39
|
+
for vfn in validators:
|
|
40
|
+
coerced_result = vfn(coerced_result)
|
|
41
|
+
if not ret_errors:
|
|
42
|
+
coerced_result = run_validators(coerced_result, base_hint, "return", ret_errors)
|
|
43
|
+
if ret_errors:
|
|
44
|
+
raise ValidationError(ret_errors)
|
|
45
|
+
return coerced_result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def validate(fn=None, *, coerce=Coerce.STRICT):
|
|
49
|
+
"""Decorator for runtime type validation of function arguments and return value."""
|
|
50
|
+
|
|
51
|
+
def decorator(func):
|
|
52
|
+
is_async = inspect.iscoroutinefunction(func)
|
|
53
|
+
|
|
54
|
+
def _validate_args(args, kwargs, annotations):
|
|
55
|
+
sig = inspect.signature(func)
|
|
56
|
+
params = list(sig.parameters.keys())
|
|
57
|
+
errors = []
|
|
58
|
+
new_args = list(args)
|
|
59
|
+
|
|
60
|
+
# Validate positional args
|
|
61
|
+
for i, arg_val in enumerate(args):
|
|
62
|
+
if i < len(params):
|
|
63
|
+
param_name = params[i]
|
|
64
|
+
if param_name in annotations:
|
|
65
|
+
hint = annotations[param_name]
|
|
66
|
+
base_hint, validators = _extract_hint_and_validators(hint)
|
|
67
|
+
coerced = _check_type(arg_val, base_hint, param_name, errors, coerce)
|
|
68
|
+
if not errors:
|
|
69
|
+
for vfn in validators:
|
|
70
|
+
try:
|
|
71
|
+
coerced = vfn(coerced)
|
|
72
|
+
except (ValueError, TypeError) as e:
|
|
73
|
+
errors.append({
|
|
74
|
+
"field": param_name,
|
|
75
|
+
"expected": vfn.__name__,
|
|
76
|
+
"got": type(coerced).__name__,
|
|
77
|
+
"value": coerced,
|
|
78
|
+
"message": str(e),
|
|
79
|
+
})
|
|
80
|
+
break
|
|
81
|
+
if not errors:
|
|
82
|
+
coerced = run_validators(coerced, base_hint, param_name, errors)
|
|
83
|
+
new_args[i] = coerced
|
|
84
|
+
|
|
85
|
+
# Validate keyword args
|
|
86
|
+
for key, arg_val in kwargs.items():
|
|
87
|
+
if key in annotations:
|
|
88
|
+
hint = annotations[key]
|
|
89
|
+
base_hint, validators = _extract_hint_and_validators(hint)
|
|
90
|
+
coerced = _check_type(arg_val, base_hint, key, errors, coerce)
|
|
91
|
+
if not errors:
|
|
92
|
+
for vfn in validators:
|
|
93
|
+
try:
|
|
94
|
+
coerced = vfn(coerced)
|
|
95
|
+
except (ValueError, TypeError) as e:
|
|
96
|
+
errors.append({
|
|
97
|
+
"field": key,
|
|
98
|
+
"expected": vfn.__name__,
|
|
99
|
+
"got": type(coerced).__name__,
|
|
100
|
+
"value": coerced,
|
|
101
|
+
"message": str(e),
|
|
102
|
+
})
|
|
103
|
+
break
|
|
104
|
+
if not errors:
|
|
105
|
+
coerced = run_validators(coerced, base_hint, key, errors)
|
|
106
|
+
kwargs[key] = coerced
|
|
107
|
+
|
|
108
|
+
if errors:
|
|
109
|
+
raise ValidationError(errors)
|
|
110
|
+
return new_args, kwargs
|
|
111
|
+
|
|
112
|
+
if is_async:
|
|
113
|
+
@functools.wraps(func)
|
|
114
|
+
async def wrapper(*args, **kwargs):
|
|
115
|
+
annotations = {}
|
|
116
|
+
try:
|
|
117
|
+
annotations = get_type_hints(func, include_extras=True)
|
|
118
|
+
except Exception:
|
|
119
|
+
annotations = getattr(func, "__annotations__", {})
|
|
120
|
+
|
|
121
|
+
new_args, kwargs = _validate_args(args, kwargs, annotations)
|
|
122
|
+
result = await func(*new_args, **kwargs)
|
|
123
|
+
return _validate_return(result, annotations, coerce)
|
|
124
|
+
else:
|
|
125
|
+
@functools.wraps(func)
|
|
126
|
+
def wrapper(*args, **kwargs):
|
|
127
|
+
annotations = {}
|
|
128
|
+
try:
|
|
129
|
+
annotations = get_type_hints(func, include_extras=True)
|
|
130
|
+
except Exception:
|
|
131
|
+
annotations = getattr(func, "__annotations__", {})
|
|
132
|
+
|
|
133
|
+
new_args, kwargs = _validate_args(args, kwargs, annotations)
|
|
134
|
+
result = func(*new_args, **kwargs)
|
|
135
|
+
return _validate_return(result, annotations, coerce)
|
|
136
|
+
|
|
137
|
+
return wrapper
|
|
138
|
+
|
|
139
|
+
if fn is not None:
|
|
140
|
+
return decorator(fn)
|
|
141
|
+
return decorator
|