depr: templated hooks

Templated hooks we almost completely useless, so we get rid of them.
This allows us to get rid entirely of hook names and hook indexes, which
makes the whole implementation much simpler. Hook removal (with
`clear_all`) is achieved thanks to weak references.
This commit is contained in:
Régis Behmo 2023-04-28 17:11:14 +02:00
parent cbe32cbc15
commit 3ab0dcb9e6
21 changed files with 175 additions and 419 deletions

View File

@ -3,3 +3,9 @@
- 💥[Improvement] During registration, the honor code and terms of service links are no longer visible by default. For most platforms, these links did not work anyway.
- 💥[Deprecation] Halt support for Python 3.7. The binary release of Tutor is also no longer compatible with macOS 10.
- 💥[Deprecation] Drop support for `docker-compose`, also known as Compose V1. The `docker compose` (no hyphen) plugin must be installed.
- 💥[Refactor] We simplify the hooks API by getting rid of the `ContextTemplate`, `FilterTemplate` and `ActionTemplate` classes. As a consequences, the following changes occur:
- `APP` was previously a ContextTemplate, and is now a dictionary of contexts indexed by name. Developers who implemented this context should replace `Contexts.APP(...)` by `Contexts.app(...)`.
- Removed the `ENV_PATCH` filter, which was for internal use only anyway.
- The `PLUGIN_LOADED` ActionTemplate is now an Action which takes a single argument. (the plugin name)
- 💥[Refactor] We refactored the hooks API further by removing the static hook indexes and the hooks names. As a consequence:
- The syntactic sugar functions from the "filters" and "actions" modules were all removed: `get`, `add*`, `iterate*`, `apply*`, `do*`, etc.

View File

@ -9,9 +9,6 @@ Actions are one of the two types of hooks (the other being :ref:`filters`) that
.. autoclass:: tutor.core.hooks.Action
:members:
.. autoclass:: tutor.core.hooks.ActionTemplate
:members:
.. The following are only to ensure that the docs build without warnings
.. class:: tutor.core.hooks.actions.T
.. class:: tutor.types.Config

View File

@ -9,9 +9,6 @@ Filters are one of the two types of hooks (the other being :ref:`actions`) that
.. autoclass:: tutor.core.hooks.Filter
:members:
.. autoclass:: tutor.core.hooks.FilterTemplate
:members:
.. The following are only to ensure that the docs build without warnings
.. class:: tutor.core.hooks.filters.T1
.. class:: tutor.core.hooks.filters.T2

View File

@ -1,7 +1,7 @@
import unittest
from tutor import config as tutor_config
from tests.helpers import temporary_root
from tutor import config as tutor_config
from .base import TestCommandMixin

View File

@ -8,16 +8,12 @@ class PluginActionsTests(unittest.TestCase):
def setUp(self) -> None:
self.side_effect_int = 0
def tearDown(self) -> None:
super().tearDown()
actions.clear_all(context="tests")
def run(self, result: t.Any = None) -> t.Any:
with contexts.enter("tests"):
return super().run(result=result)
def test_do(self) -> None:
action: actions.Action[int] = actions.get("test-action")
action: actions.Action[int] = actions.Action()
@action.add()
def _test_action_1(increment: int) -> None:
@ -31,29 +27,33 @@ class PluginActionsTests(unittest.TestCase):
self.assertEqual(3, self.side_effect_int)
def test_priority(self) -> None:
@actions.add("test-action", priority=2)
action: actions.Action[[]] = actions.Action()
@action.add(priority=2)
def _test_action_1() -> None:
self.side_effect_int += 4
@actions.add("test-action", priority=1)
@action.add(priority=1)
def _test_action_2() -> None:
self.side_effect_int = self.side_effect_int // 2
# Action 2 must be performed before action 1
self.side_effect_int = 4
actions.do("test-action")
action.do()
self.assertEqual(6, self.side_effect_int)
def test_equal_priority(self) -> None:
@actions.add("test-action", priority=2)
action: actions.Action[[]] = actions.Action()
@action.add(priority=2)
def _test_action_1() -> None:
self.side_effect_int += 4
@actions.add("test-action", priority=2)
@action.add(priority=2)
def _test_action_2() -> None:
self.side_effect_int = self.side_effect_int // 2
# Action 2 must be performed after action 1
self.side_effect_int = 4
actions.do("test-action")
action.do()
self.assertEqual(4, self.side_effect_int)

View File

@ -7,32 +7,32 @@ from tutor.core.hooks import contexts, filters
class PluginFiltersTests(unittest.TestCase):
def tearDown(self) -> None:
super().tearDown()
filters.clear_all(context="tests")
def run(self, result: t.Any = None) -> t.Any:
with contexts.enter("tests"):
return super().run(result=result)
def test_add(self) -> None:
@filters.add("tests:count-sheeps")
filtre: filters.Filter[int, []] = filters.Filter()
@filtre.add()
def filter1(value: int) -> int:
return value + 1
value = filters.apply("tests:count-sheeps", 0)
value = filtre.apply(0)
self.assertEqual(1, value)
def test_add_items(self) -> None:
@filters.add("tests:add-sheeps")
filtre: filters.Filter[list[int], []] = filters.Filter()
@filtre.add()
def filter1(sheeps: list[int]) -> list[int]:
return sheeps + [0]
filters.add_item("tests:add-sheeps", 1)
filters.add_item("tests:add-sheeps", 2)
filters.add_items("tests:add-sheeps", [3, 4])
filtre.add_item(1)
filtre.add_item(2)
filtre.add_items([3, 4])
sheeps: list[int] = filters.apply("tests:add-sheeps", [])
sheeps: list[int] = filtre.apply([])
self.assertEqual([0, 1, 2, 3, 4], sheeps)
def test_filter_callbacks(self) -> None:
@ -42,20 +42,20 @@ class PluginFiltersTests(unittest.TestCase):
self.assertEqual(1, callback.apply(0))
def test_filter_context(self) -> None:
filtre: filters.Filter[list[int], []] = filters.Filter()
with contexts.enter("testcontext"):
filters.add_item("test:sheeps", 1)
filters.add_item("test:sheeps", 2)
filtre.add_item(1)
filtre.add_item(2)
self.assertEqual([1, 2], filters.apply("test:sheeps", []))
self.assertEqual(
[1], filters.apply_from_context("testcontext", "test:sheeps", [])
)
self.assertEqual([1, 2], filtre.apply([]))
self.assertEqual([1], filtre.apply_from_context("testcontext", []))
def test_clear_context(self) -> None:
filtre: filters.Filter[list[int], []] = filters.Filter()
with contexts.enter("testcontext"):
filters.add_item("test:sheeps", 1)
filters.add_item("test:sheeps", 2)
filtre.add_item(1)
filtre.add_item(2)
self.assertEqual([1, 2], filters.apply("test:sheeps", []))
filters.clear("test:sheeps", context="testcontext")
self.assertEqual([2], filters.apply("test:sheeps", []))
self.assertEqual([1, 2], filtre.apply([]))
filtre.clear(context="testcontext")
self.assertEqual([2], filtre.apply([]))

View File

@ -92,7 +92,7 @@ class PluginsTests(PluginsTestCase):
def test_plugin_without_patches(self) -> None:
plugins_v0.DictPlugin({"name": "plugin1"})
plugins.load("plugin1")
plugins.load_all(["plugin1"])
patches = list(plugins.iter_patches("patch1"))
self.assertEqual([], patches)

View File

@ -1,9 +1,9 @@
from __future__ import annotations
from functools import lru_cache
import os
import re
import typing as t
from functools import lru_cache
from tutor import hooks

View File

@ -4,9 +4,10 @@ import os
import click
from tutor import bindmount
from tutor import config as tutor_config
from tutor import env as tutor_env
from tutor import bindmount, hooks, utils
from tutor import hooks, utils
from tutor.commands import jobs
from tutor.commands.context import BaseTaskContext
from tutor.core.hooks import Filter # pylint: disable=unused-import

View File

