polycodegraph 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.
- codegraph/__init__.py +10 -0
- codegraph/analysis/__init__.py +30 -0
- codegraph/analysis/_common.py +125 -0
- codegraph/analysis/blast_radius.py +63 -0
- codegraph/analysis/cycles.py +79 -0
- codegraph/analysis/dataflow.py +861 -0
- codegraph/analysis/dead_code.py +165 -0
- codegraph/analysis/hotspots.py +68 -0
- codegraph/analysis/infrastructure.py +439 -0
- codegraph/analysis/metrics.py +52 -0
- codegraph/analysis/report.py +222 -0
- codegraph/analysis/roles.py +323 -0
- codegraph/analysis/untested.py +79 -0
- codegraph/cli.py +1506 -0
- codegraph/config.py +64 -0
- codegraph/embed/__init__.py +35 -0
- codegraph/embed/chunker.py +120 -0
- codegraph/embed/embedder.py +113 -0
- codegraph/embed/query.py +181 -0
- codegraph/embed/store.py +360 -0
- codegraph/graph/__init__.py +0 -0
- codegraph/graph/builder.py +212 -0
- codegraph/graph/schema.py +69 -0
- codegraph/graph/store_networkx.py +55 -0
- codegraph/graph/store_sqlite.py +249 -0
- codegraph/mcp_server/__init__.py +6 -0
- codegraph/mcp_server/server.py +933 -0
- codegraph/parsers/__init__.py +0 -0
- codegraph/parsers/base.py +70 -0
- codegraph/parsers/go.py +570 -0
- codegraph/parsers/python.py +1707 -0
- codegraph/parsers/typescript.py +1397 -0
- codegraph/py.typed +0 -0
- codegraph/resolve/__init__.py +4 -0
- codegraph/resolve/calls.py +480 -0
- codegraph/review/__init__.py +31 -0
- codegraph/review/baseline.py +32 -0
- codegraph/review/differ.py +211 -0
- codegraph/review/hook.py +70 -0
- codegraph/review/risk.py +219 -0
- codegraph/review/rules.py +342 -0
- codegraph/viz/__init__.py +17 -0
- codegraph/viz/_style.py +45 -0
- codegraph/viz/dashboard.py +740 -0
- codegraph/viz/diagrams.py +370 -0
- codegraph/viz/explore.py +453 -0
- codegraph/viz/hld.py +683 -0
- codegraph/viz/html.py +115 -0
- codegraph/viz/mermaid.py +111 -0
- codegraph/viz/svg.py +77 -0
- codegraph/web/__init__.py +4 -0
- codegraph/web/server.py +165 -0
- codegraph/web/static/app.css +664 -0
- codegraph/web/static/app.js +919 -0
- codegraph/web/static/index.html +112 -0
- codegraph/web/static/views/architecture.js +1671 -0
- codegraph/web/static/views/graph3d.css +564 -0
- codegraph/web/static/views/graph3d.js +999 -0
- codegraph/web/static/views/graph3d_transform.js +984 -0
- codegraph/workspace/__init__.py +34 -0
- codegraph/workspace/config.py +110 -0
- codegraph/workspace/operations.py +294 -0
- polycodegraph-0.1.0.dist-info/METADATA +687 -0
- polycodegraph-0.1.0.dist-info/RECORD +67 -0
- polycodegraph-0.1.0.dist-info/WHEEL +4 -0
- polycodegraph-0.1.0.dist-info/entry_points.txt +2 -0
- polycodegraph-0.1.0.dist-info/licenses/LICENSE +21 -0
codegraph/viz/html.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Interactive pyvis HTML renderer."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
import networkx as nx
|
|
8
|
+
|
|
9
|
+
from codegraph.viz._style import EDGE_STYLE, KIND_COLOR, kind_str
|
|
10
|
+
|
|
11
|
+
_DEFAULT_HEIGHT = "780px"
|
|
12
|
+
_DEFAULT_WIDTH = "100%"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _shape_for_kind(kind: str) -> str:
|
|
16
|
+
if kind in ("FILE", "MODULE"):
|
|
17
|
+
return "box"
|
|
18
|
+
if kind == "CLASS":
|
|
19
|
+
return "ellipse"
|
|
20
|
+
if kind == "TEST":
|
|
21
|
+
return "diamond"
|
|
22
|
+
return "dot"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _node_title(attrs: dict[str, Any]) -> str:
|
|
26
|
+
parts = [
|
|
27
|
+
f"<b>{attrs.get('name') or attrs.get('qualname') or ''}</b>",
|
|
28
|
+
f"kind: {kind_str(attrs.get('kind'))}",
|
|
29
|
+
f"qualname: {attrs.get('qualname') or '-'}",
|
|
30
|
+
f"file: {attrs.get('file') or '-'}:"
|
|
31
|
+
f"{attrs.get('line_start') or '?'}",
|
|
32
|
+
f"language: {attrs.get('language') or '-'}",
|
|
33
|
+
]
|
|
34
|
+
sig = attrs.get("signature")
|
|
35
|
+
if sig:
|
|
36
|
+
parts.append(f"signature: {sig}")
|
|
37
|
+
return "<br>".join(str(p) for p in parts)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render_html(
|
|
41
|
+
graph: nx.MultiDiGraph,
|
|
42
|
+
output: Path,
|
|
43
|
+
*,
|
|
44
|
+
height: str = _DEFAULT_HEIGHT,
|
|
45
|
+
width: str = _DEFAULT_WIDTH,
|
|
46
|
+
notebook: bool = False,
|
|
47
|
+
) -> Path:
|
|
48
|
+
"""Render an interactive HTML visualization with pyvis.
|
|
49
|
+
|
|
50
|
+
Returns the path that was written. Pyvis is a required dependency, so
|
|
51
|
+
this function will only fail if the user has uninstalled it manually.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
from pyvis.network import Network
|
|
55
|
+
except ImportError as exc: # pragma: no cover - pyvis is a hard dep
|
|
56
|
+
raise RuntimeError(
|
|
57
|
+
"pyvis is required for HTML output: pip install pyvis"
|
|
58
|
+
) from exc
|
|
59
|
+
|
|
60
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
|
|
62
|
+
net = Network(
|
|
63
|
+
height=height,
|
|
64
|
+
width=width,
|
|
65
|
+
directed=True,
|
|
66
|
+
notebook=notebook,
|
|
67
|
+
cdn_resources="in_line",
|
|
68
|
+
bgcolor="#0f172a",
|
|
69
|
+
font_color="#f1f5f9",
|
|
70
|
+
)
|
|
71
|
+
net.barnes_hut(
|
|
72
|
+
gravity=-8000,
|
|
73
|
+
central_gravity=0.3,
|
|
74
|
+
spring_length=120,
|
|
75
|
+
spring_strength=0.04,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
for nid, attrs in graph.nodes(data=True):
|
|
79
|
+
kind = kind_str(attrs.get("kind"))
|
|
80
|
+
color = KIND_COLOR.get(kind, "#94a3b8")
|
|
81
|
+
label = str(attrs.get("name") or attrs.get("qualname") or nid[:8])
|
|
82
|
+
net.add_node(
|
|
83
|
+
nid,
|
|
84
|
+
label=label,
|
|
85
|
+
color=color,
|
|
86
|
+
shape=_shape_for_kind(kind),
|
|
87
|
+
title=_node_title(cast(dict[str, Any], attrs)),
|
|
88
|
+
group=kind or "OTHER",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
seen: set[tuple[str, str, str]] = set()
|
|
92
|
+
for src, dst, data in graph.edges(data=True):
|
|
93
|
+
if src not in graph.nodes or dst not in graph.nodes:
|
|
94
|
+
continue
|
|
95
|
+
ek = kind_str(data.get("kind"))
|
|
96
|
+
key = (src, dst, ek)
|
|
97
|
+
if key in seen:
|
|
98
|
+
continue
|
|
99
|
+
seen.add(key)
|
|
100
|
+
style = EDGE_STYLE.get(ek, "solid")
|
|
101
|
+
dashes = style in ("dashed", "dotted")
|
|
102
|
+
width_n = 3 if style == "bold" else 1
|
|
103
|
+
net.add_edge(
|
|
104
|
+
src,
|
|
105
|
+
dst,
|
|
106
|
+
label=ek,
|
|
107
|
+
arrows="to",
|
|
108
|
+
dashes=dashes,
|
|
109
|
+
width=width_n,
|
|
110
|
+
title=ek,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
html = cast(str, net.generate_html(notebook=notebook))
|
|
114
|
+
output.write_text(html, encoding="utf-8")
|
|
115
|
+
return output
|
codegraph/viz/mermaid.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Mermaid renderer with file-clustering and per-kind coloring."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
|
|
7
|
+
import networkx as nx
|
|
8
|
+
|
|
9
|
+
from codegraph.viz._style import EDGE_STYLE, KIND_CLASS, kind_str
|
|
10
|
+
|
|
11
|
+
_SAFE_RE = re.compile(r"[^a-zA-Z0-9_]+")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _safe_id(node_id: str, prefix: str = "n_") -> str:
|
|
15
|
+
return prefix + _SAFE_RE.sub("_", node_id)[:32]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _safe_subgraph_id(name: str) -> str:
|
|
19
|
+
return "g_" + _SAFE_RE.sub("_", name)[:48]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _label(attrs: dict[str, object]) -> str:
|
|
23
|
+
raw = str(attrs.get("name") or "")
|
|
24
|
+
if not raw:
|
|
25
|
+
raw = str(attrs.get("qualname") or "?")
|
|
26
|
+
return raw.replace('"', "'").replace("[", "(").replace("]", ")")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def render_mermaid(
|
|
30
|
+
graph: nx.MultiDiGraph,
|
|
31
|
+
*,
|
|
32
|
+
cluster_by_file: bool = True,
|
|
33
|
+
show_legend: bool = True,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Return a Mermaid ``flowchart LR`` diagram of ``graph``.
|
|
36
|
+
|
|
37
|
+
Nodes are colored by NodeKind; edges by EdgeKind. When
|
|
38
|
+
``cluster_by_file`` is True, nodes that share a file path are grouped
|
|
39
|
+
into a Mermaid subgraph.
|
|
40
|
+
"""
|
|
41
|
+
lines: list[str] = ["flowchart LR"]
|
|
42
|
+
|
|
43
|
+
classes = {
|
|
44
|
+
"file": "stroke:#475569,fill:#e2e8f0,color:#1e293b",
|
|
45
|
+
"module": "stroke:#3730a3,fill:#e0e7ff,color:#1e1b4b",
|
|
46
|
+
"klass": "stroke:#b45309,fill:#fef3c7,color:#451a03",
|
|
47
|
+
"func": "stroke:#047857,fill:#d1fae5,color:#022c22",
|
|
48
|
+
"method": "stroke:#15803d,fill:#dcfce7,color:#052e16",
|
|
49
|
+
"var": "stroke:#4b5563,fill:#f3f4f6,color:#111827",
|
|
50
|
+
"param": "stroke:#6b7280,fill:#f9fafb,color:#111827",
|
|
51
|
+
"imp": "stroke:#0369a1,fill:#e0f2fe,color:#082f49",
|
|
52
|
+
"test": "stroke:#be185d,fill:#fce7f3,color:#500724",
|
|
53
|
+
}
|
|
54
|
+
for cls, style in classes.items():
|
|
55
|
+
lines.append(f" classDef {cls} {style};")
|
|
56
|
+
|
|
57
|
+
by_file: dict[str, list[tuple[str, dict[str, object]]]] = defaultdict(list)
|
|
58
|
+
free: list[tuple[str, dict[str, object]]] = []
|
|
59
|
+
for nid, attrs in graph.nodes(data=True):
|
|
60
|
+
file_path = attrs.get("file")
|
|
61
|
+
if cluster_by_file and isinstance(file_path, str) and file_path:
|
|
62
|
+
by_file[file_path].append((nid, attrs))
|
|
63
|
+
else:
|
|
64
|
+
free.append((nid, attrs))
|
|
65
|
+
|
|
66
|
+
safe_map: dict[str, str] = {}
|
|
67
|
+
|
|
68
|
+
def _emit_node(nid: str, attrs: dict[str, object], indent: str) -> None:
|
|
69
|
+
sid = _safe_id(nid)
|
|
70
|
+
safe_map[nid] = sid
|
|
71
|
+
kind = kind_str(attrs.get("kind"))
|
|
72
|
+
cls = KIND_CLASS.get(kind, "var")
|
|
73
|
+
label = f"{kind}: {_label(attrs)}"
|
|
74
|
+
lines.append(f'{indent}{sid}["{label}"]:::{cls}')
|
|
75
|
+
|
|
76
|
+
for file_path, members in sorted(by_file.items()):
|
|
77
|
+
sg_id = _safe_subgraph_id(file_path)
|
|
78
|
+
lines.append(f' subgraph {sg_id}["{file_path}"]')
|
|
79
|
+
for nid, attrs in members:
|
|
80
|
+
_emit_node(nid, attrs, " ")
|
|
81
|
+
lines.append(" end")
|
|
82
|
+
for nid, attrs in free:
|
|
83
|
+
_emit_node(nid, attrs, " ")
|
|
84
|
+
|
|
85
|
+
seen: set[tuple[str, str, str]] = set()
|
|
86
|
+
for src, dst, data in graph.edges(data=True):
|
|
87
|
+
if src not in safe_map or dst not in safe_map:
|
|
88
|
+
continue
|
|
89
|
+
ek = kind_str(data.get("kind"))
|
|
90
|
+
key = (src, dst, ek)
|
|
91
|
+
if key in seen:
|
|
92
|
+
continue
|
|
93
|
+
seen.add(key)
|
|
94
|
+
style = EDGE_STYLE.get(ek, "solid")
|
|
95
|
+
arrow = "-->" if style != "dotted" else "-.->"
|
|
96
|
+
if style == "bold":
|
|
97
|
+
arrow = "==>"
|
|
98
|
+
lines.append(
|
|
99
|
+
f" {safe_map[src]} {arrow}|{ek}| {safe_map[dst]}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if show_legend:
|
|
103
|
+
lines.append(" %% legend")
|
|
104
|
+
lines.append(' legend_file["FILE"]:::file')
|
|
105
|
+
lines.append(' legend_module["MODULE"]:::module')
|
|
106
|
+
lines.append(' legend_class["CLASS"]:::klass')
|
|
107
|
+
lines.append(' legend_func["FUNCTION"]:::func')
|
|
108
|
+
lines.append(' legend_method["METHOD"]:::method')
|
|
109
|
+
lines.append(' legend_test["TEST"]:::test')
|
|
110
|
+
|
|
111
|
+
return "\n".join(lines) + "\n"
|
codegraph/viz/svg.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Optional Graphviz SVG renderer (no-op if `dot` binary is missing)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import networkx as nx
|
|
9
|
+
|
|
10
|
+
from codegraph.viz._style import EDGE_STYLE, KIND_COLOR, kind_str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GraphvizUnavailableError(RuntimeError):
|
|
14
|
+
"""Raised when the graphviz Python package or `dot` binary is missing."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _ensure_graphviz() -> Any:
|
|
18
|
+
try:
|
|
19
|
+
import graphviz as _graphviz
|
|
20
|
+
except ImportError as exc:
|
|
21
|
+
raise GraphvizUnavailableError(
|
|
22
|
+
"graphviz Python package not installed. "
|
|
23
|
+
"Install with: pip install polycodegraph[viz]"
|
|
24
|
+
) from exc
|
|
25
|
+
if shutil.which("dot") is None:
|
|
26
|
+
raise GraphvizUnavailableError(
|
|
27
|
+
"Graphviz `dot` binary not found in PATH. "
|
|
28
|
+
"Install Graphviz from https://graphviz.org/download/"
|
|
29
|
+
)
|
|
30
|
+
return _graphviz
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render_svg(graph: nx.MultiDiGraph, output: Path) -> Path:
|
|
34
|
+
"""Render an SVG of ``graph`` to ``output``.
|
|
35
|
+
|
|
36
|
+
Raises ``GraphvizUnavailableError`` if the toolchain is missing so the
|
|
37
|
+
CLI can degrade gracefully.
|
|
38
|
+
"""
|
|
39
|
+
gv = _ensure_graphviz()
|
|
40
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
|
|
42
|
+
dot = gv.Digraph(format="svg")
|
|
43
|
+
dot.attr(rankdir="LR", bgcolor="white", fontname="Helvetica")
|
|
44
|
+
dot.attr("node", style="filled", fontname="Helvetica", fontsize="11")
|
|
45
|
+
dot.attr("edge", fontname="Helvetica", fontsize="9", color="#475569")
|
|
46
|
+
|
|
47
|
+
for nid, attrs in graph.nodes(data=True):
|
|
48
|
+
kind = kind_str(attrs.get("kind"))
|
|
49
|
+
color = KIND_COLOR.get(kind, "#94a3b8")
|
|
50
|
+
label = str(attrs.get("name") or attrs.get("qualname") or nid[:8])
|
|
51
|
+
dot.node(
|
|
52
|
+
nid,
|
|
53
|
+
label=f"{kind}\\n{label}",
|
|
54
|
+
fillcolor=color,
|
|
55
|
+
color="#1e293b",
|
|
56
|
+
fontcolor="#0f172a",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
seen: set[tuple[str, str, str]] = set()
|
|
60
|
+
for src, dst, data in graph.edges(data=True):
|
|
61
|
+
ek = kind_str(data.get("kind"))
|
|
62
|
+
key = (src, dst, ek)
|
|
63
|
+
if key in seen:
|
|
64
|
+
continue
|
|
65
|
+
seen.add(key)
|
|
66
|
+
style = EDGE_STYLE.get(ek, "solid")
|
|
67
|
+
dot.edge(src, dst, label=ek, style=style)
|
|
68
|
+
|
|
69
|
+
rendered_path = dot.render(
|
|
70
|
+
filename=output.with_suffix("").name,
|
|
71
|
+
directory=str(output.parent),
|
|
72
|
+
cleanup=True,
|
|
73
|
+
)
|
|
74
|
+
rendered = Path(rendered_path)
|
|
75
|
+
if rendered != output:
|
|
76
|
+
rendered.replace(output)
|
|
77
|
+
return output
|
codegraph/web/server.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Lightweight stdlib HTTP server for the codegraph dashboard."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import importlib.resources as resources
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import mimetypes
|
|
9
|
+
import threading
|
|
10
|
+
import webbrowser
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from http import HTTPStatus
|
|
13
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import urlparse
|
|
17
|
+
|
|
18
|
+
import networkx as nx
|
|
19
|
+
|
|
20
|
+
from codegraph.viz.dashboard import build_dashboard_payload
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
_STATIC_PKG = "codegraph.web.static"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _read_static(name: str) -> bytes:
|
|
28
|
+
return (resources.files(_STATIC_PKG) / name).read_bytes()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DashboardState:
|
|
32
|
+
"""Holds the rebuildable graph + cached payload."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
repo_root: Path,
|
|
37
|
+
explore_dir: Path,
|
|
38
|
+
graph_loader: Callable[[], nx.MultiDiGraph],
|
|
39
|
+
rebuild: Callable[[], nx.MultiDiGraph] | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.repo_root = repo_root
|
|
42
|
+
self.explore_dir = explore_dir
|
|
43
|
+
self._graph_loader = graph_loader
|
|
44
|
+
self._rebuild = rebuild
|
|
45
|
+
self._lock = threading.Lock()
|
|
46
|
+
self._payload: dict[str, Any] | None = None
|
|
47
|
+
|
|
48
|
+
def payload(self) -> dict[str, Any]:
|
|
49
|
+
with self._lock:
|
|
50
|
+
if self._payload is None:
|
|
51
|
+
self._payload = self._build_payload(self._graph_loader())
|
|
52
|
+
return self._payload
|
|
53
|
+
|
|
54
|
+
def rebuild(self) -> dict[str, Any]:
|
|
55
|
+
with self._lock:
|
|
56
|
+
graph = (
|
|
57
|
+
self._graph_loader() if self._rebuild is None else self._rebuild()
|
|
58
|
+
)
|
|
59
|
+
self._payload = self._build_payload(graph)
|
|
60
|
+
return self._payload
|
|
61
|
+
|
|
62
|
+
def _build_payload(self, graph: nx.MultiDiGraph) -> dict[str, Any]:
|
|
63
|
+
payload = build_dashboard_payload(graph)
|
|
64
|
+
payload["repo"] = self.repo_root.name
|
|
65
|
+
payload["built_at"] = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
66
|
+
return payload
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
70
|
+
state: DashboardState # set per-instance via factory
|
|
71
|
+
|
|
72
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
73
|
+
logger.debug("%s - %s", self.address_string(), format % args)
|
|
74
|
+
|
|
75
|
+
# ---- helpers ----
|
|
76
|
+
def _send_bytes(
|
|
77
|
+
self, body: bytes, content_type: str, status: int = 200
|
|
78
|
+
) -> None:
|
|
79
|
+
self.send_response(status)
|
|
80
|
+
self.send_header("Content-Type", content_type)
|
|
81
|
+
self.send_header("Content-Length", str(len(body)))
|
|
82
|
+
self.send_header("Cache-Control", "no-store")
|
|
83
|
+
self.end_headers()
|
|
84
|
+
self.wfile.write(body)
|
|
85
|
+
|
|
86
|
+
def _send_json(self, data: dict[str, Any], status: int = 200) -> None:
|
|
87
|
+
body = json.dumps(data).encode("utf-8")
|
|
88
|
+
self._send_bytes(body, "application/json; charset=utf-8", status)
|
|
89
|
+
|
|
90
|
+
def _not_found(self) -> None:
|
|
91
|
+
self._send_bytes(b"not found", "text/plain", HTTPStatus.NOT_FOUND)
|
|
92
|
+
|
|
93
|
+
# ---- routes ----
|
|
94
|
+
def do_GET(self) -> None:
|
|
95
|
+
path = urlparse(self.path).path
|
|
96
|
+
if path == "/" or path == "/index.html":
|
|
97
|
+
self._send_bytes(_read_static("index.html"),
|
|
98
|
+
"text/html; charset=utf-8")
|
|
99
|
+
return
|
|
100
|
+
if path == "/api/data.json":
|
|
101
|
+
self._send_json(self.state.payload())
|
|
102
|
+
return
|
|
103
|
+
if path.startswith("/static/"):
|
|
104
|
+
name = path[len("/static/"):]
|
|
105
|
+
try:
|
|
106
|
+
data = _read_static(name)
|
|
107
|
+
except (FileNotFoundError, ModuleNotFoundError):
|
|
108
|
+
self._not_found()
|
|
109
|
+
return
|
|
110
|
+
ctype, _ = mimetypes.guess_type(name)
|
|
111
|
+
self._send_bytes(data, ctype or "application/octet-stream")
|
|
112
|
+
return
|
|
113
|
+
# Anything else: try the explore dir for pyvis pages.
|
|
114
|
+
candidate = self.state.explore_dir / path.lstrip("/")
|
|
115
|
+
try:
|
|
116
|
+
candidate = candidate.resolve()
|
|
117
|
+
candidate.relative_to(self.state.explore_dir.resolve())
|
|
118
|
+
except (ValueError, OSError):
|
|
119
|
+
self._not_found()
|
|
120
|
+
return
|
|
121
|
+
if candidate.is_file():
|
|
122
|
+
ctype, _ = mimetypes.guess_type(str(candidate))
|
|
123
|
+
self._send_bytes(candidate.read_bytes(),
|
|
124
|
+
ctype or "application/octet-stream")
|
|
125
|
+
return
|
|
126
|
+
self._not_found()
|
|
127
|
+
|
|
128
|
+
def do_POST(self) -> None:
|
|
129
|
+
path = urlparse(self.path).path
|
|
130
|
+
if path == "/api/rebuild":
|
|
131
|
+
try:
|
|
132
|
+
self.state.rebuild()
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
logger.exception("rebuild failed")
|
|
135
|
+
self._send_json({"error": str(exc)}, status=500)
|
|
136
|
+
return
|
|
137
|
+
self._send_json({"ok": True})
|
|
138
|
+
return
|
|
139
|
+
self._not_found()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def serve(
|
|
143
|
+
state: DashboardState,
|
|
144
|
+
*,
|
|
145
|
+
host: str = "127.0.0.1",
|
|
146
|
+
port: int = 8765,
|
|
147
|
+
open_browser: bool = True,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Block on the dashboard HTTP server."""
|
|
150
|
+
handler_cls = type("_BoundHandler", (_Handler,), {"state": state})
|
|
151
|
+
server = ThreadingHTTPServer((host, port), handler_cls)
|
|
152
|
+
url = f"http://{host}:{port}/"
|
|
153
|
+
print(f"\n codegraph dashboard ready at \033[36m{url}\033[0m")
|
|
154
|
+
print(" press Ctrl+C to stop\n")
|
|
155
|
+
if open_browser:
|
|
156
|
+
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
|
|
157
|
+
try:
|
|
158
|
+
server.serve_forever()
|
|
159
|
+
except KeyboardInterrupt:
|
|
160
|
+
print("\n shutting down...")
|
|
161
|
+
finally:
|
|
162
|
+
server.shutdown()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
__all__ = ["DashboardState", "serve"]
|