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.
- django_div-2026.8.1/PKG-INFO +251 -0
- django_div-2026.8.1/README.md +213 -0
- django_div-2026.8.1/pyproject.toml +107 -0
- django_div-2026.8.1/pyproject.toml.orig +113 -0
- django_div-2026.8.1/src/django_div/__init__.py +795 -0
- django_div-2026.8.1/src/django_div/__init__.pyi +556 -0
- django_div-2026.8.1/src/django_div/django.py +166 -0
- django_div-2026.8.1/src/django_div/markdown.py +459 -0
- django_div-2026.8.1/src/django_div/py.typed +0 -0
|
@@ -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><script>x</script></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><script>x</script></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"]
|