Initial upload: TermDiagram v0.1.0
This commit is contained in:
423
src/termdiagram/cli.py
Normal file
423
src/termdiagram/cli.py
Normal file
@@ -0,0 +1,423 @@
|
||||
import os
|
||||
import sys
|
||||
import click
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
|
||||
from . import __version__
|
||||
from .parser import CodeParser
|
||||
from .generators import AsciiDiagramGenerator, MermaidDiagramGenerator, SvgDiagramGenerator
|
||||
from .ui import TermDiagramTUI
|
||||
from .git import GitHistoryTracker, ArchitectureDiffAnalyzer
|
||||
from .utils import Config
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version=__version__, prog_name="termdiagram")
|
||||
@click.option(
|
||||
"--config",
|
||||
"-c",
|
||||
type=click.Path(exists=False),
|
||||
help="Path to configuration file",
|
||||
)
|
||||
@click.pass_context
|
||||
def main(ctx: click.Context, config: Optional[str]):
|
||||
"""TermDiagram - Generate architecture diagrams from codebases."""
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["config"] = Config()
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("path", type=click.Path(exists=True), default=".")
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
type=click.Path(),
|
||||
help="Output file path",
|
||||
)
|
||||
@click.option(
|
||||
"--format",
|
||||
"-f",
|
||||
type=click.Choice(["ascii", "mermaid", "svg", "dot"]),
|
||||
default="ascii",
|
||||
help="Output format",
|
||||
)
|
||||
@click.option(
|
||||
"--extensions",
|
||||
"-e",
|
||||
multiple=True,
|
||||
help="File extensions to parse",
|
||||
)
|
||||
@click.option(
|
||||
"--exclude",
|
||||
multiple=True,
|
||||
help="Patterns to exclude",
|
||||
)
|
||||
@click.option(
|
||||
"--no-imports",
|
||||
is_flag=True,
|
||||
help="Don't show imports",
|
||||
)
|
||||
@click.option(
|
||||
"--no-methods",
|
||||
is_flag=True,
|
||||
help="Don't show methods",
|
||||
)
|
||||
@click.option(
|
||||
"--max-methods",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Maximum methods to show per class",
|
||||
)
|
||||
def diagram(
|
||||
path: str,
|
||||
output: Optional[str],
|
||||
format: str,
|
||||
extensions: tuple,
|
||||
exclude: tuple,
|
||||
no_imports: bool,
|
||||
no_methods: bool,
|
||||
max_methods: int,
|
||||
):
|
||||
"""Generate an architecture diagram for a codebase."""
|
||||
parser = CodeParser()
|
||||
path = os.path.abspath(path)
|
||||
|
||||
click.echo(f"Parsing {path}...")
|
||||
|
||||
ext_list = list(extensions) if extensions else None
|
||||
exclude_list = list(exclude) if exclude else None
|
||||
|
||||
modules = parser.parse_directory(path, ext_list, exclude_list)
|
||||
|
||||
if not modules:
|
||||
click.echo("No modules found. Check your path and extensions.")
|
||||
return
|
||||
|
||||
click.echo(f"Found {len(modules)} modules")
|
||||
|
||||
stats = parser.get_statistics(modules)
|
||||
click.echo(f" {stats['total_modules']} modules")
|
||||
click.echo(f" {stats['total_classes']} classes")
|
||||
click.echo(f" {stats['total_functions']} functions")
|
||||
click.echo(f" {stats['total_methods']} methods")
|
||||
|
||||
if format == "ascii":
|
||||
generator = AsciiDiagramGenerator()
|
||||
result = generator.generate(modules)
|
||||
|
||||
if output:
|
||||
with open(output, "w") as f:
|
||||
f.write(result)
|
||||
click.echo(f"ASCII diagram saved to {output}")
|
||||
else:
|
||||
click.echo("")
|
||||
click.echo(result)
|
||||
|
||||
elif format == "mermaid":
|
||||
generator = MermaidDiagramGenerator()
|
||||
result = generator.generate_class_diagram(modules)
|
||||
|
||||
if output:
|
||||
with open(output, "w") as f:
|
||||
f.write(result)
|
||||
click.echo(f"Mermaid diagram saved to {output}")
|
||||
else:
|
||||
click.echo("")
|
||||
click.echo(result)
|
||||
|
||||
elif format in ("svg", "dot"):
|
||||
generator = SvgDiagramGenerator()
|
||||
|
||||
if format == "dot":
|
||||
result = generator.generate_dot(modules)
|
||||
|
||||
if output:
|
||||
with open(output, "w") as f:
|
||||
f.write(result)
|
||||
click.echo(f"DOT file saved to {output}")
|
||||
else:
|
||||
click.echo(result)
|
||||
else:
|
||||
svg = generator.generate(modules)
|
||||
if svg:
|
||||
if output:
|
||||
generator.save_svg(modules, output)
|
||||
click.echo(f"SVG saved to {output}")
|
||||
else:
|
||||
click.echo("SVG generation requires output file")
|
||||
else:
|
||||
click.echo("Graphviz not available. Install Graphviz and pygraphviz.")
|
||||
click.echo(" Falling back to Mermaid format...")
|
||||
result = generator.generate_dot(modules)
|
||||
click.echo(result)
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("path", type=click.Path(exists=True), default=".")
|
||||
@click.option(
|
||||
"--format",
|
||||
"-f",
|
||||
type=click.Choice(["json", "yaml"]),
|
||||
default="json",
|
||||
help="Output format",
|
||||
)
|
||||
def json(
|
||||
path: str,
|
||||
format: str,
|
||||
):
|
||||
"""Export parsed data as JSON or YAML."""
|
||||
parser = CodeParser()
|
||||
path = os.path.abspath(path)
|
||||
|
||||
click.echo(f"Parsing {path}...")
|
||||
|
||||
modules = parser.parse_directory(path)
|
||||
|
||||
if not modules:
|
||||
click.echo("No modules found.")
|
||||
return
|
||||
|
||||
if format == "json":
|
||||
import json
|
||||
|
||||
data = {
|
||||
"modules": [m.to_dict() for m in modules],
|
||||
"statistics": parser.get_statistics(modules),
|
||||
}
|
||||
|
||||
click.echo(json.dumps(data, indent=2))
|
||||
else:
|
||||
import yaml
|
||||
|
||||
data = {
|
||||
"modules": [m.to_dict() for m in modules],
|
||||
"statistics": parser.get_statistics(modules),
|
||||
}
|
||||
|
||||
click.echo(yaml.dump(data, default_flow_style=False))
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("path", type=click.Path(exists=True), default=".")
|
||||
def stats(path: str):
|
||||
"""Show statistics about the codebase."""
|
||||
parser = CodeParser()
|
||||
path = os.path.abspath(path)
|
||||
|
||||
click.echo(f"Analyzing {path}...")
|
||||
|
||||
modules = parser.parse_directory(path)
|
||||
|
||||
if not modules:
|
||||
click.echo("No modules found.")
|
||||
return
|
||||
|
||||
stats = parser.get_statistics(modules)
|
||||
|
||||
click.echo("")
|
||||
click.echo("Codebase Statistics")
|
||||
click.echo("=" * 40)
|
||||
click.echo(f" Modules: {stats['total_modules']}")
|
||||
click.echo(f" Classes: {stats['total_classes']}")
|
||||
click.echo(f" Functions: {stats['total_functions']}")
|
||||
click.echo(f" Methods: {stats['total_methods']}")
|
||||
click.echo("")
|
||||
click.echo("Languages:")
|
||||
for lang, count in stats["languages"].items():
|
||||
click.echo(f" - {lang}: {count} modules")
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("path", type=click.Path(exists=True), default=".")
|
||||
def tree(path: str):
|
||||
"""Display the project structure as a tree."""
|
||||
parser = CodeParser()
|
||||
from .ui import TreeView
|
||||
from rich import print as rprint
|
||||
|
||||
path = os.path.abspath(path)
|
||||
|
||||
click.echo(f"Parsing {path}...")
|
||||
|
||||
modules = parser.parse_directory(path)
|
||||
|
||||
if not modules:
|
||||
click.echo("No modules found.")
|
||||
return
|
||||
|
||||
tree_view = TreeView()
|
||||
tree = tree_view.create_tree(modules, show_methods=True)
|
||||
rprint(tree)
|
||||
|
||||
|
||||
@main.command(context_settings={"ignore_unknown_options": True})
|
||||
@click.argument("path", type=click.Path(exists=True), default=".")
|
||||
@click.option(
|
||||
"--search",
|
||||
"-s",
|
||||
help="Search for symbols",
|
||||
)
|
||||
def search(path: str, search: Optional[str]):
|
||||
"""Search for symbols in the codebase."""
|
||||
parser = CodeParser()
|
||||
from .ui import FuzzySearcher
|
||||
|
||||
path = os.path.abspath(path)
|
||||
|
||||
click.echo(f"Parsing {path}...")
|
||||
|
||||
modules = parser.parse_directory(path)
|
||||
|
||||
if not modules:
|
||||
click.echo("No modules found.")
|
||||
return
|
||||
|
||||
all_symbols = []
|
||||
for module in modules:
|
||||
all_symbols.append(module)
|
||||
all_symbols.extend(module.classes)
|
||||
all_symbols.extend(module.functions)
|
||||
|
||||
searcher = FuzzySearcher()
|
||||
searcher.build_index(all_symbols)
|
||||
|
||||
if search:
|
||||
results = searcher.search(search)
|
||||
click.echo(f"\nSearch results for '{search}':")
|
||||
click.echo("-" * 50)
|
||||
|
||||
for result in results[:10]:
|
||||
click.echo(
|
||||
f" {result.name} ({result.symbol_type}) - {result.path}:{result.line_number}"
|
||||
)
|
||||
else:
|
||||
count = searcher.get_symbol_count()
|
||||
click.echo(f"{count} symbols indexed")
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("path", type=click.Path(exists=True), default=".")
|
||||
def interactive(path: str):
|
||||
"""Launch interactive TUI mode."""
|
||||
from .ui import TermDiagramTUI
|
||||
|
||||
path = os.path.abspath(path)
|
||||
|
||||
if not os.path.isdir(path):
|
||||
click.echo(f"{path} is not a directory")
|
||||
return
|
||||
|
||||
tui = TermDiagramTUI()
|
||||
tui.run(path)
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("path", type=click.Path(exists=True), default=".")
|
||||
@click.option(
|
||||
"--since",
|
||||
type=str,
|
||||
help="Show changes since commit or date",
|
||||
)
|
||||
@click.option(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=20,
|
||||
help="Limit number of commits",
|
||||
)
|
||||
def history(path: str, since: Optional[str], limit: int):
|
||||
"""Show git history for the project."""
|
||||
tracker = GitHistoryTracker(path)
|
||||
|
||||
if not tracker.is_git_repo():
|
||||
click.echo(f"{path} is not a git repository")
|
||||
return
|
||||
|
||||
click.echo(f"Git history for {path}")
|
||||
click.echo("=" * 50)
|
||||
|
||||
commits = tracker.get_commit_history(limit=limit)
|
||||
|
||||
for commit in commits:
|
||||
click.echo(f" {commit.hash} - {commit.message[:50]}")
|
||||
click.echo(f" {commit.author} - {commit.date.strftime('%Y-%m-%d')}")
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("path", type=click.Path(exists=True), default=".")
|
||||
@click.argument("old_commit", type=str)
|
||||
@click.argument("new_commit", type=str)
|
||||
def diff(path: str, old_commit: str, new_commit: str):
|
||||
"""Show architecture changes between two commits."""
|
||||
analyzer = ArchitectureDiffAnalyzer(path)
|
||||
|
||||
if not analyzer.initialize():
|
||||
click.echo(f"{path} is not a git repository")
|
||||
return
|
||||
|
||||
report = analyzer.get_change_report(old_commit, new_commit)
|
||||
click.echo(report)
|
||||
|
||||
|
||||
@main.command()
|
||||
def version():
|
||||
"""Show version information."""
|
||||
click.echo(f"TermDiagram version {__version__}")
|
||||
|
||||
|
||||
@main.command()
|
||||
def check():
|
||||
"""Check system dependencies."""
|
||||
click.echo("Checking dependencies...")
|
||||
|
||||
click.echo("")
|
||||
click.echo("Python packages:")
|
||||
try:
|
||||
import rich
|
||||
click.echo(f" rich: {rich.__version__}")
|
||||
except ImportError:
|
||||
click.echo(" rich: not installed")
|
||||
|
||||
try:
|
||||
import tree_sitter
|
||||
click.echo(f" tree-sitter: {tree_sitter.__version__}")
|
||||
except ImportError:
|
||||
click.echo(" tree-sitter: not installed")
|
||||
|
||||
try:
|
||||
import rapidfuzz
|
||||
click.echo(f" rapidfuzz: {rapidfuzz.__version__}")
|
||||
except ImportError:
|
||||
click.echo(" rapidfuzz: not installed")
|
||||
|
||||
try:
|
||||
import git
|
||||
click.echo(f" gitpython: installed")
|
||||
except ImportError:
|
||||
click.echo(" gitpython: not installed")
|
||||
|
||||
try:
|
||||
import pygraphviz
|
||||
click.echo(f" pygraphviz: installed")
|
||||
except ImportError:
|
||||
click.echo(" pygraphviz: not installed (SVG generation unavailable)")
|
||||
|
||||
click.echo("")
|
||||
click.echo("System dependencies:")
|
||||
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
["dot", "-V"], capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
click.echo(" Graphviz: installed")
|
||||
else:
|
||||
click.echo(" Graphviz: version check failed")
|
||||
except FileNotFoundError:
|
||||
click.echo(" Graphviz: not found (SVG generation unavailable)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user