Metadata-Version: 2.4
Name: checked
Version: 1.0.0
Summary: Constraint-enforced state objects for Python
Project-URL: Homepage, https://github.com/xof/checked
Project-URL: Issues, https://github.com/xof/checked/issues
Author: xof
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.12
Description-Content-Type: text/markdown

# checked

A lightweight Python package for constraint-enforced state objects. Declare your fields once with type annotations, and the machinery enforces your rules on every assignment — no boilerplate validation code scattered through your logic.

```python
from typing import Annotated
from checked import Checked
from checked.constraints import Immutable, Range, OneOf, Transitions, Requires, Derived

class CharacterState(Checked):
    health:       Annotated[int, Range(0, 100)] = 100
    alignment:    Annotated[str, OneOf("good", "neutral", "evil")] = "neutral"
    took_the_key: Annotated[bool, Immutable] = None
    relationship: Annotated[str,
                      OneOf("neutral", "friendly", "hostile"),
                      Transitions(
                          neutral=["friendly", "hostile"],
                          friendly=["neutral"],
                          hostile=[]
                      )] = "neutral"
    is_injured:   Annotated[bool, Derived(lambda s: s.health < 50)]
```

For how the library works internally, see [ARCHITECTURE.md](ARCHITECTURE.md); for the design rationale and rejected alternatives, see [THEORY.md](THEORY.md).

## Installation

`checked` requires Python 3.12 or newer and has no runtime dependencies beyond the standard library.

The package is not yet published on PyPI; for now, install directly from the repository.

With `pip`:

```bash
pip install git+https://github.com/xof/checked
```

With `uv`:

```bash
uv add "git+https://github.com/xof/checked"
```

Once the package is published, the usual forms will work:

```bash
pip install checked
uv add checked
```

**Hacking on `checked` itself.** Clone the repo and let `uv` set up the environment:

```bash
git clone https://github.com/xof/checked
cd checked
uv sync --dev              # installs runtime + dev dependencies (pytest, ruff, mypy)
uv run pytest -v           # run the test suite
uv run ruff check .        # lint
uv run mypy src/checked    # type-check
```

## Core concepts

### Checked

Subclass `Checked` and declare fields with standard Python annotations. Plain annotations get type enforcement only; wrap them in `Annotated[]` to add constraints.

```python
class MyState(Checked):
    # Type-enforced only
    name: str = ""
    move_count: int = 0

    # Type-enforced + constrained
    health: Annotated[int, Range(0, 100)] = 100
```

Assignments to undeclared fields are rejected. Assignments of the wrong type are rejected. Everything else is validated against declared constraints.

```python
s = MyState()
s.health = 50      # fine
s.health = 999     # ConstraintError: outside range [0, 100]
s.mood = "happy"   # ConstraintError: field 'mood' is not declared in the schema
s.health = "full"  # ConstraintError: expected int, got str
```

Keyword arguments to the constructor go through the same enforcement:

```python
s = MyState(name="hero", health=75)
```

### Defaults

A default assigned in the class body is written into every new instance as-is. That's fine for immutable values (`int`, `str`, tuples), but if you use a mutable default (`list`, `dict`, `set`) every instance will share the same object — see "Things that might surprise you" below.

For mutable defaults, use `Field(default_factory=callable)`. The factory runs once per instance:

```python
from checked import Checked, Field

class Bag(Checked):
    items:    list = Field(default_factory=list)
    counts:   dict = Field(default_factory=dict)
    defaults: dict = Field(default_factory=lambda: {"verbose": True})
```

`Field` is purely a default-expansion mechanism; constraints still compose with it:

```python
inventory: Annotated[list, MaxLen(3)] = Field(default_factory=list)
```

### Constraint classes

Multiple constraints on one field are all checked on every assignment.

#### `Range(min_val, max_val)`

Value must fall within `[min_val, max_val]` inclusive. Works on any comparable type.

