pyproject-rpm-macros/pyproject_buildrequires.py

251 lines
8.0 KiB
Python
Raw Normal View History

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 pathlib
import re
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.-]+')
class EndPass(Exception):
"""End current pass of generating requirements"""
2019-07-02 14:53:05 +00:00
try:
import pytoml
from packaging.requirements import Requirement, InvalidRequirement
2019-07-17 13:44:22 +00:00
from packaging.version import Version
2019-07-02 14:53:05 +00:00
from packaging.utils import canonicalize_name, canonicalize_version
2019-07-17 13:44:22 +00:00
import pip
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, freeze_output, extras=''):
2019-07-17 13:44:22 +00:00
self.installed_packages = {}
for line in freeze_output.splitlines():
line = line.strip()
if line.startswith('#'):
continue
name, version = line.split('==')
self.installed_packages[name.strip()] = Version(version)
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'
+ 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
installed = self.installed_packages.get(requirement.name)
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 == "!=":
lower = python3dist(name, '<', version)
higher = python3dist(name, '>', f'{version}.0')
together.append(
f"({lower} or {higher})"
)
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 = pytoml.load(f)
buildsystem_data = pyproject_data.get("build-system", {})
requirements.extend(
buildsystem_data.get("requires", ()),
source='build-system.requires',
)
backend_name = buildsystem_data.get('build-backend')
if not backend_name:
requirements.add("setuptools >= 40.8", source='default build backend')
requirements.add("wheel", source='default build backend')
backend_name = 'setuptools.build_meta'
requirements.check(source='build backend')
backend_path = buildsystem_data.get('backend-path')
if backend_path:
sys.path.insert(0, backend_path)
return importlib.import_module(backend_name)
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)
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):
prepare_metadata = getattr(backend, "prepare_metadata_for_build_wheel", 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}')
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(
freeze_output, *, include_runtime=False, toxenv=None, extras='',
):
requirements = Requirements(freeze_output, 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 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(
'-t', '--toxenv', metavar='TOXENVS',
2019-07-17 13:44:22 +00:00
help='generate test tequirements from tox environment '
+ '(not implemented; implies --runtime)',
)
parser.add_argument(
'-x', '--extras', metavar='EXTRAS', default='',
help='extra for runtime requirements (e.g. -x testing)',
# 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)
if args.toxenv:
args.runtime = True
2019-07-18 07:24:02 +00:00
print_err('-t (--toxenv) is not implemented')
exit(1)
if args.extras and not args.runtime:
print_err('-x (--extras) are only useful with -r (--runtime)')
2019-07-17 13:44:22 +00:00
exit(1)
freeze_output = subprocess.run(
[sys.executable, '-I', '-m', 'pip', 'freeze', '--all'],
encoding='utf-8',
2019-07-17 13:44:22 +00:00
stdout=subprocess.PIPE,
check=True,
).stdout
try:
generate_requires(
freeze_output,
include_runtime=args.runtime,
extras=args.extras,
)
2019-07-17 13:44:22 +00:00
except Exception as e:
# 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:])