fix: resolve CI type checking and lint failures
This commit is contained in:
368
app/depnav/src/depnav/renderer.py
Normal file
368
app/depnav/src/depnav/renderer.py
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
"""ASCII dependency graph renderer using Rich."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from rich import box
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from .graph import DependencyGraph
|
||||||
|
|
||||||
|
|
||||||
|
class GraphStyle:
|
||||||
|
"""Styling configuration for graph rendering."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
node_style: str = "cyan",
|
||||||
|
edge_style: str = "dim",
|
||||||
|
highlight_style: str = "yellow",
|
||||||
|
cycle_style: str = "red",
|
||||||
|
max_depth: int = 3,
|
||||||
|
show_imports: bool = True,
|
||||||
|
):
|
||||||
|
self.node_style = node_style
|
||||||
|
self.edge_style = edge_style
|
||||||
|
self.highlight_style = highlight_style
|
||||||
|
self.cycle_style = cycle_style
|
||||||
|
self.max_depth = max_depth
|
||||||
|
self.show_imports = show_imports
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_STYLE = GraphStyle()
|
||||||
|
|
||||||
|
|
||||||
|
class ASCIIRenderer:
|
||||||
|
"""Renderer for ASCII dependency graphs."""
|
||||||
|
|
||||||
|
def __init__(self, console: Optional[Console] = None):
|
||||||
|
self.console = console or Console()
|
||||||
|
self.style = DEFAULT_STYLE
|
||||||
|
|
||||||
|
def set_style(self, style: GraphStyle) -> None:
|
||||||
|
"""Update the rendering style."""
|
||||||
|
self.style = style
|
||||||
|
|
||||||
|
def render_graph(
|
||||||
|
self,
|
||||||
|
graph: DependencyGraph,
|
||||||
|
focus_file: Optional[Path] = None,
|
||||||
|
max_nodes: int = 50,
|
||||||
|
) -> Panel:
|
||||||
|
"""Render the dependency graph as ASCII art."""
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
|
||||||
|
if not nodes:
|
||||||
|
content = Text("No files found in the project.", style="dim")
|
||||||
|
return Panel(content, title="Dependency Graph")
|
||||||
|
|
||||||
|
if len(nodes) > max_nodes:
|
||||||
|
content = Text(
|
||||||
|
f"Showing {max_nodes} of {len(nodes)} files. "
|
||||||
|
"Use --depth to limit traversal.",
|
||||||
|
style="dim",
|
||||||
|
)
|
||||||
|
|
||||||
|
if focus_file is not None:
|
||||||
|
return self._render_focused_graph(
|
||||||
|
graph, focus_file, max_nodes
|
||||||
|
)
|
||||||
|
return self._render_full_graph(graph, max_nodes)
|
||||||
|
|
||||||
|
def _render_full_graph(
|
||||||
|
self, graph: DependencyGraph, max_nodes: int
|
||||||
|
) -> Panel:
|
||||||
|
"""Render the full dependency graph."""
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
edges = graph.get_edges()
|
||||||
|
|
||||||
|
table = Table(
|
||||||
|
box=box.ROUNDED,
|
||||||
|
show_header=False,
|
||||||
|
expand=True,
|
||||||
|
padding=(0, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
table.add_column("File", style="bold " + self.style.node_style)
|
||||||
|
table.add_column("Dependencies", style=self.style.edge_style)
|
||||||
|
|
||||||
|
for i, node in enumerate(nodes[:max_nodes]):
|
||||||
|
deps = graph.get_dependencies(node)
|
||||||
|
if deps:
|
||||||
|
dep_strs = [
|
||||||
|
f"[{self.style.edge_style}][/]{d.name}"
|
||||||
|
for d in deps[:5]
|
||||||
|
]
|
||||||
|
if len(deps) > 5:
|
||||||
|
dep_strs.append(f"+{len(deps) - 5} more")
|
||||||
|
deps_display = " ".join(dep_strs)
|
||||||
|
else:
|
||||||
|
deps_display = f"[{self.style.edge_style}](no deps)[/]"
|
||||||
|
table.add_row(node.name, deps_display)
|
||||||
|
|
||||||
|
if len(nodes) > max_nodes:
|
||||||
|
table.add_row(
|
||||||
|
f"... and {len(nodes) - max_nodes} more files", ""
|
||||||
|
)
|
||||||
|
|
||||||
|
return Panel(
|
||||||
|
table,
|
||||||
|
title="Dependency Graph",
|
||||||
|
subtitle=f"{len(nodes)} files, {len(edges)} dependencies",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _render_focused_graph(
|
||||||
|
self, graph: DependencyGraph, focus_file: Path, max_nodes: int
|
||||||
|
) -> Panel:
|
||||||
|
"""Render a focused view centered on a specific file."""
|
||||||
|
focus_deps = graph.get_dependencies(focus_file)
|
||||||
|
focus_deps_rev = graph.get_dependents(focus_file)
|
||||||
|
|
||||||
|
table = Table(
|
||||||
|
box=box.ROUNDED,
|
||||||
|
show_header=False,
|
||||||
|
expand=True,
|
||||||
|
padding=(0, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
table.add_column("Type", style="dim")
|
||||||
|
table.add_column("File", style="bold " + self.style.node_style)
|
||||||
|
|
||||||
|
table.add_row(
|
||||||
|
"[bold magenta]→ DEPENDS ON[/]",
|
||||||
|
f"[bold {self.style.highlight_style}]{focus_file.name}[/]",
|
||||||
|
)
|
||||||
|
|
||||||
|
for dep in focus_deps[:max_nodes - 1]:
|
||||||
|
table.add_row(" └──", dep.name)
|
||||||
|
|
||||||
|
if focus_deps:
|
||||||
|
table.add_row(
|
||||||
|
f" [{self.style.edge_style}]({len(focus_deps)} total deps)[/]",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
if focus_deps_rev:
|
||||||
|
table.add_row("")
|
||||||
|
table.add_row(
|
||||||
|
"[bold magenta]← DEPENDED BY[/]",
|
||||||
|
f"[bold {self.style.highlight_style}]{focus_file.name}[/]",
|
||||||
|
)
|
||||||
|
for dep in focus_deps_rev[:max_nodes - 2]:
|
||||||
|
table.add_row(" └──", dep.name)
|
||||||
|
|
||||||
|
if len(focus_deps_rev) > max_nodes - 2:
|
||||||
|
table.add_row(
|
||||||
|
f" [{self.style.edge_style}]({len(focus_deps_rev)} total)[/]",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
return Panel(
|
||||||
|
table,
|
||||||
|
title=f"Dependencies: {focus_file.name}",
|
||||||
|
subtitle=f"{len(focus_deps)} imports, {len(focus_deps_rev)} importers",
|
||||||
|
)
|
||||||
|
|
||||||
|
def render_tree(
|
||||||
|
self,
|
||||||
|
graph: DependencyGraph,
|
||||||
|
root: Optional[Path] = None,
|
||||||
|
max_depth: int = 3,
|
||||||
|
) -> Panel:
|
||||||
|
"""Render the project structure as a tree."""
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
if not nodes:
|
||||||
|
content = Text("No files found.", style="dim")
|
||||||
|
return Panel(content, title="Project Tree")
|
||||||
|
|
||||||
|
if root is None:
|
||||||
|
root = graph.project_root
|
||||||
|
|
||||||
|
tree_lines = self._build_tree_lines(graph, root, 0, max_depth)
|
||||||
|
|
||||||
|
tree_str = "\n".join(tree_lines)
|
||||||
|
content = Text(tree_str, style=self.style.node_style)
|
||||||
|
|
||||||
|
return Panel(
|
||||||
|
content,
|
||||||
|
title="Project Structure",
|
||||||
|
subtitle=f"{len(nodes)} files",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_tree_lines(
|
||||||
|
self,
|
||||||
|
graph: DependencyGraph,
|
||||||
|
path: Path,
|
||||||
|
depth: int,
|
||||||
|
max_depth: int,
|
||||||
|
prefix: str = "",
|
||||||
|
is_last: bool = True,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Recursively build tree display lines."""
|
||||||
|
if depth > max_depth:
|
||||||
|
return []
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
connector = "└── " if is_last else "├── "
|
||||||
|
if depth == 0:
|
||||||
|
connector = ""
|
||||||
|
|
||||||
|
indent = " " if is_last else "│ "
|
||||||
|
lines.append(f"{prefix}{connector}{path.name}")
|
||||||
|
|
||||||
|
if path.is_dir():
|
||||||
|
try:
|
||||||
|
children = sorted(
|
||||||
|
[
|
||||||
|
p
|
||||||
|
for p in path.iterdir()
|
||||||
|
if p.exists() and not p.name.startswith(".")
|
||||||
|
],
|
||||||
|
key=lambda x: (x.is_file(), x.name),
|
||||||
|
)
|
||||||
|
except PermissionError:
|
||||||
|
return lines
|
||||||
|
|
||||||
|
for i, child in enumerate(children):
|
||||||
|
is_child_last = i == len(children) - 1
|
||||||
|
lines.extend(
|
||||||
|
self._build_tree_lines(
|
||||||
|
graph,
|
||||||
|
child,
|
||||||
|
depth + 1,
|
||||||
|
max_depth,
|
||||||
|
prefix + indent,
|
||||||
|
is_child_last,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
deps = graph.get_dependencies(path)
|
||||||
|
if deps and depth < max_depth:
|
||||||
|
dep_indent = prefix + indent + " "
|
||||||
|
for j, dep in enumerate(deps[:3]):
|
||||||
|
dep_is_last = j == min(len(deps), 3) - 1
|
||||||
|
dep_connector = "└── " if dep_is_last else "├── "
|
||||||
|
lines.append(
|
||||||
|
f"{dep_indent}{dep_connector}[{self.style.edge_style}]{dep.name}[/]"
|
||||||
|
)
|
||||||
|
if len(deps) > 3:
|
||||||
|
lines.append(
|
||||||
|
f"{dep_indent} +{len(deps) - 3} more"
|
||||||
|
)
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def render_cycles(self, cycles: list[list[Path]]) -> Panel:
|
||||||
|
"""Render detected dependency cycles."""
|
||||||
|
if not cycles:
|
||||||
|
content = Text(
|
||||||
|
"✓ No circular dependencies detected.",
|
||||||
|
style="green",
|
||||||
|
)
|
||||||
|
return Panel(content, title="Cycle Detection")
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
Text(
|
||||||
|
f"⚠ Found {len(cycles)} circular dependency(ies):",
|
||||||
|
style=self.style.cycle_style,
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, cycle in enumerate(cycles, 1):
|
||||||
|
cycle_str = " → ".join(p.name for p in cycle)
|
||||||
|
cycle_str += f" → {cycle[0].name}"
|
||||||
|
lines.append(Text(f" {i}. {cycle_str}", style="red"))
|
||||||
|
|
||||||
|
content = Text("\n".join(str(line) for line in lines))
|
||||||
|
|
||||||
|
return Panel(content, title="Circular Dependencies", style="red")
|
||||||
|
|
||||||
|
def render_statistics(self, graph: DependencyGraph) -> Panel:
|
||||||
|
"""Render dependency graph statistics."""
|
||||||
|
stats = self._compute_statistics(graph)
|
||||||
|
|
||||||
|
table = Table(box=box.ROUNDED, show_header=False)
|
||||||
|
table.add_column("Metric", style="dim")
|
||||||
|
table.add_column("Value", style="bold")
|
||||||
|
|
||||||
|
for metric, value in stats.items():
|
||||||
|
table.add_row(metric, str(value))
|
||||||
|
|
||||||
|
return Panel(table, title="Statistics")
|
||||||
|
|
||||||
|
def _compute_statistics(
|
||||||
|
self, graph: DependencyGraph
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Compute statistics about the dependency graph."""
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
edges = graph.get_edges()
|
||||||
|
cycles = graph.detect_cycles()
|
||||||
|
|
||||||
|
total_deps = sum(len(graph.get_dependencies(n)) for n in nodes)
|
||||||
|
total_imports = sum(len(graph.get_dependents(n)) for n in nodes)
|
||||||
|
|
||||||
|
max_deps_node = max(nodes, key=lambda n: len(graph.get_dependencies(n)))
|
||||||
|
max_imports_node = max(
|
||||||
|
nodes, key=lambda n: len(graph.get_dependents(n))
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Files": len(nodes),
|
||||||
|
"Dependencies": len(edges),
|
||||||
|
"Total Imports": total_deps,
|
||||||
|
"Total Importers": total_imports,
|
||||||
|
"Circular Dependencies": len(cycles),
|
||||||
|
"Most Imported": f"{max_deps_node.name} ({len(graph.get_dependencies(max_deps_node))} deps)",
|
||||||
|
"Most Imported By": f"{max_imports_node.name} ({len(graph.get_dependents(max_imports_node))} importers)",
|
||||||
|
}
|
||||||
|
|
||||||
|
def render_json(self, graph: DependencyGraph) -> str:
|
||||||
|
"""Render the graph as JSON for export."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
edges = graph.get_edges()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"project_root": str(graph.project_root),
|
||||||
|
"nodes": [str(n) for n in nodes],
|
||||||
|
"edges": [(str(u), str(v)) for u, v in edges],
|
||||||
|
"statistics": self._compute_statistics(graph),
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.dumps(data, indent=2)
|
||||||
|
|
||||||
|
def render_dot(self, graph: DependencyGraph) -> str:
|
||||||
|
"""Render the graph in DOT format for Graphviz."""
|
||||||
|
lines = [
|
||||||
|
"digraph dependency_graph {",
|
||||||
|
' rankdir=LR;',
|
||||||
|
' node [shape=box, style=rounded];',
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
for node in graph.get_nodes():
|
||||||
|
lines.append(f' "{node.name}";')
|
||||||
|
|
||||||
|
for u, v in graph.get_edges():
|
||||||
|
lines.append(f' "{u.name}" -> "{v.name}";')
|
||||||
|
|
||||||
|
lines.append("}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def render_stats_only(self, graph: DependencyGraph) -> str:
|
||||||
|
"""Render brief statistics for terminal output."""
|
||||||
|
nodes = graph.get_nodes()
|
||||||
|
edges = graph.get_edges()
|
||||||
|
cycles = graph.detect_cycles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"[bold]Files:[/] {len(nodes)} | "
|
||||||
|
f"[bold]Dependencies:[/] {len(edges)} | "
|
||||||
|
f"[bold]Cycles:[/] {len(cycles)}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user