Skip to content

Commit 803c9b5

Browse files
committed
Add sort scripts for yaml
1 parent 2df8921 commit 803c9b5

3 files changed

Lines changed: 314 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ vinca-gha = "vinca.generate_gha:main"
5151
vinca-azure = "vinca.generate_azure:main"
5252
vinca-migrate = "vinca.migrate:main"
5353
vinca-snapshot = "vinca.snapshot:main"
54+
vinca-sort-vinca-lists = "vinca.sort_vinca_lists:main"
55+
vinca-sort-yaml-keys = "vinca.sort_yaml_keys:main"
5456

5557
[tool.hatch.version]
5658
path = "vinca/__init__.py"

vinca/sort_vinca_lists.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
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()

vinca/sort_yaml_keys.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#!/usr/bin/env python3
2+
"""Sort top-level keys in YAML mapping files using ruamel.yaml (preserves comments).
3+
4+
Usage:
5+
vinca-sort-yaml-keys FILE [FILE ...]
6+
vinca-sort-yaml-keys --check FILE [FILE ...]
7+
"""
8+
9+
import shutil
10+
import sys
11+
import tempfile
12+
from io import StringIO
13+
from pathlib import Path
14+
15+
from ruamel.yaml import YAML
16+
from ruamel.yaml.comments import CommentedMap
17+
18+
19+
def sort_mapping_keys(path: Path) -> bool:
20+
"""Sort top-level keys of a YAML mapping file in place. Returns True if changed."""
21+
yaml = YAML()
22+
yaml.preserve_quotes = True
23+
yaml.width = 4096
24+
25+
text_before = path.read_text()
26+
data = yaml.load(text_before)
27+
if not hasattr(data, "keys"):
28+
return False
29+
30+
sorted_keys = sorted(data.keys(), key=str.casefold)
31+
if list(data.keys()) == sorted_keys:
32+
return False
33+
34+
new_data = CommentedMap()
35+
if data.ca and data.ca.comment:
36+
new_data.ca.comment = data.ca.comment
37+
for key in sorted_keys:
38+
new_data[key] = data[key]
39+
if key in data.ca.items:
40+
new_data.ca.items[key] = data.ca.items[key]
41+
42+
buf = StringIO()
43+
yaml.dump(new_data, buf)
44+
text_after = buf.getvalue()
45+
if text_after != text_before:
46+
path.write_text(text_after)
47+
return True
48+
return False
49+
50+
51+
def main():
52+
import argparse
53+
54+
parser = argparse.ArgumentParser(description=__doc__)
55+
parser.add_argument("files", nargs="+", type=Path, help="YAML files to sort")
56+
parser.add_argument(
57+
"--check", action="store_true", help="Check only, exit 1 if unsorted"
58+
)
59+
args = parser.parse_args()
60+
61+
any_changed = False
62+
for path in args.files:
63+
if not path.exists():
64+
print(f"ERROR: {path} not found", file=sys.stderr)
65+
sys.exit(1)
66+
if args.check:
67+
with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as tf:
68+
tmp = Path(tf.name)
69+
shutil.copy2(path, tmp)
70+
changed = sort_mapping_keys(tmp)
71+
tmp.unlink(missing_ok=True)
72+
else:
73+
changed = sort_mapping_keys(path)
74+
75+
status = ("UNSORTED" if args.check else "SORTED") if changed else "OK"
76+
print(f"{status}: {path}")
77+
any_changed = any_changed or changed
78+
79+
if args.check and any_changed:
80+
sys.exit(1)
81+
82+
83+
if __name__ == "__main__":
84+
main()

0 commit comments

Comments
 (0)