diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 917a441..986ead8 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -35,4 +35,4 @@ jobs: run: uv run --dev prek run -a - name: Test with pytest - run: uv run --dev pytest --ignore tests/library_integration + run: uv run --dev pytest --ignore tests/library_integration -s diff --git a/src/service/core.py b/src/service/core.py index a7b5150..c57a34b 100644 --- a/src/service/core.py +++ b/src/service/core.py @@ -153,7 +153,7 @@ def get_config_schema(self) -> Type[CoreConfig]: """Return the configuration schema for this service. If component_config_class is specified in settings, load and - return it. Otherwise return the default CoreConfig. + return it. Otherwise, return the default CoreConfig. """ if hasattr(self.settings, 'component_config_class') and self.settings.component_config_class: try: diff --git a/src/service/features/config_manager.py b/src/service/features/config_manager.py index ac90714..3320b73 100644 --- a/src/service/features/config_manager.py +++ b/src/service/features/config_manager.py @@ -12,6 +12,7 @@ class ServiceConfig(BaseModel): detectors: Optional[Dict[str, Dict[str, Any]]] = None parsers: Optional[Dict[str, Dict[str, Any]]] = None + readers: Optional[Dict[str, Dict[str, Any]]] = None class ConfigManager: @@ -52,9 +53,9 @@ def load(self) -> None: if self.schema and data: # Problem: mismatch between component config schema and structure the library expects # cannot validate against self.schema - # self.schema does not accept "detectors" or "parsers" key which the library expects - # cannot nest, because self.schema does not accept a params field, which the library expects - # --> validate against ServiceConfig here, let library handle the rest + # self.schema does not accept "detectors", "parsers" or "readers" key which the library + # expects cannot nest, because self.schema does not accept a params field, which the library + # expects --> validate against ServiceConfig here, let library handle the rest self._configs = ServiceConfig.model_validate(data) self.logger.debug(f"Validated params: {self._configs}") diff --git a/src/service/metadata.py b/src/service/metadata.py index 7be6f97..6c0318a 100644 --- a/src/service/metadata.py +++ b/src/service/metadata.py @@ -7,7 +7,7 @@ __website__ = 'https://aecid.ait.ac.at' __license__ = 'EUPL-1.2' __status__ = 'Development' -__version__ = '0.1.2' +__version__ = '0.1.3' __all__ = [ '__authors__', '__contact__', diff --git a/tests/library_integration/library_integration_base.py b/tests/library_integration/library_integration_base.py new file mode 100644 index 0000000..eda64ee --- /dev/null +++ b/tests/library_integration/library_integration_base.py @@ -0,0 +1,50 @@ +import time +import json +import signal +import yaml +import sys +from subprocess import Popen, PIPE, TimeoutExpired + + +def start_service(module_path, settings, config, settings_file, config_file): + with open(settings_file, "w") as f: + yaml.dump(settings, f) + with open(config_file, "w") as f: + yaml.dump(config, f) + url = f"http://{settings["http_host"]}:{settings["http_port"]}" + proc = Popen([sys.executable, "-m", "service.cli", "--settings", + str(settings_file), "--config", str(config_file)], cwd=module_path) + + max_retries = 10 + for attempt in range(max_retries): + status = Popen([sys.executable, "-m", "service.client", "--url", + url, "status"], cwd=module_path, stdout=PIPE) + stdout = status.communicate(timeout=5) + time.sleep(1) + try: + data = json.loads(stdout[0]) + if data.get("status", {}).get("running"): + break + except json.JSONDecodeError: + # Service may not yet be returning valid JSON; ignore and retry until max_retries is reached. + pass + if attempt == max_retries - 1: + proc.terminate() + proc.wait(timeout=5) + raise RuntimeError(f"Service not ready within {max_retries} attempts") + time.sleep(0.2) + return proc, url + + +def cleanup_service(module_path, proc, url): + stop = Popen([sys.executable, "-m", "service.client", "--url", url, "stop"], cwd=module_path) + stop.communicate(timeout=5) + proc.send_signal(signal.SIGINT) + try: + proc.wait(timeout=5) + except TimeoutExpired: + # If it doesn't exit, force kill + proc.kill() + proc.wait() + except Exception: # skip any other exception and continue testing + pass diff --git a/tests/library_integration/library_integration_base_fixtures.py b/tests/library_integration/library_integration_base_fixtures.py new file mode 100644 index 0000000..658c1a1 --- /dev/null +++ b/tests/library_integration/library_integration_base_fixtures.py @@ -0,0 +1,115 @@ +import pytest +from pathlib import Path +from detectmatelibrary.schemas import ParserSchema, LogSchema + + +@pytest.fixture(scope="session") +def test_log_file() -> Path: + """Return path to the test log file in this folder.""" + return Path(__file__).parent / "test_logs.log" + + +@pytest.fixture(scope="session") +def audit_log_file() -> Path: + return Path(__file__).parent / "audit.log" + + +@pytest.fixture(scope="session") +def test_templates_file() -> Path: + return Path(__file__).parent / "audit_templates.txt" + + +@pytest.fixture(scope="session") +def test_parser_messages() -> list: + """Generate test ParserSchema messages for detector input.""" + messages = [] + parser_configs = [ + { + "parserType": "LogParser", + "parserID": "parser_001", + "EventID": 1, + "template": "User <*> logged in from <*>", + "variables": ["john", "192.168.1.100"], + "parsedLogID": "101", + "logID": "1", + "log": "User john logged in from 192.168.1.100", + "logFormatVariables": { + "username": "john", + "ip": "192.168.1.100", + "Time": "1634567890" + }, + "receivedTimestamp": 1634567890, + "parsedTimestamp": 1634567891, + }, + { + "parserType": "LogParser", + "parserID": "parser_002", + "EventID": 2, + "template": "Database query failed: <*>", + "variables": ["connection timeout"], + "parsedLogID": "102", + "logID": "2", + "log": "Database query failed: connection timeout", + "logFormatVariables": { + "error": "connection timeout", + "severity": "HIGH", + "Time": "1634567900" + }, + "receivedTimestamp": 1634567900, + "parsedTimestamp": 1634567901, + }, + { + "parserType": "LogParser", + "parserID": "parser_003", + "EventID": 3, + "template": "File <*> accessed by <*> at <*>", + "variables": ["config.txt", "admin", "10:45:30"], + "parsedLogID": "103", + "logID": "3", + "log": "File config.txt accessed by admin at 10:45:30", + "logFormatVariables": { + "filename": "config.txt", + "user": "admin", + "Time": "1634567910" + }, + "receivedTimestamp": 1634567910, + "parsedTimestamp": 1634567911, + }, + ] + + for config in parser_configs: + parser_msg = ParserSchema(config) + byte_message = parser_msg.serialize() + messages.append(byte_message) + return messages + + +@pytest.fixture(scope="session") +def test_log_messages() -> list: + """Fixture providing test LogSchema messages for parser input.""" + messages = [] + log_configs = [ + { + "logID": "1", + "log": "User john logged in from 192.168.1.100", + "logSource": "auth_server", + "hostname": "server-01", + }, + { + "logID": "2", + "log": "Database query failed: connection timeout", + "logSource": "database", + "hostname": "db-01", + }, + { + "logID": "3", + "log": "File config.txt accessed by admin at 10:45:30", + "logSource": "file_server", + "hostname": "fs-01", + }, + ] + for config in log_configs: + log_msg = LogSchema(config) + byte_message = log_msg.serialize() + messages.append(byte_message) + return messages diff --git a/tests/library_integration/test_detector_integration.py b/tests/library_integration/test_detector_integration.py index 8176bbf..dd212e2 100644 --- a/tests/library_integration/test_detector_integration.py +++ b/tests/library_integration/test_detector_integration.py @@ -4,82 +4,15 @@ Timeout means no detection occurred (detector returns None/False). DummyDetector alternates: False, True, False """ +from library_integration_base import start_service, cleanup_service import time from pathlib import Path -from subprocess import Popen from typing import Generator import pytest import pynng -import yaml -import sys import os -from detectmatelibrary.schemas import DetectorSchema, ParserSchema - - -@pytest.fixture(scope="session") -def test_parser_messages() -> list: - """Generate test ParserSchema messages for detector input.""" - messages = [] - parser_configs = [ - { - "parserType": "LogParser", - "parserID": "parser_001", - "EventID": 1, - "template": "User <*> logged in from <*>", - "variables": ["john", "192.168.1.100"], - "parsedLogID": "101", - "logID": "1", - "log": "User john logged in from 192.168.1.100", - "logFormatVariables": { - "username": "john", - "ip": "192.168.1.100", - "Time": "1634567890" - }, - "receivedTimestamp": 1634567890, - "parsedTimestamp": 1634567891, - }, - { - "parserType": "LogParser", - "parserID": "parser_002", - "EventID": 2, - "template": "Database query failed: <*>", - "variables": ["connection timeout"], - "parsedLogID": "102", - "logID": "2", - "log": "Database query failed: connection timeout", - "logFormatVariables": { - "error": "connection timeout", - "severity": "HIGH", - "Time": "1634567900" - }, - "receivedTimestamp": 1634567900, - "parsedTimestamp": 1634567901, - }, - { - "parserType": "LogParser", - "parserID": "parser_003", - "EventID": 3, - "template": "File <*> accessed by <*> at <*>", - "variables": ["config.txt", "admin", "10:45:30"], - "parsedLogID": "103", - "logID": "3", - "log": "File config.txt accessed by admin at 10:45:30", - "logFormatVariables": { - "filename": "config.txt", - "user": "admin", - "Time": "1634567910" - }, - "receivedTimestamp": 1634567910, - "parsedTimestamp": 1634567911, - }, - ] - - for config in parser_configs: - parser_msg = ParserSchema(config) - byte_message = parser_msg.serialize() - messages.append(byte_message) - - return messages +from detectmatelibrary.schemas import DetectorSchema +pytest_plugins = ["library_integration_base_fixtures"] @pytest.fixture(scope="function") @@ -88,12 +21,12 @@ def running_detector_service(tmp_path: Path) -> Generator[dict, None, None]: info.""" timestamp = int(time.time() * 1000) module_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - settings = { "component_type": "detectors.dummy_detector.DummyDetector", "component_config_class": "detectors.dummy_detector.DummyDetectorConfig", "component_name": "test-detector", - "manager_addr": f"ipc:///tmp/test_detector_cmd_{timestamp}.ipc", + "http_host": "127.0.0.1", + "http_port": "8010", "engine_addr": f"ipc:///tmp/test_detector_engine_{timestamp}.ipc", "log_level": "DEBUG", "log_dir": "./logs", @@ -101,57 +34,19 @@ def running_detector_service(tmp_path: Path) -> Generator[dict, None, None]: "log_to_file": False, "engine_autostart": True, } - config = {} # DummyDetectorConfig has no additional config - - # Write YAML files - settings_file = tmp_path / "detector_settings.yaml" - config_file = tmp_path / "detector_config.yaml" - - with open(settings_file, "w") as f: - yaml.dump(settings, f) - - with open(config_file, "w") as f: - yaml.dump(config, f) - - # Start service - proc = Popen( - [sys.executable, "-m", "service.cli", "start", - "--settings", str(settings_file), - "--config", str(config_file)], - cwd=module_path, - ) - + proc, url = start_service(module_path, settings, config, tmp_path / + "detector_settings.yaml", tmp_path / "detector_config.yaml") service_info = { "process": proc, - "manager_addr": settings["manager_addr"], + "http_host": settings["http_host"], + "http_port": settings["http_port"], "engine_addr": settings["engine_addr"], } - # Wait for service to be ready - max_retries = 10 - for attempt in range(max_retries): - try: - with pynng.Req0(dial=service_info["manager_addr"], recv_timeout=1000) as sock: - sock.send(b"ping") - if sock.recv().decode() == "pong": - break - except Exception: - if attempt == max_retries - 1: - proc.terminate() - proc.wait(timeout=5) - raise RuntimeError(f"Detector service not ready within {max_retries} attempts") - time.sleep(0.2) - yield service_info - # Cleanup - try: - with pynng.Req0(dial=service_info["manager_addr"], recv_timeout=5000) as sock: - sock.send(b"stop") - sock.recv() - except Exception: - pass + cleanup_service(module_path, proc, url) class TestDetectorServiceViaEngine: diff --git a/tests/library_integration/test_one_pipe_to_rule_them_all.py b/tests/library_integration/test_one_pipe_to_rule_them_all.py index 23ab23c..997ca7d 100644 --- a/tests/library_integration/test_one_pipe_to_rule_them_all.py +++ b/tests/library_integration/test_one_pipe_to_rule_them_all.py @@ -7,22 +7,18 @@ The DummyDetector alternates: False, True, False """ +from library_integration_base import start_service, cleanup_service import time from pathlib import Path -from subprocess import Popen from typing import Generator import pytest import pynng -import yaml +import json import sys import os +from subprocess import Popen, PIPE from detectmatelibrary.schemas import LogSchema, ParserSchema, DetectorSchema - - -@pytest.fixture(scope="session") -def test_log_file() -> Path: - """Return path to the test log file in this folder.""" - return Path(__file__).parent / "test_logs.log" +pytest_plugins = ["library_integration_base_fixtures"] @pytest.fixture(scope="function") @@ -37,7 +33,8 @@ def running_pipeline_services(tmp_path: Path, test_log_file: Path) -> Generator[ "component_type": "readers.log_file.LogFileReader", "component_config_class": "readers.log_file.LogFileConfig", "component_name": "test-reader", - "manager_addr": f"ipc:///tmp/test_pipeline_reader_cmd_{timestamp}.ipc", + "http_host": "127.0.0.1", + "http_port": "8010", "engine_addr": f"ipc:///tmp/test_pipeline_reader_engine_{timestamp}.ipc", "log_level": "DEBUG", "log_dir": "./logs", @@ -62,7 +59,8 @@ def running_pipeline_services(tmp_path: Path, test_log_file: Path) -> Generator[ "component_type": "parsers.dummy_parser.DummyParser", "component_config_class": "parsers.dummy_parser.DummyParserConfig", "component_name": "test-parser", - "manager_addr": f"ipc:///tmp/test_pipeline_parser_cmd_{timestamp}.ipc", + "http_host": "127.0.0.1", + "http_port": "8020", "engine_addr": f"ipc:///tmp/test_pipeline_parser_engine_{timestamp}.ipc", "log_level": "DEBUG", "log_dir": "./logs", @@ -77,7 +75,8 @@ def running_pipeline_services(tmp_path: Path, test_log_file: Path) -> Generator[ "component_type": "detectors.dummy_detector.DummyDetector", "component_config_class": "detectors.dummy_detector.DummyDetectorConfig", "component_name": "test-detector", - "manager_addr": f"ipc:///tmp/test_pipeline_detector_cmd_{timestamp}.ipc", + "http_host": "127.0.0.1", + "http_port": "8030", "engine_addr": f"ipc:///tmp/test_pipeline_detector_engine_{timestamp}.ipc", "log_level": "DEBUG", "log_dir": "./logs", @@ -87,101 +86,35 @@ def running_pipeline_services(tmp_path: Path, test_log_file: Path) -> Generator[ } detector_config = {} - # Write all YAML files - reader_settings_file = tmp_path / "reader_settings.yaml" - reader_config_file = tmp_path / "reader_config.yaml" - parser_settings_file = tmp_path / "parser_settings.yaml" - parser_config_file = tmp_path / "parser_config.yaml" - detector_settings_file = tmp_path / "detector_settings.yaml" - detector_config_file = tmp_path / "detector_config.yaml" - - with open(reader_settings_file, "w") as f: - yaml.dump(reader_settings, f) - with open(reader_config_file, "w") as f: - yaml.dump(reader_config, f) - with open(parser_settings_file, "w") as f: - yaml.dump(parser_settings, f) - with open(parser_config_file, "w") as f: - yaml.dump(parser_config, f) - with open(detector_settings_file, "w") as f: - yaml.dump(detector_settings, f) - with open(detector_config_file, "w") as f: - yaml.dump(detector_config, f) - - # Start all services - reader_proc = Popen( - [sys.executable, "-m", "service.cli", "start", - "--settings", str(reader_settings_file), - "--config", str(reader_config_file)], - cwd=module_path, - ) - - parser_proc = Popen( - [sys.executable, "-m", "service.cli", "start", - "--settings", str(parser_settings_file), - "--config", str(parser_config_file)], - cwd=module_path, - ) - - detector_proc = Popen( - [sys.executable, "-m", "service.cli", "start", - "--settings", str(detector_settings_file), - "--config", str(detector_config_file)], - cwd=module_path, - ) + reader_proc, reader_url = start_service( + module_path, reader_settings, reader_config, tmp_path / "reader_settings.yaml", + tmp_path / "reader_config.yaml") + parser_proc, parser_url = start_service( + module_path, parser_settings, parser_config, tmp_path / "parser_settings.yaml", + tmp_path / "parser_config.yaml") + detector_proc, detector_url = start_service( + module_path, detector_settings, detector_config, tmp_path / "detector_settings.yaml", + tmp_path / "detector_config.yaml") time.sleep(1.0) - service_info = { "reader_process": reader_proc, "parser_process": parser_proc, "detector_process": detector_proc, - "reader_manager_addr": reader_settings["manager_addr"], + "http_host": reader_settings["http_host"], + "reader_http_port": reader_settings["http_port"], "reader_engine_addr": reader_settings["engine_addr"], - "parser_manager_addr": parser_settings["manager_addr"], + "parser_http_port": parser_settings["http_port"], "parser_engine_addr": parser_settings["engine_addr"], - "detector_manager_addr": detector_settings["manager_addr"], + "detector_http_port": detector_settings["http_port"], "detector_engine_addr": detector_settings["engine_addr"], } - # Verify all services are running - max_retries = 10 - for service_name, manager_addr in [ - ("reader", service_info["reader_manager_addr"]), - ("parser", service_info["parser_manager_addr"]), - ("detector", service_info["detector_manager_addr"]), - ]: - for attempt in range(max_retries): - try: - with pynng.Req0(dial=manager_addr, recv_timeout=2000) as sock: - sock.send(b"ping") - if sock.recv().decode() == "pong": - break - except Exception: - if attempt == max_retries - 1: - reader_proc.terminate() - parser_proc.terminate() - detector_proc.terminate() - reader_proc.wait(timeout=5) - parser_proc.wait(timeout=5) - detector_proc.wait(timeout=5) - raise RuntimeError(f"{service_name} service not ready within {max_retries} attempts") - time.sleep(0.2) - yield service_info - # Cleanup all services - for proc, manager_addr in [ - (reader_proc, service_info["reader_manager_addr"]), - (parser_proc, service_info["parser_manager_addr"]), - (detector_proc, service_info["detector_manager_addr"]), - ]: - try: - with pynng.Req0(dial=manager_addr, recv_timeout=5000) as sock: - sock.send(b"stop") - sock.recv() - except Exception: - pass + cleanup_service(module_path, reader_proc, reader_url) + cleanup_service(module_path, parser_proc, parser_url) + cleanup_service(module_path, detector_proc, detector_url) class TestFullPipeline: @@ -189,15 +122,30 @@ class TestFullPipeline: def test_all_services_start_successfully(self, running_pipeline_services: dict) -> None: """Verify all three services start and respond to ping.""" - for service_name, manager_addr in [ - ("reader", running_pipeline_services["reader_manager_addr"]), - ("parser", running_pipeline_services["parser_manager_addr"]), - ("detector", running_pipeline_services["detector_manager_addr"]), + module_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + for service_name, host, port in [ + ("reader", running_pipeline_services["http_host"], running_pipeline_services["reader_http_port"]), + ("parser", running_pipeline_services["http_host"], running_pipeline_services["parser_http_port"]), + ("detector", running_pipeline_services["http_host"], + running_pipeline_services["detector_http_port"]), ]: - with pynng.Req0(dial=manager_addr, recv_timeout=2000) as sock: - sock.send(b"ping") - reply = sock.recv().decode() - assert reply == "pong", f"{service_name} should respond to ping" + max_retries = 10 + url = f"http://{host}:{port}" + for attempt in range(max_retries): + status = Popen([sys.executable, "-m", "service.client", "--url", + url, "status"], cwd=module_path, stdout=PIPE) + stdout = status.communicate(timeout=5) + time.sleep(1) + try: + data = json.loads(stdout[0]) + if data.get("status", {}).get("running"): + break + except json.JSONDecodeError: + # Service may not yet be returning valid JSON; retry until max_retries is reached. + pass + if attempt == max_retries - 1: + raise RuntimeError(f"Service not ready within {max_retries} attempts") + time.sleep(0.2) def test_single_pipeline_flow(self, running_pipeline_services: dict) -> None: """Test a single message flowing through the entire pipeline.""" @@ -206,7 +154,7 @@ def test_single_pipeline_flow(self, running_pipeline_services: dict) -> None: detector_engine = running_pipeline_services["detector_engine_addr"] # Step 1: Read log from Reader - with pynng.Pair0(dial=reader_engine, recv_timeout=3000) as socket: + with pynng.Pair0(dial=reader_engine, recv_timeout=10000) as socket: socket.send(b"read") log_response = socket.recv() diff --git a/tests/library_integration/test_parser_integration.py b/tests/library_integration/test_parser_integration.py index 6611381..64a7b60 100644 --- a/tests/library_integration/test_parser_integration.py +++ b/tests/library_integration/test_parser_integration.py @@ -2,55 +2,15 @@ Tests verify parsing via engine socket with LogSchema input. """ +from library_integration_base import start_service, cleanup_service import time from pathlib import Path -from subprocess import Popen from typing import Generator import pytest import pynng -import yaml -import sys import os -from detectmatelibrary.schemas import ParserSchema, LogSchema - - -def create_test_log_messages() -> list: - """Generate test LogSchema messages for parser input.""" - messages = [] - log_configs = [ - { - "logID": "1", - "log": "User john logged in from 192.168.1.100", - "logSource": "auth_server", - "hostname": "server-01", - }, - { - "logID": "2", - "log": "Database query failed: connection timeout", - "logSource": "database", - "hostname": "db-01", - }, - { - "logID": "3", - "log": "File config.txt accessed by admin at 10:45:30", - "logSource": "file_server", - "hostname": "fs-01", - }, - ] - for config in log_configs: - log_msg = LogSchema(config) - byte_message = log_msg.serialize() - messages.append(byte_message) - return messages - - -TEST_LOG_MESSAGES = create_test_log_messages() - - -@pytest.fixture(scope="session") -def test_log_messages() -> list: - """Fixture providing test LogSchema messages for parser input.""" - return TEST_LOG_MESSAGES +from detectmatelibrary.schemas import ParserSchema +pytest_plugins = ["library_integration_base_fixtures"] @pytest.fixture(scope="function") @@ -58,12 +18,12 @@ def running_parser_service(tmp_path: Path) -> Generator[dict, None, None]: """Start the parser service with test config and yield connection info.""" timestamp = int(time.time() * 1000) module_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - settings = { "component_type": "parsers.dummy_parser.DummyParser", "component_config_class": "parsers.dummy_parser.DummyParserConfig", "component_name": "test-parser", - "manager_addr": f"ipc:///tmp/test_parser_cmd_{timestamp}.ipc", + "http_host": "127.0.0.1", + "http_port": "8020", "engine_addr": f"ipc:///tmp/test_parser_engine_{timestamp}.ipc", "log_level": "DEBUG", "log_dir": "./logs", @@ -71,71 +31,20 @@ def running_parser_service(tmp_path: Path) -> Generator[dict, None, None]: "log_to_file": False, "engine_autostart": True, } - config = {} - - # Write YAML files - settings_file = tmp_path / "parser_settings.yaml" - config_file = tmp_path / "parser_config.yaml" - with open(settings_file, "w") as f: - yaml.dump(settings, f) - with open(config_file, "w") as f: - yaml.dump(config, f) - - # Start service - proc = Popen( - [sys.executable, "-m", "service.cli", "start", - "--settings", str(settings_file), - "--config", str(config_file)], - cwd=module_path, - ) - + proc, url = start_service(module_path, settings, config, tmp_path / + "parser_settings.yaml", tmp_path / "parser_config.yaml") time.sleep(0.5) - service_info = { "process": proc, - "manager_addr": settings["manager_addr"], + "http_host": settings["http_host"], + "http_port": settings["http_port"], "engine_addr": settings["engine_addr"], } - def is_service_ready(addr: str) -> bool: - """Check if service is ready for actual work, not just ping.""" - try: - with pynng.Pair0(dial=addr, recv_timeout=1000) as sock: - # Send a test message that should get a real response - test_msg = TEST_LOG_MESSAGES[0] - sock.send(test_msg) - response = sock.recv() - return len(response) > 0 - except Exception: - return False - - # Wait for service to be truly ready - max_retries = 5 - for attempt in range(max_retries): - try: - # First check basic ping - with pynng.Req0(dial=service_info["manager_addr"], recv_timeout=2000) as sock: - sock.send(b"ping") - if sock.recv().decode() == "pong": - # Then check if engine is actually processing messages - if is_service_ready(service_info["engine_addr"]): - break - except Exception: - if attempt == max_retries - 1: - proc.terminate() - proc.wait(timeout=5) - raise RuntimeError(f"Parser service not ready within {max_retries} attempts") - time.sleep(0.5) - yield service_info - try: - with pynng.Req0(dial=service_info["manager_addr"], recv_timeout=5000) as sock: - sock.send(b"stop") - sock.recv() - except Exception: - pass # Service might already be dead + cleanup_service(module_path, proc, url) class TestParserServiceViaEngine: @@ -165,6 +74,7 @@ def test_single_parse_returns_valid_result( assert hasattr(parser_schema, "variables"), "ParserSchema should have variables" assert hasattr(parser_schema, "template"), "ParserSchema should have template" except pynng.Timeout: + raise ValueError pytest.skip("Parser service did not respond to message") def test_parse_preserves_original_log( diff --git a/tests/library_integration/test_pipe_filereader_matcher_nvd.py b/tests/library_integration/test_pipe_filereader_matcher_nvd.py index 825c6de..c14560a 100644 --- a/tests/library_integration/test_pipe_filereader_matcher_nvd.py +++ b/tests/library_integration/test_pipe_filereader_matcher_nvd.py @@ -5,32 +5,25 @@ 2. Parser consumes LogSchema and outputs ParserSchema 3. Detector consumes ParserSchema and outputs DetectorSchema (or None) """ +from library_integration_base import start_service, cleanup_service import time from pathlib import Path from subprocess import Popen from typing import Generator import pytest import pynng -import yaml import sys import os +import json +from subprocess import PIPE from detectmatelibrary.schemas import LogSchema, ParserSchema, DetectorSchema - - -@pytest.fixture(scope="session") -def test_log_file() -> Path: - return Path(__file__).parent / "audit.log" - - -@pytest.fixture(scope="session") -def test_templates_file() -> Path: - return Path(__file__).parent / "audit_templates.txt" +pytest_plugins = ["library_integration_base_fixtures"] @pytest.fixture(scope="function") def running_pipeline_services( tmp_path: Path, - test_log_file: Path, + audit_log_file: Path, test_templates_file: Path ) -> Generator[dict, None, None]: """Start all three services (Reader, Parser, Detector) with test @@ -43,7 +36,8 @@ def running_pipeline_services( "component_type": "readers.log_file.LogFileReader", "component_config_class": "readers.log_file.LogFileConfig", "component_name": "test-reader", - "manager_addr": f"ipc:///tmp/test_pipeline_reader_cmd_{timestamp}.ipc", + "http_host": "127.0.0.1", + "http_port": "8010", "engine_addr": f"ipc:///tmp/test_pipeline_reader_engine_{timestamp}.ipc", "log_level": "DEBUG", "log_dir": "./logs", @@ -57,7 +51,7 @@ def running_pipeline_services( "method_type": "log_file_reader", "auto_config": False, "params": { - "file": str(test_log_file) + "file": str(audit_log_file) } } } @@ -68,7 +62,8 @@ def running_pipeline_services( "component_type": "parsers.template_matcher.MatcherParser", "component_config_class": "parsers.template_matcher.MatcherParserConfig", "component_name": "test-parser", - "manager_addr": f"ipc:///tmp/test_pipeline_parser_cmd_{timestamp}.ipc", + "http_host": "127.0.0.1", + "http_port": "8020", "engine_addr": f"ipc:///tmp/test_pipeline_parser_engine_{timestamp}.ipc", "log_level": "DEBUG", "log_dir": "./logs", @@ -98,7 +93,8 @@ def running_pipeline_services( "component_type": "detectors.new_value_detector.NewValueDetector", "component_config_class": "detectors.new_value_detector.NewValueDetectorConfig", "component_name": "test-nvd", - "manager_addr": f"ipc:///tmp/test_pipeline_detector_cmd_{timestamp}.ipc", + "http_host": "127.0.0.1", + "http_port": "8030", "engine_addr": f"ipc:///tmp/test_pipeline_detector_engine_{timestamp}.ipc", "log_level": "DEBUG", "log_dir": "./logs", @@ -108,103 +104,37 @@ def running_pipeline_services( } detector_config = {} - # Write all YAML files - reader_settings_file = tmp_path / "reader_settings.yaml" - reader_config_file = tmp_path / "reader_config.yaml" - parser_settings_file = tmp_path / "parser_settings.yaml" - parser_config_file = tmp_path / "parser_config.yaml" - detector_settings_file = tmp_path / "detector_settings.yaml" - detector_config_file = tmp_path / "detector_config.yaml" - - with open(reader_settings_file, "w") as f: - yaml.dump(reader_settings, f) - with open(reader_config_file, "w") as f: - yaml.dump(reader_config, f) - with open(parser_settings_file, "w") as f: - yaml.dump(parser_settings, f) - with open(parser_config_file, "w") as f: - yaml.dump(parser_config, f) - with open(detector_settings_file, "w") as f: - yaml.dump(detector_settings, f) - with open(detector_config_file, "w") as f: - yaml.dump(detector_config, f) - - # Start all services - reader_proc = Popen( - [sys.executable, "-m", "service.cli", "start", - "--settings", str(reader_settings_file), - "--config", str(reader_config_file)], - cwd=module_path, - ) - - parser_proc = Popen( - [sys.executable, "-m", "service.cli", "start", - "--settings", str(parser_settings_file), - "--config", str(parser_config_file)], - cwd=module_path, - ) - - detector_proc = Popen( - [sys.executable, "-m", "service.cli", "start", - "--settings", str(detector_settings_file)], - cwd=module_path, - ) + reader_proc, reader_url = start_service( + module_path, reader_settings, reader_config, tmp_path / "reader_settings.yaml", + tmp_path / "reader_config.yaml") + parser_proc, parser_url = start_service( + module_path, parser_settings, parser_config, tmp_path / "parser_settings.yaml", + tmp_path / "parser_config.yaml") + detector_proc, detector_url = start_service( + module_path, detector_settings, detector_config, tmp_path / "detector_settings.yaml", + tmp_path / "detector_config.yaml") time.sleep(1.5) + time.sleep(5) service_info = { "reader_process": reader_proc, "parser_process": parser_proc, "detector_process": detector_proc, - "reader_manager_addr": reader_settings["manager_addr"], + "http_host": reader_settings["http_host"], + "reader_http_port": reader_settings["http_port"], "reader_engine_addr": reader_settings["engine_addr"], - "parser_manager_addr": parser_settings["manager_addr"], + "parser_http_port": parser_settings["http_port"], "parser_engine_addr": parser_settings["engine_addr"], - "detector_manager_addr": detector_settings["manager_addr"], + "detector_http_port": detector_settings["http_port"], "detector_engine_addr": detector_settings["engine_addr"], } - # Verify all services are running - max_retries = 10 - for service_name, manager_addr in [ - ("reader", service_info["reader_manager_addr"]), - ("parser", service_info["parser_manager_addr"]), - ("detector", service_info["detector_manager_addr"]), - ]: - for attempt in range(max_retries): - try: - with pynng.Req0(dial=manager_addr, recv_timeout=2000) as sock: - sock.send(b"ping") - if sock.recv().decode() == "pong": - break - except Exception: - if attempt == max_retries - 1: - reader_proc.terminate() - parser_proc.terminate() - detector_proc.terminate() - reader_proc.wait(timeout=5) - parser_proc.wait(timeout=5) - detector_proc.wait(timeout=5) - raise RuntimeError(f"{service_name} service not ready within {max_retries} attempts") - time.sleep(0.2) - yield service_info - # Cleanup all services - for proc, manager_addr in [ - (reader_proc, service_info["reader_manager_addr"]), - (parser_proc, service_info["parser_manager_addr"]), - (detector_proc, service_info["detector_manager_addr"]), - ]: - try: - with pynng.Req0(dial=manager_addr, recv_timeout=5000) as sock: - sock.send(b"stop") - sock.recv() - except Exception: - pass - finally: - proc.terminate() - proc.wait(timeout=5) + cleanup_service(module_path, reader_proc, reader_url) + cleanup_service(module_path, parser_proc, parser_url) + cleanup_service(module_path, detector_proc, detector_url) class TestFullPipeline: @@ -212,15 +142,30 @@ class TestFullPipeline: def test_all_services_start_successfully(self, running_pipeline_services: dict) -> None: """Verify all three services start and respond to ping.""" - for service_name, manager_addr in [ - ("reader", running_pipeline_services["reader_manager_addr"]), - ("parser", running_pipeline_services["parser_manager_addr"]), - ("detector", running_pipeline_services["detector_manager_addr"]), + module_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + for service_name, host, port in [ + ("reader", running_pipeline_services["http_host"], running_pipeline_services["reader_http_port"]), + ("parser", running_pipeline_services["http_host"], running_pipeline_services["parser_http_port"]), + ("detector", running_pipeline_services["http_host"], + running_pipeline_services["detector_http_port"]), ]: - with pynng.Req0(dial=manager_addr, recv_timeout=2000) as sock: - sock.send(b"ping") - reply = sock.recv().decode() - assert reply == "pong", f"{service_name} should respond to ping" + max_retries = 10 + url = f"http://{host}:{port}" + for attempt in range(max_retries): + status = Popen([sys.executable, "-m", "service.client", "--url", + url, "status"], cwd=module_path, stdout=PIPE) + stdout = status.communicate(timeout=5) + time.sleep(1) + try: + data = json.loads(stdout[0]) + if data.get("status", {}).get("running"): + break + except json.JSONDecodeError: + # Service may not yet be returning valid JSON; retry until max_retries is reached. + pass + if attempt == max_retries - 1: + raise RuntimeError(f"Service not ready within {max_retries} attempts") + time.sleep(0.2) def test_single_pipeline_flow(self, running_pipeline_services: dict) -> None: """Test a single message flowing through the entire pipeline.""" @@ -364,6 +309,5 @@ def test_full_pipeline_chain(self, running_pipeline_services: dict) -> None: detector_response = socket.recv() detector_schema = DetectorSchema() detector_schema.deserialize(detector_response) - print(f"Detection occurred: {detector_schema}") except pynng.Timeout: pass # no detection diff --git a/tests/library_integration/test_reader_integration.py b/tests/library_integration/test_reader_integration.py index 1b27391..3f4bfe8 100644 --- a/tests/library_integration/test_reader_integration.py +++ b/tests/library_integration/test_reader_integration.py @@ -2,25 +2,18 @@ Tests verify log reading via engine socket. """ +from library_integration_base import start_service, cleanup_service import time from pathlib import Path -from subprocess import Popen from typing import Generator - import pytest import pynng -import yaml import sys import os - +import json +from subprocess import Popen, PIPE from detectmatelibrary.schemas import LogSchema - - -# fixtures and configuration -@pytest.fixture(scope="session") -def test_log_file() -> Path: - """Return path to the test log file in this folder.""" - return Path(__file__).parent / "test_logs.log" +pytest_plugins = ["library_integration_base"] @pytest.fixture @@ -35,13 +28,14 @@ def running_service(tmp_path: Path, test_log_file: Path) -> Generator[dict, None "component_type": "readers.log_file.LogFileReader", "component_config_class": "readers.log_file.LogFileConfig", "component_name": "test-reader", - "manager_addr": f"ipc:///tmp/test_reader_cmd_{timestamp}.ipc", + "http_host": "127.0.0.1", + "http_port": "8010", "engine_addr": f"ipc:///tmp/test_reader_engine_{timestamp}.ipc", "log_level": "DEBUG", "log_dir": "./logs", "log_to_console": False, "log_to_file": False, - "engine_autostart": True, + "engine_autostart": True } config = { @@ -56,53 +50,21 @@ def running_service(tmp_path: Path, test_log_file: Path) -> Generator[dict, None } } - # Write YAML files - settings_file = tmp_path / "reader_settings.yaml" - config_file = tmp_path / "reader_config.yaml" - with open(settings_file, "w") as f: - yaml.dump(settings, f) - with open(config_file, "w") as f: - yaml.dump(config, f) - - # Start service - proc = Popen( - [sys.executable, "-m", "service.cli", "start", - "--settings", str(settings_file), - "--config", str(config_file)], - cwd=module_path, - ) + proc, url = start_service(module_path, settings, config, tmp_path / + "reader_settings.yaml", tmp_path / "reader_config.yaml") time.sleep(0.5) service_info = { "process": proc, - "manager_addr": settings["manager_addr"], + "http_host": settings["http_host"], + "http_port": settings["http_port"], "engine_addr": settings["engine_addr"], } - # Verify service is running - max_retries = 5 - for attempt in range(max_retries): - try: - with pynng.Req0(dial=service_info["manager_addr"], recv_timeout=2000) as sock: - sock.send(b"ping") - if sock.recv().decode() == "pong": - break - except Exception: - if attempt == max_retries - 1: - proc.terminate() - proc.wait(timeout=5) - raise RuntimeError("Service did not start within timeout") - time.sleep(0.5) - yield service_info - try: - proc.terminate() - proc.wait(timeout=5) - except Exception: - proc.kill() - proc.wait() + cleanup_service(module_path, proc, url) # Tests @@ -111,12 +73,27 @@ class TestReaderServiceInitialization: def test_service_starts_successfully(self, running_service: dict) -> None: """Verify the service starts and is responsive to ping.""" - manager_addr = running_service["manager_addr"] - - with pynng.Req0(dial=manager_addr, recv_timeout=2000) as sock: - sock.send(b"ping") - reply = sock.recv().decode() - assert reply == "pong", "Service should respond to ping" + module_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + for service_name, host, port in [ + ("reader", running_service["http_host"], running_service["http_port"]) + ]: + max_retries = 10 + url = f"http://{host}:{port}" + for attempt in range(max_retries): + status = Popen([sys.executable, "-m", "service.client", "--url", + url, "status"], cwd=module_path, stdout=PIPE) + stdout = status.communicate(timeout=5) + time.sleep(1) + try: + data = json.loads(stdout[0]) + if data.get("status", {}).get("running"): + break + except json.JSONDecodeError: + # Service may not yet be returning valid JSON; retry until max_retries is reached. + pass + if attempt == max_retries - 1: + raise RuntimeError(f"Service not ready within {max_retries} attempts") + time.sleep(0.2) class TestReaderServiceViaEngine: diff --git a/uv.lock b/uv.lock index d923194..25dcf39 100644 --- a/uv.lock +++ b/uv.lock @@ -314,7 +314,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/dc/83/4da2d3a11b5e0edf1 [[package]] name = "fastapi" -version = "0.128.8" +version = "0.129.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -323,9 +323,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/01/72/0df5c58c954742f31a7054e2dd1143bae0b408b7f36b59b85f928f9b456c/fastapi-0.128.8.tar.gz", hash = "sha256:3171f9f328c4a218f0a8d2ba8310ac3a55d1ee12c28c949650288aee25966007", size = 375523, upload-time = "2026-02-11T15:19:36.69Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/37/37b07e276f8923c69a5df266bfcb5bac4ba8b55dfe4a126720f8c48681d1/fastapi-0.128.8-py3-none-any.whl", hash = "sha256:5618f492d0fe973a778f8fec97723f598aa9deee495040a8d51aaf3cf123ecf1", size = 103630, upload-time = "2026-02-11T15:19:35.209Z" }, + { url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" }, ] [[package]] @@ -770,26 +770,26 @@ wheels = [ [[package]] name = "prek" -version = "0.3.2" +version = "0.3.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/f5/ee52def928dd1355c20bcfcf765e1e61434635c33f3075e848e7b83a157b/prek-0.3.2.tar.gz", hash = "sha256:dce0074ff1a21290748ca567b4bda7553ee305a8c7b14d737e6c58364a499364", size = 334229, upload-time = "2026-02-06T13:49:47.539Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/f1/7613dc8347a33e40fc5b79eec6bc7d458d8bbc339782333d8433b665f86f/prek-0.3.3.tar.gz", hash = "sha256:117bd46ebeb39def24298ce021ccc73edcf697b81856fcff36d762dd56093f6f", size = 343697, upload-time = "2026-02-15T13:33:28.723Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/69/70a5fc881290a63910494df2677c0fb241d27cfaa435bbcd0de5cd2e2443/prek-0.3.2-py3-none-linux_armv6l.whl", hash = "sha256:4f352f9c3fc98aeed4c8b2ec4dbf16fc386e45eea163c44d67e5571489bd8e6f", size = 4614960, upload-time = "2026-02-06T13:50:05.818Z" }, - { url = "https://files.pythonhosted.org/packages/c0/15/a82d5d32a2207ccae5d86ea9e44f2b93531ed000faf83a253e8d1108e026/prek-0.3.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4a000cfbc3a6ec7d424f8be3c3e69ccd595448197f92daac8652382d0acc2593", size = 4622889, upload-time = "2026-02-06T13:49:53.662Z" }, - { url = "https://files.pythonhosted.org/packages/89/75/ea833b58a12741397017baef9b66a6e443bfa8286ecbd645d14111446280/prek-0.3.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5436bdc2702cbd7bcf9e355564ae66f8131211e65fefae54665a94a07c3d450a", size = 4239653, upload-time = "2026-02-06T13:50:02.88Z" }, - { url = "https://files.pythonhosted.org/packages/10/b4/d9c3885987afac6e20df4cb7db14e3b0d5a08a77ae4916488254ebac4d0b/prek-0.3.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:0161b5f584f9e7f416d6cf40a17b98f17953050ff8d8350ec60f20fe966b86b6", size = 4595101, upload-time = "2026-02-06T13:49:49.813Z" }, - { url = "https://files.pythonhosted.org/packages/21/a6/1a06473ed83dbc898de22838abdb13954e2583ce229f857f61828384634c/prek-0.3.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4e641e8533bca38797eebb49aa89ed0e8db0e61225943b27008c257e3af4d631", size = 4521978, upload-time = "2026-02-06T13:49:41.266Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5e/c38390d5612e6d86b32151c1d2fdab74a57913473193591f0eb00c894c21/prek-0.3.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfca1810d49d3f9ef37599c958c4e716bc19a1d78a7e88cbdcb332e0b008994f", size = 4829108, upload-time = "2026-02-06T13:49:44.598Z" }, - { url = "https://files.pythonhosted.org/packages/80/a6/cecce2ab623747ff65ed990bb0d95fa38449ee19b348234862acf9392fff/prek-0.3.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d69d754299a95a85dc20196f633232f306bee7e7c8cba61791f49ce70404ec", size = 5357520, upload-time = "2026-02-06T13:49:48.512Z" }, - { url = "https://files.pythonhosted.org/packages/a5/18/d6bcb29501514023c76d55d5cd03bdbc037737c8de8b6bc41cdebfb1682c/prek-0.3.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:539dcb90ad9b20837968539855df6a29493b328a1ae87641560768eed4f313b0", size = 4852635, upload-time = "2026-02-06T13:49:58.347Z" }, - { url = "https://files.pythonhosted.org/packages/1b/0a/ae46f34ba27ba87aea5c9ad4ac9cd3e07e014fd5079ae079c84198f62118/prek-0.3.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:1998db3d0cbe243984736c82232be51318f9192e2433919a6b1c5790f600b5fd", size = 4599484, upload-time = "2026-02-06T13:49:43.296Z" }, - { url = "https://files.pythonhosted.org/packages/1a/a9/73bfb5b3f7c3583f9b0d431924873928705cdef6abb3d0461c37254a681b/prek-0.3.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:07ab237a5415a3e8c0db54de9d63899bcd947624bdd8820d26f12e65f8d19eb7", size = 4657694, upload-time = "2026-02-06T13:50:01.074Z" }, - { url = "https://files.pythonhosted.org/packages/a7/bc/0994bc176e1a80110fad3babce2c98b0ac4007630774c9e18fc200a34781/prek-0.3.2-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:0ced19701d69c14a08125f14a5dd03945982edf59e793c73a95caf4697a7ac30", size = 4509337, upload-time = "2026-02-06T13:49:54.891Z" }, - { url = "https://files.pythonhosted.org/packages/f9/13/e73f85f65ba8f626468e5d1694ab3763111513da08e0074517f40238c061/prek-0.3.2-py3-none-musllinux_1_1_i686.whl", hash = "sha256:ffb28189f976fa111e770ee94e4f298add307714568fb7d610c8a7095cb1ce59", size = 4697350, upload-time = "2026-02-06T13:50:04.526Z" }, - { url = "https://files.pythonhosted.org/packages/14/47/98c46dcd580305b9960252a4eb966f1a7b1035c55c363f378d85662ba400/prek-0.3.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:f63134b3eea14421789a7335d86f99aee277cb520427196f2923b9260c60e5c5", size = 4955860, upload-time = "2026-02-06T13:49:56.581Z" }, - { url = "https://files.pythonhosted.org/packages/73/42/1bb4bba3ff47897df11e9dfd774027cdfa135482c961a54e079af0faf45a/prek-0.3.2-py3-none-win32.whl", hash = "sha256:58c806bd1344becd480ef5a5ba348846cc000af0e1fbe854fef91181a2e06461", size = 4267619, upload-time = "2026-02-06T13:49:39.503Z" }, - { url = "https://files.pythonhosted.org/packages/97/11/6665f47a7c350d83de17403c90bbf7a762ef50876ece456a86f64f46fbfb/prek-0.3.2-py3-none-win_amd64.whl", hash = "sha256:70114b48e9eb8048b2c11b4c7715ce618529c6af71acc84dd8877871a2ef71a6", size = 4624324, upload-time = "2026-02-06T13:49:45.922Z" }, - { url = "https://files.pythonhosted.org/packages/22/e7/740997ca82574d03426f897fd88afe3fc8a7306b8c7ea342a8bc1c538488/prek-0.3.2-py3-none-win_arm64.whl", hash = "sha256:9144d176d0daa2469a25c303ef6f6fa95a8df015eb275232f5cb53551ecefef0", size = 4336008, upload-time = "2026-02-06T13:49:52.27Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/dce13d2a3065fd1e8ffce593a0e51c4a79c3cde9c9a15dc0acc8d9d1573d/prek-0.3.3-py3-none-linux_armv6l.whl", hash = "sha256:e8629cac4bdb131be8dc6e5a337f0f76073ad34a8305f3fe2bc1ab6201ede0a4", size = 4644636, upload-time = "2026-02-15T13:33:43.609Z" }, + { url = "https://files.pythonhosted.org/packages/01/30/06ab4dbe7ce02a8ce833e92deb1d9a8e85ae9d40e33d1959a2070b7494c6/prek-0.3.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4b9e819b9e4118e1e785047b1c8bd9aec7e4d836ed034cb58b7db5bcaaf49437", size = 4651410, upload-time = "2026-02-15T13:33:34.277Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fc/da3bc5cb38471e7192eda06b7a26b7c24ef83e82da2c1dbc145f2bf33640/prek-0.3.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bf29db3b5657c083eb8444c25aadeeec5167dc492e9019e188f87932f01ea50a", size = 4273163, upload-time = "2026-02-15T13:33:42.106Z" }, + { url = "https://files.pythonhosted.org/packages/b4/74/47839395091e2937beced81a5dd2f8ea9c8239c853da8611aaf78ee21a8b/prek-0.3.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:ae09736149815b26e64a9d350ca05692bab32c2afdf2939114d3211aaad68a3e", size = 4631808, upload-time = "2026-02-15T13:33:20.076Z" }, + { url = "https://files.pythonhosted.org/packages/e2/89/3f5ef6f7c928c017cb63b029349d6bc03598ab7f6979d4a770ce02575f82/prek-0.3.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:856c2b55c51703c366bb4ce81c6a91102b70573a9fc8637db2ac61c66e4565f9", size = 4548959, upload-time = "2026-02-15T13:33:36.325Z" }, + { url = "https://files.pythonhosted.org/packages/b2/18/80002c4c4475f90ca025f27739a016927a0e5d905c60612fc95da1c56ab7/prek-0.3.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3acdf13a018f685beaff0a71d4b0d2ccbab4eaa1aced6d08fd471c1a654183eb", size = 4862256, upload-time = "2026-02-15T13:33:37.754Z" }, + { url = "https://files.pythonhosted.org/packages/c5/25/648bf084c2468fa7cfcdbbe9e59956bbb31b81f36e113bc9107d80af26a7/prek-0.3.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0f035667a8bd0a77b2bfa2b2e125da8cb1793949e9eeef0d8daab7f8ac8b57fe", size = 5404486, upload-time = "2026-02-15T13:33:39.239Z" }, + { url = "https://files.pythonhosted.org/packages/8b/43/261fb60a11712a327da345912bd8b338dc5a050199de800faafa278a6133/prek-0.3.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d09b2ad14332eede441d977de08eb57fb3f61226ed5fd2ceb7aadf5afcdb6794", size = 4887513, upload-time = "2026-02-15T13:33:40.702Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2c/581e757ee57ec6046b32e0ee25660fc734bc2622c319f57119c49c0cab58/prek-0.3.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c0c3ffac16e37a9daba43a7e8316778f5809b70254be138761a8b5b9ef0df28e", size = 4632336, upload-time = "2026-02-15T13:33:25.867Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d8/aa276ce5d11b77882da4102ca0cb7161095831105043ae7979bbfdcc3dc4/prek-0.3.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a3dc7720b580c07c0386e17af2486a5b4bc2f6cc57034a288a614dcbc4abe555", size = 4679370, upload-time = "2026-02-15T13:33:22.247Z" }, + { url = "https://files.pythonhosted.org/packages/70/19/9d4fa7bde428e58d9f48a74290c08736d42aeb5690dcdccc7a713e34a449/prek-0.3.3-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:60e0fa15da5020a03df2ee40268145ec5b88267ec2141a205317ad4df8c992d6", size = 4540316, upload-time = "2026-02-15T13:33:24.088Z" }, + { url = "https://files.pythonhosted.org/packages/25/b5/973cce29257e0b47b16cc9b4c162772ea01dbb7c080791ea0c068e106e05/prek-0.3.3-py3-none-musllinux_1_1_i686.whl", hash = "sha256:553515da9586d9624dc42db32b744fdb91cf62b053753037a0cadb3c2d8d82a2", size = 4724566, upload-time = "2026-02-15T13:33:29.832Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/ad8b2658895a8ed2b0bc630bf38686fe38b7ff2c619c58953a80e4de3048/prek-0.3.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:9512cf370e0d1496503463a4a65621480efb41b487841a9e9ff1661edf14b238", size = 4995072, upload-time = "2026-02-15T13:33:27.417Z" }, + { url = "https://files.pythonhosted.org/packages/fd/b7/0540c101c00882adb9d30319d22d8f879413598269ecc60235e41875efd4/prek-0.3.3-py3-none-win32.whl", hash = "sha256:b2b328c7c6dc14ccdc79785348589aa39850f47baff33d8f199f2dee80ff774c", size = 4293144, upload-time = "2026-02-15T13:33:46.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/e4f11da653093040efba2d835aa0995d78940aea30887287aeaebe34a545/prek-0.3.3-py3-none-win_amd64.whl", hash = "sha256:3d7d7acf7ca8db65ba0943c52326c898f84bab0b1c26a35c87e0d177f574ca5f", size = 4652761, upload-time = "2026-02-15T13:33:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/11/e4/d99dec54c6a5fb2763488bff6078166383169a93f3af27d2edae88379a39/prek-0.3.3-py3-none-win_arm64.whl", hash = "sha256:8aa87ee7628cd74482c0dd6537a3def1f162b25cd642d78b1b35dd3e81817f60", size = 4367520, upload-time = "2026-02-15T13:33:31.664Z" }, ] [[package]] @@ -913,16 +913,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.12.0" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/a1/ae859ffac5a3338a66b74c5e29e244fd3a3cc483c89feaf9f56c39898d75/pydantic_settings-2.13.0.tar.gz", hash = "sha256:95d875514610e8595672800a5c40b073e99e4aae467fa7c8f9c263061ea2e1fe", size = 222450, upload-time = "2026-02-15T12:11:23.476Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1a/dd1b9d7e627486cf8e7523d09b70010e05a4bc41414f4ae6ce184cf0afb6/pydantic_settings-2.13.0-py3-none-any.whl", hash = "sha256:d67b576fff39cd086b595441bf9c75d4193ca9c0ed643b90360694d0f1240246", size = 58429, upload-time = "2026-02-15T12:11:22.133Z" }, ] [[package]]