From 99c018de359f13bf659039277fa74cc49213d1d0 Mon Sep 17 00:00:00 2001 From: Thorsten Vitt Date: Tue, 7 Oct 2025 16:35:57 +0200 Subject: [PATCH] Escape content and attribute strings. The implementation uses Python's built-in escaping to escape characters in text content. For attributes, it will also escape quotes if there is no way to express the attribute without escaping by sensibly choosing the outer quotes. Fixes #5 --- htbuilder/__init__.py | 47 ++++++++++++++++++++++++++++++++++++++++--- tests/htbuild_test.py | 30 +++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/htbuilder/__init__.py b/htbuilder/__init__.py index dd50be8..07bd93f 100644 --- a/htbuilder/__init__.py +++ b/htbuilder/__init__.py @@ -47,7 +47,8 @@ from __future__ import annotations -from typing import Any, Iterable +from typing import Any, Iterable, Literal +from html import escape as _escape from .funcs import func from .units import unit @@ -82,6 +83,39 @@ ) +def escape(s: str, quote: bool | Literal["'", '"'] = True): + """ + Version of html.escape that allows to escape only one kind of quote. + """ + result = _escape(s, quote=False) + if quote is True or quote == "'": + result = result.replace("'", "'") + if quote is True or quote == '"': + result = result.replace('"', """) + return result + + +def quote(s: str) -> str: + """ + Returns an 'intelligently' quoted version of s, i.e. one that + chooses outer quotes according to string content. + + >>> print(quote("Kim's")) + "Kim's" + >>> print(quote('"Hello", he said')) + '"Hello", he said' + >>> print(quote("Kim\'s text is \"Hello\".")) + "Kim's text is "Hello"." + """ + if '"' not in s: + return f'"{escape(s, quote=False)}"' + elif "'" not in s: + return f"'{escape(s, quote=False)}'" + else: + dblquote = '"' + return f'"{escape(s, quote=dblquote)}"' + + class HtmlElement: _MEMBERS = { "_cannot_have_attributes", @@ -143,13 +177,20 @@ def __getitem__(self, *children: Any): return self(children) def __str__(self) -> str: - children = "".join([str(c) for c in self._children]) + children = "".join( + [ + str(c) if isinstance(c, HtmlElement) else escape(str(c), quote=False) + for c in self._children + ] + ) if self._tag is None: return children tag = _clean_name(self._tag) - attrs = " ".join([f'{_clean_name(k)}="{v}"' for k, v in self._attrs.items()]) + attrs = " ".join( + [f"{_clean_name(k)}={quote(v)}" for k, v in self._attrs.items()] + ) if self._cannot_have_children: if self._attrs: diff --git a/tests/htbuild_test.py b/tests/htbuild_test.py index eb5e273..854370c 100644 --- a/tests/htbuild_test.py +++ b/tests/htbuild_test.py @@ -12,11 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. +from shlex import quote import unittest from htbuilder import ( _my_custom_element, div, + escape, fragment, h1, img, @@ -473,6 +475,34 @@ def test_repr_html(self): normalize_whitespace("
Exists!
"), ) + def test_escape_all(self): + self.assertEqual( + escape('Kim\'s text is "Hello".', quote=True), + "Kim's text is "Hello".", + ) + + def test_escape_apos(self): + self.assertEqual( + escape('Kim\'s text is "Hello".', quote="'"), 'Kim's text is "Hello".' + ) + + def test_escape_quot(self): + self.assertEqual( + escape('Kim\'s text is "Hello".', quote='"'), + "Kim's text is "Hello".", + ) + + def test_complex_escaping(self): + dom = div( + "let ε < 0", + span('Hello "World"!', title="Kim's Frittenbude"), + title='He said: "Hello"', + ) + self.assertEqual( + str(dom), + """
let ε < 0Hello "World"!
""", + ) + if __name__ == "__main__": unittest.main()