Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

plugin: new interface #1479

Merged
merged 30 commits into from
May 2, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f6565b7
plugins: definition of AbstractPluginHandler
Exirel Feb 9, 2019
3352c4f
plugins: PyModulePlugin implementation
Exirel Feb 10, 2019
0533aeb
plugins: PyFilePlugin implementation
Exirel Feb 10, 2019
e280252
plugins: enumerate_plugins
Exirel Feb 10, 2019
860e315
plugins: use plugins in sopel.bot.Sopel.setup()
Exirel Feb 10, 2019
25d93e2
plugins: add AbstractPluginHandler.get_label
Exirel Feb 12, 2019
2c1b305
plugins: use plugins in the module wizard
Exirel Feb 12, 2019
3b8fa61
doc: sopel.plugins
Exirel Feb 12, 2019
8738ef0
plugins: add sopel.plugins to setup.py
Exirel Feb 13, 2019
ec02ba8
plugins: prevent circular import in sopel.config
Exirel Feb 13, 2019
37e4fe6
plugins: load plugin only once by name
Exirel Feb 15, 2019
6e1a493
plugins: add AbstractPluginHandler.reload
Exirel Feb 15, 2019
5e53ae1
loader: clean_callable is now idempotent
Exirel Feb 16, 2019
78c476c
plugins: bot's new interface
Exirel Feb 16, 2019
14e9957
reload: use new plugin interface
Exirel Feb 16, 2019
c407239
plugins: extract python loading from sopel.loader
Exirel Mar 25, 2019
5372869
plugins: use a function to get usable plugins
Exirel Mar 25, 2019
d93ec3f
config: remove circular import with sopel.plugins
Exirel Mar 25, 2019
65e8d97
fix: remove intervals from reloaded modules only
Exirel Mar 26, 2019
ad0dce2
bot: unregister plugin's URL callback with unregister_url_callback me…
Exirel Apr 12, 2019
0c2dad1
test: assert plugin can not reloaded when unknown
Exirel Apr 15, 2019
4b1afd0
bot: reuse has_plugin
dgw Apr 20, 2019
ff49792
fix: broken English by Exirel fixed by dgw
dgw Apr 20, 2019
3e8957b
reload: fix unrelated exceptions by bot.reply
Exirel Apr 20, 2019
95376af
plugins: improve docstrings thanks to dgw
Exirel Apr 20, 2019
14c112e
plugins: catch ImportError when looking for sopel_modules.*
Exirel Apr 20, 2019
7c2bb13
plugins: raise a PluginError when PyFilePlugin fails
Exirel Apr 20, 2019
456ff6a
run: prevent config file to be a directory
Exirel Apr 20, 2019
0952f44
cli: share wizard between run and config commands
Exirel Apr 29, 2019
b0e7bfa
doc: add missing docstrings
Exirel Apr 29, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions docs/source/plugin.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@ A Sopel plugin consists of a Python module containing one or more
``callable``\s. It may optionally also contain ``configure``, ``setup``, and
``shutdown`` hooks.

.. py:method:: callable(bot, trigger)
.. py:function:: callable(bot, trigger)

:param bot: the bot's instance
:type bot: :class:`sopel.bot.SopelWrapper`
:param trigger: the object that triggered the call
:type trigger: :class:`sopel.trigger.Trigger`

A callable is any function which takes as its arguments a
:class:`sopel.bot.Sopel` object and a :class:`sopel.trigger.Trigger`
:class:`sopel.bot.SopelWrapper` object and a :class:`sopel.trigger.Trigger`
object, and is wrapped with appropriate decorators from
:mod:`sopel.module`. The ``bot`` provides the ability to send messages to
the network and check the state of the bot. The ``trigger`` provides
Expand All @@ -21,7 +26,10 @@ A Sopel plugin consists of a Python module containing one or more
Note that the name can, and should, be anything - it doesn't need to be
called "callable".

.. py:method:: setup(bot)
.. py:function:: setup(bot)

