"""Sort a simple YAML file, keeping blocks of comments and definitions together. We assume a strict subset of YAML that looks like: # block of header comments # here that should always # be at the top of the file # optional comments # can go here key: value key: value key: value In other words, we don't sort deeper than the top layer, and might corrupt complicated YAML files. """ from __future__ import annotations import argparse from collections.abc import Sequence QUOTES = ["'", '"'] def sort(lines: list[str]) -> list[str]: """Sort a YAML file in alphabetical order, keeping blocks together. :param lines: array of strings (without newlines) :return: sorted array of strings """ # make a copy of lines since we will clobber it lines = list(lines) new_lines = parse_block(lines, header=True) for block in sorted(parse_blocks(lines), key=first_key): if new_lines: new_lines.append('') new_lines.extend(block) return new_lines def parse_block(lines: list[str], header: bool = False) -> list[str]: """Parse and return a single block, popping off the start of `lines`. If parsing a header block, we stop after we reach a line that is not a comment. Otherwise, we stop after reaching an empty line. :param lines: list of lines :param header: whether we are parsing a header block :return: list of lines that form the single block """ block_lines = [] while lines and lines[0] and (not header or lines[0].startswith('#')): block_lines.append(lines.pop(0)) return block_lines def parse_blocks(lines: list[str]) -> list[list[str]]: """Parse and return all possible blocks, popping off the start of `lines`. :param lines: list of lines :return: list of blocks, where each block is a list of lines """ blocks = [] while lines: if lines[0] == '': lines.pop(0) else: blocks.append(parse_block(lines)) return blocks def first_key(lines: list[str]) -> str: """Returns a string representing the sort key of a block. The sort key is the first YAML key we encounter, ignoring comments, and stripping leading quotes. >>> print(test) # some comment 'foo': true >>> first_key(test) 'foo' """ for line in lines: if line.startswith('#'): continue if any(line.startswith(quote) for quote in QUOTES): return line[1:] return line else: return '' # not actually reached in reality def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument('filenames', nargs='*', help='Filenames to fix') args = parser.parse_args(argv) retval = 0 for filename in args.filenames: with open(filename, 'r+') as f: lines = [line.rstrip() for line in f.readlines()] new_lines = sort(lines) if lines != new_lines: print(f'Fixing file `{filename}`') f.seek(0) f.write('\n'.join(new_lines) + '\n') f.truncate() retval = 1 return retval if __name__ == '__main__': raise SystemExit(main())