pyproject-rpm-macros/pyproject_buildrequires.py

581 lines
22 KiB
Python
Raw Normal View History

import glob
import io
import os
2019-07-02 14:53:05 +00:00
import sys
import importlib.metadata
2019-07-17 13:44:22 +00:00
import argparse
import traceback
import json
2019-07-17 13:44:22 +00:00
import subprocess
import re
import tempfile
import email.parser
import pathlib
import zipfile
from pyproject_requirements_txt import convert_requirements_txt
from pyproject_wheel import parse_config_settings_args
2019-07-17 13:44:22 +00:00
# Some valid Python version specifiers are not supported.
# Allow only the forms we know we can handle.
VERSION_RE = re.compile(r'[a-zA-Z0-9.-]+(\.\*)?')
2019-07-17 13:44:22 +00:00
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
# nb: we don't use functools.partial to be able to use pytest's capsys
# see https://github.com/pytest-dev/pytest/issues/8900
def print_err(*args, **kwargs):
kwargs.setdefault('file', sys.stderr)
print(*args, **kwargs)
2019-07-02 14:53:05 +00:00
try:
from packaging.requirements import Requirement, InvalidRequirement
from packaging.utils import canonicalize_name
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)
# uses packaging, needs to be imported after packaging is verified to be present
from pyproject_convert import convert
2019-07-02 14:53:05 +00:00
def guess_reason_for_invalid_requirement(requirement_str):
if ':' in requirement_str:
message = (
'It might be an URL. '
'%pyproject_buildrequires cannot handle all URL-based requirements. '
'Add PackageName@ (see PEP 508) to the URL to at least require any version of PackageName.'
)
if '@' in requirement_str:
message += ' (but note that URLs might not work well with other features)'
return message
if '/' in requirement_str:
return (
'It might be a local path. '
'%pyproject_buildrequires cannot handle local paths as requirements. '
'Use an URL with PackageName@ (see PEP 508) to at least require any version of PackageName.'
)
# No more ideas
return None
2019-07-17 13:44:22 +00:00
class Requirements:
"""Requirement gatherer. The macro will eventually print out output_lines."""
def __init__(self, get_installed_version, extras=None,
generate_extras=False, python3_pkgversion='3', config_settings=None):
self.get_installed_version = get_installed_version
self.output_lines = []
self.extras = set()
2019-07-17 13:44:22 +00:00
if extras:
for extra in extras:
self.add_extras(*extra.split(','))
2019-07-17 13:44:22 +00:00
self.missing_requirements = False
self.ignored_alien_requirements = []
2019-07-17 13:44:22 +00:00
self.generate_extras = generate_extras
self.python3_pkgversion = python3_pkgversion
self.config_settings = config_settings
def add_extras(self, *extras):
self.extras |= set(e.strip() for e in extras)
@property
def marker_envs(self):
if self.extras:
return [{'extra': e} for e in sorted(self.extras)]
return [{'extra': ''}]
def evaluate_all_environments(self, requirement):
for marker_env in self.marker_envs:
if requirement.marker.evaluate(environment=marker_env):
return True
return False
def add(self, requirement_str, *, package_name=None, source=None):
2019-07-17 13:44:22 +00:00
"""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:
hint = guess_reason_for_invalid_requirement(requirement_str)
message = f'Requirement {requirement_str!r} from {source} is invalid.'
if hint:
message += f' Hint: {hint}'
raise ValueError(message)
if requirement.url:
2019-07-17 13:44:22 +00:00
print_err(
f'WARNING: Simplifying {requirement_str!r} to {requirement.name!r}.'
2019-07-17 13:44:22 +00:00
)
name = canonicalize_name(requirement.name)
if (requirement.marker is not None and
not self.evaluate_all_environments(requirement)):
2019-07-17 13:44:22 +00:00
print_err(f'Ignoring alien requirement:', requirement_str)
self.ignored_alien_requirements.append(requirement_str)
return
# Handle self-referencing requirements
if package_name and canonicalize_name(package_name) == name:
# Self-referential extras need to be handled specially
if requirement.extras:
if not (requirement.extras <= self.extras): # only handle it if needed
# let all further requirements know we want those extras
self.add_extras(*requirement.extras)
# re-add all of the alien requirements ignored in the past
# they might no longer be alien now
self.readd_ignored_alien_requirements(package_name=package_name)
else:
print_err(f'Ignoring self-referential requirement without extras:', requirement_str)
2019-07-17 13:44:22 +00:00
return
# We need to always accept pre-releases as satisfying the requirement
# Otherwise e.g. installed cffi version 1.15.0rc2 won't even satisfy the requirement for "cffi"
# https://bugzilla.redhat.com/show_bug.cgi?id=2014639#c3
requirement.specifier.prereleases = True
try:
# TODO: check if requirements with extras are satisfied
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})')
if requirement.extras:
print_err(f' (extras are currently not checked)')
2019-07-17 13:44:22 +00:00
else:
self.missing_requirements = True
if self.generate_extras:
extra_names = [f'{name}[{extra.lower()}]' for extra in sorted(requirement.extras)]
2019-07-17 13:44:22 +00:00
else:
extra_names = []
for name in [name] + extra_names:
together = []
for specifier in sorted(
requirement.specifier,
key=lambda s: (s.operator, s.version),
):
if not VERSION_RE.fullmatch(str(specifier.version)):
raise ValueError(
f'Unknown character in version: {specifier.version}. '
+ '(This might be a bug in pyproject-rpm-macros.)',
)
together.append(convert(python3dist(name, python3_pkgversion=self.python3_pkgversion),
specifier.operator, specifier.version))
if len(together) == 0:
dep = python3dist(name, python3_pkgversion=self.python3_pkgversion)
self.output_lines.append(dep)
elif len(together) == 1:
self.output_lines.append(together[0])
else:
self.output_lines.append(f"({' with '.join(together)})")
2019-07-17 13:44:22 +00:00
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, **kwargs):
2019-07-17 13:44:22 +00:00
"""add() several requirements"""
for req_str in requirement_strs:
self.add(req_str, **kwargs)
2019-07-02 14:53:05 +00:00
def readd_ignored_alien_requirements(self, **kwargs):
"""add() previously ignored alien requirements again."""
requirements, self.ignored_alien_requirements = self.ignored_alien_requirements, []
kwargs.setdefault('source', 'Previously ignored alien requirements')
self.extend(requirements, **kwargs)
def toml_load(opened_binary_file):
2019-07-17 13:44:22 +00:00
try:
# tomllib is in the standard library since 3.11.0b1
import tomllib
except ImportError:
try:
import tomli as tomllib
except ImportError as e:
print_err('Import error:', e)
# already echoed by the %pyproject_buildrequires macro
sys.exit(0)
return tomllib.load(opened_binary_file)
def get_backend(requirements):
try:
f = open('pyproject.toml', 'rb')
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).
# If setup.py is also not present program will mimick pip's behavior
# and end with an error.
if not os.path.exists('setup.py'):
raise FileNotFoundError('File "setup.py" not found for legacy project.')
backend_name = 'setuptools.build_meta:__legacy__'
# Note: For projects without pyproject.toml, this was already echoed
# by the %pyproject_buildrequires macro, but this also handles cases
# with pyproject.toml without a specified build backend.
# If the default requirements change, also change them in the macro!
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:
# PEP 517 example shows the path as a list, but some projects don't follow that
if isinstance(backend_path, str):
backend_path = [backend_path]
sys.path = backend_path + sys.path
2019-07-17 13:44:22 +00:00
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:
new_reqs = get_requires(config_settings=requirements.config_settings)
2019-07-17 13:44:22 +00:00
requirements.extend(new_reqs, source='get_requires_for_build_wheel')
requirements.check(source='get_requires_for_build_wheel')
2019-07-02 14:53:05 +00:00
def parse_metadata_file(metadata_file):
return email.parser.Parser().parse(metadata_file, headersonly=True)
def requires_from_parsed_metadata_file(message):
return {k: message.get_all(k, ()) for k in ('Requires', 'Requires-Dist')}
def package_name_from_parsed_metadata_file(message):
return message.get('name')
def package_name_and_requires_from_metadata_file(metadata_file):
message = parse_metadata_file(metadata_file)
package_name = package_name_from_parsed_metadata_file(message)
requires = requires_from_parsed_metadata_file(message)
return package_name, requires
def generate_run_requirements_hook(backend, requirements):
hook_name = 'prepare_metadata_for_build_wheel'
prepare_metadata = getattr(backend, hook_name, None)
if not prepare_metadata:
raise ValueError(
'The build backend cannot provide build metadata '
'(incl. runtime requirements) before build. '
'Use the provisional -w flag to build the wheel and parse the metadata from it, '
'or use the -R flag not to generate runtime dependencies.'
)
dir_basename = prepare_metadata('.', config_settings=requirements.config_settings)
with open(dir_basename + '/METADATA') as metadata_file:
name, requires = package_name_and_requires_from_metadata_file(metadata_file)
for key, req in requires.items():
requirements.extend(req,
package_name=name,
source=f'hook generated metadata: {key} ({name})')
def find_built_wheel(wheeldir):
wheels = glob.glob(os.path.join(wheeldir, '*.whl'))
if not wheels:
return None
if len(wheels) > 1:
raise RuntimeError('Found multiple wheels in %{_pyproject_wheeldir}, '
'this is not supported with %pyproject_buildrequires -w.')
return wheels[0]
def generate_run_requirements_wheel(backend, requirements, wheeldir):
# Reuse the wheel from the previous round of %pyproject_buildrequires (if it exists)
wheel = find_built_wheel(wheeldir)
if not wheel:
# pip is already echoed from the macro
# but we need to explicitly restart if has not yet been installed
# see https://bugzilla.redhat.com/2169855
requirements.add('pip >= 19', source='%pyproject_buildrequires -w')
requirements.check(source='%pyproject_buildrequires -w')
import pyproject_wheel
returncode = pyproject_wheel.build_wheel(
wheeldir=wheeldir,
stdout=sys.stderr,
config_settings=requirements.config_settings,
)
if returncode != 0:
raise RuntimeError('Failed to build the wheel for %pyproject_buildrequires -w.')
wheel = find_built_wheel(wheeldir)
if not wheel:
raise RuntimeError('Cannot locate the built wheel for %pyproject_buildrequires -w.')
print_err(f'Reading metadata from {wheel}')
with zipfile.ZipFile(wheel) as wheelfile:
for name in wheelfile.namelist():
if name.count('/') == 1 and name.endswith('.dist-info/METADATA'):
with io.TextIOWrapper(wheelfile.open(name), encoding='utf-8') as metadata_file:
name, requires = package_name_and_requires_from_metadata_file(metadata_file)
for key, req in requires.items():
requirements.extend(req,
package_name=name,
source=f'built wheel metadata: {key} ({name})')
break
else:
raise RuntimeError('Could not find *.dist-info/METADATA in built wheel.')
def generate_run_requirements(backend, requirements, *, build_wheel, wheeldir):
if build_wheel:
generate_run_requirements_wheel(backend, requirements, wheeldir)
else:
generate_run_requirements_hook(backend, requirements)
def generate_tox_requirements(toxenv, requirements):
toxenv = ','.join(toxenv)
requirements.add('tox-current-env >= 0.0.6', source='tox itself')
requirements.check(source='tox itself')
with tempfile.NamedTemporaryFile('r') as deps, \
tempfile.NamedTemporaryFile('r') as extras, \
tempfile.NamedTemporaryFile('r') as provision:
r = subprocess.run(
[sys.executable, '-m', 'tox',
'--print-deps-to', deps.name,
'--print-extras-to', extras.name,
'--no-provision', provision.name,
2022-02-07 11:02:50 +00:00
'-q', '-r', '-e', 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='')
provision_content = provision.read()
if provision_content and r.returncode != 0:
provision_requires = json.loads(provision_content)
if provision_requires.get('minversion') is not None:
requirements.add(f'tox >= {provision_requires["minversion"]}',
source='tox provision (minversion)')
if 'requires' in provision_requires:
requirements.extend(provision_requires["requires"],
source='tox provision (requires)')
requirements.check(source='tox provision') # this terminates the script
raise RuntimeError(
'Dependencies requested by tox provisioning appear installed, '
'but tox disagreed.')
else:
r.check_returncode()
deplines = deps.read().splitlines()
packages = convert_requirements_txt(deplines)
requirements.add_extras(*extras.read().splitlines())
requirements.extend(packages,
source=f'tox --print-deps-only: {toxenv}')
def python3dist(name, op=None, version=None, python3_pkgversion="3"):
prefix = f"python{python3_pkgversion}dist"
2019-07-17 13:44:22 +00:00
if op is None:
if version is not None:
raise AssertionError('op and version go together')
return f'{prefix}({name})'
2019-07-17 13:44:22 +00:00
else:
return f'{prefix}({name}) {op} {version}'
2019-07-17 13:44:22 +00:00
def generate_requires(
*, include_runtime=False, build_wheel=False, wheeldir=None, toxenv=None, extras=None,
get_installed_version=importlib.metadata.version, # for dep injection
generate_extras=False, python3_pkgversion="3", requirement_files=None, use_build_system=True,
output, config_settings=None,
):
"""Generate the BuildRequires for the project in the current directory
The generated BuildRequires are written to the provided output.
This is the main Python entry point.
"""
requirements = Requirements(
get_installed_version, extras=extras or [],
generate_extras=generate_extras,
python3_pkgversion=python3_pkgversion,
config_settings=config_settings,
)
2019-07-17 13:44:22 +00:00
2019-07-02 14:53:05 +00:00
try:
if (include_runtime or toxenv) and not use_build_system:
raise ValueError('-N option cannot be used in combination with -r, -e, -t, -x options')
if requirement_files:
for req_file in requirement_files:
requirements.extend(
convert_requirements_txt(req_file, pathlib.Path(req_file.name)),
source=f'requirements file {req_file.name}'
)
requirements.check(source='all requirements files')
if use_build_system:
backend = get_backend(requirements)
generate_build_requirements(backend, requirements)
if toxenv:
include_runtime = True
generate_tox_requirements(toxenv, requirements)
if include_runtime:
generate_run_requirements(backend, requirements, build_wheel=build_wheel, wheeldir=wheeldir)
2019-07-17 13:44:22 +00:00
except EndPass:
2019-07-17 14:25:20 +00:00
return
finally:
output.write_text(os.linesep.join(requirements.output_lines) + os.linesep)
2019-07-17 13:44:22 +00:00
def main(argv):
parser = argparse.ArgumentParser(
description='Generate BuildRequires for a Python project.',
prog='%pyproject_buildrequires',
add_help=False,
)
parser.add_argument(
'--help', action='help',
default=argparse.SUPPRESS,
help=argparse.SUPPRESS,
2019-07-17 13:44:22 +00:00
)
parser.add_argument(
'-r', '--runtime', action='store_true', default=True,
help=argparse.SUPPRESS, # Generate run-time requirements (backwards-compatibility only)
)
parser.add_argument(
'--generate-extras', action='store_true',
help=argparse.SUPPRESS,
)
parser.add_argument(
'-p', '--python3_pkgversion', metavar='PYTHON3_PKGVERSION',
default="3", help=argparse.SUPPRESS,
)
parser.add_argument(
'--output', type=pathlib.Path, required=True, help=argparse.SUPPRESS,
)
parser.add_argument(
'--wheeldir', metavar='PATH', default=None,
help=argparse.SUPPRESS,
2019-07-17 13:44:22 +00:00
)
parser.add_argument(
'-x', '--extras', metavar='EXTRAS', action='append',
help='comma separated list of "extras" for runtime requirements '
'(e.g. -x testing,feature-x) (implies --runtime, can be repeated)',
)
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(
'-e', '--toxenv', metavar='TOXENVS', action='append',
help=('specify tox environments (comma separated and/or repeated)'
'(implies --tox)'),
)
parser.add_argument(
'-w', '--wheel', action='store_true', default=False,
help=('Generate run-time requirements by building the wheel '
'(useful for build backends without the prepare_metadata_for_build_wheel hook)'),
)
parser.add_argument(
'-R', '--no-runtime', action='store_false', dest='runtime',
help="Don't generate run-time requirements (implied by -N)",
)
parser.add_argument(
'-N', '--no-use-build-system', dest='use_build_system',
action='store_false', help='Use -N to indicate that project does not use any build system',
)
parser.add_argument(
'requirement_files', nargs='*', type=argparse.FileType('r'),
metavar='REQUIREMENTS.TXT',
help=('Add buildrequires from file'),
)
parser.add_argument(
'-C',
dest='config_settings',
action='append',
help='Configuration settings to pass to the PEP 517 backend',
)
2019-07-17 13:44:22 +00:00
args = parser.parse_args(argv)
if not args.use_build_system:
args.runtime = False
if args.wheel:
if not args.wheeldir:
raise ValueError('--wheeldir must be set when -w.')
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
if not args.toxenv:
_default = f'py{sys.version_info.major}{sys.version_info.minor}'
args.toxenv = [os.getenv('RPM_TOXENV', _default)]
if args.extras:
args.runtime = True
2019-07-17 13:44:22 +00:00
try:
generate_requires(
include_runtime=args.runtime,
build_wheel=args.wheel,
wheeldir=args.wheeldir,
toxenv=args.toxenv,
extras=args.extras,
generate_extras=args.generate_extras,
python3_pkgversion=args.python3_pkgversion,
requirement_files=args.requirement_files,
use_build_system=args.use_build_system,
output=args.output,
config_settings=parse_config_settings_args(args.config_settings),
)
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:])