cmap-core 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.
- cmap_core/__init__.py +1 -0
- cmap_core/__main__.py +3 -0
- cmap_core/cmap_analyzer.py +68 -0
- cmap_core/cmap_ast.py +5 -0
- cmap_core/cmap_graph.py +67 -0
- cmap_core/cmap_models.py +29 -0
- cmap_core/cmap_render.py +14 -0
- cmap_core/cmap_scan.py +6 -0
- cmap_core/main.py +66 -0
- cmap_core-0.1.0.dist-info/METADATA +7 -0
- cmap_core-0.1.0.dist-info/RECORD +13 -0
- cmap_core-0.1.0.dist-info/WHEEL +4 -0
- cmap_core-0.1.0.dist-info/entry_points.txt +2 -0
cmap_core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
cmap_core/__main__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from .cmap_models import Call, CodeMap, Import, Symbol
|
|
4
|
+
|
|
5
|
+
class Analyzer(ast.NodeVisitor):
|
|
6
|
+
def __init__(self, path: Path) -> None:
|
|
7
|
+
self.path = path
|
|
8
|
+
self.result = CodeMap()
|
|
9
|
+
self.scope: list[str] = []
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def current(self) -> str:
|
|
13
|
+
return ".".join(self.scope) or "<module>"
|
|
14
|
+
|
|
15
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
16
|
+
self._visit_function(node)
|
|
17
|
+
|
|
18
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
19
|
+
self._visit_function(node)
|
|
20
|
+
|
|
21
|
+
def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
22
|
+
parent = self.current if self.scope else None
|
|
23
|
+
symbol_id = f"{self.path}:{'.'.join((*self.scope, node.name))}"
|
|
24
|
+
self.result.symbols.append(Symbol(symbol_id, node.name, "function", str(self.path), node.lineno, parent))
|
|
25
|
+
self.scope.append(node.name)
|
|
26
|
+
self.generic_visit(node)
|
|
27
|
+
self.scope.pop()
|
|
28
|
+
|
|
29
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
30
|
+
parent = self.current if self.scope else None
|
|
31
|
+
symbol_id = f"{self.path}:{'.'.join((*self.scope, node.name))}"
|
|
32
|
+
self.result.symbols.append(Symbol(symbol_id, node.name, "class", str(self.path), node.lineno, parent))
|
|
33
|
+
self.scope.append(node.name)
|
|
34
|
+
self.generic_visit(node)
|
|
35
|
+
self.scope.pop()
|
|
36
|
+
|
|
37
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
38
|
+
if isinstance(node.func, ast.Name):
|
|
39
|
+
callee = node.func.id
|
|
40
|
+
elif isinstance(node.func, ast.Attribute):
|
|
41
|
+
callee = ast.unparse(node.func)
|
|
42
|
+
else:
|
|
43
|
+
callee = ast.unparse(node.func)
|
|
44
|
+
self.result.calls.append(Call(self.current, callee, str(self.path), node.lineno))
|
|
45
|
+
self.generic_visit(node)
|
|
46
|
+
|
|
47
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
48
|
+
for alias in node.names:
|
|
49
|
+
self.result.imports.append(Import(alias.name, str(self.path), node.lineno))
|
|
50
|
+
|
|
51
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
52
|
+
module = "." * node.level + (node.module or "")
|
|
53
|
+
self.result.imports.append(Import(module, str(self.path), node.lineno))
|
|
54
|
+
|
|
55
|
+
def analyze(path: Path) -> CodeMap:
|
|
56
|
+
from .cmap_ast import parse
|
|
57
|
+
analyzer = Analyzer(path)
|
|
58
|
+
analyzer.visit(parse(path))
|
|
59
|
+
return analyzer.result
|
|
60
|
+
|
|
61
|
+
def analyze_paths(paths: list[Path]) -> CodeMap:
|
|
62
|
+
result = CodeMap()
|
|
63
|
+
for path in paths:
|
|
64
|
+
current = analyze(path)
|
|
65
|
+
result.symbols.extend(current.symbols)
|
|
66
|
+
result.calls.extend(current.calls)
|
|
67
|
+
result.imports.extend(current.imports)
|
|
68
|
+
return result
|
cmap_core/cmap_ast.py
ADDED
cmap_core/cmap_graph.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from .cmap_models import CodeMap
|
|
4
|
+
|
|
5
|
+
def callers(graph: CodeMap, target: str) -> list[str]:
|
|
6
|
+
return sorted({call.caller for call in graph.calls if call.callee == target})
|
|
7
|
+
|
|
8
|
+
def callees(graph: CodeMap, target: str) -> list[str]:
|
|
9
|
+
return sorted({call.callee for call in graph.calls if call.caller == target})
|
|
10
|
+
|
|
11
|
+
def dependencies(graph: CodeMap) -> dict[str, set[str]]:
|
|
12
|
+
result: dict[str, set[str]] = defaultdict(set)
|
|
13
|
+
for item in graph.imports:
|
|
14
|
+
result[item.file].add(item.module)
|
|
15
|
+
return dict(result)
|
|
16
|
+
|
|
17
|
+
def _module_name(path: str, root: Path) -> str:
|
|
18
|
+
relative = Path(path).relative_to(root)
|
|
19
|
+
parts = list(relative.with_suffix("").parts)
|
|
20
|
+
if parts[-1] == "__init__":
|
|
21
|
+
parts.pop()
|
|
22
|
+
return ".".join(parts)
|
|
23
|
+
|
|
24
|
+
def circular_dependencies(graph: CodeMap, root: Path) -> list[list[str]]:
|
|
25
|
+
files = {Path(symbol.file) for symbol in graph.symbols}
|
|
26
|
+
modules = {_module_name(str(path), root): path for path in files}
|
|
27
|
+
edges: dict[Path, set[Path]] = defaultdict(set)
|
|
28
|
+
for item in graph.imports:
|
|
29
|
+
source = Path(item.file)
|
|
30
|
+
for module, target in modules.items():
|
|
31
|
+
if item.module == module or item.module.startswith(f"{module}."):
|
|
32
|
+
edges[source].add(target)
|
|
33
|
+
visited: set[Path] = set()
|
|
34
|
+
stack: list[Path] = []
|
|
35
|
+
found: list[list[str]] = []
|
|
36
|
+
|
|
37
|
+
def visit(node: Path) -> None:
|
|
38
|
+
if node in stack:
|
|
39
|
+
cycle = stack[stack.index(node):] + [node]
|
|
40
|
+
names = [str(item) for item in cycle]
|
|
41
|
+
if names not in found:
|
|
42
|
+
found.append(names)
|
|
43
|
+
return
|
|
44
|
+
if node in visited:
|
|
45
|
+
return
|
|
46
|
+
visited.add(node)
|
|
47
|
+
stack.append(node)
|
|
48
|
+
for target in edges.get(node, ()):
|
|
49
|
+
visit(target)
|
|
50
|
+
stack.pop()
|
|
51
|
+
|
|
52
|
+
for node in files:
|
|
53
|
+
visit(node)
|
|
54
|
+
return found
|
|
55
|
+
|
|
56
|
+
def trace(graph: CodeMap, target: str) -> list[tuple[str, str]]:
|
|
57
|
+
result: list[tuple[str, str]] = []
|
|
58
|
+
queue = [target]
|
|
59
|
+
seen = {target}
|
|
60
|
+
while queue:
|
|
61
|
+
current = queue.pop(0)
|
|
62
|
+
for callee in callees(graph, current):
|
|
63
|
+
result.append((current, callee))
|
|
64
|
+
if callee not in seen:
|
|
65
|
+
seen.add(callee)
|
|
66
|
+
queue.append(callee)
|
|
67
|
+
return result
|
cmap_core/cmap_models.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
@dataclass(frozen=True, slots=True)
|
|
4
|
+
class Symbol:
|
|
5
|
+
id: str
|
|
6
|
+
name: str
|
|
7
|
+
kind: str
|
|
8
|
+
file: str
|
|
9
|
+
line: int
|
|
10
|
+
parent: str | None = None
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class Call:
|
|
14
|
+
caller: str
|
|
15
|
+
callee: str
|
|
16
|
+
file: str
|
|
17
|
+
line: int
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class Import:
|
|
21
|
+
module: str
|
|
22
|
+
file: str
|
|
23
|
+
line: int
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class CodeMap:
|
|
27
|
+
symbols: list[Symbol] = field(default_factory=list)
|
|
28
|
+
calls: list[Call] = field(default_factory=list)
|
|
29
|
+
imports: list[Import] = field(default_factory=list)
|
cmap_core/cmap_render.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from dataclasses import asdict
|
|
3
|
+
from .cmap_models import CodeMap
|
|
4
|
+
|
|
5
|
+
def text(graph: CodeMap) -> str:
|
|
6
|
+
lines = [f"{symbol.kind} {symbol.name} {symbol.file}:{symbol.line}" for symbol in graph.symbols]
|
|
7
|
+
if graph.calls:
|
|
8
|
+
lines += ["", "Calls", *[f" {call.caller} -> {call.callee}" for call in graph.calls]]
|
|
9
|
+
if graph.imports:
|
|
10
|
+
lines += ["", "Imports", *[f" {item.file} -> {item.module}" for item in graph.imports]]
|
|
11
|
+
return "\n".join(lines)
|
|
12
|
+
|
|
13
|
+
def json_output(graph: CodeMap) -> str:
|
|
14
|
+
return json.dumps({"symbols": [asdict(item) for item in graph.symbols], "calls": [asdict(item) for item in graph.calls], "imports": [asdict(item) for item in graph.imports]}, indent=2)
|
cmap_core/cmap_scan.py
ADDED
cmap_core/main.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
import json
|
|
4
|
+
import typer
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from . import __version__
|
|
7
|
+
from .cmap_analyzer import analyze_paths
|
|
8
|
+
from .cmap_graph import callers, callees, circular_dependencies, dependencies, trace
|
|
9
|
+
from .cmap_render import json_output, text
|
|
10
|
+
from .cmap_scan import python_files
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="CMap — Code Map for Python.", no_args_is_help=True)
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
@app.command()
|
|
16
|
+
def version() -> None:
|
|
17
|
+
"""Show the CMap version."""
|
|
18
|
+
typer.echo(__version__)
|
|
19
|
+
|
|
20
|
+
@app.command()
|
|
21
|
+
def scan(path: Annotated[Path, typer.Argument(exists=True, readable=True)], json_mode: Annotated[bool, typer.Option("--json")] = False) -> None:
|
|
22
|
+
"""Scan Python files and show their structure."""
|
|
23
|
+
graph = analyze_paths(python_files(path))
|
|
24
|
+
typer.echo(json_output(graph) if json_mode else text(graph))
|
|
25
|
+
|
|
26
|
+
@app.command("trace")
|
|
27
|
+
def trace_code(path: Annotated[Path, typer.Argument(exists=True, readable=True)], target: Annotated[str, typer.Argument()], json_mode: Annotated[bool, typer.Option("--json")] = False) -> None:
|
|
28
|
+
"""Show the reachable calls from a function."""
|
|
29
|
+
graph = analyze_paths(python_files(path))
|
|
30
|
+
result = trace(graph, target)
|
|
31
|
+
typer.echo(json.dumps(result) if json_mode else "\n".join(f"{a} -> {b}" for a, b in result))
|
|
32
|
+
|
|
33
|
+
@app.command("callers")
|
|
34
|
+
def callers_code(path: Annotated[Path, typer.Argument(exists=True, readable=True)], target: Annotated[str, typer.Argument()]) -> None:
|
|
35
|
+
"""Show callers of a function."""
|
|
36
|
+
graph = analyze_paths(python_files(path))
|
|
37
|
+
typer.echo("\n".join(callers(graph, target)))
|
|
38
|
+
|
|
39
|
+
@app.command("callees")
|
|
40
|
+
def callees_code(path: Annotated[Path, typer.Argument(exists=True, readable=True)], target: Annotated[str, typer.Argument()]) -> None:
|
|
41
|
+
"""Show functions called by a function."""
|
|
42
|
+
graph = analyze_paths(python_files(path))
|
|
43
|
+
typer.echo("\n".join(callees(graph, target)))
|
|
44
|
+
|
|
45
|
+
@app.command()
|
|
46
|
+
def deps(path: Annotated[Path, typer.Argument(exists=True, readable=True)]) -> None:
|
|
47
|
+
"""Show Python import dependencies."""
|
|
48
|
+
graph = analyze_paths(python_files(path))
|
|
49
|
+
for source, modules in dependencies(graph).items():
|
|
50
|
+
typer.echo(source)
|
|
51
|
+
for module in sorted(modules):
|
|
52
|
+
typer.echo(f" -> {module}")
|
|
53
|
+
|
|
54
|
+
@app.command()
|
|
55
|
+
def circular(path: Annotated[Path, typer.Argument(exists=True, readable=True)]) -> None:
|
|
56
|
+
"""Find circular local import dependencies."""
|
|
57
|
+
graph = analyze_paths(python_files(path))
|
|
58
|
+
cycles = circular_dependencies(graph)
|
|
59
|
+
if not cycles:
|
|
60
|
+
typer.echo("No circular dependencies found.")
|
|
61
|
+
return
|
|
62
|
+
for cycle in cycles:
|
|
63
|
+
typer.echo(" -> ".join(cycle))
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
app()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
cmap_core/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
cmap_core/__main__.py,sha256=6Hs2PV7EYc5Tid4g4OtcLXhqVHiNYTGzSBdoOnW2HXA,29
|
|
3
|
+
cmap_core/cmap_analyzer.py,sha256=EcguGOqGG1hANNF1rmKHFApzWREWLm8Mz_YaZ4h-tEs,2599
|
|
4
|
+
cmap_core/cmap_ast.py,sha256=SmHP-oHUQZXyBhUlTUKCAx8vYKsGbJgZWxi5hXLjMxo,149
|
|
5
|
+
cmap_core/cmap_graph.py,sha256=16Lh9xP2guCkiSnTNgKMUD6SNIVPqEalBEAc6qll0p8,2292
|
|
6
|
+
cmap_core/cmap_models.py,sha256=9X4zBqkPPZ0rBFNtI5wwA6gIfXyD9awRDsSqDigI2P8,597
|
|
7
|
+
cmap_core/cmap_render.py,sha256=B3zgZB_RJkHOjRpjjn5QuIhlgZi8p55AuVEmoxlJb8E,704
|
|
8
|
+
cmap_core/cmap_scan.py,sha256=fLLH8vUYKDQrBamdke1WhfyuoOhNASpqVc7SFIsYTOY,267
|
|
9
|
+
cmap_core/main.py,sha256=rrLE4pnaZnh7jjpBpDt7Y8yoiQNYp0NMVDWmQvDRrZI,2697
|
|
10
|
+
cmap_core-0.1.0.dist-info/METADATA,sha256=hvlZf9sjZZ_kecPsgZacdL8gLHdVX840VXG1AeZ9cWw,223
|
|
11
|
+
cmap_core-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
12
|
+
cmap_core-0.1.0.dist-info/entry_points.txt,sha256=rlT_geTsrm10pMU0JPJCqyNAB520HwXGo2F_XWc7o5s,49
|
|
13
|
+
cmap_core-0.1.0.dist-info/RECORD,,
|