diff --git a/README.md b/README.md index 8de9272..b388285 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,19 @@ A Python CLI tool for analyzing logs using the GitHub Copilot SDK. ## Features -- Accepts logs from a single file +- Accepts logs from a single file or a directory (combined analysis) - Supports plain text and JSON log formats - Uses Copilot SDK for LLM-powered analysis ## Usage (MVP) ```sh -python -m logpilot.cli analyze [OPTIONS] +python -m logpilot.cli analyze [OPTIONS] ``` ### Arguments -- ``: Path to a log file (any extension) +- ``: Path to a log file or directory (any extension) ### Options @@ -25,6 +25,9 @@ python -m logpilot.cli analyze [OPTIONS] - `--output [text|json]` : Output type (default: text) - `--max-tokens ` : Max tokens per chunk (default: 2048) - `--out-file ` : Write output to file (default: stdout) +- `--recursive` : Recurse into subdirectories (directory mode) +- `--include ` : Glob pattern(s) to include (repeatable) +- `--exclude ` : Glob pattern(s) to exclude (repeatable) ### Example @@ -34,6 +37,18 @@ Summarize a log file and print to terminal: python -m logpilot.cli analyze mylog.txt ``` +Summarize all logs in a directory (combined analysis): + +```sh +python -m logpilot.cli analyze ./logs +``` + +Summarize logs in a directory recursively with filters: + +```sh +python -m logpilot.cli analyze ./logs --recursive --include "*.log" --exclude "archive/*" +``` + Summarize a log file and write to a file: ```sh diff --git a/logpilot/cli.py b/logpilot/cli.py index 62d7371..5b05ddf 100644 --- a/logpilot/cli.py +++ b/logpilot/cli.py @@ -1,4 +1,5 @@ import os +from typing import List, Optional import typer @@ -8,6 +9,7 @@ import toml as tomllib from logpilot.chunker import chunk_logs +from logpilot.input_manager import iter_log_files, read_logs_from_paths from logpilot.llm_client import LLMClient from logpilot.log_parser import parse_logs from logpilot.postprocessor import aggregate_responses @@ -40,30 +42,52 @@ def version_callback(value: bool): @app.command() def analyze( - path: str = typer.Argument(..., help="Path to log file"), + path: str = typer.Argument(..., help="Path to log file or directory"), format: str = typer.Option("auto", help="Log format: auto, json, text"), output: str = typer.Option("text", help="Output type: text or json"), max_tokens: int = typer.Option(2048, help="Max tokens per chunk"), out_file: str = typer.Option(None, help="Output file path (default: stdout)"), + recursive: bool = typer.Option(False, help="Recurse into directories"), + include: Optional[List[str]] = typer.Option( + None, + "--include", + help="Glob pattern(s) to include (repeatable).", + ), + exclude: Optional[List[str]] = typer.Option( + None, + "--exclude", + help="Glob pattern(s) to exclude (repeatable).", + ), ): """Analyze logs using Copilot SDK.""" import os - if path == "-" or os.path.isdir(path): - typer.echo("Error: Only a single log file is supported.\n", err=True) + if path == "-": + typer.echo("Error: stdin is not supported.\n", err=True) typer.echo(analyze.get_help(typer.Context(analyze))) raise typer.Exit(1) - if not os.path.isfile(path): - typer.echo(f"Error: File not found: {path}", err=True) - raise typer.Exit(1) # 1. Read log lines try: - with open(path, "r", encoding="utf-8") as f: - lines = [line.rstrip("\n") for line in f] + paths = list( + iter_log_files( + path, + recursive=recursive, + include=include, + exclude=exclude, + ) + ) + if not paths: + typer.echo(f"No log files matched: {path}", err=True) + raise typer.Exit(1) + + lines = [line for line in read_logs_from_paths(paths)] except PermissionError as e: typer.echo(f"Error: Permission denied reading log file: {e}", err=True) raise typer.Exit(1) + except FileNotFoundError as e: + typer.echo(str(e), err=True) + raise typer.Exit(1) except Exception as e: typer.echo(f"Error reading log file: {e}", err=True) raise typer.Exit(1) @@ -99,7 +123,6 @@ def analyze( typer.echo(summary) -@app.callback() @app.callback() def main( version: bool = typer.Option( diff --git a/logpilot/input_manager.py b/logpilot/input_manager.py index 122e4f2..e900387 100644 --- a/logpilot/input_manager.py +++ b/logpilot/input_manager.py @@ -1,5 +1,6 @@ +import fnmatch import os -from typing import Iterator +from typing import Iterable, Iterator, Optional, Sequence def read_logs(path: str) -> Iterator[str]: @@ -9,3 +10,62 @@ def read_logs(path: str) -> Iterator[str]: with open(path, "r", encoding="utf-8") as f: for line in f: yield line.rstrip("\n") + + +def iter_log_files( + path: str, + recursive: bool = False, + include: Optional[Sequence[str]] = None, + exclude: Optional[Sequence[str]] = None, +) -> Iterator[str]: + """Yield log file paths from a file or directory.""" + include = include or ["*"] + exclude = exclude or [] + + def matches(patterns: Sequence[str], rel_path: str, name: str) -> bool: + for pattern in patterns: + if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(name, pattern): + return True + return False + + if os.path.isfile(path): + rel_path = os.path.basename(path) + if matches(include, rel_path, rel_path) and not matches( + exclude, rel_path, rel_path + ): + yield path + return + + if not os.path.isdir(path): + raise FileNotFoundError(f"No such file or directory: {path}") + + files: list[str] = [] + if recursive: + for root, _, filenames in os.walk(path): + for name in filenames: + full_path = os.path.join(root, name) + rel_path = os.path.relpath(full_path, path) + if matches(include, rel_path, name) and not matches( + exclude, rel_path, name + ): + files.append(full_path) + else: + for name in os.listdir(path): + full_path = os.path.join(path, name) + if not os.path.isfile(full_path): + continue + rel_path = os.path.relpath(full_path, path) + if matches(include, rel_path, name) and not matches( + exclude, rel_path, name + ): + files.append(full_path) + + files.sort() + for file_path in files: + yield file_path + + +def read_logs_from_paths(paths: Iterable[str]) -> Iterator[str]: + """Yield log lines from multiple files in order.""" + for path in paths: + yield from read_logs(path) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2707ab0..eb9de94 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,8 @@ import os +import subprocess +import sys + +import pytest def run_cli(args, env=None): @@ -90,6 +94,9 @@ def test_cli_format_text(tmp_path): def test_cli_unreadable_file(tmp_path): + if os.name == "nt": + pytest.skip("File permissions are not reliable on Windows") + log_file = tmp_path / "unreadable.log" log_file.write_text("test") log_file.chmod(0) @@ -121,29 +128,6 @@ def test_cli_large_file(tmp_path): assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" -import subprocess # noqa: E402 -import sys # noqa: E402 - - -def test_cli_shows_help(): - # Run the CLI with '--help' and check output - import os - - env = dict(os.environ) - env["LOGPILOT_MOCK_LLM"] = "1" - result = subprocess.run( - [sys.executable, "-m", "logpilot.cli", "--help"], - capture_output=True, - text=True, - env=env, - ) - assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" - output = (result.stdout or "") + (result.stderr or "") - assert ( - "Options:" in output or "logpilot" in output - ), f"stdout: {result.stdout}\nstderr: {result.stderr}" - - def test_cli_analyze_basic(tmp_path, monkeypatch): # Create a simple log file log_content = ( @@ -155,8 +139,6 @@ def test_cli_analyze_basic(tmp_path, monkeypatch): log_file.write_text(log_content) # Run the CLI analyze command with mock LLM enabled - import os - env = dict(os.environ) env["LOGPILOT_MOCK_LLM"] = "1" result = subprocess.run( @@ -167,3 +149,58 @@ def test_cli_analyze_basic(tmp_path, monkeypatch): ) assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" assert "Something failed" in result.stdout or "critical" in result.stdout.lower() + + +def test_cli_directory_basic(tmp_path): + log_dir = tmp_path / "logs" + log_dir.mkdir() + (log_dir / "a.log").write_text( + '{"timestamp": "2026-01-28T12:00:00Z", "level": "ERROR", "message": "A"}\n' + ) + (log_dir / "b.log").write_text( + '{"timestamp": "2026-01-28T12:00:01Z", "level": "ERROR", "message": "B"}\n' + ) + + result = run_cli(["analyze", str(log_dir)]) + assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" + assert "Mocked summary" in result.stdout + + +def test_cli_directory_recursive(tmp_path): + log_dir = tmp_path / "logs" + nested = log_dir / "nested" + nested.mkdir(parents=True) + (nested / "nested.log").write_text( + '{"timestamp": "2026-01-28T12:00:00Z", "level": "ERROR", "message": "Nested"}\n' + ) + + result = run_cli(["analyze", str(log_dir), "--recursive"]) + assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" + assert "Mocked summary" in result.stdout + + +def test_cli_directory_include_exclude(tmp_path): + log_dir = tmp_path / "logs" + log_dir.mkdir() + (log_dir / "keep.log").write_text( + '{"timestamp": "2026-01-28T12:00:00Z", "level": "ERROR", "message": "Keep"}\n' + ) + (log_dir / "skip.txt").write_text( + '{"timestamp": "2026-01-28T12:00:01Z", "level": "ERROR", "message": "Skip"}\n' + ) + + result = run_cli( + ["analyze", str(log_dir), "--include", "*.log", "--exclude", "skip.*"] + ) + assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" + assert "Mocked summary" in result.stdout + + +def test_cli_directory_no_matches(tmp_path): + log_dir = tmp_path / "logs" + log_dir.mkdir() + (log_dir / "skip.txt").write_text("not a log\n") + + result = run_cli(["analyze", str(log_dir), "--include", "*.log"]) + assert result.returncode != 0 + assert "No log files matched" in result.stderr or "Error" in result.stderr