From 03e7a79b15a75a2a57bacba6680dc9bc05630163 Mon Sep 17 00:00:00 2001 From: Vasileios Karakasis Date: Wed, 10 Feb 2021 01:09:51 +0100 Subject: [PATCH 1/4] Comply with the semantic versioning spec Expand deprecation message function to issue deprecation warnings in future releases. --- reframe/__init__.py | 3 +- reframe/core/decorators.py | 3 +- reframe/core/warnings.py | 19 +++++- reframe/frontend/runreport.py | 10 +-- reframe/utility/osext.py | 14 ++-- reframe/utility/versioning.py | 119 ++++++++++++---------------------- requirements.txt | 1 + unittests/test_versioning.py | 91 ++++++++++---------------- unittests/test_warnings.py | 7 ++ 9 files changed, 117 insertions(+), 150 deletions(-) diff --git a/reframe/__init__.py b/reframe/__init__.py index 65aae2c9b9..6c19dfcdf1 100644 --- a/reframe/__init__.py +++ b/reframe/__init__.py @@ -6,8 +6,7 @@ import os import sys - -VERSION = '3.5-dev1' +VERSION = '3.5.0-dev.1' INSTALL_PREFIX = os.path.normpath( os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) ) diff --git a/reframe/core/decorators.py b/reframe/core/decorators.py index 39f4b74ba9..32e1d4a212 100644 --- a/reframe/core/decorators.py +++ b/reframe/core/decorators.py @@ -20,6 +20,7 @@ import traceback import reframe +import reframe.utility.osext as osext from reframe.core.exceptions import ReframeSyntaxError, user_frame from reframe.core.logging import getlogger from reframe.core.pipeline import RegressionTest @@ -168,7 +169,7 @@ def _skip_tests(cls): if not hasattr(mod, '__rfm_skip_tests'): mod.__rfm_skip_tests = set() - if not any(c.validate(reframe.VERSION) for c in conditions): + if not any(c.validate(osext.reframe_version()) for c in conditions): getlogger().info('skipping incompatible test defined' ' in class: %s' % cls.__name__) mod.__rfm_skip_tests.add(cls) diff --git a/reframe/core/warnings.py b/reframe/core/warnings.py index 4cd898e818..0b37e3b279 100644 --- a/reframe/core/warnings.py +++ b/reframe/core/warnings.py @@ -1,7 +1,9 @@ import contextlib import inspect +import semver import warnings +import reframe from reframe.core.exceptions import ReframeFatalError @@ -44,12 +46,21 @@ def _format_warning(message, category, filename, lineno, line=None): warnings.formatwarning = _format_warning -def user_deprecation_warning(message): +_RAISE_DEPRECATION_ALWAYS = False + + +def user_deprecation_warning(message, from_version='0.0.0'): '''Raise a deprecation warning at the user stack frame that eventually calls this function. As "user stack frame" is considered a stack frame that is outside the :py:mod:`reframe` base module. + + :arg message: the message of the warning + :arg from_version: raise the warning only for ReFrame versions greater than + this one. This is useful if you want to "schedule" a deprecation + warning for the future + ''' # Unroll the stack and issue the warning from the first stack frame that is @@ -62,4 +73,8 @@ def user_deprecation_warning(message): stack_level += 1 - warnings.warn(message, ReframeDeprecationWarning, stacklevel=stack_level) + min_version = semver.VersionInfo.parse(from_version) + version = semver.VersionInfo.parse(reframe.VERSION) + if _RAISE_DEPRECATION_ALWAYS or version >= min_version: + warnings.warn(message, ReframeDeprecationWarning, + stacklevel=stack_level) diff --git a/reframe/frontend/runreport.py b/reframe/frontend/runreport.py index c02c46b314..1cedb843ec 100644 --- a/reframe/frontend/runreport.py +++ b/reframe/frontend/runreport.py @@ -11,10 +11,10 @@ import reframe as rfm import reframe.core.exceptions as errors import reframe.utility.jsonext as jsonext -from reframe.utility.versioning import Version +import reframe.utility.versioning as versioning -DATA_VERSION = '1.3' +DATA_VERSION = '1.3.0' _SCHEMA = os.path.join(rfm.INSTALL_PREFIX, 'reframe/schemas/runreport.json') @@ -141,8 +141,10 @@ def load_report(filename): raise errors.ReframeError(f'invalid report {filename!r}') from e # Check if the report data is compatible - found_ver = Version(report['session_info']['data_version']) - required_ver = Version(DATA_VERSION) + found_ver = versioning.parse( + report['session_info']['data_version'] + ) + required_ver = versioning.parse(DATA_VERSION) if found_ver.major != required_ver.major or found_ver < required_ver: raise errors.ReframeError( f'incompatible report data versions: ' diff --git a/reframe/utility/osext.py b/reframe/utility/osext.py index 0a61c65d1b..a6b2cdc64f 100644 --- a/reframe/utility/osext.py +++ b/reframe/utility/osext.py @@ -13,6 +13,7 @@ import grp import os import re +import semver import shlex import shutil import signal @@ -481,15 +482,16 @@ def git_repo_hash(commit='HEAD', short=True, wd=None): def reframe_version(): '''Return ReFrame version. - If ReFrame's installation contains the repository metadata, the - repository's hash will be appended to the actual version. + If ReFrame's installation contains the repository metadata and the current + version is a pre-release version, the repository's hash will be appended + to the actual version. + ''' - version = reframe.VERSION repo_hash = git_repo_hash() - if repo_hash: - return '%s (rev: %s)' % (version, repo_hash) + if repo_hash and semver.VersionInfo.parse(reframe.VERSION).prerelease: + return f'{reframe.VERSION}+{repo_hash}' else: - return version + return reframe.VERSION def expandvars(s): diff --git a/reframe/utility/versioning.py b/reframe/utility/versioning.py index bb7bdc44c5..5b137b48ea 100644 --- a/reframe/utility/versioning.py +++ b/reframe/utility/versioning.py @@ -4,86 +4,49 @@ # SPDX-License-Identifier: BSD-3-Clause import abc -import functools +import semver import sys import re +import reframe +from reframe.core.warnings import user_deprecation_warning -@functools.total_ordering -class Version: - def __init__(self, version): - if version is None: - raise ValueError('version string may not be None') - base_part, *dev_part = version.split('-dev') +def parse(version_str): + '''Compatibility function to normalize version strings from prior + ReFrame versions - try: - major, minor, *patch_part = base_part.split('.') - except ValueError: - raise ValueError('invalid version string: %s' % version) from None - - patch_level = patch_part[0] if patch_part else 0 - - try: - self._major = int(major) - self._minor = int(minor) - self._patch_level = int(patch_level) - self._dev_number = int(dev_part[0]) if dev_part else None - except ValueError: - raise ValueError('invalid version string: %s' % version) from None - - @property - def major(self): - return self._major - - @property - def minor(self): - return self._minor - - @property - def patch_level(self): - return self._patch_level - - @property - def dev_number(self): - return self._dev_number - - def _value(self): - return 10000*self._major + 100*self._minor + self._patch_level - - def __eq__(self, other): - if not isinstance(other, type(self)): - return NotImplemented - - return (self._value() == other._value() and - self._dev_number == other._dev_number) - - def __gt__(self, other): - if not isinstance(other, type(self)): - return NotImplemented - - if self._value() > other._value(): - return self._value() > other._value() - - if self._value() < other._value(): - return self._value() > other._value() - - self_dev_number = (self._dev_number if self._dev_number is not None - else sys.maxsize) - other_dev_number = (other._dev_number if other._dev_number is not None - else sys.maxsize) - - return self_dev_number > other_dev_number - - def __repr__(self): - return "Version('%s')" % self - - def __str__(self): - base = '%s.%s.%s' % (self._major, self._minor, self._patch_level) - if self._dev_number is None: - return base + :returns: a :class:`semver.VersionInfo` object. + ''' - return base + '-dev%s' % self._dev_number + import re + + compat = False + old_style_stable = re.search(r'^(\d+)\.(\d+)$', version_str) + old_style_dev = re.search(r'(\d+)\.(\d+)((\d+))?-dev(\d+)$', version_str) + if old_style_stable: + compat = True + major = old_style_stable.group(1) + minor = old_style_stable.group(2) + ret = semver.VersionInfo(major, minor, 0) + elif old_style_dev: + compat = True + major = old_style_dev.group(1) + minor = old_style_dev.group(2) + patchlevel = old_style_dev.group(4) or 0 + prerelease = old_style_dev.group(5) + ret = semver.VersionInfo(major, minor, patchlevel, f'dev.{prerelease}') + else: + ret = semver.VersionInfo.parse(version_str) + + if compat: + user_deprecation_warning( + f"the version string {version_str!r} is deprecated; " + f"please use the conformant '{ret}'", + from_version='3.5.0' + ) + + return ret class _ValidatorImpl(abc.ABC): @@ -109,14 +72,14 @@ def __init__(self, condition): condition) from None if min_version_str and max_version_str: - self._min_version = Version(min_version_str) - self._max_version = Version(max_version_str) + self._min_version = parse(min_version_str) + self._max_version = parse(max_version_str) else: raise ValueError("missing bound on version interval %s" % condition) def validate(self, version): - version = Version(version) + version = parse(version) return ((version >= self._min_version) and (version <= self._max_version)) @@ -142,7 +105,7 @@ def __init__(self, condition): if not cond_match: raise ValueError("invalid condition: '%s'" % condition) - self._ref_version = Version(cond_match.group(2)) + self._ref_version = parse(cond_match.group(2)) op = cond_match.group(1) if not op: op = '==' @@ -154,7 +117,7 @@ def __init__(self, condition): def validate(self, version): do_validate = self._op_actions[self._operator] - return do_validate(Version(version), self._ref_version) + return do_validate(parse(version), self._ref_version) class VersionValidator: diff --git a/requirements.txt b/requirements.txt index 0ec656591b..84b7473d47 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ pytest-forked==1.3.0 pytest-parallel==0.1.0 PyYAML==5.3.1 requests==2.25.1 +semver==2.13.0 setuptools==50.3.0 wcwidth==0.2.5 #+pygelf%pygelf==0.3.6 diff --git a/unittests/test_versioning.py b/unittests/test_versioning.py index 160e1d8971..e74f2bc3e4 100644 --- a/unittests/test_versioning.py +++ b/unittests/test_versioning.py @@ -4,83 +4,60 @@ # SPDX-License-Identifier: BSD-3-Clause import pytest +import semver -from reframe.utility.versioning import Version, VersionValidator +import reframe.core.warnings as warnings +import reframe.utility.versioning as versioning -def test_version_format(): - Version('1.2') - Version('1.2.3') - Version('1.2-dev0') - Version('1.2-dev5') - v = Version('1.2.3-dev2') - assert v.major == 1 - assert v.minor == 2 - assert v.patch_level == 3 - assert v.dev_number == 2 - - with pytest.raises(ValueError): - Version(None) - - with pytest.raises(ValueError): - Version('') +def test_version_validation(): + conditions = [versioning.VersionValidator('<=1.0.0'), + versioning.VersionValidator('2.0.0..2.5.0'), + versioning.VersionValidator('3.0.0')] + assert all([any(c.validate('0.1.0') for c in conditions), + any(c.validate('2.0.0') for c in conditions), + any(c.validate('2.2.0') for c in conditions), + any(c.validate('2.5.0') for c in conditions), + any(c.validate('3.0.0') for c in conditions), + not any(c.validate('3.1.0') for c in conditions)]) with pytest.raises(ValueError): - Version('1') + versioning.VersionValidator('2.0.0..') with pytest.raises(ValueError): - Version('1.2a') + versioning.VersionValidator('..2.0.0') with pytest.raises(ValueError): - Version('a.b.c') + versioning.VersionValidator('1.0.0..2.0.0..3.0.0') with pytest.raises(ValueError): - Version('1.2.3-dev') - - -def test_comparing_versions(): - assert Version('1.2') < Version('1.2.1') - assert Version('1.2.1') < Version('1.2.2') - assert Version('1.2.2') < Version('1.3-dev0') - assert Version('1.3-dev0') < Version('1.3-dev1') - assert Version('1.3-dev1') < Version('1.3') - assert Version('1.3') == Version('1.3.0') - assert Version('1.3-dev1') == Version('1.3.0-dev1') - assert Version('1.12.3') > Version('1.2.3') - assert Version('1.2.23') > Version('1.2.3') + versioning.VersionValidator('=>2.0.0') - -def test_version_validation(): - conditions = [VersionValidator('<=1.0.0'), - VersionValidator('2.0.0..2.5'), - VersionValidator('3.0')] - - assert all([any(c.validate('0.1') for c in conditions), - any(c.validate('2.0.0') for c in conditions), - any(c.validate('2.2') for c in conditions), - any(c.validate('2.5') for c in conditions), - any(c.validate('3.0') for c in conditions), - not any(c.validate('3.1') for c in conditions)]) with pytest.raises(ValueError): - VersionValidator('2.0.0..') + versioning.VersionValidator('2.0.0>') with pytest.raises(ValueError): - VersionValidator('..2.0.0') + versioning.VersionValidator('2.0.0>1.0.0') with pytest.raises(ValueError): - VersionValidator('1.0.0..2.0.0..3.0.0') + versioning.VersionValidator('=>') with pytest.raises(ValueError): - VersionValidator('=>2.0.0') + versioning.VersionValidator('>1') - with pytest.raises(ValueError): - VersionValidator('2.0.0>') - with pytest.raises(ValueError): - VersionValidator('2.0.0>1.0.0') +def test_parse(monkeypatch): + monkeypatch.setattr(warnings, '_RAISE_DEPRECATION_ALWAYS', True) + with pytest.warns(warnings.ReframeDeprecationWarning, + match="please use the conformant '3.5.0'"): + deprecated = versioning.parse('3.5') - with pytest.raises(ValueError): - VersionValidator('=>') + assert deprecated == versioning.parse('3.5.0') + with pytest.warns(warnings.ReframeDeprecationWarning, + match="please use the conformant '3.5.0-dev.0'"): + deprecated = versioning.parse('3.5-dev0') - with pytest.raises(ValueError): - VersionValidator('>1') + assert deprecated == versioning.parse('3.5.0-dev.0') + assert str(versioning.parse('3.5.0')) == '3.5.0' + assert str(versioning.parse('3.5.0-dev.1')) == '3.5.0-dev.1' + assert str(versioning.parse('3.5.0-dev.1+HASH')) == '3.5.0-dev.1+HASH' diff --git a/unittests/test_warnings.py b/unittests/test_warnings.py index dc84e96cf5..ae240a0968 100644 --- a/unittests/test_warnings.py +++ b/unittests/test_warnings.py @@ -1,6 +1,8 @@ import pytest +import semver import warnings +import reframe import reframe.core.runtime as rt import reframe.core.warnings as warn import reframe.utility.color as color @@ -19,6 +21,11 @@ def test_deprecation_warning(): warn.user_deprecation_warning('deprecated') +def test_deprecation_warning_from_version(): + next_version = semver.VersionInfo.parse(reframe.VERSION).bump_minor() + warn.user_deprecation_warning('deprecated', str(next_version)) + + def test_deprecation_warning_formatting(with_colors): message = warnings.formatwarning( 'deprecated', warn.ReframeDeprecationWarning, 'file', 10, 'a = 1' From 111baf47ed1fdd1b999ce9d3433796dc9ea30e43 Mon Sep 17 00:00:00 2001 From: Vasileios Karakasis Date: Wed, 10 Feb 2021 17:21:11 +0100 Subject: [PATCH 2/4] Remove unused imports --- reframe/core/decorators.py | 1 - reframe/utility/versioning.py | 6 +----- unittests/test_versioning.py | 2 -- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/reframe/core/decorators.py b/reframe/core/decorators.py index 32e1d4a212..fd3b33c950 100644 --- a/reframe/core/decorators.py +++ b/reframe/core/decorators.py @@ -19,7 +19,6 @@ import sys import traceback -import reframe import reframe.utility.osext as osext from reframe.core.exceptions import ReframeSyntaxError, user_frame from reframe.core.logging import getlogger diff --git a/reframe/utility/versioning.py b/reframe/utility/versioning.py index 5b137b48ea..f97619cfd0 100644 --- a/reframe/utility/versioning.py +++ b/reframe/utility/versioning.py @@ -4,11 +4,9 @@ # SPDX-License-Identifier: BSD-3-Clause import abc -import semver -import sys import re +import semver -import reframe from reframe.core.warnings import user_deprecation_warning @@ -19,8 +17,6 @@ def parse(version_str): :returns: a :class:`semver.VersionInfo` object. ''' - import re - compat = False old_style_stable = re.search(r'^(\d+)\.(\d+)$', version_str) old_style_dev = re.search(r'(\d+)\.(\d+)((\d+))?-dev(\d+)$', version_str) diff --git a/unittests/test_versioning.py b/unittests/test_versioning.py index e74f2bc3e4..98dd92d2dd 100644 --- a/unittests/test_versioning.py +++ b/unittests/test_versioning.py @@ -4,8 +4,6 @@ # SPDX-License-Identifier: BSD-3-Clause import pytest -import semver - import reframe.core.warnings as warnings import reframe.utility.versioning as versioning From 8064ebf7392bb9a145198229c0534ed64775e754 Mon Sep 17 00:00:00 2001 From: Vasileios Karakasis Date: Wed, 10 Feb 2021 23:02:56 +0100 Subject: [PATCH 3/4] Update documentation of `@required_version` decorator --- reframe/core/decorators.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/reframe/core/decorators.py b/reframe/core/decorators.py index fd3b33c950..72daa9a2d4 100644 --- a/reframe/core/decorators.py +++ b/reframe/core/decorators.py @@ -139,8 +139,6 @@ def required_version(*versions): If the test is not compatible with the current ReFrame version it will be skipped. - .. versionadded:: 2.13 - :arg versions: A list of ReFrame version specifications that this test is allowed to run. A version specification string can have one of the following formats: @@ -148,15 +146,25 @@ def required_version(*versions): 1. ``VERSION``: Specifies a single version. 2. ``{OP}VERSION``, where ``{OP}`` can be any of ``>``, ``>=``, ``<``, ``<=``, ``==`` and ``!=``. For example, the version specification - string ``'>=2.15'`` will allow the following test to be loaded only - by ReFrame 2.15 and higher. The ``==VERSION`` specification is the + string ``'>=3.5.0'`` will allow the following test to be loaded only + by ReFrame 3.5.0 and higher. The ``==VERSION`` specification is the equivalent of ``VERSION``. 3. ``V1..V2``: Specifies a range of versions. You can specify multiple versions with this decorator, such as - ``@required_version('2.13', '>=2.16')``, in which case the test will be + ``@required_version('3.5.1', '>=3.5.6')``, in which case the test will be selected if *any* of the versions is satisfied, even if the versions specifications are conflicting. + + .. versionadded:: 2.13 + + .. versionchanged:: 3.5.0 + + Passing ReFrame version numbers that do not comply with the `semantic + versioning `__ specification is deprecated. + Examples of non-compliant version numbers are ``3.5`` and ``3.5-dev0``. + These should be written as ``3.5.0`` and ``3.5.0-dev.0``. + ''' if not versions: raise ValueError('no versions specified') From 78a673923d9d30436893db238f64d55fdaf1e9f4 Mon Sep 17 00:00:00 2001 From: Vasileios Karakasis Date: Tue, 16 Feb 2021 23:50:08 +0100 Subject: [PATCH 4/4] Fix wheel package --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index fb6737b223..7eecab95ed 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ ), package_data={'reframe': ['schemas/*']}, include_package_data=True, - install_requires=['argcomplete', 'jsonschema', 'PyYAML'], + install_requires=['argcomplete', 'jsonschema', 'PyYAML', 'semver'], python_requires='>=3.6', scripts=['bin/reframe'], classifiers=(