Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion .github/workflows/test_and_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ jobs:
- python-version: "3.13"
os: windows-latest
add-group: pyqt6


steps:
- uses: actions/checkout@v4
with:
Expand Down Expand Up @@ -99,6 +101,39 @@ jobs:
path: ./.coverage*
include-hidden-files: true

test-min-deps:
name: min-deps ${{ matrix.os }} (${{ matrix.python-version }})
runs-on: ${{ matrix.os }}
env:
UV_MANAGED_PYTHON: 1
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.13"]
os: [macos-latest]

steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: 🐍 Set up Python ${{ matrix.python-version }}
uses: astral-sh/setup-uv@v6
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
cache-dependency-glob: "**/pyproject.toml"

- name: 🧪 Run Tests
run: uv run --no-dev --group pyqt6 coverage run -p -m pytest -v

- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: covreport-mindeps-${{ matrix.os }}-py${{ matrix.python-version }}
path: ./.coverage*
include-hidden-files: true

upload_coverage:
if: always()
needs: [test]
Expand Down Expand Up @@ -158,4 +193,4 @@ jobs:
- uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: './dist/*'
files: "./dist/*"
10 changes: 9 additions & 1 deletion docs/examples/applications/pint_quantity.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,15 @@
https://pint.readthedocs.io/en/stable/
"""

from pint import Quantity
try:
from pint import Quantity
except ImportError:
msg = (
"This example requires the pint package. "
"To use magicgui with pint please `pip install pint`, "
"or use the pint extra: `pip install magicgui[pint]`"
)
raise ImportError(msg) from None

from magicgui import magicgui

Expand Down
9 changes: 8 additions & 1 deletion docs/examples/demo_widgets/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,15 @@
(This requires pillow, or that magicgui was installed as ``magicgui[image]``)
"""

from pathlib import Path

from magicgui.widgets import Image

image = Image(value="../../images/_test.jpg")
try:
test_jpg = Path(__file__).parent.parent.parent / "images" / "_test.jpg"
except NameError: # hack to support mkdocs-gallery build, which doesn't define __file__
test_jpg = "../../images/_test.jpg"

