Skip to content

Commit

Permalink
Cause RecallContextManagers to run when used without with (#992)
Browse files Browse the repository at this point in the history
  • Loading branch information
wch authored Jan 11, 2024
1 parent b0582f1 commit a4ab950
Show file tree
Hide file tree
Showing 2 changed files with 54 additions and 2 deletions.
23 changes: 21 additions & 2 deletions shiny/express/_recall_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ def __init__(
kwargs = {}
self.args: list[object] = list(args)
self.kwargs: dict[str, object] = dict(kwargs)
# Let htmltools.wrap_displayhook_handler decide what to do with objects before
# we append them.
self.wrapped_append = wrap_displayhook_handler(self.args.append)

def __enter__(self) -> None:
self._prev_displayhook = sys.displayhook
# Collect each of the "printed" values in the args list.
sys.displayhook = wrap_displayhook_handler(self.args.append)
sys.displayhook = self.displayhook

def __exit__(
self,
Expand All @@ -47,6 +49,23 @@ def __exit__(
sys.displayhook(res)
return False

def displayhook(self, x: object) -> None:
if isinstance(x, RecallContextManager):
# This displayhook first checks if x (the child) is a RecallContextManager,
# in which case it uses `with x` to trigger x.__enter__() and x.__exit__().
# When x.__exit__() is called, it will invoke x.fn() and then pass the
# result to this object's (the parent) self.displayhook(), which is this
# same function, but instead of passing in a RecallContextManager, it will
# pass in the actual object.
#
# In short, this is a way of invoking a re-entrant call to the current
# function, but instead of passing in a RecallContextManager, it passes in
# the result from the RecallContextManager.
with x:
pass
else:
self.wrapped_append(x)

def tagify(self) -> Tag | TagList | MetadataNode | str:
res = self.fn(*self.args, **self.kwargs)

Expand Down
33 changes: 33 additions & 0 deletions tests/pytest/test_express_ui.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import sys
import tempfile
from pathlib import Path
from typing import Any

import pytest

from shiny import render, ui
from shiny.express import suspend_display, ui_kwargs
from shiny.express._run import run_express


def test_express_ui_is_complete():
Expand Down Expand Up @@ -104,3 +107,33 @@ def whatever(x: Any):

finally:
sys.displayhook = old_displayhook


def test_recall_context_manager():
# A Shiny Express app that uses a RecallContextManager (ui.card_header()) without
# `with`. It is used within another RecallContextManager (ui.card()), but that one
# is used with `with`. This test makes sure that the non-with RecallContextManager
# will invoke the wrapped function and its result will be passed to the parent.

card_app_express_text = """\
from shiny.express import ui
with ui.card():
ui.card_header("Header")
"Body"
"""

# The same UI, written in the Shiny Core style.
card_app_core = ui.page_fixed(
ui.card(
ui.card_header("Header"),
"Body",
)
)

with tempfile.NamedTemporaryFile(mode="w+t") as temp_file:
temp_file.write(card_app_express_text)
temp_file.flush()
res = run_express(Path(temp_file.name)).tagify()

assert str(res) == str(card_app_core)

0 comments on commit a4ab950

Please sign in to comment.