La vida es Corta

Hasta que cumple veinticinco, todo hombre piensa cada tanto que dadas las circunstancias correctas podría ser el más jodido del mundo. Si me mudara a un monasterio de artes marciales en China y estudiara duro por diez años. Si mi familia fuera masacrada por traficantes colombianos y jurara venganza. Si tuviera una enfermedad fatal, me quedara un año de vida y lo dedicara a acabar con el crimen. Si tan sólo abandonara todo y dedicara mi vida a ser jodido.

—Neal Stephenson (Snow Crash)

A los veinticinco, sin embargo, uno se da cuenta que realmente no vale la pena pasarse diez años estudiando en un monasterio, porque no hay WiFi y no hay una cantidad ilimitada de años como para hacerse el Kung Fu.

De la misma forma, cuando uno empieza a programar cree que cada cosa que encuentra podría rehacerse mejor. Ese framework web es demasiado grande y complejo. Esa herramienta de blog no tiene exactamente los features que yo quiero. Y la reacción es "¡Yo puedo hacerlo mejor!" y ponerse a programar furiosamente para demostrarlo.

Eso es bueno y es malo.

Es bueno porque a veces de ahí salen cosas que son, efectivamente, mucho mejores que las existentes. Si nadie hiciera esto, el software en general sería una porquería.

Es malo porque la gran gran mayoria de las veces, tratando de implementar el framework web número 9856, que es un 0.01% mejor que los existentes, se pasa un año y no se hace algo original que realmente puede hacer una diferencia.

Por eso digo que "la vida es corta". No es que sea corta, es que es demasiado corta para perder tiempo haciendo lo que ya está hecho o buscándole la quinta pata al gato. Hay que sobreponerse a la tristeza de que nunca vamos a usar 100% programas hechos por nosotros y nuestros amigos, y aplicar la fuerza en los puntos críticos, crear las cosas que no existen, no las que ya están.

Antes de decidirse a empezar un proyecto hay que preguntarse muchas cosas:

  • ¿Me va a dejar plata?
  • ¿Qué es lo nuevo de este proyecto?
  • ¿Tengo alguna idea de implementación que nadie tuvo?
  • ¿Tengo alguna idea de interface original?
  • ¿Por qué alguien va a querer usar eso?
  • ¿Tengo tiempo y ganas de encarar este proyecto?
  • ¿Me voy a divertir haciéndolo?

Las más importantes son probablemente la última y la primera. La primera porque de algo hay que vivir, y la última porque es suficiente. Si uno decide que sí, que va a encarar un proyecto, hay que tratar de programar lo menos posible.

Una de las tentaciones del programador es afeitar yaks [1]: es una actividad inútil en sí misma, que uno espera le dé beneficios más adelante.

[1]

Frase inventada por Carlin Vieri

Yo estoy escribiendo este libro que tiene links a URLs. Yo quiero que esas URLs sean válidas para siempre. Entonces necesito poder editarlas después de que se imprima el libro y me gustaría un "acortador" de URLs donde se puedan editar. Como no lo encuentro lo escribo.

Si siguiera con "y para eso necesito hacer un framework web, y un módulo para almacenar los datos"... estoy afeitando yaks.

Para poder hacer A, uno descubre que necesita B, para B necesita C. Cuando llegás a D... estás afeitando yaks.

Si necesitás B para lograr A, entonces, buscá una B en algún lado, y usala. Si realmente no existe nada parecido, entonces ahora tenés dos proyectos. Pensá si te interesa más A o B, y si podés llevar los dos adelante. Es un problema.

En este capítulo lo que vamos a hacer es aprender a no reinventar la rueda. Vamos a elegir un objetivo y vamos a lograrlo sin afeitar ningún yak. Vas a ver como creamos un programa útil con casi nada de código propio.

El Problema

Recibí algunas quejas acerca de que algunos links en mis libros no funcionaban cuando fueron publicados.

Para el próximo libro que estoy escribiendo, le propuse a mi editor crear un sitio para registrar las referencias mencionadas.

Usando referencias ascii cortas y únicas a lo largo del libro, es facil proveer un servicio sencillo de redirección a la URL de destino, y arreglarlo cuando cambie (simplemente creando un alerta de email si la redirección da error 404).

—Tarek Ziadé en URLs in Books

Ya que no tengo editor, lo voy a tener que hacer yo mismo. Me parece una buena idea, va a ser útil para este proyecto, no encuentro nada hecho similar [2], es un buen ejemplo del objetivo de este capítulo... ¡vendido!

[2]El que me hizo ver esa cita de Tarek Ziadé fué Martín Gaitán. Con el capítulo ya escrito, Juanjo Conti me ha hecho notar http://a.gd

