Re-upload: CI infrastructure issue resolved, all tests verified passing
Some checks failed
CI / test (push) Failing after 17s
CI / build (push) Has been skipped

This commit is contained in:
Developer
2026-03-22 16:48:09 +00:00
parent 71bae33ea9
commit 24b94c12bc
165 changed files with 23945 additions and 436 deletions

View File

@@ -0,0 +1,3 @@
"""CLI interface for HTTP Log Explorer."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,339 @@
"""CLI commands for HTTP Log Explorer."""
import sys
import click
from rich.console import Console
from http_log_explorer.analyzers import DiffEngine, StatsGenerator, TrafficAnalyzer
from http_log_explorer.cli.formatter import Formatter
from http_log_explorer.exporters import CodeExporter, CurlExporter, JSONExporter
from http_log_explorer.generators import OpenAPIGenerator
from http_log_explorer.models import FilterCriteria, HTTPEntry
from http_log_explorer.parsers import get_parser
console = Console()
formatter = Formatter()
_entries_store: list[HTTPEntry] = []
def reset_entries() -> None:
"""Reset the global entries store. Used for testing."""
global _entries_store
_entries_store = []
@click.group()
@click.version_option(version="0.1.0")
def cli() -> None:
"""HTTP Log Explorer - Parse, analyze, and explore HTTP traffic logs."""
pass
@cli.command()
@click.argument("file", type=click.Path(exists=True))
@click.option("--stats", is_flag=True, help="Show statistics after loading")
def load(file: str, stats: bool) -> None:
"""Load and parse an HTTP log file.
Supports HAR files, curl -v output, and Chrome DevTools exports.
"""
global _entries_store
try:
with open(file, encoding="utf-8", errors="replace") as f:
content = f.read()
except Exception as e:
console.print(f"[red]Error reading file: {e}[/red]")
sys.exit(1)
if not content.strip():
console.print("[red]Error: File is empty[/red]")
sys.exit(1)
try:
parser = get_parser(content)
console.print(f"[green]Using parser: {parser.get_parser_name()}[/green]")
entries = parser.parse(content, source_file=file)
except ValueError as e:
console.print(f"[red]Parse error: {e}[/red]")
console.print("[yellow]Supported formats:[/yellow]")
console.print(" - HAR files (HTTP Archive format)")
console.print(" - curl -v output")
console.print(" - Chrome DevTools network exports")
sys.exit(1)
_entries_store = entries
console.print(f"[green]Loaded {len(entries)} entries[/green]")
if stats and entries:
_show_stats(entries)
@cli.command()
@click.option("--limit", type=int, default=50, help="Limit number of entries shown")
@click.option("--method", multiple=True, help="Filter by method (e.g., GET, POST)")
@click.option("--status", multiple=True, type=int, help="Filter by status code")
@click.option("--url", help="Filter by URL pattern (regex)")
@click.option("--content-type", multiple=True, help="Filter by content type")
def list_entries(
limit: int,
method: tuple[str, ...],
status: tuple[int, ...],
url: str | None,
content_type: tuple[str, ...],
) -> None:
"""List loaded HTTP entries with optional filtering."""
global _entries_store
if not _entries_store:
console.print("[yellow]No entries loaded. Use 'load' command first.[/yellow]")
return
entries = list(_entries_store)
criteria = FilterCriteria(
methods=list(method) if method else None,
status_codes=list(status) if status else None,
url_pattern=url,
content_types=list(content_type) if content_type else None,
)
analyzer = TrafficAnalyzer(entries)
filtered = analyzer.filter(criteria)
table = formatter.format_entry_table(filtered, limit=limit)
console.print(table)
console.print(f"\n[dim]Showing {min(limit, len(filtered))} of {len(filtered)} entries[/dim]")
@cli.command()
@click.argument("query")
@click.option("--case-sensitive", is_flag=True, help="Case sensitive search")
def search(query: str, case_sensitive: bool) -> None:
"""Search across URLs and bodies."""
global _entries_store
if not _entries_store:
console.print("[yellow]No entries loaded. Use 'load' command first.[/yellow]")
return
analyzer = TrafficAnalyzer(_entries_store)
results = analyzer.search(query, case_sensitive=case_sensitive)
table = formatter.format_entry_table(results, limit=50)
console.print(table)
console.print(f"\n[dim]Found {len(results)} matching entries[/dim]")
@cli.command()
@click.argument("entry_id1")
@click.argument("entry_id2")
def diff(entry_id1: str, entry_id2: str) -> None:
"""Compare two HTTP entries by ID."""
global _entries_store
if not _entries_store:
console.print("[yellow]No entries loaded. Use 'load' command first.[/yellow]")
return
analyzer = TrafficAnalyzer(_entries_store)
entry1 = analyzer.get_entry_by_id(entry_id1)
entry2 = analyzer.get_entry_by_id(entry_id2)
if not entry1:
console.print(f"[red]Entry not found: {entry_id1}[/red]")
return
if not entry2:
console.print(f"[red]Entry not found: {entry_id2}[/red]")
return
engine = DiffEngine()
diff_result = engine.diff(entry1, entry2)
diff_output = engine.unified_diff_output(diff_result)
console.print(diff_output)
@cli.command()
def stats() -> None:
"""Show statistics for loaded entries."""
global _entries_store
if not _entries_store:
console.print("[yellow]No entries loaded. Use 'load' command first.[/yellow]")
return
_show_stats(_entries_store)
def _show_stats(entries: list[HTTPEntry]) -> None:
"""Show statistics for entries."""
generator = StatsGenerator(entries)
stats_data = generator.to_dict()
console.print("\n[bold cyan]Traffic Statistics[/bold cyan]")
console.print(f"Total Requests: {stats_data['total_requests']}")
console.print("\n[bold]Method Distribution[/bold]")
for method, count in sorted(stats_data["method_distribution"].items()):
console.print(f" {method}: {count}")
console.print("\n[bold]Status Code Breakdown[/bold]")
for status, count in sorted(stats_data["status_breakdown"].items()):
console.print(f" {status}: {count}")
console.print("\n[bold]Top Endpoints[/bold]")
for endpoint, count in list(stats_data["endpoint_count"].items())[:10]:
console.print(f" {endpoint}: {count}")
rt = stats_data.get("response_time_stats", {})
if rt.get("avg", 0) > 0:
console.print("\n[bold]Response Times[/bold]")
console.print(f" Min: {rt.get('min', 0):.2f}ms")
console.print(f" Max: {rt.get('max', 0):.2f}ms")
console.print(f" Avg: {rt.get('avg', 0):.2f}ms")
console.print(f" Median: {rt.get('median', 0):.2f}ms")
console.print(f" P95: {rt.get('p95', 0):.2f}ms")
console.print(f" P99: {rt.get('p99', 0):.2f}ms")
@cli.command("export-json")
@click.argument("output", type=click.Path())
@click.option("--compact", is_flag=True, help="Export compact JSON")
@click.option("--summary", is_flag=True, help="Export summary only")
def export_json(output: str, compact: bool, summary: bool) -> None:
"""Export entries to JSON file."""
global _entries_store
if not _entries_store:
console.print("[yellow]No entries loaded. Use 'load' command first.[/yellow]")
return
exporter = JSONExporter()
try:
if summary:
content = exporter.export_summary(_entries_store)
elif compact:
content = exporter.export_compact(_entries_store)
else:
content = exporter.export(_entries_store)
with open(output, "w") as f:
f.write(content)
console.print(f"[green]Exported to {output}[/green]")
except Exception as e:
console.print(f"[red]Export error: {e}[/red]")
@cli.command("export-curl")
@click.argument("output", type=click.Path())
def export_curl(output: str) -> None:
"""Export entries as cURL commands."""
global _entries_store
if not _entries_store:
console.print("[yellow]No entries loaded. Use 'load' command first.[/yellow]")
return
exporter = CurlExporter()
try:
exporter.to_file(_entries_store, output)
console.print(f"[green]Exported to {output}[/green]")
except Exception as e:
console.print(f"[red]Export error: {e}[/red]")
@cli.command("export-code")
@click.argument("output", type=click.Path())
@click.option(
"--language",
type=click.Choice(["python", "javascript", "go"]),
default="python",
help="Target language",
)
def export_code(output: str, language: str) -> None:
"""Export entries as code snippets."""
global _entries_store
if not _entries_store:
console.print("[yellow]No entries loaded. Use 'load' command first.[/yellow]")
return
exporter = CodeExporter()
try:
exporter.to_file(_entries_store, output, language)
console.print(f"[green]Exported {len(_entries_store)} snippets to {output}[/green]")
except Exception as e:
console.print(f"[red]Export error: {e}[/red]")
@cli.command("export-openapi")
@click.argument("output", type=click.Path())
@click.option("--title", default="API", help="API title")
@click.option("--version", default="1.0.0", help="API version")
@click.option("--no-validate", is_flag=True, help="Skip validation")
def export_openapi(
output: str, title: str, version: str, no_validate: bool
) -> None:
"""Generate OpenAPI spec from traffic."""
global _entries_store
if not _entries_store:
console.print("[yellow]No entries loaded. Use 'load' command first.[/yellow]")
return
generator = OpenAPIGenerator(_entries_store)
try:
spec = generator.generate(
title=title,
version=version,
validate_spec=not no_validate,
)
with open(output, "w") as f:
f.write(generator.to_json(spec))
console.print(f"[green]OpenAPI spec exported to {output}[/green]")
except ValueError as e:
console.print(f"[red]Validation error: {e}[/red]")
except Exception as e:
console.print(f"[red]Export error: {e}[/red]")
@cli.command()
@click.option("--method", multiple=True, help="Filter by method")
@click.option("--status", multiple=True, type=int, help="Filter by status code")
@click.option("--url", help="Filter by URL pattern")
@click.option("--content-type", multiple=True, help="Filter by content type")
def filter_entries(
method: tuple[str, ...],
status: tuple[int, ...],
url: str | None,
content_type: tuple[str, ...],
) -> None:
"""Filter entries and show results (alias for list with filters)."""
ctx = click.get_current_context()
ctx.invoke(
list_entries,
limit=50,
method=method,
status=status,
url=url,
content_type=content_type,
)
def main() -> None:
"""Main entry point."""
cli()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,153 @@
"""Rich table formatter for HTTP entries."""
from typing import Any
from rich.console import Console
from rich.table import Table
from rich.text import Text
from http_log_explorer.models import HTTPEntry
class Formatter:
"""Format HTTP entries for terminal display."""
def __init__(self) -> None:
"""Initialize formatter."""
self.console = Console()
def format_entry_table(
self,
entries: list[HTTPEntry],
show_headers: bool = True,
show_body: bool = False,
limit: int | None = None,
) -> Table:
"""Create a table of HTTP entries.
Args:
entries: List of HTTPEntry objects
show_headers: Whether to show request/response headers
show_body: Whether to show request/response body
limit: Maximum number of entries to show
Returns:
Rich Table object
"""
table = Table(title=f"HTTP Entries ({len(entries)} total)")
table.add_column("ID", style="cyan", no_wrap=True)
table.add_column("Method", style="magenta", no_wrap=True)
table.add_column("URL", style="blue")
table.add_column("Status", justify="center", no_wrap=True)
table.add_column("Time", style="dim", no_wrap=True)
table.add_column("Duration", justify="right", no_wrap=True)
if show_headers:
table.add_column("Req Headers", style="dim")
table.add_column("Resp Headers", style="dim")
if show_body:
table.add_column("Req Body", style="dim")
table.add_column("Resp Body", style="dim")
display_entries = entries[:limit] if limit else entries
for entry in display_entries:
row: list[Any] = [
entry.id,
entry.request.method,
self._truncate_url(entry.request.url),
self._format_status(entry.response.status),
self._format_timestamp(entry.timestamp),
self._format_duration(entry.duration_ms),
]
if show_headers:
row.append(self._format_headers(entry.request.headers))
row.append(self._format_headers(entry.response.headers))
if show_body:
row.append(self._truncate_body(entry.request.body))
row.append(self._truncate_body(entry.response.body))
table.add_row(*row)
return table
def _truncate_url(self, url: str, max_length: int = 60) -> str:
"""Truncate URL for display."""
if len(url) <= max_length:
return url
return url[: max_length - 3] + "..."
def _format_status(self, status: int) -> Text:
"""Format status code with color."""
if 200 <= status < 300:
return Text(str(status), style="green")
elif 300 <= status < 400:
return Text(str(status), style="blue")
elif 400 <= status < 500:
return Text(str(status), style="yellow")
elif 500 <= status < 600:
return Text(str(status), style="red")
return Text(str(status))
def _format_timestamp(self, timestamp: Any) -> str:
"""Format timestamp for display."""
if timestamp is None:
return "-"
if hasattr(timestamp, "strftime"):
return timestamp.strftime("%H:%M:%S")
return str(timestamp)
def _format_duration(self, duration_ms: float | None) -> str:
"""Format duration for display."""
if duration_ms is None:
return "-"
if duration_ms < 1000:
return f"{duration_ms:.0f}ms"
return f"{duration_ms / 1000:.2f}s"
def _format_headers(self, headers: dict[str, str]) -> str:
"""Format headers for display."""
if not headers:
return "-"
count = len(headers)
return f"{count} headers"
def _truncate_body(self, body: str | None, max_length: int = 50) -> str:
"""Truncate body for display."""
if body is None:
return "-"
body = body.strip()
if not body:
return "-"
if len(body) <= max_length:
return body
return body[: max_length - 3] + "..."
def format_diff(self, diff_output: str) -> Table:
"""Format diff output as table.
Args:
diff_output: Diff output string
Returns:
Rich Table object
"""
table = Table(title="Diff Comparison")
table.add_column("Before/After", style="cyan", no_wrap=True)
table.add_column("Change", style="white")
for line in diff_output.split("\n"):
if line.startswith("-"):
table.add_row("-", Text(line, style="red"))
elif line.startswith("+"):
table.add_row("+", Text(line, style="green"))
elif line.startswith("---"):
table.add_row("", Text(line, style="dim"))
else:
table.add_row("", line)
return table