@ -37,11 +37,11 @@ def _add_core_init_tasks() -> None:
The context is important, because it allows us to select the init scripts based on
the --limit argument.
"""
with hooks.Contexts.APP("mysql").enter():
with hooks.Contexts.app("mysql").enter():
hooks.Filters.CLI_DO_INIT_TASKS.add_item(
("mysql", env.read_core_template_file("jobs", "init", "mysql.sh"))
)
with hooks.Contexts.APP("lms").enter():
with hooks.Contexts.app("lms").enter():
hooks.Filters.CLI_DO_INIT_TASKS.add_item(
(
"lms",
@ -54,7 +54,7 @@ def _add_core_init_tasks() -> None:
hooks.Filters.CLI_DO_INIT_TASKS.add_item(
("lms", env.read_core_template_file("jobs", "init", "lms.sh"))
)
with hooks.Contexts.APP("cms").enter():
with hooks.Contexts.app("cms").enter():
hooks.Filters.CLI_DO_INIT_TASKS.add_item(
("cms", env.read_core_template_file("jobs", "init", "cms.sh"))
)
@ -64,7 +64,7 @@ def _add_core_init_tasks() -> None:
@click.option("-l", "--limit", help="Limit initialisation to this service or plugin")
def initialise(limit: t.Optional[str]) -> t.Iterator[tuple[str, str]]:
fmt.echo_info("Initialising all services...")
filter_context = hooks.Contexts.APP(limit).name if limit else None
filter_context = hooks.Contexts.app(limit).name if limit else None
# Deprecated pre-init tasks
for service, path in hooks.Filters.COMMANDS_PRE_INIT.iterate_from_context(

View File

@ -304,7 +304,7 @@ def _remove_plugin_config_overrides_on_unload(
# Find the configuration entries that were overridden by the plugin and
# remove them from the current config
for key, _value in hooks.Filters.CONFIG_OVERRIDES.iterate_from_context(
hooks.Contexts.APP(plugin).name
hooks.Contexts.app(plugin).name
):
value = config.pop(key, None)
value = env.render_unknown(config, value)

View File

@ -1,15 +1,13 @@
import typing as t
from .actions import Action, ActionTemplate
from .actions import clear_all as _clear_all_actions
from .contexts import Context, ContextTemplate
from .filters import Filter, FilterTemplate
from .filters import clear_all as _clear_all_filters
from .actions import Action
from .contexts import Context
from .filters import Filter
def clear_all(context: t.Optional[str] = None) -> None:
"""
Clear both actions and filters.
"""
_clear_all_actions(context=context)
_clear_all_filters(context=context)
Action.clear_all(context=context)
Filter.clear_all(context=context)

View File

@ -5,9 +5,11 @@ __license__ = "Apache 2.0"
import sys
import typing as t
from weakref import WeakSet
from typing_extensions import ParamSpec
from . import priorities
from .contexts import Contextualized
@ -44,32 +46,25 @@ class Action(t.Generic[T]):
This is the typical action lifecycle:
1. Create an action with method :py:meth:`get`.
2. Add callbacks with method :py:meth:`add`.
3. Call the action callbacks with method :py:meth:`do`.
1. Create an action with ``Action()``.
2. Add callbacks with :py:meth:`add`.
3. Call the action callbacks with :py:meth:`do`.
The ``P`` type parameter of the Action class corresponds to the expected signature of
The ``T`` type parameter of the Action class corresponds to the expected signature of
the action callbacks. For instance, ``Action[[str, int]]`` means that the action
callbacks are expected to take two arguments: one string and one integer.
This strong typing makes it easier for plugin developers to quickly check whether they are adding and calling action callbacks correctly.
This strong typing makes it easier for plugin developers to quickly check whether
they are adding and calling action callbacks correctly.
"""
INDEX: dict[str, "Action[t.Any]"] = {}
# Keep a weak reference to all created filters. This allows us to clear them when
# necessary.
INSTANCES: WeakSet[Action[t.Any]] = WeakSet()
def __init__(self, name: str) -> None:
self.name = name
def __init__(self) -> None:
self.callbacks: list[ActionCallback[T]] = []
def __repr__(self) -> str:
return f"{self.__class__.__name__}('{self.name}')"
@classmethod
def get(cls, name: str) -> "Action[t.Any]":
"""
Get an existing action with the given name from the index, or create one.
"""
return cls.INDEX.setdefault(name, cls(name))
self.INSTANCES.add(self)
def add(
self, priority: t.Optional[int] = None
@ -144,7 +139,7 @@ class Action(t.Generic[T]):
)
except:
sys.stderr.write(
f"Error applying action '{self.name}': func={callback.func} contexts={callback.contexts}'\n"
f"Error applying action: func={callback.func} contexts={callback.contexts}'\n"
)
raise
@ -168,98 +163,10 @@ class Action(t.Generic[T]):
if not callback.is_in_context(context)
]
class ActionTemplate(t.Generic[T]):
"""
Action templates are for actions for which the name needs to be formatted
before the action can be applied.
Action templates can generate different :py:class:`Action` objects for which the
name matches a certain template.
Templated actions must be formatted with ``(*args)`` before being applied. For example::
action_template = ActionTemplate("namespace:{0}")
# Return the action named "namespace:name"
my_action = action_template("name")
@my_action.add()
def my_callback():
...
my_action.do()
"""
def __init__(self, name: str):
self.template = name
def __repr__(self) -> str:
return f"{self.__class__.__name__}('{self.template}')"
def __call__(self, *args: t.Any, **kwargs: t.Any) -> Action[T]:
name = self.template.format(*args, **kwargs)
action: Action[T] = Action.get(name)
return action
# Syntactic sugar
get = Action.get
def get_template(name: str) -> ActionTemplate[t.Any]:
"""
Create an action with a template name.
"""
return ActionTemplate(name)
def add(
name: str, priority: t.Optional[int] = None
) -> t.Callable[[ActionCallbackFunc[T]], ActionCallbackFunc[T]]:
"""
Decorator to add a callback action associated to a name.
"""
return get(name).add(priority=priority)
def do(
name: str,
*args: T.args,
**kwargs: T.kwargs,
) -> None:
"""
Run action callbacks associated to a name/context.
"""
action: Action[T] = Action.get(name)
action.do(*args, **kwargs)
def do_from_context(
context: str,
name: str,
*args: T.args,
**kwargs: T.kwargs,
) -> None:
"""
Same as :py:func:`do` but only run the callbacks that were created in a given context.
"""
action: Action[T] = Action.get(name)
action.do_from_context(context, *args, **kwargs)
def clear_all(context: t.Optional[str] = None) -> None:
"""
Clear any previously defined filter with the given context.
This will call :py:func:`clear` with all action names.
"""
for name in Action.INDEX:
clear(name, context=context)
def clear(name: str, context: t.Optional[str] = None) -> None:
"""
Clear any previously defined action with the given name and context.
"""
Action.get(name).clear(context=context)
@classmethod
def clear_all(cls, context: t.Optional[str] = None) -> None:
"""
Clear any previously defined action with the given context.
"""
for action in cls.INSTANCES:
action.clear(context)

View File

