pyproject-rpm-macros/pyproject_buildrequires.py

316 lines
10 KiB
Python
Raw Normal View History

import os
2019-07-02 14:53:05 +00:00
import sys
import importlib
2019-07-17 13:44:22 +00:00
import argparse
import functools
import traceback
import contextlib
from io import StringIO
import subprocess
import re
import tempfile
import email.parser
2019-07-17 13:44:22 +00:00
print_err = functools.partial(print, file=sys.stderr)
# Some valid Python version specifiers are not supported.
# Whitelist characters we can handle.
VERSION_RE = re.compile('[a-zA-Z0-9.-]+')
2019-07-17 13:44:22 +00:00
class EndPass(Exception):
"""End current pass of generating requirements"""
2019-07-02 14:53:05 +00:00
2019-07-02 14:53:05 +00:00
try:
import toml
2019-07-02 14:53:05 +00:00
from packaging.requirements import Requirement, InvalidRequirement
from packaging.utils import canonicalize_name, canonicalize_version
try:
import importlib.metadata as importlib_metadata
except ImportError:
import importlib_metadata
2019-07-17 13:44:22 +00:00
except ImportError as e:
print_err('Import error:', e)
2019-07-02 14:53:05 +00:00
# already echoed by the %pyproject_buildrequires macro
sys.exit(0)
2019-07-17 13:44:22 +00:00
@contextlib.contextmanager
def hook_call():
captured_out = StringIO()
with contextlib.redirect_stdout(captured_out):
yield
for line in captured_out.getvalue().splitlines():
print_err('HOOK STDOUT:', line)
class Requirements:
"""Requirement printer"""
def __init__(self, get_installed_version, extras=''):
self.get_installed_version = get_installed_version
2019-07-17 13:44:22 +00:00
self.marker_env = {'extra': extras}
2019-07-17 13:44:22 +00:00
self.missing_requirements = False
def add(self, requirement_str, *, source=None):
"""Output a Python-style requirement string as RPM dep"""
print_err(f'Handling {requirement_str} from {source}')
2019-07-02 14:53:05 +00:00
try:
2019-07-17 13:44:22 +00:00
requirement = Requirement(requirement_str)
except InvalidRequirement as e:
print_err(
f'WARNING: Skipping invalid requirement: {requirement_str}\n'
2019-07-17 13:44:22 +00:00
+ f' {e}',
)
return
name = canonicalize_name(requirement.name)
if (requirement.marker is not None and
not requirement.marker.evaluate(environment=self.marker_env)):
2019-07-17 13:44:22 +00:00
print_err(f'Ignoring alien requirement:', requirement_str)
return
try:
installed = self.get_installed_version(requirement.name)
except importlib_metadata.PackageNotFoundError:
print_err(f'Requirement not satisfied: {requirement_str}')
installed = None
2019-07-17 13:44:22 +00:00
if installed and installed in requirement.specifier:
print_err(f'Requirement satisfied: {requirement_str}')
print_err(f' (installed: {requirement.name} {installed})')
else:
self.missing_requirements = True
together = []
for specifier in sorted(
requirement.specifier,
key=lambda s: (s.operator, s.version),
):
version = canonicalize_version(specifier.version)
if not VERSION_RE.fullmatch(str(specifier.version)):
raise ValueError(
f'Unknown character in version: {specifier.version}. '
+ '(This is probably a bug in pyproject-rpm-macros.)',
)
if specifier.operator == '!=':
2019-07-17 13:44:22 +00:00
lower = python3dist(name, '<', version)
higher = python3dist(name, '>', f'{version}.0')
together.append(
f'({lower} or {higher})'
2019-07-17 13:44:22 +00:00
)
else:
together.append(python3dist(name, specifier.operator, version))
if len(together) == 0:
print(python3dist(name))
elif len(together) == 1:
print(together[0])
else:
print(f"({' and '.join(together)})")
def check(self, *, source=None):
"""End current pass if any unsatisfied dependencies were output"""
if self.missing_requirements:
print_err(f'Exiting dependency generation pass: {source}')
raise EndPass(source)
def extend(self, requirement_strs, *, source=None):
"""add() several requirements"""
for req_str in requirement_strs:
self.add(req_str, source=source)
2019-07-02 14:53:05 +00:00
2019-07-17 13:44:22 +00:00
def get_backend(requirements):
try:
f = open('pyproject.toml')
except FileNotFoundError:
pyproject_data = {}
else:
2019-07-17 13:44:22 +00:00
with f:
pyproject_data = toml.load(f)
2019-07-17 13:44:22 +00:00
buildsystem_data = pyproject_data.get('build-system', {})
2019-07-17 13:44:22 +00:00
requirements.extend(
buildsystem_data.get('requires', ()),
2019-07-17 13:44:22 +00:00
source='build-system.requires',
)
backend_name = buildsystem_data.get('build-backend')
if not backend_name:
# https://www.python.org/dev/peps/pep-0517/:
# If the pyproject.toml file is absent, or the build-backend key is
# missing, the source tree is not using this specification, and tools
# should revert to the legacy behaviour of running setup.py
# (either directly, or by implicitly invoking the [following] backend).
backend_name = 'setuptools.build_meta:__legacy__'
requirements.add('setuptools >= 40.8', source='default build backend')
requirements.add('wheel', source='default build backend')
2019-07-17 13:44:22 +00:00
requirements.check(source='build backend')
backend_path = buildsystem_data.get('backend-path')
if backend_path:
sys.path.insert(0, backend_path)
module_name, _, object_name = backend_name.partition(":")
backend_module = importlib.import_module(module_name)
if object_name:
return getattr(backend_module, object_name)
return backend_module
2019-07-02 14:53:05 +00:00
2019-07-17 13:44:22 +00:00
def generate_build_requirements(backend, requirements):
get_requires = getattr(backend, 'get_requires_for_build_wheel', None)
2019-07-17 13:44:22 +00:00
if get_requires:
with hook_call():
new_reqs = get_requires()
requirements.extend(new_reqs, source='get_requires_for_build_wheel')
2019-07-02 14:53:05 +00:00
def generate_run_requirements(backend, requirements):
hook_name = 'prepare_metadata_for_build_wheel'
prepare_metadata = getattr(backend, hook_name, None)
if not prepare_metadata:
raise ValueError(
'build backend cannot provide build metadata '
+ '(incl. runtime requirements) before buld'
)
with hook_call():
dir_basename = prepare_metadata('.')
with open(dir_basename + '/METADATA') as f:
message = email.parser.Parser().parse(f, headersonly=True)
for key in 'Requires', 'Requires-Dist':
requires = message.get_all(key, ())
requirements.extend(requires, source=f'wheel metadata: {key}')
def parse_tox_requires_lines(lines):
packages = []
for line in lines:
line = line.strip()
if line.startswith('-r'):
path = line[2:]
with open(path) as f:
packages.extend(parse_tox_requires_lines(f.read().splitlines()))
elif line.startswith('-'):
print_err(
f'WARNING: Skipping dependency line: {line}\n'
+ f' tox deps options other than -r are not supported (yet).',
)
else:
packages.append(line)
return packages
def generate_tox_requirements(toxenv, requirements):
requirements.add('tox-current-env >= 0.0.2', source='tox itself')
requirements.check(source='tox itself')
with tempfile.NamedTemporaryFile('r') as depfile:
r = subprocess.run(
['tox', '--print-deps-to-file', depfile.name, '-qre', toxenv],
When tox fails, print tox output before failing Previously, it wasn't possible to see why tox failed: ... Requirement satisfied: tox-current-env >= 0.0.2 (installed: tox-current-env 0.0.2) Traceback (most recent call last): File "/usr/lib/rpm/redhat/pyproject_buildrequires.py", line 269, in main generate_requires( File "/usr/lib/rpm/redhat/pyproject_buildrequires.py", line 221, in generate_requires generate_tox_requirements(toxenv, requirements) File "/usr/lib/rpm/redhat/pyproject_buildrequires.py", line 184, in generate_tox_requirements r = subprocess.run( File "/usr/lib64/python3.8/subprocess.py", line 512, in run raise CalledProcessError(retcode, process.args, subprocess.CalledProcessError: Command '['tox', '--print-deps-to-file', '/tmp/tmp96smu4rv', '-qre', 'py38']' returned non-zero exit status 1. Now it is: ... Requirement satisfied: tox-current-env >= 0.0.2 (installed: tox-current-env 0.0.2) ERROR: tox config file (either pyproject.toml, tox.ini, setup.cfg) not found Traceback (most recent call last): File "/usr/lib/rpm/redhat/pyproject_buildrequires.py", line 270, in main generate_requires( File "/usr/lib/rpm/redhat/pyproject_buildrequires.py", line 222, in generate_requires generate_tox_requirements(toxenv, requirements) File "/usr/lib/rpm/redhat/pyproject_buildrequires.py", line 193, in generate_tox_requirements r.check_returncode() File "/usr/lib64/python3.8/subprocess.py", line 444, in check_returncode raise CalledProcessError(self.returncode, self.args, self.stdout, subprocess.CalledProcessError: Command '['tox', '--print-deps-to-file', '/tmp/tmpwp8sffv1', '-qre', 'py38']' returned non-zero exit status 1. Inspired by https://src.fedoraproject.org/rpms/python-chaospy/pull-request/1#comment-32750
2019-10-25 14:51:01 +00:00
check=False,
encoding='utf-8',
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
if r.stdout:
When tox fails, print tox output before failing Previously, it wasn't possible to see why tox failed: ... Requirement satisfied: tox-current-env >= 0.0.2 (installed: tox-current-env 0.0.2) Traceback (most recent call last): File "/usr/lib/rpm/redhat/pyproject_buildrequires.py", line 269, in main generate_requires( File "/usr/lib/rpm/redhat/pyproject_buildrequires.py", line 221, in generate_requires generate_tox_requirements(toxenv, requirements) File "/usr/lib/rpm/redhat/pyproject_buildrequires.py", line 184, in generate_tox_requirements r = subprocess.run( File "/usr/lib64/python3.8/subprocess.py", line 512, in run raise CalledProcessError(retcode, process.args, subprocess.CalledProcessError: Command '['tox', '--print-deps-to-file', '/tmp/tmp96smu4rv', '-qre', 'py38']' returned non-zero exit status 1. Now it is: ... Requirement satisfied: tox-current-env >= 0.0.2 (installed: tox-current-env 0.0.2) ERROR: tox config file (either pyproject.toml, tox.ini, setup.cfg) not found Traceback (most recent call last): File "/usr/lib/rpm/redhat/pyproject_buildrequires.py", line 270, in main generate_requires( File "/usr/lib/rpm/redhat/pyproject_buildrequires.py", line 222, in generate_requires generate_tox_requirements(toxenv, requirements) File "/usr/lib/rpm/redhat/pyproject_buildrequires.py", line 193, in generate_tox_requirements r.check_returncode() File "/usr/lib64/python3.8/subprocess.py", line 444, in check_returncode raise CalledProcessError(self.returncode, self.args, self.stdout, subprocess.CalledProcessError: Command '['tox', '--print-deps-to-file', '/tmp/tmpwp8sffv1', '-qre', 'py38']' returned non-zero exit status 1. Inspired by https://src.fedoraproject.org/rpms/python-chaospy/pull-request/1#comment-32750
2019-10-25 14:51:01 +00:00
print_err(r.stdout, end='')
r.check_returncode()
deplines = depfile.read().splitlines()
packages = parse_tox_requires_lines(deplines)
requirements.extend(packages,
source=f'tox --print-deps-only: {toxenv}')
2019-07-17 13:44:22 +00:00
def python3dist(name, op=None, version=None):
if op is None:
if version is not None:
raise AssertionError('op and version go together')
return f'python3dist({name})'
else:
return f'python3dist({name}) {op} {version}'
def generate_requires(
*, include_runtime=False, toxenv=None, extras='',
get_installed_version=importlib_metadata.version, # for dep injection
):
"""Generate the BuildRequires for the project in the current directory
This is the main Python entry point.
"""
requirements = Requirements(get_installed_version, extras=extras)
2019-07-17 13:44:22 +00:00
2019-07-02 14:53:05 +00:00
try:
2019-07-17 13:44:22 +00:00
backend = get_backend(requirements)
generate_build_requirements(backend, requirements)
if toxenv is not None:
include_runtime = True
generate_tox_requirements(toxenv, requirements)
if include_runtime:
generate_run_requirements(backend, requirements)
2019-07-17 13:44:22 +00:00
except EndPass:
2019-07-17 14:25:20 +00:00
return
2019-07-17 13:44:22 +00:00
def main(argv):
parser = argparse.ArgumentParser(
description='Generate BuildRequires for a Python project.'
)
parser.add_argument(
'-r', '--runtime', action='store_true',
2019-07-18 07:24:02 +00:00
help='Generate run-time requirements',
2019-07-17 13:44:22 +00:00
)
parser.add_argument(
'-e', '--toxenv', metavar='TOXENVS', default=None,
help=('specify tox environments'
'(implies --tox)'),
)
parser.add_argument(
'-t', '--tox', action='store_true',
help=('generate test tequirements from tox environment '
'(implies --runtime)'),
2019-07-17 13:44:22 +00:00
)
parser.add_argument(
'-x', '--extras', metavar='EXTRAS', default='',
help='extra for runtime requirements (e.g. -x testing) '
'(implies --runtime)',
# XXX: a comma-separated list should be possible here
# help='comma separated list of "extras" for runtime requirements '
# + '(e.g. -x testing,feature-x)',
)
2019-07-17 13:44:22 +00:00
args = parser.parse_args(argv)
2019-08-13 12:05:14 +00:00
if args.toxenv:
args.tox = True
if args.tox:
2019-08-13 12:05:14 +00:00
args.runtime = True
args.toxenv = (args.toxenv or os.getenv('RPM_TOXENV') or
f'py{sys.version_info.major}{sys.version_info.minor}')
if args.extras:
args.runtime = True
2019-07-17 13:44:22 +00:00
try:
generate_requires(
include_runtime=args.runtime,
toxenv=args.toxenv,
extras=args.extras,
)
except Exception:
2019-07-17 13:44:22 +00:00
# Log the traceback explicitly (it's useful debug info)
traceback.print_exc()
exit(1)
2019-07-02 14:53:05 +00:00
2019-07-17 13:44:22 +00:00
if __name__ == '__main__':
main(sys.argv[1:])