pyproject-rpm-macros/pyproject_buildrequires.py

213 lines
6.5 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
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):
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.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():
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)
print_err(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
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):
requirements = Requirements(freeze_output)
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)
except EndPass:
return 0
def main(argv):
parser = argparse.ArgumentParser(
description='Generate BuildRequires for a Python project.'
)
parser.add_argument(
'--runtime', action='store_true',
help='Generate run-time requirements (not implemented)',
)
parser.add_argument(
'--toxenv', metavar='TOXENVS',
help='generate test tequirements from tox environment '
+ '(not implemented; implies --runtime)',
)
args = parser.parse_args(argv)
if args.toxenv:
args.runtime = True
if args.runtime:
print_err('--runtime is not implemented')
exit(1)
freeze_output = subprocess.run(
['pip', 'freeze', '--all'],
stdout=subprocess.PIPE,
check=True,
).stdout
try:
generate_requires(freeze_output)
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:])