@ -43,22 +43,6 @@ class Context:
Context.CURRENT.pop()
class ContextTemplate:
"""
Context templates are for filters for which the name needs to be formatted
before the filter can be applied.
"""
def __init__(self, name: str):
self.template = name
def __repr__(self) -> str:
return f"{self.__class__.__name__}('{self.template}')"
def __call__(self, *args: t.Any, **kwargs: t.Any) -> Context:
return Context(self.template.format(*args, **kwargs))
class Contextualized:
"""
This is a simple class to store the current context in hooks.

View File

@ -5,6 +5,7 @@ __license__ = "Apache 2.0"
import sys
import typing as t
from weakref import WeakSet
from typing_extensions import Concatenate, ParamSpec
@ -43,16 +44,16 @@ class Filter(t.Generic[T1, T2]):
This is the typical filter lifecycle:
1. Create an action with method :py:meth:`get`.
2. Add callbacks with method :py:meth:`add`.
1. Create a filter with ``Filter()``.
2. Add callbacks with :py:meth:`add`.
3. Call the filter callbacks with method :py:meth:`apply`.
The result of each callback is passed as the first argument to the next one. Thus,
the type of the first argument must match the callback return type.
The `T` and `P` type parameters of the Filter class correspond to the expected
signature of the filter callbacks. `T` is the type of the first argument (and thus
the return value type as well) and `P` is the signature of the other arguments.
The ``T1`` and ``T2`` type parameters of the Filter class correspond to the expected
signature of the filter callbacks. ``T1`` is the type of the first argument (and thus
the return value type as well) and ``T2`` is the signature of the other arguments.
For instance, `Filter[str, [int]]` means that the filter callbacks are expected to
take two arguments: one string and one integer. Each callback must then return a
@ -62,21 +63,13 @@ class Filter(t.Generic[T1, T2]):
they are adding and calling filter callbacks correctly.
"""
INDEX: dict[str, "Filter[t.Any, t.Any]"] = {}
# Keep a weak reference to all created filters. This allows us to clear them when
# necessary.
INSTANCES: WeakSet[Filter[t.Any, t.Any]] = WeakSet()
def __init__(self, name: str) -> None:
self.name = name
def __init__(self) -> None:
self.callbacks: list[FilterCallback[T1, T2]] = []
def __repr__(self) -> str:
return f"{self.__class__.__name__}('{self.name}')"
@classmethod
def get(cls, name: str) -> "Filter[t.Any, t.Any]":
"""
Get an existing action with the given name from the index, or create one.
"""
return cls.INDEX.setdefault(name, cls(name))
self.INSTANCES.add(self)
def add(
self, priority: t.Optional[int] = None
@ -156,7 +149,7 @@ class Filter(t.Generic[T1, T2]):
)
except:
sys.stderr.write(
f"Error applying filter '{self.name}': func={callback.func} contexts={callback.contexts}'\n"
f"Error applying filter: func={callback.func} contexts={callback.contexts}'\n"
)
raise
return value
@ -171,6 +164,14 @@ class Filter(t.Generic[T1, T2]):
if not callback.is_in_context(context)
]
@classmethod
def clear_all(cls, context: t.Optional[str] = None) -> None:
"""
Clear any previously defined filter with the given context.
"""
for filtre in cls.INSTANCES:
filtre.clear(context)
# The methods below are specific to filters which take lists as first arguments
def add_item(
self: "Filter[list[L], T2]", item: L, priority: t.Optional[int] = None
@ -205,8 +206,8 @@ class Filter(t.Generic[T1, T2]):
``add_item`` multiple times on the same filter, then you probably want to use a
single call to ``add_items`` instead.
:param name: filter name.
:param list[object] items: items that will be appended to the resulting list.
:param int priority: optional priority.
Usage::
@ -261,114 +262,3 @@ class Filter(t.Generic[T1, T2]):
Same as :py:func:`Filter.iterate` but apply only callbacks from a given context.
"""
yield from self.apply_from_context(context, [], *args, **kwargs)
class FilterTemplate(t.Generic[T1, T2]):
"""
Filter templates are for filters for which the name needs to be formatted
before the filter can be applied.
Similar to :py:class:`tutor.core.hooks.ActionTemplate`, filter templates are used to generate
:py:class:`Filter` objects for which the name matches a certain template.
Templated filters must be formatted with ``(*args)`` before being applied. For example::
filter_template = FilterTemplate("namespace:{0}")
named_filter = filter_template("name")
@named_filter.add()
def my_callback(x: int) -> int:
...
named_filter.apply(42)
"""
def __init__(self, name: str):
self.template = name
def __repr__(self) -> str:
return f"{self.__class__.__name__}('{self.template}')"
def __call__(self, *args: t.Any, **kwargs: t.Any) -> Filter[T1, T2]:
return get(self.template.format(*args, **kwargs))
# Syntactic sugar
get = Filter.get
def get_template(name: str) -> FilterTemplate[t.Any, t.Any]:
"""
Create a filter with a template name.
"""
return FilterTemplate(name)
def add(
name: str, priority: t.Optional[int] = None
) -> t.Callable[[FilterCallbackFunc[T1, T2]], FilterCallbackFunc[T1, T2]]:
"""
Decorator for functions that will be applied to a single named filter.
"""
return Filter.get(name).add(priority=priority)
def add_item(name: str, item: T1, priority: t.Optional[int] = None) -> None:
"""
Convenience function to add a single item to a filter that returns a list of items.
"""
get(name).add_item(item, priority=priority)
def add_items(name: str, items: list[T1], priority: t.Optional[int] = None) -> None:
"""
Convenience decorator to add multiple item to a filter that returns a list of items.
"""
get(name).add_items(items, priority=priority)
def iterate(name: str, *args: t.Any, **kwargs: t.Any) -> t.Iterator[T1]:
"""
Convenient function to iterate over the results of a filter result list.
"""
yield from iterate_from_context(None, name, *args, **kwargs)
def iterate_from_context(
context: t.Optional[str], name: str, *args: t.Any, **kwargs: t.Any
) -> t.Iterator[T1]:
yield from Filter.get(name).iterate_from_context(context, *args, **kwargs)
def apply(name: str, value: T1, *args: t.Any, **kwargs: t.Any) -> T1:
"""
Apply all declared filters to a single value, passing along the additional arguments.
"""
return apply_from_context(None, name, value, *args, **kwargs)
def apply_from_context(
context: t.Optional[str], name: str, value: T1, *args: T2.args, **kwargs: T2.kwargs
) -> T1:
"""
Same as :py:func:`apply` but only run the callbacks that were created in a given context.
"""
filtre: Filter[T1, T2] = Filter.get(name)
return filtre.apply_from_context(context, value, *args, **kwargs)
def clear_all(context: t.Optional[str] = None) -> None:
"""
Clear any previously defined filter with the given context.
"""
for name in Filter.INDEX:
clear(name, context=context)
def clear(name: str, context: t.Optional[str] = None) -> None:
"""
Clear any previously defined filter with the given name and context.
"""
filtre = Filter.INDEX.get(name)
if filtre:
filtre.clear(context=context)

View File

@ -526,7 +526,7 @@ def _delete_plugin_templates(plugin: str, root: str, _config: Config) -> None:
Delete plugin env files on unload.
"""
targets = hooks.Filters.ENV_TEMPLATE_TARGETS.iterate_from_context(
hooks.Contexts.APP(plugin).name
hooks.Contexts.app(plugin).name
)
for src, dst in targets:
path = pathjoin(root, dst.replace("/", os.sep), src.replace("/", os.sep))

View File

@ -11,16 +11,7 @@ from typing import Any, Callable, Iterable
import click
from tutor.core.hooks import (
Action,
ActionTemplate,
Context,
ContextTemplate,
Filter,
FilterTemplate,
actions,
filters,
)
from tutor.core.hooks import Action, Context, Filter
from tutor.types import Config
__all__ = ["Actions", "Filters", "Contexts"]
@ -64,9 +55,7 @@ class Actions:
#: :parameter str root: project root.
#: :parameter dict config: project configuration.
#: :parameter str name: docker-compose project name.
COMPOSE_PROJECT_STARTED: Action[[str, Config, str]] = actions.get(
"compose:project:started"
)
COMPOSE_PROJECT_STARTED: Action[[str, Config, str]] = Action()
#: Called whenever the core project is ready to run. This action is called as soon
#: as possible. This is the right time to discover plugins, for instance. In
@ -81,14 +70,14 @@ class Actions:
#: developers probably don't have to implement this action themselves.
#:
#: This action does not have any parameter.
CORE_READY: Action[[]] = actions.get("core:ready")
CORE_READY: Action[[]] = Action()
#: Called just before triggering the job tasks of any ``... do <job>`` command.
#:
#: :parameter str job: job name.
#: :parameter args: job positional arguments.
#: :parameter kwargs: job named arguments.
DO_JOB: Action[[str, Any]] = actions.get("do:job")
DO_JOB: Action[[str, Any]] = Action()
#: Triggered when a single plugin needs to be loaded. Only plugins that have previously been
#: discovered can be loaded (see :py:data:`CORE_READY`).
@ -100,14 +89,14 @@ class Actions:
#: Most plugin developers will not have to implement this action themselves, unless
#: they want to perform a specific action at the moment the plugin is enabled.
#:
#: This action does not have any parameter.
PLUGIN_LOADED: ActionTemplate[[]] = actions.get_template("plugins:loaded:{0}")
#: :parameter str plugin: plugin name.
PLUGIN_LOADED: Action[[str]] = Action()
#: Triggered after all plugins have been loaded. At this point the list of loaded
#: plugins may be obtained from the :py:data:`Filters.PLUGINS_LOADED` filter.
#:
#: This action does not have any parameter.
PLUGINS_LOADED: Action[[]] = actions.get("plugins:loaded")
PLUGINS_LOADED: Action[[]] = Action()
#: Triggered when a single plugin is unloaded. Only plugins that have previously been
#: loaded can be unloaded (see :py:data:`PLUGIN_LOADED`).
@ -120,12 +109,12 @@ class Actions:
#: :parameter str plugin: plugin name.
#: :parameter str root: absolute path to the project root.
#: :parameter config: full project configuration
PLUGIN_UNLOADED: Action[str, str, Config] = actions.get("plugins:unloaded")
PLUGIN_UNLOADED: Action[str, str, Config] = Action()
#: Called as soon as we have access to the Tutor project root.
#:
#: :parameter str root: absolute path to the project root.
PROJECT_ROOT_READY: Action[str] = actions.get("project:root:ready")
PROJECT_ROOT_READY: Action[str] = Action()
class Filters:
@ -178,7 +167,7 @@ class Filters:
#:
#: :parameter list commands: commands are instances of ``click.Command``. They will
#: all be added as subcommands of the main ``tutor`` command.
CLI_COMMANDS: Filter[list[click.Command], []] = filters.get("cli:commands")
CLI_COMMANDS: Filter[list[click.Command], []] = Filter()
#: List of `do ...` commands.
#:
@ -188,7 +177,7 @@ class Filters:
#: in the "service" container, both in local, dev and k8s mode.
CLI_DO_COMMANDS: Filter[
list[Callable[[Any], Iterable[tuple[str, str]]]], []
] = filters.get("cli:commands:do")
] = Filter()
#: List of initialization tasks (scripts) to be run in the `init` job. This job
#: includes all database migrations, setting up, etc. To run some tasks before or
@ -197,9 +186,7 @@ class Filters:
#: :parameter list[tuple[str, str]] tasks: list of ``(service, task)`` tuples. Each
#: task is essentially a bash script to be run in the "service" container. Scripts
#: may contain Jinja markup, similar to templates.
CLI_DO_INIT_TASKS: Filter[list[tuple[str, str]], []] = filters.get(
"cli:commands:do:init"
)
CLI_DO_INIT_TASKS: Filter[list[tuple[str, str]], []] = Filter()
#: DEPRECATED use :py:data:`CLI_DO_INIT_TASKS` instead.
#:
@ -212,9 +199,7 @@ class Filters:
#: - ``path`` is a tuple that corresponds to a template relative path.
#: Example: ``("myplugin", "hooks", "myservice", "pre-init")`` (see :py:data:`IMAGES_BUILD`).
#: The command to execute will be read from that template, after it is rendered.
COMMANDS_INIT: Filter[list[tuple[str, tuple[str, ...]]], []] = filters.get(
"commands:init"
)
COMMANDS_INIT: Filter[list[tuple[str, tuple[str, ...]]], []] = Filter()
#: DEPRECATED use :py:data:`CLI_DO_INIT_TASKS` instead with a lower priority score.
#:
@ -223,9 +208,7 @@ class Filters:
#:
#: :parameter list[tuple[str, tuple[str, ...]]] tasks: list of ``(service, path)``
#: tasks. (see :py:data:`COMMANDS_INIT`).
COMMANDS_PRE_INIT: Filter[list[tuple[str, tuple[str, ...]]], []] = filters.get(
"commands:pre-init"
)
COMMANDS_PRE_INIT: Filter[list[tuple[str, tuple[str, ...]]], []] = Filter()
#: List of folders to bind-mount in docker-compose containers, either in ``tutor local`` or ``tutor dev``.
#:
@ -250,7 +233,7 @@ class Filters:
#: :parameter str name: basename of the host-mounted folder. In the example above,
#: this is "edx-platform". When implementing this filter you should check this name to
#: conditionally add mounts.
COMPOSE_MOUNTS: Filter[list[tuple[str, str]], [str]] = filters.get("compose:mounts")
COMPOSE_MOUNTS: Filter[list[tuple[str, str]], [str]] = Filter()
#: Declare new default configuration settings that don't necessarily have to be saved in the user
#: ``config.yml`` file. Default settings may be overridden with ``tutor config save --set=...``, in which
@ -258,44 +241,34 @@ class Filters:
#:
#: :parameter list[tuple[str, ...]] items: list of (name, value) new settings. All
#: new entries must be prefixed with the plugin name in all-caps.
CONFIG_DEFAULTS: Filter[list[tuple[str, Any]], []] = filters.get("config:defaults")
CONFIG_DEFAULTS: Filter[list[tuple[str, Any]], []] = Filter()
#: Modify existing settings, either from Tutor core or from other plugins. Beware not to override any
#: important setting, such as passwords! Overridden setting values will be printed to stdout when the plugin
#: is disabled, such that users have a chance to back them up.
#:
#: :parameter list[tuple[str, ...]] items: list of (name, value) settings.
CONFIG_OVERRIDES: Filter[list[tuple[str, Any]], []] = filters.get(
"config:overrides"
)
CONFIG_OVERRIDES: Filter[list[tuple[str, Any]], []] = Filter()
#: Declare unique configuration settings that must be saved in the user ``config.yml`` file. This is where
#: you should declare passwords and randomly-generated values that are different from one environment to the next.
#:
#: :parameter list[tuple[str, ...]] items: list of (name, value) new settings. All
#: names must be prefixed with the plugin name in all-caps.
CONFIG_UNIQUE: Filter[list[tuple[str, Any]], []] = filters.get("config:unique")
CONFIG_UNIQUE: Filter[list[tuple[str, Any]], []] = Filter()
#: Use this filter to modify the ``docker build`` command. For instance, to replace
#: the ``build`` subcommand by ``buildx build``.
#:
#: :parameter list[str] command: the full build command, including options and
#: arguments. Note that these arguments do not include the leading ``docker`` command.
DOCKER_BUILD_COMMAND: Filter[list[str], []] = filters.get("docker:build:command")
DOCKER_BUILD_COMMAND: Filter[list[str], []] = Filter()
#: List of patches that should be inserted in a given location of the templates. The
#: filter name must be formatted with the patch name.
#: This filter is not so convenient and plugin developers will probably
#: prefer :py:data:`ENV_PATCHES`.
#:
#: :parameter list[str] patches: each item is the unrendered patch content.
ENV_PATCH: FilterTemplate[list[str], []] = filters.get_template("env:patches:{0}")
#: List of patches that should be inserted in a given location of the templates. This is very similar to :py:data:`ENV_PATCH`, except that the patch is added as a ``(name, content)`` tuple.
#: List of patches that should be inserted in a given location of the templates.
#:
#: :parameter list[tuple[str, str]] patches: pairs of (name, content) tuples. Use this
#: filter to modify the Tutor templates.
ENV_PATCHES: Filter[list[tuple[str, str]], []] = filters.get("env:patches")
ENV_PATCHES: Filter[list[tuple[str, str]], []] = Filter()
#: List of template path patterns to be ignored when rendering templates to the project root. By default, we ignore:
#:
@ -306,13 +279,13 @@ class Filters:
#: Ignored patterns are overridden by include patterns; see :py:data:`ENV_PATTERNS_INCLUDE`.
#:
#: :parameter list[str] patterns: list of regular expression patterns. E.g: ``r"(.*/)?ignored_file_name(/.*)?"``.
ENV_PATTERNS_IGNORE: Filter[list[str], []] = filters.get("env:patterns:ignore")
ENV_PATTERNS_IGNORE: Filter[list[str], []] = Filter()
#: List of template path patterns to be included when rendering templates to the project root.
#: Patterns from this list will take priority over the patterns from :py:data:`ENV_PATTERNS_IGNORE`.
#:
#: :parameter list[str] patterns: list of regular expression patterns. See :py:data:`ENV_PATTERNS_IGNORE`.
ENV_PATTERNS_INCLUDE: Filter[list[str], []] = filters.get("env:patterns:include")
ENV_PATTERNS_INCLUDE: Filter[list[str], []] = Filter()
#: List of `Jinja2 filters <https://jinja.palletsprojects.com/en/latest/templates/#filters>`__ that will be
#: available in templates. Jinja2 filters are basically functions that can be used
@ -343,16 +316,14 @@ class Filters:
#:
#: :parameter filters: list of (name, function) tuples. The function signature
#: should correspond to its usage in templates.
ENV_TEMPLATE_FILTERS: Filter[
list[tuple[str, Callable[..., Any]]], []
] = filters.get("env:templates:filters")
ENV_TEMPLATE_FILTERS: Filter[list[tuple[str, Callable[..., Any]]], []] = Filter()
#: List of all template root folders.
#:
#: :parameter list[str] templates_root: absolute paths to folders which contain templates.
#: The templates in these folders will then be accessible by the environment
#: renderer using paths that are relative to their template root.
ENV_TEMPLATE_ROOTS: Filter[list[str], []] = filters.get("env:templates:roots")
ENV_TEMPLATE_ROOTS: Filter[list[str], []] = Filter()
#: List of template source/destination targets.
#:
@ -361,9 +332,7 @@ class Filters:
#: is a path relative to the environment root. For instance: adding ``("c/d",
#: "a/b")`` to the filter will cause all files from "c/d" to be rendered to the ``a/b/c/d``
#: subfolder.
ENV_TEMPLATE_TARGETS: Filter[list[tuple[str, str]], []] = filters.get(
"env:templates:targets"
)
ENV_TEMPLATE_TARGETS: Filter[list[tuple[str, str]], []] = Filter()
#: List of extra variables to be included in all templates.
#:
@ -378,9 +347,7 @@ class Filters:
#: - `patch`: a function to incorporate extra content into a template.
#:
#: :parameter filters: list of (name, value) tuples.
ENV_TEMPLATE_VARIABLES: Filter[list[tuple[str, Any]], []] = filters.get(
"env:templates:variables"
)
ENV_TEMPLATE_VARIABLES: Filter[list[tuple[str, Any]], []] = Filter()
#: List of images to be built when we run ``tutor images build ...``.
#:
@ -397,7 +364,7 @@ class Filters:
#: :parameter Config config: user configuration.
IMAGES_BUILD: Filter[
list[tuple[str, tuple[str, ...], str, tuple[str, ...]]], [Config]
] = filters.get("images:build")
] = Filter()
#: List of host directories to be automatically bind-mounted in Docker images at
#: build time. For instance, this is useful to build Docker images using a custom
@ -415,9 +382,7 @@ class Filters:
#: :py:data:`COMPOSE_MOUNTS`, this is not just the basename, but the full path. When
#: implementing this filter you should check this path (for instance: with
#: ``os.path.basename(path)``) to conditionally add mounts.
IMAGES_BUILD_MOUNTS: Filter[list[tuple[str, str]], [str]] = filters.get(
"images:build:mounts"
)
IMAGES_BUILD_MOUNTS: Filter[list[tuple[str, str]], [str]] = Filter()
#: List of images to be pulled when we run ``tutor images pull ...``.
#:
@ -426,11 +391,11 @@ class Filters:
#: - ``name`` is the name of the image, as in ``tutor images pull myimage``.
#: - ``tag`` is the Docker tag that will be applied to the image. (see :py:data:`IMAGES_BUILD`).
#: :parameter Config config: user configuration.
IMAGES_PULL: Filter[list[tuple[str, str]], [Config]] = filters.get("images:pull")
IMAGES_PULL: Filter[list[tuple[str, str]], [Config]] = Filter()
#: List of images to be pushed when we run ``tutor images push ...``.
#: Parameters are the same as for :py:data:`IMAGES_PULL`.
IMAGES_PUSH: Filter[list[tuple[str, str]], [Config]] = filters.get("images:push")
IMAGES_PUSH: Filter[list[tuple[str, str]], [Config]] = Filter()
#: List of plugin indexes that are loaded when we run `tutor plugins update`. By
#: default, the plugin indexes are stored in the user configuration. This filter makes
@ -438,13 +403,13 @@ class Filters:
#:
#: :parameter list[str] indexes: list of index URLs. Remember that entries further
#: in the list have priority.
PLUGIN_INDEXES: Filter[list[str], []] = filters.get("plugins:indexes:entries")
PLUGIN_INDEXES: Filter[list[str], []] = Filter()
#: Filter to modify the url of a plugin index url. This is convenient to alias
#: plugin indexes with a simple name, such as "main" or "contrib".
#:
#: :parameter str url: value passed to the `index add/remove` commands.
PLUGIN_INDEX_URL: Filter[str, []] = filters.get("plugins:indexes:url")
PLUGIN_INDEX_URL: Filter[str, []] = Filter()
#: When installing an entry from a plugin index, the plugin data from the index will
#: go through this filter before it is passed along to `pip install`. Thus, this is a
@ -453,17 +418,13 @@ class Filters:
#:
#: :parameter dict[str, str] plugin: the dict entry from the plugin index. It
#: includes an additional "index" key which contains the plugin index URL.
PLUGIN_INDEX_ENTRY_TO_INSTALL: Filter[dict[str, str], []] = filters.get(
"plugins:indexes:entries:install"
)
PLUGIN_INDEX_ENTRY_TO_INSTALL: Filter[dict[str, str], []] = Filter()
#: Information about each installed plugin, including its version.
#: Keep this information to a single line for easier parsing by 3rd-party scripts.
#:
#: :param list[tuple[str, str]] versions: each pair is a ``(plugin, info)`` tuple.
PLUGINS_INFO: Filter[list[tuple[str, str]], []] = filters.get(
"plugins:installed:versions"
)
PLUGINS_INFO: Filter[list[tuple[str, str]], []] = Filter()
#: List of installed plugins. In order to be added to this list, a plugin must first
#: be discovered (see :py:data:`Actions.CORE_READY`).
@ -471,13 +432,13 @@ class Filters:
#: :param list[str] plugins: plugin developers probably don't have to implement this
#: filter themselves, but they can apply it to check for the presence of other
#: plugins.
PLUGINS_INSTALLED: Filter[list[str], []] = filters.get("plugins:installed")
PLUGINS_INSTALLED: Filter[list[str], []] = Filter()
#: List of loaded plugins.
#:
#: :param list[str] plugins: plugin developers probably don't have to modify this
#: filter themselves, but they can apply it to check whether other plugins are enabled.
PLUGINS_LOADED: Filter[list[str], []] = filters.get("plugins:loaded")
PLUGINS_LOADED: Filter[list[str], []] = Filter()
class Contexts:
@ -497,10 +458,16 @@ class Contexts:
hooks.Filters.MY_FILTER.apply_from_context(hooks.Contexts.SOME_CONTEXT.name)
"""
#: We enter this context whenever we create hooks for a specific application or :
#: plugin. For instance, plugin "myplugin" will be enabled within the "app:myplugin"
#: context.
APP = ContextTemplate("app:{0}")
#: Dictionary of name/contexts. Each value is a context that we enter whenever we
#: create hooks for a specific application or : : plugin. For instance, plugin
#: "myplugin" will be enabled within the "app:myplugin" : context.
APP: dict[str, Context] = {}
@classmethod
def app(cls, name: str) -> Context:
if name not in cls.APP:
cls.APP[name] = Context(f"app:{name}")
return cls.APP[name]
#: Plugins will be installed and enabled within this context.
PLUGINS = Context("plugins")

View File

@ -12,19 +12,24 @@ from tutor.types import Config, get_typed
# Import modules to trigger hook creation
from . import v0, v1
# Cache of plugin patches, for efficiency
ENV_PATCHES_DICT: dict[str, list[str]] = {}
@hooks.Actions.PLUGINS_LOADED.add()
def _convert_plugin_patches() -> None:
"""
Some patches are added as (name, content) tuples with the ENV_PATCHES
filter. We convert these patches to add them to ENV_PATCH. This makes it
filter. We convert these patches to add them to ENV_PATCHES_DICT. This makes it
easier for end-user to declare patches, and it's more performant.
This action is run after plugins have been loaded.
"""
ENV_PATCHES_DICT.clear()
patches: t.Iterable[tuple[str, str]] = hooks.Filters.ENV_PATCHES.iterate()
for name, content in patches:
hooks.Filters.ENV_PATCH(name).add_item(content)
ENV_PATCHES_DICT.setdefault(name, [])
ENV_PATCHES_DICT[name].append(content)
def is_installed(name: str) -> bool:
@ -89,8 +94,8 @@ def load(name: str) -> None:
if not is_installed(name):
raise exceptions.TutorError(f"plugin '{name}' is not installed.")
with hooks.Contexts.PLUGINS.enter():
with hooks.Contexts.APP(name).enter():
hooks.Actions.PLUGIN_LOADED(name).do()
with hooks.Contexts.app(name).enter():
hooks.Actions.PLUGIN_LOADED.do(name)
hooks.Filters.PLUGINS_LOADED.add_item(name)
@ -109,14 +114,14 @@ def iter_patches(name: str) -> t.Iterator[str]:
"""
Yields: patch (str)
"""
yield from hooks.Filters.ENV_PATCH(name).iterate()
yield from ENV_PATCHES_DICT.get(name, [])
def unload(plugin: str) -> None:
"""
Remove all filters and actions associated to a given plugin.
"""
hooks.clear_all(context=hooks.Contexts.APP(plugin).name)
hooks.clear_all(context=hooks.Contexts.app(plugin).name)
@hooks.Actions.PLUGIN_UNLOADED.add(priority=50)

View File

@ -60,7 +60,10 @@ class BasePlugin:
hooks.Filters.PLUGINS_INFO.add_item((self.name, self._version() or ""))
# Create actions and filters on load
hooks.Actions.PLUGIN_LOADED(self.name).add()(self.__load)
@hooks.Actions.PLUGIN_LOADED.add()
def _load_plugin(name: str) -> None:
if name == self.name:
self.__load()
def __load(self) -> None:
"""

