Initial upload with CI/CD workflow
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-02-02 18:27:13 +00:00
parent 9657f46db0
commit 14ac93f22b

403
src/cli.py Normal file
View File

@@ -0,0 +1,403 @@
"""CLI interface for Code Pattern Search."""
import sys
from pathlib import Path
from typing import Any, Optional, cast
import click
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from .config import Config
from .github_client import GitHubClient, RateLimitExceededError
from .pattern_matcher import PatternMatcher, PatternLibrary, MatchLocation
from .cache_manager import CacheManager
from .exporter import Exporter
from .models import SearchResult
console = Console()
@click.group()
@click.option(
"--cache-dir",
type=click.Path(path_type=Path),
help="Custom cache directory",
)
@click.option(
"--cache-ttl",
type=int,
help="Cache TTL in seconds",
)
@click.option(
"--token",
envvar="GITHUB_TOKEN",
help="GitHub API token",
)
@click.option(
"--verbose",
"-v",
is_flag=True,
help="Enable verbose output",
)
@click.pass_context
def main(
ctx: click.Context,
cache_dir: Optional[Path],
cache_ttl: Optional[int],
token: Optional[str],
verbose: bool,
) -> None:
"""Search GitHub repositories by code patterns."""
ctx.ensure_object(dict)
ctx.obj["config"] = Config(
cache_dir=cache_dir,
cache_ttl=cache_ttl or 3600,
token=token,
verbose=verbose,
)
ctx.obj["verbose"] = verbose
@main.command()
@click.argument("pattern", type=str, default=None, required=False)
@click.option(
"--language", "-l",
type=str,
help="Filter by programming language",
)
@click.option(
"--stars-min",
type=int,
default=0,
help="Minimum star count",
)
@click.option(
"--stars-max",
type=int,
default=None,
help="Maximum star count",
)
@click.option(
"--repos",
type=int,
default=10,
help="Number of repositories to search",
)
@click.option(
"--output", "-o",
type=click.Path(path_type=Path),
help="Export results to JSON file",
)
@click.option(
"--use-cache/--no-cache",
default=True,
help="Use cached results",
)
@click.option(
"--preset",
type=str,
help="Use a preset pattern from the library",
)
@click.pass_context
def search(
ctx: click.Context,
pattern: Optional[str],
language: Optional[str],
stars_min: int,
stars_max: Optional[int],
repos: int,
output: Optional[Path],
use_cache: bool,
preset: Optional[str],
) -> None:
"""Search for a pattern in GitHub repositories."""
config: Config = ctx.obj["config"]
verbose: bool = ctx.obj.get("verbose", False)
if preset:
pattern = PatternLibrary.get_pattern(preset)
if not pattern:
console.print(f"[red]Error: Unknown preset '{preset}'[/red]")
console.print(f"Available presets: {', '.join(PatternLibrary.list_presets())}")
sys.exit(1)
elif pattern is None:
console.print("[red]Error: Pattern or --preset is required[/red]")
sys.exit(1)
try:
pattern_matcher = PatternMatcher(pattern)
except Exception as e:
console.print(f"[red]Error: Invalid regex pattern: {e}[/red]")
sys.exit(1)
cache_manager = CacheManager(
cache_dir=config.cache_dir,
ttl=config.cache_ttl,
)
github_client = GitHubClient(
token=config.token,
verbose=verbose,
)
console.print(f"Searching for pattern: [cyan]{pattern}[/cyan]")
if language:
console.print(f"Language filter: [cyan]{language}[/cyan]")
console.print(f"Star range: [cyan]{stars_min} - {stars_max or 'unlimited'}[/cyan]")
console.print()
try:
results = search_repositories(
github_client=github_client,
pattern_matcher=pattern_matcher,
language=language,
stars_min=stars_min,
stars_max=stars_max,
max_repos=repos,
cache_manager=cache_manager,
use_cache=use_cache,
verbose=verbose,
console=console,
)
if not results:
console.print("[yellow]No matches found[/yellow]")
return
sorted_results = sorted(
results,
key=lambda r: (r.score, r.total_matches),
reverse=True,
)
display_results(sorted_results, console)
if output:
Exporter.export_results(sorted_results, output)
console.print(f"\n[green]Results exported to {output}[/green]")
except RateLimitExceededError as e:
console.print("[red]Error: GitHub API rate limit exceeded[/red]")
console.print(f"Reset time: {e.reset_time}")
console.print("Tip: Set GITHUB_TOKEN environment variable for higher limits")
sys.exit(1)
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
if verbose:
raise
sys.exit(1)
def search_repositories(
github_client: GitHubClient,
pattern_matcher: PatternMatcher,
language: Optional[str],
stars_min: int,
stars_max: Optional[int],
max_repos: int,
cache_manager: CacheManager,
use_cache: bool,
verbose: bool,
console: Console,
) -> list[SearchResult]:
"""Search repositories for the pattern."""
search_key = f"search:{pattern_matcher.pattern}:{language}:{stars_min}:{stars_max}:{max_repos}"
if use_cache:
cached = cache_manager.get(search_key)
if cached:
console.print("[dim]Using cached search results[/dim]")
return cached
if verbose:
console.print("Fetching repositories...")
repos = github_client.search_repositories(
language=language,
stars_min=stars_min,
stars_max=stars_max,
per_page=max_repos,
)
if not repos:
return []
results: list[SearchResult] = []
def process_repo(repo) -> Optional[SearchResult]:
repo_name = repo.full_name
if verbose:
console.print(f" Processing {repo_name}...")
cache_key = f"repo:{repo_name}:{pattern_matcher.pattern}"
if use_cache:
cached_result = cache_manager.get(cache_key)
if cached_result:
return cached_result
try:
file_tree = github_client.get_file_tree(repo.owner.login, repo.name)
if not file_tree:
return None
matches: list[MatchLocation] = []
for item in file_tree:
item_dict = cast(dict[str, Any], item)
file_path_item = item_dict["path"]
if not pattern_matcher.matches_extension(file_path_item):
continue
content = github_client.get_file_content(
repo.owner.login,
repo.name,
file_path_item,
)
if content:
file_matches = pattern_matcher.find_matches(content, file_path_item)
matches.extend(file_matches)
if matches:
result = SearchResult(
repo_name=repo_name,
repo_url=repo.html_url,
stars=repo.stargazers_count,
description=repo.description,
language=repo.language,
matches=matches,
total_matches=len(matches),
score=calculate_score(repo.stargazers_count, len(matches)),
)
cache_manager.set(cache_key, result)
return result
except Exception as e:
if verbose:
console.print(f" [yellow]Warning: Error processing {repo_name}: {e}[/yellow]")
return None
with console.status("[bold]Searching repositories...") as status:
for repo in repos:
status.update(f"Processing {repo.full_name}...")
result = process_repo(repo)
if result:
results.append(result)
cache_manager.set(search_key, results)
return results
def calculate_score(stars: int, match_count: int) -> float:
"""Calculate relevance score based on stars and match count."""
import math
star_score = math.log10(max(stars, 1)) * 10
match_score = match_count * 5
return round(star_score + match_score, 2)
def display_results(results: list[SearchResult], console: Console) -> None:
"""Display search results in a formatted table."""
table = Table(show_header=True, header_style="bold magenta")
table.add_column("Repository", width=40)
table.add_column("Stars")
table.add_column("Matches")
table.add_column("Score")
for result in results:
repo_link = f"[link={result.repo_url}]{result.repo_name}[/link]"
table.add_row(
repo_link,
str(result.stars),
str(result.total_matches),
str(result.score),
)
console.print(Panel(table, title="Search Results", expand=False))
if len(results) > 0:
console.print(f"\n[dim]Found {len(results)} repositories with matches[/dim]")
@main.command()
def presets() -> None:
"""List available pattern presets."""
presets_list = PatternLibrary.list_presets()
table = Table(show_header=True, header_style="bold magenta")
table.add_column("Preset Name")
table.add_column("Pattern")
for name in presets_list:
pattern = PatternLibrary.get_pattern(name)
table.add_row(name, pattern)
console.print(Panel(table, title="Available Presets", expand=False))
@main.command()
@click.option(
"--all",
is_flag=True,
help="Show all cached entries",
)
@click.option(
"--clear",
is_flag=True,
help="Clear all cached data",
)
def cache(all: bool, clear: bool) -> None:
"""Manage cache."""
cache_manager = CacheManager()
if clear:
cache_manager.clear()
console.print("[green]Cache cleared successfully[/green]")
return
stats = cache_manager.get_stats()
if all:
entries = cache_manager.get_all()
if entries:
table = Table(show_header=True, header_style="bold magenta")
table.add_column("Key", width=60)
table.add_column("Type")
table.add_column("Size")
for key, value in entries.items():
value_type = type(value).__name__
size = len(str(value))
table.add_row(key[:60], value_type, str(size))
console.print(Panel(table, title="Cached Entries", expand=False))
else:
console.print("[yellow]Cache is empty[/yellow]")
console.print("\nCache statistics:")
console.print(f" Total entries: {stats['size']}")
console.print(f" Cache size: {stats['cache_size_mb']:.2f} MB")
@main.command()
def version() -> None:
"""Show version information."""
from . import __version__
console.print(f"[bold]Code Pattern Search CLI[/bold] v{__version__}")
if __name__ == "__main__":
main()