fix: resolve CI type checking and lint failures
Some checks failed
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled

This commit is contained in:
2026-01-30 17:19:20 +00:00
parent 4e90bd776e
commit bebc1de023

View 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)}"
)