django-div
Build and parse HTML in Python with Pydantic models.
from django_div import A, Div, P
print(Div(P("Hello, World!"), A("Click", href="/x"), class_="card"))
# <div class="card"><p>Hello, World!</p><a href="/x">Click</a></div>
Children are positional, attributes are keyword arguments. Text is escaped,
void tags self-close, and Python attribute spellings map onto HTML ones
(class_ → class, data_test_id → data-test-id). All 114 elements of
the HTML living standard
ship as classes, with MDN links in their docstrings.
Building
Div(class_="card", data_id="1") # <div class="card" data-id="1"></div>
Input(type="checkbox", checked=True) # <input type="checkbox" checked />
Div(class_=["btn", "btn-primary"]) # <div class="btn btn-primary"></div>
Div(class_={"btn": True, "on": False}) # <div class="btn"></div>
Div("<script>x</script>") # <div><script>x</script></div>
style takes a mapping too, and <script>/<style> content is left
unescaped, since escaping it would change what the code means:
Div(style={"color": "red", "font_size": "2rem"})
Script("if (a < b) { go() }") # <script>if (a < b) { go() }</script>
Void elements raise rather than silently dropping children, and a raw-text element refuses to render content containing its own closing tag.
Comments neutralize HTML's comment-syntax rules on render, so content can never close the comment early or leak out as live markup:
Comment(content="note") # <!--note-->
Comment(content="a--b") # <!--a- -b--> -- would end the comment
Comment(content=">boom") # <!-- >boom--> HTML5 reads <!--> as a whole comment
None and False children drop out, so inline conditionals work. Lists and
generators flatten, so comprehensions splat in.
Div("Hello", user and Span(user.name))
Ul(Li(item) for item in items)
Call a tag to append children and get a copy back, leaving the original alone:
card = Div(class_="card")
card(H1("Title"), P("Body"))
Tag handles anything that isn't pre-generated, including custom elements:
Tag("my-widget", "hi", data_state="ready")
Parsing
from_html() returns the same kind of tree the constructors build, so parsed
markup can be searched, edited, and re-rendered. Needs the parse extra.
page = from_html(response.text)
page.text # all text in the subtree
page.find("a", class_="external") # first match, or None
page.find_all("a") # every descendant match
page.walk() # every node, depth first
for link in page.find_all("a", target="_blank"):
link.attrs["rel"] = "noopener"
print(page)
parse() is the underlying function and always returns a list;
from_html() unwraps the single-root case.
Both pick the best parser installed: lxml, then html5lib, then the
stdlib. That matters: the stdlib parser turns <p>one<p>two into nested
paragraphs instead of closing the first, and lxml is also about 1.6x faster.
Pass parser= to override. Fragments stay fragments. The <html><body>
skeleton lxml and html5lib invent is stripped unless the source asked for it.
Serializing
Trees are Pydantic models, so they round-trip through JSON with their classes intact:
payload = page.model_dump_json()
Tag.model_validate_json(payload) # same tree, same subclasses
Markdown
The same tree renders to Markdown, so from_html + to_markdown is an
HTML-to-Markdown converter, and from_markdown() reads Markdown into a tree
(via markdown-it-py, with the markdown extra):
from django_div.markdown import from_markdown, to_markdown
to_markdown(from_html("<h1>Title</h1><p>Body</p>")) # '# Title\n\nBody'
from_markdown("# Title") # H1(...)
Lossy by design: attributes have no Markdown home and are dropped.
Django
Django is never imported unless it is installed, so it stays an optional dependency.
Components as templates
Register the backend and a component becomes addressable as a template:
TEMPLATES = [
{
"BACKEND": "django_div.django.DjangoDivTemplates",
"NAME": "django_div",
"DIRS": [],
"APP_DIRS": False,
"OPTIONS": {"context_processors": [...]},
},
# your usual DjangoTemplates entry can stay alongside it
]
# myapp/components.py
def home(title, **context):
return Div(H1(title), class_="page")
# myapp/views.py
def home_view(request):
return render(request, "myapp.components.home", {"title": "Hi"})
A component is any callable returning an HtmlItem. It receives the context
as keyword arguments: the whole context if it declares **kwargs, otherwise
only the parameters it names, so context processors can add user and friends
without breaking every signature.
Without the template layer
from django_div.django import as_response, csrf_input
def index(request):
return as_response(Div(H1("Hi")))
def form_view(request):
return as_response(Form(csrf_input(request), Input(name="q"), method="post"))
Escaping
Rendering escapes text and attribute values, so output is safe markup by
construction and {{ tag }} works in a Django template with no |safe.
Interop runs both ways: anything with __html__ (a SafeString, a
markupsafe.Markup, a rendered Django form) passes through a tag unescaped,
while plain strings are still escaped.
Lazy objects work too: Div(gettext_lazy("Hello")) resolves to one string
rather than one element per character.
Install
uv add django-div # building only
uv add 'django-div[parse]' # plus from_html()/parse(), via bs4 + lxml
uv add 'django-div[html5]' # spec-exact parsing, ~3x slower than lxml
uv add 'django-div[markdown]' # plus from_markdown(), via markdown-it-py
django-div needs Python 3.12 or later.
Django is optional and never imported unless installed; django_div.django
is the only module that needs it.
Development
just bootstrap # uv sync
just install-hooks # prek install
just test # pytest
just lint # prek run --all-files
just docs # serve the docs locally
just example # run examples/example.py
Prior art
htpy, dominate, and django-components cover adjacent ground. django-div's angle is that the tree is a Pydantic model, so the same objects parse, validate, and serialize.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_div-2026.8.2.tar.gz.
File metadata
- Download URL: django_div-2026.8.2.tar.gz
- Upload date:
- Size: 23.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a6bbaf57a40b72b89c1e0cdef9f608695d846b8ce5c4302f6aedc37708188fc6
|
|
| MD5 |
28127ad06fb85e32c57e8584d803374b
|
|
| BLAKE2b-256 |
1df87cd1b9497837cda489cc4a7f64dbcbcaf9bd6f474e6f5a739a95f7d82960
|
Provenance
The following attestation bundles were made for django_div-2026.8.2.tar.gz:
Publisher:
release.yml on jefftriplett/django-div
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_div-2026.8.2.tar.gz -
Subject digest:
a6bbaf57a40b72b89c1e0cdef9f608695d846b8ce5c4302f6aedc37708188fc6 - Sigstore transparency entry: 2460506435
- Sigstore integration time:
-
Permalink:
jefftriplett/django-div@d1d29d5bb9f7ebf2e1dec5cb46a4895f13dd81d3 -
Branch / Tag:
refs/tags/2026.8.2 - Owner: https://github.com/jefftriplett
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d1d29d5bb9f7ebf2e1dec5cb46a4895f13dd81d3 -
Trigger Event:
push
-
Statement type:
File details
Details for the file django_div-2026.8.2-py3-none-any.whl.
File metadata
- Download URL: django_div-2026.8.2-py3-none-any.whl
- Upload date:
- Size: 24.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8a4cd52ee25c2674f58c33d1d52cf5cd8e0c8bcf73dc9469e078e2cadf19d65d
|
|
| MD5 |
fd6214555a32ccbad2f96d9648ee0e30
|
|
| BLAKE2b-256 |
017c0ad0e794d8816cde1138b0a5ec743d311e3f715d515ccfe9e4da518a0632
|
Provenance
The following attestation bundles were made for django_div-2026.8.2-py3-none-any.whl:
Publisher:
release.yml on jefftriplett/django-div
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_div-2026.8.2-py3-none-any.whl -
Subject digest:
8a4cd52ee25c2674f58c33d1d52cf5cd8e0c8bcf73dc9469e078e2cadf19d65d - Sigstore transparency entry: 2460506487
- Sigstore integration time:
-
Permalink:
jefftriplett/django-div@d1d29d5bb9f7ebf2e1dec5cb46a4895f13dd81d3 -
Branch / Tag:
refs/tags/2026.8.2 - Owner: https://github.com/jefftriplett
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d1d29d5bb9f7ebf2e1dec5cb46a4895f13dd81d3 -
Trigger Event:
push
-
Statement type: