Skip to content
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

Automatically load globals.py for Express apps #1172

Merged
merged 4 commits into from
Mar 2, 2024
Merged

Automatically load globals.py for Express apps #1172

merged 4 commits into from
Mar 2, 2024

Conversation

wch
Copy link
Collaborator

@wch wch commented Mar 1, 2024

Closes #1079.

For Shiny Express apps, this PR makes it so that if there is a globals.py file in the same directory as the app file, it will be loaded with the session context set to None.

Note that even if the user does not have an import globals statement in their app file, this will still run the code in globals.py. If they want to actually access the variables defined in that file, they will have to an import, as in import globals. The part of this that may be surprising is that the code in globals.py could execute even though it's not being referred to anywhere in the app code.

@jcheng5 It feels weird to me that globals.py would be executed simply because it's in the same directory as the app file, and that behavior could be surprising for users. Like, Imagine that globals.py sets up a database connection and/or writes something to a log file, and the user removes or comments out import globals from their app. If it still keeps establishing the database connection and writing to a log file, that would be really surprising and would be difficult to diagnose and debug if you didn't know that how Shiny Express always loads that file.

Another way we could get the same effect is to walk the AST and look for import globals or from globals import ... and if it finds that, rewrite the AST to:

with session_context(None):
    import globals

Or, instead of rewriting the AST, during evaluation time we could simply set the session context to None before evaluating the import line.


With this change, the example app from #1079 works, after renaming data to globals:

Full source code

app.py

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

from globals import stock_quotes, SYMBOLS

ui.markdown(
    """
    # `shiny.reactive.poll` demo

    This example app shows how to stream results from a database (in this
    case, an in-memory sqlite3) with the help of `shiny.reactive.poll`.
    """
)

ui.input_selectize("symbols", "Filter by symbol", [""] + SYMBOLS, multiple=True)


def filtered_quotes():
    df = stock_quotes()
    if input.symbols():
        df = df[df["symbol"].isin(input.symbols())]
    return df

@render.express
def table():
    ui.HTML(
        filtered_quotes().to_html(
            index=False, classes="table font-monospace w-auto"
        )
    )

globals.py

import asyncio
import random
import sqlite3
from datetime import datetime
from typing import Any, Awaitable

import pandas as pd

from shiny import reactive

SYMBOLS = ["AAA", "BBB", "CCC", "DDD", "EEE", "FFF"]


def timestamp() -> str:
    return datetime.now().strftime("%x %X")


def rand_price() -> float:
    return round(random.random() * 250, 2)


# === Initialize the database =========================================


def init_db(con: sqlite3.Connection) -> None:
    cur = con.cursor()
    try:
        cur.executescript(
            """
            CREATE TABLE stock_quotes (timestamp text, symbol text, price real);
            CREATE INDEX idx_timestamp ON stock_quotes (timestamp);
            """
        )
        cur.executemany(
            "INSERT INTO stock_quotes (timestamp, symbol, price) VALUES (?, ?, ?)",
            [(timestamp(), symbol, rand_price()) for symbol in SYMBOLS],
        )
        con.commit()
    finally:
        cur.close()


conn = sqlite3.connect(":memory:")
init_db(conn)


# === Randomly update the database with an asyncio.task ==============


def update_db(con: sqlite3.Connection) -> None:
    """Update a single stock price entry at random"""

    cur = con.cursor()
    try:
        sym = SYMBOLS[random.randint(0, len(SYMBOLS) - 1)]
        print(f"Updating {sym}")
        cur.execute(
            "UPDATE stock_quotes SET timestamp = ?, price = ? WHERE symbol = ?",
            (timestamp(), rand_price(), sym),
        )
        con.commit()
    finally:
        cur.close()


async def update_db_task(con: sqlite3.Connection) -> Awaitable[None]:
    """Task that alternates between sleeping and updating prices"""
    while True:
        await asyncio.sleep(random.random() * 1.5)
        update_db(con)


asyncio.create_task(update_db_task(conn))


# === Create the reactive.poll object ===============================


def tbl_last_modified() -> Any:
    print("polling")
    df = pd.read_sql_query("SELECT MAX(timestamp) AS timestamp FROM stock_quotes", conn)
    return df["timestamp"].to_list()


@reactive.poll(tbl_last_modified, 0.5)
def stock_quotes() -> pd.DataFrame:
    return pd.read_sql_query("SELECT timestamp, symbol, price FROM stock_quotes", conn)

@wch wch requested a review from jcheng5 March 1, 2024 22:59
@wch wch added this to the v0.8.0 milestone Mar 1, 2024
@jcheng5
Copy link
Collaborator

jcheng5 commented Mar 2, 2024

In both cases, "globals" has special meaning--you'd have to be told that in the documentation. I far prefer "Shiny Express looks for globals.py and loads it before the app starts, outside of any session." instead of "Shiny Express detects imports of globals.py directly from the app.py file (not from any modules imported by app.py) and treats it differently so that objects inside don't belong to the current session."

@jcheng5
Copy link
Collaborator

jcheng5 commented Mar 2, 2024

By the way, pytest has conftest.py that's imported before tests run. It's almost exactly analogous to this situation. I'm not saying I love it, but I far far prefer it to the rewriting, which even after it's explained is still surprising IMHO.

@wch
Copy link
Collaborator Author

wch commented Mar 2, 2024

Hm, OK, those things make sense, and I can get on board with this approach.

@wch wch merged commit faa7648 into main Mar 2, 2024
28 checks passed
@wch wch deleted the globals branch March 2, 2024 05:07
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.

reactive.poll doesn't poll when defined in Express-adjacent modules
2 participants