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.
- docwrap/__init__.py +59 -0
- docwrap/__main__.py +8 -0
- docwrap/_canonical.py +66 -0
- docwrap/_classify.py +56 -0
- docwrap/_cli.py +1284 -0
- docwrap/_color.py +135 -0
- docwrap/_config.py +615 -0
- docwrap/_convert.py +1324 -0
- docwrap/_core/__init__.py +27 -0
- docwrap/_core/_codeblock.py +214 -0
- docwrap/_core/_discover.py +466 -0
- docwrap/_core/_escape.py +204 -0
- docwrap/_core/_incremental.py +402 -0
- docwrap/_core/_normalize.py +306 -0
- docwrap/_core/_parse.py +594 -0
- docwrap/_core/_process.py +826 -0
- docwrap/_core/_wrap.py +1714 -0
- docwrap/_detect.py +98 -0
- docwrap/_detector.py +166 -0
- docwrap/_display/__init__.py +17 -0
- docwrap/_display/_diff.py +62 -0
- docwrap/_display/_json.py +168 -0
- docwrap/_display/_print.py +93 -0
- docwrap/_display/_recommend.py +165 -0
- docwrap/_display/_skips.py +17 -0
- docwrap/_display/_stats.py +1075 -0
- docwrap/_display/_summary.py +213 -0
- docwrap/_display/_text.py +17 -0
- docwrap/_downstream.py +181 -0
- docwrap/_exceptions.py +52 -0
- docwrap/_files.py +304 -0
- docwrap/_git.py +764 -0
- docwrap/_git_utils.py +76 -0
- docwrap/_grammar.py +800 -0
- docwrap/_hook.py +1001 -0
- docwrap/_hosts/__init__.py +50 -0
- docwrap/_shell.py +73 -0
- docwrap/_shellparse.py +643 -0
- docwrap/_stats.py +529 -0
- docwrap/_styles/__init__.py +335 -0
- docwrap/_styles/_google.py +423 -0
- docwrap/_styles/_numpy.py +292 -0
- docwrap/_styles/_sphinx.py +324 -0
- docwrap/_types.py +423 -0
- docwrap/_validate.py +62 -0
- docwrap/hooks/__init__.py +245 -0
- docwrap/py.typed +0 -0
- docwrap-0.1.0.dist-info/METADATA +126 -0
- docwrap-0.1.0.dist-info/RECORD +58 -0
- docwrap-0.1.0.dist-info/WHEEL +4 -0
- docwrap-0.1.0.dist-info/entry_points.txt +7 -0
- docwrap-0.1.0.dist-info/licenses/LICENSE +21 -0
- plugins/agy/_docwrap/__init__.py +1 -0
- plugins/agy/_docwrap/_adapter.py +122 -0
- plugins/claude/_docwrap/__init__.py +1 -0
- plugins/claude/_docwrap/_adapter.py +106 -0
- plugins/codex/_docwrap/__init__.py +1 -0
- 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())
|