summaryrefslogtreecommitdiff
path: root/pyproject_buildrequires.py
diff options
context:
space:
mode:
Diffstat (limited to 'pyproject_buildrequires.py')
-rw-r--r--pyproject_buildrequires.py550
1 files changed, 550 insertions, 0 deletions
diff --git a/pyproject_buildrequires.py b/pyproject_buildrequires.py
new file mode 100644
index 0000000..323ab2a
--- /dev/null
+++ b/pyproject_buildrequires.py
@@ -0,0 +1,550 @@
+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:])