From 0d113a3e70aff2302676849907b3adea765290db Mon Sep 17 00:00:00 2001 From: Jean-Christophe Morin Date: Wed, 1 Jan 2025 16:11:12 -0500 Subject: [PATCH] More tests and docs Signed-off-by: Jean-Christophe Morin --- docs/source/plugins.rst | 5 + src/rez_pip/cli.py | 6 + src/rez_pip/plugins/PySide6.py | 39 +++--- src/rez_pip/plugins/shiboken6.py | 3 + tests/plugins/__init__.py | 0 tests/plugins/test_plugins.py | 2 - tests/plugins/test_pyside6.py | 220 +++++++++++++++++++++++++++++++ tests/plugins/test_shiboken6.py | 52 ++++++++ tests/plugins/utils.py | 18 +++ tests/test_cli.py | 16 +++ tests/test_pip.py | 62 +++++++++ 11 files changed, 402 insertions(+), 21 deletions(-) create mode 100644 tests/plugins/__init__.py create mode 100644 tests/plugins/test_pyside6.py create mode 100644 tests/plugins/test_shiboken6.py create mode 100644 tests/plugins/utils.py diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index 024438a..7ad6f10 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -7,6 +7,11 @@ modifying packages (both metadata and files), etc. This page documents the hooks available to plugins and how to implement plugins. +List installed plugins +====================== + +To list all installed plugins, use the :option:`--list-plugins` command line argument. + Register a plugin ================= diff --git a/src/rez_pip/cli.py b/src/rez_pip/cli.py index 6c393d7..a0fc2cb 100644 --- a/src/rez_pip/cli.py +++ b/src/rez_pip/cli.py @@ -212,6 +212,12 @@ def _run(args: argparse.Namespace, pipArgs: typing.List[str], pipWorkArea: str) itertools.chain(*rez_pip.plugins.getHook().groupPackages(packages=packages)) # type: ignore[arg-type] ) + # TODO: Verify that no packages are in two or more groups? It should theorically + # not be possible since plugins are called one after the other? But it could happen + # if a plugin forgets to pop items from the package list... The problem is that we + # can't know which plugin did what, so we could only say "something went wrong" + # and can't point to which plugin is at fault. + # Remove empty groups _packageGroups = [group for group in _packageGroups if group] diff --git a/src/rez_pip/plugins/PySide6.py b/src/rez_pip/plugins/PySide6.py index c28bff3..d56cb04 100644 --- a/src/rez_pip/plugins/PySide6.py +++ b/src/rez_pip/plugins/PySide6.py @@ -1,15 +1,4 @@ """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. """ from __future__ import annotations @@ -17,6 +6,7 @@ import os import shutil import typing +import logging import packaging.utils import packaging.version @@ -30,15 +20,21 @@ 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. +_LOG = logging.getLogger(__name__) @rez_pip.plugins.hookimpl def prePipResolve( - packages: typing.List[str], + packages: typing.Tuple[str], ) -> None: + """ + PySide6 was initially 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. + + So we need to intercept what the user installs and install all 3 packages together. + Not doing that would result in a broken install (eventually). + """ pyside6Seen = False variantsSeens = [] @@ -60,7 +56,7 @@ def prePipResolve( @rez_pip.plugins.hookimpl -def postPipResolve(packages: typing.List[rez_pip.pip.PackageInfo]) -> None: +def postPipResolve(packages: typing.Tuple[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. @@ -91,7 +87,7 @@ def groupPackages( packages: typing.List[rez_pip.pip.PackageInfo], ) -> typing.List[rez_pip.pip.PackageGroup[rez_pip.pip.PackageInfo]]: data = [] - for index, package in enumerate(packages[:]): + for package in packages[:]: if packaging.utils.canonicalize_name(package.name) in [ "pyside6", "pyside6-addons", @@ -116,5 +112,10 @@ def cleanup(dist: "importlib_metadata.Distribution", path: str) -> None: # 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")) + for innerpath in [ + os.path.join(path, "python", "shiboken6"), + os.path.join(path, "python", "shiboken6_generator"), + ]: + if os.path.exists(innerpath): + _LOG.debug("Removing %r", innerpath) + shutil.rmtree(innerpath) diff --git a/src/rez_pip/plugins/shiboken6.py b/src/rez_pip/plugins/shiboken6.py index 9742807..64af160 100644 --- a/src/rez_pip/plugins/shiboken6.py +++ b/src/rez_pip/plugins/shiboken6.py @@ -1,3 +1,6 @@ +"""shiboken6 plugin. +""" + from __future__ import annotations import os diff --git a/tests/plugins/__init__.py b/tests/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/plugins/test_plugins.py b/tests/plugins/test_plugins.py index 8649431..27639c6 100644 --- a/tests/plugins/test_plugins.py +++ b/tests/plugins/test_plugins.py @@ -1,5 +1,3 @@ -import typing - import pluggy import rez_pip.plugins diff --git a/tests/plugins/test_pyside6.py b/tests/plugins/test_pyside6.py new file mode 100644 index 0000000..799eb9e --- /dev/null +++ b/tests/plugins/test_pyside6.py @@ -0,0 +1,220 @@ +import sys +import typing +import pathlib + +import pytest + +import rez_pip.pip +import rez_pip.plugins +import rez_pip.exceptions + +from . import utils + + +if sys.version_info[:2] < (3, 8): + import mock +else: + from unittest import mock + + +@pytest.fixture(scope="module") +def setupPluginManager(): + yield utils.initializePluginManager("pyside6") + + +@pytest.mark.parametrize( + "packages", + [ + ("asd",), + ("pyside6",), + ("PysiDe6",), + ("pyside6", "pyside6-addons"), + ("pyside6", "pyside6-essentials"), + ("pyside6", "pyside6-essentials", "pyside6-addons"), + ("pyside6", "pyside6-addons", "asdasdad"), + ], +) +def test_prePipResolve_noop(packages: typing.Tuple[str, ...]): + rez_pip.plugins.getHook().prePipResolve(packages=packages) + + +@pytest.mark.parametrize("packages", [("pyside6-addons",), ("PysiDe6_essentials",)]) +def test_prePipResolve_raises(packages: typing.Tuple[str, ...]): + with pytest.raises(rez_pip.exceptions.RezPipError): + rez_pip.plugins.getHook().prePipResolve(packages=packages) + + +def fakePackage(name: str, **kwargs) -> mock.Mock: + value = mock.MagicMock() + value.configure_mock(name=name, **kwargs) + return value + + +@pytest.mark.parametrize( + "packages", + [ + (fakePackage("asd"),), + (fakePackage("pyside6"),), + (fakePackage("PysiDe6"),), + (fakePackage("pyside6"), fakePackage("pyside6-addons")), + (fakePackage("pyside6"), fakePackage("pyside6-essentials")), + ( + fakePackage("pyside6"), + fakePackage("pyside6-essentials"), + fakePackage("pyside6-addons"), + ), + ( + fakePackage("pyside6"), + fakePackage("pyside6-addons"), + fakePackage("asdasdad"), + ), + ], +) +def test_postPipResolve_noop(packages: typing.Tuple[str, ...]): + rez_pip.plugins.getHook().postPipResolve(packages=packages) + + +@pytest.mark.parametrize( + "packages", + [ + (fakePackage("pyside6-addons"),), + (fakePackage("PysiDe6_essentials"),), + (fakePackage("PysiDe6_essentials"), fakePackage("asd")), + ], +) +def test_postPipResolve_raises(packages: typing.Tuple[str, ...]): + with pytest.raises(rez_pip.exceptions.RezPipError): + rez_pip.plugins.getHook().postPipResolve(packages=packages) + + +@pytest.mark.parametrize( + "packages", + [[fakePackage("asd")]], +) +def test_groupPackages_noop(packages: typing.List[str]): + assert rez_pip.plugins.getHook().groupPackages(packages=packages) == [ + [rez_pip.pip.PackageGroup(tuple())] + ] + + +class FakePackageInfo: + def __init__(self, name: str, version: str): + self.name = name + self.version = version + + def __eq__(self, value): + return self.name == value.name and self.version == value.version + + +@pytest.mark.parametrize( + "packages,expectedGroups", + [ + [ + [fakePackage("pyside6", version="1")], + [[rez_pip.pip.PackageGroup((FakePackageInfo("pyside6", "1"),))]], + ], + [ + [ + fakePackage("pyside6", version="1"), + fakePackage("pyside6_addons", version="1"), + ], + [ + [ + rez_pip.pip.PackageGroup( + ( + FakePackageInfo("pyside6", "1"), + FakePackageInfo("pyside6_addons", "1"), + ) + ) + ] + ], + ], + [ + [ + fakePackage("pyside6", version="1"), + fakePackage("pyside6_essentials", version="1"), + ], + [ + [ + rez_pip.pip.PackageGroup( + ( + FakePackageInfo("pyside6", "1"), + FakePackageInfo("pyside6_essentials", "1"), + ) + ) + ] + ], + ], + [ + [ + fakePackage("pyside6", version="1"), + fakePackage("pyside6_essentials", version="1"), + fakePackage("pyside6-Addons", version="1"), + ], + [ + [ + rez_pip.pip.PackageGroup( + ( + FakePackageInfo("pyside6", "1"), + FakePackageInfo("pyside6_essentials", "1"), + FakePackageInfo("pyside6-Addons", "1"), + ) + ) + ] + ], + ], + [ + [ + fakePackage("pyside6", version="1"), + fakePackage("asdasd", version=2), + fakePackage("pyside6_essentials", version="1"), + fakePackage("pyside6-Addons", version="1"), + ], + [ + [ + rez_pip.pip.PackageGroup( + ( + FakePackageInfo("pyside6", "1"), + FakePackageInfo("pyside6_essentials", "1"), + FakePackageInfo("pyside6-Addons", "1"), + ) + ) + ] + ], + ], + ], +) +def test_groupPackages( + packages: typing.List[str], expectedGroups: list[rez_pip.pip.PackageGroup] +): + data = rez_pip.plugins.getHook().groupPackages(packages=packages) + assert data == expectedGroups + + +@pytest.mark.parametrize("package", [fakePackage("asd")]) +def test_cleanup_noop(package, tmp_path: pathlib.Path): + (tmp_path / "python" / "shiboken6").mkdir(parents=True) + (tmp_path / "python" / "shiboken6_generator").mkdir(parents=True) + + rez_pip.plugins.getHook().cleanup(dist=package, path=tmp_path) + + assert (tmp_path / "python" / "shiboken6").exists() + assert (tmp_path / "python" / "shiboken6_generator").exists() + + +@pytest.mark.parametrize( + "package", + [ + fakePackage("pyside6"), + fakePackage("pyside6_essentials"), + fakePackage("PySiDe6-AddoNs"), + ], +) +def test_cleanup(package, tmp_path: pathlib.Path): + (tmp_path / "python" / "shiboken6").mkdir(parents=True) + (tmp_path / "python" / "shiboken6_generator").mkdir(parents=True) + + rez_pip.plugins.getHook().cleanup(dist=package, path=tmp_path) + + assert not (tmp_path / "python" / "shiboken6").exists() + assert not (tmp_path / "python" / "shiboken6_generator").exists() diff --git a/tests/plugins/test_shiboken6.py b/tests/plugins/test_shiboken6.py new file mode 100644 index 0000000..4a9c48a --- /dev/null +++ b/tests/plugins/test_shiboken6.py @@ -0,0 +1,52 @@ +import sys +import pathlib + +import pytest + +import rez_pip.pip +import rez_pip.plugins +import rez_pip.exceptions + +from . import utils + + +if sys.version_info[:2] < (3, 8): + import mock +else: + from unittest import mock + + +@pytest.fixture(scope="module") +def setupPluginManager(): + yield utils.initializePluginManager("shiboken6") + + +def fakePackage(name: str, **kwargs) -> mock.Mock: + value = mock.MagicMock() + value.configure_mock(name=name, **kwargs) + return value + + +@pytest.mark.parametrize("package", [fakePackage("asd")]) +def test_cleanup_noop(package, tmp_path: pathlib.Path): + (tmp_path / "python" / "PySide6").mkdir(parents=True) + + rez_pip.plugins.getHook().cleanup(dist=package, path=tmp_path) + + assert (tmp_path / "python" / "PySide6").exists() + + +@pytest.mark.parametrize( + "package", + [ + fakePackage("shiboken6"), + fakePackage("shiboken6_essentials"), + fakePackage("ShIbOkEn6-AddoNs"), + ], +) +def test_cleanup(package, tmp_path: pathlib.Path): + (tmp_path / "python" / "PySide6").mkdir(parents=True) + + rez_pip.plugins.getHook().cleanup(dist=package, path=tmp_path) + + assert not (tmp_path / "python" / "shiboken6").exists() diff --git a/tests/plugins/utils.py b/tests/plugins/utils.py new file mode 100644 index 0000000..bebfc15 --- /dev/null +++ b/tests/plugins/utils.py @@ -0,0 +1,18 @@ +import rez_pip.plugins + + +def initializePluginManager(name: str): + """Initialize a plugin manager and clear the cache before exiting the function + + :param name: Name of the plugin to load. + """ + manager = rez_pip.plugins.getManager() + for name, plugin in manager.list_name_plugin(): + if not name.startswith(f"rez-pip.plugins.{name}"): + manager.unregister(plugin) + + try: + yield manager + finally: + del manager + rez_pip.plugins.getManager.cache_clear() diff --git a/tests/test_cli.py b/tests/test_cli.py index c01ff69..3c3e2d1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -367,3 +367,19 @@ def test_debug(capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch): """ ) + + +def test_list_plugins(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture): + monkeypatch.setattr(sys, "argv", ["rez-pip", "--list-plugins"]) + + assert rez_pip.cli.run() == 0 + + output = capsys.readouterr().out + output = "\n".join(map(str.strip, output.split("\n"))) + assert ( + output + == """Name Hooks +rez_pip.PySide6 cleanup, groupPackages, postPipResolve, prePipResolve +rez_pip.shiboken6 cleanup +""" + ) diff --git a/tests/test_pip.py b/tests/test_pip.py index e525091..2070a6a 100644 --- a/tests/test_pip.py +++ b/tests/test_pip.py @@ -15,6 +15,68 @@ from . import utils +@pytest.mark.parametrize( + "url,shouldDownload", + [ + ( + "https://pypi.org/packages/package_a/package_a-1.0.0-py2.py3-none-any.whl", + True, + ), + ("file:///tmp/asd.whl", False), + ], +) +def test_PackageInfo(url: str, shouldDownload: bool): + info = rez_pip.pip.PackageInfo( + rez_pip.pip.DownloadInfo( + url, + rez_pip.pip.ArchiveInfo( + "sha256=", + {"sha256": ""}, + ), + ), + False, + True, + rez_pip.pip.Metadata( + "1.0.0", + "package_a", + ), + ) + + assert info.name == "package_a" + assert info.version == "1.0.0" + assert info.isDownloadRequired() == shouldDownload + + +@pytest.mark.parametrize( + "url", + [ + "https://pypi.org/packages/package_a/package_a-1.0.0-py2.py3-none-any.whl", + "file:///tmp/package_a-1.0.0-py2.py3-none-any.whl", + ], +) +def test_DownloadedArtifact(url: str): + info = rez_pip.pip.DownloadedArtifact( + rez_pip.pip.DownloadInfo( + url, + rez_pip.pip.ArchiveInfo( + "sha256=", + {"sha256": ""}, + ), + ), + False, + True, + rez_pip.pip.Metadata( + "1.0.0", + "package_a", + ), + "/tmp/package_a-1.0.0-py2.py3-none-any.whl", + ) + + assert info.name == "package_a" + assert info.version == "1.0.0" + assert info.path == "/tmp/package_a-1.0.0-py2.py3-none-any.whl" + + def test_getBundledPip(): """Test that the bundled pip exists and can be executed""" assert os.path.exists(rez_pip.pip.getBundledPip())