SecNot

jun 25, 2014

Variables de entorno en virtualenv

Un problema muy común en cualquier aplicación django es la necesidad de almacenar algún dato confidencial para su funcionamiento, por ejemplo una clave, nombre de usuario, o identificador de un API, esto se suele hacer en el archivo de configuración settings.py, pero es una mala practica de seguridad.

La solución es definir variables de entorno en el shell, e importarlas desde settings.py, de manera que sea mas difícil que un fallo exponga la información.

#!/bin/bash
#.bashrc
export EMAIL_HOST_USER="tuemail@gmail.com"
export EMAIL_HOST_PASSWORD="tuclave"
#settings.py
import os

EMAIL_HOST_USER=os.environ['EMAIL_HOST_USER']
EMAIL_HOST_PASSWORD=os.environ['EMAIL_HOST_PASSWORD']

La limitación de este sistema cuando se está usando virtualenv, es que no permite tener distintos valores de una variable para cada entorno. Esto se puede solucionar usando los hooks .virtualenvs/app_env/bin/postactivate para establecer las variables al entrar en el entorno:

#!/bin/bash
# This hook is run after this virtualenv is activated.

export EMAIL_HOST_USER="tuemail@gmail.com"
export EMAIL_HOST_PASSWORD="tuclave"

y .virtualenvs/app_env/bin/predeactivate para limpiarlas al salir:

#!/bin/bash
# This hook is run before this virtualenv is deactivated.

unset EMAIL_HOST_USER
unset EMAIL_HOST_PASSWORD
Click to read and post comments

mar 18, 2014

Web crawler con Scrapy

Los programas que recopilan información de una página web en busca de información se llaman crawler, spider, o arañas en castellano. Es una tarea tan común, que existen multitud de bibliotecas y frameworks con este propósito, en algunos casos simplificando la programación hasta el punto en el que se puede programar un crawler sencillo con menos de 100 lineas de código. Y eso es justamente lo que voy a hacer en este post.

Imaginemos que quiero crear una pequeña base de datos para organizar que libros he leído, y cuales me faltan por leer, pero no quiero tener que introducir manualmente la información de cada libro, así que voy a obtener toda la informacón de una página web, por ejemplo epublibre.

Para programar la araña me he decantado por Python, y un framework llamado Scrapy del que he leido mucho últimamente.

Instalación

La instalación de un paquete en python suele ser muy sencilla, pero en este caso he tenido algún problema con las dependencias, y he necesitado instalar algunos paquetes en el sistema:

$ apt-get install libxml2-dev libxslt-dev libffi-dev

Tras esto se instala scrapy con pip:

$ pip install scrapy

Uso

El primer paso es iniciar un nuevo proyecto:

$ scrapy startproject epublibre_crawler

Esto crea un directorio de proyecto con la siguiente estructura:

epublibre_crawler/
├── epublibre_crawler
│   ├── __init__.py
│   ├── items.py
│   ├── pipelines.py
│   ├── settings.py
│   └── spiders
│       └── __init__.py
└── scrapy.cfg

Una vez creado el proyecto, tenemos que definir los items que queremos extraer, o mejor dicho la clase donde se almacenaran los datos extraídos por scrapy. En mi caso el titulo del libro, y su autor es suficiente, así que añadimos al archivo items.py la subclase de Item:

from scrapy.item import Item, Field

class BookItem(Item):
    title = Field()
    author  = Field()

El siguiente paso es describir usando expresiones XPath, como se puede extraer la información del titulo y autor, de manera que Scrapy pueda diferenciarla del resto de código html de la página de cada libro.

Si miramos el código html de la página de un libro, el título está contenido en un div de la forma:

<div class="det_titulo" id="titulo_libro" style="display:inline-block;">
        Omega
</div>

Dado que el titulo tiene un identificador único "titulo_libro", es fácil de extraer:

xpath("//div[@id='titulo_libro']/text()[normalize-space()]")

para el nombre del autor, tenemos:

<div class="negrita aut_sec" style="display:inline-block;">
    <a href="http://www.epublibre.org/autor/index/425">Jack McDevitt</a>