:param bot: the bot's instance
:type bot: :class:`sopel.bot.Sopel`

This is an optional function of a plugin, which will be called while the
module is being loaded. The purpose of this function is to perform whatever
Expand All @@ -41,7 +49,10 @@ A Sopel plugin consists of a Python module containing one or more
execution of this function. As such, an infinite loop (such as an
unthreaded polling loop) will cause the bot to hang.

.. py:method:: shutdown(bot)
.. py:function:: shutdown(bot)

:param bot: the bot's instance
:type bot: :class:`sopel.bot.Sopel`

This is an optional function of a module, which will be called while the
bot is quitting. Note that this normally occurs after closing connection
Expand All @@ -57,7 +68,10 @@ A Sopel plugin consists of a Python module containing one or more

.. versionadded:: 4.1

.. py:method:: configure(config)
.. py:function:: configure(config)

:param bot: the bot's configuration object
:type bot: :class:`sopel.config.Config`

This is an optional function of a module, which will be called during the
user's setup of the bot. It's intended purpose is to use the methods of the
Expand All @@ -71,3 +85,12 @@ sopel.module
.. automodule:: sopel.module
:members:

sopel.plugins
-------------
.. automodule:: sopel.plugins
:members:

sopel.plugins.handlers
----------------------
.. automodule:: sopel.plugins.handlers
:members:
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def read_reqs(path):
),
# Distutils is shit, and doesn't check if it's a list of basestring
# but instead requires str.
packages=[str('sopel'), str('sopel.modules'),
packages=[str('sopel'), str('sopel.modules'), str('sopel.plugins'),
str('sopel.cli'), str('sopel.config'), str('sopel.tools')],
classifiers=classifiers,
license='Eiffel Forum License, version 2',
Expand Down
132 changes: 106 additions & 26 deletions sopel/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@

from ast import literal_eval
import collections
import itertools
import os
import re
import sys
import threading
import time

from sopel import tools
from sopel import irc
from sopel import irc, plugins, tools
from sopel.db import SopelDB
from sopel.tools import stderr, Identifier
import sopel.tools.jobs
Expand Down Expand Up @@ -60,6 +60,7 @@ def __init__(self, config, daemon=False):
'medium': collections.defaultdict(list),
'low': collections.defaultdict(list)
}
self._plugins = {}
self.config = config
"""The :class:`sopel.config.Config` for the current Sopel instance."""
self.doc = {}
Expand Down Expand Up @@ -178,31 +179,33 @@ def write(self, args, text=None): # Shim this in here for autodocs
irc.Bot.write(self, args, text=text)

def setup(self):
stderr("\nWelcome to Sopel. Loading modules...\n\n")

modules = sopel.loader.enumerate_modules(self.config)

error_count = 0
success_count = 0
for name in modules:
path, type_ = modules[name]
load_success = 0
load_error = 0
load_disabled = 0

stderr("Welcome to Sopel. Loading modules...")
usable_plugins = plugins.get_usable_plugins(self.config)
for name, info in usable_plugins.items():
plugin, is_enabled = info
if not is_enabled:
load_disabled = load_disabled + 1
continue

