From d2f76a789cf064ef880b81ee77fd6b1c0a74f90c Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Fri, 8 May 2026 23:52:32 +0200 Subject: [PATCH 1/2] 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. --- HISTORY.md | 14 ++++---- zpretty/attributes.py | 59 +++++++++++++++++++++++++++++--- zpretty/tests/test_attributes.py | 24 +++++++++++++ 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 4e0dc5c..2fceed3 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,21 +1,21 @@ # Changelog -## 4.0.1 (unreleased) - - -- Nothing changed yet. +## 4.1.0 (unreleased) +- 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 +24,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/zpretty/attributes.py b/zpretty/attributes.py index 05ec795..d433484 100644 --- a/zpretty/attributes.py +++ b/zpretty/attributes.py @@ -43,7 +43,7 @@ class PrettyAttributes: "truespeed", ) _multiline_prefix = " " - _multiline_attributes = () + _multiline_attributes = ("class",) _tal_multiline_attributes = ( "attributes", "define", @@ -129,11 +129,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/tests/test_attributes.py b/zpretty/tests/test_attributes.py index bcc40a4..82d4aed 100644 --- a/zpretty/tests/test_attributes.py +++ b/zpretty/tests/test_attributes.py @@ -77,3 +77,27 @@ def test_format_attributes_many_attribute(self): ) ), ) + + def test_class_multiline(self): + """Class attributes with multiple values should be split.""" + self.assertPrettifiedAttributes( + {"class": "class1 class2 ${python: 'class3' if True else ''} class4"}, + "\n".join( + ( + 'class="', + " class1", + " class2", + " ${python: 'class3' if True else ''}", + " class4", + '"', + ) + ), + ) + + def test_data_pat_singleline(self): + """Class with single values should not be split.""" + self.assertPrettifiedAttributes({"class": "class1"}, 'class="class1"') + + def test_data_pat_empty(self): + """Empty class attributes should not be split.""" + self.assertPrettifiedAttributes({"class": ""}, 'class=""') From 9908462b2f2cd3f9f882a9508e1fd2bdd2ecf117 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Sat, 9 May 2026 01:00:29 +0200 Subject: [PATCH 2/2] 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. --- HISTORY.md | 6 ++++++ README.md | 5 +++-- zpretty/attributes.py | 8 ++++++-- zpretty/cli.py | 9 ++++++++- zpretty/elements.py | 9 +++++---- zpretty/prettifier.py | 5 +++-- zpretty/tests/test_attributes.py | 34 +++++++++++++++++++++++++++----- zpretty/tests/test_elements.py | 6 +++++- zpretty/tests/test_xml.py | 8 ++++++-- zpretty/tests/test_zcml.py | 12 +++++++---- zpretty/tests/test_zpretty.py | 10 +++++++--- 11 files changed, 86 insertions(+), 26 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 2fceed3..68c1512 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,6 +2,12 @@ ## 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] + - 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 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 d433484..a8fbbe0 100644 --- a/zpretty/attributes.py +++ b/zpretty/attributes.py @@ -43,7 +43,7 @@ class PrettyAttributes: "truespeed", ) _multiline_prefix = " " - _multiline_attributes = ("class",) + _multiline_attributes = () _tal_multiline_attributes = ( "attributes", "define", @@ -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) 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 82d4aed..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) @@ -78,8 +85,17 @@ 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( @@ -92,12 +108,20 @@ def test_class_multiline(self): '"', ) ), + 0, + config, ) def test_data_pat_singleline(self): """Class with single values should not be split.""" - self.assertPrettifiedAttributes({"class": "class1"}, 'class="class1"') + 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.""" - self.assertPrettifiedAttributes({"class": ""}, 'class=""') + 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):