fda0a23
import os
fdf5116
import sys
e6c1981
import importlib
50645e1
import argparse
50645e1
import functools
50645e1
import traceback
50645e1
import contextlib
50645e1
from io import StringIO
50645e1
import subprocess
50645e1
import re
7ba1a33
import tempfile
bc156c4
import email.parser
50645e1
50645e1
print_err = functools.partial(print, file=sys.stderr)
50645e1
50645e1
# Some valid Python version specifiers are not supported.
50645e1
# Whitelist characters we can handle.
50645e1
VERSION_RE = re.compile('[a-zA-Z0-9.-]+')
50645e1
50464a4
50645e1
class EndPass(Exception):
50645e1
    """End current pass of generating requirements"""
fdf5116
50464a4
fdf5116
try:
ed5dd77
    import toml
fdf5116
    from packaging.requirements import Requirement, InvalidRequirement
fdf5116
    from packaging.utils import canonicalize_name, canonicalize_version
d262d90
    try:
d262d90
        import importlib.metadata as importlib_metadata
d262d90
    except ImportError:
d262d90
        import importlib_metadata
50645e1
except ImportError as e:
50645e1
    print_err('Import error:', e)
fdf5116
    # already echoed by the %pyproject_buildrequires macro
fdf5116
    sys.exit(0)
fdf5116
fdf5116
50645e1
@contextlib.contextmanager
50645e1
def hook_call():
50645e1
    captured_out = StringIO()
50645e1
    with contextlib.redirect_stdout(captured_out):
50645e1
        yield
50645e1
    for line in captured_out.getvalue().splitlines():
50645e1
        print_err('HOOK STDOUT:', line)
50645e1
50645e1
50645e1
class Requirements:
50645e1
    """Requirement printer"""
5561755
    def __init__(self, get_installed_version, extras='',
5561755
                 python3_pkgversion='3'):
d262d90
        self.get_installed_version = get_installed_version
50645e1
6a8d86e
        if extras:
6a8d86e
            self.marker_envs = [{'extra': e.strip()} for e in extras.split(',')]
6a8d86e
        else:
6a8d86e
            self.marker_envs = [{'extra': ''}]
d6e6bb7
50645e1
        self.missing_requirements = False
50645e1
5561755
        self.python3_pkgversion = python3_pkgversion
5561755
6a8d86e
    def evaluate_all_environamnets(self, requirement):
6a8d86e
        for marker_env in self.marker_envs:
6a8d86e
            if requirement.marker.evaluate(environment=marker_env):
6a8d86e
                return True
6a8d86e
        return False
6a8d86e
50645e1
    def add(self, requirement_str, *, source=None):
50645e1
        """Output a Python-style requirement string as RPM dep"""
50645e1
        print_err(f'Handling {requirement_str} from {source}')
fdf5116
fdf5116
        try:
50645e1
            requirement = Requirement(requirement_str)
50645e1
        except InvalidRequirement as e:
50645e1
            print_err(
50464a4
                f'WARNING: Skipping invalid requirement: {requirement_str}\n'
50645e1
                + f'    {e}',
50645e1
            )
50645e1
            return
50645e1
50645e1
        name = canonicalize_name(requirement.name)
50464a4
        if (requirement.marker is not None and
6a8d86e
                not self.evaluate_all_environamnets(requirement)):
50645e1
            print_err(f'Ignoring alien requirement:', requirement_str)
50645e1
            return
50645e1
d262d90
        try:
d262d90
            installed = self.get_installed_version(requirement.name)
d262d90
        except importlib_metadata.PackageNotFoundError:
d262d90
            print_err(f'Requirement not satisfied: {requirement_str}')
d262d90
            installed = None
50645e1
        if installed and installed in requirement.specifier:
50645e1
            print_err(f'Requirement satisfied: {requirement_str}')
50645e1
            print_err(f'   (installed: {requirement.name} {installed})')
50645e1
        else:
50645e1
            self.missing_requirements = True
50645e1
50645e1
        together = []
50645e1
        for specifier in sorted(
50645e1
            requirement.specifier,
50645e1
            key=lambda s: (s.operator, s.version),
50645e1
        ):
50645e1
            version = canonicalize_version(specifier.version)
50645e1
            if not VERSION_RE.fullmatch(str(specifier.version)):
50645e1
                raise ValueError(
50645e1
                    f'Unknown character in version: {specifier.version}. '
50645e1
                    + '(This is probably a bug in pyproject-rpm-macros.)',
50645e1
                )
50464a4
            if specifier.operator == '!=':
5561755
                lower = python3dist(name, '<', version,
5561755
                                    self.python3_pkgversion)
5561755
                higher = python3dist(name, '>', f'{version}.0',
5561755
                                     self.python3_pkgversion)
50645e1
                together.append(
50464a4
                    f'({lower} or {higher})'
50645e1
                )
50645e1
            else:
5561755
                together.append(python3dist(name, specifier.operator, version,
5561755
                                            self.python3_pkgversion))
50645e1
        if len(together) == 0:
5561755
            print(python3dist(name,
5561755
                              python3_pkgversion=self.python3_pkgversion))
50645e1
        elif len(together) == 1:
50645e1
            print(together[0])
50645e1
        else:
50645e1
            print(f"({' and '.join(together)})")
50645e1
50645e1
    def check(self, *, source=None):
50645e1
        """End current pass if any unsatisfied dependencies were output"""
50645e1
        if self.missing_requirements:
50645e1
            print_err(f'Exiting dependency generation pass: {source}')
50645e1
            raise EndPass(source)
50645e1
50645e1
    def extend(self, requirement_strs, *, source=None):
50645e1
        """add() several requirements"""
50645e1
        for req_str in requirement_strs:
50645e1
            self.add(req_str, source=source)
fdf5116
50464a4
50645e1
def get_backend(requirements):
50645e1
    try:
50645e1
        f = open('pyproject.toml')
50645e1
    except FileNotFoundError:
50645e1
        pyproject_data = {}
e6c1981
    else:
50645e1
        with f:
ed5dd77
            pyproject_data = toml.load(f)
50645e1
50464a4
    buildsystem_data = pyproject_data.get('build-system', {})
50645e1
    requirements.extend(
50464a4
        buildsystem_data.get('requires', ()),
50645e1
        source='build-system.requires',
50645e1
    )
50645e1
50645e1
    backend_name = buildsystem_data.get('build-backend')
50645e1
    if not backend_name:
6210f94
        # https://www.python.org/dev/peps/pep-0517/:
6210f94
        # If the pyproject.toml file is absent, or the build-backend key is
6210f94
        # missing, the source tree is not using this specification, and tools
6210f94
        # should revert to the legacy behaviour of running setup.py
6210f94
        # (either directly, or by implicitly invoking the [following] backend).
6210f94
        backend_name = 'setuptools.build_meta:__legacy__'
6210f94
50464a4
        requirements.add('setuptools >= 40.8', source='default build backend')
50464a4
        requirements.add('wheel', source='default build backend')
50645e1
50645e1
    requirements.check(source='build backend')
50645e1
50645e1
    backend_path = buildsystem_data.get('backend-path')
50645e1
    if backend_path:
50645e1
        sys.path.insert(0, backend_path)
50645e1
6210f94
    module_name, _, object_name = backend_name.partition(":")
6210f94
    backend_module = importlib.import_module(module_name)
6210f94
6210f94
    if object_name:
6210f94
        return getattr(backend_module, object_name)
6210f94
6210f94
    return backend_module
fdf5116
fdf5116
50645e1
def generate_build_requirements(backend, requirements):
50464a4
    get_requires = getattr(backend, 'get_requires_for_build_wheel', None)
50645e1
    if get_requires:
50645e1
        with hook_call():
50645e1
            new_reqs = get_requires()
50645e1
        requirements.extend(new_reqs, source='get_requires_for_build_wheel')
fdf5116
fdf5116
bc156c4
def generate_run_requirements(backend, requirements):
50464a4
    hook_name = 'prepare_metadata_for_build_wheel'
50464a4
    prepare_metadata = getattr(backend, hook_name, None)
bc156c4
    if not prepare_metadata:
bc156c4
        raise ValueError(
bc156c4
            'build backend cannot provide build metadata '
bc156c4
            + '(incl. runtime requirements) before buld'
bc156c4
        )
bc156c4
    with hook_call():
bc156c4
        dir_basename = prepare_metadata('.')
bc156c4
    with open(dir_basename + '/METADATA') as f:
bc156c4
        message = email.parser.Parser().parse(f, headersonly=True)
bc156c4
    for key in 'Requires', 'Requires-Dist':
bc156c4
        requires = message.get_all(key, ())
bc156c4
        requirements.extend(requires, source=f'wheel metadata: {key}')
bc156c4
bc156c4
99d952c
def parse_tox_requires_lines(lines):
99d952c
    packages = []
99d952c
    for line in lines:
99d952c
        line = line.strip()
99d952c
        if line.startswith('-r'):
99d952c
            path = line[2:]
99d952c
            with open(path) as f:
99d952c
                packages.extend(parse_tox_requires_lines(f.read().splitlines()))
99d952c
        elif line.startswith('-'):
99d952c
            print_err(
99d952c
                f'WARNING: Skipping dependency line: {line}\n'
99d952c
                + f'    tox deps options other than -r are not supported (yet).',
99d952c
            )
99d952c
        else:
99d952c
            packages.append(line)
99d952c
    return packages
99d952c
99d952c
8a60635
def generate_tox_requirements(toxenv, requirements):
fda0a23
    requirements.add('tox-current-env >= 0.0.2', source='tox itself')
fda0a23
    requirements.check(source='tox itself')
