├── tests ├── testdata │ ├── __init__.py │ ├── bad │ │ ├── __init__.py │ │ └── syntax.py │ ├── lib │ │ ├── __init__.py │ │ ├── engines │ │ │ ├── __init__.py │ │ │ └── steam.py │ │ ├── hooks │ │ │ ├── __init__.py │ │ │ ├── left.py │ │ │ └── right.py │ │ └── parsers │ │ │ ├── __init__.py │ │ │ ├── xml.py │ │ │ └── json.py │ ├── importer │ │ └── __init__.py │ ├── bare │ │ ├── package │ │ │ ├── __init__.py │ │ │ └── parsers │ │ │ │ ├── __init__.py │ │ │ │ └── silly_walks.py │ │ ├── engines │ │ │ └── electric.py │ │ └── hooks │ │ │ ├── grappling.py │ │ │ └── fish.py │ ├── bad2.py │ └── parents.py ├── __init__.py ├── test_exceptions.py ├── test_util.py ├── test_objects.py ├── test_loader.py └── test_parent.py ├── requirements.txt ├── MANIFEST.in ├── .sourcery.yaml ├── doc ├── spelling_wordlist.txt ├── api.rst ├── error_handling.rst ├── conf.py ├── index.rst ├── faq.rst └── concepts.rst ├── .readthedocs.yaml ├── setup.cfg ├── pluginlib ├── __init__.py ├── exceptions.py ├── _util.py ├── _objects.py ├── _loader.py └── _parent.py ├── tox.ini ├── pylintrc ├── setup.py ├── .github └── workflows │ └── tests.yml ├── setup_helpers.py ├── README.rst └── LICENSE /tests/testdata/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testdata/bad/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testdata/lib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testdata/importer/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testdata/lib/engines/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testdata/lib/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testdata/lib/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testdata/bare/package/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testdata/bare/package/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | packaging 2 | importlib-metadata; python_version < "3.10" 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE setup_helpers.py README.* 2 | graft tests 3 | graft doc 4 | global-exclude *.pyc __pycache__ 5 | -------------------------------------------------------------------------------- /.sourcery.yaml: -------------------------------------------------------------------------------- 1 | refactor: 2 | skip: 3 | - use-named-expression # Python 3.8+ 4 | 5 | clone_detection: 6 | min_lines: 4 7 | -------------------------------------------------------------------------------- /doc/spelling_wordlist.txt: -------------------------------------------------------------------------------- 1 | builtin 2 | filesystem 3 | iterable 4 | namespace 5 | Pluginlib 6 | subclasses 7 | subclassing 8 | tuples 9 | wildcard 10 | -------------------------------------------------------------------------------- /tests/testdata/bad/syntax.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 - 2020 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | Bad Module that will raise syntax error when imported 9 | """ 10 | 11 | # Bad syntax, left off colon and didn't finish statement 12 | for key in [] # noqa: E999 13 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs Configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Version is required 5 | version: 2 6 | 7 | # Set the version of Python and other tools you might need 8 | build: 9 | os: ubuntu-lts-latest 10 | tools: 11 | python: '3' 12 | jobs: 13 | pre_build: 14 | - pip install sphinx sphinx_rtd_theme sphinxcontrib-spelling 15 | 16 | # Build documentation in the docs/ directory with Sphinx 17 | sphinx: 18 | configuration: doc/conf.py 19 | 20 | python: 21 | install: 22 | - requirements: requirements.txt 23 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2025 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Test module for pluginlib** 9 | """ 10 | 11 | import logging 12 | from io import StringIO 13 | 14 | from pluginlib._util import LOGGER 15 | 16 | 17 | OUTPUT = StringIO() 18 | HANDLER = logging.StreamHandler(OUTPUT) 19 | LOGGER.addHandler(HANDLER) 20 | LOGGER.setLevel(logging.INFO) 21 | 22 | __all__ = ['OUTPUT'] 23 | -------------------------------------------------------------------------------- /tests/testdata/lib/parsers/xml.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2020 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **XML parser** 9 | Test data standard library 10 | """ 11 | 12 | from __future__ import absolute_import 13 | from tests.testdata.parents import Parser 14 | 15 | 16 | class XML(Parser): 17 | """Dummy XML parser""" 18 | 19 | _alias_ = 'xml' 20 | 21 | def parse(self): 22 | return 'xml' 23 | -------------------------------------------------------------------------------- /tests/testdata/lib/engines/steam.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2020 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Steam engine** 9 | Test data standard library 10 | """ 11 | 12 | from __future__ import absolute_import 13 | from tests.testdata.parents import Engine 14 | 15 | 16 | class Steam(Engine): 17 | """Dummy steam engine""" 18 | 19 | _alias_ = 'steam' 20 | 21 | def start(self): 22 | return 'toot' 23 | -------------------------------------------------------------------------------- /tests/testdata/bare/engines/electric.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2020 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Electric engine** 9 | Test data standard library 10 | """ 11 | 12 | from __future__ import absolute_import 13 | from tests.testdata.parents import Engine 14 | 15 | 16 | class Electric(Engine): 17 | """Dummy electric engine""" 18 | 19 | _alias_ = 'electric' 20 | 21 | def start(self): 22 | return 'beep' 23 | -------------------------------------------------------------------------------- /tests/testdata/lib/hooks/left.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2020 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Left hook** 9 | Test data standard library 10 | """ 11 | 12 | from __future__ import absolute_import 13 | from tests.testdata.parents import Hook 14 | 15 | 16 | class Left(Hook): 17 | """Dummy left hook""" 18 | 19 | _alias_ = 'left' 20 | 21 | def hook(self): 22 | """Throw hook""" 23 | return self._alias_ 24 | -------------------------------------------------------------------------------- /tests/testdata/lib/hooks/right.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2020 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Right hook** 9 | Test data standard library 10 | """ 11 | 12 | from __future__ import absolute_import 13 | from tests.testdata.parents import Hook 14 | 15 | 16 | class Right(Hook): 17 | """Dummy right hook""" 18 | 19 | _alias_ = 'right' 20 | 21 | def hook(self): 22 | """Throw hook""" 23 | return self._alias_ 24 | -------------------------------------------------------------------------------- /tests/testdata/bad2.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 - 2018 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | Bad Module that will raise syntax error when imported 9 | """ 10 | 11 | 12 | def func1(): 13 | """Raises an exception""" 14 | raise RuntimeError('This parrot is no more') 15 | 16 | 17 | def func2(): 18 | """Calls function 1""" 19 | func1() 20 | 21 | 22 | def func3(): 23 | """Calls function 2""" 24 | func2() 25 | 26 | 27 | func3() 28 | -------------------------------------------------------------------------------- /tests/testdata/bare/package/parsers/silly_walks.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2020 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Silly Walk parser** 9 | Test data standard library 10 | """ 11 | 12 | from __future__ import absolute_import 13 | from tests.testdata.parents import Parser 14 | 15 | 16 | class SillyWalk(Parser): 17 | """Dummy silly walk parser""" 18 | 19 | _alias_ = 'sillywalk' 20 | 21 | def parse(self): 22 | return 'sillywalk' 23 | -------------------------------------------------------------------------------- /tests/testdata/bare/hooks/grappling.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2020 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Grappling hook** 9 | Test data standard library 10 | """ 11 | 12 | from __future__ import absolute_import 13 | from tests.testdata.parents import Hook 14 | 15 | 16 | class Grappling(Hook): 17 | """Dummy grappling hook""" 18 | 19 | _alias_ = 'grappling' 20 | 21 | def hook(self): 22 | """Throw hook""" 23 | return self._alias_ 24 | -------------------------------------------------------------------------------- /tests/testdata/bare/hooks/fish.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2022 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Fish hook** 9 | Test data standard library 10 | """ 11 | 12 | from __future__ import absolute_import 13 | from tests.testdata.parents import Hook 14 | 15 | __version__ = '1.2.3' 16 | 17 | 18 | class Fish(Hook): 19 | """Dummy fish hook""" 20 | 21 | _alias_ = 'fish' 22 | 23 | def hook(self): 24 | """Throw hook""" 25 | return self._alias_ 26 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [metadata] 5 | description_file = README.rst 6 | license_files = LICENSE 7 | 8 | [flake8] 9 | builtins = __path__ 10 | max-line-length = 120 11 | 12 | [pycodestyle] 13 | max-line-length = 120 14 | 15 | [coverage:run] 16 | branch = True 17 | source = 18 | pluginlib 19 | 20 | [coverage:report] 21 | show_missing: True 22 | fail_under: 100 23 | exclude_lines = 24 | pragma: no cover 25 | 26 | [build_sphinx] 27 | source-dir = doc 28 | build-dir = build/doc 29 | all_files = True 30 | fresh-env = True 31 | 32 | [aliases] 33 | spelling=build_sphinx --builder spelling 34 | html=build_sphinx --builder html 35 | 36 | [doc8] 37 | max-line-length=100 38 | -------------------------------------------------------------------------------- /tests/testdata/lib/parsers/json.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2020 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **JSON parser** 9 | Test data standard library 10 | """ 11 | 12 | from __future__ import absolute_import 13 | from tests.testdata.parents import Parser 14 | 15 | 16 | class JSON(Parser): 17 | """Dummy JSON parser""" 18 | 19 | _alias_ = 'json' 20 | _version_ = '1.0' 21 | 22 | def parse(self): 23 | return 'json' 24 | 25 | 26 | class JSON2(Parser): 27 | """Dummy JSON parser""" 28 | 29 | _alias_ = 'json' 30 | _version_ = '2.0' 31 | 32 | def parse(self): 33 | return 'json' 34 | -------------------------------------------------------------------------------- /tests/testdata/parents.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2025 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **pluginlib test data parents** 9 | Parent classes for tests 10 | """ 11 | 12 | from pluginlib import Parent, abstractmethod 13 | 14 | 15 | @Parent('parser', 'testdata') 16 | class Parser: 17 | """Parser parent class""" 18 | 19 | @abstractmethod 20 | def parse(self): 21 | """Required method""" 22 | 23 | 24 | @Parent('engine', 'testdata') 25 | class Engine: 26 | """Engine parent class""" 27 | 28 | @abstractmethod 29 | def start(self): 30 | """Required method""" 31 | 32 | 33 | @Parent('hook', 'testdata') 34 | class Hook: 35 | """Hook parent class""" 36 | -------------------------------------------------------------------------------- /pluginlib/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2025 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Pluginlib Package** 9 | 10 | A framework for creating and importing plugins 11 | """ 12 | 13 | __version__ = '0.10.0' 14 | 15 | __all__ = ['abstractmethod', 'abstractattribute', 'BlacklistEntry', 'EntryPointWarning', 'Parent', 16 | 'Plugin', 'PluginlibError', 'PluginImportError', 'PluginLoader', 'PluginWarning'] 17 | 18 | from abc import abstractmethod 19 | 20 | from pluginlib._parent import Parent, Plugin 21 | from pluginlib._loader import PluginLoader 22 | from pluginlib._objects import BlacklistEntry 23 | from pluginlib._util import ( # noqa: F401 24 | abstractstaticmethod, abstractclassmethod, abstractattribute, abstractproperty 25 | ) 26 | from pluginlib.exceptions import PluginlibError, PluginImportError, PluginWarning, EntryPointWarning 27 | -------------------------------------------------------------------------------- /doc/api.rst: -------------------------------------------------------------------------------- 1 | .. 2 | Copyright 2020 Avram Lubkin, All Rights Reserved 3 | 4 | This Source Code Form is subject to the terms of the Mozilla Public 5 | License, v. 2.0. If a copy of the MPL was not distributed with this 6 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | :github_url: https://github.com/Rockhopper-Technologies/pluginlib 9 | 10 | .. _API-Reference: 11 | 12 | API Reference 13 | ============= 14 | 15 | Classes 16 | ------- 17 | 18 | .. py:module:: pluginlib 19 | 20 | .. autoclass:: Plugin 21 | :members: 22 | :exclude-members: plugin_group, plugin_type 23 | 24 | .. autoclass:: PluginLoader 25 | :members: 26 | 27 | .. autoclass:: BlacklistEntry 28 | :members: 29 | 30 | .. autoclass:: abstractattribute 31 | 32 | Decorators 33 | ---------- 34 | 35 | .. autodecorator:: Parent 36 | 37 | .. py:decorator:: abstractmethod 38 | 39 | Provides :py:func:`@abc.abstractmethod ` decorator 40 | 41 | Used in parent classes to identify methods required in child plugins 42 | 43 | .. py:decorator:: abstractproperty 44 | 45 | Provides :py:func:`@abc.abstractproperty ` decorator 46 | 47 | Used in parent classes to identify properties required in child plugins 48 | 49 | This decorator has been deprecated since Python 3.3. The preferred implementation is: 50 | 51 | .. code-block:: python 52 | 53 | @property 54 | @pluginlib.abstractmethod 55 | def abstract_property(self): 56 | return self.foo 57 | 58 | .. autodecorator:: abstractstaticmethod 59 | 60 | .. autodecorator:: abstractclassmethod 61 | 62 | Exceptions 63 | ---------- 64 | 65 | .. autoclass:: PluginlibError 66 | :members: 67 | 68 | .. autoclass:: PluginImportError 69 | :members: 70 | 71 | .. autoclass:: PluginWarning 72 | :members: 73 | 74 | .. autoclass:: EntryPointWarning 75 | :members: 76 | -------------------------------------------------------------------------------- /pluginlib/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2025 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Pluginlib Exceptions Submodule** 9 | 10 | Provides exceptions classes 11 | """ 12 | 13 | 14 | class PluginlibError(Exception): 15 | """ 16 | **Base exception class for Pluginlib exceptions** 17 | 18 | All Pluginlib exceptions are derived from this class. 19 | 20 | Subclass of :py:exc:`Exception` 21 | 22 | **Custom Instance Attributes** 23 | 24 | .. py:attribute:: friendly 25 | :annotation: = None 26 | 27 | :py:class:`str` -- Optional friendly output 28 | """ 29 | 30 | def __init__(self, *args, **kwargs): 31 | super().__init__(*args) 32 | self.friendly = kwargs.get('friendly', None) 33 | 34 | 35 | class PluginImportError(PluginlibError): 36 | """ 37 | **Exception class for Pluginlib import errors** 38 | 39 | Subclass of :py:exc:`PluginlibError` 40 | 41 | **Custom Instance Attributes** 42 | 43 | .. py:attribute:: friendly 44 | :annotation: = None 45 | 46 | :py:class:`str` -- May contain abbreviated traceback 47 | 48 | When an exception is raised while importing a module, an attempt is made to create a 49 | "friendly" version of the output with a traceback limited to the plugin itself 50 | or, failing that, the loader module. 51 | 52 | """ 53 | 54 | 55 | class PluginWarning(UserWarning): 56 | """ 57 | Warning for errors with imported plugins 58 | 59 | Subclass of :py:exc:`UserWarning` 60 | """ 61 | 62 | 63 | class EntryPointWarning(ImportWarning): 64 | """ 65 | Warning for errors with importing entry points 66 | 67 | Subclass of :py:exc:`ImportWarning` 68 | """ 69 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | # Pin virtualenv to the last version supporting 3.6 4 | virtualenv<=20.21.1 5 | pip<23.2 6 | ignore_basepython_conflict = True 7 | envlist = 8 | lint 9 | copyright 10 | coverage 11 | docs 12 | py3{12,11,10,9,8,7,6} 13 | pypy{310} 14 | 15 | [base] 16 | deps = 17 | packaging 18 | setuptools 19 | 20 | [testenv] 21 | basepython = python3.13 22 | usedevelop=False 23 | download=True 24 | 25 | deps = 26 | {[base]deps} 27 | 28 | commands = 29 | {envpython} -m unittest discover -s {toxinidir}/tests {posargs} 30 | 31 | [testenv:flake8] 32 | skip_install = True 33 | deps = 34 | flake8 35 | 36 | commands = 37 | flake8 38 | 39 | [testenv:pylint] 40 | skip_install = True 41 | ignore_errors=True 42 | deps = 43 | {[base]deps} 44 | pylint 45 | pyenchant 46 | 47 | commands = 48 | pylint pluginlib setup setup_helpers tests 49 | 50 | [testenv:lint] 51 | skip_install = True 52 | ignore_errors=True 53 | deps = 54 | {[testenv:flake8]deps} 55 | {[testenv:pylint]deps} 56 | 57 | commands = 58 | {[testenv:flake8]commands} 59 | {[testenv:pylint]commands} 60 | 61 | [testenv:coverage] 62 | passenv = 63 | CI 64 | CODECOV_* 65 | GITHUB_* 66 | deps = 67 | {[base]deps} 68 | coverage 69 | 70 | commands = 71 | coverage run -m unittest discover -s {toxinidir}/tests {posargs} 72 | coverage xml 73 | coverage report 74 | 75 | [testenv:docs] 76 | deps = 77 | sphinx 78 | sphinxcontrib-spelling 79 | sphinx_rtd_theme 80 | 81 | commands= 82 | {envpython} setup_helpers.py spelling-clean 83 | sphinx-build -vWEa --keep-going -b spelling doc build/doc 84 | {envpython} setup_helpers.py spelling 85 | sphinx-build -vWEa --keep-going -b html doc build/doc 86 | {envpython} setup_helpers.py rst2html README.rst 87 | 88 | [testenv:copyright] 89 | skip_install = True 90 | ignore_errors = True 91 | 92 | commands = 93 | {envpython} setup_helpers.py copyright 94 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore=syntax.py 3 | 4 | [REPORTS] 5 | output-format=colorized 6 | 7 | [FORMAT] 8 | # Use max line length of 100 9 | max-line-length=100 10 | 11 | # Regexp for a line that is allowed to be longer than the limit. 12 | # URLs and pure strings 13 | ignore-long-lines=^\s*(# )??$|^\s*[f|r|u]?b?[\"\'\`].+[\"\'\`]$ 14 | 15 | [BASIC] 16 | 17 | good-names=e 18 | 19 | # Regular expression which should only match function or class names that do 20 | # not require a docstring. 21 | no-docstring-rgx=__.*__ 22 | 23 | [DESIGN] 24 | # Minimum number of public methods for a class (see R0903). 25 | min-public-methods=0 26 | 27 | #Maximum number of locals for function / method body 28 | max-locals=20 29 | 30 | # Maximum number of branch for function / method body 31 | max-branches=16 32 | 33 | #Maximum number of arguments for function / method, 34 | max-args=8 35 | 36 | #Maximum number of attributes for a class (see R0902) 37 | max-attributes=8 38 | 39 | [MESSAGES CONTROL] 40 | disable= 41 | locally-disabled, 42 | useless-object-inheritance, 43 | 44 | 45 | [SPELLING] 46 | # Spelling dictionary name. 47 | spelling-dict=en_US 48 | 49 | # List of comma separated words that should not be checked. 50 | spelling-ignore-words= 51 | abstractattribute, abstractclassmethod, abstractmethod, abstractproperty, abstractstaticmethod, 52 | argspecs, asyncio, attr, autoattribute, Args, Avram, Lubkin, 53 | basename, BlacklistEntry, bool, Boolean, 54 | classmethod, cls, coroutine, 55 | EntryPointWarning, errorcode, exc, 56 | FullArgSpec, func, 57 | getset, 58 | importlib, ImportWarning, init, Iterable, iterable, iterables, 59 | json, JSON, 60 | KeyError, 61 | Metaclass, metaclass, Mixin, mixin, Mozilla, MPL, MRO, 62 | namespace, NoneType, noqa, 63 | OrderedDict, 64 | Pluginlib, pluginlib, PluginlibError, PluginLoader, PluginWarning, popitem, pragma, py, pypy, 65 | repr, 66 | setdefault, sphinxcontrib, str, Skipload, staticmethod, submethod, 67 | subclasses, Subpackage, Submodule, submodules, sys, 68 | TestCase, traceback, tuple, tuples, 69 | UserWarning, 70 | wildcard, 71 | XML 72 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 - 2025 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Pluginlib setup file** 9 | """ 10 | import os 11 | 12 | from setuptools import setup, find_packages 13 | 14 | from setup_helpers import get_version, readme 15 | 16 | INSTALL_REQUIRE = ['packaging', 'importlib-metadata; python_version < "3.10"'] 17 | TESTS_REQUIRE = [] 18 | 19 | setup( 20 | name='pluginlib', 21 | version=get_version(os.path.join('pluginlib', '__init__.py')), 22 | description='A framework for creating and importing plugins', 23 | long_description=readme('README.rst'), 24 | url='https://github.com/Rockhopper-Technologies/pluginlib', 25 | license='MPLv2.0', 26 | author='Avram Lubkin', 27 | author_email='avylove@rockhopper.net', 28 | packages=find_packages(exclude=['tests', 'tests.*']), 29 | install_requires=INSTALL_REQUIRE, 30 | tests_require=TESTS_REQUIRE, 31 | zip_safe=False, 32 | classifiers=[ 33 | 'Development Status :: 5 - Production/Stable', 34 | 'Environment :: Plugins', 35 | 'Intended Audience :: Developers', 36 | 'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)', 37 | 'Operating System :: POSIX', 38 | 'Operating System :: Microsoft :: Windows', 39 | 'Operating System :: MacOS', 40 | 'Programming Language :: Python', 41 | 'Programming Language :: Python :: 3.6', 42 | 'Programming Language :: Python :: 3.7', 43 | 'Programming Language :: Python :: 3.8', 44 | 'Programming Language :: Python :: 3.9', 45 | 'Programming Language :: Python :: 3.10', 46 | 'Programming Language :: Python :: 3.11', 47 | 'Programming Language :: Python :: 3.12', 48 | 'Programming Language :: Python :: 3.13', 49 | 'Programming Language :: Python :: Implementation :: CPython', 50 | 'Programming Language :: Python :: Implementation :: PyPy', 51 | 'Topic :: Software Development :: Libraries :: Python Modules', 52 | ], 53 | keywords='plugin, plugins, pluginlib', 54 | test_loader="unittest:TestLoader", 55 | python_requires='>=3.6', 56 | ) 57 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | release: 7 | schedule: 8 | # Every Thursday at 1 AM 9 | - cron: '0 1 * * 4' 10 | 11 | jobs: 12 | 13 | Tests: 14 | runs-on: ${{ matrix.os || 'ubuntu-latest' }} 15 | container: ${{ matrix.container || format('python:{0}', matrix.python-version) }} 16 | name: ${{ (matrix.toxenv && !startsWith(matrix.toxenv, 'py')) && format('{0} ({1})', matrix.toxenv, matrix.python-version) || matrix.python-version }} ${{ matrix.optional && '[OPTIONAL]' }} 17 | continue-on-error: ${{ matrix.optional || false }} 18 | 19 | strategy: 20 | fail-fast: false 21 | 22 | matrix: 23 | python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] 24 | include: 25 | 26 | - python-version: '3.13' 27 | toxenv: lint 28 | os-deps: 29 | - enchant-2 30 | 31 | - python-version: '3.13' 32 | toxenv: docs 33 | os-deps: 34 | - enchant-2 35 | 36 | - python-version: '3.13' 37 | toxenv: coverage 38 | 39 | - python-version: '3.14' 40 | container: 'python:3.14-rc' 41 | optional: true 42 | 43 | - python-version: pypy-3 44 | toxenv: pypy3 45 | container: pypy:3 46 | 47 | env: 48 | TOXENV: ${{ matrix.toxenv || format('py{0}', matrix.python-version) }} 49 | 50 | steps: 51 | # This is only needed for Python 3.6 and earlier because Tox 4 requires 3.7+ 52 | - name: Fix TOXENV 53 | run: echo "TOXENV=$(echo $TOXENV | sed 's/\.//g')" >> $GITHUB_ENV 54 | if: ${{ contains(fromJson('["3.6"]'), matrix.python-version) }} 55 | 56 | - name: Install OS Dependencies 57 | run: apt update && apt -y install ${{ join(matrix.os-deps, ' ') }} 58 | if: ${{ matrix.os-deps }} 59 | 60 | - uses: actions/checkout@v4 61 | 62 | # https://github.com/actions/checkout/issues/1048 63 | - name: Workaround for git ownership issue 64 | run: git config --global --add safe.directory $GITHUB_WORKSPACE 65 | 66 | - name: Install tox 67 | run: pip install tox 68 | 69 | - name: Run tox 70 | run: tox -- --verbose 71 | 72 | - name: Upload coverage to Codecov 73 | uses: codecov/codecov-action@v4 74 | with: 75 | verbose: true 76 | fail_ci_if_error: true 77 | token: ${{ secrets.CODECOV_TOKEN }} 78 | if: ${{ matrix.toxenv == 'coverage' }} 79 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2025 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Test module for pluginlib.exceptions** 9 | """ 10 | 11 | import warnings 12 | from unittest import TestCase 13 | 14 | from pluginlib import exceptions 15 | 16 | 17 | # Because we want the test names to match the class names 18 | # pylint: disable=invalid-name 19 | class TestExceptions(TestCase): 20 | """Tests for custom exceptions""" 21 | 22 | def setUp(self): 23 | self.msg = 'Just your friendly neighborhood Spiderman' 24 | 25 | def test_pluginlib_error(self): 26 | """Raises PluginlibError as expected""" 27 | with self.assertRaises(Exception) as e: 28 | raise exceptions.PluginlibError() 29 | self.assertIsNone(e.exception.friendly) 30 | 31 | with self.assertRaises(Exception) as e: 32 | raise exceptions.PluginlibError(friendly=self.msg) 33 | self.assertEqual(e.exception.friendly, self.msg) 34 | 35 | def test_plugin_import_error(self): 36 | """Raises PluginImportError as expected""" 37 | with self.assertRaises(exceptions.PluginlibError) as e: 38 | raise exceptions.PluginImportError() 39 | self.assertIsNone(e.exception.friendly) 40 | 41 | with self.assertRaises(exceptions.PluginlibError) as e: 42 | raise exceptions.PluginImportError(friendly=self.msg) 43 | self.assertEqual(e.exception.friendly, self.msg) 44 | 45 | 46 | class TestWarnings(TestCase): 47 | """Tests for custom warnings""" 48 | 49 | def test_plugin_warning(self): 50 | """Warns with PluginWarning as expected""" 51 | 52 | with warnings.catch_warnings(record=True) as e: 53 | warnings.simplefilter("always") 54 | warnings.warn('Just a warning', exceptions.PluginWarning) 55 | self.assertEqual(len(e), 1) 56 | self.assertTrue(issubclass(e[-1].category, exceptions.PluginWarning)) 57 | self.assertTrue(issubclass(e[-1].category, UserWarning)) 58 | self.assertEqual(str(e[-1].message), 'Just a warning') 59 | 60 | def test_entrypoint_warning(self): 61 | """Warns with EntryPointWarning as expected""" 62 | 63 | with warnings.catch_warnings(record=True) as e: 64 | warnings.simplefilter("always") 65 | warnings.warn('Just a warning', exceptions.EntryPointWarning) 66 | self.assertEqual(len(e), 1) 67 | self.assertTrue(issubclass(e[-1].category, exceptions.EntryPointWarning)) 68 | self.assertTrue(issubclass(e[-1].category, ImportWarning)) 69 | self.assertEqual(str(e[-1].message), 'Just a warning') 70 | -------------------------------------------------------------------------------- /doc/error_handling.rst: -------------------------------------------------------------------------------- 1 | .. 2 | Copyright 2018 Avram Lubkin, All Rights Reserved 3 | 4 | This Source Code Form is subject to the terms of the Mozilla Public 5 | License, v. 2.0. If a copy of the MPL was not distributed with this 6 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | :github_url: https://github.com/Rockhopper-Technologies/pluginlib 9 | 10 | .. py:currentmodule:: pluginlib 11 | 12 | Error Handling 13 | ============== 14 | 15 | Logging 16 | ------- 17 | 18 | Pluginlib uses the :py:mod:`logging` module from the 19 | `Python Standard Library `_ 20 | for debug and info level messages. 21 | The 'pluginlib' :py:class:`logging.Logger` instance is configured with a 22 | :py:class:`~logging.handlers.NullHandler` so, unless logging is configured in the program, 23 | there will be no output. 24 | 25 | To configure basic debug logging in a program: 26 | 27 | .. code-block:: python 28 | 29 | import logging 30 | 31 | logging.basicConfig(level=logging.DEBUG) 32 | 33 | See the :py:mod:`logging` module for more information on configuring logging. 34 | 35 | 36 | To disable all logging for Pluginlib: 37 | 38 | .. code-block:: python 39 | 40 | import logging 41 | 42 | logging.getLogger('pluginlib').propagate = False 43 | 44 | To change the logging level for Pluginlib only: 45 | 46 | .. code-block:: python 47 | 48 | import logging 49 | 50 | logging.getLogger('pluginlib').setLevel(logging.DEBUG) 51 | 52 | Warnings 53 | -------- 54 | 55 | Pluginlib raises a custom :py:exc:`PluginWarning` warning for malformed plugins and a custom 56 | :py:exc:`EntryPointWarning` warning for invalid `entry points`_. 57 | :py:exc:`EntryPointWarning` is a subclass of the builtin :py:exc:`ImportWarning` 58 | and ignored by default in Python. 59 | 60 | To raise an exception when a :py:exc:`PluginWarning` occurs: 61 | 62 | .. code-block:: python 63 | 64 | import warnings 65 | import pluginlib 66 | 67 | warnings.simplefilter('error', pluginlib.PluginWarning) 68 | 69 | See the :py:mod:`warnings` module for more information on warnings. 70 | 71 | .. _entry points: https://packaging.python.org/specifications/entry-points/ 72 | 73 | Exceptions 74 | ---------- 75 | 76 | When :py:class:`PluginLoader` encounters an error importing a module, 77 | a :py:exc:`PluginImportError` exception will be raised. 78 | :py:exc:`PluginImportError` is a subclass of :py:exc:`PluginlibError`. 79 | 80 | When possible, :py:attr:`PluginImportError.friendly` is populated with a formatted exception 81 | truncated to limit the output to the relevant part of the traceback. 82 | 83 | To use friendly output for import errors: 84 | 85 | .. code-block:: python 86 | 87 | import sys 88 | import pluginlib 89 | 90 | loader = pluginlib.PluginLoader(modules=['sample_plugins']) 91 | 92 | try: 93 | plugins = loader.plugins 94 | except pluginlib.PluginImportError as e: 95 | if e.friendly: 96 | sys.exit(e.friendly) 97 | else: 98 | raise 99 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # This file is execfile()d with the current directory set to its 5 | # containing dir. 6 | # 7 | # Note that not all possible configuration values are present in this 8 | # autogenerated file. 9 | # 10 | # All configuration values have a default; values that are commented out 11 | # serve to show the default. 12 | 13 | # If extensions (or modules to document with autodoc) are in another directory, 14 | # add these directories to sys.path here. If the directory is relative to the 15 | # documentation root, use os.path.abspath to make it absolute, like shown here. 16 | # 17 | import os 18 | import sys 19 | 20 | sys.path.insert(0, os.path.abspath('..')) 21 | 22 | from setup_helpers import get_version # noqa: E402 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | # 28 | # needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ['sphinx.ext.autodoc', 34 | 'sphinx.ext.intersphinx', 35 | 'sphinx.ext.napoleon'] 36 | 37 | if not os.environ.get('READTHEDOCS') == 'True': 38 | extensions.append('sphinxcontrib.spelling') 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ['_templates'] 42 | 43 | # The suffix(es) of source filenames. 44 | # You can specify multiple suffix as a list of string: 45 | # 46 | # source_suffix = ['.rst', '.md'] 47 | source_suffix = '.rst' 48 | 49 | # The master toctree document. 50 | master_doc = 'index' 51 | 52 | # General information about the project. 53 | project = 'Pluginlib' 54 | copyright = '2018 - 2025, Avram Lubkin' 55 | author = 'Avram Lubkin' 56 | 57 | # The version info for the project you're documenting, acts as replacement for 58 | # |version| and |release|, also used in various other places throughout the 59 | # built documents. 60 | # 61 | # The short X.Y version. 62 | version = get_version('../pluginlib/__init__.py') 63 | # The full version, including alpha/beta/rc tags. 64 | release = version 65 | 66 | # The language for content autogenerated by Sphinx. Refer to documentation 67 | # for a list of supported languages. 68 | # 69 | # This is also used if you do content translation via gettext catalogs. 70 | # Usually you set "language" from the command line for these cases. 71 | language = 'en' 72 | 73 | # List of patterns, relative to source directory, that match files and 74 | # directories to ignore when looking for source files. 75 | # This patterns also effect to html_static_path and html_extra_path 76 | exclude_patterns = [] 77 | 78 | # The name of the Pygments (syntax highlighting) style to use. 79 | pygments_style = 'sphinx' 80 | 81 | # If true, `todo` and `todoList` produce output, else they produce nothing. 82 | todo_include_todos = False 83 | 84 | 85 | # -- Options for HTML output ---------------------------------------------- 86 | 87 | # The theme to use for HTML and HTML Help pages. See the documentation for 88 | # a list of builtin themes. 89 | # 90 | html_theme = 'sphinx_rtd_theme' 91 | 92 | # Theme options are theme-specific and customize the look and feel of a theme 93 | # further. For a list of options available for each theme, see the 94 | # documentation. 95 | # 96 | # html_theme_options = {} 97 | 98 | # Add any paths that contain custom static files (such as style sheets) here, 99 | # relative to this directory. They are copied after the builtin static files, 100 | # so a file named "default.css" will overwrite the builtin "default.css". 101 | html_static_path = [] 102 | 103 | 104 | # -- Options for HTMLHelp output ------------------------------------------ 105 | 106 | # Output file base name for HTML help builder. 107 | htmlhelp_basename = '%sdoc' % project 108 | 109 | # Example configuration for intersphinx: refer to the Python standard library. 110 | intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} 111 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. 2 | Copyright 2018 - 2025 Avram Lubkin, All Rights Reserved 3 | 4 | This Source Code Form is subject to the terms of the Mozilla Public 5 | License, v. 2.0. If a copy of the MPL was not distributed with this 6 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | :github_url: https://github.com/Rockhopper-Technologies/pluginlib 9 | 10 | .. toctree:: 11 | :hidden: 12 | 13 | self 14 | concepts.rst 15 | error_handling.rst 16 | faq.rst 17 | api.rst 18 | 19 | .. py:currentmodule:: pluginlib 20 | 21 | Overview 22 | ======== 23 | 24 | Pluginlib makes creating plugins for your project simple. 25 | 26 | Step 1: Define plugin parent classes 27 | ------------------------------------ 28 | 29 | All plugins are subclasses of parent classes. To create a parent class, use the 30 | :py:func:`@Parent ` decorator. 31 | 32 | The :py:func:`@Parent ` decorator can take a plugin type for accessing child plugins 33 | of the parent. If a plugin type isn't given, the class name will be used. 34 | 35 | The :py:func:`@Parent ` decorator can also take a ``group`` keyword which 36 | restricts plugins to a specific plugin group. ``group`` should be specified if plugins for 37 | different projects could be accessed in an single program, such as with libraries and frameworks. 38 | For more information, see the :ref:`plugin-groups` section. 39 | 40 | Methods required in child plugins should be labeled as abstract methods. 41 | Plugins without these methods or with :py:term:`parameters ` 42 | that don't match, will not be loaded. 43 | For more information, see the :ref:`abstract-methods` section. 44 | 45 | .. code-block:: python 46 | 47 | """ 48 | sample.py 49 | """ 50 | import pluginlib 51 | 52 | @pluginlib.Parent('parser') 53 | class Parser: 54 | 55 | @pluginlib.abstractmethod 56 | def parse(self, string): 57 | pass 58 | 59 | Step 2: Define plugin classes 60 | ----------------------------- 61 | 62 | To create a plugin, subclass a parent class and include any required methods. 63 | 64 | Plugins can be customized through optional class attributes: 65 | 66 | :py:attr:`~pluginlib.Plugin._alias_` 67 | Changes the name of the plugin which defaults to the class name. 68 | 69 | :py:attr:`~pluginlib.Plugin._version_` 70 | Sets the version of the plugin. Defaults to the module ``__version__`` or :py:data:`None`. 71 | If multiple plugins with the same type and name are loaded, the plugin with 72 | the highest version is used. For more information, see the :ref:`versions` section. 73 | 74 | :py:attr:`~pluginlib.Plugin._skipload_` 75 | Specifies the plugin should not be loaded. This is useful when a plugin is a parent class 76 | for additional plugins or when a plugin should only be loaded under certain conditions. 77 | For more information see the :ref:`conditional-loading` section. 78 | 79 | .. code-block:: python 80 | 81 | """ 82 | sample_plugins.py 83 | """ 84 | import json 85 | import sample 86 | 87 | class JSON(sample.Parser): 88 | 89 | _alias_ = 'json' 90 | 91 | def parse(self, string): 92 | return json.loads(string) 93 | 94 | Step 3: Load plugins 95 | -------------------- 96 | 97 | Plugins are loaded when the module they are in is imported. :py:class:`PluginLoader` 98 | will load modules from specified locations and provides access to them. 99 | 100 | :py:class:`PluginLoader` can load plugins from several locations. 101 | - A program's standard library 102 | - `Entry points`_ 103 | - A list of modules 104 | - A list of filesystem paths 105 | 106 | Plugins can also be filtered through blacklists and type filters. 107 | See the :ref:`blacklists` and :ref:`type-filters` sections for more information. 108 | 109 | Plugins are accessible through the :py:attr:`PluginLoader.plugins` property, 110 | a nested dictionary accessible through dot notation. For other ways to access plugins, 111 | see the :ref:`accessing-plugins` section. 112 | 113 | .. code-block:: python 114 | 115 | import pluginlib 116 | import sample 117 | 118 | loader = pluginlib.PluginLoader(modules=['sample_plugins']) 119 | plugins = loader.plugins 120 | parser = plugins.parser.json() 121 | print(parser.parse('{"json": "test"}')) 122 | 123 | .. _Entry points: https://packaging.python.org/specifications/entry-points/ 124 | -------------------------------------------------------------------------------- /doc/faq.rst: -------------------------------------------------------------------------------- 1 | .. 2 | Copyright 2018 - 2025 Avram Lubkin, All Rights Reserved 3 | 4 | This Source Code Form is subject to the terms of the Mozilla Public 5 | License, v. 2.0. If a copy of the MPL was not distributed with this 6 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | :github_url: https://github.com/Rockhopper-Technologies/pluginlib 9 | 10 | .. py:currentmodule:: pluginlib 11 | 12 | Frequently Asked Questions 13 | ========================== 14 | 15 | Is this magic? 16 | -------------- 17 | 18 | No, it's :py:term:`metaclasses `. 19 | 20 | 21 | How is method checking handled differently from :py:mod:`abc`? 22 | ---------------------------------------------------------------- 23 | 24 | Pluginlib checks methods when classes are declared, not when they are initialized. 25 | 26 | 27 | How can I check if a class is a plugin? 28 | --------------------------------------- 29 | 30 | It's usually best to check inheritance against a specific parent. 31 | 32 | .. code-block:: python 33 | 34 | issubclass(ChildPlugin, ParentPlugin) 35 | 36 | For a more general case, use the :py:class:`Plugin` class. 37 | 38 | .. code-block:: python 39 | 40 | issubclass(ChildPlugin, pluginlib.Plugin) 41 | 42 | 43 | Why does creating a parent by subclassing another parent raise a warning? 44 | ------------------------------------------------------------------------- 45 | 46 | A warning is raised when subclassing a plugin parent if it does not meet the 47 | conditions of being a child plugin. Generally, it's best to avoid this 48 | situation completely by having both plugin parent classes inherit 49 | from a common, non-plugin, class. If it's unavoidable, set the 50 | :py:attr:`~pluginlib.Plugin._skipload_` class attribute to :py:data:`False` to 51 | avoid evaluating the class as a child plugin. 52 | 53 | 54 | Why does calling :py:func:`super` with no arguments in a parent class raise a :py:exc:`TypeError`? 55 | -------------------------------------------------------------------------------------------------- 56 | 57 | This is a side-effect of :py:term:`metaclasses `. Take the following example: 58 | 59 | .. code-block:: pycon 60 | :emphasize-lines: 10, 21 61 | 62 | >>> import pluginlib 63 | >>> 64 | >>> class Plugin(): 65 | ... def __init__(self): 66 | ... pass 67 | ... 68 | >>> @pluginlib.Parent('collector') 69 | ... class Collector(Plugin): 70 | ... def __init__(self): 71 | ... super().__init__() 72 | ... 73 | >>> class Child(Collector): 74 | ... def __init__(self): 75 | ... super().__init__() 76 | ... 77 | >>> Child() 78 | Traceback (most recent call last): 79 | File "", line 1, in 80 | File "", line 3, in __init__ 81 | File "", line 4, in __init__ 82 | TypeError: super(type, obj): obj must be an instance or subtype of type 83 | 84 | We get this error when :py:func:`super` is called with no arguments in ``Collector`` because the 85 | type of ``Collector`` is ``PluginType``, not ``Plugin``. 86 | 87 | .. code-block:: pycon 88 | 89 | >>> type(Collector) 90 | 91 | 92 | :py:func:`super` will use this type for the first argument if one is not supplied. 93 | To work around this, we simply have to supply arguments to :py:func:`super`. 94 | As you can see, this is only required in parent classes. 95 | 96 | .. code-block:: pycon 97 | :emphasize-lines: 10, 14 98 | 99 | >>> import pluginlib 100 | >>> 101 | >>> class Plugin(): 102 | ... def __init__(self): 103 | ... pass 104 | ... 105 | >>> @pluginlib.Parent('collector') 106 | ... class Collector(Plugin): 107 | ... def __init__(self): 108 | ... super(Collector, self).__init__() 109 | ... 110 | >>> class Child(Collector): 111 | ... def __init__(self): 112 | ... super().__init__() 113 | ... 114 | >>> Child() 115 | <__main__.Child object at 0x7f5786e8d490> 116 | 117 | 118 | Why am I getting ``TypeError: metaclass conflict``? 119 | --------------------------------------------------- 120 | 121 | This happens when a parent class inherits from a class derived from a :py:term:`metaclass`. 122 | This is **not** supported. Here is an example that illustrates the behavior. 123 | 124 | .. code-block:: python 125 | 126 | import pluginlib 127 | 128 | class Meta(type): 129 | pass 130 | 131 | class ClassFromMeta(metaclass=Meta): 132 | pass 133 | 134 | @pluginlib.Parent('widget') 135 | class Widget(ClassFromMeta): 136 | pass 137 | 138 | This will raise the following error. 139 | 140 | .. code-block:: python 141 | 142 | TypeError: metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its bases 143 | 144 | An alternative is to make an instance of the class an attribute of the parent class. 145 | 146 | .. code-block:: python 147 | 148 | @pluginlib.Parent('widget') 149 | class Widget(): 150 | def __init__(self): 151 | self._widget = ClassFromMeta() 152 | 153 | If desired, :py:meth:`~object.__getattr__` can be used to provide pass-through access. 154 | 155 | .. code-block:: python 156 | 157 | @pluginlib.Parent('widget') 158 | class Widget(): 159 | def __init__(self): 160 | self._widget = ClassFromMeta() 161 | 162 | def __getattr__(self, attr): 163 | return getattr(self._widget, attr) 164 | 165 | 166 | Why aren't namespace packages searched recursively for imports? 167 | --------------------------------------------------------------- 168 | 169 | This is a function the `behavior `_ of 170 | :py:func:`pkgutil.walk_packages`. While `namespace packages `_ 171 | are abused more than they are used, I leave it up to the underlying mechanisms to determine how 172 | they should be supported. If you want to include plugins in namespace packages recursively, 173 | I suggest using the ``paths`` argument to :py:class:`PluginLoader`. 174 | -------------------------------------------------------------------------------- /pluginlib/_util.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2025 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Pluginlib Utility Module** 9 | 10 | This module contains generic functions for use in other modules 11 | """ 12 | 13 | import abc 14 | import logging 15 | import operator as _operator 16 | import sys 17 | import warnings 18 | from functools import update_wrapper, wraps 19 | from inspect import isclass 20 | 21 | 22 | PY_LT_3_10 = sys.version_info[:2] < (3, 10) 23 | 24 | # Setup logger 25 | LOGGER = logging.getLogger('pluginlib') 26 | LOGGER.addHandler(logging.NullHandler()) 27 | 28 | OPERATORS = {'=': _operator.eq, 29 | '==': _operator.eq, 30 | '!=': _operator.ne, 31 | '<': _operator.lt, 32 | '<=': _operator.le, 33 | '>': _operator.gt, 34 | '>=': _operator.ge} 35 | 36 | # types.NoneType isn't available until 3.10 37 | NoneType = type(None) 38 | 39 | 40 | class ClassProperty: 41 | """ 42 | Property decorator for class methods 43 | """ 44 | 45 | def __init__(self, method): 46 | 47 | self.method = method 48 | update_wrapper(self, method) 49 | 50 | def __get__(self, instance, cls): 51 | return self.method(cls) 52 | 53 | 54 | class Undefined: 55 | """ 56 | Class for creating unique undefined objects for value comparisons 57 | """ 58 | 59 | def __init__(self, label='UNDEF'): 60 | self.label = label 61 | 62 | def __str__(self): 63 | return self.label 64 | 65 | def __repr__(self): 66 | return self.__str__() 67 | 68 | def __bool__(self): 69 | return False 70 | 71 | 72 | def allow_bare_decorator(cls): 73 | """ 74 | Wrapper for a class decorator which allows for bare decorator and argument syntax 75 | """ 76 | 77 | @wraps(cls) 78 | def wrapper(*args, **kwargs): 79 | """"Wrapper for real decorator""" 80 | 81 | # If we weren't only passed a bare class, return class instance 82 | if kwargs or len(args) != 1 or not isclass(args[0]): # pylint: disable=no-else-return 83 | return cls(*args, **kwargs) 84 | # Otherwise, pass call to instance with default values 85 | else: 86 | return cls()(args[0]) 87 | 88 | return wrapper 89 | 90 | 91 | class CachingDict(dict): 92 | """ 93 | A subclass of :py:class:`dict` that has a private _cache attribute 94 | 95 | self._cache is regular dictionary which is cleared whenever the CachingDict is changed 96 | 97 | Nothing is actually cached. That is the responsibility of the inheriting class 98 | """ 99 | 100 | def __init__(self, *args, **kwargs): 101 | super().__init__(*args, **kwargs) 102 | self._cache = {} 103 | 104 | def __setitem__(self, key, value): 105 | try: 106 | super().__setitem__(key, value) 107 | finally: 108 | self._cache.clear() 109 | 110 | def __delitem__(self, key): 111 | try: 112 | super().__delitem__(key) 113 | finally: 114 | self._cache.clear() 115 | 116 | def clear(self): 117 | try: 118 | super().clear() 119 | finally: 120 | self._cache.clear() 121 | 122 | def setdefault(self, key, default=None): 123 | 124 | try: 125 | return self[key] 126 | except KeyError: 127 | self[key] = default 128 | return default 129 | 130 | def pop(self, *args): 131 | try: 132 | value = super().pop(*args) 133 | except KeyError as e: 134 | raise e 135 | 136 | self._cache.clear() 137 | return value 138 | 139 | def popitem(self): 140 | try: 141 | item = super().popitem() 142 | except KeyError as e: 143 | raise e 144 | 145 | self._cache.clear() 146 | return item 147 | 148 | 149 | class DictWithDotNotation(dict): 150 | """ 151 | Dictionary addressable by dot notation 152 | """ 153 | 154 | def __getattr__(self, name): 155 | try: 156 | return self[name] 157 | except KeyError: 158 | raise AttributeError(f"'dict' object has no attribute '{name}'") from None 159 | 160 | 161 | class abstractstaticmethod(staticmethod): # noqa: N801 # pylint: disable=invalid-name 162 | """ 163 | A decorator for abstract static methods 164 | 165 | Used in parent classes to identify static methods required in child plugins 166 | 167 | This decorator is included to support older versions of Python and 168 | should be considered deprecated as of Python 3.3. 169 | The preferred implementation is: 170 | 171 | .. code-block:: python 172 | 173 | @staticmethod 174 | @pluginlib.abstractmethod 175 | def abstract_staticmethod(): 176 | return 'foo' 177 | """ 178 | 179 | __isabstractmethod__ = True 180 | 181 | def __init__(self, func): 182 | warnings.warn( 183 | "abstractstaticmethod is deprecated since Python 3.3 and will be removed in future versions. " 184 | "Use @staticmethod combined with @abstractmethod instead.", 185 | DeprecationWarning, 186 | stacklevel=2, 187 | ) 188 | super().__init__(abc.abstractmethod(func)) 189 | 190 | 191 | class abstractclassmethod(classmethod): # noqa: N801 # pylint: disable=invalid-name 192 | """ 193 | A decorator for abstract class methods 194 | 195 | Used in parent classes to identify class methods required in child plugins 196 | 197 | This decorator is included to support older versions of Python and 198 | should be considered deprecated as of Python 3.3. 199 | The preferred implementation is: 200 | 201 | .. code-block:: python 202 | 203 | @classmethod 204 | @pluginlib.abstractmethod 205 | def abstract_classmethod(cls): 206 | return cls.foo 207 | """ 208 | 209 | __isabstractmethod__ = True 210 | 211 | def __init__(self, func): 212 | warnings.warn( 213 | "abstractclassmethod is deprecated since Python 3.3 and will be removed in future versions. " 214 | "Use @classmethod combined with @abstractmethod instead.", 215 | DeprecationWarning, 216 | stacklevel=2, 217 | ) 218 | super().__init__(abc.abstractmethod(func)) 219 | 220 | 221 | class abstractattribute: # noqa: N801 # pylint: disable=invalid-name 222 | """ 223 | A class to be used to identify abstract attributes 224 | 225 | .. code-block:: python 226 | 227 | @pluginlib.Parent 228 | class ParentClass: 229 | abstract_attribute = pluginlib.abstractattribute 230 | 231 | """ 232 | __isabstractmethod__ = True 233 | 234 | 235 | def abstractproperty(*args, **kwargs): 236 | """ 237 | Wrapper for abstractproperty to raise deprecation warning 238 | """ 239 | 240 | warnings.warn( 241 | "abstractproperty is deprecated since Python 3.3 and will be removed in future versions. " 242 | "Use @property combined with @abstractmethod instead.", 243 | DeprecationWarning, 244 | stacklevel=2, 245 | ) 246 | return abc.abstractproperty(*args, **kwargs) 247 | -------------------------------------------------------------------------------- /setup_helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 - 2025 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | Functions to help with build and setup 9 | """ 10 | 11 | import contextlib 12 | import datetime 13 | import io 14 | import os 15 | import re 16 | import subprocess 17 | import sys 18 | 19 | 20 | RE_VERSION = re.compile(r'__version__\s*=\s*[\'\"](.+)[\'\"]$') 21 | DIR_SPELLING = 'build/doc/spelling/' 22 | 23 | 24 | def get_version(filename, encoding='utf8'): 25 | """ 26 | Get __version__ definition out of a source file 27 | """ 28 | 29 | with io.open(filename, encoding=encoding) as sourcecode: 30 | for line in sourcecode: 31 | version = RE_VERSION.match(line) 32 | if version: 33 | return version.group(1) 34 | 35 | return None 36 | 37 | 38 | def readme(filename, encoding='utf8'): 39 | """ 40 | Read the contents of a file 41 | """ 42 | 43 | with io.open(filename, encoding=encoding) as source: 44 | return source.read() 45 | 46 | 47 | def print_spelling_errors(filename, encoding='utf8'): 48 | """ 49 | Print misspelled words returned by sphinxcontrib-spelling 50 | """ 51 | try: 52 | filesize = os.stat(filename).st_size 53 | except FileNotFoundError: 54 | filesize = 0 55 | 56 | if filesize: 57 | sys.stdout.write('Misspelled Words:\n') 58 | with io.open(filename, encoding=encoding) as wordlist: 59 | for line in wordlist: 60 | sys.stdout.write(f' {line}') 61 | 62 | return 1 if filesize else 0 63 | 64 | 65 | def print_all_spelling_errors(path): 66 | """ 67 | Print all spelling errors in the path 68 | """ 69 | 70 | rtn = 0 71 | if not os.path.isdir(path): 72 | return rtn 73 | 74 | for filename in os.listdir(path): 75 | if print_spelling_errors(os.path.join(path, filename)): 76 | rtn = 1 77 | 78 | return rtn 79 | 80 | 81 | def spelling_clean_dir(path): 82 | """ 83 | Remove spelling files from path 84 | """ 85 | if not os.path.isdir(path): 86 | return 87 | for filename in os.listdir(path): 88 | os.unlink(os.path.join(path, filename)) 89 | 90 | 91 | def check_rst2html(path): 92 | """ 93 | Checks for warnings when doing ReST to HTML conversion 94 | """ 95 | 96 | from docutils.core import publish_file # pylint: disable=import-error,import-outside-toplevel 97 | 98 | stderr = io.StringIO() 99 | 100 | # This will exit with status if there is a bad enough error 101 | with contextlib.redirect_stderr(stderr): 102 | output = publish_file(source_path=path, writer_name='html', 103 | enable_exit_status=True, destination_path='/dev/null') 104 | 105 | warning_text = stderr.getvalue() 106 | 107 | if warning_text or not output: 108 | print(warning_text) 109 | return 1 110 | 111 | return 0 112 | 113 | 114 | def _get_changed_files(): 115 | """ 116 | Get files in current repository that have been changed 117 | Ignore changes to copyright lines 118 | """ 119 | 120 | changed = [] 121 | 122 | # Get list of changed files 123 | process = subprocess.run( 124 | ('git', 'status', '--porcelain=1'), stdout=subprocess.PIPE, check=True, text=True 125 | ) 126 | for entry in process.stdout.splitlines(): 127 | 128 | # Ignore deleted files 129 | if entry[1] == 'D': 130 | continue 131 | 132 | # Construct diff command 133 | filename = entry[3:].strip() 134 | diff_cmd = ['git', 'diff', filename] 135 | if entry[0].strip(): 136 | diff_cmd.insert(-1, '--cached') 137 | 138 | # Find files with changes that aren't only for copyright 139 | process = subprocess.run(diff_cmd, stdout=subprocess.PIPE, check=True, text=True) 140 | for line in process.stdout.splitlines(): 141 | if line[0] != '+' or line[:3] == '+++': # Ignore everything but the new contents 142 | continue 143 | 144 | if re.search(r'copyright.*20\d\d', line, re.IGNORECASE): # Ignore copyright line 145 | continue 146 | 147 | changed.append(filename) 148 | break 149 | 150 | return changed 151 | 152 | 153 | def check_copyrights(): 154 | """ 155 | Check files recursively to ensure year of last change is in copyright line 156 | """ 157 | 158 | this_year = str(datetime.date.today().year) 159 | changed_now = _get_changed_files() 160 | 161 | # Look for copyright lines 162 | process = subprocess.run( 163 | ('git', 'grep', '-i', 'copyright'), stdout=subprocess.PIPE, check=True, text=True 164 | ) 165 | 166 | rtn = 0 167 | for entry in process.stdout.splitlines(): 168 | 169 | modified = None 170 | 171 | # Get the year in the copyright line 172 | filename, text = entry.split(':', 1) 173 | match = re.match(r'.*(20\d\d)', text) 174 | if match: 175 | year = match[1] 176 | 177 | # If file is in current changes, use this year 178 | if filename in changed_now: 179 | modified = this_year 180 | 181 | # Otherwise, try to get the year of last commit that wasn't only updating copyright 182 | else: 183 | git_log = subprocess.run( 184 | ('git', '--no-pager', 'log', '-U0', filename), 185 | stdout=subprocess.PIPE, check=True, text=True 186 | ) 187 | 188 | for line in git_log.stdout.splitlines(): 189 | 190 | # Get year 191 | if line.startswith('Date: '): 192 | modified = line.split()[5] 193 | 194 | # Skip blank line and lines that aren't changes 195 | if not line.strip() or line[0] != '+' or line[:3] == '+++': 196 | continue 197 | 198 | # Stop looking on the first line we hit that isn't a copyright 199 | if re.search(r'copyright.*20\d\d', line, re.IGNORECASE) is None: 200 | break 201 | 202 | # Special case for Sphinx configuration 203 | if filename == 'doc/conf.py' and modified != this_year: 204 | 205 | # Get the latest change date for docs 206 | process = subprocess.run( 207 | ('git', 'log', '-1', '--pretty=format:%cs', 'doc/*.rst'), 208 | stdout=subprocess.PIPE, check=True, text=True 209 | ) 210 | modified = process.stdout[:4] 211 | 212 | # Compare modified date to copyright year 213 | if modified and modified != year: 214 | rtn = 1 215 | print(f'{filename}: {text} [{modified}]') 216 | 217 | return rtn 218 | 219 | 220 | if __name__ == '__main__': 221 | 222 | # Do nothing if no arguments were given 223 | if len(sys.argv) < 2: 224 | sys.exit(0) 225 | 226 | # Print misspelled word list 227 | if sys.argv[1] == 'spelling-clean': 228 | spelling_clean_dir(DIR_SPELLING) 229 | sys.exit(0) 230 | 231 | # Print misspelled word list 232 | if sys.argv[1] == 'spelling': 233 | if len(sys.argv) > 2: 234 | sys.exit(print_spelling_errors(sys.argv[2])) 235 | else: 236 | sys.exit(print_all_spelling_errors(DIR_SPELLING)) 237 | 238 | # Check file for Rest to HTML conversion 239 | if sys.argv[1] == 'rst2html': 240 | if len(sys.argv) < 3: 241 | sys.exit('Missing filename for ReST to HTML check') 242 | sys.exit(check_rst2html(sys.argv[2])) 243 | 244 | # Check copyrights 245 | if sys.argv[1] == 'copyright': 246 | sys.exit(check_copyrights()) 247 | 248 | # Unknown option 249 | else: 250 | sys.stderr.write(f'Unknown option: {sys.argv[1]}') 251 | sys.exit(1) 252 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2025 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Test module for pluginlib._util** 9 | """ 10 | 11 | from unittest import TestCase 12 | 13 | import pluginlib._util as util 14 | 15 | 16 | class TestClassProperty(TestCase): 17 | """Tests for ClassProperty decorator class""" 18 | 19 | someproperty = 'Value of a property' 20 | 21 | @util.ClassProperty 22 | def testproperty(cls): # noqa: N805 # pylint: disable=no-self-argument 23 | """Example class property""" 24 | return cls.someproperty 25 | 26 | def test_class_property(self): 27 | """Class property should be accessible via standard dot notation""" 28 | self.assertEqual(self.testproperty, self.someproperty) 29 | self.assertEqual(self.testproperty, 'Value of a property') 30 | 31 | 32 | class TestUndefined(TestCase): 33 | """Tests for Undefined class""" 34 | 35 | def test_str(self): 36 | """String representation""" 37 | undef = util.Undefined() 38 | self.assertEqual(str(undef), 'UNDEF') 39 | self.assertEqual(repr(undef), 'UNDEF') 40 | 41 | def test_str_nondefault(self): 42 | """String representation with non-default label""" 43 | undef = util.Undefined('UNSET') 44 | self.assertEqual(str(undef), 'UNSET') 45 | self.assertEqual(repr(undef), 'UNSET') 46 | 47 | def test_bool(self): 48 | """Should always be False in a Boolean test""" 49 | undef = util.Undefined() 50 | self.assertFalse(undef) 51 | 52 | 53 | class TestAllowBareDecorator(TestCase): 54 | """Test allow_bare_decorator class decorator decorator""" 55 | 56 | # pylint: disable=no-member 57 | def test_allow_bare_decorator(self): 58 | """Decorator should work with or without parentheses""" 59 | 60 | @util.allow_bare_decorator 61 | class Decorator: 62 | """Sample decorator class""" 63 | 64 | def __init__(self, name=None, group=None): 65 | self.name = name 66 | self.group = group 67 | 68 | def __call__(self, cls): 69 | cls.name = self.name or cls.__name__ 70 | cls.group = self.group or 'default' 71 | return cls 72 | 73 | @Decorator 74 | class Class1: 75 | """Bare decorator""" 76 | 77 | self.assertEqual(Class1.__name__, 'Class1') 78 | self.assertEqual(Class1.name, 'Class1') 79 | self.assertEqual(Class1.group, 'default') 80 | 81 | @Decorator() 82 | class Class2: 83 | """Decorator without arguments""" 84 | 85 | self.assertEqual(Class2.__name__, 'Class2') 86 | self.assertEqual(Class2.name, 'Class2') 87 | self.assertEqual(Class2.group, 'default') 88 | 89 | @Decorator('spam') 90 | class Class3: 91 | """Decorator with one argument""" 92 | 93 | self.assertEqual(Class3.__name__, 'Class3') 94 | self.assertEqual(Class3.name, 'spam') 95 | self.assertEqual(Class3.group, 'default') 96 | 97 | @Decorator('spam', 'ham') 98 | class Class4: 99 | """Decorator with two arguments""" 100 | 101 | self.assertEqual(Class4.__name__, 'Class4') 102 | self.assertEqual(Class4.name, 'spam') 103 | self.assertEqual(Class4.group, 'ham') 104 | 105 | 106 | class TestCachingDict(TestCase): 107 | """Tests for CachingDict dictionary subclass""" 108 | # pylint: disable=protected-access 109 | 110 | def setUp(self): 111 | """Stage dictionary and cache""" 112 | self.cdict = util.CachingDict() 113 | self.cdict['hello'] = 'world' 114 | self.cdict._cache['test'] = 'Testing' 115 | 116 | def test_init(self): 117 | """Standard initialization""" 118 | self.assertTrue(isinstance(self.cdict._cache, dict)) 119 | self.assertTrue('test' in self.cdict._cache) 120 | self.cdict._cache.clear() 121 | 122 | def test_setitem(self): 123 | """Setting a value clears the cache""" 124 | self.cdict['Hey'] = 'Jude' 125 | 126 | def test_delitem(self): 127 | """Deleting a value clears the cache""" 128 | del self.cdict['hello'] 129 | 130 | def test_clear(self): 131 | """Clearing the dictionary also clears the cache""" 132 | self.cdict.clear() 133 | 134 | def test_setdefault(self): 135 | """setdefault() only clears the cache when the key doesn't exist""" 136 | self.assertEqual('world', self.cdict.setdefault('hello', None)) 137 | self.assertTrue('test' in self.cdict._cache) 138 | self.assertEqual('Jude', self.cdict.setdefault('Hey', 'Jude')) 139 | 140 | def test_pop(self): 141 | """pop() only clears the cache is the key exists""" 142 | with self.assertRaises(KeyError): 143 | self.cdict.pop('Hey') 144 | self.assertTrue('test' in self.cdict._cache) 145 | self.assertEqual('world', self.cdict.pop('hello')) 146 | 147 | def test_popitem(self): 148 | """"popitem() only clears the cache if the dictionary isn't empty""" 149 | del self.cdict['hello'] 150 | self.cdict._cache['test'] = 'Testing' 151 | self.assertTrue('test' in self.cdict._cache) 152 | 153 | with self.assertRaises(KeyError): 154 | self.cdict.popitem() 155 | self.assertTrue('test' in self.cdict._cache) 156 | 157 | self.setUp() 158 | self.assertTrue('test' in self.cdict._cache) 159 | self.assertEqual(('hello', 'world'), self.cdict.popitem()) 160 | 161 | def tearDown(self): 162 | """Ensure cache was cleared""" 163 | self.assertFalse('test' in self.cdict._cache) 164 | self.assertFalse(self.cdict._cache) 165 | 166 | 167 | class TestDictWithDotNotation(TestCase): 168 | """Tests for DictWithDotNotation dictionary subclass""" 169 | 170 | def test_getter(self): 171 | """Values can be retrieved by index or dot notation""" 172 | dotdict = util.DictWithDotNotation({'hello': 'world'}) 173 | 174 | self.assertEqual('world', dotdict['hello']) 175 | self.assertEqual('world', dotdict.hello) 176 | 177 | with self.assertRaises(AttributeError): 178 | dotdict.notInDict # pylint: disable=pointless-statement 179 | 180 | with self.assertRaises(KeyError): 181 | dotdict['notInDict'] # pylint: disable=pointless-statement 182 | 183 | 184 | class TestDeprecatedMethods(TestCase): 185 | """Tests for deprecated abstract decorator class""" 186 | 187 | def setUp(self): 188 | 189 | def func(): 190 | """Dummy function""" 191 | return True 192 | 193 | self.func = func 194 | 195 | def test_abstractstaticmethod(self): 196 | """Creates a static method marked as an abstract method""" 197 | 198 | with self.assertWarns(DeprecationWarning): 199 | meth = util.abstractstaticmethod(self.func) 200 | 201 | self.assertIsInstance(meth, staticmethod) 202 | self.assertTrue(getattr(meth, '__isabstractmethod__', False)) 203 | self.assertTrue(getattr(meth.__func__, '__isabstractmethod__', False)) 204 | 205 | def test_abstractclassmethod(self): 206 | """Creates a class method marked as an abstract method""" 207 | 208 | with self.assertWarns(DeprecationWarning): 209 | meth = util.abstractclassmethod(self.func) 210 | 211 | self.assertIsInstance(meth, classmethod) 212 | self.assertTrue(getattr(meth, '__isabstractmethod__', False)) 213 | self.assertTrue(getattr(meth.__func__, '__isabstractmethod__', False)) 214 | 215 | def test_abstractpropertymethod(self): 216 | """Creates a property marked as abstract""" 217 | 218 | with self.assertWarns(DeprecationWarning): 219 | prop = util.abstractproperty(self.func) 220 | 221 | self.assertIsInstance(prop, property) 222 | self.assertTrue(getattr(prop, '__isabstractmethod__', False)) 223 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. start-badges 2 | 3 | | |docs| |gh_actions| |codecov| 4 | | |pypi| |supported-versions| |supported-implementations| 5 | | |fedora| |EPEL| 6 | 7 | .. |docs| image:: https://img.shields.io/readthedocs/pluginlib.svg?style=plastic&logo=read-the-docs 8 | :target: https://pluginlib.readthedocs.org 9 | :alt: Documentation Status 10 | 11 | .. |gh_actions| image:: https://img.shields.io/github/actions/workflow/status/Rockhopper-Technologies/pluginlib/tests.yml?event=push&logo=github-actions&style=plastic 12 | :target: https://github.com/Rockhopper-Technologies/pluginlib/actions/workflows/tests.yml 13 | :alt: GitHub Actions Status 14 | 15 | .. |travis| image:: https://img.shields.io/travis/com/Rockhopper-Technologies/pluginlib.svg?style=plastic&logo=travis 16 | :target: https://travis-ci.com/Rockhopper-Technologies/pluginlib 17 | :alt: Travis-CI Build Status 18 | 19 | .. |codecov| image:: https://img.shields.io/codecov/c/github/Rockhopper-Technologies/pluginlib.svg?style=plastic&logo=codecov 20 | :target: https://codecov.io/gh/Rockhopper-Technologies/pluginlib 21 | :alt: Coverage Status 22 | 23 | .. |pypi| image:: https://img.shields.io/pypi/v/pluginlib.svg?style=plastic&logo=pypi 24 | :alt: PyPI Package latest release 25 | :target: https://pypi.python.org/pypi/pluginlib 26 | .. |supported-versions| image:: https://img.shields.io/pypi/pyversions/pluginlib.svg?style=plastic&logo=pypi 27 | :alt: Supported versions 28 | :target: https://pypi.python.org/pypi/pluginlib 29 | .. |supported-implementations| image:: https://img.shields.io/pypi/implementation/pluginlib.svg?style=plastic&logo=pypi 30 | :alt: Supported implementations 31 | :target: https://pypi.python.org/pypi/pluginlib 32 | 33 | .. |fedora| image:: https://img.shields.io/fedora/v/python3-pluginlib?style=plastic&logo=fedora&label=Fedora&color=lightgray 34 | :alt: Fedora version support 35 | :target: https://bodhi.fedoraproject.org/updates/?packages=python-pluginlib 36 | 37 | .. |EPEL| image:: https://img.shields.io/fedora/v/python3-pluginlib/epel9?style=plastic&logo=epel&label=EPEL&color=lightgray 38 | :alt: EPEL version support 39 | :target: https://bodhi.fedoraproject.org/updates/?packages=python-pluginlib 40 | 41 | .. end-badges 42 | 43 | Overview 44 | ======== 45 | 46 | Pluginlib makes creating plugins for your project simple. 47 | 48 | Features 49 | -------- 50 | 51 | - Plugins are validated when they are imported 52 | 53 | - Plugins can be loaded through different mechanisms (modules, filesystem paths, `entry points`_) 54 | 55 | - Multiple versions_ of the same plugin are supported (The newest one is used by default) 56 | 57 | - Plugins can be `blacklisted`_ by type, name, or version 58 | 59 | - Multiple `plugin groups`_ are supported so one program can use multiple sets of plugins that won't conflict 60 | 61 | - Plugins support `conditional loading`_ (examples: os, version, installed software, etc) 62 | 63 | - Once loaded, plugins can be accessed_ through dictionary or dot notation 64 | 65 | Installation 66 | ============ 67 | 68 | PIP 69 | --- 70 | 71 | .. code-block:: console 72 | 73 | $ pip install pluginlib 74 | 75 | Fedora and EL (RHEL/CentOS/Rocky/Alma) 76 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 77 | 78 | (EPEL_ repositories must be configured_ for EL8) 79 | 80 | .. code-block:: console 81 | 82 | $ dnf install python3-pluginlib 83 | 84 | EL7 (RHEL/CentOS) 85 | ^^^^^^^^^^^^^^^^^ 86 | 87 | (EPEL_ repositories must be configured_) 88 | 89 | .. code-block:: console 90 | 91 | $ yum install python2-pluginlib 92 | $ yum install python36-pluginlib 93 | 94 | Usage 95 | ===== 96 | 97 | Step 1: Define plugin parent classes 98 | ------------------------------------ 99 | 100 | All plugins are subclasses of parent classes. To create a parent class, use the 101 | `@Parent`_ decorator. 102 | 103 | The `@Parent`_ decorator can take a plugin type for accessing child plugins 104 | of the parent. If a plugin type isn't given, the class name will be used. 105 | 106 | The `@Parent`_ decorator can also take a ``group`` keyword which 107 | restricts plugins to a specific plugin group. ``group`` should be specified if plugins for 108 | different projects could be accessed in an single program, such as with libraries and frameworks. 109 | For more information, see the `Plugin Groups`_ section. 110 | 111 | Methods required in child plugins should be labeled as abstract methods. 112 | Plugins without these methods or with parameters 113 | that don't match, will not be loaded. 114 | For more information, see the `Abstract Methods`_ section. 115 | 116 | .. code-block:: python 117 | 118 | """ 119 | sample.py 120 | """ 121 | import pluginlib 122 | 123 | @pluginlib.Parent('parser') 124 | class Parser(): 125 | 126 | @pluginlib.abstractmethod 127 | def parse(self, string): 128 | pass 129 | 130 | Step 2: Define plugin classes 131 | ----------------------------- 132 | 133 | To create a plugin, subclass a parent class and include any required methods. 134 | 135 | Plugins can be customized through optional class attributes: 136 | 137 | `_alias_`_ 138 | Changes the name of the plugin which defaults to the class name. 139 | 140 | `_version_`_ 141 | Sets the version of the plugin. Defaults to the module ``__version__`` or ``None`` 142 | If multiple plugins with the same type and name are loaded, the plugin with 143 | the highest version is used. For more information, see the Versions_ section. 144 | 145 | `_skipload_`_ 146 | Specifies the plugin should not be loaded. This is useful when a plugin is a parent class 147 | for additional plugins or when a plugin should only be loaded under certain conditions. 148 | For more information see the `Conditional Loading`_ section. 149 | 150 | 151 | .. code-block:: python 152 | 153 | """ 154 | sample_plugins.py 155 | """ 156 | import json 157 | import sample 158 | 159 | class JSON(sample.Parser): 160 | 161 | _alias_ = 'json' 162 | 163 | def parse(self, string): 164 | return json.loads(string) 165 | 166 | Step 3: Load plugins 167 | -------------------- 168 | 169 | Plugins are loaded when the module they are in is imported. PluginLoader_ 170 | will load modules from specified locations and provides access to them. 171 | 172 | PluginLoader_ can load plugins from several locations. 173 | - A program's standard library 174 | - `Entry points`_ 175 | - A list of modules 176 | - A list of filesystem paths 177 | 178 | Plugins can also be filtered through blacklists and type filters. 179 | See the Blacklists_ and `Type Filters`_ sections for more information. 180 | 181 | Plugins are accessible through the PluginLoader.plugins_ property, 182 | a nested dictionary accessible through dot notation. For other ways to access plugins, 183 | see the `Accessing Plugins`_ section. 184 | 185 | .. code-block:: python 186 | 187 | import pluginlib 188 | import sample 189 | 190 | loader = pluginlib.PluginLoader(modules=['sample_plugins']) 191 | plugins = loader.plugins 192 | parser = plugins.parser.json() 193 | print(parser.parse('{"json": "test"}')) 194 | 195 | .. _Entry points: https://packaging.python.org/specifications/entry-points/ 196 | 197 | .. _PluginLoader: http://pluginlib.readthedocs.io/en/stable/api.html#pluginlib.PluginLoader 198 | .. _PluginLoader.plugins: http://pluginlib.readthedocs.io/en/stable/api.html#pluginlib.PluginLoader.plugins 199 | .. _@Parent: http://pluginlib.readthedocs.io/en/stable/api.html#pluginlib.Parent 200 | .. _\_alias\_: http://pluginlib.readthedocs.io/en/stable/api.html#pluginlib.Plugin._alias_ 201 | .. _\_version\_: http://pluginlib.readthedocs.io/en/stable/api.html#pluginlib.Plugin._version_ 202 | .. _\_skipload\_: http://pluginlib.readthedocs.io/en/stable/api.html#pluginlib.Plugin._skipload_ 203 | 204 | .. _Versions: http://pluginlib.readthedocs.io/en/stable/concepts.html#versions 205 | .. _Blacklists: http://pluginlib.readthedocs.io/en/stable/concepts.html#blacklists 206 | .. _blacklisted: http://pluginlib.readthedocs.io/en/stable/concepts.html#blacklists 207 | .. _Type Filters: http://pluginlib.readthedocs.io/en/stable/concepts.html#type-filters 208 | .. _Accessing Plugins: http://pluginlib.readthedocs.io/en/stable/concepts.html#accessing-plugins 209 | .. _accessed: http://pluginlib.readthedocs.io/en/stable/concepts.html#accessing-plugins 210 | .. _Abstract Methods: http://pluginlib.readthedocs.io/en/stable/concepts.html#abstract-methods 211 | .. _Conditional Loading: http://pluginlib.readthedocs.io/en/stable/concepts.html#conditional-loading 212 | .. _Plugin Groups: http://pluginlib.readthedocs.io/en/stable/concepts.html#plugin-groups 213 | 214 | .. _EPEL: https://fedoraproject.org/wiki/EPEL 215 | .. _configured: https://docs.fedoraproject.org/en-US/epel/#how_can_i_use_these_extra_packages 216 | -------------------------------------------------------------------------------- /pluginlib/_objects.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2025 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Pluginlib Object Module** 9 | 10 | This module contains pluginlib object classes 11 | """ 12 | 13 | from collections import OrderedDict 14 | from packaging.version import parse as parse_version 15 | 16 | from pluginlib._util import CachingDict, DictWithDotNotation, OPERATORS 17 | 18 | 19 | class BlacklistEntry: 20 | """ 21 | Args: 22 | plugin_type(str): Parent type 23 | name(str): Plugin name 24 | version(str): Plugin version 25 | operator(str): Comparison operator ('=', '==', '!=', '<', '<=', '>', '>=') 26 | 27 | **Container for blacklist entry** 28 | 29 | If ``operator`` is :py:data:`None` or not specified, it defaults to '=='. 30 | 31 | One of ``plugin_type``, ``name``, or ``version`` must be specified. 32 | If any are unspecified or :py:data:`None`, they are treated as a wildcard. 33 | 34 | In order to be more compatible with parsed text, 35 | the order of ``operator`` and ``version`` can be swapped. The following are equivalent: 36 | 37 | .. code-block:: python 38 | 39 | BlacklistEntry('parser', 'json', '1.0', '>=') 40 | 41 | .. code-block:: python 42 | 43 | BlacklistEntry('parser', 'json', '>=', '1.0') 44 | 45 | ``version`` is evaluated using :py:func:`packaging.version.parse` 46 | and should conform to `PEP 440`_ 47 | 48 | .. _PEP 440: https://www.python.org/dev/peps/pep-0440/ 49 | """ 50 | 51 | __slots__ = ('type', 'name', 'version', 'operator') 52 | 53 | def __init__(self, plugin_type=None, name=None, version=None, operator=None): 54 | 55 | if plugin_type is name is version is None: 56 | raise AttributeError('plugin_type, name, or version must be specified') 57 | 58 | self.type = plugin_type 59 | self.name = name 60 | if version in OPERATORS: 61 | 62 | self.operator = version 63 | self.version = operator 64 | 65 | if self.version is None: 66 | raise AttributeError('version must be specifed when operator is specified') 67 | 68 | else: 69 | self.version = version 70 | self.operator = operator 71 | 72 | if self.version is not None and not isinstance(self.version, str): 73 | raise TypeError(f'version must be a string, received {type(self.version).__name__}') 74 | 75 | if self.operator is None: 76 | self.operator = '==' 77 | elif self.operator not in OPERATORS: 78 | raise AttributeError(f"Unsupported operator '{self.operator}'") 79 | 80 | def __repr__(self): 81 | 82 | attrs = (self.type, self.name, self.operator, self.version) 83 | return f'{self.__class__.__name__}({", ".join([repr(attr) for attr in attrs])})' 84 | 85 | 86 | class GroupDict(DictWithDotNotation): 87 | """ 88 | Container for a plugin group 89 | """ 90 | 91 | _skip_empty = False 92 | _key_attr = 'type' 93 | _bl_skip_attrs = ('name', 'version') 94 | _bl_empty = DictWithDotNotation 95 | 96 | def _items(self, type_filter=None, name=None): 97 | """ 98 | Args: 99 | type_filter(list): Optional iterable of types to return (GroupDict only) 100 | name(str): Only return key by this name 101 | 102 | Alternative generator for items() method 103 | """ 104 | 105 | if name: 106 | if type_filter and self._key_attr == 'type': 107 | if name in type_filter and name in self: 108 | yield name, self[name] 109 | elif name in self: 110 | yield name, self[name] 111 | 112 | elif type_filter and self._key_attr == 'type': 113 | for key, val in self.items(): 114 | if key in type_filter: 115 | yield key, val 116 | else: 117 | yield from self.items() 118 | 119 | def _filter(self, blacklist=None, newest_only=False, type_filter=None, **kwargs): 120 | """ 121 | Args: 122 | blacklist(tuple): Iterable of of BlacklistEntry objects 123 | newest_only(bool): Only the newest version of each plugin is returned 124 | type(str): Plugin type to retrieve 125 | name(str): Plugin name to retrieve 126 | version(str): Plugin version to retrieve 127 | 128 | Returns nested dictionary of plugins 129 | 130 | If a blacklist is supplied, plugins are evaluated against the blacklist entries 131 | """ 132 | 133 | plugins = DictWithDotNotation() 134 | filtered_name = kwargs.get(self._key_attr, None) 135 | 136 | for key, val in self._items(type_filter, filtered_name): 137 | plugin_blacklist = None 138 | skip = False 139 | 140 | if blacklist: 141 | 142 | # Assume blacklist is correct format since it is checked by PluginLoade 143 | 144 | plugin_blacklist = [] 145 | for entry in blacklist: 146 | if getattr(entry, self._key_attr) not in (key, None): 147 | continue 148 | if all(getattr(entry, attr) is None for attr in self._bl_skip_attrs): 149 | if not self._skip_empty: 150 | plugins[key] = None if filtered_name else self._bl_empty() 151 | skip = True 152 | break 153 | 154 | plugin_blacklist.append(entry) 155 | 156 | if not skip: 157 | # pylint: disable=protected-access 158 | result = val._filter(plugin_blacklist, newest_only=newest_only, **kwargs) 159 | 160 | if result or not self._skip_empty: 161 | plugins[key] = result 162 | 163 | if filtered_name: 164 | return plugins.get(filtered_name, None) 165 | return plugins 166 | 167 | 168 | class TypeDict(GroupDict): 169 | """ 170 | Container for a plugin type 171 | """ 172 | 173 | _skip_empty = True 174 | _key_attr = 'name' 175 | _bl_skip_attrs = ('version',) 176 | _bl_empty = None # Not callable, but never called since _skip_empty is True 177 | 178 | def __init__(self, parent, *args, **kwargs): 179 | super().__init__(*args, **kwargs) 180 | self._parent = parent 181 | 182 | 183 | class PluginDict(CachingDict): 184 | """ 185 | Dictionary with properties for retrieving plugins 186 | """ 187 | 188 | def _sorted_keys(self): 189 | """ 190 | Return list of keys sorted by version 191 | 192 | Sorting is done based on :py:func:`packaging.version.parse` 193 | """ 194 | 195 | try: 196 | keys = self._cache['sorted_keys'] 197 | except KeyError: 198 | keys = self._cache['sorted_keys'] = sorted(self.keys(), key=parse_version) 199 | 200 | return keys 201 | 202 | def _process_blacklist(self, blacklist): 203 | """ 204 | Process blacklist into set of excluded versions 205 | """ 206 | 207 | # Assume blacklist is correct format since it is checked by PluginLoader 208 | 209 | blacklist_cache = {} 210 | blacklist_cache_old = self._cache.get('blacklist', {}) 211 | 212 | for entry in blacklist: 213 | 214 | blackkey = (entry.version, entry.operator) 215 | 216 | if blackkey in blacklist_cache: 217 | continue 218 | 219 | if blackkey in blacklist_cache_old: 220 | blacklist_cache[blackkey] = blacklist_cache_old[blackkey] 221 | else: 222 | entry_cache = blacklist_cache[blackkey] = set() 223 | blackversion = parse_version(entry.version or '0') 224 | blackop = OPERATORS[entry.operator] 225 | 226 | for key in self: 227 | if blackop(parse_version(key), blackversion): 228 | entry_cache.add(key) 229 | 230 | self._cache['blacklist'] = blacklist_cache 231 | return set().union(*blacklist_cache.values()) 232 | 233 | def _filter(self, blacklist=None, newest_only=False, **kwargs): 234 | """ 235 | Args: 236 | blacklist(tuple): Iterable of of BlacklistEntry objects 237 | newest_only(bool): Only the newest version of each plugin is returned 238 | version(str): Specific version to retrieve 239 | 240 | Returns dictionary of plugins 241 | 242 | If a blacklist is supplied, plugins are evaluated against the blacklist entries 243 | """ 244 | 245 | version = kwargs.get('version', None) 246 | rtn = None 247 | 248 | if self: # Dict is not empty 249 | 250 | if blacklist: 251 | 252 | blacklist = self._process_blacklist(blacklist) 253 | 254 | if version: 255 | if version not in blacklist: 256 | rtn = self.get(version, None) 257 | 258 | elif newest_only: 259 | for key in reversed(self._sorted_keys()): 260 | if key not in blacklist: 261 | rtn = self[key] 262 | break 263 | # If no keys are left, None will be returned 264 | else: 265 | rtn = OrderedDict() 266 | for key in self._sorted_keys(): 267 | if key not in blacklist: 268 | rtn[key] = self[key] 269 | 270 | elif version: 271 | rtn = self.get(version, None) 272 | 273 | elif newest_only: 274 | rtn = self[self._sorted_keys()[-1]] 275 | 276 | else: 277 | rtn = OrderedDict((key, self[key]) for key in self._sorted_keys()) 278 | 279 | return rtn 280 | -------------------------------------------------------------------------------- /doc/concepts.rst: -------------------------------------------------------------------------------- 1 | .. 2 | Copyright 2018 - 2025 Avram Lubkin, All Rights Reserved 3 | 4 | This Source Code Form is subject to the terms of the Mozilla Public 5 | License, v. 2.0. If a copy of the MPL was not distributed with this 6 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | :github_url: https://github.com/Rockhopper-Technologies/pluginlib 9 | 10 | .. py:currentmodule:: pluginlib 11 | 12 | 13 | Concepts 14 | ======== 15 | 16 | .. _abstract-methods: 17 | 18 | Abstract Methods 19 | ---------------- 20 | 21 | Methods required in child plugins should be labeled as abstract methods. 22 | Plugins without these methods or with :py:term:`parameters ` 23 | that don’t match, will not be loaded. 24 | 25 | Regular methods, static methods, class methods, properties, and attributes can all 26 | be designated as abstract. The process is slightly different depending on the type and 27 | what version of Python you are running. 28 | 29 | Regular methods always use the :py:func:`@abstractmethod ` decorator. 30 | 31 | .. code-block:: python 32 | 33 | @pluginlib.Parent('parser') 34 | class Parser: 35 | 36 | @pluginlib.abstractmethod 37 | def parse(self, string): 38 | pass 39 | 40 | For Python 3.3 and above, static methods, class methods, and properties also use the 41 | :py:func:`@abstractmethod ` decorator. Just place 42 | :py:func:`@abstractmethod ` as the innermost decorator. 43 | 44 | .. code-block:: python 45 | 46 | @pluginlib.Parent('parser') 47 | class Parser: 48 | 49 | @staticmethod 50 | @pluginlib.abstractmethod 51 | def abstract_staticmethod(): 52 | return 'foo' 53 | 54 | @classmethod 55 | @pluginlib.abstractmethod 56 | def abstract_classmethod(cls): 57 | return cls.foo 58 | 59 | @property 60 | @pluginlib.abstractmethod 61 | def abstract_property(self): 62 | return self.foo 63 | 64 | For code that must be compatible with older versions of Python, use the 65 | :py:func:`@abstractstaticmethod `, 66 | :py:func:`@abstractclassmethod `, and 67 | :py:func:`@abstractproperty ` decorators. 68 | 69 | .. code-block:: python 70 | 71 | @pluginlib.Parent('parser') 72 | class Parser: 73 | 74 | @pluginlib.abstractstaticmethod 75 | def abstract_staticmethod(): 76 | return 'foo' 77 | 78 | @pluginlib.abstractclassmethod 79 | def abstract_classmethod(cls): 80 | return cls.foo 81 | 82 | @pluginlib.abstractproperty 83 | def abstract_property(self): 84 | return self.foo 85 | 86 | Abstract attributes call also be defined, but no guarantee is made as to what kind of attribute 87 | the child plugin will have, just that the attribute is present. 88 | Abstract attributes are defined using :py:class:`abstractattribute`. 89 | 90 | .. code-block:: python 91 | 92 | @pluginlib.Parent('parser') 93 | class Parser: 94 | abstract_attribute = pluginlib.abstractattribute 95 | 96 | 97 | .. _versions: 98 | 99 | Versions 100 | -------- 101 | 102 | Plugin versions have two uses in Pluginlib: 103 | 104 | 1. If multiple plugins with the same type and name are loaded, the plugin with 105 | the highest version is used when :py:attr:`PluginLoader.plugins` is accessed. 106 | 2. :ref:`blacklists` can filter plugins based on their version number. 107 | 3. :py:attr:`PluginLoader.plugins_all` returns all unfiltered versions of plugins 108 | 109 | Versions must be strings and should adhere to `PEP 440`_. Version strings are 110 | evaluated using :py:func:`packaging.version.parse`. 111 | 112 | By default, all plugins will have a version of :py:data:`None`, 113 | which is treated as ``'0'`` when compared against other versions. 114 | 115 | A plugin version can be set explicitly with the 116 | :py:attr:`~pluginlib.Plugin._version_` class attribute. 117 | 118 | .. code-block:: python 119 | 120 | class NullParser(ParserParent): 121 | 122 | _version _ = '1.0.1' 123 | 124 | def parse(self, string): 125 | return string 126 | 127 | If a plugin version is not explicitly set and the module it's found in 128 | has a ``__version__`` variable, the module version is used. 129 | 130 | .. code-block:: python 131 | 132 | __version__ = '1.0.1' 133 | 134 | class NullParser(ParserParent): 135 | 136 | def parse(self, string): 137 | return string 138 | 139 | .. _PEP 440: https://www.python.org/dev/peps/pep-0440/ 140 | 141 | 142 | .. _conditional-loading: 143 | 144 | Conditional Loading 145 | ------------------- 146 | 147 | Sometimes a plugin child class is created that should not be loaded as a plugin. 148 | Examples include plugins only intended for specific environments and plugins inherited 149 | by additional plugins. 150 | 151 | The :py:attr:`~pluginlib.Plugin._skipload_` attribute can be configured to prevent a 152 | plugin from loading. :py:attr:`~pluginlib.Plugin._skipload_` can be a :py:class:`Boolean `, 153 | :py:func:`static method `, or :py:func:`class method `. 154 | If :py:attr:`~pluginlib.Plugin._skipload_` is a method, it will be called with no arguments. 155 | 156 | .. note:: 157 | :py:attr:`~pluginlib.Plugin._skipload_` can not be inherited and must be declared directly 158 | in the plugin class it applies to. 159 | 160 | :py:attr:`~pluginlib.Plugin._skipload_` as an attribute: 161 | 162 | .. code-block:: python 163 | 164 | class ParserPlugin(ParserParent): 165 | 166 | _skipload_ = True 167 | 168 | 169 | :py:attr:`~pluginlib.Plugin._skipload_` as a static method: 170 | 171 | .. code-block:: python 172 | 173 | import platform 174 | 175 | class ParserPlugin(ParserParent): 176 | 177 | @staticmethod 178 | def _skipload_(): 179 | 180 | if platform.system() != 'Linux': 181 | return True, "Only supported on Linux" 182 | return False 183 | 184 | :py:attr:`~pluginlib.Plugin._skipload_` as a class method: 185 | 186 | .. code-block:: python 187 | 188 | import sys 189 | 190 | class ParserPlugin(ParserParent): 191 | 192 | minimum_python = (3,4) 193 | 194 | @classmethod 195 | def _skipload_(cls): 196 | if sys.version_info[:2] < cls.minimum_python 197 | return True, "Not supported on this version of Python" 198 | return False 199 | 200 | 201 | .. _blacklists: 202 | 203 | Blacklists 204 | ---------- 205 | 206 | :py:class:`PluginLoader` allows blacklisting plugins based on the plugin type, name, or version. 207 | Blacklists are implemented with the ``blacklist`` argument. 208 | 209 | The ``blacklist`` argument to :py:class:`PluginLoader` must an iterable containing 210 | either :py:class:`BlacklistEntry` instances or tuples of arguments for creating 211 | :py:class:`BlacklistEntry` instances. 212 | 213 | The following are equivalent: 214 | 215 | .. code-block:: python 216 | 217 | PluginLoader(blacklist=[BlacklistEntry('parser', 'json')]) 218 | 219 | .. code-block:: python 220 | 221 | PluginLoader(blacklist=[('parser', 'json')]) 222 | 223 | For information about blacklist entries, see :py:class:`BlacklistEntry` in the :ref:`API-Reference`. 224 | 225 | 226 | .. _plugin-groups: 227 | 228 | Plugin Groups 229 | ------------- 230 | 231 | By default, Pluginlib places all plugins in a single group. This may not be desired 232 | in all cases, such as when created libraries and frameworks. For these use cases, 233 | a group should be specified for the :py:func:`@Parent ` decorator and when 234 | creating a :py:class:`PluginLoader` instance. Only plugins with a matching group 235 | will be available from the :py:class:`PluginLoader` instance. 236 | 237 | .. code-block:: python 238 | 239 | @pluginlib.Parent('parser', group='my_framework') 240 | class Parser: 241 | 242 | @pluginlib.abstractmethod 243 | def parse(self, string): 244 | pass 245 | 246 | .. code-block:: python 247 | 248 | loader = pluginlib.PluginLoader(modules=['sample_plugins'], group='my_framework') 249 | 250 | 251 | .. _type-filters: 252 | 253 | Type Filters 254 | ------------ 255 | 256 | By default, :py:class:`PluginLoader` will provide plugins for all parent plugins in the same 257 | plugin group. To limit plugins to specific types, use the ``type_filter`` keyword. 258 | 259 | .. code-block:: python 260 | 261 | loader = PluginLoader(library='myapp.lib') 262 | print(loader.plugins.keys()) 263 | # ['parser', 'engine', 'hook', 'action'] 264 | 265 | loader = PluginLoader(library='myapp.lib', type_filter=('parser', 'engine')) 266 | print(loader.plugins.keys()) 267 | # ['parser', 'engine'] 268 | 269 | 270 | 271 | .. _accessing-plugins: 272 | 273 | Accessing Plugins 274 | ----------------- 275 | 276 | Plugins are accessed through :py:class:`PluginLoader` properties and methods. In all cases, 277 | plugins that are filtered out through :ref:`blacklists ` or 278 | :ref:`type filters ` will not be returned. 279 | 280 | Plugins are filtered each time these methods are called, so it is recommended to save the result 281 | to a variable. 282 | 283 | :py:attr:`PluginLoader.plugins` 284 | This property returns the newest version of each available plugin. 285 | 286 | :py:attr:`PluginLoader.plugins_all` 287 | This property returns all versions of each available plugin. 288 | 289 | :py:meth:`PluginLoader.get_plugin` 290 | This method returns a specific plugin or :py:data:`None` if unavailable. 291 | 292 | .. code-block:: python 293 | 294 | loader = PluginLoader(library='myapp.lib') 295 | 296 | plugins = loader.plugins 297 | # {'parser': {'json': }} 298 | 299 | plugins_all = loader.plugins_all 300 | # {'parser': {'json': {'1.0': , 301 | # '2.0': }}} 302 | 303 | json_parser = loader.get_plugin('parser', 'json') 304 | # 305 | 306 | json_parser = loader.get_plugin('parser', 'json', '1.0') 307 | # 308 | 309 | json_parser = loader.get_plugin('parser', 'json', '4.0') 310 | # None 311 | -------------------------------------------------------------------------------- /tests/test_objects.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2025 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Test module for pluginlib._objects** 9 | """ 10 | 11 | from unittest import mock, TestCase 12 | 13 | import pluginlib._objects as objects 14 | 15 | 16 | # pylint: disable=protected-access 17 | 18 | 19 | class TestBlacklistEntry(TestCase): 20 | """Tests for BlacklistEntry class""" 21 | 22 | def test_empty(self): 23 | """Some value must be supplied or all plugins would be blacklisted""" 24 | with self.assertRaises(AttributeError): 25 | objects.BlacklistEntry() 26 | with self.assertRaises(AttributeError): 27 | objects.BlacklistEntry(operator='>') 28 | 29 | def test_ver_op(self): 30 | """Operator and version can be swapped""" 31 | entry = objects.BlacklistEntry('parser', 'json', '1.0', '>=') 32 | self.assertEqual(entry.type, 'parser') 33 | self.assertEqual(entry.name, 'json') 34 | self.assertEqual(entry.version, '1.0') 35 | self.assertEqual(entry.operator, '>=') 36 | 37 | def test_op_ver(self): 38 | """Operator and version can be swapped""" 39 | entry = objects.BlacklistEntry('parser', 'json', '>=', '1.0') 40 | self.assertEqual(entry.type, 'parser') 41 | self.assertEqual(entry.name, 'json') 42 | self.assertEqual(entry.version, '1.0') 43 | self.assertEqual(entry.operator, '>=') 44 | 45 | def test_op_none(self): 46 | """Operator is required when version is supplied""" 47 | with self.assertRaises(AttributeError): 48 | objects.BlacklistEntry('parser', 'json', '>=') 49 | entry = objects.BlacklistEntry('parser', 'json') 50 | self.assertEqual(entry.type, 'parser') 51 | self.assertEqual(entry.name, 'json') 52 | self.assertEqual(entry.version, None) 53 | self.assertEqual(entry.operator, '==') 54 | 55 | def test_no_op(self): 56 | """Operator defaults to '=='""" 57 | entry = objects.BlacklistEntry('parser', 'json', '1.0') 58 | self.assertEqual(entry.type, 'parser') 59 | self.assertEqual(entry.name, 'json') 60 | self.assertEqual(entry.version, '1.0') 61 | self.assertEqual(entry.operator, '==') 62 | 63 | def test_op_bad(self): 64 | """Operators must be in '=', '==', '!=', '<', '<=', '>', '>='""" 65 | with self.assertRaises(AttributeError): 66 | objects.BlacklistEntry('parser', 'json', '1.0', '%') 67 | 68 | def test_ver_bad(self): 69 | """Version is not a string""" 70 | with self.assertRaises(TypeError): 71 | objects.BlacklistEntry('parser', 'json', 1.0) 72 | 73 | def test_repr(self): 74 | """Ensure repr displays properly""" 75 | entry = objects.BlacklistEntry('parser', 'json', '1.0', '>=') 76 | self.assertEqual(repr(entry), "BlacklistEntry('parser', 'json', '>=', '1.0')") 77 | 78 | 79 | class TestGroupDict(TestCase): 80 | """Tests for GroupDict dictionary subclass""" 81 | 82 | def setUp(self): 83 | """Create sample dictionary""" 84 | self.mock_type_parser = mock.Mock() 85 | self.mock_type_parser._filter.return_value = 'parsertype' 86 | self.mock_type_engine = mock.Mock() 87 | self.mock_type_engine._filter.return_value = 'enginetype' 88 | self.mock_type_empty = mock.Mock() 89 | self.mock_type_empty._filter.return_value = {} 90 | 91 | self.expected = {'parser': 'parsertype', 'engine': 'enginetype', 'empty': {}} 92 | self.gdict = objects.GroupDict({'parser': self.mock_type_parser, 93 | 'engine': self.mock_type_engine, 94 | 'empty': self.mock_type_empty}) 95 | 96 | def test_no_blacklist(self): 97 | """Skip all blacklist logic""" 98 | self.assertEqual(self.gdict._filter(), self.expected) 99 | self.mock_type_parser._filter.assert_called_with(None, newest_only=False) 100 | self.mock_type_engine._filter.assert_called_with(None, newest_only=False) 101 | self.mock_type_empty._filter.assert_called_with(None, newest_only=False) 102 | 103 | self.assertEqual(self.gdict._filter([]), self.expected) 104 | self.mock_type_parser._filter.assert_called_with(None, newest_only=False) 105 | self.mock_type_engine._filter.assert_called_with(None, newest_only=False) 106 | self.mock_type_empty._filter.assert_called_with(None, newest_only=False) 107 | 108 | def test_blacklist_by_type(self): 109 | """Entire type is blacklisted, so it should be empty""" 110 | blacklist = [objects.BlacklistEntry('parser')] 111 | self.expected['parser'] = {} 112 | self.assertEqual(self.gdict._filter(blacklist), self.expected) 113 | self.mock_type_parser._filter.assert_not_called() 114 | self.mock_type_engine._filter.assert_called_with([], newest_only=False) 115 | self.mock_type_empty._filter.assert_called_with([], newest_only=False) 116 | 117 | def test_blacklist_by_type_name(self): 118 | """A type and specific name is blacklisted, so blacklist is passed to child for type""" 119 | blacklist = [objects.BlacklistEntry('parser', 'json')] 120 | self.assertEqual(self.gdict._filter(blacklist), self.expected) 121 | self.mock_type_parser._filter.assert_called_with(blacklist, newest_only=False) 122 | self.mock_type_engine._filter.assert_called_with([], newest_only=False) 123 | self.mock_type_empty._filter.assert_called_with([], newest_only=False) 124 | 125 | def test_blacklist_by_name(self): 126 | """A name is blacklisted in all types, so all children are passed the blacklist""" 127 | blacklist = [objects.BlacklistEntry(None, 'json')] 128 | self.assertEqual(self.gdict._filter(blacklist), self.expected) 129 | self.mock_type_parser._filter.assert_called_with(blacklist, newest_only=False) 130 | self.mock_type_engine._filter.assert_called_with(blacklist, newest_only=False) 131 | self.mock_type_empty._filter.assert_called_with(blacklist, newest_only=False) 132 | 133 | def test_empty(self): 134 | """Unless the entire type is blacklisted, an empty return value is still included""" 135 | blacklist = [objects.BlacklistEntry('empty', 'json')] 136 | self.assertEqual(self.gdict._filter(blacklist), self.expected) 137 | self.mock_type_parser._filter.assert_called_with([], newest_only=False) 138 | self.mock_type_engine._filter.assert_called_with([], newest_only=False) 139 | self.mock_type_empty._filter.assert_called_with(blacklist, newest_only=False) 140 | 141 | def test_type_filter(self): 142 | """Types not in list should be ignored""" 143 | del self.expected['engine'] 144 | self.assertEqual(self.gdict._filter(type_filter=('parser', 'empty')), self.expected) 145 | 146 | def test_type(self): 147 | """Get a specific type""" 148 | self.assertEqual(self.gdict._filter(type='engine'), 'enginetype') 149 | self.mock_type_engine._filter.assert_called_with(None, newest_only=False, type='engine') 150 | self.assertIsNone(self.gdict._filter(type='penguin')) 151 | 152 | def test_type_and_type_filter(self): 153 | """Type filter still affects calls for specific types""" 154 | self.assertEqual(self.gdict._filter(type_filter=('parser', 'empty'), type='engine'), None) 155 | self.mock_type_engine._filter.assert_not_called() 156 | self.mock_type_parser._filter.assert_not_called() 157 | self.mock_type_empty._filter.assert_not_called() 158 | 159 | self.assertEqual(self.gdict._filter(type_filter=('engine', 'empty'), type='engine'), 160 | 'enginetype') 161 | self.mock_type_engine._filter.assert_called_with(None, newest_only=False, type='engine') 162 | self.mock_type_parser._filter.assert_not_called() 163 | self.mock_type_empty._filter.assert_not_called() 164 | 165 | 166 | class TestTypeDict(TestCase): 167 | """Tests for GroupDict dictionary subclass""" 168 | 169 | def setUp(self): 170 | """Create sample dictionary""" 171 | self.mock_plugin_json = mock.Mock() 172 | self.mock_plugin_json._filter.return_value = 'jsonplugin' 173 | self.mock_plugin_xml = mock.Mock() 174 | self.mock_plugin_xml._filter.return_value = 'xmlplugin' 175 | self.expected = {'json': 'jsonplugin', 'xml': 'xmlplugin'} 176 | self.tdict = objects.TypeDict('parser', {'json': self.mock_plugin_json, 177 | 'xml': self.mock_plugin_xml}) 178 | 179 | def test_parent(self): 180 | """Ensure parent attribute is set""" 181 | self.assertEqual('parser', self.tdict._parent) 182 | 183 | def test_no_blacklist(self): 184 | """Skip all blacklist logic""" 185 | self.assertEqual(self.tdict._filter(), self.expected) 186 | self.mock_plugin_json._filter.assert_called_with(None, newest_only=False) 187 | self.mock_plugin_xml._filter.assert_called_with(None, newest_only=False) 188 | 189 | self.assertEqual(self.tdict._filter([]), self.expected) 190 | self.mock_plugin_json._filter.assert_called_with(None, newest_only=False) 191 | self.mock_plugin_xml._filter.assert_called_with(None, newest_only=False) 192 | 193 | def test_blacklist_by_name(self): 194 | """Entire name is blacklisted, so it's not called or included""" 195 | blacklist = [objects.BlacklistEntry(None, 'json')] 196 | del self.expected['json'] 197 | self.assertEqual(self.tdict._filter(blacklist), self.expected) 198 | self.mock_plugin_json._filter.assert_not_called() 199 | self.mock_plugin_xml._filter.assert_called_with([], newest_only=False) 200 | 201 | def test_blacklist_all(self): 202 | """Type is blacklisted, so all are blacklisted""" 203 | blacklist = [objects.BlacklistEntry('parser')] 204 | self.assertEqual(self.tdict._filter(blacklist), {}) 205 | self.mock_plugin_json._filter.assert_not_called() 206 | self.mock_plugin_xml._filter.assert_not_called() 207 | 208 | def test_blacklist_by_name_version(self): 209 | """A name and version is blacklisted, so blacklist is passed to child for type""" 210 | blacklist = [objects.BlacklistEntry('parser', 'json', '1.0')] 211 | self.assertEqual(self.tdict._filter(blacklist), self.expected) 212 | self.mock_plugin_json._filter.assert_called_with(blacklist, newest_only=False) 213 | self.mock_plugin_xml._filter.assert_called_with([], newest_only=False) 214 | 215 | def test_blacklist_by_version(self): 216 | """Only a version is blacklisted, so blacklist is passed to all children""" 217 | blacklist = [objects.BlacklistEntry(None, None, '1.0')] 218 | self.assertEqual(self.tdict._filter(blacklist), self.expected) 219 | self.mock_plugin_json._filter.assert_called_with(blacklist, newest_only=False) 220 | self.mock_plugin_xml._filter.assert_called_with(blacklist, newest_only=False) 221 | 222 | def test_empty(self): 223 | """Empty values are not included""" 224 | mock_plugin_empty = mock.Mock() 225 | mock_plugin_empty._filter.return_value = None 226 | self.tdict['empty'] = mock_plugin_empty 227 | self.assertEqual(self.tdict._filter(), self.expected) 228 | self.tdict._filter() 229 | self.mock_plugin_json._filter.assert_called_with(None, newest_only=False) 230 | self.mock_plugin_xml._filter.assert_called_with(None, newest_only=False) 231 | mock_plugin_empty._filter.assert_called_with(None, newest_only=False) 232 | 233 | blacklist = [objects.BlacklistEntry(None, None, '1.0')] 234 | self.assertEqual(self.tdict._filter(blacklist), self.expected) 235 | self.mock_plugin_json._filter.assert_called_with(blacklist, newest_only=False) 236 | self.mock_plugin_xml._filter.assert_called_with(blacklist, newest_only=False) 237 | mock_plugin_empty._filter.assert_called_with(blacklist, newest_only=False) 238 | 239 | def test_type_filter(self): 240 | """type_filter ignored in type dict""" 241 | self.assertEqual(self.tdict._filter(type_filter=('engine',)), self.expected) 242 | 243 | def test_name(self): 244 | """Get a specific name""" 245 | self.assertEqual(self.tdict._filter(name='json'), 'jsonplugin') 246 | self.mock_plugin_json._filter.assert_called_with(None, newest_only=False, name='json') 247 | 248 | self.assertIsNone(self.tdict._filter(name='pengion')) 249 | 250 | 251 | class TestPluginDict(TestCase): 252 | """Tests for PluginDict dictionary subclass""" 253 | 254 | def setUp(self): 255 | self.udict = objects.PluginDict({'2.0': 'dos', '1.0': 'uno', '3.0': 'tres'}) 256 | 257 | def test_sorted_keys(self): 258 | """Sorts by version and populates cache""" 259 | 260 | sorted_keys = ['1.0', '2.0', '3.0'] 261 | self.assertFalse('sorted_keys' in self.udict._cache) 262 | 263 | self.assertEqual(self.udict._sorted_keys(), sorted_keys) 264 | self.assertTrue('sorted_keys' in self.udict._cache) 265 | self.assertEqual(self.udict._cache['sorted_keys'], sorted_keys) 266 | 267 | # Call again to make sure results are consistent 268 | self.assertEqual(self.udict._sorted_keys(), sorted_keys) 269 | self.assertTrue('sorted_keys' in self.udict._cache) 270 | self.assertEqual(self.udict._cache['sorted_keys'], sorted_keys) 271 | 272 | del self.udict['2.0'] 273 | self.assertFalse('sorted_keys' in self.udict._cache) 274 | self.assertEqual(self.udict._sorted_keys(), ['1.0', '3.0']) 275 | self.assertTrue('sorted_keys' in self.udict._cache) 276 | self.assertEqual(self.udict._cache['sorted_keys'], ['1.0', '3.0']) 277 | 278 | def test_empty(self): 279 | """Empty dictionary will return None""" 280 | udict = objects.PluginDict() 281 | self.assertIsNone(udict._filter()) 282 | self.assertIsNone(udict._filter(newest_only=True)) 283 | self.assertIsNone(udict._filter('FakeBlackList')) 284 | self.assertIsNone(udict._filter('FakeBlackList', newest_only=True)) 285 | 286 | def test_no_blacklist(self): 287 | """Return newest without filtering""" 288 | self.assertEqual(self.udict._filter(newest_only=True), 'tres') 289 | 290 | def test_empty_blacklist(self): 291 | """Same as no blacklist""" 292 | self.assertEqual(self.udict._filter([]), self.udict) 293 | self.assertEqual(self.udict._filter([], newest_only=True), 'tres') 294 | 295 | def test_blacklist(self): 296 | """Cache gets populated and results get filtered""" 297 | 298 | # Cache is populated and highest value is filtered 299 | blacklist = [objects.BlacklistEntry(None, None, '3.0')] 300 | self.assertFalse('blacklist' in self.udict._cache) 301 | self.assertEqual(self.udict._filter(blacklist, newest_only=True), 'dos') 302 | self.assertEqual(len(self.udict._cache['blacklist']), 1) 303 | self.assertEqual(self.udict._cache['blacklist'][('3.0', '==')], set(['3.0'])) 304 | 305 | self.assertEqual(self.udict._filter(blacklist), 306 | objects.OrderedDict([('1.0', 'uno'), ('2.0', 'dos')])) 307 | 308 | # Another entry is cached, result is the same 309 | blacklist.append(objects.BlacklistEntry(None, None, '1.0')) 310 | self.assertEqual(self.udict._filter(blacklist, newest_only=True), 'dos') 311 | self.assertEqual(len(self.udict._cache['blacklist']), 2) 312 | self.assertEqual(self.udict._cache['blacklist'][('1.0', '==')], set(['1.0'])) 313 | 314 | self.assertEqual(self.udict._filter(blacklist), 315 | objects.OrderedDict([('2.0', 'dos')])) 316 | 317 | # An equivalent entry is added, cache is the same 318 | blacklist.append(objects.BlacklistEntry(None, 'number', '3.0', '==')) 319 | self.assertEqual(self.udict._filter(blacklist, newest_only=True), 'dos') 320 | self.assertEqual(len(self.udict._cache['blacklist']), 2) 321 | 322 | self.assertEqual(self.udict._filter(blacklist), 323 | objects.OrderedDict([('2.0', 'dos')])) 324 | 325 | def test_no_result(self): 326 | """Blacklist filters all""" 327 | blacklist = [objects.BlacklistEntry(None, None, '4.0', '<')] 328 | 329 | self.assertEqual(self.udict._filter(blacklist), objects.OrderedDict()) 330 | self.assertEqual(self.udict._cache['blacklist'][('4.0', '<')], set(['1.0', '2.0', '3.0'])) 331 | 332 | self.assertIsNone(self.udict._filter(blacklist, newest_only=True)) 333 | self.assertEqual(self.udict._cache['blacklist'][('4.0', '<')], set(['1.0', '2.0', '3.0'])) 334 | 335 | def test_version(self): 336 | """Return specific version""" 337 | self.assertEqual(self.udict._filter(newest_only=True, version='1.0'), 'uno') 338 | 339 | def test_version_blacklist(self): 340 | """Return specific version if not blacklisted""" 341 | blacklist = [objects.BlacklistEntry(None, None, '1.0')] 342 | self.assertEqual(self.udict._filter(blacklist, newest_only=True, version='1.0'), None) 343 | self.assertEqual(self.udict._filter(blacklist, newest_only=True, version='2.0'), 'dos') 344 | -------------------------------------------------------------------------------- /pluginlib/_loader.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2025 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Pluginlib Loader Submodule** 9 | 10 | Provides functions and classes for loading plugins 11 | """ 12 | 13 | 14 | import contextlib 15 | import importlib 16 | from inspect import ismodule 17 | import os 18 | import pkgutil 19 | import sys 20 | import traceback 21 | import warnings 22 | from collections.abc import Iterable 23 | 24 | from pluginlib.exceptions import PluginImportError, EntryPointWarning 25 | from pluginlib._objects import BlacklistEntry 26 | from pluginlib._parent import get_plugins 27 | from pluginlib._util import LOGGER, NoneType, PY_LT_3_10 28 | 29 | 30 | if PY_LT_3_10: # pragma: no cover 31 | from importlib_metadata import entry_points, EntryPoint # pylint: disable=import-error 32 | else: 33 | from importlib.metadata import entry_points, EntryPoint 34 | 35 | 36 | def _raise_friendly_exception(exc, name, path): 37 | """ 38 | Attempt to create a friendly traceback that only shows the errors 39 | encountered from the plugin import and not the framework 40 | """ 41 | 42 | etype = exc.__class__ 43 | tback = getattr(exc, '__traceback__', sys.exc_info()[2]) 44 | 45 | # Create traceback starting at module for friendly output 46 | start = 0 47 | here = 0 48 | tb_list = traceback.extract_tb(tback) 49 | 50 | if path: 51 | for idx, entry in enumerate(tb_list): 52 | # Find index for traceback starting with module we tried to load 53 | if os.path.dirname(entry[0]) == path: 54 | start = idx 55 | break 56 | 57 | # Find index for traceback starting with this file 58 | if os.path.splitext(entry[0])[0] == os.path.splitext(__file__)[0]: 59 | here = idx 60 | 61 | if start == 0 and isinstance(exc, SyntaxError): 62 | limit = 0 63 | else: 64 | limit = 0 - len(tb_list) + max(start, here) 65 | 66 | friendly = ''.join(traceback.format_exception(etype, exc, tback, limit)) 67 | 68 | # Format exception 69 | msg = f'Error while importing candidate plugin module {name} from {path}' 70 | exception = PluginImportError(f'{msg}: {repr(exc)}', friendly=friendly) 71 | 72 | raise exception.with_traceback(tback) from None 73 | 74 | 75 | def _import_module(name, path=None): 76 | """ 77 | Args: 78 | name(str): 79 | * Full name of object 80 | * name can also be an EntryPoint object, name and path will be determined dynamically 81 | path(str): Module directory 82 | 83 | Returns: 84 | object: module object or advertised object for EntryPoint 85 | 86 | Loads a module using importlib catching exceptions 87 | If path is given, the traceback will be formatted to give more friendly and direct information 88 | """ 89 | 90 | # If name is an entry point, try to parse it 91 | epoint = None 92 | if isinstance(name, EntryPoint): 93 | epoint = name 94 | name = epoint.module 95 | 96 | if path is None: 97 | with contextlib.suppress(ImportError): 98 | spec = importlib.util.find_spec(name) 99 | if spec: 100 | path = ( 101 | os.path.dirname(spec.origin) 102 | if getattr(spec, 'origin', None) 103 | else next(iter(spec.submodule_search_locations)) 104 | ) 105 | LOGGER.debug('Attempting to load module %s from %s', name, path) 106 | try: 107 | mod = epoint.load() if epoint else importlib.import_module(name) 108 | 109 | except Exception as e: # pylint: disable=broad-except 110 | _raise_friendly_exception(e, name, path) 111 | 112 | return mod 113 | 114 | 115 | def _recursive_import(package): 116 | """ 117 | Args: 118 | package(py:term:`package`): Package to walk 119 | 120 | Import all modules from a package recursively 121 | """ 122 | 123 | path = getattr(package, '__path__', None) 124 | if path: 125 | # pylint: disable=unused-variable 126 | for finder, name, is_pkg in pkgutil.walk_packages(path, prefix=f'{package.__name__}.'): 127 | _import_module(name, finder.path) 128 | 129 | 130 | def _recursive_path_import(path, prefix_package): 131 | """ 132 | Args: 133 | path(str): Path to walk 134 | prefix_package(str): Prefix to apply to found modules 135 | 136 | Import all modules from a path recursively 137 | 138 | If a python package is found, it is imported recursively, 139 | however, the directory walk will stop on the directory of the package 140 | and Python files under the package that are in directories without 141 | init files, will be skipped. 142 | """ 143 | 144 | # Include basename of path in module prefix 145 | basename = os.path.basename(path.strip('/')) 146 | root_prefix = f'{prefix_package}.{basename}.' 147 | 148 | # Walk path 149 | for root, dirs, files in os.walk(path): 150 | # If root is a Python module, we won't walk any farther down it 151 | # find_module() seems to be recursive in Python 3 152 | if '__init__.py' in files: 153 | dirs.clear() 154 | if root != path: 155 | continue 156 | 157 | # Generate prefix 158 | if root == path: 159 | prefix = root_prefix 160 | else: 161 | prefix = f'{root_prefix}{os.path.relpath(root, path).replace(os.sep, ".")}.' 162 | 163 | # Walk root and import modules 164 | # pylint: disable=unused-variable 165 | for finder, name, is_pkg in pkgutil.walk_packages([root], prefix=prefix): 166 | LOGGER.debug('Attempting to load module %s from %s', name, finder.path) 167 | try: 168 | spec = finder.find_spec(name) 169 | module = importlib.util.module_from_spec(spec) 170 | sys.modules[name] = module 171 | spec.loader.exec_module(module) 172 | 173 | except Exception as e: # pylint: disable=broad-except 174 | _raise_friendly_exception(e, name, root) 175 | 176 | 177 | # pylint: disable=too-many-instance-attributes,too-many-arguments 178 | class PluginLoader: 179 | """ 180 | Args: 181 | group(str): Group to retrieve plugins from 182 | library(str): Standard library package 183 | modules(list): Iterable of modules to import recursively 184 | paths(list): Iterable of paths to import recursively 185 | entry_point(str): `Entry point`_ for additional plugins 186 | blacklist(list): Iterable of :py:class:`BlacklistEntry` objects or tuples 187 | prefix_package(str): Alternative prefix for imported packages 188 | type_filter(list): Iterable of parent plugin types to allow 189 | 190 | **Interface for importing and accessing plugins** 191 | 192 | Plugins are loaded from sources specified at initialization when 193 | :py:meth:`load_modules` is called or when the :py:attr:`plugins` property is first accessed. 194 | 195 | ``group`` specifies the group whose members will be returned by :py:attr:`plugins` 196 | This corresponds directly with the ``group`` attribute for :py:func:`Parent`. 197 | When not specified, the default group is used. 198 | ``group`` should be specified if plugins for different projects could be accessed 199 | in an single program, such as in libraries and frameworks. 200 | 201 | ``library`` indicates the package of a program's standard library. 202 | This should be a package which is always loaded. 203 | 204 | ``modules`` is an iterable of optional modules to load. 205 | If a package is given, it will be loaded recursively. 206 | 207 | ``paths`` is an iterable of optional paths to find modules to load. 208 | The paths are searched recursively and imported under the namespace specified by 209 | ``prefix_package``. 210 | 211 | ``entry_point`` specifies an `entry point `_ group to identify 212 | additional modules and packages which should be loaded. 213 | 214 | ``blacklist`` is an iterable containing :py:class:`BlacklistEntry` objects or tuples 215 | with arguments for new :py:class:`BlacklistEntry` objects. 216 | 217 | ``prefix_package`` must be the name of an existing package under which to import the paths 218 | specified in ``paths``. Because the package paths will be traversed recursively, this should 219 | be an empty path. 220 | 221 | ``type_filter`` limits plugins types to only those specified. A specified type is not 222 | guaranteed to be available. 223 | 224 | .. _Entry point: https://packaging.python.org/specifications/entry-points/ 225 | """ 226 | 227 | # pylint: disable-next=too-many-positional-arguments 228 | def __init__(self, group=None, library=None, modules=None, paths=None, entry_point=None, 229 | blacklist=None, prefix_package='pluginlib.importer', type_filter=None): 230 | 231 | # Make sure we got iterables 232 | for argname, arg in (('modules', modules), ('paths', paths), ('blacklist', blacklist), 233 | ('type_filter', type_filter)): 234 | if not isinstance(arg, (NoneType, Iterable)) or isinstance(arg, str): 235 | raise TypeError(f"Expecting iterable for '{argname}', received {type(arg)}") 236 | 237 | # Make sure we got strings 238 | for argname, arg in (('library', library), ('entry_point', entry_point), 239 | ('prefix_package', prefix_package)): 240 | if not isinstance(arg, (NoneType, str)): 241 | raise TypeError(f"Expecting string for '{argname}', received {type(arg)}") 242 | 243 | self.group = group or '_default' 244 | self.library = library 245 | self.modules = modules or tuple() 246 | self.paths = paths or tuple() 247 | self.entry_point = entry_point 248 | self.prefix_package = prefix_package 249 | self.type_filter = type_filter 250 | self.loaded = False 251 | 252 | if blacklist: 253 | self.blacklist = [] 254 | for entry in blacklist: 255 | 256 | if isinstance(entry, BlacklistEntry): 257 | pass 258 | elif isinstance(entry, Iterable): 259 | try: 260 | entry = BlacklistEntry(*entry) 261 | except (AttributeError, TypeError) as e: 262 | # pylint: disable=raise-missing-from 263 | raise AttributeError( 264 | "Invalid blacklist entry f'{entry}': {e} " 265 | ) from e 266 | else: 267 | raise AttributeError(f"Invalid blacklist entry '{entry}': Not an iterable") 268 | 269 | self.blacklist.append(entry) 270 | 271 | self.blacklist = tuple(self.blacklist) 272 | 273 | else: 274 | self.blacklist = None 275 | 276 | def __repr__(self): 277 | 278 | args = [] 279 | for attr, default in (('group', '_default'), ('library', None), ('modules', None), 280 | ('paths', None), ('entry_point', None), ('blacklist', None), 281 | ('prefix_package', 'pluginlib.importer'), ('type_filter', None)): 282 | 283 | val = getattr(self, attr) 284 | if default and val == default: 285 | continue 286 | 287 | if val: 288 | args.append(f'{attr}={val!r}') 289 | 290 | return f'{self.__class__.__name__}({", ".join(args)})' 291 | 292 | def load_modules(self): 293 | """ 294 | Locate and import modules from locations specified during initialization. 295 | 296 | Locations include: 297 | - Program's standard library (``library``) 298 | - `Entry points `_ (``entry_point``) 299 | - Specified modules (``modules``) 300 | - Specified paths (``paths``) 301 | 302 | If a malformed child plugin class is imported, a :py:exc:`PluginWarning` will be issued, 303 | the class is skipped, and loading operations continue. 304 | 305 | If an invalid `entry point `_ is specified, an :py:exc:`EntryPointWarning` 306 | is issued and loading operations continue. 307 | """ 308 | 309 | # Start with standard library 310 | if self.library: 311 | LOGGER.info('Loading plugins from standard library') 312 | libmod = _import_module(self.library) 313 | _recursive_import(libmod) 314 | 315 | # Get entry points 316 | if self.entry_point: 317 | LOGGER.info('Loading plugins from entry points group %s', self.entry_point) 318 | for epoint in entry_points(group=self.entry_point): 319 | try: 320 | mod = _import_module(epoint) 321 | except PluginImportError as e: 322 | warnings.warn( 323 | f'Module {epoint.module} can not be loaded for entry point {epoint.name}: ' 324 | f'{e}', 325 | EntryPointWarning 326 | ) 327 | continue 328 | 329 | # If we have a package, walk it 330 | if ismodule(mod): 331 | _recursive_import(mod) 332 | else: 333 | warnings.warn(f"Entry point '{epoint.name}' is not a module or package", 334 | EntryPointWarning) 335 | 336 | # Load auxiliary modules 337 | if self.modules: 338 | for mod in self.modules: 339 | LOGGER.info('Loading plugins from %s', mod) 340 | _recursive_import(_import_module(mod)) 341 | 342 | # Load auxiliary paths 343 | if self.paths: 344 | # Import each path recursively 345 | for path in self.paths: 346 | modpath = os.path.realpath(path) 347 | if os.path.isdir(modpath): 348 | LOGGER.info("Recursively importing plugins from path `%s`", path) 349 | _recursive_path_import(path, self.prefix_package) 350 | else: 351 | LOGGER.info("Configured plugin path '%s' is not a valid directory", path) 352 | 353 | self.loaded = True 354 | 355 | @property 356 | def plugins(self): 357 | """ 358 | Newest version of all plugins in the group filtered by ``blacklist`` 359 | 360 | Returns: 361 | dict: Nested dictionary of plugins accessible through dot-notation. 362 | 363 | Plugins are returned in a nested dictionary, but can also be accessed through dot-notion. 364 | Just as when accessing an undefined dictionary key with index-notation, 365 | a :py:exc:`KeyError` will be raised if the plugin type or plugin does not exist. 366 | 367 | Parent types are always included. 368 | Child plugins will only be included if a valid, non-blacklisted plugin is available. 369 | """ 370 | 371 | if not self.loaded: 372 | self.load_modules() 373 | 374 | # pylint: disable=protected-access 375 | return get_plugins()[self.group]._filter(blacklist=self.blacklist, newest_only=True, 376 | type_filter=self.type_filter) 377 | 378 | @property 379 | def plugins_all(self): 380 | """ 381 | All resulting versions of all plugins in the group filtered by ``blacklist`` 382 | 383 | Returns: 384 | dict: Nested dictionary of plugins accessible through dot-notation. 385 | 386 | Similar to :py:attr:`plugins`, but lowest level is an :py:class:`~collections.OrderedDict` 387 | of all unfiltered plugin versions for the given plugin type and name. 388 | 389 | Parent types are always included. 390 | Child plugins will only be included if at least one valid, non-blacklisted plugin 391 | is available. 392 | 393 | The newest plugin can be retrieved by accessing the last item in the dictionary. 394 | 395 | .. code-block:: python 396 | 397 | plugins = loader.plugins_all 398 | tuple(plugins.parser.json.values())[-1] 399 | """ 400 | 401 | if not self.loaded: 402 | self.load_modules() 403 | 404 | # pylint: disable=protected-access 405 | return get_plugins()[self.group]._filter(blacklist=self.blacklist, 406 | type_filter=self.type_filter) 407 | 408 | def get_plugin(self, plugin_type, name, version=None): 409 | """ 410 | Args: 411 | plugin_type(str): Parent type 412 | name(str): Plugin name 413 | version(str): Plugin version 414 | 415 | Returns: 416 | :py:class:`Plugin`: Plugin, or :py:data:`None` if plugin can't be found 417 | 418 | Retrieve a specific plugin. ``blacklist`` and ``type_filter`` still apply. 419 | 420 | If ``version`` is not specified, the newest available version is returned. 421 | """ 422 | 423 | if not self.loaded: 424 | self.load_modules() 425 | 426 | # pylint: disable=protected-access 427 | return get_plugins()[self.group]._filter(blacklist=self.blacklist, 428 | newest_only=True, 429 | type_filter=self.type_filter, 430 | type=plugin_type, 431 | name=name, 432 | version=version) 433 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /pluginlib/_parent.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2025 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Pluginlib Parent Submodule** 9 | 10 | Provides plugin bases class and Parent decorator 11 | """ 12 | 13 | import sys 14 | import warnings 15 | from inspect import getfullargspec, iscoroutinefunction, isfunction 16 | 17 | from pluginlib.exceptions import PluginWarning 18 | from pluginlib._objects import GroupDict, TypeDict, PluginDict 19 | from pluginlib._util import (allow_bare_decorator, ClassProperty, DictWithDotNotation, 20 | LOGGER, Undefined) 21 | 22 | 23 | DEFAULT = '_default' 24 | UNDEFINED = Undefined() 25 | 26 | 27 | class ClassInspector: 28 | """ 29 | Args: 30 | cls(:py:class:`Plugin`): Parent class 31 | subclass(:py:class:`Plugin`): Subclass to evaluate 32 | 33 | Inspects subclass for inclusion 34 | After initialization, the following attributes are set: 35 | 36 | errorcode 37 | A code corresponding to the error condition 38 | 39 | message 40 | Error message 41 | 42 | Values for errorcode: 43 | 44 | * 0: No error 45 | 46 | Error codes between 0 and 100 are not intended for import 47 | 48 | * 50 Skipload flag is True 49 | 50 | Error codes between 99 and 200 are excluded from import 51 | 52 | * 156: Skipload call returned True 53 | 54 | Error codes 200 and above are malformed classes 55 | 56 | * 210: Missing abstract property 57 | * 211: Missing abstract static method 58 | * 212: Missing abstract class method 59 | * 213: Missing abstract method 60 | * 214: Missing abstract attribute 61 | * 215: Missing abstract coroutine 62 | * 216: Type annotations differ 63 | * 220: Argument spec does not match 64 | """ 65 | 66 | def __init__(self, cls, subclass): 67 | 68 | self.message = None 69 | self.errorcode = 0 70 | self.cls = cls 71 | self.subclass = subclass 72 | 73 | self._check_skipload() 74 | if not self.errorcode: 75 | self._check_methods() 76 | 77 | def __bool__(self): 78 | return self.errorcode == 0 79 | 80 | def _check_skipload(self): 81 | """ 82 | Determine if subclass should be skipped 83 | _skipload_ is either a Boolean or callable that returns a Boolean 84 | """ 85 | 86 | # pylint: disable=protected-access 87 | if callable(self.subclass._skipload_): 88 | 89 | result = self.subclass._skipload_() 90 | 91 | if isinstance(result, tuple): 92 | skip, self.message = result 93 | else: 94 | skip = result 95 | 96 | if skip: 97 | self.errorcode = 156 98 | 99 | elif self.subclass._skipload_: 100 | self.errorcode = 50 101 | self.message = 'Skipload flag is True' 102 | 103 | def _check_methods(self): 104 | """ 105 | Validate abstract methods are defined in subclass 106 | """ 107 | 108 | for name, method in self.cls.__abstractmethods__.items(): 109 | 110 | if self.errorcode: 111 | break 112 | 113 | # Need to get attribute from dictionary for instance tests to work 114 | for base in self.subclass.__mro__: # pragma: no branch 115 | if name in base.__dict__: 116 | submethod = base.__dict__[name] 117 | break 118 | 119 | # If we found our abstract method, we didn't find anything 120 | if submethod is method: 121 | submethod = UNDEFINED 122 | 123 | if isinstance(method, property): 124 | self._check_property(name, submethod) 125 | elif isinstance(method, staticmethod): 126 | self._check_static_method(name, method, submethod) 127 | elif isinstance(method, classmethod): 128 | self._check_class_method(name, method, submethod) 129 | elif isfunction(method): 130 | self._check_generic_method(name, method, submethod) 131 | 132 | # If it's not a type we're specifically checking, just check for existence 133 | elif submethod is UNDEFINED: 134 | self.errorcode = 214 135 | self.message = f'Does not contain required attribute ({name})' 136 | 137 | if not self.errorcode: 138 | self._check_coroutine_method(name, method, submethod) 139 | 140 | if not self.errorcode: 141 | self._check_annotations(name, method, submethod) 142 | 143 | def _check_property(self, name, submethod): 144 | """ 145 | Args: 146 | name(str): Method name 147 | method(:py:class:`function`): Abstract method object 148 | submethod(:py:class:`function`): Subclass method object 149 | 150 | Check for class properties 151 | """ 152 | 153 | if submethod is UNDEFINED or not isinstance(submethod, property): 154 | self.errorcode = 210 155 | self.message = f'Does not contain required property ({name})' 156 | 157 | def _check_static_method(self, name, method, submethod): 158 | """ 159 | Args: 160 | name(str): Method name 161 | method(:py:class:`function`): Abstract method object 162 | submethod(:py:class:`function`): Subclass method object 163 | 164 | Check for static methods 165 | """ 166 | 167 | if submethod is UNDEFINED or not isinstance(submethod, staticmethod): 168 | self.errorcode = 211 169 | self.message = f'Does not contain required static method ({name})' 170 | else: 171 | self._compare_argspec(name, getfullargspec(method.__func__), 172 | getfullargspec(submethod.__func__)) 173 | 174 | def _check_class_method(self, name, method, submethod): 175 | """ 176 | Args: 177 | name(str): Method name 178 | method(:py:class:`function`): Abstract method object 179 | submethod(:py:class:`function`): Subclass method object 180 | 181 | Check for class methods 182 | """ 183 | 184 | if submethod is UNDEFINED or not isinstance(submethod, classmethod): 185 | self.errorcode = 212 186 | self.message = f'Does not contain required class method ({name})' 187 | else: 188 | self._compare_argspec(name, getfullargspec(method.__func__), 189 | getfullargspec(submethod.__func__)) 190 | 191 | def _check_generic_method(self, name, method, submethod): 192 | """ 193 | Args: 194 | name(str): Method name 195 | method(:py:class:`function`): Abstract method object 196 | submethod(:py:class:`function`): Subclass method object 197 | 198 | Check for generic methods 199 | """ 200 | 201 | if submethod is UNDEFINED or not isfunction(submethod): 202 | self.errorcode = 213 203 | self.message = f'Does not contain required method ({name})' 204 | else: 205 | self._compare_argspec(name, getfullargspec(method), getfullargspec(submethod)) 206 | 207 | def _check_coroutine_method(self, name, method, submethod): 208 | """ 209 | Args: 210 | name(str): Method name 211 | method(:py:class:`function`): Abstract method object 212 | submethod(:py:class:`function`): Subclass method object 213 | 214 | If abstract is a coroutine method, child should be too 215 | """ 216 | 217 | if iscoroutinefunction(method) and not iscoroutinefunction(submethod): 218 | self.errorcode = 215 219 | self.message = f'Does not contain required coroutine method ({name})' 220 | 221 | def _check_annotations(self, name, method, submethod): 222 | """ 223 | Args: 224 | name(str): Method name 225 | method(:py:class:`function`): Abstract method object 226 | submethod(:py:class:`function`): Subclass method object 227 | 228 | If abstract has type annotations and the child has type annotations, they should match 229 | """ 230 | 231 | meth_annotations = getattr(method, '__annotations__', {}) 232 | if meth_annotations: 233 | submeth_annotations = getattr(submethod, '__annotations__', {}) 234 | if submeth_annotations and meth_annotations != submeth_annotations: 235 | self.errorcode = 216 236 | self.message = f'Type annotations differ for ({name})' 237 | 238 | def _compare_argspec(self, name, spec_1, spec_2): 239 | """ 240 | Args: 241 | name(str): Method name 242 | spec_1(:py:class:`inspect.FullArgSpec`): Argument spec 243 | spec_2(:py:class:`inspect.FullArgSpec`): Argument spec 244 | 245 | Compares two argspecs skipping type annotations 246 | """ 247 | 248 | spec_1_dict = spec_1._asdict() 249 | spec_2_dict = spec_2._asdict() 250 | 251 | matches = True 252 | 253 | for key, val in spec_1_dict.items(): 254 | # Annotations are checked separately 255 | if key == 'annotations': 256 | continue 257 | if spec_2_dict[key] != val: 258 | matches = False 259 | break 260 | 261 | if not matches: 262 | self.errorcode = 220 263 | self.message = f'Argument spec does not match parent for method {name}' 264 | 265 | 266 | class PluginType(type): 267 | """ 268 | Metaclass for plugins 269 | """ 270 | 271 | __plugins = DictWithDotNotation([(DEFAULT, GroupDict())]) 272 | 273 | # pylint: disable=bad-mcs-classmethod-argument 274 | def __new__(cls, name, bases, namespace, **kwargs): 275 | 276 | # Make sure '_parent_', '_skipload_' are set explicitly or ignore 277 | for attr in ('_parent_', '_skipload_'): 278 | if attr not in namespace: 279 | namespace[attr] = False 280 | 281 | new = super(PluginType, cls).__new__(cls, name, bases, namespace, **kwargs) 282 | 283 | # Determine group 284 | group = cls.__plugins.setdefault(new._group_ or DEFAULT, GroupDict()) 285 | 286 | if new._type_ in group: 287 | if new._parent_: 288 | raise ValueError(f'parent must be unique: {new._type_}') 289 | 290 | plugindict = group[new._type_].get(new.name, UNDEFINED) 291 | version = str(new.version or 0) 292 | 293 | # Check for duplicates. Warn and ignore 294 | if plugindict and version in plugindict: 295 | 296 | existing = plugindict[version] 297 | warnings.warn( 298 | f'Duplicate plugins found for {new}: {new.__module__}.{new.__name__} and ' 299 | f'{existing.__module__}.{existing.__name__}', 300 | PluginWarning, 301 | stacklevel=2 302 | ) 303 | 304 | else: 305 | result = ClassInspector(group[new._type_]._parent, new) 306 | 307 | if result: 308 | group[new._type_].setdefault(new.name, PluginDict())[version] = new 309 | 310 | else: 311 | skipmsg = 'Skipping %s class %s.%s: Reason: %s' 312 | args = (new, new.__module__, new.__name__, result.message) 313 | 314 | if result.errorcode < 100: 315 | LOGGER.debug(skipmsg, *args) 316 | elif result.errorcode < 200: 317 | LOGGER.info(skipmsg, *args) 318 | else: 319 | warnings.warn(skipmsg % args, PluginWarning, stacklevel=2) 320 | 321 | elif new._parent_: 322 | group[new._type_] = TypeDict(new) 323 | 324 | new.__abstractmethods__ = {} 325 | 326 | # Get abstract methods by walking the MRO 327 | for base in reversed(new.__mro__): 328 | for method_name, method in base.__dict__.items(): 329 | if getattr(method, '__isabstractmethod__', False): 330 | new.__abstractmethods__[method_name] = method 331 | 332 | else: 333 | raise ValueError(f'Unknown parent type: {new._type_}') 334 | 335 | return new 336 | 337 | def _get_plugins(cls): 338 | """ 339 | Return registered plugins 340 | """ 341 | 342 | return cls.__plugins[cls._group_ or DEFAULT][cls._type_] 343 | 344 | 345 | class Plugin: 346 | """ 347 | **Mixin class for plugins. 348 | All parents and child plugins will inherit from this class automatically.** 349 | 350 | **Class Attributes** 351 | 352 | *The following attributes can be set as class attributes in subclasses* 353 | 354 | .. autoattribute:: _alias_ 355 | .. autoattribute:: _skipload_ 356 | .. autoattribute:: _version_ 357 | 358 | **Class Properties** 359 | 360 | .. autoattribute:: name 361 | :annotation: 362 | 363 | :py:class:`str` -- :attr:`_alias_` if set or falls back to class name 364 | 365 | .. autoattribute:: plugin_group 366 | 367 | .. autoattribute:: plugin_type 368 | 369 | .. autoattribute:: version 370 | :annotation: 371 | 372 | :py:class:`str` -- Returns :attr:`_version_` if set, 373 | otherwise falls back to module ``__version__`` or :py:data:`None` 374 | 375 | """ 376 | 377 | __slots__ = () 378 | 379 | _alias_ = None 380 | """:py:class:`str` -- Friendly name to refer to plugin. 381 | Accessed through :attr:`~Plugin.name` property.""" 382 | _skipload_ = False 383 | """:py:class:`bool` -- When True, plugin is not loaded. 384 | Can also be a static or class method that returns a tuple ``(bool, message)``""" 385 | _version_ = None 386 | """:py:class:`str` -- Plugin version. Should adhere to `PEP 440`_. 387 | Accessed through :attr:`~Plugin.version` property. 388 | 389 | .. _PEP 440: https://www.python.org/dev/peps/pep-0440/""" 390 | 391 | @ClassProperty 392 | def version(cls): # noqa: N805 # pylint: disable=no-self-argument 393 | """ 394 | :py:class:Returns `str` -- Returns :attr:`_version_` if set, 395 | otherwise falls back to module `__version__` or None 396 | """ 397 | return cls._version_ or getattr(sys.modules.get(cls.__module__, None), 398 | '__version__', None) 399 | 400 | @ClassProperty 401 | def name(cls): # noqa: N805 # pylint: disable=no-self-argument 402 | """ 403 | :py:class:`str` -- :attr:`_alias_` if set or falls back to class name 404 | """ 405 | 406 | return cls._alias_ or cls.__name__ # pylint: disable=no-member 407 | 408 | @ClassProperty 409 | def plugin_type(cls): # noqa: N805 # pylint: disable=no-self-argument 410 | """ 411 | :py:class:`str` -- ``plugin_type`` of :py:class:`~pluginlib.Parent` class 412 | """ 413 | 414 | return cls._type_ # pylint: disable=no-member 415 | 416 | @ClassProperty 417 | def plugin_group(cls): # noqa: N805 # pylint: disable=no-self-argument 418 | """ 419 | :py:class:`str` -- ``group`` of :py:class:`~pluginlib.Parent` class 420 | """ 421 | 422 | return cls._group_ # pylint: disable=no-member 423 | 424 | 425 | @allow_bare_decorator 426 | class Parent: 427 | """ 428 | Args: 429 | plugin_type(str): Plugin type 430 | group(str): Group to store plugins 431 | 432 | **Class Decorator for plugin parents** 433 | 434 | ``plugin_type`` determines under what attribute child plugins will be accessed in 435 | :py:attr:`PluginLoader.plugins`. 436 | When not specified, the class name is used. 437 | 438 | ``group`` specifies the parent and all child plugins are members of 439 | the specified plugin group. A :py:attr:`PluginLoader` instance only accesses the 440 | plugins group specified when it was initialized. 441 | When not specified, the default group is used. 442 | ``group`` should be specified if plugins for different projects could be accessed 443 | in an single program, such as in libraries and frameworks. 444 | """ 445 | 446 | __slots__ = 'plugin_type', 'group' 447 | 448 | def __init__(self, plugin_type=None, group=None): 449 | self.plugin_type = plugin_type 450 | self.group = group 451 | 452 | def __call__(self, cls): 453 | 454 | # In case we're inheriting another parent, clean registry 455 | if isinstance(cls, PluginType): 456 | plugins = cls._get_plugins().get(cls.name, {}) 457 | remove = [pver for pver, pcls in plugins.items() if cls is pcls] 458 | 459 | if plugins and len(remove) == len(plugins): 460 | del cls._get_plugins()[cls.name] 461 | 462 | else: 463 | for key in remove: 464 | del plugins[key] 465 | 466 | dict_ = cls.__dict__.copy() 467 | dict_.pop('__dict__', None) 468 | dict_.pop('__weakref__', None) 469 | 470 | # Clean out slot members 471 | for member in dict_.get('__slots__', ()): 472 | dict_.pop(member, None) 473 | 474 | # Set type 475 | dict_['_type_'] = self.plugin_type or cls.__name__ 476 | dict_['_group_'] = self.group 477 | 478 | # Mark as parents 479 | for attr in ('_parent_', '_skipload_'): 480 | dict_[attr] = True 481 | 482 | # Reorder bases so any overrides are hit first in the MRO 483 | bases = [base for base in cls.__bases__ if base not in (object, Plugin)] 484 | bases.append(Plugin) 485 | bases = tuple(bases) 486 | 487 | return PluginType(cls.__name__, bases, dict_) 488 | 489 | 490 | def get_plugins(): 491 | """ 492 | Convenience method for accessing all imported plugins 493 | """ 494 | 495 | # pylint: disable=protected-access 496 | return PluginType._PluginType__plugins 497 | -------------------------------------------------------------------------------- /tests/test_loader.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2025 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Test module for pluginlib._loader** 9 | """ 10 | 11 | import importlib 12 | import os 13 | import sys 14 | import warnings 15 | from unittest import mock, TestCase 16 | 17 | import pluginlib._loader as loader 18 | from pluginlib._objects import OrderedDict 19 | from pluginlib._util import PY_LT_3_10 20 | from pluginlib import BlacklistEntry, PluginImportError, EntryPointWarning, PluginWarning 21 | 22 | from tests import OUTPUT 23 | import tests.testdata 24 | import tests.testdata.parents 25 | 26 | 27 | if PY_LT_3_10: 28 | from importlib_metadata import EntryPoint, EntryPoints # pylint: disable=import-error 29 | else: 30 | from importlib.metadata import EntryPoint, EntryPoints 31 | 32 | 33 | DATAPATH = os.path.dirname(tests.testdata.__file__) 34 | 35 | 36 | class TestPluginLoaderInit(TestCase): 37 | """Tests for initialization of PluginLoader""" 38 | 39 | def test_bad_arguments(self): 40 | """Error is raised when argument type is wrong""" 41 | 42 | # Expect iterables 43 | for arg in ('modules', 'paths', 'blacklist', 'type_filter'): 44 | 45 | with self.assertRaises(TypeError): 46 | loader.PluginLoader(**{arg: 'string'}) 47 | 48 | with self.assertRaises(TypeError): 49 | loader.PluginLoader(**{arg: 8675309}) 50 | 51 | # Expect strings 52 | for arg in ('library', 'entry_point', 'prefix_package'): 53 | 54 | with self.assertRaises(TypeError): 55 | loader.PluginLoader(**{arg: [1, 2, 3]}) 56 | 57 | with self.assertRaises(TypeError): 58 | loader.PluginLoader(**{arg: 8675309}) 59 | 60 | def test_blacklist_argument(self): 61 | """blacklist argument gets verified and processed in init""" 62 | 63 | # No blacklist 64 | ploader = loader.PluginLoader() 65 | self.assertIsNone(ploader.blacklist) 66 | 67 | # List of tuples and BlacklistEntry objects 68 | blentry = BlacklistEntry('parser', 'yaml', '2.0', '>=') 69 | blacklist = [blentry, ('parser', 'json'), ('parser', 'xml', '<', '1.0')] 70 | ploader = loader.PluginLoader(blacklist=blacklist) 71 | 72 | self.assertEqual(len(ploader.blacklist), 3) 73 | for entry in ploader.blacklist: 74 | self.assertIsInstance(entry, BlacklistEntry) 75 | 76 | self.assertIs(ploader.blacklist[0], blentry) 77 | 78 | self.assertEqual(ploader.blacklist[1].type, 'parser') 79 | self.assertEqual(ploader.blacklist[1].name, 'json') 80 | self.assertEqual(ploader.blacklist[1].version, None) 81 | self.assertEqual(ploader.blacklist[1].operator, '==') 82 | 83 | self.assertEqual(ploader.blacklist[2].type, 'parser') 84 | self.assertEqual(ploader.blacklist[2].name, 'xml') 85 | self.assertEqual(ploader.blacklist[2].version, '1.0') 86 | self.assertEqual(ploader.blacklist[2].operator, '<') 87 | 88 | # Entry isn't iterable 89 | with self.assertRaisesRegex(AttributeError, 'Invalid blacklist entry'): 90 | ploader = loader.PluginLoader(blacklist=[1, 2, 3]) 91 | 92 | # Bad arguments for BlacklistEntry 93 | with self.assertRaisesRegex(AttributeError, 'Invalid blacklist entry'): 94 | ploader = loader.PluginLoader(blacklist=[(1, 2, 3)]) 95 | 96 | def test_repr(self): 97 | """Only non-default settings show up in repr""" 98 | 99 | ploader = loader.PluginLoader(modules=['avengers.iwar', 'avengers.stark'], group='Avengers') 100 | output = "PluginLoader(group='Avengers', modules=['avengers.iwar', 'avengers.stark'])" 101 | self.assertEqual(repr(ploader), output) 102 | 103 | blacklist = [('parser', 'json'), ('parser', 'xml', '<', '1.0')] 104 | ploader = loader.PluginLoader(blacklist=blacklist) 105 | 106 | entry1 = BlacklistEntry('parser', 'json') 107 | entry2 = BlacklistEntry('parser', 'xml', '<', '1.0') 108 | 109 | self.assertEqual(repr(ploader), f'PluginLoader(blacklist=({entry1!r}, {entry2!r}))') 110 | 111 | 112 | def unload(module): 113 | """Unload a package/module and all submodules from sys.modules""" 114 | for mod in [mod for mod in sys.modules if mod.startswith(module)]: 115 | del sys.modules[mod] 116 | 117 | 118 | # pylint: disable=protected-access,too-many-public-methods 119 | class TestPluginLoader(TestCase): 120 | """Tests for PluginLoader""" 121 | 122 | def setUp(self): 123 | 124 | # Truncate log output 125 | OUTPUT.seek(0) 126 | OUTPUT.truncate(0) 127 | 128 | def tearDown(self): 129 | 130 | loader.get_plugins().clear() 131 | unload('tests.testdata.lib') 132 | unload('pluginlib.importer.') 133 | importlib.reload(tests.testdata.parents) 134 | 135 | def test_load_lib(self): 136 | """Load modules from standard library""" 137 | 138 | ploader = loader.PluginLoader(group='testdata', library='tests.testdata.lib') 139 | plugins = ploader.plugins 140 | 141 | self.assertEqual(len(plugins), 3) 142 | self.assertTrue('parser' in plugins) 143 | self.assertTrue('engine' in plugins) 144 | self.assertTrue('hook' in plugins) 145 | 146 | self.assertEqual(len(plugins.parser), 2) 147 | self.assertTrue('xml' in plugins.parser) 148 | self.assertTrue('json' in plugins.parser) 149 | self.assertEqual(plugins.parser.json.version, '2.0') 150 | 151 | self.assertEqual(len(plugins.engine), 1) 152 | self.assertTrue('steam' in plugins.engine) 153 | self.assertEqual(len(plugins.hook), 2) 154 | self.assertTrue('right' in plugins.hook) 155 | self.assertTrue('left' in plugins.hook) 156 | 157 | def test_load_entry_points_pkg(self): 158 | """Load modules from entry points""" 159 | 160 | with mock.patch( 161 | 'pluginlib._loader.entry_points', 162 | return_value=EntryPoints([ 163 | # Entry point is package 164 | EntryPoint('hooks', 'tests.testdata.lib.hooks', 'pluginlib.test.plugins'), 165 | # Entry point is module 166 | EntryPoint('parsers', 'tests.testdata.lib.parsers.xml', 'pluginlib.test.plugins'), 167 | ]), 168 | ): 169 | 170 | ploader = loader.PluginLoader(group='testdata', entry_point='pluginlib.test.plugins') 171 | plugins = ploader.plugins 172 | 173 | self.assertEqual(len(plugins), 3) 174 | self.assertTrue('parser' in plugins) 175 | self.assertTrue('engine' in plugins) 176 | self.assertTrue('hook' in plugins) 177 | 178 | self.assertEqual(len(plugins.engine), 0) 179 | 180 | self.assertEqual(len(plugins.hook), 2) 181 | self.assertTrue('right' in plugins.hook) 182 | self.assertTrue('left' in plugins.hook) 183 | 184 | self.assertEqual(len(plugins.parser), 1) 185 | self.assertTrue('xml' in plugins.parser) 186 | 187 | def test_load_entry_points_bad(self): 188 | """Raise warning and continue when entry point fails - bad package""" 189 | 190 | with mock.patch( 191 | 'pluginlib._loader.entry_points', 192 | return_value=EntryPoints([ 193 | EntryPoint('bad', 'not.a.real.module', 'pluginlib.test.plugins'), 194 | EntryPoint('parsers', 'tests.testdata.lib.parsers.xml', 'pluginlib.test.plugins'), 195 | ]), 196 | ): 197 | 198 | ploader = loader.PluginLoader(group='testdata', entry_point='pluginlib.test.plugins') 199 | 200 | with warnings.catch_warnings(record=True) as e: 201 | warnings.simplefilter("always") 202 | plugins = ploader.plugins 203 | 204 | self.assertEqual(len(e), 1) 205 | self.assertTrue(issubclass(e[-1].category, EntryPointWarning)) 206 | self.assertRegex(str(e[-1].message), 'can not be loaded for entry point bad') 207 | 208 | self.assertEqual(len(plugins.parser), 1) 209 | self.assertTrue('xml' in plugins.parser) 210 | self.assertEqual(len(plugins.engine), 0) 211 | self.assertEqual(len(plugins.hook), 0) 212 | 213 | def test_load_entry_points_bad2(self): 214 | """Raise warning and continue when entry point fails - bad module""" 215 | 216 | with mock.patch( 217 | 'pluginlib._loader.entry_points', 218 | return_value=EntryPoints([ 219 | EntryPoint('bad', 'tests.testdata.lib.parsers.bad', 'pluginlib.test.plugins'), 220 | EntryPoint('parsers', 'tests.testdata.lib.parsers.xml', 'pluginlib.test.plugins'), 221 | ]), 222 | ): 223 | 224 | ploader = loader.PluginLoader(group='testdata', entry_point='pluginlib.test.plugins') 225 | 226 | with warnings.catch_warnings(record=True) as e: 227 | warnings.simplefilter("always") 228 | plugins = ploader.plugins 229 | 230 | self.assertEqual(len(e), 1) 231 | self.assertTrue(issubclass(e[-1].category, EntryPointWarning)) 232 | self.assertRegex(str(e[-1].message), 'can not be loaded for entry point bad') 233 | 234 | self.assertEqual(len(plugins.parser), 1) 235 | self.assertTrue('xml' in plugins.parser) 236 | self.assertEqual(len(plugins.engine), 0) 237 | self.assertEqual(len(plugins.hook), 0) 238 | 239 | def test_load_entry_points_not_mod(self): 240 | """Raise warning and continue when entry point fails""" 241 | 242 | with mock.patch( 243 | 'pluginlib._loader.entry_points', 244 | return_value=EntryPoints([ 245 | EntryPoint( 246 | 'parsers', 'tests.testdata.lib.parsers.xml:XML', 'pluginlib.test.plugins' 247 | ), 248 | ]), 249 | ): 250 | 251 | ploader = loader.PluginLoader(group='testdata', entry_point='pluginlib.test.plugins') 252 | 253 | with warnings.catch_warnings(record=True) as e: 254 | warnings.simplefilter("always") 255 | plugins = ploader.plugins 256 | 257 | self.assertEqual(len(e), 1) 258 | self.assertTrue(issubclass(e[-1].category, EntryPointWarning)) 259 | self.assertRegex( 260 | str(e[-1].message), "Entry point 'parsers' is not a module or package" 261 | ) 262 | 263 | self.assertEqual(len(plugins.parser), 1) 264 | self.assertTrue('xml' in plugins.parser) 265 | self.assertEqual(len(plugins.engine), 0) 266 | self.assertEqual(len(plugins.hook), 0) 267 | 268 | def test_load_modules(self): 269 | """Load modules from modules""" 270 | ploader = loader.PluginLoader(group='testdata', modules=['tests.testdata.lib.parsers']) 271 | plugins = ploader.plugins 272 | 273 | self.assertEqual(len(plugins), 3) 274 | self.assertTrue('parser' in plugins) 275 | self.assertTrue('engine' in plugins) 276 | self.assertTrue('hook' in plugins) 277 | 278 | self.assertEqual(len(plugins.engine), 0) 279 | self.assertEqual(len(plugins.hook), 0) 280 | 281 | self.assertEqual(len(plugins.parser), 2) 282 | self.assertTrue('xml' in plugins.parser) 283 | self.assertTrue('json' in plugins.parser) 284 | self.assertEqual(plugins.parser.json.version, '2.0') 285 | 286 | def test_load_modules_namespace(self): 287 | """Load modules from namespace style package""" 288 | 289 | ploader = loader.PluginLoader(group='testdata', modules=['tests.testdata.bare.hooks']) 290 | plugins = ploader.plugins 291 | 292 | self.assertEqual(len(plugins.hook), 2) 293 | self.assertTrue('fish' in plugins.hook) 294 | self.assertEqual(plugins.hook.fish.version, '1.2.3') 295 | self.assertEqual(plugins.hook.fish.__module__, 296 | 'tests.testdata.bare.hooks.fish') 297 | self.assertTrue('grappling' in plugins.hook) 298 | 299 | def test_load_paths(self): 300 | """Load modules from paths""" 301 | 302 | path = os.path.join(DATAPATH, 'lib', 'engines') 303 | ploader = loader.PluginLoader(group='testdata', paths=[path]) 304 | plugins = ploader.plugins 305 | 306 | self.assertEqual(len(plugins), 3) 307 | self.assertTrue('parser' in plugins) 308 | self.assertTrue('engine' in plugins) 309 | self.assertTrue('hook' in plugins) 310 | 311 | self.assertEqual(len(plugins.parser), 0) 312 | self.assertEqual(len(plugins.hook), 0) 313 | 314 | self.assertEqual(len(plugins.engine), 1) 315 | self.assertTrue('steam' in plugins.engine) 316 | 317 | self.assertEqual(plugins.engine.steam.__module__, 'pluginlib.importer.engines.steam') 318 | 319 | def test_load_paths_bare(self): 320 | """Load modules from paths without init file""" 321 | 322 | path = os.path.join(DATAPATH, 'bare') 323 | ploader = loader.PluginLoader(group='testdata', paths=[path]) 324 | plugins = ploader.plugins 325 | 326 | self.assertEqual(len(plugins), 3) 327 | self.assertTrue('parser' in plugins) 328 | self.assertTrue('engine' in plugins) 329 | self.assertTrue('hook' in plugins) 330 | 331 | self.assertEqual(len(plugins.parser), 1) 332 | self.assertTrue('sillywalk' in plugins.parser) 333 | 334 | self.assertEqual(len(plugins.hook), 2) 335 | self.assertTrue('fish' in plugins.hook) 336 | self.assertEqual(plugins.hook.fish.version, '1.2.3') 337 | self.assertTrue('grappling' in plugins.hook) 338 | 339 | self.assertEqual(len(plugins.engine), 1) 340 | self.assertTrue('electric' in plugins.engine) 341 | 342 | self.assertEqual(plugins.engine.electric.__module__, 343 | 'pluginlib.importer.bare.engines.electric') 344 | 345 | def test_load_paths_prefix_pkg(self): 346 | """Load modules from paths with alternate prefix package""" 347 | 348 | path = os.path.join(DATAPATH, 'lib', 'engines') 349 | ploader = loader.PluginLoader(group='testdata', paths=[path], 350 | prefix_package='tests.testdata.importer') 351 | plugins = ploader.plugins 352 | 353 | self.assertEqual(len(plugins), 3) 354 | self.assertTrue('parser' in plugins) 355 | self.assertTrue('engine' in plugins) 356 | self.assertTrue('hook' in plugins) 357 | 358 | self.assertEqual(len(plugins.parser), 0) 359 | self.assertEqual(len(plugins.hook), 0) 360 | 361 | self.assertEqual(len(plugins.engine), 1) 362 | self.assertTrue('steam' in plugins.engine) 363 | 364 | self.assertEqual(plugins.engine.steam.__module__, 'tests.testdata.importer.engines.steam') 365 | 366 | def test_type_filter(self): 367 | """Filter plugin types""" 368 | 369 | ploader = loader.PluginLoader(group='testdata', library='tests.testdata.lib', 370 | type_filter=('engine', 'parser')) 371 | plugins = ploader.plugins 372 | 373 | self.assertTrue('parser' in plugins) 374 | self.assertTrue('engine' in plugins) 375 | self.assertFalse('hook' in plugins) 376 | 377 | def test_load_paths_missing(self): 378 | """Log on invalid path""" 379 | 380 | path1 = os.path.join(DATAPATH, 'lib', 'engines') 381 | path2 = os.path.join(DATAPATH, 'NotARealPath') 382 | ploader = loader.PluginLoader(group='testdata', paths=[path1, path2]) 383 | plugins = ploader.plugins 384 | 385 | self.assertEqual(len(plugins.engine), 1) 386 | self.assertTrue('steam' in plugins.engine) 387 | 388 | self.assertRegex(OUTPUT.getvalue().splitlines()[-1], 'not a valid directory') 389 | 390 | def test_load_paths_duplicate(self): 391 | """Ignore duplicate paths""" 392 | 393 | path = os.path.join(DATAPATH, 'lib', 'engines') 394 | 395 | with warnings.catch_warnings(record=True) as e: 396 | 397 | warnings.simplefilter("always") 398 | ploader = loader.PluginLoader(group='testdata', paths=[path, path]) 399 | plugins = ploader.plugins 400 | 401 | self.assertEqual(len(e), 1) 402 | self.assertTrue(issubclass(e[-1].category, PluginWarning)) 403 | self.assertRegex(str(e[-1].message), 'Duplicate plugins found') 404 | 405 | self.assertEqual(len(plugins.engine), 1) 406 | self.assertTrue('steam' in plugins.engine) 407 | 408 | def test_bad_import(self): 409 | """Syntax error in imported module""" 410 | 411 | ploader = loader.PluginLoader(group='testdata', modules=['tests.testdata.bad.syntax']) 412 | error = 'Error while importing candidate plugin module tests.testdata.bad.syntax' 413 | with self.assertRaisesRegex(PluginImportError, error) as e: 414 | ploader.plugins # pylint: disable=pointless-statement 415 | 416 | self.assertRegex(e.exception.friendly, "SyntaxError: (?:invalid syntax|expected ':')") 417 | self.assertRegex(e.exception.friendly, 'tests.testdata.bad.syntax') 418 | self.assertRegex(e.exception.friendly, 'line 12') 419 | 420 | def test_bad_import2(self): 421 | """Exception raised by imported module""" 422 | 423 | ploader = loader.PluginLoader(group='testdata', modules=['tests.testdata.bad2']) 424 | error = 'Error while importing candidate plugin module tests.testdata.bad2' 425 | with self.assertRaisesRegex(PluginImportError, error) as e: 426 | ploader.plugins # pylint: disable=pointless-statement 427 | 428 | self.assertRegex(e.exception.friendly, 'RuntimeError: This parrot is no more') 429 | self.assertRegex(e.exception.friendly, 'tests.testdata.bad2') 430 | self.assertRegex(e.exception.friendly, 'line 24') 431 | 432 | def test_bad_import_path(self): 433 | """Syntax error in imported module loaded by path""" 434 | 435 | path = os.path.join(DATAPATH, 'bad') 436 | ploader = loader.PluginLoader(group='testdata', paths=[path]) 437 | error = 'Error while importing candidate plugin module pluginlib.importer.bad.syntax' 438 | with self.assertRaisesRegex(PluginImportError, error) as e: 439 | ploader.plugins # pylint: disable=pointless-statement 440 | 441 | self.assertRegex(e.exception.friendly, "SyntaxError: (?:invalid syntax|expected ':')") 442 | self.assertRegex(e.exception.friendly, 'testdata/bad/syntax.py') 443 | self.assertRegex(e.exception.friendly, 'line 12') 444 | 445 | def test_plugins(self): 446 | """plugins only loads modules on the first call""" 447 | 448 | ploader = loader.PluginLoader(group='testdata', library='tests.testdata.lib') 449 | with mock.patch.object(ploader, 'load_modules', 450 | wraps=ploader.load_modules) as mock_load_modules: 451 | plugins1 = ploader.plugins 452 | self.assertEqual(mock_load_modules.call_count, 1) 453 | 454 | self.assertEqual(len(plugins1.parser), 2) 455 | self.assertTrue('xml' in plugins1.parser) 456 | self.assertTrue('json' in plugins1.parser) 457 | self.assertEqual(plugins1.parser.json.version, '2.0') 458 | 459 | plugins2 = ploader.plugins 460 | self.assertEqual(mock_load_modules.call_count, 1) 461 | 462 | self.assertEqual(plugins1, plugins2) 463 | 464 | def test_plugins_all(self): 465 | """plugins only loads modules on the first call""" 466 | 467 | ploader = loader.PluginLoader(group='testdata', library='tests.testdata.lib') 468 | with mock.patch.object(ploader, 'load_modules', 469 | wraps=ploader.load_modules) as mock_load_modules: 470 | plugins1 = ploader.plugins_all 471 | self.assertEqual(mock_load_modules.call_count, 1) 472 | 473 | self.assertEqual(len(plugins1.parser), 2) 474 | self.assertTrue('xml' in plugins1.parser) 475 | self.assertIsInstance(plugins1.parser.xml, OrderedDict) 476 | self.assertTrue('json' in plugins1.parser) 477 | self.assertIsInstance(plugins1.parser.json, OrderedDict) 478 | self.assertEqual(tuple(plugins1.parser.json.keys()), ('1.0', '2.0')) 479 | 480 | plugins2 = ploader.plugins_all 481 | self.assertEqual(mock_load_modules.call_count, 1) 482 | 483 | self.assertEqual(plugins1, plugins2) 484 | 485 | # Test for example in docs 486 | self.assertIs(tuple(plugins1.parser.json.values())[-1], plugins1.parser.json['2.0']) 487 | 488 | def test_blacklist(self): 489 | """Blacklist prevents listing plugin""" 490 | 491 | ploader = loader.PluginLoader(group='testdata', library='tests.testdata.lib', 492 | blacklist=[('parser', 'json', '2.0'), ('engine',)]) 493 | plugins = ploader.plugins 494 | 495 | self.assertEqual(len(plugins.engine), 0) 496 | self.assertEqual(plugins.parser.json.version, '1.0') 497 | 498 | def test_get_plugin(self): 499 | """Retrieve specific plugin""" 500 | 501 | ploader = loader.PluginLoader(group='testdata', library='tests.testdata.lib') 502 | with mock.patch.object(ploader, 'load_modules', 503 | wraps=ploader.load_modules) as mock_load_modules: 504 | 505 | jsonplugin = ploader.get_plugin('parser', 'json') 506 | self.assertEqual(jsonplugin.name, 'json') 507 | self.assertEqual(jsonplugin.version, '2.0') 508 | self.assertEqual(mock_load_modules.call_count, 1) 509 | 510 | jsonplugin = ploader.get_plugin('parser', 'json', '1.0') 511 | self.assertEqual(jsonplugin.name, 'json') 512 | self.assertEqual(jsonplugin.version, '1.0') 513 | self.assertEqual(mock_load_modules.call_count, 1) 514 | 515 | def test_get_plugin_missing(self): 516 | """Attempt to retrieve non-existent plugin, return None""" 517 | 518 | ploader = loader.PluginLoader(group='testdata', library='tests.testdata.lib') 519 | self.assertIsNone(ploader.get_plugin('penguin', 'json')) 520 | self.assertIsNone(ploader.get_plugin('parser', 'penguin')) 521 | 522 | ploader = loader.PluginLoader(group='testdata', library='tests.testdata.lib') 523 | self.assertIsNone(ploader.get_plugin('parser', 'json', '300.1.1')) 524 | 525 | def test_get_plugin_filtered(self): 526 | """Retrieve specific plugin if not filtered""" 527 | 528 | ploader = loader.PluginLoader(group='testdata', library='tests.testdata.lib', 529 | type_filter=('engine', 'hook')) 530 | 531 | self.assertIsNone(ploader.get_plugin('parser', 'json')) 532 | 533 | steamplugin = ploader.get_plugin('engine', 'steam') 534 | self.assertEqual(steamplugin.name, 'steam') 535 | 536 | def test_get_plugin_blacklist(self): 537 | """Retrieve specific plugin if not blacklisted""" 538 | 539 | blacklist = [('parser', 'json', '2.0'), ('parser', 'xml'), ('engine',)] 540 | 541 | ploader = loader.PluginLoader(group='testdata', library='tests.testdata.lib', 542 | blacklist=blacklist) 543 | 544 | self.assertIsNone(ploader.get_plugin('parser', 'json', '2.0')) 545 | self.assertIsNone(ploader.get_plugin('parser', 'xml')) 546 | self.assertIsNone(ploader.get_plugin('engine', 'steam')) 547 | 548 | jsonplugin = ploader.get_plugin('parser', 'json') 549 | self.assertEqual(jsonplugin.name, 'json') 550 | self.assertEqual(jsonplugin.version, '1.0') 551 | -------------------------------------------------------------------------------- /tests/test_parent.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 - 2025 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | **Test module for pluginlib._parent** 9 | """ 10 | 11 | import sys 12 | import textwrap 13 | import warnings 14 | from unittest import TestCase 15 | 16 | from pluginlib import abstractmethod, abstractattribute 17 | import pluginlib._parent as parent 18 | 19 | from tests import OUTPUT 20 | 21 | 22 | # pylint: disable=protected-access, no-member 23 | 24 | 25 | # This should be imported from types, but it's broken in pypy 26 | # https://bitbucket.org/pypy/pypy/issues/2865 27 | class Placeholder: 28 | """Placeholder to get type""" 29 | __slots__ = ('member',) 30 | 31 | 32 | MemberDescriptorType = type(Placeholder.__dict__['member']) # pylint: disable=invalid-name 33 | 34 | 35 | class TestParent(TestCase): 36 | """Test Parent decorator""" 37 | 38 | def tearDown(self): 39 | 40 | # Clear plugins from module 41 | parent.get_plugins().clear() 42 | 43 | def test_parent_basic(self): 44 | """Basic use of Parent decorator""" 45 | 46 | @parent.Parent('basic') 47 | class Basic: 48 | """Basic document string""" 49 | 50 | self.assertTrue(issubclass(Basic, parent.Plugin)) 51 | self.assertTrue(isinstance(Basic, parent.PluginType)) 52 | self.assertTrue(Basic._parent_) 53 | self.assertTrue(Basic._skipload_) 54 | self.assertEqual(Basic.__doc__, 'Basic document string') 55 | self.assertEqual(Basic._type_, 'basic') 56 | self.assertTrue('basic' in parent.get_plugins()['_default']) 57 | 58 | def test_parent_no_type(self): 59 | """Parent type defaults to parent class name""" 60 | 61 | @parent.Parent() 62 | class Basic: 63 | """Basic document string""" 64 | 65 | self.assertEqual(Basic._type_, 'Basic') 66 | self.assertTrue('Basic' in parent.get_plugins()['_default']) 67 | 68 | def test_parent_bare(self): 69 | """Parent type defaults to parent class name""" 70 | 71 | @parent.Parent 72 | class Basic: 73 | """Basic document string""" 74 | 75 | self.assertEqual(Basic._type_, 'Basic') 76 | self.assertTrue('Basic' in parent.get_plugins()['_default']) 77 | 78 | def test_multiple_inheritance(self): 79 | """Base class is inherited from another class""" 80 | 81 | class Sample: 82 | """Sample Class""" 83 | 84 | @parent.Parent('multiple_inheritence') 85 | class MultiInheritance(Sample): 86 | """TestMultiInheritance""" 87 | 88 | self.assertTrue(issubclass(MultiInheritance, parent.Plugin)) 89 | self.assertTrue(isinstance(MultiInheritance, parent.PluginType)) 90 | self.assertTrue(issubclass(MultiInheritance, Sample)) 91 | self.assertTrue(MultiInheritance._parent_) 92 | self.assertTrue(MultiInheritance._skipload_) 93 | 94 | def child_is_parent(self, peers=False): 95 | """ 96 | A parent class is used as base class for new parent 97 | If peers is True, another class is created with the same alias 98 | """ 99 | 100 | @parent.Parent('test_parent') 101 | class Parent: 102 | """Parent""" 103 | 104 | def hello(self): 105 | """Hello, world!""" 106 | return 'world' 107 | 108 | if peers: 109 | 110 | class Child(Parent): 111 | """Child""" 112 | 113 | _alias_ = 'foo' 114 | _version_ = '1.0' 115 | 116 | @parent.Parent('child_is_parent') 117 | class ParentChild(Parent): 118 | """TestParentChild""" 119 | 120 | _alias_ = 'foo' 121 | 122 | self.assertTrue(issubclass(ParentChild, parent.Plugin)) 123 | self.assertTrue(isinstance(ParentChild, parent.PluginType)) 124 | self.assertTrue(issubclass(ParentChild, Parent)) 125 | self.assertTrue(ParentChild._parent_) 126 | self.assertTrue(ParentChild._skipload_) 127 | self.assertTrue(hasattr(ParentChild, 'hello')) 128 | 129 | if peers: 130 | self.assertTrue(len(Parent._get_plugins()['foo']) == 1) 131 | self.assertTrue(Parent._get_plugins()['foo']['1.0'] is Child) 132 | 133 | else: 134 | self.assertFalse('foo' in Parent._get_plugins()) 135 | 136 | class GrandChild(ParentChild): 137 | """GrandChild""" 138 | 139 | self.assertTrue(issubclass(GrandChild, Parent)) 140 | self.assertTrue(issubclass(GrandChild, ParentChild)) 141 | self.assertTrue(hasattr(GrandChild, 'hello')) 142 | 143 | def test_child_is_parent(self): 144 | """A parent class is used as base class for new parent""" 145 | self.child_is_parent(False) 146 | 147 | def test_child_is_parent_peers(self): 148 | """A parent class is used as base class for new parent, has peers""" 149 | self.child_is_parent(True) 150 | 151 | def test_slots(self): 152 | """slots in a parent work as expected""" 153 | 154 | @parent.Parent 155 | class WithSlots: 156 | """This class has slots""" 157 | __slots__ = ('ivar',) 158 | 159 | def __init__(self, ivar): 160 | self.ivar = ivar 161 | 162 | inst = WithSlots(1) 163 | 164 | self.assertTrue('ivar' in WithSlots.__dict__) 165 | # Usually slots are member descriptors, but in pypy they are getset descriptors 166 | self.assertIsInstance(WithSlots.__dict__['ivar'], MemberDescriptorType,) 167 | self.assertTrue(hasattr(inst, '__slots__')) 168 | self.assertFalse(hasattr(inst, '__dict__')) 169 | self.assertEqual(inst.ivar, 1) 170 | 171 | 172 | class TestPlugin(TestCase): 173 | """Test Plugin mixin""" 174 | 175 | def setUp(self): 176 | 177 | @parent.Parent('test_parent') 178 | class Parent: 179 | """Parent""" 180 | 181 | def hello(self): 182 | """Hello, world!""" 183 | return 'world' 184 | 185 | self.parent = Parent 186 | self.plugins = self.parent._get_plugins() 187 | 188 | def tearDown(self): 189 | 190 | # Clear plugins from module 191 | parent.get_plugins().clear() 192 | 193 | def test_plugin_type(self): 194 | """plugin_type property is passed from parent""" 195 | self.assertEqual(self.parent.plugin_type, 'test_parent') 196 | 197 | class Child(self.parent): 198 | """Child has same plugin_type""" 199 | 200 | self.assertEqual(Child.plugin_type, 'test_parent') 201 | 202 | def test_plugin_group_none(self): 203 | """plugin_group property is passed from parent""" 204 | self.assertEqual(self.parent.plugin_group, None) 205 | 206 | class Child(self.parent): 207 | """Child has same plugin_group""" 208 | 209 | self.assertEqual(Child.plugin_group, None) 210 | 211 | def test_plugin_group(self): 212 | """plugin_group property is passed from parent""" 213 | 214 | @parent.Parent('test_parent', group='A-Team') 215 | class Parent: 216 | """Parent""" 217 | 218 | self.assertEqual(Parent.plugin_group, 'A-Team') 219 | 220 | class Child(Parent): 221 | """Child has same plugin_group""" 222 | 223 | self.assertEqual(Child.plugin_group, 'A-Team') 224 | 225 | def test_alias_none(self): 226 | """name attribute is class name""" 227 | 228 | class Child(self.parent): 229 | """No alias""" 230 | 231 | self.assertTrue(issubclass(Child, self.parent)) 232 | self.assertEqual(Child.name, 'Child') 233 | self.assertIs(self.plugins['Child']['0'], Child) 234 | 235 | def test_alias(self): 236 | """name attribute is _alias_""" 237 | 238 | class Child(self.parent): 239 | """With alias""" 240 | _alias_ = 'voldemort' 241 | 242 | self.assertTrue(issubclass(Child, self.parent)) 243 | self.assertEqual(Child.name, 'voldemort') 244 | self.assertIs(self.plugins['voldemort']['0'], Child) 245 | self.assertFalse('Child' in self.plugins) 246 | 247 | def test_version_none(self): 248 | """version attribute is _version_, module __version__ or None""" 249 | 250 | class Child(self.parent): 251 | """No version""" 252 | _alias_ = 'the_boy_who_lived' 253 | 254 | self.assertEqual(Child.version, None) 255 | self.assertIs(self.plugins['the_boy_who_lived']['0'], Child) 256 | 257 | def test_version_class(self): 258 | """version attribute is _version_""" 259 | 260 | class Child(self.parent): 261 | """With version""" 262 | _version_ = '1.0.1' 263 | _alias_ = 'the_boy_who_lived' 264 | 265 | self.assertEqual(Child.version, '1.0.1') 266 | self.assertIs(self.plugins['the_boy_who_lived']['1.0.1'], Child) 267 | 268 | def test_version_module(self): 269 | """version attribute is _version_, module __version__ or None""" 270 | 271 | sys.modules[__name__].__version__ = '2.0.0' 272 | 273 | class Child(self.parent): 274 | """Module version""" 275 | _alias_ = 'the_boy_who_lived' 276 | 277 | self.assertEqual(Child.version, '2.0.0') 278 | self.assertIs(self.plugins['the_boy_who_lived']['2.0.0'], Child) 279 | 280 | delattr(sys.modules[__name__], '__version__') 281 | 282 | def test_skipload(self): 283 | """Child not loaded when _skipload_ is True""" 284 | 285 | class Child(self.parent): # pylint: disable=unused-variable 286 | """_skipload_ is True""" 287 | _skipload_ = True 288 | 289 | self.assertFalse('Child' in self.plugins) 290 | 291 | 292 | # pylint: disable=too-many-public-methods 293 | class TestPluginType(TestCase): 294 | """Test PluginType metaclass""" 295 | 296 | def setUp(self): 297 | 298 | # Truncate log output 299 | OUTPUT.seek(0) 300 | OUTPUT.truncate(0) 301 | 302 | def tearDown(self): 303 | 304 | # Clear plugins from module 305 | parent.get_plugins().clear() 306 | 307 | def test_skipload_static(self): 308 | """Use _skipload_ static method to determine if plugin should be used""" 309 | 310 | skip = False 311 | 312 | @parent.Parent('test_parent') 313 | class Parent: 314 | """Parent with callable _skipload_""" 315 | 316 | plugins = Parent._get_plugins() 317 | 318 | class Child1(Parent): 319 | """Child succeeds""" 320 | 321 | @staticmethod 322 | def _skipload_(): 323 | """Don't skip""" 324 | return skip 325 | 326 | self.assertIs(plugins['Child1']['0'], Child1) 327 | self.assertEqual(OUTPUT.getvalue(), '') 328 | 329 | skip = True, 'This parrot is no more!' 330 | 331 | class Child2(Parent): # pylint: disable=unused-variable 332 | """Child fails""" 333 | 334 | @staticmethod 335 | def _skipload_(): 336 | """Skip""" 337 | return skip 338 | 339 | self.assertFalse('Child2' in plugins) 340 | self.assertRegex(OUTPUT.getvalue(), 'This parrot is no more!') 341 | 342 | def test_skipload_class(self): 343 | """Use _skipload_ class method to determine if plugin should be used""" 344 | 345 | @parent.Parent('test_parent') 346 | class Parent: 347 | """Parent with callable _skipload_""" 348 | 349 | plugins = Parent._get_plugins() 350 | 351 | class Child1(Parent): 352 | """Child succeeds""" 353 | 354 | skip = False 355 | 356 | @classmethod 357 | def _skipload_(cls): 358 | """Don't skip""" 359 | return cls.skip 360 | 361 | self.assertIs(plugins['Child1']['0'], Child1) 362 | self.assertEqual(OUTPUT.getvalue(), '') 363 | 364 | class Child2(Parent): # pylint: disable=unused-variable 365 | """Child fails""" 366 | 367 | skip = True, 'He has ceased to be!' 368 | 369 | @classmethod 370 | def _skipload_(cls): 371 | """Don't skip""" 372 | return cls.skip 373 | 374 | self.assertFalse('Child2' in plugins) 375 | self.assertRegex(OUTPUT.getvalue(), 'He has ceased to be!') 376 | 377 | def test_abstract_method(self): 378 | """Method required in subclass""" 379 | 380 | @parent.Parent('test_parent') 381 | class Parent: 382 | """Parent with abstract class method""" 383 | 384 | @abstractmethod 385 | def abstract(self): 386 | """Abstract method""" 387 | 388 | self.missing(Parent, 'Does not contain required method') 389 | self.meth(Parent) 390 | self.static(Parent, 'Does not contain required method') 391 | self.klass(Parent, 'Does not contain required method') 392 | self.prop(Parent, 'Does not contain required method') 393 | self.attr(Parent, 'Does not contain required method') 394 | self.coroutine(Parent) 395 | self.type_hints_1(Parent) 396 | self.type_hints_2(Parent) 397 | 398 | def test_multiple_methods(self): 399 | """Method required in subclass""" 400 | 401 | @parent.Parent('test_parent') 402 | class Parent: 403 | """Parent with abstract class method""" 404 | 405 | @abstractmethod 406 | def abstract2(self): 407 | """Abstract method""" 408 | 409 | @abstractmethod 410 | def abstract(self): 411 | """Abstract method""" 412 | 413 | self.meth(Parent, 'Does not contain required method') 414 | self.multiple(Parent) 415 | 416 | def test_abs_method_argspec(self): 417 | """Method argument spec must match""" 418 | 419 | @parent.Parent('test_parent') 420 | class Parent: 421 | """Parent with abstract class method""" 422 | 423 | @abstractmethod 424 | def abstract(self, some_arg): 425 | """Abstract method with another argument""" 426 | 427 | self.meth(Parent, 'Argument spec does not match parent for method') 428 | 429 | def test_abstract_staticmethod(self): 430 | """Static method required in subclass""" 431 | 432 | @parent.Parent('test_parent') 433 | class Parent: 434 | """Parent with abstract static method""" 435 | 436 | @staticmethod 437 | @abstractmethod 438 | def abstract(): 439 | """Abstract static method""" 440 | 441 | self.missing(Parent, 'Does not contain required static method') 442 | self.meth(Parent, 'Does not contain required static method') 443 | self.static(Parent) 444 | self.klass(Parent, 'Does not contain required static method') 445 | self.prop(Parent, 'Does not contain required static method') 446 | self.attr(Parent, 'Does not contain required static method') 447 | 448 | def test_abs_static_meth_argspec(self): 449 | """Static method argument spec must match""" 450 | 451 | @parent.Parent('test_parent') 452 | class Parent: 453 | """Parent with abstract static method""" 454 | 455 | @staticmethod 456 | @abstractmethod 457 | def abstract(some_arg): 458 | """Abstract static method with another argument""" 459 | 460 | self.static(Parent, 'Argument spec does not match parent for method') 461 | 462 | def test_abstract_classmethod(self): 463 | """Class method required in subclass""" 464 | 465 | @parent.Parent('test_parent') 466 | class Parent: 467 | """Parent with abstract class method""" 468 | 469 | @classmethod 470 | @abstractmethod 471 | def abstract(cls): # noqa: N805 472 | """Abstract class method""" 473 | 474 | self.missing(Parent, 'Does not contain required class method') 475 | self.meth(Parent, 'Does not contain required class method') 476 | self.static(Parent, 'Does not contain required class method') 477 | self.klass(Parent) 478 | self.prop(Parent, 'Does not contain required class method') 479 | self.attr(Parent, 'Does not contain required class method') 480 | 481 | def test_abs_class_meth_argspec(self): 482 | """Class method argument spec must match""" 483 | 484 | @parent.Parent('test_parent') 485 | class Parent: 486 | """Parent with abstract class method""" 487 | 488 | @classmethod 489 | @abstractmethod 490 | def abstract(cls, some_arg): # noqa: N805 491 | """Abstract class method with another argument""" 492 | 493 | self.klass(Parent, 'Argument spec does not match parent for method') 494 | 495 | def test_abstract_property(self): 496 | """Property required in subclass""" 497 | 498 | @parent.Parent('test_parent') 499 | class Parent: 500 | """Parent with abstract property""" 501 | 502 | @property 503 | @abstractmethod 504 | def abstract(self): 505 | """Abstract property""" 506 | 507 | self.missing(Parent, 'Does not contain required property') 508 | self.meth(Parent, 'Does not contain required property') 509 | self.static(Parent, 'Does not contain required property') 510 | self.klass(Parent, 'Does not contain required property') 511 | self.prop(Parent) 512 | self.attr(Parent, 'Does not contain required property') 513 | 514 | def test_abstract_attribute(self): 515 | """Attribute required in subclass""" 516 | 517 | @parent.Parent('test_parent') 518 | class Parent: 519 | """Parent with abstract attribute""" 520 | 521 | abstract = abstractattribute 522 | 523 | self.missing(Parent, 'Does not contain required attribute') 524 | self.meth(Parent) 525 | self.static(Parent) 526 | self.klass(Parent) 527 | self.prop(Parent) 528 | self.attr(Parent) 529 | 530 | def test_abstract_coroutine(self): 531 | """Attribute required in subclass""" 532 | 533 | @parent.Parent('test_parent') 534 | class Parent: 535 | """Parent with abstract coroutine""" 536 | 537 | class_definition = textwrap.dedent('''\ 538 | @abstractmethod 539 | async def abstract(self): 540 | """Abstract coroutine method""" 541 | ''') 542 | 543 | local_rtn = {} 544 | exec(class_definition, globals(), local_rtn) # pylint: disable=exec-used 545 | abstract = local_rtn['abstract'] 546 | 547 | self.missing(Parent, 'Does not contain required method') 548 | self.meth(Parent, 'Does not contain required coroutine method') 549 | self.static(Parent, 'Does not contain required method') 550 | self.klass(Parent, 'Does not contain required method') 551 | self.prop(Parent, 'Does not contain required method') 552 | self.attr(Parent, 'Does not contain required method') 553 | self.coroutine(Parent) 554 | 555 | def test_type_annotations(self): 556 | """If parent and child have annotations they must match""" 557 | 558 | @parent.Parent('test_parent') 559 | class Parent: 560 | """Parent with typed method""" 561 | 562 | class_definition = textwrap.dedent('''\ 563 | @abstractmethod 564 | def abstract(self) -> str: 565 | """Method with type annotations""" 566 | ''') 567 | 568 | local_rtn = {} 569 | exec(class_definition, globals(), local_rtn) # pylint: disable=exec-used 570 | abstract = local_rtn['abstract'] 571 | 572 | self.meth(Parent) 573 | self.type_hints_1(Parent) 574 | self.type_hints_2(Parent, 'Type annotations differ') 575 | 576 | def check_method(self, parent_class, error, child, e): 577 | """Check child methods""" 578 | 579 | self.assertEqual(OUTPUT.getvalue(), '') 580 | 581 | plugins = parent_class._get_plugins() 582 | 583 | if error: 584 | self.assertFalse(child.name in plugins) 585 | self.assertEqual(len(e), 1) 586 | self.assertRegex(str(e[-1].message), error) 587 | else: 588 | self.assertIs(plugins[child.name]['0'], child) 589 | self.assertEqual(len(e), 0) 590 | 591 | def missing(self, parent_class, error=None): 592 | """Test abstract method is missing""" 593 | 594 | with warnings.catch_warnings(record=True) as e: 595 | 596 | class Missing(parent_class): 597 | """Does not have abstract method""" 598 | 599 | self.check_method(parent_class, error, Missing, e) 600 | 601 | def meth(self, parent_class, error=None): 602 | """Test abstract method is a regular method""" 603 | 604 | with warnings.catch_warnings(record=True) as e: 605 | 606 | class Meth(parent_class): 607 | """Only has regular method""" 608 | 609 | def abstract(self): 610 | """Regular method""" 611 | 612 | self.check_method(parent_class, error, Meth, e) 613 | 614 | def multiple(self, parent_class, error=None): 615 | """Test abstract method is a regular method""" 616 | 617 | with warnings.catch_warnings(record=True) as e: 618 | 619 | class Meth(parent_class): 620 | """Only has regular method""" 621 | 622 | def abstract(self): 623 | """Regular method""" 624 | 625 | def abstract2(self): 626 | """Regular method""" 627 | 628 | self.check_method(parent_class, error, Meth, e) 629 | 630 | def static(self, parent_class, error=None): 631 | """Test abstract method is a static method""" 632 | 633 | with warnings.catch_warnings(record=True) as e: 634 | 635 | class Static(parent_class): 636 | """Only has static method""" 637 | 638 | @staticmethod 639 | def abstract(): 640 | """Static method""" 641 | 642 | self.check_method(parent_class, error, Static, e) 643 | 644 | def klass(self, parent_class, error=None): 645 | """Test abstract method is a class method""" 646 | 647 | with warnings.catch_warnings(record=True) as e: 648 | 649 | class Klass(parent_class): 650 | """Only has class method""" 651 | 652 | @classmethod 653 | def abstract(cls): 654 | """Class method""" 655 | 656 | self.check_method(parent_class, error, Klass, e) 657 | 658 | def prop(self, parent_class, error=None): 659 | """Test abstract method is a property""" 660 | 661 | with warnings.catch_warnings(record=True) as e: 662 | 663 | class Prop(parent_class): 664 | """Only has property""" 665 | 666 | @property 667 | def abstract(self): 668 | """Property""" 669 | 670 | @abstract.setter 671 | def abstract(self, value): 672 | pass 673 | 674 | @abstract.deleter 675 | def abstract(self): 676 | pass 677 | 678 | self.check_method(parent_class, error, Prop, e) 679 | 680 | def attr(self, parent_class, error=None): 681 | """Test abstract attribute""" 682 | 683 | with warnings.catch_warnings(record=True) as e: 684 | 685 | class Attr(parent_class): 686 | """Only has property""" 687 | abstract = 'No one expects the abstract attribute' 688 | 689 | self.check_method(parent_class, error, Attr, e) 690 | 691 | def coroutine(self, parent_class, error=None): 692 | """Test abstract coroutine method""" 693 | 694 | with warnings.catch_warnings(record=True) as e: 695 | 696 | class_definition = textwrap.dedent('''\ 697 | class Coroutine(parent_class): 698 | """Only has coroutine method""" 699 | async def abstract(self): 700 | """Coroutine method""" 701 | ''') 702 | 703 | local_rtn = {'parent_class': parent_class} 704 | exec(class_definition, globals(), local_rtn) # pylint: disable=exec-used 705 | Coroutine = local_rtn['Coroutine'] # pylint: disable=invalid-name 706 | 707 | self.check_method(parent_class, error, Coroutine, e) 708 | 709 | def type_hints_1(self, parent_class, error=None): 710 | """Test abstract method has type annotations""" 711 | 712 | with warnings.catch_warnings(record=True) as e: 713 | 714 | class_definition = textwrap.dedent('''\ 715 | class TypeHints1(parent_class): 716 | """Has type annotations""" 717 | 718 | def abstract(self) -> str: 719 | """Method with type annotations""" 720 | ''') 721 | 722 | local_rtn = {'parent_class': parent_class} 723 | exec(class_definition, globals(), local_rtn) # pylint: disable=exec-used 724 | TypeHints1 = local_rtn['TypeHints1'] # pylint: disable=invalid-name 725 | 726 | self.check_method(parent_class, error, TypeHints1, e) 727 | 728 | def type_hints_2(self, parent_class, error=None): 729 | """Test abstract method has type annotations""" 730 | 731 | with warnings.catch_warnings(record=True) as e: 732 | 733 | class_definition = textwrap.dedent('''\ 734 | class TypeHints2(parent_class): 735 | """Has type annotations""" 736 | 737 | def abstract(self) -> int: 738 | """Method with type annotations""" 739 | ''') 740 | 741 | local_rtn = {'parent_class': parent_class} 742 | exec(class_definition, globals(), local_rtn) # pylint: disable=exec-used 743 | TypeHints2 = local_rtn['TypeHints2'] # pylint: disable=invalid-name 744 | 745 | self.check_method(parent_class, error, TypeHints2, e) 746 | 747 | def test_duplicate_parents(self): 748 | """Parents with the same plugin type should raise an error""" 749 | 750 | @parent.Parent('test_parent') 751 | class Parent1: # pylint: disable=unused-variable 752 | """First Parent""" 753 | 754 | with self.assertRaisesRegex(ValueError, "parent must be unique"): 755 | 756 | @parent.Parent('test_parent') 757 | class Parent2: # pylint: disable=unused-variable 758 | """Second Parent""" 759 | 760 | def test_duplicate_versions(self): 761 | """Duplicate versions throw warning and are ignored""" 762 | 763 | @parent.Parent('test_parent') 764 | class Parent: 765 | """Parent""" 766 | 767 | plugins = Parent._get_plugins() 768 | 769 | with warnings.catch_warnings(record=True) as e: 770 | 771 | class Child1(Parent): 772 | """First Child""" 773 | _alias_ = 'child' 774 | 775 | self.assertEqual(len(plugins['child']), 1) 776 | self.assertIs(plugins['child']['0'], Child1) 777 | self.assertEqual(len(e), 0) 778 | 779 | with warnings.catch_warnings(record=True) as e: 780 | 781 | class Child2(Parent): # pylint: disable=unused-variable 782 | """Duplicate Child""" 783 | _alias_ = 'child' 784 | 785 | self.assertEqual(len(plugins['child']), 1) 786 | self.assertIs(plugins['child']['0'], Child1) 787 | self.assertEqual(len(e), 1) 788 | self.assertRegex(str(e[-1].message), 'Duplicate plugins found') 789 | 790 | with warnings.catch_warnings(record=True) as e: 791 | 792 | class Child3(Parent): 793 | """Child with a different version""" 794 | _alias_ = 'child' 795 | _version_ = '1.0' 796 | 797 | self.assertEqual(len(plugins['child']), 2) 798 | self.assertIs(plugins['child']['0'], Child1) 799 | self.assertIs(plugins['child']['1.0'], Child3) 800 | self.assertEqual(len(e), 0) 801 | 802 | def test_unknown_parent(self): 803 | """Raise an exception if parent is an unknown type""" 804 | 805 | @parent.Parent('test_parent') 806 | class Parent: 807 | """Parent""" 808 | 809 | # Clear registry 810 | parent.get_plugins().clear() 811 | 812 | with self.assertRaisesRegex(ValueError, "Unknown parent type"): 813 | 814 | class Child1(Parent): # pylint: disable=unused-variable 815 | """Child""" 816 | _alias_ = 'child' 817 | --------------------------------------------------------------------------------