From 5c67c348b0b2009d6d92ac7b138cdb7cd20acc10 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Sat, 9 May 2026 01:50:01 +0200 Subject: [PATCH 1/3] Support Chameleon attribute expressions. Fix incorrect splitting of Chameleon attribute expressions which led to broken code. This HTML `` leads now correctly to: ```html ``` --- HISTORY.md | 14 ++++++++--- zpretty/prettifier.py | 44 +++++++++++++++++++++++++++++++++++ zpretty/tests/test_zpretty.py | 31 ++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 4e0dc5c..3fc93e3 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,9 +2,17 @@ ## 4.0.1 (unreleased) - -- Nothing changed yet. - +- Support Chameleon attribute expressions. + Fix incorrect splitting of Chameleon attribute expressions which led to + broken code. + This HTML `` + leads now correctly to: + ```html + + ``` + [thet] ## 4.0.0 (2026-04-10) diff --git a/zpretty/prettifier.py b/zpretty/prettifier.py index 097d89d..595ce26 100644 --- a/zpretty/prettifier.py +++ b/zpretty/prettifier.py @@ -30,6 +30,8 @@ class ZPrettifier: _rcdata_tags = ("title", "textarea") _cdatas = [] _doctype = None + _chameleon_marker_prefix = f"data-chameleon-expr-{str(uuid4())}-" + _chameleon_expressions = [] def __init__(self, filename="", text="", encoding="utf8"): """Create a prettifier instance taking the contents @@ -106,6 +108,43 @@ def fix_rcdata_markup(self, soup): for child in parsed_children: tag.append(child) + def _extract_chameleon_expressions(self, text): + """Replace ${...} Chameleon template expressions with safe markers. + + BeautifulSoup's parser mangles ${...} expressions in attribute positions + because they contain characters invalid in HTML attribute names (spaces, + quotes). Replace them with data-* attribute markers before parsing and + restore them afterwards. + """ + self._chameleon_expressions = [] + result = [] + i = 0 + n = len(text) + while i < n: + if text[i] == "$" and i + 1 < n and text[i + 1] == "{": + depth = 1 + j = i + 2 + while j < n and depth > 0: + if text[j] == "{": + depth += 1 + elif text[j] == "}": + depth -= 1 + j += 1 + if depth == 0: + expr = text[i:j] + idx = len(self._chameleon_expressions) + marker = f"{self._chameleon_marker_prefix}{idx}" + self._chameleon_expressions.append(expr) + result.append(marker) + i = j + else: + result.append(text[i]) + i += 1 + else: + result.append(text[i]) + i += 1 + return "".join(result) + def _prepare_text(self): """This tweaks the text passed to the prettifier to overcome some limitations of the BeautifulSoup parser @@ -121,6 +160,7 @@ def _prepare_text(self): pass text = re.sub(self._cdata_pattern, self._cdata_marker, text) text = re.sub(self._doctype_pattern, self._doctype_marker, text) + text = self._extract_chameleon_expressions(text) # Get all the entities in the text and replace them with a marker # The text might contain undefined entities that BeautifulSoup @@ -175,6 +215,10 @@ def pretty_print(self, el): # Restore entities for entity, marker in self._entity_mapping.items(): prettified = prettified.replace(marker, entity) + # Restore Chameleon template expressions + for idx, expr in enumerate(self._chameleon_expressions): + marker = f"{self._chameleon_marker_prefix}{idx}" + prettified = prettified.replace(marker, expr) if self._end_with_newline and not prettified.endswith("\n"): prettified += "\n" return prettified diff --git a/zpretty/tests/test_zpretty.py b/zpretty/tests/test_zpretty.py index af66060..b929da2 100644 --- a/zpretty/tests/test_zpretty.py +++ b/zpretty/tests/test_zpretty.py @@ -204,6 +204,37 @@ def test_ampersand_and_column_in_separate_attrs(self): '\n\n', ) + def test_chameleon_expression_in_attribute_position(self): + # ${...} as a standalone attribute (dynamic attribute syntax) + self.assertPrettified( + '', + '\n', + ) + + def test_chameleon_expression_with_other_attributes(self): + self.assertPrettified( + '', # noqa: E501 + '\n', # noqa: E501 + ) + + def test_chameleon_expression_in_attribute_value(self): + self.assertPrettified( + '
hello
', + '
hello
\n', + ) + + def test_chameleon_expression_in_text_content(self): + self.assertPrettified( + "

