From e14687a0f1b0e1358f627c092bfe42dd569cdfec Mon Sep 17 00:00:00 2001 From: pwwang Date: Mon, 19 Oct 2020 20:55:10 -0500 Subject: [PATCH] FIrst commit --- .editorconfig | 8 + .github/workflows/build.yml | 62 +++++ .github/workflows/docs.yml | 64 +++++ .gitignore | 153 +++++++++++ .pre-commit-config.yaml | 50 ++++ LICENSE | 21 ++ README.md | 141 ++++++++++ docs/requirements.txt | 3 + docs/style.css | 115 ++++++++ examples/__init__.py | 0 examples/complete/__init__.py | 0 examples/complete/__main__.py | 4 + examples/complete/hookspecs.py | 19 ++ examples/complete/host.py | 42 +++ examples/complete/lib.py | 15 ++ examples/complete/plugin.py | 23 ++ examples/toy.py | 30 +++ mkdocs.yml | 17 ++ pyproject.toml | 21 ++ simplug.py | 474 +++++++++++++++++++++++++++++++++ tests/__init__.py | 0 tests/plugin_module.py | 10 + tests/test_simplug.py | 195 ++++++++++++++ tox.ini | 3 + 24 files changed, 1470 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/docs.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/requirements.txt create mode 100644 docs/style.css create mode 100644 examples/__init__.py create mode 100644 examples/complete/__init__.py create mode 100644 examples/complete/__main__.py create mode 100644 examples/complete/hookspecs.py create mode 100644 examples/complete/host.py create mode 100644 examples/complete/lib.py create mode 100644 examples/complete/plugin.py create mode 100644 examples/toy.py create mode 100644 mkdocs.yml create mode 100644 pyproject.toml create mode 100644 simplug.py create mode 100644 tests/__init__.py create mode 100644 tests/plugin_module.py create mode 100644 tests/test_simplug.py create mode 100644 tox.ini diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4766d90 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +indent_style = tab +indent_size = 4 +tab_width = 4 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..629877e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,62 @@ +name: Build and Deploy + +on: [push, pull_request] + +jobs: + + build: + runs-on: ubuntu-latest + if: "! contains(github.event.head_commit.message, 'wip')" + strategy: + matrix: + python-version: [3.6, 3.7, 3.8] + + steps: + - uses: actions/checkout@v2 + - name: Setup Python # Set Python version + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install poetry + poetry config virtualenvs.create false + python -m pip install pylint + poetry install -v + - name: Run pylint + run: pylint simplug + - name: Test with pytest + run: poetry run pytest tests/ --junitxml=junit/test-results-${{ matrix.python-version }}.xml + - name: Upload pytest test results + uses: actions/upload-artifact@v2 + with: + name: pytest-results-${{ matrix.python-version }} + path: junit/test-results-${{ matrix.python-version }}.xml + # Use always() to always run this step to publish test results when there are test failures + if: ${{ always() }} + - name: Run codacy-coverage-reporter + uses: codacy/codacy-coverage-reporter-action@master + if: matrix.python-version == 3.8 + with: + project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} + coverage-reports: .coverage.xml + + deploy: + needs: build + runs-on: ubuntu-latest + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + strategy: + matrix: + python-version: [3.8] + steps: + - uses: actions/checkout@v2 + - name: Setup Python # Set Python version + uses: actions/setup-python@v2 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install poetry + - name: Publish to PyPI + run: poetry publish --build -u ${{ secrets.PYPI_USER }} -p ${{ secrets.PYPI_PASSWORD }} + if: success() diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..e40aefc --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,64 @@ +name: Build Docs + +on: [push] + +jobs: + docs: + runs-on: ubuntu-latest + if: "! contains(github.event.head_commit.message, 'wip')" + strategy: + matrix: + python-version: [3.8] + steps: + - uses: actions/checkout@v2 + - name: Setup Python # Set Python version + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install poetry + poetry config virtualenvs.create false + poetry install -v + - name: Update docs + run: | + python -m pip install mkdocs + python -m pip install -r docs/requirements.txt + + cd docs + cp ../README.md index.md + cd .. + mkdocs gh-deploy --clean --force + if: success() + + fix-index: + needs: docs + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.8] + steps: + - uses: actions/checkout@v2 + with: + ref: gh-pages + - name: Fix index.html + run: | + echo ':: head of index.html - before ::' + head index.html + sed -i '1,5{/^$/d}' index.html + echo ':: head of index.html - after ::' + head index.html + if: success() + - name: Commit changes + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git commit -m "Add changes" -a + if: success() + - name: Push changes + uses: ad-m/github-push-action@master + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + branch: gh-pages + if: success() diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9dbb03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,153 @@ + +# Created by https://www.gitignore.io/api/python +# Edit at https://www.gitignore.io/?templates=python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don’t work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +.vscode/ + +*.bak + +# data for Examples +examples/data/ + +# logs +plkit_logs/ +hparams.yaml + +workdir/ + +**/t-*.ipynb +.in.fish +.out.fish + +# local history for vscode +.history/ + +# lightning_logs, plkit_logs +*_logs/ + +# End of https://www.gitignore.io/api/python diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d001254 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,50 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +fail_fast: false +exclude: '^README.rst$|^tests/|^setup.py$|^examples/' +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: 5df1a4bf6f04a1ed3a643167b38d502575e29aef + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files +- repo: local + hooks: + - id: masterpylintrc + name: Overwrite local .pylintrc by master one + entry: cp ../.pylintrc ./.pylintrc + files: ../.pylintrc + pass_filenames: false + always_run: true + language: system +- repo: https://github.com/pre-commit/mirrors-pylint + rev: v2.4.4 + hooks: + - id: pylint + files: ^simplug\.py$ + pass_filenames: false + types: [python] + args: [simplug.py] +- repo: local + hooks: + - id: poetry2setuppy + name: Convert pyproject.toml to setup.py + entry: dephell deps convert --from=poetry --to=setup.py + language: system + files: pyproject.toml + pass_filenames: false + - id: poetry2requirements + name: Convert pyproject.toml to requirements.txt + entry: dephell deps convert --from=poetry --to=requirements.txt + language: system + files: pyproject.toml + pass_filenames: false + - id: pytest + name: Run pytest + entry: pytest + language: system + args: [tests/] + pass_filenames: false + files: ^tests/.+$|simplug\.py$ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..feb0f64 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 pwwang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..79009cf --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +# simplug + +A simple entrypoint-free plugin system for python + +## Installation + +``` +pip install -U simplug +``` + +## Features +- Entrypoint-free, meaning you can configure the plugins to be loaded in your host +- Loading priority definition while implementing a plugin +- Required hooks (hooks required to be implemented in plugins) +- Different ways to fetch results from hooks + +## Examples + +### A toy example + +```python +from simplug import Simplug + +simplug = Simplug('project') + +class MySpec: + """A hook specification namespace.""" + + @simplug.spec + def myhook(self, arg1, arg2): + """My special little hook that you can customize.""" + +class Plugin_1: + """A hook implementation namespace.""" + + @simplug.impl + def myhook(self, arg1, arg2): + print("inside Plugin_1.myhook()") + return arg1 + arg2 + +class Plugin_2: + """A 2nd hook implementation namespace.""" + + @simplug.impl + def myhook(self, arg1, arg2): + print("inside Plugin_2.myhook()") + return arg1 - arg2 + +simplug.register(Plugin_1, Plugin_2) +results = simplug.hooks.myhook(arg1=1, arg2=2) +print(results) +``` + +``` +inside Plugin_1.myhook() +inside Plugin_2.myhook() +[3, -1] +``` + +Note that the hooks are executed in the order the plugins are registered. This is different from `pluggy`. + +### A complete example + +See `examples/complete/`. + +Running `python -m examples/complete/` gets us: +``` +Your food. Enjoy some egg, egg, egg, salt, pepper, egg, egg, lovely spam, wonderous spam +Some condiments? We have pickled walnuts, mushy peas, mint sauce, spam sauce +Now this is what I call a condiments tray! +``` + +## Usage +### Definition of hooks + +Hooks are specified and implemented by decorating the functions with `simplug.spec` and `simplug.impl` respectively. + +`simplug` is initialized by: +```python +simplug = Simplug('project') +``` + +The `'project'` is a unique name to mark the project, which makes sure `Simplug('project')` get the same instance each time. + +Note that if `simplug` is initialized without `project`, then a name is generated automatically as such `project-0`, `project-1`, etc. + +Hook specification is marked by `simplug.spec`: +```python +simplug = Simplug('project') + +@simplug.spec +def setup(args): + ... +``` + +`simplug.spec` can take two keyword-arguments: + +- `required`: Whether this hook is required to be implemented in plugins +- `result`: An enumerator to specify the way to collec the results. + - SimplugResult.ALL: Get all the results from the hook, as a list + including `NONE`s + - SimplugResult.ALL_BUT_NONE: Get all the results from the hook, + as a list, not including `NONE`s + - SimplugResult.FIRST: Get the none-`None` result from the + first plugin only (ordered by priority) + - SimplugResult.LAST: Get the none-`None` result from + the last plugin only + +Hook implementation is marked by `simplug.impl`, which takes no additional arguments. + +The name of the function has to match the name of the function by `simplug.spec`. And the signatures of the specification function and the implementation function have to be the same in terms of names. This means you can specify default values in the specification function, but you don't have to write the default values in the implementation function. + +Note that default values in implementation functions will be ignored. + +Also note if a hook specification is under a namespace, it can take `self` as argument. However, this argument will be ignored while the hook is being called (`self` will be `None`, and you still have to specify it in the function definition). + +### The plugin registry + +The plugins are registered by `simplug.register(*plugins)`. Each plugin of `plugins` can be either a python object or a str denoting a module that can be imported by `importlib.import_module`. + +The python object must have an attribute `name`, `__name__` or `__class.__name__` for `simplug` to determine the name of the plugin. If the plugin name is determined from `__name__` or `__class__.__name__`, it will be lowercased. + +You can enable or disable a plugin temporarily after registration by: +```python +simplug.disable('plugin_name') +simplug.enable('plugin_name') +``` + +You can use following methods to inspect the plugin registry: + +- `simplug.get_plugin`: Get the plugin by name +- `simplug.get_all_plugins`: Get a dictionary of name-plugin mappings of all plugins +- `simplug.get_all_plugin_names`: Get the names of all plugins, in the order it will be executed. + +### Calling hooks + +Hooks are call by `simplug.hooks.()` and results are collected based on the `result` argument passed in `simplug.spec` when defining hooks. + +## API + +https://pwwang.github.io/simplug/ diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..3d202af --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,3 @@ +mkdocs-material +mkapi +pymdown-extensions diff --git a/docs/style.css b/docs/style.css new file mode 100644 index 0000000..81b80ac --- /dev/null +++ b/docs/style.css @@ -0,0 +1,115 @@ + + +.md-typeset .admonition, .md-typeset details { + font-size: .7rem !important; +} + +.md-typeset table:not([class]) td { + padding: .55em 1.25em !important; +} + +.md-typeset table:not([class]) th { + padding: .75em 1.25em !important; +} + +.mkapi-docstring{ + line-height: 1; +} +.mkapi-node { + background-color: #f0f6fa; + border-top: 3px solid #559bc9; +} +.mkapi-node .mkapi-object-container { + background-color: #b4d4e9; + padding: .12em .4em; +} +.mkapi-node .mkapi-object-container .mkapi-object.code { + background: none; + border: none; +} +.mkapi-node .mkapi-object-container .mkapi-object.code * { + font-size: .65rem !important; +} +.mkapi-node pre { + line-height: 1.5; +} +.md-typeset pre>code { + overflow: visible; + line-height: 1.2; +} +.mkapi-docstring .md-typeset pre>code { + font-size: 0.1rem !important; +} +.mkapi-section-name.bases { + margin-top: .2em; +} +.mkapi-section-body.bases { + padding-bottom: .7em; + line-height: 1.3; +} +.mkapi-section.bases { + margin-bottom: .8em; +} +.mkapi-node * { + font-size: .7rem; +} +.mkapi-node a.mkapi-src-link { + word-break: keep-all; +} +.mkapi-docstring { + padding: .4em .15em !important; +} +.mkapi-section-name-body { + font-size: .72rem !important; +} +.mkapi-node ul.mkapi-items li { + line-height: 1.4 !important; +} +.mkapi-node ul.mkapi-items li * { + font-size: .65rem !important; +} +.mkapi-node code.mkapi-object-signature { + padding-right: 2px; +} +.mkapi-node .mkapi-code * { + font-size: .65rem; +} +.mkapi-node a.mkapi-docs-link { + font-size: .6rem; +} +.mkapi-node h1.mkapi-object.mkapi-object-code { + margin: .2em .3em; +} +.mkapi-node h1.mkapi-object.mkapi-object-code .mkapi-object-kind.mkapi-object-kind-code { + font-style: normal; + margin-right: 16px; +} +.mkapi-node .mkapi-item-name { + font-size: .7rem !important; + color: #555; + padding-right: 4px; +} +.md-typeset { + font-size: .75rem !important; + line-height: 1.5 !important; +} +.mkapi-object-kind.package.top { + font-size: .8rem !important; + color: #111; + +} +.mkapi-object.package.top > h2 { + font-size: .8rem !important; +} + +.mkapi-object-body.package.top * { + font-size: .75rem !important; +} +.mkapi-object-kind.module.top { + font-size: .75rem !important; + color: #222; +} + +.mkapi-object-body.module.top * { + font-size: .75rem !important; +} diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/complete/__init__.py b/examples/complete/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/complete/__main__.py b/examples/complete/__main__.py new file mode 100644 index 0000000..1675546 --- /dev/null +++ b/examples/complete/__main__.py @@ -0,0 +1,4 @@ +from .host import main + +if __name__ == "__main__": + main() diff --git a/examples/complete/hookspecs.py b/examples/complete/hookspecs.py new file mode 100644 index 0000000..647ff36 --- /dev/null +++ b/examples/complete/hookspecs.py @@ -0,0 +1,19 @@ +from simplug import Simplug + +simplug = Simplug('complete-example') + +@simplug.spec +def add_ingredients(ingredients: tuple): + """Have a look at the ingredients and offer your own. + + :param ingredients: the ingredients, don't touch them! + :return: a list of ingredients + """ + +@simplug.spec +def prep_condiments(condiments: dict): + """Reorganize the condiments tray to your heart's content. + + :param condiments: some sauces and stuff + :return: a witty comment about your activity + """ diff --git a/examples/complete/host.py b/examples/complete/host.py new file mode 100644 index 0000000..aad083c --- /dev/null +++ b/examples/complete/host.py @@ -0,0 +1,42 @@ +import itertools + +from simplug import Simplug +# make sure specs are imported +from . import hookspecs +from . import lib + +simplug = Simplug('complete-example') +simplug.register(lib) + +condiments_tray = {"pickled walnuts": 13, "steak sauce": 4, "mushy peas": 2} + +class EggsellentCook: + FAVORITE_INGREDIENTS = ("egg", "egg", "egg") + + def __init__(self, hooks): + self.hooks = hooks + self.ingredients = None + + def add_ingredients(self): + results = self.hooks.add_ingredients( + ingredients=self.FAVORITE_INGREDIENTS + ) + my_ingredients = list(self.FAVORITE_INGREDIENTS) + # Each hooks returns a list - so we chain this list of lists + other_ingredients = list(itertools.chain(*results)) + self.ingredients = my_ingredients + other_ingredients + + def serve_the_food(self): + condiment_comments = self.hooks.prep_condiments( + condiments=condiments_tray + ) + print(f"Your food. Enjoy some {', '.join(self.ingredients)}") + print(f"Some condiments? We have {', '.join(condiments_tray.keys())}") + if any(condiment_comments): + print("\n".join(condiment_comments)) + +def main(): + simplug.register(__name__.replace('.host', '.plugin')) + cook = EggsellentCook(simplug.hooks) + cook.add_ingredients() + cook.serve_the_food() diff --git a/examples/complete/lib.py b/examples/complete/lib.py new file mode 100644 index 0000000..594e8b9 --- /dev/null +++ b/examples/complete/lib.py @@ -0,0 +1,15 @@ +from simplug import Simplug + +simplug = Simplug('complete-example') +priority = -99 # make sure this plugin executes first + +@simplug.impl +def add_ingredients(ingredients): + spices = ["salt", "pepper"] + you_can_never_have_enough_eggs = ["egg", "egg"] + ingredients = spices + you_can_never_have_enough_eggs + return ingredients + +@simplug.impl +def prep_condiments(condiments): + condiments["mint sauce"] = 1 diff --git a/examples/complete/plugin.py b/examples/complete/plugin.py new file mode 100644 index 0000000..70551fe --- /dev/null +++ b/examples/complete/plugin.py @@ -0,0 +1,23 @@ +from simplug import Simplug + +simplug = Simplug('complete-example') + +@simplug.impl +def add_ingredients(ingredients): + """Here the caller expects us to return a list.""" + if "egg" in ingredients: + spam = ["lovely spam", "wonderous spam"] + else: + spam = ["splendiferous spam", "magnificent spam"] + return spam + + +@simplug.impl +def prep_condiments(condiments): + """Here the caller passes a mutable object, so we mess with it directly.""" + try: + del condiments["steak sauce"] + except KeyError: + pass + condiments["spam sauce"] = 42 + return "Now this is what I call a condiments tray!" diff --git a/examples/toy.py b/examples/toy.py new file mode 100644 index 0000000..da45510 --- /dev/null +++ b/examples/toy.py @@ -0,0 +1,30 @@ +from simplug import Simplug + +simplug = Simplug('project') + +class MySpec: + """A hook specification namespace.""" + + @simplug.spec + def myhook(self, arg1, arg2): + """My special little hook that you can customize.""" + +class Plugin_1: + """A hook implementation namespace.""" + + @simplug.impl + def myhook(self, arg1, arg2): + print("inside Plugin_1.myhook()") + return arg1 + arg2 + +class Plugin_2: + """A 2nd hook implementation namespace.""" + + @simplug.impl + def myhook(self, arg1, arg2): + print("inside Plugin_2.myhook()") + return arg1 - arg2 + +simplug.register(Plugin_1, Plugin_2) +results = simplug.hooks.myhook(arg1=1, arg2=2) +print(results) \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..62f0c68 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,17 @@ +site_name: simplug +theme: + name: 'material' +markdown_extensions: + - markdown.extensions.admonition + - pymdownx.superfences: + preserve_tabs: true + - toc: + baselevel: 2 +plugins: + - search # necessary for search to work + - mkapi +extra_css: + - style.css +nav: + - 'Home': 'index.md' + - 'API': mkapi/api/simplug diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..42b461e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[tool.poetry] +name = "simplug" +version = "0.0.1" +description = "A simple entrypoint-free plugin system for python" +authors = ["pwwang "] +license = "MIT" +homepage = "https://github.com/pwwang/simplug" +repository = "https://github.com/pwwang/simplug" +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.6" +diot = "*" + +[tool.poetry.dev-dependencies] +pytest = "*" +pytest-cov = "*" + +[build-system] +requires = ["poetry>=0.12"] +build-backend = "poetry.masonry.api" diff --git a/simplug.py b/simplug.py new file mode 100644 index 0000000..33ef986 --- /dev/null +++ b/simplug.py @@ -0,0 +1,474 @@ +"""A simple entrypoint-free plugin system for python""" +from typing import Any, Callable, Dict, List, Optional, Tuple + +import inspect +from importlib import import_module +from collections import namedtuple +from enum import Enum +from diot import OrderedDiot + +SimplugImpl = namedtuple('SimplugImpl', ['impl']) +SimplugImpl.__doc__ = """A namedtuple wrapper for hook implementation. + +This is used to mark the method/function to be an implementation of a hook. + +Args: + impl: The hook implementation +""" + + +class SimplugException(Exception): + """Base exception class for simplug""" + +class NoSuchPlugin(SimplugException): + """When a plugin cannot be imported""" + +class NoPluginNameDefined(SimplugException): + """When the name of the plugin cannot be found""" + +class HookSignatureDifferentFromSpec(SimplugException): + """When the hook signature is different from spec""" + +class NoSuchHookSpec(SimplugException): + """When implemented a undefined hook or calling a non-exist hook""" + +class HookRequired(SimplugException): + """When a required hook is not implemented""" + +class HookSpecExists(SimplugException): + """When a hook has already been defined""" + +class SimplugResult(Enum): + """Way to get the results from the hooks + + Attributes: + ALL: Get all the results from the hook, as a list + including `NONE`s + ALL_BUT_NONE: Get all the results from the hook, as a list + not including `NONE`s + FIRST: Get the none-`None` result from the first plugin only + (ordered by priority) + LAST: Get the none-`None` result from the last plugin only + """ + ALL = 'all' + ALL_BUT_NONE = 'all_but_none' + FIRST = 'first' + LAST = 'last' + +class SimplugWrapper: + """A wrapper for plugin + + Args: + plugin: A object or a string indicating the plugin as a module + batch_index: The batch_index when the plugin is registered + >>> simplug = Simplug() + >>> simplug.register('plugin1', 'plugin2') # batch 0 + >>> # index:0, index:1 + >>> simplug.register('plugin3', 'plugin4') # batch 1 + >>> # index:0, index:1 + index: The index when the plugin is registered + + Attributes: + plugin: The raw plugin object + priority: A 2-element tuple used to prioritize the plugins + - If `plugin.priority` is specified, use it as the first element + and batch_index will be the second element + - Otherwise, batch_index the first and index the second. + - Smaller number has higher priority + - Negative numbers allowed + + Raises: + NoSuchPlugin: When a string is passed in and the plugin cannot be + imported as a module + """ + + def __init__(self, plugin: Any, batch_index: int, index: int): + if isinstance(plugin, str): + try: + plugin = import_module(plugin) + except ImportError as exc: + raise NoSuchPlugin(plugin).with_traceback( + exc.__traceback__ + ) from None + + self.plugin = plugin # type: object + + priority = getattr(self.plugin, 'priority', None) + self.priority = ((batch_index, index) + if priority is None + else (priority, batch_index)) # type: Tuple[int, int] + + self.enabled = True # type: bool + + @property + def name(self) -> str: + """Try to get the name of the plugin. + + A lowercase name is recommended. + + if `.name` is defined, then the name is used. Otherwise, + `.__name__` is used. Finally, `.__class__.__name__` is + tried. + + Raises: + NoPluginNameDefined: When a name cannot be retrieved. + + Returns: + The name of the plugin + """ + try: + return self.plugin.name + except AttributeError: + pass + + try: + return self.plugin.__name__.lower() + except AttributeError: + pass + + try: + return self.plugin.__class__.__name__.lower() + except AttributeError: # pragma: no cover + pass + + raise NoPluginNameDefined(str(self.plugin)) # pragma: no cover + + def enable(self) -> None: + """Enable this plugin""" + self.enabled = True + + def disable(self): + """Disable this plugin""" + self.enabled = False + + def hook(self, name: str) -> Optional[SimplugImpl]: + """Get the hook implementation of this plugin by name + + Args: + name: The name of the hook + + Returns: + The wrapper of the implementation. If the implementation is not + found or it's not decorated by `simplug.impl`, None will be + returned. + """ + ret = getattr(self.plugin, name, None) + if not isinstance(ret, SimplugImpl): + return None + return ret + +class SimplugHook: + """A hook of a plugin + + Args: + simplug_hooks: The SimplugHooks object + spec: The specification of the hook + required: Whether this hook is required to be implemented + result: Way to collect the results from the hook + + Attributes: + name: The name of the hook + simplug_hooks: The SimplugHooks object + spec: The specification of the hook + required: Whether this hook is required to be implemented + result: Way to collect the results from the hook + _has_self: Whether the parameters have `self` as the first. If so, + it will be ignored while being called. + """ + def __init__(self, + simplug_hooks: "SimplugHooks", + spec: Callable, + required: bool, + result: SimplugResult): + self.simplug_hooks = simplug_hooks + self.spec = spec + self.name = spec.__name__ + self.required = required + self.result = result + self._has_self = list(inspect.signature(spec).parameters)[0] == 'self' + + def __call__(self, *args, **kwargs): + """Call the hook in your system + + Args: + *args: args for the hook + **kwargs: kwargs for the hook + + Returns: + Depending on `self.result`: + - SimplugResult.ALL: Get all the results from the hook, as a list + including `NONE`s + - SimplugResult.ALL_BUT_NONE: Get all the results from the hook, + as a list, not including `NONE`s + - SimplugResult.FIRST: Get the none-`None` result from the + first plugin only (ordered by priority) + - SimplugResult.LAST: Get the none-`None` result from + the last plugin only + """ + self.simplug_hooks._sort_registry() + results = [] + for plugin in self.simplug_hooks._registry.values(): + if not plugin.enabled: + continue + hook = plugin.hook(self.name) + if hook is not None: + results.append(hook.impl(None, *args, **kwargs) + if self._has_self + else hook.impl(*args, **kwargs)) + + if self.result == SimplugResult.ALL: + return results + + results = [result for result in results if result is not None] + if self.result == SimplugResult.FIRST: + return results[0] if results else None + + if self.result == SimplugResult.LAST: + return results[-1] if results else None + # ALL_BUT_NONE + return results + +class SimplugHooks: + """The hooks manager + + Methods in this class are prefixed with a underscore to attributes clean + for hooks. + + To call a hook in your system: + >>> simplug.hooks.() + + Attributes: + _registry: The plugin registry + _specs: The registry for the hook specs + _registry_sorted: Whether the plugin registry has been sorted already + """ + + def __init__(self): + + self._registry = OrderedDiot() # type: OrderedDiot + self._specs = {} # type: Dict[str, SimplugHook] + self._registry_sorted = False # type: bool + + def _register(self, plugin: SimplugWrapper) -> None: + """Register a plugin (already wrapped by SimplugWrapper) + + Args: + plugin: The plugin wrapper + + Raises: + HookRequired: When a required hook is not implemented + HookSignatureDifferentFromSpec: When the arguments of a hook + implementation is different from its specification + """ + # check if required hooks implemented + # and signature + for specname, spec in self._specs.items(): + hook = plugin.hook(specname) + if spec.required and hook is None: + raise HookRequired(f'{specname}, but not implemented ' + f'in plugin {plugin.name}') + if hook is None: + continue + if (inspect.signature(hook.impl).parameters.keys() != + inspect.signature(spec.spec).parameters.keys()): + raise HookSignatureDifferentFromSpec( + f'{specname!r} in plugin {plugin.name}\n' + f'Expect {inspect.signature(spec.spec).parameters.keys()}, ' + f'but got {inspect.signature(hook.impl).parameters.keys()}' + ) + self._registry[plugin.name] = plugin + + def _sort_registry(self) -> None: + """Sort the registry by the priority only once""" + if self._registry_sorted: + return + orderedkeys = self._registry.__diot__['orderedkeys'] + self._registry.__diot__['orderedkeys'] = sorted( + orderedkeys, + key=lambda plug: self._registry[plug].priority + ) + self._registry_sorted = True + + def __getattr__(self, name: str) -> "SimplugHook": + """Get the hook by name + + Args: + name: The hook name + + Returns: + The SimplugHook object + + Raises: + NoSuchHookSpec: When the hook has no specification defined. + """ + try: + return self._specs[name] + except KeyError as exc: + raise NoSuchHookSpec(name).with_traceback( + exc.__traceback__ + ) from None + +class Simplug: + """The plugin manager for simplug + + Attributes: + PROJECT_INDEX: The project index to name the project by default + PROJECTS: The projects registry, to make sure the same `Simplug` + object by the name project name. + + _batch_index: The batch index for plugin registration + hooks: The hooks manager + _inited: Whether `__init__` has already been called. Since the + `__init__` method will be called after `__new__`, this is used to + avoid `__init__` to be called more than once + """ + + PROJECT_INDEX: int = 0 + PROJECTS: Dict[str, "Simplug"] = {} + + def __new__(cls, project: Optional[str] = None) -> "Simplug": + proj_name = project + if proj_name is None: + proj_name = f"project-{cls.PROJECT_INDEX}" + cls.PROJECT_INDEX += 1 + + if proj_name not in cls.PROJECTS: + cls.PROJECTS[proj_name] = super().__new__(cls) + + return cls.PROJECTS[proj_name] + + def __init__(self, + # pylint: disable=unused-argument + project: Optional[str] = None): + if getattr(self, '_inited', None): + return + self._batch_index = 0 + self.hooks = SimplugHooks() + self._inited = True + + def register(self, *plugins: Any) -> None: + """Register plugins + + Args: + *plugins: The plugins, each of which could be a str, indicating + that the plugin is a module and will be imported by + `__import__`; or an object with the hook implementations as + its attributes. + """ + for i, plugin in enumerate(plugins): + plugin = SimplugWrapper(plugin, self._batch_index, i) + self.hooks._register(plugin) + + self._batch_index += 1 + + def get_plugin(self, name: str, raw: bool = False) -> object: + """Get the plugin wrapper or the raw plugin object + + Args: + name: The name of the plugin + raw: Get the raw plugin object (the one when it's registered) + If a plugin is a module and registered by its name, the + module is returned + + Raises: + NoSuchPlugin: When the plugin does not exist + + Returns: + The plugin wrapper or raw plugin + """ + if name not in self.hooks._registry: + raise NoSuchPlugin(name) + wrapper = self.hooks._registry[name] + return wrapper.plugin if raw else wrapper + + def get_all_plugins(self, + raw: bool = False) -> Dict[str, SimplugWrapper]: + """Get a mapping of all plugins + + Args: + raw: Whether return the raw plugin or not + (the one when it's registered) + If a plugin is registered as a module by its name, the module + is returned. + + Returns: + The mapping of all plugins + """ + if not raw: + return self.hooks._registry + return OrderedDiot([(name, plugin.plugin) + for name, plugin + in self.hooks._registry.items()]) + + def get_all_plugin_names(self) -> List[str]: + """Get the names of all plugins + + Returns: + The names of all plugins + """ + return list(self.hooks._registry.keys()) + + def enable(self, name: str) -> None: + """Enable a plugin by name + + Args: + name: The name of the plugin + """ + self.get_plugin(name).enable() + + def disable(self, name: str): + """Disable a plugin by name + + Args: + name: The name of the plugin + """ + self.get_plugin(name).disable() + + def spec(self, + hook: Optional[Callable] = None, + required: bool = False, + result: SimplugResult = SimplugResult.ALL_BUT_NONE) -> Callable: + """A decorator to define the specification of a hook + + Args: + hook: The hook spec. If it is None, that means this decorator is + called with arguments, and it should be keyword arguments. + Otherwise, it is called like this `simplug.spec` + required: Whether this hook is required to be implemented. + result: How should we collect the results from the plugins + + Raises: + HookSpecExists: If a hook spec with the same name (`hook.__name__`) + is already defined. + + Returns: + A decorator function of other argument is passed, or the hook spec + itself. + """ + def decorator(hook_func: Callable): + hook_name = hook_func.__name__ + if hook_name in self.hooks._specs: + raise HookSpecExists(hook_name) + self.hooks._specs[hook_name] = SimplugHook(self.hooks, + hook_func, + required, + result) + return hook_func + + return decorator(hook) if hook else decorator + + def impl(self, hook: Callable): + """A decorator for the implementation of a hook + + Args: + hook: The hook implementation + + Raises: + NoSuchHookSpec: When no specification is defined for this hook + + Returns: + The wrapped hook implementation by `SimplugImpl` + """ + if hook.__name__ not in self.hooks._specs: + raise NoSuchHookSpec(hook.__name__) + return SimplugImpl(hook) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/plugin_module.py b/tests/plugin_module.py new file mode 100644 index 0000000..8d6a319 --- /dev/null +++ b/tests/plugin_module.py @@ -0,0 +1,10 @@ +from simplug import Simplug + +simplug = Simplug('simplug_module') + +name = 'module_plugin' + +@simplug.impl +def on_init(self, arg): + print('Arg:', arg) + diff --git a/tests/test_simplug.py b/tests/test_simplug.py new file mode 100644 index 0000000..e0c9c4d --- /dev/null +++ b/tests/test_simplug.py @@ -0,0 +1,195 @@ +import pytest +from simplug import * + +def test_simplug(capsys): + + simplug = Simplug() + + class PluginSpec: + + @simplug.spec + def pre_init(self, a=1): + pass + + @simplug.spec(required=True) + def on_init(self, arg): + pass + + @simplug.spec(result=SimplugResult.FIRST) + def first_result(self, b=1): + pass + + @simplug.spec(result=SimplugResult.LAST) + def last_result(self, c=1): + pass + + @simplug.spec(result=SimplugResult.ALL) + def all_result(self, d=1): + pass + + @simplug.spec(result=SimplugResult.ALL_BUT_NONE) + def on_end(self, e=1): + pass + + class System: + + def __init__(self): + simplug.hooks.on_init('arg') + + def first(self): + return simplug.hooks.first_result(1) + + def last(self): + return simplug.hooks.last_result(1) + + def all(self): + return simplug.hooks.all_result(1) + + def end(self): + return simplug.hooks.on_end(1) + + def no_such_hooks(self): + return simplug.hooks._no_such_hook() + + class Plugin1: + + def __init__(self, name): + self.__name__ = name + + @simplug.impl + def on_init(self, arg): + print('Arg:', arg) + + + class Plugin2: + + @simplug.impl + def on_init(self, arg): + print('Arg:', arg) + + class Plugin3: + + @simplug.impl + def on_init(self, arg): + pass + + @simplug.impl + def first_result(self, b): + return 30 + + @simplug.impl + def last_result(self, c): + return 300 + + @simplug.impl + def all_result(self, d): + return 5000 + + @simplug.impl + def on_end(self, e): + return None + + class Plugin4: + + priority = -1 + + @simplug.impl + def on_init(self, arg): + pass + + @simplug.impl + def first_result(self, b): + return 40 + + @simplug.impl + def last_result(self, c): + return 400 + + @simplug.impl + def all_result(self, d): + return None + + @simplug.impl + def on_end(self, e): + return None + + class Plugin5: + ... + + class Plugin6: + + @simplug.impl + def on_init(self, diff_arg): + pass + + with pytest.raises(HookSpecExists): + @simplug.spec + def on_init(): pass + + with pytest.raises(NoSuchHookSpec): + @simplug.impl + def no_such_hook(): pass + + with pytest.raises(NoSuchPlugin): + simplug.register('nosuch') + with pytest.raises(HookRequired): + simplug.register(Plugin5) + with pytest.raises(HookSignatureDifferentFromSpec): + simplug.register(Plugin6) + + plug1 = Plugin1('plugin-1') + plug2 = Plugin2() + simplug.register(plug1, Plugin1, plug2, Plugin3, Plugin4) + s = System() + s.first() == 40 + s.last() == 300 + s.all() == [None] * 5 + s.end() is None + assert 'Arg: arg\n' * 3 == capsys.readouterr().out + + with pytest.raises(NoSuchHookSpec): + s.no_such_hooks() + + simplug.disable('plugin2') + System() + assert 'Arg: arg\n' * 2 == capsys.readouterr().out + + simplug.enable('plugin2') + System() + assert 'Arg: arg\n' * 3 == capsys.readouterr().out + + with pytest.raises(NoSuchPlugin): + simplug.get_plugin('nosuchplugin') + + assert simplug.get_all_plugin_names() == ['plugin4', 'plugin-1', 'plugin1', + 'plugin2', 'plugin3'] + + all_plugins = simplug.get_all_plugins() + assert isinstance(all_plugins, OrderedDiot) + assert list(all_plugins.keys()) == ['plugin4', 'plugin-1', 'plugin1', + 'plugin2', 'plugin3'] + assert simplug.get_all_plugins(raw=True) == { + 'plugin-1': plug1, + 'plugin1': Plugin1, + 'plugin2': plug2, + 'plugin3': Plugin3, + 'plugin4': Plugin4 + } + +def test_simplug_module(capsys): + simplug = Simplug('simplug_module') + + class PluginSpec: + @simplug.spec + def on_init(self, arg): + pass + + class System: + + def __init__(self): + simplug.hooks.on_init('arg') + + simplug.register(f"{'.'.join(__name__.split('.')[:-1])}.plugin_module") + System() + + assert 'Arg: arg\n' == capsys.readouterr().out diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..8fae787 --- /dev/null +++ b/tox.ini @@ -0,0 +1,3 @@ +[pytest] +addopts = -vv --cov=simplug --cov-report xml:.coverage.xml --cov-report term-missing +console_output_style = progress