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.
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
@@ -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
+ }
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ code-metadata = code_metadata.cli:app