synaptic-graph 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.
synaptic/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
synaptic/cli.py ADDED
@@ -0,0 +1,214 @@
1
+ """
2
+ cli.py — Synaptic CLI built with Typer + Rich.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import Optional
9
+ import sys
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+ from rich.progress import Progress, SpinnerColumn, TextColumn
15
+ from rich import print as rprint
16
+
17
+ from synaptic import __version__
18
+
19
+ app = typer.Typer(
20
+ name="synaptic",
21
+ help="[bold cyan]synaptic[/] — visualize the dependency graph of any Python project.",
22
+ rich_markup_mode="rich",
23
+ add_completion=True,
24
+ )
25
+
26
+ console = Console()
27
+
28
+
29
+ def _version_callback(value: bool) -> None:
30
+ if value:
31
+ rprint(f"[bold cyan]synaptic[/] version [bold]{__version__}[/]")
32
+ raise typer.Exit()
33
+
34
+
35
+ @app.callback()
36
+ def main(
37
+ version: Optional[bool] = typer.Option(
38
+ None, "--version", "-v",
39
+ callback=_version_callback,
40
+ is_eager=True,
41
+ help="Show version and exit.",
42
+ ),
43
+ ) -> None:
44
+ pass
45
+
46
+
47
+ @app.command()
48
+ def scan(
49
+ project: Path = typer.Argument(
50
+ ...,
51
+ help="Root path of the Python project to analyse.",
52
+ exists=True,
53
+ file_okay=False,
54
+ dir_okay=True,
55
+ resolve_path=True,
56
+ ),
57
+ output: Path = typer.Option(
58
+ Path("synaptic_graph.html"),
59
+ "--output", "-o",
60
+ help="Output file path. Extension determines format: .html (interactive) or .svg.",
61
+ ),
62
+ cloud: bool = typer.Option(True, "--cloud/--no-cloud", help="Detect AWS / GCP / Azure SDK usage."),
63
+ http: bool = typer.Option(True, "--http/--no-http", help="Detect HTTP client library usage."),
64
+ tests: bool = typer.Option(False, "--tests/--no-tests", help="Include test files in the scan."),
65
+ filter_stdlib: bool = typer.Option(True, "--filter-stdlib/--no-filter-stdlib", help="Exclude Python stdlib modules from the graph."),
66
+ filter_external: bool = typer.Option(False, "--filter-external/--no-filter-external", help="Exclude third-party (non-project) modules from the graph."),
67
+ circular: bool = typer.Option(False, "--circular", "-c", help="Highlight circular dependencies in red."),
68
+ ) -> None:
69
+ """Scan *PROJECT* and generate a dependency graph."""
70
+
71
+ console.print(
72
+ Panel.fit(
73
+ f"[bold cyan]synaptic[/] [dim]v{__version__}[/]\n"
74
+ f"[dim]Scanning:[/] [bold]{project}[/]",
75
+ border_style="cyan",
76
+ )
77
+ )
78
+
79
+ with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console) as progress:
80
+
81
+ # 1. Scan files
82
+ task = progress.add_task("Scanning .py files...", total=None)
83
+ from synaptic.scanner import scan as do_scan
84
+ files = do_scan(project, include_tests=tests)
85
+ progress.update(task, description=f"[green]Found {len(files)} Python files[/]", completed=True)
86
+
87
+ if not files:
88
+ console.print("[yellow]No Python files found. Is the path correct?[/]")
89
+ raise typer.Exit(1)
90
+
91
+ # 2. Parse imports
92
+ task = progress.add_task("Parsing imports (AST)...", total=None)
93
+ from synaptic.parser import parse_project
94
+ edges = parse_project(files, project)
95
+ progress.update(task, description=f"[green]Parsed {len(edges)} import edges[/]", completed=True)
96
+
97
+ # 3. Cloud detection
98
+ cloud_deps = []
99
+ if cloud:
100
+ task = progress.add_task("Detecting cloud SDKs...", total=None)
101
+ from synaptic.cloud_detector import detect as detect_cloud
102
+ cloud_deps = detect_cloud(edges)
103
+ progress.update(task, description=f"[green]Found {len(cloud_deps)} cloud dependencies[/]", completed=True)
104
+
105
+ # 4. HTTP detection
106
+ http_deps = []
107
+ if http:
108
+ task = progress.add_task("Detecting HTTP clients...", total=None)
109
+ from synaptic.http_detector import detect as detect_http
110
+ http_deps = detect_http(edges)
111
+ progress.update(task, description=f"[green]Found {len(http_deps)} HTTP dependencies[/]", completed=True)
112
+
113
+ # 5. Build graph
114
+ task = progress.add_task("Building graph...", total=None)
115
+ from synaptic.utils import get_stdlib_modules, resolve_internal_modules, choose_output_format
116
+ from synaptic.graph import build, render_html, render_svg
117
+
118
+ internal_modules = resolve_internal_modules(files, project)
119
+ stdlib_modules = get_stdlib_modules()
120
+
121
+ G = build(
122
+ edges=edges,
123
+ cloud_deps=cloud_deps,
124
+ http_deps=http_deps,
125
+ internal_modules=internal_modules,
126
+ stdlib_modules=stdlib_modules,
127
+ filter_stdlib=filter_stdlib,
128
+ filter_external=filter_external,
129
+ highlight_circular=circular,
130
+ )
131
+ progress.update(task, description=f"[green]Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges[/]", completed=True)
132
+
133
+ # 6. Render
134
+ fmt = choose_output_format(output)
135
+ task = progress.add_task(f"Rendering {fmt.upper()}...", total=None)
136
+ if fmt == "html":
137
+ render_html(G, output)
138
+ else:
139
+ render_svg(G, output)
140
+ progress.update(task, description=f"[green]Saved to {output}[/]", completed=True)
141
+
142
+ # Summary
143
+ console.print()
144
+ console.print(
145
+ Panel(
146
+ f"[bold green]Done![/]\n\n"
147
+ f" [dim]Files scanned:[/] [bold]{len(files)}[/]\n"
148
+ f" [dim]Import edges:[/] [bold]{len(edges)}[/]\n"
149
+ f" [dim]Cloud deps:[/] [bold]{len(cloud_deps)}[/]\n"
150
+ f" [dim]HTTP deps:[/] [bold]{len(http_deps)}[/]\n"
151
+ f" [dim]Graph nodes:[/] [bold]{G.number_of_nodes()}[/]\n"
152
+ f" [dim]Graph edges:[/] [bold]{G.number_of_edges()}[/]\n\n"
153
+ f" [dim]Output:[/] [bold cyan]{output.resolve()}[/]",
154
+ title="[bold cyan]synaptic[/]",
155
+ border_style="green",
156
+ )
157
+ )
158
+
159
+
160
+ @app.command()
161
+ def tui(
162
+ project: Path = typer.Argument(
163
+ ...,
164
+ help="Root path of the Python project to analyse.",
165
+ exists=True,
166
+ file_okay=False,
167
+ dir_okay=True,
168
+ resolve_path=True,
169
+ ),
170
+ cloud: bool = typer.Option(True, "--cloud/--no-cloud", help="Detect AWS / GCP / Azure SDK usage."),
171
+ http: bool = typer.Option(True, "--http/--no-http", help="Detect HTTP client library usage."),
172
+ tests: bool = typer.Option(False, "--tests/--no-tests", help="Include test files in the scan."),
173
+ filter_stdlib: bool = typer.Option(True, "--filter-stdlib/--no-filter-stdlib", help="Exclude stdlib modules from the graph."),
174
+ filter_external: bool = typer.Option(False, "--filter-external/--no-filter-external", help="Exclude third-party modules."),
175
+ circular: bool = typer.Option(True, "--circular/--no-circular", help="Highlight circular dependencies."),
176
+ ) -> None:
177
+ """Launch the interactive TUI graph explorer for *PROJECT*."""
178
+ from synaptic.scanner import scan as do_scan
179
+ from synaptic.parser import parse_project
180
+ from synaptic.cloud_detector import detect as detect_cloud
181
+ from synaptic.http_detector import detect as detect_http
182
+ from synaptic.graph import build
183
+ from synaptic.utils import get_stdlib_modules, resolve_internal_modules
184
+ from synaptic.tui import launch
185
+
186
+ with console.status("[cyan]Building graph…[/]"):
187
+ files = do_scan(project, include_tests=tests)
188
+ if not files:
189
+ console.print("[yellow]No Python files found.[/]")
190
+ raise typer.Exit(1)
191
+
192
+ edges = parse_project(files, project)
193
+ cloud_deps = detect_cloud(edges) if cloud else []
194
+ http_deps = detect_http(edges) if http else []
195
+
196
+ internal_modules = resolve_internal_modules(files, project)
197
+ stdlib_modules = get_stdlib_modules()
198
+
199
+ G = build(
200
+ edges=edges,
201
+ cloud_deps=cloud_deps,
202
+ http_deps=http_deps,
203
+ internal_modules=internal_modules,
204
+ stdlib_modules=stdlib_modules,
205
+ filter_stdlib=filter_stdlib,
206
+ filter_external=filter_external,
207
+ highlight_circular=circular,
208
+ )
209
+
210
+ launch(G, project)
211
+
212
+
213
+ if __name__ == "__main__":
214
+ app()
@@ -0,0 +1,47 @@
1
+ """
2
+ cloud_detector.py — Identify cloud SDK usage from import edges.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+ from synaptic.parser import ImportEdge
7
+
8
+ # Map root package prefixes → (provider, service label)
9
+ CLOUD_SIGNATURES: dict[str, tuple[str, str]] = {
10
+ "boto3": ("AWS", "boto3"),
11
+ "botocore": ("AWS", "botocore"),
12
+ "aiobotocore": ("AWS", "aiobotocore"),
13
+ "google.cloud": ("GCP", "google-cloud"),
14
+ "google.api_core": ("GCP", "google-api-core"),
15
+ "firebase_admin": ("GCP", "firebase-admin"),
16
+ "googleapiclient": ("GCP", "google-api-python-client"),
17
+ "azure": ("Azure", "azure-sdk"),
18
+ "msrest": ("Azure", "msrest"),
19
+ }
20
+
21
+
22
+ @dataclass
23
+ class CloudDependency:
24
+ source: str # module that imports the SDK
25
+ provider: str # AWS | GCP | Azure
26
+ sdk: str # human-readable SDK name
27
+ raw_import: str # original import target string
28
+
29
+
30
+ def detect(edges: list[ImportEdge]) -> list[CloudDependency]:
31
+ """Return CloudDependency entries found in the import edge list."""
32
+ found: list[CloudDependency] = []
33
+
34
+ for edge in edges:
35
+ for prefix, (provider, sdk) in CLOUD_SIGNATURES.items():
36
+ if edge.target == prefix or edge.target.startswith(prefix + "."):
37
+ found.append(
38
+ CloudDependency(
39
+ source=edge.source,
40
+ provider=provider,
41
+ sdk=sdk,
42
+ raw_import=edge.target,
43
+ )
44
+ )
45
+ break # first match wins
46
+
47
+ return found
synaptic/graph.py ADDED
@@ -0,0 +1,166 @@
1
+ """
2
+ graph.py — Build a dependency graph and render it as SVG or interactive HTML.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+
9
+ import networkx as nx
10
+
11
+ from synaptic.parser import ImportEdge
12
+ from synaptic.cloud_detector import CloudDependency
13
+ from synaptic.http_detector import HttpDependency
14
+
15
+ # Node kinds → visual properties
16
+ _COLORS = {
17
+ "internal": "#4F8EF7", # blue
18
+ "stdlib": "#A0A0A0", # grey
19
+ "external": "#F7A24F", # orange
20
+ "AWS": "#FF9900", # AWS orange
21
+ "GCP": "#4285F4", # Google blue
22
+ "Azure": "#0089D6", # Azure blue
23
+ "http": "#E84393", # pink
24
+ }
25
+
26
+
27
+ def build(
28
+ edges: list[ImportEdge],
29
+ cloud_deps: list[CloudDependency],
30
+ http_deps: list[HttpDependency],
31
+ internal_modules: set[str],
32
+ stdlib_modules: set[str],
33
+ filter_stdlib: bool = True,
34
+ filter_external: bool = False,
35
+ highlight_circular: bool = False,
36
+ ) -> nx.DiGraph:
37
+ G = nx.DiGraph()
38
+
39
+ def node_kind(name: str) -> str:
40
+ if name in internal_modules:
41
+ return "internal"
42
+ if name in stdlib_modules:
43
+ return "stdlib"
44
+ return "external"
45
+
46
+ # Internal import edges
47
+ for edge in edges:
48
+ src_kind = node_kind(edge.source)
49
+ tgt_kind = node_kind(edge.target)
50
+
51
+ if filter_stdlib and (src_kind == "stdlib" or tgt_kind == "stdlib"):
52
+ continue
53
+ if filter_external and tgt_kind == "external":
54
+ continue
55
+
56
+ for n, k in [(edge.source, src_kind), (edge.target, tgt_kind)]:
57
+ if n not in G:
58
+ G.add_node(n, kind=k, color=_COLORS.get(k, "#cccccc"), label=n.split(".")[-1])
59
+
60
+ G.add_edge(edge.source, edge.target, kind="import")
61
+
62
+ # Cloud dependency edges
63
+ for dep in cloud_deps:
64
+ node_id = f"[{dep.provider}] {dep.sdk}"
65
+ if node_id not in G:
66
+ G.add_node(node_id, kind=dep.provider, color=_COLORS.get(dep.provider, "#cccccc"), label=dep.sdk)
67
+ if dep.source not in G:
68
+ G.add_node(dep.source, kind="internal", color=_COLORS["internal"], label=dep.source.split(".")[-1])
69
+ G.add_edge(dep.source, node_id, kind="cloud")
70
+
71
+ # HTTP dependency edges
72
+ for dep in http_deps:
73
+ node_id = f"[HTTP] {dep.library}"
74
+ if node_id not in G:
75
+ G.add_node(node_id, kind="http", color=_COLORS["http"], label=dep.library)
76
+ if dep.source not in G:
77
+ G.add_node(dep.source, kind="internal", color=_COLORS["internal"], label=dep.source.split(".")[-1])
78
+ G.add_edge(dep.source, node_id, kind="http")
79
+
80
+ # Mark circular dependencies
81
+ if highlight_circular:
82
+ cycles = list(nx.simple_cycles(G))
83
+ for cycle in cycles:
84
+ for i, node in enumerate(cycle):
85
+ next_node = cycle[(i + 1) % len(cycle)]
86
+ if G.has_edge(node, next_node):
87
+ G[node][next_node]["circular"] = True
88
+
89
+ return G
90
+
91
+
92
+ def render_svg(G: nx.DiGraph, output: Path) -> Path:
93
+ """Render graph to SVG using graphviz."""
94
+ try:
95
+ import graphviz as gv
96
+ except ImportError:
97
+ raise RuntimeError("graphviz package not installed. Run: pip install graphviz")
98
+
99
+ dot = gv.Digraph(
100
+ name="synaptic",
101
+ graph_attr={"rankdir": "LR", "bgcolor": "#1a1a2e", "fontname": "Helvetica"},
102
+ node_attr={"style": "filled", "fontname": "Helvetica", "fontcolor": "white", "fontsize": "11"},
103
+ edge_attr={"color": "#666688", "arrowsize": "0.7"},
104
+ )
105
+
106
+ for node, data in G.nodes(data=True):
107
+ color = data.get("color", "#cccccc")
108
+ label = data.get("label", node)
109
+ dot.node(node, label=label, fillcolor=color)
110
+
111
+ for src, tgt, data in G.edges(data=True):
112
+ attrs: dict[str, str] = {}
113
+ if data.get("circular"):
114
+ attrs = {"color": "#FF4444", "penwidth": "2"}
115
+ elif data.get("kind") == "cloud":
116
+ attrs = {"color": "#FF9900", "style": "dashed"}
117
+ elif data.get("kind") == "http":
118
+ attrs = {"color": "#E84393", "style": "dashed"}
119
+ dot.edge(src, tgt, **attrs)
120
+
121
+ out = output.with_suffix("")
122
+ try:
123
+ dot.render(str(out), format="svg", cleanup=True)
124
+ except Exception as exc:
125
+ if "ExecutableNotFound" in type(exc).__name__ or "dot" in str(exc).lower():
126
+ raise RuntimeError(
127
+ "Graphviz binary not found. Install it with:\n"
128
+ " sudo apt install graphviz # Debian/Ubuntu/WSL\n"
129
+ " brew install graphviz # macOS"
130
+ ) from exc
131
+ raise
132
+ return output
133
+
134
+
135
+ def render_html(G: nx.DiGraph, output: Path) -> Path:
136
+ """Render interactive HTML graph using pyvis."""
137
+ try:
138
+ from pyvis.network import Network
139
+ except ImportError:
140
+ raise RuntimeError("pyvis package not installed. Run: pip install pyvis")
141
+
142
+ net = Network(
143
+ height="900px",
144
+ width="100%",
145
+ bgcolor="#1a1a2e",
146
+ font_color="white",
147
+ directed=True,
148
+ notebook=False,
149
+ )
150
+ net.barnes_hut(gravity=-8000, central_gravity=0.3, spring_length=150)
151
+
152
+ for node, data in G.nodes(data=True):
153
+ color = data.get("color", "#cccccc")
154
+ label = data.get("label", node)
155
+ title = f"<b>{node}</b><br/>kind: {data.get('kind', '?')}"
156
+ net.add_node(node, label=label, color=color, title=title, size=20)
157
+
158
+ for src, tgt, data in G.edges(data=True):
159
+ color = "#FF4444" if data.get("circular") else (
160
+ "#FF9900" if data.get("kind") == "cloud" else (
161
+ "#E84393" if data.get("kind") == "http" else "#666688"
162
+ ))
163
+ net.add_edge(src, tgt, color=color, arrows="to")
164
+
165
+ net.write_html(str(output))
166
+ return output
@@ -0,0 +1,46 @@
1
+ """
2
+ http_detector.py — Static detection of HTTP client library usage.
3
+
4
+ We do NOT monkey-patch at runtime; instead we detect *static* import of
5
+ common HTTP libraries and flag those modules as potential HTTP callers.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from synaptic.parser import ImportEdge
10
+
11
+ HTTP_SIGNATURES: dict[str, str] = {
12
+ "requests": "requests",
13
+ "httpx": "httpx",
14
+ "aiohttp": "aiohttp",
15
+ "urllib.request": "urllib",
16
+ "urllib3": "urllib3",
17
+ "httplib2": "httplib2",
18
+ "pycurl": "pycurl",
19
+ "tornado.httpclient": "tornado",
20
+ }
21
+
22
+
23
+ @dataclass
24
+ class HttpDependency:
25
+ source: str # module that imports the HTTP library
26
+ library: str # human-readable library name
27
+ raw_import: str # original import target string
28
+
29
+
30
+ def detect(edges: list[ImportEdge]) -> list[HttpDependency]:
31
+ """Return HttpDependency entries found in the import edge list."""
32
+ found: list[HttpDependency] = []
33
+
34
+ for edge in edges:
35
+ for prefix, library in HTTP_SIGNATURES.items():
36
+ if edge.target == prefix or edge.target.startswith(prefix + "."):
37
+ found.append(
38
+ HttpDependency(
39
+ source=edge.source,
40
+ library=library,
41
+ raw_import=edge.target,
42
+ )
43
+ )
44
+ break
45
+
46
+ return found
synaptic/parser.py ADDED
@@ -0,0 +1,60 @@
1
+ """
2
+ parser.py — Parse Python files with AST and extract import relationships.
3
+ """
4
+
5
+ import ast
6
+ from pathlib import Path
7
+ from dataclasses import dataclass, field
8
+
9
+
10
+ @dataclass
11
+ class ImportEdge:
12
+ source: str # dotted module name of the importing file
13
+ target: str # dotted module name being imported
14
+ names: list[str] = field(default_factory=list) # specific names imported
15
+
16
+
17
+ def file_to_module(path: Path, root: Path) -> str:
18
+ """Convert a file path to a dotted module name relative to *root*."""
19
+ rel = path.relative_to(root)
20
+ parts = list(rel.with_suffix("").parts)
21
+ if parts[-1] == "__init__":
22
+ parts = parts[:-1]
23
+ return ".".join(parts) if parts else path.stem
24
+
25
+
26
+ def parse_file(path: Path, root: Path) -> list[ImportEdge]:
27
+ """Return all ImportEdge entries found in *path*."""
28
+ try:
29
+ source = path.read_text(encoding="utf-8", errors="replace")
30
+ tree = ast.parse(source, filename=str(path))
31
+ except SyntaxError:
32
+ return []
33
+
34
+ module_name = file_to_module(path, root)
35
+ edges: list[ImportEdge] = []
36
+
37
+ for node in ast.walk(tree):
38
+ if isinstance(node, ast.Import):
39
+ for alias in node.names:
40
+ edges.append(ImportEdge(source=module_name, target=alias.name))
41
+
42
+ elif isinstance(node, ast.ImportFrom):
43
+ if node.module:
44
+ names = [a.name for a in node.names]
45
+ # Handle relative imports: level > 0 means relative to current package
46
+ target = node.module
47
+ if node.level and node.level > 0:
48
+ pkg_parts = module_name.split(".")[: -node.level]
49
+ target = ".".join(pkg_parts + [node.module]) if pkg_parts else node.module
50
+ edges.append(ImportEdge(source=module_name, target=target, names=names))
51
+
52
+ return edges
53
+
54
+
55
+ def parse_project(files: list[Path], root: Path) -> list[ImportEdge]:
56
+ """Aggregate all import edges across the entire project."""
57
+ all_edges: list[ImportEdge] = []
58
+ for f in files:
59
+ all_edges.extend(parse_file(f, root))
60
+ return all_edges
synaptic/scanner.py ADDED
@@ -0,0 +1,39 @@
1
+ """
2
+ scanner.py — Recursively walk a project directory and collect .py files.
3
+ """
4
+
5
+ from pathlib import Path
6
+
7
+ IGNORE_DIRS = {
8
+ "venv", ".venv", "env", ".env",
9
+ "__pycache__", ".git", ".tox",
10
+ "node_modules", "dist", "build", ".mypy_cache",
11
+ ".pytest_cache", "site-packages",
12
+ }
13
+
14
+
15
+ def scan(
16
+ root: Path,
17
+ include_tests: bool = False,
18
+ ) -> list[Path]:
19
+ """Return all .py files under *root*, skipping ignored directories."""
20
+ result: list[Path] = []
21
+
22
+ for path in sorted(root.rglob("*.py")):
23
+ parts = set(path.parts)
24
+ if parts & IGNORE_DIRS:
25
+ continue
26
+ if not include_tests and _is_test(path):
27
+ continue
28
+ result.append(path)
29
+
30
+ return result
31
+
32
+
33
+ def _is_test(path: Path) -> bool:
34
+ """Heuristic: skip files/dirs that look like tests."""
35
+ lowered = [p.lower() for p in path.parts]
36
+ return any(
37
+ p in {"tests", "test", "testing"} or p.startswith("test_")
38
+ for p in lowered
39
+ ) or path.stem.startswith("test_") or path.stem.endswith("_test")
synaptic/tui.py ADDED
@@ -0,0 +1,562 @@
1
+ """
2
+ tui.py — Interactive TUI for synaptic using Textual.
3
+
4
+ Design: ego-graph explorer
5
+ ┌─────────────────────────────────────────────────────────────────┐
6
+ │ ⬡ synaptic project · 237 nodes · 643 edges │
7
+ ├─────────────┬───────────────────────────────────────────────────┤
8
+ │ │ ← imported by imports → │
9
+ │ All nodes │ │
10
+ │ (sidebar) │ predecessor1 ──╮ │
11
+ │ │ predecessor2 ──┤──▶ [ SELECTED NODE ] ──┬──▶ s1│
12
+ │ ● internal │ predecessor3 ──╯ ├──▶ s2│
13
+ │ ◈ AWS ╰──▶ s3│
14
+ │ ◈ HTTP │ │
15
+ ├─────────────┴───────────────────────────────────────────────────┤
16
+ │ kind · in: N · out: N · ⚠ circular │
17
+ └─────────────────────────────────────────────────────────────────┘
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import networkx as nx
26
+ from rich.align import Align
27
+ from rich.box import ROUNDED, SIMPLE, MINIMAL
28
+ from rich.columns import Columns
29
+ from rich.console import Group
30
+ from rich.padding import Padding
31
+ from rich.panel import Panel
32
+ from rich.rule import Rule
33
+ from rich.style import Style
34
+ from rich.table import Table
35
+ from rich.text import Text
36
+ from textual import events, on
37
+ from textual.app import App, ComposeResult
38
+ from textual.binding import Binding
39
+ from textual.message import Message
40
+ from textual.reactive import reactive
41
+ from textual.widget import Widget
42
+ from textual.widgets import Footer, Input, Label, Static
43
+
44
+ # ─── Palette ─────────────────────────────────────────────────────────────────
45
+
46
+ KIND_COLOR: dict[str, str] = {
47
+ "internal": "#4F8EF7",
48
+ "external": "#888899",
49
+ "stdlib": "#444466",
50
+ "AWS": "#FF9900",
51
+ "GCP": "#4285F4",
52
+ "Azure": "#0089D6",
53
+ "http": "#E84393",
54
+ }
55
+
56
+ KIND_ICON: dict[str, str] = {
57
+ "internal": "◉",
58
+ "external": "○",
59
+ "stdlib": "·",
60
+ "AWS": "◈",
61
+ "GCP": "◈",
62
+ "Azure": "◈",
63
+ "http": "◈",
64
+ }
65
+
66
+ KIND_LABEL: dict[str, str] = {
67
+ "internal": "Internal",
68
+ "AWS": "AWS SDK",
69
+ "GCP": "GCP SDK",
70
+ "Azure": "Azure SDK",
71
+ "http": "HTTP client",
72
+ "external": "External pkg",
73
+ "stdlib": "Stdlib",
74
+ }
75
+
76
+
77
+ # ─── Messages ────────────────────────────────────────────────────────────────
78
+
79
+ class NodeSelected(Message):
80
+ def __init__(self, node: str) -> None:
81
+ super().__init__()
82
+ self.node = node
83
+
84
+
85
+ # ─── Sidebar ─────────────────────────────────────────────────────────────────
86
+
87
+ class NodeSidebar(Widget, can_focus=False):
88
+ DEFAULT_CSS = """
89
+ NodeSidebar {
90
+ width: 28;
91
+ background: #08081a;
92
+ border-right: solid #1e1e3a;
93
+ overflow-y: scroll;
94
+ }
95
+ NodeSidebar Input {
96
+ background: #111128;
97
+ border: solid #2a2a4a;
98
+ color: #a0a0cc;
99
+ margin: 0 1;
100
+ height: 3;
101
+ }
102
+ NodeSidebar Input:focus {
103
+ border: solid #4F8EF7;
104
+ }
105
+ NodeSidebar .node-row {
106
+ padding: 0 2;
107
+ height: 1;
108
+ color: #888899;
109
+ }
110
+ NodeSidebar .node-row:hover {
111
+ background: #14143a;
112
+ color: white;
113
+ }
114
+ NodeSidebar .node-row.--selected {
115
+ background: #0d2060;
116
+ color: white;
117
+ }
118
+ NodeSidebar .section-header {
119
+ padding: 0 1;
120
+ height: 1;
121
+ color: #444466;
122
+ background: #0a0a1e;
123
+ }
124
+ """
125
+
126
+ filter_text: reactive[str] = reactive("")
127
+ selected_node: reactive[str | None] = reactive(None)
128
+
129
+ def __init__(self, graph: nx.DiGraph, **kwargs: Any) -> None:
130
+ super().__init__(**kwargs)
131
+ self.graph = graph
132
+ self._all_nodes: list[tuple[str, dict[str, Any]]] = sorted(
133
+ graph.nodes(data=True), key=lambda x: (x[1].get("kind", "z"), x[0])
134
+ )
135
+
136
+ def compose(self) -> ComposeResult:
137
+ yield Input(placeholder="🔍 filter nodes…", id="search-input")
138
+ yield Label(
139
+ f" {self.graph.number_of_nodes()} nodes · {self.graph.number_of_edges()} edges",
140
+ classes="section-header",
141
+ )
142
+ self._render_list()
143
+
144
+ def _render_list(self) -> None:
145
+ """Mount node rows, grouped by kind."""
146
+ # Remove existing rows
147
+ for w in self.query(".node-row,.section-header.kind-header"):
148
+ w.remove()
149
+
150
+ q = self.filter_text.lower()
151
+ current_kind: str | None = None
152
+
153
+ for node, data in self._all_nodes:
154
+ label = data.get("label", node.split(".")[-1])
155
+ if q and q not in node.lower() and q not in label.lower():
156
+ continue
157
+
158
+ kind = data.get("kind", "internal")
159
+ if kind != current_kind:
160
+ current_kind = kind
161
+ color = KIND_COLOR.get(kind, "#888")
162
+ icon = KIND_ICON.get(kind, "·")
163
+ kind_lbl = KIND_LABEL.get(kind, kind)
164
+ self.mount(
165
+ Label(
166
+ Text.assemble((f" {icon} {kind_lbl}", f"bold {color}")),
167
+ classes="section-header kind-header",
168
+ )
169
+ )
170
+
171
+ color = KIND_COLOR.get(kind, "#888")
172
+ icon = KIND_ICON.get(kind, "·")
173
+ is_sel = node == self.selected_node
174
+
175
+ row = Static(
176
+ Text.assemble(
177
+ (f" {icon} ", f"{'bold ' if is_sel else ''}{color}"),
178
+ (label[:20], f"{'bold white' if is_sel else color}"),
179
+ ),
180
+ classes=f"node-row{' --selected' if is_sel else ''}",
181
+ )
182
+ row._node_id = node # type: ignore[attr-defined]
183
+ self.mount(row)
184
+
185
+ def watch_filter_text(self, _: str) -> None:
186
+ self._render_list()
187
+
188
+ def watch_selected_node(self, _: str | None) -> None:
189
+ self._render_list()
190
+
191
+ @on(Input.Changed, "#search-input")
192
+ def on_search(self, event: Input.Changed) -> None:
193
+ self.filter_text = event.value
194
+
195
+ def on_click(self, event: events.Click) -> None:
196
+ for child in self.query(".node-row"):
197
+ node_id = getattr(child, "_node_id", None)
198
+ if node_id and child.region.contains(event.screen_x, event.screen_y):
199
+ self.post_message(NodeSelected(node_id))
200
+ break
201
+
202
+
203
+ # ─── Ego graph canvas ─────────────────────────────────────────────────────────
204
+
205
+ class EgoCanvas(Widget, can_focus=True):
206
+ """
207
+ 3-column ego graph view:
208
+
209
+ predecessors │ center node │ successors
210
+ (imported by) │ │ (imports)
211
+ """
212
+
213
+ DEFAULT_CSS = """
214
+ EgoCanvas {
215
+ background: #09091a;
216
+ height: 1fr;
217
+ padding: 1 2;
218
+ }
219
+ EgoCanvas:focus {
220
+ border: solid #4F8EF7 25%;
221
+ }
222
+ """
223
+
224
+ selected_node: reactive[str | None] = reactive(None, layout=True)
225
+
226
+ _MAX_NEIGHBORS = 18 # max per side before truncating
227
+
228
+ def __init__(self, graph: nx.DiGraph, **kwargs: Any) -> None:
229
+ super().__init__(**kwargs)
230
+ self.graph = graph
231
+ self._node_order: list[str] = list(graph.nodes())
232
+
233
+ def render(self) -> Any:
234
+ if self.selected_node is None:
235
+ return self._render_welcome()
236
+ return self._render_ego(self.selected_node)
237
+
238
+ def _render_welcome(self) -> Panel:
239
+ t = Text(justify="center")
240
+ t.append("\n\n\n")
241
+ t.append("⬡ synaptic\n\n", style="bold #4F8EF7")
242
+ t.append("Press ", style="dim")
243
+ t.append("Tab", style="bold cyan")
244
+ t.append(" or ", style="dim")
245
+ t.append("click a node", style="bold cyan")
246
+ t.append(" in the sidebar\n", style="dim")
247
+ t.append("to explore its dependency graph", style="dim")
248
+ return Panel(Align.center(t, vertical="middle"), border_style="#1e1e3a", box=ROUNDED)
249
+
250
+ def _render_ego(self, node: str) -> Any:
251
+ data = self.graph.nodes.get(node, {})
252
+ kind = data.get("kind", "internal")
253
+ color = KIND_COLOR.get(kind, "#cccccc")
254
+ icon = KIND_ICON.get(kind, "◉")
255
+
256
+ predecessors = list(self.graph.predecessors(node))
257
+ successors = list(self.graph.successors(node))
258
+
259
+ # Detect circular edges involving this node
260
+ circular_partners: set[str] = set()
261
+ for u, v, d in self.graph.edges(data=True):
262
+ if d.get("circular") and (u == node or v == node):
263
+ circular_partners.add(v if u == node else u)
264
+
265
+ # ── Center node panel ─────────────────────────────────────────
266
+ center_text = Text(justify="center")
267
+ center_text.append(f"\n{icon}\n", style=f"bold {color}")
268
+ center_text.append(f"{node}\n", style=f"bold {color}")
269
+ center_text.append(f"\n{KIND_LABEL.get(kind, kind)}", style="dim #666688")
270
+ center_text.append(f"\n\n← {len(predecessors)} {len(successors)} →", style="#444466")
271
+ if circular_partners:
272
+ center_text.append(f"\n⚠ {len(circular_partners)} circular", style="bold red")
273
+
274
+ center_panel = Panel(
275
+ Align.center(center_text, vertical="middle"),
276
+ border_style=color,
277
+ box=ROUNDED,
278
+ expand=True,
279
+ )
280
+
281
+ # ── Predecessors column ───────────────────────────────────────
282
+ pred_rows = self._build_neighbor_column(
283
+ predecessors, circular_partners, arrow="→", title="imported by"
284
+ )
285
+
286
+ # ── Successors column ─────────────────────────────────────────
287
+ succ_rows = self._build_neighbor_column(
288
+ successors, circular_partners, arrow="←", title="imports"
289
+ )
290
+
291
+ # ── 3-column table ────────────────────────────────────────────
292
+ table = Table.grid(expand=True, padding=(0, 1))
293
+ table.add_column(ratio=3, no_wrap=False) # predecessors
294
+ table.add_column(ratio=2, no_wrap=False) # center
295
+ table.add_column(ratio=3, no_wrap=False) # successors
296
+ table.add_row(pred_rows, center_panel, succ_rows)
297
+
298
+ return table
299
+
300
+ def _build_neighbor_column(
301
+ self,
302
+ neighbors: list[str],
303
+ circular_partners: set[str],
304
+ arrow: str,
305
+ title: str,
306
+ ) -> Panel:
307
+ content = Text()
308
+ total = len(neighbors)
309
+ shown = neighbors[: self._MAX_NEIGHBORS]
310
+
311
+ if not shown:
312
+ content.append(f"\n (none)", style="dim #444466")
313
+ else:
314
+ for i, nb in enumerate(shown):
315
+ nb_data = self.graph.nodes.get(nb, {})
316
+ nb_kind = nb_data.get("kind", "internal")
317
+ nb_color = KIND_COLOR.get(nb_kind, "#888")
318
+ nb_icon = KIND_ICON.get(nb_kind, "·")
319
+ nb_label = nb_data.get("label", nb.split(".")[-1])
320
+
321
+ is_circ = nb in circular_partners
322
+ connector = "⚠ " if is_circ else " "
323
+ circ_style = "bold red" if is_circ else ""
324
+
325
+ content.append(f"\n")
326
+ if arrow == "→":
327
+ content.append(f" {connector}", circ_style)
328
+ content.append(f"{nb_icon} ", f"{nb_color}")
329
+ content.append(f"{nb_label[:22]}", f"{'bold ' if is_circ else ''}{nb_color}")
330
+ content.append(f" {arrow}", "#2a3a6a")
331
+ else:
332
+ content.append(f" {arrow} ", "#2a3a6a")
333
+ content.append(f"{nb_icon} ", f"{nb_color}")
334
+ content.append(f"{nb_label[:22]}", f"{'bold ' if is_circ else ''}{nb_color}")
335
+ content.append(f" {connector}", circ_style)
336
+
337
+ if total > self._MAX_NEIGHBORS:
338
+ content.append(f"\n … +{total - self._MAX_NEIGHBORS} more", style="dim #444466")
339
+
340
+ border_title = (
341
+ f"[dim]← [bold]{title}[/bold] {total}[/dim]" if arrow == "→"
342
+ else f"[dim]{title} [bold]{total}[/bold] →[/dim]"
343
+ )
344
+ return Panel(
345
+ content,
346
+ title=border_title,
347
+ border_style="#1e1e3a",
348
+ box=ROUNDED,
349
+ expand=True,
350
+ )
351
+
352
+ # ── Keyboard navigation ───────────────────────────────────────────
353
+
354
+ def on_key(self, event: events.Key) -> None:
355
+ nodes = self._node_order
356
+ if not nodes:
357
+ return
358
+ if self.selected_node is None:
359
+ self.selected_node = nodes[0]
360
+ self.post_message(NodeSelected(nodes[0]))
361
+ return
362
+
363
+ try:
364
+ idx = nodes.index(self.selected_node)
365
+ except ValueError:
366
+ idx = 0
367
+
368
+ if event.key in ("tab", "down", "j", "n"):
369
+ new = nodes[(idx + 1) % len(nodes)]
370
+ elif event.key in ("shift+tab", "up", "k", "p"):
371
+ new = nodes[(idx - 1) % len(nodes)]
372
+ else:
373
+ return
374
+
375
+ event.stop()
376
+ self.selected_node = new
377
+ self.post_message(NodeSelected(new))
378
+
379
+ def select(self, node: str) -> None:
380
+ self.selected_node = node
381
+ self.refresh()
382
+
383
+
384
+ # ─── Bottom detail bar ────────────────────────────────────────────────────────
385
+
386
+ class DetailBar(Widget):
387
+ DEFAULT_CSS = """
388
+ DetailBar {
389
+ height: 3;
390
+ background: #0a0a1e;
391
+ border-top: solid #1e1e3a;
392
+ padding: 0 2;
393
+ }
394
+ """
395
+
396
+ def compose(self) -> ComposeResult:
397
+ yield Static("", id="detail-text")
398
+
399
+ def update(self, node: str, graph: nx.DiGraph) -> None:
400
+ data = graph.nodes.get(node, {})
401
+ kind = data.get("kind", "internal")
402
+ color = KIND_COLOR.get(kind, "#888")
403
+ icon = KIND_ICON.get(kind, "·")
404
+
405
+ preds = list(graph.predecessors(node))
406
+ succs = list(graph.successors(node))
407
+ circs = [
408
+ (u, v) for u, v, d in graph.edges(data=True)
409
+ if d.get("circular") and (u == node or v == node)
410
+ ]
411
+
412
+ t = Text()
413
+ t.append(f" {icon} ", f"bold {color}")
414
+ t.append(node, f"bold {color}")
415
+ t.append(" · ", "dim #333355")
416
+ t.append(KIND_LABEL.get(kind, kind), f"dim {color}")
417
+ t.append(" · ", "dim #333355")
418
+ t.append("imported by ", "dim")
419
+ t.append(str(len(preds)), "bold #a0a0ff")
420
+ t.append(" imports ", "dim")
421
+ t.append(str(len(succs)), "bold #4F8EF7")
422
+ if circs:
423
+ t.append(" · ", "dim #333355")
424
+ t.append(f"⚠ {len(circs)} circular dep{'s' if len(circs)>1 else ''}", "bold red")
425
+
426
+ self.query_one("#detail-text", Static).update(t)
427
+
428
+
429
+ # ─── Mini stats bar (top) ─────────────────────────────────────────────────────
430
+
431
+ class StatsBar(Widget):
432
+ DEFAULT_CSS = """
433
+ StatsBar {
434
+ height: 1;
435
+ background: #111122;
436
+ padding: 0 2;
437
+ dock: top;
438
+ }
439
+ """
440
+
441
+ def __init__(self, graph: nx.DiGraph, project: Path, **kwargs: Any) -> None:
442
+ super().__init__(**kwargs)
443
+ self.graph = graph
444
+ self.project = project
445
+
446
+ def render(self) -> Text:
447
+ from synaptic import __version__
448
+ G = self.graph
449
+
450
+ internals = sum(1 for _, d in G.nodes(data=True) if d.get("kind") == "internal")
451
+ clouds = sum(1 for _, d in G.nodes(data=True) if d.get("kind") in ("AWS","GCP","Azure"))
452
+ http_ = sum(1 for _, d in G.nodes(data=True) if d.get("kind") == "http")
453
+ circs = sum(1 for *_, d in G.edges(data=True) if d.get("circular"))
454
+
455
+ t = Text(no_wrap=True, overflow="crop")
456
+ t.append("⬡ ", "bold #4F8EF7")
457
+ t.append("synaptic", "bold #4F8EF7")
458
+ t.append(f" {self.project.name}", "#666688")
459
+ t.append(" · ", "dim #333355")
460
+ t.append(str(G.number_of_nodes()), "bold #4F8EF7")
461
+ t.append(" nodes ", "dim #444466")
462
+ t.append(str(G.number_of_edges()), "bold #4F8EF7")
463
+ t.append(" edges ", "dim #444466")
464
+ if clouds:
465
+ t.append(f" ◈ {clouds} cloud", "#FF9900")
466
+ if http_:
467
+ t.append(f" ◈ {http_} http", "#E84393")
468
+ if circs:
469
+ t.append(f" ⚠ {circs} circular", "bold red")
470
+ t.append(f" · v{__version__}", "dim #333355")
471
+ return t
472
+
473
+
474
+ # ─── Main App ─────────────────────────────────────────────────────────────────
475
+
476
+ class SynapticApp(App[None]):
477
+ CSS = """
478
+ Screen {
479
+ layout: vertical;
480
+ background: #0d0d1a;
481
+ }
482
+ #body {
483
+ layout: horizontal;
484
+ height: 1fr;
485
+ }
486
+ #main {
487
+ layout: vertical;
488
+ height: 1fr;
489
+ }
490
+ """
491
+
492
+ BINDINGS = [
493
+ Binding("q", "quit", "Quit"),
494
+ Binding("tab", "next_node", "Next", show=False),
495
+ Binding("shift+tab", "prev_node", "Prev", show=False),
496
+ Binding("r", "reset", "Reset"),
497
+ Binding("g", "focus_canvas", "Graph"),
498
+ Binding("s", "focus_search", "Search"),
499
+ ]
500
+
501
+ def __init__(self, graph: nx.DiGraph, project: Path, **kwargs: Any) -> None:
502
+ super().__init__(**kwargs)
503
+ self.graph = graph
504
+ self.project = project
505
+
506
+ def compose(self) -> ComposeResult:
507
+ from textual.containers import Horizontal, Vertical
508
+
509
+ canvas = EgoCanvas(self.graph, id="canvas")
510
+ sidebar = NodeSidebar(self.graph, id="sidebar")
511
+ detail = DetailBar(id="detail")
512
+
513
+ yield StatsBar(self.graph, self.project)
514
+
515
+ with Horizontal(id="body"):
516
+ yield sidebar
517
+ with Vertical(id="main"):
518
+ yield canvas
519
+ yield detail
520
+
521
+ yield Footer()
522
+
523
+ # ── Event handlers ───────────────────────────────────────────────
524
+
525
+ def on_node_selected(self, message: NodeSelected) -> None:
526
+ canvas = self.query_one("#canvas", EgoCanvas)
527
+ sidebar = self.query_one("#sidebar", NodeSidebar)
528
+ detail = self.query_one("#detail", DetailBar)
529
+
530
+ canvas.selected_node = message.node
531
+ sidebar.selected_node = message.node
532
+ detail.update(message.node, self.graph)
533
+
534
+ # ── Actions ──────────────────────────────────────────────────────
535
+
536
+ def action_next_node(self) -> None:
537
+ self.query_one("#canvas", EgoCanvas).on_key(
538
+ events.Key(self, "tab", character=None)
539
+ )
540
+
541
+ def action_prev_node(self) -> None:
542
+ self.query_one("#canvas", EgoCanvas).on_key(
543
+ events.Key(self, "shift+tab", character=None)
544
+ )
545
+
546
+ def action_reset(self) -> None:
547
+ canvas = self.query_one("#canvas", EgoCanvas)
548
+ sidebar = self.query_one("#sidebar", NodeSidebar)
549
+ canvas.selected_node = None
550
+ sidebar.selected_node = None
551
+
552
+ def action_focus_canvas(self) -> None:
553
+ self.query_one("#canvas", EgoCanvas).focus()
554
+
555
+ def action_focus_search(self) -> None:
556
+ self.query_one("#search-input", Input).focus()
557
+
558
+
559
+ # ─── Entry point ──────────────────────────────────────────────────────────────
560
+
561
+ def launch(graph: nx.DiGraph, project: Path) -> None:
562
+ SynapticApp(graph=graph, project=project).run()
synaptic/utils.py ADDED
@@ -0,0 +1,41 @@
1
+ """
2
+ utils.py — Shared helpers for synaptic.
3
+ """
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ def get_stdlib_modules() -> set[str]:
10
+ """Return the set of top-level standard library module names."""
11
+ if sys.version_info >= (3, 10):
12
+ import sys as _sys
13
+ return set(_sys.stdlib_module_names) # type: ignore[attr-defined]
14
+
15
+ # Fallback for older Pythons: a curated list of common stdlib modules
16
+ return {
17
+ "abc", "ast", "asyncio", "builtins", "collections", "contextlib",
18
+ "copy", "dataclasses", "datetime", "enum", "functools", "hashlib",
19
+ "io", "itertools", "json", "logging", "math", "operator", "os",
20
+ "pathlib", "pickle", "platform", "pprint", "queue", "random",
21
+ "re", "shutil", "signal", "socket", "sqlite3", "string", "struct",
22
+ "subprocess", "sys", "tempfile", "threading", "time", "traceback",
23
+ "typing", "unittest", "urllib", "uuid", "warnings", "weakref",
24
+ "xml", "xmlrpc", "zipfile", "zlib",
25
+ }
26
+
27
+
28
+ def resolve_internal_modules(files: list[Path], root: Path) -> set[str]:
29
+ """Build the set of dotted module names that belong to the project."""
30
+ from synaptic.parser import file_to_module
31
+ return {file_to_module(f, root) for f in files}
32
+
33
+
34
+ def choose_output_format(output: Path) -> str:
35
+ """Infer desired output format from the file extension."""
36
+ suffix = output.suffix.lower()
37
+ if suffix in {".html", ".htm"}:
38
+ return "html"
39
+ if suffix in {".svg"}:
40
+ return "svg"
41
+ return "html" # default
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: synaptic-graph
3
+ Version: 0.1.0
4
+ Summary: Visualize the dependency graph of any Python project — internal imports, cloud SDKs and HTTP calls.
5
+ Project-URL: Homepage, https://github.com/darkvius/synaptic
6
+ Project-URL: Repository, https://github.com/darkvius/synaptic
7
+ Project-URL: Issues, https://github.com/darkvius/synaptic/issues
8
+ License: MIT
9
+ Keywords: architecture,ast,dependencies,graph,visualization
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: graphviz>=0.20
22
+ Requires-Dist: networkx>=3.2
23
+ Requires-Dist: numpy>=1.24
24
+ Requires-Dist: pyvis>=0.3
25
+ Requires-Dist: rich>=13
26
+ Requires-Dist: textual>=0.80
27
+ Requires-Dist: typer>=0.12
28
+ Description-Content-Type: text/markdown
29
+
30
+ <div align="center">
31
+
32
+ <img src="assets/demo.svg" alt="synaptic demo" width="100%"/>
33
+
34
+ # synaptic
35
+
36
+ **Visualize the dependency graph of any Python project.**
37
+ Internal imports · Cloud SDKs · HTTP clients · Circular deps — all in one command.
38
+
39
+ [![Python](https://img.shields.io/badge/python-3.10%2B-4F8EF7?style=flat-square&logo=python&logoColor=white)](https://python.org)
40
+ [![License](https://img.shields.io/badge/license-MIT-2ecc71?style=flat-square)](LICENSE)
41
+ [![Built with Typer](https://img.shields.io/badge/CLI-Typer-E84393?style=flat-square)](https://typer.tiangolo.com)
42
+ [![Powered by Rich](https://img.shields.io/badge/output-Rich-FF9900?style=flat-square)](https://rich.readthedocs.io)
43
+
44
+ </div>
45
+
46
+ ---
47
+
48
+ ## Features
49
+
50
+ - **Static AST analysis** — no runtime execution needed
51
+ - **Internal imports** — maps every `import` and `from X import Y` across your codebase
52
+ - **Cloud SDK detection** — identifies AWS (`boto3`), GCP (`google.cloud`, `firebase_admin`) and Azure (`azure.*`) usage
53
+ - **HTTP client detection** — flags modules using `requests`, `httpx`, `aiohttp`, `urllib3` and more
54
+ - **Circular dependency highlighting** — broken cycles rendered in red
55
+ - **Two output formats** — interactive HTML (`pyvis`) or static SVG/PNG (`graphviz`)
56
+ - **Rich terminal output** — live progress, color-coded summary
57
+
58
+ ---
59
+
60
+ ## Installation
61
+
62
+ ```bash
63
+ pip install synaptic
64
+ ```
65
+
66
+ Or from source:
67
+
68
+ ```bash
69
+ git clone https://github.com/your-username/synaptic
70
+ cd synaptic
71
+ pip install -e .
72
+ ```
73
+
74
+ > **Requirements:** Python 3.10+, `graphviz` binary installed on your system (`apt install graphviz` / `brew install graphviz`).
75
+
76
+ ---
77
+
78
+ ## Quick start
79
+
80
+ ```bash
81
+ # Interactive HTML graph (default)
82
+ synaptic scan ./my-project
83
+
84
+ # Custom output path
85
+ synaptic scan ./my-project --output architecture.html
86
+
87
+ # SVG with circular dependency highlighting
88
+ synaptic scan ./my-project --output graph.svg --circular
89
+
90
+ # Skip cloud and HTTP detection, filter stdlib
91
+ synaptic scan ./my-project --no-cloud --no-http --filter-stdlib
92
+
93
+ # Include test files in the scan
94
+ synaptic scan ./my-project --tests
95
+ ```
96
+
97
+ ---
98
+
99
+ ## Options
100
+
101
+ | Flag | Default | Description |
102
+ |---|---|---|
103
+ | `--output`, `-o` | `synaptic_graph.html` | Output file (`.html` or `.svg`) |
104
+ | `--cloud / --no-cloud` | `on` | Detect AWS / GCP / Azure SDKs |
105
+ | `--http / --no-http` | `on` | Detect HTTP client libraries |
106
+ | `--tests / --no-tests` | `off` | Include test files |
107
+ | `--filter-stdlib / --no-filter-stdlib` | `on` | Exclude Python stdlib from graph |
108
+ | `--filter-external / --no-filter-external` | `off` | Exclude third-party packages |
109
+ | `--circular`, `-c` | `off` | Highlight circular dependencies in red |
110
+ | `--version`, `-v` | — | Show version and exit |
111
+
112
+ ---
113
+
114
+ ## Architecture
115
+
116
+ ```
117
+ synaptic/
118
+ ├── cli.py # Typer CLI + Rich output
119
+ ├── scanner.py # Recursive .py file discovery
120
+ ├── parser.py # AST-based import analysis
121
+ ├── cloud_detector.py # AWS / GCP / Azure SDK detection
122
+ ├── http_detector.py # HTTP client library detection
123
+ ├── graph.py # networkx graph + graphviz / pyvis rendering
124
+ └── utils.py # Shared helpers
125
+ ```
126
+
127
+ ---
128
+
129
+ ## Node types
130
+
131
+ | Color | Meaning |
132
+ |---|---|
133
+ | 🔵 Blue | Internal project module |
134
+ | 🟠 Orange | AWS / GCP / Azure SDK |
135
+ | 🩷 Pink | HTTP client (requests, httpx…) |
136
+ | ⚫ Grey | Stdlib / external package |
137
+ | 🔴 Red edge | Circular dependency |
138
+
139
+ ---
140
+
141
+ ## License
142
+
143
+ MIT © 2024
@@ -0,0 +1,13 @@
1
+ synaptic/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ synaptic/cli.py,sha256=z6HVgYUZgcyVL8FryP4KBTW15BcdYGVR1jleSBG5670,8203
3
+ synaptic/cloud_detector.py,sha256=Mp0uXl8wTSDx1LI__SCPXTKWushRALUtXACrfetKy8M,1607
4
+ synaptic/graph.py,sha256=-XuieF1M2gP7rqfG1W5s1CnGJQo1p5c3XB1oAT0sZUs,5700
5
+ synaptic/http_detector.py,sha256=gmGza5FdyJFvJX77MkVgoTlDBcuK7Y3PJk6VBCBGcMQ,1424
6
+ synaptic/parser.py,sha256=8oabkkTDxMSx9mcmArxKipyinHurqDXYU-1my4ZqzNo,2111
7
+ synaptic/scanner.py,sha256=mgiXIV4RkAFRv9zVUoOneyiBY05td1vjfvmOWAMU2m8,1036
8
+ synaptic/tui.py,sha256=3rKYsLw-AS7H-UdLuMjgoqKlyULx66XA6v5O_c4J6DU,21296
9
+ synaptic/utils.py,sha256=QIcsYw-1P70YT5mtIl1PnPum2VwMRDPHOo6LBW0Dj2E,1515
10
+ synaptic_graph-0.1.0.dist-info/METADATA,sha256=_IkAT4yKQ9cfOK9uSQSXAAAKXR3EH1iNs5ABBnfY-qE,4646
11
+ synaptic_graph-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
+ synaptic_graph-0.1.0.dist-info/entry_points.txt,sha256=eGAKwerDsS9rPffdCXurFypd2mpw3AgGIcwamzjr4wQ,46
13
+ synaptic_graph-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ synaptic = synaptic.cli:app