posit-dev / py-shinylive

Python package for deploying Shinylive applications
https://shiny.posit.co/py/docs/shinylive.html
MIT License
42 stars 4 forks source link

feat: Add `shinylive url` command #20

Closed gadenbuie closed 6 months ago

gadenbuie commented 6 months ago

Adds shinylive url, a command group to encode (or create) a shinylive.io URL for Python or R apps from local files or decode the existing URL. The core functionality is wrapped in encode_shinylive_url() and decode_shinylive_url() which are exported to facilitate creating and decoding shinylive URLs from within a Python session (the primary use case is for the Component and Layout galleries, but I'm sure this would be broadly useful).

❯ shinylive url encode --help
Usage: shinylive url encode [OPTIONS] APP [FILES]...

  Create a shinylive.io URL for a Shiny app from local files.

  APP is the path to the primary Shiny app file.

  FILES are additional supporting files or directories for the app.

  On macOS, you can copy the URL to the clipboard with:

      shinylive url encode app.py | pbcopy

Options:
  -m, --mode [editor|app]         The shinylive mode: include the editor or
                                  show only the app.  [required]
  -l, --language [python|py|R|r]  The primary language used to run the app, by
                                  default inferred from the app file.
  -v, --view                      Open the link in a browser.
  --no-header                     Hide the Shinylive header.
  --help                          Show this message and exit.
❯ shinylive url decode --help
Usage: shinylive url decode [OPTIONS] [URL]

  Decode a shinylive.io URL.

  URL is the shinylive editor or app URL. If not specified, the URL will be
  read from stdin, allowing you to read the URL from a file or the clipboard.

  On macOS, you can read the URL from the clipboard with:

      pbpaste | shinylive url decode

Options:
  --out TEXT  Output directory into which the app's files will be written. The
              directory is created if it does not exist.
  --json      Prints the decoded shinylive bundle as JSON to stdout, ignoring
              --out.
  --help      Show this message and exit.

Examples

Copy the multiple files Python app example link:

pbpaste | shinylive url decode
## file: app.py

from shiny import App, render, ui
from utils import square

app_ui = ui.page_fluid(
    ui.input_slider("n", "N", 0, 100, 20),
    ui.output_text_verbatim("txt"),
)

def server(input, output, session):
    @output
    @render.text
    def txt():
        val = square(input.n())
        return f"{input.n()} squared is {val}"

app = App(app_ui, server, debug=True)

## file: utils.py

def square(n):
    return n * n

Copy this example Shiny app

from shiny import App, render, ui

app_ui = ui.page_fluid(
    ui.input_slider("x", "Slider input", min=0, max=20, value=10),
    ui.output_text_verbatim("txt"),
)

def server(input, output, session):
    @output
    @render.text
    def txt():
        return f"x: {input.x()}"

app = App(app_ui, server, debug=True)

and then generate a link and open it on shinylive

pbpaste | shinylive url encode --view

Try the same idea, but with this R app

library(shiny)

ui <- fluidPage(
  h2(textOutput("currentTime"))
)

server <- function(input, output, session) {
  output$currentTime <- renderText({
    invalidateLater(1000, session)
    paste("The current time is", Sys.time())
  })
}

shinyApp(ui, server)
pbpaste | shinylive url encode --view

Or apply the same idea to a local app on your computer. In this case, the language is inferred from the file extension.

shinylive url encode -v posit-dev/py-shiny/examples/event/app.py

shinylive url encode -v rstudio/shiny/inst/examples/11_timer/app.R
gadenbuie commented 6 months ago

Thank you for your comments and suggestions @wch, they were super helpful! I've addressed and resolved them all, except for two bigger items that I'll call out because they might be hard to find in the UI above.

  1. We've identified some weirdness around the dual-purpose app argument in encode_shinylive_url(). As it was reviewed, it could be a file on disk or the contents of a hypothetical app.py or app.R. I tried an alternative approach that's more consistent, but I'm worried this has user experience tradeoffs. https://github.com/posit-dev/py-shinylive/pull/20#discussion_r1443852134

  2. We updated the imports to use typing_extensions for Python < 3.11, but this is causing issues in CI (that I've resolved!). https://github.com/posit-dev/py-shinylive/pull/20#discussion_r1443852134. In doing this, I realized the type we were adjusting already existed in this project and suffers from the same problem https://github.com/posit-dev/py-shinylive/blob/373f639ed81336f2a1393b9726d2a2a1dc48a0b9/shinylive/_app_json.py#L12-L17 https://github.com/posit-dev/py-shinylive/blob/373f639ed81336f2a1393b9726d2a2a1dc48a0b9/shinylive/_url.py#L11-L23

gadenbuie commented 6 months ago

I've figured out the issue with pip install -e . and typing_extensions:

  1. We use version = attr: shinylive.__version__ in setup.cfg https://github.com/posit-dev/py-shinylive/blob/a959c07873a3f2ecb3dbb518c7fc840ead27e415/setup.cfg#L3
  2. When installing, pip generates metadata for the package and it needs to load shinylive to determine the package version.
  3. Loading the package requires importing type_annotations, which hasn't been installed yet, causing the ModuleNotFoundError.

It took a bit of exploration, but I've come up with what I think is a pretty good solution:

  1. I've moved the version information into a subpackage: shinylive.version.
  2. In setup.cfg we call version = attr: shinylive.version.SHINYLIVE_PACKAGE_VERSION and in other places we import the constants from .version instead of ._version.
  3. This way, pip can find the package version in the version subpackage without having to evaluate the code in the main shinylive package.
wch commented 6 months ago

For the typing_extensions issue, I believe you should be able to do what we do in shiny, and list it in install_requires: https://github.com/posit-dev/py-shiny/blob/592cf34397606eed98c1488799e495754ebf8a1f/setup.cfg#L34-L35

I had forgotten that that typing_extensions isn't part of the Python standard library, and so it needs to be listed as an explicit dependency.

gadenbuie commented 6 months ago

For the typing_extensions issue, I believe you should be able to do what we do in shiny, and list it in install_requires: posit-dev/py-shiny@592cf34/setup.cfg#L34-L35

I had forgotten that that typing_extensions isn't part of the Python standard library, and so it needs to be listed as an explicit dependency.

@wch Did you see my last comment above? (I know there's a lot going on in this PR its easy to miss.)

In short, it's not about install_requires but rather how shinylive uses a dynamic version that requires pip to run the module code. In other words, I found the source of the problem we paired on together last week and I have a pretty good fix for it.

wch commented 6 months ago

I've figured out the issue with pip install -e . and typing_extensions

Wait, do you mean the lzstring issue? (I don't recall typing_extensions being a problem when we paired on it last week but I could be missing something.)

gadenbuie commented 6 months ago

Yeah, we hadn't added typing_extensions yet, but once we did we resurfaced the same problem we had with lzstring. Fundamentally they're caused by the same thing (see above for description). With lzstring, we could move the import statement into a function and delay its evaluation; with typing_extensions we can't use that strategy so I had to figure out the root cause.

wch commented 6 months ago

Hm, it's strange that the same thing happened with typing_extensions -- for shiny and a number of other packages, we use version = attr: shiny.__version__, and that works fine. I think the fact that it's not working here is a symptom of something else that's weird. I'll take a deeper look into it.

gadenbuie commented 6 months ago

@wch What's different about py-shiny is that __version__ is set to a literal value:

__version__ = "0.6.1.9000"

Here in py-shinylive it's set a to an expression, which then forces pip to evaluate some of the packaged code to determine the value. The problem also goes away if we were to do the same here and set __version__ directly rather than storing the version numbers in a separate variable.

wch commented 6 months ago

Ah, OK, I see. That sounds good then. Can you you rename version to _version?