doc-code 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.
- doc_code/__init__.py +3 -0
- doc_code/ai.py +206 -0
- doc_code/cli.py +593 -0
- doc_code/config.py +362 -0
- doc_code/editor.py +496 -0
- doc_code/errors.py +21 -0
- doc_code/git.py +74 -0
- doc_code/py.typed +1 -0
- doc_code/scope.py +105 -0
- doc_code/symbols.py +594 -0
- doc_code-0.1.0.dist-info/METADATA +138 -0
- doc_code-0.1.0.dist-info/RECORD +16 -0
- doc_code-0.1.0.dist-info/WHEEL +5 -0
- doc_code-0.1.0.dist-info/entry_points.txt +2 -0
- doc_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- doc_code-0.1.0.dist-info/top_level.txt +1 -0
doc_code/cli.py
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
"""Typer command-line interface for Doc Code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from time import perf_counter
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from .ai import documentation_for
|
|
16
|
+
from .config import TEMPLATE, Settings, load
|
|
17
|
+
from .editor import (
|
|
18
|
+
PreparedFile,
|
|
19
|
+
apply,
|
|
20
|
+
can_insert_documentation,
|
|
21
|
+
prepare,
|
|
22
|
+
preview_documentation,
|
|
23
|
+
validation_command,
|
|
24
|
+
)
|
|
25
|
+
from .errors import (
|
|
26
|
+
AIProviderError,
|
|
27
|
+
AITimeoutError,
|
|
28
|
+
DocGubError,
|
|
29
|
+
InvalidAIResponseError,
|
|
30
|
+
NoEligibleFilesError,
|
|
31
|
+
)
|
|
32
|
+
from .git import GitRepo
|
|
33
|
+
from .scope import resolve
|
|
34
|
+
from .symbols import Documentation, Symbol, discover, needs_documentation, source_for_symbol
|
|
35
|
+
|
|
36
|
+
app = typer.Typer(add_completion=False, no_args_is_help=False)
|
|
37
|
+
config_app = typer.Typer(help="Create and inspect Doc Code configuration.", no_args_is_help=True)
|
|
38
|
+
MAX_AI_ATTEMPTS = 3
|
|
39
|
+
_DUPLICATE_SYMBOL_SUFFIX = re.compile(r"^(?P<name>.+)@L\d+:\d+$")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@contextmanager
|
|
43
|
+
def _loading(message: str):
|
|
44
|
+
"""Show one stable progress line while the AI responds interactively."""
|
|
45
|
+
if not sys.stderr.isatty():
|
|
46
|
+
yield
|
|
47
|
+
return
|
|
48
|
+
lines = message.splitlines()
|
|
49
|
+
typer.secho(f"\r{message}", fg=typer.colors.CYAN, nl=False, err=True)
|
|
50
|
+
try:
|
|
51
|
+
yield
|
|
52
|
+
finally:
|
|
53
|
+
if len(lines) > 1:
|
|
54
|
+
# Move up for each extra line and clear from cursor to end of screen
|
|
55
|
+
typer.echo(f"\033[{len(lines) - 1}A\033[J", nl=False, err=True)
|
|
56
|
+
else:
|
|
57
|
+
typer.echo("\r" + " " * len(message) + "\r", nl=False, err=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _show(item: PreparedFile, model: str, elapsed: float, show_diff: bool = False) -> None:
|
|
61
|
+
"""Display a generation summary and optionally its unified diff."""
|
|
62
|
+
relative = (item.display_path or item.path).as_posix()
|
|
63
|
+
typer.echo()
|
|
64
|
+
typer.secho(relative, fg=typer.colors.CYAN, bold=True)
|
|
65
|
+
typer.echo(
|
|
66
|
+
f"Symbols: {len(item.symbols)} | changed: {len(item.changed)} | "
|
|
67
|
+
f"ignored: {len(item.ignored)}"
|
|
68
|
+
)
|
|
69
|
+
typer.echo(f"Model: {model} | Generated in {elapsed:.2f}s")
|
|
70
|
+
if item.diff:
|
|
71
|
+
typer.secho("Status: documentation changes generated.", fg=typer.colors.GREEN)
|
|
72
|
+
if show_diff:
|
|
73
|
+
typer.echo(item.diff, nl=not item.diff.endswith("\n"))
|
|
74
|
+
else:
|
|
75
|
+
typer.secho("No documentation changes needed.", fg=typer.colors.BRIGHT_BLACK)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _show_skipped(relative: str, reason: str) -> None:
|
|
79
|
+
"""Report a file-level generation failure without stopping the remaining scope."""
|
|
80
|
+
typer.echo()
|
|
81
|
+
typer.secho(f"Skipped documentation: {relative}", fg=typer.colors.YELLOW, bold=True)
|
|
82
|
+
typer.secho(f"Reason: {reason}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _show_check(missing: dict[str, list[str]]) -> None:
|
|
86
|
+
"""Print the actionable output used by CI and local check runs."""
|
|
87
|
+
if not missing:
|
|
88
|
+
typer.secho("Documentation check passed.", fg=typer.colors.GREEN, bold=True)
|
|
89
|
+
return
|
|
90
|
+
typer.secho("Documentation is missing:", fg=typer.colors.YELLOW, bold=True)
|
|
91
|
+
for relative, symbols in missing.items():
|
|
92
|
+
typer.echo(f" {relative}: {', '.join(symbols)}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _model_for_attempt(candidates: tuple[str, ...], attempt: int) -> str:
|
|
96
|
+
"""Cycle configured fallback models instead of pinning later retries to the last one."""
|
|
97
|
+
return candidates[(attempt - 1) % len(candidates)]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _symbol_identity(symbol: Symbol) -> tuple[str, str]:
|
|
101
|
+
"""Return a symbol identity that remains stable when inserted lines shift it."""
|
|
102
|
+
match = _DUPLICATE_SYMBOL_SUFFIX.fullmatch(symbol.name)
|
|
103
|
+
name = match.group("name") if match else symbol.name
|
|
104
|
+
return name, symbol.kind
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _symbol_occurrence(symbols: list[Symbol], target: Symbol) -> int | None:
|
|
108
|
+
"""Return the ordinal needed to locate a duplicate symbol after an edit."""
|
|
109
|
+
matches = [symbol for symbol in symbols if _symbol_identity(symbol) == _symbol_identity(target)]
|
|
110
|
+
if len(matches) == 1:
|
|
111
|
+
return None
|
|
112
|
+
return matches.index(target)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _undocumented_symbols(content: str, suffix: str, filename: str = "<unknown>") -> list[str]:
|
|
116
|
+
"""Return symbols that make `--check` fail without requesting AI output."""
|
|
117
|
+
return [symbol.name for symbol in discover(content, suffix, filename) if not symbol.has_doc]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _syntax_error_message(exc: SyntaxError) -> str:
|
|
121
|
+
"""Render Python syntax errors with the file, source line, and error column."""
|
|
122
|
+
location = f"{exc.filename or '<unknown>'}:{exc.lineno or '?'}"
|
|
123
|
+
message = f"{location}: {exc.msg}"
|
|
124
|
+
if not exc.text:
|
|
125
|
+
return message
|
|
126
|
+
source = exc.text.rstrip("\n")
|
|
127
|
+
column = max((exc.offset or 1) - 1, 0)
|
|
128
|
+
return f"{message}\n {source}\n {' ' * column}^"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _read_source(path: Path) -> str:
|
|
132
|
+
"""Read UTF-8 source and convert filesystem failures to domain errors."""
|
|
133
|
+
try:
|
|
134
|
+
return path.read_text(encoding="utf-8")
|
|
135
|
+
except OSError as exc:
|
|
136
|
+
raise DocGubError(f"Unable to read source file {path}: {exc}") from exc
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class _RunState:
|
|
141
|
+
"""Track file-level results across an incremental run."""
|
|
142
|
+
|
|
143
|
+
completed: list[str]
|
|
144
|
+
skipped: list[str]
|
|
145
|
+
|
|
146
|
+
def mark_completed(self, relative: str) -> None:
|
|
147
|
+
"""Record an applied file once."""
|
|
148
|
+
if relative not in self.completed:
|
|
149
|
+
self.completed.append(relative)
|
|
150
|
+
|
|
151
|
+
def mark_skipped(self, relative: str, reason: str) -> None:
|
|
152
|
+
"""Record and display a file-level failure."""
|
|
153
|
+
self.skipped.append(relative)
|
|
154
|
+
_show_skipped(relative, reason)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _run_check(repo: GitRepo, files: list[str]) -> None:
|
|
158
|
+
"""Inspect selected files without calling an AI provider."""
|
|
159
|
+
missing: dict[str, list[str]] = {}
|
|
160
|
+
inspection_failures: list[str] = []
|
|
161
|
+
for relative in files:
|
|
162
|
+
try:
|
|
163
|
+
content = _read_source(repo.root / relative)
|
|
164
|
+
undocumented = _undocumented_symbols(content, Path(relative).suffix, relative)
|
|
165
|
+
except (DocGubError, UnicodeDecodeError, SyntaxError) as exc:
|
|
166
|
+
inspection_failures.append(relative)
|
|
167
|
+
reason = _syntax_error_message(exc) if isinstance(exc, SyntaxError) else str(exc)
|
|
168
|
+
_show_skipped(relative, reason)
|
|
169
|
+
continue
|
|
170
|
+
if undocumented:
|
|
171
|
+
missing[relative] = undocumented
|
|
172
|
+
if not missing and not inspection_failures:
|
|
173
|
+
_show_check({})
|
|
174
|
+
return
|
|
175
|
+
if missing:
|
|
176
|
+
_show_check(missing)
|
|
177
|
+
if inspection_failures:
|
|
178
|
+
typer.secho(
|
|
179
|
+
f"Documentation check could not inspect {len(inspection_failures)} file(s).",
|
|
180
|
+
fg=typer.colors.RED,
|
|
181
|
+
)
|
|
182
|
+
raise typer.Exit(1)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _require_interactive_confirmation(settings: Settings, yes: bool) -> None:
|
|
186
|
+
"""Ensure per-docstring confirmation can be requested before generating output."""
|
|
187
|
+
if settings.output != "apply" or not settings.confirm or yes:
|
|
188
|
+
return
|
|
189
|
+
if not sys.stdin.isatty():
|
|
190
|
+
raise DocGubError(
|
|
191
|
+
"Confirmation requires an interactive terminal; use --yes for automation."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _confirm_generated_docstring(relative: str, symbol: Symbol, documentation: str) -> bool:
|
|
196
|
+
"""Show one rendered docstring and ask whether it should be written."""
|
|
197
|
+
if not sys.stdin.isatty():
|
|
198
|
+
raise DocGubError(
|
|
199
|
+
"Confirmation requires an interactive terminal; use --yes for automation."
|
|
200
|
+
)
|
|
201
|
+
typer.echo()
|
|
202
|
+
typer.secho(f"{relative}:{symbol.name}", fg=typer.colors.CYAN, bold=True)
|
|
203
|
+
typer.echo(documentation)
|
|
204
|
+
confirmed = typer.confirm("Apply this docstring?", default=False)
|
|
205
|
+
if not confirmed:
|
|
206
|
+
typer.secho(f"Not applied: {relative}:{symbol.name}", fg=typer.colors.YELLOW)
|
|
207
|
+
return confirmed
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _confirm_generated_file(item: PreparedFile) -> bool:
|
|
211
|
+
"""Confirm a displayed generated diff immediately before applying it."""
|
|
212
|
+
if not sys.stdin.isatty():
|
|
213
|
+
raise DocGubError(
|
|
214
|
+
"Confirmation requires an interactive terminal; use --yes for automation."
|
|
215
|
+
)
|
|
216
|
+
relative = (item.display_path or item.path).as_posix()
|
|
217
|
+
confirmed = typer.confirm(f"Apply the generated documentation to {relative}?", default=False)
|
|
218
|
+
if not confirmed:
|
|
219
|
+
typer.secho(f"Not applied: {relative}", fg=typer.colors.YELLOW)
|
|
220
|
+
return confirmed
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _request_batches(
|
|
224
|
+
content: str,
|
|
225
|
+
targets: list[Symbol],
|
|
226
|
+
path: Path,
|
|
227
|
+
relative: str,
|
|
228
|
+
settings: Settings,
|
|
229
|
+
) -> list[tuple[str, list[Symbol]]]:
|
|
230
|
+
"""Build file- or symbol-scoped provider requests."""
|
|
231
|
+
if settings.request_scope == "file":
|
|
232
|
+
return [(content, targets)]
|
|
233
|
+
return [
|
|
234
|
+
(source_for_symbol(content, symbol, path.suffix, relative), [symbol]) for symbol in targets
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _request_documentation(
|
|
239
|
+
source: str,
|
|
240
|
+
symbols: list[Symbol],
|
|
241
|
+
settings: Settings,
|
|
242
|
+
label: str,
|
|
243
|
+
) -> tuple[dict[str, Documentation], str]:
|
|
244
|
+
"""Request documentation with bounded, model-cycling retries."""
|
|
245
|
+
candidates = settings.model_candidates
|
|
246
|
+
last_error: Exception | None = None
|
|
247
|
+
for attempt in range(1, MAX_AI_ATTEMPTS + 1):
|
|
248
|
+
candidate = _model_for_attempt(candidates, attempt)
|
|
249
|
+
try:
|
|
250
|
+
with _loading(
|
|
251
|
+
f"Generating docs [{label}] | model: {candidate} ({attempt}/{MAX_AI_ATTEMPTS})..."
|
|
252
|
+
):
|
|
253
|
+
generated = documentation_for(
|
|
254
|
+
source,
|
|
255
|
+
symbols,
|
|
256
|
+
replace(settings, model=candidate, models=()),
|
|
257
|
+
)
|
|
258
|
+
return generated, candidate
|
|
259
|
+
except (AIProviderError, InvalidAIResponseError) as exc:
|
|
260
|
+
last_error = exc
|
|
261
|
+
if attempt < MAX_AI_ATTEMPTS:
|
|
262
|
+
next_model = _model_for_attempt(candidates, attempt + 1)
|
|
263
|
+
reason = "AI request timed out" if isinstance(exc, AITimeoutError) else str(exc)
|
|
264
|
+
typer.secho(
|
|
265
|
+
f"{reason} with model [{candidate}]. Retrying with model "
|
|
266
|
+
f"[{next_model}] ({attempt + 1}/{MAX_AI_ATTEMPTS})...",
|
|
267
|
+
fg=typer.colors.YELLOW,
|
|
268
|
+
)
|
|
269
|
+
raise AIProviderError(f"generation failed after {MAX_AI_ATTEMPTS} attempts: {last_error}")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _apply_generated_symbol(
|
|
273
|
+
file_path: Path,
|
|
274
|
+
relative: str,
|
|
275
|
+
target: Symbol,
|
|
276
|
+
generated: dict[str, Documentation],
|
|
277
|
+
settings: Settings,
|
|
278
|
+
occurrence: int | None = None,
|
|
279
|
+
confirm: bool = False,
|
|
280
|
+
) -> bool:
|
|
281
|
+
"""Apply one generated symbol against a freshly discovered source tree."""
|
|
282
|
+
current_symbols = discover(_read_source(file_path), file_path.suffix, relative)
|
|
283
|
+
if occurrence is None:
|
|
284
|
+
current_target = next(
|
|
285
|
+
(
|
|
286
|
+
symbol
|
|
287
|
+
for symbol in current_symbols
|
|
288
|
+
if symbol.name == target.name and symbol.kind == target.kind
|
|
289
|
+
),
|
|
290
|
+
None,
|
|
291
|
+
)
|
|
292
|
+
else:
|
|
293
|
+
matches = [
|
|
294
|
+
symbol
|
|
295
|
+
for symbol in current_symbols
|
|
296
|
+
if _symbol_identity(symbol) == _symbol_identity(target)
|
|
297
|
+
]
|
|
298
|
+
current_target = matches[occurrence] if occurrence < len(matches) else None
|
|
299
|
+
if current_target is None:
|
|
300
|
+
raise DocGubError(f"{relative}: symbol `{target.name}` changed during generation.")
|
|
301
|
+
documentation = generated.get(target.name)
|
|
302
|
+
if documentation is None:
|
|
303
|
+
raise DocGubError(f"{relative}: missing generated documentation for `{target.name}`.")
|
|
304
|
+
if confirm:
|
|
305
|
+
preview = preview_documentation(
|
|
306
|
+
file_path,
|
|
307
|
+
_read_source(file_path),
|
|
308
|
+
current_target,
|
|
309
|
+
documentation,
|
|
310
|
+
settings,
|
|
311
|
+
)
|
|
312
|
+
if not _confirm_generated_docstring(relative, current_target, preview):
|
|
313
|
+
return False
|
|
314
|
+
item = prepare(
|
|
315
|
+
file_path,
|
|
316
|
+
current_symbols,
|
|
317
|
+
{current_target.name: documentation},
|
|
318
|
+
settings,
|
|
319
|
+
selected_symbols=[current_target],
|
|
320
|
+
display_path=Path(relative),
|
|
321
|
+
)
|
|
322
|
+
if not item.diff:
|
|
323
|
+
return False
|
|
324
|
+
apply(item)
|
|
325
|
+
return True
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _generate_for_file(
|
|
329
|
+
file_path: Path,
|
|
330
|
+
relative: str,
|
|
331
|
+
content: str,
|
|
332
|
+
targets: list[Symbol],
|
|
333
|
+
settings: Settings,
|
|
334
|
+
state: _RunState,
|
|
335
|
+
apply_incrementally: bool,
|
|
336
|
+
confirm_each_docstring: bool,
|
|
337
|
+
) -> tuple[dict[str, Documentation], str]:
|
|
338
|
+
"""Generate every request for one file and optionally apply symbols incrementally."""
|
|
339
|
+
requests = _request_batches(content, targets, file_path, relative, settings)
|
|
340
|
+
descriptions: dict[str, Documentation] = {}
|
|
341
|
+
candidate = settings.model
|
|
342
|
+
for number, (source, requested_symbols) in enumerate(requests, start=1):
|
|
343
|
+
label = relative
|
|
344
|
+
if settings.request_scope == "symbol":
|
|
345
|
+
label = f"{number}/{len(requests)} {relative}:{requested_symbols[0].name}"
|
|
346
|
+
generated, candidate = _request_documentation(source, requested_symbols, settings, label)
|
|
347
|
+
descriptions.update(generated)
|
|
348
|
+
if (
|
|
349
|
+
apply_incrementally
|
|
350
|
+
and settings.request_scope == "symbol"
|
|
351
|
+
and settings.output == "apply"
|
|
352
|
+
):
|
|
353
|
+
if _apply_generated_symbol(
|
|
354
|
+
file_path,
|
|
355
|
+
relative,
|
|
356
|
+
requested_symbols[0],
|
|
357
|
+
generated,
|
|
358
|
+
settings,
|
|
359
|
+
_symbol_occurrence(targets, requested_symbols[0]),
|
|
360
|
+
confirm=confirm_each_docstring,
|
|
361
|
+
):
|
|
362
|
+
state.mark_completed(relative)
|
|
363
|
+
return descriptions, candidate
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _apply_reviewed_docstrings(
|
|
367
|
+
file_path: Path,
|
|
368
|
+
relative: str,
|
|
369
|
+
targets: list[Symbol],
|
|
370
|
+
descriptions: dict[str, Documentation],
|
|
371
|
+
settings: Settings,
|
|
372
|
+
state: _RunState,
|
|
373
|
+
) -> None:
|
|
374
|
+
"""Review and apply generated docstrings one at a time for a file-scoped request."""
|
|
375
|
+
for target in targets:
|
|
376
|
+
if _apply_generated_symbol(
|
|
377
|
+
file_path,
|
|
378
|
+
relative,
|
|
379
|
+
target,
|
|
380
|
+
descriptions,
|
|
381
|
+
settings,
|
|
382
|
+
_symbol_occurrence(targets, target),
|
|
383
|
+
confirm=True,
|
|
384
|
+
):
|
|
385
|
+
state.mark_completed(relative)
|
|
386
|
+
typer.secho(f"Applied documentation: {relative}:{target.name}", fg=typer.colors.GREEN)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _process_file(
|
|
390
|
+
repo: GitRepo,
|
|
391
|
+
relative: str,
|
|
392
|
+
settings: Settings,
|
|
393
|
+
show_diff: bool,
|
|
394
|
+
state: _RunState,
|
|
395
|
+
yes: bool,
|
|
396
|
+
) -> None:
|
|
397
|
+
"""Discover, generate, validate, and optionally apply documentation for one file."""
|
|
398
|
+
file_path = repo.root / relative
|
|
399
|
+
try:
|
|
400
|
+
content = _read_source(file_path)
|
|
401
|
+
symbols = discover(content, file_path.suffix, relative)
|
|
402
|
+
except (DocGubError, UnicodeDecodeError, SyntaxError) as exc:
|
|
403
|
+
reason = _syntax_error_message(exc) if isinstance(exc, SyntaxError) else str(exc)
|
|
404
|
+
state.mark_skipped(relative, reason)
|
|
405
|
+
return
|
|
406
|
+
targets = [
|
|
407
|
+
symbol
|
|
408
|
+
for symbol in symbols
|
|
409
|
+
if needs_documentation(symbol, settings.coverage)
|
|
410
|
+
and can_insert_documentation(content, symbol, file_path.suffix)
|
|
411
|
+
]
|
|
412
|
+
if not targets:
|
|
413
|
+
item = prepare(file_path, symbols, {}, settings, display_path=Path(relative))
|
|
414
|
+
_show(item, "not used", 0, settings.output == "preview" and show_diff)
|
|
415
|
+
return
|
|
416
|
+
confirm_after_generation = settings.output == "apply" and not settings.confirm and not yes
|
|
417
|
+
confirm_each_docstring = settings.output == "apply" and settings.confirm and not yes
|
|
418
|
+
started = perf_counter()
|
|
419
|
+
try:
|
|
420
|
+
if file_path.suffix != ".py":
|
|
421
|
+
validation_command(file_path.suffix, file_path)
|
|
422
|
+
descriptions, candidate = _generate_for_file(
|
|
423
|
+
file_path,
|
|
424
|
+
relative,
|
|
425
|
+
content,
|
|
426
|
+
targets,
|
|
427
|
+
settings,
|
|
428
|
+
state,
|
|
429
|
+
apply_incrementally=not confirm_after_generation,
|
|
430
|
+
confirm_each_docstring=confirm_each_docstring,
|
|
431
|
+
)
|
|
432
|
+
if (
|
|
433
|
+
settings.request_scope == "symbol"
|
|
434
|
+
and settings.output == "apply"
|
|
435
|
+
and not confirm_after_generation
|
|
436
|
+
):
|
|
437
|
+
return
|
|
438
|
+
if settings.output == "apply" and confirm_each_docstring:
|
|
439
|
+
_apply_reviewed_docstrings(file_path, relative, targets, descriptions, settings, state)
|
|
440
|
+
return
|
|
441
|
+
item = prepare(file_path, symbols, descriptions, settings, display_path=Path(relative))
|
|
442
|
+
_show(
|
|
443
|
+
item,
|
|
444
|
+
candidate,
|
|
445
|
+
perf_counter() - started,
|
|
446
|
+
(settings.output == "preview" and show_diff) or confirm_after_generation,
|
|
447
|
+
)
|
|
448
|
+
if settings.output == "apply" and item.diff:
|
|
449
|
+
if confirm_after_generation and not _confirm_generated_file(item):
|
|
450
|
+
return
|
|
451
|
+
apply(item)
|
|
452
|
+
state.mark_completed(relative)
|
|
453
|
+
typer.secho(f"Applied documentation: {relative}", fg=typer.colors.GREEN)
|
|
454
|
+
except DocGubError as exc:
|
|
455
|
+
state.mark_skipped(relative, str(exc))
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _finish_run(settings: Settings, state: _RunState, continue_on_error: bool) -> None:
|
|
459
|
+
"""Display aggregate results and enforce the partial-failure exit policy."""
|
|
460
|
+
if settings.output == "apply":
|
|
461
|
+
typer.secho(
|
|
462
|
+
f"Documentation applied to {len(state.completed)} file(s).",
|
|
463
|
+
fg=typer.colors.GREEN,
|
|
464
|
+
bold=True,
|
|
465
|
+
)
|
|
466
|
+
if state.skipped:
|
|
467
|
+
typer.secho(f"Skipped files: {len(state.skipped)}", fg=typer.colors.YELLOW, bold=True)
|
|
468
|
+
if not continue_on_error:
|
|
469
|
+
raise typer.Exit(1)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@app.command()
|
|
473
|
+
def doc_code(
|
|
474
|
+
paths: list[Path] = typer.Argument(
|
|
475
|
+
None,
|
|
476
|
+
metavar="[PATH]...",
|
|
477
|
+
help="One or more files or directories inside the Git worktree.",
|
|
478
|
+
),
|
|
479
|
+
output: str | None = typer.Option(None, "--output", help="preview (default) or apply."),
|
|
480
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Alias for --output preview."),
|
|
481
|
+
check: bool = typer.Option(
|
|
482
|
+
False,
|
|
483
|
+
"--check",
|
|
484
|
+
help="Exit with status 1 when eligible symbols are undocumented; never calls AI.",
|
|
485
|
+
),
|
|
486
|
+
continue_on_error: bool = typer.Option(
|
|
487
|
+
False,
|
|
488
|
+
"--continue-on-error",
|
|
489
|
+
help="Return status 0 after partial failures when other files can still be processed.",
|
|
490
|
+
),
|
|
491
|
+
show_diff: bool = typer.Option(
|
|
492
|
+
True,
|
|
493
|
+
"--show-diff/--no-show-diff",
|
|
494
|
+
help="Show generated unified diffs in preview mode.",
|
|
495
|
+
),
|
|
496
|
+
yes: bool = typer.Option(
|
|
497
|
+
False, "--yes", "-y", help="Skip interactive confirmation when applying."
|
|
498
|
+
),
|
|
499
|
+
coverage: str | None = typer.Option(None, "--coverage", help="missing, minimal, or all."),
|
|
500
|
+
request_scope: str | None = typer.Option(
|
|
501
|
+
None,
|
|
502
|
+
"--request-scope",
|
|
503
|
+
help="file (default) or symbol; symbol sends one source scope per request.",
|
|
504
|
+
),
|
|
505
|
+
language: str | None = typer.Option(
|
|
506
|
+
None, "--language", help="Language used for generated documentation."
|
|
507
|
+
),
|
|
508
|
+
selection: str | None = typer.Option(None, "--selection", help="changes or repository."),
|
|
509
|
+
python_format: str | None = typer.Option(
|
|
510
|
+
None, "--format", help="google, numpy, or sphinx for Python."
|
|
511
|
+
),
|
|
512
|
+
provider: str | None = typer.Option(None, "--provider", help="openai, gemini, or ollama."),
|
|
513
|
+
model: str | None = typer.Option(None, "--model", help="Model name."),
|
|
514
|
+
timeout_seconds: int | None = typer.Option(None, "--timeout-seconds", min=1),
|
|
515
|
+
max_input_tokens: int | None = typer.Option(None, "--max-input-tokens", min=1),
|
|
516
|
+
context_window_tokens: int | None = typer.Option(None, "--context-window-tokens", min=1),
|
|
517
|
+
config: Path | None = typer.Option(None, "--config", help="Additional TOML configuration."),
|
|
518
|
+
) -> None:
|
|
519
|
+
"""Preview or safely apply AI-generated docs to Python, JavaScript and TypeScript."""
|
|
520
|
+
try:
|
|
521
|
+
repo = GitRepo()
|
|
522
|
+
settings = load(
|
|
523
|
+
repo.root,
|
|
524
|
+
config,
|
|
525
|
+
output="preview" if dry_run or check else output,
|
|
526
|
+
coverage=coverage,
|
|
527
|
+
request_scope=request_scope,
|
|
528
|
+
language=language,
|
|
529
|
+
selection=selection,
|
|
530
|
+
python_format=python_format,
|
|
531
|
+
provider=provider,
|
|
532
|
+
model=model,
|
|
533
|
+
timeout_seconds=timeout_seconds,
|
|
534
|
+
max_input_tokens=max_input_tokens,
|
|
535
|
+
context_window_tokens=context_window_tokens,
|
|
536
|
+
)
|
|
537
|
+
try:
|
|
538
|
+
files = resolve(repo, paths, settings)
|
|
539
|
+
except NoEligibleFilesError:
|
|
540
|
+
if check:
|
|
541
|
+
raise
|
|
542
|
+
typer.secho(
|
|
543
|
+
"Nothing to document: no eligible source files were found. "
|
|
544
|
+
"The selected scope may already be fully documented or contain no supported "
|
|
545
|
+
"changes.",
|
|
546
|
+
fg=typer.colors.GREEN,
|
|
547
|
+
)
|
|
548
|
+
return
|
|
549
|
+
if check:
|
|
550
|
+
_run_check(repo, files)
|
|
551
|
+
return
|
|
552
|
+
_require_interactive_confirmation(settings, yes)
|
|
553
|
+
state = _RunState([], [])
|
|
554
|
+
for relative in files:
|
|
555
|
+
_process_file(repo, relative, settings, show_diff, state, yes)
|
|
556
|
+
_finish_run(settings, state, continue_on_error)
|
|
557
|
+
except (DocGubError, UnicodeDecodeError, SyntaxError) as exc:
|
|
558
|
+
message = _syntax_error_message(exc) if isinstance(exc, SyntaxError) else str(exc)
|
|
559
|
+
typer.secho(f"Error: {message}", fg=typer.colors.RED, bold=True, err=True)
|
|
560
|
+
raise typer.Exit(1) from exc
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
@config_app.command("init")
|
|
564
|
+
def config_init(path: Path = typer.Option(Path(".doc-code.toml"), "--path")) -> None:
|
|
565
|
+
"""Create a documentation configuration template without overwriting files."""
|
|
566
|
+
try:
|
|
567
|
+
if path.exists():
|
|
568
|
+
typer.secho(f"Error: {path} already exists.", fg=typer.colors.RED, err=True)
|
|
569
|
+
raise typer.Exit(1)
|
|
570
|
+
path.write_text(TEMPLATE, encoding="utf-8")
|
|
571
|
+
except OSError as exc:
|
|
572
|
+
typer.secho(f"Error: unable to create {path}: {exc}", fg=typer.colors.RED, err=True)
|
|
573
|
+
raise typer.Exit(1) from exc
|
|
574
|
+
typer.secho(f"Configuration created at {path}.", fg=typer.colors.GREEN)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@config_app.command("show")
|
|
578
|
+
def config_show(config: Path | None = typer.Option(None, "--config")) -> None:
|
|
579
|
+
"""Print effective configuration without credentials."""
|
|
580
|
+
try:
|
|
581
|
+
repo = GitRepo()
|
|
582
|
+
typer.echo(json.dumps(load(repo.root, config).__dict__, ensure_ascii=False, indent=2))
|
|
583
|
+
except DocGubError as exc:
|
|
584
|
+
typer.secho(f"Error: {exc}", fg=typer.colors.RED, err=True)
|
|
585
|
+
raise typer.Exit(1) from exc
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def main() -> None:
|
|
589
|
+
"""Dispatch `config` separately so it never looks like a path argument."""
|
|
590
|
+
if len(sys.argv) > 1 and sys.argv[1] == "config":
|
|
591
|
+
config_app(args=sys.argv[2:], prog_name="doc-code config")
|
|
592
|
+
else:
|
|
593
|
+
app()
|