Skip to main content

HTML/SVG generation via python functions.

Project description

html-tags

PyPI version

[!WARNING] Under active development — Apr 2026

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: clsclass, _forfor, data_test_iddata-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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

html_tags-0.3.4.tar.gz (9.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

html_tags-0.3.4-py3-none-any.whl (10.7 kB view details)

Uploaded Python 3

File details

Details for the file html_tags-0.3.4.tar.gz.

File metadata

  • Download URL: html_tags-0.3.4.tar.gz
  • Upload date:
  • Size: 9.5 kB
  • Tags: Source
  • Uploaded using 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}

File hashes

Hashes for html_tags-0.3.4.tar.gz
Algorithm Hash digest
SHA256 22b820eb4cb24a2c343097d8a00662afc31560bc8aae91d3ab28344e1a3169d6
MD5 71964b0fd236b828b4ebfaf40e508ec8
BLAKE2b-256 7ff3ae61f7faacd662e48f14da6e2e0c99f5494eba39d3f0a3c14b5d5f5c1455

See more details on using hashes here.

File details

Details for the file html_tags-0.3.4-py3-none-any.whl.

File metadata

  • Download URL: html_tags-0.3.4-py3-none-any.whl
  • Upload date:
  • Size: 10.7 kB
  • Tags: Python 3
  • Uploaded using 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}

File hashes

Hashes for html_tags-0.3.4-py3-none-any.whl
Algorithm Hash digest
SHA256 b1896e8bccaa7a08eba6be321c0aa2862d7ad6cd25d86b1359063b1eb4c0f3b8
MD5 b119f53bfc1fa213ae7f8d2f23df8186
BLAKE2b-256 d03675c68dd356ff062c0e6a69811d5f9e71047590f5eb828f1259eacb641251

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page