├── .coveragerc ├── .flake8 ├── .github ├── dependabot.yml ├── utils │ └── check_version.py └── workflows │ ├── pypi.yml │ └── tests.yml ├── .gitignore ├── .isort.cfg ├── .readthedocs.yml ├── CHANGES.rst ├── LICENSE ├── README.rst ├── all-requirements.txt ├── artwork ├── pluginengine-long.svg └── pluginengine.svg ├── docs ├── Makefile ├── _static │ ├── pluginengine-long.png │ └── pluginengine.png ├── _templates │ ├── sidebarintro.html │ └── sidebarlogo.html ├── api.rst ├── application.rst ├── changelog.rst ├── conf.py ├── index.rst └── plugin.rst ├── example ├── app │ ├── app.py │ └── templates │ │ ├── base.html │ │ └── index.html └── plugin │ ├── example_plugin.py │ ├── setup.py │ └── templates │ └── index.html ├── flask_pluginengine ├── __init__.py ├── engine.py ├── globals.py ├── mixins.py ├── plugin.py ├── signals.py ├── templating.py └── util.py ├── pytest.ini ├── setup.cfg ├── setup.py ├── tests ├── foobar_plugin │ ├── foobar_plugin.py │ ├── setup.cfg │ └── setup.py ├── templates │ ├── core │ │ ├── base.txt │ │ ├── context.txt │ │ ├── macro.txt │ │ ├── nested_calls.txt │ │ ├── simple_macro.txt │ │ ├── simple_macro_extends.txt │ │ ├── simple_macro_extends_base.txt │ │ └── test.txt │ └── plugin │ │ ├── context.txt │ │ ├── macro.txt │ │ ├── nested_calls.txt │ │ ├── simple_macro.txt │ │ ├── simple_macro_extends.txt │ │ ├── simple_macro_extends_base.txt │ │ ├── super.txt │ │ └── test.txt └── test_engine.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | source = flask_pluginengine 4 | 5 | [report] 6 | exclude_lines = 7 | pragma: no cover 8 | def __repr__ 9 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | day: monday 8 | time: '14:30' 9 | timezone: Europe/Zurich 10 | groups: 11 | github-actions: 12 | patterns: ['*'] 13 | -------------------------------------------------------------------------------- /.github/utils/check_version.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from importlib.util import find_spec 4 | 5 | from setuptools.config.expand import StaticModule 6 | 7 | 8 | package = 'flask_pluginengine' 9 | sys.path.insert(0, os.getcwd()) 10 | version = StaticModule(package, find_spec(package)).__version__ 11 | tag_version = sys.argv[1] 12 | 13 | if tag_version != version: 14 | print(f'::error::Tag version {tag_version} does not match package version {version}') 15 | sys.exit(1) 16 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: PyPI release 🐍 📦 2 | 3 | on: 4 | push: 5 | tags: [v*] 6 | 7 | jobs: 8 | build: 9 | name: Build package 📦 10 | runs-on: ubuntu-22.04 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-python@v5 14 | name: Set up Python 🐍 15 | with: 16 | python-version: '3.11' 17 | - name: Check version 🔍 18 | run: python .github/utils/check_version.py "${GITHUB_REF#refs/tags/v}" 19 | - name: Install build deps 🔧 20 | run: pip install --user build 21 | - name: Build wheel and sdist 📦 22 | run: >- 23 | python -m 24 | build 25 | --sdist 26 | --wheel 27 | --outdir dist/ 28 | . 29 | - uses: actions/upload-artifact@v4 30 | name: Upload build artifacts 📦 31 | with: 32 | name: wheel 33 | retention-days: 7 34 | path: ./dist 35 | 36 | create-github-release: 37 | name: Create GitHub release 🐙 38 | # Upload wheel to a GitHub release. It remains available as a build artifact for a while as well. 39 | needs: build 40 | runs-on: ubuntu-22.04 41 | permissions: 42 | contents: write 43 | steps: 44 | - uses: actions/download-artifact@v4 45 | name: Download build artifacts 📦 46 | - name: Create draft release 🐙 47 | run: >- 48 | gh release create 49 | --draft 50 | --repo ${{ github.repository }} 51 | --title ${{ github.ref_name }} 52 | ${{ github.ref_name }} 53 | wheel/* 54 | env: 55 | GH_TOKEN: ${{ github.token }} 56 | 57 | publish-pypi: 58 | name: Publish 🚀 59 | needs: build 60 | # Wait for approval before attempting to upload to PyPI. This allows reviewing the 61 | # files in the draft release. 62 | environment: publish 63 | runs-on: ubuntu-22.04 64 | permissions: 65 | contents: write 66 | id-token: write 67 | steps: 68 | - uses: actions/download-artifact@v4 69 | # Try uploading to Test PyPI first, in case something fails. 70 | - name: Publish to Test PyPI 🧪 71 | uses: pypa/gh-action-pypi-publish@v1.12.4 72 | with: 73 | repository-url: https://test.pypi.org/legacy/ 74 | packages-dir: wheel/ 75 | - name: Publish to PyPI 🚀 76 | uses: pypa/gh-action-pypi-publish@v1.12.4 77 | with: 78 | packages-dir: wheel/ 79 | - name: Publish GitHub release 🐙 80 | run: >- 81 | gh release edit 82 | --draft=false 83 | --repo ${{ github.repository }} 84 | ${{ github.ref_name }} 85 | env: 86 | GH_TOKEN: ${{ github.token }} 87 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | tests: 11 | name: ${{ matrix.name }} 12 | runs-on: ubuntu-22.04 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | include: 18 | - {name: Style, python: '3.12', tox: style} 19 | - {name: '3.12', python: '3.12', tox: py312} 20 | - {name: '3.11', python: '3.11', tox: py311} 21 | - {name: '3.10', python: '3.10', tox: py310} 22 | - {name: '3.9', python: '3.9', tox: py39} 23 | - {name: '3.8', python: '3.8', tox: py38} 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - uses: actions/setup-python@v5 29 | with: 30 | python-version: ${{ matrix.python }} 31 | 32 | - name: Update pip 33 | run: | 34 | pip install -U wheel 35 | pip install -U setuptools 36 | python -m pip install -U pip 37 | 38 | - name: Install tox 39 | run: pip install tox 40 | 41 | - name: Run tests 42 | run: tox -e ${{ matrix.tox }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | env/ 4 | .venv/ 5 | *.pyc 6 | *.egg-info 7 | *.egg 8 | .tox/ 9 | .eggs/ 10 | htmlcov/ 11 | .coverage 12 | .cache/ 13 | dist/ 14 | build/ 15 | docs/_build 16 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | line_length=120 3 | multi_line_output=0 4 | lines_after_imports=2 5 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: '3.11' 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | builder: dirhtml 11 | 12 | python: 13 | install: 14 | - requirements: all-requirements.txt 15 | - method: pip 16 | path: . 17 | extra_requirements: 18 | - dev 19 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | Version 0.5 5 | ----------- 6 | 7 | - Drop support for Python 3.7 and older (3.7 is EOL since June 2023) 8 | - Declare explicit compatibility with Python 3.11 and 3.12 9 | - Support Flask/werkzeug 3.0 10 | 11 | Older Versions 12 | -------------- 13 | 14 | Sorry, no changelog for those (very old) versions. 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Flask-PluginEngine is free software; you can redistribute it and/or 2 | modify it under the terms of the Revised BSD License quoted below. 3 | 4 | Copyright (C) 2014-2021 CERN. 5 | 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are 10 | met: 11 | 12 | * Redistributions of source code must retain the above copyright 13 | notice, this list of conditions and the following disclaimer. 14 | 15 | * Redistributions in binary form must reproduce the above copyright 16 | notice, this list of conditions and the following disclaimer in the 17 | documentation and/or other materials provided with the distribution. 18 | 19 | * Neither the name of the copyright holder nor the names of its 20 | contributors may be used to endorse or promote products derived from 21 | this software without specific prior written permission. 22 | 23 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 24 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 25 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 26 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 27 | HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 28 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 29 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS 30 | OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 31 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR 32 | TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE 33 | USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH 34 | DAMAGE. 35 | 36 | In applying this license, CERN does not waive the privileges and 37 | immunities granted to it by virtue of its status as an 38 | Intergovernmental Organization or submit itself to any jurisdiction. 39 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Flask-PluginEngine 2 | ================== 3 | 4 | .. image:: https://github.com/indico/flask-pluginengine/workflows/Tests/badge.svg 5 | :target: https://github.com/indico/flask-pluginengine/actions 6 | .. image:: https://readthedocs.org/projects/flask-pluginengine/badge/?version=latest 7 | :target: https://flask-pluginengine.readthedocs.io/ 8 | 9 | 10 | A simple plugin system for Flask applications. More detailed documentation available at 11 | https://flask-pluginengine.readthedocs.io/. 12 | -------------------------------------------------------------------------------- /all-requirements.txt: -------------------------------------------------------------------------------- 1 | Flask-Sphinx-Themes 2 | Flask 3 | Jinja2 4 | blinker 5 | sphinx>=7.2.6 6 | -------------------------------------------------------------------------------- /artwork/pluginengine-long.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xmlPluginEngine 183 | Flask 219 | -------------------------------------------------------------------------------- /artwork/pluginengine.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 16 | 18 | image/svg+xml 19 | 21 | 22 | 23 | 24 | 25 | 27 | 31 | 35 | 39 | 43 | 47 | 48 | 52 | 56 | 60 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = Flask-PluginEngine 8 | PAPER = a4 9 | SOURCEDIR = . 10 | BUILDDIR = _build 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | # Put it first so that "make" without argument is like "make help". 16 | help: 17 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 18 | 19 | .PHONY: help Makefile 20 | 21 | 22 | livehtml: 23 | sphinx-autobuild -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 24 | 25 | # Catch-all target: route all unknown targets to Sphinx using the new 26 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 27 | %: Makefile 28 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 29 | -------------------------------------------------------------------------------- /docs/_static/pluginengine-long.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indico/flask-pluginengine/f01ba6131c6c55517d3518630d357375682c0ecd/docs/_static/pluginengine-long.png -------------------------------------------------------------------------------- /docs/_static/pluginengine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indico/flask-pluginengine/f01ba6131c6c55517d3518630d357375682c0ecd/docs/_static/pluginengine.png -------------------------------------------------------------------------------- /docs/_templates/sidebarintro.html: -------------------------------------------------------------------------------- 1 |

About

2 |

3 | Flask-PluginEngine is an extension that provides interfaces to create plugins and handle them within 4 | a Flask application. 5 |

6 |

Useful Links

7 | 11 | -------------------------------------------------------------------------------- /docs/_templates/sidebarlogo.html: -------------------------------------------------------------------------------- 1 | 6 |

{{ project }}

7 |

A plugin system for Flask.

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 | --------------------------------------------------------------------------------