Chapter 12: Composants et plugins

Composants et plugins

component
plugin

Les composants et plugins sont des fonctionnalités relativement nouvelles de web2py, et il y a quelques désaccords entre les dévelopeurs au sujet de ce qu'ils sont et ce qu'ils devraient être. La plupart des confusions proviennent des usages différents de ces termes dans d'autres projets logiciels et du fait que les développeurs sont toujours en train de travailler pour finaliser les spécifications.

Cependant, le support de plugin est une fonctionnalité importante et nous avons besoin de fournir quelques définitions. Ces définitions ne sont pas destinées à être finalies, juste consistentes avec les patterns de programmation que nous souhaitons présenter dans ce chapitre.

Nous essaierons d'adresser deux problèmes ici :

  • Comment pouvons-nous construire des applications modulaires qui minimisent la charge serveur et maximiser la réutilisation de code ?
  • Comment pouvons-nous distribuer des morceaux de code de façon plus ou moins plug-and-play ?

Les Components répondent au premier problème ; les plugins au deuxième.

Components, LOAD et Ajax

load
LOAD
Ajax
Un component est une fonctionnalité autonome d'une page web.

Un composant peut être composé de modules, de contrôleurs et de vues, mais il n'y a pas de besoin strict autrement que, lorsqu'embarqué dans une page, il doit être localisé dans un tag html (par exemple un DIV, un SPAN ou un IFRAME) et doit exécuter ses tâches indépendamment du reste de la page. Nous sommes spécifiquemenet intéressés dans les composants qui sont chargés dans la page et qui communiquent avec la fonction contrôleur du composant via Ajax.

Un exemple d'un composant est un "comments component" qui est contenu dans un DIV et montre les commentaires des utilisateurs et un formulaire pour poster un nouveau commentaire. Lorsque le formulaire est soumis, il est envoyé au serveur via Ajax, la liste est mise à jour, et le commentaire est stocké côté serveur dans la base de données. Le contenu du DIV est rafraichi sans recharger le reste de la page.

LOAD

La fonction web2py LOAD rend cela plus facile à faire sans connaissance explicite JavaScript/Ajax ou en programmation.

Notre but est d'être capable de développer des applications web en assemblant les composants dans des layouts de page.

Considérons une simple application web2py "test" qui étend l'application de base par défaut avec un modèle personnalisé dans le fichier "models/db_comments.py" :

db.define_table('comment_post',
   Field('body','text',label='Your comment'),
   auth.signature)

une action dans "controllers/comments.py"

@auth.requires_login()
def post():
    return dict(form=SQLFORM(db.comment_post).process(),
                comments=db(db.comment_post).select())

et le "views/comments/post.html" correspondant :

{{extend 'layout.html'}}
{{for post in comments:}}
<div class="post">
  On {{=post.created_on}} {{=post.created_by.first_name}}
  says <span class="post_body">{{=post.body}}</span>
</div>
{{pass}}
{{=form}}

Vous pouvez y accéder comme d'habitude à :

http://127.0.0.1:8000/test/comments/post

Tant qu'il n'y a rien de spécial dans cette action, mais que nous pouvons le mettre dans un composant en définissant une nouvelle vue avec l'extension ".load" qui n'étend pas le layout.

D'où nous créons un "views/comments/post.load" :

{{for post in comments:}}
<div class="post">
  On {{=post.created_on}} {{=post.created_by.first_name}}
  says <blockquote class="post_body">{{=post.body}}</blockquote>
</div>
{{pass}}
{{=form}}

Nous pouvons y accéder à l'URL

http://127.0.0.1:8000/test/comments/post.load

C'est un composant que nous pouvons embarquer dans n'importe quelle autre page en faisant simplement

{{=LOAD('comments','post.load',ajax=True)}}

Par exemple dans "controllers/default.py", nous pouvons éditer

def index():
    return dict()

et dans la vue correspondante ajouter le composant :

{{extend 'layout.html'}}
{{=LOAD('comments','post.load',ajax=True)}}

Visiter la page

http://127.0.0.1:8000/test/default/index

va montrer le contenu normal et le composant de commentaires :

image

Le composant {{=LOAD(...)}} est rendu comme :

<script type="text/javascript"><!--
web2py_component("/test/comment/post.load","c282718984176")
//--></script><div id="c282718984176">loading...</div>

