diff --git a/HISTORY.md b/HISTORY.md
index 4e0dc5c..68c1512 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,21 +1,27 @@
# Changelog
-## 4.0.1 (unreleased)
+## 4.1.0 (unreleased)
+- Add a switch for multiline class attribute values.
+ Per default class attributes are not split into multiple lines. That's what
+ most tools do and most people would expect. To split class attributes into
+ multiple lines use the `--split-class` option on the CLI.
+ [thet]
-- Nothing changed yet.
-
+- Multiline values for class attributes.
+ Split multiple class attribute values into multiple lines. Keep it
+ single-lined, if there is only one value. And don't split Chameleon
+ expressions.
+ [thet]
## 4.0.0 (2026-04-10)
-
- Declare support for Python 3.10 - 3.14
[ale-rt]
- Do not break `title` and `textarea` in page templates.
(Fixes #198)
[ale-rt]
-
## 3.1.1 (2025-06-23)
- When parsing XML files, preserve space in all the tags
@@ -24,10 +30,8 @@
- Declare support for Python 3.13
[ale-rt]
-
## 3.1.0 (2023-06-30)
-
- No changes were made from the latest alpha version
[ale-rt]
diff --git a/README.md b/README.md
index 86f514f..d75c1b9 100644
--- a/README.md
+++ b/README.md
@@ -69,8 +69,8 @@ Basic usage:
```console
$ zpretty -h
-usage: zpretty [-h] [--encoding ENCODING] [-i] [-v] [-x] [-z] [--check]
- [--include INCLUDE] [--exclude EXCLUDE]
+usage: zpretty [-h] [--encoding ENCODING] [-i] [-v] [-x] [-z] [--split-class]
+ [--check] [--include INCLUDE] [--exclude EXCLUDE]
[--extend-exclude EXTEND_EXCLUDE]
[paths ...]
@@ -88,6 +88,7 @@ options:
-x, --xml Treat the input file(s) as XML
-z, --zcml Treat the input file(s) as XML. Follow the ZCML
styleguide
+ --split-class Split CSS class attribute values into multiple lines.
--check Return code 0 if nothing would be changed, 1 if some
files would be reformatted
--include INCLUDE A regular expression that matches files and directories
diff --git a/zpretty/attributes.py b/zpretty/attributes.py
index 05ec795..a8fbbe0 100644
--- a/zpretty/attributes.py
+++ b/zpretty/attributes.py
@@ -78,11 +78,15 @@ class PrettyAttributes:
"i18n:ignore-attributes",
)
- def __init__(self, attributes, element=None):
+ def __init__(self, config, attributes, element=None):
"""attributes is a dict like object"""
+ self.config = config
self.attributes = attributes
self.element = element
+ if self.config.split_class:
+ self._multiline_attributes += ("class",)
+
def __len__(self):
return len(self.attributes)
@@ -129,11 +133,60 @@ def sort_attributes(self, name):
return (900, name)
return (200, name)
+ def split_outside_braces(self, value: str) -> list[str]:
+ """Empty space splitter that respects Chameleon expressions.
+
+ It splits on ` ` except for expressions within `{}`
+ """
+ parts = []
+ current = []
+ depth = 0
+
+ for char in value:
+ if char == "{":
+ depth += 1
+ current.append(char)
+ elif char == "}":
+ depth -= 1
+ current.append(char)
+ elif char == " " and depth == 0:
+ if current:
+ parts.append("".join(current))
+ current = []
+ else:
+ current.append(char)
+
+ if current:
+ parts.append("".join(current))
+
+ return parts
+
def format_multiline(self, name, value):
- """"""
- value_lines = filter(None, value.split())
- line_joiner = "\n" + (" " * (len(name) + 2))
- return line_joiner.join(value_lines)
+ """Format attributes values to multiline, split on an empty space.` `
+ except for expressions within `{}`
+ """
+
+ lines = self.split_outside_braces(value)
+
+ # Don't split single-values
+ if len(lines) < 2:
+ return value
+
+ try:
+ line_prefix = self.element.prefix + self.prefix + self._multiline_prefix
+ except AttributeError:
+ line_prefix = self._multiline_prefix
+
+ # unindent at the end
+ line_prefix_end = line_prefix[:-2]
+
+ # Add line indents
+ new_value = line_prefix + f"\n{line_prefix}".join(lines)
+
+ # Line break at start and end and add end indent.
+ new_value = f"\n{new_value}\n{line_prefix_end}"
+
+ return new_value
def format_tal_multiline(self, value):
"""There are some tal specific attributes that contain ; separated
diff --git a/zpretty/cli.py b/zpretty/cli.py
index e11369f..cb8b75f 100644
--- a/zpretty/cli.py
+++ b/zpretty/cli.py
@@ -72,6 +72,13 @@ def parser(self):
dest="zcml",
default=False,
)
+ parser.add_argument(
+ "--split-class",
+ help="Split CSS class attribute values into multiple lines.",
+ action="store_true",
+ dest="split_class",
+ default=False,
+ )
parser.add_argument(
"--check",
help=(
@@ -212,7 +219,7 @@ def run(self):
for path in self.good_paths:
# use Pathlib to check if the file exists and it is a file
Prettifier = self.choose_prettifier(path)
- prettifier = Prettifier(path, encoding=encoding)
+ prettifier = Prettifier(self.config, path, encoding=encoding)
if self.config.check:
if not prettifier.check():
self.errors.append(f"This file would be rewritten: {path}")
diff --git a/zpretty/elements.py b/zpretty/elements.py
index 5eb2053..d3740f0 100644
--- a/zpretty/elements.py
+++ b/zpretty/elements.py
@@ -91,8 +91,9 @@ class PrettyElement:
"textarea",
]
- def __init__(self, context, level=0):
+ def __init__(self, config, context, level=0):
"""Take something a (bs4) element and an indentation level"""
+ self.config = config
self.context = context
self.level = level
@@ -180,7 +181,7 @@ def getparent(self):
parent = self.context.parent
if not parent or parent.name == BeautifulSoup.ROOT_TAG_NAME:
return None
- return self.__class__(parent)
+ return self.__class__(self.config, parent)
@memo
def getchildren(self):
@@ -188,7 +189,7 @@ def getchildren(self):
children = []
next_level = self.level + 1
for child in getattr(self.context, "children", []):
- child = self.__class__(child, next_level)
+ child = self.__class__(self.config, child, next_level)
try:
child.is_tag() and child.is_self_closing()
except OpenTagException:
@@ -227,7 +228,7 @@ def text(self):
def attributes(self):
"""Return the wrapped attributes"""
attributes = getattr(self.context, "attrs", {})
- return self.attribute_klass(attributes, self)
+ return self.attribute_klass(self.config, attributes, self)
@property
@memo
diff --git a/zpretty/prettifier.py b/zpretty/prettifier.py
index 097d89d..9da2186 100644
--- a/zpretty/prettifier.py
+++ b/zpretty/prettifier.py
@@ -31,10 +31,11 @@ class ZPrettifier:
_cdatas = []
_doctype = None
- def __init__(self, filename="", text="", encoding="utf8"):
+ def __init__(self, config, filename="", text="", encoding="utf8"):
"""Create a prettifier instance taking the contents
from a text or a filename
"""
+ self.config = config
self._entity_mapping = {}
self.encoding = encoding
self.filename = filename
@@ -67,7 +68,7 @@ def __init__(self, filename="", text="", encoding="utf8"):
for el in self.soup.find_all(attrs={key: ""}):
el.attrs.pop(key, None)
- self.root = self.pretty_element(self.soup, -1)
+ self.root = self.pretty_element(config, self.soup, -1)
def fix_rcdata_markup(self, soup):
"""Parse markup-like text inside RCDATA tags as child nodes.
diff --git a/zpretty/tests/test_attributes.py b/zpretty/tests/test_attributes.py
index bcc40a4..e332d74 100644
--- a/zpretty/tests/test_attributes.py
+++ b/zpretty/tests/test_attributes.py
@@ -4,6 +4,10 @@
from zpretty.elements import PrettyElement
+class FakeConfig:
+ split_class = False
+
+
class TestZPrettyAttributess(TestCase):
"""Test zpretty"""
@@ -12,15 +16,18 @@ def get_element(self, text, level=0):
soup = BeautifulSoup(
"%s" % text, "html.parser"
)
- return PrettyElement(soup.fake_root.next_element, level)
+ return PrettyElement(FakeConfig(), soup.fake_root.next_element, level)
- def assertPrettifiedAttributes(self, attributes, expected, level=0):
+ def assertPrettifiedAttributes(self, attributes, expected, level=0, config=None):
"""Check if the attributes are properly sorted and formatted"""
if level == 0:
el = None
else:
el = self.get_element("a", level)
- pretty_attribute = PrettyAttributes(attributes, el)
+
+ config = config if config else FakeConfig()
+
+ pretty_attribute = PrettyAttributes(config, attributes, el)
observed = pretty_attribute()
self.assertEqual(observed, expected)
@@ -77,3 +84,44 @@ def test_format_attributes_many_attribute(self):
)
),
)
+
+ def test_class_multiline_no_split(self):
+ """Class attributes with multiple values should be split."""
+ self.assertPrettifiedAttributes(
+ {"class": "class1 class2 ${python: 'class3' if True else ''} class4"},
+ '''class="class1 class2 ${python: 'class3' if True else ''} class4"''',
+ )
+
+ def test_class_multiline(self):
+ """Class attributes with multiple values should be split."""
+ config = FakeConfig()
+ config.split_class = True
+ self.assertPrettifiedAttributes(
+ {"class": "class1 class2 ${python: 'class3' if True else ''} class4"},
+ "\n".join(
+ (
+ 'class="',
+ " class1",
+ " class2",
+ " ${python: 'class3' if True else ''}",
+ " class4",
+ '"',
+ )
+ ),
+ 0,
+ config,
+ )
+
+ def test_data_pat_singleline(self):
+ """Class with single values should not be split."""
+ config = FakeConfig()
+ config.split_class = True
+ self.assertPrettifiedAttributes(
+ {"class": "class1"}, 'class="class1"', 0, config
+ )
+
+ def test_data_pat_empty(self):
+ """Empty class attributes should not be split."""
+ config = FakeConfig()
+ config.split_class = True
+ self.assertPrettifiedAttributes({"class": ""}, 'class=""', 0, config)
diff --git a/zpretty/tests/test_elements.py b/zpretty/tests/test_elements.py
index 9df6dbd..59e31cc 100644
--- a/zpretty/tests/test_elements.py
+++ b/zpretty/tests/test_elements.py
@@ -3,6 +3,10 @@
from zpretty.elements import PrettyElement
+class FakeConfig:
+ split_class = False
+
+
class TestPrettyElements(TestCase):
"""Test basic funtionalities of the PrettyElement class"""
@@ -11,7 +15,7 @@ def get_element(self, text, level=0):
soup = BeautifulSoup(
"%s" % text, "html.parser"
)
- return PrettyElement(soup.fake_root.next_element, level)
+ return PrettyElement(FakeConfig(), soup.fake_root.next_element, level)
def test_comment(self):
el = self.get_element("")
diff --git a/zpretty/tests/test_xml.py b/zpretty/tests/test_xml.py
index c66efe5..e44a8a1 100644
--- a/zpretty/tests/test_xml.py
+++ b/zpretty/tests/test_xml.py
@@ -5,6 +5,10 @@
from zpretty.xml import XMLPrettifier
+class FakeConfig:
+ split_class = False
+
+
class TestZpretty(TestCase):
"""Test zpretty"""
@@ -17,14 +21,14 @@ def get_element(self, text, level=0):
soup = BeautifulSoup(
"%s" % text, "html.parser"
)
- return XMLElement(soup.fake_root.next_element, level)
+ return XMLElement(FakeConfig(), soup.fake_root.next_element, level)
def prettify(self, filename):
"""Run prettify on filename and check that the output is equal to
the file content itself
"""
filename_path = self.sample_folder_path / filename
- prettifier = XMLPrettifier(filename_path)
+ prettifier = XMLPrettifier(FakeConfig(), filename_path)
observed = prettifier()
expected = filename_path.read_text()
self.assertListEqual(observed.splitlines(), expected.splitlines())
diff --git a/zpretty/tests/test_zcml.py b/zpretty/tests/test_zcml.py
index d5ccb7b..3cfb8ed 100644
--- a/zpretty/tests/test_zcml.py
+++ b/zpretty/tests/test_zcml.py
@@ -6,6 +6,10 @@
from zpretty.zcml import ZCMLPrettifier
+class FakeConfig:
+ split_class = False
+
+
class TestZpretty(TestCase):
"""Test zpretty"""
@@ -17,7 +21,7 @@ def get_element(self, text, level=0):
soup = BeautifulSoup(
"%s" % text, "html.parser"
)
- return ZCMLElement(soup.fake_root.next_element, level)
+ return ZCMLElement(FakeConfig(), soup.fake_root.next_element, level)
def assertPrettifiedAttributes(self, attributes, expected, level=0):
"""Check if the attributes are properly sorted and formatted"""
@@ -25,12 +29,12 @@ def assertPrettifiedAttributes(self, attributes, expected, level=0):
el = None
else:
el = self.get_element("foo", level)
- pretty_attribute = ZCMLAttributes(attributes, el)
+ pretty_attribute = ZCMLAttributes(FakeConfig(), attributes, el)
observed = pretty_attribute()
self.assertEqual(observed, expected)
def test_zcml_attributes_no_attributes(self):
- self.assertPrettifiedAttributes(ZCMLAttributes({})(), "")
+ self.assertPrettifiedAttributes(ZCMLAttributes(FakeConfig(), {})(), "")
self.assertPrettifiedAttributes({}, "", level=2)
def test_zcml_attributes_one_attributes(self):
@@ -225,7 +229,7 @@ def prettify(self, filename):
the file content itself
"""
filename_path = self.sample_folder_path / filename
- prettifier = ZCMLPrettifier(filename_path)
+ prettifier = ZCMLPrettifier(FakeConfig(), filename_path)
observed = prettifier()
expected = filename_path.read_text()
self.assertListEqual(observed.splitlines(), expected.splitlines())
diff --git a/zpretty/tests/test_zpretty.py b/zpretty/tests/test_zpretty.py
index af66060..cb11b62 100644
--- a/zpretty/tests/test_zpretty.py
+++ b/zpretty/tests/test_zpretty.py
@@ -3,6 +3,10 @@
from zpretty.prettifier import ZPrettifier
+class FakeConfig:
+ split_class = False
+
+
class TestZpretty(TestCase):
"""Test zpretty"""
@@ -13,7 +17,7 @@ def assertPrettified(self, original, expected, encoding="utf8"):
"""Check if the original html has been prettified as expected"""
if isinstance(expected, tuple):
expected = "\n".join(expected)
- prettifier = ZPrettifier(text=original, encoding=encoding)
+ prettifier = ZPrettifier(FakeConfig(), text=original, encoding=encoding)
self.assertFalse(prettifier.check())
observed = prettifier()
self.assertEqual(observed, expected)
@@ -23,7 +27,7 @@ def prettify(self, filename):
the file content itself
"""
filename_path = self.sample_folder_path / filename
- prettifier = ZPrettifier(filename_path)
+ prettifier = ZPrettifier(FakeConfig(), filename_path)
self.assertTrue(prettifier.check())
observed = prettifier()
expected = filename_path.read_text()
@@ -153,7 +157,7 @@ def test_textarea_prettifies_markup_like_text(self):
)
def test_element_repr(self):
- prettifier = ZPrettifier(text="")
+ prettifier = ZPrettifier(FakeConfig(), text="")
self.assertEqual(repr(prettifier.root), "")
def test_whitelines_not_stripped(self):