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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

layrz_forms-3.0.0.tar.gz (71.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

layrz_forms-3.0.0-py3-none-any.whl (22.2 kB view details)

Uploaded Python 3

File details

Details for the file layrz_forms-3.0.0.tar.gz.

File metadata

  • Download URL: layrz_forms-3.0.0.tar.gz
  • Upload date:
  • Size: 71.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for layrz_forms-3.0.0.tar.gz
Algorithm Hash digest
SHA256 f6045e37ca0db4b7590c3d0bce9e165b060388fcb9b61d17122badf02e4c5de0
MD5 f9b9d97de2ea4a05ba9fa2721f884b4c
BLAKE2b-256 c761df8f966645f8bdc35dd6383a373b2c7b643cf131639b296e2a7b3b87dee9

See more details on using hashes here.

Provenance

The following attestation bundles were made for layrz_forms-3.0.0.tar.gz:

Publisher: release.yaml on goldenm-software/layrz-forms

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file layrz_forms-3.0.0-py3-none-any.whl.

File metadata

  • Download URL: layrz_forms-3.0.0-py3-none-any.whl
  • Upload date:
  • Size: 22.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for layrz_forms-3.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 5d6289f6a594efbbe432844349fd54c8676acd3079796109431f59e4b7e86868
MD5 f7c3a56a0c75dda197a7f53e47772cd3
BLAKE2b-256 d2b791f1dd0527c5820c7779c931a49dbca39b3e7e7a5b1c08eb0671b29ce314

See more details on using hashes here.

Provenance

The following attestation bundles were made for layrz_forms-3.0.0-py3-none-any.whl:

Publisher: release.yaml on goldenm-software/layrz-forms

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

3.0.0 This release

2 files

2.2.0

2 files

2.1.11

2 files

2.1.10

2 files

2.1.9

2 files

2.1.8

2 files

2.1.7

2 files

2.1.6

2 files

2.1.5

2 files

2.1.4

2 files

2.1.3

2 files

2.1.2

2 files

2.1.1

2 files

2.1.0

2 files

2.0.1

2 files

2.0.0

2 files

1.0.12

2 files

1.0.11

2 files

1.0.10

2 files

1.0.9

2 files

1.0.8

2 files

1.0.7

2 files

1.0.6

2 files

1.0.5

2 files

1.0.4

2 files

1.0.3

2 files

1.0.2

2 files

1.0.1

2 files

1.0.0

2 files

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