diff --git a/python/private/pypi/requirements_parser/BUILD.bazel b/python/private/pypi/requirements_parser/BUILD.bazel new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/private/pypi/requirements_parser/resolve_target_platforms.py b/python/private/pypi/requirements_parser/resolve_target_platforms.py new file mode 100755 index 0000000000..c899a943cc --- /dev/null +++ b/python/private/pypi/requirements_parser/resolve_target_platforms.py @@ -0,0 +1,63 @@ +"""A CLI to evaluate env markers for requirements files. + +A simple script to evaluate the `requirements.txt` files. Currently it is only +handling environment markers in the requirements files, but in the future it +may handle more things. We require a `python` interpreter that can run on the +host platform and then we depend on the [packaging] PyPI wheel. + +In order to be able to resolve requirements files for any platform, we are +re-using the same code that is used in the `whl_library` installer. See +[here](../whl_installer/wheel.py). + +Requirements for the code are: +- Depends only on `packaging` and core Python. +- Produces the same result irrespective of the Python interpreter platform or version. + +[packaging]: https://packaging.pypa.io/en/stable/ +""" + +import argparse +import json +import pathlib + +from packaging.requirements import Requirement + +from python.private.pypi.whl_installer.platform import Platform + +INPUT_HELP = """\ +Input path to read the requirements as a json file, the keys in the dictionary +are the requirements lines and the values are strings of target platforms. +""" +OUTPUT_HELP = """\ +Output to write the requirements as a json filepath, the keys in the dictionary +are the requirements lines and the values are strings of target platforms, which +got changed based on the evaluated markers. +""" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input_path", type=pathlib.Path, help=INPUT_HELP.strip()) + parser.add_argument("output_path", type=pathlib.Path, help=OUTPUT_HELP.strip()) + args = parser.parse_args() + + with args.input_path.open() as f: + reqs = json.load(f) + + response = {} + for requirement_line, target_platforms in reqs.items(): + entry, prefix, hashes = requirement_line.partition("--hash") + hashes = prefix + hashes + + req = Requirement(entry) + for p in target_platforms: + (platform,) = Platform.from_string(p) + if not req.marker or req.marker.evaluate(platform.env_markers("")): + response.setdefault(requirement_line, []).append(p) + + with args.output_path.open("w") as f: + json.dump(response, f) + + +if __name__ == "__main__": + main() diff --git a/python/private/pypi/whl_installer/BUILD.bazel b/python/private/pypi/whl_installer/BUILD.bazel index 49f1a119c1..5fb617004d 100644 --- a/python/private/pypi/whl_installer/BUILD.bazel +++ b/python/private/pypi/whl_installer/BUILD.bazel @@ -6,6 +6,7 @@ py_library( srcs = [ "arguments.py", "namespace_pkgs.py", + "platform.py", "wheel.py", "wheel_installer.py", ], diff --git a/python/private/pypi/whl_installer/arguments.py b/python/private/pypi/whl_installer/arguments.py index bb841ea9ab..29bea8026e 100644 --- a/python/private/pypi/whl_installer/arguments.py +++ b/python/private/pypi/whl_installer/arguments.py @@ -17,6 +17,8 @@ import pathlib from typing import Any, Dict, Set +from python.private.pypi.whl_installer.platform import Platform + def parser(**kwargs: Any) -> argparse.ArgumentParser: """Create a parser for the wheel_installer tool.""" @@ -39,6 +41,12 @@ def parser(**kwargs: Any) -> argparse.ArgumentParser: action="store", help="Extra arguments to pass down to pip.", ) + parser.add_argument( + "--platform", + action="extend", + type=Platform.from_string, + help="Platforms to target dependencies. Can be used multiple times.", + ) parser.add_argument( "--pip_data_exclude", action="store", diff --git a/python/private/pypi/whl_installer/platform.py b/python/private/pypi/whl_installer/platform.py new file mode 100644 index 0000000000..11dd6e37ab --- /dev/null +++ b/python/private/pypi/whl_installer/platform.py @@ -0,0 +1,304 @@ +# Copyright 2024 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Utility class to inspect an extracted wheel directory""" + +import platform +import sys +from dataclasses import dataclass +from enum import Enum +from typing import Any, Dict, Iterator, List, Optional, Union + + +class OS(Enum): + linux = 1 + osx = 2 + windows = 3 + darwin = osx + win32 = windows + + @classmethod + def interpreter(cls) -> "OS": + "Return the interpreter operating system." + return cls[sys.platform.lower()] + + def __str__(self) -> str: + return self.name.lower() + + +class Arch(Enum): + x86_64 = 1 + x86_32 = 2 + aarch64 = 3 + ppc = 4 + ppc64le = 5 + s390x = 6 + arm = 7 + amd64 = x86_64 + arm64 = aarch64 + i386 = x86_32 + i686 = x86_32 + x86 = x86_32 + + @classmethod + def interpreter(cls) -> "Arch": + "Return the currently running interpreter architecture." + # FIXME @aignas 2023-12-13: Hermetic toolchain on Windows 3.11.6 + # is returning an empty string here, so lets default to x86_64 + return cls[platform.machine().lower() or "x86_64"] + + def __str__(self) -> str: + return self.name.lower() + + +def _as_int(value: Optional[Union[OS, Arch]]) -> int: + """Convert one of the enums above to an int for easier sorting algorithms. + + Args: + value: The value of an enum or None. + + Returns: + -1 if we get None, otherwise, the numeric value of the given enum. + """ + if value is None: + return -1 + + return int(value.value) + + +def host_interpreter_minor_version() -> int: + return sys.version_info.minor + + +@dataclass(frozen=True) +class Platform: + os: Optional[OS] = None + arch: Optional[Arch] = None + minor_version: Optional[int] = None + + @classmethod + def all( + cls, + want_os: Optional[OS] = None, + minor_version: Optional[int] = None, + ) -> List["Platform"]: + return sorted( + [ + cls(os=os, arch=arch, minor_version=minor_version) + for os in OS + for arch in Arch + if not want_os or want_os == os + ] + ) + + @classmethod + def host(cls) -> List["Platform"]: + """Use the Python interpreter to detect the platform. + + We extract `os` from sys.platform and `arch` from platform.machine + + Returns: + A list of parsed values which makes the signature the same as + `Platform.all` and `Platform.from_string`. + """ + return [ + Platform( + os=OS.interpreter(), + arch=Arch.interpreter(), + minor_version=host_interpreter_minor_version(), + ) + ] + + def all_specializations(self) -> Iterator["Platform"]: + """Return the platform itself and all its unambiguous specializations. + + For more info about specializations see + https://bazel.build/docs/configurable-attributes + """ + yield self + if self.arch is None: + for arch in Arch: + yield Platform(os=self.os, arch=arch, minor_version=self.minor_version) + if self.os is None: + for os in OS: + yield Platform(os=os, arch=self.arch, minor_version=self.minor_version) + if self.arch is None and self.os is None: + for os in OS: + for arch in Arch: + yield Platform(os=os, arch=arch, minor_version=self.minor_version) + + def __lt__(self, other: Any) -> bool: + """Add a comparison method, so that `sorted` returns the most specialized platforms first.""" + if not isinstance(other, Platform) or other is None: + raise ValueError(f"cannot compare {other} with Platform") + + self_arch, self_os = _as_int(self.arch), _as_int(self.os) + other_arch, other_os = _as_int(other.arch), _as_int(other.os) + + if self_os == other_os: + return self_arch < other_arch + else: + return self_os < other_os + + def __str__(self) -> str: + if self.minor_version is None: + if self.os is None and self.arch is None: + return "//conditions:default" + + if self.arch is None: + return f"@platforms//os:{self.os}" + else: + return f"{self.os}_{self.arch}" + + if self.arch is None and self.os is None: + return f"@//python/config_settings:is_python_3.{self.minor_version}" + + if self.arch is None: + return f"cp3{self.minor_version}_{self.os}_anyarch" + + if self.os is None: + return f"cp3{self.minor_version}_anyos_{self.arch}" + + return f"cp3{self.minor_version}_{self.os}_{self.arch}" + + @classmethod + def from_string(cls, platform: Union[str, List[str]]) -> List["Platform"]: + """Parse a string and return a list of platforms""" + platform = [platform] if isinstance(platform, str) else list(platform) + ret = set() + for p in platform: + if p == "host": + ret.update(cls.host()) + continue + + abi, _, tail = p.partition("_") + if not abi.startswith("cp"): + # The first item is not an abi + tail = p + abi = "" + os, _, arch = tail.partition("_") + arch = arch or "*" + + minor_version = int(abi[len("cp3") :]) if abi else None + + if arch != "*": + ret.add( + cls( + os=OS[os] if os != "*" else None, + arch=Arch[arch], + minor_version=minor_version, + ) + ) + + else: + ret.update( + cls.all( + want_os=OS[os] if os != "*" else None, + minor_version=minor_version, + ) + ) + + return sorted(ret) + + # NOTE @aignas 2023-12-05: below is the minimum number of accessors that are defined in + # https://peps.python.org/pep-0496/ to make rules_python generate dependencies. + # + # WARNING: It may not work in cases where the python implementation is different between + # different platforms. + + # derived from OS + @property + def os_name(self) -> str: + if self.os == OS.linux or self.os == OS.osx: + return "posix" + elif self.os == OS.windows: + return "nt" + else: + return "" + + @property + def sys_platform(self) -> str: + if self.os == OS.linux: + return "linux" + elif self.os == OS.osx: + return "darwin" + elif self.os == OS.windows: + return "win32" + else: + return "" + + @property + def platform_system(self) -> str: + if self.os == OS.linux: + return "Linux" + elif self.os == OS.osx: + return "Darwin" + elif self.os == OS.windows: + return "Windows" + else: + return "" + + # derived from OS and Arch + @property + def platform_machine(self) -> str: + """Guess the target 'platform_machine' marker. + + NOTE @aignas 2023-12-05: this may not work on really new systems, like + Windows if they define the platform markers in a different way. + """ + if self.arch == Arch.x86_64: + return "x86_64" + elif self.arch == Arch.x86_32 and self.os != OS.osx: + return "i386" + elif self.arch == Arch.x86_32: + return "" + elif self.arch == Arch.aarch64 and self.os == OS.linux: + return "aarch64" + elif self.arch == Arch.aarch64: + # Assuming that OSX and Windows use this one since the precedent is set here: + # https://github.com/cgohlke/win_arm64-wheels + return "arm64" + elif self.os != OS.linux: + return "" + elif self.arch == Arch.ppc: + return "ppc" + elif self.arch == Arch.ppc64le: + return "ppc64le" + elif self.arch == Arch.s390x: + return "s390x" + else: + return "" + + def env_markers(self, extra: str) -> Dict[str, str]: + # If it is None, use the host version + minor_version = self.minor_version or host_interpreter_minor_version() + + return { + "extra": extra, + "os_name": self.os_name, + "sys_platform": self.sys_platform, + "platform_machine": self.platform_machine, + "platform_system": self.platform_system, + "platform_release": "", # unset + "platform_version": "", # unset + "python_version": f"3.{minor_version}", + # FIXME @aignas 2024-01-14: is putting zero last a good idea? Maybe we should + # use `20` or something else to avoid having weird issues where the full version is used for + # matching and the author decides to only support 3.y.5 upwards. + "implementation_version": f"3.{minor_version}.0", + "python_full_version": f"3.{minor_version}.0", + # we assume that the following are the same as the interpreter used to setup the deps: + # "implementation_name": "cpython" + # "platform_python_implementation: "CPython", + } diff --git a/python/private/pypi/whl_installer/wheel.py b/python/private/pypi/whl_installer/wheel.py index da81b5ea9f..d95b33a194 100644 --- a/python/private/pypi/whl_installer/wheel.py +++ b/python/private/pypi/whl_installer/wheel.py @@ -25,6 +25,275 @@ from packaging.requirements import Requirement from pip._vendor.packaging.utils import canonicalize_name +from python.private.pypi.whl_installer.platform import ( + Platform, + host_interpreter_minor_version, +) + + +@dataclass(frozen=True) +class FrozenDeps: + deps: List[str] + deps_select: Dict[str, List[str]] + + +class Deps: + """Deps is a dependency builder that has a build() method to return FrozenDeps.""" + + def __init__( + self, + name: str, + requires_dist: List[str], + *, + extras: Optional[Set[str]] = None, + platforms: Optional[Set[Platform]] = None, + ): + """Create a new instance and parse the requires_dist + + Args: + name (str): The name of the whl distribution + requires_dist (list[Str]): The Requires-Dist from the METADATA of the whl + distribution. + extras (set[str], optional): The list of requested extras, defaults to None. + platforms (set[Platform], optional): The list of target platforms, defaults to + None. If the list of platforms has multiple `minor_version` values, it + will change the code to generate the select statements using + `@rules_python//python/config_settings:is_python_3.y` conditions. + """ + self.name: str = Deps._normalize(name) + self._platforms: Set[Platform] = platforms or set() + self._target_versions = {p.minor_version for p in platforms or {}} + self._default_minor_version = None + if platforms and len(self._target_versions) > 2: + # TODO @aignas 2024-06-23: enable this to be set via a CLI arg + # for being more explicit. + self._default_minor_version = host_interpreter_minor_version() + + if None in self._target_versions and len(self._target_versions) > 2: + raise ValueError( + f"all python versions need to be specified explicitly, got: {platforms}" + ) + + # Sort so that the dictionary order in the FrozenDeps is deterministic + # without the final sort because Python retains insertion order. That way + # the sorting by platform is limited within the Platform class itself and + # the unit-tests for the Deps can be simpler. + reqs = sorted( + (Requirement(wheel_req) for wheel_req in requires_dist), + key=lambda x: f"{x.name}:{sorted(x.extras)}", + ) + + want_extras = self._resolve_extras(reqs, extras) + + # Then add all of the requirements in order + self._deps: Set[str] = set() + self._select: Dict[Platform, Set[str]] = defaultdict(set) + for req in reqs: + self._add_req(req, want_extras) + + def _add(self, dep: str, platform: Optional[Platform]): + dep = Deps._normalize(dep) + + # Self-edges are processed in _resolve_extras + if dep == self.name: + return + + if not platform: + self._deps.add(dep) + + # If the dep is in the platform-specific list, remove it from the select. + pop_keys = [] + for p, deps in self._select.items(): + if dep not in deps: + continue + + deps.remove(dep) + if not deps: + pop_keys.append(p) + + for p in pop_keys: + self._select.pop(p) + return + + if dep in self._deps: + # If the dep is already in the main dependency list, no need to add it in the + # platform-specific dependency list. + return + + # Add the platform-specific dep + self._select[platform].add(dep) + + # Add the dep to specializations of the given platform if they + # exist in the select statement. + for p in platform.all_specializations(): + if p not in self._select: + continue + + self._select[p].add(dep) + + if len(self._select[platform]) == 1: + # We are adding a new item to the select and we need to ensure that + # existing dependencies from less specialized platforms are propagated + # to the newly added dependency set. + for p, deps in self._select.items(): + # Check if the existing platform overlaps with the given platform + if p == platform or platform not in p.all_specializations(): + continue + + self._select[platform].update(self._select[p]) + + def _maybe_add_common_dep(self, dep): + if len(self._target_versions) < 2: + return + + platforms = [Platform()] + [ + Platform(minor_version=v) for v in self._target_versions + ] + + # If the dep is targeting all target python versions, lets add it to + # the common dependency list to simplify the select statements. + for p in platforms: + if p not in self._select: + return + + if dep not in self._select[p]: + return + + # All of the python version-specific branches have the dep, so lets add + # it to the common deps. + self._deps.add(dep) + for p in platforms: + self._select[p].remove(dep) + if not self._select[p]: + self._select.pop(p) + + @staticmethod + def _normalize(name: str) -> str: + return re.sub(r"[-_.]+", "_", name).lower() + + def _resolve_extras( + self, reqs: List[Requirement], extras: Optional[Set[str]] + ) -> Set[str]: + """Resolve extras which are due to depending on self[some_other_extra]. + + Some packages may have cyclic dependencies resulting from extras being used, one example is + `etils`, where we have one set of extras as aliases for other extras + and we have an extra called 'all' that includes all other extras. + + Example: github.com/google/etils/blob/a0b71032095db14acf6b33516bca6d885fe09e35/pyproject.toml#L32. + + When the `requirements.txt` is generated by `pip-tools`, then it is likely that + this step is not needed, but for other `requirements.txt` files this may be useful. + + NOTE @aignas 2023-12-08: the extra resolution is not platform dependent, + but in order for it to become platform dependent we would have to have + separate targets for each extra in extras. + """ + + # Resolve any extra extras due to self-edges, empty string means no + # extras The empty string in the set is just a way to make the handling + # of no extras and a single extra easier and having a set of {"", "foo"} + # is equivalent to having {"foo"}. + extras = extras or {""} + + self_reqs = [] + for req in reqs: + if Deps._normalize(req.name) != self.name: + continue + + if req.marker is None: + # I am pretty sure we cannot reach this code as it does not + # make sense to specify packages in this way, but since it is + # easy to handle, lets do it. + # + # TODO @aignas 2023-12-08: add a test + extras = extras | req.extras + else: + # process these in a separate loop + self_reqs.append(req) + + # A double loop is not strictly optimal, but always correct without recursion + for req in self_reqs: + if any(req.marker.evaluate({"extra": extra}) for extra in extras): + extras = extras | req.extras + else: + continue + + # Iterate through all packages to ensure that we include all of the extras from previously + # visited packages. + for req_ in self_reqs: + if any(req_.marker.evaluate({"extra": extra}) for extra in extras): + extras = extras | req_.extras + + return extras + + def _add_req(self, req: Requirement, extras: Set[str]) -> None: + if req.marker is None: + self._add(req.name, None) + return + + marker_str = str(req.marker) + + if not self._platforms: + if any(req.marker.evaluate({"extra": extra}) for extra in extras): + self._add(req.name, None) + return + + # NOTE @aignas 2023-12-08: in order to have reasonable select statements + # we do have to have some parsing of the markers, so it begs the question + # if packaging should be reimplemented in Starlark to have the best solution + # for now we will implement it in Python and see what the best parsing result + # can be before making this decision. + match_os = any( + tag in marker_str + for tag in [ + "os_name", + "sys_platform", + "platform_system", + ] + ) + match_arch = "platform_machine" in marker_str + match_version = "version" in marker_str + + if not (match_os or match_arch or match_version): + if any(req.marker.evaluate({"extra": extra}) for extra in extras): + self._add(req.name, None) + return + + for plat in self._platforms: + if not any( + req.marker.evaluate(plat.env_markers(extra)) for extra in extras + ): + continue + + if match_arch and self._default_minor_version: + self._add(req.name, plat) + if plat.minor_version == self._default_minor_version: + self._add(req.name, Platform(plat.os, plat.arch)) + elif match_arch: + self._add(req.name, Platform(plat.os, plat.arch)) + elif match_os and self._default_minor_version: + self._add(req.name, Platform(plat.os, minor_version=plat.minor_version)) + if plat.minor_version == self._default_minor_version: + self._add(req.name, Platform(plat.os)) + elif match_os: + self._add(req.name, Platform(plat.os)) + elif match_version and self._default_minor_version: + self._add(req.name, Platform(minor_version=plat.minor_version)) + if plat.minor_version == self._default_minor_version: + self._add(req.name, Platform()) + elif match_version: + self._add(req.name, None) + + # Merge to common if possible after processing all platforms + self._maybe_add_common_dep(req.name) + + def build(self) -> FrozenDeps: + return FrozenDeps( + deps=sorted(self._deps), + deps_select={str(p): sorted(deps) for p, deps in self._select.items()}, + ) + class Wheel: """Representation of the compressed .whl file""" @@ -75,6 +344,18 @@ def entry_points(self) -> Dict[str, Tuple[str, str]]: return entry_points_mapping + def dependencies( + self, + extras_requested: Set[str] = None, + platforms: Optional[Set[Platform]] = None, + ) -> FrozenDeps: + return Deps( + self.name, + extras=extras_requested, + platforms=platforms, + requires_dist=self.metadata.get_all("Requires-Dist", []), + ).build() + def unzip(self, directory: str) -> None: installation_schemes = { "purelib": "/site-packages", diff --git a/python/private/pypi/whl_installer/wheel_installer.py b/python/private/pypi/whl_installer/wheel_installer.py index c7695d92e8..a48df699ba 100644 --- a/python/private/pypi/whl_installer/wheel_installer.py +++ b/python/private/pypi/whl_installer/wheel_installer.py @@ -23,7 +23,7 @@ import sys from pathlib import Path from tempfile import NamedTemporaryFile -from typing import Dict, Optional, Set, Tuple +from typing import Dict, List, Optional, Set, Tuple from pip._vendor.packaging.utils import canonicalize_name @@ -103,7 +103,9 @@ def _setup_namespace_pkg_compatibility(wheel_dir: str) -> None: def _extract_wheel( wheel_file: str, + extras: Dict[str, Set[str]], enable_implicit_namespace_pkgs: bool, + platforms: List[wheel.Platform], installation_dir: Path = Path("."), ) -> None: """Extracts wheel into given directory and creates py_library and filegroup targets. @@ -111,6 +113,7 @@ def _extract_wheel( Args: wheel_file: the filepath of the .whl installation_dir: the destination directory for installation of the wheel. + extras: a list of extras to add as dependencies for the installed wheel enable_implicit_namespace_pkgs: if true, disables conversion of implicit namespace packages and will unzip as-is """ @@ -120,19 +123,26 @@ def _extract_wheel( if not enable_implicit_namespace_pkgs: _setup_namespace_pkg_compatibility(installation_dir) - metadata = { - "python_version": sys.version.partition(" ")[0], - "entry_points": [ - { - "name": name, - "module": module, - "attribute": attribute, - } - for name, (module, attribute) in sorted(whl.entry_points().items()) - ], - } + extras_requested = extras[whl.name] if whl.name in extras else set() + + dependencies = whl.dependencies(extras_requested, platforms) with open(os.path.join(installation_dir, "metadata.json"), "w") as f: + metadata = { + "name": whl.name, + "version": whl.version, + "deps": dependencies.deps, + "python_version": f"{sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]}", + "deps_by_platform": dependencies.deps_select, + "entry_points": [ + { + "name": name, + "module": module, + "attribute": attribute, + } + for name, (module, attribute) in sorted(whl.entry_points().items()) + ], + } json.dump(metadata, f) @@ -146,9 +156,13 @@ def main() -> None: if args.whl_file: whl = Path(args.whl_file) + name, extras_for_pkg = _parse_requirement_for_extra(args.requirement) + extras = {name: extras_for_pkg} if extras_for_pkg and name else dict() _extract_wheel( wheel_file=whl, + extras=extras, enable_implicit_namespace_pkgs=args.enable_implicit_namespace_pkgs, + platforms=arguments.get_platforms(args), ) return diff --git a/tests/pypi/whl_installer/BUILD.bazel b/tests/pypi/whl_installer/BUILD.bazel index fea6a46d01..040e4d765f 100644 --- a/tests/pypi/whl_installer/BUILD.bazel +++ b/tests/pypi/whl_installer/BUILD.bazel @@ -27,6 +27,18 @@ py_test( ], ) +py_test( + name = "platform_test", + size = "small", + srcs = [ + "platform_test.py", + ], + data = ["//examples/wheel:minimal_with_py_package"], + deps = [ + ":lib", + ], +) + py_test( name = "wheel_installer_test", size = "small", @@ -38,3 +50,15 @@ py_test( ":lib", ], ) + +py_test( + name = "wheel_test", + size = "small", + srcs = [ + "wheel_test.py", + ], + data = ["//examples/wheel:minimal_with_py_package"], + deps = [ + ":lib", + ], +) diff --git a/tests/pypi/whl_installer/arguments_test.py b/tests/pypi/whl_installer/arguments_test.py index 9f73ae96a9..5538054a59 100644 --- a/tests/pypi/whl_installer/arguments_test.py +++ b/tests/pypi/whl_installer/arguments_test.py @@ -15,7 +15,7 @@ import json import unittest -from python.private.pypi.whl_installer import arguments +from python.private.pypi.whl_installer import arguments, wheel class ArgumentsTestCase(unittest.TestCase): @@ -49,6 +49,18 @@ def test_deserialize_structured_args(self) -> None: self.assertEqual(args["environment"], {"PIP_DO_SOMETHING": "True"}) self.assertEqual(args["extra_pip_args"], []) + def test_platform_aggregation(self) -> None: + parser = arguments.parser() + args = parser.parse_args( + args=[ + "--platform=linux_*", + "--platform=osx_*", + "--platform=windows_*", + "--requirement=foo", + ] + ) + self.assertEqual(set(wheel.Platform.all()), arguments.get_platforms(args)) + if __name__ == "__main__": unittest.main() diff --git a/tests/pypi/whl_installer/platform_test.py b/tests/pypi/whl_installer/platform_test.py new file mode 100644 index 0000000000..2aeb4caa69 --- /dev/null +++ b/tests/pypi/whl_installer/platform_test.py @@ -0,0 +1,154 @@ +import unittest +from random import shuffle + +from python.private.pypi.whl_installer.platform import ( + OS, + Arch, + Platform, + host_interpreter_minor_version, +) + + +class MinorVersionTest(unittest.TestCase): + def test_host(self): + host = host_interpreter_minor_version() + self.assertIsNotNone(host) + + +class PlatformTest(unittest.TestCase): + def test_can_get_host(self): + host = Platform.host() + self.assertIsNotNone(host) + self.assertEqual(1, len(Platform.from_string("host"))) + self.assertEqual(host, Platform.from_string("host")) + + def test_can_get_linux_x86_64_without_py_version(self): + got = Platform.from_string("linux_x86_64") + want = Platform(os=OS.linux, arch=Arch.x86_64) + self.assertEqual(want, got[0]) + + def test_can_get_specific_from_string(self): + got = Platform.from_string("cp33_linux_x86_64") + want = Platform(os=OS.linux, arch=Arch.x86_64, minor_version=3) + self.assertEqual(want, got[0]) + + def test_can_get_all_for_py_version(self): + cp39 = Platform.all(minor_version=9) + self.assertEqual(21, len(cp39), f"Got {cp39}") + self.assertEqual(cp39, Platform.from_string("cp39_*")) + + def test_can_get_all_for_os(self): + linuxes = Platform.all(OS.linux, minor_version=9) + self.assertEqual(7, len(linuxes)) + self.assertEqual(linuxes, Platform.from_string("cp39_linux_*")) + + def test_can_get_all_for_os_for_host_python(self): + linuxes = Platform.all(OS.linux) + self.assertEqual(7, len(linuxes)) + self.assertEqual(linuxes, Platform.from_string("linux_*")) + + def test_specific_version_specializations(self): + any_py33 = Platform(minor_version=3) + + # When + all_specializations = list(any_py33.all_specializations()) + + want = ( + [any_py33] + + [ + Platform(arch=arch, minor_version=any_py33.minor_version) + for arch in Arch + ] + + [Platform(os=os, minor_version=any_py33.minor_version) for os in OS] + + Platform.all(minor_version=any_py33.minor_version) + ) + self.assertEqual(want, all_specializations) + + def test_aarch64_specializations(self): + any_aarch64 = Platform(arch=Arch.aarch64) + all_specializations = list(any_aarch64.all_specializations()) + want = [ + Platform(os=None, arch=Arch.aarch64), + Platform(os=OS.linux, arch=Arch.aarch64), + Platform(os=OS.osx, arch=Arch.aarch64), + Platform(os=OS.windows, arch=Arch.aarch64), + ] + self.assertEqual(want, all_specializations) + + def test_linux_specializations(self): + any_linux = Platform(os=OS.linux) + all_specializations = list(any_linux.all_specializations()) + want = [ + Platform(os=OS.linux, arch=None), + Platform(os=OS.linux, arch=Arch.x86_64), + Platform(os=OS.linux, arch=Arch.x86_32), + Platform(os=OS.linux, arch=Arch.aarch64), + Platform(os=OS.linux, arch=Arch.ppc), + Platform(os=OS.linux, arch=Arch.ppc64le), + Platform(os=OS.linux, arch=Arch.s390x), + Platform(os=OS.linux, arch=Arch.arm), + ] + self.assertEqual(want, all_specializations) + + def test_osx_specializations(self): + any_osx = Platform(os=OS.osx) + all_specializations = list(any_osx.all_specializations()) + # NOTE @aignas 2024-01-14: even though in practice we would only have + # Python on osx aarch64 and osx x86_64, we return all arch posibilities + # to make the code simpler. + want = [ + Platform(os=OS.osx, arch=None), + Platform(os=OS.osx, arch=Arch.x86_64), + Platform(os=OS.osx, arch=Arch.x86_32), + Platform(os=OS.osx, arch=Arch.aarch64), + Platform(os=OS.osx, arch=Arch.ppc), + Platform(os=OS.osx, arch=Arch.ppc64le), + Platform(os=OS.osx, arch=Arch.s390x), + Platform(os=OS.osx, arch=Arch.arm), + ] + self.assertEqual(want, all_specializations) + + def test_platform_sort(self): + platforms = [ + Platform(os=OS.linux, arch=None), + Platform(os=OS.linux, arch=Arch.x86_64), + Platform(os=OS.osx, arch=None), + Platform(os=OS.osx, arch=Arch.x86_64), + Platform(os=OS.osx, arch=Arch.aarch64), + ] + shuffle(platforms) + platforms.sort() + want = [ + Platform(os=OS.linux, arch=None), + Platform(os=OS.linux, arch=Arch.x86_64), + Platform(os=OS.osx, arch=None), + Platform(os=OS.osx, arch=Arch.x86_64), + Platform(os=OS.osx, arch=Arch.aarch64), + ] + + self.assertEqual(want, platforms) + + def test_wheel_os_alias(self): + self.assertEqual("osx", str(OS.osx)) + self.assertEqual(str(OS.darwin), str(OS.osx)) + + def test_wheel_arch_alias(self): + self.assertEqual("x86_64", str(Arch.x86_64)) + self.assertEqual(str(Arch.amd64), str(Arch.x86_64)) + + def test_wheel_platform_alias(self): + give = Platform( + os=OS.darwin, + arch=Arch.amd64, + ) + alias = Platform( + os=OS.osx, + arch=Arch.x86_64, + ) + + self.assertEqual("osx_x86_64", str(give)) + self.assertEqual(str(alias), str(give)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/pypi/whl_installer/wheel_installer_test.py b/tests/pypi/whl_installer/wheel_installer_test.py index 3c118af3c4..b736877e81 100644 --- a/tests/pypi/whl_installer/wheel_installer_test.py +++ b/tests/pypi/whl_installer/wheel_installer_test.py @@ -22,6 +22,39 @@ from python.private.pypi.whl_installer import wheel_installer +class TestRequirementExtrasParsing(unittest.TestCase): + def test_parses_requirement_for_extra(self) -> None: + cases = [ + ("name[foo]", ("name", frozenset(["foo"]))), + ("name[ Foo123 ]", ("name", frozenset(["Foo123"]))), + (" name1[ foo ] ", ("name1", frozenset(["foo"]))), + ("Name[foo]", ("name", frozenset(["foo"]))), + ("name_foo[bar]", ("name-foo", frozenset(["bar"]))), + ( + "name [fred,bar] @ http://foo.com ; python_version=='2.7'", + ("name", frozenset(["fred", "bar"])), + ), + ( + "name[quux, strange];python_version<'2.7' and platform_version=='2'", + ("name", frozenset(["quux", "strange"])), + ), + ( + "name; (os_name=='a' or os_name=='b') and os_name=='c'", + (None, None), + ), + ( + "name@http://foo.com", + (None, None), + ), + ] + + for case, expected in cases: + with self.subTest(): + self.assertTupleEqual( + wheel_installer._parse_requirement_for_extra(case), expected + ) + + class TestWhlFilegroup(unittest.TestCase): def setUp(self) -> None: self.wheel_name = "example_minimal_package-0.0.1-py3-none-any.whl" @@ -35,8 +68,10 @@ def tearDown(self): def test_wheel_exists(self) -> None: wheel_installer._extract_wheel( Path(self.wheel_path), - enable_implicit_namespace_pkgs=False, installation_dir=Path(self.wheel_dir), + extras={}, + enable_implicit_namespace_pkgs=False, + platforms=[], ) want_files = [ @@ -57,8 +92,12 @@ def test_wheel_exists(self) -> None: metadata_file_content = json.load(metadata_file) want = dict( + deps=[], + deps_by_platform={}, entry_points=[], + name="example-minimal-package", python_version="3.11.11", + version="0.0.1", ) self.assertEqual(want, metadata_file_content) diff --git a/tests/pypi/whl_installer/wheel_test.py b/tests/pypi/whl_installer/wheel_test.py new file mode 100644 index 0000000000..404218e12b --- /dev/null +++ b/tests/pypi/whl_installer/wheel_test.py @@ -0,0 +1,371 @@ +import unittest +from unittest import mock + +from python.private.pypi.whl_installer import wheel +from python.private.pypi.whl_installer.platform import OS, Arch, Platform + +_HOST_INTERPRETER_FN = ( + "python.private.pypi.whl_installer.wheel.host_interpreter_minor_version" +) + + +class DepsTest(unittest.TestCase): + def test_simple(self): + deps = wheel.Deps("foo", requires_dist=["bar"]) + + got = deps.build() + + self.assertIsInstance(got, wheel.FrozenDeps) + self.assertEqual(["bar"], got.deps) + self.assertEqual({}, got.deps_select) + + def test_can_add_os_specific_deps(self): + deps = wheel.Deps( + "foo", + requires_dist=[ + "bar", + "an_osx_dep; sys_platform=='darwin'", + "posix_dep; os_name=='posix'", + "win_dep; os_name=='nt'", + ], + platforms={ + Platform(os=OS.linux, arch=Arch.x86_64), + Platform(os=OS.osx, arch=Arch.x86_64), + Platform(os=OS.osx, arch=Arch.aarch64), + Platform(os=OS.windows, arch=Arch.x86_64), + }, + ) + + got = deps.build() + + self.assertEqual(["bar"], got.deps) + self.assertEqual( + { + "@platforms//os:linux": ["posix_dep"], + "@platforms//os:osx": ["an_osx_dep", "posix_dep"], + "@platforms//os:windows": ["win_dep"], + }, + got.deps_select, + ) + + def test_can_add_os_specific_deps_with_specific_python_version(self): + deps = wheel.Deps( + "foo", + requires_dist=[ + "bar", + "an_osx_dep; sys_platform=='darwin'", + "posix_dep; os_name=='posix'", + "win_dep; os_name=='nt'", + ], + platforms={ + Platform(os=OS.linux, arch=Arch.x86_64, minor_version=8), + Platform(os=OS.osx, arch=Arch.x86_64, minor_version=8), + Platform(os=OS.osx, arch=Arch.aarch64, minor_version=8), + Platform(os=OS.windows, arch=Arch.x86_64, minor_version=8), + }, + ) + + got = deps.build() + + self.assertEqual(["bar"], got.deps) + self.assertEqual( + { + "@platforms//os:linux": ["posix_dep"], + "@platforms//os:osx": ["an_osx_dep", "posix_dep"], + "@platforms//os:windows": ["win_dep"], + }, + got.deps_select, + ) + + def test_deps_are_added_to_more_specialized_platforms(self): + got = wheel.Deps( + "foo", + requires_dist=[ + "m1_dep; sys_platform=='darwin' and platform_machine=='arm64'", + "mac_dep; sys_platform=='darwin'", + ], + platforms={ + Platform(os=OS.osx, arch=Arch.x86_64), + Platform(os=OS.osx, arch=Arch.aarch64), + }, + ).build() + + self.assertEqual( + wheel.FrozenDeps( + deps=[], + deps_select={ + "osx_aarch64": ["m1_dep", "mac_dep"], + "@platforms//os:osx": ["mac_dep"], + }, + ), + got, + ) + + def test_deps_from_more_specialized_platforms_are_propagated(self): + got = wheel.Deps( + "foo", + requires_dist=[ + "a_mac_dep; sys_platform=='darwin'", + "m1_dep; sys_platform=='darwin' and platform_machine=='arm64'", + ], + platforms={ + Platform(os=OS.osx, arch=Arch.x86_64), + Platform(os=OS.osx, arch=Arch.aarch64), + }, + ).build() + + self.assertEqual([], got.deps) + self.assertEqual( + { + "osx_aarch64": ["a_mac_dep", "m1_dep"], + "@platforms//os:osx": ["a_mac_dep"], + }, + got.deps_select, + ) + + def test_non_platform_markers_are_added_to_common_deps(self): + got = wheel.Deps( + "foo", + requires_dist=[ + "bar", + "baz; implementation_name=='cpython'", + "m1_dep; sys_platform=='darwin' and platform_machine=='arm64'", + ], + platforms={ + Platform(os=OS.linux, arch=Arch.x86_64), + Platform(os=OS.osx, arch=Arch.x86_64), + Platform(os=OS.osx, arch=Arch.aarch64), + Platform(os=OS.windows, arch=Arch.x86_64), + }, + ).build() + + self.assertEqual(["bar", "baz"], got.deps) + self.assertEqual( + { + "osx_aarch64": ["m1_dep"], + }, + got.deps_select, + ) + + def test_self_is_ignored(self): + deps = wheel.Deps( + "foo", + requires_dist=[ + "bar", + "req_dep; extra == 'requests'", + "foo[requests]; extra == 'ssl'", + "ssl_lib; extra == 'ssl'", + ], + extras={"ssl"}, + ) + + got = deps.build() + + self.assertEqual(["bar", "req_dep", "ssl_lib"], got.deps) + self.assertEqual({}, got.deps_select) + + def test_self_dependencies_can_come_in_any_order(self): + deps = wheel.Deps( + "foo", + requires_dist=[ + "bar", + "baz; extra == 'feat'", + "foo[feat2]; extra == 'all'", + "foo[feat]; extra == 'feat2'", + "zdep; extra == 'all'", + ], + extras={"all"}, + ) + + got = deps.build() + + self.assertEqual(["bar", "baz", "zdep"], got.deps) + self.assertEqual({}, got.deps_select) + + def test_can_get_deps_based_on_specific_python_version(self): + requires_dist = [ + "bar", + "baz; python_version < '3.8'", + "posix_dep; os_name=='posix' and python_version >= '3.8'", + ] + + py38_deps = wheel.Deps( + "foo", + requires_dist=requires_dist, + platforms=[ + Platform(os=OS.linux, arch=Arch.x86_64, minor_version=8), + ], + ).build() + py37_deps = wheel.Deps( + "foo", + requires_dist=requires_dist, + platforms=[ + Platform(os=OS.linux, arch=Arch.x86_64, minor_version=7), + ], + ).build() + + self.assertEqual(["bar", "baz"], py37_deps.deps) + self.assertEqual({}, py37_deps.deps_select) + self.assertEqual(["bar"], py38_deps.deps) + self.assertEqual({"@platforms//os:linux": ["posix_dep"]}, py38_deps.deps_select) + + @mock.patch(_HOST_INTERPRETER_FN) + def test_no_version_select_when_single_version(self, mock_host_interpreter_version): + requires_dist = [ + "bar", + "baz; python_version >= '3.8'", + "posix_dep; os_name=='posix'", + "posix_dep_with_version; os_name=='posix' and python_version >= '3.8'", + "arch_dep; platform_machine=='x86_64' and python_version >= '3.8'", + ] + mock_host_interpreter_version.return_value = 7 + + self.maxDiff = None + + deps = wheel.Deps( + "foo", + requires_dist=requires_dist, + platforms=[ + Platform(os=os, arch=Arch.x86_64, minor_version=minor) + for minor in [8] + for os in [OS.linux, OS.windows] + ], + ) + got = deps.build() + + self.assertEqual(["bar", "baz"], got.deps) + self.assertEqual( + { + "@platforms//os:linux": ["posix_dep", "posix_dep_with_version"], + "linux_x86_64": ["arch_dep", "posix_dep", "posix_dep_with_version"], + "windows_x86_64": ["arch_dep"], + }, + got.deps_select, + ) + + @mock.patch(_HOST_INTERPRETER_FN) + def test_can_get_version_select(self, mock_host_interpreter_version): + requires_dist = [ + "bar", + "baz; python_version < '3.8'", + "baz_new; python_version >= '3.8'", + "posix_dep; os_name=='posix'", + "posix_dep_with_version; os_name=='posix' and python_version >= '3.8'", + "arch_dep; platform_machine=='x86_64' and python_version < '3.8'", + ] + mock_host_interpreter_version.return_value = 7 + + self.maxDiff = None + + deps = wheel.Deps( + "foo", + requires_dist=requires_dist, + platforms=[ + Platform(os=os, arch=Arch.x86_64, minor_version=minor) + for minor in [7, 8, 9] + for os in [OS.linux, OS.windows] + ], + ) + got = deps.build() + + self.assertEqual(["bar"], got.deps) + self.assertEqual( + { + "//conditions:default": ["baz"], + "@//python/config_settings:is_python_3.7": ["baz"], + "@//python/config_settings:is_python_3.8": ["baz_new"], + "@//python/config_settings:is_python_3.9": ["baz_new"], + "@platforms//os:linux": ["baz", "posix_dep"], + "cp37_linux_x86_64": ["arch_dep", "baz", "posix_dep"], + "cp37_windows_x86_64": ["arch_dep", "baz"], + "cp37_linux_anyarch": ["baz", "posix_dep"], + "cp38_linux_anyarch": [ + "baz_new", + "posix_dep", + "posix_dep_with_version", + ], + "cp39_linux_anyarch": [ + "baz_new", + "posix_dep", + "posix_dep_with_version", + ], + "linux_x86_64": ["arch_dep", "baz", "posix_dep"], + "windows_x86_64": ["arch_dep", "baz"], + }, + got.deps_select, + ) + + @mock.patch(_HOST_INTERPRETER_FN) + def test_deps_spanning_all_target_py_versions_are_added_to_common( + self, mock_host_version + ): + requires_dist = [ + "bar", + "baz (<2,>=1.11) ; python_version < '3.8'", + "baz (<2,>=1.14) ; python_version >= '3.8'", + ] + mock_host_version.return_value = 8 + + deps = wheel.Deps( + "foo", + requires_dist=requires_dist, + platforms=Platform.from_string(["cp37_*", "cp38_*", "cp39_*"]), + ) + got = deps.build() + + self.assertEqual(["bar", "baz"], got.deps) + self.assertEqual({}, got.deps_select) + + @mock.patch(_HOST_INTERPRETER_FN) + def test_deps_are_not_duplicated(self, mock_host_version): + mock_host_version.return_value = 7 + + # See an example in + # https://files.pythonhosted.org/packages/76/9e/db1c2d56c04b97981c06663384f45f28950a73d9acf840c4006d60d0a1ff/opencv_python-4.9.0.80-cp37-abi3-win32.whl.metadata + requires_dist = [ + "bar >=0.1.0 ; python_version < '3.7'", + "bar >=0.2.0 ; python_version >= '3.7'", + "bar >=0.4.0 ; python_version >= '3.6' and platform_system == 'Linux' and platform_machine == 'aarch64'", + "bar >=0.4.0 ; python_version >= '3.9'", + "bar >=0.5.0 ; python_version <= '3.9' and platform_system == 'Darwin' and platform_machine == 'arm64'", + "bar >=0.5.0 ; python_version >= '3.10' and platform_system == 'Darwin'", + "bar >=0.5.0 ; python_version >= '3.10'", + "bar >=0.6.0 ; python_version >= '3.11'", + ] + + deps = wheel.Deps( + "foo", + requires_dist=requires_dist, + platforms=Platform.from_string(["cp37_*", "cp310_*"]), + ) + got = deps.build() + + self.assertEqual(["bar"], got.deps) + self.assertEqual({}, got.deps_select) + + @mock.patch(_HOST_INTERPRETER_FN) + def test_deps_are_not_duplicated_when_encountering_platform_dep_first( + self, mock_host_version + ): + mock_host_version.return_value = 7 + + # Note, that we are sorting the incoming `requires_dist` and we need to ensure that we are not getting any + # issues even if the platform-specific line comes first. + requires_dist = [ + "bar >=0.4.0 ; python_version >= '3.6' and platform_system == 'Linux' and platform_machine == 'aarch64'", + "bar >=0.5.0 ; python_version >= '3.9'", + ] + + deps = wheel.Deps( + "foo", + requires_dist=requires_dist, + platforms=Platform.from_string(["cp37_*", "cp310_*"]), + ) + got = deps.build() + + self.assertEqual(["bar"], got.deps) + self.assertEqual({}, got.deps_select) + + +if __name__ == "__main__": + unittest.main()