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,311 @@
1
+ """Built-in callbacks for trigger declarations.
2
+
3
+ Each callback takes ``(form, source, target)``. ``value_set``,
4
+ ``value_cycle`` and ``list_move`` build such a callback. The ones for a
5
+ SelectMultipleField or a FieldList raise TypeError on another target type.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from wtforms.fields import FieldList
13
+ from wtforms.fields import SelectMultipleField
14
+
15
+ from .triggers import TriggerCallback
16
+
17
+
18
+ def _entry_is_padding(entry: Any) -> bool:
19
+ """Whether ``entry`` comes from ``min_entries`` padding rather than formdata.
20
+
21
+ A padded field has ``raw_data == []``. FormField and FieldList entries
22
+ are checked through their sub-fields.
23
+ """
24
+ if hasattr(entry, "form"):
25
+ return all(_entry_is_padding(f) for f in entry.form._fields.values())
26
+
27
+ if hasattr(entry, "entries"):
28
+ return all(_entry_is_padding(e) for e in entry.entries)
29
+
30
+ return entry.raw_data == []
31
+
32
+
33
+ def _truncate_to_padding(field: FieldList) -> None:
34
+ """Drop the trailing entries that come from ``min_entries`` padding.
35
+
36
+ Mutations then only see the entries the user submitted.
37
+ """
38
+ while field.entries and _entry_is_padding(field.entries[-1]):
39
+ field.pop_entry()
40
+
41
+
42
+ def _require(target: Any, field_class: type, name: str) -> None:
43
+ """Raise TypeError when ``target`` is not a ``field_class``."""
44
+ if not isinstance(target, field_class):
45
+ raise TypeError(
46
+ f"{name} requires {field_class.__name__}, got {type(target).__name__}"
47
+ )
48
+
49
+
50
+ def _source_value(source: Any) -> str:
51
+ """Return the source's value: ``raw_data[0]`` stripped, or ``""``."""
52
+ raw = getattr(source, "raw_data", None)
53
+ return raw[0].strip() if raw else ""
54
+
55
+
56
+ def _source_index(source: Any) -> int | None:
57
+ """Return the source's value as an integer, or ``None`` when it is not one."""
58
+ try:
59
+ return int(_source_value(source))
60
+ except ValueError:
61
+ return None
62
+
63
+
64
+ def value_set(value: Any) -> TriggerCallback:
65
+ """Return a mutation that overwrites ``target.data`` with ``value``.
66
+
67
+ Use it to clear a field (``value_set("")`` / ``value_set(None)``) or
68
+ to reset it to a default, for example from a "clear" button::
69
+
70
+ query = StringField()
71
+ clear = ButtonField("Clear").triggers("query", value_set(""))
72
+ """
73
+
74
+ def _mutation(form: Any, source: Any, target: Any) -> None:
75
+ target.data = value
76
+
77
+ return _mutation
78
+
79
+
80
+ def value_cycle(values: list[Any]) -> TriggerCallback:
81
+ """Return a mutation that rotates ``target.data`` through ``values``.
82
+
83
+ Each firing advances ``target.data`` to the next entry, wrapping around
84
+ at the end. If the current ``target.data`` isn't in ``values`` (for
85
+ example, when the field is empty), the mutation resets to ``values[0]``.
86
+
87
+ Use it with a single :class:`~wtforms.fields.ButtonField` for status
88
+ toggles, view/edit modes or step indicators. No value is read from
89
+ the source. Raise :exc:`ValueError` when ``values`` is empty.
90
+
91
+ Cycle a status field from one button::
92
+
93
+ STATES = ["todo", "doing", "done"]
94
+ status = SelectField(choices=STATES)
95
+ cycle = ButtonField("·").triggers("status", value_cycle(STATES))
96
+ """
97
+ if not values:
98
+ raise ValueError("value_cycle() needs at least one value")
99
+
100
+ def _mutation(form: Any, _source: Any, target: Any) -> None:
101
+ current = target.data or ""
102
+ if current not in values:
103
+ target.data = values[0]
104
+ return
105
+
106
+ target.data = values[(values.index(current) + 1) % len(values)]
107
+
108
+ return _mutation
109
+
110
+
111
+ def value_add(form: Any, source: Any, target: Any) -> None:
112
+ """Append the source's value to a :class:`~wtforms.fields.SelectMultipleField`'s data.
113
+
114
+ No deduplication: clicking a button whose value is already in
115
+ ``target.data`` adds a duplicate entry. Filter at the source if
116
+ uniqueness matters (e.g. exclude already-selected values from the
117
+ candidate set).
118
+
119
+ Each catalog button carries its option's value::
120
+
121
+ tags = SelectMultipleField(choices=CATALOG)
122
+ add_tag = ButtonField("Add").triggers("tags", value_add)
123
+
124
+ Render one button per option and pass the value at render time:
125
+
126
+ .. code-block:: jinja
127
+
128
+ {% for value, label in form.tags.choices %}
129
+ {{ form.add_tag(value=value, label=label, id=False) }}
130
+ {% endfor %}
131
+ """
132
+ _require(target, SelectMultipleField, "value_add")
133
+
134
+ value = _source_value(source)
135
+ if not value:
136
+ return
137
+
138
+ target.data = [*list(target.data or []), value]
139
+
140
+
141
+ def value_remove(form: Any, source: Any, target: Any) -> None:
142
+ """Remove the first occurrence of the source's value from a :class:`~wtforms.fields.SelectMultipleField`'s data.
143
+
144
+ Subsequent matches (if duplicates exist) are left untouched,
145
+ like :meth:`list.remove`.
146
+
147
+ Each chip "×" button carries its value::
148
+
149
+ tags = SelectMultipleField(choices=CATALOG)
150
+ rm_tag = ButtonField("×").triggers("tags", value_remove)
151
+
152
+ .. code-block:: jinja
153
+
154
+ {% for value in form.tags.data or [] %}
155
+ {{ value }} {{ form.rm_tag(value=value, id=False) }}
156
+ {% endfor %}
157
+ """
158
+ _require(target, SelectMultipleField, "value_remove")
159
+
160
+ value = _source_value(source)
161
+ data = list(target.data or [])
162
+ if value in data:
163
+ data.remove(value)
164
+
165
+ target.data = data
166
+
167
+
168
+ def list_add(form: Any, source: Any, target: Any) -> None:
169
+ """Append or insert an entry into a :class:`~wtforms.fields.FieldList`.
170
+
171
+ The insertion position is read from the source's value (typically
172
+ the clicked button's ``value=``). An empty string appends a fresh
173
+ entry at the end of the list. An integer inserts at that absolute
174
+ index and shifts the following entries one slot to the right. Any
175
+ other value does nothing.
176
+
177
+ Append a row from an Add button::
178
+
179
+ tasks = FieldList(StringField())
180
+ add_task = ButtonField("+ Add task").triggers("tasks", list_add)
181
+ """
182
+ _require(target, FieldList, "list_add")
183
+
184
+ _truncate_to_padding(target)
185
+ if not _source_value(source):
186
+ target.append_entry()
187
+ return
188
+
189
+ index = _source_index(source)
190
+ if index is not None:
191
+ target.insert_entry(index)
192
+
193
+
194
+ def list_remove(form: Any, source: Any, target: Any) -> None:
195
+ """Remove an entry from a :class:`~wtforms.fields.FieldList`.
196
+
197
+ The position is read from the source's value: an empty string
198
+ pops the last entry, while an integer drops the entry at that
199
+ absolute index. Any other value does nothing. Remaining entries are
200
+ re-indexed contiguously. An index captured client-side before the
201
+ call is then stale.
202
+
203
+ Remove a row from a per-row × button::
204
+
205
+ tasks = FieldList(StringField())
206
+ rm_task = ButtonField("×").triggers("tasks", list_remove)
207
+ """
208
+ _require(target, FieldList, "list_remove")
209
+
210
+ _truncate_to_padding(target)
211
+ index = _source_index(source) if _source_value(source) else len(target) - 1
212
+ if index is not None and 0 <= index < len(target):
213
+ target.pop_entry(index)
214
+
215
+
216
+ def list_clear(form: Any, _source: Any, target: Any) -> None:
217
+ """Empty a :class:`~wtforms.fields.FieldList` of all its entries.
218
+
219
+ Entries added afterwards in the same request are numbered from
220
+ ``0``.
221
+
222
+ Empty the whole list from one button::
223
+
224
+ tasks = FieldList(StringField())
225
+ clear = ButtonField("Clear all").triggers("tasks", list_clear)
226
+ """
227
+ _require(target, FieldList, "list_clear")
228
+
229
+ target.entries = []
230
+ target.last_index = -1
231
+
232
+
233
+ def list_reorder(form: Any, source: Any, target: Any) -> None:
234
+ """Reorder a :class:`~wtforms.fields.FieldList` in the order given by the source's value.
235
+
236
+ The value is a comma-separated list of the *old* indices in their
237
+ new order, e.g. ``"0,2,1"`` rebuilds the list as
238
+ ``[old[0], old[2], old[1]]``. The indices must cover
239
+ ``range(len(target))`` exactly once. A partial, duplicate or
240
+ out-of-range permutation does nothing, so a stale client request
241
+ cannot silently truncate or duplicate entries.
242
+
243
+ A hidden field carries the new index order (written client-side)::
244
+
245
+ tasks = FieldList(StringField())
246
+ reorder = HiddenField().triggers("tasks", list_reorder, on="change")
247
+ """
248
+ _require(target, FieldList, "list_reorder")
249
+
250
+ _truncate_to_padding(target)
251
+ value = _source_value(source)
252
+ if not value:
253
+ return
254
+
255
+ try:
256
+ order = [int(part) for part in value.split(",")]
257
+ except ValueError:
258
+ return
259
+
260
+ if sorted(order) != list(range(len(target.entries))):
261
+ return
262
+
263
+ _rebuild_in_order(target, order)
264
+
265
+
266
+ def list_move(offset: int) -> TriggerCallback:
267
+ """Return a mutation that moves a :class:`~wtforms.fields.FieldList` entry by ``offset``.
268
+
269
+ The entry index is read from the source's value. ``-1`` moves the
270
+ entry one row up, ``+1`` one row down. A move past either end of
271
+ the list, a missing value or a value that is not an index does
272
+ nothing. Remaining entries are re-indexed contiguously.
273
+
274
+ Move a row from per-row ↑ / ↓ buttons, an alternative to dragging
275
+ that works without JavaScript::
276
+
277
+ tasks = FieldList(StringField())
278
+ move_up = ButtonField("↑").triggers("tasks", list_move(-1))
279
+ move_down = ButtonField("↓").triggers("tasks", list_move(+1))
280
+ """
281
+
282
+ def _mutation(form: Any, source: Any, target: Any) -> None:
283
+ _require(target, FieldList, "list_move")
284
+
285
+ _truncate_to_padding(target)
286
+ idx = _source_index(source)
287
+ if idx is None:
288
+ return
289
+
290
+ new = idx + offset
291
+ n = len(target.entries)
292
+ if not (0 <= idx < n and 0 <= new < n):
293
+ return
294
+
295
+ order = list(range(n))
296
+ order.insert(new, order.pop(idx))
297
+ _rebuild_in_order(target, order)
298
+
299
+ return _mutation
300
+
301
+
302
+ def _rebuild_in_order(target: FieldList, order: list[int]) -> None:
303
+ """Rebuild ``target`` with its entries taken in ``order`` (old indices)."""
304
+ # Snapshot data in the desired order before draining: pop_entry
305
+ # mutates entries in place and reindexes after each call.
306
+ data_in_order = [target.entries[i].data for i in order]
307
+ for _ in range(len(order)):
308
+ target.pop_entry()
309
+
310
+ for data in data_in_order:
311
+ target.append_entry(data)
@@ -0,0 +1,308 @@
1
+ """Build the partial response of a form, as an ``HtmxResponse``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from copy import copy
7
+ from dataclasses import dataclass
8
+ from dataclasses import field
9
+ from html.parser import HTMLParser
10
+ from typing import Any
11
+ from typing import TYPE_CHECKING
12
+
13
+ from markupsafe import Markup
14
+ from wtforms.fields import Field
15
+
16
+ from .headers import _HtmxRequestInfo
17
+ from .headers import _parse_request
18
+
19
+ if TYPE_CHECKING:
20
+ from .form import HtmxFormMixin
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class HtmxResponse:
25
+ """An htmx partial response.
26
+
27
+ Pairs the body to return with any htmx response headers the
28
+ library computed for it.
29
+ """
30
+
31
+ body: str
32
+ """The rendered HTML fragment to send back to the client."""
33
+
34
+ headers: Mapping[str, str] = field(default_factory=dict)
35
+ """htmx response headers the library computed for this response.
36
+
37
+ The library sets :htmx-header:`hx-retarget` (when the fragment's
38
+ outer element is not the element htmx targeted) and
39
+ :htmx-header:`hx-reswap` (``none`` when a selector target's callback
40
+ returns no body). Add any other htmx response header yourself
41
+ (:htmx-header:`hx-trigger` to fire a client event,
42
+ :htmx-header:`hx-redirect`, :htmx-header:`hx-refresh`,
43
+ :htmx-header:`hx-push-url`, :htmx-header:`hx-reselect`, …) by merging
44
+ it into a new dict when you build the framework response::
45
+
46
+ if result := form.partial():
47
+ return Response(
48
+ result.body,
49
+ headers={**result.headers, "HX-Trigger": "saved"},
50
+ )
51
+
52
+ The mapping may be read-only, so do not assign into it.
53
+ """
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class _HtmxMutation:
58
+ """Render plan of one request: the targets to swap, a primary override, OOB extras.
59
+
60
+ Dispatch records the targets and the callback results. The plan applies
61
+ the callback return-value contract (see the API reference).
62
+ """
63
+
64
+ targets: list[Any] = field(default_factory=list)
65
+ primary_override: Markup | None = None
66
+ primary_field: Any = None
67
+ oob_extras: list[Any] = field(default_factory=list)
68
+ selector_dispatched: bool = False
69
+
70
+ @property
71
+ def dispatched(self) -> bool:
72
+ """Whether a trigger fired and left something to render."""
73
+ return bool(
74
+ self.targets
75
+ or self.primary_override is not None
76
+ or self.primary_field is not None
77
+ or self.selector_dispatched
78
+ )
79
+
80
+ def add_result(self, result: Any) -> None:
81
+ """Record a callback's return value.
82
+
83
+ Check for a Field before an iterable: SelectFieldBase and FieldList are
84
+ iterable too.
85
+ """
86
+ if result is None:
87
+ return
88
+
89
+ if isinstance(result, Field):
90
+ self.primary_field = result
91
+ return
92
+
93
+ if isinstance(result, (str, Markup)):
94
+ self.primary_override = Markup(result)
95
+ return
96
+
97
+ items = list(result)
98
+ if not items:
99
+ return
100
+
101
+ primary, *rest = items
102
+ if isinstance(primary, (str, Markup)):
103
+ self.primary_override = Markup(primary)
104
+ elif isinstance(primary, Field):
105
+ self.primary_field = primary
106
+
107
+ # primary is None → default render kept (no override set).
108
+ self.oob_extras.extend(rest)
109
+
110
+ def split_targets(self) -> tuple[Any, list[Any]]:
111
+ """Return the primary field, if any, and the targets rendered out-of-band.
112
+
113
+ A callback override replaces ``targets[0]``, unless a selector was
114
+ dispatched: then every target is out-of-band.
115
+ """
116
+ if self.primary_override is None and self.primary_field is None:
117
+ return (self.targets[0] if self.targets else None), self.targets[1:]
118
+
119
+ rest = self.targets if self.selector_dispatched else self.targets[1:]
120
+ return self.primary_field, [t for t in rest if t is not self.primary_field]
121
+
122
+
123
+ class _OuterTagParser(HTMLParser):
124
+ """Locate the first opening tag of a fragment.
125
+
126
+ ``tag`` stays ``None`` when the fragment has no tag. ``end`` is the
127
+ offset just past the tag's ``>``.
128
+ """
129
+
130
+ def __init__(self, html: str) -> None:
131
+ super().__init__()
132
+ self.tag: str | None = None
133
+ self.attrs: dict[str, str | None] = {}
134
+ self.end = 0
135
+ # getpos() counts lines on "\n" only, unlike str.splitlines().
136
+ self._lines = html.split("\n")
137
+ self.feed(html)
138
+
139
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
140
+ if self.tag is not None:
141
+ return
142
+
143
+ self.tag = tag
144
+ self.attrs = dict(attrs)
145
+ lineno, col = self.getpos()
146
+ start = sum(len(line) + 1 for line in self._lines[: lineno - 1]) + col
147
+ self.end = start + len(self.get_starttag_text() or "")
148
+
149
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
150
+ self.handle_starttag(tag, attrs)
151
+
152
+
153
+ def _extract_outer_id(html: str) -> str | None:
154
+ """Return the ``id`` of the first opening tag in ``html``, if any."""
155
+ return _OuterTagParser(html).attrs.get("id") or None
156
+
157
+
158
+ def _with_swap_oob(html: str) -> str:
159
+ """Add ``hx-swap-oob="outerHTML"`` to the outer element of an OOB fragment.
160
+
161
+ Left untouched: a fragment that already sets ``hx-swap-oob``, and a
162
+ <template> wrapper. Raise ValueError when the outer element has no id.
163
+ """
164
+ outer = _OuterTagParser(html)
165
+ if outer.tag is None or outer.tag == "template":
166
+ return html
167
+
168
+ if "hx-swap-oob" in outer.attrs or "data-hx-swap-oob" in outer.attrs:
169
+ return html
170
+
171
+ if not outer.attrs.get("id"):
172
+ raise ValueError(
173
+ f"An out-of-band HTML fragment needs an id on its outer element: {html!r}"
174
+ )
175
+
176
+ head = html[: outer.end]
177
+ close = "/>" if head.endswith("/>") else ">"
178
+ head = head[: -len(close)].rstrip()
179
+ return f'{head} hx-swap-oob="outerHTML"{close}{html[outer.end :]}'
180
+
181
+
182
+ def _render_mutation_target(field: Any, *, oob: bool) -> str:
183
+ """Render one field for a partial response, through its wrapper.
184
+
185
+ An out-of-band field gets ``hx-swap-oob`` on the outer element of its
186
+ rendering, so the whole wrapper is swapped. A ``hide_value`` widget
187
+ (PasswordInput) shows the typed value during this render, so the swap
188
+ does not erase it.
189
+ """
190
+ html = _render_revealed(field)
191
+ return _with_swap_oob(html) if oob else html
192
+
193
+
194
+ def _render_revealed(field: Any) -> str:
195
+ """Render ``field``, with the value of a ``hide_value`` widget shown."""
196
+ widget = getattr(field, "widget", None)
197
+ if not (getattr(widget, "hide_value", False) and field.data):
198
+ return str(field.meta.render_field(field, {}))
199
+
200
+ # The widget is shared by every field of the class: reveal a copy.
201
+ field.widget = copy(widget)
202
+ field.widget.hide_value = False
203
+ try:
204
+ return str(field.meta.render_field(field, {}))
205
+ finally:
206
+ field.widget = widget
207
+
208
+
209
+ def _render_oob(form: HtmxFormMixin, extras: list[Any]) -> Markup:
210
+ """Render out-of-band extras, each once.
211
+
212
+ Fields are deduplicated by identity, HTML fragments by outer id.
213
+ """
214
+ html = Markup("")
215
+ seen: set[Any] = set()
216
+ for extra in extras:
217
+ is_html = isinstance(extra, (str, Markup))
218
+ key = _extract_outer_id(str(extra)) if is_html else id(extra)
219
+ if key is not None and key in seen:
220
+ continue
221
+
222
+ seen.add(key)
223
+ if is_html:
224
+ html += Markup(_with_swap_oob(str(extra)))
225
+ else:
226
+ html += Markup(_render_mutation_target(extra, oob=True))
227
+
228
+ return html
229
+
230
+
231
+ def _render_mutation_response(
232
+ form: HtmxFormMixin,
233
+ mutation: _HtmxMutation,
234
+ request: _HtmxRequestInfo,
235
+ ) -> HtmxResponse:
236
+ """Build the response for a dispatched mutation.
237
+
238
+ The primary swap is, in order: the callback's HTML, the callback's field,
239
+ the first target. The other targets and the callback's extras are
240
+ out-of-band. Without a primary swap, the body only holds the extras, with
241
+ ``HX-Reswap: none`` and no ``HX-Retarget``.
242
+ """
243
+ response_headers: dict[str, str] = {}
244
+ primary_field, rest = mutation.split_targets()
245
+ # Keep the body a Markup: ``Markup.__radd__`` escapes a plain ``str``.
246
+ if mutation.primary_override is not None:
247
+ body = mutation.primary_override
248
+ elif primary_field is not None:
249
+ body = Markup(_render_mutation_target(primary_field, oob=False))
250
+ else:
251
+ oob = _render_oob(form, rest + mutation.oob_extras)
252
+ return HtmxResponse(body=str(oob), headers={"HX-Reswap": "none"})
253
+
254
+ body += _render_oob(form, rest + mutation.oob_extras)
255
+ if retarget := _compute_retarget(str(body), request):
256
+ response_headers["HX-Retarget"] = retarget
257
+
258
+ return HtmxResponse(body=str(body), headers=response_headers)
259
+
260
+
261
+ def _compute_partial(
262
+ form: HtmxFormMixin,
263
+ headers: Mapping[str, str] | None,
264
+ ) -> HtmxResponse | None:
265
+ """Render the mutation plan, or a datalist refresh.
266
+
267
+ Return ``None`` for a non-htmx or boosted request, or when there is
268
+ nothing to render.
269
+ """
270
+ request = _parse_request(headers)
271
+ if not request.htmx or request.boosted:
272
+ return None
273
+
274
+ mutation = getattr(form, "_htmx_mutation", None)
275
+ if mutation is not None and mutation.dispatched:
276
+ return _render_mutation_response(form, mutation, request)
277
+
278
+ return _compute_datalist_partial(form, request)
279
+
280
+
281
+ def _compute_datalist_partial(
282
+ form: HtmxFormMixin,
283
+ request: _HtmxRequestInfo,
284
+ ) -> HtmxResponse | None:
285
+ """Render the datalist that sent the request, alone.
286
+
287
+ Its ``hx-select`` picks the ``<option>`` children. No validation runs.
288
+ """
289
+ field = form._htmx_resolve_datalist(request.source_id)
290
+ if field is None:
291
+ return None
292
+
293
+ return HtmxResponse(body=str(field.datalist()), headers={})
294
+
295
+
296
+ def _compute_retarget(body: str, request: _HtmxRequestInfo) -> str | None:
297
+ """Return an ``hx-retarget`` selector for the fragment's outer id.
298
+
299
+ ``None`` when the fragment has no outer id, or when htmx already
300
+ targets that element.
301
+ """
302
+ if not (outer_id := _extract_outer_id(body)):
303
+ return None
304
+
305
+ if request.target_id == outer_id:
306
+ return None
307
+
308
+ return f"#{outer_id}"
wtforms_htmx/py.typed ADDED
File without changes