Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* Updated example apps to use lower-case versions of `reactive.Calc`->`reactive.calc`, `reactive.Effect`->`reactive.effect`, and `reactive.Value`->`reactive.value`. (#1164)

* Closed #1081: The `@expressify()` function now has an option `has_docstring`. This allows the decorator to be used with functions that contain a docstring. (#1163)

### Bug fixes

* Fixed `input_task_button` not working in a Shiny module. (#1108)
Expand Down
34 changes: 26 additions & 8 deletions shiny/express/expressify_decorator/_expressify.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,15 @@ def expressify(fn: TFunc) -> TFunc: ...


@overload
def expressify() -> Callable[[TFunc], TFunc]: ...
def expressify(*, has_docstring: bool = False) -> Callable[[TFunc], TFunc]: ...


@no_example()
def expressify(fn: TFunc | None = None) -> TFunc | Callable[[TFunc], TFunc]:
def expressify(
fn: TFunc | None = None,
*,
has_docstring: bool = False,
) -> TFunc | Callable[[TFunc], TFunc]:
"""
Decorate a function so that output is captured as in Shiny Express

Expand All @@ -115,6 +119,10 @@ def expressify(fn: TFunc | None = None) -> TFunc | Callable[[TFunc], TFunc]:
----------
fn :
The function to decorate. If not provided, this is a decorator factory.
has_docstring :
Whether the function has a docstring. Set this to `True` if the function to
decorate has a docstring. This tells `expressify()` to *not* capture the
docstring and display it in the UI.

Returns
-------
Expand All @@ -133,13 +141,13 @@ def decorator(fn: TFunc) -> TFunc:
# Disable code caching on Pyodide due to bug in hashing bytecode in 0.22.1.
# When Pyodide is updated to a newer version, this will be not be needed.
# https://github.com/posit-dev/py-shiny/issues/1042#issuecomment-1901945787
fcode = _transform_body(cast(types.FunctionType, fn))
fcode = _transform_body(cast(types.FunctionType, fn), has_docstring)
else:
if fn.__code__ in code_cache:
fcode = code_cache[fn.__code__]
else:
# Save for next time
fcode = _transform_body(cast(types.FunctionType, fn))
fcode = _transform_body(cast(types.FunctionType, fn), has_docstring)
code_cache[fn.__code__] = fcode

# Create a new function from the code object
Expand Down Expand Up @@ -169,7 +177,10 @@ def decorator(fn: TFunc) -> TFunc:
return decorator


def _transform_body(fn: types.FunctionType) -> types.CodeType:
def _transform_body(
fn: types.FunctionType,
has_docstring: bool = False,
) -> types.CodeType:
# The approach we take here is much more complicated than what you'd expect.
#
# The simple approach is to use ast.parse() to get an AST for the function,
Expand Down Expand Up @@ -197,10 +208,14 @@ def _transform_body(fn: types.FunctionType) -> types.CodeType:
" This should never happen, please file an issue!"
)

# A wrapper for _transform_function_ast that conveys the value of has_docstring.
def transform_function_ast_local(node: ast.AST) -> ast.AST:
return _transform_function_ast(node, has_docstring)

tft = TargetFunctionTransformer(
fn,
# If we find `fn` in the AST, use transform its body to use displayhook
_transform_function_ast,
transform_function_ast_local,
)

new_ast = tft.visit(parsed_ast)
Expand Down Expand Up @@ -248,10 +263,13 @@ def read_ast(filename: str) -> ast.Module | None:
return ast.parse("".join(lines), filename=filename)


def _transform_function_ast(node: ast.AST) -> ast.AST:
def _transform_function_ast(node: ast.AST, has_docstring: bool = False) -> ast.AST:
if not isinstance(node, ast.FunctionDef):
return node
func_node = cast(ast.FunctionDef, FuncBodyDisplayHookTransformer().visit(node))
func_node = cast(
ast.FunctionDef,
FuncBodyDisplayHookTransformer(has_docstring).visit(node),
)
func_node.body = [
DisplayFuncsTransformer().visit(child) for child in func_node.body
]
Expand Down
17 changes: 16 additions & 1 deletion shiny/express/expressify_decorator/_node_transformers.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@ class FuncBodyDisplayHookTransformer(TopLevelTransformer):
FunctionDef it finds will be transformed.
"""

def __init__(self):
def __init__(self, has_docstring: bool = False):
self.saw_func = False
self.has_docstring = has_docstring
self.has_visited_first_node = False

def visit_FunctionDef(self, node: ast.FunctionDef) -> object:
"""
Expand All @@ -78,6 +80,19 @@ def visit_Expr(self, node: ast.Expr) -> object:
Note that we don't descend into the node, so child expressions will not
be affected.
"""
if not self.has_visited_first_node:
self.has_visited_first_node = True

# If the first node is meant to be treated as a docstring, first make sure
# it actually is a static string, and return it without wrapping it with the
# displayhook.
if (
self.has_docstring
and isinstance(node.value, ast.Constant)
and isinstance(node.value.value, str)
):
return node

return ast.Expr(
value=ast.Call(
func=ast.Name(id=sys_alias, ctx=ast.Load()),
Expand Down