docgenpranay 0.1.0__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.
- docgenpranay-0.1.0/PKG-INFO +8 -0
- docgenpranay-0.1.0/docgenpranay.egg-info/PKG-INFO +8 -0
- docgenpranay-0.1.0/docgenpranay.egg-info/SOURCES.txt +11 -0
- docgenpranay-0.1.0/docgenpranay.egg-info/dependency_links.txt +1 -0
- docgenpranay-0.1.0/docgenpranay.egg-info/entry_points.txt +2 -0
- docgenpranay-0.1.0/docgenpranay.egg-info/requires.txt +1 -0
- docgenpranay-0.1.0/docgenpranay.egg-info/top_level.txt +3 -0
- docgenpranay-0.1.0/docugenius/__init__.py +0 -0
- docgenpranay-0.1.0/docugenius/cli.py +117 -0
- docgenpranay-0.1.0/docugenius/core.py +215 -0
- docgenpranay-0.1.0/pyproject.toml +22 -0
- docgenpranay-0.1.0/setup.cfg +4 -0
- docgenpranay-0.1.0/tests/test_edge_cases.py +123 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
docgenpranay.egg-info/PKG-INFO
|
|
3
|
+
docgenpranay.egg-info/SOURCES.txt
|
|
4
|
+
docgenpranay.egg-info/dependency_links.txt
|
|
5
|
+
docgenpranay.egg-info/entry_points.txt
|
|
6
|
+
docgenpranay.egg-info/requires.txt
|
|
7
|
+
docgenpranay.egg-info/top_level.txt
|
|
8
|
+
docugenius/__init__.py
|
|
9
|
+
docugenius/cli.py
|
|
10
|
+
docugenius/core.py
|
|
11
|
+
tests/test_edge_cases.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pydocstyle
|
|
File without changes
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from docugenius.core import (
|
|
7
|
+
extract_definitions,
|
|
8
|
+
get_function_metadata,
|
|
9
|
+
generate_coverage_report,
|
|
10
|
+
instrument_tree,
|
|
11
|
+
generate_instrumented_code,
|
|
12
|
+
validate_file,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def process_file(file_path: Path, style: str):
|
|
17
|
+
"""
|
|
18
|
+
Process a single Python file.
|
|
19
|
+
Returns (instrumented_code, coverage_percent, compliance_percent)
|
|
20
|
+
"""
|
|
21
|
+
source = file_path.read_text(encoding="utf-8")
|
|
22
|
+
|
|
23
|
+
tree = ast.parse(source)
|
|
24
|
+
functions, _ = extract_definitions(tree)
|
|
25
|
+
|
|
26
|
+
metadata = [get_function_metadata(func) for func in functions]
|
|
27
|
+
|
|
28
|
+
coverage = generate_coverage_report(metadata)
|
|
29
|
+
violations = validate_file(source)
|
|
30
|
+
|
|
31
|
+
total = coverage["total_functions"]
|
|
32
|
+
compliance = (
|
|
33
|
+
round(((total - len(violations)) / total) * 100, 2)
|
|
34
|
+
if total > 0 else 100.0
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Instrument
|
|
38
|
+
instrumented_tree = instrument_tree(tree, style=style)
|
|
39
|
+
instrumented_code = generate_instrumented_code(instrumented_tree)
|
|
40
|
+
|
|
41
|
+
return instrumented_code, coverage["coverage_percent"], compliance
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main():
|
|
45
|
+
parser = argparse.ArgumentParser(
|
|
46
|
+
description="Automated Python Docstring Generator"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"files",
|
|
51
|
+
nargs="+",
|
|
52
|
+
help="Path(s) to Python file(s)"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--style",
|
|
57
|
+
choices=["google", "numpy", "reST"],
|
|
58
|
+
default="google",
|
|
59
|
+
help="Docstring style"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--min-coverage",
|
|
64
|
+
type=float,
|
|
65
|
+
default=0,
|
|
66
|
+
help="Minimum required documentation coverage percentage"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--min-compliance",
|
|
71
|
+
type=float,
|
|
72
|
+
default=0,
|
|
73
|
+
help="Minimum required PEP-257 compliance percentage"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
args = parser.parse_args()
|
|
77
|
+
|
|
78
|
+
overall_success = True
|
|
79
|
+
|
|
80
|
+
for file_str in args.files:
|
|
81
|
+
file_path = Path(file_str)
|
|
82
|
+
|
|
83
|
+
if not file_path.exists():
|
|
84
|
+
print(f"❌ File not found: {file_str}")
|
|
85
|
+
overall_success = False
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
instrumented_code, coverage, compliance = process_file(
|
|
90
|
+
file_path, args.style
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
print(f"\n📄 File: {file_path}")
|
|
94
|
+
print(f"Coverage: {coverage}%")
|
|
95
|
+
print(f"Compliance: {compliance}%")
|
|
96
|
+
|
|
97
|
+
if coverage < args.min_coverage:
|
|
98
|
+
print(f"❌ Coverage below minimum ({args.min_coverage}%)")
|
|
99
|
+
overall_success = False
|
|
100
|
+
|
|
101
|
+
if compliance < args.min_compliance:
|
|
102
|
+
print(f"❌ Compliance below minimum ({args.min_compliance}%)")
|
|
103
|
+
overall_success = False
|
|
104
|
+
|
|
105
|
+
# Print generated code
|
|
106
|
+
print("\n--- Generated Code ---\n")
|
|
107
|
+
print(instrumented_code)
|
|
108
|
+
|
|
109
|
+
except Exception as e:
|
|
110
|
+
print(f"❌ Error processing {file_str}: {e}")
|
|
111
|
+
overall_success = False
|
|
112
|
+
|
|
113
|
+
sys.exit(0 if overall_success else 1)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
main()
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import re
|
|
3
|
+
import tempfile
|
|
4
|
+
import os
|
|
5
|
+
from typing import Dict, List
|
|
6
|
+
import pydocstyle
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# =====================================================
|
|
10
|
+
# PARSER
|
|
11
|
+
# =====================================================
|
|
12
|
+
|
|
13
|
+
def parse_file(file_path):
|
|
14
|
+
with open(file_path, "r") as f:
|
|
15
|
+
source_code = f.read()
|
|
16
|
+
return ast.parse(source_code)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# =====================================================
|
|
20
|
+
# EXTRACTION
|
|
21
|
+
# =====================================================
|
|
22
|
+
|
|
23
|
+
def extract_definitions(tree: ast.AST):
|
|
24
|
+
functions = []
|
|
25
|
+
classes = []
|
|
26
|
+
|
|
27
|
+
for node in ast.walk(tree):
|
|
28
|
+
if isinstance(node, ast.FunctionDef):
|
|
29
|
+
functions.append(node)
|
|
30
|
+
elif isinstance(node, ast.ClassDef):
|
|
31
|
+
classes.append(node)
|
|
32
|
+
|
|
33
|
+
return functions, classes
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_function_metadata(func: ast.FunctionDef) -> Dict:
|
|
37
|
+
params = [arg.arg for arg in func.args.args]
|
|
38
|
+
docstring = ast.get_docstring(func)
|
|
39
|
+
has_return = any(isinstance(n, ast.Return) for n in ast.walk(func))
|
|
40
|
+
|
|
41
|
+
raises = set()
|
|
42
|
+
for n in ast.walk(func):
|
|
43
|
+
if isinstance(n, ast.Raise) and n.exc:
|
|
44
|
+
try:
|
|
45
|
+
exc = ast.unparse(n.exc)
|
|
46
|
+
exc = re.sub(r"\(.*\)", "", exc)
|
|
47
|
+
raises.add(exc)
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
is_generator = any(
|
|
52
|
+
isinstance(n, (ast.Yield, ast.YieldFrom))
|
|
53
|
+
for n in ast.walk(func)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
"name": func.name,
|
|
58
|
+
"params": params,
|
|
59
|
+
"has_docstring": docstring is not None,
|
|
60
|
+
"docstring": docstring,
|
|
61
|
+
"has_return": has_return,
|
|
62
|
+
"raises": list(raises),
|
|
63
|
+
"is_generator": is_generator,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# =====================================================
|
|
68
|
+
# COVERAGE
|
|
69
|
+
# =====================================================
|
|
70
|
+
|
|
71
|
+
def generate_coverage_report(metadata):
|
|
72
|
+
total_functions = len(metadata)
|
|
73
|
+
|
|
74
|
+
documented = [m for m in metadata if m.get("has_docstring")]
|
|
75
|
+
undocumented = [m for m in metadata if not m.get("has_docstring")]
|
|
76
|
+
|
|
77
|
+
coverage_percent = (
|
|
78
|
+
round((len(documented) / total_functions) * 100, 2)
|
|
79
|
+
if total_functions > 0 else 0
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"total_functions": total_functions,
|
|
84
|
+
"documented_functions": len(documented),
|
|
85
|
+
"undocumented_functions": len(undocumented),
|
|
86
|
+
"coverage_percent": coverage_percent
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# =====================================================
|
|
91
|
+
# DOCSTRING GENERATION
|
|
92
|
+
# =====================================================
|
|
93
|
+
|
|
94
|
+
def generate_docstring(meta: dict, style: str = "google") -> str:
|
|
95
|
+
"""
|
|
96
|
+
Generate clean PEP-257 compliant docstring.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
name = meta["name"].replace("_", " ").capitalize()
|
|
100
|
+
params = meta["params"]
|
|
101
|
+
raises = meta["raises"]
|
|
102
|
+
is_generator = meta["is_generator"]
|
|
103
|
+
has_return = meta["has_return"]
|
|
104
|
+
|
|
105
|
+
lines = [f"{name}.", ""]
|
|
106
|
+
|
|
107
|
+
# ---------------- PARAMETERS ----------------
|
|
108
|
+
if params:
|
|
109
|
+
lines.append("Args:")
|
|
110
|
+
for p in params:
|
|
111
|
+
lines.append(f" {p}: Description.")
|
|
112
|
+
lines.append("")
|
|
113
|
+
|
|
114
|
+
# ---------------- RETURNS / YIELDS ----------------
|
|
115
|
+
if is_generator:
|
|
116
|
+
lines.append("Yields:")
|
|
117
|
+
lines.append(" value: Description.")
|
|
118
|
+
lines.append("")
|
|
119
|
+
elif has_return:
|
|
120
|
+
lines.append("Returns:")
|
|
121
|
+
lines.append(" value: Description.")
|
|
122
|
+
lines.append("")
|
|
123
|
+
|
|
124
|
+
# ---------------- RAISES ----------------
|
|
125
|
+
if raises:
|
|
126
|
+
lines.append("Raises:")
|
|
127
|
+
for r in raises:
|
|
128
|
+
lines.append(f" {r}: Description.")
|
|
129
|
+
lines.append("")
|
|
130
|
+
|
|
131
|
+
# Remove trailing blank lines
|
|
132
|
+
while lines and lines[-1] == "":
|
|
133
|
+
lines.pop()
|
|
134
|
+
|
|
135
|
+
return "\n".join(lines)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# =====================================================
|
|
139
|
+
# INSTRUMENTATION
|
|
140
|
+
# =====================================================
|
|
141
|
+
|
|
142
|
+
def instrument_ast(tree: ast.AST, style: str = "google") -> ast.AST:
|
|
143
|
+
"""
|
|
144
|
+
Insert generated docstrings into module, classes, and functions.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
# ---------------- MODULE DOCSTRING ----------------
|
|
148
|
+
if not ast.get_docstring(tree):
|
|
149
|
+
module_doc = ast.Expr(
|
|
150
|
+
value=ast.Constant("Module description.")
|
|
151
|
+
)
|
|
152
|
+
tree.body.insert(0, module_doc)
|
|
153
|
+
|
|
154
|
+
for node in ast.walk(tree):
|
|
155
|
+
|
|
156
|
+
# ---------------- CLASS DOCSTRING ----------------
|
|
157
|
+
if isinstance(node, ast.ClassDef):
|
|
158
|
+
if not ast.get_docstring(node):
|
|
159
|
+
class_doc = ast.Expr(
|
|
160
|
+
value=ast.Constant(f"{node.name} class.")
|
|
161
|
+
)
|
|
162
|
+
node.body.insert(0, class_doc)
|
|
163
|
+
|
|
164
|
+
# ---------------- FUNCTION DOCSTRING ----------------
|
|
165
|
+
if isinstance(node, ast.FunctionDef):
|
|
166
|
+
if not ast.get_docstring(node):
|
|
167
|
+
meta = get_function_metadata(node)
|
|
168
|
+
docstring = generate_docstring(meta, style)
|
|
169
|
+
|
|
170
|
+
node.body.insert(
|
|
171
|
+
0,
|
|
172
|
+
ast.Expr(value=ast.Constant(docstring))
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return tree
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def instrument_tree(tree, metadata=None, style="google"):
|
|
179
|
+
return instrument_ast(tree, style)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def generate_instrumented_code(tree):
|
|
183
|
+
return ast.unparse(tree)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# =====================================================
|
|
187
|
+
# VALIDATOR
|
|
188
|
+
# =====================================================
|
|
189
|
+
|
|
190
|
+
IGNORED_RULES = [
|
|
191
|
+
"D100", "D101", "D102",
|
|
192
|
+
"D205", "D400", "D401", "D212", "D213",
|
|
193
|
+
"D406", "D407", "D413",
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def validate_file(source_code: str) -> List[Dict]:
|
|
198
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as tmp:
|
|
199
|
+
tmp.write(source_code)
|
|
200
|
+
tmp_path = tmp.name
|
|
201
|
+
|
|
202
|
+
violations = []
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
for error in pydocstyle.check([tmp_path], ignore=IGNORED_RULES):
|
|
206
|
+
violations.append({
|
|
207
|
+
"line": error.line,
|
|
208
|
+
"code": error.code,
|
|
209
|
+
"message": error.message,
|
|
210
|
+
"source": error.source,
|
|
211
|
+
})
|
|
212
|
+
finally:
|
|
213
|
+
os.unlink(tmp_path)
|
|
214
|
+
|
|
215
|
+
return violations
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "docgenpranay"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Automated Python Docstring Generator"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Muddam Pranay" }
|
|
13
|
+
]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"pydocstyle"
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
docgenpranay = "docugenius.cli:main"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["."]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from docugenius.core import (
|
|
5
|
+
extract_definitions,
|
|
6
|
+
get_function_metadata,
|
|
7
|
+
instrument_tree,
|
|
8
|
+
generate_instrumented_code,
|
|
9
|
+
validate_file,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------
|
|
14
|
+
# HELPER
|
|
15
|
+
# ---------------------------------------------------
|
|
16
|
+
|
|
17
|
+
def _parse_and_instrument(source: str):
|
|
18
|
+
tree = ast.parse(source)
|
|
19
|
+
instrumented_tree = instrument_tree(tree, style="google")
|
|
20
|
+
return generate_instrumented_code(instrumented_tree)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------
|
|
24
|
+
# TESTS
|
|
25
|
+
# ---------------------------------------------------
|
|
26
|
+
|
|
27
|
+
def test_empty_python_file():
|
|
28
|
+
"""Empty file should be handled safely."""
|
|
29
|
+
source = ""
|
|
30
|
+
tree = ast.parse(source)
|
|
31
|
+
instrumented_tree = instrument_tree(tree, style="google")
|
|
32
|
+
code = generate_instrumented_code(instrumented_tree)
|
|
33
|
+
|
|
34
|
+
# Should at least be valid Python
|
|
35
|
+
ast.parse(code)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_module_docstring_added_if_missing():
|
|
39
|
+
"""Missing module docstring should be inserted."""
|
|
40
|
+
source = "x = 10"
|
|
41
|
+
instrumented = _parse_and_instrument(source)
|
|
42
|
+
|
|
43
|
+
assert 'Module description.' in instrumented
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_nested_function_only_outer_documented():
|
|
47
|
+
"""Nested functions should not necessarily be documented separately."""
|
|
48
|
+
source = """
|
|
49
|
+
def outer():
|
|
50
|
+
def inner():
|
|
51
|
+
return 5
|
|
52
|
+
return inner()
|
|
53
|
+
"""
|
|
54
|
+
instrumented = _parse_and_instrument(source)
|
|
55
|
+
tree = ast.parse(instrumented)
|
|
56
|
+
|
|
57
|
+
functions, _ = extract_definitions(tree)
|
|
58
|
+
|
|
59
|
+
outer = next(f for f in functions if f.name == "outer")
|
|
60
|
+
inner = next(f for f in functions if f.name == "inner")
|
|
61
|
+
|
|
62
|
+
assert ast.get_docstring(outer) is not None
|
|
63
|
+
# Depending on your logic, inner may or may not be documented.
|
|
64
|
+
# If your instrumentor documents all, change this to assert is not None.
|
|
65
|
+
assert ast.get_docstring(inner) is not None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_class_without_methods_gets_docstring():
|
|
69
|
+
"""Classes without methods should receive docstrings."""
|
|
70
|
+
source = """
|
|
71
|
+
class Empty:
|
|
72
|
+
pass
|
|
73
|
+
"""
|
|
74
|
+
instrumented = _parse_and_instrument(source)
|
|
75
|
+
tree = ast.parse(instrumented)
|
|
76
|
+
_, classes = extract_definitions(tree)
|
|
77
|
+
|
|
78
|
+
empty_class = next(c for c in classes if c.name == "Empty")
|
|
79
|
+
assert ast.get_docstring(empty_class) is not None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_existing_docstring_not_overwritten():
|
|
83
|
+
"""Existing function docstrings should not be replaced."""
|
|
84
|
+
source = '''
|
|
85
|
+
def multiply(x, y):
|
|
86
|
+
"""Existing docstring."""
|
|
87
|
+
return x * y
|
|
88
|
+
'''
|
|
89
|
+
instrumented = _parse_and_instrument(source)
|
|
90
|
+
tree = ast.parse(instrumented)
|
|
91
|
+
functions, _ = extract_definitions(tree)
|
|
92
|
+
|
|
93
|
+
multiply = next(f for f in functions if f.name == "multiply")
|
|
94
|
+
doc = ast.get_docstring(multiply)
|
|
95
|
+
|
|
96
|
+
assert "Existing docstring." in doc
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_syntax_error_raises():
|
|
100
|
+
"""Invalid Python input should raise SyntaxError."""
|
|
101
|
+
bad_source = "def broken(:"
|
|
102
|
+
with pytest.raises(SyntaxError):
|
|
103
|
+
ast.parse(bad_source)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_generated_code_removes_missing_docstring_violation():
|
|
107
|
+
"""Instrumentation should remove missing-docstring violations."""
|
|
108
|
+
source = """
|
|
109
|
+
def add(x, y):
|
|
110
|
+
return x + y
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
before_violations = validate_file(source)
|
|
114
|
+
instrumented = _parse_and_instrument(source)
|
|
115
|
+
after_violations = validate_file(instrumented)
|
|
116
|
+
|
|
117
|
+
before_codes = {v["code"] for v in before_violations}
|
|
118
|
+
after_codes = {v["code"] for v in after_violations}
|
|
119
|
+
|
|
120
|
+
# Ensure D103 (missing function docstring) is removed
|
|
121
|
+
assert "D103" in before_codes
|
|
122
|
+
assert "D103" not in after_codes
|
|
123
|
+
|