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.
@@ -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