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,563 @@
1
+ """
2
+ Docstring validation module for PyCodeCommenter.
3
+
4
+ This module provides comprehensive validation of Python docstrings against actual
5
+ code signatures. It checks for signature mismatches, type consistency, exception
6
+ documentation, return documentation, format compliance, and content quality.
7
+
8
+ Classes:
9
+ Severity: Enum for issue severity levels (ERROR, WARNING, INFO)
10
+ ValidationIssue: Represents a single documentation issue
11
+ ValidationStats: Statistics from validation run
12
+ ValidationReport: Container for validation results with reporting capabilities
13
+ DocstringValidator: Main validator class for checking documentation quality
14
+ """
15
+ import ast
16
+ import logging
17
+ from typing import List, Dict, Optional, Set, Any, Union
18
+ from dataclasses import dataclass, field
19
+ from enum import Enum
20
+ try:
21
+ from .docstring_parser import DocstringParser
22
+ except (ImportError, ValueError):
23
+ from docstring_parser import DocstringParser
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ class Severity(Enum):
28
+ ERROR = "error" # Must fix (missing required docs)
29
+ WARNING = "warning" # Should fix (inconsistencies)
30
+ INFO = "info" # Nice to have (style issues)
31
+
32
+ @dataclass
33
+ class ValidationIssue:
34
+ """Represents a single documentation issue."""
35
+ severity: Severity
36
+ category: str # "signature", "types", "exceptions", "format", "quality"
37
+ location: str # "file.py:line:function_name"
38
+ message: str
39
+ suggestion: Optional[str] = None # How to fix it
40
+
41
+ def __str__(self):
42
+ return f"[{self.severity.value.upper()}] {self.location}: {self.message}"
43
+
44
+ @dataclass
45
+ class ValidationStats:
46
+ """Statistics from validation run."""
47
+ total_functions: int = 0
48
+ total_classes: int = 0
49
+ documented_functions: int = 0
50
+ documented_classes: int = 0
51
+ total_issues: int = 0
52
+ errors: int = 0
53
+ warnings: int = 0
54
+ infos: int = 0
55
+
56
+ @property
57
+ def coverage_percentage(self) -> float:
58
+ total = self.total_functions + self.total_classes
59
+ documented = self.documented_functions + self.documented_classes
60
+ return (documented / total * 100) if total > 0 else 0.0
61
+
62
+ class ValidationReport:
63
+ """Container for validation results with reporting capabilities."""
64
+
65
+ def __init__(self):
66
+ self.issues: List[ValidationIssue] = []
67
+ self.stats = ValidationStats()
68
+ self.file_path: Optional[str] = None
69
+
70
+ def add_issue(self, issue: ValidationIssue):
71
+ """Add an issue and update stats."""
72
+ self.issues.append(issue)
73
+ self.stats.total_issues += 1
74
+ if issue.severity == Severity.ERROR:
75
+ self.stats.errors += 1
76
+ elif issue.severity == Severity.WARNING:
77
+ self.stats.warnings += 1
78
+ else:
79
+ self.stats.infos += 1
80
+
81
+ def print_summary(self):
82
+ """Print human-readable summary to console."""
83
+ print("\n" + "="*60)
84
+ print("VALIDATION REPORT")
85
+ print("="*60)
86
+ print(f"File: {self.file_path or 'N/A'}")
87
+ print(f"Coverage: {self.stats.coverage_percentage:.1f}%")
88
+ print(f"Total Issues: {self.stats.total_issues}")
89
+ print(f" - Errors: {self.stats.errors}")
90
+ print(f" - Warnings: {self.stats.warnings}")
91
+ print(f" - Info: {self.stats.infos}")
92
+
93
+ if self.issues:
94
+ print("\nISSUES:")
95
+ for issue in self.issues:
96
+ print(f"\n{issue}")
97
+ if issue.suggestion:
98
+ print(f" → Suggestion: {issue.suggestion}")
99
+
100
+ def to_dict(self) -> dict:
101
+ """Convert to dictionary for JSON export."""
102
+ return {
103
+ "file": self.file_path,
104
+ "stats": {
105
+ "coverage": self.stats.coverage_percentage,
106
+ "total_issues": self.stats.total_issues,
107
+ "errors": self.stats.errors,
108
+ "warnings": self.stats.warnings,
109
+ "infos": self.stats.infos
110
+ },
111
+ "issues": [
112
+ {
113
+ "severity": issue.severity.value,
114
+ "category": issue.category,
115
+ "location": issue.location,
116
+ "message": issue.message,
117
+ "suggestion": issue.suggestion
118
+ }
119
+ for issue in self.issues
120
+ ]
121
+ }
122
+
123
+ def to_markdown(self) -> str:
124
+ """Generate markdown report."""
125
+ md = f"# Validation Report\n\n"
126
+ md += f"**File:** {self.file_path or 'N/A'}\n\n"
127
+ md += f"## Summary\n\n"
128
+ md += f"- **Coverage:** {self.stats.coverage_percentage:.1f}%\n"
129
+ md += f"- **Total Issues:** {self.stats.total_issues}\n"
130
+ md += f" - Errors: {self.stats.errors}\n"
131
+ md += f" - Warnings: {self.stats.warnings}\n"
132
+ md += f" - Info: {self.stats.infos}\n\n"
133
+
134
+ if self.issues:
135
+ md += f"## Issues\n\n"
136
+ for issue in self.issues:
137
+ md += f"### {issue.severity.value.upper()}: {issue.message}\n\n"
138
+ md += f"- **Location:** `{issue.location}`\n"
139
+ md += f"- **Category:** {issue.category}\n"
140
+ if issue.suggestion:
141
+ md += f"- **Suggestion:** {issue.suggestion}\n"
142
+ md += "\n"
143
+
144
+ return md
145
+
146
+ class DocstringValidator:
147
+ """Main validator class for checking documentation quality."""
148
+
149
+ def __init__(self, code_string: Optional[str] = None, file_path: Optional[str] = None):
150
+ """
151
+ Initializes the validator.
152
+
153
+ Args:
154
+ code_string (Optional[str]): The code to validate as a string.
155
+ file_path (Optional[str]): The path to the file to validate.
156
+ """
157
+ self.code = code_string
158
+ self.file_path = file_path
159
+ self.parsed_code = None
160
+
161
+ try:
162
+ if code_string:
163
+ self.parsed_code = ast.parse(code_string)
164
+ elif file_path:
165
+ with open(file_path, 'r', encoding='utf-8') as f:
166
+ self.code = f.read()
167
+ self.parsed_code = ast.parse(self.code)
168
+ except (FileNotFoundError, IOError) as e:
169
+ logger.error(f"Error reading file {file_path}: {e}")
170
+ except SyntaxError as e:
171
+ logger.error(f"Syntax error in code: {e}")
172
+
173
+ def validate_all(self) -> ValidationReport:
174
+ """Run all validation checks and return comprehensive report."""
175
+ report = ValidationReport()
176
+ report.file_path = self.file_path
177
+
178
+ if not self.parsed_code:
179
+ return report
180
+
181
+ # Traverse AST and validate each function/class
182
+ for node in ast.walk(self.parsed_code):
183
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
184
+ self._validate_function(node, report)
185
+ report.stats.total_functions += 1
186
+ elif isinstance(node, ast.ClassDef):
187
+ self._validate_class(node, report)
188
+ report.stats.total_classes += 1
189
+
190
+ return report
191
+
192
+ def _validate_function(self, func_node: Union[ast.FunctionDef, ast.AsyncFunctionDef], report: ValidationReport) -> None:
193
+ """
194
+ Validate a single function.
195
+
196
+ Args:
197
+ func_node (Union[ast.FunctionDef, ast.AsyncFunctionDef]): The function node to validate.
198
+ report (ValidationReport): The report to add issues to.
199
+ """
200
+ docstring = ast.get_docstring(func_node)
201
+ location = f"{self.file_path or 'code'}:{func_node.lineno}:{func_node.name}"
202
+
203
+ # Check if documented
204
+ if not docstring:
205
+ report.add_issue(ValidationIssue(
206
+ severity=Severity.ERROR,
207
+ category="missing",
208
+ location=location,
209
+ message=f"Function '{func_node.name}' has no docstring",
210
+ suggestion="Add a docstring with at minimum a summary line"
211
+ ))
212
+ return
213
+
214
+ report.stats.documented_functions += 1
215
+
216
+ # Run all checks
217
+ issues = []
218
+ issues.extend(self.check_signature_match(func_node, docstring, location))
219
+ issues.extend(self.check_type_consistency(func_node, docstring, location))
220
+ issues.extend(self.check_exception_documentation(func_node, docstring, location))
221
+ issues.extend(self.check_return_documentation(func_node, docstring, location))
222
+ issues.extend(self.check_format_compliance(docstring, location))
223
+ issues.extend(self.check_content_quality(docstring, location))
224
+
225
+ for issue in issues:
226
+ report.add_issue(issue)
227
+
228
+ def _validate_class(self, class_node: ast.ClassDef, report: ValidationReport) -> None:
229
+ """
230
+ Validate a single class.
231
+
232
+ Args:
233
+ class_node (ast.ClassDef): The class node to validate.
234
+ report (ValidationReport): The report to add issues to.
235
+ """
236
+ docstring = ast.get_docstring(class_node)
237
+ location = f"{self.file_path or 'code'}:{class_node.lineno}:{class_node.name}"
238
+
239
+ if not docstring:
240
+ report.add_issue(ValidationIssue(
241
+ severity=Severity.ERROR,
242
+ category="missing",
243
+ location=location,
244
+ message=f"Class '{class_node.name}' has no docstring",
245
+ suggestion="Add a class docstring describing its purpose"
246
+ ))
247
+ return
248
+
249
+ report.stats.documented_classes += 1
250
+
251
+ def check_signature_match(self, func_node: Union[ast.FunctionDef, ast.AsyncFunctionDef], docstring: str, location: str) -> List[ValidationIssue]:
252
+ """
253
+ Verify all function parameters are documented and vice versa.
254
+
255
+ Args:
256
+ func_node (Union[ast.FunctionDef, ast.AsyncFunctionDef]): The function node.
257
+ docstring (str): The docstring to check.
258
+ location (str): The location string for issues.
259
+
260
+ Returns:
261
+ List[ValidationIssue]: List of issues found.
262
+ """
263
+ issues = []
264
+ parser = DocstringParser(docstring)
265
+ doc_params = list(parser.params.keys())
266
+
267
+ # Actual arguments from AST
268
+ actual_params = [arg.arg for arg in func_node.args.args]
269
+
270
+ # Handle self/cls for methods
271
+ is_method = False
272
+ # Simple heuristic: if it's inside a ClassDef (but we don't have parent info easily here without extra logic)
273
+ # Better: check if first arg is self/cls and it's likely a method
274
+ if actual_params and actual_params[0] in ('self', 'cls'):
275
+ actual_params = actual_params[1:]
276
+ is_method = True
277
+
278
+ actual_set = set(actual_params)
279
+ doc_set = set(doc_params)
280
+
281
+ # Missing parameters in docstring
282
+ missing = actual_set - doc_set
283
+ for p in missing:
284
+ issues.append(ValidationIssue(
285
+ severity=Severity.ERROR,
286
+ category="signature",
287
+ location=location,
288
+ message=f"Parameter '{p}' is not documented in docstring",
289
+ suggestion=f"Add '{p}' to the Args section"
290
+ ))
291
+
292
+ # Extra parameters in docstring
293
+ extra = doc_set - actual_set
294
+ for p in extra:
295
+ issues.append(ValidationIssue(
296
+ severity=Severity.WARNING,
297
+ category="signature",
298
+ location=location,
299
+ message=f"Parameter '{p}' is documented but not in function signature",
300
+ suggestion=f"Remove '{p}' from docstring or update function signature"
301
+ ))
302
+
303
+ # Order mismatch
304
+ # Only check if the sets are the same and non-empty
305
+ if not missing and not extra and actual_params and doc_params:
306
+ if actual_params != doc_params:
307
+ issues.append(ValidationIssue(
308
+ severity=Severity.INFO,
309
+ category="signature",
310
+ location=location,
311
+ message="Parameter order mismatch between signature and docstring",
312
+ suggestion=f"Reorder docstring params to match: {', '.join(actual_params)}"
313
+ ))
314
+
315
+ # Self/cls incorrectly documented
316
+ for p in doc_params:
317
+ if p in ('self', 'cls'):
318
+ issues.append(ValidationIssue(
319
+ severity=Severity.WARNING,
320
+ category="signature",
321
+ location=location,
322
+ message=f"'{p}' should not be documented in docstring",
323
+ suggestion=f"Remove '{p}' from Args section"
324
+ ))
325
+
326
+ return issues
327
+
328
+ def check_type_consistency(self, func_node: Union[ast.FunctionDef, ast.AsyncFunctionDef], docstring: str, location: str) -> List[ValidationIssue]:
329
+ """
330
+ Verify type hints match docstring type documentation.
331
+
332
+ Args:
333
+ func_node (Union[ast.FunctionDef, ast.AsyncFunctionDef]): The function node.
334
+ docstring (str): The docstring to check.
335
+ location (str): The location string for issues.
336
+
337
+ Returns:
338
+ List[ValidationIssue]: List of issues found.
339
+ """
340
+ issues = []
341
+ parser = DocstringParser(docstring)
342
+
343
+ # Check return type consistency
344
+ if func_node.returns:
345
+ return_type_hint = ast.unparse(func_node.returns) if hasattr(ast, 'unparse') else str(func_node.returns)
346
+ if not parser.returns:
347
+ issues.append(ValidationIssue(
348
+ severity=Severity.WARNING,
349
+ category="types",
350
+ location=location,
351
+ message=f"Function has return type hint '{return_type_hint}' but no Returns section in docstring",
352
+ suggestion="Add a Returns section documenting the return value"
353
+ ))
354
+
355
+ # Check parameter type hints vs docstring
356
+ for arg in func_node.args.args:
357
+ if arg.arg in ('self', 'cls'):
358
+ continue
359
+ if arg.annotation and arg.arg not in parser.params:
360
+ type_hint = ast.unparse(arg.annotation) if hasattr(ast, 'unparse') else str(arg.annotation)
361
+ issues.append(ValidationIssue(
362
+ severity=Severity.INFO,
363
+ category="types",
364
+ location=location,
365
+ message=f"Parameter '{arg.arg}' has type hint '{type_hint}' but is not documented",
366
+ suggestion=f"Document '{arg.arg}' in the Args section"
367
+ ))
368
+
369
+ return issues
370
+
371
+ def check_exception_documentation(self, func_node: Union[ast.FunctionDef, ast.AsyncFunctionDef], docstring: str, location: str) -> List[ValidationIssue]:
372
+ """
373
+ Verify all raised exceptions are documented.
374
+
375
+ Args:
376
+ func_node (Union[ast.FunctionDef, ast.AsyncFunctionDef]): The function node.
377
+ docstring (str): The docstring to check.
378
+ location (str): The location string for issues.
379
+
380
+ Returns:
381
+ List[ValidationIssue]: List of issues found.
382
+ """
383
+ issues = []
384
+ parser = DocstringParser(docstring)
385
+
386
+ # Find all raise statements in function body
387
+ raised_exceptions = set()
388
+ for node in ast.walk(func_node):
389
+ if isinstance(node, ast.Raise):
390
+ if node.exc:
391
+ # Extract exception name
392
+ if isinstance(node.exc, ast.Name):
393
+ raised_exceptions.add(node.exc.id)
394
+ elif isinstance(node.exc, ast.Call) and isinstance(node.exc.func, ast.Name):
395
+ raised_exceptions.add(node.exc.func.id)
396
+
397
+ # Check if docstring has Raises section
398
+ has_raises_section = 'Raises:' in docstring or 'Raises\n' in docstring or ':raises' in docstring.lower()
399
+
400
+ if raised_exceptions and not has_raises_section:
401
+ issues.append(ValidationIssue(
402
+ severity=Severity.WARNING,
403
+ category="exceptions",
404
+ location=location,
405
+ message=f"Function raises exceptions {raised_exceptions} but has no Raises section",
406
+ suggestion="Add a Raises section documenting the exceptions"
407
+ ))
408
+
409
+ return issues
410
+
411
+ def check_return_documentation(self, func_node: Union[ast.FunctionDef, ast.AsyncFunctionDef], docstring: str, location: str) -> List[ValidationIssue]:
412
+ """
413
+ Validate return value documentation.
414
+
415
+ Args:
416
+ func_node (Union[ast.FunctionDef, ast.AsyncFunctionDef]): The function node.
417
+ docstring (str): The docstring to check.
418
+ location (str): The location string for issues.
419
+
420
+ Returns:
421
+ List[ValidationIssue]: List of issues found.
422
+ """
423
+ issues = []
424
+ parser = DocstringParser(docstring)
425
+
426
+ # Find all return statements
427
+ has_return_value = False
428
+ for node in ast.walk(func_node):
429
+ if isinstance(node, ast.Return) and node.value is not None:
430
+ has_return_value = True
431
+ break
432
+
433
+ # Check consistency
434
+ if has_return_value and not parser.returns:
435
+ issues.append(ValidationIssue(
436
+ severity=Severity.WARNING,
437
+ category="returns",
438
+ location=location,
439
+ message="Function returns a value but has no Returns section in docstring",
440
+ suggestion="Add a Returns section documenting the return value"
441
+ ))
442
+ elif not has_return_value and parser.returns and func_node.name != '__init__':
443
+ issues.append(ValidationIssue(
444
+ severity=Severity.INFO,
445
+ category="returns",
446
+ location=location,
447
+ message="Function has Returns section but doesn't return a value",
448
+ suggestion="Remove the Returns section or add a return statement"
449
+ ))
450
+
451
+ return issues
452
+
453
+ def check_format_compliance(self, docstring: str, location: str) -> List[ValidationIssue]:
454
+ """
455
+ Validate docstring follows Google-style format.
456
+
457
+ Args:
458
+ docstring (str): The docstring to check.
459
+ location (str): The location string for issues.
460
+
461
+ Returns:
462
+ List[ValidationIssue]: List of issues found.
463
+ """
464
+ issues = []
465
+
466
+ # Check for summary line
467
+ lines = docstring.strip().splitlines()
468
+ if not lines:
469
+ issues.append(ValidationIssue(
470
+ severity=Severity.ERROR,
471
+ category="format",
472
+ location=location,
473
+ message="Docstring is empty",
474
+ suggestion="Add at least a summary line"
475
+ ))
476
+ return issues
477
+
478
+ summary = lines[0].strip()
479
+ if not summary:
480
+ issues.append(ValidationIssue(
481
+ severity=Severity.ERROR,
482
+ category="format",
483
+ location=location,
484
+ message="Docstring missing summary line",
485
+ suggestion="Add a summary line as the first line of the docstring"
486
+ ))
487
+
488
+ # Check for proper section headers (Google style)
489
+ valid_sections = ['Args:', 'Returns:', 'Raises:', 'Yields:', 'Attributes:', 'Example:', 'Examples:', 'Note:', 'Notes:']
490
+ for line in lines:
491
+ stripped = line.strip()
492
+ if stripped.endswith(':') and stripped not in valid_sections:
493
+ # Check if it looks like a section header
494
+ if stripped[0].isupper() and len(stripped.split()) == 1:
495
+ issues.append(ValidationIssue(
496
+ severity=Severity.INFO,
497
+ category="format",
498
+ location=location,
499
+ message=f"Non-standard section header '{stripped}' found",
500
+ suggestion=f"Use standard Google-style sections: {', '.join(valid_sections)}"
501
+ ))
502
+
503
+ return issues
504
+
505
+ def check_content_quality(self, docstring: str, location: str) -> List[ValidationIssue]:
506
+ """
507
+ Check for low-quality documentation content.
508
+
509
+ Args:
510
+ docstring (str): The docstring to check.
511
+ location (str): The location string for issues.
512
+
513
+ Returns:
514
+ List[ValidationIssue]: List of issues found.
515
+ """
516
+ issues = []
517
+ parser = DocstringParser(docstring)
518
+
519
+ # Check for placeholder text
520
+ placeholders = ['TODO', 'FIXME', 'XXX', 'HACK', 'Description of', 'TBD', 'To be determined']
521
+ for placeholder in placeholders:
522
+ if placeholder.lower() in docstring.lower():
523
+ issues.append(ValidationIssue(
524
+ severity=Severity.WARNING,
525
+ category="quality",
526
+ location=location,
527
+ message=f"Placeholder text '{placeholder}' found in docstring",
528
+ suggestion="Replace placeholder with actual documentation"
529
+ ))
530
+
531
+ # Check summary length
532
+ if parser.summary and len(parser.summary) < 10:
533
+ issues.append(ValidationIssue(
534
+ severity=Severity.INFO,
535
+ category="quality",
536
+ location=location,
537
+ message=f"Summary line is very short ({len(parser.summary)} chars)",
538
+ suggestion="Provide a more descriptive summary (at least 10 characters)"
539
+ ))
540
+
541
+ # Check for duplicate parameter descriptions
542
+ if len(parser.params) > 1:
543
+ descriptions = list(parser.params.values())
544
+ if len(descriptions) != len(set(descriptions)):
545
+ issues.append(ValidationIssue(
546
+ severity=Severity.WARNING,
547
+ category="quality",
548
+ location=location,
549
+ message="Multiple parameters have identical descriptions",
550
+ suggestion="Provide unique descriptions for each parameter"
551
+ ))
552
+
553
+ # Check if summary and description are identical
554
+ if parser.summary and parser.description and parser.summary.strip() == parser.description.strip():
555
+ issues.append(ValidationIssue(
556
+ severity=Severity.INFO,
557
+ category="quality",
558
+ location=location,
559
+ message="Summary and description are identical",
560
+ suggestion="Either remove the description or expand it with more details"
561
+ ))
562
+
563
+ return issues