From 77d69c70516f8ee1186ffe42dce1ef8673f45cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Sun, 8 Mar 2026 13:55:58 +0100 Subject: [PATCH] Add compatible tags selector utility --- docs/tags.rst | 2 ++ src/packaging/tags.py | 56 +++++++++++++++++++++++++++++++++++++++++++ tests/test_tags.py | 25 +++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/docs/tags.rst b/docs/tags.rst index 3c4d725d8..4ae787ff6 100644 --- a/docs/tags.rst +++ b/docs/tags.rst @@ -46,6 +46,8 @@ items that applications should need to reference, in order to parse and check ta .. autofunction:: sys_tags +.. autofunction:: create_compatible_tags_selector + Low Level Interface diff --git a/src/packaging/tags.py b/src/packaging/tags.py index b11b3c91c..d6b707f1d 100644 --- a/src/packaging/tags.py +++ b/src/packaging/tags.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging +import operator import platform import re import struct @@ -13,16 +14,23 @@ import sysconfig from importlib.machinery import EXTENSION_SUFFIXES from typing import ( + TYPE_CHECKING, Any, Iterable, Iterator, Sequence, Tuple, + TypeVar, cast, ) from . import _manylinux, _musllinux +if TYPE_CHECKING: + from collections.abc import Callable, Collection, Iterable + from typing import AbstractSet + + __all__ = [ "INTERPRETER_SHORT_NAMES", "AppleVersion", @@ -31,6 +39,7 @@ "android_platforms", "compatible_tags", "cpython_tags", + "create_compatible_tags_selector", "generic_tags", "interpreter_name", "interpreter_version", @@ -50,6 +59,7 @@ def __dir__() -> list[str]: PythonVersion = Sequence[int] AppleVersion = Tuple[int, int] +_T = TypeVar("_T") INTERPRETER_SHORT_NAMES: dict[str, str] = { "python": "py", # Generic. @@ -769,3 +779,49 @@ def sys_tags(*, warn: bool = False) -> Iterator[Tag]: else: interp = None yield from compatible_tags(interpreter=interp) + + +def create_compatible_tags_selector( + tags: Iterable[Tag], +) -> Callable[[Collection[tuple[_T, AbstractSet[Tag]]]], Iterator[_T]]: + """Create a callable to select things compatible with supported tags. + + This function accepts an ordered sequence of tags, with the preferred + tags first. + + The returned callable accepts a collection of tuples (thing, set[Tag]), + and returns an iterator of things, with the things with the best + matching tags first. + + Example to select compatible wheel filenames: + + >>> from packaging import tags + >>> from packaging.utils import parse_wheel_filename + >>> selector = tags.create_compatible_tags_selector(tags.sys_tags()) + >>> filenames = ["foo-1.0-py3-none-any.whl", "foo-1.0-py2-none-any.whl"] + >>> list(selector([ + ... (filename, parse_wheel_filename(filename)[-1]) for filename in filenames + ... ])) + ['foo-1.0-py3-none-any.whl'] + + .. versionadded:: 26.1 + """ + tag_ranks: dict[Tag, int] = {} + for rank, tag in enumerate(tags): + tag_ranks.setdefault(tag, rank) # ignore duplicate tags, keep first + supported_tags = tag_ranks.keys() + + def selector( + tagged_things: Collection[tuple[_T, AbstractSet[Tag]]], + ) -> Iterator[_T]: + ranked_things: list[tuple[_T, int]] = [] + for thing, thing_tags in tagged_things: + supported_thing_tags = thing_tags & supported_tags + if supported_thing_tags: + thing_rank = min(tag_ranks[t] for t in supported_thing_tags) + ranked_things.append((thing, thing_rank)) + return iter( + thing for thing, _ in sorted(ranked_things, key=operator.itemgetter(1)) + ) + + return selector diff --git a/tests/test_tags.py b/tests/test_tags.py index 0439377ac..74d40a546 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -1694,3 +1694,28 @@ def test_pickle() -> None: # Make sure equality works between a pickle/unpickle round trip. tag = tags.Tag("py3", "none", "any") assert pickle.loads(pickle.dumps(tag)) == tag + + +@pytest.mark.parametrize( + ("supported", "things", "expected"), + [ + (["t1", "t2"], ["t1", "t2"], ["t1", "t2"]), + (["t1", "t2"], ["t3", "t4"], []), + (["t1", "t2"], ["t2", "t1"], ["t1", "t2"]), + (["t1", "t2", "t1"], ["t2", "t1"], ["t1", "t2"]), + (["t1", "t3"], ["t2", "t1"], ["t1"]), + (["t1", "t3"], ["t2.t3", "t1"], ["t1", "t2.t3"]), + (["t1"], ["t2", "t1"], ["t1"]), + ], +) +def test_create_compatible_tags_selector( + supported: list[str], things: list[str], expected: list[str] +) -> None: + def t_to_tag(t: str) -> tags.Tag: + return tags.Tag("py3", "none", t) + + def t_to_tags(t: str) -> frozenset[tags.Tag]: + return tags.parse_tag(f"py3-none-{t}") + + selector = tags.create_compatible_tags_selector([t_to_tag(t) for t in supported]) + assert list(selector([(t, t_to_tags(t)) for t in things])) == expected