Structurez vos applications Python avec un système de Plugins

Presenter Notes

De quoi va-t-on parler ?

  • Qu'est ce qu'un plugin ?
  • Pourquoi utiliser des plugins ?
  • Un exemple d'utilisation avec une application de suivi des tâches
    • Cœur de l'application
    • Plugins par modules
    • Plugins par paquets

Presenter Notes

Qu'est ce qu'un plugin ?

Paquet/module qui complète un logiciel hôte de façon dynamique pour lui apporter de nouvelles fonctionnalités.

Un module/paquet chargé de façon dynamique dans notre application par une action de l'utilisateur (argument de ligne de commande, une activation via l'UI, un fichier de configuration, ...)

Plusieurs logiciels avec plugins :

  • Pylint
  • Apps Django
  • IDE
  • Chats
  • ...

Presenter Notes

Pourquoi utiliser des plugins ?

Programmation par modules/paquets

  • Moins complexe
  • Plus facile à maintenir et à tester
  • Plus facile d'ajouter de nouvelles fonctionnalités
  • Moins de code spécifique

Les plugins

  • Possibilité de contrôler votre application via des configurations
  • Plus facile à adapter par vos utilisateurs
  • Plus facile de contribuer au code par un tiers

Presenter Notes

Exemple

Presenter Notes

Cœur de l'application

Une simple application de suivi des tâches :

  • Application en ligne de commande
  • Récupérer les informations stockées dans un fichier contenant les tâches effectuées dans la semaine
  • Les afficher de manière organisée

Presenter Notes

Cœur de l'application

.
├── LICENSE
├── README.rst
├── requirements.txt
├── setup.cfg
└── wts
    ├── __init__.py
    ├── app.py
    ├── readers
    │   └── __init__.py
    └── writers
        └── __init__.py
  • readers => Récupération depuis un fichier
  • writers => Gérer la sortie de l'application

Presenter Notes

Cœur de l'application

Gérer l'appel de nos modules depuis le cœur de l'application.

# app.py
import sys
import os

from wts.readers import read
from wts.writers import write

def main():
    """Read tasks from file and display it using writer"""
    # check args
    if len(sys.argv) != 2:
        sys.exit(f"Usage: {sys.argv[0]} data_file")

    filename = sys.argv[1]
    if not os.path.exists(filename):
        sys.exit(f"{filename} not found")

    data = read(filename)
    write(data)

if __name__ == "__main__":
    main()

Presenter Notes

Plugins par modules

Presenter Notes

Lecture JSON et écriture console

.
├── LICENSE
├── README.rst
├── requirements.txt
├── setup.cfg
└── wts
    ├── __init__.py
    ├── app.py
    ├── readers
    │   ├── __init__.py
    │   └── json_reader.py
    └── writers
        ├── __init__.py
        └── cli_writer.py
  • Deux nouveaux modules dans les dossiers readers et writers.
  • Modification des fichiers __init__ pour ne pas modifier app.py.

Presenter Notes

Deux nouveaux modules sont ajoutés respectivement dans les dossiers readers et writers : json_reader et cli_writer

Les fichiers __init__ des dossiers sont modifiés respectivement pour ne pas modifier le fichier app.py et respecter le dynamisme de l'application.

tag: jsoncli

Plugin de lecture JSON

# readers/__init__.py

from wts.readers.json_reader import read

def read(filename: str) -> dict:
    """Read the tasks file and parse it into
    a dict"""
    return read(filename)
# readers/json_reader.py

import json


def read(taskfile: str) -> dict:
    """Read the JSON tasks file and parse it
    into a dict"""
    with open(taskfile, 'r') as tasks:
        return json.load(tasks)

Presenter Notes

Plugin d'écriture console

# writers/__init__.py

from wts.writers.cli_writer import write

def write(data: dict):
    """Parse tasks dict into a readable
    output"""
    write(data)
# writers/cli_writer.py

from datetime import datetime

BOLD = '\033[1m'
CANCEL = '\033[0m'

def write(data: dict):
    """Parse tasks dict into a readable
    console output"""
    output = []
    fromiso = datetime.fromisoformat
    for task, timelist in data.items():
        output.append(
            f'{BOLD}{task}{CANCEL}'
        )
        hours = sum(
            (
                fromiso(time['end']) -
                fromiso(time['begin'])
            ).seconds // 3600
            for time in timelist
        )
        output.append(f'{hours} hours')
    print('\n'.join(output))

Presenter Notes

Résultats

{
    "Task 1": [{
        "begin": "2019-10-07T10:35:03.333597",
        "end": "2019-10-07T12:35:03.333597"
    }, {
        "begin": "2019-10-07T13:35:03.333597",
        "end": "2019-10-07T17:35:03.333597"
    }],
    "Task 2": [{
        "begin": "2019-10-08T09:35:03.333597",
        "end": "2019-10-08T17:35:03.333597"
    }],
    "Task 3": [{
        "begin": "2019-10-09T08:55:03.333597",
        "end": "2019-10-09T18:00:03.333597"
    }],
    "Task 4": [{
        "begin": "2019-10-09T18:55:03.333597",
        "end": "2019-10-09T23:00:03.333597"
    }, {
        "begin": "2019-10-10T08:55:03.333597",
        "end": "2019-10-09T17:00:03.333597"
    }, {
        "begin": "2019-11-09T07:55:03.333597",
        "end": "2019-10-09T23:00:03.333597"
    }]
}

Presenter Notes

Ajout de nouveaux plugins

.
├── LICENSE
├── README.rst
├── requirements.txt
├── setup.cfg
└── wts
    ├── __init__.py
    ├── app.py
    ├── readers
    │   ├── __init__.py
    │   ├── json_reader.py
    │   └── org_reader.py
    └── writers
        ├── __init__.py
        ├── cli_writer.py
        └── html_writer.py
  • Un en lecture => fichiers org
  • Un en écriture => HTML

