Skip to content

Update @add_examples() to work with Express, avoid Express docs example errors for now #1073

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 26 commits into from
Jan 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
45d35a6
ex(page_sidebar): remove `@output` decorator
gadenbuie Jan 16, 2024
b17f0f6
expressify: api-examples/page_sidebar
gadenbuie Jan 16, 2024
37f1fe9
expressify: api-examples/sidebar
gadenbuie Jan 16, 2024
5ff52bb
expressify: api-examples/layout_column_wrap
gadenbuie Jan 16, 2024
efd57b2
expressify: api-examples/layout_columns
gadenbuie Jan 16, 2024
6c09496
expressify: api-examples/layout_sidebar
gadenbuie Jan 16, 2024
d941199
expressify: api-examples/markdown
gadenbuie Jan 16, 2024
930759c
expressify: api-examples/panel_absolute
gadenbuie Jan 16, 2024
81f0314
expressify: api-examples/panel_conditional
gadenbuie Jan 16, 2024
4023397
Apply suggestions from code review
gadenbuie Jan 24, 2024
4f602f8
Update shiny/api-examples/panel_conditional/app-express.py
gadenbuie Jan 24, 2024
9c5a87c
Linting
Jan 25, 2024
e24d44e
expressify-input-examples (#1057)
Jan 25, 2024
3dd8257
Update `@add_examples()` to work with split core/express docs
gadenbuie Jan 25, 2024
2b77b14
Merge branch 'docs/expressify/api-examples' into add-examples-express…
gadenbuie Jan 25, 2024
c2c3f43
Add `@no_example_express()` re-decorator
gadenbuie Jan 25, 2024
14b0836
Use `@no_example_express()` in a few places
gadenbuie Jan 25, 2024
63b8bf2
Merge branch 'split-api-docs' into add-examples-express-split
gadenbuie Jan 25, 2024
f9c9ddd
Rename app.py examples to app-core.py (#1076)
Jan 26, 2024
4abf8f7
Expressify reactive examples (#1078)
Jan 26, 2024
524fb6a
temp: `add_examples()` warns about missing docs
gadenbuie Jan 26, 2024
20b0dff
don't error in quarto build step if in express docs build
gadenbuie Jan 26, 2024
0d30827
import os
gadenbuie Jan 26, 2024
7bb5f33
fix comment
gadenbuie Jan 26, 2024
03d1e69
don't need gha group around `quartodoc interlinks`
gadenbuie Jan 26, 2024
d827837
Merge remote-tracking branch 'origin/split-api-docs' into add-example…
gadenbuie Jan 26, 2024
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
10 changes: 7 additions & 3 deletions docs/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,14 @@ quartodoc_impl: $(PYBIN) ## Build qmd files for API docs
$(eval export IN_QUARTODOC=true)
. $(PYBIN)/activate \
&& quartodoc interlinks \
&& quartodoc build --config _quartodoc-core.yml --verbose \
&& echo "::group::quartodoc build core docs" \
&& SHINY_MODE="core" quartodoc build --config _quartodoc-core.yml --verbose \
&& mv objects.json _objects_core.json \
&& quartodoc build --config _quartodoc-express.yml --verbose \
&& mv objects.json _objects_express.json
&& echo "::endgroup::" \
&& echo "::group::quartodoc build express docs" \
&& SHINY_MODE="express" quartodoc build --config _quartodoc-express.yml --verbose \
&& mv objects.json _objects_express.json \
&& echo "::endgroup::"

quartodoc_post: $(PYBIN) ## Post-process qmd files for API docs
. $(PYBIN)/activate \
Expand Down
5 changes: 5 additions & 0 deletions docs/_renderer_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import base64
import html
import os
import re
from importlib.resources import files
from pathlib import Path
Expand Down Expand Up @@ -270,6 +271,10 @@ def read_file(file: str | Path, root_dir: str | Path | None = None) -> FileConte


def check_if_missing_expected_example(el, converted):
if os.environ.get("SHINY_MODE") == "express":
# These errors are thrown much earlier by @add_example()
return

if re.search(r"(^|\n)#{2,6} Examples\n", converted):
# Manually added examples are fine
return
Expand Down
141 changes: 132 additions & 9 deletions shiny/_docstring.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import os
import sys
from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar
from functools import wraps
from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, TypeVar


def find_api_examples_dir(start_dir: str) -> Optional[str]:
Expand All @@ -29,6 +30,25 @@ def no_example(func: F) -> F:
return func


def no_example_express(decorator: Callable[..., F]) -> F | Callable[..., F]:
"""
Prevent ``@add_example()`` from throwing an error about missing Express examples.
"""

@wraps(decorator)
def wrapper_decorator(*args: Any, **kwargs: Any) -> F:
try:
# Apply the potentially problematic decorator
return decorator(*args, **kwargs)
except ExpressExampleNotFoundException:
# If an error occurs, return the original function
if args and callable(args[0]):
return args[0]
raise

return wrapper_decorator


# This class is used to mark docstrings when @add_example() is used, so that an error
# will be thrown if @doc_format() is used afterward. This is to avoid an error when
# the example contains curly braces -- the @doc_format() decorator will try to evaluate
Expand All @@ -50,7 +70,7 @@ def write_example(self, app_files: list[str]) -> str:


def add_example(
app_file: str = "app.py",
app_file: Optional[str] = None,
ex_dir: Optional[str] = None,
) -> Callable[[F], F]:
"""
Expand All @@ -60,6 +80,14 @@ def add_example(
``__name__`` matches the name of directory under a ``api-examples/`` directory in
the current or any parent directory.

* Examples for the ``shiny`` package are in ``shiny/api-examples/``.
* Examples for the ``shiny.express`` subpackage are in ``shiny/express/api-examples/``.

Functions that can be used in Express or Core and whose canonical implementation is
in the ``shiny`` package should have examples in ``shiny/api-examples``. In this
case, the express variant should include an ``-express`` suffix and the core
variation can be named with a ``-core`` suffix or ``app.py``.

Parameters
----------
app_file:
Expand Down Expand Up @@ -88,25 +116,40 @@ def _(func: F) -> F:
ex_dir_found = find_api_examples_dir(func_dir)

if ex_dir_found is None:
raise ValueError(
raise FileNotFoundError(
f"No example directory found for {fn_name} in {func_dir} or its parent directories."
)
example_dir = os.path.join(ex_dir_found, fn_name)
else:
example_dir = os.path.join(func_dir, ex_dir)
example_dir = os.path.abspath(os.path.join(func_dir, ex_dir))

example_file = os.path.join(example_dir, app_file)
if not os.path.exists(example_file):
raise ValueError(
f"No example for {fn_name} found in '{os.path.abspath(example_dir)}'."
if not os.path.exists(example_dir):
raise FileNotFoundError(
f"Example directory '{example_dir}' does not exist for {fn_name}."
)

app_file_name = app_file or "app.py"
try:
example_file = app_choose_core_or_express(
os.path.join(example_dir, app_file_name)
)
except ExampleNotFoundException as e:
func_dir = get_decorated_source_directory(func).split("py-shiny/")[1]
if "__code__" in dir(func):
print(
f"::warning file={func_dir},line={func.__code__.co_firstlineno}::{e}"
)
else:
print(f"::warning file={func_dir}::{e}")

return func

other_files: list[str] = []
for f in os.listdir(example_dir):
abs_f = os.path.join(example_dir, f)
is_support_file = (
os.path.isfile(abs_f)
and f != app_file
and f != app_file_name
and f != "app.py"
and not f.startswith("app-")
and not f.startswith("__")
Expand Down Expand Up @@ -150,6 +193,86 @@ def _(func: F) -> F:
return _


def is_express_app(app_path: str) -> bool:
# We can't use .shiny.express._is_express.is_express_app() here because that would
# create a circular import.
Comment on lines +197 to +198
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import here, avoid circular import

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof, this still gave me a circular import error

  File "/Users/garrick/work/posit-dev/py-shiny/shiny/ui/_accordion.py", line 11, in <module>
    from ..session import require_active_session
ImportError: cannot import name 'require_active_session' from partially initialized module 'shiny.session' (most likely due to a circular import) (/Users/garrick/work/posit-dev/py-shiny/shiny/session/__init__.py)

I'm going to leave this alone for now

if not os.path.exists(app_path):
return False

with open(app_path) as f:
for line in f:
if "from shiny.express" in line:
return True
elif "import shiny.express" in line:
return True
return False


class ExampleNotFoundException(FileNotFoundError):
def __init__(
self,
file_names: list[str] | str,
dir: str,
type: Optional[Literal["core", "express"]] = None,
) -> None:
self.type = type or os.environ.get("SHINY_MODE") or "core"
self.file_names = [file_names] if isinstance(file_names, str) else file_names
self.dir = dir

def __str__(self):
if self.type in ("core", "express"):
# Capitalize first letter
type = "a Shiny Express" if self.type == "express" else "a Shiny Core"
else:
type = "an"

return (
f"Could not find {type} example file named "
+ f"{' or '.join(self.file_names)} in {self.dir}."
)


class ExpressExampleNotFoundException(ExampleNotFoundException):
def __init__(
self,
file_names: list[str] | str,
dir: str,
) -> None:
super().__init__(file_names, dir, "express")


def app_choose_core_or_express(app_path: Optional[str] = None) -> str:
app_path = app_path or "app.py"

if os.environ.get("SHINY_MODE") == "express":
if is_express_app(app_path):
return app_path

app_path = app_path.replace("-core.py", ".py")

path, ext = os.path.splitext(app_path)
app_path_express = f"{path}-express{ext}"

if not is_express_app(app_path_express):
raise ExpressExampleNotFoundException(
[os.path.basename(app_path), os.path.basename(app_path_express)],
os.path.dirname(app_path),
)

return app_path_express

if os.path.basename(app_path) == "app.py" and not os.path.exists(app_path):
app_path = app_path.replace("app.py", "app-core.py")

if not os.path.exists(app_path):
raise ExampleNotFoundException(
os.path.basename(app_path),
os.path.dirname(app_path),
)

return app_path


def get_decorated_source_directory(func: FuncType) -> str:
if hasattr(func, "__module__"):
path = os.path.abspath(str(sys.modules[func.__module__].__file__))
Expand Down
File renamed without changes.
21 changes: 21 additions & 0 deletions shiny/api-examples/Progress/app-express.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import asyncio

from shiny import reactive
from shiny.express import input, render, ui

ui.input_action_button("button", "Compute")


@render.text
@reactive.event(input.button)
async def compute():
with ui.Progress(min=1, max=15) as p:
p.set(message="Calculation in progress", detail="This may take a while...")

for i in range(1, 15):
p.set(i, message="Computing")
await asyncio.sleep(0.1)
# Normally use time.sleep() instead, but it doesn't yet work in Pyodide.
# https://github.com/pyodide/pyodide/issues/2354

return "Done computing!"
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
from shiny import App, Inputs, Outputs, Session, reactive, render, ui

app_ui = ui.page_fluid(
ui.input_action_button("minus", "-1"),
" ",
ui.input_action_button("plus", "+1"),
ui.br(),
app_ui = ui.page_sidebar(
ui.sidebar(
ui.input_action_button("minus", "-1"),
ui.input_action_button("plus", "+1"),
),
ui.output_text("value"),
)


def server(input: Inputs, output: Outputs, session: Session):
val = reactive.Value(0)

@reactive.Effect
@reactive.effect
@reactive.event(input.minus)
def _():
newVal = val.get() - 1
val.set(newVal)

@reactive.Effect
@reactive.effect
@reactive.event(input.plus)
def _():
newVal = val.get() + 1
Expand Down
28 changes: 28 additions & 0 deletions shiny/api-examples/Value/app-express.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from shiny import reactive
from shiny.express import input, render, ui

val = reactive.Value(0)


@reactive.effect
@reactive.event(input.minus)
def _():
newVal = val.get() - 1
val.set(newVal)


@reactive.effect
@reactive.event(input.plus)
def _():
newVal = val.get() + 1
val.set(newVal)


with ui.sidebar():
ui.input_action_button("minus", "-1")
ui.input_action_button("plus", "+1")


@render.text
def value():
return str(val.get())
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@
from shiny import App, Inputs, Outputs, Session, reactive, render, ui

app_ui = ui.page_fluid(
ui.input_action_button("first", "Invalidate first (slow) computation"),
" ",
ui.input_action_button("second", "Invalidate second (fast) computation"),
ui.br(),
ui.output_ui("result"),
ui.card(
ui.layout_columns(
ui.input_action_button("first", "Invalidate first (slow) computation"),
ui.input_action_button("second", "Invalidate second (fast) computation"),
),
ui.output_text_verbatim("result"),
)
)


def server(input: Inputs, output: Outputs, session: Session):
@reactive.Calc
@reactive.calc
def first():
input.first()
p = ui.Progress()
Expand All @@ -23,12 +25,12 @@ def first():
p.close()
return random.randint(1, 1000)

@reactive.Calc
@reactive.calc
def second():
input.second()
return random.randint(1, 1000)

@render.ui
@render.text
def result():
return first() + second()

Expand Down
32 changes: 32 additions & 0 deletions shiny/api-examples/calc/app-express.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import random
import time

from shiny import reactive
from shiny.express import input, render, ui


@reactive.calc
def first():
input.first()
p = ui.Progress()
for i in range(30):
p.set(i / 30, message="Computing, please wait...")
time.sleep(0.1)
p.close()
return random.randint(1, 1000)


@reactive.calc
def second():
input.second()
return random.randint(1, 1000)


with ui.card():
with ui.layout_columns():
ui.input_action_button("first", "Invalidate first (slow) computation")
ui.input_action_button("second", "Invalidate second (fast) computation")

@render.text
def result():
return first() + second()
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loading