Constraint-enforced state objects for Python
Project description
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.
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; for the design rationale and rejected alternatives, see 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:
pip install git+https://github.com/xof/checked
With uv:
uv add "git+https://github.com/xof/checked"
Once the package is published, the usual forms will work:
pip install checked
uv add checked
Hacking on checked itself. Clone the repo and let uv set up the environment:
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.
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.
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:
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:
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:
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.
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.
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."
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.
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.
magic_sword: Annotated[bool, Requires(lambda s: s.visited_armory)] = False
Named functions work too, and are preferable when the condition is non-trivial:
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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:
score: Annotated[int, Even, Range(0, 1000)]
Putting it together
A small adventure-turn example that exercises most of the library at once:
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 (the structure, invariants, and landmines) and 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.
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:
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:
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:
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:
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:
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.
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:
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:
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:
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:
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.
Project details
Release history Release notifications | RSS feed
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 checked-1.0.0.tar.gz.
File metadata
- Download URL: checked-1.0.0.tar.gz
- Upload date:
- Size: 37.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cb9c9e2647e3cdeb9b6d9134c22b27045cde9ffdb21296b2adc36fb9b0f101d1
|
|
| MD5 |
4f599f03e0fd44aca1bbf068dc791582
|
|
| BLAKE2b-256 |
cdc40af7ef15649af0bb9dfc4e1300099d69539556adf0c5f38fd7508e69c40f
|
Provenance
The following attestation bundles were made for checked-1.0.0.tar.gz:
Publisher:
publish.yml on Xof/checked
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
checked-1.0.0.tar.gz -
Subject digest:
cb9c9e2647e3cdeb9b6d9134c22b27045cde9ffdb21296b2adc36fb9b0f101d1 - Sigstore transparency entry: 1902107866
- Sigstore integration time:
-
Permalink:
Xof/checked@61718e395e10af16a3f5c33a6f5b40536f81abec -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/Xof
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@61718e395e10af16a3f5c33a6f5b40536f81abec -
Trigger Event:
release
-
Statement type:
File details
Details for the file checked-1.0.0-py3-none-any.whl.
File metadata
- Download URL: checked-1.0.0-py3-none-any.whl
- Upload date:
- Size: 20.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e50bded8c3538515396a411953efb699096b4b2bef828bef10d47515e4666c88
|
|
| MD5 |
3484895deb7f5a87a03e956de9bfd476
|
|
| BLAKE2b-256 |
c1662b8aa10bdc613a2cf27c895029964103cef97fd9b1552f989f4631926fa4
|
Provenance
The following attestation bundles were made for checked-1.0.0-py3-none-any.whl:
Publisher:
publish.yml on Xof/checked
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
checked-1.0.0-py3-none-any.whl -
Subject digest:
e50bded8c3538515396a411953efb699096b4b2bef828bef10d47515e4666c88 - Sigstore transparency entry: 1902108002
- Sigstore integration time:
-
Permalink:
Xof/checked@61718e395e10af16a3f5c33a6f5b40536f81abec -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/Xof
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@61718e395e10af16a3f5c33a6f5b40536f81abec -
Trigger Event:
release
-
Statement type: