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/__init__.py +18 -0
- wtforms_htmx/form.py +761 -0
- wtforms_htmx/headers.py +55 -0
- wtforms_htmx/meta.py +416 -0
- wtforms_htmx/mutations.py +311 -0
- wtforms_htmx/partial.py +308 -0
- wtforms_htmx/py.typed +0 -0
- wtforms_htmx/tree.py +147 -0
- wtforms_htmx/triggers.py +182 -0
- wtforms_htmx/wrapper.py +24 -0
- wtforms_htmx-0.1.0.dist-info/METADATA +87 -0
- wtforms_htmx-0.1.0.dist-info/RECORD +14 -0
- wtforms_htmx-0.1.0.dist-info/WHEEL +4 -0
- wtforms_htmx-0.1.0.dist-info/licenses/LICENSE.rst +28 -0
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)
|
wtforms_htmx/triggers.py
ADDED
|
@@ -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
|
wtforms_htmx/wrapper.py
ADDED
|
@@ -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,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.
|