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