Una vez decidido a encarar este proyecto, establezcamos las metas:

  • Un redirector estilo tinyURL, bit.ly, etc.
  • Que use URLs cortas y mnemotécnicas.
  • Que el usuario pueda editar las redirecciones en cualquier momento.
  • Que notifique cuando la URL no sirva, para poder corregirla.

Además, como metas "ideológicas":

  • Un mínimo de afeitado de yaks.
  • Que sea un programa relativamente breve.
  • Código lo más simple posible: no hay que hacerse el piola, porque no quiero mantener algo complejo.
  • Cada vez que haya que hacer algo: buscar si ya está hecho (excepto el programa en sí; si no, el capítulo termina dentro de dos renglones).

Separemos la tarea en componentes:

  • Una función que dada una URL genera un slug [3]
  • Un componente para almacenar las relaciones slug => URL
  • Un sitio web que haga la redirección
  • Un mecanismo de edición de las relaciones
[3]Slug es un término que ví en Django: un identificador único formado con letras y números. En este caso, es la parte única de la URL.

Veamos los componentes elegidos para este desarrollo.

Twill

Una de las cosas interesantes de este proyecto me parece hacer que el sistema testee automáticamente las URLs de un usuario.

Una herramienta muy cómoda para estas cosas es Twill que podría definirse como un lenguaje de testing de sitios web.

Por ejemplo, si todo lo que quiero es saber si el sitio www.google.com funciona es tan sencillo como:

go http://www.google.com
code 200

Y así funciona:

$ twill-sh twilltest.script
>> EXECUTING FILE twilltest.script
AT LINE: twilltest.script:0
==> at http://www.google.com.ar/
AT LINE: twilltest.script:1
--
1 of 1 files SUCCEEDED.

Ahora bien, twill es demasiado para nosotros. Permite almacenar cookies [4], llenar formularios, y mucho más. Yo tan solo quiero lo siguiente:

[4]Como problema adicional, almacena cookies en el archivo que le digas. Serio problema de seguridad para una aplicación web.
  1. Ir al sitio indicado.
  2. Testear el código (para asegurarse que la página existe).
  3. Verificar que un texto se encuentra en la página (para asegurarse que ahora no es un sitio acerca de un tema distinto).

O sea, solo necesito los comandos twill code y find. Porque soy buen tipo, podríamos habilitar notfind y title.

Todos esos comandos son de la forma comando argumento con lo que un parser de un lenguaje "minitwill" es muy fácil de hacer:

pyurl3.py

10 from twill.commands import go, code, find, notfind, title
11 
12 
13 def minitwill(url, script):
14     '''Dada una URL y un script en una versión limitada
15     de twill, ejecuta ese script.
16     Apenas una línea falla, devuelve False.
17 
18     Si todas tienen éxito, devuelve True.
19 
20     Ejemplos:
21 
22     >>> minitwill('http://google.com','code 200')
23     ==> at http://www.google.com.ar/
24     True
25 
26     >>> minitwill('http://google.com','title bing')
27     ==> at http://www.google.com.ar/
28     title is 'Google'.
29     False
30 
31     '''
32     try:
33         go(url)
34     except:
35         return False
36     for line in script.splitlines():
37         cmd, arg = line.split(' ', 1)
38         try:
39             if cmd in ['code', 'find', 'notfind', 'title']:
40                 # Si line es "code 200", esto es el equivalente
41                 # de code(200)
42                 r = globals()[cmd](arg)
43         except:
44             return False
45     return True
46 

Veamos minitwill en acción:

>>> minitwill('http://www.google.com','code 200')
==> at http://www.google.com.ar/
True
>>> minitwill('http://www.google.com','code 404')
==> at http://www.google.com.ar/
False
>>> minitwill('http://www.google.com','find bing')
==> at http://www.google.com.ar/
False
>>> minitwill('http://www.google.com','title google')
==> at http://www.google.com.ar/
title is 'Google'.
False
>>> minitwill('http://www.google.com','title Google')
==> at http://www.google.com.ar/
title is 'Google'.
True

Bottle

Esto va a ser una aplicación web. Hay docenas de frameworks para crearlas usando Python. Voy a elegir casi al azar uno que se llama Bottle porque es sencillo, sirve para lo que necesitamos, y es un único archivo. Literalmente se puede aprender a usar en una hora.

¿Qué Páginas tiene nuestra aplicación web?

  • / donde el usuario se puede autenticar o ver un listado de sus redirecciones existentes.
  • /SLUG/edit donde se edita una redirección (solo para el dueño del slug).
  • /SLUG/del para eliminar una redirección (solo para el dueño del slug).
  • /SLUG/test para correr el test de una redirección (solo para el dueño del slug).
  • /SLUG redirige al sitio deseado.
  • /static/archivo devuelve un archivo (para CSS, imágenes, etc)
  • /logout cierra la sesión del usuario.

Empecemos con un "stub", una aplicación bottle mínima que controle esas URLs. El concepto básico en bottle es:

  • Creás una función que toma argumentos y devuelve una página web
  • Usás el decorador @bottle.route para que un PATH de URL determinado llame a esa función.
  • Si querés que una parte de la URL sea un argumento de la función, usás :nombrearg y la tomás como argumento (ej: ver en el listado, función borrar)

Después hay más cosas, pero esto es suficiente por ahora:

pyurl1.py

 1 # -*- coding: utf-8 -*-
 2 '''Un acortador de URLs pero que permite:
 3 
 4 * Editar adonde apunta el atajo más tarde
 5 * Eliminar atajos
 6 * Definir tests para saber si el atajo es válido
 7 
 8 '''
 9 
10 # Usamos bottle para hacer el sitio
11 import bottle
12 
13 @bottle.route('/')
14 def alta():
15     """Crea un nuevo slug"""
16     return "Pagina: /"
17 
18 @bottle.route('/:slug/edit')
19 def editar(slug):
20     """Edita un slug"""
21     return "Editar el slug=%s"%slug
22 
23 @bottle.route('/:slug/del')
24 def borrar(slug):
25     """Elimina un slug"""
26     return "Borrar el slug=%s"%slug
27 
28 # Un slug está formado sólo por estos caracteres
29 @bottle.route('/:slug#[a-zA-Z0-9]+#')
30 def redir(slug):
31     """Redirigir un slug"""
32     return "Redirigir con slug=%s"%slug
33 
34 @bottle.route('/static/:filename#.*#')
35 @bottle.route('/:filename#favicon.*#')
36 def static_file(filename):
37     """Archivos estáticos (CSS etc)"""
38     # No permitir volver para atras
39     filename.replace("..",".")
40     # bottle.static_file parece no funcionar en esta version de bottle
41     return open(os.path.join("static", *filename.split("/")))
42 
43 if __name__=='__main__':
44     """Ejecutar con el server de debug de bottle"""
45     bottle.debug(True)
46     app = bottle.default_app()
47 
48     # Mostrar excepciones mientras desarrollamos
49     app.catchall = False
50 
51     # Ejecutar aplicación
52     bottle.run(app)

Para probarlo, alcanza con python pyurl1.py y sale esto en la consola:

$ python pyurl1.py
Bottle server starting up (using WSGIRefServer())...
Listening on http://127.0.0.1:8080/
Use Ctrl-C to quit.

Apuntando un navegador a esa URL podemos verificar que cada función responde en la URL correcta y hace lo que tiene que hacer:

pyurl1-1.screen.png

La aplicación de prueba funcionando.

Autenticación

Bottle es un framework WSGI. WSGI es un standard para crear aplicaciones web. Permite conectarlas entre sí, y hacer muchas cosas interesantes.

En particular, tiene el concepto de "middleware". ¿Qué es el middleware? Es una aplicación intermediaria. El pedido del cliente va al middleware, este lo procesa y luego se lo pasa a tu aplicación original.

Un caso particular es el middleware de autenticación, que permite que la aplicación web sepa si el usuario está autenticado o no. En nuestro caso, ciertas áreas de la aplicación sólo deben ser accesibles a ciertos usuarios. Por ejemplo, un atajo sólo puede ser editado por el usuario que lo creó.

Todo lo que esta aplicación requiere del esquema de autenticación es saber:

  1. Si el usuario está autenticado o no.
  2. Cuál usuario es.

Vamos a usar AuthKit con OpenID. De esa manera vamos a evitar una de las cosas más molestas de las aplicaciones web, la proliferación de cuentas de usuario.

Al usar OpenID, no vamos a tener ningún concepto de usuario propio, simplemente vamos a confiar en que OpenID haga su trabajo y nos diga "este acceso lo está haciendo el usuario X" o "este acceso es de un usuario sin autenticar".

¿Cómo se autentica el usuario?

Yahoo
Ingresa yahoo.com
Google
Ingresa https://www.google.com/accounts/o8/id [5]
Otro proveedor OpenID
Ingresa el dominio del proveedor o su URL de usuario.
[5]O se crean botones "Entrar con tu cuenta de google", etc. En views/invitado.tpl puede verse como hacerlo usando openid-selector una muy interesante solución basada pricipalmente en javascript.

Luego OpenID se encarga de autenticarlo via Yahoo/Google/etc. y darnos el usuario autenticado como parte de la sesión.

Hagamos entonces que nuestra aplicación de prueba soporte OpenID.

Para empezar, se "envuelve" la aplicación con el middleware de autenticación. Es necesario importar varios módulos nuevos [6]. Eso significa que todos los pedidos realizados ahora se hacen a la aplicación de middleware, no a la aplicación original de bottle.