</div>

este es el único div de la página que usa la clase aut_sec, así que podemos extraer el nombre con:

xpath("//div[@class='negrita aut_sec']/a/text()")

Una vez tenemos las reglas de extracción, hay que describir como son las direcciones de las páginas de libro a las que queremos aplicarlas. El formato es http://www.epublibre.org/libro/detalle/6467, con el numero final variando para cada libro:

'libro/detalle/\d+'

Por último hay que crear la araña que usara scrapy en el directorio spiders:

#epublibre_spider.py
from scrapy.contrib.spiders import CrawlSpider, Rule
from scrapy.contrib.linkextractors.sgml import SgmlLinkExtractor
from scrapy.selector import Selector

# Importar la clase donde almacenar los resultados
from epublibre_crawler.items import BookItem

class BookSpider(CrawlSpider):

    # Nombre de la araña.
    name = 'epublibre'

    # Dominios en los que el crawler tiene permiso a acceder
    allowed_domains = ['epublibre.org']

    # La direccion de inicio para el crawler
    start_urls = ['http://www.epublibre.org']

    # Regla para diferenciar los enlaces de libros y función que se les aplica
    rules = [Rule(SgmlLinkExtractor(allow=['/libro/detalle/\d+']), 'parse_book')]

    def parse_book(self, response):
        """ Parser para las pagina de detalle de los libros"""
        sel = Selector(response)

        # Creamos un nuevo libro y asignamos los valores extraidos a
        # los campos correspondientes.
        book = BookItem()
        author = sel.xpath("//div[@class='negrita aut_sec']/a/text()").extract()
        title = sel.xpath("//div[@id='titulo_libro']/text()[normalize-space()]").extract()

        # Con Strip eliminamos tabulaciones y linea nueva.
        book['title']  = title[0].strip("\t\n\r")
        book['author'] = author[0].strip("\t\n\r")

        return book

Antes de iniciar scrapy hay que modificar las configuración y limitar la velocidad a la que los datos son accedidos, para no crear un ataque DOS:

# Scrapy settings for epublibre_scrapy project
#
# For simplicity, this file contains only the most important settings by
# default. All the other settings are documented here:
#
#     http://doc.scrapy.org/en/latest/topics/settings.html
#

BOT_NAME = 'epublibre_crawler'

SPIDER_MODULES = ['epublibre_scrapy.spiders']
NEWSPIDER_MODULE = 'epublibre_scrapy.spiders'

# Maximo una pagina cada minuto
DOWNLOAD_DELAY = 60

# Identificate
# USER_AGENT = 'epublibre_crawler (+http://www.tudominio.com)'

Finalmente solo queda ejecutar scrapy para que recolecte la información y la guarde en un archivo en alguno de los formatos soportados (XML, JSON, CSV), o hasta directamente en una base de datos usando una pipeline. En mi caso JSON es el formato elegido:

$ scrapy crawl epublibre -o libros.json -t json

el nombre epublibre es el que asigné a la araña BookSpider en la variable name.

El formato del archivo de salida es el siguiente:

[{"author": ["Ray Bradbury"], "title": ["Fahrenheit 451"]},
 {"author": ["John Scalzi"], "title": ["Redshirts"]}
]

Nota: Este programa es sólo un ejemplo para explicar el funcionamiento de scrapy, si realmente deseas extraer la información de epublibre.org, la propia página distribuye un archivo CSV con todos los datos de los libros disponibles.

Click to read and post comments

feb 11, 2014

Problemas con ImageField en Django

ImageField proporciona una buena abstracción para la gestión imágenes, hace fácil la subida de los archivos a la pagina, y proporciona tags para mostrarlas en los template. Pero ciertos comportamientos por defecto del campo, hacen imposible usarlo sin modificaciones en prácticamente cualquier proyecto django.

Estas son algunas de las modificaciones que suelo hacer en casi cualquier modelo con un campo ImageField.

Cambiar el nombre del archivo antes de guardarlo

Cuando ImageField salva la imagen, lo hace en un archivo con el mismo nombre que el archivo original, esto es un problema puesto que si se salvan dos imágenes con el mismo nombre una sobreescribe a la otra.

