Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Typing updates #2252

Merged
merged 14 commits into from
Jun 11, 2024
1 change: 1 addition & 0 deletions changes/2252.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The typing for Toga's API surface was updated to be more precise.
7 changes: 5 additions & 2 deletions core/src/toga/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from __future__ import annotations

import warnings
from pathlib import Path

from .app import App, DocumentApp, DocumentMainWindow, MainWindow

Expand Down Expand Up @@ -49,7 +52,7 @@ class NotImplementedWarning(RuntimeWarning):
# single argument (the warning message). Use a factory method to avoid reproducing
# the message format and the warn invocation.
@classmethod
def warn(self, platform, feature):
def warn(cls, platform: str, feature: str) -> None:
"""Raise a warning that a feature isn't implemented on a platform."""
warnings.warn(NotImplementedWarning(f"[{platform}] Not implemented: {feature}"))

Expand Down Expand Up @@ -114,7 +117,7 @@ def warn(self, platform, feature):
]


def _package_version(file, name):
def _package_version(file: Path | str | None, name: str) -> str:
try:
# Read version from SCM metadata
# This will only exist in a development environment
Expand Down
110 changes: 49 additions & 61 deletions core/src/toga/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,14 @@
import sys
import warnings
import webbrowser
from collections.abc import (
Collection,
ItemsView,
Iterator,
KeysView,
Mapping,
MutableSet,
ValuesView,
)
from collections.abc import Iterator
from email.message import Message
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
from typing import TYPE_CHECKING, Any, MutableSet, Protocol
from warnings import warn
from weakref import WeakValueDictionary

from toga.command import Command, CommandSet
from toga.command import CommandSet
from toga.documents import Document
from toga.handlers import wrapped_handler
from toga.hardware.camera import Camera
Expand All @@ -31,7 +23,7 @@
from toga.platform import get_platform_factory
from toga.screens import Screen
from toga.widgets.base import Widget
from toga.window import Window
from toga.window import OnCloseHandler, Window

if TYPE_CHECKING:
from toga.icons import IconContent
Expand All @@ -51,7 +43,6 @@ def __call__(self, app: App, **kwargs: Any) -> Widget:
future versions.
:returns: The widget to use as the main window content.
"""
...


class OnExitHandler(Protocol):
Expand All @@ -67,21 +58,19 @@ def __call__(self, app: App, **kwargs: Any) -> bool:
:returns: ``True`` if the app is allowed to exit; ``False`` if the app is not
allowed to exit.
"""
...


class BackgroundTask(Protocol):
def __call__(self, app: App, **kwargs: Any) -> None:
def __call__(self, app: App, **kwargs: Any) -> object:
"""Code that should 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 WindowSet(MutableSet[Window]):
def __init__(self, app: App):
"""A collection of windows managed by an app.

