From 3b6e0bdca29b86ffddd12397c994a9769c9a063b Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Fri, 27 Sep 2024 01:55:23 +1000 Subject: [PATCH 01/55] Support new and old style of PSyclone command line (no more nemo api etc) --- source/fab/tools/psyclone.py | 129 +++++++++++++- .../psyclone/test_psyclone_system_test.py | 6 +- tests/unit_tests/tools/test_psyclone.py | 163 +++++++++++------- 3 files changed, 229 insertions(+), 69 deletions(-) diff --git a/source/fab/tools/psyclone.py b/source/fab/tools/psyclone.py index 1a2b3b40..1d7fa255 100644 --- a/source/fab/tools/psyclone.py +++ b/source/fab/tools/psyclone.py @@ -8,7 +8,9 @@ """ from pathlib import Path +import re from typing import Callable, List, Optional, TYPE_CHECKING, Union +import warnings from fab.tools.category import Category from fab.tools.tool import Tool @@ -24,15 +26,75 @@ class Psyclone(Tool): '''This is the base class for `PSyclone`. ''' - def __init__(self, api: Optional[str] = None): + def __init__(self): super().__init__("psyclone", "psyclone", Category.PSYCLONE) - self._api = api + self._version = None + + def check_available(self) -> bool: + '''This function determines if PSyclone is available. Additionally, + it established the version, since command line option changes + significantly from python 2.5.0 to the next release. + ''' + + # First get the version (and confirm that PSyclone is installed): + try: + # Older versions of PSyclone (2.3.1 and earlier) expect a filename + # even when --version is used, and won't produce version info + # without this. So provide a dummy file (which does not need to + # exist), and check the error for details to see if PSyclone does + # not exist, or if the error is because of the non-existing file + version_output = self.run(["--version", "does_not_exist"], + capture_output=True) + except RuntimeError as err: + # If the command is not found, the error contains the following: + if "could not be executed" in str(err): + return False + # Otherwise, psyclone likely complained about the not existing + # file. Continue and try to find version information in the output: + version_output = str(err) + + # Search for the version info: + exp = r"PSyclone version: (\d[\d.]+\d)" + print("VERSION [", version_output, "]") + matches = re.search(exp, version_output) + if not matches: + warnings.warn(f"Unexpected version information for PSyclone: " + f"'{version_output}'.") + # If we don't recognise the version number, something is wrong + return False + + # Now convert the version info to integer. The regular expression + # match guarantees that we have integer numbers now: + version = tuple(int(x) for x in matches.groups()[0].split('.')) + + if version == (2, 5, 0): + # The behaviour of PSyclone changes from 2.5.0 to the next + # release. But since head-of-trunk still reports 2.5.0, we + # need to run additional tests to see if we have the official + # 2.5.0 release, or current trunk (which already has the new + # command line options). PSyclone needs an existing file + # in order to work, so use __file__ to present this file. + # PSyclone will obviously abort since this is not a Fortran + # file, but we only need to check the error message to + # see if the domain name is incorrect (--> current trunk) + # or not (2.5.0 release) + try: + self.run(["-api", "nemo", __file__], capture_output=True) + except RuntimeError as err: + if "Unsupported PSyKAL DSL / API 'nemo' specified" in str(err): + # It is current development. Just give it a version number + # greater than 2.5.0 + version = (2, 5, 0, 1) + + self._version = version + return True def process(self, config: "BuildConfig", x90_file: Path, - psy_file: Path, - alg_file: Union[Path, str], + psy_file: Optional[Path] = None, + alg_file: Optional[Union[Path, str]] = None, + transformed_file: Optional[Path] = None, transformation_script: Optional[Callable[[Path, "BuildConfig"], Path]] = None, additional_parameters: Optional[List[str]] = None, @@ -40,29 +102,78 @@ def process(self, api: Optional[str] = None, ): # pylint: disable=too-many-arguments - '''Run PSyclone with the specified parameters. + '''Run PSyclone with the specified parameters. If PSyclone is used to + transform existing Fortran files, `api` must be None, and the output + file name is `transformed_file`. If PSyclone is using its DSL + features, api must be a valid PSyclone API, and the two output + filenames are `psy_file` and `alg_file`. :param api: the PSyclone API. :param x90_file: the input file for PSyclone :param psy_file: the output PSy-layer file. :param alg_file: the output modified algorithm file. + :param transformed_file: the output filename if PSyclone is called + as transformation tool. :param transformation_script: an optional transformation script :param additional_parameters: optional additional parameters for PSyclone :param kernel_roots: optional directories with kernels. ''' + if not self.is_available: + raise RuntimeError("PSyclone is not available.") + + if api: + # 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.") + if not alg_file: + raise RuntimeError(f"PSyclone called with api '{api}', but " + f"no alg_file is specified.") + if transformed_file: + raise RuntimeError(f"PSyclone called with api '{api}' and " + f"transformed_file.") + else: + if psy_file: + raise RuntimeError("PSyclone called without api, but " + "psy_file is specified.") + if alg_file: + raise RuntimeError("PSyclone called without api, but " + "alg_file is specified.") + if not transformed_file: + raise RuntimeError("PSyclone called without api, but " + "transformed_file it not specified.") + parameters: List[Union[str, Path]] = [] # If an api is defined in this call (or in the constructor) add it # as parameter. No API is required if PSyclone works as # transformation tool only, so calling PSyclone without api is # actually valid. + print("API", api, self._version) if api: - parameters.extend(["-api", api]) - elif self._api: - parameters.extend(["-api", self._api]) + if self._version > (2, 5, 0): + api_param = "--psykal-dsl" + # Mapping from old names to new names: + mapping = {"dynamo0.3": "lfric", + "gocean1.0": "gocean"} + else: + api_param = "-api" + # Mapping from new names to old names: + mapping = {"lfric": "dynamo0.3", + "gocean": "gocean1.0"} - parameters.extend(["-l", "all", "-opsy", psy_file, "-oalg", alg_file]) + parameters.extend([api_param, mapping.get(api, api), + "-opsy", psy_file, "-oalg", alg_file]) + else: # no api + if self._version > (2, 5, 0): + # New version: no API, parameter, but -o for output name: + parameters.extend(["-o", transformed_file]) + else: + # 2.5.0 or earlier: needs api nemo, output name is -oalg + parameters.extend(["-api", "nemo", "-opsy", transformed_file]) + parameters.extend(["-l", "all"]) if transformation_script: transformation_script_return_path = \ diff --git a/tests/system_tests/psyclone/test_psyclone_system_test.py b/tests/system_tests/psyclone/test_psyclone_system_test.py index 14b265d8..df299470 100644 --- a/tests/system_tests/psyclone/test_psyclone_system_test.py +++ b/tests/system_tests/psyclone/test_psyclone_system_test.py @@ -202,6 +202,9 @@ class TestTransformationScript: """ def test_transformation_script(self, psyclone_lfric_api): psyclone_tool = Psyclone() + psyclone_tool._version = (2, 4, 0) + psyclone_tool._is_available = True + mock_transformation_script = mock.Mock(return_value=__file__) with mock.patch('fab.tools.psyclone.Psyclone.run') as mock_run_command: mock_transformation_script.return_value = Path(__file__) @@ -219,8 +222,9 @@ def test_transformation_script(self, psyclone_lfric_api): mock_transformation_script.assert_called_once_with(Path(__file__), None) # check transformation_script is passed to psyclone command with '-s' mock_run_command.assert_called_with( - additional_parameters=['-api', psyclone_lfric_api, '-l', 'all', + additional_parameters=['-api', psyclone_lfric_api, '-opsy', Path(__file__), '-oalg', Path(__file__), + '-l', 'all', '-s', Path(__file__), __file__]) diff --git a/tests/unit_tests/tools/test_psyclone.py b/tests/unit_tests/tools/test_psyclone.py index 7efc60ec..619c0260 100644 --- a/tests/unit_tests/tools/test_psyclone.py +++ b/tests/unit_tests/tools/test_psyclone.py @@ -10,9 +10,26 @@ from importlib import reload from unittest import mock +import pytest + from fab.tools import (Category, Psyclone) +def get_mock_result(version_info: str) -> mock.Mock: + '''Returns a mock PSyclone object that will return + the specified str as version info. + + :param version_info: the simulated output of psyclone --version + The leading "PSyclone version: " will be added automatically. + ''' + # The return of subprocess run has an attribute 'stdout', + # that returns the stdout when its `decode` method is called. + # So we mock stdout, then put this mock_stdout into the mock result: + mock_stdout = mock.Mock(decode=lambda: f"PSyclone version: {version_info}") + mock_result = mock.Mock(stdout=mock_stdout, returncode=0) + return mock_result + + def test_psyclone_constructor(): '''Test the PSyclone constructor.''' psyclone = Psyclone() @@ -20,45 +37,102 @@ def test_psyclone_constructor(): assert psyclone.name == "psyclone" assert psyclone.exec_name == "psyclone" assert psyclone.flags == [] - assert psyclone._api is None - psyclone = Psyclone(api="gocean1.0") - assert psyclone.category == Category.PSYCLONE - assert psyclone.name == "psyclone" - assert psyclone.exec_name == "psyclone" - assert psyclone.flags == [] - assert psyclone._api == "gocean1.0" - -def test_psyclone_check_available(): - '''Tests the is_available functionality.''' +def test_psyclone_check_available_2_4_0(): + '''Tests the is_available functionality with version 2.4.0. + We get only one call. + ''' psyclone = Psyclone() - mock_result = mock.Mock(returncode=0) + + mock_result = get_mock_result("2.4.0") with mock.patch('fab.tools.tool.subprocess.run', return_value=mock_result) as tool_run: assert psyclone.check_available() tool_run.assert_called_once_with( - ["psyclone", "--version"], capture_output=True, env=None, - cwd=None, check=False) + ["psyclone", "--version", mock.ANY], capture_output=True, + env=None, cwd=None, check=False) + + +def test_psyclone_check_available_2_5_0(): + '''Tests the is_available functionality with PSyclone 2.5.0. + We get two calls. First version, then check if nemo API exists + ''' + psyclone = Psyclone() + + mock_result = get_mock_result("2.5.0") + with mock.patch('fab.tools.tool.subprocess.run', + return_value=mock_result) as tool_run: + assert psyclone.check_available() + tool_run.assert_any_call( + ["psyclone", "--version", mock.ANY], capture_output=True, + env=None, cwd=None, check=False) + tool_run.assert_any_call( + ["psyclone", "-api", "nemo", mock.ANY], 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: + with pytest.warns(UserWarning, + match="Unexpected version information " + "for PSyclone: ''."): + assert not psyclone.check_available() + + +def test_psyclone_check_available_after_2_5_0(): + '''Tests the is_available functionality with releases after 2.5.0. + We get two calls. First version, then check if nemo API exists + ''' + psyclone = Psyclone() + + # We detect the dummy version '2.5.0.1' if psyclone reports 2.5.0 + # but the command line option "-api nemo" is not accepted. + # So we need to return two results from our mock objects: first + # success for version 2.5.0, then a failure with an appropriate + # error message: + mock_result1 = get_mock_result("2.5.0") + mock_result2 = get_mock_result("Unsupported PSyKAL DSL / " + "API 'nemo' specified") + mock_result2.returncode = 1 + + # "Unsupported PSyKAL DSL / API 'nemo' specified" + with mock.patch('fab.tools.tool.subprocess.run', + return_value=mock_result1) as tool_run: + tool_run.side_effect = [mock_result1, mock_result2] + assert psyclone.check_available() + assert psyclone._version == (2, 5, 0, 1) + + +def test_psyclone_check_available_errors(): + '''Test various errors that can happen in check_available. + ''' + psyclone = Psyclone() + with mock.patch('fab.tools.tool.subprocess.run', + side_effect=FileNotFoundError("ERR")): assert not psyclone.check_available() + psyclone = Psyclone() + mock_result = get_mock_result("NOT_A_NUMBER.4.0") + with mock.patch('fab.tools.tool.subprocess.run', + return_value=mock_result): + with pytest.warns(UserWarning, + match="Unexpected version information for PSyclone: " + "'PSyclone version: NOT_A_NUMBER.4.0'"): + assert not psyclone.check_available() -def test_psyclone_process(psyclone_lfric_api): + +@pytest.mark.parametrize("api", ["dynamo0.3", "lfric"]) +def test_psyclone_process_api_2_4_0(api): '''Test running PSyclone.''' psyclone = Psyclone() - mock_result = mock.Mock(returncode=0) - # Create a mock function that returns a 'transformation script' - # called `script_called`: + mock_result = get_mock_result("2.4.0") transformation_function = mock.Mock(return_value="script_called") config = mock.Mock() with mock.patch('fab.tools.tool.subprocess.run', return_value=mock_result) as tool_run: psyclone.process(config=config, - api=psyclone_lfric_api, + api=api, x90_file="x90_file", psy_file="psy_file", alg_file="alg_file", @@ -66,60 +140,31 @@ def test_psyclone_process(psyclone_lfric_api): kernel_roots=["root1", "root2"], additional_parameters=["-c", "psyclone.cfg"]) tool_run.assert_called_with( - ['psyclone', '-api', psyclone_lfric_api, '-l', 'all', '-opsy', - 'psy_file', '-oalg', 'alg_file', '-s', 'script_called', '-c', + ['psyclone', '-api', 'dynamo0.3', '-opsy', 'psy_file', + '-oalg', 'alg_file', '-l', 'all', '-s', 'script_called', '-c', 'psyclone.cfg', '-d', 'root1', '-d', 'root2', 'x90_file'], capture_output=True, env=None, cwd=None, check=False) - # Don't specify an API: - with mock.patch('fab.tools.tool.subprocess.run', - return_value=mock_result) as tool_run: - psyclone.process(config=config, - x90_file="x90_file", - psy_file="psy_file", - alg_file="alg_file", - transformation_script=transformation_function, - kernel_roots=["root1", "root2"], - additional_parameters=["-c", "psyclone.cfg"]) - tool_run.assert_called_with( - ['psyclone', '-l', 'all', '-opsy', '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) - # Don't specify an API, but define an API on the PSyclone tool: - psyclone = Psyclone(api="gocean1.0") - with mock.patch('fab.tools.tool.subprocess.run', - return_value=mock_result) as tool_run: - psyclone.process(config=config, - x90_file="x90_file", - psy_file="psy_file", - alg_file="alg_file", - transformation_script=transformation_function, - kernel_roots=["root1", "root2"], - additional_parameters=["-c", "psyclone.cfg"]) - tool_run.assert_called_with( - ['psyclone', '-api', 'gocean1.0', '-l', 'all', '-opsy', '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_psyclone_process_no_api_2_4_0(): + '''Test running PSyclone.''' + psyclone = Psyclone() + mock_result = get_mock_result("2.4.0") + transformation_function = mock.Mock(return_value="script_called") + config = mock.Mock() - # Have both a default and a command line option - the latter - # must take precedence: - psyclone = Psyclone(api="gocean1.0") with mock.patch('fab.tools.tool.subprocess.run', return_value=mock_result) as tool_run: psyclone.process(config=config, + api="", x90_file="x90_file", - psy_file="psy_file", - alg_file="alg_file", - api=psyclone_lfric_api, + transformed_file="psy_file", transformation_script=transformation_function, kernel_roots=["root1", "root2"], additional_parameters=["-c", "psyclone.cfg"]) tool_run.assert_called_with( - ['psyclone', '-api', psyclone_lfric_api, '-l', 'all', '-opsy', - 'psy_file', '-oalg', 'alg_file', '-s', 'script_called', '-c', + ['psyclone', '-api', 'nemo', '-opsy', 'psy_file', '-l', 'all', + '-s', 'script_called', '-c', 'psyclone.cfg', '-d', 'root1', '-d', 'root2', 'x90_file'], capture_output=True, env=None, cwd=None, check=False) From 16d3ff5d59a6c73d0378da9345a9e8b3e89b1696 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Fri, 27 Sep 2024 02:00:29 +1000 Subject: [PATCH 02/55] Fix mypy errors. --- source/fab/tools/psyclone.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/source/fab/tools/psyclone.py b/source/fab/tools/psyclone.py index 1d7fa255..424b3907 100644 --- a/source/fab/tools/psyclone.py +++ b/source/fab/tools/psyclone.py @@ -55,7 +55,6 @@ def check_available(self) -> bool: # Search for the version info: exp = r"PSyclone version: (\d[\d.]+\d)" - print("VERSION [", version_output, "]") matches = re.search(exp, version_output) if not matches: warnings.warn(f"Unexpected version information for PSyclone: " @@ -151,7 +150,6 @@ def process(self, # as parameter. No API is required if PSyclone works as # transformation tool only, so calling PSyclone without api is # actually valid. - print("API", api, self._version) if api: if self._version > (2, 5, 0): api_param = "--psykal-dsl" @@ -163,10 +161,16 @@ def process(self, # Mapping from new names to old names: mapping = {"lfric": "dynamo0.3", "gocean": "gocean1.0"} - + # Make mypy happy - we tested above that these variables + # are defined + assert psy_file + assert alg_file parameters.extend([api_param, mapping.get(api, api), "-opsy", psy_file, "-oalg", alg_file]) else: # no api + # Make mypy happy - we tested above that transformed_file is + # specified when no api is specified. + assert transformed_file if self._version > (2, 5, 0): # New version: no API, parameter, but -o for output name: parameters.extend(["-o", transformed_file]) From 71fd1aed179fb0e2b3655db9fb0704c1b76329b2 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Mon, 30 Sep 2024 12:02:07 +1000 Subject: [PATCH 03/55] Added missing tests for calling psyclone, and converting old style to new stle arguments and vice versa. --- source/fab/tools/psyclone.py | 8 +- tests/unit_tests/tools/test_psyclone.py | 198 +++++++++++++++++++++++- 2 files changed, 196 insertions(+), 10 deletions(-) diff --git a/source/fab/tools/psyclone.py b/source/fab/tools/psyclone.py index 424b3907..fa508d7a 100644 --- a/source/fab/tools/psyclone.py +++ b/source/fab/tools/psyclone.py @@ -100,7 +100,7 @@ def process(self, kernel_roots: Optional[List[Union[str, Path]]] = None, api: Optional[str] = None, ): - # pylint: disable=too-many-arguments + # pylint: disable=too-many-arguments, too-many-branches '''Run PSyclone with the specified parameters. If PSyclone is used to transform existing Fortran files, `api` must be None, and the output file name is `transformed_file`. If PSyclone is using its DSL @@ -122,6 +122,10 @@ def process(self, if not self.is_available: raise RuntimeError("PSyclone is not available.") + # Convert the old style API nemo to be empty + if api and api.lower() == "nemo": + api = "" + if api: # API specified, we need both psy- and alg-file, but not # transformed file. @@ -143,7 +147,7 @@ def process(self, "alg_file is specified.") if not transformed_file: raise RuntimeError("PSyclone called without api, but " - "transformed_file it not specified.") + "transformed_file is not specified.") parameters: List[Union[str, Path]] = [] # If an api is defined in this call (or in the constructor) add it diff --git a/tests/unit_tests/tools/test_psyclone.py b/tests/unit_tests/tools/test_psyclone.py index 619c0260..5586c485 100644 --- a/tests/unit_tests/tools/test_psyclone.py +++ b/tests/unit_tests/tools/test_psyclone.py @@ -36,6 +36,7 @@ def test_psyclone_constructor(): assert psyclone.category == Category.PSYCLONE assert psyclone.name == "psyclone" assert psyclone.exec_name == "psyclone" + # pylint: disable=use-implicit-booleaness-not-comparison assert psyclone.flags == [] @@ -120,19 +121,82 @@ def test_psyclone_check_available_errors(): match="Unexpected version information for PSyclone: " "'PSyclone version: NOT_A_NUMBER.4.0'"): assert not psyclone.check_available() + # Also check that we can't call process if PSyclone is not available. + psyclone._is_available = False + config = mock.Mock() + with pytest.raises(RuntimeError) as err: + psyclone.process(config, "x90file") + assert "PSyclone is not available" in str(err.value) + + +def test_psyclone_processing_errors_without_api(): + '''Test all processing errors in PSyclone if no API is specified.''' + + psyclone = Psyclone() + psyclone._is_available = True + config = mock.Mock() + + # No API --> we need transformed file, but not psy or alg: + with pytest.raises(RuntimeError) as err: + psyclone.process(config, "x90file", api=None, psy_file="psy_file") + assert ("PSyclone called without api, but psy_file is specified" + in str(err.value)) + with pytest.raises(RuntimeError) as err: + psyclone.process(config, "x90file", api=None, alg_file="alg_file") + assert ("PSyclone called without api, but alg_file is specified" + in str(err.value)) + with pytest.raises(RuntimeError) as err: + psyclone.process(config, "x90file", api=None) + assert ("PSyclone called without api, but transformed_file is not " + "specified" in str(err.value)) @pytest.mark.parametrize("api", ["dynamo0.3", "lfric"]) -def test_psyclone_process_api_2_4_0(api): - '''Test running PSyclone.''' +def test_psyclone_processing_errors_with_api(api): + '''Test all processing errors in PSyclone if an API is specified.''' + psyclone = Psyclone() - mock_result = get_mock_result("2.4.0") + psyclone._is_available = True + config = mock.Mock() + + # No API --> we need transformed file, but not psy or alg: + with pytest.raises(RuntimeError) as err: + psyclone.process(config, "x90file", api=api, psy_file="psy_file") + assert (f"PSyclone called with api '{api}', but no alg_file is specified" + in str(err.value)) + with pytest.raises(RuntimeError) as err: + psyclone.process(config, "x90file", api=api, alg_file="alg_file") + assert (f"PSyclone called with api '{api}', but no psy_file is specified" + in str(err.value)) + with pytest.raises(RuntimeError) as err: + psyclone.process(config, "x90file", api=api, + psy_file="psy_file", alg_file="alg_file", + transformed_file="transformed_file") + assert (f"PSyclone called with api '{api}' and transformed_file" + in str(err.value)) + + +@pytest.mark.parametrize("version", ["2.4.0", "2.5.0"]) +@pytest.mark.parametrize("api", [("dynamo0.3", "dynamo0.3"), + ("lfric", "dynamo0.3"), + ("gocean1.0", "gocean1.0"), + ("gocean", "gocean1.0") + ]) +def test_psyclone_process_api_old_psyclone(api, version): + '''Test running 'old style' PSyclone (2.5.0 and earlier) with the old API + names (dynamo0.3 and gocean1.0). Also check that the new API names will + be accepted, but are mapped to the old style names. The 'api' parameter + contains the input api, and expected output API. + ''' + api_in, api_out = api + psyclone = Psyclone() + mock_result = get_mock_result(version) transformation_function = mock.Mock(return_value="script_called") config = mock.Mock() with mock.patch('fab.tools.tool.subprocess.run', return_value=mock_result) as tool_run: psyclone.process(config=config, - api=api, + api=api_in, x90_file="x90_file", psy_file="psy_file", alg_file="alg_file", @@ -140,16 +204,20 @@ def test_psyclone_process_api_2_4_0(api): kernel_roots=["root1", "root2"], additional_parameters=["-c", "psyclone.cfg"]) tool_run.assert_called_with( - ['psyclone', '-api', 'dynamo0.3', '-opsy', 'psy_file', + ['psyclone', '-api', api_out, '-opsy', 'psy_file', '-oalg', 'alg_file', '-l', 'all', '-s', 'script_called', '-c', 'psyclone.cfg', '-d', 'root1', '-d', 'root2', 'x90_file'], capture_output=True, env=None, cwd=None, check=False) -def test_psyclone_process_no_api_2_4_0(): - '''Test running PSyclone.''' +@pytest.mark.parametrize("version", ["2.4.0", "2.5.0"]) +def test_psyclone_process_no_api_old_psyclone(version): + '''Test running old-style PSyclone (2.5.0 and earlier) when requesting + to transform existing files by not specifying an API. We need to add + the flags `-api nemo` in this case for older PSyclone versions. + ''' psyclone = Psyclone() - mock_result = get_mock_result("2.4.0") + mock_result = get_mock_result(version) transformation_function = mock.Mock(return_value="script_called") config = mock.Mock() @@ -169,6 +237,119 @@ def test_psyclone_process_no_api_2_4_0(): capture_output=True, env=None, cwd=None, check=False) +@pytest.mark.parametrize("version", ["2.4.0", "2.5.0"]) +def test_psyclone_process_nemo_api_old_psyclone(version): + '''Test running old-style PSyclone (2.5.0 and earlier) when requesting + to transform existing files by specifying the nemo api. + ''' + + psyclone = Psyclone() + mock_result = get_mock_result(version) + transformation_function = mock.Mock(return_value="script_called") + config = mock.Mock() + + with mock.patch('fab.tools.tool.subprocess.run', + return_value=mock_result) as tool_run: + psyclone.process(config=config, + api="nemo", + x90_file="x90_file", + transformed_file="psy_file", + transformation_script=transformation_function, + kernel_roots=["root1", "root2"], + additional_parameters=["-c", "psyclone.cfg"]) + tool_run.assert_called_with( + ['psyclone', '-api', 'nemo', '-opsy', 'psy_file', '-l', 'all', + '-s', 'script_called', '-c', + 'psyclone.cfg', '-d', 'root1', '-d', 'root2', 'x90_file'], + capture_output=True, env=None, cwd=None, check=False) + + +@pytest.mark.parametrize("api", [("dynamo0.3", "lfric"), + ("lfric", "lfric"), + ("gocean1.0", "gocean"), + ("gocean", "gocean") + ]) +def test_psyclone_process_api_new__psyclone(api): + '''Test running the new PSyclone version. Since this version is not + yet released, we use the Fab internal version number 2.5.0.1 for + now. It uses new API names, and we need to check that the old style + names are converted to the new names. + ''' + api_in, api_out = api + psyclone = Psyclone() + mock_result = get_mock_result("2.5.0.1") + transformation_function = mock.Mock(return_value="script_called") + config = mock.Mock() + with mock.patch('fab.tools.tool.subprocess.run', + return_value=mock_result) as tool_run: + psyclone.process(config=config, + api=api_in, + x90_file="x90_file", + psy_file="psy_file", + alg_file="alg_file", + transformation_script=transformation_function, + kernel_roots=["root1", "root2"], + additional_parameters=["-c", "psyclone.cfg"]) + tool_run.assert_called_with( + ['psyclone', '--psykal-dsl', api_out, '-opsy', 'psy_file', + '-oalg', 'alg_file', '-l', 'all', '-s', 'script_called', '-c', + 'psyclone.cfg', '-d', 'root1', '-d', 'root2', 'x90_file'], + capture_output=True, env=None, cwd=None, check=False) + + +def test_psyclone_process_no_api_new_psyclone(): + '''Test running the new PSyclone version without an API. Since this + version is not yet released, we use the Fab internal version number + 2.5.0.1 for now. + ''' + psyclone = Psyclone() + mock_result = get_mock_result("2.5.0.1") + transformation_function = mock.Mock(return_value="script_called") + config = mock.Mock() + + with mock.patch('fab.tools.tool.subprocess.run', + return_value=mock_result) as tool_run: + psyclone.process(config=config, + api="", + x90_file="x90_file", + transformed_file="psy_file", + transformation_script=transformation_function, + kernel_roots=["root1", "root2"], + additional_parameters=["-c", "psyclone.cfg"]) + tool_run.assert_called_with( + ['psyclone', '-o', 'psy_file', '-l', 'all', + '-s', 'script_called', '-c', + 'psyclone.cfg', '-d', 'root1', '-d', 'root2', 'x90_file'], + capture_output=True, env=None, cwd=None, check=False) + + +def test_psyclone_process_nemo_api_new_psyclone(): + '''Test running PSyclone. Since this version is not yet released, we use + the Fab internal version number 2.5.0.1 for now. This tests that + backwards compatibility of using the nemo api works, i.e. '-api nemo' is + just removed. + ''' + psyclone = Psyclone() + mock_result = get_mock_result("2.5.0.1") + transformation_function = mock.Mock(return_value="script_called") + config = mock.Mock() + + with mock.patch('fab.tools.tool.subprocess.run', + return_value=mock_result) as tool_run: + psyclone.process(config=config, + api="nemo", + x90_file="x90_file", + transformed_file="psy_file", + transformation_script=transformation_function, + kernel_roots=["root1", "root2"], + additional_parameters=["-c", "psyclone.cfg"]) + tool_run.assert_called_with( + ['psyclone', '-o', 'psy_file', '-l', 'all', + '-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 @@ -178,5 +359,6 @@ def test_type_checking_import(): 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: + # pylint: disable=import-outside-toplevel import fab.tools.psyclone reload(fab.tools.psyclone) From ec4c0f6cd01dbb01f6ea42792a37e3991189137b Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Mon, 30 Sep 2024 13:52:05 +1000 Subject: [PATCH 04/55] Updated comment. --- source/fab/tools/psyclone.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/fab/tools/psyclone.py b/source/fab/tools/psyclone.py index fa508d7a..cbf12a9f 100644 --- a/source/fab/tools/psyclone.py +++ b/source/fab/tools/psyclone.py @@ -82,7 +82,8 @@ def check_available(self) -> bool: except RuntimeError as err: if "Unsupported PSyKAL DSL / API 'nemo' specified" in str(err): # It is current development. Just give it a version number - # greater than 2.5.0 + # greater than 2.5.0 for now, till the official release + # is done. version = (2, 5, 0, 1) self._version = version From b9aabf8d444447c8f086202de1b1d590593178ff Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Thu, 17 Oct 2024 23:46:18 +1300 Subject: [PATCH 05/55] Removed mixing, use a simple regex instead. --- source/fab/tools/__init__.py | 5 +- source/fab/tools/compiler.py | 133 ++++++++---------------- source/fab/tools/compiler_wrapper.py | 5 +- tests/conftest.py | 4 +- tests/unit_tests/tools/test_compiler.py | 23 ++-- tests/unit_tests/tools/test_tool_box.py | 6 +- 6 files changed, 61 insertions(+), 115 deletions(-) diff --git a/source/fab/tools/__init__.py b/source/fab/tools/__init__.py index 45eb666f..baa06c01 100644 --- a/source/fab/tools/__init__.py +++ b/source/fab/tools/__init__.py @@ -10,8 +10,7 @@ from fab.tools.ar import Ar from fab.tools.category import Category from fab.tools.compiler import (CCompiler, Compiler, FortranCompiler, Gcc, - Gfortran, GnuVersionHandling, Icc, Ifort, - IntelVersionHandling) + Gfortran, Icc, Ifort) from fab.tools.compiler_wrapper import CompilerWrapper, Mpicc, Mpif90 from fab.tools.flags import Flags from fab.tools.linker import Linker @@ -39,10 +38,8 @@ "Gcc", "Gfortran", "Git", - "GnuVersionHandling", "Icc", "Ifort", - "IntelVersionHandling", "Linker", "Mpif90", "Mpicc", diff --git a/source/fab/tools/compiler.py b/source/fab/tools/compiler.py index 6566a292..6935a3ff 100644 --- a/source/fab/tools/compiler.py +++ b/source/fab/tools/compiler.py @@ -30,6 +30,9 @@ class Compiler(CompilerSuiteTool): :param name: name of the compiler. :param exec_name: name of the executable to start. :param suite: name of the compiler suite this tool belongs to. + :param version_regex: A regular expression that allows extraction of + the version number from the version output of the compiler. The + version is taken from the first group of a match. :param category: the Category (C_COMPILER or FORTRAN_COMPILER). :param compile_flag: the compilation flag to use when only requesting compilation (not linking). @@ -46,6 +49,7 @@ class Compiler(CompilerSuiteTool): def __init__(self, name: str, exec_name: Union[str, Path], suite: str, + version_regex: str, category: Category, mpi: bool = False, compile_flag: Optional[str] = None, @@ -60,6 +64,7 @@ def __init__(self, name: str, 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()) + self._version_regex = version_regex @property def mpi(self) -> bool: @@ -149,7 +154,14 @@ def get_version(self) -> Tuple[int, ...]: # Run the compiler to get the version and parse the output # The implementations depend on vendor output = self.run_version_command() - version_string = self.parse_version_output(self.category, output) + + # Multiline is required in case that the version number is the end + # 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}") + version_string = matches.groups()[0] # Expect the version to be dot-separated integers. try: @@ -188,15 +200,6 @@ def run_version_command( raise RuntimeError(f"Error asking for version of compiler " f"'{self.name}'") from err - def parse_version_output(self, category: Category, - version_output: str) -> str: - ''' - Extract the numerical part from the version output. - Implemented in specific compilers. - ''' - raise NotImplementedError("The method `parse_version_output` must be " - "provided using a mixin.") - def get_version_string(self) -> str: """ Get a string representing the version of the given compiler. @@ -219,6 +222,8 @@ class CCompiler(Compiler): :param name: name of the compiler. :param exec_name: name of the executable to start. :param suite: name of the compiler suite. + :param version_regex: A regular expression that allows extraction of + the version number from the version output of the compiler. :param mpi: whether the compiler or linker support MPI. :param compile_flag: the compilation flag to use when only requesting compilation (not linking). @@ -229,6 +234,7 @@ class CCompiler(Compiler): # pylint: disable=too-many-arguments def __init__(self, name: str, exec_name: str, suite: str, + version_regex: str, mpi: bool = False, compile_flag: Optional[str] = None, output_flag: Optional[str] = None, @@ -236,7 +242,8 @@ def __init__(self, name: str, exec_name: str, suite: str, super().__init__(name, exec_name, suite, category=Category.C_COMPILER, mpi=mpi, compile_flag=compile_flag, output_flag=output_flag, - openmp_flag=openmp_flag) + openmp_flag=openmp_flag, + version_regex=version_regex) # ============================================================================ @@ -248,6 +255,8 @@ 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 version_regex: A regular expression that allows extraction of + the version number from the version output of the compiler. :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). @@ -262,6 +271,7 @@ class FortranCompiler(Compiler): # pylint: disable=too-many-arguments def __init__(self, name: str, exec_name: str, suite: str, + version_regex: str, mpi: bool = False, compile_flag: Optional[str] = None, output_flag: Optional[str] = None, @@ -273,7 +283,8 @@ def __init__(self, name: str, exec_name: str, suite: str, super().__init__(name=name, exec_name=exec_name, suite=suite, category=Category.FORTRAN_COMPILER, mpi=mpi, compile_flag=compile_flag, - output_flag=output_flag, openmp_flag=openmp_flag) + output_flag=output_flag, openmp_flag=openmp_flag, + version_regex=version_regex) self._module_folder_flag = (module_folder_flag if module_folder_flag else "") self._syntax_only_flag = syntax_only_flag @@ -327,45 +338,7 @@ def compile_file(self, input_file: Path, # ============================================================================ -class GnuVersionHandling(): - '''Mixin to handle version information from GNU compilers''' - - def parse_version_output(self, category: Category, - version_output: str) -> str: - ''' - Extract the numerical part from a GNU compiler's version output - - :param name: the compiler's name - :param category: the compiler's Category - :param version_output: the full version output from the compiler - :returns: the actual version as a string - - :raises RuntimeError: if the output is not in an expected format. - ''' - - # Expect the version to appear after some in parentheses, e.g. - # "GNU Fortran (...) n.n[.n, ...]" or # "gcc (...) n.n[.n, ...]" - if category is Category.FORTRAN_COMPILER: - name = "GNU Fortran" - else: - name = "gcc" - # A version number is a digit, followed by a sequence of digits and - # '.'', ending with a digit. It must then be followed by either the - # end of the string, or a space (e.g. "... 5.6 123456"). We can't use - # \b to determine the end, since then "1.2." would be matched - # excluding the dot (so it would become a valid 1.2) - exp = name + r" \(.*?\) (\d[\d\.]+\d)(?:$| )" - # Multiline is required in case that the version number is the - # end of the string, otherwise the $ would not match the end of line - matches = re.search(exp, version_output, re.MULTILINE) - if not matches: - raise RuntimeError(f"Unexpected version output format for " - f"compiler '{name}': {version_output}") - return matches.groups()[0] - - -# ============================================================================ -class Gcc(GnuVersionHandling, CCompiler): +class Gcc(CCompiler): '''Class for GNU's gcc compiler. :param name: name of this compiler. @@ -375,12 +348,18 @@ def __init__(self, name: str = "gcc", exec_name: str = "gcc", mpi: bool = False): + # A version number is a digit, followed by a sequence of digits and + # '.'', ending with a digit. It must then be followed by either the + # end of the string, or a space (e.g. "... 5.6 123456"). We can't use + # \b to determine the end, since then "1.2." would be matched + # excluding the dot (so it would become a valid 1.2) super().__init__(name, exec_name, suite="gnu", mpi=mpi, - openmp_flag="-fopenmp") + openmp_flag="-fopenmp", + version_regex=r"gcc \(.*?\) (\d[\d\.]+\d)(?:$| )") # ============================================================================ -class Gfortran(GnuVersionHandling, FortranCompiler): +class Gfortran(FortranCompiler): '''Class for GNU's gfortran compiler. :param name: name of this compiler. @@ -392,45 +371,13 @@ def __init__(self, name: str = "gfortran", super().__init__(name, exec_name, suite="gnu", openmp_flag="-fopenmp", module_folder_flag="-J", - syntax_only_flag="-fsyntax-only") + syntax_only_flag="-fsyntax-only", + version_regex=(r"GNU Fortran \(.*?\) " + r"(\d[\d\.]+\d)(?:$| )")) # ============================================================================ -class IntelVersionHandling(): - '''Mixin to handle version information from Intel compilers''' - - def parse_version_output(self, category: Category, - version_output: str) -> str: - ''' - Extract the numerical part from an Intel compiler's version output - - :param name: the compiler's name - :param version_output: the full version output from the compiler - :returns: the actual version as a string - - :raises RuntimeError: if the output is not in an expected format. - ''' - - # Expect the version to appear after some in parentheses, e.g. - # "icc (...) n.n[.n, ...]" or "ifort (...) n.n[.n, ...]" - if category == Category.C_COMPILER: - name = "icc" - else: - name = "ifort" - - # A version number is a digit, followed by a sequence of digits and - # '.'', ending with a digit. It must then be followed by a space. - exp = name + r" \(.*?\) (\d[\d\.]+\d) " - matches = re.search(exp, version_output) - - if not matches: - raise RuntimeError(f"Unexpected version output format for " - f"compiler '{name}': {version_output}") - return matches.groups()[0] - - -# ============================================================================ -class Icc(IntelVersionHandling, CCompiler): +class Icc(CCompiler): '''Class for the Intel's icc compiler. :param name: name of this compiler. @@ -439,11 +386,12 @@ class Icc(IntelVersionHandling, CCompiler): def __init__(self, name: str = "icc", exec_name: str = "icc"): super().__init__(name, exec_name, suite="intel-classic", - openmp_flag="-qopenmp") + openmp_flag="-qopenmp", + version_regex=r"icc \(ICC\) (\d[\d\.]+\d) ") # ============================================================================ -class Ifort(IntelVersionHandling, FortranCompiler): +class Ifort(FortranCompiler): '''Class for Intel's ifort compiler. :param name: name of this compiler. @@ -454,4 +402,5 @@ 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") + syntax_only_flag="-syntax-only", + version_regex=r"ifort \(IFORT\) (\d[\d\.]+\d) ") diff --git a/source/fab/tools/compiler_wrapper.py b/source/fab/tools/compiler_wrapper.py index e54f98ea..4dc24199 100644 --- a/source/fab/tools/compiler_wrapper.py +++ b/source/fab/tools/compiler_wrapper.py @@ -36,12 +36,9 @@ def __init__(self, name: str, exec_name: str, name=name, exec_name=exec_name, category=self._compiler.category, suite=self._compiler.suite, + version_regex=self._compiler._version_regex, 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})" diff --git a/tests/conftest.py b/tests/conftest.py index 559d4f3b..86de6476 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,7 +18,8 @@ @pytest.fixture(name="mock_c_compiler") def fixture_mock_c_compiler(): '''Provides a mock C-compiler.''' - mock_compiler = CCompiler("mock_c_compiler", "mock_exec", "suite") + mock_compiler = CCompiler("mock_c_compiler", "mock_exec", "suite", + version_regex="something") mock_compiler.run = mock.Mock() mock_compiler._version = (1, 2, 3) mock_compiler._name = "mock_c_compiler" @@ -32,6 +33,7 @@ def fixture_mock_fortran_compiler(): '''Provides a mock Fortran-compiler.''' mock_compiler = FortranCompiler("mock_fortran_compiler", "mock_exec", "suite", module_folder_flag="", + version_regex="something", syntax_only_flag=None, compile_flag=None, output_flag=None, openmp_flag=None) mock_compiler.run = mock.Mock() diff --git a/tests/unit_tests/tools/test_compiler.py b/tests/unit_tests/tools/test_compiler.py index ac948246..ff4ec01b 100644 --- a/tests/unit_tests/tools/test_compiler.py +++ b/tests/unit_tests/tools/test_compiler.py @@ -20,7 +20,8 @@ def test_compiler(): '''Test the compiler constructor.''' - cc = Compiler("gcc", "gcc", "gnu", category=Category.C_COMPILER, openmp_flag="-fopenmp") + cc = Compiler("gcc", "gcc", "gnu", version_regex="some_regex", + category=Category.C_COMPILER, openmp_flag="-fopenmp") assert cc.category == Category.C_COMPILER assert cc._compile_flag == "-c" assert cc._output_flag == "-o" @@ -29,13 +30,9 @@ def test_compiler(): assert cc.suite == "gnu" assert not cc.mpi assert cc.openmp_flag == "-fopenmp" - with pytest.raises(NotImplementedError) as err: - cc.parse_version_output(Category.FORTRAN_COMPILER, "NOT NEEDED") - assert ("The method `parse_version_output` must be provided using a mixin." - in str(err.value)) fc = FortranCompiler("gfortran", "gfortran", "gnu", openmp_flag="-fopenmp", - module_folder_flag="-J") + version_regex="something", module_folder_flag="-J") assert fc._compile_flag == "-c" assert fc._output_flag == "-o" assert fc.category == Category.FORTRAN_COMPILER @@ -44,10 +41,6 @@ def test_compiler(): assert fc.flags == [] assert not fc.mpi assert fc.openmp_flag == "-fopenmp" - with pytest.raises(NotImplementedError) as err: - fc.parse_version_output(Category.FORTRAN_COMPILER, "NOT NEEDED") - assert ("The method `parse_version_output` must be provided using a mixin." - in str(err.value)) def test_compiler_check_available(): @@ -121,16 +114,19 @@ def test_compiler_with_env_fflags(): def test_compiler_syntax_only(): '''Tests handling of syntax only flags.''' fc = FortranCompiler("gfortran", "gfortran", "gnu", + version_regex="something", openmp_flag="-fopenmp", module_folder_flag="-J") # Empty since no flag is defined assert not fc.has_syntax_only fc = FortranCompiler("gfortran", "gfortran", "gnu", openmp_flag="-fopenmp", - module_folder_flag="-J", syntax_only_flag=None) + version_regex="something", module_folder_flag="-J", + syntax_only_flag=None) # Empty since no flag is defined assert not fc.has_syntax_only fc = FortranCompiler("gfortran", "gfortran", "gnu", + version_regex="something", openmp_flag="-fopenmp", module_folder_flag="-J", syntax_only_flag="-fsyntax-only") @@ -141,6 +137,7 @@ def test_compiler_syntax_only(): def test_compiler_without_openmp(): '''Tests that the openmp flag is not used when openmp is not enabled. ''' fc = FortranCompiler("gfortran", "gfortran", "gnu", + version_regex="something", openmp_flag="-fopenmp", module_folder_flag="-J", syntax_only_flag="-fsyntax-only") @@ -157,6 +154,7 @@ def test_compiler_with_openmp(): '''Tests that the openmp flag is used as expected if openmp is enabled. ''' fc = FortranCompiler("gfortran", "gfortran", "gnu", + version_regex="something", openmp_flag="-fopenmp", module_folder_flag="-J", syntax_only_flag="-fsyntax-only") @@ -172,7 +170,7 @@ def test_compiler_with_openmp(): def test_compiler_module_output(): '''Tests handling of module output_flags.''' fc = FortranCompiler("gfortran", "gfortran", suite="gnu", - module_folder_flag="-J") + version_regex="something", module_folder_flag="-J") fc.set_module_output_path("/module_out") assert fc._module_output_path == "/module_out" fc.run = mock.MagicMock() @@ -185,6 +183,7 @@ def test_compiler_module_output(): def test_compiler_with_add_args(): '''Tests that additional arguments are handled as expected.''' fc = FortranCompiler("gfortran", "gfortran", suite="gnu", + version_regex="something", openmp_flag="-fopenmp", module_folder_flag="-J") fc.set_module_output_path("/module_out") diff --git a/tests/unit_tests/tools/test_tool_box.py b/tests/unit_tests/tools/test_tool_box.py index b8e2e903..965fe4f0 100644 --- a/tests/unit_tests/tools/test_tool_box.py +++ b/tests/unit_tests/tools/test_tool_box.py @@ -43,9 +43,11 @@ def test_tool_box_add_tool_replacement(): warning can be disabled.''' tb = ToolBox() - mock_compiler1 = CCompiler("mock_c_compiler1", "mock_exec1", "suite") + mock_compiler1 = CCompiler("mock_c_compiler1", "mock_exec1", "suite", + version_regex="something") mock_compiler1._is_available = True - mock_compiler2 = CCompiler("mock_c_compiler2", "mock_exec2", "suite") + mock_compiler2 = CCompiler("mock_c_compiler2", "mock_exec2", "suite", + version_regex="something") mock_compiler2._is_available = True tb.add_tool(mock_compiler1) From 8ee10e83437d77bb73d4693c1645b2e8ab8fbf08 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Fri, 18 Oct 2024 15:07:11 +1300 Subject: [PATCH 06/55] Added support for ifx/icx compiler as intel-llvm class. --- source/fab/tools/__init__.py | 4 +- source/fab/tools/compiler.py | 30 ++++++++++ tests/unit_tests/tools/test_compiler.py | 77 ++++++++++++++++++++++++- 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/source/fab/tools/__init__.py b/source/fab/tools/__init__.py index baa06c01..42357f2a 100644 --- a/source/fab/tools/__init__.py +++ b/source/fab/tools/__init__.py @@ -10,7 +10,7 @@ from fab.tools.ar import Ar from fab.tools.category import Category from fab.tools.compiler import (CCompiler, Compiler, FortranCompiler, Gcc, - Gfortran, Icc, Ifort) + Gfortran, Icc, Icx, Ifort, Ifx) from fab.tools.compiler_wrapper import CompilerWrapper, Mpicc, Mpif90 from fab.tools.flags import Flags from fab.tools.linker import Linker @@ -39,7 +39,9 @@ "Gfortran", "Git", "Icc", + "Icx", "Ifort", + "Ifx", "Linker", "Mpif90", "Mpicc", diff --git a/source/fab/tools/compiler.py b/source/fab/tools/compiler.py index 6935a3ff..587dfa1a 100644 --- a/source/fab/tools/compiler.py +++ b/source/fab/tools/compiler.py @@ -390,6 +390,20 @@ def __init__(self, name: str = "icc", exec_name: str = "icc"): version_regex=r"icc \(ICC\) (\d[\d\.]+\d) ") +# ============================================================================ +class Icx(CCompiler): + '''Class for the Intel's new llvm based icx compiler. + + :param name: name of this compiler. + :param exec_name: name of the executable. + ''' + def __init__(self, name: str = "icx", exec_name: str = "icx"): + super().__init__(name, exec_name, suite="intel-llvm", + openmp_flag="-qopenmp", + version_regex=(r"Intel\(R\) oneAPI DPC\+\+/C\+\+ " + r"Compiler (\d[\d\.]+\d) ")) + + # ============================================================================ class Ifort(FortranCompiler): '''Class for Intel's ifort compiler. @@ -404,3 +418,19 @@ def __init__(self, name: str = "ifort", exec_name: str = "ifort"): openmp_flag="-qopenmp", syntax_only_flag="-syntax-only", version_regex=r"ifort \(IFORT\) (\d[\d\.]+\d) ") + + +# ============================================================================ +class Ifx(FortranCompiler): + '''Class for Intel's new ifx compiler. + + :param name: name of this compiler. + :param exec_name: name of the executable. + ''' + + def __init__(self, name: str = "ifx", exec_name: str = "ifx"): + super().__init__(name, exec_name, suite="intel-llvm", + module_folder_flag="-module", + openmp_flag="-qopenmp", + syntax_only_flag="-syntax-only", + version_regex=r"ifx \(IFORT\) (\d[\d\.]+\d) ") diff --git a/tests/unit_tests/tools/test_compiler.py b/tests/unit_tests/tools/test_compiler.py index ff4ec01b..761825d6 100644 --- a/tests/unit_tests/tools/test_compiler.py +++ b/tests/unit_tests/tools/test_compiler.py @@ -15,7 +15,7 @@ import pytest from fab.tools import (Category, CCompiler, Compiler, FortranCompiler, - Gcc, Gfortran, Icc, Ifort) + Gcc, Gfortran, Icc, Icx, Ifort, Ifx) def test_compiler(): @@ -626,3 +626,78 @@ def test_ifort_get_version_invalid_version(version): with pytest.raises(RuntimeError) as err: icc.get_version() assert "Unexpected version output format for compiler" in str(err.value) + + +# ============================================================================ +def test_icx(): + '''Tests the icx class.''' + icx = Icx() + assert icx.name == "icx" + assert isinstance(icx, CCompiler) + assert icx.category == Category.C_COMPILER + assert not icx.mpi + + +def test_icx_get_version_2023(): + '''Test icx 2023.0.0 version detection.''' + full_output = dedent(""" +Intel(R) oneAPI DPC++/C++ Compiler 2023.0.0 (2023.0.0.20221201) +Target: x86_64-unknown-linux-gnu +Thread model: posix +InstalledDir: /opt/intel/oneapi/compiler/2023.0.0/linux/bin-llvm +Configuration file: /opt/intel/oneapi/compiler/2023.0.0/linux/bin-llvm/../bin/icx.cfg + + """) + icx = Icx() + with mock.patch.object(icx, "run", mock.Mock(return_value=full_output)): + assert icx.get_version() == (2023, 0, 0) + + +def test_icx_get_version_with_icc_string(): + '''Tests the icx class with an icc version output.''' + full_output = dedent(""" + icc (ICC) 2021.10.0 20230609 + Copyright (C) 1985-2023 Intel Corporation. All rights reserved. + + """) + icx = Icx() + with mock.patch.object(icx, "run", mock.Mock(return_value=full_output)): + with pytest.raises(RuntimeError) as err: + icx.get_version() + assert "Unexpected version output format for compiler" in str(err.value) + + +# ============================================================================ +def test_ifx(): + '''Tests the ifx class.''' + ifx = Ifx() + assert ifx.name == "ifx" + assert isinstance(ifx, FortranCompiler) + assert ifx.category == Category.FORTRAN_COMPILER + assert not ifx.mpi + + +def test_ifx_get_version_2023(): + '''Test ifx 2023.0.0 version detection.''' + full_output = dedent(""" +ifx (IFORT) 2023.0.0 20221201 +Copyright (C) 1985-2022 Intel Corporation. All rights reserved. + + """) + ifx = Ifx() + with mock.patch.object(ifx, "run", mock.Mock(return_value=full_output)): + assert ifx.get_version() == (2023, 0, 0) + + +def test_ifx_get_version_with_icc_string(): + '''Tests the ifx class with an icc version output.''' + full_output = dedent(""" + icc (ICC) 2021.10.0 20230609 + Copyright (C) 1985-2023 Intel Corporation. All rights reserved. + + """) + ifx = Ifx() + with mock.patch.object(ifx, "run", mock.Mock(return_value=full_output)): + with pytest.raises(RuntimeError) as err: + ifx.get_version() + assert "Unexpected version output format for compiler" in str(err.value) From d7b20083a19ceea025dd72fc43202dc82a971621 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Fri, 18 Oct 2024 16:40:06 +1300 Subject: [PATCH 07/55] Added support for nvidia compiler. --- source/fab/tools/__init__.py | 4 +- source/fab/tools/compiler.py | 92 ++++++++++++++--- tests/unit_tests/tools/test_compiler.py | 132 +++++++++++++++++++++--- 3 files changed, 202 insertions(+), 26 deletions(-) diff --git a/source/fab/tools/__init__.py b/source/fab/tools/__init__.py index 42357f2a..ec2a6e23 100644 --- a/source/fab/tools/__init__.py +++ b/source/fab/tools/__init__.py @@ -10,7 +10,7 @@ from fab.tools.ar import Ar from fab.tools.category import Category from fab.tools.compiler import (CCompiler, Compiler, FortranCompiler, Gcc, - Gfortran, Icc, Icx, Ifort, Ifx) + Gfortran, Icc, Icx, Ifort, Ifx, Nvc, Nvfortran) from fab.tools.compiler_wrapper import CompilerWrapper, Mpicc, Mpif90 from fab.tools.flags import Flags from fab.tools.linker import Linker @@ -45,6 +45,8 @@ "Linker", "Mpif90", "Mpicc", + "Nvc", + "Nvfortran", "Preprocessor", "Psyclone", "Rsync", diff --git a/source/fab/tools/compiler.py b/source/fab/tools/compiler.py index 587dfa1a..b1f2d74f 100644 --- a/source/fab/tools/compiler.py +++ b/source/fab/tools/compiler.py @@ -377,6 +377,8 @@ def __init__(self, name: str = "gfortran", # ============================================================================ +# intel-classic +# class Icc(CCompiler): '''Class for the Intel's icc compiler. @@ -391,6 +393,24 @@ def __init__(self, name: str = "icc", exec_name: str = "icc"): # ============================================================================ +class Ifort(FortranCompiler): + '''Class for Intel's ifort compiler. + + :param name: name of this compiler. + :param exec_name: name of the executable. + ''' + + 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", + version_regex=r"ifort \(IFORT\) (\d[\d\.]+\d) ") + + +# ============================================================================ +# intel-llvm +# class Icx(CCompiler): '''Class for the Intel's new llvm based icx compiler. @@ -405,32 +425,80 @@ def __init__(self, name: str = "icx", exec_name: str = "icx"): # ============================================================================ -class Ifort(FortranCompiler): - '''Class for Intel's ifort compiler. +class Ifx(FortranCompiler): + '''Class for Intel's new ifx compiler. :param name: name of this compiler. :param exec_name: name of the executable. ''' - def __init__(self, name: str = "ifort", exec_name: str = "ifort"): - super().__init__(name, exec_name, suite="intel-classic", + def __init__(self, name: str = "ifx", exec_name: str = "ifx"): + super().__init__(name, exec_name, suite="intel-llvm", module_folder_flag="-module", openmp_flag="-qopenmp", syntax_only_flag="-syntax-only", - version_regex=r"ifort \(IFORT\) (\d[\d\.]+\d) ") + version_regex=r"ifx \(IFORT\) (\d[\d\.]+\d) ") # ============================================================================ -class Ifx(FortranCompiler): - '''Class for Intel's new ifx compiler. +# nvidia +# +class Nvc(CCompiler): + '''Class for Nvidia's nvc compiler. Nvc has a '-' in the + version number. In order to get this, we overwrite run_version_command + and replace any '-' with a '.' :param name: name of this compiler. :param exec_name: name of the executable. ''' - def __init__(self, name: str = "ifx", exec_name: str = "ifx"): - super().__init__(name, exec_name, suite="intel-llvm", + def __init__(self, name: str = "nvc", exec_name: str = "nvc"): + super().__init__(name, exec_name, suite="nvidia", + openmp_flag="-mp", + version_regex=r"nvc (\d[\d\.-]+\d)") + + def run_version_command( + self, version_command: Optional[str] = '--version') -> str: + '''Run the compiler's command to get its version. This implementation + runs the function in the base class, and changes any '-' into a + '.' to support nvidia version numbers which have dashes, e.g. 23.5-0. + + :param version_command: The compiler argument used to get version info. + + :returns: The output from the version command, with any '-' replaced + with '.' + ''' + version_string = super().run_version_command() + return version_string.replace("-", ".") + + +# ============================================================================ +class Nvfortran(FortranCompiler): + '''Class for Nvidia's nvfortran compiler. Nvfortran has a '-' in the + version number. In order to get this, we overwrite run_version_command + and replace any '-' with a '.' + + :param name: name of this compiler. + :param exec_name: name of the executable. + ''' + + def __init__(self, name: str = "nvfortran", exec_name: str = "nvfortran"): + super().__init__(name, exec_name, suite="nvidia", module_folder_flag="-module", - openmp_flag="-qopenmp", - syntax_only_flag="-syntax-only", - version_regex=r"ifx \(IFORT\) (\d[\d\.]+\d) ") + openmp_flag="-mp", + syntax_only_flag="-Msyntax-only", + version_regex=r"nvfortran (\d[\d\.-]+\d)") + + def run_version_command( + self, version_command: Optional[str] = '--version') -> str: + '''Run the compiler's command to get its version. This implementation + runs the function in the base class, and changes any '-' into a + '.' to support nvidia version numbers which have dashes, e.g. 23.5-0. + + :param version_command: The compiler argument used to get version info. + + :returns: The output from the version command, with any '-' replaced + with '.' + ''' + version_string = super().run_version_command() + return version_string.replace("-", ".") diff --git a/tests/unit_tests/tools/test_compiler.py b/tests/unit_tests/tools/test_compiler.py index 761825d6..4f0566bd 100644 --- a/tests/unit_tests/tools/test_compiler.py +++ b/tests/unit_tests/tools/test_compiler.py @@ -15,7 +15,7 @@ import pytest from fab.tools import (Category, CCompiler, Compiler, FortranCompiler, - Gcc, Gfortran, Icc, Icx, Ifort, Ifx) + Gcc, Gfortran, Icc, Icx, Ifort, Ifx, Nvc, Nvfortran) def test_compiler(): @@ -205,6 +205,9 @@ def test_compiler_with_add_args(): openmp=True, syntax_only=True) +# ============================================================================ +# Test version number handling +# ============================================================================ def test_get_version_string(): '''Tests the get_version_string() method. ''' @@ -365,6 +368,8 @@ def test_get_version_bad_result_is_not_cached(): assert c.run.called +# ============================================================================ +# gcc # ============================================================================ def test_gcc(): '''Tests the gcc class.''' @@ -400,6 +405,8 @@ def test_gcc_get_version_with_icc_string(): assert "Unexpected version output format for compiler" in str(err.value) +# ============================================================================ +# gfortran # ============================================================================ def test_gfortran(): '''Tests the gfortran class.''' @@ -484,7 +491,8 @@ def test_gfortran_get_version_12(): """) gfortran = Gfortran() - with mock.patch.object(gfortran, "run", mock.Mock(return_value=full_output)): + with mock.patch.object(gfortran, "run", + mock.Mock(return_value=full_output)): assert gfortran.get_version() == (12, 1, 0) @@ -496,12 +504,16 @@ def test_gfortran_get_version_with_ifort_string(): """) gfortran = Gfortran() - with mock.patch.object(gfortran, "run", mock.Mock(return_value=full_output)): + with mock.patch.object(gfortran, "run", + mock.Mock(return_value=full_output)): with pytest.raises(RuntimeError) as err: gfortran.get_version() - assert "Unexpected version output format for compiler" in str(err.value) + assert ("Unexpected version output format for compiler" + in str(err.value)) +# ============================================================================ +# icc # ============================================================================ def test_icc(): '''Tests the icc class.''' @@ -534,9 +546,12 @@ def test_icc_get_version_with_gcc_string(): 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) + assert ("Unexpected version output format for compiler" + in str(err.value)) +# ============================================================================ +# ifort # ============================================================================ def test_ifort(): '''Tests the ifort class.''' @@ -606,7 +621,8 @@ def test_ifort_get_version_with_icc_string(): 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) + assert ("Unexpected version output format for compiler" + in str(err.value)) @pytest.mark.parametrize("version", ["5.15f.2", @@ -625,9 +641,12 @@ def test_ifort_get_version_invalid_version(version): 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) + assert ("Unexpected version output format for compiler" + in str(err.value)) +# ============================================================================ +# icx # ============================================================================ def test_icx(): '''Tests the icx class.''' @@ -645,7 +664,8 @@ def test_icx_get_version_2023(): Target: x86_64-unknown-linux-gnu Thread model: posix InstalledDir: /opt/intel/oneapi/compiler/2023.0.0/linux/bin-llvm -Configuration file: /opt/intel/oneapi/compiler/2023.0.0/linux/bin-llvm/../bin/icx.cfg +Configuration file: /opt/intel/oneapi/compiler/2023.0.0/linux/bin-llvm/""" + """../bin/icx.cfg """) icx = Icx() @@ -664,9 +684,12 @@ def test_icx_get_version_with_icc_string(): with mock.patch.object(icx, "run", mock.Mock(return_value=full_output)): with pytest.raises(RuntimeError) as err: icx.get_version() - assert "Unexpected version output format for compiler" in str(err.value) + assert ("Unexpected version output format for compiler" + in str(err.value)) +# ============================================================================ +# ifx # ============================================================================ def test_ifx(): '''Tests the ifx class.''' @@ -689,15 +712,98 @@ def test_ifx_get_version_2023(): assert ifx.get_version() == (2023, 0, 0) -def test_ifx_get_version_with_icc_string(): +def test_ifx_get_version_with_ifort_string(): '''Tests the ifx 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 (IFORT) 19.0.0.117 20180804 + Copyright (C) 1985-2018 Intel Corporation. All rights reserved. """) ifx = Ifx() with mock.patch.object(ifx, "run", mock.Mock(return_value=full_output)): with pytest.raises(RuntimeError) as err: ifx.get_version() - assert "Unexpected version output format for compiler" in str(err.value) + assert ("Unexpected version output format for compiler" + in str(err.value)) + + +# ============================================================================ +# nvc +# ============================================================================ +def test_nvc(): + '''Tests the nvc class.''' + nvc = Nvc() + assert nvc.name == "nvc" + assert isinstance(nvc, CCompiler) + assert nvc.category == Category.C_COMPILER + assert not nvc.mpi + + +def test_nvc_get_version_2023(): + '''Test nvc .23.5 version detection.''' + full_output = dedent(""" + +nvc 23.5-0 64-bit target on x86-64 Linux -tp icelake-server +NVIDIA Compilers and Tools +Copyright (c) 2022, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + """) + nvc = Nvc() + with mock.patch.object(nvc, "run", mock.Mock(return_value=full_output)): + assert nvc.get_version() == (23, 5, 0) + + +def test_nvc_get_version_with_icc_string(): + '''Tests the nvc class with an icc version output.''' + full_output = dedent(""" + icc (ICC) 2021.10.0 20230609 + Copyright (C) 1985-2023 Intel Corporation. All rights reserved. + + """) + nvc = Nvc() + with mock.patch.object(nvc, "run", mock.Mock(return_value=full_output)): + with pytest.raises(RuntimeError) as err: + nvc.get_version() + assert ("Unexpected version output format for compiler" + in str(err.value)) + + +# ============================================================================ +# nvfortran +# ============================================================================ +def test_nvfortran(): + '''Tests the nvfortran class.''' + nvfortran = Nvfortran() + assert nvfortran.name == "nvfortran" + assert isinstance(nvfortran, FortranCompiler) + assert nvfortran.category == Category.FORTRAN_COMPILER + assert not nvfortran.mpi + + +def test_nvfortran_get_version_2023(): + '''Test nvfortran .23.5 version detection.''' + full_output = dedent(""" + +nvfortran 23.5-0 64-bit target on x86-64 Linux -tp icelake-server +NVIDIA Compilers and Tools +Copyright (c) 2022, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + """) + nvfortran = Nvfortran() + with mock.patch.object(nvfortran, "run", + mock.Mock(return_value=full_output)): + assert nvfortran.get_version() == (23, 5, 0) + + +def test_nvfortran_get_version_with_ifort_string(): + '''Tests the nvfortran class with an icc version output.''' + full_output = dedent(""" + ifort (IFORT) 19.0.0.117 20180804 + Copyright (C) 1985-2018 Intel Corporation. All rights reserved. + + """) + nvfortran = Nvfortran() + with mock.patch.object(nvfortran, "run", + mock.Mock(return_value=full_output)): + with pytest.raises(RuntimeError) as err: + nvfortran.get_version() + assert ("Unexpected version output format for compiler" + in str(err.value)) From 9005b3b6f1c970eaa0b565f8b30a9e7d79f04753 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Fri, 18 Oct 2024 18:18:03 +1300 Subject: [PATCH 08/55] Add preliminary support for Cray compiler. --- source/fab/tools/__init__.py | 7 +- source/fab/tools/compiler.py | 50 ++++++++- tests/unit_tests/tools/test_compiler.py | 129 ++++++++++++++++++++++-- 3 files changed, 174 insertions(+), 12 deletions(-) diff --git a/source/fab/tools/__init__.py b/source/fab/tools/__init__.py index ec2a6e23..4dc59d14 100644 --- a/source/fab/tools/__init__.py +++ b/source/fab/tools/__init__.py @@ -9,8 +9,9 @@ from fab.tools.ar import Ar from fab.tools.category import Category -from fab.tools.compiler import (CCompiler, Compiler, FortranCompiler, Gcc, - Gfortran, Icc, Icx, Ifort, Ifx, Nvc, Nvfortran) +from fab.tools.compiler import (CCompiler, Compiler, Craycc, Crayftn, + FortranCompiler, Gcc, Gfortran, Icc, + Icx, Ifort, Ifx, Nvc, Nvfortran) from fab.tools.compiler_wrapper import CompilerWrapper, Mpicc, Mpif90 from fab.tools.flags import Flags from fab.tools.linker import Linker @@ -31,6 +32,8 @@ "CompilerWrapper", "Cpp", "CppFortran", + "Craycc", + "Crayftn", "Fcm", "Flags", "FortranCompiler", diff --git a/source/fab/tools/compiler.py b/source/fab/tools/compiler.py index b1f2d74f..0f10c01a 100644 --- a/source/fab/tools/compiler.py +++ b/source/fab/tools/compiler.py @@ -158,11 +158,12 @@ def get_version(self) -> Tuple[int, ...]: # Multiline is required in case that the version number is the end # of the string, otherwise the $ would not match the end of line matches = re.search(self._version_regex, output, re.MULTILINE) + print("XXX", output, matches) if not matches: raise RuntimeError(f"Unexpected version output format for " f"compiler '{self.name}': {output}") version_string = matches.groups()[0] - + print("YYY", matches.groups(), version_string) # Expect the version to be dot-separated integers. try: version = tuple(int(x) for x in version_string.split('.')) @@ -337,6 +338,8 @@ def compile_file(self, input_file: Path, add_flags=params) +# ============================================================================ +# Gnu # ============================================================================ class Gcc(CCompiler): '''Class for GNU's gcc compiler. @@ -378,7 +381,7 @@ def __init__(self, name: str = "gfortran", # ============================================================================ # intel-classic -# +# ============================================================================ class Icc(CCompiler): '''Class for the Intel's icc compiler. @@ -410,7 +413,7 @@ def __init__(self, name: str = "ifort", exec_name: str = "ifort"): # ============================================================================ # intel-llvm -# +# ============================================================================ class Icx(CCompiler): '''Class for the Intel's new llvm based icx compiler. @@ -442,7 +445,7 @@ def __init__(self, name: str = "ifx", exec_name: str = "ifx"): # ============================================================================ # nvidia -# +# ============================================================================ class Nvc(CCompiler): '''Class for Nvidia's nvc compiler. Nvc has a '-' in the version number. In order to get this, we overwrite run_version_command @@ -502,3 +505,42 @@ def run_version_command( ''' version_string = super().run_version_command() return version_string.replace("-", ".") + + +# ============================================================================ +# Cray compiler +# ============================================================================ +class Craycc(CCompiler): + '''Class for the native Cray C compiler. Cray has two different compilers. + Older ones have as version number: + Cray C : Version 8.7.0 Tue Jul 23, 2024 07:39:46 + Newer compiler (several lines, the important one): + Cray clang version 15.0.1 (66f7391d6a03cf932f321b9f6b1d8612ef5f362c) + We use the beginning ("cray c") to identify the compiler, which works for + both cray c and cray clang. Then we ignore non-numbers, to reach the + version number which is then extracted. + + :param name: name of this compiler. + :param exec_name: name of the executable. + ''' + def __init__(self, name: str = "craycc", exec_name: str = "craycc"): + super().__init__(name, exec_name, suite="cray", mpi=True, + openmp_flag="-qopenmp", + version_regex=r"Cray [Cc][^\d]* (\d[\d\.]+\d) ") + + +# ============================================================================ +class Crayftn(FortranCompiler): + '''Class for the native Cray Fortran compiler. + + :param name: name of this compiler. + :param exec_name: name of the executable. + ''' + + def __init__(self, name: str = "crayftn", exec_name: str = "crayftn"): + super().__init__(name, exec_name, suite="cray", mpi=True, + module_folder_flag="-module", + openmp_flag="-qopenmp", + syntax_only_flag="-syntax-only", + version_regex=(r"Cray Fortran : Version " + r"(\d[\d\.]+\d) ")) diff --git a/tests/unit_tests/tools/test_compiler.py b/tests/unit_tests/tools/test_compiler.py index 4f0566bd..6f3138dc 100644 --- a/tests/unit_tests/tools/test_compiler.py +++ b/tests/unit_tests/tools/test_compiler.py @@ -14,8 +14,9 @@ import pytest -from fab.tools import (Category, CCompiler, Compiler, FortranCompiler, - Gcc, Gfortran, Icc, Icx, Ifort, Ifx, Nvc, Nvfortran) +from fab.tools import (Category, CCompiler, Compiler, Craycc, Crayftn, + FortranCompiler, Gcc, Gfortran, Icc, Icx, Ifort, Ifx, + Nvc, Nvfortran) def test_compiler(): @@ -739,8 +740,8 @@ def test_nvc(): assert not nvc.mpi -def test_nvc_get_version_2023(): - '''Test nvc .23.5 version detection.''' +def test_nvc_get_version_23_5_0(): + '''Test nvc 23.5.0 version detection.''' full_output = dedent(""" nvc 23.5-0 64-bit target on x86-64 Linux -tp icelake-server @@ -779,8 +780,8 @@ def test_nvfortran(): assert not nvfortran.mpi -def test_nvfortran_get_version_2023(): - '''Test nvfortran .23.5 version detection.''' +def test_nvfortran_get_version_23_5_0(): + '''Test nvfortran 23.5 version detection.''' full_output = dedent(""" nvfortran 23.5-0 64-bit target on x86-64 Linux -tp icelake-server @@ -807,3 +808,119 @@ def test_nvfortran_get_version_with_ifort_string(): nvfortran.get_version() assert ("Unexpected version output format for compiler" in str(err.value)) + + +# ============================================================================ +# Craycc +# ============================================================================ +def test_craycc(): + '''Tests the Craycc class.''' + craycc = Craycc() + assert craycc.name == "craycc" + assert isinstance(craycc, CCompiler) + assert craycc.category == Category.C_COMPILER + assert craycc.mpi + + +def test_craycc_get_version_8_7_0(): + '''Test craycc .23.5 version detection.''' + full_output = dedent(""" +Cray C : Version 8.7.0 Tue Jul 23, 2024 07:39:46 + + """) + craycc = Craycc() + with mock.patch.object(craycc, "run", mock.Mock(return_value=full_output)): + assert craycc.get_version() == (8, 7, 0) + + +def test_craycc_get_version_2023(): + '''Test craycc .23.5 version detection.''' + full_output = dedent(""" +Cray clang version 15.0.1 (66f7391d6a03cf932f321b9f6b1d8612ef5f362c) + +Target: x86_64-unknown-linux-gnu + +Thread model: posix + +InstalledDir: /opt/cray/pe/cce/15.0.1/cce-clang/x86_64/share/../bin + +Found candidate GCC installation: /opt/gcc/10.3.0/snos/lib/gcc/x86_64-""" + """suse-linux/10.3.0 + +Selected GCC installation: /opt/gcc/10.3.0/snos/lib/gcc/x86_64-suse-""" + """linux/10.3.0 + +Candidate multilib: .;@m64 + +Selected multilib: .;@m64 + +OFFICIAL + """) + craycc = Craycc() + with mock.patch.object(craycc, "run", mock.Mock(return_value=full_output)): + assert craycc.get_version() == (15, 0, 1) + + +def test_craycc_get_version_with_icc_string(): + '''Tests the Craycc class with an icc version output.''' + full_output = dedent(""" + icc (ICC) 2021.10.0 20230609 + Copyright (C) 1985-2023 Intel Corporation. All rights reserved. + + """) + craycc = Craycc() + with mock.patch.object(craycc, "run", mock.Mock(return_value=full_output)): + with pytest.raises(RuntimeError) as err: + craycc.get_version() + assert ("Unexpected version output format for compiler" + in str(err.value)) + + +# ============================================================================ +# Crayftn +# ============================================================================ +def test_crayftn(): + '''Tests the Crayftn class.''' + crayftn = Crayftn() + assert crayftn.name == "crayftn" + assert isinstance(crayftn, FortranCompiler) + assert crayftn.category == Category.FORTRAN_COMPILER + assert crayftn.mpi + + +def test_crayftn_get_version_8_7_0(): + '''Test crayftn .23.5 version detection.''' + full_output = dedent(""" +Cray Fortran : Version 8.7.0 Tue Jul 23, 2024 07:39:25 + """) + crayftn = Crayftn() + with mock.patch.object(crayftn, "run", + mock.Mock(return_value=full_output)): + assert crayftn.get_version() == (8, 7, 0) + + +def test_crayftn_get_version_15_0_1(): + '''Test Crayftn 15.0.1 version detection.''' + full_output = dedent(""" +Cray Fortran : Version 15.0.1 Tue Jul 23, 2024 07:39:25 + """) + crayftn = Crayftn() + with mock.patch.object(crayftn, "run", + mock.Mock(return_value=full_output)): + assert crayftn.get_version() == (15, 0, 1) + + +def test_crayftn_get_version_with_ifort_string(): + '''Tests the crayftn class with an icc version output.''' + full_output = dedent(""" + ifort (IFORT) 19.0.0.117 20180804 + Copyright (C) 1985-2018 Intel Corporation. All rights reserved. + + """) + crayftn = Crayftn() + with mock.patch.object(crayftn, "run", + mock.Mock(return_value=full_output)): + with pytest.raises(RuntimeError) as err: + crayftn.get_version() + assert ("Unexpected version output format for compiler" + in str(err.value)) From 8771e8068823ce79e7d630405bd3dba4dfda2d30 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Fri, 18 Oct 2024 19:02:58 +1300 Subject: [PATCH 09/55] Added Cray compiler wrapper ftn and cc. --- source/fab/tools/__init__.py | 5 +- source/fab/tools/compiler.py | 2 - source/fab/tools/compiler_wrapper.py | 24 +++++++++ source/fab/tools/tool_repository.py | 31 ++++++++---- .../unit_tests/tools/test_compiler_wrapper.py | 49 ++++++++++++++++++- 5 files changed, 97 insertions(+), 14 deletions(-) diff --git a/source/fab/tools/__init__.py b/source/fab/tools/__init__.py index 4dc59d14..4ee807da 100644 --- a/source/fab/tools/__init__.py +++ b/source/fab/tools/__init__.py @@ -12,7 +12,8 @@ from fab.tools.compiler import (CCompiler, Compiler, Craycc, Crayftn, FortranCompiler, Gcc, Gfortran, Icc, Icx, Ifort, Ifx, Nvc, Nvfortran) -from fab.tools.compiler_wrapper import CompilerWrapper, Mpicc, Mpif90 +from fab.tools.compiler_wrapper import (CompilerWrapper, CrayCc, CrayFtn, + Mpicc, Mpif90) from fab.tools.flags import Flags from fab.tools.linker import Linker from fab.tools.psyclone import Psyclone @@ -33,7 +34,9 @@ "Cpp", "CppFortran", "Craycc", + "CrayCc", "Crayftn", + "CrayFtn", "Fcm", "Flags", "FortranCompiler", diff --git a/source/fab/tools/compiler.py b/source/fab/tools/compiler.py index 0f10c01a..84cd3ea3 100644 --- a/source/fab/tools/compiler.py +++ b/source/fab/tools/compiler.py @@ -158,12 +158,10 @@ def get_version(self) -> Tuple[int, ...]: # Multiline is required in case that the version number is the end # of the string, otherwise the $ would not match the end of line matches = re.search(self._version_regex, output, re.MULTILINE) - print("XXX", output, matches) if not matches: raise RuntimeError(f"Unexpected version output format for " f"compiler '{self.name}': {output}") version_string = matches.groups()[0] - print("YYY", matches.groups(), version_string) # Expect the version to be dot-separated integers. try: version = tuple(int(x) for x in version_string.split('.')) diff --git a/source/fab/tools/compiler_wrapper.py b/source/fab/tools/compiler_wrapper.py index 4dc24199..1b9b35ac 100644 --- a/source/fab/tools/compiler_wrapper.py +++ b/source/fab/tools/compiler_wrapper.py @@ -193,3 +193,27 @@ class Mpicc(CompilerWrapper): def __init__(self, compiler: Compiler): super().__init__(name=f"mpicc-{compiler.name}", exec_name="mpicc", compiler=compiler, mpi=True) + + +# ============================================================================ +class CrayFtn(CompilerWrapper): + '''Class for the Cray Fortran compiler wrapper. + + :param compiler: the compiler that the ftn wrapper will use. + ''' + + def __init__(self, compiler: Compiler): + super().__init__(name=f"crayftn-{compiler.name}", + exec_name="ftn", compiler=compiler, mpi=True) + + +# ============================================================================ +class CrayCc(CompilerWrapper): + '''Class for the Cray C compiler wrapper + + :param compiler: the compiler that the mpicc wrapper will use. + ''' + + def __init__(self, compiler: Compiler): + super().__init__(name=f"craycc-{compiler.name}", + exec_name="cc", compiler=compiler, mpi=True) diff --git a/source/fab/tools/tool_repository.py b/source/fab/tools/tool_repository.py index 7d3aa754..e18de0c4 100644 --- a/source/fab/tools/tool_repository.py +++ b/source/fab/tools/tool_repository.py @@ -17,8 +17,12 @@ from fab.tools.tool import Tool from fab.tools.category import Category from fab.tools.compiler import Compiler +from fab.tools.compiler_wrapper import CrayCc, CrayFtn, Mpif90, Mpicc from fab.tools.linker import Linker 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) class ToolRepository(dict): @@ -57,26 +61,35 @@ def __init__(self): # Add the FAB default tools: # TODO: sort the defaults so that they actually work (since not all - # tools FAB knows about are available). For now, disable Fpp: - # 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, Psyclone, Rsync) - - for cls in [Gcc, Icc, Gfortran, Ifort, Cpp, CppFortran, - Fcm, Git, Subversion, Ar, Psyclone, Rsync]: + # tools FAB knows about are available). For now, disable Fpp (by not + # adding it). IF someone actually uses it it can added. + for cls in [Craycc, Crayftn, + Gcc, Gfortran, + Icc, Icx, Ifort, Ifx, + Nvc, Nvfortran, + Cpp, CppFortran, + Ar, Fcm, Git, Psyclone, Rsync, Subversion]: self.add_tool(cls()) - from fab.tools.compiler_wrapper import Mpif90, Mpicc + # Now create the potential mpif90 and Cray ftn wrapper all_fc = self[Category.FORTRAN_COMPILER][:] for fc in all_fc: mpif90 = Mpif90(fc) self.add_tool(mpif90) + # I assume cray has (besides cray) only support for gfortran/ifort + if fc.name in ["gfortran", "ifort"]: + crayftn = CrayFtn(fc) + self.add_tool(crayftn) + # Now create the potential mpicc and Cray cc wrapper all_cc = self[Category.C_COMPILER][:] for cc in all_cc: mpicc = Mpicc(cc) self.add_tool(mpicc) + # I assume cray has (besides cray) only support for gfortran/ifort + if cc.name in ["gcc", "icc"]: + craycc = CrayCc(cc) + self.add_tool(craycc) def add_tool(self, tool: Tool): '''Creates an instance of the specified class and adds it diff --git a/tests/unit_tests/tools/test_compiler_wrapper.py b/tests/unit_tests/tools/test_compiler_wrapper.py index 11fdde57..42ee31ab 100644 --- a/tests/unit_tests/tools/test_compiler_wrapper.py +++ b/tests/unit_tests/tools/test_compiler_wrapper.py @@ -12,8 +12,9 @@ import pytest -from fab.tools import (Category, CompilerWrapper, Gcc, Gfortran, Icc, Ifort, - Mpicc, Mpif90, ToolRepository) +from fab.tools import (Category, CompilerWrapper, CrayCc, CrayFtn, + Gcc, Gfortran, Icc, Ifort, Mpicc, Mpif90, + ToolRepository) def test_compiler_wrapper_compiler_getter(): @@ -346,3 +347,47 @@ def test_compiler_wrapper_mpi_ifort(): assert mpi_ifort.category == Category.FORTRAN_COMPILER assert mpi_ifort.mpi assert mpi_ifort.suite == "intel-classic" + + +def test_compiler_wrapper_cray_icc(): + '''Tests the Cray wrapper for icc.''' + craycc = CrayCc(Icc()) + assert craycc.name == "craycc-icc" + assert str(craycc) == "CrayCc(icc)" + assert isinstance(craycc, CompilerWrapper) + assert craycc.category == Category.C_COMPILER + assert craycc.mpi + assert craycc.suite == "intel-classic" + + +def test_compiler_wrapper_cray_ifort(): + '''Tests the Cray wrapper for ifort.''' + crayftn = CrayFtn(Ifort()) + assert crayftn.name == "crayftn-ifort" + assert str(crayftn) == "CrayFtn(ifort)" + assert isinstance(crayftn, CompilerWrapper) + assert crayftn.category == Category.FORTRAN_COMPILER + assert crayftn.mpi + assert crayftn.suite == "intel-classic" + + +def test_compiler_wrapper_cray_gcc(): + '''Tests the Cray wrapper for gcc.''' + craycc = CrayCc(Gcc()) + assert craycc.name == "craycc-gcc" + assert str(craycc) == "CrayCc(gcc)" + assert isinstance(craycc, CompilerWrapper) + assert craycc.category == Category.C_COMPILER + assert craycc.mpi + assert craycc.suite == "gnu" + + +def test_compiler_wrapper_cray_gfortran(): + '''Tests the Cray wrapper for gfortran.''' + crayftn = CrayFtn(Gfortran()) + assert crayftn.name == "crayftn-gfortran" + assert str(crayftn) == "CrayFtn(gfortran)" + assert isinstance(crayftn, CompilerWrapper) + assert crayftn.category == Category.FORTRAN_COMPILER + assert crayftn.mpi + assert crayftn.suite == "gnu" From 01880508947b7159f627c6c75fb11948f62218ee Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Tue, 22 Oct 2024 14:06:54 +1100 Subject: [PATCH 10/55] Follow a more consistent naming scheme for crays, even though the native compiler names are longer (crayftn-cray, craycc-cray). --- source/fab/tools/compiler.py | 21 +++++++++++++-------- tests/unit_tests/tools/test_compiler.py | 4 ++-- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/source/fab/tools/compiler.py b/source/fab/tools/compiler.py index 84cd3ea3..816f4ebc 100644 --- a/source/fab/tools/compiler.py +++ b/source/fab/tools/compiler.py @@ -509,8 +509,11 @@ def run_version_command( # Cray compiler # ============================================================================ class Craycc(CCompiler): - '''Class for the native Cray C compiler. Cray has two different compilers. - Older ones have as version number: + '''Class for the native Cray C compiler. Since cc is actually a compiler + wrapper, follow the naming scheme of a compiler wrapper and call it: + craycc-cray. + + Cray has two different compilers. Older ones have as version number: Cray C : Version 8.7.0 Tue Jul 23, 2024 07:39:46 Newer compiler (several lines, the important one): Cray clang version 15.0.1 (66f7391d6a03cf932f321b9f6b1d8612ef5f362c) @@ -521,24 +524,26 @@ class Craycc(CCompiler): :param name: name of this compiler. :param exec_name: name of the executable. ''' - def __init__(self, name: str = "craycc", exec_name: str = "craycc"): + def __init__(self, name: str = "craycc-cray", exec_name: str = "cc"): super().__init__(name, exec_name, suite="cray", mpi=True, - openmp_flag="-qopenmp", + openmp_flag="-homp", version_regex=r"Cray [Cc][^\d]* (\d[\d\.]+\d) ") # ============================================================================ class Crayftn(FortranCompiler): - '''Class for the native Cray Fortran compiler. + '''Class for the native Cray Fortran compiler. Since ftn is actually a + compiler wrapper, follow the naming scheme of Cray compiler wrapper + and call it crayftn-cray. :param name: name of this compiler. :param exec_name: name of the executable. ''' - def __init__(self, name: str = "crayftn", exec_name: str = "crayftn"): + def __init__(self, name: str = "crayftn-cray", exec_name: str = "ftn"): super().__init__(name, exec_name, suite="cray", mpi=True, - module_folder_flag="-module", - openmp_flag="-qopenmp", + module_folder_flag="-J", + openmp_flag="-homp", syntax_only_flag="-syntax-only", version_regex=(r"Cray Fortran : Version " r"(\d[\d\.]+\d) ")) diff --git a/tests/unit_tests/tools/test_compiler.py b/tests/unit_tests/tools/test_compiler.py index 6f3138dc..834fccb0 100644 --- a/tests/unit_tests/tools/test_compiler.py +++ b/tests/unit_tests/tools/test_compiler.py @@ -816,7 +816,7 @@ def test_nvfortran_get_version_with_ifort_string(): def test_craycc(): '''Tests the Craycc class.''' craycc = Craycc() - assert craycc.name == "craycc" + assert craycc.name == "craycc-cray" assert isinstance(craycc, CCompiler) assert craycc.category == Category.C_COMPILER assert craycc.mpi @@ -882,7 +882,7 @@ def test_craycc_get_version_with_icc_string(): def test_crayftn(): '''Tests the Crayftn class.''' crayftn = Crayftn() - assert crayftn.name == "crayftn" + assert crayftn.name == "crayftn-cray" assert isinstance(crayftn, FortranCompiler) assert crayftn.category == Category.FORTRAN_COMPILER assert crayftn.mpi From 3c569bd249fd23a356efb4e09999f65f48bd7b3a Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Tue, 22 Oct 2024 14:17:59 +1100 Subject: [PATCH 11/55] Changed names again. --- source/fab/tools/compiler.py | 4 ++-- tests/unit_tests/tools/test_compiler.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/source/fab/tools/compiler.py b/source/fab/tools/compiler.py index 816f4ebc..e83bcd42 100644 --- a/source/fab/tools/compiler.py +++ b/source/fab/tools/compiler.py @@ -524,7 +524,7 @@ class Craycc(CCompiler): :param name: name of this compiler. :param exec_name: name of the executable. ''' - def __init__(self, name: str = "craycc-cray", exec_name: str = "cc"): + def __init__(self, name: str = "craycc-cc", exec_name: str = "cc"): super().__init__(name, exec_name, suite="cray", mpi=True, openmp_flag="-homp", version_regex=r"Cray [Cc][^\d]* (\d[\d\.]+\d) ") @@ -540,7 +540,7 @@ class Crayftn(FortranCompiler): :param exec_name: name of the executable. ''' - def __init__(self, name: str = "crayftn-cray", exec_name: str = "ftn"): + def __init__(self, name: str = "crayftn-ftn", exec_name: str = "ftn"): super().__init__(name, exec_name, suite="cray", mpi=True, module_folder_flag="-J", openmp_flag="-homp", diff --git a/tests/unit_tests/tools/test_compiler.py b/tests/unit_tests/tools/test_compiler.py index 834fccb0..5a31bbbc 100644 --- a/tests/unit_tests/tools/test_compiler.py +++ b/tests/unit_tests/tools/test_compiler.py @@ -816,7 +816,7 @@ def test_nvfortran_get_version_with_ifort_string(): def test_craycc(): '''Tests the Craycc class.''' craycc = Craycc() - assert craycc.name == "craycc-cray" + assert craycc.name == "craycc-cc" assert isinstance(craycc, CCompiler) assert craycc.category == Category.C_COMPILER assert craycc.mpi @@ -882,7 +882,7 @@ def test_craycc_get_version_with_icc_string(): def test_crayftn(): '''Tests the Crayftn class.''' crayftn = Crayftn() - assert crayftn.name == "crayftn-cray" + assert crayftn.name == "crayftn-ftn" assert isinstance(crayftn, FortranCompiler) assert crayftn.category == Category.FORTRAN_COMPILER assert crayftn.mpi From edc5fcd84f5f5fbd1dfd60278a8a2cad066a45bc Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Mon, 11 Nov 2024 11:00:26 +1100 Subject: [PATCH 12/55] Renamed cray compiler wrapper to be CrayCcWrapper and CrayFtnWrapper, to avoid confusion with Craycc. --- source/fab/tools/__init__.py | 8 +++---- source/fab/tools/compiler_wrapper.py | 10 +++++---- source/fab/tools/tool_repository.py | 12 +++++----- .../unit_tests/tools/test_compiler_wrapper.py | 22 +++++++++---------- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/source/fab/tools/__init__.py b/source/fab/tools/__init__.py index 4ee807da..bc7430b7 100644 --- a/source/fab/tools/__init__.py +++ b/source/fab/tools/__init__.py @@ -12,8 +12,8 @@ from fab.tools.compiler import (CCompiler, Compiler, Craycc, Crayftn, FortranCompiler, Gcc, Gfortran, Icc, Icx, Ifort, Ifx, Nvc, Nvfortran) -from fab.tools.compiler_wrapper import (CompilerWrapper, CrayCc, CrayFtn, - Mpicc, Mpif90) +from fab.tools.compiler_wrapper import (CompilerWrapper, CrayCcWrapper, + CrayFtnWrapper, Mpicc, Mpif90) from fab.tools.flags import Flags from fab.tools.linker import Linker from fab.tools.psyclone import Psyclone @@ -34,9 +34,9 @@ "Cpp", "CppFortran", "Craycc", - "CrayCc", + "CrayCcWrapper", "Crayftn", - "CrayFtn", + "CrayFtnWrapper", "Fcm", "Flags", "FortranCompiler", diff --git a/source/fab/tools/compiler_wrapper.py b/source/fab/tools/compiler_wrapper.py index 1b9b35ac..b046a541 100644 --- a/source/fab/tools/compiler_wrapper.py +++ b/source/fab/tools/compiler_wrapper.py @@ -196,8 +196,9 @@ def __init__(self, compiler: Compiler): # ============================================================================ -class CrayFtn(CompilerWrapper): - '''Class for the Cray Fortran compiler wrapper. +class CrayFtnWrapper(CompilerWrapper): + '''Class for the Cray Fortran compiler wrapper. We add 'wrapper' to the + class name to make this class distinct from the Crayftn compiler class. :param compiler: the compiler that the ftn wrapper will use. ''' @@ -208,8 +209,9 @@ def __init__(self, compiler: Compiler): # ============================================================================ -class CrayCc(CompilerWrapper): - '''Class for the Cray C compiler wrapper +class CrayCcWrapper(CompilerWrapper): + '''Class for the Cray C compiler wrapper. We add 'wrapper' to the class + name to make this class distinct from the Craycc compiler class :param compiler: the compiler that the mpicc wrapper will use. ''' diff --git a/source/fab/tools/tool_repository.py b/source/fab/tools/tool_repository.py index e18de0c4..5dc36300 100644 --- a/source/fab/tools/tool_repository.py +++ b/source/fab/tools/tool_repository.py @@ -17,7 +17,8 @@ from fab.tools.tool import Tool from fab.tools.category import Category from fab.tools.compiler import Compiler -from fab.tools.compiler_wrapper import CrayCc, CrayFtn, Mpif90, Mpicc +from fab.tools.compiler_wrapper import (CrayCcWrapper, CrayFtnWrapper, + Mpif90, Mpicc) from fab.tools.linker import Linker from fab.tools.versioning import Fcm, Git, Subversion from fab.tools import (Ar, Cpp, CppFortran, Craycc, Crayftn, @@ -76,9 +77,10 @@ def __init__(self): for fc in all_fc: mpif90 = Mpif90(fc) self.add_tool(mpif90) - # I assume cray has (besides cray) only support for gfortran/ifort + # I assume cray has (besides cray) only support for Intel and GNU if fc.name in ["gfortran", "ifort"]: - crayftn = CrayFtn(fc) + crayftn = CrayFtnWrapper(fc) + print("NEW NAME", crayftn, crayftn.name) self.add_tool(crayftn) # Now create the potential mpicc and Cray cc wrapper @@ -86,9 +88,9 @@ def __init__(self): for cc in all_cc: mpicc = Mpicc(cc) self.add_tool(mpicc) - # I assume cray has (besides cray) only support for gfortran/ifort + # I assume cray has (besides cray) only support for Intel and GNU if cc.name in ["gcc", "icc"]: - craycc = CrayCc(cc) + craycc = CrayCcWrapper(cc) self.add_tool(craycc) def add_tool(self, tool: Tool): diff --git a/tests/unit_tests/tools/test_compiler_wrapper.py b/tests/unit_tests/tools/test_compiler_wrapper.py index 42ee31ab..07f9a08b 100644 --- a/tests/unit_tests/tools/test_compiler_wrapper.py +++ b/tests/unit_tests/tools/test_compiler_wrapper.py @@ -12,9 +12,9 @@ import pytest -from fab.tools import (Category, CompilerWrapper, CrayCc, CrayFtn, - Gcc, Gfortran, Icc, Ifort, Mpicc, Mpif90, - ToolRepository) +from fab.tools import (Category, CompilerWrapper, CrayCcWrapper, + CrayFtnWrapper, Gcc, Gfortran, Icc, Ifort, + Mpicc, Mpif90, ToolRepository) def test_compiler_wrapper_compiler_getter(): @@ -351,9 +351,9 @@ def test_compiler_wrapper_mpi_ifort(): def test_compiler_wrapper_cray_icc(): '''Tests the Cray wrapper for icc.''' - craycc = CrayCc(Icc()) + craycc = CrayCcWrapper(Icc()) assert craycc.name == "craycc-icc" - assert str(craycc) == "CrayCc(icc)" + assert str(craycc) == "CrayCcWrapper(icc)" assert isinstance(craycc, CompilerWrapper) assert craycc.category == Category.C_COMPILER assert craycc.mpi @@ -362,9 +362,9 @@ def test_compiler_wrapper_cray_icc(): def test_compiler_wrapper_cray_ifort(): '''Tests the Cray wrapper for ifort.''' - crayftn = CrayFtn(Ifort()) + crayftn = CrayFtnWrapper(Ifort()) assert crayftn.name == "crayftn-ifort" - assert str(crayftn) == "CrayFtn(ifort)" + assert str(crayftn) == "CrayFtnWrapper(ifort)" assert isinstance(crayftn, CompilerWrapper) assert crayftn.category == Category.FORTRAN_COMPILER assert crayftn.mpi @@ -373,9 +373,9 @@ def test_compiler_wrapper_cray_ifort(): def test_compiler_wrapper_cray_gcc(): '''Tests the Cray wrapper for gcc.''' - craycc = CrayCc(Gcc()) + craycc = CrayCcWrapper(Gcc()) assert craycc.name == "craycc-gcc" - assert str(craycc) == "CrayCc(gcc)" + assert str(craycc) == "CrayCcWrapper(gcc)" assert isinstance(craycc, CompilerWrapper) assert craycc.category == Category.C_COMPILER assert craycc.mpi @@ -384,9 +384,9 @@ def test_compiler_wrapper_cray_gcc(): def test_compiler_wrapper_cray_gfortran(): '''Tests the Cray wrapper for gfortran.''' - crayftn = CrayFtn(Gfortran()) + crayftn = CrayFtnWrapper(Gfortran()) assert crayftn.name == "crayftn-gfortran" - assert str(crayftn) == "CrayFtn(gfortran)" + assert str(crayftn) == "CrayFtnWrapper(gfortran)" assert isinstance(crayftn, CompilerWrapper) assert crayftn.category == Category.FORTRAN_COMPILER assert crayftn.mpi From f6a70c8145e7e88e13d125f7a18de5e9f272a8f2 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Mon, 11 Nov 2024 12:47:25 +1100 Subject: [PATCH 13/55] Fixed incorrect name in comments. --- source/fab/tools/compiler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/fab/tools/compiler.py b/source/fab/tools/compiler.py index e83bcd42..a1d3a65b 100644 --- a/source/fab/tools/compiler.py +++ b/source/fab/tools/compiler.py @@ -511,7 +511,7 @@ def run_version_command( class Craycc(CCompiler): '''Class for the native Cray C compiler. Since cc is actually a compiler wrapper, follow the naming scheme of a compiler wrapper and call it: - craycc-cray. + craycc-cc. Cray has two different compilers. Older ones have as version number: Cray C : Version 8.7.0 Tue Jul 23, 2024 07:39:46 @@ -534,7 +534,7 @@ def __init__(self, name: str = "craycc-cc", exec_name: str = "cc"): class Crayftn(FortranCompiler): '''Class for the native Cray Fortran compiler. Since ftn is actually a compiler wrapper, follow the naming scheme of Cray compiler wrapper - and call it crayftn-cray. + and call it crayftn-ftn. :param name: name of this compiler. :param exec_name: name of the executable. From 7a2eb5937fa2dd61278d274c5c26433aff07ce02 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Tue, 12 Nov 2024 21:36:07 +1100 Subject: [PATCH 14/55] Additional compilers (#349) * 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. * Add simple getter for linker library flags * Add getter for linker flags by library * Fix formatting * Add optional libs argument to link function * Reorder and clean up linker tests * Make sure `Linker.link()` raises for unknown lib * Add missing type * Fix typing error * Add 'libs' argument to link_exe function * Try to add documentation for the linker libs feature * Use correct list type in link_exe hint * Add silent replace option to linker.add_lib_flags * Fixed spelling mistake in option. * Clarified documentation. * Removed unnecessary functions in CompilerWrapper. * Fixed failing test triggered by executing them in specific order (tools then steps) * Fixed line lengths. * Add tests for linker LDFLAG * Add pre- and post- lib flags to link function * Fix syntax in built-in lib flags * Remove netcdf as a built-in linker library Bash-style substitution is not currently handled * Configure pre- and post-lib flags on the Linker object Previously they were passed into the Linker.link() function * Use more realistic linker lib flags * Formatting fix * Removed mixing, use a simple regex instead. * Added support for ifx/icx compiler as intel-llvm class. * Added support for nvidia compiler. * Add preliminary support for Cray compiler. * Added Cray compiler wrapper ftn and cc. * Made mpi and openmp default to False in the BuildConfig constructor. * Removed white space. * Follow a more consistent naming scheme for crays, even though the native compiler names are longer (crayftn-cray, craycc-cray). * Changed names again. * Support compilers that do not support OpenMP. * Added documentation for openmp parameter. * Renamed cray compiler wrapper to be CrayCcWrapper and CrayFtnWrapper, to avoid confusion with Craycc. * Fixed incorrect name in comments. --------- 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> --- source/fab/tools/__init__.py | 19 +- source/fab/tools/compiler.py | 272 +++++++++----- source/fab/tools/compiler_wrapper.py | 31 +- source/fab/tools/tool_repository.py | 33 +- tests/conftest.py | 4 +- .../unit_tests/steps/test_archive_objects.py | 1 - tests/unit_tests/tools/test_compiler.py | 350 ++++++++++++++++-- .../unit_tests/tools/test_compiler_wrapper.py | 47 ++- tests/unit_tests/tools/test_tool_box.py | 6 +- .../unit_tests/tools/test_tool_repository.py | 2 +- 10 files changed, 624 insertions(+), 141 deletions(-) diff --git a/source/fab/tools/__init__.py b/source/fab/tools/__init__.py index 45eb666f..bc7430b7 100644 --- a/source/fab/tools/__init__.py +++ b/source/fab/tools/__init__.py @@ -9,10 +9,11 @@ from fab.tools.ar import Ar from fab.tools.category import Category -from fab.tools.compiler import (CCompiler, Compiler, FortranCompiler, Gcc, - Gfortran, GnuVersionHandling, Icc, Ifort, - IntelVersionHandling) -from fab.tools.compiler_wrapper import CompilerWrapper, Mpicc, Mpif90 +from fab.tools.compiler import (CCompiler, Compiler, Craycc, Crayftn, + FortranCompiler, Gcc, Gfortran, Icc, + Icx, Ifort, Ifx, Nvc, Nvfortran) +from fab.tools.compiler_wrapper import (CompilerWrapper, CrayCcWrapper, + CrayFtnWrapper, Mpicc, Mpif90) from fab.tools.flags import Flags from fab.tools.linker import Linker from fab.tools.psyclone import Psyclone @@ -32,6 +33,10 @@ "CompilerWrapper", "Cpp", "CppFortran", + "Craycc", + "CrayCcWrapper", + "Crayftn", + "CrayFtnWrapper", "Fcm", "Flags", "FortranCompiler", @@ -39,13 +44,15 @@ "Gcc", "Gfortran", "Git", - "GnuVersionHandling", "Icc", + "Icx", "Ifort", - "IntelVersionHandling", + "Ifx", "Linker", "Mpif90", "Mpicc", + "Nvc", + "Nvfortran", "Preprocessor", "Psyclone", "Rsync", diff --git a/source/fab/tools/compiler.py b/source/fab/tools/compiler.py index e1f87271..3f8c31e1 100644 --- a/source/fab/tools/compiler.py +++ b/source/fab/tools/compiler.py @@ -30,6 +30,9 @@ class Compiler(CompilerSuiteTool): :param name: name of the compiler. :param exec_name: name of the executable to start. :param suite: name of the compiler suite this tool belongs to. + :param version_regex: A regular expression that allows extraction of + the version number from the version output of the compiler. The + version is taken from the first group of a match. :param category: the Category (C_COMPILER or FORTRAN_COMPILER). :param mpi: whether the compiler or linker support MPI. :param compile_flag: the compilation flag to use when only requesting @@ -47,6 +50,7 @@ class Compiler(CompilerSuiteTool): def __init__(self, name: str, exec_name: Union[str, Path], suite: str, + version_regex: str, category: Category, mpi: bool = False, compile_flag: Optional[str] = None, @@ -61,6 +65,7 @@ def __init__(self, name: str, 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()) + self._version_regex = version_regex @property def mpi(self) -> bool: @@ -156,8 +161,14 @@ def get_version(self) -> Tuple[int, ...]: # Run the compiler to get the version and parse the output # The implementations depend on vendor output = self.run_version_command() - version_string = self.parse_version_output(self.category, output) + # Multiline is required in case that the version number is the end + # 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}") + version_string = matches.groups()[0] # Expect the version to be dot-separated integers. try: version = tuple(int(x) for x in version_string.split('.')) @@ -195,15 +206,6 @@ def run_version_command( raise RuntimeError(f"Error asking for version of compiler " f"'{self.name}'") from err - def parse_version_output(self, category: Category, - version_output: str) -> str: - ''' - Extract the numerical part from the version output. - Implemented in specific compilers. - ''' - raise NotImplementedError("The method `parse_version_output` must be " - "provided using a mixin.") - def get_version_string(self) -> str: """ Get a string representing the version of the given compiler. @@ -226,6 +228,8 @@ class CCompiler(Compiler): :param name: name of the compiler. :param exec_name: name of the executable to start. :param suite: name of the compiler suite. + :param version_regex: A regular expression that allows extraction of + the version number from the version output of the compiler. :param mpi: whether the compiler or linker support MPI. :param compile_flag: the compilation flag to use when only requesting compilation (not linking). @@ -236,6 +240,7 @@ class CCompiler(Compiler): # pylint: disable=too-many-arguments def __init__(self, name: str, exec_name: str, suite: str, + version_regex: str, mpi: bool = False, compile_flag: Optional[str] = None, output_flag: Optional[str] = None, @@ -243,7 +248,8 @@ def __init__(self, name: str, exec_name: str, suite: str, super().__init__(name, exec_name, suite, category=Category.C_COMPILER, mpi=mpi, compile_flag=compile_flag, output_flag=output_flag, - openmp_flag=openmp_flag) + openmp_flag=openmp_flag, + version_regex=version_regex) # ============================================================================ @@ -255,6 +261,8 @@ 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 version_regex: A regular expression that allows extraction of + the version number from the version output of the compiler. :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). @@ -269,6 +277,7 @@ class FortranCompiler(Compiler): # pylint: disable=too-many-arguments def __init__(self, name: str, exec_name: str, suite: str, + version_regex: str, mpi: bool = False, compile_flag: Optional[str] = None, output_flag: Optional[str] = None, @@ -280,7 +289,8 @@ def __init__(self, name: str, exec_name: str, suite: str, super().__init__(name=name, exec_name=exec_name, suite=suite, category=Category.FORTRAN_COMPILER, mpi=mpi, compile_flag=compile_flag, - output_flag=output_flag, openmp_flag=openmp_flag) + output_flag=output_flag, openmp_flag=openmp_flag, + version_regex=version_regex) self._module_folder_flag = (module_folder_flag if module_folder_flag else "") self._syntax_only_flag = syntax_only_flag @@ -334,45 +344,9 @@ def compile_file(self, input_file: Path, # ============================================================================ -class GnuVersionHandling(): - '''Mixin to handle version information from GNU compilers''' - - def parse_version_output(self, category: Category, - version_output: str) -> str: - ''' - Extract the numerical part from a GNU compiler's version output - - :param name: the compiler's name - :param category: the compiler's Category - :param version_output: the full version output from the compiler - :returns: the actual version as a string - - :raises RuntimeError: if the output is not in an expected format. - ''' - - # Expect the version to appear after some in parentheses, e.g. - # "GNU Fortran (...) n.n[.n, ...]" or # "gcc (...) n.n[.n, ...]" - if category is Category.FORTRAN_COMPILER: - name = "GNU Fortran" - else: - name = "gcc" - # A version number is a digit, followed by a sequence of digits and - # '.'', ending with a digit. It must then be followed by either the - # end of the string, or a space (e.g. "... 5.6 123456"). We can't use - # \b to determine the end, since then "1.2." would be matched - # excluding the dot (so it would become a valid 1.2) - exp = name + r" \(.*?\) (\d[\d\.]+\d)(?:$| )" - # Multiline is required in case that the version number is the - # end of the string, otherwise the $ would not match the end of line - matches = re.search(exp, version_output, re.MULTILINE) - if not matches: - raise RuntimeError(f"Unexpected version output format for " - f"compiler '{name}': {version_output}") - return matches.groups()[0] - - +# Gnu # ============================================================================ -class Gcc(GnuVersionHandling, CCompiler): +class Gcc(CCompiler): '''Class for GNU's gcc compiler. :param name: name of this compiler. @@ -383,12 +357,18 @@ def __init__(self, name: str = "gcc", exec_name: str = "gcc", mpi: bool = False): + # A version number is a digit, followed by a sequence of digits and + # '.'', ending with a digit. It must then be followed by either the + # end of the string, or a space (e.g. "... 5.6 123456"). We can't use + # \b to determine the end, since then "1.2." would be matched + # excluding the dot (so it would become a valid 1.2) super().__init__(name, exec_name, suite="gnu", mpi=mpi, - openmp_flag="-fopenmp") + openmp_flag="-fopenmp", + version_regex=r"gcc \(.*?\) (\d[\d\.]+\d)(?:$| )") # ============================================================================ -class Gfortran(GnuVersionHandling, FortranCompiler): +class Gfortran(FortranCompiler): '''Class for GNU's gfortran compiler. :param name: name of this compiler. @@ -401,45 +381,15 @@ def __init__(self, name: str = "gfortran", super().__init__(name, exec_name, suite="gnu", openmp_flag="-fopenmp", module_folder_flag="-J", - syntax_only_flag="-fsyntax-only") + syntax_only_flag="-fsyntax-only", + version_regex=(r"GNU Fortran \(.*?\) " + r"(\d[\d\.]+\d)(?:$| )")) # ============================================================================ -class IntelVersionHandling(): - '''Mixin to handle version information from Intel compilers''' - - def parse_version_output(self, category: Category, - version_output: str) -> str: - ''' - Extract the numerical part from an Intel compiler's version output - - :param name: the compiler's name - :param version_output: the full version output from the compiler - :returns: the actual version as a string - - :raises RuntimeError: if the output is not in an expected format. - ''' - - # Expect the version to appear after some in parentheses, e.g. - # "icc (...) n.n[.n, ...]" or "ifort (...) n.n[.n, ...]" - if category == Category.C_COMPILER: - name = "icc" - else: - name = "ifort" - - # A version number is a digit, followed by a sequence of digits and - # '.'', ending with a digit. It must then be followed by a space. - exp = name + r" \(.*?\) (\d[\d\.]+\d) " - matches = re.search(exp, version_output) - - if not matches: - raise RuntimeError(f"Unexpected version output format for " - f"compiler '{name}': {version_output}") - return matches.groups()[0] - - +# intel-classic # ============================================================================ -class Icc(IntelVersionHandling, CCompiler): +class Icc(CCompiler): '''Class for the Intel's icc compiler. :param name: name of this compiler. @@ -449,11 +399,12 @@ class Icc(IntelVersionHandling, CCompiler): def __init__(self, name: str = "icc", exec_name: str = "icc"): super().__init__(name, exec_name, suite="intel-classic", - openmp_flag="-qopenmp") + openmp_flag="-qopenmp", + version_regex=r"icc \(ICC\) (\d[\d\.]+\d) ") # ============================================================================ -class Ifort(IntelVersionHandling, FortranCompiler): +class Ifort(FortranCompiler): '''Class for Intel's ifort compiler. :param name: name of this compiler. @@ -465,4 +416,145 @@ 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") + syntax_only_flag="-syntax-only", + version_regex=r"ifort \(IFORT\) (\d[\d\.]+\d) ") + + +# ============================================================================ +# intel-llvm +# ============================================================================ +class Icx(CCompiler): + '''Class for the Intel's new llvm based icx compiler. + + :param name: name of this compiler. + :param exec_name: name of the executable. + ''' + def __init__(self, name: str = "icx", exec_name: str = "icx"): + super().__init__(name, exec_name, suite="intel-llvm", + openmp_flag="-qopenmp", + version_regex=(r"Intel\(R\) oneAPI DPC\+\+/C\+\+ " + r"Compiler (\d[\d\.]+\d) ")) + + +# ============================================================================ +class Ifx(FortranCompiler): + '''Class for Intel's new ifx compiler. + + :param name: name of this compiler. + :param exec_name: name of the executable. + ''' + + def __init__(self, name: str = "ifx", exec_name: str = "ifx"): + super().__init__(name, exec_name, suite="intel-llvm", + module_folder_flag="-module", + openmp_flag="-qopenmp", + syntax_only_flag="-syntax-only", + version_regex=r"ifx \(IFORT\) (\d[\d\.]+\d) ") + + +# ============================================================================ +# nvidia +# ============================================================================ +class Nvc(CCompiler): + '''Class for Nvidia's nvc compiler. Nvc has a '-' in the + version number. In order to get this, we overwrite run_version_command + and replace any '-' with a '.' + + :param name: name of this compiler. + :param exec_name: name of the executable. + ''' + + def __init__(self, name: str = "nvc", exec_name: str = "nvc"): + super().__init__(name, exec_name, suite="nvidia", + openmp_flag="-mp", + version_regex=r"nvc (\d[\d\.-]+\d)") + + def run_version_command( + self, version_command: Optional[str] = '--version') -> str: + '''Run the compiler's command to get its version. This implementation + runs the function in the base class, and changes any '-' into a + '.' to support nvidia version numbers which have dashes, e.g. 23.5-0. + + :param version_command: The compiler argument used to get version info. + + :returns: The output from the version command, with any '-' replaced + with '.' + ''' + version_string = super().run_version_command() + return version_string.replace("-", ".") + + +# ============================================================================ +class Nvfortran(FortranCompiler): + '''Class for Nvidia's nvfortran compiler. Nvfortran has a '-' in the + version number. In order to get this, we overwrite run_version_command + and replace any '-' with a '.' + + :param name: name of this compiler. + :param exec_name: name of the executable. + ''' + + def __init__(self, name: str = "nvfortran", exec_name: str = "nvfortran"): + super().__init__(name, exec_name, suite="nvidia", + module_folder_flag="-module", + openmp_flag="-mp", + syntax_only_flag="-Msyntax-only", + version_regex=r"nvfortran (\d[\d\.-]+\d)") + + def run_version_command( + self, version_command: Optional[str] = '--version') -> str: + '''Run the compiler's command to get its version. This implementation + runs the function in the base class, and changes any '-' into a + '.' to support nvidia version numbers which have dashes, e.g. 23.5-0. + + :param version_command: The compiler argument used to get version info. + + :returns: The output from the version command, with any '-' replaced + with '.' + ''' + version_string = super().run_version_command() + return version_string.replace("-", ".") + + +# ============================================================================ +# Cray compiler +# ============================================================================ +class Craycc(CCompiler): + '''Class for the native Cray C compiler. Since cc is actually a compiler + wrapper, follow the naming scheme of a compiler wrapper and call it: + craycc-cc. + + Cray has two different compilers. Older ones have as version number: + Cray C : Version 8.7.0 Tue Jul 23, 2024 07:39:46 + Newer compiler (several lines, the important one): + Cray clang version 15.0.1 (66f7391d6a03cf932f321b9f6b1d8612ef5f362c) + We use the beginning ("cray c") to identify the compiler, which works for + both cray c and cray clang. Then we ignore non-numbers, to reach the + version number which is then extracted. + + :param name: name of this compiler. + :param exec_name: name of the executable. + ''' + def __init__(self, name: str = "craycc-cc", exec_name: str = "cc"): + super().__init__(name, exec_name, suite="cray", mpi=True, + openmp_flag="-homp", + version_regex=r"Cray [Cc][^\d]* (\d[\d\.]+\d) ") + + +# ============================================================================ +class Crayftn(FortranCompiler): + '''Class for the native Cray Fortran compiler. Since ftn is actually a + compiler wrapper, follow the naming scheme of Cray compiler wrapper + and call it crayftn-ftn. + + :param name: name of this compiler. + :param exec_name: name of the executable. + ''' + + def __init__(self, name: str = "crayftn-ftn", exec_name: str = "ftn"): + super().__init__(name, exec_name, suite="cray", mpi=True, + module_folder_flag="-J", + openmp_flag="-homp", + syntax_only_flag="-syntax-only", + version_regex=(r"Cray Fortran : Version " + r"(\d[\d\.]+\d) ")) diff --git a/source/fab/tools/compiler_wrapper.py b/source/fab/tools/compiler_wrapper.py index e54f98ea..b046a541 100644 --- a/source/fab/tools/compiler_wrapper.py +++ b/source/fab/tools/compiler_wrapper.py @@ -36,12 +36,9 @@ def __init__(self, name: str, exec_name: str, name=name, exec_name=exec_name, category=self._compiler.category, suite=self._compiler.suite, + version_regex=self._compiler._version_regex, 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})" @@ -196,3 +193,29 @@ class Mpicc(CompilerWrapper): def __init__(self, compiler: Compiler): super().__init__(name=f"mpicc-{compiler.name}", exec_name="mpicc", compiler=compiler, mpi=True) + + +# ============================================================================ +class CrayFtnWrapper(CompilerWrapper): + '''Class for the Cray Fortran compiler wrapper. We add 'wrapper' to the + class name to make this class distinct from the Crayftn compiler class. + + :param compiler: the compiler that the ftn wrapper will use. + ''' + + def __init__(self, compiler: Compiler): + super().__init__(name=f"crayftn-{compiler.name}", + exec_name="ftn", compiler=compiler, mpi=True) + + +# ============================================================================ +class CrayCcWrapper(CompilerWrapper): + '''Class for the Cray C compiler wrapper. We add 'wrapper' to the class + name to make this class distinct from the Craycc compiler class + + :param compiler: the compiler that the mpicc wrapper will use. + ''' + + def __init__(self, compiler: Compiler): + super().__init__(name=f"craycc-{compiler.name}", + exec_name="cc", compiler=compiler, mpi=True) diff --git a/source/fab/tools/tool_repository.py b/source/fab/tools/tool_repository.py index 6a077b67..d699574a 100644 --- a/source/fab/tools/tool_repository.py +++ b/source/fab/tools/tool_repository.py @@ -17,8 +17,13 @@ from fab.tools.tool import Tool from fab.tools.category import Category from fab.tools.compiler import Compiler +from fab.tools.compiler_wrapper import (CrayCcWrapper, CrayFtnWrapper, + Mpif90, Mpicc) from fab.tools.linker import Linker 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) class ToolRepository(dict): @@ -57,26 +62,36 @@ def __init__(self): # Add the FAB default tools: # TODO: sort the defaults so that they actually work (since not all - # tools FAB knows about are available). For now, disable Fpp: - # 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, Psyclone, Rsync) - - for cls in [Gcc, Icc, Gfortran, Ifort, Cpp, CppFortran, - Fcm, Git, Subversion, Ar, Psyclone, Rsync]: + # tools FAB knows about are available). For now, disable Fpp (by not + # adding it). IF someone actually uses it it can added. + for cls in [Craycc, Crayftn, + Gcc, Gfortran, + Icc, Icx, Ifort, Ifx, + Nvc, Nvfortran, + Cpp, CppFortran, + Ar, Fcm, Git, Psyclone, Rsync, Subversion]: self.add_tool(cls()) - from fab.tools.compiler_wrapper import Mpif90, Mpicc + # Now create the potential mpif90 and Cray ftn wrapper all_fc = self[Category.FORTRAN_COMPILER][:] for fc in all_fc: mpif90 = Mpif90(fc) self.add_tool(mpif90) + # I assume cray has (besides cray) only support for Intel and GNU + if fc.name in ["gfortran", "ifort"]: + crayftn = CrayFtnWrapper(fc) + print("NEW NAME", crayftn, crayftn.name) + self.add_tool(crayftn) + # Now create the potential mpicc and Cray cc wrapper all_cc = self[Category.C_COMPILER][:] for cc in all_cc: mpicc = Mpicc(cc) self.add_tool(mpicc) + # I assume cray has (besides cray) only support for Intel and GNU + if cc.name in ["gcc", "icc"]: + craycc = CrayCcWrapper(cc) + self.add_tool(craycc) def add_tool(self, tool: Tool): '''Creates an instance of the specified class and adds it diff --git a/tests/conftest.py b/tests/conftest.py index 559d4f3b..86de6476 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,7 +18,8 @@ @pytest.fixture(name="mock_c_compiler") def fixture_mock_c_compiler(): '''Provides a mock C-compiler.''' - mock_compiler = CCompiler("mock_c_compiler", "mock_exec", "suite") + mock_compiler = CCompiler("mock_c_compiler", "mock_exec", "suite", + version_regex="something") mock_compiler.run = mock.Mock() mock_compiler._version = (1, 2, 3) mock_compiler._name = "mock_c_compiler" @@ -32,6 +33,7 @@ def fixture_mock_fortran_compiler(): '''Provides a mock Fortran-compiler.''' mock_compiler = FortranCompiler("mock_fortran_compiler", "mock_exec", "suite", module_folder_flag="", + version_regex="something", syntax_only_flag=None, compile_flag=None, output_flag=None, openmp_flag=None) mock_compiler.run = mock.Mock() diff --git a/tests/unit_tests/steps/test_archive_objects.py b/tests/unit_tests/steps/test_archive_objects.py index 097200ea..1cc9e2cf 100644 --- a/tests/unit_tests/steps/test_archive_objects.py +++ b/tests/unit_tests/steps/test_archive_objects.py @@ -84,7 +84,6 @@ def test_for_library(self): def test_incorrect_tool(self, tool_box): '''Test that an incorrect archive tool is detected ''' - config = BuildConfig('proj', tool_box) cc = tool_box.get_tool(Category.C_COMPILER, config.mpi, config.openmp) # And set its category to be AR diff --git a/tests/unit_tests/tools/test_compiler.py b/tests/unit_tests/tools/test_compiler.py index 6bfcece7..eea7d282 100644 --- a/tests/unit_tests/tools/test_compiler.py +++ b/tests/unit_tests/tools/test_compiler.py @@ -14,13 +14,15 @@ import pytest -from fab.tools import (Category, CCompiler, Compiler, FortranCompiler, - Gcc, Gfortran, Icc, Ifort) +from fab.tools import (Category, CCompiler, Compiler, Craycc, Crayftn, + FortranCompiler, Gcc, Gfortran, Icc, Icx, Ifort, Ifx, + Nvc, Nvfortran) def test_compiler(): '''Test the compiler constructor.''' - cc = Compiler("gcc", "gcc", "gnu", category=Category.C_COMPILER, openmp_flag="-fopenmp") + cc = Compiler("gcc", "gcc", "gnu", version_regex="some_regex", + category=Category.C_COMPILER, openmp_flag="-fopenmp") assert cc.category == Category.C_COMPILER assert cc._compile_flag == "-c" assert cc._output_flag == "-o" @@ -29,13 +31,9 @@ def test_compiler(): assert cc.suite == "gnu" assert not cc.mpi assert cc.openmp_flag == "-fopenmp" - with pytest.raises(NotImplementedError) as err: - cc.parse_version_output(Category.FORTRAN_COMPILER, "NOT NEEDED") - assert ("The method `parse_version_output` must be provided using a mixin." - in str(err.value)) fc = FortranCompiler("gfortran", "gfortran", "gnu", openmp_flag="-fopenmp", - module_folder_flag="-J") + version_regex="something", module_folder_flag="-J") assert fc._compile_flag == "-c" assert fc._output_flag == "-o" assert fc.category == Category.FORTRAN_COMPILER @@ -44,35 +42,32 @@ def test_compiler(): assert fc.flags == [] assert not fc.mpi assert fc.openmp_flag == "-fopenmp" - with pytest.raises(NotImplementedError) as err: - fc.parse_version_output(Category.FORTRAN_COMPILER, "NOT NEEDED") - assert ("The method `parse_version_output` must be provided using a mixin." - in str(err.value)) def test_compiler_openmp(): '''Test that the openmp flag is correctly reflected in the test if a compiler supports OpenMP or not.''' - cc = CCompiler("gcc", "gcc", "gnu", openmp_flag="-fopenmp") + cc = CCompiler("gcc", "gcc", "gnu", openmp_flag="-fopenmp", + version_regex=None) assert cc.openmp_flag == "-fopenmp" assert cc.openmp - cc = CCompiler("gcc", "gcc", "gnu", openmp_flag=None) + cc = CCompiler("gcc", "gcc", "gnu", openmp_flag=None, version_regex=None) assert cc.openmp_flag == "" assert not cc.openmp - cc = CCompiler("gcc", "gcc", "gnu") + cc = CCompiler("gcc", "gcc", "gnu", version_regex=None) assert cc.openmp_flag == "" assert not cc.openmp fc = FortranCompiler("gfortran", "gfortran", "gnu", openmp_flag="-fopenmp", - module_folder_flag="-J") + module_folder_flag="-J", version_regex=None) assert fc.openmp_flag == "-fopenmp" assert fc.openmp fc = FortranCompiler("gfortran", "gfortran", "gnu", openmp_flag=None, - module_folder_flag="-J") + module_folder_flag="-J", version_regex=None) assert fc.openmp_flag == "" assert not fc.openmp fc = FortranCompiler("gfortran", "gfortran", "gnu", - module_folder_flag="-J") + module_folder_flag="-J", version_regex=None) assert fc.openmp_flag == "" assert not fc.openmp @@ -148,16 +143,19 @@ def test_compiler_with_env_fflags(): def test_compiler_syntax_only(): '''Tests handling of syntax only flags.''' fc = FortranCompiler("gfortran", "gfortran", "gnu", + version_regex="something", openmp_flag="-fopenmp", module_folder_flag="-J") # Empty since no flag is defined assert not fc.has_syntax_only fc = FortranCompiler("gfortran", "gfortran", "gnu", openmp_flag="-fopenmp", - module_folder_flag="-J", syntax_only_flag=None) + version_regex="something", module_folder_flag="-J", + syntax_only_flag=None) # Empty since no flag is defined assert not fc.has_syntax_only fc = FortranCompiler("gfortran", "gfortran", "gnu", + version_regex="something", openmp_flag="-fopenmp", module_folder_flag="-J", syntax_only_flag="-fsyntax-only") @@ -168,6 +166,7 @@ def test_compiler_syntax_only(): def test_compiler_without_openmp(): '''Tests that the openmp flag is not used when openmp is not enabled. ''' fc = FortranCompiler("gfortran", "gfortran", "gnu", + version_regex="something", openmp_flag="-fopenmp", module_folder_flag="-J", syntax_only_flag="-fsyntax-only") @@ -184,6 +183,7 @@ def test_compiler_with_openmp(): '''Tests that the openmp flag is used as expected if openmp is enabled. ''' fc = FortranCompiler("gfortran", "gfortran", "gnu", + version_regex="something", openmp_flag="-fopenmp", module_folder_flag="-J", syntax_only_flag="-fsyntax-only") @@ -199,7 +199,7 @@ def test_compiler_with_openmp(): def test_compiler_module_output(): '''Tests handling of module output_flags.''' fc = FortranCompiler("gfortran", "gfortran", suite="gnu", - module_folder_flag="-J") + version_regex="something", module_folder_flag="-J") fc.set_module_output_path("/module_out") assert fc._module_output_path == "/module_out" fc.run = mock.MagicMock() @@ -212,6 +212,7 @@ def test_compiler_module_output(): def test_compiler_with_add_args(): '''Tests that additional arguments are handled as expected.''' fc = FortranCompiler("gfortran", "gfortran", suite="gnu", + version_regex="something", openmp_flag="-fopenmp", module_folder_flag="-J") fc.set_module_output_path("/module_out") @@ -233,6 +234,9 @@ def test_compiler_with_add_args(): openmp=True, syntax_only=True) +# ============================================================================ +# Test version number handling +# ============================================================================ def test_get_version_string(): '''Tests the get_version_string() method. ''' @@ -393,6 +397,8 @@ def test_get_version_bad_result_is_not_cached(): assert c.run.called +# ============================================================================ +# gcc # ============================================================================ def test_gcc(): '''Tests the gcc class.''' @@ -428,6 +434,8 @@ def test_gcc_get_version_with_icc_string(): assert "Unexpected version output format for compiler" in str(err.value) +# ============================================================================ +# gfortran # ============================================================================ def test_gfortran(): '''Tests the gfortran class.''' @@ -512,7 +520,8 @@ def test_gfortran_get_version_12(): """) gfortran = Gfortran() - with mock.patch.object(gfortran, "run", mock.Mock(return_value=full_output)): + with mock.patch.object(gfortran, "run", + mock.Mock(return_value=full_output)): assert gfortran.get_version() == (12, 1, 0) @@ -524,12 +533,16 @@ def test_gfortran_get_version_with_ifort_string(): """) gfortran = Gfortran() - with mock.patch.object(gfortran, "run", mock.Mock(return_value=full_output)): + with mock.patch.object(gfortran, "run", + mock.Mock(return_value=full_output)): with pytest.raises(RuntimeError) as err: gfortran.get_version() - assert "Unexpected version output format for compiler" in str(err.value) + assert ("Unexpected version output format for compiler" + in str(err.value)) +# ============================================================================ +# icc # ============================================================================ def test_icc(): '''Tests the icc class.''' @@ -562,9 +575,12 @@ def test_icc_get_version_with_gcc_string(): 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) + assert ("Unexpected version output format for compiler" + in str(err.value)) +# ============================================================================ +# ifort # ============================================================================ def test_ifort(): '''Tests the ifort class.''' @@ -634,7 +650,8 @@ def test_ifort_get_version_with_icc_string(): 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) + assert ("Unexpected version output format for compiler" + in str(err.value)) @pytest.mark.parametrize("version", ["5.15f.2", @@ -653,4 +670,285 @@ def test_ifort_get_version_invalid_version(version): 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) + assert ("Unexpected version output format for compiler" + in str(err.value)) + + +# ============================================================================ +# icx +# ============================================================================ +def test_icx(): + '''Tests the icx class.''' + icx = Icx() + assert icx.name == "icx" + assert isinstance(icx, CCompiler) + assert icx.category == Category.C_COMPILER + assert not icx.mpi + + +def test_icx_get_version_2023(): + '''Test icx 2023.0.0 version detection.''' + full_output = dedent(""" +Intel(R) oneAPI DPC++/C++ Compiler 2023.0.0 (2023.0.0.20221201) +Target: x86_64-unknown-linux-gnu +Thread model: posix +InstalledDir: /opt/intel/oneapi/compiler/2023.0.0/linux/bin-llvm +Configuration file: /opt/intel/oneapi/compiler/2023.0.0/linux/bin-llvm/""" + """../bin/icx.cfg + + """) + icx = Icx() + with mock.patch.object(icx, "run", mock.Mock(return_value=full_output)): + assert icx.get_version() == (2023, 0, 0) + + +def test_icx_get_version_with_icc_string(): + '''Tests the icx class with an icc version output.''' + full_output = dedent(""" + icc (ICC) 2021.10.0 20230609 + Copyright (C) 1985-2023 Intel Corporation. All rights reserved. + + """) + icx = Icx() + with mock.patch.object(icx, "run", mock.Mock(return_value=full_output)): + with pytest.raises(RuntimeError) as err: + icx.get_version() + assert ("Unexpected version output format for compiler" + in str(err.value)) + + +# ============================================================================ +# ifx +# ============================================================================ +def test_ifx(): + '''Tests the ifx class.''' + ifx = Ifx() + assert ifx.name == "ifx" + assert isinstance(ifx, FortranCompiler) + assert ifx.category == Category.FORTRAN_COMPILER + assert not ifx.mpi + + +def test_ifx_get_version_2023(): + '''Test ifx 2023.0.0 version detection.''' + full_output = dedent(""" +ifx (IFORT) 2023.0.0 20221201 +Copyright (C) 1985-2022 Intel Corporation. All rights reserved. + + """) + ifx = Ifx() + with mock.patch.object(ifx, "run", mock.Mock(return_value=full_output)): + assert ifx.get_version() == (2023, 0, 0) + + +def test_ifx_get_version_with_ifort_string(): + '''Tests the ifx class with an icc version output.''' + full_output = dedent(""" + ifort (IFORT) 19.0.0.117 20180804 + Copyright (C) 1985-2018 Intel Corporation. All rights reserved. + + """) + ifx = Ifx() + with mock.patch.object(ifx, "run", mock.Mock(return_value=full_output)): + with pytest.raises(RuntimeError) as err: + ifx.get_version() + assert ("Unexpected version output format for compiler" + in str(err.value)) + + +# ============================================================================ +# nvc +# ============================================================================ +def test_nvc(): + '''Tests the nvc class.''' + nvc = Nvc() + assert nvc.name == "nvc" + assert isinstance(nvc, CCompiler) + assert nvc.category == Category.C_COMPILER + assert not nvc.mpi + + +def test_nvc_get_version_23_5_0(): + '''Test nvc 23.5.0 version detection.''' + full_output = dedent(""" + +nvc 23.5-0 64-bit target on x86-64 Linux -tp icelake-server +NVIDIA Compilers and Tools +Copyright (c) 2022, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + """) + nvc = Nvc() + with mock.patch.object(nvc, "run", mock.Mock(return_value=full_output)): + assert nvc.get_version() == (23, 5, 0) + + +def test_nvc_get_version_with_icc_string(): + '''Tests the nvc class with an icc version output.''' + full_output = dedent(""" + icc (ICC) 2021.10.0 20230609 + Copyright (C) 1985-2023 Intel Corporation. All rights reserved. + + """) + nvc = Nvc() + with mock.patch.object(nvc, "run", mock.Mock(return_value=full_output)): + with pytest.raises(RuntimeError) as err: + nvc.get_version() + assert ("Unexpected version output format for compiler" + in str(err.value)) + + +# ============================================================================ +# nvfortran +# ============================================================================ +def test_nvfortran(): + '''Tests the nvfortran class.''' + nvfortran = Nvfortran() + assert nvfortran.name == "nvfortran" + assert isinstance(nvfortran, FortranCompiler) + assert nvfortran.category == Category.FORTRAN_COMPILER + assert not nvfortran.mpi + + +def test_nvfortran_get_version_23_5_0(): + '''Test nvfortran 23.5 version detection.''' + full_output = dedent(""" + +nvfortran 23.5-0 64-bit target on x86-64 Linux -tp icelake-server +NVIDIA Compilers and Tools +Copyright (c) 2022, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + """) + nvfortran = Nvfortran() + with mock.patch.object(nvfortran, "run", + mock.Mock(return_value=full_output)): + assert nvfortran.get_version() == (23, 5, 0) + + +def test_nvfortran_get_version_with_ifort_string(): + '''Tests the nvfortran class with an icc version output.''' + full_output = dedent(""" + ifort (IFORT) 19.0.0.117 20180804 + Copyright (C) 1985-2018 Intel Corporation. All rights reserved. + + """) + nvfortran = Nvfortran() + with mock.patch.object(nvfortran, "run", + mock.Mock(return_value=full_output)): + with pytest.raises(RuntimeError) as err: + nvfortran.get_version() + assert ("Unexpected version output format for compiler" + in str(err.value)) + + +# ============================================================================ +# Craycc +# ============================================================================ +def test_craycc(): + '''Tests the Craycc class.''' + craycc = Craycc() + assert craycc.name == "craycc-cc" + assert isinstance(craycc, CCompiler) + assert craycc.category == Category.C_COMPILER + assert craycc.mpi + + +def test_craycc_get_version_8_7_0(): + '''Test craycc .23.5 version detection.''' + full_output = dedent(""" +Cray C : Version 8.7.0 Tue Jul 23, 2024 07:39:46 + + """) + craycc = Craycc() + with mock.patch.object(craycc, "run", mock.Mock(return_value=full_output)): + assert craycc.get_version() == (8, 7, 0) + + +def test_craycc_get_version_2023(): + '''Test craycc .23.5 version detection.''' + full_output = dedent(""" +Cray clang version 15.0.1 (66f7391d6a03cf932f321b9f6b1d8612ef5f362c) + +Target: x86_64-unknown-linux-gnu + +Thread model: posix + +InstalledDir: /opt/cray/pe/cce/15.0.1/cce-clang/x86_64/share/../bin + +Found candidate GCC installation: /opt/gcc/10.3.0/snos/lib/gcc/x86_64-""" + """suse-linux/10.3.0 + +Selected GCC installation: /opt/gcc/10.3.0/snos/lib/gcc/x86_64-suse-""" + """linux/10.3.0 + +Candidate multilib: .;@m64 + +Selected multilib: .;@m64 + +OFFICIAL + """) + craycc = Craycc() + with mock.patch.object(craycc, "run", mock.Mock(return_value=full_output)): + assert craycc.get_version() == (15, 0, 1) + + +def test_craycc_get_version_with_icc_string(): + '''Tests the Craycc class with an icc version output.''' + full_output = dedent(""" + icc (ICC) 2021.10.0 20230609 + Copyright (C) 1985-2023 Intel Corporation. All rights reserved. + + """) + craycc = Craycc() + with mock.patch.object(craycc, "run", mock.Mock(return_value=full_output)): + with pytest.raises(RuntimeError) as err: + craycc.get_version() + assert ("Unexpected version output format for compiler" + in str(err.value)) + + +# ============================================================================ +# Crayftn +# ============================================================================ +def test_crayftn(): + '''Tests the Crayftn class.''' + crayftn = Crayftn() + assert crayftn.name == "crayftn-ftn" + assert isinstance(crayftn, FortranCompiler) + assert crayftn.category == Category.FORTRAN_COMPILER + assert crayftn.mpi + + +def test_crayftn_get_version_8_7_0(): + '''Test crayftn .23.5 version detection.''' + full_output = dedent(""" +Cray Fortran : Version 8.7.0 Tue Jul 23, 2024 07:39:25 + """) + crayftn = Crayftn() + with mock.patch.object(crayftn, "run", + mock.Mock(return_value=full_output)): + assert crayftn.get_version() == (8, 7, 0) + + +def test_crayftn_get_version_15_0_1(): + '''Test Crayftn 15.0.1 version detection.''' + full_output = dedent(""" +Cray Fortran : Version 15.0.1 Tue Jul 23, 2024 07:39:25 + """) + crayftn = Crayftn() + with mock.patch.object(crayftn, "run", + mock.Mock(return_value=full_output)): + assert crayftn.get_version() == (15, 0, 1) + + +def test_crayftn_get_version_with_ifort_string(): + '''Tests the crayftn class with an icc version output.''' + full_output = dedent(""" + ifort (IFORT) 19.0.0.117 20180804 + Copyright (C) 1985-2018 Intel Corporation. All rights reserved. + + """) + crayftn = Crayftn() + with mock.patch.object(crayftn, "run", + mock.Mock(return_value=full_output)): + with pytest.raises(RuntimeError) as err: + crayftn.get_version() + assert ("Unexpected version output format for compiler" + 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 11fdde57..07f9a08b 100644 --- a/tests/unit_tests/tools/test_compiler_wrapper.py +++ b/tests/unit_tests/tools/test_compiler_wrapper.py @@ -12,7 +12,8 @@ import pytest -from fab.tools import (Category, CompilerWrapper, Gcc, Gfortran, Icc, Ifort, +from fab.tools import (Category, CompilerWrapper, CrayCcWrapper, + CrayFtnWrapper, Gcc, Gfortran, Icc, Ifort, Mpicc, Mpif90, ToolRepository) @@ -346,3 +347,47 @@ def test_compiler_wrapper_mpi_ifort(): assert mpi_ifort.category == Category.FORTRAN_COMPILER assert mpi_ifort.mpi assert mpi_ifort.suite == "intel-classic" + + +def test_compiler_wrapper_cray_icc(): + '''Tests the Cray wrapper for icc.''' + craycc = CrayCcWrapper(Icc()) + assert craycc.name == "craycc-icc" + assert str(craycc) == "CrayCcWrapper(icc)" + assert isinstance(craycc, CompilerWrapper) + assert craycc.category == Category.C_COMPILER + assert craycc.mpi + assert craycc.suite == "intel-classic" + + +def test_compiler_wrapper_cray_ifort(): + '''Tests the Cray wrapper for ifort.''' + crayftn = CrayFtnWrapper(Ifort()) + assert crayftn.name == "crayftn-ifort" + assert str(crayftn) == "CrayFtnWrapper(ifort)" + assert isinstance(crayftn, CompilerWrapper) + assert crayftn.category == Category.FORTRAN_COMPILER + assert crayftn.mpi + assert crayftn.suite == "intel-classic" + + +def test_compiler_wrapper_cray_gcc(): + '''Tests the Cray wrapper for gcc.''' + craycc = CrayCcWrapper(Gcc()) + assert craycc.name == "craycc-gcc" + assert str(craycc) == "CrayCcWrapper(gcc)" + assert isinstance(craycc, CompilerWrapper) + assert craycc.category == Category.C_COMPILER + assert craycc.mpi + assert craycc.suite == "gnu" + + +def test_compiler_wrapper_cray_gfortran(): + '''Tests the Cray wrapper for gfortran.''' + crayftn = CrayFtnWrapper(Gfortran()) + assert crayftn.name == "crayftn-gfortran" + assert str(crayftn) == "CrayFtnWrapper(gfortran)" + assert isinstance(crayftn, CompilerWrapper) + assert crayftn.category == Category.FORTRAN_COMPILER + assert crayftn.mpi + assert crayftn.suite == "gnu" diff --git a/tests/unit_tests/tools/test_tool_box.py b/tests/unit_tests/tools/test_tool_box.py index 29bedf30..2e886ea5 100644 --- a/tests/unit_tests/tools/test_tool_box.py +++ b/tests/unit_tests/tools/test_tool_box.py @@ -44,9 +44,11 @@ def test_tool_box_add_tool_replacement(): warning can be disabled.''' tb = ToolBox() - mock_compiler1 = CCompiler("mock_c_compiler1", "mock_exec1", "suite") + mock_compiler1 = CCompiler("mock_c_compiler1", "mock_exec1", "suite", + version_regex="something") mock_compiler1._is_available = True - mock_compiler2 = CCompiler("mock_c_compiler2", "mock_exec2", "suite") + mock_compiler2 = CCompiler("mock_c_compiler2", "mock_exec2", "suite", + version_regex="something") mock_compiler2._is_available = True tb.add_tool(mock_compiler1) diff --git a/tests/unit_tests/tools/test_tool_repository.py b/tests/unit_tests/tools/test_tool_repository.py index 8369668e..e9bfb0c1 100644 --- a/tests/unit_tests/tools/test_tool_repository.py +++ b/tests/unit_tests/tools/test_tool_repository.py @@ -137,7 +137,7 @@ def test_tool_repository_get_default_error_missing_openmp_compiler(): ToolRepository.''' tr = ToolRepository() fc = FortranCompiler("gfortran", "gfortran", "gnu", openmp_flag=None, - module_folder_flag="-J") + module_folder_flag="-J", version_regex=None) with mock.patch.dict(tr, {Category.FORTRAN_COMPILER: [fc]}), \ pytest.raises(RuntimeError) as err: From a493c5365ae1c37a0c8f79c6c30f8e9f2f8d88a5 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Fri, 27 Sep 2024 01:55:23 +1000 Subject: [PATCH 15/55] Support new and old style of PSyclone command line (no more nemo api etc) --- source/fab/tools/psyclone.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/source/fab/tools/psyclone.py b/source/fab/tools/psyclone.py index cbf12a9f..26fea04e 100644 --- a/source/fab/tools/psyclone.py +++ b/source/fab/tools/psyclone.py @@ -148,13 +148,14 @@ def process(self, "alg_file is specified.") if not transformed_file: raise RuntimeError("PSyclone called without api, but " - "transformed_file is not specified.") + "transformed_file it not specified.") parameters: List[Union[str, Path]] = [] # If an api is defined in this call (or in the constructor) add it # as parameter. No API is required if PSyclone works as # transformation tool only, so calling PSyclone without api is # actually valid. + print("API", api, self._version) if api: if self._version > (2, 5, 0): api_param = "--psykal-dsl" @@ -180,7 +181,7 @@ def process(self, # New version: no API, parameter, but -o for output name: parameters.extend(["-o", transformed_file]) else: - # 2.5.0 or earlier: needs api nemo, output name is -oalg + # 2.5.0 or earlier: needs api nemo, output name is -opsy parameters.extend(["-api", "nemo", "-opsy", transformed_file]) parameters.extend(["-l", "all"]) From 824851d5cfc4f9146f4c8668e21b0841b61ff20e Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Fri, 27 Sep 2024 02:00:29 +1000 Subject: [PATCH 16/55] Fix mypy errors. --- source/fab/tools/psyclone.py | 1 - 1 file changed, 1 deletion(-) diff --git a/source/fab/tools/psyclone.py b/source/fab/tools/psyclone.py index 26fea04e..9423b5f0 100644 --- a/source/fab/tools/psyclone.py +++ b/source/fab/tools/psyclone.py @@ -155,7 +155,6 @@ def process(self, # as parameter. No API is required if PSyclone works as # transformation tool only, so calling PSyclone without api is # actually valid. - print("API", api, self._version) if api: if self._version > (2, 5, 0): api_param = "--psykal-dsl" From 16a125c9880ced28dedb5d0d13bcd509baf2d36d Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Mon, 30 Sep 2024 12:02:07 +1000 Subject: [PATCH 17/55] Added missing tests for calling psyclone, and converting old style to new stle arguments and vice versa. --- source/fab/tools/psyclone.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/fab/tools/psyclone.py b/source/fab/tools/psyclone.py index 9423b5f0..066b05be 100644 --- a/source/fab/tools/psyclone.py +++ b/source/fab/tools/psyclone.py @@ -148,7 +148,7 @@ def process(self, "alg_file is specified.") if not transformed_file: raise RuntimeError("PSyclone called without api, but " - "transformed_file it not specified.") + "transformed_file is not specified.") parameters: List[Union[str, Path]] = [] # If an api is defined in this call (or in the constructor) add it From fc192838a39a8f6fc16da381c4dcd3b3eb490bc7 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Wed, 23 Oct 2024 14:53:32 +1100 Subject: [PATCH 18/55] Added shell tool. --- source/fab/tools/__init__.py | 2 + source/fab/tools/category.py | 1 + source/fab/tools/compiler.py | 2 +- source/fab/tools/shell.py | 44 ++++++++++++++++++++ source/fab/tools/tool.py | 13 +++--- source/fab/tools/tool_repository.py | 9 +++- tests/unit_tests/tools/test_shell.py | 61 ++++++++++++++++++++++++++++ 7 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 source/fab/tools/shell.py create mode 100644 tests/unit_tests/tools/test_shell.py 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) From 730a8243abdeb30a754a2ca5c43e203d8ebbdcb3 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Wed, 23 Oct 2024 15:01:26 +1100 Subject: [PATCH 19/55] Try to make mypy happy. --- source/fab/tools/shell.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/source/fab/tools/shell.py b/source/fab/tools/shell.py index 44688528..162649fb 100644 --- a/source/fab/tools/shell.py +++ b/source/fab/tools/shell.py @@ -35,6 +35,8 @@ def exec(self, command: Union[str, List[Union[Path, str]]]) -> str: :returns: stdout of the result. ''' + # Make mypy happy: + params: List[Union[str, Path]] if isinstance(command, str): params = ["-c", command] else: From 6e280d9a3a5c1a618197e491ac764125d097c9d3 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Wed, 23 Oct 2024 23:39:35 +1100 Subject: [PATCH 20/55] Removed debug code. --- source/fab/tools/tool_repository.py | 1 - 1 file changed, 1 deletion(-) diff --git a/source/fab/tools/tool_repository.py b/source/fab/tools/tool_repository.py index 0b2d2663..8a0b5cb5 100644 --- a/source/fab/tools/tool_repository.py +++ b/source/fab/tools/tool_repository.py @@ -77,7 +77,6 @@ def __init__(self): # 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][:] From 6c3f1c2b8a7ff57adffe7a8b0a588f9aac200978 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Thu, 24 Oct 2024 10:07:04 +1100 Subject: [PATCH 21/55] ToolRepository now only returns default that are available. Updated tests to make tools as available. --- source/fab/tools/tool_repository.py | 28 +++++---- tests/unit_tests/steps/test_grab.py | 60 +++++++++++++++---- .../unit_tests/tools/test_tool_repository.py | 47 ++++++++++----- 3 files changed, 97 insertions(+), 38 deletions(-) diff --git a/source/fab/tools/tool_repository.py b/source/fab/tools/tool_repository.py index 8a0b5cb5..a0b8be82 100644 --- a/source/fab/tools/tool_repository.py +++ b/source/fab/tools/tool_repository.py @@ -63,7 +63,7 @@ def __init__(self): # Add the FAB default tools: # TODO: sort the defaults so that they actually work (since not all # tools FAB knows about are available). For now, disable Fpp (by not - # adding it). IF someone actually uses it it can added. + # adding it). If someone actually uses it it can added. for cls in [Craycc, Crayftn, Gcc, Gfortran, Icc, Icx, Ifort, Ifx, @@ -101,9 +101,10 @@ def __init__(self): def add_tool(self, tool: Tool): '''Creates an instance of the specified class and adds it - to the tool repository. + to the tool repository. If the tool is a compiler, it automatically + adds the compiler as a linker as well (named "linker-{tool.name}"). - :param cls: the tool to instantiate. + :param tool: the tool to add. ''' # We do not test if a tool is actually available. The ToolRepository @@ -161,15 +162,15 @@ def set_default_compiler_suite(self, suite: str): def get_default(self, category: Category, mpi: Optional[bool] = None, openmp: Optional[bool] = None): - '''Returns the default tool for a given category. For most tools - that will be the first entry in the list of tools. The exception - are compilers and linker: in this case it must be specified if - MPI support is required or not. And the default return will be + '''Returns the default tool for a given category that is available. + For most tools that will be the first entry in the list of tools. The + exception are compilers and linker: in this case it must be specified + if MPI support is required or not. And the default return will be the first tool that either supports MPI or not. :param category: the category for which to return the default tool. :param mpi: if a compiler or linker is required that supports MPI. - :param open: if a compiler or linker is required that supports OpenMP. + :param openmp: if a compiler or linker is required that supports OpenMP. :raises KeyError: if the category does not exist. :raises RuntimeError: if no compiler/linker is found with the @@ -182,7 +183,12 @@ def get_default(self, category: Category, # If not a compiler or linker, return the first tool if not category.is_compiler and category != Category.LINKER: - return self[category][0] + for tool in self[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}'.") if not isinstance(mpi, bool): raise RuntimeError(f"Invalid or missing mpi specification " @@ -197,8 +203,8 @@ def get_default(self, category: Category, # ignore it. if openmp and not tool.openmp: continue - # If the tool supports/does not support MPI, return it. - if mpi == tool.mpi: + # If the tool supports/does not support MPI, return the first one + if tool.is_available and mpi == tool.mpi: return tool # Don't bother returning an MPI enabled tool if no-MPI is requested - diff --git a/tests/unit_tests/steps/test_grab.py b/tests/unit_tests/steps/test_grab.py index 348dc293..dc222a22 100644 --- a/tests/unit_tests/steps/test_grab.py +++ b/tests/unit_tests/steps/test_grab.py @@ -3,6 +3,10 @@ # For further details please refer to the file COPYRIGHT # which you should have received as part of this distribution ############################################################################## + +'''Test various grab implementation - folders and fcm. +''' + from pathlib import Path from types import SimpleNamespace from unittest import mock @@ -15,14 +19,21 @@ class TestGrabFolder: + '''Test grab folder functionality.''' def test_trailing_slash(self): - with pytest.warns(UserWarning, match="_metric_send_conn not set, cannot send metrics"): - self._common(grab_src='/grab/source/', expect_grab_src='/grab/source/') + '''Test folder grabbing with a trailing slash.''' + with pytest.warns(UserWarning, match="_metric_send_conn not set, " + "cannot send metrics"): + self._common(grab_src='/grab/source/', + expect_grab_src='/grab/source/') def test_no_trailing_slash(self): - with pytest.warns(UserWarning, match="_metric_send_conn not set, cannot send metrics"): - self._common(grab_src='/grab/source', expect_grab_src='/grab/source/') + '''Test folder grabbing without a trailing slash.''' + with pytest.warns(UserWarning, match="_metric_send_conn not set, " + "cannot send metrics"): + self._common(grab_src='/grab/source', + expect_grab_src='/grab/source/') def _common(self, grab_src, expect_grab_src): source_root = Path('/workspace/source') @@ -30,9 +41,15 @@ def _common(self, grab_src, expect_grab_src): mock_config = SimpleNamespace(source_root=source_root, tool_box=ToolBox()) + # Since is_available calls run, in order to test a single run call, + # we patch is_available to be always true. with mock.patch('pathlib.Path.mkdir'): with mock.patch('fab.tools.tool.Tool.run') as mock_run: - grab_folder(mock_config, src=grab_src, dst_label=dst) + with mock.patch( + 'fab.tools.tool.Tool.is_available', + new_callable=mock.PropertyMock) as is_available: + is_available.return_value = True + grab_folder(mock_config, src=grab_src, dst_label=dst) expect_dst = mock_config.source_root / dst mock_run.assert_called_once_with( @@ -41,8 +58,10 @@ def _common(self, grab_src, expect_grab_src): class TestGrabFcm: + '''Test FCM functionality.''' def test_no_revision(self): + '''Test FCM without specifying a revision.''' source_root = Path('/workspace/source') source_url = '/www.example.com/bar' dst_label = 'bar' @@ -50,15 +69,23 @@ def test_no_revision(self): mock_config = SimpleNamespace(source_root=source_root, tool_box=ToolBox()) with mock.patch('pathlib.Path.mkdir'): - with mock.patch('fab.tools.tool.Tool.run') as mock_run, \ - pytest.warns(UserWarning, match="_metric_send_conn not set, cannot send metrics"): - fcm_export(config=mock_config, src=source_url, dst_label=dst_label) + with mock.patch('fab.tools.tool.Tool.is_available', + new_callable=mock.PropertyMock) as is_available: + is_available.return_value = True + with mock.patch('fab.tools.tool.Tool.run') as mock_run, \ + pytest.warns(UserWarning, + match="_metric_send_conn not " + "set, cannot send metrics"): + fcm_export(config=mock_config, src=source_url, + dst_label=dst_label) mock_run.assert_called_once_with(['export', '--force', source_url, str(source_root / dst_label)], - env=None, cwd=None, capture_output=True) + env=None, cwd=None, + capture_output=True) def test_revision(self): + '''Test that the revision is passed on correctly in fcm export.''' source_root = Path('/workspace/source') source_url = '/www.example.com/bar' dst_label = 'bar' @@ -67,12 +94,19 @@ def test_revision(self): mock_config = SimpleNamespace(source_root=source_root, tool_box=ToolBox()) with mock.patch('pathlib.Path.mkdir'): - with mock.patch('fab.tools.tool.Tool.run') as mock_run, \ - pytest.warns(UserWarning, match="_metric_send_conn not set, cannot send metrics"): - fcm_export(mock_config, src=source_url, dst_label=dst_label, revision=revision) + with mock.patch('fab.tools.tool.Tool.is_available', + new_callable=mock.PropertyMock) as is_available: + is_available.return_value = True + with mock.patch('fab.tools.tool.Tool.run') as mock_run, \ + pytest.warns( + UserWarning, match="_metric_send_conn not set, " + "cannot send metrics"): + fcm_export(mock_config, src=source_url, + dst_label=dst_label, revision=revision) mock_run.assert_called_once_with( - ['export', '--force', '--revision', '42', f'{source_url}', str(source_root / dst_label)], + ['export', '--force', '--revision', '42', f'{source_url}', + str(source_root / dst_label)], env=None, cwd=None, capture_output=True) # todo: test missing repo diff --git a/tests/unit_tests/tools/test_tool_repository.py b/tests/unit_tests/tools/test_tool_repository.py index e9bfb0c1..32a63d08 100644 --- a/tests/unit_tests/tools/test_tool_repository.py +++ b/tests/unit_tests/tools/test_tool_repository.py @@ -151,17 +151,36 @@ def test_tool_repository_default_compiler_suite(): '''Tests the setting of default suite for compiler and linker.''' tr = ToolRepository() tr.set_default_compiler_suite("gnu") - for cat in [Category.C_COMPILER, Category.FORTRAN_COMPILER, - Category.LINKER]: - def_tool = tr.get_default(cat, mpi=False, openmp=False) - assert def_tool.suite == "gnu" - - tr.set_default_compiler_suite("intel-classic") - for cat in [Category.C_COMPILER, Category.FORTRAN_COMPILER, - Category.LINKER]: - def_tool = tr.get_default(cat, mpi=False, openmp=False) - assert def_tool.suite == "intel-classic" - with pytest.raises(RuntimeError) as err: - tr.set_default_compiler_suite("does-not-exist") - assert ("Cannot find 'FORTRAN_COMPILER' in the suite 'does-not-exist'" - in str(err.value)) + + # Mark all compiler and linker as available. + with mock.patch('fab.tools.tool.Tool.is_available', + new_callable=mock.PropertyMock) as is_available: + is_available.return_value = True + for cat in [Category.C_COMPILER, Category.FORTRAN_COMPILER, + Category.LINKER]: + def_tool = tr.get_default(cat, mpi=False, openmmp=False) + assert def_tool.suite == "gnu" + + tr.set_default_compiler_suite("intel-classic") + for cat in [Category.C_COMPILER, Category.FORTRAN_COMPILER, + Category.LINKER]: + def_tool = tr.get_default(cat, mpi=False, openmp=False) + assert def_tool.suite == "intel-classic" + with pytest.raises(RuntimeError) as err: + tr.set_default_compiler_suite("does-not-exist") + assert ("Cannot find 'FORTRAN_COMPILER' in the suite 'does-not-exist'" + in str(err.value)) + + +def test_tool_repository_no_tool_available(): + '''Tests error handling if no tool is available.''' + + tr = ToolRepository() + tr.set_default_compiler_suite("gnu") + with mock.patch('fab.tools.tool.Tool.is_available', + new_callable=mock.PropertyMock) as is_available: + is_available.return_value = False + with pytest.raises(RuntimeError) as err: + def_tool = tr.get_default(Category.SHELL) + assert ("Can't find available 'SHELL' tool. Tools are 'bash,sh,ksh," + "dash'" in str(err.value)) From ae61d4a9a986a17202271f50f36fadf82bf7b411 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Thu, 21 Nov 2024 17:44:10 +1100 Subject: [PATCH 22/55] Fixed typos and coding style. --- tests/unit_tests/tools/test_tool_repository.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/tools/test_tool_repository.py b/tests/unit_tests/tools/test_tool_repository.py index 32a63d08..54d15e92 100644 --- a/tests/unit_tests/tools/test_tool_repository.py +++ b/tests/unit_tests/tools/test_tool_repository.py @@ -158,7 +158,7 @@ def test_tool_repository_default_compiler_suite(): is_available.return_value = True for cat in [Category.C_COMPILER, Category.FORTRAN_COMPILER, Category.LINKER]: - def_tool = tr.get_default(cat, mpi=False, openmmp=False) + def_tool = tr.get_default(cat, mpi=False, openmp=False) assert def_tool.suite == "gnu" tr.set_default_compiler_suite("intel-classic") @@ -181,6 +181,6 @@ def test_tool_repository_no_tool_available(): new_callable=mock.PropertyMock) as is_available: is_available.return_value = False with pytest.raises(RuntimeError) as err: - def_tool = tr.get_default(Category.SHELL) + tr.get_default(Category.SHELL) assert ("Can't find available 'SHELL' tool. Tools are 'bash,sh,ksh," "dash'" in str(err.value)) From e7c2c8378c936ba594b21154588d075816a59273 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Fri, 27 Sep 2024 01:55:23 +1000 Subject: [PATCH 23/55] Support new and old style of PSyclone command line (no more nemo api etc) --- source/fab/tools/psyclone.py | 129 +++++++++++++- .../psyclone/test_psyclone_system_test.py | 6 +- tests/unit_tests/tools/test_psyclone.py | 163 +++++++++++------- 3 files changed, 229 insertions(+), 69 deletions(-) diff --git a/source/fab/tools/psyclone.py b/source/fab/tools/psyclone.py index 1a2b3b40..1d7fa255 100644 --- a/source/fab/tools/psyclone.py +++ b/source/fab/tools/psyclone.py @@ -8,7 +8,9 @@ """ from pathlib import Path +import re from typing import Callable, List, Optional, TYPE_CHECKING, Union +import warnings from fab.tools.category import Category from fab.tools.tool import Tool @@ -24,15 +26,75 @@ class Psyclone(Tool): '''This is the base class for `PSyclone`. ''' - def __init__(self, api: Optional[str] = None): + def __init__(self): super().__init__("psyclone", "psyclone", Category.PSYCLONE) - self._api = api + self._version = None + + def check_available(self) -> bool: + '''This function determines if PSyclone is available. Additionally, + it established the version, since command line option changes + significantly from python 2.5.0 to the next release. + ''' + + # First get the version (and confirm that PSyclone is installed): + try: + # Older versions of PSyclone (2.3.1 and earlier) expect a filename + # even when --version is used, and won't produce version info + # without this. So provide a dummy file (which does not need to + # exist), and check the error for details to see if PSyclone does + # not exist, or if the error is because of the non-existing file + version_output = self.run(["--version", "does_not_exist"], + capture_output=True) + except RuntimeError as err: + # If the command is not found, the error contains the following: + if "could not be executed" in str(err): + return False + # Otherwise, psyclone likely complained about the not existing + # file. Continue and try to find version information in the output: + version_output = str(err) + + # Search for the version info: + exp = r"PSyclone version: (\d[\d.]+\d)" + print("VERSION [", version_output, "]") + matches = re.search(exp, version_output) + if not matches: + warnings.warn(f"Unexpected version information for PSyclone: " + f"'{version_output}'.") + # If we don't recognise the version number, something is wrong + return False + + # Now convert the version info to integer. The regular expression + # match guarantees that we have integer numbers now: + version = tuple(int(x) for x in matches.groups()[0].split('.')) + + if version == (2, 5, 0): + # The behaviour of PSyclone changes from 2.5.0 to the next + # release. But since head-of-trunk still reports 2.5.0, we + # need to run additional tests to see if we have the official + # 2.5.0 release, or current trunk (which already has the new + # command line options). PSyclone needs an existing file + # in order to work, so use __file__ to present this file. + # PSyclone will obviously abort since this is not a Fortran + # file, but we only need to check the error message to + # see if the domain name is incorrect (--> current trunk) + # or not (2.5.0 release) + try: + self.run(["-api", "nemo", __file__], capture_output=True) + except RuntimeError as err: + if "Unsupported PSyKAL DSL / API 'nemo' specified" in str(err): + # It is current development. Just give it a version number + # greater than 2.5.0 + version = (2, 5, 0, 1) + + self._version = version + return True def process(self, config: "BuildConfig", x90_file: Path, - psy_file: Path, - alg_file: Union[Path, str], + psy_file: Optional[Path] = None, + alg_file: Optional[Union[Path, str]] = None, + transformed_file: Optional[Path] = None, transformation_script: Optional[Callable[[Path, "BuildConfig"], Path]] = None, additional_parameters: Optional[List[str]] = None, @@ -40,29 +102,78 @@ def process(self, api: Optional[str] = None, ): # pylint: disable=too-many-arguments - '''Run PSyclone with the specified parameters. + '''Run PSyclone with the specified parameters. If PSyclone is used to + transform existing Fortran files, `api` must be None, and the output + file name is `transformed_file`. If PSyclone is using its DSL + features, api must be a valid PSyclone API, and the two output + filenames are `psy_file` and `alg_file`. :param api: the PSyclone API. :param x90_file: the input file for PSyclone :param psy_file: the output PSy-layer file. :param alg_file: the output modified algorithm file. + :param transformed_file: the output filename if PSyclone is called + as transformation tool. :param transformation_script: an optional transformation script :param additional_parameters: optional additional parameters for PSyclone :param kernel_roots: optional directories with kernels. ''' + if not self.is_available: + raise RuntimeError("PSyclone is not available.") + + if api: + # 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.") + if not alg_file: + raise RuntimeError(f"PSyclone called with api '{api}', but " + f"no alg_file is specified.") + if transformed_file: + raise RuntimeError(f"PSyclone called with api '{api}' and " + f"transformed_file.") + else: + if psy_file: + raise RuntimeError("PSyclone called without api, but " + "psy_file is specified.") + if alg_file: + raise RuntimeError("PSyclone called without api, but " + "alg_file is specified.") + if not transformed_file: + raise RuntimeError("PSyclone called without api, but " + "transformed_file it not specified.") + parameters: List[Union[str, Path]] = [] # If an api is defined in this call (or in the constructor) add it # as parameter. No API is required if PSyclone works as # transformation tool only, so calling PSyclone without api is # actually valid. + print("API", api, self._version) if api: - parameters.extend(["-api", api]) - elif self._api: - parameters.extend(["-api", self._api]) + if self._version > (2, 5, 0): + api_param = "--psykal-dsl" + # Mapping from old names to new names: + mapping = {"dynamo0.3": "lfric", + "gocean1.0": "gocean"} + else: + api_param = "-api" + # Mapping from new names to old names: + mapping = {"lfric": "dynamo0.3", + "gocean": "gocean1.0"} - parameters.extend(["-l", "all", "-opsy", psy_file, "-oalg", alg_file]) + parameters.extend([api_param, mapping.get(api, api), + "-opsy", psy_file, "-oalg", alg_file]) + else: # no api + if self._version > (2, 5, 0): + # New version: no API, parameter, but -o for output name: + parameters.extend(["-o", transformed_file]) + else: + # 2.5.0 or earlier: needs api nemo, output name is -oalg + parameters.extend(["-api", "nemo", "-opsy", transformed_file]) + parameters.extend(["-l", "all"]) if transformation_script: transformation_script_return_path = \ diff --git a/tests/system_tests/psyclone/test_psyclone_system_test.py b/tests/system_tests/psyclone/test_psyclone_system_test.py index cf3c80d0..3c16fd4a 100644 --- a/tests/system_tests/psyclone/test_psyclone_system_test.py +++ b/tests/system_tests/psyclone/test_psyclone_system_test.py @@ -199,6 +199,9 @@ class TestTransformationScript: """ def test_transformation_script(self, psyclone_lfric_api): psyclone_tool = Psyclone() + psyclone_tool._version = (2, 4, 0) + psyclone_tool._is_available = True + mock_transformation_script = mock.Mock(return_value=__file__) with mock.patch('fab.tools.psyclone.Psyclone.run') as mock_run_command: mock_transformation_script.return_value = Path(__file__) @@ -216,8 +219,9 @@ def test_transformation_script(self, psyclone_lfric_api): mock_transformation_script.assert_called_once_with(Path(__file__), None) # check transformation_script is passed to psyclone command with '-s' mock_run_command.assert_called_with( - additional_parameters=['-api', psyclone_lfric_api, '-l', 'all', + additional_parameters=['-api', psyclone_lfric_api, '-opsy', Path(__file__), '-oalg', Path(__file__), + '-l', 'all', '-s', Path(__file__), __file__]) diff --git a/tests/unit_tests/tools/test_psyclone.py b/tests/unit_tests/tools/test_psyclone.py index 7efc60ec..619c0260 100644 --- a/tests/unit_tests/tools/test_psyclone.py +++ b/tests/unit_tests/tools/test_psyclone.py @@ -10,9 +10,26 @@ from importlib import reload from unittest import mock +import pytest + from fab.tools import (Category, Psyclone) +def get_mock_result(version_info: str) -> mock.Mock: + '''Returns a mock PSyclone object that will return + the specified str as version info. + + :param version_info: the simulated output of psyclone --version + The leading "PSyclone version: " will be added automatically. + ''' + # The return of subprocess run has an attribute 'stdout', + # that returns the stdout when its `decode` method is called. + # So we mock stdout, then put this mock_stdout into the mock result: + mock_stdout = mock.Mock(decode=lambda: f"PSyclone version: {version_info}") + mock_result = mock.Mock(stdout=mock_stdout, returncode=0) + return mock_result + + def test_psyclone_constructor(): '''Test the PSyclone constructor.''' psyclone = Psyclone() @@ -20,45 +37,102 @@ def test_psyclone_constructor(): assert psyclone.name == "psyclone" assert psyclone.exec_name == "psyclone" assert psyclone.flags == [] - assert psyclone._api is None - psyclone = Psyclone(api="gocean1.0") - assert psyclone.category == Category.PSYCLONE - assert psyclone.name == "psyclone" - assert psyclone.exec_name == "psyclone" - assert psyclone.flags == [] - assert psyclone._api == "gocean1.0" - -def test_psyclone_check_available(): - '''Tests the is_available functionality.''' +def test_psyclone_check_available_2_4_0(): + '''Tests the is_available functionality with version 2.4.0. + We get only one call. + ''' psyclone = Psyclone() - mock_result = mock.Mock(returncode=0) + + mock_result = get_mock_result("2.4.0") with mock.patch('fab.tools.tool.subprocess.run', return_value=mock_result) as tool_run: assert psyclone.check_available() tool_run.assert_called_once_with( - ["psyclone", "--version"], capture_output=True, env=None, - cwd=None, check=False) + ["psyclone", "--version", mock.ANY], capture_output=True, + env=None, cwd=None, check=False) + + +def test_psyclone_check_available_2_5_0(): + '''Tests the is_available functionality with PSyclone 2.5.0. + We get two calls. First version, then check if nemo API exists + ''' + psyclone = Psyclone() + + mock_result = get_mock_result("2.5.0") + with mock.patch('fab.tools.tool.subprocess.run', + return_value=mock_result) as tool_run: + assert psyclone.check_available() + tool_run.assert_any_call( + ["psyclone", "--version", mock.ANY], capture_output=True, + env=None, cwd=None, check=False) + tool_run.assert_any_call( + ["psyclone", "-api", "nemo", mock.ANY], 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: + with pytest.warns(UserWarning, + match="Unexpected version information " + "for PSyclone: ''."): + assert not psyclone.check_available() + + +def test_psyclone_check_available_after_2_5_0(): + '''Tests the is_available functionality with releases after 2.5.0. + We get two calls. First version, then check if nemo API exists + ''' + psyclone = Psyclone() + + # We detect the dummy version '2.5.0.1' if psyclone reports 2.5.0 + # but the command line option "-api nemo" is not accepted. + # So we need to return two results from our mock objects: first + # success for version 2.5.0, then a failure with an appropriate + # error message: + mock_result1 = get_mock_result("2.5.0") + mock_result2 = get_mock_result("Unsupported PSyKAL DSL / " + "API 'nemo' specified") + mock_result2.returncode = 1 + + # "Unsupported PSyKAL DSL / API 'nemo' specified" + with mock.patch('fab.tools.tool.subprocess.run', + return_value=mock_result1) as tool_run: + tool_run.side_effect = [mock_result1, mock_result2] + assert psyclone.check_available() + assert psyclone._version == (2, 5, 0, 1) + + +def test_psyclone_check_available_errors(): + '''Test various errors that can happen in check_available. + ''' + psyclone = Psyclone() + with mock.patch('fab.tools.tool.subprocess.run', + side_effect=FileNotFoundError("ERR")): assert not psyclone.check_available() + psyclone = Psyclone() + mock_result = get_mock_result("NOT_A_NUMBER.4.0") + with mock.patch('fab.tools.tool.subprocess.run', + return_value=mock_result): + with pytest.warns(UserWarning, + match="Unexpected version information for PSyclone: " + "'PSyclone version: NOT_A_NUMBER.4.0'"): + assert not psyclone.check_available() -def test_psyclone_process(psyclone_lfric_api): + +@pytest.mark.parametrize("api", ["dynamo0.3", "lfric"]) +def test_psyclone_process_api_2_4_0(api): '''Test running PSyclone.''' psyclone = Psyclone() - mock_result = mock.Mock(returncode=0) - # Create a mock function that returns a 'transformation script' - # called `script_called`: + mock_result = get_mock_result("2.4.0") transformation_function = mock.Mock(return_value="script_called") config = mock.Mock() with mock.patch('fab.tools.tool.subprocess.run', return_value=mock_result) as tool_run: psyclone.process(config=config, - api=psyclone_lfric_api, + api=api, x90_file="x90_file", psy_file="psy_file", alg_file="alg_file", @@ -66,60 +140,31 @@ def test_psyclone_process(psyclone_lfric_api): kernel_roots=["root1", "root2"], additional_parameters=["-c", "psyclone.cfg"]) tool_run.assert_called_with( - ['psyclone', '-api', psyclone_lfric_api, '-l', 'all', '-opsy', - 'psy_file', '-oalg', 'alg_file', '-s', 'script_called', '-c', + ['psyclone', '-api', 'dynamo0.3', '-opsy', 'psy_file', + '-oalg', 'alg_file', '-l', 'all', '-s', 'script_called', '-c', 'psyclone.cfg', '-d', 'root1', '-d', 'root2', 'x90_file'], capture_output=True, env=None, cwd=None, check=False) - # Don't specify an API: - with mock.patch('fab.tools.tool.subprocess.run', - return_value=mock_result) as tool_run: - psyclone.process(config=config, - x90_file="x90_file", - psy_file="psy_file", - alg_file="alg_file", - transformation_script=transformation_function, - kernel_roots=["root1", "root2"], - additional_parameters=["-c", "psyclone.cfg"]) - tool_run.assert_called_with( - ['psyclone', '-l', 'all', '-opsy', '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) - # Don't specify an API, but define an API on the PSyclone tool: - psyclone = Psyclone(api="gocean1.0") - with mock.patch('fab.tools.tool.subprocess.run', - return_value=mock_result) as tool_run: - psyclone.process(config=config, - x90_file="x90_file", - psy_file="psy_file", - alg_file="alg_file", - transformation_script=transformation_function, - kernel_roots=["root1", "root2"], - additional_parameters=["-c", "psyclone.cfg"]) - tool_run.assert_called_with( - ['psyclone', '-api', 'gocean1.0', '-l', 'all', '-opsy', '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_psyclone_process_no_api_2_4_0(): + '''Test running PSyclone.''' + psyclone = Psyclone() + mock_result = get_mock_result("2.4.0") + transformation_function = mock.Mock(return_value="script_called") + config = mock.Mock() - # Have both a default and a command line option - the latter - # must take precedence: - psyclone = Psyclone(api="gocean1.0") with mock.patch('fab.tools.tool.subprocess.run', return_value=mock_result) as tool_run: psyclone.process(config=config, + api="", x90_file="x90_file", - psy_file="psy_file", - alg_file="alg_file", - api=psyclone_lfric_api, + transformed_file="psy_file", transformation_script=transformation_function, kernel_roots=["root1", "root2"], additional_parameters=["-c", "psyclone.cfg"]) tool_run.assert_called_with( - ['psyclone', '-api', psyclone_lfric_api, '-l', 'all', '-opsy', - 'psy_file', '-oalg', 'alg_file', '-s', 'script_called', '-c', + ['psyclone', '-api', 'nemo', '-opsy', 'psy_file', '-l', 'all', + '-s', 'script_called', '-c', 'psyclone.cfg', '-d', 'root1', '-d', 'root2', 'x90_file'], capture_output=True, env=None, cwd=None, check=False) From e2051f2ba058f85be480f19dd2fc40262930df73 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Fri, 27 Sep 2024 02:00:29 +1000 Subject: [PATCH 24/55] Fix mypy errors. --- source/fab/tools/psyclone.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/source/fab/tools/psyclone.py b/source/fab/tools/psyclone.py index 1d7fa255..424b3907 100644 --- a/source/fab/tools/psyclone.py +++ b/source/fab/tools/psyclone.py @@ -55,7 +55,6 @@ def check_available(self) -> bool: # Search for the version info: exp = r"PSyclone version: (\d[\d.]+\d)" - print("VERSION [", version_output, "]") matches = re.search(exp, version_output) if not matches: warnings.warn(f"Unexpected version information for PSyclone: " @@ -151,7 +150,6 @@ def process(self, # as parameter. No API is required if PSyclone works as # transformation tool only, so calling PSyclone without api is # actually valid. - print("API", api, self._version) if api: if self._version > (2, 5, 0): api_param = "--psykal-dsl" @@ -163,10 +161,16 @@ def process(self, # Mapping from new names to old names: mapping = {"lfric": "dynamo0.3", "gocean": "gocean1.0"} - + # Make mypy happy - we tested above that these variables + # are defined + assert psy_file + assert alg_file parameters.extend([api_param, mapping.get(api, api), "-opsy", psy_file, "-oalg", alg_file]) else: # no api + # Make mypy happy - we tested above that transformed_file is + # specified when no api is specified. + assert transformed_file if self._version > (2, 5, 0): # New version: no API, parameter, but -o for output name: parameters.extend(["-o", transformed_file]) From 0ad85ee1401c78e5ee2347065dfb337bce3676f7 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Mon, 30 Sep 2024 12:02:07 +1000 Subject: [PATCH 25/55] Added missing tests for calling psyclone, and converting old style to new stle arguments and vice versa. --- source/fab/tools/psyclone.py | 8 +- tests/unit_tests/tools/test_psyclone.py | 198 +++++++++++++++++++++++- 2 files changed, 196 insertions(+), 10 deletions(-) diff --git a/source/fab/tools/psyclone.py b/source/fab/tools/psyclone.py index 424b3907..fa508d7a 100644 --- a/source/fab/tools/psyclone.py +++ b/source/fab/tools/psyclone.py @@ -100,7 +100,7 @@ def process(self, kernel_roots: Optional[List[Union[str, Path]]] = None, api: Optional[str] = None, ): - # pylint: disable=too-many-arguments + # pylint: disable=too-many-arguments, too-many-branches '''Run PSyclone with the specified parameters. If PSyclone is used to transform existing Fortran files, `api` must be None, and the output file name is `transformed_file`. If PSyclone is using its DSL @@ -122,6 +122,10 @@ def process(self, if not self.is_available: raise RuntimeError("PSyclone is not available.") + # Convert the old style API nemo to be empty + if api and api.lower() == "nemo": + api = "" + if api: # API specified, we need both psy- and alg-file, but not # transformed file. @@ -143,7 +147,7 @@ def process(self, "alg_file is specified.") if not transformed_file: raise RuntimeError("PSyclone called without api, but " - "transformed_file it not specified.") + "transformed_file is not specified.") parameters: List[Union[str, Path]] = [] # If an api is defined in this call (or in the constructor) add it diff --git a/tests/unit_tests/tools/test_psyclone.py b/tests/unit_tests/tools/test_psyclone.py index 619c0260..5586c485 100644 --- a/tests/unit_tests/tools/test_psyclone.py +++ b/tests/unit_tests/tools/test_psyclone.py @@ -36,6 +36,7 @@ def test_psyclone_constructor(): assert psyclone.category == Category.PSYCLONE assert psyclone.name == "psyclone" assert psyclone.exec_name == "psyclone" + # pylint: disable=use-implicit-booleaness-not-comparison assert psyclone.flags == [] @@ -120,19 +121,82 @@ def test_psyclone_check_available_errors(): match="Unexpected version information for PSyclone: " "'PSyclone version: NOT_A_NUMBER.4.0'"): assert not psyclone.check_available() + # Also check that we can't call process if PSyclone is not available. + psyclone._is_available = False + config = mock.Mock() + with pytest.raises(RuntimeError) as err: + psyclone.process(config, "x90file") + assert "PSyclone is not available" in str(err.value) + + +def test_psyclone_processing_errors_without_api(): + '''Test all processing errors in PSyclone if no API is specified.''' + + psyclone = Psyclone() + psyclone._is_available = True + config = mock.Mock() + + # No API --> we need transformed file, but not psy or alg: + with pytest.raises(RuntimeError) as err: + psyclone.process(config, "x90file", api=None, psy_file="psy_file") + assert ("PSyclone called without api, but psy_file is specified" + in str(err.value)) + with pytest.raises(RuntimeError) as err: + psyclone.process(config, "x90file", api=None, alg_file="alg_file") + assert ("PSyclone called without api, but alg_file is specified" + in str(err.value)) + with pytest.raises(RuntimeError) as err: + psyclone.process(config, "x90file", api=None) + assert ("PSyclone called without api, but transformed_file is not " + "specified" in str(err.value)) @pytest.mark.parametrize("api", ["dynamo0.3", "lfric"]) -def test_psyclone_process_api_2_4_0(api): - '''Test running PSyclone.''' +def test_psyclone_processing_errors_with_api(api): + '''Test all processing errors in PSyclone if an API is specified.''' + psyclone = Psyclone() - mock_result = get_mock_result("2.4.0") + psyclone._is_available = True + config = mock.Mock() + + # No API --> we need transformed file, but not psy or alg: + with pytest.raises(RuntimeError) as err: + psyclone.process(config, "x90file", api=api, psy_file="psy_file") + assert (f"PSyclone called with api '{api}', but no alg_file is specified" + in str(err.value)) + with pytest.raises(RuntimeError) as err: + psyclone.process(config, "x90file", api=api, alg_file="alg_file") + assert (f"PSyclone called with api '{api}', but no psy_file is specified" + in str(err.value)) + with pytest.raises(RuntimeError) as err: + psyclone.process(config, "x90file", api=api, + psy_file="psy_file", alg_file="alg_file", + transformed_file="transformed_file") + assert (f"PSyclone called with api '{api}' and transformed_file" + in str(err.value)) + + +@pytest.mark.parametrize("version", ["2.4.0", "2.5.0"]) +@pytest.mark.parametrize("api", [("dynamo0.3", "dynamo0.3"), + ("lfric", "dynamo0.3"), + ("gocean1.0", "gocean1.0"), + ("gocean", "gocean1.0") + ]) +def test_psyclone_process_api_old_psyclone(api, version): + '''Test running 'old style' PSyclone (2.5.0 and earlier) with the old API + names (dynamo0.3 and gocean1.0). Also check that the new API names will + be accepted, but are mapped to the old style names. The 'api' parameter + contains the input api, and expected output API. + ''' + api_in, api_out = api + psyclone = Psyclone() + mock_result = get_mock_result(version) transformation_function = mock.Mock(return_value="script_called") config = mock.Mock() with mock.patch('fab.tools.tool.subprocess.run', return_value=mock_result) as tool_run: psyclone.process(config=config, - api=api, + api=api_in, x90_file="x90_file", psy_file="psy_file", alg_file="alg_file", @@ -140,16 +204,20 @@ def test_psyclone_process_api_2_4_0(api): kernel_roots=["root1", "root2"], additional_parameters=["-c", "psyclone.cfg"]) tool_run.assert_called_with( - ['psyclone', '-api', 'dynamo0.3', '-opsy', 'psy_file', + ['psyclone', '-api', api_out, '-opsy', 'psy_file', '-oalg', 'alg_file', '-l', 'all', '-s', 'script_called', '-c', 'psyclone.cfg', '-d', 'root1', '-d', 'root2', 'x90_file'], capture_output=True, env=None, cwd=None, check=False) -def test_psyclone_process_no_api_2_4_0(): - '''Test running PSyclone.''' +@pytest.mark.parametrize("version", ["2.4.0", "2.5.0"]) +def test_psyclone_process_no_api_old_psyclone(version): + '''Test running old-style PSyclone (2.5.0 and earlier) when requesting + to transform existing files by not specifying an API. We need to add + the flags `-api nemo` in this case for older PSyclone versions. + ''' psyclone = Psyclone() - mock_result = get_mock_result("2.4.0") + mock_result = get_mock_result(version) transformation_function = mock.Mock(return_value="script_called") config = mock.Mock() @@ -169,6 +237,119 @@ def test_psyclone_process_no_api_2_4_0(): capture_output=True, env=None, cwd=None, check=False) +@pytest.mark.parametrize("version", ["2.4.0", "2.5.0"]) +def test_psyclone_process_nemo_api_old_psyclone(version): + '''Test running old-style PSyclone (2.5.0 and earlier) when requesting + to transform existing files by specifying the nemo api. + ''' + + psyclone = Psyclone() + mock_result = get_mock_result(version) + transformation_function = mock.Mock(return_value="script_called") + config = mock.Mock() + + with mock.patch('fab.tools.tool.subprocess.run', + return_value=mock_result) as tool_run: + psyclone.process(config=config, + api="nemo", + x90_file="x90_file", + transformed_file="psy_file", + transformation_script=transformation_function, + kernel_roots=["root1", "root2"], + additional_parameters=["-c", "psyclone.cfg"]) + tool_run.assert_called_with( + ['psyclone', '-api', 'nemo', '-opsy', 'psy_file', '-l', 'all', + '-s', 'script_called', '-c', + 'psyclone.cfg', '-d', 'root1', '-d', 'root2', 'x90_file'], + capture_output=True, env=None, cwd=None, check=False) + + +@pytest.mark.parametrize("api", [("dynamo0.3", "lfric"), + ("lfric", "lfric"), + ("gocean1.0", "gocean"), + ("gocean", "gocean") + ]) +def test_psyclone_process_api_new__psyclone(api): + '''Test running the new PSyclone version. Since this version is not + yet released, we use the Fab internal version number 2.5.0.1 for + now. It uses new API names, and we need to check that the old style + names are converted to the new names. + ''' + api_in, api_out = api + psyclone = Psyclone() + mock_result = get_mock_result("2.5.0.1") + transformation_function = mock.Mock(return_value="script_called") + config = mock.Mock() + with mock.patch('fab.tools.tool.subprocess.run', + return_value=mock_result) as tool_run: + psyclone.process(config=config, + api=api_in, + x90_file="x90_file", + psy_file="psy_file", + alg_file="alg_file", + transformation_script=transformation_function, + kernel_roots=["root1", "root2"], + additional_parameters=["-c", "psyclone.cfg"]) + tool_run.assert_called_with( + ['psyclone', '--psykal-dsl', api_out, '-opsy', 'psy_file', + '-oalg', 'alg_file', '-l', 'all', '-s', 'script_called', '-c', + 'psyclone.cfg', '-d', 'root1', '-d', 'root2', 'x90_file'], + capture_output=True, env=None, cwd=None, check=False) + + +def test_psyclone_process_no_api_new_psyclone(): + '''Test running the new PSyclone version without an API. Since this + version is not yet released, we use the Fab internal version number + 2.5.0.1 for now. + ''' + psyclone = Psyclone() + mock_result = get_mock_result("2.5.0.1") + transformation_function = mock.Mock(return_value="script_called") + config = mock.Mock() + + with mock.patch('fab.tools.tool.subprocess.run', + return_value=mock_result) as tool_run: + psyclone.process(config=config, + api="", + x90_file="x90_file", + transformed_file="psy_file", + transformation_script=transformation_function, + kernel_roots=["root1", "root2"], + additional_parameters=["-c", "psyclone.cfg"]) + tool_run.assert_called_with( + ['psyclone', '-o', 'psy_file', '-l', 'all', + '-s', 'script_called', '-c', + 'psyclone.cfg', '-d', 'root1', '-d', 'root2', 'x90_file'], + capture_output=True, env=None, cwd=None, check=False) + + +def test_psyclone_process_nemo_api_new_psyclone(): + '''Test running PSyclone. Since this version is not yet released, we use + the Fab internal version number 2.5.0.1 for now. This tests that + backwards compatibility of using the nemo api works, i.e. '-api nemo' is + just removed. + ''' + psyclone = Psyclone() + mock_result = get_mock_result("2.5.0.1") + transformation_function = mock.Mock(return_value="script_called") + config = mock.Mock() + + with mock.patch('fab.tools.tool.subprocess.run', + return_value=mock_result) as tool_run: + psyclone.process(config=config, + api="nemo", + x90_file="x90_file", + transformed_file="psy_file", + transformation_script=transformation_function, + kernel_roots=["root1", "root2"], + additional_parameters=["-c", "psyclone.cfg"]) + tool_run.assert_called_with( + ['psyclone', '-o', 'psy_file', '-l', 'all', + '-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 @@ -178,5 +359,6 @@ def test_type_checking_import(): 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: + # pylint: disable=import-outside-toplevel import fab.tools.psyclone reload(fab.tools.psyclone) From 890b50dde7e9e3b6154137f5e98abffaa7ee5765 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Mon, 30 Sep 2024 13:52:05 +1000 Subject: [PATCH 26/55] Updated comment. --- source/fab/tools/psyclone.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/fab/tools/psyclone.py b/source/fab/tools/psyclone.py index fa508d7a..cbf12a9f 100644 --- a/source/fab/tools/psyclone.py +++ b/source/fab/tools/psyclone.py @@ -82,7 +82,8 @@ def check_available(self) -> bool: except RuntimeError as err: if "Unsupported PSyKAL DSL / API 'nemo' specified" in str(err): # It is current development. Just give it a version number - # greater than 2.5.0 + # greater than 2.5.0 for now, till the official release + # is done. version = (2, 5, 0, 1) self._version = version From 032ab2651337d6456ba84ae9e7f92b670001c46d Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Fri, 22 Nov 2024 00:42:48 +1100 Subject: [PATCH 27/55] Fixed failing tests. --- tests/unit_tests/tools/test_compiler.py | 13 +++++++------ tests/unit_tests/tools/test_tool_repository.py | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/unit_tests/tools/test_compiler.py b/tests/unit_tests/tools/test_compiler.py index 70c16da0..f6c7c158 100644 --- a/tests/unit_tests/tools/test_compiler.py +++ b/tests/unit_tests/tools/test_compiler.py @@ -47,26 +47,27 @@ def test_compiler(): def test_compiler_openmp(): '''Test that the openmp flag is correctly reflected in the test if a compiler supports OpenMP or not.''' - cc = CCompiler("gcc", "gcc", "gnu", openmp_flag="-fopenmp") + cc = CCompiler("gcc", "gcc", "gnu", openmp_flag="-fopenmp", + version_regex=None) assert cc.openmp_flag == "-fopenmp" assert cc.openmp - cc = CCompiler("gcc", "gcc", "gnu", openmp_flag=None) + cc = CCompiler("gcc", "gcc", "gnu", openmp_flag=None, version_regex=None) assert cc.openmp_flag == "" assert not cc.openmp - cc = CCompiler("gcc", "gcc", "gnu") + cc = CCompiler("gcc", "gcc", "gnu", version_regex=None) assert cc.openmp_flag == "" assert not cc.openmp fc = FortranCompiler("gfortran", "gfortran", "gnu", openmp_flag="-fopenmp", - module_folder_flag="-J") + module_folder_flag="-J", version_regex=None) assert fc.openmp_flag == "-fopenmp" assert fc.openmp fc = FortranCompiler("gfortran", "gfortran", "gnu", openmp_flag=None, - module_folder_flag="-J") + module_folder_flag="-J", version_regex=None) assert fc.openmp_flag == "" assert not fc.openmp fc = FortranCompiler("gfortran", "gfortran", "gnu", - module_folder_flag="-J") + module_folder_flag="-J", version_regex=None) assert fc.openmp_flag == "" assert not fc.openmp diff --git a/tests/unit_tests/tools/test_tool_repository.py b/tests/unit_tests/tools/test_tool_repository.py index 8369668e..e9bfb0c1 100644 --- a/tests/unit_tests/tools/test_tool_repository.py +++ b/tests/unit_tests/tools/test_tool_repository.py @@ -137,7 +137,7 @@ def test_tool_repository_get_default_error_missing_openmp_compiler(): ToolRepository.''' tr = ToolRepository() fc = FortranCompiler("gfortran", "gfortran", "gnu", openmp_flag=None, - module_folder_flag="-J") + module_folder_flag="-J", version_regex=None) with mock.patch.dict(tr, {Category.FORTRAN_COMPILER: [fc]}), \ pytest.raises(RuntimeError) as err: From 8753d0c9e1e649ab8e3993705fadf5f08ffa4e9e Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Thu, 28 Nov 2024 14:45:09 +1100 Subject: [PATCH 28/55] Updated fparser dependency to version 0.2. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bd6bdb74..26f67d53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ authors = [ license = {file = 'LICENSE.txt'} dynamic = ['version', 'readme'] requires-python = '>=3.7, <4' -dependencies = ['fparser'] +dependencies = ['fparser >= 0.2'] classifiers = [ 'Development Status :: 1 - Planning', 'Environment :: Console', From 634d28ca7e0b648cad98cca596e4636fe9715e6f Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Fri, 29 Nov 2024 16:25:21 +1100 Subject: [PATCH 29/55] Replace old code for handling sentinels with triggering this behaviour in fparser. Require config in constructor of Analyser classes. --- source/fab/parse/fortran.py | 41 ++++++++---------------------- source/fab/parse/fortran_common.py | 26 +++++++++++++------ source/fab/parse/x90.py | 5 ++-- source/fab/steps/analyse.py | 4 ++- source/fab/steps/psyclone.py | 6 ++--- 5 files changed, 37 insertions(+), 45 deletions(-) diff --git a/source/fab/parse/fortran.py b/source/fab/parse/fortran.py index c5ea7f60..0f753f56 100644 --- a/source/fab/parse/fortran.py +++ b/source/fab/parse/fortran.py @@ -11,7 +11,6 @@ from pathlib import Path from typing import Union, Optional, Iterable, Dict, Any, Set -from fparser.common.readfortran import FortranStringReader # type: ignore from fparser.two.Fortran2003 import ( # type: ignore Entity_Decl_List, Use_Stmt, Module_Stmt, Program_Stmt, Subroutine_Stmt, Function_Stmt, Language_Binding_Spec, Char_Literal_Constant, Interface_Block, Name, Comment, Module, Call_Stmt, Derived_Type_Def, Derived_Type_Stmt, @@ -21,6 +20,7 @@ from fparser.two.Fortran2008 import ( # type: ignore Type_Declaration_Stmt, Attr_Spec_List) +from fab.build_config import BuildConfig from fab.dep_tree import AnalysedDependent from fab.parse.fortran_common import iter_content, _has_ancestor_type, _typed_child, FortranAnalyserBase from fab.util import file_checksum, string_checksum @@ -167,15 +167,21 @@ class FortranAnalyser(FortranAnalyserBase): A build step which analyses a fortran file using fparser2, creating an :class:`~fab.dep_tree.AnalysedFortran`. """ - def __init__(self, std=None, ignore_mod_deps: Optional[Iterable[str]] = None): + def __init__(self, + config: BuildConfig, + std: Optional[str]=None, + ignore_mod_deps: Optional[Iterable[str]] = None): """ + :param config: The BuildConfig to use. :param std: The Fortran standard. :param ignore_mod_deps: Module names to ignore in use statements. """ - super().__init__(result_class=AnalysedFortran, std=std) + super().__init__(config=config, + result_class=AnalysedFortran, + std=std) self.ignore_mod_deps: Iterable[str] = list(ignore_mod_deps or []) self.depends_on_comment_found = False @@ -295,33 +301,6 @@ def _process_comment(self, analysed_file, obj): # without .o means a fortran symbol else: analysed_file.add_symbol_dep(dep) - if comment[:2] == "!$": - # Check if it is a use statement with an OpenMP sentinel: - # Use fparser's string reader to discard potential comment - # TODO #327: once fparser supports reading the sentinels, - # this can be removed. - # fparser issue: https://github.com/stfc/fparser/issues/443 - reader = FortranStringReader(comment[2:]) - try: - line = reader.next() - except StopIteration: - # No other item, ignore - return - try: - # match returns a 5-tuple, the third one being the module name - module_name = Use_Stmt.match(line.strline)[2] - module_name = module_name.string - except Exception: - # Not a use statement in a sentinel, ignore: - return - - # Register the module name - if module_name in self.ignore_mod_deps: - logger.debug(f"ignoring use of {module_name}") - return - if module_name.lower() not in self._intrinsic_modules: - # found a dependency on fortran - analysed_file.add_module_dep(module_name) def _process_subroutine_or_function(self, analysed_file, fpath, obj): # binding? @@ -353,7 +332,7 @@ def _process_subroutine_or_function(self, analysed_file, fpath, obj): analysed_file.add_symbol_def(name.string) -class FortranParserWorkaround(object): +class FortranParserWorkaround(): """ Use this class to create a workaround when the third-party Fortran parser is unable to process a valid source file. diff --git a/source/fab/parse/fortran_common.py b/source/fab/parse/fortran_common.py index 0ed4f3fe..a184eb41 100644 --- a/source/fab/parse/fortran_common.py +++ b/source/fab/parse/fortran_common.py @@ -10,13 +10,14 @@ import logging from abc import ABC, abstractmethod from pathlib import Path -from typing import Union, Tuple, Type +from typing import Optional, Tuple, Type, Union from fparser.common.readfortran import FortranFileReader # type: ignore from fparser.two.parser import ParserFactory # type: ignore from fparser.two.utils import FortranSyntaxError # type: ignore from fab import FabException +from fab.build_config import BuildConfig from fab.dep_tree import AnalysedDependent from fab.parse import EmptySourceFile from fab.util import log_or_dot, file_checksum @@ -77,21 +78,26 @@ class FortranAnalyserBase(ABC): """ _intrinsic_modules = ['iso_fortran_env', 'iso_c_binding'] - def __init__(self, result_class, std=None): + def __init__(self, config: BuildConfig, + result_class, + std: Optional[str] = None): """ + :param config: The BuildConfig object. :param result_class: The type (class) of the analysis result. Defined by the subclass. :param std: The Fortran standard. """ + self._config = config self.result_class = result_class self.f2008_parser = ParserFactory().create(std=std or "f2008") - # todo: this, and perhaps other runtime variables like it, might be better set at construction - # if we construct these objects at runtime instead... - # runtime, for child processes to read - self._config = None + @property + def config(self) -> BuildConfig: + '''Returns the BuildConfig to use. + ''' + return self._config def run(self, fpath: Path) \ -> Union[Tuple[AnalysedDependent, Path], Tuple[EmptySourceFile, None], Tuple[Exception, None]]: @@ -142,8 +148,12 @@ def _get_analysis_fpath(self, fpath, file_hash) -> Path: def _parse_file(self, fpath): """Get a node tree from a fortran file.""" - reader = FortranFileReader(str(fpath), ignore_comments=False) - reader.exit_on_error = False # don't call sys.exit, it messes up the multi-processing + reader = FortranFileReader( + str(fpath), + ignore_comments=False, + include_omp_conditional_lines=self.config.openmp) + # don't call sys.exit, it messes up the multi-processing + reader.exit_on_error = False try: tree = self.f2008_parser(reader) diff --git a/source/fab/parse/x90.py b/source/fab/parse/x90.py index 902c01fe..09d51718 100644 --- a/source/fab/parse/x90.py +++ b/source/fab/parse/x90.py @@ -9,6 +9,7 @@ from fparser.two.Fortran2003 import Use_Stmt, Call_Stmt, Name, Only_List, Actual_Arg_Spec_List, Part_Ref # type: ignore from fab.parse import AnalysedFile +from fab.build_config import BuildConfig from fab.parse.fortran_common import FortranAnalyserBase, iter_content, logger, _typed_child from fab.util import by_type @@ -64,8 +65,8 @@ class X90Analyser(FortranAnalyserBase): # Makes a parsable fortran version of x90. # todo: Use hashing to reuse previous analysis results. - def __init__(self): - super().__init__(result_class=AnalysedX90) + def __init__(self, config: BuildConfig): + super().__init__(config=config, result_class=AnalysedX90) def walk_nodes(self, fpath, file_hash, node_tree) -> AnalysedX90: # type: ignore diff --git a/source/fab/steps/analyse.py b/source/fab/steps/analyse.py index 1b739e56..1e7c3a58 100644 --- a/source/fab/steps/analyse.py +++ b/source/fab/steps/analyse.py @@ -130,7 +130,9 @@ def analyse( unreferenced_deps = list(unreferenced_deps or []) # todo: these seem more like functions - fortran_analyser = FortranAnalyser(std=std, ignore_mod_deps=ignore_mod_deps) + fortran_analyser = FortranAnalyser(config=config, + std=std, + ignore_mod_deps=ignore_mod_deps) c_analyser = CAnalyser() # Creates the *build_trees* artefact from the files in `self.source_getter`. diff --git a/source/fab/steps/psyclone.py b/source/fab/steps/psyclone.py index 04c1cc27..bb05e291 100644 --- a/source/fab/steps/psyclone.py +++ b/source/fab/steps/psyclone.py @@ -192,7 +192,7 @@ def _analyse_x90s(config, x90s: Set[Path]) -> Dict[Path, AnalysedX90]: parsable_x90s = run_mp(config, items=x90s, func=make_parsable_x90) # parse - x90_analyser = X90Analyser() + x90_analyser = X90Analyser(config=config) x90_analyser._config = config with TimerLogger(f"analysing {len(parsable_x90s)} parsable x90 files"): x90_results = run_mp(config, items=parsable_x90s, func=x90_analyser.run) @@ -209,7 +209,7 @@ def _analyse_x90s(config, x90s: Set[Path]) -> Dict[Path, AnalysedX90]: analysed_x90 = {result.fpath.with_suffix('.x90'): result for result in analysed_x90} # make the hashes from the original x90s, not the parsable versions which have invoke names removed. - for p, r in analysed_x90.items(): + for p, _ in analysed_x90.items(): analysed_x90[p]._file_hash = file_checksum(p).file_hash return analysed_x90 @@ -249,7 +249,7 @@ def _analyse_kernels(config, kernel_roots) -> Dict[str, int]: # We use the normal Fortran analyser, which records psyclone kernel metadata. # todo: We'd like to separate that from the general fortran analyser at some point, to reduce coupling. # The Analyse step also uses the same fortran analyser. It stores its results so they won't be analysed twice. - fortran_analyser = FortranAnalyser() + fortran_analyser = FortranAnalyser(config=config) fortran_analyser._config = config with TimerLogger(f"analysing {len(kernel_files)} potential psyclone kernel files"): fortran_results = run_mp(config, items=kernel_files, func=fortran_analyser.run) From 78697bf4df6482857e67c9cac10fcc94aa4ade27 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Fri, 29 Nov 2024 16:26:47 +1100 Subject: [PATCH 30/55] Fixed tests for latest changes. --- tests/system_tests/psyclone/test_psyclone_system_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/system_tests/psyclone/test_psyclone_system_test.py b/tests/system_tests/psyclone/test_psyclone_system_test.py index 3c16fd4a..adc35315 100644 --- a/tests/system_tests/psyclone/test_psyclone_system_test.py +++ b/tests/system_tests/psyclone/test_psyclone_system_test.py @@ -48,8 +48,8 @@ def test_make_parsable_x90(tmp_path): parsable_x90_path = make_parsable_x90(input_x90_path) - x90_analyser = X90Analyser() with BuildConfig('proj', ToolBox(), fab_workspace=tmp_path) as config: + x90_analyser = X90Analyser(config=config) x90_analyser._config = config # todo: code smell x90_analyser.run(parsable_x90_path) @@ -72,8 +72,8 @@ class TestX90Analyser: def run(self, tmp_path): parsable_x90_path = self.expected_analysis_result.fpath - x90_analyser = X90Analyser() with BuildConfig('proj', ToolBox(), fab_workspace=tmp_path) as config: + x90_analyser = X90Analyser(config=config) x90_analyser._config = config analysed_x90, _ = x90_analyser.run(parsable_x90_path) # type: ignore # don't delete the prebuild From c82cedf24050e7a00007d11148de60370387a32f Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Fri, 29 Nov 2024 16:27:32 +1100 Subject: [PATCH 31/55] Removed invalid openmp continuation line - since now fparser fails when trying to parse this line. --- tests/unit_tests/parse/fortran/test_fortran_analyser.f90 | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit_tests/parse/fortran/test_fortran_analyser.f90 b/tests/unit_tests/parse/fortran/test_fortran_analyser.f90 index 508ba56b..2c530269 100644 --- a/tests/unit_tests/parse/fortran/test_fortran_analyser.f90 +++ b/tests/unit_tests/parse/fortran/test_fortran_analyser.f90 @@ -19,7 +19,6 @@ END SUBROUTINE internal_sub SUBROUTINE openmp_sentinel !$ USE compute_chunk_size_mod, ONLY: compute_chunk_size ! Note OpenMP sentinel -!$ USE test that is not a sentinel with a use statement inside !GCC$ unroll 6 !DIR$ assume (mod(p, 6) == 0) !$omp do From 652db982317039aff885d7aa33ac97acea20bcff Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Fri, 29 Nov 2024 16:40:20 +1100 Subject: [PATCH 32/55] Added test for disabled openmp parsing. Updated test to work with new test file. --- .../parse/fortran/test_fortran_analyser.py | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/tests/unit_tests/parse/fortran/test_fortran_analyser.py b/tests/unit_tests/parse/fortran/test_fortran_analyser.py index 75621020..6eb28f3c 100644 --- a/tests/unit_tests/parse/fortran/test_fortran_analyser.py +++ b/tests/unit_tests/parse/fortran/test_fortran_analyser.py @@ -3,6 +3,11 @@ # For further details please refer to the file COPYRIGHT # which you should have received as part of this distribution # ############################################################################## + +'''Tests the Fortran analyser. +''' + + from pathlib import Path from tempfile import NamedTemporaryFile from unittest import mock @@ -18,9 +23,6 @@ from fab.parse.fortran_common import iter_content from fab.tools import ToolBox -'''Tests the Fortran analyser. -''' - # todo: test function binding @@ -36,7 +38,7 @@ def module_expected(module_fpath) -> AnalysedFortran: test module.''' return AnalysedFortran( fpath=module_fpath, - file_hash=1757501304, + file_hash=3737289404, module_defs={'foo_mod'}, symbol_defs={'external_sub', 'external_func', 'foo_mod'}, module_deps={'bar_mod', 'compute_chunk_size_mod'}, @@ -50,9 +52,10 @@ class TestAnalyser: @pytest.fixture def fortran_analyser(self, tmp_path): - fortran_analyser = FortranAnalyser() - fortran_analyser._config = BuildConfig('proj', ToolBox(), - fab_workspace=tmp_path) + # Enable openmp, so fparser will handle the lines with omp sentinels + config = BuildConfig('proj', ToolBox(), + fab_workspace=tmp_path, openmp=True) + fortran_analyser = FortranAnalyser(config=config) return fortran_analyser def test_empty_file(self, fortran_analyser): @@ -71,6 +74,24 @@ def test_module_file(self, fortran_analyser, module_fpath, assert artefact == (fortran_analyser._config.prebuild_folder / f'test_fortran_analyser.{analysis.file_hash}.an') + def test_module_file_no_openmp(self, fortran_analyser, module_fpath, + module_expected): + '''Disable OpenMP, meaning the dependency on compute_chunk_size_mod + should not be detected anymore. + ''' + fortran_analyser.config._openmp = False + with mock.patch('fab.parse.AnalysedFile.save'): + analysis, artefact = fortran_analyser.run(fpath=module_fpath) + + # Without parsing openmp sentinels, the compute_chunk... symbols + # must not be added: + module_expected.module_deps.remove('compute_chunk_size_mod') + module_expected.symbol_deps.remove('compute_chunk_size_mod') + + assert analysis == module_expected + assert artefact == (fortran_analyser._config.prebuild_folder / + f'test_fortran_analyser.{analysis.file_hash}.an') + def test_program_file(self, fortran_analyser, module_fpath, module_expected): # same as test_module_file() but replacing MODULE with PROGRAM @@ -83,7 +104,7 @@ def test_program_file(self, fortran_analyser, module_fpath, fpath=Path(tmp_file.name)) module_expected.fpath = Path(tmp_file.name) - module_expected._file_hash = 3388519280 + module_expected._file_hash = 325155675 module_expected.program_defs = {'foo_mod'} module_expected.module_defs = set() module_expected.symbol_defs.update({'internal_func', @@ -133,7 +154,7 @@ def test_define_without_bind_name(self, tmp_path): # run our handler fpath = Path('foo') analysed_file = AnalysedFortran(fpath=fpath, file_hash=0) - analyser = FortranAnalyser() + analyser = FortranAnalyser(config=None) analyser._process_variable_binding(analysed_file=analysed_file, obj=var_decl) From ea7e428d3927ad344507d1f126d09f551a7ddbae Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Fri, 29 Nov 2024 16:40:39 +1100 Subject: [PATCH 33/55] Coding style changes. --- source/fab/parse/fortran_common.py | 50 ++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/source/fab/parse/fortran_common.py b/source/fab/parse/fortran_common.py index a184eb41..5942dc8c 100644 --- a/source/fab/parse/fortran_common.py +++ b/source/fab/parse/fortran_common.py @@ -59,7 +59,8 @@ def _typed_child(parent, child_type: Type, must_exist=False): # Look for a child of a certain type. # Returns the child or None. # Raises ValueError if more than one child of the given type is found. - children = list(filter(lambda child: isinstance(child, child_type), parent.children)) + children = list(filter(lambda child: isinstance(child, child_type), + parent.children)) if len(children) > 1: raise ValueError(f"too many children found of type {child_type}") @@ -67,13 +68,15 @@ def _typed_child(parent, child_type: Type, must_exist=False): return children[0] if must_exist: - raise FabException(f'Could not find child of type {child_type} in {parent}') + raise FabException(f'Could not find child of type {child_type} ' + f'in {parent}') return None class FortranAnalyserBase(ABC): """ - Base class for Fortran parse-tree analysers, e.g FortranAnalyser and X90Analyser. + Base class for Fortran parse-tree analysers, e.g FortranAnalyser and + X90Analyser. """ _intrinsic_modules = ['iso_fortran_env', 'iso_c_binding'] @@ -100,13 +103,17 @@ def config(self) -> BuildConfig: return self._config def run(self, fpath: Path) \ - -> Union[Tuple[AnalysedDependent, Path], Tuple[EmptySourceFile, None], Tuple[Exception, None]]: + -> Union[Tuple[AnalysedDependent, Path], + Tuple[EmptySourceFile, None], + Tuple[Exception, None]]: """ - Parse the source file and record what we're interested in (subclass specific). + Parse the source file and record what we're interested in (subclass + specific). Reloads previous analysis results if available. - Returns the analysis data and the result file where it was stored/loaded. + Returns the analysis data and the result file where it was + stored/loaded. """ # calculate the prebuild filename @@ -120,9 +127,11 @@ def run(self, fpath: Path) \ # Load the result file into whatever result class we use. loaded_result = self.result_class.load(analysis_fpath) if loaded_result: - # This result might have been created by another user; their prebuild folder copied to ours. - # If so, the fpath in the result will *not* point to the file we eventually want to compile, - # it will point to the user's original file, somewhere else. So replace it with our own path. + # This result might have been created by another user; their + # prebuild folder copied to ours. If so, the fpath in the + # result will *not* point to the file we eventually want to + # compile, it will point to the user's original file, + # somewhere else. So replace it with our own path. loaded_result.fpath = fpath return loaded_result, analysis_fpath @@ -131,20 +140,24 @@ def run(self, fpath: Path) \ # parse the file, get a node tree node_tree = self._parse_file(fpath=fpath) if isinstance(node_tree, Exception): - return Exception(f"error parsing file '{fpath}':\n{node_tree}"), None + return (Exception(f"error parsing file '{fpath}':\n{node_tree}"), + None) if node_tree.content[0] is None: logger.debug(f" empty tree found when parsing {fpath}") - # todo: If we don't save the empty result we'll keep analysing it every time! + # todo: If we don't save the empty result we'll keep analysing + # it every time! return EmptySourceFile(fpath), None # find things in the node tree - analysed_file = self.walk_nodes(fpath=fpath, file_hash=file_hash, node_tree=node_tree) + analysed_file = self.walk_nodes(fpath=fpath, file_hash=file_hash, + node_tree=node_tree) analysed_file.save(analysis_fpath) return analysed_file, analysis_fpath def _get_analysis_fpath(self, fpath, file_hash) -> Path: - return Path(self._config.prebuild_folder / f'{fpath.stem}.{file_hash}.an') + return Path(self.config.prebuild_folder / + f'{fpath.stem}.{file_hash}.an') def _parse_file(self, fpath): """Get a node tree from a fortran file.""" @@ -159,19 +172,22 @@ def _parse_file(self, fpath): tree = self.f2008_parser(reader) return tree except FortranSyntaxError as err: - # we can't return the FortranSyntaxError, it breaks multiprocessing! + # Don't return the FortranSyntaxError, it breaks multiprocessing! logger.error(f"\nfparser raised a syntax error in {fpath}\n{err}") return Exception(f"syntax error in {fpath}\n{err}") except Exception as err: logger.error(f"\nunhandled error '{type(err)}' in {fpath}\n{err}") - return Exception(f"unhandled error '{type(err)}' in {fpath}\n{err}") + return Exception(f"unhandled error '{type(err)}' in " + f"{fpath}\n{err}") @abstractmethod def walk_nodes(self, fpath, file_hash, node_tree) -> AnalysedDependent: """ - Examine the nodes in the parse tree, recording things we're interested in. + Examine the nodes in the parse tree, recording things we're + interested in. - Return type depends on our subclass, and will be a subclass of AnalysedDependent. + Return type depends on our subclass, and will be a subclass of + AnalysedDependent. """ raise NotImplementedError From 137d346daedc5fdfcb54f40d6c9f02e769952d59 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Fri, 29 Nov 2024 16:41:46 +1100 Subject: [PATCH 34/55] Fix flake issues. --- source/fab/parse/fortran.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/fab/parse/fortran.py b/source/fab/parse/fortran.py index 0f753f56..ed2e14d3 100644 --- a/source/fab/parse/fortran.py +++ b/source/fab/parse/fortran.py @@ -169,7 +169,7 @@ class FortranAnalyser(FortranAnalyserBase): """ def __init__(self, config: BuildConfig, - std: Optional[str]=None, + std: Optional[str] = None, ignore_mod_deps: Optional[Iterable[str]] = None): """ :param config: The BuildConfig to use. From fa0cb5d4a3accbe97b3c0854e309af97a0283e75 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Fri, 29 Nov 2024 17:04:09 +1100 Subject: [PATCH 35/55] Fixed double _. --- tests/unit_tests/tools/test_psyclone.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/tools/test_psyclone.py b/tests/unit_tests/tools/test_psyclone.py index 5586c485..52cd2404 100644 --- a/tests/unit_tests/tools/test_psyclone.py +++ b/tests/unit_tests/tools/test_psyclone.py @@ -269,7 +269,7 @@ def test_psyclone_process_nemo_api_old_psyclone(version): ("gocean1.0", "gocean"), ("gocean", "gocean") ]) -def test_psyclone_process_api_new__psyclone(api): +def test_psyclone_process_api_new_psyclone(api): '''Test running the new PSyclone version. Since this version is not yet released, we use the Fab internal version number 2.5.0.1 for now. It uses new API names, and we need to check that the old style From ada81c9642d8280686fc71a418e9eee0842f5894 Mon Sep 17 00:00:00 2001 From: Luke Hoffmann Date: Mon, 9 Sep 2024 17:08:50 +1000 Subject: [PATCH 36/55] Make Linker inherit CompilerWrapper --- source/fab/tools/compiler_wrapper.py | 5 +- source/fab/tools/linker.py | 66 +++++++-------------------- source/fab/tools/tool_repository.py | 2 +- tests/unit_tests/tools/test_linker.py | 63 ++++++++++--------------- 4 files changed, 45 insertions(+), 91 deletions(-) diff --git a/source/fab/tools/compiler_wrapper.py b/source/fab/tools/compiler_wrapper.py index 09ce5015..d7b167cd 100644 --- a/source/fab/tools/compiler_wrapper.py +++ b/source/fab/tools/compiler_wrapper.py @@ -25,16 +25,19 @@ class CompilerWrapper(Compiler): :param name: name of the wrapper. :param exec_name: name of the executable to call. :param compiler: the compiler that is decorated. + :param category: the tool's category. Defaults to the compiler's category. :param mpi: whether MPI is supported by this compiler or not. ''' def __init__(self, name: str, exec_name: str, compiler: Compiler, + category: Optional[Category] = None, mpi: bool = False): self._compiler = compiler + category = category or self._compiler.category super().__init__( name=name, exec_name=exec_name, - category=self._compiler.category, + category=category, suite=self._compiler.suite, version_regex=self._compiler._version_regex, mpi=mpi, diff --git a/source/fab/tools/linker.py b/source/fab/tools/linker.py index 8959b3de..e61215e0 100644 --- a/source/fab/tools/linker.py +++ b/source/fab/tools/linker.py @@ -9,48 +9,33 @@ import os from pathlib import Path -from typing import cast, Dict, List, Optional +from typing import Dict, List, Optional, Union import warnings from fab.tools.category import Category from fab.tools.compiler import Compiler -from fab.tools.tool import CompilerSuiteTool +from fab.tools.compiler_wrapper import CompilerWrapper -class Linker(CompilerSuiteTool): +class Linker(CompilerWrapper): '''This is the base class for any Linker. If a compiler is specified, its name, executable, and compile suite will be used for the linker (if not explicitly set in the constructor). - :param name: the name of the linker. - :param exec_name: the name of the executable. - :param suite: optional, the name of the suite. :param compiler: optional, a compiler instance :param output_flag: flag to use to specify the output name. ''' - # pylint: disable=too-many-arguments - def __init__(self, name: Optional[str] = None, - exec_name: Optional[str] = None, - suite: Optional[str] = None, - compiler: Optional[Compiler] = None, - output_flag: str = "-o"): - if (not name or not exec_name or not suite) and not compiler: - raise RuntimeError("Either specify name, exec name, and suite " - "or a compiler when creating Linker.") - # Make mypy happy, since it can't work out otherwise if these string - # variables might still be None :( - compiler = cast(Compiler, compiler) - if not name: - name = compiler.name - if not exec_name: - exec_name = compiler.exec_name - if not suite: - suite = compiler.suite + def __init__(self, compiler: Compiler, output_flag: str = "-o"): self._output_flag = output_flag - super().__init__(name, exec_name, suite, Category.LINKER) - self._compiler = compiler - self.flags.extend(os.getenv("LDFLAGS", "").split()) + super().__init__( + name=f"linker-{compiler.name}", + exec_name=compiler.exec_name, + compiler=compiler, + category=Category.LINKER, + mpi=compiler.mpi) + + self._flags.extend(os.getenv("LDFLAGS", "").split()) # Maintain a set of flags for common libraries. self._lib_flags: Dict[str, List[str]] = {} @@ -58,21 +43,6 @@ def __init__(self, name: Optional[str] = None, self._pre_lib_flags: List[str] = [] self._post_lib_flags: List[str] = [] - @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 - by requesting the linker version. - ''' - if self._compiler: - return self._compiler.check_available() - - return super().check_available() - def get_lib_flags(self, lib: str) -> List[str]: '''Gets the standard flags for a standard library @@ -141,13 +111,11 @@ def link(self, input_files: List[Path], output_file: Path, :returns: the stdout of the link command ''' - if self._compiler: - # Create a copy: - params = self._compiler.flags[:] - if openmp: - params.append(self._compiler.openmp_flag) - else: - params = [] + # Don't need to add compiler's flags, they are added by CompilerWrapper. + params: List[Union[str, Path]] = [] + if openmp: + params.append(self._compiler.openmp_flag) + # TODO: why are the .o files sorted? That shouldn't matter params.extend(sorted(map(str, input_files))) diff --git a/source/fab/tools/tool_repository.py b/source/fab/tools/tool_repository.py index 0a17d7e6..62046712 100644 --- a/source/fab/tools/tool_repository.py +++ b/source/fab/tools/tool_repository.py @@ -115,7 +115,7 @@ def add_tool(self, tool: Tool): # 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) + linker = Linker(compiler=tool) self[linker.category].append(linker) def get_tool(self, category: Category, name: str) -> Tool: diff --git a/tests/unit_tests/tools/test_linker.py b/tests/unit_tests/tools/test_linker.py index 6984c790..499be774 100644 --- a/tests/unit_tests/tools/test_linker.py +++ b/tests/unit_tests/tools/test_linker.py @@ -19,31 +19,24 @@ def test_linker(mock_c_compiler, mock_fortran_compiler): '''Test the linker constructor.''' - linker = Linker(name="my_linker", exec_name="my_linker.exe", suite="suite") - assert linker.category == Category.LINKER - assert linker.name == "my_linker" - assert linker.exec_name == "my_linker.exe" - assert linker.suite == "suite" - assert linker.flags == [] + assert mock_c_compiler.category == Category.C_COMPILER + assert mock_c_compiler.name == "mock_c_compiler" - linker = Linker(name="my_linker", compiler=mock_c_compiler) + linker = Linker(mock_c_compiler) assert linker.category == Category.LINKER - assert linker.name == "my_linker" - assert linker.exec_name == mock_c_compiler.exec_name - assert linker.suite == mock_c_compiler.suite + assert linker.name == "linker-mock_c_compiler" + assert linker.exec_name == "mock_c_compiler.exe" + assert linker.suite == "suite" assert linker.flags == [] - linker = Linker(compiler=mock_c_compiler) - assert linker.category == Category.LINKER - assert linker.name == mock_c_compiler.name - assert linker.exec_name == mock_c_compiler.exec_name - assert linker.suite == mock_c_compiler.suite - assert linker.flags == [] + assert mock_fortran_compiler.category == Category.FORTRAN_COMPILER + assert mock_fortran_compiler.name == "mock_fortran_compiler" - linker = Linker(compiler=mock_fortran_compiler) + linker = Linker(mock_fortran_compiler) assert linker.category == Category.LINKER - assert linker.name == mock_fortran_compiler.name - assert linker.exec_name == mock_fortran_compiler.exec_name + assert linker.name == "linker-mock_fortran_compiler" + assert linker.exec_name == "mock_fortran_compiler.exe" + assert linker.suite == "suite" assert linker.flags == [] with pytest.raises(RuntimeError) as err: @@ -64,29 +57,19 @@ def test_linker_check_available(mock_c_compiler): # First test if a compiler is given. The linker will call the # corresponding function in the compiler: - linker = Linker(compiler=mock_c_compiler) - with mock.patch.object(mock_c_compiler, "check_available", - return_value=True) as comp_run: + linker = Linker(mock_c_compiler) + with mock.patch('fab.tools.compiler.Compiler.get_version', + return_value=(1, 2, 3)): assert linker.check_available() - # It should be called once without any parameter - comp_run.assert_called_once_with() - # Second test, no compiler is given. Mock Tool.run to - # return a success: - linker = Linker("ld", "ld", suite="gnu") - mock_result = mock.Mock(returncode=0) - with mock.patch('fab.tools.tool.subprocess.run', - return_value=mock_result) as tool_run: - linker.check_available() - tool_run.assert_called_once_with( - ["ld", "--version"], capture_output=True, env=None, - cwd=None, check=False) - - # Third test: assume the tool does not exist, check_available - # will return False (and not raise an exception) - linker._is_available = None - with mock.patch("fab.tools.tool.Tool.run", - side_effect=RuntimeError("")) as tool_run: + +def test_linker_check_unavailable(mock_c_compiler): + '''Tests the is_available functionality.''' + # assume the tool does not exist, check_available + # will return False (and not raise an exception) + linker = Linker(mock_c_compiler) + with mock.patch('fab.tools.compiler.Compiler.get_version', + side_effect=RuntimeError("")): assert linker.check_available() is False From 3fb40187d96afef83d94bba9bb0168516395222a Mon Sep 17 00:00:00 2001 From: Luke Hoffmann Date: Wed, 25 Sep 2024 14:36:49 +1000 Subject: [PATCH 37/55] Fix up tests for new Linker inheritence --- source/fab/tools/compiler.py | 2 +- source/fab/tools/linker.py | 2 +- source/fab/tools/preprocessor.py | 2 +- tests/conftest.py | 5 +-- tests/unit_tests/steps/test_link.py | 9 +++-- .../steps/test_link_shared_object.py | 8 ++-- tests/unit_tests/tools/test_linker.py | 38 ++++++------------- 7 files changed, 26 insertions(+), 40 deletions(-) diff --git a/source/fab/tools/compiler.py b/source/fab/tools/compiler.py index 0b5618de..669bb292 100644 --- a/source/fab/tools/compiler.py +++ b/source/fab/tools/compiler.py @@ -64,7 +64,7 @@ def __init__(self, name: str, 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()) + self.add_flags(os.getenv("FFLAGS", "").split()) self._version_regex = version_regex @property diff --git a/source/fab/tools/linker.py b/source/fab/tools/linker.py index e61215e0..1256ff39 100644 --- a/source/fab/tools/linker.py +++ b/source/fab/tools/linker.py @@ -35,7 +35,7 @@ def __init__(self, compiler: Compiler, output_flag: str = "-o"): category=Category.LINKER, mpi=compiler.mpi) - self._flags.extend(os.getenv("LDFLAGS", "").split()) + self.add_flags(os.getenv("LDFLAGS", "").split()) # Maintain a set of flags for common libraries. self._lib_flags: Dict[str, List[str]] = {} diff --git a/source/fab/tools/preprocessor.py b/source/fab/tools/preprocessor.py index e620ce2a..dd037874 100644 --- a/source/fab/tools/preprocessor.py +++ b/source/fab/tools/preprocessor.py @@ -63,7 +63,7 @@ class CppFortran(Preprocessor): ''' def __init__(self): super().__init__("cpp", "cpp", Category.FORTRAN_PREPROCESSOR) - self.flags.extend(["-traditional-cpp", "-P"]) + self.add_flags(["-traditional-cpp", "-P"]) # ============================================================================ diff --git a/tests/conftest.py b/tests/conftest.py index 86de6476..4479eb7d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,10 +44,9 @@ def fixture_mock_fortran_compiler(): @pytest.fixture(name="mock_linker") -def fixture_mock_linker(): +def fixture_mock_linker(mock_fortran_compiler): '''Provides a mock linker.''' - mock_linker = Linker("mock_linker", "mock_linker.exe", - Category.FORTRAN_COMPILER) + mock_linker = Linker(mock_fortran_compiler) mock_linker.run = mock.Mock() mock_linker._version = (1, 2, 3) mock_linker.add_lib_flags("netcdf", ["-lnetcdff", "-lnetcdf"]) diff --git a/tests/unit_tests/steps/test_link.py b/tests/unit_tests/steps/test_link.py index a20c4ff4..3203bf09 100644 --- a/tests/unit_tests/steps/test_link.py +++ b/tests/unit_tests/steps/test_link.py @@ -15,7 +15,7 @@ class TestLinkExe: - def test_run(self, tool_box): + def test_run(self, tool_box, mock_fortran_compiler): # ensure the command is formed correctly, with the flags at the # end (why?!) @@ -29,9 +29,9 @@ def test_run(self, tool_box): config.artefact_store[ArtefactSet.OBJECT_FILES] = \ {'foo': {'foo.o', 'bar.o'}} - with mock.patch('os.getenv', return_value='-L/foo1/lib -L/foo2/lib'): + with mock.patch.dict("os.environ", {"FFLAGS": "-L/foo1/lib -L/foo2/lib"}): # We need to create a linker here to pick up the env var: - linker = Linker("mock_link", "mock_link.exe", "mock-vendor") + linker = Linker(mock_fortran_compiler) # Mark the linker as available to it can be added to the tool box linker._is_available = True @@ -47,7 +47,8 @@ def test_run(self, tool_box): link_exe(config, libs=['mylib'], flags=['-fooflag', '-barflag']) tool_run.assert_called_with( - ['mock_link.exe', '-L/foo1/lib', '-L/foo2/lib', 'bar.o', 'foo.o', + ['mock_fortran_compiler.exe', '-L/foo1/lib', '-L/foo2/lib', + 'bar.o', 'foo.o', '-L/my/lib', '-mylib', '-fooflag', '-barflag', '-o', 'workspace/foo'], capture_output=True, env=None, cwd=None, check=False) diff --git a/tests/unit_tests/steps/test_link_shared_object.py b/tests/unit_tests/steps/test_link_shared_object.py index 700a3de3..5275b280 100644 --- a/tests/unit_tests/steps/test_link_shared_object.py +++ b/tests/unit_tests/steps/test_link_shared_object.py @@ -18,7 +18,7 @@ import pytest -def test_run(tool_box): +def test_run(tool_box, mock_c_compiler): '''Ensure the command is formed correctly, with the flags at the end since they are typically libraries.''' @@ -32,9 +32,9 @@ def test_run(tool_box): config.artefact_store[ArtefactSet.OBJECT_FILES] = \ {None: {'foo.o', 'bar.o'}} - with mock.patch('os.getenv', return_value='-L/foo1/lib -L/foo2/lib'): + with mock.patch.dict("os.environ", {"FFLAGS": "-L/foo1/lib -L/foo2/lib"}): # We need to create a linker here to pick up the env var: - linker = Linker("mock_link", "mock_link.exe", "vendor") + linker = Linker(mock_c_compiler) # Mark the linker as available so it can added to the tool box: linker._is_available = True tool_box.add_tool(linker, silent_replace=True) @@ -47,6 +47,6 @@ def test_run(tool_box): flags=['-fooflag', '-barflag']) tool_run.assert_called_with( - ['mock_link.exe', '-L/foo1/lib', '-L/foo2/lib', 'bar.o', 'foo.o', + ['mock_c_compiler.exe', '-L/foo1/lib', '-L/foo2/lib', 'bar.o', 'foo.o', '-fooflag', '-barflag', '-fPIC', '-shared', '-o', '/tmp/lib_my.so'], capture_output=True, env=None, cwd=None, check=False) diff --git a/tests/unit_tests/tools/test_linker.py b/tests/unit_tests/tools/test_linker.py index 499be774..2d032688 100644 --- a/tests/unit_tests/tools/test_linker.py +++ b/tests/unit_tests/tools/test_linker.py @@ -39,11 +39,6 @@ def test_linker(mock_c_compiler, mock_fortran_compiler): assert linker.suite == "suite" assert linker.flags == [] - with pytest.raises(RuntimeError) as err: - linker = Linker(name="no-exec-given") - assert ("Either specify name, exec name, and suite or a compiler when " - "creating Linker." in str(err.value)) - def test_linker_gets_ldflags(mock_c_compiler): """Tests that the linker retrieves env.LDFLAGS""" @@ -247,29 +242,22 @@ def test_compiler_linker_add_compiler_flag(mock_c_compiler): capture_output=True, env=None, cwd=None, check=False) -def test_linker_add_compiler_flag(): - '''Make sure ad-hoc linker flags work if a linker is created without a - compiler: - ''' - linker = Linker("no-compiler", "no-compiler.exe", "suite") - linker.flags.append("-some-other-flag") - mock_result = mock.Mock(returncode=0) - with mock.patch('fab.tools.tool.subprocess.run', - return_value=mock_result) as tool_run: - linker.link([Path("a.o")], Path("a.out"), openmp=False) - tool_run.assert_called_with( - ['no-compiler.exe', '-some-other-flag', 'a.o', '-o', 'a.out'], - capture_output=True, env=None, cwd=None, check=False) - - def test_linker_all_flag_types(mock_c_compiler): """Make sure all possible sources of linker flags are used in the right order""" - with mock.patch.dict("os.environ", {"LDFLAGS": "-ldflag"}): + + # Environment variables for both the compiler and linker + # TODO: THIS IS ACTUALLY WRONG - The FFLAGS shouldn't be picked up here, + # because the compiler already exists. It is being added twice, because + # Linker inherits Compiler (in addition to wrapping it) + with mock.patch.dict("os.environ", { + "FFLAGS": "-fflag", + "LDFLAGS": "-ldflag" + }): linker = Linker(compiler=mock_c_compiler) - mock_c_compiler.flags.extend(["-compiler-flag1", "-compiler-flag2"]) - linker.flags.extend(["-linker-flag1", "-linker-flag2"]) + mock_c_compiler.add_flags(["-compiler-flag1", "-compiler-flag2"]) + linker.add_flags(["-linker-flag1", "-linker-flag2"]) linker.add_pre_lib_flags(["-prelibflag1", "-prelibflag2"]) linker.add_lib_flags("customlib1", ["-lib1flag1", "lib1flag2"]) linker.add_lib_flags("customlib2", ["-lib2flag1", "lib2flag2"]) @@ -285,10 +273,8 @@ def test_linker_all_flag_types(mock_c_compiler): tool_run.assert_called_with([ "mock_c_compiler.exe", - # Note: compiler flags and linker flags will be switched when the Linker - # becomes a CompilerWrapper in a following PR + "-compiler-flag1", "-compiler-flag2", "-fflag", "-ldflag", "-linker-flag1", "-linker-flag2", - "-compiler-flag1", "-compiler-flag2", "-fopenmp", "a.o", "-prelibflag1", "-prelibflag2", From 0f1bd0055978d362abfbc5d383fb0d29e9c4aeb0 Mon Sep 17 00:00:00 2001 From: Luke Hoffmann Date: Wed, 25 Sep 2024 14:40:50 +1000 Subject: [PATCH 38/55] Fix a flake error --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4479eb7d..36896de7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ import pytest -from fab.tools import Category, CCompiler, FortranCompiler, Linker, ToolBox +from fab.tools import CCompiler, FortranCompiler, Linker, ToolBox # This avoids pylint warnings about Redefining names from outer scope From 239f4173e7907ce90041ed7f23e4fafeb16565e0 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Mon, 2 Dec 2024 16:33:41 +1100 Subject: [PATCH 39/55] Use linker wrapping to combine flags from the wrapped linker with the linker wrapper. --- source/fab/tools/linker.py | 67 ++++++++++++++++++++++----- tests/unit_tests/tools/test_linker.py | 38 +++++++++++++-- 2 files changed, 90 insertions(+), 15 deletions(-) diff --git a/source/fab/tools/linker.py b/source/fab/tools/linker.py index 1256ff39..fc348e5e 100644 --- a/source/fab/tools/linker.py +++ b/source/fab/tools/linker.py @@ -9,7 +9,7 @@ import os from pathlib import Path -from typing import Dict, List, Optional, Union +from typing import cast, Dict, List, Optional, Union import warnings from fab.tools.category import Category @@ -18,16 +18,14 @@ class Linker(CompilerWrapper): - '''This is the base class for any Linker. If a compiler is specified, - its name, executable, and compile suite will be used for the linker (if - not explicitly set in the constructor). + '''This is the base class for any Linker. - :param compiler: optional, a compiler instance + :param compiler: a compiler or linker instance :param output_flag: flag to use to specify the output name. ''' def __init__(self, compiler: Compiler, output_flag: str = "-o"): - self._output_flag = output_flag + super().__init__( name=f"linker-{compiler.name}", exec_name=compiler.exec_name, @@ -35,6 +33,7 @@ def __init__(self, compiler: Compiler, output_flag: str = "-o"): category=Category.LINKER, mpi=compiler.mpi) + self._output_flag = output_flag self.add_flags(os.getenv("LDFLAGS", "").split()) # Maintain a set of flags for common libraries. @@ -43,6 +42,17 @@ def __init__(self, compiler: Compiler, output_flag: str = "-o"): self._pre_lib_flags: List[str] = [] self._post_lib_flags: List[str] = [] + def get_output_flag(self) -> str: + ''':returns: the flag that is used to specify the output name. + ''' + if self._output_flag: + return self._output_flag + if not self.compiler.category == Category.LINKER: + raise RuntimeError(f"No output flag found for linker {self.name}.") + + linker = cast(Linker, self.compiler) + return linker.get_output_flag() + def get_lib_flags(self, lib: str) -> List[str]: '''Gets the standard flags for a standard library @@ -55,6 +65,11 @@ def get_lib_flags(self, lib: str) -> List[str]: try: return self._lib_flags[lib] except KeyError: + # If a lib is not defined here, but this is a wrapper around + # another linker, return the result from the wrapped linker + if self.compiler.category is Category.LINKER: + linker = cast(Linker, self.compiler) + return linker.get_lib_flags(lib) raise RuntimeError(f"Unknown library name: '{lib}'") def add_lib_flags(self, lib: str, flags: List[str], @@ -98,6 +113,29 @@ def add_post_lib_flags(self, flags: List[str]): ''' self._post_lib_flags.extend(flags) + def get_pre_link_flags(self) -> List[str]: + '''Returns the list of pre-link flags. It will concatenate the + flags for this instance with all potentially wrapper linkers. + This wrapper's flag will come first - the assumption is that + the pre-link flags are likely paths, so we need a wrapper to + be able to put a search path before the paths from a wrapped + linker. + + :returns: List of pre-link flags of this linker and all + wrapped linkers + ''' + params: List[str] = [] + + if self._pre_lib_flags: + params.extend(self._pre_lib_flags) + if self.compiler.category == Category.LINKER: + # If we are wrapping a linker, get the wrapped linker's + # pre-link flags and append them to the end (so the linker + # wrapper's settings come first) + linker = cast(Linker, self.compiler) + params.extend(linker.get_pre_link_flags()) + return params + def link(self, input_files: List[Path], output_file: Path, openmp: bool, libs: Optional[List[str]] = None) -> str: @@ -111,19 +149,26 @@ def link(self, input_files: List[Path], output_file: Path, :returns: the stdout of the link command ''' - # Don't need to add compiler's flags, they are added by CompilerWrapper. + params: List[Union[str, Path]] = [] + if openmp: - params.append(self._compiler.openmp_flag) + compiler = self.compiler + while compiler.category == Category.LINKER: + linker = cast(Linker, compiler) + compiler = linker.compiler + + params.append(compiler.openmp_flag) # TODO: why are the .o files sorted? That shouldn't matter params.extend(sorted(map(str, input_files))) + params.extend(self.get_pre_link_flags()) - if self._pre_lib_flags: - params.extend(self._pre_lib_flags) for lib in (libs or []): params.extend(self.get_lib_flags(lib)) + if self._post_lib_flags: params.extend(self._post_lib_flags) - params.extend([self._output_flag, str(output_file)]) + params.extend([self.get_output_flag(), str(output_file)]) + return self.run(params) diff --git a/tests/unit_tests/tools/test_linker.py b/tests/unit_tests/tools/test_linker.py index 2d032688..344243a5 100644 --- a/tests/unit_tests/tools/test_linker.py +++ b/tests/unit_tests/tools/test_linker.py @@ -81,8 +81,8 @@ def test_linker_get_lib_flags(mock_linker): def test_linker_get_lib_flags_unknown(mock_c_compiler): - """Linker should raise an error if flags are requested for a library that is - unknown + """Linker should raise an error if flags are requested for a library + that is unknown. """ linker = Linker(compiler=mock_c_compiler) with pytest.raises(RuntimeError) as err: @@ -101,7 +101,8 @@ def test_linker_add_lib_flags(mock_c_compiler): def test_linker_add_lib_flags_overwrite_defaults(mock_linker): - """Linker should provide a way to replace the default flags for a library""" + """Linker should provide a way to replace the default flags for + a library""" # Initially we have the default netcdf flags result = mock_linker.get_lib_flags("netcdf") @@ -156,7 +157,9 @@ def test_linker_remove_lib_flags_unknown(mock_linker): # Linking: # ==================== def test_linker_c(mock_c_compiler): - '''Test the link command line when no additional libraries are specified.''' + '''Test the link command line when no additional libraries are + specified.''' + linker = Linker(compiler=mock_c_compiler) # Add a library to the linker, but don't use it in the link step linker.add_lib_flags("customlib", ["-lcustom", "-jcustom"]) @@ -283,3 +286,30 @@ def test_linker_all_flag_types(mock_c_compiler): "-postlibflag1", "-postlibflag2", "-o", "a.out"], capture_output=True, env=None, cwd=None, check=False) + + +def test_linker_nesting(mock_c_compiler): + """Make sure all possible sources of linker flags are used in the right + order""" + + linker1 = Linker(compiler=mock_c_compiler) + linker1.add_pre_lib_flags(["pre_lib1"]) + linker1.add_lib_flags("lib_a", ["a_from_1"]) + linker1.add_lib_flags("lib_c", ["c_from_1"]) + linker2 = Linker(compiler=linker1) + linker2.add_pre_lib_flags(["pre_lib2"]) + linker2.add_lib_flags("lib_b", ["b_from_2"]) + linker2.add_lib_flags("lib_c", ["c_from_2"]) + + mock_result = mock.Mock(returncode=0) + with mock.patch("fab.tools.tool.subprocess.run", + return_value=mock_result) as tool_run: + linker2.link( + [Path("a.o")], Path("a.out"), + libs=["lib_a", "lib_b", "lib_c"], + openmp=True) + tool_run.assert_called_with(['mock_c_compiler.exe', '-fopenmp', + 'a.o', "pre_lib2", "pre_lib1", "a_from_1", + "b_from_2", "c_from_2", '-o', 'a.out'], + capture_output=True, env=None, cwd=None, + check=False) From 6b07056e191de7feffe37a18f8962c747bf2b897 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Mon, 2 Dec 2024 17:31:42 +1100 Subject: [PATCH 40/55] Minor code cleanup. --- source/fab/tools/linker.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/source/fab/tools/linker.py b/source/fab/tools/linker.py index fc348e5e..15a1c28a 100644 --- a/source/fab/tools/linker.py +++ b/source/fab/tools/linker.py @@ -131,7 +131,8 @@ def get_pre_link_flags(self) -> List[str]: if self.compiler.category == Category.LINKER: # If we are wrapping a linker, get the wrapped linker's # pre-link flags and append them to the end (so the linker - # wrapper's settings come first) + # wrapper's settings come before the setting from the + # wrapped linker). linker = cast(Linker, self.compiler) params.extend(linker.get_pre_link_flags()) return params @@ -153,10 +154,12 @@ def link(self, input_files: List[Path], output_file: Path, params: List[Union[str, Path]] = [] if openmp: + # Find the compiler by following the (potentially + # layered) linker wrapper. compiler = self.compiler while compiler.category == Category.LINKER: - linker = cast(Linker, compiler) - compiler = linker.compiler + # Make mypy happy + compiler = cast(Linker, compiler).compiler params.append(compiler.openmp_flag) From d06c9ce6c93e982b39c1d5f484c69141e1579b8c Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Mon, 2 Dec 2024 23:51:31 +1100 Subject: [PATCH 41/55] Created linker wrapper in ToolRepository. --- source/fab/tools/compiler.py | 5 ++++ source/fab/tools/linker.py | 10 +++++--- source/fab/tools/tool_repository.py | 34 +++++++++++++++++++------ tests/unit_tests/tools/test_compiler.py | 4 +-- tests/unit_tests/tools/test_linker.py | 21 +++++++++++++-- 5 files changed, 58 insertions(+), 16 deletions(-) diff --git a/source/fab/tools/compiler.py b/source/fab/tools/compiler.py index 669bb292..b937e9d1 100644 --- a/source/fab/tools/compiler.py +++ b/source/fab/tools/compiler.py @@ -83,6 +83,11 @@ def openmp_flag(self) -> str: '''Returns the flag to enable OpenMP.''' return self._openmp_flag + @property + def output_flag(self) -> str: + '''Returns the flag that specifies the output flag.''' + return self._output_flag + def get_hash(self) -> int: ''':returns: a hash based on the compiler name and version. ''' diff --git a/source/fab/tools/linker.py b/source/fab/tools/linker.py index 15a1c28a..bf2eda2b 100644 --- a/source/fab/tools/linker.py +++ b/source/fab/tools/linker.py @@ -21,19 +21,21 @@ class Linker(CompilerWrapper): '''This is the base class for any Linker. :param compiler: a compiler or linker instance + :param name: name of the linker :param output_flag: flag to use to specify the output name. ''' - def __init__(self, compiler: Compiler, output_flag: str = "-o"): + def __init__(self, compiler: Compiler, name: Optional[str] = None, + output_flag: Optional[str] = None): super().__init__( - name=f"linker-{compiler.name}", + name=name or f"linker-{compiler.name}", exec_name=compiler.exec_name, compiler=compiler, category=Category.LINKER, mpi=compiler.mpi) - self._output_flag = output_flag + self._output_flag = output_flag or "" self.add_flags(os.getenv("LDFLAGS", "").split()) # Maintain a set of flags for common libraries. @@ -48,7 +50,7 @@ def get_output_flag(self) -> str: if self._output_flag: return self._output_flag if not self.compiler.category == Category.LINKER: - raise RuntimeError(f"No output flag found for linker {self.name}.") + return self.compiler.output_flag linker = cast(Linker, self.compiler) return linker.get_output_flag() diff --git a/source/fab/tools/tool_repository.py b/source/fab/tools/tool_repository.py index 62046712..d93c61c0 100644 --- a/source/fab/tools/tool_repository.py +++ b/source/fab/tools/tool_repository.py @@ -17,8 +17,8 @@ from fab.tools.tool import Tool from fab.tools.category import Category from fab.tools.compiler import Compiler -from fab.tools.compiler_wrapper import (CrayCcWrapper, CrayFtnWrapper, - Mpif90, Mpicc) +from fab.tools.compiler_wrapper import (CompilerWrapper, CrayCcWrapper, + CrayFtnWrapper, Mpif90, Mpicc) from fab.tools.linker import Linker from fab.tools.versioning import Fcm, Git, Subversion from fab.tools import (Ar, Cpp, CppFortran, Craycc, Crayftn, @@ -81,12 +81,12 @@ def __init__(self): # Now create the potential mpif90 and Cray ftn wrapper all_fc = self[Category.FORTRAN_COMPILER][:] for fc in all_fc: - mpif90 = Mpif90(fc) - self.add_tool(mpif90) + if not fc.mpi: + mpif90 = Mpif90(fc) + self.add_tool(mpif90) # I assume cray has (besides cray) only support for Intel and GNU if fc.name in ["gfortran", "ifort"]: crayftn = CrayFtnWrapper(fc) - print("NEW NAME", crayftn, crayftn.name) self.add_tool(crayftn) # Now create the potential mpicc and Cray cc wrapper @@ -114,9 +114,27 @@ def add_tool(self, tool: Tool): # If we have a compiler, add the compiler as linker as well if tool.is_compiler: - tool = cast(Compiler, tool) - linker = Linker(compiler=tool) - self[linker.category].append(linker) + compiler = cast(Compiler, tool) + if isinstance(compiler, CompilerWrapper): + # If we have a compiler wrapper, create a new linker using + # the linker based on the wrappped compiler. For example, when + # creating linker-mpif90-gfortran, we want this to be based on + # linker-gfortran (and not on the compiler mpif90-gfortran), + # since the linker-gfortran might have library definitions + # that should be reused. So we first get the existing linker + # (since the compiler exists, a linker for this compiler was + # already created and must exist). + other_linker = self.get_tool( + category=Category.LINKER, + name=f"linker-{compiler.compiler.name}") + other_linker = cast(Linker, other_linker) + linker = Linker(compiler=other_linker, + name=f"linker-{compiler.name}") + self[linker.category].append(linker) + else: + linker = Linker(compiler=compiler, + name=f"linker-{compiler.name}") + self[linker.category].append(linker) def get_tool(self, category: Category, name: str) -> Tool: ''':returns: the tool with a given name in the specified category. diff --git a/tests/unit_tests/tools/test_compiler.py b/tests/unit_tests/tools/test_compiler.py index f6c7c158..60c18d78 100644 --- a/tests/unit_tests/tools/test_compiler.py +++ b/tests/unit_tests/tools/test_compiler.py @@ -25,7 +25,7 @@ def test_compiler(): category=Category.C_COMPILER, openmp_flag="-fopenmp") assert cc.category == Category.C_COMPILER assert cc._compile_flag == "-c" - assert cc._output_flag == "-o" + assert cc.output_flag == "-o" # pylint: disable-next=use-implicit-booleaness-not-comparison assert cc.flags == [] assert cc.suite == "gnu" @@ -35,7 +35,7 @@ def test_compiler(): fc = FortranCompiler("gfortran", "gfortran", "gnu", openmp_flag="-fopenmp", version_regex="something", module_folder_flag="-J") assert fc._compile_flag == "-c" - assert fc._output_flag == "-o" + assert fc.output_flag == "-o" assert fc.category == Category.FORTRAN_COMPILER assert fc.suite == "gnu" # pylint: disable-next=use-implicit-booleaness-not-comparison diff --git a/tests/unit_tests/tools/test_linker.py b/tests/unit_tests/tools/test_linker.py index 344243a5..cb94a039 100644 --- a/tests/unit_tests/tools/test_linker.py +++ b/tests/unit_tests/tools/test_linker.py @@ -13,7 +13,7 @@ import pytest -from fab.tools import (Category, Linker) +from fab.tools import (Category, Linker, ToolRepository) def test_linker(mock_c_compiler, mock_fortran_compiler): @@ -22,12 +22,13 @@ def test_linker(mock_c_compiler, mock_fortran_compiler): assert mock_c_compiler.category == Category.C_COMPILER assert mock_c_compiler.name == "mock_c_compiler" - linker = Linker(mock_c_compiler) + linker = Linker(mock_c_compiler, output_flag="-o") assert linker.category == Category.LINKER assert linker.name == "linker-mock_c_compiler" assert linker.exec_name == "mock_c_compiler.exe" assert linker.suite == "suite" assert linker.flags == [] + assert linker.get_output_flag() == "-o" assert mock_fortran_compiler.category == Category.FORTRAN_COMPILER assert mock_fortran_compiler.name == "mock_fortran_compiler" @@ -313,3 +314,19 @@ def test_linker_nesting(mock_c_compiler): "b_from_2", "c_from_2", '-o', 'a.out'], capture_output=True, env=None, cwd=None, check=False) + + +def test_linker_inheriting(): + '''Make sure that libraries from a wrapper compiler will be + available for a wrapper. + ''' + tr = ToolRepository() + linker_gfortran = tr.get_tool(Category.LINKER, "linker-gfortran") + linker_mpif90 = tr.get_tool(Category.LINKER, "linker-mpif90-gfortran") + + linker_gfortran.add_lib_flags("lib_a", ["a_from_1"]) + assert linker_mpif90.get_lib_flags("lib_a") == ["a_from_1"] + + with pytest.raises(RuntimeError) as err: + linker_mpif90.get_lib_flags("does_not_exist") + assert "Unknown library name: 'does_not_exist'" in str(err.value) From 39a820460572f5ee8910296c2596bdc9e44b983d Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Tue, 3 Dec 2024 01:19:35 +1100 Subject: [PATCH 42/55] Try making linker a CompilerSuiteTool instead of a CompilerWrapper. --- source/fab/tools/linker.py | 115 ++++++++++++++++++-------- source/fab/tools/tool_repository.py | 2 +- tests/unit_tests/steps/test_link.py | 2 +- tests/unit_tests/tools/test_linker.py | 8 +- 4 files changed, 85 insertions(+), 42 deletions(-) diff --git a/source/fab/tools/linker.py b/source/fab/tools/linker.py index bf2eda2b..9bbf0048 100644 --- a/source/fab/tools/linker.py +++ b/source/fab/tools/linker.py @@ -7,6 +7,8 @@ """This file contains the base class for any Linker. """ +from __future__ import annotations + import os from pathlib import Path from typing import cast, Dict, List, Optional, Union @@ -14,28 +16,49 @@ from fab.tools.category import Category from fab.tools.compiler import Compiler -from fab.tools.compiler_wrapper import CompilerWrapper +from fab.tools.tool import CompilerSuiteTool -class Linker(CompilerWrapper): - '''This is the base class for any Linker. +class Linker(CompilerSuiteTool): + '''This is the base class for any Linker. It takes either another linker + instance, or a compiler instance as parameter in the constructor. Exactly + one of these must be provided. - :param compiler: a compiler or linker instance + :param compiler: an optional compiler instance + :param linker: an optional linker instance :param name: name of the linker - :param output_flag: flag to use to specify the output name. + + :raises RuntimeError: if both compiler and linker are specified. + :raises RuntimeError: if neither compiler nor linker is specified. ''' - def __init__(self, compiler: Compiler, name: Optional[str] = None, - output_flag: Optional[str] = None): + def __init__(self, compiler: Optional[Compiler] = None, + linker: Optional[Linker] = None, + name: Optional[str] = None): + + if linker and compiler: + raise RuntimeError("Both compiler and linker is specified in " + "linker constructor.") + if not linker and not compiler: + raise RuntimeError("Neither compiler nor linker is specified in " + "linker constructor.") + self._compiler = compiler + self._linker = linker + + search_linker = self + while search_linker._linker: + search_linker = search_linker._linker + final_compiler = search_linker._compiler + if not name: + assert final_compiler + name = f"linker-{final_compiler.name}" super().__init__( - name=name or f"linker-{compiler.name}", - exec_name=compiler.exec_name, - compiler=compiler, - category=Category.LINKER, - mpi=compiler.mpi) + name=name or f"linker-{name}", + exec_name=self.exec_name, + suite=self.suite, + category=Category.LINKER) - self._output_flag = output_flag or "" self.add_flags(os.getenv("LDFLAGS", "").split()) # Maintain a set of flags for common libraries. @@ -44,16 +67,35 @@ def __init__(self, compiler: Compiler, name: Optional[str] = None, self._pre_lib_flags: List[str] = [] self._post_lib_flags: List[str] = [] - def get_output_flag(self) -> str: + def check_available(self): + return (self._compiler or self._linker).check_available() + + @property + def exec_name(self) -> str: + if self._compiler: + return self._compiler.exec_name + assert self._linker # make mypy happy + return self._linker.exec_name + + @property + def suite(self) -> str: + return cast(CompilerSuiteTool, (self._compiler or self._linker)).suite + + @property + def mpi(self) -> bool: + if self._compiler: + return self._compiler.mpi + assert self._linker + return self._linker.mpi + + @property + def output_flag(self) -> str: ''':returns: the flag that is used to specify the output name. ''' - if self._output_flag: - return self._output_flag - if not self.compiler.category == Category.LINKER: - return self.compiler.output_flag - - linker = cast(Linker, self.compiler) - return linker.get_output_flag() + if self._compiler: + return self._compiler.output_flag + assert self._linker + return self._linker.output_flag def get_lib_flags(self, lib: str) -> List[str]: '''Gets the standard flags for a standard library @@ -69,9 +111,8 @@ def get_lib_flags(self, lib: str) -> List[str]: except KeyError: # If a lib is not defined here, but this is a wrapper around # another linker, return the result from the wrapped linker - if self.compiler.category is Category.LINKER: - linker = cast(Linker, self.compiler) - return linker.get_lib_flags(lib) + if self._linker: + return self._linker.get_lib_flags(lib) raise RuntimeError(f"Unknown library name: '{lib}'") def add_lib_flags(self, lib: str, flags: List[str], @@ -127,16 +168,15 @@ def get_pre_link_flags(self) -> List[str]: wrapped linkers ''' params: List[str] = [] - + print("gplf", self._pre_lib_flags, self, self._linker) if self._pre_lib_flags: params.extend(self._pre_lib_flags) - if self.compiler.category == Category.LINKER: + if self._linker: # If we are wrapping a linker, get the wrapped linker's # pre-link flags and append them to the end (so the linker # wrapper's settings come before the setting from the # wrapped linker). - linker = cast(Linker, self.compiler) - params.extend(linker.get_pre_link_flags()) + params.extend(self._linker.get_pre_link_flags()) return params def link(self, input_files: List[Path], output_file: Path, @@ -155,14 +195,17 @@ def link(self, input_files: List[Path], output_file: Path, params: List[Union[str, Path]] = [] - if openmp: - # Find the compiler by following the (potentially - # layered) linker wrapper. - compiler = self.compiler - while compiler.category == Category.LINKER: - # Make mypy happy - compiler = cast(Linker, compiler).compiler + # Find the compiler by following the (potentially + # layered) linker wrapper. + linker = self + while linker._linker: + linker = linker._linker + # Now we must have a compiler + compiler = linker._compiler + assert compiler + params.extend(compiler.flags) + if openmp: params.append(compiler.openmp_flag) # TODO: why are the .o files sorted? That shouldn't matter @@ -174,6 +217,6 @@ def link(self, input_files: List[Path], output_file: Path, if self._post_lib_flags: params.extend(self._post_lib_flags) - params.extend([self.get_output_flag(), str(output_file)]) + params.extend([self.output_flag, str(output_file)]) return self.run(params) diff --git a/source/fab/tools/tool_repository.py b/source/fab/tools/tool_repository.py index d93c61c0..e4b4b256 100644 --- a/source/fab/tools/tool_repository.py +++ b/source/fab/tools/tool_repository.py @@ -128,7 +128,7 @@ def add_tool(self, tool: Tool): category=Category.LINKER, name=f"linker-{compiler.compiler.name}") other_linker = cast(Linker, other_linker) - linker = Linker(compiler=other_linker, + linker = Linker(linker=other_linker, name=f"linker-{compiler.name}") self[linker.category].append(linker) else: diff --git a/tests/unit_tests/steps/test_link.py b/tests/unit_tests/steps/test_link.py index 3203bf09..b6e12449 100644 --- a/tests/unit_tests/steps/test_link.py +++ b/tests/unit_tests/steps/test_link.py @@ -31,7 +31,7 @@ def test_run(self, tool_box, mock_fortran_compiler): with mock.patch.dict("os.environ", {"FFLAGS": "-L/foo1/lib -L/foo2/lib"}): # We need to create a linker here to pick up the env var: - linker = Linker(mock_fortran_compiler) + linker = Linker(compiler=mock_fortran_compiler) # Mark the linker as available to it can be added to the tool box linker._is_available = True diff --git a/tests/unit_tests/tools/test_linker.py b/tests/unit_tests/tools/test_linker.py index cb94a039..0a8b1a7a 100644 --- a/tests/unit_tests/tools/test_linker.py +++ b/tests/unit_tests/tools/test_linker.py @@ -22,13 +22,13 @@ def test_linker(mock_c_compiler, mock_fortran_compiler): assert mock_c_compiler.category == Category.C_COMPILER assert mock_c_compiler.name == "mock_c_compiler" - linker = Linker(mock_c_compiler, output_flag="-o") + linker = Linker(mock_c_compiler) assert linker.category == Category.LINKER assert linker.name == "linker-mock_c_compiler" assert linker.exec_name == "mock_c_compiler.exe" assert linker.suite == "suite" assert linker.flags == [] - assert linker.get_output_flag() == "-o" + assert linker.output_flag == "-o" assert mock_fortran_compiler.category == Category.FORTRAN_COMPILER assert mock_fortran_compiler.name == "mock_fortran_compiler" @@ -277,8 +277,8 @@ def test_linker_all_flag_types(mock_c_compiler): tool_run.assert_called_with([ "mock_c_compiler.exe", - "-compiler-flag1", "-compiler-flag2", "-fflag", "-ldflag", "-linker-flag1", "-linker-flag2", + "-compiler-flag1", "-compiler-flag2", "-fopenmp", "a.o", "-prelibflag1", "-prelibflag2", @@ -297,7 +297,7 @@ def test_linker_nesting(mock_c_compiler): linker1.add_pre_lib_flags(["pre_lib1"]) linker1.add_lib_flags("lib_a", ["a_from_1"]) linker1.add_lib_flags("lib_c", ["c_from_1"]) - linker2 = Linker(compiler=linker1) + linker2 = Linker(linker=linker1) linker2.add_pre_lib_flags(["pre_lib2"]) linker2.add_lib_flags("lib_b", ["b_from_2"]) linker2.add_lib_flags("lib_c", ["c_from_2"]) From 7b189e8610ad89054a4a7d3ffc42bd71dd9c465a Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Tue, 3 Dec 2024 11:39:42 +1100 Subject: [PATCH 43/55] Updated tests. --- source/fab/tools/linker.py | 3 +- tests/unit_tests/steps/test_link.py | 37 ++++++++++++++----- .../steps/test_link_shared_object.py | 22 ++++++++--- tests/unit_tests/tools/test_linker.py | 35 ++++++++++++++---- 4 files changed, 72 insertions(+), 25 deletions(-) diff --git a/source/fab/tools/linker.py b/source/fab/tools/linker.py index 9bbf0048..cb832c27 100644 --- a/source/fab/tools/linker.py +++ b/source/fab/tools/linker.py @@ -168,7 +168,6 @@ def get_pre_link_flags(self) -> List[str]: wrapped linkers ''' params: List[str] = [] - print("gplf", self._pre_lib_flags, self, self._linker) if self._pre_lib_flags: params.extend(self._pre_lib_flags) if self._linker: @@ -202,7 +201,7 @@ def link(self, input_files: List[Path], output_file: Path, linker = linker._linker # Now we must have a compiler compiler = linker._compiler - assert compiler + assert compiler # make mypy happy params.extend(compiler.flags) if openmp: diff --git a/tests/unit_tests/steps/test_link.py b/tests/unit_tests/steps/test_link.py index b6e12449..e9a6750c 100644 --- a/tests/unit_tests/steps/test_link.py +++ b/tests/unit_tests/steps/test_link.py @@ -3,22 +3,29 @@ # For further details please refer to the file COPYRIGHT # which you should have received as part of this distribution # ############################################################################## + +''' +Tests linking an executable. +''' + from pathlib import Path from types import SimpleNamespace from unittest import mock from fab.artefacts import ArtefactSet, ArtefactStore from fab.steps.link import link_exe -from fab.tools import Linker +from fab.tools import FortranCompiler, Linker import pytest class TestLinkExe: - def test_run(self, tool_box, mock_fortran_compiler): - # ensure the command is formed correctly, with the flags at the - # end (why?!) - + '''Test class for linking an executable. + ''' + def test_run(self, tool_box): + '''Ensure the command is formed correctly, with the flags at the + end and that environment variable FFLAGS is picked up. + ''' config = SimpleNamespace( project_workspace=Path('workspace'), artefact_store=ArtefactStore(), @@ -29,9 +36,20 @@ def test_run(self, tool_box, mock_fortran_compiler): config.artefact_store[ArtefactSet.OBJECT_FILES] = \ {'foo': {'foo.o', 'bar.o'}} - with mock.patch.dict("os.environ", {"FFLAGS": "-L/foo1/lib -L/foo2/lib"}): - # We need to create a linker here to pick up the env var: - linker = Linker(compiler=mock_fortran_compiler) + with mock.patch.dict("os.environ", + {"FFLAGS": "-L/foo1/lib -L/foo2/lib"}): + # We need to create the compiler here in order to pick + # up the environment + mock_compiler = FortranCompiler("mock_fortran_compiler", + "mock_fortran_compiler.exe", + "suite", module_folder_flag="", + version_regex="something", + syntax_only_flag=None, + compile_flag=None, + output_flag=None, openmp_flag=None) + mock_compiler.run = mock.Mock() + + linker = Linker(compiler=mock_compiler) # Mark the linker as available to it can be added to the tool box linker._is_available = True @@ -44,7 +62,8 @@ def test_run(self, tool_box, mock_fortran_compiler): pytest.warns(UserWarning, match="_metric_send_conn not " "set, cannot send metrics"): - link_exe(config, libs=['mylib'], flags=['-fooflag', '-barflag']) + link_exe(config, libs=['mylib'], + flags=['-fooflag', '-barflag']) tool_run.assert_called_with( ['mock_fortran_compiler.exe', '-L/foo1/lib', '-L/foo2/lib', diff --git a/tests/unit_tests/steps/test_link_shared_object.py b/tests/unit_tests/steps/test_link_shared_object.py index 5275b280..c76eb957 100644 --- a/tests/unit_tests/steps/test_link_shared_object.py +++ b/tests/unit_tests/steps/test_link_shared_object.py @@ -13,12 +13,12 @@ from fab.artefacts import ArtefactSet, ArtefactStore from fab.steps.link import link_shared_object -from fab.tools import Linker +from fab.tools import FortranCompiler, Linker import pytest -def test_run(tool_box, mock_c_compiler): +def test_run(tool_box): '''Ensure the command is formed correctly, with the flags at the end since they are typically libraries.''' @@ -33,8 +33,17 @@ def test_run(tool_box, mock_c_compiler): {None: {'foo.o', 'bar.o'}} with mock.patch.dict("os.environ", {"FFLAGS": "-L/foo1/lib -L/foo2/lib"}): - # We need to create a linker here to pick up the env var: - linker = Linker(mock_c_compiler) + # We need to create the compiler here in order to pick + # up the environment + mock_compiler = FortranCompiler("mock_fortran_compiler", + "mock_fortran_compiler.exe", + "suite", module_folder_flag="", + version_regex="something", + syntax_only_flag=None, + compile_flag=None, output_flag=None, + openmp_flag=None) + mock_compiler.run = mock.Mock() + linker = Linker(mock_compiler) # Mark the linker as available so it can added to the tool box: linker._is_available = True tool_box.add_tool(linker, silent_replace=True) @@ -47,6 +56,7 @@ def test_run(tool_box, mock_c_compiler): flags=['-fooflag', '-barflag']) tool_run.assert_called_with( - ['mock_c_compiler.exe', '-L/foo1/lib', '-L/foo2/lib', 'bar.o', 'foo.o', - '-fooflag', '-barflag', '-fPIC', '-shared', '-o', '/tmp/lib_my.so'], + ['mock_fortran_compiler.exe', '-L/foo1/lib', '-L/foo2/lib', 'bar.o', + 'foo.o', '-fooflag', '-barflag', '-fPIC', '-shared', + '-o', '/tmp/lib_my.so'], capture_output=True, env=None, cwd=None, check=False) diff --git a/tests/unit_tests/tools/test_linker.py b/tests/unit_tests/tools/test_linker.py index 0a8b1a7a..c0e31ad4 100644 --- a/tests/unit_tests/tools/test_linker.py +++ b/tests/unit_tests/tools/test_linker.py @@ -41,6 +41,31 @@ def test_linker(mock_c_compiler, mock_fortran_compiler): assert linker.flags == [] +def test_linker_constructor_error(mock_c_compiler): + '''Test the linker constructor with invalid parameters.''' + + with pytest.raises(RuntimeError) as err: + Linker() + assert ("Neither compiler nor linker is specified in linker constructor." + in str(err.value)) + with pytest.raises(RuntimeError) as err: + Linker(compiler=mock_c_compiler, linker=mock_c_compiler) + assert ("Both compiler and linker is specified in linker constructor." + in str(err.value)) + + +@pytest.mark.parametrize("mpi", [True, False]) +def test_linker_mpi(mock_c_compiler, mpi): + '''Test the linker constructor with invalid parameters.''' + + mock_c_compiler._mpi = mpi + linker = Linker(compiler=mock_c_compiler) + assert linker.mpi == mpi + + wrapped_linker = Linker(linker=linker) + assert wrapped_linker.mpi == mpi + + def test_linker_gets_ldflags(mock_c_compiler): """Tests that the linker retrieves env.LDFLAGS""" with mock.patch.dict("os.environ", {"LDFLAGS": "-lm"}): @@ -250,14 +275,8 @@ def test_linker_all_flag_types(mock_c_compiler): """Make sure all possible sources of linker flags are used in the right order""" - # Environment variables for both the compiler and linker - # TODO: THIS IS ACTUALLY WRONG - The FFLAGS shouldn't be picked up here, - # because the compiler already exists. It is being added twice, because - # Linker inherits Compiler (in addition to wrapping it) - with mock.patch.dict("os.environ", { - "FFLAGS": "-fflag", - "LDFLAGS": "-ldflag" - }): + # Environment variables for both the linker + with mock.patch.dict("os.environ", {"LDFLAGS": "-ldflag"}): linker = Linker(compiler=mock_c_compiler) mock_c_compiler.add_flags(["-compiler-flag1", "-compiler-flag2"]) From d084f206c6c2526f3182a2cbabaa6cf95a97e431 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Tue, 3 Dec 2024 13:40:07 +1100 Subject: [PATCH 44/55] Fix support for post-libs. --- source/fab/tools/linker.py | 45 ++++++++++++++++++++++----- source/fab/tools/tool_repository.py | 1 + tests/unit_tests/tools/test_linker.py | 9 ++++-- 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/source/fab/tools/linker.py b/source/fab/tools/linker.py index cb832c27..4bd962cf 100644 --- a/source/fab/tools/linker.py +++ b/source/fab/tools/linker.py @@ -34,6 +34,7 @@ class Linker(CompilerSuiteTool): def __init__(self, compiler: Optional[Compiler] = None, linker: Optional[Linker] = None, + exec_name: Optional[str] = None, name: Optional[str] = None): if linker and compiler: @@ -53,9 +54,13 @@ def __init__(self, compiler: Optional[Compiler] = None, assert final_compiler name = f"linker-{final_compiler.name}" + if not exec_name: + # This will search for the name in linker or compiler + exec_name = self.get_exec_name() + super().__init__( name=name or f"linker-{name}", - exec_name=self.exec_name, + exec_name=exec_name, suite=self.suite, category=Category.LINKER) @@ -67,11 +72,15 @@ def __init__(self, compiler: Optional[Compiler] = None, self._pre_lib_flags: List[str] = [] self._post_lib_flags: List[str] = [] - def check_available(self): + def check_available(self) -> bool: + ''':returns: whether this linker is available by asking the wrapped + linker or compiler. + ''' return (self._compiler or self._linker).check_available() - @property - def exec_name(self) -> str: + def get_exec_name(self) -> str: + ''':returns: the name of the executable by asking the wrapped + linker or compiler.''' if self._compiler: return self._compiler.exec_name assert self._linker # make mypy happy @@ -79,10 +88,14 @@ def exec_name(self) -> str: @property def suite(self) -> str: + ''':returns: the suite this linker belongs to by getting it from + the wrapper compiler or linker.''' return cast(CompilerSuiteTool, (self._compiler or self._linker)).suite @property def mpi(self) -> bool: + ''':returns" whether this linker supports MPI or not by checking + with the wrapper compiler or linker.''' if self._compiler: return self._compiler.mpi assert self._linker @@ -158,7 +171,7 @@ def add_post_lib_flags(self, flags: List[str]): def get_pre_link_flags(self) -> List[str]: '''Returns the list of pre-link flags. It will concatenate the - flags for this instance with all potentially wrapper linkers. + flags for this instance with all potentially wrapped linkers. This wrapper's flag will come first - the assumption is that the pre-link flags are likely paths, so we need a wrapper to be able to put a search path before the paths from a wrapped @@ -178,6 +191,25 @@ def get_pre_link_flags(self) -> List[str]: params.extend(self._linker.get_pre_link_flags()) return params + def get_post_link_flags(self) -> List[str]: + '''Returns the list of post-link flags. It will concatenate the + flags for this instance with all potentially wrapped linkers. + This wrapper's flag will be added to the end. + + :returns: List of post-link flags of this linker and all + wrapped linkers + ''' + params: List[str] = [] + if self._linker: + # If we are wrapping a linker, get the wrapped linker's + # pre-link flags and append them to the end (so the linker + # wrapper's settings come before the setting from the + # wrapped linker). + params.extend(self._linker.get_post_link_flags()) + if self._post_lib_flags: + params.extend(self._post_lib_flags) + return params + def link(self, input_files: List[Path], output_file: Path, openmp: bool, libs: Optional[List[str]] = None) -> str: @@ -214,8 +246,7 @@ def link(self, input_files: List[Path], output_file: Path, for lib in (libs or []): params.extend(self.get_lib_flags(lib)) - if self._post_lib_flags: - params.extend(self._post_lib_flags) + params.extend(self.get_post_link_flags()) params.extend([self.output_flag, str(output_file)]) return self.run(params) diff --git a/source/fab/tools/tool_repository.py b/source/fab/tools/tool_repository.py index e4b4b256..9f6e7abe 100644 --- a/source/fab/tools/tool_repository.py +++ b/source/fab/tools/tool_repository.py @@ -129,6 +129,7 @@ def add_tool(self, tool: Tool): name=f"linker-{compiler.compiler.name}") other_linker = cast(Linker, other_linker) linker = Linker(linker=other_linker, + exec_name=compiler.exec_name, name=f"linker-{compiler.name}") self[linker.category].append(linker) else: diff --git a/tests/unit_tests/tools/test_linker.py b/tests/unit_tests/tools/test_linker.py index c0e31ad4..3ed82994 100644 --- a/tests/unit_tests/tools/test_linker.py +++ b/tests/unit_tests/tools/test_linker.py @@ -316,10 +316,12 @@ def test_linker_nesting(mock_c_compiler): linker1.add_pre_lib_flags(["pre_lib1"]) linker1.add_lib_flags("lib_a", ["a_from_1"]) linker1.add_lib_flags("lib_c", ["c_from_1"]) + linker1.add_post_lib_flags(["post_lib1"]) linker2 = Linker(linker=linker1) linker2.add_pre_lib_flags(["pre_lib2"]) linker2.add_lib_flags("lib_b", ["b_from_2"]) linker2.add_lib_flags("lib_c", ["c_from_2"]) + linker1.add_post_lib_flags(["post_lib2"]) mock_result = mock.Mock(returncode=0) with mock.patch("fab.tools.tool.subprocess.run", @@ -328,9 +330,10 @@ def test_linker_nesting(mock_c_compiler): [Path("a.o")], Path("a.out"), libs=["lib_a", "lib_b", "lib_c"], openmp=True) - tool_run.assert_called_with(['mock_c_compiler.exe', '-fopenmp', - 'a.o', "pre_lib2", "pre_lib1", "a_from_1", - "b_from_2", "c_from_2", '-o', 'a.out'], + tool_run.assert_called_with(["mock_c_compiler.exe", "-fopenmp", + "a.o", "pre_lib2", "pre_lib1", "a_from_1", + "b_from_2", "c_from_2", + "post_lib1", "post_lib2", "-o", "a.out"], capture_output=True, env=None, cwd=None, check=False) From fe93339a3f765353f32687bae429674c5344588c Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Tue, 3 Dec 2024 13:53:35 +1100 Subject: [PATCH 45/55] Fixed mypy. --- source/fab/tools/linker.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/source/fab/tools/linker.py b/source/fab/tools/linker.py index 4bd962cf..8579df2c 100644 --- a/source/fab/tools/linker.py +++ b/source/fab/tools/linker.py @@ -76,7 +76,10 @@ def check_available(self) -> bool: ''':returns: whether this linker is available by asking the wrapped linker or compiler. ''' - return (self._compiler or self._linker).check_available() + if self._compiler: + return self._compiler.check_available() + assert self._linker # make mypy ghappy + return self._linker.check_available() def get_exec_name(self) -> str: ''':returns: the name of the executable by asking the wrapped From ccc8a39b8bcfc4080b1b0b3661f1f5c4464d041b Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Thu, 9 Jan 2025 15:53:56 +1100 Subject: [PATCH 46/55] Removed more accesses to private members. --- source/fab/parse/c.py | 42 ++++++++++++--------- source/fab/steps/analyse.py | 6 +-- source/fab/steps/psyclone.py | 4 +- tests/unit_tests/parse/c/test_c_analyser.py | 19 +++++----- 4 files changed, 36 insertions(+), 35 deletions(-) diff --git a/source/fab/parse/c.py b/source/fab/parse/c.py index 24c26045..7919d0c8 100644 --- a/source/fab/parse/c.py +++ b/source/fab/parse/c.py @@ -11,14 +11,14 @@ from pathlib import Path from typing import List, Optional, Union, Tuple -from fab.dep_tree import AnalysedDependent - try: import clang # type: ignore import clang.cindex # type: ignore except ImportError: clang = None +from fab.build_config import BuildConfig +from fab.dep_tree import AnalysedDependent from fab.util import log_or_dot, file_checksum logger = logging.getLogger(__name__) @@ -26,29 +26,33 @@ class AnalysedC(AnalysedDependent): """ - An analysis result for a single C file, containing symbol definitions and dependencies. + An analysis result for a single C file, containing symbol definitions and + dependencies. - Note: We don't need to worry about compile order with pure C projects; we can compile all in one go. - However, with a *Fortran -> C -> Fortran* dependency chain, we do need to ensure that one Fortran file - is compiled before another, so this class must be part of the dependency tree analysis. + Note: We don't need to worry about compile order with pure C projects; we + can compile all in one go. However, with a *Fortran -> C -> Fortran* + dependency chain, we do need to ensure that one Fortran file is + compiled before another, so this class must be part of the + dependency tree analysis. """ - # Note: This subclass adds nothing to it's parent, which provides everything it needs. - # We'd normally remove an irrelevant class like this but we want to keep the door open - # for filtering analysis results by type, rather than suffix. - pass + # Note: This subclass adds nothing to it's parent, which provides + # everything it needs. We'd normally remove an irrelevant class + # like this but we want to keep the door open for filtering + # analysis results by type, rather than suffix. -class CAnalyser(object): +class CAnalyser: """ Identify symbol definitions and dependencies in a C file. """ - def __init__(self): + def __init__(self, config: BuildConfig): # runtime - self._config = None + self._config = config + self._include_region = [] # todo: simplifiy by passing in the file path instead of the analysed tokens? def _locate_include_regions(self, trans_unit) -> None: @@ -100,8 +104,7 @@ def _check_for_include(self, lineno) -> Optional[str]: include_stack.pop() if include_stack: return include_stack[-1] - else: - return None + return None def run(self, fpath: Path) \ -> Union[Tuple[AnalysedC, Path], Tuple[Exception, None]]: @@ -149,9 +152,11 @@ def run(self, fpath: Path) \ continue logger.debug('Considering node: %s', node.spelling) - if node.kind in {clang.cindex.CursorKind.FUNCTION_DECL, clang.cindex.CursorKind.VAR_DECL}: + if node.kind in {clang.cindex.CursorKind.FUNCTION_DECL, + clang.cindex.CursorKind.VAR_DECL}: self._process_symbol_declaration(analysed_file, node, usr_symbols) - elif node.kind in {clang.cindex.CursorKind.CALL_EXPR, clang.cindex.CursorKind.DECL_REF_EXPR}: + elif node.kind in {clang.cindex.CursorKind.CALL_EXPR, + clang.cindex.CursorKind.DECL_REF_EXPR}: self._process_symbol_dependency(analysed_file, node, usr_symbols) except Exception as err: logger.exception(f'error walking parsed nodes {fpath}') @@ -166,7 +171,8 @@ def _process_symbol_declaration(self, analysed_file, node, usr_symbols): if node.is_definition(): # only global symbols can be used by other files, not static symbols if node.linkage == clang.cindex.LinkageKind.EXTERNAL: - # This should catch function definitions which are exposed to the rest of the application + # This should catch function definitions which are exposed to + # the rest of the application logger.debug(' * Is defined in this file') # todo: ignore if inside user pragmas? analysed_file.add_symbol_def(node.spelling) diff --git a/source/fab/steps/analyse.py b/source/fab/steps/analyse.py index 1e7c3a58..49d4b55a 100644 --- a/source/fab/steps/analyse.py +++ b/source/fab/steps/analyse.py @@ -133,7 +133,7 @@ def analyse( fortran_analyser = FortranAnalyser(config=config, std=std, ignore_mod_deps=ignore_mod_deps) - c_analyser = CAnalyser() + c_analyser = CAnalyser(config=config) # Creates the *build_trees* artefact from the files in `self.source_getter`. @@ -146,10 +146,6 @@ def analyse( # - At this point we have a source tree for the entire source. # - (Optionally) Extract a sub tree for every root symbol, if provided. For building executables. - # todo: code smell - refactor (in another PR to keep things small) - fortran_analyser._config = config - c_analyser._config = config - # parse files: List[Path] = source_getter(config.artefact_store) analysed_files = _parse_files(config, files=files, fortran_analyser=fortran_analyser, c_analyser=c_analyser) diff --git a/source/fab/steps/psyclone.py b/source/fab/steps/psyclone.py index bb05e291..c9bb3990 100644 --- a/source/fab/steps/psyclone.py +++ b/source/fab/steps/psyclone.py @@ -193,7 +193,6 @@ def _analyse_x90s(config, x90s: Set[Path]) -> Dict[Path, AnalysedX90]: # parse x90_analyser = X90Analyser(config=config) - x90_analyser._config = config with TimerLogger(f"analysing {len(parsable_x90s)} parsable x90 files"): x90_results = run_mp(config, items=parsable_x90s, func=x90_analyser.run) log_or_dot_finish(logger) @@ -209,7 +208,7 @@ def _analyse_x90s(config, x90s: Set[Path]) -> Dict[Path, AnalysedX90]: analysed_x90 = {result.fpath.with_suffix('.x90'): result for result in analysed_x90} # make the hashes from the original x90s, not the parsable versions which have invoke names removed. - for p, _ in analysed_x90.items(): + for p in analysed_x90: analysed_x90[p]._file_hash = file_checksum(p).file_hash return analysed_x90 @@ -250,7 +249,6 @@ def _analyse_kernels(config, kernel_roots) -> Dict[str, int]: # todo: We'd like to separate that from the general fortran analyser at some point, to reduce coupling. # The Analyse step also uses the same fortran analyser. It stores its results so they won't be analysed twice. fortran_analyser = FortranAnalyser(config=config) - fortran_analyser._config = config with TimerLogger(f"analysing {len(kernel_files)} potential psyclone kernel files"): fortran_results = run_mp(config, items=kernel_files, func=fortran_analyser.run) log_or_dot_finish(logger) diff --git a/tests/unit_tests/parse/c/test_c_analyser.py b/tests/unit_tests/parse/c/test_c_analyser.py index c288baf9..7d457da9 100644 --- a/tests/unit_tests/parse/c/test_c_analyser.py +++ b/tests/unit_tests/parse/c/test_c_analyser.py @@ -17,9 +17,9 @@ def test_simple_result(tmp_path): - c_analyser = CAnalyser() - c_analyser._config = BuildConfig('proj', ToolBox(), mpi=False, - openmp=False, fab_workspace=tmp_path) + config = BuildConfig('proj', ToolBox(), mpi=False, openmp=False, + fab_workspace=tmp_path) + c_analyser = CAnalyser(config) with mock.patch('fab.parse.AnalysedFile.save'): fpath = Path(__file__).parent / "test_c_analyser.c" @@ -72,7 +72,7 @@ def __init__(self, spelling, line): mock_trans_unit = Mock() mock_trans_unit.cursor.get_tokens.return_value = tokens - analyser = CAnalyser() + analyser = CAnalyser(config=None) analyser._locate_include_regions(mock_trans_unit) assert analyser._include_region == expect @@ -81,7 +81,7 @@ def __init__(self, spelling, line): class Test__check_for_include: def test_vanilla(self): - analyser = CAnalyser() + analyser = CAnalyser(config=None) analyser._include_region = [ (10, "sys_include_start"), (20, "sys_include_end"), @@ -113,7 +113,7 @@ def _definition(self, spelling, linkage): node.linkage = linkage node.spelling = spelling - analyser = CAnalyser() + analyser = CAnalyser(config=None) analysed_file = Mock() analyser._process_symbol_declaration(analysed_file=analysed_file, node=node, usr_symbols=None) @@ -134,7 +134,7 @@ def _declaration(self, spelling, include_type): node.is_definition.return_value = False node.spelling = spelling - analyser = CAnalyser() + analyser = CAnalyser(config=None) analyser._check_for_include = Mock(return_value=include_type) usr_symbols = [] @@ -155,7 +155,7 @@ def test_not_usr_symbol(self): analysed_file.add_symbol_dep.assert_not_called() def _dependency(self, spelling, usr_symbols): - analyser = CAnalyser() + analyser = CAnalyser(config=None) analysed_file = Mock() node = Mock(spelling=spelling) @@ -168,7 +168,8 @@ def test_clang_disable(): with mock.patch('fab.parse.c.clang', None): with mock.patch('fab.parse.c.file_checksum') as mock_file_checksum: - result = CAnalyser().run(Path(__file__).parent / "test_c_analyser.c") + c_analyser = CAnalyser(config=None) + result = c_analyser.run(Path(__file__).parent / "test_c_analyser.c") assert isinstance(result[0], ImportWarning) mock_file_checksum.assert_not_called() From 34f498571f6e46c3294dddc9db4fd7578bf10402 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Thu, 9 Jan 2025 15:57:47 +1100 Subject: [PATCH 47/55] Added missing type hint. --- source/fab/parse/c.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/fab/parse/c.py b/source/fab/parse/c.py index 7919d0c8..19ef97f4 100644 --- a/source/fab/parse/c.py +++ b/source/fab/parse/c.py @@ -52,7 +52,7 @@ def __init__(self, config: BuildConfig): # runtime self._config = config - self._include_region = [] + self._include_region: List[Tuple[int,str]] = [] # todo: simplifiy by passing in the file path instead of the analysed tokens? def _locate_include_regions(self, trans_unit) -> None: From 686f990a2d4e8f413594890b3c39760ca22d5f22 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Thu, 9 Jan 2025 16:00:42 +1100 Subject: [PATCH 48/55] Make flake8 happy. --- source/fab/parse/c.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/fab/parse/c.py b/source/fab/parse/c.py index 19ef97f4..02cf2853 100644 --- a/source/fab/parse/c.py +++ b/source/fab/parse/c.py @@ -52,7 +52,7 @@ def __init__(self, config: BuildConfig): # runtime self._config = config - self._include_region: List[Tuple[int,str]] = [] + self._include_region: List[Tuple[int, str]] = [] # todo: simplifiy by passing in the file path instead of the analysed tokens? def _locate_include_regions(self, trans_unit) -> None: From 439571d551a37f90ef7c805a66947c321260679d Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Fri, 17 Jan 2025 18:32:53 +1100 Subject: [PATCH 49/55] Added missing openmp handling in linker. --- source/fab/tools/linker.py | 11 ++++++++++- tests/unit_tests/tools/test_linker.py | 27 ++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/source/fab/tools/linker.py b/source/fab/tools/linker.py index 8579df2c..68343623 100644 --- a/source/fab/tools/linker.py +++ b/source/fab/tools/linker.py @@ -78,7 +78,7 @@ def check_available(self) -> bool: ''' if self._compiler: return self._compiler.check_available() - assert self._linker # make mypy ghappy + assert self._linker # make mypy happy return self._linker.check_available() def get_exec_name(self) -> str: @@ -104,6 +104,15 @@ def mpi(self) -> bool: assert self._linker return self._linker.mpi + @property + def openmp(self) -> bool: + ''':returns" whether this linker supports OpenMP or not by checking + with the wrapper compiler or linker.''' + if self._compiler: + return self._compiler.openmp + assert self._linker + return self._linker.openmp + @property def output_flag(self) -> str: ''':returns: the flag that is used to specify the output name. diff --git a/tests/unit_tests/tools/test_linker.py b/tests/unit_tests/tools/test_linker.py index 3ed82994..ce5714fb 100644 --- a/tests/unit_tests/tools/test_linker.py +++ b/tests/unit_tests/tools/test_linker.py @@ -56,7 +56,7 @@ def test_linker_constructor_error(mock_c_compiler): @pytest.mark.parametrize("mpi", [True, False]) def test_linker_mpi(mock_c_compiler, mpi): - '''Test the linker constructor with invalid parameters.''' + '''Test that linker wrappers handle MPI as expected.''' mock_c_compiler._mpi = mpi linker = Linker(compiler=mock_c_compiler) @@ -66,6 +66,24 @@ def test_linker_mpi(mock_c_compiler, mpi): assert wrapped_linker.mpi == mpi +@pytest.mark.parametrize("openmp", [True, False]) +def test_linker_openmp(mock_c_compiler, openmp): + '''Test that linker wrappers handle openmp as expected. Note that + a compiler detects support for OpenMP by checking if an openmp flag + is defined. + ''' + + if openmp: + mock_c_compiler._openmp_flag = "-some-openmp-flag" + else: + mock_c_compiler._openmp_flag = "" + linker = Linker(compiler=mock_c_compiler) + assert linker.openmp == openmp + + wrapped_linker = Linker(linker=linker) + assert wrapped_linker.openmp == openmp + + def test_linker_gets_ldflags(mock_c_compiler): """Tests that the linker retrieves env.LDFLAGS""" with mock.patch.dict("os.environ", {"LDFLAGS": "-lm"}): @@ -83,6 +101,13 @@ def test_linker_check_available(mock_c_compiler): return_value=(1, 2, 3)): assert linker.check_available() + # Then test the usage of a linker wrapper. The linker will call the + # corresponding function in the wrapper linker: + wrapped_linker = Linker(linker=linker) + with mock.patch('fab.tools.compiler.Compiler.get_version', + return_value=(1, 2, 3)): + assert wrapped_linker.check_available() + def test_linker_check_unavailable(mock_c_compiler): '''Tests the is_available functionality.''' From 3501e5957878727d3adf67b24e62e14649195b2b Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Wed, 29 Jan 2025 13:34:14 +1100 Subject: [PATCH 50/55] Addressed issues raised in review. --- source/fab/tools/compiler_wrapper.py | 9 +++------ source/fab/tools/linker.py | 14 +++++++------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/source/fab/tools/compiler_wrapper.py b/source/fab/tools/compiler_wrapper.py index d7b167cd..9338f848 100644 --- a/source/fab/tools/compiler_wrapper.py +++ b/source/fab/tools/compiler_wrapper.py @@ -4,8 +4,8 @@ # 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 +"""This file contains the base class for any compiler-wrapper, including +the derived classes for mpif90, mpicc, and CrayFtnWrapper and CrayCcWrapper. """ from pathlib import Path @@ -25,19 +25,16 @@ class CompilerWrapper(Compiler): :param name: name of the wrapper. :param exec_name: name of the executable to call. :param compiler: the compiler that is decorated. - :param category: the tool's category. Defaults to the compiler's category. :param mpi: whether MPI is supported by this compiler or not. ''' def __init__(self, name: str, exec_name: str, compiler: Compiler, - category: Optional[Category] = None, mpi: bool = False): self._compiler = compiler - category = category or self._compiler.category super().__init__( name=name, exec_name=exec_name, - category=category, + category=self._compiler.category, suite=self._compiler.suite, version_regex=self._compiler._version_regex, mpi=mpi, diff --git a/source/fab/tools/linker.py b/source/fab/tools/linker.py index 68343623..2acef01b 100644 --- a/source/fab/tools/linker.py +++ b/source/fab/tools/linker.py @@ -51,7 +51,7 @@ def __init__(self, compiler: Optional[Compiler] = None, search_linker = search_linker._linker final_compiler = search_linker._compiler if not name: - assert final_compiler + assert final_compiler # make mypy happy name = f"linker-{final_compiler.name}" if not exec_name: @@ -59,7 +59,7 @@ def __init__(self, compiler: Optional[Compiler] = None, exec_name = self.get_exec_name() super().__init__( - name=name or f"linker-{name}", + name=name, exec_name=exec_name, suite=self.suite, category=Category.LINKER) @@ -101,7 +101,7 @@ def mpi(self) -> bool: with the wrapper compiler or linker.''' if self._compiler: return self._compiler.mpi - assert self._linker + assert self._linker # make mypy happy return self._linker.mpi @property @@ -110,7 +110,7 @@ def openmp(self) -> bool: with the wrapper compiler or linker.''' if self._compiler: return self._compiler.openmp - assert self._linker + assert self._linker # make mypy happy return self._linker.openmp @property @@ -119,7 +119,7 @@ def output_flag(self) -> str: ''' if self._compiler: return self._compiler.output_flag - assert self._linker + assert self._linker # make mypy happy return self._linker.output_flag def get_lib_flags(self, lib: str) -> List[str]: @@ -214,8 +214,8 @@ def get_post_link_flags(self) -> List[str]: params: List[str] = [] if self._linker: # If we are wrapping a linker, get the wrapped linker's - # pre-link flags and append them to the end (so the linker - # wrapper's settings come before the setting from the + # post-link flags and add them first (so this linker + # wrapper's settings come after the setting from the # wrapped linker). params.extend(self._linker.get_post_link_flags()) if self._post_lib_flags: From ddf240a6d1e89795f4d7446ca7940964bf72af20 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Wed, 29 Jan 2025 13:53:11 +1100 Subject: [PATCH 51/55] Forgot that file in previous commit. --- tests/unit_tests/tools/test_linker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/tools/test_linker.py b/tests/unit_tests/tools/test_linker.py index ce5714fb..052af88d 100644 --- a/tests/unit_tests/tools/test_linker.py +++ b/tests/unit_tests/tools/test_linker.py @@ -94,7 +94,7 @@ def test_linker_gets_ldflags(mock_c_compiler): def test_linker_check_available(mock_c_compiler): '''Tests the is_available functionality.''' - # First test if a compiler is given. The linker will call the + # First test when a compiler is given. The linker will call the # corresponding function in the compiler: linker = Linker(mock_c_compiler) with mock.patch('fab.tools.compiler.Compiler.get_version', From 68e175e672abb399ac331044724342fcc9cb781c Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Tue, 4 Feb 2025 12:29:07 +1100 Subject: [PATCH 52/55] Updated linker to always require a compiler (previously it was actually never checked if the wrapped compiler actually exists, it only checked the linker). --- source/fab/tools/linker.py | 88 ++++++++------------------- source/fab/tools/tool_repository.py | 16 ++--- tests/unit_tests/tools/test_linker.py | 23 ++----- 3 files changed, 37 insertions(+), 90 deletions(-) diff --git a/source/fab/tools/linker.py b/source/fab/tools/linker.py index 2acef01b..63a3dd2b 100644 --- a/source/fab/tools/linker.py +++ b/source/fab/tools/linker.py @@ -11,7 +11,7 @@ import os from pathlib import Path -from typing import cast, Dict, List, Optional, Union +from typing import Dict, List, Optional, Union import warnings from fab.tools.category import Category @@ -20,11 +20,15 @@ class Linker(CompilerSuiteTool): - '''This is the base class for any Linker. It takes either another linker - instance, or a compiler instance as parameter in the constructor. Exactly - one of these must be provided. - - :param compiler: an optional compiler instance + '''This is the base class for any Linker. It takes an existing compiler + instance as parameter, and optional another linker. The latter is used + to get linker settings - for example, linker-mpif90-gfortran will use + mpif90-gfortran as compiler (i.e. to test if it is available and get + compilation flags), and linker-gfortran as linker. This way a user + only has to specify linker flags in the most basic class (gfortran), + all other linker wrapper will inherit the settings. + + :param compiler: a compiler instance :param linker: an optional linker instance :param name: name of the linker @@ -32,35 +36,19 @@ class Linker(CompilerSuiteTool): :raises RuntimeError: if neither compiler nor linker is specified. ''' - def __init__(self, compiler: Optional[Compiler] = None, + def __init__(self, compiler: Compiler, linker: Optional[Linker] = None, - exec_name: Optional[str] = None, name: Optional[str] = None): - if linker and compiler: - raise RuntimeError("Both compiler and linker is specified in " - "linker constructor.") - if not linker and not compiler: - raise RuntimeError("Neither compiler nor linker is specified in " - "linker constructor.") self._compiler = compiler self._linker = linker - search_linker = self - while search_linker._linker: - search_linker = search_linker._linker - final_compiler = search_linker._compiler if not name: - assert final_compiler # make mypy happy - name = f"linker-{final_compiler.name}" - - if not exec_name: - # This will search for the name in linker or compiler - exec_name = self.get_exec_name() + name = f"linker-{compiler.name}" super().__init__( name=name, - exec_name=exec_name, + exec_name=compiler.exec_name, suite=self.suite, category=Category.LINKER) @@ -76,51 +64,31 @@ def check_available(self) -> bool: ''':returns: whether this linker is available by asking the wrapped linker or compiler. ''' - if self._compiler: - return self._compiler.check_available() - assert self._linker # make mypy happy - return self._linker.check_available() - - def get_exec_name(self) -> str: - ''':returns: the name of the executable by asking the wrapped - linker or compiler.''' - if self._compiler: - return self._compiler.exec_name - assert self._linker # make mypy happy - return self._linker.exec_name + return self._compiler.check_available() @property def suite(self) -> str: ''':returns: the suite this linker belongs to by getting it from - the wrapper compiler or linker.''' - return cast(CompilerSuiteTool, (self._compiler or self._linker)).suite + the wrapped compiler.''' + return self._compiler.suite @property def mpi(self) -> bool: ''':returns" whether this linker supports MPI or not by checking - with the wrapper compiler or linker.''' - if self._compiler: - return self._compiler.mpi - assert self._linker # make mypy happy - return self._linker.mpi + with the wrapped compiler.''' + return self._compiler.mpi @property def openmp(self) -> bool: - ''':returns" whether this linker supports OpenMP or not by checking - with the wrapper compiler or linker.''' - if self._compiler: - return self._compiler.openmp - assert self._linker # make mypy happy - return self._linker.openmp + ''':returns: whether this linker supports OpenMP or not by checking + with the wrapped compiler.''' + return self._compiler.openmp @property def output_flag(self) -> str: ''':returns: the flag that is used to specify the output name. ''' - if self._compiler: - return self._compiler.output_flag - assert self._linker # make mypy happy - return self._linker.output_flag + return self._compiler.output_flag def get_lib_flags(self, lib: str) -> List[str]: '''Gets the standard flags for a standard library @@ -238,18 +206,10 @@ def link(self, input_files: List[Path], output_file: Path, params: List[Union[str, Path]] = [] - # Find the compiler by following the (potentially - # layered) linker wrapper. - linker = self - while linker._linker: - linker = linker._linker - # Now we must have a compiler - compiler = linker._compiler - assert compiler # make mypy happy - params.extend(compiler.flags) + params.extend(self._compiler.flags) if openmp: - params.append(compiler.openmp_flag) + params.append(self._compiler.openmp_flag) # TODO: why are the .o files sorted? That shouldn't matter params.extend(sorted(map(str, input_files))) diff --git a/source/fab/tools/tool_repository.py b/source/fab/tools/tool_repository.py index 1bf839f8..a9749757 100644 --- a/source/fab/tools/tool_repository.py +++ b/source/fab/tools/tool_repository.py @@ -117,19 +117,19 @@ def add_tool(self, tool: Tool): compiler = cast(Compiler, tool) if isinstance(compiler, CompilerWrapper): # If we have a compiler wrapper, create a new linker using - # the linker based on the wrappped compiler. For example, when + # the linker based on the wrapped compiler. For example, when # creating linker-mpif90-gfortran, we want this to be based on - # linker-gfortran (and not on the compiler mpif90-gfortran), - # since the linker-gfortran might have library definitions - # that should be reused. So we first get the existing linker - # (since the compiler exists, a linker for this compiler was - # already created and must exist). + # linker-gfortran. The compiler mpif90-gfortran will be the + # wrapper compiler. Reason is that e.g. linker-gfortran might + # have library definitions that should be reused. So we first + # get the existing linker (since the compiler exists, a linker + # for this compiler was already created and must exist). other_linker = self.get_tool( category=Category.LINKER, name=f"linker-{compiler.compiler.name}") other_linker = cast(Linker, other_linker) - linker = Linker(linker=other_linker, - exec_name=compiler.exec_name, + linker = Linker(compiler, + linker=other_linker, name=f"linker-{compiler.name}") self[linker.category].append(linker) else: diff --git a/tests/unit_tests/tools/test_linker.py b/tests/unit_tests/tools/test_linker.py index 052af88d..cd2d8dc9 100644 --- a/tests/unit_tests/tools/test_linker.py +++ b/tests/unit_tests/tools/test_linker.py @@ -41,28 +41,15 @@ def test_linker(mock_c_compiler, mock_fortran_compiler): assert linker.flags == [] -def test_linker_constructor_error(mock_c_compiler): - '''Test the linker constructor with invalid parameters.''' - - with pytest.raises(RuntimeError) as err: - Linker() - assert ("Neither compiler nor linker is specified in linker constructor." - in str(err.value)) - with pytest.raises(RuntimeError) as err: - Linker(compiler=mock_c_compiler, linker=mock_c_compiler) - assert ("Both compiler and linker is specified in linker constructor." - in str(err.value)) - - @pytest.mark.parametrize("mpi", [True, False]) def test_linker_mpi(mock_c_compiler, mpi): '''Test that linker wrappers handle MPI as expected.''' mock_c_compiler._mpi = mpi - linker = Linker(compiler=mock_c_compiler) + linker = Linker(mock_c_compiler) assert linker.mpi == mpi - wrapped_linker = Linker(linker=linker) + wrapped_linker = Linker(mock_c_compiler, linker=linker) assert wrapped_linker.mpi == mpi @@ -80,7 +67,7 @@ def test_linker_openmp(mock_c_compiler, openmp): linker = Linker(compiler=mock_c_compiler) assert linker.openmp == openmp - wrapped_linker = Linker(linker=linker) + wrapped_linker = Linker(mock_c_compiler, linker=linker) assert wrapped_linker.openmp == openmp @@ -103,7 +90,7 @@ def test_linker_check_available(mock_c_compiler): # Then test the usage of a linker wrapper. The linker will call the # corresponding function in the wrapper linker: - wrapped_linker = Linker(linker=linker) + wrapped_linker = Linker(mock_c_compiler, linker=linker) with mock.patch('fab.tools.compiler.Compiler.get_version', return_value=(1, 2, 3)): assert wrapped_linker.check_available() @@ -342,7 +329,7 @@ def test_linker_nesting(mock_c_compiler): linker1.add_lib_flags("lib_a", ["a_from_1"]) linker1.add_lib_flags("lib_c", ["c_from_1"]) linker1.add_post_lib_flags(["post_lib1"]) - linker2 = Linker(linker=linker1) + linker2 = Linker(mock_c_compiler, linker=linker1) linker2.add_pre_lib_flags(["pre_lib2"]) linker2.add_lib_flags("lib_b", ["b_from_2"]) linker2.add_lib_flags("lib_c", ["c_from_2"]) From 82a91a974c723239031f5424c82c278ee06d3597 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Tue, 4 Feb 2025 12:30:10 +1100 Subject: [PATCH 53/55] Remove unreliable test, since there is no guarantee that a C linker is returned, see issue #379. --- tests/unit_tests/tools/test_tool_repository.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/unit_tests/tools/test_tool_repository.py b/tests/unit_tests/tools/test_tool_repository.py index 0c7d77e5..012487d4 100644 --- a/tests/unit_tests/tools/test_tool_repository.py +++ b/tests/unit_tests/tools/test_tool_repository.py @@ -11,7 +11,7 @@ import pytest from fab.tools import (Ar, Category, FortranCompiler, Gcc, Gfortran, Ifort, - Linker, ToolRepository) + ToolRepository) def test_tool_repository_get_singleton_new(): @@ -62,10 +62,6 @@ def test_tool_repository_get_default(): openmp=False) assert isinstance(gfortran, Gfortran) - gcc_linker = tr.get_default(Category.LINKER, mpi=False, openmp=False) - assert isinstance(gcc_linker, Linker) - assert gcc_linker.name == "linker-gcc" - gcc = tr.get_default(Category.C_COMPILER, mpi=False, openmp=False) assert isinstance(gcc, Gcc) From 16bf511df05c10308da5140c48cf2b5c70dcf23e Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Tue, 4 Feb 2025 12:41:41 +1100 Subject: [PATCH 54/55] Fixed bug that a wrapper would not report openmp status based on the compiler. --- source/fab/tools/compiler.py | 4 +++- tests/unit_tests/tools/test_compiler_wrapper.py | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/source/fab/tools/compiler.py b/source/fab/tools/compiler.py index b937e9d1..8e04d011 100644 --- a/source/fab/tools/compiler.py +++ b/source/fab/tools/compiler.py @@ -76,7 +76,9 @@ def mpi(self) -> bool: def openmp(self) -> bool: ''':returns: if the compiler supports openmp or not ''' - return self._openmp_flag != "" + # It is important not to use `_openmp_flag` directly, since a compiler + # wrapper overwrites `openmp_flag`. + return self.openmp_flag != "" @property def openmp_flag(self) -> str: diff --git a/tests/unit_tests/tools/test_compiler_wrapper.py b/tests/unit_tests/tools/test_compiler_wrapper.py index 07f9a08b..64f65589 100644 --- a/tests/unit_tests/tools/test_compiler_wrapper.py +++ b/tests/unit_tests/tools/test_compiler_wrapper.py @@ -257,6 +257,14 @@ def test_compiler_wrapper_flags_independent(): assert mpicc.flags == ["-a", "-b"] assert mpicc.openmp_flag == gcc.openmp_flag + # Test a compiler wrapper correctly queries the wrapper compiler + # for openmp flag: Set the wrapper to have no _openmp_flag (which + # is actually the default, since it never sets this), but gcc + # does have a flag -s o mpicc should report that is supports openmp + with mock.patch.object(mpicc, "_openmp_flag", ""): + assert mpicc._openmp_flag == "" + assert mpicc.openmp + # Adding flags to the wrapper should not affect the wrapped compiler: mpicc.add_flags(["-d", "-e"]) assert gcc.flags == ["-a", "-b"] From 6e5170d231ecd1bb5ef47fab5f8a3672eed56830 Mon Sep 17 00:00:00 2001 From: Joerg Henrichs Date: Wed, 5 Feb 2025 00:01:44 +1100 Subject: [PATCH 55/55] Added more description for this test. --- tests/unit_tests/tools/test_compiler_wrapper.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/unit_tests/tools/test_compiler_wrapper.py b/tests/unit_tests/tools/test_compiler_wrapper.py index 64f65589..11096f0c 100644 --- a/tests/unit_tests/tools/test_compiler_wrapper.py +++ b/tests/unit_tests/tools/test_compiler_wrapper.py @@ -257,10 +257,14 @@ def test_compiler_wrapper_flags_independent(): assert mpicc.flags == ["-a", "-b"] assert mpicc.openmp_flag == gcc.openmp_flag - # Test a compiler wrapper correctly queries the wrapper compiler - # for openmp flag: Set the wrapper to have no _openmp_flag (which - # is actually the default, since it never sets this), but gcc - # does have a flag -s o mpicc should report that is supports openmp + # Test a compiler wrapper correctly queries the wrapper compiler for + # openmp flag: Set the wrapper to have no _openmp_flag (which is + # actually the default, since the wrapper never sets its own flag), but + # gcc does have a flag, so mpicc should report that is supports openmp. + # mpicc.openmp calls openmp of its base class (Compiler), which queries + # if an openmp flag is defined. This query must go to the openmp property, + # since the wrapper overwrites this property to return the wrapped + # compiler's flag (and not the wrapper's flag, which would not be defined) with mock.patch.object(mpicc, "_openmp_flag", ""): assert mpicc._openmp_flag == "" assert mpicc.openmp