Skip to main content

html-tags

PyPI version

html_tags

A minimal, zero-dependency Python DSL for HTML, SVG, and MathML. Built around a single idea: a tag is a closure. No classes, no templates, no mutation — just functions that return functions that render to strings.

Install

pip install html-tags

The whole API in ten seconds

from html_tags import div, p, h1, ul, li, render

page = div(cls="card")(
    h1("Hello"),
    p("A minimal HTML DSL."),
    ul(li(x) for x in ["red", "blue", "green"]),
)

print(render(page))

Any name you import from html_tags becomes a tag constructor — from html_tags import my_custom_component works, no pre-registration. Underscores become hyphens (data_list → <data-list>), trailing underscores are stripped (input_ → <input>).

The attribute rule

There are two channels for attributes:

  • Keyword arguments are Pythonified: cls → class, _for → for, data_test_id → data-test-id, trailing _ stripped.
  • Dict arguments pass through verbatim. Use a dict for anything that isn't a valid Python identifier.
# kwargs: friendly Python names
div(cls="btn", data_test_id="save")

# dict: anything with colons, dots, or reserved names
div({"data-on:click": "@post('/save')"})

# mix freely — they merge
form({"data-signals": "{count: 0}"}, cls="counter")(
    input_({"data-bind:name": True}, type="text"),
)

This rule is why the library handles Datastar 1.0 attributes (data-on:click__debounce.500ms.leading), XML namespaces (xlink:href), and any other non-identifier attribute name without special cases.

Purity: extension never mutates

shell = div(cls="card")
a = shell(p("branch A"))
b = shell(p("branch B"))

# shell is unchanged. a and b are independent.

Each call returns a new closure. Reuse shells freely across requests, components, whatever — they're immutable values.

Namespace handling

The library automatically switches rendering rules for SVG, MathML, and back to HTML inside <foreignObject>. You write the tags; namespacing, xmlns, and void-element conventions just work.

SVG

from html_tags import svg, circle, rect, render

logo = svg(
    circle(cx="50", cy="50", r="40", fill="steelblue"),
    rect(x="30", y="30", width="40", height="40", fill="white"),
    viewBox="0 0 100 100", width="200",
)
print(render(logo))

Renders (note the automatic xmlns on the root and self-closing void elements):

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="200">
  <circle cx="50" cy="50" r="40" fill="steelblue" />
  <rect x="30" y="30" width="40" height="40" fill="white" />
</svg>

MathML

from html_tags import math, mrow, msup, mi, mn, mo, render

# a² + b² = c²
eq = math(mrow(
    msup(mi("a"), mn("2")), mo("+"),
    msup(mi("b"), mn("2")), mo("="),
    msup(mi("c"), mn("2")),
))
print(render(eq))

Renders with the correct MathML xmlns and namespace-appropriate void rules:

<math xmlns="http://www.w3.org/1998/Math/MathML">
  <mrow>
    <msup><mi>a</mi><mn>2</mn></msup>
    <mo>+</mo>
    ...
  </mrow>
</math>

HTML inside SVG via foreignObject

This is the interesting case. <foreignObject> embeds real HTML inside an SVG. The void-element rules are different in the two namespaces (<br> self-closes in SVG, not in HTML) — and html_tags switches back to HTML mode for the subtree automatically.

from html_tags import div, svg, circle, foreignObject, p, input_, br, strong, render

doc = div(
    svg(
        circle(cx="50", cy="50", r="40", fill="steelblue"),
        foreignObject(
            div(
                p("Real HTML, including ", input_(type="text", placeholder="type here")),
                br(),
                strong("bold text"),
            ),
            x="20", y="20", width="60", height="60",
        ),
        viewBox="0 0 100 100", width="200",
    ),
)
print(render(doc))

Output — note how <circle /> self-closes (SVG) but <input> and <br> inside the foreignObject use HTML's void conventions:

