Pequeño sistema de plugins con Python 3

sábado, 10 de diciembre de 2016



En Python importar módulos en tiempo de ejecución es tan complicado como llamar un método. Esto, junto con algunas de las built-in functions como hasattr() y getattr() (que nos permiten tanto obtener como saber, respectivamente si un objeto existe en otro objeto) nos permitirá agregar funcionalidades a nuestros scripts sobre la marcha con relativamente poco esfuerzo.

Antes de comenzar a programar debemos definir la estructura que van a tener los plugins que se desean importar y por lo tanto cómo se van a implementar en el sistema base.

En esta entrada vamos a programar una calculadora en consola, donde cada función es implementada por un plugin. Las partes del sistema serán:
  1. Base: Es quien implementa los plugins.
  2. Loader: Es quien encuentra y carga los plugins.
  3. Plugins: Son quienes añaden funcionalidades a la base.
La estructura en este caso va a ser muy simple, la calculadora usará un diccionario donde se guardará el nombre de la función como key y como value una tupla donde el primer elemento es la descripción de la función y el segundo la referencia a la función que se debe ejecutar cuando sea seleccionada esa opción.

Loader


El módulo importlib (en Python a partir de la versión 3.1) nos brinda, entre otros el método import_module() el cual nos permite cargar en tiempo de ejecución un módulo. Devuelve la referencia al módulo si este se pudo cargar o, en su defecto lanza un error del tipo: ImportError Ver documentación

Antes de poder importar los módulos, debemos encontrarlos y para eso usaremos las librerias glob y os:


Sólo necesitamos una función que liste los archivos de un directorio, encuentre los scripts de Python, los importe y guarde sus referencias para poderlos llamar luego.

import os
import glob
import importlib


def load(ruta):
    cargados = []
    # Generamos una ruta de la forma: path/*.py
    # Que hace referencia a todos los archivos terminados en .py
    ruta_final = os.path.join(ruta, "*.py")
    for archivo in glob.iglob(ruta_final):  # Listamos los archivos directorio
        print("abriendo:", archivo)
        # Verificamos que la ruta pertenezca a un archivo
        if os.path.isfile(archivo):
            print("encontrado:", archivo)
            try:
                # formateamos a path_1/path_2.modulo
                ruta, archivo = os.path.split(archivo)
                modulo = "{0}.{1}".format(ruta, archivo[:-3])
                print("importando:", modulo)
                # Importamos el modulo
                cargados.append(importlib.import_module(modulo))
            except ImportError as e:
                print("Error cargando el modulo:", archivo)
                print(e)
                next  # En caso de error saltamos al siguiente archivo
    return cargados

La función load() recive como argumento la ruta donde se van a tener guardados los plugins. Se busca en esta ruta todos los archivos terminados en .py, los importamos y guardamos en una lista la referencia a tal módulo.

Base

Este es el esqueleto de la calculadora, donde el diccionario que se mencionó al comienzo de la entrada tiene la forma:

{"nombre_funcion": ("descripcion_de_la_función", referencia_funcion_ejecutar)}

Con este diccionario se generará el menú que se mostrará al usuario. Además, es importante porque esta estructura, de alguna forma, la deben cumplir los plugins para poder ser cargados adecuadamente.

Esta base, también será la encargada de verificar que los plugins que se cargaron con el loader sean válidos según la estructura que definimos anteriormente y así evitar errores.

Para hacer las validaciones necesarias, haremos uso de algunas de las ya mencionadas y muy útiles Built-in functions:

  • hasattr(objeto, nombre): Devuelve True si existe un objeto nombre en objeto (Todo en Python es un objeto, desde las clases hasta las variables)
  • getattr(objeto, nombre [, default]): Devuelve el objeto nombre de objeto. Si el objeto no existe y se especifica default devuelve éste valor, de los contrario lanza un error del tipo AtributeError.
  • isinstance(objeto, clase): Devuelve True si objeto es una instancia de clase.
  • callable(objeto): Devuelve True si objeto es una función, clase (cualquier objeto callable)
  • map(función, lista): Aplica una función a una lista de elementos y devuelve un iterador con el resultado que devuelve la función por cada elemento.
  • max(lista): Devuelve el elemento más grande de la lista.

