wtforms-htmx 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
wtforms_htmx/form.py ADDED
@@ -0,0 +1,761 @@
1
+ """Form mixin: request dispatch and partial rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from typing import Any
7
+ from typing import TYPE_CHECKING
8
+
9
+ from markupsafe import Markup
10
+ from wtforms import Form
11
+ from wtforms.fields import FieldList
12
+ from wtforms.fields import FormField
13
+ from wtforms.fields.choices import SelectFieldBase
14
+ from wtforms.widgets import html_params
15
+ from wtforms.widgets import ListWidget
16
+ from wtforms.widgets import Select
17
+
18
+ from .headers import _parse_request
19
+ from .meta import _is_source_posted
20
+ from .meta import HtmxMeta
21
+ from .partial import _compute_partial
22
+ from .partial import _HtmxMutation
23
+ from .tree import _classify_target
24
+ from .tree import _resolve_field
25
+ from .tree import _resolve_field_by_id
26
+ from .tree import _root_of
27
+ from .tree import _state
28
+ from .tree import _walk_fields
29
+ from .triggers import _TriggerEntry
30
+
31
+ if TYPE_CHECKING:
32
+ from wtforms.fields import Field
33
+
34
+
35
+ def _is_submit_source(field: Any) -> bool:
36
+ """Whether ``field`` submits the form on click.
37
+
38
+ True when its widget, or the option widget of a choices field, is in
39
+ ``meta.htmx_submit_widgets``.
40
+ """
41
+ submit_widgets = getattr(field.meta, "htmx_submit_widgets", ())
42
+ if not submit_widgets:
43
+ return False
44
+
45
+ if isinstance(field, SelectFieldBase):
46
+ return isinstance(field.option_widget, submit_widgets)
47
+
48
+ return isinstance(field.widget, submit_widgets)
49
+
50
+
51
+ def _iter_submit_sources(form: Any) -> Any:
52
+ """Yield the submit-source fields of the whole form tree, nested ones included."""
53
+ return (field for field in _walk_fields(form) if _is_submit_source(field))
54
+
55
+
56
+ def _resolve_field_by_datalist_id(container: Any, datalist_id: str):
57
+ """Find the field whose bound DataList has the id ``datalist_id``.
58
+
59
+ Match the DataList's actual id rather than a ``-datalist`` suffix.
60
+ """
61
+ for field in _walk_fields(container):
62
+ datalist = getattr(field, "_datalist", None)
63
+ if (
64
+ datalist is not None
65
+ and not isinstance(datalist, str)
66
+ and datalist.id == datalist_id
67
+ ):
68
+ return field
69
+
70
+ return None
71
+
72
+
73
+ def _resolve_source_field(container: Any, element_id: str | None) -> Field | None:
74
+ """Find the field that owns the element whose id is ``element_id``.
75
+
76
+ Exact ids first, which is what a FieldList entry matches. Then a single
77
+ trailing ``-<n>`` is trimmed, so a choice option (``color-0``) routes to
78
+ its field. The trim never repeats.
79
+ """
80
+ if not element_id:
81
+ return None
82
+
83
+ if (field := _resolve_field_by_id(container, element_id)) is not None:
84
+ return field
85
+
86
+ prefix, separator, tail = element_id.rpartition("-")
87
+ if not separator or not tail.isdigit():
88
+ return None
89
+
90
+ return _resolve_field_by_id(container, prefix)
91
+
92
+
93
+ def _add_trigger_source(
94
+ target: Any, source_id: str, source_name: str, entry: Any
95
+ ) -> None:
96
+ """Append ``(source_id, source_name, entry)`` to ``target._htmx_trigger_sources``."""
97
+ incoming = _state(target, "_htmx_trigger_sources")
98
+ if incoming is None:
99
+ incoming = []
100
+ target._htmx_trigger_sources = incoming
101
+
102
+ incoming.append((source_id, source_name, entry))
103
+
104
+
105
+ def _inline_validate(form: Any, _source: Any, _target: Any) -> None:
106
+ """Inline-validation callback: validate the whole form.
107
+
108
+ It leaves ``target.data`` untouched, so the field keeps what the user
109
+ typed.
110
+ """
111
+ form.validate()
112
+
113
+
114
+ def _inline_validation_fields(fields: Any) -> Any:
115
+ """Yield the plain fields of ``fields``, and the rows of their FieldLists."""
116
+ for field in fields:
117
+ if isinstance(field, FieldList):
118
+ yield from _inline_validation_fields(field.entries)
119
+ elif not isinstance(field, FormField):
120
+ yield field
121
+
122
+
123
+ _HTMX_TARGET_KEYWORDS = frozenset({"this", "next", "previous", "body"})
124
+
125
+
126
+ def _button_attrs(form: Any, holder: Any, target_name: str) -> dict[str, Any]:
127
+ """Return the htmx attributes of a button that fires ``holder``'s mutation."""
128
+ host = getattr(holder, "_form", None) or form
129
+ kind, target = _classify_target(host, _root_of(form), target_name)
130
+ # A field becomes ``#<id>``; anything else is an htmx selector, used verbatim.
131
+ hx_target = f"#{target.id}" if kind == "field" else target_name
132
+ return {
133
+ "formnovalidate": True,
134
+ form.meta._htmx_request_attr: form.meta.htmx_endpoint,
135
+ "hx-target": hx_target,
136
+ "hx-swap": "outerHTML",
137
+ # htmx only adds the enclosing form for verbs other than GET/DELETE.
138
+ "hx-include": "closest form",
139
+ }
140
+
141
+
142
+ def _warn_unbound_targets(holder: Any, targets: list[tuple]) -> None:
143
+ """Warn about Python names that match no field and are not htmx keywords."""
144
+ for target_name, _entry, kind, _val in targets:
145
+ if kind == "unbound" and target_name not in _HTMX_TARGET_KEYWORDS:
146
+ warnings.warn(
147
+ f"{holder.name!r} triggers {target_name!r}, which matches no field. "
148
+ "It is used as a CSS selector. Check the field name, or write "
149
+ "the selector with a '#', '.' or other CSS syntax.",
150
+ stacklevel=2,
151
+ )
152
+
153
+
154
+ def _wire_button(holder: Any, form: Any, triggers: dict[str, Any]) -> None:
155
+ """Give a button the htmx attributes of its first target.
156
+
157
+ Raise ValueError when a declaration sets ``on=``: a button fires on click.
158
+ A choices field renders its buttons itself, with ``htmx_submit_attrs``.
159
+ """
160
+ if any(entry.event for entry in triggers.values()):
161
+ raise ValueError(
162
+ f"{holder.name!r} is a button: it fires on click, so on= does not apply."
163
+ )
164
+
165
+ if not isinstance(holder, SelectFieldBase):
166
+ attrs = _button_attrs(form, holder, next(iter(triggers)))
167
+ holder.render_kw = {**attrs, **(holder.render_kw or {})}
168
+
169
+
170
+ def _collect_triggers(holder: Any, form: Any) -> None:
171
+ """Record ``holder.triggers(...)`` declarations on their targets.
172
+
173
+ A field target gets an entry in ``_htmx_trigger_sources``. A selector
174
+ target stays on the source, in ``_htmx_selector_target``. A button sends
175
+ its own request: it gets its htmx attributes instead. Raise ValueError
176
+ when the source declares more than one selector target.
177
+ """
178
+ triggers = _state(holder, "_htmx_triggers")
179
+ if not triggers:
180
+ return
181
+
182
+ root = _root_of(form)
183
+ targets = [
184
+ (target_name, entry, *_classify_target(form, root, target_name))
185
+ for target_name, entry in triggers.items()
186
+ ]
187
+ _warn_unbound_targets(holder, targets)
188
+ selectors = [name for name, _entry, kind, _val in targets if kind != "field"]
189
+ if len(selectors) > 1:
190
+ raise ValueError(
191
+ f"{holder.name!r} declares several selector targets "
192
+ f"({', '.join(selectors)}). Declare one, and return the other "
193
+ "elements as out-of-band extras from its callback."
194
+ )
195
+
196
+ if _is_submit_source(holder):
197
+ _wire_button(holder, form, triggers)
198
+ return
199
+
200
+ for target_name, entry, kind, val in targets:
201
+ if kind == "field":
202
+ _add_trigger_source(val, holder.id, holder.name, entry)
203
+ else:
204
+ # "selector", or a name matching no field: hand it to htmx
205
+ # verbatim so the source carries its own hx-target.
206
+ holder._htmx_selector_target = (target_name, entry)
207
+
208
+
209
+ def _check_selector_sources(form: Any) -> None:
210
+ """Raise ValueError when a field has a selector target and listens to another field.
211
+
212
+ Its requests could not both reach the selector and re-render the field.
213
+ """
214
+ for field in _walk_fields(form):
215
+ if _state(field, "_htmx_selector_target") is None:
216
+ continue
217
+
218
+ sources = _state(field, "_htmx_trigger_sources") or []
219
+ if any(source_id != field.id for source_id, _name, _entry in sources):
220
+ raise ValueError(
221
+ f"{field.name!r} has a selector target and listens to another "
222
+ "field. Keep one of the two, and update the other element as "
223
+ "an out-of-band extra from a callback."
224
+ )
225
+
226
+
227
+ def _collect_triggered_by(holder: Any, form: Any) -> None:
228
+ """Record ``holder.triggered_by(...)`` declarations in ``holder._htmx_trigger_sources``.
229
+
230
+ The wildcard is kept as ``("*", "*", entry)``. Its callback gets
231
+ ``source=None``. Warn about a source that matches no field.
232
+ """
233
+ triggered_by = _state(holder, "_htmx_triggered_by")
234
+ if not triggered_by:
235
+ return
236
+
237
+ root = _root_of(form)
238
+ for source_name, entry in triggered_by.items():
239
+ if source_name == "*":
240
+ _add_trigger_source(holder, "*", "*", entry)
241
+ continue
242
+
243
+ kind, val = _classify_target(form, root, source_name)
244
+ if kind != "field":
245
+ warnings.warn(
246
+ f"{holder.name!r} is triggered by {source_name!r}, which matches "
247
+ "no field. The declaration is ignored. Check the field name.",
248
+ stacklevel=2,
249
+ )
250
+ continue
251
+
252
+ _add_trigger_source(holder, val.id, val.name, entry)
253
+
254
+
255
+ class HtmxFormMixin:
256
+ """Mixin exposing htmx integration helpers on a WTForms form.
257
+
258
+ Mix it in first, before the other form base. WTForms merges the
259
+ ``Meta`` classes of every base, and :class:`HtmxMeta` takes precedence
260
+ over the other base's ``Meta``::
261
+
262
+ class ProfileForm(HtmxFormMixin, FlaskForm):
263
+ email = EmailField()
264
+ country = StringField(datalist=DataList(...))
265
+ region = StringField().triggered_by("country", on="change")
266
+
267
+ The default :class:`HtmxMeta` adds htmx attributes on render, based
268
+ on form-level defaults. Trigger declarations go through
269
+ :meth:`.triggers() <wtforms.fields.core.UnboundField.triggers>` and
270
+ :meth:`.triggered_by() <wtforms.fields.core.UnboundField.triggered_by>`. The
271
+ mixin itself is framework-agnostic: it does not touch request or
272
+ response objects. Your view layer does that.
273
+
274
+ Pass the request headers to the constructor as ``headers=``, for
275
+ example ``ProfileForm(request.form or None, headers=request.headers)``.
276
+ Without them, the form does not detect htmx requests.
277
+ """
278
+
279
+ Meta = HtmxMeta
280
+
281
+ if TYPE_CHECKING:
282
+ meta: HtmxMeta
283
+ _fields: dict[str, Field]
284
+ _parent_form: HtmxFormMixin | None
285
+ _htmx_mutation: _HtmxMutation | None
286
+
287
+ def __init__(self, *args, headers=None, **kwargs):
288
+ # Set before ``super().__init__``: post_process, which dispatches,
289
+ # runs inside it.
290
+ self._htmx_headers = headers
291
+ self._htmx_formdata = None
292
+ if "meta" in kwargs:
293
+ kwargs["meta"] = _with_parent_settings(type(self), kwargs["meta"])
294
+
295
+ super().__init__(*args, **kwargs)
296
+ # A nested form wires its triggers now: its post_process runs
297
+ # after the root's mutate.
298
+ if self._parent_form is not None:
299
+ self._htmx_wire_triggers()
300
+
301
+ def post_process(self, formdata=None):
302
+ """Wire the trigger declarations, then dispatch the mutations.
303
+
304
+ Wire before ``super()`` so dispatch sees the declarations, and
305
+ dispatch after it so callbacks see resolved ``choices``. Nested forms
306
+ never dispatch.
307
+ """
308
+ self._htmx_wire_triggers()
309
+ self._htmx_formdata = formdata
310
+ super().post_process(formdata)
311
+ if self._parent_form is None and self.meta.htmx_auto_mutate:
312
+ self.mutate()
313
+
314
+ def partial(self):
315
+ """Build the htmx partial response for the current request, or ``None``.
316
+
317
+ Returns an :class:`~wtforms_htmx.HtmxResponse` carrying the body
318
+ and the optional htmx response headers (typically
319
+ :htmx-header:`hx-retarget`), or ``None`` when there's no partial
320
+ to produce: non-htmx request, :htmx-attr:`hx-boost` full-page swap, or
321
+ nothing to re-render.
322
+
323
+ Uses the request headers captured at form construction
324
+ (``Form(formdata, headers=request.headers)``).
325
+
326
+ This method renders. It does not run callbacks or change the
327
+ form's data: :meth:`mutate` does that, at construction. The
328
+ fragment is the re-render of the triggered mutation, the inline
329
+ validation result, or a datalist refresh.
330
+
331
+ Typical usage::
332
+
333
+ form = ProfileForm(request.form or None, headers=request.headers)
334
+ if result := form.partial():
335
+ return Response(result.body, headers=result.headers)
336
+
337
+ if form.submitter is form.save and form.validate():
338
+ save(form.data) # skipped on a no-JS mutation click
339
+ return redirect("/profile")
340
+
341
+ return render_template("page.html", form=form)
342
+ """
343
+ return _compute_partial(self, self._htmx_headers)
344
+
345
+ def _htmx_wire_triggers(self) -> None:
346
+ """Record every trigger declaration of the form once, then wire inline validation.
347
+
348
+ ``post_process`` can run several times: a second collection would
349
+ duplicate the entries, but inline validation is wired again so that
350
+ list rows added since get it. Names that match no field are skipped.
351
+ """
352
+ if not self.__dict__.get("_htmx_triggers_wired"):
353
+ self._htmx_triggers_wired = True
354
+ # The helpers record the qualified ``holder.name``, which dispatch
355
+ # needs inside a sub-form.
356
+ for holder in self._fields.values():
357
+ _collect_triggers(holder, self)
358
+ _collect_triggered_by(holder, self)
359
+
360
+ # Nested forms collect while the root is processed, so the root
361
+ # sees every declaration of the tree.
362
+ if self._parent_form is None:
363
+ _check_selector_sources(self)
364
+
365
+ if self.meta.htmx_inline_validation:
366
+ self._wire_inline_validation()
367
+
368
+ def _wire_inline_validation(self) -> None:
369
+ """Declare inline validation as a self-trigger on every eligible field.
370
+
371
+ It covers the rows of a FieldList of plain fields. A FormField wires its
372
+ own form. Skipped: submit sources and fields with a selector target. A
373
+ select, and a list of radio buttons or checkboxes, validates on
374
+ ``change``. The declaration is replaced each time, so a renumbered row
375
+ keeps its id.
376
+ """
377
+ for holder in _inline_validation_fields(self._fields.values()):
378
+ if (
379
+ _is_submit_source(holder)
380
+ or _state(holder, "_htmx_selector_target") is not None
381
+ ):
382
+ continue
383
+
384
+ # ``blur`` never reaches the list container, ``change`` bubbles to
385
+ # it. A select is done once the user picks an option.
386
+ event = (
387
+ "change"
388
+ if isinstance(holder.widget, (ListWidget, Select))
389
+ else self.meta.htmx_validation_trigger
390
+ )
391
+ sources = _state(holder, "_htmx_trigger_sources") or []
392
+ holder._htmx_trigger_sources = [
393
+ source
394
+ for source in sources
395
+ if source[2].callback is not _inline_validate
396
+ ]
397
+ _add_trigger_source(
398
+ holder, holder.id, holder.name, _TriggerEntry(event, _inline_validate)
399
+ )
400
+
401
+ def _htmx_resolve(self, name: str):
402
+ """Find a field by its HTML name, with or without the form's prefix.
403
+
404
+ Return ``None`` when ``name`` matches no field.
405
+ """
406
+ prefix = getattr(self, "_prefix", "") or ""
407
+ if prefix and name.startswith(prefix):
408
+ name = name[len(prefix) :]
409
+
410
+ return _resolve_field(self, name)
411
+
412
+ def _htmx_resolve_datalist(self, datalist_id: str | None):
413
+ """Find the field whose bound DataList has the id ``datalist_id``, or ``None``."""
414
+ if not datalist_id:
415
+ return None
416
+
417
+ return _resolve_field_by_datalist_id(self, datalist_id)
418
+
419
+ def htmx_submit_attrs(self, field: Any) -> dict[str, Any]:
420
+ """Return the hx-* attributes a submit :mdn-tag:`button` needs to fire ``field``'s mutation.
421
+
422
+ A field declared with
423
+ :meth:`.triggers() <wtforms.fields.core.UnboundField.triggers>` posts
424
+ the form and swaps its target through htmx attributes on the rendered
425
+ button. The stock :class:`~wtforms.fields.SubmitField` /
426
+ :class:`~wtforms.fields.ButtonField` widgets get them automatically.
427
+ This helper is for a **custom widget** that renders the
428
+ button(s) itself (typically a Jinja macro emitting one button
429
+ per choice) and has to thread the attributes on by hand:
430
+
431
+ .. code-block:: jinja
432
+
433
+ {%- macro listbox(field) -%}
434
+ <ul role="listbox" id="{{ field.id }}">
435
+ {%- for opt in field -%}
436
+ {%- set attrs = dict(opt.choice.render_kw,
437
+ **form.htmx_submit_attrs(opt)) -%}
438
+ {{ opt(label=opt.choice.label, **attrs) }}
439
+ {%- endfor -%}
440
+ </ul>
441
+ {%- endmacro %}
442
+
443
+ The dict carries :mdn-attr:`button#formnovalidate`, the request attribute
444
+ (:htmx-attr:`hx-post` by default, see :attr:`~wtforms_htmx.HtmxMeta.htmx_method`)
445
+ set to :attr:`~wtforms_htmx.HtmxMeta.htmx_endpoint`, :htmx-attr:`hx-target` (the first
446
+ :meth:`.triggers() <wtforms.fields.core.UnboundField.triggers>`
447
+ target), :htmx-attr:`hx-swap` ``outerHTML`` and
448
+ :htmx-attr:`hx-include` ``closest form``. Merge it with
449
+ other attributes before splatting, as above. For a formatted
450
+ string, see :meth:`htmx_submit_params`.
451
+
452
+ Returns ``{}`` for a field with no trigger declaration. A select
453
+ option returns its parent field's attributes, so a macro can call
454
+ it on each option.
455
+ """
456
+ triggers = _state(field, "_htmx_triggers")
457
+ # A choice option shares its name and its form with the choices field.
458
+ if not triggers and (parent := self._htmx_resolve(field.name)) is not None:
459
+ triggers = _state(parent, "_htmx_triggers")
460
+
461
+ if not triggers:
462
+ return {}
463
+
464
+ return _button_attrs(self, field, next(iter(triggers)))
465
+
466
+ def htmx_submit_params(self, field: Any) -> Markup:
467
+ """Pre-formatted version of :meth:`htmx_submit_attrs`.
468
+
469
+ Wraps the computed dict through :func:`~wtforms.widgets.html_params`
470
+ and returns :class:`~markupsafe.Markup`, so templates can
471
+ splat it directly onto a :mdn-tag:`button` without iteration::
472
+
473
+ <button ... {{ form.htmx_submit_params(field) }}>
474
+
475
+ Returns empty :class:`~markupsafe.Markup` when ``field`` has
476
+ no :meth:`.triggers() <wtforms.fields.core.UnboundField.triggers>`
477
+ declaration. For composition (merging
478
+ caller-side attrs), use :meth:`htmx_submit_attrs` and format
479
+ yourself.
480
+ """
481
+ attrs = self.htmx_submit_attrs(field)
482
+ return Markup(html_params(**attrs)) if attrs else Markup("")
483
+
484
+ @property
485
+ def submitter(self) -> Field | None:
486
+ """Return the button field that submitted the form, or ``None``.
487
+
488
+ Compare it to a field with ``is``: ``form.submitter is form.save``.
489
+ A :class:`~wtforms.fields.ButtonField` or
490
+ :class:`~wtforms.fields.SubmitField` nested in a
491
+ :class:`~wtforms.fields.FieldList` or
492
+ :class:`~wtforms.fields.FormField` is found too, as long as the
493
+ row form uses :class:`~wtforms_htmx.HtmxMeta`, as an
494
+ :class:`~wtforms_htmx.HtmxForm` or an :class:`~wtforms_htmx.HtmxFormMixin`
495
+ subclass does. A row button compares to its own entry. A ``prefix=`` on the form changes nothing.
496
+
497
+ Use it when a form has several submit buttons with different
498
+ meanings (save, delete, prefill…), and to tell a submit from a
499
+ mutation click. A button written in the template without a
500
+ ``name`` leaves it at ``None``.
501
+ """
502
+ for btn in _iter_submit_sources(self):
503
+ if btn.raw_data:
504
+ return btn
505
+
506
+ return None
507
+
508
+ @property
509
+ def mutated(self) -> bool:
510
+ """Return ``True`` when the request ran a mutation.
511
+
512
+ This is the case after a click on a button with
513
+ :meth:`.triggers() <wtforms.fields.core.UnboundField.triggers>`, with
514
+ or without JavaScript, and after an htmx request that re-renders a
515
+ field. A view checks it to skip saving::
516
+
517
+ if not form.mutated and form.validate():
518
+ save(form.data)
519
+
520
+ It stays ``False`` until :meth:`mutate` runs.
521
+ """
522
+ mutation = self.__dict__.get("_htmx_mutation")
523
+ return mutation is not None and mutation.dispatched
524
+
525
+ def mutate(self) -> None:
526
+ """Dispatch the request's mutations.
527
+
528
+ Runs the callbacks of the request and records which targets
529
+ re-render; :meth:`partial` does the rendering. It runs the
530
+ callbacks of the button that submitted the form, with or without
531
+ JavaScript. For an htmx request, it then runs the callbacks
532
+ declared towards the field that sent the request. See
533
+ :ref:`dispatch-path`. To tell a
534
+ mutation click from a submit in the view, read :attr:`submitter`.
535
+
536
+ You rarely call this in a view: by default it runs automatically
537
+ (:attr:`Meta.htmx_auto_mutate <HtmxMeta.htmx_auto_mutate>`,
538
+ default ``True``). Call it
539
+ explicitly when ``htmx_auto_mutate`` is off and the view must run
540
+ logic *between* construction and the mutation, such as
541
+ inspecting the raw submission, authorizing the clicked button, or
542
+ loading state the mutation builds on::
543
+
544
+ class TagsForm(HtmxForm):
545
+ class Meta:
546
+ htmx_auto_mutate = False # opt out of the automatic run
547
+
548
+ tags = FieldList(StringField())
549
+ add_tag = ButtonField("Add tag").triggers("tags", list_add)
550
+
551
+ form = TagsForm(request.form or None, headers=request.headers)
552
+ if not user_may(form.submitter): # gate the click before applying
553
+ abort(403)
554
+
555
+ form.mutate() # then run the declared mutations
556
+
557
+ It reads the request headers passed to the constructor, like
558
+ :meth:`partial`.
559
+
560
+ The first call runs the mutations. Later calls do nothing, so an
561
+ explicit call after the automatic one is harmless.
562
+
563
+ When a mutation fired, the form is post-processed again, so derived
564
+ state such as ``choices`` reflects the new data.
565
+
566
+ With CSRF protection on, a submitted form with an invalid token runs
567
+ no callback. The error is set on the CSRF field.
568
+ """
569
+ if self.__dict__.get("_htmx_mutation") is not None:
570
+ return
571
+
572
+ # Stored even when empty: its presence marks that mutate has run.
573
+ mutation = _HtmxMutation()
574
+ self._htmx_mutation = mutation
575
+ if not self._htmx_csrf_valid():
576
+ return
577
+
578
+ self._dispatch_button(mutation)
579
+ self._dispatch_source_request(mutation)
580
+ if mutation.dispatched:
581
+ self.post_process(self._htmx_formdata)
582
+
583
+ def _htmx_csrf_valid(self) -> bool:
584
+ """Check the CSRF token of a submitted form, when CSRF protection is on."""
585
+ if not self.meta.csrf or self._htmx_formdata is None:
586
+ return True
587
+
588
+ return self._fields[self.meta.csrf_field_name].validate(self)
589
+
590
+ def _dispatch_button(self, mutation: _HtmxMutation) -> None:
591
+ """Run the callbacks of the button that submitted the form."""
592
+ button = self.submitter
593
+ if button is None or not (triggers := _state(button, "_htmx_triggers")):
594
+ return
595
+
596
+ host = getattr(button, "_form", None) or self
597
+ for target_name, entry in triggers.items():
598
+ kind, target = _classify_target(host, self, target_name)
599
+ if kind != "field":
600
+ # A selector, or a name matching no field: the callback owns the HTML.
601
+ mutation.selector_dispatched = True
602
+ if entry.callback is not None:
603
+ mutation.add_result(entry.callback(self, button, target))
604
+
605
+ continue
606
+
607
+ if entry.callback is not None:
608
+ self._run_field_callback(entry, button, target, mutation)
609
+
610
+ mutation.targets.append(target)
611
+
612
+ def _dispatch_source_request(self, mutation: _HtmxMutation) -> None:
613
+ """Run the callbacks for the field that sent an htmx request.
614
+
615
+ A field with a selector target runs that callback with the selector
616
+ string as ``target``. A listening field runs every callback declared
617
+ towards it. Then the field runs the callbacks of the declarations it
618
+ posts itself, and their targets re-render out of band.
619
+ """
620
+ if not self._htmx_headers:
621
+ return
622
+
623
+ source_id = _parse_request(self._htmx_headers).source_id
624
+ if (field := _resolve_source_field(self, source_id)) is None:
625
+ return
626
+
627
+ outgoing = self._source_posted_targets(field)
628
+ if _state(field, "_htmx_selector_target") is not None:
629
+ self._dispatch_selector(field, mutation)
630
+ elif _state(field, "_htmx_trigger_sources"):
631
+ self._dispatch_listener(field, mutation)
632
+ elif outgoing:
633
+ mutation.targets.append(field)
634
+
635
+ # Inline validation of the source validates the whole form, but the
636
+ # user has not visited the targets yet. A callback may validate them.
637
+ for target, _entry in outgoing:
638
+ _clear_errors(target)
639
+
640
+ for target, entry in outgoing:
641
+ if entry.callback is not None:
642
+ self._run_field_callback(entry, field, target, mutation)
643
+
644
+ mutation.targets.append(target)
645
+
646
+ def _source_posted_targets(self, source: Any) -> list[tuple[Any, Any]]:
647
+ """Return the fields ``source`` feeds through a declaration it posts itself."""
648
+ found = []
649
+ for target in _walk_fields(self):
650
+ for source_id, source_name, entry in (
651
+ _state(target, "_htmx_trigger_sources") or []
652
+ ):
653
+ if source_id == target.id or source_name != source.name:
654
+ continue
655
+
656
+ if _is_source_posted(source_name, entry):
657
+ found.append((target, entry))
658
+
659
+ return found
660
+
661
+ def _dispatch_listener(self, target: Any, mutation: _HtmxMutation) -> None:
662
+ """Run every callback declared towards ``target`` and record it for re-render."""
663
+ for source_id, source_name, entry in _state(target, "_htmx_trigger_sources"):
664
+ if entry.callback is None:
665
+ continue
666
+
667
+ # The source posts this declaration itself.
668
+ if source_id != target.id and _is_source_posted(source_name, entry):
669
+ continue
670
+
671
+ # A wildcard has no single source: callbacks get ``source=None``.
672
+ if source_name == "*":
673
+ source = None
674
+ elif (source := self._htmx_resolve(source_name)) is None:
675
+ continue
676
+
677
+ self._run_field_callback(entry, source, target, mutation)
678
+
679
+ mutation.targets.append(target)
680
+
681
+ def _dispatch_selector(self, source: Any, mutation: _HtmxMutation) -> None:
682
+ """Run the callback of ``source``'s selector target."""
683
+ mutation.selector_dispatched = True
684
+ selector, entry = _state(source, "_htmx_selector_target")
685
+ if entry.callback is not None:
686
+ mutation.add_result(entry.callback(self, source, selector))
687
+
688
+ def _run_field_callback(
689
+ self, entry: Any, source: Any, target: Any, mutation: _HtmxMutation
690
+ ) -> None:
691
+ """Run ``entry``'s callback on the field ``target``.
692
+
693
+ ``raw_data`` is cleared only when the callback changed ``target.data``:
694
+ it would otherwise win over the new data, and a plain re-render keeps
695
+ what the user typed.
696
+ """
697
+ before = target.data
698
+ mutation.add_result(entry.callback(self, source, target))
699
+ if target.data != before:
700
+ target.raw_data = None
701
+
702
+
703
+ _PARENT_SETTINGS = tuple(name for name in vars(HtmxMeta) if name.startswith("htmx_"))
704
+
705
+
706
+ def _with_parent_settings(form_class: Any, meta: Any) -> Any:
707
+ """Add the htmx settings of the parent form that a sub-form does not set itself.
708
+
709
+ A sub-form gets a meta dict with its parent form. The settings go in
710
+ before the fields are bound, which reads some of them. WTForms passes
711
+ only the parent form to a sub-form, not its meta (wtforms#247 proposed
712
+ it, unmerged).
713
+ """
714
+ if not isinstance(meta, dict) or (parent := meta.get("_parent_form")) is None:
715
+ return meta
716
+
717
+ meta_class = form_class._wtforms_meta
718
+ inherited = {
719
+ name: getattr(parent.meta, name)
720
+ for name in _PARENT_SETTINGS
721
+ if name not in meta
722
+ and hasattr(parent.meta, name)
723
+ and not _declares(meta_class, name)
724
+ }
725
+ return {**inherited, **meta}
726
+
727
+
728
+ def _declares(meta_class: Any, name: str) -> bool:
729
+ """Tell whether a Meta class of the form, above HtmxMeta, sets ``name``."""
730
+ mro = meta_class.__mro__
731
+ return any(name in vars(klass) for klass in mro[: mro.index(HtmxMeta)])
732
+
733
+
734
+ def _clear_errors(field: Any) -> None:
735
+ """Remove the validation errors of ``field`` and of the fields it holds."""
736
+ if isinstance(field, FormField):
737
+ for sub in field.form:
738
+ _clear_errors(sub)
739
+
740
+ return
741
+
742
+ if isinstance(field, FieldList):
743
+ for entry in field.entries:
744
+ _clear_errors(entry)
745
+
746
+ return
747
+
748
+ field.errors = []
749
+
750
+
751
+ class HtmxForm(HtmxFormMixin, Form):
752
+ """Canonical :class:`~wtforms.form.Form` subclass with htmx helpers baked in.
753
+
754
+ Convenience composition of :class:`HtmxFormMixin` and
755
+ :class:`~wtforms.form.Form`, equivalent to::
756
+
757
+ class MyForm(HtmxFormMixin, Form):
758
+ ...
759
+
760
+ Prefer this class as the base class for htmx-enabled forms.
761
+ """