Initial upload: TermDiagram v0.1.0
Some checks failed
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled

This commit is contained in:
2026-01-29 22:27:57 +00:00
parent b9508a14f1
commit 2e7e6d81c5

423
src/termdiagram/cli.py Normal file
View 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()