Skip to content

Repository files navigation

Winged-Python

CI codecov PyPI Python License: MIT

Write HTML in Python. No templates, no template language, no runtime dependencies — just functions that compose into a tree and render to a string.

from winged import Body, Document, H1, Head, Li, P, Title, Ul, render

page = Document(
    Head(Title("Home")),
    Body(
        H1("Winged"),
        P("Hello ", "<world>"),
        Ul(*(Li(name) for name in ["one", "two"])),
    ),
    lang="pt-BR",
)

print(render(page))
<!DOCTYPE html>
<html lang="pt-BR"><head><title>Home</title></head><body><h1>Winged</h1><p>Hello &lt;world&gt;</p><ul><li>one</li><li>two</li></ul></body></html>

Note &lt;world&gt;: text is escaped by default. That is the one thing a library which builds HTML from data has to get right.

This is the Python sibling of Winged-Swift, and 1.0.0 is at parity with Winged-Swift 2.0.0 — a parity that is tested against Winged-Swift's own golden fixtures, not asserted.

Contents

Install

pip install winged-python

Python 3.10 or newer. No runtime dependencies.

Quick start

winged new mysite && cd mysite
winged build          # writes dist/
winged serve --watch  # http://127.0.0.1:8000, rebuilding on change

The CLI

Command What it does
winged new <name> Scaffold a project: site.py, layout.py, assets, and its own AGENTS.md
winged build Run the generator into dist/ and report what was written
winged serve [--watch] [--open] Preview on 127.0.0.1, rebuilding on change

build and serve find the project by walking up for site.py, so they work from any subdirectory.

Writing markup

Children are positional; attributes are keyword.

from winged import A, Div, Img, Input, Label, P, Span, render

Div(P("Body"), cls="card")  # <div class="card"><p>Body</p></div>
A("Home", href="/")  # <a href="/">Home</a>
Img("/a.png", "A screenshot")  # alt is required, by signature
Input(type_="checkbox", checked=True)  # <input type="checkbox" checked>
Input(type_="text", disabled=False)  # the attribute is omitted
Label("E-mail", for_="email")  # for_ renders as for
Div(data_user_id="7", aria_label="Card")  # data-user-id, aria-label

Names that collide with a Python keyword or builtin take a trailing underscore — cls, for_, type_, id_. The rendered attribute is always the HTML spelling.

Three shapes keep loops and conditionals readable:

Ul(Li(x) for x in items)  # iterables are flattened
Div(banner if logged_in else None)  # None children are dropped
Div(*sections)  # so are lists

Helpers chain, and each returns the element:

Div().add_class("card").add_class("wide").set_id("main").set_role("region")

Pretty or compact

from winged import RenderOptions, render

render(page)  # one line — what you ship
render(page, RenderOptions.pretty_())  # indented — what you read
render(page, RenderOptions(indent="\t", xhtml_self_closing=True))

Options are a value passed per call, never global state, so two callers can render differently at the same time. <pre>, <code> and <textarea> stay compact even in pretty mode, because indentation inside them changes what the browser displays.

render builds the whole string first. For a page large enough that you would rather not hold it twice, render_into writes straight into any text file object instead:

from winged import render_into

with open("dist/index.html", "w", encoding="utf-8", newline="\n") as handle:
    render_into(page, handle)

Fragments, raw markup and comments

from winged import Comment, Fragment, RawHtml

Fragment(P("a"), P("b"))  # no wrapper element, indentation preserved
RawHtml("<p>a</p>")  # verbatim — never pass user input here
Comment("build 42")  # <!-- build 42 -->

Pages and layouts

Document owns the doctype and lang, so a page cannot be assembled without them:

Document(Head(Title("Home")), Body(H1("Hi")), lang="pt-BR")

A layout is any class with a render method — it satisfies winged.Layout structurally, with no base class and no registration:

from winged import Body, Footer, Header, Nav, P, render_many
from winged.core.render import Node


