diff --git a/source/fab/tools/__init__.py b/source/fab/tools/__init__.py index bc7430b7..f6830d85 100644 --- a/source/fab/tools/__init__.py +++ b/source/fab/tools/__init__.py @@ -19,6 +19,7 @@ from fab.tools.psyclone import Psyclone from fab.tools.rsync import Rsync from fab.tools.preprocessor import Cpp, CppFortran, Fpp, Preprocessor +from fab.tools.shell import Shell from fab.tools.tool import Tool, CompilerSuiteTool # Order here is important to avoid a circular import from fab.tools.tool_repository import ToolRepository @@ -56,6 +57,7 @@ "Preprocessor", "Psyclone", "Rsync", + "Shell", "Subversion", "Tool", "ToolBox", diff --git a/source/fab/tools/category.py b/source/fab/tools/category.py index 6eab9b9d..a64781f1 100644 --- a/source/fab/tools/category.py +++ b/source/fab/tools/category.py @@ -25,6 +25,7 @@ class Category(Enum): SUBVERSION = auto() AR = auto() RSYNC = auto() + SHELL = auto() MISC = auto() def __str__(self): diff --git a/source/fab/tools/compiler.py b/source/fab/tools/compiler.py index 3f8c31e1..0b5618de 100644 --- a/source/fab/tools/compiler.py +++ b/source/fab/tools/compiler.py @@ -56,7 +56,7 @@ def __init__(self, name: str, compile_flag: Optional[str] = None, output_flag: Optional[str] = None, openmp_flag: Optional[str] = None, - availability_option: Optional[str] = None): + availability_option: Optional[Union[str, List[str]]] = None): super().__init__(name, exec_name, suite, category=category, availability_option=availability_option) self._version: Union[Tuple[int, ...], None] = None diff --git a/source/fab/tools/shell.py b/source/fab/tools/shell.py new file mode 100644 index 00000000..44688528 --- /dev/null +++ b/source/fab/tools/shell.py @@ -0,0 +1,44 @@ +############################################################################## +# (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 a base class for shells. This can be used to execute +other scripts. +""" + +from pathlib import Path +from typing import List, Union + +from fab.tools.category import Category +from fab.tools.tool import Tool + + +class Shell(Tool): + '''A simple wrapper that runs a shell script. There seems to be no + consistent way to simply check if a shell is working - not only support + a version command (e.g. sh and dash don't). Instead, availability + is tested by running a simple 'echo' command. + + :name: the path to the script to run. + ''' + def __init__(self, name: str): + super().__init__(name=name, exec_name=name, + availability_option=["-c", "echo hello"], + category=Category.SHELL) + + def exec(self, command: Union[str, List[Union[Path, str]]]) -> str: + '''Executes the specified command. + + :param command: the command and potential parameters to execute. + + :returns: stdout of the result. + ''' + if isinstance(command, str): + params = ["-c", command] + else: + params = ["-c"] + params.extend(command) + return super().run(additional_parameters=params, + capture_output=True) diff --git a/source/fab/tools/tool.py b/source/fab/tools/tool.py index cb8a7a06..a870c657 100644 --- a/source/fab/tools/tool.py +++ b/source/fab/tools/tool.py @@ -16,7 +16,7 @@ import logging from pathlib import Path import subprocess -from typing import Dict, List, Optional, Union +from typing import Dict, List, Optional, Sequence, Union from fab.tools.category import Category from fab.tools.flags import Flags @@ -36,7 +36,7 @@ class Tool: def __init__(self, name: str, exec_name: Union[str, Path], category: Category = Category.MISC, - availability_option: Optional[str] = None): + availability_option: Optional[Union[str, List[str]]] = None): self._logger = logging.getLogger(__name__) self._name = name self._exec_name = str(exec_name) @@ -63,7 +63,8 @@ def check_available(self) -> bool: :returns: whether the tool is working (True) or not. ''' try: - self.run(self._availability_option) + op = self._availability_option + self.run(op) except (RuntimeError, FileNotFoundError): return False return True @@ -107,7 +108,7 @@ def name(self) -> str: return self._name @property - def availability_option(self) -> str: + def availability_option(self) -> Union[str, List[str]]: ''':returns: the option to use to check if the tool is available.''' return self._availability_option @@ -139,7 +140,7 @@ def __str__(self): def run(self, additional_parameters: Optional[ - Union[str, List[Union[Path, str]]]] = None, + Union[str, Sequence[Union[Path, str]]]] = None, env: Optional[Dict[str, str]] = None, cwd: Optional[Union[Path, str]] = None, capture_output=True) -> str: @@ -210,7 +211,7 @@ class CompilerSuiteTool(Tool): ''' def __init__(self, name: str, exec_name: Union[str, Path], suite: str, category: Category, - availability_option: Optional[str] = None): + availability_option: Optional[Union[str, List[str]]] = None): super().__init__(name, exec_name, category, availability_option=availability_option) self._suite = suite diff --git a/source/fab/tools/tool_repository.py b/source/fab/tools/tool_repository.py index d699574a..0b2d2663 100644 --- a/source/fab/tools/tool_repository.py +++ b/source/fab/tools/tool_repository.py @@ -23,7 +23,7 @@ from fab.tools.versioning import Fcm, Git, Subversion from fab.tools import (Ar, Cpp, CppFortran, Craycc, Crayftn, Gcc, Gfortran, Icc, Icx, Ifort, Ifx, - Nvc, Nvfortran, Psyclone, Rsync) + Nvc, Nvfortran, Psyclone, Rsync, Shell) class ToolRepository(dict): @@ -72,6 +72,13 @@ def __init__(self): Ar, Fcm, Git, Psyclone, Rsync, Subversion]: self.add_tool(cls()) + # Add the common shells. While Fab itself does not need this, + # it is a very convenient tool for user configuration (e.g. to + # query nc-config etc) + for shell_name in ["bash", "sh", "ksh", "dash"]: + self.add_tool(Shell(shell_name)) + self.get_tool(Category.SHELL, shell_name) + # Now create the potential mpif90 and Cray ftn wrapper all_fc = self[Category.FORTRAN_COMPILER][:] for fc in all_fc: diff --git a/tests/unit_tests/tools/test_shell.py b/tests/unit_tests/tools/test_shell.py new file mode 100644 index 00000000..38ba8c71 --- /dev/null +++ b/tests/unit_tests/tools/test_shell.py @@ -0,0 +1,61 @@ +############################################################################## +# (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 shell implementation. +''' + +from unittest import mock + +from fab.tools import Category, Shell + + +def test_shell_constructor(): + '''Test the Shell constructor.''' + bash = Shell("bash") + assert bash.category == Category.SHELL + assert bash.name == "bash" + assert bash.exec_name == "bash" + + +def test_shell_check_available(): + '''Tests the is_available functionality.''' + bash = Shell("bash") + mock_result = mock.Mock(returncode=0) + with mock.patch('fab.tools.tool.subprocess.run', + return_value=mock_result) as tool_run: + assert bash.check_available() + tool_run.assert_called_once_with( + ["bash", "-c", "echo hello"], capture_output=True, env=None, + cwd=None, check=False) + + # Test behaviour if a runtime error happens: + with mock.patch("fab.tools.tool.Tool.run", + side_effect=RuntimeError("")) as tool_run: + assert not bash.check_available() + + +def test_shell_exec_single_arg(): + '''Test running a shell script without additional parameters.''' + bash = Shell("ksh") + mock_result = mock.Mock(returncode=0) + with mock.patch('fab.tools.tool.subprocess.run', + return_value=mock_result) as tool_run: + bash.exec("echo") + tool_run.assert_called_with(['ksh', '-c', 'echo'], + capture_output=True, env=None, cwd=None, + check=False) + + +def test_shell_exec_multiple_args(): + '''Test running a shell script with parameters.''' + bash = Shell("ksh") + mock_result = mock.Mock(returncode=0) + with mock.patch('fab.tools.tool.subprocess.run', + return_value=mock_result) as tool_run: + bash.exec(["some", "shell", "function"]) + tool_run.assert_called_with(['ksh', '-c', 'some', 'shell', 'function'], + capture_output=True, env=None, cwd=None, + check=False)