8 |
--------------------------------------------------------------------------------
/docs/api.rst:
--------------------------------------------------------------------------------
1 | ===
2 | API
3 | ===
4 |
5 |
6 | .. automodule:: flask_pluginengine
7 | :members: uses, depends, render_plugin_template, url_for_plugin
8 |
9 | PluginEngine
10 | ------------
11 |
12 | .. autoclass:: PluginEngine
13 | :members:
14 |
15 | Plugin
16 | ------
17 |
18 | .. autoclass:: Plugin
19 | :members: init
20 |
21 | .. automethod:: plugin_context()
22 | .. classmethod:: instance
23 |
24 | The Plugin instance used by the current app
25 |
26 | .. classmethod:: title
27 |
28 | Plugin's title from the docstring
29 |
30 | .. classmethod:: description
31 |
32 | Plugin's description from the docstring
--------------------------------------------------------------------------------
/docs/application.rst:
--------------------------------------------------------------------------------
1 | ===========
2 | Application
3 | ===========
4 |
5 | Minimal application setup
6 | -------------------------
7 |
8 | To create a Flask application using Flask-PluginEngine we need to define it as a ``PluginFlask`` application. It's a regular ``Flask`` application object that can handle plugins::
9 |
10 | from flask_pluginengine import PluginFlask
11 | app = PluginFlask(__name__)
12 |
13 | @app.route("/")
14 | def hello():
15 | return "Hello world!"
16 |
17 | if __name__ == "__main__":
18 | app.run()
19 |
20 | Now we need to set up the configuration, specifically the namespace of the plugins and the plugins we want to use.
21 | Let's use the ``example_plugin`` we created before. For example::
22 |
23 | app.config['PLUGINENGINE_NAMESPACE'] = 'example'
24 | app.config['PLUGINENGINE_PLUGINS'] = ['example_plugin']
25 |
26 | Next we need to create an instance of PluginEngine for our app and load the plugins::
27 |
28 | plugin_engine = PluginEngine(app=app)
29 | plugin_engine.load_plugins(app)
30 |
31 | Then, we can access the loaded plugins by calling the :func:`get_active_plugins` method, which will return a dictionary containing the active plugins.::
32 |
33 | active_plugins = plugin_engine.get_active_plugins(app=app).values()
34 |
35 | To check if all of the plugins were loaded correctly we can also call the :func:`get_failed_plugins` method that will return a dictionary with plugins that failed to load.
36 |
37 | Example application functionality
38 | ---------------------------------
39 |
40 | To understand the possibilities of Flask-PluginEngine we will create a Jinja template where we will list all the plugins we are using.
41 | Let's create two templates, ``base.html`` and ``index.html``.
42 | Our files should be structured like below, where ``app.py`` is the file with the application code:
43 |
44 | .. code-block:: text
45 |
46 | app
47 | ├── app.py
48 | └── templates
49 | ├── base.html
50 | └── index.html
51 |
52 | base.html
53 | ::
54 |
55 |
56 |
57 |
58 |
59 | Flask-PluginEngine example
60 |
61 |
62 | {% block content %}
63 | {% endblock %}
64 |
65 |
66 |
67 | index.html
68 | ::
69 |
70 | {% extends 'base.html' %}
71 |
72 | {% block content %}
73 | {% for plugin in plugins %}
74 |
{{ plugin.title }} - {{ plugin.description }}
75 | {% endfor %}
76 | {% endblock %}
77 |
78 | And let's pass the plugins, when rendering the template in our application::
79 |
80 | @app.route("/")
81 | def hello():
82 | return render_template('index.html', plugins=active_plugins)
83 |
84 | Now what we should also do is to register the blueprints of all our plugins, for instance::
85 |
86 | for plugin in active_plugins:
87 | with plugin.plugin_context():
88 | app.register_blueprint(plugin.get_blueprint())
89 |
90 | So our application code looks like this::
91 |
92 | from flask import render_template
93 | from flask_pluginengine import PluginFlask, PluginEngine
94 |
95 | app = PluginFlask(__name__)
96 |
97 | app.config['PLUGINENGINE_NAMESPACE'] = 'example'
98 | app.config['PLUGINENGINE_PLUGINS'] = ['example_plugin']
99 |
100 | plugin_engine = PluginEngine(app=app)
101 | plugin_engine.load_plugins(app)
102 | active_plugins = plugin_engine.get_active_plugins(app=app).values()
103 |
104 | for plugin in active_plugins:
105 | with plugin.plugin_context():
106 | app.register_blueprint(plugin.get_blueprint())
107 |
108 |
109 | @app.route('/')
110 | def hello():
111 | return render_template('index.html', plugins=active_plugins)
112 |
113 |
114 | if __name__ == '__main__':
115 | app.run()
116 |
117 | Now when we go to the index page of our application we can access the plugin's template.
118 | We can also directly access it if we go to `/plugin`.
119 |
120 | You can find the source code of the application in the `example folder `_.
121 |
122 | Configuring Flask-PluginEngine
123 | ------------------------------
124 |
125 | The following configuration values exist for Flask-PluginEngine:
126 |
127 | ====================================== ===========================================
128 | ``PLUGINENGINE_NAMESPACE`` Specifies a namespace of the plugins
129 | ``PLUGINENGINE_PLUGINS`` List of plugins the application will be
130 | using
131 | ====================================== ===========================================
132 |
--------------------------------------------------------------------------------
/docs/changelog.rst:
--------------------------------------------------------------------------------
1 | .. include:: ../CHANGES.rst
2 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | #
2 | # Flask-PluginEngine documentation build configuration file, created by
3 | # sphinx-quickstart on Tue Mar 28 14:14:23 2017.
4 | #
5 | # This file is execfile()d with the current directory set to its
6 | # containing dir.
7 | #
8 | # Note that not all possible configuration values are present in this
9 | # autogenerated file.
10 | #
11 | # All configuration values have a default; values that are commented out
12 | # serve to show the default.
13 |
14 | # If extensions (or modules to document with autodoc) are in another directory,
15 | # add these directories to sys.path here. If the directory is relative to the
16 | # documentation root, use os.path.abspath to make it absolute, like shown here.
17 | #
18 | import os
19 | import sys
20 | sys.path.insert(0, os.path.abspath('.'))
21 |
22 |
23 | # -- General configuration ------------------------------------------------
24 |
25 | # If your documentation needs a minimal Sphinx version, state it here.
26 | #
27 | # needs_sphinx = '1.0'
28 |
29 | # Add any Sphinx extension module names here, as strings. They can be
30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
31 | # ones.
32 | extensions = ['sphinx.ext.autodoc']
33 |
34 | # Add any paths that contain templates here, relative to this directory.
35 | templates_path = ['_templates']
36 |
37 | # The suffix(es) of source filenames.
38 | # You can specify multiple suffix as a list of string:
39 | #
40 | # source_suffix = ['.rst', '.md']
41 | source_suffix = '.rst'
42 |
43 | # The master toctree document.
44 | master_doc = 'index'
45 |
46 | # General information about the project.
47 | project = 'Flask-PluginEngine'
48 | copyright = '2017-2023, Indico Team'
49 | author = 'Indico Team'
50 |
51 | # The version info for the project you're documenting, acts as replacement for
52 | # |version| and |release|, also used in various other places throughout the
53 | # built documents.
54 | import importlib.metadata
55 | try:
56 | release = importlib.metadata.version('flask-pluginengine')
57 | except importlib.metadata.PackageNotFoundError:
58 | print('To build the documentation, the distribution information')
59 | print('of Flask-PluginEngine has to be available.')
60 | sys.exit(1)
61 | del importlib
62 |
63 | if 'dev' in release:
64 | release = release.split('dev')[0] + 'dev'
65 | version = '.'.join(release.split('.')[:2])
66 |
67 | # The language for content autogenerated by Sphinx. Refer to documentation
68 | # for a list of supported languages.
69 | #
70 | # This is also used if you do content translation via gettext catalogs.
71 | # Usually you set "language" from the command line for these cases.
72 | language = 'en'
73 |
74 | # List of patterns, relative to source directory, that match files and
75 | # directories to ignore when looking for source files.
76 | # This patterns also effect to html_static_path and html_extra_path
77 | exclude_patterns = ['build', 'Thumbs.db', '.DS_Store']
78 |
79 | # The name of the Pygments (syntax highlighting) style to use.
80 | pygments_style = 'sphinx'
81 |
82 | # If true, `todo` and `todoList` produce output, else they produce nothing.
83 | todo_include_todos = False
84 |
85 |
86 | # -- Options for HTML output ----------------------------------------------
87 |
88 | # The theme to use for HTML and HTML Help pages. See the documentation for
89 | # a list of builtin themes.
90 | #
91 | html_theme_path = ['_themes']
92 | html_theme = 'flask'
93 | html_theme_options = {
94 | 'index_logo_height': '120px',
95 | 'index_logo': 'pluginengine-long.png',
96 | 'github_fork': 'indico/flask-pluginengine'
97 | }
98 |
99 | html_sidebars = {
100 | 'index': ['sidebarintro.html', 'sourcelink.html', 'searchbox.html'],
101 | '**': ['sidebarlogo.html', 'localtoc.html', 'relations.html', 'sourcelink.html', 'searchbox.html']
102 | }
103 |
104 | # Theme options are theme-specific and customize the look and feel of a theme
105 | # further. For a list of options available for each theme, see the
106 | # documentation.
107 | #
108 |
109 | # Add any paths that contain custom static files (such as style sheets) here,
110 | # relative to this directory. They are copied after the builtin static files,
111 | # so a file named "default.css" will overwrite the builtin "default.css".
112 | html_static_path = ['_static']
113 |
114 |
115 | # -- Options for HTMLHelp output ------------------------------------------
116 |
117 | # Output file base name for HTML help builder.
118 | htmlhelp_basename = 'Flask-PluginEnginedoc'
119 |
120 |
121 | # -- Options for LaTeX output ---------------------------------------------
122 |
123 | latex_elements = {
124 | # The paper size ('letterpaper' or 'a4paper').
125 | #
126 | # 'papersize': 'letterpaper',
127 |
128 | # The font size ('10pt', '11pt' or '12pt').
129 | #
130 | # 'pointsize': '10pt',
131 |
132 | # Additional stuff for the LaTeX preamble.
133 | #
134 | # 'preamble': '',
135 |
136 | # Latex figure (float) alignment
137 | #
138 | # 'figure_align': 'htbp',
139 | }
140 |
141 | # Grouping the document tree into LaTeX files. List of tuples
142 | # (source start file, target name, title,
143 | # author, documentclass [howto, manual, or own class]).
144 | latex_documents = [
145 | (master_doc, 'Flask-PluginEngine.tex', 'Flask-PluginEngine Documentation',
146 | 'Indico', 'manual'),
147 | ]
148 |
149 |
150 | # -- Options for manual page output ---------------------------------------
151 |
152 | # One entry per manual page. List of tuples
153 | # (source start file, name, description, authors, manual section).
154 | man_pages = [
155 | (master_doc, 'flask-pluginengine', 'Flask-PluginEngine Documentation',
156 | [author], 1)
157 | ]
158 |
159 |
160 | # -- Options for Texinfo output -------------------------------------------
161 |
162 | # Grouping the document tree into Texinfo files. List of tuples
163 | # (source start file, target name, title, author,
164 | # dir menu entry, description, category)
165 | texinfo_documents = [
166 | (master_doc, 'Flask-PluginEngine', 'Flask-PluginEngine Documentation',
167 | author, 'Flask-PluginEngine', 'One line description of project.',
168 | 'Miscellaneous'),
169 | ]
170 |
171 |
172 | autodoc_member_order = 'bysource'
173 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | ==================
2 | Flask-PluginEngine
3 | ==================
4 |
5 | Flask-PluginEngine allows you to define and use different plugins in your Flask application
6 |
7 |
8 | Installation
9 | ------------
10 |
11 | You can install Flask-PluginEngine with the following command::
12 |
13 | $ pip install Flask-PluginEngine
14 |
15 | User’s Guide
16 | ------------
17 |
18 | This part of the documentation will show you how to get started in using Flask-PluginEngine with Flask.
19 |
20 | .. toctree::
21 | :maxdepth: 2
22 |
23 | application
24 | plugin
25 |
26 | API Reference
27 | -------------
28 | Informations on a specific functions, classes or methods.
29 |
30 | .. toctree::
31 | :maxdepth: 2
32 |
33 | api
34 |
35 | Changelog
36 | ---------
37 | List of released versions of Flask-PluginEngine can be found here.
38 |
39 | .. toctree::
40 | :maxdepth: 2
41 |
42 | changelog
43 |
44 | Indices and tables
45 | ------------------
46 | * :ref:`genindex`
47 | * :ref:`modindex`
48 | * :ref:`search`
49 |
--------------------------------------------------------------------------------
/docs/plugin.rst:
--------------------------------------------------------------------------------
1 | ======
2 | Plugin
3 | ======
4 |
5 | Creating a plugin
6 | -----------------
7 |
8 | Creating a plugin is simple. First of all, you need to create the necessary files: a file for your plugin code (in our case: ``example_plugin.py``), ``setup.py`` and
9 | a ``templates`` folder, with a Jinja template inside.
10 | It should be structured like below:
11 |
12 | .. code-block:: text
13 |
14 | plugin
15 | ├── example_plugin.py
16 | ├── setup.py
17 | └── templates
18 | └── index.html
19 |
20 | In ``example_plugin.py`` you need to create a class for your plugin inheriting from ``Plugin``::
21 |
22 | from flask_pluginengine import Plugin
23 |
24 | class ExamplePlugin(Plugin):
25 | """ExamplePlugin
26 |
27 | Just an example plugin
28 | """
29 |
30 | Second, you need to fill the ``setup.py``. For example::
31 |
32 | from setuptools import setup
33 |
34 | setup(
35 | name='Example-Plugin',
36 | version='0.0.1',
37 | py_modules=['example_plugin'],
38 | entry_points={'example': {'example_plugin = example_plugin:ExamplePlugin'}}
39 | )
40 |
41 | It's important to define a proper entry point to the plugin's class: ``'example'`` is the namespace we are going to use later in the application's configuration.
42 |
43 | Now you can install the plugin running in console::
44 |
45 | $ pip install -e .
46 |
47 | Current plugin
48 | --------------
49 |
50 | Very useful feature of Flask-PluginEngine is the ``current_plugin`` global value. It's a proxy to the currently active plugin.
51 |
52 | Example plugin functionality
53 | ----------------------------
54 |
55 | Now let's give our plugin some functionality to show how you can use it. In this example we will render the plugin's template from our application.
56 |
57 | Let our ``index.html`` template display for example the plugin's title and description::
58 |
59 | {% extends 'base.html' %}
60 |
61 | {% block content %}
62 |
{{ plugin.title }}
63 |
{{ plugin.description }}
64 | {% endblock %}
65 |
66 | Notice that the template extends a ``base.html``. We are going to use the application's base template.
67 |
68 | Let's create a blueprint in ``example_plugin.py``::
69 |
70 | plugin_blueprint = PluginBlueprint('example', __name__)
71 |
72 | In order to create a blueprint of a plugin we need to create a ``PluginBlueprint`` instance.
73 |
74 | Then we can create a function rendering the template::
75 |
76 | @plugin_blueprint.route('/plugin')
77 | def hello_plugin():
78 | return render_template('example_plugin:index.html', plugin=current_plugin)
79 |
80 | We can render any plugin template inside our application specifying the name of the plugin before the template name in the ``render_template`` function, like above.
81 | We are also passing a ``current_plugin`` object to the template.
82 |
83 |
84 | Now, in the plugin class we can create a method returning the plugin's blueprint::
85 |
86 | def get_blueprint(self):
87 | return plugin_blueprint
88 |
89 | So our plugin code should look like this::
90 |
91 | from flask import render_template
92 | from flask_pluginengine import Plugin, PluginBlueprint, current_plugin
93 |
94 | plugin_blueprint = PluginBlueprint('example', __name__)
95 |
96 |
97 | @plugin_blueprint.route('/plugin')
98 | def hello_plugin():
99 | return render_template('example_plugin:index.html', plugin=current_plugin)
100 |
101 |
102 | class ExamplePlugin(Plugin):
103 | """ExamplePlugin
104 |
105 | Just an example plugin
106 | """
107 |
108 | def get_blueprint(self):
109 | return plugin_blueprint
110 |
111 |
112 | To get more information see the `Plugin`_ API reference.
113 | You can find the source code of the plugin in the `example folder `_.
114 |
--------------------------------------------------------------------------------
/example/app/app.py:
--------------------------------------------------------------------------------
1 | # This file is part of Flask-PluginEngine.
2 | # Copyright (C) 2014-2021 CERN
3 | #
4 | # Flask-PluginEngine is free software; you can redistribute it
5 | # and/or modify it under the terms of the Revised BSD License.
6 |
7 | from flask import render_template
8 | from flask_pluginengine import PluginFlask, PluginEngine
9 |
10 | app = PluginFlask(__name__)
11 |
12 | app.config['PLUGINENGINE_NAMESPACE'] = 'example'
13 | app.config['PLUGINENGINE_PLUGINS'] = ['example_plugin']
14 |
15 | plugin_engine = PluginEngine(app=app)
16 | plugin_engine.load_plugins(app)
17 | active_plugins = plugin_engine.get_active_plugins(app=app).values()
18 |
19 | for plugin in active_plugins:
20 | with plugin.plugin_context():
21 | app.register_blueprint(plugin.get_blueprint())
22 |
23 |
24 | @app.route('/')
25 | def hello():
26 | return render_template('index.html', plugins=active_plugins)
27 |
28 |
29 | if __name__ == '__main__':
30 | app.run()
31 |
--------------------------------------------------------------------------------
/example/app/templates/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Title
6 |
7 |
8 | {% block content %}
9 | {% endblock %}
10 |
11 |
12 |
--------------------------------------------------------------------------------
/example/app/templates/index.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% block content %}
4 | {% for plugin in plugins %}
5 |
{{ plugin.title }} - {{ plugin.description }}
6 | {% endfor %}
7 | Plugin page
8 | {% endblock %}
9 |
--------------------------------------------------------------------------------
/example/plugin/example_plugin.py:
--------------------------------------------------------------------------------
1 | # This file is part of Flask-PluginEngine.
2 | # Copyright (C) 2014-2021 CERN
3 | #
4 | # Flask-PluginEngine is free software; you can redistribute it
5 | # and/or modify it under the terms of the Revised BSD License.
6 |
7 | from flask import render_template
8 | from flask_pluginengine import Plugin, PluginBlueprint, current_plugin
9 |
10 | plugin_blueprint = PluginBlueprint('example', __name__)
11 |
12 |
13 | @plugin_blueprint.route('/plugin')
14 | def hello_plugin():
15 | return render_template('example_plugin:index.html', plugin=current_plugin)
16 |
17 |
18 | class ExamplePlugin(Plugin):
19 | """ExamplePlugin
20 |
21 | Just an example plugin
22 | """
23 |
24 | def get_blueprint(self):
25 | return plugin_blueprint
26 |
--------------------------------------------------------------------------------
/example/plugin/setup.py:
--------------------------------------------------------------------------------
1 | # This file is part of Flask-PluginEngine.
2 | # Copyright (C) 2014-2021 CERN
3 | #
4 | # Flask-PluginEngine is free software; you can redistribute it
5 | # and/or modify it under the terms of the Revised BSD License.
6 |
7 | from setuptools import setup
8 |
9 | setup(
10 | name='Example-Plugin',
11 | version='0.0.1',
12 | py_modules=['example_plugin'],
13 | entry_points={'example': {'example_plugin = example_plugin:ExamplePlugin'}}
14 | )
15 |
--------------------------------------------------------------------------------
/example/plugin/templates/index.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% block content %}
4 |
{{ plugin.title }}
5 |
{{ plugin.description }}
6 | {% endblock %}
7 |
--------------------------------------------------------------------------------
/flask_pluginengine/__init__.py:
--------------------------------------------------------------------------------
1 | # This file is part of Flask-PluginEngine.
2 | # Copyright (C) 2014-2021 CERN
3 | #
4 | # Flask-PluginEngine is free software; you can redistribute it
5 | # and/or modify it under the terms of the Revised BSD License.
6 |
7 | from .engine import PluginEngine
8 | from .globals import current_plugin
9 | from .mixins import (PluginBlueprint, PluginBlueprintMixin, PluginBlueprintSetupState, PluginBlueprintSetupStateMixin,
10 | PluginFlask, PluginFlaskMixin)
11 | from .plugin import Plugin, depends, render_plugin_template, url_for_plugin, uses
12 | from .signals import plugins_loaded
13 | from .templating import PluginPrefixLoader
14 | from .util import plugin_context, trim_docstring, with_plugin_context, wrap_in_plugin_context
15 |
16 |
17 | __version__ = '0.5'
18 | __all__ = ('PluginEngine', 'current_plugin', 'PluginBlueprintSetupState', 'PluginBlueprintSetupStateMixin',
19 | 'PluginBlueprint', 'PluginBlueprintMixin', 'PluginFlask', 'PluginFlaskMixin', 'Plugin', 'uses', 'depends',
20 | 'render_plugin_template', 'url_for_plugin', 'plugins_loaded', 'PluginPrefixLoader', 'with_plugin_context',
21 | 'wrap_in_plugin_context', 'trim_docstring', 'plugin_context')
22 |
--------------------------------------------------------------------------------
/flask_pluginengine/engine.py:
--------------------------------------------------------------------------------
1 | # This file is part of Flask-PluginEngine.
2 | # Copyright (C) 2014-2021 CERN
3 | #
4 | # Flask-PluginEngine is free software; you can redistribute it
5 | # and/or modify it under the terms of the Revised BSD License.
6 |
7 | from flask import current_app
8 | from flask.helpers import get_root_path
9 | from importlib_metadata import entry_points as importlib_entry_points
10 | from werkzeug.datastructures import ImmutableDict
11 |
12 | from .plugin import Plugin
13 | from .signals import plugins_loaded
14 | from .util import get_state, resolve_dependencies
15 |
16 |
17 | class PluginEngine:
18 | plugin_class = Plugin
19 |
20 | def __init__(self, app=None, **kwargs):
21 | self.logger = None
22 | if app is not None:
23 | self.init_app(app, **kwargs)
24 |
25 | def init_app(self, app, logger=None):
26 | app.extensions['pluginengine'] = _PluginEngineState(self, app, logger or app.logger)
27 | app.config.setdefault('PLUGINENGINE_PLUGINS', {})
28 | if not app.config.get('PLUGINENGINE_NAMESPACE'):
29 | raise Exception('PLUGINENGINE_NAMESPACE is not set')
30 |
31 | def load_plugins(self, app, skip_failed=True):
32 | """Load all plugins for an application.
33 |
34 | :param app: A Flask application
35 | :param skip_failed: If True, initialize plugins even if some
36 | plugins could not be loaded.
37 | :return: True if all plugins could have been loaded, False otherwise.
38 | """
39 | state = get_state(app)
40 | if state.plugins_loaded:
41 | raise RuntimeError(f'Plugins already loaded for {state.app}')
42 | state.plugins_loaded = True
43 | plugins = self._import_plugins(state.app)
44 | if state.failed and not skip_failed:
45 | return False
46 | for name, cls in resolve_dependencies(plugins):
47 | instance = cls(self, state.app)
48 | state.plugins[name] = instance
49 | plugins_loaded.send(app)
50 | return not state.failed
51 |
52 | def _import_plugins(self, app):
53 | """Import the plugins for an application.
54 |
55 | :param app: A Flask application
56 | :return: A dict mapping plugin names to plugin classes
57 | """
58 | state = get_state(app)
59 | plugins = {}
60 | for name in state.app.config['PLUGINENGINE_PLUGINS']:
61 | entry_points = importlib_entry_points(group=app.config['PLUGINENGINE_NAMESPACE'], name=name)
62 | if not entry_points:
63 | state.logger.error('Plugin %s does not exist', name)
64 | state.failed.add(name)
65 | continue
66 | elif len(entry_points) > 1:
67 | defs = ', '.join(ep.module for ep in entry_points)
68 | state.logger.error('Plugin name %s is not unique (defined in %s)', name, defs)
69 | state.failed.add(name)
70 | continue
71 | entry_point = list(entry_points)[0]
72 | try:
73 | plugin_class = entry_point.load()
74 | except ImportError:
75 | state.logger.exception('Could not load plugin %s', name)
76 | state.failed.add(name)
77 | continue
78 | if not issubclass(plugin_class, self.plugin_class):
79 | state.logger.error('Plugin %s does not inherit from %s', name, self.plugin_class.__name__)
80 | state.failed.add(name)
81 | continue
82 | plugin_class.package_name = entry_point.module.split('.')[0]
83 | plugin_class.package_version = entry_point.dist.version
84 | if plugin_class.version is None:
85 | plugin_class.version = plugin_class.package_version
86 | plugin_class.name = name
87 | plugin_class.root_path = get_root_path(entry_point.module)
88 | plugins[name] = plugin_class
89 | return plugins
90 |
91 | def get_failed_plugins(self, app=None):
92 | """Return the list of plugins which could not be loaded.
93 |
94 | :param app: A Flask app. Defaults to the current app.
95 | """
96 | state = get_state(app or current_app)
97 | return frozenset(state.failed)
98 |
99 | def get_active_plugins(self, app=None):
100 | """Return the currently active plugins.
101 |
102 | :param app: A Flask app. Defaults to the current app.
103 | :return: dict mapping plugin names to plugin instances
104 | """
105 | state = get_state(app or current_app)
106 | return ImmutableDict(state.plugins)
107 |
108 | def has_plugin(self, name, app=None):
109 | """Return if a plugin is loaded in the current app.
110 |
111 | :param name: Plugin name
112 | :param app: A Flask app. Defaults to the current app.
113 | """
114 | state = get_state(app or current_app)
115 | return name in state.plugins
116 |
117 | def get_plugin(self, name, app=None):
118 | """Return a specific plugin of the current app.
119 |
120 | :param name: Plugin name
121 | :param app: A Flask app. Defaults to the current app.
122 | """
123 | state = get_state(app or current_app)
124 | return state.plugins.get(name)
125 |
126 | def __repr__(self):
127 | return ''
128 |
129 |
130 | class _PluginEngineState:
131 | def __init__(self, plugin_engine, app, logger):
132 | self.plugin_engine = plugin_engine
133 | self.app = app
134 | self.logger = logger
135 | self.plugins = {}
136 | self.failed = set()
137 | self.plugins_loaded = False
138 |
139 | def __repr__(self):
140 | return f'<_PluginEngineState({self.plugin_engine}, {self.app}, {self.plugins})>'
141 |
--------------------------------------------------------------------------------
/flask_pluginengine/globals.py:
--------------------------------------------------------------------------------
1 | # This file is part of Flask-PluginEngine.
2 | # Copyright (C) 2014-2021 CERN
3 | #
4 | # Flask-PluginEngine is free software; you can redistribute it
5 | # and/or modify it under the terms of the Revised BSD License.
6 |
7 | from werkzeug.local import LocalProxy, LocalStack
8 |
9 |
10 | _plugin_ctx_stack = LocalStack()
11 |
12 | #: Proxy to the currently active plugin
13 | current_plugin = LocalProxy(lambda: _plugin_ctx_stack.top)
14 |
--------------------------------------------------------------------------------
/flask_pluginengine/mixins.py:
--------------------------------------------------------------------------------
1 | # This file is part of Flask-PluginEngine.
2 | # Copyright (C) 2014-2021 CERN
3 | #
4 | # Flask-PluginEngine is free software; you can redistribute it
5 | # and/or modify it under the terms of the Revised BSD License.
6 |
7 | from flask import Blueprint, Flask
8 | from flask.blueprints import BlueprintSetupState
9 | from jinja2 import ChoiceLoader
10 | from werkzeug.utils import cached_property
11 |
12 | from .globals import current_plugin
13 | from .templating import PluginEnvironment, PluginPrefixLoader
14 | from .util import wrap_in_plugin_context
15 |
16 |
17 | class PluginBlueprintSetupStateMixin:
18 | def add_url_rule(self, rule, endpoint=None, view_func=None, **options):
19 | func = view_func
20 | if view_func is not None:
21 | plugin = current_plugin._get_current_object()
22 | func = wrap_in_plugin_context(plugin, view_func)
23 |
24 | super().add_url_rule(rule, endpoint, func, **options)
25 |
26 |
27 | class PluginBlueprintMixin:
28 | def __init__(self, name, *args, **kwargs):
29 | if 'template_folder' in kwargs:
30 | raise ValueError('Template folder cannot be specified')
31 | kwargs.setdefault('static_folder', 'static')
32 | kwargs.setdefault('static_url_path', f'/static/plugins/{name}')
33 | name = f'plugin_{name}'
34 | super().__init__(name, *args, **kwargs)
35 |
36 | def make_setup_state(self, app, options, first_registration=False):
37 | return PluginBlueprintSetupState(self, app, options, first_registration)
38 |
39 | @cached_property
40 | def jinja_loader(self):
41 | return None
42 |
43 |
44 | class PluginFlaskMixin:
45 | plugin_jinja_loader = PluginPrefixLoader
46 | jinja_environment = PluginEnvironment
47 |
48 | def create_global_jinja_loader(self):
49 | default_loader = super().create_global_jinja_loader()
50 | return ChoiceLoader([self.plugin_jinja_loader(self), default_loader])
51 |
52 |
53 | class PluginBlueprintSetupState(PluginBlueprintSetupStateMixin, BlueprintSetupState):
54 | pass
55 |
56 |
57 | class PluginBlueprint(PluginBlueprintMixin, Blueprint):
58 | pass
59 |
60 |
61 | class PluginFlask(PluginFlaskMixin, Flask):
62 | pass
63 |
--------------------------------------------------------------------------------
/flask_pluginengine/plugin.py:
--------------------------------------------------------------------------------
1 | # This file is part of Flask-PluginEngine.
2 | # Copyright (C) 2014-2021 CERN
3 | #
4 | # Flask-PluginEngine is free software; you can redistribute it
5 | # and/or modify it under the terms of the Revised BSD License.
6 |
7 | from contextlib import contextmanager
8 |
9 | from flask import current_app, render_template, url_for
10 |
11 | from .globals import _plugin_ctx_stack, current_plugin
12 | from .util import classproperty, get_state, trim_docstring, wrap_in_plugin_context
13 |
14 |
15 | def depends(*plugins):
16 | """Adds dependencies for a plugin.
17 |
18 | This decorator adds the given dependencies to the plugin. Multiple
19 | dependencies can be specified using multiple arguments or by using
20 | the decorator multiple times.
21 |
22 | :param plugins: plugin names
23 | """
24 |
25 | def wrapper(cls):
26 | cls.required_plugins |= frozenset(plugins)
27 | return cls
28 |
29 | return wrapper
30 |
31 |
32 | def uses(*plugins):
33 | """Adds soft dependencies for a plugin.
34 |
35 | This decorator adds the given soft dependencies to the plugin.
36 | Multiple soft dependencies can be specified using multiple arguments
37 | or by using the decorator multiple times.
38 |
39 | Unlike dependencies, the specified plugins will be loaded before the
40 | plugin if possible, but if they are not available, the plugin will be
41 | loaded anyway.
42 |
43 | :param plugins: plugin names
44 | """
45 |
46 | def wrapper(cls):
47 | cls.used_plugins |= frozenset(plugins)
48 | return cls
49 |
50 | return wrapper
51 |
52 |
53 | def render_plugin_template(template_name_or_list, **context):
54 | """Renders a template from the plugin's template folder with the given context.
55 |
56 | If the template name contains a plugin name (``pluginname:name``), that
57 | name is used instead of the current plugin's name.
58 |
59 | :param template_name_or_list: the name of the template or an iterable
60 | containing template names (the first
61 | existing template is used)
62 | :param context: the variables that should be available in the
63 | context of the template.
64 | """
65 | if not isinstance(template_name_or_list, str):
66 | if not current_plugin and not all(':' in tpl for tpl in template_name_or_list):
67 | raise RuntimeError('render_plugin_template outside plugin context')
68 | template_name_or_list = [f'{current_plugin.name}:{tpl}' if ':' not in tpl else tpl
69 | for tpl in template_name_or_list]
70 | elif ':' not in template_name_or_list:
71 | if not current_plugin:
72 | raise RuntimeError('render_plugin_template outside plugin context')
73 | template_name_or_list = f'{current_plugin.name}:{template_name_or_list}'
74 | return render_template(template_name_or_list, **context)
75 |
76 |
77 | def url_for_plugin(endpoint, **values):
78 | """Like url_for but prepending plugin_ to endpoint."""
79 | endpoint = f'plugin_{endpoint}'
80 | return url_for(endpoint, **values)
81 |
82 |
83 | class Plugin:
84 | package_name = None # set to the containing package when the plugin is loaded
85 | package_version = None # set to the version of the containing package when the plugin is loaded
86 | version = None # set to the package_version if it's None when the plugin is loaded
87 | name = None # set to the entry point name when the plugin is loaded
88 | root_path = None # set to the path of the module containing the class when the plugin is loaded
89 | required_plugins = frozenset()
90 | used_plugins = frozenset()
91 |
92 | def __init__(self, plugin_engine, app):
93 | self.plugin_engine = plugin_engine
94 | self.app = app
95 | with self.app.app_context():
96 | with self.plugin_context():
97 | self.init()
98 |
99 | def init(self):
100 | """Initializes the plugin at application startup.
101 |
102 | Should be overridden in your plugin if you need initialization.
103 | Runs inside an application context.
104 | """
105 | pass
106 |
107 | @classproperty
108 | @classmethod
109 | def instance(cls):
110 | """The Plugin instance used by the current app"""
111 | instance = get_state(current_app).plugin_engine.get_plugin(cls.name)
112 | if instance is None:
113 | raise RuntimeError('Plugin is not active in the current app')
114 | return instance
115 |
116 | @classproperty
117 | @classmethod
118 | def title(cls):
119 | """The title of the plugin.
120 |
121 | Automatically retrieved from the docstring of the plugin class.
122 | """
123 | parts = trim_docstring(cls.__doc__).split('\n', 1)
124 | return parts[0].strip()
125 |
126 | @classproperty
127 | @classmethod
128 | def description(cls):
129 | """The description of the plugin.
130 |
131 | Automatically retrieved from the docstring of the plugin class.
132 | """
133 | parts = trim_docstring(cls.__doc__).split('\n', 1)
134 | try:
135 | return parts[1].strip()
136 | except IndexError:
137 | return 'no description available'
138 |
139 | @contextmanager
140 | def plugin_context(self):
141 | """Pushes the plugin on the plugin context stack."""
142 | _plugin_ctx_stack.push(self)
143 | try:
144 | yield
145 | finally:
146 | assert _plugin_ctx_stack.pop() is self, 'Popped wrong plugin'
147 |
148 | def connect(self, signal, receiver, **connect_kwargs):
149 | connect_kwargs['weak'] = False
150 | signal.connect(wrap_in_plugin_context(self, receiver), **connect_kwargs)
151 |
152 | def __repr__(self):
153 | return '<{}({}) bound to {}>'.format(type(self).__name__, self.name, self.app)
154 |
--------------------------------------------------------------------------------
/flask_pluginengine/signals.py:
--------------------------------------------------------------------------------
1 | # This file is part of Flask-PluginEngine.
2 | # Copyright (C) 2014-2021 CERN
3 | #
4 | # Flask-PluginEngine is free software; you can redistribute it
5 | # and/or modify it under the terms of the Revised BSD License.
6 |
7 | from blinker import Namespace
8 |
9 |
10 | _signals = Namespace()
11 | plugins_loaded = _signals.signal('plugins-loaded', """
12 | Called after :meth:`~PluginEngine.load_plugins` has loaded the
13 | plugins successfully. This triggers even if there are no enabled
14 | plugins. *sender* is the Flask app.
15 | """)
16 |
--------------------------------------------------------------------------------
/flask_pluginengine/templating.py:
--------------------------------------------------------------------------------
1 | # This file is part of Flask-PluginEngine.
2 | # Copyright (C) 2014-2021 CERN
3 | #
4 | # Flask-PluginEngine is free software; you can redistribute it
5 | # and/or modify it under the terms of the Revised BSD License.
6 |
7 | import os
8 |
9 | from flask import current_app
10 | from flask.templating import Environment
11 | from jinja2 import FileSystemLoader, PrefixLoader, Template, TemplateNotFound
12 | from jinja2.compiler import CodeGenerator
13 | from jinja2.runtime import Context, Macro
14 | from jinja2.utils import internalcode
15 |
16 | from .util import (get_state, plugin_name_from_template_name, wrap_iterator_in_plugin_context,
17 | wrap_macro_in_plugin_context)
18 |
19 |
20 | class PrefixIgnoringFileSystemLoader(FileSystemLoader):
21 | """FileSystemLoader loader handling plugin prefixes properly
22 |
23 | The prefix is preserved in the template name but not when actually
24 | accessing the file system since the files there do not have prefixes.
25 | """
26 |
27 | def get_source(self, environment, template):
28 | name = template.split(':', 1)[1]
29 | source, filename, uptodate = super().get_source(environment, name)
30 | return source, filename, uptodate
31 |
32 | def list_templates(self): # pragma: no cover
33 | raise TypeError('this loader cannot iterate over all templates')
34 |
35 |
36 | class PluginPrefixLoader(PrefixLoader):
37 | """Prefix loader that uses plugin names to select the load path"""
38 |
39 | def __init__(self, app):
40 | super().__init__(None, ':')
41 | self.app = app
42 |
43 | def get_loader(self, template):
44 | try:
45 | plugin_name, _ = template.split(self.delimiter, 1)
46 | except ValueError:
47 | raise TemplateNotFound(template)
48 | plugin = get_state(self.app).plugin_engine.get_plugin(plugin_name)
49 | if plugin is None:
50 | raise TemplateNotFound(template)
51 | loader = PrefixIgnoringFileSystemLoader(os.path.join(plugin.root_path, 'templates'))
52 | return loader, template
53 |
54 | def list_templates(self): # pragma: no cover
55 | raise TypeError('this loader cannot iterate over all templates')
56 |
57 | @internalcode
58 | def load(self, environment, name, globals=None):
59 | loader = self.get_loader(name)[0]
60 | tpl = loader.load(environment, name, globals)
61 | plugin_name = name.split(':', 1)[0]
62 | plugin = get_state(current_app).plugin_engine.get_plugin(plugin_name)
63 | if plugin is None: # pragma: no cover
64 | # that should never happen
65 | raise RuntimeError(f'Plugin template {name} has no plugin')
66 | # Keep a reference to the plugin so we don't have to get it from the name later
67 | tpl.plugin = plugin
68 | return tpl
69 |
70 |
71 | class PluginContextTemplate(Template):
72 | plugin = None # overridden on the instance level if a template is in a plugin
73 |
74 | @property
75 | def root_render_func(self):
76 | # Wraps the root render function in the plugin context.
77 | # That way we get the correct context when inheritance/includes are used
78 | return wrap_iterator_in_plugin_context(self.plugin, self._root_render_func)
79 |
80 | @root_render_func.setter
81 | def root_render_func(self, value):
82 | self._root_render_func = value
83 |
84 | def make_module(self, vars=None, shared=False, locals=None):
85 | # When creating a template module we need to wrap all macros in the plugin context
86 | # of the containing template in case they are called from another context
87 | module = super().make_module(vars, shared, locals)
88 | for macro in module.__dict__.values():
89 | if not isinstance(macro, Macro):
90 | continue
91 | wrap_macro_in_plugin_context(self.plugin, macro)
92 | return module
93 |
94 |
95 | class PluginJinjaContext(Context):
96 | @internalcode
97 | def call(__self, __obj, *args, **kwargs):
98 | # A caller must run in the containing template's context instead of the
99 | # one containing the macro. This is achieved by storing the plugin name
100 | # on the anonymous caller macro.
101 | if 'caller' in kwargs:
102 | caller = kwargs['caller']
103 | plugin = None
104 | if caller._plugin_name:
105 | plugin = get_state(current_app).plugin_engine.get_plugin(caller._plugin_name)
106 | wrap_macro_in_plugin_context(plugin, caller)
107 | return super().call(__obj, *args, **kwargs)
108 |
109 |
110 | class PluginCodeGenerator(CodeGenerator):
111 | def __init__(self, *args, **kwargs):
112 | super().__init__(*args, **kwargs)
113 | self.inside_call_blocks = []
114 |
115 | def visit_Template(self, node, frame=None):
116 | super().visit_Template(node, frame)
117 | plugin_name = plugin_name_from_template_name(self.name)
118 | # Execute all blocks inside the plugin context
119 | self.writeline('from flask_pluginengine.util import wrap_iterator_in_plugin_context')
120 | self.writeline(
121 | 'blocks = {name: wrap_iterator_in_plugin_context(%r, func) for name, func in blocks.items()}' % plugin_name
122 | )
123 |
124 | def visit_CallBlock(self, *args, **kwargs):
125 | sentinel = object()
126 | self.inside_call_blocks.append(sentinel)
127 | # ths parent's function ends up calling `macro_def` to create the macro function
128 | super().visit_CallBlock(*args, **kwargs)
129 | assert self.inside_call_blocks.pop() is sentinel
130 |
131 | def macro_def(self, *args, **kwargs):
132 | super().macro_def(*args, **kwargs)
133 | if self.inside_call_blocks:
134 | # we don't have access to the actual Template object here, but we do have
135 | # access to its name which gives us the plugin name.
136 | plugin_name = plugin_name_from_template_name(self.name)
137 | self.writeline(f'caller._plugin_name = {plugin_name!r}')
138 |
139 |
140 | class PluginEnvironmentMixin:
141 | code_generator_class = PluginCodeGenerator
142 | context_class = PluginJinjaContext
143 | template_class = PluginContextTemplate
144 |
145 |
146 | class PluginEnvironment(PluginEnvironmentMixin, Environment):
147 | pass
148 |
--------------------------------------------------------------------------------
/flask_pluginengine/util.py:
--------------------------------------------------------------------------------
1 | # This file is part of Flask-PluginEngine.
2 | # Copyright (C) 2014-2021 CERN
3 | #
4 | # Flask-PluginEngine is free software; you can redistribute it
5 | # and/or modify it under the terms of the Revised BSD License.
6 |
7 | import sys
8 | from contextlib import contextmanager
9 | from functools import wraps
10 | from types import FunctionType
11 |
12 | from flask import current_app
13 | from jinja2.utils import internalcode
14 |
15 | from .globals import _plugin_ctx_stack
16 |
17 |
18 | def get_state(app):
19 | """Get the application-specific plugine engine data."""
20 | assert 'pluginengine' in app.extensions, \
21 | 'The pluginengine extension was not registered to the current application. ' \
22 | 'Please make sure to call init_app() first.'
23 | return app.extensions['pluginengine']
24 |
25 |
26 | def resolve_dependencies(plugins):
27 | """Resolve dependencies between plugins and sort them accordingly.
28 |
29 | This function guarantees that a plugin is never loaded before any
30 | plugin it depends on. If multiple plugins are ready to be loaded,
31 | the order in which they are loaded is undefined and should not be
32 | relied upon. If you want a certain order, add a (soft) dependency!
33 |
34 | :param plugins: dict mapping plugin names to plugin classes
35 | """
36 | plugins_deps = {name: (cls.required_plugins, cls.used_plugins) for name, cls in plugins.items()}
37 | resolved_deps = set()
38 | while plugins_deps:
39 | # Get plugins with both hard and soft dependencies being met
40 | ready = {cls for cls, deps in plugins_deps.items() if all(d <= resolved_deps for d in deps)}
41 | if not ready:
42 | # Otherwise check for plugins with all hard dependencies being met
43 | ready = {cls for cls, deps in plugins_deps.items() if deps[0] <= resolved_deps}
44 | if not ready:
45 | # Either a circular dependency or a dependency that's not loaded
46 | raise Exception('Could not resolve dependencies between plugins')
47 | resolved_deps |= ready
48 | for name in ready:
49 | yield name, plugins[name]
50 | del plugins_deps[name]
51 |
52 |
53 | @contextmanager
54 | def plugin_context(plugin):
55 | """Enter a plugin context if a plugin is provided, otherwise clear it
56 |
57 | Useful for code which sometimes needs a plugin context, e.g.
58 | because it may be used in both the core and in a plugin.
59 | """
60 | if plugin is None:
61 | # Explicitly push a None plugin to disable an existing plugin context
62 | _plugin_ctx_stack.push(None)
63 | try:
64 | yield
65 | finally:
66 | assert _plugin_ctx_stack.pop() is None, 'Popped wrong plugin'
67 | else:
68 | with plugin.instance.plugin_context():
69 | yield
70 |
71 |
72 | class equality_preserving_decorator:
73 | """Decorator which is considered equal with the original function"""
74 | def __init__(self, orig_func):
75 | self.orig_func = orig_func
76 | self.wrapper = None
77 |
78 | def __call__(self, *args, **kwargs):
79 | if self.wrapper is None:
80 | assert len(args) == 1
81 | assert not kwargs
82 | self.wrapper = args[0]
83 | return self
84 | else:
85 | return self.wrapper(*args, **kwargs)
86 |
87 | def __eq__(self, other):
88 | if isinstance(other, FunctionType):
89 | return self.orig_func == other
90 | else:
91 | return self.orig_func == other.orig_func
92 |
93 | def __ne__(self, other):
94 | return not (self == other)
95 |
96 | def __hash__(self):
97 | return hash(self.orig_func)
98 |
99 | def __repr__(self):
100 | return f''
101 |
102 |
103 | def plugin_name_from_template_name(name):
104 | if not name:
105 | return None
106 | return name.split(':', 1)[0] if ':' in name else None
107 |
108 |
109 | def wrap_iterator_in_plugin_context(plugin, gen_or_func):
110 | """Run an iterator inside a plugin context"""
111 | # Heavily based on Flask's stream_with_context
112 | try:
113 | gen = iter(gen_or_func)
114 | except TypeError:
115 | @equality_preserving_decorator(gen_or_func)
116 | def decorator(*args, **kwargs):
117 | return wrap_iterator_in_plugin_context(plugin, gen_or_func(*args, **kwargs))
118 |
119 | return decorator
120 |
121 | if plugin is not None and isinstance(plugin, str):
122 | plugin = get_state(current_app).plugin_engine.get_plugin(plugin)
123 |
124 | @internalcode
125 | def generator():
126 | with plugin_context(plugin):
127 | # Dummy sentinel. Has to be inside the context block or we're
128 | # not actually keeping the context around.
129 | yield None
130 |
131 | yield from gen
132 |
133 | # The trick is to start the generator. Then the code execution runs until
134 | # the first dummy None is yielded at which point the context was already
135 | # pushed. This item is discarded. Then when the iteration continues the
136 | # real generator is executed.
137 | wrapped_g = generator()
138 | next(wrapped_g)
139 | return wrapped_g
140 |
141 |
142 | def wrap_macro_in_plugin_context(plugin, macro):
143 | """Wrap a macro inside a plugin context"""
144 | func = macro._func
145 |
146 | @internalcode
147 | @wraps(func)
148 | def decorator(*args, **kwargs):
149 | with plugin_context(plugin):
150 | return func(*args, **kwargs)
151 |
152 | macro._func = decorator
153 |
154 |
155 | class classproperty(property):
156 | def __get__(self, obj, type=None):
157 | return self.fget.__get__(None, type)()
158 |
159 |
160 | def make_hashable(obj):
161 | """Make an object containing dicts and lists hashable."""
162 | if isinstance(obj, list):
163 | return tuple(obj)
164 | elif isinstance(obj, dict):
165 | return frozenset((k, make_hashable(v)) for k, v in obj.items())
166 | return obj
167 |
168 |
169 | # http://wiki.python.org/moin/PythonDecoratorLibrary#Alternate_memoize_as_nested_functions
170 | def memoize(obj):
171 | cache = {}
172 |
173 | @wraps(obj)
174 | def memoizer(*args, **kwargs):
175 | key = (make_hashable(args), make_hashable(kwargs))
176 | if key not in cache:
177 | cache[key] = obj(*args, **kwargs)
178 | return cache[key]
179 |
180 | return memoizer
181 |
182 |
183 | @memoize
184 | def wrap_in_plugin_context(plugin, func):
185 | assert plugin is not None
186 |
187 | @wraps(func)
188 | def wrapped(*args, **kwargs):
189 | with plugin.plugin_context():
190 | return func(*args, **kwargs)
191 |
192 | return wrapped
193 |
194 |
195 | def with_plugin_context(plugin):
196 | """Decorator to ensure a function is always called in the given plugin context.
197 |
198 | :param plugin: Plugin instance
199 | """
200 | def decorator(f):
201 | return wrap_in_plugin_context(plugin, f)
202 |
203 | return decorator
204 |
205 |
206 | def trim_docstring(docstring):
207 | """Trim a docstring based on the algorithm in PEP 257
208 |
209 | http://legacy.python.org/dev/peps/pep-0257/#handling-docstring-indentation
210 | """
211 | if not docstring:
212 | return ''
213 | # Convert tabs to spaces (following the normal Python rules)
214 | # and split into a list of lines:
215 | lines = docstring.expandtabs().splitlines()
216 | # Determine minimum indentation (first line doesn't count):
217 | indent = sys.maxsize
218 | for line in lines[1:]:
219 | stripped = line.lstrip()
220 | if stripped:
221 | indent = min(indent, len(line) - len(stripped))
222 | # Remove indentation (first line is special):
223 | trimmed = [lines[0].strip()]
224 | if indent < sys.maxsize:
225 | for line in lines[1:]:
226 | trimmed.append(line[indent:].rstrip())
227 | # Strip off trailing and leading blank lines:
228 | while trimmed and not trimmed[-1]:
229 | trimmed.pop()
230 | while trimmed and not trimmed[0]:
231 | trimmed.pop(0)
232 | # Return a single string:
233 | return '\n'.join(trimmed)
234 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | ; exclude unrelated folders and all old tests
3 | norecursedirs =
4 | .*
5 | env
6 | tests/templates
7 | ; more verbose summary (include skip/fail/error/warning), coverage
8 | addopts = -rsfEw --cov flask_pluginengine --cov-report html --no-cov-on-fail
9 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | name = Flask-PluginEngine
3 | version = attr: flask_pluginengine.__version__
4 | description = A simple plugin system for Flask applications.
5 | long_description = file: README.rst
6 | long_description_content_type = text/x-rst
7 | url = https://github.com/indico/flask-pluginengine
8 | license = BSD
9 | author = Indico Team
10 | author_email = indico-team@cern.ch
11 | classifiers =
12 | Environment :: Web Environment
13 | Framework :: Flask
14 | License :: OSI Approved :: BSD License
15 | Programming Language :: Python :: 3.8
16 | Programming Language :: Python :: 3.9
17 | Programming Language :: Python :: 3.10
18 | Programming Language :: Python :: 3.11
19 | Programming Language :: Python :: 3.12
20 |
21 | [options]
22 | packages = find:
23 | zip_safe = false
24 | include_package_data = true
25 | python_requires = ~=3.8
26 | install_requires =
27 | # TODO figure out the right minimum versions we need, or just
28 | # require the versions that dropped python 2 as well to encourage
29 | # people to use something recent..
30 | Flask
31 | Jinja2
32 | blinker
33 | importlib-metadata
34 |
35 | [options.extras_require]
36 | dev =
37 | isort
38 | pytest
39 | pytest-cov
40 |
41 | [options.packages.find]
42 | include =
43 | flask_pluginengine
44 | flask_pluginengine.*
45 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | # This file is part of Flask-PluginEngine.
2 | # Copyright (C) 2014-2021 CERN
3 | #
4 | # Flask-PluginEngine is free software; you can redistribute it
5 | # and/or modify it under the terms of the Revised BSD License.
6 |
7 | from setuptools import setup
8 |
9 |
10 | setup()
11 |
--------------------------------------------------------------------------------
/tests/foobar_plugin/foobar_plugin.py:
--------------------------------------------------------------------------------
1 | from flask_pluginengine import Plugin
2 |
3 |
4 | class FoobarPlugin(Plugin):
5 | """Foobar plugin
6 |
7 | This plugin foobars your foo with your bars.
8 | And doing so is amazing!
9 | """
10 |
11 | version = '69.42'
12 |
--------------------------------------------------------------------------------
/tests/foobar_plugin/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | name = flask-multipass-foobar-plugin
3 | version = 69.13.37
4 | description = Dummy plugin for the tests
5 |
6 | [options]
7 | py_modules = foobar_plugin
8 | zip_safe = false
9 |
10 | [options.entry_points]
11 | flask_multipass.test.plugins =
12 | foobar = foobar_plugin:FoobarPlugin
13 |
--------------------------------------------------------------------------------
/tests/foobar_plugin/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup
2 |
3 |
4 | setup()
5 |
--------------------------------------------------------------------------------
/tests/templates/core/base.txt:
--------------------------------------------------------------------------------
1 | {% block test %}block={{ whereami() }}{% endblock -%}
2 |
--------------------------------------------------------------------------------
/tests/templates/core/context.txt:
--------------------------------------------------------------------------------
1 | {% from 'macro.txt' import mi %}
2 | {% from 'espresso:macro.txt' import mip %}
3 | {% macro m() %}core-macro/{{ whereami() }}/{{ caller() if caller else 'undef' }}{% endmacro %}
4 | core_main={{ whereami() }}
5 | core_block_a={% block a %}core-a/{{ whereami() }}{% endblock %}
6 | core_block_b={% block b %}core-b/{{ whereami() }}{% endblock %}
7 | core_macro={{ m() }}
8 | core_macro_call={% call m() %}{{ whereami() }}{% endcall %}
9 | core_macro_imp={{ mi() }}
10 | core_macro_imp_call={% call mi() %}{{ whereami() }}{% endcall %}
11 | core_macro_plugin_imp={{ mip() }}
12 | core_macro_plugin_imp_call={% call mip() %}{{ whereami() }}{% endcall %}
13 | core_inc_core={% include 'test.txt' %}
14 | core_inc_plugin={% include 'espresso:test.txt' %}
15 | {% block extra %}{% endblock %}
16 |
--------------------------------------------------------------------------------
/tests/templates/core/macro.txt:
--------------------------------------------------------------------------------
1 | {% macro mi() -%}
2 | core-imp-macro/{{ whereami() }}/{{ caller() if caller else 'undef' }}
3 | {%- endmacro %}
4 |
--------------------------------------------------------------------------------
/tests/templates/core/nested_calls.txt:
--------------------------------------------------------------------------------
1 | {%- macro outer() -%}
2 | core-outer-macro/{{ whereami() }}/{{ caller() if caller else 'undef' }}
3 | {%- endmacro -%}
4 |
5 | {%- macro inner() -%}
6 | core-inner-macro/{{ whereami() }}/{{ caller() if caller else 'undef' }}
7 | {%- endmacro -%}
8 |
9 | outer={%- call outer() -%}
10 | {{ whereami() }}
11 | inner={%- call inner() -%}
12 | {{ whereami() }}
13 | {%- endcall -%}
14 | {%- endcall -%}
15 |
--------------------------------------------------------------------------------
/tests/templates/core/simple_macro.txt:
--------------------------------------------------------------------------------
1 | {% from 'macro.txt' import mi -%}
2 | macro={{ mi() }}
3 | macro_call={% call mi() %}{{ whereami() }}{% endcall %}
4 |
--------------------------------------------------------------------------------
/tests/templates/core/simple_macro_extends.txt:
--------------------------------------------------------------------------------
1 | {% macro m() %}core-macro/{{ whereami() }}/{{ caller() if caller else 'undef' }}{% endmacro %}
2 | {% block test -%}
3 | core_macro={{ m() }}
4 | core_macro_call={% call m() %}{{ whereami() }}{% endcall %}
5 | {% endblock -%}
6 |
--------------------------------------------------------------------------------
/tests/templates/core/simple_macro_extends_base.txt:
--------------------------------------------------------------------------------
1 | {% macro m() %}core-macro/{{ whereami() }}/{{ caller() if caller else 'undef' }}{% endmacro %}
2 | core_macro={{ m() }}
3 | core_macro_call={% call m() %}{{ whereami() }}{% endcall %}
4 |
--------------------------------------------------------------------------------
/tests/templates/core/test.txt:
--------------------------------------------------------------------------------
1 | core test
2 |
--------------------------------------------------------------------------------
/tests/templates/plugin/context.txt:
--------------------------------------------------------------------------------
1 | {% extends 'context.txt' %}
2 | {% from 'context.txt' import m as mm %}
3 | {% macro mp() %}plugin-macro/{{ whereami() }}/{{ caller() if caller else 'undef' }}{% endmacro %}
4 | {% block a %}plugin-a/{{ whereami() }}{% endblock %}
5 | {% block extra -%}
6 | core_macro_in_plugin={{ mm() }}
7 | core_macro_in_plugin_call={% call mm() %}{{ whereami() }}{% endcall %}
8 | plugin_macro={{ mp() }}
9 | plugin_macro_call={% call mp() %}{{ whereami() }}{% endcall %}
10 | plugin_inc_core={% include 'test.txt' %}
11 | plugin_inc_plugin={% include 'espresso:test.txt' %}
12 | {%- endblock %}
13 |
--------------------------------------------------------------------------------
/tests/templates/plugin/macro.txt:
--------------------------------------------------------------------------------
1 | {% macro mip() -%}
2 | plugin-imp-macro/{{ whereami() }}/{{ caller() if caller else 'undef' }}
3 | {%- endmacro %}
4 |
--------------------------------------------------------------------------------
/tests/templates/plugin/nested_calls.txt:
--------------------------------------------------------------------------------
1 | {% from 'nested_calls.txt' import outer, inner %}
2 |
3 | outer={%- call outer() -%}
4 | {{ whereami() }}
5 | inner={%- call inner() -%}
6 | {{ whereami() }}
7 | {%- endcall -%}
8 | {%- endcall -%}
9 |
--------------------------------------------------------------------------------
/tests/templates/plugin/simple_macro.txt:
--------------------------------------------------------------------------------
1 | {% from 'macro.txt' import mi -%}
2 | macro={{ mi() }}
3 | macro_call={% call mi() %}{{ whereami() }}{% endcall %}
4 |
--------------------------------------------------------------------------------
/tests/templates/plugin/simple_macro_extends.txt:
--------------------------------------------------------------------------------
1 | {% extends 'simple_macro_extends.txt' %}
2 | {% from 'simple_macro_extends.txt' import m as mm -%}
3 | {% block test -%}
4 | plugin_macro={{ mm() }}
5 | plugin_macro_call={% call mm() %}{{ whereami() }}{% endcall %}
6 | {% endblock -%}
7 |
8 |
--------------------------------------------------------------------------------
/tests/templates/plugin/simple_macro_extends_base.txt:
--------------------------------------------------------------------------------
1 | {% extends 'simple_macro_extends_base.txt' %}
2 |
--------------------------------------------------------------------------------
/tests/templates/plugin/super.txt:
--------------------------------------------------------------------------------
1 | {% extends 'base.txt' %}
2 | {% block test %}{{ super() }}/{{ whereami() }}{% endblock %}
3 |
--------------------------------------------------------------------------------
/tests/templates/plugin/test.txt:
--------------------------------------------------------------------------------
1 | plugin test
2 |
--------------------------------------------------------------------------------
/tests/test_engine.py:
--------------------------------------------------------------------------------
1 | # This file is part of Flask-PluginEngine.
2 | # Copyright (C) 2014-2021 CERN
3 | #
4 | # Flask-PluginEngine is free software; you can redistribute it
5 | # and/or modify it under the terms of the Revised BSD License.
6 |
7 | import os
8 | import re
9 | from dataclasses import dataclass
10 |
11 | import pytest
12 | from importlib_metadata import EntryPoint
13 | from jinja2 import TemplateNotFound
14 | from flask import render_template, Flask
15 |
16 | from flask_pluginengine import (PluginEngine, plugins_loaded, Plugin, render_plugin_template, current_plugin,
17 | plugin_context, PluginFlask)
18 | from flask_pluginengine.templating import PrefixIgnoringFileSystemLoader
19 |
20 |
21 | class EspressoModule(Plugin):
22 | """EspressoModule
23 |
24 | Creamy espresso out of your Flask app
25 | """
26 | pass
27 |
28 |
29 | class ImposterPlugin(object):
30 | """ImposterPlugin
31 |
32 | I am not really a plugin as I do not inherit from the Plugin class
33 | """
34 | pass
35 |
36 |
37 | class OtherVersionPlugin(Plugin):
38 | """OtherVersionPlugin
39 |
40 | I am a plugin with a custom version
41 | """
42 | version = '2.0'
43 |
44 |
45 | class NonDescriptivePlugin(Plugin):
46 | """NonDescriptivePlugin"""
47 |
48 |
49 | class MockEntryPoint(EntryPoint):
50 | def load(self, *args, **kwargs):
51 | if self.name == 'importfail':
52 | raise ImportError()
53 | elif self.name == 'imposter':
54 | return ImposterPlugin
55 | elif self.name == 'otherversion':
56 | return OtherVersionPlugin
57 | elif self.name == 'nondescriptive':
58 | return NonDescriptivePlugin
59 | else:
60 | return EspressoModule
61 |
62 |
63 | @dataclass
64 | class MockDistribution:
65 | version: str
66 |
67 |
68 | def mock_entry_point(name, value):
69 | return MockEntryPoint(name, value, 'dummy.group')._for(MockDistribution('1.2.3'))
70 |
71 |
72 | @pytest.fixture
73 | def flask_app():
74 | app = PluginFlask(__name__, template_folder='templates/core')
75 | app.config['TESTING'] = True
76 | app.config['PLUGINENGINE_NAMESPACE'] = 'test'
77 | app.config['PLUGINENGINE_PLUGINS'] = ['espresso']
78 | app.add_template_global(lambda: current_plugin.name if current_plugin else 'core', 'whereami')
79 | return app
80 |
81 |
82 | @pytest.fixture
83 | def flask_app_ctx(flask_app):
84 | with flask_app.app_context():
85 | yield flask_app
86 | assert not current_plugin, 'leaked plugin context'
87 |
88 |
89 | @pytest.fixture
90 | def engine(flask_app):
91 | return PluginEngine(app=flask_app)
92 |
93 |
94 | @pytest.fixture
95 | def mock_entry_points(monkeypatch):
96 | from flask_pluginengine import engine as engine_mod
97 |
98 | MOCK_EPS = {
99 | 'test': [
100 | mock_entry_point('espresso', 'test.plugin'),
101 | mock_entry_point('otherversion', 'test.plugin'),
102 | mock_entry_point('nondescriptive', 'test.plugin'),
103 | mock_entry_point('double', 'double'), mock_entry_point('double', 'double'),
104 | mock_entry_point('importfail', 'test.importfail'),
105 | mock_entry_point('imposter', 'test.imposter')
106 | ]
107 | }
108 |
109 | def _mock_entry_points(*, group, name):
110 | return [ep for ep in MOCK_EPS[group] if ep.name == name]
111 |
112 | monkeypatch.setattr(engine_mod, 'importlib_entry_points', _mock_entry_points)
113 |
114 |
115 | @pytest.fixture
116 | def loaded_engine(mock_entry_points, monkeypatch, flask_app, engine):
117 | engine.load_plugins(flask_app)
118 |
119 | def init_loader(self, *args, **kwargs):
120 | super(PrefixIgnoringFileSystemLoader, self).__init__(os.path.join(flask_app.root_path, 'templates/plugin'))
121 |
122 | monkeypatch.setattr('flask_pluginengine.templating.PrefixIgnoringFileSystemLoader.__init__', init_loader)
123 | return engine
124 |
125 |
126 | def test_real_plugin():
127 | app = PluginFlask(__name__)
128 | app.config['TESTING'] = True
129 | app.config['PLUGINENGINE_NAMESPACE'] = 'flask_multipass.test.plugins'
130 | app.config['PLUGINENGINE_PLUGINS'] = ['foobar']
131 | engine = PluginEngine(app)
132 | assert engine.load_plugins(app)
133 | with app.app_context():
134 | assert len(engine.get_failed_plugins()) == 0
135 | assert list(engine.get_active_plugins()) == ['foobar']
136 | plugin = engine.get_active_plugins()['foobar']
137 | assert plugin.title == 'Foobar plugin'
138 | assert plugin.description == 'This plugin foobars your foo with your bars.\nAnd doing so is amazing!'
139 | assert plugin.version == '69.42'
140 | assert plugin.package_version == '69.13.37'
141 |
142 |
143 | def test_fail_pluginengine_namespace(flask_app):
144 | """
145 | Fail if PLUGINENGINE_NAMESPACE is not defined
146 | """
147 | del flask_app.config['PLUGINENGINE_NAMESPACE']
148 | with pytest.raises(Exception) as exc_info:
149 | PluginEngine(app=flask_app)
150 | assert 'PLUGINENGINE_NAMESPACE' in str(exc_info.value)
151 |
152 |
153 | @pytest.mark.usefixtures('mock_entry_points')
154 | def test_load(flask_app, engine):
155 | """
156 | We can load a plugin
157 | """
158 |
159 | loaded = {'result': False}
160 |
161 | def _on_load(sender):
162 | loaded['result'] = True
163 |
164 | plugins_loaded.connect(_on_load, flask_app)
165 | engine.load_plugins(flask_app)
166 |
167 | assert loaded['result']
168 |
169 | with flask_app.app_context():
170 | assert len(engine.get_failed_plugins()) == 0
171 | assert list(engine.get_active_plugins()) == ['espresso']
172 |
173 | plugin = engine.get_active_plugins()['espresso']
174 |
175 | assert plugin.title == 'EspressoModule'
176 | assert plugin.description == 'Creamy espresso out of your Flask app'
177 | assert plugin.version == '1.2.3'
178 | assert plugin.package_version == '1.2.3'
179 |
180 |
181 | @pytest.mark.usefixtures('mock_entry_points')
182 | def test_no_description(flask_app, engine):
183 | flask_app.config['PLUGINENGINE_PLUGINS'] = ['nondescriptive']
184 | engine.load_plugins(flask_app)
185 | with flask_app.app_context():
186 | plugin = engine.get_active_plugins()['nondescriptive']
187 | assert plugin.title == 'NonDescriptivePlugin'
188 | assert plugin.description == 'no description available'
189 |
190 |
191 | @pytest.mark.usefixtures('mock_entry_points')
192 | def test_custom_version(flask_app, engine):
193 | flask_app.config['PLUGINENGINE_PLUGINS'] = ['otherversion']
194 | engine.load_plugins(flask_app)
195 | with flask_app.app_context():
196 | plugin = engine.get_active_plugins()['otherversion']
197 | assert plugin.package_version == '1.2.3'
198 | assert plugin.version == '2.0'
199 |
200 |
201 | @pytest.mark.usefixtures('mock_entry_points')
202 | def test_fail_non_existing(flask_app, engine):
203 | """
204 | Fail if a plugin that is specified in the config does not exist
205 | """
206 |
207 | flask_app.config['PLUGINENGINE_PLUGINS'] = ['someotherstuff']
208 |
209 | engine.load_plugins(flask_app)
210 |
211 | with flask_app.app_context():
212 | assert len(engine.get_failed_plugins()) == 1
213 | assert len(engine.get_active_plugins()) == 0
214 |
215 |
216 | @pytest.mark.usefixtures('mock_entry_points')
217 | def test_fail_noskip(flask_app, engine):
218 | """
219 | Fail immediately if no_skip=False
220 | """
221 |
222 | flask_app.config['PLUGINENGINE_PLUGINS'] = ['someotherstuff']
223 |
224 | assert engine.load_plugins(flask_app, skip_failed=False) is False
225 |
226 |
227 | @pytest.mark.usefixtures('mock_entry_points')
228 | def test_fail_double(flask_app, engine):
229 | """
230 | Fail if the same plugin corresponds to two extension points
231 | """
232 |
233 | flask_app.config['PLUGINENGINE_PLUGINS'] = ['doubletrouble']
234 |
235 | engine.load_plugins(flask_app)
236 |
237 | with flask_app.app_context():
238 | assert len(engine.get_failed_plugins()) == 1
239 | assert len(engine.get_active_plugins()) == 0
240 |
241 |
242 | @pytest.mark.usefixtures('mock_entry_points')
243 | def test_fail_import_error(flask_app, engine):
244 | """
245 | Fail if impossible to import Plugin
246 | """
247 |
248 | flask_app.config['PLUGINENGINE_PLUGINS'] = ['importfail']
249 |
250 | engine.load_plugins(flask_app)
251 |
252 | with flask_app.app_context():
253 | assert len(engine.get_failed_plugins()) == 1
254 | assert len(engine.get_active_plugins()) == 0
255 |
256 |
257 | @pytest.mark.usefixtures('mock_entry_points')
258 | def test_fail_not_subclass(flask_app, engine):
259 | """
260 | Fail if the plugin is not a subclass of `Plugin`
261 | """
262 |
263 | flask_app.config['PLUGINENGINE_PLUGINS'] = ['imposter']
264 |
265 | engine.load_plugins(flask_app)
266 |
267 | with flask_app.app_context():
268 | assert len(engine.get_failed_plugins()) == 1
269 | assert len(engine.get_active_plugins()) == 0
270 |
271 |
272 | @pytest.mark.usefixtures('mock_entry_points')
273 | def test_instance_not_loaded(flask_app, engine):
274 | """
275 | Fail when trying to get the instance for a plugin that's not loaded
276 | """
277 |
278 | other_app = Flask(__name__)
279 | other_app.config['TESTING'] = True
280 | other_app.config['PLUGINENGINE_NAMESPACE'] = 'test'
281 | other_app.config['PLUGINENGINE_PLUGINS'] = []
282 | engine.init_app(other_app)
283 | with other_app.app_context():
284 | with pytest.raises(RuntimeError):
285 | EspressoModule.instance
286 |
287 |
288 | @pytest.mark.usefixtures('flask_app_ctx')
289 | def test_instance(loaded_engine):
290 | """
291 | Check if Plugin.instance points to the correct instance
292 | """
293 |
294 | assert EspressoModule.instance is loaded_engine.get_plugin(EspressoModule.name)
295 |
296 |
297 | def test_double_load(flask_app, loaded_engine):
298 | """
299 | Fail if the engine tries to load the plugins a second time
300 | """
301 |
302 | with pytest.raises(RuntimeError) as exc_info:
303 | loaded_engine.load_plugins(flask_app)
304 | assert 'Plugins already loaded' in str(exc_info.value)
305 |
306 |
307 | def test_has_plugin(flask_app, loaded_engine):
308 | """
309 | Test that has_plugin() returns the correct result
310 | """
311 | with flask_app.app_context():
312 | assert loaded_engine.has_plugin('espresso')
313 | assert not loaded_engine.has_plugin('someotherstuff')
314 |
315 |
316 | def test_get_plugin(flask_app, loaded_engine):
317 | """
318 | Test that get_plugin() behaves consistently
319 | """
320 | with flask_app.app_context():
321 | plugin = loaded_engine.get_plugin('espresso')
322 | assert isinstance(plugin, EspressoModule)
323 | assert plugin.name == 'espresso'
324 |
325 | assert loaded_engine.get_plugin('someotherstuff') is None
326 |
327 |
328 | def test_repr(loaded_engine):
329 | """
330 | Check that repr(PluginEngine(...)) is OK
331 | """
332 | assert repr(loaded_engine) == ''
333 |
334 |
335 | def test_repr_state(flask_app, loaded_engine):
336 | """
337 | Check that repr(PluginEngineState(...)) is OK
338 | """
339 | from flask_pluginengine.util import get_state
340 | assert repr(get_state(flask_app)) == ("<_PluginEngineState(, , "
341 | "{'espresso': >})>")
343 |
344 |
345 | def test_render_template(flask_app, loaded_engine):
346 | """
347 | Check that app/plugin templates are separate
348 | """
349 | with flask_app.app_context():
350 | assert render_template('test.txt') == 'core test'
351 | assert render_template('espresso:test.txt') == 'plugin test'
352 |
353 |
354 | def test_render_plugin_template(flask_app_ctx, loaded_engine):
355 | """
356 | Check that render_plugin_template works
357 | """
358 | plugin = loaded_engine.get_plugin('espresso')
359 | text = 'plugin test'
360 | with plugin.plugin_context():
361 | assert render_template('espresso:test.txt') == text
362 | assert render_plugin_template('test.txt') == text
363 | assert render_plugin_template('espresso:test.txt') == text
364 | # explicit plugin name works outside context
365 | assert render_plugin_template('espresso:test.txt') == text
366 | # implicit plugin name fails outside context
367 | with pytest.raises(RuntimeError):
368 | render_plugin_template('test.txt')
369 |
370 |
371 | def _parse_template_data(data):
372 | items = [re.search(r'(\S+)=(.+)', item.strip()).groups() for item in data.strip().splitlines() if item.strip()]
373 | rv = dict(items)
374 | assert len(rv) == len(items)
375 | return rv
376 |
377 |
378 | @pytest.mark.parametrize('in_plugin_ctx', (False, True))
379 | def test_template_plugin_contexts_macros(flask_app_ctx, loaded_engine, in_plugin_ctx):
380 | """
381 | Check that the plugin context is handled properly in macros
382 | """
383 | plugin = loaded_engine.get_plugin('espresso')
384 | with plugin_context(plugin if in_plugin_ctx else None):
385 | assert _parse_template_data(render_template('simple_macro.txt')) == {
386 | 'macro': 'core-imp-macro/core/undef',
387 | 'macro_call': 'core-imp-macro/core/core'
388 | }
389 | assert _parse_template_data(render_template('espresso:simple_macro.txt')) == {
390 | 'macro': 'core-imp-macro/core/undef',
391 | 'macro_call': 'core-imp-macro/core/espresso'
392 | }
393 |
394 |
395 | @pytest.mark.parametrize('in_plugin_ctx', (False, True))
396 | def test_template_plugin_contexts_macros_extends(flask_app_ctx, loaded_engine, in_plugin_ctx):
397 | """
398 | Check that the plugin context is handled properly in macros with template inheritance
399 | """
400 | plugin = loaded_engine.get_plugin('espresso')
401 | with plugin_context(plugin if in_plugin_ctx else None):
402 | assert _parse_template_data(render_template('simple_macro_extends.txt')) == {
403 | 'core_macro': 'core-macro/core/undef',
404 | 'core_macro_call': 'core-macro/core/core',
405 | }
406 | assert _parse_template_data(render_template('espresso:simple_macro_extends.txt')) == {
407 | 'plugin_macro': 'core-macro/core/undef',
408 | 'plugin_macro_call': 'core-macro/core/espresso',
409 | }
410 |
411 |
412 | @pytest.mark.parametrize('in_plugin_ctx', (False, True))
413 | def test_template_plugin_contexts_macros_extends_base(flask_app_ctx, loaded_engine, in_plugin_ctx):
414 | """
415 | Check that the plugin context is handled properly in macros defined/called in the base tpl
416 | """
417 | plugin = loaded_engine.get_plugin('espresso')
418 | with plugin_context(plugin if in_plugin_ctx else None):
419 | assert _parse_template_data(render_template('simple_macro_extends_base.txt')) == {
420 | 'core_macro': 'core-macro/core/undef',
421 | 'core_macro_call': 'core-macro/core/core',
422 | }
423 | assert _parse_template_data(render_template('espresso:simple_macro_extends_base.txt')) == {
424 | 'core_macro': 'core-macro/core/undef',
425 | 'core_macro_call': 'core-macro/core/core',
426 | }
427 |
428 |
429 | @pytest.mark.parametrize('in_plugin_ctx', (False, True))
430 | def test_template_plugin_contexts_macros_nested_calls(flask_app_ctx, loaded_engine, in_plugin_ctx):
431 | """
432 | Check that the plugin context is handled properly when using nested call blocks
433 | """
434 | plugin = loaded_engine.get_plugin('espresso')
435 | with plugin_context(plugin if in_plugin_ctx else None):
436 | assert _parse_template_data(render_template('nested_calls.txt')) == {
437 | 'outer': 'core-outer-macro/core/core',
438 | 'inner': 'core-inner-macro/core/core',
439 | }
440 | assert _parse_template_data(render_template('espresso:nested_calls.txt')) == {
441 | 'outer': 'core-outer-macro/core/espresso',
442 | 'inner': 'core-inner-macro/core/espresso',
443 | }
444 |
445 |
446 | @pytest.mark.parametrize('in_plugin_ctx', (False, True))
447 | def test_template_plugin_contexts_super(flask_app_ctx, loaded_engine, in_plugin_ctx):
448 | """
449 | Check that the plugin context is handled properly when using `super()`
450 | """
451 | plugin = loaded_engine.get_plugin('espresso')
452 | with plugin_context(plugin if in_plugin_ctx else None):
453 | assert _parse_template_data(render_template('base.txt')) == {
454 | 'block': 'core',
455 | }
456 | assert _parse_template_data(render_template('espresso:super.txt')) == {
457 | 'block': 'core/espresso',
458 | }
459 |
460 |
461 | @pytest.mark.parametrize('in_plugin_ctx', (False, True))
462 | def test_template_plugin_contexts(flask_app_ctx, loaded_engine, in_plugin_ctx):
463 | """
464 | Check that the plugin contexts are correct in all cases
465 | """
466 | plugin = loaded_engine.get_plugin('espresso')
467 | with plugin_context(plugin if in_plugin_ctx else None):
468 | assert _parse_template_data(render_template('context.txt')) == {
469 | 'core_main': 'core',
470 | 'core_block_a': 'core-a/core',
471 | 'core_block_b': 'core-b/core',
472 | 'core_macro': 'core-macro/core/undef',
473 | 'core_macro_call': 'core-macro/core/core',
474 | 'core_macro_imp': 'core-imp-macro/core/undef',
475 | 'core_macro_imp_call': 'core-imp-macro/core/core',
476 | 'core_macro_plugin_imp': 'plugin-imp-macro/espresso/undef',
477 | 'core_macro_plugin_imp_call': 'plugin-imp-macro/espresso/core',
478 | 'core_inc_core': 'core test',
479 | 'core_inc_plugin': 'plugin test',
480 | }
481 | assert _parse_template_data(render_template('espresso:context.txt')) == {
482 | 'core_main': 'core',
483 | 'core_block_a': 'plugin-a/espresso',
484 | 'core_block_b': 'core-b/core',
485 | 'core_macro': 'core-macro/core/undef',
486 | 'core_macro_call': 'core-macro/core/core',
487 | 'core_macro_in_plugin': 'core-macro/core/undef',
488 | 'core_macro_in_plugin_call': 'core-macro/core/espresso',
489 | 'core_macro_imp': 'core-imp-macro/core/undef',
490 | 'core_macro_imp_call': 'core-imp-macro/core/core',
491 | 'core_macro_plugin_imp': 'plugin-imp-macro/espresso/undef',
492 | 'core_macro_plugin_imp_call': 'plugin-imp-macro/espresso/core',
493 | 'plugin_macro': 'plugin-macro/espresso/undef',
494 | 'plugin_macro_call': 'plugin-macro/espresso/espresso',
495 | 'core_inc_core': 'core test',
496 | 'core_inc_plugin': 'plugin test',
497 | 'plugin_inc_core': 'core test',
498 | 'plugin_inc_plugin': 'plugin test',
499 | }
500 |
501 |
502 | def test_template_invalid(flask_app_ctx, loaded_engine):
503 | """
504 | Check that loading an invalid plugin template fails
505 | """
506 | with pytest.raises(TemplateNotFound):
507 | render_template('nosuchplugin:foobar.txt')
508 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist =
3 | py{38,39,310,311,312}
4 | style
5 | skip_missing_interpreters = true
6 |
7 | [testenv]
8 | commands = pytest --color=yes --cov {envsitepackagesdir}/flask_pluginengine
9 | deps =
10 | pytest
11 | pytest-cov
12 | ./tests/foobar_plugin
13 |
14 | [testenv:style]
15 | skip_install = true
16 | deps =
17 | flake8
18 | isort
19 | commands =
20 | flake8 setup.py flask_pluginengine
21 | isort --diff --check-only setup.py flask_pluginengine
22 |
--------------------------------------------------------------------------------