From 803c9b555806d2ca677bb99492acda8995e3fd10 Mon Sep 17 00:00:00 2001 From: Tim Clephas Date: Tue, 7 Jul 2026 10:26:40 +0200 Subject: [PATCH] Add sort scripts for yaml --- pyproject.toml | 2 + vinca/sort_vinca_lists.py | 228 ++++++++++++++++++++++++++++++++++++++ vinca/sort_yaml_keys.py | 84 ++++++++++++++ 3 files changed, 314 insertions(+) create mode 100644 vinca/sort_vinca_lists.py create mode 100644 vinca/sort_yaml_keys.py diff --git a/pyproject.toml b/pyproject.toml index 474aae9..3fc0560 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,8 @@ vinca-gha = "vinca.generate_gha:main" vinca-azure = "vinca.generate_azure:main" vinca-migrate = "vinca.migrate:main" vinca-snapshot = "vinca.snapshot:main" +vinca-sort-vinca-lists = "vinca.sort_vinca_lists:main" +vinca-sort-yaml-keys = "vinca.sort_yaml_keys:main" [tool.hatch.version] path = "vinca/__init__.py" diff --git a/vinca/sort_vinca_lists.py b/vinca/sort_vinca_lists.py new file mode 100644 index 0000000..b472288 --- /dev/null +++ b/vinca/sort_vinca_lists.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Sort list items in vinca.yaml (line-based, preserves comments and formatting). + +Sorts plain ` - item` entries alphabetically within each target list key. +Conditional blocks (`- if: ... then: [...]`) stay at the end; their inner +`then:` lists are also sorted. + +Usage: + vinca-sort-vinca-lists [FILE] + vinca-sort-vinca-lists --check [FILE] +""" + +import re +import shutil +import sys +import tempfile +from pathlib import Path + +# Top-level keys whose list items should be sorted +LISTS_TO_SORT = { + "packages_select_by_deps", + "packages_skip_by_deps", + "packages_remove_from_deps", +} + +# Regex for a simple list item line: " - value" with optional inline comment +RE_SIMPLE_ITEM = re.compile(r"^ - (\S.*)$") +# Regex for the start of a conditional block: " - if: ..." +RE_IF_BLOCK = re.compile(r"^ - if:") +# Regex for a then-list item inside a conditional block: " - value" +RE_THEN_ITEM = re.compile(r"^ - (\S.*)$") +# Regex for a top-level key +RE_TOP_KEY = re.compile(r"^(\S+):") + + +def _sort_key(line: str) -> str: + """Extract sortable value from a list item line (lowercase, ignore comments).""" + m = RE_SIMPLE_ITEM.match(line) or RE_THEN_ITEM.match(line) + if m: + val = m.group(1).split("#")[0].strip() + return val.casefold() + return line.casefold() + + +def sort_vinca_lists(path: Path) -> bool: + """Sort list items in vinca.yaml in place. Returns True if changed.""" + text = path.read_text() + lines = text.splitlines(keepends=True) + + result = [] + i = 0 + changed = False + + while i < len(lines): + line = lines[i] + + # Check if this line starts a target list key + m = RE_TOP_KEY.match(line) + if m and m.group(1) in LISTS_TO_SORT: + result.append(line) + i += 1 + + # Collect all content belonging to this list + simple_items = [] # plain " - value" lines + if_blocks = [] # multi-line conditional blocks + current_if_block = None + + while i < len(lines): + line = lines[i] + + # Next top-level key or end of list + if RE_TOP_KEY.match(line): + break + + # Blank line + if line.strip() == "": + if current_if_block is not None: + current_if_block.append(line) + # Skip blank lines between simple items (sorting removes grouping) + i += 1 + continue + + # Comment-only line at list level (e.g. " # These packages...") + if line.startswith(" #") and not line.startswith(" "): + # Only finalize if current block has actual if/then content + if current_if_block is not None and any( + RE_IF_BLOCK.match(bl) for bl in current_if_block + ): + if_blocks.append(current_if_block) + current_if_block = [line] + elif current_if_block is not None: + current_if_block.append(line) + else: + current_if_block = [line] + i += 1 + continue + + # Start of conditional block: " - if: ..." + if RE_IF_BLOCK.match(line): + if current_if_block is None: + current_if_block = [] + current_if_block.append(line) + i += 1 + continue + + # Inside conditional block (then:, items, etc.) + if current_if_block is not None: + current_if_block.append(line) + i += 1 + continue + + # Simple list item: " - value" + if RE_SIMPLE_ITEM.match(line): + simple_items.append(line) + i += 1 + continue + + # Something unexpected; pass through + result.append(line) + i += 1 + continue + + # Finalize any pending if-block + if current_if_block is not None: + if_blocks.append(current_if_block) + + # Sort simple items + sorted_simple = sorted(simple_items, key=_sort_key) + if sorted_simple != simple_items: + changed = True + + # Sort then-lists inside each if-block + for block in if_blocks: + then_items = [] + then_indices = [] + blank_indices = [] + in_then = False + for bi, bline in enumerate(block): + if bline.strip() == "then:": + in_then = True + continue + if in_then: + if RE_THEN_ITEM.match(bline): + then_items.append(bline) + then_indices.append(bi) + elif bline.strip() == "": + blank_indices.append(bi) + + sorted_then = sorted(then_items, key=_sort_key) + if sorted_then != then_items: + changed = True + for bi, bline in zip(then_indices, sorted_then): + block[bi] = bline + + # Remove blank lines between then-items (sorting removes grouping) + for bi in reversed(blank_indices): + # Only remove if between then-items (not trailing) + if any(ti < bi for ti in then_indices) and any( + ti > bi for ti in then_indices + ): + block.pop(bi) + changed = True + + # Write: sorted simple items, then if-blocks + for item in sorted_simple: + result.append(item) + + for block in if_blocks: + # Strip trailing blank lines from block + while block and block[-1].strip() == "": + block.pop() + # Ensure single blank line before block + if result and result[-1].strip() != "": + result.append("\n") + for bline in block: + result.append(bline) + # Trailing blank line after all blocks + if if_blocks: + result.append("\n") + + # Ensure blank line after the list if next line is a key + if result and result[-1].strip() != "": + result.append("\n") + + continue + + # Non-target line, pass through + result.append(line) + i += 1 + + new_text = "".join(result) + if new_text != text: + changed = True + path.write_text(new_text) + return changed + + +def main(): + import argparse + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("file", nargs="?", type=Path, default=Path("vinca.yaml")) + parser.add_argument( + "--check", action="store_true", help="Check only, exit 1 if unsorted" + ) + args = parser.parse_args() + + if not args.file.exists(): + print(f"ERROR: {args.file} not found", file=sys.stderr) + sys.exit(1) + + if args.check: + with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as tf: + tmp = Path(tf.name) + shutil.copy2(args.file, tmp) + changed = sort_vinca_lists(tmp) + tmp.unlink(missing_ok=True) + else: + changed = sort_vinca_lists(args.file) + + status = ("UNSORTED" if args.check else "SORTED") if changed else "OK" + print(f"{status}: {args.file}") + if args.check and changed: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/vinca/sort_yaml_keys.py b/vinca/sort_yaml_keys.py new file mode 100644 index 0000000..c1be6ac --- /dev/null +++ b/vinca/sort_yaml_keys.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Sort top-level keys in YAML mapping files using ruamel.yaml (preserves comments). + +Usage: + vinca-sort-yaml-keys FILE [FILE ...] + vinca-sort-yaml-keys --check FILE [FILE ...] +""" + +import shutil +import sys +import tempfile +from io import StringIO +from pathlib import Path + +from ruamel.yaml import YAML +from ruamel.yaml.comments import CommentedMap + + +def sort_mapping_keys(path: Path) -> bool: + """Sort top-level keys of a YAML mapping file in place. Returns True if changed.""" + yaml = YAML() + yaml.preserve_quotes = True + yaml.width = 4096 + + text_before = path.read_text() + data = yaml.load(text_before) + if not hasattr(data, "keys"): + return False + + sorted_keys = sorted(data.keys(), key=str.casefold) + if list(data.keys()) == sorted_keys: + return False + + new_data = CommentedMap() + if data.ca and data.ca.comment: + new_data.ca.comment = data.ca.comment + for key in sorted_keys: + new_data[key] = data[key] + if key in data.ca.items: + new_data.ca.items[key] = data.ca.items[key] + + buf = StringIO() + yaml.dump(new_data, buf) + text_after = buf.getvalue() + if text_after != text_before: + path.write_text(text_after) + return True + return False + + +def main(): + import argparse + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("files", nargs="+", type=Path, help="YAML files to sort") + parser.add_argument( + "--check", action="store_true", help="Check only, exit 1 if unsorted" + ) + args = parser.parse_args() + + any_changed = False + for path in args.files: + if not path.exists(): + print(f"ERROR: {path} not found", file=sys.stderr) + sys.exit(1) + if args.check: + with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as tf: + tmp = Path(tf.name) + shutil.copy2(path, tmp) + changed = sort_mapping_keys(tmp) + tmp.unlink(missing_ok=True) + else: + changed = sort_mapping_keys(path) + + status = ("UNSORTED" if args.check else "SORTED") if changed else "OK" + print(f"{status}: {path}") + any_changed = any_changed or changed + + if args.check and any_changed: + sys.exit(1) + + +if __name__ == "__main__": + main()