Type-safe AirForm: form.data knows your model#1100
Merged
Conversation
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.
Contributor
There was a problem hiding this comment.
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:
AirFormis nowAirForm[M: BaseModel]with auto-extraction of the model via__init_subclass__, typeddataproperty, and validation state resetto_form()andAirModel.to_form()return types updated toAirForm[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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
AirFormis now generic over the Pydantic model type.AirForm[MyModel]givesform.datafull 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, soclass MyForm(AirForm[MyModel]): passjust works. No separatemodel = MyModelneeded.form.datais a property that raisesAttributeErrorwith a clear message if accessed before successful validation, instead of silently returningNone.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