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.
@@ -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
+ )
@@ -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)}}