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
4 changes: 1 addition & 3 deletions docs/examples/demo_widgets/optional.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
14 changes: 7 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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"]
Expand Down
10 changes: 5 additions & 5 deletions src/magicgui/_type_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions src/magicgui/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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]: ...

Expand Down
2 changes: 1 addition & 1 deletion src/magicgui/backends/_ipynb/application.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
5 changes: 3 additions & 2 deletions src/magicgui/schema/_ui_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
get_origin,
)

from magicgui._util import is_union
from magicgui.types import JsonStringFormats, Undefined, _Undefined

if TYPE_CHECKING:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/magicgui/type_map/_type_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions src/magicgui/widgets/_image/_mpl_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
5 changes: 2 additions & 3 deletions src/magicgui/widgets/bases/_value_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
33 changes: 33 additions & 0 deletions tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading