Skip to content

Rebuild plain.forms as a typed validating parser - #128

Draft
davegaeddert wants to merge 40 commits into
masterfrom
plain-forms
Draft

davegaeddert wants to merge 40 commits into
masterfrom
plain-forms

Conversation

@davegaeddert

Copy link
Copy Markdown
Member

plain.forms is rebuilt in place as the framework's typed validation primitive. Same package, same Form name, different thing: a form stops being a stateful bound object and becomes a validating parser whose result is a typed instance.

What changes

  • Form.validate(data) -> Form | Invalid — a classmethod. No is_valid() mutation, no cleaned_data dict. Invalid carries the errors as a value plus the raw submitted data for re-rendering. Narrow with isinstance and you hold the typed form.
  • Field[T] descriptors — a field is a typed reference on the class (ContactForm.email is Field[str]) and the cleaned value on a validated instance (result.email is str), via a types.pyi stub overloaded on required, the same pattern plain.postgres uses.
  • Cross-field validation is check(), an instance method that returns an errors dict.
  • ModelForm is declared with model_field(Model.column) instead of class Meta: model/fields. model_field() stamps its source column on the form field, so the form derives its model with no second declaration site. Writes go through create_from(form) / update_from(instance, form); validate(instance=) is the update spelling.
  • FormView, CreateView, UpdateView, DeleteView are gone. Views are explicit get/post on TemplateView/DetailView using validate_form() and render_form(). DetailView/ListView are untouched.
  • plain-api accepts an Invalid as a handler result and maps error codes to response ids through one table; missing_field is one output of it.
  • Every framework form, form-view, and admin create/update view is migrated, along with the built-in packages, docs, rules, and the example app. plain-oauthserver never used forms and is untouched.

Behavior kept from master, on purpose

  • ModelForm.validate() still pre-checks unique and FK constraints and reports them as field errors, so a duplicate value re-renders the form instead of raising at write time. One query per constrained model, none when there are no constraints, run against a fresh probe instance rather than the object being edited. The concurrent-submit race stays a raised ValidationError, as before.
  • DateTimeField parses naive input as local wall time and produces an aware datetime; a new Field.display(value) (the inverse of parse() where they differ) renders it back. Ambiguous DST times are rejected.
  • plain-api's public error ids are unchanged.

Not in here

  • FK and M2M fields on a ModelForm type as Any; the forward FK descriptor carries no related-model type parameter. Scalar typing is pinned with assert_type.
  • The 422 status on invalid POSTs, NullBooleanField consolidation, and changed-columns-only updates each ship as their own change.

Review shape

The engine (plain/plain/forms/, plain-postgres/plain/postgres/forms.py, plain-html-free TemplateView helpers in plain-templates/plain/templates/views.py) carefully; the migrated forms and views sampled. plain/plain/forms/README.md and .claude/rules/plain-forms.md describe the new API and list the Django/old-API removals under "Differences from Django".

Verification

  • ./scripts/fix --check clean, ./scripts/type-validate 26/26
  • ./scripts/test — 2571 passed, 1 skipped, across every package plus the example app
  • HTTP-level create/update/delete round-trip tests in plain-postgres/tests/public/test_modelform_views.py, including the duplicate-value re-render

davegaeddert and others added 30 commits May 18, 2026 16:53
Reshapes plain.forms from a Django-forms-style primitive (declaration,
validation, widgets, HTML, and persistence all fused) into a pure, typed
validating parser.

- A Form subclass is a typed schema. Form.validate(data) returns a
  Form | Invalid tagged union, branched with `if not result:`; it never
  raises on invalid input.
- Invalid carries a flat list of Error(message, code, field) — field=None
  is a form-level error. Validator codes are preserved.
- Rendering is a template-layer concern: no BoundField, no render
  interface on Form/Invalid. The old class-based form/CRUD views are
  dropped from plain.templates.views.
- ModelForm (plain.postgres) keeps an opt-in save(); other side effects
  move into package functions the view calls after validate() succeeds.
- plain-passwords, plain-loginlink and plain-support migrate to the
  errors/values view->template contract with explicit templates; their
  merged regression suites pass (23 tests).

