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
102 changes: 72 additions & 30 deletions src/cordon/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from math import isclose
from pathlib import Path

from cordon import AnalysisConfig, SemanticLogAnalyzer
from cordon import AnalysisConfig, AnalysisResult, SemanticLogAnalyzer


def parse_args() -> argparse.Namespace:
Expand All @@ -20,7 +20,7 @@ def parse_args() -> argparse.Namespace:
"logfiles",
type=Path,
nargs="+",
help="Path(s) to log file(s) to analyze",
help="Path(s) to log file(s) to analyze (use '-' for stdin)",
)

# embedding backend selection
Expand Down Expand Up @@ -207,6 +207,45 @@ def _write_output(content: str, output_path: Path, force: bool) -> None:
print(f"Error writing output file: {error}", file=sys.stderr)


def _display_results(
result: AnalysisResult,
detailed: bool,
output_path: Path | None,
force: bool,
) -> None:
"""Display analysis results, optionally with detailed statistics.

Args:
result: The analysis result to display.
detailed: Whether to print detailed statistics before the output.
output_path: Optional path to save anomalous blocks (None prints to stdout).
force: If True, overwrite an existing output file.
"""
if detailed:
print(f"Total lines: {result.total_lines:,}")
print("\nAnalysis Statistics:")
print(f" Total windows created: {result.total_windows:,}")
print(f" Significant windows: {result.significant_windows:,}")
print(f" Merged blocks: {result.merged_blocks}")
print(f" Processing time: {result.processing_time:.2f}s")
print("\nScore Distribution:")
print(f" Min: {result.score_distribution['min']:.4f}")
print(f" Mean: {result.score_distribution['mean']:.4f}")
print(f" Median: {result.score_distribution['median']:.4f}")
print(f" P90: {result.score_distribution['p90']:.4f}")
print(f" Max: {result.score_distribution['max']:.4f}")

print(f"\n{'Significant Blocks':^80}")
print("=" * 80)

if output_path:
_write_output(result.output, output_path, force)
else:
print(result.output)

print()