Esta aplicación de middleware puede decidir procesar el pedido ella misma (por ejemplo, una aplicación de autenticación va a querer procesar los errores 401, que significan "No autorizado"), o si no, va a pasar el pedido a la siguiente aplicación de la pila (en nuestro caso la aplicación bottle).

[6]

Hasta donde sé, necesitamos instalar:

  • AuthKit
  • Beaker
  • PasteDeploy
  • PasteScript
  • WebOb
  • Decorator

pyurl2.py

 9 # Middlewares
10 from beaker.middleware import SessionMiddleware
11 from authkit.authenticate import middleware
12 from paste.auth.auth_tkt import AuthTKTMiddleware
13 
21 if __name__=='__main__':
22     """Ejecutar con el server de debug de bottle"""
23     bottle.debug(True)
24     app = bottle.default_app()
25 
26     # Mostrar excepciones mientras desarrollamos
27     app.catchall = False
28 
29     app = middleware(app,
30                  enable=True,
31                  setup_method='openid',
32                  openid_store_type='file',
33                  openid_store_config=os.getcwd(),
34                  openid_path_signedin='/')
35 
36     app = AuthTKTMiddleware(SessionMiddleware(app),
37                         'some auth ticket secret');
38 
39     # Ejecutar aplicación
40     bottle.run(app)

Para entender esto, necesitamos ver como es el flujo de una conexión standard en Bottle (o en casi cualquier otro framework web). [7]

[7]Este diagrama es 90% mentira. Por ejemplo, en realidad route no llama a pyurl2.alta sino que la devuelve a app que después la ejecuta. Sin embargo, digamos que es metafóricamente cierto.
middleware1.graph.png

Una conexión a la URL "/".

  1. El usuario hace un pedido via HTTP pidiendo la URL "/"
  2. La aplicación web recibe el pedido, ve el PATH y pasa el mismo pedido a route.
  3. La función registrada para ese PATH es pyurl2.alta, y se la llama.
  4. pyurl2.alta devuelve datos, pasados a un mecanismo de templates -- o HTML directo al cliente, pero eso no es lo habitual.
  5. De una manera u otra, se devuelve el HTML al cliente, que vé el resultado de su pedido.

Al "envolver" app con un middleware, es importante que recordemos que app ya no es la misma de antes, tiene código nuevo, que proviene de AuthKit. [8] El nuevo "flujo" es algo así (lo nuevo está en linea de puntos en el diagrama):

[8]Nuevamente es muy mentiroso, estamos ignorando completamente el middleware de sesión, y sin eso AuthKit no funciona. Como excusa: ¡Es con fines educativos! todo lo que hacen las sesiones para nosotros es que AuthKit tenga un lugar donde guardar las credenciales del usuario para el paso 6.
middleware2.graph.png

Una conexión a la URL "/" con AuthKit.

  1. El usuario hace un pedido via HTTP pidiendo la URL "/"
  2. La aplicación web recibe el pedido, ve el PATH y pasa el mismo pedido a route.
  3. La función registrada para ese PATH es pyurl2.alta, y se la llama.
  4. Si pyurl2.alta decide que esta página no puede ser vista, sin estar autenticado, entonces en vez de mandar datos al template, pasa una excepción a app (Error 401).

pyurl2.py

23 @bottle.route('/')
24 def alta():
25     """Crea un nuevo slug"""
26     if not 'REMOTE_USER' in bottle.request.environ:
27         bottle.abort(401, "Sorry, access denied.")
28     return "Pagina: /"
29 
  1. Si app recibe un error 401, en vez de devolverlo al usuario, le dice a AuthKit: "hacete cargo". Ahí Authkit muestra el login, llama a yahoo o quien sea, verifica las credenciales, y una vez que está todo listo...
  2. Vuelve a llamar a pyurl2.alta pero esta vez, además de el request original hay unas credenciales de usuario, indicando que hubo un login exitoso.
  3. pyurl2.alta devuelve datos, pasados a un mecanismo de templates -- o HTML directo al cliente, pero eso no es lo habitual.
  4. De una manera u otra, HTML se devuelve al cliente, que vé el resultado de su pedido.

Para que el usuario pueda cerrar su sesión, implementamos logout:

pyurl2.py

14 @bottle.route('/logout')
15 def logout():
16     bottle.request.environ['paste.auth_tkt.logout_user']()
17     if 'REMOTE_USER' in bottle.request.environ:
18         del bottle.request.environ['REMOTE_USER']
19     bottle.redirect('/')
20 

¿Funciona?

pyurl2-1.screen.png

El sitio muestra una pantalla de login (Es fea porque es la que viene por default)

pyurl2-2.screen.png

Tal vez, el proveedor de OpenID pide usuario/password

pyurl2-3.screen.png

Por una única vez se pide autorizar al otro sitio.

pyurl2-4.screen.png

Estamos autenticados y nuestra aplicación de prueba funciona como antes.

