|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Sort list items in vinca.yaml (line-based, preserves comments and formatting). |
| 3 | +
|
| 4 | +Sorts plain ` - item` entries alphabetically within each target list key. |
| 5 | +Conditional blocks (`- if: ... then: [...]`) stay at the end; their inner |
| 6 | +`then:` lists are also sorted. |
| 7 | +
|
| 8 | +Usage: |
| 9 | + vinca-sort-vinca-lists [FILE] |
| 10 | + vinca-sort-vinca-lists --check [FILE] |
| 11 | +""" |
| 12 | + |
| 13 | +import re |
| 14 | +import shutil |
| 15 | +import sys |
| 16 | +import tempfile |
| 17 | +from pathlib import Path |
| 18 | + |
| 19 | +# Top-level keys whose list items should be sorted |
| 20 | +LISTS_TO_SORT = { |
| 21 | + "packages_select_by_deps", |
| 22 | + "packages_skip_by_deps", |
| 23 | + "packages_remove_from_deps", |
| 24 | +} |
| 25 | + |
| 26 | +# Regex for a simple list item line: " - value" with optional inline comment |
| 27 | +RE_SIMPLE_ITEM = re.compile(r"^ - (\S.*)$") |
| 28 | +# Regex for the start of a conditional block: " - if: ..." |
| 29 | +RE_IF_BLOCK = re.compile(r"^ - if:") |
| 30 | +# Regex for a then-list item inside a conditional block: " - value" |
| 31 | +RE_THEN_ITEM = re.compile(r"^ - (\S.*)$") |
| 32 | +# Regex for a top-level key |
| 33 | +RE_TOP_KEY = re.compile(r"^(\S+):") |
| 34 | + |
| 35 | + |
| 36 | +def _sort_key(line: str) -> str: |
| 37 | + """Extract sortable value from a list item line (lowercase, ignore comments).""" |
| 38 | + m = RE_SIMPLE_ITEM.match(line) or RE_THEN_ITEM.match(line) |
| 39 | + if m: |
| 40 | + val = m.group(1).split("#")[0].strip() |
| 41 | + return val.casefold() |
| 42 | + return line.casefold() |
| 43 | + |
| 44 | + |
| 45 | +def sort_vinca_lists(path: Path) -> bool: |
| 46 | + """Sort list items in vinca.yaml in place. Returns True if changed.""" |
| 47 | + text = path.read_text() |
| 48 | + lines = text.splitlines(keepends=True) |
| 49 | + |
| 50 | + result = [] |
| 51 | + i = 0 |
| 52 | + changed = False |
| 53 | + |
| 54 | + while i < len(lines): |
| 55 | + line = lines[i] |
| 56 | + |
| 57 | + # Check if this line starts a target list key |
| 58 | + m = RE_TOP_KEY.match(line) |
| 59 | + if m and m.group(1) in LISTS_TO_SORT: |
| 60 | + result.append(line) |
| 61 | + i += 1 |
| 62 | + |
| 63 | + # Collect all content belonging to this list |
| 64 | + simple_items = [] # plain " - value" lines |
| 65 | + if_blocks = [] # multi-line conditional blocks |
| 66 | + current_if_block = None |
| 67 | + |
| 68 | + while i < len(lines): |
| 69 | + line = lines[i] |
| 70 | + |
| 71 | + # Next top-level key or end of list |
| 72 | + if RE_TOP_KEY.match(line): |
| 73 | + break |
| 74 | + |
| 75 | + # Blank line |
| 76 | + if line.strip() == "": |
| 77 | + if current_if_block is not None: |
| 78 | + current_if_block.append(line) |
| 79 | + # Skip blank lines between simple items (sorting removes grouping) |
| 80 | + i += 1 |
| 81 | + continue |
| 82 | + |
| 83 | + # Comment-only line at list level (e.g. " # These packages...") |
| 84 | + if line.startswith(" #") and not line.startswith(" "): |
| 85 | + # Only finalize if current block has actual if/then content |
| 86 | + if current_if_block is not None and any( |
| 87 | + RE_IF_BLOCK.match(bl) for bl in current_if_block |
| 88 | + ): |
| 89 | + if_blocks.append(current_if_block) |
| 90 | + current_if_block = [line] |
| 91 | + elif current_if_block is not None: |
| 92 | + current_if_block.append(line) |
| 93 | + else: |
| 94 | + current_if_block = [line] |
| 95 | + i += 1 |
| 96 | + continue |
| 97 | + |
| 98 | + # Start of conditional block: " - if: ..." |
| 99 | + if RE_IF_BLOCK.match(line): |
| 100 | + if current_if_block is None: |
| 101 | + current_if_block = [] |
| 102 | + current_if_block.append(line) |
| 103 | + i += 1 |
| 104 | + continue |
| 105 | + |
| 106 | + # Inside conditional block (then:, items, etc.) |
| 107 | + if current_if_block is not None: |
| 108 | + current_if_block.append(line) |
| 109 | + i += 1 |
| 110 | + continue |
| 111 | + |
| 112 | + # Simple list item: " - value" |
| 113 | + if RE_SIMPLE_ITEM.match(line): |
| 114 | + simple_items.append(line) |
| 115 | + i += 1 |
| 116 | + continue |
| 117 | + |
| 118 | + # Something unexpected; pass through |
| 119 | + result.append(line) |
| 120 | + i += 1 |
| 121 | + continue |
| 122 | + |
| 123 | + # Finalize any pending if-block |
| 124 | + if current_if_block is not None: |
| 125 | + if_blocks.append(current_if_block) |
| 126 | + |
| 127 | + # Sort simple items |
| 128 | + sorted_simple = sorted(simple_items, key=_sort_key) |
| 129 | + if sorted_simple != simple_items: |
| 130 | + changed = True |
| 131 | + |
| 132 | + # Sort then-lists inside each if-block |
| 133 | + for block in if_blocks: |
| 134 | + then_items = [] |
| 135 | + then_indices = [] |
| 136 | + blank_indices = [] |
| 137 | + in_then = False |
| 138 | + for bi, bline in enumerate(block): |
| 139 | + if bline.strip() == "then:": |
| 140 | + in_then = True |
| 141 | + continue |
| 142 | + if in_then: |
| 143 | + if RE_THEN_ITEM.match(bline): |
| 144 | + then_items.append(bline) |
| 145 | + then_indices.append(bi) |
| 146 | + elif bline.strip() == "": |
| 147 | + blank_indices.append(bi) |
| 148 | + |
| 149 | + sorted_then = sorted(then_items, key=_sort_key) |
| 150 | + if sorted_then != then_items: |
| 151 | + changed = True |
| 152 | + for bi, bline in zip(then_indices, sorted_then): |
| 153 | + block[bi] = bline |
| 154 | + |
| 155 | + # Remove blank lines between then-items (sorting removes grouping) |
| 156 | + for bi in reversed(blank_indices): |
| 157 | + # Only remove if between then-items (not trailing) |
| 158 | + if any(ti < bi for ti in then_indices) and any( |
| 159 | + ti > bi for ti in then_indices |
| 160 | + ): |
| 161 | + block.pop(bi) |
| 162 | + changed = True |
| 163 | + |
| 164 | + # Write: sorted simple items, then if-blocks |
| 165 | + for item in sorted_simple: |
| 166 | + result.append(item) |
| 167 | + |
| 168 | + for block in if_blocks: |
| 169 | + # Strip trailing blank lines from block |
| 170 | + while block and block[-1].strip() == "": |
| 171 | + block.pop() |
| 172 | + # Ensure single blank line before block |
| 173 | + if result and result[-1].strip() != "": |
| 174 | + result.append("\n") |
| 175 | + for bline in block: |
| 176 | + result.append(bline) |
| 177 | + # Trailing blank line after all blocks |
| 178 | + if if_blocks: |
| 179 | + result.append("\n") |
| 180 | + |
| 181 | + # Ensure blank line after the list if next line is a key |
| 182 | + if result and result[-1].strip() != "": |
| 183 | + result.append("\n") |
| 184 | + |
| 185 | + continue |
| 186 | + |
| 187 | + # Non-target line, pass through |
| 188 | + result.append(line) |
| 189 | + i += 1 |
| 190 | + |
| 191 | + new_text = "".join(result) |
| 192 | + if new_text != text: |
| 193 | + changed = True |
| 194 | + path.write_text(new_text) |
| 195 | + return changed |
| 196 | + |
| 197 | + |
| 198 | +def main(): |
| 199 | + import argparse |
| 200 | + |
| 201 | + parser = argparse.ArgumentParser(description=__doc__) |
| 202 | + parser.add_argument("file", nargs="?", type=Path, default=Path("vinca.yaml")) |
| 203 | + parser.add_argument( |
| 204 | + "--check", action="store_true", help="Check only, exit 1 if unsorted" |
| 205 | + ) |
| 206 | + args = parser.parse_args() |
| 207 | + |
| 208 | + if not args.file.exists(): |
| 209 | + print(f"ERROR: {args.file} not found", file=sys.stderr) |
| 210 | + sys.exit(1) |
| 211 | + |
| 212 | + if args.check: |
| 213 | + with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as tf: |
| 214 | + tmp = Path(tf.name) |
| 215 | + shutil.copy2(args.file, tmp) |
| 216 | + changed = sort_vinca_lists(tmp) |
| 217 | + tmp.unlink(missing_ok=True) |
| 218 | + else: |
| 219 | + changed = sort_vinca_lists(args.file) |
| 220 | + |
| 221 | + status = ("UNSORTED" if args.check else "SORTED") if changed else "OK" |
| 222 | + print(f"{status}: {args.file}") |
| 223 | + if args.check and changed: |
| 224 | + sys.exit(1) |
| 225 | + |
| 226 | + |
| 227 | +if __name__ == "__main__": |
| 228 | + main() |
0 commit comments