| name | py-form-builder |
| description | Build, edit, or debug Python forms using layrz-forms Form subclasses. Use this for any Python validation work — when defining or modifying Form subclasses, adding BooleanField/CharField/EmailField/IdField/JsonField/NumberField/UuidField fields, implementing clean_* methods, calling is_valid() or ais_valid(), reading .errors or .cleaned_data, integrating with Strawberry GraphQL, or diagnosing error codes. Do NOT use for Go forms — this is Python-specific. |
Python form builder
Minimal form
A form is a class that inherits Form and declares Field instances as attributes. Each field
defines validation rules. Call is_valid() to run validation, then inspect .errors (a
property that auto-validates if not yet run):
from layrz_forms import Form, CharField, NumberField
class UserForm(Form):
name = CharField(required=True)
age = NumberField(datatype=int, required=False)
form = UserForm({'name': 'Alice', 'age': 30})
if form.is_valid():
print(form.cleaned_data)
else:
print(form.errors)
Missing required field:
form = UserForm({'name': 'Bob'})
form.is_valid()
print(form.errors)
Present but invalid:
form = UserForm({'name': 'Charlie', 'age': 'not a number'})
form.is_valid()
print(form.errors)
To serialize errors to JSON, use:
{k: [e.model_dump() for e in v] for k, v in form.errors.items()}
Field names are auto-converted to camelCase: user_age → userAge in error keys.
Field reference
| Field | Constructor Kwargs | Default | Error Codes |
|---|
BooleanField | required | False | required, invalid |
CharField | required, max_length, min_length, empty, regex, choices | required=False, all others None/False | required, invalid, empty, maxLength, minLength, invalidChoice, invalidFormat |
EmailField | required, empty, regex | required=False, empty=False, regex=r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-z]{2,63}$' | required, invalid, empty |
IdField | required | False | required, invalid |
JsonField | required, empty, datatype | required=False, empty=False, datatype=dict | required, invalid |
NumberField | required, datatype, min_value, max_value | required=False, datatype=float, min/max=None | required, invalid, minValue, maxValue |
UuidField | required | False | required, invalid |
BooleanField
Accepts only bool values (or None if not required).
field = BooleanField(required=True)
CharField
Accepts str, Enum, or StrEnum (auto-converted to string). Returns early on non-string type,
then accumulates all length/choice/regex errors in one call.
String lengths are counted by code point (Python len()), not bytes, so non-ASCII characters
count as single characters. If choices is defined, value must be in the first element of each
tuple:
field = CharField(
required=True,
min_length=2,
max_length=10,
choices=(('admin', 'Administrator'), ('user', 'User')),
regex=r'^[a-z]+$',
)
With empty=True, empty string '' is accepted without regex validation. Non-empty strings
are always regex-checked if regex is set.
field = CharField(empty=True, regex=r'^\d+$')
Error codes:
invalid: not a string
empty: empty string when empty=False
minLength: expected is the minimum, received is the actual length
maxLength: expected is the maximum, received is the actual length
invalidChoice: expected is list of allowed choices, received is the value
invalidFormat: expected is the regex pattern, received is the value
EmailField
Validates against a regex (default: ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-z]{2,63}$).
With empty=True, empty string is allowed without regex check. Non-empty strings always
validate against the regex.
field = EmailField(required=True)
field = EmailField(empty=True)
Error codes:
required: value is None and required=True
invalid: not a string or doesn't match regex (both non-empty and custom regex)
empty: empty string when empty=False
IdField
Accepts int or numeric str (e.g., "123"). Rejects bool explicitly (since
isinstance(True, int) is true in Python). Must be > 0.
field = IdField(required=True)
Error code:
required: value is None and required=True
invalid: non-int/str, bool, ≤ 0, or unparseable string
JsonField
Accepts dict or list (type specified by datatype parameter). With empty=False, dict
must have at least one key and list must have at least one element. An absent optional field
(not in the dict at all) produces NO errors.
field = JsonField(datatype=dict, required=False, empty=False)
field = JsonField(datatype=list, empty=True)
Error codes:
required: value is None and required=True
invalid: wrong datatype, or empty dict/list when empty=False
NumberField
Validates using isinstance(value, self.datatype), so int does NOT pass datatype=float.
Rejects bool explicitly. Checks min_value and max_value constraints, emitting both if
violated.
field = NumberField(datatype=float, min_value=0.0, max_value=100.0)
field = NumberField(datatype=int)
Error codes:
required: value is None and required=True
invalid: wrong datatype, bool, or constraint parse error
minValue: expected is min_value, received is the value
maxValue: expected is max_value, received is the value
UuidField
Accepts str or uuid.UUID instance. Strings are validated against UUID format using
Python's uuid.UUID() constructor.
field = UuidField(required=True)
Error codes:
required: value is None and required=True
invalid: not a string/UUID or invalid UUID format
Clean methods
After all field validation, any method starting with clean is auto-discovered and called in
alphabetical order (not declaration order). This allows cross-field validation.
class RegistrationForm(Form):
password = CharField(required=True)
password_confirm = CharField(required=True)
def clean_password_match(self) -> None:
if self._obj.get('password') != self._obj.get('password_confirm'):
self.add_errors(
key='passwordConfirm',
code='mismatch',
extra_args={'message': 'Passwords do not match'},
)
When errors are added in add_errors(), expected and received are lifted into their own
fields; other keys nest under extra. The resulting error:
LayrzError(
code='mismatch',
expected=None,
received=None,
extra={'message': 'Passwords do not match'},
)
Use expected and received when they matter:
def clean_age_range(self) -> None:
min_age = 18
actual = self._obj.get('age')
if actual and actual < min_age:
self.add_errors(
key='age',
code='tooYoung',
extra_args={
'expected': min_age,
'received': actual,
'note': 'Must be 18+',
},
)
Clean methods always see the result of field validation — if a field failed, it is still in
self._obj (the original input), not yet in cleaned_data.
Async clean methods
Use async def clean_* for async validation (e.g., database checks). Must use await form.ais_valid() to wait for them.
async def clean_email_unique(self) -> None:
email = self._obj.get('email')
if email and await db.email_exists(email):
self.add_errors(key='email', code='duplicate')
Reading .errors without awaiting ais_valid() when async clean methods exist raises
RuntimeError:
form = MyForm(data)
await form.ais_valid()
form.errors
If mixing sync and async clean methods, all run within ais_valid() (sync methods are wrapped
in a no-op await asyncio.sleep(0)).
Nested forms and lists
A Form instance as a class attribute is validated recursively; errors are keyed by dot-notation:
class AddressForm(Form):
street = CharField(required=True)
city = CharField(required=True)
class UserForm(Form):
name = CharField(required=True)
address = AddressForm()
form = UserForm({'name': 'Alice', 'address': {'street': '', 'city': 'NYC'}})
form.is_valid()
print(form.errors)
A list attribute whose first element is a Field or Form declares a nested list:
class PhoneListForm(Form):
phones = [EmailField()]
form = PhoneListForm({'phones': ['alice@ex.com', 'invalid', 'bob@ex.com']})
form.is_valid()
print(form.errors)
Similarly for nested forms:
class TeamForm(Form):
members = [AddressForm()]
form = TeamForm({
'members': [
{'street': '123 Main', 'city': 'NYC'},
{'street': '', 'city': 'LA'},
]
})
form.is_valid()
A non-list value supplied where a list belongs emits {'code': 'invalid', 'extra': {'message': 'Invalid data type'}}:
form = PhoneListForm({'phones': 'single@email.com'})
form.is_valid()
print(form.errors)
An empty list attribute is skipped (no validation performed on it).
Errors and serialisation
.errors is a lazy property: the first access triggers sync validation if it has not run yet.
Each error is a LayrzError Pydantic model; calling .model_dump() excludes None by default:
error = LayrzError(code='minLength', expected=5, received=3)
error.model_dump()
error = LayrzError(code='required')
error.model_dump()
To serialize the entire error dict to a plain dict (no Pydantic models):
serialized = {k: [e.model_dump() for e in v] for k, v in form.errors.items()}
To serialize to JSON:
import json
serialized = {k: [e.model_dump() for e in v] for k, v in form.errors.items()}
json_str = json.dumps(serialized)
cleaned_data
Returns a deep copy of the original input dict if validation passed. Mutations to the
returned object at any depth cannot affect the caller's original dict. Deep copying can fail on
non-copyable values; such errors propagate naturally.
form = UserForm({'name': 'Alice', 'address': {'city': 'NYC'}})
form.is_valid()
clean = form.cleaned_data
clean['address']['city'] = 'LA'
If validation failed, cleaned_data still returns the deep copy (garbage-in, garbage-out —
validation failure does not prevent access to the copy).
Strawberry GraphQL
Pass a Strawberry input object directly to Form(obj=...). It is converted internally via
strawberry_to_dict():
import strawberry
from layrz_forms import Form, CharField
@strawberry.input
class UserInput:
name: str
email: str
class UserForm(Form):
name = CharField(required=True)
email = CharField(required=True)
user_input = UserInput(name='Alice', email='alice@ex.com')
form = UserForm(user_input)
form.is_valid()
Reserved names
These names cannot be used as field or nested-form attributes (they collide with Form methods):
add_errors, change_obj, clean, errors, is_valid, ais_valid, set_obj,
calculate_members, cleaned_data.
Declaring one raises an error during discovery.
Common mistakes
Calling .errors() as a method: .errors is a property, not a method. Write form.errors,
not form.errors().
Confusing required and empty: required means the field must be present in the input
dict. empty is only for CharField and EmailField and means an empty string is acceptable.
A missing optional field produces no errors; an empty string in a non-empty field produces
empty, never required.
Expecting an int to satisfy datatype=float: NumberField uses isinstance() strictly.
NumberField(datatype=float) rejects integers; use NumberField(datatype=(int, float)) is not
supported — use datatype=float and handle both in clean methods, or split the form.
Declaring a plain list and expecting it to be a nested form:
class BadForm(Form):
colors = ['red', 'green', 'blue']
Do this instead:
class GoodForm(Form):
colors = [CharField()]
Forgetting await ais_valid(): Reading .errors on a form with async clean methods
without awaiting ais_valid() raises RuntimeError. Always await if any async def clean_* methods are present.
Expecting field validation to short-circuit: All fields are validated, and errors
accumulate. A field that is too short AND fails a regex reports both errors.
Verify
Run tests from python/:
uv run pytest -q
uv run pytest -q -k CharField
uv run ruff check
uv run ty check
uv run pytest --cov=layrz_forms --cov-report=term-missing
The shared vectors at vectors/fields/*.json pin behaviour for all fields. When unsure whether
a value produces an error, grep the vectors:
grep -l 'required\|empty' vectors/fields/*.json
python3 -c "import json;[print(c['name'],c.get('value','ABSENT'),c['expected_errors']) for c in json.load(open('vectors/fields/CharField.json'))]"
Type checking uses ty, not mypy. Suppress with # ty: ignore[rule-code].
See also
See the form-builder router for cross-language contract details and Go form guidance.