Compare commits

...

No commits in common. 'c9' and 'c8' have entirely different histories.
c9 ... c8

@ -1,27 +1,4 @@
%__python_provides() %{lua: %__python_provides %{_rpmconfigdir}/pythondistdeps.py --provides --majorver-provides-versions @MAJORVER-PROVIDES-VERSIONS@
-- Match buildroot/payload paths of the form %__python_requires %{_rpmconfigdir}/pythondeps.sh --requires
-- /PATH/OF/BUILDROOT/usr/bin/pythonMAJOR.MINOR %__python_path ^((/usr/lib(64)?/python[[:digit:]]+\\.[[:digit:]]+/site-packages/[^/]+\\.(dist-info|egg-info|egg-link))|(/usr/lib(64)?/python[[:digit:]]+\\.[[:digit:]]+/.*\\.(py[oc]?|so))|(%{_bindir}/python[[:digit:]]+\\.[[:digit:]]+))$
-- generating a line of the form %__python_magic [Pp]ython.*(executable|byte-compiled)
-- python(abi) = MAJOR.MINOR
-- (Don't match against -config tools e.g. /usr/bin/python2.6-config)
local path = rpm.expand('%1')
if path:match('/usr/bin/python%d+%.%d+$') then
local provides = path:gsub('.*/usr/bin/python(%d+%.%d+)', 'python(abi) = %1')
print(provides)
end
}
%__python_requires() %{lua:
-- Match buildroot paths of the form
-- /PATH/OF/BUILDROOT/usr/lib/pythonMAJOR.MINOR/ and
-- /PATH/OF/BUILDROOT/usr/lib64/pythonMAJOR.MINOR/
-- generating a line of the form:
-- python(abi) = MAJOR.MINOR
local path = rpm.expand('%1')
if path:match('/usr/lib%d*/python%d+%.%d+/.*') then
local requires = path:gsub('.*/usr/lib%d*/python(%d+%.%d+)/.*', 'python(abi) = %1')
print(requires)
end
}
%__python_path ^((%{_prefix}/lib(64)?/python[[:digit:]]+\\.[[:digit:]]+/.*\\.(py[oc]?|so))|(%{_bindir}/python[[:digit:]]+\\.[[:digit:]]+))$

@ -1,90 +0,0 @@
#!/usr/bin/python3 -sB
# (imports pythondistdeps from /usr/lib/rpm, hence -B)
#
# This program is free software.
#
# It is placed in the public domain or under the CC0-1.0-Universal license,
# whichever is more permissive.
#
# Alternatively, it may be redistributed and/or modified under the terms of
# the LGPL version 2.1 (or later) or GPL version 2 (or later).
#
# Use this script to generate bundled provides, e.g.:
# ./pythonbundles.py setuptools-47.1.1/pkg_resources/_vendor/vendored.txt
import pathlib
import sys
# inject parse_version import to pythondistdeps
# not the nicest API, but :/
from pkg_resources import parse_version
import pythondistdeps
pythondistdeps.parse_version = parse_version
def generate_bundled_provides(path, namespace):
provides = set()
for line in path.read_text().splitlines():
line, _, comment = line.partition('#')
if comment.startswith('egg='):
# not a real comment
# e.g. git+https://github.com/monty/spam.git@master#egg=spam&...
egg, *_ = comment.strip().partition(' ')
egg, *_ = egg.strip().partition('&')
name = pythondistdeps.normalize_name(egg[4:])
provides.add(f'Provides: bundled({namespace}({name}))')
continue
line = line.strip()
if line:
name, _, version = line.partition('==')
name = pythondistdeps.normalize_name(name)
bundled_name = f"bundled({namespace}({name}))"
python_provide = pythondistdeps.convert(bundled_name, '==', version)
provides.add(f'Provides: {python_provide}')
return provides
def compare(expected, given):
stripped = (l.strip() for l in given)
no_comments = set(l for l in stripped if not l.startswith('#'))
no_comments.discard('')
if expected == no_comments:
return True
extra_expected = expected - no_comments
extra_given = no_comments - expected
if extra_expected:
print('Missing expected provides:', file=sys.stderr)
for provide in sorted(extra_expected):
print(f' - {provide}', file=sys.stderr)
if extra_given:
print('Redundant unexpected provides:', file=sys.stderr)
for provide in sorted(extra_given):
print(f' + {provide}', file=sys.stderr)
return False
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(prog=sys.argv[0],
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('vendored', metavar='VENDORED.TXT',
help='Upstream information about vendored libraries')
parser.add_argument('-c', '--compare-with', action='store',
help='A string value to compare with and verify')
parser.add_argument('-n', '--namespace', action='store',
help='What namespace of provides will used', default='python3dist')
args = parser.parse_args()
provides = generate_bundled_provides(pathlib.Path(args.vendored), args.namespace)
if args.compare_with:
given = args.compare_with.splitlines()
same = compare(provides, given)
if not same:
sys.exit(1)
else:
for provide in sorted(provides):
print(provide)

@ -0,0 +1,32 @@
#!/bin/bash
[ $# -ge 1 ] || {
cat > /dev/null
exit 0
}
case $1 in
-P|--provides)
shift
# Match buildroot/payload paths of the form
# /PATH/OF/BUILDROOT/usr/bin/pythonMAJOR.MINOR
# generating a line of the form
# python(abi) = MAJOR.MINOR
# (Don't match against -config tools e.g. /usr/bin/python2.6-config)
grep "/usr/bin/python.\..$" \
| sed -e "s|.*/usr/bin/python\(.\..\)|python(abi) = \1|"
;;
-R|--requires)
shift
# Match buildroot paths of the form
# /PATH/OF/BUILDROOT/usr/lib/pythonMAJOR.MINOR/ and
# /PATH/OF/BUILDROOT/usr/lib64/pythonMAJOR.MINOR/
# generating (uniqely) lines of the form:
# python(abi) = MAJOR.MINOR
grep -E "/usr/lib[^/]*/python[[:digit:]]+\.[[:digit:]]+/.*" \
| sed -Ee "s|.*/usr/lib[^/]*/python([[:digit:]]+\.[[:digit:]]+)/.*|python(abi) = \1|g" \
| sort | uniq
;;
esac
exit 0

