diff --git a/docs/examples/demo_widgets/optional.py b/docs/examples/demo_widgets/optional.py index 6fa88fd94..90d6cdcf5 100644 --- a/docs/examples/demo_widgets/optional.py +++ b/docs/examples/demo_widgets/optional.py @@ -3,14 +3,12 @@ Optional user input using a dropdown selection widget. """ -from typing import Optional - from magicgui import magicgui # Using optional will add a '----' to the combobox, which returns "None" @magicgui(path={"choices": ["a", "b"]}) -def f(path: Optional[str] = None): +def f(path: str | None = None): """Öptional user input function.""" print(path, type(path)) diff --git a/pyproject.toml b/pyproject.toml index ec012096c..0efc26e4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -161,12 +161,10 @@ select = [ ] ignore = [ "D401", # First line should be in imperative mood - # magicgui resolves annotations at *runtime*, and on python < 3.14 - # `get_origin(int | None)` is `types.UnionType`, not `typing.Union` -- so - # rewriting Optional/Union to PEP 604 changes behaviour rather than just - # spelling. Tests also deliberately exercise both spellings. + # `Union` is still needed as a runtime *value* for the public type aliases + # (PathLike, ChoicesType, AppRef, TableData, ...) and for `Union[args]` + # construction; ruff offers no fix for those, so the rule can't be enabled. "UP007", # Use `X | Y` for type annotations - "UP045", # Use `X | None` for type annotations ] [tool.ruff.lint.flake8-type-checking] @@ -176,8 +174,10 @@ ignore = [ exempt-modules = ["typing", "typing_extensions", "collections.abc"] [tool.ruff.lint.per-file-ignores] -"tests/*.py" = ["D", "S", "E501"] -"tests/test_util.py" = ["D", "S", "E501", "UP006"] +# tests deliberately exercise both `Union[X, Y]` and `X | Y` spellings, +# so they must not be rewritten to one of them (see test_no_order) +"tests/*.py" = ["D", "S", "E501", "UP007", "UP045"] +"tests/test_util.py" = ["D", "S", "E501", "UP006", "UP007", "UP045"] "docs/*.py" = ["B"] "docs/examples/*.py" = ["D", "B", "E501"] "src/magicgui/widgets/_image/*.py" = ["D"] diff --git a/src/magicgui/_type_resolution.py b/src/magicgui/_type_resolution.py index e3d0137b4..f402f2ca1 100644 --- a/src/magicgui/_type_resolution.py +++ b/src/magicgui/_type_resolution.py @@ -4,7 +4,7 @@ from copy import copy from functools import lru_cache, partial from importlib import import_module -from typing import Any, Optional, Union, get_type_hints +from typing import Any, Union, get_type_hints try: from toolz import curry @@ -27,8 +27,8 @@ def _unwrap_partial(func: Any) -> Any: def resolve_types( obj: Union[Callable, types.ModuleType, types.MethodType, type], - globalns: Optional[dict[str, Any]] = None, - localns: Optional[dict[str, Any]] = None, + globalns: dict[str, Any] | None = None, + localns: dict[str, Any] | None = None, do_imports: bool = False, ) -> dict[str, Any]: """Resolve type hints from an object. @@ -80,8 +80,8 @@ def _resolve_forwards(v: Any) -> Any: def resolve_single_type( hint: Any, - globalns: Optional[dict[str, Any]] = None, - localns: Optional[dict[str, Any]] = None, + globalns: dict[str, Any] | None = None, + localns: dict[str, Any] | None = None, do_imports: bool = True, ) -> Any: """Resolve a single type hint. diff --git a/src/magicgui/_util.py b/src/magicgui/_util.py index 5c9fcfa72..3cc970f34 100644 --- a/src/magicgui/_util.py +++ b/src/magicgui/_util.py @@ -4,11 +4,14 @@ import os import sys import time +import types from collections.abc import Callable from functools import wraps from pathlib import Path from typing import ( TYPE_CHECKING, + Any, + Union, get_args, get_origin, overload, @@ -27,6 +30,16 @@ C = TypeVar("C", bound=type) +def is_union(annotation: Any) -> bool: + """Return True if `annotation` is a union, in either spelling. + + `Union[X, Y]` and `X | Y` have different origins (`typing.Union` and + `types.UnionType`) on python < 3.14, so both must be checked. + """ + origin = get_origin(annotation) + return origin is Union or origin is types.UnionType + + @overload def debounce(function: Callable[P, T]) -> Callable[P, T | None]: ... diff --git a/src/magicgui/backends/_ipynb/application.py b/src/magicgui/backends/_ipynb/application.py index b84504d9a..364b61bfc 100644 --- a/src/magicgui/backends/_ipynb/application.py +++ b/src/magicgui/backends/_ipynb/application.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from typing import Callable +from collections.abc import Callable from magicgui.widgets.protocols import BaseApplicationBackend diff --git a/src/magicgui/schema/_ui_field.py b/src/magicgui/schema/_ui_field.py index 0ab019212..c93b3c181 100644 --- a/src/magicgui/schema/_ui_field.py +++ b/src/magicgui/schema/_ui_field.py @@ -21,6 +21,7 @@ get_origin, ) +from magicgui._util import is_union from magicgui.types import JsonStringFormats, Undefined, _Undefined if TYPE_CHECKING: @@ -53,7 +54,7 @@ class UiField(Generic[T]): def __post_init__(self) -> None: """Coerce Optional[...] to nullable and remove it from the type.""" - if get_origin(self.type) is Union: + if is_union(self.type): args = get_args(self.type) nonnull = tuple(a for a in args if a is not type(None)) if len(nonnull) < len(args): @@ -601,7 +602,7 @@ def _uifield_from_pydantic2(finfo: FieldInfo, name: str) -> UiField: ) nullable = None - if get_origin(finfo.annotation) is Union and any( + if is_union(finfo.annotation) and any( i for i in get_args(finfo.annotation) if i is type(None) ): nullable = True diff --git a/src/magicgui/type_map/_type_map.py b/src/magicgui/type_map/_type_map.py index 62f2d35ff..48de51efa 100644 --- a/src/magicgui/type_map/_type_map.py +++ b/src/magicgui/type_map/_type_map.py @@ -32,7 +32,7 @@ from magicgui import widgets from magicgui._type_resolution import resolve_single_type -from magicgui._util import safe_issubclass +from magicgui._util import is_union, safe_issubclass from magicgui.application import AppRef, use_app from magicgui.types import PathLike, ReturnCallback, Undefined, _Undefined from magicgui.widgets import protocols @@ -994,7 +994,7 @@ def _register_type_callback( _validate_return_callback(return_callback) # if the type is a Union, add the callback to all of the types in the union # (except NoneType) - if get_origin(resolved_type) is Union: + if is_union(resolved_type): for type_per in _generate_union_variants(resolved_type): if return_callback not in self._return_callbacks[type_per]: self._return_callbacks[type_per].append(return_callback) diff --git a/src/magicgui/widgets/_image/_mpl_image.py b/src/magicgui/widgets/_image/_mpl_image.py index 514652718..189251079 100644 --- a/src/magicgui/widgets/_image/_mpl_image.py +++ b/src/magicgui/widgets/_image/_mpl_image.py @@ -54,7 +54,7 @@ import logging from collections.abc import Collection from functools import lru_cache -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, Union try: import numpy as np @@ -513,7 +513,7 @@ def __init__(self, cmap=None, norm=None): def set_data( self, A: Union[str, "Path", "np.ndarray", "PIL.Image.Image"], - format: Optional[str] = None, + format: str | None = None, ): """Set the image array. diff --git a/src/magicgui/widgets/bases/_value_widget.py b/src/magicgui/widgets/bases/_value_widget.py index 96c0fc271..fbc13eefd 100644 --- a/src/magicgui/widgets/bases/_value_widget.py +++ b/src/magicgui/widgets/bases/_value_widget.py @@ -7,14 +7,13 @@ Any, Generic, TypeVar, - Union, cast, get_args, - get_origin, ) from psygnal import Signal +from magicgui._util import is_union from magicgui.types import Undefined, _Undefined from ._widget import Widget @@ -167,7 +166,7 @@ def annotation(self) -> Any: annotation will return the first argument in the Optional clause. """ annotation = Widget.annotation.fget(self) # type: ignore - if self._nullable and get_origin(annotation) is Union: + if self._nullable and is_union(annotation): return get_args(annotation)[0] return annotation diff --git a/tests/test_types.py b/tests/test_types.py index 14ff41cc0..f7008ce9a 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -247,3 +247,36 @@ def f(a: int, b: str): assert isinstance(fgui0[1], widgets.LineEdit) assert isinstance(fgui1[0], widgets.Slider) assert isinstance(fgui1[1], widgets.LineEdit) + + +def test_pep604_union_matches_typing_union(): + """`X | None` should behave exactly like `Optional[X]`. + + On python < 3.14 `get_origin(int | None)` is `types.UnionType` rather than + `typing.Union`, so anything comparing against `Union` must accept both. + """ + old = widgets.create_widget(annotation=Optional[int]) + new = widgets.create_widget(annotation=int | None) + + assert type(new) is type(old) + assert new._nullable is old._nullable is True + # the Optional wrapper is stripped from the reported annotation + assert new.annotation is old.annotation is int + + +def test_pep604_union_return_callback(): + """Registering `X | Y` should register each member, as `Union[X, Y]` does.""" + mock = Mock() + register_type(int | str, return_callback=mock) + try: + # registering a union registers a callback for each member type + @magicgui + def f() -> int: + return 1 + + f() + mock.assert_called_once() + finally: + callbacks = TypeMap.global_instance()._return_callbacks + for key in (int, str, Union[int, str]): + callbacks.pop(key, None)