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()