From 76e87254492eed81086caeaa17a67dec951e212b Mon Sep 17 00:00:00 2001 From: jaimergp Date: Thu, 4 May 2023 11:05:38 +0200 Subject: [PATCH] Add CI, tox config and other test adjustments (#3) * add GHA workflow and tox config * add fixture by @Czaki * remove pyside6 pin * fail-fast: false * prune matrix a bit more * use optional-dependencies * allow dialog via decorator-marker * try something else * prune matrix a bit more * fix link in README * remove py38 * skip on linux py311 pyside2 * skip on all platforms * parametrize fixture and waitUntil * skip module * increase timeout? * update workflows * a bit more? * run all bindings in the same job * wait for a known number of mocked plugins * pre-commit * add pre-commit step * pin to x.x.x * do not use a lambda here * Update napari_plugin_manager/_tests/test_qt_plugin_dialog.py Co-authored-by: Grzegorz Bokota * Apply suggestions from code review Co-authored-by: Grzegorz Bokota --------- Co-authored-by: Grzegorz Bokota --- .github/workflows/test_and_deploy.yml | 107 ++++++++++++++++ README.md | 2 +- napari_plugin_manager/_tests/conftest.py | 18 +++ .../_tests/test_qt_plugin_dialog.py | 119 +++++++++--------- pyproject.toml | 12 ++ tox.ini | 47 +++++++ 6 files changed, 241 insertions(+), 64 deletions(-) create mode 100644 .github/workflows/test_and_deploy.yml create mode 100644 napari_plugin_manager/_tests/conftest.py create mode 100644 tox.ini diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml new file mode 100644 index 0000000..5cb157a --- /dev/null +++ b/.github/workflows/test_and_deploy.yml @@ -0,0 +1,107 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +name: test and deploy + +on: + push: + branches: + - main + tags: + - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 + pull_request: + branches: + - main + workflow_dispatch: + +concurrency: + # Concurrency group that uses the workflow name and PR number if available + # or commit SHA as a fallback. If a new build is triggered under that + # concurrency group while a previous build is running it will be canceled. + # Repeated pushes to a PR will cancel all previous builds, while multiple + # merges to main will not cancel. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + test: + name: ${{ matrix.platform }}, py${{ matrix.python-version }}, napari ${{ matrix.napari }} + runs-on: ${{ matrix.platform }} + strategy: + fail-fast: false + matrix: + platform: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.9", "3.10", "3.11"] + napari: ["latest", "repo"] + exclude: + # TODO: Remove when we have a napari release with the plugin manager changes + - napari: "latest" + # TODO: PyQt / PySide wheels missing + - python-version: "3.11" + platform: "windows-latest" + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - uses: tlambert03/setup-qt-libs@v1 + + # strategy borrowed from vispy for installing opengl libs on windows + - name: Install Windows OpenGL + if: runner.os == 'Windows' + run: | + git clone --depth 1 https://github.com/pyvista/gl-ci-helpers.git + powershell gl-ci-helpers/appveyor/install_opengl.ps1 + if (Test-Path -Path "C:\Windows\system32\opengl32.dll" -PathType Leaf) {Exit 0} else {Exit 1} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools tox tox-gh-actions + + - name: Test with tox + uses: aganders3/headless-gui@v1 + with: + run: python -m tox -vv + env: + PYVISTA_OFF_SCREEN: True # required for opengl on windows + NAPARI: ${{ matrix.napari }} + FORCE_COLOR: 1 + # PySide6 only functional with Python 3.10+ + TOX_SKIP_ENV: ".*py39-PySide6.*" + + - name: pre-commit + uses: pre-commit/action@v3.0.0 + + - name: Coverage + uses: codecov/codecov-action@v3 + + deploy: + # this will run when you have tagged a commit, starting with "v*" + # and requires that you have put your twine API key in your + # github secrets (see readme for details) + needs: [test] + runs-on: ubuntu-latest + if: contains(github.ref, 'tags') + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -U setuptools twine build + - name: Build and publish + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }} + run: | + git tag + python -m build + twine upload dist/* diff --git a/README.md b/README.md index 50d6181..97a2673 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![License](https://img.shields.io/pypi/l/napari-plugin-manager.svg?color=green)](https://github.com/napari/napari-plugin-manager/raw/main/LICENSE) [![PyPI](https://img.shields.io/pypi/v/napari-plugin-manager.svg?color=green)](https://pypi.org/project/napari-plugin-manager) [![Python Version](https://img.shields.io/pypi/pyversions/napari-plugin-manager.svg?color=green)](https://python.org) -[![tests](https://github.com/napari/napari-plugin-manager/workflows/tests/badge.svg)](https://github.com/napari/napari-plugin-manager/actions) +[![tests](https://github.com/napari/napari-plugin-manager/workflows/test_and_deploy/badge.svg)](https://github.com/napari/napari-plugin-manager/actions) [![codecov](https://codecov.io/gh/napari/napari-plugin-manager/branch/main/graph/badge.svg)](https://codecov.io/gh/napari/napari-plugin-manager) A plugin that adds a plugin manager to [napari]. diff --git a/napari_plugin_manager/_tests/conftest.py b/napari_plugin_manager/_tests/conftest.py new file mode 100644 index 0000000..cc500fd --- /dev/null +++ b/napari_plugin_manager/_tests/conftest.py @@ -0,0 +1,18 @@ +import pytest +from qtpy.QtWidgets import QDialog, QInputDialog, QMessageBox + + +@pytest.fixture(autouse=True) +def _block_message_box(monkeypatch, request): + def raise_on_call(*_, **__): + raise RuntimeError("exec_ call") # pragma: no cover + + monkeypatch.setattr(QMessageBox, "exec_", raise_on_call) + monkeypatch.setattr(QMessageBox, "critical", raise_on_call) + monkeypatch.setattr(QMessageBox, "information", raise_on_call) + monkeypatch.setattr(QMessageBox, "question", raise_on_call) + monkeypatch.setattr(QMessageBox, "warning", raise_on_call) + monkeypatch.setattr(QInputDialog, "getText", raise_on_call) + # QDialogs can be allowed via a marker; only raise if not decorated + if "enabledialog" not in request.keywords: + monkeypatch.setattr(QDialog, "exec_", raise_on_call) diff --git a/napari_plugin_manager/_tests/test_qt_plugin_dialog.py b/napari_plugin_manager/_tests/test_qt_plugin_dialog.py index d271562..175b862 100644 --- a/napari_plugin_manager/_tests/test_qt_plugin_dialog.py +++ b/napari_plugin_manager/_tests/test_qt_plugin_dialog.py @@ -1,16 +1,27 @@ import importlib.metadata +import sys from typing import Generator, Optional, Tuple from unittest.mock import patch import napari.plugins import npe2 import pytest +import qtpy from napari.plugins._tests.test_npe2 import mock_pm # noqa from napari.utils.translations import trans from napari_plugin_manager import qt_plugin_dialog from napari_plugin_manager.qt_package_installer import InstallerActions +if qtpy.API_NAME == 'PySide2' and sys.version_info[:2] == (3, 11): + pytest.skip( + "Known PySide2 x Python 3.11 incompatibility: " + "TypeError: 'PySide2.QtCore.Qt.Alignment' object cannot be interpreted as an integer", + allow_module_level=True, + ) + +N_MOCKED_PLUGINS = 2 + def _iter_napari_pypi_plugin_info( conda_forge: bool = True, @@ -35,7 +46,7 @@ def _iter_napari_pypi_plugin_info( "author": "test author", "license": "UNKNOWN", } - for i in range(2): + for i in range(N_MOCKED_PLUGINS): yield npe2.PackageMetadata(name=f"test-name-{i}", **base_data), bool( i ), { @@ -70,8 +81,32 @@ def plugins(qtbot): return PluginsMock() -@pytest.fixture -def plugin_dialog(qtbot, monkeypatch, mock_pm, plugins, old_plugins): # noqa +class WarnPopupMock: + def __init__(self, text): + self._is_visible = False + + def exec_(self): + self._is_visible = True + + def move(self, pos): + return False + + def isVisible(self): + return self._is_visible + + def close(self): + self._is_visible = False + + +@pytest.fixture(params=[True, False], ids=["constructor", "no-constructor"]) +def plugin_dialog( + request, + qtbot, + monkeypatch, + mock_pm, # noqa + plugins, + old_plugins, +): """Fixture that provides a plugin dialog for a normal napari install.""" class PluginManagerMock: @@ -102,16 +137,6 @@ def disable(self, plugin): self.plugins[plugin] = False return - class WarnPopupMock: - def __init__(self, text): - return None - - def exec_(self): - return None - - def move(self, pos): - return False - def mock_metadata(name): meta = { 'version': '0.1.0', @@ -145,15 +170,12 @@ def set_blocked(self, plugin, blocked): "iter_napari_plugin_info", _iter_napari_pypi_plugin_info, ) - monkeypatch.setattr(qt_plugin_dialog, 'WarnPopup', WarnPopupMock) # This is patching `napari.utils.misc.running_as_constructor_app` function # to mock a normal napari install. monkeypatch.setattr( - qt_plugin_dialog, - "running_as_constructor_app", - lambda: False, + qt_plugin_dialog, "running_as_constructor_app", lambda: request.param ) monkeypatch.setattr( @@ -166,42 +188,20 @@ def set_blocked(self, plugin, blocked): widget = qt_plugin_dialog.QtPluginDialog() widget.show() - qtbot.wait(300) - qtbot.add_widget(widget) - yield widget - widget.hide() - widget._add_items_timer.stop() - assert not widget._add_items_timer.isActive() + qtbot.waitUntil(widget.isVisible, timeout=300) + def available_list_populated(): + return widget.available_list.count() == N_MOCKED_PLUGINS -@pytest.fixture -def plugin_dialog_constructor(qtbot, monkeypatch): - """ - Fixture that provides a plugin dialog for a constructor based install. - """ - monkeypatch.setattr( - qt_plugin_dialog, - "iter_napari_plugin_info", - _iter_napari_pypi_plugin_info, - ) - - # This is patching `napari.utils.misc.running_as_constructor_app` function - # to mock a constructor based install. - monkeypatch.setattr( - qt_plugin_dialog, - "running_as_constructor_app", - lambda: True, - ) - widget = qt_plugin_dialog.QtPluginDialog() - widget.show() - qtbot.wait(300) + qtbot.waitUntil(available_list_populated, timeout=3000) qtbot.add_widget(widget) yield widget widget.hide() widget._add_items_timer.stop() + assert not widget._add_items_timer.isActive() -def test_filter_not_available_plugins(plugin_dialog_constructor): +def test_filter_not_available_plugins(plugin_dialog): """ Check that the plugins listed under available plugins are enabled and disabled accordingly. @@ -212,14 +212,14 @@ def test_filter_not_available_plugins(plugin_dialog_constructor): The second plugin ("test-name-1") is available on conda-forge and should be enabled without the tooltip warning. """ - item = plugin_dialog_constructor.available_list.item(0) - widget = plugin_dialog_constructor.available_list.itemWidget(item) + item = plugin_dialog.available_list.item(0) + widget = plugin_dialog.available_list.itemWidget(item) if widget: assert not widget.action_button.isEnabled() assert widget.warning_tooltip.isVisible() - item = plugin_dialog_constructor.available_list.item(1) - widget = plugin_dialog_constructor.available_list.itemWidget(item) + item = plugin_dialog.available_list.item(1) + widget = plugin_dialog.available_list.itemWidget(item) assert widget.action_button.isEnabled() assert not widget.warning_tooltip.isVisible() @@ -253,26 +253,19 @@ def test_filter_installed_plugins(plugin_dialog): assert plugin_dialog.installed_list._count_visible() == 0 -def test_visible_widgets(plugin_dialog): +def test_visible_widgets(request, plugin_dialog): """ - Test that the direct entry button and textbox are visible for - normal napari installs. + Test that the direct entry button and textbox are visible """ - + if "no-constructor" not in request.node.name: + # the plugin_dialog fixture has this id + # skip for 'constructor' variant + pytest.skip() assert plugin_dialog.direct_entry_edit.isVisible() assert plugin_dialog.direct_entry_btn.isVisible() -def test_constructor_visible_widgets(plugin_dialog_constructor): - """ - Test that the direct entry button and textbox are hidden for - constructor based napari installs. - """ - assert not plugin_dialog_constructor.direct_entry_edit.isVisible() - assert not plugin_dialog_constructor.direct_entry_btn.isVisible() - - -def test_version_dropdown(plugin_dialog): +def test_version_dropdown(qtbot, plugin_dialog): """ Test that when the source drop down is changed, it displays the other versions properly. """ diff --git a/pyproject.toml b/pyproject.toml index c114f70..84aebf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,14 @@ dynamic = [ "version" ] +[project.optional-dependencies] +testing = [ + "pytest", + "virtualenv", + "pytest-cov", + "pytest-qt", +] + [project.urls] homepage = "https://github.com/napari/napari-plugin-manager" @@ -159,6 +167,10 @@ filterwarnings = [ "error:::test_.*", # turn warnings in our own tests into errors ] +markers = [ + "enabledialog: Allow to use dialog in test" +] + [tool.mypy] files = "napari_plugin_manager" ignore_missing_imports = true diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..fe3ced8 --- /dev/null +++ b/tox.ini @@ -0,0 +1,47 @@ +# For more information about tox, see https://tox.readthedocs.io/en/latest/ +[tox] +envlist = py{39,310,311}-{PyQt5,PySide2,PyQt6,PySide6}-napari_{latest,repo} +toxworkdir=/tmp/.tox +isolated_build = true + +[gh-actions] +python = + 3.8: py38 + 3.9: py39 + 3.10: py310 + 3.11: py311 + +[gh-actions:env] +NAPARI = + latest: napari_latest + repo: napari_repo +BACKEND = + pyqt: PyQt5 + pyside: PySide2 + PyQt5: PyQt5 + PySide2: PySide2 + PyQt6: PyQt6 + PySide6: PySide6 + +[testenv] +passenv = + QT_API + CI + GITHUB_ACTIONS + AZURE_PIPELINES + DISPLAY + XAUTHORITY + NUMPY_EXPERIMENTAL_ARRAY_FUNCTION + PYVISTA_OFF_SCREEN +deps = + PyQt5: PyQt5!=5.15.0 + PyQt5: PyQt5-sip!=12.12.0 + PySide2: PySide2!=5.15.0 + PyQt6: PyQt6 + # fix PySide6 when a new napari release is out + PySide6: PySide6 + PySide2: npe2!=0.2.2 + napari_repo: git+https://github.com/napari/napari.git + napari_latest: napari +extras = testing +commands = pytest -v --color=yes --cov=napari_plugin_manager --cov-report=xml