diff --git a/src/packaging/pylock.py b/src/packaging/pylock.py index 96aa35c6d..e3e3c8798 100644 --- a/src/packaging/pylock.py +++ b/src/packaging/pylock.py @@ -12,11 +12,13 @@ Callable, Protocol, TypeVar, + cast, ) from urllib.parse import urlparse -from .markers import Marker +from .markers import Environment, Marker, default_environment from .specifiers import SpecifierSet +from .tags import sys_tags from .utils import ( NormalizedName, is_normalized_name, @@ -26,10 +28,13 @@ 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 .tags import Tag + _logger = logging.getLogger(__name__) __all__ = [ @@ -300,6 +305,10 @@ class PylockUnsupportedVersionError(PylockValidationError): """Raised when encountering an unsupported `lock_version`.""" +class PylockSelectError(Exception): + """Base exception for errors raised by :meth:`Pylock.select`.""" + + @dataclass(frozen=True, init=False) class PackageVcs: type: str @@ -692,3 +701,173 @@ 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[ + 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. + + 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`). + """ + 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. + # + # #. ``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 = 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 group + else dependency_groups + ), + ), + ) + 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() 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 and not self.requires_python.contains( + env_python_version, + ): + 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( + cast("dict[str, str]", environment or {}), context="requirement" + ): + 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(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="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 and not package.requires_python.contains( + env_python_version, + ): + 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() 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) + + # #. 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 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: + 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() which is a precondition for this method. + raise NotImplementedError # pragma: no cover diff --git a/tests/test_pylock_select.py b/tests/test_pylock_select.py new file mode 100644 index 000000000..65eb798fa --- /dev/null +++ b/tests/test_pylock_select.py @@ -0,0 +1,368 @@ +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( + 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