Streamlit: The 'Good' Way

Para quien no lo conozca, Streamlit es un framework de Python que permite crear aplicaciones web interactivas con tan solo unas pocas líneas de código. En este post, veremos como aplicar algunas buenas prácticas típicas del desarrollo de software más tradicional en Streamlit.

Streamlit

Estas últimas semanas he tenido la necesidad de utilizar este framework en el trabajo para crear una aplicación web de datos que permita visualizar una los mismos de forma sencilla. Dentro de la empresa Streamlit está ampliamente usando y, por tanto, no hubo dudas sobre su usarlo no, sin embargo, cuando empecé a dar los primeros pasos con él me empezaron a entrar sudores fríos.

Todo son scripts.

Es casi imposible no abrir la documentación oficial y no ver referencias constantes a que Streamlit está pensado para usarse con scripts de Python. Y claro, partiendo de esta premisa todo se complica.

Debido a que Streamlit nace con esta premisa es fácil darse cuenta de que no está pensando ni para ser testeable fácilmente, ni modulable, ni mucho menos para ser usando como un framework UI tradicional.

Si todo esto fuera poco, hay una característica que a mí al menos me explota la cabeza y es que tanto la propia documentación como cualquiera de las apps de ejemplos siempre están hechas en un único fichero con cientos de líneas, cero modularidad y cero tests.

Aquí podéis ver la app mejor valorada, 428 líneas de fichero.

The Good Way

El mundo de las apps para datos ha estado ocupado en otras cosas más importantes, por lo que hablar de temas como testing, inyección de dependencias o modularidad en Streamlit es como hablar de ciencia ficción. No lo digo como una crítica, sinceramente creo que el sector ha tenido que solucionar primero otros problemas más importantes y no ha tenido tiempo para esto.

Enfoque tradicional

Imaginemos que queremos crear una aplicación que nos muestre de forma aleatoria la bandera, nombre y capital de varios países.

Esto sería lo que el framework nos propone:

import streamlit as st

st.header("Countries")

if st.button("Load Countries", type="primary"):
    response = self._req.get("https://restcountries.com/v2/all?fields=name,capital,flag")
    content = response.json()
    random.shuffle(content)
    limit = 3
    for item in content[0:limit]:
        name = item["name"]
        flag = item["flag"]
        capital = item.get("capital", "N/A")

        text = Text()
        text.render(f"Country: {name}, Capital: {capital}")

        image = Image()
        image.render(url=flag, width=100)

st.text("Made by @pmareke")

Tenemos una interfaz que muestra un header, un botón y un footer. Cuando el botón es pulsado se hace una llamada para obtener una lista de países de un endpoint, de los cuales elegimos 3 al azar y pintamos su nombre, bandera y capital.

El ejemplo es muy básico, pero creo que será suficiente para ver la idea.

Las desventajas de tener una aplicación de este estilo son claras:

  • Aunque el ejemplo es muy básico, ya estamos mezclando lógica de negocio con la UI.
  • No es posible testear la lógica de negocio sin tener que hacer llamadas a la API.
  • No podemos testear partes de la UI de forma individual.
  • A medida que la aplicación crezca, el fichero crecerá y crecerá.
  • No podemos reutilizar nada de este código en otra aplicación.

The “Good” Way

Lo que buscamos es partir la UI en componentes, separar la lógica de negocio de la UI y poder testear ambas partes de forma individual.

Para ello vamos a crear una clase App que será nuestro contenedor principal y que se encargará de renderizar los distintos componentes.

class App:
    def __init__(self, get_all_countries_handler: QueryHandler) -> None:
        self.get_all_countries_handler = get_all_countries_handler

        self.header = Header()
        self.countries_list = CountriesList(self.get_all_countries_handler)
        self.text = Text()

    def render(self) -> None:
        self.header.render("Countries")

        self.countries_list.render()

        self.text.render("Made by @pmareke")

Esta clase tiene un Header:

import streamlit as st


class Header:
    def render(self, message: str) -> None:
        st.header(message)

Una lista de países (CountriesList):

class CountriesList:
    def __init__(self, handler: QueryHandler) -> None:
        self.handler = handler
        self.button = Button()

    def render(self) -> None:
        if self.button.render("Load Countries"):
            query = GetAllCountriesQuery(limit=3)
            response = self.handler.execute(query)
            for country in response.message():
                text = Text()
                image = Image()

                text.render(f"Country: {country.name}, Capital: {country.capital}")
                image.render(url=country.flag, width=100)

Un botón (Button):

class Button:
    def render(self, label: str) -> bool:
        return st.button(label=label, type="primary")

Un texto (Text):

class Text:
    def render(self, message: str) -> None:
        st.text(message)

Y una imagen (Image):

class Image:
    def render(self, url: str, width: int) -> None:
        st.image(image=url, width=width)

Con todos estos componentes a mano, ahora es trivial crear la aplicación:

countries_client = HttpCountriesClient()
get_all_countries_handler = GetAllCountriesQueryHandler(countries_client)
app = App(get_all_countries_handler)

