From 6c391aa487b9032e1a82a4b6856da410be937e48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Sun, 15 Feb 2026 14:54:23 +0100 Subject: [PATCH 01/10] Add pylock select function --- src/packaging/pylock.py | 192 +++++++++++++++++++++++++++++++++++++++- tests/test_pylock.py | 24 +++++ 2 files changed, 215 insertions(+), 1 deletion(-) diff --git a/src/packaging/pylock.py b/src/packaging/pylock.py index 96aa35c6d..94be06379 100644 --- a/src/packaging/pylock.py +++ b/src/packaging/pylock.py @@ -15,7 +15,7 @@ ) from urllib.parse import urlparse -from .markers import Marker +from .markers import Marker, default_environment from .specifiers import SpecifierSet from .utils import ( NormalizedName, @@ -23,13 +23,19 @@ parse_sdist_filename, parse_wheel_filename, ) +from .tags import sys_tags +from .utils import NormalizedName, is_normalized_name, parse_wheel_filename from .version import Version if TYPE_CHECKING: # pragma: no cover + from collections.abc import Collection, Iterator from pathlib import Path from typing_extensions import Self + from .markers import Environment + from .tags import Tag + _logger = logging.getLogger(__name__) __all__ = [ @@ -692,3 +698,187 @@ def validate(self) -> None: Raises :class:`PylockValidationError` otherwise.""" self.from_dict(self.to_dict()) + + +class PylockSelectError(Exception): + """Base exception for errors raised by :func:`select()`.""" + + +def select( + lock: Pylock, + *, + environment: Environment | None = None, + tags: Sequence[Tag] | None = None, + extras: Collection[str] | None = None, + dependency_groups: Collection[str] | None = None, +) -> Iterator[ # XXX or Iterable? + tuple[ + Package, + PackageVcs | PackageDirectory | PackageArchive | PackageWheel | PackageSdist, + ] +]: + """Select what to install from the lock file. + + The *environment* and *tags* parameters represent the environment being + selected for. If unspecified, ``packaging.markers.default_environment()`` + and ``packaging.tags.sys_tags()`` are used. + + The *extras* parameter represents the extras to install. + + The *dependency_groups* parameter represents the groups to install. If + unspecified, the default groups are used. + """ + if environment is None: + environment = default_environment() + if tags is None: + tags = list(sys_tags()) + + # Validating the lock object covers some parts of the spec, such as checking + # the lock file version, and conflicting sources for packages. + # XXX we could document that we expect a valid lock object here. + lock.validate() + + # #. Gather the extras and dependency groups to install and set ``extras`` and + # ``dependency_groups`` for marker evaluation, respectively. + # + # #. ``extras`` SHOULD be set to the empty set by default. + # #. ``dependency_groups`` SHOULD be the set created from + # :ref:`pylock-default-groups` by default. + env: dict[str, str | frozenset[str]] = { + **cast("dict[str, str]", environment), + "extras": frozenset(extras or []), # XXX is normalization needed? + "dependency_groups": frozenset( + dependency_groups or lock.default_groups or [] + ), # XXX is normalization needed? + } + env_python_version = environment.get("python_version") + + # #. Check if the metadata version specified by :ref:`pylock-lock-version` is + # supported; an error or warning MUST be raised as appropriate. + # Covered by lock.validate() above. + + # #. If :ref:`pylock-requires-python` is specified, check that the environment + # being installed for meets the requirement; an error MUST be raised if it is + # not met. + if lock.requires_python is not None: + if not env_python_version: + raise PylockSelectError( + f"Provided environment does not specify a Python version, " + f"but the lock file requires Python {lock.requires_python!r}" + ) + if not lock.requires_python.contains(env_python_version, prereleases=True): + # XXX confirm prereleases=True + raise PylockSelectError( + f"Provided environment does not satisfy the Python version " + f"requirement {lock.requires_python!r}" + ) + + # #. If :ref:`pylock-environments` is specified, check that at least one of the + # environment marker expressions is satisfied; an error MUST be raised if no + # expression is satisfied. + if lock.environments: + for env_marker in lock.environments: + if env_marker.evaluate(env, context="lock_file"): # XXX check context + break + else: + raise PylockSelectError( + "Provided environment does not satisfy any of the " + "environments specified in the lock file" + ) + + # #. For each package listed in :ref:`pylock-packages`: + selected_packages_by_name: dict[str, tuple[int, Package]] = {} + for package_index, package in enumerate(lock.packages): + # #. If :ref:`pylock-packages-marker` is specified, check if it is satisfied; + # if it isn't, skip to the next package. + if package.marker and not package.marker.evaluate( + env, context="requirement" + ): # XXX check context + continue + + # #. If :ref:`pylock-packages-requires-python` is specified, check if it is + # satisfied; an error MUST be raised if it isn't. + if package.requires_python: + if not env_python_version: + raise PylockSelectError( + f"Provided environment does not specify a Python version, " + f"but package {package.name!r} at packages[{package_index}] " + f"requires Python {package.requires_python!r}" + ) + if not package.requires_python.contains( + env_python_version, prereleases=True + ): + # XXX confirm prereleases=True + raise PylockSelectError( + f"Provided environment does not satisfy the Python version " + f"requirement {package.requires_python!r} for package " + f"{package.name!r} at packages[{package_index}]" + ) + + # #. Check that no other conflicting instance of the package has been slated to + # be installed; an error about the ambiguity MUST be raised otherwise. + if package.name in selected_packages_by_name: + raise PylockSelectError( + f"Multiple packages with the name {package.name!r} are " + f"selected at packages[{package_index}] and " + f"packages[{selected_packages_by_name[package.name][0]}]" + ) + + # #. Check that the source of the package is specified appropriately (i.e. + # there are no conflicting sources in the package entry); + # an error MUST be raised if any issues are found. + # Covered by lock.validate() above. + + # #. Add the package to the set of packages to install. + selected_packages_by_name[package.name] = (package_index, package) + + # #. For each package to be installed: + for package_index, package in selected_packages_by_name.values(): + # - If :ref:`pylock-packages-vcs` is set: + if package.vcs is not None: + yield package, package.vcs + + # - Else if :ref:`pylock-packages-directory` is set: + elif package.directory is not None: + yield package, package.directory + + # - Else if :ref:`pylock-packages-archive` is set: + elif package.archive is not None: + yield package, package.archive + + # - Else if there are entries for :ref:`pylock-packages-wheels`: + elif package.wheels: + # #. Look for the appropriate wheel file based on + # :ref:`pylock-packages-wheels-name`; if one is not found then move on + # to :ref:`pylock-packages-sdist` or an error MUST be raised about a + # lack of source for the project. + for package_wheel in package.wheels: + try: + assert package_wheel.name # XXX get name from path or url + package_wheel_tags = parse_wheel_filename(package_wheel.name)[-1] + except Exception as e: + raise PylockSelectError( + f"Invalid wheel filename {package_wheel.name!r} for " + f"package {package.name!r} at packages[{package_index}]" + ) from e + if not package_wheel_tags.isdisjoint(tags): + yield package, package_wheel + break + else: + if package.sdist is not None: + yield package, package.sdist + else: + raise PylockSelectError( + f"No matching wheel found matching the provided tags " + f"for package {package.name!r} at packages[{package_index}], " + f"and no sdist available as a fallback" + ) + + # - Else if no :ref:`pylock-packages-wheels` file is found or + # :ref:`pylock-packages-sdist` is solely set: + elif package.sdist is not None: + yield package, package.sdist + + else: + # Covered by lock.validate() above. + raise NotImplementedError diff --git a/tests/test_pylock.py b/tests/test_pylock.py index 7c0090ecc..6f8ca0f9a 100644 --- a/tests/test_pylock.py +++ b/tests/test_pylock.py @@ -16,9 +16,11 @@ PackageVcs, PackageWheel, Pylock, + PylockSelectError, PylockUnsupportedVersionError, PylockValidationError, is_valid_pylock_path, + select, ) from packaging.specifiers import SpecifierSet from packaging.utils import NormalizedName @@ -758,3 +760,25 @@ def test_validate_attestation_identity_invalid_kind() -> None: "Unexpected type int (expected str) " "in 'packages[0].attestation-identities[0].kind'" ) + + +def test_select_smoke_test() -> None: + pylock_path = Path(__file__).parent / "pylock" / "pylock.spec-example.toml" + lock = Pylock.from_dict(tomllib.loads(pylock_path.read_text())) + for package, dist in select(lock): + assert isinstance(package, Package) + assert isinstance(dist, PackageWheel) + + +def test_require_python_mismatch() -> None: + pylock = Pylock( + lock_version=Version("1.0"), + created_by="some_tool", + requires_python=SpecifierSet("==3.14.*"), + packages=[], + ) + with pytest.raises( + PylockSelectError, + match="Provided environment does not satisfy the Python version requirement", + ): + list(select(pylock, environment={"python_version": "3.15"})) From 3cb4609cd9df71540228a4332ade85436da092d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Sat, 21 Feb 2026 11:51:40 +0100 Subject: [PATCH 02/10] pylock select: make it a Pylock instance method --- src/packaging/pylock.py | 338 ++++++++++++++++++++-------------------- tests/test_pylock.py | 46 +++++- 2 files changed, 213 insertions(+), 171 deletions(-) diff --git a/src/packaging/pylock.py b/src/packaging/pylock.py index 94be06379..05ee8bb4f 100644 --- a/src/packaging/pylock.py +++ b/src/packaging/pylock.py @@ -306,6 +306,10 @@ class PylockUnsupportedVersionError(PylockValidationError): """Raised when encountering an unsupported `lock_version`.""" +class PylockSelectError(Exception): + """Base exception for errors raised by :method:`Pylock.select()`.""" + + @dataclass(frozen=True, init=False) class PackageVcs: type: str @@ -699,186 +703,188 @@ def validate(self) -> None: Raises :class:`PylockValidationError` otherwise.""" self.from_dict(self.to_dict()) + def select( + self, + *, + environment: Environment | None = None, + tags: Sequence[Tag] | None = None, + extras: Collection[str] | None = None, + dependency_groups: Collection[str] | None = None, + ) -> Iterator[ # XXX or Iterable? + tuple[ + Package, + PackageVcs + | PackageDirectory + | PackageArchive + | PackageWheel + | PackageSdist, + ] + ]: + """Select what to install from the lock file. + + The *environment* and *tags* parameters represent the environment being + selected for. If unspecified, ``packaging.markers.default_environment()`` + and ``packaging.tags.sys_tags()`` are used. + + The *extras* parameter represents the extras to install. + + The *dependency_groups* parameter represents the groups to install. If + unspecified, the default groups are used. + """ + if environment is None: + environment = default_environment() + if tags is None: + tags = list(sys_tags()) + + # Validating the lock object covers some parts of the spec, such as checking + # the lock file version, and conflicting sources for packages. + # XXX we could document that we expect a valid lock object here. + self.validate() + + # #. Gather the extras and dependency groups to install and set ``extras`` and + # ``dependency_groups`` for marker evaluation, respectively. + # + # #. ``extras`` SHOULD be set to the empty set by default. + # #. ``dependency_groups`` SHOULD be the set created from + # :ref:`pylock-default-groups` by default. + env: dict[str, str | frozenset[str]] = { + **cast("dict[str, str]", environment), + "extras": frozenset(extras or []), + "dependency_groups": frozenset( + dependency_groups or self.default_groups or [] + ), + } + env_python_version = environment.get("python_version") + + # #. Check if the metadata version specified by :ref:`pylock-lock-version` is + # supported; an error or warning MUST be raised as appropriate. + # Covered by lock.validate() above. -class PylockSelectError(Exception): - """Base exception for errors raised by :func:`select()`.""" - - -def select( - lock: Pylock, - *, - environment: Environment | None = None, - tags: Sequence[Tag] | None = None, - extras: Collection[str] | None = None, - dependency_groups: Collection[str] | None = None, -) -> Iterator[ # XXX or Iterable? - tuple[ - Package, - PackageVcs | PackageDirectory | PackageArchive | PackageWheel | PackageSdist, - ] -]: - """Select what to install from the lock file. - - The *environment* and *tags* parameters represent the environment being - selected for. If unspecified, ``packaging.markers.default_environment()`` - and ``packaging.tags.sys_tags()`` are used. - - The *extras* parameter represents the extras to install. - - The *dependency_groups* parameter represents the groups to install. If - unspecified, the default groups are used. - """ - if environment is None: - environment = default_environment() - if tags is None: - tags = list(sys_tags()) - - # Validating the lock object covers some parts of the spec, such as checking - # the lock file version, and conflicting sources for packages. - # XXX we could document that we expect a valid lock object here. - lock.validate() - - # #. Gather the extras and dependency groups to install and set ``extras`` and - # ``dependency_groups`` for marker evaluation, respectively. - # - # #. ``extras`` SHOULD be set to the empty set by default. - # #. ``dependency_groups`` SHOULD be the set created from - # :ref:`pylock-default-groups` by default. - env: dict[str, str | frozenset[str]] = { - **cast("dict[str, str]", environment), - "extras": frozenset(extras or []), # XXX is normalization needed? - "dependency_groups": frozenset( - dependency_groups or lock.default_groups or [] - ), # XXX is normalization needed? - } - env_python_version = environment.get("python_version") - - # #. Check if the metadata version specified by :ref:`pylock-lock-version` is - # supported; an error or warning MUST be raised as appropriate. - # Covered by lock.validate() above. - - # #. If :ref:`pylock-requires-python` is specified, check that the environment - # being installed for meets the requirement; an error MUST be raised if it is - # not met. - if lock.requires_python is not None: - if not env_python_version: - raise PylockSelectError( - f"Provided environment does not specify a Python version, " - f"but the lock file requires Python {lock.requires_python!r}" - ) - if not lock.requires_python.contains(env_python_version, prereleases=True): - # XXX confirm prereleases=True - raise PylockSelectError( - f"Provided environment does not satisfy the Python version " - f"requirement {lock.requires_python!r}" - ) - - # #. If :ref:`pylock-environments` is specified, check that at least one of the - # environment marker expressions is satisfied; an error MUST be raised if no - # expression is satisfied. - if lock.environments: - for env_marker in lock.environments: - if env_marker.evaluate(env, context="lock_file"): # XXX check context - break - else: - raise PylockSelectError( - "Provided environment does not satisfy any of the " - "environments specified in the lock file" - ) - - # #. For each package listed in :ref:`pylock-packages`: - selected_packages_by_name: dict[str, tuple[int, Package]] = {} - for package_index, package in enumerate(lock.packages): - # #. If :ref:`pylock-packages-marker` is specified, check if it is satisfied; - # if it isn't, skip to the next package. - if package.marker and not package.marker.evaluate( - env, context="requirement" - ): # XXX check context - continue - - # #. If :ref:`pylock-packages-requires-python` is specified, check if it is - # satisfied; an error MUST be raised if it isn't. - if package.requires_python: + # #. If :ref:`pylock-requires-python` is specified, check that the environment + # being installed for meets the requirement; an error MUST be raised if it is + # not met. + if self.requires_python is not None: if not env_python_version: raise PylockSelectError( f"Provided environment does not specify a Python version, " - f"but package {package.name!r} at packages[{package_index}] " - f"requires Python {package.requires_python!r}" + f"but the lock file requires Python {self.requires_python!r}" ) - if not package.requires_python.contains( - env_python_version, prereleases=True - ): + if not self.requires_python.contains(env_python_version, prereleases=True): # XXX confirm prereleases=True raise PylockSelectError( f"Provided environment does not satisfy the Python version " - f"requirement {package.requires_python!r} for package " - f"{package.name!r} at packages[{package_index}]" + f"requirement {self.requires_python!r}" ) - # #. Check that no other conflicting instance of the package has been slated to - # be installed; an error about the ambiguity MUST be raised otherwise. - if package.name in selected_packages_by_name: - raise PylockSelectError( - f"Multiple packages with the name {package.name!r} are " - f"selected at packages[{package_index}] and " - f"packages[{selected_packages_by_name[package.name][0]}]" - ) - - # #. Check that the source of the package is specified appropriately (i.e. - # there are no conflicting sources in the package entry); - # an error MUST be raised if any issues are found. - # Covered by lock.validate() above. - - # #. Add the package to the set of packages to install. - selected_packages_by_name[package.name] = (package_index, package) - - # #. For each package to be installed: - for package_index, package in selected_packages_by_name.values(): - # - If :ref:`pylock-packages-vcs` is set: - if package.vcs is not None: - yield package, package.vcs - - # - Else if :ref:`pylock-packages-directory` is set: - elif package.directory is not None: - yield package, package.directory - - # - Else if :ref:`pylock-packages-archive` is set: - elif package.archive is not None: - yield package, package.archive - - # - Else if there are entries for :ref:`pylock-packages-wheels`: - elif package.wheels: - # #. Look for the appropriate wheel file based on - # :ref:`pylock-packages-wheels-name`; if one is not found then move on - # to :ref:`pylock-packages-sdist` or an error MUST be raised about a - # lack of source for the project. - for package_wheel in package.wheels: - try: - assert package_wheel.name # XXX get name from path or url - package_wheel_tags = parse_wheel_filename(package_wheel.name)[-1] - except Exception as e: - raise PylockSelectError( - f"Invalid wheel filename {package_wheel.name!r} for " - f"package {package.name!r} at packages[{package_index}]" - ) from e - if not package_wheel_tags.isdisjoint(tags): - yield package, package_wheel + # #. If :ref:`pylock-environments` is specified, check that at least one of the + # environment marker expressions is satisfied; an error MUST be raised if no + # expression is satisfied. + if self.environments: + for env_marker in self.environments: + if env_marker.evaluate(env, context="lock_file"): # XXX check context break else: - if package.sdist is not None: - yield package, package.sdist - else: + raise PylockSelectError( + "Provided environment does not satisfy any of the " + "environments specified in the lock file" + ) + + # #. For each package listed in :ref:`pylock-packages`: + selected_packages_by_name: dict[str, tuple[int, Package]] = {} + for package_index, package in enumerate(self.packages): + # #. If :ref:`pylock-packages-marker` is specified, check if it is + # satisfied;if it isn't, skip to the next package. + if package.marker and not package.marker.evaluate( + env, context="requirement" + ): # XXX check context + continue + + # #. If :ref:`pylock-packages-requires-python` is specified, check if it is + # satisfied; an error MUST be raised if it isn't. + if package.requires_python: + if not env_python_version: raise PylockSelectError( - f"No matching wheel found matching the provided tags " - f"for package {package.name!r} at packages[{package_index}], " - f"and no sdist available as a fallback" + f"Provided environment does not specify a Python version, " + f"but package {package.name!r} at packages[{package_index}] " + f"requires Python {package.requires_python!r}" + ) + if not package.requires_python.contains( + env_python_version, prereleases=True + ): + # XXX confirm prereleases=True + raise PylockSelectError( + f"Provided environment does not satisfy the Python version " + f"requirement {package.requires_python!r} for package " + f"{package.name!r} at packages[{package_index}]" ) - # - Else if no :ref:`pylock-packages-wheels` file is found or - # :ref:`pylock-packages-sdist` is solely set: - elif package.sdist is not None: - yield package, package.sdist + # #. Check that no other conflicting instance of the package has been slated + # to be installed; an error about the ambiguity MUST be raised otherwise. + if package.name in selected_packages_by_name: + raise PylockSelectError( + f"Multiple packages with the name {package.name!r} are " + f"selected at packages[{package_index}] and " + f"packages[{selected_packages_by_name[package.name][0]}]" + ) - else: + # #. Check that the source of the package is specified appropriately (i.e. + # there are no conflicting sources in the package entry); + # an error MUST be raised if any issues are found. # Covered by lock.validate() above. - raise NotImplementedError + + # #. Add the package to the set of packages to install. + selected_packages_by_name[package.name] = (package_index, package) + + # #. For each package to be installed: + for package_index, package in selected_packages_by_name.values(): + # - If :ref:`pylock-packages-vcs` is set: + if package.vcs is not None: + yield package, package.vcs + + # - Else if :ref:`pylock-packages-directory` is set: + elif package.directory is not None: + yield package, package.directory + + # - Else if :ref:`pylock-packages-archive` is set: + elif package.archive is not None: + yield package, package.archive + + # - Else if there are entries for :ref:`pylock-packages-wheels`: + elif package.wheels: + # #. Look for the appropriate wheel file based on + # :ref:`pylock-packages-wheels-name`; if one is not found then move + # on to :ref:`pylock-packages-sdist` or an error MUST be raised about + # a lack of source for the project. + for package_wheel in package.wheels: + try: + assert package_wheel.name # XXX get name from path or url + package_wheel_tags = parse_wheel_filename(package_wheel.name)[ + -1 + ] + except Exception as e: + raise PylockSelectError( + f"Invalid wheel filename {package_wheel.name!r} for " + f"package {package.name!r} at packages[{package_index}]" + ) from e + if not package_wheel_tags.isdisjoint(tags): + yield package, package_wheel + break + else: + if package.sdist is not None: + yield package, package.sdist + else: + raise PylockSelectError( + f"No wheel found matching the provided tags " + f"for package {package.name!r} " + f"at packages[{package_index}], " + f"and no sdist available as a fallback" + ) + + # - Else if no :ref:`pylock-packages-wheels` file is found or + # :ref:`pylock-packages-sdist` is solely set: + elif package.sdist is not None: + yield package, package.sdist + + else: + # Covered by lock.validate() above. + raise NotImplementedError diff --git a/tests/test_pylock.py b/tests/test_pylock.py index 6f8ca0f9a..0a20ab741 100644 --- a/tests/test_pylock.py +++ b/tests/test_pylock.py @@ -2,13 +2,14 @@ import datetime import sys +from dataclasses import dataclass from pathlib import Path from typing import Any import pytest import tomli_w -from packaging.markers import Marker +from packaging.markers import Environment, Marker from packaging.pylock import ( Package, PackageDirectory, @@ -20,9 +21,9 @@ PylockUnsupportedVersionError, PylockValidationError, is_valid_pylock_path, - select, ) from packaging.specifiers import SpecifierSet +from packaging.tags import Tag from packaging.utils import NormalizedName from packaging.version import Version @@ -762,15 +763,45 @@ def test_validate_attestation_identity_invalid_kind() -> None: ) +@dataclass +class Platform: + tags: list[Tag] + environment: Environment + + +_py312_linux = Platform( + tags=[ + Tag("cp312", "cp312", "manylinux_2_17_x86_64"), + Tag("py3", "none", "any"), + ], + environment={ + "implementation_name": "cpython", + "implementation_version": "3.12.12", + "os_name": "posix", + "platform_machine": "x86_64", + "platform_release": "6.8.0-100-generic", + "platform_system": "Linux", + "platform_version": "#100-Ubuntu SMP PREEMPT_DYNAMIC", + "python_full_version": "3.12.12", + "platform_python_implementation": "CPython", + "python_version": "3.12", + "sys_platform": "linux", + }, +) + + def test_select_smoke_test() -> None: pylock_path = Path(__file__).parent / "pylock" / "pylock.spec-example.toml" lock = Pylock.from_dict(tomllib.loads(pylock_path.read_text())) - for package, dist in select(lock): + for package, dist in lock.select( + tags=_py312_linux.tags, + environment=_py312_linux.environment, + ): assert isinstance(package, Package) assert isinstance(dist, PackageWheel) -def test_require_python_mismatch() -> None: +def test_select_require_python_mismatch() -> None: pylock = Pylock( lock_version=Version("1.0"), created_by="some_tool", @@ -781,4 +812,9 @@ def test_require_python_mismatch() -> None: PylockSelectError, match="Provided environment does not satisfy the Python version requirement", ): - list(select(pylock, environment={"python_version": "3.15"})) + list( + pylock.select( + tags=_py312_linux.tags, + environment=_py312_linux.environment, + ) + ) From 5a19f68516e1b6c24cc705a12e4554d1b97d41e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Tue, 24 Feb 2026 10:51:12 +0100 Subject: [PATCH 03/10] pylock select: remove specific error handling for wheel name This will be handled by validation. --- src/packaging/pylock.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/packaging/pylock.py b/src/packaging/pylock.py index 05ee8bb4f..19c3c73b8 100644 --- a/src/packaging/pylock.py +++ b/src/packaging/pylock.py @@ -856,16 +856,8 @@ def select( # on to :ref:`pylock-packages-sdist` or an error MUST be raised about # a lack of source for the project. for package_wheel in package.wheels: - try: - assert package_wheel.name # XXX get name from path or url - package_wheel_tags = parse_wheel_filename(package_wheel.name)[ - -1 - ] - except Exception as e: - raise PylockSelectError( - f"Invalid wheel filename {package_wheel.name!r} for " - f"package {package.name!r} at packages[{package_index}]" - ) from e + assert package_wheel.name # XXX get name from path or url + package_wheel_tags = parse_wheel_filename(package_wheel.name)[-1] if not package_wheel_tags.isdisjoint(tags): yield package, package_wheel break From 40455b9bca9edfdbfd006fbbd215cf16fc017ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Tue, 24 Feb 2026 10:54:07 +0100 Subject: [PATCH 04/10] pylock sekect: recommend pre-validation --- src/packaging/pylock.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/packaging/pylock.py b/src/packaging/pylock.py index 19c3c73b8..ede9a72b3 100644 --- a/src/packaging/pylock.py +++ b/src/packaging/pylock.py @@ -307,7 +307,7 @@ class PylockUnsupportedVersionError(PylockValidationError): class PylockSelectError(Exception): - """Base exception for errors raised by :method:`Pylock.select()`.""" + """Base exception for errors raised by :meth:`Pylock.select`.""" @dataclass(frozen=True, init=False) @@ -723,24 +723,24 @@ def select( """Select what to install from the lock file. The *environment* and *tags* parameters represent the environment being - selected for. If unspecified, ``packaging.markers.default_environment()`` - and ``packaging.tags.sys_tags()`` are used. + selected for. If unspecified, + ``packaging.markers.default_environment()`` and + ``packaging.tags.sys_tags()`` are used. The *extras* parameter represents the extras to install. The *dependency_groups* parameter represents the groups to install. If unspecified, the default groups are used. + + For better error reporting, it is recommended to use this method on + valid Pylock instances (i.e. one obtained from :meth:`Pylock.from_dict` + or if constructed manually, after calling :meth:`Pylock.validate`). """ if environment is None: environment = default_environment() if tags is None: tags = list(sys_tags()) - # Validating the lock object covers some parts of the spec, such as checking - # the lock file version, and conflicting sources for packages. - # XXX we could document that we expect a valid lock object here. - self.validate() - # #. Gather the extras and dependency groups to install and set ``extras`` and # ``dependency_groups`` for marker evaluation, respectively. # From a70e12918c1fe2b142bbfa84d7ed0bf11766a936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Sat, 28 Feb 2026 14:39:22 +0100 Subject: [PATCH 05/10] pylock select: full coverage and fixes --- src/packaging/pylock.py | 89 ++++----- tests/test_pylock.py | 62 +----- tests/test_pylock_select.py | 369 ++++++++++++++++++++++++++++++++++++ 3 files changed, 411 insertions(+), 109 deletions(-) create mode 100644 tests/test_pylock_select.py diff --git a/src/packaging/pylock.py b/src/packaging/pylock.py index ede9a72b3..d8568a02f 100644 --- a/src/packaging/pylock.py +++ b/src/packaging/pylock.py @@ -15,7 +15,7 @@ ) from urllib.parse import urlparse -from .markers import Marker, default_environment +from .markers import Environment, Marker, default_environment from .specifiers import SpecifierSet from .utils import ( NormalizedName, @@ -33,7 +33,6 @@ from typing_extensions import Self - from .markers import Environment from .tags import Tag _logger = logging.getLogger(__name__) @@ -710,7 +709,7 @@ def select( tags: Sequence[Tag] | None = None, extras: Collection[str] | None = None, dependency_groups: Collection[str] | None = None, - ) -> Iterator[ # XXX or Iterable? + ) -> Iterator[ tuple[ Package, PackageVcs @@ -736,8 +735,6 @@ def select( valid Pylock instances (i.e. one obtained from :meth:`Pylock.from_dict` or if constructed manually, after calling :meth:`Pylock.validate`). """ - if environment is None: - environment = default_environment() if tags is None: tags = list(sys_tags()) @@ -747,41 +744,46 @@ def select( # #. ``extras`` SHOULD be set to the empty set by default. # #. ``dependency_groups`` SHOULD be the set created from # :ref:`pylock-default-groups` by default. - env: dict[str, str | frozenset[str]] = { - **cast("dict[str, str]", environment), - "extras": frozenset(extras or []), - "dependency_groups": frozenset( - dependency_groups or self.default_groups or [] + env = cast( + "dict[str, str | frozenset[str]]", + dict( + environment or {}, # Marker.evaluate will fill-up + extras=frozenset(extras or []), + dependency_groups=frozenset( + (self.default_groups or []) + if dependency_groups is None # to allow selecting no groups + else dependency_groups + ), ), - } - env_python_version = environment.get("python_version") + ) + env_python_version = ( + environment["python_version"] + if environment + else default_environment()["python_version"] + ) # #. Check if the metadata version specified by :ref:`pylock-lock-version` is # supported; an error or warning MUST be raised as appropriate. - # Covered by lock.validate() above. + # Covered by lock.validate() which is a precondition for this method. # #. If :ref:`pylock-requires-python` is specified, check that the environment # being installed for meets the requirement; an error MUST be raised if it is # not met. - if self.requires_python is not None: - if not env_python_version: - raise PylockSelectError( - f"Provided environment does not specify a Python version, " - f"but the lock file requires Python {self.requires_python!r}" - ) - if not self.requires_python.contains(env_python_version, prereleases=True): - # XXX confirm prereleases=True - raise PylockSelectError( - f"Provided environment does not satisfy the Python version " - f"requirement {self.requires_python!r}" - ) + if self.requires_python and not self.requires_python.contains( + env_python_version, + prereleases=True, # XXX confirm prereleases=True + ): + raise PylockSelectError( + f"Provided environment does not satisfy the Python version " + f"requirement {self.requires_python!r}" + ) # #. If :ref:`pylock-environments` is specified, check that at least one of the # environment marker expressions is satisfied; an error MUST be raised if no # expression is satisfied. if self.environments: for env_marker in self.environments: - if env_marker.evaluate(env, context="lock_file"): # XXX check context + if env_marker.evaluate(env, context="lock_file"): break else: raise PylockSelectError( @@ -794,29 +796,20 @@ def select( for package_index, package in enumerate(self.packages): # #. If :ref:`pylock-packages-marker` is specified, check if it is # satisfied;if it isn't, skip to the next package. - if package.marker and not package.marker.evaluate( - env, context="requirement" - ): # XXX check context + if package.marker and not package.marker.evaluate(env, context="lock_file"): continue # #. If :ref:`pylock-packages-requires-python` is specified, check if it is # satisfied; an error MUST be raised if it isn't. - if package.requires_python: - if not env_python_version: - raise PylockSelectError( - f"Provided environment does not specify a Python version, " - f"but package {package.name!r} at packages[{package_index}] " - f"requires Python {package.requires_python!r}" - ) - if not package.requires_python.contains( - env_python_version, prereleases=True - ): - # XXX confirm prereleases=True - raise PylockSelectError( - f"Provided environment does not satisfy the Python version " - f"requirement {package.requires_python!r} for package " - f"{package.name!r} at packages[{package_index}]" - ) + if package.requires_python and not package.requires_python.contains( + env_python_version, + prereleases=True, # XXX confirm prereleases=True + ): + raise PylockSelectError( + f"Provided environment does not satisfy the Python version " + f"requirement {package.requires_python!r} for package " + f"{package.name!r} at packages[{package_index}]" + ) # #. Check that no other conflicting instance of the package has been slated # to be installed; an error about the ambiguity MUST be raised otherwise. @@ -830,7 +823,7 @@ def select( # #. Check that the source of the package is specified appropriately (i.e. # there are no conflicting sources in the package entry); # an error MUST be raised if any issues are found. - # Covered by lock.validate() above. + # Covered by lock.validate() which is a precondition for this method. # #. Add the package to the set of packages to install. selected_packages_by_name[package.name] = (package_index, package) @@ -878,5 +871,5 @@ def select( yield package, package.sdist else: - # Covered by lock.validate() above. - raise NotImplementedError + # Covered by lock.validate() which is a precondition for this method. + raise NotImplementedError # pragma: no cover diff --git a/tests/test_pylock.py b/tests/test_pylock.py index 0a20ab741..7c0090ecc 100644 --- a/tests/test_pylock.py +++ b/tests/test_pylock.py @@ -2,14 +2,13 @@ import datetime import sys -from dataclasses import dataclass from pathlib import Path from typing import Any import pytest import tomli_w -from packaging.markers import Environment, Marker +from packaging.markers import Marker from packaging.pylock import ( Package, PackageDirectory, @@ -17,13 +16,11 @@ PackageVcs, PackageWheel, Pylock, - PylockSelectError, PylockUnsupportedVersionError, PylockValidationError, is_valid_pylock_path, ) from packaging.specifiers import SpecifierSet -from packaging.tags import Tag from packaging.utils import NormalizedName from packaging.version import Version @@ -761,60 +758,3 @@ def test_validate_attestation_identity_invalid_kind() -> None: "Unexpected type int (expected str) " "in 'packages[0].attestation-identities[0].kind'" ) - - -@dataclass -class Platform: - tags: list[Tag] - environment: Environment - - -_py312_linux = Platform( - tags=[ - Tag("cp312", "cp312", "manylinux_2_17_x86_64"), - Tag("py3", "none", "any"), - ], - environment={ - "implementation_name": "cpython", - "implementation_version": "3.12.12", - "os_name": "posix", - "platform_machine": "x86_64", - "platform_release": "6.8.0-100-generic", - "platform_system": "Linux", - "platform_version": "#100-Ubuntu SMP PREEMPT_DYNAMIC", - "python_full_version": "3.12.12", - "platform_python_implementation": "CPython", - "python_version": "3.12", - "sys_platform": "linux", - }, -) - - -def test_select_smoke_test() -> None: - pylock_path = Path(__file__).parent / "pylock" / "pylock.spec-example.toml" - lock = Pylock.from_dict(tomllib.loads(pylock_path.read_text())) - for package, dist in lock.select( - tags=_py312_linux.tags, - environment=_py312_linux.environment, - ): - assert isinstance(package, Package) - assert isinstance(dist, PackageWheel) - - -def test_select_require_python_mismatch() -> None: - pylock = Pylock( - lock_version=Version("1.0"), - created_by="some_tool", - requires_python=SpecifierSet("==3.14.*"), - packages=[], - ) - with pytest.raises( - PylockSelectError, - match="Provided environment does not satisfy the Python version requirement", - ): - list( - pylock.select( - tags=_py312_linux.tags, - environment=_py312_linux.environment, - ) - ) diff --git a/tests/test_pylock_select.py b/tests/test_pylock_select.py new file mode 100644 index 000000000..76d75d825 --- /dev/null +++ b/tests/test_pylock_select.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +import dataclasses +import sys +from pathlib import Path +from typing import TYPE_CHECKING, cast + +import pytest + +from packaging.markers import Marker +from packaging.pylock import ( + Package, + PackageArchive, + PackageDirectory, + PackageSdist, + PackageVcs, + PackageWheel, + Pylock, + PylockSelectError, +) +from packaging.specifiers import SpecifierSet +from packaging.tags import Tag +from packaging.version import Version + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +if TYPE_CHECKING: + from packaging.markers import Environment + from packaging.utils import NormalizedName + + +@dataclasses.dataclass +class Platform: + tags: list[Tag] + environment: Environment + + +_py312_linux = Platform( + tags=[ + Tag("cp312", "cp312", "manylinux_2_17_x86_64"), + Tag("py3", "none", "any"), + ], + environment={ + "implementation_name": "cpython", + "implementation_version": "3.12.12", + "os_name": "posix", + "platform_machine": "x86_64", + "platform_release": "6.8.0-100-generic", + "platform_system": "Linux", + "platform_version": "#100-Ubuntu SMP PREEMPT_DYNAMIC", + "python_full_version": "3.12.12", + "platform_python_implementation": "CPython", + "python_version": "3.12", + "sys_platform": "linux", + }, +) + + +def test_smoke_test() -> None: + pylock_path = Path(__file__).parent / "pylock" / "pylock.spec-example.toml" + lock = Pylock.from_dict(tomllib.loads(pylock_path.read_text())) + for package, dist in lock.select( + tags=_py312_linux.tags, + environment=_py312_linux.environment, + ): + assert isinstance(package, Package) + assert isinstance(dist, PackageWheel) + + +def test_lock_no_matching_env() -> None: + pylock = Pylock( + lock_version=Version("1.0"), + created_by="some_tool", + environments=[Marker('python_version == "3.14"')], + packages=[], + ) + pylock.validate() + with pytest.raises( + PylockSelectError, + match=( + "Provided environment does not satisfy any of the " + "environments specified in the lock file" + ), + ): + list( + pylock.select( + tags=_py312_linux.tags, + environment=_py312_linux.environment, + ) + ) + + +def test_lock_require_python_mismatch() -> None: + pylock = Pylock( + lock_version=Version("1.0"), + created_by="some_tool", + requires_python=SpecifierSet("==3.14.*"), + packages=[], + ) + pylock.validate() + with pytest.raises( + PylockSelectError, + match="Provided environment does not satisfy the Python version requirement", + ): + list( + pylock.select( + tags=_py312_linux.tags, + environment=_py312_linux.environment, + ) + ) + + +def test_package_require_python_mismatch() -> None: + pylock = Pylock( + lock_version=Version("1.0"), + created_by="some_tool", + packages=[ + Package( + name=cast("NormalizedName", "foo"), + version=Version("1.0"), + requires_python=SpecifierSet("==3.14.*"), + directory=PackageDirectory(path="."), + ), + ], + ) + pylock.validate() + with pytest.raises( + PylockSelectError, + match=( + r"Provided environment does not satisfy the Python version requirement " + r".* for package 'foo'" + ), + ): + list( + pylock.select( + tags=_py312_linux.tags, + environment=_py312_linux.environment, + ) + ) + + +def test_package_select_by_marker() -> None: + pylock = Pylock( + lock_version=Version("1.0"), + created_by="some_tool", + packages=[ + Package( + name=cast("NormalizedName", "tomli"), + marker=Marker('python_version < "3.11"'), + version=Version("1.0"), + archive=PackageArchive( + path="tomli-1.0.tar.gz", hashes={"sha256": "abc123"} + ), + ), + Package( + name=cast("NormalizedName", "foo"), + marker=Marker('python_version >= "3.11"'), + version=Version("1.0"), + archive=PackageArchive( + path="foo-1.0.tar.gz", hashes={"sha256": "abc123"} + ), + ), + ], + ) + pylock.validate() + selected = list( + pylock.select( + tags=_py312_linux.tags, + environment=_py312_linux.environment, + ) + ) + assert len(selected) == 1 + assert selected[0][0].name == "foo" + + +def test_duplicate_packages() -> None: + pylock = Pylock( + lock_version=Version("1.0"), + created_by="some_tool", + packages=[ + Package( + name=cast("NormalizedName", "foo"), + version=Version("1.0"), + archive=PackageArchive( + path="tomli-1.0.tar.gz", hashes={"sha256": "abc123"} + ), + ), + Package( + name=cast("NormalizedName", "foo"), + version=Version("2.0"), + archive=PackageArchive( + path="foo-1.0.tar.gz", hashes={"sha256": "abc123"} + ), + ), + ], + ) + pylock.validate() + with pytest.raises( + PylockSelectError, + match=( + r"Multiple packages with the name 'foo' are selected " + r"at packages\[1\] and packages\[0\]" + ), + ): + list( + pylock.select( + tags=_py312_linux.tags, + environment=_py312_linux.environment, + ) + ) + + +def test_yield_all_types() -> None: + pylock = Pylock( + lock_version=Version("1.0"), + created_by="some_tool", + packages=[ + Package( + name=cast("NormalizedName", "foo-archive"), + archive=PackageArchive( + path="tomli-1.0.tar.gz", hashes={"sha256": "abc123"} + ), + ), + Package( + name=cast("NormalizedName", "foo-directory"), + directory=PackageDirectory(path="./foo-directory"), + ), + Package( + name=cast("NormalizedName", "foo-vcs"), + vcs=PackageVcs( + type="git", url="https://example.com/foo.git", commit_id="fa123" + ), + ), + Package( + name=cast("NormalizedName", "foo-sdist"), + sdist=PackageSdist(path="foo-1.0.tar.gz", hashes={"sha256": "abc123"}), + ), + Package( + name=cast("NormalizedName", "foo-wheel"), + wheels=[ + PackageWheel( + name="foo-1.0-py3-none-any.whl", + path="./foo-1.0-py3-none-any.whl", + hashes={"sha256": "abc123"}, + ) + ], + ), + ], + ) + pylock.validate() + selected = list(pylock.select()) + assert len(selected) == 5 + + +def test_sdist_fallback() -> None: + pylock = Pylock( + lock_version=Version("1.0"), + created_by="some_tool", + packages=[ + Package( + name=cast("NormalizedName", "foo"), + sdist=PackageSdist( + path="foo-1.0.tar.gz", + hashes={"sha256": "abc123"}, + ), + wheels=[ + PackageWheel( + name="foo-1.0-py5-none-any.whl", + path="./foo-1.0-py5-none-any.whl", + hashes={"sha256": "abc123"}, + ) + ], + ), + ], + ) + selected = list(pylock.select()) + assert len(selected) == 1 + assert isinstance(selected[0][1], PackageSdist) + + +def test_missing_sdist_fallback() -> None: + pylock = Pylock( + lock_version=Version("1.0"), + created_by="some_tool", + packages=[ + Package( + name=cast("NormalizedName", "foo"), + wheels=[ + PackageWheel( + name="foo-1.0-py5-none-any.whl", + path="./foo-1.0-py5-none-any.whl", + hashes={"sha256": "abc123"}, + ) + ], + ), + ], + ) + pylock.validate() + with pytest.raises( + PylockSelectError, match=r"No wheel found matching .* and no sdist available" + ): + list(pylock.select()) + + +@pytest.mark.parametrize( + ("extras", "dependency_groups", "expected"), + [ + (None, None, ["foo", "foo-dev"]), # select default_groups + (None, ["dev"], ["foo", "foo-dev"]), # same as default_groups + (None, [], ["foo"]), # select no groups + (None, ["docs"], ["foo", "foo-docs"]), + (None, ["dev", "docs"], ["foo", "foo-dev", "foo-docs"]), + ([], None, ["foo", "foo-dev"]), + (["feat1"], None, ["foo", "foo-dev", "foo-feat1"]), + (["feat2"], None, ["foo", "foo-dev", "foo-feat2"]), + (["feat1", "feat2"], None, ["foo", "foo-dev", "foo-feat1", "foo-feat2"]), + (["feat1", "feat2"], ["docs"], ["foo", "foo-docs", "foo-feat1", "foo-feat2"]), + ], +) +def test_extras_and_groups( + extras: list[str] | None, + dependency_groups: list[str] | None, + expected: list[str], +) -> None: + pylock = Pylock( + lock_version=Version("1.0"), + created_by="some_tool", + extras=[cast("NormalizedName", "feat1"), cast("NormalizedName", "feat2")], + dependency_groups=["dev", "docs"], + default_groups=["dev"], + packages=[ + Package( + name=cast("NormalizedName", "foo"), + directory=PackageDirectory(path="./foo"), + ), + Package( + name=cast("NormalizedName", "foo-dev"), + directory=PackageDirectory(path="./foo-dev"), + marker=Marker("'dev' in dependency_groups"), + ), + Package( + name=cast("NormalizedName", "foo-docs"), + directory=PackageDirectory(path="./foo-docs"), + marker=Marker("'docs' in dependency_groups"), + ), + Package( + name=cast("NormalizedName", "foo-feat1"), + directory=PackageDirectory(path="./foo-feat1"), + marker=Marker("'feat1' in extras"), + ), + Package( + name=cast("NormalizedName", "foo-feat2"), + directory=PackageDirectory(path="./foo-feat2"), + marker=Marker("'feat2' in extras"), + ), + ], + ) + pylock.validate() + selected_names = [ + package.name + for package, _ in pylock.select( + extras=extras, + dependency_groups=dependency_groups, + ) + ] + assert selected_names == expected From dd87b9899299caba7208d5bd5b1e02d356737ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Sun, 1 Mar 2026 11:59:44 +0100 Subject: [PATCH 06/10] pylock select: make a set of supported tags --- src/packaging/pylock.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/packaging/pylock.py b/src/packaging/pylock.py index d8568a02f..f553de147 100644 --- a/src/packaging/pylock.py +++ b/src/packaging/pylock.py @@ -735,8 +735,7 @@ def select( valid Pylock instances (i.e. one obtained from :meth:`Pylock.from_dict` or if constructed manually, after calling :meth:`Pylock.validate`). """ - if tags is None: - tags = list(sys_tags()) + supported_tags = frozenset(tags or sys_tags()) # #. Gather the extras and dependency groups to install and set ``extras`` and # ``dependency_groups`` for marker evaluation, respectively. @@ -851,7 +850,7 @@ def select( for package_wheel in package.wheels: assert package_wheel.name # XXX get name from path or url package_wheel_tags = parse_wheel_filename(package_wheel.name)[-1] - if not package_wheel_tags.isdisjoint(tags): + if not package_wheel_tags.isdisjoint(supported_tags): yield package, package_wheel break else: From 63cfe1d5fb0dc34d6b4891f8db81244ba5b08eb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Sat, 7 Mar 2026 11:40:27 +0100 Subject: [PATCH 07/10] pylock select: use wheel filename property --- src/packaging/pylock.py | 13 ++++++------- tests/test_pylock_select.py | 1 - 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/packaging/pylock.py b/src/packaging/pylock.py index f553de147..dc34512ad 100644 --- a/src/packaging/pylock.py +++ b/src/packaging/pylock.py @@ -12,19 +12,19 @@ Callable, Protocol, TypeVar, + cast, ) from urllib.parse import urlparse from .markers import Environment, Marker, default_environment from .specifiers import SpecifierSet +from .tags import sys_tags from .utils import ( NormalizedName, is_normalized_name, parse_sdist_filename, parse_wheel_filename, ) -from .tags import sys_tags -from .utils import NormalizedName, is_normalized_name, parse_wheel_filename from .version import Version if TYPE_CHECKING: # pragma: no cover @@ -847,11 +847,10 @@ def select( # :ref:`pylock-packages-wheels-name`; if one is not found then move # on to :ref:`pylock-packages-sdist` or an error MUST be raised about # a lack of source for the project. - for package_wheel in package.wheels: - assert package_wheel.name # XXX get name from path or url - package_wheel_tags = parse_wheel_filename(package_wheel.name)[-1] - if not package_wheel_tags.isdisjoint(supported_tags): - yield package, package_wheel + for wheel in package.wheels: + wheel_tags = parse_wheel_filename(wheel.filename)[-1] + if not wheel_tags.isdisjoint(supported_tags): + yield package, wheel break else: if package.sdist is not None: diff --git a/tests/test_pylock_select.py b/tests/test_pylock_select.py index 76d75d825..65eb798fa 100644 --- a/tests/test_pylock_select.py +++ b/tests/test_pylock_select.py @@ -268,7 +268,6 @@ def test_sdist_fallback() -> None: ), wheels=[ PackageWheel( - name="foo-1.0-py5-none-any.whl", path="./foo-1.0-py5-none-any.whl", hashes={"sha256": "abc123"}, ) From b7e2df19fb37997b735942296e15febaa75d60ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Sat, 7 Mar 2026 11:57:33 +0100 Subject: [PATCH 08/10] pylock select: tweak requires_python chekcks --- src/packaging/pylock.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/packaging/pylock.py b/src/packaging/pylock.py index dc34512ad..b7de0e87d 100644 --- a/src/packaging/pylock.py +++ b/src/packaging/pylock.py @@ -750,7 +750,7 @@ def select( extras=frozenset(extras or []), dependency_groups=frozenset( (self.default_groups or []) - if dependency_groups is None # to allow selecting no groups + if dependency_groups is None # to allow selecting no group else dependency_groups ), ), @@ -770,7 +770,6 @@ def select( # not met. if self.requires_python and not self.requires_python.contains( env_python_version, - prereleases=True, # XXX confirm prereleases=True ): raise PylockSelectError( f"Provided environment does not satisfy the Python version " @@ -802,7 +801,6 @@ def select( # satisfied; an error MUST be raised if it isn't. if package.requires_python and not package.requires_python.contains( env_python_version, - prereleases=True, # XXX confirm prereleases=True ): raise PylockSelectError( f"Provided environment does not satisfy the Python version " From 47c397f9482024872881d55a11a469d032447bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Sat, 7 Mar 2026 12:10:38 +0100 Subject: [PATCH 09/10] pylock select: tweak environments check --- src/packaging/pylock.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/packaging/pylock.py b/src/packaging/pylock.py index b7de0e87d..e3e3c8798 100644 --- a/src/packaging/pylock.py +++ b/src/packaging/pylock.py @@ -781,7 +781,9 @@ def select( # expression is satisfied. if self.environments: for env_marker in self.environments: - if env_marker.evaluate(env, context="lock_file"): + if env_marker.evaluate( + cast("dict[str, str]", environment or {}), context="requirement" + ): break else: raise PylockSelectError( From a0256b29e08f5072b5c3b111a597ab02d4960eca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Sat, 21 Mar 2026 14:38:57 +0100 Subject: [PATCH 10/10] pylock select: tweak requires_python checks --- src/packaging/pylock.py | 20 +++++++++++--------- tests/pylock/pylock.spec-example.toml | 2 +- tests/test_pylock_select.py | 4 ++-- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/packaging/pylock.py b/src/packaging/pylock.py index e3e3c8798..add6df6aa 100644 --- a/src/packaging/pylock.py +++ b/src/packaging/pylock.py @@ -755,10 +755,10 @@ def select( ), ), ) - env_python_version = ( - environment["python_version"] + env_python_full_version = ( + environment["python_full_version"] if environment - else default_environment()["python_version"] + else default_environment()["python_full_version"] ) # #. Check if the metadata version specified by :ref:`pylock-lock-version` is @@ -769,11 +769,12 @@ def select( # being installed for meets the requirement; an error MUST be raised if it is # not met. if self.requires_python and not self.requires_python.contains( - env_python_version, + env_python_full_version, ): raise PylockSelectError( - f"Provided environment does not satisfy the Python version " - f"requirement {self.requires_python!r}" + f"python_full_version {env_python_full_version!r} " + f"in provided environment does not satisfy the Python version " + f"requirement {str(self.requires_python)!r}" ) # #. If :ref:`pylock-environments` is specified, check that at least one of the @@ -802,11 +803,12 @@ def select( # #. If :ref:`pylock-packages-requires-python` is specified, check if it is # satisfied; an error MUST be raised if it isn't. if package.requires_python and not package.requires_python.contains( - env_python_version, + env_python_full_version, ): raise PylockSelectError( - f"Provided environment does not satisfy the Python version " - f"requirement {package.requires_python!r} for package " + f"python_full_version {env_python_full_version!r} " + f"in provided environment does not satisfy the Python version " + f"requirement {str(package.requires_python)!r} for package " f"{package.name!r} at packages[{package_index}]" ) diff --git a/tests/pylock/pylock.spec-example.toml b/tests/pylock/pylock.spec-example.toml index 80d3f14ee..127aed1b3 100644 --- a/tests/pylock/pylock.spec-example.toml +++ b/tests/pylock/pylock.spec-example.toml @@ -7,7 +7,7 @@ lock-version = '1.0' environments = ['sys_platform == "win32"', 'sys_platform == "linux"'] -requires-python = '==3.12' +requires-python = '==3.12.*' created-by = 'mousebender' [[packages]] diff --git a/tests/test_pylock_select.py b/tests/test_pylock_select.py index 65eb798fa..2a62c9fd0 100644 --- a/tests/test_pylock_select.py +++ b/tests/test_pylock_select.py @@ -103,7 +103,7 @@ def test_lock_require_python_mismatch() -> None: pylock.validate() with pytest.raises( PylockSelectError, - match="Provided environment does not satisfy the Python version requirement", + match="provided environment does not satisfy the Python version requirement", ): list( pylock.select( @@ -130,7 +130,7 @@ def test_package_require_python_mismatch() -> None: with pytest.raises( PylockSelectError, match=( - r"Provided environment does not satisfy the Python version requirement " + r"provided environment does not satisfy the Python version requirement " r".* for package 'foo'" ), ):