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