From 43b2eea5d4e3b71e59e29c467bcb5cf59d3e41c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miro=20Hron=C4=8Dok?= Date: Wed, 12 Aug 2026 13:55:36 +0200 Subject: [PATCH] Generate purl(pkg:pypi/...) Provides - Fixes: rhbz#2514458 - https://fedoraproject.org/wiki/Changes/Adopt_PURL_Metadata Alongside the existing python*dist() Provides, generate Package URL (purl) Provides like purl(pkg:pypi/setuptools@41.6.0) for identifying Python packages in a ecosystem-independent way. The purl name is normalized per the purl spec for PyPI (lowercase, underscores replaced with dashes, dots preserved, unlike PEP 503). The version is canonicalized per PEP 440 with trailing zeros preserved. Enabled by default via the %__purl_provides_pypi macro. Packagers can %undefine it for packages not actually on PyPI. Extras subpackages do not get purl Provides. Assisted-By: Claude Opus 4.6 --- macros.python-rpm-generators | 6 ++++ python-rpm-generators.spec | 9 +++++- pythondist.attr | 2 +- pythondistdeps.py | 27 ++++++++++++++++ .../scripts_pythondistdeps/test-data.yaml | 32 +++++++++++++++++-- tests/pythondist.sh | 4 +++ 6 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 macros.python-rpm-generators diff --git a/macros.python-rpm-generators b/macros.python-rpm-generators new file mode 100644 index 0000000..59e0557 --- /dev/null +++ b/macros.python-rpm-generators @@ -0,0 +1,6 @@ +# Enable purl(pkg:pypi/...) Provides generation by default. +# To disable for packages not actually on PyPI, add to the spec file: +# %%undefine _purl_provides_pypi +# or: +# %%global _purl_provides_pypi 0 +%_purl_provides_pypi 1 diff --git a/python-rpm-generators.spec b/python-rpm-generators.spec index 0e080a7..4a82d2d 100644 --- a/python-rpm-generators.spec +++ b/python-rpm-generators.spec @@ -1,6 +1,6 @@ Name: python-rpm-generators Summary: Dependency generators for Python RPMs -Version: 14 +Version: 15 Release: %autorelease Url: https://src.fedoraproject.org/rpms/python-rpm-generators @@ -18,6 +18,11 @@ Source4: pythondistdeps.py # LicenseRef-Fedora-Public-Domain OR CC0-1.0 OR LGPL-2.1-or-later OR GPL-2.0-or-later # Note that CC0-1.0 is not allowed for code in Fedora, so we skip it in the package License tag Source5: pythonbundles.py +# Macros to enable/disable opt-in/opt-out generator features. +# Older features have their macros in python-srpm-macros, +# but shipping them here is simpler to maintain. +# This is essentially data, so no explicit license. +Source6: macros.python-rpm-generators # See individual licenses above Source declarations # Originally, this was simplified to GPL-2.0-or-later, but "effective license" analysis is no longer allowed @@ -46,6 +51,7 @@ cp -a %{sources} . %install install -Dpm0644 -t %{buildroot}%{_fileattrsdir} *.attr install -Dpm0755 -t %{buildroot}%{_rpmconfigdir} *.py +install -Dpm0644 -t %{buildroot}%{_rpmmacrodir} macros.* %files -n python3-rpm-generators %license COPYING @@ -54,6 +60,7 @@ install -Dpm0755 -t %{buildroot}%{_rpmconfigdir} *.py %{_fileattrsdir}/pythonname.attr %{_rpmconfigdir}/pythondistdeps.py %{_rpmconfigdir}/pythonbundles.py +%{_rpmmacrodir}/macros.python-rpm-generators %changelog %autochangelog diff --git a/pythondist.attr b/pythondist.attr index ede3a51..c6e6f49 100644 --- a/pythondist.attr +++ b/pythondist.attr @@ -1,3 +1,3 @@ -%__pythondist_provides %{_rpmconfigdir}/pythondistdeps.py --provides --normalized-names-format pep503 --package-name %{name} --majorver-provides-versions %{__default_python3_version} %{?!_python_dist_allow_version_zero:--fail-if-zero} +%__pythondist_provides %{_rpmconfigdir}/pythondistdeps.py --provides --normalized-names-format pep503 --package-name %{name} --majorver-provides-versions %{__default_python3_version} %{?!_python_dist_allow_version_zero:--fail-if-zero} %[0%{?_purl_provides_pypi} ? "--purl-provides" : ""] %__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 ^%{?!flatpak:/usr}%{?flatpak:/app}/lib(64)?/python[3-9]\\.[[:digit:]]+/site-packages/[^/]+\\.(dist-info|egg-info|egg-link)$ diff --git a/pythondistdeps.py b/pythondistdeps.py index a982d9f..a7b97d9 100755 --- a/pythondistdeps.py +++ b/pythondistdeps.py @@ -20,6 +20,7 @@ from sysconfig import get_path from warnings import warn from packaging.requirements import Requirement as Requirement_ +from packaging.utils import canonicalize_version from packaging.version import parse import packaging.markers @@ -63,6 +64,13 @@ def legacy_normalize_name(name): return re.sub(r'[-_]+', '-', name).lower() +def purl_normalize_name(name): + """https://packageurl.org/docs/purl-spec/types/definitions/pypi-definition#name-definition + Lowercase and replace underscore _ with dash -. + Unlike PEP 503, dots and sequences of dashes are preserved.""" + return name.lower().replace('_', '-') + + class Requirement(Requirement_): def __init__(self, requirement_string): super(Requirement, self).__init__(requirement_string) @@ -343,12 +351,17 @@ def main(): 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('--namespace', action='store', help="Namespace for the printed Requires, Provides, Recommends and Conflicts") parser.add_argument('--fail-if-zero', action='store_true', help='Fail the script if the automatically generated Provides version was 0, which usually indicates a packaging error.') + parser.add_argument('--purl-provides', action='store_true', + help='Generate purl(pkg:pypi/...) Provides for Package URL identification') 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() if args.fail_if_zero and not args.provides: raise parser.error('--fail-if-zero only works with --provides') + if args.purl_provides and not args.provides: + raise parser.error('--purl-provides only works with --provides') + py_abi = args.requires py_deps = {} @@ -479,6 +492,20 @@ def main(): legacy_name = namespace.format('pythonegg({})({})').format(pyver_major, dist.legacy_normalized_name) if legacy_name not in py_deps: py_deps[legacy_name] = [] + if args.purl_provides and not extras_subpackage and dist.name and dist.version: + # The purl is a universal identifier, not namespace-wrapped. + # The version is canonicalized per PEP 440 but trailing + # zeros are preserved to match how versions appear on PyPI. + # The purl spec does not specify PEP 440 canonicalization, + # but without it, non-canonical spellings like "1.0beta2" + # and "1.0b2" would produce different purls for the same + # package version. + purl_name = purl_normalize_name(dist.name) + purl_version = canonicalize_version(dist.version, strip_trailing_zero=False) + purl_provide = 'purl(pkg:pypi/{}@{})'.format(purl_name, purl_version) + if purl_provide not in py_deps: + # The Provide is unversioned, version is part of name + py_deps[purl_provide] = [] if dist.version: version = dist.version spec = ('==', version) diff --git a/tests/data/scripts_pythondistdeps/test-data.yaml b/tests/data/scripts_pythondistdeps/test-data.yaml index aa0bcdc..271a6be 100644 --- a/tests/data/scripts_pythondistdeps/test-data.yaml +++ b/tests/data/scripts_pythondistdeps/test-data.yaml @@ -968,9 +968,28 @@ python3dist(backports.range) = 3.7.2 requires: python(abi) = 3.7 --requires --normalized-names-format pep503: - --provides --majorver-provides --normalized-names-format pep503: + --provides --majorver-provides --normalized-names-format pep503 --purl-provides: + usr/lib/python2.7/site-packages/kubernetes-11.0.0b2.dist-info: + provides: |- + purl(pkg:pypi/kubernetes@11.0.0b2) + python2.7dist(kubernetes) = 11~b2 + python2dist(kubernetes) = 11~b2 + requires: |- + python(abi) = 2.7 + python2.7dist(certifi) >= 14.5.14 + python2.7dist(google-auth) >= 1.0.1 + python2.7dist(ipaddress) >= 1.0.17 + python2.7dist(python-dateutil) >= 2.5.3 + python2.7dist(pyyaml) >= 3.12 + python2.7dist(requests) + python2.7dist(requests-oauthlib) + python2.7dist(setuptools) >= 21 + python2.7dist(six) >= 1.9 + python2.7dist(urllib3) >= 1.24.2 + ((python2.7dist(websocket-client) < 0.40 or python2.7dist(websocket-client) > 0.40) with (python2.7dist(websocket-client) < 0.41~~ or python2.7dist(websocket-client) >= 0.42) with (python2.7dist(websocket-client) < 0.42~~ or python2.7dist(websocket-client) >= 0.43) with python2.7dist(websocket-client) >= 0.32) usr/lib/python2.7/site-packages/zope.component-4.3.0-py2.7.egg-info: provides: |- + purl(pkg:pypi/zope.component@4.3.0) python2.7dist(zope-component) = 4.3 python2dist(zope-component) = 4.3 requires: |- @@ -980,6 +999,7 @@ python2.7dist(zope-interface) >= 4.1 usr/lib/python2.7/site-packages/zope.event-4.2.0.dist-info: provides: |- + purl(pkg:pypi/zope.event@4.2.0) python2.7dist(zope-event) = 4.2 python2dist(zope-event) = 4.2 requires: |- @@ -987,6 +1007,7 @@ python2.7dist(setuptools) usr/lib/python2.7/site-packages/zope.interface-4.6.0.dist-info: provides: |- + purl(pkg:pypi/zope.interface@4.6.0) python2.7dist(zope-interface) = 4.6 python2dist(zope-interface) = 4.6 requires: |- @@ -994,6 +1015,7 @@ python2.7dist(setuptools) usr/lib/python2.7/site-packages/zope.schema-4.4.2-py2.7.egg-info: provides: |- + purl(pkg:pypi/zope.schema@4.4.2) python2.7dist(zope-schema) = 4.4.2 python2dist(zope-schema) = 4.4.2 requires: |- @@ -1003,6 +1025,7 @@ python2.7dist(zope-interface) >= 3.6 usr/lib/python3.9/site-packages/zope.component-4.3.0-py3.9.egg-info: provides: |- + purl(pkg:pypi/zope.component@4.3.0) python3.9dist(zope-component) = 4.3 python3dist(zope-component) = 4.3 requires: |- @@ -1012,6 +1035,7 @@ python3.9dist(zope-interface) >= 4.1 usr/lib/python3.9/site-packages/zope.event-4.2.0.dist-info: provides: |- + purl(pkg:pypi/zope.event@4.2.0) python3.9dist(zope-event) = 4.2 python3dist(zope-event) = 4.2 requires: |- @@ -1019,6 +1043,7 @@ python3.9dist(setuptools) usr/lib/python3.9/site-packages/zope.interface-5.1.0-py3.9.egg-info: provides: |- + purl(pkg:pypi/zope.interface@5.1.0) python3.9dist(zope-interface) = 5.1 python3dist(zope-interface) = 5.1 requires: |- @@ -1026,6 +1051,7 @@ python3.9dist(setuptools) usr/lib/python3.9/site-packages/zope.schema-4.4.2-py3.9.egg-info: provides: |- + purl(pkg:pypi/zope.schema@4.4.2) python3.9dist(zope-schema) = 4.4.2 python3dist(zope-schema) = 4.4.2 requires: |- @@ -1035,11 +1061,13 @@ python3.9dist(zope-interface) >= 3.6 usr/lib64/python2.7/site-packages/backports.range-3.7.2-py2.7.egg-info: provides: |- + purl(pkg:pypi/backports.range@3.7.2) python2.7dist(backports-range) = 3.7.2 python2dist(backports-range) = 3.7.2 requires: python(abi) = 2.7 usr/lib64/python3.7/site-packages/backports.range-3.7.2-py3.7.egg-info: provides: |- + purl(pkg:pypi/backports.range@3.7.2) python3.7dist(backports-range) = 3.7.2 python3dist(backports-range) = 3.7.2 requires: python(abi) = 3.7 @@ -1182,7 +1210,7 @@ python(abi) = 3.9 python3.9dist(certifi) = 2016.9.26 --requires --normalized-names-format pep503 --package-name python3-zope-component+testing: - --provides --majorver-provides --normalized-names-format pep503 --package-name python3-zope-component+testing: + --provides --majorver-provides --normalized-names-format pep503 --purl-provides --package-name python3-zope-component+testing: usr/lib/python3.9/site-packages/zope.component-4.3.0-py3.9.egg-info: provides: |- python3.9dist(zope-component[testing]) = 4.3 diff --git a/tests/pythondist.sh b/tests/pythondist.sh index 51f5511..3442afe 100755 --- a/tests/pythondist.sh +++ b/tests/pythondist.sh @@ -12,6 +12,7 @@ rpm -qp --provides ${RPMDIR}/python3-zope-component-4.3.0-0.noarch.rpm | grep '^ rpm -qp --provides ${RPMDIR}/python3-zope-component-4.3.0-0.noarch.rpm | grep '^python3dist(zope-component)' rpm -qp --provides ${RPMDIR}/python3-zope-component-4.3.0-0.noarch.rpm | grep '^python'$X_Y'dist(zope\.component)' && exit 1 || true rpm -qp --provides ${RPMDIR}/python3-zope-component-4.3.0-0.noarch.rpm | grep '^python'$X_Y'dist(zope-component)' +rpm -qp --provides ${RPMDIR}/python3-zope-component-4.3.0-0.noarch.rpm | grep '^purl(pkg:pypi/zope\.component@4\.3\.0)$' rpm -qp --requires ${RPMDIR}/python3-zope-component-4.3.0-0.noarch.rpm | grep '^python'$X_Y'dist(zope-event)' rpm -qp --requires ${RPMDIR}/python3-zope-component-4.3.0-0.noarch.rpm | grep '^python'$X_Y'dist(zope-interface)' @@ -21,6 +22,7 @@ rpm -qp --provides ${RPMDIR}/python3.7-zope-component-4.3.0-0.noarch.rpm | grep rpm -qp --provides ${RPMDIR}/python3.7-zope-component-4.3.0-0.noarch.rpm | grep '^python3dist(zope-component)' && exit 1 || true rpm -qp --provides ${RPMDIR}/python3.7-zope-component-4.3.0-0.noarch.rpm | grep '^python3\.7dist(zope\.component)' && exit 1 || true rpm -qp --provides ${RPMDIR}/python3.7-zope-component-4.3.0-0.noarch.rpm | grep '^python3\.7dist(zope-component)' +rpm -qp --provides ${RPMDIR}/python3.7-zope-component-4.3.0-0.noarch.rpm | grep '^purl(pkg:pypi/zope\.component@4\.3\.0)$' rpm -qp --requires ${RPMDIR}/python3.7-zope-component-4.3.0-0.noarch.rpm | grep '^python3\.7dist(zope-event)' rpm -qp --requires ${RPMDIR}/python3.7-zope-component-4.3.0-0.noarch.rpm | grep '^python3\.7dist(zope-interface)' @@ -30,6 +32,7 @@ rpm -qp --provides ${RPMDIR}/python3.9-zope-component-4.3.0-0.noarch.rpm | grep rpm -qp --provides ${RPMDIR}/python3.9-zope-component-4.3.0-0.noarch.rpm | grep '^python3dist(zope-component)' && exit 1 || true rpm -qp --provides ${RPMDIR}/python3.9-zope-component-4.3.0-0.noarch.rpm | grep '^python3\.9dist(zope\.component)' && exit 1 || true rpm -qp --provides ${RPMDIR}/python3.9-zope-component-4.3.0-0.noarch.rpm | grep '^python3\.9dist(zope-component)' +rpm -qp --provides ${RPMDIR}/python3.9-zope-component-4.3.0-0.noarch.rpm | grep '^purl(pkg:pypi/zope\.component@4\.3\.0)$' rpm -qp --requires ${RPMDIR}/python3.9-zope-component-4.3.0-0.noarch.rpm | grep '^python3\.9dist(zope-event)' rpm -qp --requires ${RPMDIR}/python3.9-zope-component-4.3.0-0.noarch.rpm | grep '^python3\.9dist(zope-interface)' @@ -40,6 +43,7 @@ rpm -qp --provides ${RPMDIR}/python3.10-zope-component-4.3.0-0.noarch.rpm | grep rpm -qp --provides ${RPMDIR}/python3.10-zope-component-4.3.0-0.noarch.rpm | grep '^python3dist(zope-component)' && exit 1 || true rpm -qp --provides ${RPMDIR}/python3.10-zope-component-4.3.0-0.noarch.rpm | grep '^python3\.10dist(zope\.component)' && exit 1 || true rpm -qp --provides ${RPMDIR}/python3.10-zope-component-4.3.0-0.noarch.rpm | grep '^python3\.10dist(zope-component)' +rpm -qp --provides ${RPMDIR}/python3.10-zope-component-4.3.0-0.noarch.rpm | grep '^purl(pkg:pypi/zope\.component@4\.3\.0)$' rpm -qp --requires ${RPMDIR}/python3.10-zope-component-4.3.0-0.noarch.rpm | grep '^python3\.10dist(zope-event)' rpm -qp --requires ${RPMDIR}/python3.10-zope-component-4.3.0-0.noarch.rpm | grep '^python3\.10dist(zope-interface)'