Admin, the example app, and the forms docs are not yet migrated.
FormDisplay/FieldDisplay are the opt-in template adapter, keeping the core types render-agnostic. Form.apply_to is removed — persistence goes through ModelForm. Adds the plain.forms public test suite.
Fields are declared name = model_field(Model.column); create_from() and update_from() persist a validated result, and update_from() leaves a blank database-default column (generate/create_now) for the database to fill. Replaces the class Meta ModelForm and its CRUD-view roundtrip tests with a direct test_modelform.py suite.
AdminCreateView/AdminUpdateView validate and persist via create_from()/update_from(); the form-element templates render through FieldDisplay.
plain-passwords, plain-support, plain-flags, plain-redirection, and plain-loginlink — forms, views, admin, and templates onto Form/FormDisplay and the ModelForm model_field()/create_from() API.
Contacts, notes, and tasks — forms, views, templates, and the tasks JSON API — onto Form/FormDisplay, ModelForm, and create_from()/update_from(). Adds example/tests/test_forms.py.
Rewrites the forms README, adds the plain-forms agent rule, and points the plain-templates rule at FormDisplay.
_get_all_names only recognized a list __all__, so plain docs --api returned nothing for modules that declare it as a tuple (e.g. plain.forms).
A generate/create_now column is never user input — _modelfield_to_formfield now returns None for it, so model_field rejects it with a clear error. That drops the auto_filled branch and lets update_from go back to a plain copy-and-save (the DATABASE_DEFAULT sentinel patch is no longer needed).

Smaller cleanups: privatize _modelfield_to_formfield, give Field a default choices so FieldDisplay drops a getattr, and use isinstance(MultiValueDict) instead of hasattr duck-typing.
plain-templates drops the removed FormView and Create/Update/Delete object views; plain-postgres, plain-admin, plain-support, and plain-api swap the old class Meta ModelForm and is_valid()/save() for model_field() and validate() / create_from() / update_from().
# Conflicts:
#	plain-postgres/plain/postgres/forms.py
#	plain/plain/cli/llmdocs.py
#	plain/plain/forms/boundfield.py
#	plain/plain/forms/fields.py
#	plain/plain/forms/forms.py
- ChoiceField/MultipleChoiceField/BooleanField now run their extra
  validators in clean(), matching every other field.
- Form reserves the _frozen name at class declaration so a user field
  can't silently lose its cleaned value.
- FormDisplay rejects errors that reference an unknown field, surfacing
  view->template typos that previously vanished, and caches FieldDisplay
  bindings so repeated form.field access in templates is free.
- ModelChoiceField.choices iterates queryset.all() to avoid populating
  a shared _result_cache on the class-level field instance.
- update_from() leaves DB-default columns alone when the submitted
  value is empty, so the DATABASE_DEFAULT sentinel survives.
- get_password_errors() returns list[Error] carrying the validator's
  own code (e.g. "password_too_short") instead of a list of strings, so
  callers can branch on which rule failed. The two views drop their
  hardcoded code="invalid" rewrap.
- New public tests pin two behaviors:
  - Form.__eq__/__hash__: two validate() results from identical input
    compare equal and hash equal, including for multi-value fields.
  - ModelForm.with_querysets() chains — re-scoping narrows from the
    parent scope and does not mutate the original class.
The JSON section noted that Errors are structured but didn't show the
wire shape. Add a JsonResponse example, the response body it produces,
and a note on which fields a client branches on.
Type information from Form.validate() used to die at the FormDisplay
boundary — FormDisplay stored dict[str, Field[Any]] and FieldDisplay
exposed value: Any, so a consuming template (especially a statically
type-checked one) had no way to know what form it was holding.

- FormDisplay[F: Form] carries the form class through; FormDisplay(MyForm)
  is inferred as FormDisplay[MyForm].
- FieldDisplay[T] carries the cleaned value type.
- Indexing by a Field reference (form[ContactForm.email]) returns a typed
  FieldDisplay[str]; string-keyed access and attribute access stay
  FieldDisplay[Any] since Python's type system can't dispatch attribute
  lookup by literal name without per-form stubs.
- A `form_class` property exposes the source form for introspection;
  `Field in form` membership now also works in addition to `str in form`.

Public tests pin the typed pathway and verify the cache identity.
The validate-then-render-on-failure pattern was three identical lines
in every view that takes a form (notes, contacts, tasks, admin
create/update, loginlink, passwords x5). Two helpers on TemplateView
absorb it without bringing FormView back:

