diff --git a/.conda/benchcab-dev.yaml b/.conda/benchcab-dev.yaml index 0228564c..2652f852 100644 --- a/.conda/benchcab-dev.yaml +++ b/.conda/benchcab-dev.yaml @@ -9,3 +9,4 @@ dependencies: - pytest-cov - pyyaml - flatdict + - cerberus>=1.3.5 \ No newline at end of file diff --git a/.conda/meta.yaml b/.conda/meta.yaml index 0ba08259..21cd03c7 100644 --- a/.conda/meta.yaml +++ b/.conda/meta.yaml @@ -26,3 +26,4 @@ requirements: - PyYAML - f90nml - flatdict + - cerberus >=1.3.5 diff --git a/benchcab/config.py b/benchcab/config.py index f6aaabf4..dc1e7e02 100644 --- a/benchcab/config.py +++ b/benchcab/config.py @@ -1,170 +1,93 @@ """A module containing all *_config() functions.""" - from pathlib import Path import yaml - from benchcab import internal +from cerberus import Validator +import benchcab.utils as bu + + +class ConfigValidationException(Exception): + + def __init__(self, validator: Validator): + """Config validation exception. + + Parameters + ---------- + validator: cerberus.Validator + A validation object that has been used and has the errors attribute. + """ + + # Nicely format the errors. + errors = [f'{k} = {v}' for k, v in validator.errors.items()] + # Assemble the error message and + msg = '\n\nThe following errors were raised when validating the config file.\n' + msg += '\n'.join(errors) + '\n' -def check_config(config: dict): - """Performs input validation on config file. + # Raise to super. + super().__init__(msg) - If the config is invalid, an exception is raised. Otherwise, do nothing. + +def validate_config(config: dict) -> bool: + """Validate the configuration dictionary. + + Parameters + ---------- + config : dict + Dictionary of configuration loaded from the yaml file. + + Returns + ------- + bool + True if valid, exception raised otherwise. + + Raises + ------ + ConfigValidationException + Raised when the configuration file fails validation. + """ + + # Load the schema + schema = bu.load_package_data('config-schema.yml') + + # Create a validator + v = Validator(schema) + + # Validate + is_valid = v.validate(config) + + # Valid + if is_valid: + return True + + # Invalid + raise ConfigValidationException(v) + + +def read_config(config_path: str) -> dict: + """Reads the config file and returns a dictionary containing the configurations. + + Parameters + ---------- + config_path : str + Path to the configuration file. + + Returns + ------- + dict + Configuration dict. + + Raises + ------ + ConfigValidationError + Raised when the configuration file fails validation. """ - if any(key not in config for key in internal.CONFIG_REQUIRED_KEYS): - raise ValueError( - "Keys are missing from the config file: " - + ", ".join( - key for key in internal.CONFIG_REQUIRED_KEYS if key not in config - ) - ) - - if not isinstance(config["project"], str): - msg = "The 'project' key must be a string." - raise TypeError(msg) - - if not isinstance(config["modules"], list): - msg = "The 'modules' key must be a list." - raise TypeError(msg) - - if not isinstance(config["experiment"], str): - msg = "The 'experiment' key must be a string." - raise TypeError(msg) - - # the "science_configurations" key is optional - if "science_configurations" in config: - if not isinstance(config["science_configurations"], list): - msg = "The 'science_configurations' key must be a list." - raise TypeError(msg) - if config["science_configurations"] == []: - msg = "The 'science_configurations' key cannot be empty." - raise ValueError(msg) - if not all( - isinstance(value, dict) for value in config["science_configurations"] - ): - msg = ( - "Science config settings must be specified using a dictionary " - "that is compatible with the f90nml python package." - ) - raise TypeError(msg) - - # the "fluxsite" key is optional - if "fluxsite" in config: - if not isinstance(config["fluxsite"], dict): - msg = "The 'fluxsite' key must be a dictionary." - raise TypeError(msg) - # the "pbs" key is optional - if "pbs" in config["fluxsite"]: - if not isinstance(config["fluxsite"]["pbs"], dict): - msg = "The 'pbs' key must be a dictionary." - raise TypeError(msg) - # the "ncpus" key is optional - if "ncpus" in config["fluxsite"]["pbs"] and not isinstance( - config["fluxsite"]["pbs"]["ncpus"], int - ): - msg = "The 'ncpus' key must be an integer." - raise TypeError(msg) - # the "mem" key is optional - if "mem" in config["fluxsite"]["pbs"] and not isinstance( - config["fluxsite"]["pbs"]["mem"], str - ): - msg = "The 'mem' key must be a string." - raise TypeError(msg) - # the "walltime" key is optional - if "walltime" in config["fluxsite"]["pbs"] and not isinstance( - config["fluxsite"]["pbs"]["walltime"], str - ): - msg = "The 'walltime' key must be a string." - raise TypeError(msg) - # the "storage" key is optional - if "storage" in config["fluxsite"]["pbs"]: - if not isinstance(config["fluxsite"]["pbs"]["storage"], list) or any( - not isinstance(val, str) - for val in config["fluxsite"]["pbs"]["storage"] - ): - msg = "The 'storage' key must be a list of strings." - raise TypeError(msg) - # the "multiprocessing" key is optional - if "multiprocessing" in config["fluxsite"] and not isinstance( - config["fluxsite"]["multiprocessing"], bool - ): - msg = "The 'multiprocessing' key must be a boolean." - raise TypeError(msg) - - valid_experiments = ( - list(internal.MEORG_EXPERIMENTS) + internal.MEORG_EXPERIMENTS["five-site-test"] - ) - if config["experiment"] not in valid_experiments: - msg = ( - "The 'experiment' key is invalid.\n" - "Valid experiments are: " + ", ".join(valid_experiments) - ) - raise ValueError(msg) - - if not isinstance(config["realisations"], list): - msg = "The 'realisations' key must be a list." - raise TypeError(msg) - - if config["realisations"] == []: - msg = "The 'realisations' key cannot be empty." - raise ValueError(msg) - - for branch_id, branch_config in enumerate(config["realisations"]): - if not isinstance(branch_config, dict): - msg = f"Realisation '{branch_id}' must be a dictionary object." - raise TypeError(msg) - if "path" not in branch_config: - msg = f"Realisation '{branch_id}' must specify the `path` field." - raise ValueError(msg) - if not isinstance(branch_config["path"], str): - msg = f"The 'path' field in realisation '{branch_id}' must be a string." - raise TypeError(msg) - # the "name" key is optional - if "name" in branch_config and not isinstance(branch_config["name"], str): - msg = f"The 'name' field in realisation '{branch_id}' must be a string." - raise TypeError(msg) - # the "revision" key is optional - if "revision" in branch_config and not isinstance( - branch_config["revision"], int - ): - msg = ( - f"The 'revision' field in realisation '{branch_id}' must be an " - "integer." - ) - raise TypeError(msg) - # the "patch" key is optional - if "patch" in branch_config and not isinstance(branch_config["patch"], dict): - msg = ( - f"The 'patch' field in realisation '{branch_id}' must be a " - "dictionary that is compatible with the f90nml python package." - ) - raise TypeError(msg) - # the "patch_remove" key is optional - if "patch_remove" in branch_config and not isinstance( - branch_config["patch_remove"], dict - ): - msg = ( - f"The 'patch_remove' field in realisation '{branch_id}' must be a " - "dictionary that is compatible with the f90nml python package." - ) - raise TypeError(msg) - # the "build_script" key is optional - if "build_script" in branch_config and not isinstance( - branch_config["build_script"], str - ): - msg = ( - f"The 'build_script' field in realisation '{branch_id}' must be a " - "string." - ) - raise TypeError(msg) - - -def read_config(config_path: Path) -> dict: - """Reads the config file and returns a dictionary containing the configurations.""" - with config_path.open("r", encoding="utf-8") as file: - config = yaml.safe_load(file) - check_config(config) + # Load the configuration file. + with open(Path(config_path), "r", encoding="utf-8") as file: + config = yaml.safe_load(file) - return config + # Validate and return. + validate_config(config) + return config \ No newline at end of file diff --git a/benchcab/data/config-schema.yml b/benchcab/data/config-schema.yml new file mode 100644 index 00000000..a9325ac5 --- /dev/null +++ b/benchcab/data/config-schema.yml @@ -0,0 +1,77 @@ +project: + type: "string" + +modules: + type: "list" + schema: + type: "string" + +experiment: + type: "string" + allowed: [ + "five-site-test", + "forty-two-site-test", + "AU-Tum", + "AU-How", + "FI-Hyy", + "US-Var", + "US-Whs" + ] + +science_configurations: + type: "list" + schema: + type: "dict" + +realisations: + type: "list" + required: true + schema: + type: "dict" + schema: + path: + type: "string" + name: + type: "string" + required: false + build_script: + type: "string" + required: false + revision: + type: "string" + required: false + patch: + type: "dict" + required: false + patch_remove: + type: "dict" + required: false + +fluxsite: + type: "dict" + required: false + schema: + multiprocessing: + type: "boolean" + required: false + pbs: + type: "dict" + schema: + ncpus: + type: "integer" + required: false + mem: + type: "string" + regex: "^[0-9]+(?i)(mb|gb)$" + required: false + walltime: + type: "string" + regex: "^[0-4][0-9]:[0-5][0-9]:[0-5][0-9]$" + required: false + storage: + type: list + required: false + schema: + type: "string" + required: false + \ No newline at end of file diff --git a/benchcab/data/test/config-invalid.yml b/benchcab/data/test/config-invalid.yml new file mode 100644 index 00000000..0e2f6e6b --- /dev/null +++ b/benchcab/data/test/config-invalid.yml @@ -0,0 +1,19 @@ +# A sample configuration that should fail validation. +project: w97 + +experiment: NON EXISTENT EXPERIMENT!!! + +realisations: [ + { + path: "trunk", + }, + { + path: "branches/Users/ccc561/v3.0-YP-changes", + } +] + +modules: [ + intel-compiler/2021.1.1, + netcdf/4.7.4, + openmpi/4.1.0 +] \ No newline at end of file diff --git a/benchcab/data/test/config-valid.yml b/benchcab/data/test/config-valid.yml new file mode 100644 index 00000000..902a3249 --- /dev/null +++ b/benchcab/data/test/config-valid.yml @@ -0,0 +1,35 @@ +# Example configuration file for running the CABLE benchmarking. +# +# Note, optional keys are available in this config file. See +# https://benchcab.readthedocs.io/en/stable/user_guide/config_options/ +# for documentation on all the available keys. +# +# This file is in YAML format. You can get information on the syntax here: +# https://yaml.org/spec/1.2.2/#chapter-2-language-overview +# Quick tips: +# You need a space after the ":" +# +# It uses the same syntax as Python for: +# - lists (aka sequences) +# - dictionaries (aka mappings with key/value pairs) +# +# Strings can be given with or without double or single quotes. + +project: w97 + +experiment: five-site-test + +realisations: [ + { + path: "trunk", + }, + { + path: "branches/Users/ccc561/v3.0-YP-changes", + } +] + +modules: [ + intel-compiler/2021.1.1, + netcdf/4.7.4, + openmpi/4.1.0 +] \ No newline at end of file diff --git a/benchcab/data/test/integration.sh b/benchcab/data/test/integration.sh new file mode 100644 index 00000000..f3440cbe --- /dev/null +++ b/benchcab/data/test/integration.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +set -ex + +TEST_DIR=/scratch/$PROJECT/$USER/benchcab/integration +EXAMPLE_REPO="git@github.com:CABLE-LSM/bench_example.git" + +# Remove the test work space, then recreate +rm -rf $TEST_DIR +mkdir -p $TEST_DIR + +# Clone the example repo +git clone $EXAMPLE_REPO $TEST_DIR +cd $TEST_DIR +git reset --hard 6287539e96fc8ef36dc578201fbf9847314147fb + +cat > config.yaml << EOL +project: $PROJECT + +experiment: AU-Tum + +realisations: + - path: trunk + - path: branches/Users/sb8430/test-branch + +modules: [ + intel-compiler/2021.1.1, + netcdf/4.7.4, + openmpi/4.1.0 +] + +fluxsite: + pbs: + storage: + - scratch/$PROJECT +EOL + +benchcab run -v \ No newline at end of file diff --git a/benchcab/utils/__init__.py b/benchcab/utils/__init__.py index e69de29b..59095572 100644 --- a/benchcab/utils/__init__.py +++ b/benchcab/utils/__init__.py @@ -0,0 +1,46 @@ +"""Top-level utilities.""" +import pkgutil +import json +import yaml +import os +from importlib import resources +from pathlib import Path + + +# List of one-argument decoding functions. +PACKAGE_DATA_DECODERS = dict( + json=json.loads, + yml=yaml.safe_load +) + + +def get_installed_root() -> Path: + """Get the installed root of the benchcab installation. + + Returns + ------- + Path + Path to the installed root. + """ + return Path(resources.files('benchcab')) + + +def load_package_data(filename: str) -> dict: + """Load data out of the installed package data directory. + + Parameters + ---------- + filename : str + Filename of the file to load out of the data directory. + """ + # Work out the encoding of requested file. + ext = filename.split('.')[-1] + + # Alias yaml and yml. + ext = ext if ext != 'yaml' else 'yml' + + # Extract from the installations data directory. + raw = pkgutil.get_data('benchcab', os.path.join('data', filename)).decode('utf-8') + + # Decode and return. + return PACKAGE_DATA_DECODERS[ext](raw) \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 7b5ebde0..13bc5ab3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -21,3 +21,6 @@ console_scripts = [tool:pytest] addopts = --doctest-modules --doctest-glob='*.rst' --ignore setup.py --ignore conftest.py --ignore docs/conf.py + +[options] +include_package_data = True \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py index 140fb721..b6e0d8a8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,330 +1,35 @@ -"""`pytest` tests for `config.py`. - -Note: explicit teardown for generated files and directories are not required as -the working directory used for testing is cleaned up in the `_run_around_tests` -pytest autouse fixture. -""" - -from pathlib import Path - +"""`pytest` tests for config.py""" import pytest -import yaml - -from benchcab import internal -from benchcab.config import check_config, read_config - - -class TestCheckConfig: - """Tests for `check_config()`.""" - - def test_config_is_valid(self, config): - """Success case: test barebones config is valid.""" - check_config(config) - - def test_branch_configuration_with_missing_name_key(self, config): - """Success case: branch configuration with missing name key.""" - config["realisations"][0].pop("name") - check_config(config) - - def test_branch_configuration_with_missing_revision_key(self, config): - """Success case: branch configuration with missing revision key.""" - config["realisations"][0].pop("revision") - check_config(config) - - def test_branch_configuration_with_missing_patch_key(self, config): - """Success case: branch configuration with missing patch key.""" - config["realisations"][0].pop("patch") - check_config(config) - - def test_branch_configuration_with_missing_patch_remove_key(self, config): - """Success case: branch configuration with missing patch_remove key.""" - config["realisations"][0].pop("patch_remove") - check_config(config) - - def test_config_when_realisations_contains_more_than_two_keys(self, config): - """Success case: test config when realisations contains more than two keys.""" - config["realisations"].append({"path": "path/to/my_new_branch"}) - check_config(config) - - def test_config_when_realisations_contains_less_than_two_keys(self, config): - """Success case: test config when realisations contains less than two keys.""" - config["realisations"].pop() - check_config(config) - - def test_experiment_from_five_site_test(self, config): - """Success case: test experiment with site id from the five-site-test is valid.""" - config["experiment"] = "AU-Tum" - check_config(config) - - def test_config_without_science_configurations_is_valid(self, config): - """Success case: test config without science_configurations is valid.""" - config.pop("science_configurations") - check_config(config) - - def test_config_without_fluxsite_key_is_valid(self, config): - """Success case: test config without fluxsite key is valid.""" - config.pop("fluxsite") - check_config(config) - - def test_config_without_multiprocessing_key_is_valid(self, config): - """Success case: test config without multiprocessing key is valid.""" - config["fluxsite"].pop("multiprocessing") - check_config(config) - - def test_config_without_pbs_key_is_valid(self, config): - """Success case: test config without pbs key is valid.""" - config["fluxsite"].pop("pbs") - check_config(config) - - def test_config_without_ncpus_key_is_valid(self, config): - """Success case: test config without ncpus key is valid.""" - config["fluxsite"]["pbs"].pop("ncpus") - check_config(config) - - def test_config_without_mem_key_is_valid(self, config): - """Success case: test config without mem key is valid.""" - config["fluxsite"]["pbs"].pop("mem") - check_config(config) - - def test_config_without_walltime_key_is_valid(self, config): - """Success case: test config without walltime key is valid.""" - config["fluxsite"]["pbs"].pop("walltime") - check_config(config) - - def test_config_without_storage_key_is_valid(self, config): - """Success case: test config without storage key is valid.""" - config["fluxsite"]["pbs"].pop("storage") - check_config(config) - - def test_missing_required_keys_raises_an_exception(self, config): - """Failure case: test missing required keys raises an exception.""" - config.pop("project") - config.pop("experiment") - with pytest.raises( - ValueError, - match="Keys are missing from the config file: project, experiment", - ): - check_config(config) - - def test_config_with_empty_realisations_key_raises_an_exception(self, config): - """Failure case: test config with empty realisations key raises an exception.""" - config["realisations"] = [] - with pytest.raises(ValueError, match="The 'realisations' key cannot be empty."): - check_config(config) - - def test_config_with_invalid_experiment_key_raises_an_exception(self, config): - """Failure case: test config with invalid experiment key raises an exception.""" - config["experiment"] = "foo" - with pytest.raises( - ValueError, - match="The 'experiment' key is invalid.\n" - "Valid experiments are: " - + ", ".join( - list(internal.MEORG_EXPERIMENTS) - + internal.MEORG_EXPERIMENTS["five-site-test"] - ), - ): - check_config(config) - - def test_invlid_experiment_key_raises_exception(self, config): - """Failure case: test invalid experiment key (not a subset of -site-test).""" - config["experiment"] = "CH-Dav" - with pytest.raises( - ValueError, - match="The 'experiment' key is invalid.\n" - "Valid experiments are: " - + ", ".join( - list(internal.MEORG_EXPERIMENTS) - + internal.MEORG_EXPERIMENTS["five-site-test"] - ), - ): - check_config(config) - - def test_missing_path_key_raises_exception(self, config): - """Failure case: 'path' key is missing in branch configuration.""" - config["realisations"][1].pop("path") - with pytest.raises( - ValueError, match="Realisation '1' must specify the `path` field." - ): - check_config(config) - - def test_empty_science_configurations_raises_exception(self, config): - """Failure case: test empty science_configurations key raises an exception.""" - config["science_configurations"] = [] - with pytest.raises( - ValueError, match="The 'science_configurations' key cannot be empty." - ): - check_config(config) - - def test_project_key_type_error(self, config): - """Failure case: project key is not a string.""" - config["project"] = 123 - with pytest.raises(TypeError, match="The 'project' key must be a string."): - check_config(config) - - def test_realisations_key_type_error(self, config): - """Failure case: realisations key is not a list.""" - config["realisations"] = {"foo": "bar"} - with pytest.raises(TypeError, match="The 'realisations' key must be a list."): - check_config(config) - - def test_realisations_element_type_error(self, config): - """Failure case: realisations key is not a list of dict.""" - config["realisations"] = ["foo"] - with pytest.raises( - TypeError, match="Realisation '0' must be a dictionary object." - ): - check_config(config) - - def test_name_key_type_error(self, config): - """Failure case: type of name is not a string.""" - config["realisations"][1]["name"] = 1234 - with pytest.raises( - TypeError, match="The 'name' field in realisation '1' must be a string." - ): - check_config(config) - - def test_path_key_type_error(self, config): - """Failure case: type of path is not a string.""" - config["realisations"][1]["path"] = 1234 - with pytest.raises( - TypeError, match="The 'path' field in realisation '1' must be a string." - ): - check_config(config) - - def test_revision_key_type_error(self, config): - """Failure case: type of revision key is not an integer.""" - config["realisations"][1]["revision"] = "-1" - with pytest.raises( - TypeError, - match="The 'revision' field in realisation '1' must be an integer.", - ): - check_config(config) - - def test_patch_key_type_error(self, config): - """Failure case: type of patch key is not a dictionary.""" - config["realisations"][1]["patch"] = r"cable_user%ENABLE_SOME_FEATURE = .FALSE." - with pytest.raises( - TypeError, - match="The 'patch' field in realisation '1' must be a dictionary that is " - "compatible with the f90nml python package.", - ): - check_config(config) - - def test_patch_remove_key_type_error(self, config): - """Failure case: type of patch_remove key is not a dictionary.""" - config["realisations"][1]["patch_remove"] = r"cable_user%ENABLE_SOME_FEATURE" - with pytest.raises( - TypeError, - match="The 'patch_remove' field in realisation '1' must be a dictionary that is " - "compatible with the f90nml python package.", - ): - check_config(config) - - def test_build_script_type_error(self, config): - """Failure case: type of build_script key is not a string.""" - config["realisations"][1]["build_script"] = ["echo", "hello"] - with pytest.raises( - TypeError, - match="The 'build_script' field in realisation '1' must be a string.", - ): - check_config(config) - - def test_modules_key_type_error(self, config): - """Failure case: modules key is not a list.""" - config["modules"] = "netcdf" - with pytest.raises(TypeError, match="The 'modules' key must be a list."): - check_config(config) - - def test_experiment_key_type_error(self, config): - """Failure case: experiment key is not a string.""" - config["experiment"] = 0 - with pytest.raises(TypeError, match="The 'experiment' key must be a string."): - check_config(config) - - def test_science_configurations_key_type_error(self, config): - """Failure case: type of config["science_configurations"] is not a list.""" - config["science_configurations"] = r"cable_user%GS_SWITCH = 'medlyn'" - with pytest.raises( - TypeError, match="The 'science_configurations' key must be a list." - ): - check_config(config) - - def test_science_configurations_element_type_error(self, config): - """Failure case: type of config["science_configurations"] is not a list of dict.""" - config["science_configurations"] = [r"cable_user%GS_SWITCH = 'medlyn'"] - with pytest.raises( - TypeError, - match="Science config settings must be specified using a dictionary " - "that is compatible with the f90nml python package.", - ): - check_config(config) - - def test_fluxsite_key_type_error(self, config): - """Failure case: type of config["fluxsite"] is not a dict.""" - config["fluxsite"] = ["ncpus: 16\nmem: 64GB\n"] - with pytest.raises(TypeError, match="The 'fluxsite' key must be a dictionary."): - check_config(config) - - def test_pbs_key_type_error(self, config): - """Failure case: type of config["pbs"] is not a dict.""" - config["fluxsite"]["pbs"] = "-l ncpus=16" - with pytest.raises(TypeError, match="The 'pbs' key must be a dictionary."): - check_config(config) - - def test_ncpus_key_type_error(self, config): - """Failure case: type of config["pbs"]["ncpus"] is not an int.""" - config["fluxsite"]["pbs"]["ncpus"] = "16" - with pytest.raises(TypeError, match="The 'ncpus' key must be an integer."): - check_config(config) - - def test_mem_key_type_error(self, config): - """Failure case: type of config["pbs"]["mem"] is not a string.""" - config["fluxsite"]["pbs"]["mem"] = 64 - with pytest.raises(TypeError, match="The 'mem' key must be a string."): - check_config(config) +import benchcab.utils as bu +import benchcab.config as bc - def test_walltime_key_type_error(self, config): - """Failure case: type of config["pbs"]["walltime"] is not a string.""" - config["fluxsite"]["pbs"]["walltime"] = 60 - with pytest.raises(TypeError, match="The 'walltime' key must be a string."): - check_config(config) - def test_storage_key_type_error(self, config): - """Failure case: type of config["pbs"]["storage"] is not a list.""" - config["fluxsite"]["pbs"]["storage"] = "gdata/foo+gdata/bar" - with pytest.raises( - TypeError, match="The 'storage' key must be a list of strings." - ): - check_config(config) +def test_read_config_pass(): + """Test read_config() passes as expected.""" + existent_path = bu.get_installed_root() / 'data' / 'test' / 'config-valid.yml' + + # Test for a path that exists + config = bc.read_config(existent_path) + assert config - def test_storage_element_type_error(self, config): - """Failure case: type of config["pbs"]["storage"] is not a list of strings.""" - config["fluxsite"]["pbs"]["storage"] = [1, 2, 3] - with pytest.raises( - TypeError, match="The 'storage' key must be a list of strings." - ): - check_config(config) - def test_multiprocessing_key_type_error(self, config): - """Failure case: type of config["multiprocessing"] is not a bool.""" - config["fluxsite"]["multiprocessing"] = 1 - with pytest.raises( - TypeError, match="The 'multiprocessing' key must be a boolean." - ): - check_config(config) +def test_read_config_fail(): + """Test that read_config() fails as expected.""" + nonexistent_path = bu.get_installed_root() / 'data' / 'test' / 'config-missing.yml' + # Test for a path that does not exist. + with pytest.raises(FileNotFoundError): + config = bc.read_config(nonexistent_path) -class TestReadConfig: - """Tests for `read_config()`.""" - def test_read_config(self, config): - """Success case: write config to file, then read config from file.""" - filename = Path("config-barebones.yaml") +def test_validate_config_valid(): + """Test validate_config() for a valid config file.""" + valid_config = bu.load_package_data('test/config-valid.yml') + assert bc.validate_config(valid_config) - with filename.open("w", encoding="utf-8") as file: - yaml.dump(config, file) - res = read_config(filename) - filename.unlink() - assert config == res +def test_validate_config_invalid(): + """Test validate_config() for an invalid config file.""" + invalid_config = bu.load_package_data('test/config-invalid.yml') + with pytest.raises(bc.ConfigValidationException): + bc.validate_config(invalid_config) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..848161db --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,23 @@ +"""Tests for utilities.""" +import pytest +import benchcab.utils as bu + + +def test_get_installed_root(): + """Test get_installed_root().""" + + # Test it actually returns something. We should be able to mock this. + assert bu.get_installed_root() + + +def test_load_package_data_pass(): + """Test load_package_data() passes as expected.""" + + assert isinstance(bu.load_package_data('config-schema.yml'), dict) + + +def test_load_package_data_fail(): + """Test load_package_data() fails as expected.""" + + with pytest.raises(FileNotFoundError): + missing = bu.load_package_data('config-missing.yml') \ No newline at end of file