diff --git a/src/cordon/cli.py b/src/cordon/cli.py index 96db9c6..27065bb 100644 --- a/src/cordon/cli.py +++ b/src/cordon/cli.py @@ -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: @@ -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 @@ -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, @@ -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: ") + print("=" * 80) + + try: + result = analyzer.analyze_text_detailed(text) + except Exception as error: + print(f"Error analyzing : {error}", file=sys.stderr) + return + + _display_results(result, detailed, output_path, force) def _print_backend_info(config: AnalysisConfig) -> None: @@ -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: diff --git a/src/cordon/pipeline.py b/src/cordon/pipeline.py index 813f29a..caba6a6 100644 --- a/src/cordon/pipeline.py +++ b/src/cordon/pipeline.py @@ -1,4 +1,5 @@ import time +from collections.abc import Sequence from pathlib import Path import numpy as np @@ -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 @@ -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 @@ -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, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 219bcf3..9236070 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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.""" @@ -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() @@ -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 = "" + analyzer.analyze_file_detailed.return_value = mock_result analyze_file( log_file, analyzer, @@ -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 = "new" + mock_result = MagicMock() + mock_result.output = "new" + analyzer.analyze_file_detailed.return_value = mock_result analyze_file( log_file, @@ -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 = "results" + mock_result = MagicMock() + mock_result.output = "results" + analyzer.analyze_file_detailed.return_value = mock_result analyze_file(log_file, analyzer, detailed=False) captured = capsys.readouterr() diff --git a/tests/test_pipeline_unit.py b/tests/test_pipeline_unit.py index 2a16c56..4b15bd4 100644 --- a/tests/test_pipeline_unit.py +++ b/tests/test_pipeline_unit.py @@ -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