import sys import importlib import argparse import functools import traceback import contextlib from io import StringIO import subprocess import pathlib import re import tempfile import email.parser 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""" try: import pytoml from packaging.requirements import Requirement, InvalidRequirement from packaging.version import Version from packaging.utils import canonicalize_name, canonicalize_version import pip except ImportError as e: print_err('Import error:', e) # already echoed by the %pyproject_buildrequires macro sys.exit(0) @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=''): 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} 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}') try: 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) ): 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) def get_backend(requirements): try: f = open('pyproject.toml') except FileNotFoundError: pyproject_data = {} else: 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) 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') 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}') def generate_tox_requirements(toxenv, requirements): requirements.extend(['tox-current-env >= 0.0.2'], source='tox itself') with tempfile.NamedTemporaryFile('r') as depfile: with hook_call(): subprocess.run( ['tox', '--print-deps-to-file', depfile.name, '-qre', toxenv], check=True, ) requirements.extend(depfile.read().splitlines(), source=f'tox --print-deps-only: {toxenv}') 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) try: 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) except EndPass: return def main(argv): parser = argparse.ArgumentParser( description='Generate BuildRequires for a Python project.' ) parser.add_argument( '-r', '--runtime', action='store_true', help='Generate run-time requirements', ) parser.add_argument( '-t', '--toxenv', metavar='TOXENVS', help=('generate test tequirements from tox environment ' '(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)', ) args = parser.parse_args(argv) if args.toxenv: args.runtime = True if args.extras and not args.runtime: print_err('-x (--extras) are only useful with -r (--runtime)') exit(1) freeze_output = subprocess.run( [sys.executable, '-I', '-m', 'pip', 'freeze', '--all'], encoding='utf-8', stdout=subprocess.PIPE, check=True, ).stdout try: generate_requires( freeze_output, include_runtime=args.runtime, toxenv=args.toxenv, extras=args.extras, ) except Exception as e: # Log the traceback explicitly (it's useful debug info) traceback.print_exc() exit(1) if __name__ == '__main__': main(sys.argv[1:])