Learning Python

La persistencia de datos

Cuando hablamos de persistencia de datos nos referimos al hecho de que la información quede almacenada en una máquina, ya sea local o remota, con intención de poder tener acceso a dicha información para un uso futuro.

El objetivo de hoy va a ser realizar una persistencia lo más sencilla posible con el mínimo de conocimientos necesarios. Vamos a hacer uso de dataset, una dependencia que permite trabajar con bases de datos como si de diccionarios se tratase.

Comienza instalando la nueva dependencia: pipenv install dataset==1.3.1

Para manejar los datos con los que vas a trabajar a mí me gusta tener un objeto el cual se encargue de exponer solo aquellas funcionalidades de dataset que te interesen, centralizando la lógica a base de datos en unas clases llamadas modelos.

Crea un nuevo fichero, dentro de src, llamado models.py y copia el siguiente código:

"""The models, representation of database tables."""
import logging

import dataset

from settings import Settings as settings


logging.basicConfig(format='%(asctime)-15s [%(levelname)s] %(filename)s - %(message)s', level='DEBUG')
logger = logging.getLogger(__name__)


db = dataset.connect(f'sqlite:///{settings.DDBB}')


class Article:

   def __init__(self):
       self.table = db['article']

   def get(self, **kwargs) -> dict:
       """Get an article."""
       res = self.table.find_one(**kwargs)

       if res:
           logger.debug(f'Article found. criteria: "{kwargs}".')

       return res

   def all(self, is_published=False) -> dict:
       """Get all un published articles."""
       res = self.table.find(is_published=is_published, order_by='-id')
       if res:
           logger.debug(f'Articles found.')

       return res

   def create(self, title: str, link: str) -> int:
       """Create a new article if not exists the title.

       :return int: ID of inserted element.
       """
       if self.get(title=title):
           return 0

       res = self.table.insert({'title': title, 'link': link, 'is_published': False})
       logger.debug(f'Article created. id={res}')

       return res

   def publish(self, pk: int) -> int:
       """Publish the article.

       :return int: number of update elements.
       """
       res = self.table.update({'id': pk, 'is_published': True}, ['id'])

       if res:
           logger.debug(f'Article published. criteria: id={res}')

       return res

Puedes contrastar los cambios aplicados en el repositorio.

