diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a96a2617..d4939566b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [UNRELEASED] + +### New features + +* Added `@render.download` as a replacement for `@session.download`, which is now deprecated. (#977) + ### Bug fixes * CLI command `shiny create`... (#965) diff --git a/examples/annotation-export/app.py b/examples/annotation-export/app.py index 601b4116c..3d6e2ce12 100644 --- a/examples/annotation-export/app.py +++ b/examples/annotation-export/app.py @@ -109,7 +109,7 @@ def annotations(): df = df.loc[df["annotation"] != ""] return df - @session.download(filename="data.csv") + @render.download(filename="data.csv") def download(): yield annotated_data().to_csv() diff --git a/shiny/api-examples/download/app.py b/shiny/api-examples/download/app.py index 7b06ab09c..b88161867 100644 --- a/shiny/api-examples/download/app.py +++ b/shiny/api-examples/download/app.py @@ -7,7 +7,7 @@ import matplotlib.pyplot as plt import numpy as np -from shiny import App, Inputs, Outputs, Session, ui +from shiny import App, Inputs, Outputs, Session, render, ui def make_example(id: str, label: str, title: str, desc: str, extra: Any = None): @@ -77,7 +77,7 @@ def make_example(id: str, label: str, title: str, desc: str, extra: Any = None): def server(input: Inputs, output: Outputs, session: Session): - @session.download() + @render.download() def download1(): """ This is the simplest case. The implementation simply returns the name of a file. @@ -88,12 +88,12 @@ def download1(): path = os.path.join(os.path.dirname(__file__), "mtcars.csv") return path - @session.download(filename="image.png") + @render.download(filename="image.png") def download2(): """ Another way to implement a file download is by yielding bytes; either all at once, like in this case, or by yielding multiple times. When using this - approach, you should pass a filename argument to @session.download, which + approach, you should pass a filename argument to @render.download, which determines what the browser will name the downloaded file. """ @@ -107,7 +107,7 @@ def download2(): plt.savefig(buf, format="png") yield buf.getvalue() - @session.download( + @render.download( filename=lambda: f"新型-{date.today().isoformat()}-{np.random.randint(100,999)}.csv" ) async def download3(): @@ -116,7 +116,8 @@ async def download3(): yield "新,1,2\n" yield "型,4,5\n" - @session.download(id="download4", filename="failuretest.txt") + @output(id="download4") + @render.download(filename="failuretest.txt") async def _(): yield "hello" raise Exception("This error was caused intentionally") diff --git a/shiny/api-examples/download_button/app.py b/shiny/api-examples/download_button/app.py index 07f55f1f6..520fa934d 100644 --- a/shiny/api-examples/download_button/app.py +++ b/shiny/api-examples/download_button/app.py @@ -1,9 +1,8 @@ import asyncio +import random from datetime import date -import numpy as np - -from shiny import App, Inputs, Outputs, Session, ui +from shiny import App, Inputs, Outputs, Session, render, ui app_ui = ui.page_fluid( ui.download_button("downloadData", "Download"), @@ -11,8 +10,8 @@ def server(input: Inputs, output: Outputs, session: Session): - @session.download( - filename=lambda: f"新型-{date.today().isoformat()}-{np.random.randint(100,999)}.csv" + @render.download( + filename=lambda: f"新型-{date.today().isoformat()}-{random.randint(100,999)}.csv" ) async def downloadData(): await asyncio.sleep(0.25) diff --git a/shiny/api-examples/download_link/app.py b/shiny/api-examples/download_link/app.py index 9038f6643..408b38761 100644 --- a/shiny/api-examples/download_link/app.py +++ b/shiny/api-examples/download_link/app.py @@ -1,9 +1,8 @@ import asyncio +import random from datetime import date -import numpy as np - -from shiny import App, Inputs, Outputs, Session, ui +from shiny import App, Inputs, Outputs, Session, render, ui app_ui = ui.page_fluid( ui.download_link("downloadData", "Download"), @@ -11,8 +10,8 @@ def server(input: Inputs, output: Outputs, session: Session): - @session.download( - filename=lambda: f"新型-{date.today().isoformat()}-{np.random.randint(100,999)}.csv" + @render.download( + filename=lambda: f"新型-{date.today().isoformat()}-{random.randint(100,999)}.csv" ) async def downloadData(): await asyncio.sleep(0.25) diff --git a/shiny/render/__init__.py b/shiny/render/__init__.py index ffd4460a5..ce47f4f64 100644 --- a/shiny/render/__init__.py +++ b/shiny/render/__init__.py @@ -20,6 +20,7 @@ table, text, ui, + download, ) __all__ = ( @@ -31,6 +32,7 @@ "image", "table", "ui", + "download", "DataGrid", "DataTable", ) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index df9b5b57d..5d03a2a6d 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -9,6 +9,7 @@ # Can use `dict` in python >= 3.9 from typing import ( TYPE_CHECKING, + Callable, Literal, Optional, Protocol, @@ -26,7 +27,9 @@ from .. import _utils from .. import ui as _ui from .._namespaces import ResolvedId -from ..session import require_active_session +from .._typing_extensions import Self +from ..session import get_current_session, require_active_session +from ..session._session import DownloadHandler, DownloadInfo from ..types import MISSING, MISSING_TYPE, ImgData from ._try_render_plot import ( PlotSizeInfo, @@ -47,6 +50,7 @@ "image", "table", "ui", + "download", ) # ====================================================================================== # RenderText @@ -492,3 +496,102 @@ async def transform(self, value: TagChild) -> Jsonifiable: return rendered_deps_to_jsonifiable( session._process_ui(value), ) + + +# ====================================================================================== +# RenderDownload +# ====================================================================================== +class download(Renderer[str]): + """ + Decorator to register a function to handle a download. + + Parameters + ---------- + filename + The filename of the download. + label + A label for the button, when used in Express mode. Defaults to "Download". + media_type + The media type of the download. + encoding + The encoding of the download. + + Returns + ------- + : + The decorated function. + + See Also + -------- + ~shiny.ui.download_button + """ + + def default_ui(self, id: str) -> Tag: + return _ui.download_button(id, label=self.label) + + def __init__( + self, + fn: Optional[DownloadHandler] = None, + *, + filename: Optional[str | Callable[[], str]] = None, + label: TagChild = "Download", + media_type: None | str | Callable[[], str] = None, + encoding: str = "utf-8", + ) -> None: + super().__init__() + + self.label = label + self.filename = filename + self.media_type = media_type + self.encoding = encoding + + if fn is not None: + self(fn) + + def __call__( # pyright: ignore[reportIncompatibleMethodOverride] + self, + fn: DownloadHandler, + ) -> Self: + # For downloads, the value function (which is passed to `__call__()`) is + # different than for other renderers. For normal renderers, the user supplies + # the value function. This function returns a value which is transformed, + # serialized to JSON, and then sent to the browser. + # + # For downloads, the download button itself is actually an output. The value + # that it renders is a URL; when the user clicks the button, the browser + # initiates a download from that URL, and the server provides the file via + # `session._downloads`. + # + # The `url()` function here is the value function for the download button. It + # returns the URL for downloading the file. + def url() -> str: + from urllib.parse import quote + + session = require_active_session(None) + return f"session/{quote(session.id)}/download/{quote(self.output_id)}?w=" + + # Unlike most value functions, this one's name is `url`. But we want to get the + # name from the user-supplied function. + url.__name__ = fn.__name__ + + # We invoke `super().__call__()` now, because it indirectly invokes + # `Outputs.__call__()`, which sets `output_id` (and `self.__name__`), which is + # then used below. + super().__call__(url) + + # Register the download handler for the session. The reason we check for session + # not being None is because in Express, when the UI is rendered, this function + # `render.download()()` called once before any sessions have been started. + session = get_current_session() + if session is not None: + session._downloads[self.output_id] = DownloadInfo( + filename=self.filename, + content_type=self.media_type, + handler=fn, + encoding=self.encoding, + ) + + return self + + async def transform(self, value: str) -> Jsonifiable: + return value diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 79ffdd8b7..f2618aaa3 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -39,6 +39,7 @@ from .. import _utils, reactive, render from .._connection import Connection, ConnectionClosed +from .._deprecated import warn_deprecated from .._docstring import add_example from .._fileupload import FileInfo, FileUploadManager from .._namespaces import Id, ResolvedId, Root @@ -456,7 +457,7 @@ async def _handle_request( warnings.warn( "Unable to infer a filename for the " f"'{download_id}' download handler; please use " - "@session.download(filename=) to specify one " + "@render.download(filename=) to specify one " "manually", SessionWarning, stacklevel=2, @@ -748,7 +749,7 @@ def download( encoding: str = "utf-8", ) -> Callable[[DownloadHandler], None]: """ - Decorator to register a function to handle a download. + Deprecated. Please use :class:`~shiny.render.download` instead. Parameters ---------- @@ -767,6 +768,10 @@ def download( The decorated function. """ + warn_deprecated( + "session.download() is deprecated. Please use render.download() instead." + ) + def wrapper(fn: DownloadHandler): effective_name = id or fn.__name__ diff --git a/shiny/ui/_download_button.py b/shiny/ui/_download_button.py index be0e92fd4..52ab48d1c 100644 --- a/shiny/ui/_download_button.py +++ b/shiny/ui/_download_button.py @@ -41,8 +41,8 @@ def download_button( See Also -------- - ~shiny.Session.download - ~shiny.ui.download_link + * :class:`~shiny.render.download` + * :func:`~shiny.ui.download_link` """ return tags.a( @@ -96,8 +96,8 @@ def download_link( See Also -------- - ~shiny.Session.download - ~shiny.ui.download_link + * :class:`~shiny.render.download` + * :func:`~shiny.ui.download_button` """ return tags.a( diff --git a/tests/playwright/shiny/bugs/0696-resolve-id/app.py b/tests/playwright/shiny/bugs/0696-resolve-id/app.py index 179e26a7b..e59d7cbde 100644 --- a/tests/playwright/shiny/bugs/0696-resolve-id/app.py +++ b/tests/playwright/shiny/bugs/0696-resolve-id/app.py @@ -258,7 +258,7 @@ def out_ui(): download_button_count = 0 - @session.download(filename=lambda: f"download_button-{session.ns}.csv") + @render.download(filename=lambda: f"download_button-{session.ns}.csv") async def download_button(): nonlocal download_button_count download_button_count += 1 @@ -267,7 +267,7 @@ async def download_button(): download_link_count = 0 - @session.download(filename=lambda: f"download_link-{session.ns}.csv") + @render.download(filename=lambda: f"download_link-{session.ns}.csv") async def download_link(): nonlocal download_link_count download_link_count += 1