- render_form(form_class, ...) wraps self.render(form=FormDisplay(...))
  for the GET/blank, pre-filled, and hand-built-rejection cases. Removes
  FormDisplay from view import lists.
- validate_form(form_class) -> F | Response is the POST shortcut: reads
  form_data + files, returns the typed form on success or a re-rendered
  Response (via render_form) on failure. Centralizes the files= propagation
  so a FileField form can't lose uploads to a forgotten kwarg.

The helpers are stateless — no form_class attribute on the view, no
form_valid, no success_url, no auto-dispatch. Handlers still write their
own get/post. Anything not POST-shaped (query_params, json_data, the
HTMX action-handler pattern) drops to the explicit MyForm.validate(...).

Net effect across the converted views: -48 caller-side lines, FormDisplay
no longer imported in 5 of 6 view files. The biggest savings are in the
mid-flow error renders (passwords' incorrect_password / invalid_login /
get_password_errors paths) — those went from 7-line FormDisplay blocks
to 4-line render_form calls.
…n helpers

The wrapper classes existed to bundle value/errors/metadata for each field,
but their attribute access (`form.email.value`) couldn't be type-narrowed
without per-form codegen or a type-checker plugin. Three free helpers —
`field_value(form, ContactForm.email)`, `field_errors`, `form_errors` —
dispatch typing through the `Field[T]` reference, so the cleaned-value
type rides into the template engine without any opt-in.

Templates now receive `form_class` (for metadata: `.required`, `.choices`,
`.html_id`, `.name`) and `form` (a `Form | Invalid` for value/errors via
the helpers). `Field` gains `html_id` directly; the FormDisplay wrapper,
its caching, and the per-form subclassing pattern all go away.
The helpers exist for typing-by-default — `field_value(form, ContactForm.email)`
returns `str | None` instead of `Any`. Without an explicit pin, a regression
that collapses the return type back to `Any` would pass tests silently.

`assert_type` runs as a no-op at runtime and is enforced by `ty` when
`./scripts/fix --check` runs over the suite — covering field_value's
generic dispatch, field_errors' uniform return shape, Field reference
typing at class access, cleaned-value narrowing on validated Forms,
and Invalid arm narrowing via isinstance.
Reconcile the forms rebuild on this branch with master's overlapping changes:

- master replaced Model.save() with explicit create()/update()/build();
  migrated the branch's persist paths onto it:
  - postgres ModelForm helpers: create_from() now create(), update_from()
    now update() (split via a shared _apply_result helper)
  - set_user_password(), contacts/tasks views, ModelForm tests
- master removed obj.<fk>_id access; ModelForm.initial_from() and the
  example test now read obj.<fk>.id
- master dropped constraint pre-checking from full_clean() (the DB enforces
  constraints, mapped to ValidationError at write time); rebuilt
  test_constraint_violation_error on master's version, dropping the tests
  that routed constraints through the removed ModelForm(class Meta)/is_valid
  API and keeping the constraint/mapper mechanics
- master deleted plain-support, plain-observer, plain-pageviews; accepted
  the deletions over the branch's forms migration of those packages
- kept the branch's rebuilt forms API (validate()/Invalid, model_field,
  typed render helpers) over master's old FormView/CreateView/UpdateView/
  DeleteView and ModelForm.save()
Pull in the latest master (postgres FK scaffolding collapse, db_constraint
removal, preflight split, releases). Only plain-postgres/forms.py conflicted:
master's change touched its superseded WIP ModelForm, so kept forms-rebuild's
rebuilt model_field()-based forms.py. master's FK/model-layer changes
auto-merged into base.py/related.py.
Brings 322 commits of master onto the rebuilt forms branch. 30 files
conflicted. The branch's forms design wins for forms/views/fields;
master wins elsewhere; master's intent is carried onto the new API
where the two overlapped.

Notable resolutions:

- plain/plain/forms/{__init__,fields,forms}.py, README.md — branch
  rewrite kept whole. plain/plain/forms/exceptions.py stays deleted
  (FormFieldMissingError's only caller, plain-api/views.py, no longer
  needs it). Carried master's UUIDField whitespace strip (f00e707).
- .claude/rules/plain-forms.md (both copies) — branch rule kept; master
  added a rule for the old API in the same file.
- plain-templates/views.py — dropped master's FormView/CreateView,
  kept master's status_for_exception handle_exception and paginated
  ListView, fixed __all__.
- plain-postgres/forms.py — branch rewrite kept; _apply_result now
  reads _model_meta.fields (master renamed concrete_fields).
- plain-passwords — branch forms/views kept, with master's explicit
  RedirectResponse status_code and UTC-aware token timestamps.
- plain-loginlink — branch forms/links kept; adopted master's
  redirect_to_next_url for an already-logged-in user on the login page
  (#79) instead of sending them to the "sent" page.
- plain-admin, plain-flags, plain-api, example/ — Meta-based ModelForms
  in code and docs rewritten as model_field(); kept master's ListView
  pagination, tuple url/field attrs and explicit redirect statuses.
- plain-redirection — removed, per master.
- plain-postgres tests — examples/urls.py, examples/views.py,
  examples/forms.py and test_modelform_roundtrip.py stay deleted (the
  branch replaced them with tests/public/test_modelform.py); adopted
  master's capture_queries fixture in
  test_constraint_violation_error.py.
Master added a link_expires_in knob on LoginLinkForm (927bcd1). The
branch moved sending out of the form into send_login_link(), so the knob
now lives on LoginLinkFormView and is passed through. README updated to
match: the expiration, email-customization and success-URL examples all
used form hooks that no longer exist.
Carries master's d9406df narrowing (get_forward_field + ColumnField
isinstance) into get_password_errors, which is where the branch moved
the model-field clean() that used to live in PasswordSetForm.
Follows the previous commit — master's test app set link_expires_in on a
LoginLinkForm subclass; the knob now lives on LoginLinkFormView.
Master made RedirectResponse.status_code a required keyword (caa718b).
The views the forms rebuild rewrote still called it positionally.
- default_validators is a tuple, matching plain.postgres column fields
  and master's own forms fields (f52e18f); Field.choices declares a
  Sequence so the class-level default can be an empty tuple.
- DateTimeField.parse reuses timezone.naive_datetime_from_date() for a
  date input instead of building the datetime inline.
- Timezone-aware datetimes and ClassVar annotations in the tests the
  rebuild added, plus timezone.localtime() in the example task form.
model_field[T](column: T) bound T to the *field object* (Note.title is a
TextField[str]), so result.title typed as TextField[str] rather than str
— the opposite of what the ModelForm docs promise. Binding through
ColumnField[T] fixes scalar columns; FK/M2M keep the Any fallback
because the forward descriptors carry no related-model type yet.
- The tuple rule cited 'fields in a form Meta'; the rebuild has no Meta.
- The postgres README still said a ModelForm pre-checks constraints in
  _post_clean and writes via form.create()/form.update(). The rebuilt
  ModelForm never pre-checks and never writes — create_from()/
  update_from() do the write and the database enforces the constraint.
Import grouping and line wrapping only, from ./scripts/fix.
The forms rule and README both advertised a render_form(errors=[...])
kwarg. render_form has no such parameter — **context would swallow it
into the template context silently. Point at the Invalid(...) result
instead, which is what the docstring and plain-passwords already use.
The rebuild dropped master's from_current_timezone/to_current_timezone
(they lived at the bottom of plain/plain/forms/fields.py), leaving
DateTimeField parsing to a naive datetime. plain.postgres passes a naive
datetime straight through to a timestamptz column, so a form-submitted
time was being written with no explicit zone.

Both helpers are back in plain.forms. Parsing reads a naive input as
local wall time; an ambiguous or imaginary DST time is rejected with
code 'ambiguous_timezone' rather than guessed.

Rendering needed a hook the rebuild had no equivalent for -- master used
BoundField.value() -> prepare_value(). Field.display() is that hook: the
inverse of parse() where the two differ, identity everywhere else, and
field_value() calls it on the success arm so an aware value from the
database renders as the wall time the user typed.
Master's ModelForm ran validate_constraints() in _post_clean so one
submission surfaced every violation at once. The rebuild dropped it,
which left a duplicate unique value escaping create_from()/update_from()
as an uncaught ValidationError -- a 500 in the admin where master
re-rendered the form with a field error.

