diff options
Diffstat (limited to 'pyproject_buildrequires.py')
-rw-r--r-- | pyproject_buildrequires.py | 550 |
1 files changed, 0 insertions, 550 deletions
diff --git a/pyproject_buildrequires.py b/pyproject_buildrequires.py deleted file mode 100644 index 323ab2a..0000000 --- a/pyproject_buildrequires.py +++ /dev/null @@ -1,550 +0,0 @@ -import glob -import io -import os -import sys -import importlib.metadata -import argparse -import tempfile -import traceback -import contextlib -import json -import subprocess -import re -import tempfile -import email.parser -import pathlib -import zipfile - -from pyproject_requirements_txt import convert_requirements_txt - - -# 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.-]+(\.\*)?') - - -class EndPass(Exception): - """End current pass of generating requirements""" - - -# 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) - - -try: - from packaging.requirements import Requirement, InvalidRequirement - from packaging.utils import canonicalize_name -except ImportError as e: - print_err('Import error:', e) - # 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 - - -@contextlib.contextmanager -def hook_call(): - """Context manager that records all stdout content (on FD level) - and prints it to stderr at the end, with a 'HOOK STDOUT: ' prefix.""" - tmpfile = io.TextIOWrapper( - tempfile.TemporaryFile(buffering=0), - encoding='utf-8', - errors='replace', - write_through=True, - ) - - stdout_fd = 1 - stdout_fd_dup = os.dup(stdout_fd) - stdout_orig = sys.stdout - - # begin capture - sys.stdout = tmpfile - os.dup2(tmpfile.fileno(), stdout_fd) - - try: - yield - finally: - # end capture - sys.stdout = stdout_orig - os.dup2(stdout_fd_dup, stdout_fd) - - tmpfile.seek(0) # rewind - for line in tmpfile: - print_err('HOOK STDOUT:', line, end='') - - tmpfile.close() - - -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 - - -class Requirements: - """Requirement printer""" - def __init__(self, get_installed_version, extras=None, - generate_extras=False, python3_pkgversion='3'): - self.get_installed_version = get_installed_version - self.extras = set() - - if extras: - for extra in extras: - self.add_extras(*extra.split(',')) - - self.missing_requirements = False - - self.generate_extras = generate_extras - self.python3_pkgversion = python3_pkgversion - - 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, *, 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: - 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: - print_err( - f'WARNING: Simplifying {requirement_str!r} to {requirement.name!r}.' - ) - - name = canonicalize_name(requirement.name) - if (requirement.marker is not None and - not self.evaluate_all_environments(requirement)): - print_err(f'Ignoring alien requirement:', requirement_str) - 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 - 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)') - else: - self.missing_requirements = True - - if self.generate_extras: - extra_names = [f'{name}[{extra.lower()}]' for extra in sorted(requirement.extras)] - 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: - print(python3dist(name, - python3_pkgversion=self.python3_pkgversion)) - elif len(together) == 1: - print(together[0]) - else: - print(f"({' with '.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, **kwargs): - """add() several requirements""" - for req_str in requirement_strs: - self.add(req_str, **kwargs) - - -def toml_load(opened_binary_file): - try: - # tomllib is in the standard library since 3.11.0b1 - import tomllib as toml_module - load_from = opened_binary_file - except ImportError: - try: - # note: we could use tomli here, - # but for backwards compatibility with RHEL 9, we use toml instead - import toml as toml_module - load_from = io.TextIOWrapper(opened_binary_file, encoding='utf-8') - except ImportError as e: - print_err('Import error:', e) - # already echoed by the %pyproject_buildrequires macro - sys.exit(0) - return toml_module.load(load_from) - - -def get_backend(requirements): - try: - f = open('pyproject.toml', 'rb') - except FileNotFoundError: - pyproject_data = {} - else: - with f: - pyproject_data = toml_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: - # 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') - - 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 - - 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 - - -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') - requirements.check(source='get_requires_for_build_wheel') - - -def requires_from_metadata_file(metadata_file): - message = email.parser.Parser().parse(metadata_file, headersonly=True) - return {k: message.get_all(k, ()) for k in ('Requires', 'Requires-Dist')} - - -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.' - ) - with hook_call(): - dir_basename = prepare_metadata('.') - with open(dir_basename + '/METADATA') as metadata_file: - for key, requires in requires_from_metadata_file(metadata_file).items(): - requirements.extend(requires, source=f'hook generated metadata: {key}') - - -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: - import pyproject_wheel - returncode = pyproject_wheel.build_wheel(wheeldir=wheeldir, stdout=sys.stderr) - 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: - for key, requires in requires_from_metadata_file(metadata_file).items(): - requirements.extend(requires, source=f'built wheel metadata: {key}') - 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, - '-q', '-r', '-e', toxenv], - check=False, - encoding='utf-8', - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - ) - if r.stdout: - print_err(r.stdout, end='') - - provision_content = provision.read() - if provision_content and r.returncode != 0: - provision_requires = json.loads(provision_content) - if 'minversion' in provision_requires: - 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" - - if op is None: - if version is not None: - raise AssertionError('op and version go together') - return f'{prefix}({name})' - else: - return f'{prefix}({name}) {op} {version}' - - -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 -): - """Generate the BuildRequires for the project in the current directory - - This is the main Python entry point. - """ - requirements = Requirements( - get_installed_version, extras=extras or [], - generate_extras=generate_extras, - python3_pkgversion=python3_pkgversion - ) - - 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) - except EndPass: - return - - -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, - ) - 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( - '--wheeldir', metavar='PATH', default=None, - help=argparse.SUPPRESS, - ) - 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)'), - ) - 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'), - ) - - 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.') - - if args.toxenv: - args.tox = True - - if args.tox: - 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 - - 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, - ) - except Exception: - # Log the traceback explicitly (it's useful debug info) - traceback.print_exc() - exit(1) - - -if __name__ == '__main__': - main(sys.argv[1:]) |