Este comportamiento es admisible un blog, donde el autor puede controlar el nombre de cada imagen, en cambio en una página donde los usuarios puedan subir imágenes, no tardaría en ser un problema grave.

La solución es usar el parámetro de ImageField upload_to, para proporcionar un nombre de archivo aleatorio con el que salvar la imagen.

from django.db import models
from uuid import uuid4
from datetime import date
import os

class Post(models.Model):

    def _generar_ruta_imagen(instance, filename):
        # El primer paso es extraer la extension de la imagen del
        # archivo original
        extension = os.path.splitext(filename)[1][1:]

        # Generamos la ruta relativa a MEDIA_ROOT donde almacenar
        # el archivo, usando la fecha actual (año/mes)
        ruta = os.path.join('Imagenes', date.today().strftime("%Y/%m"))

        # Generamos el nombre del archivo con un identificador
        # aleatorio, y la extension del archivo original.
        nombre_archivo = '{}.{}'.format(uuid4().hex, extension)

        # Devolvermos la ruta completa
        return os.path.join(ruta, nombre_archivo)

    imagen = models.ImageField(upload_to=_generar_ruta_imagen)

    text = models.TextField(max_lenght=1000)

La función _generar_ruta_imagen es llamada la primera vez que se salva la imagen para generar la ruta. Si estudias su código, verás que ademas de generar el nombre del archivo, generamos la ruta donde es almacenado, esta ruta se genera con la fecha en el momento de salvado, de manera que cambia con el paso del tiempo. Con esto se evita que miles de imágenes se guarden en un solo directorio, haciéndolo difícil de listar y manipular.

Usar un Mixin para añadir imágenes a varios modelos

En el caso de que tengamos varios modelos que necesiten una imagen, podemos crear una clase abstracta que proporciona un campo imagen, que genere de forma correcta las rutas y nombres. Luego solo tenemos que heredar de ella en cada modelo que necesite esa funcionalidad.

from django.db import models
from uuid import uuid4
from datetime import date
import os

class ImagenMixin(models.Model):

    def _generar_ruta_imagen(instance, filename):
        # El primer paso es extraer la extension de la imagen del
        # archivo original
        extension = os.path.splitext(filename)[1][1:]

        # Generamos la ruta relativa a MEDIA_ROOT donde almacenar
        # el archivo, se usa el nombre de la clase y la fecha actual.
        directorio_clase = instance.__class__.__name__
        ruta = os.path.join('imagenes', directorio_clase,
            date.today().strftime("%Y/%m"))

        # Generamos el nombre del archivo con un identificador
        # aleatorio, y la extension del archivo original.
        nombre_archivo = '{}.{}'.format(uuid4().hex, extension)

        # Devolvermos la ruta completa
        return os.path.join(ruta, nombre_archivo)

    imagen = models.ImageField(upload_to=_generar_ruta_imagen)

    class Meta:
        abstract = True

class Post(ImagenMixin):
    text = models.TextField(max_lenght=1000)

class Libro(ImagenMixin):
    titulo = models.CharField(max_length=100)

El único cambio está en _generar_ruta_imagen de la clase abstracta, donde se añade a la ruta de salvado el nombre de la clase. De esta manera se separan las imágenes salvadas por cada clase en un directorio distinto. Por ejemplo los modelos Post y Libro guardarían sus imágenes en:

  • /MEDIA_ROOT/imagenes/Post/año/mes/
  • /MEDIA_ROOT/imagenes/Libro/año/mes/

Mostrar imágenes en el interfaz Admin

Si usas admin para añadir contenido a tu página web, es realmente incómodo no poder ver las imágenes directamente en su interfaz. Para solucionarlo creamos un método en el modelo, que devuelve un tag <img> apuntando a la dirección de la imagen:

from django.db import models
from django.utils.safestring import mark_safe


