Skip to content

Type-safe AirForm: form.data knows your model#1100

Merged
pydanny merged 4 commits intomainfrom
typesafe-airform
Mar 17, 2026
Merged

Type-safe AirForm: form.data knows your model#1100
pydanny merged 4 commits intomainfrom
typesafe-airform

Conversation

@audreyfeldroy
Copy link
Member

Summary

  • AirForm is now generic over the Pydantic model type. AirForm[MyModel] gives form.data full type information after validation, with autocomplete and typo detection in editors. No other Python form system does this.
  • __init_subclass__ auto-extracts the model from the type parameter, so class MyForm(AirForm[MyModel]): pass just works. No separate model = MyModel needed.
  • form.data is a property that raises AttributeError with a clear message if accessed before successful validation, instead of silently returning None.
  • validate() now resets state between calls so stale data never leaks through.

How it works

```python
class JeepneyRouteModel(BaseModel):
route_name: str
origin: str
destination: str

class JeepneyRouteForm(air.AirForm[JeepneyRouteModel]):
pass # model auto-set from type parameter

form = JeepneyRouteForm()
form.validate({"route_name": "01C", "origin": "Antipolo", "destination": "Cubao"})

if form.is_valid:
form.data.route_name # editor knows this is str
form.data.orign # typo caught by type checker
```

Test plan

  • 460 tests pass
  • ruff check and format clean
  • ty check clean (changing data from Any to M caught 18 latent type errors in examples)
  • New tests: generic extraction, data-before-validation, data-after-failed-validation, explicit model priority, revalidation state reset, multi-level inheritance, to_form() data access, AirModel.to_form() data access

AirForm is now generic over the model type. Users write
AirForm[MyModel] and get typed form.data after validation,
with full autocomplete and typo detection in their editor.

Key design decisions:

- __init_subclass__ reads the type parameter at class creation
  time and sets the model automatically, so AirForm[MyModel]
  is the only declaration needed (no separate model = MyModel).
  Explicit model assignment in the class body still takes
  priority.

- form.data is a property returning M, not M | None. Accessing
  it before validation or after failed validation raises
  AttributeError with a clear message. This avoids forcing users
  to narrow with assert or is-not-None checks after every
  is_valid guard.

- to_form() and AirModel.to_form() carry the generic parameter
  through, so the factory path is also type-safe.

When data changed from Any to M, ty immediately found 18 type
errors across the example files that had been invisible before.
The docs now explain what the type parameter does, how the
auto-extraction works, and how this compares to Django and
WTForms.
Each call to validate() now resets _data, is_valid, and errors
before running Pydantic validation. Previously a successful
validation followed by a failed one would leave is_valid as True
and form.data returning the stale model from the first call.

Five new tests from reviewer feedback cover the paths that
caught this: re-validation state reset, multi-level class
inheritance, to_form() data access, and AirModel.to_form()
data access. The existing validate test now asserts that errors
is None after successful re-validation (it previously asserted
the stale errors as expected behavior).
The previous commit broadened the intro to say "Pydantic models"
when AirModel is the recommended base class and provides the
to_form() convenience method. The lead example now uses AirModel
to match. Plain BaseModel still works and the docs mention that.
Restructured based on review feedback. The page now opens with
a complete contact form (GET renders, POST validates, errors
re-render with preserved values) so readers see the full
workflow before the breakdown.

Key changes to the structure:

- Lead with an end-to-end example instead of a raw Starlette
  form that didn't use AirForm at all.

- Document from_request, which was completely absent despite
  being the primary pattern for POST handlers.

- Reconcile the three ways to create a form (subclass,
  AirModel.to_form, air.to_form) in one section that explains
  when to use which.

- Show render() output before and after failed validation as a
  side-by-side pair so readers see how error display works.

- Explain that AirField wraps pydantic.Field, so users know
  min_length, max_length, gt, etc. all work alongside the
  HTML-specific parameters.

- Document the includes parameter for rendering field subsets.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR makes AirForm generic over a Pydantic model type parameter, so form.data carries full type information after validation. It adds __init_subclass__ to auto-extract the model from the type parameter, converts data from a plain attribute to a property that raises AttributeError before successful validation, and resets state between validate() calls.

Changes:

  • AirForm is now AirForm[M: BaseModel] with auto-extraction of the model via __init_subclass__, typed data property, and validation state reset
  • to_form() and AirModel.to_form() return types updated to AirForm[M] / AirForm[Self]
  • Documentation rewritten with a complete example-driven walkthrough; tests updated and expanded

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
src/air/forms.py Made AirForm generic, added __init_subclass__ for model extraction, data property, and validation reset
src/air/models.py Updated to_form() return type to AirForm[Self]
tests/test_forms.py Renamed test models, added 8 new tests for generic extraction, data access, revalidation, inheritance
docs/learn/forms.md Complete rewrite with structured sections and type-safe examples

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@pydanny pydanny merged commit 1fc6734 into main Mar 17, 2026
12 checks passed
@pydanny pydanny deleted the typesafe-airform branch March 17, 2026 10:27
audreyfeldroy added a commit that referenced this pull request Mar 17, 2026
AirModel extends Pydantic's BaseModel directly and auto-registers
subclasses via __init_subclass__. Each AirDB instance exposes a
db.Model property whose subclasses register only on that instance,
so test databases and production databases never share tables.

Key design decisions:
- __init_subclass__ replaces the _TableMeta metaclass. The base class
  can't register itself because __init_subclass__ only fires for
  subclasses — structural safety, not conditional checks. Air's own
  BaseTag already uses this pattern for tag registration.
- Scoped models pass _scoped=True to skip the global registry, then
  define their own __init_subclass__ to register on the owning AirDB.
- air.AirModel now exports from air.db (the ORM), not air.models
  (the forms helper). Form generation uses the standalone air.to_form()
  function, which accepts any Pydantic BaseModel — no special base
  class needed.

The forms-only AirModel in air/models.py still exists for backward
compatibility. Removing it is a separate pass after PR #1100 lands.
audreyfeldroy added a commit that referenced this pull request Mar 17, 2026
PR #1100 added test_airmodel_to_form_generic_data_access using
air.AirModel.to_form(). Since air.AirModel is now the ORM base
(no .to_form()), the test uses the standalone air.to_form() function
with a plain BaseModel, consistent with the other form tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants