Skip to content

Use a renderer generator to create sync/async render methods with full typing #621

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 69 commits into from
Aug 10, 2023

Conversation

schloerke
Copy link
Collaborator

@schloerke schloerke commented Jul 13, 2023

A renderer author can now supply a value that will render a value (value_fn) given the result of the user.

Example given render.text:

@renderer_components
async def _text(_meta: RenderMeta, _fn: RenderFnAsync[str | None]) -> str | None:
    value = await _fn()
    if value is None:
        return None
    return str(value)


@overload
def text() -> _text.type_decorator:
    ...


@overload
def text(_fn: _text.type_renderer_fn) -> _text.type_renderer:
    ...


def text(_fn: _text.type_impl_fn = None) -> _text.type_impl:
    " DOCS "
    return _text.impl(_fn)
Previous definition for render.text (w/ docs removed for fair comparison)
RenderTextFunc = Callable[[], "str | None"]
RenderTextFuncAsync = Callable[[], Awaitable["str | None"]]


class RenderText(RenderFunction["str | None", "str | None"]):
    def __init__(self, fn: RenderTextFunc) -> None:
        super().__init__(fn)
        # The Render*Async subclass will pass in an async function, but it tells the
        # static type checker that it's synchronous. wrap_async() is smart -- if is
        # passed an async function, it will not change it.
        self._fn: RenderTextFuncAsync = _utils.wrap_async(fn)

    def __call__(self) -> str | None:
        return _utils.run_coro_sync(self._run())

    async def _run(self) -> str | None:
        res = await self._fn()
        if res is None:
            return None
        return str(res)


class RenderTextAsync(RenderText, RenderFunctionAsync["str | None", "str | None"]):
    def __init__(self, fn: RenderTextFuncAsync) -> None:
        if not _utils.is_async_callable(fn):
            raise TypeError(self.__class__.__name__ + " requires an async function")
        super().__init__(typing.cast(RenderTextFunc, fn))

    async def __call__(  # pyright: ignore[reportIncompatibleMethodOverride]
        self,
    ) -> str | None:
        return await self._run()


@overload
def text(fn: RenderTextFunc | RenderTextFuncAsync) -> RenderText:
    ...


@overload
def text() -> Callable[[RenderTextFunc | RenderTextFuncAsync], RenderText]:
    ...


def text(
    fn: Optional[RenderTextFunc | RenderTextFuncAsync] = None,
) -> RenderText | Callable[[RenderTextFunc | RenderTextFuncAsync], RenderText]:
    def wrapper(fn: RenderTextFunc | RenderTextFuncAsync) -> RenderText:
        if _utils.is_async_callable(fn):
            return RenderTextAsync(fn)
        else:
            fn = typing.cast(RenderTextFunc, fn)
            return RenderText(fn)

    if fn is None:
        return wrapper
    else:
        return wrapper(fn)

Render methods converted:

  • text
  • plot
  • image
  • table
  • ui

Questions

  • In render.plot()... Plot variable calls are after self._fn() has been called. Is this ok? (Previously they were before)
    • A: The transform function needs to take the value function and call it to get the value.
  • How can I update an @overloaded function name?
    • A: You can't. Very sad

Ex renderer used in testing:

from __future__ import annotations

from typing import Optional, overload

from shiny import App, Inputs, Outputs, Session, ui
from shiny.render.transformer import (
    TransformerMetadata,
    ValueFn,
    is_async_callable,
    output_transformer,
    resolve_value_fn,
)


@output_transformer
async def TestTextTransformer(
    _meta: TransformerMetadata,
    _fn: ValueFn[str | None],
    *,
    extra_txt: Optional[str] = None,
) -> str | None:
    value = await resolve_value_fn(_fn)
    value = str(value)
    value += "; "
    value += "async" if is_async_callable(_fn) else "sync"
    if extra_txt:
        value = value + "; " + str(extra_txt)
    return value


@overload
def render_test_text(
    *, extra_txt: Optional[str] = None
) -> TestTextTransformer.OutputRendererDecorator:
    ...


@overload
def render_test_text(
    _fn: TestTextTransformer.ValueFn,
) -> TestTextTransformer.OutputRenderer:
    ...


def render_test_text(
    _fn: TestTextTransformer.ValueFn | None = None,
    *,
    extra_txt: Optional[str] = None,
) -> TestTextTransformer.OutputRenderer | TestTextTransformer.OutputRendererDecorator:
    return TestTextTransformer(
        _fn,
        TestTextTransformer.params(extra_txt=extra_txt),
    )


app_ui = ui.page_fluid(
    ui.code("t1:"),
    ui.output_text_verbatim("t1"),
    ui.code("t2:"),
    ui.output_text_verbatim("t2"),
    ui.code("t3:"),
    ui.output_text_verbatim("t3"),
    ui.code("t4:"),
    ui.output_text_verbatim("t4"),
    ui.code("t5:"),
    ui.output_text_verbatim("t5"),
    ui.code("t6:"),
    ui.output_text_verbatim("t6"),
)


