feat(whl_library): generate platform-specific dependency closures (#1593)
Before this change, the dependency closures would be influenced by the
host-python interpreter, this removes the influence by detecting the
platforms against which the `Requires-Dist` wheel metadata is evaluated.
This functionality can be enabled via `experimental_target_platforms`
attribute to the `pip.parse` extension and is showcased in the `bzlmod`
example. The same attribute is also supported on the legacy `pip_parse`
repository rule.
The detection works in the following way:
- Check if the python wheel is platform specific or cross-platform
(i.e., ends with `any.whl`), if it is then platform-specific
dependencies are generated, which will go through a `select`
statement.
- If it is platform specific, then parse the platform_tag and evaluate
the `Requires-Dist` markers assuming the target platform rather than
the host platform.
NOTE: The `whl` `METADATA` is now being parsed using the `packaging`
Python package instead of `pkg_resources` from `setuptools`.
Fixes #1591
---------
Co-authored-by: Richard Levasseur <richardlev@gmail.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1937fe5..4c761ed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,6 +36,13 @@
* (gazelle) The gazelle plugin helper was not working with Python toolchains 3.11
and above due to a bug in the helper components not being on PYTHONPATH.
+* (pip_parse) The repositories created by `whl_library` can now parse the `whl`
+ METADATA and generate dependency closures irrespective of the host platform
+ the generation is executed on. This can be turned on by supplying
+ `experimental_target_platforms = ["all"]` to the `pip_parse` or the `bzlmod`
+ equivalent. This may help in cases where fetching wheels for a different
+ platform using `download_only = True` feature.
+
[0.XX.0]: https://github.com/bazelbuild/rules_python/releases/tag/0.XX.0
## [0.27.0] - 2023-11-16
diff --git a/examples/bzlmod/MODULE.bazel b/examples/bzlmod/MODULE.bazel
index 44d686e..9ce84ee 100644
--- a/examples/bzlmod/MODULE.bazel
+++ b/examples/bzlmod/MODULE.bazel
@@ -98,6 +98,13 @@
"sphinxcontrib-serializinghtml",
],
},
+ # You can use one of the values below to specify the target platform
+ # to generate the dependency graph for.
+ experimental_target_platforms = [
+ "all",
+ "linux_*",
+ "host",
+ ],
hub_name = "pip",
python_version = "3.9",
requirements_lock = "//:requirements_lock_3_9.txt",
@@ -121,6 +128,13 @@
"sphinxcontrib-serializinghtml",
],
},
+ # You can use one of the values below to specify the target platform
+ # to generate the dependency graph for.
+ experimental_target_platforms = [
+ "all",
+ "linux_*",
+ "host",
+ ],
hub_name = "pip",
python_version = "3.10",
requirements_lock = "//:requirements_lock_3_10.txt",
diff --git a/python/pip_install/pip_repository.bzl b/python/pip_install/pip_repository.bzl
index 07e3353..bf37977 100644
--- a/python/pip_install/pip_repository.bzl
+++ b/python/pip_install/pip_repository.bzl
@@ -345,6 +345,8 @@
if rctx.attr.python_interpreter_target:
config["python_interpreter_target"] = str(rctx.attr.python_interpreter_target)
+ if rctx.attr.experimental_target_platforms:
+ config["experimental_target_platforms"] = rctx.attr.experimental_target_platforms
if rctx.attr.incompatible_generate_aliases:
macro_tmpl = "@%s//{}:{}" % rctx.attr.name
@@ -474,6 +476,30 @@
as two separate cycles.
""",
),
+ "experimental_target_platforms": attr.string_list(
+ default = [],
+ doc = """\
+A list of platforms that we will generate the conditional dependency graph for
+cross platform wheels by parsing the wheel metadata. This will generate the
+correct dependencies for packages like `sphinx` or `pylint`, which include
+`colorama` when installed and used on Windows platforms.
+
+An empty list means falling back to the legacy behaviour where the host
+platform is the target platform.
+
+WARNING: It may not work as expected in cases where the python interpreter
+implementation that is being used at runtime is different between different platforms.
+This has been tested for CPython only.
+
+Special values: `all` (for generating deps for all platforms), `host` (for
+generating deps for the host platform only). `linux_*` and other `<os>_*` values.
+In the future we plan to set `all` as the default to this attribute.
+
+For specific target platforms use values of the form `<os>_<arch>` where `<os>`
+is one of `linux`, `osx`, `windows` and arch is one of `x86_64`, `x86_32`,
+`aarch64`, `s390x` and `ppc64le`.
+""",
+ ),
"extra_pip_args": attr.string_list(
doc = "Extra arguments to pass on to pip. Must not contain spaces.",
),
@@ -713,7 +739,10 @@
)
result = rctx.execute(
- args + ["--whl-file", whl_path],
+ args + [
+ "--whl-file",
+ whl_path,
+ ] + ["--platform={}".format(p) for p in rctx.attr.experimental_target_platforms],
environment = environment,
quiet = rctx.attr.quiet,
timeout = rctx.attr.timeout,
@@ -749,6 +778,7 @@
repo_prefix = rctx.attr.repo_prefix,
whl_name = whl_path.basename,
dependencies = metadata["deps"],
+ dependencies_by_platform = metadata["deps_by_platform"],
group_name = rctx.attr.group_name,
group_deps = rctx.attr.group_deps,
data_exclude = rctx.attr.pip_data_exclude,
@@ -815,7 +845,7 @@
doc = "Python requirement string describing the package to make available",
),
"whl_patches": attr.label_keyed_string_dict(
- doc = """"a label-keyed-string dict that has
+ doc = """a label-keyed-string dict that has
json.encode(struct([whl_file], patch_strip]) as values. This
is to maintain flexibility and correct bzlmod extension interface
until we have a better way to define whl_library and move whl
diff --git a/python/pip_install/private/generate_whl_library_build_bazel.bzl b/python/pip_install/private/generate_whl_library_build_bazel.bzl
index 6d0f167..568b00e 100644
--- a/python/pip_install/private/generate_whl_library_build_bazel.bzl
+++ b/python/pip_install/private/generate_whl_library_build_bazel.bzl
@@ -25,6 +25,7 @@
"WHEEL_FILE_PUBLIC_LABEL",
)
load("//python/private:normalize_name.bzl", "normalize_name")
+load("//python/private:text_util.bzl", "render")
_COPY_FILE_TEMPLATE = """\
copy_file(
@@ -101,11 +102,36 @@
)
"""
+def _render_list_and_select(deps, deps_by_platform, tmpl):
+ deps = render.list([tmpl.format(d) for d in deps])
+
+ if not deps_by_platform:
+ return deps
+
+ deps_by_platform = {
+ p if p.startswith("@") else ":is_" + p: [
+ tmpl.format(d)
+ for d in deps
+ ]
+ for p, deps in deps_by_platform.items()
+ }
+
+ # Add the default, which means that we will be just using the dependencies in
+ # `deps` for platforms that are not handled in a special way by the packages
+ deps_by_platform["//conditions:default"] = []
+ deps_by_platform = render.select(deps_by_platform, value_repr = render.list)
+
+ if deps == "[]":
+ return deps_by_platform
+ else:
+ return "{} + {}".format(deps, deps_by_platform)
+
def generate_whl_library_build_bazel(
*,
repo_prefix,
whl_name,
dependencies,
+ dependencies_by_platform,
data_exclude,
tags,
entry_points,
@@ -118,6 +144,7 @@
repo_prefix: the repo prefix that should be used for dependency lists.
whl_name: the whl_name that this is generated for.
dependencies: a list of PyPI packages that are dependencies to the py_library.
+ dependencies_by_platform: a dict[str, list] of PyPI packages that may vary by platform.
data_exclude: more patterns to exclude from the data attribute of generated py_library rules.
tags: list of tags to apply to generated py_library rules.
entry_points: A dict of entry points to add py_binary rules for.
@@ -138,6 +165,10 @@
srcs_exclude = []
data_exclude = [] + data_exclude
dependencies = sorted([normalize_name(d) for d in dependencies])
+ dependencies_by_platform = {
+ platform: sorted([normalize_name(d) for d in deps])
+ for platform, deps in dependencies_by_platform.items()
+ }
tags = sorted(tags)
for entry_point, entry_point_script_name in entry_points.items():
@@ -185,22 +216,48 @@
for d in group_deps
}
- # Filter out deps which are within the group to avoid cycles
- non_group_deps = [
+ dependencies = [
d
for d in dependencies
if d not in group_deps
]
+ dependencies_by_platform = {
+ p: deps
+ for p, deps in dependencies_by_platform.items()
+ for deps in [[d for d in deps if d not in group_deps]]
+ if deps
+ }
- lib_dependencies = [
- "@%s%s//:%s" % (repo_prefix, normalize_name(d), PY_LIBRARY_PUBLIC_LABEL)
- for d in non_group_deps
- ]
+ for p in dependencies_by_platform:
+ if p.startswith("@"):
+ continue
- whl_file_deps = [
- "@%s%s//:%s" % (repo_prefix, normalize_name(d), WHEEL_FILE_PUBLIC_LABEL)
- for d in non_group_deps
- ]
+ os, _, cpu = p.partition("_")
+
+ additional_content.append(
+ """\
+config_setting(
+ name = "is_{os}_{cpu}",
+ constraint_values = [
+ "@platforms//cpu:{cpu}",
+ "@platforms//os:{os}",
+ ],
+ visibility = ["//visibility:private"],
+)
+""".format(os = os, cpu = cpu),
+ )
+
+ lib_dependencies = _render_list_and_select(
+ deps = dependencies,
+ deps_by_platform = dependencies_by_platform,
+ tmpl = "@{}{{}}//:{}".format(repo_prefix, PY_LIBRARY_PUBLIC_LABEL),
+ )
+
+ whl_file_deps = _render_list_and_select(
+ deps = dependencies,
+ deps_by_platform = dependencies_by_platform,
+ tmpl = "@{}{{}}//:{}".format(repo_prefix, WHEEL_FILE_PUBLIC_LABEL),
+ )
# If this library is a member of a group, its public label aliases need to
# point to the group implementation rule not the implementation rules. We
@@ -223,13 +280,13 @@
py_library_public_label = PY_LIBRARY_PUBLIC_LABEL,
py_library_impl_label = PY_LIBRARY_IMPL_LABEL,
py_library_actual_label = library_impl_label,
- dependencies = repr(lib_dependencies),
+ dependencies = render.indent(lib_dependencies, " " * 4).lstrip(),
+ whl_file_deps = render.indent(whl_file_deps, " " * 4).lstrip(),
data_exclude = repr(_data_exclude),
whl_name = whl_name,
whl_file_public_label = WHEEL_FILE_PUBLIC_LABEL,
whl_file_impl_label = WHEEL_FILE_IMPL_LABEL,
whl_file_actual_label = whl_impl_label,
- whl_file_deps = repr(whl_file_deps),
tags = repr(tags),
data_label = DATA_LABEL,
dist_info_label = DIST_INFO_LABEL,
diff --git a/python/pip_install/tools/wheel_installer/BUILD.bazel b/python/pip_install/tools/wheel_installer/BUILD.bazel
index 0eadcc2..a396488 100644
--- a/python/pip_install/tools/wheel_installer/BUILD.bazel
+++ b/python/pip_install/tools/wheel_installer/BUILD.bazel
@@ -13,6 +13,7 @@
deps = [
requirement("installer"),
requirement("pip"),
+ requirement("packaging"),
requirement("setuptools"),
],
)
@@ -48,6 +49,18 @@
)
py_test(
+ name = "wheel_test",
+ size = "small",
+ srcs = [
+ "wheel_test.py",
+ ],
+ data = ["//examples/wheel:minimal_with_py_package"],
+ deps = [
+ ":lib",
+ ],
+)
+
+py_test(
name = "wheel_installer_test",
size = "small",
srcs = [
diff --git a/python/pip_install/tools/wheel_installer/arguments.py b/python/pip_install/tools/wheel_installer/arguments.py
index 25fd30f..71133c2 100644
--- a/python/pip_install/tools/wheel_installer/arguments.py
+++ b/python/pip_install/tools/wheel_installer/arguments.py
@@ -15,7 +15,9 @@
import argparse
import json
import pathlib
-from typing import Any
+from typing import Any, Dict, Set
+
+from python.pip_install.tools.wheel_installer import wheel
def parser(**kwargs: Any) -> argparse.ArgumentParser:
@@ -40,6 +42,12 @@
help="Extra arguments to pass down to pip.",
)
parser.add_argument(
+ "--platform",
+ action="extend",
+ type=wheel.Platform.from_string,
+ help="Platforms to target dependencies. Can be used multiple times.",
+ )
+ parser.add_argument(
"--pip_data_exclude",
action="store",
help="Additional data exclusion parameters to add to the pip packages BUILD file.",
@@ -68,8 +76,9 @@
return parser
-def deserialize_structured_args(args):
+def deserialize_structured_args(args: Dict[str, str]) -> Dict:
"""Deserialize structured arguments passed from the starlark rules.
+
Args:
args: dict of parsed command line arguments
"""
@@ -80,3 +89,18 @@
else:
args[arg_name] = []
return args
+
+
+def get_platforms(args: argparse.Namespace) -> Set:
+ """Aggregate platforms into a single set.
+
+ Args:
+ args: dict of parsed command line arguments
+ """
+ platforms = set()
+ if args.platform is None:
+ return platforms
+
+ platforms.update(args.platform)
+
+ return platforms
diff --git a/python/pip_install/tools/wheel_installer/arguments_test.py b/python/pip_install/tools/wheel_installer/arguments_test.py
index 7193f4a..840c2fa 100644
--- a/python/pip_install/tools/wheel_installer/arguments_test.py
+++ b/python/pip_install/tools/wheel_installer/arguments_test.py
@@ -16,7 +16,7 @@
import json
import unittest
-from python.pip_install.tools.wheel_installer import arguments
+from python.pip_install.tools.wheel_installer import arguments, wheel
class ArgumentsTestCase(unittest.TestCase):
@@ -52,6 +52,18 @@
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=host",
+ "--platform=linux_*",
+ "--platform=all",
+ "--requirement=foo",
+ ]
+ )
+ self.assertEqual(set(wheel.Platform.all()), arguments.get_platforms(args))
+
if __name__ == "__main__":
unittest.main()
diff --git a/python/pip_install/tools/wheel_installer/wheel.py b/python/pip_install/tools/wheel_installer/wheel.py
index 84af04c..9c18dfd 100644
--- a/python/pip_install/tools/wheel_installer/wheel.py
+++ b/python/pip_install/tools/wheel_installer/wheel.py
@@ -13,18 +13,405 @@
# limitations under the License.
"""Utility class to inspect an extracted wheel directory"""
+
import email
-from typing import Dict, Optional, Set, Tuple
+import platform
+import re
+import sys
+from collections import defaultdict
+from dataclasses import dataclass
+from enum import Enum
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Set, Tuple, Union
import installer
-import pkg_resources
+from packaging.requirements import Requirement
from pip._vendor.packaging.utils import canonicalize_name
+class OS(Enum):
+ linux = 1
+ osx = 2
+ windows = 3
+ darwin = osx
+ win32 = windows
+
+ @staticmethod
+ def from_tag(tag: str) -> "OS":
+ if tag.startswith("linux"):
+ return OS.linux
+ elif tag.startswith("manylinux"):
+ return OS.linux
+ elif tag.startswith("musllinux"):
+ return OS.linux
+ elif tag.startswith("macos"):
+ return OS.osx
+ elif tag.startswith("win"):
+ return OS.windows
+ else:
+ raise ValueError(f"unknown tag: {tag}")
+
+
+class Arch(Enum):
+ x86_64 = 1
+ x86_32 = 2
+ aarch64 = 3
+ ppc = 4
+ s390x = 5
+ amd64 = x86_64
+ arm64 = aarch64
+ i386 = x86_32
+ i686 = x86_32
+ x86 = x86_32
+ ppc64le = ppc
+
+ @staticmethod
+ def from_tag(tag: str) -> "Arch":
+ for s, value in Arch.__members__.items():
+ if s in tag:
+ return value
+
+ if tag == "win32":
+ return Arch.x86_32
+ else:
+ raise ValueError(f"unknown tag: {tag}")
+
+
+@dataclass(frozen=True)
+class Platform:
+ os: OS
+ arch: Optional[Arch] = None
+
+ @classmethod
+ def all(cls, want_os: Optional[OS] = None) -> List["Platform"]:
+ return sorted(
+ [
+ cls(os=os, arch=arch)
+ 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 [
+ cls(
+ os=OS[sys.platform.lower()],
+ # FIXME @aignas 2023-12-13: Hermetic toolchain on Windows 3.11.6
+ # is returning an empty string here, so lets default to x86_64
+ arch=Arch[platform.machine().lower() or "x86_64"],
+ )
+ ]
+
+ 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")
+
+ if self.arch is None and other.arch is not None:
+ return True
+
+ if self.arch is not None and other.arch is None:
+ return True
+
+ # Here we ensure that we sort by OS before sorting by arch
+
+ if self.arch is None and other.arch is None:
+ return self.os.value < other.os.value
+
+ if self.os.value < other.os.value:
+ return True
+
+ if self.os.value == other.os.value:
+ return self.arch.value < other.arch.value
+
+ return False
+
+ def __str__(self) -> str:
+ if self.arch is None:
+ return f"@platforms//os:{self.os.name.lower()}"
+
+ return self.os.name.lower() + "_" + self.arch.name.lower()
+
+ @classmethod
+ def from_tag(cls, tag: str) -> "Platform":
+ return cls(
+ os=OS.from_tag(tag),
+ arch=Arch.from_tag(tag),
+ )
+
+ @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())
+ elif p == "all":
+ ret.update(cls.all())
+ elif p.endswith("*"):
+ os, _, _ = p.partition("_")
+ ret.update(cls.all(OS[os]))
+ else:
+ os, _, arch = p.partition("_")
+ ret.add(cls(os=OS[os], arch=Arch[arch]))
+
+ 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"
+
+ # 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.ppc64le:
+ return "ppc64le"
+ elif self.arch == Arch.s390x:
+ return "s390x"
+ else:
+ return ""
+
+ def env_markers(self, extra: str) -> Dict[str, str]:
+ 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
+ # we assume that the following are the same as the interpreter used to setup the deps:
+ # "implementation_version": "X.Y.Z",
+ # "implementation_name": "cpython"
+ # "python_version": "X.Y",
+ # "python_full_version": "X.Y.Z",
+ # "platform_python_implementation: "CPython",
+ }
+
+
+@dataclass(frozen=True)
+class FrozenDeps:
+ deps: List[str]
+ deps_select: Dict[str, List[str]]
+
+
+class Deps:
+ def __init__(
+ self,
+ name: str,
+ extras: Optional[Set[str]] = None,
+ platforms: Optional[Set[Platform]] = None,
+ ):
+ self.name: str = Deps._normalize(name)
+ self._deps: Set[str] = set()
+ self._select: Dict[Platform, Set[str]] = defaultdict(set)
+ self._want_extras: Set[str] = extras or {""} # empty strings means no extras
+ self._platforms: Set[Platform] = platforms or set()
+
+ def _add(self, dep: str, platform: Optional[Platform]):
+ dep = Deps._normalize(dep)
+
+ # Packages may create dependency cycles when specifying optional-dependencies / 'extras'.
+ # Example: github.com/google/etils/blob/a0b71032095db14acf6b33516bca6d885fe09e35/pyproject.toml#L32.
+ if dep == self.name:
+ return
+
+ if platform:
+ self._select[platform].add(dep)
+ else:
+ self._deps.add(dep)
+
+ @staticmethod
+ def _normalize(name: str) -> str:
+ return re.sub(r"[-_.]+", "_", name).lower()
+
+ def add(self, *wheel_reqs: str) -> None:
+ reqs = [Requirement(wheel_req) for wheel_req in wheel_reqs]
+
+ # Resolve any extra extras due to self-edges
+ self._want_extras = self._resolve_extras(reqs)
+
+ # process self-edges first to resolve the extras used
+ for req in reqs:
+ self._add_req(req)
+
+ def _resolve_extras(self, reqs: List[Requirement]) -> 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
+ `elint`, where we have one set of extras as aliases for other extras
+ and we have an extra called 'all' that includes all other extras.
+
+ 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
+ self._want_extras.
+ """
+ extras = self._want_extras
+
+ 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) -> None:
+ extras = self._want_extras
+
+ if req.marker is None:
+ self._add(req.name, None)
+ return
+
+ marker_str = str(req.marker)
+
+ # 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.
+ if not self._platforms or not any(
+ tag in marker_str
+ for tag in [
+ "os_name",
+ "sys_platform",
+ "platform_machine",
+ "platform_system",
+ ]
+ ):
+ 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 "platform_machine" in marker_str:
+ self._add(req.name, plat)
+ else:
+ self._add(req.name, Platform(plat.os))
+
+ def build(self) -> FrozenDeps:
+ if not self._select:
+ return FrozenDeps(
+ deps=sorted(self._deps),
+ deps_select={},
+ )
+
+ # Get all of the OS-specific dependencies applicable to all architectures
+ select = {
+ p: deps for p, deps in self._select.items() if deps and p.arch is None
+ }
+ # Now add them to all arch specific dependencies
+ select.update(
+ {
+ p: deps | select.get(Platform(p.os), set())
+ for p, deps in self._select.items()
+ if deps and p.arch is not None
+ }
+ )
+
+ return FrozenDeps(
+ deps=sorted(self._deps),
+ deps_select={str(p): sorted(deps) for p, deps in sorted(select.items())},
+ )
+
+
class Wheel:
"""Representation of the compressed .whl file"""
- def __init__(self, path: str):
+ def __init__(self, path: Path):
self._path = path
@property
@@ -70,19 +457,31 @@
return entry_points_mapping
- def dependencies(self, extras_requested: Optional[Set[str]] = None) -> Set[str]:
- dependency_set = set()
+ def dependencies(
+ self,
+ extras_requested: Set[str] = None,
+ platforms: Optional[Set[Platform]] = None,
+ ) -> FrozenDeps:
+ if platforms:
+ # NOTE @aignas 2023-12-04: if the wheel is a platform specific wheel, we only include deps for that platform
+ _, _, platform_tag = self._path.name.rpartition("-")
+ platform_tag = platform_tag[:-4] # strip .whl
+ if platform_tag != "any":
+ platform = Platform.from_tag(platform_tag)
+ assert (
+ platform in platforms
+ ), f"BUG: wheel platform '{platform}' must be one of '{platforms}'"
+ platforms = {platform}
+ dependency_set = Deps(
+ self.name,
+ extras=extras_requested,
+ platforms=platforms,
+ )
for wheel_req in self.metadata.get_all("Requires-Dist", []):
- req = pkg_resources.Requirement(wheel_req) # type: ignore
+ dependency_set.add(wheel_req)
- if req.marker is None or any(
- req.marker.evaluate({"extra": extra})
- for extra in extras_requested or [""]
- ):
- dependency_set.add(req.name) # type: ignore
-
- return dependency_set
+ return dependency_set.build()
def unzip(self, directory: str) -> None:
installation_schemes = {
diff --git a/python/pip_install/tools/wheel_installer/wheel_installer.py b/python/pip_install/tools/wheel_installer/wheel_installer.py
index f5ed8c3..801ef95 100644
--- a/python/pip_install/tools/wheel_installer/wheel_installer.py
+++ b/python/pip_install/tools/wheel_installer/wheel_installer.py
@@ -14,19 +14,16 @@
"""Build and/or fetch a single wheel based on the requirement passed in"""
-import argparse
import errno
import glob
import json
import os
import re
-import shutil
import subprocess
import sys
-import textwrap
from pathlib import Path
from tempfile import NamedTemporaryFile
-from typing import Dict, Iterable, List, Optional, Set, Tuple
+from typing import Dict, List, Optional, Set, Tuple
from pip._vendor.packaging.utils import canonicalize_name
@@ -108,6 +105,7 @@
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.
@@ -126,16 +124,15 @@
_setup_namespace_pkg_compatibility(installation_dir)
extras_requested = extras[whl.name] if whl.name in extras else set()
- # Packages may create dependency cycles when specifying optional-dependencies / 'extras'.
- # Example: github.com/google/etils/blob/a0b71032095db14acf6b33516bca6d885fe09e35/pyproject.toml#L32.
- self_edge_dep = set([whl.name])
- whl_deps = sorted(whl.dependencies(extras_requested) - self_edge_dep)
+
+ 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": whl_deps,
+ "deps": dependencies.deps,
+ "deps_by_platform": dependencies.deps_select,
"entry_points": [
{
"name": name,
@@ -164,6 +161,7 @@
wheel_file=whl,
extras=extras,
enable_implicit_namespace_pkgs=args.enable_implicit_namespace_pkgs,
+ platforms=arguments.get_platforms(args),
)
return
diff --git a/python/pip_install/tools/wheel_installer/wheel_installer_test.py b/python/pip_install/tools/wheel_installer/wheel_installer_test.py
index b24e500..6eacd1f 100644
--- a/python/pip_install/tools/wheel_installer/wheel_installer_test.py
+++ b/python/pip_install/tools/wheel_installer/wheel_installer_test.py
@@ -19,7 +19,7 @@
import unittest
from pathlib import Path
-from python.pip_install.tools.wheel_installer import wheel_installer
+from python.pip_install.tools.wheel_installer import wheel, wheel_installer
class TestRequirementExtrasParsing(unittest.TestCase):
@@ -55,31 +55,6 @@
)
-# TODO @aignas 2023-07-21: migrate to starlark
-# class BazelTestCase(unittest.TestCase):
-# def test_generate_entry_point_contents(self):
-# got = wheel_installer._generate_entry_point_contents("sphinx.cmd.build", "main")
-# want = """#!/usr/bin/env python3
-# import sys
-# from sphinx.cmd.build import main
-# if __name__ == "__main__":
-# sys.exit(main())
-# """
-# self.assertEqual(got, want)
-#
-# def test_generate_entry_point_contents_with_shebang(self):
-# got = wheel_installer._generate_entry_point_contents(
-# "sphinx.cmd.build", "main", shebang="#!/usr/bin/python"
-# )
-# want = """#!/usr/bin/python
-# import sys
-# from sphinx.cmd.build import main
-# if __name__ == "__main__":
-# sys.exit(main())
-# """
-# self.assertEqual(got, want)
-
-
class TestWhlFilegroup(unittest.TestCase):
def setUp(self) -> None:
self.wheel_name = "example_minimal_package-0.0.1-py3-none-any.whl"
@@ -92,10 +67,11 @@
def test_wheel_exists(self) -> None:
wheel_installer._extract_wheel(
- self.wheel_path,
+ Path(self.wheel_path),
installation_dir=Path(self.wheel_dir),
extras={},
enable_implicit_namespace_pkgs=False,
+ platforms=[],
)
want_files = [
@@ -119,10 +95,34 @@
version="0.0.1",
name="example-minimal-package",
deps=[],
+ deps_by_platform={},
entry_points=[],
)
self.assertEqual(want, metadata_file_content)
+class TestWheelPlatform(unittest.TestCase):
+ def test_wheel_os_alias(self):
+ self.assertEqual("OS.osx", str(wheel.OS.osx))
+ self.assertEqual(str(wheel.OS.darwin), str(wheel.OS.osx))
+
+ def test_wheel_arch_alias(self):
+ self.assertEqual("Arch.x86_64", str(wheel.Arch.x86_64))
+ self.assertEqual(str(wheel.Arch.amd64), str(wheel.Arch.x86_64))
+
+ def test_wheel_platform_alias(self):
+ give = wheel.Platform(
+ os=wheel.OS.darwin,
+ arch=wheel.Arch.amd64,
+ )
+ alias = wheel.Platform(
+ os=wheel.OS.osx,
+ arch=wheel.Arch.x86_64,
+ )
+
+ self.assertEqual("osx_x86_64", str(give))
+ self.assertEqual(str(alias), str(give))
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/python/pip_install/tools/wheel_installer/wheel_test.py b/python/pip_install/tools/wheel_installer/wheel_test.py
new file mode 100644
index 0000000..57bfa94
--- /dev/null
+++ b/python/pip_install/tools/wheel_installer/wheel_test.py
@@ -0,0 +1,235 @@
+import unittest
+
+from python.pip_install.tools.wheel_installer import wheel
+
+
+class DepsTest(unittest.TestCase):
+ def test_simple(self):
+ deps = wheel.Deps("foo")
+ deps.add("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):
+ platforms = {
+ "linux_x86_64",
+ "osx_x86_64",
+ "windows_x86_64",
+ }
+ deps = wheel.Deps("foo", platforms=set(wheel.Platform.from_string(platforms)))
+ deps.add(
+ "bar",
+ "posix_dep; os_name=='posix'",
+ "win_dep; os_name=='nt'",
+ )
+
+ got = deps.build()
+
+ self.assertEqual(["bar"], got.deps)
+ self.assertEqual(
+ {
+ "@platforms//os:linux": ["posix_dep"],
+ "@platforms//os:osx": ["posix_dep"],
+ "@platforms//os:windows": ["win_dep"],
+ },
+ got.deps_select,
+ )
+
+ def test_can_add_platform_specific_deps(self):
+ platforms = {
+ "linux_x86_64",
+ "osx_x86_64",
+ "osx_aarch64",
+ "windows_x86_64",
+ }
+ deps = wheel.Deps("foo", platforms=set(wheel.Platform.from_string(platforms)))
+ deps.add(
+ "bar",
+ "posix_dep; os_name=='posix'",
+ "m1_dep; sys_platform=='darwin' and platform_machine=='arm64'",
+ "win_dep; os_name=='nt'",
+ )
+
+ got = deps.build()
+
+ self.assertEqual(["bar"], got.deps)
+ self.assertEqual(
+ {
+ "osx_aarch64": ["m1_dep", "posix_dep"],
+ "@platforms//os:linux": ["posix_dep"],
+ "@platforms//os:osx": ["posix_dep"],
+ "@platforms//os:windows": ["win_dep"],
+ },
+ got.deps_select,
+ )
+
+ def test_non_platform_markers_are_added_to_common_deps(self):
+ platforms = {
+ "linux_x86_64",
+ "osx_x86_64",
+ "osx_aarch64",
+ "windows_x86_64",
+ }
+ deps = wheel.Deps("foo", platforms=set(wheel.Platform.from_string(platforms)))
+ deps.add(
+ "bar",
+ "baz; implementation_name=='cpython'",
+ "m1_dep; sys_platform=='darwin' and platform_machine=='arm64'",
+ )
+
+ got = deps.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", extras={"ssl"})
+ deps.add(
+ "bar",
+ "req_dep; extra == 'requests'",
+ "foo[requests]; extra == 'ssl'",
+ "ssl_lib; extra == 'ssl'",
+ )
+
+ got = deps.build()
+
+ self.assertEqual(["bar", "req_dep", "ssl_lib"], got.deps)
+ self.assertEqual({}, got.deps_select)
+
+ def test_handle_etils(self):
+ deps = wheel.Deps("etils", extras={"all"})
+ requires = """
+etils[array-types] ; extra == "all"
+etils[eapp] ; extra == "all"
+etils[ecolab] ; extra == "all"
+etils[edc] ; extra == "all"
+etils[enp] ; extra == "all"
+etils[epath] ; extra == "all"
+etils[epath-gcs] ; extra == "all"
+etils[epath-s3] ; extra == "all"
+etils[epy] ; extra == "all"
+etils[etqdm] ; extra == "all"
+etils[etree] ; extra == "all"
+etils[etree-dm] ; extra == "all"
+etils[etree-jax] ; extra == "all"
+etils[etree-tf] ; extra == "all"
+etils[enp] ; extra == "array-types"
+pytest ; extra == "dev"
+pytest-subtests ; extra == "dev"
+pytest-xdist ; extra == "dev"
+pyink ; extra == "dev"
+pylint>=2.6.0 ; extra == "dev"
+chex ; extra == "dev"
+torch ; extra == "dev"
+optree ; extra == "dev"
+dataclass_array ; extra == "dev"
+sphinx-apitree[ext] ; extra == "docs"
+etils[dev,all] ; extra == "docs"
+absl-py ; extra == "eapp"
+simple_parsing ; extra == "eapp"
+etils[epy] ; extra == "eapp"
+jupyter ; extra == "ecolab"
+numpy ; extra == "ecolab"
+mediapy ; extra == "ecolab"
+packaging ; extra == "ecolab"
+etils[enp] ; extra == "ecolab"
+etils[epy] ; extra == "ecolab"
+etils[epy] ; extra == "edc"
+numpy ; extra == "enp"
+etils[epy] ; extra == "enp"
+fsspec ; extra == "epath"
+importlib_resources ; extra == "epath"
+typing_extensions ; extra == "epath"
+zipp ; extra == "epath"
+etils[epy] ; extra == "epath"
+gcsfs ; extra == "epath-gcs"
+etils[epath] ; extra == "epath-gcs"
+s3fs ; extra == "epath-s3"
+etils[epath] ; extra == "epath-s3"
+typing_extensions ; extra == "epy"
+absl-py ; extra == "etqdm"
+tqdm ; extra == "etqdm"
+etils[epy] ; extra == "etqdm"
+etils[array_types] ; extra == "etree"
+etils[epy] ; extra == "etree"
+etils[enp] ; extra == "etree"
+etils[etqdm] ; extra == "etree"
+dm-tree ; extra == "etree-dm"
+etils[etree] ; extra == "etree-dm"
+jax[cpu] ; extra == "etree-jax"
+etils[etree] ; extra == "etree-jax"
+tensorflow ; extra == "etree-tf"
+etils[etree] ; extra == "etree-tf"
+etils[ecolab] ; extra == "lazy-imports"
+"""
+
+ deps.add(*requires.strip().split("\n"))
+
+ got = deps.build()
+ want = [
+ "absl_py",
+ "dm_tree",
+ "fsspec",
+ "gcsfs",
+ "importlib_resources",
+ "jax",
+ "jupyter",
+ "mediapy",
+ "numpy",
+ "packaging",
+ "s3fs",
+ "simple_parsing",
+ "tensorflow",
+ "tqdm",
+ "typing_extensions",
+ "zipp",
+ ]
+
+ self.assertEqual(want, got.deps)
+ self.assertEqual({}, got.deps_select)
+
+
+class PlatformTest(unittest.TestCase):
+ def test_platform_from_string(self):
+ tests = {
+ "win_amd64": "windows_x86_64",
+ "macosx_10_9_arm64": "osx_aarch64",
+ "manylinux1_i686.manylinux_2_17_i686": "linux_x86_32",
+ "musllinux_1_1_ppc64le": "linux_ppc",
+ }
+
+ for give, want in tests.items():
+ with self.subTest(give=give, want=want):
+ self.assertEqual(
+ wheel.Platform.from_string(want)[0],
+ wheel.Platform.from_tag(give),
+ )
+
+ def test_can_get_host(self):
+ host = wheel.Platform.host()
+ self.assertIsNotNone(host)
+ self.assertEqual(1, len(wheel.Platform.from_string("host")))
+ self.assertEqual(host, wheel.Platform.from_string("host"))
+
+ def test_can_get_all(self):
+ all_platforms = wheel.Platform.all()
+ self.assertEqual(15, len(all_platforms))
+ self.assertEqual(all_platforms, wheel.Platform.from_string("all"))
+
+ def test_can_get_all_for_os(self):
+ linuxes = wheel.Platform.all(wheel.OS.linux)
+ self.assertEqual(5, len(linuxes))
+ self.assertEqual(linuxes, wheel.Platform.from_string("linux_*"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/python/private/bzlmod/pip.bzl b/python/private/bzlmod/pip.bzl
index 305039f..3735ed8 100644
--- a/python/private/bzlmod/pip.bzl
+++ b/python/private/bzlmod/pip.bzl
@@ -158,6 +158,7 @@
p: json.encode(args)
for p, args in whl_overrides.get(whl_name, {}).items()
},
+ experimental_target_platforms = pip_attr.experimental_target_platforms,
python_interpreter = pip_attr.python_interpreter,
python_interpreter_target = python_interpreter_target,
quiet = pip_attr.quiet,
diff --git a/python/private/text_util.bzl b/python/private/text_util.bzl
index da67001..a1bec5e 100644
--- a/python/private/text_util.bzl
+++ b/python/private/text_util.bzl
@@ -28,18 +28,18 @@
")",
])
-def _render_dict(d):
+def _render_dict(d, *, value_repr = repr):
return "\n".join([
"{",
_indent("\n".join([
- "{}: {},".format(repr(k), repr(v))
+ "{}: {},".format(repr(k), value_repr(v))
for k, v in d.items()
])),
"}",
])
-def _render_select(selects, *, no_match_error = None):
- dict_str = _render_dict(selects) + ","
+def _render_select(selects, *, no_match_error = None, value_repr = repr):
+ dict_str = _render_dict(selects, value_repr = value_repr) + ","
if no_match_error:
args = "\n".join([
@@ -58,6 +58,12 @@
return "select({})".format(args)
def _render_list(items):
+ if not items:
+ return "[]"
+
+ if len(items) == 1:
+ return "[{}]".format(repr(items[0]))
+
return "\n".join([
"[",
_indent("\n".join([
diff --git a/tests/pip_install/whl_library/generate_build_bazel_tests.bzl b/tests/pip_install/whl_library/generate_build_bazel_tests.bzl
index c65beb5..b89477f 100644
--- a/tests/pip_install/whl_library/generate_build_bazel_tests.bzl
+++ b/tests/pip_install/whl_library/generate_build_bazel_tests.bzl
@@ -39,7 +39,15 @@
filegroup(
name = "_whl",
srcs = ["foo.whl"],
- data = ["@pypi_bar_baz//:whl", "@pypi_foo//:whl"],
+ data = [
+ "@pypi_bar_baz//:whl",
+ "@pypi_foo//:whl",
+ ] + select(
+ {
+ "@platforms//os:windows": ["@pypi_colorama//:whl"],
+ "//conditions:default": [],
+ },
+ ),
visibility = ["//visibility:private"],
)
@@ -59,7 +67,15 @@
# This makes this directory a top-level in the python import
# search path for anything that depends on this.
imports = ["site-packages"],
- deps = ["@pypi_bar_baz//:pkg", "@pypi_foo//:pkg"],
+ deps = [
+ "@pypi_bar_baz//:pkg",
+ "@pypi_foo//:pkg",
+ ] + select(
+ {
+ "@platforms//os:windows": ["@pypi_colorama//:pkg"],
+ "//conditions:default": [],
+ },
+ ),
tags = ["tag1", "tag2"],
visibility = ["//visibility:private"],
)
@@ -78,6 +94,7 @@
repo_prefix = "pypi_",
whl_name = "foo.whl",
dependencies = ["foo", "bar-baz"],
+ dependencies_by_platform = {"@platforms//os:windows": ["colorama"]},
data_exclude = [],
tags = ["tag1", "tag2"],
entry_points = {},
@@ -107,7 +124,10 @@
filegroup(
name = "_whl",
srcs = ["foo.whl"],
- data = ["@pypi_bar_baz//:whl", "@pypi_foo//:whl"],
+ data = [
+ "@pypi_bar_baz//:whl",
+ "@pypi_foo//:whl",
+ ],
visibility = ["//visibility:private"],
)
@@ -127,7 +147,10 @@
# This makes this directory a top-level in the python import
# search path for anything that depends on this.
imports = ["site-packages"],
- deps = ["@pypi_bar_baz//:pkg", "@pypi_foo//:pkg"],
+ deps = [
+ "@pypi_bar_baz//:pkg",
+ "@pypi_foo//:pkg",
+ ],
tags = ["tag1", "tag2"],
visibility = ["//visibility:private"],
)
@@ -162,6 +185,7 @@
repo_prefix = "pypi_",
whl_name = "foo.whl",
dependencies = ["foo", "bar-baz"],
+ dependencies_by_platform = {},
data_exclude = [],
tags = ["tag1", "tag2"],
entry_points = {},
@@ -198,7 +222,10 @@
filegroup(
name = "_whl",
srcs = ["foo.whl"],
- data = ["@pypi_bar_baz//:whl", "@pypi_foo//:whl"],
+ data = [
+ "@pypi_bar_baz//:whl",
+ "@pypi_foo//:whl",
+ ],
visibility = ["//visibility:private"],
)
@@ -218,7 +245,10 @@
# This makes this directory a top-level in the python import
# search path for anything that depends on this.
imports = ["site-packages"],
- deps = ["@pypi_bar_baz//:pkg", "@pypi_foo//:pkg"],
+ deps = [
+ "@pypi_bar_baz//:pkg",
+ "@pypi_foo//:pkg",
+ ],
tags = ["tag1", "tag2"],
visibility = ["//visibility:private"],
)
@@ -246,6 +276,7 @@
repo_prefix = "pypi_",
whl_name = "foo.whl",
dependencies = ["foo", "bar-baz"],
+ dependencies_by_platform = {},
data_exclude = [],
tags = ["tag1", "tag2"],
entry_points = {"fizz": "buzz.py"},
@@ -275,7 +306,16 @@
filegroup(
name = "_whl",
srcs = ["foo.whl"],
- data = ["@pypi_bar_baz//:whl"],
+ data = ["@pypi_bar_baz//:whl"] + select(
+ {
+ ":is_linux_x86_64": [
+ "@pypi_box//:whl",
+ "@pypi_box_amd64//:whl",
+ ],
+ "@platforms//os:linux": ["@pypi_box//:whl"],
+ "//conditions:default": [],
+ },
+ ),
visibility = ["@pypi__groups//:__pkg__"],
)
@@ -295,7 +335,16 @@
# This makes this directory a top-level in the python import
# search path for anything that depends on this.
imports = ["site-packages"],
- deps = ["@pypi_bar_baz//:pkg"],
+ deps = ["@pypi_bar_baz//:pkg"] + select(
+ {
+ ":is_linux_x86_64": [
+ "@pypi_box//:pkg",
+ "@pypi_box_amd64//:pkg",
+ ],
+ "@platforms//os:linux": ["@pypi_box//:pkg"],
+ "//conditions:default": [],
+ },
+ ),
tags = [],
visibility = ["@pypi__groups//:__pkg__"],
)
@@ -309,17 +358,31 @@
name = "whl",
actual = "@pypi__groups//:qux_whl",
)
+
+config_setting(
+ name = "is_linux_x86_64",
+ constraint_values = [
+ "@platforms//cpu:x86_64",
+ "@platforms//os:linux",
+ ],
+ visibility = ["//visibility:private"],
+)
"""
actual = generate_whl_library_build_bazel(
repo_prefix = "pypi_",
whl_name = "foo.whl",
dependencies = ["foo", "bar-baz", "qux"],
+ dependencies_by_platform = {
+ "linux_x86_64": ["box", "box-amd64"],
+ "windows_x86_64": ["fox"],
+ "@platforms//os:linux": ["box"], # buildifier: disable=unsorted-dict-items
+ },
tags = [],
entry_points = {},
data_exclude = [],
annotation = None,
group_name = "qux",
- group_deps = ["foo", "qux"],
+ group_deps = ["foo", "fox", "qux"],
)
env.expect.that_str(actual).equals(want)