Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
72 changes: 61 additions & 11 deletions sdk/python/packages/flet/src/flet/controls/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -16,6 +16,7 @@
Optional,
ParamSpec,
TypeVar,
cast,
)
from urllib.parse import urlparse

Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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):
Expand All @@ -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:
"""
Expand Down
18 changes: 18 additions & 0 deletions website/docs/cookbook/async-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading