Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file> [OPTIONS]
python -m logpilot.cli analyze <path> [OPTIONS]
```

### Arguments

- `<file>`: Path to a log file (any extension)
- `<path>`: Path to a log file or directory (any extension)

### Options

Expand All @@ -25,6 +25,9 @@ python -m logpilot.cli analyze <file> [OPTIONS]
- `--output [text|json]` : Output type (default: text)
- `--max-tokens <int>` : Max tokens per chunk (default: 2048)
- `--out-file <path>` : Write output to file (default: stdout)
- `--recursive` : Recurse into subdirectories (directory mode)
- `--include <glob>` : Glob pattern(s) to include (repeatable)
- `--exclude <glob>` : Glob pattern(s) to exclude (repeatable)

### Example

Expand All @@ -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
Expand Down
41 changes: 32 additions & 9 deletions logpilot/cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from typing import List, Optional

import typer

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -99,7 +123,6 @@ def analyze(
typer.echo(summary)


@app.callback()
@app.callback()
def main(
version: bool = typer.Option(
Expand Down
62 changes: 61 additions & 1 deletion logpilot/input_manager.py
Original file line number Diff line number Diff line change
@@ -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]:
Expand All @@ -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)
87 changes: 62 additions & 25 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import os
import subprocess
import sys

import pytest


def run_cli(args, env=None):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = (
Expand All @@ -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(
Expand All @@ -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