docwrap 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. docwrap/__init__.py +59 -0
  2. docwrap/__main__.py +8 -0
  3. docwrap/_canonical.py +66 -0
  4. docwrap/_classify.py +56 -0
  5. docwrap/_cli.py +1284 -0
  6. docwrap/_color.py +135 -0
  7. docwrap/_config.py +615 -0
  8. docwrap/_convert.py +1324 -0
  9. docwrap/_core/__init__.py +27 -0
  10. docwrap/_core/_codeblock.py +214 -0
  11. docwrap/_core/_discover.py +466 -0
  12. docwrap/_core/_escape.py +204 -0
  13. docwrap/_core/_incremental.py +402 -0
  14. docwrap/_core/_normalize.py +306 -0
  15. docwrap/_core/_parse.py +594 -0
  16. docwrap/_core/_process.py +826 -0
  17. docwrap/_core/_wrap.py +1714 -0
  18. docwrap/_detect.py +98 -0
  19. docwrap/_detector.py +166 -0
  20. docwrap/_display/__init__.py +17 -0
  21. docwrap/_display/_diff.py +62 -0
  22. docwrap/_display/_json.py +168 -0
  23. docwrap/_display/_print.py +93 -0
  24. docwrap/_display/_recommend.py +165 -0
  25. docwrap/_display/_skips.py +17 -0
  26. docwrap/_display/_stats.py +1075 -0
  27. docwrap/_display/_summary.py +213 -0
  28. docwrap/_display/_text.py +17 -0
  29. docwrap/_downstream.py +181 -0
  30. docwrap/_exceptions.py +52 -0
  31. docwrap/_files.py +304 -0
  32. docwrap/_git.py +764 -0
  33. docwrap/_git_utils.py +76 -0
  34. docwrap/_grammar.py +800 -0
  35. docwrap/_hook.py +1001 -0
  36. docwrap/_hosts/__init__.py +50 -0
  37. docwrap/_shell.py +73 -0
  38. docwrap/_shellparse.py +643 -0
  39. docwrap/_stats.py +529 -0
  40. docwrap/_styles/__init__.py +335 -0
  41. docwrap/_styles/_google.py +423 -0
  42. docwrap/_styles/_numpy.py +292 -0
  43. docwrap/_styles/_sphinx.py +324 -0
  44. docwrap/_types.py +423 -0
  45. docwrap/_validate.py +62 -0
  46. docwrap/hooks/__init__.py +245 -0
  47. docwrap/py.typed +0 -0
  48. docwrap-0.1.0.dist-info/METADATA +126 -0
  49. docwrap-0.1.0.dist-info/RECORD +58 -0
  50. docwrap-0.1.0.dist-info/WHEEL +4 -0
  51. docwrap-0.1.0.dist-info/entry_points.txt +7 -0
  52. docwrap-0.1.0.dist-info/licenses/LICENSE +21 -0
  53. plugins/agy/_docwrap/__init__.py +1 -0
  54. plugins/agy/_docwrap/_adapter.py +122 -0
  55. plugins/claude/_docwrap/__init__.py +1 -0
  56. plugins/claude/_docwrap/_adapter.py +106 -0
  57. plugins/codex/_docwrap/__init__.py +1 -0
  58. plugins/codex/_docwrap/_adapter.py +117 -0