```python
health:     Annotated[int,   Range(0, 100)]   = 100
price:      Annotated[float, Range(0.0, 9.99)] = 1.99
```

#### `OneOf(*values)`

Value must be one of the specified options. Pairs naturally with `Transitions`.

```python
alignment: Annotated[str, OneOf("good", "neutral", "evil")] = "neutral"
```

#### `Immutable`

Once set to a non-`None` value, the field cannot be changed. Use `None` as the default to indicate "not yet set."

```python
took_the_key: Annotated[bool, Immutable] = None

s = MyState()
s.took_the_key = True   # fine — first assignment
s.took_the_key = False  # ImmutabilityError
s.took_the_key = True   # ImmutabilityError — same value still rejected
```

Immutable fields are a good fit for any write-once fact: the player's chosen name, a decision that can't be undone, a flag that records a one-time event.

#### `Transitions(**allowed)`

Declares which state transitions are permitted. The keys are "from" values; the lists are permitted "to" values. An empty list declares a terminal state.

```python
relationship: Annotated[str,
    OneOf("neutral", "friendly", "hostile"),
    Transitions(
        neutral=["friendly", "hostile"],
        friendly=["neutral"],
        hostile=[]             # terminal — no escape
    )] = "neutral"
```

Attempting a transition not listed raises `TransitionError`. Attempting to leave a terminal state raises `TransitionError`. The initial assignment (from the default) is always allowed.

#### `Requires(callable)`

The field may only be set to a truthy value if the callable returns `True` when passed the current state. Setting to a falsy value bypasses the check.

```python
magic_sword: Annotated[bool, Requires(lambda s: s.visited_armory)] = False
```

Named functions work too, and are preferable when the condition is non-trivial:

```python
def has_completed_tutorial(s):
    return s.tutorial_complete and s.move_count > 5

advanced_weapon: Annotated[bool, Requires(has_completed_tutorial)] = False
```

#### `Derived(callable)`

A read-only field computed from other state. The callable receives the state object and returns the current value. Derived fields cannot be assigned to directly.

```python
is_injured:    Annotated[bool, Derived(lambda s: s.health < 50)]
carrying_load: Annotated[int,  Derived(lambda s: sum(item.weight for item in s.inventory))]
```

Derived fields are included in `as_dict()` output but excluded from the writable field set.

#### `MaxLen(max_length)`

The value's `len()` must not exceed `max_length`. Works on lists, strings, dicts, or any sized type.

```python
inventory: Annotated[list, MaxLen(3)]
tags:      Annotated[list, MaxLen(10)]
```

#### `StrLen(min_len, max_len)`

String length must fall within `[min_len, max_len]`. Either bound may be omitted.

```python
username: Annotated[str, StrLen(1, 20)]
bio:      Annotated[str, StrLen(0, 500)]
```

#### `Regex(pattern)`

String value must match the given regular expression (anchored at the start via `re.match`).

```python
postal_code: Annotated[str, Regex(r'^[A-Z]{2}\d{4}$')]
hex_color:   Annotated[str, Regex(r'^#[0-9a-fA-F]{6}$')]
```

### Combining constraints

Any number of constraints can be stacked on one field. All are checked on every assignment.

```python
username: Annotated[str, StrLen(1, 20), Regex(r'^\w+$')]
level:    Annotated[int, Range(1, 99), Immutable]        # can only be set once, within range
```

### Utility methods

#### `as_dict()`

Returns a plain `dict` snapshot of all field values, including derived fields.

```python
s.as_dict()
# {'health': 75, 'alignment': 'neutral', 'is_injured': False, ...}
```

#### `delta(other)`

Returns a dict of fields that differ between two state objects. Values are `(self_value, other_value)` tuples.

```python
a = MyState()
b = a.copy()
b.health = 30

a.delta(b)
# {'health': (100, 30)}
```

Useful for logging, debugging, and computing move objects in history-based systems.

#### `copy()`

Returns a deep copy of the state object. The copy is fully independent — mutations to one do not affect the other.

