Skip to main content

Layrz Forms (Python)

A form validation library for Python with a simple, flexible API. Validates data from dicts, plain objects, and Strawberry GraphQL inputs. Simpler than Django Forms, yet powerful enough for complex nested forms and custom validation.

Install

pip install layrz-forms

Or with uv:

uv add layrz-forms

Requires Python 3.14+.

Quick Start

import layrz_forms as forms


class ExampleForm(forms.Form):
  """Example form with basic and nested validation."""
  id_test = forms.IdField(required=True)
  email_text = forms.EmailField(required=True)
  json_dict_test = forms.JsonField(required=True, datatype=dict)
  json_list_test = forms.JsonField(required=True, datatype=list)
  int_test = forms.NumberField(required=True, datatype=int, min_value=0, max_value=5)
  float_test = forms.NumberField(required=True, datatype=float, min_value=0, max_value=5)
  bool_test = forms.BooleanField(required=True)
  plain_text = forms.CharField(required=True, empty=False)
  empty_text = forms.CharField(required=True, empty=True)
  range_text = forms.CharField(required=True, empty=False, min_length=5, max_length=10)

  def clean_func1(self) -> None:
    """Cross-field validation (alphabetically first)."""
    self.add_errors(key='clean1', code='error1')
    self.add_errors(key='clean1', code='error2')

  def clean_func2(self) -> None:
    """Cross-field validation (alphabetically second)."""
    self.add_errors(key='clean2', code='error1')


if __name__ == '__main__':
  obj = {
    'id_test': 1,
    'email_text': 'example@goldenmcorp.com',
    'json_dict_test': {'hola': 'mundo'},
    'json_list_test': ['hola mundo'],
    'int_test': 5,
    'float_test': 4.5,
    'bool_test': True,
    'plain_text': 'hola mundo',
    'empty_text': 'hola',
    'range_text': 'hola',  # 4 chars, min_length=5 — error!
  }

  form = ExampleForm(obj)

  print('form.is_valid():', form.is_valid())
  # Output: form.is_valid(): False

  print('form.errors:')
  for key, errors in form.errors.items():
    print(f'  {key}:')
    for e in errors:
      print(f'    code={e.code!r}, expected={e.expected}, received={e.received}')
  # Output:
  #   rangeTextTest:
  #     code='minLength', expected=5, received=4
  #   clean1:
  #     code='error1', expected=None, received=None
  #     code='error2', expected=None, received=None
  #   clean2:
  #     code='error1', expected=None, received=None

  # Serialize errors to JSON
  errors_as_dicts = {k: [e.model_dump() for e in v] for k, v in form.errors.items()}
  print('errors_as_dicts:', errors_as_dicts)
  # Output: errors_as_dicts: {'rangeTextTest': [{'code': 'minLength', 'expected': 5, 'received': 4}], 'clean1': [{'code': 'error1'}, {'code': 'error2'}], 'clean2': [{'code': 'error1'}]}

Field Reference

BooleanField

Validates boolean values.

Parameter Type Default Description
required bool False Whether the field must be present (non-None)

Error Codes:

  • required — Value is None and required=True
  • invalid — Value is present but not a bool

CharField

Validates strings with optional length, choice, and regex constraints.

Parameter Type Default Description
required bool False Whether the field must be present (non-None)
empty bool False Whether an empty string ('') is allowed
min_length int | None None Minimum number of characters
max_length int | None None Maximum number of characters
choices tuple[tuple[str, str], ...] | None None Allowed values as (('value', 'Label'), ...)
regex str | None None PCRE regex pattern to match (checked on non-empty strings)

Error Codes:

  • required — Value is None and required=True
  • invalid — Value is present but not a str (or Enum/StrEnum)
  • empty — Value is '' and empty=False
  • minLength — String length < min_length; expected and received are length values
  • maxLength — String length > max_length; expected and received are length values
  • invalidChoice — Value not in choices; expected is the list of allowed values, received is the value
  • invalidFormat — Value does not match regex; expected is the regex pattern, received is the value

EmailField

Validates email addresses.

Parameter Type Default Description
required bool False Whether the field must be present (non-None)
empty bool False Whether an empty string ('') is allowed
regex str r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-z]{2,63}$' Email regex pattern

Error Codes:

  • required — Value is None and required=True
  • invalid — Value is present but not a str, or non-empty string fails regex match
  • empty — Value is '' and empty=False

IdField

Validates positive integer IDs (can be int or numeric str).

Parameter Type Default Description
required bool False Whether the field must be present (non-None)

Error Codes:

  • required — Value is None and required=True
  • invalid — Value is not an int or numeric str, is bool, or is ≤ 0

JsonField

Validates JSON objects (dicts) or arrays (lists).

Parameter Type Default Description
required bool False Whether the field must be present (non-None)
empty bool False Whether an empty container is allowed
datatype type[list] | type[dict] dict The container type to validate

Error Codes:

  • required — Value is None and required=True
  • invalid — Value is not an instance of datatype, or is empty and empty=False

NumberField

Validates numeric values (int or float, configurable).

