"""A minimal tag/text writer with Airium-style dynamic tag access."""
from __future__ import annotations
import html
import sys
from contextlib import AbstractContextManager
from dataclasses import dataclass, field
from types import TracebackType
from typing import (
Any,
NewType,
Protocol,
TextIO,
TypeAlias,
overload,
runtime_checkable,
)
if sys.version_info >= (3, 13):
from warnings import deprecated
else:
from typing_extensions import deprecated
if sys.version_info >= (3, 11):
from typing import LiteralString
else:
from typing_extensions import LiteralString
if sys.version_info >= (3, 14):
from string.templatelib import Interpolation, Template, convert
@runtime_checkable
class SupportsHTML(Protocol):
"""An object whose ``__html__`` method returns trusted HTML."""
def __html__(self) -> str: ...
SafeStr = NewType("SafeStr", str)
"""A string that is safe to write as HTML, returned by :func:`escape` and :func:`trust`.
It only exists for type checkers; at runtime it is a plain :class:`str`.
Attribute values are escaped as usual, whatever their type.
"""
if sys.version_info >= (3, 14):
TrustedContent: TypeAlias = (
LiteralString | SafeStr | SupportsHTML | int | float | Template
)
else:
TrustedContent: TypeAlias = LiteralString | SafeStr | SupportsHTML | int | float
"""Content that is written verbatim, by :class:`DocWriter` and by tags alike.
Strings built from anything else, such as user input,
have to go through :func:`escape` first.
On Python 3.14 and newer, a template string (``t"..."``) is accepted as well:
its literal parts are trusted and its interpolated values are escaped
unless they are numbers, objects with ``__html__`` or other template strings.
"""
if sys.version_info >= (3, 14):
AttributeValue: TypeAlias = (
LiteralString
| SafeStr
| SupportsHTML
| int
| float
| bool
| None
| dict[str, "AttributeValue"]
| Template
)
else:
AttributeValue: TypeAlias = (
LiteralString
| SafeStr
| SupportsHTML
| int
| float
| bool
| None
| dict[str, "AttributeValue"]
)
"""What an attribute may be set to.
Strings are written verbatim between double quotes, like tag content,
so a string that is not a literal has to go through :func:`escape` first.
Numbers and objects with ``__html__`` are written as they are,
and a template string is handled like tag content:
its literal parts are trusted and its interpolated values are escaped.
``None`` and ``False`` omit the attribute,
``True`` writes it without a value (``<script defer>``),
and a dictionary is flattened into one attribute per entry
with the name as a prefix (``data={"id": 7}`` gives ``data-id="7"``).
Inside ``aria``, booleans are written as ``"true"`` and ``"false"`` instead,
because ARIA attributes take strings rather than being HTML boolean attributes.
"""
_MISSING: object = object()
[docs]
def escape(value: str) -> SafeStr:
"""Escape ``&``, ``<``, ``>`` and quotes so the text can be written as HTML.
Quotes are escaped as well,
so the result is safe in text and in attribute values.
"""
# Looking for characters to escape is much cheaper than escaping,
# and most strings have none.
if (
"&" not in value
and "<" not in value
and ">" not in value
and '"' not in value
and "'" not in value
):
return SafeStr(value)
return SafeStr(html.escape(value))
[docs]
def escape_silent(value: str | None) -> SafeStr:
"""Like :func:`escape`, but write ``None`` as an empty string.
Use it for optional values, such as a field that may be missing in some data,
where an empty result is what the page should show.
"""
return SafeStr("") if value is None else escape(value)
[docs]
def trust(value: str) -> SafeStr:
"""Mark a string as safe to write as HTML, without escaping it.
Only use it for content that cannot contain untrusted input.
"""
return SafeStr(value)
if sys.version_info >= (3, 14):
def _interpolated_text(interpolation: Interpolation) -> str:
"""Return the text of a value in a template string, escaped unless trusted.
Numbers and objects with ``__html__`` are trusted
when they are interpolated as they are.
Anything else, and anything that was converted or formatted,
is turned into a string and escaped.
"""
value = interpolation.value
if interpolation.conversion is None and not interpolation.format_spec:
if hasattr(value, "__html__"):
return value.__html__()
if isinstance(value, (int, float)):
return str(value)
if value is None:
raise TypeError(
f"{{{interpolation.expression}}} is None; "
"interpolate a string or use escape_silent"
)
else:
value = format(
convert(value, interpolation.conversion), interpolation.format_spec
)
return escape(str(value))
def _template_text(template: Template) -> str:
"""Return the text of a template string, trusting only its literal parts."""
return "".join(
item if isinstance(item, str) else _interpolated_text(item)
for item in template
)
def _trusted_text(value: TrustedContent) -> str:
"""Return the text of trusted content, which is written without escaping it."""
# Exact strings are by far the most common content.
# Subclasses and other types may implement __html__, so they are looked at below.
# The attribute is looked up instead of calling isinstance with SupportsHTML,
# which is about fifty times slower for objects that do not have it.
if type(value) is str:
return value
if hasattr(value, "__html__"):
return value.__html__()
if sys.version_info >= (3, 14) and isinstance(value, Template):
return _template_text(value)
if value is None:
raise TypeError("content must not be None; pass a string or omit it")
return str(value)
def _format_attributes(attrs: dict[str, AttributeValue], prefix: str = "") -> str:
"""Write attributes in the order given, with trusted values as they are.
A trailing underscore is dropped from names, so ``class_`` is written ``class``.
"""
aria = prefix == "aria"
parts: list[str] = []
for key, value in attrs.items():
if value is None:
continue
name = key[:-1] if key.endswith("_") else key
if prefix:
name = f"{prefix}-{name}"
if value is True:
parts.append(f' {name}="true"' if aria else f" {name}")
elif value is False:
if aria:
parts.append(f' {name}="false"')
elif type(value) is str:
parts.append(f' {name}="{value}"')
elif isinstance(value, dict):
parts.append(_format_attributes(value, name))
else:
parts.append(f' {name}="{_trusted_text(value)}"')
return "".join(parts)
@dataclass(slots=True)
class _InlineContext:
"""Write everything in the block on one line, indented once and ended once."""
doc: DocWriter
previous_prefixes: list[str] = field(init=False)
previous_end: str = field(init=False)
def __enter__(self) -> None:
doc = self.doc
doc._write(doc._prefix(doc._depth))
self.previous_prefixes = doc._prefixes
self.previous_end = doc._end
doc._prefixes = doc._inline_prefixes
doc._end = ""
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
doc = self.doc
doc._prefixes = self.previous_prefixes
doc._end = self.previous_end
doc._write(doc._end)
[docs]
@dataclass(slots=True)
class _TagBuilder:
"""Represent a chain of tags, usable directly or with attributes supplied by a call.
Builders are immutable, so they can be cached and nested inside themselves.
"""
_doc: DocWriter
_openings: tuple[str, ...]
_closings: tuple[str, ...]
_children: dict[str, _TagBuilder] | None = field(default=None, repr=False)
[docs]
def __getattr__(self, name: str) -> _TagBuilder:
"""Return a builder for a nested tag, such as ``main`` in ``doc.body.main``."""
if name.startswith("_"):
raise AttributeError(name)
return self[name]
[docs]
def __getitem__(self, name: str) -> _TagBuilder:
"""Return a builder for a nested tag, as in ``doc.div["x-y"]``."""
children = self._children
if children is None:
children = self._children = {}
elif child := children.get(name):
return child
child = children[name] = _TagBuilder(
self._doc,
(*self._openings, f"<{name}>"),
(f"</{name}>", *self._closings),
)
return child
@overload
def __call__(self, /, **attrs: AttributeValue) -> _TagBuilder: ...
@overload
def __call__(self, content: TrustedContent, /, **attrs: AttributeValue) -> None: ...
[docs]
def __call__(
self,
content: Any = _MISSING,
/,
**attrs: AttributeValue,
) -> _TagBuilder | None:
"""Write a leaf element when given content, else return a builder.
Attributes are added to the innermost tag,
after any it was given by an earlier call.
The content is handled like that of :meth:`DocWriter.__call__`:
it is written verbatim, so a string that is not a literal
has to go through :func:`escape` first.
``None`` is rejected with a :class:`TypeError`
instead of being mistaken for missing content.
"""
openings = self._openings
if attrs:
if not openings:
raise TypeError("attributes need a tag, as in doc.tags.div(...)")
formatted = _format_attributes(attrs)
if content is _MISSING:
return _TagBuilder(
self._doc,
(*openings[:-1], f"{openings[-1][:-1]}{formatted}>"),
self._closings,
)
opening = f"{''.join(openings[:-1])}{openings[-1][:-1]}{formatted}>"
elif content is _MISSING:
return self
else:
opening = "".join(openings)
doc = self._doc
text = content if type(content) is str else _trusted_text(content)
closing = "".join(self._closings)
try:
prefix = doc._prefixes[doc._depth]
except IndexError:
prefix = doc._prefix(doc._depth)
doc._write(f"{prefix}{opening}{text}{closing}{doc._end}")
return None
def __enter__(self) -> None:
doc = self._doc
for opening in self._openings:
doc._write(f"{doc._prefix(doc._depth)}{opening}{doc._end}")
doc._depth += 1
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
doc = self._doc
for closing in self._closings:
doc._depth -= 1
doc._write(f"{doc._prefix(doc._depth)}{closing}{doc._end}")
[docs]
@dataclass(slots=True, frozen=True)
class _VoidBuilder:
"""Write a void element, such as ``<br>``, on its own line when called."""
_doc: DocWriter
_opening: str
[docs]
def __call__(self, /, **attrs: AttributeValue) -> None:
"""Write the element with the given attributes.
Void elements have no content and no closing tag,
so there is nothing to pass as content or to enter as a context manager.
"""
doc = self._doc
attributes = _format_attributes(attrs) if attrs else ""
doc._write(f"{doc._prefix(doc._depth)}{self._opening}{attributes}>{doc._end}")
[docs]
@dataclass(slots=True, frozen=True)
class _Voids:
"""Look up void elements by name, as in ``doc.voids.br()``."""
_doc: DocWriter
_builders: dict[str, _VoidBuilder] = field(default_factory=dict, repr=False)
[docs]
def __getattr__(self, name: str) -> _VoidBuilder:
"""Return the void element with this name, such as ``img``."""
if name.startswith("_"):
raise AttributeError(name)
return self[name]
[docs]
def __getitem__(self, name: str) -> _VoidBuilder:
"""Return the void element with any name, as in ``doc.voids["x-y"]``."""
builders = self._builders
if builder := builders.get(name):
return builder
builder = builders[name] = _VoidBuilder(self._doc, f"<{name}")
return builder
[docs]
class DocWriter:
"""Write escaped HTML fragments to any object with a ``write(str)`` method."""
def __init__(self, writer: TextIO) -> None:
self._write = writer.write
# An empty chain whose children are the top-level tags.
self.tags: _TagBuilder = _TagBuilder(self, (), (), {})
"""Tags by name, as in ``doc.tags.div``, or ``doc.tags["my-element"]``."""
self.voids: _Voids = _Voids(self)
"""Void elements by name, as in ``doc.voids.br()``."""
self._indentation = " "
self._depth: int = 0
# Indentation by depth and the line ending written after each tag, like
# print(); inline blocks swap in empty strings for both.
self._end = "\n"
self._line_prefixes: list[str] = []
self._inline_prefixes: list[str] = []
self._prefixes = self._line_prefixes
@property
def parts(self) -> tuple[DocWriter, _TagBuilder, _Voids]:
"""Return the writer, its tags and its void elements, for unpacking.
``doc, t, v = DocWriter(output).parts`` gives short local names.
"""
return self, self.tags, self.voids
@deprecated("Use doc.tags.<name> instead")
def __getattr__(self, name: str) -> _TagBuilder:
"""Return a tag by name; use :attr:`tags` instead."""
if name.startswith("_"):
raise AttributeError(name)
return self.tags[name]
@deprecated("Use doc.tags[name] instead")
def __getitem__(self, name: str) -> _TagBuilder:
"""Return a tag by any name; use :attr:`tags` instead."""
return self.tags[name]
[docs]
def __call__(self, value: TrustedContent) -> None:
"""Write trusted content verbatim, preserving the current indentation.
Strings with non-literal provenance must be passed to :func:`escape`.
Objects implementing ``__html__`` contribute their trusted HTML string;
literal strings and primitive numeric values are also written verbatim.
Tag content, as in ``doc.tags.p(...)``, is handled the same way.
"""
self._write(f"{self._prefix(self._depth)}{_trusted_text(value)}{self._end}")
[docs]
def inline(self) -> AbstractContextManager[None]:
"""Suppress line breaks and indentation for the markup written in the block.
The block is indented and ends its line like any other tag,
so ``with doc.inline(), doc.tags.div:`` renders the whole ``div`` on one line.
Blocks may be nested; formatting resumes once the outermost one exits.
"""
return _InlineContext(self)
[docs]
@deprecated("Use doc.tags[name](...) instead")
def tag(self, name: str, /, **attrs: AttributeValue) -> _TagBuilder:
"""Return a tag with any name and attributes.
Equivalent to ``doc.tags[name](**attrs)``, which should be used instead.
"""
return self.tags[name](**attrs)
[docs]
@deprecated("Use doc.voids[name](...) instead")
def void_tag(self, name: str, /, **attrs: AttributeValue) -> None:
"""Write a void element such as ``<br>`` or ``<img>`` on its own line.
Equivalent to ``doc.voids[name](**attrs)``, which should be used instead.
"""
self.voids[name](**attrs)
def _generate_prefix(self, depth: int) -> str:
"""Return the prefix for a depth, extending the per-depth caches as needed."""
self._line_prefixes.append(self._indentation * depth)
self._inline_prefixes.append("")
return self._line_prefixes[depth]
def _prefix(self, depth: int) -> str:
"""Return what to write before a tag at this depth, honoring inline blocks."""
try:
return self._prefixes[depth]
except IndexError:
self._generate_prefix(depth)
return self._prefixes[depth]
[docs]
@deprecated("Use doc(...) instead")
def write_raw(self, value: str) -> None:
"""Write unescaped text to the document, bypassing the escaping logic."""
self._write(value)
[docs]
@deprecated("Use doc(...) instead")
def text(self, value: object) -> None:
"""Write escaped text to the document on its own line.
Values that are not strings are converted with :class:`str`.
"""
self._write(f"{self._prefix(self._depth)}{escape(str(value))}{self._end}")