app.render()

Como podemos ver en este último fragmento de código, la clase encargada de obtener los países es inyectada en nuestros componentes de forma que puede ser reutiliza haya donde haga falta además de ser muy fácil de testear.

Testing

Si me seguís desde hace tiempo ya sabréis los critico que es tener una buena suite de tests para mí.

Infraestuctura

Por un lado, vamos a poder tener unos tests de integración para nuestro cliente que obtiene la lista de países:

class TestCountriesClientIntegration:
    def test_get_all_countries(self) -> None:
        countries_client = HttpCountriesClient()

        countries = countries_client.all(limit=1)

        expect(countries[0].name).not_to(be_none)
        expect(countries[0].capital).not_to(be_none)
        expect(countries[0].flag).not_to(be_none)
class HttpCountriesClient(CountriesClient):
    URL = "https://restcountries.com/v2/all?fields=name,capital,flag"

    def __init__(self) -> None:
        self._req = requests

    def all(self, limit: int = 10) -> list[Country]:
        response = self._req.get(self.URL)
        content = response.json()
        random.shuffle(content)
        countries = []
        for item in content[0:limit]:
            countries.append(self._create_country(item))
        return countries

    def _create_country(self, item: dict) -> Country:
        name = item["name"]
        flag = item["flag"]
        capital = item.get("capital", "N/A")
        return Country(name, capital, flag)

Casos de uso

Vamos a tener también unos tests unitarios para nuestro caso de uso:

class TestGetAllCountriesQueryHandler:
    def test_get_all(self) -> None:
        query = GetAllCountriesQuery(limit=1)
        client = DummyCountriesClient()
        handler = GetAllCountriesQueryHandler(client)

        response = handler.execute(query)
        countries = response.message()

        expect(countries[0].name).to(equal("Argentina"))
        expect(countries[0].capital).to(equal("Buenos Aires"))

Para estos tests he creado una clase dummy DummyCountriesClient que devuelve siempre los mismos países:

class DummyCountriesClient(CountriesClient):
    def all(self, limit: int = 10) -> list[Country]:
        return [
            Country(
                name="Argentina",
                capital="Buenos Aires",
                flag="https://restcountries.com/data/arg.svg",
            ),
        ]
class GetAllCountriesQuery(Query):
    def __init__(self, limit: int) -> None:
        self.limit = limit
        super().__init__()


class GetAllCountriesQueryResponse(QueryResponse):
    def __init__(self, countries: list[Country]) -> None:
        self.countries = countries

    def message(self) -> list[Country]:
        return self.countries


class GetAllCountriesQueryHandler(QueryHandler):
    def __init__(self, countries_client: CountriesClient) -> None:
        self._countries_client = countries_client

    def execute(self, query: GetAllCountriesQuery) -> GetAllCountriesQueryResponse:
        countries = self._countries_client.all(limit=query.limit)
        response = GetAllCountriesQueryResponse(countries)
        return response

Componentes de la UI

Y por último nuestros componentes, tanto a nivel individual:

class TestHeaderComponent:
    def test_title(self) -> None:
        app = AppTest.from_file("src/delivery/streamlit/components/header.py")

        at = app.run()

        assert at.header[0].value == "any-header"
        expect(at.header[0].value).to(equal("any-header"))

Como de aplicación:

class TestStreamlitApp:
    def test_hello_world(self) -> None:
        def create_app() -> None:
            from src.delivery.streamlit.app import App
            from src.infrastructure.countries.dummy_countries_client import DummyCountriesClient
            from src.use_cases.get_all_countries_query import GetAllCountriesQueryHandler

            client = DummyCountriesClient()
            handler = GetAllCountriesQueryHandler(client)
            app = App(handler)
            app.render()

        app = AppTest.from_function(create_app)

        at = app.run()
        at.button[0].click().run()

        expect(at.header[0].value).to(equal("Countries"))
        expect(at.button[0].label).to(equal("Load Countries"))
        expect(at.text[0].value).to(equal("Country: Argentina, Capital: Buenos Aires"))
        expect(at.text[1].value).to(equal("Made by @pmareke"))

La parte que puede resultar un poco más tricky de estos tests a los componentes es la necesidad de incluir

if __name__ == "__main__"

en los mismos para que se autorendericen cuando se ejecutan de forma aislada.

import streamlit as st


class Header:
    def render(self, message: str) -> None:
        st.header(message)


if __name__ == "__main__":
    header = Header()
    header.render("any-header")

De esta forma cuando se usan como parte de la app se llama a su método render de forma explícita y cuando se testean se hace de forma “automática”.

Conclusiones

Los beneficios de tener una aplicación de este estilo son claros:

  • Todos los componentes están testeados de forma aislada.
  • Nuestra aplicación está construida a partir de componentes reutilizables.
  • Todas las dependencias están inyectadas.
  • La lógica de negocio está separada de la UI.

!Espero que os haya gustado os dejo aquí el código, un saludo!