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 +1 -0
- synaptic/cli.py +214 -0
- synaptic/cloud_detector.py +47 -0
- synaptic/graph.py +166 -0
- synaptic/http_detector.py +46 -0
- synaptic/parser.py +60 -0
- synaptic/scanner.py +39 -0
- synaptic/tui.py +562 -0
- synaptic/utils.py +41 -0
- synaptic_graph-0.1.0.dist-info/METADATA +143 -0
- synaptic_graph-0.1.0.dist-info/RECORD +13 -0
- synaptic_graph-0.1.0.dist-info/WHEEL +4 -0
- synaptic_graph-0.1.0.dist-info/entry_points.txt +2 -0
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
|
+
[](https://python.org)
|
|
40
|
+
[](LICENSE)
|
|
41
|
+
[](https://typer.tiangolo.com)
|
|
42
|
+
[](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,,
|