@ -1,3 +0,0 @@
%__pythondist_provides %{_rpmconfigdir}/pythondistdeps.py --provides --normalized-names-format pep503 --package-name %{name} --normalized-names-provide-both --majorver-provides-versions %{__default_python3_version}
%__pythondist_requires %{_rpmconfigdir}/pythondistdeps.py --requires --normalized-names-format pep503 --package-name %{name} %{?!_python_no_extras_requires:--require-extras-subpackages} --console-scripts-nodep-setuptools-since 3.10
%__pythondist_path ^/usr/lib(64)?/python[3-9]\\.[[:digit:]]+/site-packages/[^/]+\\.(dist-info|egg-info|egg-link)$

@ -1,9 +1,8 @@
#!/usr/bin/python3 -s #!/usr/libexec/platform-python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
# Copyright 2010 Per Øyvind Karlsen <proyvind@moondrake.org> # Copyright 2010 Per Øyvind Karlsen <proyvind@moondrake.org>
# Copyright 2015 Neal Gompa <ngompa13@gmail.com> # Copyright 2015 Neal Gompa <ngompa13@gmail.com>
# Copyright 2020 SUSE LLC
# #
# This program is free software. It may be redistributed and/or modified under # This program is free software. It may be redistributed and/or modified under
# the terms of the LGPL version 2.1 (or later). # the terms of the LGPL version 2.1 (or later).
@ -12,328 +11,79 @@
# #
from __future__ import print_function from __future__ import print_function
import argparse from getopt import getopt
from os.path import dirname, sep from os.path import basename, dirname, isdir, sep
import re from sys import argv, stdin, version
from sys import argv, stdin, stderr, version_info from distutils.sysconfig import get_python_lib
from sysconfig import get_path
from warnings import warn from warnings import warn
from packaging.requirements import Requirement as Requirement_
from packaging.version import parse
import packaging.markers
# Monkey patching packaging.markers to handle extras names in a opts, args = getopt(
# case-insensitive manner: argv[1:], 'hPRrCEMmLl:',
# pip considers dnspython[DNSSEC] and dnspython[dnssec] to be equal, but ['help', 'provides', 'requires', 'recommends', 'conflicts', 'extras', 'majorver-provides', 'majorver-provides-versions=', 'majorver-only', 'legacy-provides' , 'legacy'])
# packaging markers treat extras in a case-sensitive manner. To solve this
# issue, we introduce a comparison operator that compares case-insensitively Provides = False
# if both sides of the comparison are strings. And then we inject this Requires = False
# operator into packaging.markers to be used when comparing names of extras. Recommends = False
# Fedora BZ: https://bugzilla.redhat.com/show_bug.cgi?id=1936875 Conflicts = False
# Upstream issue: https://discuss.python.org/t/what-extras-names-are-treated-as-equal-and-why/7614 Extras = False
# - After it's established upstream what is the canonical form of an extras Provides_PyMajorVer_Variant = False
# name, we plan to open an issue with packaging to hopefully solve this Provides_PyMajorVer_Versions = None
# there without having to resort to monkeypatching. PyMajorVer_Deps = False
def str_lower_eq(a, b): legacy_Provides = False
if isinstance(a, str) and isinstance(b, str): legacy = False
return a.lower() == b.lower()
for o, a in opts:
if o in ('-h', '--help'):
print('-h, --help\tPrint help')
print('-P, --provides\tPrint Provides')
print('-R, --requires\tPrint Requires')
print('-r, --recommends\tPrint Recommends')
print('-C, --conflicts\tPrint Conflicts')
print('-E, --extras\tPrint Extras ')
print('-M, --majorver-provides\tPrint extra Provides with Python major version only for all Python versions')
print(' --majorver-provides-versions VERSIONS\n'
' \tPrint extra Provides with Python major version only for listed Python VERSIONS (comma separated, no spaces, e.g. 2.7,3.6)')
print('-m, --majorver-only\tPrint Provides/Requires with Python major version only')
print('-L, --legacy-provides\tPrint extra legacy pythonegg Provides')
print('-l, --legacy\tPrint legacy pythonegg Provides/Requires instead')
exit(1)
elif o in ('-P', '--provides'):
Provides = True
elif o in ('-R', '--requires'):
Requires = True
elif o in ('-r', '--recommends'):
Recommends = True
elif o in ('-C', '--conflicts'):
Conflicts = True
elif o in ('-E', '--extras'):
Extras = True
elif o in ('-M', '--majorver-provides'):
Provides_PyMajorVer_Variant = True
elif o in ('--majorver-provides-versions'):
Provides_PyMajorVer_Versions = a.split(",")
elif o in ('-m', '--majorver-only'):
PyMajorVer_Deps = True
elif o in ('-L', '--legacy-provides'):
legacy_Provides = True
elif o in ('-l', '--legacy'):
legacy = True
if Provides_PyMajorVer_Variant and Provides_PyMajorVer_Versions:
print("Error, options --majorver-provides and --majorver-provides-versions are mutually incompatible.")
exit(2)
if Requires:
py_abi = True
else: else:
return a == b py_abi = False
packaging.markers._operators["=="] = str_lower_eq
try:
from importlib.metadata import PathDistribution
except ImportError:
from importlib_metadata import PathDistribution
try:
from pathlib import Path
except ImportError:
from pathlib2 import Path
def normalize_name(name):
"""https://www.python.org/dev/peps/pep-0503/#normalized-names"""
return re.sub(r'[-_.]+', '-', name).lower()
def legacy_normalize_name(name):
"""Like pkg_resources Distribution.key property"""
return re.sub(r'[-_]+', '-', name).lower()
class Requirement(Requirement_):
def __init__(self, requirement_string):
super(Requirement, self).__init__(requirement_string)
self.normalized_name = normalize_name(self.name)
self.legacy_normalized_name = legacy_normalize_name(self.name)
class Distribution(PathDistribution):
def __init__(self, path):
super(Distribution, self).__init__(Path(path))
self.normalized_name = normalize_name(self.name)
self.legacy_normalized_name = legacy_normalize_name(self.name)
self.requirements = [Requirement(r) for r in self.requires or []]
self.extras = [
v.lower() for k, v in self.metadata.items() if k == 'Provides-Extra']
self.py_version = self._parse_py_version(path)
# `name` is defined as a property exactly like this in Python 3.10 in the
# PathDistribution class. Due to that we can't redefine `name` as a normal
# attribute. So we copied the Python 3.10 definition here into the code so
# that it works also on previous Python/importlib_metadata versions.
@property
def name(self):
"""Return the 'Name' metadata for the distribution package."""
return self.metadata['Name']
def _parse_py_version(self, path):
# Try to parse the Python version from the path the metadata
# resides at (e.g. /usr/lib/pythonX.Y/site-packages/...)
res = re.search(r"/python(?P<pyver>\d+\.\d+)/", path)
if res:
return res.group('pyver')
# If that hasn't worked, attempt to parse it from the metadata
# directory name
res = re.search(r"-py(?P<pyver>\d+.\d+)[.-]egg-info$", path)
if res:
return res.group('pyver')
return None
def requirements_for_extra(self, extra):
extra_deps = []
for req in self.requirements:
if not req.marker:
continue
if req.marker.evaluate(get_marker_env(self, extra)):
extra_deps.append(req)
return extra_deps
def __repr__(self):
return '{} from {}'.format(self.name, self._path)
class RpmVersion():
def __init__(self, version_id):
version = parse(version_id)
if isinstance(version._version, str):
self.version = version._version
else:
self.epoch = version._version.epoch
self.version = list(version._version.release)
self.pre = version._version.pre
self.dev = version._version.dev
self.post = version._version.post
def increment(self):
self.version[-1] += 1
self.pre = None
self.dev = None
self.post = None
return self
def __str__(self):
if isinstance(self.version, str):
return self.version
if self.epoch:
rpm_epoch = str(self.epoch) + ':'
else:
rpm_epoch = ''
while len(self.version) > 1 and self.version[-1] == 0:
self.version.pop()
rpm_version = '.'.join(str(x) for x in self.version)
if self.pre:
rpm_suffix = '~{}'.format(''.join(str(x) for x in self.pre))
elif self.dev:
rpm_suffix = '~~{}'.format(''.join(str(x) for x in self.dev))
elif self.post:
rpm_suffix = '^post{}'.format(self.post[1])
else:
rpm_suffix = ''
return '{}{}{}'.format(rpm_epoch, rpm_version, rpm_suffix)
def convert_compatible(name, operator, version_id):
if version_id.endswith('.*'):
print("*** INVALID_REQUIREMENT_ERROR___SEE_STDERR ***")
print('Invalid requirement: {} {} {}'.format(name, operator, version_id), file=stderr)
exit(65) # os.EX_DATAERR
version = RpmVersion(version_id)
if len(version.version) == 1:
print("*** INVALID_REQUIREMENT_ERROR___SEE_STDERR ***")
print('Invalid requirement: {} {} {}'.format(name, operator, version_id), file=stderr)
exit(65) # os.EX_DATAERR
upper_version = RpmVersion(version_id)
upper_version.version.pop()
upper_version.increment()
return '({} >= {} with {} < {})'.format(
name, version, name, upper_version)
def convert_equal(name, operator, version_id):
if version_id.endswith('.*'):
version_id = version_id[:-2] + '.0'
return convert_compatible(name, '~=', version_id)
version = RpmVersion(version_id)
return '{} = {}'.format(name, version)
def convert_arbitrary_equal(name, operator, version_id):
if version_id.endswith('.*'):
print("*** INVALID_REQUIREMENT_ERROR___SEE_STDERR ***")
print('Invalid requirement: {} {} {}'.format(name, operator, version_id), file=stderr)
exit(65) # os.EX_DATAERR
version = RpmVersion(version_id)
return '{} = {}'.format(name, version)
def convert_not_equal(name, operator, version_id):
if version_id.endswith('.*'):
version_id = version_id[:-2]
version = RpmVersion(version_id)
lower_version = RpmVersion(version_id).increment()
else:
version = RpmVersion(version_id)
lower_version = version
return '({} < {} or {} > {})'.format(
name, version, name, lower_version)
def convert_ordered(name, operator, version_id):
if version_id.endswith('.*'):
# PEP 440 does not define semantics for prefix matching
# with ordered comparisons
version_id = version_id[:-2]
version = RpmVersion(version_id)
if operator == '>':
# distutils will allow a prefix match with '>'
operator = '>='
if operator == '<=':
# distutils will not allow a prefix match with '<='
operator = '<'
else:
version = RpmVersion(version_id)
return '{} {} {}'.format(name, operator, version)
OPERATORS = {'~=': convert_compatible,
'==': convert_equal,
'===': convert_arbitrary_equal,
'!=': convert_not_equal,
'<=': convert_ordered,
'<': convert_ordered,
'>=': convert_ordered,
'>': convert_ordered}
def convert(name, operator, version_id):
try:
return OPERATORS[operator](name, operator, version_id)
except Exception as exc:
raise RuntimeError("Cannot process Python package version `{}` for name `{}`".
format(version_id, name)) from exc
def get_marker_env(dist, extra):
# packaging uses a default environment using
# platform.python_version to evaluate if a dependency is relevant
# based on environment markers [1],
# e.g. requirement `argparse;python_version<"2.7"`
#
# Since we're running this script on one Python version while
# possibly evaluating packages for different versions, we
# set up an environment with the version we want to evaluate.
#
# [1] https://www.python.org/dev/peps/pep-0508/#environment-markers
return {"python_full_version": dist.py_version,
"python_version": dist.py_version,
"extra": extra}
if __name__ == "__main__":
"""To allow this script to be importable (and its classes/functions
reused), actions are performed only when run as a main script."""
parser = argparse.ArgumentParser(prog=argv[0])
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('-P', '--provides', action='store_true', help='Print Provides')
group.add_argument('-R', '--requires', action='store_true', help='Print Requires')
group.add_argument('-r', '--recommends', action='store_true', help='Print Recommends')
group.add_argument('-C', '--conflicts', action='store_true', help='Print Conflicts')
group.add_argument('-E', '--extras', action='store_true', help='[Unused] Generate spec file snippets for extras subpackages')
group_majorver = parser.add_mutually_exclusive_group()
group_majorver.add_argument('-M', '--majorver-provides', action='store_true', help='Print extra Provides with Python major version only')
group_majorver.add_argument('--majorver-provides-versions', action='append',
help='Print extra Provides with Python major version only for listed '
'Python VERSIONS (appended or comma separated without spaces, e.g. 2.7,3.9)')
parser.add_argument('-m', '--majorver-only', action='store_true', help='Print Provides/Requires with Python major version only')
parser.add_argument('-n', '--normalized-names-format', action='store',
default="legacy-dots", choices=["pep503", "legacy-dots"],
help='Format of normalized names according to pep503 or legacy format that allows dots [default]')
parser.add_argument('--normalized-names-provide-both', action='store_true',
help='Provide both `pep503` and `legacy-dots` format of normalized names (useful for a transition period)')
parser.add_argument('-L', '--legacy-provides', action='store_true', help='Print extra legacy pythonegg Provides')
parser.add_argument('-l', '--legacy', action='store_true', help='Print legacy pythonegg Provides/Requires instead')
parser.add_argument('--console-scripts-nodep-setuptools-since', action='store',
help='An optional Python version (X.Y), at least 3.8. '
'For that version and any newer version, '
'a dependency on "setuptools" WILL NOT be generated for packages with console_scripts/gui_scripts entry points. '
'By setting this flag, you guarantee that setuptools >= 47.2.0 is used '
'during the build of packages for this and any newer Python version.')
parser.add_argument('--require-extras-subpackages', action='store_true',
help="If there is a dependency on a package with extras functionality, require the extras subpackage")
parser.add_argument('--package-name', action='store', help="Name of the RPM package that's being inspected. Required for extras requires/provides to work.")
parser.add_argument('files', nargs=argparse.REMAINDER, help="Files from the RPM package that are to be inspected, can also be supplied on stdin")
args = parser.parse_args()
py_abi = args.requires
py_deps = {} py_deps = {}
if args:
if args.majorver_provides_versions: files = args
# Go through the arguments (can be specified multiple times),
# and parse individual versions (can be comma-separated)
args.majorver_provides_versions = [v for vstring in args.majorver_provides_versions
for v in vstring.split(",")]
# If normalized_names_require_pep503 is True we require the pep503
# normalized name, if it is False we provide the legacy normalized name
normalized_names_require_pep503 = args.normalized_names_format == "pep503"
# If normalized_names_provide_pep503/legacy is True we provide the
# pep503/legacy normalized name, if it is False we don't
normalized_names_provide_pep503 = \
args.normalized_names_format == "pep503" or args.normalized_names_provide_both
normalized_names_provide_legacy = \
args.normalized_names_format == "legacy-dots" or args.normalized_names_provide_both
# At least one type of normalization must be provided
assert normalized_names_provide_pep503 or normalized_names_provide_legacy
if args.console_scripts_nodep_setuptools_since:
nodep_setuptools_pyversion = parse(args.console_scripts_nodep_setuptools_since)
if nodep_setuptools_pyversion < parse("3.8"):
print("Only version 3.8+ is supported in --console-scripts-nodep-setuptools-since", file=stderr)
print("*** PYTHON_EXTRAS_ARGUMENT_ERROR___SEE_STDERR ***")
exit(65) # os.EX_DATAERR
else: else:
nodep_setuptools_pyversion = None files = stdin.readlines()
# Is this script being run for an extras subpackage? for f in files:
extras_subpackage = None
if args.package_name and '+' in args.package_name:
# The extras names are encoded in the package names after the + sign.
# We take the part after the rightmost +, ignoring when empty,
# this allows packages like nicotine+ or c++ to work fine.
# While packages with names like +spam or foo+bar would break,
# names started with the plus sign are not very common
# and pluses in the middle can be easily replaced with dashes.
# Python extras names don't contain pluses according to PEP 508.
package_name_parts = args.package_name.rpartition('+')
extras_subpackage = package_name_parts[2].lower() or None
for f in (args.files or stdin.readlines()):
f = f.strip() f = f.strip()
lower = f.lower() lower = f.lower()
name = 'python(abi)' name = 'python(abi)'
@ -341,9 +91,8 @@ if __name__ == "__main__":
if py_abi and (lower.endswith('.py') or lower.endswith('.pyc') or lower.endswith('.pyo')): if py_abi and (lower.endswith('.py') or lower.endswith('.pyc') or lower.endswith('.pyo')):
if name not in py_deps: if name not in py_deps:
py_deps[name] = [] py_deps[name] = []
running_python_version = '{}.{}'.format(*version_info[:2]) purelib = get_python_lib(standard_lib=0, plat_specific=0).split(version[:3])[0]
purelib = get_path('purelib').split(running_python_version)[0] platlib = get_python_lib(standard_lib=0, plat_specific=1).split(version[:3])[0]
platlib = get_path('platlib').split(running_python_version)[0]
for lib in (purelib, platlib): for lib in (purelib, platlib):
if lib in f: if lib in f:
spec = ('==', f.split(lib)[1].split(sep)[0]) spec = ('==', f.split(lib)[1].split(sep)[0])
@ -361,84 +110,69 @@ if __name__ == "__main__":
if lower.endswith('.egg') or \ if lower.endswith('.egg') or \
lower.endswith('.egg-info') or \ lower.endswith('.egg-info') or \
lower.endswith('.dist-info'): lower.endswith('.dist-info'):
dist = Distribution(f) # This import is very slow, so only do it if needed
from pkg_resources import Distribution, FileMetadata, PathMetadata
dist_name = basename(f)
if isdir(f):
path_item = dirname(f)
metadata = PathMetadata(path_item, f)
else:
path_item = f
metadata = FileMetadata(f)
dist = Distribution.from_location(path_item, dist_name, metadata)
# Check if py_version is defined in the metadata file/directory name
if not dist.py_version: if not dist.py_version:
# Try to parse the Python version from the path the metadata
# resides at (e.g. /usr/lib/pythonX.Y/site-packages/...)
import re
res = re.search(r"/python(?P<pyver>\d+\.\d+)/", path_item)
if res:
dist.py_version = res.group('pyver')
else:
warn("Version for {!r} has not been found".format(dist), RuntimeWarning) warn("Version for {!r} has not been found".format(dist), RuntimeWarning)
continue continue
# If processing an extras subpackage: # XXX: https://github.com/pypa/setuptools/pull/1275
# Check that the extras name is declared in the metadata, or import platform
# that there are some dependencies associated with the extras platform.python_version = lambda: dist.py_version
# name in the requires.txt (this is an outdated way to declare
# extras packages).
# - If there is an extras package declared only in requires.txt
# without any dependencies, this check will fail. In that case
# make sure to use updated metadata and declare the extras
# package there.
if extras_subpackage and extras_subpackage not in dist.extras and not dist.requirements_for_extra(extras_subpackage):
print("*** PYTHON_EXTRAS_NOT_FOUND_ERROR___SEE_STDERR ***")
print(f"\nError: The package name contains an extras name `{extras_subpackage}` that was not found in the metadata.\n"
"Check if the extras were removed from the project. If so, consider removing the subpackage and obsoleting it from another.\n", file=stderr)
exit(65) # os.EX_DATAERR
if args.majorver_provides or args.majorver_provides_versions or \ if Provides_PyMajorVer_Variant or PyMajorVer_Deps or legacy_Provides or legacy or Provides_PyMajorVer_Versions:
args.majorver_only or args.legacy_provides or args.legacy:
# Get the Python major version # Get the Python major version
pyver_major = dist.py_version.split('.')[0] pyver_major = dist.py_version.split('.')[0]
if args.provides: if Provides:
extras_suffix = f"[{extras_subpackage}]" if extras_subpackage else ""
# If egg/dist metadata says package name is python, we provide python(abi) # If egg/dist metadata says package name is python, we provide python(abi)
if dist.normalized_name == 'python': if dist.key == 'python':
name = 'python(abi)' name = 'python(abi)'
if name not in py_deps: if name not in py_deps:
py_deps[name] = [] py_deps[name] = []
py_deps[name].append(('==', dist.py_version)) py_deps[name].append(('==', dist.py_version))
if not args.legacy or not args.majorver_only: if not legacy or not PyMajorVer_Deps:
if normalized_names_provide_legacy: name = 'python{}dist({})'.format(dist.py_version, dist.key)
name = 'python{}dist({}{})'.format(dist.py_version, dist.legacy_normalized_name, extras_suffix)
if name not in py_deps: if name not in py_deps:
py_deps[name] = [] py_deps[name] = []
if normalized_names_provide_pep503: if Provides_PyMajorVer_Variant or PyMajorVer_Deps or \
name_ = 'python{}dist({}{})'.format(dist.py_version, dist.normalized_name, extras_suffix) (Provides_PyMajorVer_Versions and dist.py_version in Provides_PyMajorVer_Versions):
if name_ not in py_deps: pymajor_name = 'python{}dist({})'.format(pyver_major, dist.key)
py_deps[name_] = []
if args.majorver_provides or args.majorver_only or \
(args.majorver_provides_versions and dist.py_version in args.majorver_provides_versions):
if normalized_names_provide_legacy:
pymajor_name = 'python{}dist({}{})'.format(pyver_major, dist.legacy_normalized_name, extras_suffix)
if pymajor_name not in py_deps: if pymajor_name not in py_deps:
py_deps[pymajor_name] = [] py_deps[pymajor_name] = []
if normalized_names_provide_pep503: if legacy or legacy_Provides:
pymajor_name_ = 'python{}dist({}{})'.format(pyver_major, dist.normalized_name, extras_suffix) legacy_name = 'pythonegg({})({})'.format(pyver_major, dist.key)
if pymajor_name_ not in py_deps:
py_deps[pymajor_name_] = []
if args.legacy or args.legacy_provides:
legacy_name = 'pythonegg({})({})'.format(pyver_major, dist.legacy_normalized_name)
if legacy_name not in py_deps: if legacy_name not in py_deps:
py_deps[legacy_name] = [] py_deps[legacy_name] = []
if dist.version: if dist.version:
version = dist.version spec = ('==', dist.version)
spec = ('==', version)
if normalized_names_provide_legacy:
if spec not in py_deps[name]: if spec not in py_deps[name]:
if not legacy:
py_deps[name].append(spec) py_deps[name].append(spec)
if args.majorver_provides or \ if Provides_PyMajorVer_Variant or \
(args.majorver_provides_versions and dist.py_version in args.majorver_provides_versions): (Provides_PyMajorVer_Versions and dist.py_version in Provides_PyMajorVer_Versions):
py_deps[pymajor_name].append(spec) py_deps[pymajor_name].append(spec)
if normalized_names_provide_pep503: if legacy or legacy_Provides:
if spec not in py_deps[name_]:
py_deps[name_].append(spec)
if args.majorver_provides or \
(args.majorver_provides_versions and dist.py_version in args.majorver_provides_versions):
py_deps[pymajor_name_].append(spec)
if args.legacy or args.legacy_provides:
if spec not in py_deps[legacy_name]:
py_deps[legacy_name].append(spec) py_deps[legacy_name].append(spec)
if args.requires or (args.recommends and dist.extras): if Requires or (Recommends and dist.extras):
name = 'python(abi)' name = 'python(abi)'
# If egg/dist metadata says package name is python, we don't add dependency on python(abi) # If egg/dist metadata says package name is python, we don't add dependency on python(abi)
if dist.normalized_name == 'python': if dist.key == 'python':
py_abi = False py_abi = False
if name in py_deps: if name in py_deps:
py_deps.pop(name) py_deps.pop(name)
@ -448,99 +182,75 @@ if __name__ == "__main__":
spec = ('==', dist.py_version) spec = ('==', dist.py_version)
if spec not in py_deps[name]: if spec not in py_deps[name]:
py_deps[name].append(spec) py_deps[name].append(spec)
deps = dist.requires()
if extras_subpackage: if Recommends:
deps = [d for d in dist.requirements_for_extra(extras_subpackage)] depsextras = dist.requires(extras=dist.extras)
else: if not Requires:
deps = dist.requirements for dep in reversed(depsextras):
if dep in deps:
# console_scripts/gui_scripts entry points needed pkg_resources from setuptools depsextras.remove(dep)
# on new Python/setuptools versions, this is no longer required deps = depsextras
if nodep_setuptools_pyversion is None or parse(dist.py_version) < nodep_setuptools_pyversion:
if (dist.entry_points and
(lower.endswith('.egg') or
lower.endswith('.egg-info'))):
groups = {ep.group for ep in dist.entry_points}
if {"console_scripts", "gui_scripts"} & groups:
# stick them first so any more specific requirement
# overrides it
deps.insert(0, Requirement('setuptools'))
# add requires/recommends based on egg/dist metadata # add requires/recommends based on egg/dist metadata
for dep in deps: for dep in deps:
# Even if we're requiring `foo[bar]`, also require `foo` if legacy:
# to be safe, and to make it discoverable through name = 'pythonegg({})({})'.format(pyver_major, dep.key)
# `repoquery --whatrequires`
extras_suffixes = [""]
if args.require_extras_subpackages and dep.extras:
# A dependency can have more than one extras,
# i.e. foo[bar,baz], so let's go through all of them
extras_suffixes += [f"[{e.lower()}]" for e in dep.extras]
for extras_suffix in extras_suffixes:
if normalized_names_require_pep503:
dep_normalized_name = dep.normalized_name
else: else:
dep_normalized_name = dep.legacy_normalized_name if PyMajorVer_Deps:
name = 'python{}dist({})'.format(pyver_major, dep.key)
if args.legacy:
name = 'pythonegg({})({})'.format(pyver_major, dep.legacy_normalized_name)
else: else:
if args.majorver_only: name = 'python{}dist({})'.format(dist.py_version, dep.key)
name = 'python{}dist({}{})'.format(pyver_major, dep_normalized_name, extras_suffix) for spec in dep.specs:
else: if spec[0] != '!=':
name = 'python{}dist({}{})'.format(dist.py_version, dep_normalized_name, extras_suffix)
if dep.marker and not args.recommends and not extras_subpackage:
if not dep.marker.evaluate(get_marker_env(dist, '')):
continue
if name not in py_deps: if name not in py_deps:
py_deps[name] = [] py_deps[name] = []
for spec in dep.specifier: if spec not in py_deps[name]:
if (spec.operator, spec.version) not in py_deps[name]: py_deps[name].append(spec)
py_deps[name].append((spec.operator, spec.version)) if not dep.specs:
py_deps[name] = []
# Unused, for automatic sub-package generation based on 'extras' from egg/dist metadata # Unused, for automatic sub-package generation based on 'extras' from egg/dist metadata
# TODO: implement in rpm later, or...? # TODO: implement in rpm later, or...?
if args.extras: if Extras:
print(dist.extras) deps = dist.requires()
for extra in dist.extras: extras = dist.extras
print(extras)
for extra in extras:
print('%%package\textras-{}'.format(extra)) print('%%package\textras-{}'.format(extra))
print('Summary:\t{} extra for {} python package'.format(extra, dist.legacy_normalized_name)) print('Summary:\t{} extra for {} python package'.format(extra, dist.key))
print('Group:\t\tDevelopment/Python') print('Group:\t\tDevelopment/Python')
for dep in dist.requirements_for_extra(extra): depsextras = dist.requires(extras=[extra])
for spec in dep.specifier: for dep in reversed(depsextras):
if spec.operator == '!=': if dep in deps:
print('Conflicts:\t{} {} {}'.format(dep.legacy_normalized_name, '==', spec.version)) depsextras.remove(dep)
deps = depsextras
for dep in deps:
for spec in dep.specs:
if spec[0] == '!=':
print('Conflicts:\t{} {} {}'.format(dep.key, '==', spec[1]))
else: else:
print('Requires:\t{} {} {}'.format(dep.legacy_normalized_name, spec.operator, spec.version)) print('Requires:\t{} {} {}'.format(dep.key, spec[0], spec[1]))
print('%%description\t{}'.format(extra)) print('%%description\t{}'.format(extra))
print('{} extra for {} python package'.format(extra, dist.legacy_normalized_name)) print('{} extra for {} python package'.format(extra, dist.key))
print('%%files\t\textras-{}\n'.format(extra)) print('%%files\t\textras-{}\n'.format(extra))
if args.conflicts: if Conflicts:
# Should we really add conflicts for extras? # Should we really add conflicts for extras?
# Creating a meta package per extra with recommends on, which has # Creating a meta package per extra with recommends on, which has
# the requires/conflicts in stead might be a better solution... # the requires/conflicts in stead might be a better solution...
for dep in dist.requirements: for dep in dist.requires(extras=dist.extras):
for spec in dep.specifier: name = dep.key
if spec.operator == '!=': for spec in dep.specs:
if dep.legacy_normalized_name not in py_deps: if spec[0] == '!=':
py_deps[dep.legacy_normalized_name] = [] if name not in py_deps:
spec = ('==', spec.version) py_deps[name] = []
if spec not in py_deps[dep.legacy_normalized_name]: spec = ('==', spec[1])
py_deps[dep.legacy_normalized_name].append(spec) if spec not in py_deps[name]:
py_deps[name].append(spec)
for name in sorted(py_deps): names = list(py_deps.keys())
names.sort()
for name in names:
if py_deps[name]: if py_deps[name]:
# Print out versioned provides, requires, recommends, conflicts # Print out versioned provides, requires, recommends, conflicts
spec_list = []
for spec in py_deps[name]: for spec in py_deps[name]:
spec_list.append(convert(name, spec[0], spec[1])) print('{} {} {}'.format(name, spec[0], spec[1]))
if len(spec_list) == 1:
print(spec_list[0])
else:
# Sort spec_list so that the results can be tested easily
print('({})'.format(' with '.join(sorted(spec_list))))
else: else:
# Print out unversioned provides, requires, recommends, conflicts # Print out unversioned provides, requires, recommends, conflicts
print(name) print(name)

