Metadata-Version: 2.4
Name: guardly
Version: 0.3.0
Summary: Schema-first validation for dicts and configs. Zero dependencies.
Author: Ravi Teja Prabhala Venkata
License-Expression: MIT
Keywords: config,dict,schema,validation
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == 'dev'
Description-Content-Type: text/markdown

# Guardly

![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)
![MIT License](https://img.shields.io/badge/license-MIT-green.svg)
![Tests](https://img.shields.io/badge/tests-106%20passing-brightgreen.svg)
![Zero Dependencies](https://img.shields.io/badge/dependencies-zero-orange.svg)

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

```python
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

```bash
pip install guardly
```

No other dependencies. Python 3.10+.

---

## Quick Start

### Basic Types

```python
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

```python
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

```python
schema = {
    "user": {
        "name": str,
        "address": {
            "street": str,
            "city": str,
            "zip": guardly.Str(pattern=r"\d{5}"),
        },
    },
}
```

### Lists

```python
# 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)

```python
schema = {"metadata": guardly.Dict(int)}
# validates {"metadata": {"views": 100, "likes": 42}}
```

### Optional Fields

```python
schema = {
    "name": str,
    "nickname": guardly.Optional(str),  # can be missing or None
    "role": guardly.Optional(guardly.Str(), default="viewer"),
}
```

### Custom Validators

```python
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

```python
# 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:

```python
# 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

```python
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

```python
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

```python
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

```python
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

```bash
# 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](CONTRIBUTING.md) for details.

---

## License

MIT © Ravi Teja Prabhala Venkata
