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.
@@ -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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ """yaml-sort — deterministic YAML key ordering.
2
+
3
+ Public API: :func:`yaml_sort.core.sort_text`.
4
+ """
5
+
6
+ from .core import parse_document, sort_text
7
+
8
+ __all__ = ["parse_document", "sort_text"]
9
+ __version__ = "0.2.1"
@@ -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,2 @@
1
+ [console_scripts]
2
+ yaml-sort = yaml_sort.cli:main
@@ -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