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
This commit is contained in:
Miro Hrončok 2026-08-12 13:55:36 +02:00 committed by ksurma
commit 43b2eea5d4
6 changed files with 76 additions and 4 deletions

View file

@ -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

View file

@ -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

View file

@ -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)$

View file

@ -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)

View file

@ -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

View file

@ -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)'