guardly 0.2.0__py3-none-any.whl
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.
- guardly/__init__.py +21 -0
- guardly/errors.py +28 -0
- guardly/types.py +266 -0
- guardly/validator.py +16 -0
- guardly-0.2.0.dist-info/METADATA +392 -0
- guardly-0.2.0.dist-info/RECORD +7 -0
- guardly-0.2.0.dist-info/WHEEL +4 -0
guardly/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Guardly - Schema-first validation for dicts and configs."""
|
|
2
|
+
|
|
3
|
+
from guardly.errors import ValidationError
|
|
4
|
+
from guardly.types import Bool, Dict, Email, Float, Int, List, OneOf, Str, Optional
|
|
5
|
+
from guardly.validator import check, validate
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ValidationError",
|
|
9
|
+
"check",
|
|
10
|
+
"validate",
|
|
11
|
+
"Int",
|
|
12
|
+
"Str",
|
|
13
|
+
"Float",
|
|
14
|
+
"Bool",
|
|
15
|
+
"Email",
|
|
16
|
+
"OneOf",
|
|
17
|
+
"List",
|
|
18
|
+
"Dict",
|
|
19
|
+
"Optional",
|
|
20
|
+
]
|
|
21
|
+
__version__ = "0.1.0"
|
guardly/errors.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Validation errors."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ValidationError(Exception):
|
|
5
|
+
"""Raised by check() when validation fails. Collects all errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, errors):
|
|
8
|
+
self.errors = errors
|
|
9
|
+
msgs = "; ".join(f"[{'.'.join(e.path) if e.path else '_root_'}] {e.message}" for e in errors)
|
|
10
|
+
super().__init__(msgs)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ValidationIssue:
|
|
14
|
+
"""A single validation error."""
|
|
15
|
+
|
|
16
|
+
__slots__ = ("path", "message")
|
|
17
|
+
|
|
18
|
+
def __init__(self, path, message):
|
|
19
|
+
self.path = tuple(path)
|
|
20
|
+
self.message = message
|
|
21
|
+
|
|
22
|
+
def __repr__(self):
|
|
23
|
+
p = ".".join(self.path) if self.path else "_root_"
|
|
24
|
+
return f"ValidationIssue(path={self.path!r}, message={self.message!r})"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _issue(path, message):
|
|
28
|
+
return ValidationIssue(path, message)
|
guardly/types.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Type validators for Guardly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from guardly.errors import _issue
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _Optional:
|
|
12
|
+
"""Wraps a type to mark it as optional with optional default."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, type_, default=None):
|
|
15
|
+
self.type = type_
|
|
16
|
+
self.default = default
|
|
17
|
+
|
|
18
|
+
def __repr__(self):
|
|
19
|
+
return f"Optional({self.type!r}, default={self.default!r})"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def Optional(type_, default=None):
|
|
23
|
+
"""Mark a field as optional. Use as a schema value."""
|
|
24
|
+
return _Optional(type_, default=default)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _TypeValidator:
|
|
28
|
+
"""Base for type validators."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, type_: type, name: str, **constraints):
|
|
31
|
+
self.type = type_
|
|
32
|
+
self.name = name
|
|
33
|
+
self.constraints = constraints
|
|
34
|
+
|
|
35
|
+
def validate(self, value: Any, path: list) -> list:
|
|
36
|
+
coerced = self._coerce(value)
|
|
37
|
+
if coerced is None or not isinstance(coerced, self.type):
|
|
38
|
+
return [_issue(path, f"expected {self.name}, got {type(value).__name__}")]
|
|
39
|
+
return self._check_constraints(coerced, path)
|
|
40
|
+
|
|
41
|
+
def _coerce(self, value: Any) -> Any:
|
|
42
|
+
return value if isinstance(value, self.type) else None
|
|
43
|
+
|
|
44
|
+
def _check_constraints(self, value: Any, path: list) -> list:
|
|
45
|
+
errors = []
|
|
46
|
+
c = self.constraints
|
|
47
|
+
if c.get("min") is not None and value < c["min"]:
|
|
48
|
+
errors.append(_issue(path, f"value {value} is less than minimum {c['min']}"))
|
|
49
|
+
if c.get("max") is not None and value > c["max"]:
|
|
50
|
+
errors.append(_issue(path, f"value {value} is greater than maximum {c['max']}"))
|
|
51
|
+
if c.get("pattern") is not None and not re.fullmatch(c["pattern"], str(value)):
|
|
52
|
+
errors.append(_issue(path, f"value {value!r} does not match pattern {c['pattern']!r}"))
|
|
53
|
+
if c.get("min_len") is not None and len(value) < c["min_len"]:
|
|
54
|
+
errors.append(_issue(path, f"string length {len(value)} is less than minimum {c['min_len']}"))
|
|
55
|
+
if c.get("max_len") is not None and len(value) > c["max_len"]:
|
|
56
|
+
errors.append(_issue(path, f"string length {len(value)} is greater than maximum {c['max_len']}"))
|
|
57
|
+
return errors
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Int(_TypeValidator):
|
|
61
|
+
def __init__(self, min: int | None = None, max: int | None = None):
|
|
62
|
+
super().__init__(int, "int", min=min, max=max)
|
|
63
|
+
|
|
64
|
+
def _coerce(self, value):
|
|
65
|
+
if isinstance(value, bool):
|
|
66
|
+
return None
|
|
67
|
+
if isinstance(value, int):
|
|
68
|
+
return value
|
|
69
|
+
if isinstance(value, (float, str)):
|
|
70
|
+
try:
|
|
71
|
+
v = int(value)
|
|
72
|
+
if isinstance(value, float) and v != float(value):
|
|
73
|
+
return None
|
|
74
|
+
if isinstance(value, str) and "." in value:
|
|
75
|
+
return None
|
|
76
|
+
return v
|
|
77
|
+
except (ValueError, TypeError):
|
|
78
|
+
return None
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Str(_TypeValidator):
|
|
83
|
+
def __init__(self, pattern: str | None = None, min_len: int | None = None, max_len: int | None = None):
|
|
84
|
+
constraints = {}
|
|
85
|
+
if pattern is not None:
|
|
86
|
+
constraints["pattern"] = pattern
|
|
87
|
+
if min_len is not None:
|
|
88
|
+
constraints["min_len"] = min_len
|
|
89
|
+
if max_len is not None:
|
|
90
|
+
constraints["max_len"] = max_len
|
|
91
|
+
super().__init__(str, "str", **constraints)
|
|
92
|
+
|
|
93
|
+
def _coerce(self, value):
|
|
94
|
+
if isinstance(value, str):
|
|
95
|
+
return value
|
|
96
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
97
|
+
return str(value)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Float(_TypeValidator):
|
|
102
|
+
def __init__(self, min: float | None = None, max: float | None = None):
|
|
103
|
+
super().__init__(float, "float", min=min, max=max)
|
|
104
|
+
|
|
105
|
+
def _coerce(self, value):
|
|
106
|
+
if isinstance(value, bool):
|
|
107
|
+
return None
|
|
108
|
+
if isinstance(value, (int, float)):
|
|
109
|
+
return float(value)
|
|
110
|
+
if isinstance(value, str):
|
|
111
|
+
try:
|
|
112
|
+
return float(value)
|
|
113
|
+
except (ValueError, TypeError):
|
|
114
|
+
return None
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Bool(_TypeValidator):
|
|
119
|
+
def __init__(self):
|
|
120
|
+
super().__init__(bool, "bool")
|
|
121
|
+
|
|
122
|
+
def _coerce(self, value):
|
|
123
|
+
if isinstance(value, bool):
|
|
124
|
+
return value
|
|
125
|
+
if isinstance(value, str):
|
|
126
|
+
if value.lower() in ("true", "1", "yes"):
|
|
127
|
+
return True
|
|
128
|
+
if value.lower() in ("false", "0", "no"):
|
|
129
|
+
return False
|
|
130
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
131
|
+
if value == 1:
|
|
132
|
+
return True
|
|
133
|
+
if value == 0:
|
|
134
|
+
return False
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class Email(_TypeValidator):
|
|
139
|
+
_re = re.compile(r"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$")
|
|
140
|
+
|
|
141
|
+
def __init__(self):
|
|
142
|
+
super().__init__(str, "email")
|
|
143
|
+
|
|
144
|
+
def validate(self, value, path):
|
|
145
|
+
errors = Str().validate(value, path)
|
|
146
|
+
if errors:
|
|
147
|
+
return errors
|
|
148
|
+
if not self._re.fullmatch(str(value)):
|
|
149
|
+
errors.append(_issue(path, f"{value!r} is not a valid email address"))
|
|
150
|
+
return errors
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class OneOf:
|
|
154
|
+
def __init__(self, choices):
|
|
155
|
+
self.choices = choices
|
|
156
|
+
|
|
157
|
+
def validate(self, value, path):
|
|
158
|
+
if value in self.choices:
|
|
159
|
+
return []
|
|
160
|
+
return [_issue(path, f"value {value!r} is not one of {self.choices!r}")]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class List:
|
|
164
|
+
def __init__(self, element_type, min_len: int | None = None, max_len: int | None = None):
|
|
165
|
+
self.element_type = element_type
|
|
166
|
+
self.min_len = min_len
|
|
167
|
+
self.max_len = max_len
|
|
168
|
+
|
|
169
|
+
def validate(self, value, path):
|
|
170
|
+
if not isinstance(value, list):
|
|
171
|
+
return [_issue(path, f"expected list, got {type(value).__name__}")]
|
|
172
|
+
errors = []
|
|
173
|
+
if self.min_len is not None and len(value) < self.min_len:
|
|
174
|
+
errors.append(_issue(path, f"list has {len(value)} items, minimum is {self.min_len}"))
|
|
175
|
+
if self.max_len is not None and len(value) > self.max_len:
|
|
176
|
+
errors.append(_issue(path, f"list has {len(value)} items, maximum is {self.max_len}"))
|
|
177
|
+
for i, item in enumerate(value):
|
|
178
|
+
errors.extend(_validate_value(item, self.element_type, path + [str(i)]))
|
|
179
|
+
return errors
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class Dict:
|
|
183
|
+
def __init__(self, value_type):
|
|
184
|
+
self.value_type = value_type
|
|
185
|
+
|
|
186
|
+
def validate(self, value, path):
|
|
187
|
+
if not isinstance(value, dict):
|
|
188
|
+
return [_issue(path, f"expected dict, got {type(value).__name__}")]
|
|
189
|
+
errors = []
|
|
190
|
+
for k, v in value.items():
|
|
191
|
+
errors.extend(_validate_value(v, self.value_type, path + [str(k)]))
|
|
192
|
+
return errors
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# -- main dispatch --
|
|
196
|
+
|
|
197
|
+
def _validate_value(value, schema_node, path):
|
|
198
|
+
"""Dispatch validation for a single value against a schema node."""
|
|
199
|
+
# Optional wrapper
|
|
200
|
+
if isinstance(schema_node, _Optional):
|
|
201
|
+
if value is None:
|
|
202
|
+
return []
|
|
203
|
+
return _validate_value(value, schema_node.type, path)
|
|
204
|
+
|
|
205
|
+
# Built-in type validators
|
|
206
|
+
if isinstance(schema_node, (Int, Str, Float, Bool, Email, OneOf, List, Dict)):
|
|
207
|
+
return schema_node.validate(value, path)
|
|
208
|
+
|
|
209
|
+
# Bare type: str, int, float, bool
|
|
210
|
+
if isinstance(schema_node, type):
|
|
211
|
+
if schema_node is bool:
|
|
212
|
+
v = Bool()._coerce(value)
|
|
213
|
+
return [] if v is not None else [_issue(path, f"expected bool, got {type(value).__name__}")]
|
|
214
|
+
if schema_node is int:
|
|
215
|
+
v = Int()._coerce(value)
|
|
216
|
+
return [] if v is not None else [_issue(path, f"expected int, got {type(value).__name__}")]
|
|
217
|
+
if schema_node is float:
|
|
218
|
+
v = Float()._coerce(value)
|
|
219
|
+
return [] if v is not None else [_issue(path, f"expected float, got {type(value).__name__}")]
|
|
220
|
+
if schema_node is str:
|
|
221
|
+
if isinstance(value, str):
|
|
222
|
+
return []
|
|
223
|
+
return [_issue(path, f"expected str, got {type(value).__name__}")]
|
|
224
|
+
return [_issue(path, f"expected {schema_node.__name__}, got {type(value).__name__}")]
|
|
225
|
+
|
|
226
|
+
# Nested schema (dict)
|
|
227
|
+
if isinstance(schema_node, dict):
|
|
228
|
+
if not isinstance(value, dict):
|
|
229
|
+
return [_issue(path, f"expected dict, got {type(value).__name__}")]
|
|
230
|
+
errors = []
|
|
231
|
+
for k, v_spec in schema_node.items():
|
|
232
|
+
if k not in value:
|
|
233
|
+
if isinstance(v_spec, _Optional):
|
|
234
|
+
continue
|
|
235
|
+
errors.append(_issue(path + [k], "field is required"))
|
|
236
|
+
else:
|
|
237
|
+
errors.extend(_validate_value(value[k], v_spec, path + [k]))
|
|
238
|
+
return errors
|
|
239
|
+
|
|
240
|
+
# List shorthand: [int], [str]
|
|
241
|
+
if isinstance(schema_node, list) and len(schema_node) == 1:
|
|
242
|
+
if not isinstance(value, list):
|
|
243
|
+
return [_issue(path, f"expected list, got {type(value).__name__}")]
|
|
244
|
+
errors = []
|
|
245
|
+
for i, item in enumerate(value):
|
|
246
|
+
errors.extend(_validate_value(item, schema_node[0], path + [str(i)]))
|
|
247
|
+
return errors
|
|
248
|
+
|
|
249
|
+
# Callable (custom validator)
|
|
250
|
+
if callable(schema_node):
|
|
251
|
+
try:
|
|
252
|
+
result = schema_node(value)
|
|
253
|
+
if isinstance(result, str):
|
|
254
|
+
# "or" pattern: lambda x: condition or "error msg"
|
|
255
|
+
# Return the string as an error
|
|
256
|
+
return [_issue(path, result)]
|
|
257
|
+
if result is None or result:
|
|
258
|
+
return []
|
|
259
|
+
# falsy (e.g., False) — treat as failure
|
|
260
|
+
return [_issue(path, "validation failed")]
|
|
261
|
+
except AssertionError as e:
|
|
262
|
+
return [_issue(path, str(e) or "validation failed")]
|
|
263
|
+
except Exception as e:
|
|
264
|
+
return [_issue(path, str(e))]
|
|
265
|
+
|
|
266
|
+
return []
|
guardly/validator.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Main validate/check API."""
|
|
2
|
+
|
|
3
|
+
from guardly.errors import ValidationIssue, ValidationError
|
|
4
|
+
from guardly.types import _validate_value
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def validate(data, schema) -> list[ValidationIssue]:
|
|
8
|
+
"""Validate data against a schema. Returns a list of ValidationIssue (empty if valid)."""
|
|
9
|
+
return _validate_value(data, schema, [])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def check(data, schema) -> None:
|
|
13
|
+
"""Validate data against a schema. Raises ValidationError if invalid."""
|
|
14
|
+
errors = validate(data, schema)
|
|
15
|
+
if errors:
|
|
16
|
+
raise ValidationError(errors)
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: guardly
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Schema-first validation for dicts and configs. Zero dependencies.
|
|
5
|
+
Author: Ravi Teja Prabhala Venkata
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: config,dict,schema,validation
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Guardly
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+

|
|
19
|
+

|
|
20
|
+

|
|
21
|
+
|
|
22
|
+
**Schema-first validation for Python dicts and configs. Zero dependencies.**
|
|
23
|
+
|
|
24
|
+
Guardly validates plain dictionaries against schemas you define as data — no models, no decorators, no ceremony. Define what you expect, get back clear errors with path info.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import guardly
|
|
28
|
+
|
|
29
|
+
schema = {
|
|
30
|
+
"name": str,
|
|
31
|
+
"age": guardly.Int(min=0, max=150),
|
|
32
|
+
"email": guardly.Email(),
|
|
33
|
+
"tags": [str],
|
|
34
|
+
"active": bool,
|
|
35
|
+
"score": float,
|
|
36
|
+
"address": {
|
|
37
|
+
"city": str,
|
|
38
|
+
"zip": guardly.Str(pattern=r"\d{5}"),
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
errors = guardly.validate(data, schema)
|
|
43
|
+
if errors:
|
|
44
|
+
for e in errors:
|
|
45
|
+
print(f"{'.'.join(e.path)}: {e.message}")
|
|
46
|
+
|
|
47
|
+
# Or raise on failure:
|
|
48
|
+
guardly.check(data, schema) # raises guardly.ValidationError
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Why Guardly?
|
|
54
|
+
|
|
55
|
+
Python's validation ecosystem has a gap. Here's the landscape:
|
|
56
|
+
|
|
57
|
+
| Library | Approach | Dependencies | Status |
|
|
58
|
+
|---------|----------|-------------|--------|
|
|
59
|
+
| **Pydantic** | Model-first (class-based) | pydantic-core, typing-extensions | Active, heavy |
|
|
60
|
+
| **Marshmallow** | Schema-based (class declarations) | marshmallow, packaging | Active, verbose |
|
|
61
|
+
| **Cerberus** | Schema-as-dict | None | Last release 2021, unmaintained |
|
|
62
|
+
| **Voluptuous** | Schema-as-dict | None | Last release 2020, unmaintained |
|
|
63
|
+
| **jsonschema** | JSON Schema spec | jsonschema, attrs, rpds-py | Active, spec-heavy |
|
|
64
|
+
| **Guardly** | Schema-as-dict | **None** | ✅ Active, lightweight |
|
|
65
|
+
|
|
66
|
+
**The problem:** If you're validating raw dicts — API payloads, config files, environment variables — you don't need models. Pydantic forces a class-based design that's overkill for simple validation. Cerberus and Voluptuous filled this niche but are now unmaintained. Marshmallow requires declaring classes.
|
|
67
|
+
|
|
68
|
+
**Guardly fills this gap:** Define schemas as plain Python dicts. Validate any dict against them. Get clear, path-annotated errors. Zero dependencies. 100% pure Python.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Installation
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install guardly
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
No other dependencies. Python 3.10+.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Quick Start
|
|
83
|
+
|
|
84
|
+
### Basic Types
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
import guardly
|
|
88
|
+
|
|
89
|
+
schema = {
|
|
90
|
+
"name": str,
|
|
91
|
+
"age": int,
|
|
92
|
+
"score": float,
|
|
93
|
+
"active": bool,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
errors = guardly.validate({"name": "Alice", "age": 30, "score": 95.5, "active": True}, schema)
|
|
97
|
+
# errors == []
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Constrained Types
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
schema = {
|
|
104
|
+
"age": guardly.Int(min=0, max=150),
|
|
105
|
+
"email": guardly.Str(pattern=r"[^@]+@[^@]+\.[^@]+"),
|
|
106
|
+
"bio": guardly.Str(min_len=10, max_len=500),
|
|
107
|
+
"rating": guardly.Float(min=0.0, max=5.0),
|
|
108
|
+
"role": guardly.OneOf(["admin", "editor", "viewer"]),
|
|
109
|
+
"contact": guardly.Email(),
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Nested Schemas
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
schema = {
|
|
117
|
+
"user": {
|
|
118
|
+
"name": str,
|
|
119
|
+
"address": {
|
|
120
|
+
"street": str,
|
|
121
|
+
"city": str,
|
|
122
|
+
"zip": guardly.Str(pattern=r"\d{5}"),
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Lists
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
# Shorthand: all elements must be strings
|
|
132
|
+
schema = {"tags": [str]}
|
|
133
|
+
|
|
134
|
+
# Full control with List()
|
|
135
|
+
schema = {"scores": guardly.List(guardly.Float(min=0, max=100), min_len=1, max_len=10)}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Dict (untyped keys, typed values)
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
schema = {"metadata": guardly.Dict(int)}
|
|
142
|
+
# validates {"metadata": {"views": 100, "likes": 42}}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Optional Fields
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
schema = {
|
|
149
|
+
"name": str,
|
|
150
|
+
"nickname": guardly.Optional(str), # can be missing or None
|
|
151
|
+
"role": guardly.Optional(guardly.Str(), default="viewer"),
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Custom Validators
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
schema = {
|
|
159
|
+
"password": lambda x: len(x) >= 8 or "password must be at least 8 characters",
|
|
160
|
+
"age": lambda x: x >= 0 or "age must be non-negative",
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Error Handling
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
# Collect all errors:
|
|
168
|
+
errors = guardly.validate(data, schema)
|
|
169
|
+
for e in errors:
|
|
170
|
+
print(f"[{'.'.join(e.path) if e.path else 'root'}] {e.message}")
|
|
171
|
+
|
|
172
|
+
# Or raise immediately:
|
|
173
|
+
try:
|
|
174
|
+
guardly.check(data, schema)
|
|
175
|
+
except guardly.ValidationError as e:
|
|
176
|
+
for e in e.errors:
|
|
177
|
+
print(e)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Type System
|
|
183
|
+
|
|
184
|
+
### Primitive Types
|
|
185
|
+
|
|
186
|
+
| Schema | Validates | Coercions |
|
|
187
|
+
|--------|-----------|-----------|
|
|
188
|
+
| `str` | strings only | — |
|
|
189
|
+
| `int` | integers | `"42"` → `42` |
|
|
190
|
+
| `float` | floats | `42` → `42.0`, `"3.14"` → `3.14` |
|
|
191
|
+
| `bool` | booleans | `"true"`/`"false"`/`"yes"`/`"no"`/`0`/`1` |
|
|
192
|
+
|
|
193
|
+
### Constrained Types
|
|
194
|
+
|
|
195
|
+
| Type | Parameters | Example |
|
|
196
|
+
|------|-----------|---------|
|
|
197
|
+
| `Int(min, max)` | min/max bounds | `Int(min=0, max=150)` |
|
|
198
|
+
| `Str(pattern, min_len, max_len)` | regex, length bounds | `Str(pattern=r"\d{5}")` |
|
|
199
|
+
| `Float(min, max)` | min/max bounds | `Float(min=0.0, max=1.0)` |
|
|
200
|
+
| `Email()` | built-in regex | `Email()` |
|
|
201
|
+
| `OneOf(choices)` | allowed values | `OneOf(["a", "b", "c"])` |
|
|
202
|
+
| `List(element_type, min_len, max_len)` | element type, size bounds | `List(int, min_len=1)` |
|
|
203
|
+
| `Dict(value_type)` | value type (keys unrestricted) | `Dict(int)` |
|
|
204
|
+
| `Optional(type, default)` | wraps any type as optional | `Optional(str, default="N/A")` |
|
|
205
|
+
|
|
206
|
+
### Custom Validators
|
|
207
|
+
|
|
208
|
+
Any callable works as a schema node:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
# Return truthy for pass, falsy for fail
|
|
212
|
+
schema = {"x": lambda x: x > 0}
|
|
213
|
+
|
|
214
|
+
# Return error message string on failure
|
|
215
|
+
schema = {"x": lambda x: x > 0 or "must be positive"}
|
|
216
|
+
|
|
217
|
+
# Raise an exception
|
|
218
|
+
schema = {"x": lambda x: assert x > 0}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## Design Philosophy
|
|
224
|
+
|
|
225
|
+
1. **Schema-as-data.** Your schema is a Python dict, not a class. It's serializable, composable, and trivially dynamic. You can load it from JSON, generate it programmatically, or compose it from pieces.
|
|
226
|
+
|
|
227
|
+
2. **Zero dependencies.** Guardly is ~250 lines of pure Python. No pydantic-core, no attrs, no typing extensions. Install it, use it, ship it — nothing else to track.
|
|
228
|
+
|
|
229
|
+
3. **Clear errors with paths.** Every validation error tells you exactly where it is (`address.zip`), what went wrong, and what was expected. No hunting through nested exceptions.
|
|
230
|
+
|
|
231
|
+
4. **Coercion where sensible, strict where it matters.** `"42"` coerces to `42` for `Int()` because that's what most APIs need. But `True` never coerces to `1` — that's a bug waiting to happen.
|
|
232
|
+
|
|
233
|
+
5. **Extra fields ignored by default.** Your schema declares what you need. Additional keys in the data are silently ignored. This makes forward-compatible APIs natural.
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Use Cases
|
|
238
|
+
|
|
239
|
+
### API Input Validation
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
def create_user(request_json):
|
|
243
|
+
schema = {
|
|
244
|
+
"username": Str(min_len=3, max_len=32, pattern=r"[a-zA-Z0-9_]+"),
|
|
245
|
+
"email": Email(),
|
|
246
|
+
"age": Optional(Int(min=13, max=120)),
|
|
247
|
+
"role": Optional(OneOf(["user", "admin"]), default="user"),
|
|
248
|
+
}
|
|
249
|
+
errors = guardly.validate(request_json, schema)
|
|
250
|
+
if errors:
|
|
251
|
+
return {"errors": [{"field": ".".join(e.path), "msg": e.message} for e in errors]}, 400
|
|
252
|
+
# proceed with validated data...
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Config File Validation
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
import json
|
|
259
|
+
|
|
260
|
+
CONFIG_SCHEMA = {
|
|
261
|
+
"database": {
|
|
262
|
+
"host": str,
|
|
263
|
+
"port": Int(min=1, max=65535),
|
|
264
|
+
"name": str,
|
|
265
|
+
"pool_size": Optional(Int(min=1, max=100), default=10),
|
|
266
|
+
},
|
|
267
|
+
"server": {
|
|
268
|
+
"host": str,
|
|
269
|
+
"port": Int(min=1, max=65535),
|
|
270
|
+
"debug": bool,
|
|
271
|
+
},
|
|
272
|
+
"logging": {
|
|
273
|
+
"level": OneOf(["DEBUG", "INFO", "WARNING", "ERROR"]),
|
|
274
|
+
"file": Optional(str),
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
with open("config.json") as f:
|
|
279
|
+
config = json.load(f)
|
|
280
|
+
guardly.check(config, CONFIG_SCHEMA)
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Environment Variable Validation
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
import os
|
|
287
|
+
|
|
288
|
+
env_schema = {
|
|
289
|
+
"DATABASE_URL": Str(pattern=r"postgres://.+"),
|
|
290
|
+
"PORT": Int(min=1, max=65535),
|
|
291
|
+
"DEBUG": bool,
|
|
292
|
+
"SECRET_KEY": Str(min_len=32),
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
env_data = {k: os.environ.get(k) for k in env_schema}
|
|
296
|
+
errors = guardly.validate(env_data, env_schema)
|
|
297
|
+
if errors:
|
|
298
|
+
raise RuntimeError(f"Invalid environment: {errors}")
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### Form Data Validation
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
form_schema = {
|
|
305
|
+
"email": Email(),
|
|
306
|
+
"password": Str(min_len=8),
|
|
307
|
+
"confirm_password": str,
|
|
308
|
+
"terms_accepted": bool,
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
# Custom cross-field validation
|
|
312
|
+
errors = guardly.validate(form_data, form_schema)
|
|
313
|
+
if form_data.get("password") != form_data.get("confirm_password"):
|
|
314
|
+
errors.append(guardly.errors.ValidationIssue(
|
|
315
|
+
("confirm_password",), "passwords do not match"
|
|
316
|
+
))
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## API Reference
|
|
322
|
+
|
|
323
|
+
### `guardly.validate(data, schema) -> list[ValidationIssue]`
|
|
324
|
+
|
|
325
|
+
Validates `data` (a dict) against `schema`. Returns a list of errors. Empty list means valid.
|
|
326
|
+
|
|
327
|
+
### `guardly.check(data, schema)`
|
|
328
|
+
|
|
329
|
+
Validates and raises `ValidationError` if any errors are found.
|
|
330
|
+
|
|
331
|
+
### `guardly.ValidationError`
|
|
332
|
+
|
|
333
|
+
Exception raised by `check()`. Contains `.errors` — a list of `ValidationIssue` objects.
|
|
334
|
+
|
|
335
|
+
### `guardly.errors.ValidationIssue`
|
|
336
|
+
|
|
337
|
+
- `.path` — tuple of path segments, e.g., `("address", "zip")`
|
|
338
|
+
- `.message` — human-readable error description
|
|
339
|
+
|
|
340
|
+
### Types
|
|
341
|
+
|
|
342
|
+
- `guardly.Int(min=None, max=None)`
|
|
343
|
+
- `guardly.Str(pattern=None, min_len=None, max_len=None)`
|
|
344
|
+
- `guardly.Float(min=None, max=None)`
|
|
345
|
+
- `guardly.Bool()`
|
|
346
|
+
- `guardly.Email()`
|
|
347
|
+
- `guardly.OneOf(choices)`
|
|
348
|
+
- `guardly.List(element_type, min_len=None, max_len=None)`
|
|
349
|
+
- `guardly.Dict(value_type)`
|
|
350
|
+
- `guardly.Optional(type, default=None)`
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
## Comparison: Guardly vs Alternatives
|
|
355
|
+
|
|
356
|
+
| Feature | Guardly | Pydantic | Cerberus | Voluptuous | Marshmallow | jsonschema |
|
|
357
|
+
|---------|---------|----------|----------|------------|-------------|------------|
|
|
358
|
+
| Schema-as-dict | ✅ | ❌ (class-based) | ✅ | ✅ | ❌ (class-based) | ✅ (JSON Schema) |
|
|
359
|
+
| Zero dependencies | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ |
|
|
360
|
+
| Type coercion | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
|
361
|
+
| Nested validation | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
362
|
+
| Custom validators | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
363
|
+
| Error path info | ✅ | ✅ | ✅ | Partial | ✅ | ✅ |
|
|
364
|
+
| Optional fields | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
365
|
+
| Maintained (2024+) | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ |
|
|
366
|
+
| Lines of code | ~250 | ~20,000 | ~5,000 | ~2,500 | ~10,000 | ~8,000 |
|
|
367
|
+
| Install size | ~10 KB | ~5 MB | ~200 KB | ~150 KB | ~500 KB | ~1 MB |
|
|
368
|
+
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
## Development
|
|
372
|
+
|
|
373
|
+
```bash
|
|
374
|
+
# Clone and set up
|
|
375
|
+
git clone https://github.com/yourusername/guardly.git
|
|
376
|
+
cd guardly
|
|
377
|
+
pip install -e ".[dev]"
|
|
378
|
+
|
|
379
|
+
# Run tests
|
|
380
|
+
python -m pytest tests/ -v
|
|
381
|
+
|
|
382
|
+
# Run with coverage
|
|
383
|
+
python -m pytest tests/ -v --cov=guardly
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
## License
|
|
391
|
+
|
|
392
|
+
MIT © Ravi Teja Prabhala Venkata
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
guardly/__init__.py,sha256=mucqEe4dzKAHyGHnB3t-EbAMHvCknPSpm2cukVuqpCk,439
|
|
2
|
+
guardly/errors.py,sha256=Y4voinEzbxOidmlfrBpn-dhklt2KJPMhYG-zDXbWiPI,765
|
|
3
|
+
guardly/types.py,sha256=q84mi87i_czDKmxVHIbBIDHg7oo4o6C_AgFH-Q6cPSw,9588
|
|
4
|
+
guardly/validator.py,sha256=lVMrz0Q0_kxOOW21iyUhVBZka4nyoo5rOfwaYzYEMd4,529
|
|
5
|
+
guardly-0.2.0.dist-info/METADATA,sha256=AZbm2KhsR4qBy1CpORUDnlGcbqsRDJG99k2NJJvONnw,10907
|
|
6
|
+
guardly-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
guardly-0.2.0.dist-info/RECORD,,
|