code2docs 3.0.4__tar.gz → 3.0.6__tar.gz
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.
- {code2docs-3.0.4 → code2docs-3.0.6}/PKG-INFO +3 -3
- {code2docs-3.0.4 → code2docs-3.0.6}/README.md +2 -2
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/__init__.py +1 -1
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/cli.py +4 -1
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/config.py +1 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/generators/_registry_adapters.py +30 -0
- code2docs-3.0.6/code2docs/generators/org_readme_gen.py +227 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs.egg-info/PKG-INFO +3 -3
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs.egg-info/SOURCES.txt +1 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/pyproject.toml +1 -1
- code2docs-3.0.6/tests/test_cli.py +287 -0
- code2docs-3.0.4/tests/test_cli.py +0 -90
- {code2docs-3.0.4 → code2docs-3.0.6}/LICENSE +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/__main__.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/analyzers/__init__.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/analyzers/dependency_scanner.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/analyzers/docstring_extractor.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/analyzers/endpoint_detector.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/analyzers/project_scanner.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/base.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/examples/advanced_usage.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/examples/quickstart.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/formatters/__init__.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/formatters/badges.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/formatters/markdown.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/formatters/toc.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/generators/__init__.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/generators/_source_links.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/generators/api_changelog_gen.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/generators/api_reference_gen.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/generators/architecture_gen.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/generators/changelog_gen.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/generators/code2llm_gen.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/generators/config_docs_gen.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/generators/contributing_gen.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/generators/coverage_gen.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/generators/depgraph_gen.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/generators/examples_gen.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/generators/getting_started_gen.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/generators/mkdocs_gen.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/generators/module_docs_gen.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/generators/readme_gen.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/llm_helper.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/registry.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/sync/__init__.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/sync/differ.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/sync/updater.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/sync/watcher.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/templates/api_module.md.j2 +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/templates/architecture.md.j2 +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/templates/example_usage.py.j2 +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/templates/index.md.j2 +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/templates/module_doc.md.j2 +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs/templates/readme.md.j2 +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs.egg-info/dependency_links.txt +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs.egg-info/entry_points.txt +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs.egg-info/requires.txt +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/code2docs.egg-info/top_level.txt +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/setup.cfg +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/tests/test_analyzers.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/tests/test_code2docs.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/tests/test_config.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/tests/test_formatters.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/tests/test_generators.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/tests/test_llm_helper.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/tests/test_registry.py +0 -0
- {code2docs-3.0.4 → code2docs-3.0.6}/tests/test_sync.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code2docs
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.6
|
|
4
4
|
Summary: Auto-generate and sync project documentation from source code analysis
|
|
5
5
|
Author-email: Tom Sapletta <tom@sapletta.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -50,7 +50,7 @@ Dynamic: license-file
|
|
|
50
50
|
|
|
51
51
|
# code2docs
|
|
52
52
|
|
|
53
|
-
  
|
|
54
54
|
|
|
55
55
|
> Auto-generate and sync project documentation from source code analysis.
|
|
56
56
|
|
|
@@ -190,7 +190,7 @@ code2docs can update only specific sections of an existing README using markers:
|
|
|
190
190
|
```markdown
|
|
191
191
|
<!-- code2docs:start --># code2docs
|
|
192
192
|
|
|
193
|
-
   
|
|
194
194
|
> **276** functions | **57** classes | **51** files | CC̄ = 3.8
|
|
195
195
|
|
|
196
196
|
> Auto-generated project documentation from source code analysis.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# code2docs
|
|
2
2
|
|
|
3
|
-
  
|
|
4
4
|
|
|
5
5
|
> Auto-generate and sync project documentation from source code analysis.
|
|
6
6
|
|
|
@@ -140,7 +140,7 @@ code2docs can update only specific sections of an existing README using markers:
|
|
|
140
140
|
```markdown
|
|
141
141
|
<!-- code2docs:start --># code2docs
|
|
142
142
|
|
|
143
|
-
   
|
|
144
144
|
> **276** functions | **57** classes | **51** files | CC̄ = 3.8
|
|
145
145
|
|
|
146
146
|
> Auto-generated project documentation from source code analysis.
|
|
@@ -5,7 +5,7 @@ Uses code2llm's AnalysisResult to produce human-readable documentation:
|
|
|
5
5
|
README.md, API references, module docs, examples, and architecture diagrams.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
__version__ = "3.0.
|
|
8
|
+
__version__ = "3.0.6"
|
|
9
9
|
__author__ = "Tom Sapletta"
|
|
10
10
|
|
|
11
11
|
from .config import Code2DocsConfig
|
|
@@ -40,8 +40,9 @@ def main():
|
|
|
40
40
|
@click.option("--dry-run", is_flag=True, help="Show what would be generated without writing")
|
|
41
41
|
@click.option("--llm", "llm_model", default=None,
|
|
42
42
|
help="Enable LLM-assisted generation (e.g. openai/gpt-4o-mini, ollama/llama3)")
|
|
43
|
+
@click.option("--org-name", default=None, help="Organization name for org-mode README generation")
|
|
43
44
|
def generate(project_path, config_path, readme_only, sections, output, verbose, dry_run,
|
|
44
|
-
llm_model):
|
|
45
|
+
llm_model, org_name):
|
|
45
46
|
"""Generate documentation (default command)."""
|
|
46
47
|
config = _load_config(project_path, config_path)
|
|
47
48
|
if verbose:
|
|
@@ -53,6 +54,8 @@ def generate(project_path, config_path, readme_only, sections, output, verbose,
|
|
|
53
54
|
if llm_model:
|
|
54
55
|
config.llm.enabled = True
|
|
55
56
|
config.llm.model = llm_model
|
|
57
|
+
if org_name:
|
|
58
|
+
config.org_name = org_name
|
|
56
59
|
|
|
57
60
|
_run_generate(project_path, config, readme_only=readme_only, dry_run=dry_run)
|
|
58
61
|
|
|
@@ -108,6 +108,7 @@ class Code2DocsConfig:
|
|
|
108
108
|
output: str = "./docs/"
|
|
109
109
|
readme_output: str = "./README.md"
|
|
110
110
|
repo_url: str = "" # GitHub/GitLab URL for source links (auto-detected from git)
|
|
111
|
+
org_name: str = "" # Organization name for org-mode README generation
|
|
111
112
|
|
|
112
113
|
readme: ReadmeConfig = field(default_factory=ReadmeConfig)
|
|
113
114
|
docs: DocsConfig = field(default_factory=DocsConfig)
|
|
@@ -240,6 +240,35 @@ class Code2LlmAdapter(BaseGenerator):
|
|
|
240
240
|
return "⚠️ project/ (no files generated)"
|
|
241
241
|
|
|
242
242
|
|
|
243
|
+
class OrgReadmeAdapter(BaseGenerator):
|
|
244
|
+
"""Adapter for organization README generation."""
|
|
245
|
+
name = "org_readme"
|
|
246
|
+
|
|
247
|
+
def should_run(self, *, readme_only: bool = False) -> bool:
|
|
248
|
+
# Only run if org_name is set in config
|
|
249
|
+
return hasattr(self.config, 'org_name') and bool(self.config.org_name)
|
|
250
|
+
|
|
251
|
+
def run(self, ctx: GenerateContext) -> Optional[str]:
|
|
252
|
+
from .org_readme_gen import OrgReadmeGenerator
|
|
253
|
+
|
|
254
|
+
org_name = getattr(self.config, 'org_name', '')
|
|
255
|
+
if not org_name:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
gen = OrgReadmeGenerator(self.config, str(ctx.project), org_name)
|
|
259
|
+
content = gen.generate()
|
|
260
|
+
|
|
261
|
+
if ctx.dry_run:
|
|
262
|
+
click.echo(f"\n--- {org_name} README ({len(content)} chars) ---")
|
|
263
|
+
preview = content[:500] + "..." if len(content) > 500 else content
|
|
264
|
+
click.echo(preview)
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
readme_path = ctx.docs_dir / "README.md"
|
|
268
|
+
gen.write(str(readme_path), content)
|
|
269
|
+
return f"✅ {readme_path.relative_to(ctx.project)}"
|
|
270
|
+
|
|
271
|
+
|
|
243
272
|
ALL_ADAPTERS = [
|
|
244
273
|
ReadmeGeneratorAdapter,
|
|
245
274
|
ApiReferenceAdapter,
|
|
@@ -254,4 +283,5 @@ ALL_ADAPTERS = [
|
|
|
254
283
|
ContributingAdapter,
|
|
255
284
|
MkDocsAdapter,
|
|
256
285
|
Code2LlmAdapter,
|
|
286
|
+
OrgReadmeAdapter,
|
|
257
287
|
]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Organization README generator - generates overview of multiple projects."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from code2llm.api import AnalysisResult
|
|
7
|
+
|
|
8
|
+
from ..config import Code2DocsConfig
|
|
9
|
+
from ..analyzers.project_scanner import ProjectScanner
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OrgReadmeGenerator:
|
|
13
|
+
"""Generate organization README with list of projects and brief descriptions."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, config: Code2DocsConfig, org_path: str, org_name: str = ""):
|
|
16
|
+
self.config = config
|
|
17
|
+
self.org_path = Path(org_path).resolve()
|
|
18
|
+
self.org_name = org_name or self.org_path.name
|
|
19
|
+
self.scanner = ProjectScanner(config)
|
|
20
|
+
|
|
21
|
+
def generate(self) -> str:
|
|
22
|
+
"""Generate organization README content."""
|
|
23
|
+
projects = self._discover_projects()
|
|
24
|
+
|
|
25
|
+
lines = [
|
|
26
|
+
f"# {self.org_name}\n",
|
|
27
|
+
f"Projects in the {self.org_name} organization.\n",
|
|
28
|
+
f"**{len(projects)}** projects discovered.\n",
|
|
29
|
+
"## Projects\n",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
for project_name, project_info in sorted(projects.items()):
|
|
33
|
+
lines.append(self._render_project_section(project_name, project_info))
|
|
34
|
+
lines.append("")
|
|
35
|
+
|
|
36
|
+
return "\n".join(lines)
|
|
37
|
+
|
|
38
|
+
def _discover_projects(self) -> Dict[str, Dict]:
|
|
39
|
+
"""Discover all projects in organization directory."""
|
|
40
|
+
projects = {}
|
|
41
|
+
|
|
42
|
+
for item in self.org_path.iterdir():
|
|
43
|
+
if not item.is_dir():
|
|
44
|
+
continue
|
|
45
|
+
if item.name.startswith(".") or item.name.startswith("__"):
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
project_info = self._analyze_project(item)
|
|
49
|
+
if project_info:
|
|
50
|
+
projects[item.name] = project_info
|
|
51
|
+
|
|
52
|
+
return projects
|
|
53
|
+
|
|
54
|
+
def _analyze_project(self, project_path: Path) -> Optional[Dict]:
|
|
55
|
+
"""Analyze a single project and return summary info."""
|
|
56
|
+
try:
|
|
57
|
+
result = self.scanner.analyze(str(project_path))
|
|
58
|
+
|
|
59
|
+
# Extract description from first module docstring or pyproject.toml
|
|
60
|
+
description = self._extract_description(project_path, result)
|
|
61
|
+
|
|
62
|
+
# Count functions, classes, modules
|
|
63
|
+
func_count = len(result.functions)
|
|
64
|
+
class_count = len(result.classes)
|
|
65
|
+
module_count = len(result.modules)
|
|
66
|
+
|
|
67
|
+
# Get version from pyproject.toml if available
|
|
68
|
+
version = self._get_version(project_path)
|
|
69
|
+
|
|
70
|
+
# Get repo URL from git or config
|
|
71
|
+
repo_url = self._get_repo_url(project_path)
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
"name": project_path.name,
|
|
75
|
+
"description": description,
|
|
76
|
+
"version": version,
|
|
77
|
+
"stats": {
|
|
78
|
+
"functions": func_count,
|
|
79
|
+
"classes": class_count,
|
|
80
|
+
"modules": module_count,
|
|
81
|
+
},
|
|
82
|
+
"repo_url": repo_url,
|
|
83
|
+
"path": str(project_path),
|
|
84
|
+
}
|
|
85
|
+
except Exception:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def _extract_description(self, project_path: Path, result: AnalysisResult) -> str:
|
|
89
|
+
"""Extract short description from project (max 5 lines)."""
|
|
90
|
+
# Try pyproject.toml first
|
|
91
|
+
try:
|
|
92
|
+
import tomllib
|
|
93
|
+
pyproject = project_path / "pyproject.toml"
|
|
94
|
+
if pyproject.exists():
|
|
95
|
+
with open(pyproject, "rb") as f:
|
|
96
|
+
data = tomllib.load(f)
|
|
97
|
+
desc = data.get("project", {}).get("description", "")
|
|
98
|
+
if desc:
|
|
99
|
+
# Limit to ~5 lines worth of content
|
|
100
|
+
return self._truncate_description(desc)
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
# Try first package docstring
|
|
105
|
+
for mod in result.modules.values():
|
|
106
|
+
if mod.is_package and hasattr(mod, "docstring") and mod.docstring:
|
|
107
|
+
return self._truncate_description(mod.docstring)
|
|
108
|
+
|
|
109
|
+
# Try README.md first paragraph
|
|
110
|
+
readme = project_path / "README.md"
|
|
111
|
+
if readme.exists():
|
|
112
|
+
try:
|
|
113
|
+
content = readme.read_text(encoding="utf-8")
|
|
114
|
+
# Find first paragraph after title
|
|
115
|
+
lines = content.split("\n")
|
|
116
|
+
for i, line in enumerate(lines):
|
|
117
|
+
if line.startswith("# "):
|
|
118
|
+
# Get next non-empty lines
|
|
119
|
+
desc_lines = []
|
|
120
|
+
for j in range(i + 1, min(i + 10, len(lines))):
|
|
121
|
+
if lines[j].strip() and not lines[j].startswith("#"):
|
|
122
|
+
desc_lines.append(lines[j].strip())
|
|
123
|
+
if len(desc_lines) >= 5:
|
|
124
|
+
break
|
|
125
|
+
if desc_lines:
|
|
126
|
+
return " ".join(desc_lines)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
return "No description available."
|
|
131
|
+
|
|
132
|
+
def _truncate_description(self, desc: str, max_chars: int = 300) -> str:
|
|
133
|
+
"""Truncate description to ~5 lines of content."""
|
|
134
|
+
lines = desc.strip().split("\n")
|
|
135
|
+
# Filter out empty lines and headers
|
|
136
|
+
content_lines = [l.strip() for l in lines if l.strip() and not l.startswith("#")]
|
|
137
|
+
|
|
138
|
+
result = []
|
|
139
|
+
char_count = 0
|
|
140
|
+
for line in content_lines[:5]:
|
|
141
|
+
if char_count + len(line) > max_chars:
|
|
142
|
+
remaining = max_chars - char_count
|
|
143
|
+
if remaining > 20:
|
|
144
|
+
result.append(line[:remaining] + "...")
|
|
145
|
+
break
|
|
146
|
+
result.append(line)
|
|
147
|
+
char_count += len(line)
|
|
148
|
+
|
|
149
|
+
return " ".join(result) if result else "No description available."
|
|
150
|
+
|
|
151
|
+
def _get_version(self, project_path: Path) -> str:
|
|
152
|
+
"""Get version from pyproject.toml or VERSION file."""
|
|
153
|
+
try:
|
|
154
|
+
import tomllib
|
|
155
|
+
pyproject = project_path / "pyproject.toml"
|
|
156
|
+
if pyproject.exists():
|
|
157
|
+
with open(pyproject, "rb") as f:
|
|
158
|
+
data = tomllib.load(f)
|
|
159
|
+
return data.get("project", {}).get("version", "")
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
version_file = project_path / "VERSION"
|
|
164
|
+
if version_file.exists():
|
|
165
|
+
return version_file.read_text(encoding="utf-8").strip()
|
|
166
|
+
|
|
167
|
+
return ""
|
|
168
|
+
|
|
169
|
+
def _get_repo_url(self, project_path: Path) -> str:
|
|
170
|
+
"""Get repository URL from git or pyproject.toml."""
|
|
171
|
+
# Try pyproject.toml
|
|
172
|
+
try:
|
|
173
|
+
import tomllib
|
|
174
|
+
pyproject = project_path / "pyproject.toml"
|
|
175
|
+
if pyproject.exists():
|
|
176
|
+
with open(pyproject, "rb") as f:
|
|
177
|
+
data = tomllib.load(f)
|
|
178
|
+
urls = data.get("project", {}).get("urls", {})
|
|
179
|
+
if urls:
|
|
180
|
+
return urls.get("Repository", urls.get("Homepage", ""))
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
# Try git remote
|
|
185
|
+
try:
|
|
186
|
+
import subprocess
|
|
187
|
+
result = subprocess.run(
|
|
188
|
+
["git", "remote", "get-url", "origin"],
|
|
189
|
+
cwd=str(project_path),
|
|
190
|
+
capture_output=True, text=True, timeout=5,
|
|
191
|
+
)
|
|
192
|
+
if result.returncode == 0:
|
|
193
|
+
url = result.stdout.strip()
|
|
194
|
+
# Convert SSH to HTTPS
|
|
195
|
+
if url.startswith("git@"):
|
|
196
|
+
url = url.replace(":", "/", 1).replace("git@", "https://", 1)
|
|
197
|
+
return url.removesuffix(".git")
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
return ""
|
|
202
|
+
|
|
203
|
+
def _render_project_section(self, name: str, info: Dict) -> str:
|
|
204
|
+
"""Render a single project section (5 lines max)."""
|
|
205
|
+
lines = [f"### {name}"]
|
|
206
|
+
|
|
207
|
+
# Line 1: Description
|
|
208
|
+
lines.append(info["description"])
|
|
209
|
+
|
|
210
|
+
# Line 2: Stats
|
|
211
|
+
stats = info["stats"]
|
|
212
|
+
stats_line = f"📊 {stats['functions']} functions | {stats['classes']} classes | {stats['modules']} modules"
|
|
213
|
+
if info["version"]:
|
|
214
|
+
stats_line += f" | v{info['version']}"
|
|
215
|
+
lines.append(stats_line)
|
|
216
|
+
|
|
217
|
+
# Line 3: Repo link if available
|
|
218
|
+
if info["repo_url"]:
|
|
219
|
+
lines.append(f"🔗 [{info['repo_url']}]({info['repo_url']})")
|
|
220
|
+
|
|
221
|
+
return "\n".join(lines)
|
|
222
|
+
|
|
223
|
+
def write(self, output_path: str, content: str) -> None:
|
|
224
|
+
"""Write README to output path."""
|
|
225
|
+
out_path = Path(output_path)
|
|
226
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
out_path.write_text(content, encoding="utf-8")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code2docs
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.6
|
|
4
4
|
Summary: Auto-generate and sync project documentation from source code analysis
|
|
5
5
|
Author-email: Tom Sapletta <tom@sapletta.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -50,7 +50,7 @@ Dynamic: license-file
|
|
|
50
50
|
|
|
51
51
|
# code2docs
|
|
52
52
|
|
|
53
|
-
  
|
|
54
54
|
|
|
55
55
|
> Auto-generate and sync project documentation from source code analysis.
|
|
56
56
|
|
|
@@ -190,7 +190,7 @@ code2docs can update only specific sections of an existing README using markers:
|
|
|
190
190
|
```markdown
|
|
191
191
|
<!-- code2docs:start --># code2docs
|
|
192
192
|
|
|
193
|
-
   
|
|
194
194
|
> **276** functions | **57** classes | **51** files | CC̄ = 3.8
|
|
195
195
|
|
|
196
196
|
> Auto-generated project documentation from source code analysis.
|
|
@@ -41,6 +41,7 @@ code2docs/generators/examples_gen.py
|
|
|
41
41
|
code2docs/generators/getting_started_gen.py
|
|
42
42
|
code2docs/generators/mkdocs_gen.py
|
|
43
43
|
code2docs/generators/module_docs_gen.py
|
|
44
|
+
code2docs/generators/org_readme_gen.py
|
|
44
45
|
code2docs/generators/readme_gen.py
|
|
45
46
|
code2docs/sync/__init__.py
|
|
46
47
|
code2docs/sync/differ.py
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""CLI smoke tests using Click's CliRunner and subprocess e2e tests."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from click.testing import CliRunner
|
|
10
|
+
|
|
11
|
+
from code2docs.cli import main
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestCLI:
|
|
15
|
+
"""Unit tests using Click's CliRunner."""
|
|
16
|
+
|
|
17
|
+
def setup_method(self):
|
|
18
|
+
self.runner = CliRunner()
|
|
19
|
+
|
|
20
|
+
def test_help(self):
|
|
21
|
+
result = self.runner.invoke(main, ["--help"])
|
|
22
|
+
assert result.exit_code == 0
|
|
23
|
+
assert "code2docs" in result.output
|
|
24
|
+
assert "generate" in result.output
|
|
25
|
+
assert "sync" in result.output
|
|
26
|
+
assert "init" in result.output
|
|
27
|
+
|
|
28
|
+
def test_generate_help(self):
|
|
29
|
+
result = self.runner.invoke(main, ["generate", "--help"])
|
|
30
|
+
assert result.exit_code == 0
|
|
31
|
+
assert "--readme-only" in result.output
|
|
32
|
+
assert "--dry-run" in result.output
|
|
33
|
+
assert "--verbose" in result.output
|
|
34
|
+
|
|
35
|
+
def test_sync_help(self):
|
|
36
|
+
result = self.runner.invoke(main, ["sync", "--help"])
|
|
37
|
+
assert result.exit_code == 0
|
|
38
|
+
assert "Synchronize" in result.output
|
|
39
|
+
|
|
40
|
+
def test_init_creates_config(self):
|
|
41
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
42
|
+
result = self.runner.invoke(main, ["init", tmpdir])
|
|
43
|
+
assert result.exit_code == 0
|
|
44
|
+
assert (Path(tmpdir) / "code2docs.yaml").exists()
|
|
45
|
+
|
|
46
|
+
def test_generate_dry_run(self):
|
|
47
|
+
result = self.runner.invoke(main, [".", "--dry-run"])
|
|
48
|
+
assert result.exit_code == 0
|
|
49
|
+
assert "code2docs" in result.output
|
|
50
|
+
assert "README.md" in result.output
|
|
51
|
+
assert "dry-run" in result.output
|
|
52
|
+
assert "Done!" in result.output
|
|
53
|
+
|
|
54
|
+
def test_readme_only_dry_run(self):
|
|
55
|
+
result = self.runner.invoke(main, [".", "--readme-only", "--dry-run"])
|
|
56
|
+
assert result.exit_code == 0
|
|
57
|
+
assert "README.md" in result.output
|
|
58
|
+
# Should NOT have docs/api since --readme-only
|
|
59
|
+
assert "docs/api" not in result.output
|
|
60
|
+
|
|
61
|
+
def test_default_group_routes_path(self):
|
|
62
|
+
"""Ensure bare path argument is routed to generate."""
|
|
63
|
+
result = self.runner.invoke(main, [".", "--dry-run"])
|
|
64
|
+
assert result.exit_code == 0
|
|
65
|
+
assert "Done!" in result.output
|
|
66
|
+
|
|
67
|
+
def test_verbose(self):
|
|
68
|
+
result = self.runner.invoke(main, [".", "--verbose", "--dry-run"])
|
|
69
|
+
assert result.exit_code == 0
|
|
70
|
+
assert "Functions" in result.output
|
|
71
|
+
assert "Classes" in result.output
|
|
72
|
+
assert "Modules" in result.output
|
|
73
|
+
|
|
74
|
+
def test_check_help(self):
|
|
75
|
+
result = self.runner.invoke(main, ["check", "--help"])
|
|
76
|
+
assert result.exit_code == 0
|
|
77
|
+
assert "Health check" in result.output
|
|
78
|
+
assert "--target" in result.output
|
|
79
|
+
|
|
80
|
+
def test_check_runs(self):
|
|
81
|
+
result = self.runner.invoke(main, ["check", "."])
|
|
82
|
+
assert result.exit_code == 0
|
|
83
|
+
assert "check" in result.output
|
|
84
|
+
assert "Score:" in result.output
|
|
85
|
+
|
|
86
|
+
def test_diff_help(self):
|
|
87
|
+
result = self.runner.invoke(main, ["diff", "--help"])
|
|
88
|
+
assert result.exit_code == 0
|
|
89
|
+
assert "Preview" in result.output
|
|
90
|
+
|
|
91
|
+
def test_diff_runs(self):
|
|
92
|
+
result = self.runner.invoke(main, ["diff", "."])
|
|
93
|
+
assert result.exit_code == 0
|
|
94
|
+
assert "diff" in result.output
|
|
95
|
+
assert "dry-run" in result.output
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestCLIShellE2E:
|
|
99
|
+
"""E2E tests using actual shell subprocess calls."""
|
|
100
|
+
|
|
101
|
+
@pytest.fixture
|
|
102
|
+
def temp_project(self):
|
|
103
|
+
"""Create a temporary project with Python files."""
|
|
104
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
105
|
+
project_dir = Path(tmpdir) / "test_project"
|
|
106
|
+
project_dir.mkdir()
|
|
107
|
+
|
|
108
|
+
# Create a simple Python package
|
|
109
|
+
pkg_dir = project_dir / "my_pkg"
|
|
110
|
+
pkg_dir.mkdir()
|
|
111
|
+
(pkg_dir / "__init__.py").write_text('"""My package."""\n')
|
|
112
|
+
(pkg_dir / "module.py").write_text(
|
|
113
|
+
'"""A module."""\n\ndef hello():\n """Say hello."""\n return "Hello"\n'
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Create pyproject.toml
|
|
117
|
+
(project_dir / "pyproject.toml").write_text(
|
|
118
|
+
'[project]\nname = "test-project"\nversion = "0.1.0"\n'
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
yield project_dir
|
|
122
|
+
|
|
123
|
+
def _run_shell(self, cmd: list, cwd: Path = None) -> subprocess.CompletedProcess:
|
|
124
|
+
"""Run command in shell and return result."""
|
|
125
|
+
return subprocess.run(
|
|
126
|
+
cmd,
|
|
127
|
+
cwd=str(cwd) if cwd else None,
|
|
128
|
+
capture_output=True,
|
|
129
|
+
text=True,
|
|
130
|
+
timeout=30,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def test_shell_generate(self, temp_project):
|
|
134
|
+
"""E2E: Generate docs via shell."""
|
|
135
|
+
result = self._run_shell(
|
|
136
|
+
[sys.executable, "-m", "code2docs", str(temp_project), "--dry-run"],
|
|
137
|
+
)
|
|
138
|
+
assert result.returncode == 0
|
|
139
|
+
assert "code2docs" in result.stdout
|
|
140
|
+
|
|
141
|
+
def test_shell_generate_creates_files(self, temp_project):
|
|
142
|
+
"""E2E: Generate actually creates output files."""
|
|
143
|
+
output_dir = temp_project / "docs_output"
|
|
144
|
+
|
|
145
|
+
result = self._run_shell(
|
|
146
|
+
[sys.executable, "-m", "code2docs", str(temp_project),
|
|
147
|
+
"--output", str(output_dir)],
|
|
148
|
+
cwd=temp_project,
|
|
149
|
+
)
|
|
150
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
151
|
+
assert "Done!" in result.stdout
|
|
152
|
+
|
|
153
|
+
# Check files were created
|
|
154
|
+
assert (temp_project / "README.md").exists()
|
|
155
|
+
|
|
156
|
+
def test_shell_init(self, temp_project):
|
|
157
|
+
"""E2E: Init command creates config file."""
|
|
158
|
+
result = self._run_shell(
|
|
159
|
+
[sys.executable, "-m", "code2docs", "init", str(temp_project)],
|
|
160
|
+
)
|
|
161
|
+
assert result.returncode == 0
|
|
162
|
+
assert (temp_project / "code2docs.yaml").exists()
|
|
163
|
+
|
|
164
|
+
def test_shell_check(self, temp_project):
|
|
165
|
+
"""E2E: Check command runs successfully."""
|
|
166
|
+
# First generate some docs
|
|
167
|
+
self._run_shell(
|
|
168
|
+
[sys.executable, "-m", "code2docs", str(temp_project)],
|
|
169
|
+
cwd=temp_project,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
result = self._run_shell(
|
|
173
|
+
[sys.executable, "-m", "code2docs", "check", str(temp_project)],
|
|
174
|
+
)
|
|
175
|
+
assert result.returncode == 0
|
|
176
|
+
assert "Health" in result.stdout or "check" in result.stdout
|
|
177
|
+
assert "Score:" in result.stdout
|
|
178
|
+
|
|
179
|
+
def test_shell_diff(self, temp_project):
|
|
180
|
+
"""E2E: Diff command runs successfully."""
|
|
181
|
+
result = self._run_shell(
|
|
182
|
+
[sys.executable, "-m", "code2docs", "diff", str(temp_project)],
|
|
183
|
+
)
|
|
184
|
+
assert result.returncode == 0
|
|
185
|
+
assert "diff" in result.stdout.lower()
|
|
186
|
+
|
|
187
|
+
def test_shell_help(self):
|
|
188
|
+
"""E2E: Help command works."""
|
|
189
|
+
result = self._run_shell(
|
|
190
|
+
[sys.executable, "-m", "code2docs", "--help"],
|
|
191
|
+
)
|
|
192
|
+
assert result.returncode == 0
|
|
193
|
+
assert "code2docs" in result.stdout
|
|
194
|
+
assert "generate" in result.stdout
|
|
195
|
+
|
|
196
|
+
def test_shell_generate_with_sections(self, temp_project):
|
|
197
|
+
"""E2E: Generate with --sections option."""
|
|
198
|
+
result = self._run_shell(
|
|
199
|
+
[sys.executable, "-m", "code2docs", str(temp_project),
|
|
200
|
+
"--sections", "overview,install", "--dry-run"],
|
|
201
|
+
cwd=temp_project,
|
|
202
|
+
)
|
|
203
|
+
assert result.returncode == 0
|
|
204
|
+
|
|
205
|
+
def test_shell_version(self):
|
|
206
|
+
"""E2E: Check version output."""
|
|
207
|
+
result = self._run_shell(
|
|
208
|
+
[sys.executable, "-c", "import code2docs; print(code2docs.__version__)"],
|
|
209
|
+
)
|
|
210
|
+
assert result.returncode == 0
|
|
211
|
+
assert "." in result.stdout.strip()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class TestCLINewFeaturesE2E:
|
|
215
|
+
"""E2E tests for new CLI features (--org-name)."""
|
|
216
|
+
|
|
217
|
+
@pytest.fixture
|
|
218
|
+
def temp_org(self):
|
|
219
|
+
"""Create a temporary organization with multiple projects."""
|
|
220
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
221
|
+
org_dir = Path(tmpdir) / "test_org"
|
|
222
|
+
org_dir.mkdir()
|
|
223
|
+
|
|
224
|
+
# Create project 1
|
|
225
|
+
proj1 = org_dir / "project1"
|
|
226
|
+
proj1.mkdir()
|
|
227
|
+
(proj1 / "pyproject.toml").write_text(
|
|
228
|
+
'[project]\nname = "project1"\nversion = "0.1.0"\n'
|
|
229
|
+
'description = "First project"\n'
|
|
230
|
+
)
|
|
231
|
+
pkg1 = proj1 / "pkg1"
|
|
232
|
+
pkg1.mkdir()
|
|
233
|
+
(pkg1 / "__init__.py").write_text('"""Package 1."""\n')
|
|
234
|
+
|
|
235
|
+
# Create project 2
|
|
236
|
+
proj2 = org_dir / "project2"
|
|
237
|
+
proj2.mkdir()
|
|
238
|
+
(proj2 / "pyproject.toml").write_text(
|
|
239
|
+
'[project]\nname = "project2"\nversion = "0.2.0"\n'
|
|
240
|
+
'description = "Second project"\n'
|
|
241
|
+
)
|
|
242
|
+
pkg2 = proj2 / "pkg2"
|
|
243
|
+
pkg2.mkdir()
|
|
244
|
+
(pkg2 / "__init__.py").write_text('"""Package 2."""\n')
|
|
245
|
+
|
|
246
|
+
yield org_dir
|
|
247
|
+
|
|
248
|
+
def _run_shell(self, cmd: list, cwd: Path = None) -> subprocess.CompletedProcess:
|
|
249
|
+
"""Run command in shell and return result."""
|
|
250
|
+
return subprocess.run(
|
|
251
|
+
cmd,
|
|
252
|
+
cwd=str(cwd) if cwd else None,
|
|
253
|
+
capture_output=True,
|
|
254
|
+
text=True,
|
|
255
|
+
timeout=30,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def test_shell_org_mode(self, temp_org):
|
|
259
|
+
"""E2E: Generate organization README with --org-name."""
|
|
260
|
+
output_dir = temp_org / "articles"
|
|
261
|
+
|
|
262
|
+
result = self._run_shell(
|
|
263
|
+
[sys.executable, "-m", "code2docs", str(temp_org),
|
|
264
|
+
"--output", str(output_dir),
|
|
265
|
+
"--org-name", "TestOrg"],
|
|
266
|
+
cwd=temp_org,
|
|
267
|
+
)
|
|
268
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
269
|
+
assert "Done!" in result.stdout
|
|
270
|
+
|
|
271
|
+
# Check org README was created
|
|
272
|
+
org_readme = output_dir / "README.md"
|
|
273
|
+
assert org_readme.exists(), f"Files in {output_dir}: {list(output_dir.iterdir())}"
|
|
274
|
+
|
|
275
|
+
content = org_readme.read_text()
|
|
276
|
+
assert "TestOrg" in content
|
|
277
|
+
assert "project1" in content
|
|
278
|
+
assert "project2" in content
|
|
279
|
+
|
|
280
|
+
def test_shell_org_mode_dry_run(self, temp_org):
|
|
281
|
+
"""E2E: Organization mode dry run."""
|
|
282
|
+
result = self._run_shell(
|
|
283
|
+
[sys.executable, "-m", "code2docs", str(temp_org),
|
|
284
|
+
"--org-name", "TestOrg", "--dry-run"],
|
|
285
|
+
)
|
|
286
|
+
assert result.returncode == 0
|
|
287
|
+
assert "TestOrg" in result.stdout or "dry-run" in result.stdout
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
"""CLI smoke tests using Click's CliRunner."""
|
|
2
|
-
|
|
3
|
-
import tempfile
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
from click.testing import CliRunner
|
|
7
|
-
|
|
8
|
-
from code2docs.cli import main
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class TestCLI:
|
|
12
|
-
def setup_method(self):
|
|
13
|
-
self.runner = CliRunner()
|
|
14
|
-
|
|
15
|
-
def test_help(self):
|
|
16
|
-
result = self.runner.invoke(main, ["--help"])
|
|
17
|
-
assert result.exit_code == 0
|
|
18
|
-
assert "code2docs" in result.output
|
|
19
|
-
assert "generate" in result.output
|
|
20
|
-
assert "sync" in result.output
|
|
21
|
-
assert "init" in result.output
|
|
22
|
-
|
|
23
|
-
def test_generate_help(self):
|
|
24
|
-
result = self.runner.invoke(main, ["generate", "--help"])
|
|
25
|
-
assert result.exit_code == 0
|
|
26
|
-
assert "--readme-only" in result.output
|
|
27
|
-
assert "--dry-run" in result.output
|
|
28
|
-
assert "--verbose" in result.output
|
|
29
|
-
|
|
30
|
-
def test_sync_help(self):
|
|
31
|
-
result = self.runner.invoke(main, ["sync", "--help"])
|
|
32
|
-
assert result.exit_code == 0
|
|
33
|
-
assert "Synchronize" in result.output
|
|
34
|
-
|
|
35
|
-
def test_init_creates_config(self):
|
|
36
|
-
with tempfile.TemporaryDirectory() as tmpdir:
|
|
37
|
-
result = self.runner.invoke(main, ["init", tmpdir])
|
|
38
|
-
assert result.exit_code == 0
|
|
39
|
-
assert (Path(tmpdir) / "code2docs.yaml").exists()
|
|
40
|
-
|
|
41
|
-
def test_generate_dry_run(self):
|
|
42
|
-
result = self.runner.invoke(main, [".", "--dry-run"])
|
|
43
|
-
assert result.exit_code == 0
|
|
44
|
-
assert "code2docs" in result.output
|
|
45
|
-
assert "README.md" in result.output
|
|
46
|
-
assert "dry-run" in result.output
|
|
47
|
-
assert "Done!" in result.output
|
|
48
|
-
|
|
49
|
-
def test_readme_only_dry_run(self):
|
|
50
|
-
result = self.runner.invoke(main, [".", "--readme-only", "--dry-run"])
|
|
51
|
-
assert result.exit_code == 0
|
|
52
|
-
assert "README.md" in result.output
|
|
53
|
-
# Should NOT have docs/api since --readme-only
|
|
54
|
-
assert "docs/api" not in result.output
|
|
55
|
-
|
|
56
|
-
def test_default_group_routes_path(self):
|
|
57
|
-
"""Ensure bare path argument is routed to generate."""
|
|
58
|
-
result = self.runner.invoke(main, [".", "--dry-run"])
|
|
59
|
-
assert result.exit_code == 0
|
|
60
|
-
assert "Done!" in result.output
|
|
61
|
-
|
|
62
|
-
def test_verbose(self):
|
|
63
|
-
result = self.runner.invoke(main, [".", "--verbose", "--dry-run"])
|
|
64
|
-
assert result.exit_code == 0
|
|
65
|
-
assert "Functions" in result.output
|
|
66
|
-
assert "Classes" in result.output
|
|
67
|
-
assert "Modules" in result.output
|
|
68
|
-
|
|
69
|
-
def test_check_help(self):
|
|
70
|
-
result = self.runner.invoke(main, ["check", "--help"])
|
|
71
|
-
assert result.exit_code == 0
|
|
72
|
-
assert "Health check" in result.output
|
|
73
|
-
assert "--target" in result.output
|
|
74
|
-
|
|
75
|
-
def test_check_runs(self):
|
|
76
|
-
result = self.runner.invoke(main, ["check", "."])
|
|
77
|
-
assert result.exit_code == 0
|
|
78
|
-
assert "check" in result.output
|
|
79
|
-
assert "Score:" in result.output
|
|
80
|
-
|
|
81
|
-
def test_diff_help(self):
|
|
82
|
-
result = self.runner.invoke(main, ["diff", "--help"])
|
|
83
|
-
assert result.exit_code == 0
|
|
84
|
-
assert "Preview" in result.output
|
|
85
|
-
|
|
86
|
-
def test_diff_runs(self):
|
|
87
|
-
result = self.runner.invoke(main, ["diff", "."])
|
|
88
|
-
assert result.exit_code == 0
|
|
89
|
-
assert "diff" in result.output
|
|
90
|
-
assert "dry-run" in result.output
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|