From 7bae755b29fd602ac224c1e8d8ae044b43e7e70c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 26 Aug 2026 14:47:00 +0200 Subject: [PATCH 1/3] fix: handle PEP 604 unions (`X | Y`) like `Union[X, Y]` `get_origin(int | None)` is `types.UnionType`, not `typing.Union`, on python < 3.14 -- so the four `get_origin(...) is Union` comparisons silently took the non-union path for PEP 604 annotations. Most visibly, the Optional wrapper was not stripped from a widget's reported annotation: @magicgui def f(x: Optional[int] = None): ... # .annotation -> int @magicgui def f(x: int | None = None): ... # .annotation -> int | None and `register_type(int | str, return_callback=...)` registered nothing for the individual member types. Adds `magicgui._util.is_union`, which accepts both spellings, and uses it at all four sites. Co-Authored-By: Claude Opus 5 --- src/magicgui/_util.py | 13 ++++++++ src/magicgui/schema/_ui_field.py | 5 ++-- src/magicgui/type_map/_type_map.py | 4 +-- src/magicgui/widgets/bases/_value_widget.py | 5 ++-- tests/test_types.py | 33 +++++++++++++++++++++ 5 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/magicgui/_util.py b/src/magicgui/_util.py index 5c9fcfa7..3cc970f3 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/schema/_ui_field.py b/src/magicgui/schema/_ui_field.py index 0ab01921..c93b3c18 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 62f2d35f..48de51ef 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/bases/_value_widget.py b/src/magicgui/widgets/bases/_value_widget.py index 96c0fc27..fbc13eef 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 14ff41cc..f7008ce9 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) From 057db3a834388a4cc0ccad5fa3bbd0050179d4e7 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 26 Aug 2026 14:49:36 +0200 Subject: [PATCH 2/3] style: import Callable from collections.abc in the ipynb backend UP035 under the new py311 target. #742 merged after #745 switched target-version, so its CI ran against the old config and main is currently failing ruff. Co-Authored-By: Claude Opus 5 --- src/magicgui/backends/_ipynb/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/magicgui/backends/_ipynb/application.py b/src/magicgui/backends/_ipynb/application.py index b84504d9..364b61bf 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 From b2220420f6602a654611c7c7385dec6ec445e7b9 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 26 Aug 2026 15:05:06 +0200 Subject: [PATCH 3/3] style: enable UP045 for source now that PEP 604 unions work The preceding fix makes `X | None` behave like `Optional[X]`, so the pyupgrade rewrite is safe for src. Scoped deliberately: - UP045 is enabled for src/ and docs/ only; tests/ keep it ignored, since they exercise both spellings on purpose (see test_no_order). - UP007 stays ignored: `Union` is still needed as a runtime *value* for the public type aliases (PathLike, ChoicesType, AppRef, TableData, WidgetRef) and for `Union[args]` construction. ruff offers no fix for those 11 sites, so enabling it would just leave permanent errors. Co-Authored-By: Claude Opus 5 --- docs/examples/demo_widgets/optional.py | 4 +--- pyproject.toml | 14 +++++++------- src/magicgui/_type_resolution.py | 10 +++++----- src/magicgui/widgets/_image/_mpl_image.py | 4 ++-- 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/docs/examples/demo_widgets/optional.py b/docs/examples/demo_widgets/optional.py index 6fa88fd9..90d6cdcf 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 ec012096..0efc26e4 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 e3d0137b..f402f2ca 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/widgets/_image/_mpl_image.py b/src/magicgui/widgets/_image/_mpl_image.py index 51465271..18925107 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.