diff --git a/source/fab/artefacts.py b/source/fab/artefacts.py index 4391117d..5fa79acf 100644 --- a/source/fab/artefacts.py +++ b/source/fab/artefacts.py @@ -129,8 +129,8 @@ def replace(self, artefact: Union[str, ArtefactSet], art_set = self[artefact] if not isinstance(art_set, set): - raise RuntimeError(f"Replacing artefacts in dictionary " - f"'{artefact}' is not supported.") + name = artefact if isinstance(artefact, str) else artefact.name + raise ValueError(f"{name} is not mutable") art_set.difference_update(set(remove_files)) art_set.update(add_files) diff --git a/source/fab/errors.py b/source/fab/errors.py new file mode 100644 index 00000000..1dab3a48 --- /dev/null +++ b/source/fab/errors.py @@ -0,0 +1,344 @@ +############################################################################## +# (c) Crown copyright Met Office. All rights reserved. +# For further details please refer to the file COPYRIGHT +# which you should have received as part of this distribution +############################################################################## +""" +Custom exception classes designed to replace generic RuntimeError +exceptions originally used in fab. +""" + +from pathlib import Path +from typing import List, Optional, TYPE_CHECKING, Union + +if TYPE_CHECKING: + from fab.tools.category import Category + from fab.tools.tool import Tool + + +class FabError(RuntimeError): + """Base class for all fab specific exceptions. + + :param message: reason for the exception + """ + + def __init__(self, message: str) -> None: + self.message = message + + def __str__(self) -> str: + return self.message + + +class FabToolError(FabError): + """Base class for fab tool and toolbox exceptions. + + :param tool: name of the current tool or category + :param message: reason for the exception + """ + + def __init__(self, tool: Union["Category", "Tool", str], message: str) -> None: + self.tool = tool + + # Check for name attributes rather than using isintance + # because Category and Tool ahve issues with circular + # dependencies + if hasattr(tool, "name"): + self.name = tool.name + elif hasattr(tool, "_name"): + self.name = tool._name + else: + self.name = tool + super().__init__(f"[{self.name}] {message}") + + +class FabToolMismatch(FabToolError): + """Tool and category mismatch. + + Error when a tool category does not match the expected setting. + + :param tool: name of the current tool + :param category: name of the current category + :param expectedd: name of the correct category + """ + + def __init__( + self, + tool: Union["Category", "Tool", str], + category: Union["Category", "Tool", type, str], + expected: str, + ) -> None: + self.category = category + self.expected = expected + + super().__init__(tool, f"got type {category} instead of {expected}") + + +class FabToolInvalidVersion(FabToolError): + """Version format problem. + + Error when version information cannot be extracted from a specific + tool. Where a version pattern is available, report this as part + of the error. + + :param tool: name of the current tool + :param value: output from the query command + :param expected: optional format of version string + """ + + def __init__( + self, + tool: Union["Category", "Tool", str], + value: str, + expected: Optional[str] = None, + ) -> None: + self.value = value + self.expected = expected + + message = f"invalid version {repr(self.value)}" + if expected is not None: + message += f" should be {repr(expected)}" + + super().__init__(tool, message) + + +class FabToolPsycloneAPI(FabToolError): + """PSyclone API and target problem. + + Error when the specified PSyclone API, which can be empty to + indicate DSL mode, and the associated target do not match. + + :param api: the current API or empty for DSL mode + :param target: the name of the target + :param present: optionally whether the target is present or + absent. Used to format the error message. Defaults to False. + """ + + def __init__( + self, api: Union[str, None], target: str, present: Optional[bool] = False + ) -> None: + self.target = target + self.present = present + self.api = api + + message = "called " + if api: + message += f"with {api} API " + else: + message += "without API " + if present: + message += "and with " + else: + message += "but not with " + message += f"{target}" + + super().__init__("psyclone", message) + + +class FabToolNotAvailable(FabToolError): + """An unavailable tool has been requested. + + Error where a tool which is not available in a particular suite of + tools has been requested. + + :param tool: name of the current tool + :param suite: optional name of the current tool suite + """ + + def __init__( + self, tool: Union["Category", "Tool", str], suite: Optional[str] = None + ) -> None: + message = "not available" + if suite: + message += f" in suite {suite}" + super().__init__(tool, message) + + +class FabToolInvalidSetting(FabToolError): + """An invalid tool setting has been requested. + + Error where an invalid setting, e.g. MPI, has been requested for a + particular tool. + + :param setting_type: name of the invalid setting + :param tool: the tool to which setting applies + :param additional: optional additional information + """ + + # FIXME: improve these here and in tool_repository + + def __init__( + self, + setting_type: str, + tool: Union["Category", "Tool", str], + additional: Optional[str] = None, + ) -> None: + self.setting_type = setting_type + + message = f"invalid {setting_type}" + if additional: + message += f" {additional}" + + super().__init__(tool, message) + + +class FabUnknownLibraryError(FabError): + """An unknown library has been requested. + + Error where an library which is not known to the current Linker + instance is requested. + + :param library: the name of the unknown library + """ + + def __init__(self, library: str) -> None: + self.library = library + super().__init__(f"unknown library {library}") + + +class FabCommandError(FabError): + """An error was encountered running a subcommand. + + Error where a subcommand run by the fab framework returned with a + non-zero exit code. The exit code plus any data from the stdout + and stderr streams are retained to allow them to be passed back up + the calling stack. + + :param command: the command being run + :param code: the command return code from the OS + :param output: output stream from the command + :param error: error stream from the command + """ + + def __init__( + self, + command: str, + code: int, + output: Union[str, bytes, None], + error: Union[str, bytes, None], + cwd: Optional[Union[str, Path]] = None, + ) -> None: + if isinstance(command, list): + self.command: str = " ".join(command) + else: + self.command = str(command) + self.code = int(code) + self.output = self._decode(output) + self.error = self._decode(error) + self.cwd = cwd + super().__init__(f"command {repr(self.command)} returned {code}") + + def _decode(self, value: Union[str, bytes, None]) -> str: + """Convert from bytes to a string as necessary.""" + if value is None: + return "" + if isinstance(value, bytes): + return value.decode() + return value + + +class FabCommandNotFound(FabError): + """Target command could not be found by subprocess. + + Error where the target command passed to a subprocess call could + not be found. + + :param command: the target command. For clarity, only the first + item is used in the error message but the entire command is + preserved for inspection by the caller. + """ + + def __init__(self, command: str) -> None: + self.command = command + if isinstance(command, list): + self.target: str = command[0] + elif isinstance(command, str): + self.target = command.split()[0] + else: + raise ValueError(f"invalid command: {command}") + + super().__init__(f"unable to execute {self.target}") + + +class FabMultiCommandError(FabError): + """Unpack multiple exceptions into a single one. + + Error which combines all potential exceptions raised by a + multiprocessing section into a single exception class for + subsequent inspection. + + This feature is is required because versions of python prior to + 3.11 do not support ExceptionGroups. + + :param errors: a list ot exceptions + :param label: an identifier for the multiprocessing section + """ + + def __init__( + self, errors: List[Union[str, Exception]], label: Optional[str] = None + ) -> None: + self.errors = errors + self.label = label or "during multiprocessing" + + message = f"{len(errors)} exception" + message += " " if len(errors) == 1 else "s " + message += f"{self.label}" + + super().__init__(message) + + +class FabSourceError(FabError): + """Base class for source code management exceptions.""" + + +class FabSourceNoFilesError(FabSourceError): + """No source files were found. + + Error where no source files have been once any filtering rules + have been applied. + + """ + + def __init__(self) -> None: + super().__init__("no source files found after filtering") + + +class FabSourceMergeError(FabSourceError): + """Version control merge has failed. + + Error where the underlying version control system has failed to + automatically merge source code changes, e.g. because of a source + conflict that requires manual resolution. + + :param tool: name of the version control system + :param reason: reason/error output from the version control system + indicating why the merge failed + :param revision: optional name of the specific revision being + targeted + """ + + def __init__(self, tool: str, reason: str, revision: Optional[str] = None) -> None: + self.tool = tool + self.reason = reason + + message = f"[{tool}] merge " + if revision: + message += f"of {revision} " + message += f"failed: {reason}" + + super().__init__(message) + + +class FabSourceFetchError(FabSourceError): + """An attempt to fetch source files has failed. + + Error where a specific set of files could not be fetched, + e.g. from a location containing prebuild files. + + :params source: location of the source files + :param reason: reason for the failure + """ + + def __init__(self, source: str, reason: str) -> None: + self.source = source + self.reason = reason + super().__init__(f"could not fetch {source}: {reason}") diff --git a/source/fab/steps/__init__.py b/source/fab/steps/__init__.py index 160d3ed9..f45040dc 100644 --- a/source/fab/steps/__init__.py +++ b/source/fab/steps/__init__.py @@ -11,6 +11,8 @@ from fab.metrics import send_metric from fab.util import by_type, TimerLogger +from fab.errors import FabMultiCommandError + from functools import wraps @@ -96,7 +98,4 @@ def check_for_errors(results: Iterable[Union[str, Exception]], exceptions = list(by_type(results, Exception)) if exceptions: - formatted_errors = "\n\n".join(map(str, exceptions)) - raise RuntimeError( - f"{formatted_errors}\n\n{len(exceptions)} error(s) found {caller_label}" - ) + raise FabMultiCommandError(exceptions, caller_label) diff --git a/source/fab/steps/archive_objects.py b/source/fab/steps/archive_objects.py index 13a64ece..32ac73bc 100644 --- a/source/fab/steps/archive_objects.py +++ b/source/fab/steps/archive_objects.py @@ -19,6 +19,8 @@ from fab.tools import Ar, Category from fab.artefacts import ArtefactsGetter, CollectionGetter +from fab.errors import FabToolMismatch + logger = logging.getLogger(__name__) DEFAULT_SOURCE_GETTER = CollectionGetter(ArtefactSet.OBJECT_FILES) @@ -105,8 +107,7 @@ def archive_objects(config: BuildConfig, source_getter = source or DEFAULT_SOURCE_GETTER ar = config.tool_box[Category.AR] if not isinstance(ar, Ar): - raise RuntimeError(f"Unexpected tool '{ar.name}' of type " - f"'{type(ar)}' instead of Ar") + raise FabToolMismatch(ar.name, type(ar), "Ar") output_fpath = str(output_fpath) if output_fpath else None target_objects = source_getter(config.artefact_store) @@ -130,9 +131,8 @@ def archive_objects(config: BuildConfig, log_or_dot(logger, f"CreateObjectArchive running archiver for " f"'{output_fpath}'.") - try: - ar.create(output_fpath, sorted(objects)) - except RuntimeError as err: - raise RuntimeError(f"error creating object archive:\n{err}") from err + + # Allow command errors to propagate up to the caller + ar.create(output_fpath, sorted(objects)) config.artefact_store.update_dict(output_collection, output_fpath, root) diff --git a/source/fab/steps/compile_c.py b/source/fab/steps/compile_c.py index a059b434..16c584ce 100644 --- a/source/fab/steps/compile_c.py +++ b/source/fab/steps/compile_c.py @@ -21,6 +21,8 @@ from fab.tools import Category, Compiler, Flags from fab.util import CompiledFile, log_or_dot, Timer, by_type +from fab.errors import FabToolMismatch + logger = logging.getLogger(__name__) DEFAULT_SOURCE_GETTER = FilterBuildTrees(suffix='.c') @@ -123,8 +125,7 @@ def _compile_file(arg: Tuple[AnalysedC, MpCommonArgs]): config = mp_payload.config compiler = config.tool_box[Category.C_COMPILER] if compiler.category != Category.C_COMPILER: - raise RuntimeError(f"Unexpected tool '{compiler.name}' of category " - f"'{compiler.category}' instead of CCompiler") + raise FabToolMismatch(compiler.name, compiler.category, "CCompiler") # Tool box returns a Tool, in order to make mypy happy, we need # to cast it to be a Compiler. compiler = cast(Compiler, compiler) diff --git a/source/fab/steps/compile_fortran.py b/source/fab/steps/compile_fortran.py index a3dc97ea..9c7e0e68 100644 --- a/source/fab/steps/compile_fortran.py +++ b/source/fab/steps/compile_fortran.py @@ -25,6 +25,8 @@ from fab.util import (CompiledFile, log_or_dot_finish, log_or_dot, Timer, by_type, file_checksum) +from fab.errors import FabToolMismatch + logger = logging.getLogger(__name__) DEFAULT_SOURCE_GETTER = FilterBuildTrees(suffix=['.f', '.f90']) @@ -133,8 +135,7 @@ def handle_compiler_args(config: BuildConfig, common_flags=None, # Command line tools are sometimes specified with flags attached. compiler = config.tool_box[Category.FORTRAN_COMPILER] if compiler.category != Category.FORTRAN_COMPILER: - raise RuntimeError(f"Unexpected tool '{compiler.name}' of category " - f"'{compiler.category}' instead of FortranCompiler") + raise FabToolMismatch(compiler.name, compiler.category, "FortranCompiler") # The ToolBox returns a Tool. In order to make mypy happy, we need to # cast this to become a Compiler. compiler = cast(Compiler, compiler) @@ -264,9 +265,7 @@ def process_file(arg: Tuple[AnalysedFortran, MpCommonArgs]) \ compiler = config.tool_box.get_tool(Category.FORTRAN_COMPILER, config.mpi) if compiler.category != Category.FORTRAN_COMPILER: - raise RuntimeError(f"Unexpected tool '{compiler.name}' of " - f"category '{compiler.category}' instead of " - f"FortranCompiler") + raise FabToolMismatch(compiler.name, compiler.category, "FortranCompiler") # The ToolBox returns a Tool, but we need to tell mypy that # this is a Compiler compiler = cast(Compiler, compiler) diff --git a/source/fab/steps/find_source_files.py b/source/fab/steps/find_source_files.py index 52b03e0a..0e3e28dd 100644 --- a/source/fab/steps/find_source_files.py +++ b/source/fab/steps/find_source_files.py @@ -13,6 +13,7 @@ from fab.artefacts import ArtefactSet from fab.steps import step from fab.util import file_walk +from fab.errors import FabSourceNoFilesError logger = logging.getLogger(__name__) @@ -144,7 +145,7 @@ def find_source_files(config, source_root=None, logger.debug(f"excluding {fpath}") if not filtered_fpaths: - raise RuntimeError("no source files found after filtering") + raise FabSourceNoFilesError() config.artefact_store.add(output_collection, filtered_fpaths) diff --git a/source/fab/steps/grab/prebuild.py b/source/fab/steps/grab/prebuild.py index 75ad8ff5..611acdaa 100644 --- a/source/fab/steps/grab/prebuild.py +++ b/source/fab/steps/grab/prebuild.py @@ -6,6 +6,7 @@ from fab.steps import step from fab.steps.grab import logger from fab.tools import Category +from fab.errors import FabSourceFetchError @step @@ -28,4 +29,4 @@ def grab_pre_build(config, path, allow_fail=False): msg = f"could not grab pre-build '{path}':\n{err}" logger.warning(msg) if not allow_fail: - raise RuntimeError(msg) from err + raise FabSourceFetchError(path, err) from err diff --git a/source/fab/steps/grab/svn.py b/source/fab/steps/grab/svn.py index b49c4652..5c37e166 100644 --- a/source/fab/steps/grab/svn.py +++ b/source/fab/steps/grab/svn.py @@ -15,6 +15,7 @@ from fab.steps import step from fab.tools import Category, Versioning +from fab.errors import FabSourceMergeError def _get_revision(src, revision=None) -> Tuple[str, Union[str, None]]: @@ -124,6 +125,6 @@ def check_conflict(tool: Versioning, dst: Union[str, Path]): for element in entry: if (element.tag == 'wc-status' and element.attrib['item'] == 'conflicted'): - raise RuntimeError(f'{tool} merge encountered a ' - f'conflict:\n{xml_str}') + raise FabSourceMergeError("svn", xml_str) + return False diff --git a/source/fab/steps/preprocess.py b/source/fab/steps/preprocess.py index 8698a1ef..c8122eb1 100644 --- a/source/fab/steps/preprocess.py +++ b/source/fab/steps/preprocess.py @@ -22,6 +22,8 @@ from fab.util import (log_or_dot_finish, input_to_output_fpath, log_or_dot, suffix_filter, Timer, by_type) +from fab.errors import FabToolMismatch + logger = logging.getLogger(__name__) @@ -150,8 +152,7 @@ def preprocess_fortran(config: BuildConfig, source: Optional[ArtefactsGetter] = fpp = config.tool_box[Category.FORTRAN_PREPROCESSOR] if not isinstance(fpp, CppFortran): - raise RuntimeError(f"Unexpected tool '{fpp.name}' of type " - f"'{type(fpp)}' instead of CppFortran") + raise FabToolMismatch(fpp.name, type(fpp), "CppFortran") try: common_flags = kwargs.pop('common_flags') @@ -223,8 +224,7 @@ def preprocess_c(config: BuildConfig, source_files = source_getter(config.artefact_store) cpp = config.tool_box[Category.C_PREPROCESSOR] if not isinstance(cpp, Cpp): - raise RuntimeError(f"Unexpected tool '{cpp.name}' of type " - f"'{type(cpp)}' instead of Cpp") + raise FabToolMismatch(cpp.name, type(cpp), "Cpp") pre_processor( config, diff --git a/source/fab/steps/psyclone.py b/source/fab/steps/psyclone.py index 33ec0208..815b414a 100644 --- a/source/fab/steps/psyclone.py +++ b/source/fab/steps/psyclone.py @@ -29,6 +29,8 @@ file_walk, TimerLogger, string_checksum, suffix_filter, by_type, log_or_dot_finish) +from fab.errors import FabToolMismatch + logger = logging.getLogger(__name__) @@ -303,8 +305,8 @@ def do_one_file(arg: Tuple[Path, MpCommonArgs]): config = mp_payload.config psyclone = config.tool_box[Category.PSYCLONE] if not isinstance(psyclone, Psyclone): - raise RuntimeError(f"Unexpected tool '{psyclone.name}' of type " - f"'{type(psyclone)}' instead of Psyclone") + raise FabToolMismatch(psyclone.name, type(psyclone), "Psyclone") + try: transformation_script = mp_payload.transformation_script logger.info(f"running psyclone on '{x90_file}'.") diff --git a/source/fab/tools/compiler.py b/source/fab/tools/compiler.py index 1a3d1c4f..80671470 100644 --- a/source/fab/tools/compiler.py +++ b/source/fab/tools/compiler.py @@ -17,6 +17,7 @@ from fab.tools.category import Category from fab.tools.flags import Flags from fab.tools.tool import CompilerSuiteTool +from fab.errors import FabToolInvalidVersion, FabToolError if TYPE_CHECKING: from fab.build_config import BuildConfig @@ -220,8 +221,8 @@ def get_version(self) -> Tuple[int, ...]: # of the string, otherwise the $ would not match the end of line matches = re.search(self._version_regex, output, re.MULTILINE) if not matches: - raise RuntimeError(f"Unexpected version output format for " - f"compiler '{self.name}': {output}") + raise FabToolInvalidVersion(self.name, output) + version_string = matches.groups()[0] # Expect the version to be dot-separated integers. try: @@ -229,15 +230,13 @@ def get_version(self) -> Tuple[int, ...]: version = cast(Tuple[int], tuple(int(x) for x in version_string.split('.'))) except ValueError as err: - raise RuntimeError(f"Unexpected version output format for " - f"compiler '{self.name}'. Should be numeric " - f": {version_string}") from err + raise FabToolInvalidVersion(self.name, version_string, + "") from err # Expect at least 2 integer components, i.e. major.minor[.patch, ...] if len(version) < 2: - raise RuntimeError(f"Unexpected version output format for " - f"compiler '{self.name}'. Should have at least " - f"two parts, : {version_string}") + raise FabToolInvalidVersion(self.name, version_string, + "should have at least two parts, ") self.logger.info( f'Found compiler version for {self.name} = {version_string}') @@ -259,8 +258,7 @@ def run_version_command( try: return self.run(version_command, capture_output=True) except RuntimeError as err: - raise RuntimeError(f"Error asking for version of compiler " - f"'{self.name}'") from err + raise FabToolError(self.name, "unable to get compiler version") from err def get_version_string(self) -> str: """ diff --git a/source/fab/tools/compiler_wrapper.py b/source/fab/tools/compiler_wrapper.py index d5c56f92..1d255efe 100644 --- a/source/fab/tools/compiler_wrapper.py +++ b/source/fab/tools/compiler_wrapper.py @@ -14,6 +14,8 @@ from fab.tools.category import Category from fab.tools.compiler import Compiler, FortranCompiler from fab.tools.flags import Flags +from fab.errors import FabToolError + if TYPE_CHECKING: from fab.build_config import BuildConfig @@ -68,8 +70,7 @@ def has_syntax_only(self) -> bool: if self._compiler.category == Category.FORTRAN_COMPILER: return cast(FortranCompiler, self._compiler).has_syntax_only - raise RuntimeError(f"Compiler '{self._compiler.name}' has " - f"no has_syntax_only.") + raise FabToolError(self._compiler.name, "no syntax-only feature") def get_flags(self, profile: Optional[str] = None) -> List[str]: ''':returns: the ProfileFlags for the given profile, combined @@ -90,8 +91,7 @@ def set_module_output_path(self, path: Path): ''' if self._compiler.category != Category.FORTRAN_COMPILER: - raise RuntimeError(f"Compiler '{self._compiler.name}' has no " - f"'set_module_output_path' function.") + raise FabToolError(self._compiler.name, "no module output path feature") cast(FortranCompiler, self._compiler).set_module_output_path(path) def get_all_commandline_options( @@ -141,8 +141,7 @@ def get_all_commandline_options( else: # It's not valid to specify syntax_only for a non-Fortran compiler if syntax_only is not None: - raise RuntimeError(f"Syntax-only cannot be used with compiler " - f"'{self.name}'.") + raise FabToolError(self._compiler.name, "syntax-only is Fortran-specific") flags = self._compiler.get_all_commandline_options( config, input_file, output_file, add_flags=add_flags) diff --git a/source/fab/tools/linker.py b/source/fab/tools/linker.py index 03f58503..cb6b0073 100644 --- a/source/fab/tools/linker.py +++ b/source/fab/tools/linker.py @@ -17,6 +17,7 @@ from fab.tools.compiler import Compiler from fab.tools.flags import ProfileFlags from fab.tools.tool import CompilerSuiteTool +from fab.errors import FabUnknownLibraryError if TYPE_CHECKING: from fab.build_config import BuildConfig @@ -138,7 +139,7 @@ def get_lib_flags(self, lib: str) -> List[str]: # another linker, return the result from the wrapped linker if self._linker: return self._linker.get_lib_flags(lib) - raise RuntimeError(f"Unknown library name: '{lib}'") from err + raise FabUnknownLibraryError(lib) from err def add_lib_flags(self, lib: str, flags: List[str], silent_replace: bool = False): diff --git a/source/fab/tools/psyclone.py b/source/fab/tools/psyclone.py index 6e4adbbf..091f522d 100644 --- a/source/fab/tools/psyclone.py +++ b/source/fab/tools/psyclone.py @@ -14,6 +14,7 @@ from fab.tools.category import Category from fab.tools.tool import Tool +from fab.errors import FabToolPsycloneAPI, FabToolNotAvailable if TYPE_CHECKING: # TODO 314: see if this circular dependency can be broken @@ -90,7 +91,7 @@ def process(self, ''' if not self.is_available: - raise RuntimeError("PSyclone is not available.") + raise FabToolNotAvailable("psyclone") # Convert the old style API nemo to be empty if api and api.lower() == "nemo": @@ -100,24 +101,23 @@ def process(self, # API specified, we need both psy- and alg-file, but not # transformed file. if not psy_file: - raise RuntimeError(f"PSyclone called with api '{api}', but " - f"no psy_file is specified.") + raise FabToolPsycloneAPI(api, "psy_file") + if not alg_file: - raise RuntimeError(f"PSyclone called with api '{api}', but " - f"no alg_file is specified.") + raise FabToolPsycloneAPI(api, "alg_file") + if transformed_file: - raise RuntimeError(f"PSyclone called with api '{api}' and " - f"transformed_file.") + raise FabToolPsycloneAPI(api, "transformed_file", True) + else: if psy_file: - raise RuntimeError("PSyclone called without api, but " - "psy_file is specified.") + raise FabToolPsycloneAPI(api, "psy_file", True) + if alg_file: - raise RuntimeError("PSyclone called without api, but " - "alg_file is specified.") + raise FabToolPsycloneAPI(api, "alg_file", True) + if not transformed_file: - raise RuntimeError("PSyclone called without api, but " - "transformed_file is not specified.") + raise FabToolPsycloneAPI(api, "transformed_file") parameters: List[Union[str, Path]] = [] # If an api is defined in this call (or in the constructor) add it diff --git a/source/fab/tools/tool.py b/source/fab/tools/tool.py index a12773cc..0ddfe3c2 100644 --- a/source/fab/tools/tool.py +++ b/source/fab/tools/tool.py @@ -20,6 +20,7 @@ from fab.tools.category import Category from fab.tools.flags import ProfileFlags +from fab.errors import FabToolNotAvailable, FabCommandError, FabCommandNotFound class Tool: @@ -193,23 +194,19 @@ def run(self, # is available or not. Testing for `False` only means this `run` # function can be used to test if a tool is available. if self._is_available is False: - raise RuntimeError(f"Tool '{self.name}' is not available to run " - f"'{command}'.") + raise FabToolNotAvailable(self.name) self._logger.debug(f'run_command: {" ".join(command)}') try: res = subprocess.run(command, capture_output=capture_output, env=env, cwd=cwd, check=False) except FileNotFoundError as err: - raise RuntimeError("Unable to execute command: " - + str(command)) from err + raise FabCommandNotFound(command) from err if res.returncode != 0: - msg = (f'Command failed with return code {res.returncode}:\n' - f'{command}') - if res.stdout: - msg += f'\n{res.stdout.decode()}' - if res.stderr: - msg += f'\n{res.stderr.decode()}' - raise RuntimeError(msg) + raise FabCommandError(command, + res.returncode, + res.stdout, + res.stderr, + cwd) if capture_output: return res.stdout.decode() return "" diff --git a/source/fab/tools/tool_box.py b/source/fab/tools/tool_box.py index 4dd95aa5..5c47acbf 100644 --- a/source/fab/tools/tool_box.py +++ b/source/fab/tools/tool_box.py @@ -12,6 +12,7 @@ from fab.tools.category import Category from fab.tools.tool import Tool +from fab.errors import FabToolNotAvailable class ToolBox: @@ -37,7 +38,7 @@ def add_tool(self, tool: Tool, :raises RuntimeError: if the tool to be added is not available. ''' if not tool.is_available: - raise RuntimeError(f"Tool '{tool}' is not available.") + raise FabToolNotAvailable(tool) if tool.category in self._all_tools and not silent_replace: warnings.warn(f"Replacing existing tool " diff --git a/source/fab/tools/tool_repository.py b/source/fab/tools/tool_repository.py index 3def7b7f..8315e84a 100644 --- a/source/fab/tools/tool_repository.py +++ b/source/fab/tools/tool_repository.py @@ -26,6 +26,8 @@ Gcc, Gfortran, Icc, Icx, Ifort, Ifx, Nvc, Nvfortran, Psyclone, Rsync, Shell) +from fab.errors import FabToolInvalidSetting, FabToolNotAvailable + class ToolRepository(dict): '''This class implements the tool repository. It stores a list of @@ -218,8 +220,7 @@ def set_default_compiler_suite(self, suite: str): self[category] = sorted(self[category], key=lambda x: x.suite != suite) if len(self[category]) > 0 and self[category][0].suite != suite: - raise RuntimeError(f"Cannot find '{category}' " - f"in the suite '{suite}'.") + raise FabToolNotAvailable(category, suite) def get_default(self, category: Category, mpi: Optional[bool] = None, @@ -243,8 +244,7 @@ def get_default(self, category: Category, ''' if not isinstance(category, Category): - raise RuntimeError(f"Invalid category type " - f"'{type(category).__name__}'.") + raise FabToolInvalidSetting("category type", type(category).__name__) # If not a compiler or linker, return the first tool if not category.is_compiler and category != Category.LINKER: @@ -252,16 +252,14 @@ def get_default(self, category: Category, if tool.is_available: return tool tool_names = ",".join(i.name for i in self[category]) - raise RuntimeError(f"Can't find available '{category}' tool. " - f"Tools are '{tool_names}'.") + raise FabToolInvalidSetting("category", category, + f"where tool names are {tool_names}") if not isinstance(mpi, bool): - raise RuntimeError(f"Invalid or missing mpi specification " - f"for '{category}'.") + raise FabToolInvalidSetting("MPI setting", category) if not isinstance(openmp, bool): - raise RuntimeError(f"Invalid or missing openmp specification " - f"for '{category}'.") + raise FabToolInvalidSetting("OpenMP setting", category) for tool in self[category]: # If OpenMP is request, but the tool does not support openmp, @@ -276,12 +274,11 @@ def get_default(self, category: Category, # that seems to be an unlikely scenario. if mpi: if openmp: - raise RuntimeError(f"Could not find '{category}' that " - f"supports MPI and OpenMP.") - raise RuntimeError(f"Could not find '{category}' that " - f"supports MPI.") + raise FabToolInvalidSetting("MPI and OpenMP setting", category) + + raise FabToolInvalidSetting("MPI setting", category) if openmp: - raise RuntimeError(f"Could not find '{category}' that " - f"supports OpenMP.") - raise RuntimeError(f"Could not find any '{category}'.") + raise FabToolInvalidSetting("OpenMP setting", category) + + raise FabToolInvalidSetting("category match", category) diff --git a/source/fab/tools/versioning.py b/source/fab/tools/versioning.py index 410cc250..de12bc74 100644 --- a/source/fab/tools/versioning.py +++ b/source/fab/tools/versioning.py @@ -12,6 +12,7 @@ from fab.tools.category import Category from fab.tools.tool import Tool +from fab.errors import FabSourceMergeError class Versioning(Tool, ABC): @@ -108,8 +109,7 @@ def merge(self, dst: Union[str, Path], self.run(['merge', 'FETCH_HEAD'], cwd=dst, capture_output=False) except RuntimeError as err: self.run(['merge', '--abort'], cwd=dst, capture_output=False) - raise RuntimeError(f"Error merging {revision}. " - f"Merge aborted.\n{err}") from err + raise FabSourceMergeError("git", str(err), revision) from err # ============================================================================= diff --git a/tests/unit_tests/steps/test_archive_objects.py b/tests/unit_tests/steps/test_archive_objects.py index 843da2ef..b06d901c 100644 --- a/tests/unit_tests/steps/test_archive_objects.py +++ b/tests/unit_tests/steps/test_archive_objects.py @@ -19,6 +19,8 @@ from fab.steps.archive_objects import archive_objects from fab.tools import Category, ToolRepository +from fab.errors import FabToolMismatch + class TestArchiveObjects: """ @@ -116,6 +118,6 @@ def test_incorrect_tool(self, stub_tool_box, monkeypatch): with raises(RuntimeError) as err: archive_objects(config=config, output_fpath=config.build_output / 'mylib.a') - assert str(err.value) == ("Unexpected tool 'some C compiler' of type " - "'' " - "instead of Ar") + assert isinstance(err.value, FabToolMismatch) + assert str(err.value) == ("[some C compiler] got type " + " instead of Ar") diff --git a/tests/unit_tests/steps/test_compile_c.py b/tests/unit_tests/steps/test_compile_c.py index 7b439963..dbe042f8 100644 --- a/tests/unit_tests/steps/test_compile_c.py +++ b/tests/unit_tests/steps/test_compile_c.py @@ -20,6 +20,8 @@ from fab.tools.flags import Flags from fab.tools.tool_box import ToolBox +from fab.errors import FabToolMismatch + @fixture(scope='function') def content(tmp_path: Path, stub_tool_box: ToolBox): @@ -62,8 +64,9 @@ def test_compile_c_wrong_compiler(content, fake_process: FakeProcess) -> None: mp_common_args = Mock(config=config) with raises(RuntimeError) as err: _compile_file((Mock(), mp_common_args)) - assert str(err.value) == ("Unexpected tool 'some C compiler' of category " - "'FORTRAN_COMPILER' instead of CCompiler") + assert isinstance(err.value, FabToolMismatch) + assert str(err.value) == ("[some C compiler] got type " + "FORTRAN_COMPILER instead of CCompiler") # This is more of an integration test than a unit test diff --git a/tests/unit_tests/steps/test_compile_fortran.py b/tests/unit_tests/steps/test_compile_fortran.py index a991b97d..2f55ce6d 100644 --- a/tests/unit_tests/steps/test_compile_fortran.py +++ b/tests/unit_tests/steps/test_compile_fortran.py @@ -18,6 +18,8 @@ from fab.tools.tool_box import ToolBox from fab.util import CompiledFile +from fab.errors import FabToolMismatch + @fixture(scope='function') def analysed_files(): @@ -60,15 +62,17 @@ def test_compile_cc_wrong_compiler(stub_tool_box, mp_common_args = Mock(config=config) with raises(RuntimeError) as err: process_file((Mock(), mp_common_args)) + assert isinstance(err.value, FabToolMismatch) assert str(err.value) \ - == "Unexpected tool 'some Fortran compiler' of category " \ - + "'C_COMPILER' instead of FortranCompiler" + == "[some Fortran compiler] got type " \ + + "C_COMPILER instead of FortranCompiler" with raises(RuntimeError) as err: handle_compiler_args(config) + assert isinstance(err.value, FabToolMismatch) assert str(err.value) \ - == "Unexpected tool 'some Fortran compiler' of category " \ - + "'C_COMPILER' instead of FortranCompiler" + == "[some Fortran compiler] got type " \ + + "C_COMPILER instead of FortranCompiler" class TestCompilePass: diff --git a/tests/unit_tests/steps/test_preprocess.py b/tests/unit_tests/steps/test_preprocess.py index bd5786d2..47b2c4d9 100644 --- a/tests/unit_tests/steps/test_preprocess.py +++ b/tests/unit_tests/steps/test_preprocess.py @@ -13,6 +13,8 @@ from fab.tools.category import Category from fab.tools.tool_box import ToolBox +from fab.errors import FabToolMismatch + class Test_preprocess_fortran: @@ -68,5 +70,6 @@ def test_wrong_exe(self, tmp_path: Path, config = BuildConfig('proj', tool_box, fab_workspace=tmp_path) with raises(RuntimeError) as err: preprocess_fortran(config=config) - assert str(err.value) == "Unexpected tool 'cpp' of type '' instead of CppFortran" + assert isinstance(err.value, FabToolMismatch) + assert str(err.value) == "[cpp] got type instead of CppFortran" diff --git a/tests/unit_tests/test_artefacts.py b/tests/unit_tests/test_artefacts.py index b253362c..a12b5a69 100644 --- a/tests/unit_tests/test_artefacts.py +++ b/tests/unit_tests/test_artefacts.py @@ -84,11 +84,10 @@ def test_artefact_store_replace() -> None: Path("c")]) # Test the behaviour for dictionaries - with pytest.raises(RuntimeError) as err: + with pytest.raises(ValueError) as err: artefact_store.replace(ArtefactSet.OBJECT_FILES, remove_files=[Path("a")], add_files=["c"]) - assert ("Replacing artefacts in dictionary 'ArtefactSet.OBJECT_FILES' " - "is not supported" in str(err.value)) + assert str(err.value) == "OBJECT_FILES is not mutable" def test_artefacts_getter(): diff --git a/tests/unit_tests/test_cui_arguments.py b/tests/unit_tests/test_cui_arguments.py index 8e86cf21..6a95dca9 100644 --- a/tests/unit_tests/test_cui_arguments.py +++ b/tests/unit_tests/test_cui_arguments.py @@ -101,7 +101,7 @@ def test_nonexistent_user_file(self, fs: FakeFilesystem, capsys): assert exc.value.code == 2 captured = capsys.readouterr() - assert "error: fab file does not exist" in captured.err + assert "fab file does not exist" in captured.err def test_user_file_missing_arg(self, capsys): """Check missing file name triggers an error.""" diff --git a/tests/unit_tests/test_errors.py b/tests/unit_tests/test_errors.py new file mode 100644 index 00000000..4d58e4dc --- /dev/null +++ b/tests/unit_tests/test_errors.py @@ -0,0 +1,179 @@ +############################################################################## +# (c) Crown copyright Met Office. All rights reserved. +# For further details please refer to the file COPYRIGHT +# which you should have received as part of this distribution +############################################################################## +""" +Unit tests for custom fab exceptions. +""" + +import pytest +from unittest.mock import Mock, PropertyMock + +from fab.errors import ( + FabError, + FabToolError, + FabToolMismatch, + FabToolInvalidVersion, + FabToolPsycloneAPI, + FabToolNotAvailable, + FabToolInvalidSetting, + FabCommandError, + FabCommandNotFound, + FabMultiCommandError, + FabSourceNoFilesError, + FabSourceMergeError, + FabSourceFetchError, + FabUnknownLibraryError, +) + + +class TestErrors: + """Basic tests for the FabError class hierarchy.""" + + def test_base(self): + """Test the base Fab error class.""" + + err = FabError("test message") + assert str(err) == "test message" + + def test_unknown_library(self): + """Test unknown library errors.""" + + err = FabUnknownLibraryError("mylib") + assert str(err) == "unknown library mylib" + + +class TestToolErrors: + """Test the FabToolError hierarchy.""" + + def test_tool_string(self): + """Test the base FabToolError class.""" + + err = FabToolError("cc", "compiler message") + assert str(err) == "[cc] compiler message" + + # Mock defines an internal name property, so special handling + # is required to reset the value to support testing + tool = Mock(_name="tool cc") + del tool.name + err = FabToolError(tool, "compiler message") + assert str(err) == "[tool cc] compiler message" + + category = Mock() + del category._name + type(category).name = PropertyMock(return_value="category cc") + err = FabToolError(category, "compiler message") + assert str(err) == "[category cc] compiler message" + + def test_mismatch(self): + """Test tool type mismatch class.""" + + err = FabToolMismatch("cc", "CCompiler", "Ar") + assert str(err) == "[cc] got type CCompiler instead of Ar" + + def test_invalid_version(self): + """Test invalid version class.""" + + err = FabToolInvalidVersion("cc", "abc") + assert str(err) == "[cc] invalid version 'abc'" + + err = FabToolInvalidVersion("cc", "abc", "VV.NN") + assert str(err) == "[cc] invalid version 'abc' should be 'VV.NN'" + + def test_psyclone_api(self): + """Test PSyclone API class.""" + + err = FabToolPsycloneAPI(None, "alg_file") + assert str(err) == "[psyclone] called without API but not with alg_file" + + err = FabToolPsycloneAPI("nemo", "alg_file") + assert str(err) == "[psyclone] called with nemo API but not with alg_file" + + err = FabToolPsycloneAPI("nemo", "alg_file", present=True) + assert str(err) == "[psyclone] called with nemo API and with alg_file" + + def test_not_available(self): + """Test tool not available class.""" + + err = FabToolNotAvailable("psyclone") + assert str(err) == "[psyclone] not available" + + err = FabToolNotAvailable("gfortran", "GCC") + assert str(err) == "[gfortran] not available in suite GCC" + + def test_invalid_setting(self): + """Test invalid setting class.""" + + err = FabToolInvalidSetting("category", "compiler") + assert str(err) == "[compiler] invalid category" + + err = FabToolInvalidSetting("category", "compiler", "nosuch") + assert str(err) == "[compiler] invalid category nosuch" + + +class TestCommandErrors: + """Test various command errors.""" + + def test_command(self): + """Test FabCommandError in various configurations.""" + + err = FabCommandError(["ls", "-l", "/nosuch"], 1, b"", b"ls: cannot", "/") + assert str(err) == "command 'ls -l /nosuch' returned 1" + + err = FabCommandError("ls -l /nosuch", 1, None, "ls: cannot", "/") + assert str(err) == "command 'ls -l /nosuch' returned 1" + + def test_not_found(self): + """Test command not found errors.""" + + err = FabCommandNotFound(["ls", "-l"]) + assert str(err) == "unable to execute ls" + + err = FabCommandNotFound("ls -l") + assert str(err) == "unable to execute ls" + + with pytest.raises(ValueError) as exc: + FabCommandNotFound({"a": 1}) + assert "invalid command" in str(exc.value) + + def test_multi(self): + """Test multiprocessing command errors.""" + + err = FabMultiCommandError([ValueError("invalid value")]) + assert str(err) == "1 exception during multiprocessing" + + err = FabMultiCommandError( + [ValueError("invalid value"), TypeError("invalid type")] + ) + assert str(err) == "2 exceptions during multiprocessing" + + err = FabMultiCommandError( + [ValueError("invalid value"), TypeError("invalid type")], "during psyclone" + ) + assert str(err) == "2 exceptions during psyclone" + + +class TestSourceErrors: + """Test the source errors hierarchy.""" + + def test_no_files(self): + """Test lack of source files.""" + + err = FabSourceNoFilesError() + assert str(err) == "no source files found after filtering" + + def test_merge(self): + """Test merge errors.""" + + err = FabSourceMergeError("git", "conflicting source files") + assert str(err) == "[git] merge failed: conflicting source files" + + err = FabSourceMergeError("git", "conflicting source files", "vn1.1") + assert str(err) == "[git] merge of vn1.1 failed: conflicting source files" + + def test_fetch(self): + """Test fetch errors.""" + + err = FabSourceFetchError("/my/dir1", "no such directory") + assert str(err) == "could not fetch /my/dir1: no such directory" diff --git a/tests/unit_tests/tools/test_compiler.py b/tests/unit_tests/tools/test_compiler.py index 13b3827d..33481fc1 100644 --- a/tests/unit_tests/tools/test_compiler.py +++ b/tests/unit_tests/tools/test_compiler.py @@ -22,6 +22,8 @@ Icx, Ifx, Nvc, Nvfortran) +from fab.errors import FabToolInvalidVersion, FabToolError + from tests.conftest import arg_list, call_list @@ -104,7 +106,7 @@ def test_compiler_check_available_runtime_error(): ''' Check the compiler is not available when get_version raises an error. ''' cc = Gcc() - with mock.patch.object(cc, "get_version", side_effect=RuntimeError("")): + with mock.patch.object(cc, "get_version", side_effect=FabToolInvalidVersion("cc", "")): assert not cc.check_available() @@ -131,10 +133,10 @@ def test_compiler_hash_compiler_error(): cc = Gcc() # raise an error when trying to get compiler version - with mock.patch.object(cc, 'run', side_effect=RuntimeError()): - with raises(RuntimeError) as err: + with mock.patch.object(cc, 'run', side_effect=FabToolError("hash", "")): + with raises(FabToolError) as err: cc.get_hash() - assert "Error asking for version of compiler" in str(err.value) + assert "unable to get compiler version" in str(err.value) def test_compiler_hash_invalid_version(): @@ -145,8 +147,8 @@ def test_compiler_hash_invalid_version(): with mock.patch.object(cc, "run", mock.Mock(return_value='foo v1')): with raises(RuntimeError) as err: cc.get_hash() - assert ("Unexpected version output format for compiler 'gcc'" - in str(err.value)) + assert isinstance(err.value, FabToolInvalidVersion) + assert "[gcc] invalid version" in str(err.value) def test_compiler_syntax_only(): @@ -294,12 +296,13 @@ def test_get_version_1_part_version(): GNU Fortran (gcc) 777 Copyright (C) 2022 Foo Software Foundation, Inc. """) - expected_error = "Unexpected version output format for compiler" + expected_error = "invalid version" c = Gfortran() with mock.patch.object(c, "run", mock.Mock(return_value=full_output)): with raises(RuntimeError) as err: c.get_version() + assert isinstance(err.value, FabToolInvalidVersion) assert expected_error in str(err.value) @@ -354,12 +357,13 @@ def test_get_version_non_int_version_format(version): GNU Fortran (gcc) {version} (Foo Hat 4.8.5) Copyright (C) 2022 Foo Software Foundation, Inc. """) - expected_error = "Unexpected version output format for compiler" + expected_error = "invalid version" c = Gfortran() with mock.patch.object(c, "run", mock.Mock(return_value=full_output)): with raises(RuntimeError) as err: c.get_version() + assert isinstance(err.value, FabToolInvalidVersion) assert expected_error in str(err.value) @@ -372,12 +376,13 @@ def test_get_version_unknown_version_format(): full_output = dedent(""" Foo Fortran version 175 """) - expected_error = "Unexpected version output format for compiler" + expected_error = "invalid version" c = Gfortran() with mock.patch.object(c, "run", mock.Mock(return_value=full_output)): with raises(RuntimeError) as err: c.get_version() + assert isinstance(err.value, FabToolInvalidVersion) assert expected_error in str(err.value) @@ -386,19 +391,21 @@ def test_get_version_command_failure(): c = Gfortran(exec_name="does_not_exist") with raises(RuntimeError) as err: c.get_version() - assert "Error asking for version of compiler" in str(err.value) + assert isinstance(err.value, FabToolError) + assert "unable to get compiler version" in str(err.value) def test_get_version_unknown_command_response(): '''If the full version output is in an unknown format, we must raise an error.''' full_output = 'GNU Fortran 1.2.3' - expected_error = "Unexpected version output format for compiler" + expected_error = "invalid version" c = Gfortran() with mock.patch.object(c, "run", mock.Mock(return_value=full_output)): with raises(RuntimeError) as err: c.get_version() + assert isinstance(err.value, FabToolInvalidVersion) assert expected_error in str(err.value) @@ -414,7 +421,7 @@ def test_get_version_good_result_is_cached(): # Now let the run method raise an exception, to make sure we get a cached # value back (and the run method isn't called again): - with mock.patch.object(c, 'run', side_effect=RuntimeError()): + with mock.patch.object(c, 'run', side_effect=FabToolError("cc", "")): assert c.get_version() == expected assert not c.run.called @@ -424,7 +431,7 @@ def test_get_version_bad_result_is_not_cached(): ''' # Set up the compiler to fail the first time c = Gfortran() - with mock.patch.object(c, 'run', side_effect=RuntimeError()): + with mock.patch.object(c, 'run', side_effect=RuntimeError("")): with raises(RuntimeError): c.get_version() @@ -469,7 +476,8 @@ def test_gcc_get_version_with_icc_string(): with mock.patch.object(gcc, "run", mock.Mock(return_value=full_output)): with raises(RuntimeError) as err: gcc.get_version() - assert "Unexpected version output format for compiler" in str(err.value) + assert isinstance(err.value, FabToolInvalidVersion) + assert "invalid version" in str(err.value) # ============================================================================ @@ -575,7 +583,8 @@ def test_gfortran_get_version_with_ifort_string(): mock.Mock(return_value=full_output)): with raises(RuntimeError) as err: gfortran.get_version() - assert ("Unexpected version output format for compiler" + assert isinstance(err.value, FabToolInvalidVersion) + assert ("invalid version" in str(err.value)) @@ -613,7 +622,8 @@ def test_icc_get_version_with_gcc_string(): with mock.patch.object(icc, "run", mock.Mock(return_value=full_output)): with raises(RuntimeError) as err: icc.get_version() - assert ("Unexpected version output format for compiler" + assert isinstance(err.value, FabToolInvalidVersion) + assert ("invalid version" in str(err.value)) @@ -688,7 +698,8 @@ def test_ifort_get_version_with_icc_string(): with mock.patch.object(ifort, "run", mock.Mock(return_value=full_output)): with raises(RuntimeError) as err: ifort.get_version() - assert ("Unexpected version output format for compiler" + assert isinstance(err.value, FabToolInvalidVersion) + assert ("invalid version" in str(err.value)) @@ -708,7 +719,8 @@ def test_ifort_get_version_invalid_version(version): with mock.patch.object(ifort, "run", mock.Mock(return_value=full_output)): with raises(RuntimeError) as err: ifort.get_version() - assert ("Unexpected version output format for compiler" + assert isinstance(err.value, FabToolInvalidVersion) + assert ("invalid version" in str(err.value)) @@ -751,7 +763,8 @@ def test_icx_get_version_with_icc_string(): with mock.patch.object(icx, "run", mock.Mock(return_value=full_output)): with raises(RuntimeError) as err: icx.get_version() - assert ("Unexpected version output format for compiler" + assert isinstance(err.value, FabToolInvalidVersion) + assert ("invalid version" in str(err.value)) @@ -790,7 +803,8 @@ def test_ifx_get_version_with_ifort_string(): with mock.patch.object(ifx, "run", mock.Mock(return_value=full_output)): with raises(RuntimeError) as err: ifx.get_version() - assert ("Unexpected version output format for compiler" + assert isinstance(err.value, FabToolInvalidVersion) + assert ("invalid version" in str(err.value)) @@ -829,7 +843,8 @@ def test_nvc_get_version_with_icc_string(): with mock.patch.object(nvc, "run", mock.Mock(return_value=full_output)): with raises(RuntimeError) as err: nvc.get_version() - assert ("Unexpected version output format for compiler" + assert isinstance(err.value, FabToolInvalidVersion) + assert ("invalid version" in str(err.value)) @@ -871,7 +886,8 @@ def test_nvfortran_get_version_with_ifort_string(): mock.Mock(return_value=full_output)): with raises(RuntimeError) as err: nvfortran.get_version() - assert ("Unexpected version output format for compiler" + assert isinstance(err.value, FabToolInvalidVersion) + assert ("invalid version" in str(err.value)) @@ -937,7 +953,8 @@ def test_craycc_get_version_with_icc_string(): with mock.patch.object(craycc, "run", mock.Mock(return_value=full_output)): with raises(RuntimeError) as err: craycc.get_version() - assert ("Unexpected version output format for compiler" + assert isinstance(err.value, FabToolInvalidVersion) + assert ("invalid version" in str(err.value)) @@ -987,5 +1004,6 @@ def test_crayftn_get_version_with_ifort_string(): mock.Mock(return_value=full_output)): with raises(RuntimeError) as err: crayftn.get_version() - assert ("Unexpected version output format for compiler" + assert isinstance(err.value, FabToolInvalidVersion) + assert ("invalid version" in str(err.value)) diff --git a/tests/unit_tests/tools/test_compiler_wrapper.py b/tests/unit_tests/tools/test_compiler_wrapper.py index 0109d82c..ff3eeef4 100644 --- a/tests/unit_tests/tools/test_compiler_wrapper.py +++ b/tests/unit_tests/tools/test_compiler_wrapper.py @@ -19,6 +19,7 @@ from fab.tools.compiler_wrapper import (CompilerWrapper, CrayCcWrapper, CrayFtnWrapper, Mpicc, Mpif90) +from fab.errors import FabToolError def test_compiler_getter(stub_c_compiler: CCompiler) -> None: @@ -141,8 +142,8 @@ def test_syntax_only(stub_c_compiler: CCompiler) -> None: mpicc = Mpicc(stub_c_compiler) with raises(RuntimeError) as err: _ = mpicc.has_syntax_only - assert (str(err.value) == "Compiler 'some C compiler' has no " - "has_syntax_only.") + assert isinstance(err.value, FabToolError) + assert str(err.value) == "[some C compiler] no syntax-only feature" def test_module_output(stub_fortran_compiler: FortranCompiler, @@ -162,8 +163,8 @@ def test_module_output(stub_fortran_compiler: FortranCompiler, mpicc = Mpicc(stub_c_compiler) with raises(RuntimeError) as err: mpicc.set_module_output_path(Path("/tmp")) - assert str(err.value) == ("Compiler 'some C compiler' has " - "no 'set_module_output_path' function.") + assert isinstance(err.value, FabToolError) + assert str(err.value) == "[some C compiler] no module output path feature" def test_fortran_with_add_args(stub_fortran_compiler: FortranCompiler, @@ -233,8 +234,8 @@ def test_c_with_add_args(stub_c_compiler: CCompiler, mpicc.compile_file(Path("a.f90"), Path('a.o'), add_flags=["-O3"], syntax_only=True, config=stub_configuration) - assert (str(err.value) == "Syntax-only cannot be used with compiler " - "'mpicc-some C compiler'.") + assert isinstance(err.value, FabToolError) + assert (str(err.value) == "[some C compiler] syntax-only is Fortran-specific") # Check that providing the openmp flag in add_flag raises a warning: with warns(UserWarning, diff --git a/tests/unit_tests/tools/test_linker.py b/tests/unit_tests/tools/test_linker.py index c479f0e7..aea8eea2 100644 --- a/tests/unit_tests/tools/test_linker.py +++ b/tests/unit_tests/tools/test_linker.py @@ -124,7 +124,7 @@ def test_get_lib_flags_unknown(stub_c_compiler: CCompiler) -> None: linker = Linker(compiler=stub_c_compiler) with raises(RuntimeError) as err: linker.get_lib_flags("unknown") - assert str(err.value) == "Unknown library name: 'unknown'" + assert str(err.value) == "unknown library unknown" def test_add_lib_flags(stub_c_compiler: CCompiler) -> None: @@ -263,7 +263,7 @@ def test_c_with_unknown_library(stub_c_compiler: CCompiler, # Try to use "customlib" when we haven't added it to the linker linker.link([Path("a.o")], Path("a.out"), libs=["customlib"], config=stub_configuration) - assert str(err.value) == "Unknown library name: 'customlib'" + assert str(err.value) == "unknown library customlib" def test_add_compiler_flag(stub_c_compiler: CCompiler, @@ -363,7 +363,7 @@ def test_linker_inheriting() -> None: with raises(RuntimeError) as err: wrapper_linker.get_lib_flags("does_not_exist") - assert str(err.value) == "Unknown library name: 'does_not_exist'" + assert str(err.value) == "unknown library does_not_exist" def test_linker_profile_flags_inheriting(stub_c_compiler): diff --git a/tests/unit_tests/tools/test_psyclone.py b/tests/unit_tests/tools/test_psyclone.py index da02dfa1..e29eb4fa 100644 --- a/tests/unit_tests/tools/test_psyclone.py +++ b/tests/unit_tests/tools/test_psyclone.py @@ -19,6 +19,8 @@ import fab.tools.psyclone # Needed for mockery from fab.tools.psyclone import Psyclone +from fab.errors import FabToolPsycloneAPI, FabToolNotAvailable + from tests.conftest import call_list, not_found_callback @@ -108,7 +110,8 @@ def test_check_process_missing(fake_process: FakeProcess) -> None: with raises(RuntimeError) as err: psyclone.process(config, Path("x90file")) - assert str(err.value).startswith("PSyclone is not available") + assert isinstance(err.value, FabToolNotAvailable) + assert str(err.value).startswith("[psyclone] not available") def test_processing_errors_without_api(fake_process: FakeProcess) -> None: @@ -126,23 +129,23 @@ def test_processing_errors_without_api(fake_process: FakeProcess) -> None: Path('x90file'), api=None, psy_file=Path('psy_file')) - assert (str(err.value) == "PSyclone called without api, but psy_file " - "is specified.") + assert isinstance(err.value, FabToolPsycloneAPI) + assert (str(err.value) == "[psyclone] called without API and with psy_file") with raises(RuntimeError) as err: psyclone.process(config, Path('x90file'), api=None, alg_file=Path('alg_file')) - assert (str(err.value) == "PSyclone called without api, but alg_file is " - "specified.") + assert isinstance(err.value, FabToolPsycloneAPI) + assert (str(err.value) == "[psyclone] called without API and with alg_file") with raises(RuntimeError) as err: psyclone.process(config, Path('x90file'), api=None) - assert (str(err.value) == "PSyclone called without api, but " - "transformed_file is not specified.") + assert isinstance(err.value, FabToolPsycloneAPI) + assert (str(err.value) == "[psyclone] called without API but not with transformed_file") @mark.parametrize("api", ["dynamo0.3", "lfric"]) @@ -162,16 +165,18 @@ def test_processing_errors_with_api(api: str, Path("x90file"), api=api, psy_file=Path("psy_file")) + assert isinstance(err.value, FabToolPsycloneAPI) assert str(err.value).startswith( - f"PSyclone called with api '{api}', but no alg_file is specified" + f"[psyclone] called with {api} API but not with alg_file" ) with raises(RuntimeError) as err: psyclone.process(config, Path("x90file"), api=api, alg_file=Path("alg_file")) + assert isinstance(err.value, FabToolPsycloneAPI) assert str(err.value).startswith( - f"PSyclone called with api '{api}', but no psy_file is specified" + f"[psyclone] called with {api} API but not with psy_file" ) with raises(RuntimeError) as err: psyclone.process(config, @@ -180,8 +185,9 @@ def test_processing_errors_with_api(api: str, psy_file=Path("psy_file"), alg_file=Path("alg_file"), transformed_file=Path("transformed_file")) + assert isinstance(err.value, FabToolPsycloneAPI) assert str(err.value).startswith( - f"PSyclone called with api '{api}' and transformed_file" + f"[psyclone] called with {api} API and with transformed_file" ) diff --git a/tests/unit_tests/tools/test_tool.py b/tests/unit_tests/tools/test_tool.py index bbfa9738..f37e955b 100644 --- a/tests/unit_tests/tools/test_tool.py +++ b/tests/unit_tests/tools/test_tool.py @@ -18,6 +18,8 @@ from fab.tools.flags import ProfileFlags from fab.tools.tool import CompilerSuiteTool, Tool +from fab.errors import FabCommandError, FabCommandNotFound + def test_constructor() -> None: """ @@ -89,8 +91,7 @@ def test_is_not_available(fake_process: FakeProcess) -> None: # an exception now: with raises(RuntimeError) as err: tool.run("--ops") - assert ("Tool 'gfortran' is not available to run '['gfortran', '--ops']" - in str(err.value)) + assert ("[gfortran] not available" in str(err.value)) def test_availability_argument(fake_process: FakeProcess) -> None: @@ -112,9 +113,8 @@ def test_run_missing(fake_process: FakeProcess) -> None: tool = Tool("some tool", "stool", Category.MISC) with raises(RuntimeError) as err: tool.run("--ops") - assert str(err.value).startswith( - "Unable to execute command: ['stool', '--ops']" - ) + assert isinstance(err.value, FabCommandNotFound) + assert str(err.value) == "unable to execute stool" # Check that stdout and stderr is returned fake_process.register(['stool', '--ops'], returncode=1, @@ -123,8 +123,9 @@ def test_run_missing(fake_process: FakeProcess) -> None: tool = Tool("some tool", "stool", Category.MISC) with raises(RuntimeError) as err: tool.run("--ops") - assert "this is stdout" in str(err.value) - assert "this is stderr" in str(err.value) + assert isinstance(err.value, FabCommandError) + assert "this is stdout" in str(err.value.output) + assert "this is stderr" in str(err.value.error) def test_tool_flags_no_profile() -> None: @@ -204,8 +205,12 @@ def test_error(self, fake_process: FakeProcess) -> None: tool = Tool("some tool", "tool", Category.MISC) with raises(RuntimeError) as err: tool.run() - assert str(err.value) == ("Command failed with return code 1:\n" - "['tool']\nBeef.") + assert isinstance(err.value, FabCommandError) + assert str(err.value) == "command 'tool' returned 1" + assert err.value.code == 1 + assert err.value.output == "Beef." + assert err.value.error == "" + assert call_list(fake_process) == [['tool']] def test_error_file_not_found(self, fake_process: FakeProcess) -> None: @@ -216,7 +221,8 @@ def test_error_file_not_found(self, fake_process: FakeProcess) -> None: tool = Tool('some tool', 'tool', Category.MISC) with raises(RuntimeError) as err: tool.run() - assert str(err.value) == "Unable to execute command: ['tool']" + assert isinstance(err.value, FabCommandNotFound) + assert str(err.value) == "unable to execute tool" assert call_list(fake_process) == [['tool']] diff --git a/tests/unit_tests/tools/test_tool_box.py b/tests/unit_tests/tools/test_tool_box.py index e777139c..eee77cf7 100644 --- a/tests/unit_tests/tools/test_tool_box.py +++ b/tests/unit_tests/tools/test_tool_box.py @@ -17,6 +17,7 @@ from fab.tools.compiler import CCompiler, Gfortran from fab.tools.tool_box import ToolBox from fab.tools.tool_repository import ToolRepository +from fab.errors import FabToolNotAvailable def test_constructor() -> None: @@ -88,4 +89,5 @@ def test_add_unavailable_tool(fake_process: FakeProcess) -> None: gfortran = Gfortran() with raises(RuntimeError) as err: tb.add_tool(gfortran) - assert str(err.value).startswith(f"Tool '{gfortran}' is not available") + assert isinstance(err.value, FabToolNotAvailable) + assert str(err.value).startswith("[gfortran] not available") diff --git a/tests/unit_tests/tools/test_tool_repository.py b/tests/unit_tests/tools/test_tool_repository.py index af2ca8a2..e9d5f456 100644 --- a/tests/unit_tests/tools/test_tool_repository.py +++ b/tests/unit_tests/tools/test_tool_repository.py @@ -16,6 +16,8 @@ from fab.tools.compiler_wrapper import Mpif90 from fab.tools.tool_repository import ToolRepository +from fab.errors import FabToolInvalidSetting, FabToolNotAvailable + def test_tool_repository_get_singleton_new(): '''Tests the singleton behaviour.''' @@ -128,7 +130,8 @@ def test_get_default_error_invalid_category() -> None: tr = ToolRepository() with raises(RuntimeError) as err: tr.get_default("unknown-category-type") # type: ignore[arg-type] - assert "Invalid category type 'str'." in str(err.value) + assert isinstance(err.value, FabToolInvalidSetting) + assert "[str] invalid category" in str(err.value) def test_get_default_error_missing_mpi() -> None: @@ -139,13 +142,13 @@ def test_get_default_error_missing_mpi() -> None: tr = ToolRepository() with raises(RuntimeError) as err: tr.get_default(Category.FORTRAN_COMPILER, openmp=True) - assert str(err.value) == ("Invalid or missing mpi specification " - "for 'FORTRAN_COMPILER'.") + assert isinstance(err.value, FabToolInvalidSetting) + assert str(err.value) == "[FORTRAN_COMPILER] invalid MPI setting" with raises(RuntimeError) as err: tr.get_default(Category.FORTRAN_COMPILER, mpi=True) - assert str(err.value) == ("Invalid or missing openmp specification " - "for 'FORTRAN_COMPILER'.") + assert isinstance(err.value, FabToolInvalidSetting) + assert str(err.value) == "[FORTRAN_COMPILER] invalid OpenMP setting" def test_get_default_error_missing_openmp() -> None: @@ -157,23 +160,24 @@ def test_get_default_error_missing_openmp() -> None: with raises(RuntimeError) as err: tr.get_default(Category.FORTRAN_COMPILER, mpi=True) - assert ("Invalid or missing openmp specification for 'FORTRAN_COMPILER'" - in str(err.value)) + assert isinstance(err.value, FabToolInvalidSetting) + assert str(err.value) == "[FORTRAN_COMPILER] invalid OpenMP setting" + with raises(RuntimeError) as err: tr.get_default(Category.FORTRAN_COMPILER, mpi=True, openmp='123') # type: ignore[arg-type] - assert str(err.value) == ("Invalid or missing openmp specification " - "for 'FORTRAN_COMPILER'.") + assert isinstance(err.value, FabToolInvalidSetting) + assert str(err.value) == "[FORTRAN_COMPILER] invalid OpenMP setting" @mark.parametrize("mpi, openmp, message", - [(False, False, "any 'FORTRAN_COMPILER'."), + [(False, False, "invalid category match"), (False, True, - "'FORTRAN_COMPILER' that supports OpenMP."), + "[FORTRAN_COMPILER] invalid OpenMP setting"), (True, False, - "'FORTRAN_COMPILER' that supports MPI."), - (True, True, "'FORTRAN_COMPILER' that supports MPI " - "and OpenMP.")]) + "[FORTRAN_COMPILER] invalid MPI setting"), + (True, True, "[FORTRAN_COMPILER] invalid MPI " + "and OpenMP setting")]) def test_get_default_error_missing_compiler(mpi, openmp, message, monkeypatch) -> None: """ @@ -185,7 +189,8 @@ def test_get_default_error_missing_compiler(mpi, openmp, message, with raises(RuntimeError) as err: tr.get_default(Category.FORTRAN_COMPILER, mpi=mpi, openmp=openmp) - assert str(err.value) == f"Could not find {message}" + assert isinstance(err.value, FabToolInvalidSetting) + assert message in str(err.value) def test_get_default_error_missing_openmp_compiler(monkeypatch) -> None: @@ -204,8 +209,8 @@ def test_get_default_error_missing_openmp_compiler(monkeypatch) -> None: with raises(RuntimeError) as err: tr.get_default(Category.FORTRAN_COMPILER, mpi=False, openmp=True) - assert (str(err.value) == "Could not find 'FORTRAN_COMPILER' that " - "supports OpenMP.") + assert isinstance(err.value, FabToolInvalidSetting) + assert str(err.value) == "[FORTRAN_COMPILER] invalid OpenMP setting" @mark.parametrize('category', [Category.C_COMPILER, @@ -249,8 +254,8 @@ def test_default_suite_unknown() -> None: repo = ToolRepository() with raises(RuntimeError) as err: repo.set_default_compiler_suite("does-not-exist") - assert str(err.value) == ("Cannot find 'FORTRAN_COMPILER' in " - "the suite 'does-not-exist'.") + assert isinstance(err.value, FabToolNotAvailable) + assert str(err.value) == "[FORTRAN_COMPILER] not available in suite does-not-exist" def test_no_tool_available(fake_process: FakeProcess) -> None: @@ -266,8 +271,8 @@ def test_no_tool_available(fake_process: FakeProcess) -> None: with raises(RuntimeError) as err: tr.get_default(Category.SHELL) - assert (str(err.value) == "Can't find available 'SHELL' tool. Tools are " - "'sh'.") + assert isinstance(err.value, FabToolInvalidSetting) + assert str(err.value) == "[SHELL] invalid category where tool names are sh" def test_tool_repository_full_path(fake_process: FakeProcess) -> None: diff --git a/tests/unit_tests/tools/test_versioning.py b/tests/unit_tests/tools/test_versioning.py index bf9f41e2..e49ee4af 100644 --- a/tests/unit_tests/tools/test_versioning.py +++ b/tests/unit_tests/tools/test_versioning.py @@ -22,6 +22,8 @@ from fab.tools.category import Category from fab.tools.versioning import Fcm, Git, Subversion +from fab.errors import FabCommandError + class TestGit: """ @@ -132,7 +134,8 @@ def test_git_fetch_error(self, fake_process: FakeProcess) -> None: git = Git() with raises(RuntimeError) as err: git.fetch("/src", "/dst", revision="revision") - assert str(err.value).startswith("Command failed with return code 1:") + assert isinstance(err.value, FabCommandError) + assert str(err.value) == "command 'git fetch /src revision' returned 1" assert call_list(fake_process) == [ ['git', 'fetch', "/src", "revision"] ] @@ -164,7 +167,8 @@ def test_git_checkout_error(self, fake_process: FakeProcess) -> None: git = Git() with raises(RuntimeError) as err: git.checkout("/src", "/dst", revision="revision") - assert str(err.value).startswith("Command failed with return code 1:") + assert isinstance(err.value, FabCommandError) + assert str(err.value) == "command 'git fetch /src revision' returned 1" assert call_list(fake_process) == [ ['git', 'fetch', "/src", "revision"] ] @@ -195,7 +199,7 @@ def test_git_merge_error(self, fake_process: FakeProcess) -> None: with raises(RuntimeError) as err: git.merge("/dst", revision="revision") assert str(err.value).startswith( - "Error merging revision. Merge aborted." + "[git] merge of revision failed:" ) assert call_list(fake_process) == [ ['git', 'merge', 'FETCH_HEAD'], @@ -216,7 +220,7 @@ def test_git_merge_collapse(self, fake_process: FakeProcess) -> None: git = Git() with raises(RuntimeError) as err: git.merge("/dst", revision="revision") - assert str(err.value).startswith("Command failed with return code 1:") + assert str(err.value).startswith("command 'git merge") assert call_list(fake_process) == [ ['git', 'merge', 'FETCH_HEAD'], ['git', 'merge', '--abort']