From e670b6f257e6a113f3d4523d998b49ee94db6892 Mon Sep 17 00:00:00 2001 From: casparvl Date: Thu, 1 Oct 2026 16:13:46 +0000 Subject: [PATCH 1/5] Move supported toplevel toolchains from eb_hooks.py into a separate JSON file The supported toplevel toolchains per EESSI version are now defined in eessi_supported_toolchains.json rather than in eb_hooks.py itself, so that they can also be used by other scripts without having to parse (or import) the hooks file. - eessi_supported_toolchains.json is located next to eb_hooks.py, both in the repository and when installed in /init/easybuild/ by install_scripts.sh. eb_hooks.py locates it relative to its own location. - Toolchains that can only be installed with a recent enough EasyBuild version (lfoss/2025b, rompi/2025a) now specify 'min_easybuild_version' instead of being appended conditionally in eb_hooks.py. - CI also checks that the deployed eessi_supported_toolchains.json is up-to-date, like is done for eb_hooks.py. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test-eb-hooks.yml | 22 +++++++++++++ eb_hooks.py | 49 +++++++++++++++-------------- eessi_supported_toolchains.json | 18 +++++++++++ install_scripts.sh | 1 + 4 files changed, 66 insertions(+), 24 deletions(-) create mode 100644 eessi_supported_toolchains.json diff --git a/.github/workflows/test-eb-hooks.yml b/.github/workflows/test-eb-hooks.yml index dfcc576c..bf8a48cc 100644 --- a/.github/workflows/test-eb-hooks.yml +++ b/.github/workflows/test-eb-hooks.yml @@ -51,6 +51,28 @@ jobs: module load EESSI-extend diff "$TEMP_FILE" "$EASYBUILD_HOOKS" + - name: Check whether eessi_supported_toolchains.json (used by eb_hooks.py) is up-to-date + if: ${{ github.event_name == 'pull_request' }} + run: | + FILE="eessi_supported_toolchains.json" + TEMP_FILE="$(mktemp)" + + # Fetch base branch + git fetch origin ${{ github.base_ref }} + + # Check if the file has changed in the PR + if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q "^$FILE$"; then + echo "$FILE changed in PR. Using PR version." + cp "$FILE" "$TEMP_FILE" + else + echo "File not changed in PR. Using default branch version." + git show origin/${{ github.base_ref }}:$FILE > "$TEMP_FILE" + fi + + # Compare to what is shipped next to the hooks in the repository + module load EESSI-extend + diff "$TEMP_FILE" "$(dirname $EASYBUILD_HOOKS)/$FILE" + check_eb_hooks_functionality: runs-on: ubuntu-24.04 strategy: diff --git a/eb_hooks.py b/eb_hooks.py index fc26f88b..547f9e0e 100644 --- a/eb_hooks.py +++ b/eb_hooks.py @@ -67,31 +67,32 @@ STACK_REPROD_SUBDIR = 'reprod' -EESSI_SUPPORTED_TOP_LEVEL_TOOLCHAINS = { - '2023.06': [ - {'name': 'foss', 'version': '2022b'}, - {'name': 'foss', 'version': '2023a'}, - {'name': 'foss', 'version': '2023b'}, - ], - '2025.06': [ - {'name': 'foss', 'version': '2024a'}, - {'name': 'foss', 'version': '2025a'}, - {'name': 'foss', 'version': '2025b'}, - ], - '2026.06': [ - {'name': 'foss', 'version': '2026.1'}, - {'name': 'lfoss', 'version': '2026.1'}, - ], -} -if EASYBUILD_VERSION >= '5.2.0': - EESSI_SUPPORTED_TOP_LEVEL_TOOLCHAINS['2025.06'].append( - {'name': 'lfoss', 'version': '2025b'} - ) -if EASYBUILD_VERSION >= '5.3.1': - EESSI_SUPPORTED_TOP_LEVEL_TOOLCHAINS['2025.06'].append( - {'name': 'rompi', 'version': '2025a'} - ) +def load_supported_top_level_toolchains(): + """ + Load the supported top-level toolchains per EESSI version from eessi_supported_toolchains.json, + which is located next to this hooks file (both in the software-layer-scripts repository, and when installed + in /init/easybuild). Toolchains that require a more recent EasyBuild version than the one + being used (as specified via 'min_easybuild_version') are left out. + """ + toolchains_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'eessi_supported_toolchains.json') + try: + with open(toolchains_file) as fh: + toolchains = json.load(fh) + except (OSError, ValueError) as err: + raise EasyBuildError(f"Failed to load supported toolchains from {toolchains_file} " + f"(it is expected to be located next to the EasyBuild hooks file): {err}") + + return { + eessi_version: [ + {'name': tc['name'], 'version': tc['version']} for tc in tcs + if EASYBUILD_VERSION >= tc.get('min_easybuild_version', '0') + ] + for eessi_version, tcs in toolchains.items() + } + + +EESSI_SUPPORTED_TOP_LEVEL_TOOLCHAINS = load_supported_top_level_toolchains() # Supported compute capabilities by CUDA toolkit version # Obtained by installing all CUDAs from 12.0.0 to 13.3.0, then using: diff --git a/eessi_supported_toolchains.json b/eessi_supported_toolchains.json new file mode 100644 index 00000000..d89d38fe --- /dev/null +++ b/eessi_supported_toolchains.json @@ -0,0 +1,18 @@ +{ + "2023.06": [ + {"name": "foss", "version": "2022b"}, + {"name": "foss", "version": "2023a"}, + {"name": "foss", "version": "2023b"} + ], + "2025.06": [ + {"name": "foss", "version": "2024a"}, + {"name": "foss", "version": "2025a"}, + {"name": "foss", "version": "2025b"}, + {"name": "lfoss", "version": "2025b", "min_easybuild_version": "5.2.0"}, + {"name": "rompi", "version": "2025a", "min_easybuild_version": "5.3.1"} + ], + "2026.06": [ + {"name": "foss", "version": "2026.1"}, + {"name": "lfoss", "version": "2026.1"} + ] +} diff --git a/install_scripts.sh b/install_scripts.sh index 48745a3d..29ffcf24 100755 --- a/install_scripts.sh +++ b/install_scripts.sh @@ -225,6 +225,7 @@ fi # Copy over EasyBuild hooks file used for installations hook_files=( eb_hooks.py + eessi_supported_toolchains.json ) copy_files_by_list ${TOPDIR} ${INSTALL_PREFIX}/init/easybuild "${hook_files[@]}" From 986d210dd97ea8b9551e6c117d68678ce857d691 Mon Sep 17 00:00:00 2001 From: Caspar van Leeuwen Date: Mon, 5 Oct 2026 16:53:17 +0200 Subject: [PATCH 2/5] Address review comments on supported toolchains JSON - Allow setting the location of the supported toolchains file through EESSI_SUPPORTED_TOOLCHAINS_FILE (default: next to eb_hooks.py), with specific errors for a missing file and for invalid JSON - Document return value of load_supported_top_level_toolchains() - Rename hook_files to easybuild_init_files in install_scripts.sh - Rename test-eb-hooks.yml to test-eb-init-files.yml and check all init files in a single loop instead of duplicating the step - Add unit tests for the JSON format and loader, run in CI only when relevant files change Co-Authored-By: Claude Sonnet 5.5 --- .../workflows/test-eb-hooks-toolchains.yml | 33 +++++ ...st-eb-hooks.yml => test-eb-init-files.yml} | 62 ++++------ eb_hooks.py | 41 +++++-- install_scripts.sh | 6 +- tests/eb_hooks/test_supported_toolchains.py | 113 ++++++++++++++++++ 5 files changed, 205 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/test-eb-hooks-toolchains.yml rename .github/workflows/{test-eb-hooks.yml => test-eb-init-files.yml} (79%) create mode 100644 tests/eb_hooks/test_supported_toolchains.py diff --git a/.github/workflows/test-eb-hooks-toolchains.yml b/.github/workflows/test-eb-hooks-toolchains.yml new file mode 100644 index 00000000..0f481390 --- /dev/null +++ b/.github/workflows/test-eb-hooks-toolchains.yml @@ -0,0 +1,33 @@ +# documentation: https://help.github.com/en/articles/workflow-syntax-for-github-actions +name: Unit tests for supported toolchains in EasyBuild hooks +on: + push: + paths: + - eb_hooks.py + - eessi_supported_toolchains.json + - tests/eb_hooks/** + - .github/workflows/test-eb-hooks-toolchains.yml + pull_request: + paths: + - eb_hooks.py + - eessi_supported_toolchains.json + - tests/eb_hooks/** + - .github/workflows/test-eb-hooks-toolchains.yml + workflow_dispatch: +permissions: + contents: read # to fetch code (actions/checkout) +jobs: + test_supported_toolchains: + runs-on: ubuntu-24.04 + steps: + - name: Check out software-layer repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Install EasyBuild and pytest + run: | + python3 -m venv "$RUNNER_TEMP/venv" + "$RUNNER_TEMP/venv/bin/pip" install easybuild pytest + + - name: Run unit tests + run: | + "$RUNNER_TEMP/venv/bin/python" -m pytest -v tests/eb_hooks diff --git a/.github/workflows/test-eb-hooks.yml b/.github/workflows/test-eb-init-files.yml similarity index 79% rename from .github/workflows/test-eb-hooks.yml rename to .github/workflows/test-eb-init-files.yml index bf8a48cc..546b9d57 100644 --- a/.github/workflows/test-eb-hooks.yml +++ b/.github/workflows/test-eb-init-files.yml @@ -1,5 +1,5 @@ # documentation: https://help.github.com/en/articles/workflow-syntax-for-github-actions -name: Run checks on EasyBuild hooks script +name: Run checks on EasyBuild hooks script and related init files on: push: pull_request: @@ -26,52 +26,36 @@ jobs: eessi_stack_version: ${{matrix.EESSI_VERSION}} use_eessi_module: true - - name: Check whether eb_hooks.py script is up-to-date + - name: Check whether eb_hooks.py and related files are up-to-date if: ${{ github.event_name == 'pull_request' }} run: | - FILE="eb_hooks.py" - TEMP_FILE="$(mktemp)" + # Files that are installed in the same directory as the hooks, and have to be up-to-date + FILES=(eb_hooks.py eessi_supported_toolchains.json) # Fetch base branch git fetch origin ${{ github.base_ref }} - # Check if the hooks has changed in the PR - if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q "^$FILE$"; then - echo "Hooks changed in PR. Using PR version." - cp "$FILE" "$TEMP_FILE" - else - echo "File not changed in PR. Using default branch version." - git show origin/${{ github.base_ref }}:$FILE > "$TEMP_FILE" - fi - - # replace placeholder (as is also done in install_scripts.sh) - sed -i "s//${{matrix.EESSI_VERSION}}/g" "${TEMP_FILE}" - - # Compare the hooks to what is shipped in the repository module load EESSI-extend - diff "$TEMP_FILE" "$EASYBUILD_HOOKS" - - - name: Check whether eessi_supported_toolchains.json (used by eb_hooks.py) is up-to-date - if: ${{ github.event_name == 'pull_request' }} - run: | - FILE="eessi_supported_toolchains.json" - TEMP_FILE="$(mktemp)" - # Fetch base branch - git fetch origin ${{ github.base_ref }} - - # Check if the file has changed in the PR - if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q "^$FILE$"; then - echo "$FILE changed in PR. Using PR version." - cp "$FILE" "$TEMP_FILE" - else - echo "File not changed in PR. Using default branch version." - git show origin/${{ github.base_ref }}:$FILE > "$TEMP_FILE" - fi - - # Compare to what is shipped next to the hooks in the repository - module load EESSI-extend - diff "$TEMP_FILE" "$(dirname $EASYBUILD_HOOKS)/$FILE" + for FILE in "${FILES[@]}"; do + echo "Checking whether $FILE is up-to-date" + TEMP_FILE="$(mktemp)" + + # Check if the file has changed in the PR + if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q "^$FILE$"; then + echo "$FILE changed in PR. Using PR version." + cp "$FILE" "$TEMP_FILE" + else + echo "$FILE not changed in PR. Using default branch version." + git show origin/${{ github.base_ref }}:$FILE > "$TEMP_FILE" + fi + + # replace placeholder (as is also done in install_scripts.sh) + sed -i "s//${{matrix.EESSI_VERSION}}/g" "${TEMP_FILE}" + + # Compare to what is shipped in the repository (all files are installed in the same directory as the hooks) + diff "$TEMP_FILE" "$(dirname "$EASYBUILD_HOOKS")/$FILE" + done check_eb_hooks_functionality: runs-on: ubuntu-24.04 diff --git a/eb_hooks.py b/eb_hooks.py index 547f9e0e..0ffc30d1 100644 --- a/eb_hooks.py +++ b/eb_hooks.py @@ -67,21 +67,46 @@ STACK_REPROD_SUBDIR = 'reprod' +# Environment variable that can be used to point to a custom JSON file with the supported top-level toolchains +SUPPORTED_TOOLCHAINS_FILE_ENVVAR = 'EESSI_SUPPORTED_TOOLCHAINS_FILE' + def load_supported_top_level_toolchains(): """ - Load the supported top-level toolchains per EESSI version from eessi_supported_toolchains.json, - which is located next to this hooks file (both in the software-layer-scripts repository, and when installed - in /init/easybuild). Toolchains that require a more recent EasyBuild version than the one - being used (as specified via 'min_easybuild_version') are left out. + Load the supported top-level toolchains per EESSI version from a JSON file. + + The location of the JSON file can be set through the environment variable EESSI_SUPPORTED_TOOLCHAINS_FILE. + If that is not set, eessi_supported_toolchains.json is expected next to this hooks file (both in the + software-layer-scripts repository, and when installed in /init/easybuild). + + Toolchains that require a more recent EasyBuild version than the one being used (as specified via + 'min_easybuild_version') are left out. + + Returns: + supported_toolchains (dict): maps each EESSI version to a list of dicts with the 'name' and 'version' + of a supported top-level toolchain """ - toolchains_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'eessi_supported_toolchains.json') + default_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'eessi_supported_toolchains.json') + toolchains_file = os.getenv(SUPPORTED_TOOLCHAINS_FILE_ENVVAR) + envvar_msg = f" (set via ${SUPPORTED_TOOLCHAINS_FILE_ENVVAR})" + if not toolchains_file: + toolchains_file = default_file + envvar_msg = "" + try: with open(toolchains_file) as fh: toolchains = json.load(fh) - except (OSError, ValueError) as err: - raise EasyBuildError(f"Failed to load supported toolchains from {toolchains_file} " - f"(it is expected to be located next to the EasyBuild hooks file): {err}") + except OSError as err: + msg = (f"Failed to read the file with supported toolchains {toolchains_file}{envvar_msg}: {err}. " + f"By default, it is expected next to the EasyBuild hooks file; its location can be configured " + f"through the environment variable {SUPPORTED_TOOLCHAINS_FILE_ENVVAR}.") + if envvar_msg and os.path.isfile(default_file): + msg += (f" Note that a file with supported toolchains does exist in the default location {default_file}. " + f"If that is the file you intended to use, unset {SUPPORTED_TOOLCHAINS_FILE_ENVVAR}.") + raise EasyBuildError(msg) + except ValueError as err: + raise EasyBuildError(f"The file with supported toolchains {toolchains_file}{envvar_msg} " + f"does not contain valid JSON: {err}") return { eessi_version: [ diff --git a/install_scripts.sh b/install_scripts.sh index 29ffcf24..b4a36383 100755 --- a/install_scripts.sh +++ b/install_scripts.sh @@ -222,12 +222,12 @@ if [[ -d ${host_injections_easystacks_dir} ]]; then copy_files_by_list ${host_injections_easystacks_dir} ${INSTALL_PREFIX}/scripts/gpu_support/nvidia/easystacks "${host_injections_easystacks[@]}" fi -# Copy over EasyBuild hooks file used for installations -hook_files=( +# Copy over EasyBuild hooks file used for installations (and the files it needs, which have to be located next to it) +easybuild_init_files=( eb_hooks.py eessi_supported_toolchains.json ) -copy_files_by_list ${TOPDIR} ${INSTALL_PREFIX}/init/easybuild "${hook_files[@]}" +copy_files_by_list ${TOPDIR} ${INSTALL_PREFIX}/init/easybuild "${easybuild_init_files[@]}" # replace version placeholders in scripts; # note: the commands below are always run, regardless of whether the scripts were changed, diff --git a/tests/eb_hooks/test_supported_toolchains.py b/tests/eb_hooks/test_supported_toolchains.py new file mode 100644 index 00000000..ad79c545 --- /dev/null +++ b/tests/eb_hooks/test_supported_toolchains.py @@ -0,0 +1,113 @@ +# Unit tests for the supported top-level toolchains (eessi_supported_toolchains.json) +# and the function in eb_hooks.py that loads them. +# Requires EasyBuild to be importable, e.g.: pip install easybuild pytest +import json +import os +import sys + +import pytest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +import eb_hooks # noqa: E402 +from easybuild.tools.build_log import EasyBuildError # noqa: E402 + +TOOLCHAINS_FILE = os.path.join(REPO_ROOT, 'eessi_supported_toolchains.json') +ENVVAR = 'EESSI_SUPPORTED_TOOLCHAINS_FILE' + + +@pytest.fixture(autouse=True) +def clean_env(monkeypatch): + monkeypatch.delenv(ENVVAR, raising=False) + + +def write_json(path, content): + path.write_text(content if isinstance(content, str) else json.dumps(content)) + return str(path) + + +def test_json_format(): + """Check that the shipped JSON file has the expected structure.""" + with open(TOOLCHAINS_FILE) as fh: + data = json.load(fh) + + assert isinstance(data, dict) and data + for eessi_version, toolchains in data.items(): + assert isinstance(eessi_version, str) + assert isinstance(toolchains, list) and toolchains, f"No toolchains for {eessi_version}" + for tc in toolchains: + assert set(tc) <= {'name', 'version', 'min_easybuild_version'}, tc + assert isinstance(tc.get('name'), str) and tc['name'], tc + assert isinstance(tc.get('version'), str) and tc['version'], tc + if 'min_easybuild_version' in tc: + assert isinstance(tc['min_easybuild_version'], str), tc + # no duplicates + pairs = [(tc['name'], tc['version']) for tc in toolchains] + assert len(pairs) == len(set(pairs)), f"Duplicate toolchains for {eessi_version}" + + +def test_load_default_location(): + """Without the environment variable, the file next to eb_hooks.py is used.""" + result = eb_hooks.load_supported_top_level_toolchains() + assert set(result) == set(json.load(open(TOOLCHAINS_FILE))) + for tcs in result.values(): + for tc in tcs: + assert set(tc) == {'name', 'version'} + + +@pytest.mark.parametrize('eb_version, lfoss_present, rompi_present', [ + ('5.1.0', False, False), + ('5.2.0', True, False), + ('5.3.0', True, False), + ('5.3.1', True, True), + ('5.4.0', True, True), +]) +def test_min_easybuild_version(monkeypatch, eb_version, lfoss_present, rompi_present): + monkeypatch.setattr(eb_hooks, 'EASYBUILD_VERSION', eb_version) + tcs = eb_hooks.load_supported_top_level_toolchains()['2025.06'] + assert ({'name': 'lfoss', 'version': '2025b'} in tcs) == lfoss_present + assert ({'name': 'rompi', 'version': '2025a'} in tcs) == rompi_present + # toolchains without a minimum EasyBuild version are always there + assert {'name': 'foss', 'version': '2025b'} in tcs + + +def test_envvar_overrides_location(monkeypatch, tmp_path): + custom = write_json(tmp_path / 'custom.json', {'2099.01': [{'name': 'foo', 'version': '1'}]}) + monkeypatch.setenv(ENVVAR, custom) + assert eb_hooks.load_supported_top_level_toolchains() == {'2099.01': [{'name': 'foo', 'version': '1'}]} + + +def test_envvar_missing_file(monkeypatch, tmp_path): + missing = str(tmp_path / 'does_not_exist.json') + monkeypatch.setenv(ENVVAR, missing) + with pytest.raises(EasyBuildError) as excinfo: + eb_hooks.load_supported_top_level_toolchains() + msg = str(excinfo.value) + assert missing in msg + assert ENVVAR in msg + # the file in the default location exists, so the user should be pointed to it and told how to use it + assert TOOLCHAINS_FILE in msg + assert f"unset {ENVVAR}" in msg + + +def test_missing_default_file(monkeypatch, tmp_path): + monkeypatch.setattr(eb_hooks, '__file__', str(tmp_path / 'eb_hooks.py')) + with pytest.raises(EasyBuildError) as excinfo: + eb_hooks.load_supported_top_level_toolchains() + msg = str(excinfo.value) + assert str(tmp_path / 'eessi_supported_toolchains.json') in msg + assert ENVVAR in msg # mentions how to configure the location + assert 'unset' not in msg + + +@pytest.mark.parametrize('use_envvar', [False, True]) +def test_invalid_json(monkeypatch, tmp_path, use_envvar): + bad = write_json(tmp_path / 'bad.json', '{"2025.06": [') + if use_envvar: + monkeypatch.setenv(ENVVAR, bad) + else: + monkeypatch.setattr(eb_hooks, '__file__', str(tmp_path / 'eb_hooks.py')) + os.rename(bad, tmp_path / 'eessi_supported_toolchains.json') + with pytest.raises(EasyBuildError, match='does not contain valid JSON'): + eb_hooks.load_supported_top_level_toolchains() From fe8f18224aa84d0c48168a1a2ac449a3dc5f3b53 Mon Sep 17 00:00:00 2001 From: Caspar van Leeuwen Date: Mon, 5 Oct 2026 17:13:08 +0200 Subject: [PATCH 3/5] Test toolchain loader with dummy JSON files instead of the shipped one Only the structure of the shipped eessi_supported_toolchains.json is tested now; the behaviour of load_supported_top_level_toolchains() is tested using JSON files created on the fly, so the toolchain content is not duplicated in the tests. Co-Authored-By: Claude Sonnet 5.5 --- tests/eb_hooks/test_supported_toolchains.py | 105 +++++++++++++------- 1 file changed, 71 insertions(+), 34 deletions(-) diff --git a/tests/eb_hooks/test_supported_toolchains.py b/tests/eb_hooks/test_supported_toolchains.py index ad79c545..4f0546fc 100644 --- a/tests/eb_hooks/test_supported_toolchains.py +++ b/tests/eb_hooks/test_supported_toolchains.py @@ -47,38 +47,66 @@ def test_json_format(): assert len(pairs) == len(set(pairs)), f"Duplicate toolchains for {eessi_version}" -def test_load_default_location(): +DEFAULT_FILENAME = 'eessi_supported_toolchains.json' + + +@pytest.fixture +def default_dir(monkeypatch, tmp_path): + """Make the 'directory of eb_hooks.py' (i.e. the default location of the JSON file) an empty temporary dir.""" + monkeypatch.setattr(eb_hooks, '__file__', str(tmp_path / 'eb_hooks.py')) + return tmp_path + + +def test_load_default_location(default_dir): """Without the environment variable, the file next to eb_hooks.py is used.""" - result = eb_hooks.load_supported_top_level_toolchains() - assert set(result) == set(json.load(open(TOOLCHAINS_FILE))) - for tcs in result.values(): - for tc in tcs: - assert set(tc) == {'name', 'version'} - - -@pytest.mark.parametrize('eb_version, lfoss_present, rompi_present', [ - ('5.1.0', False, False), - ('5.2.0', True, False), - ('5.3.0', True, False), - ('5.3.1', True, True), - ('5.4.0', True, True), + write_json(default_dir / DEFAULT_FILENAME, { + '2099.01': [{'name': 'foo', 'version': '1'}, {'name': 'bar', 'version': '2'}], + '2099.02': [{'name': 'baz', 'version': '3'}], + }) + assert eb_hooks.load_supported_top_level_toolchains() == { + '2099.01': [{'name': 'foo', 'version': '1'}, {'name': 'bar', 'version': '2'}], + '2099.02': [{'name': 'baz', 'version': '3'}], + } + + +@pytest.mark.parametrize('eb_version, expected', [ + ('4.9.0', ['always']), + ('5.2.0', ['always', 'since_5_2_0']), + ('5.2.1', ['always', 'since_5_2_0']), + ('5.3.0', ['always', 'since_5_2_0']), + ('5.3.1', ['always', 'since_5_2_0', 'since_5_3_1']), + ('6.0.0', ['always', 'since_5_2_0', 'since_5_3_1']), ]) -def test_min_easybuild_version(monkeypatch, eb_version, lfoss_present, rompi_present): +def test_min_easybuild_version(monkeypatch, default_dir, eb_version, expected): + """Toolchains with a 'min_easybuild_version' are only included for that EasyBuild version or newer.""" + write_json(default_dir / DEFAULT_FILENAME, { + '2099.01': [ + {'name': 'always', 'version': '1'}, + {'name': 'since_5_2_0', 'version': '1', 'min_easybuild_version': '5.2.0'}, + {'name': 'since_5_3_1', 'version': '1', 'min_easybuild_version': '5.3.1'}, + ], + '2099.02': [ + {'name': 'only_future', 'version': '1', 'min_easybuild_version': '99.0.0'}, + ], + }) monkeypatch.setattr(eb_hooks, 'EASYBUILD_VERSION', eb_version) - tcs = eb_hooks.load_supported_top_level_toolchains()['2025.06'] - assert ({'name': 'lfoss', 'version': '2025b'} in tcs) == lfoss_present - assert ({'name': 'rompi', 'version': '2025a'} in tcs) == rompi_present - # toolchains without a minimum EasyBuild version are always there - assert {'name': 'foss', 'version': '2025b'} in tcs + result = eb_hooks.load_supported_top_level_toolchains() + assert [tc['name'] for tc in result['2099.01']] == expected + # An EESSI version for which no toolchain is supported by this EasyBuild version is kept, with an empty list + assert result['2099.02'] == [] + # The minimum version is not part of the returned toolchain dicts + assert all(set(tc) == {'name', 'version'} for tcs in result.values() for tc in tcs) -def test_envvar_overrides_location(monkeypatch, tmp_path): - custom = write_json(tmp_path / 'custom.json', {'2099.01': [{'name': 'foo', 'version': '1'}]}) +def test_envvar_overrides_location(monkeypatch, default_dir, tmp_path): + write_json(default_dir / DEFAULT_FILENAME, {'2099.01': [{'name': 'default', 'version': '1'}]}) + custom = write_json(tmp_path / 'custom.json', {'2099.01': [{'name': 'custom', 'version': '1'}]}) monkeypatch.setenv(ENVVAR, custom) - assert eb_hooks.load_supported_top_level_toolchains() == {'2099.01': [{'name': 'foo', 'version': '1'}]} + assert eb_hooks.load_supported_top_level_toolchains() == {'2099.01': [{'name': 'custom', 'version': '1'}]} -def test_envvar_missing_file(monkeypatch, tmp_path): +def test_envvar_missing_file(monkeypatch, default_dir, tmp_path): + default_file = write_json(default_dir / DEFAULT_FILENAME, {'2099.01': []}) missing = str(tmp_path / 'does_not_exist.json') monkeypatch.setenv(ENVVAR, missing) with pytest.raises(EasyBuildError) as excinfo: @@ -86,28 +114,37 @@ def test_envvar_missing_file(monkeypatch, tmp_path): msg = str(excinfo.value) assert missing in msg assert ENVVAR in msg - # the file in the default location exists, so the user should be pointed to it and told how to use it - assert TOOLCHAINS_FILE in msg + # a file exists in the default location, so the user should be pointed to it and told how to use it + assert default_file in msg assert f"unset {ENVVAR}" in msg -def test_missing_default_file(monkeypatch, tmp_path): - monkeypatch.setattr(eb_hooks, '__file__', str(tmp_path / 'eb_hooks.py')) +def test_envvar_missing_file_no_default(monkeypatch, default_dir, tmp_path): + missing = str(tmp_path / 'does_not_exist.json') + monkeypatch.setenv(ENVVAR, missing) + with pytest.raises(EasyBuildError) as excinfo: + eb_hooks.load_supported_top_level_toolchains() + msg = str(excinfo.value) + assert missing in msg + assert 'unset' not in msg + + +def test_missing_default_file(default_dir): with pytest.raises(EasyBuildError) as excinfo: eb_hooks.load_supported_top_level_toolchains() msg = str(excinfo.value) - assert str(tmp_path / 'eessi_supported_toolchains.json') in msg + assert str(default_dir / DEFAULT_FILENAME) in msg assert ENVVAR in msg # mentions how to configure the location assert 'unset' not in msg @pytest.mark.parametrize('use_envvar', [False, True]) -def test_invalid_json(monkeypatch, tmp_path, use_envvar): - bad = write_json(tmp_path / 'bad.json', '{"2025.06": [') +def test_invalid_json(monkeypatch, default_dir, tmp_path, use_envvar): if use_envvar: + bad = write_json(tmp_path / 'bad.json', '{"2099.01": [') monkeypatch.setenv(ENVVAR, bad) else: - monkeypatch.setattr(eb_hooks, '__file__', str(tmp_path / 'eb_hooks.py')) - os.rename(bad, tmp_path / 'eessi_supported_toolchains.json') - with pytest.raises(EasyBuildError, match='does not contain valid JSON'): + bad = write_json(default_dir / DEFAULT_FILENAME, '{"2099.01": [') + with pytest.raises(EasyBuildError, match='does not contain valid JSON') as excinfo: eb_hooks.load_supported_top_level_toolchains() + assert bad in str(excinfo.value) From 242a8a4aef5931010c00927a08e8c13e41c4e0c5 Mon Sep 17 00:00:00 2001 From: Caspar van Leeuwen Date: Tue, 6 Oct 2026 14:39:40 +0200 Subject: [PATCH 4/5] Convert to using TOML instead, so that we can use comments in the file --- .../workflows/test-eb-hooks-toolchains.yml | 4 +- .github/workflows/test-eb-init-files.yml | 2 +- eb_hooks.py | 27 ++++-- eessi_supported_toolchains.json | 18 ---- eessi_supported_toolchains.toml | 25 ++++++ install_scripts.sh | 2 +- tests/eb_hooks/test_supported_toolchains.py | 84 +++++++++++-------- 7 files changed, 98 insertions(+), 64 deletions(-) delete mode 100644 eessi_supported_toolchains.json create mode 100644 eessi_supported_toolchains.toml diff --git a/.github/workflows/test-eb-hooks-toolchains.yml b/.github/workflows/test-eb-hooks-toolchains.yml index 0f481390..2041a3ae 100644 --- a/.github/workflows/test-eb-hooks-toolchains.yml +++ b/.github/workflows/test-eb-hooks-toolchains.yml @@ -4,13 +4,13 @@ on: push: paths: - eb_hooks.py - - eessi_supported_toolchains.json + - eessi_supported_toolchains.toml - tests/eb_hooks/** - .github/workflows/test-eb-hooks-toolchains.yml pull_request: paths: - eb_hooks.py - - eessi_supported_toolchains.json + - eessi_supported_toolchains.toml - tests/eb_hooks/** - .github/workflows/test-eb-hooks-toolchains.yml workflow_dispatch: diff --git a/.github/workflows/test-eb-init-files.yml b/.github/workflows/test-eb-init-files.yml index 546b9d57..c8bd3981 100644 --- a/.github/workflows/test-eb-init-files.yml +++ b/.github/workflows/test-eb-init-files.yml @@ -30,7 +30,7 @@ jobs: if: ${{ github.event_name == 'pull_request' }} run: | # Files that are installed in the same directory as the hooks, and have to be up-to-date - FILES=(eb_hooks.py eessi_supported_toolchains.json) + FILES=(eb_hooks.py eessi_supported_toolchains.toml) # Fetch base branch git fetch origin ${{ github.base_ref }} diff --git a/eb_hooks.py b/eb_hooks.py index 0ffc30d1..7125cf51 100644 --- a/eb_hooks.py +++ b/eb_hooks.py @@ -10,6 +10,14 @@ import tarfile from typing import NamedTuple +try: + import tomllib # in the standard library since Python 3.11 +except ImportError: + try: + import tomli as tomllib # backport for older Python versions + except ImportError: + tomllib = None + import easybuild.tools.environment as env from easybuild.easyblocks.generic.configuremake import obtain_config_guess from easybuild.framework.easyconfig.constants import EASYCONFIG_CONSTANTS @@ -67,16 +75,16 @@ STACK_REPROD_SUBDIR = 'reprod' -# Environment variable that can be used to point to a custom JSON file with the supported top-level toolchains +# Environment variable that can be used to point to a custom TOML file with the supported top-level toolchains SUPPORTED_TOOLCHAINS_FILE_ENVVAR = 'EESSI_SUPPORTED_TOOLCHAINS_FILE' def load_supported_top_level_toolchains(): """ - Load the supported top-level toolchains per EESSI version from a JSON file. + Load the supported top-level toolchains per EESSI version from a TOML file. - The location of the JSON file can be set through the environment variable EESSI_SUPPORTED_TOOLCHAINS_FILE. - If that is not set, eessi_supported_toolchains.json is expected next to this hooks file (both in the + The location of the TOML file can be set through the environment variable EESSI_SUPPORTED_TOOLCHAINS_FILE. + If that is not set, eessi_supported_toolchains.toml is expected next to this hooks file (both in the software-layer-scripts repository, and when installed in /init/easybuild). Toolchains that require a more recent EasyBuild version than the one being used (as specified via @@ -86,16 +94,19 @@ def load_supported_top_level_toolchains(): supported_toolchains (dict): maps each EESSI version to a list of dicts with the 'name' and 'version' of a supported top-level toolchain """ - default_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'eessi_supported_toolchains.json') + default_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'eessi_supported_toolchains.toml') toolchains_file = os.getenv(SUPPORTED_TOOLCHAINS_FILE_ENVVAR) envvar_msg = f" (set via ${SUPPORTED_TOOLCHAINS_FILE_ENVVAR})" if not toolchains_file: toolchains_file = default_file envvar_msg = "" + if tomllib is None: + raise EasyBuildError("Parsing TOML files requires Python 3.11 or newer, or the 'tomli' Python package") + try: - with open(toolchains_file) as fh: - toolchains = json.load(fh) + with open(toolchains_file, 'rb') as fh: + toolchains = tomllib.load(fh) except OSError as err: msg = (f"Failed to read the file with supported toolchains {toolchains_file}{envvar_msg}: {err}. " f"By default, it is expected next to the EasyBuild hooks file; its location can be configured " @@ -106,7 +117,7 @@ def load_supported_top_level_toolchains(): raise EasyBuildError(msg) except ValueError as err: raise EasyBuildError(f"The file with supported toolchains {toolchains_file}{envvar_msg} " - f"does not contain valid JSON: {err}") + f"does not contain valid TOML: {err}") return { eessi_version: [ diff --git a/eessi_supported_toolchains.json b/eessi_supported_toolchains.json deleted file mode 100644 index d89d38fe..00000000 --- a/eessi_supported_toolchains.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "2023.06": [ - {"name": "foss", "version": "2022b"}, - {"name": "foss", "version": "2023a"}, - {"name": "foss", "version": "2023b"} - ], - "2025.06": [ - {"name": "foss", "version": "2024a"}, - {"name": "foss", "version": "2025a"}, - {"name": "foss", "version": "2025b"}, - {"name": "lfoss", "version": "2025b", "min_easybuild_version": "5.2.0"}, - {"name": "rompi", "version": "2025a", "min_easybuild_version": "5.3.1"} - ], - "2026.06": [ - {"name": "foss", "version": "2026.1"}, - {"name": "lfoss", "version": "2026.1"} - ] -} diff --git a/eessi_supported_toolchains.toml b/eessi_supported_toolchains.toml new file mode 100644 index 00000000..5aa00d65 --- /dev/null +++ b/eessi_supported_toolchains.toml @@ -0,0 +1,25 @@ +# Top-level toolchains that are supported for installations in each EESSI version. +# This file is read by eb_hooks.py (see load_supported_top_level_toolchains()), and has to be installed next to it. +# +# Each key is an EESSI version, with a list of toolchains ('name' and 'version'). +# An optional 'min_easybuild_version' can be used for toolchains that are only supported when +# a sufficiently recent EasyBuild version is used (e.g. because older versions don't know the toolchain). + +"2023.06" = [ + { name = "foss", version = "2022b" }, + { name = "foss", version = "2023a" }, + { name = "foss", version = "2023b" }, +] + +"2025.06" = [ + { name = "foss", version = "2024a" }, + { name = "foss", version = "2025a" }, + { name = "foss", version = "2025b" }, + { name = "lfoss", version = "2025b", min_easybuild_version = "5.2.0" }, + { name = "rompi", version = "2025a", min_easybuild_version = "5.3.1" }, +] + +"2026.06" = [ + { name = "foss", version = "2026.1" }, + { name = "lfoss", version = "2026.1" }, +] diff --git a/install_scripts.sh b/install_scripts.sh index b4a36383..ae6a854b 100755 --- a/install_scripts.sh +++ b/install_scripts.sh @@ -225,7 +225,7 @@ fi # Copy over EasyBuild hooks file used for installations (and the files it needs, which have to be located next to it) easybuild_init_files=( eb_hooks.py - eessi_supported_toolchains.json + eessi_supported_toolchains.toml ) copy_files_by_list ${TOPDIR} ${INSTALL_PREFIX}/init/easybuild "${easybuild_init_files[@]}" diff --git a/tests/eb_hooks/test_supported_toolchains.py b/tests/eb_hooks/test_supported_toolchains.py index 4f0546fc..b0c18ce5 100644 --- a/tests/eb_hooks/test_supported_toolchains.py +++ b/tests/eb_hooks/test_supported_toolchains.py @@ -1,19 +1,23 @@ -# Unit tests for the supported top-level toolchains (eessi_supported_toolchains.json) +# Unit tests for the supported top-level toolchains (eessi_supported_toolchains.toml) # and the function in eb_hooks.py that loads them. # Requires EasyBuild to be importable, e.g.: pip install easybuild pytest -import json import os import sys import pytest +try: + import tomllib +except ImportError: # Python < 3.11 + import tomli as tomllib + REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) sys.path.insert(0, REPO_ROOT) import eb_hooks # noqa: E402 from easybuild.tools.build_log import EasyBuildError # noqa: E402 -TOOLCHAINS_FILE = os.path.join(REPO_ROOT, 'eessi_supported_toolchains.json') +TOOLCHAINS_FILE = os.path.join(REPO_ROOT, 'eessi_supported_toolchains.toml') ENVVAR = 'EESSI_SUPPORTED_TOOLCHAINS_FILE' @@ -22,15 +26,15 @@ def clean_env(monkeypatch): monkeypatch.delenv(ENVVAR, raising=False) -def write_json(path, content): - path.write_text(content if isinstance(content, str) else json.dumps(content)) +def write_file(path, content): + path.write_text(content) return str(path) -def test_json_format(): - """Check that the shipped JSON file has the expected structure.""" - with open(TOOLCHAINS_FILE) as fh: - data = json.load(fh) +def test_toml_format(): + """Check that the shipped TOML file has the expected structure.""" + with open(TOOLCHAINS_FILE, 'rb') as fh: + data = tomllib.load(fh) assert isinstance(data, dict) and data for eessi_version, toolchains in data.items(): @@ -47,22 +51,26 @@ def test_json_format(): assert len(pairs) == len(set(pairs)), f"Duplicate toolchains for {eessi_version}" -DEFAULT_FILENAME = 'eessi_supported_toolchains.json' +DEFAULT_FILENAME = 'eessi_supported_toolchains.toml' @pytest.fixture def default_dir(monkeypatch, tmp_path): - """Make the 'directory of eb_hooks.py' (i.e. the default location of the JSON file) an empty temporary dir.""" + """Make the 'directory of eb_hooks.py' (i.e. the default location of the TOML file) an empty temporary dir.""" monkeypatch.setattr(eb_hooks, '__file__', str(tmp_path / 'eb_hooks.py')) return tmp_path def test_load_default_location(default_dir): """Without the environment variable, the file next to eb_hooks.py is used.""" - write_json(default_dir / DEFAULT_FILENAME, { - '2099.01': [{'name': 'foo', 'version': '1'}, {'name': 'bar', 'version': '2'}], - '2099.02': [{'name': 'baz', 'version': '3'}], - }) + write_file(default_dir / DEFAULT_FILENAME, ''' + "2099.01" = [ + # comments are the reason for using TOML + { name = "foo", version = "1" }, + { name = "bar", version = "2" }, + ] + "2099.02" = [{ name = "baz", version = "3" }] + ''') assert eb_hooks.load_supported_top_level_toolchains() == { '2099.01': [{'name': 'foo', 'version': '1'}, {'name': 'bar', 'version': '2'}], '2099.02': [{'name': 'baz', 'version': '3'}], @@ -79,16 +87,16 @@ def test_load_default_location(default_dir): ]) def test_min_easybuild_version(monkeypatch, default_dir, eb_version, expected): """Toolchains with a 'min_easybuild_version' are only included for that EasyBuild version or newer.""" - write_json(default_dir / DEFAULT_FILENAME, { - '2099.01': [ - {'name': 'always', 'version': '1'}, - {'name': 'since_5_2_0', 'version': '1', 'min_easybuild_version': '5.2.0'}, - {'name': 'since_5_3_1', 'version': '1', 'min_easybuild_version': '5.3.1'}, - ], - '2099.02': [ - {'name': 'only_future', 'version': '1', 'min_easybuild_version': '99.0.0'}, - ], - }) + write_file(default_dir / DEFAULT_FILENAME, ''' + "2099.01" = [ + { name = "always", version = "1" }, + { name = "since_5_2_0", version = "1", min_easybuild_version = "5.2.0" }, + { name = "since_5_3_1", version = "1", min_easybuild_version = "5.3.1" }, + ] + "2099.02" = [ + { name = "only_future", version = "1", min_easybuild_version = "99.0.0" }, + ] + ''') monkeypatch.setattr(eb_hooks, 'EASYBUILD_VERSION', eb_version) result = eb_hooks.load_supported_top_level_toolchains() assert [tc['name'] for tc in result['2099.01']] == expected @@ -99,15 +107,15 @@ def test_min_easybuild_version(monkeypatch, default_dir, eb_version, expected): def test_envvar_overrides_location(monkeypatch, default_dir, tmp_path): - write_json(default_dir / DEFAULT_FILENAME, {'2099.01': [{'name': 'default', 'version': '1'}]}) - custom = write_json(tmp_path / 'custom.json', {'2099.01': [{'name': 'custom', 'version': '1'}]}) + write_file(default_dir / DEFAULT_FILENAME, '"2099.01" = [{ name = "default", version = "1" }]') + custom = write_file(tmp_path / 'custom.toml', '"2099.01" = [{ name = "custom", version = "1" }]') monkeypatch.setenv(ENVVAR, custom) assert eb_hooks.load_supported_top_level_toolchains() == {'2099.01': [{'name': 'custom', 'version': '1'}]} def test_envvar_missing_file(monkeypatch, default_dir, tmp_path): - default_file = write_json(default_dir / DEFAULT_FILENAME, {'2099.01': []}) - missing = str(tmp_path / 'does_not_exist.json') + default_file = write_file(default_dir / DEFAULT_FILENAME, '"2099.01" = []') + missing = str(tmp_path / 'does_not_exist.toml') monkeypatch.setenv(ENVVAR, missing) with pytest.raises(EasyBuildError) as excinfo: eb_hooks.load_supported_top_level_toolchains() @@ -120,7 +128,7 @@ def test_envvar_missing_file(monkeypatch, default_dir, tmp_path): def test_envvar_missing_file_no_default(monkeypatch, default_dir, tmp_path): - missing = str(tmp_path / 'does_not_exist.json') + missing = str(tmp_path / 'does_not_exist.toml') monkeypatch.setenv(ENVVAR, missing) with pytest.raises(EasyBuildError) as excinfo: eb_hooks.load_supported_top_level_toolchains() @@ -139,12 +147,20 @@ def test_missing_default_file(default_dir): @pytest.mark.parametrize('use_envvar', [False, True]) -def test_invalid_json(monkeypatch, default_dir, tmp_path, use_envvar): +def test_invalid_toml(monkeypatch, default_dir, tmp_path, use_envvar): if use_envvar: - bad = write_json(tmp_path / 'bad.json', '{"2099.01": [') + bad = write_file(tmp_path / 'bad.toml', '"2099.01" = [') monkeypatch.setenv(ENVVAR, bad) else: - bad = write_json(default_dir / DEFAULT_FILENAME, '{"2099.01": [') - with pytest.raises(EasyBuildError, match='does not contain valid JSON') as excinfo: + bad = write_file(default_dir / DEFAULT_FILENAME, '"2099.01" = [') + with pytest.raises(EasyBuildError, match='does not contain valid TOML') as excinfo: eb_hooks.load_supported_top_level_toolchains() assert bad in str(excinfo.value) + + +def test_no_toml_support(monkeypatch, default_dir): + """A clear error is raised if there is no TOML parser available (Python < 3.11 without tomli).""" + write_file(default_dir / DEFAULT_FILENAME, '"2099.01" = []') + monkeypatch.setattr(eb_hooks, 'tomllib', None) + with pytest.raises(EasyBuildError, match='tomli'): + eb_hooks.load_supported_top_level_toolchains() From 754ae62d987d846e260d893f9ff18104e5fcc719 Mon Sep 17 00:00:00 2001 From: Caspar van Leeuwen Date: Tue, 6 Oct 2026 14:44:47 +0200 Subject: [PATCH 5/5] Add comment on how to convert to json, if needed --- eessi_supported_toolchains.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/eessi_supported_toolchains.toml b/eessi_supported_toolchains.toml index 5aa00d65..f3734119 100644 --- a/eessi_supported_toolchains.toml +++ b/eessi_supported_toolchains.toml @@ -5,6 +5,9 @@ # An optional 'min_easybuild_version' can be used for toolchains that are only supported when # a sufficiently recent EasyBuild version is used (e.g. because older versions don't know the toolchain). +# If you need to use this from a shell and don't have yq available, you can convert it to json on the fly using +# python -c "import tomllib, json; print(json.dumps(tomllib.load(open('input.toml','rb')), indent=2))" > output.json + "2023.06" = [ { name = "foss", version = "2022b" }, { name = "foss", version = "2023a" },