Skip to main content

django-div

PyPI

Build and parse HTML in Python with Pydantic models.

Documentation

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 134 elements of the HTML living standard ship as classes, with MDN links in their docstrings. The 19 deprecated and 2 experimental ones warn when you build one, and stay quiet when you parse one.

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>&lt;script&gt;x&lt;/script&gt;</div>

ARIA booleans render explicit values: aria_expanded=False produces aria-expanded="false". None omits the attribute.

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"))

Use with_attrs() to copy an element with replacement attributes:

primary = card.with_attrs(class_="card primary", data_id="featured")

The original stays unchanged. Classes are replaced as a whole; the copy shares existing child objects and nested attribute values.

JsonScript(data, id="config") embeds JSON for JavaScript to read with JSON.parse(), with script-safe escaping and null preservation. See the cookbook for the browser code.

Fragment groups siblings without adding an HTML element. It supports the same rendering, search, text extraction, and JSON round trips as a tag:

from django_div import Fragment, H1, P

content = Fragment(H1("Title"), P("Body"))
print(content)  # <h1>Title</h1><p>Body</p>
content.get_text(" ", strip=True)  # 'Title 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
page.get_text(" ", strip=True)      # text nodes joined with spaces

for link in page.find_all("a", target="_blank"):
    link.attrs["rel"] = "noopener"

print(page)

Use tag.has_class("external") to test a class token in a multi-class attribute. tag.classes returns tokens for string, list, and mapping values; find(class_="external") continues to compare the entire attribute. Use page.find(lambda node: node.has_class("external")) for token matching. All search methods accept predicates and exclude the root.

page.transform(visitor) edits a copy, visiting children before parents. Return a node to keep or replace it, None to remove it, or a Fragment for sibling replacements. See the parsing guide for copy and traversal semantics.

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 a callable, usually returning an HtmlItem. Plain-string returns are escaped; intentional HTML strings require Raw or explicitly trusted markup. 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

Install into a virtual environment with uv:

uv venv                     # create an environment if needed
uv pip install django-div

Or, with pip in your active virtual environment:

python -m pip install django-div

Optional extras enable parsing and Markdown support. Use either installer:

uv pip install 'django-div[parse]'
python -m pip install 'django-div[parse]'

For a project managed by uv, use uv add to record the dependency in pyproject.toml:

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.

Metadata

Release files for django-div 2026.9.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-div 2026.9.3
File Size Uploaded
django_div-2026.9.3.tar.gz 31.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-div 2026.9.3
File Interpreter ABI Platform
django_div-2026.9.3-py3-none-any.whl Python 3 none any Details

Total release size: 61.9 kB

Release files / django_div-2026.9.3.tar.gz

Download URL django_div-2026.9.3.tar.gz
Size 31.3 kB
Tags Source
SHA-256 checksum
How to use checksums
de0c6d47db1aaaa3ae88b7fbaef02b7bfbbfc0e15b7412e0096b49564fbf5b2e
BLAKE2b-256 checksum
How to use checksums
cc0a5a801b0b86d3a2f8c69bc66b46c5255843c9de77451d293fc036c9af3653
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.

Transparency log

Release files / django_div-2026.9.3-py3-none-any.whl

Download URL django_div-2026.9.3-py3-none-any.whl
Size 30.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
882df26a44f483fda20485a59957728ea1f3dfb55dd63054b3163cfb934a78bd
BLAKE2b-256 checksum
How to use checksums
65bc62b4c2dca03d2a20f0a36e56326a524d6134d9faf7efe8acfbaab985fc81
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

2026.9.3 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page