¿Puede quedar bueno esto?

pyurl-production.screen.png

Este mismo programa, en producción, en http://pyurl.sytes.net

Storm

Es obviamente necesario guardar las relaciones usuario/slug/URL en alguna parte. Lo obvio es usar una base de datos. Lo inteligente es usar un ORM.

A favor de usar un ORM:
No se usa SQL directo, lo que permite hacer todo (o casi) en Python. El programa queda más "limpio" al no tener que cambiar de contexto todo el tiempo.
En contra de usar un ORM:
Es una dependencia extra, te ata a un producto que tal vez mañana "desaparezca". Puede tener una pérdida de performance con respecto a usar la base de datos en forma directa.

No me parece grave: Si tenemos cuidado y aislamos el ORM del resto de la aplicación, es posible reemplazarlo con otro más adelante (o eliminarlo y "bajar" a SQL o a NoSQL).

Por lo tanto, en el espíritu de "no inventes, usá", vamos a usar un ORM. En particular vamos a usar Storm, un ORM creado por Canonical, que me gusta [9].

[9]Me gusta más Elixir pero es bastante más complicado para algunas cosas.

En esta aplicación los requerimientos de base de datos son mínimos. Necesito poder guardar algo como (url,usuario,slug,test) y poder después recuperarlo sea por slug, sea por usuario.

Necesito que el slug sea único. Todos los demás campos pueden repetirse. [10]

[10]Sería bueno que la combinación usuario+url lo fuera pero lo veremos más adelante.

Veamos código. Primero, definimos lo que Storm requiere.

pyurl3.py

42 # Usamos storm para almacenar los datos
43 from storm.locals import *
44 
45 
46 # FIXME: tengo que hacer más consistentes los nombres
47 # de los métodos.
48 class Atajo(object):
49     '''Representa una relación slug <=> URL
50 
51     Miembros:
52 
53     id     = Único, creciente, entero (primary key)
54     url    = la URL original
55     test   = un test de validez de la URL
56     user   = el dueño del atajo
57     activo = Si este atajo está activo o no.
58              Nunca hay que borrarlos, sino el ID puede volver
59              atrás y se "recicla" una URL. ¡Malo, malo, malo!
60     status = Resultado del último test (bien/mal)
61     ultimo = Fecha/hora del último test
62     '''
63 
64     # Hacer que los datos se guarden via Storm
65     __storm_table__ = "atajo"
66     id = Int(primary=True)
67     url = Unicode()
68     test = Unicode()
69     user = Unicode()
70     activo = Bool()
71     status = Bool()
72     ultimo = DateTime()
73 
74     

Veamos ahora el __init__ de esta clase. Como "truco", se guarda automáticamente en la base de datos al crearse:

pyurl3.py

65 def __init__(self, url, user, test=''):
66         '''Exigimos la URL y el usuario, test es opcional,
67         _id es automático.'''
68 
69         # Hace falta crear esto?
70         r = self.store.find(Atajo, user=user, url=url)
71         self.url = url
72         self.user = user
73         self.activo = True
74         # Test por default, verifica que la página exista.
75         self.test = u'code 200'
76         if r.count():
77             # FIXME: esto creo que es una race condition
78             # Existe la misma URL para el mismo usuario,
79             # reciclamos el id y el test, pero activa.
80             viejo = r.one()
81             Atajo.store.remove(viejo)
82             self.id = viejo.id
83             self.test = viejo.test
84         self.store.add(self)
85         # Autosave/flush/commit a la base de datos
86         self.save()
87 
88     def save(self):
89         '''Método de conveniencia'''
90         Atajo.store.flush()
91         Atajo.store.commit()
92 
93     

¿Y de dónde sale self.store? De un método de inicialización que hay que llamar antes de poder crear una instancia de Atajo:

pyurl3.py

113     @classmethod
114     def init_db(cls):
115         # Creamos una base SQLite
116         if not os.path.exists('pyurl.sqlite'):
117             cls.database = create_database(
118                 "sqlite:///pyurl.sqlite")
119             cls.store = Store(cls.database)
120             try:
121                 # Creamos la tabla
122                 cls.store.execute('''
123                 CREATE TABLE atajo (
124                     id INTEGER PRIMARY KEY,
125                     url VARCHAR,
126                     test VARCHAR,
127                     user VARCHAR,
128                     activo TINYINT,
129                     status TINYINT,
130                     ultimo TIMESTAMP
131                 )''')
132                 cls.store.flush()
133                 cls.store.commit()
134             except:
135                 pass
136         else:
137             cls.database = create_database(
138                 "sqlite:///pyurl.sqlite")
139             cls.store = Store(cls.database)
140 
141     
142 

El código "original", es decir, convertir URLs a slugs y viceversa es bastante tonto:

pyurl3.py

