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