@ -1,42 +0,0 @@
%__pythonname_provides() %{lua:
local python = require 'fedora.srpm.python'
-- this macro is called for each file in a package, the path being in %1
-- but we don't need to know the path, so we would get for each file: Macro %1 defined but not used within scope
-- in here, we expand %name conditionally on %1 to suppress the warning
local name = rpm.expand('%{?1:%{name}}')
local evr = rpm.expand('%{?epoch:%{epoch}:}%{version}-%{release}')
local provides = python.python_altprovides_once(name, evr)
-- provides is either an array/table or nil
-- nil means the function was already called with the same arguments:
-- either with another file in %1 or manually via %py_provides
if provides then
for i, provide in ipairs(provides) do
print(provide .. ' ')
end
end
}
%__pythonname_obsoletes() %{?rhel:%{lua:
-- On CentOS/RHEL we automatically generate Obsoletes tags in the form:
-- package python3-foo -> Obsoletes: python3.XY-foo
-- This provides a clean upgrade path between major versions of CentOS/RHEL.
-- In Fedora this is not needed as we don't ship ecosystem packages
-- for alternative Python interpreters.
local python = require 'fedora.srpm.python'
-- this macro is called for each file in a package, the path being in %1
-- but we don't need to know the path, so we would get for each file: Macro %1 defined but not used within scope
-- in here, we expand %name conditionally on %1 to suppress the warning
local name = rpm.expand('%{?1:%{name}}')
local evr = rpm.expand('%{?epoch:%{epoch}:}%{version}-%{release}')
local obsoletes = python.python_altobsoletes_once(name, evr)
-- obsoletes is either an array/table or nil
-- nil means the function was already called with the same arguments:
-- either with another file in %1 or manually via %py_provides
if obsoletes then
for i, obsolete in ipairs(obsoletes) do
print(obsolete .. ' ')
end
end
}}
%__pythonname_path ^/