docwrap/_cli.py ADDED
@@ -0,0 +1,1284 @@
1
+ """CLI entry point for docwrap."""
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ import sys
7
+ import tokenize
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass, replace
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Literal, cast
12
+
13
+ from docwrap._color import should_color, visible_controls
14
+ from docwrap._config import (
15
+ ResolvedConfig,
16
+ effective_config,
17
+ find_color_config,
18
+ find_config,
19
+ find_incremental_config,
20
+ find_stats_config,
21
+ render_config_warnings,
22
+ resolve_config,
23
+ )
24
+ from docwrap._core import analyze_source, wrap_source_report_eligible
25
+ from docwrap._core._incremental import (
26
+ DiffHunk,
27
+ derive_eligible_target,
28
+ get_eligible_ranges,
29
+ parse_diff_hunks,
30
+ resolve_incremental_level,
31
+ )
32
+ from docwrap._downstream import closable_stdout, stdout_closed
33
+ from docwrap._exceptions import UnknownStyleError
34
+ from docwrap._files import (
35
+ ReadResult,
36
+ collect_files,
37
+ ensure_utf8_stdio,
38
+ read_file,
39
+ read_file_result,
40
+ write_preserving_newline,
41
+ )
42
+ from docwrap._git import (
43
+ RepoDiscoveryStatus,
44
+ can_reuse_repo_root,
45
+ discover_repo_root,
46
+ get_repo_diff,
47
+ get_tracked_files,
48
+ )
49
+ from docwrap._styles import get_styles
50
+ from docwrap._types import (
51
+ COLOR_MODES,
52
+ DEFAULT_STATS_SECTIONS,
53
+ FILL_STYLES,
54
+ INCREMENTAL_LEVELS,
55
+ REFLOW_MODES,
56
+ SUMMARY_STARTS,
57
+ DetailLevel,
58
+ DocwrapConfig,
59
+ FileResult,
60
+ IncrementalLevel,
61
+ LineLengthSource,
62
+ OutputMode,
63
+ StatsSection,
64
+ WrapOverrides,
65
+ WrapReport,
66
+ )
67
+
68
+ if TYPE_CHECKING:
69
+ from docwrap._display._json import JsonFileRecord
70
+ from docwrap._stats import ImpactRow, LineMeasurement, StatsData
71
+
72
+ _DEFAULT_CONFIG = DocwrapConfig()
73
+ _DEFAULT_RESOLVED = ResolvedConfig(config=DocwrapConfig())
74
+ _EXIT_CHANGED = 1
75
+ _EXIT_ERROR = 2
76
+ _EXIT_OK = 0
77
+
78
+ type _FileFailureKind = Literal['error', 'invalid']
79
+ type _FilePlanState = Literal['failed', 'full', 'no-eligible', 'no-hunks', 'ranges']
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class _FileModeOptions:
84
+ """Resolved output mode options for file processing."""
85
+
86
+ mode: OutputMode
87
+ detail: DetailLevel
88
+ verbose: bool
89
+ color: bool
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class _FilePlan:
94
+ """Pre-computed file plan from incremental analysis."""
95
+
96
+ state: _FilePlanState
97
+ eligible_ranges: set[tuple[int, int]] | None
98
+ prepared: ReadResult | None = None
99
+ failure_kind: _FileFailureKind | None = None
100
+ diagnostic: str | None = None
101
+
102
+
103
+ @dataclass
104
+ class _IncrementalContext:
105
+ """Computed incremental context for CLI file processing."""
106
+
107
+ level: IncrementalLevel | None
108
+ all_hunks: dict[Path, list[DiffHunk]]
109
+ untracked: set[Path]
110
+ repo_roots: dict[Path, str | None]
111
+ diff_failed: set[Path]
112
+
113
+
114
+ @dataclass(frozen=True, slots=True)
115
+ class _IncrementalDiagnostic:
116
+ """Diagnostic returned for caller-specific rendering."""
117
+
118
+ message: str
119
+ fatal: bool = False
120
+
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class _IncrementalOutcome:
124
+ """Incremental context plus an optional caller-owned diagnostic."""
125
+
126
+ context: _IncrementalContext | None
127
+ diagnostic: _IncrementalDiagnostic | None = None
128
+
129
+
130
+ @dataclass
131
+ class _IncrementalRepositoryState:
132
+ """Mutable incremental state collected across repositories."""
133
+
134
+ all_hunks: dict[Path, list[DiffHunk]]
135
+ diff_failed: set[Path]
136
+ path_to_rel: dict[Path, str]
137
+ untracked: set[Path]
138
+ warnings: list[str]
139
+
140
+
141
+ @dataclass(frozen=True, slots=True)
142
+ class _WrappedFile:
143
+ """Result of reading, resolving config, and wrapping a single file."""
144
+
145
+ source: str
146
+ encoding: str
147
+ config: DocwrapConfig
148
+ report: WrapReport
149
+ raw: bytes | None
150
+ show_skips: bool
151
+
152
+
153
+ def _aggregate_impact(target: dict[int, 'ImpactRow'], rows: list['ImpactRow']) -> None:
154
+ """Merge per-file impact rows into aggregate impact dict."""
155
+ for row in rows:
156
+ prev = target.get(row.line_length)
157
+ if prev is None:
158
+ target[row.line_length] = row
159
+ continue
160
+ target[row.line_length] = replace(
161
+ prev,
162
+ docstrings_changed=prev.docstrings_changed + row.docstrings_changed,
163
+ files_changed=prev.files_changed + row.files_changed,
164
+ lines_added=prev.lines_added + row.lines_added,
165
+ lines_removed=prev.lines_removed + row.lines_removed,
166
+ )
167
+
168
+
169
+ def _argv_requests_help(args_list: list[str]) -> bool:
170
+ """Check whether the argument list requests help output."""
171
+ for arg in args_list:
172
+ if arg == '--':
173
+ return False
174
+ if arg in {'-h', '--help'}:
175
+ return True
176
+ return False
177
+
178
+
179
+ def _build_hook_parser() -> argparse.ArgumentParser:
180
+ """Build the argument parser for the hook subcommand."""
181
+ parser = argparse.ArgumentParser(
182
+ prog='docwrap hook',
183
+ formatter_class=argparse.RawDescriptionHelpFormatter,
184
+ description='Agent hook entry point; reads the host payload as JSON from stdin.',
185
+ )
186
+ parser.add_argument(
187
+ '-a',
188
+ '--agent',
189
+ required=True,
190
+ metavar='NAME',
191
+ help='Installed coding-agent adapter name.',
192
+ )
193
+ parser.add_argument(
194
+ '-e',
195
+ '--event',
196
+ metavar='NAME',
197
+ help='Host event name; required only for hosts without an event in the payload.',
198
+ )
199
+ group = parser.add_mutually_exclusive_group()
200
+ group.add_argument(
201
+ '-i',
202
+ '--incremental',
203
+ action='store_true',
204
+ help='Enable incremental wrapping at the configured level (default: definition).',
205
+ )
206
+ group.add_argument(
207
+ '--no-incremental',
208
+ action='store_true',
209
+ help='Disable incremental wrapping, even when configured.',
210
+ )
211
+ parser.add_argument(
212
+ '--allow-on-failure',
213
+ action='store_true',
214
+ help='Allow accepted commands on hook failures; unsupported commits still deny.',
215
+ )
216
+ return parser
217
+
218
+
219
+ def _classify_missing_incremental_paths(
220
+ state: _IncrementalRepositoryState,
221
+ root: str,
222
+ missing: list[Path],
223
+ incr_explicit: bool,
224
+ ) -> bool:
225
+ """Classify paths missing from diff hunks using Git tracked membership."""
226
+ if not missing:
227
+ return True
228
+
229
+ tracked_set = get_tracked_files(root, [state.path_to_rel[path] for path in missing])
230
+ if tracked_set is None:
231
+ if incr_explicit:
232
+ return False
233
+ state.diff_failed.update(missing)
234
+ state.warnings.append(
235
+ f'git tracked-file discovery failed for {root}, falling back to full mode'
236
+ )
237
+ return True
238
+
239
+ for resolved in missing:
240
+ if state.path_to_rel[resolved] not in tracked_set:
241
+ state.untracked.add(resolved)
242
+ return True
243
+
244
+
245
+ def _collect_incremental_repository_state(
246
+ by_root: dict[str, list[Path]],
247
+ incr_explicit: bool,
248
+ ) -> _IncrementalRepositoryState | None:
249
+ """Collect diff and untracked state once per repository."""
250
+ state = _IncrementalRepositoryState(
251
+ all_hunks={},
252
+ diff_failed=set(),
253
+ path_to_rel={},
254
+ untracked=set(),
255
+ warnings=[],
256
+ )
257
+
258
+ for root, root_files in by_root.items():
259
+ relative_paths = tuple(
260
+ os.path.relpath(str(resolved), root).replace(os.sep, '/') for resolved in root_files
261
+ )
262
+ state.path_to_rel.update(zip(root_files, relative_paths, strict=True))
263
+ diff_output = get_repo_diff(root)
264
+ root_hunks = None if diff_output is None else parse_diff_hunks(diff_output)
265
+ if root_hunks is None:
266
+ if incr_explicit:
267
+ return None
268
+ state.diff_failed.update(root_files)
269
+ state.warnings.append(f'git diff failed for {root}, falling back to full mode')
270
+ continue
271
+
272
+ for resolved, rel in zip(root_files, relative_paths, strict=True):
273
+ if rel in root_hunks:
274
+ state.all_hunks[resolved] = root_hunks[rel]
275
+
276
+ missing = [resolved for resolved in root_files if resolved not in state.all_hunks]
277
+ if not _classify_missing_incremental_paths(state, root, missing, incr_explicit):
278
+ return None
279
+
280
+ return state
281
+
282
+
283
+ def _collect_stats(
284
+ files: list[Path],
285
+ overrides: WrapOverrides,
286
+ *,
287
+ extra_lengths: tuple[int, ...] = (),
288
+ needs_impact: bool = True,
289
+ ) -> 'StatsData':
290
+ """Collect stats in one pass, optionally computing wrapping impact."""
291
+ from docwrap._display._skips import format_safety_skip # noqa: PLC0415
292
+ from docwrap._stats import ( # noqa: PLC0415
293
+ StatsData,
294
+ compute_impact,
295
+ measure_docstring,
296
+ measure_source_lines,
297
+ )
298
+
299
+ all_measurements: list[LineMeasurement] = []
300
+ all_source_measurements: list[LineMeasurement] = []
301
+ agg_impact: dict[int, ImpactRow] = {}
302
+ total_docstrings = 0
303
+ docstring_files = 0
304
+ skipped = 0
305
+ safety_skips = 0
306
+
307
+ for path in files:
308
+ try:
309
+ source, _ = read_file(path)
310
+ except (OSError, UnicodeDecodeError, SyntaxError):
311
+ skipped += 1
312
+ continue
313
+
314
+ with render_config_warnings():
315
+ cfg = resolve_config(path)
316
+ effective_cfg = effective_config(cfg, overrides)
317
+
318
+ analysis = analyze_source(source, effective_cfg)
319
+ if analysis.parse_error:
320
+ continue
321
+
322
+ for skip in analysis.skips:
323
+ print(
324
+ format_safety_skip(str(path), skip),
325
+ file=sys.stderr,
326
+ )
327
+ safety_skips += len(analysis.skips)
328
+
329
+ docstrings = analysis.docstrings
330
+ parsed = analysis.parsed
331
+
332
+ n_docstrings = len(docstrings)
333
+ total_docstrings += n_docstrings
334
+ if n_docstrings > 0:
335
+ docstring_files += 1
336
+
337
+ measurements = measure_docstring(
338
+ parsed,
339
+ file_path=str(path),
340
+ wrap_summary=effective_cfg.wrap_summary,
341
+ )
342
+ all_measurements.extend(measurements)
343
+
344
+ docstring_ranges = [(info.lineno, info.end_lineno) for info in docstrings]
345
+ source_ms = measure_source_lines(source, docstring_ranges, file_path=str(path))
346
+ all_source_measurements.extend(source_ms)
347
+
348
+ if needs_impact:
349
+ rows = compute_impact(
350
+ parsed,
351
+ effective_cfg,
352
+ extra_lengths=extra_lengths,
353
+ )
354
+ _aggregate_impact(agg_impact, rows)
355
+
356
+ return StatsData(
357
+ docstring_count=total_docstrings,
358
+ docstring_file_count=docstring_files,
359
+ impact=agg_impact,
360
+ measurements=all_measurements,
361
+ skipped_count=skipped,
362
+ source_measurements=all_source_measurements,
363
+ safety_skip_count=safety_skips,
364
+ )
365
+
366
+
367
+ def _compute_file_plan(
368
+ path: Path,
369
+ incr_ctx: _IncrementalContext,
370
+ ) -> _FilePlan:
371
+ """Compute a discriminated file plan for incremental mode."""
372
+ resolved = path.resolve()
373
+ if (
374
+ incr_ctx.level is None
375
+ or resolved in incr_ctx.diff_failed
376
+ or resolved in incr_ctx.untracked
377
+ ):
378
+ plan = _FilePlan(state='full', eligible_ranges=None)
379
+ else:
380
+ hunks = incr_ctx.all_hunks.get(resolved, [])
381
+ if incr_ctx.repo_roots.get(resolved) is None or not hunks:
382
+ plan = _FilePlan(state='no-hunks', eligible_ranges=None)
383
+ else:
384
+ try:
385
+ read_result = read_file_result(path)
386
+ except (OSError, UnicodeDecodeError) as error:
387
+ plan = _FilePlan(
388
+ state='failed',
389
+ eligible_ranges=None,
390
+ failure_kind='error',
391
+ diagnostic=str(error),
392
+ )
393
+ except (SyntaxError, tokenize.TokenError, ValueError) as error:
394
+ plan = _FilePlan(
395
+ state='failed',
396
+ eligible_ranges=None,
397
+ failure_kind='invalid',
398
+ diagnostic=str(error),
399
+ )
400
+ else:
401
+ prepared = read_result
402
+ try:
403
+ ranges = get_eligible_ranges(
404
+ prepared.source,
405
+ hunks,
406
+ level=incr_ctx.level,
407
+ encoding=prepared.encoding,
408
+ )
409
+ except (SyntaxError, tokenize.TokenError, ValueError) as error:
410
+ plan = _FilePlan(
411
+ state='failed',
412
+ eligible_ranges=None,
413
+ prepared=prepared,
414
+ failure_kind='invalid',
415
+ diagnostic=str(error),
416
+ )
417
+ else:
418
+ if ranges is None:
419
+ state: _FilePlanState = 'full'
420
+ elif ranges:
421
+ state = 'ranges'
422
+ else:
423
+ state = 'no-eligible'
424
+ plan = _FilePlan(
425
+ state=state,
426
+ eligible_ranges=ranges,
427
+ prepared=prepared,
428
+ )
429
+ return plan
430
+
431
+
432
+ def _config_field_source(config: DocwrapConfig, field_name: str) -> str:
433
+ """Return the source label for a config field."""
434
+ if getattr(config, field_name) == getattr(_DEFAULT_CONFIG, field_name):
435
+ return 'default'
436
+ return '[tool.docwrap]'
437
+
438
+
439
+ def _discover_incremental_repositories(
440
+ files: list[Path],
441
+ ) -> tuple[dict[Path, str | None], dict[str, list[Path]], set[Path]]:
442
+ """Map files to repository roots and group files by root."""
443
+ discovery_failed: set[Path] = set()
444
+ repo_roots: dict[Path, str | None] = {}
445
+ root_cache: dict[Path, tuple[str | None, RepoDiscoveryStatus]] = {}
446
+ discovered_roots: dict[str, None] = {}
447
+ for path in files:
448
+ resolved = path.resolve()
449
+ parent = resolved.parent
450
+ if parent not in root_cache:
451
+ reusable = next(
452
+ (root for root in discovered_roots if can_reuse_repo_root(parent, root)),
453
+ None,
454
+ )
455
+ if reusable is not None:
456
+ root_cache[parent] = reusable, RepoDiscoveryStatus.WORKTREE
457
+ else:
458
+ discovery = discover_repo_root(str(parent))
459
+ root_cache[parent] = discovery.root, discovery.status
460
+ if discovery.root is not None:
461
+ discovered_roots[discovery.root] = None
462
+ root, status = root_cache[parent]
463
+ repo_roots[resolved] = root
464
+ if status is RepoDiscoveryStatus.ERROR:
465
+ discovery_failed.add(resolved)
466
+
467
+ by_root: dict[str, list[Path]] = {}
468
+ for resolved, root in repo_roots.items():
469
+ if root is not None:
470
+ by_root.setdefault(root, []).append(resolved)
471
+ return repo_roots, by_root, discovery_failed
472
+
473
+
474
+ def _compute_incremental_context(
475
+ files: list[Path],
476
+ incr_level: IncrementalLevel | None,
477
+ incr_explicit: bool,
478
+ ) -> _IncrementalOutcome:
479
+ """Compute diff hunks and untracked file set for incremental mode."""
480
+ if incr_level is None or not files:
481
+ return _IncrementalOutcome(
482
+ context=_IncrementalContext(
483
+ level=incr_level,
484
+ all_hunks={},
485
+ untracked=set(),
486
+ repo_roots={},
487
+ diff_failed=set(),
488
+ )
489
+ )
490
+
491
+ repo_roots, by_root, discovery_failed = _discover_incremental_repositories(files)
492
+ if discovery_failed and incr_explicit:
493
+ return _IncrementalOutcome(
494
+ context=None,
495
+ diagnostic=_IncrementalDiagnostic(
496
+ 'git state collection failed for incremental mode', fatal=True
497
+ ),
498
+ )
499
+
500
+ state = _collect_incremental_repository_state(by_root, incr_explicit)
501
+ if state is None:
502
+ return _IncrementalOutcome(
503
+ context=None,
504
+ diagnostic=_IncrementalDiagnostic(
505
+ 'git state collection failed for incremental mode', fatal=True
506
+ ),
507
+ )
508
+
509
+ if discovery_failed:
510
+ state.diff_failed.update(discovery_failed)
511
+ state.warnings.append('git repository discovery failed, falling back to full mode')
512
+ if any(
513
+ root is None and resolved not in discovery_failed for resolved, root in repo_roots.items()
514
+ ):
515
+ state.warnings.append('not in a git repository, skipping incremental files')
516
+ diagnostic = (
517
+ _IncrementalDiagnostic('; '.join(sorted(state.warnings))) if state.warnings else None
518
+ )
519
+
520
+ return _IncrementalOutcome(
521
+ context=_IncrementalContext(
522
+ level=incr_level,
523
+ all_hunks=state.all_hunks,
524
+ untracked=state.untracked,
525
+ repo_roots=repo_roots,
526
+ diff_failed=state.diff_failed,
527
+ ),
528
+ diagnostic=diagnostic,
529
+ )
530
+
531
+
532
+ def _format_default(value: object, source: str) -> str:
533
+ """Format a default-value string for help text."""
534
+ if source == 'default':
535
+ return f'(default: {value})'
536
+ return f'(default: {value}, from {source})'
537
+
538
+
539
+ def _incremental_override(args: argparse.Namespace) -> bool | None:
540
+ """Map CLI incremental switches to a tri-state override."""
541
+ if args.no_incremental:
542
+ return False
543
+ if args.incremental:
544
+ return True
545
+ return None
546
+
547
+
548
+ def _parse_stats_longest(value: str) -> int:
549
+ """Parse a positive stats longest-line limit."""
550
+ try:
551
+ parsed = int(value)
552
+ except ValueError as error:
553
+ raise argparse.ArgumentTypeError('must be a positive integer') from error
554
+ if parsed < 1:
555
+ raise argparse.ArgumentTypeError('must be a positive integer')
556
+ return parsed
557
+
558
+
559
+ def _parse_stats_sections(value: str) -> tuple[StatsSection, ...]:
560
+ """Parse an ordered comma-separated stats section list."""
561
+ if value == '':
562
+ return ()
563
+ names = [part.strip() for part in value.split(',')]
564
+ if any(not name for name in names):
565
+ raise argparse.ArgumentTypeError('stats sections cannot contain an empty item')
566
+ unknown = [name for name in names if name not in DEFAULT_STATS_SECTIONS]
567
+ if unknown:
568
+ raise argparse.ArgumentTypeError(f'unknown stats section: {unknown[0]}')
569
+ if len(names) != len(set(names)):
570
+ raise argparse.ArgumentTypeError('stats sections cannot contain duplicates')
571
+ return cast('tuple[StatsSection, ...]', tuple(names))
572
+
573
+
574
+ def _build_parser(resolved: ResolvedConfig | None = None) -> argparse.ArgumentParser:
575
+ """Build the CLI argument parser."""
576
+ resolved_cfg = _DEFAULT_RESOLVED if resolved is None else resolved
577
+ help_config = resolved_cfg.config
578
+
579
+ color_default = _format_default(
580
+ resolved_cfg.color,
581
+ 'default' if resolved_cfg.color == _DEFAULT_RESOLVED.color else '[tool.docwrap]',
582
+ )
583
+ length_default = _format_default(
584
+ help_config.line_length,
585
+ resolved_cfg.line_length_source,
586
+ )
587
+ fill_style_default = _format_default(
588
+ help_config.fill_style,
589
+ _config_field_source(help_config, 'fill_style'),
590
+ )
591
+ smart_slack_default = _format_default(
592
+ help_config.smart_slack,
593
+ _config_field_source(help_config, 'smart_slack'),
594
+ )
595
+ reflow_default = _format_default(
596
+ help_config.reflow,
597
+ _config_field_source(help_config, 'reflow'),
598
+ )
599
+ summary_start_default = _format_default(
600
+ help_config.summary_start,
601
+ _config_field_source(help_config, 'summary_start'),
602
+ )
603
+ wrap_summary_default = _format_default(
604
+ help_config.wrap_summary,
605
+ _config_field_source(help_config, 'wrap_summary'),
606
+ )
607
+
608
+ parser = argparse.ArgumentParser(
609
+ prog='docwrap',
610
+ description='Wrap Python docstrings to a configured line length.',
611
+ formatter_class=argparse.RawDescriptionHelpFormatter,
612
+ epilog=('subcommands:\n hook Agent hook entry point (see docwrap hook --help)'),
613
+ )
614
+ wrapping_policy_group = parser.add_argument_group('wrapping policy options')
615
+ style_group = parser.add_argument_group('style options')
616
+ statistics_group = parser.add_argument_group('statistics options')
617
+ parser.add_argument('paths', nargs='*', default=['.'], help='Python files or directories.')
618
+ parser.add_argument('--version', action='store_true', help='Show the installed version.')
619
+ mode_group = parser.add_mutually_exclusive_group()
620
+ mode_group.add_argument(
621
+ '-p', '--preview', action='store_true', help='Show per-docstring before/after.'
622
+ )
623
+ mode_group.add_argument(
624
+ '-s',
625
+ '--stats',
626
+ action='store_true',
627
+ help='Show line length statistics.',
628
+ )
629
+ mode_group.add_argument('-d', '--diff', action='store_true', help='Show unified diff.')
630
+ mode_group.add_argument('-j', '--json', action='store_true', help='Output to JSON.')
631
+ mode_group.add_argument('-w', '--write', action='store_true', help='Modify files in place.')
632
+ parser.add_argument(
633
+ '-l',
634
+ '--length',
635
+ type=int,
636
+ dest='line_length',
637
+ help=f'Target line length {length_default}.',
638
+ )
639
+ parser.add_argument(
640
+ '-i',
641
+ '--incremental',
642
+ action='store_true',
643
+ default=False,
644
+ help=(
645
+ 'Enable incremental mode with the configured level (default when unset: definition).'
646
+ ),
647
+ )
648
+ parser.add_argument('-v', '--verbose', action='store_true', help='Include unchanged files.')
649
+ parser.add_argument(
650
+ '--color',
651
+ choices=COLOR_MODES,
652
+ default='auto',
653
+ help=f'Color output {color_default}.',
654
+ )
655
+ parser.add_argument(
656
+ '--no-incremental',
657
+ action='store_true',
658
+ default=False,
659
+ help='Explicitly disable incremental mode.',
660
+ )
661
+ skip_group = parser.add_mutually_exclusive_group()
662
+ skip_group.add_argument(
663
+ '--show-skips',
664
+ action='store_true',
665
+ default=None,
666
+ dest='show_skips',
667
+ help='Show skipped-summary diagnostics in preview output.',
668
+ )
669
+ skip_group.add_argument(
670
+ '--hide-skips',
671
+ action='store_false',
672
+ dest='show_skips',
673
+ help='Hide skipped-summary diagnostics in preview output.',
674
+ )
675
+ wrapping_policy_group.add_argument(
676
+ '--incremental-level',
677
+ choices=INCREMENTAL_LEVELS,
678
+ default=None,
679
+ help='Override incremental level (enables incremental).',
680
+ )
681
+ wrapping_policy_group.add_argument(
682
+ '--summary-start',
683
+ choices=SUMMARY_STARTS,
684
+ default=None,
685
+ help=f'Summary position relative to opening quotes {summary_start_default}.',
686
+ )
687
+ wrapping_policy_group.add_argument(
688
+ '--wrap-summary',
689
+ action=argparse.BooleanOptionalAction,
690
+ default=None,
691
+ help=f'Wrap summary lines that exceed the target length {wrap_summary_default}.',
692
+ )
693
+ wrapping_policy_group.add_argument(
694
+ '--fill-style',
695
+ choices=FILL_STYLES,
696
+ default=None,
697
+ help=f'Fill style: balanced, greedy, or smart {fill_style_default}.',
698
+ )
699
+ wrapping_policy_group.add_argument(
700
+ '--smart-slack',
701
+ type=int,
702
+ default=None,
703
+ help=f'Smart-fill break-repair budget {smart_slack_default}.',
704
+ )
705
+ wrapping_policy_group.add_argument(
706
+ '--reflow',
707
+ choices=REFLOW_MODES,
708
+ default=None,
709
+ help=f'Reflow mode: none, cautious, or full {reflow_default}.',
710
+ )
711
+ style_group.add_argument(
712
+ '--style',
713
+ choices=sorted(get_styles()),
714
+ help='Override docstring style.',
715
+ )
716
+ style_group.add_argument(
717
+ '--convert',
718
+ choices=sorted(get_styles()),
719
+ help='Convert docstrings to the specified style.',
720
+ )
721
+ style_group.add_argument(
722
+ '--strip-types',
723
+ action='store_true',
724
+ default=False,
725
+ help='Strip inline type annotations from docstrings.',
726
+ )
727
+ statistics_group.add_argument(
728
+ '--stats-sections',
729
+ type=_parse_stats_sections,
730
+ default=None,
731
+ metavar='SECTION[,SECTION...]',
732
+ help='Stats body sections, shown once in the listed order.',
733
+ )
734
+ statistics_group.add_argument(
735
+ '--stats-longest-per-category',
736
+ type=_parse_stats_longest,
737
+ default=None,
738
+ metavar='N',
739
+ help='Longest lines shown per wrappable category in stats mode (default: 3).',
740
+ )
741
+ return parser
742
+
743
+
744
+ def _print_version() -> int:
745
+ """Print the installed distribution version without other work."""
746
+ import importlib.metadata # noqa: PLC0415
747
+
748
+ try:
749
+ version = importlib.metadata.version('docwrap')
750
+ except importlib.metadata.PackageNotFoundError:
751
+ print('error: installed distribution metadata for docwrap is unavailable', file=sys.stderr)
752
+ return _EXIT_ERROR
753
+ print(f'docwrap {version}')
754
+ return _EXIT_OK
755
+
756
+
757
+ def _resolve_help_config() -> ResolvedConfig:
758
+ """Resolve composed config from cwd for help-text defaults."""
759
+ with render_config_warnings():
760
+ return find_config(Path())
761
+
762
+
763
+ def _resolve_incremental(
764
+ args: argparse.Namespace,
765
+ file_config_incremental: IncrementalLevel | bool | None,
766
+ ) -> tuple[IncrementalLevel | None, bool]:
767
+ """Resolve effective incremental level and whether it was explicit."""
768
+ level = resolve_incremental_level(
769
+ override=_incremental_override(args),
770
+ explicit_level=args.incremental_level,
771
+ config_value=file_config_incremental,
772
+ )
773
+ is_explicit = bool(
774
+ args.no_incremental or args.incremental_level is not None or args.incremental
775
+ )
776
+ return level, is_explicit
777
+
778
+
779
+ def _resolve_mode(args: argparse.Namespace) -> tuple[OutputMode, DetailLevel]:
780
+ """Determine output mode and detail level from parsed args."""
781
+ if args.preview:
782
+ return 'preview', 'diff'
783
+ if args.diff:
784
+ return 'diff', 'summary'
785
+ if args.stats:
786
+ return 'stats', 'summary'
787
+ if args.write:
788
+ return 'write', 'summary'
789
+ if args.json:
790
+ return 'json', 'diff'
791
+ return 'summary', 'summary'
792
+
793
+
794
+ def _run_hook(argv: list[str]) -> int:
795
+ """Handle the hook subcommand."""
796
+ hook_parser = _build_hook_parser()
797
+ args = hook_parser.parse_args(argv)
798
+
799
+ if sys.stdin.isatty():
800
+ print('docwrap hook: reads the host hook payload as JSON from stdin')
801
+ print('See docs/hook.md for configuration.')
802
+ return 0
803
+
804
+ try:
805
+ stdin_data = sys.stdin.read()
806
+ except (ValueError, OSError):
807
+ return 0
808
+
809
+ from docwrap._hook import run_hook # noqa: PLC0415
810
+
811
+ return run_hook(
812
+ stdin_data,
813
+ host=args.agent,
814
+ event=args.event,
815
+ allow_on_failure=args.allow_on_failure,
816
+ incremental=_incremental_override(args),
817
+ )
818
+
819
+
820
+ def _validate_args(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
821
+ """Validate parsed arguments that argparse cannot constrain directly."""
822
+ if args.line_length is not None and args.line_length < 1:
823
+ parser.error('--length must be a positive integer')
824
+
825
+ if not args.stats and (
826
+ args.stats_sections is not None or args.stats_longest_per_category is not None
827
+ ):
828
+ parser.error('--stats-sections and --stats-longest-per-category require --stats')
829
+
830
+ if args.smart_slack is not None and args.smart_slack < 0:
831
+ parser.error('--smart-slack must be a non-negative integer')
832
+
833
+ incr_count = sum([args.incremental, args.incremental_level is not None, args.no_incremental])
834
+ if incr_count > 1:
835
+ parser.error(
836
+ '-i/--incremental, --incremental-level, and --no-incremental are mutually exclusive'
837
+ )
838
+
839
+
840
+ def _wrap_file_once(
841
+ path: Path,
842
+ overrides: WrapOverrides,
843
+ file_plan: _FilePlan,
844
+ incr_level: IncrementalLevel | None,
845
+ detail: DetailLevel = 'summary',
846
+ ) -> _WrappedFile:
847
+ """Read, resolve config, and wrap a single file."""
848
+ prepared = file_plan.prepared
849
+ if prepared is None:
850
+ read_result = read_file_result(path)
851
+ raw = read_result.raw
852
+ source = read_result.source
853
+ encoding = read_result.encoding
854
+ else:
855
+ raw = prepared.raw
856
+ source = prepared.source
857
+ encoding = prepared.encoding
858
+ with render_config_warnings():
859
+ resolved = find_config(path)
860
+ eff_cfg = effective_config(resolved.config, overrides)
861
+ show_skips = overrides.show_skips if overrides.show_skips is not None else resolved.show_skips
862
+ eligible_target = derive_eligible_target(incr_level)
863
+ report = wrap_source_report_eligible(
864
+ source,
865
+ config=eff_cfg,
866
+ detail=detail,
867
+ eligible_ranges=file_plan.eligible_ranges,
868
+ eligible_target=eligible_target,
869
+ )
870
+ return _WrappedFile(
871
+ source=source,
872
+ encoding=encoding,
873
+ config=eff_cfg,
874
+ report=report,
875
+ raw=raw,
876
+ show_skips=show_skips,
877
+ )
878
+
879
+
880
+ def _process_file(
881
+ path: Path,
882
+ overrides: WrapOverrides,
883
+ file_plan: _FilePlan,
884
+ incr_level: IncrementalLevel | None,
885
+ options: _FileModeOptions,
886
+ ) -> FileResult:
887
+ """Process a single file."""
888
+ try:
889
+ wf = _wrap_file_once(
890
+ path,
891
+ overrides,
892
+ file_plan,
893
+ incr_level,
894
+ detail=options.detail,
895
+ )
896
+ except UnknownStyleError as error:
897
+ print(f'error: {visible_controls(str(error))}', file=sys.stderr)
898
+ return FileResult(status=_EXIT_ERROR, path=path)
899
+ except (SyntaxError, UnicodeDecodeError, ValueError, OSError) as e:
900
+ print(
901
+ f'error: {visible_controls(str(path))}: {visible_controls(str(e))}',
902
+ file=sys.stderr,
903
+ )
904
+ return FileResult(status=_EXIT_ERROR, path=path)
905
+
906
+ report = wf.report
907
+ if report.parse_error:
908
+ print(f'error: {visible_controls(str(path))}: failed to parse', file=sys.stderr)
909
+ return FileResult(status=_EXIT_ERROR, path=path)
910
+
911
+ from docwrap._display._skips import format_safety_skip # noqa: PLC0415
912
+
913
+ for skip in report.skips:
914
+ print(
915
+ format_safety_skip(str(path), skip),
916
+ file=sys.stderr,
917
+ )
918
+
919
+ effective_length = wf.config.line_length
920
+ show_skips = wf.show_skips
921
+ result = report.source
922
+ docstrings = report.docstrings if options.mode == 'preview' else ()
923
+
924
+ if result == wf.source:
925
+ return FileResult(
926
+ status=_EXIT_OK,
927
+ path=path,
928
+ line_length=effective_length,
929
+ total=report.total,
930
+ wrapped=report.wrapped,
931
+ skipped_summaries=report.skipped_summaries,
932
+ docstrings=docstrings,
933
+ show_skips=show_skips,
934
+ )
935
+
936
+ if options.mode == 'write':
937
+ try:
938
+ write_preserving_newline(path, result, wf.encoding, raw=wf.raw)
939
+ except OSError as e:
940
+ print(
941
+ f'error: {visible_controls(str(path))}: {visible_controls(str(e))}',
942
+ file=sys.stderr,
943
+ )
944
+ return FileResult(
945
+ status=_EXIT_ERROR,
946
+ path=path,
947
+ line_length=effective_length,
948
+ total=report.total,
949
+ wrapped=report.wrapped,
950
+ skipped_summaries=report.skipped_summaries,
951
+ docstrings=docstrings,
952
+ show_skips=show_skips,
953
+ )
954
+
955
+ if options.mode == 'diff':
956
+ from docwrap._display import iter_diff # noqa: PLC0415
957
+
958
+ sys.stdout.writelines(iter_diff(wf.source, result, str(path), color=options.color))
959
+
960
+ status = _EXIT_OK if options.mode == 'write' else _EXIT_CHANGED
961
+ return FileResult(
962
+ status=status,
963
+ path=path,
964
+ line_length=effective_length,
965
+ total=report.total,
966
+ wrapped=report.wrapped,
967
+ skipped_summaries=report.skipped_summaries,
968
+ docstrings=docstrings,
969
+ show_skips=show_skips,
970
+ )
971
+
972
+
973
+ def _process_file_json(
974
+ path: Path,
975
+ overrides: WrapOverrides,
976
+ file_plan: _FilePlan,
977
+ incr_level: IncrementalLevel | None,
978
+ ) -> 'JsonFileRecord':
979
+ """Process a single file for JSON output."""
980
+ from docwrap._display._json import ( # noqa: PLC0415
981
+ json_error_record,
982
+ json_success_record,
983
+ )
984
+
985
+ str_path = str(path)
986
+
987
+ try:
988
+ wf = _wrap_file_once(
989
+ path,
990
+ overrides,
991
+ file_plan,
992
+ incr_level,
993
+ detail='diff',
994
+ )
995
+ except (OSError, UnicodeDecodeError) as e:
996
+ return json_error_record(str_path, 'error', str(e))
997
+ except (SyntaxError, tokenize.TokenError, ValueError) as e:
998
+ return json_error_record(str_path, 'invalid', str(e))
999
+
1000
+ report = wf.report
1001
+ if report.parse_error:
1002
+ return json_error_record(str_path, 'invalid', f'{path}: failed to parse')
1003
+
1004
+ return json_success_record(
1005
+ str_path, report, wf.config.line_length, changed=report.source != wf.source
1006
+ )
1007
+
1008
+
1009
+ def _run_file_mode(
1010
+ overrides: WrapOverrides,
1011
+ files: list[Path],
1012
+ incr_ctx: _IncrementalContext,
1013
+ opts: _FileModeOptions,
1014
+ ) -> int:
1015
+ """Handle non-JSON, non-stats output modes (preview, diff, write, summary)."""
1016
+ from docwrap._display import print_preview, print_summary # noqa: PLC0415
1017
+
1018
+ results: list[FileResult] = []
1019
+ for path in files:
1020
+ if stdout_closed():
1021
+ break
1022
+ file_plan = _compute_file_plan(path, incr_ctx)
1023
+ if file_plan.state in ('no-hunks', 'no-eligible'):
1024
+ continue
1025
+ if file_plan.state == 'failed':
1026
+ assert file_plan.diagnostic is not None # noqa: S101 -- failed-plan invariant
1027
+ print(
1028
+ f'error: {visible_controls(str(path))}: {visible_controls(file_plan.diagnostic)}',
1029
+ file=sys.stderr,
1030
+ )
1031
+ results.append(FileResult(status=_EXIT_ERROR, path=path))
1032
+ continue
1033
+ result = _process_file(
1034
+ path,
1035
+ overrides,
1036
+ file_plan,
1037
+ incr_ctx.level,
1038
+ opts,
1039
+ )
1040
+ results.append(result)
1041
+
1042
+ if opts.mode != 'diff':
1043
+ if opts.mode in ('summary', 'write'):
1044
+ print_summary(results=results, verbose=opts.verbose, color=opts.color)
1045
+ elif opts.mode == 'preview':
1046
+ print_preview(results=results, color=opts.color)
1047
+
1048
+ has_error = any(r.status == _EXIT_ERROR for r in results)
1049
+ has_changes = any(r.status == _EXIT_CHANGED for r in results)
1050
+
1051
+ if has_error:
1052
+ return _EXIT_ERROR
1053
+ if has_changes:
1054
+ return _EXIT_CHANGED
1055
+ return _EXIT_OK
1056
+
1057
+
1058
+ def _run_json_mode(
1059
+ overrides: WrapOverrides,
1060
+ files: list[Path],
1061
+ incr_ctx: _IncrementalContext,
1062
+ ) -> int:
1063
+ """Handle JSON output mode."""
1064
+ from docwrap._display import format_json, json_error_record # noqa: PLC0415
1065
+
1066
+ logging.getLogger('docwrap').setLevel(logging.CRITICAL)
1067
+ records: list[JsonFileRecord] = []
1068
+ for path in files:
1069
+ file_plan = _compute_file_plan(path, incr_ctx)
1070
+ if file_plan.state in ('no-hunks', 'no-eligible'):
1071
+ continue
1072
+ if file_plan.state == 'failed':
1073
+ assert file_plan.failure_kind is not None # noqa: S101 -- failed-plan invariant
1074
+ assert file_plan.diagnostic is not None # noqa: S101 -- failed-plan invariant
1075
+ records.append(
1076
+ json_error_record(
1077
+ str(path),
1078
+ file_plan.failure_kind,
1079
+ file_plan.diagnostic,
1080
+ )
1081
+ )
1082
+ continue
1083
+ records.append(
1084
+ _process_file_json(
1085
+ path,
1086
+ overrides,
1087
+ file_plan,
1088
+ incr_ctx.level,
1089
+ )
1090
+ )
1091
+ print(format_json(records))
1092
+ has_error = any(r['status'] in ('error', 'invalid') for r in records)
1093
+ has_changes = any(r['status'] == 'changed' for r in records)
1094
+ if has_error:
1095
+ return _EXIT_ERROR
1096
+ if has_changes:
1097
+ return _EXIT_CHANGED
1098
+ return _EXIT_OK
1099
+
1100
+
1101
+ def _wrap_overrides(args: argparse.Namespace) -> WrapOverrides:
1102
+ """Translate CLI wrapping options into configuration overrides."""
1103
+ return WrapOverrides(
1104
+ line_length=args.line_length,
1105
+ style=args.style,
1106
+ convert=args.convert,
1107
+ fill_style=args.fill_style,
1108
+ smart_slack=args.smart_slack,
1109
+ show_skips=args.show_skips,
1110
+ strip_types=args.strip_types,
1111
+ summary_start=args.summary_start,
1112
+ wrap_summary=args.wrap_summary,
1113
+ reflow=args.reflow,
1114
+ )
1115
+
1116
+
1117
+ def _print_stats(
1118
+ *,
1119
+ files: list[Path],
1120
+ args: argparse.Namespace,
1121
+ ) -> None:
1122
+ """Collect and print stats output."""
1123
+ if not files:
1124
+ return
1125
+
1126
+ with render_config_warnings():
1127
+ stats_cfg = find_stats_config(files[0])
1128
+ sections = args.stats_sections if args.stats_sections is not None else stats_cfg.sections
1129
+ longest_per_category = (
1130
+ args.stats_longest_per_category
1131
+ if args.stats_longest_per_category is not None
1132
+ else stats_cfg.longest_per_category
1133
+ )
1134
+ needs_impact = 'impact' in sections or 'recommendations' in sections
1135
+
1136
+ from docwrap._display import print_stats # noqa: PLC0415
1137
+
1138
+ with render_config_warnings():
1139
+ resolved = find_config(files[0])
1140
+ first_cfg = resolved.config
1141
+ overrides = _wrap_overrides(args)
1142
+ first_effective_cfg = effective_config(first_cfg, overrides)
1143
+
1144
+ if args.line_length is not None:
1145
+ effective_line_length: int = args.line_length
1146
+ line_length_source: LineLengthSource = LineLengthSource.CLI
1147
+ else:
1148
+ effective_line_length = first_effective_cfg.line_length
1149
+ line_length_source = resolved.line_length_source
1150
+
1151
+ extra_lengths: tuple[int, ...] = ()
1152
+ if needs_impact and line_length_source in (
1153
+ LineLengthSource.CLI,
1154
+ LineLengthSource.DOCWRAP,
1155
+ ):
1156
+ extra_lengths = (effective_line_length,)
1157
+
1158
+ data = _collect_stats(
1159
+ files,
1160
+ overrides,
1161
+ extra_lengths=extra_lengths,
1162
+ needs_impact=needs_impact,
1163
+ )
1164
+ with render_config_warnings():
1165
+ color_config = find_color_config()
1166
+ color = should_color(sys.stdout, cli_flag=args.color, config_value=color_config)
1167
+
1168
+ print_stats(
1169
+ data,
1170
+ line_length=effective_line_length,
1171
+ color=color,
1172
+ line_length_source=line_length_source,
1173
+ sections=sections,
1174
+ longest_per_category=longest_per_category,
1175
+ )
1176
+
1177
+
1178
+ def _run_stats_mode(args: argparse.Namespace, files: list[Path]) -> int:
1179
+ """Handle stats output mode."""
1180
+ try:
1181
+ _print_stats(files=files, args=args)
1182
+ except UnknownStyleError as error:
1183
+ print(f'error: {visible_controls(str(error))}', file=sys.stderr)
1184
+ return _EXIT_ERROR
1185
+ return _EXIT_OK
1186
+
1187
+
1188
+ def _run_cli(argv: Sequence[str] | None) -> int:
1189
+ """Process CLI arguments and run the requested command."""
1190
+ ensure_utf8_stdio()
1191
+ args_list = sys.argv[1:] if argv is None else list(argv)
1192
+ if args_list and args_list[0] == 'hook':
1193
+ return _run_hook(args_list[1:])
1194
+ help_cfg = _resolve_help_config() if _argv_requests_help(args_list) else None
1195
+ parser = _build_parser(help_cfg)
1196
+ args = parser.parse_args(args_list)
1197
+ if args.version:
1198
+ return _print_version()
1199
+ _validate_args(parser, args)
1200
+
1201
+ mode, detail = _resolve_mode(args)
1202
+ files = collect_files(args.paths)
1203
+ if mode == 'stats':
1204
+ return _run_stats_mode(args, files)
1205
+
1206
+ overrides = _wrap_overrides(args)
1207
+
1208
+ if args.no_incremental or args.incremental_level is not None:
1209
+ file_config_incr = None
1210
+ else:
1211
+ with render_config_warnings():
1212
+ file_config_incr = find_incremental_config(files[0]) if files else None
1213
+ incr_level, incr_explicit = _resolve_incremental(args, file_config_incr)
1214
+ incr_outcome = _compute_incremental_context(files, incr_level, incr_explicit)
1215
+ diagnostic = incr_outcome.diagnostic
1216
+
1217
+ if mode == 'json':
1218
+ if diagnostic is not None and diagnostic.fatal:
1219
+ from docwrap._display import format_json, json_error_record # noqa: PLC0415
1220
+
1221
+ print(
1222
+ format_json(
1223
+ [
1224
+ json_error_record(
1225
+ str(files[0]),
1226
+ 'error',
1227
+ diagnostic.message,
1228
+ )
1229
+ ]
1230
+ )
1231
+ )
1232
+ status = _EXIT_ERROR
1233
+ else:
1234
+ assert incr_outcome.context is not None # noqa: S101 -- nonfatal outcome contract
1235
+ status = _run_json_mode(overrides, files, incr_outcome.context)
1236
+ return status
1237
+
1238
+ if diagnostic is not None:
1239
+ prefix = 'error' if diagnostic.fatal else 'warning'
1240
+ print(f'{prefix}: {visible_controls(diagnostic.message)}', file=sys.stderr)
1241
+ if diagnostic.fatal:
1242
+ return _EXIT_ERROR
1243
+
1244
+ assert incr_outcome.context is not None # noqa: S101 -- nonfatal outcome contract
1245
+ incr_ctx = incr_outcome.context
1246
+
1247
+ with render_config_warnings():
1248
+ color_config = find_color_config()
1249
+ color = should_color(sys.stdout, cli_flag=args.color, config_value=color_config)
1250
+
1251
+ opts = _FileModeOptions(
1252
+ mode=mode,
1253
+ detail=detail,
1254
+ verbose=args.verbose,
1255
+ color=color,
1256
+ )
1257
+ return _run_file_mode(overrides, files, incr_ctx, opts)
1258
+
1259
+
1260
+ def main(argv: Sequence[str] | None = None) -> int:
1261
+ """Run the CLI command and emit its output and diagnostics.
1262
+
1263
+ Configure standard streams for UTF-8 where supported before processing
1264
+ arguments. If stdout closes, discard remaining output, examine no further
1265
+ files, and return the status of work completed so far.
1266
+
1267
+ Args:
1268
+ argv: Command-line arguments, or `None` to use `sys.argv[1:]`.
1269
+
1270
+ Returns:
1271
+ For file and statistics commands, 0 on success, including successful
1272
+ writes; 1 when changes remain pending; or 2 on processing errors.
1273
+ Hook commands use host-specific exit statuses, or 2 when no functioning
1274
+ adapter is available.
1275
+
1276
+ Raises:
1277
+ SystemExit: When argument parsing displays help or rejects arguments.
1278
+ """
1279
+ with closable_stdout():
1280
+ return _run_cli(argv)
1281
+
1282
+
1283
+ if __name__ == '__main__':
1284
+ sys.exit(main())