Hello ${name}!

", + "

Hello ${name}!

\n", + ) + + def test_chameleon_expression_nested_braces(self): + self.assertPrettified( + '', + '\n', + ) + def test_sample_html(self): self.prettify("sample_html.html") From ba60d6e736da1a02d5124942a94e37350da7859d Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Wed, 13 May 2026 17:51:24 +0200 Subject: [PATCH 2/3] Fix restoration of chameleon expressions where a numerical counter was not sorted and the restoration was messed up. --- zpretty/prettifier.py | 8 ++++++-- zpretty/tests/test_zpretty.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/zpretty/prettifier.py b/zpretty/prettifier.py index 595ce26..1b821c1 100644 --- a/zpretty/prettifier.py +++ b/zpretty/prettifier.py @@ -215,8 +215,12 @@ def pretty_print(self, el): # Restore entities for entity, marker in self._entity_mapping.items(): prettified = prettified.replace(marker, entity) - # Restore Chameleon template expressions - for idx, expr in enumerate(self._chameleon_expressions): + # Restore Chameleon template expressions. + # Sort longest-index-first so that e.g. "prefix-10" is replaced before + # "prefix-1" (which is a prefix of "prefix-10" and would corrupt it). + for idx, expr in sorted( + enumerate(self._chameleon_expressions), key=lambda x: -len(str(x[0])) + ): marker = f"{self._chameleon_marker_prefix}{idx}" prettified = prettified.replace(marker, expr) if self._end_with_newline and not prettified.endswith("\n"): diff --git a/zpretty/tests/test_zpretty.py b/zpretty/tests/test_zpretty.py index b929da2..238ebf7 100644 --- a/zpretty/tests/test_zpretty.py +++ b/zpretty/tests/test_zpretty.py @@ -235,6 +235,17 @@ def test_chameleon_expression_nested_braces(self): '\n', ) + def test_chameleon_expression_many_expressions(self): + # 10+ expressions: "prefix-1" is a prefix of "prefix-10" so restoration + # must replace longer markers first to avoid corrupting shorter ones. + items = "".join(f'
  • ${{label{i}}}
  • ' for i in range(12)) + input_html = f"
      {items}
    " + prettifier = ZPrettifier(FakeConfig(), text=input_html) + result = prettifier() + for i in range(12): + self.assertIn(f'href="${{{i}}}"', result) + self.assertIn(f">${{label{i}}}<", result) + def test_sample_html(self): self.prettify("sample_html.html") From ebc25d42fadc8e8cd4f76c308cb7618ec0d64c04 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 18:27:05 +0000 Subject: [PATCH 3/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- zpretty/tests/test_zpretty.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/zpretty/tests/test_zpretty.py b/zpretty/tests/test_zpretty.py index 238ebf7..7825068 100644 --- a/zpretty/tests/test_zpretty.py +++ b/zpretty/tests/test_zpretty.py @@ -238,7 +238,9 @@ def test_chameleon_expression_nested_braces(self): def test_chameleon_expression_many_expressions(self): # 10+ expressions: "prefix-1" is a prefix of "prefix-10" so restoration # must replace longer markers first to avoid corrupting shorter ones. - items = "".join(f'
  • ${{label{i}}}
  • ' for i in range(12)) + items = "".join( + f'
  • ${{label{i}}}
  • ' for i in range(12) + ) input_html = f"
      {items}
    " prettifier = ZPrettifier(FakeConfig(), text=input_html) result = prettifier()