Implementar un Singleton en Python

domingo, enero 29, 2012

Singleton es un patrón de diseño cuya función es evitar que un objeto pueda ser instanciado más de una vez. En este post traigo una receta simple para implementar el patrón singleton en Python.

En esta implementación utilizaremos un archivo de bloqueo (lock file) para indicar si la aplicación está en ejecución o no. Python cuenta con el módulo fcntl que nos proporciona una interfaz bastante cómoda para el control de archivos pero está disponible solo para Linux/Unix, eso implica que debemos usar medidas alternativas para Windows.

Detectar SO e importar módulos

Lo primero que debemos hacer es detectar el sistema operativo y ejecutar los import correspondientes para cada caso. Usaremos además el módulo tempfile para generar el lock file como un archivo temporal del sistema.
#!/usr/bin/python2
# -*- coding: utf-8 -*-

import os
import sys
import tempfile

OS = None
if sys.platform.startswith('linux'):
    OS = 'linux'
    import fcntl
elif sys.platform.startswith('win32'):
    OS = 'windows'


Definir la clase Singleton

Después de detectar el sistema operativo definimos la clase Singleton. Básicamente, esta clase será la encargada de crear el lock file al inicio o generar una advertencia y termina la ejecución en caso de que el archivo ya exista (es decir, que ya existe una instancia de la aplicación en ejecución).
class Singleton:
    def __init__(self):
        # Variable para almacenar el file descriptor
        self.fd = None
        # Ruta para el lock file en la carpeta temporal del sistema
        self.filepath = os.path.abspath(os.path.join(tempfile.gettempdir(), 
            'myapp.pid'))
        
        if OS == 'linux':
            # Para el caso de linux usamos el módulo fcntl para crear el archivo
            # y bloquearlo automáticamente. Si la operación falla es porque el
            # archivo ya existe y está bloqueado.
            self.fd = open(self.filepath, 'w')
            try:
                fcntl.lockf(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
            except IOError:
                self.__exit()
        elif OS == 'windows':
            try:
                # Para el caso windows simplemente creamos el archivo "a mano",
                # pero verificamos primero si el archivo existe e intentamos 
                # removerlo (para casos en que la ejecución previa haya sido 
                # interrumpida)
                if os.path.exists(self.filepath):
                    os.unlink(self.filepath)
                self.fd = os.open(self.filepath, os.O_CREAT|os.O_EXCL|os.O_RDWR)
            except OSError, err:
                if err.errno == 13:
                    self.__exit()
    
    def __del__(self):
        # Para el caso de windows también debemos destruir el archivo "a mano" 
        # al finalizar la ejecución del programa.
        if OS == 'windows':
            if self.fd:
                os.close(self.fd)
                os.unlink(self.filepath)
    
    def __exit(self):
        print 'Ya hay una instancia en ejecución. Saliendo'
        sys.exit(-1)
En el __init__ se observa que creamos una variable para almacenar el file descriptor (fd), luego generamos una ruta para el lock file. Posteriormente creamos un lock file con el módulo fcntl (en el caso linux) o creamos un archivo regular (para el caso windows). Adicionalmente, en el caso windows necesitamos hacernos cargo del archivo al finalizar la ejecución, para eso sobreescribimos el método __del__ y colocamos nuestro código. Adicionalmente tenemos la función __exit(), que es la encargada de detener la ejecución del programa de forma elegante.

Clase de pruebas

Con los pasos anteriores tenemos lista nuestra implementación simple del patrón singleton. Ahora, ¿Cómo la usamos? Creamos una clase (por ejemplo MyApp) que herede de singleton y ponemos un bucle infinito para que se mantenga haciendo "algo".
class MyApp(Singleton):
    def __init__(self):
        Singleton.__init__(self)
        print 'Ejecutando MyApp'
        # Creamos un bucle infinito solo para mantener la aplicación en
        # ejecución
        while 1:
            continue


¿Cómo se vería nuestro script?

El código completo de nuestro script se vería así:
#!/usr/bin/python2
# -*- coding: utf-8 -*-

import os
import sys
import tempfile

OS = None
if sys.platform.startswith('linux'):
    OS = 'linux'
    import fcntl
elif sys.platform.startswith('win32'):
    OS = 'windows'

class Singleton:
    def __init__(self):
        # Variable para almacenar el file descriptor
        self.fd = None
        # Ruta para el lock file en la carpeta temporal del sistema
        self.filepath = os.path.abspath(os.path.join(tempfile.gettempdir(), 
            'myapp.pid'))
        
        if OS == 'linux':
            # Para el caso de linux usamos el módulo fcntl para crear el archivo
            # y bloquearlo automáticamente. Si la operación falla es porque el
            # archivo ya existe y está bloqueado.
            self.fd = open(self.filepath, 'w')
            try:
                fcntl.lockf(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
            except IOError:
                self.__exit()
        elif OS == 'windows':
            try:
                # Para el caso windows simplemente creamos el archivo "a mano",
                # pero verificamos primero si el archivo existe e intentamos 
                # removerlo (para casos en que la ejecución previa haya sido 
                # interrumpida)
                if os.path.exists(self.filepath):
                    os.unlink(self.filepath)
                self.fd = os.open(self.filepath, os.O_CREAT|os.O_EXCL|os.O_RDWR)
            except OSError, err:
                if err.errno == 13:
                    self.__exit()
    
    def __del__(self):
        # Para el caso de windows también debemos destruir el archivo "a mano" 
        # al finalizar la ejecución del programa.
        if OS == 'windows':
            if self.fd:
                os.close(self.fd)
                os.unlink(self.filepath)
    
    def __exit(self):
        print 'Ya hay una instancia en ejecución. Saliendo'
        sys.exit(-1)

class MyApp(Singleton):
    def __init__(self):
        Singleton.__init__(self)
        print 'Ejecutando MyApp'
        # Creamos un bucle infinito solo para mantener la aplicación en
        # ejecución
        while 1:
            continue
    
if __name__ == '__main__':
    app = MyApp()


Probando el singleton

Para probarlo abrimos un terminal, nos colocamos en la carpeta donde esté ubicado nuestro script y lo ejecutamos por primera vez. Eso nos dará como resultado algo como:
$ python myapp.py 
Ejecutando MyApp


Abrimos una terminal nueva (sin cerrar la terminal anterior) e intentamos ejecutar la aplicación por segunda vez. Eso nos devolverá:
$ python myapp.py 
Ya hay una instancia en ejecución. Saliendo
¡Y voilá! Logramos que un script de Python pueda ser ejecutado una sola vez.

Hay implementaciones más complejas que almacenan el ID del proceso dentro del lock file y cada vez que se intenta ejecutar una nueva instancia se lee el ID y se verifica que realmente exista un proceso en ejecución con ese identificador. Pero como dije al principio, esta es una receta simple, así que as implementaciones más complejas las dejamos como tareas para el lector ;)

1 comentarios:

Anónimo dijo...

Dos preguntas:

1. En el caso de windows, al borrar el archivo si existe no hacés que básicamente no ande el lock ? (si existe lo borra y entonces siempre podés correr el programa ?)

2. Por qué no usas os.open con os.O_CREAT y os.O_EXCL en linux también que debería andar ? Digo, para achicar las diferencias entre las plataformas... :)