7ba1a33
    with tempfile.NamedTemporaryFile('r') as depfile:
fda0a23
        r = subprocess.run(
cb8e334
            [sys.executable, '-m', 'tox', '--print-deps-to-file',
cb8e334
             depfile.name, '-qre', toxenv],
d5c3fb3
            check=False,
fda0a23
            encoding='utf-8',
fda0a23
            stdout=subprocess.PIPE,
fda0a23
            stderr=subprocess.STDOUT,
fda0a23
        )
fda0a23
        if r.stdout:
d5c3fb3
            print_err(r.stdout, end='')
d5c3fb3
        r.check_returncode()
99d952c
99d952c
        deplines = depfile.read().splitlines()
99d952c
        packages = parse_tox_requires_lines(deplines)
99d952c
        requirements.extend(packages,
7ba1a33
                            source=f'tox --print-deps-only: {toxenv}')
8a60635
8a60635
5561755
def python3dist(name, op=None, version=None, python3_pkgversion="3"):
5561755
    prefix = f"python{python3_pkgversion}dist"
5561755
50645e1
    if op is None:
50645e1
        if version is not None:
50645e1
            raise AssertionError('op and version go together')
5561755
        return f'{prefix}({name})'
50645e1
    else:
5561755
        return f'{prefix}({name}) {op} {version}'
50645e1
50645e1
d6e6bb7
def generate_requires(
d262d90
    *, include_runtime=False, toxenv=None, extras='',
d262d90
    get_installed_version=importlib_metadata.version,  # for dep injection
5561755
    python3_pkgversion="3",
d6e6bb7
):
d262d90
    """Generate the BuildRequires for the project in the current directory
d262d90
d262d90
    This is the main Python entry point.
d262d90
    """
5561755
    requirements = Requirements(
5561755
        get_installed_version, extras=extras,
5561755
        python3_pkgversion=python3_pkgversion
5561755
    )
50645e1
fdf5116
    try:
50645e1
        backend = get_backend(requirements)
50645e1
        generate_build_requirements(backend, requirements)
8a60635
        if toxenv is not None:
8a60635
            include_runtime = True
8a60635
            generate_tox_requirements(toxenv, requirements)
bc156c4
        if include_runtime:
bc156c4
            generate_run_requirements(backend, requirements)
50645e1
    except EndPass:
5abd40f
        return
50645e1
50645e1
50645e1
def main(argv):
50645e1
    parser = argparse.ArgumentParser(
50645e1
        description='Generate BuildRequires for a Python project.'
50645e1
    )
50645e1
    parser.add_argument(
bc156c4
        '-r', '--runtime', action='store_true',
3600e98
        help='Generate run-time requirements',
50645e1
    )
50645e1
    parser.add_argument(
fda0a23
        '-e', '--toxenv', metavar='TOXENVS', default=None,
fda0a23
        help=('specify tox environments'
fda0a23
              '(implies --tox)'),
fda0a23
    )
fda0a23
    parser.add_argument(
fda0a23
        '-t', '--tox', action='store_true',
8a60635
        help=('generate test tequirements from tox environment '
8a60635
              '(implies --runtime)'),
50645e1
    )
d6e6bb7
    parser.add_argument(
d6e6bb7
        '-x', '--extras', metavar='EXTRAS', default='',
6a8d86e
        help='comma separated list of "extras" for runtime requirements '
6a8d86e
             '(e.g. -x testing,feature-x) (implies --runtime)',
d6e6bb7
    )
5561755
    parser.add_argument(
5561755
        '-p', '--python3_pkgversion', metavar='PYTHON3_PKGVERSION',
5561755
        default="3", help=('Python version for pythonXdist()'
5561755
                           'or pythonX.Ydist() requirements'),
5561755
    )
50645e1
50645e1
    args = parser.parse_args(argv)
fda0a23
c975fbe
    if args.toxenv:
fda0a23
        args.tox = True
fda0a23
fda0a23
    if args.tox:
c975fbe
        args.runtime = True
fda0a23
        args.toxenv = (args.toxenv or os.getenv('RPM_TOXENV') or
fda0a23
                       f'py{sys.version_info.major}{sys.version_info.minor}')
fda0a23
262f6d3
    if args.extras:
262f6d3
        args.runtime = True
50645e1
50645e1
    try:
d6e6bb7
        generate_requires(
d6e6bb7
            include_runtime=args.runtime,
8a60635
            toxenv=args.toxenv,
d6e6bb7
            extras=args.extras,
5561755
            python3_pkgversion=args.python3_pkgversion,
d6e6bb7
        )
fda0a23
    except Exception:
50645e1
        # Log the traceback explicitly (it's useful debug info)
50645e1
        traceback.print_exc()
50645e1
        exit(1)
fdf5116
fdf5116
50645e1
if __name__ == '__main__':
50645e1
    main(sys.argv[1:])