diff --git a/integration-tests/.pytest.ini b/integration-tests/.pytest.ini index 6235100f27..07fb7b9ee4 100644 --- a/integration-tests/.pytest.ini +++ b/integration-tests/.pytest.ini @@ -3,6 +3,7 @@ addopts = --capture=no --code-highlight=yes --color=yes + -rA --strict-config --strict-markers --verbose @@ -18,3 +19,4 @@ markers = clp: mark tests that use the CLP storage engine clp_s: mark tests that use the CLP-S storage engine core: mark tests that test the CLP core binaries + package: mark tests that run when the CLP package is active diff --git a/integration-tests/pyproject.toml b/integration-tests/pyproject.toml index 44f079fdd4..d6177ef0ec 100644 --- a/integration-tests/pyproject.toml +++ b/integration-tests/pyproject.toml @@ -27,6 +27,8 @@ dev = [ "ruff>=0.11.12", "pytest>=8.4.1", "pytest-env>=1.1.5", + "PyYAML>=6.0.3", + "types-PyYAML>=6.0.12.20250915", ] [tool.mypy] diff --git a/integration-tests/tests/conftest.py b/integration-tests/tests/conftest.py index e3b07cd0de..224fefcd0d 100644 --- a/integration-tests/tests/conftest.py +++ b/integration-tests/tests/conftest.py @@ -1,6 +1,9 @@ -"""Make the fixtures defined in `tests/fixtures/` globally available without imports.""" +"""Global pytest setup.""" +# Make the fixtures defined in `tests/fixtures/` globally available without imports. pytest_plugins = [ "tests.fixtures.integration_test_logs", "tests.fixtures.path_configs", + "tests.fixtures.package_instance", + "tests.fixtures.package_config", ] diff --git a/integration-tests/tests/fixtures/package_config.py b/integration-tests/tests/fixtures/package_config.py new file mode 100644 index 0000000000..85293697fc --- /dev/null +++ b/integration-tests/tests/fixtures/package_config.py @@ -0,0 +1,37 @@ +"""Fixtures that create and remove temporary config files for CLP packages.""" + +from collections.abc import Iterator + +import pytest + +from tests.utils.clp_mode_utils import get_clp_config_from_mode +from tests.utils.config import PackageConfig, PackagePathConfig + + +@pytest.fixture +def fixt_package_config( + request: pytest.FixtureRequest, + fixt_package_path_config: PackagePathConfig, +) -> Iterator[PackageConfig]: + """ + Creates and maintains a PackageConfig object for a specific CLP mode. + + :param request: + :return: An iterator that yields the PackageConfig object for the specified mode. + """ + mode_name: str = request.param + + # Get the ClpConfig for this mode. + clp_config_obj = get_clp_config_from_mode(mode_name) + + # Construct PackageConfig. + package_config = PackageConfig( + path_config=fixt_package_path_config, + mode_name=mode_name, + clp_config=clp_config_obj, + ) + + try: + yield package_config + finally: + package_config.temp_config_file_path.unlink(missing_ok=True) diff --git a/integration-tests/tests/fixtures/package_instance.py b/integration-tests/tests/fixtures/package_instance.py new file mode 100644 index 0000000000..310e7eaf77 --- /dev/null +++ b/integration-tests/tests/fixtures/package_instance.py @@ -0,0 +1,34 @@ +"""Fixtures that start and stop CLP package instances for integration tests.""" + +from collections.abc import Iterator + +import pytest + +from tests.utils.config import ( + PackageConfig, + PackageInstance, +) +from tests.utils.package_utils import ( + start_clp_package, + stop_clp_package, +) + + +@pytest.fixture +def fixt_package_instance(fixt_package_config: PackageConfig) -> Iterator[PackageInstance]: + """ + Starts a CLP package instance for the given configuration and stops it during teardown. + + :param fixt_package_config: + :return: Iterator that yields the running package instance. + """ + mode_name = fixt_package_config.mode_name + + try: + start_clp_package(fixt_package_config) + instance = PackageInstance(package_config=fixt_package_config) + yield instance + except RuntimeError: + pytest.fail(f"Failed to start the {mode_name} package.") + finally: + stop_clp_package(fixt_package_config) diff --git a/integration-tests/tests/fixtures/path_configs.py b/integration-tests/tests/fixtures/path_configs.py index 023da981a1..bc6e270ac5 100644 --- a/integration-tests/tests/fixtures/path_configs.py +++ b/integration-tests/tests/fixtures/path_configs.py @@ -25,6 +25,11 @@ def integration_test_path_config() -> IntegrationTestPathConfig: @pytest.fixture(scope="session") -def package_path_config() -> PackagePathConfig: +def fixt_package_path_config( + integration_test_path_config: IntegrationTestPathConfig, +) -> PackagePathConfig: """Provides paths for the clp-package directory and its contents.""" - return PackagePathConfig(clp_package_dir=resolve_path_env_var("CLP_PACKAGE_DIR")) + return PackagePathConfig( + clp_package_dir=resolve_path_env_var("CLP_PACKAGE_DIR"), + test_root_dir=integration_test_path_config.test_root_dir, + ) diff --git a/integration-tests/tests/test_package_start.py b/integration-tests/tests/test_package_start.py new file mode 100644 index 0000000000..9a2d1f4503 --- /dev/null +++ b/integration-tests/tests/test_package_start.py @@ -0,0 +1,25 @@ +"""Integration tests verifying that the CLP package can be started and stopped.""" + +import logging + +import pytest + +from tests.utils.clp_mode_utils import CLP_MODE_CONFIGS +from tests.utils.config import PackageInstance + +TEST_MODES = CLP_MODE_CONFIGS.keys() + +logger = logging.getLogger(__name__) + + +@pytest.mark.package +@pytest.mark.parametrize("fixt_package_config", TEST_MODES, indirect=True) +def test_clp_package(fixt_package_instance: PackageInstance) -> None: + """ + Validate that the CLP package starts up successfully for the selected mode(s) of operation. + + :param fixt_package_instance: + """ + # TODO: write code that properly validates that the package is running. This is a placeholder. + mode_name = fixt_package_instance.package_config.mode_name + logger.info("The '%s' package has been spun up successfully.", mode_name) diff --git a/integration-tests/tests/utils/asserting_utils.py b/integration-tests/tests/utils/asserting_utils.py index fe39ec4576..3658f83ad3 100644 --- a/integration-tests/tests/utils/asserting_utils.py +++ b/integration-tests/tests/utils/asserting_utils.py @@ -1,10 +1,14 @@ """Utilities that raise pytest assertions on failure.""" +import logging +import shlex import subprocess from typing import Any import pytest +logger = logging.getLogger(__name__) + def run_and_assert(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[Any]: """ @@ -15,8 +19,12 @@ def run_and_assert(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess :return: The completed process object, for inspection or further handling. :raise: pytest.fail if the command exits with a non-zero return code. """ + logger.info("Running command: %s", shlex.join(cmd)) + try: proc = subprocess.run(cmd, check=True, **kwargs) except subprocess.CalledProcessError as e: pytest.fail(f"Command failed: {' '.join(cmd)}: {e}") + except subprocess.TimeoutExpired as e: + pytest.fail(f"Command timed out: {' '.join(cmd)}: {e}") return proc diff --git a/integration-tests/tests/utils/clp_mode_utils.py b/integration-tests/tests/utils/clp_mode_utils.py new file mode 100644 index 0000000000..e7059051f6 --- /dev/null +++ b/integration-tests/tests/utils/clp_mode_utils.py @@ -0,0 +1,40 @@ +"""Provides utilities related to the user-level configurations of CLP's operating modes.""" + +from collections.abc import Callable + +from clp_py_utils.clp_config import ( + ClpConfig, + Package, + QueryEngine, + StorageEngine, +) + +CLP_MODE_CONFIGS: dict[str, Callable[[], ClpConfig]] = { + "clp-text": lambda: ClpConfig( + package=Package( + storage_engine=StorageEngine.CLP, + query_engine=QueryEngine.CLP, + ), + api_server=None, + ), + "clp-json": lambda: ClpConfig( + package=Package( + storage_engine=StorageEngine.CLP_S, + query_engine=QueryEngine.CLP_S, + ), + ), +} + + +def get_clp_config_from_mode(mode_name: str) -> ClpConfig: + """ + Return a ClpConfig object for the given mode name. + + :param mode_name: + :return: ClpConfig object corresponding to the mode. + :raise ValueError: If the mode is not supported. + """ + if mode_name not in CLP_MODE_CONFIGS: + err_msg = f"Unsupported mode: {mode_name}" + raise ValueError(err_msg) + return CLP_MODE_CONFIGS[mode_name]() diff --git a/integration-tests/tests/utils/config.py b/integration-tests/tests/utils/config.py index 192e2f892d..f958b38640 100644 --- a/integration-tests/tests/utils/config.py +++ b/integration-tests/tests/utils/config.py @@ -4,12 +4,19 @@ from dataclasses import dataclass, field, InitVar from pathlib import Path +from typing import TYPE_CHECKING + +import yaml from tests.utils.utils import ( unlink, validate_dir_exists, + validate_file_exists, ) +if TYPE_CHECKING: + from clp_py_utils.clp_config import ClpConfig + @dataclass(frozen=True) class ClpCorePathConfig: @@ -61,8 +68,15 @@ class PackagePathConfig: #: Root directory containing all CLP package contents. clp_package_dir: Path - def __post_init__(self) -> None: - """Validates that the CLP package directory exists and contains all required directories.""" + #: Root directory for package tests output. + test_root_dir: InitVar[Path] + + #: Directory to store temporary package config files. + temp_config_dir: Path = field(init=False, repr=True) + + def __post_init__(self, test_root_dir: Path) -> None: + """Validates init values and initializes attributes.""" + # Validate that the CLP package directory exists and contains required directories. clp_package_dir = self.clp_package_dir validate_dir_exists(clp_package_dir) @@ -75,6 +89,70 @@ def __post_init__(self) -> None: ) raise RuntimeError(err_msg) + # Initialize directory for package tests. + validate_dir_exists(test_root_dir) + object.__setattr__(self, "temp_config_dir", test_root_dir / "temp_config_files") + + # Create directories if they do not already exist. + self.temp_config_dir.mkdir(parents=True, exist_ok=True) + + @property + def start_script_path(self) -> Path: + """:return: The absolute path to the package start script.""" + return self.clp_package_dir / "sbin" / "start-clp.sh" + + @property + def stop_script_path(self) -> Path: + """:return: The absolute path to the package stop script.""" + return self.clp_package_dir / "sbin" / "stop-clp.sh" + + +@dataclass(frozen=True) +class PackageConfig: + """Metadata for a specific configuration of the CLP package.""" + + #: Path configuration for this package. + path_config: PackagePathConfig + + #: Name of the package operation mode. + mode_name: str + + #: The Pydantic representation of a CLP package configuration. + clp_config: ClpConfig + + def __post_init__(self) -> None: + """Write the temporary config file for this package.""" + self._write_temp_config_file() + + @property + def temp_config_file_path(self) -> Path: + """:return: The absolute path to the temporary configuration file for the package.""" + return self.path_config.temp_config_dir / f"clp-config-{self.mode_name}.yaml" + + def _write_temp_config_file(self) -> None: + """Writes the temporary config file for this package.""" + temp_config_file_path = self.temp_config_file_path + + payload = self.clp_config.dump_to_primitive_dict() # type: ignore[no-untyped-call] + + tmp_path = temp_config_file_path.with_suffix(temp_config_file_path.suffix + ".tmp") + with tmp_path.open("w", encoding="utf-8") as f: + yaml.safe_dump(payload, f, sort_keys=False) + tmp_path.replace(temp_config_file_path) + + +@dataclass(frozen=True) +class PackageInstance: + """Metadata for a running instance of the CLP package.""" + + #: The configuration for this package instance. + package_config: PackageConfig + + def __post_init__(self) -> None: + """Validates init values and initializes attributes.""" + # Validate that the temp config file exists. + validate_file_exists(self.package_config.temp_config_file_path) + @dataclass(frozen=True) class IntegrationTestPathConfig: diff --git a/integration-tests/tests/utils/package_utils.py b/integration-tests/tests/utils/package_utils.py new file mode 100644 index 0000000000..7d714f707a --- /dev/null +++ b/integration-tests/tests/utils/package_utils.py @@ -0,0 +1,46 @@ +"""Provides utility functions related to the CLP package used across `integration-tests`.""" + +from tests.utils.asserting_utils import run_and_assert +from tests.utils.config import PackageConfig + +DEFAULT_CMD_TIMEOUT_SECONDS = 120.0 + + +def start_clp_package(package_config: PackageConfig) -> None: + """ + Starts an instance of the CLP package. + + :param package_config: + :raise: Propagates `run_and_assert`'s errors. + """ + path_config = package_config.path_config + start_script_path = path_config.start_script_path + temp_config_file_path = package_config.temp_config_file_path + + # fmt: off + start_cmd = [ + str(start_script_path), + "--config", str(temp_config_file_path), + ] + # fmt: on + run_and_assert(start_cmd, timeout=DEFAULT_CMD_TIMEOUT_SECONDS) + + +def stop_clp_package(package_config: PackageConfig) -> None: + """ + Stops the running instance of the CLP package. + + :param package_config: + :raise: Propagates `run_and_assert`'s errors. + """ + path_config = package_config.path_config + stop_script_path = path_config.stop_script_path + temp_config_file_path = package_config.temp_config_file_path + + # fmt: off + stop_cmd = [ + str(stop_script_path), + "--config", str(temp_config_file_path), + ] + # fmt: on + run_and_assert(stop_cmd, timeout=DEFAULT_CMD_TIMEOUT_SECONDS) diff --git a/integration-tests/tests/utils/utils.py b/integration-tests/tests/utils/utils.py index 6d856c5551..54bbaf9148 100644 --- a/integration-tests/tests/utils/utils.py +++ b/integration-tests/tests/utils/utils.py @@ -96,6 +96,16 @@ def validate_dir_exists(dir_path: Path) -> None: raise ValueError(err_msg) +def validate_file_exists(file_path: Path) -> None: + """ + :param file_path: + :raise ValueError: if the path does not exist or is not a file. + """ + if not file_path.is_file(): + err_msg = f"Path does not exist or is not a file: {file_path}" + raise ValueError(err_msg) + + def _sort_json_keys_and_rows(json_fp: Path) -> IO[str]: """ Normalize a JSON file to a stable, deterministically ordered form for comparison. diff --git a/integration-tests/uv.lock b/integration-tests/uv.lock index 1f30562b1d..0c9639f52b 100644 --- a/integration-tests/uv.lock +++ b/integration-tests/uv.lock @@ -903,7 +903,9 @@ dev = [ { name = "mypy" }, { name = "pytest" }, { name = "pytest-env" }, + { name = "pyyaml" }, { name = "ruff" }, + { name = "types-pyyaml" }, ] [package.metadata] @@ -919,7 +921,9 @@ dev = [ { name = "mypy", specifier = ">=1.16.0" }, { name = "pytest", specifier = ">=8.4.1" }, { name = "pytest-env", specifier = ">=1.1.5" }, + { name = "pyyaml", specifier = ">=6.0.3" }, { name = "ruff", specifier = ">=0.11.12" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, ] [[package]] @@ -2506,6 +2510,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250915" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"