fix: resolve CI type checking and lint failures
This commit is contained in:
444
app/depnav/src/depnav/cli.py
Normal file
444
app/depnav/src/depnav/cli.py
Normal 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()
|
||||
Reference in New Issue
Block a user