shinyreact 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shinyreact/.agents/skills/shinyreact-build-app/SKILL.md +433 -0
- shinyreact/.agents/skills/shinyreact-build-app/references/bookmarking.md +24 -0
- shinyreact/.agents/skills/shinyreact-build-app/references/debugging.md +29 -0
- shinyreact/.agents/skills/shinyreact-build-app/references/modules.md +118 -0
- shinyreact/.agents/skills/shinyreact-build-app/references/no-build.md +35 -0
- shinyreact/.agents/skills/shinyreact-build-app/references/shiny-outputs.md +74 -0
- shinyreact/.agents/skills/shinyreact-build-app/references/testing.md +188 -0
- shinyreact/.agents/skills/shinyreact-convert-app/SKILL.md +282 -0
- shinyreact/__init__.py +24 -0
- shinyreact/_app.py +205 -0
- shinyreact/_bookmark.py +86 -0
- shinyreact/_dep.py +93 -0
- shinyreact/_dep_discovery.py +76 -0
- shinyreact/_input_handler.py +42 -0
- shinyreact/_page.py +656 -0
- shinyreact/_protocol.py +17 -0
- shinyreact/_reactive_output.py +19 -0
- shinyreact/_send_message.py +40 -0
- shinyreact/playwright.py +204 -0
- shinyreact/www/shinyreact.css +1 -0
- shinyreact/www/shinyreact.js +49 -0
- shinyreact-0.1.0.dist-info/METADATA +114 -0
- shinyreact-0.1.0.dist-info/RECORD +25 -0
- shinyreact-0.1.0.dist-info/WHEEL +4 -0
- shinyreact-0.1.0.dist-info/licenses/LICENSE +21 -0
shinyreact/_page.py
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import warnings
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Callable, cast
|
|
7
|
+
|
|
8
|
+
from htmltools import HTML, HTMLDependency, Tag, TagChild, TagList
|
|
9
|
+
from shiny.express.ui import page_opts
|
|
10
|
+
from shiny.render.renderer import Renderer
|
|
11
|
+
from shiny.session import get_current_session
|
|
12
|
+
from shiny.ui import page_html
|
|
13
|
+
|
|
14
|
+
from ._app import SRC_DIR_ATTR
|
|
15
|
+
from ._bookmark import _config_script_tag
|
|
16
|
+
from ._dep import ShinyreactJs, _dep, _dep_page, _file_mtime_int, _serves_bundle
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
# Private, but it is the only name for HTMLDependency's stylesheet entry.
|
|
20
|
+
from htmltools._core import ScriptItem, StylesheetItem
|
|
21
|
+
|
|
22
|
+
# The class `page_html()` returns. `App(ui=)` accepts it but not its
|
|
23
|
+
# `HTMLTextDocument` base, so declaring the base as our return type would
|
|
24
|
+
# make the documented `App(page_react_html(...), server)` fail to
|
|
25
|
+
# type-check. py-shiny exports the function but not the class -- hence the
|
|
26
|
+
# private import, kept type-only so a rename upstream is a pyright error,
|
|
27
|
+
# not an ImportError at app startup.
|
|
28
|
+
from shiny.ui._page import PageHtmlDocument
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def page_bare(
|
|
32
|
+
*args: TagChild,
|
|
33
|
+
title: str | None = None,
|
|
34
|
+
lang: str = "en",
|
|
35
|
+
**kwargs: Any,
|
|
36
|
+
) -> Tag:
|
|
37
|
+
"""Create a bare HTML page with only Shiny dependencies.
|
|
38
|
+
|
|
39
|
+
This is the escape hatch for fully custom setups that don't need the
|
|
40
|
+
shinyreact JS/CSS. It wraps ``shiny.ui.page_bootstrap()`` with minimal
|
|
41
|
+
defaults.
|
|
42
|
+
|
|
43
|
+
Pass :class:`~htmltools.HTMLDependency` objects as positional arguments to
|
|
44
|
+
include them in the page — Shiny automatically hoists them to ``<head>``.
|
|
45
|
+
|
|
46
|
+
With no ``theme=``, the page carries **no Bootstrap**: only jQuery, Shiny's
|
|
47
|
+
own JS/CSS, and a ``width=device-width`` viewport meta tag. Shiny's own
|
|
48
|
+
default would attach Bootstrap, and in the ui.tsx pattern the client owns
|
|
49
|
+
styling. Pass a ``theme=`` — e.g. ``shiny.ui.Theme()`` — to get Bootstrap
|
|
50
|
+
back; then everything is a plain passthrough to
|
|
51
|
+
:func:`shiny.ui.page_bootstrap`.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
*args: Child tags or HTMLDependency objects to include in the page.
|
|
55
|
+
title: Page title.
|
|
56
|
+
lang: HTML ``lang`` attribute.
|
|
57
|
+
**kwargs: Forwarded to :func:`shiny.ui.page_bootstrap` — its own
|
|
58
|
+
``theme=``, or tag attributes for the page. Deliberately not
|
|
59
|
+
surfaced as named parameters: in the ui.tsx pattern the client owns
|
|
60
|
+
styling, so Bootstrap theming is a passthrough, not part of this
|
|
61
|
+
API.
|
|
62
|
+
"""
|
|
63
|
+
from shiny.ui import head_content, page_bootstrap, tags
|
|
64
|
+
|
|
65
|
+
if kwargs.get("theme") is not None:
|
|
66
|
+
return page_bootstrap(*args, title=title, lang=lang, **kwargs)
|
|
67
|
+
|
|
68
|
+
# No theme: build the page ourselves rather than let page_bootstrap()
|
|
69
|
+
# attach Bootstrap (#285). It excludes Shiny's own CSS on the assumption
|
|
70
|
+
# that the Bootstrap CSS bundles it, so ask for it explicitly here.
|
|
71
|
+
from shiny.html_dependencies import jquery_deps, shiny_deps
|
|
72
|
+
|
|
73
|
+
kwargs.pop("theme", None)
|
|
74
|
+
return tags.html(
|
|
75
|
+
tags.head(tags.title(title) if title else None),
|
|
76
|
+
tags.body(
|
|
77
|
+
jquery_deps(),
|
|
78
|
+
*shiny_deps(),
|
|
79
|
+
# The one thing worth keeping from the Bootstrap dependency:
|
|
80
|
+
# without it a phone renders the page at 980px wide. Via
|
|
81
|
+
# head_content() so it reaches the *document's* <head> -- Shiny
|
|
82
|
+
# nests this page tag inside the document it builds.
|
|
83
|
+
head_content(
|
|
84
|
+
tags.meta(
|
|
85
|
+
name="viewport", content="width=device-width, initial-scale=1"
|
|
86
|
+
)
|
|
87
|
+
),
|
|
88
|
+
*args,
|
|
89
|
+
**kwargs,
|
|
90
|
+
),
|
|
91
|
+
lang=lang,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _resolve_react_dirs(
|
|
96
|
+
src_dir: str | Path | None, caller_dir: Path
|
|
97
|
+
) -> tuple[Path, str]:
|
|
98
|
+
"""Resolve ``page_react``'s asset dir and derive the app name.
|
|
99
|
+
|
|
100
|
+
Returns ``(base_dir, app_name)``. ``app_name`` is the app folder's name:
|
|
101
|
+
when the asset dir is the conventional ``www/``, its parent (the app dir)
|
|
102
|
+
names the app; otherwise the asset dir itself does.
|
|
103
|
+
"""
|
|
104
|
+
if src_dir is None:
|
|
105
|
+
base_dir = caller_dir / "www"
|
|
106
|
+
else:
|
|
107
|
+
src_dir = Path(src_dir)
|
|
108
|
+
base_dir = src_dir if src_dir.is_absolute() else caller_dir / src_dir
|
|
109
|
+
app_name = base_dir.parent.name if base_dir.name == "www" else base_dir.name
|
|
110
|
+
return base_dir, app_name
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def page_react(
|
|
114
|
+
*args: TagChild,
|
|
115
|
+
src_dir: str | Path | None = None,
|
|
116
|
+
js_file: str = "ui.js",
|
|
117
|
+
css_file: str | None = "ui.css",
|
|
118
|
+
title: str | None = None,
|
|
119
|
+
lang: str = "en",
|
|
120
|
+
shinyreact_js: ShinyreactJs = "server",
|
|
121
|
+
**kwargs: Any,
|
|
122
|
+
) -> Tag:
|
|
123
|
+
"""Create a React page from conventional assets — no HTML file required.
|
|
124
|
+
|
|
125
|
+
The zero-configuration page for the ui.tsx pattern: the server emits no
|
|
126
|
+
body HTML at all. It attaches the shinyreact bundle plus your app's entry
|
|
127
|
+
assets, discovered at ``www/ui.js`` and ``www/ui.css`` (relative to the
|
|
128
|
+
calling module). Your JS owns the DOM — create and append your own mount
|
|
129
|
+
container::
|
|
130
|
+
|
|
131
|
+
const root = ReactDOM.createRoot(
|
|
132
|
+
document.body.appendChild(document.createElement("div")),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
``ui.js`` is required (a missing file warns, pointing at the resolved
|
|
136
|
+
path); ``ui.css`` is attached only when it exists. Both are served as an
|
|
137
|
+
:class:`~htmltools.HTMLDependency` versioned by ``ui.js``'s mtime, so the
|
|
138
|
+
browser re-fetches after every edit — unlike raw ``<script src=...>``
|
|
139
|
+
tags in a hand-written HTML file, which the browser caches.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
*args: Extra children or :class:`~htmltools.HTMLDependency` objects.
|
|
143
|
+
src_dir: Directory containing the assets. Defaults to ``www/`` next
|
|
144
|
+
to the calling module; relative paths resolve against the caller.
|
|
145
|
+
js_file: JS entry filename within ``src_dir`` (default ``"ui.js"``).
|
|
146
|
+
css_file: CSS filename within ``src_dir`` (default ``"ui.css"``).
|
|
147
|
+
title: Page title. Defaults to the app folder's name (``src_dir``'s
|
|
148
|
+
parent when ``src_dir`` is a ``www/`` dir).
|
|
149
|
+
lang: HTML ``lang`` attribute.
|
|
150
|
+
shinyreact_js: Who supplies ``shinyreact.js`` (and
|
|
151
|
+
``shinyreact.css``) to the page.
|
|
152
|
+
|
|
153
|
+
- ``"server"`` (default) — the shinyreact package serves them as an
|
|
154
|
+
:class:`~htmltools.HTMLDependency`. What a no-build app needs,
|
|
155
|
+
and what makes ``window.shinyreact`` exist.
|
|
156
|
+
- ``"client"`` — your own bundle imports ``@posit-dev/shinyreact`` and
|
|
157
|
+
ships its own copy, so the server sends nothing. Serving them too
|
|
158
|
+
would put two copies of React and the hooks on one page.
|
|
159
|
+
|
|
160
|
+
The ``#shinyreact-config`` tag is emitted either way — it carries
|
|
161
|
+
the protocol version and any bookmark restore payload.
|
|
162
|
+
**kwargs: Forwarded to :func:`page_bare`, and on to
|
|
163
|
+
:func:`shiny.ui.page_bootstrap`.
|
|
164
|
+
"""
|
|
165
|
+
caller_file = sys._getframe(1).f_globals.get("__file__")
|
|
166
|
+
caller_dir = Path(caller_file).parent if caller_file else Path.cwd()
|
|
167
|
+
base_dir, app_name = _resolve_react_dirs(src_dir, caller_dir)
|
|
168
|
+
return page_bare(
|
|
169
|
+
_dep_page(shinyreact_js),
|
|
170
|
+
page_react_dep(
|
|
171
|
+
src_dir=base_dir,
|
|
172
|
+
js_file=js_file,
|
|
173
|
+
css_file=css_file,
|
|
174
|
+
name=app_name,
|
|
175
|
+
),
|
|
176
|
+
*args,
|
|
177
|
+
title=title if title is not None else app_name,
|
|
178
|
+
lang=lang,
|
|
179
|
+
**kwargs,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def page_react_dep(
|
|
184
|
+
*,
|
|
185
|
+
src_dir: str | Path | None = None,
|
|
186
|
+
js_file: str = "ui.js",
|
|
187
|
+
css_file: str | None = "ui.css",
|
|
188
|
+
name: str | None = None,
|
|
189
|
+
) -> HTMLDependency:
|
|
190
|
+
"""Build an HTMLDependency for a React app's JS and CSS entry points.
|
|
191
|
+
|
|
192
|
+
The JS file's mtime is the dependency version, so the
|
|
193
|
+
``/lib/<name>-<version>/`` URL changes on every rebuild and the browser
|
|
194
|
+
re-fetches. That is what you want while developing, and the wrong thing for
|
|
195
|
+
a published package — an mtime is whatever the install happened to write, so
|
|
196
|
+
it is neither stable across machines nor meaningful to a reader. There is no
|
|
197
|
+
``version=`` here on purpose: a package shipping a fixed version should
|
|
198
|
+
build its own :class:`~htmltools.HTMLDependency` (the same advice as for a
|
|
199
|
+
classic, non-module bundle), which is five lines and leaves nothing about
|
|
200
|
+
the dependency implicit.
|
|
201
|
+
|
|
202
|
+
Both the script and the stylesheet are attached only when the file exists
|
|
203
|
+
inside the resolved ``src_dir``, so a bundle that ships no CSS — or that has
|
|
204
|
+
not been built yet — does not emit a tag pointing at a 404. Pass
|
|
205
|
+
``css_file=None`` to never attach a stylesheet. A missing ``js_file`` warns,
|
|
206
|
+
since it is the entry point and an empty dependency would otherwise fail
|
|
207
|
+
silently.
|
|
208
|
+
|
|
209
|
+
A missing ``src_dir`` **raises** :class:`NotADirectoryError`, where a
|
|
210
|
+
missing ``js_file`` only warns. The asymmetry is not arbitrary: Shiny mounts
|
|
211
|
+
the dependency's source directory as static files, so a directory that does
|
|
212
|
+
not exist is fatal no matter what this function does — the only question is
|
|
213
|
+
whether the author gets Starlette's ``Directory '...' does not exist`` from
|
|
214
|
+
inside ``App.__init__``, or a message naming the argument that chose the
|
|
215
|
+
path. Matches R's ``page_react_dep()``.
|
|
216
|
+
|
|
217
|
+
Path resolution
|
|
218
|
+
---------------
|
|
219
|
+
The base directory is ``src_dir`` when given. Passing it explicitly is
|
|
220
|
+
recommended for library authors — the inference below reads the *immediate*
|
|
221
|
+
calling frame, so wrapping this function in a helper resolves against the
|
|
222
|
+
wrapper's directory rather than the app's.
|
|
223
|
+
|
|
224
|
+
When ``src_dir`` is omitted it is inferred:
|
|
225
|
+
|
|
226
|
+
1. **Module call (typical):** when the caller is a regular Python module
|
|
227
|
+
(``__file__`` set), paths resolve against the module's directory. This
|
|
228
|
+
is the expected usage::
|
|
229
|
+
|
|
230
|
+
# /path/to/my-app/app.py
|
|
231
|
+
from shinyreact import page_react_dep
|
|
232
|
+
|
|
233
|
+
dep = page_react_dep(js_file="bundle.js")
|
|
234
|
+
# dep.source["subdir"] == "/path/to/my-app"
|
|
235
|
+
# dep.name == "my-app"
|
|
236
|
+
# version == mtime of /path/to/my-app/bundle.js
|
|
237
|
+
|
|
238
|
+
2. **REPL / exec'd code (no ``__file__``):** falls back to
|
|
239
|
+
:func:`pathlib.Path.cwd` — the current working directory of the
|
|
240
|
+
process. This matches the convention CLI tools use when resolving
|
|
241
|
+
relative paths::
|
|
242
|
+
|
|
243
|
+
>>> import os, shinyreact
|
|
244
|
+
>>> os.chdir("/path/to/my-app")
|
|
245
|
+
>>> shinyreact.page_react_dep(js_file="bundle.js")
|
|
246
|
+
# source["subdir"] == "/path/to/my-app"
|
|
247
|
+
# name == "my-app"
|
|
248
|
+
|
|
249
|
+
The fallback is deliberate — call from any working directory and you
|
|
250
|
+
get a predictable result. If you need a specific directory regardless
|
|
251
|
+
of CWD, pass ``src_dir``.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
src_dir: Directory containing the JS/CSS. Inferred from the calling
|
|
255
|
+
frame when omitted (see above).
|
|
256
|
+
js_file: Filename of the JS entry point, relative to ``src_dir``
|
|
257
|
+
(default ``"ui.js"``). Attached only if the file exists.
|
|
258
|
+
css_file: Filename of the CSS file, relative to ``src_dir`` (default
|
|
259
|
+
``"ui.css"``). Attached only if the file exists; ``None`` to skip.
|
|
260
|
+
name: Dependency name. Defaults to ``src_dir``'s basename.
|
|
261
|
+
"""
|
|
262
|
+
if src_dir is not None:
|
|
263
|
+
base_dir = Path(src_dir)
|
|
264
|
+
else:
|
|
265
|
+
caller_file = sys._getframe(1).f_globals.get("__file__")
|
|
266
|
+
# If the caller has no __file__ (REPL or dynamically exec'd code),
|
|
267
|
+
# fall back to the current working directory — same convention as
|
|
268
|
+
# most CLI tools resolving relative paths.
|
|
269
|
+
base_dir = Path(caller_file).parent if caller_file else Path.cwd()
|
|
270
|
+
dep_name = name if name is not None else base_dir.name
|
|
271
|
+
|
|
272
|
+
if not base_dir.is_dir():
|
|
273
|
+
# Shiny mounts a dependency's source dir as static files, so this is
|
|
274
|
+
# fatal either way -- but the error it raises is Starlette's
|
|
275
|
+
# "Directory '...' does not exist", from inside App.__init__, naming
|
|
276
|
+
# neither shinyreact nor the argument that chose the path. Fail here
|
|
277
|
+
# instead, where the message can say what to do about it.
|
|
278
|
+
raise NotADirectoryError(
|
|
279
|
+
f"React asset directory not found: {base_dir}. Shiny serves this "
|
|
280
|
+
"directory's files, so it must exist by the time the page is "
|
|
281
|
+
"built. Build the bundle first (its output directory is created "
|
|
282
|
+
"by the build), or pass a different src_dir=."
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
js_path = base_dir / js_file
|
|
286
|
+
mtime = _file_mtime_int(js_path)
|
|
287
|
+
version = str(mtime) if mtime is not None else "0"
|
|
288
|
+
|
|
289
|
+
script: ScriptItem | None = None
|
|
290
|
+
if js_path.exists():
|
|
291
|
+
script = {"src": js_file, "type": "module"}
|
|
292
|
+
else:
|
|
293
|
+
# An empty dependency loads nothing and reports nothing, so say so here
|
|
294
|
+
# — without the tag there is not even a 404 in the console to go on.
|
|
295
|
+
warnings.warn(
|
|
296
|
+
f"JS entry point not found: {js_path}. No script tag will be "
|
|
297
|
+
"emitted. Build the bundle first?",
|
|
298
|
+
stacklevel=2,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
stylesheet: StylesheetItem | None = None
|
|
302
|
+
if css_file is not None and (base_dir / css_file).exists():
|
|
303
|
+
stylesheet = {"href": css_file}
|
|
304
|
+
|
|
305
|
+
return HTMLDependency(
|
|
306
|
+
name=dep_name,
|
|
307
|
+
version=version,
|
|
308
|
+
source={"subdir": str(base_dir)},
|
|
309
|
+
script=script,
|
|
310
|
+
stylesheet=stylesheet,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# Cache of documents read by page_react_html(), keyed by resolved path. Value is
|
|
315
|
+
# (stat signature, text).
|
|
316
|
+
_DOCUMENT_CACHE: dict[Path, tuple[tuple[int, int], str]] = {}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _read_document_cached(path: Path) -> str:
|
|
320
|
+
"""Read an HTML document, re-reading only when it has changed on disk.
|
|
321
|
+
|
|
322
|
+
``ReactApp`` makes the UI a per-request function, so ``page_react_html()``
|
|
323
|
+
runs on every page render. Reading the file each time is what lets an author
|
|
324
|
+
edit ``index.html`` and hit refresh — no restart — but re-reading bytes that
|
|
325
|
+
have not changed is pure waste on a page that never changes.
|
|
326
|
+
|
|
327
|
+
So: stat every call, read only when the signature moves. ``st_mtime_ns`` plus
|
|
328
|
+
``st_size`` is the same heuristic build tools use; a same-nanosecond,
|
|
329
|
+
same-size edit would be missed, which needs a machine fast enough to write
|
|
330
|
+
twice within one filesystem timestamp tick.
|
|
331
|
+
|
|
332
|
+
Not thread-locked deliberately: two concurrent requests may both read the
|
|
333
|
+
file and both store the result, which costs one redundant read and cannot
|
|
334
|
+
produce a wrong answer.
|
|
335
|
+
"""
|
|
336
|
+
stat = path.stat()
|
|
337
|
+
signature = (stat.st_mtime_ns, stat.st_size)
|
|
338
|
+
cached = _DOCUMENT_CACHE.get(path)
|
|
339
|
+
if cached is not None and cached[0] == signature:
|
|
340
|
+
return cached[1]
|
|
341
|
+
text = path.read_text(encoding="utf-8")
|
|
342
|
+
_DOCUMENT_CACHE[path] = (signature, text)
|
|
343
|
+
return text
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def set_react_page(
|
|
347
|
+
path: str | Path | None = None, *, shinyreact_js: ShinyreactJs = "server"
|
|
348
|
+
) -> None:
|
|
349
|
+
"""Set the page for this Express app to a React app (the ui.tsx pattern).
|
|
350
|
+
|
|
351
|
+
With no arguments, serves ``www/index.html`` when it exists; otherwise
|
|
352
|
+
falls back to :func:`page_react`-style discovery of ``www/ui.js`` /
|
|
353
|
+
``www/ui.css``, emitting no body HTML at all (your JS appends its own
|
|
354
|
+
mount container to ``<body>``). Passing ``path`` explicitly requires the
|
|
355
|
+
file to exist.
|
|
356
|
+
|
|
357
|
+
When an HTML file is used, it is read once (cached at call time) and used
|
|
358
|
+
as the page body. In both modes, dependencies from traditional Shiny
|
|
359
|
+
renderers (e.g. ``@render.data_frame``) are discovered automatically and
|
|
360
|
+
injected into the page head.
|
|
361
|
+
|
|
362
|
+
Renderers defined inside ``@module.server`` are discovered too: every
|
|
363
|
+
renderer mounted while the app body runs is found via the session's
|
|
364
|
+
registered outputs, so module components load their JS/CSS with no extra
|
|
365
|
+
configuration. Renderers mounted *dynamically after page load* (e.g. a
|
|
366
|
+
module server called inside a ``@reactive.effect``) are not in the initial
|
|
367
|
+
page; when their UI is delivered through Shiny's dynamic-UI path
|
|
368
|
+
(``@render.ui``), Shiny injects their dependencies on render.
|
|
369
|
+
|
|
370
|
+
.. note::
|
|
371
|
+
|
|
372
|
+
Edits to ``index.html`` require restarting the Shiny server — see the
|
|
373
|
+
comment in :func:`_build_react_page_fn` for the upstream Shiny Express
|
|
374
|
+
constraint that prevents per-request re-reads.
|
|
375
|
+
|
|
376
|
+
Path resolution
|
|
377
|
+
---------------
|
|
378
|
+
``path`` resolution depends on whether it is absolute or relative:
|
|
379
|
+
|
|
380
|
+
1. **Absolute path:** used verbatim, regardless of caller or CWD::
|
|
381
|
+
|
|
382
|
+
# /tmp/standalone-app.py
|
|
383
|
+
from shinyreact import set_react_page
|
|
384
|
+
set_react_page("/srv/myapp/www/index.html")
|
|
385
|
+
# → reads /srv/myapp/www/index.html
|
|
386
|
+
|
|
387
|
+
2. **Relative path from a module (typical):** resolved against the
|
|
388
|
+
caller's module directory (read from the calling frame's
|
|
389
|
+
``__file__``)::
|
|
390
|
+
|
|
391
|
+
# /path/to/my-app/app.py
|
|
392
|
+
from shinyreact import set_react_page
|
|
393
|
+
set_react_page() # → /path/to/my-app/www/index.html
|
|
394
|
+
set_react_page("static/index.html") # → /path/to/my-app/static/index.html
|
|
395
|
+
|
|
396
|
+
This is the expected usage for ``shiny run app.py``.
|
|
397
|
+
|
|
398
|
+
3. **Relative path with no caller ``__file__`` (REPL / exec'd code):**
|
|
399
|
+
falls back to :func:`pathlib.Path.cwd` — the current working
|
|
400
|
+
directory of the process. Same convention CLI tools use for relative
|
|
401
|
+
paths::
|
|
402
|
+
|
|
403
|
+
>>> import os, shinyreact
|
|
404
|
+
>>> os.chdir("/path/to/my-app")
|
|
405
|
+
>>> shinyreact.set_react_page() # → /path/to/my-app/www/index.html
|
|
406
|
+
>>> shinyreact.set_react_page("a.html") # → /path/to/my-app/a.html
|
|
407
|
+
|
|
408
|
+
The fallback is deliberate — call from any working directory and you
|
|
409
|
+
get a predictable result. If you need a specific path regardless of
|
|
410
|
+
CWD, pass an absolute path (case 1).
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
path: Path to the HTML file. Absolute paths are used verbatim;
|
|
414
|
+
relative paths resolve against the caller module's directory,
|
|
415
|
+
or against ``Path.cwd()`` when there is no caller ``__file__``.
|
|
416
|
+
When ``None`` (the default), uses ``www/index.html`` if it
|
|
417
|
+
exists, else discovers ``www/ui.js`` / ``www/ui.css``.
|
|
418
|
+
shinyreact_js: Who supplies ``shinyreact.js`` / ``shinyreact.css``:
|
|
419
|
+
``"server"`` (default) or ``"client"`` for an npm-tier app whose
|
|
420
|
+
bundle imports ``@posit-dev/shinyreact`` — see :func:`page_react`.
|
|
421
|
+
"""
|
|
422
|
+
# Validate now rather than at first page render: a typo should fail at
|
|
423
|
+
# startup, next to the call that made it.
|
|
424
|
+
_serves_bundle(shinyreact_js)
|
|
425
|
+
caller_file = sys._getframe(1).f_globals.get("__file__")
|
|
426
|
+
# If the caller has no __file__ (REPL or dynamically exec'd code),
|
|
427
|
+
# fall back to the current working directory.
|
|
428
|
+
caller_dir = Path(caller_file).parent if caller_file else Path.cwd()
|
|
429
|
+
|
|
430
|
+
if path is None:
|
|
431
|
+
index_path = caller_dir / "www" / "index.html"
|
|
432
|
+
if not index_path.exists():
|
|
433
|
+
page_opts(
|
|
434
|
+
page_fn=_build_react_page_fn_discovered(caller_dir, shinyreact_js)
|
|
435
|
+
)
|
|
436
|
+
return
|
|
437
|
+
else:
|
|
438
|
+
path = Path(path)
|
|
439
|
+
index_path = path if path.is_absolute() else caller_dir / path
|
|
440
|
+
page_opts(page_fn=_build_react_page_fn(index_path, shinyreact_js))
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def page_react_html(
|
|
444
|
+
path: str | Path = "www/index.html",
|
|
445
|
+
*,
|
|
446
|
+
extra_deps: list[HTMLDependency] | None = None,
|
|
447
|
+
shinyreact_js: ShinyreactJs = "server",
|
|
448
|
+
) -> PageHtmlDocument:
|
|
449
|
+
"""Serve a React ``index.html`` document (the ui.tsx pattern, Core API).
|
|
450
|
+
|
|
451
|
+
Reads a complete HTML document — the kind a Vite build emits — and injects
|
|
452
|
+
Shiny's and shinyreact's dependencies into it. The document must contain
|
|
453
|
+
Shiny's dependency placeholder in ``<head>``::
|
|
454
|
+
|
|
455
|
+
<meta name="shiny-dependency-placeholder" content="">
|
|
456
|
+
|
|
457
|
+
The script/link tags render in its place; the same literal is
|
|
458
|
+
py-shiny's own ``DEPS_PLACEHOLDER``. It is an ordinary ``<meta>``
|
|
459
|
+
tag rather than template syntax, so the document stays valid HTML that a
|
|
460
|
+
bundler's dev server can serve unchanged. Matches R's ``page_react_html()``.
|
|
461
|
+
|
|
462
|
+
You rarely need to call this yourself — :class:`shinyreact.ReactApp`
|
|
463
|
+
discovers ``www/index.html`` and calls it for you::
|
|
464
|
+
|
|
465
|
+
from shinyreact import ReactApp
|
|
466
|
+
|
|
467
|
+
app = ReactApp(server)
|
|
468
|
+
|
|
469
|
+
Call it directly to pass a non-default path (``ReactApp(server,
|
|
470
|
+
ui=page_react_html("client/index.html"))``). ``shiny.App`` works too (via
|
|
471
|
+
``ui.page_html()``, py-shiny#2475), but only ``ReactApp`` mounts the
|
|
472
|
+
document's directory at ``/``, so the assets the document references
|
|
473
|
+
(your bundle's JS/CSS) are served when they live next to it
|
|
474
|
+
(conventionally ``www/``).
|
|
475
|
+
|
|
476
|
+
For apps that don't need to own the HTML document, prefer
|
|
477
|
+
:func:`page_react` — it requires no HTML file at all and works with plain
|
|
478
|
+
``shiny.App``.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
path: Path to the HTML document. Absolute paths are used verbatim;
|
|
482
|
+
relative paths resolve against the caller module's directory, or
|
|
483
|
+
against :func:`pathlib.Path.cwd` when there is no caller
|
|
484
|
+
``__file__``. Defaults to ``"www/index.html"``.
|
|
485
|
+
extra_deps: Additional :class:`~htmltools.HTMLDependency` objects to
|
|
486
|
+
render at the placeholder. A full document has no tag tree to
|
|
487
|
+
attach dependencies to, so this is the only way in — the
|
|
488
|
+
counterpart of :func:`page_react`'s positional ``*args``. They
|
|
489
|
+
render *after* Shiny's and shinyreact's, so they can rely on
|
|
490
|
+
``window.shinyreact`` existing.
|
|
491
|
+
shinyreact_js: Who supplies ``shinyreact.js`` / ``shinyreact.css``:
|
|
492
|
+
``"server"`` (default) or ``"client"`` for an npm-tier app whose
|
|
493
|
+
bundle imports ``@posit-dev/shinyreact`` — see :func:`page_react`.
|
|
494
|
+
"""
|
|
495
|
+
path = Path(path)
|
|
496
|
+
if path.is_absolute():
|
|
497
|
+
index_path = path
|
|
498
|
+
else:
|
|
499
|
+
caller_file = sys._getframe(1).f_globals.get("__file__")
|
|
500
|
+
# If the caller has no __file__ (REPL or dynamically exec'd code),
|
|
501
|
+
# fall back to the current working directory.
|
|
502
|
+
caller_dir = Path(caller_file).parent if caller_file else Path.cwd()
|
|
503
|
+
index_path = caller_dir / path
|
|
504
|
+
if not index_path.exists():
|
|
505
|
+
raise FileNotFoundError(f"HTML file not found: {index_path}")
|
|
506
|
+
# ui.page_html() (py-shiny#2475) owns the placeholder: it prefixes Shiny's
|
|
507
|
+
# own dependencies and raises at render time when the document has no
|
|
508
|
+
# placeholder to insert them at. We only add shinyreact's bundle and the
|
|
509
|
+
# #shinyreact-config tag.
|
|
510
|
+
doc = page_html(
|
|
511
|
+
_read_document_cached(index_path),
|
|
512
|
+
extra_deps=[
|
|
513
|
+
*([_dep()] if _serves_bundle(shinyreact_js) else []),
|
|
514
|
+
_config_script_tag(),
|
|
515
|
+
*(extra_deps or []),
|
|
516
|
+
],
|
|
517
|
+
)
|
|
518
|
+
# Tagged, not subclassed: py-shiny does not export the class publicly.
|
|
519
|
+
# ReactApp reads this to mount the document's directory at "/".
|
|
520
|
+
setattr(doc, SRC_DIR_ATTR, index_path.parent)
|
|
521
|
+
return doc
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _collect_renderer_deps(renderer: Renderer, deps: list[HTMLDependency]) -> None:
|
|
525
|
+
"""Append a renderer's output-UI dependencies to ``deps``.
|
|
526
|
+
|
|
527
|
+
Calls ``.tagify()`` first so dependencies that only materialize during
|
|
528
|
+
tagification are resolved (a bare ``get_dependencies()`` on the untagified
|
|
529
|
+
UI can miss them). The page function runs under the Express stub session,
|
|
530
|
+
whose ``_process_ui`` is a no-op, so tagify — not ``session._process_ui`` —
|
|
531
|
+
is the correct resolver here; the resolved deps are emitted into the page
|
|
532
|
+
TagList, and Shiny registers their file routes when it renders the page.
|
|
533
|
+
"""
|
|
534
|
+
ui = renderer.auto_output_ui()
|
|
535
|
+
if isinstance(ui, (Tag, TagList)):
|
|
536
|
+
deps.extend(ui.tagify().get_dependencies())
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _harvest_renderer_deps(args: tuple[Any, ...]) -> list[HTMLDependency]:
|
|
540
|
+
"""Collect HTMLDependencies from Express renderers for the page head.
|
|
541
|
+
|
|
542
|
+
Looks at the top-level renderers Shiny Express hands to the page function,
|
|
543
|
+
plus every renderer registered on the active session — including those
|
|
544
|
+
defined inside ``@module.server``, which ``args`` never sees (issue #87).
|
|
545
|
+
At the tagify pass the stub session already holds every synchronously
|
|
546
|
+
mounted renderer in ``output._outputs``.
|
|
547
|
+
"""
|
|
548
|
+
deps: list[HTMLDependency] = []
|
|
549
|
+
for arg in args:
|
|
550
|
+
if isinstance(arg, Renderer):
|
|
551
|
+
_collect_renderer_deps(arg, deps)
|
|
552
|
+
session = get_current_session()
|
|
553
|
+
if session is not None:
|
|
554
|
+
# `_outputs` is private; Shiny exposes no public API to iterate
|
|
555
|
+
# registered outputs.
|
|
556
|
+
for info in session.output._outputs.values():
|
|
557
|
+
_collect_renderer_deps(info.renderer, deps)
|
|
558
|
+
return deps
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# The `page_opts()` options a React page can honor, mapped onto page_bare()'s
|
|
562
|
+
# (i.e. page_bootstrap()'s) parameters. Everything else page_auto() might
|
|
563
|
+
# forward describes a Bootstrap layout a bare React page does not have.
|
|
564
|
+
_SUPPORTED_PAGE_OPTS = ("title", "lang", "theme")
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _react_page_opts(kwargs: dict[str, Any], *, mode: str) -> dict[str, Any]:
|
|
568
|
+
"""Validate the options ``page_auto()`` forwards to our page function.
|
|
569
|
+
|
|
570
|
+
``page_opts()`` records its arguments and ``page_auto()`` splats them into
|
|
571
|
+
whatever ``page_fn`` it resolved — ours. So a page function taking only
|
|
572
|
+
``*args`` raises ``TypeError: ... unexpected keyword argument 'title'`` from
|
|
573
|
+
inside a private local, which tells an author nothing about what to do.
|
|
574
|
+
Accept what the page can express, and name the rest.
|
|
575
|
+
"""
|
|
576
|
+
unsupported = [k for k in kwargs if k not in _SUPPORTED_PAGE_OPTS]
|
|
577
|
+
if unsupported:
|
|
578
|
+
supported = ", ".join(_SUPPORTED_PAGE_OPTS)
|
|
579
|
+
raise TypeError(
|
|
580
|
+
f"page_opts({unsupported[0]}=...) is not supported by "
|
|
581
|
+
f"set_react_page()'s {mode}: a React page has no Bootstrap layout "
|
|
582
|
+
f"to apply it to. Supported page options: {supported}."
|
|
583
|
+
)
|
|
584
|
+
return {k: v for k, v in kwargs.items() if v is not None}
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _build_react_page_fn_discovered(
|
|
588
|
+
app_dir: Path, shinyreact_js: ShinyreactJs = "server"
|
|
589
|
+
) -> Callable[..., Tag]:
|
|
590
|
+
"""Express page function for the no-HTML-file mode.
|
|
591
|
+
|
|
592
|
+
Serves a :func:`page_react` page from ``app_dir/www`` with the same
|
|
593
|
+
renderer-dependency discovery as the index.html mode.
|
|
594
|
+
"""
|
|
595
|
+
|
|
596
|
+
def _react_page_fn(*args: Any, **kwargs: Any) -> Tag:
|
|
597
|
+
# `title` here is a default, so `page_opts(title=...)` overrides it.
|
|
598
|
+
opts = {"title": app_dir.name, **_react_page_opts(kwargs, mode="page")}
|
|
599
|
+
return page_react(
|
|
600
|
+
*_harvest_renderer_deps(args),
|
|
601
|
+
src_dir=app_dir / "www",
|
|
602
|
+
shinyreact_js=shinyreact_js,
|
|
603
|
+
**opts,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
return _react_page_fn
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _build_react_page_fn(
|
|
610
|
+
index_path: Path, shinyreact_js: ShinyreactJs = "server"
|
|
611
|
+
) -> Callable[..., Tag]:
|
|
612
|
+
if not index_path.exists():
|
|
613
|
+
raise FileNotFoundError(f"HTML file not found: {index_path}")
|
|
614
|
+
|
|
615
|
+
# `index.html` is read once at construction time and closed over.
|
|
616
|
+
# See issue #82 (https://github.com/posit-dev/shinyreact/issues/82) for
|
|
617
|
+
# why a per-request re-read can't be implemented from inside this package
|
|
618
|
+
# alone:
|
|
619
|
+
#
|
|
620
|
+
# Shiny Express's `shiny/express/_run.py` calls `run_express(...).tagify()`
|
|
621
|
+
# ONCE at app startup. The resulting `app_ui` is a static `RenderedHTML`
|
|
622
|
+
# whose bytes are served verbatim for every `/` request (see
|
|
623
|
+
# `shiny/_app.py` around `if callable(self.ui): ... else: ui = self.ui`).
|
|
624
|
+
# Express only wraps `app_ui` in a per-request callable when
|
|
625
|
+
# `app_opts(bookmark_store=...)` is set to something other than `"disable"`
|
|
626
|
+
# — the only knob exposed today that flips static → callable.
|
|
627
|
+
#
|
|
628
|
+
# So this closure could re-read on mtime change all it wants; it's only
|
|
629
|
+
# invoked once. A real fix needs an upstream py-shiny change adding an
|
|
630
|
+
# opt-in for per-request `app_ui` independent of bookmarking. Until then,
|
|
631
|
+
# editing `www/index.html` requires restarting the Shiny server.
|
|
632
|
+
# Explicit encoding: the default follows the platform locale, so a document
|
|
633
|
+
# with non-ASCII content would decode differently on a machine whose locale
|
|
634
|
+
# is not UTF-8. R's page_react_html() decodes UTF-8 unconditionally.
|
|
635
|
+
index_html = index_path.read_text(encoding="utf-8")
|
|
636
|
+
|
|
637
|
+
def _react_page_fn(*args: Any, **kwargs: Any) -> Tag:
|
|
638
|
+
if kwargs:
|
|
639
|
+
# This mode emits no page tag at all — the document's own HTML is
|
|
640
|
+
# the body — so there is nothing for title/lang/theme to land on.
|
|
641
|
+
# Raise instead of ignoring, and instead of the bare TypeError that
|
|
642
|
+
# page_auto()'s splat used to produce from inside this local.
|
|
643
|
+
raise TypeError(
|
|
644
|
+
f"page_opts({next(iter(kwargs))}=...) is not supported by "
|
|
645
|
+
"set_react_page()'s HTML-file mode, which emits the document "
|
|
646
|
+
"as the page body and no page tag of its own. Put it in the "
|
|
647
|
+
"HTML, or use the no-HTML-file mode, which supports "
|
|
648
|
+
f"{', '.join(_SUPPORTED_PAGE_OPTS)}."
|
|
649
|
+
)
|
|
650
|
+
deps = _harvest_renderer_deps(args)
|
|
651
|
+
# Shiny de-duplicates dependencies by name+version when hoisting to
|
|
652
|
+
# <head>, so any overlap between the harvest passes is harmless.
|
|
653
|
+
# page_opts types page_fn as -> Tag, but TagList works at runtime
|
|
654
|
+
return cast(Tag, TagList(_dep_page(shinyreact_js), *deps, HTML(index_html)))
|
|
655
|
+
|
|
656
|
+
return _react_page_fn
|
shinyreact/_protocol.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""The wire-protocol version this server speaks.
|
|
2
|
+
|
|
3
|
+
Rendered into every page via the ``#shinyreact-config`` JSON script tag
|
|
4
|
+
(see ``_bookmark.py``); the JS client asserts the major versions match at
|
|
5
|
+
boot. The protocol covers every shape that crosses the client/server
|
|
6
|
+
boundary; ``protocol/surface.json`` enumerates them and a test in each
|
|
7
|
+
language enforces it. Only changes an existing peer would misinterpret bump
|
|
8
|
+
this version — additive shapes do not (see ``protocol/README.md``) — so
|
|
9
|
+
client and server package releases do not need to be in lockstep.
|
|
10
|
+
|
|
11
|
+
Decided in ``decisions/2026-08-17-js-distribution.md``. Mirrors
|
|
12
|
+
``PROTOCOL_VERSION`` in ``pkg-js/src/shiny-react/config.ts`` and
|
|
13
|
+
``pkg-r/R/protocol.R``; a parity test in each language asserts all three
|
|
14
|
+
match.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
PROTOCOL_VERSION = "1.0"
|