diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f6b8714b..3f6170e80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### New features +* The `@output` decorator is no longer required for rendering functions; `@render.xxx` decorators now register themselves automatically. You can still use `@output` explicitly if you need to set specific output options (#747). +* Added support for integration with Quarto (#746). * Added `shiny.render.renderer_components` decorator to help create new output renderers (#621). * Added `shiny.experimental.ui.popover()`, `update_popover()`, and `toggle_popover()` for easy creation (and server-side updating) of [Bootstrap popovers](https://getbootstrap.com/docs/5.2/components/popovers/). Popovers are similar to tooltips, but are more persistent, and should primarily be used with button-like UI elements (e.g. `input_action_button()` or icons) (#680). * Added CSS classes to UI input methods (#680) . diff --git a/docs/_extensions/quarto-ext/shinylive/README.md b/docs/_extensions/quarto-ext/shinylive/README.md new file mode 100644 index 000000000..55bfed2f6 --- /dev/null +++ b/docs/_extensions/quarto-ext/shinylive/README.md @@ -0,0 +1,126 @@ +# Shinylive package methods + +## Methods + +### R + +Interaction: + +``` +Rscript -e 'shinylive:::quarto_ext()' [methods] [args] +``` + +### Python + +Interaction: + +``` +shinylive [methods] [args] +``` + +## CLI Methods + +* `extension info` + * Package, version, asset version, and script paths information +* `extension base-htmldeps` + * Quarto html dependencies for the base shinylive integration +* `extension language-resources` + * Language specific resource files for the quarto html dependency named `shinylive` +* `extension app-resources` + * App specific resource files for the quarto html dependency named `shinylive` + +### CLI Interface +* `extension info` + * Prints information about the extension including: + * `version`: The version of the R package + * `assets_version`: The version of the web assets + * `scripts`: A list of paths scripts that are used by the extension, + mainly `codeblock-to-json` + * Example + ``` + { + "version": "0.1.0", + "assets_version": "0.2.0", + "scripts": { + "codeblock-to-json": "//shinylive-0.2.0/scripts/codeblock-to-json.js" + } + } + ``` +* `extension base-htmldeps` + * Prints the language agnostic quarto html dependencies as a JSON array. + * The first html dependency is the `shinylive` service workers. + * The second html dependency is the `shinylive` base dependencies. This + dependency will contain the core `shinylive` asset scripts (JS files + automatically sourced), stylesheets (CSS files that are automatically + included), and resources (additional files that the JS and CSS files can + source). + * Example + ``` + [ + { + "name": "shinylive-serviceworker", + "version": "0.2.0", + "meta": { "shinylive:serviceworker_dir": "." }, + "serviceworkers": [ + { + "source": "//shinylive-0.2.0/shinylive-sw.js", + "destination": "/shinylive-sw.js" + } + ] + }, + { + "name": "shinylive", + "version": "0.2.0", + "scripts": [{ + "name": "shinylive/load-shinylive-sw.js", + "path": "//shinylive-0.2.0/shinylive/load-shinylive-sw.js", + "attribs": { "type": "module" } + }], + "stylesheets": [{ + "name": "shinylive/shinylive.css", + "path": "//shinylive-0.2.0/shinylive/shinylive.css" + }], + "resources": [ + { + "name": "shinylive/shinylive.js", + "path": "//shinylive-0.2.0/shinylive/shinylive.js" + }, + ... # [ truncated ] + ] + } + ] + ``` +* `extension language-resources` + * Prints the language-specific resource files as JSON that should be added to the quarto html dependency. + * For r-shinylive, this includes the webr resource files + * For py-shinylive, this includes the pyodide and pyright resource files. + * Example + ``` + [ + { + "name": "shinylive/webr/esbuild.d.ts", + "path": "//shinylive-0.2.0/shinylive/webr/esbuild.d.ts" + }, + { + "name": "shinylive/webr/libRblas.so", + "path": "//shinylive-0.2.0/shinylive/webr/libRblas.so" + }, + ... # [ truncated ] + ] +* `extension app-resources` + * Prints app-specific resource files as JSON that should be added to the `"shinylive"` quarto html dependency. + * Currently, r-shinylive does not return any resource files. + * Example + ``` + [ + { + "name": "shinylive/pyodide/anyio-3.7.0-py3-none-any.whl", + "path": "//shinylive-0.2.0/shinylive/pyodide/anyio-3.7.0-py3-none-any.whl" + }, + { + "name": "shinylive/pyodide/appdirs-1.4.4-py2.py3-none-any.whl", + "path": "//shinylive-0.2.0/shinylive/pyodide/appdirs-1.4.4-py2.py3-none-any.whl" + }, + ... # [ truncated ] + ] + ``` diff --git a/docs/_extensions/quarto-ext/shinylive/_extension.yml b/docs/_extensions/quarto-ext/shinylive/_extension.yml index 764d138cf..01b4d68b1 100644 --- a/docs/_extensions/quarto-ext/shinylive/_extension.yml +++ b/docs/_extensions/quarto-ext/shinylive/_extension.yml @@ -1,7 +1,7 @@ name: shinylive title: Embedded Shinylive applications author: Winston Chang -version: 0.0.3 +version: 0.1.0 quarto-required: ">=1.2.198" contributes: filters: diff --git a/docs/_extensions/quarto-ext/shinylive/shinylive.lua b/docs/_extensions/quarto-ext/shinylive/shinylive.lua index f488d043a..e2828db61 100644 --- a/docs/_extensions/quarto-ext/shinylive/shinylive.lua +++ b/docs/_extensions/quarto-ext/shinylive/shinylive.lua @@ -1,9 +1,51 @@ -local hasDoneShinyliveSetup = false +-- Notes: +-- * 2023/10/04 - Barret: +-- Always use `callShinyLive()` to call a shinylive extension. +-- `callPythonShinyLive()` and `callRShinyLive()` should not be used directly. +-- Instead, always use `callShinyLive()`. +-- * 2023/10/04 - Barret: +-- I could not get `error(msg)` to quit the current function execution and +-- bubble up the stack and stop. Instead, I am using `assert(false, msg)` to +-- achieve the desired behavior. Multi-line error messages should start with a +-- `\n` to keep the message in the same readable area. + + +-- `table` to organize flags to have code only run once. +local hasDoneSetup = { base = false, r = false, python = false, python_version = false } +-- `table` to store `{ version, assets_version }` for each language's extension. +-- If both `r` and `python` are used in the same document, then the +-- `assets_version` for each language must be the same. +local versions = { r = nil, python = nil } +-- Global variable for the codeblock-to-json.js script file location local codeblockScript = nil +-- Global hash table to store app specific dependencies to avoid calling +-- `quarto.doc.attach_to_dependency()` multiple times for the same dependency. +local appSpecificDeps = {} + +-- Display error message and throw error w/ short message +-- @param msg: string Error message to be displayed +-- @param short_msg: string Error message to be thrown +function throw_quarto_error(err_msg, ...) + n = select("#", ...) + if n > 0 then + -- Display any meta information about the error + -- Add blank lines after msg for line separation for better readability + quarto.log.error(...) + else + quarto.log.error(err_msg .. "\n\n") + end + -- Add blank lines after short_msg for line separation for better readability + -- Use assert(false, msg) to quit the current function execution and + -- bubble up the stack and stop. Barret: I could not get this to work with `error(msg)`. + assert(false, err_msg .. "\n") +end --- Try calling `pandoc.pipe('shinylive', ...)` and if it fails, print a message --- about installing shinylive package. -function callShinylive(args, input) +-- Python specific method to call py-shinylive +-- @param args: list of string arguments to pass to py-shinylive +-- @param input: string to pipe into to py-shinylive +function callPythonShinylive(args, input) + -- Try calling `pandoc.pipe('shinylive', ...)` and if it fails, print a message + -- about installing shinylive python package. local res local status, err = pcall( function() @@ -12,88 +54,401 @@ function callShinylive(args, input) ) if not status then - print(err) - error("Error running 'shinylive' command. Perhaps you need to install the 'shinylive' Python package?") + throw_quarto_error( + "Error running 'shinylive' command. Perhaps you need to install / update the 'shinylive' Python package?", + "Error running 'shinylive' command. Perhaps you need to install / update the 'shinylive' Python package?\n", + "Error:\n", + err + ) + end + + return res +end + +-- R specific method to call {r-shinylive} +-- @param args: list of string arguments to pass to r-shinylive +-- @param input: string to pipe into to r-shinylive +function callRShinylive(args, input) + args = { "-e", + "shinylive:::quarto_ext()", + table.unpack(args) } + + -- Try calling `pandoc.pipe('Rscript', ...)` and if it fails, print a message + -- about installing shinylive R package. + local res + local status, err = pcall( + function() + res = pandoc.pipe("Rscript", args, input) + end + ) + + if not status then + throw_quarto_error( + "Error running 'Rscript' command. Perhaps you need to install / update the 'shinylive' R package?", + "Error running 'Rscript' command. Perhaps you need to install / update the 'shinylive' R package?\n", + "Error:\n", + err + ) end return res end +-- Returns decoded object +-- @param language: "python" or "r" +-- @param args, input: see `callPythonShinylive` and `callRShinylive` +function callShinylive(language, args, input, parseJson) + if input == nil then + input = "" + end + if parseJson == nil then + parseJson = true + end + + local res + -- print("Calling " .. language .. " shinylive with args: ", args) + if language == "python" then + res = callPythonShinylive(args, input) + elseif language == "r" then + res = callRShinylive(args, input) + else + throw_quarto_error("internal - Unknown language: " .. language) + end + + if not parseJson then + return res + end + + -- Remove any unwanted output before the first curly brace or square bracket. + -- print("res: " .. string.sub(res, 1, math.min(string.len(res), 100)) .. "...") + local curly_start = string.find(res, "{", 0, true) + local brace_start = string.find(res, "[", 0, true) + local min_start + if curly_start == nil then + min_start = brace_start + elseif brace_start == nil then + min_start = curly_start + else + min_start = math.min(curly_start, brace_start) + end + if min_start == nil then + local res_str = res + if string.len(res) > 100 then + res_str = string.sub(res, 1, 100) .. "... [truncated]" + end + throw_quarto_error( + "Could not find start curly brace or start brace in " .. + language .. " shinylive response. Is JSON being returned from the " .. language .. " `shinylive` package?", + "Could not find start curly brace or start brace in " .. language .. " shinylive response.\n", + "JSON string being parsed:\n", + res_str + ) + end + if min_start > 1 then + res = string.sub(res, min_start) + end + + + -- Decode JSON object + local result + local status, err = pcall( + function() + result = quarto.json.decode(res) + end + ) + if not status then + throw_quarto_error( + "Error decoding JSON response from `shinylive` " .. language .. " package.", + "Error decoding JSON response from `shinylive` " .. language .. " package.\n", + "JSON string being parsed:\n", + res, + "Error:\n", + err + ) + end + return result +end + +function parseVersion(versionTxt) + local versionParts = {} + for part in string.gmatch(versionTxt, "%d+") do + table.insert(versionParts, tonumber(part)) + end + local ret = { + major = nil, + minor = nil, + patch = nil, + extra = nil, + length = #versionParts, + str = versionTxt + } + + if ret.length >= 1 then + ret.major = versionParts[1] + if ret.length >= 2 then + ret.minor = versionParts[2] + if ret.length >= 3 then + ret.patch = versionParts[3] + if ret.length >= 4 then + ret.extra = versionParts[4] + end + end + end + end --- Do one-time setup when a Shinylive codeblock is encountered. -function ensureShinyliveSetup() - if hasDoneShinyliveSetup then + return ret +end + +-- If verA > verB, return 1 +-- If verA == verB, return 0 +-- If verA < verB, return -1 +function compareVersions(verA, verB) + if verA.major == nil or verB.major == nil then + throw_quarto_error("Trying to compare an invalid version: " .. verA.str .. " or " .. verB.str) + end + + for index, key in ipairs({ "major", "minor", "patch", "extra" }) do + local partDiff = compareVersionPart(verA[key], verB[key]) + if partDiff ~= 0 then + return partDiff + end + end + + -- Equal! + return 0 +end + +function compareVersionPart(aPart, bPart) + if aPart == nil and bPart == nil then + return 0 + end + if aPart == nil then + return -1 + end + if bPart == nil then + return 1 + end + if aPart > bPart then + return 1 + elseif aPart < bPart then + return -1 + end + + -- Equal! + return 0 +end + +function ensurePyshinyliveVersion(language) + -- Quit early if not python + if language ~= "python" then + return + end + -- Quit early if already completed check + if hasDoneSetup.python_version then return end - hasDoneShinyliveSetup = true + hasDoneSetup.python_version = true - -- Find the path to codeblock-to-json.ts and save it for later use. - codeblockScript = callShinylive({ "codeblock-to-json-path" }, "") + -- Verify that min python shinylive version is met + pyShinyliveVersion = callShinylive(language, { "--version" }, "", false) -- Remove trailing whitespace - codeblockScript = codeblockScript:gsub("%s+$", "") + pyShinyliveVersion = pyShinyliveVersion:gsub("%s+$", "") + -- Parse version into table + parsedVersion = parseVersion(pyShinyliveVersion) + + -- Verify that the version is at least 0.1.0 + if + (parsedVersion.length < 3) or + -- Major and minor values are 0. Ex: 0.0.18 + (parsedVersion.major == 0 and parsedVersion.minor == 0) + then + assert(false, + "\nThe shinylive Python package must be at least version v0.1.0 to be used in a Quarto document." .. + "\n\nInstalled Python Shinylive package version: " .. pyShinyliveVersion .. + "\n\nPlease upgrade the Python Shinylive package by running:" .. + "\n\tpip install --upgrade shinylive" .. + "\n\n(If you are using a virtual environment, please activate it before running the command above.)" + ) + end +end + +-- Do one-time setup for language agnostic html dependencies. +-- This should only be called once per document +-- @param language: "python" or "r" +function ensureBaseSetup(language) + -- Quit early if already done + if hasDoneSetup.base then + return + end + hasDoneSetup.base = true + + -- Find the path to codeblock-to-json.ts and save it for later use. + local infoObj = callShinylive(language, { "extension", "info" }) + -- Store the path to codeblock-to-json.ts for later use + codeblockScript = infoObj.scripts['codeblock-to-json'] + -- Store the version info for later use + versions[language] = { version = infoObj.version, assets_version = infoObj.assets_version } - local baseDeps = getShinyliveBaseDeps() + -- Add language-agnostic dependencies + local baseDeps = getShinyliveBaseDeps(language) for idx, dep in ipairs(baseDeps) do quarto.doc.add_html_dependency(dep) end + -- Add ext css dependency quarto.doc.add_html_dependency( { name = "shinylive-quarto-css", - stylesheets = {"resources/css/shinylive-quarto.css"} + stylesheets = { "resources/css/shinylive-quarto.css" } } ) end +-- Do one-time setup for language specific html dependencies. +-- This should only be called once per document +-- @param language: "python" or "r" +function ensureLanguageSetup(language) + -- Min version check must be done first + ensurePyshinyliveVersion(language) + + -- Make sure the base setup is done before the langage setup + ensureBaseSetup(language) + + if hasDoneSetup[language] then + return + end + hasDoneSetup[language] = true + + -- Only get the asset version value if it hasn't been retrieved yet. + if versions[language] == nil then + local infoObj = callShinylive(language, { "extension", "info" }) + versions[language] = { version = infoObj.version, assets_version = infoObj.assets_version } + end + -- Verify that the r-shinylive and py-shinylive supported assets versions match + if + (versions.r and versions.python) and + ---@diagnostic disable-next-line: undefined-field + versions.r.assets_version ~= versions.python.assets_version + then + local parsedRAssetsVersion = parseVersion(versions.r.assets_version) + local parsedPythonAssetsVersion = parseVersion(versions.python.assets_version) + + local verDiff = compareVersions(parsedRAssetsVersion, parsedPythonAssetsVersion) + local verDiffStr = "" + if verDiff == 1 then + -- R shinylive supports higher version of assets. Upgrade python shinylive + verDiffStr = + "The currently installed python shinylive package supports a lower assets version, " .. + "therefore we recommend updating your python shinylive package to the latest version." + elseif verDiff == -1 then + -- Python shinylive supports higher version of assets. Upgrade R shinylive + verDiffStr = + "The currently installed R shinylive package supports a lower assets version, " .. + "therefore we recommend updating your R shinylive package to the latest version." + end -function getShinyliveBaseDeps() + throw_quarto_error( + "The shinylive R and Python packages must support the same Shinylive Assets version to be used in the same Quarto document.", + "The shinylive R and Python packages must support the same Shinylive Assets version to be used in the same Quarto document.\n", + "\n", + "Python shinylive package version: ", + ---@diagnostic disable-next-line: undefined-field + versions.python.version .. " ; Supported assets version: " .. versions.python.assets_version .. "\n", + "R shinylive package version: " .. + ---@diagnostic disable-next-line: undefined-field + versions.r.version .. " ; Supported assets version: " .. versions.r.assets_version .. "\n", + "\n", + verDiffStr .. "\n", + "\n", + "To update your R Shinylive package, run:\n", + "\tR -e \"install.packages('shinylive')\"\n", + "\n", + "To update your Python Shinylive package, run:\n", + "\tpip install --upgrade shinylive\n", + "(If you are using a virtual environment, please activate it before running the command above.)\n", + "\n" + ) + end + + -- Add language-specific dependencies + local langResources = callShinylive(language, { "extension", "language-resources" }) + for idx, resourceDep in ipairs(langResources) do + -- No need to check for uniqueness. + -- Each resource is only be added once and should already be unique. + quarto.doc.attach_to_dependency("shinylive", resourceDep) + end +end + +function getShinyliveBaseDeps(language) -- Relative path from the current page to the root of the site. This is needed -- to find out where shinylive-sw.js is, relative to the current page. if quarto.project.offset == nil then - error("The shinylive extension must be used in a Quarto project directory (with a _quarto.yml file).") + throw_quarto_error("The `shinylive` extension must be used in a Quarto project directory (with a _quarto.yml file).") end - local depJson = callShinylive( - { "base-deps", "--sw-dir", quarto.project.offset }, + local deps = callShinylive( + language, + { "extension", "base-htmldeps", "--sw-dir", quarto.project.offset }, "" ) - - local deps = quarto.json.decode(depJson) return deps end - return { { CodeBlock = function(el) - if el.attr and el.attr.classes:includes("{shinylive-python}") then - ensureShinyliveSetup() + if not el.attr then + -- Not a shinylive codeblock, return + return + end - -- Convert code block to JSON string in the same format as app.json. - local parsedCodeblockJson = pandoc.pipe( - "quarto", - { "run", codeblockScript }, - el.text - ) + local language + if el.attr.classes:includes("{shinylive-r}") then + language = "r" + elseif el.attr.classes:includes("{shinylive-python}") then + language = "python" + else + -- Not a shinylive codeblock, return + return + end + -- Setup language and language-agnostic dependencies + ensureLanguageSetup(language) - -- This contains "files" and "quartoArgs" keys. - local parsedCodeblock = quarto.json.decode(parsedCodeblockJson) + -- Convert code block to JSON string in the same format as app.json. + local parsedCodeblockJson = pandoc.pipe( + "quarto", + { "run", codeblockScript, language }, + el.text + ) - -- Find Python package dependencies for the current app. - local appDepsJson = callShinylive( - { "package-deps" }, - quarto.json.encode(parsedCodeblock["files"]) - ) + -- This contains "files" and "quartoArgs" keys. + local parsedCodeblock = quarto.json.decode(parsedCodeblockJson) - local appDeps = quarto.json.decode(appDepsJson) + -- Find Python package dependencies for the current app. + local appDeps = callShinylive( + language, + { "extension", "app-resources" }, + -- Send as piped input to the shinylive command + quarto.json.encode(parsedCodeblock["files"]) + ) - for idx, dep in ipairs(appDeps) do + -- Add app specific dependencies + for idx, dep in ipairs(appDeps) do + if not appSpecificDeps[dep.name] then + appSpecificDeps[dep.name] = true quarto.doc.attach_to_dependency("shinylive", dep) end + end + if el.attr.classes:includes("{shinylive-python}") then + el.attributes.engine = "python" el.attr.classes = pandoc.List() el.attr.classes:insert("shinylive-python") - return el + elseif el.attr.classes:includes("{shinylive-r}") then + el.attributes.engine = "r" + el.attr.classes = pandoc.List() + el.attr.classes:insert("shinylive-r") end + return el end } } diff --git a/setup.cfg b/setup.cfg index 42408710d..b6dff9b36 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,7 +37,7 @@ install_requires = starlette>=0.17.1 websockets>=10.0 python-multipart - htmltools>=0.2.1 + htmltools @ git+https://github.com/posit-dev/py-htmltools.git click>=8.1.4 markdown-it-py>=1.1.0 # This is needed for markdown-it-py. Without it, when loading shiny/ui/_markdown.py, @@ -100,7 +100,7 @@ doc = jupyter jupyter_client < 8.0.0 tabulate - shinylive==0.0.14 + shinylive==0.1.0 pydantic==1.10 quartodoc==0.4.1 griffe==0.32.3 diff --git a/shiny/__init__.py b/shiny/__init__.py index 12c95b494..3dfbe25cb 100644 --- a/shiny/__init__.py +++ b/shiny/__init__.py @@ -1,10 +1,11 @@ """A package for building reactive web applications.""" -__version__ = "0.5.1.9000" +__version__ = "0.5.1.9003" from ._shinyenv import is_pyodide as _is_pyodide # User-facing subpackages that should be available on `from shiny import *` +from . import quarto from . import reactive from . import render from .session import ( @@ -37,6 +38,7 @@ __all__ = ( # public sub-packages + "quarto", "reactive", "render", "session", diff --git a/shiny/_app.py b/shiny/_app.py index 4715a2a93..4461fcdfa 100644 --- a/shiny/_app.py +++ b/shiny/_app.py @@ -3,6 +3,7 @@ import copy import os import secrets +from pathlib import Path from typing import Any, Callable, Optional, cast import starlette.applications @@ -10,7 +11,14 @@ import starlette.middleware import starlette.routing import starlette.websockets -from htmltools import HTMLDependency, HTMLDocument, RenderedHTML, Tag, TagList +from htmltools import ( + HTMLDependency, + HTMLDocument, + HTMLTextDocument, + RenderedHTML, + Tag, + TagList, +) from starlette.requests import Request from starlette.responses import HTMLResponse, JSONResponse, Response from starlette.types import ASGIApp, Message, Receive, Scope, Send @@ -89,7 +97,7 @@ def server(input: Inputs, output: Outputs, session: Session): def __init__( self, - ui: Tag | TagList | Callable[[Request], Tag | TagList], + ui: Tag | TagList | Callable[[Request], Tag | TagList] | Path, server: Optional[Callable[[Inputs, Outputs, Session], None]], *, static_assets: Optional["str" | "os.PathLike[str]"] = None, @@ -146,6 +154,12 @@ def _server(inputs: Inputs, outputs: Outputs, session: Session): raise TypeError("App UI cannot be a coroutine function") # Dynamic UI: just store the function for later self.ui = cast("Callable[[Request], Tag | TagList]", ui) + elif isinstance(ui, Path): + if not ui.is_absolute(): + raise ValueError("Path to UI must be absolute") + + self.ui = self._render_page_from_file(ui, lib_prefix=self.lib_prefix) + else: # Static UI: render the UI now and save the results self.ui = self._render_page( @@ -370,9 +384,29 @@ def _render_page(self, ui: Tag | TagList, lib_prefix: str) -> RenderedHTML: self._ensure_web_dependencies(rendered["dependencies"]) return rendered + def _render_page_from_file(self, file: Path, lib_prefix: str) -> RenderedHTML: + with open(file, "r") as f: + page_html = f.read() + + doc = HTMLTextDocument( + page_html, + deps=[require_deps(), jquery_deps(), shiny_deps()], + deps_replace_pattern='', + ) + + rendered = doc.render(lib_prefix=lib_prefix) + self._ensure_web_dependencies(rendered["dependencies"]) + + return rendered + -def is_uifunc(x: Tag | TagList | Callable[[Request], Tag | TagList]): - if isinstance(x, Tag) or isinstance(x, TagList) or not callable(x): +def is_uifunc(x: Path | Tag | TagList | Callable[[Request], Tag | TagList]): + if ( + isinstance(x, Path) + or isinstance(x, Tag) + or isinstance(x, TagList) + or not callable(x) + ): return False else: return True diff --git a/shiny/_main.py b/shiny/_main.py index f53e51392..ae52ac963 100644 --- a/shiny/_main.py +++ b/shiny/_main.py @@ -30,6 +30,8 @@ def main() -> None: stop_shortcut = "Ctrl+C" +RELOAD_INCLUDES_DEFAULT = ("*.py", "*.css", "*.js", "*.htm", "*.html", "*.png") + @main.command( help=f"""Run a Shiny app (press {stop_shortcut} to stop). @@ -76,7 +78,7 @@ def main() -> None: "--reload", is_flag=True, default=False, - help="Enable auto-reload, when these types of files change: .py .css .js .html", + help="Enable auto-reload. See --reload-includes for types of files that are monitored for changes.", ) @click.option( "--reload-dir", @@ -86,6 +88,13 @@ def main() -> None: "addition to the app's parent directory. Can be used more than once.", type=click.Path(exists=True), ) +@click.option( + "--reload-includes", + "reload_includes", + default=",".join(RELOAD_INCLUDES_DEFAULT), + help="File glob(s) to indicate which files should be monitored for changes. Defaults" + f' to "{",".join(RELOAD_INCLUDES_DEFAULT)}".', +) @click.option( "--ws-max-size", type=int, @@ -131,12 +140,14 @@ def run( autoreload_port: int, reload: bool, reload_dirs: tuple[str, ...], + reload_includes: str, ws_max_size: int, log_level: str, app_dir: str, factory: bool, launch_browser: bool, ) -> None: + reload_includes_list = reload_includes.split(",") return run_app( app, host=host, @@ -144,6 +155,7 @@ def run( autoreload_port=autoreload_port, reload=reload, reload_dirs=list(reload_dirs), + reload_includes=reload_includes_list, ws_max_size=ws_max_size, log_level=log_level, app_dir=app_dir, @@ -159,6 +171,7 @@ def run_app( autoreload_port: int = 0, reload: bool = False, reload_dirs: Optional[list[str]] = None, + reload_includes: list[str] | tuple[str, ...] = RELOAD_INCLUDES_DEFAULT, ws_max_size: int = 16777216, log_level: Optional[str] = None, app_dir: Optional[str] = ".", @@ -188,6 +201,12 @@ def run_app( hot-reload. Set to 0 to use a random port. reload Enable auto-reload. + reload_dirs + List of directories (in addition to the app directory) to watch for changes that + will trigger app reloading. + reload_includes + List or tuple of file globs to indicate which files should be monitored for + changes. ws_max_size WebSocket max size message in bytes. log_level @@ -273,7 +292,8 @@ def run_app( "reload": reload, # Adding `reload_includes` param while `reload=False` produces an warning # https://github.com/encode/uvicorn/blob/d43afed1cfa018a85c83094da8a2dd29f656d676/uvicorn/config.py#L298-L304 - "reload_includes": ["*.py", "*.css", "*.js", "*.htm", "*.html", "*.png"], + "reload_includes": list(reload_includes), + "reload_excludes": [".*", "*.py[cod]", "__pycache__", "env", "venv"], "reload_dirs": reload_dirs, } @@ -480,7 +500,26 @@ def static_assets(command: str) -> None: raise click.UsageError(f"Unknown command: {command}") +@main.command(help="""Convert a JSON file with code cells to a py file.""") +@click.argument( + "json_file", + type=str, +) +@click.argument( + "py_file", + type=str, +) +def cells_to_app(json_file: str, py_file: str) -> None: + shiny.quarto.convert_code_cells_to_app_py(json_file, py_file) + + +@main.command(help="""Get Shiny's HTML dependencies as JSON.""") +def get_shiny_deps() -> None: + print(shiny.quarto.get_shiny_deps()) + + class ReloadArgs(TypedDict): reload: NotRequired[bool] reload_includes: NotRequired[list[str]] + reload_excludes: NotRequired[list[str]] reload_dirs: NotRequired[list[str]] diff --git a/shiny/_namespaces.py b/shiny/_namespaces.py index 039039803..d031d52b0 100644 --- a/shiny/_namespaces.py +++ b/shiny/_namespaces.py @@ -28,7 +28,7 @@ def __call__(self, id: Id) -> ResolvedId: def current_namespace() -> ResolvedId: - return _current_namespace.get() + return _current_namespace.get() or _default_namespace def resolve_id(id: Id) -> ResolvedId: @@ -43,7 +43,7 @@ def resolve_id(id: Id) -> ResolvedId: Returns An ID (if in a module, this will contain a namespace prefix). """ - curr_ns = _current_namespace.get() + curr_ns = current_namespace() return curr_ns(id) @@ -90,15 +90,16 @@ def validate_id(id: str): ) -_current_namespace: ContextVar[ResolvedId] = ContextVar( - "current_namespace", default=Root +_current_namespace: ContextVar[ResolvedId | None] = ContextVar( + "current_namespace", default=None ) +_default_namespace: ResolvedId = Root @contextmanager def namespace_context(id: Id | None): namespace = resolve_id(id) if id else Root - token: Token[ResolvedId] = _current_namespace.set(namespace) + token: Token[ResolvedId | None] = _current_namespace.set(namespace) try: yield finally: diff --git a/shiny/api-examples/output_transformer/app.py b/shiny/api-examples/output_transformer/app.py index 1c92b0830..48821ff52 100644 --- a/shiny/api-examples/output_transformer/app.py +++ b/shiny/api-examples/output_transformer/app.py @@ -20,7 +20,7 @@ # Create renderer components from the async handler function: `capitalize_components()` -@output_transformer +@output_transformer() async def CapitalizeTransformer( # Contains information about the render call: `name` and `session` _meta: TransformerMetadata, diff --git a/shiny/experimental/ui/_accordion.py b/shiny/experimental/ui/_accordion.py index 548958829..20791984c 100644 --- a/shiny/experimental/ui/_accordion.py +++ b/shiny/experimental/ui/_accordion.py @@ -5,10 +5,9 @@ from htmltools import Tag, TagAttrs, TagAttrValue, TagChild, css, tags -from ... import Session from ..._namespaces import resolve_id_or_none from ..._utils import drop_none -from ...session import require_active_session +from ...session import Session, require_active_session from ...types import MISSING, MISSING_TYPE from ...ui._html_deps_shinyverse import accordion_dependency from ...ui._tag import consolidate_attrs diff --git a/shiny/module.py b/shiny/module.py index 6af6a5043..227fb55a2 100644 --- a/shiny/module.py +++ b/shiny/module.py @@ -1,10 +1,14 @@ +from __future__ import annotations + __all__ = ("current_namespace", "resolve_id", "ui", "server") -from typing import Callable, TypeVar +from typing import TYPE_CHECKING, Callable, TypeVar from ._namespaces import Id, current_namespace, namespace_context, resolve_id from ._typing_extensions import Concatenate, ParamSpec -from .session import Inputs, Outputs, Session, require_active_session, session_context + +if TYPE_CHECKING: + from .session import Inputs, Outputs, Session P = ParamSpec("P") R = TypeVar("R") @@ -21,6 +25,8 @@ def wrapper(id: Id, *args: P.args, **kwargs: P.kwargs) -> R: def server( fn: Callable[Concatenate[Inputs, Outputs, Session, P], R] ) -> Callable[Concatenate[str, P], R]: + from .session import require_active_session, session_context + def wrapper(id: Id, *args: P.args, **kwargs: P.kwargs) -> R: sess = require_active_session(None) child_sess = sess.make_scope(id) diff --git a/shiny/quarto.py b/shiny/quarto.py new file mode 100644 index 000000000..684f1747a --- /dev/null +++ b/shiny/quarto.py @@ -0,0 +1,124 @@ +"""Tools for parsing ipynb files.""" +from __future__ import annotations + +from pathlib import Path + +__all__ = ("convert_code_cells_to_app_py", "get_shiny_deps") + +from typing import Literal, cast + +from ._typing_extensions import NotRequired, TypedDict + +QuartoShinyCodeCellClass = Literal["python", "r", "cell-code", "hidden"] +QuartoShinyCodeCellContext = Literal["ui", "server", "server-setup"] + + +class QuartoShinyCodeCell(TypedDict): + text: str + context: list[QuartoShinyCodeCellContext] + classes: list[QuartoShinyCodeCellClass] + + +class QuartoShinyCodeCells(TypedDict): + schema_version: int + cells: list[QuartoShinyCodeCell] + html_file: str + + +def convert_code_cells_to_app_py(json_file: str | Path, app_file: str | Path) -> None: + """Parse an code cell JSON file and output an app.py file.""" + import json + from textwrap import indent + + json_file = Path(json_file) + app_file = Path(app_file) + + if app_file.exists(): + with open(app_file, "r") as f: + first_line = f.readline().strip() + if first_line != "# This file generated by Quarto; do not edit by hand.": + raise ValueError( + f"Not overwriting app file {app_file}, because it does not appear to be generated by Quarto. " + " If this is incorrect, remove the file and try again." + ) + + with open(json_file, "r") as f: + data = cast(QuartoShinyCodeCells, json.load(f)) + + if data["schema_version"] != 1: + raise ValueError("Only schema_version 1 is supported.") + + cells = data["cells"] + + session_code_cell_texts: list[str] = [] + global_code_cell_texts: list[str] = [] + + for cell in cells: + if "python" not in cell["classes"]: + continue + + if "server-setup" in cell["context"]: + global_code_cell_texts.append(cell["text"] + "\n\n# " + "=" * 72 + "\n\n") + elif "server" in cell["context"]: + session_code_cell_texts.append( + indent(cell["text"], " ") + "\n\n # " + "=" * 72 + "\n\n" + ) + + app_content = f"""# This file generated by Quarto; do not edit by hand. + +from pathlib import Path +from shiny import App, Inputs, Outputs, Session, ui + +{ "".join(global_code_cell_texts) } + + +def server(input: Inputs, output: Outputs, session: Session) -> None: +{ "".join(session_code_cell_texts) } + +app = App( + Path(__file__).parent / "{ data["html_file"] }", + server, + static_assets=Path(__file__).parent, +) + """ + + with open(app_file, "w") as f: + f.write(app_content) + + +# ============================================================================= +# HTML Dependency types +# ============================================================================= +class QuartoHtmlDepItem(TypedDict): + name: str + path: str + attribs: NotRequired[dict[str, str]] + + +class QuartoHtmlDepServiceworkerItem(TypedDict): + source: str + destination: str + + +class QuartoHtmlDependency(TypedDict): + name: str + version: NotRequired[str] + scripts: NotRequired[list[str | QuartoHtmlDepItem]] + stylesheets: NotRequired[list[str | QuartoHtmlDepItem]] + resources: NotRequired[list[QuartoHtmlDepItem]] + meta: NotRequired[dict[str, str]] + serviceworkers: NotRequired[list[QuartoHtmlDepServiceworkerItem]] + + +def placeholder_dep() -> QuartoHtmlDependency: + return { + "name": "shiny-dependency-placeholder", + "version": "9.9.9", + "meta": {"shiny-dependency-placeholder": ""}, + } + + +def get_shiny_deps() -> str: + import json + + return json.dumps([placeholder_dep()], indent=2) diff --git a/shiny/reactive/_reactives.py b/shiny/reactive/_reactives.py index 988c28b2e..630a6d093 100644 --- a/shiny/reactive/_reactives.py +++ b/shiny/reactive/_reactives.py @@ -22,7 +22,6 @@ from .._docstring import add_example from .._utils import is_async_callable, run_coro_sync from .._validation import req -from ..render.transformer import OutputRenderer from ..types import MISSING, MISSING_TYPE, ActionButtonValue, SilentException from ._core import Context, Dependents, ReactiveWarning, isolate @@ -782,6 +781,10 @@ def decorator(user_fn: Callable[[], T]) -> Callable[[], T]: + "In other words, `@reactive.Calc` must be above `@reactive.event()`." ) + # This is here instead of at the top of the .py file in order to avoid a + # circular dependency. + from ..render.transformer import OutputRenderer + if isinstance(user_fn, OutputRenderer): # At some point in the future, we may allow this condition, if we find an # use case. For now we'll disallow it, for simplicity. diff --git a/shiny/render/_dataframe.py b/shiny/render/_dataframe.py index ffb203e32..f788c0896 100644 --- a/shiny/render/_dataframe.py +++ b/shiny/render/_dataframe.py @@ -13,6 +13,7 @@ runtime_checkable, ) +from .. import ui from .._docstring import add_example from ._dataframe_unsafe import serialize_numpy_dtypes from .transformer import ( @@ -224,7 +225,7 @@ def serialize_pandas_df(df: "pd.DataFrame") -> dict[str, Any]: DataFrameResult = Union[None, "pd.DataFrame", DataGrid, DataTable] -@output_transformer +@output_transformer(default_ui=ui.output_data_frame) async def DataFrameTransformer( _meta: TransformerMetadata, _fn: ValueFn[DataFrameResult | None], diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 00beb901f..ea0e05108 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -30,6 +30,7 @@ import pandas as pd from .. import _utils +from .. import ui as _ui from .._namespaces import ResolvedId from ..types import ImgData from ._try_render_plot import try_render_matplotlib, try_render_pil, try_render_plotnine @@ -46,7 +47,7 @@ # ====================================================================================== -@output_transformer +@output_transformer(default_ui=_ui.output_text_verbatim) async def TextTransformer( _meta: TransformerMetadata, _fn: ValueFn[str | None], @@ -99,7 +100,7 @@ def text( # Union[matplotlib.figure.Figure, PIL.Image.Image] # However, if we did that, we'd have to import those modules at load time, which adds # a nontrivial amount of overhead. So for now, we're just using `object`. -@output_transformer +@output_transformer(default_ui=_ui.output_plot) async def PlotTransformer( _meta: TransformerMetadata, _fn: ValueFn[object], @@ -267,7 +268,7 @@ def plot( # ====================================================================================== # RenderImage # ====================================================================================== -@output_transformer +@output_transformer(default_ui=_ui.output_image) async def ImageTransformer( _meta: TransformerMetadata, _fn: ValueFn[ImgData | None], @@ -353,7 +354,7 @@ def to_pandas(self) -> "pd.DataFrame": TableResult = Union["pd.DataFrame", PandasCompatible, None] -@output_transformer +@output_transformer(default_ui=_ui.output_table) async def TableTransformer( _meta: TransformerMetadata, _fn: ValueFn[TableResult | None], @@ -483,7 +484,7 @@ def table( # ====================================================================================== # RenderUI # ====================================================================================== -@output_transformer +@output_transformer(default_ui=_ui.output_ui) async def UiTransformer( _meta: TransformerMetadata, _fn: ValueFn[TagChild], diff --git a/shiny/render/transformer/_transformer.py b/shiny/render/transformer/_transformer.py index f206a8221..4450f9131 100644 --- a/shiny/render/transformer/_transformer.py +++ b/shiny/render/transformer/_transformer.py @@ -33,15 +33,16 @@ TypeVar, Union, cast, + overload, ) if TYPE_CHECKING: - from ... import Session + from ...session import Session + from htmltools import MetadataNode, Tag, TagList -from ... import _utils from ..._docstring import add_example from ..._typing_extensions import Concatenate, ParamSpec -from ..._utils import is_async_callable +from ..._utils import is_async_callable, run_coro_sync # Input type for the user-spplied function that is passed to a render.xx IT = TypeVar("IT") @@ -220,6 +221,7 @@ def __init__( value_fn: ValueFn[IT], transform_fn: TransformFn[IT, P, OT], params: TransformerParams[P], + default_ui: Callable[[str], TagList | Tag | MetadataNode | str] | None = None, ) -> None: """ Parameters @@ -234,7 +236,10 @@ def __init__( function), then the function should execute synchronously. params App-provided parameters for the transform function (`transform_fn`). - + default_ui + Optional function that takes an `output_id` string and returns a Shiny UI + object that can be used to display the output. This allows render functions + to respond to `_repr_html_` method calls in environments like Jupyter. """ # Copy over function name as it is consistent with how Session and Output @@ -250,6 +255,23 @@ def __init__( self._value_fn = value_fn self._transformer = transform_fn self._params = params + self.default_ui = default_ui + self._auto_registered = False + + from ...session import get_current_session + + s = get_current_session() + if s is not None: + s.output(self) + # We mark the fact that we're auto-registered so that, if an explicit + # registration now occurs, we can undo this auto-registration. + self._auto_registered = True + + def on_register(self) -> None: + if self._auto_registered: + # We're being explicitly registered now. Undo the auto-registration. + self._session.output.remove(self.__name__) + self._auto_registered = False def _set_metadata(self, session: Session, name: str) -> None: """ @@ -294,6 +316,18 @@ async def _run(self) -> OT: ) return ret + def _repr_html_(self) -> str | None: + import htmltools + + if self.default_ui is None: + return None + return htmltools.TagList(self.default_ui(self.__name__))._repr_html_() + + def tagify(self) -> TagList | Tag | MetadataNode | str: + if self.default_ui is None: + raise TypeError("No default UI exists for this type of render function") + return self.default_ui(self.__name__) + # Using a second class to help clarify that it is of a particular type class OutputRendererSync(OutputRenderer[OT]): @@ -314,6 +348,7 @@ def __init__( value_fn: ValueFnSync[IT], transform_fn: TransformFn[IT, P, OT], params: TransformerParams[P], + default_ui: Callable[[str], TagList | Tag | MetadataNode | str] | None = None, ) -> None: if is_async_callable(value_fn): raise TypeError( @@ -324,13 +359,14 @@ def __init__( value_fn=value_fn, transform_fn=transform_fn, params=params, + default_ui=default_ui, ) def __call__(self) -> OT: """ Synchronously executes the output renderer as a function. """ - return _utils.run_coro_sync(self._run()) + return run_coro_sync(self._run()) # The reason for having a separate RendererAsync class is because the __call__ @@ -354,6 +390,7 @@ def __init__( value_fn: ValueFnAsync[IT], transform_fn: TransformFn[IT, P, OT], params: TransformerParams[P], + default_ui: Callable[[str], TagList | Tag | MetadataNode | str] | None = None, ) -> None: if not is_async_callable(value_fn): raise TypeError( @@ -364,6 +401,7 @@ def __init__( value_fn=value_fn, transform_fn=transform_fn, params=params, + default_ui=default_ui, ) async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverride] @@ -526,10 +564,30 @@ def __init__( self.OutputRendererDecorator = OutputRendererDecorator[IT, OT] -@add_example() +@overload +def output_transformer( + *, + default_ui: Callable[[str], TagList | Tag | MetadataNode | str] | None = None, +) -> Callable[[TransformFn[IT, P, OT]], OutputTransformer[IT, OT, P]]: + ... + + +@overload def output_transformer( transform_fn: TransformFn[IT, P, OT], ) -> OutputTransformer[IT, OT, P]: + ... + + +@add_example() +def output_transformer( + transform_fn: TransformFn[IT, P, OT] | None = None, + *, + default_ui: Callable[[str], TagList | Tag | MetadataNode | str] | None = None, +) -> ( + OutputTransformer[IT, OT, P] + | Callable[[TransformFn[IT, P, OT]], OutputTransformer[IT, OT, P]] +): """ Output transformer decorator @@ -594,6 +652,10 @@ def output_transformer( Asynchronous function used to determine the app-supplied output value function return type (`IT`), the transformed type (`OT`), and the keyword arguments (`P`) app authors can supply to the renderer decorator. + default_ui + Optional function that takes an `output_id` string and returns a Shiny UI object + that can be used to display the output. This allows render functions to respond + to `_repr_html_` method calls in environments like Jupyter. Returns ------- @@ -603,28 +665,37 @@ def output_transformer( renderer is called without parentheses and the other is for when the renderer is called with parentheses. """ - _assert_transformer(transform_fn) - def renderer_decorator( - value_fn: ValueFn[IT] | None, - params: TransformerParams[P], - ) -> OutputRenderer[OT] | OutputRendererDecorator[IT, OT]: - def as_value_fn( - fn: ValueFn[IT], - ) -> OutputRenderer[OT]: - if is_async_callable(fn): - return OutputRendererAsync(fn, transform_fn, params) - else: - # To avoid duplicate work just for a typeguard, we cast the function - fn = cast(ValueFnSync[IT], fn) - return OutputRendererSync(fn, transform_fn, params) - - if value_fn is None: - return as_value_fn - val = as_value_fn(value_fn) - return val - - return OutputTransformer(renderer_decorator) + def output_transformer_impl( + transform_fn: TransformFn[IT, P, OT], + ) -> OutputTransformer[IT, OT, P]: + _assert_transformer(transform_fn) + + def renderer_decorator( + value_fn: ValueFn[IT] | None, + params: TransformerParams[P], + ) -> OutputRenderer[OT] | OutputRendererDecorator[IT, OT]: + def as_value_fn( + fn: ValueFn[IT], + ) -> OutputRenderer[OT]: + if is_async_callable(fn): + return OutputRendererAsync(fn, transform_fn, params, default_ui) + else: + # To avoid duplicate work just for a typeguard, we cast the function + fn = cast(ValueFnSync[IT], fn) + return OutputRendererSync(fn, transform_fn, params, default_ui) + + if value_fn is None: + return as_value_fn + val = as_value_fn(value_fn) + return val + + return OutputTransformer(renderer_decorator) + + if transform_fn is not None: + return output_transformer_impl(transform_fn) + else: + return output_transformer_impl async def resolve_value_fn(value_fn: ValueFn[IT]) -> IT: diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 7942e7024..4939bf9a8 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -955,7 +955,7 @@ class Outputs: def __init__( self, session: Session, - ns: Callable[[str], str], + ns: Callable[[str], ResolvedId], effects: dict[str, Effect_], suspend_when_hidden: dict[str, bool], ) -> None: @@ -997,6 +997,9 @@ def __call__( id = name def set_renderer(renderer_fn: OutputRenderer[OT]) -> None: + if hasattr(renderer_fn, "on_register"): + renderer_fn.on_register() + # Get the (possibly namespaced) output id output_name = self._ns(id or renderer_fn.__name__) @@ -1009,8 +1012,7 @@ def set_renderer(renderer_fn: OutputRenderer[OT]) -> None: # renderer_fn is a Renderer object. Give it a bit of metadata. renderer_fn._set_metadata(self._session, output_name) - if output_name in self._effects: - self._effects[output_name].destroy() + self.remove(output_name) self._suspend_when_hidden[output_name] = suspend_when_hidden @@ -1045,7 +1047,7 @@ async def output_obs(): err_msg = str(e) # Register the outbound error message err_message = { - output_name: { + str(output_name): { "message": err_msg, # TODO: is it possible to get the call? "call": None, @@ -1074,6 +1076,13 @@ async def output_obs(): else: return set_renderer(renderer_fn) + def remove(self, id: Id): + output_name = self._ns(id) + if output_name in self._effects: + self._effects[output_name].destroy() + del self._effects[output_name] + del self._suspend_when_hidden[output_name] + def _manage_hidden(self) -> None: "Suspends execution of hidden outputs and resumes execution of visible outputs." output_names = list(self._suspend_when_hidden.keys()) diff --git a/shiny/session/_utils.py b/shiny/session/_utils.py index 78fd23d08..fce434fb1 100644 --- a/shiny/session/_utils.py +++ b/shiny/session/_utils.py @@ -30,6 +30,7 @@ class RenderedDeps(TypedDict): _current_session: ContextVar[Optional[Session]] = ContextVar( "current_session", default=None ) +_default_session: Optional[Session] = None def get_current_session() -> Optional[Session]: @@ -51,7 +52,7 @@ def get_current_session() -> Optional[Session]: ------- ~require_active_session """ - return _current_session.get() + return _current_session.get() or _default_session @contextmanager diff --git a/shiny/ui/_html_deps_shinyverse.py b/shiny/ui/_html_deps_shinyverse.py index dabe8010a..7c98fd1f0 100644 --- a/shiny/ui/_html_deps_shinyverse.py +++ b/shiny/ui/_html_deps_shinyverse.py @@ -67,7 +67,7 @@ def _bslib_component_dep( def fill_dependency() -> HTMLDependency: - return _htmltools_dep("fill", stylesheet=True) + return _htmltools_dep("fill", stylesheet=True, all_files=False) # -- bslib ------------------------- diff --git a/shiny/ui/_input_update.py b/shiny/ui/_input_update.py index f15319e8f..5351725b8 100644 --- a/shiny/ui/_input_update.py +++ b/shiny/ui/_input_update.py @@ -31,8 +31,7 @@ from .._namespaces import resolve_id from .._typing_extensions import NotRequired, TypedDict from .._utils import drop_none -from ..module import session_context -from ..session import Session, require_active_session +from ..session import Session, require_active_session, session_context from ._input_check_radio import ChoicesArg, _generate_options from ._input_date import _as_date_attr from ._input_select import SelectChoicesArg, _normalize_choices, _render_choices diff --git a/tests/e2e/default-render-ui/app.py b/tests/e2e/default-render-ui/app.py new file mode 100644 index 000000000..e597e1a67 --- /dev/null +++ b/tests/e2e/default-render-ui/app.py @@ -0,0 +1,16 @@ +from shiny import App, Inputs, Outputs, Session, render, ui + +app_ui = ui.page_fluid(ui.output_ui("dynamic_ui")) + + +def server(input: Inputs, output: Outputs, session: Session): + @render.ui + def dynamic_ui(): + @render.text + def txt(): + return "Hello" + + return txt + + +app = App(app_ui, server) diff --git a/tests/e2e/default-render-ui/test_default_render_ui.py b/tests/e2e/default-render-ui/test_default_render_ui.py new file mode 100644 index 000000000..283557aec --- /dev/null +++ b/tests/e2e/default-render-ui/test_default_render_ui.py @@ -0,0 +1,8 @@ +from conftest import ShinyAppProc +from playwright.sync_api import Page, expect + + +def test_implicit_register(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + + expect(page.locator("#txt")).to_have_text("Hello") diff --git a/tests/e2e/implicit-register/app.py b/tests/e2e/implicit-register/app.py new file mode 100644 index 000000000..eb88220a1 --- /dev/null +++ b/tests/e2e/implicit-register/app.py @@ -0,0 +1,39 @@ +from shiny import App, Inputs, Outputs, Session, render, ui + +scenarios = dict( + out1="The following output should be empty", + out2='The following output should have the word "One"', + out3='The following output should have the word "Two"', + out4='The following output should also have the word "Two"', +) + +app_ui = ui.page_fluid( + [ + ui.p(ui.div(desc), ui.output_text_verbatim(id, placeholder=True)) + for id, desc in scenarios.items() + ] +) + + +def server(input: Inputs, output: Outputs, session: Session): + # When @output(id="out2") is added to the function, it should + # un-register the implicit @output(id="out1") that @render.text + # does internally. + + @output(id="out2") + @render.text + def out1(): + return "One" + + @render.text + def out3(): + return "Two" + + # Only implicit registration can be revoked. Since out3 was + # explicitly registered, registering it again should result + # in both registrations being active. + output(out3) + output(id="out4")(out3) + + +app = App(app_ui, server) diff --git a/tests/e2e/implicit-register/test_implicit_register.py b/tests/e2e/implicit-register/test_implicit_register.py new file mode 100644 index 000000000..668274dbd --- /dev/null +++ b/tests/e2e/implicit-register/test_implicit_register.py @@ -0,0 +1,11 @@ +from conftest import ShinyAppProc +from playwright.sync_api import Page, expect + + +def test_implicit_register(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + + expect(page.locator("#out2")).to_have_text("One") + expect(page.locator("#out3")).to_have_text("Two") + expect(page.locator("#out4")).to_have_text("Two") + expect(page.locator("#out1")).to_be_empty()