<div>
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="200">
    <circle cx="50" cy="50" r="40" fill="steelblue" />
    <foreignObject x="20" y="20" width="60" height="60">
      <div>
        <p>Real HTML, including <input type="text" placeholder="type here"></p>
        <br>
        <strong>bold text</strong>
      </div>
    </foreignObject>
  </svg>
</div>

Three layers of namespace, one function call per tag, no configuration.

Dynamic trees

Build from data — the closure model handles arbitrary depth because it's just normal Python recursion:

import html_tags as h

def build(node):
    if isinstance(node, str):
        return node
    ctor = getattr(h, node["type"])
    children = [build(c) for c in node.get("children", [])]
    return ctor(*children, **node.get("attrs", {}))

tree = {"type": "ul", "children": [
    {"type": "li", "children": [f"Item {i}"]} for i in range(3)
]}
print(h.render(build(tree)))

Full document

from html_tags import html_doc, head, title, body, h1, p, Datastar, Favicon

doc = html_doc(
    head(
        title("My Page"),
        Favicon("🚀"),
        Datastar(),  # v1.0.0 stable, pass 'latest' for main branch
    ),
    body(
        h1("Hello"),
        p("World."),
    ),
)
print(doc)

Safe HTML opt-out

By default, text children and attribute values are escaped. Wrap pre-sanitized HTML in Safe to opt out:

from html_tags import div, Safe, render

render(div("<b>escaped</b>"))        # &lt;b&gt;escaped&lt;/b&gt;
render(div(Safe("<b>trusted</b>")))  # <b>trusted</b>

Parsing HTML into tags

from html_tags import html_to_tag, render

t = html_to_tag('<div class="x"><p>hi</p></div>')
print(render(t))

Useful for round-tripping content through the same pipeline as generated HTML.

Design notes

The entire library is one file, built on three ideas:

  1. A tag is a closure. tag(name, children, attrs) returns a function that, when called, returns another closure with extended children/attrs. No classes, no mutation.
  2. Two attribute channels. Kwargs are for Python-friendly names; dicts are for everything else. Transformation happens once, at the boundary.
  3. Namespaces switch at specific tags. <svg> switches to SVG rules, <math> to MathML, <foreignObject> back to HTML. Everything else inherits from its enclosing namespace.

That's the whole model. If you want to read the source, it's a single file under 250 lines.

License

MIT

Metadata

Release files for html-tags 0.4.4

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

Source distribution (sdist)

Source distribution for html-tags 0.4.4
File Size Uploaded Provenance Actions
html_tags-0.4.4.tar.gz 13.1 kB Without provenance Details for html_tags-0.4.4.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for html-tags 0.4.4
File Interpreter ABI Platform Actions
html_tags-0.4.4-py3-none-any.whl Python 3 none any Details for html_tags-0.4.4-py3-none-any.whl

Total release size: 29.7 kB

Release files / html_tags-0.4.4.tar.gz

Download URL html_tags-0.4.4.tar.gz
Size 13.1 kB
Tags Source
SHA-256 checksum
How to use checksums
7dc1865bdcd22e41636d682396899e2d73b6241022802c14242dd617f3765d5f
BLAKE2b-256 checksum
How to use checksums
ea303a3e777c694045ff1ef663067c22c7434b9017be79820fb133d34fcd635d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.6 {"installer":{"name":"uv","version":"0.11.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Arch Linux ARM","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / html_tags-0.4.4-py3-none-any.whl

Download URL html_tags-0.4.4-py3-none-any.whl
Size 16.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1fe3a32bcd682dec7f97f8cbac2a77e1fa670867d6aa3018c1a6cf135dac5091
BLAKE2b-256 checksum
How to use checksums
036588f7c64c511517586233495714c992f2d907b6192708a14356e36d866e55
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.6 {"installer":{"name":"uv","version":"0.11.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Arch Linux ARM","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Security for html-tags 0.4.4

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.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.4.4 This release

2 release files No provenance

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