125 # Caracteres válidos en un atajo de URL
126     validos = string.letters + string.digits
127 
128     def slug(self):
129         '''Devuelve el slug correspondiente al
130         ID de este atajo
131 
132         Básicamente un slug es un número en base 62,
133         representado usando a-zA-Z0-9 como "dígitos",
134         y dado vuelta:
135 
136         Más significativo a la derecha.
137 
138         Ejemplo:
139 
140         100000 => '4aA'
141         100001 => '5aA'
142 
143         '''
144         s = ''
145         n = self.id
146         while n:
147             s += self.validos[n % 62]
148             n = n // 62
149         return s
150 
151     @classmethod
152     # FIXME: no estoy feliz con esta API
153     def get(cls, slug=None, user=None, url=None):
154         ''' Dado un slug, devuelve el atajo correspondiente.
155 
156             Dado un usuario:
157             Si url es None, devuelve la lista de sus atajos
158             Si url no es None , devuelve *ese* atajo.
159         '''
160         if slug is not None:
161             i = 0
162             for p, l in enumerate(slug):
163                 i += 62 ** p * cls.validos.index(l)
164             return cls.store.find(cls, id=i,
165                 activo=True).one()
166         if user is not None:
167             if url is None:
168                 return cls.store.find(cls, user=user,
169                     activo=True)
170             else:
171                 return cls.store.find(cls, user=user,
172                     url=url, activo=True).one()
173 
174     def delete(self):
175         '''Eliminar este objeto de la base de datos'''
176         self.activo = False
177         self.save()
178 
179     def run_test(self):
180         '''Correr el test con minitwill y almacenar
181         el resultado'''
182         self.status = minitwill(self.url, self.test)
183         self.ultimo = datetime.datetime.now()
184         self.save()
185 

¡Veámoslo en acción!

>>> from pyurl3 import Atajo
>>> Atajo.init_db()
>>> a1 = Atajo(u'http://nomuerde.netmanagers.com.ar',
    u'unnombredeusuario')
>>> a1.slug()
'b'
>>> a1 = Atajo(u'http://www.python.org',
    u'unnombredeusuario')
>>> a1.slug()
'c'
>>> Atajo.get(slug='b').url
u'http://nomuerde.netmanagers.com.ar'
>>> [x.url for x in Atajo.get(user=u'unnombredeusuario')]
[u'http://nomuerde.netmanagers.com.ar',
u'http://www.python.org']

Y desde ya que todo está en la base de datos:

sqlite> .dump
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE atajo (
                    id INTEGER PRIMARY KEY,
                    url VARCHAR,
                    test VARCHAR,
                    user VARCHAR
                );
INSERT INTO "atajo" VALUES(1,'http://nomuerde.netmanagers.com.ar',
NULL,'unnombredeusuario');
INSERT INTO "atajo" VALUES(2,'http://www.python.org',NULL,
'unnombredeusuario');
COMMIT;

HTML / Templates

BlueTrip te da un conjunto razonable de estilos y una forma común de construir un sitio web para que puedas saltear la parte aburrida y ponerte a diseñar.

http://bluetrip.org

Soy un cero a la izquierda en cuanto a diseño gráfico, HTML, estética, etc. En consecuencia, para CSS y demás simplemente busqué algo fácil de usar y lo usé. Todo el "look" del sitio va a estar basado en BlueTrip, un framework de CSS.

Dado que no pienso diseñar mucho, ¡gracias BlueTrip!

Necesitamos 3 páginas en HTML:

  • Bienvenida (invitado):
    • Ofrece login.
    • Explica el servicio.
  • Bienvenida (usuario):
    • Ofrece crear nuevo atajo
    • Muestra atajos existentes (ofrece edición/eliminar/status)
    • Ofrece logout
  • Edición de atajo:
    • Cambiar donde apunta (URL).
    • Cambiar test.
    • Probar test.
    • Eliminar.

No voy a mostrar el detalle de cada página, mi HTML es básico, sólo veamos algunas capturas de las páginas:

pyurl3-1.screen.png

Pantalla de invitado.

pyurl3-2.screen.png

Pantalla de usuario.

pyurl3-3.screen.png

Usuario editando un atajo.

Como las páginas son en realidad generadas con el lenguaje de templates de bottle, hay que pensar qué parámetros se pasan, y usarlos en el template. Luego, se le dice a bottle que template usar.

Tomemos como ejemplo la página usuario.tpl, que es lo que vé el usuario registrado en el sitio y es la más complicada. Explicación breve de la sintaxis de los templates [11]:

[11]Si no te gusta, es fácil reemplazarlo con otro motor de templates.
  • {{variable}} se reemplaza con el valor de variable.

  • {{funcion()}} se reemplaza con el resultado de funcion()

  • {{!cosa}} es un reemplazo inseguro. En los otros, se reemplaza < con &lt; etc. para prevenir problemas de seguridad.

  • Las líneas que empiezan con % son Python. Pero....

    Hay que cerrar cada bloque con %end (porque no podemos confiar en la indentación). Ejemplo:

    %for x in range(10):
        <li>{{x}}
    %end
    

