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/editor.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""Textual edits with content fingerprints to prevent stale previews from overwriting work."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import difflib
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
import tomllib
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .config import Settings
|
|
20
|
+
from .errors import DocGubError
|
|
21
|
+
from .symbols import Documentation, Symbol, needs_documentation, render
|
|
22
|
+
|
|
23
|
+
_DEFAULT_PYTHON_LINE_LENGTH = 88
|
|
24
|
+
_DEFAULT_JAVASCRIPT_LINE_LENGTH = 100
|
|
25
|
+
_ESLINT_MAX_LEN = re.compile(
|
|
26
|
+
r"[\"']?max-len[\"']?\s*:\s*\[\s*(?:[\"'](?:error|warn)[\"']|[12])\s*,\s*"
|
|
27
|
+
r"(?:\{\s*code\s*:\s*)?(\d+)",
|
|
28
|
+
re.MULTILINE,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class PreparedFile:
|
|
34
|
+
"""Store an immutable, validated file edit and its source fingerprint."""
|
|
35
|
+
|
|
36
|
+
path: Path
|
|
37
|
+
before: str
|
|
38
|
+
after: str
|
|
39
|
+
fingerprint: str
|
|
40
|
+
symbols: tuple[Symbol, ...]
|
|
41
|
+
changed: tuple[Symbol, ...]
|
|
42
|
+
ignored: tuple[Symbol, ...]
|
|
43
|
+
display_path: Path | None = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def diff(self) -> str:
|
|
47
|
+
"""Return the unified diff for the prepared edit."""
|
|
48
|
+
display_path = self.display_path or self.path
|
|
49
|
+
return "".join(
|
|
50
|
+
difflib.unified_diff(
|
|
51
|
+
self.before.splitlines(keepends=True),
|
|
52
|
+
self.after.splitlines(keepends=True),
|
|
53
|
+
fromfile=f"a/{display_path.as_posix()}",
|
|
54
|
+
tofile=f"b/{display_path.as_posix()}",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def fingerprint(content: str) -> str:
|
|
60
|
+
"""Return a SHA-256 fingerprint for content."""
|
|
61
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _read_utf8(path: Path) -> str:
|
|
65
|
+
"""Decode UTF-8 without universal-newline conversion."""
|
|
66
|
+
try:
|
|
67
|
+
return path.read_bytes().decode("utf-8")
|
|
68
|
+
except OSError as exc:
|
|
69
|
+
raise DocGubError(f"{path}: unable to read source file: {exc}") from exc
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _line_ending(content: str) -> str:
|
|
73
|
+
"""Return the first line-ending convention used by source content."""
|
|
74
|
+
match = re.search(r"\r\n|\r|\n", content)
|
|
75
|
+
return match.group(0) if match else "\n"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _python_code_shape(content: str, filename: str = "<unknown>") -> str:
|
|
79
|
+
"""Return a Python AST representation with only documentation expressions removed."""
|
|
80
|
+
tree = ast.parse(content, filename=filename)
|
|
81
|
+
|
|
82
|
+
class RemoveDocstrings(ast.NodeTransformer):
|
|
83
|
+
"""Remove Python docstrings from an AST."""
|
|
84
|
+
|
|
85
|
+
def visit_Module(self, node: ast.Module) -> ast.Module:
|
|
86
|
+
"""Remove a module docstring."""
|
|
87
|
+
self.generic_visit(node)
|
|
88
|
+
_remove_leading_docstring(node.body)
|
|
89
|
+
return node
|
|
90
|
+
|
|
91
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef:
|
|
92
|
+
"""Remove a class docstring."""
|
|
93
|
+
self.generic_visit(node)
|
|
94
|
+
_remove_leading_docstring(node.body)
|
|
95
|
+
return node
|
|
96
|
+
|
|
97
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.FunctionDef:
|
|
98
|
+
"""Remove a function docstring."""
|
|
99
|
+
self.generic_visit(node)
|
|
100
|
+
_remove_leading_docstring(node.body)
|
|
101
|
+
return node
|
|
102
|
+
|
|
103
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> ast.AsyncFunctionDef:
|
|
104
|
+
"""Remove an async function docstring."""
|
|
105
|
+
self.generic_visit(node)
|
|
106
|
+
_remove_leading_docstring(node.body)
|
|
107
|
+
return node
|
|
108
|
+
|
|
109
|
+
return ast.dump(RemoveDocstrings().visit(tree), include_attributes=False)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _remove_leading_docstring(body: list[ast.stmt]) -> None:
|
|
113
|
+
"""Remove the leading docstring from a statement body."""
|
|
114
|
+
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant):
|
|
115
|
+
if isinstance(body[0].value.value, str):
|
|
116
|
+
body.pop(0)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _module_insertion(lines: list[str]) -> int:
|
|
120
|
+
"""Keep Unix shebang and Python encoding declarations in their required positions."""
|
|
121
|
+
insertion = 1 if lines and lines[0].startswith("#!") else 0
|
|
122
|
+
coding = "coding"
|
|
123
|
+
if insertion < len(lines) and coding in lines[insertion][:80]:
|
|
124
|
+
insertion += 1
|
|
125
|
+
return insertion
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _is_inline_python_suite(lines: list[str], symbol: Symbol) -> bool:
|
|
129
|
+
"""Return whether a Python function or class uses an inline suite."""
|
|
130
|
+
header = lines[symbol.line - 1].split("#", maxsplit=1)[0]
|
|
131
|
+
return ":" in header and bool(header.rsplit(":", maxsplit=1)[1].strip())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def can_insert_documentation(content: str, symbol: Symbol, suffix: str) -> bool:
|
|
135
|
+
"""Return whether a symbol can receive documentation without rewriting its code."""
|
|
136
|
+
if suffix != ".py" or symbol.kind == "module" or symbol.has_doc:
|
|
137
|
+
return True
|
|
138
|
+
return not _is_inline_python_suite(content.splitlines(keepends=True), symbol)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _documentation_indent(lines: list[str], symbol: Symbol, suffix: str) -> str:
|
|
142
|
+
"""Use the body's existing indentation for Python docstrings."""
|
|
143
|
+
if suffix != ".py" or symbol.kind == "module":
|
|
144
|
+
return symbol.indent
|
|
145
|
+
if symbol.has_doc and symbol.doc_start:
|
|
146
|
+
return lines[symbol.doc_start - 1][
|
|
147
|
+
: len(lines[symbol.doc_start - 1]) - len(lines[symbol.doc_start - 1].lstrip())
|
|
148
|
+
]
|
|
149
|
+
for line in lines[symbol.line : symbol.end_line]:
|
|
150
|
+
if line.strip():
|
|
151
|
+
indentation = line[: len(line) - len(line.lstrip())]
|
|
152
|
+
if len(indentation) > len(symbol.indent):
|
|
153
|
+
return indentation
|
|
154
|
+
return symbol.indent + " "
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _python_rendering_options(path: Path, fallback_format: str) -> tuple[int, str]:
|
|
158
|
+
"""Read Ruff line length and pydocstyle convention from the target project."""
|
|
159
|
+
for directory in (path.parent, *path.parents):
|
|
160
|
+
for name in (".ruff.toml", "ruff.toml", "pyproject.toml"):
|
|
161
|
+
ruff = _ruff_settings(directory / name)
|
|
162
|
+
if ruff is None:
|
|
163
|
+
continue
|
|
164
|
+
line_length = ruff.get("line-length")
|
|
165
|
+
normalized_line_length = (
|
|
166
|
+
line_length
|
|
167
|
+
if isinstance(line_length, int) and line_length > 0
|
|
168
|
+
else _DEFAULT_PYTHON_LINE_LENGTH
|
|
169
|
+
)
|
|
170
|
+
lint = ruff.get("lint", {})
|
|
171
|
+
pydocstyle = lint.get("pydocstyle", {}) if isinstance(lint, dict) else {}
|
|
172
|
+
convention = pydocstyle.get("convention") if isinstance(pydocstyle, dict) else None
|
|
173
|
+
python_format = convention if convention in {"google", "numpy"} else fallback_format
|
|
174
|
+
return normalized_line_length, python_format
|
|
175
|
+
return _DEFAULT_PYTHON_LINE_LENGTH, fallback_format
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _ruff_settings(config: Path, seen: frozenset[Path] = frozenset()) -> dict[str, object] | None:
|
|
179
|
+
"""Load one Ruff configuration, including its optional extended configuration."""
|
|
180
|
+
if not config.is_file():
|
|
181
|
+
return None
|
|
182
|
+
resolved = config.resolve()
|
|
183
|
+
if resolved in seen:
|
|
184
|
+
return None
|
|
185
|
+
try:
|
|
186
|
+
with config.open("rb") as handle:
|
|
187
|
+
data = tomllib.load(handle)
|
|
188
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
189
|
+
return None
|
|
190
|
+
if config.name == "pyproject.toml":
|
|
191
|
+
tool = data.get("tool", {})
|
|
192
|
+
ruff = tool.get("ruff", {}) if isinstance(tool, dict) else {}
|
|
193
|
+
if not ruff:
|
|
194
|
+
return None
|
|
195
|
+
else:
|
|
196
|
+
ruff = data
|
|
197
|
+
if not isinstance(ruff, dict):
|
|
198
|
+
return None
|
|
199
|
+
extend = ruff.get("extend")
|
|
200
|
+
inherited: dict[str, object] = {}
|
|
201
|
+
if isinstance(extend, str) and extend:
|
|
202
|
+
inherited = _ruff_settings(config.parent / extend, seen | {resolved}) or {}
|
|
203
|
+
return _merge_ruff_settings(inherited, ruff)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _merge_ruff_settings(
|
|
207
|
+
inherited: dict[str, object], configured: dict[str, object]
|
|
208
|
+
) -> dict[str, object]:
|
|
209
|
+
"""Overlay nested Ruff settings while retaining inherited pydocstyle options."""
|
|
210
|
+
merged = dict(inherited)
|
|
211
|
+
for key, value in configured.items():
|
|
212
|
+
previous = merged.get(key)
|
|
213
|
+
if isinstance(previous, dict) and isinstance(value, dict):
|
|
214
|
+
merged[key] = _merge_ruff_settings(previous, value)
|
|
215
|
+
else:
|
|
216
|
+
merged[key] = value
|
|
217
|
+
return merged
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _rendering_options(path: Path, settings: Settings) -> tuple[int, str]:
|
|
221
|
+
"""Return target-project line length and Python documentation format."""
|
|
222
|
+
if path.suffix == ".py":
|
|
223
|
+
return _python_rendering_options(path, settings.python_format)
|
|
224
|
+
return _javascript_line_length(path), settings.python_format
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _javascript_line_length(path: Path) -> int:
|
|
228
|
+
"""Read ESLint's `max-len` code limit from the nearest flat config."""
|
|
229
|
+
for directory in (path.parent, *path.parents):
|
|
230
|
+
for name in ("eslint.config.js", "eslint.config.mjs", "eslint.config.cjs"):
|
|
231
|
+
config = directory / name
|
|
232
|
+
if not config.is_file():
|
|
233
|
+
continue
|
|
234
|
+
try:
|
|
235
|
+
content = config.read_text(encoding="utf-8")
|
|
236
|
+
except OSError:
|
|
237
|
+
return _DEFAULT_JAVASCRIPT_LINE_LENGTH
|
|
238
|
+
match = _ESLINT_MAX_LEN.search(content)
|
|
239
|
+
return int(match.group(1)) if match else _DEFAULT_JAVASCRIPT_LINE_LENGTH
|
|
240
|
+
return _DEFAULT_JAVASCRIPT_LINE_LENGTH
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _pep257_separator(
|
|
244
|
+
rendered: list[str],
|
|
245
|
+
lines: list[str],
|
|
246
|
+
following_index: int,
|
|
247
|
+
symbol: Symbol,
|
|
248
|
+
suffix: str,
|
|
249
|
+
newline: str,
|
|
250
|
+
) -> None:
|
|
251
|
+
"""Keep one blank line between module/class docstrings and the following statement."""
|
|
252
|
+
if suffix != ".py" or symbol.kind not in {"module", "class"}:
|
|
253
|
+
return
|
|
254
|
+
if following_index < len(lines) and lines[following_index].strip():
|
|
255
|
+
rendered.append(newline)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _validate_javascript(content: str, suffix: str, path: Path) -> None:
|
|
259
|
+
"""Parse generated JS/TS before it can be applied to the working tree."""
|
|
260
|
+
command = validation_command(suffix, path)
|
|
261
|
+
|
|
262
|
+
with tempfile.NamedTemporaryFile(
|
|
263
|
+
mode="w", encoding="utf-8", suffix=suffix, prefix="doc-code-", delete=False
|
|
264
|
+
) as temporary:
|
|
265
|
+
temporary.write(content)
|
|
266
|
+
candidate = Path(temporary.name)
|
|
267
|
+
try:
|
|
268
|
+
try:
|
|
269
|
+
result = subprocess.run(
|
|
270
|
+
[*command, str(candidate)], text=True, capture_output=True, check=False
|
|
271
|
+
)
|
|
272
|
+
except OSError as exc:
|
|
273
|
+
raise DocGubError(
|
|
274
|
+
f"{path}: unable to run {Path(command[0]).name} validation: {exc}; "
|
|
275
|
+
"no file was changed."
|
|
276
|
+
) from exc
|
|
277
|
+
finally:
|
|
278
|
+
candidate.unlink(missing_ok=True)
|
|
279
|
+
if result.returncode:
|
|
280
|
+
detail = result.stderr.strip() or result.stdout.strip()
|
|
281
|
+
raise DocGubError(f"{path}: generated documentation failed {suffix} validation: {detail}")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def validation_command(suffix: str, path: Path) -> list[str]:
|
|
285
|
+
"""Return the validation command required for a JavaScript-family source file."""
|
|
286
|
+
if suffix == ".js":
|
|
287
|
+
runtime = shutil.which("node")
|
|
288
|
+
if not runtime:
|
|
289
|
+
raise DocGubError(
|
|
290
|
+
f"{path}: JavaScript validation requires `node` on PATH; no file was changed."
|
|
291
|
+
)
|
|
292
|
+
command = [runtime, "--check"]
|
|
293
|
+
else:
|
|
294
|
+
compiler = shutil.which("tsc")
|
|
295
|
+
if not compiler:
|
|
296
|
+
raise DocGubError(
|
|
297
|
+
f"{path}: TypeScript validation requires `tsc` on PATH; no file was changed."
|
|
298
|
+
)
|
|
299
|
+
command = [compiler, "--noEmit", "--noCheck", "--pretty", "false", "--allowJs"]
|
|
300
|
+
if suffix in {".jsx", ".tsx"}:
|
|
301
|
+
command.extend(["--jsx", "preserve"])
|
|
302
|
+
return command
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _selected_symbols(
|
|
306
|
+
symbols: list[Symbol],
|
|
307
|
+
descriptions: Mapping[str, str | Documentation],
|
|
308
|
+
settings: Settings,
|
|
309
|
+
selected_symbols: list[Symbol] | None,
|
|
310
|
+
) -> list[Symbol]:
|
|
311
|
+
"""Return explicitly selected symbols or eligible generated symbols."""
|
|
312
|
+
if selected_symbols is not None:
|
|
313
|
+
return selected_symbols
|
|
314
|
+
return [
|
|
315
|
+
symbol
|
|
316
|
+
for symbol in symbols
|
|
317
|
+
if needs_documentation(symbol, settings.coverage)
|
|
318
|
+
and symbol.name in descriptions
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _insert_documentation(
|
|
323
|
+
lines: list[str],
|
|
324
|
+
symbol: Symbol,
|
|
325
|
+
description: str | Documentation,
|
|
326
|
+
path: Path,
|
|
327
|
+
settings: Settings,
|
|
328
|
+
python_format: str,
|
|
329
|
+
newline: str,
|
|
330
|
+
line_length: int,
|
|
331
|
+
) -> bool:
|
|
332
|
+
"""Render and insert one symbol's documentation, returning whether it changed."""
|
|
333
|
+
if path.suffix == ".py" and not symbol.has_doc and symbol.kind != "module":
|
|
334
|
+
if not can_insert_documentation("".join(lines), symbol, path.suffix):
|
|
335
|
+
return False
|
|
336
|
+
indentation = _documentation_indent(lines, symbol, path.suffix)
|
|
337
|
+
documentation = render(
|
|
338
|
+
symbol,
|
|
339
|
+
description,
|
|
340
|
+
path.suffix,
|
|
341
|
+
python_format,
|
|
342
|
+
line_length,
|
|
343
|
+
indentation,
|
|
344
|
+
)
|
|
345
|
+
rendered = [
|
|
346
|
+
f"{indentation}{row}{newline}" if row else newline for row in documentation.splitlines()
|
|
347
|
+
]
|
|
348
|
+
if symbol.has_doc and symbol.doc_start and symbol.doc_end:
|
|
349
|
+
_pep257_separator(rendered, lines, symbol.doc_end, symbol, path.suffix, newline)
|
|
350
|
+
lines[symbol.doc_start - 1 : symbol.doc_end] = rendered
|
|
351
|
+
return True
|
|
352
|
+
if symbol.has_doc:
|
|
353
|
+
return False
|
|
354
|
+
if path.suffix == ".py" and symbol.kind == "module":
|
|
355
|
+
insertion = _module_insertion(lines)
|
|
356
|
+
elif path.suffix == ".py":
|
|
357
|
+
insertion = (symbol.body_line or symbol.line + 1) - 1
|
|
358
|
+
else:
|
|
359
|
+
insertion = symbol.line - 1
|
|
360
|
+
_pep257_separator(rendered, lines, insertion, symbol, path.suffix, newline)
|
|
361
|
+
lines[insertion:insertion] = rendered
|
|
362
|
+
return True
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def preview_documentation(
|
|
366
|
+
path: Path,
|
|
367
|
+
content: str,
|
|
368
|
+
symbol: Symbol,
|
|
369
|
+
documentation: str | Documentation,
|
|
370
|
+
settings: Settings,
|
|
371
|
+
) -> str:
|
|
372
|
+
"""Render one docstring exactly as it will appear in the source file."""
|
|
373
|
+
lines = content.splitlines(keepends=True)
|
|
374
|
+
line_length, python_format = _rendering_options(path, settings)
|
|
375
|
+
indentation = _documentation_indent(lines, symbol, path.suffix)
|
|
376
|
+
rendered = render(
|
|
377
|
+
symbol,
|
|
378
|
+
documentation,
|
|
379
|
+
path.suffix,
|
|
380
|
+
python_format,
|
|
381
|
+
line_length,
|
|
382
|
+
indentation,
|
|
383
|
+
)
|
|
384
|
+
return "\n".join(f"{indentation}{row}" if row else "" for row in rendered.splitlines())
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _validate_edit(before: str, after: str, path: Path) -> None:
|
|
388
|
+
"""Verify that an edit changes documentation only and remains syntactically valid."""
|
|
389
|
+
if path.suffix == ".py":
|
|
390
|
+
before_shape = _python_code_shape(before, str(path))
|
|
391
|
+
try:
|
|
392
|
+
after_shape = _python_code_shape(after, str(path))
|
|
393
|
+
except SyntaxError as exc:
|
|
394
|
+
line = exc.lineno or "?"
|
|
395
|
+
raise DocGubError(
|
|
396
|
+
f"{path}: generated documentation failed Python validation at line {line}: "
|
|
397
|
+
f"{exc.msg}; the source file was not changed."
|
|
398
|
+
) from exc
|
|
399
|
+
if before_shape != after_shape:
|
|
400
|
+
raise DocGubError(f"{path}: refusing an edit that changes Python code.")
|
|
401
|
+
elif after != before and path.suffix in {".js", ".jsx", ".ts", ".tsx"}:
|
|
402
|
+
_validate_javascript(after, path.suffix, path)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def prepare(
|
|
406
|
+
path: Path,
|
|
407
|
+
symbols: list[Symbol],
|
|
408
|
+
descriptions: Mapping[str, str | Documentation],
|
|
409
|
+
settings: Settings,
|
|
410
|
+
selected_symbols: list[Symbol] | None = None,
|
|
411
|
+
display_path: Path | None = None,
|
|
412
|
+
) -> PreparedFile:
|
|
413
|
+
"""Prepare and validate an edit, optionally limited to generated symbols."""
|
|
414
|
+
if path.is_symlink():
|
|
415
|
+
raise DocGubError(f"{path}: symbolic links are not supported; no file was changed.")
|
|
416
|
+
before = _read_utf8(path)
|
|
417
|
+
if len(before.encode("utf-8")) > settings.max_file_bytes:
|
|
418
|
+
raise DocGubError(f"{path}: exceeds max_file_bytes.")
|
|
419
|
+
selected = _selected_symbols(symbols, descriptions, settings, selected_symbols)
|
|
420
|
+
ignored = [item for item in symbols if item not in selected]
|
|
421
|
+
lines = before.splitlines(keepends=True)
|
|
422
|
+
newline = _line_ending(before)
|
|
423
|
+
line_length, python_format = _rendering_options(path, settings)
|
|
424
|
+
for symbol in reversed(selected):
|
|
425
|
+
inserted = _insert_documentation(
|
|
426
|
+
lines,
|
|
427
|
+
symbol,
|
|
428
|
+
descriptions.get(symbol.name, ""),
|
|
429
|
+
path,
|
|
430
|
+
settings,
|
|
431
|
+
python_format,
|
|
432
|
+
newline,
|
|
433
|
+
line_length,
|
|
434
|
+
)
|
|
435
|
+
if not inserted:
|
|
436
|
+
ignored.append(symbol)
|
|
437
|
+
after = "".join(lines)
|
|
438
|
+
_validate_edit(before, after, path)
|
|
439
|
+
changed = tuple(item for item in selected if item not in ignored)
|
|
440
|
+
return PreparedFile(
|
|
441
|
+
path,
|
|
442
|
+
before,
|
|
443
|
+
after,
|
|
444
|
+
fingerprint(before),
|
|
445
|
+
tuple(symbols),
|
|
446
|
+
changed,
|
|
447
|
+
tuple(ignored),
|
|
448
|
+
display_path,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def apply(prepared: PreparedFile) -> None:
|
|
453
|
+
"""Atomically apply an edit when its source fingerprint is still current."""
|
|
454
|
+
if prepared.path.is_symlink():
|
|
455
|
+
raise DocGubError(
|
|
456
|
+
f"{prepared.path}: became a symbolic link after preview; file was not written."
|
|
457
|
+
)
|
|
458
|
+
current = _read_utf8(prepared.path)
|
|
459
|
+
if fingerprint(current) != prepared.fingerprint:
|
|
460
|
+
raise DocGubError(f"{prepared.path}: changed after preview; file was not written.")
|
|
461
|
+
mode = stat.S_IMODE(prepared.path.stat().st_mode)
|
|
462
|
+
temporary_path: Path | None = None
|
|
463
|
+
try:
|
|
464
|
+
with tempfile.NamedTemporaryFile(
|
|
465
|
+
mode="w",
|
|
466
|
+
encoding="utf-8",
|
|
467
|
+
newline="",
|
|
468
|
+
prefix=f".{prepared.path.name}.",
|
|
469
|
+
dir=prepared.path.parent,
|
|
470
|
+
delete=False,
|
|
471
|
+
) as temporary:
|
|
472
|
+
temporary.write(prepared.after)
|
|
473
|
+
temporary.flush()
|
|
474
|
+
os.fsync(temporary.fileno())
|
|
475
|
+
temporary_path = Path(temporary.name)
|
|
476
|
+
temporary_path.chmod(mode)
|
|
477
|
+
os.replace(temporary_path, prepared.path)
|
|
478
|
+
temporary_path = None
|
|
479
|
+
try:
|
|
480
|
+
directory = os.open(prepared.path.parent, os.O_RDONLY)
|
|
481
|
+
try:
|
|
482
|
+
os.fsync(directory)
|
|
483
|
+
finally:
|
|
484
|
+
os.close(directory)
|
|
485
|
+
except OSError:
|
|
486
|
+
pass
|
|
487
|
+
except OSError as exc:
|
|
488
|
+
raise DocGubError(
|
|
489
|
+
f"{prepared.path}: unable to apply the atomic file update: {exc}"
|
|
490
|
+
) from exc
|
|
491
|
+
finally:
|
|
492
|
+
if temporary_path is not None:
|
|
493
|
+
try:
|
|
494
|
+
temporary_path.unlink(missing_ok=True)
|
|
495
|
+
except OSError:
|
|
496
|
+
pass
|
doc_code/errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Domain exceptions shown to CLI users without tracebacks."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DocGubError(Exception):
|
|
5
|
+
"""Expected, actionable application error."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NoEligibleFilesError(DocGubError):
|
|
9
|
+
"""The selected scope contains no supported source files to process."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AIProviderError(DocGubError):
|
|
13
|
+
"""The configured model provider could not be reached or answered incorrectly."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AITimeoutError(AIProviderError):
|
|
17
|
+
"""The configured model provider timed out."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InvalidAIResponseError(DocGubError):
|
|
21
|
+
"""The model response did not conform to the documented JSON contract."""
|
doc_code/git.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Small, safe Git interface used for path and ignore decisions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .errors import DocGubError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GitRepo:
|
|
14
|
+
"""Provide safe Git operations rooted in one worktree."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, start: Path | None = None) -> None:
|
|
17
|
+
"""Find the worktree root from ``start`` or the current directory."""
|
|
18
|
+
result = self._run("rev-parse", "--show-toplevel", cwd=str(start or Path.cwd()))
|
|
19
|
+
self.root = Path(result.stdout.strip()).resolve()
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def _run(
|
|
23
|
+
*args: str, cwd: str | None = None, check: bool = True, input_text: str | None = None
|
|
24
|
+
) -> subprocess.CompletedProcess[str]:
|
|
25
|
+
"""Execute Git and convert expected process failures to domain errors."""
|
|
26
|
+
executable = shutil.which("git")
|
|
27
|
+
if not executable:
|
|
28
|
+
raise DocGubError("Git is required but was not found on PATH.")
|
|
29
|
+
try:
|
|
30
|
+
result = subprocess.run(
|
|
31
|
+
[executable, *args],
|
|
32
|
+
cwd=cwd,
|
|
33
|
+
input=input_text,
|
|
34
|
+
text=True,
|
|
35
|
+
capture_output=True,
|
|
36
|
+
check=False,
|
|
37
|
+
)
|
|
38
|
+
except OSError as exc:
|
|
39
|
+
raise DocGubError(f"Unable to run Git: {exc}") from exc
|
|
40
|
+
if check and result.returncode:
|
|
41
|
+
raise DocGubError(
|
|
42
|
+
f"Git command failed: {result.stderr.strip() or result.stdout.strip()}"
|
|
43
|
+
)
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
def run(
|
|
47
|
+
self, *args: str, check: bool = True, input_text: str | None = None
|
|
48
|
+
) -> subprocess.CompletedProcess[str]:
|
|
49
|
+
"""Execute Git inside this worktree."""
|
|
50
|
+
return self._run(*args, cwd=str(self.root), check=check, input_text=input_text)
|
|
51
|
+
|
|
52
|
+
def relative_path(self, requested: Path) -> str:
|
|
53
|
+
"""Return a resolved worktree-relative path or reject the request."""
|
|
54
|
+
try:
|
|
55
|
+
return requested.resolve(strict=True).relative_to(self.root).as_posix()
|
|
56
|
+
except (OSError, ValueError) as exc:
|
|
57
|
+
raise DocGubError("The path must exist inside the Git worktree.") from exc
|
|
58
|
+
|
|
59
|
+
def changed_files(self) -> list[str]:
|
|
60
|
+
"""Return every staged, unstaged, and untracked non-ignored file once."""
|
|
61
|
+
staged = self.run("diff", "--cached", "--name-only", "-z").stdout.split("\0")
|
|
62
|
+
unstaged = self.run("diff", "--name-only", "-z").stdout.split("\0")
|
|
63
|
+
untracked = self.run("ls-files", "--others", "--exclude-standard", "-z").stdout.split("\0")
|
|
64
|
+
return list(dict.fromkeys(name for name in [*staged, *unstaged, *untracked] if name))
|
|
65
|
+
|
|
66
|
+
def ignored_paths(self, paths: Iterable[str]) -> set[str]:
|
|
67
|
+
"""Return paths ignored by the repository's Git exclude rules."""
|
|
68
|
+
candidates = tuple(paths)
|
|
69
|
+
if not candidates:
|
|
70
|
+
return set()
|
|
71
|
+
result = self.run(
|
|
72
|
+
"check-ignore", "-z", "--stdin", check=False, input_text="\0".join(candidates) + "\0"
|
|
73
|
+
)
|
|
74
|
+
return {path for path in result.stdout.split("\0") if path}
|
doc_code/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|