class SiteLayout:
    def render(self, content: Node) -> Node:
        return Body(Header(Nav(...)), content, Footer(P("© 2026")))

SEO, sitemap and RSS

from winged.seo import SeoBuilder
from winged.sitemap import SitemapGenerator, SitemapUrl
from winged.feed import RssGenerator, RssItem, rfc822

head = Head(
    SeoBuilder(
        title="RideKeeper",
        description="Motorcycle maintenance companion",
        image="https://example.com/og.jpg",
        url="https://example.com",
        keywords=["python", "html"],
        twitter_site="@micheltlutz",
    ).build(),
    Title("RideKeeper"),
)

SitemapGenerator("https://example.com").generate(
    [
        SitemapUrl("/", changefreq="weekly", priority=1.0),
    ]
)

RssGenerator(title="Blog", link="https://example.com", description="Notes").generate(
    [
        RssItem(
            title="Hello",
            link="https://example.com/1",
            description="First post",
            pub_date=rfc822(datetime.now(timezone.utc)),
        ),
    ]
)

Open Graph keys go on property, Twitter keys on name — a distinction the spec makes and that is easy to get wrong. pub_date is RFC 822, which is not the ISO 8601 a sitemap's lastmod uses, which is why rfc822() exists.

Static sites

from winged.ssg import StaticSiteGenerator

site = StaticSiteGenerator("dist")
site.clean()
site.generate(page, "index.html")
site.generate_multiple({"about.html": about, "blog/1.html": post})
site.copy_asset("assets/css/style.css", "css/style.css")

Intermediate directories are created for you. clean() refuses the filesystem root, your home directory, and anything reached through a symlink out of the output directory.

Accessibility

from winged.accessibility import audit

for issue in audit(page):
    print(f"{issue.rule}: {issue.message} at {issue.path}")

Eight rules: img-alt, button-label, iframe-title, link-text, heading-order, html-lang, form-label, duplicate-id. audit returns findings and never raises — whether a finding should fail your build is your decision. Two of the rules are already unreachable through the normal constructors, because Img requires alt and Iframe requires title.

Three of them are deliberately not naive: link-text looks at every <img> under the link, not only its direct children; heading-order treats <section>, <article>, <aside> and <nav> as opening a new heading context, so an <h3> starting a section after an <h1> is not a finding; and form-label reads the whole tree before it judges, so a <label for=…> placed after its input still counts.

Escaping

What Escaped?
A str child ✅
Attribute values ✅ — at construction, so never twice
add_class, set_id, set_style, attr, data_attr, aria_attr ✅
Sitemap and RSS values ✅
RawHtml(...) ❌ — that is what it is for
Element.text(x, escape=False) ❌

URL schemes are not inspected: a javascript: URL in href is escaped but not rejected. Validate URLs that come from data. See SECURITY.md.

Documentation

Document What is in it
GETTING_STARTED.md Four ways in, then deployment
docs/tag-catalog.md All 93 elements — generated from the source
docs/recipes.md Task-shaped examples
docs/pitfalls.md The mistakes, and why they happen
MIGRATION.md 0.1.0 → 1.0.0, name by name
PORTING.md Winged-Swift → Winged-Python, and the deliberate differences
ROADMAP.md What is missing, stated as problems
AGENTS.md Working on this repository, including with a coding agent

Contributing

git clone https://github.com/micheltlutz/Winged-Python.git
cd Winged-Python
python -m venv .venv && source .venv/bin/activate
pip install -e . pytest pytest-cov ruff mypy build

./scripts/verify.sh      # build, test, lint, types, generated files, parity, end-to-end

verify.sh is a superset of CI. Run it before opening a pull request — and if you are a coding agent, run it before reporting that you are done. See CONTRIBUTING.md and AGENTS.md.

License

MIT. See LICENSE.

About

Python HTML Made Simple and Powerful

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

4 stars

Watchers

2 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages