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:21 +00:00
parent bebc1de023
commit f78dd52382

View File

@@ -0,0 +1,444 @@
"""Main CLI interface for depnav."""
from pathlib import Path
from typing import Optional
import click
from rich.console import Console
from rich.panel import Panel
from .config import Config, load_config
from .detector import CycleDetector
from .graph import DependencyGraph
from .navigator import Navigator
from .renderer import ASCIIRenderer, GraphStyle
console = Console()
@click.group()
@click.option(
"--config",
"-c",
type=click.Path(exists=True, path_type=Path),
help="Path to configuration file",
)
@click.option(
"--theme",
type=click.Choice(["default", "dark", "light", "mono"]),
default="default",
help="Color theme",
)
@click.option(
"--pager/--no-pager",
default=True,
help="Use pager for output",
)
@click.pass_context
def main(
ctx: click.Context,
config: Optional[Path],
theme: str,
pager: bool,
):
"""Depnav - CLI Dependency Graph Navigator."""
ctx.ensure_object(dict)
ctx.obj["config"] = config
ctx.obj["theme"] = theme
ctx.obj["pager"] = pager
cfg = load_config(config)
ctx.obj["config_obj"] = cfg
@main.command("graph")
@click.argument(
"path",
type=click.Path(exists=True, file_okay=True, dir_okay=True, path_type=Path),
default=Path("."),
)
@click.option(
"--depth",
"-d",
type=int,
default=3,
help="Maximum traversal depth",
)
@click.option(
"--focus",
"-f",
type=str,
default=None,
help="Focus on a specific file",
)
@click.option(
"--max-nodes",
"-m",
type=int,
default=50,
help="Maximum number of nodes to display",
)
@click.option(
"--output",
"-o",
type=click.Choice(["ascii", "json", "dot"]),
default="ascii",
help="Output format",
)
@click.pass_context
def graph_command(
ctx: click.Context,
path: Path,
depth: int,
focus: Optional[str],
max_nodes: int,
output: str,
):
"""Generate a dependency graph for the project."""
cfg: Optional[Config] = ctx.obj.get("config_obj")
if cfg:
depth = cfg.get_depth() or depth
max_nodes = cfg.get_max_nodes() or max_nodes
graph = DependencyGraph(path if path.is_dir() else path.parent)
graph.build_from_directory(
directory=path if path.is_dir() else None,
max_depth=depth,
)
theme = ctx.obj.get("theme", "default")
style = _get_style_from_theme(theme)
renderer = ASCIIRenderer(console)
renderer.set_style(style)
if output == "json":
click.echo(renderer.render_json(graph))
elif output == "dot":
click.echo(renderer.render_dot(graph))
else:
focus_path = None
if focus:
focus_path = graph.get_node_by_name(focus)
panel = renderer.render_graph(graph, focus_path, max_nodes)
console.print(panel, soft_wrap=True)
@main.command("tree")
@click.argument(
"path",
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
default=Path("."),
)
@click.option(
"--depth",
"-d",
type=int,
default=2,
help="Maximum tree depth",
)
@click.pass_context
def tree_command(
ctx: click.Context,
path: Path,
depth: int,
):
"""Show the project structure as a tree."""
graph = DependencyGraph(path)
graph.build_from_directory(directory=path, max_depth=depth + 2)
theme = ctx.obj.get("theme", "default")
style = _get_style_from_theme(theme)
renderer = ASCIIRenderer(console)
renderer.set_style(style)
panel = renderer.render_tree(graph, path, depth)
console.print(panel, soft_wrap=True)
@main.command("cycles")
@click.argument(
"path",
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
default=Path("."),
)
@click.option(
"--report",
"-r",
type=click.Path(path_type=Path),
default=None,
help="Export report to file",
)
@click.pass_context
def cycles_command(
ctx: click.Context,
path: Path,
report: Optional[Path],
):
"""Detect and report circular dependencies."""
graph = DependencyGraph(path)
graph.build_from_directory()
detector = CycleDetector(graph)
cycles = detector.detect_all_cycles()
theme = ctx.obj.get("theme", "default")
style = _get_style_from_theme(theme)
renderer = ASCIIRenderer(console)
renderer.set_style(style)
panel = renderer.render_cycles(cycles)
console.print(panel, soft_wrap=True)
if report:
detector.export_cycle_report(report)
click.echo(f"Report exported to {report}")
@main.command("navigate")
@click.argument(
"path",
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
default=Path("."),
)
@click.option(
"--file",
"-f",
type=str,
default=None,
help="Initial file to navigate to",
)
@click.pass_context
def navigate_command(
ctx: click.Context,
path: Path,
file: Optional[str],
):
"""Start interactive navigation mode."""
graph = DependencyGraph(path)
graph.build_from_directory()
theme = ctx.obj.get("theme", "default")
style = _get_style_from_theme(theme)
navigator = Navigator(graph, console, style)
if file:
file_path = graph.get_node_by_name(file)
if file_path:
navigator.navigate_to(file_path)
_run_interactive_mode(navigator, graph)
def _run_interactive_mode(navigator: Navigator, graph: DependencyGraph):
"""Run the interactive navigation mode."""
click.echo("Interactive navigation mode. Type 'help' for commands.")
while True:
try:
cmd = click.prompt(
"depnav",
type=str,
default="",
show_default=False,
).strip()
except click.exceptions.ClickException:
break
if not cmd:
continue
parts = cmd.split()
action = parts[0].lower()
if action in ("quit", "exit", "q"):
break
elif action == "help":
_show_help()
elif action == "current":
click.echo(navigator.show_current())
elif action == "deps" and len(parts) > 1:
target = graph.get_node_by_name(parts[1])
if target:
click.echo(navigator.show_dependencies(target))
elif action == "imported" and len(parts) > 1:
target = graph.get_node_by_name(parts[1])
if target:
click.echo(navigator.show_dependents(target))
elif action == "path" and len(parts) > 2:
source = graph.get_node_by_name(parts[1])
target = graph.get_node_by_name(parts[2])
if source and target:
click.echo(navigator.find_path(source, target))
elif action == "search" and len(parts) > 1:
results = navigator.search(parts[1])
for r in results:
click.echo(f" {r}")
elif action == "stats":
renderer = ASCIIRenderer(console)
console.print(renderer.render_statistics(graph))
elif action == "back":
prev = navigator.back()
if prev:
click.echo(f"Back to: {prev}")
else:
click.echo("No previous file")
elif action == "list":
nodes = graph.get_nodes()
for n in nodes[:20]:
click.echo(f" {n}")
if len(nodes) > 20:
click.echo(f" ... and {len(nodes) - 20} more")
else:
click.echo(f"Unknown command: {action}. Type 'help' for available commands.")
def _show_help():
"""Show help for interactive mode."""
help_text = """
Available commands:
current Show current file info
deps <file> Show dependencies of a file
imported <file> Show files importing a file
path <from> <to> Show shortest path between files
search <query> Search for files
stats Show graph statistics
back Go back to previous file
list List all files
help Show this help
quit Exit
"""
click.echo(help_text)
@main.command("stats")
@click.argument(
"path",
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
default=Path("."),
)
@click.pass_context
def stats_command(ctx: click.Context, path: Path):
"""Show dependency graph statistics."""
graph = DependencyGraph(path)
graph.build_from_directory()
renderer = ASCIIRenderer(console)
console.print(renderer.render_statistics(graph))
@main.command("info")
@click.argument(
"file",
type=click.Path(exists=True, path_type=Path),
)
@click.option(
"--path",
"-p",
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
default=Path("."),
help="Project root path",
)
@click.pass_context
def info_command(
ctx: click.Context,
file: Path,
path: Path,
):
"""Show detailed information about a file."""
graph = DependencyGraph(path)
graph.build_from_directory()
if not str(file).startswith("/"):
file = path / file
try:
rel_file = file.relative_to(path)
except ValueError:
rel_file = file
navigator = Navigator(graph)
info = navigator.get_file_info(rel_file)
console.print(Panel(f"{info}", title=f"Info: {rel_file.name}"))
@main.command("export")
@click.argument(
"path",
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
default=Path("."),
)
@click.option(
"--format",
"-f",
type=click.Choice(["json", "dot"]),
default="json",
help="Export format",
)
@click.option(
"--output",
"-o",
type=click.Path(path_type=Path),
default=Path("depgraph.json"),
help="Output file",
)
@click.pass_context
def export_command(
ctx: click.Context,
path: Path,
format: str,
output: Path,
):
"""Export the dependency graph to a file."""
graph = DependencyGraph(path)
graph.build_from_directory()
renderer = ASCIIRenderer(console)
if format == "json":
content = renderer.render_json(graph)
else:
content = renderer.render_dot(graph)
output.write_text(content)
click.echo(f"Exported to {output}")
def _get_style_from_theme(theme: str) -> GraphStyle:
"""Get a GraphStyle from a theme name."""
themes = {
"default": GraphStyle(
node_style="cyan",
edge_style="dim",
highlight_style="yellow",
cycle_style="red",
),
"dark": GraphStyle(
node_style="green",
edge_style="dim green",
highlight_style="white",
cycle_style="red",
),
"light": GraphStyle(
node_style="blue",
edge_style="dim blue",
highlight_style="magenta",
cycle_style="red",
),
"mono": GraphStyle(
node_style="",
edge_style="dim",
highlight_style="bold",
cycle_style="bold",
),
}
return themes.get(theme, themes["default"])
if __name__ == "__main__":
main()