From cb38f210d20996dea87c55b38c5d383bb881c444 Mon Sep 17 00:00:00 2001 From: Gordon Messmer Date: Tue, 8 Sep 2020 21:47:51 -0700 Subject: [PATCH] Support more Python version specifiers in generated BuildRequires This change introduces code from pyreq2rpm, a tested set of requirement conversion functions used in pyp2rpm and rpm's pythondistdeps. This adds support for the '~=' operator and wildcards. --- pyproject-rpm-macros.spec | 9 +- pyproject_buildrequires.py | 18 ++-- pyproject_buildrequires_testcases.yaml | 17 +-- pyproject_convert.py | 142 +++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 21 deletions(-) create mode 100644 pyproject_convert.py diff --git a/pyproject-rpm-macros.spec b/pyproject-rpm-macros.spec index e0b20f8..095b110 100644 --- a/pyproject-rpm-macros.spec +++ b/pyproject-rpm-macros.spec @@ -6,7 +6,7 @@ License: MIT # Keep the version at zero and increment only release Version: 0 -Release: 27%{?dist} +Release: 28%{?dist} # Macro files Source001: macros.pyproject @@ -14,6 +14,7 @@ Source001: macros.pyproject # Implementation files Source101: pyproject_buildrequires.py Source102: pyproject_save_files.py +Source103: pyproject_convert.py # Tests Source201: test_pyproject_buildrequires.py @@ -70,6 +71,7 @@ mkdir -p %{buildroot}%{_rpmmacrodir} mkdir -p %{buildroot}%{_rpmconfigdir}/redhat install -m 644 macros.pyproject %{buildroot}%{_rpmmacrodir}/ install -m 644 pyproject_buildrequires.py %{buildroot}%{_rpmconfigdir}/redhat/ +install -m 644 pyproject_convert.py %{buildroot}%{_rpmconfigdir}/redhat/ install -m 644 pyproject_save_files.py %{buildroot}%{_rpmconfigdir}/redhat/ %if %{with tests} @@ -82,12 +84,17 @@ export HOSTNAME="rpmbuild" # to speedup tox in network-less mock, see rhbz#1856 %files %{_rpmmacrodir}/macros.pyproject %{_rpmconfigdir}/redhat/pyproject_buildrequires.py +%{_rpmconfigdir}/redhat/pyproject_convert.py %{_rpmconfigdir}/redhat/pyproject_save_files.py %doc README.md %license LICENSE %changelog +* Fri Sep 08 2020 Gordon Messmer - 0-28 +- Support more Python version specifiers in generated BuildRequires +- This adds support for the '~=' operator and wildcards + * Fri Sep 4 2020 Miro HronĨok - 0-27 - Make code in $PWD importable from %%pyproject_buildrequires - Only require toml for projects with pyproject.toml diff --git a/pyproject_buildrequires.py b/pyproject_buildrequires.py index 6290a42..ca52252 100644 --- a/pyproject_buildrequires.py +++ b/pyproject_buildrequires.py @@ -34,6 +34,9 @@ except ImportError as e: # already echoed by the %pyproject_buildrequires macro sys.exit(0) +# uses packaging, needs to be imported after packaging is verified to be present +from pyproject_convert import convert + @contextlib.contextmanager def hook_call(): @@ -116,24 +119,15 @@ class Requirements: f'Unknown character in version: {specifier.version}. ' + '(This is probably a bug in pyproject-rpm-macros.)', ) - if specifier.operator == '!=': - lower = python3dist(name, '<', version, - self.python3_pkgversion) - higher = python3dist(name, '>', f'{version}.0', - self.python3_pkgversion) - together.append( - f'({lower} or {higher})' - ) - else: - together.append(python3dist(name, specifier.operator, version, - self.python3_pkgversion)) + together.append(convert(python3dist(name, python3_pkgversion=self.python3_pkgversion), + specifier.operator, version)) if len(together) == 0: print(python3dist(name, python3_pkgversion=self.python3_pkgversion)) elif len(together) == 1: print(together[0]) else: - print(f"({' and '.join(together)})") + print(f"({' with '.join(together)})") def check(self, *, source=None): """End current pass if any unsatisfied dependencies were output""" diff --git a/pyproject_buildrequires_testcases.yaml b/pyproject_buildrequires_testcases.yaml index f70c6e0..77360c6 100644 --- a/pyproject_buildrequires_testcases.yaml +++ b/pyproject_buildrequires_testcases.yaml @@ -97,15 +97,15 @@ Build system dependencies in pyproject.toml with extras: python3dist(foo) python3dist(bar) > 5 python3dist(bar[baz]) > 5 - (python3dist(ne) < 1 or python3dist(ne) > 1.0) + (python3dist(ne) < 1 or python3dist(ne) > 1) python3dist(ge) >= 1.2 python3dist(le) <= 1.2.3 python3dist(lt) < 1.2.3.4 python3dist(gt) > 1.2.3.4.5 - python3dist(multi) == 6 - python3dist(multi[extras1]) == 6 - python3dist(multi[extras2]) == 6 - ((python3dist(combo) < 3 or python3dist(combo) > 3.0) and python3dist(combo) < 5 and python3dist(combo) > 2) + python3dist(multi) = 6 + python3dist(multi[extras1]) = 6 + python3dist(multi[extras2]) = 6 + ((python3dist(combo) < 3 or python3dist(combo) > 3) with python3dist(combo) < 5 with python3dist(combo) > 2) python3dist(py3) python3dist(setuptools) >= 40.8 python3dist(wheel) @@ -126,7 +126,7 @@ Build system dependencies in pyproject.toml without extras: expected: | python3dist(toml) python3dist(bar) > 5 - python3dist(multi) == 6 + python3dist(multi) = 6 python3dist(setuptools) >= 40.8 python3dist(wheel) result: 0 @@ -140,7 +140,7 @@ Default build system, build dependencies in setup.py: setup( name='test', version='0.1', - setup_requires=['foo', 'bar!=2'], + setup_requires=['foo', 'bar!=2', 'baz~=1.1.1'], install_requires=['inst'], ) expected: | @@ -148,7 +148,8 @@ Default build system, build dependencies in setup.py: python3dist(wheel) python3dist(wheel) python3dist(foo) - (python3dist(bar) < 2 or python3dist(bar) > 2.0) + (python3dist(bar) < 2 or python3dist(bar) > 2) + (python3dist(baz) >= 1.1.1 with python3dist(baz) < 1.2) result: 0 Default build system, run dependencies in setup.py: diff --git a/pyproject_convert.py b/pyproject_convert.py new file mode 100644 index 0000000..942dc21 --- /dev/null +++ b/pyproject_convert.py @@ -0,0 +1,142 @@ +# Copyright 2019 Gordon Messmer +# +# Upstream: https://github.com/gordonmessmer/pyreq2rpm +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from packaging.requirements import Requirement +from packaging.version import parse as parse_version + +class RpmVersion(): + def __init__(self, version_id): + version = parse_version(version_id) + if isinstance(version._version, str): + self.version = version._version + else: + self.epoch = version._version.epoch + self.version = list(version._version.release) + self.pre = version._version.pre + self.dev = version._version.dev + self.post = version._version.post + + def increment(self): + self.version[-1] += 1 + self.pre = None + self.dev = None + self.post = None + return self + + def __str__(self): + if isinstance(self.version, str): + return self.version + if self.epoch: + rpm_epoch = str(self.epoch) + ':' + else: + rpm_epoch = '' + while len(self.version) > 1 and self.version[-1] == 0: + self.version.pop() + rpm_version = '.'.join(str(x) for x in self.version) + if self.pre: + rpm_suffix = '~{}'.format(''.join(str(x) for x in self.pre)) + elif self.dev: + rpm_suffix = '~~{}'.format(''.join(str(x) for x in self.dev)) + elif self.post: + rpm_suffix = '^post{}'.format(self.post[1]) + else: + rpm_suffix = '' + return '{}{}{}'.format(rpm_epoch, rpm_version, rpm_suffix) + +def convert_compatible(name, operator, version_id): + if version_id.endswith('.*'): + return 'Invalid version' + version = RpmVersion(version_id) + if len(version.version) == 1: + return 'Invalid version' + upper_version = RpmVersion(version_id) + upper_version.version.pop() + upper_version.increment() + return '({} >= {} with {} < {})'.format( + name, version, name, upper_version) + +def convert_equal(name, operator, version_id): + if version_id.endswith('.*'): + version_id = version_id[:-2] + '.0' + return convert_compatible(name, '~=', version_id) + version = RpmVersion(version_id) + return '{} = {}'.format(name, version) + +def convert_arbitrary_equal(name, operator, version_id): + if version_id.endswith('.*'): + return 'Invalid version' + version = RpmVersion(version_id) + return '{} = {}'.format(name, version) + +def convert_not_equal(name, operator, version_id): + if version_id.endswith('.*'): + version_id = version_id[:-2] + version = RpmVersion(version_id) + lower_version = RpmVersion(version_id).increment() + else: + version = RpmVersion(version_id) + lower_version = version + return '({} < {} or {} > {})'.format( + name, version, name, lower_version) + +def convert_ordered(name, operator, version_id): + if version_id.endswith('.*'): + # PEP 440 does not define semantics for prefix matching + # with ordered comparisons + version_id = version_id[:-2] + version = RpmVersion(version_id) + if operator == '>': + # distutils will allow a prefix match with '>' + operator = '>=' + if operator == '<=': + # distutils will not allow a prefix match with '<=' + operator = '<' + else: + version = RpmVersion(version_id) + return '{} {} {}'.format(name, operator, version) + +OPERATORS = {'~=': convert_compatible, + '==': convert_equal, + '===': convert_arbitrary_equal, + '!=': convert_not_equal, + '<=': convert_ordered, + '<': convert_ordered, + '>=': convert_ordered, + '>': convert_ordered} + +def convert(name, operator, version_id): + return OPERATORS[operator](name, operator, version_id) + +def convert_requirement(req): + parsed_req = Requirement.parse(req) + reqs = [] + for spec in parsed_req.specs: + reqs.append(convert(parsed_req.project_name, spec[0], spec[1])) + if len(reqs) == 0: + return parsed_req.project_name + if len(reqs) == 1: + return reqs[0] + else: + reqs.sort() + return '({})'.format(' with '.join(reqs))