code-metadata 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.
- code_metadata/__init__.py +0 -0
- code_metadata/analyzer.py +168 -0
- code_metadata/cli.py +178 -0
- code_metadata/docstring_scorer.py +116 -0
- code_metadata/exporters.py +82 -0
- code_metadata/git_utils.py +134 -0
- code_metadata/parser.py +146 -0
- code_metadata/schema.py +88 -0
- code_metadata/summarizer.py +50 -0
- code_metadata-0.1.0.dist-info/METADATA +16 -0
- code_metadata-0.1.0.dist-info/RECORD +13 -0
- code_metadata-0.1.0.dist-info/WHEEL +4 -0
- code_metadata-0.1.0.dist-info/entry_points.txt +2 -0
|
File without changes
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Complexity, LOC, dependencies, and test-coverage inference."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from radon.complexity import cc_visit
|
|
8
|
+
from radon.raw import analyze as raw_analyze
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── complexity via radon ──────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
def _radon_complexity_map(source: str) -> dict[str, int]:
|
|
14
|
+
"""Return {function_name: cyclomatic_complexity} for all functions/methods."""
|
|
15
|
+
try:
|
|
16
|
+
results = cc_visit(source)
|
|
17
|
+
except Exception:
|
|
18
|
+
return {}
|
|
19
|
+
return {r.name: r.complexity for r in results}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def file_raw_metrics(source: str) -> dict:
|
|
23
|
+
"""Return radon raw metrics dict (loc, sloc, comments, blank, multi)."""
|
|
24
|
+
try:
|
|
25
|
+
m = raw_analyze(source)
|
|
26
|
+
return {"loc": m.loc, "sloc": m.sloc, "comments": m.comments, "blank": m.blank}
|
|
27
|
+
except Exception:
|
|
28
|
+
return {"loc": 0, "sloc": 0, "comments": 0, "blank": 0}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ── dependency extraction ─────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
def _call_names(node: ast.AST) -> list[str]:
|
|
34
|
+
"""Names of all calls made inside a function/method node."""
|
|
35
|
+
names: list[str] = []
|
|
36
|
+
for child in ast.walk(node):
|
|
37
|
+
if isinstance(child, ast.Call):
|
|
38
|
+
func = child.func
|
|
39
|
+
if isinstance(func, ast.Name):
|
|
40
|
+
names.append(func.id)
|
|
41
|
+
elif isinstance(func, ast.Attribute):
|
|
42
|
+
# e.g. os.path.join → "os.path.join"
|
|
43
|
+
parts: list[str] = [func.attr]
|
|
44
|
+
obj = func.value
|
|
45
|
+
while isinstance(obj, ast.Attribute):
|
|
46
|
+
parts.append(obj.attr)
|
|
47
|
+
obj = obj.value
|
|
48
|
+
if isinstance(obj, ast.Name):
|
|
49
|
+
parts.append(obj.id)
|
|
50
|
+
names.append(".".join(reversed(parts)))
|
|
51
|
+
return list(dict.fromkeys(names)) # deduplicate, preserve order
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def extract_all_calls(func_node: ast.AST) -> list[str]:
|
|
55
|
+
"""All bare function/method names called in func_node (for call graph)."""
|
|
56
|
+
names: list[str] = []
|
|
57
|
+
for child in ast.walk(func_node):
|
|
58
|
+
if isinstance(child, ast.Call):
|
|
59
|
+
func = child.func
|
|
60
|
+
if isinstance(func, ast.Name):
|
|
61
|
+
names.append(func.id)
|
|
62
|
+
elif isinstance(func, ast.Attribute):
|
|
63
|
+
names.append(func.attr)
|
|
64
|
+
return list(dict.fromkeys(names))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _type_coverage(fn: dict) -> float:
|
|
68
|
+
"""Fraction of params + return slot that carry type annotations (0.0–1.0)."""
|
|
69
|
+
params = fn.get("params", [])
|
|
70
|
+
total = len(params) + 1 # +1 for return type slot
|
|
71
|
+
typed = sum(1 for p in params if p.get("type_hint")) + (1 if fn.get("return_type") else 0)
|
|
72
|
+
return round(typed / total, 3) if total else 0.0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def extract_dependencies(func_node: ast.AST, imported_names: list[str]) -> list[str]:
|
|
76
|
+
"""Calls made inside func_node that match something imported at module level."""
|
|
77
|
+
calls = _call_names(func_node)
|
|
78
|
+
imported_set = set(imported_names)
|
|
79
|
+
# keep call if its root matches an import name
|
|
80
|
+
deps: list[str] = []
|
|
81
|
+
for call in calls:
|
|
82
|
+
root = call.split(".")[0]
|
|
83
|
+
if root in imported_set:
|
|
84
|
+
deps.append(call)
|
|
85
|
+
return deps
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ── test-coverage inference ───────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def _test_function_names(test_file: Path) -> set[str]:
|
|
91
|
+
"""Parse a test file and return all function names defined in it."""
|
|
92
|
+
try:
|
|
93
|
+
source = test_file.read_text(encoding="utf-8", errors="replace")
|
|
94
|
+
tree = ast.parse(source)
|
|
95
|
+
except Exception:
|
|
96
|
+
return set()
|
|
97
|
+
names: set[str] = set()
|
|
98
|
+
for node in ast.walk(tree):
|
|
99
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
100
|
+
names.add(node.name)
|
|
101
|
+
return names
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_test_index(repo_path: str | Path) -> dict[str, list[str]]:
|
|
105
|
+
"""Scan repo for test files; return {func_name: [test_file, ...]}."""
|
|
106
|
+
root = Path(repo_path)
|
|
107
|
+
test_files = list(root.rglob("test_*.py")) + list(root.rglob("*_test.py"))
|
|
108
|
+
index: dict[str, list[str]] = {}
|
|
109
|
+
for tf in test_files:
|
|
110
|
+
for name in _test_function_names(tf):
|
|
111
|
+
# strip leading "test_" to get the target function name
|
|
112
|
+
target = name[5:] if name.startswith("test_") else name
|
|
113
|
+
index.setdefault(target, []).append(str(tf))
|
|
114
|
+
return index
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── main enrichment entry point ───────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def enrich_file(parsed: dict, source: str, test_index: dict[str, list[str]]) -> dict:
|
|
120
|
+
"""Attach complexity/LOC/deps/test fields to a parsed-file dict (mutates in place).
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
parsed: dict from parser.parse_file
|
|
124
|
+
source: raw source text of the file
|
|
125
|
+
test_index: from build_test_index, covers the whole repo
|
|
126
|
+
"""
|
|
127
|
+
complexity_map = _radon_complexity_map(source)
|
|
128
|
+
raw = file_raw_metrics(source)
|
|
129
|
+
parsed["sloc"] = raw["sloc"]
|
|
130
|
+
|
|
131
|
+
# re-parse for AST nodes so we can walk into function bodies
|
|
132
|
+
try:
|
|
133
|
+
tree = ast.parse(source)
|
|
134
|
+
except SyntaxError:
|
|
135
|
+
tree = None
|
|
136
|
+
|
|
137
|
+
func_nodes: dict[str, ast.AST] = {}
|
|
138
|
+
if tree:
|
|
139
|
+
for node in ast.walk(tree):
|
|
140
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
141
|
+
func_nodes[node.name] = node
|
|
142
|
+
|
|
143
|
+
imports = parsed.get("imports", [])
|
|
144
|
+
|
|
145
|
+
def _enrich_fn(fn: dict) -> dict:
|
|
146
|
+
name = fn["name"]
|
|
147
|
+
ast_node = func_nodes.get(name)
|
|
148
|
+
fn["complexity"] = {
|
|
149
|
+
"cyclomatic_complexity": complexity_map.get(name, 1),
|
|
150
|
+
"lines_of_code": fn.get("_loc", 0),
|
|
151
|
+
"docstring_lines": fn.get("_docstring_lines", 0),
|
|
152
|
+
"dependencies": extract_dependencies(ast_node, imports) if ast_node else [],
|
|
153
|
+
"has_test": name in test_index,
|
|
154
|
+
"test_files": test_index.get(name, []),
|
|
155
|
+
}
|
|
156
|
+
fn["type_annotation_coverage"] = _type_coverage(fn)
|
|
157
|
+
fn["_raw_callees"] = extract_all_calls(ast_node) if ast_node else []
|
|
158
|
+
fn["callees"] = [] # filled later by call graph pass
|
|
159
|
+
fn["callers"] = [] # filled later by call graph pass
|
|
160
|
+
fn.pop("_loc", None)
|
|
161
|
+
fn.pop("_docstring_lines", None)
|
|
162
|
+
return fn
|
|
163
|
+
|
|
164
|
+
parsed["functions"] = [_enrich_fn(f) for f in parsed.get("functions", [])]
|
|
165
|
+
for cls in parsed.get("classes", []):
|
|
166
|
+
cls["methods"] = [_enrich_fn(m) for m in cls.get("methods", [])]
|
|
167
|
+
|
|
168
|
+
return parsed
|
code_metadata/cli.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""CLI entry point and repo-level orchestration."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from code_metadata.analyzer import build_test_index, enrich_file
|
|
12
|
+
from code_metadata.docstring_scorer import enrich_docstrings
|
|
13
|
+
from code_metadata.exporters import to_csv, to_json
|
|
14
|
+
from code_metadata.git_utils import enrich_git
|
|
15
|
+
from code_metadata.parser import parse_file
|
|
16
|
+
from code_metadata.schema import (
|
|
17
|
+
ClassMetadata,
|
|
18
|
+
FileMetadata,
|
|
19
|
+
FunctionMetadata,
|
|
20
|
+
RepositoryMetadata,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(help="Extract metadata from Python repositories.")
|
|
24
|
+
console = Console(stderr=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── orchestration (importable for tests) ─────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
def _to_function_metadata(fn: dict) -> FunctionMetadata:
|
|
30
|
+
return FunctionMetadata.model_validate(fn)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _to_class_metadata(cls: dict) -> ClassMetadata:
|
|
34
|
+
methods = [_to_function_metadata(m) for m in cls.get("methods", [])]
|
|
35
|
+
return ClassMetadata.model_validate({**cls, "methods": methods})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _to_file_metadata(parsed: dict) -> FileMetadata:
|
|
39
|
+
functions = [_to_function_metadata(f) for f in parsed.get("functions", [])]
|
|
40
|
+
classes = [_to_class_metadata(c) for c in parsed.get("classes", [])]
|
|
41
|
+
return FileMetadata(
|
|
42
|
+
file_path=parsed["file_path"],
|
|
43
|
+
functions=functions,
|
|
44
|
+
classes=classes,
|
|
45
|
+
total_lines=parsed.get("total_lines", 0),
|
|
46
|
+
import_count=len(parsed.get("imports", [])),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _build_call_graph(all_parsed: list[dict]) -> None:
|
|
51
|
+
"""Compute callers/callees across all files; mutates parsed dicts in place."""
|
|
52
|
+
all_fn_names: set[str] = set()
|
|
53
|
+
all_fns: list[dict] = []
|
|
54
|
+
for parsed in all_parsed:
|
|
55
|
+
for fn in parsed.get("functions", []):
|
|
56
|
+
all_fn_names.add(fn["name"])
|
|
57
|
+
all_fns.append(fn)
|
|
58
|
+
for cls in parsed.get("classes", []):
|
|
59
|
+
for m in cls.get("methods", []):
|
|
60
|
+
all_fn_names.add(m["name"])
|
|
61
|
+
all_fns.append(m)
|
|
62
|
+
|
|
63
|
+
callers: dict[str, list[str]] = {n: [] for n in all_fn_names}
|
|
64
|
+
for fn in all_fns:
|
|
65
|
+
raw = fn.pop("_raw_callees", [])
|
|
66
|
+
callees = [c for c in raw if c in all_fn_names and c != fn["name"]]
|
|
67
|
+
fn["callees"] = list(dict.fromkeys(callees))
|
|
68
|
+
for callee in fn["callees"]:
|
|
69
|
+
callers[callee].append(fn["name"])
|
|
70
|
+
|
|
71
|
+
for fn in all_fns:
|
|
72
|
+
fn["callers"] = list(dict.fromkeys(callers.get(fn["name"], [])))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def extract_repo(
|
|
76
|
+
repo_path: str | Path,
|
|
77
|
+
include_git: bool = True,
|
|
78
|
+
summarize: bool = False,
|
|
79
|
+
) -> RepositoryMetadata:
|
|
80
|
+
"""Parse every .py file in repo_path; return RepositoryMetadata."""
|
|
81
|
+
root = Path(repo_path).resolve()
|
|
82
|
+
py_files = sorted(root.rglob("*.py"))
|
|
83
|
+
|
|
84
|
+
test_index = build_test_index(root)
|
|
85
|
+
|
|
86
|
+
# Phase 1: enrich all files (dicts), keep sources for summarizer
|
|
87
|
+
all_parsed: list[dict] = []
|
|
88
|
+
sources: list[str] = []
|
|
89
|
+
for py in py_files:
|
|
90
|
+
source = py.read_text(encoding="utf-8", errors="replace")
|
|
91
|
+
parsed = parse_file(py)
|
|
92
|
+
enrich_file(parsed, source, test_index)
|
|
93
|
+
enrich_docstrings(parsed)
|
|
94
|
+
if include_git:
|
|
95
|
+
enrich_git(parsed)
|
|
96
|
+
else:
|
|
97
|
+
for fn in parsed.get("functions", []):
|
|
98
|
+
fn["git_metadata"] = None
|
|
99
|
+
for cls in parsed.get("classes", []):
|
|
100
|
+
cls["git_metadata"] = None
|
|
101
|
+
for m in cls.get("methods", []):
|
|
102
|
+
m["git_metadata"] = None
|
|
103
|
+
all_parsed.append(parsed)
|
|
104
|
+
sources.append(source)
|
|
105
|
+
|
|
106
|
+
# Phase 2: cross-file call graph
|
|
107
|
+
_build_call_graph(all_parsed)
|
|
108
|
+
|
|
109
|
+
# Phase 3: optional LLM summaries
|
|
110
|
+
if summarize:
|
|
111
|
+
from code_metadata.summarizer import enrich_summaries
|
|
112
|
+
for parsed, source in zip(all_parsed, sources):
|
|
113
|
+
enrich_summaries(parsed, source)
|
|
114
|
+
|
|
115
|
+
# Phase 4: validate into Pydantic schema
|
|
116
|
+
file_metas: list[FileMetadata] = []
|
|
117
|
+
for parsed in all_parsed:
|
|
118
|
+
try:
|
|
119
|
+
file_metas.append(_to_file_metadata(parsed))
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
console.print(f"[yellow]Skipping {Path(parsed['file_path']).name}: {exc}[/yellow]")
|
|
122
|
+
|
|
123
|
+
total_functions = sum(
|
|
124
|
+
len(f.functions) + sum(len(c.methods) for c in f.classes)
|
|
125
|
+
for f in file_metas
|
|
126
|
+
)
|
|
127
|
+
total_classes = sum(len(f.classes) for f in file_metas)
|
|
128
|
+
|
|
129
|
+
return RepositoryMetadata(
|
|
130
|
+
repository_path=str(root),
|
|
131
|
+
analyzed_at=datetime.now(tz=timezone.utc),
|
|
132
|
+
total_functions=total_functions,
|
|
133
|
+
total_classes=total_classes,
|
|
134
|
+
total_files=len(file_metas),
|
|
135
|
+
files=file_metas,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ── CLI commands ──────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
@app.command()
|
|
142
|
+
def extract(
|
|
143
|
+
repo_path: Path = typer.Argument(..., help="Path to the Python repository."),
|
|
144
|
+
format: str = typer.Option("json", "--format", "-f", help="Output format: json or csv."),
|
|
145
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output file path."),
|
|
146
|
+
no_git: bool = typer.Option(False, "--no-git", help="Skip git blame integration."),
|
|
147
|
+
summarize: bool = typer.Option(False, "--summarize", "-s", help="Add LLM summaries (requires anthropic)."),
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Extract metadata from a Python repository."""
|
|
150
|
+
if not repo_path.exists():
|
|
151
|
+
console.print(f"[red]Path not found: {repo_path}[/red]")
|
|
152
|
+
raise typer.Exit(1)
|
|
153
|
+
|
|
154
|
+
console.print(f"Scanning [bold]{repo_path}[/bold] ...")
|
|
155
|
+
repo = extract_repo(repo_path, include_git=not no_git, summarize=summarize)
|
|
156
|
+
console.print(
|
|
157
|
+
f"Found [green]{repo.total_files}[/green] files, "
|
|
158
|
+
f"[green]{repo.total_functions}[/green] functions, "
|
|
159
|
+
f"[green]{repo.total_classes}[/green] classes."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
fmt = format.lower()
|
|
163
|
+
if fmt == "json":
|
|
164
|
+
text = to_json(repo, output)
|
|
165
|
+
elif fmt == "csv":
|
|
166
|
+
text = to_csv(repo, output)
|
|
167
|
+
else:
|
|
168
|
+
console.print(f"[red]Unknown format '{format}'. Use json or csv.[/red]")
|
|
169
|
+
raise typer.Exit(1)
|
|
170
|
+
|
|
171
|
+
if output:
|
|
172
|
+
console.print(f"Written to [bold]{output}[/bold]")
|
|
173
|
+
else:
|
|
174
|
+
typer.echo(text)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
app()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Heuristic docstring quality scoring — no LLM, pure string matching."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from typing import Literal, Optional
|
|
6
|
+
|
|
7
|
+
DocstringStyle = Literal["numpy", "google", "sphinx", "unstructured", "none"]
|
|
8
|
+
|
|
9
|
+
# ── style detection ───────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
# Sphinx: :param name: :type name: :returns: :rtype:
|
|
12
|
+
_RE_SPHINX = re.compile(r":(param|type|returns?|rtype)\b")
|
|
13
|
+
|
|
14
|
+
# NumPy: section headers underlined with dashes
|
|
15
|
+
# Parameters
|
|
16
|
+
# ----------
|
|
17
|
+
_RE_NUMPY = re.compile(r"^\s*(Parameters|Returns|Raises|Notes|Examples|Attributes)\s*\n\s*-{3,}", re.MULTILINE)
|
|
18
|
+
|
|
19
|
+
# Google: "Args:\n ", "Returns:\n "
|
|
20
|
+
_RE_GOOGLE = re.compile(r"^\s*(Args|Returns|Raises|Note|Example|Attributes)\s*:\s*\n\s+\S", re.MULTILINE)
|
|
21
|
+
|
|
22
|
+
# Section presence (style-agnostic)
|
|
23
|
+
_RE_ARGS = re.compile(
|
|
24
|
+
r"(^\s*(Args|Parameters|Params)\s*[:\n]|:param\s+\w|Parameters\s*\n\s*-{3,})",
|
|
25
|
+
re.MULTILINE | re.IGNORECASE,
|
|
26
|
+
)
|
|
27
|
+
_RE_RETURNS = re.compile(
|
|
28
|
+
r"(^\s*(Returns?|Return value)\s*[:\n]|:returns?:|:rtype:|Returns\s*\n\s*-{3,})",
|
|
29
|
+
re.MULTILINE | re.IGNORECASE,
|
|
30
|
+
)
|
|
31
|
+
_RE_EXAMPLE = re.compile(
|
|
32
|
+
r"(^\s*(Examples?)\s*[:\n]|>>>\s|\bExample\b)",
|
|
33
|
+
re.MULTILINE | re.IGNORECASE,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _detect_style(doc: str) -> DocstringStyle:
|
|
38
|
+
if _RE_SPHINX.search(doc):
|
|
39
|
+
return "sphinx"
|
|
40
|
+
if _RE_NUMPY.search(doc):
|
|
41
|
+
return "numpy"
|
|
42
|
+
if _RE_GOOGLE.search(doc):
|
|
43
|
+
return "google"
|
|
44
|
+
return "unstructured"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── scoring ───────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
def _quality_score(
|
|
50
|
+
presence: bool,
|
|
51
|
+
length: int,
|
|
52
|
+
has_args: bool,
|
|
53
|
+
has_return: bool,
|
|
54
|
+
has_example: bool,
|
|
55
|
+
) -> float:
|
|
56
|
+
if not presence:
|
|
57
|
+
return 0.0
|
|
58
|
+
score = 0.2 # base: docstring exists
|
|
59
|
+
score += 0.2 if has_args else 0.0
|
|
60
|
+
score += 0.2 if has_return else 0.0
|
|
61
|
+
score += 0.2 if has_example else 0.0
|
|
62
|
+
score += 0.1 if length >= 50 else 0.0
|
|
63
|
+
score += 0.1 if length >= 200 else 0.0
|
|
64
|
+
return round(min(score, 1.0), 4)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ── public API ────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
def score_docstring(docstring: Optional[str]) -> dict:
|
|
70
|
+
"""Return a DocstringQuality-shaped dict for one function/method/class.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
docstring: raw docstring text, or None.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Dict matching the DocstringQuality schema.
|
|
77
|
+
"""
|
|
78
|
+
if not docstring:
|
|
79
|
+
return {
|
|
80
|
+
"presence": False,
|
|
81
|
+
"length": 0,
|
|
82
|
+
"has_args_section": False,
|
|
83
|
+
"has_return_section": False,
|
|
84
|
+
"has_example": False,
|
|
85
|
+
"style": "none",
|
|
86
|
+
"quality_score": 0.0,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
has_args = bool(_RE_ARGS.search(docstring))
|
|
90
|
+
has_return = bool(_RE_RETURNS.search(docstring))
|
|
91
|
+
has_example = bool(_RE_EXAMPLE.search(docstring))
|
|
92
|
+
length = len(docstring)
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
"presence": True,
|
|
96
|
+
"length": length,
|
|
97
|
+
"has_args_section": has_args,
|
|
98
|
+
"has_return_section": has_return,
|
|
99
|
+
"has_example": has_example,
|
|
100
|
+
"style": _detect_style(docstring),
|
|
101
|
+
"quality_score": _quality_score(True, length, has_args, has_return, has_example),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def enrich_docstrings(parsed: dict) -> dict:
|
|
106
|
+
"""Attach docstring_quality to every function and class in a parsed-file dict."""
|
|
107
|
+
|
|
108
|
+
def _attach_fn(fn: dict) -> dict:
|
|
109
|
+
fn["docstring_quality"] = score_docstring(fn.get("docstring"))
|
|
110
|
+
return fn
|
|
111
|
+
|
|
112
|
+
parsed["functions"] = [_attach_fn(f) for f in parsed.get("functions", [])]
|
|
113
|
+
for cls in parsed.get("classes", []):
|
|
114
|
+
cls["docstring_quality"] = score_docstring(cls.get("docstring"))
|
|
115
|
+
cls["methods"] = [_attach_fn(m) for m in cls.get("methods", [])]
|
|
116
|
+
return parsed
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""JSON and CSV export for RepositoryMetadata."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import csv
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from code_metadata.schema import FunctionMetadata, RepositoryMetadata
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _fn_rows(repo: RepositoryMetadata) -> list[dict]:
|
|
15
|
+
"""Flatten every function/method to one CSV row each."""
|
|
16
|
+
rows = []
|
|
17
|
+
for file in repo.files:
|
|
18
|
+
def _row(fn: FunctionMetadata) -> dict:
|
|
19
|
+
git = fn.git_metadata
|
|
20
|
+
cplx = fn.complexity
|
|
21
|
+
dq = fn.docstring_quality
|
|
22
|
+
return {
|
|
23
|
+
"file_path": fn.file_path,
|
|
24
|
+
"name": fn.name,
|
|
25
|
+
"line_number": fn.line_number,
|
|
26
|
+
"end_line_number": fn.end_line_number,
|
|
27
|
+
"is_method": fn.is_method,
|
|
28
|
+
"is_async": fn.is_async,
|
|
29
|
+
"parent_class": fn.parent_class or "",
|
|
30
|
+
"decorators": "|".join(fn.decorators),
|
|
31
|
+
"return_type": fn.return_type or "",
|
|
32
|
+
"type_annotation_coverage": fn.type_annotation_coverage,
|
|
33
|
+
"cyclomatic_complexity": cplx.cyclomatic_complexity,
|
|
34
|
+
"lines_of_code": cplx.lines_of_code,
|
|
35
|
+
"docstring_lines": cplx.docstring_lines,
|
|
36
|
+
"has_test": cplx.has_test,
|
|
37
|
+
"has_docstring": dq.presence,
|
|
38
|
+
"docstring_score": dq.quality_score,
|
|
39
|
+
"docstring_style": dq.style,
|
|
40
|
+
"callees": "|".join(fn.callees),
|
|
41
|
+
"callers": "|".join(fn.callers),
|
|
42
|
+
"summary": fn.summary or "",
|
|
43
|
+
"git_author": git.author if git else "",
|
|
44
|
+
"git_last_modified": git.last_modified.isoformat() if git else "",
|
|
45
|
+
"git_commit_count": git.commit_count if git else "",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for fn in file.functions:
|
|
49
|
+
rows.append(_row(fn))
|
|
50
|
+
for cls in file.classes:
|
|
51
|
+
for method in cls.methods:
|
|
52
|
+
rows.append(_row(method))
|
|
53
|
+
return rows
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _json_default(obj):
|
|
57
|
+
if isinstance(obj, datetime):
|
|
58
|
+
return obj.isoformat()
|
|
59
|
+
raise TypeError(f"Not serializable: {type(obj)}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def to_json(repo: RepositoryMetadata, output: Optional[str | Path] = None) -> str:
|
|
63
|
+
"""Serialize repo metadata to JSON string; optionally write to file."""
|
|
64
|
+
text = repo.model_dump_json(indent=2)
|
|
65
|
+
if output:
|
|
66
|
+
Path(output).write_text(text, encoding="utf-8")
|
|
67
|
+
return text
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def to_csv(repo: RepositoryMetadata, output: Optional[str | Path] = None) -> str:
|
|
71
|
+
"""Flatten functions/methods to CSV; optionally write to file."""
|
|
72
|
+
rows = _fn_rows(repo)
|
|
73
|
+
if not rows:
|
|
74
|
+
return ""
|
|
75
|
+
buf = io.StringIO()
|
|
76
|
+
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()))
|
|
77
|
+
writer.writeheader()
|
|
78
|
+
writer.writerows(rows)
|
|
79
|
+
text = buf.getvalue()
|
|
80
|
+
if output:
|
|
81
|
+
Path(output).write_text(text, encoding="utf-8")
|
|
82
|
+
return text
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Git blame integration — cached per file, optional (null on non-git repos)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import git
|
|
11
|
+
_GIT_AVAILABLE = True
|
|
12
|
+
except ImportError:
|
|
13
|
+
_GIT_AVAILABLE = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ── repo discovery ────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
@lru_cache(maxsize=16)
|
|
19
|
+
def _find_repo(path: str) -> Optional[object]:
|
|
20
|
+
"""Return a git.Repo for the given path, or None if not a git repo."""
|
|
21
|
+
if not _GIT_AVAILABLE:
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
return git.Repo(path, search_parent_directories=True)
|
|
25
|
+
except Exception:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_repo(path: str | Path) -> Optional[object]:
|
|
30
|
+
return _find_repo(str(Path(path).resolve()))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ── per-file blame cache ──────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
@lru_cache(maxsize=256)
|
|
36
|
+
def _blame_lines(repo_path: str, rel_file: str) -> list[dict]:
|
|
37
|
+
"""Return per-line blame list (1-indexed; index 0 is a dummy placeholder).
|
|
38
|
+
|
|
39
|
+
Each entry: {author, author_email, date: datetime, sha: str}
|
|
40
|
+
One call per file — cached by (repo_path, rel_file).
|
|
41
|
+
"""
|
|
42
|
+
repo = _find_repo(repo_path)
|
|
43
|
+
if repo is None:
|
|
44
|
+
return []
|
|
45
|
+
try:
|
|
46
|
+
blame = repo.blame("HEAD", rel_file)
|
|
47
|
+
except Exception:
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
lines: list[dict] = [{}] # index 0 unused so line 1 == lines[1]
|
|
51
|
+
for commit, file_lines in blame:
|
|
52
|
+
entry = {
|
|
53
|
+
"author": commit.author.name,
|
|
54
|
+
"author_email": commit.author.email,
|
|
55
|
+
"date": datetime.fromtimestamp(commit.authored_date, tz=timezone.utc),
|
|
56
|
+
"sha": commit.hexsha,
|
|
57
|
+
}
|
|
58
|
+
for _ in file_lines:
|
|
59
|
+
lines.append(entry)
|
|
60
|
+
return lines
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _commit_count(repo: object, rel_file: str) -> int:
|
|
64
|
+
try:
|
|
65
|
+
return sum(1 for _ in repo.iter_commits(paths=rel_file))
|
|
66
|
+
except Exception:
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── public API ────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
def git_metadata_for_lines(
|
|
73
|
+
file_path: str | Path,
|
|
74
|
+
start_line: int,
|
|
75
|
+
end_line: int,
|
|
76
|
+
) -> Optional[dict]:
|
|
77
|
+
"""Return git metadata covering [start_line, end_line] (1-based), or None.
|
|
78
|
+
|
|
79
|
+
Picks the most-recent commit in the line range as "last modified".
|
|
80
|
+
"""
|
|
81
|
+
abs_path = Path(file_path).resolve()
|
|
82
|
+
repo = get_repo(abs_path)
|
|
83
|
+
if repo is None:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
repo_root = Path(repo.working_tree_dir).resolve()
|
|
87
|
+
try:
|
|
88
|
+
rel_file = str(abs_path.relative_to(repo_root))
|
|
89
|
+
except ValueError:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
blame = _blame_lines(str(repo_root), rel_file)
|
|
93
|
+
if not blame:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
# slice lines in range (clamp to available)
|
|
97
|
+
relevant = [
|
|
98
|
+
blame[i]
|
|
99
|
+
for i in range(start_line, min(end_line + 1, len(blame)))
|
|
100
|
+
if blame[i]
|
|
101
|
+
]
|
|
102
|
+
if not relevant:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
# most-recent commit in range
|
|
106
|
+
latest = max(relevant, key=lambda e: e["date"])
|
|
107
|
+
|
|
108
|
+
commit_cnt = _commit_count(repo, rel_file)
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
"last_modified": latest["date"],
|
|
112
|
+
"author": latest["author"],
|
|
113
|
+
"author_email": latest["author_email"],
|
|
114
|
+
"commit_count": commit_cnt,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def enrich_git(parsed: dict) -> dict:
|
|
119
|
+
"""Attach git_metadata to every function and class in a parsed-file dict."""
|
|
120
|
+
file_path = parsed.get("file_path", "")
|
|
121
|
+
|
|
122
|
+
def _attach(fn: dict) -> dict:
|
|
123
|
+
fn["git_metadata"] = git_metadata_for_lines(
|
|
124
|
+
file_path, fn["line_number"], fn["end_line_number"]
|
|
125
|
+
)
|
|
126
|
+
return fn
|
|
127
|
+
|
|
128
|
+
parsed["functions"] = [_attach(f) for f in parsed.get("functions", [])]
|
|
129
|
+
for cls in parsed.get("classes", []):
|
|
130
|
+
cls["methods"] = [_attach(m) for m in cls.get("methods", [])]
|
|
131
|
+
cls["git_metadata"] = git_metadata_for_lines(
|
|
132
|
+
file_path, cls["line_number"], cls["end_line_number"]
|
|
133
|
+
)
|
|
134
|
+
return parsed
|
code_metadata/parser.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""AST walker — extracts functions, classes, imports from a Python source file."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _annotation_to_str(node: Optional[ast.expr]) -> Optional[str]:
|
|
10
|
+
if node is None:
|
|
11
|
+
return None
|
|
12
|
+
try:
|
|
13
|
+
return ast.unparse(node)
|
|
14
|
+
except Exception:
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _decorator_names(decorator_list: list[ast.expr]) -> list[str]:
|
|
19
|
+
return [_annotation_to_str(d) or "" for d in decorator_list]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _extract_params(args: ast.arguments) -> list[dict]:
|
|
23
|
+
params = []
|
|
24
|
+
all_args = args.posonlyargs + args.args + args.kwonlyargs
|
|
25
|
+
if args.vararg:
|
|
26
|
+
all_args.append(args.vararg)
|
|
27
|
+
if args.kwarg:
|
|
28
|
+
all_args.append(args.kwarg)
|
|
29
|
+
for arg in all_args:
|
|
30
|
+
params.append({
|
|
31
|
+
"name": arg.arg,
|
|
32
|
+
"type_hint": _annotation_to_str(arg.annotation),
|
|
33
|
+
})
|
|
34
|
+
return params
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _count_lines(node: ast.AST) -> int:
|
|
38
|
+
"""Lines spanned by a function/class body (end_lineno - lineno + 1)."""
|
|
39
|
+
end = getattr(node, "end_lineno", None)
|
|
40
|
+
start = getattr(node, "lineno", None)
|
|
41
|
+
if end is not None and start is not None:
|
|
42
|
+
return end - start + 1
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _extract_imports(tree: ast.Module) -> list[str]:
|
|
47
|
+
"""Top-level import names from the module."""
|
|
48
|
+
names: list[str] = []
|
|
49
|
+
for node in ast.walk(tree):
|
|
50
|
+
if isinstance(node, ast.Import):
|
|
51
|
+
for alias in node.names:
|
|
52
|
+
names.append(alias.asname or alias.name)
|
|
53
|
+
elif isinstance(node, ast.ImportFrom):
|
|
54
|
+
module = node.module or ""
|
|
55
|
+
for alias in node.names:
|
|
56
|
+
names.append(alias.asname or f"{module}.{alias.name}")
|
|
57
|
+
return names
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_function(
|
|
61
|
+
node: ast.AsyncFunctionDef | ast.FunctionDef,
|
|
62
|
+
file_path: str,
|
|
63
|
+
parent_class: Optional[str] = None,
|
|
64
|
+
is_method: bool = False,
|
|
65
|
+
) -> dict:
|
|
66
|
+
docstring = ast.get_docstring(node)
|
|
67
|
+
docstring_lines = 0
|
|
68
|
+
if docstring:
|
|
69
|
+
docstring_lines = docstring.count("\n") + 1
|
|
70
|
+
return {
|
|
71
|
+
"name": node.name,
|
|
72
|
+
"file_path": file_path,
|
|
73
|
+
"line_number": node.lineno,
|
|
74
|
+
"end_line_number": getattr(node, "end_lineno", node.lineno),
|
|
75
|
+
"params": _extract_params(node.args),
|
|
76
|
+
"return_type": _annotation_to_str(node.returns),
|
|
77
|
+
"docstring": docstring,
|
|
78
|
+
"is_method": is_method,
|
|
79
|
+
"is_async": isinstance(node, ast.AsyncFunctionDef),
|
|
80
|
+
"decorators": _decorator_names(node.decorator_list),
|
|
81
|
+
"parent_class": parent_class,
|
|
82
|
+
# complexity fields filled later by analyzer
|
|
83
|
+
"_loc": _count_lines(node),
|
|
84
|
+
"_docstring_lines": docstring_lines,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_class(node: ast.ClassDef, file_path: str) -> dict:
|
|
89
|
+
methods = []
|
|
90
|
+
for item in node.body:
|
|
91
|
+
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
92
|
+
methods.append(_parse_function(item, file_path, parent_class=node.name, is_method=True))
|
|
93
|
+
return {
|
|
94
|
+
"name": node.name,
|
|
95
|
+
"file_path": file_path,
|
|
96
|
+
"line_number": node.lineno,
|
|
97
|
+
"end_line_number": getattr(node, "end_lineno", node.lineno),
|
|
98
|
+
"docstring": ast.get_docstring(node),
|
|
99
|
+
"decorators": _decorator_names(node.decorator_list),
|
|
100
|
+
"methods": methods,
|
|
101
|
+
"bases": [_annotation_to_str(b) or "" for b in node.bases],
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_file(source_path: str | Path) -> dict:
|
|
106
|
+
"""Parse one Python file; return raw dicts (schema applied later).
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
{
|
|
110
|
+
"file_path": str,
|
|
111
|
+
"functions": [...], # top-level only
|
|
112
|
+
"classes": [...],
|
|
113
|
+
"imports": [...],
|
|
114
|
+
"total_lines": int,
|
|
115
|
+
}
|
|
116
|
+
"""
|
|
117
|
+
path = Path(source_path)
|
|
118
|
+
source = path.read_text(encoding="utf-8", errors="replace")
|
|
119
|
+
try:
|
|
120
|
+
tree = ast.parse(source, filename=str(path))
|
|
121
|
+
except SyntaxError as exc:
|
|
122
|
+
return {
|
|
123
|
+
"file_path": str(path),
|
|
124
|
+
"functions": [],
|
|
125
|
+
"classes": [],
|
|
126
|
+
"imports": [],
|
|
127
|
+
"total_lines": source.count("\n") + 1,
|
|
128
|
+
"parse_error": str(exc),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
functions: list[dict] = []
|
|
132
|
+
classes: list[dict] = []
|
|
133
|
+
|
|
134
|
+
for node in ast.iter_child_nodes(tree):
|
|
135
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
136
|
+
functions.append(_parse_function(node, str(path)))
|
|
137
|
+
elif isinstance(node, ast.ClassDef):
|
|
138
|
+
classes.append(_parse_class(node, str(path)))
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
"file_path": str(path),
|
|
142
|
+
"functions": functions,
|
|
143
|
+
"classes": classes,
|
|
144
|
+
"imports": _extract_imports(tree),
|
|
145
|
+
"total_lines": source.count("\n") + 1,
|
|
146
|
+
}
|
code_metadata/schema.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Literal, Optional
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ParamMetadata(BaseModel):
|
|
10
|
+
name: str
|
|
11
|
+
type_hint: Optional[str] = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DocstringQuality(BaseModel):
|
|
15
|
+
presence: bool
|
|
16
|
+
length: int
|
|
17
|
+
has_args_section: bool
|
|
18
|
+
has_return_section: bool
|
|
19
|
+
has_example: bool
|
|
20
|
+
style: Literal["numpy", "google", "sphinx", "unstructured", "none"]
|
|
21
|
+
quality_score: float = Field(ge=0.0, le=1.0)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GitMetadata(BaseModel):
|
|
25
|
+
last_modified: datetime
|
|
26
|
+
author: str
|
|
27
|
+
author_email: str
|
|
28
|
+
commit_count: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ComplexityMetrics(BaseModel):
|
|
32
|
+
cyclomatic_complexity: int
|
|
33
|
+
lines_of_code: int
|
|
34
|
+
docstring_lines: int
|
|
35
|
+
dependencies: list[str]
|
|
36
|
+
has_test: bool
|
|
37
|
+
test_files: list[str]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FunctionMetadata(BaseModel):
|
|
41
|
+
name: str
|
|
42
|
+
file_path: str
|
|
43
|
+
line_number: int
|
|
44
|
+
end_line_number: int
|
|
45
|
+
params: list[ParamMetadata]
|
|
46
|
+
return_type: Optional[str] = None
|
|
47
|
+
docstring: Optional[str] = None
|
|
48
|
+
is_method: bool
|
|
49
|
+
is_async: bool
|
|
50
|
+
decorators: list[str]
|
|
51
|
+
parent_class: Optional[str] = None
|
|
52
|
+
complexity: ComplexityMetrics
|
|
53
|
+
docstring_quality: DocstringQuality
|
|
54
|
+
git_metadata: Optional[GitMetadata] = None
|
|
55
|
+
type_annotation_coverage: float = Field(default=0.0, ge=0.0, le=1.0)
|
|
56
|
+
callees: list[str] = []
|
|
57
|
+
callers: list[str] = []
|
|
58
|
+
summary: Optional[str] = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ClassMetadata(BaseModel):
|
|
62
|
+
name: str
|
|
63
|
+
file_path: str
|
|
64
|
+
line_number: int
|
|
65
|
+
end_line_number: int
|
|
66
|
+
docstring: Optional[str] = None
|
|
67
|
+
decorators: list[str]
|
|
68
|
+
methods: list[FunctionMetadata]
|
|
69
|
+
bases: list[str]
|
|
70
|
+
git_metadata: Optional[GitMetadata] = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class FileMetadata(BaseModel):
|
|
74
|
+
file_path: str
|
|
75
|
+
functions: list[FunctionMetadata]
|
|
76
|
+
classes: list[ClassMetadata]
|
|
77
|
+
total_lines: int
|
|
78
|
+
import_count: int
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RepositoryMetadata(BaseModel):
|
|
82
|
+
repository_path: str
|
|
83
|
+
analyzed_at: datetime
|
|
84
|
+
language: str = "python"
|
|
85
|
+
total_functions: int
|
|
86
|
+
total_classes: int
|
|
87
|
+
total_files: int
|
|
88
|
+
files: list[FileMetadata]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Optional LLM summarization via Anthropic API (claude-haiku-4-5-20251001)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _client():
|
|
8
|
+
try:
|
|
9
|
+
import anthropic
|
|
10
|
+
return anthropic.Anthropic()
|
|
11
|
+
except ImportError:
|
|
12
|
+
raise ImportError("Install anthropic: pip install 'code-metadata[llm]'")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def summarize_function(name: str, docstring: Optional[str], source_lines: str) -> str:
|
|
16
|
+
"""Return a one-sentence summary of a function via Claude Haiku."""
|
|
17
|
+
client = _client()
|
|
18
|
+
prompt = (
|
|
19
|
+
f"Summarize this Python function in one sentence (max 20 words). "
|
|
20
|
+
f"Focus on what it does, not its structure.\n\n"
|
|
21
|
+
f"Name: {name}\n"
|
|
22
|
+
f"Docstring: {docstring or 'None'}\n"
|
|
23
|
+
f"Source:\n{source_lines}\n\n"
|
|
24
|
+
f"Reply with just the summary sentence, no quotes."
|
|
25
|
+
)
|
|
26
|
+
msg = client.messages.create(
|
|
27
|
+
model="claude-haiku-4-5-20251001",
|
|
28
|
+
max_tokens=80,
|
|
29
|
+
messages=[{"role": "user", "content": prompt}],
|
|
30
|
+
)
|
|
31
|
+
return msg.content[0].text.strip()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def enrich_summaries(parsed: dict, source: str) -> dict:
|
|
35
|
+
"""Attach LLM-generated summary to each function/method in a parsed file dict."""
|
|
36
|
+
lines = source.splitlines()
|
|
37
|
+
|
|
38
|
+
def _src(fn: dict) -> str:
|
|
39
|
+
start = max(0, fn.get("line_number", 1) - 1)
|
|
40
|
+
end = fn.get("end_line_number", start + 1)
|
|
41
|
+
return "\n".join(lines[start:end])
|
|
42
|
+
|
|
43
|
+
for fn in parsed.get("functions", []):
|
|
44
|
+
fn["summary"] = summarize_function(fn["name"], fn.get("docstring"), _src(fn))
|
|
45
|
+
|
|
46
|
+
for cls in parsed.get("classes", []):
|
|
47
|
+
for m in cls.get("methods", []):
|
|
48
|
+
m["summary"] = summarize_function(m["name"], m.get("docstring"), _src(m))
|
|
49
|
+
|
|
50
|
+
return parsed
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: code-metadata
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python repository metadata extractor — AST-based, no LLM
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: gitpython>=3.1
|
|
7
|
+
Requires-Dist: pydantic>=2.0
|
|
8
|
+
Requires-Dist: radon>=6.0
|
|
9
|
+
Requires-Dist: rich>=13.0
|
|
10
|
+
Requires-Dist: typer>=0.12
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
15
|
+
Provides-Extra: llm
|
|
16
|
+
Requires-Dist: anthropic>=0.34; extra == 'llm'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
code_metadata/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
code_metadata/analyzer.py,sha256=zZIAI_VuGBNRS6DVIrA_VgVEVirBffRer2GFBCizK9A,6721
|
|
3
|
+
code_metadata/cli.py,sha256=5FI0BGadJpczuJytIuWcoXhpksA3YBt8Ot3jVrwyjng,6360
|
|
4
|
+
code_metadata/docstring_scorer.py,sha256=6-r7slmHi4osmFkuwVnDf1K6yaLHPRGWw__4HNsLVyM,4093
|
|
5
|
+
code_metadata/exporters.py,sha256=FSAkAc7au6Dc0V7muJTGRCJvHud-l6PVFeVcRlGHhXw,2940
|
|
6
|
+
code_metadata/git_utils.py,sha256=j_4iky-nMjt-e7YTfzYGyTL084GBakI-G6BBeycOD1Y,4277
|
|
7
|
+
code_metadata/parser.py,sha256=G23AzxDK4pgr7QDsJG4yvwLp1fY4kRWcBwqjqvWjWik,4638
|
|
8
|
+
code_metadata/schema.py,sha256=JzFcIHwYyJ_75981_EcAu8hyBBuofuIt_eX8X2NTmFI,2080
|
|
9
|
+
code_metadata/summarizer.py,sha256=g982lS2T7fupZ9_ajAf34jX5-rPOgiAkgeHJeYZxAhE,1721
|
|
10
|
+
code_metadata-0.1.0.dist-info/METADATA,sha256=apArmH-ybLlaQaxob9IxVyty2-w0sgGTnDJUCMv_yYQ,505
|
|
11
|
+
code_metadata-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
12
|
+
code_metadata-0.1.0.dist-info/entry_points.txt,sha256=sJw3dCa0EgaD7ElIQ3sma91YjnEObFOQedYrkhYM01U,56
|
|
13
|
+
code_metadata-0.1.0.dist-info/RECORD,,
|