Source code for testing.configuration

"""Integration test configuration."""

import logging
import re
import shutil
import sys
from pathlib import Path

import simtools.version as simtools_version
from simtools.io import ascii_handler, io_handler
from simtools.utils import value_conversion

_logger = logging.getLogger(__name__)
_PREPARED_RESOURCE_PATTERN = re.compile(r"\$\{prepared:([^}]+)\}")


[docs] class VersionError(Exception): """Raise if model version requested is not supported."""
[docs] def get_list_of_test_configurations(config_files, test_resources_path=None): """ Return list of test configuration dictionaries or test names. Read all configuration files for testing. Parameters ---------- config_files: list List of integration test configuration files. test_resources_path: str or pathlib.Path, optional Base directory containing the ``static``, ``generated``, and ``downloaded`` resource directories. Returns ------- list: list of test names or of configuration dictionaries. """ _logger.debug(f"Configuration files: {config_files}") configs = _read_configs_from_files(config_files, test_resources_path=test_resources_path) return ( configs, [ f"{item.get('application', 'no-app-name')}_{item.get('test_name', 'no-test-name')}" for item in configs ], )
def _read_configs_from_files(config_files, test_resources_path=None): """Read test configuration from files.""" configs = [] for config_file in config_files: # read config file # remove new line characters from config - otherwise issues # with especially long file names _dict = value_conversion.remove_substring_recursively_from_dict( ascii_handler.collect_data_from_file(file_name=config_file), substring="\n" ) configs.extend( resolve_test_resource_paths( _dict.get("applications", []), test_resources_path=test_resources_path ) ) return configs
[docs] def resolve_test_resource_paths(value, test_resources_path=None): """Resolve test-resource references recursively.""" return io_handler.resolve_test_resource_paths(value, test_resources_path=test_resources_path)
[docs] def resolve_prepared_resource_paths(value, prepared_resources_path): """Resolve per-test prepared-resource references recursively. Parameters ---------- value : object Configuration value, mapping, or sequence to resolve. prepared_resources_path : str or pathlib.Path Directory containing files created by integration-test preparation steps. Returns ------- object Configuration with ``${prepared:...}`` references replaced by absolute paths. Raises ------ ValueError If a prepared-resource reference escapes its preparation directory. """ prepared_resources_path = Path(prepared_resources_path).resolve() if isinstance(value, dict): return { key: resolve_prepared_resource_paths(item, prepared_resources_path) for key, item in value.items() } if isinstance(value, list): return [resolve_prepared_resource_paths(item, prepared_resources_path) for item in value] if not isinstance(value, str): return value def _resolve(match): relative_path = Path(match.group(1)) if relative_path.is_absolute() or ".." in relative_path.parts: raise ValueError(f"Invalid prepared-resource path: {relative_path.as_posix()}") return str(prepared_resources_path / relative_path) return _PREPARED_RESOURCE_PATTERN.sub(_resolve, value)
def _copy_resolved_resource_config_files(config, output_path, test_resources_path): """Copy resource config files to output path with test-resource paths resolved.""" if test_resources_path is None: return test_resources_path = Path(test_resources_path).expanduser().resolve() config_dir = Path(output_path) / "resolved-resource-configs" config_dir.mkdir(parents=True, exist_ok=True) def _resolve(value): if isinstance(value, dict): return {key: _resolve(item) for key, item in value.items()} if isinstance(value, list): return [_resolve(item) for item in value] if not isinstance(value, str): return value path = Path(value).expanduser() if ( not path.is_absolute() or not path.exists() or path.suffix.lower() not in ( ".json", ".yaml", ".yml", ) ): return value try: path.relative_to(test_resources_path) except ValueError: return value resolved_data = resolve_test_resource_paths( ascii_handler.collect_data_from_file(path, test_resources_path=test_resources_path), test_resources_path=test_resources_path, ) resolved_config_file = config_dir / path.name ascii_handler.write_data_to_file( data=resolved_data, output_file=resolved_config_file, sort_keys=False, ) return str(resolved_config_file) config.update(_resolve(config))
[docs] def configure(config, tmp_test_directory, request): """ Prepare configuration and command for integration tests. Parameters ---------- config: dict Configuration dictionary. tmp_test_directory: str Temporary test directory (from pytest fixture). request: request Request object. Returns ------- str: command to run the application test. str: config file model version. """ tmp_output_path = create_tmp_output_path(tmp_test_directory, config) model_version_requested = request.config.getoption("--model_version") model_version_requested = ( model_version_requested.split(",") if model_version_requested else None ) if isinstance(model_version_requested, list) and len(model_version_requested) == 1: model_version_requested = model_version_requested[0] if "configuration" in config: _skip_test_for_model_version(config, model_version_requested) config_file, config_string, config_file_model_version = _prepare_test_options( config["configuration"], output_path=tmp_output_path, model_version=model_version_requested, test_resources_path=request.config.getoption("test_resources_path", default=None), ) else: config_file = None config_string = None config_file_model_version = None cmd = get_application_command( app=config.get("application", None), config_file=config_file, config_string=config_string, ) return cmd, config_file_model_version
def _skip_test_for_model_version(config, model_version_requested): """ Skip test if model version requested is not supported. Compares full and major.minor version strings. """ if not (config.get("model_version_use_current") and model_version_requested): return model_version_config = str(config["configuration"]["model_version"]) if ( simtools_version.compare_versions( model_version_requested, model_version_config, level=simtools_version.version_kind(model_version_requested), ) != 0 ): raise VersionError( f"Model version requested {model_version_requested} not supported for this test" ) def _prepare_test_options(config, output_path, model_version=None, test_resources_path=None): """ Prepare test configuration. This means either to write a temporary config file or to return a single string for single boolean options (e.g., --version). Change output path and file to paths provided with output_path. Parameters ---------- config: dict Dictionary with the configuration for the application test. output_path: str Output path. model_version: str Model versions (default: use those given in config files) Returns ------- config_file: str Path to the temporary configuration file. config_string: str Command line configuration as single string. config_file_model_version: str Configuration file model version """ if len(config) == 1 and next(iter(config.values())) is True: return None, "--" + next(iter(config.keys())).lower(), None tmp_config_file = output_path / "tmp_config.yml" config_file_model_version = config.get("model_version") if isinstance(config_file_model_version, list): config_file_model_version = [str(item) for item in config_file_model_version] elif config_file_model_version is not None: config_file_model_version = str(config_file_model_version) if model_version and "model_version" in config: config.update({"model_version": model_version}) _relocate_output_paths(config, output_path) _copy_resolved_resource_config_files(config, output_path, test_resources_path) _logger.info(f"Writing config file: {tmp_config_file}") ascii_handler.write_data_to_file(data=config, output_file=tmp_config_file, sort_keys=False) return tmp_config_file, None, config_file_model_version def _relocate_output_paths(config, output_path): """Relocate configured output directories and paths beneath them together.""" output_roots = [ Path(config[key]) for key in ("output_path", "grid_output_path") if key in config ] for key, value in config.items(): if not isinstance(value, str | Path): continue path = Path(value) if path.is_absolute(): continue if any(path.is_relative_to(root) for root in output_roots): config[key] = str(Path(output_path) / path)
[docs] def get_application_command(app, config_file=None, config_string=None): """ Return the command to run the application with the given config file. Parameters ---------- app: str Name of the application. config_file: str Configuration file. config_string: str Configuration string (e.g., '--version') Returns ------- str: command to run the application test. """ if "simtools-" in app: cmd = app if shutil.which(app) is None: module_name = app.removeprefix("simtools-").replace("-", "_") cmd = f"{sys.executable} src/simtools/applications/{module_name}.py" else: cmd = f"{sys.executable} src/simtools/applications/{app}.py" if config_string: return f"{cmd} {config_string}" if config_file is not None: return f"{cmd} --config {config_file}" return cmd
[docs] def create_tmp_output_path(tmp_test_directory, config): """ Create temporary output path. Parameters ---------- tmp_test_directory: str Temporary directory. config: dict Configuration dictionary. Returns ------- str: path to the temporary output directory. """ try: tmp_output_path = Path(tmp_test_directory).joinpath( config["application"] + "-" + config["test_name"] ) except KeyError as exc: raise KeyError(f"No application defined in configuration {config}.") from exc tmp_output_path.mkdir(parents=True, exist_ok=True) _logger.info(f"Temporary output path: {tmp_output_path}") return tmp_output_path