class Post(models.Model):

    imagen = models.ImageField()
    text = models.TextField(max_length=1000)

    def imagen_admin(self):
        if self.image:
            # Marcamos imagen como safe para evitar escape automatico
            return mark_safe(u'<img src="%s" />' % self.imagen.url))
        else:
            return '(Sin imagen)'

    # Para cambiar el nombre del campo en pantalla
    imagen_admin.short_description = 'Imagen'

    # En lugar de mark_safe podemos añadir:
    imagen_admin.allow_tags = True

Y modificamos el ModelAdmin del modelo para que muestre la imagen. Hay que asegurarse de añadir el nuevo método a readonly_fields:

from django.contrib import admin
from blog.models import Post

class PostAdmin(admin.ModelAdmin):
    # imagen_admin tiene que ser siempre readonly, si queremos modificar
    # la imagen hay que hacerlo a traves del campo imagen.
    readonly_fields = ('imagen_admin',)
    fields = ('imagen_admin', 'imagen', 'text',)

admin.site.register(Post, PostAdmin)

Si no quieres modificar tu modelo, puedes crear un método similar directamente en ModelAdmin:

from django.contrib import admin
from blog.models import Post

class PostAdmin(admin.ModelAdmin):

    def imagen_admin(self, obj):
        return u'<img src="%s" />' % obj.image.url

    imagen_admin.allow_tags = True

    readonly_fields = ('imagen_admin',)
    fields = ('imagen_admin', 'text')

admin.site.register(Post, PostAdmin)

Por último si estás usando el mixin de la sección anterior, ese es el mejor lugar donde añadir el método.

Eliminar el archivo de la imagen al borrar el modelo

El comportamiento por defecto de ImageField, es no borrar el archivo cuando el modelo es eliminado, para borrarlo es necesario conectarse a la señal pre_delete que se envía antes de la eliminación de cualquier modelo, y hacerlo manualmente.

from django.db import models
from django.db.models.signals import pre_delete, pre_save
from django.dispatch import receiver


class Post(models.Model):
    imagen = models.ImageField()
    text = models.TextField(max_length=1000)

# Usamos el Decorador receiver para ejecutar nuestra función
# cuando el Post el borrado.
@receiver(pre_delete, sender=Post)
def post_pre_delete_handler(sender, instance, **kwargs):
    instance.imagen.delete(False)

Ten en cuenta que si borras el archivo con el modelo, puedes perder datos si hay algún accidente, si el numero de imágenes almacenado no es grande es mejor no borrarlas. Si el número es grande, puedes usar una tarea asíncrona que borre los archivos huérfanos tras borrar los modelos, esto lo puedes conseguir con celery, cron, o similar.

Click to read and post comments

feb 02, 2014

Django con virtualenv

Virtualenv es una herramienta para crear entornos virtuales para python.

Cuando mas de una aplicación python esta instalada en un mismo sistema, se puede dar el caso de que requieran versiones incompatibles de una biblioteca, que no sea posible instalarla en el directorio site-packages, o que no quieras actualizar las bibliotecas de la aplicación cuando se actualizen las del sistema.

Virtualenv soluciona todos estos problemas, permitiendo crear entornos virtuales en los que se pueden instalar bibliotecas y programas, de forma independiente del sistema, y otros entornos. Esto es especialmente útil durante el desarrollo y despliegue de aplicaciones.

Virtualenv

Instalación

Para instalar virtualenv, lo mejor es usar el gestor de paquetes de tu distribución, para hacer la herramienta disponible a todos los usuarios:

secnot@secnot:~$ sudo apt-get install python-virtualenv

Crear un entorno

Una vez instalado, vamos a crear un directorio donde almacenar todos nuetros entornos, por ejemplo:

secnot@secnot:~$ mkdir virtualenvs

Ahora creamos un nuevo entorno dentro del directorio, como no queremos que use ninguna libreria del sistema añadimos el parametro --no-site-packages

secnot@secnot:~$ virtualenv --no-site-packages virtualenvs/proyecto1_env

Instalar django en el entorno

Antes de instalar un nuevo paquete, tenemos que activar el entorno, para que cambie los directorios de busquesda a los del entorno:

secnot@secnot:~$ source virtualenvs/proyecto1_env/bin/activate

