Estoy trabajando en una herramienta interna que requiere un típico CRUD (Create, Read, Update, Delete) de varias tablas. Para ello, he decidido utilizar Streamlit, una librería de Python que permite crear aplicaciones web. En otras palabras, puedo crear una aplicación web sin necesidad de escribir HTML, CSS o JavaScript, solo centrandome en la lógica escrita en Python.
En este artículo, voy a mostrar un ejemplo de código para realizar las operaciones. No es un tutorial detallado, sino una muestra que puede servir de base para tus propias aplicaciones. Sin embargo vamos a ser ambiciosos. En lugar de crear un CRUD, como indica el título, trabajaremos un BREAD (Browse, Read, Edit, Add, Delete). Una interfaz más rica que permitir la navegación (paginador), lectura, edición, adición y eliminación de registros.
El resultado final sería el siguiente:
1. Configuración
Todo el código se ejecutará en un contenedor Docker. Por lo tanto, necesitamos crear algunos archivos de configuración antes de continuar.
Creamos requirements.txt
con las dependencias de Python.
pandas
streamlit
Ahora creamos compose.yaml
con la configuración de Docker Compose.
services:
streamlit:
build: .
ports:
- "8501:8501"
volumes:
- ./src:/app
Habrán 2 volúmenes: uno para el código fuente y otro para la base de datos.
Por último creamos el archivo Dockerfile
para construir la imagen de Docker.
FROM python:3.12-slim
# Prevents Python from writing pyc files to disc (equivalent to python -B option)
ENV PYTHONDONTWRITEBYTECODE=1
# Prevents Python from buffering stdout and stderr (equivalent to python -u option)
ENV PYTHONUNBUFFERED=1
# set work directory
WORKDIR /app
# set time
RUN ln -fs /usr/share/zoneinfo/Europe/Madrid /etc/localtime
RUN dpkg-reconfigure -f noninteractive tzdata
# install software
RUN apt-get update && apt-get install -y \
build-essential \
sqlite3 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# install dependencies
RUN pip3 install --upgrade pip
COPY ./requirements.txt .
RUN pip3 install -r requirements.txt
EXPOSE 8501
HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
2. Base de datos
Usaremos la base de datos embebida por excelencia: SQLite. Es suficiente para nuestras necesidades.
Trabajaremos con una base de datos relacionada con colonias de gatos. Tendremos la tabla de la colonia, la tabla de los gatos y la tabla de los dueños. Un gato pertenece a una colonia, pero un gato puede tener varios dueños, y un dueño puede tener varios gatos.
colony
id
(clave primaria)name
cat
id
(clave primaria)name
colony_id
(clave foránea)
cat_owner
cat_id
(clave primaria y clave foránea)owner_id
(clave primaria y clave foránea)
owner
id
(clave primaria)name
Creamos un archivo db/schema.sql
con la definición de las tablas.
CREATE TABLE colony (
id INTEGER PRIMARY KEY,
name TEXT
);
CREATE TABLE cat (
id INTEGER PRIMARY KEY,
name TEXT,
colony_id INTEGER,
FOREIGN KEY (colony_id) REFERENCES colony(id)
);
CREATE TABLE owner (
id INTEGER PRIMARY KEY,
name TEXT
);
CREATE TABLE cat_owner (
cat_id INTEGER,
owner_id INTEGER,
PRIMARY KEY (cat_id, owner_id),
FOREIGN KEY (cat_id) REFERENCES cat(id),
FOREIGN KEY (owner_id) REFERENCES owner(id)
);
Y creamos un archivo db/data.sql
con algunos datos de ejemplo.
# Colonies
INSERT INTO colony (name) VALUES ('Gatolandia');
INSERT INTO colony (name) VALUES ('Pueblo Ronroneo');
INSERT INTO colony (name) VALUES ('La Nación Michi');
# Gatos
INSERT INTO cat (name, colony_id) VALUES ('Garfield', 1);
INSERT INTO cat (name, colony_id) VALUES ('Tom', 1);
INSERT INTO cat (name, colony_id) VALUES ('Félix', 2);
INSERT INTO cat (name, colony_id) VALUES ('Silvestre', 3);
# Dueños
INSERT INTO owner (name) VALUES ('Jon Arbuckle');
INSERT INTO owner (name) VALUES ('Jerry Mouse');
INSERT INTO owner (name) VALUES ('Mickey Mouse');
INSERT INTO owner (name) VALUES ('Bugs Bunny');
# Gatos y dueños
INSERT INTO cat_owner (cat_id, owner_id) VALUES (1, 1);
INSERT INTO cat_owner (cat_id, owner_id) VALUES (2, 1);
INSERT INTO cat_owner (cat_id, owner_id) VALUES (3, 3);
INSERT INTO cat_owner (cat_id, owner_id) VALUES (4, 4);
Ejecutamos los scripts para crear la base de datos y cargar los datos.
sqlite3 db/db.sqlite < db/schema.sql
sqlite3 db/db.sqlite < db/data.sql
3. Colonia
Creamos el archivo src/streamlit_app.py
con el siguiente contenido.
import pandas as pd
import streamlit as st
import sqlite3 as sqlite
import math
# Variables
conn = sqlite.connect("/app/db/db.sqlite")
conn.row_factory = sqlite.Row # This enables column access by name: row['column_name']
RECORDS_PER_PAGE = 5
# Session state
if "page" not in st.session_state:
st.session_state.page = 1
def get_colonies():
"""
Returns a list of colonies from the database.
"""
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM colony LIMIT ? OFFSET ?",
(RECORDS_PER_PAGE, (st.session_state.page - 1) * RECORDS_PER_PAGE),
)
return cursor.fetchall()
def add_colony(name):
"""
Adds a colony to the database.
"""
cursor = conn.cursor()
cursor.execute("INSERT INTO colony (name) VALUES (?)", (name,))
conn.commit()
def update_colony(id, name):
"""
Updates a colony in the database.
"""
cursor = conn.cursor()
cursor.execute("UPDATE colony SET name = ? WHERE id = ?", (name, id))
conn.commit()
def delete_colony(id):
"""
Deletes a colony from the database.
"""
cursor = conn.cursor()
cursor.execute("DELETE FROM colony WHERE id = ?", (id,))
conn.commit()
def query_colony(name):
"""
Returns a list of colonies from the database that match the name.
"""
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM colony WHERE UPPER(name) LIKE ?", (f"%{name}%".upper(),)
)
return cursor.fetchall()
def last_page():
"""
Returns the last page.
"""
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM colony")
return math.ceil(cursor.fetchone()[0] / RECORDS_PER_PAGE)
def is_last_page():
"""
Returns True if the current page is the last page.
"""
return st.session_state.page == last_page()
# Section: Search
search = st.text_input("Search", "")
results = query_colony(search) if search else get_colonies()
if search:
st.session_state.page = 1
f"{len(results)} results found."
# Section: Table
def table_data(results):
"""
Returns a DataFrame with the results.
"""
return (
pd.DataFrame(
tuple(
map(
lambda colony: {
"id": colony["id"],
"name": colony["name"],
},
results,
)
)
)
if results
else pd.DataFrame([{"id": 0, "name": "Empty. Add a new record."}])
)
# Display the table
edited_df = st.data_editor(
table_data(results),
disabled=["id"],
num_rows="dynamic",
use_container_width=True,
hide_index=True,
)
# Section: Pagination
left, middle, right = st.columns(3)
# Back button
if left.button("Back", use_container_width=True, disabled=st.session_state.page == 1):
if st.session_state.page > 1:
st.session_state.page -= 1
st.rerun()
# Next button
if right.button("Next", use_container_width=True, disabled=is_last_page()):
if not is_last_page():
st.session_state.page += 1
st.rerun()
# Events
if search == "":
ids_from_table = edited_df["id"].tolist() if "id" in edited_df.columns else []
# Insert the database
for row in edited_df.itertuples():
if math.isnan(row.id) and row.name:
add_colony(row.name)
# Go to the last page
st.session_state.page = last_page()
st.rerun()
# Update the database
cursor = conn.cursor()
for row in edited_df.itertuples():
update_colony(row.id, row.name)
conn.commit()
# Delete the database
cursor = conn.cursor()
ids_from_database = tuple(map(lambda colony: colony["id"], get_colonies()))
ids_to_delete = list(set(ids_from_database) - set(ids_from_table))
for id in ids_to_delete:
delete_colony(id)
if ids_to_delete:
st.session_state.page = 1
st.rerun()
Levantamos el proyecto con:
docker-compose up
Y acceder a la aplicación en tu navegador favorito entrando a http://localhost:8501.
El resultado lo hemos podido ver al inicio del artículo.
Ahora disponemos de una multitud de características para manipular y explorar la tabla de colonias:
- Listado.
- Paginador.
- Búsqueda.
- Creación
- Edición.
- Eliminación.
Y solo por usar Streamlit también podremos exportar la tabla a CSV. Todo ello con un mínimo esfuerzo y sin necesidad de escribir HTML, CSS o JavaScript, interactuando con la base de datos SQLite.
Podrías quedarte en este punto. Cumple la gran mayoría de los requisitos para la gestión de una tabla. Sin embargo vamos a ir un paso más allá: claves foráneas. Crearemos una tabla de gatos, donde dispondremos de un desplegable para seleccionar la colonia a la que pertenece..
4. Gatos
La relación entre gatos y colonias es uno a muchos. Un gato pertenece a una colonia, pero una colonia puede tener varios gatos. Con este requisito es necesitamos un desplegable con las colonias disponibles. Por lo tanto el código será similar al anterior, pero con algunas modificaciones.
Para empezar necesitaremos una intefaz exactamente igual, salvo que los datos serán de la tabla cat
. En lugar de copiar el código anterior y pegarlo a continuación, vamos a dividir el código en páginas.
Renombramos el archivo src/streamlit_app.py
a src/colony.py
, así estará aislado.
De momento, creamos un fichero vacío src/cat.py
.
touch src/cat.py
Ahora volvemos a crear un nuevo archivo src/streamlit_app.py
con el siguiente contenido.
import streamlit as st
pg = st.navigation(
{
"Colonies": [st.Page("colony.py", title="List", icon="🏠", url_path="colony")],
"Cats": [st.Page("cat.py", title="List", icon="🐈", url_path="cat")],
}
)
pg.run()
Deberíamos ver un menú de navegación con dos opciones: Colonias y Gatos.
Esta todo listo. Es el momento de realizar la nueva interfaz para los gatos.
Editamos src/cat.py
con el siguiente contenido.
{{ comments.length }} comentarios