ModelForm.validate() now pre-checks the model's constraints against a
constructed-but-unsaved probe instance and folds the errors into the
Invalid it already returns. Routing matches master: a single-field
unique lands on its field, a composite one is form-level (field=None).
FK existence was already covered -- ModelChoiceField.clean() raises
invalid_choice for a missing row.

Supporting pieces:

- model_field() stamps the column it derived from onto the form field,
  so ModelForm.model() can name its model. The rebuilt form has no
  Meta.model, and a second place to declare it would be a second place
  to get wrong. A form mixing two models is now a TypeError.
- validate(instance=) excludes the row being edited from the uniqueness
  lookup -- master's instance kwarg in the new shape. TemplateView's
  validate_form(instance=) forwards it; the three update views pass it.
- The probe is always a fresh instance with the edited row's identity
  borrowed, never the caller's object -- a failed validate must not
  leave a half-assigned model behind. Master mutated self.instance here.
- A field that failed to clean is excluded, so a constraint over a bad
  value is skipped rather than double-reported (master recomputed
  _get_validation_exclusions for the same reason).

Costs one SELECT per constrained model per validate, and none for a
model with no constraints. The race master had remains: the pre-check
is not a lock, and a write that loses the race still raises
ValidationError through the IntegrityError mapping.
Master produced error_id "missing_field" from FormFieldMissingError,
which the rebuild deleted along with the rest of the exception-driven
form layer -- the id silently vanished from a public error contract.

Rather than reconstruct the exception, APIView now accepts an Invalid as
a handler result and renders it. The response id comes from the error
codes the form already reports: a body whose errors are all "required"
is missing_field, anything else is validation_error. One table, and
missing_field is one output of it rather than a special case.

This also gives plain-api a validation story it was missing since the
rebuild -- a handler returns what validate() returned and the error body
is the same shape as every other API error, errors[] included.
The overload bug fixed in 47534b3 had nothing asserting it, which is
why it survived the rebuild: model_field[T](column: T) typed
result.name as TextField[str] rather than str and every test still
passed. These assert_type checks are verified by ty over the suite, so
a regression is a type-check failure rather than silence.

Covers each scalar kind FormsExample declares, a nullable column
keeping its `| None`, and the class-attribute face (Field[T], the
metadata properties, and field_value() narrowing through it).
The rebuild deleted tests/app/examples/{urls,views,forms}.py and
test_modelform_roundtrip.py, which were master's only view-layer
coverage of a ModelForm. test_modelform.py replaced the direct-call half
but nothing drove a form through a real request, so the render ->
submit -> re-render -> write path was untested.

app/examples/views.py is back, written the way the framework works now:
explicit get/post on a TemplateView/DetailView rather than the generic
FormView master had. tests/public/test_modelform_views.py drives create,
update and delete with plain.test.Client and asserts the rows.

Includes the case that motivated the constraint pre-check: posting a
duplicate re-renders the form with "already exists" and a 200, where
before the rebuild's create_from() would have raised ValidationError out
of the view as a 500.
Master's 4c1108a settled where Django differences live: a
'## Differences from Django' section in each package rule. plain.forms
was the one package rule not following it, which is backwards given it
is the package that diverges most.

The 'Removed in the rebuild' list becomes that section, rewritten as
old -> new pairs rather than bare names, so an agent that reaches for
cleaned_data or ModelForm.Meta is told what to use instead. Also picks
up what the last few commits changed: validate_form(instance=), the
constraint pre-check, aware datetimes, returning an Invalid from an
APIView, and the Invalid(...) idiom in place of a render_form errors=
argument that never existed.
The docs advertised a render_form(errors=...) argument that does not
exist; 1f6d031 removed the claim but left nothing in its place. Show
the authentication-rejection case in full, since that is the one place
a view builds an Invalid by hand.
# Conflicts:
#	plain-postgres/tests/internal/test_db_expression_defaults.py
#83 gave every model column a `Field[T]` annotation, which is what
synthesizes the typed constructor. That made the `ColumnField[T]` overload
on `model_field()` stop matching -- a column reference is typed by its
annotation, so every `model_field(Model.column)` fell through to the
`Any` overload and nothing downstream type-checked. Match the base
`Field[T]` instead.

Also narrows an optional foreign key in the example suite, which the same
annotations made visible.
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.

2 participants