From 113f87cdb292ca21c147d2d97ef923c8b747ab6d Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Mon, 12 Oct 2026 20:00:54 +0200 Subject: [PATCH 1/4] Make the future returned by page.run_task() awaitable run_task() returned a concurrent.futures.Future, which a coroutine can't await. It now returns a TaskFuture, a Future subclass whose __await__ wraps asyncio.wrap_future(), so `await page.run_task(...)` returns the handler's value on any event loop while the existing Future API keeps working. As with asyncio tasks, cancelling the awaiting coroutine cancels the task. --- CHANGELOG.md | 1 + .../packages/flet/src/flet/controls/page.py | 42 +++++++++++++++++-- website/docs/cookbook/async-apps.md | 13 ++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5103430aaa..004ac26dd6 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)) 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..e67c7cfa5b 100644 --- a/sdk/python/packages/flet/src/flet/controls/page.py +++ b/sdk/python/packages/flet/src/flet/controls/page.py @@ -4,7 +4,7 @@ import sys import threading import weakref -from collections.abc import Awaitable, Coroutine +from collections.abc import Awaitable, Coroutine, Generator from concurrent.futures import CancelledError, Future, ThreadPoolExecutor from dataclasses import InitVar, dataclass, field, fields, is_dataclass from functools import partial @@ -16,6 +16,7 @@ Optional, ParamSpec, TypeVar, + cast, ) from urllib.parse import urlparse @@ -91,6 +92,25 @@ def _default_authorization_impl() -> "type[Authorization]": RetT = TypeVar("RetT") +class TaskFuture(Future[RetT]): + """ + A `concurrent.futures.Future` of a task started with `Page.run_task()` that + can also be awaited. + + Awaiting it returns the value of the task or raises its exception, from the + page's event loop or any other one. Cancelling the coroutine that awaits it + also cancels the task, as awaiting an `asyncio.Task` does; await + `asyncio.shield()` of it to keep the task running. + + Blocking methods, such as `result()`, are meant for other threads: called on + the page's event loop thread, they block the loop that runs the task. + """ + + 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 +963,25 @@ 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. Await it to get the value of `handler`, or use + its `concurrent.futures.Future` methods, such as `cancel()` and + `add_done_callback()`. + + Raises: + TypeError: If `handler` is not a coroutine function. """ _context_page.set(self) if not inspect.iscoroutinefunction(handler): @@ -955,6 +990,7 @@ def run_task( future = asyncio.run_coroutine_threadsafe( handler(*args, **kwargs), self.session.connection.loop ) + future.__class__ = TaskFuture def _on_completion(f): """ @@ -973,7 +1009,7 @@ def _on_completion(f): 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..17c09d342f 100644 --- a/website/docs/cookbook/async-apps.md +++ b/website/docs/cookbook/async-apps.md @@ -194,6 +194,19 @@ 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) +``` + +As with `asyncio` tasks, cancelling the coroutine that awaits the future also +cancels the task. + ## Threading Some libraries have no async version. To keep a blocking call off the loop, run it From 224307008464b6302aa17e2ea05cb95c4605c114 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Tue, 27 Oct 2026 01:45:18 +0100 Subject: [PATCH 2/4] Keep run_task() awaiters from hanging on a BaseException run_task()'s done callback re-raised the task's exception so that concurrent.futures logs it. That logger only catches Exception: a BaseException raised there, such as the asyncio.CancelledError asyncio makes from a handler's concurrent.futures.CancelledError, skipped the remaining done callbacks, including the one that wakes a coroutine awaiting the future, so the await never returned. Re-raise only Exception subclasses. The outcome is stored on the future and reaches awaiters through their own callback either way. --- .../packages/flet/src/flet/controls/page.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/sdk/python/packages/flet/src/flet/controls/page.py b/sdk/python/packages/flet/src/flet/controls/page.py index e67c7cfa5b..5d9fa0d89f 100644 --- a/sdk/python/packages/flet/src/flet/controls/page.py +++ b/sdk/python/packages/flet/src/flet/controls/page.py @@ -5,7 +5,7 @@ import threading import weakref from collections.abc import Awaitable, Coroutine, Generator -from concurrent.futures import CancelledError, Future, ThreadPoolExecutor +from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import InitVar, dataclass, field, fields, is_dataclass from functools import partial from typing import ( @@ -996,16 +996,20 @@ def _on_completion(f): """ Surface background task exceptions to default error handling. + Only `Exception`s are re-raised: `concurrent.futures` logs those and + goes on to the next done callback, while a `BaseException` raised + here would skip the remaining callbacks, including the one that + wakes a coroutine awaiting the future. + 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) From 1aad04e87afde11a03141e274ca6dc0ddbf593db Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Tue, 27 Oct 2026 01:47:33 +0100 Subject: [PATCH 3/4] Document how run_task() futures deliver their outcome Say in the TaskFuture and run_task() docstrings that awaiting returns the value or raises, and that the future keeps its outcome afterwards, with an example. Explain in _on_completion why it re-raises only Exception, and in the async-apps cookbook that an un-awaited task's exception is logged. --- .../packages/flet/src/flet/controls/page.py | 42 ++++++++++++------- website/docs/cookbook/async-apps.md | 9 +++- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/sdk/python/packages/flet/src/flet/controls/page.py b/sdk/python/packages/flet/src/flet/controls/page.py index 5d9fa0d89f..44913a646e 100644 --- a/sdk/python/packages/flet/src/flet/controls/page.py +++ b/sdk/python/packages/flet/src/flet/controls/page.py @@ -94,16 +94,17 @@ def _default_authorization_impl() -> "type[Authorization]": class TaskFuture(Future[RetT]): """ - A `concurrent.futures.Future` of a task started with `Page.run_task()` that - can also be awaited. + The future of a task started with `Page.run_task()`. - Awaiting it returns the value of the task or raises its exception, from the - page's event loop or any other one. Cancelling the coroutine that awaits it - also cancels the task, as awaiting an `asyncio.Task` does; await - `asyncio.shield()` of it to keep the task running. + 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()`. - Blocking methods, such as `result()`, are meant for other threads: called on - the page's event loop thread, they block the loop that runs the task. + 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]: @@ -976,12 +977,20 @@ def run_task( **kwargs: Keyword arguments to call `handler` with. Returns: - A future of the task. Await it to get the value of `handler`, or use - its `concurrent.futures.Future` methods, such as `cancel()` and - `add_done_callback()`. + 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): @@ -994,12 +1003,13 @@ def run_task( def _on_completion(f): """ - Surface background task exceptions to default error handling. + Log the exception of a task nobody awaits. - Only `Exception`s are re-raised: `concurrent.futures` logs those and - goes on to the next done callback, while a `BaseException` raised - here would skip the remaining callbacks, including the one that - wakes a coroutine awaiting the future. + `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()`. diff --git a/website/docs/cookbook/async-apps.md b/website/docs/cookbook/async-apps.md index 17c09d342f..7554aec56c 100644 --- a/website/docs/cookbook/async-apps.md +++ b/website/docs/cookbook/async-apps.md @@ -204,8 +204,13 @@ async def load(e): show(await users, await orders) ``` -As with `asyncio` tasks, cancelling the coroutine that awaits the future also -cancels the task. +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 From 5997835fd3f5a99946ca099a8785c4b1f8e0849c Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Tue, 27 Oct 2026 01:52:14 +0100 Subject: [PATCH 4/4] Link PR #7008 in the changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 004ac26dd6..2115bb6695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +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)) by @ndonkoHenri. +* 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