Presenter Notes

Nous ajoutons la possibilité de récupérer en entrée des fichiers .org et d'obtenir un contenu html en sortie.

Lecture Org et écriture HTML

# readers/__init__.py
import os

from wts.readers.json_reader import (
    read as json_read
)
from wts.readers.org_reader import (
    read as org_read
)

def read(filename: str) -> dict:
    """Read the tasks file and parse it into
    a dict"""
    _, ext = os.path.splitext(filename)
    if ext == ".json":
        return json_read(filename)
    elif ext == ".org":
        return org_read(filename)
# writers/__init__.py

from wts.writers.cli_writer import (
    write as cli_write
)
from wts.writers.html_writer import (
    write as html_write
)

def write(data: dict, otype: str="cli"):
    """Parse tasks dict into a readable
    output"""
    if otype == "cli":
        cli_write(data)
    elif otype == "html":
        html_write(data)

Presenter Notes

Lecture Org et écriture HTML

Modification de app.py pour ajouter l'option de sortie

# app.py
import sys
import os
from argparse import ArgumentParser

from wts.readers import read
from wts.writers import write

def main():
    """Read tasks from file and display it using writer"""
    parser = ArgumentParser(description="My app")
    parser.add_argument("datafile")
    parser.add_argument("--output")

    args = parser.parse_args()

    filename = args.datafile
    output = args.output or "cli"
    if not os.path.exists(filename):
        sys.exit(f"{filename} not found")

    data = read(filename)
    write(data, output)

if __name__ == "__main__":
    main()

Presenter Notes

Plus de souplesse ?

Importlib à la rescousse

  • Ajout de plugins => Ajout de fichier source et modifications __init__
  • Chargement à la volée avec importlib
# writers/__init__.py

from importlib import import_module

def write(data: dict, otype: str="cli"):
    """Parse tasks dict into a readable
    output"""
    modname = f"wts.writers.{otype}_writer"
    module = import_module(modname)
    module.write(data)

Presenter Notes

À ce stade l'ajout de nouveaux modules demande d'ajouter le fichier source et de modifier le fichier __init__ correspondant pour qu'il soit pris en compte dans notre application.

Pour plus de souplesse nous allons utiliser importlib pour charger dynamiquement les modules afin que les utilisateurs n'aient pas à modifier la logique du projet.

Un petit mot sur les dépendances

Utilisation de options.extras_require de setuptools pour les dépendances optionnelles

[options.extras_require]
orgmode = orgparse
html = jinja2

Installation de dépendances de plugin

$ pip install -e .[orgmode]

Presenter Notes

Si vos plugins comportent des dépendances que vous ne souhaitez pas inclure dans le cœur de votre application, setuptools définit une clé nommée options.extras_require qui peut vous permettre de définir des dépendances optionnelles pour votre application.

Ainsi on pourra par exemple dans notre application définir un setup.cfg

Plugins par paquets

Presenter Notes

Plugins par paquets

État actuel

  • Import de façon dynamique depuis le nom du plugin
  • Nécessité des fichiers d'être dans le répertoire
  • Impossibilité d'importer une bibliothèque externe

État souhaité

  • Import dynamique depuis une bibliothèque externe
  • Possibilité de remplacer un plugin du core

Presenter Notes

Nous avons ajouté dans la dernière partie la possibilité à l'application de charger et d'importer de façon dynamique un plugin depuis son nom.

Il est encore nécessaire aux plugins de se trouver au sein des répertoires writers ou readers pour être chargés par l'application.

Nous pouvons étendre le fonctionnement par plugin en permettant à l'application de charger des paquets Python qui seront installés en parallèle de notre programme.

Découverte

Modifier le cœur de l'application pour qu'il soit en mesure de charger les plugins externes à l'appli.

Plusieurs choix :

  • Utiliser une convention de nommage pour les plugins (wts_)
  • Utiliser des sous-paquets qui détermineront où nos plugins seront placés dans l'arborescence
  • Utiliser les entry_points de nos plugins avec Setuptools pour ajouter de nouvelles fonctionnalités

Pypa

Presenter Notes

Lecteur yaml par entry_point

.
├── LICENSE
├── README.rst
├── requirements.txt
├── setup.cfg
└── wts_plugins_yaml
    ├── __init__.py
    └── app.py

Nouveau paquet wts_plugins_yaml

Presenter Notes

Lecteur yaml par entry_point

# app.py

import yaml

def read(taskfile: str) -> dict:
    """Read the YAML tasks file and parse it
    into a dict"""
    with open(taskfile, 'r') as tasks:
        return yaml.load(tasks)
# setup.cfg

[metadata]
name = wts_plugins_yaml
license = GPL3+
long_description = README.rst

[options.entry_points]
wts.plugins =
  yaml_reader = wts_plugins_yaml

Presenter Notes

Modification du cœur

# readers/__init__.py
import os
import importlib
from pkg_resources import iter_entry_points

def read(filename: str) -> dict:
    """Read the tasks file and parse it into a dict"""
    _, ext = os.path.splitext(filename)
    if ext.startswith("."):
        ext = ext.replace(".", "")

    # first check entry points to be able to
    # override core code
    plugins = {
        entry_point.name: entry_point.load()
        for entry_point
        in iter_entry_points('wts.plugins')
    }

    if plugins.get(ext):
        module = plugins[ext]
    else:
        modname = f"wts.readers.{ext}_reader"
        module = importlib.import_module(modname)


    return module.read(filename)

Presenter Notes

Merci de votre attention

TROUVERIE Joachim

Presenter Notes