Skip to content

Commit d188f2e

Browse files
author
Daniel Gallagher
committed
Merge branch 'master' into file_contents_sorter_hook
2 parents 05d9c8c + d419bef commit d188f2e

File tree

8 files changed

+270
-8
lines changed

8 files changed

+270
-8
lines changed

.pre-commit-hooks.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,12 @@
153153
entry: requirements-txt-fixer
154154
language: python
155155
files: requirements.*\.txt$
156+
- id: sort-simple-yaml
157+
name: Sort simple YAML files
158+
language: python
159+
entry: sort-simple-yaml
160+
description: Sorts simple YAML files which consist only of top-level keys, preserving comments and blocks.
161+
files: '^$'
156162
- id: trailing-whitespace
157163
name: Trim Trailing Whitespace
158164
description: This hook trims trailing whitespace.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ Add this to your `.pre-commit-config.yaml`
6868
- `--no-sort-keys` - when autofixing, retain the original key ordering (instead of sorting the keys)
6969
- `--top-keys comma,separated,keys` - Keys to keep at the top of mappings.
7070
- `requirements-txt-fixer` - Sorts entries in requirements.txt
71+
- `sort-simple-yaml` - Sorts simple YAML files which consist only of top-level keys, preserving comments and blocks.
7172
- `trailing-whitespace` - Trims trailing whitespace.
7273
- Markdown linebreak trailing spaces preserved for `.md` and`.markdown`;
7374
use `args: ['--markdown-linebreak-ext=txt,text']` to add other extensions,

hooks.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,12 @@
153153
entry: requirements-txt-fixer
154154
language: python
155155
files: requirements.*\.txt$
156+
- id: sort-simple-yaml
157+
name: Sort simple YAML files
158+
language: python
159+
entry: sort-simple-yaml
160+
description: Sorts simple YAML files which consist only of top-level keys, preserving comments and blocks.
161+
files: '^$'
156162
- id: trailing-whitespace
157163
name: Trim Trailing Whitespace
158164
description: This hook trims trailing whitespace.

pre_commit_hooks/requirements_txt_fixer.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,21 +30,25 @@ def __lt__(self, requirement):
3030

3131
def fix_requirements(f):
3232
requirements = []
33-
before = []
33+
before = list(f)
3434
after = []
3535

36-
for line in f:
37-
before.append(line)
36+
before_string = b''.join(before)
37+
38+
# If the file is empty (i.e. only whitespace/newlines) exit early
39+
if before_string.strip() == b'':
40+
return 0
3841

39-
# If the most recent requirement object has a value, then it's time to
40-
# start building the next requirement object.
42+
for line in before:
43+
# If the most recent requirement object has a value, then it's
44+
# time to start building the next requirement object.
4145
if not len(requirements) or requirements[-1].value is not None:
4246
requirements.append(Requirement())
4347

4448
requirement = requirements[-1]
4549

46-
# If we see a newline before any requirements, then this is a top of
47-
# file comment.
50+
# If we see a newline before any requirements, then this is a
51+
# top of file comment.
4852
if len(requirements) == 1 and line.strip() == b'':
4953
if len(requirement.comments) and requirement.comments[0].startswith(b'#'):
5054
requirement.value = b'\n'
@@ -60,7 +64,6 @@ def fix_requirements(f):
6064
after.append(comment)
6165
after.append(requirement.value)
6266

63-
before_string = b''.join(before)
6467
after_string = b''.join(after)
6568

