Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input type="text" ${python: "required" if view.required else ""} />`
leads now correctly to:
```html
<input type="text"
${python: "required" if view.required else ""}
/>
```
[thet]

## 4.0.0 (2026-04-10)

Expand Down
48 changes: 48 additions & 0 deletions zpretty/prettifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -175,6 +215,14 @@ def pretty_print(self, el):
# Restore entities
for entity, marker in self._entity_mapping.items():
prettified = prettified.replace(marker, entity)
# 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"):
prettified += "\n"
return prettified
Expand Down
44 changes: 44 additions & 0 deletions zpretty/tests/test_zpretty.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,50 @@ def test_ampersand_and_column_in_separate_attrs(self):
'<foo a="&amp;"></foo>\n<tal:bar b=";" />\n',
)

def test_chameleon_expression_in_attribute_position(self):
# ${...} as a standalone attribute (dynamic attribute syntax)
self.assertPrettified(
'<input ${python:"required" if view.required else ""} />',
'<input ${python:"required" if view.required else ""} />\n',
)

def test_chameleon_expression_with_other_attributes(self):
self.assertPrettified(
'<input ${python:"required" if view.required else ""} type="text" name="foo" />', # noqa: E501
'<input name="foo"\n type="text"\n ${python:"required" if view.required else ""}\n/>\n', # noqa: E501
)

def test_chameleon_expression_in_attribute_value(self):
self.assertPrettified(
'<div class="${mycss}">hello</div>',
'<div class="${mycss}">hello</div>\n',
)

def test_chameleon_expression_in_text_content(self):
self.assertPrettified(
"<p>Hello ${name}!</p>",
"<p>Hello ${name}!</p>\n",
)

def test_chameleon_expression_nested_braces(self):
self.assertPrettified(
'<input ${python:{"key": "val"}.get("key", "")} />',
'<input ${python:{"key": "val"}.get("key", "")} />\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'<li><a href="${{{i}}}">${{label{i}}}</a></li>' for i in range(12)
)
input_html = f"<ul>{items}</ul>"
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")

Expand Down
Loading