django-div 2026.8.1__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.
@@ -0,0 +1,251 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-div
3
+ Version: 2026.8.1
4
+ Summary: Build and parse HTML in Python with Pydantic models
5
+ Keywords: django,html,pydantic,templates
6
+ Author: Jeff Triplett
7
+ Author-email: Jeff Triplett <jeff.triplett@gmail.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: Django
11
+ Classifier: Framework :: Django :: 5.2
12
+ Classifier: Framework :: Django :: 6.0
13
+ Classifier: Framework :: Django :: 6.1
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
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: Programming Language :: Python :: Free Threading :: 3 - Stable
21
+ Classifier: Topic :: Text Processing :: Markup :: HTML
22
+ Requires-Dist: pydantic>=2.13
23
+ Requires-Dist: beautifulsoup4>=4.12 ; extra == 'html5'
24
+ Requires-Dist: html5lib>=1.1 ; extra == 'html5'
25
+ Requires-Dist: beautifulsoup4>=4.12 ; extra == 'markdown'
26
+ Requires-Dist: lxml>=5.0 ; extra == 'markdown'
27
+ Requires-Dist: markdown-it-py>=3.0 ; extra == 'markdown'
28
+ Requires-Dist: beautifulsoup4>=4.12 ; extra == 'parse'
29
+ Requires-Dist: lxml>=5.0 ; extra == 'parse'
30
+ Requires-Python: >=3.12
31
+ Project-URL: Documentation, https://django-div.readthedocs.io/en/latest/
32
+ Project-URL: Repository, https://github.com/jefftriplett/django-div
33
+ Project-URL: Issues, https://github.com/jefftriplett/django-div/issues
34
+ Provides-Extra: html5
35
+ Provides-Extra: markdown
36
+ Provides-Extra: parse
37
+ Description-Content-Type: text/markdown
38
+
39
+ # django-div
40
+
41
+ Build and parse HTML in Python with Pydantic models.
42
+
43
+ **[Documentation](https://django-div.readthedocs.io/en/latest/)**
44
+
45
+ ```python
46
+ from django_div import A, Div, P
47
+
48
+ print(Div(P("Hello, World!"), A("Click", href="/x"), class_="card"))
49
+ # <div class="card"><p>Hello, World!</p><a href="/x">Click</a></div>
50
+ ```
51
+
52
+ Children are positional, attributes are keyword arguments. Text is escaped,
53
+ void tags self-close, and Python attribute spellings map onto HTML ones
54
+ (`class_` → `class`, `data_test_id` → `data-test-id`). All 114 elements of
55
+ the [HTML living standard](https://developer.mozilla.org/en-US/docs/Web/HTML/Element)
56
+ ship as classes, with MDN links in their docstrings.
57
+
58
+ ## Building
59
+
60
+ ```python
61
+ Div(class_="card", data_id="1") # <div class="card" data-id="1"></div>
62
+ Input(type="checkbox", checked=True) # <input type="checkbox" checked />
63
+ Div(class_=["btn", "btn-primary"]) # <div class="btn btn-primary"></div>
64
+ Div(class_={"btn": True, "on": False}) # <div class="btn"></div>
65
+ Div("<script>x</script>") # <div>&lt;script&gt;x&lt;/script&gt;</div>
66
+ ```
67
+
68
+ `style` takes a mapping too, and `<script>`/`<style>` content is left
69
+ unescaped, since escaping it would change what the code means:
70
+
71
+ ```python
72
+ Div(style={"color": "red", "font_size": "2rem"})
73
+ Script("if (a < b) { go() }") # <script>if (a < b) { go() }</script>
74
+ ```
75
+
76
+ Void elements raise rather than silently dropping children, and a raw-text
77
+ element refuses to render content containing its own closing tag.
78
+
79
+ Comments neutralize HTML's comment-syntax rules on render, so content can
80
+ never close the comment early or leak out as live markup:
81
+
82
+ ```python
83
+ Comment(content="note") # <!--note-->
84
+ Comment(content="a--b") # <!--a- -b--> -- would end the comment
85
+ Comment(content=">boom") # <!-- >boom--> HTML5 reads <!--> as a whole comment
86
+ ```
87
+
88
+ `None` and `False` children drop out, so inline conditionals work. Lists and
89
+ generators flatten, so comprehensions splat in.
90
+
91
+ ```python
92
+ Div("Hello", user and Span(user.name))
93
+ Ul(Li(item) for item in items)
94
+ ```
95
+
96
+ Call a tag to append children and get a copy back, leaving the original alone:
97
+
98
+ ```python
99
+ card = Div(class_="card")
100
+ card(H1("Title"), P("Body"))
101
+ ```
102
+
103
+ `Tag` handles anything that isn't pre-generated, including custom elements:
104
+
105
+ ```python
106
+ Tag("my-widget", "hi", data_state="ready")
107
+ ```
108
+
109
+ ## Parsing
110
+
111
+ `from_html()` returns the same kind of tree the constructors build, so parsed
112
+ markup can be searched, edited, and re-rendered. Needs the `parse` extra.
113
+
114
+ ```python
115
+ page = from_html(response.text)
116
+
117
+ page.text # all text in the subtree
118
+ page.find("a", class_="external") # first match, or None
119
+ page.find_all("a") # every descendant match
120
+ page.walk() # every node, depth first
121
+
122
+ for link in page.find_all("a", target="_blank"):
123
+ link.attrs["rel"] = "noopener"
124
+
125
+ print(page)
126
+ ```
127
+
128
+ `parse()` is the underlying function and always returns a list;
129
+ `from_html()` unwraps the single-root case.
130
+
131
+ Both pick the best parser installed: `lxml`, then `html5lib`, then the
132
+ stdlib. That matters: the stdlib parser turns `<p>one<p>two` into *nested*
133
+ paragraphs instead of closing the first, and lxml is also about 1.6x faster.
134
+ Pass `parser=` to override. Fragments stay fragments. The `<html><body>`
135
+ skeleton lxml and html5lib invent is stripped unless the source asked for it.
136
+
137
+ ## Serializing
138
+
139
+ Trees are Pydantic models, so they round-trip through JSON with their classes
140
+ intact:
141
+
142
+ ```python
143
+ payload = page.model_dump_json()
144
+ Tag.model_validate_json(payload) # same tree, same subclasses
145
+ ```
146
+
147
+ ## Markdown
148
+
149
+ The same tree renders to Markdown, so `from_html` + `to_markdown` is an
150
+ HTML-to-Markdown converter, and `from_markdown()` reads Markdown into a tree
151
+ (via markdown-it-py, with the `markdown` extra):
152
+
153
+ ```python
154
+ from django_div.markdown import from_markdown, to_markdown
155
+
156
+ to_markdown(from_html("<h1>Title</h1><p>Body</p>")) # '# Title\n\nBody'
157
+ from_markdown("# Title") # H1(...)
158
+ ```
159
+
160
+ Lossy by design: attributes have no Markdown home and are dropped.
161
+
162
+ ## Django
163
+
164
+ Django is never imported unless it is installed, so it stays an optional
165
+ dependency.
166
+
167
+ ### Components as templates
168
+
169
+ Register the backend and a component becomes addressable as a template:
170
+
171
+ ```python
172
+ TEMPLATES = [
173
+ {
174
+ "BACKEND": "django_div.django.DjangoDivTemplates",
175
+ "NAME": "django_div",
176
+ "DIRS": [],
177
+ "APP_DIRS": False,
178
+ "OPTIONS": {"context_processors": [...]},
179
+ },
180
+ # your usual DjangoTemplates entry can stay alongside it
181
+ ]
182
+ ```
183
+
184
+ ```python
185
+ # myapp/components.py
186
+ def home(title, **context):
187
+ return Div(H1(title), class_="page")
188
+
189
+ # myapp/views.py
190
+ def home_view(request):
191
+ return render(request, "myapp.components.home", {"title": "Hi"})
192
+ ```
193
+
194
+ A component is any callable returning an `HtmlItem`. It receives the context
195
+ as keyword arguments: the whole context if it declares `**kwargs`, otherwise
196
+ only the parameters it names, so context processors can add `user` and friends
197
+ without breaking every signature.
198
+
199
+ ### Without the template layer
200
+
201
+ ```python
202
+ from django_div.django import as_response, csrf_input
203
+
204
+ def index(request):
205
+ return as_response(Div(H1("Hi")))
206
+
207
+ def form_view(request):
208
+ return as_response(Form(csrf_input(request), Input(name="q"), method="post"))
209
+ ```
210
+
211
+ ### Escaping
212
+
213
+ Rendering escapes text and attribute values, so output is safe markup by
214
+ construction and `{{ tag }}` works in a Django template with no `|safe`.
215
+ Interop runs both ways: anything with `__html__` (a `SafeString`, a
216
+ `markupsafe.Markup`, a rendered Django form) passes through a tag unescaped,
217
+ while plain strings are still escaped.
218
+
219
+ Lazy objects work too: `Div(gettext_lazy("Hello"))` resolves to one string
220
+ rather than one element per character.
221
+
222
+ ## Install
223
+
224
+ ```console
225
+ uv add django-div # building only
226
+ uv add 'django-div[parse]' # plus from_html()/parse(), via bs4 + lxml
227
+ uv add 'django-div[html5]' # spec-exact parsing, ~3x slower than lxml
228
+ uv add 'django-div[markdown]' # plus from_markdown(), via markdown-it-py
229
+ ```
230
+
231
+ django-div needs Python 3.12 or later.
232
+ Django is optional and never imported unless installed; `django_div.django`
233
+ is the only module that needs it.
234
+
235
+ ## Development
236
+
237
+ ```console
238
+ just bootstrap # uv sync
239
+ just install-hooks # prek install
240
+ just test # pytest
241
+ just lint # prek run --all-files
242
+ just docs # serve the docs locally
243
+ just example # run examples/example.py
244
+ ```
245
+
246
+ ## Prior art
247
+
248
+ [htpy](https://htpy.dev), [dominate](https://github.com/Knio/dominate), and
249
+ [django-components](https://github.com/django-components/django-components)
250
+ cover adjacent ground. django-div's angle is that the tree is a Pydantic
251
+ model, so the same objects parse, validate, and serialize.
@@ -0,0 +1,213 @@
1
+ # django-div
2
+
3
+ Build and parse HTML in Python with Pydantic models.
4
+
5
+ **[Documentation](https://django-div.readthedocs.io/en/latest/)**
6
+
7
+ ```python
8
+ from django_div import A, Div, P
9
+
10
+ print(Div(P("Hello, World!"), A("Click", href="/x"), class_="card"))
11
+ # <div class="card"><p>Hello, World!</p><a href="/x">Click</a></div>
12
+ ```
13
+
14
+ Children are positional, attributes are keyword arguments. Text is escaped,
15
+ void tags self-close, and Python attribute spellings map onto HTML ones
16
+ (`class_` → `class`, `data_test_id` → `data-test-id`). All 114 elements of
17
+ the [HTML living standard](https://developer.mozilla.org/en-US/docs/Web/HTML/Element)
18
+ ship as classes, with MDN links in their docstrings.
19
+
20
+ ## Building
21
+
22
+ ```python
23
+ Div(class_="card", data_id="1") # <div class="card" data-id="1"></div>
24
+ Input(type="checkbox", checked=True) # <input type="checkbox" checked />
25
+ Div(class_=["btn", "btn-primary"]) # <div class="btn btn-primary"></div>
26
+ Div(class_={"btn": True, "on": False}) # <div class="btn"></div>
27
+ Div("<script>x</script>") # <div>&lt;script&gt;x&lt;/script&gt;</div>
28
+ ```
29
+
30
+ `style` takes a mapping too, and `<script>`/`<style>` content is left
31
+ unescaped, since escaping it would change what the code means:
32
+
33
+ ```python
34
+ Div(style={"color": "red", "font_size": "2rem"})
35
+ Script("if (a < b) { go() }") # <script>if (a < b) { go() }</script>
36
+ ```
37
+
38
+ Void elements raise rather than silently dropping children, and a raw-text
39
+ element refuses to render content containing its own closing tag.
40
+
41
+ Comments neutralize HTML's comment-syntax rules on render, so content can
42
+ never close the comment early or leak out as live markup:
43
+
44
+ ```python
45
+ Comment(content="note") # <!--note-->
46
+ Comment(content="a--b") # <!--a- -b--> -- would end the comment
47
+ Comment(content=">boom") # <!-- >boom--> HTML5 reads <!--> as a whole comment
48
+ ```
49
+
50
+ `None` and `False` children drop out, so inline conditionals work. Lists and
51
+ generators flatten, so comprehensions splat in.
52
+
53
+ ```python
54
+ Div("Hello", user and Span(user.name))
55
+ Ul(Li(item) for item in items)
56
+ ```
57
+
58
+ Call a tag to append children and get a copy back, leaving the original alone:
59
+
60
+ ```python
61
+ card = Div(class_="card")
62
+ card(H1("Title"), P("Body"))
63
+ ```
64
+
65
+ `Tag` handles anything that isn't pre-generated, including custom elements:
66
+
67
+ ```python
68
+ Tag("my-widget", "hi", data_state="ready")
69
+ ```
70
+
71
+ ## Parsing
72
+
73
+ `from_html()` returns the same kind of tree the constructors build, so parsed
74
+ markup can be searched, edited, and re-rendered. Needs the `parse` extra.
75
+
76
+ ```python
77
+ page = from_html(response.text)
78
+
79
+ page.text # all text in the subtree
80
+ page.find("a", class_="external") # first match, or None
81
+ page.find_all("a") # every descendant match
82
+ page.walk() # every node, depth first
83
+
84
+ for link in page.find_all("a", target="_blank"):
85
+ link.attrs["rel"] = "noopener"
86
+
87
+ print(page)
88
+ ```
89
+
90
+ `parse()` is the underlying function and always returns a list;
91
+ `from_html()` unwraps the single-root case.
92
+
93
+ Both pick the best parser installed: `lxml`, then `html5lib`, then the
94
+ stdlib. That matters: the stdlib parser turns `<p>one<p>two` into *nested*
95
+ paragraphs instead of closing the first, and lxml is also about 1.6x faster.
96
+ Pass `parser=` to override. Fragments stay fragments. The `<html><body>`
97
+ skeleton lxml and html5lib invent is stripped unless the source asked for it.
98
+
99
+ ## Serializing
100
+
101
+ Trees are Pydantic models, so they round-trip through JSON with their classes
102
+ intact:
103
+
104
+ ```python
105
+ payload = page.model_dump_json()
106
+ Tag.model_validate_json(payload) # same tree, same subclasses
107
+ ```
108
+
109
+ ## Markdown
110
+
111
+ The same tree renders to Markdown, so `from_html` + `to_markdown` is an
112
+ HTML-to-Markdown converter, and `from_markdown()` reads Markdown into a tree
113
+ (via markdown-it-py, with the `markdown` extra):
114
+
115
+ ```python
116
+ from django_div.markdown import from_markdown, to_markdown
117
+
118
+ to_markdown(from_html("<h1>Title</h1><p>Body</p>")) # '# Title\n\nBody'
119
+ from_markdown("# Title") # H1(...)
120
+ ```
121
+
122
+ Lossy by design: attributes have no Markdown home and are dropped.
123
+
124
+ ## Django
125
+
126
+ Django is never imported unless it is installed, so it stays an optional
127
+ dependency.
128
+
129
+ ### Components as templates
130
+
131
+ Register the backend and a component becomes addressable as a template:
132
+
133
+ ```python
134
+ TEMPLATES = [
135
+ {
136
+ "BACKEND": "django_div.django.DjangoDivTemplates",
137
+ "NAME": "django_div",
138
+ "DIRS": [],
139
+ "APP_DIRS": False,
140
+ "OPTIONS": {"context_processors": [...]},
141
+ },
142
+ # your usual DjangoTemplates entry can stay alongside it
143
+ ]
144
+ ```
145
+
146
+ ```python
147
+ # myapp/components.py
148
+ def home(title, **context):
149
+ return Div(H1(title), class_="page")
150
+
151
+ # myapp/views.py
152
+ def home_view(request):
153
+ return render(request, "myapp.components.home", {"title": "Hi"})
154
+ ```
155
+
156
+ A component is any callable returning an `HtmlItem`. It receives the context
157
+ as keyword arguments: the whole context if it declares `**kwargs`, otherwise
158
+ only the parameters it names, so context processors can add `user` and friends
159
+ without breaking every signature.
160
+
161
+ ### Without the template layer
162
+
163
+ ```python
164
+ from django_div.django import as_response, csrf_input
165
+
166
+ def index(request):
167
+ return as_response(Div(H1("Hi")))
168
+
169
+ def form_view(request):
170
+ return as_response(Form(csrf_input(request), Input(name="q"), method="post"))
171
+ ```
172
+
173
+ ### Escaping
174
+
175
+ Rendering escapes text and attribute values, so output is safe markup by
176
+ construction and `{{ tag }}` works in a Django template with no `|safe`.
177
+ Interop runs both ways: anything with `__html__` (a `SafeString`, a
178
+ `markupsafe.Markup`, a rendered Django form) passes through a tag unescaped,
179
+ while plain strings are still escaped.
180
+
181
+ Lazy objects work too: `Div(gettext_lazy("Hello"))` resolves to one string
182
+ rather than one element per character.
183
+
184
+ ## Install
185
+
186
+ ```console
187
+ uv add django-div # building only
188
+ uv add 'django-div[parse]' # plus from_html()/parse(), via bs4 + lxml
189
+ uv add 'django-div[html5]' # spec-exact parsing, ~3x slower than lxml
190
+ uv add 'django-div[markdown]' # plus from_markdown(), via markdown-it-py
191
+ ```
192
+
193
+ django-div needs Python 3.12 or later.
194
+ Django is optional and never imported unless installed; `django_div.django`
195
+ is the only module that needs it.
196
+
197
+ ## Development
198
+
199
+ ```console
200
+ just bootstrap # uv sync
201
+ just install-hooks # prek install
202
+ just test # pytest
203
+ just lint # prek run --all-files
204
+ just docs # serve the docs locally
205
+ just example # run examples/example.py
206
+ ```
207
+
208
+ ## Prior art
209
+
210
+ [htpy](https://htpy.dev), [dominate](https://github.com/Knio/dominate), and
211
+ [django-components](https://github.com/django-components/django-components)
212
+ cover adjacent ground. django-div's angle is that the tree is a Pydantic
213
+ model, so the same objects parse, validate, and serialize.
@@ -0,0 +1,107 @@
1
+ [project]
2
+ name = "django-div"
3
+ version = "2026.8.1"
4
+ description = "Build and parse HTML in Python with Pydantic models"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ keywords = [
9
+ "django",
10
+ "html",
11
+ "pydantic",
12
+ "templates",
13
+ ]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Framework :: Django",
17
+ "Framework :: Django :: 5.2",
18
+ "Framework :: Django :: 6.0",
19
+ "Framework :: Django :: 6.1",
20
+ "Intended Audience :: Developers",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ "Programming Language :: Python :: 3.15",
26
+ "Programming Language :: Python :: Free Threading :: 3 - Stable",
27
+ "Topic :: Text Processing :: Markup :: HTML",
28
+ ]
29
+ dependencies = ["pydantic>=2.13"]
30
+
31
+ [[project.authors]]
32
+ name = "Jeff Triplett"
33
+ email = "jeff.triplett@gmail.com"
34
+
35
+ [project.urls]
36
+ Documentation = "https://django-div.readthedocs.io/en/latest/"
37
+ Repository = "https://github.com/jefftriplett/django-div"
38
+ Issues = "https://github.com/jefftriplett/django-div/issues"
39
+
40
+ [project.optional-dependencies]
41
+ parse = [
42
+ "beautifulsoup4>=4.12",
43
+ "lxml>=5.0",
44
+ ]
45
+ html5 = [
46
+ "beautifulsoup4>=4.12",
47
+ "html5lib>=1.1",
48
+ ]
49
+ markdown = [
50
+ "beautifulsoup4>=4.12",
51
+ "lxml>=5.0",
52
+ "markdown-it-py>=3.0",
53
+ ]
54
+
55
+ [dependency-groups]
56
+ dev = [
57
+ "beautifulsoup4>=4.12",
58
+ "django>=5.2.16",
59
+ "html5lib>=1.1",
60
+ "lxml>=5.0",
61
+ "markdown-it-py>=3.0",
62
+ "prek>=0.4",
63
+ "pytest>=8.0",
64
+ "ruff>=0.16",
65
+ "zensical>=0.0.52",
66
+ ]
67
+
68
+ [build-system]
69
+ requires = ["uv_build>=0.11.29,<0.12.0"]
70
+ build-backend = "uv_build"
71
+
72
+ [tool.bumpver]
73
+ current_version = "2026.8.1"
74
+ version_pattern = "YYYY.MM.INC1"
75
+ commit_message = ":bookmark: bump version {old_version} -> {new_version}"
76
+ commit = true
77
+ push = false
78
+ tag = true
79
+
80
+ [tool.bumpver.file_patterns]
81
+ "pyproject.toml" = [
82
+ 'current_version = "{version}"',
83
+ '^version = "{version}"',
84
+ ]
85
+ "src/django_div/__init__.py" = ['__version__ = "{version}"']
86
+
87
+ [tool.pytest.ini_options]
88
+ testpaths = ["tests"]
89
+ pythonpath = ["."]
90
+ filterwarnings = ["error::DeprecationWarning"]
91
+
92
+ [tool.ruff.lint]
93
+ select = [
94
+ "E",
95
+ "F",
96
+ "FURB",
97
+ "I",
98
+ "RUF",
99
+ "SIM",
100
+ "UP",
101
+ ]
102
+
103
+ [tool.ruff.lint.per-file-ignores]
104
+ "src/django_div/__init__.pyi" = [
105
+ "E742",
106
+ "RUF022",
107
+ ]
@@ -0,0 +1,113 @@
1
+ [project]
2
+ name = "django-div"
3
+ version = "2026.8.1"
4
+ description = "Build and parse HTML in Python with Pydantic models"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Jeff Triplett", email = "jeff.triplett@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ license = "MIT"
11
+ keywords = ["django", "html", "pydantic", "templates"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Framework :: Django",
15
+ "Framework :: Django :: 5.2",
16
+ "Framework :: Django :: 6.0",
17
+ "Framework :: Django :: 6.1",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Programming Language :: Python :: 3.15",
24
+ "Programming Language :: Python :: Free Threading :: 3 - Stable",
25
+ "Topic :: Text Processing :: Markup :: HTML",
26
+ ]
27
+ dependencies = [
28
+ "pydantic>=2.13",
29
+ ]
30
+
31
+ [project.urls]
32
+ Documentation = "https://django-div.readthedocs.io/en/latest/"
33
+ Repository = "https://github.com/jefftriplett/django-div"
34
+ Issues = "https://github.com/jefftriplett/django-div/issues"
35
+
36
+ # parse()/from_html() are the only things that need a parser, and the imports
37
+ # are guarded, so none of this is required just to build HTML.
38
+ [project.optional-dependencies]
39
+ # beautifulsoup4 alone falls back to the stdlib parser, which nests implicit
40
+ # closes (<p>a<p>b) rather than closing them.
41
+ parse = [
42
+ "beautifulsoup4>=4.12",
43
+ "lxml>=5.0",
44
+ ]
45
+ # Spec-exact parsing, roughly 3x slower than lxml.
46
+ html5 = [
47
+ "beautifulsoup4>=4.12",
48
+ "html5lib>=1.1",
49
+ ]
50
+ # to_markdown() needs nothing; from_markdown() renders via markdown-it-py
51
+ # and reads the result back through parse(), so it needs that stack too.
52
+ markdown = [
53
+ "beautifulsoup4>=4.12",
54
+ "lxml>=5.0",
55
+ "markdown-it-py>=3.0",
56
+ ]
57
+
58
+ [dependency-groups]
59
+ dev = [
60
+ "beautifulsoup4>=4.12",
61
+ "django>=5.2.16",
62
+ "html5lib>=1.1",
63
+ "lxml>=5.0",
64
+ "markdown-it-py>=3.0",
65
+ "prek>=0.4",
66
+ "pytest>=8.0",
67
+ "ruff>=0.16",
68
+ "zensical>=0.0.52",
69
+ ]
70
+
71
+ [build-system]
72
+ requires = ["uv_build>=0.11.29,<0.12.0"]
73
+ build-backend = "uv_build"
74
+
75
+ [tool.bumpver]
76
+ current_version = "2026.8.1"
77
+ # CalVer, YYYY.M.N: unpadded month, micro 1-based per release within the month
78
+ version_pattern = "YYYY.MM.INC1"
79
+ commit_message = ":bookmark: bump version {old_version} -> {new_version}"
80
+ commit = true
81
+ push = false # the release recipe pushes the tag once uv.lock is amended in
82
+ tag = true
83
+
84
+ [tool.bumpver.file_patterns]
85
+ "pyproject.toml" = [
86
+ 'current_version = "{version}"',
87
+ '^version = "{version}"',
88
+ ]
89
+ "src/django_div/__init__.py" = [
90
+ '__version__ = "{version}"',
91
+ ]
92
+
93
+ [tool.pytest.ini_options]
94
+ testpaths = ["tests"]
95
+ # so components can be addressed as "tests.components.<name>"
96
+ pythonpath = ["."]
97
+ filterwarnings = ["error::DeprecationWarning"]
98
+
99
+ [tool.ruff.lint]
100
+ select = [
101
+ "E", # pycodestyle
102
+ "F", # pyflakes
103
+ "FURB", # refurb
104
+ "I", # isort
105
+ "RUF", # ruff-specific
106
+ "SIM", # flake8-simplify
107
+ "UP", # pyupgrade
108
+ ]
109
+
110
+ [tool.ruff.lint.per-file-ignores]
111
+ # Generated by scripts/gen_stub.py. `I` really is the <i> element (E742),
112
+ # and __all__ mirrors the runtime module's order, not ruff's (RUF022).
113
+ "src/django_div/__init__.pyi" = ["E742", "RUF022"]