@ -1,6 +1,10 @@
# Disable automatic bytecompilation. We install only one script and we will
# never "import" it.
%undefine py_auto_byte_compile
Name: python-rpm-generators Name: python-rpm-generators
Summary: Dependency generators for Python RPMs Summary: Dependency generators for Python RPMs
Version: 12 Version: 5
Release: 8%{?dist} Release: 8%{?dist}
# Originally all those files were part of RPM, so license is kept here # Originally all those files were part of RPM, so license is kept here
@ -9,10 +13,8 @@ Url: https://src.fedoraproject.org/python-rpm-generators
# Commit is the last change in following files # Commit is the last change in following files
Source0: https://raw.githubusercontent.com/rpm-software-management/rpm/102eab50b3d0d6546dfe082eac0ade21e6b3dbf1/COPYING Source0: https://raw.githubusercontent.com/rpm-software-management/rpm/102eab50b3d0d6546dfe082eac0ade21e6b3dbf1/COPYING
Source1: python.attr Source1: python.attr
Source2: pythondist.attr Source2: pythondeps.sh
Source3: pythonname.attr Source3: pythondistdeps.py
Source4: pythondistdeps.py
Source5: pythonbundles.py
BuildArch: noarch BuildArch: noarch
@ -21,11 +23,13 @@ BuildArch: noarch
%package -n python3-rpm-generators %package -n python3-rpm-generators
Summary: %{summary} Summary: %{summary}
Requires: python3-packaging %if 0%{?rhel} && 0%{?rhel} >= 8
# We have parametric macro generators, we need RPM 4.16 (4.15.90+ is 4.16 alpha) Requires: platform-python-setuptools
Requires: rpm > 4.15.90-0 %else
# This contains the Lua functions we use: Requires: python3-setuptools
Requires: python-srpm-macros >= 3.9-49 %endif
# The point of split
Conflicts: rpm-build < 4.13.0.1-2
%description -n python3-rpm-generators %description -n python3-rpm-generators
%{summary}. %{summary}.
@ -34,153 +38,47 @@ Requires: python-srpm-macros >= 3.9-49
%autosetup -c -T %autosetup -c -T
cp -a %{sources} . cp -a %{sources} .
# Set which Python versions should have the major-version provides
# (pythonXdist...) generated
sed -i 's/@MAJORVER-PROVIDES-VERSIONS@/2.7,3.6/' python.attr
%install %install
install -Dpm0644 -t %{buildroot}%{_fileattrsdir} *.attr install -Dpm0644 -t %{buildroot}%{_fileattrsdir} python.attr
install -Dpm0755 -t %{buildroot}%{_rpmconfigdir} *.py install -Dpm0755 -t %{buildroot}%{_rpmconfigdir} pythondeps.sh pythondistdeps.py
%files -n python3-rpm-generators %files -n python3-rpm-generators
%license COPYING %license COPYING
%{_fileattrsdir}/python.attr %{_fileattrsdir}/python.attr
%{_fileattrsdir}/pythondist.attr %{_rpmconfigdir}/pythondeps.sh
%{_fileattrsdir}/pythonname.attr
%{_rpmconfigdir}/pythondistdeps.py %{_rpmconfigdir}/pythondistdeps.py
%{_rpmconfigdir}/pythonbundles.py
%changelog %changelog
* Wed Jan 26 2022 Tomas Orsava <torsava@redhat.com> - 12-8 * Mon Nov 14 2022 Charalampos Stratakis <cstratak@redhat.com> - 5-8
- From `python3-foo` packages automatically generate `python3.X-foo` Obsoletes - Fix the pythondeps.sh and pythondistdeps.py scripts for multiple digits python versions
tags on CentOS/RHEL - Resolves: rhbz#2143990
- Resolves: rhbz#1990421
* Tue Aug 10 2021 Mohan Boddu <mboddu@redhat.com> - 12-7
- Rebuilt for IMA sigs, glibc 2.34, aarch64 flags
Related: rhbz#1991688
* Mon Apr 19 2021 Miro Hrončok <mhroncok@redhat.com> - 12-6
- Get rid of distutils deprecation warning (by not using it)
- The distutils module is deprecated in Python 3.10+
- https://www.python.org/dev/peps/pep-0632/
* Fri Apr 16 2021 Miro Hrončok <mhroncok@redhat.com> - 12-5.1
- Do not generate setuptools requirement for console_scripts on Python 3.10+
- See https://fedoraproject.org/wiki/Changes/Reduce_dependencies_on_python3-setuptools
* Fri Apr 16 2021 Mohan Boddu <mboddu@redhat.com> - 12-5
- Rebuilt for RHEL 9 BETA on Apr 15th 2021. Related: rhbz#1947937
* Thu Mar 11 2021 Tomas Orsava <torsava@redhat.com> - 12-4
- scripts/pythondistdeps: Treat extras names case-insensitively and always
output them in lower case (#1936875)
* Mon Feb 22 2021 Tomas Orsava <torsava@redhat.com> - 12-3
- scripts/pythondistdeps: Fix for Python 3.10
* Wed Feb 17 2021 Tomas Orsava <torsava@redhat.com> - 12-2
- scripts/pythondistdeps: Switch from using pkg_resources to importlib.metadata
for reading the egg/dist-info metadata
- The script no longer requires setuptools but instead requires packaging
* Wed Feb 03 2021 Miro Hrončok <mhroncok@redhat.com> - 12-1
- Disable the dist generators for Python 2
- https://fedoraproject.org/wiki/Changes/Disable_Python_2_Dist_RPM_Generators_and_Freeze_Python_2_Macros
* Wed Jan 27 2021 Fedora Release Engineering <releng@fedoraproject.org> - 11-13
- Rebuilt for https://fedoraproject.org/wiki/Fedora_34_Mass_Rebuild
* Mon Oct 19 2020 Tomas Orsava <torsava@redhat.com> - 11-12
- Run scripts in an isolated Python environment (#1889080)
* Wed Jul 29 2020 Fedora Release Engineering <releng@fedoraproject.org> - 11-11
- Rebuilt for https://fedoraproject.org/wiki/Fedora_33_Mass_Rebuild
* Tue Jul 21 2020 Miro Hrončok <mhroncok@redhat.com> - 11-10
- pythondistdeps: Split Python Extras names after the rightmost plus sign
- pythondistdeps: Handle edge cases of version comparisons more closely to
upstream, despite irrationality
See: https://github.com/pypa/packaging/issues/320
* Fri Jul 10 2020 Tomas Orsava <torsava@redhat.com> - 11-9
- pythondistdeps: Implement provides/requires for extras packages
- Enable --require-extras-subpackages
- Adapt Python version marker workaround for setuptools 42+
* Fri Jun 26 2020 Miro Hrončok <mhroncok@redhat.com> - 11-8
- Fix python(abi) requires generator, it picked files from almost good directories
- Add a script to generate Python bundled provides
* Thu May 21 2020 Miro Hrončok <mhroncok@redhat.com> - 11-7 * Tue Jun 15 2021 Tomas Orsava <torsava@redhat.com> - 5-7
- Use PEP 503 names for requires - Do not parse nested dist/egg-info metadata
- Resolves: rhbz#1916172
* Tue May 05 2020 Miro Hrončok <mhroncok@redhat.com> - 11-6 * Thu Dec 12 2019 Tomas Orsava <torsava@redhat.com> - 5-6
- Deduplicate automatically provided names trough Python RPM Lua macros - Enabled gating
- Related: rhbz#1776941
* Wed Apr 29 2020 Tomas Orsava <torsava@redhat.com> - 11-5 * Wed Nov 27 2019 Tomas Orsava <torsava@redhat.com> - 5-5
- Backporting proposed upstream changes - Create major-version provides only on major Python versions (2.7, 3.6)
https://github.com/rpm-software-management/rpm/pull/1195 - Fix an extra parenthesis in python.attr
- Only provide python3dist(..) for the main Python versions (BZ#1812083) - Resolves: rhbz#1776941
- Preparation for the proper handling of normalized names (BZ#1791530)
- Add a test suite (and enable it in Fedora CI)
- Better error messages for unsupported package versions
- Fix sorting of dev versions
* Tue Apr 28 2020 Miro Hrončok <mhroncok@redhat.com> - 11-4 * Fri Nov 16 2018 Lumír Balhar <lbalhar@redhat.com> - 5-4
- Don't define global Lua variables from Python generator - Require platform-python-setuptools instead of python3-setuptools
- Resolves: rhbz#1650544
* Mon Apr 20 2020 Gordon Messmer <gordon.messmer@gmail.com> - 11-3 * Sat Jul 28 2018 Miro Hrončok <mhroncok@redhat.com> - 5-3
- Handle all-zero versions without crashing
* Tue Apr 07 2020 Miro Hrončok <mhroncok@redhat.com> - 11-2
- Use dynamic %%_prefix value when matching files for python(abi) provides
- Sync with upstream RPM dist generator
* Wed Apr 01 2020 Miro Hrončok <mhroncok@redhat.com> - 11-1
- Rewrite python(abi) generators to Lua to make them faster
- RPM 4.16+ is needed
- Automatically call %%python_provide
* Thu Jan 30 2020 Fedora Release Engineering <releng@fedoraproject.org> - 10-4
- Rebuilt for https://fedoraproject.org/wiki/Fedora_32_Mass_Rebuild
* Fri Jan 17 2020 Miro Hrončok <mhroncok@redhat.com> - 10-3
- Also provide pythonXdist() with PEP 503 normalized names (#1791530)
* Fri Jan 03 2020 Miro Hrončok <mhroncok@redhat.com> - 10-2
- Fix more complicated requirement expressions by adding parenthesis
* Wed Jan 01 2020 Miro Hrončok <mhroncok@redhat.com> - 10-1
- Handle version ending with ".*" (#1758141)
- Handle compatible-release operator "~=" (#1758141)
- Use rich deps for semantically versioned dependencies
- Match Python version if minor has multiple digits (e.g. 3.10, #1777382)
- Only add setuptools requirement for egg-info packages
* Fri Jul 26 2019 Fedora Release Engineering <releng@fedoraproject.org> - 9-2
- Rebuilt for https://fedoraproject.org/wiki/Fedora_31_Mass_Rebuild
* Mon Jun 24 2019 Tomas Orsava <torsava@redhat.com> - 9-1
- Canonicalize Python versions and properly handle != spec
* Wed Apr 17 2019 Miro Hrončok <mhroncok@redhat.com> - 8-1
- console_scripts entry points to require setuptools
https://github.com/rpm-software-management/rpm/pull/666
* Sat Feb 02 2019 Fedora Release Engineering <releng@fedoraproject.org> - 7-2
- Rebuilt for https://fedoraproject.org/wiki/Fedora_30_Mass_Rebuild
* Thu Dec 20 2018 Igor Gnatenko <ignatenkobrain@fedoraproject.org> - 7-1
- Enable requires generator
* Wed Oct 03 2018 Igor Gnatenko <ignatenkobrain@fedoraproject.org> - 6-1
- Tighten regex for depgen
* Sat Jul 28 2018 Miro Hrončok <mhroncok@redhat.com> - 5-4
- Use nonstandardlib for purelib definition (#1609492) - Use nonstandardlib for purelib definition (#1609492)
* Sat Jul 28 2018 Igor Gnatenko <ignatenkobrain@fedoraproject.org> - 5-3 * Tue Jun 05 2018 Tomas Orsava <torsava@redhat.com> - 5-2
- Add pythondist generator - Switch the pythondistdeps.py script to /usr/libexec/platform-python
* Sat Jul 14 2018 Fedora Release Engineering <releng@fedoraproject.org> - 5-2
- Rebuilt for https://fedoraproject.org/wiki/Fedora_29_Mass_Rebuild
* Sun Feb 11 2018 Igor Gnatenko <ignatenkobrain@fedoraproject.org> - 5-1 * Sun Feb 11 2018 Igor Gnatenko <ignatenkobrain@fedoraproject.org> - 5-1
- Fork upstream generators - Fork upstream generators

Loading…
Cancel
Save