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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
cmap_core/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .main import app
2
+
3
+ app()
@@ -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
@@ -0,0 +1,5 @@
1
+ import ast
2
+ from pathlib import Path
3
+
4
+ def parse(path: Path) -> ast.Module:
5
+ return ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
@@ -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
@@ -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)
@@ -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
@@ -0,0 +1,6 @@
1
+ from pathlib import Path
2
+
3
+ def python_files(root: Path) -> list[Path]:
4
+ if root.is_file():
5
+ return [root] if root.suffix == ".py" else []
6
+ return sorted(path for path in root.rglob("*.py") if ".venv" not in path.parts and "__pycache__" not in path.parts)
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,7 @@
1
+ Metadata-Version: 2.5
2
+ Name: cmap-core
3
+ Version: 0.1.0
4
+ Summary: Code Map: inspect Python code structure and relationships from the terminal.
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: rich<15,>=13.9
7
+ Requires-Dist: typer<1,>=0.16
@@ -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,,
@@ -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
+ cmap-core = cmap_core.main:app