Proyecto : Gestor de Bookmarks
En este post, vamos a desarrollar una sencilla aplicación de consola en Python para gestionar enlaces web de una forma estructurada. De manera similar a los bookmarks de los navegadores, la aplicación permitirá realizar las operaciones básicas de un CRUD como agregar, listar, actualizar y eliminar, pero además realizar búsquedas y organizar los enlaces web en categorías, todo desde la terminal.
La base de datos será SQLite y crearemos dos tablas que estarán relacionadas. Podemos visualizar esa relación en el siguiente diagrama:
---
title: "Diagrama Entidad Relación"
---
erDiagram
Category {
INTEGER id PK
STRING name
}
Bookmark {
INTEGER id PK
STRING url
STRING description
INTEGER category_id FK
}
Bookmark ||--o| Category : "tiene"
Category
: Almacenaremos las categorías disponibles para clasificar los enlaces.Bookmark
: Almacenaremos la URL, descripción y categoría que pertenece.
Estructura del Proyecto
La estructura del proyecto será modular y se organizará de la siguiente manera para mantener claridad, la separación de responsabilidades y la facilidad de mantenimiento:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bookmarks-manager-consola/
│
├── db/
│ ├── __init__.py # Inicializador para el paquete db
│ ├── db.py # Conexión a la base de datos y función run_query
├── crud/
│ ├── __init__.py # Inicializador para el paquete crud
│ ├── crud.py # Lógica CRUD para manejar bookmarks y categorías
├── models/
│ ├── __init__.py # Inicializador para el paquete models
│ ├── models.py # Modelos para Bookmark y Category
├── schema/
│ └── schema.sql # Esquema de la base de datos (SQL)
├── gestor/
│ ├── __init__.py # Inicializador para el gestor de bookmarks
│ ├── bookmark_manager.py # Funcionalidades principales de gestión de bookmarks
├── main.py # Archivo principal para ejecutar la aplicación
└── requirements.txt # Dependencias del proyecto
db/
: Aquí estará el archivo de conexión con la base de datos y las funciones relacionadas con el manejo de las consultas.crud/
: Contiene la lógica para manejar las operaciones CRUD (Crear, Leer, Actualizar, Eliminar) para las entidadesCategory
yBoomark
.models/
: Aquí se definen los modelos de datos paraCategory
yBookmark
, lo que proporciona una forma estructurada de interactuar con los datos.schema/
: Este directorio contiene el archivo SQL (schema.sql
) que define la estructura de la base de datos.main.py
: Este archivo será el punto de entrada del programa donde se ejecutan las acciones del gestor de bookmarks.requirements.txt
: Listado de las dependencias necesarias, comoprettytable
.
Conexión y configuración de la base de datos
Creamos el archivo db.py
que se encargará de la conexión a la base de datos y la creación de las tablas:
1
touch db.py
1
type null > db.py
El código que escribamos en este módulo tiene como objetivo conectarse a la base de datos SQLite, ejecutar consultas SQL y crear la estructura de la base de datos a partir de un archivo SQL externo. Es una forma más modular y limpia de manejar las conexiones y consultas.
Importarciones
Lo primero que necesitamos es importar las librerías que nos ayudarán a conectar con SQLite, gestionar las rutas de los archivos y utilidades para las anotaciones de los tipos:
1
2
3
import sqlite3, os
from sqlite3 import Error
from typing import Optional, List, Any
sqlite3
: El módulo que proporciona la interfaz de Python para interactuar con bases de datos SQLite.Error
: Importamos la claseError
para manejar excepciones relacionadas con errores en la base de datos.os
: Este módulo nos permite gestionar rutas de archivos y directorios. En este caso, es útil para encontrar la ruta del archivo SQL que contiene la estructura de la base de datos.typing
: Es un módulo que proporciona herramientas para mejorar la claridad del código mediante anotaciones de tipo. Estas anotaciones ayudan a los desarrolladores y herramientas automáticas (como linters y IDEs) a entender los tipos esperados.
Declaración de Constantes
A continuación, declaramos algunas constantes que utilizaremos para gestionar las rutas de los archivos de nuestra base de datos. Usamos mayúsculas para indicar que estas variables son constantes (esto es una convención), lo que significa que su valor no cambiará durante la ejecución del programa:
1
2
3
CURDIR = os.path.dirname(os.path.abspath(__file__))
FILENAME = "schema.sql"
FILE = os.path.join(CURDIR, "db", FILENAME)
CURDIR
: Utilizamosos.path.abspath(__file__)
para obtener la ruta del archivo que se está ejecutando. Después, conos.path.dirname()
, extraemos solo el directorio, sin incluir el nombre del archivo. Así obtenemos el directorio donde se encuentra el script. Esto es útil si queremos ubicar otros archivos en el mismo directorio o en subcarpetas sin depender de la ubicación desde la que ejecutemos el script.FILENAME
: Es el nombre del archivo SQL que contiene las instrucciones para crear las tablas y la estructura de la base de datos. En nuestro caso, llamamos a este archivoschema.sql
.FILE
: Aquí estamos utilizandoos.path.join()
para generar la ruta completa del archivoschema.sql
que se encuentra dentro de una subcarpeta llamadadb
. Usaros.path.join()
asegura que las rutas se generen correctamente en cualquier sistema operativo, sin preocuparnos de las barras (\
o/
) que puedan variar.
Conectar a la Base de Datos
Ahora, creamos una función que se encargará de abrir la conexión a la base de datos. Esta es una de las primeras cosas que necesitamos hacer cuando interactuamos con una base de datos. La razón de crear esta función es para centralizar la conexión a la base de datos. Al hacerlo, evitamos tener que escribir varias veces el mismo código para conectarnos a la base de datos cada vez que queramos hacer una operación. Además, si en el futuro necesitamos cambiar el tipo de base de datos o ajustar la conexión, solo tendríamos que modificar esta función.
Aquí tienes el código de la función:
1
2
3
4
5
6
7
8
def open_db() -> Optional[sqlite3.Connection]:
try:
con = sqlite3.connect('gestion_links.db')
return con
except Error as e:
print('Error: ', e)
return None
open_db() -> Optional[sqlite3.Connection]
: Esto indica que la función puede devolver un objeto de tiposqlite3.Connection
o puede devolverNone
si la conexión no se establece correctamente.sqlite3.connect('gestion_links.db')
: Con esta línea intentamos abrir la base de datosgestion_links.db
. Si no existe, SQLite la creará automáticamente.return con
: Si la conexión es exitosa, devolvemos el objeto de conexión para usarlo más tarde en operaciones con la base de datos.except Error as e
: Si ocurre un error al intentar conectarse, lo capturamos y mostramos el mensaje de error.
Ejecutar Consultas SQL
Una vez tenemos la conexión, necesitamos una función que ejecute las consultas SQL. Es importante que esta función sea lo suficientemente flexible para ejecutar tanto consultas simples como múltiples. Creamos una función run_query()
que nos permite modularizar las consultas SQL, evitando la repitición de código. Además, con esta función centralizamos el manejo de errores de las consultas SQL, lo que hace que el código sea más robusto y fácil de mantener.
Aquí tienes el código de la función:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def run_query(sql: str = '', params: Optional[List[Any]] = None, multiple: bool = False) -> sqlite3.Cursor:
if params is None:
params = []
try:
with open_db() as con:
cursor = con.cursor()
if multiple:
cursor.executemany(sql, params)
else:
cursor.execute(sql, params)
if sql.strip().lower().startswith("select"):
return cursor
con.commit()
except Error as e:
print("Error al ejecutar la consulta:", e)
raise
Parámetros de la Función:
sql
: Es la consulta SQL que se va a ejecutar. Si no se pasa, por defecto es una cadena vacía.params
: Es una lista opcional de parámetros que se pasará a la consulta SQL, como los valores en una sentenciaINSERT
,UPDATE
, oSELECT
. Si no se pasa, la función crea una lista vacía.multiple
: Un parámetro booleano que indica si se deben ejecutar múltiples consultas al mismo tiempo (por ejemplo, cuando usamosexecutemany()
para insertar muchos registros de una vez). Si esFalse
, solo se ejecutará una consulta.
Crear la Estructura de la Base de Datos
Una vez tenemos la capacidad de ejecutar consultas, necesitamos una función que nos ayude a crear la estructura inicial de la base de datos, es decir, las tablas, relaciones y demás elementos necesarios. Esta función create_schema()
tiene el próposito de leer el archivo schema.sql
(que contiene las instrucciones SQL para crear las tablas) y lo ejecuta.
Aquí tienes el código de la función:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def create_schema() -> bool:
try:
with open(FILE, 'r') as sql_file:
sql_script = sql_file.read()
with open_db() as con:
con.executescript(sql_script)
print("Base de datos creada exitosamente.")
return True
except (FileNotFoundError, IOError) as e:
print(f"Error al abrir o leer el archivo {FILE}: {e}")
return False
with open(FILE, 'r') as sql_file
: Aquí utilizamos la funciónopen()
para abrir archivos dentro el context managerwith
,FILE
es la constante que apunta a la ubicación del archivo SQL que contiene el esquema de la base de datos.sql_file.read()
: Lee completamente el archivo y se guarda en la variablesql_script
.with open_db() as con
: La función que mencionamos antes que abre la conexión y las contenemos en la variablecon
.con.executescript(sql_script)
: Es un método de la conexión (con
) en SQLite que se utiliza para ejecutar múltiples sentencias SQL.(FileNotFoundError, IOError) as e
: Capturamos tanto los errores de “archivo no encontrado” (FileNotFound
) como errores generales de entrada/salida (IOError
).
Definición de Entidades
Aunque en SQLite no es estrictamente necesario usar clases de modelos como en un ORM, podemos hacer representaciones de nuestras tablas como clases para tener una estructura clara y añadirle algunas funcionales como por ejemplo truncar las URLs que sean largas.
Aquí tienes el código completo para nuestro módulo models.py
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Category:
"""Modelo de la tabla categories."""
def __init__(self, id: int, name: str):
self.id = id
self.name = name
def __repr__(self):
return f"Category(id={self.id}, name={self.name})"
class Bookmark:
"""Modelo de la tabla bookmarks."""
def __init__(self, id: int, name: str, url: str, category_id: int):
self.id = id
self.name = name
self.url = url
self.category_id = category_id
def truncate_url(self, max_len=30) -> str:
"""Método que trunca una URL, si su longitud es mayor a 30 caracteres."""
if len(self.url) > max_len:
return self.url[:max_len] + "..."
return self.url
def __repr__(self):
return f"Bookmark(id={self.id}, url={self.url}, description={self.description}, category_id={self.category_id})"
Operaciones CRUD
En este módulo llamado crud.py
realizaremos las operaciones CRUD (crear, leer, actualizar, eliminar) para ambas tablas (bookmarks
, categories
) y también métodos de búsquedas.
Las clases para manejar las operaciones CRUD se deben centrar en la lógica de negocio de la base de datos, no en la validación o en el manejo de errores detallado. Para ello, tenemos otros módulos como
db.py
que maneja los errores relacionados con las consultas y las conexiones a la base de datos.
Aquí tienes el códido que corresponde a la clase CategoryCRUD:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from db import run_query
from models import Category, Link
class CategoryCRUD:
@staticmethod
def create_category(name: str) -> Category:
sql = "INSERT INTO categories (name) VALUES (?)"
try:
run_query(sql, (name,))
return Category(id=CategoryCRUD.get_category_by_name(name), name=name)
except Exception as e:
pass
@staticmethod
def get_category_by_name(name: str) -> Category:
sql = "SELECT id, name FROM categories WHERE name = ?"
run_query(sql, (name,))
@staticmethod
def search_categories_by_name(text: str) -> list[Category]:
sql = "SELECT id, name FROM categories WHERE name LIKE ?"
cursor = run_query(sql, ('%' + text + '%',))
categories_data = cursor.fetchall()
if categories_data:
categories = [Category(*category_data) for category_data in categories_data]
return categories
return []
Aquí tienes el códido que corresponde a la clase BookmarkCRUD:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class BookmarkCRUD:
@staticmethod
def get_all_bookmarks() -> list[Bookmark]:
sql = "SELECT id, url, description, category_id FROM bookmarks"
cursor = run_query(sql)
bookmarks_data = cursor.fetchall()
bookmarks = [Bookmark(*bookmark_data) for bookmark_data in bookmarks_data]
if len(bookmarks) >= 1:
return bookmarks
return []
@staticmethod
def create_bookmark(url: str, description: str, category_id: int) -> Bookmark:
sql = "INSERT INTO bookmarks (url, url, category_id) VALUES (?, ?, ?)"
run_query(sql, (url, description, category_id))
return Bookmark(id=CategoryCRUD.get_category_id_by_name(name), name=name)
@staticmethod
def get_bookmark_id_by_description(description: str) -> int:
sql = "SELECT id FROM bookmarks WHERE description = ?"
cursor = run_query(sql, (description,))
bookmark_data = cursor.fetchone()
return bookmark_data[0] if bookmark_data else None
Métodos de CategoryCRUD
create_category
: Inserta una categoría en la base de datos y devuelve un objetoCategory
con el ID generado.get_category_by_name
Crear el script de arranque
Para ello creamos un nuevo archivo main.py
1
touch main.py
1
type null > main.py
Este archivo actuará como el script principal, desde donde puedes probar la funcionalidad de todo el sistema.