From 243fe476dc2344bc1f5a92ea53b5729d222d781a Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Wed, 10 Jan 2024 17:31:02 -0600 Subject: [PATCH 1/2] Cause RecallContextManagers to run when used without `with` --- shiny/express/_recall_context.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/shiny/express/_recall_context.py b/shiny/express/_recall_context.py index e491f733c..f846de89c 100644 --- a/shiny/express/_recall_context.py +++ b/shiny/express/_recall_context.py @@ -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, @@ -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) From 4bec60997041e7cf058e218d08e7d018a7fda2d3 Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Thu, 11 Jan 2024 00:56:13 -0600 Subject: [PATCH 2/2] Add test --- tests/pytest/test_express_ui.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/pytest/test_express_ui.py b/tests/pytest/test_express_ui.py index 48b43aa53..2f5a7cc0d 100644 --- a/tests/pytest/test_express_ui.py +++ b/tests/pytest/test_express_ui.py @@ -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(): @@ -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)