Schema-first validation for dicts and configs. Zero dependencies.
Project description
Guardly
Schema-first validation for Python dicts and configs. Zero dependencies.
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.
import guardly
schema = {
"name": str,
"age": guardly.Int(min=0, max=150),
"email": guardly.Email(),
"tags": [str],
"active": bool,
"score": float,
"address": {
"city": str,
"zip": guardly.Str(pattern=r"\d{5}"),
},
}
errors = guardly.validate(data, schema)
if errors:
for e in errors:
print(f"{'.'.join(e.path)}: {e.message}")
# Or raise on failure:
guardly.check(data, schema) # raises guardly.ValidationError
Why Guardly?
Python's validation ecosystem has a gap. Here's the landscape:
| Library | Approach | Dependencies | Status |
|---|---|---|---|
| Pydantic | Model-first (class-based) | pydantic-core, typing-extensions | Active, heavy |
| Marshmallow | Schema-based (class declarations) | marshmallow, packaging | Active, verbose |
| Cerberus | Schema-as-dict | None | Last release 2021, unmaintained |
| Voluptuous | Schema-as-dict | None | Last release 2020, unmaintained |
| jsonschema | JSON Schema spec | jsonschema, attrs, rpds-py | Active, spec-heavy |
| Guardly | Schema-as-dict | None | ✅ Active, lightweight |
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.
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.
Installation
pip install guardly
No other dependencies. Python 3.10+.
Quick Start
Basic Types
import guardly
schema = {
"name": str,
"age": int,
"score": float,
"active": bool,
}
errors = guardly.validate({"name": "Alice", "age": 30, "score": 95.5, "active": True}, schema)
# errors == []
Constrained Types
schema = {
"age": guardly.Int(min=0, max=150),
"email": guardly.Str(pattern=r"[^@]+@[^@]+\.[^@]+"),
"bio": guardly.Str(min_len=10, max_len=500),
"rating": guardly.Float(min=0.0, max=5.0),
"role": guardly.OneOf(["admin", "editor", "viewer"]),
"contact": guardly.Email(),
}
Nested Schemas
schema = {
"user": {
"name": str,
"address": {
"street": str,
"city": str,
"zip": guardly.Str(pattern=r"\d{5}"),
},
},
}
Lists
# Shorthand: all elements must be strings
schema = {"tags": [str]}
# Full control with List()
schema = {"scores": guardly.List(guardly.Float(min=0, max=100), min_len=1, max_len=10)}
Dict (untyped keys, typed values)
schema = {"metadata": guardly.Dict(int)}
# validates {"metadata": {"views": 100, "likes": 42}}
Optional Fields
schema = {
"name": str,
"nickname": guardly.Optional(str), # can be missing or None
"role": guardly.Optional(guardly.Str(), default="viewer"),
}
Custom Validators
schema = {
"password": lambda x: len(x) >= 8 or "password must be at least 8 characters",
"age": lambda x: x >= 0 or "age must be non-negative",
}
Error Handling
# Collect all errors:
errors = guardly.validate(data, schema)
for e in errors:
print(f"[{'.'.join(e.path) if e.path else 'root'}] {e.message}")
# Or raise immediately:
try:
guardly.check(data, schema)
except guardly.ValidationError as e:
for e in e.errors:
print(e)
Type System
Primitive Types
| Schema | Validates | Coercions |
|---|---|---|
str |
strings only | — |
int |
integers | "42" → 42 |
float |
floats | 42 → 42.0, "3.14" → 3.14 |
bool |
booleans | "true"/"false"/"yes"/"no"/0/1 |
Constrained Types
| Type | Parameters | Example |
|---|---|---|
Int(min, max) |
min/max bounds | Int(min=0, max=150) |
Str(pattern, min_len, max_len) |
regex, length bounds | Str(pattern=r"\d{5}") |
Float(min, max) |
min/max bounds | Float(min=0.0, max=1.0) |
Email() |
built-in regex | Email() |
OneOf(choices) |
allowed values | OneOf(["a", "b", "c"]) |
List(element_type, min_len, max_len) |
element type, size bounds | List(int, min_len=1) |
Dict(value_type) |
value type (keys unrestricted) | Dict(int) |
Optional(type, default) |
wraps any type as optional | Optional(str, default="N/A") |
Custom Validators
Any callable works as a schema node:
# Return truthy for pass, falsy for fail
schema = {"x": lambda x: x > 0}
# Return error message string on failure
schema = {"x": lambda x: x > 0 or "must be positive"}
# Raise an exception
schema = {"x": lambda x: assert x > 0}
Design Philosophy
-
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.
-
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.
-
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. -
Coercion where sensible, strict where it matters.
"42"coerces to42forInt()because that's what most APIs need. ButTruenever coerces to1— that's a bug waiting to happen. -
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.
Use Cases
API Input Validation
def create_user(request_json):
schema = {
"username": Str(min_len=3, max_len=32, pattern=r"[a-zA-Z0-9_]+"),
"email": Email(),
"age": Optional(Int(min=13, max=120)),
"role": Optional(OneOf(["user", "admin"]), default="user"),
}
errors = guardly.validate(request_json, schema)
if errors:
return {"errors": [{"field": ".".join(e.path), "msg": e.message} for e in errors]}, 400
# proceed with validated data...
Config File Validation
import json
CONFIG_SCHEMA = {
"database": {
"host": str,
"port": Int(min=1, max=65535),
"name": str,
"pool_size": Optional(Int(min=1, max=100), default=10),
},
"server": {
"host": str,
"port": Int(min=1, max=65535),
"debug": bool,
},
"logging": {
"level": OneOf(["DEBUG", "INFO", "WARNING", "ERROR"]),
"file": Optional(str),
},
}
with open("config.json") as f:
config = json.load(f)
guardly.check(config, CONFIG_SCHEMA)
Environment Variable Validation
import os
env_schema = {
"DATABASE_URL": Str(pattern=r"postgres://.+"),
"PORT": Int(min=1, max=65535),
"DEBUG": bool,
"SECRET_KEY": Str(min_len=32),
}
env_data = {k: os.environ.get(k) for k in env_schema}
errors = guardly.validate(env_data, env_schema)
if errors:
raise RuntimeError(f"Invalid environment: {errors}")
Form Data Validation
form_schema = {
"email": Email(),
"password": Str(min_len=8),
"confirm_password": str,
"terms_accepted": bool,
}
# Custom cross-field validation
errors = guardly.validate(form_data, form_schema)
if form_data.get("password") != form_data.get("confirm_password"):
errors.append(guardly.errors.ValidationIssue(
("confirm_password",), "passwords do not match"
))
API Reference
guardly.validate(data, schema) -> list[ValidationIssue]
Validates data (a dict) against schema. Returns a list of errors. Empty list means valid.
guardly.check(data, schema)
Validates and raises ValidationError if any errors are found.
guardly.ValidationError
Exception raised by check(). Contains .errors — a list of ValidationIssue objects.
guardly.errors.ValidationIssue
.path— tuple of path segments, e.g.,("address", "zip").message— human-readable error description
Types
guardly.Int(min=None, max=None)guardly.Str(pattern=None, min_len=None, max_len=None)guardly.Float(min=None, max=None)guardly.Bool()guardly.Email()guardly.OneOf(choices)guardly.List(element_type, min_len=None, max_len=None)guardly.Dict(value_type)guardly.Optional(type, default=None)
Comparison: Guardly vs Alternatives
| Feature | Guardly | Pydantic | Cerberus | Voluptuous | Marshmallow | jsonschema |
|---|---|---|---|---|---|---|
| Schema-as-dict | ✅ | ❌ (class-based) | ✅ | ✅ | ❌ (class-based) | ✅ (JSON Schema) |
| Zero dependencies | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ |
| Type coercion | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| Nested validation | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Custom validators | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Error path info | ✅ | ✅ | ✅ | Partial | ✅ | ✅ |
| Optional fields | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Maintained (2024+) | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ |
| Lines of code | ~250 | ~20,000 | ~5,000 | ~2,500 | ~10,000 | ~8,000 |
| Install size | ~10 KB | ~5 MB | ~200 KB | ~150 KB | ~500 KB | ~1 MB |
Development
# Clone and set up
git clone https://github.com/yourusername/guardly.git
cd guardly
pip install -e ".[dev]"
# Run tests
python -m pytest tests/ -v
# Run with coverage
python -m pytest tests/ -v --cov=guardly
See CONTRIBUTING.md for details.
License
MIT © Ravi Teja Prabhala Venkata
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file guardly-0.2.0.tar.gz.
File metadata
- Download URL: guardly-0.2.0.tar.gz
- Upload date:
- Size: 11.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
54c8f567764024ba419affb04e849d34a41209fc607d79b7c50cb27505b4087e
|
|
| MD5 |
48df9e888fd3b7bc6d4bebc4a3a556e1
|
|
| BLAKE2b-256 |
45345b468d06629ac2d111468f9581d8684fa675beb1487e9b1ed04ef8b60bb7
|
File details
Details for the file guardly-0.2.0-py3-none-any.whl.
File metadata
- Download URL: guardly-0.2.0-py3-none-any.whl
- Upload date:
- Size: 8.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
88701047ec48cb66656eeb9df6dc09203ec0370e6778fd7341a9ada980cdba1a
|
|
| MD5 |
f6b81054c2dbe9a6eb3ae5264b617cc2
|
|
| BLAKE2b-256 |
c98b583ef25b94c28f2590d41c14d2d1bfe3729a0d16644411503939782d59f9
|