Refactor plugin internals

This is for supporting json-based plugins. The great thing about this
change is that it allows us to easily print plugin version numbers in
`plugins list`.
This commit is contained in:
Régis Behmo 2020-01-14 13:32:45 +01:00
parent c1e47cec82
commit 159e24a95d
7 changed files with 190 additions and 95 deletions

View File

@ -2,6 +2,10 @@
Note: Breaking changes between versions are indicated by "💥".
## Unreleased
- 💥[Improvement] The output of `plugins list` now includes plugin version numbers.
## 3.10.1 (2020-01-13)
- [Improvement] Explicitely point to docker.io images, when necessary, for [podman](https://podman.io/) compatibility.

View File

@ -87,7 +87,9 @@ ci-bundle: bundle ## Create bundle and run basic tests
./dist/tutor config printroot
yes "" | ./dist/tutor config save --interactive
./dist/tutor config save
./dist/tutor plugins list
./dist/tutor plugins enable discovery ecommerce figures lts minio notes xqueue
./dist/tutor plugins list
./dist/tutor lts --help
./releases/github-release: ## Download github-release binary

View File

@ -1,16 +1,20 @@
#!/usr/bin/env python3
import importlib
from tutor.plugins import Plugins
from tutor.plugins import OfficialPlugin
# Manually install plugins (this is for creating the bundle)
for plugin in ["discovery", "ecommerce", "figures", "lts", "minio", "notes", "xqueue"]:
for plugin_name in [
"discovery",
"ecommerce",
"figures",
"lts",
"minio",
"notes",
"xqueue",
]:
try:
module = importlib.import_module("tutor{}.plugin".format(plugin))
OfficialPlugin.INSTALLED.append(OfficialPlugin(plugin_name))
except ImportError:
pass
else:
Plugins.EXTRA_INSTALLED[plugin] = module
from tutor.commands.cli import main

View File

@ -20,22 +20,16 @@ class PluginsTests(unittest.TestCase):
self.assertFalse(plugins.is_installed("dummy"))
def test_extra_installed(self):
class plugin1:
pass
plugin1 = plugins.BasePlugin("plugin1", None)
plugin2 = plugins.BasePlugin("plugin2", None)
class plugin2:
pass
plugins.Plugins.EXTRA_INSTALLED["plugin1"] = plugin1
plugins.Plugins.EXTRA_INSTALLED["plugin2"] = plugin2
plugins.OfficialPlugin.INSTALLED.append(plugin1)
plugins.OfficialPlugin.INSTALLED.append(plugin2)
with unittest.mock.patch.object(
plugins.Plugins,
"iter_installed_entrypoints",
return_value=[("plugin1", plugin1)],
plugins.EntrypointPlugin, "iter_installed", return_value=[plugin1],
):
self.assertEqual(
[("plugin1", plugin1), ("plugin2", plugin2)],
list(plugins.iter_installed()),
[plugin1, plugin2], list(plugins.iter_installed()),
)
def test_enable(self):
@ -67,17 +61,18 @@ class PluginsTests(unittest.TestCase):
patches = {"patch1": "Hello {{ ID }}"}
with unittest.mock.patch.object(
plugins.Plugins, "iter_enabled", return_value=[("plugin1", plugin1)]
plugins.Plugins,
"iter_enabled",
return_value=[plugins.BasePlugin("plugin1", plugin1)],
):
patches = list(plugins.iter_patches({}, "patch1"))
self.assertEqual([("plugin1", "Hello {{ ID }}")], patches)
def test_plugin_without_patches(self):
class plugin1:
pass
with unittest.mock.patch.object(
plugins.Plugins, "iter_enabled", return_value=[("plugin1", plugin1)]
plugins.Plugins,
"iter_enabled",
return_value=[plugins.BasePlugin("plugin1", None)],
):
patches = list(plugins.iter_patches({}, "patch1"))
self.assertEqual([], patches)
@ -94,7 +89,9 @@ class PluginsTests(unittest.TestCase):
}
with unittest.mock.patch.object(
plugins.Plugins, "iter_enabled", return_value=[("plugin1", plugin1)]
plugins.Plugins,
"iter_enabled",
return_value=[plugins.BasePlugin("plugin1", plugin1)],
):
tutor_config.load_plugins(config, defaults)
@ -116,7 +113,9 @@ class PluginsTests(unittest.TestCase):
config = {"set": {"ID": "newid"}}
with unittest.mock.patch.object(
plugins.Plugins, "iter_enabled", return_value=[("plugin1", plugin1)]
plugins.Plugins,
"iter_enabled",
return_value=[plugins.BasePlugin("plugin1", plugin1)],
):
tutor_config.load_plugins(config, {})
@ -129,7 +128,9 @@ class PluginsTests(unittest.TestCase):
config = {"set": {"PARAM1": "{{ 128|random_string }}"}}
with unittest.mock.patch.object(
plugins.Plugins, "iter_enabled", return_value=[("plugin1", plugin1)]
plugins.Plugins,
"iter_enabled",
return_value=[plugins.BasePlugin("plugin1", plugin1)],
):
tutor_config.load_plugins(config, {})
self.assertEqual(128, len(config["PARAM1"]))
@ -142,7 +143,9 @@ class PluginsTests(unittest.TestCase):
config = {"defaults": {"PARAM2": "{{ PARAM1 }}"}}
with unittest.mock.patch.object(
plugins.Plugins, "iter_enabled", return_value=[("plugin1", plugin1)]
plugins.Plugins,
"iter_enabled",
return_value=[plugins.BasePlugin("plugin1", plugin1)],
):
tutor_config.load_plugins(config, defaults)
self.assertEqual("{{ PARAM1 }}", defaults["PLUGIN1_PARAM2"])
@ -154,12 +157,16 @@ class PluginsTests(unittest.TestCase):
config = {"add": {"PARAM1": "{{ 10|random_string }}"}}
with unittest.mock.patch.object(
plugins.Plugins, "iter_enabled", return_value=[("plugin1", plugin1)]
plugins.Plugins,
"iter_enabled",
return_value=[plugins.BasePlugin("plugin1", plugin1)],
):
tutor_config.load_plugins(config, {})
value1 = config["PLUGIN1_PARAM1"]
with unittest.mock.patch.object(
plugins.Plugins, "iter_enabled", return_value=[("plugin1", plugin1)]
plugins.Plugins,
"iter_enabled",
return_value=[plugins.BasePlugin("plugin1", plugin1)],
):
tutor_config.load_plugins(config, {})
value2 = config["PLUGIN1_PARAM1"]
@ -173,7 +180,9 @@ class PluginsTests(unittest.TestCase):
hooks = {"init": ["myclient"]}
with unittest.mock.patch.object(
plugins.Plugins, "iter_enabled", return_value=[("plugin1", plugin1)]
plugins.Plugins,
"iter_enabled",
return_value=[plugins.BasePlugin("plugin1", plugin1)],
):
self.assertEqual(
[("plugin1", ["myclient"])], list(plugins.iter_hooks({}, "init"))
@ -184,7 +193,9 @@ class PluginsTests(unittest.TestCase):
templates = "/tmp/templates"
with unittest.mock.patch.object(
plugins.Plugins, "iter_enabled", return_value=[("plugin1", plugin1)]
plugins.Plugins,
"iter_enabled",
return_value=[plugins.BasePlugin("plugin1", plugin1)],
):
self.assertEqual(
[("plugin1", "/tmp/templates")], list(plugins.iter_template_roots({}))
@ -192,11 +203,13 @@ class PluginsTests(unittest.TestCase):
def test_plugins_are_updated_on_config_change(self):
config = {"PLUGINS": []}
instance1 = plugins.Plugins(config)
self.assertEqual(0, len(list(instance1.iter_enabled())))
plugins1 = plugins.Plugins(config)
self.assertEqual(0, len(list(plugins1.iter_enabled())))
config["PLUGINS"].append("plugin1")
with unittest.mock.patch.object(
plugins.Plugins, "iter_installed", return_value=[("plugin1", None)]
plugins.Plugins,
"iter_installed",
return_value=[plugins.BasePlugin("plugin1", None)],
):
instance2 = plugins.Plugins(config)
self.assertEqual(1, len(list(instance2.iter_enabled())))
plugins2 = plugins.Plugins(config)
self.assertEqual(1, len(list(plugins2.iter_enabled())))

View File

@ -24,9 +24,13 @@ def plugins_command():
@click.pass_obj
def list_command(context):
config = tutor_config.load_user(context.root)
for name, _ in plugins.iter_installed():
status = "" if plugins.is_enabled(config, name) else " (disabled)"
print("{plugin}{status}".format(plugin=name, status=status))
for plugin in plugins.iter_installed():
status = "" if plugins.is_enabled(config, plugin.name) else " (disabled)"
print(
"{plugin}@{version}{status}".format(
plugin=plugin.name, status=status, version=plugin.version
)
)
@click.command(help="Enable a plugin")
@ -67,10 +71,10 @@ def add_plugin_commands(command_group):
Add commands provided by all plugins to the given command group. Each command is
added with a name that is equal to the plugin name.
"""
for plugin_name, command in plugins.iter_commands():
if isinstance(command, click.Command):
command.name = plugin_name
command_group.add_command(command)
for plugin in plugins.iter_installed():
if isinstance(plugin.command, click.Command):
plugin.command.name = plugin.name
command_group.add_command(plugin.command)
plugins_command.add_command(list_command)

View File

@ -107,24 +107,21 @@ def load_plugins(config, defaults):
"""
Add, override and set new defaults from plugins.
"""
for plugin_name, plugin in plugins.iter_enabled(config):
plugin_prefix = plugin_name.upper() + "_"
plugin_config = plugins.get_callable_attr(plugin, "config", {})
for plugin in plugins.iter_enabled(config):
# Add new config key/values
for key, value in plugin_config.get("add", {}).items():
new_key = plugin_prefix + key
for key, value in plugin.config_add.items():
new_key = plugin.config_key(key)
if new_key not in config:
config[new_key] = env.render_unknown(config, value)
# Set existing config key/values: here, we do not override existing values
for key, value in plugin_config.get("set", {}).items():
for key, value in plugin.config_set.items():
if key not in config:
config[key] = env.render_unknown(config, value)
# Create new defaults
for key, value in plugin_config.get("defaults", {}).items():
defaults[plugin_prefix + key] = value
for key, value in plugin.config_defaults.items():
defaults[plugin.config_key(key)] = value
def upgrade_obsolete(config):

View File

@ -1,4 +1,5 @@
from copy import deepcopy
import importlib
import pkg_resources
from . import exceptions
@ -7,16 +8,15 @@ from . import exceptions
CONFIG_KEY = "PLUGINS"
class Plugins:
class BasePlugin:
"""
Tutor plugins are regular python packages that have a 'tutor.plugin.v0' entrypoint.
The API for Tutor plugins is currently in development. The entrypoint will switch to
'tutor.plugin.v1' once it is stabilised.
This entrypoint must point to a module or a class that implements one or more of the
Tutor plugins are defined by a name and an object that implements one or more of the
following properties:
`config` (dict str->dict(str->str)): contains "add", "set", "default" keys. Entries
in these dicts will be added or override the global configuration. Keys in "add" and
"set" will be prefixed by the plugin name in uppercase.
`patches` (dict str->str): entries in this dict will be used to patch the rendered
Tutor templates. For instance, to add "somecontent" to a template that includes '{{
patch("mypatch") }}', set: `patches["mypatch"] = "somecontent"`. It is recommended
@ -36,11 +36,94 @@ class Plugins:
It is then assumed that there are `myplugin/hooks/service1/init` and
`myplugin/hooks/service2/init` templates in the plugin `templates` directory.
`command` (click.Command): if a plugin exposes a `command` attribute, users will be able to run it from the command line as `tutor pluginname`.
"""
def __init__(self, name, obj):
self.name = name
self.config = get_callable_attr(obj, "config", {})
self.patches = get_callable_attr(obj, "patches", default={})
self.hooks = get_callable_attr(obj, "hooks", default={})
self.templates_root = get_callable_attr(obj, "templates", default=None)
self.command = getattr(obj, "command", None)
def config_key(self, key):
"""
Config keys in the "add" and "defaults" dicts should be prefixed by the plugin name, in uppercase.
"""
return self.name.upper() + "_" + key
@property
def config_add(self):
return self.config.get("add", {})
@property
def config_set(self):
return self.config.get("set", {})
@property
def config_defaults(self):
return self.config.get("defaults", {})
@property
def version(self):
raise NotImplementedError
@classmethod
def iter_installed(cls):
raise NotImplementedError
class EntrypointPlugin(BasePlugin):
"""
Entrypoint plugins are regular python packages that have a 'tutor.plugin.v0' entrypoint.
The API for Tutor plugins is currently in development. The entrypoint will switch to
'tutor.plugin.v1' once it is stabilised.
"""
ENTRYPOINT = "tutor.plugin.v0"
def __init__(self, entrypoint):
super().__init__(entrypoint.name, entrypoint.load())
self.entrypoint = entrypoint
@property
def version(self):
return self.entrypoint.dist.version
@classmethod
def iter_installed(cls):
for entrypoint in pkg_resources.iter_entry_points(cls.ENTRYPOINT):
yield cls(entrypoint)
class OfficialPlugin(BasePlugin):
"""
Official plugins have a "plugin" module which exposes a __version__
attribute.
Official plugins should be manually added to INSTALLED.
"""
INSTALLED = []
def __init__(self, name):
self.module = importlib.import_module("tutor{}.plugin".format(name))
super().__init__(name, self.module)
@property
def version(self):
return self.module.__version__
@classmethod
def iter_installed(cls):
yield from cls.INSTALLED
class Plugins:
INSTANCE = None
EXTRA_INSTALLED = {}
def __init__(self, config):
self.config = deepcopy(config)
@ -48,27 +131,24 @@ class Plugins:
self.hooks = {}
self.template_roots = {}
for plugin_name, plugin in self.iter_enabled():
patches = get_callable_attr(plugin, "patches", {})
for patch_name, content in patches.items():
for plugin in self.iter_enabled():
for patch_name, content in plugin.patches.items():
if patch_name not in self.patches:
self.patches[patch_name] = {}
self.patches[patch_name][plugin_name] = content
self.patches[patch_name][plugin.name] = content
hooks = get_callable_attr(plugin, "hooks", {})
for hook_name, services in hooks.items():
for hook_name, services in plugin.hooks.items():
if hook_name not in self.hooks:
self.hooks[hook_name] = {}
self.hooks[hook_name][plugin_name] = services
self.hooks[hook_name][plugin.name] = services
templates_root = get_callable_attr(plugin, "templates")
if templates_root:
self.template_roots[plugin_name] = templates_root
if plugin.templates_root:
self.template_roots[plugin.name] = plugin.templates_root
@classmethod
def clear(cls):
cls.INSTANCE = None
cls.EXTRA_INSTALLED.clear()
OfficialPlugin.INSTALLED.clear()
@classmethod
def instance(cls, config):
@ -78,20 +158,21 @@ class Plugins:
@classmethod
def iter_installed(cls):
yield from cls.EXTRA_INSTALLED.items()
for name, module in cls.iter_installed_entrypoints():
if name not in cls.EXTRA_INSTALLED:
yield name, module
@classmethod
def iter_installed_entrypoints(cls):
for entrypoint in pkg_resources.iter_entry_points(cls.ENTRYPOINT):
yield (entrypoint.name, entrypoint.load())
"""
Iterate on all installed plugins. Plugins are deduplicated by name.
"""
classes = [OfficialPlugin, EntrypointPlugin]
installed_plugin_names = set()
for PluginClass in classes:
for plugin in PluginClass.iter_installed():
if plugin.name not in installed_plugin_names:
installed_plugin_names.add(plugin.name)
yield plugin
def iter_enabled(self):
for name, plugin in self.iter_installed():
if is_enabled(self.config, name):
yield name, plugin
for plugin in self.iter_installed():
if is_enabled(self.config, plugin.name):
yield plugin
def iter_patches(self, name):
plugin_patches = self.patches.get(name, {})
@ -114,7 +195,7 @@ def get_callable_attr(plugin, attr_name, default=None):
def is_installed(name):
plugin_names = [name for name, _ in iter_installed()]
plugin_names = [plugin.name for plugin in iter_installed()]
return name in plugin_names
@ -156,13 +237,3 @@ def iter_hooks(config, hook_name):
def iter_template_roots(config):
yield from Plugins.instance(config).iter_template_roots()
def iter_commands():
"""
Iterate over all plugins that provide a `command` attribute.
"""
for plugin_name, plugin in iter_installed():
command = getattr(plugin, "command", None)
if command:
yield plugin_name, command