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
19 changes: 12 additions & 7 deletions .github/workflows/test_and_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -97,15 +102,15 @@ 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 }}
dependency-extras: ${{ matrix.package-extras || 'testing' }}
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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
58 changes: 58 additions & 0 deletions src/magicgui/signature.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""

Expand Down
1 change: 0 additions & 1 deletion src/magicgui/widgets/_function_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions tests/test_types.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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