flask-wtforms-htmx 0.1.0__tar.gz
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.
- flask_wtforms_htmx-0.1.0/LICENSE.rst +28 -0
- flask_wtforms_htmx-0.1.0/PKG-INFO +90 -0
- flask_wtforms_htmx-0.1.0/README.md +58 -0
- flask_wtforms_htmx-0.1.0/pyproject.toml +193 -0
- flask_wtforms_htmx-0.1.0/pyproject.toml.orig +163 -0
- flask_wtforms_htmx-0.1.0/src/flask_wtforms_htmx/__init__.py +119 -0
- flask_wtforms_htmx-0.1.0/src/flask_wtforms_htmx/py.typed +0 -0
|
@@ -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.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flask-wtforms-htmx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A drop-in FlaskForm that brings wtforms-htmx to Flask-WTF.
|
|
5
|
+
Keywords: wtforms,htmx,forms,flask,validation
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
License-File: LICENSE.rst
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Web Environment
|
|
10
|
+
Classifier: Framework :: Flask
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
21
|
+
Requires-Dist: flask>=3.0
|
|
22
|
+
Requires-Dist: flask-wtf>=1.2
|
|
23
|
+
Requires-Dist: wtforms-htmx>=0.1.0
|
|
24
|
+
Maintainer: Éloi Rivard
|
|
25
|
+
Maintainer-email: Éloi Rivard <eloi@yaal.coop>
|
|
26
|
+
Requires-Python: >=3.11
|
|
27
|
+
Project-URL: Documentation, https://flask-wtforms-htmx.readthedocs.io/
|
|
28
|
+
Project-URL: Changes, https://flask-wtforms-htmx.readthedocs.io/changes/
|
|
29
|
+
Project-URL: Source Code, https://github.com/yaal-coop/flask-wtforms-htmx
|
|
30
|
+
Project-URL: Issue Tracker, https://github.com/yaal-coop/flask-wtforms-htmx/issues
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# flask-wtforms-htmx
|
|
34
|
+
|
|
35
|
+
A drop-in `FlaskForm` that brings
|
|
36
|
+
[wtforms-htmx](https://github.com/yaal-coop/wtforms-htmx) to
|
|
37
|
+
[Flask-WTF](https://flask-wtf.readthedocs.io/).
|
|
38
|
+
|
|
39
|
+
**Status: alpha, work in progress.**
|
|
40
|
+
|
|
41
|
+
`HtmxFlaskForm` replaces Flask-WTF's `FlaskForm`. The usual
|
|
42
|
+
`validate_on_submit()` view answers htmx requests, and a mutation click
|
|
43
|
+
without JavaScript does not save the form. CSRF protection and file
|
|
44
|
+
uploads work as with Flask-WTF.
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
pip install flask-wtforms-htmx
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Example
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from flask import redirect, render_template, url_for
|
|
56
|
+
from wtforms import ButtonField, FieldList, StringField, SubmitField
|
|
57
|
+
from wtforms_htmx.mutations import list_add
|
|
58
|
+
|
|
59
|
+
from flask_wtforms_htmx import HtmxFlaskForm
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TagsForm(HtmxFlaskForm):
|
|
63
|
+
tags = FieldList(StringField(), min_entries=1)
|
|
64
|
+
add_tag = ButtonField("+").triggers("tags", list_add)
|
|
65
|
+
save = SubmitField("Save")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.route("/tags", methods=["GET", "POST"])
|
|
69
|
+
def tags():
|
|
70
|
+
form = TagsForm()
|
|
71
|
+
if form.validate_on_submit():
|
|
72
|
+
save(form.data)
|
|
73
|
+
return redirect(url_for("tags"))
|
|
74
|
+
|
|
75
|
+
return render_template("tags.html", form=form)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
With htmx, a click on `+` adds a row without reloading the page. Without
|
|
79
|
+
JavaScript, it reloads the page with the new row, and the form is not
|
|
80
|
+
saved.
|
|
81
|
+
|
|
82
|
+
## Links
|
|
83
|
+
|
|
84
|
+
- Documentation: <https://flask-wtforms-htmx.readthedocs.io/>
|
|
85
|
+
- Source: <https://github.com/yaal-coop/flask-wtforms-htmx>
|
|
86
|
+
- Issues: <https://github.com/yaal-coop/flask-wtforms-htmx/issues>
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
BSD-3-Clause. See `LICENSE.rst`.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# flask-wtforms-htmx
|
|
2
|
+
|
|
3
|
+
A drop-in `FlaskForm` that brings
|
|
4
|
+
[wtforms-htmx](https://github.com/yaal-coop/wtforms-htmx) to
|
|
5
|
+
[Flask-WTF](https://flask-wtf.readthedocs.io/).
|
|
6
|
+
|
|
7
|
+
**Status: alpha, work in progress.**
|
|
8
|
+
|
|
9
|
+
`HtmxFlaskForm` replaces Flask-WTF's `FlaskForm`. The usual
|
|
10
|
+
`validate_on_submit()` view answers htmx requests, and a mutation click
|
|
11
|
+
without JavaScript does not save the form. CSRF protection and file
|
|
12
|
+
uploads work as with Flask-WTF.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pip install flask-wtforms-htmx
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Example
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from flask import redirect, render_template, url_for
|
|
24
|
+
from wtforms import ButtonField, FieldList, StringField, SubmitField
|
|
25
|
+
from wtforms_htmx.mutations import list_add
|
|
26
|
+
|
|
27
|
+
from flask_wtforms_htmx import HtmxFlaskForm
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TagsForm(HtmxFlaskForm):
|
|
31
|
+
tags = FieldList(StringField(), min_entries=1)
|
|
32
|
+
add_tag = ButtonField("+").triggers("tags", list_add)
|
|
33
|
+
save = SubmitField("Save")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.route("/tags", methods=["GET", "POST"])
|
|
37
|
+
def tags():
|
|
38
|
+
form = TagsForm()
|
|
39
|
+
if form.validate_on_submit():
|
|
40
|
+
save(form.data)
|
|
41
|
+
return redirect(url_for("tags"))
|
|
42
|
+
|
|
43
|
+
return render_template("tags.html", form=form)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
With htmx, a click on `+` adds a row without reloading the page. Without
|
|
47
|
+
JavaScript, it reloads the page with the new row, and the form is not
|
|
48
|
+
saved.
|
|
49
|
+
|
|
50
|
+
## Links
|
|
51
|
+
|
|
52
|
+
- Documentation: <https://flask-wtforms-htmx.readthedocs.io/>
|
|
53
|
+
- Source: <https://github.com/yaal-coop/flask-wtforms-htmx>
|
|
54
|
+
- Issues: <https://github.com/yaal-coop/flask-wtforms-htmx/issues>
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
BSD-3-Clause. See `LICENSE.rst`.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "flask-wtforms-htmx"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A drop-in FlaskForm that brings wtforms-htmx to Flask-WTF."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "BSD-3-Clause"
|
|
7
|
+
license-files = ["LICENSE.rst"]
|
|
8
|
+
keywords = [
|
|
9
|
+
"wtforms",
|
|
10
|
+
"htmx",
|
|
11
|
+
"forms",
|
|
12
|
+
"flask",
|
|
13
|
+
"validation",
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Web Environment",
|
|
18
|
+
"Framework :: Flask",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Programming Language :: Python :: 3.15",
|
|
28
|
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
29
|
+
]
|
|
30
|
+
requires-python = ">=3.11"
|
|
31
|
+
dependencies = [
|
|
32
|
+
"flask>=3.0",
|
|
33
|
+
"flask-wtf>=1.2",
|
|
34
|
+
"wtforms-htmx>=0.1.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[[project.maintainers]]
|
|
38
|
+
name = "Éloi Rivard"
|
|
39
|
+
email = "eloi@yaal.coop"
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Documentation = "https://flask-wtforms-htmx.readthedocs.io/"
|
|
43
|
+
Changes = "https://flask-wtforms-htmx.readthedocs.io/changes/"
|
|
44
|
+
"Source Code" = "https://github.com/yaal-coop/flask-wtforms-htmx"
|
|
45
|
+
"Issue Tracker" = "https://github.com/yaal-coop/flask-wtforms-htmx/issues"
|
|
46
|
+
|
|
47
|
+
[build-system]
|
|
48
|
+
requires = ["uv_build>=0.13.1,<0.14"]
|
|
49
|
+
build-backend = "uv_build"
|
|
50
|
+
|
|
51
|
+
[tool.uv]
|
|
52
|
+
required-version = ">=0.13.1"
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
testpaths = ["tests/unit"]
|
|
56
|
+
pythonpath = ["."]
|
|
57
|
+
filterwarnings = ["error"]
|
|
58
|
+
|
|
59
|
+
[tool.coverage.run]
|
|
60
|
+
branch = true
|
|
61
|
+
source = [
|
|
62
|
+
"flask_wtforms_htmx",
|
|
63
|
+
"tests",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[tool.coverage.paths]
|
|
67
|
+
source = [
|
|
68
|
+
"src",
|
|
69
|
+
"*/site-packages",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
[tool.coverage.report]
|
|
73
|
+
fail_under = 100
|
|
74
|
+
exclude_lines = [
|
|
75
|
+
"pragma: no cover",
|
|
76
|
+
"except ImportError",
|
|
77
|
+
"if TYPE_CHECKING:",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
[tool.ruff]
|
|
81
|
+
src = ["src"]
|
|
82
|
+
fix = true
|
|
83
|
+
show-fixes = true
|
|
84
|
+
output-format = "full"
|
|
85
|
+
|
|
86
|
+
[tool.ruff.lint]
|
|
87
|
+
select = [
|
|
88
|
+
"B",
|
|
89
|
+
"C90",
|
|
90
|
+
"D",
|
|
91
|
+
"E",
|
|
92
|
+
"F",
|
|
93
|
+
"I",
|
|
94
|
+
"UP",
|
|
95
|
+
"W",
|
|
96
|
+
"SIM",
|
|
97
|
+
"RET",
|
|
98
|
+
"BLE",
|
|
99
|
+
"TRY",
|
|
100
|
+
"ERA",
|
|
101
|
+
"PIE",
|
|
102
|
+
"RUF",
|
|
103
|
+
"DTZ",
|
|
104
|
+
"PTH",
|
|
105
|
+
]
|
|
106
|
+
ignore = [
|
|
107
|
+
"E501",
|
|
108
|
+
"E722",
|
|
109
|
+
"D100",
|
|
110
|
+
"D101",
|
|
111
|
+
"D102",
|
|
112
|
+
"D103",
|
|
113
|
+
"D104",
|
|
114
|
+
"D105",
|
|
115
|
+
"D106",
|
|
116
|
+
"D107",
|
|
117
|
+
"D203",
|
|
118
|
+
"D213",
|
|
119
|
+
"RUF001",
|
|
120
|
+
"RUF002",
|
|
121
|
+
"RUF003",
|
|
122
|
+
"TRY003",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
[tool.ruff.lint.isort]
|
|
126
|
+
force-single-line = true
|
|
127
|
+
order-by-type = false
|
|
128
|
+
|
|
129
|
+
[tool.ruff.lint.mccabe]
|
|
130
|
+
max-complexity = 10
|
|
131
|
+
|
|
132
|
+
[tool.mypy]
|
|
133
|
+
python_version = "3.11"
|
|
134
|
+
packages = ["flask_wtforms_htmx"]
|
|
135
|
+
mypy_path = "src"
|
|
136
|
+
ignore_missing_imports = true
|
|
137
|
+
|
|
138
|
+
[tool.tox]
|
|
139
|
+
requires = [
|
|
140
|
+
"tox>=4.22",
|
|
141
|
+
"tox-uv>=1.16",
|
|
142
|
+
]
|
|
143
|
+
env_list = [
|
|
144
|
+
"py3.11",
|
|
145
|
+
"py3.12",
|
|
146
|
+
"py3.13",
|
|
147
|
+
"py3.14",
|
|
148
|
+
"py3.15",
|
|
149
|
+
"minversions",
|
|
150
|
+
"typing",
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
[tool.tox.env_run_base]
|
|
154
|
+
runner = "uv-venv-lock-runner"
|
|
155
|
+
dependency_groups = ["test"]
|
|
156
|
+
commands = [[
|
|
157
|
+
"pytest",
|
|
158
|
+
{ replace = "posargs", extend = true },
|
|
159
|
+
]]
|
|
160
|
+
|
|
161
|
+
[tool.tox.env.typing]
|
|
162
|
+
runner = "uv-venv-lock-runner"
|
|
163
|
+
dependency_groups = ["dev"]
|
|
164
|
+
commands = [["mypy"]]
|
|
165
|
+
|
|
166
|
+
[tool.tox.env.minversions]
|
|
167
|
+
runner = "uv-venv-runner"
|
|
168
|
+
base_python = ["py3.11"]
|
|
169
|
+
uv_resolution = "lowest-direct"
|
|
170
|
+
dependency_groups = ["test"]
|
|
171
|
+
commands = [[
|
|
172
|
+
"pytest",
|
|
173
|
+
"tests/unit",
|
|
174
|
+
{ replace = "posargs", extend = true },
|
|
175
|
+
]]
|
|
176
|
+
|
|
177
|
+
[dependency-groups]
|
|
178
|
+
doc = [
|
|
179
|
+
"pallets-sphinx-themes>=2.5.0",
|
|
180
|
+
"sphinx>=8.1.0",
|
|
181
|
+
"sphinx-issues>=6.0.0",
|
|
182
|
+
]
|
|
183
|
+
test = [
|
|
184
|
+
"pytest>=8.0",
|
|
185
|
+
"pytest-cov>=5.0",
|
|
186
|
+
]
|
|
187
|
+
dev = [
|
|
188
|
+
{ include-group = "test" },
|
|
189
|
+
"mypy>=1.11",
|
|
190
|
+
"prek>=0.3.9",
|
|
191
|
+
"pytest-xdist>=3.8.0",
|
|
192
|
+
"tox-uv>=1.35.1",
|
|
193
|
+
]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "flask-wtforms-htmx"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A drop-in FlaskForm that brings wtforms-htmx to Flask-WTF."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "BSD-3-Clause"
|
|
7
|
+
license-files = ["LICENSE.rst"]
|
|
8
|
+
maintainers = [
|
|
9
|
+
{ name = "Éloi Rivard", email = "eloi@yaal.coop" },
|
|
10
|
+
]
|
|
11
|
+
keywords = ["wtforms", "htmx", "forms", "flask", "validation"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Environment :: Web Environment",
|
|
15
|
+
"Framework :: Flask",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Programming Language :: Python :: 3.15",
|
|
25
|
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
26
|
+
]
|
|
27
|
+
requires-python = ">=3.11"
|
|
28
|
+
dependencies = [
|
|
29
|
+
"flask>=3.0",
|
|
30
|
+
"flask-wtf>=1.2",
|
|
31
|
+
"wtforms-htmx>=0.1.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Documentation = "https://flask-wtforms-htmx.readthedocs.io/"
|
|
36
|
+
Changes = "https://flask-wtforms-htmx.readthedocs.io/changes/"
|
|
37
|
+
"Source Code" = "https://github.com/yaal-coop/flask-wtforms-htmx"
|
|
38
|
+
"Issue Tracker" = "https://github.com/yaal-coop/flask-wtforms-htmx/issues"
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["uv_build>=0.13.1,<0.14"]
|
|
42
|
+
build-backend = "uv_build"
|
|
43
|
+
|
|
44
|
+
[tool.uv]
|
|
45
|
+
required-version = ">=0.13.1"
|
|
46
|
+
|
|
47
|
+
[dependency-groups]
|
|
48
|
+
doc = [
|
|
49
|
+
"pallets-sphinx-themes>=2.5.0",
|
|
50
|
+
"sphinx>=8.1.0",
|
|
51
|
+
"sphinx-issues>=6.0.0",
|
|
52
|
+
]
|
|
53
|
+
test = [
|
|
54
|
+
"pytest>=8.0",
|
|
55
|
+
"pytest-cov>=5.0",
|
|
56
|
+
]
|
|
57
|
+
dev = [
|
|
58
|
+
{ include-group = "test" },
|
|
59
|
+
"mypy>=1.11",
|
|
60
|
+
"prek>=0.3.9",
|
|
61
|
+
"pytest-xdist>=3.8.0",
|
|
62
|
+
"tox-uv>=1.35.1",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
testpaths = ["tests/unit"]
|
|
67
|
+
pythonpath = ["."]
|
|
68
|
+
filterwarnings = ["error"]
|
|
69
|
+
|
|
70
|
+
[tool.coverage.run]
|
|
71
|
+
branch = true
|
|
72
|
+
source = ["flask_wtforms_htmx", "tests"]
|
|
73
|
+
|
|
74
|
+
[tool.coverage.paths]
|
|
75
|
+
source = ["src", "*/site-packages"]
|
|
76
|
+
|
|
77
|
+
[tool.coverage.report]
|
|
78
|
+
fail_under = 100
|
|
79
|
+
exclude_lines = [
|
|
80
|
+
"pragma: no cover",
|
|
81
|
+
"except ImportError",
|
|
82
|
+
"if TYPE_CHECKING:",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
[tool.ruff]
|
|
86
|
+
src = ["src"]
|
|
87
|
+
fix = true
|
|
88
|
+
show-fixes = true
|
|
89
|
+
output-format = "full"
|
|
90
|
+
|
|
91
|
+
[tool.ruff.lint]
|
|
92
|
+
select = [
|
|
93
|
+
"B", # flake8-bugbear
|
|
94
|
+
"C90", # mccabe
|
|
95
|
+
"D", # pydocstyle
|
|
96
|
+
"E", # pycodestyle error
|
|
97
|
+
"F", # pyflakes
|
|
98
|
+
"I", # isort
|
|
99
|
+
"UP", # pyupgrade
|
|
100
|
+
"W", # pycodestyle warning
|
|
101
|
+
"SIM",
|
|
102
|
+
"RET",
|
|
103
|
+
"BLE",
|
|
104
|
+
"TRY",
|
|
105
|
+
"ERA",
|
|
106
|
+
"PIE",
|
|
107
|
+
"RUF",
|
|
108
|
+
"DTZ",
|
|
109
|
+
"PTH",
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
ignore = [
|
|
113
|
+
"E501", # line-too-long
|
|
114
|
+
"E722", # bare-except
|
|
115
|
+
"D100", # public module
|
|
116
|
+
"D101", # public class
|
|
117
|
+
"D102", # public method
|
|
118
|
+
"D103", # public function
|
|
119
|
+
"D104", # public package
|
|
120
|
+
"D105", # magic method
|
|
121
|
+
"D106", # nested class
|
|
122
|
+
"D107", # public init
|
|
123
|
+
"D203", # no-blank-line-before-class
|
|
124
|
+
"D213", # multi-line-summary-second-line
|
|
125
|
+
"RUF001",
|
|
126
|
+
"RUF002",
|
|
127
|
+
"RUF003",
|
|
128
|
+
"TRY003", # raise-vanilla-args — dedicated exception classes would be overkill here
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
[tool.ruff.lint.isort]
|
|
132
|
+
force-single-line = true
|
|
133
|
+
order-by-type = false
|
|
134
|
+
|
|
135
|
+
[tool.ruff.lint.mccabe]
|
|
136
|
+
max-complexity = 10
|
|
137
|
+
|
|
138
|
+
[tool.mypy]
|
|
139
|
+
python_version = "3.11"
|
|
140
|
+
packages = ["flask_wtforms_htmx"]
|
|
141
|
+
mypy_path = "src"
|
|
142
|
+
ignore_missing_imports = true
|
|
143
|
+
|
|
144
|
+
[tool.tox]
|
|
145
|
+
requires = ["tox>=4.22", "tox-uv>=1.16"]
|
|
146
|
+
env_list = ["py3.11", "py3.12", "py3.13", "py3.14", "py3.15", "minversions", "typing"]
|
|
147
|
+
|
|
148
|
+
[tool.tox.env_run_base]
|
|
149
|
+
runner = "uv-venv-lock-runner"
|
|
150
|
+
dependency_groups = ["test"]
|
|
151
|
+
commands = [["pytest", { replace = "posargs", extend = true }]]
|
|
152
|
+
|
|
153
|
+
[tool.tox.env.typing]
|
|
154
|
+
runner = "uv-venv-lock-runner"
|
|
155
|
+
dependency_groups = ["dev"]
|
|
156
|
+
commands = [["mypy"]]
|
|
157
|
+
|
|
158
|
+
[tool.tox.env.minversions]
|
|
159
|
+
runner = "uv-venv-runner"
|
|
160
|
+
base_python = ["py3.11"]
|
|
161
|
+
uv_resolution = "lowest-direct"
|
|
162
|
+
dependency_groups = ["test"]
|
|
163
|
+
commands = [["pytest", "tests/unit", { replace = "posargs", extend = true }]]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""A drop-in FlaskForm that brings wtforms-htmx to Flask-WTF."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from flask import abort
|
|
9
|
+
from flask import current_app
|
|
10
|
+
from flask import has_request_context
|
|
11
|
+
from flask import render_template
|
|
12
|
+
from flask import request
|
|
13
|
+
from flask import Response
|
|
14
|
+
from flask_wtf import FlaskForm
|
|
15
|
+
from flask_wtf.csrf import CSRFError
|
|
16
|
+
from markupsafe import Markup
|
|
17
|
+
from wtforms_htmx import HtmxFormMixin
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HtmxFlaskForm(HtmxFormMixin, FlaskForm):
|
|
24
|
+
"""Drop-in replacement for :class:`~flask_wtf.FlaskForm` that answers htmx requests.
|
|
25
|
+
|
|
26
|
+
The form reads the request headers itself, so the view does not pass
|
|
27
|
+
``headers=``.
|
|
28
|
+
|
|
29
|
+
With an invalid CSRF token, htmx requests and clicks on a button with
|
|
30
|
+
:meth:`.triggers() <wtforms.fields.core.UnboundField.triggers>` get a
|
|
31
|
+
400 response (:class:`~flask_wtf.csrf.CSRFError`), and no callback runs.
|
|
32
|
+
htmx leaves the page as it is. A plain submit shows the error in the
|
|
33
|
+
form, as with Flask-WTF.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, *args, headers=None, **kwargs):
|
|
37
|
+
if headers is None and has_request_context():
|
|
38
|
+
headers = request.headers
|
|
39
|
+
|
|
40
|
+
super().__init__(*args, headers=headers, **kwargs)
|
|
41
|
+
|
|
42
|
+
def mutate(self) -> None:
|
|
43
|
+
"""Run the mutations, or abort with a 400 when the CSRF token blocks them."""
|
|
44
|
+
super().mutate()
|
|
45
|
+
if not self.meta.csrf:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
errors = self[self.meta.csrf_field_name].errors
|
|
49
|
+
if not errors or not self._is_interaction():
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
# htmx 4 swaps 4xx responses by default: keep the page as it is.
|
|
53
|
+
response = Response(errors[0], status=400, headers={"HX-Reswap": "none"})
|
|
54
|
+
raise CSRFError(errors[0], response=response)
|
|
55
|
+
|
|
56
|
+
def _is_interaction(self) -> bool:
|
|
57
|
+
"""Return True for an htmx request or a click on a button with triggers."""
|
|
58
|
+
if self.submitter is not None and self.htmx_submit_attrs(self.submitter):
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
return "HX-Request" in request.headers and "HX-Boosted" not in request.headers
|
|
62
|
+
|
|
63
|
+
def validate_on_submit(
|
|
64
|
+
self, extra_validators=None, *, submitter: Any = None
|
|
65
|
+
) -> bool:
|
|
66
|
+
"""Validate the form when the user saves it.
|
|
67
|
+
|
|
68
|
+
On an htmx request with a partial response, :func:`flask.abort` sends
|
|
69
|
+
that response, so the rest of the view does not run.
|
|
70
|
+
|
|
71
|
+
Returns ``False`` when the form was not submitted, or when a button
|
|
72
|
+
with :meth:`.triggers() <wtforms.fields.core.UnboundField.triggers>`
|
|
73
|
+
submitted it without JavaScript. With ``submitter=form.save``, only
|
|
74
|
+
that button saves.
|
|
75
|
+
"""
|
|
76
|
+
if result := self.partial():
|
|
77
|
+
abort(Response(result.body, headers=result.headers))
|
|
78
|
+
|
|
79
|
+
if not self.is_submitted():
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
if submitter is not None and self.submitter is not submitter:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
if self.mutated:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
return self.validate(extra_validators)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def jinja_renderer(template: str, macro: str | None = None) -> Callable:
|
|
92
|
+
"""Build a field wrapper callable that renders a Jinja template or macro.
|
|
93
|
+
|
|
94
|
+
``jinja_renderer("_field.html")`` renders the template with ``field`` and
|
|
95
|
+
``render_kw`` in its context. ``jinja_renderer("_macros.html", "render_field")``
|
|
96
|
+
calls the macro with ``(field, render_kw)``::
|
|
97
|
+
|
|
98
|
+
class ProfileForm(HtmxFlaskForm):
|
|
99
|
+
class Meta:
|
|
100
|
+
field_wrapper = staticmethod(jinja_renderer("_field.html"))
|
|
101
|
+
"""
|
|
102
|
+
if macro is None:
|
|
103
|
+
|
|
104
|
+
def render_template_fn(field, render_kw):
|
|
105
|
+
return render_template(template, field=field, render_kw=render_kw)
|
|
106
|
+
|
|
107
|
+
return render_template_fn
|
|
108
|
+
|
|
109
|
+
def render_macro_fn(field, render_kw):
|
|
110
|
+
module = current_app.jinja_env.get_template(template).module
|
|
111
|
+
return Markup(getattr(module, macro)(field, render_kw))
|
|
112
|
+
|
|
113
|
+
return render_macro_fn
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = [
|
|
117
|
+
"HtmxFlaskForm",
|
|
118
|
+
"jinja_renderer",
|
|
119
|
+
]
|
|
File without changes
|