Ahora ya podemos instalar django y los paquetes que necesitemos para nuestra aplicación:

(proyecto1_env)secnot@secnot:~$ pip install django
(proyecto1_env)secnot@secnot:~$ pip install django-countries
(proyecto1_env)secnot@secnot:~$ django-admin.py startproject proyecto1

Para instalar un paquete no es necesario ser root, todos los paquetes se almacenan en los directorios creados dentro de nuestro directorio. Si te fijas verás que el prompt ha cambiado, indicando el entorno que está activo.

Una vez no necesitemos el entorno podemos salir ejecutando:

(proyecto1_env)secnot@secnot:~$ deactivate

Requirements

Una funcionalidad muy útil es la generación de una lista con todos los paquetes instalados en un entorno, esta normalmente se almacenan en un archivo llamado requirements.txt. Este archivo puede usarse después para duplicar los paquetes instalados al crear un nuevo entorno. Para generarla:

secnot@secnot:~$  source environments/env1/bin/activate
(env1)secnot@secnot:~$ pip freeze
Django==1.6
gunicorn==18.0
wsgiref==0.1.2
(env1)secnot@secnot:~$ pip freeze > requirements.txt

Si queremos instalar todos los paquetes de la lista en un entorno:

(env3)secnot@secnot:~$ pip install -r requirements.txt

Virtualenvwrapper

Virtualenvwrapper es un conjunto de scripts que automatizan la creación, borrado, y gestión de entornos. Se puede trabajar directamente con virtualenv sin ningún problema, pero virtualenvwrapper hace que todo el proceso sea un poco más sencillo y cómodo.

Instalación y configuración

Instalamos virtualenvwrapper con el gestor de paquetes:

secnot@secnot:~$ sudo apt-get install virtualenvwrapper

Por defecto los entornos se almacenan en el directorio .virtualenvs y no necesita mas configuración simplemente sal y vuelve ha hacer login en la cuenta para que se configuren las variables de entorno.

Si por qualquier razón en tu sistema no ha funcionado, puedes configurarlo manualmente y añadirlo en el script de inicio .bashrc:

secnot@secnot:~$ mkdir .virtualenvs
# /home/secnot/.bashrc
# Configuración de VIRTUALENVWRAPPER
export WORKON_HOME=$HOME/.virtualenvs
source /usr/local/bin/virtualenvwrapper.sh
# Ubuntu 14.04: source /etc/bash_completion.d/virtualenvwrapper

Uso

Estos son los comandos de virtualenvwrapper más usados, para mas detalles es recomendable leer la documentación:

  • mkvirtualenv: Crea un nuevo entorno, con la particularidad de que se convierte en el entorno activo.
  • cpvirtualenv: Duplica un entorno.
  • rmvirtualenv: Elimina un entorno, el entorno a elminar debes estar inactivo antes de eliminarlo.
  • allvirtualenv: Ejecuta un comando en todos los entornos.
  • workon: Selecciona el entorno activo, si no hay argumentos lista los entornos disponibles.
  • deactivate: Desactiva el entorno indicado.

El mejor método para entender como funciona es un ejemplo, así el vamos a crear dos entornos, despues instalar django en el primero de ellos, y por último eliminar el segundo entorno:

secnot@secnot:~$ mkvirtualenv --no-site-packages env1
New python executable in env1/bin/python
Installing setuotools, pip...done.
(env1)secnot@secnot:~$ mkvirtualenv --no-site-packages env2
New python executable in env2/bin/python
Installing setuotools, pip...done.
(env2)secnot@secnot:~$ workon env1
(env1)secnot@secnot:~$ pip install django
(env1)secnot@secnot:~$ workon
env1
env2
(env1)secnot@secnot:~$ deactivate
secnot@secnot:~$ rmvirtualenv env2
Removing env2...
secnot@secnot:~$ ...

Si quieres usar una versión de Python distinta a la por defecto del sistema, puedes especificarla en el momento de creación del entorno.

secnot@secnot:~$ mkvirtualenv -p /usr/bin/python3 --no-site-packages env1
Click to read and post comments