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/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
+