try:
module, _ = sopel.loader.load_module(name, path, type_)
plugin.load()
except Exception as e:
error_count = error_count + 1
load_error = load_error + 1
filename, lineno = tools.get_raising_file_and_line()
rel_path = os.path.relpath(filename, os.path.dirname(__file__))
raising_stmt = "%s:%d" % (rel_path, lineno)
stderr("Error loading %s: %s (%s)" % (name, e, raising_stmt))
else:
try:
if hasattr(module, 'setup'):
module.setup(self)
relevant_parts = sopel.loader.clean_module(
module, self.config)
if plugin.has_setup():
plugin.setup(self)
plugin.register(self)
except Exception as e:
error_count = error_count + 1
load_error = load_error + 1
filename, lineno = tools.get_raising_file_and_line()
rel_path = os.path.relpath(
filename, os.path.dirname(__file__)
Expand All @@ -211,15 +214,94 @@ def setup(self):
stderr("Error in %s setup procedure: %s (%s)"
% (name, e, raising_stmt))
else:
self.register(*relevant_parts)
success_count += 1

if len(modules) > 1: # coretasks is counted
stderr('\n\nRegistered %d modules,' % (success_count - 1))
stderr('%d modules failed to load\n\n' % error_count)
load_success = load_success + 1
print('Loaded: %s' % name)

total = sum([load_success, load_error, load_disabled])
if total and load_success:
stderr('Registered %d modules' % (load_success - 1))
stderr('%d modules failed to load' % load_error)
stderr('%d modules disabled' % load_disabled)
else:
stderr("Warning: Couldn't load any modules")

def reload_plugin(self, name):
"""Reload a plugin

:param str name: name of the plugin to reload
:raise PluginNotRegistered: when there is no ``name`` plugin registered

It runs the plugin's shutdown routine and unregisters it. Then it
reloads it, runs its setup routines, and registers it again.
"""
if not self.has_plugin(name):
raise plugins.exceptions.PluginNotRegistered(name)

plugin = self._plugins[name]
# tear down
plugin.shutdown(self)
plugin.unregister(self)
print('Unloaded: %s' % name)
# reload & setup
plugin.reload()
plugin.setup(self)
plugin.register(self)
print('Reloaded: %s' % name)

def reload_plugins(self):
"""Reload all plugins

First, run all plugin shutdown routines and unregister all plugins.
Then reload all plugins, run their setup routines, and register them
again.
"""
Exirel marked this conversation as resolved.
Show resolved Hide resolved
registered = list(self._plugins.items())
# tear down all plugins
for name, plugin in registered:
plugin.shutdown(self)
plugin.unregister(self)
print('Unloaded: %s' % name)

# reload & setup all plugins
for name, plugin in registered:
plugin.reload()
plugin.setup(self)
plugin.register(self)
print('Reloaded: %s' % name)

def add_plugin(self, plugin, callables, jobs, shutdowns, urls):
"""Add a loaded plugin to the bot's registry"""
self._plugins[plugin.name] = plugin
self.register(callables, jobs, shutdowns, urls)

def remove_plugin(self, plugin, callables, jobs, shutdowns, urls):
"""Remove a loaded plugin from the bot's registry"""
name = plugin.name
if not self.has_plugin(name):
raise plugins.exceptions.PluginNotRegistered(name)

try:
# remove commands, jobs, and shutdown functions
for func in itertools.chain(callables, jobs, shutdowns):
self.unregister(func)

# remove URL callback handlers
if self.memory.contains('url_callbacks'):
for func in urls:
regex = func.url_regex
if func == self.memory['url_callbacks'].get(regex):
self.unregister_url_callback(regex)
except: # noqa
# TODO: consider logging?
Exirel marked this conversation as resolved.
Show resolved Hide resolved
raise # re-raised
else:
# remove plugin from registry
del self._plugins[name]

def has_plugin(self, name):
"""Tell if the bot has registered this plugin by its name"""
return name in self._plugins

def unregister(self, obj):
if not callable(obj):
return
Expand All @@ -229,9 +311,7 @@ def unregister(self, obj):
if obj in callb_list:
callb_list.remove(obj)
if hasattr(obj, 'interval'):
# TODO this should somehow find the right job to remove, rather than
# clearing the entire queue. Issue #831
self.scheduler.clear_jobs()
self.scheduler.remove_callable_job(obj)
if (getattr(obj, '__name__', None) == 'shutdown' and
obj in self.shutdown_methods):
self.shutdown_methods.remove(obj)
Expand Down
2 changes: 1 addition & 1 deletion sopel/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ def handle_init(options):
return 1

print('Starting Sopel config wizard for: %s' % config_filename)
config._wizard('all', config_name)
utils.wizard(config_name)


def handle_get(options):
Expand Down
Loading