Skip to content

Commit c84df3e

Browse files
authored
Support for Extended Tasks (#907)
1 parent 77c73e4 commit c84df3e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+15071
-9790
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414

1515
### New features
1616

17+
* Added `shiny.ui.input_task_button()` for creating buttons that launch longer-running tasks than `shiny.ui.input_action_button()` was designed for. Task buttons give visual feedback that the task is running, and cannot be clicked again until the task is complete. (#907)
18+
19+
* Added `@extended_task` decorator for creating long-running tasks that can be cancelled. (#907)
20+
1721
* Added `@render.download` as a replacement for `@session.download`, which is now deprecated. (#977)
1822

1923
* Added `ui.output_code()`, which is currently an alias for `ui.output_text_verbatim()`. (#997)
2024

2125
* Added `@render.code`, which is an alias for `@render.text`, but in Express mode, it displays the result using `ui.output_code()`. (#997)
2226

27+
* Added `App.on_shutdown` method for registering a callback to be called when the app is shutting down. (#907)
28+
2329
### Bug fixes
2430

2531
* CLI command `shiny create`... (#965)

js/build.ts

+5
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ const opts: Array<BuildOptions> = [
6161
minify: false,
6262
sourcemap: false,
6363
},
64+
{
65+
entryPoints: { "spin/spin": "spin/spin.scss" },
66+
plugins: [sassPlugin({ type: "css", sourceMap: false })],
67+
metafile: true,
68+
},
6469
];
6570

6671
// Run function to avoid top level await

js/spin/spin.scss

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
@keyframes py-shiny-spin {
2+
0% {
3+
transform: rotate(0deg);
4+
}
5+
100% {
6+
transform: rotate(360deg);
7+
}
8+
}
9+
10+
@media (prefers-reduced-motion: no-preference) {
11+
.py-shiny-spin {
12+
animation: py-shiny-spin 2s linear infinite;
13+
}
14+
}

scripts/htmlDependencies.R

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ versions <- list()
1111
message("Installing GitHub packages: bslib, shiny, htmltools")
1212
withr::local_temp_libpaths()
1313
ignore <- capture.output({
14-
pak::pkg_install(c("rstudio/bslib", "cran::shiny", "cran::htmltools"))
14+
pak::pkg_install(c("rstudio/bslib@v0.6.1-plus-layout-columns-and-task-button", "rstudio/shiny@main", "cran::htmltools"))
1515
#pak::pkg_install(c("rstudio/bslib@main", "rstudio/shiny@main", "rstudio/htmltools@main"))
1616
})
1717

setup.cfg

+1
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ profile=black
132132
skip=
133133
__init__.py
134134
typings/
135+
_dev/
135136
.venv
136137
venv
137138
.tox

shiny/_app.py

+35-1
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
import copy
44
import os
55
import secrets
6+
from contextlib import AsyncExitStack, asynccontextmanager
67
from inspect import signature
78
from pathlib import Path
8-
from typing import Any, Callable, Optional, cast
9+
from typing import Any, Callable, Optional, TypeVar, cast
910

1011
import starlette.applications
1112
import starlette.exceptions
@@ -33,6 +34,8 @@
3334
from .http_staticfiles import FileResponse, StaticFiles
3435
from .session import Inputs, Outputs, Session, session_context
3536

37+
T = TypeVar("T")
38+
3639
# Default values for App options.
3740
LIB_PREFIX: str = "lib/"
3841
SANITIZE_ERRORS: bool = False
@@ -109,6 +112,10 @@ def __init__(
109112
static_assets: Optional["str" | "os.PathLike[str]" | dict[str, Path]] = None,
110113
debug: bool = False,
111114
) -> None:
115+
# Used to store callbacks to be called when the app is shutting down (according
116+
# to the ASGI lifespan protocol)
117+
self._exit_stack = AsyncExitStack()
118+
112119
if server is None:
113120
self.server = noop_server_fn
114121
elif len(signature(server).parameters) == 1:
@@ -208,10 +215,16 @@ def init_starlette_app(self) -> starlette.applications.Starlette:
208215
starlette_app = starlette.applications.Starlette(
209216
routes=routes,
210217
middleware=middleware,
218+
lifespan=self._lifespan,
211219
)
212220

213221
return starlette_app
214222

223+
@asynccontextmanager
224+
async def _lifespan(self, app: starlette.applications.Starlette):
225+
async with self._exit_stack:
226+
yield
227+
215228
def _create_session(self, conn: Connection) -> Session:
216229
id = secrets.token_hex(32)
217230
session = Session(self, id, conn, debug=self._debug)
@@ -243,6 +256,27 @@ def run(self, **kwargs: object) -> None:
243256
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
244257
await self.starlette_app(scope, receive, send)
245258

259+
def on_shutdown(self, callback: Callable[[], None]) -> Callable[[], None]:
260+
"""
261+
Register a callback to be called when the app is shutting down. This can be
262+
useful for cleaning up app-wide resources, like connection pools, temporary
263+
directories, worker threads/processes, etc.
264+
265+
Parameters
266+
----------
267+
callback
268+
The callback to call. It should take no arguments, and any return value will
269+
be ignored. Try not to raise an exception in the callback, as exceptions
270+
during cleanup can hide the original exception that caused the app to shut
271+
down.
272+
273+
Returns
274+
-------
275+
:
276+
The callback, to allow this method to be used as a decorator.
277+
"""
278+
return self._exit_stack.callback(callback)
279+
246280
async def call_pyodide(self, scope: Scope, receive: Receive, send: Send) -> None:
247281
"""
248282
Communicate with pyodide.

shiny/_datastructures.py

+8-4
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,14 @@ def put(self, priority: int, item: T) -> None:
2424
"""
2525
Add an item to the queue.
2626
27-
Parameters:
28-
priority (int): The priority of the item. Higher priority items will
29-
come out of the queue before lower priority items.
30-
item (T): The item to put in the queue.
27+
Parameters
28+
----------
29+
priority
30+
The priority of the item. Higher priority items will come out of the queue
31+
before lower priority items.
32+
33+
item
34+
The item to put in the queue.
3135
"""
3236
self._counter += 1
3337
self._pq.put((-priority, self._counter, item))

shiny/_main.py

+1
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ def run_app(
346346
log_config=log_config,
347347
app_dir=app_dir,
348348
factory=factory,
349+
lifespan="on",
349350
**reload_args, # pyright: ignore[reportGeneralTypeIssues]
350351
**kwargs,
351352
)

shiny/_validation.py

+12-6
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,29 @@
11
from __future__ import annotations
22

3-
from typing import TypeVar, overload
3+
from typing import Literal, TypeVar, overload
44

55
from ._docstring import add_example
6-
from .types import SilentCancelOutputException, SilentException
6+
from .types import (
7+
SilentCancelOutputException,
8+
SilentException,
9+
SilentOperationInProgressException,
10+
)
711

812
T = TypeVar("T")
913

1014

1115
@overload
12-
def req(*, cancel_output: bool = False) -> None:
16+
def req(*, cancel_output: bool | Literal["progress"] = False) -> None:
1317
...
1418

1519

1620
@overload
17-
def req(*args: T, cancel_output: bool = False) -> T:
21+
def req(*args: T, cancel_output: bool | Literal["progress"] = False) -> T:
1822
...
1923

2024

2125
@add_example()
22-
def req(*args: T, cancel_output: bool = False) -> T | None:
26+
def req(*args: T, cancel_output: bool | Literal["progress"] = False) -> T | None:
2327
"""
2428
Throw a silent exception for falsy values.
2529
@@ -51,8 +55,10 @@ def req(*args: T, cancel_output: bool = False) -> T | None:
5155

5256
for arg in args:
5357
if not arg:
54-
if cancel_output:
58+
if cancel_output is True:
5559
raise SilentCancelOutputException()
60+
elif cancel_output == "progress":
61+
raise SilentOperationInProgressException()
5662
else:
5763
raise SilentException()
5864

shiny/_versions.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
shiny_html_deps = "1.8.0"
2-
bslib = "0.6.1.9000"
1+
shiny_html_deps = "1.8.0.9000"
2+
bslib = "0.6.1.9001"
33
htmltools = "0.5.7"
44
bootstrap = "5.3.1"
55
requirejs = "2.3.6"
+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import asyncio
2+
from datetime import datetime
3+
4+
from shiny import reactive, render
5+
from shiny.express import input, ui
6+
7+
ui.h5("Current time")
8+
9+
10+
@render.text
11+
def current_time():
12+
reactive.invalidate_later(1)
13+
return datetime.now().strftime("%H:%M:%S")
14+
15+
16+
with ui.p():
17+
"Notice that the time above updates every second, even if you click the button below."
18+
19+
20+
@ui.bind_task_button(button_id="btn")
21+
@reactive.extended_task
22+
async def slow_compute(a: int, b: int) -> int:
23+
await asyncio.sleep(3)
24+
return a + b
25+
26+
27+
with ui.layout_sidebar():
28+
with ui.sidebar():
29+
ui.input_numeric("x", "x", 1)
30+
ui.input_numeric("y", "y", 2)
31+
ui.input_task_button("btn", "Compute, slowly")
32+
ui.input_action_button("btn_cancel", "Cancel")
33+
34+
@reactive.Effect
35+
@reactive.event(input.btn, ignore_none=False)
36+
def handle_click():
37+
# slow_compute.cancel()
38+
slow_compute(input.x(), input.y())
39+
40+
@reactive.Effect
41+
@reactive.event(input.btn_cancel)
42+
def handle_cancel():
43+
slow_compute.cancel()
44+
45+
ui.h5("Sum of x and y")
46+
47+
@render.text
48+
def show_result():
49+
return str(slow_compute.result())

shiny/express/ui/__init__.py

+6
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@
5959
input_select,
6060
input_selectize,
6161
input_slider,
62+
bind_task_button,
63+
input_task_button,
6264
input_text,
6365
input_text_area,
6466
insert_accordion_panel,
@@ -78,6 +80,7 @@
7880
update_select,
7981
update_selectize,
8082
update_slider,
83+
update_task_button,
8184
update_text,
8285
update_text_area,
8386
update_navs,
@@ -190,6 +193,8 @@
190193
"input_select",
191194
"input_selectize",
192195
"input_slider",
196+
"bind_task_button",
197+
"input_task_button",
193198
"input_text",
194199
"input_text_area",
195200
"insert_accordion_panel",
@@ -209,6 +214,7 @@
209214
"update_select",
210215
"update_selectize",
211216
"update_slider",
217+
"update_task_button",
212218
"update_text",
213219
"update_text_area",
214220
"update_navs",

shiny/reactive/__init__.py

+5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from ._core import ( # noqa: F401
2+
Context,
23
isolate,
34
invalidate_later,
45
flush,
@@ -19,9 +20,11 @@
1920
Effect_, # pyright: ignore[reportUnusedImport]
2021
event,
2122
)
23+
from ._extended_task import ExtendedTask, extended_task
2224

2325

2426
__all__ = (
27+
"Context",
2528
"isolate",
2629
"invalidate_later",
2730
"flush",
@@ -36,4 +39,6 @@
3639
"effect",
3740
"Effect",
3841
"event",
42+
"ExtendedTask",
43+
"extended_task",
3944
)

0 commit comments

Comments
 (0)