autodocstring-tool 0.1.0__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.
- autodocstring/__init__.py +1 -0
- autodocstring/__main__.py +4 -0
- autodocstring/cli.py +91 -0
- autodocstring/compliance.py +53 -0
- autodocstring/config.py +20 -0
- autodocstring/coverage.py +70 -0
- autodocstring/generator.py +78 -0
- autodocstring/injector.py +64 -0
- autodocstring/llm_generator.py +173 -0
- autodocstring/parser.py +89 -0
- autodocstring/pep257_fixer.py +60 -0
- autodocstring/pydoc_report.py +31 -0
- autodocstring/quality.py +29 -0
- autodocstring_tool-0.1.0.dist-info/METADATA +10 -0
- autodocstring_tool-0.1.0.dist-info/RECORD +19 -0
- autodocstring_tool-0.1.0.dist-info/WHEEL +5 -0
- autodocstring_tool-0.1.0.dist-info/entry_points.txt +2 -0
- autodocstring_tool-0.1.0.dist-info/licenses/LICENSE +21 -0
- autodocstring_tool-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
autodocstring/cli.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import argparse
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from autodocstring.parser import parse_file
|
|
6
|
+
from autodocstring.coverage import coverage_report
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
parser = argparse.ArgumentParser(
|
|
11
|
+
description="Docstring coverage checker and reporter",
|
|
12
|
+
epilog="Example: python cli.py samples/ --style Google --min-coverage 80 --verbose",
|
|
13
|
+
)
|
|
14
|
+
parser.add_argument(
|
|
15
|
+
"paths",
|
|
16
|
+
nargs="*",
|
|
17
|
+
default=["samples"],
|
|
18
|
+
help="Path(s) to check (folder or files). Default: samples",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--min-coverage",
|
|
22
|
+
type=float,
|
|
23
|
+
default=80.0,
|
|
24
|
+
help="Minimum coverage per file (default: 80.0)",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--style",
|
|
28
|
+
default="Google",
|
|
29
|
+
choices=["Google", "NumPy", "reST"],
|
|
30
|
+
help="Docstring style (default: Google)",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--verbose",
|
|
34
|
+
action="store_true",
|
|
35
|
+
help="Print detailed output for each file",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
args = parser.parse_args()
|
|
39
|
+
|
|
40
|
+
files = []
|
|
41
|
+
for p in args.paths:
|
|
42
|
+
path = Path(p)
|
|
43
|
+
if path.is_dir():
|
|
44
|
+
files.extend(path.rglob("*.py"))
|
|
45
|
+
elif path.is_file() and path.suffix.lower() == ".py":
|
|
46
|
+
files.append(path)
|
|
47
|
+
else:
|
|
48
|
+
if args.verbose:
|
|
49
|
+
print(f"Skipping non-Python path: {path}")
|
|
50
|
+
|
|
51
|
+
if not files:
|
|
52
|
+
print("No Python files found.")
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
|
|
55
|
+
failed = []
|
|
56
|
+
print(f"Checking {len(files)} file(s)")
|
|
57
|
+
|
|
58
|
+
for file_path in files:
|
|
59
|
+
try:
|
|
60
|
+
parsed = parse_file(str(file_path))
|
|
61
|
+
report = coverage_report(parsed)
|
|
62
|
+
|
|
63
|
+
# Safe access to keys with defaults
|
|
64
|
+
coverage_pct = report.get("Coverage (%)", 0.0)
|
|
65
|
+
missing_count = report.get("Missing", 0)
|
|
66
|
+
|
|
67
|
+
if coverage_pct < args.min_coverage:
|
|
68
|
+
failed.append((file_path, coverage_pct, missing_count))
|
|
69
|
+
print(
|
|
70
|
+
f" {file_path}: {coverage_pct:.2f}% - missing {missing_count} items"
|
|
71
|
+
)
|
|
72
|
+
else:
|
|
73
|
+
if args.verbose:
|
|
74
|
+
print(f" {file_path}: {coverage_pct:.2f}% OK")
|
|
75
|
+
|
|
76
|
+
except Exception as e:
|
|
77
|
+
print(f"Error processing {file_path}: {type(e).__name__}: {str(e)}")
|
|
78
|
+
failed.append((file_path, 0.0, "error"))
|
|
79
|
+
|
|
80
|
+
if failed:
|
|
81
|
+
print(
|
|
82
|
+
f"\nFailed: {len(failed)} file(s) below {args.min_coverage}% or had errors"
|
|
83
|
+
)
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
|
|
86
|
+
print("All checked files passed coverage check.")
|
|
87
|
+
sys.exit(0)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
main()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
def analyze_docstring(func, style=None):
|
|
2
|
+
raw_doc = func.get("docstring") or ""
|
|
3
|
+
doc = raw_doc.lower()
|
|
4
|
+
missing = []
|
|
5
|
+
formatting_issues = []
|
|
6
|
+
warnings = []
|
|
7
|
+
# ---------- SECTION COMPLETENESS ----------
|
|
8
|
+
if func["params"] and not any(k in doc for k in ["param", "args", "parameters"]):
|
|
9
|
+
missing.append("Parameters")
|
|
10
|
+
|
|
11
|
+
if func["returns"] and "return" not in doc:
|
|
12
|
+
missing.append("Returns")
|
|
13
|
+
|
|
14
|
+
if func["raises"] and "raise" not in doc:
|
|
15
|
+
missing.append("Raises")
|
|
16
|
+
|
|
17
|
+
if func["is_generator"] and "yield" not in doc:
|
|
18
|
+
missing.append("Yields")
|
|
19
|
+
|
|
20
|
+
if func.get("class") and func.get("attributes") and "attribute" not in doc:
|
|
21
|
+
missing.append("Attributes")
|
|
22
|
+
# ---------- SUMMARY LINE CHECK ----------
|
|
23
|
+
if raw_doc:
|
|
24
|
+
first_line = raw_doc.strip().split("\n")[0].strip()
|
|
25
|
+
|
|
26
|
+
if not first_line:
|
|
27
|
+
formatting_issues.append("Empty summary line")
|
|
28
|
+
elif not first_line.endswith("."):
|
|
29
|
+
warnings.append("Summary line should end with a period")
|
|
30
|
+
elif len(first_line) > 72:
|
|
31
|
+
warnings.append("Summary line is too long")
|
|
32
|
+
elif first_line.lower().startswith(
|
|
33
|
+
("returns", "does", "divides", "calculates")
|
|
34
|
+
):
|
|
35
|
+
warnings.append("Summary line not in imperative mood")
|
|
36
|
+
else:
|
|
37
|
+
formatting_issues.append("Missing summary line")
|
|
38
|
+
# ---------- STYLE-SPECIFIC CHECK ----------
|
|
39
|
+
if style and raw_doc:
|
|
40
|
+
if style == "Google" and "args:" not in doc:
|
|
41
|
+
warnings.append("Google style expects 'Args:' section")
|
|
42
|
+
|
|
43
|
+
if style == "NumPy" and "parameters" not in doc:
|
|
44
|
+
warnings.append("NumPy style expects 'Parameters' section")
|
|
45
|
+
|
|
46
|
+
if style == "reST" and ":param" not in doc:
|
|
47
|
+
warnings.append("reST style expects ':param:' fields")
|
|
48
|
+
return {
|
|
49
|
+
"missing_sections": missing,
|
|
50
|
+
"formatting_issues": formatting_issues,
|
|
51
|
+
"warnings": warnings,
|
|
52
|
+
"pep257_compliant": len(missing) == 0 and len(formatting_issues) == 0,
|
|
53
|
+
}
|
autodocstring/config.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def load_config():
|
|
6
|
+
path = Path("pyproject.toml")
|
|
7
|
+
|
|
8
|
+
defaults = {
|
|
9
|
+
"style": "Google",
|
|
10
|
+
"min_coverage": 80,
|
|
11
|
+
"enforce_pep257": True,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if not path.exists():
|
|
15
|
+
return defaults
|
|
16
|
+
|
|
17
|
+
with open(path, "rb") as f:
|
|
18
|
+
data = tomllib.load(f)
|
|
19
|
+
|
|
20
|
+
return {**defaults, **data.get("tool", {}).get("autodoc", {})}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from autodocstring.compliance import analyze_docstring
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def coverage_report(parsed_data):
|
|
5
|
+
functions = parsed_data["functions"]
|
|
6
|
+
classes = parsed_data["classes"]
|
|
7
|
+
methods = [m for c in classes for m in c["methods"]]
|
|
8
|
+
all_items = functions + methods
|
|
9
|
+
missing = {"Parameters": 0, "Returns": 0, "Raises": 0, "Yields": 0, "Attributes": 0}
|
|
10
|
+
compliant = 0
|
|
11
|
+
# ✅ NEW: collect non-compliant details
|
|
12
|
+
non_compliant_items = []
|
|
13
|
+
|
|
14
|
+
for item in all_items:
|
|
15
|
+
result = analyze_docstring(item)
|
|
16
|
+
|
|
17
|
+
if result["pep257_compliant"]:
|
|
18
|
+
compliant += 1
|
|
19
|
+
else:
|
|
20
|
+
non_compliant_items.append(
|
|
21
|
+
{
|
|
22
|
+
"Name": (
|
|
23
|
+
item["name"]
|
|
24
|
+
if not item.get("class")
|
|
25
|
+
else f"{item['class']}.{item['name']}"
|
|
26
|
+
),
|
|
27
|
+
"Type": "Method" if item.get("class") else "Function",
|
|
28
|
+
"Missing Sections": ", ".join(result["missing_sections"]) or "None",
|
|
29
|
+
"Formatting Issues": ", ".join(result.get("formatting_issues", []))
|
|
30
|
+
or "None",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
for sec in result["missing_sections"]:
|
|
35
|
+
missing[sec] += 1
|
|
36
|
+
|
|
37
|
+
total = len(all_items)
|
|
38
|
+
documented = sum(i["has_docstring"] for i in all_items)
|
|
39
|
+
# ---------- TYPE-WISE COUNTS ----------
|
|
40
|
+
documented_functions = sum(f["has_docstring"] for f in functions)
|
|
41
|
+
documented_methods = sum(m["has_docstring"] for m in methods)
|
|
42
|
+
documented_classes = sum(c["has_docstring"] for c in classes)
|
|
43
|
+
return {
|
|
44
|
+
# ---------- COUNTS ----------
|
|
45
|
+
"Functions": len(functions),
|
|
46
|
+
"Classes": len(classes),
|
|
47
|
+
"Methods": len(methods),
|
|
48
|
+
"Total": total,
|
|
49
|
+
# ---------- OVERALL COVERAGE ----------
|
|
50
|
+
"Documented": documented,
|
|
51
|
+
"Missing": total - documented,
|
|
52
|
+
"Coverage (%)": (documented / total * 100) if total else 0,
|
|
53
|
+
# ---------- TYPE-WISE COVERAGE ----------
|
|
54
|
+
"Function Coverage (%)": (
|
|
55
|
+
documented_functions / len(functions) * 100 if functions else 0
|
|
56
|
+
),
|
|
57
|
+
"Method Coverage (%)": (
|
|
58
|
+
documented_methods / len(methods) * 100 if methods else 0
|
|
59
|
+
),
|
|
60
|
+
"Class Coverage (%)": (
|
|
61
|
+
documented_classes / len(classes) * 100 if classes else 0
|
|
62
|
+
),
|
|
63
|
+
# ---------- COMPLIANCE ----------
|
|
64
|
+
"PEP-257 Compliant": compliant,
|
|
65
|
+
"PEP-257 Compliance (%)": (compliant / total * 100) if total else 0,
|
|
66
|
+
# ---------- MISSING SECTIONS ----------
|
|
67
|
+
**{f"Missing {k}": v for k, v in missing.items()},
|
|
68
|
+
# ---------- ✅ NEW ADDITION ----------
|
|
69
|
+
"Non-Compliant Items": non_compliant_items,
|
|
70
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
def _summary(item, class_name=None):
|
|
2
|
+
if "name" in item:
|
|
3
|
+
name = f"{class_name}.{item['name']}" if class_name else item["name"]
|
|
4
|
+
else:
|
|
5
|
+
name = "Object"
|
|
6
|
+
|
|
7
|
+
return f"Generate documentation for {name}."
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def generate_google(func, class_name=None):
|
|
11
|
+
doc = f"{_summary(func, class_name)}\n\n"
|
|
12
|
+
|
|
13
|
+
if func.get("params"):
|
|
14
|
+
doc += "Args:\n"
|
|
15
|
+
for p in func["params"]:
|
|
16
|
+
doc += f" {p['name']} ({p['type'] or 'Any'}): Description.\n"
|
|
17
|
+
|
|
18
|
+
if func.get("returns"):
|
|
19
|
+
doc += f"\nReturns:\n {func['returns']}: Description.\n"
|
|
20
|
+
|
|
21
|
+
if func.get("raises"):
|
|
22
|
+
doc += "\nRaises:\n"
|
|
23
|
+
for r in func["raises"]:
|
|
24
|
+
doc += f" {r}: Description.\n"
|
|
25
|
+
|
|
26
|
+
if func.get("is_generator"):
|
|
27
|
+
doc += "\nYields:\n value: Generated value.\n"
|
|
28
|
+
|
|
29
|
+
return doc.strip()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def generate_numpy(func, class_name=None):
|
|
33
|
+
doc = f"{_summary(func, class_name)}\n\n"
|
|
34
|
+
|
|
35
|
+
if func.get("params"):
|
|
36
|
+
doc += "Parameters\n----------\n"
|
|
37
|
+
for p in func["params"]:
|
|
38
|
+
doc += f"{p['name']} : {p['type'] or 'Any'}\n Description.\n"
|
|
39
|
+
|
|
40
|
+
if func.get("returns"):
|
|
41
|
+
doc += f"\nReturns\n-------\n{func['returns']}\n Description.\n"
|
|
42
|
+
|
|
43
|
+
if func.get("raises"):
|
|
44
|
+
doc += "\nRaises\n------\n"
|
|
45
|
+
for r in func["raises"]:
|
|
46
|
+
doc += f"{r}\n Description.\n"
|
|
47
|
+
|
|
48
|
+
if func.get("is_generator"):
|
|
49
|
+
doc += "\nYields\n------\nvalue\n Generated value.\n"
|
|
50
|
+
|
|
51
|
+
return doc.strip()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def generate_rest(func, class_name=None):
|
|
55
|
+
doc = f"{_summary(func, class_name)}\n\n"
|
|
56
|
+
|
|
57
|
+
for p in func.get("params", []):
|
|
58
|
+
doc += f":param {p['name']}: Description.\n"
|
|
59
|
+
doc += f":type {p['name']}: {p['type'] or 'Any'}\n"
|
|
60
|
+
|
|
61
|
+
if func.get("returns"):
|
|
62
|
+
doc += f":return: {func['returns']}.\n"
|
|
63
|
+
|
|
64
|
+
for r in func.get("raises", []):
|
|
65
|
+
doc += f":raises {r}: Description.\n"
|
|
66
|
+
|
|
67
|
+
if func.get("is_generator"):
|
|
68
|
+
doc += ":yields: Generated value.\n"
|
|
69
|
+
|
|
70
|
+
return doc.strip()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def generate_docstring(func, class_name=None, style="Google"):
|
|
74
|
+
if style == "NumPy":
|
|
75
|
+
return generate_numpy(func, class_name)
|
|
76
|
+
if style == "reST":
|
|
77
|
+
return generate_rest(func, class_name)
|
|
78
|
+
return generate_google(func, class_name)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from autodocstring.llm_generator import generate_docstring_llm
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DocstringInjector(ast.NodeTransformer):
|
|
6
|
+
"""Injects clean PEP 257 docstrings into functions and methods."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, style: str = "Google"):
|
|
9
|
+
self.style = style
|
|
10
|
+
|
|
11
|
+
def visit_FunctionDef(self, node):
|
|
12
|
+
self.generic_visit(node)
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
source = ast.unparse(node)
|
|
16
|
+
raw_body = generate_docstring_llm(source, self.style)
|
|
17
|
+
|
|
18
|
+
# Strip any triple quotes the LLM might have added
|
|
19
|
+
body = raw_body.strip()
|
|
20
|
+
for q in ['"""', "'''"]:
|
|
21
|
+
if body.startswith(q) and body.endswith(q):
|
|
22
|
+
body = body[len(q) : -len(q)].strip()
|
|
23
|
+
|
|
24
|
+
# Remove any leading/trailing junk
|
|
25
|
+
body = body.strip()
|
|
26
|
+
|
|
27
|
+
# Build clean docstring (we add the quotes here only once)
|
|
28
|
+
docstring = f"\n{ body}\n "
|
|
29
|
+
|
|
30
|
+
doc_node = ast.Expr(value=ast.Constant(value=docstring))
|
|
31
|
+
|
|
32
|
+
# Replace existing docstring or insert at the beginning
|
|
33
|
+
if node.body and isinstance(node.body[0], ast.Expr):
|
|
34
|
+
if isinstance(node.body[0].value, ast.Constant) and isinstance(
|
|
35
|
+
node.body[0].value.value, str
|
|
36
|
+
):
|
|
37
|
+
# Replace old docstring
|
|
38
|
+
node.body[0] = doc_node
|
|
39
|
+
else:
|
|
40
|
+
# Insert new one
|
|
41
|
+
node.body.insert(0, doc_node)
|
|
42
|
+
else:
|
|
43
|
+
node.body.insert(0, doc_node)
|
|
44
|
+
|
|
45
|
+
except Exception as e:
|
|
46
|
+
print(f"Docstring generation failed for {node.name}: {e}")
|
|
47
|
+
|
|
48
|
+
return node
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def inject_docstrings(source_code: str, style: str = "Google") -> str:
|
|
52
|
+
"""
|
|
53
|
+
Parse source code and inject docstrings into all functions/methods.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
tree = ast.parse(source_code)
|
|
57
|
+
except SyntaxError as e:
|
|
58
|
+
raise SyntaxError(f"Invalid Python code: {str(e)}")
|
|
59
|
+
|
|
60
|
+
transformer = DocstringInjector(style=style)
|
|
61
|
+
new_tree = transformer.visit(tree)
|
|
62
|
+
|
|
63
|
+
ast.fix_missing_locations(new_tree)
|
|
64
|
+
return ast.unparse(new_tree)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import re
|
|
3
|
+
import textwrap
|
|
4
|
+
|
|
5
|
+
OLLAMA_URL = "http://localhost:11434/api/generate"
|
|
6
|
+
MODEL = "qwen2.5-coder:3b"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_prompt(code: str, style: str) -> str:
|
|
10
|
+
"""
|
|
11
|
+
Build a strong, clear prompt optimized for qwen2.5-coder:3b
|
|
12
|
+
"""
|
|
13
|
+
style = style.lower()
|
|
14
|
+
if style in ["rst", "restructuredtext"]:
|
|
15
|
+
style = "reST"
|
|
16
|
+
|
|
17
|
+
return f"""You are an expert Python docstring generator.
|
|
18
|
+
|
|
19
|
+
Generate EXACTLY ONE PEP 257 compliant docstring for the given function/method.
|
|
20
|
+
|
|
21
|
+
MANDATORY RULES - YOU MUST FOLLOW ALL:
|
|
22
|
+
- Output ONLY the docstring content (text that goes between triple quotes)
|
|
23
|
+
- Start with one short imperative summary sentence. End with a period.
|
|
24
|
+
- Then exactly one blank line.
|
|
25
|
+
- Then only needed sections: Args/Parameters, Returns, Raises, Yields
|
|
26
|
+
- Follow the selected style strictly: {style}
|
|
27
|
+
- Use exactly 4 spaces for indentation under sections
|
|
28
|
+
- Do NOT include ```python
|
|
29
|
+
- Do NOT repeat the function definition
|
|
30
|
+
- Do NOT add extra triple quotes around your output
|
|
31
|
+
- Do NOT add examples, notes, explanations, or any extra text
|
|
32
|
+
- Do NOT include the words "python" or "code" unless in type hints
|
|
33
|
+
- Return ONLY the pure docstring text — nothing before or after
|
|
34
|
+
|
|
35
|
+
Style reference:
|
|
36
|
+
- google: Args:, Returns:, Raises:
|
|
37
|
+
- numpy: Parameters\n----------, Returns\n-------, Raises\n------
|
|
38
|
+
- reST: :param name:, :returns:, :raises:
|
|
39
|
+
|
|
40
|
+
STRICT OUTPUT RULES - YOU MUST OBEY:
|
|
41
|
+
- Output ONLY the docstring content (the text INSIDE the triple quotes)
|
|
42
|
+
- Start directly with the summary sentence
|
|
43
|
+
- Then one blank line
|
|
44
|
+
- Then only needed sections
|
|
45
|
+
- Do NOT output triple quotes at all
|
|
46
|
+
- Do NOT repeat the function definition
|
|
47
|
+
- Do NOT include ```python or any fences
|
|
48
|
+
- Do NOT add extra text, examples, explanations or markup
|
|
49
|
+
- Return pure text only
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
Code:
|
|
54
|
+
{code}
|
|
55
|
+
|
|
56
|
+
Style: {style}
|
|
57
|
+
|
|
58
|
+
Respond with ONLY the docstring content. No other text.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def clean_docstring(raw: str) -> str:
|
|
63
|
+
"""
|
|
64
|
+
Clean LLM output and ensure it becomes a valid docstring.
|
|
65
|
+
"""
|
|
66
|
+
raw = raw.strip()
|
|
67
|
+
|
|
68
|
+
# Remove common LLM mistakes
|
|
69
|
+
raw = re.sub(r"^```(?:python|py)?\s*", "", raw, flags=re.IGNORECASE)
|
|
70
|
+
raw = re.sub(r"\s*```$", "", raw)
|
|
71
|
+
raw = re.sub(r'^("""|\'\'\')+', "", raw)
|
|
72
|
+
raw = re.sub(r'("""|\'\'\')+$', "", raw)
|
|
73
|
+
|
|
74
|
+
# Split into lines and clean
|
|
75
|
+
lines = [line.rstrip() for line in raw.splitlines()]
|
|
76
|
+
|
|
77
|
+
# Remove leading/trailing empty lines
|
|
78
|
+
while lines and not lines[0].strip():
|
|
79
|
+
lines.pop(0)
|
|
80
|
+
while lines and not lines[-1].strip():
|
|
81
|
+
lines.pop()
|
|
82
|
+
|
|
83
|
+
if not lines:
|
|
84
|
+
return '"""\nNo description available.\n"""'
|
|
85
|
+
|
|
86
|
+
# Force PEP 257 structure: summary + blank line
|
|
87
|
+
summary = lines[0].strip()
|
|
88
|
+
body = lines[1:]
|
|
89
|
+
|
|
90
|
+
result = [summary, ""] # summary + blank line
|
|
91
|
+
|
|
92
|
+
for line in body:
|
|
93
|
+
stripped = line.rstrip()
|
|
94
|
+
if stripped:
|
|
95
|
+
# Keep section headers clean, indent descriptions
|
|
96
|
+
if re.match(r"^(Args|Parameters|Returns|Raises|Yields):", stripped.strip()):
|
|
97
|
+
result.append(stripped)
|
|
98
|
+
else:
|
|
99
|
+
result.append(" " + stripped.lstrip())
|
|
100
|
+
else:
|
|
101
|
+
result.append(" ")
|
|
102
|
+
|
|
103
|
+
# Add triple quotes
|
|
104
|
+
result = ['"""'] + result + ['"""']
|
|
105
|
+
|
|
106
|
+
return "\n".join(result)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def generate_docstring_llm(code: str, style: str = "Google") -> str:
|
|
110
|
+
"""
|
|
111
|
+
Generate a PEP 257 compliant docstring using Ollama + qwen2.5-coder:3b
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
code: Function/method code as string
|
|
115
|
+
style: "Google", "NumPy", "reST"
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Properly formatted docstring string (with triple quotes)
|
|
119
|
+
"""
|
|
120
|
+
prompt = build_prompt(code, style)
|
|
121
|
+
|
|
122
|
+
payload = {
|
|
123
|
+
"model": MODEL,
|
|
124
|
+
"prompt": prompt,
|
|
125
|
+
"stream": False,
|
|
126
|
+
"options": {
|
|
127
|
+
"temperature": 0.0,
|
|
128
|
+
"top_p": 0.9,
|
|
129
|
+
"repeat_penalty": 1.1,
|
|
130
|
+
"num_predict": 512, # enough for most docstrings
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
response = requests.post(OLLAMA_URL, json=payload, timeout=120)
|
|
136
|
+
response.raise_for_status()
|
|
137
|
+
|
|
138
|
+
raw = response.json().get("response", "").strip()
|
|
139
|
+
|
|
140
|
+
if not raw:
|
|
141
|
+
return '"""\nGenerated docstring.\n"""'
|
|
142
|
+
|
|
143
|
+
cleaned = clean_docstring(raw)
|
|
144
|
+
|
|
145
|
+
# Final safety: ensure triple quotes
|
|
146
|
+
if not cleaned.startswith('"""'):
|
|
147
|
+
cleaned = '"""\n' + cleaned.lstrip()
|
|
148
|
+
if not cleaned.endswith('"""'):
|
|
149
|
+
cleaned = cleaned.rstrip() + '\n"""'
|
|
150
|
+
|
|
151
|
+
return cleaned
|
|
152
|
+
|
|
153
|
+
except requests.exceptions.RequestException as e:
|
|
154
|
+
raise RuntimeError(f"Ollama connection failed: {e}") from e
|
|
155
|
+
except Exception as e:
|
|
156
|
+
raise RuntimeError(f"Docstring generation failed: {e}") from e
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ────────────────────────────────────────────────
|
|
160
|
+
# Optional test
|
|
161
|
+
# ────────────────────────────────────────────────
|
|
162
|
+
if __name__ == "__main__":
|
|
163
|
+
sample = """
|
|
164
|
+
def add(a: int, b: int = 0) -> int:
|
|
165
|
+
return a + b
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
doc = generate_docstring_llm(sample, style="Google")
|
|
170
|
+
print("Generated docstring:\n")
|
|
171
|
+
print(doc)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
print(f"Error: {e}")
|
autodocstring/parser.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CodeParser(ast.NodeVisitor):
|
|
5
|
+
def __init__(self):
|
|
6
|
+
self.functions = []
|
|
7
|
+
self.classes = []
|
|
8
|
+
self.current_class = None
|
|
9
|
+
|
|
10
|
+
# ---------- TOP-LEVEL FUNCTIONS ----------
|
|
11
|
+
def visit_FunctionDef(self, node):
|
|
12
|
+
if self.current_class is None:
|
|
13
|
+
self.functions.append(self.extract_function(node))
|
|
14
|
+
self.generic_visit(node)
|
|
15
|
+
|
|
16
|
+
# ---------- CLASSES ----------
|
|
17
|
+
def visit_ClassDef(self, node):
|
|
18
|
+
class_docstring = ast.get_docstring(node)
|
|
19
|
+
|
|
20
|
+
class_info = {
|
|
21
|
+
"class_name": node.name,
|
|
22
|
+
"has_docstring": class_docstring is not None,
|
|
23
|
+
"docstring": class_docstring,
|
|
24
|
+
"attributes": [],
|
|
25
|
+
"methods": [],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
self.current_class = node.name
|
|
29
|
+
|
|
30
|
+
for item in node.body:
|
|
31
|
+
if isinstance(item, ast.Assign):
|
|
32
|
+
for target in item.targets:
|
|
33
|
+
if isinstance(target, ast.Name):
|
|
34
|
+
class_info["attributes"].append(target.id)
|
|
35
|
+
|
|
36
|
+
if isinstance(item, ast.FunctionDef):
|
|
37
|
+
class_info["methods"].append(self.extract_function(item, node.name))
|
|
38
|
+
|
|
39
|
+
self.classes.append(class_info)
|
|
40
|
+
self.current_class = None
|
|
41
|
+
|
|
42
|
+
# ---------- FUNCTION EXTRACTION ----------
|
|
43
|
+
def extract_function(self, node, class_name=None):
|
|
44
|
+
docstring = ast.get_docstring(node)
|
|
45
|
+
|
|
46
|
+
params = [
|
|
47
|
+
{
|
|
48
|
+
"name": arg.arg,
|
|
49
|
+
"type": ast.unparse(arg.annotation) if arg.annotation else None,
|
|
50
|
+
}
|
|
51
|
+
for arg in node.args.args
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
param_names = [p["name"] for p in params]
|
|
55
|
+
signature = f"{node.name}({', '.join(param_names)})"
|
|
56
|
+
|
|
57
|
+
returns = ast.unparse(node.returns) if node.returns else None
|
|
58
|
+
|
|
59
|
+
raises = []
|
|
60
|
+
is_generator = False
|
|
61
|
+
|
|
62
|
+
for child in ast.walk(node):
|
|
63
|
+
if isinstance(child, ast.Raise):
|
|
64
|
+
raises.append(ast.unparse(child.exc) if child.exc else "Exception")
|
|
65
|
+
if isinstance(child, (ast.Yield, ast.YieldFrom)):
|
|
66
|
+
is_generator = True
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
"name": node.name,
|
|
70
|
+
"class": class_name,
|
|
71
|
+
"signature": signature, # ⭐ ADDED
|
|
72
|
+
"params": params,
|
|
73
|
+
"returns": returns,
|
|
74
|
+
"has_docstring": docstring is not None,
|
|
75
|
+
"lineno": node.lineno,
|
|
76
|
+
"docstring": docstring,
|
|
77
|
+
"raises": list(set(raises)),
|
|
78
|
+
"is_generator": is_generator,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_file(file_path):
|
|
83
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
84
|
+
tree = ast.parse(f.read())
|
|
85
|
+
|
|
86
|
+
parser = CodeParser()
|
|
87
|
+
parser.visit(tree)
|
|
88
|
+
|
|
89
|
+
return {"functions": parser.functions, "classes": parser.classes}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
CATEGORY_MAP = {
|
|
5
|
+
"D10": "Missing Docstrings",
|
|
6
|
+
"D20": "Whitespace Issues",
|
|
7
|
+
"D30": "Quotes Issues",
|
|
8
|
+
"D40": "Docstring Content Issues",
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def categorize(code):
|
|
13
|
+
for k in CATEGORY_MAP:
|
|
14
|
+
if code.startswith(k):
|
|
15
|
+
return CATEGORY_MAP[k]
|
|
16
|
+
return "Other"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_full_pep257(file_path):
|
|
20
|
+
result = subprocess.run(["pydocstyle", file_path], capture_output=True, text=True)
|
|
21
|
+
|
|
22
|
+
issues = []
|
|
23
|
+
|
|
24
|
+
for line in result.stdout.splitlines():
|
|
25
|
+
|
|
26
|
+
match = re.match(r"(.*?):(\d+)\s+(D\d+)\s+(.*)", line)
|
|
27
|
+
|
|
28
|
+
if match:
|
|
29
|
+
code = match.group(3)
|
|
30
|
+
|
|
31
|
+
issues.append(
|
|
32
|
+
{
|
|
33
|
+
"File": match.group(1),
|
|
34
|
+
"Line": int(match.group(2)),
|
|
35
|
+
"Code": code,
|
|
36
|
+
"Category": categorize(code),
|
|
37
|
+
"Message": match.group(4),
|
|
38
|
+
"Fix": suggest_fix(code),
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return issues
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Only SAFE automatic fixes
|
|
46
|
+
def suggest_fix(code):
|
|
47
|
+
|
|
48
|
+
fixes = {
|
|
49
|
+
"D100": "Add module docstring at top of file",
|
|
50
|
+
"D101": "Add class docstring",
|
|
51
|
+
"D102": "Add method docstring",
|
|
52
|
+
"D103": "Add function docstring",
|
|
53
|
+
"D205": "Add blank line after summary",
|
|
54
|
+
"D400": "End summary with period",
|
|
55
|
+
"D401": "Use imperative mood",
|
|
56
|
+
"D403": "Capitalize first word",
|
|
57
|
+
"D404": "Avoid starting with 'This'",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return fixes.get(code, "Manual fix required")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def run_pydocstyle(file_path):
|
|
5
|
+
"""
|
|
6
|
+
Runs pydocstyle and returns RAW violations.
|
|
7
|
+
This version is FAIL-SAFE.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
result = subprocess.run(
|
|
12
|
+
["pydocstyle", file_path],
|
|
13
|
+
stdout=subprocess.PIPE,
|
|
14
|
+
stderr=subprocess.PIPE,
|
|
15
|
+
text=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
output = result.stdout + result.stderr
|
|
19
|
+
|
|
20
|
+
if not output.strip():
|
|
21
|
+
return []
|
|
22
|
+
|
|
23
|
+
issues = []
|
|
24
|
+
|
|
25
|
+
for line in output.splitlines():
|
|
26
|
+
issues.append({"Violation": line})
|
|
27
|
+
|
|
28
|
+
return issues
|
|
29
|
+
|
|
30
|
+
except Exception as e:
|
|
31
|
+
return [{"Violation": str(e)}]
|
autodocstring/quality.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import tempfile
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def pep257_report(file_path):
|
|
7
|
+
"""
|
|
8
|
+
Runs pydocstyle on a Python file and returns:
|
|
9
|
+
- Total errors
|
|
10
|
+
- Error messages
|
|
11
|
+
- Compliance %
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
result = subprocess.run(
|
|
16
|
+
["pydocstyle", file_path], capture_output=True, text=True
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
errors = result.stdout.strip().split("\n") if result.stdout else []
|
|
20
|
+
errors = [e for e in errors if e.strip()]
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
"total_errors": len(errors),
|
|
24
|
+
"messages": errors,
|
|
25
|
+
"compliance": 0 if errors else 100,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
except Exception as e:
|
|
29
|
+
return {"total_errors": 0, "messages": [str(e)], "compliance": 0}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: autodocstring-tool
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Automated Python docstring generator and enforcer
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: streamlit
|
|
8
|
+
Requires-Dist: pandas
|
|
9
|
+
Requires-Dist: pydocstyle
|
|
10
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
autodocstring/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
autodocstring/__main__.py,sha256=xzXQLgKVJ0b_xAWbzwNaeK6kGZaN0r0EIODKY1hAC0M,77
|
|
3
|
+
autodocstring/cli.py,sha256=g1M0_FpILS_vZvnOrnTpfSmeRh0XvQJjvRkFlB5vEds,2676
|
|
4
|
+
autodocstring/compliance.py,sha256=GKevPx2ciwtoi6aBUXUMh-1cKdeDGObX8FdPVoO2csc,2081
|
|
5
|
+
autodocstring/config.py,sha256=XhCGQs3sJo1q-xk9A7O0RRu0GLmhKrwqHNFNYzDTvh8,409
|
|
6
|
+
autodocstring/coverage.py,sha256=BEls7x1HPacmekNWkM5GbssLWPul1tYfC4VQEd6L1dk,2795
|
|
7
|
+
autodocstring/generator.py,sha256=uh1izp0JNuM4Yw_CpP2BJKelt1Fbh_TufyTtAmN-3Gs,2305
|
|
8
|
+
autodocstring/injector.py,sha256=XpTC2pMhqUytjeejVKYdw_c3gcXTGyKP_qJ40ov5IXc,2175
|
|
9
|
+
autodocstring/llm_generator.py,sha256=tUZ7ofYrIp223aCm0RfAtWYEjxMcO3G0kRD5B0QvJeg,5392
|
|
10
|
+
autodocstring/parser.py,sha256=uxECK0SnmZL0dSUA3mYIwpvz57ksi0kAwR_wsprjRWI,2819
|
|
11
|
+
autodocstring/pep257_fixer.py,sha256=x9rbONRllE28cnbg6xOo0wC5bx0l_EDECheu50ebnZQ,1561
|
|
12
|
+
autodocstring/pydoc_report.py,sha256=0OPX4AZP7d65NEQoJGv5BFOsvNDSUKm3GPYWiD_ouIU,666
|
|
13
|
+
autodocstring/quality.py,sha256=DRcXOsI4_CiH53-kYyllZuqG4VSo7p7Poqs_oi5CDDM,731
|
|
14
|
+
autodocstring_tool-0.1.0.dist-info/licenses/LICENSE,sha256=wNekdRt3aqWWk7_mI-LKOqoh8i_kSEcYsk_7hp_nbZs,1094
|
|
15
|
+
autodocstring_tool-0.1.0.dist-info/METADATA,sha256=V-RmBF_5kENCmRHZNAdwCXSq46EfS0X8nV704GJElEY,271
|
|
16
|
+
autodocstring_tool-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
17
|
+
autodocstring_tool-0.1.0.dist-info/entry_points.txt,sha256=84sJzO0Ap7fEMbOJxfjZjYJfNZ2HsSBnVzJ8F_8WaWY,57
|
|
18
|
+
autodocstring_tool-0.1.0.dist-info/top_level.txt,sha256=so2wqryBeazxmDEbIO1vC1ma_wnCkiB0jaNjpL0RAmc,14
|
|
19
|
+
autodocstring_tool-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 keerthireddy2006
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
autodocstring
|