From a49a824a0a6423ebfd0b8c3d8712fd08dad58250 Mon Sep 17 00:00:00 2001 From: Samuel Moors Date: Thu, 29 Jan 2026 10:20:42 +0100 Subject: [PATCH 01/27] don't check export variables in function --- tools/filter.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/filter.py b/tools/filter.py index 54e0f5e3..4bd44a2b 100644 --- a/tools/filter.py +++ b/tools/filter.py @@ -303,6 +303,9 @@ def check_filters(self, context): else: check = False break + # Skip export variables: they are not action filters + elif af.component == FILTER_COMPONENT_EXPORT: + continue # Action filter wasn't found in the context, we won't allow this else: check = False From 8d34ebdd666c0ac4edad5508e1aab57e914192f5 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:51:29 +0100 Subject: [PATCH 02/27] Exclude tests from test coverage report --- .coveragerc | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..c712d259 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +omit = tests/* From 90e115c9fe933c828e0cc3b62236b4a8898de761 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:06:11 +0100 Subject: [PATCH 03/27] Improved config file handling when running pytest - Back up `app.cfg` before testing, restore after - Copy `test_app.cfg` once, on test start --- tests/conftest.py | 18 ++++++++++++++++++ tests/test_eessi_bot_job_manager.py | 4 ---- tests/test_task_build.py | 6 ------ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5c7aa6c4..6338687e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,9 @@ # license: GPLv2 # +import os +import shutil + def pytest_configure(config): # register custom markers @@ -24,3 +27,18 @@ def pytest_configure(config): config.addinivalue_line( "markers", "create_fails(bool): let function create_issue_comment return None" ) + + +def pytest_sessionstart(): + # Back up app.cfg if it exists + if os.path.exists("app.cfg"): + shutil.copyfile("app.cfg", "appbackup.cfg") + + # Copy needed app.cfg from tests directory + shutil.copyfile("tests/test_app.cfg", "app.cfg") + + +def pytest_sessionfinish(): + # Restore backup if it exists + if os.path.exists("appbackup.cfg"): + shutil.copyfile("appbackup.cfg", "app.cfg") diff --git a/tests/test_eessi_bot_job_manager.py b/tests/test_eessi_bot_job_manager.py index 5c5a9c05..4e032bea 100644 --- a/tests/test_eessi_bot_job_manager.py +++ b/tests/test_eessi_bot_job_manager.py @@ -12,15 +12,11 @@ # license: GPLv2 # -import shutil from eessi_bot_job_manager import EESSIBotSoftwareLayerJobManager def test_determine_running_jobs(): - # copy needed app.cfg from tests directory - shutil.copyfile("tests/test_app.cfg", "app.cfg") - job_manager = EESSIBotSoftwareLayerJobManager() assert job_manager.determine_running_jobs({}) == [] diff --git a/tests/test_task_build.py b/tests/test_task_build.py index fcb9f428..16840d51 100644 --- a/tests/test_task_build.py +++ b/tests/test_task_build.py @@ -18,7 +18,6 @@ import filecmp import os import re -import shutil from unittest.mock import patch # Third party imports (anything installed into the local Python environment) @@ -282,7 +281,6 @@ def no_sleep_after_create(delay): def test_create_pr_comment_succeeds(monkeypatch, mocked_github, tmpdir): """Tests for function create_pr_comment.""" monkeypatch.setattr('tools.pr_comments.github', mocked_github) - shutil.copyfile("tests/test_app.cfg", "app.cfg") # creating a PR comment print("CREATING PR COMMENT") ym = datetime.today().strftime('%Y.%m') @@ -313,7 +311,6 @@ def test_create_pr_comment_succeeds(monkeypatch, mocked_github, tmpdir): def test_create_pr_comment_succeeds_none(monkeypatch, mocked_github, tmpdir): """Tests for function create_pr_comment.""" monkeypatch.setattr('tools.pr_comments.github', mocked_github) - shutil.copyfile("tests/test_app.cfg", "app.cfg") # creating a PR comment print("CREATING PR COMMENT") ym = datetime.today().strftime('%Y.%m') @@ -340,7 +337,6 @@ def test_create_pr_comment_succeeds_none(monkeypatch, mocked_github, tmpdir): def test_create_pr_comment_raises_once_then_succeeds(monkeypatch, mocked_github, tmpdir): """Tests for function create_pr_comment.""" monkeypatch.setattr('tools.pr_comments.github', mocked_github) - shutil.copyfile("tests/test_app.cfg", "app.cfg") # creating a PR comment print("CREATING PR COMMENT") ym = datetime.today().strftime('%Y.%m') @@ -367,7 +363,6 @@ def test_create_pr_comment_raises_once_then_succeeds(monkeypatch, mocked_github, def test_create_pr_comment_always_raises(monkeypatch, mocked_github, tmpdir): """Tests for function create_pr_comment.""" monkeypatch.setattr('tools.pr_comments.github', mocked_github) - shutil.copyfile("tests/test_app.cfg", "app.cfg") # creating a PR comment print("CREATING PR COMMENT") ym = datetime.today().strftime('%Y.%m') @@ -395,7 +390,6 @@ def test_create_pr_comment_always_raises(monkeypatch, mocked_github, tmpdir): def test_create_pr_comment_three_raises(monkeypatch, mocked_github, tmpdir): """Tests for function create_pr_comment.""" monkeypatch.setattr('tools.pr_comments.github', mocked_github) - shutil.copyfile("tests/test_app.cfg", "app.cfg") # creating a PR comment print("CREATING PR COMMENT") ym = datetime.today().strftime('%Y.%m') From 3164f0c6860f5552e17057c973f936bd149fccd2 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Fri, 6 Feb 2026 14:55:07 +0100 Subject: [PATCH 04/27] Update run_cmd and run_subprocess tests - Add comments to test_run_cmd - Add comments and test cases to test_run_subprocess --- tests/test_eessi_bot_job_manager.py | 1 - tests/test_task_build.py | 40 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/test_eessi_bot_job_manager.py b/tests/test_eessi_bot_job_manager.py index 4e032bea..27c33478 100644 --- a/tests/test_eessi_bot_job_manager.py +++ b/tests/test_eessi_bot_job_manager.py @@ -12,7 +12,6 @@ # license: GPLv2 # - from eessi_bot_job_manager import EESSIBotSoftwareLayerJobManager diff --git a/tests/test_task_build.py b/tests/test_task_build.py index 16840d51..af3ea842 100644 --- a/tests/test_task_build.py +++ b/tests/test_task_build.py @@ -45,6 +45,7 @@ def test_run_cmd(tmpdir): assert output == "hello\n" assert err == "" + # Command fails and raise_on_error=True with pytest.raises(Exception): output, err, exit_code = run_cmd("ls -l /does_not_exists.txt", 'fail test', tmpdir, log_file=log_file) @@ -52,6 +53,7 @@ def test_run_cmd(tmpdir): assert output == "" assert "No such file or directory" in err + # Command fails and raise_on_error=False output, err, exit_code = run_cmd("ls -l /does_not_exists.txt", 'fail test', tmpdir, @@ -62,6 +64,7 @@ def test_run_cmd(tmpdir): assert output == "" assert "No such file or directory" in err + # Command does not exists and raise_on_error=True with pytest.raises(Exception): output, err, exit_code = run_cmd("this_command_does_not_exist", 'fail test', tmpdir, log_file=log_file) @@ -70,6 +73,7 @@ def test_run_cmd(tmpdir): assert ("this_command_does_not_exist: command not found" in err or "this_command_does_not_exist: not found" in err) + # Command does not exists and raise_on_error=False output, err, exit_code = run_cmd("this_command_does_not_exist", 'fail test', tmpdir, @@ -81,6 +85,7 @@ def test_run_cmd(tmpdir): assert ("this_command_does_not_exist: command not found" in err or "this_command_does_not_exist: not found" in err) + # Check that log_msg is written to log_file output, err, exit_code = run_cmd("echo hello", "test in file", tmpdir, log_file=log_file) with open(log_file, "r") as fp: assert "test in file" in fp.read() @@ -95,18 +100,53 @@ def test_run_subprocess(tmpdir): assert output == "hello\n" assert err == "" + # log_msg="" + output, err, exit_code = run_subprocess("echo hello", "", tmpdir, log_file=log_file) + + assert exit_code == 0 + assert output == "hello\n" + assert err == "" + with open(log_file, "r") as fp: + # TODO: Better way to do this? + assert "run_subprocess(): Running" in fp.read() + + # working_dir=tmpdir + output, err, exit_code = run_subprocess("pwd", "test", tmpdir, log_file=log_file) + + assert exit_code == 0 + assert tmpdir.strpath+"\n" == output + assert err == "" + + # working_dir=None + wd = os.getcwd() + output, err, exit_code = run_subprocess("pwd", "test", None, log_file=log_file) + + assert exit_code == 0 + assert wd in output + assert err == "" + + # env is not None + output, err, exit_code = run_subprocess("env", "test", tmpdir, log_file=log_file, env={"DUMMY": "123"}) + + assert exit_code == 0 + assert "DUMMY=123" in output + assert err == "" + + # Command fails output, err, exit_code = run_subprocess("ls -l /does_not_exists.txt", 'fail test', tmpdir, log_file=log_file) assert exit_code != 0 assert output == "" assert "No such file or directory" in err + # Command does not exist output, err, exit_code = run_subprocess("this_command_does_not_exist", 'fail test', tmpdir, log_file=log_file) assert exit_code != 0 assert output == "" assert ("this_command_does_not_exist: command not found" in err or "this_command_does_not_exist: not found" in err) + # Check that log_msg is written to log_file output, err, exit_code = run_subprocess("echo hello", "test in file", tmpdir, log_file=log_file) with open(log_file, "r") as fp: assert "test in file" in fp.read() From fbda75affaedb856f74ae643263ca5f6115b0235 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:09:08 +0100 Subject: [PATCH 05/27] Add tests for tools/args.py --- tests/test_tools_args.py | 83 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/test_tools_args.py diff --git a/tests/test_tools_args.py b/tests/test_tools_args.py new file mode 100644 index 00000000..825b454e --- /dev/null +++ b/tests/test_tools_args.py @@ -0,0 +1,83 @@ +# Tests for 'tools/args.py' of the EESSI build-and-deploy bot, +# see https://github.com/EESSI/eessi-bot-software-layer +# +# The bot helps with requests to add software installations to the +# EESSI software layer, see https://github.com/EESSI/software-layer +# +# author: Sondre Bergsvaag Risanger (@sondrebr) +# +# license: GPLv2 +# + +# Standard library imports +from argparse import Namespace +from contextlib import nullcontext +import pytest + +# Local application imports (anything from EESSI/eessi-bot-software-layer) +from tools import args + + +# Test parse_common_args() +@pytest.mark.parametrize("test_args,expected_parsed,expected_unknown", [ + # No args + ([], Namespace(debug=False), []), + + # Short-form args + (["-d"], Namespace(debug=True), []), + + # Long-form args + (["--debug"], Namespace(debug=True), []), + + # Unknown args + (["-u", "--unknown"], Namespace(debug=False), ["-u", "--unknown"]), +]) +def test_parse_common_args(test_args, expected_parsed, expected_unknown): + parsed_args, unknown = args.parse_common_args(test_args) + + assert parsed_args == expected_parsed + assert sorted(unknown) == sorted(expected_unknown) + + +# Test event_handler_parse() +@pytest.mark.parametrize("test_args,expectation", [ + # No args + ([], nullcontext(Namespace(debug=False, build=False, test=False, cron=False, file=None, port=3000))), + + # Short-form args + (["-d", "-b", "-t", "-c", "-f", "file.json", "-p", "8000"], + nullcontext(Namespace(debug=True, build=True, test=True, cron=True, file="file.json", port="8000"))), + + # Long-form args + (["--debug", "--build", "--test", "--cron", "--file", "file2.json", "--port", "9000"], + nullcontext(Namespace(debug=True, build=True, test=True, cron=True, file="file2.json", port="9000"))), + + # Unknown args - should fail and exit + (["-u"], pytest.raises(SystemExit)), + (["--unknown"], pytest.raises(SystemExit)), +]) +def test_event_handler_parse_known_args(test_args, expectation): + with expectation as expected: + assert args.event_handler_parse(test_args) == expected + + +# Test job_manager_parse() +@pytest.mark.parametrize("test_args,expectation", [ + # No args + ([], nullcontext(Namespace(debug=False, max_manager_iterations=-1, jobs=None))), + + # Short-form args + (["-d", "-i", "0", "-j", "17"], + nullcontext(Namespace(debug=True, max_manager_iterations="0", jobs="17"))), + + # Long-form args + (["--debug", "--max-manager-iterations", "10", "--jobs", "4,18,48"], + nullcontext(Namespace(debug=True, max_manager_iterations="10", jobs="4,18,48"))), + + # Unknown args - should fail and exit + (["-u"], pytest.raises(SystemExit)), + (["--unknown"], pytest.raises(SystemExit)), +]) +def test_job_manager_parse_known_args(test_args, expectation): + with expectation as expected: + assert args.job_manager_parse(test_args) == expected From 78322d107175ecbebe4d959d9bab5a391d0a3398 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:02:39 +0100 Subject: [PATCH 06/27] Add tests for tools/build_params.py --- tests/test_tools_build_params.py | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tests/test_tools_build_params.py diff --git a/tests/test_tools_build_params.py b/tests/test_tools_build_params.py new file mode 100644 index 00000000..02e79245 --- /dev/null +++ b/tests/test_tools_build_params.py @@ -0,0 +1,41 @@ +# Tests for 'tools/build_params.py' of the EESSI build-and-deploy bot, +# see https://github.com/EESSI/eessi-bot-software-layer +# +# The bot helps with requests to add software installations to the +# EESSI software layer, see https://github.com/EESSI/software-layer +# +# author: Sondre Bergsvaag Risanger (@sondrebr) +# +# license: GPLv2 +# + +# Standard library imports +from contextlib import nullcontext +import pytest + +# Local application imports (anything from EESSI/eessi-bot-software-layer) +from tools import build_params + + +# Test EESSIBotBuildParams class +@pytest.mark.parametrize("build_parameters,expectation", [ + # Test value error + ("notaparam", pytest.raises(build_params.EESSIBotBuildParamsValueError)), + + # Test name error + ("thisparam=doesnotexist", pytest.raises(build_params.EESSIBotBuildParamsNameError)), + + # Test complete component names + ("architecture=x86_64/amd/zen4,accelerator=nvidia/cc80", + nullcontext({"architecture": "x86_64/amd/zen4", "accelerator": "nvidia/cc80"})), + + # Test shortened component names + ("arch=aarch64/nvidia/grace,accel=nvidia/cc90", + nullcontext({"architecture": "aarch64/nvidia/grace", "accelerator": "nvidia/cc90"})), +]) +def test_EESSIBotBuildParams(build_parameters, expectation): + with expectation as expected_params: + params = build_params.EESSIBotBuildParams(build_parameters) + # Verify that the resulting object contains the expected items + for name, expected_value in expected_params.items(): + assert params[name] == expected_value From 55e4836d564be87bf726dadfc489debf3c3c6170 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:38:39 +0100 Subject: [PATCH 07/27] Add tests for tools/commands.py --- tests/test_tools_commands.py | 106 +++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 tests/test_tools_commands.py diff --git a/tests/test_tools_commands.py b/tests/test_tools_commands.py new file mode 100644 index 00000000..16d64090 --- /dev/null +++ b/tests/test_tools_commands.py @@ -0,0 +1,106 @@ +# Tests for 'tools/commands.py' of the EESSI build-and-deploy bot, +# see https://github.com/EESSI/eessi-bot-software-layer +# +# The bot helps with requests to add software installations to the +# EESSI software layer, see https://github.com/EESSI/software-layer +# +# author: Sondre Bergsvaag Risanger (@sondrebr) +# +# license: GPLv2 +# + +# Standard library imports +from contextlib import nullcontext + +# Third party imports (anything installed into the local Python environment) +import pytest + +# Local application imports (anything from EESSI/eessi-bot-software-layer) +from tools import commands + + +def test_contains_any_bot_command(): + # Test without command + body = "\n".join([ + "not a command", + "also not a command" + ]) + assert commands.contains_any_bot_command(body) is False + + # Test with command + body = "\n".join([ + "not a command", + "bot: help", + "also not a command" + ]) + assert commands.contains_any_bot_command(body) is True + + +def test_get_bot_command(): + # Test non-command + no_cmd = commands.get_bot_command("not a command") + assert no_cmd is None + + # Test command + test_cmd = "help" + cmd = commands.get_bot_command(f"bot: {test_cmd}") + assert cmd == test_cmd + + +# Helper classes for EESSIBotCommand test +class MockActionFilter(): + def __init__(self, action_filters=[]): + self.action_filters = action_filters + + def __eq__(self, other): + return self.action_filters == other.action_filters + + def to_string(self): + return " ".join([":".join(af) for af in self.action_filters]) + + +class MockCommand(): + def __init__(self, command, general_args=[], action_filters=MockActionFilter(), build_params=None): + self.command = command + self.general_args = general_args + self.action_filters = action_filters + self.build_params = build_params + + def __eq__(self, other): + return (self.command == other.command + and self.general_args == other.general_args + and self.action_filters == other.action_filters + and self.build_params == other.build_params) + + def to_string(self): + if self.action_filters is None: + return "" + string = self.command + if self.action_filters != MockActionFilter(): + string += f" {self.action_filters.to_string()}" + return string + + +# Test EESSIBotCommand class +@pytest.mark.parametrize("cmd_str,expectation", [ + # Test invalid filter + ("build for:arch=", pytest.raises(commands.EESSIBotCommandError)), + + # Test 'help' command + ("help", nullcontext(MockCommand("help"))), + + # Test 'status' command with last_build arg + ("status last_build", nullcontext(MockCommand("status", general_args=["last_build"], action_filters=None))), + + # Test 'build' command + ("build on:arch=icelake for:arch=x86_64/intel/icelake,accel=nvidia/cc90 repo:eessi.io-2025.06-software", + nullcontext(MockCommand("build", + action_filters=MockActionFilter([("architecture", "icelake"), + ("repository", "eessi.io-2025.06-software")]), + build_params={"architecture": "x86_64/intel/icelake", "accelerator": "nvidia/cc90"}))) +]) +def test_EESSIBotCommand(cmd_str, expectation): + with expectation as expected_command: + command = commands.EESSIBotCommand(cmd_str) + assert command == expected_command + assert command.to_string() == expected_command.to_string() From ff5bb3d34d23431a84b57c78aedde5dcd9725504 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:26:09 +0100 Subject: [PATCH 08/27] Add "Third party imports" comment for pytest --- tests/test_tools_args.py | 2 ++ tests/test_tools_build_params.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/tests/test_tools_args.py b/tests/test_tools_args.py index 825b454e..a0db511c 100644 --- a/tests/test_tools_args.py +++ b/tests/test_tools_args.py @@ -12,6 +12,8 @@ # Standard library imports from argparse import Namespace from contextlib import nullcontext + +# Third party imports (anything installed into the local Python environment) import pytest # Local application imports (anything from EESSI/eessi-bot-software-layer) diff --git a/tests/test_tools_build_params.py b/tests/test_tools_build_params.py index 02e79245..3c4e9441 100644 --- a/tests/test_tools_build_params.py +++ b/tests/test_tools_build_params.py @@ -11,6 +11,8 @@ # Standard library imports from contextlib import nullcontext + +# Third party imports (anything installed into the local Python environment) import pytest # Local application imports (anything from EESSI/eessi-bot-software-layer) From 44e89a041318f6bca021c0b0a9e0fa157df8fa6d Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:26:24 +0100 Subject: [PATCH 09/27] Fix hound issues --- tests/test_tools_commands.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_tools_commands.py b/tests/test_tools_commands.py index 16d64090..4c7f8640 100644 --- a/tests/test_tools_commands.py +++ b/tests/test_tools_commands.py @@ -49,8 +49,8 @@ def test_get_bot_command(): # Helper classes for EESSIBotCommand test class MockActionFilter(): - def __init__(self, action_filters=[]): - self.action_filters = action_filters + def __init__(self, action_filters=None): + self.action_filters = action_filters or [] def __eq__(self, other): return self.action_filters == other.action_filters @@ -60,9 +60,9 @@ def to_string(self): class MockCommand(): - def __init__(self, command, general_args=[], action_filters=MockActionFilter(), build_params=None): + def __init__(self, command, general_args=None, action_filters=None, build_params=None): self.command = command - self.general_args = general_args + self.general_args = general_args or [] self.action_filters = action_filters self.build_params = build_params @@ -87,10 +87,10 @@ def to_string(self): ("build for:arch=", pytest.raises(commands.EESSIBotCommandError)), # Test 'help' command - ("help", nullcontext(MockCommand("help"))), + ("help", nullcontext(MockCommand("help", action_filters=MockActionFilter()))), # Test 'status' command with last_build arg - ("status last_build", nullcontext(MockCommand("status", general_args=["last_build"], action_filters=None))), + ("status last_build", nullcontext(MockCommand("status", general_args=["last_build"]))), # Test 'build' command ("build on:arch=icelake for:arch=x86_64/intel/icelake,accel=nvidia/cc90 repo:eessi.io-2025.06-software", From 876b66230a8783f670a33cfffbc6db5ae8cfe457 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:35:29 +0100 Subject: [PATCH 10/27] Additional tests for tools/job_metadata.py --- tests/test_tools_job_metadata.py | 35 ++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/tests/test_tools_job_metadata.py b/tests/test_tools_job_metadata.py index 0d788248..b78f66b4 100644 --- a/tests/test_tools_job_metadata.py +++ b/tests/test_tools_job_metadata.py @@ -9,9 +9,22 @@ # license: GPLv2 # +# Standard library imports import os -from tools.job_metadata import get_section_from_file, JOB_PR_SECTION +# Local application imports (anything from EESSI/eessi-bot-software-layer) +from tools.job_metadata import determine_job_id_from_job_directory, get_section_from_file, JOB_PR_SECTION + + +def test_determine_job_id_from_job_directory(tmpdir): + logfile = os.path.join(tmpdir, "test_determine_job_id_from_job_directory.log") + + job_dir = os.path.join(tmpdir, "5") + assert determine_job_id_from_job_directory(job_dir, logfile) == 5 + + # determine_job_id_from_job_directory() should return 0 for non-job dirs + not_job_dir = os.path.join(tmpdir, "not-a-job-dir") + assert determine_job_id_from_job_directory(not_job_dir, logfile) == 0 def test_get_section_from_file(tmpdir): @@ -20,14 +33,32 @@ def test_get_section_from_file(tmpdir): path = os.path.join(tmpdir, 'test.metadata') assert get_section_from_file(path, JOB_PR_SECTION, logfile) is None + # Reading an empty file should return an empty dictionary + with open(path, 'w') as fp: + pass + metadata_pr = get_section_from_file(path, JOB_PR_SECTION, logfile) + assert metadata_pr == {} + + # Should return None if file exists but is invalid + with open(path, 'w') as fp: + fp.write("invalid format") + metadata_pr = get_section_from_file(path, JOB_PR_SECTION, logfile) + assert metadata_pr is None + + # Write a valid metadata file with open(path, 'w') as fp: fp.write('''[PR] repo=test - pr_number=12345''') + pr_number=12345 + pr_comment_id=23456 + job_owner=user01''') + # Verify that the metadata file is read correctly metadata_pr = get_section_from_file(path, JOB_PR_SECTION, logfile) expected = { "repo": "test", "pr_number": "12345", + "pr_comment_id": "23456", + "job_owner": "user01", } assert metadata_pr == expected From 2a303e7623f323c7d4df1174fb1040246060c077 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:33:36 +0100 Subject: [PATCH 11/27] Add tests for tools/logging.py --- tests/test_tools_logging.py | 56 +++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/test_tools_logging.py diff --git a/tests/test_tools_logging.py b/tests/test_tools_logging.py new file mode 100644 index 00000000..a7855f4a --- /dev/null +++ b/tests/test_tools_logging.py @@ -0,0 +1,56 @@ +# Tests for 'tools/logging.py' of the EESSI build-and-deploy bot, +# see https://github.com/EESSI/eessi-bot-software-layer +# +# The bot helps with requests to add software installations to the +# EESSI software layer, see https://github.com/EESSI/software-layer +# +# author: Sondre Bergsvaag Risanger (@sondrebr) +# +# license: GPLv2 +# + +# Standard library imports +import os + +# Third party imports (anything installed into the local Python environment) +import pytest + +# Local application imports (anything from EESSI/eessi-bot-software-layer) +from tools import logging + + +# Test error() - should write to stderr and exit +def test_error(capfd): + # Test exit code 1 (default) + msg = "this is the first error message" + with pytest.raises(SystemExit, check=lambda err: int(err.code) == 1): + logging.error(msg) + assert msg in capfd.readouterr().err + + # Test exit code 0 + msg2 = "this is the second error message" + with pytest.raises(SystemExit, check=lambda err: int(err.code) == 0): + logging.error(msg2, rc=0) + assert msg2 in capfd.readouterr().err + + +# Test log() +def test_log(monkeypatch, tmp_path): + # Use a new log file + log_file = os.path.join(tmp_path, "test.log") + monkeypatch.setattr(logging, "LOG", log_file) + + # log() should create the file if it does not exist + msg = "this is the first test message" + logging.log(msg) + assert os.path.exists(tmp_path) + with open(log_file, "r") as fp: + assert msg in fp.read() + + # log() should not truncate the file if it already exists + msg2 = "this is the second test message" + logging.log(msg2) + with open(log_file, "r") as fp: + log_contents = fp.read() + assert msg in log_contents + assert msg2 in log_contents From 831425215f0c48c603a93209886a99296811a3cd Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:44:22 +0100 Subject: [PATCH 12/27] Add tests for tools/permissions.py --- tests/test_app.cfg | 1 + tests/test_tools_permissions.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 tests/test_tools_permissions.py diff --git a/tests/test_app.cfg b/tests/test_app.cfg index 56c1d6cc..d0fff239 100644 --- a/tests/test_app.cfg +++ b/tests/test_app.cfg @@ -36,3 +36,4 @@ running_job = job `{job_id}` is running [finished_job_comments] [bot_control] +command_permission = user01 second_user diff --git a/tests/test_tools_permissions.py b/tests/test_tools_permissions.py new file mode 100644 index 00000000..c84f8eb9 --- /dev/null +++ b/tests/test_tools_permissions.py @@ -0,0 +1,33 @@ +# Tests for 'tools/permissions.py' of the EESSI build-and-deploy bot, +# see https://github.com/EESSI/eessi-bot-software-layer +# +# The bot helps with requests to add software installations to the +# EESSI software layer, see https://github.com/EESSI/software-layer +# +# author: Sondre Bergsvaag Risanger (@sondrebr) +# +# license: GPLv2 +# + +# Third party imports (anything installed into the local Python environment) +import pytest + +# Local application imports (anything from EESSI/eessi-bot-software-layer) +from tools import permissions + + +# Test check_command_permission() +@pytest.mark.parametrize("user,expected", [ + # In test_app.cfg: + # command_permission = user01 second_user + + # Users in test config + ("user01", True), + ("second_user", True), + + # Users not in test config + ("user03", False), + ("another_user_not_in_cfg", False), +]) +def test_check_command_permission(user, expected): + assert permissions.check_command_permission(user) is expected From 13c403962ddcf10b62779c77c32eb93eb54991b3 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:32:22 +0100 Subject: [PATCH 13/27] Replace tmpdir (legacy) with tmp_path --- tests/test_task_build.py | 70 ++++++++++++++++---------------- tests/test_tools_job_metadata.py | 14 +++---- tests/test_tools_pr_comments.py | 44 ++++++++++---------- 3 files changed, 64 insertions(+), 64 deletions(-) diff --git a/tests/test_task_build.py b/tests/test_task_build.py index af3ea842..cab91a35 100644 --- a/tests/test_task_build.py +++ b/tests/test_task_build.py @@ -36,10 +36,10 @@ from tests.test_tools_pr_comments import MockIssueComment -def test_run_cmd(tmpdir): +def test_run_cmd(tmp_path): """Tests for run_cmd function.""" - log_file = os.path.join(tmpdir, "log.txt") - output, err, exit_code = run_cmd("echo hello", 'test', tmpdir, log_file=log_file) + log_file = os.path.join(tmp_path, "log.txt") + output, err, exit_code = run_cmd("echo hello", 'test', tmp_path, log_file=log_file) assert exit_code == 0 assert output == "hello\n" @@ -47,7 +47,7 @@ def test_run_cmd(tmpdir): # Command fails and raise_on_error=True with pytest.raises(Exception): - output, err, exit_code = run_cmd("ls -l /does_not_exists.txt", 'fail test', tmpdir, log_file=log_file) + output, err, exit_code = run_cmd("ls -l /does_not_exists.txt", 'fail test', tmp_path, log_file=log_file) assert exit_code != 0 assert output == "" @@ -56,7 +56,7 @@ def test_run_cmd(tmpdir): # Command fails and raise_on_error=False output, err, exit_code = run_cmd("ls -l /does_not_exists.txt", 'fail test', - tmpdir, + tmp_path, log_file=log_file, raise_on_error=False) @@ -66,7 +66,7 @@ def test_run_cmd(tmpdir): # Command does not exists and raise_on_error=True with pytest.raises(Exception): - output, err, exit_code = run_cmd("this_command_does_not_exist", 'fail test', tmpdir, log_file=log_file) + output, err, exit_code = run_cmd("this_command_does_not_exist", 'fail test', tmp_path, log_file=log_file) assert exit_code != 0 assert output == "" @@ -76,7 +76,7 @@ def test_run_cmd(tmpdir): # Command does not exists and raise_on_error=False output, err, exit_code = run_cmd("this_command_does_not_exist", 'fail test', - tmpdir, + tmp_path, log_file=log_file, raise_on_error=False) @@ -86,22 +86,22 @@ def test_run_cmd(tmpdir): "this_command_does_not_exist: not found" in err) # Check that log_msg is written to log_file - output, err, exit_code = run_cmd("echo hello", "test in file", tmpdir, log_file=log_file) + output, err, exit_code = run_cmd("echo hello", "test in file", tmp_path, log_file=log_file) with open(log_file, "r") as fp: assert "test in file" in fp.read() -def test_run_subprocess(tmpdir): +def test_run_subprocess(tmp_path): """Tests for run_subprocess function.""" - log_file = os.path.join(tmpdir, "log.txt") - output, err, exit_code = run_subprocess("echo hello", 'test', tmpdir, log_file=log_file) + log_file = os.path.join(tmp_path, "log.txt") + output, err, exit_code = run_subprocess("echo hello", 'test', tmp_path, log_file=log_file) assert exit_code == 0 assert output == "hello\n" assert err == "" # log_msg="" - output, err, exit_code = run_subprocess("echo hello", "", tmpdir, log_file=log_file) + output, err, exit_code = run_subprocess("echo hello", "", tmp_path, log_file=log_file) assert exit_code == 0 assert output == "hello\n" @@ -110,11 +110,11 @@ def test_run_subprocess(tmpdir): # TODO: Better way to do this? assert "run_subprocess(): Running" in fp.read() - # working_dir=tmpdir - output, err, exit_code = run_subprocess("pwd", "test", tmpdir, log_file=log_file) + # working_dir=tmp_path + output, err, exit_code = run_subprocess("pwd", "test", tmp_path, log_file=log_file) assert exit_code == 0 - assert tmpdir.strpath+"\n" == output + assert f"{tmp_path}\n" == output assert err == "" # working_dir=None @@ -126,28 +126,28 @@ def test_run_subprocess(tmpdir): assert err == "" # env is not None - output, err, exit_code = run_subprocess("env", "test", tmpdir, log_file=log_file, env={"DUMMY": "123"}) + output, err, exit_code = run_subprocess("env", "test", tmp_path, log_file=log_file, env={"DUMMY": "123"}) assert exit_code == 0 assert "DUMMY=123" in output assert err == "" # Command fails - output, err, exit_code = run_subprocess("ls -l /does_not_exists.txt", 'fail test', tmpdir, log_file=log_file) + output, err, exit_code = run_subprocess("ls -l /does_not_exists.txt", 'fail test', tmp_path, log_file=log_file) assert exit_code != 0 assert output == "" assert "No such file or directory" in err # Command does not exist - output, err, exit_code = run_subprocess("this_command_does_not_exist", 'fail test', tmpdir, log_file=log_file) + output, err, exit_code = run_subprocess("this_command_does_not_exist", 'fail test', tmp_path, log_file=log_file) assert exit_code != 0 assert output == "" assert ("this_command_does_not_exist: command not found" in err or "this_command_does_not_exist: not found" in err) # Check that log_msg is written to log_file - output, err, exit_code = run_subprocess("echo hello", "test in file", tmpdir, log_file=log_file) + output, err, exit_code = run_subprocess("echo hello", "test in file", tmp_path, log_file=log_file) with open(log_file, "r") as fp: assert "test in file" in fp.read() @@ -318,14 +318,14 @@ def no_sleep_after_create(delay): # returns !None --> create_pr_comment returns comment (with id == 1) @pytest.mark.repo_name("EESSI/software-layer") @pytest.mark.pr_number(1) -def test_create_pr_comment_succeeds(monkeypatch, mocked_github, tmpdir): +def test_create_pr_comment_succeeds(monkeypatch, mocked_github, tmp_path): """Tests for function create_pr_comment.""" monkeypatch.setattr('tools.pr_comments.github', mocked_github) # creating a PR comment print("CREATING PR COMMENT") ym = datetime.today().strftime('%Y.%m') pr_number = 1 - job = Job(tmpdir, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") + job = Job(tmp_path, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") build_params = EESSIBotBuildParams("arch=amd/zen4,accel=nvidia/cc90") job_id = "123" @@ -348,14 +348,14 @@ def test_create_pr_comment_succeeds(monkeypatch, mocked_github, tmpdir): @pytest.mark.repo_name("EESSI/software-layer") @pytest.mark.pr_number(1) @pytest.mark.create_fails(True) -def test_create_pr_comment_succeeds_none(monkeypatch, mocked_github, tmpdir): +def test_create_pr_comment_succeeds_none(monkeypatch, mocked_github, tmp_path): """Tests for function create_pr_comment.""" monkeypatch.setattr('tools.pr_comments.github', mocked_github) # creating a PR comment print("CREATING PR COMMENT") ym = datetime.today().strftime('%Y.%m') pr_number = 1 - job = Job(tmpdir, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") + job = Job(tmp_path, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") build_params = EESSIBotBuildParams("arch=amd/zen4,accel=nvidia/cc90") job_id = "123" @@ -374,14 +374,14 @@ def test_create_pr_comment_succeeds_none(monkeypatch, mocked_github, tmpdir): @pytest.mark.repo_name("EESSI/software-layer") @pytest.mark.pr_number(1) @pytest.mark.create_raises("1") -def test_create_pr_comment_raises_once_then_succeeds(monkeypatch, mocked_github, tmpdir): +def test_create_pr_comment_raises_once_then_succeeds(monkeypatch, mocked_github, tmp_path): """Tests for function create_pr_comment.""" monkeypatch.setattr('tools.pr_comments.github', mocked_github) # creating a PR comment print("CREATING PR COMMENT") ym = datetime.today().strftime('%Y.%m') pr_number = 1 - job = Job(tmpdir, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") + job = Job(tmp_path, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") build_params = EESSIBotBuildParams("arch=amd/zen4,accel=nvidia/cc90") job_id = "123" @@ -400,14 +400,14 @@ def test_create_pr_comment_raises_once_then_succeeds(monkeypatch, mocked_github, @pytest.mark.repo_name("EESSI/software-layer") @pytest.mark.pr_number(1) @pytest.mark.create_raises("always_raise") -def test_create_pr_comment_always_raises(monkeypatch, mocked_github, tmpdir): +def test_create_pr_comment_always_raises(monkeypatch, mocked_github, tmp_path): """Tests for function create_pr_comment.""" monkeypatch.setattr('tools.pr_comments.github', mocked_github) # creating a PR comment print("CREATING PR COMMENT") ym = datetime.today().strftime('%Y.%m') pr_number = 1 - job = Job(tmpdir, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") + job = Job(tmp_path, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") build_params = EESSIBotBuildParams("arch=amd/zen4,accel=nvidia/cc90") job_id = "123" @@ -427,14 +427,14 @@ def test_create_pr_comment_always_raises(monkeypatch, mocked_github, tmpdir): @pytest.mark.repo_name("EESSI/software-layer") @pytest.mark.pr_number(1) @pytest.mark.create_raises("3") -def test_create_pr_comment_three_raises(monkeypatch, mocked_github, tmpdir): +def test_create_pr_comment_three_raises(monkeypatch, mocked_github, tmp_path): """Tests for function create_pr_comment.""" monkeypatch.setattr('tools.pr_comments.github', mocked_github) # creating a PR comment print("CREATING PR COMMENT") ym = datetime.today().strftime('%Y.%m') pr_number = 1 - job = Job(tmpdir, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") + job = Job(tmp_path, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") build_params = EESSIBotBuildParams("arch=amd/zen4,accel=nvidia/cc90") job_id = "123" @@ -452,12 +452,12 @@ def test_create_pr_comment_three_raises(monkeypatch, mocked_github, tmpdir): @pytest.mark.repo_name("test_repo") @pytest.mark.pr_number(999) -def test_create_read_metadata_file(mocked_github, tmpdir): +def test_create_read_metadata_file(mocked_github, tmp_path): """Tests for function create_metadata_file.""" # create some test data ym = datetime.today().strftime('%Y.%m') pr_number = 999 - job = Job(tmpdir, "test/architecture", "EESSI", "--speed_up_job", ym, pr_number, "fpga/magic", "user01") + job = Job(tmp_path, "test/architecture", "EESSI", "--speed_up_job", ym, pr_number, "fpga/magic", "user01") job_id = "123" @@ -466,7 +466,7 @@ def test_create_read_metadata_file(mocked_github, tmpdir): create_metadata_file(job, job_id, pr_comment) expected_file = f"_bot_job{job_id}.metadata" - expected_file_path = os.path.join(tmpdir, expected_file) + expected_file_path = os.path.join(tmp_path, expected_file) # assert expected_file exists assert os.path.exists(expected_file_path) @@ -489,7 +489,7 @@ def test_create_read_metadata_file(mocked_github, tmpdir): assert sorted(metadata["PR"].keys()) == ["job_owner", "pr_comment_id", "pr_number", "repo"] # use directory that does not exist - dir_does_not_exist = os.path.join(tmpdir, "dir_does_not_exist") + dir_does_not_exist = os.path.join(tmp_path, "dir_does_not_exist") job2 = Job(dir_does_not_exist, "test/architecture", "EESSI", "--speed_up_job", ym, pr_number, "fpga/magic", "user01") job_id2 = "222" @@ -509,12 +509,12 @@ def test_create_read_metadata_file(mocked_github, tmpdir): # use undefined values for parameters # job_id = None - job4 = Job(tmpdir, "test/architecture", "EESSI", "--speed_up_job", ym, pr_number, "fpga/magic", "user01") + job4 = Job(tmp_path, "test/architecture", "EESSI", "--speed_up_job", ym, pr_number, "fpga/magic", "user01") job_id4 = None create_metadata_file(job4, job_id4, pr_comment) expected_file4 = f"_bot_job{job_id}.metadata" - expected_file_path4 = os.path.join(tmpdir, expected_file4) + expected_file_path4 = os.path.join(tmp_path, expected_file4) # assert expected_file exists assert os.path.exists(expected_file_path4) diff --git a/tests/test_tools_job_metadata.py b/tests/test_tools_job_metadata.py index b78f66b4..c239d1eb 100644 --- a/tests/test_tools_job_metadata.py +++ b/tests/test_tools_job_metadata.py @@ -16,21 +16,21 @@ from tools.job_metadata import determine_job_id_from_job_directory, get_section_from_file, JOB_PR_SECTION -def test_determine_job_id_from_job_directory(tmpdir): - logfile = os.path.join(tmpdir, "test_determine_job_id_from_job_directory.log") +def test_determine_job_id_from_job_directory(tmp_path): + logfile = os.path.join(tmp_path, "test_determine_job_id_from_job_directory.log") - job_dir = os.path.join(tmpdir, "5") + job_dir = os.path.join(tmp_path, "5") assert determine_job_id_from_job_directory(job_dir, logfile) == 5 # determine_job_id_from_job_directory() should return 0 for non-job dirs - not_job_dir = os.path.join(tmpdir, "not-a-job-dir") + not_job_dir = os.path.join(tmp_path, "not-a-job-dir") assert determine_job_id_from_job_directory(not_job_dir, logfile) == 0 -def test_get_section_from_file(tmpdir): - logfile = os.path.join(tmpdir, 'test_get_section_from_file.log') +def test_get_section_from_file(tmp_path): + logfile = os.path.join(tmp_path, 'test_get_section_from_file.log') # if metadata file does not exist, we should get None as return value - path = os.path.join(tmpdir, 'test.metadata') + path = os.path.join(tmp_path, 'test.metadata') assert get_section_from_file(path, JOB_PR_SECTION, logfile) is None # Reading an empty file should return an empty dictionary diff --git a/tests/test_tools_pr_comments.py b/tests/test_tools_pr_comments.py index f89b3fd8..300fac25 100644 --- a/tests/test_tools_pr_comments.py +++ b/tests/test_tools_pr_comments.py @@ -636,8 +636,8 @@ def test_get_submitted_job_comment_retry(pr_job_get_comment_retry): # - pr.get_issue_comment(cmnt_id): 1st None ==> no edit # (patching pr.get_issue_comment via ContextManager to return None) -def test_update_comment_none(tmpdir): - log_file = os.path.join(tmpdir, "log.txt") +def test_update_comment_none(tmp_path): + log_file = os.path.join(tmp_path, "log.txt") with patch('github.PullRequest.PullRequest') as mock_pr: instance = mock_pr.return_value @@ -652,8 +652,8 @@ def test_update_comment_none(tmpdir): # log_file should contain error message "" expected = f"no comment with id {cmnt_id}, skipping update '{update}'" - file = tmpdir.join("log.txt") - actual = file.read() + file = tmp_path.joinpath("log.txt") + actual = file.read_text() # actual log message starts with a timestamp, hence we use 'in' assert expected in actual @@ -692,8 +692,8 @@ def test_update_comment_second_edit_succeeds(issue_edit_second_call_succeeds): # (edit_raises='N') or # (edit_raises='always_raise') # update_comment called with (str) -def test_update_comment_five_edit_fail(tmpdir, issue_edit_five_calls_fail): - log_file = os.path.join(tmpdir, "log.txt") +def test_update_comment_five_edit_fail(tmp_path, issue_edit_five_calls_fail): + log_file = os.path.join(tmp_path, "log.txt") # issue_edit_five_calls_fail provides one comment with "foo" os.environ['TEST_RAISE_EXCEPTION'] = '0' with pytest.raises(IssueCommentEditException): @@ -713,8 +713,8 @@ def test_update_comment_five_edit_fail(tmpdir, issue_edit_five_calls_fail): assert expected == actual -def test_update_comment_all_edit_fail(tmpdir, issue_edit_all_calls_fail): - log_file = os.path.join(tmpdir, "log.txt") +def test_update_comment_all_edit_fail(tmp_path, issue_edit_all_calls_fail): + log_file = os.path.join(tmp_path, "log.txt") # issue_edit_all_calls_fail provides one comment with "foo" os.environ['TEST_RAISE_EXCEPTION'] = '0' with pytest.raises(IssueCommentEditException): @@ -741,8 +741,8 @@ def test_update_comment_all_edit_fail(tmpdir, issue_edit_all_calls_fail): # (TEST_RAISE_EXCEPTION='0') # ==> edit: always fails (err2) # update_comment called with (int) -# def test_update_comment_edit_type_error(tmpdir, pr_with_any_comment): -# log_file = os.path.join(tmpdir, "log.txt") +# def test_update_comment_edit_type_error(tmp_path, pr_with_any_comment): +# log_file = os.path.join(tmp_path, "log.txt") # # pr_with_any_comment provides one comment with "foo" # os.environ['TEST_RAISE_EXCEPTION'] = '0' # #with pytest.raises(Exception) as err: @@ -763,8 +763,8 @@ def test_update_comment_all_edit_fail(tmpdir, issue_edit_all_calls_fail): # - pr.get_issue_comment(cmnt_id): 1st-Nth fail(err0) ==> no edit # (TEST_RAISE_EXCEPTION='N') -def test_update_comment_five_get_issue_comment_fail(tmpdir, issue_edit_five_calls_fail): - log_file = os.path.join(tmpdir, "log.txt") +def test_update_comment_five_get_issue_comment_fail(tmp_path, issue_edit_five_calls_fail): + log_file = os.path.join(tmp_path, "log.txt") # issue_edit_five_calls_fail just provides retry testing for # get_issue_comment # since all calls to this shall fail, we don't use the edit part here @@ -788,8 +788,8 @@ def test_update_comment_five_get_issue_comment_fail(tmpdir, issue_edit_five_call # - pr.get_issue_comment(cmnt_id): 1st-Nth fail(err0) ==> no edit # (TEST_RAISE_EXCEPTION='always_raise') -def test_update_comment_all_get_issue_comment_fail(tmpdir, issue_edit_all_calls_fail): - log_file = os.path.join(tmpdir, "log.txt") +def test_update_comment_all_get_issue_comment_fail(tmp_path, issue_edit_all_calls_fail): + log_file = os.path.join(tmp_path, "log.txt") # issue_edit_all_calls_fail just provides retry testing for # get_issue_comment # since all calls to this shall fail, we don't use the edit part here @@ -816,8 +816,8 @@ def test_update_comment_all_get_issue_comment_fail(tmpdir, issue_edit_all_calls_ # ==> edit: 1st succeeds # (edit_raises='0') # update_comment called with (str) -def test_update_comment_second_get_call_first_edit(tmpdir, issue_edit_first_call_succeeds): - log_file = os.path.join(tmpdir, "log.txt") +def test_update_comment_second_get_call_first_edit(tmp_path, issue_edit_first_call_succeeds): + log_file = os.path.join(tmp_path, "log.txt") os.environ['TEST_RAISE_EXCEPTION'] = '1' update_comment(0, issue_edit_first_call_succeeds, "-update", log_file=log_file) @@ -845,8 +845,8 @@ def test_update_comment_second_get_call_first_edit(tmpdir, issue_edit_first_call # ==> edit: 1st-(N-1)th fail(err1), 2nd-Nth succeeds # (edit_raises='1') # update_comment called with (str) -def test_update_comment_second_get_call_second_edit(tmpdir, issue_edit_second_call_succeeds): - log_file = os.path.join(tmpdir, "log.txt") +def test_update_comment_second_get_call_second_edit(tmp_path, issue_edit_second_call_succeeds): + log_file = os.path.join(tmp_path, "log.txt") os.environ['TEST_RAISE_EXCEPTION'] = '1' update_comment(0, issue_edit_second_call_succeeds, "-update", log_file=log_file) @@ -874,8 +874,8 @@ def test_update_comment_second_get_call_second_edit(tmpdir, issue_edit_second_ca # ==> edit: 1st-Nth fail(err1) # (edit_raises='N') or # update_comment called with (str) -def test_update_comment_second_get_call_five_edits_fail(tmpdir, issue_edit_five_calls_fail): - log_file = os.path.join(tmpdir, "log.txt") +def test_update_comment_second_get_call_five_edits_fail(tmp_path, issue_edit_five_calls_fail): + log_file = os.path.join(tmp_path, "log.txt") os.environ['TEST_RAISE_EXCEPTION'] = '1' with pytest.raises(IssueCommentEditException): update_comment(0, issue_edit_five_calls_fail, "-update", log_file=log_file) @@ -904,8 +904,8 @@ def test_update_comment_second_get_call_five_edits_fail(tmpdir, issue_edit_five_ # ==> edit: 1st-Nth fail(err1) # (edit_raises='always_raise') # update_comment called with (str) -def test_update_comment_second_get_call_all_edits_fail(tmpdir, issue_edit_all_calls_fail): - log_file = os.path.join(tmpdir, "log.txt") +def test_update_comment_second_get_call_all_edits_fail(tmp_path, issue_edit_all_calls_fail): + log_file = os.path.join(tmp_path, "log.txt") os.environ['TEST_RAISE_EXCEPTION'] = '1' with pytest.raises(IssueCommentEditException): update_comment(0, issue_edit_all_calls_fail, "-update", log_file=log_file) From 92ca8a5d45cb5795016d7a7d25fd57dea40e5f4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bob=20Dr=C3=B6ge?= Date: Thu, 26 Feb 2026 13:11:21 +0100 Subject: [PATCH 14/27] better parsing of scontrol output --- eessi_bot_job_manager.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/eessi_bot_job_manager.py b/eessi_bot_job_manager.py index 85fba369..8689fe77 100644 --- a/eessi_bot_job_manager.py +++ b/eessi_bot_job_manager.py @@ -281,8 +281,20 @@ def parse_scontrol_show_job_output(self, output): """ job_info = {} stripped_output = output.strip() - for pair in stripped_output.split(): - key, value = pair.split('=', 1) + + # Match keys that start with uppercase and continue until '=' + key_pattern = re.compile(r'([A-Z][A-Za-z0-9:/_]*)=') + + keys_matches = list(key_pattern.finditer(stripped_output)) + + for i, key_match in enumerate(keys_matches): + # The key is what actually got matched by the regex + key = key_match.group(1) + # The value starts where the key ends... + value_start = key_match.end() + # and it ends where the next key starts (or, if it's the last one, where the string ends) + value_end = keys_matches[i+1].start() if (i + 1) < len(keys_matches) else len(stripped_output) + value = stripped_output[value_start:value_end].strip() job_info[key] = value return job_info From f3b3fc03e747d34e3a3fb5b1466a7f2a05669b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bob=20Dr=C3=B6ge?= Date: Thu, 26 Feb 2026 13:40:13 +0100 Subject: [PATCH 15/27] update docstring --- eessi_bot_job_manager.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/eessi_bot_job_manager.py b/eessi_bot_job_manager.py index 8689fe77..4837840c 100644 --- a/eessi_bot_job_manager.py +++ b/eessi_bot_job_manager.py @@ -272,6 +272,9 @@ def parse_scontrol_show_job_output(self, output): """ The output of 'scontrol --oneliner show job' is a list of key=value pairs separated by whitespaces. + Note that with newer Slurm versions, some values can also contain whitespaces + (e.g. for SubmitLine), making it complex to distinguish between keys and values. + To solve this, we assume that all Slurm keys start with an uppercase letter. Args: output (string): the output of the scontrol command From a57b3c193a5d50be3712f3d4ed42a597d9c30553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bob=20Dr=C3=B6ge?= Date: Thu, 26 Feb 2026 13:40:53 +0100 Subject: [PATCH 16/27] add test for parse_scontrol_show_job_output --- tests/test_eessi_bot_job_manager.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_eessi_bot_job_manager.py b/tests/test_eessi_bot_job_manager.py index 5c5a9c05..57699fad 100644 --- a/tests/test_eessi_bot_job_manager.py +++ b/tests/test_eessi_bot_job_manager.py @@ -135,3 +135,25 @@ def test_determine_finished_jobs(): assert job_manager.determine_finished_jobs(known_jobs, current_jobs_all_jobs) == [] assert job_manager.determine_finished_jobs(known_jobs, current_jobs_one_job) == ['1', '2'] assert job_manager.determine_finished_jobs(known_jobs, {}) == ['0', '1', '2'] + + +def test_parse_scontrol_show_job_output(): + # Dummy output (shortened) from Slurm 25.11.3 for "scontrol show job " + scontrol_output = '''JobId=123 JobName=bot_test_job UserId=eessibot(12345) MCS_label=N/A EligibleTime=Unknown AllocNode:Sid=my.node.name:123456 SubmitLine=/opt/slurm/25.11.3/bin/sbatch --hold --time=10-0:0:0 --nodes=1 --exclusive --cpus-per-task=1 --job-name=bot_test_job /home/eessibot/job.slurm WorkDir=/home/eessibot/shared/jobs/2026.01/pr_123/event_123-456-789/run_000/riscv64/generic/dev.eessi.io-riscv-2025.06-001 StdErr= StdIn=/dev/null StdOut=/home/eessibot/shared/jobs/2026.01/pr_123/event_123-456-789/run_000/riscv64/generic/dev.eessi.io-riscv-2025.06-001/slurm-123.out TresPerTask=cpu=1''' + job_manager = EESSIBotSoftwareLayerJobManager() + job_info = job_manager.parse_scontrol_show_job_output(scontrol_output) + job_info_expected = { + 'JobId': '123', + 'JobName': 'bot_test_job', + 'UserId': 'eessibot(12345)', + 'MCS_label': 'N/A', + 'EligibleTime': 'Unknown', + 'AllocNode:Sid': 'my.node.name:123456', + 'SubmitLine': '/opt/slurm/25.11.3/bin/sbatch --hold --time=10-0:0:0 --nodes=1 --exclusive --cpus-per-task=1 --job-name=bot_test_job /home/eessibot/job.slurm', + 'WorkDir': '/home/eessibot/shared/jobs/2026.01/pr_123/event_123-456-789/run_000/riscv64/generic/dev.eessi.io-riscv-2025.06-001', + 'StdErr': '', + 'StdIn': '/dev/null', + 'StdOut': '/home/eessibot/shared/jobs/2026.01/pr_123/event_123-456-789/run_000/riscv64/generic/dev.eessi.io-riscv-2025.06-001/slurm-123.out', + 'TresPerTask': 'cpu=1', + } + assert job_info == job_info_expected From 70b8f8d905a0686f9935f3473b48031e3f4194d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bob=20Dr=C3=B6ge?= Date: Thu, 26 Feb 2026 13:44:21 +0100 Subject: [PATCH 17/27] shorten paths/lines --- tests/test_eessi_bot_job_manager.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_eessi_bot_job_manager.py b/tests/test_eessi_bot_job_manager.py index 57699fad..bc96a6f9 100644 --- a/tests/test_eessi_bot_job_manager.py +++ b/tests/test_eessi_bot_job_manager.py @@ -139,7 +139,7 @@ def test_determine_finished_jobs(): def test_parse_scontrol_show_job_output(): # Dummy output (shortened) from Slurm 25.11.3 for "scontrol show job " - scontrol_output = '''JobId=123 JobName=bot_test_job UserId=eessibot(12345) MCS_label=N/A EligibleTime=Unknown AllocNode:Sid=my.node.name:123456 SubmitLine=/opt/slurm/25.11.3/bin/sbatch --hold --time=10-0:0:0 --nodes=1 --exclusive --cpus-per-task=1 --job-name=bot_test_job /home/eessibot/job.slurm WorkDir=/home/eessibot/shared/jobs/2026.01/pr_123/event_123-456-789/run_000/riscv64/generic/dev.eessi.io-riscv-2025.06-001 StdErr= StdIn=/dev/null StdOut=/home/eessibot/shared/jobs/2026.01/pr_123/event_123-456-789/run_000/riscv64/generic/dev.eessi.io-riscv-2025.06-001/slurm-123.out TresPerTask=cpu=1''' + scontrol_output = '''JobId=123 JobName=bot_test_job UserId=eessibot(12345) MCS_label=N/A EligibleTime=Unknown AllocNode:Sid=my.node.name:123456 SubmitLine=/opt/slurm/25.11.3/bin/sbatch --hold --time=10-0:0:0 --nodes=1 --exclusive --cpus-per-task=1 --job-name=bot_test_job /home/eessibot/job.slurm WorkDir=/jobs/2026.01/pr_123/event_123-456-789/run_000/riscv64/generic/dev.eessi.io-riscv StdErr= StdIn=/dev/null StdOut=/jobs/2026.01/pr_123/event_123-456-789/run_000/riscv64/generic/dev.eessi.io-riscv/slurm-123.out TresPerTask=cpu=1''' job_manager = EESSIBotSoftwareLayerJobManager() job_info = job_manager.parse_scontrol_show_job_output(scontrol_output) job_info_expected = { @@ -149,11 +149,12 @@ def test_parse_scontrol_show_job_output(): 'MCS_label': 'N/A', 'EligibleTime': 'Unknown', 'AllocNode:Sid': 'my.node.name:123456', - 'SubmitLine': '/opt/slurm/25.11.3/bin/sbatch --hold --time=10-0:0:0 --nodes=1 --exclusive --cpus-per-task=1 --job-name=bot_test_job /home/eessibot/job.slurm', - 'WorkDir': '/home/eessibot/shared/jobs/2026.01/pr_123/event_123-456-789/run_000/riscv64/generic/dev.eessi.io-riscv-2025.06-001', + 'SubmitLine': '/opt/slurm/25.11.3/bin/sbatch --hold --time=10-0:0:0 --nodes=1 --exclusive --cpus-per-task=1 ' + '--job-name=bot_test_job /home/eessibot/job.slurm', + 'WorkDir': '/jobs/2026.01/pr_123/event_123-456-789/run_000/riscv64/generic/dev.eessi.io-riscv', 'StdErr': '', 'StdIn': '/dev/null', - 'StdOut': '/home/eessibot/shared/jobs/2026.01/pr_123/event_123-456-789/run_000/riscv64/generic/dev.eessi.io-riscv-2025.06-001/slurm-123.out', + 'StdOut': '/jobs/2026.01/pr_123/event_123-456-789/run_000/riscv64/generic/dev.eessi.io-riscv/slurm-123.out', 'TresPerTask': 'cpu=1', } assert job_info == job_info_expected From c6523f5785b16d7a7a1e39f0ff70340deaff3bdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bob=20Dr=C3=B6ge?= Date: Thu, 26 Feb 2026 13:49:06 +0100 Subject: [PATCH 18/27] split long line --- tests/test_eessi_bot_job_manager.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_eessi_bot_job_manager.py b/tests/test_eessi_bot_job_manager.py index bc96a6f9..aae9d038 100644 --- a/tests/test_eessi_bot_job_manager.py +++ b/tests/test_eessi_bot_job_manager.py @@ -139,7 +139,12 @@ def test_determine_finished_jobs(): def test_parse_scontrol_show_job_output(): # Dummy output (shortened) from Slurm 25.11.3 for "scontrol show job " - scontrol_output = '''JobId=123 JobName=bot_test_job UserId=eessibot(12345) MCS_label=N/A EligibleTime=Unknown AllocNode:Sid=my.node.name:123456 SubmitLine=/opt/slurm/25.11.3/bin/sbatch --hold --time=10-0:0:0 --nodes=1 --exclusive --cpus-per-task=1 --job-name=bot_test_job /home/eessibot/job.slurm WorkDir=/jobs/2026.01/pr_123/event_123-456-789/run_000/riscv64/generic/dev.eessi.io-riscv StdErr= StdIn=/dev/null StdOut=/jobs/2026.01/pr_123/event_123-456-789/run_000/riscv64/generic/dev.eessi.io-riscv/slurm-123.out TresPerTask=cpu=1''' + scontrol_output = 'JobId=123 JobName=bot_test_job UserId=eessibot(12345) MCS_label=N/A EligibleTime=Unknown' \ + ' AllocNode:Sid=my.node.name:123456 SubmitLine=/opt/slurm/25.11.3/bin/sbatch --hold' \ + ' --time=10-0:0:0 --nodes=1 --exclusive --cpus-per-task=1 --job-name=bot_test_job ' \ + '/home/eessibot/job.slurm WorkDir=/jobs/2026.01/pr_123/event_123-456-789/run_000/riscv64/' \ + 'generic/dev.eessi.io-riscv StdErr= StdIn=/dev/null StdOut=/jobs/2026.01/pr_123/' \ + 'event_123-456-789/run_000/riscv64/generic/dev.eessi.io-riscv/slurm-123.out TresPerTask=cpu=1' job_manager = EESSIBotSoftwareLayerJobManager() job_info = job_manager.parse_scontrol_show_job_output(scontrol_output) job_info_expected = { From 394677fb2e65c4dc1fcb99bce7449655af709537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bob=20Dr=C3=B6ge?= Date: Tue, 3 Mar 2026 10:07:11 +0100 Subject: [PATCH 19/27] add explicit Slurm version to comment --- eessi_bot_job_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eessi_bot_job_manager.py b/eessi_bot_job_manager.py index 4837840c..293bc3f7 100644 --- a/eessi_bot_job_manager.py +++ b/eessi_bot_job_manager.py @@ -272,7 +272,7 @@ def parse_scontrol_show_job_output(self, output): """ The output of 'scontrol --oneliner show job' is a list of key=value pairs separated by whitespaces. - Note that with newer Slurm versions, some values can also contain whitespaces + Note that with newer Slurm versions (25.11), some values can also contain whitespaces (e.g. for SubmitLine), making it complex to distinguish between keys and values. To solve this, we assume that all Slurm keys start with an uppercase letter. From b4777fac1b3940102ca62ffbf841086abacaeaa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bob=20Dr=C3=B6ge?= Date: Tue, 3 Mar 2026 10:08:08 +0100 Subject: [PATCH 20/27] rename i to idx --- eessi_bot_job_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eessi_bot_job_manager.py b/eessi_bot_job_manager.py index 293bc3f7..5319a22e 100644 --- a/eessi_bot_job_manager.py +++ b/eessi_bot_job_manager.py @@ -290,13 +290,13 @@ def parse_scontrol_show_job_output(self, output): keys_matches = list(key_pattern.finditer(stripped_output)) - for i, key_match in enumerate(keys_matches): + for idx, key_match in enumerate(keys_matches): # The key is what actually got matched by the regex key = key_match.group(1) # The value starts where the key ends... value_start = key_match.end() # and it ends where the next key starts (or, if it's the last one, where the string ends) - value_end = keys_matches[i+1].start() if (i + 1) < len(keys_matches) else len(stripped_output) + value_end = keys_matches[idx+1].start() if (idx + 1) < len(keys_matches) else len(stripped_output) value = stripped_output[value_start:value_end].strip() job_info[key] = value From 0f472524ae38dc8849fd32bf7e98d2596543f4c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sondre=20Bergsv=C3=A5g=20Risanger?= <168830227+sondrebr@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:23:52 +0100 Subject: [PATCH 21/27] Apply suggestion from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Thomas Röblitz --- tests/test_tools_job_metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_tools_job_metadata.py b/tests/test_tools_job_metadata.py index c239d1eb..40d596bc 100644 --- a/tests/test_tools_job_metadata.py +++ b/tests/test_tools_job_metadata.py @@ -34,7 +34,7 @@ def test_get_section_from_file(tmp_path): assert get_section_from_file(path, JOB_PR_SECTION, logfile) is None # Reading an empty file should return an empty dictionary - with open(path, 'w') as fp: + with open(path, 'w') as _: pass metadata_pr = get_section_from_file(path, JOB_PR_SECTION, logfile) assert metadata_pr == {} From cbacfa28b5a2489f7b2e6c74ea29e0921ab7c936 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:36:44 +0100 Subject: [PATCH 22/27] Update conftest.py authors --- tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/conftest.py b/tests/conftest.py index 6338687e..9d87804f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ # EESSI software layer, see https://github.com/EESSI/software-layer # # author: Thomas Roeblitz (@trz42) +# author: Sondre Bergsvaag Risanger (@sondrebr) # # license: GPLv2 # From def659e85a17dcb16b056c9e8d1b9286ee89e600 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:02:19 +0100 Subject: [PATCH 23/27] Apply suggested formatting change --- tests/test_tools_job_metadata.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/test_tools_job_metadata.py b/tests/test_tools_job_metadata.py index 40d596bc..5a1bdfc0 100644 --- a/tests/test_tools_job_metadata.py +++ b/tests/test_tools_job_metadata.py @@ -47,11 +47,13 @@ def test_get_section_from_file(tmp_path): # Write a valid metadata file with open(path, 'w') as fp: - fp.write('''[PR] - repo=test - pr_number=12345 - pr_comment_id=23456 - job_owner=user01''') + fp.write(""" +[PR] +repo=test +pr_number=12345 +pr_comment_id=23456 +job_owner=user01 +""") # Verify that the metadata file is read correctly metadata_pr = get_section_from_file(path, JOB_PR_SECTION, logfile) From 387d4d765805d068353a15cf021d8b7834a0499c Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:44:18 +0100 Subject: [PATCH 24/27] Add test cases for contains_any_bot_command --- tests/test_tools_commands.py | 59 +++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/tests/test_tools_commands.py b/tests/test_tools_commands.py index 4c7f8640..f1f9f203 100644 --- a/tests/test_tools_commands.py +++ b/tests/test_tools_commands.py @@ -19,21 +19,50 @@ from tools import commands -def test_contains_any_bot_command(): - # Test without command - body = "\n".join([ - "not a command", - "also not a command" - ]) - assert commands.contains_any_bot_command(body) is False - - # Test with command - body = "\n".join([ - "not a command", - "bot: help", - "also not a command" - ]) - assert commands.contains_any_bot_command(body) is True +# Test contains_any_bot_command() with both single-line and multi-line comments +@pytest.mark.parametrize("body,expected", [ + # Test single-line comments + # Existing command, no space after ':' + ("bot:help", True), + # Existing command, space after ':' + ("bot: help", True), + + # Command does not exist, no space after ':' + ("bot:nohelp", True), + # Command does not exist, space after ':' + ("bot: nohelp", True), + + # Leading whitespace, no space after ':' + (" bot:help", False), + # Leading whitespace, space after ':' + (" bot: help", False), + + # Test multi-line comments + # Valid command in first line, no space after ':' + ("\n".join(["bot:help", "not a command", "also not a command"]), True), + # Valid command in first line, space after ':' + ("\n".join(["bot: help", "not a command", "also not a command"]), True), + + # Valid command after first line, no space after ':' + ("\n".join(["not a command", "bot:help", "also not a command"]), True), + # Valid command after first line, space after ':' + ("\n".join(["not a command", "bot: help", "also not a command"]), True), + + # Multiple valid commands, no space after ':' + ("\n".join(["bot:help", "bot:nohelp", "also not a command"]), True), + # Multiple valid commands, space after ':' + ("\n".join(["bot: help", "bot: nohelp", "also not a command"]), True), + + # No commands + ("\n".join(["not a command", "also not a command"]), False), + + # Command with leading whitespace after second line, no space after ':' + ("\n".join(["not a command", " bot:help", "also not a command"]), False), + # Command with leading whitespace after second line, space after ':' + ("\n".join(["not a command", " bot: help", "also not a command"]), False), +]) +def test_contains_any_bot_command(body, expected): + assert commands.contains_any_bot_command(body) is expected def test_get_bot_command(): From e0bbc93e4e5008141e92d92acfd5aa225410dc29 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:23:21 +0100 Subject: [PATCH 25/27] Add test cases for get_bot_command --- tests/test_tools_commands.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/tests/test_tools_commands.py b/tests/test_tools_commands.py index f1f9f203..da0d70af 100644 --- a/tests/test_tools_commands.py +++ b/tests/test_tools_commands.py @@ -70,10 +70,31 @@ def test_get_bot_command(): no_cmd = commands.get_bot_command("not a command") assert no_cmd is None - # Test command - test_cmd = "help" - cmd = commands.get_bot_command(f"bot: {test_cmd}") - assert cmd == test_cmd + # Test different commands with varying formatting + test_cmds = [ + # All existing commands + "build", "cancel", "help", "show_config", "status", + # Build command with filters + "build on:arch=icelake for:arch=x86_64/intel/icelake,accel=nvidia/cc90 repo:eessi.io-2025.06-software", + # Non-existant command + "this_command_does_not_exist", + ] + for test_cmd in test_cmds: + # Valid formatting, with and without space after ':' + cmd = commands.get_bot_command(f"bot:{test_cmd}") + assert cmd == test_cmd + cmd = commands.get_bot_command(f"bot: {test_cmd}") + assert cmd == test_cmd + + # Leading whitespace, with and without space after ':' - should return None + cmd = commands.get_bot_command(f" bot:{test_cmd}") + assert cmd is None + cmd = commands.get_bot_command(f" bot: {test_cmd}") + assert cmd is None + + # Without ':' - should return None + cmd = commands.get_bot_command(f"bot {test_cmd}") + assert cmd is None # Helper classes for EESSIBotCommand test From 6885f671160eaa5882da366d8e5aeb837fda436d Mon Sep 17 00:00:00 2001 From: Thomas Roeblitz Date: Wed, 10 Jun 2026 17:11:22 +0200 Subject: [PATCH 26/27] release notes for v0.12.0 --- RELEASE_NOTES | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/RELEASE_NOTES b/RELEASE_NOTES index 832a5798..e9c5f013 100644 --- a/RELEASE_NOTES +++ b/RELEASE_NOTES @@ -1,6 +1,24 @@ This file contains a description of the major changes to the EESSI build-and-deploy bot. For more detailed information, please see the git log. +v0.12.0 (11 Juni 2026) +-------------------------- + +This is a minor release of the EESSI build-and-deploy bot. + +Bug fixes: +* do not check export variables in `check_filters` function (#364) + +Improvements: +* increase coverage by unit tests (#365) +* make parsing of `scontrol` output compatible with newer Slurm versions (#369) + * With newer Slurm versions (25.11), some values can also contain whitespaces + (e.g. for SubmitLine), making it complex to distinguish between keys and values. + +Changes to 'app.cfg' settings (see README.md and app.cfg.example for details): +* none + + v0.11.0 (28 January 2026) -------------------------- From b8f6ba6a4fc4d804dfb018c062dbc81863779441 Mon Sep 17 00:00:00 2001 From: Kenneth Hoste Date: Wed, 10 Jun 2026 17:35:51 +0200 Subject: [PATCH 27/27] fix typo in release date for v0.12.0 --- RELEASE_NOTES | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE_NOTES b/RELEASE_NOTES index e9c5f013..e1bb99a6 100644 --- a/RELEASE_NOTES +++ b/RELEASE_NOTES @@ -1,7 +1,7 @@ This file contains a description of the major changes to the EESSI build-and-deploy bot. For more detailed information, please see the git log. -v0.12.0 (11 Juni 2026) +v0.12.0 (11 June 2026) -------------------------- This is a minor release of the EESSI build-and-deploy bot.