Skip to main content

Schema-first validation for dicts and configs. Zero dependencies.

Project description

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 4242.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

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

guardly-0.3.0.tar.gz (11.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

guardly-0.3.0-py3-none-any.whl (8.5 kB view details)

Uploaded Python 3

File details

Details for the file guardly-0.3.0.tar.gz.

File metadata

  • Download URL: guardly-0.3.0.tar.gz
  • Upload date:
  • Size: 11.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.5

File hashes

Hashes for guardly-0.3.0.tar.gz
Algorithm Hash digest
SHA256 a57b4b5d9031d4fc9c22bdf969418169931c8973149d09015925e2c0823d3d83
MD5 f10f615947147ddfaccae9164a91b464
BLAKE2b-256 ae3cedc613e4e92d59319759095fbbe3b3ca144f274e1f74ecb0938cdfa5f5f8

See more details on using hashes here.

File details

Details for the file guardly-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: guardly-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 8.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.5

File hashes

Hashes for guardly-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 67eee39837f8c495956b2d0b4bf28e6cbe2ade000d4230c728c91cec6402dfba
MD5 999a1d4f38f3c8e96644b3c12f473e20
BLAKE2b-256 0b07fbea223708eeb005927c8b2e73af43cfdf7a83bc1c86b0d2a17ea093de95

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page