1#!/usr/local/bin/python3.8
2# -*- coding: utf-8 -*-
3#
4# Copyright 2010 Per Øyvind Karlsen <proyvind@moondrake.org>
5# Copyright 2015 Neal Gompa <ngompa13@gmail.com>
6# Copyright 2020 SUSE LLC
7#
8# This program is free software. It may be redistributed and/or modified under
9# the terms of the LGPL version 2.1 (or later).
10#
11# RPM python dependency generator, using .egg-info/.egg-link/.dist-info data
12#
13
14# Please know:
15# - Notes from an attempted rewrite from pkg_resources to importlib.metadata in
16#   2020 can be found in the message of the commit that added this line.
17
18from __future__ import print_function
19import argparse
20from os.path import basename, dirname, isdir, sep
21from sys import argv, stdin, version
22from distutils.sysconfig import get_python_lib
23from warnings import warn
24
25
26class RpmVersion():
27    def __init__(self, version_id):
28        version = parse_version(version_id)
29        if isinstance(version._version, str):
30            self.version = version._version
31        else:
32            self.epoch = version._version.epoch
33            self.version = list(version._version.release)
34            self.pre = version._version.pre
35            self.dev = version._version.dev
36            self.post = version._version.post
37
38    def increment(self):
39        self.version[-1] += 1
40        self.pre = None
41        self.dev = None
42        self.post = None
43        return self
44
45    def __str__(self):
46        if isinstance(self.version, str):
47            return self.version
48        if self.epoch:
49            rpm_epoch = str(self.epoch) + ':'
50        else:
51            rpm_epoch = ''
52        while len(self.version) > 1 and self.version[-1] == 0:
53            self.version.pop()
54        rpm_version = '.'.join(str(x) for x in self.version)
55        if self.pre:
56            rpm_suffix = '~{}'.format(''.join(str(x) for x in self.pre))
57        elif self.dev:
58            rpm_suffix = '~~{}'.format(''.join(str(x) for x in self.dev))
59        elif self.post:
60            rpm_suffix = '^post{}'.format(self.post[1])
61        else:
62            rpm_suffix = ''
63        return '{}{}{}'.format(rpm_epoch, rpm_version, rpm_suffix)
64
65
66def convert_compatible(name, operator, version_id):
67    if version_id.endswith('.*'):
68        print('Invalid requirement: {} {} {}'.format(name, operator, version_id))
69        exit(65)  # os.EX_DATAERR
70    version = RpmVersion(version_id)
71    if len(version.version) == 1:
72        print('Invalid requirement: {} {} {}'.format(name, operator, version_id))
73        exit(65)  # os.EX_DATAERR
74    upper_version = RpmVersion(version_id)
75    upper_version.version.pop()
76    upper_version.increment()
77    return '({} >= {} with {} < {})'.format(
78        name, version, name, upper_version)
79
80
81def convert_equal(name, operator, version_id):
82    if version_id.endswith('.*'):
83        version_id = version_id[:-2] + '.0'
84        return convert_compatible(name, '~=', version_id)
85    version = RpmVersion(version_id)
86    return '{} = {}'.format(name, version)
87
88
89def convert_arbitrary_equal(name, operator, version_id):
90    if version_id.endswith('.*'):
91        print('Invalid requirement: {} {} {}'.format(name, operator, version_id))
92        exit(65)  # os.EX_DATAERR
93    version = RpmVersion(version_id)
94    return '{} = {}'.format(name, version)
95
96
97def convert_not_equal(name, operator, version_id):
98    if version_id.endswith('.*'):
99        version_id = version_id[:-2]
100        version = RpmVersion(version_id)
101        lower_version = RpmVersion(version_id).increment()
102    else:
103        version = RpmVersion(version_id)
104        lower_version = version
105    return '({} < {} or {} > {})'.format(
106        name, version, name, lower_version)
107
108
109def convert_ordered(name, operator, version_id):
110    if version_id.endswith('.*'):
111        # PEP 440 does not define semantics for prefix matching
112        # with ordered comparisons
113        version_id = version_id[:-2]
114        version = RpmVersion(version_id)
115        if operator == '>':
116            # distutils will allow a prefix match with '>'
117            operator = '>='
118        if operator == '<=':
119            # distutils will not allow a prefix match with '<='
120            operator = '<'
121    else:
122        version = RpmVersion(version_id)
123    return '{} {} {}'.format(name, operator, version)
124
125
126OPERATORS = {'~=': convert_compatible,
127             '==': convert_equal,
128             '===': convert_arbitrary_equal,
129             '!=': convert_not_equal,
130             '<=': convert_ordered,
131             '<': convert_ordered,
132             '>=': convert_ordered,
133             '>': convert_ordered}
134
135
136def convert(name, operator, version_id):
137    try:
138        return OPERATORS[operator](name, operator, version_id)
139    except Exception as exc:
140        raise RuntimeError("Cannot process Python package version `{}` for name `{}`".
141                           format(version_id, name)) from exc
142
143
144def normalize_name(name):
145    """https://www.python.org/dev/peps/pep-0503/#normalized-names"""
146    import re
147    return re.sub(r'[-_.]+', '-', name).lower()
148
149
150if __name__ == "__main__":
151    """To allow this script to be importable (and its classes/functions
152       reused), actions are performed only when run as a main script."""
153
154    parser = argparse.ArgumentParser(prog=argv[0])
155    group = parser.add_mutually_exclusive_group(required=True)
156    group.add_argument('-P', '--provides', action='store_true', help='Print Provides')
157    group.add_argument('-R', '--requires', action='store_true', help='Print Requires')
158    group.add_argument('-r', '--recommends', action='store_true', help='Print Recommends')
159    group.add_argument('-C', '--conflicts', action='store_true', help='Print Conflicts')
160    group.add_argument('-E', '--extras', action='store_true', help='Print Extras')
161    group_majorver = parser.add_mutually_exclusive_group()
162    group_majorver.add_argument('-M', '--majorver-provides', action='store_true', help='Print extra Provides with Python major version only')
163    group_majorver.add_argument('--majorver-provides-versions', action='append',
164                                help='Print extra Provides with Python major version only for listed '
165                                     'Python VERSIONS (appended or comma separated without spaces, e.g. 2.7,3.9)')
166    parser.add_argument('-m', '--majorver-only', action='store_true', help='Print Provides/Requires with Python major version only')
167    parser.add_argument('-n', '--normalized-names-format', action='store',
168                        default="legacy-dots", choices=["pep503", "legacy-dots"],
169                        help='Format of normalized names according to pep503 or legacy format that allows dots [default]')
170    parser.add_argument('--normalized-names-provide-both', action='store_true',
171                        help='Provide both `pep503` and `legacy-dots` format of normalized names (useful for a transition period)')
172    parser.add_argument('-L', '--legacy-provides', action='store_true', help='Print extra legacy pythonegg Provides')
173    parser.add_argument('-l', '--legacy', action='store_true', help='Print legacy pythonegg Provides/Requires instead')
174    parser.add_argument('files', nargs=argparse.REMAINDER)
175    args = parser.parse_args()
176
177    py_abi = args.requires
178    py_deps = {}
179
180    if args.majorver_provides_versions:
181        # Go through the arguments (can be specified multiple times),
182        # and parse individual versions (can be comma-separated)
183        args.majorver_provides_versions = [v for vstring in args.majorver_provides_versions
184                                             for v in vstring.split(",")]
185
186    # If normalized_names_require_pep503 is True we require the pep503
187    # normalized name, if it is False we provide the legacy normalized name
188    normalized_names_require_pep503 = args.normalized_names_format == "pep503"
189
190    # If normalized_names_provide_pep503/legacy is True we provide the
191    #   pep503/legacy normalized name, if it is False we don't
192    normalized_names_provide_pep503 = \
193        args.normalized_names_format == "pep503" or args.normalized_names_provide_both
194    normalized_names_provide_legacy = \
195        args.normalized_names_format == "legacy-dots" or args.normalized_names_provide_both
196
197    # At least one type of normalization must be provided
198    assert normalized_names_provide_pep503 or normalized_names_provide_legacy
199
200    for f in (args.files or stdin.readlines()):
201        f = f.strip()
202        lower = f.lower()
203        name = 'python(abi)'
204        # add dependency based on path, versioned if within versioned python directory
205        if py_abi and (lower.endswith('.py') or lower.endswith('.pyc') or lower.endswith('.pyo')):
206            if name not in py_deps:
207                py_deps[name] = []
208            purelib = get_python_lib(standard_lib=0, plat_specific=0).split(version[:3])[0]
209            platlib = get_python_lib(standard_lib=0, plat_specific=1).split(version[:3])[0]
210            for lib in (purelib, platlib):
211                if lib in f:
212                    spec = ('==', f.split(lib)[1].split(sep)[0])
213                    if spec not in py_deps[name]:
214                        py_deps[name].append(spec)
215
216        # XXX: hack to workaround RPM internal dependency generator not passing directories
217        lower_dir = dirname(lower)
218        if lower_dir.endswith('.egg') or \
219                lower_dir.endswith('.egg-info') or \
220                lower_dir.endswith('.dist-info'):
221            lower = lower_dir
222            f = dirname(f)
223        # Determine provide, requires, conflicts & recommends based on egg/dist metadata
224        if lower.endswith('.egg') or \
225                lower.endswith('.egg-info') or \
226                lower.endswith('.dist-info'):
227            # This import is very slow, so only do it if needed
228            # - Notes from an attempted rewrite from pkg_resources to
229            #   importlib.metadata in 2020 can be found in the message of
230            #   the commit that added this line.
231            from pkg_resources import Distribution, FileMetadata, PathMetadata, Requirement, parse_version
232            dist_name = basename(f)
233            if isdir(f):
234                path_item = dirname(f)
235                metadata = PathMetadata(path_item, f)
236            else:
237                path_item = f
238                metadata = FileMetadata(f)
239            dist = Distribution.from_location(path_item, dist_name, metadata)
240            # Check if py_version is defined in the metadata file/directory name
241            if not dist.py_version:
242                # Try to parse the Python version from the path the metadata
243                # resides at (e.g. /usr/lib/pythonX.Y/site-packages/...)
244                import re
245                res = re.search(r"/python(?P<pyver>\d+\.\d+)/", path_item)
246                if res:
247                    dist.py_version = res.group('pyver')
248                else:
249                    warn("Version for {!r} has not been found".format(dist), RuntimeWarning)
250                    continue
251
252            # pkg_resources use platform.python_version to evaluate if a
253            # dependency is relevant based on environment markers [1],
254            # e.g. requirement `argparse;python_version<"2.7"`
255            #
256            # Since we're running this script on one Python version while
257            # possibly evaluating packages for different versions, we mock the
258            # platform.python_version function. Discussed upstream [2].
259            #
260            # [1] https://www.python.org/dev/peps/pep-0508/#environment-markers
261            # [2] https://github.com/pypa/setuptools/pull/1275
262            import platform
263            platform.python_version = lambda: dist.py_version
264            platform.python_version_tuple = lambda: tuple(dist.py_version.split('.'))
265
266            # This is the PEP 503 normalized name.
267            # It does also convert dots to dashes, unlike dist.key.
268            # See https://bugzilla.redhat.com/show_bug.cgi?id=1791530
269            normalized_name = normalize_name(dist.project_name)
270
271            if args.majorver_provides or args.majorver_provides_versions or \
272                    args.majorver_only or args.legacy_provides or args.legacy:
273                # Get the Python major version
274                pyver_major = dist.py_version.split('.')[0]
275            if args.provides:
276                # If egg/dist metadata says package name is python, we provide python(abi)
277                if dist.key == 'python':
278                    name = 'python(abi)'
279                    if name not in py_deps:
280                        py_deps[name] = []
281                    py_deps[name].append(('==', dist.py_version))
282                if not args.legacy or not args.majorver_only:
283                    if normalized_names_provide_legacy:
284                        name = 'python{}dist({})'.format(dist.py_version, dist.key)
285                        if name not in py_deps:
286                            py_deps[name] = []
287                    if normalized_names_provide_pep503:
288                        name_ = 'python{}dist({})'.format(dist.py_version, normalized_name)
289                        if name_ not in py_deps:
290                            py_deps[name_] = []
291                if args.majorver_provides or args.majorver_only or \
292                        (args.majorver_provides_versions and dist.py_version in args.majorver_provides_versions):
293                    if normalized_names_provide_legacy:
294                        pymajor_name = 'python{}dist({})'.format(pyver_major, dist.key)
295                        if pymajor_name not in py_deps:
296                            py_deps[pymajor_name] = []
297                    if normalized_names_provide_pep503:
298                        pymajor_name_ = 'python{}dist({})'.format(pyver_major, normalized_name)
299                        if pymajor_name_ not in py_deps:
300                            py_deps[pymajor_name_] = []
301                if args.legacy or args.legacy_provides:
302                    legacy_name = 'pythonegg({})({})'.format(pyver_major, dist.key)
303                    if legacy_name not in py_deps:
304                        py_deps[legacy_name] = []
305                if dist.version:
306                    version = dist.version
307                    spec = ('==', version)
308
309                    if normalized_names_provide_legacy:
310                        if spec not in py_deps[name]:
311                            py_deps[name].append(spec)
312                            if args.majorver_provides or \
313                                    (args.majorver_provides_versions and dist.py_version in args.majorver_provides_versions):
314                                py_deps[pymajor_name].append(spec)
315                    if normalized_names_provide_pep503:
316                        if spec not in py_deps[name_]:
317                            py_deps[name_].append(spec)
318                            if args.majorver_provides or \
319                                    (args.majorver_provides_versions and dist.py_version in args.majorver_provides_versions):
320                                py_deps[pymajor_name_].append(spec)
321                    if args.legacy or args.legacy_provides:
322                        if spec not in py_deps[legacy_name]:
323                            py_deps[legacy_name].append(spec)
324            if args.requires or (args.recommends and dist.extras):
325                name = 'python(abi)'
326                # If egg/dist metadata says package name is python, we don't add dependency on python(abi)
327                if dist.key == 'python':
328                    py_abi = False
329                    if name in py_deps:
330                        py_deps.pop(name)
331                elif py_abi and dist.py_version:
332                    if name not in py_deps:
333                        py_deps[name] = []
334                    spec = ('==', dist.py_version)
335                    if spec not in py_deps[name]:
336                        py_deps[name].append(spec)
337                deps = dist.requires()
338                if args.recommends:
339                    depsextras = dist.requires(extras=dist.extras)
340                    if not args.requires:
341                        for dep in reversed(depsextras):
342                            if dep in deps:
343                                depsextras.remove(dep)
344                    deps = depsextras
345                # console_scripts/gui_scripts entry points need pkg_resources from setuptools
346                if ((dist.get_entry_map('console_scripts') or
347                    dist.get_entry_map('gui_scripts')) and
348                    (lower.endswith('.egg') or
349                     lower.endswith('.egg-info'))):
350                    # stick them first so any more specific requirement overrides it
351                    deps.insert(0, Requirement.parse('setuptools'))
352                # add requires/recommends based on egg/dist metadata
353                for dep in deps:
354                    if normalized_names_require_pep503:
355                        dep_normalized_name = normalize_name(dep.project_name)
356                    else:
357                        dep_normalized_name = dep.key
358
359                    if args.legacy:
360                        name = 'pythonegg({})({})'.format(pyver_major, dep.key)
361                    else:
362                        if args.majorver_only:
363                            name = 'python{}dist({})'.format(pyver_major, dep_normalized_name)
364                        else:
365                            name = 'python{}dist({})'.format(dist.py_version, dep_normalized_name)
366                    for spec in dep.specs:
367                        if name not in py_deps:
368                            py_deps[name] = []
369                        if spec not in py_deps[name]:
370                            py_deps[name].append(spec)
371                    if not dep.specs:
372                        py_deps[name] = []
373            # Unused, for automatic sub-package generation based on 'extras' from egg/dist metadata
374            # TODO: implement in rpm later, or...?
375            if args.extras:
376                deps = dist.requires()
377                extras = dist.extras
378                print(extras)
379                for extra in extras:
380                    print('%%package\textras-{}'.format(extra))
381                    print('Summary:\t{} extra for {} python package'.format(extra, dist.key))
382                    print('Group:\t\tDevelopment/Python')
383                    depsextras = dist.requires(extras=[extra])
384                    for dep in reversed(depsextras):
385                        if dep in deps:
386                            depsextras.remove(dep)
387                    deps = depsextras
388                    for dep in deps:
389                        for spec in dep.specs:
390                            if spec[0] == '!=':
391                                print('Conflicts:\t{} {} {}'.format(dep.key, '==', spec[1]))
392                            else:
393                                print('Requires:\t{} {} {}'.format(dep.key, spec[0], spec[1]))
394                    print('%%description\t{}'.format(extra))
395                    print('{} extra for {} python package'.format(extra, dist.key))
396                    print('%%files\t\textras-{}\n'.format(extra))
397            if args.conflicts:
398                # Should we really add conflicts for extras?
399                # Creating a meta package per extra with recommends on, which has
400                # the requires/conflicts in stead might be a better solution...
401                for dep in dist.requires(extras=dist.extras):
402                    name = dep.key
403                    for spec in dep.specs:
404                        if spec[0] == '!=':
405                            if name not in py_deps:
406                                py_deps[name] = []
407                            spec = ('==', spec[1])
408                            if spec not in py_deps[name]:
409                                py_deps[name].append(spec)
410
411    names = list(py_deps.keys())
412    names.sort()
413    for name in names:
414        if py_deps[name]:
415            # Print out versioned provides, requires, recommends, conflicts
416            spec_list = []
417            for spec in py_deps[name]:
418                spec_list.append(convert(name, spec[0], spec[1]))
419            if len(spec_list) == 1:
420                print(spec_list[0])
421            else:
422                # Sort spec_list so that the results can be tested easily
423                print('({})'.format(' with '.join(sorted(spec_list))))
424        else:
425            # Print out unversioned provides, requires, recommends, conflicts
426            print(name)
427