From 519b8fb877a9470bfcf47bc660ec79b5513bca3e Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Tue, 12 Nov 2024 20:58:08 +1100 Subject: [PATCH] Compiler wrapper (#347) * #3 Converted svn and fcm to tools. * #3 Fixed missing whitespace. * Modified the documentation for writing a config with PSyclone * Add config as a parameter for run_psyclone for the transformation_script to use;Updated the related functions and tests; Changed the logic of the transformation_script examples * #3 Replaced ar with tool object. * #3 Added tests for ar.py. * #3 Removed debug output. * #3 Converted PSyclone to be a tool. * #3 Removed debug print, fixed python 3.7 typing information. * #3 Updated comments. * Modified the get_optimisation_script function examples and updated the doc formatting * #3 Add Rsync tool. * #3 Removed now unused function. * #3 Added test for rsync. * #3 Fixed all mypy warnings about functions not checked. * #3 Replace all mock-tests to use subprocess so the name of the executable is tested as well. * #3 Remove duplicated flags. * #3 Fixed changed order of linking. * #3 Removed run_command function. * #3 Fixed 3.8 typing error. * #3 Fixed unused imports. * #3 Move flags checksum into Flags, and remove now unused tools.py file. * #3 Renamed newtools to tools. * #3 Made custom function for all git functions called (instead of just calling run). * #3 Updated and fixed comments. * #3 Fixed errors in comments. * Fixed minor errors in documentation. * #3 Make it easier to create wrapper around standard compiler. * #3 Added documentation for all tool related classes and their usage. * #3 Added MISC category. * Addressed reviewer's comments. * Updated cli to properly use ToolBox etc, removing hard-coded gnu command linker option. * Fixed mypy failures, including changes to import statement to avoid cyclic imports :(. * #3 Fix circular import. * Added #TODO so that this can be removed once fparser supports sentinels. * Fix typing problems by ignoring fparser. * Replaced more string names for artefacts with enums. * Removed EXECUTABLES from constants. * Moved Artefact class out of ArtefactStore and renamed it to ArtefactSet. * Moved OBJECT_FILES from constants into ArtefactSet. * Moved OBJECT_ARCHIVES from constants to ArtefactSet. * Moved PRAGMAD_C from constants to ArtefactSet. * Turned 'all_source' into an enum. * Allow integer as revision. * Fixed flake8 error. * Removed specific functions to add/get fortran source files etc. * Removed non-existing and unneccessary collections. * Try to fix all run_configs. * Fixed rebase issues. * Added replace functionality to ArtefactStore, updated test_artefacts to cover all lines in that file. * Started to replace artefacts when files are pre-processed. * Removed linker argument from linking step in all examples. * Try to get jules to link. * Fixed build_jules. * Fixed other issues raised in reviews. * Try to get jules to link. * Fixed other issues raised in reviews. * Simplify handling of X90 files by replacing the X90 with x90, meaning only one artefact set is involved when running PSyclone. * Make OBJECT_ARCHIVES also a dict, migrate more code to replace/add files to the default build artefact collections. * Fixed some examples. * Fix flake8 error. * Fixed failing tests. * Support empty comments. * Fix preprocessor to not unnecessary remove and add files that are already in the output directory. * Allow find_soure_files to be called more than once by adding files (not replacing artefact). * Updated lfric_common so that files created by configurator are written in build (not source). * Use c_build_files instead of pragmad_c. * Removed unnecessary str. * Documented the new artefact set handling. * Fixed typo. * Make the PSyclone API configurable. * Fixed formatting of documentation, properly used ArtefactSet names. * Support .f and .F Fortran files. * Removed setter for tool.is_available, which was only used for testing. * #3 Fix documentation and coding style issues from review. * Renamed Categories into Category. * Minor coding style cleanup. * Removed more unnecessary (). * Re-added (invalid) grab_pre_build call. * Fixed typo. * Renamed set_default_vendor to set_default_compiler_suite. * Renamed VendorTool to CompilerSuiteTool. * Also accept a Path as exec_name specification for a tool. * Move the check_available function into the base class. * Fixed some types and documentation. * Fix typing error. * Added explanation for meta-compiler. * Improved error handling and documentation. * Replace mpiifort with mpifort to be a tiny bit more portable. * Use classes to group tests for git/svn/fcm together. * Fixed issue in get_transformation script, and moved script into lfric_common to remove code duplication. * Code improvement as suggested by review. * Fixed run config * Added reference to ticket. * Updated type information. * More typing fixes. * Fixed typing warnings. * As requested by reviewer removed is_working_copy functionality. * Issue a warning (which can be silenced) when a tool in a toolbox is replaced. * Fixed flake8. * Fixed flake8. * Fixed failing test. * Addressed issues raised in review. * Removed now unnecessary operations. * Updated some type information. * Fixed all references to APIs to be consistent with PSyclone 2.5. * Added api to the checksum computation. * Fixed type information. * Added test to verify that changing the api changes the checksum. * Make compiler version a tuple of integers * Update some tests to use tuple versions * Explicitly test handling of bad version format * Fix formatting * Tidying up * Make compiler raise an error for any invalid version string Assume these compilers don't need to be hashed. Saves dealing with empty tuples. * Check compiler version string for compiler name * Fix formatting * Add compiler.get_version_string() method Includes other cleanup from PR comments * Add mpi and openmp settings to BuildConfig, made compiler MPI aware. * Looks like the circular dependency has been fixed. * Revert "Looks like the circular dependency has been fixed." ... while it works with the tests, a real application still triggered it. This reverts commit 150dc379af9df8c38e623fae144a0d5196319f10. * Don't even try to find a C compiler if no C files are to be compiled. * Updated gitignore to ignore (recently renamed) documentation. * Fixed failing test. * Return from compile Fortran early if there are no files to compiles. Fixed coding style. * Add MPI enables wrapper for intel and gnu compiler. * Fixed test. * Automatically add openmp flag to compiler and linker based on BuildConfig. * Removed enforcement of keyword parameters, which is not supported in python 3.7. * Fixed failing test. * Support more than one tool of a given suite by sorting them. * Use different version checkout for each compiler vendor with mixins * Refactoring, remove unittest compiler class * Fix some mypy errors * Use 'Union' type hint to fix build checks * Added option to add flags to a tool. * Introduce proper compiler wrapper, used this to implement properly wrapper MPI compiler. * Fixed typo in types. * Return run_version_command to base Compiler class Provides default version command that can be overridden for other compilers. Also fix some incorrect tests Other tidying * Add a missing type hint * Added (somewhat stupid) 'test' to reach 100% coverage of PSyclone tool. * Simplified MPI support in wrapper. * More compiler wrapper coverage. * Removed duplicated function. * Removed debug print. * Removed permanently changing compiler attributes, which can cause test failures later. * More test for C compiler wrapper. * More work on compiler wrapper tests. * Fixed version and availability handling, added missing tests for 100% coverage. * Fixed typing error. * Try to fix python 3.7. * Tried to fix failing tests. * Remove inheritance from mixins and use protocol * Simplify compiler inheritance Mixins have static methods with unique names, overrides only happen in concrete classes * Updated wrapper and tests to handle error raised in get_version. * Simplified regular expressions (now tests cover detection of version numbers with only a major version). * Test for missing mixin. * Use the parsing mixing from the compiler in a compiler wrapper. * Use setattr instead of assignment to make mypy happy. * Simplify usage of compiler-specific parsing mixins. * Minor code cleanup. * Updated documentation. * Simplify usage of compiler-specific parsing mixins. * Test for missing mixin. * Fixed test. * Added missing openmp_flag property to compiler wrapper. * Don't use isinstance for consistency check, which does not work for CompilerWrappers. * Fixed isinstance test for C compilation which doesn't work with a CompilerWrapper. * Use a linker's compiler to determine MPI support. Removed mpi property from CompilerSuite. * Added more tests for invalid version numbers. * Added more test cases for invalid version number, improved regex to work as expected. * Fixed typo in test. * Fixed flake/mypy errors. * Combine wrapper flags with flags from wrapped compiler. * Made mypy happy. * Fixed test. * Split tests into smaller individual ones, fixed missing asssert in test. * Parameterised compiler version tests to also test wrapper. * Added missing MPI parameter when getting the compiler. * Fixed comments. * Order parameters to be in same order for various compiler classes. * Remove stray character * Added getter for wrapped compiler. * Fixed small error that would prevent nested compiler wrappers from being used. * Added a cast to make mypy happy. * Fixed spelling mistake in option. * Clarified documentation. * Removed unnecessary functions in CompilerWrapper. * Made mpi and openmp default to False in the BuildConfig constructor. * Removed white space. * Support compilers that do not support OpenMP. * Added documentation for openmp parameter. --------- Co-authored-by: Junwei Lyu Co-authored-by: Junwei Lyu Co-authored-by: jasonjunweilyu <161689601+jasonjunweilyu@users.noreply.github.com> Co-authored-by: Luke Hoffmann Co-authored-by: Luke Hoffmann <992315+lukehoffmann@users.noreply.github.com> --- Documentation/source/site-specific-config.rst | 63 ++++ source/fab/steps/compile_c.py | 11 +- source/fab/steps/compile_fortran.py | 18 +- source/fab/tools/__init__.py | 11 +- source/fab/tools/compiler.py | 117 ++---- source/fab/tools/compiler_wrapper.py | 198 ++++++++++ source/fab/tools/flags.py | 14 +- source/fab/tools/linker.py | 5 + source/fab/tools/preprocessor.py | 4 +- source/fab/tools/tool.py | 45 ++- source/fab/tools/tool_repository.py | 28 +- source/fab/tools/versioning.py | 2 +- tests/unit_tests/steps/test_compile_c.py | 14 +- .../unit_tests/steps/test_compile_fortran.py | 29 +- tests/unit_tests/tools/test_compiler.py | 147 ++------ .../unit_tests/tools/test_compiler_wrapper.py | 348 ++++++++++++++++++ tests/unit_tests/tools/test_flags.py | 14 + tests/unit_tests/tools/test_psyclone.py | 14 + tests/unit_tests/tools/test_tool.py | 28 +- 19 files changed, 848 insertions(+), 262 deletions(-) create mode 100644 source/fab/tools/compiler_wrapper.py create mode 100644 tests/unit_tests/tools/test_compiler_wrapper.py diff --git a/Documentation/source/site-specific-config.rst b/Documentation/source/site-specific-config.rst index 0fd0f840..b7481c08 100644 --- a/Documentation/source/site-specific-config.rst +++ b/Documentation/source/site-specific-config.rst @@ -161,6 +161,64 @@ On the other hand, if no MPI is requested, an MPI-enabled compiler might be returned, which does not affect the final result, since an MPI compiler just adds include- and library-paths. + +Compiler Wrapper +================ +Fab supports the concept of a compiler wrapper, which is typically +a script that calls the actual compiler. An example for a wrapper is +`mpif90`, which might call a GNU or Intel based compiler (with additional +parameter for accessing the MPI specific include and library paths.). +An example to create a `mpicc` wrapper (note that this wrapper is already +part of Fab, there is no need to explicitly add this yourself): + +.. code-block:: + :linenos: + :caption: Defining an mpicc compiler wrapper + + class Mpicc(CompilerWrapper): + def __init__(self, compiler: Compiler): + super().__init__(name=f"mpicc-{compiler.name}", + exec_name="mpicc", + compiler=compiler, mpi=True) + +The tool system allows several different tools to use the same name +for the executable, as long as the Fab name is different, i.e. the +`mpicc-{compiler.name}`. The tool +repository will automatically add compiler wrapper for `mpicc` and +`mpif90` for any compiler that is added by Fab. If you want to add +a new compiler, which can also be invoked using `mpicc`, you need +to add a compiler wrapper as follows: + +.. code-block:: + :linenos: + :caption: Adding a mpicc wrapper to the tool repository + + my_new_compiler = MyNewCompiler() + ToolRepository().add_tool(my_new_compiler) + my_new_mpicc = Mpicc(MyNewCompiler) + ToolRepository().add_tool(my_new_mpicc) + +When creating a completely new compiler and compiler wrapper +as in the example above, it is strongly recommended to add +the new compiler instance to the tool repository as well. This will +allow the wrapper and the wrapped compiler to share flags. For example, +a user script can query the ToolRepository to get the original compiler +and modify its flags. These modification will then automatically be +applied to the wrapper as well: + +.. code-block:: + :linenos: + :caption: Sharing flags between compiler and compiler wrapper + + tr = ToolRepository() + my_compiler = tr.get_tool(Category.C_COMPILER, "my_compiler") + my_mpicc = tr.get_tool(Category.C_COMPILER, "mpicc-my_compiler") + + my_compiler.add_flags(["-a", "-b"]) + + assert my_mpicc.flags == ["-a", "-b"] + + TODO ==== At this stage compiler flags are still set in the corresponding Fab @@ -169,3 +227,8 @@ definition in the compiler objects. This will allow a site to define their own set of default flags to be used with a certain compiler by replacing or updating a compiler instance in the Tool Repository + +Also, a lot of content in this chapter is not actually about site-specific +configuration. This should likely be renamed or split (once we +have details about the using site-specific configuration, which might be +once the Baf base script is added to Fab). \ No newline at end of file diff --git a/source/fab/steps/compile_c.py b/source/fab/steps/compile_c.py index 41332dda..283d9607 100644 --- a/source/fab/steps/compile_c.py +++ b/source/fab/steps/compile_c.py @@ -10,7 +10,7 @@ import logging import os from dataclasses import dataclass -from typing import List, Dict, Optional, Tuple +from typing import cast, Dict, List, Optional, Tuple from fab import FabException from fab.artefacts import (ArtefactsGetter, ArtefactSet, ArtefactStore, @@ -19,7 +19,7 @@ from fab.metrics import send_metric from fab.parse.c import AnalysedC from fab.steps import check_for_errors, run_mp, step -from fab.tools import Category, CCompiler, Flags +from fab.tools import Category, Compiler, Flags from fab.util import CompiledFile, log_or_dot, Timer, by_type logger = logging.getLogger(__name__) @@ -124,9 +124,10 @@ def _compile_file(arg: Tuple[AnalysedC, MpCommonArgs]): analysed_file, mp_payload = arg config = mp_payload.config compiler = config.tool_box[Category.C_COMPILER] - if not isinstance(compiler, CCompiler): - raise RuntimeError(f"Unexpected tool '{compiler.name}' of type " - f"'{type(compiler)}' instead of CCompiler") + if compiler.category != Category.C_COMPILER: + raise RuntimeError(f"Unexpected tool '{compiler.name}' of category " + f"'{compiler.category}' instead of CCompiler") + compiler = cast(Compiler, compiler) with Timer() as timer: flags = Flags(mp_payload.flags.flags_for_path(path=analysed_file.fpath, config=config)) diff --git a/source/fab/steps/compile_fortran.py b/source/fab/steps/compile_fortran.py index 4b065811..fe6f479b 100644 --- a/source/fab/steps/compile_fortran.py +++ b/source/fab/steps/compile_fortran.py @@ -14,7 +14,7 @@ from dataclasses import dataclass from itertools import chain from pathlib import Path -from typing import List, Set, Dict, Tuple, Optional, Union +from typing import cast, Dict, List, Optional, Set, Tuple, Union from fab.artefacts import (ArtefactsGetter, ArtefactSet, ArtefactStore, FilterBuildTrees) @@ -22,7 +22,7 @@ from fab.metrics import send_metric from fab.parse.fortran import AnalysedFortran from fab.steps import check_for_errors, run_mp, step -from fab.tools import Category, Compiler, Flags, FortranCompiler +from fab.tools import Category, Compiler, Flags from fab.util import (CompiledFile, log_or_dot_finish, log_or_dot, Timer, by_type, file_checksum) @@ -133,9 +133,10 @@ 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 not isinstance(compiler, FortranCompiler): - raise RuntimeError(f"Unexpected tool '{compiler.name}' of type " - f"'{type(compiler)}' instead of FortranCompiler") + if compiler.category != Category.FORTRAN_COMPILER: + raise RuntimeError(f"Unexpected tool '{compiler.name}' of category " + f"'{compiler.category}' instead of FortranCompiler") + compiler = cast(Compiler, compiler) logger.info( f'Fortran compiler is {compiler} {compiler.get_version_string()}') @@ -263,10 +264,11 @@ def process_file(arg: Tuple[AnalysedFortran, MpCommonArgs]) \ config = mp_common_args.config compiler = config.tool_box.get_tool(Category.FORTRAN_COMPILER, config.mpi) - if not isinstance(compiler, FortranCompiler): - raise RuntimeError(f"Unexpected tool '{compiler.name}' of type " - f"'{type(compiler)}' instead of " + if compiler.category != Category.FORTRAN_COMPILER: + raise RuntimeError(f"Unexpected tool '{compiler.name}' of " + f"category '{compiler.category}' instead of " f"FortranCompiler") + compiler = cast(Compiler, compiler) flags = Flags(mp_common_args.flags.flags_for_path( path=analysed_file.fpath, config=config)) diff --git a/source/fab/tools/__init__.py b/source/fab/tools/__init__.py index ed5850c5..45eb666f 100644 --- a/source/fab/tools/__init__.py +++ b/source/fab/tools/__init__.py @@ -11,8 +11,8 @@ from fab.tools.category import Category from fab.tools.compiler import (CCompiler, Compiler, FortranCompiler, Gcc, Gfortran, GnuVersionHandling, Icc, Ifort, - IntelVersionHandling, MpiGcc, MpiGfortran, - MpiIcc, MpiIfort) + IntelVersionHandling) +from fab.tools.compiler_wrapper import CompilerWrapper, Mpicc, Mpif90 from fab.tools.flags import Flags from fab.tools.linker import Linker from fab.tools.psyclone import Psyclone @@ -29,6 +29,7 @@ "CCompiler", "Compiler", "CompilerSuiteTool", + "CompilerWrapper", "Cpp", "CppFortran", "Fcm", @@ -43,10 +44,8 @@ "Ifort", "IntelVersionHandling", "Linker", - "MpiGcc", - "MpiGfortran", - "MpiIcc", - "MpiIfort", + "Mpif90", + "Mpicc", "Preprocessor", "Psyclone", "Rsync", diff --git a/source/fab/tools/compiler.py b/source/fab/tools/compiler.py index 5c2dfea5..e1f87271 100644 --- a/source/fab/tools/compiler.py +++ b/source/fab/tools/compiler.py @@ -38,6 +38,9 @@ class Compiler(CompilerSuiteTool): of the output file :param openmp_flag: the flag to use to enable OpenMP. If no flag is specified, it is assumed that the compiler does not support OpenMP. + :param availability_option: a command line option for the tool to test + if the tool is available on the current system. Defaults to + `--version`. ''' # pylint: disable=too-many-arguments @@ -48,19 +51,21 @@ def __init__(self, name: str, mpi: bool = False, compile_flag: Optional[str] = None, output_flag: Optional[str] = None, - openmp_flag: Optional[str] = None): - super().__init__(name, exec_name, suite, mpi=mpi, category=category) + openmp_flag: Optional[str] = None, + availability_option: Optional[str] = None): + super().__init__(name, exec_name, suite, category=category, + availability_option=availability_option) self._version: Union[Tuple[int, ...], None] = None + self._mpi = mpi self._compile_flag = compile_flag if compile_flag else "-c" self._output_flag = output_flag if output_flag else "-o" self._openmp_flag = openmp_flag if openmp_flag else "" self.flags.extend(os.getenv("FFLAGS", "").split()) - def get_hash(self) -> int: - ''':returns: a hash based on the compiler name and version. - ''' - return (zlib.crc32(self.name.encode()) + - zlib.crc32(self.get_version_string().encode())) + @property + def mpi(self) -> bool: + '''Returns whether this compiler supports MPI or not.''' + return self._mpi @property def openmp(self) -> bool: @@ -70,10 +75,15 @@ def openmp(self) -> bool: @property def openmp_flag(self) -> str: - ''':returns: The flag to enable OpenMP for this compiler. - ''' + '''Returns the flag to enable OpenMP.''' return self._openmp_flag + def get_hash(self) -> int: + ''':returns: a hash based on the compiler name and version. + ''' + return (zlib.crc32(self.name.encode()) + + zlib.crc32(self.get_version_string().encode())) + def compile_file(self, input_file: Path, output_file: Path, openmp: bool, @@ -93,11 +103,11 @@ def compile_file(self, input_file: Path, params: List[Union[Path, str]] = [self._compile_flag] if openmp: - params.append(self._openmp_flag) + params.append(self.openmp_flag) if add_flags: - if self._openmp_flag in add_flags: + if self.openmp_flag in add_flags: warnings.warn( - f"OpenMP flag '{self._openmp_flag}' explicitly provided. " + f"OpenMP flag '{self.openmp_flag}' explicitly provided. " f"OpenMP should be enabled in the BuildConfiguration " f"instead.") params += add_flags @@ -149,7 +159,6 @@ def get_version(self) -> Tuple[int, ...]: version_string = self.parse_version_output(self.category, output) # Expect the version to be dot-separated integers. - # todo: Not all will be integers? but perhaps major and minor? try: version = tuple(int(x) for x in version_string.split('.')) except ValueError as err: @@ -246,14 +255,14 @@ class FortranCompiler(Compiler): :param name: name of the compiler. :param exec_name: name of the executable to start. :param suite: name of the compiler suite. - :param mpi: whether the compiler or linker support MPI. + :param mpi: whether MPI is supported by this compiler or not. :param compile_flag: the compilation flag to use when only requesting compilation (not linking). :param output_flag: the compilation flag to use to indicate the name of the output file + :param openmp_flag: the flag to use to enable OpenMP :param module_folder_flag: the compiler flag to indicate where to store created module files. - :param openmp_flag: the flag to use to enable OpenMP :param syntax_only_flag: flag to indicate to only do a syntax check. The side effect is that the module files are created. ''' @@ -293,11 +302,12 @@ def compile_file(self, input_file: Path, output_file: Path, openmp: bool, add_flags: Union[None, List[str]] = None, - syntax_only: bool = False): + syntax_only: Optional[bool] = False): '''Compiles a file. :param input_file: the name of the input file. :param output_file: the name of the output file. + :param openmp: if compilation should be done with OpenMP. :param add_flags: additional flags for the compiler. :param syntax_only: if set, the compiler will only do a syntax check @@ -306,7 +316,9 @@ def compile_file(self, input_file: Path, params: List[str] = [] if add_flags: new_flags = Flags(add_flags) - new_flags.remove_flag(self._module_folder_flag, has_parameter=True) + if self._module_folder_flag: + new_flags.remove_flag(self._module_folder_flag, + has_parameter=True) new_flags.remove_flag(self._compile_flag, has_parameter=False) params += new_flags @@ -375,18 +387,6 @@ def __init__(self, openmp_flag="-fopenmp") -# ============================================================================ -class MpiGcc(Gcc): - '''Class for a simple wrapper around gcc that supports MPI. - It calls `mpicc`. - ''' - - def __init__(self): - super().__init__(name="mpicc-gcc", - exec_name="mpicc", - mpi=True) - - # ============================================================================ class Gfortran(GnuVersionHandling, FortranCompiler): '''Class for GNU's gfortran compiler. @@ -396,28 +396,14 @@ class Gfortran(GnuVersionHandling, FortranCompiler): :param mpi: whether the compiler supports MPI. ''' - def __init__(self, - name: str = "gfortran", - exec_name: str = "gfortran", - mpi: bool = False): - super().__init__(name, exec_name, suite="gnu", mpi=mpi, + def __init__(self, name: str = "gfortran", + exec_name: str = "gfortran"): + super().__init__(name, exec_name, suite="gnu", openmp_flag="-fopenmp", module_folder_flag="-J", syntax_only_flag="-fsyntax-only") -# ============================================================================ -class MpiGfortran(Gfortran): - '''Class for a simple wrapper around gfortran that supports MPI. - It calls `mpif90`. - ''' - - def __init__(self): - super().__init__(name="mpif90-gfortran", - exec_name="mpif90", - mpi=True) - - # ============================================================================ class IntelVersionHandling(): '''Mixin to handle version information from Intel compilers''' @@ -460,24 +446,10 @@ class Icc(IntelVersionHandling, CCompiler): :param exec_name: name of the executable. :param mpi: whether the compiler supports MPI. ''' - def __init__(self, - name: str = "icc", - exec_name: str = "icc", - mpi: bool = False): - super().__init__(name, exec_name, suite="intel-classic", mpi=mpi, - openmp_flag="-qopenmp") - -# ============================================================================ -class MpiIcc(Icc): - '''Class for a simple wrapper around icc that supports MPI. - It calls `mpicc`. - ''' - - def __init__(self): - super().__init__(name="mpicc-icc", - exec_name="mpicc", - mpi=True) + def __init__(self, name: str = "icc", exec_name: str = "icc"): + super().__init__(name, exec_name, suite="intel-classic", + openmp_flag="-qopenmp") # ============================================================================ @@ -489,23 +461,8 @@ class Ifort(IntelVersionHandling, FortranCompiler): :param mpi: whether the compiler supports MPI. ''' - def __init__(self, - name: str = "ifort", - exec_name: str = "ifort", - mpi: bool = False): - super().__init__(name, exec_name, suite="intel-classic", mpi=mpi, + def __init__(self, name: str = "ifort", exec_name: str = "ifort"): + super().__init__(name, exec_name, suite="intel-classic", module_folder_flag="-module", openmp_flag="-qopenmp", syntax_only_flag="-syntax-only") - - -# ============================================================================ -class MpiIfort(Ifort): - '''Class for a simple wrapper around ifort that supports MPI. - It calls `mpif90`. - ''' - - def __init__(self): - super().__init__(name="mpif90-ifort", - exec_name="mpif90", - mpi=True) diff --git a/source/fab/tools/compiler_wrapper.py b/source/fab/tools/compiler_wrapper.py new file mode 100644 index 00000000..e54f98ea --- /dev/null +++ b/source/fab/tools/compiler_wrapper.py @@ -0,0 +1,198 @@ +############################################################################## +# (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 +############################################################################## + +"""This file contains the base class for any compiler, and derived +classes for gcc, gfortran, icc, ifort +""" + +from pathlib import Path +from typing import cast, List, Optional, Tuple, Union + +from fab.tools.category import Category +from fab.tools.compiler import Compiler, FortranCompiler +from fab.tools.flags import Flags + + +class CompilerWrapper(Compiler): + '''A decorator-based compiler wrapper. It basically uses a different + executable name when compiling, but otherwise behaves like the wrapped + compiler. An example of a compiler wrapper is `mpif90` (which can + internally call e.g. gfortran, icc, ...) + + :param name: name of the wrapper. + :param exec_name: name of the executable to call. + :param compiler: the compiler that is decorated. + :param mpi: whether MPI is supported by this compiler or not. + ''' + + def __init__(self, name: str, exec_name: str, + compiler: Compiler, + mpi: bool = False): + self._compiler = compiler + super().__init__( + name=name, exec_name=exec_name, + category=self._compiler.category, + suite=self._compiler.suite, + mpi=mpi, + availability_option=self._compiler.availability_option) + # We need to have the right version to parse the version output + # So we set this function based on the function that the + # wrapped compiler uses: + setattr(self, "parse_version_output", compiler.parse_version_output) + + def __str__(self): + return f"{type(self).__name__}({self._compiler.name})" + + def get_version(self) -> Tuple[int, ...]: + """ + :returns: a tuple of at least 2 integers, representing the version + e.g. (6, 10, 1) for version '6.10.1'. + + :raises RuntimeError: if the compiler was not found, or if it returned + an unrecognised output from the version command. + """ + + if self._version is not None: + return self._version + + try: + compiler_version = self._compiler.get_version() + except RuntimeError as err: + raise RuntimeError(f"Cannot get version of wrapped compiler '" + f"{self._compiler}") from err + + wrapper_version = super().get_version() + if compiler_version != wrapper_version: + compiler_version_string = self._compiler.get_version_string() + # We cannot call super().get_version_string(), since this calls + # calls get_version(), so we get an infinite recursion + wrapper_version_string = ".".join(str(x) for x in wrapper_version) + raise RuntimeError(f"Different version for compiler " + f"'{self._compiler}' " + f"({compiler_version_string}) and compiler " + f"wrapper '{self}' ({wrapper_version_string}).") + self._version = wrapper_version + return wrapper_version + + @property + def compiler(self) -> Compiler: + ''':returns: the compiler that is wrapped by this CompilerWrapper.''' + return self._compiler + + @property + def flags(self) -> Flags: + ''':returns: the flags to be used with this tool.''' + return Flags(self._compiler.flags + self._flags) + + @property + def suite(self) -> str: + ''':returns: the compiler suite of this tool.''' + return self._compiler.suite + + @property + def openmp_flag(self) -> str: + '''Returns the flag to enable OpenMP.''' + return self._compiler.openmp_flag + + @property + def has_syntax_only(self) -> bool: + ''':returns: whether this compiler supports a syntax-only feature. + + :raises RuntimeError: if this function is called for a non-Fortran + wrapped compiler. + ''' + + 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.") + + def set_module_output_path(self, path: Path): + '''Sets the output path for modules. + + :params path: the path to the output directory. + + :raises RuntimeError: if this function is called for a non-Fortran + wrapped compiler. + ''' + + if self._compiler.category != Category.FORTRAN_COMPILER: + raise RuntimeError(f"Compiler '{self._compiler.name}' has no " + f"'set_module_output_path' function.") + cast(FortranCompiler, self._compiler).set_module_output_path(path) + + def compile_file(self, input_file: Path, + output_file: Path, + openmp: bool, + add_flags: Union[None, List[str]] = None, + syntax_only: Optional[bool] = None): + # pylint: disable=too-many-arguments + '''Compiles a file using the wrapper compiler. It will temporarily + change the executable name of the wrapped compiler, and then calls + the original compiler (to get all its parameters) + + :param input_file: the name of the input file. + :param output_file: the name of the output file. + :param openmp: if compilation should be done with OpenMP. + :param add_flags: additional flags for the compiler. + :param syntax_only: if set, the compiler will only do + a syntax check + ''' + + orig_compiler_name = self._compiler.exec_name + self._compiler.change_exec_name(self.exec_name) + if add_flags is None: + add_flags = [] + if self._compiler.category is Category.FORTRAN_COMPILER: + # Mypy complains that self._compiler does not take the syntax + # only parameter. Since we know it's a FortranCompiler. + # do a cast to tell mypy that this is now a Fortran compiler + # (or a CompilerWrapper in case of nested CompilerWrappers, + # which also supports the syntax_only flag anyway) + self._compiler = cast(FortranCompiler, self._compiler) + self._compiler.compile_file(input_file, output_file, openmp=openmp, + add_flags=self.flags + add_flags, + syntax_only=syntax_only, + ) + else: + if syntax_only is not None: + raise RuntimeError(f"Syntax-only cannot be used with compiler " + f"'{self.name}'.") + self._compiler.compile_file(input_file, output_file, openmp=openmp, + add_flags=self.flags+add_flags + ) + self._compiler.change_exec_name(orig_compiler_name) + + +# ============================================================================ +class Mpif90(CompilerWrapper): + '''Class for a simple wrapper for using a compiler driver (like mpif90) + It will be using the name "mpif90-COMPILER_NAME" and calls `mpif90`. + All flags from the original compiler will be used when using the wrapper + as compiler. + + :param compiler: the compiler that the mpif90 wrapper will use. + ''' + + def __init__(self, compiler: Compiler): + super().__init__(name=f"mpif90-{compiler.name}", + exec_name="mpif90", compiler=compiler, mpi=True) + + +# ============================================================================ +class Mpicc(CompilerWrapper): + '''Class for a simple wrapper for using a compiler driver (like mpicc) + It will be using the name "mpicc-COMPILER_NAME" and calls `mpicc`. + All flags from the original compiler will be used when using the wrapper + as compiler. + + :param compiler: the compiler that the mpicc wrapper will use. + ''' + + def __init__(self, compiler: Compiler): + super().__init__(name=f"mpicc-{compiler.name}", + exec_name="mpicc", compiler=compiler, mpi=True) diff --git a/source/fab/tools/flags.py b/source/fab/tools/flags.py index 6303a754..d83d57cc 100644 --- a/source/fab/tools/flags.py +++ b/source/fab/tools/flags.py @@ -10,7 +10,7 @@ ''' import logging -from typing import List, Optional +from typing import List, Optional, Union import warnings from fab.util import string_checksum @@ -38,6 +38,18 @@ def checksum(self) -> str: """ return string_checksum(str(self)) + def add_flags(self, new_flags: Union[str, List[str]]): + '''Adds the specified flags to the list of flags. + + :param new_flags: A single string or list of strings which are the + flags to be added. + ''' + + if isinstance(new_flags, str): + self.append(new_flags) + else: + self.extend(new_flags) + def remove_flag(self, remove_flag: str, has_parameter: bool = False): '''Removes all occurrences of `remove_flag` in flags`. If has_parameter is defined, the next entry in flags will also be diff --git a/source/fab/tools/linker.py b/source/fab/tools/linker.py index 02932a18..69cfcec2 100644 --- a/source/fab/tools/linker.py +++ b/source/fab/tools/linker.py @@ -51,6 +51,11 @@ def __init__(self, name: Optional[str] = None, self._compiler = compiler self.flags.extend(os.getenv("LDFLAGS", "").split()) + @property + def mpi(self) -> bool: + ''':returns: whether the linker supports MPI or not.''' + return self._compiler.mpi + def check_available(self) -> bool: ''' :returns: whether the linker is available or not. We do this diff --git a/source/fab/tools/preprocessor.py b/source/fab/tools/preprocessor.py index be9f9d43..e620ce2a 100644 --- a/source/fab/tools/preprocessor.py +++ b/source/fab/tools/preprocessor.py @@ -26,7 +26,7 @@ class Preprocessor(Tool): def __init__(self, name: str, exec_name: Union[str, Path], category: Category, - availablility_option: Optional[str] = None): + availability_option: Optional[str] = None): super().__init__(name, exec_name, category) self._version = None @@ -74,4 +74,4 @@ def __init__(self): # fpp -V prints version information, but then hangs (i.e. reading # from stdin), so use -what to see if it is available super().__init__("fpp", "fpp", Category.FORTRAN_PREPROCESSOR, - availablility_option="-what") + availability_option="-what") diff --git a/source/fab/tools/tool.py b/source/fab/tools/tool.py index 9eaa42e1..cb8a7a06 100644 --- a/source/fab/tools/tool.py +++ b/source/fab/tools/tool.py @@ -36,14 +36,14 @@ class Tool: def __init__(self, name: str, exec_name: Union[str, Path], category: Category = Category.MISC, - availablility_option: Optional[str] = None): + availability_option: Optional[str] = None): self._logger = logging.getLogger(__name__) self._name = name self._exec_name = str(exec_name) self._flags = Flags() self._category = category - if availablility_option: - self._availability_option = availablility_option + if availability_option: + self._availability_option = availability_option else: self._availability_option = "--version" @@ -91,11 +91,26 @@ def exec_name(self) -> str: ''':returns: the name of the executable.''' return self._exec_name + def change_exec_name(self, exec_name: str): + '''Changes the name of the executable This function should in general + not be used (typically it is better to create a new tool instead). The + function is only provided to support CompilerWrapper (like mpif90), + which need all parameters from the original compiler, but call the + wrapper. The name of the compiler will be changed just before + compilation, and then set back to its original value + ''' + self._exec_name = exec_name + @property def name(self) -> str: ''':returns: the name of the tool.''' return self._name + @property + def availability_option(self) -> str: + ''':returns: the option to use to check if the tool is available.''' + return self._availability_option + @property def category(self) -> Category: ''':returns: the category of this tool.''' @@ -106,6 +121,14 @@ def flags(self) -> Flags: ''':returns: the flags to be used with this tool.''' return self._flags + def add_flags(self, new_flags: Union[str, List[str]]): + '''Adds the specified flags to the list of flags. + + :param new_flags: A single string or list of strings which are the + flags to be added. + ''' + self._flags.add_flags(new_flags) + @property def logger(self) -> logging.Logger: ''':returns: a logger object for convenience.''' @@ -181,20 +204,18 @@ class CompilerSuiteTool(Tool): :param exec_name: name of the executable to start. :param suite: name of the compiler suite. :param category: the Category to which this tool belongs. - :param mpi: whether the compiler or linker support MPI. + :param availability_option: a command line option for the tool to test + if the tool is available on the current system. Defaults to + `--version`. ''' def __init__(self, name: str, exec_name: Union[str, Path], suite: str, - category: Category, mpi: bool = False): - super().__init__(name, exec_name, category) + category: Category, + availability_option: Optional[str] = None): + super().__init__(name, exec_name, category, + availability_option=availability_option) self._suite = suite - self._mpi = mpi @property def suite(self) -> str: ''':returns: the compiler suite of this tool.''' return self._suite - - @property - def mpi(self) -> bool: - ''':returns: whether this tool supports MPI or not.''' - return self._mpi diff --git a/source/fab/tools/tool_repository.py b/source/fab/tools/tool_repository.py index 70479d55..6a077b67 100644 --- a/source/fab/tools/tool_repository.py +++ b/source/fab/tools/tool_repository.py @@ -12,10 +12,11 @@ from __future__ import annotations import logging -from typing import Any, Optional, Type +from typing import cast, Optional from fab.tools.tool import Tool from fab.tools.category import Category +from fab.tools.compiler import Compiler from fab.tools.linker import Linker from fab.tools.versioning import Fcm, Git, Subversion @@ -60,26 +61,30 @@ def __init__(self): # We get circular dependencies if imported at top of the file: # pylint: disable=import-outside-toplevel from fab.tools import (Ar, Cpp, CppFortran, Gcc, Gfortran, - Icc, Ifort, MpiGcc, MpiGfortran, - MpiIcc, MpiIfort, Psyclone, Rsync) + Icc, Ifort, Psyclone, Rsync) for cls in [Gcc, Icc, Gfortran, Ifort, Cpp, CppFortran, - MpiGcc, MpiGfortran, MpiIcc, MpiIfort, Fcm, Git, Subversion, Ar, Psyclone, Rsync]: - self.add_tool(cls) + self.add_tool(cls()) - def add_tool(self, cls: Type[Any]): + from fab.tools.compiler_wrapper import Mpif90, Mpicc + all_fc = self[Category.FORTRAN_COMPILER][:] + for fc in all_fc: + mpif90 = Mpif90(fc) + self.add_tool(mpif90) + + all_cc = self[Category.C_COMPILER][:] + for cc in all_cc: + mpicc = Mpicc(cc) + self.add_tool(mpicc) + + def add_tool(self, tool: Tool): '''Creates an instance of the specified class and adds it to the tool repository. :param cls: the tool to instantiate. ''' - # Note that we cannot declare `cls` to be `Type[Tool]`, since the - # Tool constructor requires arguments, but the classes used here are - # derived from Tool which do not require any arguments (e.g. Ifort) - - tool = cls() # We do not test if a tool is actually available. The ToolRepository # contains the tools that FAB knows about. It is the responsibility # of the ToolBox to make sure only available tools are added. @@ -87,6 +92,7 @@ def add_tool(self, cls: Type[Any]): # If we have a compiler, add the compiler as linker as well if tool.is_compiler: + tool = cast(Compiler, tool) linker = Linker(name=f"linker-{tool.name}", compiler=tool) self[linker.category].append(linker) diff --git a/source/fab/tools/versioning.py b/source/fab/tools/versioning.py index 0ed6ae96..17534c1a 100644 --- a/source/fab/tools/versioning.py +++ b/source/fab/tools/versioning.py @@ -27,7 +27,7 @@ def __init__(self, name: str, exec_name: Union[str, Path], category: Category): super().__init__(name, exec_name, category, - availablility_option="help") + availability_option="help") # ============================================================================= diff --git a/tests/unit_tests/steps/test_compile_c.py b/tests/unit_tests/steps/test_compile_c.py index 8e8c8845..06a309ba 100644 --- a/tests/unit_tests/steps/test_compile_c.py +++ b/tests/unit_tests/steps/test_compile_c.py @@ -42,20 +42,19 @@ def test_compile_c_wrong_compiler(content): config = content[0] tb = config.tool_box # Take the Fortran compiler - fc = tb[Category.FORTRAN_COMPILER] - # And set its category to C_COMPILER - fc._category = Category.C_COMPILER + cc = tb[Category.C_COMPILER] # So overwrite the C compiler with the re-categorised Fortran compiler - tb.add_tool(fc, silent_replace=True) + cc._is_available = True + tb.add_tool(cc, silent_replace=True) + cc._category = Category.FORTRAN_COMPILER # Now check that _compile_file detects the incorrect class of the # C compiler mp_common_args = mock.Mock(config=config) with pytest.raises(RuntimeError) as err: _compile_file((None, mp_common_args)) - assert ("Unexpected tool 'mock_fortran_compiler' of type '' instead of CCompiler" - in str(err.value)) + assert ("Unexpected tool 'mock_c_compiler' of category " + "'FORTRAN_COMPILER' instead of CCompiler" in str(err.value)) # This is more of an integration test than a unit test @@ -66,7 +65,6 @@ def test_vanilla(self, content): '''Ensure the command is formed correctly.''' config, _, expect_hash = content compiler = config.tool_box[Category.C_COMPILER] - print("XX", compiler, type(compiler), compiler.category) # run the step with mock.patch("fab.steps.compile_c.send_metric") as send_metric: with mock.patch('pathlib.Path.mkdir'): diff --git a/tests/unit_tests/steps/test_compile_fortran.py b/tests/unit_tests/steps/test_compile_fortran.py index c9feff49..e3a1168c 100644 --- a/tests/unit_tests/steps/test_compile_fortran.py +++ b/tests/unit_tests/steps/test_compile_fortran.py @@ -35,27 +35,24 @@ def fixture_artefact_store(analysed_files): def test_compile_cc_wrong_compiler(tool_box): '''Test if a non-C compiler is specified as c compiler. ''' - config = BuildConfig('proj', tool_box) - # Take the Fortran compiler - cc = tool_box[Category.C_COMPILER] - # And set its category to C_COMPILER - cc._category = Category.FORTRAN_COMPILER - # So overwrite the C compiler with the re-categories Fortran compiler - tool_box.add_tool(cc, silent_replace=True) - - # Now check that _compile_file detects the incorrect class of the - # C compiler + config = BuildConfig('proj', tool_box, mpi=False, openmp=False) + # Get the default Fortran compiler into the ToolBox + fc = tool_box[Category.FORTRAN_COMPILER] + # But then change its category to be a C compiler: + fc._category = Category.C_COMPILER + + # Now check that _compile_file detects the incorrect category of the + # Fortran compiler mp_common_args = mock.Mock(config=config) with pytest.raises(RuntimeError) as err: process_file((None, mp_common_args)) - assert ("Unexpected tool 'mock_c_compiler' of type '' instead of FortranCompiler" - in str(err.value)) + assert ("Unexpected tool 'mock_fortran_compiler' of category " + "'C_COMPILER' instead of FortranCompiler" in str(err.value)) + with pytest.raises(RuntimeError) as err: handle_compiler_args(config) - assert ("Unexpected tool 'mock_c_compiler' of type '' instead of FortranCompiler" - in str(err.value)) + assert ("Unexpected tool 'mock_fortran_compiler' of category " + "'C_COMPILER' instead of FortranCompiler" in str(err.value)) class TestCompilePass: diff --git a/tests/unit_tests/tools/test_compiler.py b/tests/unit_tests/tools/test_compiler.py index b9b7c808..6bfcece7 100644 --- a/tests/unit_tests/tools/test_compiler.py +++ b/tests/unit_tests/tools/test_compiler.py @@ -14,17 +14,17 @@ import pytest -from fab.tools import (Category, CCompiler, FortranCompiler, - Gcc, Gfortran, Icc, Ifort, MpiGcc, MpiGfortran, - MpiIcc, MpiIfort) +from fab.tools import (Category, CCompiler, Compiler, FortranCompiler, + Gcc, Gfortran, Icc, Ifort) def test_compiler(): '''Test the compiler constructor.''' - cc = CCompiler("gcc", "gcc", "gnu", openmp_flag="-fopenmp") + cc = Compiler("gcc", "gcc", "gnu", category=Category.C_COMPILER, openmp_flag="-fopenmp") assert cc.category == Category.C_COMPILER assert cc._compile_flag == "-c" assert cc._output_flag == "-o" + # pylint: disable-next=use-implicit-booleaness-not-comparison assert cc.flags == [] assert cc.suite == "gnu" assert not cc.mpi @@ -40,6 +40,7 @@ def test_compiler(): assert fc._output_flag == "-o" assert fc.category == Category.FORTRAN_COMPILER assert fc.suite == "gnu" + # pylint: disable-next=use-implicit-booleaness-not-comparison assert fc.flags == [] assert not fc.mpi assert fc.openmp_flag == "-fopenmp" @@ -236,6 +237,7 @@ def test_get_version_string(): '''Tests the get_version_string() method. ''' full_output = 'GNU Fortran (gcc) 6.1.0' + c = Gfortran() with mock.patch.object(c, "run", mock.Mock(return_value=full_output)): assert c.get_version_string() == "6.1.0" @@ -401,19 +403,9 @@ def test_gcc(): assert not gcc.mpi -def test_mpi_gcc(): - '''Tests the MPI enables gcc class.''' - mpi_gcc = MpiGcc() - assert mpi_gcc.name == "mpicc-gcc" - assert isinstance(mpi_gcc, CCompiler) - assert mpi_gcc.category == Category.C_COMPILER - assert mpi_gcc.mpi - - -@pytest.mark.parametrize("compiler", [Gcc, MpiGcc]) -def test_gcc_get_version(compiler): +def test_gcc_get_version(): '''Tests the gcc class get_version method.''' - gcc = compiler() + gcc = Gcc() full_output = dedent(""" gcc (GCC) 8.5.0 20210514 (Red Hat 8.5.0-20) Copyright (C) 2018 Free Software Foundation, Inc. @@ -422,10 +414,9 @@ def test_gcc_get_version(compiler): assert gcc.get_version() == (8, 5, 0) -@pytest.mark.parametrize("compiler", [Gcc, MpiGcc]) -def test_gcc_get_version_with_icc_string(compiler): +def test_gcc_get_version_with_icc_string(): '''Tests the gcc class with an icc version output.''' - gcc = compiler() + gcc = Gcc() full_output = dedent(""" icc (ICC) 2021.10.0 20230609 Copyright (C) 1985-2023 Intel Corporation. All rights reserved. @@ -447,22 +438,12 @@ def test_gfortran(): assert not gfortran.mpi -def test_mpi_gfortran(): - '''Tests the MPI enabled gfortran class.''' - mpi_gfortran = MpiGfortran() - assert mpi_gfortran.name == "mpif90-gfortran" - assert isinstance(mpi_gfortran, FortranCompiler) - assert mpi_gfortran.category == Category.FORTRAN_COMPILER - assert mpi_gfortran.mpi - - # Possibly overkill to cover so many gfortran versions but I had to go # check them so might as well add them. # Note: different sources, e.g conda, change the output slightly... -@pytest.mark.parametrize("compiler", [Gfortran, MpiGfortran]) -def test_gfortran_get_version_4(compiler): +def test_gfortran_get_version_4(): '''Test gfortran 4.8.5 version detection.''' full_output = dedent(""" GNU Fortran (GCC) 4.8.5 20150623 (Red Hat 4.8.5-44) @@ -474,13 +455,12 @@ def test_gfortran_get_version_4(compiler): For more information about these matters, see the file named COPYING """) - gfortran = compiler() + gfortran = Gfortran() with mock.patch.object(gfortran, "run", mock.Mock(return_value=full_output)): assert gfortran.get_version() == (4, 8, 5) -@pytest.mark.parametrize("compiler", [Gfortran, MpiGfortran]) -def test_gfortran_get_version_6(compiler): +def test_gfortran_get_version_6(): '''Test gfortran 6.1.0 version detection.''' full_output = dedent(""" GNU Fortran (GCC) 6.1.0 @@ -489,13 +469,12 @@ def test_gfortran_get_version_6(compiler): warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. """) - gfortran = compiler() + gfortran = Gfortran() with mock.patch.object(gfortran, "run", mock.Mock(return_value=full_output)): assert gfortran.get_version() == (6, 1, 0) -@pytest.mark.parametrize("compiler", [Gfortran, MpiGfortran]) -def test_gfortran_get_version_8(compiler): +def test_gfortran_get_version_8(): '''Test gfortran 8.5.0 version detection.''' full_output = dedent(""" GNU Fortran (conda-forge gcc 8.5.0-16) 8.5.0 @@ -504,13 +483,12 @@ def test_gfortran_get_version_8(compiler): warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. """) - gfortran = compiler() + gfortran = Gfortran() with mock.patch.object(gfortran, "run", mock.Mock(return_value=full_output)): assert gfortran.get_version() == (8, 5, 0) -@pytest.mark.parametrize("compiler", [Gfortran, MpiGfortran]) -def test_gfortran_get_version_10(compiler): +def test_gfortran_get_version_10(): '''Test gfortran 10.4.0 version detection.''' full_output = dedent(""" GNU Fortran (conda-forge gcc 10.4.0-16) 10.4.0 @@ -519,13 +497,12 @@ def test_gfortran_get_version_10(compiler): warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. """) - gfortran = compiler() + gfortran = Gfortran() with mock.patch.object(gfortran, "run", mock.Mock(return_value=full_output)): assert gfortran.get_version() == (10, 4, 0) -@pytest.mark.parametrize("compiler", [Gfortran, MpiGfortran]) -def test_gfortran_get_version_12(compiler): +def test_gfortran_get_version_12(): '''Test gfortran 12.1.0 version detection.''' full_output = dedent(""" GNU Fortran (conda-forge gcc 12.1.0-16) 12.1.0 @@ -534,20 +511,19 @@ def test_gfortran_get_version_12(compiler): warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. """) - gfortran = compiler() + gfortran = Gfortran() with mock.patch.object(gfortran, "run", mock.Mock(return_value=full_output)): assert gfortran.get_version() == (12, 1, 0) -@pytest.mark.parametrize("compiler", [Gfortran, MpiGfortran]) -def test_gfortran_get_version_with_ifort_string(compiler): +def test_gfortran_get_version_with_ifort_string(): '''Tests the gfortran class with an ifort version output.''' full_output = dedent(""" ifort (IFORT) 14.0.3 20140422 Copyright (C) 1985-2014 Intel Corporation. All rights reserved. """) - gfortran = compiler() + gfortran = Gfortran() with mock.patch.object(gfortran, "run", mock.Mock(return_value=full_output)): with pytest.raises(RuntimeError) as err: gfortran.get_version() @@ -564,36 +540,25 @@ def test_icc(): assert not icc.mpi -def test_mpi_icc(): - '''Tests the MPI enabled icc class.''' - mpi_icc = MpiIcc() - assert mpi_icc.name == "mpicc-icc" - assert isinstance(mpi_icc, CCompiler) - assert mpi_icc.category == Category.C_COMPILER - assert mpi_icc.mpi - - -@pytest.mark.parametrize("compiler", [Icc, MpiIcc]) -def test_icc_get_version(compiler): +def test_icc_get_version(): '''Tests the icc class get_version method.''' full_output = dedent(""" icc (ICC) 2021.10.0 20230609 Copyright (C) 1985-2023 Intel Corporation. All rights reserved. """) - icc = compiler() + icc = Icc() with mock.patch.object(icc, "run", mock.Mock(return_value=full_output)): assert icc.get_version() == (2021, 10, 0) -@pytest.mark.parametrize("compiler", [Icc, MpiIcc]) -def test_icc_get_version_with_gcc_string(compiler): +def test_icc_get_version_with_gcc_string(): '''Tests the icc class with a GCC version output.''' full_output = dedent(""" gcc (GCC) 8.5.0 20210514 (Red Hat 8.5.0-20) Copyright (C) 2018 Free Software Foundation, Inc. """) - icc = compiler() + icc = Icc() with mock.patch.object(icc, "run", mock.Mock(return_value=full_output)): with pytest.raises(RuntimeError) as err: icc.get_version() @@ -610,88 +575,73 @@ def test_ifort(): assert not ifort.mpi -def test_mpi_ifort(): - '''Tests the MPI enabled ifort class.''' - mpi_ifort = MpiIfort() - assert mpi_ifort.name == "mpif90-ifort" - assert isinstance(mpi_ifort, FortranCompiler) - assert mpi_ifort.category == Category.FORTRAN_COMPILER - assert mpi_ifort.mpi - - -@pytest.mark.parametrize("compiler", [Ifort, MpiIfort]) -def test_ifort_get_version_14(compiler): +def test_ifort_get_version_14(): '''Test ifort 14.0.3 version detection.''' full_output = dedent(""" ifort (IFORT) 14.0.3 20140422 Copyright (C) 1985-2014 Intel Corporation. All rights reserved. """) - ifort = compiler() + ifort = Ifort() with mock.patch.object(ifort, "run", mock.Mock(return_value=full_output)): assert ifort.get_version() == (14, 0, 3) -@pytest.mark.parametrize("compiler", [Ifort, MpiIfort]) -def test_ifort_get_version_15(compiler): +def test_ifort_get_version_15(): '''Test ifort 15.0.2 version detection.''' full_output = dedent(""" ifort (IFORT) 15.0.2 20150121 Copyright (C) 1985-2015 Intel Corporation. All rights reserved. """) - ifort = compiler() + ifort = Ifort() with mock.patch.object(ifort, "run", mock.Mock(return_value=full_output)): assert ifort.get_version() == (15, 0, 2) -@pytest.mark.parametrize("compiler", [Ifort, MpiIfort]) -def test_ifort_get_version_17(compiler): +def test_ifort_get_version_17(): '''Test ifort 17.0.7 version detection.''' full_output = dedent(""" ifort (IFORT) 17.0.7 20180403 Copyright (C) 1985-2018 Intel Corporation. All rights reserved. """) - ifort = compiler() + ifort = Ifort() with mock.patch.object(ifort, "run", mock.Mock(return_value=full_output)): assert ifort.get_version() == (17, 0, 7) -@pytest.mark.parametrize("compiler", [Ifort, MpiIfort]) -def test_ifort_get_version_19(compiler): +def test_ifort_get_version_19(): '''Test ifort 19.0.0.117 version detection.''' full_output = dedent(""" ifort (IFORT) 19.0.0.117 20180804 Copyright (C) 1985-2018 Intel Corporation. All rights reserved. """) - ifort = compiler() + ifort = Ifort() with mock.patch.object(ifort, "run", mock.Mock(return_value=full_output)): assert ifort.get_version() == (19, 0, 0, 117) -@pytest.mark.parametrize("compiler", [Ifort, MpiIfort]) -def test_ifort_get_version_with_icc_string(compiler): +def test_ifort_get_version_with_icc_string(): '''Tests the ifort class with an icc version output.''' full_output = dedent(""" icc (ICC) 2021.10.0 20230609 Copyright (C) 1985-2023 Intel Corporation. All rights reserved. """) - ifort = compiler() + ifort = Ifort() with mock.patch.object(ifort, "run", mock.Mock(return_value=full_output)): with pytest.raises(RuntimeError) as err: ifort.get_version() assert "Unexpected version output format for compiler" in str(err.value) -@pytest.mark.parametrize("compiler", [Ifort, MpiIfort]) @pytest.mark.parametrize("version", ["5.15f.2", ".0.5.1", "0.5.1.", "0.5..1"]) -def test_ifort_get_version_invalid_version(compiler, version): +def test_ifort_get_version_invalid_version(version): '''Tests the icc class with an icc version string that contains an invalid version number.''' full_output = dedent(f""" @@ -699,29 +649,8 @@ def test_ifort_get_version_invalid_version(compiler, version): Copyright (C) 1985-2023 Intel Corporation. All rights reserved. """) - icc = compiler() + icc = Ifort() with mock.patch.object(icc, "run", mock.Mock(return_value=full_output)): with pytest.raises(RuntimeError) as err: icc.get_version() assert "Unexpected version output format for compiler" in str(err.value) - - -# ============================================================================ -def test_compiler_wrapper(): - '''Make sure we can easily create a compiler wrapper.''' - class MpiF90(Ifort): - '''A simple compiler wrapper''' - def __init__(self): - super().__init__(name="mpif90-intel", - exec_name="mpif90") - - @property - def mpi(self): - return True - - mpif90 = MpiF90() - assert mpif90.suite == "intel-classic" - assert mpif90.category == Category.FORTRAN_COMPILER - assert mpif90.name == "mpif90-intel" - assert mpif90.exec_name == "mpif90" - assert mpif90.mpi diff --git a/tests/unit_tests/tools/test_compiler_wrapper.py b/tests/unit_tests/tools/test_compiler_wrapper.py new file mode 100644 index 00000000..11fdde57 --- /dev/null +++ b/tests/unit_tests/tools/test_compiler_wrapper.py @@ -0,0 +1,348 @@ +############################################################################## +# (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 +############################################################################## + +'''Tests the compiler wrapper implementation. +''' + +from pathlib import Path, PosixPath +from unittest import mock + +import pytest + +from fab.tools import (Category, CompilerWrapper, Gcc, Gfortran, Icc, Ifort, + Mpicc, Mpif90, ToolRepository) + + +def test_compiler_wrapper_compiler_getter(): + '''Tests that the compiler wrapper getter returns the + wrapper compiler instance. + ''' + gcc = Gcc() + mpicc = Mpicc(gcc) + assert mpicc.compiler is gcc + + +def test_compiler_wrapper_version_and_caching(): + '''Tests that the compiler wrapper reports the right version number + from the actual compiler. + ''' + mpicc = Mpicc(Gcc()) + + # The wrapper should report the version of the wrapped compiler: + with (mock.patch('fab.tools.compiler.Compiler.get_version', + return_value=(123,))): + assert mpicc.get_version() == (123,) + + # Test that the value is cached: + assert mpicc.get_version() == (123,) + + +def test_compiler_wrapper_version_consistency(): + '''Tests that the compiler wrapper and compiler must report the + same version number: + ''' + + # The wrapper must verify that the wrapper compiler and wrapper + # report the same version number, otherwise raise an exception. + # The first patch changes the return value which the compiler wrapper + # will report (since it calls Compiler.get_version), the second + # changes the return value of the wrapper compiler instance only: + + mpicc = Mpicc(Gcc()) + with mock.patch('fab.tools.compiler.Compiler.run_version_command', + return_value="gcc (GCC) 8.6.0 20210514 (Red Hat " + "8.5.0-20)"): + with mock.patch.object(mpicc.compiler, 'run_version_command', + return_value="gcc (GCC) 8.5.0 20210514 (Red " + "Hat 8.5.0-20)"): + with pytest.raises(RuntimeError) as err: + mpicc.get_version() + assert ("Different version for compiler 'Gcc - gcc: gcc' (8.5.0) " + "and compiler wrapper 'Mpicc(gcc)' (8.6.0)" in + str(err.value)) + + +def test_compiler_wrapper_version_compiler_unavailable(): + '''Checks the behaviour if the wrapped compiler is not available. + The wrapper should then report an empty result. + ''' + + mpicc = Mpicc(Gcc()) + with mock.patch.object(mpicc.compiler, '_is_available', False): + with pytest.raises(RuntimeError) as err: + assert mpicc.get_version() == "" + assert "Cannot get version of wrapped compiler" in str(err.value) + + +def test_compiler_is_available_ok(): + '''Check if check_available works as expected. + ''' + mpicc = Mpicc(Gcc()) + + # Just make sure we get the right object: + assert isinstance(mpicc, CompilerWrapper) + assert mpicc._is_available is None + + # Make sure that the compiler-wrapper itself reports that it is available: + # even if mpicc is not installed: + with mock.patch('fab.tools.compiler_wrapper.CompilerWrapper.' + 'check_available', return_value=True) as check_available: + assert mpicc.is_available + assert mpicc.is_available + # Due to caching there should only be one call to check_avail + check_available.assert_called_once_with() + + # Test that the value is indeed cached: + assert mpicc._is_available + + +def test_compiler_is_available_no_version(): + '''Make sure a compiler that does not return a valid version + is marked as not available. + ''' + mpicc = Mpicc(Gcc()) + # Now test if get_version raises an error + with mock.patch.object(mpicc.compiler, "get_version", + side_effect=RuntimeError("")): + assert not mpicc.is_available + + +def test_compiler_hash(): + '''Test the hash functionality.''' + mpicc = ToolRepository().get_tool(Category.C_COMPILER, + "mpicc-gcc") + with mock.patch.object(mpicc, "_version", (567,)): + hash1 = mpicc.get_hash() + assert hash1 == 4702012005 + + # A change in the version number must change the hash: + with mock.patch.object(mpicc, "_version", (89,)): + hash2 = mpicc.get_hash() + assert hash2 != hash1 + + # A change in the name with the original version number + # 567) must change the hash again: + with mock.patch.object(mpicc, "_name", "new_name"): + with mock.patch.object(mpicc, "_version", (567,)): + hash3 = mpicc.get_hash() + assert hash3 not in (hash1, hash2) + + # A change in the name with the modified version number + # must change the hash again: + with mock.patch.object(mpicc, "_name", "new_name"): + with mock.patch.object(mpicc, "_version", (89,)): + hash4 = mpicc.get_hash() + assert hash4 not in (hash1, hash2, hash3) + + +def test_compiler_wrapper_syntax_only(): + '''Tests handling of syntax only flags in wrapper. In case of testing + syntax only for a C compiler an exception must be raised.''' + mpif90 = ToolRepository().get_tool(Category.FORTRAN_COMPILER, + "mpif90-gfortran") + assert mpif90.has_syntax_only + + mpicc = ToolRepository().get_tool(Category.C_COMPILER, "mpicc-gcc") + with pytest.raises(RuntimeError) as err: + _ = mpicc.has_syntax_only + assert "'gcc' has no has_syntax_only" in str(err.value) + + +def test_compiler_wrapper_module_output(): + '''Tests handling of module output_flags in a wrapper. In case of testing + this with a C compiler, an exception must be raised.''' + mpif90 = ToolRepository().get_tool(Category.FORTRAN_COMPILER, + "mpif90-gfortran") + mpif90.set_module_output_path("/somewhere") + assert mpif90.compiler._module_output_path == "/somewhere" + + mpicc = ToolRepository().get_tool(Category.C_COMPILER, "mpicc-gcc") + with pytest.raises(RuntimeError) as err: + mpicc.set_module_output_path("/tmp") + assert "'gcc' has no 'set_module_output_path' function" in str(err.value) + + +def test_compiler_wrapper_fortran_with_add_args(): + '''Tests that additional arguments are handled as expected in + a wrapper.''' + mpif90 = ToolRepository().get_tool(Category.FORTRAN_COMPILER, + "mpif90-gfortran") + mpif90.set_module_output_path("/module_out") + with mock.patch.object(mpif90.compiler, "run", mock.MagicMock()): + with pytest.warns(UserWarning, match="Removing managed flag"): + mpif90.compile_file(Path("a.f90"), "a.o", + add_flags=["-J/b", "-O3"], openmp=False, + syntax_only=True) + # Notice that "-J/b" has been removed + mpif90.compiler.run.assert_called_with( + cwd=PosixPath('.'), additional_parameters=['-c', "-O3", + '-fsyntax-only', + '-J', '/module_out', + 'a.f90', '-o', 'a.o']) + + +def test_compiler_wrapper_fortran_with_add_args_unnecessary_openmp(): + '''Tests that additional arguments are handled as expected in + a wrapper if also the openmp flags are specified.''' + mpif90 = ToolRepository().get_tool(Category.FORTRAN_COMPILER, + "mpif90-gfortran") + mpif90.set_module_output_path("/module_out") + with mock.patch.object(mpif90.compiler, "run", mock.MagicMock()): + with pytest.warns(UserWarning, + match="explicitly provided. OpenMP should be " + "enabled in the BuildConfiguration"): + mpif90.compile_file(Path("a.f90"), "a.o", + add_flags=["-fopenmp", "-O3"], + openmp=True, syntax_only=True) + mpif90.compiler.run.assert_called_with( + cwd=PosixPath('.'), + additional_parameters=['-c', '-fopenmp', '-fopenmp', '-O3', + '-fsyntax-only', '-J', '/module_out', + 'a.f90', '-o', 'a.o']) + + +def test_compiler_wrapper_c_with_add_args(): + '''Tests that additional arguments are handled as expected in a + compiler wrapper. Also verify that requesting Fortran-specific options + like syntax-only with the C compiler raises a runtime error. + ''' + + mpicc = ToolRepository().get_tool(Category.C_COMPILER, + "mpicc-gcc") + with mock.patch.object(mpicc.compiler, "run", mock.MagicMock()): + # Normal invoke of the C compiler, make sure add_flags are + # passed through: + mpicc.compile_file(Path("a.f90"), "a.o", openmp=False, + add_flags=["-O3"]) + mpicc.compiler.run.assert_called_with( + cwd=PosixPath('.'), additional_parameters=['-c', "-O3", + 'a.f90', '-o', 'a.o']) + # Invoke C compiler with syntax-only flag (which is only supported + # by Fortran compilers), which should raise an exception. + with pytest.raises(RuntimeError) as err: + mpicc.compile_file(Path("a.f90"), "a.o", openmp=False, + add_flags=["-O3"], syntax_only=True) + assert ("Syntax-only cannot be used with compiler 'mpicc-gcc'." + in str(err.value)) + + # Check that providing the openmp flag in add_flag raises a warning: + with mock.patch.object(mpicc.compiler, "run", mock.MagicMock()): + with pytest.warns(UserWarning, + match="explicitly provided. OpenMP should be " + "enabled in the BuildConfiguration"): + mpicc.compile_file(Path("a.f90"), "a.o", + add_flags=["-fopenmp", "-O3"], + openmp=True) + mpicc.compiler.run.assert_called_with( + cwd=PosixPath('.'), + additional_parameters=['-c', '-fopenmp', '-fopenmp', '-O3', + 'a.f90', '-o', 'a.o']) + + +def test_compiler_wrapper_flags_independent(): + '''Tests that flags set in the base compiler will be accessed in the + wrapper, but not the other way round.''' + gcc = Gcc() + mpicc = Mpicc(gcc) + # pylint: disable=use-implicit-booleaness-not-comparison + assert gcc.flags == [] + assert mpicc.flags == [] + # Setting flags in gcc must become visible in the wrapper compiler: + gcc.add_flags(["-a", "-b"]) + assert gcc.flags == ["-a", "-b"] + assert mpicc.flags == ["-a", "-b"] + assert mpicc.openmp_flag == gcc.openmp_flag + + # Adding flags to the wrapper should not affect the wrapped compiler: + mpicc.add_flags(["-d", "-e"]) + assert gcc.flags == ["-a", "-b"] + # And the compiler wrapper should reports the wrapped compiler's flag + # followed by the wrapper flag (i.e. the wrapper flag can therefore + # overwrite the wrapped compiler's flags) + assert mpicc.flags == ["-a", "-b", "-d", "-e"] + + +def test_compiler_wrapper_flags_with_add_arg(): + '''Tests that flags set in the base compiler will be accessed in the + wrapper if also additional flags are specified.''' + gcc = Gcc() + mpicc = Mpicc(gcc) + gcc.add_flags(["-a", "-b"]) + mpicc.add_flags(["-d", "-e"]) + + # Check that the flags are assembled in the right order in the + # actual compiler call: first the wrapper compiler flag, then + # the wrapper flag, then additional flags + with mock.patch.object(mpicc.compiler, "run", mock.MagicMock()): + mpicc.compile_file(Path("a.f90"), "a.o", add_flags=["-f"], + openmp=True) + mpicc.compiler.run.assert_called_with( + cwd=PosixPath('.'), + additional_parameters=["-c", "-fopenmp", "-a", "-b", "-d", + "-e", "-f", "a.f90", "-o", "a.o"]) + + +def test_compiler_wrapper_flags_without_add_arg(): + '''Tests that flags set in the base compiler will be accessed in the + wrapper if no additional flags are specified.''' + gcc = Gcc() + mpicc = Mpicc(gcc) + gcc.add_flags(["-a", "-b"]) + mpicc.add_flags(["-d", "-e"]) + # Check that the flags are assembled in the right order in the + # actual compiler call: first the wrapper compiler flag, then + # the wrapper flag, then additional flags + with mock.patch.object(mpicc.compiler, "run", mock.MagicMock()): + # Test if no add_flags are specified: + mpicc.compile_file(Path("a.f90"), "a.o", openmp=True) + mpicc.compiler.run.assert_called_with( + cwd=PosixPath('.'), + additional_parameters=["-c", "-fopenmp", "-a", "-b", "-d", + "-e", "a.f90", "-o", "a.o"]) + + +def test_compiler_wrapper_mpi_gcc(): + '''Tests the MPI enables gcc class.''' + mpi_gcc = Mpicc(Gcc()) + assert mpi_gcc.name == "mpicc-gcc" + assert str(mpi_gcc) == "Mpicc(gcc)" + assert isinstance(mpi_gcc, CompilerWrapper) + assert mpi_gcc.category == Category.C_COMPILER + assert mpi_gcc.mpi + assert mpi_gcc.suite == "gnu" + + +def test_compiler_wrapper_mpi_gfortran(): + '''Tests the MPI enabled gfortran class.''' + mpi_gfortran = Mpif90(Gfortran()) + assert mpi_gfortran.name == "mpif90-gfortran" + assert str(mpi_gfortran) == "Mpif90(gfortran)" + assert isinstance(mpi_gfortran, CompilerWrapper) + assert mpi_gfortran.category == Category.FORTRAN_COMPILER + assert mpi_gfortran.mpi + assert mpi_gfortran.suite == "gnu" + + +def test_compiler_wrapper_mpi_icc(): + '''Tests the MPI enabled icc class.''' + mpi_icc = Mpicc(Icc()) + assert mpi_icc.name == "mpicc-icc" + assert str(mpi_icc) == "Mpicc(icc)" + assert isinstance(mpi_icc, CompilerWrapper) + assert mpi_icc.category == Category.C_COMPILER + assert mpi_icc.mpi + assert mpi_icc.suite == "intel-classic" + + +def test_compiler_wrapper_mpi_ifort(): + '''Tests the MPI enabled ifort class.''' + mpi_ifort = Mpif90(Ifort()) + assert mpi_ifort.name == "mpif90-ifort" + assert str(mpi_ifort) == "Mpif90(ifort)" + assert isinstance(mpi_ifort, CompilerWrapper) + assert mpi_ifort.category == Category.FORTRAN_COMPILER + assert mpi_ifort.mpi + assert mpi_ifort.suite == "intel-classic" diff --git a/tests/unit_tests/tools/test_flags.py b/tests/unit_tests/tools/test_flags.py index b51c691c..0ff97ca2 100644 --- a/tests/unit_tests/tools/test_flags.py +++ b/tests/unit_tests/tools/test_flags.py @@ -16,16 +16,30 @@ def test_flags_constructor(): '''Tests the constructor of Flags.''' f1 = Flags() assert isinstance(f1, list) + + # pylint: disable-next=use-implicit-booleaness-not-comparison assert f1 == [] f2 = Flags(["a"]) assert isinstance(f2, list) assert f2 == ["a"] +def test_flags_adding(): + '''Tests adding flags.''' + f1 = Flags() + # pylint: disable-next=use-implicit-booleaness-not-comparison + assert f1 == [] + f1.add_flags("-a") + assert f1 == ["-a"] + f1.add_flags(["-b", "-c"]) + assert f1 == ["-a", "-b", "-c"] + + def test_remove_flags(): '''Test remove_flags functionality.''' flags = Flags() flags.remove_flag("-c", False) + # pylint: disable-next=use-implicit-booleaness-not-comparison assert flags == [] all_flags = ['a.f90', '-c', '-o', 'a.o', '-fsyntax-only', "-J", "/tmp"] diff --git a/tests/unit_tests/tools/test_psyclone.py b/tests/unit_tests/tools/test_psyclone.py index ad7817c2..7efc60ec 100644 --- a/tests/unit_tests/tools/test_psyclone.py +++ b/tests/unit_tests/tools/test_psyclone.py @@ -7,6 +7,7 @@ '''Tests the PSyclone implementation. ''' +from importlib import reload from unittest import mock from fab.tools import (Category, Psyclone) @@ -121,3 +122,16 @@ def test_psyclone_process(psyclone_lfric_api): 'psy_file', '-oalg', 'alg_file', '-s', 'script_called', '-c', 'psyclone.cfg', '-d', 'root1', '-d', 'root2', 'x90_file'], capture_output=True, env=None, cwd=None, check=False) + + +def test_type_checking_import(): + '''PSyclone contains an import of TYPE_CHECKING to break a circular + dependency. In order to reach 100% coverage of PSyclone, we set + mock TYPE_CHECKING to be true and force a re-import of the module. + TODO 314: This test can be removed once #314 is fixed. + ''' + with mock.patch('typing.TYPE_CHECKING', True): + # This import will not actually re-import, since the module + # is already imported. But we need this in order to call reload: + import fab.tools.psyclone + reload(fab.tools.psyclone) diff --git a/tests/unit_tests/tools/test_tool.py b/tests/unit_tests/tools/test_tool.py index dd892831..39d18bd8 100644 --- a/tests/unit_tests/tools/test_tool.py +++ b/tests/unit_tests/tools/test_tool.py @@ -50,15 +50,21 @@ def test_tool_constructor(): assert misc.category == Category.MISC +def test_tool_chance_exec_name(): + '''Test that we can change the name of the executable. + ''' + tool = Tool("gfortran", "gfortran", Category.FORTRAN_COMPILER) + assert tool.exec_name == "gfortran" + tool.change_exec_name("start_me_instead") + assert tool.exec_name == "start_me_instead" + + def test_tool_is_available(): '''Test that is_available works as expected.''' tool = Tool("gfortran", "gfortran", Category.FORTRAN_COMPILER) with mock.patch.object(tool, "check_available", return_value=True): assert tool.is_available - # Test the getter tool._is_available = False - assert not tool.is_available - assert tool.is_compiler # Test the exception when trying to use in a non-existent tool: with pytest.raises(RuntimeError) as err: @@ -66,6 +72,22 @@ def test_tool_is_available(): assert ("Tool 'gfortran' is not available to run '['gfortran', '--ops']'" in str(err.value)) + # Test setting the option and the getter + tool = Tool("gfortran", "gfortran", Category.FORTRAN_COMPILER, + availability_option="am_i_here") + assert tool.availability_option == "am_i_here" + + +def test_tool_flags(): + '''Test that flags work as expected''' + tool = Tool("gfortran", "gfortran", Category.FORTRAN_COMPILER) + # pylint: disable-next=use-implicit-booleaness-not-comparison + assert tool.flags == [] + tool.add_flags("-a") + assert tool.flags == ["-a"] + tool.add_flags(["-b", "-c"]) + assert tool.flags == ["-a", "-b", "-c"] + class TestToolRun: '''Test the run method of Tool.'''