yaml-sort 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yaml_sort-0.2.1/LICENSE +201 -0
- yaml_sort-0.2.1/PKG-INFO +81 -0
- yaml_sort-0.2.1/README.md +65 -0
- yaml_sort-0.2.1/pyproject.toml +42 -0
- yaml_sort-0.2.1/setup.cfg +4 -0
- yaml_sort-0.2.1/src/yaml_sort/__init__.py +9 -0
- yaml_sort-0.2.1/src/yaml_sort/cli.py +91 -0
- yaml_sort-0.2.1/src/yaml_sort/core.py +469 -0
- yaml_sort-0.2.1/src/yaml_sort.egg-info/PKG-INFO +81 -0
- yaml_sort-0.2.1/src/yaml_sort.egg-info/SOURCES.txt +13 -0
- yaml_sort-0.2.1/src/yaml_sort.egg-info/dependency_links.txt +1 -0
- yaml_sort-0.2.1/src/yaml_sort.egg-info/entry_points.txt +2 -0
- yaml_sort-0.2.1/src/yaml_sort.egg-info/top_level.txt +1 -0
- yaml_sort-0.2.1/tests/test_packaging.py +33 -0
- yaml_sort-0.2.1/tests/test_yaml_sort.py +139 -0
yaml_sort-0.2.1/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
yaml_sort-0.2.1/PKG-INFO
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yaml-sort
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Deterministic YAML key sorter — sorts mapping keys alphabetically while preserving comments and byte-for-byte formatting.
|
|
5
|
+
Author: Soren L. Hansen
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/sorenisanerd/yaml-sort
|
|
8
|
+
Keywords: yaml,sort,deterministic,chezmoi,diff
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# yaml-sort
|
|
18
|
+
|
|
19
|
+
Deterministic YAML key ordering. Sorts mapping keys alphabetically (bottom-up)
|
|
20
|
+
while preserving comments and byte-for-byte formatting, so running it produces
|
|
21
|
+
no diff when a file is already sorted.
|
|
22
|
+
|
|
23
|
+
Designed to keep `chezmoi`-managed config diffs clean.
|
|
24
|
+
|
|
25
|
+
## Why not just `yaml.load` → sort → `yaml.dump`?
|
|
26
|
+
|
|
27
|
+
A library round-trip rewrites the whole file: it drops comments, re-quotes
|
|
28
|
+
strings, re-indents everything, and normalizes flow/block style. That adds
|
|
29
|
+
diff noise on every run. `yaml-sort` instead parses the *block structure* and
|
|
30
|
+
re-emits it verbatim, moving only what it needs to (mapping keys).
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- Sorts mapping keys alphabetically, bottom-up — including inside list items
|
|
35
|
+
and nested maps.
|
|
36
|
+
- Never reorders list items (sequence order is meaningful).
|
|
37
|
+
- A comment that is the *last* thing in a file (a footer) stays pinned at the
|
|
38
|
+
very bottom — it is not dragged into the middle when the neighbouring key
|
|
39
|
+
re-sorts.
|
|
40
|
+
- For a list item that opens a map (`- api_key: none` then `base_url:`…), the
|
|
41
|
+
anchor key fused to the `- ` is sorted with its siblings and the `- `
|
|
42
|
+
re-fuses to whichever key sorts first, keeping every list entry fully
|
|
43
|
+
alphabetical.
|
|
44
|
+
- `--list-indent keep` (default): preserve existing indentation byte-for-byte.
|
|
45
|
+
- `--list-indent N`: re-indent sequence items N spaces right of their parent key
|
|
46
|
+
(0 = aligned, 2 = conventional indented style).
|
|
47
|
+
- `--check`, `--diff`, `--stdin` modes.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
pip install yaml-sort # from PyPI (once published)
|
|
53
|
+
# or, from a checkout:
|
|
54
|
+
pip install -e .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
yaml-sort <file> Sort file in-place
|
|
61
|
+
yaml-sort <file> --check Exit 0 if sorted, 1 if not
|
|
62
|
+
yaml-sort <file> --diff Show what would change
|
|
63
|
+
yaml-sort <file> --list-indent 2 Also re-indent lists 2 spaces under their key
|
|
64
|
+
yaml-sort --stdin Read stdin, write sorted to stdout
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Library use
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from yaml_sort import sort_text
|
|
71
|
+
|
|
72
|
+
sorted_yaml = sort_text(raw_text) # byte-preserving
|
|
73
|
+
sorted_yaml = sort_text(raw_text, 2) # re-indent lists to 2 spaces
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
pip install -e .[dev]
|
|
80
|
+
pytest
|
|
81
|
+
```
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# yaml-sort
|
|
2
|
+
|
|
3
|
+
Deterministic YAML key ordering. Sorts mapping keys alphabetically (bottom-up)
|
|
4
|
+
while preserving comments and byte-for-byte formatting, so running it produces
|
|
5
|
+
no diff when a file is already sorted.
|
|
6
|
+
|
|
7
|
+
Designed to keep `chezmoi`-managed config diffs clean.
|
|
8
|
+
|
|
9
|
+
## Why not just `yaml.load` → sort → `yaml.dump`?
|
|
10
|
+
|
|
11
|
+
A library round-trip rewrites the whole file: it drops comments, re-quotes
|
|
12
|
+
strings, re-indents everything, and normalizes flow/block style. That adds
|
|
13
|
+
diff noise on every run. `yaml-sort` instead parses the *block structure* and
|
|
14
|
+
re-emits it verbatim, moving only what it needs to (mapping keys).
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- Sorts mapping keys alphabetically, bottom-up — including inside list items
|
|
19
|
+
and nested maps.
|
|
20
|
+
- Never reorders list items (sequence order is meaningful).
|
|
21
|
+
- A comment that is the *last* thing in a file (a footer) stays pinned at the
|
|
22
|
+
very bottom — it is not dragged into the middle when the neighbouring key
|
|
23
|
+
re-sorts.
|
|
24
|
+
- For a list item that opens a map (`- api_key: none` then `base_url:`…), the
|
|
25
|
+
anchor key fused to the `- ` is sorted with its siblings and the `- `
|
|
26
|
+
re-fuses to whichever key sorts first, keeping every list entry fully
|
|
27
|
+
alphabetical.
|
|
28
|
+
- `--list-indent keep` (default): preserve existing indentation byte-for-byte.
|
|
29
|
+
- `--list-indent N`: re-indent sequence items N spaces right of their parent key
|
|
30
|
+
(0 = aligned, 2 = conventional indented style).
|
|
31
|
+
- `--check`, `--diff`, `--stdin` modes.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pip install yaml-sort # from PyPI (once published)
|
|
37
|
+
# or, from a checkout:
|
|
38
|
+
pip install -e .
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
yaml-sort <file> Sort file in-place
|
|
45
|
+
yaml-sort <file> --check Exit 0 if sorted, 1 if not
|
|
46
|
+
yaml-sort <file> --diff Show what would change
|
|
47
|
+
yaml-sort <file> --list-indent 2 Also re-indent lists 2 spaces under their key
|
|
48
|
+
yaml-sort --stdin Read stdin, write sorted to stdout
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Library use
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from yaml_sort import sort_text
|
|
55
|
+
|
|
56
|
+
sorted_yaml = sort_text(raw_text) # byte-preserving
|
|
57
|
+
sorted_yaml = sort_text(raw_text, 2) # re-indent lists to 2 spaces
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
pip install -e .[dev]
|
|
64
|
+
pytest
|
|
65
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "yaml-sort"
|
|
7
|
+
version = "0.2.1"
|
|
8
|
+
description = "Deterministic YAML key sorter — sorts mapping keys alphabetically while preserving comments and byte-for-byte formatting."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [{ name = "Soren L. Hansen" }]
|
|
13
|
+
keywords = ["yaml", "sort", "deterministic", "chezmoi", "diff"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Topic :: Text Processing :: Markup",
|
|
18
|
+
]
|
|
19
|
+
dependencies = []
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
yaml-sort = "yaml_sort.cli:main"
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/sorenisanerd/yaml-sort"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=8",
|
|
30
|
+
"PyYAML>=6",
|
|
31
|
+
"trove-classifiers>=2024.1",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools]
|
|
35
|
+
package-dir = { "" = "src" }
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["src"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
addopts = "-q"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""yaml-sort command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import difflib
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from .core import sort_text
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse_list_indent(value: str):
|
|
14
|
+
if value == "keep":
|
|
15
|
+
return None
|
|
16
|
+
try:
|
|
17
|
+
n = int(value)
|
|
18
|
+
except ValueError:
|
|
19
|
+
sys.exit(f"error: --list-indent must be 'keep' or an integer, got {value!r}")
|
|
20
|
+
if n < 0:
|
|
21
|
+
sys.exit("error: --list-indent must be >= 0")
|
|
22
|
+
return n
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalize(path: str, list_indent) -> bytes:
|
|
26
|
+
with open(path, "rb") as f:
|
|
27
|
+
return sort_text(f.read().decode("utf-8"), list_indent).encode("utf-8")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
31
|
+
ap = argparse.ArgumentParser(
|
|
32
|
+
description="Deterministic YAML key sorter — eliminates merge-conflict noise.",
|
|
33
|
+
)
|
|
34
|
+
ap.add_argument("files", nargs="*", metavar="FILE", help="YAML file(s) to sort")
|
|
35
|
+
ap.add_argument("--check", action="store_true", help="Exit 0 if sorted, 1 if not")
|
|
36
|
+
ap.add_argument("--diff", action="store_true", help="Show diff without writing")
|
|
37
|
+
ap.add_argument("--stdin", action="store_true", help="Read stdin, write sorted to stdout")
|
|
38
|
+
ap.add_argument("--list-indent", default="keep", metavar="N|keep",
|
|
39
|
+
help="Sequence item indent relative to parent key "
|
|
40
|
+
"(0=aligned, 2=indented). 'keep' preserves existing "
|
|
41
|
+
"indentation (default).")
|
|
42
|
+
return ap
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main(argv=None) -> None:
|
|
46
|
+
ap = _build_parser()
|
|
47
|
+
args = ap.parse_args(argv)
|
|
48
|
+
|
|
49
|
+
list_indent = _parse_list_indent(args.list_indent)
|
|
50
|
+
|
|
51
|
+
if args.stdin:
|
|
52
|
+
if args.check or args.diff:
|
|
53
|
+
sys.exit("error: --stdin incompatible with --check / --diff")
|
|
54
|
+
sys.stdout.write(sort_text(sys.stdin.read(), list_indent))
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
if not args.files:
|
|
58
|
+
ap.print_help()
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
exit_code = 0
|
|
62
|
+
for path in args.files:
|
|
63
|
+
if not os.path.isfile(path):
|
|
64
|
+
print(f"error: not a file — {path}", file=sys.stderr)
|
|
65
|
+
exit_code = 1
|
|
66
|
+
continue
|
|
67
|
+
normalized = normalize(path, list_indent)
|
|
68
|
+
with open(path, "rb") as f:
|
|
69
|
+
original = f.read()
|
|
70
|
+
if args.check:
|
|
71
|
+
if original != normalized:
|
|
72
|
+
print(f"not sorted — {path}", file=sys.stderr)
|
|
73
|
+
exit_code = 1
|
|
74
|
+
continue
|
|
75
|
+
if args.diff:
|
|
76
|
+
if original != normalized:
|
|
77
|
+
t1 = original.decode("utf-8").splitlines(keepends=True)
|
|
78
|
+
t2 = normalized.decode("utf-8").splitlines(keepends=True)
|
|
79
|
+
sys.stdout.writelines(difflib.unified_diff(t1, t2,
|
|
80
|
+
fromfile=f"a/{path}", tofile=f"b/{path}"))
|
|
81
|
+
continue
|
|
82
|
+
if original == normalized:
|
|
83
|
+
continue
|
|
84
|
+
with open(path, "wb") as f:
|
|
85
|
+
f.write(normalized)
|
|
86
|
+
print(f"sorted — {path}")
|
|
87
|
+
sys.exit(exit_code)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
main()
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""
|
|
2
|
+
yaml_sort — deterministic YAML key ordering.
|
|
3
|
+
|
|
4
|
+
Reads a YAML document, parses it into a block-structure tree (mappings and
|
|
5
|
+
sequences), sorts sibling mapping keys alphabetically bottom-up, and re-emits.
|
|
6
|
+
In the default ``list_indent=None`` ("keep") mode every byte is preserved
|
|
7
|
+
exactly. Passing ``list_indent=N`` re-indents sequence items so they sit N
|
|
8
|
+
spaces to the right of their parent mapping key (0 = aligned, 2 = indented).
|
|
9
|
+
|
|
10
|
+
The parser understands lists, folded plain scalars, quoted scalars, and
|
|
11
|
+
comments/gaps, and preserves them verbatim. It is deliberately *not* a
|
|
12
|
+
`yaml.load`/`yaml.dump` round-trip, because that would rewrite the whole file
|
|
13
|
+
(re-quoting, re-indenting, dropping comments) and destroy the diff-cleanliness
|
|
14
|
+
the tool exists to provide.
|
|
15
|
+
|
|
16
|
+
Notable behaviour:
|
|
17
|
+
|
|
18
|
+
* Mapping keys are sorted alphabetically, bottom-up, including inside list
|
|
19
|
+
items and nested maps.
|
|
20
|
+
* List items are *ordered* — a sequence's order is meaningful and is never
|
|
21
|
+
changed.
|
|
22
|
+
* For a list item that opens a mapping (e.g. ``- api_key: none`` followed by
|
|
23
|
+
``base_url:``…), the "anchor" key fused to the ``- `` is sorted along with
|
|
24
|
+
its siblings, and the ``- `` re-fuses to whichever key sorts first, keeping
|
|
25
|
+
every list entry fully alphabetical.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ── line helpers ─────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
def _indent(line: str) -> int:
|
|
36
|
+
return len(line) - len(line.lstrip(" "))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_gap(line: str) -> bool:
|
|
40
|
+
"""Blank, comment, document marker, or directive line."""
|
|
41
|
+
s = line.strip()
|
|
42
|
+
if not s:
|
|
43
|
+
return True
|
|
44
|
+
if s.startswith("#"):
|
|
45
|
+
return True
|
|
46
|
+
if s in ("---", "..."):
|
|
47
|
+
return True
|
|
48
|
+
if s.startswith("%"):
|
|
49
|
+
return True
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_KEY_RE = re.compile(r"^(?:['\"]([^'\"]+)['\"]|([^#:\s][^:]*?))\s*:")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_key_line(line: str) -> bool:
|
|
57
|
+
"""True if *line* is a mapping key line (not a list item, comment, etc.)."""
|
|
58
|
+
stripped = line.lstrip()
|
|
59
|
+
if not stripped or stripped.startswith("- "):
|
|
60
|
+
return False
|
|
61
|
+
return bool(_KEY_RE.match(stripped))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _extract_key(line: str) -> str:
|
|
65
|
+
stripped = line.lstrip()
|
|
66
|
+
m = _KEY_RE.match(stripped)
|
|
67
|
+
return (m.group(1) or m.group(2)) if m else stripped.rstrip(":\n")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _strip_indent(line: str) -> str:
|
|
71
|
+
"""Return the line with its leading whitespace removed (keeps the rest)."""
|
|
72
|
+
return line.lstrip(" ")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── tree model ───────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
class Container:
|
|
78
|
+
"""A mapping ('map') or sequence ('seq') made of ordered blocks.
|
|
79
|
+
|
|
80
|
+
A mapping that represents a list item (a ``- key:`` line fused to the
|
|
81
|
+
``- `` dash) has ``dash_first=True``: its first key is emitted with a
|
|
82
|
+
``- `` prefix at ``seq_indent``, and the remaining keys at its own indent.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, kind: str, indent: int):
|
|
86
|
+
self.kind = kind # 'map' | 'seq'
|
|
87
|
+
self.indent = indent # indent of this container's header lines
|
|
88
|
+
self.dash_first = False # map whose first key carries the `- `
|
|
89
|
+
self.seq_indent = 0 # indent of the dash when dash_first
|
|
90
|
+
self.blocks: list = [] # KeyBlock | ItemBlock | Container | Gap
|
|
91
|
+
|
|
92
|
+
def sort(self) -> None:
|
|
93
|
+
"""Sort mapping keys alphabetically, bottom-up. Sequences are ordered."""
|
|
94
|
+
for b in self.blocks:
|
|
95
|
+
if isinstance(b, Container):
|
|
96
|
+
b.sort()
|
|
97
|
+
elif isinstance(b, (KeyBlock, ItemBlock)) and isinstance(b.value, Container):
|
|
98
|
+
b.value.sort()
|
|
99
|
+
if self.kind == "map":
|
|
100
|
+
self._sort_map_keys()
|
|
101
|
+
|
|
102
|
+
def _sort_map_keys(self) -> None:
|
|
103
|
+
"""Rebuild a mapping with keys alphabetised, keeping each gap with the
|
|
104
|
+
key it followed in the original text (trailing-gap semantics).
|
|
105
|
+
|
|
106
|
+
One exception: a run of gap lines that is the *last* block of the
|
|
107
|
+
container is the container's own terminal/footer comment, not the
|
|
108
|
+
preceding key's. It stays pinned at the very end, so a footer comment
|
|
109
|
+
at the bottom of a file is not dragged into the middle when the
|
|
110
|
+
neighbouring key re-sorts.
|
|
111
|
+
"""
|
|
112
|
+
leading_gap: list[str] = []
|
|
113
|
+
trailing_gap: list[str] = []
|
|
114
|
+
pairs: list[tuple[KeyBlock, list[str]]] = [] # (key, trailing_gap)
|
|
115
|
+
cur: tuple[KeyBlock, list[str]] | None = None
|
|
116
|
+
last = len(self.blocks) - 1
|
|
117
|
+
for i, b in enumerate(self.blocks):
|
|
118
|
+
if isinstance(b, Gap):
|
|
119
|
+
if cur is None:
|
|
120
|
+
leading_gap.extend(b.lines)
|
|
121
|
+
elif i == last:
|
|
122
|
+
trailing_gap.extend(b.lines)
|
|
123
|
+
else:
|
|
124
|
+
cur[1].extend(b.lines)
|
|
125
|
+
else:
|
|
126
|
+
cur = (b, [])
|
|
127
|
+
pairs.append(cur)
|
|
128
|
+
pairs.sort(key=lambda p: p[0].name)
|
|
129
|
+
new_blocks: list = []
|
|
130
|
+
if leading_gap:
|
|
131
|
+
new_blocks.append(Gap(leading_gap))
|
|
132
|
+
for kb, trailing in pairs:
|
|
133
|
+
new_blocks.append(kb)
|
|
134
|
+
if trailing:
|
|
135
|
+
new_blocks.append(Gap(trailing))
|
|
136
|
+
if trailing_gap:
|
|
137
|
+
new_blocks.append(Gap(trailing_gap))
|
|
138
|
+
self.blocks = new_blocks
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Gap:
|
|
142
|
+
"""A run of blank/comment/directive lines kept verbatim."""
|
|
143
|
+
|
|
144
|
+
def __init__(self, lines: list[str]):
|
|
145
|
+
self.lines = lines
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class KeyBlock:
|
|
149
|
+
def __init__(self, name: str, header: str, cont: list[str] | None = None):
|
|
150
|
+
self.name = name
|
|
151
|
+
self.header = header # header line text minus leading indent
|
|
152
|
+
self.cont = cont or [] # folded plain-scalar continuation lines
|
|
153
|
+
self.value = None # Container | None
|
|
154
|
+
self.dash_anchor = False # True if emitted with a `- ` prefix
|
|
155
|
+
self.anchor_indent = 0 # indent of the enclosing seq when anchor
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ItemBlock:
|
|
159
|
+
def __init__(self, header: str, cont: list[str] | None = None):
|
|
160
|
+
self.header = header # header line text minus leading indent
|
|
161
|
+
self.cont = cont or [] # folded plain-scalar continuation lines
|
|
162
|
+
self.value = None # Container | None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ── parser ───────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
def _inline_value(line: str) -> bool:
|
|
168
|
+
"""True if *line* has an inline scalar value after its key/`-` prefix.
|
|
169
|
+
|
|
170
|
+
e.g. `catgirl: You are...` → True; `models:` → False.
|
|
171
|
+
"""
|
|
172
|
+
h = line.lstrip()
|
|
173
|
+
if h.startswith("- "):
|
|
174
|
+
h = h[2:].lstrip()
|
|
175
|
+
m = _KEY_RE.match(h)
|
|
176
|
+
if not m or m.end() >= len(h):
|
|
177
|
+
return False
|
|
178
|
+
rest = h[m.end():].lstrip()
|
|
179
|
+
return bool(rest) and not rest.startswith("#")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _value_quoted(line: str) -> bool:
|
|
183
|
+
"""True if the inline value of *line* is single- or double-quoted.
|
|
184
|
+
|
|
185
|
+
Quoted scalars may wrap onto deeper lines containing colons, so their
|
|
186
|
+
continuations must be consumed verbatim.
|
|
187
|
+
"""
|
|
188
|
+
h = line.lstrip()
|
|
189
|
+
if h.startswith("- "):
|
|
190
|
+
h = h[2:].lstrip()
|
|
191
|
+
m = _KEY_RE.match(h)
|
|
192
|
+
if not m or m.end() >= len(h):
|
|
193
|
+
return False
|
|
194
|
+
rest = h[m.end():].lstrip()
|
|
195
|
+
return rest[:1] in ("'", '"')
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _split_item_header(header: str):
|
|
199
|
+
"""For a `- ...` item header, return (name, has_inline) if it opens a map.
|
|
200
|
+
|
|
201
|
+
`- api_key: none` → ('api_key', True); `- value` (no colon) → None.
|
|
202
|
+
"""
|
|
203
|
+
h = header[2:].lstrip()
|
|
204
|
+
m = _KEY_RE.match(h)
|
|
205
|
+
if not m:
|
|
206
|
+
return None
|
|
207
|
+
name = m.group(1) or m.group(2)
|
|
208
|
+
return name, _inline_value("- " + h)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _gather_cont(lines, idx: int, indent: int, quoted: bool = False):
|
|
212
|
+
"""Collect folded plain-scalar continuation lines deeper than *indent*.
|
|
213
|
+
|
|
214
|
+
Returns (cont_lines, next_idx). Stops at a gap, a line at/below *indent*,
|
|
215
|
+
or (for plain scalars) a key/item line. For *quoted* scalars, every deeper
|
|
216
|
+
line is consumed verbatim regardless of colons.
|
|
217
|
+
"""
|
|
218
|
+
cont: list[str] = []
|
|
219
|
+
i = idx
|
|
220
|
+
while i < len(lines):
|
|
221
|
+
line = lines[i]
|
|
222
|
+
if _is_gap(line) or _indent(line) <= indent:
|
|
223
|
+
break
|
|
224
|
+
if not quoted:
|
|
225
|
+
s = line.lstrip()
|
|
226
|
+
if _is_key_line(line) or s.startswith("- "):
|
|
227
|
+
break
|
|
228
|
+
cont.append(line[indent:])
|
|
229
|
+
i += 1
|
|
230
|
+
return cont, i
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def parse_block(lines, idx: int, indent: int, kind: str):
|
|
234
|
+
"""Parse a single mapping key or sequence item at *indent*.
|
|
235
|
+
|
|
236
|
+
Returns (KeyBlock|ItemBlock|Container, next_idx).
|
|
237
|
+
"""
|
|
238
|
+
raw = lines[idx]
|
|
239
|
+
i = idx + 1
|
|
240
|
+
header = _strip_indent(raw)
|
|
241
|
+
|
|
242
|
+
if kind == "map":
|
|
243
|
+
name = _extract_key(raw)
|
|
244
|
+
has_inline = _inline_value(raw)
|
|
245
|
+
quoted = _value_quoted(raw)
|
|
246
|
+
item_key = None
|
|
247
|
+
else:
|
|
248
|
+
item_key = _split_item_header(header)
|
|
249
|
+
name = item_key[0] if item_key else None
|
|
250
|
+
has_inline = item_key[1] if item_key else False
|
|
251
|
+
quoted = _value_quoted(raw)
|
|
252
|
+
|
|
253
|
+
cont: list[str] = []
|
|
254
|
+
value = None
|
|
255
|
+
anchor_block = None
|
|
256
|
+
dash_map = None
|
|
257
|
+
|
|
258
|
+
j = i
|
|
259
|
+
while j < len(lines) and _is_gap(lines[j]):
|
|
260
|
+
j += 1
|
|
261
|
+
|
|
262
|
+
if j < len(lines):
|
|
263
|
+
nind = _indent(lines[j])
|
|
264
|
+
s = lines[j].lstrip()
|
|
265
|
+
next_is_block = nind > indent and (_is_key_line(lines[j]) or s.startswith("- "))
|
|
266
|
+
|
|
267
|
+
if kind == "seq" and item_key is not None and next_is_block and not quoted:
|
|
268
|
+
# A list item that opens a mapping.
|
|
269
|
+
ckind = "seq" if s.startswith("- ") else "map"
|
|
270
|
+
if ckind == "map":
|
|
271
|
+
cmap, i = parse_container(lines, i, nind, "map")
|
|
272
|
+
if has_inline:
|
|
273
|
+
# `- api_key: none` then `base_url:…`: the anchor key is a
|
|
274
|
+
# sibling of the deeper keys. Represent as one dash_first
|
|
275
|
+
# map so the anchor participates in sorting.
|
|
276
|
+
anchor = KeyBlock(item_key[0], header[2:].lstrip())
|
|
277
|
+
cmap.blocks.insert(0, anchor)
|
|
278
|
+
cmap.dash_first = True
|
|
279
|
+
cmap.seq_indent = indent
|
|
280
|
+
dash_map = cmap
|
|
281
|
+
else:
|
|
282
|
+
# `- api_key:` (empty value) then deeper keys: the deeper
|
|
283
|
+
# map is the anchor's value; the anchor stays pinned.
|
|
284
|
+
anchor = KeyBlock(item_key[0], header[2:].lstrip())
|
|
285
|
+
anchor.dash_anchor = True
|
|
286
|
+
anchor.anchor_indent = indent
|
|
287
|
+
anchor.value = cmap
|
|
288
|
+
anchor_block = anchor
|
|
289
|
+
else:
|
|
290
|
+
value, i = parse_container(lines, i, nind, ckind)
|
|
291
|
+
elif next_is_block and not has_inline:
|
|
292
|
+
# Child container for a key with no inline value (e.g. `models:`
|
|
293
|
+
# followed by an indented/aligned sequence, or a nested map).
|
|
294
|
+
ckind = "seq" if s.startswith("- ") else "map"
|
|
295
|
+
value, i = parse_container(lines, i, nind, ckind)
|
|
296
|
+
elif nind > indent:
|
|
297
|
+
# Deeper plain text → folded scalar continuation.
|
|
298
|
+
cont, i = _gather_cont(lines, i, indent, quoted=quoted)
|
|
299
|
+
elif (nind == indent and kind == "map"
|
|
300
|
+
and s.startswith("- ") and not has_inline):
|
|
301
|
+
# Aligned sequence value (items at the same indent as the key).
|
|
302
|
+
value, i = parse_container(lines, i, indent, "seq")
|
|
303
|
+
|
|
304
|
+
if dash_map is not None:
|
|
305
|
+
return dash_map, i
|
|
306
|
+
if anchor_block is not None:
|
|
307
|
+
return anchor_block, i
|
|
308
|
+
blk = KeyBlock(name, header, cont) if kind == "map" else ItemBlock(header, cont)
|
|
309
|
+
blk.value = value
|
|
310
|
+
return blk, i
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def parse_container(lines, idx: int, indent: int, kind: str):
|
|
314
|
+
"""Parse a container whose header lines are at *indent*.
|
|
315
|
+
|
|
316
|
+
Returns (Container, next_idx). Stops when a line drops below *indent* or a
|
|
317
|
+
line at *indent* is the wrong type for this container.
|
|
318
|
+
"""
|
|
319
|
+
cont = Container(kind, indent)
|
|
320
|
+
i = idx
|
|
321
|
+
pending_gap: list[str] = []
|
|
322
|
+
|
|
323
|
+
while i < len(lines):
|
|
324
|
+
line = lines[i]
|
|
325
|
+
ind = _indent(line)
|
|
326
|
+
|
|
327
|
+
if _is_gap(line):
|
|
328
|
+
if ind < indent:
|
|
329
|
+
# A gap (blank/comment) at a shallower indent belongs to the
|
|
330
|
+
# enclosing container, not this one — bubble up. Otherwise a
|
|
331
|
+
# footer comment at indent 0 would be swallowed by the previous
|
|
332
|
+
# leaf map instead of landing at the document root.
|
|
333
|
+
break
|
|
334
|
+
pending_gap.append(line)
|
|
335
|
+
i += 1
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
if ind < indent:
|
|
339
|
+
break
|
|
340
|
+
|
|
341
|
+
if ind == indent:
|
|
342
|
+
if kind == "map" and _is_key_line(line):
|
|
343
|
+
block_lines = pending_gap
|
|
344
|
+
pending_gap = []
|
|
345
|
+
if block_lines:
|
|
346
|
+
cont.blocks.append(Gap(block_lines))
|
|
347
|
+
blk, i = parse_block(lines, i, indent, "map")
|
|
348
|
+
cont.blocks.append(blk)
|
|
349
|
+
elif kind == "seq" and line.lstrip().startswith("- "):
|
|
350
|
+
block_lines = pending_gap
|
|
351
|
+
pending_gap = []
|
|
352
|
+
if block_lines:
|
|
353
|
+
cont.blocks.append(Gap(block_lines))
|
|
354
|
+
blk, i = parse_block(lines, i, indent, "seq")
|
|
355
|
+
cont.blocks.append(blk)
|
|
356
|
+
else:
|
|
357
|
+
# Wrong type at this indent → container ends here.
|
|
358
|
+
break
|
|
359
|
+
else: # ind > indent
|
|
360
|
+
# Continuation not attached to a block; stop to be safe.
|
|
361
|
+
break
|
|
362
|
+
|
|
363
|
+
if pending_gap:
|
|
364
|
+
cont.blocks.append(Gap(pending_gap))
|
|
365
|
+
return cont, i
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def parse_document(text: str):
|
|
369
|
+
lines = text.splitlines(keepends=True)
|
|
370
|
+
i = 0
|
|
371
|
+
while i < len(lines) and _is_gap(lines[i]):
|
|
372
|
+
i += 1
|
|
373
|
+
if i >= len(lines):
|
|
374
|
+
# Comment-only / blank document: preserve the gaps verbatim.
|
|
375
|
+
root = Container("map", 0)
|
|
376
|
+
if lines:
|
|
377
|
+
root.blocks.append(Gap(lines))
|
|
378
|
+
return root, lines
|
|
379
|
+
s = lines[i].strip()
|
|
380
|
+
kind = "seq" if s.startswith("- ") else "map"
|
|
381
|
+
indent = _indent(lines[i])
|
|
382
|
+
root, _ = parse_container(lines, i, indent, kind)
|
|
383
|
+
# Attach any leading gaps to the root.
|
|
384
|
+
if i > 0:
|
|
385
|
+
root.blocks.insert(0, Gap(lines[:i]))
|
|
386
|
+
return root, lines
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _eol(s: str) -> str:
|
|
390
|
+
"""Ensure a line ends with a newline (idempotent)."""
|
|
391
|
+
return s if s.endswith("\n") else s + "\n"
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ── emitter ──────────────────────────────────────────────────────────────────
|
|
395
|
+
|
|
396
|
+
def _emit_keep(node: Container, out: list[str]) -> None:
|
|
397
|
+
first = True
|
|
398
|
+
for b in node.blocks:
|
|
399
|
+
if isinstance(b, Gap):
|
|
400
|
+
out.extend(_eol(line) for line in b.lines)
|
|
401
|
+
continue
|
|
402
|
+
if isinstance(b, Container):
|
|
403
|
+
_emit_keep(b, out)
|
|
404
|
+
continue
|
|
405
|
+
if node.dash_first and first:
|
|
406
|
+
out.append(_eol(" " * node.seq_indent + "- " + b.header))
|
|
407
|
+
for c in b.cont:
|
|
408
|
+
out.append(_eol(" " * node.seq_indent + c))
|
|
409
|
+
elif isinstance(b, KeyBlock) and b.dash_anchor:
|
|
410
|
+
out.append(_eol(" " * b.anchor_indent + "- " + b.header))
|
|
411
|
+
for c in b.cont:
|
|
412
|
+
out.append(_eol(" " * b.anchor_indent + c))
|
|
413
|
+
else:
|
|
414
|
+
out.append(_eol(" " * node.indent + b.header))
|
|
415
|
+
for c in b.cont:
|
|
416
|
+
out.append(_eol(" " * node.indent + c))
|
|
417
|
+
first = False
|
|
418
|
+
if isinstance(b.value, Container):
|
|
419
|
+
_emit_keep(b.value, out)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _emit_reindent(node: Container, indent: int, list_indent: int, out: list[str]) -> None:
|
|
423
|
+
"""Re-indent sequences to *list_indent* spaces under their parent key.
|
|
424
|
+
|
|
425
|
+
Mapping nesting always steps by 2; a sequence's items are placed at the
|
|
426
|
+
parent line's indent plus *list_indent* (0 → aligned, 2 → indented).
|
|
427
|
+
"""
|
|
428
|
+
first = True
|
|
429
|
+
for b in node.blocks:
|
|
430
|
+
if isinstance(b, Gap):
|
|
431
|
+
out.extend(_eol(line) for line in b.lines)
|
|
432
|
+
continue
|
|
433
|
+
if isinstance(b, Container):
|
|
434
|
+
# A dash_first list-item map nested in a sequence: its dash sits at
|
|
435
|
+
# the seq's target indent.
|
|
436
|
+
_emit_reindent(b, indent, list_indent, out)
|
|
437
|
+
continue
|
|
438
|
+
if node.dash_first and first:
|
|
439
|
+
key_indent = indent
|
|
440
|
+
prefix = "- "
|
|
441
|
+
elif isinstance(b, KeyBlock) and b.dash_anchor:
|
|
442
|
+
key_indent = indent
|
|
443
|
+
prefix = "- "
|
|
444
|
+
else:
|
|
445
|
+
key_indent = indent + (2 if node.dash_first else 0)
|
|
446
|
+
prefix = ""
|
|
447
|
+
out.append(_eol(" " * key_indent + prefix + b.header))
|
|
448
|
+
for c in b.cont:
|
|
449
|
+
out.append(_eol(" " * key_indent + c))
|
|
450
|
+
first = False
|
|
451
|
+
if isinstance(b.value, Container):
|
|
452
|
+
child = key_indent + (2 if b.value.kind == "map" else list_indent)
|
|
453
|
+
_emit_reindent(b.value, child, list_indent, out)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def sort_text(text: str, list_indent=None) -> str:
|
|
457
|
+
root, _ = parse_document(text)
|
|
458
|
+
root.sort()
|
|
459
|
+
out: list[str] = []
|
|
460
|
+
if list_indent is None: # keep (byte-exact)
|
|
461
|
+
_emit_keep(root, out)
|
|
462
|
+
else:
|
|
463
|
+
_emit_reindent(root, root.indent, list_indent, out)
|
|
464
|
+
result = "".join(out)
|
|
465
|
+
# If the source did not end with a newline, the emitters' guaranteed-eol
|
|
466
|
+
# lines would inject a trailing one; strip it so byte-exactness holds.
|
|
467
|
+
if not text.endswith("\n") and result.endswith("\n"):
|
|
468
|
+
result = result[:-1]
|
|
469
|
+
return result
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yaml-sort
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Deterministic YAML key sorter — sorts mapping keys alphabetically while preserving comments and byte-for-byte formatting.
|
|
5
|
+
Author: Soren L. Hansen
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/sorenisanerd/yaml-sort
|
|
8
|
+
Keywords: yaml,sort,deterministic,chezmoi,diff
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# yaml-sort
|
|
18
|
+
|
|
19
|
+
Deterministic YAML key ordering. Sorts mapping keys alphabetically (bottom-up)
|
|
20
|
+
while preserving comments and byte-for-byte formatting, so running it produces
|
|
21
|
+
no diff when a file is already sorted.
|
|
22
|
+
|
|
23
|
+
Designed to keep `chezmoi`-managed config diffs clean.
|
|
24
|
+
|
|
25
|
+
## Why not just `yaml.load` → sort → `yaml.dump`?
|
|
26
|
+
|
|
27
|
+
A library round-trip rewrites the whole file: it drops comments, re-quotes
|
|
28
|
+
strings, re-indents everything, and normalizes flow/block style. That adds
|
|
29
|
+
diff noise on every run. `yaml-sort` instead parses the *block structure* and
|
|
30
|
+
re-emits it verbatim, moving only what it needs to (mapping keys).
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- Sorts mapping keys alphabetically, bottom-up — including inside list items
|
|
35
|
+
and nested maps.
|
|
36
|
+
- Never reorders list items (sequence order is meaningful).
|
|
37
|
+
- A comment that is the *last* thing in a file (a footer) stays pinned at the
|
|
38
|
+
very bottom — it is not dragged into the middle when the neighbouring key
|
|
39
|
+
re-sorts.
|
|
40
|
+
- For a list item that opens a map (`- api_key: none` then `base_url:`…), the
|
|
41
|
+
anchor key fused to the `- ` is sorted with its siblings and the `- `
|
|
42
|
+
re-fuses to whichever key sorts first, keeping every list entry fully
|
|
43
|
+
alphabetical.
|
|
44
|
+
- `--list-indent keep` (default): preserve existing indentation byte-for-byte.
|
|
45
|
+
- `--list-indent N`: re-indent sequence items N spaces right of their parent key
|
|
46
|
+
(0 = aligned, 2 = conventional indented style).
|
|
47
|
+
- `--check`, `--diff`, `--stdin` modes.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
pip install yaml-sort # from PyPI (once published)
|
|
53
|
+
# or, from a checkout:
|
|
54
|
+
pip install -e .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
yaml-sort <file> Sort file in-place
|
|
61
|
+
yaml-sort <file> --check Exit 0 if sorted, 1 if not
|
|
62
|
+
yaml-sort <file> --diff Show what would change
|
|
63
|
+
yaml-sort <file> --list-indent 2 Also re-indent lists 2 spaces under their key
|
|
64
|
+
yaml-sort --stdin Read stdin, write sorted to stdout
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Library use
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from yaml_sort import sort_text
|
|
71
|
+
|
|
72
|
+
sorted_yaml = sort_text(raw_text) # byte-preserving
|
|
73
|
+
sorted_yaml = sort_text(raw_text, 2) # re-indent lists to 2 spaces
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
pip install -e .[dev]
|
|
80
|
+
pytest
|
|
81
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/yaml_sort/__init__.py
|
|
5
|
+
src/yaml_sort/cli.py
|
|
6
|
+
src/yaml_sort/core.py
|
|
7
|
+
src/yaml_sort.egg-info/PKG-INFO
|
|
8
|
+
src/yaml_sort.egg-info/SOURCES.txt
|
|
9
|
+
src/yaml_sort.egg-info/dependency_links.txt
|
|
10
|
+
src/yaml_sort.egg-info/entry_points.txt
|
|
11
|
+
src/yaml_sort.egg-info/top_level.txt
|
|
12
|
+
tests/test_packaging.py
|
|
13
|
+
tests/test_yaml_sort.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
yaml_sort
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Packaging-metal metadata tests.
|
|
2
|
+
|
|
3
|
+
These catch upload-time failures that ``twine check`` misses. In particular,
|
|
4
|
+
PyPI rejects a wheel whose ``classifiers`` contain a Trove classifer that is
|
|
5
|
+
not in PyPI's authoritative list (HTTP 400 "is not a valid classifier") — see
|
|
6
|
+
commit 37b6105. ``twine check`` does NOT validate classifiers against that
|
|
7
|
+
list; only this test does, via the ``trove-classifiers`` package (the same
|
|
8
|
+
source-trove list PyPI accepts uploads against).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import tomllib
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
import trove_classifiers
|
|
15
|
+
|
|
16
|
+
PROJECT_ROOT = __import__("pathlib").Path(__file__).resolve().parents[1]
|
|
17
|
+
PYPROJECT = PROJECT_ROOT / "pyproject.toml"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _classifiers() -> list[str]:
|
|
21
|
+
with PYPROJECT.open("rb") as f:
|
|
22
|
+
data = tomllib.load(f)
|
|
23
|
+
return data["project"]["classifiers"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_every_classifier_is_a_valid_trove_classifier():
|
|
27
|
+
known = trove_classifiers.classifiers
|
|
28
|
+
invalid = [c for c in _classifiers() if c not in known]
|
|
29
|
+
assert not invalid, f"classifiers not in PyPI's Trove list: {invalid}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_at_least_one_classifier():
|
|
33
|
+
assert len(_classifiers()) > 0
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Unit tests for yaml-sort's core sorting/emission behaviour.
|
|
2
|
+
|
|
3
|
+
Each case is a pair of fixture files under ``tests/fixtures/``:
|
|
4
|
+
|
|
5
|
+
<case>.in.yaml input document
|
|
6
|
+
<case>.out.yaml expected output (REQUIRED for every case, even when
|
|
7
|
+
it equals the input)
|
|
8
|
+
|
|
9
|
+
``.out.yaml`` is mandatory so there is no ambiguity about what a case asserts.
|
|
10
|
+
``test_fixture_pairing`` enforces that every ``.in.yaml`` has a matching
|
|
11
|
+
``.out.yaml`` (and vice versa), and that every case in the tables below has
|
|
12
|
+
both files.
|
|
13
|
+
|
|
14
|
+
The ``list_indent`` column is the value to pass to
|
|
15
|
+
:func:`~yaml_sort.sort_text` (None means leave it default).
|
|
16
|
+
|
|
17
|
+
Reading from files (rather than inline strings) keeps expected output readable
|
|
18
|
+
with no escaping.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
from yaml_sort import sort_text
|
|
24
|
+
|
|
25
|
+
FIXTURES = __import__("pathlib").Path(__file__).parent / "fixtures"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _read(name, suffix):
|
|
29
|
+
return (FIXTURES / f"{name}.{suffix}").read_text()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _call(name, list_indent, **kw):
|
|
33
|
+
kwargs = {"list_indent": list_indent} if list_indent is not None else {}
|
|
34
|
+
kwargs.update(kw)
|
|
35
|
+
return sort_text(_read(name, "in.yaml"), **kwargs)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── all cases: (name, list_indent) ───────────────────────────────────────────
|
|
39
|
+
CASES = [
|
|
40
|
+
# basic key sorting
|
|
41
|
+
("sort_top_level_keys", None),
|
|
42
|
+
("already_sorted", None),
|
|
43
|
+
("stability", None),
|
|
44
|
+
("nested_map_sort", None),
|
|
45
|
+
("deep_nested_sort", None),
|
|
46
|
+
# sequence handling (order preserved)
|
|
47
|
+
("list_order_preserved", None),
|
|
48
|
+
("list_of_scalars", None),
|
|
49
|
+
("nested_list_preserved", None),
|
|
50
|
+
("aligned_list_under_key", None),
|
|
51
|
+
# anchor behaviour
|
|
52
|
+
("anchor_sorts", None),
|
|
53
|
+
("anchor_already_first", None),
|
|
54
|
+
("anchor_no_inline_value", None),
|
|
55
|
+
("keys_inside_list_items", None),
|
|
56
|
+
# comments and gaps
|
|
57
|
+
("comments_preserved", None),
|
|
58
|
+
("blank_lines", None),
|
|
59
|
+
("comment_in_list_item", None),
|
|
60
|
+
("inline_comment", None),
|
|
61
|
+
# trailing footer comments
|
|
62
|
+
("trailing_comment_stays_bottom", None),
|
|
63
|
+
("trailing_comment_nested", None),
|
|
64
|
+
("trailing_comment_after_nested", None),
|
|
65
|
+
# scalars and quoting
|
|
66
|
+
("folded_plain_scalar", None),
|
|
67
|
+
("quoted_scalar_with_colon", None),
|
|
68
|
+
("quoted_empty_scalar", None),
|
|
69
|
+
("comment_only", None),
|
|
70
|
+
("trailing_newline", None),
|
|
71
|
+
("unicode", None),
|
|
72
|
+
("quoted_keys", None),
|
|
73
|
+
("empty_map_value", None),
|
|
74
|
+
("flow_style", None),
|
|
75
|
+
# list-indent modes
|
|
76
|
+
("list_indent_zero", 0),
|
|
77
|
+
("list_indent_two", 2),
|
|
78
|
+
("list_indent_nested", 2),
|
|
79
|
+
("list_indent_valid", 2),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.parametrize("name,list_indent", CASES)
|
|
84
|
+
def test_case(name, list_indent):
|
|
85
|
+
expected = _read(name, "out.yaml")
|
|
86
|
+
assert _call(name, list_indent) == expected
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.parametrize("name,list_indent", CASES)
|
|
90
|
+
def test_case_is_idempotent(name, list_indent):
|
|
91
|
+
# Sorting the already-sorted output must be a fixed point.
|
|
92
|
+
once = _call(name, list_indent)
|
|
93
|
+
kwargs = {"list_indent": list_indent} if list_indent is not None else {}
|
|
94
|
+
assert sort_text(once, **kwargs) == once
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── fixture integrity ─────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def test_fixture_pairing():
|
|
100
|
+
"""Every .in.yaml has a matching .out.yaml and vice versa."""
|
|
101
|
+
ins = sorted(FIXTURES.glob("*.in.yaml"))
|
|
102
|
+
outs = sorted(FIXTURES.glob("*.out.yaml"))
|
|
103
|
+
in_names = {p.name[: -len(".in.yaml")] for p in ins}
|
|
104
|
+
out_names = {p.name[: -len(".out.yaml")] for p in outs}
|
|
105
|
+
assert in_names == out_names, (
|
|
106
|
+
f"mismatched fixtures: only-in={in_names - out_names or None}, "
|
|
107
|
+
f"only-out={out_names - in_names or None}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_every_case_has_fixtures():
|
|
112
|
+
"""Every case in CASES has both files present."""
|
|
113
|
+
for name, _ in CASES:
|
|
114
|
+
assert (FIXTURES / f"{name}.in.yaml").is_file(), f"missing {name}.in.yaml"
|
|
115
|
+
assert (FIXTURES / f"{name}.out.yaml").is_file(), f"missing {name}.out.yaml"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_no_orphan_fixtures():
|
|
119
|
+
"""Every fixture pair is referenced by CASES."""
|
|
120
|
+
paired = {p.name[: -len(".in.yaml")] for p in FIXTURES.glob("*.in.yaml")}
|
|
121
|
+
referenced = {name for name, _ in CASES}
|
|
122
|
+
assert paired == referenced, (
|
|
123
|
+
f"unreferenced fixtures: {paired - referenced or None}, "
|
|
124
|
+
f"referenced-but-missing: {referenced - paired or None}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── properties / standalone ──────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def test_empty_document():
|
|
131
|
+
assert sort_text("") == ""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_list_indent_reindented_output_is_valid_yaml():
|
|
135
|
+
yaml = pytest.importorskip("yaml")
|
|
136
|
+
src = _read("list_indent_valid", "in.yaml")
|
|
137
|
+
a = yaml.safe_load(src)
|
|
138
|
+
b = yaml.safe_load(sort_text(src, list_indent=2))
|
|
139
|
+
assert a == b
|