From cb569baae15764d39ae28dd28c75c7c3831ec318 Mon Sep 17 00:00:00 2001 From: Russell Martin Date: Wed, 29 Nov 2023 19:26:29 -0500 Subject: [PATCH] [POC] Typing ergonomics and guarantees --- changes/{2246.misc.txt => 2246.misc.rst} | 0 changes/2252.misc.rst | 1 + core/pyproject.toml | 2 + core/src/toga/app.py | 89 +++++++++++------- core/src/toga/colors.py | 1 + core/src/toga/command.py | 76 +++++++++------- core/src/toga/constants/__init__.py | 9 +- core/src/toga/handlers.py | 8 ++ core/src/toga/images.py | 4 +- core/src/toga/platform.py | 4 +- core/src/toga/style/__init__.py | 2 +- core/src/toga/style/pack.py | 4 +- core/src/toga/window.py | 109 +++++++++++++++-------- docs/reference/api/app.rst | 8 +- docs/reference/api/resources/command.rst | 4 +- docs/reference/api/window.rst | 15 ++-- docs/spelling_wordlist | 1 + pyproject.toml | 24 +++++ tox.ini | 25 +++--- 19 files changed, 255 insertions(+), 131 deletions(-) rename changes/{2246.misc.txt => 2246.misc.rst} (100%) create mode 100644 changes/2252.misc.rst 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 08fc115012..7c12b04239 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.3", + "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..9b700187f1 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -8,12 +8,14 @@ import webbrowser from collections.abc import Collection, Iterator, Mapping, MutableSet from email.message import Message -from typing import Any, Protocol +from typing import Any, 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 HandlerGeneratorT, wrapped_handler from toga.icons import Icon from toga.paths import Paths from toga.platform import get_platform_factory @@ -25,44 +27,65 @@ 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, /) -> HandlerGeneratorT[bool]: + """Generator definition of :any:`OnExitHandlerSync`.""" + + +OnExitHandlerT: TypeAlias = Union[ + OnExitHandlerSync, OnExitHandlerAsync, OnExitHandlerGenerator +] + + +class BackgroundTaskSync(Protocol): + def __call__(self, app: App, /) -> Any: + """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 BackgroundTaskAsync(Protocol): + async def __call__(self, app: App, /) -> Any: + """Async definition of :any:`BackgroundTaskSync`.""" + + +class BackgroundTaskGenerator(Protocol): + def __call__(self, app: App, /) -> HandlerGeneratorT[Any]: + """Generator definition of :any:`BackgroundTaskSync`.""" + + +BackgroundTaskT: TypeAlias = Union[ + BackgroundTaskSync, BackgroundTaskAsync, BackgroundTaskGenerator +] class WindowSet(MutableSet): @@ -74,7 +97,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 +118,7 @@ def discard(self, window: Window) -> None: # 2023-10: Backwards compatibility ###################################################################### - def __iadd__(self, window: Window) -> None: + def __iadd__(self, window: Window) -> WindowSet: # The standard set type does not have a += operator. warn( "Windows are automatically associated with the app; += is not required", @@ -104,7 +127,7 @@ def __iadd__(self, window: Window) -> None: ) return self - def __isub__(self, other: Window) -> None: + def __isub__(self, other: Window) -> WindowSet: # The standard set type does have a -= operator, but it takes sets rather than # individual items. warn( @@ -238,7 +261,7 @@ def _default_title(self) -> str: class App: - app = None + app: App def __init__( self, @@ -252,7 +275,7 @@ def __init__( home_page: str | None = None, description: str | None = None, startup: AppStartupMethod | None = None, - on_exit: OnExitHandler | None = None, + on_exit: OnExitHandlerT | None = None, id=None, # DEPRECATED windows=None, # DEPRECATED ): @@ -542,12 +565,12 @@ 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 @@ -671,12 +694,12 @@ def exit(self) -> None: self._impl.exit() @property - def on_exit(self) -> OnExitHandler: + def on_exit(self) -> OnExitHandlerT: """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 on_exit(self, handler: OnExitHandlerT | None) -> None: def cleanup(app, should_exit): if should_exit or handler is None: app.exit() @@ -690,7 +713,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 +740,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, + document_types: dict[str, type[Document]] | None = None, + on_exit: OnExitHandlerT | None = None, id=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. """ 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..82f98f7a13 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 HandlerGeneratorT, 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, /) -> HandlerGeneratorT[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: str | Path | Icon | None = None, group: Group = Group.COMMANDS, section: int = 0, order: int = 0, @@ -210,7 +227,7 @@ def __init__( 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 @@ -238,7 +255,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): if isinstance(icon_or_name, Icon) or icon_or_name is None: self._icon = icon_or_name else: @@ -254,7 +271,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,9 +289,9 @@ 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 @@ -290,13 +307,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, ): """ @@ -319,7 +335,7 @@ def __init__( self.on_change = on_change def add(self, *commands: Command | Group): - if self.app and self.app is not None: + if self.app: self.app.commands.add(*commands) self._commands.update(commands) if self.on_change: @@ -331,13 +347,13 @@ def clear(self): 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): 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/handlers.py b/core/src/toga/handlers.py index 8d345883c6..925b79d88b 100644 --- a/core/src/toga/handlers.py +++ b/core/src/toga/handlers.py @@ -3,6 +3,12 @@ import sys import traceback from abc import ABC +from typing import Any, Generator, TypeVar + +from typing_extensions import TypeAlias + +GeneratorReturnT = TypeVar("GeneratorReturnT") +HandlerGeneratorT: TypeAlias = Generator[float, Any, GeneratorReturnT] class NativeHandler: @@ -112,6 +118,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..d165e1e7bc 100644 --- a/core/src/toga/images.py +++ b/core/src/toga/images.py @@ -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/platform.py b/core/src/toga/platform.py index 7fb438ad62..db3c009df1 100644 --- a/core/src/toga/platform.py +++ b/core/src/toga/platform.py @@ -3,9 +3,9 @@ import sys from functools import lru_cache -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 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/window.py b/core/src/toga/window.py index 936e447f1e..d9bac86723 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, HandlerGeneratorT, 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, /) -> HandlerGeneratorT[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, / + ) -> HandlerGeneratorT[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..894a3a3062 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 = true +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: