From 452a014af0f266c42d3c74dc4f01bed425558296 Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Fri, 5 Jan 2024 21:38:41 -0600 Subject: [PATCH 1/7] Add render.download --- shiny/render/__init__.py | 2 + shiny/render/_render.py | 104 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 1 deletion(-) 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..7e7a1b1c3 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,101 @@ 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. 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 `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 From 03a5eda5263f61ae71c3ed11a0686d60bc8f1c6b Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Fri, 5 Jan 2024 21:42:39 -0600 Subject: [PATCH 2/7] Update doc references --- shiny/session/_session.py | 2 +- shiny/ui/_download_button.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 79ffdd8b7..52bd59766 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -456,7 +456,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, diff --git a/shiny/ui/_download_button.py b/shiny/ui/_download_button.py index be0e92fd4..b62a05a4d 100644 --- a/shiny/ui/_download_button.py +++ b/shiny/ui/_download_button.py @@ -41,7 +41,7 @@ def download_button( See Also -------- - ~shiny.Session.download + ~shiny.render.download ~shiny.ui.download_link """ @@ -96,7 +96,7 @@ def download_link( See Also -------- - ~shiny.Session.download + ~shiny.render.download ~shiny.ui.download_link """ From b5550a17e802826e7cfc954c76531d5e172fa27d Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Fri, 5 Jan 2024 21:43:05 -0600 Subject: [PATCH 3/7] Update examples to use render.download --- examples/annotation-export/app.py | 2 +- shiny/api-examples/download/app.py | 13 +++++++------ shiny/api-examples/download_button/app.py | 9 ++++----- shiny/api-examples/download_link/app.py | 9 ++++----- tests/playwright/shiny/bugs/0696-resolve-id/app.py | 4 ++-- 5 files changed, 18 insertions(+), 19 deletions(-) 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/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 From 5e7e7c2bc6f3a26d324f086c04e5d3429e8991da Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Wed, 10 Jan 2024 14:26:49 -0600 Subject: [PATCH 4/7] Add deprecation message to session.download --- shiny/session/_session.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 52bd59766..c433a8224 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 @@ -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 :func:`~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__ From f70cf882cc4942ea394aa1850d5a7e4546523b39 Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Wed, 10 Jan 2024 14:27:20 -0600 Subject: [PATCH 5/7] Update changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) 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) From aa882f37b5b9d61249f5933f33410b41372d86db Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Wed, 10 Jan 2024 15:13:22 -0600 Subject: [PATCH 6/7] Update docstring --- shiny/render/_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 7e7a1b1c3..d593595d6 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -510,7 +510,7 @@ class download(Renderer[str]): filename The filename of the download. label - A label for the button. Defaults to "Download". + A label for the button, when used in Express mode. Defaults to "Download". media_type The media type of the download. encoding From 556db97cda64b12369fe15ceae8b03ce9e00427b Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Wed, 10 Jan 2024 15:38:37 -0600 Subject: [PATCH 7/7] Documentation fixes --- shiny/render/_render.py | 3 ++- shiny/session/_session.py | 2 +- shiny/ui/_download_button.py | 8 ++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index d593595d6..5d03a2a6d 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -575,7 +575,8 @@ def url() -> str: url.__name__ = fn.__name__ # We invoke `super().__call__()` now, because it indirectly invokes - # `Outputs.__call__()`, which sets `self.name`, which is then used below. + # `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 diff --git a/shiny/session/_session.py b/shiny/session/_session.py index c433a8224..f2618aaa3 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -749,7 +749,7 @@ def download( encoding: str = "utf-8", ) -> Callable[[DownloadHandler], None]: """ - Deprecated. Please use :func:`~shiny.render.download` instead. + Deprecated. Please use :class:`~shiny.render.download` instead. Parameters ---------- diff --git a/shiny/ui/_download_button.py b/shiny/ui/_download_button.py index b62a05a4d..52ab48d1c 100644 --- a/shiny/ui/_download_button.py +++ b/shiny/ui/_download_button.py @@ -41,8 +41,8 @@ def download_button( See Also -------- - ~shiny.render.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.render.download - ~shiny.ui.download_link + * :class:`~shiny.render.download` + * :func:`~shiny.ui.download_button` """ return tags.a(