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.
Files changed (58) hide show
  1. defsort/__init__.py +332 -0
  2. defsort/__main__.py +8 -0
  3. defsort/_classify.py +56 -0
  4. defsort/_cli.py +1186 -0
  5. defsort/_color.py +86 -0
  6. defsort/_config.py +576 -0
  7. defsort/_core/__init__.py +5 -0
  8. defsort/_core/_analysis.py +1237 -0
  9. defsort/_core/_api.py +373 -0
  10. defsort/_core/_banners.py +563 -0
  11. defsort/_core/_full.py +407 -0
  12. defsort/_core/_future.py +233 -0
  13. defsort/_core/_guard.py +151 -0
  14. defsort/_core/_hoist.py +238 -0
  15. defsort/_core/_incremental.py +741 -0
  16. defsort/_core/_layout.py +496 -0
  17. defsort/_core/_merge.py +123 -0
  18. defsort/_core/_methods.py +1283 -0
  19. defsort/_core/_ordering.py +394 -0
  20. defsort/_core/_parse.py +763 -0
  21. defsort/_core/_reassemble.py +42 -0
  22. defsort/_core/_safety.py +411 -0
  23. defsort/_detector.py +152 -0
  24. defsort/_display/__init__.py +16 -0
  25. defsort/_display/_constraints.py +60 -0
  26. defsort/_display/_detect.py +62 -0
  27. defsort/_display/_diff.py +105 -0
  28. defsort/_display/_json.py +138 -0
  29. defsort/_display/_movement.py +282 -0
  30. defsort/_display/_skipped.py +130 -0
  31. defsort/_display/_summary.py +211 -0
  32. defsort/_display/_terminal.py +24 -0
  33. defsort/_display/_types.py +166 -0
  34. defsort/_display/_width.py +34 -0
  35. defsort/_downstream.py +181 -0
  36. defsort/_files.py +272 -0
  37. defsort/_git.py +1021 -0
  38. defsort/_git_executable.py +51 -0
  39. defsort/_grammar.py +785 -0
  40. defsort/_hook.py +1564 -0
  41. defsort/_hosts/__init__.py +50 -0
  42. defsort/_shell.py +66 -0
  43. defsort/_shellparse.py +595 -0
  44. defsort/_source.py +140 -0
  45. defsort/_types.py +840 -0
  46. defsort/hooks/__init__.py +242 -0
  47. defsort/py.typed +0 -0
  48. defsort-0.1.0.dist-info/METADATA +150 -0
  49. defsort-0.1.0.dist-info/RECORD +58 -0
  50. defsort-0.1.0.dist-info/WHEEL +4 -0
  51. defsort-0.1.0.dist-info/entry_points.txt +7 -0
  52. defsort-0.1.0.dist-info/licenses/LICENSE +21 -0
  53. plugins/agy/_defsort/__init__.py +1 -0
  54. plugins/agy/_defsort/_adapter.py +121 -0
  55. plugins/claude/_defsort/__init__.py +1 -0
  56. plugins/claude/_defsort/_adapter.py +131 -0
  57. plugins/codex/_defsort/__init__.py +1 -0
  58. 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())