Expand All @@ -90,7 +79,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):
Expand All @@ -111,7 +100,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",
Expand All @@ -120,7 +109,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(
Expand All @@ -134,10 +123,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:
Expand All @@ -153,7 +142,7 @@ class WidgetRegistry:
# values()) are all proxied to underlying data store. Private methods exist for
# internal use, but those methods shouldn't be used by end-users.

def __init__(self, *args, **kwargs):
def __init__(self, *args: Any, **kwargs: Any):
self._registry = WeakValueDictionary(*args, **kwargs)

def __len__(self) -> int:
Expand All @@ -169,19 +158,15 @@ def __iter__(self) -> Iterator[Widget]:
return self.values()

def __repr__(self) -> str:
return (
"{"
+ ", ".join(f"{k!r}: {v!r}" for k, v in sorted(self._registry.items()))
+ "}"
)
return f"{{{', '.join(f'{k!r}: {v!r}' for k, v in sorted(self._registry.items()))}}}"

def items(self) -> ItemsView:
def items(self) -> Iterator[tuple[str, Widget]]:
return self._registry.items()

def keys(self) -> KeysView:
def keys(self) -> Iterator[str]:
return self._registry.keys()

def values(self) -> ValuesView:
def values(self) -> Iterator[Widget]:
return self._registry.values()

# Private methods for internal use
Expand Down Expand Up @@ -211,8 +196,8 @@ def __init__(
resizable: bool = True,
minimizable: bool = True,
content: Widget | None = None,
resizeable=None, # DEPRECATED
closeable=None, # DEPRECATED
resizeable: None = None, # DEPRECATED
closeable: None = None, # DEPRECATED
):
"""Create a new main window.

Expand Down Expand Up @@ -260,7 +245,7 @@ def on_close(self) -> None:
return None

@on_close.setter
def on_close(self, handler: Any):
def on_close(self, handler: OnCloseHandler | None) -> None:
if handler:
raise ValueError(
"Cannot set on_close handler for the main window. "
Expand All @@ -286,7 +271,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.
Expand Down Expand Up @@ -314,7 +299,10 @@ def _default_title(self) -> str:
class App:
#: The currently running :class:`~toga.App`. Since there can only be one running
#: Toga app in a process, this is available as a class property via ``toga.App.app``.
app: App = None
app: App
_impl: Any
_camera: Camera
_location: Location

def __init__(
self,
Expand All @@ -329,8 +317,8 @@ def __init__(
description: str | None = None,
startup: AppStartupMethod | None = None,
on_exit: OnExitHandler | None = None,
id=None, # DEPRECATED
windows=None, # DEPRECATED
id: None = None, # DEPRECATED
windows: None = None, # DEPRECATED
):
"""Create a new App instance.

Expand Down Expand Up @@ -443,7 +431,7 @@ def __init__(
if app_id:
self._app_id = app_id
else:
self._app_id = self.metadata.get("App-ID", None)
self._app_id = self.metadata.get("App-ID")
if self._app_id is None:
raise RuntimeError("Toga application must have an app ID")

Expand Down Expand Up @@ -481,24 +469,24 @@ 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):
return self.factory.App(interface=self)
def _create_impl(self) -> None:
self.factory.App(interface=self)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All that's an interesting oddity... I've never noticed that we're not actually using the result of the factory call, relying on the factory to set the attribute on the interface.

Not something we need to fix here, but I'll remember to clean this up when I'm landing the #2244 changes.


######################################################################
# App properties
Expand Down Expand Up @@ -627,23 +615,23 @@ def main_loop(self) -> None:
self._impl.main_loop()

@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)

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. "
"Does your startup() method assign a value to self.main_window?"
)

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()
Expand Down Expand Up @@ -680,7 +668,7 @@ def camera(self) -> Camera:
return self._camera

@property
def commands(self) -> MutableSet[Command]:
def commands(self) -> CommandSet:
"""The commands available in the app."""
return self._commands

Expand Down Expand Up @@ -712,7 +700,7 @@ def screens(self) -> list[Screen]:
return [screen.interface for screen in self._impl.get_screens()]

@property
def widgets(self) -> Mapping[str, Widget]:
def widgets(self) -> WidgetRegistry:
"""The widgets managed by the app, over all windows.

Can be used to look up widgets by ID over the entire app (e.g.,
Expand All @@ -725,7 +713,7 @@ def widgets(self) -> Mapping[str, Widget]:
return self._widgets

@property
def windows(self) -> Collection[Window]:
def windows(self) -> WindowSet:
"""The windows managed by the app. Windows are automatically added to the app
when they are created, and removed when they are closed."""
return self._windows
Expand Down Expand Up @@ -779,7 +767,7 @@ def current_window(self) -> Window | None:
return window.interface

@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)

Expand Down Expand Up @@ -825,7 +813,7 @@ def on_exit(self) -> OnExitHandler:

@on_exit.setter
def on_exit(self, handler: OnExitHandler | None) -> None:
def cleanup(app, should_exit):
def cleanup(app: App, should_exit: bool) -> None:
if should_exit or handler is None:
app.exit()

Expand All @@ -847,7 +835,7 @@ def name(self) -> str:

# 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'")

Expand All @@ -869,9 +857,9 @@ def __init__(
home_page: str | None = None,
description: str | None = None,
startup: AppStartupMethod | None = None,
document_types: dict[str, type[Document]] = None,
document_types: dict[str, type[Document]] | None = None,
on_exit: OnExitHandler | None = None,
id=None, # DEPRECATED
id: None = None, # DEPRECATED
):
"""Create a document-based application.

Expand All @@ -885,7 +873,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,
Expand All @@ -901,10 +889,10 @@ def __init__(
id=id,
)

def _create_impl(self):
return self.factory.DocumentApp(interface=self)
def _create_impl(self) -> None:
self.factory.DocumentApp(interface=self)

def _verify_startup(self):
def _verify_startup(self) -> None:
# No post-startup validation required for DocumentApps
pass

Expand All @@ -930,7 +918,7 @@ 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.
Expand Down
Loading
Loading