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/_app.py ADDED
@@ -0,0 +1,205 @@
1
+ """``shinyreact.ReactApp``: a ``shiny.App`` whose UI is discovered, not passed.
2
+
3
+ ``shiny.App`` accepts the full-document UI itself via ``ui.page_html()``
4
+ (py-shiny#2475). ``ReactApp`` adds the two things it does not do: discover the
5
+ ui.tsx-pattern UI next to the app file, and serve the sibling assets a full
6
+ document references.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from collections.abc import Mapping
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Any, Literal
15
+
16
+ from shiny import App as _ShinyApp
17
+ from shiny.types import MISSING, MISSING_TYPE
18
+
19
+ from ._dep import ShinyreactJs, _serves_bundle
20
+
21
+ if TYPE_CHECKING:
22
+ StaticAssets = str | Path | Mapping[str, str | Path]
23
+
24
+
25
+ # `page_react_html()` tags the `ui.page_html()` document it returns with the
26
+ # directory it was read from, so `ReactApp` can serve the sibling assets
27
+ # (`ui.js` etc.) the document references. An attribute rather than a subclass:
28
+ # py-shiny exports `ui.page_html()` but not its document class.
29
+ SRC_DIR_ATTR = "_shinyreact_src_dir"
30
+
31
+
32
+ def _add_react_dir(
33
+ static_assets: StaticAssets | None | MISSING_TYPE, react_dir: Path | None
34
+ ) -> StaticAssets | None:
35
+ """Resolve ``static_assets`` into what :class:`shiny.App` should mount.
36
+
37
+ ``react_dir`` is the directory a full HTML document's relative asset URLs
38
+ resolve against, so it has to be served at ``"/"``. It is *added to* what
39
+ the author asked for rather than replacing it, so an unrelated mount cannot
40
+ silently stop ``ui.js`` from being served. Four cases:
41
+
42
+ - not passed (``MISSING``) → ``react_dir`` at ``"/"``
43
+ - ``None`` → nothing; the author said no static assets and meant it
44
+ - a mapping → their mounts, plus ``react_dir`` at ``"/"`` if they left
45
+ ``"/"`` free
46
+ - a bare path → theirs only; a bare path *is* the ``"/"`` mount, so they
47
+ have already said which directory serves the document's siblings
48
+ """
49
+ author_mounts: Mapping[str, str | Path]
50
+ if isinstance(static_assets, MISSING_TYPE):
51
+ author_mounts = {}
52
+ elif static_assets is None or not isinstance(static_assets, Mapping):
53
+ return static_assets
54
+ else:
55
+ author_mounts = static_assets
56
+
57
+ if react_dir is None or not react_dir.is_dir():
58
+ return author_mounts or None
59
+ # Author's keys last, so an explicit "/" replaces ours. Shiny sorts mount
60
+ # points by descending length itself, so insertion order is irrelevant.
61
+ return {"/": react_dir, **author_mounts}
62
+
63
+
64
+ class ReactApp(_ShinyApp):
65
+ """:class:`shiny.App` for the ui.tsx pattern — the UI is discovered.
66
+
67
+ The app file is just the server::
68
+
69
+ from shinyreact import ReactApp
70
+
71
+ app = ReactApp(server, bookmark_store="url")
72
+
73
+ With no ``ui=``, the UI is discovered next to the calling module the same
74
+ way :func:`set_react_page` discovers it: ``www/index.html`` present →
75
+ :func:`page_react_html`; otherwise → :func:`page_react` (``www/ui.js`` /
76
+ ``www/ui.css``, served by the dependency itself). The discovered UI is a
77
+ function of the request, so it re-renders per request — which is what makes
78
+ ``bookmark_store=`` work with no further wiring, and what lets the mode
79
+ switch mid-session when you create or delete ``www/index.html``.
80
+
81
+ ``ui=`` overrides discovery and behaves exactly like ``shiny.App``'s ``ui``
82
+ argument, except that a document from :func:`page_react_html` still gets
83
+ its directory mounted. Note the
84
+ discovery reads the *immediate* calling frame (like :func:`page_react_dep`),
85
+ so a helper that wraps ``ReactApp(...)`` must pass ``ui=`` explicitly.
86
+
87
+ Args:
88
+ server: The server function, as for :class:`shiny.App`.
89
+ ui: Overrides UI discovery. Anything ``shiny.App`` accepts.
90
+ static_assets: Extra static mounts. **Added to** the React asset
91
+ directory rather than replacing it — see below. Defaults to
92
+ :data:`shiny.types.MISSING`, not ``None``, so that passing ``None``
93
+ explicitly can mean "no static assets at all".
94
+ bookmark_store: ``"url"`` / ``"server"`` to enable bookmarking, as for
95
+ :class:`shiny.App`. Requires a callable UI, which discovery
96
+ provides; a static ``ui=page_react_html(...)`` raises.
97
+ shinyreact_js: Who supplies ``shinyreact.js`` / ``shinyreact.css`` to
98
+ the discovered UI: ``"server"`` (default) or ``"client"`` for an
99
+ npm-tier app whose bundle imports ``@posit-dev/shinyreact``. Ignored
100
+ when ``ui=`` is passed — build that UI with
101
+ ``shinyreact_js="client"`` yourself.
102
+ **kwargs: Forwarded to :class:`shiny.App` (``debug=``, ``test_mode=``).
103
+
104
+ Static assets
105
+ -------------
106
+ A full HTML document references its bundle with a plain relative URL
107
+ (``<script src="ui.js">``), so *something* has to serve the directory the
108
+ document lives in. ``ReactApp`` mounts it at ``/`` for you:
109
+
110
+ - **discovery mode** — ``www/`` next to the app file, mounted whenever the
111
+ directory exists. It is mounted even in ``page_react()`` mode, where the
112
+ dependency serves the assets itself and the mount is unused: the mode is
113
+ decided per *request* while ``static_assets`` is a *constructor*
114
+ argument, so an unused mount is the only way to keep both modes working.
115
+ An unused mount is harmless; a missing one is a 404 per asset.
116
+ - **``ui=page_react_html(...)``** — the document's own directory, which
117
+ :func:`page_react_html` tagged onto the document it returned.
118
+
119
+ Your ``static_assets`` is **merged with** that mount, not substituted for
120
+ it, so adding an unrelated mount doesn't take the bundle down with it::
121
+
122
+ ReactApp(server, static_assets={"/data": DATA_DIR})
123
+ # serves /data from DATA_DIR *and* / from www/
124
+
125
+ You win wherever you actually collide with it, and there are three ways to::
126
+
127
+ ReactApp(server, static_assets={"/": DIST_DIR}) # explicit "/" key
128
+ ReactApp(server, static_assets=DIST_DIR) # a bare path *is* "/"
129
+ ReactApp(server, static_assets=None) # nothing mounted
130
+
131
+ The last one is why the default is :data:`shiny.types.MISSING` rather than
132
+ ``None``: with ``None`` as the default there would be no way to say "mount
133
+ nothing", since not passing the argument and passing ``None`` would look
134
+ identical from in here.
135
+
136
+ Shiny requires every mount path to be absolute, and sorts mount points by
137
+ descending length, so a nested mount is matched before ``/``.
138
+ """
139
+
140
+ def __init__(
141
+ self,
142
+ server: Any,
143
+ *,
144
+ ui: Any = None,
145
+ static_assets: StaticAssets | None | MISSING_TYPE = MISSING,
146
+ bookmark_store: Literal["url", "server", "disable"] = "disable",
147
+ shinyreact_js: ShinyreactJs = "server",
148
+ **kwargs: Any,
149
+ ) -> None:
150
+ # Validate now rather than at first page render: a typo should fail at
151
+ # startup, next to the call that made it.
152
+ _serves_bundle(shinyreact_js)
153
+
154
+ # The directory holding the React bundle, to be mounted at "/".
155
+ react_dir: Path | None = None
156
+
157
+ if ui is None:
158
+ # Import here: _page imports SRC_DIR_ATTR from this module.
159
+ from ._page import page_react, page_react_html
160
+
161
+ caller_file = sys._getframe(1).f_globals.get("__file__")
162
+ # No __file__ (REPL / exec'd code): fall back to CWD, matching
163
+ # page_react() / page_react_html().
164
+ app_dir = Path(caller_file).parent if caller_file else Path.cwd()
165
+ src_dir = app_dir / "www"
166
+ index_path = src_dir / "index.html"
167
+ react_dir = src_dir
168
+
169
+ def discovered_ui(request: Any) -> Any:
170
+ # Re-checked per request, not latched at construction: the UI is
171
+ # already a per-request function, so creating or deleting
172
+ # www/index.html during a dev session now switches modes without
173
+ # a restart. `exists()` is one stat call per page render.
174
+ if index_path.exists():
175
+ return page_react_html(index_path, shinyreact_js=shinyreact_js)
176
+ return page_react(src_dir=src_dir, shinyreact_js=shinyreact_js)
177
+
178
+ ui = discovered_ui
179
+
180
+ else:
181
+ react_dir = getattr(ui, SRC_DIR_ATTR, None)
182
+
183
+ if react_dir is not None and bookmark_store != "disable":
184
+ # Shiny would raise here too ("App(ui=) must be a function"),
185
+ # but the fix a shinyreact author needs is specific, and the
186
+ # obvious workaround is wrong: wrapping *this object* in a
187
+ # `lambda request: doc` yields a callable UI that never
188
+ # restores, because page_react_html() reads the RestoreContext
189
+ # when it builds the config tag — i.e. already, before any
190
+ # request. The call has to happen per request.
191
+ raise TypeError(
192
+ "bookmark_store= needs a UI that is rebuilt per request, but"
193
+ " ui=page_react_html(...) is a single document built once."
194
+ " Drop ui= and let ReactApp discover www/index.html, or pass"
195
+ " the call itself:"
196
+ ' ui=lambda request: page_react_html("client/index.html").'
197
+ )
198
+
199
+ super().__init__(
200
+ ui,
201
+ server,
202
+ static_assets=_add_react_dir(static_assets, react_dir),
203
+ bookmark_store=bookmark_store,
204
+ **kwargs,
205
+ )
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from htmltools import HTML, HTMLDependency, head_content, tags
6
+ from shiny.bookmark._restore_state import (
7
+ RestoreContext,
8
+ get_current_restore_context,
9
+ )
10
+
11
+ from ._protocol import PROTOCOL_VERSION
12
+
13
+
14
+ def _read_restore_input_values(ctx: RestoreContext) -> dict[str, object]:
15
+ """Return the underlying input value map from a RestoreContext.
16
+
17
+ Reads ``ctx.input.as_dict()`` directly. Does NOT call ``RestoreInputSet.get()``
18
+ or the public ``restore_input(id, default)`` helper — those mark each value
19
+ as pending and Shiny's normal flow would mark them used on the first flush,
20
+ making the value unavailable to subsequent ``restore_input`` callers in the
21
+ same render. We only want to *report* the values to the client; consumption
22
+ semantics are unchanged.
23
+ """
24
+ return ctx.input.as_dict()
25
+
26
+
27
+ def _current_restore_values() -> dict[str, object]:
28
+ """Restored input values for the active request, or {} when none.
29
+
30
+ Returns ``{}`` when no session/RestoreContext is active (outside an HTTP
31
+ request) or when no bookmark query string was parsed.
32
+ """
33
+ try:
34
+ ctx = get_current_restore_context()
35
+ except RuntimeError:
36
+ # No active session/RestoreContext available outside an HTTP request.
37
+ return {}
38
+ if ctx is None:
39
+ return {}
40
+ return _read_restore_input_values(ctx)
41
+
42
+
43
+ def _config_script_tag() -> HTMLDependency:
44
+ """Head-injected ``#shinyreact-config`` JSON script tag.
45
+
46
+ Always emitted by page entry points. Carries the wire-protocol version
47
+ (asserted by the JS client at boot — see
48
+ ``decisions/2026-08-17-js-distribution.md``) and, when a bookmark is being
49
+ restored, the restored input values under ``restore``.
50
+
51
+ The payload is plain JSON in a ``type="application/json"`` script tag —
52
+ the browser never executes it as JavaScript, so no JS-string-literal
53
+ escaping is needed. Exactly one property of the encoding matters:
54
+
55
+ - Every ``<`` is emitted as ``\\u003c`` so the payload can never contain
56
+ ``</script`` (which would terminate the surrounding tag) or ``<!--``.
57
+
58
+ ``json.dumps`` also defaults to ``ensure_ascii=True``, so non-ASCII lands
59
+ as ``\\uXXXX`` escapes — but nothing depends on that. U+2028 / U+2029 were
60
+ a hazard only while the payload was a JS string literal (#183); inside a
61
+ JSON tag they are inert, which is why R emits them literally and the tests
62
+ in both languages assert a *round-trip* rather than an escape.
63
+
64
+ The client reads it with ``JSON.parse``, which treats keys like
65
+ ``__proto__`` and ``constructor`` as ordinary own properties.
66
+
67
+ SECURITY
68
+ --------
69
+ Bookmarked input values appear in the rendered HTML page source. In
70
+ URL bookmark mode the values are also already in the URL itself, so this
71
+ tag adds no exposure. In server-stored bookmark mode (``?_state_id_=...``)
72
+ the URL hides the values, but this tag re-exposes them in the page
73
+ source. Anything that can read the HTML — browser extensions, logging
74
+ proxies, screen captures, "View Source" — can read these values. Apps must
75
+ not put credentials, tokens, PII, or other sensitive data into inputs that
76
+ participate in bookmarking.
77
+ """
78
+ config: dict[str, object] = {"protocolVersion": PROTOCOL_VERSION}
79
+ values = _current_restore_values()
80
+ if values:
81
+ config["restore"] = values
82
+
83
+ payload = json.dumps(config).replace("<", "\\u003c")
84
+ return head_content(
85
+ tags.script(HTML(payload), type="application/json", id="shinyreact-config")
86
+ )
shinyreact/_dep.py ADDED
@@ -0,0 +1,93 @@
1
+ from pathlib import Path
2
+ from typing import Literal
3
+
4
+ from htmltools import HTMLDependency, TagChild, TagList
5
+
6
+ from ._bookmark import _config_script_tag
7
+
8
+ _WWW_DIR = Path(__file__).parent / "www"
9
+
10
+ # `@posit-dev/shinyreact`'s version, i.e. the release of the bundle in `www/`.
11
+ # It is the shinyreact HTMLDependency's version, so
12
+ # `/lib/shinyreact-0.1.1/shinyreact.js` names the JS release being served and
13
+ # an npm-tier app can read the page source to see whether its bundled copy
14
+ # matches the server's. Bump alongside `pkg-js/package.json`; `test_dep.py`
15
+ # pins the two together. Mirrors R's `.shinyreact_js_version`.
16
+ _SHINYREACT_JS_VERSION = "0.1.1"
17
+
18
+ # Only reachable from a repo checkout (editable install), never from an
19
+ # installed wheel. Its presence is the "dev checkout" signal for
20
+ # `_bundle_version()`.
21
+ _JS_PACKAGE_JSON = Path(__file__).parents[3] / "pkg-js" / "package.json"
22
+
23
+ # Who supplies shinyreact.js (and shinyreact.css) to the page.
24
+ ShinyreactJs = Literal["server", "client"]
25
+ _SHINYREACT_JS_VALUES = ("server", "client")
26
+
27
+
28
+ def _file_mtime_int(path: Path) -> int | None:
29
+ """Return the file's mtime in whole seconds, or None if it doesn't exist."""
30
+ try:
31
+ return int(path.stat().st_mtime)
32
+ except FileNotFoundError:
33
+ return None
34
+
35
+
36
+ def _bundle_version() -> str:
37
+ """``_SHINYREACT_JS_VERSION``, suffixed with the bundle's mtime in a dev checkout.
38
+
39
+ An installed package serves ``/lib/shinyreact-0.1.1/``. In the repo
40
+ checkout it is ``/lib/shinyreact-0.1.1.<mtime>/`` instead, so a
41
+ ``make update-dist`` still cache-busts while the URL still names the release.
42
+ Mirrors R's ``.bundle_version()``.
43
+ """
44
+ if _JS_PACKAGE_JSON.is_file():
45
+ mtime = _file_mtime_int(_WWW_DIR / "shinyreact.js")
46
+ if mtime is not None:
47
+ return f"{_SHINYREACT_JS_VERSION}.{mtime}"
48
+ return _SHINYREACT_JS_VERSION
49
+
50
+
51
+ def _dep() -> HTMLDependency:
52
+ return HTMLDependency(
53
+ name="shinyreact",
54
+ version=_bundle_version(),
55
+ source={"subdir": str(_WWW_DIR)},
56
+ script={"src": "shinyreact.js", "defer": ""},
57
+ stylesheet={"href": "shinyreact.css"},
58
+ )
59
+
60
+
61
+ def _serves_bundle(shinyreact_js: ShinyreactJs) -> bool:
62
+ """Validate ``shinyreact_js=`` and say whether the page attaches the bundle.
63
+
64
+ The one place the value is checked, so every entry point rejects a typo the
65
+ same way. A bad value is a startup error rather than a page that silently
66
+ loads no hooks.
67
+ """
68
+ if shinyreact_js not in _SHINYREACT_JS_VALUES:
69
+ expected = ", ".join(repr(v) for v in _SHINYREACT_JS_VALUES)
70
+ raise ValueError(
71
+ f"shinyreact_js={shinyreact_js!r} is not valid. Expected one of "
72
+ f'{expected}. Use "server" when the shinyreact package should serve '
73
+ "shinyreact.js (the default, and what a no-build app needs), and "
74
+ '"client" when your own bundle imports @posit-dev/shinyreact and '
75
+ "therefore ships its own copy."
76
+ )
77
+ return shinyreact_js == "server"
78
+
79
+
80
+ def _dep_page(shinyreact_js: ShinyreactJs = "server") -> TagChild:
81
+ """Page-level shinyreact dependency: bundle + ``#shinyreact-config`` tag.
82
+
83
+ Use from page entry points (``page_react_html``, ``set_react_page``'s page
84
+ function) — the config tag carries the protocol version on every page and
85
+ the bookmark restore payload when one is active.
86
+
87
+ ``shinyreact_js="client"`` omits ``shinyreact.js`` / ``shinyreact.css`` for
88
+ npm-tier pages, whose client bundle ships its own copy. The config tag is
89
+ always emitted: it carries the protocol version and any bookmark restore
90
+ payload.
91
+ """
92
+ bundle = _dep() if _serves_bundle(shinyreact_js) else None
93
+ return TagList(bundle, _config_script_tag())
@@ -0,0 +1,76 @@
1
+ """Automatic renderer HTML-dependency discovery for Core mode (#146, #203, #220).
2
+
3
+ Express's ``set_react_page()`` can inline renderer dependencies into the page
4
+ head because the page function runs after the renderers mount. A Core-mode page
5
+ (:func:`page_react`, :func:`page_react_html`) is built before ``server()`` runs,
6
+ so there is nothing to inline. Instead — the same design R uses
7
+ (``pkg-r/R/dep-discovery.R``) — after every reactive flush we diff the session's
8
+ registered outputs, extract each new output's UI, and push any not-yet-sent
9
+ dependencies to the client as a ``shinyreact-deps`` custom message. The JS
10
+ bundle loads them and re-runs ``Shiny.bindAll()``.
11
+
12
+ Diffing on *every* flush (not just the first) also covers outputs registered
13
+ after startup — e.g. a module server mounted inside an observer.
14
+
15
+ The hook: the JS bundle sends one ``.shinyreact_init`` ping (type
16
+ ``shinyreact.init``) after Shiny initializes; that type's input handler
17
+ (``_input_handler.py``) calls :func:`install_dep_discovery`. Every session gets
18
+ exactly one ping, whether or not the app has any other inputs.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import TYPE_CHECKING, Any, cast
24
+
25
+ from htmltools import HTMLDependency, Tag, TagList
26
+
27
+ if TYPE_CHECKING:
28
+ from shiny.session import Session
29
+
30
+ _INSTALLED_FLAG = "_shinyreact_dep_discovery"
31
+
32
+
33
+ def install_dep_discovery(session: Session | None) -> bool:
34
+ """Install the per-session flush hook. Returns whether it was installed."""
35
+ if session is None:
36
+ return False
37
+ # `_outputs` and `on_flushed` are missing on mock/express-stub sessions;
38
+ # discovery no-ops there rather than raising.
39
+ outputs = getattr(getattr(session, "output", None), "_outputs", None)
40
+ if outputs is None or not callable(getattr(session, "on_flushed", None)):
41
+ return False
42
+ # Two copies of the JS bundle on one page send two pings; install once.
43
+ # (py-shiny has no `session$userData` equivalent, so: an attribute.)
44
+ if getattr(session, _INSTALLED_FLAG, False):
45
+ return False
46
+ setattr(session, _INSTALLED_FLAG, True)
47
+
48
+ seen_outputs: set[str] = set()
49
+ sent_deps: set[str] = set()
50
+
51
+ async def push_new_output_deps() -> None:
52
+ new_names = [name for name in outputs if name not in seen_outputs]
53
+ if not new_names:
54
+ return
55
+ seen_outputs.update(new_names)
56
+
57
+ deps: list[HTMLDependency] = []
58
+ for name in new_names:
59
+ ui = outputs[name].renderer.auto_output_ui()
60
+ if isinstance(ui, (Tag, TagList)):
61
+ deps.extend(ui.tagify().get_dependencies())
62
+ deps = [d for d in deps if f"{d.name}@{d.version}" not in sent_deps]
63
+ if not deps:
64
+ return
65
+ sent_deps.update(f"{d.name}@{d.version}" for d in deps)
66
+
67
+ # `_process_ui()` registers each dep's resource route with the app and
68
+ # returns the client-side JSON (Python's `createWebDependency()`). The
69
+ # client skips deps already on the page, so overlap is harmless.
70
+ payload = session._process_ui(TagList(*deps))["deps"]
71
+ await session.send_custom_message(
72
+ "shinyreact-deps", cast("dict[str, object]", payload)
73
+ )
74
+
75
+ session.on_flushed(cast(Any, push_new_output_deps), once=False)
76
+ return True
@@ -0,0 +1,42 @@
1
+ """Built-in shinyreact input handlers, registered on import.
2
+
3
+ See docs/superpowers/specs/2026-06-04-shinyreact-default-input-handler-design.md.
4
+
5
+ Python's deserializer never simplifies the way R's does (an array of objects is
6
+ already a list of dicts), so both handlers are no-ops here. They exist so that
7
+ the ``:shinyreact.default`` / ``:shinyreact.asis`` wire suffixes do not raise
8
+ "No input handler registered for type" on the Python server.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from shiny.input_handler import input_handlers
16
+
17
+ from ._dep_discovery import install_dep_discovery
18
+
19
+ if TYPE_CHECKING:
20
+ from shiny.module import ResolvedId
21
+ from shiny.session import Session
22
+
23
+
24
+ # force=True so re-importing this module (e.g. importlib.reload during dev or
25
+ # tests) re-registers idempotently instead of raising "already registered".
26
+ @input_handlers.add("shinyreact.default", force=True)
27
+ def _shinyreact_default(value: Any, name: ResolvedId, session: Session) -> Any:
28
+ return value
29
+
30
+
31
+ @input_handlers.add("shinyreact.asis", force=True)
32
+ def _shinyreact_asis(value: Any, name: ResolvedId, session: Session) -> Any:
33
+ return value
34
+
35
+
36
+ # The JS bundle sends one `.shinyreact_init:shinyreact.init` ping per session
37
+ # after Shiny initializes (pkg-js/src/dep-discovery.ts); the handler bootstraps
38
+ # automatic output dependency discovery, matching R (issue #220).
39
+ @input_handlers.add("shinyreact.init", force=True)
40
+ def _shinyreact_init(value: Any, name: ResolvedId, session: Session) -> Any:
41
+ install_dep_discovery(session)
42
+ return value