pycodecommenter 2.0.3__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.
- PyCodeCommenter/__init__.py +26 -0
- PyCodeCommenter/cli.py +72 -0
- PyCodeCommenter/commenter.py +450 -0
- PyCodeCommenter/coverage.py +124 -0
- PyCodeCommenter/docstring_parser.py +121 -0
- PyCodeCommenter/parameter_descriptions.py +76 -0
- PyCodeCommenter/templates.py +95 -0
- PyCodeCommenter/type_analyzer.py +203 -0
- PyCodeCommenter/validator.py +563 -0
- pycodecommenter-2.0.3.dist-info/METADATA +281 -0
- pycodecommenter-2.0.3.dist-info/RECORD +15 -0
- pycodecommenter-2.0.3.dist-info/WHEEL +5 -0
- pycodecommenter-2.0.3.dist-info/entry_points.txt +2 -0
- pycodecommenter-2.0.3.dist-info/licenses/LICENSE +21 -0
- pycodecommenter-2.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyCodeCommenter - Automatic Python docstring generation and validation.
|
|
3
|
+
|
|
4
|
+
A comprehensive tool for generating, validating, and maintaining Google-style
|
|
5
|
+
docstrings for Python code with coverage analysis and CI/CD integration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .commenter import PyCodeCommenter
|
|
9
|
+
from .validator import DocstringValidator, ValidationReport, ValidationIssue, Severity
|
|
10
|
+
from .coverage import CoverageAnalyzer, FileCoverage, ProjectCoverage
|
|
11
|
+
from .type_analyzer import TypeAnalyzer
|
|
12
|
+
from .docstring_parser import DocstringParser
|
|
13
|
+
|
|
14
|
+
__version__ = "2.0.3"
|
|
15
|
+
__all__ = [
|
|
16
|
+
"PyCodeCommenter",
|
|
17
|
+
"DocstringValidator",
|
|
18
|
+
"ValidationReport",
|
|
19
|
+
"ValidationIssue",
|
|
20
|
+
"Severity",
|
|
21
|
+
"CoverageAnalyzer",
|
|
22
|
+
"FileCoverage",
|
|
23
|
+
"ProjectCoverage",
|
|
24
|
+
"TypeAnalyzer",
|
|
25
|
+
"DocstringParser",
|
|
26
|
+
]
|
PyCodeCommenter/cli.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entry point for PyCodeCommenter.
|
|
3
|
+
"""
|
|
4
|
+
import sys
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
from .commenter import PyCodeCommenter
|
|
8
|
+
from .validator import DocstringValidator
|
|
9
|
+
from .coverage import CoverageAnalyzer
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
parser = argparse.ArgumentParser(description="PyCodeCommenter CLI - Automatic docstring generation and validation.")
|
|
13
|
+
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
|
14
|
+
|
|
15
|
+
# Generate command
|
|
16
|
+
generate_parser = subparsers.add_parser("generate", help="Generate docstrings for a file")
|
|
17
|
+
generate_parser.add_argument("file", help="Python file to process")
|
|
18
|
+
generate_parser.add_argument("-i", "--inplace", action="store_true", help="Modify file in place")
|
|
19
|
+
generate_parser.add_argument("-o", "--output", help="Output file path")
|
|
20
|
+
|
|
21
|
+
# Validate command
|
|
22
|
+
validate_parser = subparsers.add_parser("validate", help="Validate docstrings for a file")
|
|
23
|
+
validate_parser.add_argument("file", help="Python file to validate")
|
|
24
|
+
|
|
25
|
+
# Coverage command
|
|
26
|
+
coverage_parser = subparsers.add_parser("coverage", help="Analyze documentation coverage")
|
|
27
|
+
coverage_parser.add_argument("path", help="Directory or file to analyze")
|
|
28
|
+
coverage_parser.add_argument("-e", "--exclude", nargs="*", help="Patterns to exclude")
|
|
29
|
+
|
|
30
|
+
args = parser.parse_args()
|
|
31
|
+
|
|
32
|
+
if args.command == "generate":
|
|
33
|
+
commenter = PyCodeCommenter().from_file(args.file)
|
|
34
|
+
if not commenter.parsed_code:
|
|
35
|
+
print(f"Error: Could not parse {args.file}")
|
|
36
|
+
sys.exit(1)
|
|
37
|
+
|
|
38
|
+
patched_code = commenter.get_patched_code()
|
|
39
|
+
|
|
40
|
+
if args.inplace:
|
|
41
|
+
with open(args.file, 'w', encoding='utf-8') as f:
|
|
42
|
+
f.write(patched_code)
|
|
43
|
+
print(f"Successfully patched {args.file}")
|
|
44
|
+
elif args.output:
|
|
45
|
+
with open(args.output, 'w', encoding='utf-8') as f:
|
|
46
|
+
f.write(patched_code)
|
|
47
|
+
print(f"Successfully wrote patched code to {args.output}")
|
|
48
|
+
else:
|
|
49
|
+
print(patched_code)
|
|
50
|
+
|
|
51
|
+
elif args.command == "validate":
|
|
52
|
+
validator = DocstringValidator(file_path=args.file)
|
|
53
|
+
report = validator.validate_all()
|
|
54
|
+
report.print_summary()
|
|
55
|
+
if report.stats.errors > 0:
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
elif args.command == "coverage":
|
|
59
|
+
analyzer = CoverageAnalyzer()
|
|
60
|
+
if os.path.isdir(args.path):
|
|
61
|
+
result = analyzer.analyze_directory(args.path, exclude_patterns=args.exclude)
|
|
62
|
+
result.print_report()
|
|
63
|
+
else:
|
|
64
|
+
result = analyzer.analyze_file(args.path)
|
|
65
|
+
# FileCoverage doesn't have a print_report method in the same way, but we can print its stats
|
|
66
|
+
print(f"Coverage for {args.path}: {result.coverage_percentage:.1f}%")
|
|
67
|
+
|
|
68
|
+
else:
|
|
69
|
+
parser.print_help()
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
main()
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main docstring generation module for PyCodeCommenter.
|
|
3
|
+
|
|
4
|
+
This module provides the core PyCodeCommenter class for automatically generating,
|
|
5
|
+
validating, and patching Google-style docstrings in Python code. It supports both
|
|
6
|
+
file and string input, preserves existing documentation, and integrates with the
|
|
7
|
+
validation and coverage analysis systems.
|
|
8
|
+
|
|
9
|
+
Classes:
|
|
10
|
+
PyCodeCommenter: Main class for generating and patching Python docstrings
|
|
11
|
+
DocstringVisitor: AST visitor for traversing and processing code nodes
|
|
12
|
+
"""
|
|
13
|
+
import ast
|
|
14
|
+
import tokenize
|
|
15
|
+
import io
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Union, Dict, Any, Optional, List
|
|
18
|
+
try:
|
|
19
|
+
from .templates import get_function_description
|
|
20
|
+
from .parameter_descriptions import parameter_descriptions
|
|
21
|
+
from .type_analyzer import TypeAnalyzer
|
|
22
|
+
from .docstring_parser import DocstringParser
|
|
23
|
+
except (ImportError, ValueError):
|
|
24
|
+
from templates import get_function_description
|
|
25
|
+
from parameter_descriptions import parameter_descriptions
|
|
26
|
+
from type_analyzer import TypeAnalyzer
|
|
27
|
+
from docstring_parser import DocstringParser
|
|
28
|
+
|
|
29
|
+
# Configure logging
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
class PyCodeCommenter:
|
|
33
|
+
"""
|
|
34
|
+
Main class for generating and patching Python docstrings.
|
|
35
|
+
"""
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self.code = ""
|
|
38
|
+
self.parsed_code = None
|
|
39
|
+
self.comments = []
|
|
40
|
+
self.tokenized_comments = []
|
|
41
|
+
self.type_analyzer = TypeAnalyzer()
|
|
42
|
+
|
|
43
|
+
def from_string(self, code_string: str) -> 'PyCodeCommenter':
|
|
44
|
+
"""Initializes the commenter from a string of code."""
|
|
45
|
+
try:
|
|
46
|
+
if code_string is None or code_string.strip() == "":
|
|
47
|
+
logger.warning("No code provided. Proceeding with an empty string.")
|
|
48
|
+
self.code = ""
|
|
49
|
+
self.parsed_code = ast.Module(body=[])
|
|
50
|
+
else:
|
|
51
|
+
self.code = code_string
|
|
52
|
+
self.parsed_code = ast.parse(self.code)
|
|
53
|
+
self._extract_comments()
|
|
54
|
+
except SyntaxError as e:
|
|
55
|
+
logger.error(f"Syntax error in provided code: {e}")
|
|
56
|
+
self.parsed_code = None
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def _extract_comments(self):
|
|
60
|
+
"""Extracts all comments from the code using the tokenize module."""
|
|
61
|
+
try:
|
|
62
|
+
self.tokenized_comments = []
|
|
63
|
+
tokens = tokenize.generate_tokens(io.StringIO(self.code).readline)
|
|
64
|
+
for tok_type, tok_string, start, end, line in tokens:
|
|
65
|
+
if tok_type == tokenize.COMMENT:
|
|
66
|
+
self.tokenized_comments.append({
|
|
67
|
+
"text": tok_string,
|
|
68
|
+
"line": start[0],
|
|
69
|
+
"column": start[1]
|
|
70
|
+
})
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logger.error(f"Error extracting comments: {e}")
|
|
73
|
+
|
|
74
|
+
def from_file(self, file_path: str) -> 'PyCodeCommenter':
|
|
75
|
+
"""Initializes the commenter from a file path."""
|
|
76
|
+
try:
|
|
77
|
+
with open(file_path, 'r', encoding='utf-8') as file:
|
|
78
|
+
self.code = file.read()
|
|
79
|
+
self.parsed_code = ast.parse(self.code)
|
|
80
|
+
self._extract_comments()
|
|
81
|
+
except (FileNotFoundError, IOError) as e:
|
|
82
|
+
logger.error(f"Error reading file: {e}")
|
|
83
|
+
self.parsed_code = None
|
|
84
|
+
except SyntaxError as e:
|
|
85
|
+
logger.error(f"Syntax error in file: {e}")
|
|
86
|
+
self.parsed_code = None
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def generate_docstrings(self) -> list:
|
|
90
|
+
"""Iterates over the parsed code to generate docstrings for functions and classes."""
|
|
91
|
+
if self.parsed_code is None:
|
|
92
|
+
logger.error("No valid code to parse.")
|
|
93
|
+
return []
|
|
94
|
+
|
|
95
|
+
self.comments = []
|
|
96
|
+
|
|
97
|
+
# Module level docstring
|
|
98
|
+
module_doc = ast.get_docstring(self.parsed_code)
|
|
99
|
+
if module_doc:
|
|
100
|
+
self.comments.append(f"Module Docstring:\n{module_doc}\n")
|
|
101
|
+
|
|
102
|
+
visitor = DocstringVisitor(self)
|
|
103
|
+
visitor.visit(self.parsed_code)
|
|
104
|
+
|
|
105
|
+
for node, doc in visitor.results:
|
|
106
|
+
self.comments.append(doc)
|
|
107
|
+
|
|
108
|
+
return self.comments
|
|
109
|
+
|
|
110
|
+
def get_patched_code(self) -> str:
|
|
111
|
+
"""Returns the code with generated docstrings inserted or updated."""
|
|
112
|
+
if not self.code or not self.parsed_code:
|
|
113
|
+
return self.code
|
|
114
|
+
|
|
115
|
+
lines = self.code.splitlines()
|
|
116
|
+
changes = []
|
|
117
|
+
|
|
118
|
+
visitor = DocstringVisitor(self)
|
|
119
|
+
visitor.visit(self.parsed_code)
|
|
120
|
+
|
|
121
|
+
for node, docstring in visitor.results:
|
|
122
|
+
existing_doc = ast.get_docstring(node, clean=False)
|
|
123
|
+
|
|
124
|
+
# Find the line with 'def' or 'class' or 'async def'
|
|
125
|
+
target_line_idx = node.lineno - 1
|
|
126
|
+
while target_line_idx < len(lines):
|
|
127
|
+
curr_line = lines[target_line_idx].strip()
|
|
128
|
+
if curr_line.startswith(('def ', 'class ', 'async def ')):
|
|
129
|
+
break
|
|
130
|
+
target_line_idx += 1
|
|
131
|
+
|
|
132
|
+
if target_line_idx >= len(lines):
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
line = lines[target_line_idx]
|
|
136
|
+
indent = line[:len(line) - len(line.lstrip())]
|
|
137
|
+
reindented_doc = self._indent_text(docstring, len(indent) + 4)
|
|
138
|
+
|
|
139
|
+
if existing_doc:
|
|
140
|
+
# Replace existing docstring
|
|
141
|
+
doc_node = node.body[0]
|
|
142
|
+
if isinstance(doc_node, ast.Expr) and isinstance(doc_node.value, ast.Constant) and isinstance(doc_node.value.value, str):
|
|
143
|
+
start = doc_node.lineno - 1
|
|
144
|
+
end = doc_node.end_lineno - 1 if hasattr(doc_node, 'end_lineno') else start
|
|
145
|
+
changes.append((start, end, reindented_doc))
|
|
146
|
+
else:
|
|
147
|
+
# Insert after the definition line(s)
|
|
148
|
+
if node.body:
|
|
149
|
+
insert_pos = node.body[0].lineno - 1
|
|
150
|
+
changes.append((insert_pos, insert_pos - 1, reindented_doc))
|
|
151
|
+
|
|
152
|
+
# Sort changes in reverse order
|
|
153
|
+
changes.sort(key=lambda x: x[0], reverse=True)
|
|
154
|
+
|
|
155
|
+
for start, end, content in changes:
|
|
156
|
+
if start <= end:
|
|
157
|
+
lines[start:end+1] = [content]
|
|
158
|
+
else:
|
|
159
|
+
lines.insert(start, content)
|
|
160
|
+
|
|
161
|
+
return "\n".join(lines)
|
|
162
|
+
|
|
163
|
+
def _generate_function_docstring(self, func_node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> str:
|
|
164
|
+
"""Generates a Google-style docstring for a function node, merging existing info."""
|
|
165
|
+
try:
|
|
166
|
+
existing_doc = ast.get_docstring(func_node)
|
|
167
|
+
parser = DocstringParser(existing_doc)
|
|
168
|
+
parsed_info = parser.get_info()
|
|
169
|
+
|
|
170
|
+
summary = parsed_info.get("summary") or (func_node.name.replace('_', ' ').capitalize() + ".")
|
|
171
|
+
|
|
172
|
+
if func_node.name == "__init__":
|
|
173
|
+
summary = "Initialize the class."
|
|
174
|
+
description = parsed_info.get("description") or "Initialize a new instance."
|
|
175
|
+
else:
|
|
176
|
+
description = parsed_info.get("description") or get_function_description(func_node.name)
|
|
177
|
+
|
|
178
|
+
if description and description.lower().rstrip('.') == summary.lower().rstrip('.'):
|
|
179
|
+
description = ""
|
|
180
|
+
|
|
181
|
+
docstring = f'"""{summary}\n\n'
|
|
182
|
+
if description:
|
|
183
|
+
docstring += f"{description}\n\n"
|
|
184
|
+
|
|
185
|
+
docstring += "Args:\n"
|
|
186
|
+
defaults = [None] * (len(func_node.args.args) - len(func_node.args.defaults)) + func_node.args.defaults
|
|
187
|
+
|
|
188
|
+
found_args = False
|
|
189
|
+
for arg, default in zip(func_node.args.args, defaults):
|
|
190
|
+
if arg.arg == 'self':
|
|
191
|
+
continue
|
|
192
|
+
found_args = True
|
|
193
|
+
inferred_type = self._infer_type(arg)
|
|
194
|
+
# Use parser to get existing parameter description
|
|
195
|
+
param_desc = parsed_info.get("params", {}).get(arg.arg) or self._get_parameter_description(func_node.name, arg.arg)
|
|
196
|
+
|
|
197
|
+
arg_line = f" {arg.arg} ({inferred_type}): {param_desc}"
|
|
198
|
+
if not any(param_desc.endswith(p) for p in {'.', '!', '?'}):
|
|
199
|
+
arg_line += "."
|
|
200
|
+
if default is not None:
|
|
201
|
+
arg_line += f" (default: {self._get_default_value(default)})"
|
|
202
|
+
docstring += arg_line + "\n"
|
|
203
|
+
|
|
204
|
+
if not found_args:
|
|
205
|
+
docstring += " None.\n"
|
|
206
|
+
|
|
207
|
+
local_types = self._get_local_types(func_node)
|
|
208
|
+
return_type = self._get_return_type(func_node, local_types)
|
|
209
|
+
return_desc = parsed_info.get("returns") or "Description of the return value."
|
|
210
|
+
|
|
211
|
+
docstring += f"\nReturns:\n {return_type}: {return_desc}\n"
|
|
212
|
+
docstring += '"""'
|
|
213
|
+
return docstring
|
|
214
|
+
except Exception as e:
|
|
215
|
+
logger.error(f"Error generating function docstring for {func_node.name}: {e}")
|
|
216
|
+
return '"""Error generating docstring."""'
|
|
217
|
+
|
|
218
|
+
def _generate_class_docstring(self, class_node: ast.ClassDef) -> str:
|
|
219
|
+
"""Generates a Google-style docstring for a class node, merging existing info."""
|
|
220
|
+
try:
|
|
221
|
+
existing_doc = ast.get_docstring(class_node)
|
|
222
|
+
parser = DocstringParser(existing_doc)
|
|
223
|
+
parsed_info = parser.get_info()
|
|
224
|
+
|
|
225
|
+
summary = parsed_info.get("summary") or f"{class_node.name} class."
|
|
226
|
+
description = parsed_info.get("description") or f"{class_node.name} class for [describe purpose]."
|
|
227
|
+
|
|
228
|
+
docstring = f'"""{summary}\n\n'
|
|
229
|
+
if description:
|
|
230
|
+
docstring += f"{description}\n\n"
|
|
231
|
+
|
|
232
|
+
attributes = self._get_class_attributes(class_node)
|
|
233
|
+
if attributes:
|
|
234
|
+
docstring += "Attributes:\n"
|
|
235
|
+
for attr, attr_type in attributes.items():
|
|
236
|
+
# We could also parse existing attributes if we added that to DocstringParser
|
|
237
|
+
docstring += f" {attr} ({attr_type}): Description of attribute.\n"
|
|
238
|
+
|
|
239
|
+
methods = [node.name for node in class_node.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and not node.name.startswith('_')]
|
|
240
|
+
if methods:
|
|
241
|
+
docstring += "\nMethods:\n"
|
|
242
|
+
for method in methods:
|
|
243
|
+
docstring += f" {method}(): Description of method.\n"
|
|
244
|
+
|
|
245
|
+
docstring += '"""'
|
|
246
|
+
return docstring
|
|
247
|
+
except Exception as e:
|
|
248
|
+
logger.error(f"Error generating class docstring for {class_node.name}: {e}")
|
|
249
|
+
return '"""Error generating docstring."""'
|
|
250
|
+
|
|
251
|
+
def _get_parameter_description(self, func_name: str, param_name: str) -> str:
|
|
252
|
+
"""
|
|
253
|
+
Retrieves a default or predefined description for a parameter.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
func_name (str): The name of the function.
|
|
257
|
+
param_name (str): The name of the parameter.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
str: The description of the parameter.
|
|
261
|
+
"""
|
|
262
|
+
return parameter_descriptions.get(func_name, {}).get(param_name, f"{param_name.replace('_', ' ').capitalize()} of the {func_name.replace('_', ' ')}.")
|
|
263
|
+
|
|
264
|
+
def _get_class_attributes(self, class_node: ast.ClassDef) -> Dict[str, str]:
|
|
265
|
+
"""
|
|
266
|
+
Extracts attributes from a class by looking at __init__.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
class_node (ast.ClassDef): The class node.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Dict[str, str]: A dictionary mapping attribute names to their inferred types.
|
|
273
|
+
"""
|
|
274
|
+
attributes = {}
|
|
275
|
+
for item in class_node.body:
|
|
276
|
+
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name == "__init__":
|
|
277
|
+
for arg in item.args.args[1:]:
|
|
278
|
+
attributes[arg.arg] = self._infer_type(arg)
|
|
279
|
+
return attributes
|
|
280
|
+
|
|
281
|
+
def _indent_text(self, text: str, spaces: int) -> str:
|
|
282
|
+
"""
|
|
283
|
+
Indents a block of text.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
text (str): The text to indent.
|
|
287
|
+
spaces (int): The number of spaces to indent by.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
str: The indented text.
|
|
291
|
+
"""
|
|
292
|
+
indent = ' ' * spaces
|
|
293
|
+
return "\n".join([indent + line if line.strip() else line for line in text.splitlines()])
|
|
294
|
+
|
|
295
|
+
def _infer_type(self, node: Any) -> str:
|
|
296
|
+
"""
|
|
297
|
+
Infers the type of an AST node.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
node (Any): The AST node.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
str: The inferred type.
|
|
304
|
+
"""
|
|
305
|
+
return self.type_analyzer.infer_type(node)
|
|
306
|
+
|
|
307
|
+
def _infer_expr_type(self, expr: Any, local_types: Optional[Dict[str, str]] = None) -> str:
|
|
308
|
+
"""
|
|
309
|
+
Infers the type of an expression node.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
expr (Any): The expression node.
|
|
313
|
+
local_types (Optional[Dict[str, str]]): Dictionary of known local types.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
str: The inferred type.
|
|
317
|
+
"""
|
|
318
|
+
return TypeAnalyzer(local_types).infer_expr_type(expr)
|
|
319
|
+
|
|
320
|
+
def _get_default_value(self, default_node: Any) -> str:
|
|
321
|
+
"""
|
|
322
|
+
Gets the string representation of a default value.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
default_node (Any): The AST node for the default value.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
str: String representation of the default value.
|
|
329
|
+
"""
|
|
330
|
+
if isinstance(default_node, ast.Constant):
|
|
331
|
+
return repr(default_node.value)
|
|
332
|
+
return "unknown"
|
|
333
|
+
|
|
334
|
+
def _get_return_type(self, func_node: Union[ast.FunctionDef, ast.AsyncFunctionDef], local_types: Optional[Dict[str, str]] = None) -> str:
|
|
335
|
+
"""
|
|
336
|
+
Infers the return type of a function.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
func_node (Union[ast.FunctionDef, ast.AsyncFunctionDef]): The function node.
|
|
340
|
+
local_types (Optional[Dict[str, str]]): Known local types for better inference.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
str: The inferred return type.
|
|
344
|
+
"""
|
|
345
|
+
if func_node.returns:
|
|
346
|
+
return self.type_analyzer.get_annotation_type(func_node.returns)
|
|
347
|
+
return_types = set()
|
|
348
|
+
for stmt in ast.walk(func_node):
|
|
349
|
+
if isinstance(stmt, ast.Return) and stmt.value is not None:
|
|
350
|
+
return_types.add(self._infer_expr_type(stmt.value, local_types))
|
|
351
|
+
|
|
352
|
+
filtered_types = {t for t in return_types if t != "any"}
|
|
353
|
+
if not filtered_types and return_types:
|
|
354
|
+
return "any"
|
|
355
|
+
return " | ".join(sorted(filtered_types)) if filtered_types else "None"
|
|
356
|
+
|
|
357
|
+
def _get_local_types(self, func_node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> Dict[str, str]:
|
|
358
|
+
"""
|
|
359
|
+
Extracts local variable types within a function.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
func_node (Union[ast.FunctionDef, ast.AsyncFunctionDef]): The function node.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
Dict[str, str]: Mapping of variable names to their inferred types.
|
|
366
|
+
"""
|
|
367
|
+
local_types = {}
|
|
368
|
+
for stmt in func_node.body:
|
|
369
|
+
if isinstance(stmt, ast.Assign):
|
|
370
|
+
for target in stmt.targets:
|
|
371
|
+
if isinstance(target, ast.Name):
|
|
372
|
+
local_types[target.id] = self._infer_expr_type(stmt.value, local_types)
|
|
373
|
+
return local_types
|
|
374
|
+
|
|
375
|
+
def validate(self, strict: bool = False):
|
|
376
|
+
"""
|
|
377
|
+
Validate existing docstrings against code using comprehensive checks.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
strict (bool): If True, treat warnings as errors. (default: False)
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
ValidationReport: Comprehensive validation report with all issues found.
|
|
384
|
+
Returns empty report if no code is parsed.
|
|
385
|
+
"""
|
|
386
|
+
try:
|
|
387
|
+
from .validator import DocstringValidator, ValidationReport
|
|
388
|
+
except (ImportError, ValueError):
|
|
389
|
+
from validator import DocstringValidator, ValidationReport
|
|
390
|
+
|
|
391
|
+
if self.parsed_code is None:
|
|
392
|
+
logger.error("No valid code parsed for validation.")
|
|
393
|
+
# Return empty report instead of None
|
|
394
|
+
empty_report = ValidationReport()
|
|
395
|
+
empty_report.file_path = None
|
|
396
|
+
return empty_report
|
|
397
|
+
|
|
398
|
+
validator = DocstringValidator(code_string=self.code, file_path=None)
|
|
399
|
+
report = validator.validate_all()
|
|
400
|
+
|
|
401
|
+
return report
|
|
402
|
+
|
|
403
|
+
def check_coverage(self):
|
|
404
|
+
"""
|
|
405
|
+
Calculate documentation coverage for current code.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
FileCoverage: Coverage statistics for the current code.
|
|
409
|
+
"""
|
|
410
|
+
try:
|
|
411
|
+
from .coverage import FileCoverage
|
|
412
|
+
except (ImportError, ValueError):
|
|
413
|
+
from coverage import FileCoverage
|
|
414
|
+
|
|
415
|
+
if self.parsed_code is None:
|
|
416
|
+
logger.error("No valid code parsed for coverage analysis.")
|
|
417
|
+
# Return empty coverage instead of None
|
|
418
|
+
return FileCoverage(path="<no code>")
|
|
419
|
+
|
|
420
|
+
coverage = FileCoverage(path="<string>")
|
|
421
|
+
|
|
422
|
+
for node in ast.walk(self.parsed_code):
|
|
423
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
424
|
+
coverage.total_functions += 1
|
|
425
|
+
if ast.get_docstring(node):
|
|
426
|
+
coverage.documented_functions += 1
|
|
427
|
+
elif isinstance(node, ast.ClassDef):
|
|
428
|
+
coverage.total_classes += 1
|
|
429
|
+
if ast.get_docstring(node):
|
|
430
|
+
coverage.documented_classes += 1
|
|
431
|
+
|
|
432
|
+
return coverage
|
|
433
|
+
|
|
434
|
+
class DocstringVisitor(ast.NodeVisitor):
|
|
435
|
+
def __init__(self, commenter):
|
|
436
|
+
self.commenter = commenter
|
|
437
|
+
self.results = []
|
|
438
|
+
|
|
439
|
+
def visit_FunctionDef(self, node):
|
|
440
|
+
self.results.append((node, self.commenter._generate_function_docstring(node)))
|
|
441
|
+
self.generic_visit(node)
|
|
442
|
+
|
|
443
|
+
def visit_AsyncFunctionDef(self, node):
|
|
444
|
+
self.results.append((node, self.commenter._generate_function_docstring(node)))
|
|
445
|
+
self.generic_visit(node)
|
|
446
|
+
|
|
447
|
+
def visit_ClassDef(self, node):
|
|
448
|
+
self.results.append((node, self.commenter._generate_class_docstring(node)))
|
|
449
|
+
self.generic_visit(node)
|
|
450
|
+
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Documentation coverage analysis module for PyCodeCommenter.
|
|
3
|
+
|
|
4
|
+
This module provides tools for analyzing documentation coverage across Python
|
|
5
|
+
files and projects. It calculates metrics for function and class documentation
|
|
6
|
+
and generates coverage reports.
|
|
7
|
+
|
|
8
|
+
Classes:
|
|
9
|
+
FileCoverage: Coverage statistics for a single file
|
|
10
|
+
ProjectCoverage: Coverage statistics for entire project
|
|
11
|
+
CoverageAnalyzer: Analyzes documentation coverage for files or projects
|
|
12
|
+
"""
|
|
13
|
+
# coverage.py
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import os
|
|
17
|
+
import logging
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Dict, List, Optional
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
# Configure logging
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class FileCoverage:
|
|
27
|
+
"""Coverage statistics for a single file."""
|
|
28
|
+
path: str
|
|
29
|
+
total_functions: int = 0
|
|
30
|
+
documented_functions: int = 0
|
|
31
|
+
total_classes: int = 0
|
|
32
|
+
documented_classes: int = 0
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def coverage_percentage(self) -> float:
|
|
36
|
+
total = self.total_functions + self.total_classes
|
|
37
|
+
documented = self.documented_functions + self.documented_classes
|
|
38
|
+
return (documented / total * 100) if total > 0 else 0.0
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ProjectCoverage:
|
|
42
|
+
"""Coverage statistics for entire project."""
|
|
43
|
+
files: Dict[str, FileCoverage] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def total_coverage(self) -> float:
|
|
47
|
+
total_items = sum(f.total_functions + f.total_classes for f in self.files.values())
|
|
48
|
+
documented = sum(f.documented_functions + f.documented_classes for f in self.files.values())
|
|
49
|
+
return (documented / total_items * 100) if total_items > 0 else 0.0
|
|
50
|
+
|
|
51
|
+
def print_report(self):
|
|
52
|
+
"""Print coverage report to console."""
|
|
53
|
+
print("\n" + "="*80)
|
|
54
|
+
print("DOCUMENTATION COVERAGE REPORT")
|
|
55
|
+
print("="*80)
|
|
56
|
+
|
|
57
|
+
for path, coverage in sorted(self.files.items()):
|
|
58
|
+
status = "[OK]" if coverage.coverage_percentage == 100 else "[!!]"
|
|
59
|
+
# Shorten path for display
|
|
60
|
+
display_path = os.path.basename(path)
|
|
61
|
+
print(f"{status} {display_path:50} {coverage.coverage_percentage:5.1f}%")
|
|
62
|
+
|
|
63
|
+
print("-"*80)
|
|
64
|
+
print(f"{'TOTAL':52} {self.total_coverage:5.1f}%")
|
|
65
|
+
print("="*80)
|
|
66
|
+
|
|
67
|
+
def to_json(self) -> dict:
|
|
68
|
+
"""Export as JSON."""
|
|
69
|
+
return {
|
|
70
|
+
"total_coverage": self.total_coverage,
|
|
71
|
+
"files": {
|
|
72
|
+
path: {
|
|
73
|
+
"coverage": cov.coverage_percentage,
|
|
74
|
+
"functions": f"{cov.documented_functions}/{cov.total_functions}",
|
|
75
|
+
"classes": f"{cov.documented_classes}/{cov.total_classes}"
|
|
76
|
+
}
|
|
77
|
+
for path, cov in self.files.items()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
class CoverageAnalyzer:
|
|
82
|
+
"""Analyzes documentation coverage for files or projects."""
|
|
83
|
+
|
|
84
|
+
def analyze_file(self, file_path: str) -> FileCoverage:
|
|
85
|
+
"""Analyze a single Python file."""
|
|
86
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
87
|
+
code = f.read()
|
|
88
|
+
|
|
89
|
+
tree = ast.parse(code)
|
|
90
|
+
coverage = FileCoverage(path=file_path)
|
|
91
|
+
|
|
92
|
+
for node in ast.walk(tree):
|
|
93
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
94
|
+
coverage.total_functions += 1
|
|
95
|
+
if ast.get_docstring(node):
|
|
96
|
+
coverage.documented_functions += 1
|
|
97
|
+
elif isinstance(node, ast.ClassDef):
|
|
98
|
+
coverage.total_classes += 1
|
|
99
|
+
if ast.get_docstring(node):
|
|
100
|
+
coverage.documented_classes += 1
|
|
101
|
+
|
|
102
|
+
return coverage
|
|
103
|
+
|
|
104
|
+
def analyze_directory(self, directory: str, exclude_patterns: List[str] = None) -> ProjectCoverage:
|
|
105
|
+
"""Analyze all Python files in a directory."""
|
|
106
|
+
exclude_patterns = exclude_patterns or ['__pycache__', '.git', 'venv', 'tests', 'test_']
|
|
107
|
+
project = ProjectCoverage()
|
|
108
|
+
|
|
109
|
+
for py_file in Path(directory).rglob('*.py'):
|
|
110
|
+
# Skip excluded paths
|
|
111
|
+
if any(pattern in str(py_file) for pattern in exclude_patterns):
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
coverage = self.analyze_file(str(py_file))
|
|
116
|
+
project.files[str(py_file)] = coverage
|
|
117
|
+
except (IOError, OSError) as e:
|
|
118
|
+
logger.error(f"Error reading {py_file}: {e}")
|
|
119
|
+
except (SyntaxError, ValueError) as e:
|
|
120
|
+
logger.error(f"Error parsing {py_file}: {e}")
|
|
121
|
+
except UnicodeDecodeError as e:
|
|
122
|
+
logger.error(f"Encoding error in {py_file}: {e}")
|
|
123
|
+
|
|
124
|
+
return project
|