Skip to content

Commit fc3549f

Browse files
authored
Merge pull request #71 from rstudio/value-silent-exception
2 parents 997b384 + 7a6958c commit fc3549f

File tree

4 files changed

+180
-22
lines changed

4 files changed

+180
-22
lines changed

shiny/reactive/_reactives.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
Union,
1313
Generic,
1414
cast,
15+
overload,
1516
)
1617
import warnings
1718

@@ -28,18 +29,40 @@
2829
# Value
2930
# ==============================================================================
3031
class Value(Generic[T]):
31-
def __init__(self, value: T, *, _read_only: bool = False) -> None:
32+
# These overloads are necessary so that the following hold:
33+
# - Value() is marked by the type checker as an error, because the type T is
34+
# unknown. (It is not a run-time error.)
35+
# - Value[int]() works.
36+
# - Value[int](1) works.
37+
# - Value(1) works, with T is inferred to be int.
38+
@overload
39+
def __init__(
40+
self, value: MISSING_TYPE = MISSING, *, read_only: bool = False
41+
) -> None:
42+
...
43+
44+
@overload
45+
def __init__(self, value: T, *, read_only: bool = False) -> None:
46+
...
47+
48+
# If `value` is MISSING, then `get()` will raise a SilentException, until a new
49+
# value is set. Calling `unset()` will set the value to MISSING.
50+
def __init__(
51+
self, value: Union[T, MISSING_TYPE] = MISSING, *, read_only: bool = False
52+
) -> None:
3253
self._value: T = value
33-
self._read_only: bool = _read_only
54+
self._read_only: bool = read_only
3455
self._dependents: Dependents = Dependents()
3556

36-
# Calling the object is equivalent to `.get()`
3757
def __call__(self) -> T:
38-
self._dependents.register()
39-
return self._value
58+
return self.get()
4059

4160
def get(self) -> T:
4261
self._dependents.register()
62+
63+
if isinstance(self._value, MISSING_TYPE):
64+
raise SilentException
65+
4366
return self._value
4467

4568
def set(self, value: T) -> bool:
@@ -59,6 +82,17 @@ def _set(self, value: T) -> bool:
5982
self._dependents.invalidate()
6083
return True
6184

85+
def unset(self) -> None:
86+
self.set(MISSING) # type: ignore
87+
88+
def is_set(self) -> bool:
89+
self._dependents.register()
90+
return not isinstance(self._value, MISSING_TYPE)
91+
92+
# Like unset(), except that this does not invalidate dependents.
93+
def freeze(self) -> None:
94+
self._value = MISSING
95+
6296

6397
# ==============================================================================
6498
# Calc

shiny/session/_session.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
from .. import _utils
5454
from .._fileupload import FileInfo, FileUploadManager
5555
from ..input_handler import input_handlers
56-
from ..types import SafeException, SilentCancelOutputException, SilentException
56+
from ..types import MISSING, SafeException, SilentCancelOutputException, SilentException
5757
from ._utils import RenderedDeps, read_thunk_opt, session_context
5858

5959
# This cast is necessary because if the type checker thinks that if
@@ -543,7 +543,7 @@ class Inputs:
543543
def __init__(self, **kwargs: object) -> None:
544544
self._map: dict[str, Value[Any]] = {}
545545
for key, value in kwargs.items():
546-
self._map[key] = Value(value, _read_only=True)
546+
self._map[key] = Value(value, read_only=True)
547547

548548
def __setitem__(self, key: str, value: Value[Any]) -> None:
549549
if not isinstance(value, Value):
@@ -556,7 +556,7 @@ def __getitem__(self, key: str) -> Value[Any]:
556556
# dependencies on input values that haven't been received from client
557557
# yet.
558558
if key not in self._map:
559-
self._map[key] = Value(None, _read_only=True)
559+
self._map[key] = Value(read_only=True)
560560

561561
return self._map[key]
562562

@@ -677,12 +677,10 @@ def _should_suspend(self, name: str) -> bool:
677677

