diff --git a/docs/source/conf.py b/docs/source/conf.py index 5d33d50..4d763ad 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -63,11 +63,12 @@ # https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), "rez": ("https://rez.readthedocs.io/en/stable/", None), } # Force usage of :external: -intersphinx_disabled_reftypes = ["*"] +# intersphinx_disabled_reftypes = ["*"] # -- Custom ------------------------------------------------------------------ diff --git a/docs/source/index.rst b/docs/source/index.rst index 3cd4830..3aff58c 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -69,5 +69,6 @@ automatically created by the `install.py None + + The pre-pip resolve hook allows a plugin to run some checks *before* resolving the + requested packages using pip. The hook **must** not modify the content of the + arguments passed to it. + + Some use cases are allowing or disallowing the installation of some packages. + + :param packages: List of packages requested by the user. + :param requirements: List of `requirements files `_ if any. + +.. py:function:: postPipResolve(packages: list[rez_pip.pip.PackageInfo]) -> None + + The post-pip resolve hook allows a plugin to run some checks *after* resolving the + requested packages using pip. The hook **must** not modify the content of the + arguments passed to it. + + Some use cases are allowing or disallowing the installation of some packages. + + :param packages: List of resolved packages. + +.. py:function:: groupPackages(packages: list[rez_pip.pip.PackageInfo]) -> list[rez_pip.pip.PackageGroup]: + + Merge packages into groups of packages. The name and version of the first package + in the group will be used as the name and version for the rez package. + + The hook **must** pop grouped packages out of the "packages" variable. + + :param packages: List of resolved packages. + :returns: A list of package groups. + +.. py:function:: metadata(package: rez.package_maker.PackageMaker) -> None + + Modify/inject metadata in the rez package. The plugin is expected to modify + "package" in place. + + :param package: An instanciate PackageMaker. + +.. py:function:: cleanup(dist: importlib.metadata.Distribution, path: str) -> None + + Cleanup a package post-installation. + + :param dist: Python distribution. + :param path: Root path of the rez variant. diff --git a/pyproject.toml b/pyproject.toml index df30d26..3dcbbc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,9 @@ dependencies = [ "dataclasses-json", "rich", "importlib_metadata>=4.6 ; python_version < '3.10'", + # 1.3 introduces type hints. + "pluggy>=1.2", + "typing-extensions; python_version < '3.8'" ] classifiers = [ diff --git a/pytest.ini b/pytest.ini index d9f1853..6657b85 100644 --- a/pytest.ini +++ b/pytest.ini @@ -7,12 +7,12 @@ addopts = --cov-report=term-missing --cov-report=xml --cov-report=html - --durations=0 + #--durations=0 norecursedirs = rez_repo markers = integration: mark the tests as integration tests py37: mark the tests has using a Python 3.7 rez package - py39: mark the tests has using a Python 3.7 rez package + py39: mark the tests has using a Python 3.9 rez package py311: mark the tests has using a Python 3.11 rez package diff --git a/src/rez_pip/cli.py b/src/rez_pip/cli.py index 3527a02..f90d972 100644 --- a/src/rez_pip/cli.py +++ b/src/rez_pip/cli.py @@ -6,18 +6,14 @@ import logging import argparse import textwrap -import pathlib import tempfile +import itertools import subprocess -if sys.version_info >= (3, 10): - import importlib.metadata as importlib_metadata -else: - import importlib_metadata - import rich import rich.text import rich.panel +import rich.table import rez.version import rich.markup import rich.logging @@ -25,9 +21,11 @@ import rez_pip.pip import rez_pip.rez import rez_pip.data +import rez_pip.plugins import rez_pip.install import rez_pip.download import rez_pip.exceptions +from rez_pip.compat import importlib_metadata _LOG = logging.getLogger("rez_pip.cli") @@ -120,6 +118,10 @@ def _createParser() -> argparse.ArgumentParser: help="Print debug information that you can use when reporting an issue on GitHub.", ) + debugGroup.add_argument( + "--list-plugins", action="store_true", help="List all registered plugins" + ) + parser.usage = f""" %(prog)s [options] @@ -202,37 +204,50 @@ def _run(args: argparse.Namespace, pipArgs: typing.List[str], pipWorkArea: str) ) _LOG.info(f"Resolved {len(packages)} dependencies for python {pythonVersion}") + packageGroups: typing.List[rez_pip.pip.PackageGroup] = list( + itertools.chain(*rez_pip.plugins.getHook().groupPackages(packages=packages)) # type: ignore[arg-type] + ) + + # Remove empty groups + packageGroups = [group for group in packageGroups if group] + + # Add packages that were not grouped. + packageGroups += [rez_pip.pip.PackageGroup([package]) for package in packages] # TODO: Should we postpone downloading to the last minute if we can? _LOG.info("[bold]Downloading...") - wheels = rez_pip.download.downloadPackages(packages, wheelsDir) - _LOG.info(f"[bold]Downloaded {len(wheels)} wheels") - dists: typing.Dict[importlib_metadata.Distribution, bool] = {} + downloadedWheels = rez_pip.download.downloadPackages(packageGroups, wheelsDir) + foundLocally = [ + p + for group in packageGroups + for p in group.packages + if not p.isDownloadRequired() + ] + + _LOG.info( + f"[bold]Downloaded {len(downloadedWheels)} wheels, skipped {len(foundLocally)} because they resolved to local files" + ) with rich.get_console().status( f"[bold]Installing wheels into {installedWheelsDir!r}" ): - for package, wheel in zip(packages, wheels): - _LOG.info(f"[bold]Installing {package.name}-{package.version} wheel") - dist, isPure = rez_pip.install.installWheel( - package, pathlib.Path(wheel), installedWheelsDir - ) - - dists[dist] = isPure - - distNames = [dist.name for dist in dists.keys()] + for group in packageGroups: + for package in group.packages: + _LOG.info(f"[bold]Installing {package.name} {package.localPath}") + dist = rez_pip.install.installWheel( + package, + package.localPath, + os.path.join(installedWheelsDir, package.name), + ) + group.dists.append(dist) with rich.get_console().status("[bold]Creating rez packages..."): - for dist, package in zip(dists, packages): - isPure = dists[dist] + for group in packageGroups: rez_pip.rez.createPackage( - dist, - isPure, + group, rez.version.Version(pythonVersion), - distNames, installedWheelsDir, - wheelURL=package.download_info.url, prefix=args.prefix, release=args.release, ) @@ -313,10 +328,24 @@ def _debug( ) +def _printPlugins() -> None: + table = rich.table.Table("Name", "Hooks", box=None) + for plugin, hooks in rez_pip.plugins._getHookImplementations().items(): + table.add_row(plugin, ", ".join(hooks)) + rich.get_console().print(table) + + def run() -> int: pipWorkArea = tempfile.mkdtemp(prefix="rez-pip-target") args, pipArgs = _parseArgs(sys.argv[1:]) + # Initialize the plugin system + rez_pip.plugins.getManager() + + if args.list_plugins: + _printPlugins() + return 0 + try: _validateArgs(args) diff --git a/src/rez_pip/compat.py b/src/rez_pip/compat.py new file mode 100644 index 0000000..b5b1e57 --- /dev/null +++ b/src/rez_pip/compat.py @@ -0,0 +1,13 @@ +import sys + +if sys.version_info <= (3, 8): + from typing import Sequence, MutableSequence, Mapping +else: + from collections.abc import Sequence, MutableSequence, Mapping + +if sys.version_info >= (3, 10): + import importlib.metadata as importlib_metadata +else: + import importlib_metadata + +__all__ = ["Sequence", "MutableSequence", "Mapping", "importlib_metadata"] diff --git a/src/rez_pip/download.py b/src/rez_pip/download.py index 88604ee..a877e73 100644 --- a/src/rez_pip/download.py +++ b/src/rez_pip/download.py @@ -1,5 +1,4 @@ import os -import sys import typing import asyncio import hashlib @@ -9,25 +8,21 @@ import aiohttp import rich.progress -if sys.version_info >= (3, 10): - import importlib.metadata as importlib_metadata -else: - import importlib_metadata - import rez_pip.pip +from rez_pip.compat import importlib_metadata _LOG = logging.getLogger(__name__) _lock = asyncio.Lock() def downloadPackages( - packages: typing.List[rez_pip.pip.PackageInfo], dest: str + packageGroups: typing.List[rez_pip.pip.PackageGroup], dest: str ) -> typing.List[str]: - return asyncio.run(_downloadPackages(packages, dest)) + return asyncio.run(_downloadPackages(packageGroups, dest)) async def _downloadPackages( - packages: typing.List[rez_pip.pip.PackageInfo], dest: str + packageGroups: typing.List[rez_pip.pip.PackageGroup], dest: str ) -> typing.List[str]: items: typing.List[ typing.Coroutine[typing.Any, typing.Any, typing.Optional[str]] @@ -47,18 +42,33 @@ async def _downloadPackages( tasks: typing.Dict[str, rich.progress.TaskID] = {} # Create all the downlod tasks first - for package in packages: - tasks[package.name] = progress.add_task(package.name) + numPackages = 0 + for group in packageGroups: + for package in group.packages: + if not package.isDownloadRequired(): + continue - # Then create the "total" progress bar. This ensures that total is at the bottom. - mainTask = progress.add_task(f"[bold]Total (0/{len(packages)})", total=0) + numPackages += 1 + tasks[package.name] = progress.add_task(package.name) - for package in packages: - items.append( - _download( - package, dest, session, progress, tasks[package.name], mainTask + # Then create the "total" progress bar. This ensures that total is at the bottom. + mainTask = progress.add_task(f"[bold]Total (0/{numPackages})", total=0) + + for group in packageGroups: + for package in group.packages: + if not package.isDownloadRequired(): + continue + + items.append( + _download( + package, + dest, + session, + progress, + tasks[package.name], + mainTask, + ) ) - ) wheels = await asyncio.gather(*items) @@ -152,4 +162,5 @@ async def _download( mainTaskID, description=f"[bold]Total ({len(completedItems)}/{total})" ) + package.localPath = wheelPath return wheelPath diff --git a/src/rez_pip/install.py b/src/rez_pip/install.py index a4406fb..f24051c 100644 --- a/src/rez_pip/install.py +++ b/src/rez_pip/install.py @@ -11,11 +11,6 @@ import pathlib import sysconfig -if sys.version_info >= (3, 10): - import importlib.metadata as importlib_metadata -else: - import importlib_metadata - if typing.TYPE_CHECKING: if sys.version_info >= (3, 8): from typing import Literal @@ -30,6 +25,7 @@ import installer.destinations import rez_pip.pip +from rez_pip.compat import importlib_metadata _LOG = logging.getLogger(__name__) @@ -38,9 +34,15 @@ ScriptSection = Literal["console", "gui"] -def isWheelPure(source: installer.sources.WheelSource) -> bool: - stream = source.read_dist_info("WHEEL") - metadata = installer.utils.parse_metadata_file(stream) +def isWheelPure(dist: importlib_metadata.Distribution) -> bool: + # dist.files should never be empty, but assert to silence mypy. + assert dist.files is not None + + path = next( + f for f in dist.files if os.fspath(f.locate()).endswith(".dist-info/WHEEL") + ) + with open(path.locate()) as fd: + metadata = installer.utils.parse_metadata_file(fd.read()) return typing.cast(str, metadata["Root-Is-Purelib"]) == "true" @@ -69,9 +71,9 @@ def getSchemeDict(name: str, target: str) -> typing.Dict[str, str]: def installWheel( package: rez_pip.pip.PackageInfo, - wheelPath: pathlib.Path, + wheelPath: str, targetPath: str, -) -> typing.Tuple[importlib_metadata.Distribution, bool]: +) -> importlib_metadata.Distribution: # TODO: Technically, target should be optional. We will always want to install in "pip install --target" # mode. So right now it's a CLI option for debugging purposes. @@ -82,11 +84,8 @@ def installWheel( script_kind=installer.utils.get_launcher_kind(), ) - isPure = True _LOG.debug(f"Installing {wheelPath} into {targetPath!r}") - with installer.sources.WheelFile.open(wheelPath) as source: - isPure = isWheelPure(source) - + with installer.sources.WheelFile.open(pathlib.Path(wheelPath)) as source: installer.install( source=source, destination=destination, @@ -119,7 +118,7 @@ def installWheel( if not dist.files: raise RuntimeError(f"{path!r} does not exist!") - return dist, isPure + return dist # TODO: Document where this code comes from. diff --git a/src/rez_pip/pip.py b/src/rez_pip/pip.py index b02cfaf..bd03a92 100644 --- a/src/rez_pip/pip.py +++ b/src/rez_pip/pip.py @@ -11,8 +11,12 @@ import dataclasses_json import rez_pip.data +import rez_pip.plugins import rez_pip.exceptions +if typing.TYPE_CHECKING: + import importlib.metadata as importlib_metadata + _LOG = logging.getLogger(__name__) @@ -49,6 +53,10 @@ class PackageInfo(dataclasses_json.DataClassJsonMixin): undefined=dataclasses_json.Undefined.EXCLUDE ) + # Must be set once the package is downloaded. + # Can be retrieved through the localPath property. + __localPath: typing.Optional[str] = None + @property def name(self) -> str: return self.metadata.name @@ -57,6 +65,53 @@ def name(self) -> str: def version(self) -> str: return self.metadata.version + def isDownloadRequired(self) -> bool: + return not self.download_info.url.startswith("file://") + + @property + def localPath(self) -> str: + """Path to the package on disk.""" + if not self.isDownloadRequired(): + return self.download_info.url[7:] + + if self.__localPath is None: + raise rez_pip.exceptions.RezPipError( + f"{self.download_info.url} is not yet downloaded." + ) + return self.__localPath + + @localPath.setter + def localPath(self, path: str) -> None: + self.__localPath = path + + +class PackageGroup: + """A group of package""" + + packages: typing.List[PackageInfo] + dists: typing.List["importlib_metadata.Distribution"] + + def __init__(self, packages: typing.List[PackageInfo]) -> None: + self.packages = packages + self.dists = [] + + def __str__(self) -> str: + return "PackageGroup({})".format( + [f"{p.name}=={p.version}" for p in self.packages] + ) + + def __repr__(self) -> str: + return "PackageGroup({})".format( + [f"{p.name}=={p.version}" for p in self.packages] + ) + + def __bool__(self) -> bool: + return bool(self.packages) + + @property + def downloadUrls(self) -> typing.List[str]: + return [p.download_info.url for p in self.packages] + def getBundledPip() -> str: return os.path.join(os.path.dirname(rez_pip.data.__file__), "pip.pyz") @@ -71,7 +126,9 @@ def getPackages( constraints: typing.List[str], extraArgs: typing.List[str], ) -> typing.List[PackageInfo]: - # python pip.pyz install -q requests --dry-run --ignore-installed --python-version 2.7 --only-binary=:all: --target /tmp/asd --report - + rez_pip.plugins.getHook().prePipResolve( + packages=packageNames, requirements=requirements + ) _fd, tmpFile = tempfile.mkstemp(prefix="pip-install-output", text=True) os.close(_fd) @@ -138,6 +195,8 @@ def getPackages( packageInfo = PackageInfo.from_dict(rawPackage) packages.append(packageInfo) + rez_pip.plugins.getHook().postPipResolve(packages=packages) + return packages diff --git a/src/rez_pip/plugins/PySide6.py b/src/rez_pip/plugins/PySide6.py new file mode 100644 index 0000000..df9c801 --- /dev/null +++ b/src/rez_pip/plugins/PySide6.py @@ -0,0 +1,117 @@ +"""PySide6 plugin. + +For PySide6, we need a merge hook. If User says "install PySide6", we need to install PySide6, PySide6-Addons and PySide6-Essentials and shiboken6. + +But PySide6, PySide6-Addons and PySide6-Essentials have to be merged. Additionally, shiboken6 needs to be broken down to remove PySide6 (core). +Because shiboken6 vendors PySide6-core... See https://inspector.pypi.io/project/shiboken6/6.6.1/packages/bb/72/e54f758e49e8da0dcd9490d006c41a814b0e56898ce4ca054d60cdba97bd/shiboken6-6.6.1-cp38-abi3-manylinux_2_28_x86_64.whl/. + +On Windows, the PySide6/openssl folder has to be added to PATH, see https://inspector.pypi.io/project/pyside6/6.6.1/packages/ec/3d/1da1b88d74cb5318466156bac91f17ad4272c6c83a973e107ad9a9085009/PySide6-6.6.1-cp38-abi3-win_amd64.whl/PySide6/__init__.py#line.81. + +So it's at least a 3 steps process: +1. Merge PySide6, PySide6-Essentials and PySide6-Addons into the same install. Unvendor shiboken. +2. Install shiboken + cleanup. The Cleanup could be its own hook here specific to shiboken. +""" + +import os +import shutil +import typing + +import packaging.utils +import packaging.version +import packaging.specifiers +import packaging.requirements + +import rez_pip.pip +import rez_pip.plugins +import rez_pip.exceptions + +if typing.TYPE_CHECKING: + from rez_pip.compat import importlib_metadata + +# PySide6 was initiall a single package that had shiboken as a dependency. +# Starting from 6.3.0, the package was spit in 3, PySide6, PySide6-Essentials and +# PySide6-Addons. + + +@rez_pip.plugins.hookimpl +def prePipResolve( + packages: typing.List[str], +) -> None: + pyside6Seen = False + variantsSeens = [] + + for package in packages: + req = packaging.requirements.Requirement(package) + name = packaging.utils.canonicalize_name(req.name) + + if name == "pyside6": + pyside6Seen = True + elif name in ["pyside6-essentials", "pyside6-addons"]: + variantsSeens.append(req.name) + + if variantsSeens and not pyside6Seen: + variants = " and ".join(variantsSeens) + verb = "was" if len(variantsSeens) == 1 else "were" + raise rez_pip.exceptions.RezPipError( + f"{variants} {verb} requested but PySide6 was not. You must explicitly request PySide6 in addition to {variants}." + ) + + +@rez_pip.plugins.hookimpl +def postPipResolve(packages: typing.List[rez_pip.pip.PackageInfo]) -> None: + """ + This hook is implemented out of extra caution. We really don't want PySide6-Addons + or PySide6-Essentials to be installed without PySide6. + + In this case, we cover cases where a user requests a package X and that package + depends on PySide6-Addons or PySide6-Essentials. + """ + pyside6Seen = False + variantsSeens = [] + + for package in packages: + name = packaging.utils.canonicalize_name(package.name) + if name == "pyside6": + pyside6Seen = True + elif name in ["pyside6-essentials", "pyside6-addons"]: + variantsSeens.append(package.name) + + if variantsSeens and not pyside6Seen: + variants = " and ".join(variantsSeens) + verb = "is" if len(variantsSeens) == 1 else "are" + raise rez_pip.exceptions.RezPipError( + f"{variants} {verb} part of the resolved packages but PySide6 was not. Dependencies and or you must explicitly request PySide6 in addition to {variants}." + ) + + +@rez_pip.plugins.hookimpl +def groupPackages( + packages: typing.List[rez_pip.pip.PackageInfo], +) -> typing.List[rez_pip.pip.PackageGroup]: + data = [] + for index, package in enumerate(packages[:]): + if packaging.utils.canonicalize_name(package.name) in [ + "pyside6", + "pyside6-addons", + "pyside6-essentials", + ]: + data.append(package) + packages.remove(package) + return [rez_pip.pip.PackageGroup(data)] + + +@rez_pip.plugins.hookimpl +def cleanup(dist: "importlib_metadata.Distribution", path: str) -> None: + if packaging.utils.canonicalize_name(dist.name) not in [ + "pyside6", + "pyside6-addons", + "pyside6-essentials", + ]: + return + + # Remove shiboken6 from PySide6 packages... + # PySide6 >=6.3, <6.6.2 were shipping some shiboken6 folders by mistake. + # Not removing these extra folders would stop python from being able to import + # the correct shiboken (that lives in a separate rez package). + shutil.rmtree(os.path.join(path, "python", "shiboken6")) + shutil.rmtree(os.path.join(path, "python", "shiboken6_generator")) diff --git a/src/rez_pip/plugins/__init__.py b/src/rez_pip/plugins/__init__.py new file mode 100644 index 0000000..6c80065 --- /dev/null +++ b/src/rez_pip/plugins/__init__.py @@ -0,0 +1,147 @@ +"""Plugin system.""" + +import typing +import logging +import pkgutil +import functools +import importlib + +import pluggy +import rez.package_maker + +if typing.TYPE_CHECKING: + import rez_pip.pip + import rez_pip.compat + +__all__ = [ + "hookimpl", +] + + +def __dir__() -> typing.List[str]: + return __all__ + + +_LOG = logging.getLogger(__name__) + +F = typing.TypeVar("F", bound=typing.Callable[..., typing.Any]) +hookspec = typing.cast(typing.Callable[[F], F], pluggy.HookspecMarker("rez-pip")) +hookimpl = typing.cast(typing.Callable[[F], F], pluggy.HookimplMarker("rez-pip")) + + +class PluginSpec: + @hookspec + def prePipResolve( + self, + packages: "rez_pip.compat.Sequence[str]", # Immutable + requirements: "rez_pip.compat.Sequence[str]", # Immutable + ) -> None: + """ + Take an action before resolving the packages using pip. + The packages argument should not be modified in any way. + """ + + @hookspec + def postPipResolve( + self, + packages: 'rez_pip.compat.Sequence["rez_pip.pip.PackageInfo"]', # Immutable + ) -> None: + """ + Take an action after resolving the packages using pip. + The packages argument should not be modified in any way. + """ + + @hookspec + def groupPackages( # type: ignore[empty-body] + self, packages: 'rez_pip.compat.MutableSequence["rez_pip.pip.PackageInfo"]' + ) -> 'rez_pip.compat.Sequence["rez_pip.pip.PackageGroup"]': + """ + Merge packages into groups of packages. The name and version of the first package + in the group will be used as the name and version for the rez package. + + The hook must pop grouped packages out of the "packages" variable. + """ + + @hookspec + def cleanup( + self, dist: "rez_pip.compat.importlib_metadata.Distribution", path: str + ) -> None: + """Cleanup installed distribution""" + + @hookspec + def metadata(self, package: rez.package_maker.PackageMaker) -> None: + """ + Modify/inject metadata in the rez package. The plugin is expected to modify + "package" in place. + """ + + +def before( + hookName: str, + hookImpls: "rez_pip.compat.Sequence[pluggy.HookImpl]", + kwargs: "rez_pip.compat.Mapping[str, typing.Any]", +) -> None: + """Function that will be called before each hook.""" + _LOG.debug("Calling the %r hooks", hookName) + + +def after( + outcome: pluggy.Result[typing.Any], + hookName: str, + hookImpls: "rez_pip.compat.Sequence[pluggy.HookImpl]", + kwargs: "rez_pip.compat.Mapping[str, typing.Any]", +) -> None: + """Function that will be called after each hook.""" + _LOG.debug("Called the %r hooks", hookName) + + +@functools.lru_cache() +def getManager() -> pluggy.PluginManager: + """ + Returns the plugin manager. The return value will be cached on first call + and the cached value will be return in subsequent calls. + """ + manager = pluggy.PluginManager("rez-pip") + if _LOG.getEffectiveLevel() <= logging.DEBUG: + manager.trace.root.setwriter(print) + manager.enable_tracing() + + manager.add_hookspecs(PluginSpec) + + # Register the builtin plugins + for module in pkgutil.iter_modules(__path__): + manager.register( + importlib.import_module(f"rez_pip.plugins.{module.name}"), + name=f"rez_pip.{module.name}", + ) + + manager.load_setuptools_entrypoints("rez-pip") + + manager.add_hookcall_monitoring(before, after) + return manager + + +def getHook() -> PluginSpec: + """ + Returns the hook attribute from the manager. This is allows + to have type hints at the caller sites. + + Inspired by https://stackoverflow.com/a/54695761. + """ + manager = getManager() + return typing.cast(PluginSpec, manager.hook) + + +def _getHookImplementations() -> typing.Dict[str, typing.List[str]]: + manager = getManager() + + implementations = {} + for name, plugin in manager.list_name_plugin(): + hookcallers = manager.get_hookcallers(plugin) + + # hookcallers will never be None because we get the names from list_name_plugin. + # But it silences mypy. + assert hookcallers is not None + + implementations[name] = [caller.name for caller in hookcallers] + return implementations diff --git a/src/rez_pip/plugins/shiboken6.py b/src/rez_pip/plugins/shiboken6.py new file mode 100644 index 0000000..22fe8d7 --- /dev/null +++ b/src/rez_pip/plugins/shiboken6.py @@ -0,0 +1,28 @@ +import os +import typing +import shutil +import logging + +import packaging.utils + +import rez_pip.plugins + +if typing.TYPE_CHECKING: + from rez_pip.compat import importlib_metadata + +_LOG = logging.getLogger(__name__) + + +@rez_pip.plugins.hookimpl +def cleanup(dist: "importlib_metadata.Distribution", path: str) -> None: + if packaging.utils.canonicalize_name(dist.name) != "shiboken6": + return + + # Remove PySide6 from shiboken6 packages... + # shiboken6 >=6.3, <6.6.2 were shipping some PySide6 folders by mistake. + # Not removing these extra folders would stop python from being able to import + # the correct PySide6 (that lives in a separate rez package). + path = os.path.join(path, "python", "PySide6") + if os.path.exists(path): + _LOG.debug(f"Removing {path!r}") + shutil.rmtree(path) diff --git a/src/rez_pip/rez.py b/src/rez_pip/rez.py index 17d0133..9f4dc0a 100644 --- a/src/rez_pip/rez.py +++ b/src/rez_pip/rez.py @@ -1,5 +1,4 @@ import os -import sys import copy import shutil import typing @@ -7,11 +6,6 @@ import pathlib import itertools -if sys.version_info >= (3, 10): - import importlib.metadata as importlib_metadata -else: - import importlib_metadata - import rez.config import rez.version import rez.packages @@ -20,29 +14,64 @@ import rez_pip.pip import rez_pip.utils +import rez_pip.plugins +from rez_pip.compat import importlib_metadata _LOG = logging.getLogger(__name__) def createPackage( - dist: importlib_metadata.Distribution, - isPure: bool, + packageGroup: rez_pip.pip.PackageGroup, pythonVersion: rez.version.Version, - nameCasings: typing.List[str], installedWheelsDir: str, - wheelURL: str, prefix: typing.Optional[str] = None, release: bool = False, ) -> None: - _LOG.info(f"Creating rez package for {dist.name}") - name = rez_pip.utils.pythontDistributionNameToRez(dist.name) - version = rez_pip.utils.pythonDistributionVersionToRez(dist.version) + _LOG.info( + "Creating rez package for {0}".format( + " + ".join(dist.name for dist in packageGroup.dists) + ) + ) + + rezNames = [ + rez_pip.utils.pythontDistributionNameToRez(dist.name) + for dist in packageGroup.dists + ] - requirements = rez_pip.utils.getRezRequirements(dist, pythonVersion, isPure, []) + name = rezNames[0] + version = rez_pip.utils.pythonDistributionVersionToRez( + packageGroup.dists[0].version + ) - requires = requirements.requires - variant_requires = requirements.variant_requires - metadata = requirements.metadata + requires = [] + variant_requires = [] + metadata: typing.Dict[str, typing.Any] = {} + isPure = True + for dist in packageGroup.dists: + requirements = rez_pip.utils.getRezRequirements(dist, pythonVersion, []) + if not metadata: + # For now we only use the metadata from the first package. Far from ideal... + metadata = requirements.metadata + + # TODO: Remove grouped packages (PySide-Addons, etc) + requires += [ + require + for require in requirements.requires + if require not in requires + # Check that the rez requirement isn't in the group name since it would be + # an invalid requirement (because we merge them). + and rez.version.Requirement(require).name not in rezNames[1:] + ] + variant_requires += [ + require + for require in requirements.variant_requires + if require not in variant_requires + # Check that the rez requirement isn't in the group name since it would be + # an invalid requirement (because we merge them). + and rez.version.Requirement(require).name not in rezNames[1:] + ] + if isPure: + isPure = metadata["is_pure_python"] if prefix: packagesPath = prefix @@ -63,21 +92,32 @@ def make_root(variant: rez.packages.Variant, path: str) -> None: _LOG.info( rf"Installing {variant.qualified_package_name} \[{formattedRequirements}]" ) - if not dist.files: - raise RuntimeError( - f"{dist.name} package has no files registered! Something is wrong maybe?" - ) - - wheelsDirAbsolute = pathlib.Path(installedWheelsDir).resolve() - for src in dist.files: - srcAbsolute = typing.cast(pathlib.Path, src.locate()).resolve() - dest = os.path.join(path, srcAbsolute.relative_to(wheelsDirAbsolute)) - if not os.path.exists(os.path.dirname(dest)): - os.makedirs(os.path.dirname(dest)) - - _LOG.debug(f"Copying {str(srcAbsolute)!r} to {str(dest)!r}") - shutil.copyfile(srcAbsolute, dest) - shutil.copystat(srcAbsolute, dest) + for dist in packageGroup.dists: + if not dist.files: + raise RuntimeError( + f"{dist.name} package has no files registered! Something is wrong maybe?" + ) + + wheelsDirAbsolute = pathlib.Path(installedWheelsDir).resolve() + for src in dist.files: + srcAbsolute: pathlib.Path = typing.cast( + pathlib.Path, src.locate() + ).resolve() + dest = os.path.join( + path, + os.path.sep.join( + srcAbsolute.relative_to(wheelsDirAbsolute).parts[1:] + ), + ) + # print(dest) + if not os.path.exists(os.path.dirname(dest)): + os.makedirs(os.path.dirname(dest)) + + _LOG.debug(f"Copying {str(srcAbsolute)!r} to {str(dest)!r}") + shutil.copyfile(srcAbsolute, dest) + shutil.copystat(srcAbsolute, dest) + + rez_pip.plugins.getHook().cleanup(dist=dist, path=path) with rez.package_maker.make_package( name, packagesPath, make_root=make_root, skip_existing=True, warn_on_skip=False @@ -113,8 +153,8 @@ def make_root(variant: rez.packages.Variant, path: str) -> None: pkg.pip = { "name": dist.name, "version": dist.version, - "is_pure_python": metadata["is_pure_python"], - "wheel_url": wheelURL, + "is_pure_python": isPure, + "wheel_urls": packageGroup.downloadUrls, "rez_pip_version": importlib_metadata.version("rez-pip"), } @@ -126,6 +166,8 @@ def make_root(variant: rez.packages.Variant, path: str) -> None: pkg.pip["metadata"] = remainingMetadata + rez_pip.plugins.getHook().metadata(package=pkg) + _LOG.info( f"[bold]Created {len(pkg.installed_variants)} variants and skipped {len(pkg.skipped_variants)}" ) diff --git a/src/rez_pip/utils.py b/src/rez_pip/utils.py index 641dd01..e4aeb74 100644 --- a/src/rez_pip/utils.py +++ b/src/rez_pip/utils.py @@ -1,19 +1,18 @@ -import sys import typing import logging import dataclasses -if sys.version_info >= (3, 10): - import importlib.metadata as importlib_metadata -else: - import importlib_metadata - import rez.system import rez.version import packaging.version import packaging.specifiers import packaging.requirements +import rez_pip.install + +if typing.TYPE_CHECKING: + from rez_pip.compat import importlib_metadata + _LOG = logging.getLogger(__name__) @@ -464,9 +463,8 @@ def convertMarker(marker: str) -> typing.List[str]: def getRezRequirements( - installedDist: importlib_metadata.Distribution, + installedDist: "importlib_metadata.Distribution", pythonVersion: rez.version.Version, - isPure: bool, nameCasings: typing.Optional[typing.List[str]] = None, ) -> RequirementsDict: """Get requirements of the given dist, in rez-compatible format. @@ -517,6 +515,7 @@ def getRezRequirements( # python build frontends during install has_entry_points_scripts = bool(installedDist.entry_points) + isPure = rez_pip.install.isWheelPure(installedDist) # assume package is platform- and arch- specific if it isn't pure python if not isPure or has_entry_points_scripts: sys_requires.update(["platform", "arch"]) diff --git a/tests/plugins/test_plugins.py b/tests/plugins/test_plugins.py new file mode 100644 index 0000000..8649431 --- /dev/null +++ b/tests/plugins/test_plugins.py @@ -0,0 +1,26 @@ +import typing + +import pluggy + +import rez_pip.plugins + + +def test_getManager(): + assert isinstance(rez_pip.plugins.getManager(), pluggy.PluginManager) + + +def test_getHook(): + assert isinstance(rez_pip.plugins.getHook(), pluggy.HookRelay) + + +def test_getHookImplementations(): + implementations = rez_pip.plugins._getHookImplementations() + assert implementations == { + "rez_pip.PySide6": [ + "cleanup", + "groupPackages", + "postPipResolve", + "prePipResolve", + ], + "rez_pip.shiboken6": ["cleanup"], + } diff --git a/tests/test_cli.py b/tests/test_cli.py index ada7b07..c01ff69 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,11 +6,6 @@ import subprocess import unittest.mock -if sys.version_info >= (3, 10): - import importlib.metadata as importlib_metadata -else: - import importlib_metadata - import pytest import rich.console import packaging.version @@ -19,6 +14,7 @@ import rez_pip.pip import rez_pip.rez import rez_pip.exceptions +from rez_pip.compat import importlib_metadata def test_parseArgs_empty(): @@ -26,6 +22,7 @@ def test_parseArgs_empty(): assert vars(args) == { "constraint": None, "keep_tmp_dirs": False, + "list_plugins": False, "log_level": "info", "packages": [], "pip": rez_pip.pip.getBundledPip(), @@ -45,6 +42,7 @@ def test_parseArgs_packages(packages): assert vars(args) == { "constraint": None, "keep_tmp_dirs": False, + "list_plugins": False, "log_level": "info", "packages": packages, "pip": rez_pip.pip.getBundledPip(), @@ -64,6 +62,7 @@ def test_parseArgs_no_package_with_requirements(files): assert vars(args) == { "constraint": None, "keep_tmp_dirs": False, + "list_plugins": False, "log_level": "info", "packages": [], "pip": rez_pip.pip.getBundledPip(), @@ -82,6 +81,7 @@ def test_parseArgs_constraints(): assert vars(args) == { "constraint": ["asd", "adasdasd"], "keep_tmp_dirs": False, + "list_plugins": False, "log_level": "info", "packages": [], "pip": rez_pip.pip.getBundledPip(), @@ -102,6 +102,7 @@ def test_parseArgs_pipArgs(): assert vars(args) == { "constraint": None, "keep_tmp_dirs": False, + "list_plugins": False, "log_level": "info", "packages": [], "pip": rez_pip.pip.getBundledPip(), diff --git a/tests/test_download.py b/tests/test_download.py index cf8530e..765c696 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -9,16 +9,12 @@ else: from unittest import mock -if sys.version_info >= (3, 10): - import importlib.metadata as importlib_metadata -else: - import importlib_metadata - import pytest import aiohttp import rez_pip.pip import rez_pip.download +from rez_pip.compat import importlib_metadata @pytest.fixture(scope="module", autouse=True) @@ -27,32 +23,54 @@ def rezPipVersion(): yield +class Package: + def __init__(self, name: str, content: str, local: bool): + self.name = name + self.content = content + self.local = local + + +class Group: + def __init__(self, packages: typing.List[Package]): + self.packages = packages + + def getPackage(self, name: str) -> Package: + for package in self.packages: + if package.name == name: + return package + raise KeyError(name) + + @pytest.mark.parametrize( - "packages", + "groups", [ - {"package-a": "package-a data"}, - {"package-a": "package-a data", "package-b": "package-b data"}, + [Group([Package("package-a", "package-a data", False)])], + [ + Group([Package("package-a", "package-a data", False)]), + Group([Package("package-b", "package-b data", False)]), + ], ], - ids=["single-package", "multiple-packages"], + ids=["one-group-with-one-package", "multiple-groups-with-one-package"], ) -def test_download(packages: typing.Dict[str, str], tmp_path: pathlib.Path): +def test_download(groups: typing.List[Group], tmp_path: pathlib.Path): sideEffects = tuple() - for content in packages.values(): - mockedContent = mock.MagicMock() - mockedContent.return_value.__aiter__.return_value = [ - [ - content.encode("utf-8"), - None, + for group in groups: + for package in group.packages: + mockedContent = mock.MagicMock() + mockedContent.return_value.__aiter__.return_value = [ + [ + package.content.encode("utf-8"), + None, + ] ] - ] - sideEffects += ( - mock.Mock( - headers={"content-length": 100}, - status=200, - content=mock.Mock(iter_chunks=mockedContent), - ), - ) + sideEffects += ( + mock.Mock( + headers={"content-length": 100}, + status=200, + content=mock.Mock(iter_chunks=mockedContent), + ), + ) mockedGet = mock.AsyncMock() mockedGet.__aenter__.side_effect = sideEffects @@ -60,43 +78,83 @@ def test_download(packages: typing.Dict[str, str], tmp_path: pathlib.Path): with mock.patch.object(aiohttp.ClientSession, "get") as mocked: mocked.return_value = mockedGet - wheels = rez_pip.download.downloadPackages( - [ - rez_pip.pip.PackageInfo( - metadata=rez_pip.pip.Metadata(name=package, version="1.0.0"), - download_info=rez_pip.pip.DownloadInfo( - url=f"https://example.com/{package}.whl", - archive_info=rez_pip.pip.ArchiveInfo("hash", {}), - ), - is_direct=True, - requested=True, + _groups = [] + for group in groups: + infos = [] + for package in group.packages: + infos.append( + rez_pip.pip.PackageInfo( + metadata=rez_pip.pip.Metadata( + name=package.name, version="1.0.0" + ), + download_info=rez_pip.pip.DownloadInfo( + url=f"https://example.com/{package.name}.whl", + archive_info=rez_pip.pip.ArchiveInfo("hash", {}), + ), + is_direct=True, + requested=True, + ) ) - for package in packages - ], - os.fspath(tmp_path), - ) + _groups.append(rez_pip.pip.PackageGroup(infos)) + + wheels = rez_pip.download.downloadPackages(_groups, os.fspath(tmp_path)) assert sorted(wheels) == sorted( - [os.fspath(tmp_path / f"{package}.whl") for package in packages] + [ + os.fspath(tmp_path / f"{package.name}.whl") + for group in groups + for package in group.packages + ] ) - for wheel in wheels: - with open(wheel, "r") as fd: - content = fd.read() - assert packages[os.path.basename(wheel).split(".")[0]] == content + wheelsMapping = {os.path.basename(wheel).split(".")[0]: wheel for wheel in wheels} + + for group in groups: + for package in group.packages: + with open(wheelsMapping[package.name], "r") as fd: + content = fd.read() + assert content == package.content assert mocked.call_args_list == [ mock.call( - f"https://example.com/{package}.whl", + f"https://example.com/{package.name}.whl", headers={ "Content-Type": "application/octet-stream", "User-Agent": "rez-pip/1.2.3.4.5", }, ) - for package in packages + for group in groups + for package in group.packages ] +def test_download_skip_local(tmp_path: pathlib.Path): + groups = [ + rez_pip.pip.PackageGroup( + [ + rez_pip.pip.PackageInfo( + metadata=rez_pip.pip.Metadata(name="package-a", version="1.0.0"), + download_info=rez_pip.pip.DownloadInfo( + url="file:///example.com/package-a", + archive_info=rez_pip.pip.ArchiveInfo("hash-a", {}), + ), + is_direct=True, + requested=True, + ) + ] + ) + ] + + mockedGet = mock.AsyncMock() + + with mock.patch.object(aiohttp.ClientSession, "get") as mocked: + mocked.return_value = mockedGet + wheels = rez_pip.download.downloadPackages(groups, os.fspath(tmp_path)) + + assert not mocked.called + assert wheels == [] + + def test_download_multiple_packages_with_failure(tmp_path: pathlib.Path): mockedContent = mock.MagicMock() mockedContent.return_value.__aiter__.return_value = [ @@ -127,27 +185,35 @@ def test_download_multiple_packages_with_failure(tmp_path: pathlib.Path): with pytest.raises(RuntimeError): rez_pip.download.downloadPackages( [ - rez_pip.pip.PackageInfo( - metadata=rez_pip.pip.Metadata( - name="package-a", version="1.0.0" - ), - download_info=rez_pip.pip.DownloadInfo( - url="https://example.com/package-a", - archive_info=rez_pip.pip.ArchiveInfo("hash-a", {}), - ), - is_direct=True, - requested=True, + rez_pip.pip.PackageGroup( + [ + rez_pip.pip.PackageInfo( + metadata=rez_pip.pip.Metadata( + name="package-a", version="1.0.0" + ), + download_info=rez_pip.pip.DownloadInfo( + url="https://example.com/package-a", + archive_info=rez_pip.pip.ArchiveInfo("hash-a", {}), + ), + is_direct=True, + requested=True, + ) + ] ), - rez_pip.pip.PackageInfo( - metadata=rez_pip.pip.Metadata( - name="package-b", version="1.0.0" - ), - download_info=rez_pip.pip.DownloadInfo( - url="https://example.com/package-b", - archive_info=rez_pip.pip.ArchiveInfo("hash-b", {}), - ), - is_direct=True, - requested=True, + rez_pip.pip.PackageGroup( + [ + rez_pip.pip.PackageInfo( + metadata=rez_pip.pip.Metadata( + name="package-b", version="1.0.0" + ), + download_info=rez_pip.pip.DownloadInfo( + url="https://example.com/package-b", + archive_info=rez_pip.pip.ArchiveInfo("hash-b", {}), + ), + is_direct=True, + requested=True, + ) + ] ), ], os.fspath(tmp_path), @@ -178,7 +244,7 @@ def test_download_multiple_packages_with_failure(tmp_path: pathlib.Path): def test_download_reuse_if_same_hash(tmp_path: pathlib.Path): """Test that wheels are re-used if the sha256 matches""" sideEffects = tuple() - packages = [] + groups = [] for package in ["package-a", "package-b"]: content = f"{package} data".encode("utf-8") @@ -186,17 +252,21 @@ def test_download_reuse_if_same_hash(tmp_path: pathlib.Path): hash = hashlib.new("sha256") hash.update(content) - packages.append( - rez_pip.pip.PackageInfo( - metadata=rez_pip.pip.Metadata(name=package, version="1.0.0"), - download_info=rez_pip.pip.DownloadInfo( - url=f"https://example.com/{package}.whl", - archive_info=rez_pip.pip.ArchiveInfo( - "hash-a", {"sha256": hash.hexdigest()} - ), - ), - is_direct=True, - requested=True, + groups.append( + rez_pip.pip.PackageGroup( + [ + rez_pip.pip.PackageInfo( + metadata=rez_pip.pip.Metadata(name=package, version="1.0.0"), + download_info=rez_pip.pip.DownloadInfo( + url=f"https://example.com/{package}.whl", + archive_info=rez_pip.pip.ArchiveInfo( + "hash-a", {"sha256": hash.hexdigest()} + ), + ), + is_direct=True, + requested=True, + ) + ] ) ) @@ -222,7 +292,7 @@ def test_download_reuse_if_same_hash(tmp_path: pathlib.Path): with mock.patch.object(aiohttp.ClientSession, "get") as mocked: mocked.return_value = mockedGet1 - rez_pip.download.downloadPackages(packages, str(tmp_path)) + rez_pip.download.downloadPackages(groups, str(tmp_path)) assert mocked.call_args_list == [ mock.call( @@ -241,7 +311,7 @@ def test_download_reuse_if_same_hash(tmp_path: pathlib.Path): ), ] - packages = [] + groups = [] # package-b will be re-used for package in ["package-c", "package-b"]: content = f"{package} data".encode("utf-8") @@ -249,17 +319,21 @@ def test_download_reuse_if_same_hash(tmp_path: pathlib.Path): hash = hashlib.new("sha256") hash.update(content) - packages.append( - rez_pip.pip.PackageInfo( - metadata=rez_pip.pip.Metadata(name=package, version="1.0.0"), - download_info=rez_pip.pip.DownloadInfo( - url=f"https://example.com/{package}.whl", - archive_info=rez_pip.pip.ArchiveInfo( - "hash-a", {"sha256": hash.hexdigest()} - ), - ), - is_direct=True, - requested=True, + groups.append( + rez_pip.pip.PackageGroup( + [ + rez_pip.pip.PackageInfo( + metadata=rez_pip.pip.Metadata(name=package, version="1.0.0"), + download_info=rez_pip.pip.DownloadInfo( + url=f"https://example.com/{package}.whl", + archive_info=rez_pip.pip.ArchiveInfo( + "hash-a", {"sha256": hash.hexdigest()} + ), + ), + is_direct=True, + requested=True, + ) + ] ) ) @@ -285,7 +359,7 @@ def test_download_reuse_if_same_hash(tmp_path: pathlib.Path): with mock.patch.object(aiohttp.ClientSession, "get") as mocked: mocked.return_value = mockedGet2 - wheels = rez_pip.download.downloadPackages(packages, str(tmp_path)) + wheels = rez_pip.download.downloadPackages(groups, str(tmp_path)) assert mocked.call_args_list == [ mock.call( @@ -305,7 +379,7 @@ def test_download_reuse_if_same_hash(tmp_path: pathlib.Path): def test_download_redownload_if_hash_changes(tmp_path: pathlib.Path): """Test that wheels are re-used if the sha256 matches""" sideEffects = tuple() - packages = [] + groups = [] for package in ["package-a", "package-b"]: content = f"{package} data".encode("utf-8") @@ -313,17 +387,21 @@ def test_download_redownload_if_hash_changes(tmp_path: pathlib.Path): hash = hashlib.new("sha256") hash.update(content) - packages.append( - rez_pip.pip.PackageInfo( - metadata=rez_pip.pip.Metadata(name=package, version="1.0.0"), - download_info=rez_pip.pip.DownloadInfo( - url=f"https://example.com/{package}.whl", - archive_info=rez_pip.pip.ArchiveInfo( - "hash-a", {"sha256": hash.hexdigest()} - ), - ), - is_direct=True, - requested=True, + groups.append( + rez_pip.pip.PackageGroup( + [ + rez_pip.pip.PackageInfo( + metadata=rez_pip.pip.Metadata(name=package, version="1.0.0"), + download_info=rez_pip.pip.DownloadInfo( + url=f"https://example.com/{package}.whl", + archive_info=rez_pip.pip.ArchiveInfo( + "hash-a", {"sha256": hash.hexdigest()} + ), + ), + is_direct=True, + requested=True, + ) + ] ) ) @@ -349,7 +427,7 @@ def test_download_redownload_if_hash_changes(tmp_path: pathlib.Path): with mock.patch.object(aiohttp.ClientSession, "get") as mocked: mocked.return_value = mockedGet1 - rez_pip.download.downloadPackages(packages, str(tmp_path)) + rez_pip.download.downloadPackages(groups, str(tmp_path)) assert mocked.call_args_list == [ mock.call( @@ -368,26 +446,30 @@ def test_download_redownload_if_hash_changes(tmp_path: pathlib.Path): ), ] - packages = [] + groups = [] # package-b will be re-used for package in ["package-a", "package-b"]: content = f"{package} data".encode("utf-8") - packages.append( - rez_pip.pip.PackageInfo( - metadata=rez_pip.pip.Metadata(name=package, version="1.0.0"), - download_info=rez_pip.pip.DownloadInfo( - url=f"https://example.com/{package}.whl", - archive_info=rez_pip.pip.ArchiveInfo( - # - # Bad sha256. This will trigger a new download - # - "hash-a", - {"sha256": "asd"}, - ), - ), - is_direct=True, - requested=True, + groups.append( + rez_pip.pip.PackageGroup( + [ + rez_pip.pip.PackageInfo( + metadata=rez_pip.pip.Metadata(name=package, version="1.0.0"), + download_info=rez_pip.pip.DownloadInfo( + url=f"https://example.com/{package}.whl", + archive_info=rez_pip.pip.ArchiveInfo( + # + # Bad sha256. This will trigger a new download + # + "hash-a", + {"sha256": "asd"}, + ), + ), + is_direct=True, + requested=True, + ) + ] ) ) @@ -413,7 +495,7 @@ def test_download_redownload_if_hash_changes(tmp_path: pathlib.Path): with mock.patch.object(aiohttp.ClientSession, "get") as mocked: mocked.return_value = mockedGet2 - wheels = rez_pip.download.downloadPackages(packages, str(tmp_path)) + wheels = rez_pip.download.downloadPackages(groups, str(tmp_path)) assert mocked.call_args_list == [ mock.call( diff --git a/tests/test_install.py b/tests/test_install.py index 45803eb..7196f3c 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1,6 +1,4 @@ import os -import sys -import glob import pathlib import platform import subprocess @@ -10,6 +8,7 @@ import rez_pip.pip import rez_pip.install +from rez_pip.compat import importlib_metadata from . import utils diff --git a/tests/test_rez.py b/tests/test_rez.py index d24bc97..ba48d14 100644 --- a/tests/test_rez.py +++ b/tests/test_rez.py @@ -1,5 +1,4 @@ import os -import sys import stat import typing import pathlib @@ -12,13 +11,10 @@ import rez.packages import rez.package_repository -if sys.version_info >= (3, 10): - import importlib.metadata as importlib_metadata -else: - import importlib_metadata - +import rez_pip.pip import rez_pip.rez import rez_pip.utils +from rez_pip.compat import importlib_metadata def test_createPackage(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path): @@ -62,16 +58,28 @@ def make_file(path: str) -> importlib_metadata.PackagePath: metadata={"is_pure_python": False}, ) + packageGroup = rez_pip.pip.PackageGroup( + [ + rez_pip.pip.PackageInfo( + metadata=rez_pip.pip.Metadata(name="package-a", version="1.0.0.post0"), + download_info=rez_pip.pip.DownloadInfo( + url=f"http://localhost/asd", + archive_info=rez_pip.pip.ArchiveInfo("hash", {}), + ), + is_direct=True, + requested=True, + ) + ] + ) + packageGroup.dists = [dist] + with unittest.mock.patch.object( rez_pip.utils, "getRezRequirements", return_value=expectedRequirements ): rez_pip.rez.createPackage( - dist, - False, + packageGroup, rez.version.Version("3.7.0"), - [], source, - "http://localhost/asd", prefix=repo, ) @@ -84,7 +92,7 @@ def make_file(path: str) -> importlib_metadata.PackagePath: "name": dist.name, "version": dist.version, "is_pure_python": False, - "wheel_url": "http://localhost/asd", + "wheel_urls": ["http://localhost/asd"], "rez_pip_version": importlib_metadata.version("rez-pip"), "metadata": {}, }