diff --git a/changes/2246.misc.txt b/changes/2246.misc.rst similarity index 100% rename from changes/2246.misc.txt rename to changes/2246.misc.rst diff --git a/changes/2252.misc.rst b/changes/2252.misc.rst new file mode 100644 index 0000000000..4a8852817f --- /dev/null +++ b/changes/2252.misc.rst @@ -0,0 +1 @@ +The typing for Toga's API surface is now compliant with mypy. diff --git a/core/pyproject.toml b/core/pyproject.toml index 8b23cd8c35..8c312e0378 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -59,6 +59,7 @@ dependencies = [ "travertino >= 0.3.0", # limited to <=3.9 for the `group` argument for `entry_points()` "importlib_metadata >= 4.4.0; python_version <= '3.9'", + "typing-extensions == 4.8.0", ] [project.optional-dependencies] @@ -73,6 +74,7 @@ dev = [ "pytest-freezer == 0.4.8", "setuptools-scm == 8.0.4", "tox == 4.11.4", + "types-Pillow == 10.1.0.2", ] docs = [ "furo == 2023.9.10", diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 18d90c0cfa..df00f5776e 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -6,66 +6,90 @@ import sys import warnings import webbrowser -from collections.abc import Collection, Iterator, Mapping, MutableSet +from collections.abc import Collection, Iterator, Mapping from email.message import Message -from typing import Any, Protocol +from pathlib import Path +from typing import AbstractSet, Any, MutableSet, Protocol, Union from warnings import warn -from toga.command import Command, CommandSet +from typing_extensions import TypeAlias + +from toga.command import CommandSet from toga.documents import Document -from toga.handlers import wrapped_handler +from toga.handlers import HandlerGeneratorReturnT, WrappedHandlerT, wrapped_handler from toga.icons import Icon from toga.paths import Paths from toga.platform import get_platform_factory from toga.widgets.base import Widget, WidgetRegistry -from toga.window import Window +from toga.window import OnCloseHandlerT, Window # Make sure deprecation warnings are shown by default warnings.filterwarnings("default", category=DeprecationWarning) class AppStartupMethod(Protocol): - def __call__(self, app: App, **kwargs: Any) -> Widget: + def __call__(self, app: App, /) -> Widget: """The startup method of the app. Called during app startup to set the initial main window content. :param app: The app instance that is starting. - :param kwargs: Ensures compatibility with additional arguments introduced in - future versions. :returns: The widget to use as the main window content. """ - ... -class OnExitHandler(Protocol): - def __call__(self, app: App, **kwargs: Any) -> bool: +class OnExitHandlerSync(Protocol): + def __call__(self, app: App, /) -> bool: """A handler to invoke when the app is about to exit. The return value of this callback controls whether the app is allowed to exit. This can be used to prevent the app exiting with unsaved changes, etc. :param app: The app instance that is exiting. - :param kwargs: Ensures compatibility with additional arguments introduced in - future versions. - :returns: ``True`` if the app is allowed to exit; ``False`` if the app is not - allowed to exit. + :returns: :any:`True` if the app is allowed to exit; :any:`False` if the app is + not allowed to exit. """ - ... -class BackgroundTask(Protocol): - def __call__(self, app: App, **kwargs: Any) -> None: - """Code that should be executed as a background task. +class OnExitHandlerAsync(Protocol): + async def __call__(self, app: App, /) -> bool: + """Async definition of :any:`OnExitHandlerSync`.""" + + +class OnExitHandlerGenerator(Protocol): + def __call__(self, app: App, /) -> HandlerGeneratorReturnT[bool]: + """Generator definition of :any:`OnExitHandlerSync`.""" + + +OnExitHandlerT: TypeAlias = Union[ + OnExitHandlerSync, OnExitHandlerAsync, OnExitHandlerGenerator +] + + +class BackgroundTaskSync(Protocol): + def __call__(self, app: App, /) -> object: + """Code that will be executed as a background task. :param app: The app that is handling the background task. - :param kwargs: Ensures compatibility with additional arguments introduced in - future versions. """ - ... -class WindowSet(MutableSet): +class BackgroundTaskAsync(Protocol): + async def __call__(self, app: App, /) -> object: + """Async definition of :any:`BackgroundTaskSync`.""" + + +class BackgroundTaskGenerator(Protocol): + def __call__(self, app: App, /) -> HandlerGeneratorReturnT[object]: + """Generator definition of :any:`BackgroundTaskSync`.""" + + +BackgroundTaskT: TypeAlias = Union[ + BackgroundTaskSync, BackgroundTaskAsync, BackgroundTaskGenerator +] + + +class WindowSet(MutableSet[Window]): def __init__(self, app: App): """A collection of windows managed by an app. @@ -74,7 +98,7 @@ def __init__(self, app: App): :attr:`~toga.Window.app` property of the Window. """ self.app = app - self.elements = set() + self.elements: set[Window] = set() def add(self, window: Window) -> None: if not isinstance(window, Window): @@ -95,7 +119,7 @@ def discard(self, window: Window) -> None: # 2023-10: Backwards compatibility ###################################################################### - def __iadd__(self, window: Window) -> None: + def __iadd__(self, window: AbstractSet[Any]) -> WindowSet: # The standard set type does not have a += operator. warn( "Windows are automatically associated with the app; += is not required", @@ -104,7 +128,7 @@ def __iadd__(self, window: Window) -> None: ) return self - def __isub__(self, other: Window) -> None: + def __isub__(self, other: AbstractSet[Any]) -> WindowSet: # The standard set type does have a -= operator, but it takes sets rather than # individual items. warn( @@ -118,10 +142,10 @@ def __isub__(self, other: Window) -> None: # End backwards compatibility ###################################################################### - def __iter__(self) -> Iterator: + def __iter__(self) -> Iterator[Window]: return iter(self.elements) - def __contains__(self, value: Window) -> bool: + def __contains__(self, value: object) -> bool: return value in self.elements def __len__(self) -> int: @@ -139,8 +163,8 @@ def __init__( size: tuple[int, int] = (640, 480), resizable: bool = True, minimizable: bool = True, - resizeable=None, # DEPRECATED - closeable=None, # DEPRECATED + resizeable: None = None, # DEPRECATED + closeable: None = None, # DEPRECATED ): """Create a new main window. @@ -174,7 +198,7 @@ def _default_title(self) -> str: return App.app.formal_name @property - def on_close(self) -> None: + def on_close(self) -> None: # TODO:PR: align with `toga.Window` """The handler to invoke before the window is closed in response to a user action. @@ -186,7 +210,7 @@ def on_close(self) -> None: return None @on_close.setter - def on_close(self, handler: Any): + def on_close(self, handler: OnCloseHandlerT | None) -> None: if handler: raise ValueError( "Cannot set on_close handler for the main window. " @@ -212,7 +236,7 @@ def __init__( (e.g., due to having unsaved change), override :meth:`toga.Document.can_close()`, rather than implementing an on_close handler. - :param document: The document being managed by this window + :param doc: The document being managed by this window :param id: The ID of the window. :param title: Title for the window. Defaults to the formal name of the app. :param position: Position of the window, as a tuple of ``(x, y)`` coordinates. @@ -238,7 +262,7 @@ def _default_title(self) -> str: class App: - app = None + app: App def __init__( self, @@ -246,15 +270,15 @@ def __init__( app_id: str | None = None, app_name: str | None = None, *, - icon: Icon | str | None = None, + icon: Icon | str | Path | None = None, author: str | None = None, version: str | None = None, home_page: str | None = None, description: str | None = None, startup: AppStartupMethod | None = None, - on_exit: OnExitHandler | None = None, - id=None, # DEPRECATED - windows=None, # DEPRECATED + on_exit: OnExitHandlerT | None = None, + id: None = None, # DEPRECATED + windows: None = None, # DEPRECATED ): """Create a new App instance. @@ -300,7 +324,7 @@ def __init__( # 2023-10: Backwards compatibility ###################################################################### if id is not None: - warn( + warn( # type: ignore[unreachable] "App.id is deprecated and will be ignored. Use app_id instead", DeprecationWarning, stacklevel=2, @@ -409,23 +433,23 @@ def __init__( self.on_exit = on_exit - # We need the command set to exist so that startup et al can add commands; + # We need the command set to exist so that startup et al. can add commands; # but we don't have an impl yet, so we can't set the on_change handler self._commands = CommandSet() self._startup_method = startup - self._main_window = None + self._main_window: MainWindow | None = None self._windows = WindowSet(self) - self._full_screen_windows = None + self._full_screen_windows: tuple[Window, ...] | None = None self._create_impl() # Now that we have an impl, set the on_change handler for commands self.commands.on_change = self._impl.create_menus - def _create_impl(self): + def _create_impl(self) -> None: self.factory.App(interface=self) @property @@ -506,7 +530,7 @@ def icon(self) -> Icon: return self._icon @icon.setter - def icon(self, icon_or_name: Icon | str) -> None: + def icon(self, icon_or_name: Icon | str | Path) -> None: if isinstance(icon_or_name, Icon): self._icon = icon_or_name else: @@ -533,7 +557,7 @@ def windows(self) -> Collection[Window]: # Support WindowSet __iadd__ and __isub__ @windows.setter - def windows(self, windows): + def windows(self, windows: WindowSet) -> None: if windows is not self._windows: raise AttributeError("can't set attribute 'windows'") @@ -542,17 +566,17 @@ def windows(self, windows): ###################################################################### @property - def commands(self) -> MutableSet[Command]: + def commands(self) -> CommandSet: """The commands available in the app.""" return self._commands @property - def main_window(self) -> MainWindow: + def main_window(self) -> MainWindow | None: """The main window for the app.""" return self._main_window @main_window.setter - def main_window(self, window: MainWindow) -> None: + def main_window(self, window: MainWindow | None) -> None: self._main_window = window self._impl.set_main_window(window) @@ -562,10 +586,10 @@ def current_window(self) -> Window | None: window = self._impl.get_current_window() if window is None: return None - return window.interface + return window.interface # TODO:PR: this is a Window? @current_window.setter - def current_window(self, window: Window): + def current_window(self, window: Window) -> None: """Set a window into current active focus.""" self._impl.set_current_window(window) @@ -618,13 +642,13 @@ def startup(self) -> None: self.main_window.show() - def _startup(self): + def _startup(self) -> None: # This is a wrapper around the user's startup method that performs any # post-setup validation. self.startup() self._verify_startup() - def _verify_startup(self): + def _verify_startup(self) -> None: if not isinstance(self.main_window, MainWindow): raise ValueError( "Application does not have a main window. " @@ -671,13 +695,13 @@ def exit(self) -> None: self._impl.exit() @property - def on_exit(self) -> OnExitHandler: + def on_exit(self) -> WrappedHandlerT: """The handler to invoke if the user attempts to exit the app.""" return self._on_exit @on_exit.setter - def on_exit(self, handler: OnExitHandler | None) -> None: - def cleanup(app, should_exit): + def on_exit(self, handler: OnExitHandlerT | None) -> None: + def cleanup(app: App, should_exit: bool) -> None: if should_exit or handler is None: app.exit() @@ -690,7 +714,7 @@ def loop(self) -> asyncio.AbstractEventLoop: thread (read-only).""" return self._impl.loop - def add_background_task(self, handler: BackgroundTask) -> None: + def add_background_task(self, handler: BackgroundTaskT) -> None: """Schedule a task to run in the background. Schedules a coroutine or a generator to run in the background. Control @@ -717,15 +741,15 @@ def __init__( home_page: str | None = None, description: str | None = None, startup: AppStartupMethod | None = None, - document_types: dict[str, type[Document]] = None, - on_exit: OnExitHandler | None = None, - id=None, # DEPRECATED + document_types: dict[str, type[Document]] | None = None, + on_exit: OnExitHandlerT | None = None, + id: None = None, # DEPRECATED ): """Create a document-based application. A document-based application is the same as a normal application, with the exception that there is no main window. Instead, each document managed by the - app will create and manage it's own window (or windows). + app will create and manage its own window (or windows). :param document_types: Initial :any:`document_types` mapping. """ @@ -733,7 +757,7 @@ def __init__( raise ValueError("A document must manage at least one document type.") self._document_types = document_types - self._documents = [] + self._documents: list[Document] = [] super().__init__( formal_name=formal_name, @@ -749,10 +773,10 @@ def __init__( id=id, ) - def _create_impl(self): + def _create_impl(self) -> None: # TODO:PR: why is this returning anything...? return self.factory.DocumentApp(interface=self) - def _verify_startup(self): + def _verify_startup(self) -> None: # No post-startup validation required for DocumentApps pass @@ -778,18 +802,19 @@ def startup(self) -> None: Subclasses can override this method to define customized startup behavior. """ - def _open(self, path): + def _open(self, path: Path) -> None: """Internal utility method; open a new document in this app, and shows the document. :param path: The path to the document to be opened. :raises ValueError: If the document is of a type that can't be opened. Backends can - suppress this exception if necessary to presere platform-native behavior. + suppress this exception if necessary to preserve platform-native behavior. """ try: DocType = self.document_types[path.suffix[1:]] except KeyError: raise ValueError(f"Don't know how to open documents of type {path.suffix}") else: + # TODO:PR: does this need `document_type`? or is `Document` not the right type? document = DocType(path, app=self) self._documents.append(document) document.show() diff --git a/core/src/toga/colors.py b/core/src/toga/colors.py index 18f3611c8b..898c83168c 100644 --- a/core/src/toga/colors.py +++ b/core/src/toga/colors.py @@ -1,2 +1,3 @@ +# TODO:PR: replace with explicit import - probably will need tests to verify # Use the Travertino color definitions as-is from travertino.colors import * # noqa: F401, F403 diff --git a/core/src/toga/command.py b/core/src/toga/command.py index ec68ce4850..632c672fc9 100644 --- a/core/src/toga/command.py +++ b/core/src/toga/command.py @@ -1,8 +1,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Protocol +from pathlib import Path +from typing import TYPE_CHECKING, Any, Iterator, Protocol, Union -from toga.handlers import wrapped_handler +from typing_extensions import TypeAlias + +from toga.handlers import HandlerGeneratorReturnT, wrapped_handler from toga.icons import Icon from toga.keys import Key from toga.platform import get_platform_factory @@ -21,7 +24,7 @@ def __init__( order: int = 0, ): """ - An collection of commands to display together. + A collection of commands to display together. :param text: A label for the group. :param parent: The parent of this group; use ``None`` to make a root group. @@ -48,7 +51,7 @@ def parent(self) -> Group | None: return self._parent @parent.setter - def parent(self, parent: Group | None): + def parent(self, parent: Group | None) -> None: if parent is None: self._parent = None elif parent == self: @@ -120,7 +123,7 @@ def __repr__(self) -> str: return f"" @property - def key(self) -> tuple[(int, int, str)]: + def key(self) -> tuple[tuple[int, int, str], ...]: """A unique tuple describing the path to this group.""" self_tuple = (self.section, self.order, self.text) if self.parent is None: @@ -129,13 +132,13 @@ def key(self) -> tuple[(int, int, str)]: # Standard groups - docstrings can only be provided within the `class` statement, # but the objects can't be instantiated here. - APP = None #: Application-level commands - FILE = None #: File commands - EDIT = None #: Editing commands - VIEW = None #: Content appearance commands - COMMANDS = None #: Default group for user-provided commands - WINDOW = None #: Window management commands - HELP = None #: Help commands + APP: Group #: Application-level commands + FILE: Group #: File commands + EDIT: Group #: Editing commands + VIEW: Group #: Content appearance commands + COMMANDS: Group #: Default group for user-provided commands + WINDOW: Group #: Window management commands + HELP: Group #: Help commands Group.APP = Group("*", order=0) @@ -147,26 +150,40 @@ def key(self) -> tuple[(int, int, str)]: Group.HELP = Group("Help", order=100) -class ActionHandler(Protocol): - def __call__(self, command: Command, **kwargs) -> bool: +class ActionHandlerSync(Protocol): + def __call__(self, command: Command, /) -> bool: """A handler that will be invoked when a Command is invoked. :param command: The command that triggered the action. - :param kwargs: Ensures compatibility with additional arguments introduced in - future versions. """ - ... + + +class ActionHandlerAsync(Protocol): + async def __call__(self, command: Command, /) -> bool: + """Async definition of :any:`ActionHandlerSync`.""" + + +class ActionHandlerGenerator(Protocol): + async def __call__(self, command: Command, /) -> HandlerGeneratorReturnT[bool]: + """Generator definition of :any:`ActionHandlerSync`.""" + + +ActionHandlerT: TypeAlias = Union[ + ActionHandlerSync, + ActionHandlerAsync, + ActionHandlerGenerator, +] class Command: def __init__( self, - action: ActionHandler | None, + action: ActionHandlerT | None, text: str, *, shortcut: str | Key | None = None, tooltip: str | None = None, - icon: str | Icon | None = None, + icon: Icon | str | Path | None = None, group: Group = Group.COMMANDS, section: int = 0, order: int = 0, @@ -207,10 +224,11 @@ def __init__( self.factory = get_platform_factory() self._impl = self.factory.Command(interface=self) + self._enabled = True self.enabled = enabled @property - def key(self) -> tuple[(int, int, str)]: + def key(self) -> tuple[tuple[int, int, str], ...]: """A unique tuple describing the path to this command. Each element in the tuple describes the (section, order, text) for the @@ -224,7 +242,7 @@ def enabled(self) -> bool: return self._enabled @enabled.setter - def enabled(self, value: bool): + def enabled(self, value: bool) -> None: self._enabled = value and getattr(self.action, "_raw", True) is not None self._impl.set_enabled(value) @@ -238,7 +256,7 @@ def icon(self) -> Icon | None: return self._icon @icon.setter - def icon(self, icon_or_name: str | Icon): + def icon(self, icon_or_name: str | Path | Icon | None) -> None: if isinstance(icon_or_name, Icon) or icon_or_name is None: self._icon = icon_or_name else: @@ -254,7 +272,7 @@ def __gt__(self, other: Any) -> bool: return False return other < self - def __repr__(self) -> bool: + def __repr__(self) -> str: return ( f" bool: class Separator: - def __init__(self, group: Group = None): + def __init__(self, group: Group | None = None): """A representation of a separator between sections in a Group. :param group: The group that contains the separator. @@ -272,16 +290,16 @@ def __init__(self, group: Group = None): self.group = group def __repr__(self) -> str: - return f"" + return f"" - def __eq__(self, other) -> bool: + def __eq__(self, other: Any) -> bool: if isinstance(other, Separator): return self.group == other.group return False class CommandSetChangeHandler(Protocol): - def __call__(self) -> None: + def __call__(self, /) -> object: """A handler that will be invoked when a Command or Group is added to the CommandSet. .. note:: @@ -290,13 +308,12 @@ def __call__(self) -> None: :return: Nothing """ - ... class CommandSet: def __init__( self, - on_change: CommandSetChangeHandler = None, + on_change: CommandSetChangeHandler | None = None, app: App | None = None, ): """ @@ -312,35 +329,35 @@ def __init__( :param on_change: A method that should be invoked when this command set changes. :param app: The app this command set is associated with, if it is not the app's - own commandset. + own :any:`CommandSet`. """ self._app = app self._commands = set() self.on_change = on_change - def add(self, *commands: Command | Group): - if self.app and self.app is not None: + def add(self, *commands: Command | Group) -> None: + if self.app: self.app.commands.add(*commands) self._commands.update(commands) if self.on_change: self.on_change() - def clear(self): + def clear(self) -> None: self._commands = set() if self.on_change: self.on_change() @property - def app(self) -> App: + def app(self) -> App | None: return self._app def __len__(self) -> int: return len(self._commands) - def __iter__(self) -> Command | Separator: + def __iter__(self) -> Iterator[Command | Separator]: cmd_iter = iter(sorted(self._commands)) - def descendant(group, ancestor): + def descendant(group: Group, ancestor: Group) -> Group | None: # Return the immediate descendant of ancestor used by this group. if group.parent == ancestor: return group diff --git a/core/src/toga/constants/__init__.py b/core/src/toga/constants/__init__.py index 98a8e302ad..55b836584a 100644 --- a/core/src/toga/constants/__init__.py +++ b/core/src/toga/constants/__init__.py @@ -4,13 +4,15 @@ class Direction(Enum): - "The direction a given property should act" + """The direction a given property should act.""" + HORIZONTAL = 0 VERTICAL = 1 class Baseline(Enum): - "The meaning of a Y coordinate when drawing text." + """The meaning of a Y coordinate when drawing text.""" + ALPHABETIC = auto() #: Alphabetic baseline of the first line TOP = auto() #: Top of text MIDDLE = auto() #: Middle of text @@ -18,6 +20,7 @@ class Baseline(Enum): class FillRule(Enum): - "The rule to use when filling paths." + """The rule to use when filling paths.""" + EVENODD = 0 NONZERO = 1 diff --git a/core/src/toga/documents.py b/core/src/toga/documents.py index a4a02dd7c7..a1122c2c3a 100644 --- a/core/src/toga/documents.py +++ b/core/src/toga/documents.py @@ -4,7 +4,7 @@ import warnings from abc import ABC, abstractmethod from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from toga.app import App @@ -16,7 +16,7 @@ def __init__( self, path: str | Path, document_type: str, - app: App = None, + app: App | None = None, ): """Create a new Document. @@ -27,12 +27,14 @@ def __init__( self._path = Path(path) self._document_type = document_type self._app = app - self._main_window = None + # TODO:PR: how does _main_window get set? + self._main_window: Window | None = None # Create the visual representation of the document self.create() # Create a platform specific implementation of the Document + # TODO:PR: this isn't checking if `app` is `None`...? self._impl = app.factory.Document(interface=self) def can_close(self) -> bool: @@ -46,7 +48,7 @@ def can_close(self) -> bool: """ return True - async def handle_close(self, window, **kwargs): + async def handle_close(self, window: Window, **kwargs: Any) -> bool: """An ``on-close`` handler for the main window of this document that implements platform-specific document close behavior. @@ -82,7 +84,7 @@ def filename(self) -> Path: return self._path @property - def document_type(self) -> Path: + def document_type(self) -> str: """A human-readable description of the document type (read-only).""" return self._document_type @@ -92,12 +94,12 @@ def app(self) -> App: return self._app @property - def main_window(self) -> Window: + def main_window(self) -> Window | None: """The main window for the document.""" return self._main_window @main_window.setter - def main_window(self, window): + def main_window(self, window: Window) -> None: self._main_window = window def show(self) -> None: diff --git a/core/src/toga/fonts.py b/core/src/toga/fonts.py index bbb1042dce..fa24f28b4b 100644 --- a/core/src/toga/fonts.py +++ b/core/src/toga/fonts.py @@ -1,5 +1,7 @@ from __future__ import annotations +from pathlib import Path + # Use the Travertino font definitions as-is from travertino import constants from travertino.constants import ( @@ -68,7 +70,14 @@ def __str__(self) -> str: return f"{self.family} {size}{weight}{variant}{style}" @staticmethod - def register(family, path, *, weight=NORMAL, style=NORMAL, variant=NORMAL): + def register( + family: str, + path: str | Path, + *, + weight: str = NORMAL, + style: str = NORMAL, + variant: str = NORMAL, + ) -> None: """Registers a file-based font. **Note:** This is not currently supported on macOS or iOS. @@ -84,7 +93,12 @@ def register(family, path, *, weight=NORMAL, style=NORMAL, variant=NORMAL): _REGISTERED_FONT_CACHE[font_key] = str(toga.App.app.paths.app / path) @staticmethod - def _registered_font_key(family, weight, style, variant): + def _registered_font_key( + family: str, + weight: str, + style: str, + variant: str, + ) -> tuple[str, str, str, str]: if weight not in constants.FONT_WEIGHTS: weight = NORMAL if style not in constants.FONT_STYLES: diff --git a/core/src/toga/handlers.py b/core/src/toga/handlers.py index 8d345883c6..ee46c7b1bc 100644 --- a/core/src/toga/handlers.py +++ b/core/src/toga/handlers.py @@ -1,12 +1,26 @@ +from __future__ import annotations + import asyncio import inspect import sys import traceback from abc import ABC +from typing import Any, Awaitable, Callable, Generator, TypeVar, Union + +from typing_extensions import TypeAlias + +GeneratorReturnT = TypeVar("GeneratorReturnT", bound=object) +HandlerGeneratorReturnT: TypeAlias = Generator[float, Any, GeneratorReturnT] + +HandlerSyncT: TypeAlias = Callable[..., object] +HandlerAsyncT: TypeAlias = Callable[..., Awaitable[object]] +HandlerGeneratorT: TypeAlias = Callable[..., HandlerGeneratorReturnT] +HandlerT: TypeAlias = Union[HandlerSyncT, HandlerAsyncT, HandlerGeneratorT] +WrappedHandlerT: TypeAlias = Callable[..., object] class NativeHandler: - def __init__(self, handler): + def __init__(self, handler: Callable[..., object]): self.native = handler @@ -47,7 +61,11 @@ async def handler_with_cleanup(handler, cleanup, interface, *args, **kwargs): traceback.print_exc() -def wrapped_handler(interface, handler, cleanup=None): +def wrapped_handler( + interface: Any, + handler: HandlerT | None, + cleanup: HandlerSyncT | None = None, +) -> WrappedHandlerT: """Wrap a handler provided by the user, so it can be invoked. If the handler is a NativeHandler, return the handler object contained in the @@ -112,6 +130,8 @@ def _handler(*args, **kwargs): class AsyncResult(ABC): + RESULT_TYPE: str + def __init__(self): loop = asyncio.get_event_loop() self.future = loop.create_future() diff --git a/core/src/toga/images.py b/core/src/toga/images.py index 7db9e15859..a56ac9e428 100644 --- a/core/src/toga/images.py +++ b/core/src/toga/images.py @@ -36,8 +36,8 @@ def __init__( | PIL.Image.Image | None = None, *, - path=None, # DEPRECATED - data=None, # DEPRECATED + path: None = None, # DEPRECATED + data: None = None, # DEPRECATED ): """Create a new image. @@ -61,14 +61,14 @@ def __init__( "Image.__init__() missing 1 required positional argument: 'src'" ) if path is not None: - src = path + src = path # type: ignore[unreachable] warn( "Path argument is deprecated, use src instead.", DeprecationWarning, stacklevel=2, ) elif data is not None: - src = data + src = data # type: ignore[unreachable] warn( "Data argument is deprecated, use src instead.", DeprecationWarning, @@ -103,9 +103,9 @@ def __init__( raise TypeError("Unsupported source type for Image") @property - def size(self) -> (int, int): + def size(self) -> tuple[int, int]: """The size of the image, as a (width, height) tuple.""" - return (self._impl.get_width(), self._impl.get_height()) + return self._impl.get_width(), self._impl.get_height() @property def width(self) -> int: diff --git a/core/src/toga/keys.py b/core/src/toga/keys.py index 6e78728eb8..d0e8d8abc9 100644 --- a/core/src/toga/keys.py +++ b/core/src/toga/keys.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from enum import Enum @@ -151,7 +153,7 @@ def is_printable(self) -> bool: """Does pressing the key result in a printable character?""" return not (self.value.startswith("<") and self.value.endswith(">")) - def __add__(self, other): + def __add__(self, other: Key | str) -> str: """Allow two Keys to be concatenated, or a string to be concatenated to a Key. Produces a single string definition. @@ -166,6 +168,6 @@ def __add__(self, other): except AttributeError: return self.value + other - def __radd__(self, other): + def __radd__(self, other: str) -> str: """Same as add.""" return other + self.value diff --git a/core/src/toga/paths.py b/core/src/toga/paths.py index a8c38301dd..b55e2c26b4 100644 --- a/core/src/toga/paths.py +++ b/core/src/toga/paths.py @@ -1,4 +1,4 @@ -import importlib +import importlib.util import sys from pathlib import Path @@ -9,7 +9,7 @@ class Paths: def __init__(self): self.factory = get_platform_factory() - self._impl = self.factory.Paths(self) + self._impl: Paths = self.factory.Paths(self) @property def toga(self) -> Path: diff --git a/core/src/toga/platform.py b/core/src/toga/platform.py index 7fb438ad62..42ad0563cd 100644 --- a/core/src/toga/platform.py +++ b/core/src/toga/platform.py @@ -1,11 +1,14 @@ +from __future__ import annotations + import importlib import os import sys from functools import lru_cache +from types import ModuleType -if sys.version_info >= (3, 10): +if sys.version_info >= (3, 10): # pragma: no cover from importlib.metadata import entry_points -else: +else: # pragma: no cover # Before Python 3.10, entry_points did not support the group argument; # so, the backport package must be used on older versions. from importlib_metadata import entry_points @@ -26,7 +29,7 @@ } -def get_current_platform(): +def get_current_platform() -> str | None: # Rely on `sys.getandroidapilevel`, which only exists on Android; see # https://github.com/beeware/Python-Android-support/issues/8 if hasattr(sys, "getandroidapilevel"): @@ -41,7 +44,7 @@ def get_current_platform(): @lru_cache(maxsize=1) -def get_platform_factory(): +def get_platform_factory() -> ModuleType: """Determine the current host platform and import the platform factory. If the ``TOGA_BACKEND`` environment variable is set, the factory will be loaded diff --git a/core/src/toga/style/__init__.py b/core/src/toga/style/__init__.py index 4605b72b2f..b42a55d741 100644 --- a/core/src/toga/style/__init__.py +++ b/core/src/toga/style/__init__.py @@ -1,2 +1,2 @@ from toga.style.applicator import TogaApplicator # noqa: F401 -from toga.style.pack import Pack # noqa: F401 +from toga.style.pack import Pack as Pack # noqa: F401 diff --git a/core/src/toga/style/pack.py b/core/src/toga/style/pack.py index adbe22e497..83c5acbde7 100644 --- a/core/src/toga/style/pack.py +++ b/core/src/toga/style/pack.py @@ -2,7 +2,7 @@ BOLD, BOTTOM, CENTER, - COLUMN, + COLUMN as COLUMN, CURSIVE, FANTASY, HIDDEN, @@ -15,7 +15,7 @@ NORMAL, OBLIQUE, RIGHT, - ROW, + ROW as ROW, RTL, SANS_SERIF, SERIF, diff --git a/core/src/toga/validators.py b/core/src/toga/validators.py index a68cb6e21e..83ae650919 100644 --- a/core/src/toga/validators.py +++ b/core/src/toga/validators.py @@ -30,7 +30,6 @@ def is_valid(self, input_string: str) -> bool: :param input_string: The string to validate. :returns: ``True`` if the input is valid. """ - ... class CountValidator: @@ -83,7 +82,6 @@ def count(self, input_string: str) -> int: :param input_string: The string to inspect for content of interest. :returns: The number of instances of content that the validator is looking for. """ - ... class LengthBetween(BooleanValidator): @@ -105,8 +103,8 @@ def __init__( ``True`` """ if error_message is None: - error_message = "Input should be between {} and {} characters".format( - min_length, max_length + error_message = ( + f"Input should be between {min_length} and {max_length} characters" ) super().__init__(error_message=error_message, allow_empty=allow_empty) @@ -146,9 +144,7 @@ def __init__( ``True`` """ if error_message is None: - error_message = "Input is too short (length should be at least {})".format( - length - ) + error_message = f"Input is too short (length should be at least {length})" super().__init__( min_length=length, max_length=None, @@ -170,9 +166,7 @@ def __init__( string is too long. """ if error_message is None: - error_message = "Input is too long (length should be at most {})".format( - length - ) + error_message = f"Input is too long (length should be at most {length})" super().__init__( min_length=None, max_length=length, @@ -294,7 +288,7 @@ def __init__( class MatchRegex(BooleanValidator): def __init__( self, - regex_string, + regex_string: str, error_message: str | None = None, allow_empty: bool = True, ): @@ -430,9 +424,7 @@ def __init__( else: expected_existence_message = "Input should contain at least one digit" expected_non_existence_message = "Input should not contain digits" - expected_count_message = "Input should contain exactly {} digits".format( - count - ) + expected_count_message = f"Input should contain exactly {count} digits" super().__init__( count=count, diff --git a/core/src/toga/window.py b/core/src/toga/window.py index 936e447f1e..cd802edb11 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -2,12 +2,14 @@ import warnings from builtins import id as identifier -from collections.abc import Mapping, MutableSet +from collections.abc import Mapping from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, overload +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, Union, overload -from toga.command import Command, CommandSet -from toga.handlers import AsyncResult, wrapped_handler +from typing_extensions import TypeAlias + +from toga.command import CommandSet +from toga.handlers import AsyncResult, HandlerGeneratorReturnT, wrapped_handler from toga.images import Image from toga.platform import get_platform_factory from toga.widgets.base import WidgetRegistry @@ -18,35 +20,61 @@ from toga.widgets.base import Widget -class OnCloseHandler(Protocol): - def __call__(self, window: Window, **kwargs: Any) -> bool: +class OnCloseHandlerSync(Protocol): + def __call__(self, window: Window, /) -> bool: """A handler to invoke when a window is about to close. The return value of this callback controls whether the window is allowed to close. This can be used to prevent a window closing with unsaved changes, etc. :param window: The window instance that is closing. - :param kwargs: Ensures compatibility with additional arguments introduced in - future versions. - :returns: ``True`` if the window is allowed to close; ``False`` if the window is not - allowed to close. + :returns: :any:`True` if the window is allowed to close; :any:`False` if the + window is not allowed to close. """ - ... -T = TypeVar("T") +class OnCloseHandlerAsync(Protocol): + async def __call__(self, window: Window, /) -> bool: + """Async definition of :any:`OnCloseHandlerSync`.""" + + +class OnCloseHandlerGenerator(Protocol): + def __call__(self, window: Window, /) -> HandlerGeneratorReturnT[bool]: + """Generator definition of :any:`OnCloseHandlerSync`.""" + +OnCloseHandlerT: TypeAlias = Union[ + OnCloseHandlerSync, OnCloseHandlerAsync, OnCloseHandlerGenerator +] +_DialogResultT = TypeVar("_DialogResultT", bound=type) -class DialogResultHandler(Protocol[T]): - def __call__(self, window: Window, result: T, **kwargs: Any) -> None: + +class DialogResultHandlerSync(Protocol[_DialogResultT]): + def __call__(self, window: Window, result: _DialogResultT, /) -> Any: """A handler to invoke when a dialog is closed. :param window: The window that opened the dialog. :param result: The result returned by the dialog. - :param kwargs: Ensures compatibility with additional arguments introduced in - future versions. """ - ... + + +class DialogResultHandlerAsync(Protocol[_DialogResultT]): + async def __call__(self, window: Window, result: _DialogResultT, /) -> Any: + """Async definition of :any:`DialogResultHandlerSync`.""" + + +class DialogResultHandlerGenerator(Protocol[_DialogResultT]): + def __call__( + self, window: Window, result: _DialogResultT, / + ) -> HandlerGeneratorReturnT[Any]: + """Generator definition of :any:`DialogResultHandlerSync`.""" + + +DialogResultHandlerT: TypeAlias = Union[ + DialogResultHandlerSync[_DialogResultT], + DialogResultHandlerAsync[_DialogResultT], + DialogResultHandlerGenerator[_DialogResultT], +] class Dialog(AsyncResult): @@ -70,7 +98,7 @@ def __init__( resizable: bool = True, closable: bool = True, minimizable: bool = True, - on_close: OnCloseHandler | None = None, + on_close: OnCloseHandlerT | None = None, resizeable=None, # DEPRECATED closeable=None, # DEPRECATED ) -> None: @@ -202,7 +230,7 @@ def minimizable(self) -> bool: return self._minimizable @property - def toolbar(self) -> MutableSet[Command]: + def toolbar(self) -> CommandSet: """Toolbar for the window.""" return self._toolbar @@ -303,12 +331,12 @@ def visible(self, visible: bool) -> None: self.hide() @property - def on_close(self) -> OnCloseHandler: + def on_close(self) -> OnCloseHandlerT: """The handler to invoke if the user attempts to close the window.""" return self._on_close @on_close.setter - def on_close(self, handler: OnCloseHandler | None) -> None: + def on_close(self, handler: OnCloseHandlerT | None) -> None: def cleanup(window: Window, should_close: bool) -> None: if should_close or handler is None: window.close() @@ -347,7 +375,7 @@ def info_dialog( self, title: str, message: str, - on_result: DialogResultHandler[None] | None = None, + on_result: DialogResultHandlerT[None] | None = None, ) -> Dialog: """Ask the user to acknowledge some information. @@ -370,7 +398,7 @@ def question_dialog( self, title: str, message: str, - on_result: DialogResultHandler[bool] | None = None, + on_result: DialogResultHandlerT[bool] | None = None, ) -> Dialog: """Ask the user a yes/no question. @@ -394,7 +422,7 @@ def confirm_dialog( self, title: str, message: str, - on_result: DialogResultHandler[bool] | None = None, + on_result: DialogResultHandlerT[bool] | None = None, ) -> Dialog: """Ask the user to confirm if they wish to proceed with an action. @@ -419,7 +447,7 @@ def error_dialog( self, title: str, message: str, - on_result: DialogResultHandler[None] | None = None, + on_result: DialogResultHandlerT[None] | None = None, ) -> Dialog: """Ask the user to acknowledge an error state. @@ -445,7 +473,7 @@ def stack_trace_dialog( message: str, content: str, retry: Literal[False] = False, - on_result: DialogResultHandler[None] | None = None, + on_result: DialogResultHandlerT[None] | None = None, ) -> Dialog: ... @@ -456,7 +484,7 @@ def stack_trace_dialog( message: str, content: str, retry: Literal[True] = False, - on_result: DialogResultHandler[bool] | None = None, + on_result: DialogResultHandlerT[bool] | None = None, ) -> Dialog: ... @@ -467,7 +495,7 @@ def stack_trace_dialog( message: str, content: str, retry: bool = False, - on_result: DialogResultHandler[bool | None] | None = None, + on_result: DialogResultHandlerT[bool | None] | None = None, ) -> Dialog: ... @@ -477,7 +505,7 @@ def stack_trace_dialog( message: str, content: str, retry: bool = False, - on_result: DialogResultHandler[bool | None] | None = None, + on_result: DialogResultHandlerT[bool | None] | None = None, ) -> Dialog: """Open a dialog to display a large block of text, such as a stack trace. @@ -509,7 +537,7 @@ def save_file_dialog( title: str, suggested_filename: Path | str, file_types: list[str] | None = None, - on_result: DialogResultHandler[Path | None] | None = None, + on_result: DialogResultHandlerT[Path | None] | None = None, ) -> Dialog: """Prompt the user for a location to save a file. @@ -551,7 +579,7 @@ def open_file_dialog( initial_directory: Path | str | None = None, file_types: list[str] | None = None, multiple_select: Literal[False] = False, - on_result: DialogResultHandler[Path | None] | None = None, + on_result: DialogResultHandlerT[Path | None] | None = None, multiselect=None, # DEPRECATED ) -> Dialog: ... @@ -563,7 +591,7 @@ def open_file_dialog( initial_directory: Path | str | None = None, file_types: list[str] | None = None, multiple_select: Literal[True] = True, - on_result: DialogResultHandler[list[Path] | None] | None = None, + on_result: DialogResultHandlerT[list[Path] | None] | None = None, multiselect=None, # DEPRECATED ) -> Dialog: ... @@ -575,7 +603,7 @@ def open_file_dialog( initial_directory: Path | str | None = None, file_types: list[str] | None = None, multiple_select: bool = False, - on_result: DialogResultHandler[list[Path] | Path | None] | None = None, + on_result: DialogResultHandlerT[list[Path] | Path | None] | None = None, multiselect=None, # DEPRECATED ) -> Dialog: ... @@ -586,7 +614,7 @@ def open_file_dialog( initial_directory: Path | str | None = None, file_types: list[str] | None = None, multiple_select: bool = False, - on_result: DialogResultHandler[list[Path] | Path | None] | None = None, + on_result: DialogResultHandlerT[list[Path] | Path | None] | None = None, multiselect=None, # DEPRECATED ) -> Dialog: """Prompt the user to select a file (or files) to open. @@ -639,7 +667,7 @@ def select_folder_dialog( title: str, initial_directory: Path | str | None = None, multiple_select: Literal[False] = False, - on_result: DialogResultHandler[Path | None] | None = None, + on_result: DialogResultHandlerT[Path | None] | None = None, multiselect=None, # DEPRECATED ) -> Dialog: ... @@ -650,7 +678,7 @@ def select_folder_dialog( title: str, initial_directory: Path | str | None = None, multiple_select: Literal[True] = True, - on_result: DialogResultHandler[list[Path] | None] | None = None, + on_result: DialogResultHandlerT[list[Path] | None] | None = None, multiselect=None, # DEPRECATED ) -> Dialog: ... @@ -661,7 +689,7 @@ def select_folder_dialog( title: str, initial_directory: Path | str | None = None, multiple_select: bool = False, - on_result: DialogResultHandler[list[Path] | Path | None] | None = None, + on_result: DialogResultHandlerT[list[Path] | Path | None] | None = None, multiselect=None, # DEPRECATED ) -> Dialog: ... @@ -671,7 +699,7 @@ def select_folder_dialog( title: str, initial_directory: Path | str | None = None, multiple_select: bool = False, - on_result: DialogResultHandler[list[Path] | Path | None] | None = None, + on_result: DialogResultHandlerT[list[Path] | Path | None] | None = None, multiselect=None, # DEPRECATED ) -> Dialog: """Prompt the user to select a directory (or directories). @@ -719,7 +747,6 @@ def select_folder_dialog( ###################################################################### # 2023-08: Backwards compatibility ###################################################################### - @property def resizeable(self) -> bool: """**DEPRECATED** Use :attr:`resizable`""" @@ -737,3 +764,7 @@ def closeable(self) -> bool: DeprecationWarning, ) return self._closable + + ###################################################################### + # End Backwards compatibility + ###################################################################### diff --git a/docs/reference/api/app.rst b/docs/reference/api/app.rst index d0c097a974..96c871c16a 100644 --- a/docs/reference/api/app.rst +++ b/docs/reference/api/app.rst @@ -77,5 +77,9 @@ Reference :exclude-members: app .. autoprotocol:: toga.app.AppStartupMethod -.. autoprotocol:: toga.app.BackgroundTask -.. autoprotocol:: toga.app.OnExitHandler +.. autoprotocol:: toga.app.BackgroundTaskSync +.. autoprotocol:: toga.app.BackgroundTaskAsync +.. autoprotocol:: toga.app.BackgroundTaskGenerator +.. autoprotocol:: toga.app.OnExitHandlerSync +.. autoprotocol:: toga.app.OnExitHandlerAsync +.. autoprotocol:: toga.app.OnExitHandlerGenerator diff --git a/docs/reference/api/resources/command.rst b/docs/reference/api/resources/command.rst index 9eb805f59e..4b8a2bbe4a 100644 --- a/docs/reference/api/resources/command.rst +++ b/docs/reference/api/resources/command.rst @@ -83,4 +83,6 @@ Reference .. autoclass:: toga.Group :exclude-members: key -.. autoprotocol:: toga.command.ActionHandler +.. autoprotocol:: toga.command.ActionHandlerSync +.. autoprotocol:: toga.command.ActionHandlerAsync +.. autoprotocol:: toga.command.ActionHandlerGenerator diff --git a/docs/reference/api/window.rst b/docs/reference/api/window.rst index 2f5cb2a368..c73803c063 100644 --- a/docs/reference/api/window.rst +++ b/docs/reference/api/window.rst @@ -42,14 +42,14 @@ An operating system-managed container of widgets. Usage ----- -A window is the top-level container that the operating system uses to display widgets. A -window may also have other decorations, such as a title bar or toolbar. +A window is the top-level container that the operating system uses to display widgets. +A window may also have other decorations, such as a title bar or toolbar. When first created, a window is not visible. To display it, call the :meth:`~toga.Window.show` method. The window has content, which will usually be a container widget of some kind. The -content of the window can be changed by re-assigning its `content` attribute to a +content of the window can be changed by re-assigning its ``content`` attribute to a different widget. .. code-block:: python @@ -96,5 +96,10 @@ Reference .. autoclass:: toga.Window -.. autoprotocol:: toga.window.OnCloseHandler -.. autoprotocol:: toga.window.DialogResultHandler +.. autoprotocol:: toga.window.Dialog +.. autoprotocol:: toga.window.OnCloseHandlerSync +.. autoprotocol:: toga.window.OnCloseHandlerAsync +.. autoprotocol:: toga.window.OnCloseHandlerGenerator +.. autoprotocol:: toga.window.DialogResultHandlerSync +.. autoprotocol:: toga.window.DialogResultHandlerAsync +.. autoprotocol:: toga.window.DialogResultHandlerGenerator diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index 293ed0cb6f..7c1e1deedf 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -3,6 +3,7 @@ accessors amongst App apps +Async awaitable backend backends diff --git a/pyproject.toml b/pyproject.toml index 07b9ddd886..d19d4b6565 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,3 +72,27 @@ type = [ # We're not doing anything Python-related at the root level of the repo, but if this # declaration isn't here, tox commands run from the root directory raise a warning that # pyproject.toml doesn't contain a setuptools_scm section. + +[tool.mypy] +python_version = "3.8" +check_untyped_defs = true +disallow_any_decorated = false +disallow_any_explicit = false +disallow_any_expr = false +disallow_any_generics = true +disallow_any_unimported = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +implicit_reexport = false +no_implicit_optional = true +no_warn_no_return = true +strict_equality = true +strict = true +warn_redundant_casts = true +warn_return_any = true +warn_unreachable = true +warn_unused_configs = true +warn_unused_ignores = true diff --git a/tox.ini b/tox.ini index 74d3135ea8..54d03f2469 100644 --- a/tox.ini +++ b/tox.ini @@ -6,9 +6,19 @@ extend-ignore = # See https://github.com/PyCQA/pycodestyle/issues/373 E203, +[tox] +envlist = towncrier-check,docs-lint,pre-commit,py{38,39,310,311,312} +skip_missing_interpreters = True + +[testenv:pre-commit] +skip_install = True +deps = ./core[dev] +commands = pre-commit run --all-files --show-diff-on-failure --color=always + # The leading comma generates the "py" environment. [testenv:py{,38,39,310,311,312}] skip_install = True +depends: pre-commit setenv = TOGA_BACKEND = toga_dummy changedir = core @@ -21,20 +31,14 @@ commands = coverage combine coverage report --rcfile ../pyproject.toml -[testenv:towncrier-check] -skip_install = True -deps = - {[testenv:towncrier]deps} -commands = - python -m towncrier.check --compare-with origin/main - -[testenv:towncrier] +[testenv:towncrier{,-check}] skip_install = True deps = towncrier ~= 22.8 ./core commands = - towncrier {posargs} + check : python -m towncrier.check --compare-with origin/main + !check : python -m towncrier {posargs} [docs] build_dir = _build @@ -52,8 +56,7 @@ sphinx_args_extra = {[docs]sphinx_args} -v -E -T -a -d {envtmpdir}/doctrees [testenv:docs{,-lint,-all}] skip_install = True change_dir = docs -deps = - ./core[docs] +deps = ./core[docs] passenv = # On macOS M1, you need to manually set the location of the PyEnchant # library: