1"""
2A safe-string for composing HTML, a drop-in for markupsafe's public surface.
3
4A :class:`Markup` is text already safe to place in HTML. Combining it with untrusted values escapes those values, so a
5string built up through ``+``, ``%``, :meth:`Markup.format`, :meth:`Markup.join`, and the inherited ``str`` operations
6stays safe without a manual escape at each step. ``escape``, ``escape_silent``, and ``soft_str`` are the C entry points
7from :mod:`turbohtml._html`, bound here unwrapped so template autoescaping stays a single C call.
8
9The surface matches markupsafe's so the template stack (Jinja2, WTForms, Werkzeug, ...) can migrate by changing the
10import: ``Markup`` overrides every ``str`` method that returns text to keep its result a ``Markup``, because under
11autoescaping a plain ``str`` return would be escaped a second time. The implementation is turbohtml's own, though: the
12escape runs in C, the overrides call ``str`` methods unbound to skip the ``super()`` proxy, and :meth:`Markup.striptags`
13and :meth:`Markup.unescape` run on turbohtml's tokenizer and HTML5 reference resolution, which are faster and resolve
14references markupsafe's regex stripping can miss.
15"""
16
17from __future__ import annotations
18
19import string
20from typing import TYPE_CHECKING, SupportsIndex, cast
21
22from turbohtml._html import _collapse_whitespace, _register_markup, parse_fragment, unescape
23from turbohtml._html import _markup_escape as escape
24from turbohtml._html import _markup_escape_silent as escape_silent
25from turbohtml._html import _markup_soft_str as soft_str
26
27if TYPE_CHECKING:
28 from collections.abc import Callable, Iterable, Mapping
29 from typing import Protocol, Self, SupportsFloat, SupportsInt
30
31 class _HasHtml(Protocol):
32 """An object that renders itself as already-safe HTML."""
33
34 def __html__(self) -> str: ...
35
36 class _HasHtmlFormat(Protocol):
37 """An object that renders itself safely under a format spec."""
38
39 def __html_format__(self, format_spec: str, /) -> str: ... # ruff:ignore[bad-dunder-method-name] # markupsafe render protocol
40
41 class _SupportsGetItem(Protocol):
42 """Any object supporting subscript, the shape a ``%`` mapping operand needs."""
43
44 def __getitem__(self, key: object, /) -> object: ...
45
46 class _SupportsStrGetItem(Protocol):
47 """The mapping shape ``str.format_map`` accepts: a string-keyed ``__getitem__``."""
48
49 def __getitem__(self, key: str, /) -> object: ...
50
51 class _SupportsIntGetItem(Protocol):
52 """The table shape ``str.translate`` accepts: an int-keyed ``__getitem__``."""
53
54 def __getitem__(self, key: int, /) -> str | int | None: ...
55
56
57# A str subclass is required, not error-prone here: escape builds the Markup in C through str's own constructor, and
58# isinstance(value, str) must hold for the template stack, so FURB189 is suppressed. PLR0904 is suppressed because the
59# class mirrors str's full method surface on purpose: any text-returning method that fell back to str would have its
60# already-safe result escaped a second time under autoescaping.
61class Markup(str): # ruff:ignore[subclass-builtin, too-many-public-methods]
62 """
63 Text that is already safe to embed in HTML and is not escaped again.
64
65 Wrap a value to declare it trusted. Operations that combine it with other text escape that text first, so the
66 result stays safe. Constructing ``Markup`` directly trusts its argument without escaping, so wrap only content you
67 control or have already escaped; call :meth:`escape` to make untrusted text safe.
68
69 :param base: the value to trust as safe HTML; its ``__html__`` method is used when present, and ``bytes`` is
70 decoded with ``encoding``.
71 :param encoding: the codec to decode ``base`` with when it is ``bytes``; ``None`` leaves a ``str`` as is.
72 :param errors: the decoding error policy passed to :meth:`bytes.decode` when ``encoding`` is given.
73 """
74
75 __slots__ = ()
76
77 def __new__(cls, base: object = "", encoding: str | None = None, errors: str = "strict") -> Self:
78 """Trust ``base`` as safe HTML, calling its ``__html__`` if present and decoding bytes when asked."""
79 # An exact str (every escape result and str-method wrap) has no __html__, so skip the attribute probe.
80 if type(base) is not str and hasattr(base, "__html__"):
81 base = cast("_HasHtml", base).__html__()
82 if encoding is None:
83 return str.__new__(cls, base)
84 return str.__new__(cls, cast("bytes", base), encoding, errors)
85
86 def __html__(self) -> Markup:
87 """Return self, since a Markup already renders as safe HTML."""
88 return self
89
90 def __add__(self, value: str, /) -> Markup:
91 """Concatenate, escaping ``value`` so the joined result stays safe."""
92 if isinstance(value, str) or hasattr(value, "__html__"):
93 return self.__class__(str.__add__(self, self.escape(value)))
94 return NotImplemented
95
96 def __radd__(self, value: str, /) -> Markup:
97 """Concatenate with ``value`` on the left, escaping it so the result stays safe."""
98 if isinstance(value, str) or hasattr(value, "__html__"):
99 return self.escape(value) + self
100 return NotImplemented
101
102 def __mul__(self, count: SupportsIndex, /) -> Markup:
103 """Repeat the safe string; the repetition stays safe too."""
104 return self.__class__(str.__mul__(self, count))
105
106 __rmul__ = __mul__
107
108 def __mod__(self, arg: object, /) -> Markup:
109 """Interpolate with ``%``, escaping each value as ``%`` substitutes it."""
110 if isinstance(arg, tuple):
111 arg = tuple(_MarkupEscapeHelper(value, self.escape) for value in arg)
112 elif hasattr(type(arg), "__getitem__") and not isinstance(arg, str):
113 arg = _MarkupEscapeHelper(arg, self.escape)
114 else:
115 arg = (_MarkupEscapeHelper(arg, self.escape),)
116 return self.__class__(str.__mod__(self, arg))
117
118 def __repr__(self) -> str:
119 """Wrap the str repr so a safe string reads differently from a plain one in logs and tracebacks."""
120 return f"{self.__class__.__name__}({str.__repr__(self)})"
121
122 def join(self, iterable: Iterable[str], /) -> Markup:
123 """
124 Join the items, escaping each one so the result stays safe.
125
126 :param iterable: the items to join.
127 :returns: the joined text as a Markup.
128 """
129 return self.__class__(str.join(self, map(self.escape, iterable)))
130
131 def split(self, sep: str | None = None, maxsplit: SupportsIndex = -1) -> list[str]:
132 """Split into parts that stay safe; the list type is str since list is invariant, but each part is a Markup."""
133 return [self.__class__(part) for part in str.split(self, sep, maxsplit)]
134
135 def rsplit(self, sep: str | None = None, maxsplit: SupportsIndex = -1) -> list[str]:
136 """Split from the right into parts that each stay a Markup."""
137 return [self.__class__(part) for part in str.rsplit(self, sep, maxsplit)]
138
139 def splitlines(self, keepends: bool = False) -> list[str]: # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument] # keepends is str's own flag
140 """Split into lines that each stay a Markup."""
141 return [self.__class__(line) for line in str.splitlines(self, keepends)]
142
143 def unescape(self) -> str:
144 """
145 Resolve character references back to text.
146
147 :returns: the resolved text as a plain (no longer safe) :class:`str`.
148 """
149 return unescape(str(self))
150
151 def striptags(self) -> str:
152 """
153 Parse the markup, drop tags and comments, collapse whitespace, and return the plain text.
154
155 This parses with turbohtml's tokenizer rather than scanning for ``<``, so references resolve and a comment
156 that contains a ``<`` cannot end tag removal early.
157
158 :returns: the plain text, no longer safe markup.
159 """
160 return _collapse_whitespace(parse_fragment(str(self)).text)
161
162 @classmethod
163 def escape(cls, s: object, /) -> Markup:
164 """
165 Escape a value to this class, the entry point the composing operations use to make operands safe.
166
167 :param s: the value to escape; an ``__html__`` method marks it already safe.
168 :returns: a Markup holding the escaped text.
169 """
170 rv = escape(s)
171 if rv.__class__ is not cls:
172 return cls(rv)
173 return rv
174
175 def __getitem__(self, key: SupportsIndex | slice, /) -> Markup:
176 """Index or slice into safe text; the piece stays a Markup."""
177 return self.__class__(str.__getitem__(self, key))
178
179 def capitalize(self) -> Markup:
180 """Capitalize, keeping the result a Markup so autoescaping does not re-escape it."""
181 return self.__class__(str.capitalize(self))
182
183 def title(self) -> Markup:
184 """Title-case, keeping the result a Markup."""
185 return self.__class__(str.title(self))
186
187 def lower(self) -> Markup:
188 """Lowercase, keeping the result a Markup."""
189 return self.__class__(str.lower(self))
190
191 def upper(self) -> Markup:
192 """Uppercase, keeping the result a Markup."""
193 return self.__class__(str.upper(self))
194
195 def swapcase(self) -> Markup:
196 """Swap case, keeping the result a Markup."""
197 return self.__class__(str.swapcase(self))
198
199 def casefold(self) -> Markup:
200 """Casefold, keeping the result a Markup."""
201 return self.__class__(str.casefold(self))
202
203 def replace(self, old: str, new: str, /, count: SupportsIndex = -1) -> Markup:
204 """Replace occurrences, escaping ``new`` so substituted text stays safe."""
205 return self.__class__(str.replace(self, old, self.escape(new), count))
206
207 def ljust(self, width: SupportsIndex, fillchar: str = " ", /) -> Markup:
208 """Left-justify, escaping the fill character so padding stays safe."""
209 return self.__class__(str.ljust(self, width, self.escape(fillchar)))
210
211 def rjust(self, width: SupportsIndex, fillchar: str = " ", /) -> Markup:
212 """Right-justify, escaping the fill character so padding stays safe."""
213 return self.__class__(str.rjust(self, width, self.escape(fillchar)))
214
215 def center(self, width: SupportsIndex, fillchar: str = " ", /) -> Markup:
216 """Center, escaping the fill character so padding stays safe."""
217 return self.__class__(str.center(self, width, self.escape(fillchar)))
218
219 def strip(self, chars: str | None = None, /) -> Markup:
220 """Strip surrounding characters, keeping the result a Markup."""
221 return self.__class__(str.strip(self, chars))
222
223 def lstrip(self, chars: str | None = None, /) -> Markup:
224 """Strip leading characters, keeping the result a Markup."""
225 return self.__class__(str.lstrip(self, chars))
226
227 def rstrip(self, chars: str | None = None, /) -> Markup:
228 """Strip trailing characters, keeping the result a Markup."""
229 return self.__class__(str.rstrip(self, chars))
230
231 def removeprefix(self, prefix: str, /) -> Markup:
232 """Remove a prefix, keeping the result a Markup."""
233 return self.__class__(str.removeprefix(self, prefix))
234
235 def removesuffix(self, suffix: str) -> Markup:
236 """Remove a suffix, keeping the result a Markup."""
237 return self.__class__(str.removesuffix(self, suffix))
238
239 def expandtabs(self, tabsize: SupportsIndex = 8) -> Markup:
240 """Expand tabs, keeping the result a Markup."""
241 return self.__class__(str.expandtabs(self, tabsize))
242
243 def zfill(self, width: SupportsIndex, /) -> Markup:
244 """Zero-fill, keeping the result a Markup."""
245 return self.__class__(str.zfill(self, width))
246
247 def translate(self, table: _SupportsIntGetItem, /) -> Markup:
248 """Translate through a table, keeping the result a Markup; the caller's table values are trusted."""
249 return self.__class__(str.translate(self, table))
250
251 def partition(self, sep: str, /) -> tuple[Markup, Markup, Markup]:
252 """Partition into three parts that each stay a Markup."""
253 left, middle, right = str.partition(self, sep)
254 return self.__class__(left), self.__class__(middle), self.__class__(right)
255
256 def rpartition(self, sep: str, /) -> tuple[Markup, Markup, Markup]:
257 """Partition from the right into three parts that each stay a Markup."""
258 left, middle, right = str.rpartition(self, sep)
259 return self.__class__(left), self.__class__(middle), self.__class__(right)
260
261 def format(self, *args: object, **kwargs: object) -> Markup:
262 """
263 Format, escaping each field unless it renders itself through ``__html_format__`` or ``__html__``.
264
265 :param args: positional values for the format fields.
266 :param kwargs: keyword values for the format fields.
267 :returns: the formatted text as a Markup.
268 """
269 return self.__class__(EscapeFormatter(self.escape).vformat(self, args, kwargs))
270
271 def format_map(self, mapping: _SupportsStrGetItem, /) -> Markup:
272 """Format from a mapping, escaping each field the way :meth:`format` does."""
273 return self.__class__(EscapeFormatter(self.escape).vformat(self, (), cast("Mapping[str, object]", mapping)))
274
275 def __html_format__(self, format_spec: str, /) -> Markup: # ruff:ignore[bad-dunder-method-name] # markupsafe format protocol, not a dunder
276 """Render under ``format()``: a Markup ignores an empty spec and rejects any other."""
277 if format_spec:
278 msg = "Unsupported format specification for Markup."
279 raise ValueError(msg)
280 return self
281
282
283class EscapeFormatter(string.Formatter):
284 """
285 A :class:`string.Formatter` that escapes each field while rendering it.
286
287 :meth:`Markup.format` builds on str formatting but must escape interpolated values. A field that already renders
288 itself safely through ``__html_format__`` or ``__html__`` stays trusted instead of escaping again. The class is
289 public and subclassable so a template sandbox can mix it with its own formatter, the way Jinja2 does, so its calls
290 go through ``super()`` to cooperate with that multiple inheritance.
291
292 :param escape: the function applied to each interpolated field value to make it safe.
293 """
294
295 __slots__ = ("escape",)
296
297 def __init__(self, escape: Callable[[object], str]) -> None:
298 """Store the escape callable each field passes through."""
299 self.escape = escape
300 super().__init__()
301
302 def format_field(self, value: object, format_spec: str) -> str:
303 """
304 Render one field: trust its safe-rendering protocol if present, else escape the formatted value.
305
306 :param value: the field value to render.
307 :param format_spec: the field's format specification.
308 :returns: the rendered, escaped field text.
309 """
310 if hasattr(value, "__html_format__"):
311 rendered = cast("_HasHtmlFormat", value).__html_format__(format_spec)
312 elif hasattr(value, "__html__"):
313 if format_spec:
314 msg = (
315 f"Format specifier {format_spec} given, but {type(value)} does not define __html_format__. "
316 "A class that defines __html__ must define __html_format__ to work with format specifiers."
317 )
318 raise ValueError(msg)
319 rendered = cast("_HasHtml", value).__html__() # ruff:ignore[unnecessary-dunder-call] # __html__ is a render protocol, not an operator
320 else:
321 rendered = super().format_field(value, str(format_spec))
322 return str(self.escape(rendered))
323
324
325class _MarkupEscapeHelper:
326 """
327 Wraps a ``%`` operand and escapes it only when interpolation coerces it.
328
329 :meth:`Markup.__mod__` cannot escape eagerly: ``%d``/``%f`` need the raw number and ``%(k)s`` needs item access
330 first. The proxy defers escaping until str formatting coerces the operand, and forwards indexing and coercion.
331 """
332
333 def __init__(self, obj: object, escape: Callable[[object], str]) -> None:
334 """Store the operand and the escape callable coercion applies to it."""
335 self.obj = obj
336 self.escape = escape
337
338 def __getitem__(self, key: object) -> _MarkupEscapeHelper:
339 """Defer escaping through ``%(name)s`` item access, wrapping the looked-up value in turn."""
340 return _MarkupEscapeHelper(cast("_SupportsGetItem", self.obj)[key], self.escape)
341
342 def __str__(self) -> str:
343 """Escape the operand at the point ``%s`` coerces it to text."""
344 return str(self.escape(self.obj))
345
346 def __repr__(self) -> str:
347 """Escape the operand's repr at the point ``%r`` coerces it."""
348 return str(self.escape(repr(self.obj)))
349
350 def __int__(self) -> int:
351 """Pass the operand through ``%d`` unescaped, since a number carries no markup."""
352 return int(cast("SupportsInt", self.obj))
353
354 def __float__(self) -> float:
355 """Pass the operand through ``%f`` unescaped, since a number carries no markup."""
356 return float(cast("SupportsFloat", self.obj))
357
358
359_register_markup(Markup)
360
361__all__ = ["EscapeFormatter", "Markup", "escape", "escape_silent", "soft_str"]