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 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,8 @@
1
+ """Safe type coercion."""
2
+
3
+ from enum import IntEnum
4
+
5
+
6
+ class Coerce(IntEnum):
7
+ STRICT = 0
8
+ SAFE = 1
@@ -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