(le code actuel généré dépend des options passées à la fonction LOAD).

La fonction web2py_component(url,id) est définie dans "web2py_ajax.html" et effectue toute la magie : elle appelle url via Ajax et embarque la réponse dans la DIV avec l'id correspondant ; elle capture toutes les soumissions de formulaire dans le DIV et envoie ces formulaires via Ajax. La cible Ajax est toujours la DIV elle-même.

Signature LOAD

La signature complète de l'helper LOAD est la suivante :

LOAD(c=None, f='index', args=[], vars={},
     extension=None, target=None,
     ajax=False, ajax_trap=False,
     url=None,user_signature=False,
     timeout=None, times=1,
     content='loading...',**attr):

Ici :

  • Les deux premiers arguments c et f sont le contrôleur et la fonction que nous voulons appeler respectivement.
  • args et vars sont les arguments et les variables que nous voulons passer à la fonction. Le premier est une liste, le dernier un dictionnaire.
  • extension est une extension optionnelle. Notez que l'extension peut aussi être passée comme partie de la fonction comme dans f='index.load'.
  • target est l'id de la cible DIC. Si ce n'est pas spécifié, une cible aléatoire id est générée.
  • ajax devrait être défini à True si la DIC doit être remplie via Ajax et à False si la DIV doit être remplie avant que la page courante soit retournée (évitant ainsi l'appel Ajax).
  • ajax_trap=True signifie que toute soumission de formulaire dans la DIC doit être capturée et envoyée via Ajax, et la réponse doit être renvoyée dans la DIV. ajax_trap=False indique que les formulaire doivent être soumis normalement, rechargeant ainsi toute la page. ajax_trap est ignoré et supposé à True si ajax=True.
  • url, si spécifié, écrase les valeurs de c, f, args, vars, et extension et charge le composant à l'url. C'est utilisé pour charger comme composants des pages servies par d'autres applications (qui peuvent ou non être créées avec web2py).
  • user_signature est par défaut à False, mais si vous êtes connecté, devrait être à True. Ceci permettra de s'assurer que la callback ajax est signée numériquement. Ceci est documenté dans le chapitre 4.
  • times spécifie le nombre de fois où le composant est requêté. Utilisez "infinity" pour conserver le chargement du composant continuellement. Cette option est utile pour déclencher des routines régulières pour une requête donnée du document.
  • timeout définit le temps à attendre en millisecondes avant de démarrer la requête ou la fréquence si times est plus grand que 1.
  • content est le contenu à afficher lorsque l'appel ajax est en cours. Ce peut être un helper comme content=IMG(..).
  • **attr optionnel (attributs) peut être passé au DIV contenu.

Si aucune vue .load n'est spécifiée, il y a un generic.load qui rend le dictionnaire retourné par l'action sans layout. Cela marche mieux si le dictionnaire contient un seul objet.

Si vous chargez (LOAD) un composant ayant l'extension .load et la fonction contrôleur correspondant redirige vers une autre action (par exemple une formulaire de connexion), l'extension .load se propage et la nouvelle url (celle redirigée) est aussi chargée avec une extension .load.

Rediriger depuis un composant

Pour rediriger depuis un composant, utilisez ceci :

redirect(URL(...),client_side=True)

mais notez que l'URL redirigée sera par défaut avec l'extension du composant. Voir les notes à propos de l'argument extension dans la fonction URL dans le Chapitre 4

Recharger la page via une redirection après la soumission d'un composant

Si vous appelez une action via Ajax et que vous voulez que l'action force une redirection de la page parent vous pouvez le faire avec un redirect depuis la fonction chargée du contrôleur (LOAD). Si vous voulez recharger la page parent, vous pouvez faire une redirection vers elle. L'URL parent est connue (voir Composant de communications Client-Serveur )

donc après avoir procédé à la soumission du formulaire, la fonction contrôleur recharge la page parent via une redirection :

if form.process().accepted: 
    ...
    redirect( request.env.http_web2py_component_location,client_side=True)

Notez que la section ci-dessous, Composant de communications Client-Serveur, décrit comment le composant peut retourner le javascript, qui pourrait être utilisé pour des actions plus sophistiquées lorsque le composant est soumis. Le cas spécifique de rechargement d'un autre composant est décrit après.

Recharger un autre composant

Si vous utilisez de multiples composants sur une page, vous pouvez vouloir que la soumission d'un composant recharge un autre composant. Vous faites cela en ayant le composant soumis qui retourne du javascript.

C'est possible de coder en dur la cible DIV, mais dans ce moyen nous utilisons une variable de requête pour informer le contrôleur soumis de quel composant on souhaite recharger. C'est identifié par l'id du DIV contenant le composant cible. Dans ce cas, le DIV a l'id 'map'. Notez qu'il est nécessaire d'utiliser target='map' dans le LOAD de la cible ; sans cela, la cible id est aléatoire et reload() ne fonctionnera pas. Voir la signature LOAD ci-dessus.

Dans la vue, faites ceci :

{{=LOAD('default','submitting_component.load',ajax=True,vars={'reload_div':'map'})}}

Le contrôleur appartenant au composant soumis a besoin de renvoyer du javascript, donc ajoutez juste ceci au code contrôleur existant lorsque vous procédez à l'envoi :

if form.process().accepted:
...
    if request.vars.reload_div:
        response.js =  "jQuery('#%s').get(0).reload()" % request.vars.reload_div

(Bien sûr, supprimez la redirection si vous utilisiez l'approche de la section précédente.)

C'est tout. Les librairies javascript web2py s'occupent du reload. Ceci pourrait être généralisé pour gérer de multiples composants avec javascript ressemblant à :

jQuery('#div1,#div2,#div3').get(0).reload()

Pour plus d'information à propos de response.js, voir Composant de communications Client-Serveur ci-après.

Ajax post ne supporte pas les formulaires multipart

Puisque Ajax post ne supporte pas les formulaires multipart, i.e. les uploads de fichiers, les champs upload ne fonctionneront pas avec le composant LOAD. Vous pourriez être surpris en pensant que cela fonctionne car les champs upload fonctionneront normalement si le POST est fait depuis la vue du composant individuel. Au lieu de cela, les uploads sont fait avec des widgets tiers compatibles ajax et les commandes web2py d'upload et de stockage.

Composants de communication LOAD et Client-Serveur

Lorsque l'action d'un composant est appelée via Ajax, web2py passe les en-têtes HTTP avec la requête :

web2py-component-location
web2py-component-element

qui peut être accédée par l'action via les variables :

request.env.http_web2py_component_location
request.env.http_web2py_component_element

Le dernier est aussi accessible via :

request.cid

request.cid

Le premier contient l'URL de la page qui a appelé l'action du composant. Le dernier contient l'id du DIV qui contiendra la réponse.

L'action du composant peut aussi stocker les données dans deux en-têtes de réponse HTTP qui seront interprétées par la page complète par la réponse. Ce sont :

web2py-component-flash
web2py-component-command

et ils peuvent être définis via :

response.headers['web2py-component-flash']='....'
response.headers['web2py-component-command']='...'

ou (si l'action est appelée par un composant) automatiquement via :

response.flash='...'
response.js='...'

Le premier contient le texte que vous voulez flasher pour la réponse. Le dernier contient le code Javascript que vous voulez exécuté par la réponse. Il ne peut pas contenir de retour à la ligne.

Comme exemple, définissez un composant du formulaire de contact dans le "controllers/contact/ask.py" qui autorise l'utilisateur à poser une question. Le composant enverra la question par email à l'administrateur système, flashant un message "Merci", et en supprimant le composant de la page :

def ask():
    form=SQLFORM.factory(
        Field('your_email',requires=IS_EMAIL()),
        Field('question',requires=IS_NOT_EMPTY()))
    if form.process().accepted:
        if mail.send(to='admin@example.com',
                  subject='from %s' % form.vars.your_email,
                  message = form.vars.question):
            response.flash = 'Thank you'
            response.js = "jQuery('#%s').hide()" % request.cid
        else:
            form.errors.your_email = "Unable to send the email"
    return dict(form=form)

Les quatre premières lignes définissent le formulaire et l'accepte. L'objet mail utilisé pour envoyer est défini dans l'application de base par défaut. Les quatre dernières lignes implémentent toute la logique spécifique aux composants en obtenant les données depuis les en-têtes HTTP de la requête et définissant les en-têtes de la réponse HTTP.

Vous pouvez maintenant embarquer ce formulaire contact dans n'importe quelle page via

{{=LOAD('contact','ask.load',ajax=True)}}

Notez que nous ne définissons pas une vue .load pour notre composant ask. Nous n'avons pas à le faire puisqu'il retourne un objet simple (form) et ensuite le "generic.load" le fera très bien. Souvenez-vous que les vues génériques sont un outil de développement. En production, vous devriez copier "views/generic.load" dans "views/contact/ask.load".

user_signature
requires_signature
Nous pouvons bloquer l'accès à une fonction appelée via Ajax en signant numériquement l'URL en utilisant l'argument user_signature :

{{=LOAD('contact','ask.load',ajax=True,user_signature=True)}}

qui ajoute une signature numérique à l'URL. La signature numérique doit ensuite être validée en utilisant un décorateur dans la fonction callback :

@auth.requires_signature()
def ask(): ...

Liens ajax capturés et le helper A

A
Ajax links

Normalement un lien n'est pas capturé, et en cliquant sur un lien dans un composant, la page liée entière est chargée. Parfois vous voulez que la pagée liée soit chargée dans le composant. Ceci peut être fait en utilisant le helper A :

{{=A('linked page',_href='http://example.com',cid=request.cid)}}

Si cid est spécifié, la page liée est chargée via Ajax. Le cid est l'id de l'élément html où placer le contenu de la page chargée. Dans ce cas, nous le définisson à request.cid, i.e., l'id du composant qui génère le lien. La page liée peut être et habituellement est une URL interne générée en utilisant URL helper .

Plugins

Un plugin est n'importe quel sous-ensemble des fichiers d'une application.

et nous voulons vraiment dire any :

  • Un plugin n'est pas un module, ce n'est pas un modèle, ce n'est pas un contrôleur, ce n'est pas une vue, bien qu'il puisse contenir des modules, modèles, des contrôleurs et/ou des vues.
  • Un plugin n'a pas besoin d'être fonctionnellement autonome et peut dépendre d'autres plugins ou de code spécifique à l'utilisateur.
  • Un plugin n'est pas un système de plugins et n'a donc aucun concept d'enregistrement ou d'isolation, même si nous donnerons les règles pour essayer de réussir à faire quelque isolation.
  • Nous parlons d'un plugin pour votre application, pas d'un plugin pour web2py.

Alors pourquoi est-ce appelé un plugin ? Car cela fournit un mécanisme pour packager un sous-ensemble d'une application et le dépackager dans une autre application (i.e. plug-in). Sous cette définition, n'importe quel fichier dans votre application peut être traité comme un plugin.

Lorsque l'application est distribué, ses plugins sont packagés et distribués avec.

En pratique, l'admin fournit une interface pour packager et dépackager les plugins séparément de votre application. Les fichiers et dossiers de votre application qui ont des noms avec le préfixe plugin_name peuvent être packagés ensemble dans un fichier appelé :

web2py.plugin.name.w2p

et distribués ensemble.

image

Les fichiers qui composent un plugin ne sont pas traités par web2py différemment que les autres fichiers sauf que admin comprend depuis leurs noms qu'ils sont amenés à être distribués ensemble, et les affiche dans une page séparée :

image

Maintenant dans les faits, par la définition ci-dessus, ces plugins sont plus généraux que ceux reconnus comme tels par admin.

En pratique, nous serons juste confrontés à deux types de plugins :

  • Component Plugins. Ce sont des plugins qui contiennent les composants comme défini dans la section précédente. Un plugin de composant peut contenir un ou plusieurs composants. Nous pouvons penser par exemple à un plugin_comments qui contient le composant comments proposé au-dessus. Un autre exemple pourrait être plugin_tagging qui contient un composant tagging et un composant tag-cloud qui partage quelques tables de base de données également définies par le plugin.
  • Layout Plugins. Ce sont les plugins qui contiennent une vue layout et les fichiers statiques requis par un tel layout. Lorsque le plugin est appliqué il donne un nouveau look and feel à l'application.

Par les définitions ci-dessus, les composants créés dans la section précédente, par exemple "controllers/contact.py" sont déjà des plugins. Nous pouvons les déplacer d'une application à une autre et utiliser les composants qu'ils définissent. Maintenant ils ne sont pas reconnus tels quels par admin car il n'y a rien qui les définit comme plugins. Donc il y a deux problèmes que l'on a besoin de résoudre :

  • Nommer les fichiers plugin en utilisant une convention, afin que admin puisse les reconnaitre comme appartenant au même plugin.
  • Si le plugin a des fichiers modèles, établir une convention afin que les objets qu'il définit ne polluent pas l'espace de nom et ne rentrent pas en conflit avec chaque autre.

Assumons maintenant qu'un plugin est appelé name. Voici les règles qui devraient être suivies :

Règle 1 : Les modèles de plugin et contrôleurs devraient être appelés, respectivement

  • models/plugin_name.py
  • controllers/plugin_name.py

et les fichiers de vues de plugin, modules, static, et private devraient être dans des dossiers appelés respectivement :

  • views/plugin_name/
  • modules/plugin_name/
  • static/plugin_name/
  • private/plugin_name/

Règle 2 : Les modèles de plugin peuvent simplement définir les objets avec les noms qui démarrent avec

  • plugin_name
  • PluginName
  • _

Règle 3 : Les modèles de plugin peuvent simplement définir les variables de session avec les noms qui démarrent avec

  • session.plugin_name
  • session.PluginName

Règle 4 : Les plugins devraient inclure la licence et la documentation. Ils devraient être placés dans :

  • static/plugin_name/license.html
  • static/plugin_name/about.html

Règle 5 : Le plugin peut simplement se baser sur l'existence d'objets globaux définis dans le "db.py" de référence, i.e.

  • une connexion à la base de données appelée db
  • une instance Auth appelée auth
  • une instance Crud appelée crud
  • une instance Service appelée service

Quelques plugins peuvent être plus sophistiqués et avoir un paramètre de configuration dans le cas où plus d'une instance de db existe.

Règle 6 : Si un plugin a besoin de paramètres de configuration, ils devraient être définis via un PluginManager comme décrit ci-après.

PluginManager

En suivant les règles ci-dessus nous pouvons nous assurer que :

  • admin reconnait tous les fichiers et dossiers plugin_name comme partie d'une simple entité.
  • Les plugins n'interfèrent pas avec chaque autre.

Les règles ci-dessus ne résolvent pas le problème des versions de plugin et les dépendances. C'est en dehors du scope.

Plugins de composant

component plugin

Les plugins de composant sont des plugins qui définissent les composants. Les composants accèdent habituellement à la base de données et la définissent avec leurs propres modèles.

Ici nous transformons notre composant précédent comments en comments_plugin en utilisant le même code que nous avions écrit précédemment, mais en suivant toutes les règles précédentes.

D'abord, nous créons un modèle appelé "models/plugin_comments.py" :

db.define_table('plugin_comments_comment',
   Field('body','text', label='Your comment'),
   auth.signature)

def plugin_comments():
    return LOAD('plugin_comments','post',ajax=True)

(notez que les deux dernières lignes définissent une fonction qui simplifiera l'intégration du plugin)

Ensuite, nous définissons un "controllers/plugin_comments.py"

def post():
    if not auth.user:
        return A('login to comment',_href=URL('default','user/login'))
    comment = db.plugin_comments_comment
    return dict(form=SQLFORM(comment).process(),
                comments=db(comment).select())

Troisièmement, nous créons une vue appelée "views/plugin_comments/post.load" :

{{for comment in comments:}}
<div class="comment">
  on {{=comment.created_on}} {{=comment.created_by.first_name}}
  says <span class="comment_body">{{=comment.body}}</span>
</div>
{{pass}}
{{=form}}

Maintenant nous pouvons utiliser admin pour packager le plugin pour la distribution. Admin va enregistrer ce plugin comme :

web2py.plugin.comments.w2p

Nous pouvons utiliser ce plugin dans n'importe quelle vue en installant simplement le plugin via la page edit dans admin et en ajoutant cela à nos propres vues

{{=plugin_comments()}}

Bien entendu nous pouvons rendre le plugin plus sophistiqué en ayant des composants qui prennent des paramètres et des options de configuration. Plus les composants sont complexes, plus il devient difficile d'éviter les conflits de noms. Le Plugin Manager décrit ci-après est destiné à éviter ce problème.

Plugin manager

Le PluginManager est une classe définie dans gluon.tools. Avant que l'on explique comment cela fonctionne à l'intérieur, nous allons expliquer comment l'utiliser.

Nous considérons ici le plugin_comments précédent et nous le rendons meilleur. Nous voulons être capable de personnaliser :

db.plugin_comments_comment.body.label

sans avoir à éditer le code du plugin directement.

Voici comment on peut faire cela :

D'abord, ré-écrivez le plugin "models/plugin_comments.py" de cette manière :

def _():
    from gluon.tools import PluginManager
    plugins = PluginManager('comments', body_label='Your comment')

    db.define_table('plugin_comments_comment',
        Field('body','text',label=plugins.comments.body_label),
        auth.signature)
    return lambda: LOAD('plugin_comments','post.load',ajax=True)
plugin_comments = _()

Notez comment tout le code, sauf la définition de table, est encapsulé dans une simple fonction appelée _ afin qu'il ne pollue pas l'espace de nom global. Notez également comment la fonction créé une instance d'un PluginManager.

Maintenant dans tout autre modèle dans votre application, par exemple dans "models/db.py", vous pouvez configurer ce plugin comme suit :

from gluon.tools import PluginManager
plugins = PluginManager()
plugins.comments.body_label = T('Post a comment')
L'objet plugins est déjà instancié dans l'application de base par défaut dans "models/db.py"

L'objet PluginManager est un objet Storage singleton niveau thread d'objets Storage. Cela signifie que vous pouvez l'instancier autant de fois que vous le voulez dans la même application mais (qu'ils aient le même nom ou pas) ils agissent comme si c'était une simple instance de PluginManager.

En particulier, chaque fichier de plugin peut faire son propre objet PluginManager et s'enregistrer lui-même et ses paramètres par défaut avec :

plugins = PluginManager('name', param1='value', param2='value')

Vous pouvez surcharger ces paramètres n'importe où (par exemple dans "models/db.py") avec le code :

plugins = PluginManager()
plugins.name.param1 = 'other value'

Vous pouvez configurer de multiples plugins dans un même endroit :

plugins = PluginManager()
plugins.name.param1 = '...'
plugins.name.param2 = '...'
plugins.name1.param3 = '...'
plugins.name2.param4 = '...'
plugins.name3.param5 = '...'
Lorsque le plugin est défini, le PluginManager doit prendre des arguments : le nom du plugin et les arguments nommés optionnels qui sont les paramètres par défaut. Cependant, lorsque les plugins sont configurés, le constructeur PluginManager ne doit prendre aucun argument. La configuration doit précéder la définition du plugin (i.e. il doit être dans une fichier modèle qui vient avant en ordre alphabétique).

Layout de plugins

layout plugin

Les layouts de plugins sont plus simples que les plugins de composant puisqu'ils ne contiennent habituellement pas de code, mais seulement des vues et des fichiers statiques. Une fois encore, vous devriez suivre les bonnes pratiques :

Premièrement, créez un dossier appelé "static/plugin_layout_name/" (où le nom est celui du layout) et placez tous vos fichiers statiques ici.

Ensuite, créez un fichier layout appelé "views/plugin_layout_name/layout.html" qui contient votre layout et lie les images, CSS et fichiers JavaScript dans "static/plugin_layout_name/"

Troisièmement, modifiez les "views/layout.html" afin qu'il lise simplement :

{{extend 'plugin_layout_name/layout.html'}}
{{include}}

L'avantage de ce design est que les utilisateurs de ce plugin peuvent installer de multiples layouts et choisir lequel ils souhaitent appliquer en éditant simplement "views/layout.html". De plus, "views/layout.html" ne sera pas packagé par admin avec le plugin, donc il n'y a pas de risque que le plugin surcharge le code utilisateur dans le layout précédemment installé.

Dépôts de plugin, installation de plugin via admin

Alors qu'il n'y a pas de simple depôt des plugins web2py, vous pouvez trouver beaucoup d'entre eux à l'une des URLs suivantes :

http://web2pyslices.com (c'est le dépôt principal et il est intégré à l'application admin de web2py pour des installations en un clic)

http://web2py.com/plugins
http://web2py.com/layouts

Les versions récentes de l'admin web2py autorisent la récupération automatique et l'installation de plugins depuis web2pyslices. Pour ajouter un plugin à une application, éditez le via l'application admin, et choisissez Download Plugins, couramment en bas de l'écran.

Pour publier vos propres plugins, créez un compte sur web2pyslices.

Voici une capture d'écran montrant quelques uns des plugins auto-installables :

image

 top