Parameter Type Default Description
required bool False Whether the field must be present (non-None)
datatype type[int] | type[float] float The numeric type to validate
min_value float | None None Minimum allowed value
max_value float | None None Maximum allowed value

Error Codes:

  • required — Value is None and required=True
  • invalid — Value is not an instance of datatype, or is bool
  • minValue — Value < min_value; expected and received are converted to datatype
  • maxValue — Value > max_value; expected and received are converted to datatype

UuidField

Validates UUID strings or uuid.UUID instances in any standard format (hyphenated, unhyphenated, braced, URN).

Parameter Type Default Description
required bool False Whether the field must be present (non-None)

Error Codes:

  • required — Value is None and required=True
  • invalid — Value is not a str or uuid.UUID, or string is not a valid UUID

Errors

Form errors are accessed via the form.errors property (not a method), returning dict[str, list[LayrzError]].

form = ExampleForm({'email_text': None})
# Reading .errors triggers lazy validation if not yet done:
print(form.errors)
# Output: {'emailText': [LayrzError(code='required')]}

LayrzError Model

Each error in the list is a Pydantic LayrzError with four fields:

Field Type Description
code str Error code (always set)
expected Any The constraint that was violated (e.g., 5 for min_length); None if not applicable
received Any The offending value; None if not applicable
extra dict | None Additional contextual keys from custom validators (e.g., {'message': '...'}); None if empty

Serializing to JSON

Use .model_dump() to convert errors to plain dicts. It excludes None fields by default:

errors_as_dicts = {k: [e.model_dump() for e in v] for k, v in form.errors.items()}
# {'emailText': [{'code': 'required'}], 'rangeText': [{'code': 'minLength', 'expected': 5, 'received': 4}]}

Custom Errors via add_errors()

Inside a clean_* method, use self.add_errors(key, code, extra_args={...}) to add custom errors. The extra_args dict is processed as follows:

  • Keys expected and received are lifted into their own LayrzError fields
  • All other keys nest under the extra field
def clean_password(self) -> None:
  # Emit a custom error with expected/received:
  self.add_errors(
    key='password',
    code='weak_password',
    extra_args={
      'expected': 'at least 12 chars',
      'received': len(self._obj.get('password', '')),
      'min_entropy': 25,  # Nests under extra
    }
  )
  # Result: LayrzError(code='weak_password', expected='at least 12 chars', received=6, extra={'min_entropy': 25})

snake_case to camelCase Conversion

All field names in error keys are automatically converted from snake_case to camelCase:

snake_case camelCase
id_test idTest
email_text emailText
range_text_test rangeTextTest

For nested forms, each segment is converted independently:

snake_case (nested) camelCase (nested)
address.street_name address.streetName
items.0.name_display items.0.nameDisplay

Clean Methods

Any method starting with clean_ is auto-discovered and invoked after field validation completes. Clean methods run in alphabetical order by method name (e.g., clean_apple, then clean_banana, then clean_zebra). This ordering is stable and intentional—it matches the Go implementation, where declaration order is not available via reflection.

Sync Clean Methods

def clean_password_match(self) -> None:
  """Sync clean method."""
  password = self._obj.get('password')
  password_confirm = self._obj.get('password_confirm')
  if password != password_confirm:
    self.add_errors('password_confirm', 'mismatch')

Async Clean Methods

Async clean methods are supported but only when using await form.ais_valid():

async def clean_username_available(self) -> None:
  """Async clean method."""
  username = self._obj.get('username')
  if username:
    # Check database, API, etc.
    is_taken = await check_username_db(username)
    if is_taken:
      self.add_errors('username', 'already_taken')

Important: If your form has async def clean_* methods, reading .errors without awaiting ais_valid() raises RuntimeError:

form = MyAsyncForm(obj)
# ❌ This raises RuntimeError:
print(form.errors)

# ✅ This is correct:
await form.ais_valid()
print(form.errors)

Nested Forms

Assign a Form instance as a class attribute to nest it. Nested form fields are validated recursively, and error keys are prefixed with the form's name using dot notation:

class Address(forms.Form):
  street_name = forms.CharField(required=True, min_length=5)
  zip_code = forms.CharField(required=True)

class User(forms.Form):
  name = forms.CharField(required=True)
  address = Address()  # Nested form

form = User({'name': 'Alice', 'address': {'street_name': 'oak', 'zip_code': '12345'}})
form.is_valid()
# Errors keyed as: 'address.streetName' (not 'address'), 'address.zipCode'
print(form.errors)
# Output: {'address.streetName': [LayrzError(code='minLength', expected=5, received=3)]}

Lists of Fields or Forms

Declare a list of fields or forms using the special _attrs naming convention. The first element defines the type:

class ShoppingCart(forms.Form):
  items = [ItemForm()]  # List of nested forms
  tags = [forms.CharField()]  # List of char fields

form = ShoppingCart({
  'items': [
    {'name': 'Widget', 'price': 9.99},
    {'name': 'Gadget', 'price': -5},  # Error: negative price
  ],
  'tags': ['electronics', 'sale'],
})
form.is_valid()
# Errors keyed as: 'items.0.name', 'items.1.price', 'tags.2.myError', etc.
print(form.errors)