image = Image(value=test_jpg)
image.scale_widget_to_image_size()
image.show(run=True)
5 changes: 4 additions & 1 deletion docs/examples/demo_widgets/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
Demonstrating a few ways to input tables.
"""

import numpy as np
try:
import numpy as np
except ImportError:
raise ImportError("This example requires the numpy package. ")

from magicgui.widgets import Table

Expand Down
6 changes: 5 additions & 1 deletion docs/examples/matplotlib/waveform.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.backends.backend_qt5agg import FigureCanvas
from scipy import signal

try:
from scipy import signal
except ImportError:
raise ImportError("This example requires the scipy package. ")

from magicgui import magicgui, register_type, widgets

Expand Down
20 changes: 9 additions & 11 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,7 @@ dependencies = [
# extras
# https://peps.python.org/pep-0621/#dependencies-optional-dependencies
[project.optional-dependencies]
pyqt5 = [
"PyQt5>=5.15.8",
"pyqt5-qt5<=5.15.2; sys_platform == 'win32'"
]
pyqt5 = ["PyQt5>=5.15.8", "pyqt5-qt5<=5.15.2; sys_platform == 'win32'"]
pyqt6 = ["pyqt6>=6.4.0"]
pyside2 = ["pyside2>=5.15"]
pyside6 = ["pyside6>=6.4.0"]
Expand All @@ -71,14 +68,13 @@ third-party-support = [
"pydantic>=1.10.18",
"toolz>=1.0.0",
]
test-min = ["pytest>=8.4.0", "pytest-cov >=6.1", "pytest-mypy-plugins>=3.1"]
test-qt = [{ include-group = "test-min" }, "pytest-qt >=4.3.0"]
test = [
"magicgui[tqdm,jupyter,image,quantity]",
{ include-group = "test-min" },
{ include-group = "third-party-support" },
"pytest>=8.4.0",
"pytest-cov >=6.1",
"pytest-mypy-plugins>=3.1",
]
test-qt = [{ include-group = "test" }, "pytest-qt >=4.3.0"]
pyqt5 = ["magicgui[pyqt5]", { include-group = "test-qt" }]
pyqt6 = ["magicgui[pyqt6]", { include-group = "test-qt" }]
pyside2 = ["magicgui[pyside2]", { include-group = "test-qt" }]
Expand All @@ -99,10 +95,10 @@ docs = [
"mkdocstrings ==0.26.1",
"mkdocstrings-python ==1.11.1",
"griffe ==1.2.0",
"mkdocs-gen-files ==0.5.0",
"mkdocs-literate-nav ==0.6.1",
"mkdocs-gen-files >=0.5.0",
"mkdocs-literate-nav >=0.6.1",
"mkdocs-spellcheck[all] >=1.1.1",
"mkdocs-gallery ==0.10.3",
"mkdocs-gallery >=0.10.4",
"qtgallery ==0.0.2",
# extras for all the widgets
"napari ==0.5.3",
Expand Down Expand Up @@ -179,6 +175,8 @@ filterwarnings = [
"ignore:Jupyter is migrating:DeprecationWarning",
"ignore:The `ipykernel.comm.Comm` class has been deprecated",
"ignore:.*read_binary is deprecated:",
"ignore:Pickle, copy, and deepcopy support:DeprecationWarning",
"ignore:'count' is passed as positional argument::vispy",
]

# https://mypy.readthedocs.io/en/stable/config_file.html
Expand Down
10 changes: 7 additions & 3 deletions src/magicgui/type_map/_type_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,13 @@ def match_return_type(self, type_: Any) -> WidgetTuple | None:
if type_ is widgets.Table:
return widgets.Table, {}

table_types = [
resolve_single_type(x) for x in ("pandas.DataFrame", "numpy.ndarray")
]
table_types = []
for type_name in ("pandas.DataFrame", "numpy.ndarray"):
try:
table_types.append(resolve_single_type(type_name))
except ModuleNotFoundError:
# if the type cannot be resolved, it is not available
pass

if any(
safe_issubclass(type_, tt)
Expand Down
36 changes: 36 additions & 0 deletions tests/test_examples.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import runpy
from pathlib import Path
from unittest.mock import patch

import pytest
from qtpy.QtWidgets import QApplication

EXAMPLES_DIR = Path(__file__).parent.parent / "docs" / "examples"
EXAMPLES = sorted(EXAMPLES_DIR.rglob("*.py"))


@pytest.mark.parametrize(
"example",
EXAMPLES,
ids=lambda p: str(p.relative_to(EXAMPLES_DIR)),
)
def test_example(qapp: QApplication, example: Path) -> None:
"""Test that each example script runs without errors."""
assert example.is_file()
with patch.object(QApplication, "exec", lambda x: QApplication.processEvents()):
try:
runpy.run_path(str(example), run_name="__main__")
except (ModuleNotFoundError, ImportError) as e:
if "This example requires" in str(e):
# if the error message indicates a missing required dependency
# that's fine
pytest.xfail(str(e))
if "pip install magicgui[" in str(e):
# if the error message indicates a missing optional dependency
# that's fine
pytest.xfail(str(e))
if example.parent.name in str(e):
# if the example is explicitly in a folder named after the
# dependency it requires, that's fine
pytest.xfail(str(e))
raise
2 changes: 1 addition & 1 deletion tests/test_magicgui.py
Original file line number Diff line number Diff line change
Expand Up @@ -823,7 +823,7 @@ def some_func(x: int, y: str) -> str:


def test_curry():
import toolz as tz
tz = pytest.importorskip("toolz")

@tz.curry
def some_func2(x: int, y: str) -> str:
Expand Down
49 changes: 28 additions & 21 deletions tests/test_return_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,16 @@
"""Tests return widget types"""

import pathlib
from contextlib import suppress
from datetime import date, datetime, time
from inspect import signature

import numpy as np
import pytest

import magicgui
from magicgui import widgets


def _dataframe_equals(object1, object2):
assert object1.equals(object2)


def _ndarray_equals(object1: np.ndarray, object2: np.ndarray):
assert np.array_equal(object1, object2)


def _default_equals(object1, object2):
assert object1 == object2

Expand All @@ -33,18 +25,6 @@ def _generate_pandas_test_data():


parameterizations = [
# pandas dataframe
(
_generate_pandas_test_data(),
widgets.Table,
_dataframe_equals,
),
# numpy array
(
np.array([1, 1, 1, 2, 2, 2, 3, 3, 3]).reshape((3, 3)),
widgets.Table,
_ndarray_equals,
),
# NOTE: disabling for now... these types are too broad to choose a table
# # dict
# ({"a": [1], "b": [2], "c": [3]}, widgets.Table, _default_equals),
Expand Down Expand Up @@ -74,6 +54,33 @@ def _generate_pandas_test_data():
(slice(6), widgets.LineEdit, _default_equals),
]

with suppress(ImportError):
import numpy as np

def _ndarray_equals(object1: np.ndarray, object2: np.ndarray):
assert np.array_equal(object1, object2)

parameterizations.append(
(
np.array([1, 1, 1, 2, 2, 2, 3, 3, 3]).reshape((3, 3)),
widgets.Table,
_ndarray_equals,
)
)

with suppress(ImportError):

def _dataframe_equals(object1, object2):
assert object1.equals(object2)

parameterizations.append(
(
_generate_pandas_test_data(),
widgets.Table,
_dataframe_equals,
)
)


def generate_magicgui(data):
def func():
Expand Down
49 changes: 29 additions & 20 deletions tests/test_widgets.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import datetime
import importlib
import importlib.util
import inspect
from enum import Enum
from pathlib import Path
Expand All @@ -12,10 +14,14 @@
from magicgui.widgets.bases import BaseValueWidget, DialogWidget
from tests import MyInt

params = ["qt"]
if importlib.util.find_spec("ipywidgets"):
params.insert(0, "ipynb")


# it's important that "qt" be last here, so that it's used for
# the rest of the tests
@pytest.fixture(scope="module", params=["ipynb", "qt"])
@pytest.fixture(scope="module", params=params)
def backend(request):
return request.param

Expand Down Expand Up @@ -46,23 +52,26 @@ def f(x: int = 5):
assert f() == 20


@pytest.mark.parametrize(
"WidgetClass",
[
getattr(widgets, n)
for n in widgets.__all__
if n
not in (
"Widget",
"TupleEdit",
"FunctionGui",
"MainFunctionGui",
"show_file_dialog",
"request_values",
"create_widget",
)
],
)
WIDGETS_TO_TEST = [
getattr(widgets, n)
for n in widgets.__all__
if n
not in (
"Widget",
"TupleEdit",
"FunctionGui",
"MainFunctionGui",
"show_file_dialog",
"request_values",
"create_widget",
)
]

if not importlib.util.find_spec("pint"):
WIDGETS_TO_TEST.remove(widgets.QuantityEdit)


@pytest.mark.parametrize("WidgetClass", WIDGETS_TO_TEST)
def test_widgets(WidgetClass, backend):
"""Test that we can retrieve getters, setters, and signals for most Widgets."""
app = use_app(backend)
Expand Down Expand Up @@ -288,7 +297,7 @@ def test_unhashable_choice_data():

def test_ambiguous_eq_choice_data():
"""Test that providing choice data with an ambiguous equal operation is ok."""
import numpy as np
np = pytest.importorskip("numpy")

combo = widgets.ComboBox()
assert not combo.choices
Expand Down Expand Up @@ -331,7 +340,7 @@ def f(x: tuple[int, str] = (2, "b")):
def test_bound_unknown_type_annotation():
"""Test that we can bind a "permanent" value override to a parameter."""

import numpy as np
np = pytest.importorskip("numpy")

def _provide_value(_):
return np.array(1)
Expand Down