diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 59e373317..c645cc756 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -16,14 +16,13 @@ on: jobs: test: name: Test - uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v1 + uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2 with: os: ${{ matrix.os }} python-version: ${{ matrix.python-version }} qt: ${{ matrix.qt }} pip-install-pre-release: ${{ github.event_name == 'schedule' }} - report-failures: ${{ github.event_name == 'schedule' }} - secrets: inherit + coverage-upload: artifact strategy: fail-fast: false matrix: @@ -58,18 +57,24 @@ jobs: test-min-reqs: name: Test min reqs - uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@main + uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2 with: os: ubuntu-latest python-version: ${{ matrix.python-version }} qt: pyqt5 pip-install-min-reqs: true - secrets: inherit + coverage-upload: artifact strategy: fail-fast: false matrix: python-version: ["3.8", "3.9", "3.10", "3.11"] + upload_coverage: + if: always() + needs: [test, test-min-reqs] + uses: pyapp-kit/workflows/.github/workflows/upload-coverage.yml@v2 + secrets: inherit + test-pydantic1: name: Test pydantic1 runs-on: ubuntu-latest @@ -97,7 +102,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} test-dependents: - uses: pyapp-kit/workflows/.github/workflows/test-dependents.yml@v1 + uses: pyapp-kit/workflows/.github/workflows/test-dependents.yml@v2 with: dependency-repo: ${{ matrix.package }} dependency-ref: ${{ matrix.package-version }} @@ -105,7 +110,7 @@ jobs: host-extras: "testing" qt: pyqt5 python-version: "3.10" - post-install-cmd: "python -m pip install pytest-pretty lxml_html_clean" # just for napari + post-install-cmd: "python -m pip install pytest-pretty lxml_html_clean" # just for napari pytest-args: ${{ matrix.pytest-args }} strategy: fail-fast: false diff --git a/pyproject.toml b/pyproject.toml index 826c20faf..086e58274 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -180,6 +180,7 @@ minversion = "6.0" testpaths = ["tests"] filterwarnings = [ "error", + "ignore:Failed to disconnect::pytestqt", "ignore::DeprecationWarning:tqdm", "ignore::DeprecationWarning:docstring_parser", "ignore:distutils Version classes are deprecated:DeprecationWarning", diff --git a/src/magicgui/signature.py b/src/magicgui/signature.py index 835dd0df5..7170d25e8 100644 --- a/src/magicgui/signature.py +++ b/src/magicgui/signature.py @@ -15,6 +15,8 @@ from __future__ import annotations import inspect +import typing +import warnings from types import MappingProxyType from typing import TYPE_CHECKING, Any, Callable, Sequence, cast @@ -69,9 +71,65 @@ def make_annotated(annotation: Any = Any, options: dict | None = None) -> Any: f"Every item in Annotated must be castable to a dict, got: {opt!r}" ) from e _options.update(_opt) + + annotation = _make_hashable(annotation) + _options = _make_hashable(_options) return Annotated[annotation, _options] +# this is a stupid hack to deal with something that changed in typing-extensions +# v4.12.0 (and probably python 3.13), where all items in Annotated must now be hashable +# The PR that necessitated this was https://github.com/python/typing_extensions/pull/392 + + +class hashabledict(dict): + """Hashable immutable dict.""" + + def __hash__(self) -> int: # type: ignore # noqa: D105 + # we don't actually want to hash the contents, just the object itself. + return id(self) + + def __setitem__(self, key: Any, value: Any) -> None: # noqa: D105 + warnings.warn( + "Mutating magicgui Annotation metadata after creation is not supported." + "This will raise an error in a future version.", + stacklevel=2, + ) + super().__setitem__(key, value) + + def __delitem__(self, key: Any) -> None: # noqa: D105 + raise TypeError("hashabledict is immutable") + + +def _hashable(obj: Any) -> bool: + try: + hash(obj) + return True + except TypeError: + return False + + +def _make_hashable(obj: Any) -> Any: + if _hashable(obj): + return obj + if isinstance(obj, dict): + return hashabledict({k: _make_hashable(v) for k, v in obj.items()}) + if isinstance(obj, (list, tuple)): + return tuple(_make_hashable(v) for v in obj) + if isinstance(obj, set): + return frozenset(_make_hashable(v) for v in obj) + if (args := get_args(obj)) and (not _hashable(args)): + try: + obj = get_origin(obj)[_make_hashable(args)] + except TypeError: + # python 3.8 hack + if obj.__module__ == "typing" and hasattr(obj, "_name"): + generic = getattr(typing, obj._name) + return generic[_make_hashable(args)] + raise + return obj + + class _void: """private sentinel.""" diff --git a/src/magicgui/widgets/_function_gui.py b/src/magicgui/widgets/_function_gui.py index 440ea0397..b15455ce9 100644 --- a/src/magicgui/widgets/_function_gui.py +++ b/src/magicgui/widgets/_function_gui.py @@ -64,7 +64,6 @@ def _inject_tooltips_from_docstrings( for split_key in k.split(","): doc_params[split_key.strip()] = v del doc_params[k] - for name, description in doc_params.items(): # this is to catch potentially bad arg_name parsing in docstring_parser # if using napoleon style google docstringss diff --git a/tests/test_types.py b/tests/test_types.py index 01ab55321..242f7c1a1 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,6 +1,6 @@ from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, List, Optional, Union from unittest.mock import Mock import pytest @@ -117,7 +117,8 @@ def widget2(fn: Union[bytes, pathlib.Path, str]): def test_optional_type(): @magicgui(x={"choices": ["a", "b"]}) - def widget(x: Optional[str] = None): ... + def widget(x: Optional[str] = None): + ... assert isinstance(widget.x, widgets.ComboBox) assert widget.x.value is None @@ -231,3 +232,10 @@ def test_pick_widget_literal(): ) assert cls == widgets.RadioButtons assert set(options["choices"]) == {"a", "b"} + + +def test_redundant_annotation() -> None: + # just shouldn't raise + @magicgui + def f(a: Annotated[List[int], {"annotation": List[int]}]): + pass