Current Behavior & Quirks

Only lists whose first element is a Field or Form are treated as nested declarations. Plain lists (e.g., colors = ['red', 'green']) are silently ignored during validation.

If a non-list value is supplied for a declared nested list field, a validation error is emitted:

form = ShoppingCart({'items': 'not-a-list'})
form.is_valid()
print(form.errors)
# Output: {'items': [LayrzError(code='invalid', extra={'message': 'Invalid data type'})]}

Empty list class attributes (e.g., items = []) are silently skipped and produce no errors.

Strawberry GraphQL

Pass a Strawberry input object directly to Form(obj=...). It is automatically converted to a dict via Form.strawberry_to_dict():

import strawberry

@strawberry.input
class UserInput:
  name: str
  email: str

class UserForm(forms.Form):
  name = forms.CharField(required=True)
  email = forms.EmailField(required=True)

# From a Strawberry resolver:
strawberry_obj = UserInput(name='Alice', email='alice@example.com')
form = UserForm(obj=strawberry_obj)
form.is_valid()

cleaned_data

The form.cleaned_data property returns a deep copy of the validated object. Mutations to the returned dict (at any nesting depth) cannot affect the caller's original object:

form = MyForm({'name': 'Alice', 'nested': {'value': 1}})
form.is_valid()

cleaned = form.cleaned_data
cleaned['nested']['value'] = 999  # Does not affect form._obj

Note: Deep copying can fail if the payload contains non-serializable objects (e.g., file handles, custom classes without __deepcopy__); such failures propagate naturally and are not caught.

Differences from the Go Implementation

The Python and Go implementations share the same error codes, validation rules, and cross-language test vectors (96 test cases in ../vectors/fields/). However, they diverge in several aspects:

Aspect Python Go
Field Declaration Class attributes (e.g., name = CharField()) Struct tags (e.g., `layrz:"char,required"`)
Async Support async def clean_* + await form.ais_valid() No async equivalent (all sync)
Nil/None Handling Python uses None for absence; value fields always present Go uses pointers for absence; pointer to pointer is an error
Value Field Semantics N/A; Python doesn't distinguish pointer vs. value Go value fields always present, so required never fires; pointer fields model absence as nil
Boolean in Number Fields Rejected explicitly Rejected explicitly
Clean Method Ordering Alphabetical (intentional, stable) Alphabetical (intentional, stable)
Nested Form Absence Validates against empty dict Skipped entirely (nil pointers produce no errors)

Both implementations are correct and pass their respective test suites. Bugs in one will be fixed in future major releases (Python 4.0.0, Go 1.0.0 when it exists) to bring them into full alignment.

Development

All commands run from the python/ directory.

Install dev dependencies

uv sync --only-group dev

Lint

uv run ruff check

Type Check

Uses ty (not mypy), so type suppressions are # ty: ignore[rule-code]:

uv run ty check

Run Tests

uv run pytest -q

Coverage

The CI threshold is 90%; current coverage is 97%:

uv run pytest --cov=layrz_forms --cov-report=term-missing

Build Distribution

uv run python -m build

Deployment

Releases are tag-triggered via GitHub Actions. To release a new version:

  1. Update version in python/pyproject.toml (e.g., 3.1.0)
  2. Update CHANGELOG.md
  3. Push a tag matching v[0-9]+.[0-9]+.[0-9]+ (e.g., git tag v3.1.0) to the main branch
  4. The workflow in .github/workflows/deploy.yaml builds and publishes to PyPI automatically

License

MIT License. See the repository for details.


Maintained by Golden M with authorization of Layrz LTD.

Metadata

Release files for layrz-forms 3.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for layrz-forms 3.0.0
File Size Uploaded Provenance Actions
layrz_forms-3.0.0.tar.gz 71.9 kB With provenance Details for layrz_forms-3.0.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for layrz-forms 3.0.0
File Interpreter ABI Platform Actions
layrz_forms-3.0.0-py3-none-any.whl Python 3 none any Details for layrz_forms-3.0.0-py3-none-any.whl

Total release size: 94.1 kB

Release files / layrz_forms-3.0.0.tar.gz

Download URL layrz_forms-3.0.0.tar.gz
Size 71.9 kB
Tags Source
SHA-256 checksum
How to use checksums
f6045e37ca0db4b7590c3d0bce9e165b060388fcb9b61d17122badf02e4c5de0
BLAKE2b-256 checksum
How to use checksums
c761df8f966645f8bdc35dd6383a373b2c7b643cf131639b296e2a7b3b87dee9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.7

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 3, 2026.

Transparency log

Release files / layrz_forms-3.0.0-py3-none-any.whl

Download URL layrz_forms-3.0.0-py3-none-any.whl
Size 22.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5d6289f6a594efbbe432844349fd54c8676acd3079796109431f59e4b7e86868
BLAKE2b-256 checksum
How to use checksums
d2b791f1dd0527c5820c7779c931a49dbca39b3e7e7a5b1c08eb0671b29ce314
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.7

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 3, 2026.

Transparency log

Security for layrz-forms 3.0.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for layrz_forms-3.0.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 3, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page