Skip to main content

Guardly

Python 3.10+ MIT License Tests Zero Dependencies

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": guardly.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}"),
    },
}

data = {
    "name": "Alice",
    "age": 30,
    "email": "alice@example.com",
    "tags": ["python", "dev"],
    "active": True,
    "score": 95.5,
    "address": {"city": "NYC", "zip": "10001"},
}

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"}

# Use a function to raise
def positive(x):
    if x <= 0:
        raise ValueError("must be positive")
    return True

schema = {"x": positive}

Design Philosophy

  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.

  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.

  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.

  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.

  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.


Use Cases

API Input Validation

def create_user(request_json):
    schema = {
        "username": guardly.Str(min_len=3, max_len=32, pattern=r"[a-zA-Z0-9_]+"),
        "email": guardly.Email(),
        "age": guardly.Optional(guardly.Int(min=13, max=120)),
        "role": guardly.Optional(guardly.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": guardly.Int(min=1, max=65535),
        "name": str,
        "pool_size": guardly.Optional(guardly.Int(min=1, max=100), default=10),
    },
    "server": {
        "host": str,
        "port": guardly.Int(min=1, max=65535),
        "debug": bool,
    },
    "logging": {
        "level": guardly.OneOf(["DEBUG", "INFO", "WARNING", "ERROR"]),
        "file": guardly.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": guardly.Str(pattern=r"postgres://.+"),
    "PORT": guardly.Int(min=1, max=65535),
    "DEBUG": bool,
    "SECRET_KEY": guardly.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": guardly.Email(),
    "password": guardly.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

Metadata

Release files for guardly 0.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for guardly 0.5.0
File Size Uploaded Provenance Actions
guardly-0.5.0.tar.gz 11.5 kB Without provenance Details for guardly-0.5.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for guardly 0.5.0
File Interpreter ABI Platform Actions
guardly-0.5.0-py3-none-any.whl Python 3 none any Details for guardly-0.5.0-py3-none-any.whl

Total release size: 20.1 kB

Release files / guardly-0.5.0.tar.gz

Download URL guardly-0.5.0.tar.gz
Size 11.5 kB
Tags Source
SHA-256 checksum
How to use checksums
c2f3daa9fc36187fc9d62cdb1e6b5cbdc7af639b5d36672e35bfc586fb68835b
BLAKE2b-256 checksum
How to use checksums
e823260150ea611297203e040cf7ed977237e78143352c4fe6a2de5a0b746f27
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.11.5

Release files / guardly-0.5.0-py3-none-any.whl

Download URL guardly-0.5.0-py3-none-any.whl
Size 8.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ff9f015854dcc754a53c5c8c4eacbe054680bc613e6b4f915cbbe73738890583
BLAKE2b-256 checksum
How to use checksums
dd1095531f15d7a04af59542ccf23c62f98c1288e9b972c415beb56ac2b1fdae
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.11.5

Security for guardly 0.5.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page