def server(input: Inputs, output: Outputs, session: Session):
    @output
    @render_test_text
    def t1():
        return "t1; no call"
        # return "hello"

    @output
    @render_test_text
    async def t2():
        return "t2; no call"

    @output
    @render_test_text()
    def t3():
        return "t3; call"

    @output
    @render_test_text()
    async def t4():
        return "t4; call"

    @output
    @render_test_text(extra_txt="w/ extra_txt")
    def t5():
        return "t5; call"

    @output
    @render_test_text(extra_txt="w/ extra_txt")
    async def t6():
        return "t6; call"


app = App(app_ui, server)

Screenshot 2023-07-13 at 4 27 15 PM

With expectations:

    OutputTextVerbatim(page, "t1").expect_value("t1; no call; sync")
    OutputTextVerbatim(page, "t2").expect_value("t2; no call; async")
    OutputTextVerbatim(page, "t3").expect_value("t3; call; sync")
    OutputTextVerbatim(page, "t4").expect_value("t4; call; async")
    OutputTextVerbatim(page, "t5").expect_value("t5; call; sync; w/ extra_txt")
    OutputTextVerbatim(page, "t6").expect_value("t6; call; async; w/ extra_txt")

schloerke added 20 commits July 13, 2023 15:43
* main:
  Add E2E tests for accordion and autoresize (#601)
  Add card test (#622)
  Add sidebar test (#631)
  Make card fullscreen icon a tooltip (#632)
  Pull in changes from rstudio/bslib#697 and rstudio/bslib#699
  Changelog tweak. Followup to #629
  Add experimental tooltip methods and example apps (#629)
  Use `blib::bs_theme(5,"shiny")` for py-shiny theme (#624)
  Rename shiny/examples to shiny/api-examples (and X/examples to X/api-examples) (#627)
  Make --app-path work with app file argument (#598)
  Don't eval example code block
  Fix example code blocks (#626)
  Annotation export example (#584)
  Add todo list example (#603)
…lass and verify that the render fn was called
…ke overloads manually

Before, one of the overloads was being made with input type `P`. This expands to `(*P.args, **P.kwargs) -> ...`.
Given our restrictions, `P.args` is equal to `*` and therefore is not in the function signature.
However, we need `*` to be there to allow for `_fn` to be passed in the other overloads.

In the case of `text` renderer, the function was being defined as a `Callable` whose args where `P`. This evaluated to no arguments. As a decorator, it needs to accept an argument of the function. This is incompatible.

However, with overloads, the overloaded functions can not really agree with each other as long as they agree with the base method. This allows us to "define" a dedorator who does not take an argument.

I find the function name to be too important to give up. Pursuing returning types and the renderer function for users to make overloads manually.
@schloerke schloerke self-assigned this Jul 31, 2023
@gshotwell gshotwell added this to the sprint-august-1 milestone Aug 2, 2023
wch

This comment was marked as resolved.

@schloerke schloerke marked this pull request as ready for review August 10, 2023 19:10
@schloerke schloerke merged commit da69ea0 into main Aug 10, 2023
@schloerke schloerke deleted the renderer_gen branch August 10, 2023 19:19
schloerke added a commit that referenced this pull request Aug 10, 2023
* main:
  Use a renderer generator to create sync/async render methods with full typing (#621)
  docs: Add Tooltip section in experimental (#673)
  Bump version to 0.5.1
  Add missing sidebar stylesheet dep (#667)
schloerke added a commit that referenced this pull request Aug 10, 2023
* main: (26 commits)
  Use a renderer generator to create sync/async render methods with full typing (#621)
  docs: Add Tooltip section in experimental (#673)
  Bump version to 0.5.1
  Add missing sidebar stylesheet dep (#667)
  GitHub action cleanup (#662)
  Add a triage label to issues created by non-maintainers (#647)
  Bump version to 0.5.0.9000
  Model score example (#650)
  Fix malformed changelog entry
  Update CHANGELOG.md for data frame filters (#654)
  Bump version to 0.5.0
  Allow ui.update_slider to handle non-numeric values (#649)
  Add updated path to examples (#645)
  Bump griffe version
  Update README
  Fix jumpy cursor in numeric filter (#640)
  Build API docs (#633)
  Pyright: ignore docs dir
  Filtering feature for data grid/table (#592)
  Add E2E tests for accordion and autoresize (#601)
  ...
schloerke added a commit that referenced this pull request Aug 10, 2023
* main: (40 commits)
  Use a renderer generator to create sync/async render methods with full typing (#621)
  docs: Add Tooltip section in experimental (#673)
  Bump version to 0.5.1
  Add missing sidebar stylesheet dep (#667)
  GitHub action cleanup (#662)
  Add a triage label to issues created by non-maintainers (#647)
  Bump version to 0.5.0.9000
  Model score example (#650)
  Fix malformed changelog entry
  Update CHANGELOG.md for data frame filters (#654)
  Bump version to 0.5.0
  Allow ui.update_slider to handle non-numeric values (#649)
  Add updated path to examples (#645)
  Bump griffe version
  Update README
  Fix jumpy cursor in numeric filter (#640)
  Build API docs (#633)
  Pyright: ignore docs dir
  Filtering feature for data grid/table (#592)
  Add E2E tests for accordion and autoresize (#601)
  ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants