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/tree.py ADDED
@@ -0,0 +1,147 @@
1
+ """Walk the field tree of a form and resolve fields by name or id."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+ from typing import TYPE_CHECKING
8
+
9
+ from wtforms.fields import FieldList
10
+ from wtforms.fields import FormField
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Iterator
14
+
15
+ from wtforms import Field
16
+
17
+
18
+ _PY_PATH_RE = re.compile(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*")
19
+
20
+ _PLAIN_ID_RE = re.compile(r"[A-Za-z_][\w-]*")
21
+
22
+
23
+ def _state(field: Any, name: str, default: Any = None) -> Any:
24
+ """Return the wtforms-htmx attribute ``name`` set on ``field``, or ``default``.
25
+
26
+ It reads ``__dict__``: ``FormField.__getattr__`` recurses while its form
27
+ is being built.
28
+ """
29
+ return field.__dict__.get(name, default)
30
+
31
+
32
+ def _looks_like_python_path(name: str) -> bool:
33
+ """Whether ``name`` is a Python identifier path (``"a"``, ``"a.b.c"``).
34
+
35
+ Anything else (``#id``, ``.class``, ``closest form``, a dashed name) is
36
+ a selector.
37
+ """
38
+ return _PY_PATH_RE.fullmatch(name) is not None
39
+
40
+
41
+ def _fields_of(container: Any) -> dict[str, Any]:
42
+ """Return the direct fields of a form, a FormField or a FieldList."""
43
+ # A sub-form still being built must not recurse through FormField.__getattr__.
44
+ if isinstance(container, FormField):
45
+ form = container.__dict__.get("form")
46
+ return form._fields if form is not None else {}
47
+
48
+ if isinstance(container, FieldList):
49
+ entries = container.__dict__.get("entries")
50
+ return {entry.name: entry for entry in entries} if entries else {}
51
+
52
+ return container._fields
53
+
54
+
55
+ def _walk_fields(container: Any) -> Iterator[Any]:
56
+ """Yield every field of ``container``, descending into FieldList and FormField."""
57
+ for field in _fields_of(container).values():
58
+ yield field
59
+ if isinstance(field, FieldList | FormField):
60
+ yield from _walk_fields(field)
61
+
62
+
63
+ def _root_of(form: Any) -> Any:
64
+ """Walk up ``_parent_form`` links to the top-level form."""
65
+ while form._parent_form is not None:
66
+ form = form._parent_form
67
+
68
+ return form
69
+
70
+
71
+ def _resolve_field_by_id(container: Any, field_id: str) -> Field | None:
72
+ """Find the field whose ``id`` is ``field_id`` anywhere in the form tree."""
73
+ return next(
74
+ (f for f in _walk_fields(container) if getattr(f, "id", None) == field_id),
75
+ None,
76
+ )
77
+
78
+
79
+ def _resolve_field(container: Any, name: str) -> Field | None:
80
+ """Find a field by its HTML ``name``, descending into FormField and FieldList."""
81
+ fields = _fields_of(container)
82
+ if name in fields:
83
+ return fields[name]
84
+
85
+ remaining = name
86
+ while True:
87
+ dash = remaining.rfind("-")
88
+ if dash == -1:
89
+ return None
90
+
91
+ prefix = remaining[:dash]
92
+ remaining = prefix
93
+ if prefix not in fields:
94
+ continue
95
+
96
+ field = fields[prefix]
97
+ tail = name[len(prefix) + 1 :]
98
+ if isinstance(field, FieldList):
99
+ return _descend_into_fieldlist(field, tail)
100
+
101
+ if isinstance(field, FormField):
102
+ return _resolve_field(field, tail)
103
+
104
+ return None
105
+
106
+
107
+ def _descend_into_fieldlist(field: Any, tail: str) -> Field | None:
108
+ """Walk into a FieldList's entries from the remaining ``<index>[-rest]`` tail."""
109
+ idx_str, sep, rest = tail.partition("-")
110
+ try:
111
+ idx = int(idx_str)
112
+ except ValueError:
113
+ return None
114
+
115
+ if idx < 0 or idx >= len(field):
116
+ return None
117
+
118
+ entry = field[idx]
119
+ if not sep:
120
+ return entry
121
+
122
+ if isinstance(entry, FormField):
123
+ return _resolve_field(entry, rest)
124
+
125
+ return None
126
+
127
+
128
+ def _classify_target(host: Any, root: Any, target: str):
129
+ """Classify a trigger target string.
130
+
131
+ Return ``("field", field)`` for a Python path resolved in the *host*
132
+ scope, or a ``"#id"`` matching a field from the *root*;
133
+ ``("selector", target)`` for any other selector; ``("unbound", target)``
134
+ for a Python path that matches no field.
135
+ """
136
+ if _looks_like_python_path(target):
137
+ field = _resolve_field(host, target.replace(".", "-"))
138
+ return ("field", field) if field is not None else ("unbound", target)
139
+
140
+ if (
141
+ target.startswith("#")
142
+ and _PLAIN_ID_RE.fullmatch(target[1:])
143
+ and (field := _resolve_field_by_id(root, target[1:])) is not None
144
+ ):
145
+ return ("field", field)
146
+
147
+ return ("selector", target)
@@ -0,0 +1,182 @@
1
+ """Trigger declarations: the ``.triggers()`` and ``.triggered_by()`` field methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+ from typing import NamedTuple
8
+ from typing import TypeAlias
9
+
10
+ from wtforms.fields.core import UnboundField
11
+
12
+ TriggerCallback: TypeAlias = Callable[[Any, Any, Any], Any]
13
+ """A ``(form, source, target) -> result`` callback of a trigger declaration.
14
+
15
+ ``form`` is the form that holds the declaring field. ``source`` is the
16
+ field that fired, or ``None`` for the ``"*"`` wildcard. ``target`` is a
17
+ field, or the selector string of a DOM target. See :ref:`callback-return`
18
+ for the ``result``.
19
+ """
20
+
21
+
22
+ class _TriggerEntry(NamedTuple):
23
+ """Per-link declaration set by ``.triggers()`` / ``.triggered_by()``."""
24
+
25
+ event: str | None = None
26
+ """Optional ``hx-trigger`` event override.
27
+
28
+ ``None`` defers to the form-wide ``htmx_default_trigger``.
29
+ """
30
+
31
+ callback: TriggerCallback | None = None
32
+ """Optional ``(form, source, target) -> result`` callback.
33
+
34
+ ``None`` means a plain re-render.
35
+ """
36
+
37
+
38
+ def _normalize_trigger_args(
39
+ target: str | list[str],
40
+ callback: TriggerCallback | None,
41
+ on: str | None,
42
+ *,
43
+ method_name: str,
44
+ ) -> dict[str, _TriggerEntry]:
45
+ """Turn ``.triggers()`` / ``.triggered_by()`` arguments into ``{name: _TriggerEntry}``.
46
+
47
+ Raise on a wrong type, an empty list, or a misplaced wildcard.
48
+ """
49
+ if on is not None and not isinstance(on, str):
50
+ raise TypeError(
51
+ f".{method_name}(..., on=...): event must be str or None, "
52
+ f"got {type(on).__name__}"
53
+ )
54
+
55
+ if callback is not None and not callable(callback):
56
+ raise TypeError(
57
+ f".{method_name}(..., callback): callback must be callable or None, "
58
+ f"got {type(callback).__name__}"
59
+ )
60
+
61
+ if not isinstance(target, str | list):
62
+ raise TypeError(
63
+ f".{method_name}(...): target must be str or list[str], "
64
+ f"got {type(target).__name__}"
65
+ )
66
+
67
+ if target == []:
68
+ raise ValueError(f".{method_name}([]): target list must not be empty")
69
+
70
+ if isinstance(target, list) and "*" in target:
71
+ raise ValueError(
72
+ f'.{method_name}([..., "*", ...]): wildcard "*" cannot be mixed in a list'
73
+ )
74
+
75
+ if target == "*" and method_name == "triggers":
76
+ raise ValueError(
77
+ f'.{method_name}("*", ...): wildcard is only supported on .triggered_by(...)'
78
+ )
79
+
80
+ names = [target] if isinstance(target, str) else target
81
+ for name in names:
82
+ if not isinstance(name, str):
83
+ raise TypeError(
84
+ f".{method_name}(...): list items must be str, "
85
+ f"got {type(name).__name__}"
86
+ )
87
+
88
+ return dict.fromkeys(names, _TriggerEntry(event=on, callback=callback))
89
+
90
+
91
+ def _unbound_triggers(
92
+ self,
93
+ target: str | list[str],
94
+ callback: TriggerCallback | None = None,
95
+ *,
96
+ on: str | None = None,
97
+ ):
98
+ """Declare what an event on this field re-renders, optionally running a callback first.
99
+
100
+ Each call adds (or refines) one or more of this field's trigger
101
+ declarations. Calls compose. If a target name is declared twice,
102
+ the later call wins.
103
+
104
+ :param target: a field name, a list of field names, or an htmx
105
+ selector. A Python identifier resolves to a field by name. When
106
+ no field matches, a :exc:`UserWarning` is emitted and the name is
107
+ used as a selector, except for ``this``, ``next``, ``previous``
108
+ and ``body``. A
109
+ dotted ``"address.region"`` descends into a sub-form. A
110
+ ``"#id"`` matching a field's :mdn-global-attr:`id` anywhere in
111
+ the form re-renders that field. Any other string is passed
112
+ through verbatim to :htmx-attr:`hx-target`: any selector htmx
113
+ understands
114
+ (``".result"``, ``"closest .toast"``, ``"this"``,
115
+ ``"find .inner"``, ``"next"`` / ``"previous"``, …). See
116
+ :ref:`dom-targets`. A source has at most one selector target
117
+ over all its declarations, and a field with a selector target
118
+ cannot also listen to another field. Either case raises
119
+ :exc:`ValueError` when the form is built.
120
+ :param callback: optional ``(form, source, target) -> result``
121
+ callable run server-side before the target is re-rendered.
122
+ ``target`` is a :class:`~wtforms.fields.Field` for a field
123
+ target, or the raw selector ``str`` for a DOM target. The
124
+ ``result`` drives the response body (see :ref:`callback-return`).
125
+ Without a callback, the target re-renders from the current form
126
+ state. See :mod:`wtforms_htmx.mutations` for built-in callbacks.
127
+ :param on: optional :htmx-attr:`hx-trigger` event override. When ``None``,
128
+ the form-wide :attr:`~wtforms_htmx.HtmxMeta.htmx_default_trigger`
129
+ applies.
130
+
131
+ .. code-block:: python
132
+
133
+ country = StringField().triggers("region")
134
+ country = StringField().triggers("region", on="change")
135
+ postal = StringField().triggers(["city", "region"])
136
+ eur = IntegerField().triggers("jpy", eur_to_jpy)
137
+ add_item = ButtonField("Add item").triggers("items", list_add)
138
+ content = TextAreaField().triggers("#preview", render_md, on="input delay:100ms")
139
+ # mixed events: chain
140
+ src = StringField().triggers("a").triggers("b", on="click")
141
+ """
142
+ entries = _normalize_trigger_args(target, callback, on, method_name="triggers")
143
+ self._htmx_triggers = {**getattr(self, "_htmx_triggers", {}), **entries}
144
+ return self
145
+
146
+
147
+ def _unbound_triggered_by(
148
+ self,
149
+ source: str | list[str],
150
+ callback: TriggerCallback | None = None,
151
+ *,
152
+ on: str | None = None,
153
+ ):
154
+ """Declare which field events re-render this field, optionally running a callback first.
155
+
156
+ Symmetric to :meth:`.triggers() <wtforms.fields.core.UnboundField.triggers>`:
157
+ same shapes, same ``on`` / ``callback`` / return-value semantics.
158
+
159
+ The wildcard ``"*"``, used as a single string (not in a list), means
160
+ *any field of the form*: nested fields, the target itself, and
161
+ :class:`~wtforms.fields.FieldList` rows, including rows added later.
162
+ Inputs in the form that are not WTForms fields are not covered. The
163
+ callback receives ``source=None``, since there is no single source
164
+ field.
165
+
166
+ Unlike :meth:`triggers <wtforms.fields.core.UnboundField.triggers>`,
167
+ ``triggered_by`` does *not* accept DOM selectors: the source must be
168
+ a real field of the form. A string that matches none emits a
169
+ :exc:`UserWarning`, and the declaration is ignored.
170
+
171
+ .. code-block:: python
172
+
173
+ status = ButtonField("").triggered_by("*", autosave, on="input delay:800ms")
174
+ """
175
+ entries = _normalize_trigger_args(source, callback, on, method_name="triggered_by")
176
+ self._htmx_triggered_by = {**getattr(self, "_htmx_triggered_by", {}), **entries}
177
+ return self
178
+
179
+
180
+ UnboundField.triggers = _unbound_triggers
181
+
182
+ UnboundField.triggered_by = _unbound_triggered_by
@@ -0,0 +1,24 @@
1
+ """The ``wrapper=`` argument of every field declaration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from wtforms.fields.core import UnboundField
6
+
7
+ _UNSET = object()
8
+
9
+ _original_unbound_init = UnboundField.__init__
10
+
11
+
12
+ def _patched_unbound_init(self, field_class, *args, **kwargs):
13
+ """Take the ``wrapper=`` kwarg off before WTForms sees it.
14
+
15
+ It is stored on the unbound field, and ``bind_field`` copies it to the
16
+ bound field.
17
+ """
18
+ wrapper = kwargs.pop("wrapper", _UNSET)
19
+ _original_unbound_init(self, field_class, *args, **kwargs)
20
+ if wrapper is not _UNSET:
21
+ self._htmx_wrapper = wrapper
22
+
23
+
24
+ UnboundField.__init__ = _patched_unbound_init
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: wtforms-htmx
3
+ Version: 0.1.0
4
+ Summary: Interactive WTForms forms declared in Python and powered by htmx, with no JavaScript to write.
5
+ Keywords: wtforms,htmx,forms,validation
6
+ License-Expression: BSD-3-Clause
7
+ License-File: LICENSE.rst
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Web Environment
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: Python :: 3.15
19
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
20
+ Requires-Dist: wtforms>=3.3.0,<4
21
+ Maintainer: Éloi Rivard
22
+ Maintainer-email: Éloi Rivard <eloi@yaal.coop>
23
+ Requires-Python: >=3.11
24
+ Project-URL: Documentation, https://wtforms-htmx.readthedocs.io/
25
+ Project-URL: Changes, https://wtforms-htmx.readthedocs.io/changes/
26
+ Project-URL: Source Code, https://github.com/yaal-coop/wtforms-htmx
27
+ Project-URL: Issue Tracker, https://github.com/yaal-coop/wtforms-htmx/issues
28
+ Description-Content-Type: text/markdown
29
+
30
+ <p align="center">
31
+ <picture>
32
+ <source media="(prefers-color-scheme: dark)" srcset="docs/_static/logo-dark.webp">
33
+ <img src="docs/_static/logo.webp" alt="wtforms-htmx">
34
+ </picture>
35
+ </p>
36
+
37
+ # wtforms-htmx
38
+
39
+ Interactive [WTForms](https://wtforms.readthedocs.io/) forms declared in
40
+ Python and powered by [htmx](https://htmx.org/), with no JavaScript to
41
+ write, or even any htmx.
42
+
43
+ <p align="center">
44
+ <picture>
45
+ <source media="(prefers-color-scheme: dark)" srcset="https://wtforms-htmx.readthedocs.io/en/latest/_static/screencasts/title-slug-dark.webp">
46
+ <img src="https://wtforms-htmx.readthedocs.io/en/latest/_static/screencasts/title-slug-light.webp" width="700" alt="Typing a title fills in its slug as you type.">
47
+ </picture>
48
+ </p>
49
+
50
+ Here's (almost) everything wtforms-htmx adds on top of a regular form:
51
+
52
+ ```python
53
+ class PostForm(HtmxForm):
54
+ title = StringField().triggers("slug", slugify)
55
+ slug = StringField()
56
+ ```
57
+
58
+ ## Features
59
+
60
+ - **Declare interactivity in Python.**
61
+ Right on the field declaration, not scattered across `hx-*`
62
+ attributes.
63
+ - **No extra endpoints.** Interactions are handled by the form's
64
+ existing view. You don't write a route for each one.
65
+ - **Progressive enhancement.** The form works without JavaScript;
66
+ htmx layers live updates and partial swaps on top. Same form, two
67
+ render paths.
68
+ - **Opinionated defaults, fully overridable.** wtforms-htmx renders
69
+ the right `hx-*` for you, but they're yours to override.
70
+ - **Common patterns in a few lines.** Inline validation and `FieldList`
71
+ add/remove/reorder are built in. The examples show how to build
72
+ autocomplete, comboboxes and dependent fields.
73
+
74
+ Full documentation: <https://wtforms-htmx.readthedocs.io/>
75
+
76
+ ## Example app
77
+
78
+ A runnable gallery of examples lives in `examples/`. The
79
+ [documentation](https://wtforms-htmx.readthedocs.io/) shows most of them with a
80
+ screencast. To run it locally:
81
+
82
+ ```sh
83
+ uv sync --group example
84
+ uv run python -m examples
85
+ ```
86
+
87
+ Then open <http://127.0.0.1:5000/>.
@@ -0,0 +1,14 @@
1
+ wtforms_htmx/__init__.py,sha256=zxluhBiIU6y0rjWgojAFFcl_2Pbcgge7MHLnOFpVjuQ,419
2
+ wtforms_htmx/form.py,sha256=BlIEywrUJswQVG4CUnZorpTXfFbUVYLrdSBbleQ9ySk,29994
3
+ wtforms_htmx/headers.py,sha256=InOnCf5yvjeVK_FykuSHgAYGtvULDQYe0OtaSZ7bkk8,1718
4
+ wtforms_htmx/meta.py,sha256=5OFuRLPhb0cnFpqMjWEqA3_rvSLg9de7DJLYUwck8Qk,16629
5
+ wtforms_htmx/mutations.py,sha256=QwGgg5BoEA0EoG77q-KmeH7CoHFkc2JPBU4i2FIeakY,10314
6
+ wtforms_htmx/partial.py,sha256=DScIcDB_wG36ac2X7zVV37iv5ru6l85YHARgTUNCRfo,10276
7
+ wtforms_htmx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ wtforms_htmx/tree.py,sha256=cw6mAFVxoRIwJz9kqk-fATV-2iqnrFn14a-8fx75kZE,4377
9
+ wtforms_htmx/triggers.py,sha256=5OM9_iJg5xBF0Obb6FRfSffJib4eR-I5AIF1Ri_Z3ko,7035
10
+ wtforms_htmx/wrapper.py,sha256=ZRaphAFfxWqq-PulGkbPaFLDsgXgwVa_UTLM5m3UWXY,660
11
+ wtforms_htmx-0.1.0.dist-info/licenses/LICENSE.rst,sha256=_H3YS-sMh9nVxo79jXhnJ4h6vhp8Gzo6q4q-0SZhk7w,1475
12
+ wtforms_htmx-0.1.0.dist-info/WHEEL,sha256=1dD_GoEqEIxQ7VnrUExgdj_eExY4b4UgfR0iBlGKVac,80
13
+ wtforms_htmx-0.1.0.dist-info/METADATA,sha256=fumxwGeDx8uQxLftXFIuEaWeE0f8V4K77YuTImNWju4,3281
14
+ wtforms_htmx-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,28 @@
1
+ Copyright 2026 WTForms
2
+
3
+ Redistribution and use in source and binary forms, with or without
4
+ modification, are permitted provided that the following conditions are
5
+ met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright
8
+ notice, this list of conditions and the following disclaimer.
9
+
10
+ 2. Redistributions in binary form must reproduce the above copyright
11
+ notice, this list of conditions and the following disclaimer in the
12
+ documentation and/or other materials provided with the distribution.
13
+
14
+ 3. Neither the name of the copyright holder nor the names of its
15
+ contributors may be used to endorse or promote products derived from
16
+ this software without specific prior written permission.
17
+
18
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
21
+ PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22
+ HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
24
+ TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
25
+ PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
26
+ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
27
+ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.