678678
def _is_hidden(self, name: str) -> bool:
679679
with isolate():
680-
hidden = cast(
681-
Optional[bool],
682-
self._session.input[f".clientdata_output_{name}_hidden"](),
680+
hidden_value_obj = cast(
681+
Value[bool], self._session.input[f".clientdata_output_{name}_hidden"]
683682
)
683+
if not hidden_value_obj.is_set():
684+
return True
684685

685-
if hidden is None:
686-
return True
687-
else:
688-
return hidden
686+
return hidden_value_obj()

tests/test_modules.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,9 @@ async def test_inputs_proxy():
4848
with isolate():
4949
assert input.a() == 1
5050
# Different ways of accessing "a" from the input proxy.
51-
assert input_proxy.a() is None
52-
assert input_proxy["a"]() is None
53-
assert input["mod1-a"]() is None
51+
assert input_proxy.a.is_set() is False
52+
assert input_proxy["a"].is_set() is False
53+
assert input["mod1-a"].is_set() is False
5454

5555
input_proxy.a._set(2)
5656

@@ -66,9 +66,9 @@ async def test_inputs_proxy():
6666
assert input.a() == 1
6767
assert input_proxy.a() == 2
6868
# Different ways of accessing "a" from the input proxy.
69-
assert input_proxy_proxy.a() is None
70-
assert input_proxy_proxy["a"]() is None
71-
assert input["mod1-mod2-a"]() is None
69+
assert input_proxy_proxy.a.is_set() is False
70+
assert input_proxy_proxy["a"].is_set() is False
71+
assert input_proxy["mod1-a"].is_set() is False
7272

7373
input_proxy_proxy.a._set(3)
7474

tests/test_reactives.py

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
from shiny.reactive._core import ReactiveWarning
99
from shiny._decorators import *
1010
from shiny.reactive import *
11-
from shiny._validation import req
11+
from shiny.types import MISSING
12+
from shiny._validation import SilentException, req
1213

1314
from .mocktime import MockTime
1415

@@ -90,6 +91,65 @@ def o():
9091
assert o._exec_count == 1
9192

9293

94+
# ======================================================================
95+
# Intializing reactive.Value to MISSING, and unsetting
96+
# ======================================================================
97+
@pytest.mark.asyncio
98+
async def test_reactive_value_unset():
99+
v = Value[int](MISSING)
100+
101+
with isolate():
102+
assert v.is_set() is False
103+
with pytest.raises(SilentException):
104+
v()
105+
106+
val: int = 0
107+
108+
@effect()
109+
def o():
110+
nonlocal val
111+
val = v()
112+
113+
await flush()
114+
assert o._exec_count == 1
115+
assert val == 0
116+
117+
v.set(1)
118+
await flush()
119+
assert o._exec_count == 2
120+
assert val == 1
121+
122+
v.unset()
123+
await flush()
124+
assert o._exec_count == 3
125+
assert val == 1
126+
127+
with isolate():
128+
assert v.is_set() is False
129+
with pytest.raises(SilentException):
130+
v()
131+
132+
# Check that dependency is taken when is_set() is called.
133+
v = Value[int](MISSING)
134+
val2: Union[bool, None] = None
135+
136+
@effect()
137+
def o2():
138+
nonlocal val2
139+
val2 = v.is_set()
140+
141+
await flush()
142+
assert val2 is False
143+
144+
v.set(1)
145+
await flush()
146+
assert val2 is True
147+
148+
v.unset()
149+
await flush()
150+
assert val2 is False
151+
152+
93153
# ======================================================================
94154
# Recursive calls to calcs
95155
# ======================================================================
@@ -941,6 +1001,72 @@ async def _():
9411001
assert n_times == 7
9421002

9431003

1004+
# ------------------------------------------------------------
1005+
# @event() handles silent exceptions in event function, async
1006+
# ------------------------------------------------------------
1007+
@pytest.mark.asyncio
1008+
async def test_event_silent_exception_async():
1009+
n_times = 0
1010+
x = Value[bool]()
1011+
1012+
async def req_fn() -> int:
1013+
await asyncio.sleep(0)
1014+
x()
1015+
return 1234
1016+
1017+
@effect()
1018+
@event(req_fn)
1019+
async def _():
1020+
await asyncio.sleep(0)
1021+
nonlocal n_times
1022+
n_times += 1
1023+
1024+
await flush()
1025+
assert n_times == 0
1026+
1027+
x.set(True)
1028+
await flush()
1029+
assert n_times == 1
1030+
1031+
x.unset()
1032+
await flush()
1033+
assert n_times == 1
1034+
1035+
x.set(True)
1036+
await flush()
1037+
assert n_times == 2
1038+
1039+
1040+
# ------------------------------------------------------------
1041+
# @event() handles silent exceptions in async event function
1042+
# ------------------------------------------------------------
1043+
@pytest.mark.asyncio
1044+
async def test_event_silent_exception():
1045+
n_times = 0
1046+
x = Value[bool]()
1047+
1048+
@effect()
1049+
@event(x)
1050+
def _():
1051+
nonlocal n_times
1052+
n_times += 1
1053+
1054+
await flush()
1055+
assert n_times == 0
1056+
1057+
x.set(True)
1058+
await flush()
1059+
assert n_times == 1
1060+
1061+
x.unset()
1062+
await flush()
1063+
assert n_times == 1
1064+
1065+
x.set(True)
1066+
await flush()
1067+
assert n_times == 2
1068+
1069+
9441070
# ------------------------------------------------------------
9451071
# @effect()'s .suspend()/.resume() works as expected
9461072
# ------------------------------------------------------------

0 commit comments

Comments
 (0)