import load  # Importamos el loader


def cargar():
    """Llama al loader para que cargue los plugins."""
    print("Cargando los modulos")
    cargados = load.load("modulos")
    for modulo in cargados:
        # Verificamos que exista un objeto llamado menu
        if hasattr(modulo, "menu_plugin"):
            dic = modulo.menu
            if isinstance(dic, dict):  # Verificamos que sea un diccionario
                print("Agregando:", modulo.__name__)
                menu.update(dic)  # Agregamos el diccionario a menu


def mostrar():
    """Genera el menu a partir del diccionario."""
# Obtine la longitu del elemento mas largo
    max_key = max(map(len, menu.keys()))  
    print()
    for key in menu:  # Recorremos los elementos del menu
        print("{0}: {1}".format(key.ljust(max_key), menu[key][0]))
    print()

menu = {
    "cargar": ("Carga los modulos", cargar),
    "salir": ("Sale del programa", exit),
}

while True:
    mostrar()  # Mostramos el menu
    opcion = input("Elija una opcion: ")
    if opcion in menu:
        menu[opcion][1]()  # Ejecutamos la funcion correspondiente

  • dict.keys(): Devuelve un iterador con todas las llaves del diccionario
  • dict.update(iter): Agrega al diccionario los elementos del diccionario iter. no es necesario que iter sea un diccionario, también puede ser un elemento iterable que venga en pares (key, value).
  • str.ljust(len [,caracter]): Método de todos los objetos str que permite ajustar el texto para que cumpla con la longitud len, si se pasa el parámetro caracter con este se llena hasta legar a la longitud, si no se especifica se usan espacios en blanco. Esto caracteres se agregan a la derecha.

La función cargar() es la que se encarga de llamar al loader y agregar los módulos que cumplan con la estructura definida. Nótese que no se verifica en este ejemplo que el segundo elemento de la tupla del diccionario sea, por lo menos un objeto callable. Solo se verifica que en el módulo exista un diccionario con el nombre: menu_plugin. Los módulos que cumplen con la estructura son agregados al diccionario menu.

La función mostrar() genera e imprime un menú a partir del diccionario.

El bucle infinito es el encargado de mostrar cada vez el menú, verificar si la opción elegida por el usuario está en el menú y llamar a la función
correspondiente.

Plugins

Los plugins como mínimo deben cumplir con la estructura necesaria para ser agregados al sistema base. En este caso, elplugin debe tener un diccionario llamado menu_plugin con la estructura anteriormente y la función que se llamará cuando el plugin sea seleccionado.

En esta entrada se programarán dos plugins, uno que deriva una función usando sympy y otro que sume dos números.

import sympy


def derivar():
    funcion = input("Ingrese la funcion a derivar: ")
    print(sympy.diff(funcion))

menu_plugin = {
    "derivar": ("Deriva una funcion usando sympy", derivar)
}

def suma():
    a = int(input("Ingrese el primer numero: "))
    b = int(input("Ingrese el segundo numero: "))
    print("La suma es:", a + b)

menu_plugin = {
    "Suma": ("Suma dos numeros", suma)
}

Si los plugins están en un directorio distinto al directorio donde se encuentra la base, se debe agregar al directorio de los plugins un archivo llamado __init__.py que le indica a Python que los archivos que se encuentran en ese directorio pueden ser importados.

El resultado

Sistema plugins Python

Nota final

El buen manejo de las excepciones es crucial para que los errores ocurridos en los plugins no afecten el funcionamiento del sistema base.


Saludos.
Once

No hay comentarios:

Publicar un comentario