docgenie-cli 1.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.
- docgenie/__init__.py +15 -0
- docgenie/__main__.py +13 -0
- docgenie/cli.py +499 -0
- docgenie/config.py +89 -0
- docgenie/convert_to_html.py +19 -0
- docgenie/core.py +511 -0
- docgenie/exceptions.py +68 -0
- docgenie/generator.py +957 -0
- docgenie/html_generator.py +658 -0
- docgenie/index_store.py +322 -0
- docgenie/logging.py +115 -0
- docgenie/models.py +174 -0
- docgenie/parsers.py +415 -0
- docgenie/py.typed +1 -0
- docgenie/redaction.py +48 -0
- docgenie/sanitize.py +111 -0
- docgenie/utils.py +597 -0
- docgenie_cli-1.1.0.dist-info/METADATA +256 -0
- docgenie_cli-1.1.0.dist-info/RECORD +22 -0
- docgenie_cli-1.1.0.dist-info/WHEEL +4 -0
- docgenie_cli-1.1.0.dist-info/entry_points.txt +3 -0
- docgenie_cli-1.1.0.dist-info/licenses/LICENSE +21 -0
docgenie/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DocGenie - Auto-documentation tool for codebases
|
|
3
|
+
|
|
4
|
+
A powerful Python library that automatically generates comprehensive README documentation
|
|
5
|
+
for any codebase by analyzing source code, dependencies, and project structure.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "1.1.0"
|
|
9
|
+
__author__ = "DocGenie Team"
|
|
10
|
+
__email__ = "contact@docgenie.dev"
|
|
11
|
+
|
|
12
|
+
from .core import CodebaseAnalyzer
|
|
13
|
+
from .generator import ReadmeGenerator
|
|
14
|
+
|
|
15
|
+
__all__ = ["CodebaseAnalyzer", "ReadmeGenerator"]
|
docgenie/__main__.py
ADDED
docgenie/cli.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""Command-line interface for DocGenie."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import webbrowser
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
import yaml
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.progress import Progress
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from .config import load_config
|
|
17
|
+
from .core import CodebaseAnalyzer
|
|
18
|
+
from .generator import ReadmeGenerator
|
|
19
|
+
from .html_generator import HTMLGenerator
|
|
20
|
+
from .index_store import IndexStore
|
|
21
|
+
from .logging import configure_logging, get_logger
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(add_completion=False, help="DocGenie - Auto-documentation for any codebase.")
|
|
24
|
+
index_app = typer.Typer(add_completion=False, help="Manage persistent DocGenie index store.")
|
|
25
|
+
app.add_typer(index_app, name="index")
|
|
26
|
+
console = Console()
|
|
27
|
+
|
|
28
|
+
OutputSpec = tuple[str, Path]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _print_summary(analysis_data: dict, target_formats: str) -> None:
|
|
32
|
+
table = Table(title="DocGenie Summary", show_lines=True)
|
|
33
|
+
table.add_column("Metric")
|
|
34
|
+
table.add_column("Value")
|
|
35
|
+
table.add_row("Project", analysis_data.get("project_name", "unknown"))
|
|
36
|
+
table.add_row("Files", str(analysis_data.get("files_analyzed", 0)))
|
|
37
|
+
table.add_row("Languages", ", ".join(analysis_data.get("languages", {}).keys()))
|
|
38
|
+
table.add_row("Functions", str(len(analysis_data.get("functions", []))))
|
|
39
|
+
table.add_row("Classes", str(len(analysis_data.get("classes", []))))
|
|
40
|
+
table.add_row("Format", target_formats)
|
|
41
|
+
repo = analysis_data.get("git_info", {}).get("remote_url")
|
|
42
|
+
if repo:
|
|
43
|
+
table.add_row("Repository", repo)
|
|
44
|
+
console.print(table)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _validate_format(fmt: str) -> str:
|
|
48
|
+
target_formats = fmt.lower()
|
|
49
|
+
if target_formats not in {"markdown", "html", "both"}:
|
|
50
|
+
typer.echo("Invalid format. Choose markdown, html, or both.")
|
|
51
|
+
raise typer.Exit(code=1)
|
|
52
|
+
return target_formats
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _run_analysis(
|
|
56
|
+
path: Path,
|
|
57
|
+
ignore: list[str],
|
|
58
|
+
tree_sitter: bool,
|
|
59
|
+
verbose: bool,
|
|
60
|
+
config_overrides: dict | None = None,
|
|
61
|
+
) -> dict:
|
|
62
|
+
config = load_config(path)
|
|
63
|
+
if config_overrides:
|
|
64
|
+
for key, value in config_overrides.items():
|
|
65
|
+
if isinstance(value, dict) and isinstance(config.get(key), dict):
|
|
66
|
+
config[key].update(value)
|
|
67
|
+
else:
|
|
68
|
+
config[key] = value
|
|
69
|
+
# Merge CLI ignore patterns with config ignore patterns
|
|
70
|
+
config_ignore = config.get("ignore_patterns", [])
|
|
71
|
+
combined_ignore = list(set(ignore + config_ignore))
|
|
72
|
+
|
|
73
|
+
analyzer = CodebaseAnalyzer(
|
|
74
|
+
str(path),
|
|
75
|
+
combined_ignore,
|
|
76
|
+
enable_tree_sitter=tree_sitter,
|
|
77
|
+
config=config,
|
|
78
|
+
)
|
|
79
|
+
with Progress(console=console, transient=True) as progress:
|
|
80
|
+
task = progress.add_task("Analyzing codebase...", total=100)
|
|
81
|
+
analysis_data = analyzer.analyze()
|
|
82
|
+
progress.update(task, completed=100)
|
|
83
|
+
if verbose:
|
|
84
|
+
console.log("Analysis complete")
|
|
85
|
+
return analysis_data
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _content_hash(text: str) -> str:
|
|
89
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _section_hashes(content: str) -> dict[str, str]:
|
|
93
|
+
hashes: dict[str, str] = {}
|
|
94
|
+
for block in content.split("\n## "):
|
|
95
|
+
if not block.strip():
|
|
96
|
+
continue
|
|
97
|
+
title = block.splitlines()[0].lstrip("# ").strip()
|
|
98
|
+
hashes[title or "document"] = _content_hash(block)
|
|
99
|
+
return hashes
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _record_artifact(path: Path, target: str, content: str, root: Path) -> None:
|
|
103
|
+
try:
|
|
104
|
+
store = IndexStore(root)
|
|
105
|
+
run_id = store.latest_run_id()
|
|
106
|
+
if run_id is not None:
|
|
107
|
+
store.add_doc_artifact(
|
|
108
|
+
run_id=run_id,
|
|
109
|
+
artifact_path=str(path),
|
|
110
|
+
target=target,
|
|
111
|
+
content_hash=_content_hash(content),
|
|
112
|
+
section_hashes=_section_hashes(content),
|
|
113
|
+
)
|
|
114
|
+
store.commit()
|
|
115
|
+
store.close()
|
|
116
|
+
except OSError:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _build_outputs(target_formats: str, output: Path | None, base: Path) -> list[OutputSpec]:
|
|
121
|
+
outputs: list[OutputSpec] = []
|
|
122
|
+
if target_formats in {"markdown", "both"}:
|
|
123
|
+
outputs.append(("markdown", _resolve_output(output, base, "README.md")))
|
|
124
|
+
if target_formats in {"html", "both"}:
|
|
125
|
+
outputs.append(("html", _resolve_output(output, base, "docs.html")))
|
|
126
|
+
return outputs
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _confirm_overwrite(outputs: list[OutputSpec], *, preview: bool, force: bool) -> None:
|
|
130
|
+
if preview or force:
|
|
131
|
+
return
|
|
132
|
+
for _, out_path in outputs:
|
|
133
|
+
if out_path.exists() and not typer.confirm(f"{out_path.name} exists. Overwrite?"):
|
|
134
|
+
typer.echo("Operation cancelled.")
|
|
135
|
+
raise typer.Exit(code=1)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _render_outputs(outputs: list[OutputSpec], analysis_data: dict, *, preview: bool) -> None:
|
|
139
|
+
for output_format, output_path in outputs:
|
|
140
|
+
if output_format == "markdown":
|
|
141
|
+
generator = ReadmeGenerator()
|
|
142
|
+
content = generator.generate(analysis_data, None if preview else str(output_path))
|
|
143
|
+
if preview:
|
|
144
|
+
console.rule("README Preview")
|
|
145
|
+
typer.echo(content)
|
|
146
|
+
else:
|
|
147
|
+
console.log(f"[green]README generated:[/green] {output_path}")
|
|
148
|
+
else:
|
|
149
|
+
html_generator = HTMLGenerator()
|
|
150
|
+
content = html_generator.generate_from_analysis(
|
|
151
|
+
analysis_data, None if preview else str(output_path)
|
|
152
|
+
)
|
|
153
|
+
if preview:
|
|
154
|
+
console.rule("HTML Preview (truncated)")
|
|
155
|
+
typer.echo("\n".join(content.splitlines()[:80]))
|
|
156
|
+
else:
|
|
157
|
+
console.log(f"[green]HTML generated:[/green] {output_path}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command("generate")
|
|
161
|
+
def generate( # noqa: PLR0913
|
|
162
|
+
path: Path = typer.Argument(
|
|
163
|
+
Path("."), exists=True, file_okay=False, dir_okay=True, resolve_path=True
|
|
164
|
+
),
|
|
165
|
+
output: Path | None = typer.Option(
|
|
166
|
+
None, "--output", "-o", help="Output path for documentation."
|
|
167
|
+
),
|
|
168
|
+
fmt: str = typer.Option(
|
|
169
|
+
"markdown",
|
|
170
|
+
"--format",
|
|
171
|
+
"--fmt",
|
|
172
|
+
help="Output format",
|
|
173
|
+
case_sensitive=False,
|
|
174
|
+
rich_help_panel="Output",
|
|
175
|
+
),
|
|
176
|
+
ignore: list[str] = typer.Option([], "--ignore", "-i", help="Additional ignore patterns"),
|
|
177
|
+
force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing files"),
|
|
178
|
+
preview: bool = typer.Option(False, "--preview", "-p", help="Preview without saving"),
|
|
179
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
|
180
|
+
tree_sitter: bool = typer.Option(
|
|
181
|
+
True,
|
|
182
|
+
"--tree-sitter/--no-tree-sitter",
|
|
183
|
+
help="Enable tree-sitter parsing when available",
|
|
184
|
+
),
|
|
185
|
+
mode: str = typer.Option("auto", "--mode", help="Output mode: auto|single|package"),
|
|
186
|
+
engine: str = typer.Option("hybrid", "--engine", help="Engine: hybrid|stateless"),
|
|
187
|
+
incremental: bool = typer.Option(
|
|
188
|
+
True,
|
|
189
|
+
"--incremental/--no-incremental",
|
|
190
|
+
help="Enable incremental analysis",
|
|
191
|
+
),
|
|
192
|
+
max_file_size_kb: int = typer.Option(
|
|
193
|
+
512, "--max-file-size-kb", help="Skip files larger than this size in KB"
|
|
194
|
+
),
|
|
195
|
+
redaction_mode: str = typer.Option(
|
|
196
|
+
"strict",
|
|
197
|
+
"--redaction-mode",
|
|
198
|
+
help="Redaction mode: strict|balanced|open",
|
|
199
|
+
),
|
|
200
|
+
json_logs: bool = typer.Option(False, "--json-logs", help="Output structured logs as JSON"),
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Generate README and/or HTML docs for a codebase."""
|
|
203
|
+
configure_logging(verbose=verbose, json_output=json_logs)
|
|
204
|
+
logger = get_logger(__name__)
|
|
205
|
+
|
|
206
|
+
target_formats = _validate_format(fmt)
|
|
207
|
+
console.rule("[bold cyan]DocGenie")
|
|
208
|
+
logger.info("Starting documentation generation", path=str(path), format=target_formats)
|
|
209
|
+
|
|
210
|
+
config_overrides = {
|
|
211
|
+
"analysis": {
|
|
212
|
+
"engine": "hybrid_index" if engine == "hybrid" else "stateless",
|
|
213
|
+
"incremental": incremental,
|
|
214
|
+
"max_file_size_kb": max_file_size_kb,
|
|
215
|
+
},
|
|
216
|
+
"monorepo": {"mode": mode},
|
|
217
|
+
"safety": {"redaction_mode": redaction_mode},
|
|
218
|
+
}
|
|
219
|
+
analysis_data = _run_analysis(
|
|
220
|
+
path, ignore, tree_sitter, verbose, config_overrides=config_overrides
|
|
221
|
+
)
|
|
222
|
+
outputs = _build_outputs(target_formats, output, path)
|
|
223
|
+
_confirm_overwrite(outputs, preview=preview, force=force)
|
|
224
|
+
monorepo_config = (
|
|
225
|
+
analysis_data.get("config", {}).get("monorepo", {})
|
|
226
|
+
if isinstance(analysis_data.get("config"), dict)
|
|
227
|
+
else {}
|
|
228
|
+
)
|
|
229
|
+
package_mode = mode == "package" or (
|
|
230
|
+
mode == "auto" and len(analysis_data.get("packages", [])) > 1
|
|
231
|
+
)
|
|
232
|
+
generate_root_doc = bool(monorepo_config.get("root_doc", True)) or not package_mode
|
|
233
|
+
per_package_docs = bool(monorepo_config.get("per_package_docs", True))
|
|
234
|
+
package_output_dir = Path(str(monorepo_config.get("package_output_dir", ".docgenie/packages")))
|
|
235
|
+
|
|
236
|
+
for output_format, output_path in outputs:
|
|
237
|
+
if output_format == "markdown":
|
|
238
|
+
generator = ReadmeGenerator()
|
|
239
|
+
if generate_root_doc:
|
|
240
|
+
content = generator.generate(analysis_data, None if preview else str(output_path))
|
|
241
|
+
if preview:
|
|
242
|
+
console.rule("README Preview")
|
|
243
|
+
typer.echo(content)
|
|
244
|
+
else:
|
|
245
|
+
console.log(f"[green]README generated:[/green] {output_path}")
|
|
246
|
+
_record_artifact(output_path, "root", content, path)
|
|
247
|
+
if package_mode and per_package_docs and not preview:
|
|
248
|
+
pkg_artifacts = generator.generate_package_docs(
|
|
249
|
+
analysis_data, path / package_output_dir
|
|
250
|
+
)
|
|
251
|
+
for pkg_name, pkg_content in pkg_artifacts.items():
|
|
252
|
+
artifact_path = path / package_output_dir / pkg_name / "README.md"
|
|
253
|
+
_record_artifact(artifact_path, pkg_name, pkg_content, path)
|
|
254
|
+
else:
|
|
255
|
+
html_generator = HTMLGenerator()
|
|
256
|
+
if generate_root_doc:
|
|
257
|
+
content = html_generator.generate_from_analysis(
|
|
258
|
+
analysis_data, None if preview else str(output_path)
|
|
259
|
+
)
|
|
260
|
+
if preview:
|
|
261
|
+
console.rule("HTML Preview (truncated)")
|
|
262
|
+
typer.echo("\n".join(content.splitlines()[:80]))
|
|
263
|
+
else:
|
|
264
|
+
console.log(f"[green]HTML generated:[/green] {output_path}")
|
|
265
|
+
_record_artifact(output_path, "root_html", content, path)
|
|
266
|
+
|
|
267
|
+
if not preview:
|
|
268
|
+
_print_summary(analysis_data, target_formats)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _resolve_output(output: Path | None, base: Path, default_name: str) -> Path:
|
|
272
|
+
if output is None:
|
|
273
|
+
return base / default_name
|
|
274
|
+
if output.is_dir():
|
|
275
|
+
return output / default_name
|
|
276
|
+
return output
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@app.command("analyze")
|
|
280
|
+
def analyze(
|
|
281
|
+
path: Path = typer.Argument(Path("."), exists=True, resolve_path=True),
|
|
282
|
+
fmt: str = typer.Option("text", "--format", "-f", help="Output format"),
|
|
283
|
+
tree_sitter: bool = typer.Option(
|
|
284
|
+
True,
|
|
285
|
+
"--tree-sitter/--no-tree-sitter",
|
|
286
|
+
help="Enable tree-sitter parsing when available",
|
|
287
|
+
),
|
|
288
|
+
metrics_json: Path | None = typer.Option(
|
|
289
|
+
None, "--metrics-json", help="Optional path to write run metrics as JSON"
|
|
290
|
+
),
|
|
291
|
+
engine: str = typer.Option("hybrid", "--engine", help="Engine: hybrid|stateless"),
|
|
292
|
+
incremental: bool = typer.Option(True, "--incremental/--no-incremental"),
|
|
293
|
+
) -> None:
|
|
294
|
+
"""Analyze a codebase and print structured results."""
|
|
295
|
+
analysis_data = _run_analysis(
|
|
296
|
+
path,
|
|
297
|
+
ignore=[],
|
|
298
|
+
tree_sitter=tree_sitter,
|
|
299
|
+
verbose=False,
|
|
300
|
+
config_overrides={
|
|
301
|
+
"analysis": {
|
|
302
|
+
"engine": "hybrid_index" if engine == "hybrid" else "stateless",
|
|
303
|
+
"incremental": incremental,
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
if metrics_json is not None:
|
|
309
|
+
metrics_json.write_text(
|
|
310
|
+
json.dumps(analysis_data.get("run_metrics", {}), indent=2, sort_keys=True),
|
|
311
|
+
encoding="utf-8",
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
if fmt == "json":
|
|
315
|
+
typer.echo(json.dumps(analysis_data, indent=2))
|
|
316
|
+
elif fmt == "yaml":
|
|
317
|
+
typer.echo(yaml.dump(analysis_data, default_flow_style=False))
|
|
318
|
+
else:
|
|
319
|
+
typer.echo("Codebase Analysis Results")
|
|
320
|
+
typer.echo(f"Path: {analysis_data.get('root_path')}")
|
|
321
|
+
typer.echo(f"Files analyzed: {analysis_data['files_analyzed']}")
|
|
322
|
+
typer.echo(f"Languages: {', '.join(analysis_data['languages'].keys())}")
|
|
323
|
+
typer.echo(f"Functions: {len(analysis_data['functions'])}")
|
|
324
|
+
typer.echo(f"Classes: {len(analysis_data['classes'])}")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@app.command("init")
|
|
328
|
+
def init_project_config(
|
|
329
|
+
force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing config"),
|
|
330
|
+
) -> None:
|
|
331
|
+
"""Create a starter .docgenie.yaml configuration file."""
|
|
332
|
+
config_path = Path(".docgenie.yaml")
|
|
333
|
+
if config_path.exists() and not force:
|
|
334
|
+
typer.echo("Config already exists. Use --force to overwrite.")
|
|
335
|
+
raise typer.Exit(code=1)
|
|
336
|
+
|
|
337
|
+
template = """# DocGenie configuration
|
|
338
|
+
ignore_patterns:
|
|
339
|
+
- "*.log"
|
|
340
|
+
- "build/"
|
|
341
|
+
- "dist/"
|
|
342
|
+
|
|
343
|
+
template_customizations:
|
|
344
|
+
include_api_docs: true
|
|
345
|
+
include_directory_tree: true
|
|
346
|
+
max_functions_documented: 25
|
|
347
|
+
|
|
348
|
+
analysis:
|
|
349
|
+
use_gitignore: true
|
|
350
|
+
exclude_generated: true
|
|
351
|
+
include_hidden: false
|
|
352
|
+
max_file_size_kb: 512
|
|
353
|
+
generated_patterns: []
|
|
354
|
+
engine: hybrid_index
|
|
355
|
+
incremental: true
|
|
356
|
+
parallelism: auto
|
|
357
|
+
hard_file_cap: 300000
|
|
358
|
+
full_rescan_interval_runs: 20
|
|
359
|
+
|
|
360
|
+
monorepo:
|
|
361
|
+
mode: auto
|
|
362
|
+
root_doc: true
|
|
363
|
+
per_package_docs: true
|
|
364
|
+
package_output_dir: ".docgenie/packages"
|
|
365
|
+
|
|
366
|
+
quality:
|
|
367
|
+
confidence_enabled: true
|
|
368
|
+
include_warnings: true
|
|
369
|
+
min_confidence_for_api_docs: low
|
|
370
|
+
|
|
371
|
+
safety:
|
|
372
|
+
redaction_mode: strict
|
|
373
|
+
redact_patterns: []
|
|
374
|
+
"""
|
|
375
|
+
config_path.write_text(template, encoding="utf-8")
|
|
376
|
+
console.log(f"[green]Created {config_path}[/green]")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@app.command("html")
|
|
380
|
+
def html_command( # noqa: PLR0913
|
|
381
|
+
input_path: Path = typer.Argument(..., exists=True, resolve_path=True),
|
|
382
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Output HTML path"),
|
|
383
|
+
source: str = typer.Option("readme", "--source", "-s", help="readme or codebase"),
|
|
384
|
+
title: str | None = typer.Option(None, "--title", "-t", help="Custom HTML title"),
|
|
385
|
+
force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing files"),
|
|
386
|
+
open_browser: bool = typer.Option(
|
|
387
|
+
False,
|
|
388
|
+
"--open-browser",
|
|
389
|
+
"--open",
|
|
390
|
+
help="Open generated HTML in browser",
|
|
391
|
+
),
|
|
392
|
+
verbose: bool = typer.Option(False, "--verbose", "-v"),
|
|
393
|
+
tree_sitter: bool = typer.Option(True, "--tree-sitter/--no-tree-sitter"),
|
|
394
|
+
) -> None:
|
|
395
|
+
"""Convert README to HTML or generate HTML from codebase analysis."""
|
|
396
|
+
html_generator = HTMLGenerator()
|
|
397
|
+
output_path = output
|
|
398
|
+
if not output_path:
|
|
399
|
+
output_path = (input_path.parent if source == "readme" else input_path) / "docs.html"
|
|
400
|
+
elif output_path.is_dir():
|
|
401
|
+
output_path = output_path / "docs.html"
|
|
402
|
+
|
|
403
|
+
if (
|
|
404
|
+
output_path.exists()
|
|
405
|
+
and (not force)
|
|
406
|
+
and (not typer.confirm(f"{output_path} exists. Overwrite?"))
|
|
407
|
+
):
|
|
408
|
+
raise typer.Exit(code=1)
|
|
409
|
+
|
|
410
|
+
if source == "readme":
|
|
411
|
+
if not input_path.is_file() or input_path.suffix.lower() != ".md":
|
|
412
|
+
typer.echo("Input must be a markdown file when --source readme")
|
|
413
|
+
raise typer.Exit(code=1)
|
|
414
|
+
readme_content = input_path.read_text(encoding="utf-8")
|
|
415
|
+
project_name = title or _extract_title(readme_content) or input_path.stem
|
|
416
|
+
html_generator.generate_from_readme(readme_content, str(output_path), project_name)
|
|
417
|
+
else:
|
|
418
|
+
analyzer = CodebaseAnalyzer(str(input_path), enable_tree_sitter=tree_sitter)
|
|
419
|
+
if verbose:
|
|
420
|
+
console.log(f"Analyzing codebase at {input_path}")
|
|
421
|
+
analysis_data = analyzer.analyze()
|
|
422
|
+
html_generator.generate_from_analysis(analysis_data, str(output_path))
|
|
423
|
+
|
|
424
|
+
console.log(f"[green]HTML generated:[/green] {output_path}")
|
|
425
|
+
if open_browser:
|
|
426
|
+
webbrowser.open(output_path.resolve().as_uri())
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _extract_title(content: str) -> str | None:
|
|
430
|
+
for line in content.splitlines():
|
|
431
|
+
if line.startswith("# "):
|
|
432
|
+
return line[2:].strip()
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@index_app.command("rebuild")
|
|
437
|
+
def index_rebuild(
|
|
438
|
+
path: Path = typer.Argument(Path("."), exists=True, file_okay=False, resolve_path=True),
|
|
439
|
+
) -> None:
|
|
440
|
+
"""Rebuild persistent index for a repository."""
|
|
441
|
+
store = IndexStore(path)
|
|
442
|
+
store.clear_all()
|
|
443
|
+
store.commit()
|
|
444
|
+
store.close()
|
|
445
|
+
typer.echo(f"Index rebuilt at {path / '.docgenie' / 'index.db'}")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@index_app.command("stats")
|
|
449
|
+
def index_stats(
|
|
450
|
+
path: Path = typer.Argument(Path("."), exists=True, file_okay=False, resolve_path=True),
|
|
451
|
+
) -> None:
|
|
452
|
+
"""Print index statistics."""
|
|
453
|
+
store = IndexStore(path)
|
|
454
|
+
stats = store.stats()
|
|
455
|
+
store.close()
|
|
456
|
+
typer.echo(json.dumps(stats, indent=2, sort_keys=True))
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@app.command("diff")
|
|
460
|
+
def diff_command(
|
|
461
|
+
path: Path = typer.Argument(Path("."), exists=True, file_okay=False, resolve_path=True),
|
|
462
|
+
since: str = typer.Option(..., "--since", help="Run ID or git ref"),
|
|
463
|
+
) -> None:
|
|
464
|
+
"""Show documentation impact since a prior indexed run/git ref."""
|
|
465
|
+
store = IndexStore(path)
|
|
466
|
+
latest = store.latest_run_id()
|
|
467
|
+
if latest is None:
|
|
468
|
+
typer.echo("No runs in index yet.")
|
|
469
|
+
store.close()
|
|
470
|
+
raise typer.Exit(code=1)
|
|
471
|
+
|
|
472
|
+
# Git ref mode falls back to previous run for compatibility.
|
|
473
|
+
base_id = int(since) if since.isdigit() else max(1, latest - 1)
|
|
474
|
+
|
|
475
|
+
latest_artifacts = {a["artifact_path"]: a for a in store.list_artifacts_for_run(latest)}
|
|
476
|
+
base_artifacts = {a["artifact_path"]: a for a in store.list_artifacts_for_run(base_id)}
|
|
477
|
+
changed: list[str] = []
|
|
478
|
+
for artifact_path, latest_art in latest_artifacts.items():
|
|
479
|
+
base_art = base_artifacts.get(artifact_path)
|
|
480
|
+
if not base_art or base_art.get("content_hash") != latest_art.get("content_hash"):
|
|
481
|
+
changed.append(artifact_path)
|
|
482
|
+
|
|
483
|
+
typer.echo(
|
|
484
|
+
json.dumps(
|
|
485
|
+
{
|
|
486
|
+
"latest_run_id": latest,
|
|
487
|
+
"base_run_id": base_id,
|
|
488
|
+
"changed_artifacts": sorted(changed),
|
|
489
|
+
"changed_count": len(changed),
|
|
490
|
+
},
|
|
491
|
+
indent=2,
|
|
492
|
+
sort_keys=True,
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
store.close()
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
if __name__ == "__main__":
|
|
499
|
+
app()
|
docgenie/config.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Configuration management for DocGenie."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_config(root_path: Path) -> dict[str, Any]:
|
|
12
|
+
"""
|
|
13
|
+
Load configuration from .docgenie.yaml in the project root.
|
|
14
|
+
Returns a default configuration if the file doesn't exist.
|
|
15
|
+
"""
|
|
16
|
+
config_path = root_path / ".docgenie.yaml"
|
|
17
|
+
if not config_path.exists():
|
|
18
|
+
return get_default_config()
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
with open(config_path, encoding="utf-8") as f:
|
|
22
|
+
user_config = yaml.safe_load(f) or {}
|
|
23
|
+
return merge_configs(get_default_config(), user_config)
|
|
24
|
+
except (yaml.YAMLError, OSError):
|
|
25
|
+
# Return default config if loading fails
|
|
26
|
+
return get_default_config()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_default_config() -> dict[str, Any]:
|
|
30
|
+
"""Return the default configuration."""
|
|
31
|
+
return {
|
|
32
|
+
"ignore_patterns": [
|
|
33
|
+
"*.log",
|
|
34
|
+
"build/",
|
|
35
|
+
"dist/",
|
|
36
|
+
"*.egg-info",
|
|
37
|
+
"__pycache__",
|
|
38
|
+
".git",
|
|
39
|
+
".idea",
|
|
40
|
+
".vscode",
|
|
41
|
+
"node_modules",
|
|
42
|
+
"venv",
|
|
43
|
+
".venv",
|
|
44
|
+
"env",
|
|
45
|
+
],
|
|
46
|
+
"analysis": {
|
|
47
|
+
"use_gitignore": True,
|
|
48
|
+
"exclude_generated": True,
|
|
49
|
+
"include_hidden": False,
|
|
50
|
+
"max_file_size_kb": 512,
|
|
51
|
+
"generated_patterns": [],
|
|
52
|
+
"engine": "hybrid_index",
|
|
53
|
+
"incremental": True,
|
|
54
|
+
"parallelism": "auto",
|
|
55
|
+
"hard_file_cap": 300000,
|
|
56
|
+
"full_rescan_interval_runs": 20,
|
|
57
|
+
},
|
|
58
|
+
"monorepo": {
|
|
59
|
+
"mode": "auto",
|
|
60
|
+
"root_doc": True,
|
|
61
|
+
"per_package_docs": True,
|
|
62
|
+
"package_output_dir": ".docgenie/packages",
|
|
63
|
+
},
|
|
64
|
+
"quality": {
|
|
65
|
+
"confidence_enabled": True,
|
|
66
|
+
"include_warnings": True,
|
|
67
|
+
"min_confidence_for_api_docs": "low",
|
|
68
|
+
},
|
|
69
|
+
"safety": {
|
|
70
|
+
"redaction_mode": "strict",
|
|
71
|
+
"redact_patterns": [],
|
|
72
|
+
},
|
|
73
|
+
"template_customizations": {
|
|
74
|
+
"include_api_docs": True,
|
|
75
|
+
"include_directory_tree": True,
|
|
76
|
+
"max_functions_documented": 10,
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def merge_configs(default: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]:
|
|
82
|
+
"""Deep merge user config into default config."""
|
|
83
|
+
result = default.copy()
|
|
84
|
+
for key, value in user.items():
|
|
85
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
86
|
+
result[key] = merge_configs(result[key], value)
|
|
87
|
+
else:
|
|
88
|
+
result[key] = value
|
|
89
|
+
return result
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""`docgenie-html` console-script entrypoint.
|
|
2
|
+
|
|
3
|
+
This provides backwards-compatible access to the `docgenie html ...` subcommand.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from .cli import app
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
# Forward all args to the Typer subcommand.
|
|
15
|
+
app(args=["html", *sys.argv[1:]], prog_name="docgenie-html")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
main()
|