Aquí ya hay más código. Intentaré explicarlo.

  • Lo primero a destacar es que existe un objeto llamado db, el cual se encarga de gestionar todas las conexiones, creaciones y consultas realizadas a la base de datos. Se observa que hemos decidido poner el nombre de la base de datos como constante, en los ficheros .env y settings.py deberá quedar la nueva constante.

  • La clase Article, la cual es la encargada de exponer aquellas interacciones con la base de datos que nos interesa. Las funciones permitirán:

    • Devolver un articulo: get(criteria)
    • Devolver todos aquellos artículos publicados/no-publicados: all(is_published=bool)
    • Crear un nuevo artículo `create(title, link)
    • Publicar un artículo publish(id)

Pasemos a probar todo esto. Abre un terminal, navega a ~/project/src, ejecuta python y escribe:

from settings import Settings
from models import Article
article = Article()
article.create('a good title', 'https://apsl.net/')

Verás que tras crear el artículo, el terminal te devuelve un 1. Ese uno es el ID del nuevo objeto creado.

>>> article.get(id=1)
2020-05-16 13:28:52,398 [DEBUG] models.py - Article found. criteria: "{'id': 1}".
OrderedDict([('id', 1), ('title', 'a good title'), ('link', 'https://apsl.net/'), ('is_published', False)])
>>> article.get(title='a good title')
2020-05-16 13:29:07,815 [DEBUG] models.py - Article found. criteria: "{'title': 'a good title'}".
OrderedDict([('id', 1), ('title', 'a good title'), ('link', 'https://apsl.net/'), ('is_published', False)])
>>> article.get(link='https://apsl.net/')
2020-05-16 13:29:21,942 [DEBUG] models.py - Article found. criteria: "{'link': 'https://apsl.net/'}".
OrderedDict([('id', 1), ('title', 'a good title'), ('link', 'https://apsl.net/'), ('is_published', False)])
>>>

Esto habrá creado un nuevo fichero en el directorio src, llamado: mydatabase.db, esto es un fichero de base de datos del tipo SQLite. No podrás leerlo directamente, para ello requerirá de otras aplicaciones o del plugin de Visual Studio Code “SQLite, de alexcvzz” en este fichero quedará la persistencia.

Volvamos al objeto devuelto tras la invocación a la función get(), los OrderedDict. A efectos prácticos un OrderedDict es un diccionario, que permite el acceso a sus valores de la siguiente forma:

>>> first_article = article.get(id=1)
2020-05-16 13:30:52,862 [DEBUG] models.py - Article found. criteria: "{'id': 1}".
>>> first_article['title']
'a good title'
>>>

Prueba ahora el método all

>>> article.all()
2020-05-16 13:32:30,680 [DEBUG] models.py - Articles found.
<dataset.util.ResultIter object at 0x7f0b65b5e400>

¿Qué es eso de ResultIter? Los iteradores son listas que tras consumir el valor actual se eliminan de la memoria. Esto quiere decir que solo podrás recorrer una vez sus valores. Si quieres ver su contenido prueba:

>>> list(article.all())
2020-05-16 13:35:20,617 [DEBUG] models.py - Articles found.
[OrderedDict([('id', 1), ('title', 'a good title'), ('link', 'https://apsl.net/'), ('is_published', False)])]

Y la última prueba, ver si tras publicar el artículo 1, la función all deja de retornarlo:

>>> article.publish(1)
2020-05-16 13:39:08,517 [DEBUG] models.py - Article published. criteria: id=1
1
>>> list(article.all())
2020-05-16 13:39:33,547 [DEBUG] models.py - Articles found.
[]
>>> 

El proyecto debería quedar así.

Genial, la persistencia de datos es todo un éxito, podemos continuar.

Unificando los parámetros del logging

Recuerdas que logging.basicConfig(format='%(asctime)-15s [%(levelname)s] %(filename)s - %(message)s', level='DEBUG') lo tenemos repetido ya en dos ficheros diferentes? Si quisiéramos cambiar el nivel de DEBUG a INFO ya nos supondría ir a dos ficheros diferentes y esto podría incrementarse conforme creemos más ficheros. Para evitarlo vamos a unificarlo y añadirlo a nuestras constantes.

Añadimos a settings.py la siguiente función:

def kwargs_logging_config() -> dict:
   return dict(format=Settings.LOG_FORMAT, level=Settings.LOG_LEVEL)

Y, tanto en scra.py como en models.py, modificar el logging de:

logging.basicConfig(format='%(asctime)-15s [%(levelname)s] %(filename)s - %(message)s', level='DEBUG')
logger = logging.getLogger(__name__)

a:

from settings import kwargs_logging_config


logging.basicConfig(**kwargs_logging_config())
logger = logging.getLogger(__name__)

Con esto hemos centralizado tanto el formato del mensaje de los logs como el nivel de los mismos en un único punto.

Puedes contrastar los cambios con el repositorio.

En este momento, el proyecto debería verse así.

Modificando el scrapping, ahora quedará persistido

Bueno, ¿recuerdas qué hacía scra.py? Se limitaba a imprimir los datos recogidos. Bien, ahora te toca persistir esos datos en nuestra base de datos, para ello será necesario importar el modelo Article y realizar unas pocas modificaciones, debe quedar así:

"""Collector of article."""
import logging

import requests
from bs4 import BeautifulSoup

from models import Article
from settings import kwargs_logging_config


logging.basicConfig(**kwargs_logging_config())
logger = logging.getLogger(__name__)



def collect_feed(url) -> dict:
   logger.debug(f'Connecting to {url}...')
   page = requests.get(url)
   if page.status_code == 200:
       logger.debug('Connected!')

       soup = BeautifulSoup(page.text, 'xml')

       ids = []
       for item in soup.find_all('item'):
           desc = item.description.text[:512]
           if '</h1>' in desc:
               desc = ''.join(desc.split('</h1>')[1:])

           ids.append(Article().create(
               title=item.title.text,
               link=item.link.text,
           ))
       ids = [x for x in ids if x]
       if ids:
           logger.info(f'{len(ids)} article created. ids={ids}')
       else:
           logger.info('No article created.')

Aunque parece que ha cambiado mucho, en realidad no es así. Hemos cambiado el nombre de la lista titles por ids, cambiado el log de los títulos por la creación de un artículo y en vez de guardar títulos para mostrar cuántos han sido encontrados lo que imprimimos son los ids de los elementos creados.

Puedes consultar los cambios realizados en el respositorio.

Hora de las pruebas… Abre un terminal de python en el directorio ~/project/src y ejecuta el collect_feed:

>>> from settings import Settings
>>> from scra import collect_feed
>>> collect_feed(Settings.URL_FEED)
2020-05-16 14:04:32,742 [DEBUG] scra.py - Connecting to https://www.apsl.net/blog/feed...
2020-05-16 14:04:32,747 [DEBUG] connectionpool.py - Starting new HTTPS connection (1): www.apsl.net:443
2020-05-16 14:04:32,958 [DEBUG] connectionpool.py - https://www.apsl.net:443 "GET /blog/feed HTTP/1.1" 301 0
2020-05-16 14:04:33,046 [DEBUG] connectionpool.py - https://www.apsl.net:443 "GET /blog/feed/ HTTP/1.1" 200 138102
2020-05-16 14:04:33,130 [DEBUG] scra.py - Connected!
2020-05-16 14:04:33,171 [DEBUG] models.py - Article created. id=2
2020-05-16 14:04:33,180 [DEBUG] models.py - Article created. id=3
2020-05-16 14:04:33,191 [DEBUG] models.py - Article created. id=4
2020-05-16 14:04:33,201 [DEBUG] models.py - Article created. id=5
2020-05-16 14:04:33,210 [DEBUG] models.py - Article created. id=6
2020-05-16 14:04:33,219 [DEBUG] models.py - Article created. id=7
2020-05-16 14:04:33,228 [DEBUG] models.py - Article created. id=8
2020-05-16 14:04:33,239 [DEBUG] models.py - Article created. id=9
2020-05-16 14:04:33,248 [DEBUG] models.py - Article created. id=10
2020-05-16 14:04:33,259 [DEBUG] models.py - Article created. id=11
2020-05-16 14:04:33,268 [DEBUG] models.py - Article created. id=12
2020-05-16 14:04:33,279 [DEBUG] models.py - Article created. id=13
2020-05-16 14:04:33,291 [DEBUG] models.py - Article created. id=14
2020-05-16 14:04:33,299 [DEBUG] models.py - Article created. id=15
2020-05-16 14:04:33,308 [DEBUG] models.py - Article created. id=16
2020-05-16 14:04:33,322 [DEBUG] models.py - Article created. id=17
2020-05-16 14:04:33,331 [DEBUG] models.py - Article created. id=18
2020-05-16 14:04:33,340 [DEBUG] models.py - Article created. id=19
2020-05-16 14:04:33,349 [DEBUG] models.py - Article created. id=20
2020-05-16 14:04:33,360 [DEBUG] models.py - Article created. id=21
2020-05-16 14:04:33,360 [INFO] scra.py - 20 article created. ids=[2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21]
>>>

El proyecto debería quedar así.

Todo funciona correctamente. Como puedes ver esta vez se ha dedicado a crear artículos en nuestra base de datos. Solo destacar que comienza por el id 2 debido a que el id 1 está ya creado ¿recuerdas? En pruebas previas creamos un artículo manualmente, por eso comienzan por el número 2. No es problema, todo está bien.

Y con esto cerramos el capítulo de la persistencia de datos. En el próximo artículo crearemos una API con la que exponer los datos persistidos.


Lamento haber estado sin subir artículos las últimas semanas, el poco tiempo libre que he tenido he preferido dedicarlo a otros hobbies y sinceramente, con el calor no me apetecía ponerme en el ordenador :P

Espero que el contenido de hoy haya sido de tu agrado y lo hayas podido seguir sin mucha complicación. Para la próxima semana comenzaremos con la creación del API para nuestro proyecto.

Un saludo y espero que te vaya bien en estos calurosos días de agosto! :D