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
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from shiny.render.renderer import Renderer
|
|
4
|
+
from shiny.types import Jsonifiable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class reactive_output(Renderer["Jsonifiable"]):
|
|
8
|
+
"""Publish a reactive JSON value to the client (the ``ui.tsx`` pattern).
|
|
9
|
+
|
|
10
|
+
Assign to ``output[id]`` where a React client reads the value with
|
|
11
|
+
``useShinyOutputValue()``. There is no UI placeholder: ``auto_output_ui()``
|
|
12
|
+
inherits the base implementation, which returns ``None``.
|
|
13
|
+
|
|
14
|
+
Accepts any JSON-serializable value (``dict``, ``list``, ``str``, ``int``,
|
|
15
|
+
``float``, ``bool``, ``None``), passed through unchanged.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
async def transform(self, value: Jsonifiable) -> Jsonifiable:
|
|
19
|
+
return value
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from shiny.module import resolve_id
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from shiny.session import Session
|
|
9
|
+
from shiny.types import Jsonifiable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def send_message(
|
|
13
|
+
session: Session,
|
|
14
|
+
id: str,
|
|
15
|
+
data: Jsonifiable,
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Send a custom message from server to client React components.
|
|
18
|
+
|
|
19
|
+
Messages are consumed by ``useShinyMessageHandler(id, handler)`` on the
|
|
20
|
+
React side via the ``@posit/shiny-react`` hooks bundled in shinyreact.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
session: The Shiny session to send the message through.
|
|
24
|
+
id: The message id, module-resolved like input/output ids. Must match
|
|
25
|
+
the ``id`` argument passed to ``useShinyMessageHandler()`` in the
|
|
26
|
+
React component.
|
|
27
|
+
data: Any JSON-serializable data to include in the message.
|
|
28
|
+
|
|
29
|
+
Example::
|
|
30
|
+
|
|
31
|
+
@reactive.effect
|
|
32
|
+
async def notify():
|
|
33
|
+
await shinyreact.send_message(
|
|
34
|
+
session, "notification", {"text": "Hello!", "level": "info"}
|
|
35
|
+
)
|
|
36
|
+
"""
|
|
37
|
+
namespaced_id = resolve_id(id)
|
|
38
|
+
await session.send_custom_message(
|
|
39
|
+
"shinyReactMessage", {"id": namespaced_id, "data": data}
|
|
40
|
+
)
|
shinyreact/playwright.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Websocket wire assertions for Playwright tests.
|
|
2
|
+
|
|
3
|
+
``WireTap`` gives tests access to the JSON payloads that actually crossed the
|
|
4
|
+
Shiny websocket — the contract between the server and the React client.
|
|
5
|
+
Playwright is a test-only dependency: importing this module (not the
|
|
6
|
+
``shinyreact`` package) requires it.
|
|
7
|
+
|
|
8
|
+
The R counterpart is ``shinyreact::wire_tap()``, with the same methods and
|
|
9
|
+
semantics.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import playwright.sync_api # noqa: F401 (presence check only)
|
|
20
|
+
except ImportError as e: # pragma: no cover
|
|
21
|
+
raise ImportError(
|
|
22
|
+
"shinyreact.playwright requires the `playwright` package, which is a "
|
|
23
|
+
"test-only dependency. Install it with `pip install pytest-playwright` "
|
|
24
|
+
"(or `pip install playwright`)."
|
|
25
|
+
) from e
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from playwright.sync_api import Page, WebSocket
|
|
29
|
+
|
|
30
|
+
__all__ = ("WireTap",)
|
|
31
|
+
|
|
32
|
+
# An expectation matcher: a callable is satisfied by a truthy return; any
|
|
33
|
+
# other object is compared for equality.
|
|
34
|
+
Matcher = Callable[[Any], Any] | Any
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WireTap:
|
|
38
|
+
"""Passive tap on the Shiny websocket, with retrying expectations.
|
|
39
|
+
|
|
40
|
+
Construct it before ``page.goto()`` — history is complete from
|
|
41
|
+
construction onward::
|
|
42
|
+
|
|
43
|
+
tap = WireTap(page)
|
|
44
|
+
page.goto(app.url)
|
|
45
|
+
tap.expect_input_value("bins", 30)
|
|
46
|
+
tap.expect_output_value("dist_data", lambda d: d["breaks"][0] == 43.0)
|
|
47
|
+
|
|
48
|
+
Cross-channel frame order (which output lands first, how outputs batch
|
|
49
|
+
into a single ``values`` frame, busy/progress interleaving) is
|
|
50
|
+
reactive-scheduling coincidence, not contract — so the tap deliberately
|
|
51
|
+
does not expose a global frame stream. Within one channel (one output id,
|
|
52
|
+
one message type, one input id) wire order is guaranteed, and the
|
|
53
|
+
``expect_*`` methods consume it through a cursor: each expectation scans
|
|
54
|
+
the recorded history from just past the previous match, so values that
|
|
55
|
+
arrive between checks are never missed — capture is lossless; polling
|
|
56
|
+
only decides when to re-scan. Successive expectations on one channel
|
|
57
|
+
therefore assert an ordered subsequence.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, page: Page) -> None:
|
|
61
|
+
# Raw (direction, frame) stream. Cross-channel order in here is NOT a
|
|
62
|
+
# contract; the per-channel methods below are the public surface.
|
|
63
|
+
self._frames: list[tuple[str, dict[str, Any]]] = []
|
|
64
|
+
# Per-channel scan cursors for the expect_* methods.
|
|
65
|
+
self._cursors: dict[tuple[str, str], int] = {}
|
|
66
|
+
self._page = page
|
|
67
|
+
|
|
68
|
+
def on_websocket(ws: WebSocket) -> None:
|
|
69
|
+
ws.on("framesent", lambda payload: self._add("send", payload))
|
|
70
|
+
ws.on("framereceived", lambda payload: self._add("recv", payload))
|
|
71
|
+
|
|
72
|
+
page.on("websocket", on_websocket)
|
|
73
|
+
|
|
74
|
+
def _add(self, direction: str, payload: str | bytes) -> None:
|
|
75
|
+
if isinstance(payload, bytes):
|
|
76
|
+
return
|
|
77
|
+
try:
|
|
78
|
+
frame = json.loads(payload)
|
|
79
|
+
except ValueError:
|
|
80
|
+
return
|
|
81
|
+
if isinstance(frame, dict):
|
|
82
|
+
self._frames.append((direction, frame))
|
|
83
|
+
|
|
84
|
+
# --- complete per-channel history (cursor-independent) -----------------
|
|
85
|
+
|
|
86
|
+
def all_output_values(self, output_id: str) -> list[Any]:
|
|
87
|
+
"""Every value the server delivered for `output_id`, in order."""
|
|
88
|
+
return [
|
|
89
|
+
frame["values"][output_id]
|
|
90
|
+
for direction, frame in self._frames
|
|
91
|
+
if direction == "recv" and output_id in frame.get("values", {})
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
def all_messages(self, message_id: str) -> list[Any]:
|
|
95
|
+
"""Every ``send_message()`` payload of `message_id`, in order."""
|
|
96
|
+
out: list[Any] = []
|
|
97
|
+
for direction, frame in self._frames:
|
|
98
|
+
if direction != "recv":
|
|
99
|
+
continue
|
|
100
|
+
# Payload shape {id, data} per protocol/surface.json.
|
|
101
|
+
msg = frame.get("custom", {}).get("shinyReactMessage")
|
|
102
|
+
if msg and msg.get("id") == message_id:
|
|
103
|
+
out.append(msg.get("data"))
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
def all_input_values(self, input_id: str) -> list[Any]:
|
|
107
|
+
"""Every value the client sent for `input_id`, in order.
|
|
108
|
+
|
|
109
|
+
Matches the bare id or any ``id:type`` wire id (e.g. the implicit
|
|
110
|
+
``:shinyreact.default`` suffix), so use the id you wrote in
|
|
111
|
+
``useShinyInput()``.
|
|
112
|
+
"""
|
|
113
|
+
out: list[Any] = []
|
|
114
|
+
for direction, frame in self._frames:
|
|
115
|
+
if direction != "send":
|
|
116
|
+
continue
|
|
117
|
+
data = frame.get("data")
|
|
118
|
+
if not isinstance(data, dict):
|
|
119
|
+
continue
|
|
120
|
+
for key, value in data.items():
|
|
121
|
+
if key == input_id or key.startswith(input_id + ":"):
|
|
122
|
+
out.append(value)
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
# --- retrying expectations (cursor-consuming) ---------------------------
|
|
126
|
+
|
|
127
|
+
def expect_output_value(
|
|
128
|
+
self, output_id: str, matcher: Matcher, timeout_s: float = 10
|
|
129
|
+
) -> Any:
|
|
130
|
+
"""Retrying expectation against the values delivered for `output_id`.
|
|
131
|
+
|
|
132
|
+
A callable `matcher` is satisfied by a truthy return; any other
|
|
133
|
+
object is compared for equality. Returns the matched value, or raises
|
|
134
|
+
``AssertionError`` at `timeout_s`. A matcher that raises on a value's
|
|
135
|
+
shape counts as a non-match.
|
|
136
|
+
"""
|
|
137
|
+
return self._expect(
|
|
138
|
+
("output", output_id),
|
|
139
|
+
lambda: self.all_output_values(output_id),
|
|
140
|
+
matcher,
|
|
141
|
+
timeout_s,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def expect_message(
|
|
145
|
+
self, message_id: str, matcher: Matcher, timeout_s: float = 10
|
|
146
|
+
) -> Any:
|
|
147
|
+
"""As :meth:`expect_output_value`, for ``send_message()`` payloads."""
|
|
148
|
+
return self._expect(
|
|
149
|
+
("message", message_id),
|
|
150
|
+
lambda: self.all_messages(message_id),
|
|
151
|
+
matcher,
|
|
152
|
+
timeout_s,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def expect_input_value(
|
|
156
|
+
self, input_id: str, matcher: Matcher, timeout_s: float = 10
|
|
157
|
+
) -> Any:
|
|
158
|
+
"""As :meth:`expect_output_value`, for client-sent input values."""
|
|
159
|
+
return self._expect(
|
|
160
|
+
("input", input_id),
|
|
161
|
+
lambda: self.all_input_values(input_id),
|
|
162
|
+
matcher,
|
|
163
|
+
timeout_s,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def _expect(
|
|
167
|
+
self,
|
|
168
|
+
channel: tuple[str, str],
|
|
169
|
+
values: Callable[[], list[Any]],
|
|
170
|
+
matcher: Matcher,
|
|
171
|
+
timeout_s: float,
|
|
172
|
+
) -> Any:
|
|
173
|
+
matches: Callable[[Any], Any] = (
|
|
174
|
+
matcher if callable(matcher) else lambda v: v == matcher
|
|
175
|
+
)
|
|
176
|
+
deadline = time.monotonic() + timeout_s
|
|
177
|
+
last_exc: Exception | None = None
|
|
178
|
+
while True:
|
|
179
|
+
vals = values()
|
|
180
|
+
start = self._cursors.get(channel, 0)
|
|
181
|
+
for i in range(start, len(vals)):
|
|
182
|
+
# A matcher that blows up on a value's shape (e.g. an early
|
|
183
|
+
# `output: null` frame) is a non-match, not a test error; the
|
|
184
|
+
# timeout message reports the last exception.
|
|
185
|
+
try:
|
|
186
|
+
matched = matches(vals[i])
|
|
187
|
+
except Exception as e:
|
|
188
|
+
matched = False
|
|
189
|
+
last_exc = e
|
|
190
|
+
if matched:
|
|
191
|
+
# Consume through the match: the next expectation on this
|
|
192
|
+
# channel scans strictly-later values (ordered subsequence
|
|
193
|
+
# semantics). Non-matching values stay visible via the
|
|
194
|
+
# all_* views.
|
|
195
|
+
self._cursors[channel] = i + 1
|
|
196
|
+
return vals[i]
|
|
197
|
+
if time.monotonic() >= deadline:
|
|
198
|
+
raise AssertionError(
|
|
199
|
+
f"expect_{channel[0]}({channel[1]!r}): no matching value "
|
|
200
|
+
f"within {timeout_s}s; scanned {len(vals) - start} "
|
|
201
|
+
f"value(s) past the cursor: {vals[start:]!r}"
|
|
202
|
+
+ (f"; last matcher error: {last_exc!r}" if last_exc else "")
|
|
203
|
+
)
|
|
204
|
+
self._page.wait_for_timeout(100)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@keyframes spin{to{transform:rotate(360deg)}}
|