Enums dinámicos

Días atrás os había hablado de Streamlit, un framework de Python que permite crear aplicaciones webs de una forma muy sencilla. Este framework permite crear formularios de forma trivial apoyándose en Pydantic, en concreto nos vamos a centrar en como usar Enums para crear menús de selección de forma dinámica.

Introducción

Como decía en la intro, existe una librería llamada streamlit-pydantic que nos permite crear formularios a partir de clases de forma increíblemente sencilla. Simplemente basta con crear una clase que herede de BaseModel (clase de Pydantic) y la librería sabe como crear un formulario a partir de la misma.

De todos los posibles campos que puede tener el formulario (inputs, botones, checkbox, etc.) nos vamos a centrar en los select que en esta librería se forman a partir de Enums.

Concretamente, si tenemos un Enum con un par de valores como este:

from enum import Enum


class DummyEnum(Enum):
    FOO = "foo"
    BAR = "bar"

La librería nos creará un formulario con este campo como select:

Simple Select

Problema

Como hemos visto, el caso típico es muy sencillo y no tiene historia. ¿Pero qué ocurre si queremos que las opciones de nuestro select no sean constantes, sino que vengan de un servicio externo?.

Por suerte existe otra forma de crear Enums en Python y además de forma genérica.

La clase Enum (recordemos que en Python en los Enum no dejan de ser clases) acepta un par de parámetros: Enum(enum_name, names) siendo enum_name el nombre del Enum y names un diccionario clave valor de posibles opciones.

Por tanto, las opciones se vuelven casi infinitas, bastará con que le pasemos una lista de opciones (donde se obtiene ya no es relevante para el Enum) y Python será capaz de crear el Enum de forma dinámica.

Dummy Enum

Empezamos por el caso más sencillo, vamos a convertir nuestro Enum inicial en su versión dinámica aunque los valores siempre sean los mismos.

Para ello vamos a crear el test que nos permite válida que todo funciona:

class TestDummyEnum:
    def test_enum(self) -> None:
        def _values() -> dict:
            return {"FOO": "foo", "BAR": "bar"}

        DummyEnum = Enum("DummyEnum", _values())

        expect(DummyEnum.FOO.value).to(equal("foo"))
        expect(DummyEnum.BAR.value).to(equal("bar"))

El Enum, por tanto, se puede crear de la siguiente manera:

def _values() -> dict:
    return {"FOO": "foo", "BAR": "bar"}

DummyEnum = Enum("DummyEnum", _values())

Lo dicho, nada del otro mundo. Lo único que hemos hecho es pasar una función que nos devuelve un diccionario con los valores que queremos que tenga el Enum.

Enum genérico obteniendo los valores de una base de datos

Ahora imaginemos que en vez de tener los valores en un diccionario los tenemos en una base de datos.

Para simular la conexión a base de datos vamos a usar doublex que nos permite hacer Stubs de forma muy sencilla.

Como siempre, vamos a empezar por el test:

class TestDBEnum:
    def test_enum(self) -> None:
        with Mimic(Stub, Session) as session:
            results = [("Spain"), ("Italy")]
            session.execute(ANY_ARG).returns(results)

        def _values(session: Session) -> dict:
            options = {}
            for row in session.execute("SELECT * FROM countries"):
                options[f"{row[0]}".upper()] = row[0]
            return options

        DBEnum = Enum("DBEnum", _values(session))

        expect(DBEnum.SPAIN.value).to(equal("Spain"))
        expect(DBEnum.ITALY.value).to(equal("Italy"))

El Enum, por tanto, se puede crear de la siguiente manera:

def _values(session: Session) -> dict:
    options = {}
    for row in session.execute("SELECT * FROM countries"):
        options[row[0]] =  row[0]
    return options

engine = create_engine(f"{DB_URL}")
session = Session(bind=engine)
DBEnum = Enum("DBEnum", _values(session)))

Enum genérico obteniendo los valores de una API HTTP

De igual forma que hemos hecho con la base de datos, podemos hacerlo con una petición HTTP.

Lo primero el test:

class TestHTTPEnum:
    def test_enum(self) -> None:
        with Mimic(Stub, HTTPClient) as http_client:
            http_client.find_all().returns(
                [
                    {"id": "NIGERIA", "name": "Nigeria"},
                    {"id": "BRAZIL", "name": "Brazil"},
                ]
            )
        def _values(http_client: HTTPClient) -> dict:
            options = {}
            items = http_client.find_all()
            for item in items:
                options[item["id"]] = item["name"]
            return options

        HTTPEnum = Enum("HTTPEnum", _values(http_client))

        expect(HTTPEnum.NIGERIA.value).to(equal("Brazil"))
        expect(HTTPEnum.BRAZIL.value).to(equal("Nigeria"))

El Enum, por tanto, se puede crear de la siguiente manera:

def _values(http_client: HTTPClient) -> dict:
    options = {}
    for item in http_client.find_all():
        options[item["id"]] = item["name"]
    return options

http_client = HttpClient()
HTTPEnum = Enum("HTTPEnum", _values(http_client))

Conclusiones

PROS

  • Cada vez que se carga el Enum los datos están actualizados.
  • Podemos crear Enums de forma genérica.

CONTRAS

  • Al ser un Enum dinámico no es posible para el IDE saber qué opciones hay.
  • Son muy útiles para casos concretos como el del formulario, pero no para cualquier caso.

¡Espero que os haya gustado!