Source code for hyperscribe

"""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)
[docs] def comment(self, text: str) -> None: """Write an HTML comment on its own line. The text is written as given, so it must not be able to end the comment early. A :class:`ValueError` is raised if it contains ``--``. """ if "--" in text: raise ValueError(f"text cannot be written in a comment: {text!r}") self._write(f"{self._prefix(self._depth)}<!-- {text} -->{self._end}")
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}")