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.
@@ -0,0 +1,55 @@
1
+ """Read the htmx request headers the same way on htmx 2 and htmx 4.
2
+
3
+ Both versions are normalized to element ids. ``hx-trigger-name`` is
4
+ ignored: htmx 4 has no such header.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ from typing import NamedTuple
11
+ from urllib.parse import unquote
12
+
13
+
14
+ class _HtmxRequestInfo(NamedTuple):
15
+ """What an htmx request carries, whatever the htmx version."""
16
+
17
+ htmx: bool
18
+ boosted: bool
19
+ source_id: str | None
20
+ target_id: str | None
21
+
22
+
23
+ def _element_id(identifier: str | None) -> str | None:
24
+ """Extract the id from an htmx 4 ``tag#id`` element identifier.
25
+
26
+ htmx 4 percent-encodes the id, so it is decoded here.
27
+ """
28
+ if not identifier:
29
+ return None
30
+
31
+ _, separator, element_id = identifier.partition("#")
32
+ return unquote(element_id) if separator else None
33
+
34
+
35
+ def _parse_request(headers: Mapping[str, str] | None) -> _HtmxRequestInfo:
36
+ """Normalize raw request headers, whatever the case of their names."""
37
+ lowered = {name.lower(): value for name, value in (headers or {}).items()}
38
+ htmx = "hx-request" in lowered
39
+ boosted = "hx-boosted" in lowered
40
+ # ``HX-Source`` is htmx 4 only, ``HX-Trigger`` htmx 2 only: presence picks
41
+ # the dialect, no version flag needed.
42
+ if (source := lowered.get("hx-source")) is not None:
43
+ return _HtmxRequestInfo(
44
+ htmx=htmx,
45
+ boosted=boosted,
46
+ source_id=_element_id(source),
47
+ target_id=_element_id(lowered.get("hx-target")),
48
+ )
49
+
50
+ return _HtmxRequestInfo(
51
+ htmx=htmx,
52
+ boosted=boosted,
53
+ source_id=lowered.get("hx-trigger") or None,
54
+ target_id=lowered.get("hx-target") or None,
55
+ )
wtforms_htmx/meta.py ADDED
@@ -0,0 +1,416 @@
1
+ """HtmxMeta: form-wide htmx settings, field binding and field rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+ from typing import TYPE_CHECKING
8
+
9
+ from markupsafe import Markup
10
+ from wtforms.fields.choices import SelectFieldBase
11
+ from wtforms.meta import DefaultMeta
12
+ from wtforms.widgets import Button
13
+ from wtforms.widgets import SubmitInput
14
+
15
+ from .tree import _root_of
16
+ from .tree import _state
17
+ from .tree import _walk_fields
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable
21
+ from collections.abc import Mapping
22
+
23
+
24
+ class HtmxMeta(DefaultMeta):
25
+ """WTForms :class:`~wtforms.meta.DefaultMeta` with htmx-aware rendering defaults.
26
+
27
+ Form-wide settings for all htmx-enhanced behaviors. Per-field behavior
28
+ is declared on the field itself, with the
29
+ :meth:`.triggers() <wtforms.fields.core.UnboundField.triggers>` /
30
+ :meth:`.triggered_by() <wtforms.fields.core.UnboundField.triggered_by>`
31
+ methods available on any field declaration.
32
+
33
+ A sub-form, in a :class:`~wtforms.fields.FormField` or a
34
+ :class:`~wtforms.fields.FieldList` of them, uses the ``htmx_*`` settings
35
+ of its parent form, unless its own ``Meta`` sets them.
36
+ """
37
+
38
+ htmx_endpoint: str = ""
39
+ """URL of every request the library emits. Default: the current URL."""
40
+
41
+ htmx_method: str = "post"
42
+ """Lowercase HTTP verb for every auto-wired request (``"post"`` by
43
+ default → :htmx-attr:`hx-post`).
44
+
45
+ Set to ``"get"`` / ``"put"`` / ``"patch"`` / ``"delete"`` to change
46
+ the verb the library emits on inline validation, datalist refresh,
47
+ :meth:`.triggers() <wtforms.fields.core.UnboundField.triggers>` /
48
+ :meth:`.triggered_by() <wtforms.fields.core.UnboundField.triggered_by>`
49
+ trigger declarations and submit-source buttons. The same verb is
50
+ used everywhere. The library never mixes :htmx-attr:`hx-get` and
51
+ :htmx-attr:`hx-post` on one element.
52
+
53
+ The view must read the request data that matches the verb: a
54
+ ``"get"`` form builds itself from the query string, and so must any
55
+ callback that reads the request. See :doc:`/how-to/raw_htmx`.
56
+ """
57
+
58
+ field_wrapper: Callable | None = None
59
+ """Optional ``field_wrapper(self, field, render_kw) -> str`` method
60
+ invoked around every field whose ``wrapper=`` constructor kwarg is
61
+ left to its default (``True``). A function without ``self`` needs
62
+ :func:`staticmethod`. A per-field ``wrapper=`` callable takes
63
+ ``(field, render_kw)``, without ``self``.
64
+
65
+ It renders the markup around each field (label, input, errors)
66
+ inside a container with an :mdn-global-attr:`id`. A field re-rendered by a mutation
67
+ or by inline validation goes through it too. Without one, the
68
+ ``field()`` call renders the bare widget. See :ref:`field-wrapper`.
69
+
70
+ Per-field overrides go through the ``wrapper=`` constructor kwarg::
71
+
72
+ name = StringField() # uses Meta.field_wrapper
73
+ save = SubmitField("Save", wrapper=False) # bare, no wrapper
74
+ fancy = StringField(wrapper=custom_wrap) # custom callable
75
+
76
+ The library reads ``wrapper=`` from every field declaration, so a
77
+ field class with its own ``wrapper`` argument does not receive it.
78
+
79
+ A single wrapper does not fit every field family. See
80
+ :doc:`/how-to/render_fields` for the per-family recipe.
81
+ """
82
+
83
+ htmx_validation_trigger: str = "blur changed"
84
+ """Full :htmx-attr:`hx-trigger` string for inline validation (event + modifiers
85
+ + optional ``delay:Xms``), e.g. ``"blur changed"`` (default) or
86
+ ``"change delay:200ms"``. A select, and a list of radio buttons or
87
+ checkboxes, always validates on ``change``."""
88
+
89
+ # No ``changed``: it never fires on a from:body listener on htmx 4.0.0
90
+ # (htmx#4033, fixed by htmx#4035 and htmx#4096 but unreleased).
91
+ htmx_default_trigger: str = "input delay:200ms"
92
+ """Default :htmx-attr:`hx-trigger` event for
93
+ :meth:`.triggers() <wtforms.fields.core.UnboundField.triggers>` /
94
+ :meth:`.triggered_by() <wtforms.fields.core.UnboundField.triggered_by>`
95
+ declarations when no ``on=`` is given."""
96
+
97
+ htmx_swap: str = "outerHTML"
98
+ """Default :htmx-attr:`hx-swap` for inline validation and for field
99
+ targets of trigger declarations. Buttons and fields with a DOM
100
+ selector target use ``outerHTML`` instead. Override either per field
101
+ with ``render_kw``."""
102
+
103
+ htmx_inline_validation: bool = True
104
+ """Whether fields validate when the user leaves them.
105
+ See :doc:`/how-to/inline_validation`."""
106
+
107
+ htmx_submit_widgets: tuple[type, ...] = (Button, SubmitInput)
108
+ """Tuple of widget classes recognized as submit sources.
109
+
110
+ Extend to register custom button-emitting widgets::
111
+
112
+ class MyForm(HtmxForm):
113
+ class Meta:
114
+ htmx_submit_widgets = HtmxMeta.htmx_submit_widgets + (MyButton,)
115
+
116
+ A registered widget must render its ``value`` attribute from
117
+ ``field._value()``, unless the template passes ``value=``. Otherwise
118
+ the value sent with htmx differs from the one a plain submit sends.
119
+ """
120
+
121
+ htmx_auto_mutate: bool = True
122
+ """Whether the form automatically calls :meth:`HtmxFormMixin.mutate` at the
123
+ end of construction so views don't have to.
124
+
125
+ Set to ``False`` to require explicit :meth:`form.mutate()
126
+ <HtmxFormMixin.mutate>` calls (typically
127
+ in tests that need to inspect pre-mutation state, or views that must
128
+ run logic before the mutation, such as authorizing the clicked button).
129
+ """
130
+
131
+ @property
132
+ def _htmx_request_attr(self) -> str:
133
+ """The ``hx-<verb>`` attribute name derived from ``htmx_method``."""
134
+ return f"hx-{self.htmx_method}"
135
+
136
+ def bind_field(self, form, unbound_field, options):
137
+ """Copy the trigger declarations and the ``wrapper=`` choice onto the bound field.
138
+
139
+ A DataList gets its refresh attributes here, so a template that calls
140
+ ``field.widget(...)`` keeps them.
141
+ """
142
+ field = super().bind_field(form, unbound_field, options)
143
+ if triggers := getattr(unbound_field, "_htmx_triggers", None):
144
+ field._htmx_triggers = dict(triggers)
145
+
146
+ if triggered_by := getattr(unbound_field, "_htmx_triggered_by", None):
147
+ field._htmx_triggered_by = dict(triggered_by)
148
+
149
+ if hasattr(unbound_field, "_htmx_wrapper"):
150
+ field._htmx_wrapper = unbound_field._htmx_wrapper
151
+
152
+ self._inject_datalist_attrs(field)
153
+ return field
154
+
155
+ def _inject_datalist_attrs(self, field) -> None:
156
+ """Add the refresh attributes to the field's DataList, when its choices are callable.
157
+
158
+ The datalist sends its own request and swaps in the new ``<option>``
159
+ children, so the input keeps focus and caret. ``render_kw`` on the
160
+ DataList wins.
161
+ """
162
+ datalist = getattr(field, "_datalist", None)
163
+ if datalist is None or isinstance(datalist, str):
164
+ return
165
+
166
+ if not callable(datalist._raw_choices):
167
+ return
168
+
169
+ rk = dict(datalist.render_kw or {})
170
+ rk.setdefault(self._htmx_request_attr, self.htmx_endpoint)
171
+ rk.setdefault("hx-trigger", _from_clause(_DATALIST_TRIGGER, f"#{field.id}"))
172
+ rk.setdefault("hx-target", "this")
173
+ rk.setdefault("hx-swap", "innerHTML")
174
+ # Select the options, not the <datalist>, which innerHTML would nest.
175
+ rk.setdefault("hx-select", f"#{datalist.id} > option")
176
+ rk.setdefault("hx-include", "closest form")
177
+ datalist.render_kw = rk
178
+
179
+ def _compute_trigger_attrs(self, holder):
180
+ """Build the hx-* attributes for the triggers ``holder`` listens to.
181
+
182
+ One ``from:body target:`` clause per event group of sources, a bare
183
+ event for a self-trigger, a selector target, or a field this one
184
+ posts for. Return ``{}`` when there is no trigger.
185
+ """
186
+ attrs: dict[str, Any] = {}
187
+ incoming = _state(holder, "_htmx_trigger_sources") or []
188
+ selector_target = _state(holder, "_htmx_selector_target")
189
+ outgoing = _outgoing_events(holder)
190
+ if not incoming and selector_target is None and not outgoing:
191
+ return attrs
192
+
193
+ link_event = self.htmx_default_trigger
194
+ extras = [
195
+ _from_clause(event, f"[{_SOURCE_ATTR}~={_source_token(holder, index)}]")
196
+ for index, (event, _names) in enumerate(_source_groups(holder, link_event))
197
+ ]
198
+ own_events = [
199
+ entry.event or link_event
200
+ for source_id, _name, entry in incoming
201
+ if source_id == holder.id
202
+ ]
203
+ extras.extend(own_events)
204
+ # The request of an own event already runs the callbacks of the fields
205
+ # this one feeds: an outgoing event at the same moment would send a
206
+ # second request.
207
+ covered = {_event_name(event) for event in own_events}
208
+ extras.extend(event for event in outgoing if _event_name(event) not in covered)
209
+ if selector_target is not None:
210
+ extras.append(selector_target[1].event or link_event)
211
+
212
+ # Every source posts its declaration itself: nothing to listen to.
213
+ if not extras:
214
+ return attrs
215
+
216
+ attrs["hx-trigger"] = ", ".join(extras)
217
+
218
+ attrs.setdefault(self._htmx_request_attr, self.htmx_endpoint)
219
+ attrs.setdefault("hx-include", "closest form")
220
+ if selector_target is not None:
221
+ attrs["hx-target"] = selector_target[0]
222
+ attrs.setdefault("hx-swap", "outerHTML")
223
+ else:
224
+ attrs.setdefault("hx-swap", self.htmx_swap)
225
+
226
+ return attrs
227
+
228
+ def render_field(self, field, render_kw):
229
+ """Add the computed htmx attributes, then render through the field's wrapper.
230
+
231
+ ``wrapper=True`` uses ``field_wrapper``, ``False`` the bare widget, a
232
+ callable replaces ``field_wrapper``. The options of a choices field are
233
+ always rendered bare.
234
+ """
235
+ if isinstance(field, SelectFieldBase._Option):
236
+ merged = {**(field.render_kw or {}), **(render_kw or {})}
237
+ self._inject_submit_vals(field, merged)
238
+ return Markup(field.widget(field, **merged))
239
+
240
+ # Precedence: library attrs < field render_kw < call-site render_kw.
241
+ lib_attrs = self._compute_trigger_attrs(field)
242
+ if tokens := _source_tokens(field):
243
+ lib_attrs[_SOURCE_ATTR] = " ".join(tokens)
244
+
245
+ merged = {**lib_attrs, **(field.render_kw or {}), **(render_kw or {})}
246
+ self._inject_submit_vals(field, merged)
247
+
248
+ wrapper = _state(field, "_htmx_wrapper", True)
249
+ if wrapper is False:
250
+ return Markup(field.widget(field, **merged))
251
+
252
+ if callable(wrapper):
253
+ return Markup(wrapper(field, merged))
254
+
255
+ if self.field_wrapper is not None:
256
+ return Markup(self.field_wrapper(field, merged))
257
+
258
+ return Markup(field.widget(field, **merged))
259
+
260
+ def _inject_submit_vals(self, field, merged) -> None:
261
+ """Re-send a submit control's own ``name``/``value`` pair as ``hx-vals``.
262
+
263
+ Workaround for htmx#4073. The value matches what the widget renders,
264
+ so htmx and a plain submit send the same pair. An explicit ``hx-vals``
265
+ is left alone.
266
+ """
267
+ # htmx 4 drops the pair of a submit control with its own request attribute
268
+ # (htmx#4073, fixed but unreleased). Remove once a release has the fix.
269
+ if not isinstance(field.widget, self.htmx_submit_widgets):
270
+ return
271
+
272
+ if self._htmx_request_attr not in merged:
273
+ return
274
+
275
+ if "hx-vals" in merged:
276
+ return
277
+
278
+ merged["hx-vals"] = json.dumps(
279
+ {field.name: _rendered_submit_value(field, merged)}
280
+ )
281
+
282
+
283
+ def _rendered_submit_value(field: Any, merged: Mapping[str, Any]) -> str:
284
+ """Return the ``value`` attribute a submit widget renders for ``field``.
285
+
286
+ Workaround for htmx#4073. A call-time ``value=`` wins, ``SubmitInput``
287
+ renders the label, other widgets render ``field._value()``.
288
+ """
289
+ if "value" in merged:
290
+ return str(merged["value"])
291
+
292
+ if isinstance(field.widget, SubmitInput):
293
+ return str(field.label.text)
294
+
295
+ return str(field._value())
296
+
297
+
298
+ # No ``changed``: see ``HtmxMeta.htmx_default_trigger``.
299
+ _DATALIST_TRIGGER = "input delay:200ms"
300
+
301
+ # An attribute selector keeps the target: value free of spaces, which have
302
+ # no syntax that both htmx 2 and htmx 4 parse (htmx#4010).
303
+ _SOURCE_ATTR = "data-wtforms-htmx-for"
304
+
305
+
306
+ _TYPING_EVENTS = frozenset({"input", "keyup", "keydown", "keypress"})
307
+
308
+
309
+ def _event_name(event: str) -> str:
310
+ """Return the bare event name of an ``hx-trigger`` clause, ``blur`` as ``focusout``."""
311
+ return _bubbling(event).split(" ", 1)[0].split("[", 1)[0]
312
+
313
+
314
+ def _is_source_posted(source_name: str, entry: Any) -> bool:
315
+ """Tell whether the source sends the request of a declaration itself.
316
+
317
+ It does for any event but typing and polling. The target listens for
318
+ those, so a request is sent while the user types in the source. The
319
+ server cannot tell which event sent a request (htmx#4161), so a source
320
+ cannot both validate on blur and post its typing.
321
+ """
322
+ if source_name == "*" or entry.event is None:
323
+ return False
324
+
325
+ name = _event_name(entry.event)
326
+ return name not in _TYPING_EVENTS and name != "every"
327
+
328
+
329
+ def _outgoing_events(holder: Any) -> list[str]:
330
+ """Return the events on which ``holder`` posts for the fields it feeds."""
331
+ events: list[str] = []
332
+ for listener in _walk_fields(_root_of(holder._form)):
333
+ for source_id, source_name, entry in (
334
+ _state(listener, "_htmx_trigger_sources") or []
335
+ ):
336
+ if source_id == listener.id or source_name != holder.name:
337
+ continue
338
+
339
+ if _is_source_posted(source_name, entry) and entry.event not in events:
340
+ events.append(entry.event)
341
+
342
+ return events
343
+
344
+
345
+ def _source_groups(holder: Any, default_event: str) -> list[tuple[str, list[str]]]:
346
+ """Group the fields ``holder`` listens to by event.
347
+
348
+ Return ``(event, [source_name, ...])`` pairs in declaration order,
349
+ without self-triggers and the declarations a source posts itself. ``"*"`` stands for every field. One clause per
350
+ group, because htmx 2 only fires the first ``target:`` clause of an
351
+ event (htmx#4155).
352
+ """
353
+ groups: dict[str, list[str]] = {}
354
+ for source_id, source_name, entry in _state(holder, "_htmx_trigger_sources") or []:
355
+ if source_id == holder.id or _is_source_posted(source_name, entry):
356
+ continue
357
+
358
+ event = _bubbling(entry.event or default_event)
359
+ groups.setdefault(event, []).append(source_name)
360
+
361
+ return list(groups.items())
362
+
363
+
364
+ def _source_token(holder: Any, index: int) -> str:
365
+ """Return the marker a source carries for ``holder``'s ``index``-th event group."""
366
+ return holder.id if index == 0 else f"{holder.id}--{index}"
367
+
368
+
369
+ def _source_tokens(field: Any) -> list[str]:
370
+ """Return the markers of every element listening to ``field``.
371
+
372
+ A source matches by its name, or as a descendant of a container
373
+ source, so FieldList rows added later match too.
374
+ """
375
+ name = field.name
376
+ tokens = []
377
+ for listener in _walk_fields(_root_of(field._form)):
378
+ if not _state(listener, "_htmx_trigger_sources"):
379
+ continue
380
+
381
+ # A field of a plain WTForms sub-form has no htmx meta and listens to nothing.
382
+ default = getattr(listener.meta, "htmx_default_trigger", None)
383
+ if default is None:
384
+ continue
385
+
386
+ for index, (_event, names) in enumerate(_source_groups(listener, default)):
387
+ if any(
388
+ source == "*" or name == source or name.startswith(f"{source}-")
389
+ for source in names
390
+ ):
391
+ tokens.append(_source_token(listener, index))
392
+
393
+ return tokens
394
+
395
+
396
+ # Events that do not bubble, mapped to their bubbling equivalents.
397
+ _BUBBLING_EVENTS = {"blur": "focusout", "focus": "focusin"}
398
+
399
+
400
+ def _bubbling(event: str) -> str:
401
+ """Replace a non-bubbling event at the start of ``event`` with its bubbling equivalent."""
402
+ name, sep, rest = event.partition(" ")
403
+ bare = name.split("[", 1)[0]
404
+ if bare in _BUBBLING_EVENTS:
405
+ name = _BUBBLING_EVENTS[bare] + name[len(bare) :]
406
+
407
+ return f"{name}{sep}{rest}"
408
+
409
+
410
+ def _from_clause(event: str, target: str) -> str:
411
+ """Return an ``hx-trigger`` clause for ``event`` fired on ``target``.
412
+
413
+ htmx resolves ``from:`` once, so ``from:#id`` breaks when the source is
414
+ swapped. Listening on ``body`` and filtering on the target does not.
415
+ """
416
+ return f"{_bubbling(event)} from:body target:{target}"