6669
if before_string == after_string:
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
#!/usr/bin/env python
2+
"""Sort a simple YAML file, keeping blocks of comments and definitions
3+
together.
4+
5+
We assume a strict subset of YAML that looks like:
6+
7+
# block of header comments
8+
# here that should always
9+
# be at the top of the file
10+
11+
# optional comments
12+
# can go here
13+
key: value
14+
key: value
15+
16+
key: value
17+
18+
In other words, we don't sort deeper than the top layer, and might corrupt
19+
complicated YAML files.
20+
"""
21+
from __future__ import print_function
22+
23+
import argparse
24+
25+
26+
QUOTES = ["'", '"']
27+
28+
29+
def sort(lines):
30+
"""Sort a YAML file in alphabetical order, keeping blocks together.
31+
32+
:param lines: array of strings (without newlines)
33+
:return: sorted array of strings
34+
"""
35+
# make a copy of lines since we will clobber it
36+
lines = list(lines)
37+
new_lines = parse_block(lines, header=True)
38+
39+
for block in sorted(parse_blocks(lines), key=first_key):
40+
if new_lines:
41+
new_lines.append('')
42+
new_lines.extend(block)
43+
44+
return new_lines
45+
46+
47+
def parse_block(lines, header=False):
48+
"""Parse and return a single block, popping off the start of `lines`.
49+
50+
If parsing a header block, we stop after we reach a line that is not a
51+
comment. Otherwise, we stop after reaching an empty line.
52+
53+
:param lines: list of lines
54+
:param header: whether we are parsing a header block
55+
:return: list of lines that form the single block
56+
"""
57+
block_lines = []
58+
while lines and lines[0] and (not header or lines[0].startswith('#')):
59+
block_lines.append(lines.pop(0))
60+
return block_lines
61+
62+
63+
def parse_blocks(lines):
64+
"""Parse and return all possible blocks, popping off the start of `lines`.
65+
66+
:param lines: list of lines
67+
:return: list of blocks, where each block is a list of lines
68+
"""
69+
blocks = []
70+
71+
while lines:
72+
if lines[0] == '':
73+
lines.pop(0)
74+
else:
75+
blocks.append(parse_block(lines))
76+
77+
return blocks
78+
79+
80+
def first_key(lines):
81+
"""Returns a string representing the sort key of a block.
82+
83+
The sort key is the first YAML key we encounter, ignoring comments, and
84+
stripping leading quotes.
85+
86+
>>> print(test)
87+
# some comment
88+
'foo': true
89+
>>> first_key(test)
90+
'foo'
91+
"""
92+
for line in lines:
93+
if line.startswith('#'):
94+
continue
95+
if any(line.startswith(quote) for quote in QUOTES):
96+
return line[1:]
97+
return line
98+
99+
100+
def main(argv=None):
101+
parser = argparse.ArgumentParser()
102+
parser.add_argument('filenames', nargs='*', help='Filenames to fix')
103+
args = parser.parse_args(argv)
104+
105+
retval = 0
106+
107+
for filename in args.filenames:
108+
with open(filename, 'r+') as f:
109+
lines = [line.rstrip() for line in f.readlines()]
110+
new_lines = sort(lines)
111+
112+
if lines != new_lines:
113+
print("Fixing file `{filename}`".format(filename=filename))
114+
f.seek(0)
115+
f.write("\n".join(new_lines) + "\n")
116+
f.truncate()
117+
retval = 1
118+
119+
return retval
120+
121+
122+
if __name__ == '__main__':
123+
exit(main())

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
'no-commit-to-branch = pre_commit_hooks.no_commit_to_branch:main',
5757
'pretty-format-json = pre_commit_hooks.pretty_format_json:pretty_format_json',
5858
'requirements-txt-fixer = pre_commit_hooks.requirements_txt_fixer:fix_requirements_txt',
59+
'sort-simple-yaml = pre_commit_hooks.sort_simple_yaml:main',
5960
'trailing-whitespace-fixer = pre_commit_hooks.trailing_whitespace_fixer:fix_trailing_whitespace',
6061
],
6162
},