Ignorando HTML aburrido, es algo así:

usuario.tpl

25 %if mensaje:
26         <p class="{{clasemensaje}}">
27         {{!mensaje}}
28         </p>
29     %end
30 </div>
31 
32 <div style="float: right; text-align: left; width: 350px;">
33     <form method="POST">
34     <fieldset>
35         <legend>Crear nuevo atajo:</legend>
36         <div>
37         <label for="url">URL a acortar:</label>
38         <input type="text" name="url" id="url"></div>
39         <button class="button positive">Crear</button>
40     </fieldset>
41     </form>
42 </div>
43 
44 <div style="float:left;text-align: right; width: 350px;">
45  <table style="width:100%;">
46   <caption>Atajos Existentes</caption>
47    <thead>
48     <tr> <th>Atajo</th> <th>Acciones</th> </tr>
49    </thead>
50    % for atajo in atajos:
51     <tr>
52      % if atajo.status:
53       <td><img src="/static/weather-clear.png" alt="Success"
54         align="MIDDLE"/>
55       <a href="{{atajo.url}}">{{atajo.slug()}}</a>
56      % else:
57       <td><img src="/static/weather-storm.png" alt="Failure"
58         align="MIDDLE"/>
59       <a href="{{atajo.url}}">{{atajo.slug()}}</a>
60      % end
61      <td><a href="/{{atajo.slug()}}/edit">Editar</a>&nbsp;/&nbsp;
62      <a href="/{{atajo.slug()}}/del">Eliminar</a>&nbsp;/&nbsp;
63      <a href="/{{atajo.slug()}}/test">Probar</a>
64     </tr>
65    %end
66  </table>

La pantalla para usuario no autenticado es un caso particular: la genera AuthKit, no Bottle, por lo que hay que pasar el contenido como parámetro de creación del middleware:

pyurl3.py

360     app = middleware(app,
361         enable=True,
362         setup_method='openid',
363         openid_store_type='file',
364         openid_template_file=os.path.join(os.getcwd(),
365             'views', 'invitado.tpl'),
366         openid_store_config=os.getcwd(),
367         openid_path_signedin='/')
368 

Backend

Vimos recién que al template usuario.tmpl hay que pasarle:

  • Un mensaje (opcional) con una clasemensaje que define el estilo.
  • Una lista atajos conteniendo los atajos de este usuario.

También vemos que el formulario de acortar URLs apunta a esta misma página con lo que la función deberá:

  • Ver si el usuario está autenticado (o dar error 401)
  • Si recibe un parámetro url, acortarlo y dar un mensaje al respecto.
  • Pasar al template la variable atajos con los datos necesarios.

pyurl3.py

159 @bottle.post('/')
160 @bottle.get('/')
161 @bottle.view('usuario.tpl')
162 def alta():
163     """Crea un nuevo slug."""
164     # Requerimos que el usuario esté autenticado.
165     if not 'REMOTE_USER' in bottle.request.environ:
166         bottle.abort(401, "Sorry, access denied.")
167     usuario = bottle.request.environ['REMOTE_USER'].decode('utf8')
168     # Data va a contener todo lo que el template
169     # requiere para hacer la página
170     data = {}
171     # Esto probablemente debería obtenerse de una
172     # configuración
173     data['baseurl'] = 'http://pyurl.sytes.net/'
174     # Si tenemos un parámetro URL, estamos en esta
175     # funcion porque el usuario envió una URL a acortar.
176     if 'url' in bottle.request.POST:
177         # La acortamos
178         url = bottle.request.POST['url'].decode('utf8')
179         if not urlparse.urlparse(url).scheme:
180             url = 'http://' + url
181         parseada = urlparse.urlparse(url)
182         if not all([parseada.scheme, parseada.netloc]):
183             data['url'] = None
184             data['short'] = None
185             data['mensaje'] = u"""URL caca!"""
186             data['clasemensaje'] = 'error'
187         else:
188             a = Atajo(url=url, user=usuario)
189             data['short'] = a.slug()
190             data['url'] = url
191             # La probamos
192             a.run_test()
193             # Mensaje para el usuario de que el acortamiento
194             # tuvo éxito.
195             data['mensaje'] = u'''La URL
196             <a href="%(url)s">%(url)s</a> se convirtió en:
197             <a href="%(baseurl)s%(short)s">
198             %(baseurl)s%(short)s</a>''' % data
199 
200             # Clase CSS que muestra las cosas como buenas
201             data['clasemensaje'] = 'success'
202     else:
203         # No se acortó nada, no hay nada para mostrar.
204         data['url'] = None
205         data['short'] = None
206         data['mensaje'] = None
207 
208     # Lista de atajos del usuario.
209     data['atajos'] = Atajo.get(user=usuario)
210 
211     # Crear la página con esos datos.
212     return data

