defsort 0.1.0__py3-none-any.whl
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.
- defsort/__init__.py +332 -0
- defsort/__main__.py +8 -0
- defsort/_classify.py +56 -0
- defsort/_cli.py +1186 -0
- defsort/_color.py +86 -0
- defsort/_config.py +576 -0
- defsort/_core/__init__.py +5 -0
- defsort/_core/_analysis.py +1237 -0
- defsort/_core/_api.py +373 -0
- defsort/_core/_banners.py +563 -0
- defsort/_core/_full.py +407 -0
- defsort/_core/_future.py +233 -0
- defsort/_core/_guard.py +151 -0
- defsort/_core/_hoist.py +238 -0
- defsort/_core/_incremental.py +741 -0
- defsort/_core/_layout.py +496 -0
- defsort/_core/_merge.py +123 -0
- defsort/_core/_methods.py +1283 -0
- defsort/_core/_ordering.py +394 -0
- defsort/_core/_parse.py +763 -0
- defsort/_core/_reassemble.py +42 -0
- defsort/_core/_safety.py +411 -0
- defsort/_detector.py +152 -0
- defsort/_display/__init__.py +16 -0
- defsort/_display/_constraints.py +60 -0
- defsort/_display/_detect.py +62 -0
- defsort/_display/_diff.py +105 -0
- defsort/_display/_json.py +138 -0
- defsort/_display/_movement.py +282 -0
- defsort/_display/_skipped.py +130 -0
- defsort/_display/_summary.py +211 -0
- defsort/_display/_terminal.py +24 -0
- defsort/_display/_types.py +166 -0
- defsort/_display/_width.py +34 -0
- defsort/_downstream.py +181 -0
- defsort/_files.py +272 -0
- defsort/_git.py +1021 -0
- defsort/_git_executable.py +51 -0
- defsort/_grammar.py +785 -0
- defsort/_hook.py +1564 -0
- defsort/_hosts/__init__.py +50 -0
- defsort/_shell.py +66 -0
- defsort/_shellparse.py +595 -0
- defsort/_source.py +140 -0
- defsort/_types.py +840 -0
- defsort/hooks/__init__.py +242 -0
- defsort/py.typed +0 -0
- defsort-0.1.0.dist-info/METADATA +150 -0
- defsort-0.1.0.dist-info/RECORD +58 -0
- defsort-0.1.0.dist-info/WHEEL +4 -0
- defsort-0.1.0.dist-info/entry_points.txt +7 -0
- defsort-0.1.0.dist-info/licenses/LICENSE +21 -0
- plugins/agy/_defsort/__init__.py +1 -0
- plugins/agy/_defsort/_adapter.py +121 -0
- plugins/claude/_defsort/__init__.py +1 -0
- plugins/claude/_defsort/_adapter.py +131 -0
- plugins/codex/_defsort/__init__.py +1 -0
- plugins/codex/_defsort/_adapter.py +142 -0
defsort/_cli.py
ADDED
|
@@ -0,0 +1,1186 @@
|
|
|
1
|
+
"""CLI for defsort."""
|
|
2
|
+
|
|
3
|
+
# pyright: reportPrivateUsage=false
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import ast
|
|
7
|
+
import contextlib
|
|
8
|
+
import enum
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import warnings
|
|
12
|
+
from collections.abc import Generator, Sequence
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TextIO
|
|
16
|
+
|
|
17
|
+
from defsort._color import should_color
|
|
18
|
+
from defsort._config import (
|
|
19
|
+
DefsortConfigWarning,
|
|
20
|
+
ResolvedConfig,
|
|
21
|
+
effective_config,
|
|
22
|
+
resolve_config_sourced,
|
|
23
|
+
resolve_runtime_config,
|
|
24
|
+
warn_ineffective_policy,
|
|
25
|
+
)
|
|
26
|
+
from defsort._core import (
|
|
27
|
+
detect_order,
|
|
28
|
+
sort_file,
|
|
29
|
+
sort_file_incremental,
|
|
30
|
+
)
|
|
31
|
+
from defsort._core._api import detect_definition_order
|
|
32
|
+
from defsort._core._full import count_top_level_methods
|
|
33
|
+
from defsort._core._guard import build_guard_params, evaluate_reorder_guard
|
|
34
|
+
from defsort._core._parse import parse_definitions
|
|
35
|
+
from defsort._display import (
|
|
36
|
+
build_json_record,
|
|
37
|
+
build_summary_row,
|
|
38
|
+
format_banner_constraint_details,
|
|
39
|
+
format_banner_constraint_summary,
|
|
40
|
+
format_json,
|
|
41
|
+
format_summary_rows,
|
|
42
|
+
print_detect,
|
|
43
|
+
print_diff,
|
|
44
|
+
print_preview,
|
|
45
|
+
print_skipped,
|
|
46
|
+
)
|
|
47
|
+
from defsort._display._skipped import format_class_blocker
|
|
48
|
+
from defsort._display._terminal import escape_terminal_text
|
|
49
|
+
from defsort._display._types import CliMode, JsonFileRecord, SummaryRow
|
|
50
|
+
from defsort._downstream import closable_stdout, stdout_closed
|
|
51
|
+
from defsort._files import (
|
|
52
|
+
collect_files,
|
|
53
|
+
ensure_utf8_stdio,
|
|
54
|
+
read_file,
|
|
55
|
+
read_file_result,
|
|
56
|
+
write_source,
|
|
57
|
+
)
|
|
58
|
+
from defsort._git import (
|
|
59
|
+
UnsupportedGitVersionError,
|
|
60
|
+
batch_fetch_worktree_baselines,
|
|
61
|
+
require_batch_z_support,
|
|
62
|
+
try_get_repo_root,
|
|
63
|
+
)
|
|
64
|
+
from defsort._types import (
|
|
65
|
+
BANNER_MODE_VALUES,
|
|
66
|
+
EVALUATION_ORDER_VALUES,
|
|
67
|
+
EXIT_CHANGED,
|
|
68
|
+
EXIT_ERROR,
|
|
69
|
+
EXIT_OK,
|
|
70
|
+
MAIN_PLACEMENT_VALUES,
|
|
71
|
+
METHOD_BODIES_VALUES,
|
|
72
|
+
METHOD_DUNDERS_VALUES,
|
|
73
|
+
METHOD_VISIBILITY_VALUES,
|
|
74
|
+
SORT_SCOPE_ORDER_VALUES,
|
|
75
|
+
TIE_BREAK_VALUES,
|
|
76
|
+
BlobReadResult,
|
|
77
|
+
BlobReadStatus,
|
|
78
|
+
ColorMode,
|
|
79
|
+
EngineOptions,
|
|
80
|
+
OrderKind,
|
|
81
|
+
SortOverrides,
|
|
82
|
+
SortResult,
|
|
83
|
+
sort_policy_from_config,
|
|
84
|
+
validate_banner_pattern,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
_BASELINE_UNAVAILABLE = 'incremental: unable to read baseline from git'
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _FileStatus(enum.Enum):
|
|
91
|
+
"""Classify one file's CLI processing outcome."""
|
|
92
|
+
|
|
93
|
+
CHANGED = 'changed'
|
|
94
|
+
ERROR = 'error'
|
|
95
|
+
INVALID = 'invalid'
|
|
96
|
+
SKIPPED = 'skipped'
|
|
97
|
+
UNCHANGED = 'unchanged'
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _add_order_argument(parser: argparse.ArgumentParser) -> None:
|
|
101
|
+
"""Register the shared ordering-policy shorthand on a parser."""
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
'-o',
|
|
104
|
+
'--order',
|
|
105
|
+
choices=SORT_SCOPE_ORDER_VALUES,
|
|
106
|
+
help='Sort order for definitions and methods: dependency, alphabetical, none.',
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _collect_policy_paths(raw_paths: list[str]) -> tuple[list[Path], bool]:
|
|
111
|
+
"""Expand policy paths while retaining empty directories."""
|
|
112
|
+
paths: list[Path] = []
|
|
113
|
+
seen: set[Path] = set()
|
|
114
|
+
error_seen = False
|
|
115
|
+
for raw in raw_paths:
|
|
116
|
+
path = Path(raw)
|
|
117
|
+
try:
|
|
118
|
+
path.stat()
|
|
119
|
+
if path.is_dir():
|
|
120
|
+
with os.scandir(path):
|
|
121
|
+
pass
|
|
122
|
+
candidates = collect_files([raw])
|
|
123
|
+
else:
|
|
124
|
+
with path.open('rb'):
|
|
125
|
+
pass
|
|
126
|
+
candidates = [path]
|
|
127
|
+
except OSError as exc:
|
|
128
|
+
safe_path = escape_terminal_text(str(path))
|
|
129
|
+
safe_detail = escape_terminal_text(str(exc))
|
|
130
|
+
print(f'error: {safe_path}: {safe_detail}', file=sys.stderr)
|
|
131
|
+
error_seen = True
|
|
132
|
+
continue
|
|
133
|
+
if not candidates:
|
|
134
|
+
candidates = [path]
|
|
135
|
+
for candidate in candidates:
|
|
136
|
+
resolved = candidate.resolve()
|
|
137
|
+
if resolved not in seen:
|
|
138
|
+
seen.add(resolved)
|
|
139
|
+
paths.append(candidate)
|
|
140
|
+
return paths, error_seen
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _compiled_regex_pattern(value: str) -> str:
|
|
144
|
+
"""Validate a regular expression while preserving its source string."""
|
|
145
|
+
try:
|
|
146
|
+
validate_banner_pattern(value)
|
|
147
|
+
except ValueError as exc:
|
|
148
|
+
raise argparse.ArgumentTypeError(str(exc)) from exc
|
|
149
|
+
return value
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _add_policy_arguments(
|
|
153
|
+
parser: argparse.ArgumentParser,
|
|
154
|
+
*,
|
|
155
|
+
group_title: str | None = None,
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Register detailed ordering-policy arguments on a parser."""
|
|
158
|
+
arguments = parser if group_title is None else parser.add_argument_group(group_title)
|
|
159
|
+
arguments.add_argument(
|
|
160
|
+
'--definition-order',
|
|
161
|
+
choices=SORT_SCOPE_ORDER_VALUES,
|
|
162
|
+
help='Sort order for top-level definitions: dependency, alphabetical, none.',
|
|
163
|
+
)
|
|
164
|
+
arguments.add_argument(
|
|
165
|
+
'--method-order',
|
|
166
|
+
choices=SORT_SCOPE_ORDER_VALUES,
|
|
167
|
+
help='Sort order for class methods: dependency, alphabetical, none.',
|
|
168
|
+
)
|
|
169
|
+
arguments.add_argument(
|
|
170
|
+
'--evaluation-order',
|
|
171
|
+
choices=EVALUATION_ORDER_VALUES,
|
|
172
|
+
help='Preserve definition-time evaluation order or allow reordering.',
|
|
173
|
+
)
|
|
174
|
+
arguments.add_argument(
|
|
175
|
+
'--method-bodies',
|
|
176
|
+
choices=METHOD_BODIES_VALUES,
|
|
177
|
+
help=(
|
|
178
|
+
'Let method-body dependencies stay within categories, be ignored, or cross categories.'
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
arguments.add_argument(
|
|
182
|
+
'--method-visibility',
|
|
183
|
+
choices=METHOD_VISIBILITY_VALUES,
|
|
184
|
+
help='Keep regular methods mixed or place public methods before private methods.',
|
|
185
|
+
)
|
|
186
|
+
arguments.add_argument(
|
|
187
|
+
'--method-dunders',
|
|
188
|
+
choices=METHOD_DUNDERS_VALUES,
|
|
189
|
+
help='Place non-lifecycle dunders before or after regular methods.',
|
|
190
|
+
)
|
|
191
|
+
arguments.add_argument(
|
|
192
|
+
'--main',
|
|
193
|
+
choices=MAIN_PLACEMENT_VALUES,
|
|
194
|
+
help='Anchor main() last or sort it normally.',
|
|
195
|
+
)
|
|
196
|
+
arguments.add_argument(
|
|
197
|
+
'--tie-break',
|
|
198
|
+
choices=TIE_BREAK_VALUES,
|
|
199
|
+
help='Break dependency ties alphabetically or by source order.',
|
|
200
|
+
)
|
|
201
|
+
arguments.add_argument(
|
|
202
|
+
'--banners',
|
|
203
|
+
choices=BANNER_MODE_VALUES,
|
|
204
|
+
help='Treat section banners as barriers (default), ignore them, or strip them.',
|
|
205
|
+
)
|
|
206
|
+
banner_pattern_group = arguments.add_mutually_exclusive_group()
|
|
207
|
+
banner_pattern_group.add_argument(
|
|
208
|
+
'--banner-pattern',
|
|
209
|
+
type=_compiled_regex_pattern,
|
|
210
|
+
help=('Add a safe full-match pattern to built-in banner recognition; overrides config.'),
|
|
211
|
+
)
|
|
212
|
+
banner_pattern_group.add_argument(
|
|
213
|
+
'--no-banner-pattern',
|
|
214
|
+
action='store_true',
|
|
215
|
+
dest='clear_banner_pattern',
|
|
216
|
+
help='Clear the configured custom banner pattern.',
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _build_hook_parser() -> argparse.ArgumentParser:
|
|
221
|
+
"""Build the argument parser for the hook subcommand."""
|
|
222
|
+
parser = argparse.ArgumentParser(
|
|
223
|
+
prog='defsort hook',
|
|
224
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
225
|
+
description="Agent hook entry point; reads the coding agent's payload as JSON from stdin.",
|
|
226
|
+
allow_abbrev=False,
|
|
227
|
+
)
|
|
228
|
+
parser.add_argument('-a', '--agent', required=True, metavar='NAME')
|
|
229
|
+
parser.add_argument('-e', '--event', metavar='NAME')
|
|
230
|
+
_add_order_argument(parser)
|
|
231
|
+
_add_policy_arguments(parser)
|
|
232
|
+
parser.add_argument(
|
|
233
|
+
'--allow-on-failure',
|
|
234
|
+
action='store_true',
|
|
235
|
+
help=(
|
|
236
|
+
'Warn and allow infrastructure failures after a command is accepted; '
|
|
237
|
+
'safety blockers still deny.'
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
parser.add_argument(
|
|
241
|
+
'--incremental-fallback',
|
|
242
|
+
choices=['warn', 'full-sort'],
|
|
243
|
+
help='Fallback behavior when incremental sort is blocked.',
|
|
244
|
+
)
|
|
245
|
+
group = parser.add_mutually_exclusive_group()
|
|
246
|
+
group.add_argument('-i', '--incremental', action='store_true')
|
|
247
|
+
group.add_argument(
|
|
248
|
+
'--no-incremental',
|
|
249
|
+
action='store_true',
|
|
250
|
+
help='Disable incremental mode, overriding configured incremental mode.',
|
|
251
|
+
)
|
|
252
|
+
return parser
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
256
|
+
"""Build the argument parser."""
|
|
257
|
+
parser = argparse.ArgumentParser(
|
|
258
|
+
prog='defsort',
|
|
259
|
+
description='Sort Python definitions.',
|
|
260
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
261
|
+
epilog=('subcommands:\n hook Agent hook entry point (see defsort hook --help)'),
|
|
262
|
+
allow_abbrev=False,
|
|
263
|
+
)
|
|
264
|
+
parser.add_argument('paths', nargs='*', default=['.'], help='Python files or directories.')
|
|
265
|
+
parser.add_argument('--version', action='store_true', help='Show the installed version.')
|
|
266
|
+
mode_group = parser.add_mutually_exclusive_group()
|
|
267
|
+
mode_group.add_argument('-p', '--preview', action='store_true', help='Show movement tables.')
|
|
268
|
+
mode_group.add_argument('-d', '--diff', action='store_true', help='Show unified diff.')
|
|
269
|
+
mode_group.add_argument('-j', '--json', action='store_true', help='Output to JSON.')
|
|
270
|
+
mode_group.add_argument('-w', '--write', action='store_true', help='Modify files in place.')
|
|
271
|
+
_add_order_argument(parser)
|
|
272
|
+
incremental_group = parser.add_mutually_exclusive_group()
|
|
273
|
+
incremental_group.add_argument(
|
|
274
|
+
'-i',
|
|
275
|
+
'--incremental',
|
|
276
|
+
action='store_true',
|
|
277
|
+
help='Only sort new definitions (requires git).',
|
|
278
|
+
)
|
|
279
|
+
parser.add_argument(
|
|
280
|
+
'-f',
|
|
281
|
+
'--force',
|
|
282
|
+
action='store_true',
|
|
283
|
+
help='Bypass interstitial and reorder-guard blockers.',
|
|
284
|
+
)
|
|
285
|
+
parser.add_argument('-v', '--verbose', action='store_true', help='Include unchanged files.')
|
|
286
|
+
parser.add_argument(
|
|
287
|
+
'--color',
|
|
288
|
+
choices=['auto', 'always', 'never'],
|
|
289
|
+
help='Color output: auto (default), always, never.',
|
|
290
|
+
)
|
|
291
|
+
incremental_group.add_argument(
|
|
292
|
+
'--no-incremental',
|
|
293
|
+
action='store_true',
|
|
294
|
+
default=False,
|
|
295
|
+
help='Disable incremental mode (overrides config).',
|
|
296
|
+
)
|
|
297
|
+
skip_group = parser.add_mutually_exclusive_group()
|
|
298
|
+
skip_group.add_argument(
|
|
299
|
+
'--show-skips',
|
|
300
|
+
action='store_true',
|
|
301
|
+
default=None,
|
|
302
|
+
dest='show_skips',
|
|
303
|
+
help='Show skipped-file messages in compact modes.',
|
|
304
|
+
)
|
|
305
|
+
skip_group.add_argument(
|
|
306
|
+
'--hide-skips',
|
|
307
|
+
action='store_false',
|
|
308
|
+
dest='show_skips',
|
|
309
|
+
help='Hide skipped-file messages in compact modes.',
|
|
310
|
+
)
|
|
311
|
+
mode_group.add_argument(
|
|
312
|
+
'--show-policy',
|
|
313
|
+
action='store_true',
|
|
314
|
+
help='Show the resolved ordering policy.',
|
|
315
|
+
)
|
|
316
|
+
parser.add_argument('--detect', action='store_true', help='Detect definition order.')
|
|
317
|
+
parser.add_argument('--hoist', action='store_true', help='Hoist constants to preamble.')
|
|
318
|
+
parser.add_argument(
|
|
319
|
+
'--no-future', action='store_true', help="Don't add future annotations import."
|
|
320
|
+
)
|
|
321
|
+
_add_policy_arguments(parser, group_title='ordering policy options')
|
|
322
|
+
return parser
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _format_policy_block(
|
|
326
|
+
cfg: ResolvedConfig,
|
|
327
|
+
source: Path | None,
|
|
328
|
+
*,
|
|
329
|
+
has_overrides: bool,
|
|
330
|
+
) -> str:
|
|
331
|
+
"""Format one resolved policy and its provenance."""
|
|
332
|
+
policy = sort_policy_from_config(cfg.transform)
|
|
333
|
+
header = (
|
|
334
|
+
'built-in defaults'
|
|
335
|
+
if source is None
|
|
336
|
+
else f'policy from {escape_terminal_text(str(source))}'
|
|
337
|
+
)
|
|
338
|
+
if has_overrides:
|
|
339
|
+
header += ' (with CLI overrides)'
|
|
340
|
+
if policy.method_dunders == 'before-regular':
|
|
341
|
+
categories = '__new__, __init__, dunders, regular'
|
|
342
|
+
else:
|
|
343
|
+
categories = '__new__, __init__, regular, dunders'
|
|
344
|
+
body_dependencies = {
|
|
345
|
+
'across-categories': 'body dependencies across categories',
|
|
346
|
+
'ignore': 'body dependencies ignored',
|
|
347
|
+
'within-categories': 'body dependencies within categories',
|
|
348
|
+
}[policy.method_bodies]
|
|
349
|
+
return (
|
|
350
|
+
f'{header}:\n'
|
|
351
|
+
f' definitions: {policy.definition_order}; main {policy.main}; '
|
|
352
|
+
f'ties {policy.tie_break}\n'
|
|
353
|
+
f' methods: {policy.method_order}; categories {categories} '
|
|
354
|
+
f'({policy.method_visibility});\n'
|
|
355
|
+
f' {body_dependencies}; ties {policy.tie_break}\n'
|
|
356
|
+
f' evaluation order: {policy.evaluation_order}\n'
|
|
357
|
+
f' banners: {policy.banners}'
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _guarded_skip(result: SortResult, warning: str) -> SortResult:
|
|
362
|
+
"""Clone a SortResult as a guarded skip (new_source reset to original)."""
|
|
363
|
+
return SortResult(
|
|
364
|
+
original_source=result.original_source,
|
|
365
|
+
new_source=result.original_source,
|
|
366
|
+
original_order=result.original_order,
|
|
367
|
+
new_order=result.new_order,
|
|
368
|
+
warning=warning,
|
|
369
|
+
blocking_code=[],
|
|
370
|
+
guarded=True,
|
|
371
|
+
skipped=True,
|
|
372
|
+
method_sorts=result.method_sorts,
|
|
373
|
+
total_methods=result.total_methods,
|
|
374
|
+
banner_constraints=list(result.banner_constraints),
|
|
375
|
+
class_blockers=result.class_blockers,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _overrides_from_args(args: argparse.Namespace) -> SortOverrides:
|
|
380
|
+
"""Build configuration overrides from parsed CLI arguments."""
|
|
381
|
+
incremental: bool | None = None
|
|
382
|
+
if args.no_incremental:
|
|
383
|
+
incremental = False
|
|
384
|
+
elif args.incremental:
|
|
385
|
+
incremental = True
|
|
386
|
+
return SortOverrides(
|
|
387
|
+
banners=args.banners,
|
|
388
|
+
banner_pattern=args.banner_pattern,
|
|
389
|
+
clear_banner_pattern=args.clear_banner_pattern,
|
|
390
|
+
definition_order=args.definition_order,
|
|
391
|
+
evaluation_order=args.evaluation_order,
|
|
392
|
+
incremental=incremental,
|
|
393
|
+
incremental_fallback=getattr(args, 'incremental_fallback', None),
|
|
394
|
+
main=args.main,
|
|
395
|
+
method_bodies=args.method_bodies,
|
|
396
|
+
method_dunders=args.method_dunders,
|
|
397
|
+
method_order=args.method_order,
|
|
398
|
+
method_visibility=args.method_visibility,
|
|
399
|
+
order=args.order,
|
|
400
|
+
show_skips=getattr(args, 'show_skips', None),
|
|
401
|
+
tie_break=args.tie_break,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _print_version() -> int:
|
|
406
|
+
"""Print the installed distribution version without other work."""
|
|
407
|
+
import importlib.metadata # noqa: PLC0415
|
|
408
|
+
|
|
409
|
+
try:
|
|
410
|
+
version = importlib.metadata.version('defsort')
|
|
411
|
+
except importlib.metadata.PackageNotFoundError:
|
|
412
|
+
print('error: installed distribution metadata for defsort is unavailable', file=sys.stderr)
|
|
413
|
+
return EXIT_ERROR
|
|
414
|
+
print(f'defsort {version}')
|
|
415
|
+
return EXIT_OK
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@contextlib.contextmanager
|
|
419
|
+
def _render_safe_config_warnings() -> Generator[None]:
|
|
420
|
+
"""Render configuration warnings with terminal-safe message text."""
|
|
421
|
+
with warnings.catch_warnings():
|
|
422
|
+
previous_showwarning = warnings.showwarning
|
|
423
|
+
|
|
424
|
+
def dispatch_warning(
|
|
425
|
+
message: Warning | str,
|
|
426
|
+
category: type[Warning],
|
|
427
|
+
location: tuple[str, int, TextIO | None, str | None],
|
|
428
|
+
) -> None:
|
|
429
|
+
filename, lineno, file, line = location
|
|
430
|
+
if issubclass(category, DefsortConfigWarning):
|
|
431
|
+
detail = escape_terminal_text(str(message))
|
|
432
|
+
print(f'Warning: {detail}', file=sys.stderr)
|
|
433
|
+
return
|
|
434
|
+
previous_showwarning(message, category, filename, lineno, file=file, line=line)
|
|
435
|
+
|
|
436
|
+
warnings.showwarning = lambda message, category, filename, lineno, file=None, line=None: (
|
|
437
|
+
dispatch_warning(message, category, (filename, lineno, file, line))
|
|
438
|
+
)
|
|
439
|
+
warnings.simplefilter('always', DefsortConfigWarning)
|
|
440
|
+
yield
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@dataclass(frozen=True, slots=True)
|
|
444
|
+
class _FileSortResult:
|
|
445
|
+
"""Carry one file's CLI sorting outcome."""
|
|
446
|
+
|
|
447
|
+
status: _FileStatus
|
|
448
|
+
sort_result: SortResult | None = None
|
|
449
|
+
error_message: str | None = None
|
|
450
|
+
encoding: str | None = None
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@dataclass(frozen=True, slots=True)
|
|
454
|
+
class _ResolvedConfig:
|
|
455
|
+
"""Carry effective CLI settings for one file."""
|
|
456
|
+
|
|
457
|
+
options: EngineOptions
|
|
458
|
+
incremental: bool
|
|
459
|
+
max_reorder_count: int | None
|
|
460
|
+
max_reorder_percent: int | None
|
|
461
|
+
show_skips: bool
|
|
462
|
+
color: ColorMode | None = None
|
|
463
|
+
|
|
464
|
+
@property
|
|
465
|
+
def _guard_enabled(self) -> bool:
|
|
466
|
+
"""Return whether at least one reorder guard threshold is configured."""
|
|
467
|
+
return self.max_reorder_count is not None or self.max_reorder_percent is not None
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@dataclass(frozen=True, slots=True)
|
|
471
|
+
class _ConfigResolver:
|
|
472
|
+
"""Resolves per-file configuration with CLI overrides applied."""
|
|
473
|
+
|
|
474
|
+
overrides: SortOverrides
|
|
475
|
+
force: bool
|
|
476
|
+
hoist: bool
|
|
477
|
+
add_future: bool
|
|
478
|
+
warned_configs: set[ResolvedConfig] = field(
|
|
479
|
+
default_factory=set[ResolvedConfig],
|
|
480
|
+
compare=False,
|
|
481
|
+
repr=False,
|
|
482
|
+
)
|
|
483
|
+
_cache: dict[Path, _ResolvedConfig] = field(
|
|
484
|
+
default_factory=dict[Path, _ResolvedConfig],
|
|
485
|
+
compare=False,
|
|
486
|
+
repr=False,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
def _for_path(self, path: Path) -> _ResolvedConfig:
|
|
490
|
+
"""Resolve effective configuration for one file."""
|
|
491
|
+
cached = self._cache.get(path)
|
|
492
|
+
if cached is not None:
|
|
493
|
+
return cached
|
|
494
|
+
with _render_safe_config_warnings():
|
|
495
|
+
cfg = effective_config(resolve_runtime_config(path), self.overrides)
|
|
496
|
+
if cfg not in self.warned_configs:
|
|
497
|
+
warn_ineffective_policy(cfg.transform)
|
|
498
|
+
self.warned_configs.add(cfg)
|
|
499
|
+
resolved = _ResolvedConfig(
|
|
500
|
+
options=EngineOptions(
|
|
501
|
+
policy=sort_policy_from_config(cfg.transform),
|
|
502
|
+
force=self.force,
|
|
503
|
+
hoist=self.hoist,
|
|
504
|
+
add_future=self.add_future,
|
|
505
|
+
),
|
|
506
|
+
incremental=cfg.incremental if cfg.incremental is not None else False,
|
|
507
|
+
max_reorder_count=cfg.max_reorder_count,
|
|
508
|
+
max_reorder_percent=cfg.max_reorder_percent,
|
|
509
|
+
show_skips=cfg.show_skips,
|
|
510
|
+
color=cfg.color,
|
|
511
|
+
)
|
|
512
|
+
self._cache[path] = resolved
|
|
513
|
+
return resolved
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _apply_reorder_guard( # noqa: PLR0911
|
|
517
|
+
result: SortResult,
|
|
518
|
+
baseline: BlobReadResult | None,
|
|
519
|
+
*,
|
|
520
|
+
config: _ResolvedConfig,
|
|
521
|
+
) -> SortResult:
|
|
522
|
+
"""Apply the large-reorder guard, returning a guarded SortResult if triggered."""
|
|
523
|
+
if result.skipped:
|
|
524
|
+
return result
|
|
525
|
+
if not config._guard_enabled:
|
|
526
|
+
return result
|
|
527
|
+
if result.original_source == result.new_source:
|
|
528
|
+
return result
|
|
529
|
+
|
|
530
|
+
if baseline is None or baseline.status is BlobReadStatus.MISSING:
|
|
531
|
+
return result
|
|
532
|
+
|
|
533
|
+
if baseline.status in (BlobReadStatus.INFRA_ERROR, BlobReadStatus.DECODE_FAILED):
|
|
534
|
+
return _guarded_skip(result, 'reorder guard: unable to read baseline from git')
|
|
535
|
+
|
|
536
|
+
# CONTENT: parse baseline into definitions for the guard
|
|
537
|
+
assert baseline.content is not None # noqa: S101
|
|
538
|
+
params = build_guard_params(
|
|
539
|
+
baseline.content,
|
|
540
|
+
max_reorder_count=config.max_reorder_count,
|
|
541
|
+
max_reorder_percent=config.max_reorder_percent,
|
|
542
|
+
)
|
|
543
|
+
if params is None:
|
|
544
|
+
return result # unparseable baseline -> skip guard
|
|
545
|
+
guard_result = evaluate_reorder_guard(params, result.new_order)
|
|
546
|
+
if not guard_result.triggered:
|
|
547
|
+
return result
|
|
548
|
+
|
|
549
|
+
return _guarded_skip(result, guard_result.message)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _file_result_to_json(
|
|
553
|
+
path: Path,
|
|
554
|
+
file_result: _FileSortResult,
|
|
555
|
+
options: EngineOptions,
|
|
556
|
+
) -> JsonFileRecord:
|
|
557
|
+
"""Map a _FileSortResult to the dict format expected by format_json()."""
|
|
558
|
+
status_value: str = file_result.status.value
|
|
559
|
+
|
|
560
|
+
if file_result.status in (_FileStatus.ERROR, _FileStatus.INVALID):
|
|
561
|
+
return build_json_record(
|
|
562
|
+
str(path), file_result.status.value, warning=file_result.error_message
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
result = file_result.sort_result
|
|
566
|
+
detect_result: tuple[OrderKind, float, float] | None = None
|
|
567
|
+
if result is not None:
|
|
568
|
+
detect_result = detect_definition_order(
|
|
569
|
+
result.original_order,
|
|
570
|
+
banners=options.policy.banners,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
warning: str | None = None
|
|
574
|
+
if file_result.status is _FileStatus.SKIPPED:
|
|
575
|
+
warning = result.warning or 'safety check' if result is not None else 'safety check'
|
|
576
|
+
elif file_result.status is _FileStatus.CHANGED:
|
|
577
|
+
warning = result.warning if result is not None else None
|
|
578
|
+
|
|
579
|
+
return build_json_record(
|
|
580
|
+
str(path),
|
|
581
|
+
status_value,
|
|
582
|
+
warning=warning,
|
|
583
|
+
result=result,
|
|
584
|
+
detect_result=detect_result,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _repo_root_covers_parent(parent: Path, root: Path) -> bool:
|
|
589
|
+
"""Return whether no nested or uncertain Git marker blocks root reuse."""
|
|
590
|
+
directory = parent
|
|
591
|
+
while directory != root:
|
|
592
|
+
try:
|
|
593
|
+
(directory / '.git').lstat()
|
|
594
|
+
except FileNotFoundError:
|
|
595
|
+
directory = directory.parent
|
|
596
|
+
except OSError:
|
|
597
|
+
return False
|
|
598
|
+
else:
|
|
599
|
+
return False
|
|
600
|
+
return True
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _discover_repo_roots(paths: list[Path]) -> dict[Path, Path | None]:
|
|
604
|
+
"""Map each resolved path to its git repository root, if any."""
|
|
605
|
+
repo_roots: dict[Path, Path | None] = {}
|
|
606
|
+
root_cache: dict[Path, Path | None] = {}
|
|
607
|
+
successful_roots: set[Path] = set()
|
|
608
|
+
|
|
609
|
+
for path in paths:
|
|
610
|
+
resolved = path.resolve()
|
|
611
|
+
parent = resolved.parent
|
|
612
|
+
if parent not in root_cache:
|
|
613
|
+
reusable_root = max(
|
|
614
|
+
(root for root in successful_roots if parent.is_relative_to(root)),
|
|
615
|
+
key=lambda root: len(root.parts),
|
|
616
|
+
default=None,
|
|
617
|
+
)
|
|
618
|
+
if reusable_root is None or not _repo_root_covers_parent(parent, reusable_root):
|
|
619
|
+
raw_root = try_get_repo_root(str(parent))
|
|
620
|
+
discovered_root = Path(raw_root) if raw_root is not None else None
|
|
621
|
+
root_cache[parent] = discovered_root
|
|
622
|
+
if discovered_root is not None:
|
|
623
|
+
successful_roots.add(discovered_root)
|
|
624
|
+
else:
|
|
625
|
+
root_cache[parent] = reusable_root
|
|
626
|
+
repo_roots[resolved] = root_cache[parent]
|
|
627
|
+
|
|
628
|
+
return repo_roots
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _batch_fetch_cli_baselines(
|
|
632
|
+
paths: list[Path],
|
|
633
|
+
) -> dict[Path, BlobReadResult]:
|
|
634
|
+
"""Batch-read baseline content for CLI incremental mode."""
|
|
635
|
+
repo_roots = _discover_repo_roots(paths)
|
|
636
|
+
results: dict[Path, BlobReadResult] = {}
|
|
637
|
+
by_root: dict[Path, list[tuple[Path, str]]] = {}
|
|
638
|
+
|
|
639
|
+
for path in paths:
|
|
640
|
+
resolved = path.resolve()
|
|
641
|
+
root = repo_roots.get(resolved)
|
|
642
|
+
if root is None:
|
|
643
|
+
results[path] = BlobReadResult(status=BlobReadStatus.INFRA_ERROR)
|
|
644
|
+
continue
|
|
645
|
+
try:
|
|
646
|
+
rel = resolved.relative_to(root).as_posix()
|
|
647
|
+
except ValueError:
|
|
648
|
+
results[path] = BlobReadResult(status=BlobReadStatus.INFRA_ERROR)
|
|
649
|
+
continue
|
|
650
|
+
pairs = by_root.setdefault(root, [])
|
|
651
|
+
try:
|
|
652
|
+
rel.encode('utf-8')
|
|
653
|
+
except UnicodeEncodeError:
|
|
654
|
+
results[path] = BlobReadResult(status=BlobReadStatus.INFRA_ERROR)
|
|
655
|
+
continue
|
|
656
|
+
pairs.append((path, rel))
|
|
657
|
+
|
|
658
|
+
for root, pairs in by_root.items():
|
|
659
|
+
if not pairs:
|
|
660
|
+
require_batch_z_support(str(root))
|
|
661
|
+
continue
|
|
662
|
+
rels = [rel for _, rel in pairs]
|
|
663
|
+
bl_result = batch_fetch_worktree_baselines(str(root), rels)
|
|
664
|
+
if bl_result is None:
|
|
665
|
+
for orig, _ in pairs:
|
|
666
|
+
results[orig] = BlobReadResult(status=BlobReadStatus.INFRA_ERROR)
|
|
667
|
+
continue
|
|
668
|
+
for orig, rel in pairs:
|
|
669
|
+
results[orig] = bl_result[rel]
|
|
670
|
+
return results
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _run_detect(
|
|
674
|
+
paths: list[Path],
|
|
675
|
+
*,
|
|
676
|
+
resolver: _ConfigResolver,
|
|
677
|
+
verbose: bool = False,
|
|
678
|
+
) -> int:
|
|
679
|
+
"""Run --detect mode: classify file ordering."""
|
|
680
|
+
counts: dict[OrderKind, int] = {}
|
|
681
|
+
total = 0
|
|
682
|
+
entries: list[tuple[str, OrderKind | None, float, float]] = []
|
|
683
|
+
error_seen = False
|
|
684
|
+
|
|
685
|
+
for path in sorted(paths):
|
|
686
|
+
options = resolver._for_path(path).options
|
|
687
|
+
try:
|
|
688
|
+
source, _enc = read_file(path)
|
|
689
|
+
except (OSError, ValueError, SyntaxError, UnicodeDecodeError) as e:
|
|
690
|
+
safe_path = escape_terminal_text(str(path))
|
|
691
|
+
safe_detail = escape_terminal_text(str(e))
|
|
692
|
+
print(f'error: {safe_path}: {safe_detail}', file=sys.stderr)
|
|
693
|
+
error_seen = True
|
|
694
|
+
continue
|
|
695
|
+
try:
|
|
696
|
+
tree = ast.parse(source)
|
|
697
|
+
compile(tree, str(path), 'exec')
|
|
698
|
+
except SyntaxError as e:
|
|
699
|
+
safe_path = escape_terminal_text(str(path))
|
|
700
|
+
safe_detail = escape_terminal_text(str(e))
|
|
701
|
+
print(f'error: {safe_path}: {safe_detail}', file=sys.stderr)
|
|
702
|
+
error_seen = True
|
|
703
|
+
continue
|
|
704
|
+
result = detect_order(
|
|
705
|
+
source,
|
|
706
|
+
banners=options.policy.banners,
|
|
707
|
+
banner_pattern=options.policy.banner_pattern,
|
|
708
|
+
_tree=tree,
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
if result is None:
|
|
712
|
+
entries.append((str(path), None, 0.0, 0.0))
|
|
713
|
+
continue
|
|
714
|
+
|
|
715
|
+
total += 1
|
|
716
|
+
order, dep_score, alpha_score = result
|
|
717
|
+
counts[order] = counts.get(order, 0) + 1
|
|
718
|
+
entries.append((str(path), order, dep_score, alpha_score))
|
|
719
|
+
|
|
720
|
+
print_detect(entries, (counts, total), verbose=verbose)
|
|
721
|
+
|
|
722
|
+
if error_seen:
|
|
723
|
+
return EXIT_ERROR
|
|
724
|
+
return EXIT_OK
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _run_hook(argv: list[str]) -> int:
|
|
728
|
+
"""Handle the hook subcommand."""
|
|
729
|
+
hook_parser = _build_hook_parser()
|
|
730
|
+
args = hook_parser.parse_args(argv)
|
|
731
|
+
|
|
732
|
+
if sys.stdin.isatty():
|
|
733
|
+
print(
|
|
734
|
+
"defsort hook: reads the coding agent's hook payload as JSON from stdin",
|
|
735
|
+
file=sys.stderr,
|
|
736
|
+
)
|
|
737
|
+
print('See docs/hook.md for configuration.', file=sys.stderr)
|
|
738
|
+
return EXIT_OK
|
|
739
|
+
|
|
740
|
+
try:
|
|
741
|
+
stdin_data = sys.stdin.read()
|
|
742
|
+
except (ValueError, OSError):
|
|
743
|
+
return EXIT_OK
|
|
744
|
+
|
|
745
|
+
from defsort._hook import run_hook # noqa: PLC0415
|
|
746
|
+
|
|
747
|
+
return run_hook(
|
|
748
|
+
stdin_data,
|
|
749
|
+
host=args.agent,
|
|
750
|
+
event=args.event,
|
|
751
|
+
overrides=_overrides_from_args(args),
|
|
752
|
+
allow_on_failure=args.allow_on_failure,
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _run_show_policy(raw_paths: list[str], overrides: SortOverrides) -> int:
|
|
757
|
+
"""Print resolved policies grouped by configuration source."""
|
|
758
|
+
paths, error_seen = _collect_policy_paths(raw_paths)
|
|
759
|
+
policies: dict[Path | None, ResolvedConfig] = {}
|
|
760
|
+
for path in paths:
|
|
761
|
+
with _render_safe_config_warnings():
|
|
762
|
+
cfg, source = resolve_config_sourced(path)
|
|
763
|
+
policies.setdefault(source, effective_config(cfg, overrides))
|
|
764
|
+
blocks = [
|
|
765
|
+
_format_policy_block(
|
|
766
|
+
cfg,
|
|
767
|
+
source,
|
|
768
|
+
has_overrides=overrides != SortOverrides(),
|
|
769
|
+
)
|
|
770
|
+
for source, cfg in policies.items()
|
|
771
|
+
]
|
|
772
|
+
if blocks:
|
|
773
|
+
print('\n\n'.join(blocks))
|
|
774
|
+
return EXIT_ERROR if error_seen else EXIT_OK
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _run_sort(
|
|
778
|
+
source: str,
|
|
779
|
+
config: _ResolvedConfig,
|
|
780
|
+
baseline_raw: BlobReadResult | None,
|
|
781
|
+
*,
|
|
782
|
+
tree: ast.Module,
|
|
783
|
+
) -> SortResult:
|
|
784
|
+
"""Run a sort against a pre-fetched baseline."""
|
|
785
|
+
if not config.incremental or baseline_raw is None:
|
|
786
|
+
return sort_file(source, options=config.options, _tree=tree)
|
|
787
|
+
match baseline_raw.status:
|
|
788
|
+
case BlobReadStatus.MISSING:
|
|
789
|
+
return sort_file(source, options=config.options, _tree=tree)
|
|
790
|
+
case BlobReadStatus.CONTENT:
|
|
791
|
+
assert baseline_raw.content is not None # noqa: S101
|
|
792
|
+
return sort_file_incremental(
|
|
793
|
+
source,
|
|
794
|
+
baseline_raw.content,
|
|
795
|
+
options=config.options,
|
|
796
|
+
_tree=tree,
|
|
797
|
+
)
|
|
798
|
+
case BlobReadStatus.DECODE_FAILED | BlobReadStatus.INFRA_ERROR:
|
|
799
|
+
raise AssertionError(baseline_raw.status)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _sort_one_file( # noqa: PLR0911
|
|
803
|
+
path: Path,
|
|
804
|
+
config: _ResolvedConfig,
|
|
805
|
+
baseline: BlobReadResult | None,
|
|
806
|
+
) -> _FileSortResult:
|
|
807
|
+
"""Read, sort, and guard-check a single file."""
|
|
808
|
+
try:
|
|
809
|
+
file_result = read_file_result(path)
|
|
810
|
+
except (OSError, UnicodeDecodeError, SyntaxError, ValueError) as exc:
|
|
811
|
+
return _FileSortResult(status=_FileStatus.ERROR, error_message=str(exc))
|
|
812
|
+
|
|
813
|
+
source, encoding = file_result.source, file_result.encoding
|
|
814
|
+
|
|
815
|
+
try:
|
|
816
|
+
tree = ast.parse(source)
|
|
817
|
+
compile(tree, str(path), 'exec')
|
|
818
|
+
except (SyntaxError, ValueError) as exc:
|
|
819
|
+
return _FileSortResult(
|
|
820
|
+
status=_FileStatus.INVALID,
|
|
821
|
+
error_message=str(exc),
|
|
822
|
+
encoding=encoding,
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
# Check baseline availability for incremental mode (after parse so
|
|
826
|
+
# syntax errors take precedence, matching pre-refactor behavior).
|
|
827
|
+
if (
|
|
828
|
+
config.incremental
|
|
829
|
+
and baseline is not None
|
|
830
|
+
and baseline.status in (BlobReadStatus.INFRA_ERROR, BlobReadStatus.DECODE_FAILED)
|
|
831
|
+
):
|
|
832
|
+
parsed = parse_definitions(
|
|
833
|
+
source,
|
|
834
|
+
banners=config.options.policy.banners,
|
|
835
|
+
banner_pattern=config.options.policy.banner_pattern,
|
|
836
|
+
tree=tree,
|
|
837
|
+
)
|
|
838
|
+
return _FileSortResult(
|
|
839
|
+
status=_FileStatus.SKIPPED,
|
|
840
|
+
sort_result=SortResult(
|
|
841
|
+
original_source=source,
|
|
842
|
+
new_source=source,
|
|
843
|
+
original_order=parsed.definitions,
|
|
844
|
+
new_order=parsed.definitions,
|
|
845
|
+
warning=_BASELINE_UNAVAILABLE,
|
|
846
|
+
blocking_code=[],
|
|
847
|
+
skipped=True,
|
|
848
|
+
total_methods=count_top_level_methods(tree),
|
|
849
|
+
),
|
|
850
|
+
encoding=encoding,
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
try:
|
|
854
|
+
result = _run_sort(source, config, baseline, tree=tree)
|
|
855
|
+
except Exception as exc: # noqa: BLE001
|
|
856
|
+
return _FileSortResult(
|
|
857
|
+
status=_FileStatus.ERROR,
|
|
858
|
+
error_message=str(exc),
|
|
859
|
+
encoding=encoding,
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
if result.skipped:
|
|
863
|
+
return _FileSortResult(
|
|
864
|
+
status=_FileStatus.SKIPPED,
|
|
865
|
+
sort_result=result,
|
|
866
|
+
encoding=encoding,
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
if not config.options.force:
|
|
870
|
+
result = _apply_reorder_guard(result, baseline, config=config)
|
|
871
|
+
if result.skipped:
|
|
872
|
+
return _FileSortResult(
|
|
873
|
+
status=_FileStatus.SKIPPED,
|
|
874
|
+
sort_result=result,
|
|
875
|
+
encoding=encoding,
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
if result.original_source == result.new_source:
|
|
879
|
+
return _FileSortResult(
|
|
880
|
+
status=_FileStatus.UNCHANGED,
|
|
881
|
+
sort_result=result,
|
|
882
|
+
encoding=encoding,
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
return _FileSortResult(
|
|
886
|
+
status=_FileStatus.CHANGED,
|
|
887
|
+
sort_result=result,
|
|
888
|
+
encoding=encoding,
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _run_json_mode(
|
|
893
|
+
paths: list[Path],
|
|
894
|
+
resolver: _ConfigResolver,
|
|
895
|
+
baselines: dict[Path, BlobReadResult] | None,
|
|
896
|
+
) -> int:
|
|
897
|
+
"""Process files in JSON output mode."""
|
|
898
|
+
records: list[JsonFileRecord] = []
|
|
899
|
+
for path in paths:
|
|
900
|
+
config = resolver._for_path(path)
|
|
901
|
+
baseline = baselines.get(path) if baselines else None
|
|
902
|
+
file_result = _sort_one_file(path, config, baseline)
|
|
903
|
+
record = _file_result_to_json(path, file_result, config.options)
|
|
904
|
+
records.append(record)
|
|
905
|
+
print(format_json(records))
|
|
906
|
+
has_error = any(r['status'] in ('error', 'invalid') for r in records)
|
|
907
|
+
has_changes = any(r['status'] in ('changed', 'skipped') for r in records)
|
|
908
|
+
if has_error:
|
|
909
|
+
return EXIT_ERROR
|
|
910
|
+
if has_changes:
|
|
911
|
+
return EXIT_CHANGED
|
|
912
|
+
return EXIT_OK
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _run_text_mode( # noqa: C901, PLR0912, PLR0913, PLR0915
|
|
916
|
+
paths: list[Path],
|
|
917
|
+
mode: CliMode,
|
|
918
|
+
resolver: _ConfigResolver,
|
|
919
|
+
baselines: dict[Path, BlobReadResult] | None,
|
|
920
|
+
*,
|
|
921
|
+
verbose: bool,
|
|
922
|
+
stdout_color: bool,
|
|
923
|
+
stderr_color: bool,
|
|
924
|
+
multi: bool,
|
|
925
|
+
) -> tuple[bool, bool]:
|
|
926
|
+
"""Run summary, write, preview, or diff mode."""
|
|
927
|
+
summary_rows: list[SummaryRow] = []
|
|
928
|
+
first_output = True
|
|
929
|
+
changes_seen = False
|
|
930
|
+
error_seen = False
|
|
931
|
+
skipped_results: list[tuple[str, SortResult, bool]] = []
|
|
932
|
+
had_output = False
|
|
933
|
+
|
|
934
|
+
for path in paths:
|
|
935
|
+
if stdout_closed():
|
|
936
|
+
break
|
|
937
|
+
config = resolver._for_path(path)
|
|
938
|
+
ordering = config.options.policy.definition_order
|
|
939
|
+
baseline = baselines.get(path) if baselines else None
|
|
940
|
+
file_result = _sort_one_file(path, config, baseline)
|
|
941
|
+
|
|
942
|
+
if file_result.status in (_FileStatus.ERROR, _FileStatus.INVALID):
|
|
943
|
+
safe_path = escape_terminal_text(str(path))
|
|
944
|
+
safe_detail = escape_terminal_text(file_result.error_message or '')
|
|
945
|
+
print(f'error: {safe_path}: {safe_detail}', file=sys.stderr)
|
|
946
|
+
error_seen = True
|
|
947
|
+
continue
|
|
948
|
+
|
|
949
|
+
result = file_result.sort_result
|
|
950
|
+
assert result is not None # noqa: S101
|
|
951
|
+
|
|
952
|
+
constraint_summary = format_banner_constraint_summary(
|
|
953
|
+
str(path),
|
|
954
|
+
result.banner_constraints,
|
|
955
|
+
)
|
|
956
|
+
if constraint_summary and (verbose or file_result.status is not _FileStatus.UNCHANGED):
|
|
957
|
+
print(constraint_summary, file=sys.stderr)
|
|
958
|
+
show_constraint_details = verbose and mode is not CliMode.PREVIEW
|
|
959
|
+
if file_result.status is _FileStatus.SKIPPED and mode is CliMode.PREVIEW:
|
|
960
|
+
show_constraint_details = True
|
|
961
|
+
if show_constraint_details:
|
|
962
|
+
constraint_details = format_banner_constraint_details(result.banner_constraints)
|
|
963
|
+
if constraint_details:
|
|
964
|
+
print(constraint_details, file=sys.stderr)
|
|
965
|
+
|
|
966
|
+
if file_result.status is _FileStatus.SKIPPED:
|
|
967
|
+
skipped_results.append((str(path), result, config.show_skips))
|
|
968
|
+
changes_seen = True
|
|
969
|
+
continue
|
|
970
|
+
|
|
971
|
+
if result.warning:
|
|
972
|
+
safe_path = escape_terminal_text(str(path))
|
|
973
|
+
safe_warning = escape_terminal_text(result.warning)
|
|
974
|
+
print(f'{safe_path}: warning ({safe_warning})', file=sys.stderr)
|
|
975
|
+
|
|
976
|
+
# Write mode: apply changes immediately
|
|
977
|
+
if mode is CliMode.WRITE and file_result.status is _FileStatus.CHANGED:
|
|
978
|
+
assert file_result.encoding is not None # noqa: S101
|
|
979
|
+
try:
|
|
980
|
+
write_source(
|
|
981
|
+
path,
|
|
982
|
+
result.new_source,
|
|
983
|
+
encoding=file_result.encoding,
|
|
984
|
+
)
|
|
985
|
+
except OSError as e:
|
|
986
|
+
safe_path = escape_terminal_text(str(path))
|
|
987
|
+
safe_detail = escape_terminal_text(str(e))
|
|
988
|
+
print(f'error: {safe_path}: {safe_detail}', file=sys.stderr)
|
|
989
|
+
error_seen = True
|
|
990
|
+
for class_blocker in result.class_blockers:
|
|
991
|
+
print(format_class_blocker(class_blocker), file=sys.stderr)
|
|
992
|
+
continue
|
|
993
|
+
|
|
994
|
+
if mode is CliMode.PREVIEW:
|
|
995
|
+
emitted = print_preview(
|
|
996
|
+
str(path),
|
|
997
|
+
result,
|
|
998
|
+
verbose=verbose,
|
|
999
|
+
ordering=ordering,
|
|
1000
|
+
color=stdout_color,
|
|
1001
|
+
multi=multi,
|
|
1002
|
+
first=first_output,
|
|
1003
|
+
)
|
|
1004
|
+
if emitted:
|
|
1005
|
+
first_output = False
|
|
1006
|
+
had_output = True
|
|
1007
|
+
if file_result.status is _FileStatus.CHANGED:
|
|
1008
|
+
changes_seen = True
|
|
1009
|
+
for class_blocker in result.class_blockers:
|
|
1010
|
+
print(format_class_blocker(class_blocker), file=sys.stderr)
|
|
1011
|
+
|
|
1012
|
+
elif mode is CliMode.DIFF:
|
|
1013
|
+
if file_result.status is _FileStatus.CHANGED:
|
|
1014
|
+
changes_seen = True
|
|
1015
|
+
had_output = True
|
|
1016
|
+
print_diff(
|
|
1017
|
+
result,
|
|
1018
|
+
str(path),
|
|
1019
|
+
verbose=verbose,
|
|
1020
|
+
ordering=ordering,
|
|
1021
|
+
color=stdout_color,
|
|
1022
|
+
)
|
|
1023
|
+
for class_blocker in result.class_blockers:
|
|
1024
|
+
print(format_class_blocker(class_blocker), file=sys.stderr)
|
|
1025
|
+
|
|
1026
|
+
elif mode is CliMode.SUMMARY:
|
|
1027
|
+
if file_result.status is _FileStatus.CHANGED:
|
|
1028
|
+
changes_seen = True
|
|
1029
|
+
summary_rows.append(build_summary_row(str(path), result))
|
|
1030
|
+
for class_blocker in result.class_blockers:
|
|
1031
|
+
print(format_class_blocker(class_blocker), file=sys.stderr)
|
|
1032
|
+
|
|
1033
|
+
elif mode is CliMode.WRITE:
|
|
1034
|
+
# WRITE does not set changes_seen -- exit 0 after successful writes.
|
|
1035
|
+
summary_rows.append(build_summary_row(str(path), result))
|
|
1036
|
+
for class_blocker in result.class_blockers:
|
|
1037
|
+
print(format_class_blocker(class_blocker), file=sys.stderr)
|
|
1038
|
+
|
|
1039
|
+
if summary_rows and mode in (CliMode.SUMMARY, CliMode.WRITE):
|
|
1040
|
+
output = format_summary_rows(summary_rows, verbose=verbose, color=stdout_color)
|
|
1041
|
+
if output:
|
|
1042
|
+
print(output)
|
|
1043
|
+
had_output = True
|
|
1044
|
+
|
|
1045
|
+
visible_skips = [
|
|
1046
|
+
(skip_path, skip_result)
|
|
1047
|
+
for skip_path, skip_result, skip_visible in skipped_results
|
|
1048
|
+
if skip_visible or mode is CliMode.PREVIEW
|
|
1049
|
+
]
|
|
1050
|
+
if visible_skips:
|
|
1051
|
+
sys.stdout.flush()
|
|
1052
|
+
if had_output:
|
|
1053
|
+
print(file=sys.stderr)
|
|
1054
|
+
for index, (skip_path, skip_result) in enumerate(visible_skips):
|
|
1055
|
+
if index and mode is CliMode.PREVIEW:
|
|
1056
|
+
print(file=sys.stderr)
|
|
1057
|
+
print_skipped(
|
|
1058
|
+
skip_path,
|
|
1059
|
+
skip_result,
|
|
1060
|
+
mode=mode,
|
|
1061
|
+
color=stderr_color,
|
|
1062
|
+
verbose=verbose,
|
|
1063
|
+
)
|
|
1064
|
+
if mode in (CliMode.SUMMARY, CliMode.PREVIEW):
|
|
1065
|
+
for class_blocker in skip_result.class_blockers:
|
|
1066
|
+
print(format_class_blocker(class_blocker), file=sys.stderr)
|
|
1067
|
+
|
|
1068
|
+
return changes_seen, error_seen
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def _run_cli(argv: Sequence[str] | None = None) -> int: # noqa: C901, PLR0912
|
|
1072
|
+
"""Parse CLI arguments and dispatch the selected operation."""
|
|
1073
|
+
ensure_utf8_stdio()
|
|
1074
|
+
args_list = sys.argv[1:] if argv is None else list(argv)
|
|
1075
|
+
if args_list and args_list[0] == 'hook':
|
|
1076
|
+
return _run_hook(args_list[1:])
|
|
1077
|
+
|
|
1078
|
+
parser = _build_parser()
|
|
1079
|
+
args = parser.parse_args(args_list)
|
|
1080
|
+
|
|
1081
|
+
if args.version:
|
|
1082
|
+
return _print_version()
|
|
1083
|
+
|
|
1084
|
+
if args.show_policy and args.detect:
|
|
1085
|
+
parser.error('--show-policy is not allowed with --detect')
|
|
1086
|
+
overrides = _overrides_from_args(args)
|
|
1087
|
+
if args.show_policy:
|
|
1088
|
+
return _run_show_policy(args.paths, overrides)
|
|
1089
|
+
|
|
1090
|
+
raw_paths: list[str] = args.paths
|
|
1091
|
+
paths = collect_files(raw_paths)
|
|
1092
|
+
|
|
1093
|
+
resolver = _ConfigResolver(
|
|
1094
|
+
overrides=overrides,
|
|
1095
|
+
force=args.force,
|
|
1096
|
+
hoist=args.hoist,
|
|
1097
|
+
add_future=not args.no_future,
|
|
1098
|
+
)
|
|
1099
|
+
resolved_configs = [resolver._for_path(path) for path in paths]
|
|
1100
|
+
config_colors: set[ColorMode | None] = {cfg.color for cfg in resolved_configs}
|
|
1101
|
+
config_color: ColorMode | None = (
|
|
1102
|
+
next(iter(config_colors)) if len(config_colors) == 1 else 'auto'
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
stdout_color = should_color(
|
|
1106
|
+
sys.stdout,
|
|
1107
|
+
cli_flag=args.color,
|
|
1108
|
+
config_value=config_color,
|
|
1109
|
+
)
|
|
1110
|
+
stderr_color = should_color(
|
|
1111
|
+
sys.stderr,
|
|
1112
|
+
cli_flag=args.color,
|
|
1113
|
+
config_value=config_color,
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
if args.detect:
|
|
1117
|
+
if args.preview or args.diff or args.write:
|
|
1118
|
+
parser.error('--detect is not allowed with -p/--preview, -d/--diff, or -w/--write')
|
|
1119
|
+
if not args.json:
|
|
1120
|
+
return _run_detect(
|
|
1121
|
+
paths,
|
|
1122
|
+
resolver=resolver,
|
|
1123
|
+
verbose=args.verbose,
|
|
1124
|
+
)
|
|
1125
|
+
# --detect with --json: silently ignored (detect fields always in JSON)
|
|
1126
|
+
|
|
1127
|
+
needs_baselines = any(cfg.incremental or cfg._guard_enabled for cfg in resolved_configs)
|
|
1128
|
+
|
|
1129
|
+
cli_baselines: dict[Path, BlobReadResult] | None = None
|
|
1130
|
+
if needs_baselines:
|
|
1131
|
+
try:
|
|
1132
|
+
cli_baselines = _batch_fetch_cli_baselines(paths)
|
|
1133
|
+
except UnsupportedGitVersionError as error:
|
|
1134
|
+
print(str(error), file=sys.stderr)
|
|
1135
|
+
return EXIT_ERROR
|
|
1136
|
+
|
|
1137
|
+
if args.json:
|
|
1138
|
+
exit_code = _run_json_mode(paths, resolver, cli_baselines)
|
|
1139
|
+
else:
|
|
1140
|
+
multi = len(raw_paths) > 1 or any(Path(path).is_dir() for path in raw_paths)
|
|
1141
|
+
if args.write:
|
|
1142
|
+
mode = CliMode.WRITE
|
|
1143
|
+
elif args.preview:
|
|
1144
|
+
mode = CliMode.PREVIEW
|
|
1145
|
+
elif args.diff:
|
|
1146
|
+
mode = CliMode.DIFF
|
|
1147
|
+
else:
|
|
1148
|
+
mode = CliMode.SUMMARY
|
|
1149
|
+
|
|
1150
|
+
changes_seen, error_seen = _run_text_mode(
|
|
1151
|
+
paths,
|
|
1152
|
+
mode,
|
|
1153
|
+
resolver,
|
|
1154
|
+
cli_baselines,
|
|
1155
|
+
verbose=args.verbose,
|
|
1156
|
+
stdout_color=stdout_color,
|
|
1157
|
+
stderr_color=stderr_color,
|
|
1158
|
+
multi=multi,
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
exit_code = EXIT_OK
|
|
1162
|
+
if changes_seen:
|
|
1163
|
+
exit_code = EXIT_CHANGED
|
|
1164
|
+
if error_seen:
|
|
1165
|
+
exit_code = EXIT_ERROR
|
|
1166
|
+
return exit_code
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
1170
|
+
"""Run the defsort command-line interface.
|
|
1171
|
+
|
|
1172
|
+
If a reader closes standard output early, the remaining output is discarded
|
|
1173
|
+
and the exit status reflects the work completed before the closure.
|
|
1174
|
+
|
|
1175
|
+
Args:
|
|
1176
|
+
argv: Arguments to parse, or `None` to use `sys.argv`.
|
|
1177
|
+
|
|
1178
|
+
Returns:
|
|
1179
|
+
The process exit status.
|
|
1180
|
+
"""
|
|
1181
|
+
with closable_stdout():
|
|
1182
|
+
return _run_cli(argv)
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
if __name__ == '__main__':
|
|
1186
|
+
sys.exit(main())
|