From ae941d11c9783b211fbf3bb0b5ad535da68a83d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Zachar?= <1548503+lukaszachy@users.noreply.github.com> Date: Wed, 29 May 2024 12:14:20 +0200 Subject: [PATCH] Allow multiplication in duration input value (#2845) To make it easier for adjust the multiplication happens last: No need to pay attention how adjust creates the value. --- docs/releases.rst | 6 ++++++ spec/tests/duration.fmf | 31 ++++++++++++++++++++++------ tests/unit/test_utils.py | 25 +++++++++++++++++++---- tmt/schemas/common.yaml | 2 +- tmt/utils.py | 44 +++++++++++++++++++++++++++++++++++----- 5 files changed, 92 insertions(+), 16 deletions(-) diff --git a/docs/releases.rst b/docs/releases.rst index fe5aa42f83..00e4141435 100644 --- a/docs/releases.rst +++ b/docs/releases.rst @@ -4,6 +4,12 @@ Releases ====================== +tmt-1.34 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :ref:`/spec/tests/duration` now supports multiplication. + + tmt-1.33 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/spec/tests/duration.fmf b/spec/tests/duration.fmf index 471b043623..9ec33e33de 100644 --- a/spec/tests/duration.fmf +++ b/spec/tests/duration.fmf @@ -4,12 +4,18 @@ story: As a test harness I need to know after how long time I should kill test if it is still running to prevent resource wasting. -description: - In order to prevent stuck tests consuming resources we define a - maximum time for test execution. If the limit is exceeded the - running test is killed by the test harness. Use the same - format as the ``sleep`` command. Must be a ``string``. The - default value is ``5m``. +description: | + In order to prevent stuck tests from consuming resources, we define a + maximum time for test execution. If the limit is exceeded, the + running test is killed by the test harness. Value extends the + format of the ``sleep`` command by allowing multiplication (``*[float]``). + First, all time values are summed together, and only then are they multiplied. + The final value is then rounded up to the whole number. + + Must be a ``string``. The default value is ``5m``. + + .. versionadded:: 1.34 + Multiplication example: - | @@ -28,6 +34,10 @@ example: # Combination & repetition of time suffixes (total 4h 2m 3s) duration: 1h 3h 2m 3 + - | + # Multiplication is evaluated last (total 24s: 2s * 3 * 4) + duration: *3 2s *4 + - | # Use context adjust to extend duration for given arch duration: 5m @@ -35,6 +45,15 @@ example: duration+: 15m when: arch == aarch64 + - | + # Use context adjust to scale duration for given arch + duration: 5m + adjust: + duration+: *2 + when: arch == aarch64 + duration+: *0.9 + when: arch == s390x + link: - implemented-by: /tmt/base.py diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 6da9baf429..5111f80d11 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -317,7 +317,7 @@ def create_workdir(): def test_duration_to_seconds(): - """ Check conversion from sleep time format to seconds """ + """ Check conversion from extended sleep time format to seconds """ assert duration_to_seconds(5) == 5 assert duration_to_seconds('5') == 5 assert duration_to_seconds('5s') == 5 @@ -332,10 +332,27 @@ def test_duration_to_seconds(): # Divergence from 'sleep' as that expects space separated arguments assert duration_to_seconds('1s2s') == 3 assert duration_to_seconds('1 m2 m') == 180 + # Allow multiply but sum first, then multiply: (60+4) * (2+3) + assert duration_to_seconds('*2 1m *3 4') == 384 + assert duration_to_seconds('*2 *3 1m4') == 384 + # Round up + assert duration_to_seconds('1s *3.3') == 4 + + +@pytest.mark.parametrize("duration", [ + '*10m', + '**10', + '10w', + '1sm', + '*10m 3', + '3 *10m', + '1 1ss 5', + 'bad', + ]) +def test_duration_to_seconds_invalid(duration): + """ Catch invalid input duration string """ with pytest.raises(tmt.utils.SpecificationError): - duration_to_seconds('bad') - with pytest.raises(tmt.utils.SpecificationError): - duration_to_seconds('1sm') + duration_to_seconds(duration) class TestStructuredField(unittest.TestCase): diff --git a/tmt/schemas/common.yaml b/tmt/schemas/common.yaml index cebd801097..c2363ea0c5 100644 --- a/tmt/schemas/common.yaml +++ b/tmt/schemas/common.yaml @@ -162,7 +162,7 @@ definitions: # https://tmt.readthedocs.io/en/stable/spec/plans.html#script duration: type: string - pattern: "^[0-9]+[smhd]?$" + pattern: "^([0-9*. ]+[smhd]? *)+$" # https://tmt.readthedocs.io/en/stable/spec/tests.html#environment # https://tmt.readthedocs.io/en/stable/spec/plans.html#environment diff --git a/tmt/utils.py b/tmt/utils.py index 757b6192bc..17bc4a5382 100644 --- a/tmt/utils.py +++ b/tmt/utils.py @@ -27,6 +27,7 @@ from collections import Counter, OrderedDict from collections.abc import Iterable, Iterator, Sequence from contextlib import suppress +from math import ceil from re import Match, Pattern from threading import Thread from types import ModuleType @@ -3346,19 +3347,52 @@ def shell_variables( def duration_to_seconds(duration: str) -> int: - """ Convert sleep time format into seconds """ + """ Convert extended sleep time format into seconds """ units = { 's': 1, 'm': 60, 'h': 60 * 60, 'd': 60 * 60 * 24, } - if re.match(r'^(\d+ *?[smhd]? *)+$', str(duration)) is None: + # Couldn't create working validation regexp to accept '2 1m 4' + # thus fixing the string so \b can be used as word boundary + fixed_duration = re.sub(r'([smhd])(\d)', r'\1 \2', str(duration)) + fixed_duration = re.sub(r'\s\s+', ' ', fixed_duration) + raw_groups = r''' + ( # Group all possibilities + ( # Multiply by float number + (?P\*) # "*" character + \s* + (?P\d+(\.\d+)?(?![smhd])) # float part + \s* + ) + | # Or + ( # Time pattern + (?P\d+) # digits + \s* + (?P[smhd])? # suffix + \s* + ) + )\b # Needs to end with word boundary to avoid splitting + ''' + re_validate = re.compile(r''' + ^( # Match beginning, opening of input group + ''' + raw_groups + r''' + \s* # Optional spaces in the case of multiple inputs + )+$ # Inputs can repeat + ''', re.VERBOSE) + re_split = re.compile(raw_groups, re.VERBOSE) + if re_validate.match(fixed_duration) is None: raise SpecificationError(f"Invalid duration '{duration}'.") total_time = 0 - for number, suffix in re.findall(r'(\d+) *([smhd]?)', str(duration)): - total_time += int(number) * units.get(suffix, 1) - return total_time + multiply_by = 1.0 + for match in re_split.finditer(fixed_duration): + if match['asterisk'] == '*': + multiply_by *= float(match['float']) + else: + total_time += int(match['digit']) * units.get(match['suffix'], 1) + # Multiply in the end and round up + return ceil(total_time * multiply_by) @overload