tests/requirements_txt_fixer_test.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
# Input, expected return value, expected output
77
TESTS = (
8+
(b'', 0, b''),
9+
(b'\n', 0, b'\n'),
810
(b'foo\nbar\n', 1, b'bar\nfoo\n'),
911
(b'bar\nfoo\n', 0, b'bar\nfoo\n'),
1012
(b'#comment1\nfoo\n#comment2\nbar\n', 1, b'#comment2\nbar\n#comment1\nfoo\n'),

tests/sort_simple_yaml_test.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
from __future__ import absolute_import
2+
from __future__ import unicode_literals
3+
4+
import os
5+
6+
import pytest
7+
8+
from pre_commit_hooks.sort_simple_yaml import first_key
9+
from pre_commit_hooks.sort_simple_yaml import main
10+
from pre_commit_hooks.sort_simple_yaml import parse_block
11+
from pre_commit_hooks.sort_simple_yaml import parse_blocks
12+
from pre_commit_hooks.sort_simple_yaml import sort
13+
14+
RETVAL_GOOD = 0
15+
RETVAL_BAD = 1
16+
TEST_SORTS = [
17+
(
18+
['c: true', '', 'b: 42', 'a: 19'],
19+
['b: 42', 'a: 19', '', 'c: true'],
20+
RETVAL_BAD,
21+
),
22+
23+
(
24+
['# i am', '# a header', '', 'c: true', '', 'b: 42', 'a: 19'],
25+
['# i am', '# a header', '', 'b: 42', 'a: 19', '', 'c: true'],
26+
RETVAL_BAD,
27+
),
28+
29+
(
30+
['# i am', '# a header', '', 'already: sorted', '', 'yup: i am'],
31+
['# i am', '# a header', '', 'already: sorted', '', 'yup: i am'],
32+
RETVAL_GOOD,
33+
),
34+
35+
(
36+
['# i am', '# a header'],
37+
['# i am', '# a header'],
38+
RETVAL_GOOD,
39+
),
40+
]
41+
42+
43+
@pytest.mark.parametrize('bad_lines,good_lines,retval', TEST_SORTS)
44+
def test_integration_good_bad_lines(tmpdir, bad_lines, good_lines, retval):
45+
file_path = os.path.join(tmpdir.strpath, 'foo.yaml')
46+
47+
with open(file_path, 'w') as f:
48+
f.write("\n".join(bad_lines) + "\n")
49+
50+
assert main([file_path]) == retval
51+
52+
with open(file_path, 'r') as f:
53+
assert [line.rstrip() for line in f.readlines()] == good_lines
54+
55+
56+
def test_parse_header():
57+
lines = ['# some header', '# is here', '', 'this is not a header']
58+
assert parse_block(lines, header=True) == ['# some header', '# is here']
59+
assert lines == ['', 'this is not a header']
60+
61+
lines = ['this is not a header']
62+
assert parse_block(lines, header=True) == []
63+
assert lines == ['this is not a header']
64+
65+
66+
def test_parse_block():
67+
# a normal block
68+
lines = ['a: 42', 'b: 17', '', 'c: 19']
69+
assert parse_block(lines) == ['a: 42', 'b: 17']
70+
assert lines == ['', 'c: 19']
71+
72+
# a block at the end
73+
lines = ['c: 19']
74+
assert parse_block(lines) == ['c: 19']
75+
assert lines == []
76+
77+
# no block
78+
lines = []
79+
assert parse_block(lines) == []
80+
assert lines == []
81+
82+
83+
def test_parse_blocks():
84+
# normal blocks
85+
lines = ['a: 42', 'b: 17', '', 'c: 19']
86+
assert parse_blocks(lines) == [['a: 42', 'b: 17'], ['c: 19']]
87+
assert lines == []
88+
89+
# a single block
90+
lines = ['a: 42', 'b: 17']
91+
assert parse_blocks(lines) == [['a: 42', 'b: 17']]
92+
assert lines == []
93+
94+
# no blocks
95+
lines = []
96+
assert parse_blocks(lines) == []
97+
assert lines == []
98+
99+
100+
def test_first_key():
101+
# first line
102+
lines = ['a: 42', 'b: 17', '', 'c: 19']
103+
assert first_key(lines) == 'a: 42'
104+
105+
# second line
106+
lines = ['# some comment', 'a: 42', 'b: 17', '', 'c: 19']
107+
assert first_key(lines) == 'a: 42'
108+
109+
# second line with quotes
110+
lines = ['# some comment', '"a": 42', 'b: 17', '', 'c: 19']
111+
assert first_key(lines) == 'a": 42'
112+
113+
# no lines
114+
lines = []
115+
assert first_key(lines) is None
116+
117+
118+
@pytest.mark.parametrize('bad_lines,good_lines,_', TEST_SORTS)
119+
def test_sort(bad_lines, good_lines, _):
120+
assert sort(bad_lines) == good_lines

0 commit comments

Comments
 (0)