diff --git a/src/detectmatelibrary/__init__.py b/src/detectmatelibrary/__init__.py index ce6e3bb..e449a77 100644 --- a/src/detectmatelibrary/__init__.py +++ b/src/detectmatelibrary/__init__.py @@ -3,11 +3,21 @@ from . import parsers from . import schemas from . import utils +from .exceptions import ( + DetectMateError, + ComponentRunError, + DetectorRunError, + ParserRunError, +) __all__ = [ "common", "detectors", "parsers", "schemas", - "utils" + "utils", + "DetectMateError", + "ComponentRunError", + "DetectorRunError", + "ParserRunError", ] diff --git a/src/detectmatelibrary/common/__init__.py b/src/detectmatelibrary/common/__init__.py index e69de29..cbf63dc 100644 --- a/src/detectmatelibrary/common/__init__.py +++ b/src/detectmatelibrary/common/__init__.py @@ -0,0 +1,13 @@ +from detectmatelibrary.exceptions import ( + DetectMateError, + ComponentRunError, + DetectorRunError, + ParserRunError, +) + +__all__ = [ + "DetectMateError", + "ComponentRunError", + "DetectorRunError", + "ParserRunError", +] diff --git a/src/detectmatelibrary/common/core.py b/src/detectmatelibrary/common/core.py index 02afb3c..a625644 100644 --- a/src/detectmatelibrary/common/core.py +++ b/src/detectmatelibrary/common/core.py @@ -8,6 +8,7 @@ from detectmatelibrary.common._config import BasicConfig from detectmatelibrary.schemas import BaseSchema +from detectmatelibrary.exceptions import ComponentRunError, DetectMateError from tools.logging import logger, setup_logging @@ -109,7 +110,14 @@ def process(self, data: BaseSchema | bytes) -> BaseSchema | bytes | None: output_ = self.output_schema() logger.info(f"<<{self.name}>> processing data") - return_schema = self.run(input_=data_buffered, output_=output_) + try: + return_schema = self.run(input_=data_buffered, output_=output_) + except DetectMateError: + raise + except Exception as exc: + raise ComponentRunError( + f"Component '{self.name}' raised an error in run(): {exc}" + ) from exc if not return_schema: logger.info(f"<<{self.name}>> returns None") return None diff --git a/src/detectmatelibrary/common/detector.py b/src/detectmatelibrary/common/detector.py index 2be7153..803bce5 100644 --- a/src/detectmatelibrary/common/detector.py +++ b/src/detectmatelibrary/common/detector.py @@ -1,5 +1,6 @@ from detectmatelibrary.common._config._formats import EventsConfig, _EventInstance from detectmatelibrary.common.core import CoreComponent, CoreConfig +from detectmatelibrary.exceptions import DetectorRunError from detectmatelibrary.utils.data_buffer import ArgsBuffer, BufferMode from detectmatelibrary.utils.aux import get_timestamp @@ -171,7 +172,14 @@ def run( output_["extractedTimestamps"] = _extract_timestamp(input_) output_["receivedTimestamp"] = get_timestamp() - if (anomaly_detected := self.detect(input_=input_, output_=output_)): + try: + anomaly_detected = self.detect(input_=input_, output_=output_) + except Exception as exc: + raise DetectorRunError( + f"Detector '{self.name}' raised an error in detect(): {exc}" + ) from exc + + if anomaly_detected: output_["alertID"] = str(self.id_generator()) output_["detectionTimestamp"] = get_timestamp() diff --git a/src/detectmatelibrary/common/parser.py b/src/detectmatelibrary/common/parser.py index 4bfc170..3699049 100644 --- a/src/detectmatelibrary/common/parser.py +++ b/src/detectmatelibrary/common/parser.py @@ -4,6 +4,7 @@ from detectmatelibrary.utils.data_buffer import ArgsBuffer, BufferMode from detectmatelibrary.common.core import CoreComponent, CoreConfig from detectmatelibrary.utils.aux import get_timestamp +from detectmatelibrary.exceptions import ParserRunError from detectmatelibrary import schemas from typing import Any, Optional, cast @@ -70,7 +71,12 @@ def run(self, input_: schemas.LogSchema, output_: schemas.ParserSchema) -> bool: input_["log"] = content output_["receivedTimestamp"] = get_timestamp() - use_schema = self.parse(input_=input_, output_=output_) + try: + use_schema = self.parse(input_=input_, output_=output_) + except Exception as exc: + raise ParserRunError( + f"Parser '{self.name}' raised an error in parse(): {exc}" + ) from exc output_["parsedTimestamp"] = get_timestamp() return True if use_schema is None else use_schema diff --git a/src/detectmatelibrary/exceptions.py b/src/detectmatelibrary/exceptions.py new file mode 100644 index 0000000..cdf189e --- /dev/null +++ b/src/detectmatelibrary/exceptions.py @@ -0,0 +1,34 @@ +"""Custom exceptions for DetectMateLibrary. + +Use these exceptions in calling code to distinguish library-level errors +from unexpected failures and provide meaningful messages to users. +""" + + +class DetectMateError(Exception): + """Base exception for all DetectMateLibrary errors.""" + + +class ComponentRunError(DetectMateError): + """Raised when an unhandled error occurs inside a component's run() method. + + Wraps the original exception as ``__cause__`` so the full traceback is + preserved while still allowing callers to catch library errors by type. + """ + + +class DetectorRunError(ComponentRunError): + """Raised when an unhandled error occurs inside a detector's detect() method. + + Typically indicates a misconfiguration (e.g. an EventID present in the + incoming data that is not covered by the detector configuration) or a + bug in a custom detector implementation. + """ + + +class ParserRunError(ComponentRunError): + """Raised when an unhandled error occurs inside a parser's parse() method. + + Typically indicates a format mismatch between the incoming raw log and + the configured log format, or a bug in a custom parser implementation. + """ diff --git a/tests/test_common/test_exceptions.py b/tests/test_common/test_exceptions.py new file mode 100644 index 0000000..d967f46 --- /dev/null +++ b/tests/test_common/test_exceptions.py @@ -0,0 +1,169 @@ +"""Tests for meaningful exception wrapping in component run/detect/parse methods.""" +import pytest + +from detectmatelibrary.common.core import CoreComponent, CoreConfig +from detectmatelibrary.common.detector import CoreDetector, CoreDetectorConfig +from detectmatelibrary.common.parser import CoreParser, CoreParserConfig +from detectmatelibrary.exceptions import ( + ComponentRunError, + DetectMateError, + DetectorRunError, + ParserRunError, +) +import detectmatelibrary.schemas as schemas + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +class _ErrorInRun(CoreComponent): + """Component whose run() always raises a bare KeyError.""" + + def __init__(self) -> None: + super().__init__(name="ErrorInRun", type_="Dummy", config=CoreConfig(), + input_schema=schemas.LogSchema) + + def run(self, input_, output_) -> bool: + raise KeyError("missing_key") + + +class _ErrorInDetect(CoreDetector): + """Detector whose detect() always raises a bare KeyError.""" + + def __init__(self) -> None: + super().__init__(name="ErrorInDetect", config=CoreDetectorConfig()) + + def detect(self, input_, output_) -> bool: + raise KeyError("bad_event_id") + + +class _ErrorInParse(CoreParser): + """Parser whose parse() always raises a bare ValueError.""" + + def __init__(self) -> None: + super().__init__(name="ErrorInParse", config=CoreParserConfig()) + + def parse(self, input_, output_) -> bool: + raise ValueError("unrecognised_format") + + +_log = schemas.LogSchema({"logID": "1", "log": "test log"}) +_parser_schema = schemas.ParserSchema({ + "parserType": "a", + "EventID": 0, + "template": "asd", + "variables": [""], + "logID": "0", + "parsedLogID": "22", + "parserID": "test", + "log": "This is a parsed log.", + "logFormatVariables": {"Time": "12121.12"}, +}) + + +# --------------------------------------------------------------------------- +# Exception hierarchy +# --------------------------------------------------------------------------- + +class TestExceptionHierarchy: + def test_detector_run_error_is_component_run_error(self) -> None: + assert issubclass(DetectorRunError, ComponentRunError) + + def test_parser_run_error_is_component_run_error(self) -> None: + assert issubclass(ParserRunError, ComponentRunError) + + def test_component_run_error_is_detectmate_error(self) -> None: + assert issubclass(ComponentRunError, DetectMateError) + + def test_detectmate_error_is_exception(self) -> None: + assert issubclass(DetectMateError, Exception) + + +# --------------------------------------------------------------------------- +# ComponentRunError wrapping +# --------------------------------------------------------------------------- + +class TestComponentRunError: + def test_bare_exception_in_run_is_wrapped(self) -> None: + component = _ErrorInRun() + with pytest.raises(ComponentRunError): + component.process(_log) + + def test_original_cause_is_preserved(self) -> None: + component = _ErrorInRun() + with pytest.raises(ComponentRunError) as exc_info: + component.process(_log) + assert isinstance(exc_info.value.__cause__, KeyError) + + def test_component_name_in_message(self) -> None: + component = _ErrorInRun() + with pytest.raises(ComponentRunError) as exc_info: + component.process(_log) + assert "ErrorInRun" in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# DetectorRunError wrapping +# --------------------------------------------------------------------------- + +class TestDetectorRunError: + def test_bare_exception_in_detect_is_wrapped(self) -> None: + detector = _ErrorInDetect() + with pytest.raises(DetectorRunError): + detector.process(_parser_schema) + + def test_original_cause_is_preserved(self) -> None: + detector = _ErrorInDetect() + with pytest.raises(DetectorRunError) as exc_info: + detector.process(_parser_schema) + assert isinstance(exc_info.value.__cause__, KeyError) + + def test_detector_name_in_message(self) -> None: + detector = _ErrorInDetect() + with pytest.raises(DetectorRunError) as exc_info: + detector.process(_parser_schema) + assert "ErrorInDetect" in str(exc_info.value) + + def test_detector_run_error_catchable_as_component_run_error(self) -> None: + detector = _ErrorInDetect() + with pytest.raises(ComponentRunError): + detector.process(_parser_schema) + + def test_detector_run_error_catchable_as_detectmate_error(self) -> None: + detector = _ErrorInDetect() + with pytest.raises(DetectMateError): + detector.process(_parser_schema) + + +# --------------------------------------------------------------------------- +# ParserRunError wrapping +# --------------------------------------------------------------------------- + +class TestParserRunError: + def test_bare_exception_in_parse_is_wrapped(self) -> None: + parser = _ErrorInParse() + with pytest.raises(ParserRunError): + parser.process(_log) + + def test_original_cause_is_preserved(self) -> None: + parser = _ErrorInParse() + with pytest.raises(ParserRunError) as exc_info: + parser.process(_log) + assert isinstance(exc_info.value.__cause__, ValueError) + + def test_parser_name_in_message(self) -> None: + parser = _ErrorInParse() + with pytest.raises(ParserRunError) as exc_info: + parser.process(_log) + assert "ErrorInParse" in str(exc_info.value) + + def test_parser_run_error_catchable_as_component_run_error(self) -> None: + parser = _ErrorInParse() + with pytest.raises(ComponentRunError): + parser.process(_log) + + def test_parser_run_error_catchable_as_detectmate_error(self) -> None: + parser = _ErrorInParse() + with pytest.raises(DetectMateError): + parser.process(_log) diff --git a/tests/test_pipelines/test_bad_players.py b/tests/test_pipelines/test_bad_players.py index 27f21d8..1f7e140 100644 --- a/tests/test_pipelines/test_bad_players.py +++ b/tests/test_pipelines/test_bad_players.py @@ -3,6 +3,7 @@ from detectmatelibrary.common.parser import CoreParser import detectmatelibrary.schemas._classes as schema_classes +from detectmatelibrary.exceptions import ComponentRunError from detectmatelibrary.helper.from_to import From import pytest @@ -66,5 +67,6 @@ def test_get_incorrect_schema(self) -> None: buffer_size=None, ) - with pytest.raises(schema_classes.FieldNotFound): + with pytest.raises(ComponentRunError) as exc_info: next(From.log(detector, log_path)) + assert isinstance(exc_info.value.__cause__, schema_classes.FieldNotFound)