```python
before = state.copy()
state.health -= 20
before.delta(state)   # {'health': (100, 80)}
```

### Inheritance

Constraints are inherited through the normal MRO. Subclasses can add new fields and constraints without affecting the parent.

```python
class BaseState(Checked):
    health: Annotated[int, Range(0, 100)] = 100

class CombatState(BaseState):
    stamina:  Annotated[int, Range(0, 50)] = 50
    in_combat: bool = False
```

### Custom constraint classes

A constraint is any object with a `validate(field, old_value, new_value, state)` method that raises `ConstraintError` (or a subclass) on violation.

```python
from checked.exceptions import ConstraintError

class Even:
    """Value must be an even integer."""
    def validate(self, field, old_value, new_value, state):
        if new_value is not None and new_value % 2 != 0:
            raise ConstraintError(field, f"{new_value} is not even")

class MultipleOf:
    def __init__(self, n):
        self.n = n
    def validate(self, field, old_value, new_value, state):
        if new_value is not None and new_value % self.n != 0:
            raise ConstraintError(field, f"{new_value} is not a multiple of {self.n}")
```

Zero-argument constraint classes (like `Even` above) can be used bare in annotations without the empty parens:

```python
score: Annotated[int, Even, Range(0, 1000)]
```

### Putting it together

A small adventure-turn example that exercises most of the library at once:

```python
from typing import Annotated
from checked import Checked, ConstraintError, TransitionError, ImmutabilityError
from checked.constraints import (
    Range, OneOf, Transitions, Requires, Derived, Immutable, StrLen,
)

class Adventurer(Checked):
    name:           Annotated[str,  StrLen(1, 20), Immutable] = None
    health:         Annotated[int,  Range(0, 100)] = 100
    visited_armory: bool = False
    has_sword:      Annotated[bool, Requires(lambda s: s.visited_armory)] = False
    status:         Annotated[str,
                        OneOf("exploring", "in_combat", "defeated"),
                        Transitions(
                            exploring=["in_combat"],
                            in_combat=["exploring", "defeated"],
                            defeated=[],
                        )] = "exploring"
    is_injured:     Annotated[bool, Derived(lambda s: s.health < 50)]


hero = Adventurer(name="Valen")
hero.visited_armory = True
hero.has_sword = True          # Requires is now satisfied
hero.status = "in_combat"      # exploring -> in_combat, allowed
hero.health = 30

print(hero.is_injured)         # True  — recomputed each time from health
print(hero.as_dict())
# {'name': 'Valen', 'health': 30, 'visited_armory': True, 'has_sword': True,
#  'status': 'in_combat', 'is_injured': True}

# Illegal moves are rejected before they take effect:
hero.status = "exploring"      # in_combat -> exploring, allowed

try:
    hero.status = "defeated"   # exploring -> defeated is NOT in the table
except TransitionError as e:
    print(f"blocked: {e}")

try:
    hero.name = "Someone Else" # Immutable — set at construction, can't change
except ImmutabilityError as e:
    print(f"blocked: {e}")
```

The important thing to notice: none of the illegal assignments reach storage. After the exceptions are caught, `hero` is exactly as it was before each failed write. This is the core guarantee the library provides — if `hero` exists and passed construction, every field in it satisfies every constraint.

## Exceptions

All exceptions live in `checked.exceptions` and are also importable directly from `checked`.

| Exception | Raised when |
|---|---|
| `ConstraintError` | Base class. General constraint violation, type mismatch, undeclared field. |
| `ImmutabilityError` | An `Immutable` field is assigned after its initial set. |
| `TransitionError` | A `Transitions` constraint rejects the attempted change. |
| `DerivedFieldError` | A `Derived` field is assigned to directly. |
| `InitializationError` | A field is read before being initialized (no default declared). |

All exceptions carry `.field` (the field name) and `.message` (the reason) as attributes, in addition to the standard string representation.

## How it works