View File

@ -43,16 +43,17 @@ def discover_module(path: str) -> None:
hooks.Filters.PLUGINS_INFO.add_item((name, path))
# Import module on enable
load_plugin_action = hooks.Actions.PLUGIN_LOADED(name)
@load_plugin_action.add()
def load() -> None:
# https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
spec = importlib.util.spec_from_file_location("tutor.plugin.v1.{name}", path)
if spec is None or spec.loader is None:
raise ValueError("Plugin could not be found: {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
@hooks.Actions.PLUGIN_LOADED.add()
def load(plugin_name: str) -> None:
if name == plugin_name:
# https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
spec = importlib.util.spec_from_file_location(
"tutor.plugin.v1.{name}", path
)
if spec is None or spec.loader is None:
raise ValueError("Plugin could not be found: {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
def discover_package(entrypoint: pkg_resources.EntryPoint) -> None:
@ -70,8 +71,7 @@ def discover_package(entrypoint: pkg_resources.EntryPoint) -> None:
hooks.Filters.PLUGINS_INFO.add_item((name, entrypoint.dist.version))
# Import module on enable
load_plugin_action = hooks.Actions.PLUGIN_LOADED(name)
@load_plugin_action.add()
def load() -> None:
entrypoint.load()
@hooks.Actions.PLUGIN_LOADED.add()
def load(plugin_name: str) -> None:
if name == plugin_name:
entrypoint.load()

View File

@ -258,6 +258,7 @@ Tutor may not work if Docker is configured with < 4 GB RAM. Please follow instru
https://docs.tutor.overhang.io/install.html"""
)
def check_macos_docker_memory() -> None:
"""
Try to check that the RAM allocated to the Docker VM on macOS is at least 4 GB.