Rebuild plain.forms as a typed validating parser - #128
Draft
davegaeddert wants to merge 40 commits into
Draft
davegaeddert wants to merge 40 commits into
davegaeddert wants to merge 40 commits into
Conversation
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.
This was referenced Sep 18, 2026
# 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.
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.
plain.formsis rebuilt in place as the framework's typed validation primitive. Same package, sameFormname, 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. Nois_valid()mutation, nocleaned_datadict.Invalidcarries the errors as a value plus the raw submitted data for re-rendering. Narrow withisinstanceand you hold the typed form.Field[T]descriptors — a field is a typed reference on the class (ContactForm.emailisField[str]) and the cleaned value on a validated instance (result.emailisstr), via atypes.pyistub overloaded onrequired, the same patternplain.postgresuses.check(), an instance method that returns an errors dict.ModelFormis declared withmodel_field(Model.column)instead ofclass 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 throughcreate_from(form)/update_from(instance, form);validate(instance=)is the update spelling.FormView,CreateView,UpdateView,DeleteVieware gone. Views are explicitget/postonTemplateView/DetailViewusingvalidate_form()andrender_form().DetailView/ListVieware untouched.Invalidas a handler result and maps error codes to response ids through one table;missing_fieldis one output of it.plain-oauthservernever 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 raisedValidationError, as before.DateTimeFieldparses naive input as local wall time and produces an aware datetime; a newField.display(value)(the inverse ofparse()where they differ) renders it back. Ambiguous DST times are rejected.Not in here
ModelFormtype asAny; the forward FK descriptor carries no related-model type parameter. Scalar typing is pinned withassert_type.NullBooleanFieldconsolidation, 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-freeTemplateViewhelpers inplain-templates/plain/templates/views.py) carefully; the migrated forms and views sampled.plain/plain/forms/README.mdand.claude/rules/plain-forms.mddescribe the new API and list the Django/old-API removals under "Differences from Django".Verification
./scripts/fix --checkclean,./scripts/type-validate26/26./scripts/test— 2571 passed, 1 skipped, across every package plus the example appplain-postgres/tests/public/test_modelform_views.py, including the duplicate-value re-render