diff --git a/autosubmit/notifications/mail_notifier.py b/autosubmit/notifications/mail_notifier.py index e6a5f2c43..25f800b49 100644 --- a/autosubmit/notifications/mail_notifier.py +++ b/autosubmit/notifications/mail_notifier.py @@ -17,62 +17,208 @@ # You should have received a copy of the GNU General Public License # along with Autosubmit. If not, see . -import smtplib import email.utils +import smtplib +import zipfile +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText -from log.log import Log +from pathlib import Path +from tempfile import TemporaryDirectory +from textwrap import dedent +from typing import List, TYPE_CHECKING + +from autosubmitconfigparser.config.basicconfig import BasicConfig +from log.log import Log, AutosubmitError + +if TYPE_CHECKING: + from autosubmit.platforms.platform import Platform + + +def _compress_file( + temporary_directory: TemporaryDirectory, + file_path: Path) -> Path: + """Compress a file. + + The file is created inside the given temporary directory. + + The function returns a ``Path`` of the archive file. + + :param temporary_directory: The temporary directory. + :type temporary_directory: TemporaryDirectory + :param file_path: The path of the file to be compressed. + :type file_path: Path + :return: The Path object of the compressed file. + :rtype: str + :raises AutosubmitError: The file cannot be compressed. + """ + try: + zip_file_name = Path(temporary_directory.name, f'{file_path.name}.zip') + with zipfile.ZipFile(zip_file_name, 'w', zipfile.ZIP_DEFLATED) as zip_file: + zip_file.write(file_path, Path(file_path).name) + return Path(zip_file.filename) + except ValueError as e: + raise AutosubmitError( + code=6011, + message='An error has occurred while compressing log files for a warning email', + trace=str(e)) + + +def _attach_file(file_path: Path, message: MIMEMultipart) -> None: + """Attach a file to a message. + + The attachment file name will be the same name of the ``file_path``. + + :param file_path: The path of the file to be attached. + :type file_path: Path + :param message: The message for the file to be attached to. + :type message: MIMEMultipart + :raises AutosubmitError: The file cannot be attached. + """ + try: + compressed_file_name = file_path.name + part = MIMEApplication( + file_path.read_bytes(), + Name=compressed_file_name, + Content_disposition=f'attachment; filename="{compressed_file_name}' + ) + message.attach(part) + except (TypeError, ValueError) as e: + raise AutosubmitError( + code=6011, + message='An error has occurred while attaching log files to a warning email about remote_platforms', + trace=str(e)) + + +def _generate_message_text( + exp_id: str, + job_name: str, + prev_status: str, + status: str) -> str: + """Generate email body to notify about status change. + + :param exp_id: The experiment id. + :type exp_id: str + :param job_name: The name of the job. + :type job_name: str + :param prev_status: The previous status. + :type prev_status: str + :param status: The current status. + :type status: str + :return: The body of the email message. + """ + return dedent(f'''\ + Autosubmit notification\n + -------------------------\n\n + Experiment id: {exp_id}\n\n + Job name: {job_name}\n\n + The job status has changed from: {prev_status} to {status}\n\n\n\n\n + INFO: This message was auto generated by Autosubmit, + remember that you can disable these messages on Autosubmit config file.\n''') + + +def _generate_message_experiment_status( + exp_id: str, platform: "Platform") -> str: + """Generate email body for the experiment status notification. + + :param exp_id: The experiment id. + :type exp_id: str + :param platform: The platform.. + :type platform: Platform + :return: The email body for the experiment status notification. + :rtype: str + """ + return dedent(f'''\ + Autosubmit notification: Remote Platform issues\n + -------------------------\n + Experiment id: {exp_id} + Logs and errors: {BasicConfig.expid_aslog_dir(exp_id)} + Attached to this message you will find the related _run.log files.\n + Platform affected: {platform.name} using as host: {platform.host}\n + [WARN] Autosubmit encountered an issue with a remote platform. + It will resume itself, whenever is possible + If this issue persists, you can change the host IP or put multiple hosts in the platform.yml file\n\n\n\n\n + INFO: This message was auto generated by Autosubmit, + remember that you can disable these messages on Autosubmit config file.\n''') + class MailNotifier: def __init__(self, basic_config): self.config = basic_config - def notify_experiment_status(self, exp_id,mail_to,platform): - message_text = self._generate_message_experiment_status(exp_id, platform) - message = MIMEText(message_text) - message['From'] = email.utils.formataddr(('Autosubmit', self.config.MAIL_FROM)) - message['Subject'] = f'[Autosubmit] Warning a remote platform is malfunctioning' + def notify_experiment_status( + self, + exp_id: str, + mail_to: List[str], + platform: "Platform") -> None: + """Send email notifications. + + The latest run_log file (we consider latest the last one sorting + the listing by name). + + :param exp_id: The experiment id. + :type exp_id: str + :param mail_to: The email address. + :type mail_to: List[str] + :param platform: The platform. + :type platform: Platform + """ + message_text = _generate_message_experiment_status(exp_id, platform) + message = MIMEMultipart() + message['From'] = email.utils.formataddr( + ('Autosubmit', self.config.MAIL_FROM)) + message['Subject'] = '[Autosubmit] Warning: a remote platform is malfunctioning' message['Date'] = email.utils.formatdate(localtime=True) + message.attach(MIMEText(message_text)) + + run_log_files = [f for f in BasicConfig.expid_aslog_dir( + exp_id).glob('*_run.log') if Path(f).is_file()] + if run_log_files: + latest_run_log: Path = max(run_log_files) + temp_dir = TemporaryDirectory() + try: + compressed_run_log = _compress_file(temp_dir, latest_run_log) + _attach_file(compressed_run_log, message) + except AutosubmitError as e: + Log.printlog(code=e.code, message=e.message) + finally: + if temp_dir: + temp_dir.cleanup() + for mail in mail_to: message['To'] = email.utils.formataddr((mail, mail)) try: self._send_mail(self.config.MAIL_FROM, mail, message) except BaseException as e: - Log.printlog('An error has occurred while sending a mail for warn about remote_platform', 6011) + Log.printlog( + f'Trace:{str(e)}\nAn error has occurred while sending a mail for ' + f'warn about remote_platform', 6011) - def notify_status_change(self, exp_id, job_name, prev_status, status, mail_to): - message_text = self._generate_message_text(exp_id, job_name, prev_status, status) + def notify_status_change( + self, + exp_id: str, + job_name: str, + prev_status: str, + status: str, + mail_to: List[str]) -> None: + message_text = _generate_message_text( + exp_id, job_name, prev_status, status) message = MIMEText(message_text) - message['From'] = email.utils.formataddr(('Autosubmit', self.config.MAIL_FROM)) - message['Subject'] = f'[Autosubmit] The job {job_name} status has changed to {str(status)}' + message['From'] = email.utils.formataddr( + ('Autosubmit', self.config.MAIL_FROM)) + message['Subject'] = f'[Autosubmit] The job {job_name} status has changed to {status}' message['Date'] = email.utils.formatdate(localtime=True) for mail in mail_to: # expects a list message['To'] = email.utils.formataddr((mail, mail)) try: self._send_mail(self.config.MAIL_FROM, mail, message) except BaseException as e: - Log.printlog('Trace:{0}\nAn error has occurred while sending a mail for the job {0}'.format(e,job_name), 6011) + Log.printlog( + f'Trace:{str(e)}\nAn error has occurred while sending a mail ' + f'for the job {job_name}', 6011) def _send_mail(self, mail_from, mail_to, message): - server = smtplib.SMTP(self.config.SMTP_SERVER,timeout=60) + server = smtplib.SMTP(self.config.SMTP_SERVER, timeout=60) + server = smtplib.SMTP(self.config.SMTP_SERVER, timeout=60) server.sendmail(mail_from, mail_to, message.as_string()) server.quit() - - @staticmethod - def _generate_message_text(exp_id, job_name, prev_status, status): - return f'Autosubmit notification\n' \ - f'-------------------------\n\n' \ - f'Experiment id: {str(exp_id)} \n\n' \ - + f'Job name: {str(job_name)} \n\n' \ - f'The job status has changed from: {str(prev_status)} to {str(status)} \n\n\n\n\n' \ - f'INFO: This message was auto generated by Autosubmit, '\ - f'remember that you can disable these messages on Autosubmit config file. \n' - - @staticmethod - def _generate_message_experiment_status(exp_id, platform=""): - return f'Autosubmit notification: Remote Platform issues\n' \ - f'-------------------------\n\n' \ - f'Experiment id:{str(exp_id)} \n\n' \ - + f'Platform affected:{str(platform.name)} using as host:{str(platform.host)} \n\n' \ - f'[WARN] Autosubmit encountered an issue with an remote_platform.\n It will resume itself, whenever is possible\n If issue persist, you can change the host IP or put multiple hosts in the platform.yml' + '\n\n\n\n\n' \ - f'INFO: This message was auto generated by Autosubmit, '\ - f'remember that you can disable these messages on Autosubmit config file. \n' diff --git a/test/unit/conftest.py b/test/unit/conftest.py index f2ab6ab27..f01a8cb84 100644 --- a/test/unit/conftest.py +++ b/test/unit/conftest.py @@ -8,6 +8,7 @@ from shutil import rmtree from tempfile import TemporaryDirectory from typing import Any, Dict, Callable, List, Protocol, Optional +import os from autosubmit.autosubmit import Autosubmit from autosubmit.platforms.slurmplatform import SlurmPlatform, ParamikoPlatform @@ -27,7 +28,6 @@ class AutosubmitExperiment: status_dir: Path platform: ParamikoPlatform - @pytest.fixture(scope='function') def autosubmit_exp(autosubmit: Autosubmit, request: pytest.FixtureRequest) -> Callable: """Create an instance of ``Autosubmit`` with an experiment.""" @@ -36,17 +36,21 @@ def autosubmit_exp(autosubmit: Autosubmit, request: pytest.FixtureRequest) -> Ca tmp_dir = TemporaryDirectory() tmp_path = Path(tmp_dir.name) + def _create_autosubmit_exp(expid: str): - # directories used when searching for logs to cat root_dir = tmp_path BasicConfig.LOCAL_ROOT_DIR = str(root_dir) - exp_path = root_dir / expid - exp_tmp_dir = exp_path / BasicConfig.LOCAL_TMP_DIR - aslogs_dir = exp_tmp_dir / BasicConfig.LOCAL_ASLOG_DIR - status_dir = exp_path / 'status' - aslogs_dir.mkdir(parents=True, exist_ok=True) - status_dir.mkdir(parents=True, exist_ok=True) - + exp_path = BasicConfig.expid_dir(expid) + + # directories used when searching for logs to cat + exp_tmp_dir = BasicConfig.expid_tmp_dir(expid) + aslogs_dir = BasicConfig.expid_aslog_dir(expid) + status_dir =exp_path / 'status' + if not os.path.exists(aslogs_dir): + os.makedirs(aslogs_dir) + if not os.path.exists(status_dir): + os.makedirs(status_dir) + platform_config = { "LOCAL_ROOT_DIR": BasicConfig.LOCAL_ROOT_DIR, "LOCAL_TMP_DIR": str(exp_tmp_dir), @@ -59,7 +63,7 @@ def _create_autosubmit_exp(expid: str): 'QUEUING': [], 'FAILED': [] } - submit_platform_script = aslogs_dir / 'submit_local.sh' + submit_platform_script = aslogs_dir.joinpath('submit_local.sh') submit_platform_script.touch(exist_ok=True) return AutosubmitExperiment( @@ -94,7 +98,7 @@ def autosubmit() -> Autosubmit: @pytest.fixture(scope='function') def create_as_conf() -> Callable: # May need to be changed to use the autosubmit_config one def _create_as_conf(autosubmit_exp: AutosubmitExperiment, yaml_files: List[Path], experiment_data: Dict[str, Any]): - conf_dir = autosubmit_exp.exp_path / 'conf' + conf_dir = autosubmit_exp.exp_path.joinpath('conf') conf_dir.mkdir(parents=False, exist_ok=False) basic_config = BasicConfig parser_factory = YAMLParserFactory() @@ -117,7 +121,6 @@ def _create_as_conf(autosubmit_exp: AutosubmitExperiment, yaml_files: List[Path] return _create_as_conf - class AutosubmitConfigFactory(Protocol): # Copied from the autosubmit config parser, that I believe is a revised one from the create_as_conf def __call__(self, expid: str, experiment_data: Optional[Dict], *args: Any, **kwargs: Any) -> AutosubmitConfig: ... diff --git a/test/unit/test_catlog.py b/test/unit/test_catlog.py index f7b2be15e..f9c68a6a6 100644 --- a/test/unit/test_catlog.py +++ b/test/unit/test_catlog.py @@ -6,25 +6,33 @@ from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch +import pytest from autosubmit.autosubmit import Autosubmit, AutosubmitCritical from autosubmitconfigparser.config.basicconfig import BasicConfig - class TestJob(TestCase): def setUp(self): - self.autosubmit = Autosubmit() - # directories used when searching for logs to cat self.original_root_dir = BasicConfig.LOCAL_ROOT_DIR self.root_dir = TemporaryDirectory() BasicConfig.LOCAL_ROOT_DIR = self.root_dir.name - self.exp_path = Path(self.root_dir.name, 'a000') - self.tmp_dir = self.exp_path / BasicConfig.LOCAL_TMP_DIR - self.aslogs_dir = self.tmp_dir / BasicConfig.LOCAL_ASLOG_DIR - self.status_path = self.exp_path / 'status' - self.aslogs_dir.mkdir(parents=True) - self.status_path.mkdir() + + self.exp_path = BasicConfig.expid_dir('a000') + self.tmp_dir = BasicConfig.expid_tmp_dir('a000') + self.log_dir = BasicConfig.expid_log_dir('a000') + self.aslogs_dir = BasicConfig.expid_aslog_dir('a000') + + self.autosubmit = Autosubmit() + # directories used when searching for logs to cat + + self.status_path = self.exp_path / 'status' + if not self.aslogs_dir.exists(): + self.aslogs_dir.mkdir(parents = True, exist_ok = True) + if not self.status_path.exists(): + self.status_path.mkdir(parents = True, exist_ok = True) + if not self.log_dir.exists(): + self.log_dir.mkdir(parents=True, exist_ok=True) def tearDown(self) -> None: BasicConfig.LOCAL_ROOT_DIR = self.original_root_dir @@ -55,15 +63,17 @@ def test_is_workflow_not_found(self, Log): assert Log.info.call_args[0][0] == 'No logs found.' def test_is_workflow_log_is_dir(self): - log_file_actually_dir = Path(self.aslogs_dir, 'log_run.log') - log_file_actually_dir.mkdir() + log_file_actually_dir = self.aslogs_dir / 'log_run.log' + log_file_actually_dir.mkdir(parents=True) def _fn(): self.autosubmit.cat_log('a000', 'o', 'c') self.assertRaises(AutosubmitCritical, _fn) @patch('subprocess.Popen') def test_is_workflow_out_cat(self, popen): - log_file = Path(self.aslogs_dir, 'log_run.log') + log_file = self.aslogs_dir / 'log_run.log' + if log_file.is_dir(): # dir is created in previous test + log_file.rmdir() with open(log_file, 'w') as f: f.write('as test') f.flush() @@ -75,7 +85,7 @@ def test_is_workflow_out_cat(self, popen): @patch('subprocess.Popen') def test_is_workflow_status_tail(self, popen): - log_file = Path(self.status_path, 'a000_anything.txt') + log_file = self.status_path / 'a000_anything.txt' with open(log_file, 'w') as f: f.write('as test') f.flush() @@ -93,9 +103,9 @@ def test_is_jobs_not_found(self, Log): self.autosubmit.cat_log('a000_INI', file=file, mode='c') assert Log.info.called assert Log.info.call_args[0][0] == 'No logs found.' - + def test_is_jobs_log_is_dir(self): - log_file_actually_dir = Path(self.tmp_dir, 'LOG_a000/a000_INI.20000101.out') + log_file_actually_dir = self.log_dir / 'a000_INI.20000101.out' log_file_actually_dir.mkdir(parents=True) def _fn(): self.autosubmit.cat_log('a000_INI', 'o', 'c') @@ -103,9 +113,9 @@ def _fn(): @patch('subprocess.Popen') def test_is_jobs_out_tail(self, popen): - log_dir = self.tmp_dir / 'LOG_a000' - log_dir.mkdir() - log_file = log_dir / 'a000_INI.20200101.out' + log_file = self.log_dir / 'a000_INI.20200101.out' + if log_file.is_dir(): # dir is created in previous test + log_file.rmdir() with open(log_file, 'w') as f: f.write('as test') f.flush() diff --git a/test/unit/test_describe.py b/test/unit/test_describe.py index 97f6946f2..f63077d47 100644 --- a/test/unit/test_describe.py +++ b/test/unit/test_describe.py @@ -33,14 +33,18 @@ def test_describe( if expid not in _EXPIDS: continue exp = autosubmit_exp(expid) - + basic_config = mocker.MagicMock() + # TODO: Whenever autosubmitconfigparser gets released with BasicConfig.expid_dir() and similar functions, the following line and a lot of mocks need to be removed. This line is especially delicate because it "overmocks" the basic_config mock, thus making the last assertion of this file "assert f'Location: {exp.exp_path}' in log_result_output" a dummy assertion. The rest of the test is still useful. + basic_config.expid_dir.side_effect = lambda expid: exp.exp_path config_values = { 'LOCAL_ROOT_DIR': str(exp.exp_path.parent), 'LOCAL_ASLOG_DIR': str(exp.aslogs_dir) } + for key, value in config_values.items(): basic_config.__setattr__(key, value) + basic_config.get.side_effect = lambda key, default='': config_values.get(key, default) for basic_config_location in [ 'autosubmit.autosubmit.BasicConfig', @@ -48,14 +52,16 @@ def test_describe( ]: # TODO: Better design how ``BasicConfig`` is used/injected/IOC/etc.. mocker.patch(basic_config_location, basic_config) + mocked_get_submitter = mocker.patch.object(Autosubmit, '_get_submitter') submitter = mocker.MagicMock() + mocked_get_submitter.return_value = submitter submitter.platforms = [1, 2] get_experiment_descrip = mocker.patch('autosubmit.autosubmit.get_experiment_descrip') get_experiment_descrip.return_value = [[f'{expid} description']] - + create_as_conf( autosubmit_exp=exp, yaml_files=[ @@ -69,6 +75,7 @@ def test_describe( } ) + Autosubmit.describe( input_experiment_list=input_experiment_list, get_from_user=get_from_user @@ -84,4 +91,4 @@ def test_describe( ] root_dir = exp.exp_path.parent for expid in expids: - assert f'Location: {str(root_dir / expid)}' in log_result_output + assert f'Location: {exp.exp_path}' in log_result_output diff --git a/test/unit/test_mail.py b/test/unit/test_mail.py new file mode 100644 index 000000000..5e60114f2 --- /dev/null +++ b/test/unit/test_mail.py @@ -0,0 +1,202 @@ +import email.utils +from email.mime.text import MIMEText +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Optional + +import pytest + +from autosubmit.job.job_common import Status +from autosubmit.notifications.mail_notifier import MailNotifier +from autosubmitconfigparser.config.basicconfig import BasicConfig +from log.log import Log + + +# -- fixtures + + +@pytest.fixture +def mock_basic_config(mocker): + mock_config = mocker.Mock() + mock_config.MAIL_FROM = "test@example.com" + mock_config.SMTP_SERVER = "smtp.example.com" + return mock_config + + +@pytest.fixture +def mock_smtp(mocker): + return mocker.patch( + 'autosubmit.notifications.mail_notifier.smtplib.SMTP', + autospec=True + ) + + +@pytest.fixture +def mock_platform(mocker): + mock_platform = mocker.Mock() + mock_platform.name = "Test Platform" + mock_platform.host = "test.host.com" + return mock_platform + + +@pytest.fixture +def mail_notifier(mock_basic_config): + return MailNotifier(mock_basic_config) + +# --- tests + + +@pytest.mark.parametrize( + "number_of_files, sendmail_error, compress_error, attach_error", + [ + # No errors, no log files compressed. + (0, None, None, None), + + # No errors, one log file compressed. + (1, None, None, None), + + # No errors, three log files, one file compressed. + (3, None, None, None), + + # STMP error. + (0, Exception("SMTP server error"), None, None), + + # ZIP error. + (1, None, ValueError('Zip error'), None), + + # Attach error. + (1, None, None, ValueError('Attach error')) + ], + ids=[ + "No files. No errors", + "One file. Attach a single file. No errors", + "Three files. Attach a single file. No errors", + "SMTP server error", + "Zip error", + "Attach error" + ] +) +def test_compress_file( + mock_basic_config, + mock_platform, + mock_smtp, + mocker, + mail_notifier, + number_of_files: int, + sendmail_error: Optional[Exception], + compress_error: Optional[Exception], + attach_error: Optional[Exception] +): + with TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + if sendmail_error: + mock_smtp.side_effect = sendmail_error + + if compress_error: + mock_compress = mocker.patch( + 'autosubmit.notifications.mail_notifier.zipfile.ZipFile') + mock_compress.side_effect = compress_error + + if attach_error: + mock_message = mocker.patch( + 'autosubmit.notifications.mail_notifier.MIMEApplication') + mock_message.side_effect = attach_error + + mock_printlog = mocker.patch.object(Log, 'printlog') + + for _ in range(number_of_files): + test_file = temp_path / "test_file_run.log" + with open(test_file, 'w') as f: + f.write("file data 1") + f.flush() + + mocker.patch.object( + BasicConfig, + 'expid_aslog_dir', + return_value=Path(temp_dir)) + + mail_notifier.notify_experiment_status( + 'a000', ['recipient@example.com'], mock_platform) # type: ignore + + if sendmail_error: + mock_printlog.assert_called_once() + log_calls = [call[0][0] for call in mock_printlog.call_args_list] + assert 'Traceback' not in log_calls + elif compress_error: + mock_printlog.assert_called_once() + exception_raised = mock_printlog.call_args_list[0][1] + assert 'error has occurred while compressing' in exception_raised['message'] + assert 6011 == exception_raised['code'] + elif attach_error: + mock_printlog.assert_called_once() + exception_raised = mock_printlog.call_args_list[0][1] + assert 'error has occurred while attaching' in exception_raised['message'] + assert 6011 == exception_raised['code'] + else: + mock_printlog.assert_not_called() + + # First we call sendmail, then we call quit. Thus, the [0]. + # The first arguments are he sender and recipient. Third + # (or [2]) is the MIME message. + message_arg = mock_smtp.method_calls[0].args[2] + + if number_of_files > 0: + assert '.zip' in message_arg + else: + assert '.zip' not in message_arg + + +@pytest.mark.parametrize( + "sendmail_error, expected_log_message", + [ + # Normal case: No errors, should not log anything + # No logs are expected, everything works fine + (None, None), + + # Log connection error: Simulate an error while sending email + (Exception("SMTP server error"), + 'Trace:SMTP server error\nAn error has occurred while sending a mail for the job Job1') + ], + ids=[ + "Normal case: No errors", + "Log connection error (SMTP server error)" + ] +) +def test_notify_status_change( + mock_basic_config, + mock_smtp, + mocker, + mail_notifier, + sendmail_error: Optional[Exception], + expected_log_message): + exp_id = 'a123' + job_name = 'Job1' + prev_status = Status.VALUE_TO_KEY[Status.RUNNING] + status = Status.VALUE_TO_KEY[Status.COMPLETED] + mail_to = ['recipient@example.com'] + + mock_smtp = mocker.patch( + 'autosubmit.notifications.mail_notifier.smtplib.SMTP') + if sendmail_error: + mock_smtp.side_effect = sendmail_error + mock_printlog = mocker.patch.object(Log, 'printlog') + + mail_notifier.notify_status_change( + exp_id, job_name, prev_status, status, mail_to) + + message_text = "Generated message" + message = MIMEText(message_text) + message['From'] = email.utils.formataddr( + ('Autosubmit', mail_notifier.config.MAIL_FROM)) + message['Subject'] = f'[Autosubmit] The job {job_name} status has changed to {str(status)}' + message['Date'] = email.utils.formatdate(localtime=True) + + if expected_log_message: + mock_printlog.assert_called_once_with( + expected_log_message, 6011) + log_calls = [call[0][0] + for call in mock_printlog.call_args_list] + assert 'Traceback' not in log_calls + else: + mock_printlog.assert_not_called() diff --git a/test/unit/test_scheduler_general.py b/test/unit/test_scheduler_general.py index 4dded6f9a..dc152a56e 100644 --- a/test/unit/test_scheduler_general.py +++ b/test/unit/test_scheduler_general.py @@ -201,6 +201,7 @@ def generate_cmds(prepare_scheduler): ('slurm', 'horizontal_vertical'), ('slurm', 'vertical_horizontal') ]) + def test_scheduler_job_types(scheduler, job_type, generate_cmds): # Test code that uses scheduler and job_typedef test_default_parameters(scheduler: str, job_type: str, generate_cmds): """