In short: a metaclass builds each subclass's schema once at class-definition time, and the base class enforces it on every attribute read and write through a per-instance backing dict — so fields are validated on assignment, not just at construction. You don't need any of this to use the library.

For the full picture, see [ARCHITECTURE.md](ARCHITECTURE.md) (the structure, invariants, and landmines) and [THEORY.md](THEORY.md) (the design rationale and the alternatives that were rejected).

## Things that might surprise you

**`Immutable` with a non-`None` default locks the field forever.**

```python
class Item(Checked):
    level: Annotated[int, Immutable] = 1   # looks sensible, but...

Item().level = 2   # ImmutabilityError — field was locked at level=1
```

`Immutable` treats any non-`None` current value as "already set." For a field you intend to set once at runtime, default to `None`:

```python
class Item(Checked):
    level: Annotated[int, Immutable] = None

item = Item()
item.level = 42    # first set — allowed
item.level = 43    # ImmutabilityError
```

**Mutable class-body defaults are shared across instances.**

This is the familiar Python default-argument pitfall showing up in a new place:

```python
class Bag(Checked):
    items: list = []

a, b = Bag(), Bag()
a.items.append("sword")
print(b.items)      # ['sword'] — same list object!
```

Use `Field(default_factory=...)` to get a fresh value per instance:

```python
from checked import Checked, Field

class Bag(Checked):
    items: list = Field(default_factory=list)

a, b = Bag(), Bag()
a.items.append("sword")
print(b.items)      # [] — independent list
```

The factory is any zero-argument callable, so non-trivial defaults work too:

```python
class Config(Checked):
    settings: dict = Field(default_factory=lambda: {"verbose": True, "retries": 3})
```

**`bool` values pass `int` type checks.**

`isinstance(True, int)` is `True` in Python, so this is accepted silently:

```python
class Counter(Checked):
    value: int = 0

Counter().value = True     # accepted, stored as True
```

Not unique to `checked`, but worth flagging because people forget.

**`Requires` evaluates against the state *before* the pending write.**

```python
class S(Checked):
    visited_armory: bool = False
    magic_sword:    Annotated[bool, Requires(lambda s: s.visited_armory)] = False

s = S()
s.magic_sword = True       # ConstraintError — s.visited_armory is still False
s.visited_armory = True
s.magic_sword = True       # ok
```

This is what you usually want for prerequisites ("has the player already done X?"), but it means a `Requires` condition cannot reference the value being assigned — only the existing state.

**Constructor kwargs are applied in the order you pass them.**

Python 3.7+ preserves dict ordering, and `checked` honors it. When one field's `Requires` depends on another, order matters:

```python
S(magic_sword=True, visited_armory=True)   # ConstraintError — Requires trips first
S(visited_armory=True, magic_sword=True)   # fine
```

**`Regex` is anchored at the start, not the end.**

`Regex` uses `re.match`, which anchors the pattern at the start of the string but not the end. Add `$` if you want full-string matching:

```python
Regex(r'^[A-Z]{2}\d{4}')    # also matches "AB1234EXTRA"
Regex(r'^[A-Z]{2}\d{4}$')   # matches exactly "AB1234"
```

**`Derived` fields recompute on every read.**

There is no caching. Reading `hero.is_injured` twice runs the lambda twice. For cheap lambdas this is fine; for anything expensive, pull the value into a local:

```python
injured = hero.is_injured
if injured: show_injury_ui()
if injured: play_injury_sound()
```

**`as_dict()` shares references with internal state.**

The returned dict is shallow:

```python
d = bag.as_dict()
d['items'].append('bomb')    # this also mutates bag.items — and no constraints run
```

If you need an independent snapshot, wrap with `copy.deepcopy(s.as_dict())`.

## Design notes

The principles behind the design — the schema *is* the specification, constraints compose rather than inherit, `Derived` keeps a computed value in one place, and `None` means "unset" — and the tradeoffs they came from are written up in [THEORY.md](THEORY.md).