def analyze_file(
log_path: Path,
analyzer: SemanticLogAnalyzer,
Expand All @@ -231,41 +270,41 @@ def analyze_file(
print("=" * 80)

try:
if detailed:
result = analyzer.analyze_file_detailed(log_path)
else:
output = analyzer.analyze_file(log_path)
result = analyzer.analyze_file_detailed(log_path)
except Exception as error:
print(f"Error analyzing {log_path}: {error}", file=sys.stderr)
return

if detailed:
print(f"Total lines: {result.total_lines:,}")
print("\nAnalysis Statistics:")
print(f" Total windows created: {result.total_windows:,}")
print(f" Significant windows: {result.significant_windows:,}")
print(f" Merged blocks: {result.merged_blocks}")
print(f" Processing time: {result.processing_time:.2f}s")
print("\nScore Distribution:")
print(f" Min: {result.score_distribution['min']:.4f}")
print(f" Mean: {result.score_distribution['mean']:.4f}")
print(f" Median: {result.score_distribution['median']:.4f}")
print(f" P90: {result.score_distribution['p90']:.4f}")
print(f" Max: {result.score_distribution['max']:.4f}")
_display_results(result, detailed, output_path, force)

print(f"\n{'Significant Blocks':^80}")
print("=" * 80)

content = result.output
else:
content = output
def analyze_stdin(
analyzer: SemanticLogAnalyzer,
detailed: bool,
output_path: Path | None = None,
force: bool = False,
) -> None:
"""Analyze log data from stdin and print results.

if output_path:
_write_output(content, output_path, force)
else:
print(content)
Args:
analyzer: Configured SemanticLogAnalyzer instance.
detailed: Whether to show detailed statistics.
output_path: Optional path to save anomalous blocks (None prints to stdout).
force: If True, overwrite an existing output file.
"""
text = sys.stdin.read()

print()
print("=" * 80)
print("Analyzing: <stdin>")
print("=" * 80)

try:
result = analyzer.analyze_text_detailed(text)
except Exception as error:
print(f"Error analyzing <stdin>: {error}", file=sys.stderr)
return

_display_results(result, detailed, output_path, force)


def _print_backend_info(config: AnalysisConfig) -> None:
Expand Down Expand Up @@ -364,7 +403,10 @@ def _main_impl() -> None:

# analyze each log file
for log_path in args.logfiles:
analyze_file(log_path, analyzer, args.detailed, args.output, args.force)
if str(log_path) == "-":
analyze_stdin(analyzer, args.detailed, args.output, args.force)
else:
analyze_file(log_path, analyzer, args.detailed, args.output, args.force)


def main() -> None:
Expand Down
56 changes: 50 additions & 6 deletions src/cordon/pipeline.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import time
from collections.abc import Sequence
from pathlib import Path

import numpy as np
Expand Down Expand Up @@ -90,10 +91,54 @@ def analyze_file_detailed(self, file_path: Path) -> AnalysisResult:
Returns:
Complete analysis result with metadata.
"""
start_time = time.time()

# stage 1: ingestion
lines_list = list(self._reader.read_lines(file_path))
return self._analyze_lines(lines_list)

def analyze_text(self, text: str) -> str:
"""Analyze log text and return formatted output.

Args:
text: Raw log text (newline-separated lines).

Returns:
Formatted string with significant anomaly blocks.
"""
result = self.analyze_text_detailed(text)
return result.output

def analyze_text_detailed(self, text: str) -> AnalysisResult:
"""Analyze log text and return detailed results.

Args:
text: Raw log text (newline-separated lines).

Returns:
Complete analysis result with metadata.
"""
lines_list = list(enumerate(text.splitlines(), start=1))
return self._analyze_lines(lines_list)

def analyze_lines(self, lines: Sequence[tuple[int, str]]) -> AnalysisResult:
"""Analyze pre-structured log lines and return detailed results.

Args:
lines: Sequence of (line_number, line_content) tuples.

Returns:
Complete analysis result with metadata.
"""
return self._analyze_lines(list(lines))

def _analyze_lines(self, lines_list: list[tuple[int, str]]) -> AnalysisResult:
"""Internal method that runs the analysis pipeline on pre-read lines.

Args:
lines_list: List of (line_number, line_content) tuples.

Returns:
Complete analysis result with metadata.
"""
start_time = time.time()
total_lines = len(lines_list)

# stage 2: segmentation
Expand All @@ -113,13 +158,12 @@ def analyze_file_detailed(self, file_path: Path) -> AnalysisResult:

# stage 6: merging
merged = self._merger.merge_windows(significant)
merged_blocks = len(merged)
merged_blocks_count = len(merged)
del significant

# stage 7: formatting
output = self._formatter.format_blocks(merged, lines_list)

# calculate statistics
processing_time = time.time() - start_time
score_distribution = self._calculate_score_distribution(scored)
del scored
Expand All @@ -130,7 +174,7 @@ def analyze_file_detailed(self, file_path: Path) -> AnalysisResult:
total_lines=total_lines,
total_windows=total_windows,
significant_windows=significant_windows,
merged_blocks=merged_blocks,
merged_blocks=merged_blocks_count,
score_distribution=score_distribution,
processing_time=processing_time,
)
Expand Down
19 changes: 16 additions & 3 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ def test_format_default_xml(self, monkeypatch: pytest.MonkeyPatch) -> None:
args = parse_args()
assert args.output_format == "xml"

def test_stdin_argument(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that '-' is accepted as a logfile argument for stdin."""
monkeypatch.setattr(sys, "argv", ["cordon", "-"])
args = parse_args()
assert str(args.logfiles[0]) == "-"


class TestAnalyzeFile:
"""Tests for analyze_file function."""
Expand Down Expand Up @@ -125,7 +131,7 @@ def test_analysis_error_non_detailed(
log_file.write_text("line 1\nline 2\n")

analyzer = MagicMock()
analyzer.analyze_file.side_effect = ValueError("Bad input")
analyzer.analyze_file_detailed.side_effect = ValueError("Bad input")

analyze_file(log_file, analyzer, detailed=False)
captured = capsys.readouterr()
Expand Down Expand Up @@ -167,6 +173,9 @@ def test_overwrite_protection(self, capsys: pytest.CaptureFixture[str], tmp_path
output_file.write_text("existing content")

analyzer = MagicMock()
mock_result = MagicMock()
mock_result.output = "<anomalies></anomalies>"
analyzer.analyze_file_detailed.return_value = mock_result
analyze_file(
log_file,
analyzer,
Expand All @@ -188,7 +197,9 @@ def test_force_overwrite(self, tmp_path: Path) -> None:
output_file.write_text("existing content")

analyzer = MagicMock()
analyzer.analyze_file.return_value = "<anomalies>new</anomalies>"
mock_result = MagicMock()
mock_result.output = "<anomalies>new</anomalies>"
analyzer.analyze_file_detailed.return_value = mock_result

analyze_file(
log_file,
Expand All @@ -205,7 +216,9 @@ def test_stdout_output(self, capsys: pytest.CaptureFixture[str], tmp_path: Path)
log_file.write_text("line 1\n")

analyzer = MagicMock()
analyzer.analyze_file.return_value = "<anomalies>results</anomalies>"
mock_result = MagicMock()
mock_result.output = "<anomalies>results</anomalies>"
analyzer.analyze_file_detailed.return_value = mock_result

analyze_file(log_file, analyzer, detailed=False)
captured = capsys.readouterr()
Expand Down
53 changes: 53 additions & 0 deletions tests/test_pipeline_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,56 @@ def test_json_formatter_selected(self, mock_create: MagicMock) -> None:
config = AnalysisConfig(device="cpu", output_format="json")
analyzer = SemanticLogAnalyzer(config)
assert isinstance(analyzer._formatter, JsonFormatter)


class TestAnalyzeText:
"""Tests for text and line analysis methods."""

@patch("cordon.pipeline.create_embedder")
def test_analyze_text_detailed(self, mock_create: MagicMock) -> None:
"""Test that analyze_text_detailed correctly parses lines from text."""
mock_embedder = MagicMock()
mock_create.return_value = mock_embedder
mock_embedder.embed_windows.return_value = iter([])

config = AnalysisConfig(device="cpu")
analyzer = SemanticLogAnalyzer(config)
result = analyzer.analyze_text_detailed("line 1\nline 2\nline 3\n")
assert result.total_lines == 3

@patch("cordon.pipeline.create_embedder")
def test_analyze_text_detailed_empty(self, mock_create: MagicMock) -> None:
"""Test that analyze_text_detailed handles empty input."""
mock_embedder = MagicMock()
mock_create.return_value = mock_embedder
mock_embedder.embed_windows.return_value = iter([])

config = AnalysisConfig(device="cpu")
analyzer = SemanticLogAnalyzer(config)
result = analyzer.analyze_text_detailed("")
assert result.total_lines == 0

@patch("cordon.pipeline.create_embedder")
def test_analyze_text_returns_output(self, mock_create: MagicMock) -> None:
"""Test that analyze_text returns the formatted output string."""
mock_embedder = MagicMock()
mock_create.return_value = mock_embedder
mock_embedder.embed_windows.return_value = iter([])

config = AnalysisConfig(device="cpu")
analyzer = SemanticLogAnalyzer(config)
result = analyzer.analyze_text("line 1\nline 2")
assert isinstance(result, str)

@patch("cordon.pipeline.create_embedder")
def test_analyze_lines(self, mock_create: MagicMock) -> None:
"""Test that analyze_lines processes pre-structured tuples."""
mock_embedder = MagicMock()
mock_create.return_value = mock_embedder
mock_embedder.embed_windows.return_value = iter([])

config = AnalysisConfig(device="cpu")
analyzer = SemanticLogAnalyzer(config)
lines: list[tuple[int, str]] = [(1, "hello"), (2, "world")]
result = analyzer.analyze_lines(lines)
assert result.total_lines == 2
Loading