diff --git a/CHANGELOG.md b/CHANGELOG.md index 5103430aaa..2115bb6695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ * Add `--log-format {rich,plain,github}` (`FLET_CLI_LOG_FORMAT`) to `flet build`, `flet debug` and `flet test`. `github` prints plain output plus GitHub Actions workflow commands: a collapsible group per build step and annotations for warnings and errors; `plain` is the same as `--no-rich-output`. See [Log formats](/docs/cli/flet-build#log-formats) ([#6947](https://github.com/flet-dev/flet/issues/6947)) by @FeodorFitsner. * Install `uvicorn[standard]` with `flet-web` again on Windows on ARM with Python 3.12 or later, now that `httptools` 0.9.0 ships wheels for it; Python 3.10 and 3.11 there keep plain `uvicorn`, because PyYAML has no wheels for them ([#6927](https://github.com/flet-dev/flet/pull/6927)) by @ndonkoHenri. * Update python-build to `20261016` and serious_python to `6.0.1`, bringing CPython `3.12.15`, `3.13.16` and `3.14.8`. The bundled Python runtime, its native modules and dart_bridge `1.11.0` are now built for iOS 15 and macOS 12, the same minimums as the build template ([#6940](https://github.com/flet-dev/flet/issues/6940), [python-build#46](https://github.com/flet-dev/python-build/pull/46), [serious-python#256](https://github.com/flet-dev/serious-python/pull/256), [serious-python#257](https://github.com/flet-dev/serious-python/pull/257)) by @FeodorFitsner. +* Make the future returned by `page.run_task()` awaitable, so `await page.run_task(handler)` returns the value of `handler` ([#6515](https://github.com/flet-dev/flet/issues/6515), [#7008](https://github.com/flet-dev/flet/pull/7008)) by @ndonkoHenri. ### Bug fixes diff --git a/sdk/python/packages/flet/src/flet/controls/page.py b/sdk/python/packages/flet/src/flet/controls/page.py index 908ea4f98b..44913a646e 100644 --- a/sdk/python/packages/flet/src/flet/controls/page.py +++ b/sdk/python/packages/flet/src/flet/controls/page.py @@ -4,8 +4,8 @@ import sys import threading import weakref -from collections.abc import Awaitable, Coroutine -from concurrent.futures import CancelledError, Future, ThreadPoolExecutor +from collections.abc import Awaitable, Coroutine, Generator +from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import InitVar, dataclass, field, fields, is_dataclass from functools import partial from typing import ( @@ -16,6 +16,7 @@ Optional, ParamSpec, TypeVar, + cast, ) from urllib.parse import urlparse @@ -91,6 +92,26 @@ def _default_authorization_impl() -> "type[Authorization]": RetT = TypeVar("RetT") +class TaskFuture(Future[RetT]): + """ + The future of a task started with `Page.run_task()`. + + It is a `concurrent.futures.Future` that can also be awaited, from the page's + event loop or any other one: `await` returns the task's value or raises its + exception. Awaiting doesn't consume it: the outcome stays available through + `result()` and `exception()`. + + Cancelling the coroutine that awaits it also cancels the task, as with an + `asyncio.Task`; await `asyncio.shield()` of it to keep the task running. + `result()` blocks, so call it on the page's event loop thread only once the + task is done. + """ + + def __await__(self) -> Generator[Any, None, RetT]: + """Wait for the task to finish without blocking the event loop.""" + return asyncio.wrap_future(self).__await__() + + @control("ServiceRegistry") class ServiceRegistry(Service): """ @@ -943,10 +964,33 @@ def run_task( handler: Callable[InputT, Awaitable[RetT]], *args: InputT.args, **kwargs: InputT.kwargs, - ) -> Future[RetT]: + ) -> TaskFuture[RetT]: """ Run `handler` coroutine as a new Task in the event loop associated with the \ current page. + + Can be called from any thread. + + Args: + handler: The coroutine function to run. + *args: Positional arguments to call `handler` with. + **kwargs: Keyword arguments to call `handler` with. + + Returns: + A future of the task. Awaiting it returns the value of `handler`, or + raises its exception. The future keeps that outcome, so keep a + reference to it to call `cancel()`, `result()` or + `add_done_callback()` later. + + Raises: + TypeError: If `handler` is not a coroutine function. + + Example: + ```python + future = page.run_task(fetch_users) + users = await future # the value, not a future + future.result() # the same value, without blocking + ``` """ _context_page.set(self) if not inspect.iscoroutinefunction(handler): @@ -955,25 +999,31 @@ def run_task( future = asyncio.run_coroutine_threadsafe( handler(*args, **kwargs), self.session.connection.loop ) + future.__class__ = TaskFuture def _on_completion(f): """ - Surface background task exceptions to default error handling. + Log the exception of a task nobody awaits. + + `concurrent.futures` logs an `Exception` raised from a done callback + and runs the next one. The task's outcome is stored on the future + and reaches awaiters through their own callback, so raising here is + only for the log. A `BaseException` is not re-raised: it would skip + the remaining callbacks and leave awaiters waiting forever. Args: f: Completed future returned by `run_coroutine_threadsafe()`. """ - try: - exception = f.exception() - if exception: - raise exception - except CancelledError: - pass + if f.cancelled(): + return + exception = f.exception() + if isinstance(exception, Exception): + raise exception future.add_done_callback(_on_completion) - return future + return cast(TaskFuture[RetT], future) def __context_wrapper(self, handler: Callable[..., Any]) -> Wrapper: """ diff --git a/website/docs/cookbook/async-apps.md b/website/docs/cookbook/async-apps.md index 371b6b87be..7554aec56c 100644 --- a/website/docs/cookbook/async-apps.md +++ b/website/docs/cookbook/async-apps.md @@ -194,6 +194,24 @@ not `page.run_task(self.update_timer())`. It raises `TypeError` otherwise. `will_unmount()`, as above. `did_mount()` and `will_unmount()` are sync, which is why background work is started with `run_task()` rather than awaited there. +In async code, you can also `await` the future to get the coroutine's return value, +or the exception it raised. For example, to start two tasks and wait for both: + +```python +async def load(e): + users = page.run_task(fetch_users) + orders = page.run_task(fetch_orders) + show(await users, await orders) +``` + +The future keeps the outcome after you await it, so keep a reference to it if you +also need to cancel the task or check it later. As with `asyncio` tasks, +cancelling the coroutine that awaits the future also cancels the task. + +If nobody awaits the future, an exception raised by the task is logged, in your +terminal or in the [console log](../publish/index.md#console-output) of a built +app, rather than lost. + ## Threading Some libraries have no async version. To keep a blocking call off the loop, run it