code2docs 0.1.1__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.
code2docs/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """
2
+ code2docs - Auto-generate and sync project documentation from source code.
3
+
4
+ Uses code2llm's AnalysisResult to produce human-readable documentation:
5
+ README.md, API references, module docs, examples, and architecture diagrams.
6
+ """
7
+
8
+ __version__ = "0.1.1"
9
+ __author__ = "Tom Sapletta"
10
+
11
+ from .config import Code2DocsConfig
12
+
13
+ __all__ = [
14
+ "Code2DocsConfig",
15
+ "generate_readme",
16
+ "generate_docs",
17
+ "analyze_and_document",
18
+ ]
19
+
20
+
21
+ def __getattr__(name):
22
+ """Lazy import heavy modules on first access."""
23
+ if name == "generate_readme":
24
+ from .generators.readme_gen import generate_readme
25
+ return generate_readme
26
+ if name == "generate_docs":
27
+ from .generators import generate_docs
28
+ return generate_docs
29
+ if name == "analyze_and_document":
30
+ from .analyzers.project_scanner import analyze_and_document
31
+ return analyze_and_document
32
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
code2docs/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running code2docs as: python -m code2docs"""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,13 @@
1
+ """Analyzers — adapters to code2llm and custom detectors."""
2
+
3
+ from .project_scanner import ProjectScanner
4
+ from .endpoint_detector import EndpointDetector
5
+ from .docstring_extractor import DocstringExtractor
6
+ from .dependency_scanner import DependencyScanner
7
+
8
+ __all__ = [
9
+ "ProjectScanner",
10
+ "EndpointDetector",
11
+ "DocstringExtractor",
12
+ "DependencyScanner",
13
+ ]
@@ -0,0 +1,159 @@
1
+ """Scan project dependencies from requirements.txt, pyproject.toml, setup.py."""
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Dict, List, Optional
7
+
8
+ try:
9
+ import tomllib
10
+ except ImportError:
11
+ try:
12
+ import tomli as tomllib
13
+ except ImportError:
14
+ tomllib = None # type: ignore
15
+
16
+
17
+ @dataclass
18
+ class DependencyInfo:
19
+ """Information about a project dependency."""
20
+ name: str
21
+ version_spec: str = ""
22
+ optional: bool = False
23
+ group: str = "main"
24
+
25
+
26
+ @dataclass
27
+ class ProjectDependencies:
28
+ """All detected project dependencies."""
29
+ python_version: str = ""
30
+ dependencies: List[DependencyInfo] = field(default_factory=list)
31
+ dev_dependencies: List[DependencyInfo] = field(default_factory=list)
32
+ optional_groups: Dict[str, List[DependencyInfo]] = field(default_factory=dict)
33
+ install_command: str = "pip install ."
34
+ source_file: str = ""
35
+
36
+
37
+ class DependencyScanner:
38
+ """Scan and parse project dependency files."""
39
+
40
+ def scan(self, project_path: str) -> ProjectDependencies:
41
+ """Scan project for dependency information."""
42
+ project = Path(project_path)
43
+ deps = ProjectDependencies()
44
+
45
+ # Priority: pyproject.toml > setup.py > requirements.txt
46
+ pyproject = project / "pyproject.toml"
47
+ if pyproject.exists():
48
+ deps = self._parse_pyproject(pyproject)
49
+ deps.source_file = "pyproject.toml"
50
+ return deps
51
+
52
+ setup_py = project / "setup.py"
53
+ if setup_py.exists():
54
+ deps = self._parse_setup_py(setup_py)
55
+ deps.source_file = "setup.py"
56
+ return deps
57
+
58
+ req_txt = project / "requirements.txt"
59
+ if req_txt.exists():
60
+ deps = self._parse_requirements_txt(req_txt)
61
+ deps.source_file = "requirements.txt"
62
+ return deps
63
+
64
+ return deps
65
+
66
+ def _parse_pyproject(self, path: Path) -> ProjectDependencies:
67
+ """Parse pyproject.toml for dependencies."""
68
+ deps = ProjectDependencies()
69
+
70
+ if tomllib is None:
71
+ # Fallback: regex-based parsing
72
+ return self._parse_pyproject_regex(path)
73
+
74
+ with open(path, "rb") as f:
75
+ data = tomllib.load(f)
76
+
77
+ project = data.get("project", {})
78
+ deps.python_version = project.get("requires-python", "")
79
+
80
+ # Main dependencies
81
+ for dep_str in project.get("dependencies", []):
82
+ deps.dependencies.append(self._parse_dep_string(dep_str))
83
+
84
+ # Optional dependencies
85
+ for group, dep_list in project.get("optional-dependencies", {}).items():
86
+ group_deps = [self._parse_dep_string(d) for d in dep_list]
87
+ for d in group_deps:
88
+ d.optional = True
89
+ d.group = group
90
+ deps.optional_groups[group] = group_deps
91
+ if group == "dev":
92
+ deps.dev_dependencies = group_deps
93
+
94
+ # Install command
95
+ name = project.get("name", "")
96
+ if name:
97
+ deps.install_command = f"pip install {name}"
98
+
99
+ return deps
100
+
101
+ def _parse_pyproject_regex(self, path: Path) -> ProjectDependencies:
102
+ """Fallback regex-based pyproject.toml parser."""
103
+ deps = ProjectDependencies()
104
+ content = path.read_text(encoding="utf-8")
105
+
106
+ # Extract dependencies array
107
+ dep_match = re.search(r'dependencies\s*=\s*\[(.*?)\]', content, re.DOTALL)
108
+ if dep_match:
109
+ for dep_str in re.findall(r'"([^"]+)"', dep_match.group(1)):
110
+ deps.dependencies.append(self._parse_dep_string(dep_str))
111
+
112
+ # Extract python version
113
+ py_match = re.search(r'requires-python\s*=\s*"([^"]+)"', content)
114
+ if py_match:
115
+ deps.python_version = py_match.group(1)
116
+
117
+ return deps
118
+
119
+ def _parse_setup_py(self, path: Path) -> ProjectDependencies:
120
+ """Parse setup.py for dependencies (regex-based, no exec)."""
121
+ deps = ProjectDependencies()
122
+ content = path.read_text(encoding="utf-8")
123
+
124
+ # install_requires
125
+ match = re.search(r'install_requires\s*=\s*\[(.*?)\]', content, re.DOTALL)
126
+ if match:
127
+ for dep_str in re.findall(r'"([^"]+)"', match.group(1)):
128
+ deps.dependencies.append(self._parse_dep_string(dep_str))
129
+
130
+ # python_requires
131
+ py_match = re.search(r'python_requires\s*=\s*"([^"]+)"', content)
132
+ if py_match:
133
+ deps.python_version = py_match.group(1)
134
+
135
+ return deps
136
+
137
+ def _parse_requirements_txt(self, path: Path) -> ProjectDependencies:
138
+ """Parse requirements.txt."""
139
+ deps = ProjectDependencies()
140
+
141
+ for line in path.read_text(encoding="utf-8").splitlines():
142
+ line = line.strip()
143
+ if not line or line.startswith("#") or line.startswith("-"):
144
+ continue
145
+ deps.dependencies.append(self._parse_dep_string(line))
146
+
147
+ deps.install_command = "pip install -r requirements.txt"
148
+ return deps
149
+
150
+ @staticmethod
151
+ def _parse_dep_string(dep_str: str) -> DependencyInfo:
152
+ """Parse a dependency string like 'package>=1.0'."""
153
+ match = re.match(r'^([a-zA-Z0-9_-]+)\s*(.*)', dep_str.strip())
154
+ if match:
155
+ return DependencyInfo(
156
+ name=match.group(1),
157
+ version_spec=match.group(2).strip(),
158
+ )
159
+ return DependencyInfo(name=dep_str.strip())
@@ -0,0 +1,111 @@
1
+ """Extract and analyze docstrings from source code."""
2
+
3
+ import ast
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Dict, List, Optional, Tuple
7
+
8
+ from code2llm.core.models import AnalysisResult, FunctionInfo, ClassInfo
9
+
10
+
11
+ @dataclass
12
+ class DocstringInfo:
13
+ """Parsed docstring with sections."""
14
+ raw: str
15
+ summary: str = ""
16
+ description: str = ""
17
+ params: Dict[str, str] = field(default_factory=dict)
18
+ returns: str = ""
19
+ raises: List[str] = field(default_factory=list)
20
+ examples: List[str] = field(default_factory=list)
21
+
22
+
23
+ class DocstringExtractor:
24
+ """Extract and parse docstrings from AnalysisResult."""
25
+
26
+ def extract_all(self, result: AnalysisResult) -> Dict[str, DocstringInfo]:
27
+ """Extract docstrings for all functions and classes."""
28
+ docs: Dict[str, DocstringInfo] = {}
29
+
30
+ for name, func in result.functions.items():
31
+ if func.docstring:
32
+ docs[name] = self.parse(func.docstring)
33
+
34
+ for name, cls in result.classes.items():
35
+ if cls.docstring:
36
+ docs[name] = self.parse(cls.docstring)
37
+
38
+ return docs
39
+
40
+ def parse(self, docstring: str) -> DocstringInfo:
41
+ """Parse a docstring into structured sections."""
42
+ if not docstring:
43
+ return DocstringInfo(raw="")
44
+
45
+ lines = docstring.strip().splitlines()
46
+ info = DocstringInfo(raw=docstring)
47
+
48
+ if lines:
49
+ info.summary = lines[0].strip()
50
+
51
+ # Simple parser for Google/Numpy/Sphinx styles
52
+ current_section = "description"
53
+ desc_lines: List[str] = []
54
+ param_lines: List[Tuple[str, str]] = []
55
+
56
+ for line in lines[1:]:
57
+ stripped = line.strip()
58
+ lower = stripped.lower()
59
+
60
+ if lower.startswith(("args:", "parameters:", "params:")):
61
+ current_section = "params"
62
+ continue
63
+ elif lower.startswith(("returns:", "return:")):
64
+ current_section = "returns"
65
+ continue
66
+ elif lower.startswith(("raises:", "raise:")):
67
+ current_section = "raises"
68
+ continue
69
+ elif lower.startswith(("example:", "examples:", ">>>")):
70
+ current_section = "examples"
71
+ if stripped.startswith(">>>"):
72
+ info.examples.append(stripped)
73
+ continue
74
+
75
+ if current_section == "description":
76
+ desc_lines.append(stripped)
77
+ elif current_section == "params" and stripped:
78
+ # Parse "name: description" or "name (type): description"
79
+ if ":" in stripped:
80
+ pname, pdesc = stripped.split(":", 1)
81
+ info.params[pname.strip()] = pdesc.strip()
82
+ elif current_section == "returns" and stripped:
83
+ info.returns = stripped
84
+ elif current_section == "raises" and stripped:
85
+ info.raises.append(stripped)
86
+ elif current_section == "examples" and stripped:
87
+ info.examples.append(stripped)
88
+
89
+ info.description = "\n".join(desc_lines).strip()
90
+ return info
91
+
92
+ def coverage_report(self, result: AnalysisResult) -> Dict[str, float]:
93
+ """Calculate docstring coverage statistics."""
94
+ total_funcs = len(result.functions)
95
+ total_classes = len(result.classes)
96
+ documented_funcs = sum(1 for f in result.functions.values() if f.docstring)
97
+ documented_classes = sum(1 for c in result.classes.values() if c.docstring)
98
+
99
+ return {
100
+ "functions_total": total_funcs,
101
+ "functions_documented": documented_funcs,
102
+ "functions_coverage": (documented_funcs / total_funcs * 100) if total_funcs else 0,
103
+ "classes_total": total_classes,
104
+ "classes_documented": documented_classes,
105
+ "classes_coverage": (documented_classes / total_classes * 100) if total_classes else 0,
106
+ "overall_coverage": (
107
+ (documented_funcs + documented_classes)
108
+ / (total_funcs + total_classes)
109
+ * 100
110
+ ) if (total_funcs + total_classes) else 0,
111
+ }
@@ -0,0 +1,116 @@
1
+ """Detect web framework endpoints (Flask, FastAPI, Django) from AST analysis."""
2
+
3
+ import ast
4
+ import re
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Dict, List, Optional
8
+
9
+ from code2llm.core.models import AnalysisResult, FunctionInfo
10
+
11
+
12
+ @dataclass
13
+ class Endpoint:
14
+ """Represents a detected web endpoint."""
15
+ method: str # GET, POST, PUT, DELETE, etc.
16
+ path: str
17
+ function_name: str
18
+ file: str
19
+ line: int
20
+ framework: str # flask, fastapi, django
21
+ docstring: Optional[str] = None
22
+ params: List[str] = field(default_factory=list)
23
+ return_type: Optional[str] = None
24
+
25
+
26
+ class EndpointDetector:
27
+ """Detects web endpoints from decorator patterns in source code."""
28
+
29
+ FLASK_PATTERNS = re.compile(
30
+ r'@(?:app|blueprint|bp)\.(route|get|post|put|delete|patch)\s*\(\s*["\']([^"\']+)["\']'
31
+ )
32
+ FASTAPI_PATTERNS = re.compile(
33
+ r'@(?:app|router)\.(get|post|put|delete|patch|options|head)\s*\(\s*["\']([^"\']+)["\']'
34
+ )
35
+ DJANGO_URL_PATTERN = re.compile(
36
+ r'(?:path|re_path|url)\s*\(\s*["\']([^"\']+)["\']'
37
+ )
38
+
39
+ def detect(self, result: AnalysisResult, project_path: str) -> List[Endpoint]:
40
+ """Detect all endpoints from the analysis result."""
41
+ endpoints: List[Endpoint] = []
42
+
43
+ for qualified_name, func_info in result.functions.items():
44
+ file_path = func_info.file
45
+ if not file_path:
46
+ continue
47
+
48
+ # Check decorators for route patterns
49
+ for decorator in func_info.decorators:
50
+ endpoint = self._parse_decorator(decorator, func_info)
51
+ if endpoint:
52
+ endpoints.append(endpoint)
53
+
54
+ # Also scan for Django URL patterns in urls.py files
55
+ endpoints.extend(self._scan_django_urls(project_path))
56
+
57
+ return endpoints
58
+
59
+ def _parse_decorator(self, decorator: str, func: FunctionInfo) -> Optional[Endpoint]:
60
+ """Try to parse a route decorator string."""
61
+ # Flask patterns
62
+ match = self.FLASK_PATTERNS.search(decorator)
63
+ if match:
64
+ method = match.group(1).upper()
65
+ if method == "ROUTE":
66
+ method = "GET"
67
+ return Endpoint(
68
+ method=method,
69
+ path=match.group(2),
70
+ function_name=func.name,
71
+ file=func.file,
72
+ line=func.line,
73
+ framework="flask",
74
+ docstring=func.docstring,
75
+ params=func.args,
76
+ return_type=func.returns,
77
+ )
78
+
79
+ # FastAPI patterns
80
+ match = self.FASTAPI_PATTERNS.search(decorator)
81
+ if match:
82
+ return Endpoint(
83
+ method=match.group(1).upper(),
84
+ path=match.group(2),
85
+ function_name=func.name,
86
+ file=func.file,
87
+ line=func.line,
88
+ framework="fastapi",
89
+ docstring=func.docstring,
90
+ params=func.args,
91
+ return_type=func.returns,
92
+ )
93
+
94
+ return None
95
+
96
+ def _scan_django_urls(self, project_path: str) -> List[Endpoint]:
97
+ """Scan urls.py files for Django URL patterns."""
98
+ endpoints: List[Endpoint] = []
99
+ project = Path(project_path)
100
+
101
+ for urls_file in project.rglob("urls.py"):
102
+ try:
103
+ source = urls_file.read_text(encoding="utf-8")
104
+ for match in self.DJANGO_URL_PATTERN.finditer(source):
105
+ endpoints.append(Endpoint(
106
+ method="GET",
107
+ path=match.group(1),
108
+ function_name="",
109
+ file=str(urls_file),
110
+ line=source[:match.start()].count("\n") + 1,
111
+ framework="django",
112
+ ))
113
+ except (OSError, UnicodeDecodeError):
114
+ continue
115
+
116
+ return endpoints
@@ -0,0 +1,45 @@
1
+ """Wrapper around code2llm's ProjectAnalyzer for documentation purposes."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from code2llm import Config, FAST_CONFIG
7
+ from code2llm.core.analyzer import ProjectAnalyzer
8
+ from code2llm.core.models import AnalysisResult
9
+
10
+ from ..config import Code2DocsConfig
11
+
12
+
13
+ class ProjectScanner:
14
+ """Wraps code2llm's ProjectAnalyzer with code2docs-specific defaults."""
15
+
16
+ def __init__(self, config: Optional[Code2DocsConfig] = None):
17
+ self.config = config or Code2DocsConfig()
18
+ self._llm_config = self._build_llm_config()
19
+
20
+ def _build_llm_config(self) -> Config:
21
+ """Create code2llm Config tuned for documentation generation."""
22
+ config = Config(
23
+ mode="static",
24
+ verbose=self.config.verbose,
25
+ )
26
+ config.filters.exclude_tests = self.config.exclude_tests
27
+ config.filters.skip_private = self.config.skip_private
28
+ # Keep properties and accessors visible for docs
29
+ config.filters.skip_properties = False
30
+ config.filters.skip_accessors = False
31
+ # Enable pattern detection for architecture docs
32
+ config.performance.skip_pattern_detection = False
33
+ config.performance.parallel_enabled = True
34
+ return config
35
+
36
+ def analyze(self, project_path: str) -> AnalysisResult:
37
+ """Analyze a project and return AnalysisResult for doc generation."""
38
+ analyzer = ProjectAnalyzer(self._llm_config)
39
+ return analyzer.analyze_project(project_path)
40
+
41
+
42
+ def analyze_and_document(project_path: str, config: Optional[Code2DocsConfig] = None) -> AnalysisResult:
43
+ """Convenience function: analyze a project in one call."""
44
+ scanner = ProjectScanner(config)
45
+ return scanner.analyze(project_path)