diff --git a/docs/advanced.rst b/docs/advanced.rst index 668693dc3e..90f28c194e 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -227,7 +227,7 @@ The important bit here is the following line that sets the time limit for the te :lines: 17 :dedent: 8 -The :attr:`time_limit ` attribute is a three-tuple in the form ``(HOURS, MINUTES, SECONDS)``. +The :attr:`time_limit ` attribute is a string in the form ``dhms)``. Time limits are implemented for all the scheduler backends. The sanity condition for this test verifies that associated job has been canceled due to the time limit (note that this message is SLURM-specific). diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 5eae7c6641..19bc4c7005 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -527,7 +527,7 @@ For schedulers that do not provide the same functionality, some of the variables ================================================ =========================================== :class:`RegressionTest` attribute Corresponding SLURM option ================================================ =========================================== - ``time_limit = (0, 10, 30)`` ``--time=00:10:30`` + ``time_limit = '10m30s`` ``--time=00:10:30`` ``use_multithreading = True`` ``--hint=multithread`` ``use_multithreading = False`` ``--hint=nomultithread`` ``exclusive_access = True`` ``--exclusive`` diff --git a/reframe/core/fields.py b/reframe/core/fields.py index 3cf1694084..cf37b27e6a 100644 --- a/reframe/core/fields.py +++ b/reframe/core/fields.py @@ -8,7 +8,9 @@ # import copy +import datetime import os +import re import reframe.utility.typecheck as types from reframe.core.exceptions import user_deprecation_warning @@ -105,23 +107,32 @@ def __set__(self, obj, value): class TimerField(TypedField): - '''Stores a timer in the form of a tuple ``(hh, mm, ss)``''' + '''Stores a timer in the form of a :class:`datetime.timedelta` object''' def __init__(self, fieldname, *other_types): - super().__init__(fieldname, types.Tuple[int, int, int], *other_types) + super().__init__(fieldname, datetime.timedelta, str, + types.Tuple[int, int, int], *other_types) def __set__(self, obj, value): self._check_type(value) - if value is not None: - # Check also the values for minutes and seconds + if isinstance(value, tuple): + user_deprecation_warning( + "setting a timer field from a tuple is deprecated: " + "please use a string 'dhms'") h, m, s = value - if h < 0 or m < 0 or s < 0: - raise ValueError('timer field must have ' - 'non-negative values') - - if m > 59 or s > 59: - raise ValueError('minutes and seconds in a timer ' - 'field must not exceed 59') + value = datetime.timedelta(hours=h, minutes=m, seconds=s) + + if isinstance(value, str): + time_match = re.match(r'^((?P\d+)d)?' + r'((?P\d+)h)?' + r'((?P\d+)m)?' + r'((?P\d+)s)?$', + value) + if not time_match: + raise ValueError('invalid format for timer field') + + value = datetime.timedelta( + **{k: int(v) for k, v in time_match.groupdict().items() if v}) # Call Field's __set__() method, type checking is already performed Field.__set__(self, obj, value) diff --git a/reframe/core/launchers/mpi.py b/reframe/core/launchers/mpi.py index da7bda63a7..55f5facb28 100644 --- a/reframe/core/launchers/mpi.py +++ b/reframe/core/launchers/mpi.py @@ -5,6 +5,7 @@ from reframe.core.launchers import JobLauncher from reframe.core.launchers.registry import register_launcher +from reframe.utility import seconds_to_hms @register_launcher('srun') @@ -57,7 +58,8 @@ def command(self, job): ret += ['--job-name=%s' % job.name] if job.time_limit: - ret += ['--time=%d:%d:%d' % job.time_limit] + h, m, s = seconds_to_hms(job.time_limit.total_seconds()) + ret += ['--time=%d:%d:%d' % (h, m, s)] if job.stdout: ret += ['--output=%s' % job.stdout] diff --git a/reframe/core/pipeline.py b/reframe/core/pipeline.py index 5f5a6ed892..afceb0ac60 100644 --- a/reframe/core/pipeline.py +++ b/reframe/core/pipeline.py @@ -545,20 +545,28 @@ class RegressionTest(metaclass=RegressionTestMeta): #: Time limit for this test. #: - #: Time limit is specified as a three-tuple in the form ``(hh, mm, ss)``, - #: with ``hh >= 0``, ``0 <= mm <= 59`` and ``0 <= ss <= 59``. + #: Time limit is specified as a string in the form + #: ``dhms``. #: If set to :class:`None`, no time limit will be set. #: The default time limit of the system partition's scheduler will be used. #: + #: The value is internaly kept as a :class:`datetime.timedelta` object. + #: For example '2h30m' is represented as + #: `datetime.timedelta(hours=2, minutes=30)` #: - #: :type: :class:`tuple[int]` - #: :default: ``(0, 10, 0)`` + #: :type: :class:`str` or :class:`datetime.timedelta` + #: :default: ``'10m'`` #: #: .. note:: #: .. versionchanged:: 2.15 #: #: This attribute may be set to :class:`None`. #: + #: .. warning:: + #: .. versionchanged:: 3.0 + #: + #: The old syntax using a ``(h, m, s)`` tuple is deprecated. + #: time_limit = fields.TimerField('time_limit', type(None)) #: Extra resources for this test. @@ -715,7 +723,7 @@ def _rfm_init(self, name=None, prefix=None): self.variables = {} # Time limit for the check - self.time_limit = (0, 10, 0) + self.time_limit = '10m' # Runtime information of the test self._current_partition = None diff --git a/reframe/core/schedulers/local.py b/reframe/core/schedulers/local.py index 7f990424d3..6d4bbf7730 100644 --- a/reframe/core/schedulers/local.py +++ b/reframe/core/schedulers/local.py @@ -9,7 +9,7 @@ import stat import subprocess import time -from datetime import datetime +from datetime import datetime, timedelta import reframe.core.schedulers as sched import reframe.utility.os_ext as os_ext @@ -125,7 +125,7 @@ def cancel(self, job): # Set the time limit to the grace period and let wait() do the final # killing - job.time_limit = (0, 0, self._cancel_grace_period) + job.time_limit = timedelta(seconds=self._cancel_grace_period) self.wait(job) def wait(self, job): @@ -143,8 +143,7 @@ def wait(self, job): # Convert job's time_limit to seconds if job.time_limit is not None: - h, m, s = job.time_limit - timeout = h * 3600 + m * 60 + s + timeout = job.time_limit.total_seconds() else: timeout = 0 diff --git a/reframe/core/schedulers/pbs.py b/reframe/core/schedulers/pbs.py index 8ff4de693c..f65dc36c0b 100644 --- a/reframe/core/schedulers/pbs.py +++ b/reframe/core/schedulers/pbs.py @@ -22,6 +22,7 @@ from reframe.core.exceptions import SpawnedProcessError, JobError from reframe.core.logging import getlogger from reframe.core.schedulers.registry import register_scheduler +from reframe.utility import seconds_to_hms # Time to wait after a job is finished for its standard output/error to be @@ -79,8 +80,9 @@ def emit_preamble(self, job): ] if job.time_limit is not None: + h, m, s = seconds_to_hms(job.time_limit.total_seconds()) preamble.append( - self._format_option('-l walltime=%d:%d:%d' % job.time_limit)) + self._format_option('-l walltime=%d:%d:%d' % (h, m, s))) if job.sched_partition: preamble.append( diff --git a/reframe/core/schedulers/slurm.py b/reframe/core/schedulers/slurm.py index 1bc4848271..7dec0c4666 100644 --- a/reframe/core/schedulers/slurm.py +++ b/reframe/core/schedulers/slurm.py @@ -20,6 +20,7 @@ JobBlockedError, JobError) from reframe.core.logging import getlogger from reframe.core.schedulers.registry import register_scheduler +from reframe.utility import seconds_to_hms def slurm_state_completed(state): @@ -155,8 +156,9 @@ def emit_preamble(self, job): self._format_option(job.stderr, errfile_fmt)] if job.time_limit is not None: + h, m, s = seconds_to_hms(job.time_limit.total_seconds()) preamble.append( - self._format_option('%d:%d:%d' % job.time_limit, '--time={0}') + self._format_option('%d:%d:%d' % (h, m, s), '--time={0}') ) if job.sched_exclusive_access: diff --git a/reframe/utility/__init__.py b/reframe/utility/__init__.py index 0f9b59727e..070a4eed95 100644 --- a/reframe/utility/__init__.py +++ b/reframe/utility/__init__.py @@ -16,6 +16,14 @@ from collections import UserDict +def seconds_to_hms(seconds): + '''Convert time in seconds to a tuple of ``(hour, minutes, seconds)``.''' + + m, s = divmod(seconds, 60) + h, m = divmod(m, 60) + return h, m, s + + def _get_module_name(filename): barename, _ = os.path.splitext(filename) if os.path.basename(filename) == '__init__.py': diff --git a/tutorial/advanced/advanced_example5.py b/tutorial/advanced/advanced_example5.py index f4ebc27cd6..484ab6cb08 100644 --- a/tutorial/advanced/advanced_example5.py +++ b/tutorial/advanced/advanced_example5.py @@ -14,7 +14,7 @@ def __init__(self): 'of a user-defined time limit') self.valid_systems = ['daint:gpu', 'daint:mc'] self.valid_prog_environs = ['*'] - self.time_limit = (0, 1, 0) + self.time_limit = '1m' self.executable = 'sleep' self.executable_opts = ['100'] self.sanity_patterns = sn.assert_found( diff --git a/unittests/test_fields.py b/unittests/test_fields.py index 3a696f4ea1..6a980e7fb2 100644 --- a/unittests/test_fields.py +++ b/unittests/test_fields.py @@ -3,10 +3,13 @@ # # SPDX-License-Identifier: BSD-3-Clause +import datetime import os +import pytest import unittest import reframe.core.fields as fields +from reframe.core.exceptions import ReframeDeprecationWarning from reframe.utility import ScopedDict @@ -87,31 +90,48 @@ class FieldTester: 'field_maybe_none', type(None)) tester = FieldTester() - tester.field = (65, 22, 47) + tester.field = '1d65h22m87s' tester.field_maybe_none = None - self.assertIsInstance(FieldTester.field, fields.TimerField) - self.assertEqual((65, 22, 47), tester.field) - self.assertRaises(TypeError, exec, 'tester.field = (2,)', - globals(), locals()) - self.assertRaises(TypeError, exec, 'tester.field = (2, 2)', - globals(), locals()) - self.assertRaises(TypeError, exec, 'tester.field = (2, 2, 3.4)', - globals(), locals()) - self.assertRaises(TypeError, exec, "tester.field = ('foo', 2, 3)", - globals(), locals()) - self.assertRaises(TypeError, exec, 'tester.field = 3', - globals(), locals()) - self.assertRaises(ValueError, exec, 'tester.field = (-2, 3, 5)', - globals(), locals()) - self.assertRaises(ValueError, exec, 'tester.field = (100, -3, 4)', - globals(), locals()) - self.assertRaises(ValueError, exec, 'tester.field = (100, 3, -4)', - globals(), locals()) - self.assertRaises(ValueError, exec, 'tester.field = (100, 65, 4)', - globals(), locals()) - self.assertRaises(ValueError, exec, 'tester.field = (100, 3, 65)', - globals(), locals()) + assert isinstance(FieldTester.field, fields.TimerField) + assert (datetime.timedelta(days=1, hours=65, + minutes=22, seconds=87) == tester.field) + tester.field = datetime.timedelta(days=1, hours=65, + minutes=22, seconds=87) + assert (datetime.timedelta(days=1, hours=65, + minutes=22, seconds=87) == tester.field) + tester.field = '' + assert (datetime.timedelta(days=0, hours=0, + minutes=0, seconds=0) == tester.field) + with pytest.warns(ReframeDeprecationWarning): + tester.field = (65, 22, 87) + + with pytest.raises(ValueError): + exec('tester.field = "1e"', globals(), locals()) + + with pytest.raises(ValueError): + exec('tester.field = "-10m5s"', globals(), locals()) + + with pytest.raises(ValueError): + exec('tester.field = "10m-5s"', globals(), locals()) + + with pytest.raises(ValueError): + exec('tester.field = "m10s"', globals(), locals()) + + with pytest.raises(ValueError): + exec('tester.field = "10m10"', globals(), locals()) + + with pytest.raises(ValueError): + exec('tester.field = "10m10m1s"', globals(), locals()) + + with pytest.raises(ValueError): + exec('tester.field = "10m5s3m"', globals(), locals()) + + with pytest.raises(ValueError): + exec('tester.field = "10ms"', globals(), locals()) + + with pytest.raises(ValueError): + exec('tester.field = "10"', globals(), locals()) def test_proxy_field(self): class Target: @@ -136,8 +156,6 @@ class Proxy: self.assertEqual(4, t.b) def test_deprecated_field(self): - from reframe.core.exceptions import ReframeDeprecationWarning - class FieldTester: value = fields.DeprecatedField(fields.TypedField('value', int), 'value field is deprecated') diff --git a/unittests/test_launchers.py b/unittests/test_launchers.py index 80d1745c3b..689add6bd3 100644 --- a/unittests/test_launchers.py +++ b/unittests/test_launchers.py @@ -61,7 +61,7 @@ def setUp(self): self.job.num_tasks_per_socket = 1 self.job.num_cpus_per_task = 2 self.job.use_smt = True - self.job.time_limit = (0, 10, 0) + self.job.time_limit = '10m' self.job.options += ['--gres=gpu:4', '#DW jobdw anything'] self.job.launcher.options = ['--foo'] self.minimal_job = Job.create(FakeJobScheduler(), diff --git a/unittests/test_schedulers.py b/unittests/test_schedulers.py index 6691bd95dd..8543c5a44a 100644 --- a/unittests/test_schedulers.py +++ b/unittests/test_schedulers.py @@ -100,7 +100,7 @@ def assertScriptSanity(self, script_file): def setup_job(self): # Mock up a job submission - self.testjob.time_limit = (0, 5, 0) + self.testjob.time_limit = '5m' self.testjob.num_tasks = 16 self.testjob.num_tasks_per_node = 2 self.testjob.num_tasks_per_core = 1 @@ -134,7 +134,7 @@ def test_submit(self): def test_submit_timelimit(self, check_elapsed_time=True): self.setup_user() self.parallel_cmd = 'sleep 10' - self.testjob.time_limit = (0, 0, 2) + self.testjob.time_limit = '2s' self.prepare() t_job = datetime.now() self.testjob.submit() @@ -243,7 +243,7 @@ def test_cancel_with_grace(self): self.parallel_cmd = 'sleep 5 &' self.pre_run = ['trap -- "" TERM'] self.post_run = ['echo $!', 'wait'] - self.testjob.time_limit = (0, 1, 0) + self.testjob.time_limit = '1m' self.testjob.scheduler._cancel_grace_period = 2 self.prepare()