Las demás páginas no aportan nada interesante:

pyurl3.py

274 @bottle.route('/:slug/edit')
275 @bottle.post('/:slug/edit')
276 @bottle.view('atajo.tpl')
277 def editar(slug):
278     """Edita un slug"""
279     if not 'REMOTE_USER' in bottle.request.environ:
280         bottle.abort(401, "Sorry, access denied.")
281     usuario = bottle.request.environ['REMOTE_USER'].decode('utf8')
282 
283     # Solo el dueño de un atajo puede editarlo
284     a = Atajo.get(slug)
285     # Atajo no existe o no sos el dueño
286     if not a or a.user != usuario:
287         bottle.abort(404, 'El atajo no existe')
288 
289     if 'url' in bottle.request.POST:
290         # El usuario mandó el form
291         a.url = bottle.request.POST['url'].decode('utf-8')
292         a.activo = 'activo' in bottle.request.POST
293         a.test = bottle.request.POST['test'].decode('utf-8')
294         a.save()
295         bottle.redirect('/')
296     return {'atajo': a,
297             'mensaje': '',
298             }
299 
300 
301 @bottle.route('/:slug/del')
302 def borrar(slug):
303     """Elimina un slug"""
304     if not 'REMOTE_USER' in bottle.request.environ:
305         bottle.abort(401, "Sorry, access denied.")
306     usuario = bottle.request.environ['REMOTE_USER'].decode('utf8')
307     # Solo el dueño de un atajo puede borrarlo
308     a = Atajo.get(slug)
309     if a and a.user == usuario:
310         a.delete()
311     # FIXME: pasar un mensaje en la sesión
312     bottle.redirect('/')
313 
314 
315 @bottle.route('/:slug/test')
316 def run_test(slug):
317     """Corre el test correspondiente a un atajo"""
318     if not 'REMOTE_USER' in bottle.request.environ:
319         bottle.abort(401, "Sorry, access denied.")
320     usuario = bottle.request.environ['REMOTE_USER'].decode('utf8')
321 
322     # Solo el dueño de un atajo puede probarlo
323     a = Atajo.get(slug)
324     if a and a.user == usuario:
325         a.run_test()
326     # FIXME: pasar un mensaje en la sesión
327     bottle.redirect('/%s/edit' % slug)
328 
329 
330 # Un slug está formado sólo por estos caracteres
331 @bottle.route('/:slug#[a-zA-Z0-9]+#')
332 def redir(slug):
333     """Redirigir un slug"""
334     # Buscamos el atajo correspondiente
335     a = Atajo.get(slug=slug)
336     if not a:
337         bottle.abort(404, 'El atajo no existe')
338     bottle.redirect(a.url)
339 
340 
341 @bottle.route('/static/:filename#.*#')
342 @bottle.route('/:filename#favicon.*#')
343 def static_file(filename):
344     """Archivos estáticos (CSS etc)"""
345     # No permitir volver para atras
346     filename.replace("..", ".")
347     # bottle.static_file parece no funcionar en esta version de bottle
348     return open(os.path.join("static", *filename.split("/")))
349 

Conclusiones

En este capítulo se ve una aplicación web, completa, útil y (semi)original. El código que hizo falta escribir fue... unas 250 líneas de python.

Obviamente esta aplicación no está lista para ponerse en producción. Algunos de los problemas obvios:

  • Necesita un robots.txt para no pasarse la vida respondiendo a robots
  • Se puede optimizar mucho
  • Necesita protección contra DOS (ejemplo, limitar la frecuencia de corrida de los tests)
  • Necesita que correr un test no bloquee todo el sitio.
  • Necesita ser útil para el fin propuesto!
    • Idea: formulario que toma una lista de URLs y devuelve la lista correspondiente de enlaces acortados.
  • Necesita muchísimo laburo de "UI".

Y hay muchos features posibles:

  • Opcionalmente redirigir en un IFrame y permitir cosas como comentarios acerca de la página de destino.
  • Estadísticas de uso de los links.
  • Una página pública "Los links de Juan Perez" (y convertirlo en http://del.icio.us ).
  • Soportar cosas que no sean links si no texto (y convertirlo en un pastebin).
  • Soportar imágenes (y ser un image hosting).
  • Correr tests periódicamente.
  • Notificar fallas de test por email.

Todas esas cosas son posibles... y quien quiera hacerlas, puede ayudar!

Este programa es open source, se aceptan sugerencias Tal vez hasta esté funcionando en http://pyurl.sytes.net ... Visiten y ayuden!

blog comments powered by Disqus