pax_global_header00006660000000000000000000000064151425655030014520gustar00rootroot0000000000000052 comment=67b3fb40d5d7e1c33fed84619412c4e590c0f868 napari-napari-plugin-engine-67b3fb4/000077500000000000000000000000001514256550300174205ustar00rootroot00000000000000napari-napari-plugin-engine-67b3fb4/.github/000077500000000000000000000000001514256550300207605ustar00rootroot00000000000000napari-napari-plugin-engine-67b3fb4/.github/dependabot.yml000066400000000000000000000003571514256550300236150ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" commit-message: prefix: "ci(dependabot):" groups: github-actions: patterns: - "*" napari-napari-plugin-engine-67b3fb4/.github/workflows/000077500000000000000000000000001514256550300230155ustar00rootroot00000000000000napari-napari-plugin-engine-67b3fb4/.github/workflows/deploy.yml000066400000000000000000000016351514256550300250410ustar00rootroot00000000000000name: Create Release on: push: tags: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 jobs: deploy: runs-on: ubuntu-latest if: github.repository == 'napari/napari-plugin-engine' steps: - name: Checkout code uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Install Python uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: 3.8 - name: Install Dependencies run: | python -m pip install --upgrade pip python -m pip install pep517 - name: Build Distribution run: | python -m pep517.build . - name: Publish PyPI Package uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 with: user: __token__ password: ${{ secrets.pypi_password }} napari-napari-plugin-engine-67b3fb4/.github/workflows/test.yml000066400000000000000000000032171514256550300245220ustar00rootroot00000000000000name: CI on: push: branches: - main pull_request: branches: - main jobs: test: runs-on: ${{ matrix.platform }} strategy: fail-fast: false matrix: platform: [ubuntu-latest, macos-latest, windows-latest] python-version: ['3.10', 3.11, 3.12, 3.13] steps: - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ matrix.python-version }} cache: "pip" - name: Install dependencies run: | python -m pip install --upgrade pip pip install tox tox-gh-actions - name: Test with tox run: tox env: PLATFORM: ${{ matrix.platform }} - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 napari: runs-on: ubuntu-latest steps: - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.10" - uses: tlambert03/setup-qt-libs@19e4ef2d781d81f5f067182e228b54ec90d23b76 # v1.8 - name: Install dependencies run: | python -m pip install --upgrade pip pip install tox tox-gh-actions - name: Test with tox uses: aganders3/headless-gui@f85dd6316993505dfc5f21839d520ae440c84816 # v2.2 with: run: python -m tox -v env: PLATFORM: ubuntu-latest TOXENV: napari napari-napari-plugin-engine-67b3fb4/.gitignore000066400000000000000000000015501514256550300214110ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # PyBuilder target/ *.swp # generated by setuptools_scm napari_plugin_engine/_version.py # generated by pip pip-wheel-metadata/ .DS_Store .vscode *.mypy_cache* # hypothesis .hypothesis/* napari-napari-plugin-engine-67b3fb4/.pre-commit-config.yaml000066400000000000000000000003021514256550300236740ustar00rootroot00000000000000repos: - repo: https://github.com/pycqa/isort rev: 7.0.0 hooks: - id: isort - repo: https://github.com/psf/black-pre-commit-mirror rev: 26.1.0 hooks: - id: black napari-napari-plugin-engine-67b3fb4/CHANGELOG.rst000066400000000000000000000261501514256550300214450ustar00rootroot00000000000000========= Changelog ========= forked into napari-plugin-engine (2020-04-20) ===============================- .. towncrier release notes start pluggy 0.13.1 (2019-11-21) ========================== Trivial/Internal Changes ------------------------ - `#236 `_: Improved documentation, especially with regard to references. pluggy 0.13.0 (2019-09-10) ========================== Trivial/Internal Changes ------------------------ - `#222 `_: Replace ``importlib_metadata`` backport with ``importlib.metadata`` from the standard library on Python 3.8+. pluggy 0.12.0 (2019-05-27) ========================== Features -------- - `#215 `_: Switch from ``pkg_resources`` to ``importlib-metadata`` for entrypoint detection for improved performance and import time. This time with ``.egg`` support. pluggy 0.11.0 (2019-05-07) ========================== Bug Fixes --------- - `#205 `_: Revert changes made in 0.10.0 release breaking ``.egg`` installs. pluggy 0.10.0 (2019-05-07) ========================== Features -------- - `#199 `_: Switch from ``pkg_resources`` to ``importlib-metadata`` for entrypoint detection for improved performance and import time. pluggy 0.9.0 (2019-02-21) ========================= Features -------- - `#189 `_: ``PluginManager.load_setuptools_entrypoints`` now accepts a ``name`` parameter that when given will load only entry points with that name. ``PluginManager.load_setuptools_entrypoints`` also now returns the number of plugins loaded by the call, as opposed to the number of all plugins loaded by all calls to this method. Bug Fixes --------- - `#187 `_: Fix internal ``varnames`` function for PyPy3. pluggy 0.8.1 (2018-11-09) ========================= Trivial/Internal Changes ------------------------ - `#166 `_: Add ``stacklevel=2`` to implprefix warning so that the reported location of warning is the caller of PluginManager. pluggy 0.8.0 (2018-10-15) ========================= Features -------- - `#177 `_: Add ``get_hookimpls()`` method to hook callers. Trivial/Internal Changes ------------------------ - `#165 `_: Add changelog in long package description and documentation. - `#172 `_: Add a test exemplifying the opt-in nature of spec defined args. - `#57 `_: Encapsulate hook specifications in a type for easier introspection. pluggy 0.7.1 (2018-07-28) ========================= Deprecations and Removals ------------------------- - `#116 `_: Deprecate the ``implprefix`` kwarg to ``PluginManager`` and instead expect users to start using explicit ``HookimplMarker`` everywhere. Features -------- - `#122 `_: Add ``.plugin`` member to ``PluginValidationError`` to access failing plugin during post-mortem. - `#138 `_: Add per implementation warnings support for hookspecs allowing for both deprecation and future warnings of legacy and (future) experimental hooks respectively. Bug Fixes --------- - `#110 `_: Fix a bug where ``_HookCaller.call_historic()`` would call the ``proc`` arg even when the default is ``None`` resulting in a ``TypeError``. - `#160 `_: Fix problem when handling ``VersionConflict`` errors when loading setuptools plugins. Improved Documentation ---------------------- - `#123 `_: Document how exceptions are handled and how the hook call loop terminates immediately on the first error which is then delivered to any surrounding wrappers. - `#136 `_: Docs rework including a much better introduction and comprehensive example set for new users. A big thanks goes out to @obestwalter for the great work! Trivial/Internal Changes ------------------------ - `#117 `_: Break up the main monolithic package modules into separate modules by concern - `#131 `_: Automate ``setuptools`` wheels building and PyPi upload using TravisCI. - `#153 `_: Reorganize tests more appropriately by modules relating to each internal component/feature. This is in an effort to avoid (future) duplication and better separation of concerns in the test set. - `#156 `_: Add ``HookImpl.__repr__()`` for better debugging. - `#66 `_: Start using ``towncrier`` and a custom ``tox`` environment to prepare releases! pluggy 0.7.0 (Unreleased) ========================= * `#160 `_: We discovered a deployment issue so this version was never released to PyPI, only the tag exists. pluggy 0.6.0 (2017-11-24) ========================= - Add CI testing for the features, release, and master branches of ``pytest`` (PR `#79`_). - Document public API for ``_Result`` objects passed to wrappers (PR `#85`_). - Document and test hook LIFO ordering (PR `#85`_). - Turn warnings into errors in test suite (PR `#89`_). - Deprecate ``_Result.result`` (PR `#88`_). - Convert ``_Multicall`` to a simple function distinguishing it from the legacy version (PR `#90`_). - Resolve E741 errors (PR `#96`_). - Test and bug fix for unmarked hook collection (PRs `#97`_ and `#102`_). - Drop support for EOL Python 2.6 and 3.3 (PR `#103`_). - Fix ``inspect`` based arg introspection on py3.6 (PR `#94`_). .. _#79: https://github.com/pytest-dev/pluggy/pull/79 .. _#85: https://github.com/pytest-dev/pluggy/pull/85 .. _#88: https://github.com/pytest-dev/pluggy/pull/88 .. _#89: https://github.com/pytest-dev/pluggy/pull/89 .. _#90: https://github.com/pytest-dev/pluggy/pull/90 .. _#94: https://github.com/pytest-dev/pluggy/pull/94 .. _#96: https://github.com/pytest-dev/pluggy/pull/96 .. _#97: https://github.com/pytest-dev/pluggy/pull/97 .. _#102: https://github.com/pytest-dev/pluggy/pull/102 .. _#103: https://github.com/pytest-dev/pluggy/pull/103 pluggy 0.5.2 (2017-09-06) ========================= - fix bug where ``firstresult`` wrappers were being sent an incorrectly configured ``_Result`` (a list was set instead of a single value). Add tests to check for this as well as ``_Result.force_result()`` behaviour. Thanks to `@tgoodlet`_ for the PR `#72`_. - fix incorrect ``getattr`` of ``DeprecationWarning`` from the ``warnings`` module. Thanks to `@nicoddemus`_ for the PR `#77`_. - hide ``pytest`` tracebacks in certain core routines. Thanks to `@nicoddemus`_ for the PR `#80`_. .. _#72: https://github.com/pytest-dev/pluggy/pull/72 .. _#77: https://github.com/pytest-dev/pluggy/pull/77 .. _#80: https://github.com/pytest-dev/pluggy/pull/80 pluggy 0.5.1 (2017-08-29) ========================= - fix a bug and add tests for case where ``firstresult`` hooks return ``None`` results. Thanks to `@RonnyPfannschmidt`_ and `@tgoodlet`_ for the issue (`#68`_) and PR (`#69`_) respectively. .. _#69: https://github.com/pytest-dev/pluggy/pull/69 .. _#68: https://github.com/pytest-dev/pluggy/issues/68 pluggy 0.5.0 (2017-08-28) ========================= - fix bug where callbacks for historic hooks would not be called for already registered plugins. Thanks `@vodik`_ for the PR and `@hpk42`_ for further fixes. - fix `#17`_ by considering only actual functions for hooks this removes the ability to register arbitrary callable objects which at first glance is a reasonable simplification, thanks `@RonnyPfannschmidt`_ for report and pr. - fix `#19`_: allow registering hookspecs from instances. The PR from `@tgoodlet`_ also modernized the varnames implementation. - resolve `#32`_: split up the test set into multiple modules. Thanks to `@RonnyPfannschmidt`_ for the PR and `@tgoodlet`_ for the initial request. - resolve `#14`_: add full sphinx docs. Thanks to `@tgoodlet`_ for PR `#39`_. - add hook call mismatch warnings. Thanks to `@tgoodlet`_ for the PR `#42`_. - resolve `#44`_: move to new-style classes. Thanks to `@MichalTHEDUDE`_ for PR `#46`_. - add baseline benchmarking/speed tests using ``pytest-benchmark`` in PR `#54`_. Thanks to `@tgoodlet`_. - update the README to showcase the API. Thanks to `@tgoodlet`_ for the issue and PR `#55`_. - deprecate ``__multicall__`` and add a faster call loop implementation. Thanks to `@tgoodlet`_ for PR `#58`_. - raise a comprehensible error when a ``hookimpl`` is called with positional args. Thanks to `@RonnyPfannschmidt`_ for the issue and `@tgoodlet`_ for PR `#60`_. - fix the ``firstresult`` test making it more complete and remove a duplicate of that test. Thanks to `@tgoodlet`_ for PR `#62`_. .. _#62: https://github.com/pytest-dev/pluggy/pull/62 .. _#60: https://github.com/pytest-dev/pluggy/pull/60 .. _#58: https://github.com/pytest-dev/pluggy/pull/58 .. _#55: https://github.com/pytest-dev/pluggy/pull/55 .. _#54: https://github.com/pytest-dev/pluggy/pull/54 .. _#46: https://github.com/pytest-dev/pluggy/pull/46 .. _#44: https://github.com/pytest-dev/pluggy/issues/44 .. _#42: https://github.com/pytest-dev/pluggy/pull/42 .. _#39: https://github.com/pytest-dev/pluggy/pull/39 .. _#32: https://github.com/pytest-dev/pluggy/pull/32 .. _#19: https://github.com/pytest-dev/pluggy/issues/19 .. _#17: https://github.com/pytest-dev/pluggy/issues/17 .. _#14: https://github.com/pytest-dev/pluggy/issues/14 pluggy 0.4.0 (2016-09-25) ========================= - add ``has_plugin(name)`` method to pluginmanager. thanks `@nicoddemus`_. - fix `#11`_: make plugin parsing more resilient against exceptions from ``__getattr__`` functions. Thanks `@nicoddemus`_. - fix issue `#4`_: specific ``HookCallError`` exception for when a hook call provides not enough arguments. - better error message when loading setuptools entrypoints fails due to a ``VersionConflict``. Thanks `@blueyed`_. .. _#11: https://github.com/pytest-dev/pluggy/issues/11 .. _#4: https://github.com/pytest-dev/pluggy/issues/4 pluggy 0.3.1 (2015-09-17) ========================= - avoid using deprecated-in-python3.5 getargspec method. Thanks `@mdboom`_. pluggy 0.3.0 (2015-05-07) ========================= initial release .. contributors .. _@hpk42: https://github.com/hpk42 .. _@tgoodlet: https://github.com/goodboy .. _@MichalTHEDUDE: https://github.com/MichalTHEDUDE .. _@vodik: https://github.com/vodik .. _@RonnyPfannschmidt: https://github.com/RonnyPfannschmidt .. _@blueyed: https://github.com/blueyed .. _@nicoddemus: https://github.com/nicoddemus .. _@mdboom: https://github.com/mdboom napari-napari-plugin-engine-67b3fb4/LICENSE000066400000000000000000000021701514256550300204250ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2015 Holger Krekel (rather uses bitbucket/hpk42) Copyright (c) 2020 Talley Lambert Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. napari-napari-plugin-engine-67b3fb4/MANIFEST.in000066400000000000000000000002071514256550300211550ustar00rootroot00000000000000include CHANGELOG.rst include README.md include setup.py include tox.ini include LICENSE graft testing recursive-exclude * *.pyc *.pyo napari-napari-plugin-engine-67b3fb4/README.md000066400000000000000000000051751514256550300207070ustar00rootroot00000000000000# napari plugin engine [![License](https://img.shields.io/pypi/l/napari-plugin-engine.svg?color=green)](https://github.com/napari/napari-plugin-engine/raw/master/LICENSE) [![Build Status](https://travis-ci.com/napari/napari-plugin-engine.svg?branch=master)](https://travis-ci.com/napari/napari-plugin-engine) [![Docs Status](https://readthedocs.org/projects/napari-plugin-engine/badge/?version=latest)](https://readthedocs.org/projects/napari_plugin_engine/) [![codecov](https://codecov.io/gh/napari/napari/branch/master/graph/badge.svg)](https://codecov.io/gh/napari/napari) [![Python Version](https://img.shields.io/pypi/pyversions/napari-plugin-engine.svg?color=green)](https://python.org) [![PyPI](https://img.shields.io/pypi/v/napari-plugin-engine.svg?color=green)](https://pypi.org/project/napari-plugin-engine) ![Deprecated](https://img.shields.io/badge/status-deprecated-orange) --- **DEPRECATED**: This project is deprecated. Please use [npe2](https://github.com/napari/npe2) instead. `napari-plugin-engine` is the first generation napari plugin engine. We recommend: - new plugins use the second generation [npe2](https://github.com/napari/npe2) - existing plugins migrate to npe2 using the [Migration Guide](https://napari.org/stable/plugins/advanced_topics/npe2_migration_guide.html) in our docs. --- `napari-plugin-engine` is a fork of [pluggy](https://github.com/pytest-dev/pluggy), modified by the [napari](https://github.com/napari/napari) team. There are some API and feature changes, including: - discovery via [naming convention](https://packaging.python.org/guides/creating-and-discovering-plugins/#using-naming-convention) as well as [entry_points](https://packaging.python.org/guides/creating-and-discovering-plugins/#using-package-metadata) - support for reordering of hook calls after registration. - enhanced API for retrieving plugin package metadata. - modified plugin call and registration exception handling - modified `HookResult` object and hook call loop, with ability to retrieve the `HookImplementation` responsible for the result. - lazy plugin discovery - some changes to variable and attribute naming - removed all deprecated code - type annotations on everything - pytest fixtures for testing - a couple napari-specific convenience imports For usage overview and a reference for the `napari-plugin-engine` API, see our [Documentation](https://napari-plugin-engine.readthedocs.io/en/latest/) (see also: the [pluggy documentation](https://pluggy.readthedocs.io/en/latest/)) ## install ```shell pip install napari-plugin-engine ``` ## Usage see [documentation](https://napari-plugin-engine.readthedocs.io/en/latest/usage.html) napari-napari-plugin-engine-67b3fb4/changelog/000077500000000000000000000000001514256550300213475ustar00rootroot00000000000000napari-napari-plugin-engine-67b3fb4/changelog/README.rst000066400000000000000000000031761514256550300230450ustar00rootroot00000000000000This directory contains "newsfragments" which are short files that contain a small **ReST**-formatted text that will be added to the next ``CHANGELOG``. The ``CHANGELOG`` will be read by users, so this description should be aimed to pytest users instead of describing internal changes which are only relevant to the developers. Make sure to use full sentences with correct case and punctuation, for example:: Fix issue with non-ascii messages from the ``warnings`` module. Each file should be named like ``..rst``, where ```` is an issue number, and ```` is one of: * ``feature``: new user facing features, like new command-line options and new behavior. * ``bugfix``: fixes a reported bug. * ``doc``: documentation improvement, like rewording an entire session or adding missing docs. * ``removal``: feature deprecation or removal. * ``vendor``: changes in packages vendored in pytest. * ``trivial``: fixing a small typo or internal change that might be noteworthy. So for example: ``123.feature.rst``, ``456.bugfix.rst``. If your PR fixes an issue, use that number here. If there is no issue, then after you submit the PR and get the PR number you can add a changelog using that instead. If you are not sure what issue type to use, don't hesitate to ask in your PR. ``towncrier`` preserves multiple paragraphs and formatting (code blocks, lists, and so on), but for entries other than ``features`` it is usually better to stick to a single paragraph to keep it concise. You can install ``towncrier`` and then run ``towncrier --draft`` if you want to get a preview of how your change will look in the final release notes. napari-napari-plugin-engine-67b3fb4/changelog/_template.rst000066400000000000000000000016651514256550300240630ustar00rootroot00000000000000{% for section in sections %} {% set underline = "-" %} {% if section %} {{section}} {{ underline * section|length }}{% set underline = "~" %} {% endif %} {% if sections[section] %} {% for category, val in definitions.items() if category in sections[section] %} {{ definitions[category]['name'] }} {{ underline * definitions[category]['name']|length }} {% if definitions[category]['showcontent'] %} {% for text, values in sections[section][category]|dictsort(by='value') %} {% set issue_joiner = joiner(', ') %} - {% for value in values|sort %}{{ issue_joiner() }}`{{ value }} `_{% endfor %}: {{ text }} {% endfor %} {% else %} - {{ sections[section][category]['']|sort|join(', ') }} {% endif %} {% if sections[section][category]|length == 0 %} No significant changes. {% else %} {% endif %} {% endfor %} {% else %} No significant changes. {% endif %} {% endfor %} napari-napari-plugin-engine-67b3fb4/codecov.yml000066400000000000000000000001511514256550300215620ustar00rootroot00000000000000coverage: status: project: default: target: auto threshold: 1% patch: offnapari-napari-plugin-engine-67b3fb4/docs/000077500000000000000000000000001514256550300203505ustar00rootroot00000000000000napari-napari-plugin-engine-67b3fb4/docs/Makefile000066400000000000000000000012601514256550300220070ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile echo "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) napari-napari-plugin-engine-67b3fb4/docs/_static/000077500000000000000000000000001514256550300217765ustar00rootroot00000000000000napari-napari-plugin-engine-67b3fb4/docs/_static/css/000077500000000000000000000000001514256550300225665ustar00rootroot00000000000000napari-napari-plugin-engine-67b3fb4/docs/_static/css/press.css000066400000000000000000000033751514256550300244440ustar00rootroot00000000000000@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&display=swap'); pre, code, .rst-content tt, .rst-content code, kbd, samp { font-family: "IBM Plex Mono",'InconsolataRegular', 'Inconsolata', 'RobotoMonoRegular', 'Roboto Mono', monospace !important; } dl { padding-left: 1rem; } .sig-name.descname { font-size: 1.1em; font-weight: bold; padding: 0 0 3px; } a code span.pre { font-weight: bold; } .sig-param { font-family: "IBM Plex Mono",'InconsolataRegular', 'Inconsolata', 'RobotoMonoRegular', 'Roboto Mono', monospace !important; margin-left: 0.3em; font-size: 0.9rem; color: darkcyan; font-style: initial; } .sig-name.descname { font-size: 1.1em; font-weight: bold; padding: 0 5px 3px; } .viewcode-link { font-size: 0.73rem; } li p { margin-bottom: 0.8rem; } dt.field-odd, dt.field-even, p.rubric { font-size: 1.0em; font-weight: bold; color: #4d6a86; /* background-color: linen; */ padding: 0.2rem; margin-top: 1.1rem; padding-left: 0.7rem; } .py.class .field-list .field-odd li p strong:first-child { font-family: "IBM Plex Mono",'InconsolataRegular', 'Inconsolata', 'RobotoMonoRegular', 'Roboto Mono', monospace; margin-left: 0px; text-indent: 0.1rem; } code.sig-name.descname { font-weight: 700; color: darkslateblue; } .sidebar { width: 16.5rem; } .page { padding-left: 16.5rem; } @media (max-width: 959px) { .sidebar { font-size:15px; width: 15rem; } .page { padding-left: 15rem; } } @media (max-width: 719px) { .page { padding-left: 0; } } .search input[type="text"] { border: none; padding: 3px; background-color: #efefef; padding: 7px 15px; width: 59%; font-size: 16px; border-radius: 5px; margin-top: 7px; }napari-napari-plugin-engine-67b3fb4/docs/_static/css/rtd.css000066400000000000000000000061411514256550300240730ustar00rootroot00000000000000@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&family=Roboto:wght@100;300;400;700&display=swap"); body { font-family: "Roboto", "Lato", "proxima-nova", "Helvetica Neue", Arial, sans-serif !important; font-weight: 300; color: #404040; } a { color: #0088e2; } a:visited { color: #4290c3; } .rst-content a { font-weight: 400 } a.icon-home { color: white; } a.icon-home::before { content: "\f12e"; } .wy-menu-vertical a { color: #d9d9d9; } .wy-menu-vertical header, .wy-menu-vertical p.caption { color: #00a9a9; } .wy-nav-content-wrap { background: white; } pre, code samp { font-family: "IBM Plex Mono", "InconsolataRegular", "Inconsolata", "RobotoMonoRegular", "Roboto Mono", monospace !important; font-size: 1em; } code, .rst-content tt, .rst-content code { border-radius: 3px; } h1, h2, .rst-content .toctree-wrapper p.caption, h3, h4, h5, h6, legend { margin-top: 0; font-weight: 400; font-family: "Roboto", "Georgia", Arial, sans-serif; } .rst-content pre.literal-block, .rst-content div[class^="highlight"] { border-radius: 3px; } .rst-content dl:not(.docutils) dt { background-color: lightcyan; border-color: #c8b4da; } .rst-content dl:not(.docutils) dt:first-child { margin-top: 0; padding-left: 4rem; padding-right: 100px; } .rst-content dl.field-list.simple dt:first-child { padding-left: 0.5rem; } .rst-content dl:not(.docutils) .property { display: inline-block; padding-right: 8px; position: absolute; left: 0.5rem; text-transform: uppercase; font-style: normal; color: darkcyan; } .rst-content dl.py.data .property { display: inline-block; padding-right: 8px; position: inherit; text-transform: none; } .class .method dt { padding-left: 0.5rem; } .rst-content dl.method dt:first-child { margin-top: 0; padding-left: 0.5rem; } .rst-content dl.method em.property { display: inline-block; position: inherit; margin-right: 0.5rem; left: 0.5rem; font-style: normal; text-transform: uppercase; color: darkcyan; } .function dt a, .attribute dt a, .class .attribute dt a, .class dt a { position: absolute; right: 15px; padding-right: 0; top: 50%; -webkit-transform: perspective(1px) translateY(-50%); transform: perspective(1px) translateY(-50%); } .function .viewcode-link, .attribute .viewcode-link, .class .viewcode-link { font-size: 0.8rem; color: #979797; letter-spacing: 0; line-height: 1.5rem; font-weight: 300; text-transform: uppercase; padding-right: 15px; } .wy-nav-content { background: white; } .wy-side-nav-search input[type="text"] { border-radius: 5px; } span.sig-paren { font-weight: 400; } em.sig-param { font-size: 0.85rem; font-style: italic; font-weight: 400; } .wy-side-nav-search, .wy-nav-top { background-color: darkcyan; } .wy-table-responsive td code { font-size: 0.75rem; } dl.py.attribute em.property { display: none; } .rst-content dl.py.attribute dt:first-child { padding-left: 0.5rem; } .rst-content dl.py.attribute dt:first-child code.descname::before { content: "ATTR"; padding-right: 5px; color: darkcyan; }napari-napari-plugin-engine-67b3fb4/docs/api.rst000066400000000000000000000050011514256550300216470ustar00rootroot00000000000000############# API Reference ############# .. currentmodule:: napari_plugin_engine .. autosummary:: :nosignatures: PluginManager HookCaller HookResult HookImplementation HookSpecification HookSpecificationMarker HookImplementationMarker PluginManager ============= .. autoclass:: PluginManager :members: :private-members: :exclude-members: _load_and_register HookCaller ========== .. autoclass:: HookCaller :members: :private-members: :special-members: :exclude-members: __init__, __repr__, __weakref__, _check_call_kwargs, _maybe_apply_history, HookResult ========== .. autoclass:: HookResult :members: HookSpecification ================= .. autoclass:: HookSpecification :members: HookImplementation ================== .. autoclass:: HookImplementation :members: Decorators & Markers ==================== HookSpecificationMarker ----------------------- .. autoclass:: HookSpecificationMarker :members: :special-members: :exclude-members: __init__, __weakref__ HookImplementationMarker ------------------------ .. autoclass:: HookImplementationMarker :members: :special-members: :exclude-members: __init__, __weakref__ Exceptions ========== .. autosummary:: :nosignatures: PluginError PluginImportError PluginRegistrationError PluginImplementationError PluginValidationError PluginCallError HookCallError PluginError ----------- .. autoclass:: PluginError :members: :show-inheritance: PluginImportError ----------------- .. autoclass:: PluginImportError :members: :show-inheritance: PluginRegistrationError ------------------------ .. autoclass:: PluginRegistrationError :members: :show-inheritance: PluginImplementationError ------------------------- .. autoclass:: PluginImplementationError :members: :show-inheritance: PluginValidationError --------------------- .. autoclass:: PluginValidationError :members: :show-inheritance: PluginCallError --------------- .. autoclass:: PluginCallError :members: :show-inheritance: HookCallError ------------- .. autoclass:: HookCallError :members: :show-inheritance: Extra Functions =============== .. autofunction:: napari_plugin_engine.hooks._multicall .. autofunction:: napari_plugin_engine.manager.ensure_namespace .. autofunction:: napari_plugin_engine.manager.temp_path_additions .. autofunction:: napari_plugin_engine.dist.get_dist Types ===== .. autodata:: napari_plugin_engine.hooks.HookExecFuncnapari-napari-plugin-engine-67b3fb4/docs/conf.py000066400000000000000000000044061514256550300216530ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # import os import sys sys.path.insert(0, os.path.abspath('..')) # -- Project information ----------------------------------------------------- project = 'napari-plugin-engine' author = 'Holger Krekel & Talley Lambert' master_doc = 'index' html_show_sourcelink = False # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.todo', 'sphinx.ext.viewcode', 'sphinx.ext.autodoc', 'sphinx.ext.napoleon', 'sphinx_autodoc_typehints', 'sphinx.ext.autosummary', 'sphinx.ext.intersphinx', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] pygments_style = 'paraiso-dark' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'sphinx_rtd_theme' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # this is for "press" theme # html_css_files = ['css/press.css'] html_css_files = ['css/rtd.css'] intersphinx_mapping = { 'python': ('https://docs.python.org/3', None), } napari-napari-plugin-engine-67b3fb4/docs/index.rst000066400000000000000000000014311514256550300222100ustar00rootroot00000000000000napari-plugin-engine documentation ================================== ``napari-plugin-engine`` is a fork of `pluggy `_ modified by the `napari `_ team for use in napari. While much of the original API described in the `pluggy docs `_ will still be valid here, there are definitely some breaking changes and different conventions here. If you're just getting started, have a look at the :doc:`usage` For details on specific classes in napari-plugin-engine, have a look at the :doc:`api` See also: `documentation for writing a napari plugin `_. .. toctree:: :maxdepth: 3 :caption: Contents usage api napari-napari-plugin-engine-67b3fb4/docs/make.bat000066400000000000000000000014331514256550300217560ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd napari-napari-plugin-engine-67b3fb4/docs/requirements.txt000066400000000000000000000001541514256550300236340ustar00rootroot00000000000000sphinx==3.0.1 pygments sphinx_autodoc_typehints sphinx_rtd_theme sphinx-press-theme importlib-metadata>=0.12napari-napari-plugin-engine-67b3fb4/docs/usage.rst000066400000000000000000000150731514256550300222140ustar00rootroot00000000000000############## Usage Overview ############## Create a plugin manager ======================= .. currentmodule:: napari_plugin_engine A :class:`PluginManager` is the main object that registers and organizes plugins. It is instantiated with a ``project_name``: .. code-block:: python plugin_manager = PluginManager('my_project') All :ref:`hook specifications ` and :ref:`hook implementations ` must use the same ``project_name`` if they are to be recognized by this ``plugin_manager`` instance. .. _hook-specifications: Add some hook specifications ============================ You add `hook specifications `_ which outline the function signatures plugins may implement: .. code-block:: python plugin_manager.add_hookspecs(some_class_or_module) ... where "``some_class_or_module``" is any `namespace `_ object (such as a class or module) that has some functions that have been decorated as hook specifications for ``'my_project'`` using a :class:`HookSpecificationMarker` decorator. .. code-block:: python # some_class_or_module.py from napari_plugin_engine import HookSpecificationMarker my_project_hook_specification = HookSpecificationMarker('my_project') @my_project_hook_specification def do_something(arg1: int, arg2: int): -> int: """Take two integers and return one integer.""" After calling :meth:`~PluginManager.add_hookspecs`, your ``plugin_manager`` instance will have a new :class:`HookCaller` instance created under the ``plugin_manager.hooks`` namespace, for each hook specification discovered. In this case, there will be a new one at ``plugin_manager.hooks.do_something``. .. _hook-implementations: (Plugins) write hook implementations ==================================== Plugins may then provide *implementations* for your hook specifications, by creating classes or modules that contain functions that are decorated with an instance of a :class:`HookImplementationMarker` that has been created using the *same* project name (in this example: ``'my_project'``) .. code-block:: python # some_plugin.py from napari_plugin_engine import HookImplementationMarker my_project_hook_implementation = HookImplementationMarker('my_project') @my_project_hook_implementation def do_something(arg1, arg2): return arg1 + arg2 Register plugins ================ You may directly *register* these modules with the ``plugin_manager`` ... .. code-block:: python import some_plugin plugin_manager.register(some_plugin) Autodiscover plugins in the environment --------------------------------------- However, it is more often the case that you will want to *discover* plugins in your environment. ``napari-plugin-engine`` provides two ways to discover plugins via two different conventions: 1. `Using package metadata `_: looking for distributions that declare a specific `entry_point `_ in their ``setup.py`` file. 2. `Using naming convention `_: looking for modules that begin with a specific prefix. You can look for either or both, in single call to :meth:`~PluginManager.discover`, which will import any modules or entry_points that follow one of the aforementioned conventions, and search them for functions decorated with the appropriate :class:`HookImplementationMarker` (as shown above in ``some_plugin.py``) .. code-block:: python plugin_manager.discover( entry_point='my_project.plugin', prefix='my_project_' ) Use (call) the plugin implementations ===================================== Your :class:`HookCaller` should now be populated with any of the implementations found in plugins, as :class:`HookImplementation` objects on the :class:`HookCaller`. .. code-block:: python # show all implementations for do_something plugin_manager.hooks.do_something.get_hookimpls() Finally, you can call some or all of the plugin implementation functions by directly calling the :class:`HookCaller` object: .. code-block:: python result = plugin_manager.hooks.do_something(arg1=2, arg2=7) # assuming only some_plugin.py from above is registered: print(result) # [9] By default, *all* plugin implementations are called, and all non-``None`` results are returned in a list. However, this is configurable and depends on how the ``@my_project_hook_specification`` was used, and how the :class:`HookCaller` was called How the ``plugin_name`` is chosen ================================= **1. If plugin discovery via entry_points is used** (e.g. ``plugin_manager.discover(entry_point='app.plugin')``), then plugins will be named using the name of the entry_point provided by each plugin. Note, a single package may provide multiple plugins via entry points. For example, if a package had the following ``entry_points`` declared in their ``setup.py`` file: .. code-block:: python # setup.py setup( ... entry_points={'app.plugin': ['plugin1 = module_a', 'plugin2 = module_b']}, ... ) ... then ``manager.discover(entry_point='app.plugin')`` would register two plugins, named ``"plugin1"`` (which would inspect ``module_a`` for implementations) and ``"plugin2"`` (which would inspect ``module_b`` for implementations). **2. If plugin discovery via naming convention is used** (e.g. ``plugin_manager.discover(prefix='app_')``), then... **2a. If a** ``dist-info`` **folder is found for the module** Then the plugin will be named using the `Name key `_ in the distribution ``METADATA`` file if one is available. Usually, this will come from having a ``setup(name="distname", ...)`` entry in a ``setup.py`` file. See `Core metadata specifications `_ and `PEP 566 `_ for details. **2a. If no distribution metadata can be located** The the plugin will be named using the name of the module itself. **3. If a plugin is directly registered** (e.g. ``plugin_manager.register(object, name)``), then if a ``name`` argument is provided to the :meth:`PluginManager.register` method, it will be used as the ``plugin_name``, otherwise, the string form of the object is used: ``str(id(object))``napari-napari-plugin-engine-67b3fb4/napari_plugin_engine/000077500000000000000000000000001514256550300235755ustar00rootroot00000000000000napari-napari-plugin-engine-67b3fb4/napari_plugin_engine/__init__.py000066400000000000000000000024221514256550300257060ustar00rootroot00000000000000try: from ._version import version as __version__ except ImportError: # broken installation, we don't even try # unknown only works because we do poor mans version compare __version__ = "unknown" __all__ = [ "get_metadata", "HookCaller", "HookCallError", "HookImplementation", "HookImplementationMarker", "HookResult", "HookSpecification", "HookSpecificationMarker", "napari_hook_implementation", "napari_hook_specification", "PluginCallError", "PluginError", "PluginImplementationError", "PluginImportError", "PluginManager", "PluginRegistrationError", "PluginValidationError", "standard_metadata", ] from .callers import HookResult from .dist import get_metadata, standard_metadata from .exceptions import ( HookCallError, PluginCallError, PluginError, PluginImplementationError, PluginImportError, PluginRegistrationError, PluginValidationError, ) from .hooks import HookCaller from .implementation import HookImplementation, HookSpecification from .manager import PluginManager from .markers import HookImplementationMarker, HookSpecificationMarker napari_hook_implementation = HookImplementationMarker("napari") napari_hook_specification = HookSpecificationMarker("napari") napari-napari-plugin-engine-67b3fb4/napari_plugin_engine/_testsupport.py000066400000000000000000000111301514256550300267160ustar00rootroot00000000000000from contextlib import contextmanager import pytest from napari_plugin_engine import ( HookCaller, HookImplementation, HookImplementationMarker, HookSpecification, HookSpecificationMarker, PluginManager, ) @pytest.fixture def test_plugin_manager() -> PluginManager: """A plugin manager fixture with the project name 'test'.""" return PluginManager(project_name='test') @pytest.fixture def add_specification(test_plugin_manager): """Return a decorator that adds a HookSpecification to test_plugin_manager.""" def addspec(function=None, *, firstresult=False, historic=False): def wrap(func): project = test_plugin_manager.project_name test_hookspec = HookSpecificationMarker(project) test_hookspec(firstresult=firstresult, historic=historic)(func) name = func.__name__ namespace = type("Hook", (), {name: func}) assert not hasattr( test_plugin_manager.hook, name ), f"Hook already exists with name: {name}" opts = getattr(func, HookSpecification.format_tag(project)) hook_caller = HookCaller( name, test_plugin_manager._hookexec, namespace, opts ) setattr(test_plugin_manager.hook, name, hook_caller) return wrap(function) if function is not None else wrap return addspec @pytest.fixture def add_implementation(test_plugin_manager): """Return a decorator that adds a HookImplementation to test_plugin_manager.""" def addimpl( function=None, *, specname=None, tryfirst=False, trylast=False, hookwrapper=False, ): def wrap(func): project = test_plugin_manager.project_name HookImplementationMarker(project)( tryfirst=tryfirst, trylast=trylast, hookwrapper=hookwrapper, specname=specname, )(func) _specname = specname or func.__name__ hook_caller = getattr(test_plugin_manager.hook, _specname, None) assert hook_caller, f"No hook with with name: {_specname}" opts = getattr(func, HookImplementation.format_tag(project)) hook_caller._add_hookimpl(HookImplementation(func, **opts)) return func return wrap(function) if function is not None else wrap return addimpl @pytest.fixture def caller_from_implementation( test_plugin_manager, add_specification, add_implementation ): """Return hook caller with implementation as its own spec definition. Adds a specification and implementation to the test_plugin_manager based on a single function definition (e.g. assumes that the implementation has the correct signature). Returns the hook caller instance prepopulated with the hook implementation. """ def wrap(func, spec_kwargs={}, impl_kwargs={}): add_specification(func, **spec_kwargs) add_implementation(func, **impl_kwargs) name = spec_kwargs.get('specname') or func.__name__ return getattr(test_plugin_manager.hook, name) return wrap @pytest.fixture def temporary_hookimpl(test_plugin_manager): """A fixture that can be used to insert a HookImplementation in the hook call loop. Use as a context manager, which will return the hook_caller for the corresponding hook specification. Example ------- .. code-block: python def my_hook_implementation(arg): raise ValueError("shoot!") with temporary_hookimpl(my_hook_implementation) as hook_caller: with pytest.raises(PluginCallError): hook_caller(arg=42) """ @contextmanager def wrap(func, specname=None, *, tryfirst=True, trylast=None): project = test_plugin_manager.project_name marker = HookImplementationMarker(project) marker(tryfirst=tryfirst, trylast=trylast, specname=specname)(func) _specname = specname or func.__name__ hook_caller = getattr(test_plugin_manager.hook, _specname, None) assert hook_caller, f"No hook with with name: {_specname}" opts = getattr(func, HookImplementation.format_tag(project)) impl = HookImplementation(func, **opts) hook_caller._add_hookimpl(impl) try: yield hook_caller finally: if impl in hook_caller._nonwrappers: hook_caller._nonwrappers.remove(impl) if impl in hook_caller._wrappers: hook_caller._wrappers.remove(impl) assert impl not in hook_caller.get_hookimpls() return wrap napari-napari-plugin-engine-67b3fb4/napari_plugin_engine/_tracing.py000066400000000000000000000030111514256550300257300ustar00rootroot00000000000000""" Tracing utils """ class TagTracer: def __init__(self): self._tags2proc = {} self._writer = None self.indent = 0 def get(self, name): return TagTracerSub(self, (name,)) def _format_message(self, tags, args): if isinstance(args[-1], dict): extra = args[-1] args = args[:-1] else: extra = {} content = " ".join(map(str, args)) indent = " " * self.indent lines = ["%s%s [%s]\n" % (indent, content, ":".join(tags))] for name, value in extra.items(): lines.append("%s %s: %s\n" % (indent, name, value)) return "".join(lines) def _processmessage(self, tags, args): if self._writer is not None and args: self._writer(self._format_message(tags, args)) try: processor = self._tags2proc[tags] except KeyError: pass else: processor(tags, args) def setwriter(self, writer): self._writer = writer def setprocessor(self, tags, processor): if isinstance(tags, str): tags = tuple(tags.split(":")) else: assert isinstance(tags, tuple) self._tags2proc[tags] = processor class TagTracerSub: def __init__(self, root, tags): self.root = root self.tags = tags def __call__(self, *args): self.root._processmessage(self.tags, args) def get(self, name): return self.__class__(self.root, self.tags + (name,)) napari-napari-plugin-engine-67b3fb4/napari_plugin_engine/callers.py000066400000000000000000000174551514256550300256100ustar00rootroot00000000000000"""Call loop machinery.""" import sys from types import TracebackType from typing import Any, List, Optional, Tuple, Type, Union from .exceptions import HookCallError, PluginCallError from .implementation import HookImplementation def _raise_wrapfail(wrap_controller, msg): co = wrap_controller.gi_code raise RuntimeError( "wrap_controller at %r %s:%d %s" % (co.co_name, co.co_filename, co.co_firstlineno, msg) ) ExcInfo = Union[ Tuple[Type[BaseException], BaseException, TracebackType], Tuple[None, None, None], ] class HookResult: """A class to store/modify results from a :func:`~hooks._multicall` hook loop. Results are accessed in ``.result`` property, which will also raise any exceptions that occured during the hook loop. Parameters ---------- results : List[Tuple[Any, HookImplementation]] A list of (result, HookImplementation) tuples, with the result and HookImplementation object responsible for each result collected during a _multicall loop. excinfo : tuple The output of sys.exc_info() if raised during the multicall loop. firstresult : bool, optional Whether the hookspec had ``firstresult == True``, by default False. If True, self._result, and self.implementation will be single values, otherwise they will be lists. plugin_errors : list A list of any :class:`PluginCallError` instances that were created during the multicall loop. """ def __init__( self, result: List[Tuple[Any, HookImplementation]], excinfo: Optional[ExcInfo], firstresult: bool = False, plugin_errors: Optional[List[PluginCallError]] = None, ): self._result: Any = [] #: The HookImplementation(s) that were responsible for each result in ``result`` self.implementation: Optional[ Union[HookImplementation, List[HookImplementation]] ] = [] #: Whether this HookResult came from a ``firstresult`` multicall. self.is_firstresult: bool = firstresult self._excinfo = excinfo self.plugin_errors = plugin_errors if result: self._result, self.implementation = tuple(zip(*result)) self._result = list(self._result) if firstresult: if self._result: self._result = self._result[0] self.implementation = self.implementation[0] # type: ignore else: self._result = None self.implementation = None #: Name of last hookwrapper that changed the result, if any self._modified_by: Optional[str] = None @property def excinfo(self): return self._excinfo @classmethod def from_call(cls, func): """Used when hookcall monitoring is enabled. https://pluggy.readthedocs.io/en/latest/#call-monitoring """ __tracebackhide__ = True try: return func() except BaseException: return cls(None, sys.exc_info()) def force_result(self, result: Any): """Force the result(s) to ``result``. This may be used by hookwrappers to alter this result object. If the hook was marked as a ``firstresult`` a single value should be set otherwise set a (modified) list of results. Any exceptions found during invocation will be deleted. """ import inspect self._result = result self._excinfo = None self._modified_by = inspect.stack()[1].function @property def result(self) -> Union[Any, List[Any]]: """Return the result(s) for this hook call. If the hook was marked as a ``firstresult`` only a single value will be returned otherwise a list of results. """ __tracebackhide__ = True if self._excinfo is not None: _type, value, traceback = self._excinfo if value: raise value.with_traceback(traceback) return self._result def _multicall( hook_impls: List[HookImplementation], caller_kwargs: dict, firstresult: bool = False, ) -> HookResult: """The primary :class:`~napari_plugin_engine.HookImplementation` call loop. Parameters ---------- hook_impls : list A sequence of hook implementation (HookImplementation) objects caller_kwargs : dict Keyword:value pairs to pass to each ``hook_impl.function``. Every key in the dict must be present in the ``argnames`` property for each ``hook_impl`` in ``hook_impls``. firstresult : bool, optional If ``True``, return the first non-null result found, otherwise, return a list of results from all hook implementations, by default False Returns ------- outcome : HookResult A :class:`HookResult` object that contains the results returned by plugins along with other metadata about the call. Raises ------ HookCallError If one or more of the keys in ``caller_kwargs`` is not present in one of the ``hook_impl.argnames``. PluginCallError If ``firstresult == True`` and a plugin raises an Exception. """ __tracebackhide__ = True results = [] errors: List['PluginCallError'] = [] excinfo: Optional[ExcInfo] = None try: # run impl and wrapper setup functions in a loop teardowns = [] try: for hook_impl in reversed(hook_impls): # skip disabled hook implementations if not getattr(hook_impl, 'enabled', True): continue args: List[Any] = [] try: args = [ caller_kwargs[argname] for argname in hook_impl.argnames ] except KeyError: raise HookCallError( "hook call must provide argument the following " f"arguments: {set(hook_impl.argnames)!r}, but provided" f" {set(caller_kwargs)!r}" ) if hook_impl.hookwrapper: try: gen = hook_impl(*args) next(gen) # first yield teardowns.append(gen) except StopIteration: _raise_wrapfail(gen, "did not yield") else: res = None # this is where the plugin function actually gets called # we put it in a try/except so that if one plugin throws # an exception, we don't lose the whole loop try: res = hook_impl(*args) except Exception as exc: # creating a PluginCallError will store it for later # in plugins.exceptions.PLUGIN_ERRORS errors.append(PluginCallError(hook_impl, cause=exc)) # if it was a `firstresult` hook, break and raise now. if firstresult: break if res is not None: results.append((res, hook_impl)) if firstresult: # halt further impl calls break except BaseException: excinfo = sys.exc_info() finally: if firstresult and errors: raise errors[-1] outcome = HookResult( results, excinfo=excinfo, firstresult=firstresult, plugin_errors=errors, ) # run all wrapper post-yield blocks for gen in reversed(teardowns): try: gen.send(outcome) _raise_wrapfail(gen, "has second yield") except StopIteration: pass return outcome napari-napari-plugin-engine-67b3fb4/napari_plugin_engine/dist.py000066400000000000000000000111731514256550300251150ustar00rootroot00000000000000import inspect import sys from functools import lru_cache from typing import Any, Dict, Optional, overload if sys.version_info >= (3, 8): from importlib import metadata as importlib_metadata else: import importlib_metadata @lru_cache(maxsize=1) def _top_level_module_to_dist() -> Dict[str, importlib_metadata.Distribution]: mapping = {} for dist in importlib_metadata.distributions(): modules = dist.read_text('top_level.txt') if modules: for mod in modules.split('\n'): if not mod: continue mapping[mod] = dist return mapping def _object_to_top_level_module(obj: Any) -> Optional[str]: module = inspect.getmodule(obj) name = getattr(module, '__name__', None) return name.split('.')[0] if name else None def get_dist(obj) -> Optional[importlib_metadata.Distribution]: """Return a :class:`importlib.metadata.Distribution` for any python object. Parameters ---------- obj : Any A python object. If a string, will be interpreted as a dist name. Returns ------- dist: Distribution The distribution object for the corresponding package, if found. """ if isinstance(obj, str): try: return importlib_metadata.distribution(obj) except importlib_metadata.PackageNotFoundError: return None top_level = _object_to_top_level_module(obj) return _top_level_module_to_dist().get(top_level or '') def get_version(plugin) -> str: dist = get_dist(plugin) version = dist.metadata.get('version') if dist else '' if not version and inspect.ismodule(plugin): version = getattr(plugin, '__version__', '') if not version: top_module = _object_to_top_level_module(plugin) if top_module in sys.modules: version = getattr(sys.modules[top_module], '__version__', '') return str(version) if version else '' @overload def get_metadata(plugin, arg: str, *args: None) -> Optional[str]: ... @overload # noqa: F811 def get_metadata( # noqa: F811 plugin, arg: str, *args: str ) -> Dict[str, Optional[str]]: ... def get_metadata(plugin, *args): # noqa: F811 """Get metadata for this plugin. Valid arguments are any keys from the Core metadata specifications: https://packaging.python.org/specifications/core-metadata/ Parameters ---------- *args : str (Case insensitive) names of metadata entries to retrieve. Returns ------- str or dict, optional If a single argument is provided, the value for that entry is returned (which may be ``None``). If multiple arguments are provided, a dict of {arg: value} is returned. """ dist = get_dist(plugin) dct = {} if dist: for a in args: if a == 'version': dct[a] = get_version(plugin) else: dct[a] = dist.metadata.get(a) if len(args) == 1: return dct[args[0]] if dct else None return dct def standard_metadata(plugin: Any) -> Dict[str, Optional[str]]: """Return a standard metadata dict for ``plugin``. Parameters ---------- plugin : Any A python object. If a string, will be interpreted as a dist name. Returns ------- metadata : dict A dicts with plugin object metadata. The dict is guaranteed to have the following keys: - **package**: The name of the package - **version**: The version of the plugin package - **summary**: A one-line summary of what the distribution does - **author**: The author’s name - **email**: The author’s (or maintainer's) e-mail address. - **license**: The license covering the distribution - **url**: The home page for the package, or dowload url if N/A. Raises ------ ValueError If no distribution can be found for ``plugin``. """ meta = {} if not get_dist(plugin): _top_level_module_to_dist.cache_clear() if not get_dist(plugin): return {} # TODO: decide appropriate behavior here. # raise ValueError(f"could not find metadata for {plugin}") meta = get_metadata( plugin, 'name', 'version', 'summary', 'author', 'license', 'Author-Email', 'Home-page', ) meta['package'] = meta.pop('name') meta['email'] = meta.pop('Author-Email') or get_metadata( plugin, 'Maintainer-Email' ) meta['url'] = meta.pop('Home-page') or get_metadata(plugin, 'Download-Url') if meta['url'] == 'UNKNOWN': meta['url'] = None return meta napari-napari-plugin-engine-67b3fb4/napari_plugin_engine/exceptions.py000066400000000000000000000155361514256550300263420ustar00rootroot00000000000000import logging from enum import Enum from types import TracebackType from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Type, Union from .dist import standard_metadata if TYPE_CHECKING: from .manager import PluginManager # noqa: F401 ExcInfoTuple = Tuple[Type[Exception], Exception, Optional[TracebackType]] # https://www.python.org/dev/peps/pep-0484/#support-for-singleton-types-in-unions class Empty(Enum): token = 0 _empty = Empty.token class PluginError(Exception): """Base class for exceptions relating to plugins. Parameters ---------- message : str, optional An optional error message, by default '' namespace : Optional[Any], optional The python object that caused the error, by default None cause : Exception, optional Exception that caused the error. Same as ``raise * from``. by default None """ _record: List['PluginError'] = [] def __init__( self, message: str = '', *, plugin: Optional[Any] = None, plugin_name: Optional[str] = None, cause: Optional[BaseException] = None, ): self.plugin = plugin self.plugin_name = plugin_name if not message: name = plugin_name or getattr(plugin, '__name__', str(id(plugin))) message = f'Error in plugin "{name}"' if cause: message += f': {cause}' super().__init__(message) self.__cause__ = cause # store all PluginError instances. can be retrieved with get() PluginError._record.append(self) @classmethod def get( cls, *, plugin: Union[Any, Empty] = _empty, plugin_name: Union[str, Empty] = _empty, error_type: Union[Type['PluginError'], Empty] = _empty, ) -> List['PluginError']: """Return errors that have been logged, filtered by parameters. Parameters ---------- manager : PluginManager, optional If provided, will restrict errors to those that are owned by ``manager``. plugin_name : str If provided, will restrict errors to those that were raised by ``plugin_name``. error_type : Exception If provided, will restrict errors to instances of ``error_type``. Returns ------- list of PluginError A list of PluginErrors that have been instantiated during this session that match the provided parameters. Raises ------ TypeError If ``error_type`` is provided and is not an exception class. """ errors: List['PluginError'] = [] for error in cls._record: if plugin is not _empty and error.plugin != plugin: continue if plugin_name is not _empty and error.plugin_name != plugin_name: continue if error_type is not _empty: import inspect if not ( inspect.isclass(error_type) and issubclass(error_type, BaseException) ): raise TypeError( "The `error_type` argument must be an exception class" ) if not isinstance(error.__cause__, error_type): continue errors.append(error) return errors def format(self, package_info: bool = True): msg = f'PluginError: {self}' if self.__cause__: msg = msg.replace(str(self.__cause__), '').strip(": ") + "\n" cause = repr(self.__cause__).replace("\n", "\n" + " " * 13) msg += f' Cause was: {cause}' # show the exact file and line where the error occured cause_tb = self.__cause__.__traceback__ if cause_tb: while True: if not cause_tb.tb_next: break cause_tb = cause_tb.tb_next msg += f'\n in file: {cause_tb.tb_frame.f_code.co_filename}' msg += f'\n at line: {cause_tb.tb_lineno}' else: msg += "\n" if package_info and self.plugin: try: meta = standard_metadata(self.plugin) meta.pop('license', None) meta.pop('summary', None) if meta: msg += "\n" + "\n".join( [ f'{k: >11}: {v}' for k, v in sorted(meta.items()) if v ] ) except ValueError: pass msg += '\n' return msg def log( self, package_info: bool = True, logger: Union[logging.Logger, None, str] = None, level: int = logging.ERROR, ): """Log this error with metadata, optionally provide logger and level. Parameters ---------- package_info : bool, optional If true, will include package metadata in log, by default True logger : logging.Logger or str, optional A Logger instance or name of a logger to use, by default None level : int, optional The logging level to use, by default logging.ERROR """ if not isinstance(logger, logging.Logger): logger = logging.getLogger(logger) logger.log(level, self.format(package_info=package_info)) def info(self) -> ExcInfoTuple: """Return info as would be returned from sys.exc_info().""" return (self.__class__, self, self.__traceback__) class HookCallError(PluginError): """If a hook is called incorrectly. Usually this results when a HookCaller is called without the appropriate arguments. """ class PluginImportError(PluginError, ImportError): """Plugin module is unimportable.""" class PluginRegistrationError(PluginError): """If an unexpected error occurs during registration.""" class PluginImplementationError(PluginError): """Base class for errors pertaining to a specific hook implementation.""" def __init__(self, hook_implementation, msg=None, cause=None): plugin = hook_implementation.plugin plugin_name = hook_implementation.plugin_name specname = hook_implementation.specname if not msg: msg = f"Error in plugin '{plugin_name}', hook '{specname}'" if cause: msg += f": {str(cause)}" super().__init__( msg, plugin=plugin, plugin_name=plugin_name, cause=cause, ) class PluginValidationError(PluginImplementationError): """When a plugin implementation fails validation.""" class PluginCallError(PluginImplementationError): """Raised when an error is raised when calling a plugin implementation.""" napari-napari-plugin-engine-67b3fb4/napari_plugin_engine/hooks.py000066400000000000000000000515531514256550300253030ustar00rootroot00000000000000""" Internal hook annotation, representation and calling machinery. """ import warnings from collections.abc import Sequence from typing import Any, Callable, List, Optional, Union from .callers import HookCallError, HookResult, _multicall from .exceptions import PluginCallError from .implementation import HookImplementation, HookSpecification HookExecFunc = Callable[ ['HookCaller', List[HookImplementation], dict], HookResult ] """A function that loops calling a list of :class:`~napari_plugin_engine.HookImplementation` s and returns a :class:`~napari_plugin_engine.HookResult`. Parameters ---------- hook_caller : HookCaller a :class:`HookCaller` instance. hook_impls : List[HookImplementation] a list of :class:`~napari_plugin_engine.HookImplementation` instances to call. kwargs : dict a mapping of keyword arguments to provide to the implementation. Returns ------- result : HookResult The :class:`~napari_plugin_engine.HookResult` object resulting from the call loop. """ class HookCaller: """The primary hook-calling object. A :class:`PluginManager` may have multiple ``HookCaller`` objects and they are stored in the ``plugin_manager.hook`` namespace, named after the `hook specification` that they represent. For instance: .. code-block:: python pm = PluginManager("demo") pm.add_hookspec(some_module) # assuming `some_module` had an @hookspec named `my specification` assert isinstance(pm.hook.my_specification, HookCaller) Each ``HookCaller`` instance stores all of the :class:`HookImplementation` objects discovered during :meth:`plugin registration ` (each of which capture the implementation of a specific plugin for this hook specification). The ``HookCaller`` instance also usually creates and stores a reference to the :class:`HookSpecification` instance that encapsulates information about the hook specification, (at ``HookCaller.spec``) Parameters ---------- name : str The name of the `hook specification` that this ``HookCaller`` represents. hook_execute : Callable A :data:`.HookExecFunc` function. In almost every case, this will be provided by the :class:`PluginManager` during hook registration as :meth:`PluginManager._hookexec`... which is, in turn, mostly just a wrapper around :func:`._multicall`. namespace : Any, optional An namespace (such as a module or class) to search during `HookSpecification` creation for functions decorated with ``@hookspec`` named with the string ``name``. spec_opts : Optional[dict], optional keyword arguments to be passed when creating the :class:`HookSpecification` instance at ``self.spec``. """ def __init__( self, name: str, hook_execute: HookExecFunc, namespace: Any = None, spec_opts: Optional[dict] = None, ): self.name = name self._wrappers: List[HookImplementation] = [] self._nonwrappers: List[HookImplementation] = [] self._hookexec = hook_execute self.argnames = None self.kwargnames = None self.multicall = _multicall self.spec: Optional[HookSpecification] = None if namespace is not None: assert spec_opts is not None self.set_specification(namespace, spec_opts) def has_spec(self) -> bool: return self.spec is not None @property def is_firstresult(self) -> bool: return self.spec.firstresult if self.spec else False def set_specification(self, namespace, spec_opts): assert not self.has_spec() self.spec = HookSpecification(namespace, self.name, **spec_opts) if spec_opts.get("historic"): self._call_history = [] def is_historic(self) -> bool: return hasattr(self, "_call_history") def _remove_plugin(self, plugin: Any): def remove(wrappers): for i, method in enumerate(wrappers): if method.plugin == plugin: del wrappers[i] return True if remove(self._wrappers) is None: if remove(self._nonwrappers) is None: raise ValueError("plugin %r not found" % (plugin,)) def get_hookimpls(self) -> List[HookImplementation]: # Order is important for _hookexec return self._nonwrappers + self._wrappers def _add_hookimpl(self, hookimpl: HookImplementation): """Add an implementation to the callback chain.""" if hookimpl.hookwrapper: methods = self._wrappers else: methods = self._nonwrappers if hookimpl.trylast: methods.insert(0, hookimpl) elif hookimpl.tryfirst: methods.append(hookimpl) else: # find last non-tryfirst method i = len(methods) - 1 while i >= 0 and methods[i].tryfirst: i -= 1 methods.insert(i + 1, hookimpl) def __repr__(self) -> str: return f"" def call_historic( self, result_callback=None, kwargs=None, with_impl=False ): """Call the hook with given ``kwargs`` for all registered plugins and for all plugins which will be registered afterwards. If ``result_callback`` is not ``None`` it will be called for for each non-``None`` result obtained from a hook implementation. If ``with_impl`` is ``True``, the caller is indicating that ``result_callback`` has a signature of ``callback(result, hookimpl)``, and will be called as such. """ if result_callback is not None: result_callback._wants_impl = with_impl self._call_history.append((kwargs or {}, result_callback)) # historizing hooks don't return results res = self._hookexec(self, self.get_hookimpls(), kwargs) if result_callback is None: return # XXX: remember firstresult isn't compat with historic if with_impl: for result, impl in zip(res.result, res.implementation): result_callback(result, impl) else: for x in res.result or []: result_callback(x) def call_extra(self, methods: List[Callable], kwargs: dict): """Call the hook with some additional temporarily participating methods using the specified ``kwargs`` as call parameters.""" old = list(self._nonwrappers), list(self._wrappers) for method in methods: self._add_hookimpl(HookImplementation(method)) try: return self(**kwargs) finally: self._nonwrappers, self._wrappers = old def _maybe_apply_history(self, method): """Apply call history to a new hookimpl if it is marked as historic.""" if self.is_historic(): for kwargs, result_callback in self._call_history: res = self._hookexec(self, [method], kwargs) if res.result and result_callback is not None: if getattr(result_callback, '_wants_impl', False): result_callback(res.result[0], res.implementation[0]) else: result_callback(res.result[0]) def get_plugin_implementation(self, plugin_name: str): """Return hook implementation instance for ``plugin_name`` if found.""" try: return next( imp for imp in self.get_hookimpls() if imp.plugin_name == plugin_name ) except StopIteration: raise KeyError( f"No implementation of {self.name!r} found " f"for plugin {plugin_name!r}." ) def index(self, value: Union[str, HookImplementation]) -> int: """Return index of plugin_name or a HookImplementation in self._nonwrappers""" if isinstance(value, HookImplementation): return self._nonwrappers.index(value) elif isinstance(value, str): plugin_names = [imp.plugin_name for imp in self._nonwrappers] return plugin_names.index(value) else: raise TypeError( "argument provided to index must either be the " "(string) name of a plugin, or a HookImplementation instance" ) def bring_to_front( self, new_order: Union[List[str], List[HookImplementation]] ): """Move items in ``new_order`` to the front of the call order. By default, hook implementations are called in last-in-first-out order of registration, and pluggy does not provide a built-in way to rearrange the call order of hook implementations. This function accepts a :class:`HookCaller` instance and the desired ``new_order`` of the hook implementations (in the form of list of plugin names, or a list of actual :class:`HookImplementation` instances) and reorders the implementations in the hook caller accordingly. .. note:: Hook implementations are actually stored in *two* separate list attributes in the hook caller: :attr:`HookCaller._wrappers` and :attr:`HookCaller._nonwrappers`, according to whether the corresponding :class:`HookImplementation` instance was marked as a wrapper or not. This method *only* sorts _nonwrappers. Parameters ---------- new_order : list of str or list of :class:`HookImplementation` instances The desired CALL ORDER of the hook implementations. The list does *not* need to include every hook implementation in :meth:`get_hookimpls`, but those that are not included will be left at the end of the call order. Raises ------ TypeError If any item in ``new_order`` is neither a string (plugin_name) or a ``HookImplementation`` instance. ValueError If any item in ``new_order`` is neither the name of a plugin or a ``HookImplementation`` instance that is present in self._nonwrappers. ValueError If ``new_order`` argument has multiple entries for the same implementation. Examples -------- Imagine you had a hook specification named ``print_plugin_name``, that expected plugins to simply print their own name. An implementation might look like: >>> # hook implementation for ``plugin_1`` >>> @hook_implementation ... def print_plugin_name(): ... print("plugin_1") If three different plugins provided hook implementations. An example call for that hook might look like: >>> plugin_manager.hook.print_plugin_name() plugin_1 plugin_2 plugin_3 If you wanted to rearrange their call order, you could do this: >>> new_order = ["plugin_2", "plugin_3", "plugin_1"] >>> plugin_manager.hook.print_plugin_name.bring_to_front(new_order) >>> plugin_manager.hook.print_plugin_name() plugin_2 plugin_3 plugin_1 You can also just specify one or more item to move them to the front of the call order: >>> plugin_manager.hook.print_plugin_name.bring_to_front(["plugin_3"]) >>> plugin_manager.hook.print_plugin_name() plugin_3 plugin_2 plugin_1 """ if not isinstance(new_order, Sequence) or isinstance(new_order, str): raise TypeError( 'The first argument to "bring_to_front" ' 'must be a non-string sequence type.' ) # make sure items in order are unique if len(new_order) != len(set(new_order)): raise ValueError("repeated item in order") # make new lists for the rearranged _nonwrappers # for details on the difference between wrappers and nonwrappers, see: # https://pluggy.readthedocs.io/en/latest/#wrappers _old_nonwrappers = self._nonwrappers.copy() _new_nonwrappers: List[HookImplementation] = [] indices = [self.index(elem) for elem in new_order] for i in indices: # inserting because they get called in reverse order. _new_nonwrappers.insert(0, _old_nonwrappers[i]) # remove items that have been pulled, leaving only items that # were not specified in ``new_order`` argument # do this rather than using .pop() above to avoid changing indices for i in sorted(indices, reverse=True): del _old_nonwrappers[i] # if there are any hook_implementations left over, add them to the # beginning of their respective lists (because at call time, these # lists are called in reverse order) if _old_nonwrappers: _new_nonwrappers = [x for x in _old_nonwrappers] + _new_nonwrappers # update the _nonwrappers list with the reordered list self._nonwrappers = _new_nonwrappers def _set_plugin_enabled(self, plugin_name: str, enabled: bool): """Enable or disable the hook implementation for a specific plugin. Parameters ---------- plugin_name : str The name of a plugin implementing ``hook_spec``. enabled : bool Whether or not the implementation should be enabled. Raises ------ KeyError If ``plugin_name`` has not provided a hook implementation for this hook specification. """ self.get_plugin_implementation(plugin_name).enabled = enabled def enable_plugin(self, plugin_name: str): """enable implementation for ``plugin_name``.""" self._set_plugin_enabled(plugin_name, True) def disable_plugin(self, plugin_name: str): """disable implementation for ``plugin_name``.""" self._set_plugin_enabled(plugin_name, False) def _call_plugin(self, plugin_name: str, *args, **kwargs): """Call the hook implementation for a specific plugin .. note:: This method is not intended to be called directly. Instead, just call the instance directly, specifing the ``_plugin`` argument. See the :meth:`__call__` method. Parameters ---------- plugin_name : str Name of the plugin Returns ------- Any Result of implementation call provided by plugin Raises ------ TypeError If the implementation is a hook wrapper (cannot be called directly) TypeError If positional arguments are provided HookCallError If one of the required arguments in the hook specification is not present in ``kwargs``. PluginCallError If an exception is raised when calling the plugin """ self._check_call_kwargs(kwargs) implementation = self.get_plugin_implementation(plugin_name) if implementation.hookwrapper: raise TypeError("Hook wrappers can not be called directly") # pluggy only allows calling hooks with keyword arguments if args: raise TypeError("hook calling supports only keyword arguments") _args: List[Any] = [] # this converts kwargs into positional arguments in the correct order # for the hookspec try: _args = [kwargs[argname] for argname in implementation.argnames] except KeyError: for argname in implementation.argnames: if argname not in kwargs: raise HookCallError( f"hook call must provide argument {argname}" ) try: return implementation(*_args) except Exception as exc: raise PluginCallError(implementation) from exc def call_with_result_obj( self, *, _skip_impls: List[HookImplementation] = list(), **kwargs ) -> HookResult: """Call hook implementation(s) for this spec and return HookResult. The :class:`HookResult` object carries the result (in its ``result`` property) but also additional information about the hook call, such as the implementation that returned each result and any call errors. Parameters ---------- _skip_impls : List[HookImplementation], optional A list of HookImplementation instances that should be *skipped* when calling hook implementations, by default None **kwargs keys should match the names of arguments in the corresponding hook specification, values will be passed as arguments to the hook implementations. Returns ------- result : HookResult A :class:`HookResult` object that contains the results returned by plugins along with other metadata about the call. Raises ------ HookCallError If one or more of the keys in ``kwargs`` is not present in one of the ``hook_impl.argnames``. PluginCallError If ``firstresult == True`` and a plugin raises an Exception. """ # if not self.get_hookimpls(): # warnings.warn( # 'No hook implementations registered for this hook caller!' # ) self._check_call_kwargs(kwargs) impls = [imp for imp in self.get_hookimpls() if imp not in _skip_impls] return self._hookexec(self, impls, kwargs) def __call__( self, *args, _plugin: Optional[str] = None, _skip_impls: List[HookImplementation] = list(), **kwargs, ) -> Union[Any, List[Any]]: """Call hook implementation(s) for this spec and return result(s). This is the primary way to call plugin hook implementations. .. note:: Parameters are prefaced by underscores to reduce potential conflicts with argument names in hook specifications. There is a test in :func:`test_hook_specifications.test_annotation_on_hook_specification` to ensure that these argument names are never used in one of our hookspecs. Parameters ---------- _plugin : str, optional The name of a specific plugin to use. By default all implementations will be called (though if ``firstresult==True``, only the first non-None result will be returned). _skip_impls : List[HookImplementation], optional A list of HookImplementation instances that should be *skipped* when calling hook implementations, by default None **kwargs keys should match the names of arguments in the corresponding hook specification, values will be passed as arguments to the hook implementations. Raises ------ HookCallError If one or more of the keys in ``kwargs`` is not present in one of the ``hook_impl.argnames``. PluginCallError If ``firstresult == True`` and a plugin raises an Exception. Returns ------- result If the hookspec was declared with ``firstresult==True``, a single result will be returned. Otherwise will return a list of results from all hook implementations for this hook caller. If ``_plugin`` is provided, will return the single result from the specified plugin. """ if args: raise TypeError("hook calling supports only keyword arguments") if _plugin: # if a plugin name is specified, just call it directly return self._call_plugin(_plugin, **kwargs) result = self.call_with_result_obj(_skip_impls=_skip_impls, **kwargs) return result.result def _check_call_kwargs(self, kwargs): """Warn if any keys in the hookspec are not present in this call. It's possible to add arguments to hook specifications (as they evolve). Here we just emit a warning if there are arguments in the hookspec that were not specified in this call, which may mean the call could be updated. """ # "historic" hooks can be called with ``call_historic()`` *before* # having been registered. However they must be called with # self.call_historic(). # https://pluggy.readthedocs.io/en/latest/index.html#historic-hooks assert ( not self.is_historic() ), 'Historic hooks must be called with `call_historic()`' if self.spec and self.spec.argnames: notincall = set(self.spec.argnames) - set(kwargs.keys()) if notincall: warnings.warn( "Argument(s) {} which are declared in the hookspec " "can not be found in this hook call".format( tuple(notincall) ), stacklevel=2, ) napari-napari-plugin-engine-67b3fb4/napari_plugin_engine/implementation.py000066400000000000000000000117021514256550300271750ustar00rootroot00000000000000import inspect import sys from typing import Any, Callable, Optional class HookImplementation: """A class to encapsulate hook implementations.""" TAG_SUFFIX = "_impl" def __init__( self, function: Callable, plugin: Optional[Any] = None, plugin_name: Optional[str] = None, hookwrapper: bool = False, optionalhook: bool = False, tryfirst: bool = False, trylast: bool = False, specname: str = '', enabled: bool = True, ): self.function = function self.argnames, self.kwargnames = varnames(self.function) self.plugin = plugin self.plugin_name = plugin_name self.hookwrapper = hookwrapper self.optionalhook = optionalhook self.tryfirst = tryfirst self.trylast = trylast self._specname = specname self.enabled = enabled @classmethod def format_tag(cls, project_name): return project_name + cls.TAG_SUFFIX @property def opts(self) -> dict: # legacy return { x: getattr(self, x) for x in [ 'hookwrapper', 'optionalhook', 'tryfirst', 'trylast', 'specname', ] } def __repr__(self) -> str: # these are all False by default truthy = [ attr for attr in ('hookwrapper', 'optionalhook', 'tryfirst', 'trylast') if getattr(self, attr) ] suffix = (' ' + " ".join(truthy)) if truthy else '' return ( f"" ) def __call__(self, *args): return self.function(*args) @property def specname(self) -> str: return self._specname or self.function.__name__ class HookSpecification: """A class to encapsulate hook specifications.""" TAG_SUFFIX = "_spec" def __init__( self, namespace: Any, name: str, *, firstresult: bool = False, historic: bool = False, warn_on_impl: Optional[Warning] = None, ): self.namespace = namespace self.name = name self.function = getattr(namespace, name) self.argnames, self.kwargnames = varnames(self.function) for reserved in ('_plugin', '_skip_impls'): if reserved in self.argnames: raise ValueError( f'Hook specifications may not have argument: "{reserved}".' ) self.firstresult = firstresult self.historic = historic self.warn_on_impl = warn_on_impl @classmethod def format_tag(cls, project_name): return project_name + cls.TAG_SUFFIX @property def opts(self) -> dict: # legacy return { 'firstresult': self.firstresult, 'historic': self.historic, 'warn_on_impl': self.warn_on_impl, } def __repr__(self) -> str: # these are all False by default truthy = [ attr for attr in ('firstresult', 'historic', 'warn_on_impl') if getattr(self, attr) ] suffix = (' ' + " ".join(truthy)) if truthy else '' return ( f"" ) # TODO: can this be improved? def varnames(func): """Return tuple of positional and keywrord argument names for a function, method, class or callable. In case of a class, its ``__init__`` method is considered. For methods the ``self`` parameter is not included. """ cache = getattr(func, "__dict__", {}) try: return cache["_varnames"] except KeyError: pass if inspect.isclass(func): try: func = func.__init__ except AttributeError: return (), () elif not inspect.isroutine(func): # callable object? try: func = getattr(func, "__call__", func) except Exception: return (), () try: # func MUST be a function or method here or we won't parse any args spec = inspect.getfullargspec(func) except TypeError: return (), () args, defaults = tuple(spec.args), spec.defaults if defaults: index = -len(defaults) args, kwargs = args[:index], tuple(args[index:]) else: kwargs = () # strip any implicit instance arg # pypy3 uses "obj" instead of "self" for default dunder methods _PYPY3 = hasattr(sys, "pypy_version_info") and sys.version_info.major == 3 implicit_names = ("self",) if not _PYPY3 else ("self", "obj") if args: if inspect.ismethod(func) or ( "." in getattr(func, "__qualname__", ()) and args[0] in implicit_names ): args = args[1:] try: cache["_varnames"] = args, kwargs except TypeError: pass return args, kwargs napari-napari-plugin-engine-67b3fb4/napari_plugin_engine/manager.py000066400000000000000000001215411514256550300255650ustar00rootroot00000000000000import functools import importlib import inspect import os import re import sys import warnings from contextlib import contextmanager from logging import getLogger from pathlib import Path from typing import ( Any, Callable, Dict, Generator, List, Optional, Set, Tuple, Type, Union, ) from . import _tracing from .callers import HookResult from .dist import ( _top_level_module_to_dist, get_metadata, importlib_metadata, standard_metadata, ) from .exceptions import ( Empty, PluginError, PluginImportError, PluginRegistrationError, PluginValidationError, _empty, ) from .hooks import HookCaller, HookExecFunc from .implementation import HookImplementation, HookSpecification from .markers import HookImplementationMarker, HookSpecificationMarker logger = getLogger(__name__) class PluginManager: """Core class which manages registration of plugin objects and hook calls. You can register new hooks by calling :meth:`~PluginManager.add_hookspecs`. You can register plugin objects (which contain hooks) by calling :meth:`~PluginManager.register`. The ``PluginManager`` is initialized with a ``project_name`` that is used when discovering *hook specifications* and *hook implementations*. For debugging purposes you may call :meth:`.PluginManager.enable_tracing` which will subsequently send debug information to the trace helper. Parameters ---------- project_name : str The name of the host project. All :class:`HookImplementationMarker` and :class:`HookSpecificationMarker` instances must be created using the same ``project_name`` to be detected by this plugin manager. discover_entry_point : str, optional The default entry_point group to search when discovering plugins with :meth:`PluginManager.discover`, by default None discover_prefix : str, optional The default module prefix to use when discovering plugins with :meth:`PluginManager.discover`, by default None discover_path : str or list of str, optional A path or paths to include when discovering plugins with :meth:`PluginManager.discover`, by default None Examples -------- .. code-block:: python from napari_plugin_engine import PluginManager import my_hookspecs plugin_manager = PluginManager( 'my_project', discover_entry_point='app.plugin', discover_prefix='app_', ) plugin_manager.add_hookspecs(my_hookspecs) plugin_manager.discover() # hooks now live in plugin_manager.hook # plugin dict is at plugin_manager.plugins """ def __init__( self, project_name: str, *, discover_entry_point: Optional[str] = None, discover_prefix: Optional[str] = None, discover_path: Optional[List[str]] = None, ): self.project_name = project_name self.discover_entry_point = discover_entry_point self.discover_prefix = discover_prefix self.discover_path = discover_path or [] #: dict : mapping of ``plugin_name`` → ``plugin`` (object) #: #: Plugins get added to this dict in :meth:`~PluginManager.register` self.plugins: Dict[str, Any] = {} #: dict : mapping of ``plugin`` (object) → list of :class:`HookCaller` #: #: :class:`HookCaller` s get added in :meth:`~PluginManager.register` self._plugin2hookcallers: Dict[Any, List[HookCaller]] = {} self._blocked: Set[str] = set() # multiple plugins might register the same entry point # _id_counts tracks the count of each identical entry point self._id_counts: Dict[str, int] = {} self.trace = _tracing.TagTracer().get("pluginmanage") self.hook = _HookRelay(self) self._inner_hookexec: HookExecFunc = lambda c, m, k: c.multicall( m, k, firstresult=c.is_firstresult ) @property def hooks(self) -> '_HookRelay': """An alias for PluginManager.hook""" return self.hook def _hookexec( self, caller: HookCaller, methods: List[HookImplementation], kwargs: dict, ) -> HookResult: """Returns a function that will call a set of hookipmls with a caller. This function will be passed to ``HookCaller`` instances that are created during hookspec and plugin registration. If :meth:`~.PluginManager.enable_tracing` is used, it will set it's own wrapper function at self._inner_hookexec to enable tracing of hook calls. Parameters ---------- caller : HookCaller The HookCaller instance that will call the HookImplementations. methods : List[HookImplementation] A list of :class:`~napari_plugin_engine.HookImplementation` objects whose functions will be called during the hook call loop. kwargs : dict Keyword arguments to pass when calling the ``HookImplementation``. Returns ------- :class:`~napari_plugin_engine.HookResult` The result object produced by the multicall loop. """ return self._inner_hookexec(caller, methods, kwargs) def iter_available( self, path: Optional[str] = None, entry_point: Optional[str] = None, prefix: Optional[str] = None, ) -> Generator[Tuple[str, str, Optional[str]], None, None]: """Iterate over available plugins. Parameters ---------- path : str, optional If a string is provided, it is added to ``sys.path`` (and ``self.discover_path``) before importing, and removed at the end. entry_point : str, optional An entry_point group to search for, by default ``self.discover_entry_point`` is used prefix : str, optional If ``provided``, modules in the environment starting with ``prefix`` will be imported and searched for hook implementations by default ``self.discover_prefix`` is used Notes ----- See docstring of :func:`iter_available_plugins` for details. """ _path = self.discover_path if path: _path.append(path) yield from iter_available_plugins( entry_point or self.discover_entry_point, prefix or self.discover_prefix, _path, include_uninstalled=bool(self.discover_prefix), ) def discover( self, path: Optional[str] = None, entry_point: Optional[str] = None, prefix: Optional[str] = None, ignore_errors: bool = True, ) -> Tuple[int, List[PluginError]]: """Discover and load plugins. Parameters ---------- path : str, optional If a string is provided, it is added to ``sys.path`` (and ``self.discover_path``) before importing, and removed at the end. entry_point : str, optional An entry_point group to search for, by default ``self.discover_entry_point`` is used prefix : str, optional If ``provided``, modules in the environment starting with ``prefix`` will be imported and searched for hook implementations by default ``self.discover_prefix`` is used ignore_errors : bool, optional If ``True``, errors will be gathered and returned at the end. Otherwise, they will be raised immediately. by default True Returns ------- (count, errs) : Tuple[int, List[PluginError]] The number of succefully loaded modules, and a list of errors that occurred (if ``ignore_errors`` was ``True``) """ self.hook._needs_discovery = False # allow debugging escape hatch if os.environ.get("DISABLE_ALL_PLUGINS"): warnings.warn( 'Plugin discovery disabled due to ' 'environmental variable "DISABLE_ALL_PLUGINS"' ) return 0, [] errs: List[PluginError] = [] count = 0 for name, mod_name, dist_name in self.iter_available( path, entry_point, prefix ): old_name = name # different plugin has already registered this entry point if self.is_registered(name): mod_names = ( plugin_mod.__name__ for plugin_mod in self.plugins.values() ) # we may have registered this entry point under a different name, # so check module names to avoid duplicate registration if mod_name in mod_names: continue new_name = f"{name}-{self._id_counts[name]}" previously_registered_mod = self.plugins[name].__name__ warnings.warn( f"Plugin {name} already registered by module " + f"{previously_registered_mod}! Registering as {new_name}." ) name = new_name elif self.is_blocked(name): continue try: if self._load_and_register(mod_name, name): count += 1 self._id_counts[name] = 1 self._id_counts[old_name] += 1 except PluginError as e: errs.append(e) # commenting out for now, because napari stores this blockage # too permanently, and it's hard to differentiate between # plugins intentionally blocked by the user. # self.set_blocked(name) if ignore_errors: continue raise e return count, errs @contextmanager def discovery_blocked(self) -> Generator: """A context manager that temporarily blocks discovery of new plugins.""" current = self.hook._needs_discovery self.hook._needs_discovery = False try: yield finally: self.hook._needs_discovery = current def _load_and_register( self, mod_name: str, plugin_name: Optional[str] = None ) -> Optional[str]: """A helper function to import and register a module as ``plugin_name``. Parameters ---------- mod : str The name of a module (or class in a module) to load. plugin_name : str, optional Optional name for plugin, by default ``get_canonical_name(plugin)`` Returns ------- str or None canonical plugin name, or ``None`` if the name is blocked from registering. Raises ------ PluginImportError If an exception is raised when importing the module. PluginRegistrationError If an entry_point is declared that is neither a module nor a class. PluginRegistrationError If an exception is raised during plugin registration. """ try: module = load(mod_name) if self.is_registered(module): return None except Exception as exc: raise PluginImportError( f'Error while importing module {mod_name}', plugin_name=plugin_name, cause=exc, ) if not (inspect.isclass(module) or inspect.ismodule(module)): raise PluginRegistrationError( f'Plugin "{plugin_name}" declared entry_point "{mod_name}"' ' which is neither a module nor a class.', plugin=module, plugin_name=plugin_name, ) try: return self.register(module, plugin_name) except PluginError: raise except Exception as exc: raise PluginRegistrationError( plugin=module, plugin_name=plugin_name, cause=exc ) def register( self, namespace: Any, name: Optional[str] = None ) -> Optional[str]: """Register a plugin and return its canonical name or ``None``. Parameters ---------- namespace : Any The namespace (class, module, dict, etc...) of the plugin to register name : str, optional Optional name for plugin, by default ``get_canonical_name(plugin)`` Returns ------- str or None canonical plugin name, or ``None`` if the name is blocked from registering. Raises ------ TypeError If ``namespace`` is a string. ValueError if the plugin ``name`` or ``namespace`` is already registered. """ if isinstance(namespace, str): raise TypeError("Plugin objects cannot be strings.") if isinstance(namespace, dict): return self._register_dict(namespace, name) plugin_name = name or get_canonical_name(namespace) if self.is_blocked(plugin_name): return None if self.is_registered(plugin_name): raise ValueError(f"Plugin name already registered: {plugin_name}") if self.is_registered(namespace): raise ValueError(f"Plugin module already registered: {namespace}") hookcallers = [] for hookimpl in iter_implementations(namespace, self.project_name): hookimpl.plugin_name = plugin_name hook_caller = getattr(self.hook, hookimpl.specname, None) # if we don't yet have a hookcaller by this name, create one. if hook_caller is None: hook_caller = HookCaller(hookimpl.specname, self._hookexec) setattr(self.hook, hookimpl.specname, hook_caller) # otherwise, if it has a specification, validate the new # hookimpl against the specification. elif hook_caller.has_spec(): self._verify_hook(hook_caller, hookimpl) hook_caller._maybe_apply_history(hookimpl) # Finally, add the hookimpl to the hook_caller and the hook # caller to the list of callers for this plugin. hook_caller._add_hookimpl(hookimpl) hookcallers.append(hook_caller) self._plugin2hookcallers[namespace] = hookcallers if not hookcallers: ns_name = get_canonical_name(namespace) warnings.warn( f"Module {ns_name!r} from plugin {plugin_name!r} has no hooks! " "Consider disabling or uninstalling this plugin. " "If you are a developer, please check your entry point." ) self.plugins[plugin_name] = namespace return plugin_name def _register_dict( self, dct: Dict[str, Callable], name: Optional[str] = None, **kwargs ) -> Optional[str]: """Register a dict as a mapping of method name -> method. Parameters ---------- dct : Dict[str, Callable] Mapping of method name to method. name : Optional[str], optional The plugin_name to assign to this object, by default None Returns ------- str or None canonical plugin name, or ``None`` if the name is blocked from registering. """ mark = HookImplementationMarker(self.project_name) clean_dct = { key: mark(specname=key, **kwargs)(val) for key, val in dct.items() if inspect.isfunction(val) } namespace = ensure_namespace(clean_dct) return self.register(namespace, name) def get_name(self, plugin): """Return name for registered plugin or ``None`` if not registered.""" for name, val in self.plugins.items(): if plugin == val: return name def _ensure_plugin(self, name_or_object: Any) -> Any: """Return plugin object given a name or object. Or raise an exception. Parameters ---------- name_or_object : Any Either a string (in which case it is interpreted as a plugin name), or a non-string object (in which case it is assumed to be a plugin module or class). Returns ------- Any The plugin object, if found. Raises ------ KeyError If the plugin does not exist. """ if isinstance(name_or_object, str): plugin_name = name_or_object else: plugin_name = self.get_name(name_or_object) if plugin_name in self.plugins: return self.plugins[plugin_name] if isinstance(name_or_object, str): msg = f"No plugin found with the name {name_or_object}" else: msg = f"No plugin found with the name {name_or_object}" raise KeyError(msg) def unregister(self, name_or_object: Any) -> Optional[Any]: """Unregister a plugin object or ``plugin_name``. Parameters ---------- name_or_object : str or Any A module/class object or a plugin name (string). Returns ------- module : Any or None The module object, or None if the ``name_or_object`` was not found. """ try: plugin = self._ensure_plugin(name_or_object) except KeyError as e: warnings.warn(str(e)) return None del self.plugins[self.get_name(plugin)] for hookcaller in self._plugin2hookcallers.pop(plugin, []): hookcaller._remove_plugin(plugin) return plugin def prune(self): """Unregister modules that can no longer be imported. Useful if pip uninstall has been run during the session. """ _top_level_module_to_dist.cache_clear() for plugin_module in list(self.plugins.values()): try: importlib.reload(plugin_module) except ModuleNotFoundError: self.unregister(plugin_module) def _add_hookspec_dict(self, dct: Dict[str, Callable], **kwargs): mark = HookSpecificationMarker(self.project_name) clean_dct = { key: mark(**kwargs)(val) for key, val in dct.items() if inspect.isfunction(val) } namespace = ensure_namespace(clean_dct) return self.add_hookspecs(namespace) def add_hookspecs(self, namespace: Any): """Add new hook specifications defined in the given ``namespace``. Functions are recognized if they have been decorated accordingly. """ if isinstance(namespace, dict): return self._add_hookspec_dict(namespace) names = [] for name in dir(namespace): method = getattr(namespace, name) if not inspect.isroutine(method): continue tag = HookSpecification.format_tag(self.project_name) spec_opts = getattr(method, tag, None) if spec_opts is not None: hook_caller = getattr(self.hook, name, None) if hook_caller is None: hook_caller = HookCaller( name, self._hookexec, namespace, spec_opts ) setattr(self.hook, name, hook_caller) else: # plugins registered this hook without knowing the spec hook_caller.set_specification(namespace, spec_opts) for hookfunction in hook_caller.get_hookimpls(): self._verify_hook(hook_caller, hookfunction) names.append(name) if not names: raise ValueError( f"did not find any {self.project_name!r} hooks in {namespace!r}" ) def is_registered(self, obj: Any) -> bool: """Return ``True`` if the plugin is already registered.""" if isinstance(obj, str): return obj in self.plugins return obj in self._plugin2hookcallers def is_blocked(self, plugin_name: str) -> bool: """Return ``True`` if the given plugin name is blocked.""" return plugin_name in self._blocked def set_blocked(self, plugin_name: str, blocked=True): """Block registrations of ``plugin_name``, unregister if registered. Parameters ---------- plugin_name : str A plugin name to block. blocked : bool, optional Whether to block the plugin. If ``False`` will "unblock" ``plugin_name``. by default True """ if blocked: self._blocked.add(plugin_name) if self.is_registered(plugin_name): self.unregister(plugin_name) else: if plugin_name in self._blocked: self._blocked.remove(plugin_name) def get_errors( self, plugin: Union[Any, Empty] = _empty, error_type: Union[Type['PluginError'], Empty] = _empty, ) -> List[PluginError]: """Return a list of PluginErrors associated with ``plugin``. Parameters ---------- plugin : Any If provided, will restrict errors to those that were raised by ``plugin``. If a string is provided, it will be interpreted as the name of the plugin, otherwise it is assumed to be the actual plugin object itself. error_type : PluginError If provided, will restrict errors to instances of ``error_type``. """ # not using _ensure_plugin because it may not have been successfully # registered plugin_name: Union[str, Empty] = _empty if isinstance(plugin, str): plugin_name = plugin plugin = _empty return PluginError.get( plugin=plugin, plugin_name=plugin_name, error_type=error_type ) def _verify_hook( self, hook_caller: HookCaller, hookimpl: HookImplementation ): """Check validity of a ``hookimpl`` Parameters ---------- hook_caller : HookCaller A :class:`HookCaller` instance. hookimpl : HookImplementation A :class:`HookImplementation` instance, implementing the hook in ``hook_caller``. Raises ------ PluginValidationError If hook_caller is historic and the hookimpl is a hookwrapper. PluginValidationError If there are any argument names in the ``hookimpl`` that are not in the ``hook_caller.spec``. Warns ----- Warning If the hookspec has ``warn_on_impl`` flag (usually a deprecation). """ # historic hooks cannot have hookwrappers if hook_caller.is_historic() and hookimpl.hookwrapper: raise PluginValidationError( hookimpl, f"Plugin {hookimpl.plugin_name!r}\nhook " f"{hook_caller.name!r}\nhistoric incompatible to hookwrapper", ) if not hook_caller.spec: return # If the hookspec has ``warn_on_impl`` flag show a warning. if hook_caller.spec.warn_on_impl: warnings.warn_explicit( hook_caller.spec.warn_on_impl, type(hook_caller.spec.warn_on_impl), lineno=hookimpl.function.__code__.co_firstlineno, filename=hookimpl.function.__code__.co_filename, ) # If there are any argument names in the hookimpl that are not # in the hook specification. notinspec = set(hookimpl.argnames) - set(hook_caller.spec.argnames) if notinspec: raise PluginValidationError( hookimpl, f"Plugin {hookimpl.plugin_name!r} for hook {hook_caller.name!r}" f"\nhookimpl definition: {_formatdef(hookimpl.function)}\n" f"Argument(s) {notinspec} are declared in the hookimpl but " "can not be found in the hookspec", ) def check_pending(self): """Make sure all hooks have a specification, or are optional. Raises ------ PluginValidationError If a hook implementation that was *not* marked as ``optionalhook`` has been registered for a non-existent hook specification. """ for name in self.hook.__dict__: if name.startswith("_"): continue hook = getattr(self.hook, name) if not hook.has_spec(): for hookimpl in hook.get_hookimpls(): if not hookimpl.optionalhook: raise PluginValidationError( hookimpl, f"unknown hook {name!r} in " f"plugin {hookimpl.plugin!r}", ) def get_hookcallers(self, plugin: Any) -> Optional[List[HookCaller]]: """get all hook callers for the specified plugin.""" return self._plugin2hookcallers.get(plugin) def add_hookcall_monitoring( self, before: Callable[[str, List[HookImplementation], dict], None], after: Callable[ [HookResult, str, List[HookImplementation], dict], None ], ) -> Callable[[], None]: """Add before/after tracing functions for all hooks. return an undo function which, when called, will remove the added tracers. ``before(hook_name, hook_impls, kwargs)`` will be called ahead of all hook calls and receive a hookcaller instance, a list of HookImplementation instances and the keyword arguments for the hook call. ``after(outcome, hook_name, hook_impls, kwargs)`` receives the same arguments as ``before`` but also a :py:class:`napari_plugin_engine.callers._Result` object which represents the result of the overall hook call. """ oldcall = self._inner_hookexec def traced_hookexec( caller: HookCaller, impls: List[HookImplementation], kwargs: dict ): before(caller.name, impls, kwargs) outcome = HookResult.from_call( lambda: oldcall(caller, impls, kwargs) ) after(outcome, caller.name, impls, kwargs) return outcome self._inner_hookexec = traced_hookexec def undo(): self._inner_hookexec = oldcall return undo def enable_tracing(self): """Enable tracing of hook calls and return an undo function.""" hooktrace = self.trace.root.get("hook") def before(hook_name, methods, kwargs): hooktrace.root.indent += 1 hooktrace(hook_name, kwargs) def after(outcome, hook_name, methods, kwargs): if outcome.excinfo is None: hooktrace("finish", hook_name, "-->", outcome.result) hooktrace.root.indent -= 1 return self.add_hookcall_monitoring(before, after) def get_metadata( self, plugin: Any, *values ) -> Optional[Union[str, Dict[str, Optional[str]]]]: """Return metadata values for a given plugin Parameters ---------- plugin : Any Either a string (in which case it is interpreted as a plugin name), or a non-string object (in which case it is assumed to be a plugin module or class). *values : str key(s) to lookup in the plugin object distribution metadata. At least one value must be supplied. Raises ------ TypeError If no values are supplied. KeyError If the plugin does not exist. """ if not values: raise TypeError( 'get_metadata() requires at least one positional ' 'argument: the metadata value(s) to lookup' ) # allow other objects to pass through directly to get_metadata if isinstance(plugin, str): plugin = self._ensure_plugin(plugin) return get_metadata(plugin, *values) def get_standard_metadata(self, plugin: Any): """Return a standard metadata dict for ``plugin``. Parameters ---------- plugin : Any A plugin name or any object. If it is a plugin name, it *must* be a registered plugin. Returns ------- metadata : dict A dicts with plugin metadata. The dict is guaranteed to have the following keys: - **plugin_name**: The name of the plugin as registered - **package**: The name of the package - **version**: The version of the plugin package - **summary**: A one-line summary of what the distribution does - **author**: The author’s name - **email**: The author’s (or maintainer's) e-mail address. - **license**: The license covering the distribution - **url**: The home page for the package, or dowload url if N/A. - **hooks**: A list of hookspec names that this plugin implements. Raises ------ KeyError If ``plugin`` is a string, but is not a registered plugin_name. """ if isinstance(plugin, str): plugin = self._ensure_plugin(plugin) plugin_name = self.get_name(plugin) # TODO: is there no better API for this already? hooks = [] for caller in self._plugin2hookcallers[plugin]: try: impl = caller.get_plugin_implementation(plugin_name) hooks.append(impl.specname) except KeyError: pass plugin_meta = dict(plugin_name=plugin_name, hooks=hooks) plugin_meta.update(standard_metadata(plugin)) return plugin_meta def list_plugin_metadata(self) -> List[Dict[str, Optional[str]]]: """Return list of standard metadata dicts for every registered plugin. Returns ------- metadata : dict A list of dicts with plugin metadata. Every dict in the list is guaranteed to have the following keys mentioned in :meth:`~PluginManager.get_standard_metadata` """ return [ self.get_standard_metadata(plugin) for plugin in self._plugin2hookcallers ] def __str__(self) -> str: nhooks = len(self.hooks) nplug = len(self.plugins) text = f'PluginManager for "{self.project_name}"\n' text += f'({nhooks} hook specs and {nplug} plugins)\n' text += '-' * 45 + '\n' for name, plugin in sorted(self.plugins.items(), key=lambda x: x[0]): text += self.plugin_info(plugin) + "\n" if self._blocked: text += '\nBlocked Plugins:\n----------------\n' text += "\n".join(self._blocked) return text def plugin_info(self, plugin) -> str: plugin = self._ensure_plugin(plugin) plugin_name = self.get_name(plugin) version = self.get_metadata(plugin, 'version') hooks = self._plugin2hookcallers[plugin] name = f'{plugin_name} v{version}' text = f'{name:45} {len(hooks):3} hooks\n' for specname, hook_caller in self.hooks.items(): for impl in hook_caller.get_hookimpls(): if impl.plugin_name == plugin_name: funcname = '' if impl.function.__name__ != specname: funcname = ( f'{impl.function.__module__}.' f'{impl.function.__name__}' ) text += f" - {specname:28} {funcname}\n" return text def _formatdef(func): return f"{func.__name__}{str(inspect.signature(func))}" class _HookRelay: """Hook holder object for storing HookCaller instances. This object triggers (lazy) discovery of plugins as follows: When a plugin hook is accessed (e.g. plugin_manager.hook.napari_get_reader), if ``self._needs_discovery`` is True, then it will trigger autodiscovery on the parent plugin_manager. Note that ``PluginManager.__init__`` sets ``self.hook._needs_discovery = True`` *after* hook_specifications and builtins have been discovered, but before external plugins are loaded. """ def __init__(self, manager: PluginManager): self._manager = manager self._needs_discovery = True def __getattribute__(self, name) -> HookCaller: """Trigger manager plugin discovery when accessing hook first time.""" if name not in ("_needs_discovery", "_manager"): if self._needs_discovery: self._manager.discover() return object.__getattribute__(self, name) def __str__(self) -> str: text = '' for hookname, hookcaller in sorted(self.items(), key=lambda x: x[0]): text += ( f'{hookname:25} {len(hookcaller.get_hookimpls()):3}' ' implementations\n' ) return text def __len__(self) -> int: return len([k for k in vars(self) if not k.startswith("_")]) def items(self) -> List[Tuple[str, HookCaller]]: """Iterate through hookcallers, removing private attributes.""" return [ (k, val) for k, val in vars(self).items() if not k.startswith("_") ] def values(self) -> List[HookCaller]: """Iterate through hookcallers, removing private attributes.""" return [val for k, val in vars(self).items() if not k.startswith("_")] def get_canonical_name(namespace: Any) -> str: """Return canonical name for a plugin object. Note that a plugin may be registered under a different name which was specified by the caller of :meth:`PluginManager.register(plugin, name) <.PluginManager.register>`. To obtain the name of a registered plugin use :meth:`get_name(plugin) <.PluginManager.get_name>` instead. """ return getattr(namespace, "__name__", None) or str(id(namespace)) def iter_implementations( namespace, project_name: str ) -> Generator[HookImplementation, None, None]: # register matching hook implementations of the plugin for name in dir(namespace): # check all attributes/methods of plugin and look for functions or # methods that have a "{self.project_name}_impl" attribute. method = getattr(namespace, name) if not inspect.isroutine(method): continue tag = HookImplementation.format_tag(project_name) hookimpl_opts = getattr(method, tag, None) if not (isinstance(hookimpl_opts, dict) and hookimpl_opts): # false positive continue # create the HookImplementation instance for this method try: yield HookImplementation(method, namespace, **hookimpl_opts) except TypeError: # final fallback if the hookimpl_opts dict has invalid keys # it's probably not a real hook implementation anyway. pass def ensure_namespace(obj: Any, name: str = 'orphan') -> Type: """Convert a ``dict`` to an object that provides ``getattr``. Parameters ---------- obj : Any An object, may be a ``dict``, or a regular namespace object. name : str, optional A name to use for the new namespace, if created. by default 'orphan' Returns ------- type A namespace object. If ``obj`` is a ``dict``, creates a new ``type`` named ``name``, prepopulated with the key:value pairs from ``obj``. Otherwise, if ``obj`` is not a ``dict``, will return the original ``obj``. Raises ------ ValueError If ``obj`` is a ``dict`` that contains keys that are not valid `identifiers `_. """ if isinstance(obj, dict): bad_keys = [str(k) for k in obj.keys() if not str(k).isidentifier()] if bad_keys: raise ValueError( f"dict contained invalid identifiers: {', '.join(bad_keys)}" ) return type(name, (), obj) return obj @contextmanager def temp_path_additions(path: Optional[Union[str, List[str]]]) -> Generator: """A context manager that temporarily adds ``path`` to sys.path. Parameters ---------- path : str or list of str A path or list of paths to add to sys.path Yields ------- sys_path : list of str The current sys.path for the context. """ if isinstance(path, (str, Path)): path = [path] path = [os.fspath(p) for p in path] if path else [] to_add = [p for p in path if p not in sys.path] for p in to_add: sys.path.insert(0, p) try: yield sys.path finally: for p in to_add: sys.path.remove(p) pattern = re.compile( r'(?P[\w.]+)\s*' r'(:\s*(?P[\w.]+))?\s*' r'(?P\[.*\])?\s*$' ) def load(value: str): """Load and return a module or attribute of a module (such as a class). If only a module is indicated by the value, return that module. Otherwise, return the named object. """ match = pattern.match(value) if not match: raise ValueError(f"malformed entry point string: {value}") module = importlib.import_module(match.group('module')) attrs = filter(None, (match.group('attr') or '').split('.')) return functools.reduce(getattr, attrs, module) def iter_available_plugins( group: Optional[str] = None, prefix: Optional[str] = None, path: Optional[Union[str, List[str]]] = None, include_uninstalled: bool = None, ) -> Generator[Tuple[str, str, Optional[str]], None, None]: """Discover modules by both naming convention and entry_points. 1. `Using naming convention `_: modules installed in the environment that follow a naming convention (e.g. "napari_plugin"), can be discovered using :mod:`pkgutil`. This also enables easy discovery using the PyPI `simple API `_ 2. `Using package metadata `_: packages that declare an `entry_point `_ in their ``setup.py`` file that matches the ``entry_point`` argument can be discovered using `importlib.metadata `_. For background on entry points, see the `Entry Point specification `_. Parameters ---------- group : str The entry_point group name to search for prefix : str Any modules found in sys.path whose names begin with ``prefix`` will be imported and searched for hook implementations. path : str or list of str, optional Path or paths to add to sys.path before importing, removed at the end. include_uninstalled : bool, optional Whether to search for "local" (uninstalled) modules. Requires that a prefix is provided. By default, True when prefix is provided. Raises ------ ValueError If ``include_uninstalled`` is true and ``prefix`` is not provided. """ if include_uninstalled is None: include_uninstalled = bool(prefix) with temp_path_additions(path): _seen = set() for dist in importlib_metadata.distributions(): matched = False if group and not os.getenv("DISABLE_ENTRYPOINT_PLUGINS"): for ep in dist.entry_points: if ep.group == group: # type: ignore matched = True _seen.add(ep.value.split(".", maxsplit=1)[0]) yield ( ep.name, ep.value, dist.metadata.get("name"), ) if matched: continue if prefix and not os.getenv("DISABLE_PREFIX_PLUGINS"): name = dist.metadata.get("name") if not name or name == prefix or (not name.startswith(prefix)): continue top_modules = dist.read_text('top_level.txt') or "" for mod in filter(None, top_modules.split('\n')): if mod.startswith(prefix): _seen.add(mod) yield (name, mod, name) if include_uninstalled and not os.getenv("DISABLE_PREFIX_PLUGINS"): from pkgutil import iter_modules if not prefix: raise ValueError( "A prefix must be provided with 'include_uninstalled'." ) for finder, mod_name, ispkg in iter_modules(): if ( mod_name.startswith(prefix) and mod_name != prefix and mod_name not in _seen ): yield (mod_name, mod_name, None) napari-napari-plugin-engine-67b3fb4/napari_plugin_engine/markers.py000066400000000000000000000127111514256550300256150ustar00rootroot00000000000000"""Hook annotation decorators""" from typing import Callable, Optional from .implementation import HookImplementation, HookSpecification class HookSpecificationMarker: """Decorator helper class for marking functions as hook specifications. You can instantiate it with a project_name to get a decorator. Calling :py:meth:`.PluginManager.add_hookspecs` later will discover all marked functions if the :py:class:`.PluginManager` uses the same project_name. """ def __init__(self, project_name): self.project_name = project_name def __call__( self, function: Optional[Callable] = None, firstresult: bool = False, historic: bool = False, warn_on_impl=None, ): """if passed a function, directly sets attributes on the function which will make it discoverable to :py:meth:`.PluginManager.add_hookspecs`. If passed no function, returns a decorator which can be applied to a function later using the attributes supplied. If ``firstresult`` is ``True`` the 1:N hook call (N being the number of registered hook implementation functions) will stop at I<=N when the I'th function returns a non-``None`` result. If ``historic`` is ``True`` calls to a hook will be memorized and replayed on later registered plugins. """ def setattr_hookspec_opts(func): if historic and firstresult: raise ValueError("cannot have a historic firstresult hook") setattr( func, HookSpecification.format_tag(self.project_name), dict( firstresult=firstresult, historic=historic, warn_on_impl=warn_on_impl, ), ) return func if function is not None: return setattr_hookspec_opts(function) else: return setattr_hookspec_opts class HookImplementationMarker: """Decorator helper class for marking functions as hook implementations. You can instantiate with a ``project_name`` to get a decorator. Calling :meth:`.PluginManager.register` later will discover all marked functions if the :class:`.PluginManager` uses the same project_name. Parameters ---------- project_name : str A namespace for plugin implementations. Implementations decorated with this class will be discovered by ``PluginManager.register`` if and only if ``project_name`` matches the ``project_name`` of the ``PluginManager``. """ def __init__(self, project_name: str): self.project_name = project_name def __call__( self, function: Optional[Callable] = None, *, hookwrapper: bool = False, optionalhook: bool = False, tryfirst: bool = False, trylast: bool = False, specname: str = '', ) -> Callable: """Call the marker instance. If passed a function, directly sets attributes on the function which will make it discoverable to :meth:`.PluginManager.register`. If passed no function, returns a decorator which can be applied to a function later using the attributes supplied. Parameters ---------- function : callable, optional A function to decorate as a hook implementation, If ``function`` is None, this method returns a function that can be used to decorate other functions. hookwrapper : bool, optional Whether this hook implementation behaves as a hookwrapper. by default False optionalhook : bool, optional If ``True``, a missing matching hook specification will not result in an error (by default it is an error if no matching spec is found), by default False. tryfirst : bool, optional If ``True`` this hook implementation will run as early as possible in the chain of N hook implementations for a specification, by default False trylast : bool, optional If ``True`` this hook implementation will run as late as possible in the chain of N hook implementations, by default False specname : str, optional If provided, ``specname`` will be used instead of the function name when matching this hook implementation to a hook specification during registration, by default the implementation function name must match the name of the corresponding hook specification. Returns ------- Callable If ``function`` is not ``None``, will decorate the function with attributes, and return the function. If ``function`` is None, will return a decorator that can be used to decorate functions. """ def set_hook_implementation_attributes(func): setattr( func, HookImplementation.format_tag(self.project_name), dict( hookwrapper=hookwrapper, optionalhook=optionalhook, tryfirst=tryfirst, trylast=trylast, specname=specname, ), ) return func if function is None: return set_hook_implementation_attributes else: return set_hook_implementation_attributes(function) napari-napari-plugin-engine-67b3fb4/pyproject.toml000066400000000000000000000005321514256550300223340ustar00rootroot00000000000000[build-system] requires = [ "setuptools>=42", "wheel", "setuptools_scm[toml]>=3.4" ] build-backend = "setuptools.build_meta" [tool.setuptools_scm] write_to = "napari_plugin_engine/_version.py" [tool.black] target-version = ['py37', 'py38'] skip-string-normalization = true line-length = 79 [tool.isort] profile = "black" line_length = 79napari-napari-plugin-engine-67b3fb4/setup.cfg000066400000000000000000000030701514256550300212410ustar00rootroot00000000000000[metadata] name = napari-plugin-engine url = https://github.com/napari/napari-plugin-engine license = MIT license_file = LICENSE description = napari plugin engine, fork of pluggy long_description = file: README.md long_description_content_type = text/markdown author = napari team author_email = napari-steering-council@googlegroups.com classifiers = Development Status :: 4 - Beta Intended Audience :: Education Intended Audience :: Science/Research License :: OSI Approved :: MIT License Programming Language :: Python Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Topic :: Scientific/Engineering Topic :: Scientific/Engineering :: Visualization Topic :: Scientific/Engineering :: Information Analysis Topic :: Scientific/Engineering :: Bio-Informatics Topic :: Software Development :: Testing Topic :: Software Development :: Libraries Topic :: Utilities Operating System :: Microsoft :: Windows Operating System :: POSIX Operating System :: MacOS [options] packages = find: python_requires = >=3.6 install_requires = importlib-metadata>=1.5.0; python_version < "3.8" [options.extras_require] test = pytest pytest-cov dev = %(test)s pre-commit tox tox-conda [options.entry_points] pytest11 = napari-plugin-engine = napari_plugin_engine._testsupport [flake8] max-line-length=88 napari-napari-plugin-engine-67b3fb4/setup.py000066400000000000000000000004061514256550300211320ustar00rootroot00000000000000""" PEP 517 doesn’t support editable installs so this file is currently here to support "pip install -e ." """ from setuptools import setup setup( use_scm_version={"write_to": "napari_plugin_engine/_version.py"}, setup_requires=["setuptools_scm"], ) napari-napari-plugin-engine-67b3fb4/testing/000077500000000000000000000000001514256550300210755ustar00rootroot00000000000000napari-napari-plugin-engine-67b3fb4/testing/conftest.py000066400000000000000000000010411514256550300232700ustar00rootroot00000000000000import pytest @pytest.fixture( params=[lambda spec: spec, lambda spec: spec()], ids=["spec-is-class", "spec-is-instance"], ) def he_pm(request, pm): from napari_plugin_engine import HookSpecificationMarker hookspec = HookSpecificationMarker("example") class Hooks: @hookspec def he_method1(self, arg): ... pm.add_hookspecs(request.param(Hooks)) return pm @pytest.fixture def pm(): from napari_plugin_engine import PluginManager pm = PluginManager(project_name='example') return pm napari-napari-plugin-engine-67b3fb4/testing/test_call_ordering.py000066400000000000000000000120261514256550300253130ustar00rootroot00000000000000import pytest from napari_plugin_engine import ( HookImplementationMarker, HookSpecificationMarker, PluginManager, ) dummy_hook_implementation = HookImplementationMarker("dummy") dummy_hook_specification = HookSpecificationMarker("dummy") class MySpec: @dummy_hook_specification def myhook(self): pass class Plugin_1: @dummy_hook_implementation def myhook(self): return "p1" class Plugin_2: @dummy_hook_implementation(tryfirst=True) def myhook(self): return "p2" class Plugin_3: @dummy_hook_implementation def myhook(self): return "p3" class Wrapper: @dummy_hook_implementation(hookwrapper=True) def myhook(self): yield p1, p2, p3, wrapper = Plugin_1(), Plugin_2(), Plugin_3(), Wrapper() @pytest.fixture def dummy_plugin_manager(): plugin_manager = PluginManager("dummy") plugin_manager.add_hookspecs(MySpec) plugin_manager.register(p1, name='p1') plugin_manager.register(p2, name='p2') plugin_manager.register(p3, name='p3') plugin_manager.register(wrapper, name='wrapper') return plugin_manager # p2 is first because it was declared with tryfirst=True # p3 is second because of "last-in-first-out" order START_ORDER = ['p2', 'p3', 'p1'] @pytest.mark.parametrize( 'order, expected_result', [ ([], START_ORDER), (['p2'], START_ORDER), (['p2', 'p3'], START_ORDER), (['p1', 'p2', 'p3'], ['p1', 'p2', 'p3']), (['p1', 'p3', 'p2'], ['p1', 'p3', 'p2']), (['p1', 'p3'], ['p1', 'p3', 'p2']), (['p1'], ['p1', 'p2', 'p3']), (['p3'], ['p3', 'p2', 'p1']), ], ) def test_reordering_hook_caller(dummy_plugin_manager, order, expected_result): """Test that the permute_hook_implementations function reorders hooks.""" hook_caller = dummy_plugin_manager.hooks.myhook assert hook_caller() == START_ORDER hook_caller.bring_to_front(order) assert hook_caller() == expected_result # return to original order hook_caller.bring_to_front(START_ORDER) assert hook_caller() == START_ORDER # try again using HookImplementation INSTANCES instead of plugin names instances = [hook_caller.get_plugin_implementation(i) for i in order] hook_caller.bring_to_front(instances) assert hook_caller() == expected_result def test_reordering_hook_caller_raises(dummy_plugin_manager): """Test that invalid calls to permute_hook_implementations raise errors.""" hook_caller = dummy_plugin_manager.hooks.myhook with pytest.raises(TypeError): # all items must be the name of a plugin, or a HookImplementation instance hook_caller.bring_to_front([1, 2]) with pytest.raises(ValueError): # 'wrapper' is the name of a plugin that provides an implementation... # but it is a hookwrappers which is not valid for `bring_to_front` hook_caller.bring_to_front(['p1', 'wrapper']) with pytest.raises(ValueError): # 'p4' is not in the list hook_caller.bring_to_front(['p1', 'p4']) with pytest.raises(ValueError): # duplicate entries are not allowed hook_caller.bring_to_front(['p1', 'p1', 'p2']) with pytest.raises(ValueError): # too many values hook_caller.bring_to_front(['p1', 'p1', 'p2', 'p4', 'p3', 'p1']) with pytest.raises(TypeError): # it has to be a list hook_caller.bring_to_front('p1') def test_hook_caller_kwargs(dummy_plugin_manager): hook_caller = dummy_plugin_manager.hooks.myhook assert hook_caller() == ['p2', 'p3', 'p1'] for p in ['p2', 'p3', 'p1']: # call with a specific plugin assert hook_caller(_plugin=p) == p impl = hook_caller.get_plugin_implementation(p) # call without specific plugins/impls expected = ['p2', 'p3', 'p1'] expected.remove(p) assert hook_caller(_skip_impls=[impl]) == expected def test_disable_impls(dummy_plugin_manager): hook_caller = dummy_plugin_manager.hooks.myhook assert hook_caller() == ['p2', 'p3', 'p1'] for p in ['p2', 'p3', 'p1']: # call with a specific plugin expected = ['p2', 'p3', 'p1'] assert hook_caller() == expected assert hook_caller(_plugin=p) == p assert hook_caller(_plugin=p) == hook_caller._call_plugin(p) impl = hook_caller.get_plugin_implementation(p) # call without specific plugins/impls expected.remove(p) assert hook_caller(_skip_impls=[impl]) == expected hook_caller.disable_plugin(p) assert hook_caller() == expected hook_caller.enable_plugin(p) assert set(hook_caller()) == set([p] + expected) with pytest.raises(TypeError): # cannot call wrappers directly hook_caller(_plugin='wrapper') def test_get_plugin_implementation(dummy_plugin_manager): hook_caller = dummy_plugin_manager.hooks.myhook impl = hook_caller.get_plugin_implementation('p1') assert impl assert impl.plugin_name == 'p1' with pytest.raises(KeyError): hook_caller.get_plugin_implementation('pasdfsdf1') napari-napari-plugin-engine-67b3fb4/testing/test_details.py000066400000000000000000000053061514256550300241370ustar00rootroot00000000000000import warnings import pytest from napari_plugin_engine import ( HookImplementationMarker, HookSpecificationMarker, PluginManager, ) hookspec = HookSpecificationMarker("example") hookimpl = HookImplementationMarker("example") def test_warn_when_deprecated_specified(recwarn): warning = DeprecationWarning("foo is deprecated") class Spec: @hookspec(warn_on_impl=warning) def foo(self): pass class Plugin: @hookimpl def foo(self): pass pm = PluginManager(hookspec.project_name) pm.add_hookspecs(Spec) with pytest.warns(DeprecationWarning) as records: pm.register(Plugin()) (record,) = records assert record.message is warning assert record.filename == Plugin.foo.__code__.co_filename assert record.lineno == Plugin.foo.__code__.co_firstlineno def test_plugin_getattr_raises_errors(): """napari_plugin_engine must be able to handle plugins which raise weird exceptions when getattr() gets called (#11). """ class DontTouchMe: def __getattr__(self, x): raise Exception("cant touch me") class Module: pass module = Module() module.x = DontTouchMe() pm = PluginManager(hookspec.project_name) # register() would raise an error pm.register(module, "donttouch") assert pm.plugins.get("donttouch") is module def test_warning_on_call_vs_hookspec_arg_mismatch(): """Verify that is a hook is called with less arguments then defined in the spec that a warning is emitted. """ class Spec: @hookspec def myhook(self, arg1, arg2): pass class Plugin: @hookimpl def myhook(self, arg1): pass pm = PluginManager(hookspec.project_name) pm.register(Plugin()) pm.add_hookspecs(Spec()) with warnings.catch_warnings(record=True) as warns: warnings.simplefilter("always") # calling should trigger a warning pm.hook.myhook(arg1=1) assert len(warns) == 1 warning = warns[-1] assert issubclass(warning.category, Warning) assert "Argument(s) ('arg2',)" in str(warning.message) def test_repr(): class Hook: @hookspec def myhook(arg): ... class Plugin: @hookimpl def myhook(arg): raise NotImplementedError() pm = PluginManager(hookspec.project_name) pm.add_hookspecs(Hook) assert ( repr(pm.hook.myhook.spec) == "" ) plugin = Plugin() pname = pm.register(plugin) expected = f"" assert repr(pm.hook.myhook._nonwrappers[0]) == expected napari-napari-plugin-engine-67b3fb4/testing/test_discovery.py000066400000000000000000000365201514256550300245230ustar00rootroot00000000000000import os import pytest from napari_plugin_engine import ( PluginError, PluginImportError, PluginValidationError, ) from napari_plugin_engine.dist import get_version, standard_metadata from napari_plugin_engine.manager import temp_path_additions GOOD_PLUGIN = """ from napari_plugin_engine import HookImplementationMarker @HookImplementationMarker("test") def test_specification(arg1, arg2): return arg1 + arg2 """ INVALID_PLUGIN = """ from napari_plugin_engine import HookImplementationMarker @HookImplementationMarker("test") def test_specification(arg1, arg2, arg3): return arg1 + arg2 + arg3 """ @pytest.fixture def app_good_plugin(tmp_path): """A good plugin with a name prefix we will search for.""" (tmp_path / "app_good_plugin.py").write_text(GOOD_PLUGIN) @pytest.fixture def good_entrypoint_plugin(tmp_path): """A good plugin that uses entry points.""" (tmp_path / "good_entrypoint_plugin.py").write_text(GOOD_PLUGIN) distinfo = tmp_path / "good_entrypoint_plugin-1.2.3.dist-info" distinfo.mkdir() (distinfo / "top_level.txt").write_text('good_entrypoint_plugin') (distinfo / "entry_points.txt").write_text( "[app.plugin]\ngood_entry = good_entrypoint_plugin" ) (distinfo / "METADATA").write_text( "Metadata-Version: 2.1\n" "Name: good_entry\n" "Version: 1.2.3\n" "Author-Email: example@example.com\n" "Home-Page: https://www.example.com\n" "Requires-Python: >=3.6\n" ) @pytest.fixture def double_convention_plugin(tmp_path): """A good plugin that uses entry points but ALSO has naming convention""" module_folder = tmp_path / "app_double_plugin" module_folder.mkdir() (module_folder / "__init__.py").write_text('') (module_folder / "module_a.py").write_text(GOOD_PLUGIN) (module_folder / "module_b.py").write_text(GOOD_PLUGIN) distinfo = tmp_path / "app_double_plugin-3.2.1.dist-info" distinfo.mkdir() (distinfo / "top_level.txt").write_text('app_double_plugin') (distinfo / "entry_points.txt").write_text( "[app.plugin]\n" "double_a = app_double_plugin.module_a\n" "double_b = app_double_plugin.module_b\n" ) (distinfo / "METADATA").write_text( "Metadata-Version: 2.1\n" "Name: double-package\n" "Version: 3.2.1\n" "Author-Email: example@example.com\n" "Home-Page: https://www.example.com\n" "Requires-Python: >=3.6\n" ) @pytest.fixture def invalid_entrypoint_plugin(tmp_path): """A good plugin that uses entry points.""" (tmp_path / "invalid_entrypoint_plugin.py").write_text(INVALID_PLUGIN) distinfo = tmp_path / "invalid_entrypoint_plugin-1.2.3.dist-info" distinfo.mkdir() (distinfo / "top_level.txt").write_text('invalid_entrypoint_plugin') (distinfo / "entry_points.txt").write_text( "[app.plugin]\ninvalid = invalid_entrypoint_plugin" ) (distinfo / "METADATA").write_text( "Metadata-Version: 2.1\n" "Name: invalid\n" "Version: 1.2.3\n" "Author-Email: example@example.com\n" "Home-Page: https://www.example.com\n" "Requires-Python: >=3.6\n" ) return tmp_path @pytest.fixture def app_invalid_plugin(tmp_path): (tmp_path / "app_invalid_plugin.py").write_text(INVALID_PLUGIN) @pytest.fixture def app_broken_plugin(tmp_path): (tmp_path / "app_broken_plugin.py").write_text('raise ValueError("broke")') @pytest.fixture def full_plugin_manager( tmp_path, add_specification, test_plugin_manager, good_entrypoint_plugin, invalid_entrypoint_plugin, app_good_plugin, app_invalid_plugin, double_convention_plugin, ): @add_specification def test_specification(arg1, arg2): ... test_plugin_manager.discover_entry_point = 'app.plugin' test_plugin_manager.discover_prefix = 'app_' test_plugin_manager.discover_path = tmp_path cnt, err = test_plugin_manager.discover() assert cnt == 4 assert len(err) == 2 return test_plugin_manager def test_plugin_meta( tmp_path, add_specification, test_plugin_manager, app_good_plugin, good_entrypoint_plugin, double_convention_plugin, ): test_plugin_manager.discover_entry_point = 'app.plugin' test_plugin_manager.discover_prefix = 'app_' with temp_path_additions(tmp_path): cnt, err = test_plugin_manager.discover() assert set(test_plugin_manager.plugins) == { 'double_a', 'double_b', 'good_entry', 'app_good_plugin', } versions = { 'double_a': '3.2.1', 'double_b': '3.2.1', 'good_entry': '1.2.3', 'app_good_plugin': '', } for name, plug in test_plugin_manager.plugins.items(): assert versions[name] == get_version(plug) if name == 'app_good_plugin': # this one doesn't have any metadata.. but it will have plugin_name assert standard_metadata(plug) == {} else: assert get_version(plug) == standard_metadata(plug).get( 'version' ) @pytest.mark.parametrize( 'regkwargs', [ {}, {'entry_point': 'app.plugin'}, {'prefix': 'app_'}, {'entry_point': 'app.plugin', 'prefix': 'app_'}, ], ids=['neither', 'entry_point', 'prefix', 'both'], ) def test_double_convention( regkwargs, tmp_path, add_specification, test_plugin_manager, double_convention_plugin, ): """Plugins using both naming convention and entrypoints only register once.""" @add_specification def test_specification(arg1, arg2): ... assert not test_plugin_manager.plugins if 'entry_point' in regkwargs: test_plugin_manager.discover_entry_point = regkwargs['entry_point'] if 'prefix' in regkwargs: test_plugin_manager.discover_prefix = regkwargs['prefix'] test_plugin_manager.discover_path = tmp_path cnt, _ = test_plugin_manager.discover() hook_caller = test_plugin_manager.hook.test_specification plugin_names = list(test_plugin_manager.plugins.keys()) if regkwargs: if 'entry_point' in regkwargs: # if an entry_point with a matching group is provided # the plugin will be named after the entrypoint name assert 'double_a' in plugin_names assert 'double_b' in plugin_names assert 'double-package' not in test_plugin_manager.plugins assert len(hook_caller.get_hookimpls()) == 2 else: # just prefix # if entry point discovery is disabled, but a top-level module # matches the naming convnetion, then the plugin will be named using # the top level module assert 'app_double_plugin' in plugin_names # however, in this case, there are no implementations in the # top level module (which is the only way naming convention # works) assert len(hook_caller.get_hookimpls()) == 0 else: assert cnt == 0 assert not hook_caller.get_hookimpls() assert 'double' not in plugin_names def test_plugin_discovery_by_prefix( tmp_path, add_specification, test_plugin_manager, app_good_plugin, app_invalid_plugin, ): """Make sure discovery by package prefix works""" @add_specification def test_specification(arg1, arg2): ... assert test_plugin_manager.hook.test_specification.spec assert not test_plugin_manager.plugins test_plugin_manager.discover_prefix = 'app_' test_plugin_manager.discover_path = tmp_path # discover modules that begin with `app_` count, errs = test_plugin_manager.discover() # we should have had one success and one error. assert count == len(errs) == 1 # the app_good_plugin module should have been found, with one hookimpl assert 'app_good_plugin' in test_plugin_manager.plugins.keys() impls = test_plugin_manager.hook.test_specification.get_hookimpls() assert 'app_good_plugin' in [i.plugin_name for i in impls] # the plugin with the invalid spec is in the path that we loaded assert 'app_invalid_plugin.py' in os.listdir(tmp_path) # but it wasn't added to the plugin manager assert 'app_invalid_plugin' not in test_plugin_manager.plugins.keys() # However an error should have been logged for the invalid plugin assert not test_plugin_manager.get_errors('app_good_plugin') errs = test_plugin_manager.get_errors('app_invalid_plugin') assert errs assert isinstance(errs[0], PluginValidationError) # and it should now be blocked # assert test_plugin_manager.is_blocked('app_invalid_plugin') # if we unblock the plugin and turn off ignore_errors # we'll get a registration error at discovery test_plugin_manager.set_blocked('app_invalid_plugin', False) with pytest.raises(PluginValidationError): test_plugin_manager.discover(ignore_errors=False) def test_plugin_discovery_by_prefix_with_bad_plugin( tmp_path, add_specification, test_plugin_manager, app_broken_plugin ): """Make sure bad plugins can raise errors""" test_plugin_manager.discover_prefix = 'app_' test_plugin_manager.discover_path = tmp_path with pytest.raises(PluginImportError): test_plugin_manager.discover(ignore_errors=False) def test_plugin_discovery_by_entry_point( tmp_path, add_specification, test_plugin_manager, good_entrypoint_plugin, invalid_entrypoint_plugin, ): @add_specification def test_specification(arg1, arg2): ... hook_caller = test_plugin_manager.hook.test_specification assert hook_caller.spec assert not test_plugin_manager.plugins test_plugin_manager.discover_entry_point = 'app.plugin' test_plugin_manager.discover_path = tmp_path # discover modules that begin with `app_` cnt, err = test_plugin_manager.discover() # we should have had one success and one error. assert cnt == len(err) == 1 # the app_good_plugin module should have been found, with one hookimpl assert 'good_entry' in test_plugin_manager.plugins.keys() assert 'good_entry' in [i.plugin_name for i in hook_caller.get_hookimpls()] # the plugin with the invalid spec is in the path that we loaded assert 'invalid_entrypoint_plugin.py' in os.listdir(tmp_path) # but it wasn't added to the plugin manager assert 'invalid' not in test_plugin_manager.plugins.keys() # However an error should have been logged for the invalid plugin assert not test_plugin_manager.get_errors('good_entry') errs = test_plugin_manager.get_errors('invalid') assert errs assert isinstance(errs[0], PluginValidationError) # and it should now be blocked # assert test_plugin_manager.is_blocked('invalid') # if we unblock the plugin and turn off ignore_errors # we'll get a registration error at discovery test_plugin_manager.set_blocked('invalid', False) with pytest.raises(PluginValidationError): test_plugin_manager.discover(ignore_errors=False) def test_identical_entry_point_discovery( good_entrypoint_plugin, test_plugin_manager, tmp_path ): test_plugin_manager.discover_entry_point = 'app.plugin' test_plugin_manager.discover_path = tmp_path # make second plugin with same entry point (tmp_path / "good_entrypoint_plugin2.py").write_text(GOOD_PLUGIN) distinfo = tmp_path / "good_entrypoint_plugin2-1.2.3.dist-info" distinfo.mkdir() (distinfo / "top_level.txt").write_text('good_entrypoint_plugin2') (distinfo / "entry_points.txt").write_text( "[app.plugin]\ngood_entry = good_entrypoint_plugin2" ) (distinfo / "METADATA").write_text( "Metadata-Version: 2.1\n" "Name: good_entry\n" "Version: 1.2.3\n" "Author-Email: example@example.com\n" "Home-Page: https://www.example.com\n" "Requires-Python: >=3.6\n" ) with pytest.warns(UserWarning): cnt, err = test_plugin_manager.discover() assert cnt == 2 assert 'good_entry-2' in test_plugin_manager.plugins assert not err # test discovering again doesn't rediscover same plugins cnt, err = test_plugin_manager.discover() assert cnt == 0 assert not err assert 'good_entry' in test_plugin_manager.plugins assert 'good_entry-2' in test_plugin_manager.plugins assert len(test_plugin_manager.plugins) == 2 def test_lazy_autodiscovery( tmp_path, add_specification, test_plugin_manager, good_entrypoint_plugin ): test_plugin_manager.discover_entry_point = 'app.plugin' assert test_plugin_manager.hook._needs_discovery is True with test_plugin_manager.discovery_blocked(): @add_specification def test_specification(arg1, arg2): ... assert test_plugin_manager.hook._needs_discovery is True assert not test_plugin_manager.plugins with temp_path_additions(tmp_path): hook_caller = test_plugin_manager.hook.test_specification assert hook_caller.spec assert test_plugin_manager.plugins.get('good_entry') assert test_plugin_manager.hook._needs_discovery is False def test_discovery_all_together(full_plugin_manager): hook_caller = full_plugin_manager.hook.test_specification assert len(hook_caller.get_hookimpls()) == 4 assert len(full_plugin_manager.plugins) == 4 assert 'double_a' in full_plugin_manager.plugins.keys() assert 'double_b' in full_plugin_manager.plugins.keys() assert 'app_good_plugin' in full_plugin_manager.plugins.keys() assert 'good_entry' in full_plugin_manager.plugins.keys() def test_getting_errors(invalid_entrypoint_plugin, caplog): with temp_path_additions(invalid_entrypoint_plugin): import invalid_entrypoint_plugin as mod try: raise ValueError('I caused this') except ValueError as e: err = PluginError(plugin=mod, plugin_name='invalid', cause=e) errs = PluginError.get(plugin=mod) assert mod in {p.plugin for p in errs} errs = PluginError.get(plugin_name='invalid') assert 'invalid' in {p.plugin_name for p in errs} assert 'I caused this' in err.format() err.log() assert ' Error in plugin "invalid"' in caplog.text @pytest.mark.parametrize('blocked', ['ALL', 'ENTRYPOINT', 'PREFIX']) def test_env_var_disable( blocked, tmp_path, add_specification, test_plugin_manager, good_entrypoint_plugin, app_good_plugin, monkeypatch, ): @add_specification def test_specification(arg1, arg2): ... test_plugin_manager.discover_entry_point = 'app.plugin' test_plugin_manager.discover_prefix = 'app_' test_plugin_manager.discover_path = tmp_path monkeypatch.setenv(f'DISABLE_{blocked}_PLUGINS', '1') if blocked == 'ALL': with pytest.warns(UserWarning): cnt, err = test_plugin_manager.discover() else: cnt, err = test_plugin_manager.discover() assert cnt == (0 if blocked == 'ALL' else 1) if blocked == 'ENTRYPOINT': assert 'app_good_plugin' in test_plugin_manager.plugins.keys() assert 'good_entry' not in test_plugin_manager.plugins.keys() elif blocked == 'PREFIX': assert 'app_good_plugin' not in test_plugin_manager.plugins.keys() assert 'good_entry' in test_plugin_manager.plugins.keys() elif blocked == 'ALL': assert 'app_good_plugin' not in test_plugin_manager.plugins.keys() assert 'good_entry' not in test_plugin_manager.plugins.keys() monkeypatch.delenv(f'DISABLE_{blocked}_PLUGINS') napari-napari-plugin-engine-67b3fb4/testing/test_helpers.py000066400000000000000000000047531514256550300241610ustar00rootroot00000000000000import sys import pytest from napari_plugin_engine.implementation import varnames from napari_plugin_engine.manager import ( _formatdef, ensure_namespace, temp_path_additions, ) def test_varnames(): def f(x): i = 3 # noqa class A: def f(self, y): pass class B: def __call__(self, z): pass assert varnames(f) == (("x",), ()) assert varnames(A().f) == (("y",), ()) assert varnames(B()) == (("z",), ()) def test_varnames_default(): def f(x, y=3): pass assert varnames(f) == (("x",), ("y",)) def test_varnames_class(): class C: def __init__(self, x): pass class D: pass class E: def __init__(self, x): pass class F: pass assert varnames(C) == (("x",), ()) assert varnames(D) == ((), ()) assert varnames(E) == (("x",), ()) assert varnames(F) == ((), ()) @pytest.mark.skipif( sys.version_info < (3,), reason="Keyword only arguments are Python 3 only" ) def test_varnames_keyword_only(): # SyntaxError on Python 2, so we exec ns = {} exec( "def f1(x, *, y): pass\n" "def f2(x, *, y=3): pass\n" "def f3(x=1, *, y=3): pass\n", ns, ) assert varnames(ns["f1"]) == (("x",), ()) assert varnames(ns["f2"]) == (("x",), ()) assert varnames(ns["f3"]) == ((), ("x",)) def test_formatdef(): def function1(): pass assert _formatdef(function1) == "function1()" def function2(arg1): pass assert _formatdef(function2) == "function2(arg1)" def function3(arg1, arg2="qwe"): pass assert _formatdef(function3) == "function3(arg1, arg2='qwe')" def function4(arg1, *args, **kwargs): pass assert _formatdef(function4) == "function4(arg1, *args, **kwargs)" def test_ensure_namespace(): a = {'x': 1} assert not getattr(a, 'x', None) b = ensure_namespace(a) assert getattr(b, 'x') == 1 assert a != b with pytest.raises(ValueError): # '0' is not a valid identifyer ensure_namespace({0: 1}) class AlreadyNameSpace: x = 1 # doesn't touch things that are already valid namespaces assert ensure_namespace(AlreadyNameSpace) == AlreadyNameSpace def test_temp_path(): import sys orig_path = set(sys.path) with temp_path_additions('/path/') as pth: assert sys.path == pth assert '/path/' in sys.path assert set(sys.path) == orig_path napari-napari-plugin-engine-67b3fb4/testing/test_hookcaller.py000066400000000000000000000160611514256550300246350ustar00rootroot00000000000000import pytest from napari_plugin_engine import ( HookImplementation, HookImplementationMarker, HookSpecification, HookSpecificationMarker, PluginValidationError, ) example_hookspec = HookSpecificationMarker("example") example_implementation = HookImplementationMarker("example") @pytest.fixture def hook_caller(pm): class Hooks: @example_hookspec def method1(self, arg): pass pm.add_hookspecs(Hooks) return pm.hook.method1 @pytest.fixture def addmeth(hook_caller): def addmeth(tryfirst=False, trylast=False, hookwrapper=False): def wrap(func): example_implementation( tryfirst=tryfirst, trylast=trylast, hookwrapper=hookwrapper )(func) hook_caller._add_hookimpl( HookImplementation(func, **func.example_impl) ) return func return wrap return addmeth def funcs(hookmethods): return [hookmethod.function for hookmethod in hookmethods] def test_adding_nonwrappers(hook_caller, addmeth): @addmeth() def method1(): pass @addmeth() def method2(): pass @addmeth() def method3(): pass assert funcs(hook_caller._nonwrappers) == [method1, method2, method3] def test_adding_nonwrappers_trylast(hook_caller, addmeth): @addmeth() def method1_middle(): pass @addmeth(trylast=True) def method1(): pass @addmeth() def method1_b(): pass assert funcs(hook_caller._nonwrappers) == [ method1, method1_middle, method1_b, ] def test_adding_nonwrappers_trylast3(hook_caller, addmeth): @addmeth() def method1_a(): pass @addmeth(trylast=True) def method1_b(): pass @addmeth() def method1_c(): pass @addmeth(trylast=True) def method1_d(): pass assert funcs(hook_caller._nonwrappers) == [ method1_d, method1_b, method1_a, method1_c, ] def test_adding_nonwrappers_trylast2(hook_caller, addmeth): @addmeth() def method1_middle(): pass @addmeth() def method1_b(): pass @addmeth(trylast=True) def method1(): pass assert funcs(hook_caller._nonwrappers) == [ method1, method1_middle, method1_b, ] def test_adding_nonwrappers_tryfirst(hook_caller, addmeth): @addmeth(tryfirst=True) def method1(): pass @addmeth() def method1_middle(): pass @addmeth() def method1_b(): pass assert funcs(hook_caller._nonwrappers) == [ method1_middle, method1_b, method1, ] def test_adding_wrappers_ordering(hook_caller, addmeth): @addmeth(hookwrapper=True) def method1(): pass @addmeth() def method1_middle(): pass @addmeth(hookwrapper=True) def method3(): pass assert funcs(hook_caller._nonwrappers) == [method1_middle] assert funcs(hook_caller._wrappers) == [method1, method3] def test_adding_wrappers_ordering_tryfirst(hook_caller, addmeth): @addmeth(hookwrapper=True, tryfirst=True) def method1(): pass @addmeth(hookwrapper=True) def method2(): pass assert hook_caller._nonwrappers == [] assert funcs(hook_caller._wrappers) == [method2, method1] def test_hookspec(pm): class HookSpecification: @example_hookspec() def he_myhook1(arg1): pass @example_hookspec(firstresult=True) def he_myhook2(arg1): pass @example_hookspec(firstresult=False) def he_myhook3(arg1): pass pm.add_hookspecs(HookSpecification) assert not pm.hook.he_myhook1.spec.firstresult assert pm.hook.he_myhook2.spec.firstresult assert not pm.hook.he_myhook3.spec.firstresult def test_hookspec_reserved_argnames(pm): """Certain argument names are reserved and cannot be used in specs.""" class HookSpecificationA: @example_hookspec() def he_myhook1(_plugin): pass class HookSpecificationB: @example_hookspec() def he_myhook1(_skip_impls): pass for cls in (HookSpecificationA, HookSpecificationB): with pytest.raises(ValueError): pm.add_hookspecs(cls) @pytest.mark.parametrize( "name", ["hookwrapper", "optionalhook", "tryfirst", "trylast"] ) @pytest.mark.parametrize("val", [True, False]) def test_hookimpl(name, val): @example_implementation(**{name: val}) def he_myhook1(arg1): pass if val: assert he_myhook1.example_impl.get(name) else: assert not hasattr(he_myhook1, name) def test_hookrelay_registry(pm): """Verify hook caller instances are registered by name onto the relay and can be likewise unregistered.""" class Api: @example_hookspec def hello(self, arg): "api hook 1" pm.add_hookspecs(Api) hook = pm.hook assert hasattr(hook, "hello") assert repr(hook.hello).find("hello") != -1 class Plugin: @example_implementation def hello(self, arg): return arg + 1 plugin = Plugin() pm.register(plugin) out = hook.hello(arg=3) assert out == [4] assert not hasattr(hook, "world") pm.unregister(plugin) assert hook.hello(arg=3) == [] def test_hookrelay_registration_by_specname(pm): """Verify hook caller instances may also be registered by specifying a specname option to the hookimpl""" class Api: @example_hookspec def hello(self, arg): "api hook 1" pm.add_hookspecs(Api) hook = pm.hook assert hasattr(hook, "hello") assert len(pm.hook.hello.get_hookimpls()) == 0 class Plugin: @example_implementation(specname="hello") def foo(self, arg): return arg + 1 plugin = Plugin() pm.register(plugin) out = hook.hello(arg=3) assert out == [4] def test_hookrelay_registration_by_specname_raises(pm): """Verify using specname still raises the types of errors during registration as it would have without using specname.""" class Api: @example_hookspec def hello(self, arg): "api hook 1" pm.add_hookspecs(Api) # make sure a bad signature still raises an error when using specname class Plugin: @example_implementation(specname="hello") def foo(self, arg, too, many, args): return arg + 1 with pytest.raises(PluginValidationError): pm.register(Plugin()) # make sure check_pending still fails if specname doesn't have a # corresponding spec. EVEN if the function name matches one. class Plugin2: @example_implementation(specname="bar") def hello(self, arg): return arg + 1 pm.register(Plugin2()) with pytest.raises(PluginValidationError): pm.check_pending() def test_legacy_specimpl_opt(): impl = HookImplementation(lambda x: x) assert impl.opts spec = HookSpecification(type("Hook", (), {'x': lambda x: x}), 'x') assert spec.opts napari-napari-plugin-engine-67b3fb4/testing/test_invocations.py000066400000000000000000000115531514256550300250470ustar00rootroot00000000000000import pytest from napari_plugin_engine import ( HookImplementationMarker, HookSpecificationMarker, PluginValidationError, ) hookspec = HookSpecificationMarker("example") hookimpl = HookImplementationMarker("example") def test_argmismatch(pm): class Api: @hookspec def hello(self, arg): "api hook 1" pm.add_hookspecs(Api) class Plugin: @hookimpl def hello(self, argwrong): pass with pytest.raises(PluginValidationError) as exc: pm.register(Plugin()) assert "argwrong" in str(exc.value) def test_only_kwargs(pm): class Api: @hookspec def hello(self, arg): "api hook 1" pm.add_hookspecs(Api) with pytest.raises(TypeError) as exc: pm.hook.hello(3) comprehensible = "hook calling supports only keyword arguments" assert comprehensible in str(exc.value) def test_opt_in_args(pm): """Verfiy that two hookimpls with mutex args can serve under the same spec. """ class Api: @hookspec def hello(self, arg1, arg2, common_arg): "api hook 1" class Plugin1: @hookimpl def hello(self, arg1, common_arg): return arg1 + common_arg class Plugin2: @hookimpl def hello(self, arg2, common_arg): return arg2 + common_arg pm.add_hookspecs(Api) pm.register(Plugin1()) pm.register(Plugin2()) results = pm.hook.hello(arg1=1, arg2=2, common_arg=0) assert results == [2, 1] def test_call_order(pm): class Api: @hookspec def hello(self, arg): "api hook 1" pm.add_hookspecs(Api) class Plugin1: @hookimpl def hello(self, arg): return 1 class Plugin2: @hookimpl def hello(self, arg): return 2 class Plugin3: @hookimpl def hello(self, arg): return 3 class Plugin4: @hookimpl(hookwrapper=True) def hello(self, arg): assert arg == 0 outcome = yield assert outcome.result == [3, 2, 1] pm.register(Plugin1()) pm.register(Plugin2()) pm.register(Plugin3()) pm.register(Plugin4()) # hookwrapper should get same list result res = pm.hook.hello(arg=0) assert res == [3, 2, 1] def test_firstresult_definition(pm): class Api: @hookspec(firstresult=True) def hello(self, arg): "api hook 1" pm.add_hookspecs(Api) class Plugin1: @hookimpl def hello(self, arg): return arg + 1 class Plugin2: @hookimpl def hello(self, arg): return arg - 1 class Plugin3: @hookimpl def hello(self, arg): return None class Plugin4: @hookimpl(hookwrapper=True) def hello(self, arg): assert arg == 3 outcome = yield assert outcome.result == 2 pm.register(Plugin1()) # discarded - not the last registered plugin pm.register(Plugin2()) # used as result pm.register(Plugin3()) # None result is ignored pm.register(Plugin4()) # hookwrapper should get same non-list result res = pm.hook.hello(arg=3) assert res == 2 def test_firstresult_force_result(pm): """Verify forcing a result in a wrapper.""" class Api: @hookspec(firstresult=True) def hello(self, arg): "api hook 1" pm.add_hookspecs(Api) class Plugin1: @hookimpl def hello(self, arg): return arg + 1 class Plugin2: @hookimpl(hookwrapper=True) def hello(self, arg): assert arg == 3 outcome = yield assert outcome.result == 4 outcome.force_result(0) class Plugin3: @hookimpl def hello(self, arg): return None pm.register(Plugin1()) pm.register(Plugin2()) # wrapper pm.register(Plugin3()) # ignored since returns None res = pm.hook.hello(arg=3) assert res == 0 # this result is forced and not a list def test_firstresult_returns_none(pm): """If None results are returned by underlying implementations ensure the multi-call loop returns a None value. """ class Api: @hookspec(firstresult=True) def hello(self, arg): "api hook 1" pm.add_hookspecs(Api) class Plugin1: @hookimpl def hello(self, arg): return None pm.register(Plugin1()) res = pm.hook.hello(arg=3) assert res is None def test_firstresult_no_plugin(pm): """If no implementations/plugins have been registered for a firstresult hook the multi-call loop should return a None value. """ class Api: @hookspec(firstresult=True) def hello(self, arg): "api hook 1" pm.add_hookspecs(Api) res = pm.hook.hello(arg=3) assert res is None napari-napari-plugin-engine-67b3fb4/testing/test_multicall.py000066400000000000000000000103721514256550300244770ustar00rootroot00000000000000import pytest from napari_plugin_engine import ( HookCallError, HookImplementation, HookImplementationMarker, HookSpecificationMarker, ) from napari_plugin_engine.callers import _multicall hookspec = HookSpecificationMarker("example") example_implementation = HookImplementationMarker("example") def multicall(methods, kwargs, firstresult=False): """utility function to execute the hook implementations loop""" caller = _multicall hookfuncs = [] for method in methods: f = HookImplementation(method, **method.example_impl) hookfuncs.append(f) # our _multicall function returns our own HookResult object. # so to make these pluggy tests pass, we have to access .result to mimic # the old behavior (that directly returns results). return caller(hookfuncs, kwargs, firstresult=firstresult).result def test_multicall_passing(): class Plugin1: @example_implementation def method(self, x): return 17 class Plugin2: @example_implementation def method(self, x): return 23 p1 = Plugin1() p2 = Plugin2() result_list = multicall([p1.method, p2.method], {"x": 23}) assert len(result_list) == 2 # ensure reversed order assert result_list == [23, 17] def test_keyword_args(): @example_implementation def func(x): return x + 1 class Plugin: @example_implementation def func(self, x, y): return x + y reslist = multicall([func, Plugin().func], {"x": 23, "y": 24}) assert reslist == [24 + 23, 24] def test_keyword_args_with_defaultargs(): @example_implementation def func(x, z=1): return x + z reslist = multicall([func], {"x": 23, "y": 24}) assert reslist == [24] def test_tags_call_error(): @example_implementation def func(x): return x with pytest.raises(HookCallError): multicall([func], {}) def test_call_subexecute(): @example_implementation def func1(): return 2 @example_implementation def func2(): return 1 assert multicall([func2, func1], {}, firstresult=True) == 2 def test_call_none_is_no_result(): @example_implementation def func1(): return 1 @example_implementation def func2(): return None assert multicall([func1, func2], {}, firstresult=True) == 1 assert multicall([func1, func2], {}, {}) == [1] def test_hookwrapper(): out = [] @example_implementation(hookwrapper=True) def func1(): out.append("func1 init") yield None out.append("func1 finish") @example_implementation def func2(): out.append("func2") return 2 assert multicall([func2, func1], {}) == [2] assert out == ["func1 init", "func2", "func1 finish"] out = [] assert multicall([func2, func1], {}, firstresult=True) == 2 assert out == ["func1 init", "func2", "func1 finish"] def test_hookwrapper_order(): out = [] @example_implementation(hookwrapper=True) def func1(): out.append("func1 init") yield 1 out.append("func1 finish") @example_implementation(hookwrapper=True) def func2(): out.append("func2 init") yield 2 out.append("func2 finish") assert multicall([func2, func1], {}) == [] assert out == ["func1 init", "func2 init", "func2 finish", "func1 finish"] def test_hookwrapper_not_yield(): @example_implementation(hookwrapper=True) def func1(): pass with pytest.raises(TypeError): multicall([func1], {}) def test_hookwrapper_too_many_yield(): @example_implementation(hookwrapper=True) def func1(): yield 1 yield 2 with pytest.raises(RuntimeError) as ex: multicall([func1], {}) assert "func1" in str(ex.value) assert __file__ in str(ex.value) @pytest.mark.parametrize("exc", [SystemExit]) def test_hookwrapper_exception(exc): out = [] @example_implementation(hookwrapper=True) def func1(): out.append("func1 init") yield None out.append("func1 finish") @example_implementation def func2(): raise exc() with pytest.raises(exc): multicall([func2, func1], {}) assert out == ["func1 init", "func1 finish"] napari-napari-plugin-engine-67b3fb4/testing/test_pluginmanager.py000066400000000000000000000304161514256550300253430ustar00rootroot00000000000000""" ``PluginManager`` unit and public API testing. """ import pytest from napari_plugin_engine import ( HookCallError, HookImplementationMarker, HookSpecificationMarker, PluginValidationError, ) hookspec = HookSpecificationMarker("example") hookimpl = HookImplementationMarker("example") def test_plugin_double_register(pm): """Registering the same plugin more then once isn't allowed""" pm.register(42, name="abc") with pytest.raises(ValueError): pm.register(42, name="abc") with pytest.raises(ValueError): pm.register(42, name="def") def test_pm(pm): """Basic registration with objects""" class A: pass a1, a2 = A(), A() pm.register(a1) assert pm.is_registered(a1) pm.register(a2, "hello") assert pm.is_registered(a1) assert pm.is_registered(a2) assert pm.plugins.get("hello") == a2 assert pm.unregister(a1) == a1 assert not pm.is_registered(a1) def test_is_registered(pm): class A: pass a1 = A() pm.register(a1, "hello") assert pm.is_registered(a1) assert pm.is_registered("hello") def test_register_dynamic_attr(he_pm): class A: def __getattr__(self, name): if name[0] != "_": return 42 raise AttributeError() a = A() pname = he_pm.register(a) assert he_pm.plugins.get(pname) assert not he_pm.get_hookcallers(a) def test_pm_name(pm): class A: pass a1 = A() name = pm.register(a1, name="hello") assert name == "hello" pm.unregister(a1) assert not pm.is_registered(a1) assert not pm.plugins name2 = pm.register(a1, name="hello") assert name2 == name pm.unregister("hello") assert not pm.is_registered(a1) assert not pm.plugins def test_set_blocked(pm): class A: pass a1 = A() name = pm.register(a1) assert pm.is_registered(a1) assert not pm.is_blocked(name) pm.set_blocked(name) assert pm.is_blocked(name) assert not pm.is_registered(a1) pm.set_blocked("somename") assert pm.is_blocked("somename") assert not pm.register(A(), "somename") with pytest.warns(UserWarning): pm.unregister("somename") assert pm.is_blocked("somename") def test_register_mismatch_method(he_pm): class hello: @hookimpl def he_method_notexists(self): pass plugin = hello() he_pm.register(plugin) with pytest.raises(PluginValidationError) as excinfo: he_pm.check_pending() assert excinfo.value.plugin is plugin def test_register_mismatch_arg(he_pm): class hello: @hookimpl def he_method1(self, qlwkje): pass plugin = hello() with pytest.raises(PluginValidationError) as excinfo: he_pm.register(plugin) assert excinfo.value.plugin == plugin def test_register(pm): class MyPlugin: pass my = MyPlugin() myname = pm.register(my) assert pm.is_registered(my) my2 = MyPlugin() my2name = pm.register(my2) assert set([myname, my2name]).issubset(set(pm.plugins)) assert pm.is_registered(my) assert pm.is_registered(my2) pm.unregister(my) assert not pm.is_registered(my) def test_register_dict(he_pm): assert not he_pm.hook.he_method1.get_hookimpls() assert not he_pm.plugins he_pm.register({'he_method1': lambda arg: arg + 1}) assert len(he_pm.plugins) == 1 assert 'orphan' in he_pm.plugins.keys() assert he_pm.hook.he_method1.get_hookimpls() assert he_pm.hook.he_method1(arg=1) == [2] def test_register_getattr_error(he_pm): """This test mimics the dask.delayed decorator. It should pass gracefully.""" def mock_delayed(func): class GetAnything: def __getattr__(self, val): return self def __call__(self, *args): return func(*args) def __bool__(self): raise TypeError("Truth of object not supported") def __get__(self, instance, cls): import types if instance is None: return self return types.MethodType(self, instance) return GetAnything() class Plugin1: @mock_delayed def he_method1(self): pass pname = he_pm.register(Plugin1) # make sure that nonsense wasn't registered assert len(he_pm.get_hookcallers(he_pm.plugins.get(pname))) == 0 def test_register_unknown_hooks(pm): class Plugin1: @hookimpl def he_method1(self, arg): return arg + 1 pname = pm.register(Plugin1()) class Hooks: @hookspec def he_method1(self, arg): pass pm.add_hookspecs(Hooks) # assert not pm._unverified_hooks assert pm.hook.he_method1(arg=1) == [2] assert len(pm.get_hookcallers(pm.plugins.get(pname))) == 1 def test_register_historic(pm): class Hooks: @hookspec(historic=True) def he_method1(self, arg): pass pm.add_hookspecs(Hooks) pm.hook.he_method1.call_historic(kwargs=dict(arg=1)) out = [] class Plugin: @hookimpl def he_method1(self, arg): out.append(arg) pm.register(Plugin()) assert out == [1] class Plugin2: @hookimpl def he_method1(self, arg): out.append(arg * 10) pm.register(Plugin2()) assert out == [1, 10] pm.hook.he_method1.call_historic(kwargs=dict(arg=12)) assert out == [1, 10, 120, 12] def test_historic_firstresult_incompatible(pm): with pytest.raises(ValueError): @hookspec(historic=True, firstresult=True) def he_method1(self, arg): pass @pytest.mark.parametrize("result_callback", [True, False]) def test_with_result_memorized(pm, result_callback): """Verify that ``HookCaller._maybe_apply_history()` correctly applies the ``result_callback`` function, when provided, to the result from calling each newly registered hook. """ out = [] if result_callback: def callback(res): out.append(res) else: callback = None class Hooks: @hookspec(historic=True) def he_method1(self, arg): pass pm.add_hookspecs(Hooks) class Plugin1: @hookimpl def he_method1(self, arg): return arg * 10 pm.register(Plugin1()) he_method1 = pm.hook.he_method1 he_method1.call_historic(result_callback=callback, kwargs=dict(arg=1)) class Plugin2: @hookimpl def he_method1(self, arg): return arg * 10 pm.register(Plugin2()) if result_callback: assert out == [10, 10] else: assert out == [] def test_with_callbacks_immediately_executed(pm): class Hooks: @hookspec(historic=True) def he_method1(self, arg): pass pm.add_hookspecs(Hooks) class Plugin1: @hookimpl def he_method1(self, arg): return arg * 10 class Plugin2: @hookimpl def he_method1(self, arg): return arg * 20 class Plugin3: @hookimpl def he_method1(self, arg): return arg * 30 out = [] pm.register(Plugin1()) pm.register(Plugin2()) he_method1 = pm.hook.he_method1 he_method1.call_historic(lambda res: out.append(res), dict(arg=1)) assert out == [20, 10] pm.register(Plugin3()) assert out == [20, 10, 30] def test_register_historic_incompat_hookwrapper(pm): class Hooks: @hookspec(historic=True) def he_method1(self, arg): pass pm.add_hookspecs(Hooks) out = [] class Plugin: @hookimpl(hookwrapper=True) def he_method1(self, arg): out.append(arg) with pytest.raises(PluginValidationError): pm.register(Plugin()) def test_call_extra(pm): class Hooks: @hookspec def he_method1(self, arg): pass pm.add_hookspecs(Hooks) def he_method1(arg): return arg * 10 out = pm.hook.he_method1.call_extra([he_method1], dict(arg=1)) assert out == [10] def test_call_with_too_few_args(pm): class Hooks: @hookspec def he_method1(self, arg): pass pm.add_hookspecs(Hooks) class Plugin1: @hookimpl def he_method1(self, arg): 0 / 0 pm.register(Plugin1()) with pytest.raises(HookCallError): with pytest.warns(UserWarning): pm.hook.he_method1() # @pytest.mark.skip(reason='removed subset hook caller function') # def test_subset_hook_caller(pm): # class Hooks: # @hookspec # def he_method1(self, arg): # pass # pm.add_hookspecs(Hooks) # out = [] # class Plugin1: # @hookimpl # def he_method1(self, arg): # out.append(arg) # class Plugin2: # @hookimpl # def he_method1(self, arg): # out.append(arg * 10) # class PluginNo: # pass # plugin1, plugin2, plugin3 = Plugin1(), Plugin2(), PluginNo() # pm.register(plugin1) # pm.register(plugin2) # pm.register(plugin3) # pm.hook.he_method1(arg=1) # assert out == [10, 1] # out[:] = [] # hc = pm.subset_hook_caller("he_method1", [plugin1]) # hc(arg=2) # assert out == [20] # out[:] = [] # hc = pm.subset_hook_caller("he_method1", [plugin2]) # hc(arg=2) # assert out == [2] # out[:] = [] # pm.unregister(plugin1) # hc(arg=2) # assert out == [] # out[:] = [] # pm.hook.he_method1(arg=1) # assert out == [10] def test_get_hookimpls(pm): class Hooks: @hookspec def he_method1(self, arg): pass pm.add_hookspecs(Hooks) assert pm.hook.he_method1.get_hookimpls() == [] class Plugin1: @hookimpl def he_method1(self, arg): pass class Plugin2: @hookimpl def he_method1(self, arg): pass class PluginNo: pass plugin1, plugin2, plugin3 = Plugin1(), Plugin2(), PluginNo() pm.register(plugin1) pm.register(plugin2) pm.register(plugin3) hookimpls = pm.hook.he_method1.get_hookimpls() hook_plugins = [item.plugin for item in hookimpls] assert hook_plugins == [plugin1, plugin2] def test_add_hookspecs_nohooks(pm): with pytest.raises(ValueError): pm.add_hookspecs(10) def test_add_tracefuncs(he_pm): out = [] class api1: @hookimpl def he_method1(self): out.append("he_method1-api1") class api2: @hookimpl def he_method1(self): out.append("he_method1-api2") he_pm.register(api1()) he_pm.register(api2()) def before(hook_name, hook_impls, kwargs): out.append((hook_name, list(hook_impls), kwargs)) def after(outcome, hook_name, hook_impls, kwargs): out.append((outcome, hook_name, list(hook_impls), kwargs)) undo = he_pm.add_hookcall_monitoring(before, after) he_pm.hook.he_method1(arg=1) assert len(out) == 4 assert out[0][0] == "he_method1" assert len(out[0][1]) == 2 assert isinstance(out[0][2], dict) assert out[1] == "he_method1-api2" assert out[2] == "he_method1-api1" assert len(out[3]) == 4 assert out[3][1] == out[0][0] undo() he_pm.hook.he_method1(arg=1) assert len(out) == 4 + 2 def test_hook_tracing(he_pm): saveindent = [] class api1: @hookimpl def he_method1(self): saveindent.append(he_pm.trace.root.indent) class api2: @hookimpl def he_method1(self): saveindent.append(he_pm.trace.root.indent) raise ValueError() he_pm.register(api1()) out = [] he_pm.trace.root.setwriter(out.append) undo = he_pm.enable_tracing() try: indent = he_pm.trace.root.indent he_pm.hook.he_method1(arg=1) assert indent == he_pm.trace.root.indent assert len(out) == 2 assert "he_method1" in out[0] assert "finish" in out[1] out[:] = [] he_pm.register(api2()) # # TODO: decide on exception handling behavior. We currently # # catch errors in multicall so this fails # with pytest.raises(ValueError): # he_pm.hook.he_method1(arg=1) # assert he_pm.trace.root.indent == indent # assert saveindent[0] > indent finally: undo() napari-napari-plugin-engine-67b3fb4/testing/test_testsupport.py000066400000000000000000000023031514256550300251200ustar00rootroot00000000000000def test_spec(test_plugin_manager, add_specification): assert not test_plugin_manager.hook.items() assert not hasattr(test_plugin_manager.hook, 'my_spec') @add_specification def my_spec(arg1, arg2): ... assert hasattr(test_plugin_manager.hook, 'my_spec') assert hasattr(test_plugin_manager.hook.my_spec, 'spec') def test_full(test_plugin_manager, add_specification, add_implementation): relay = test_plugin_manager.hook assert not relay.items() @add_specification def my_spec(arg1, arg2): ... assert hasattr(relay, 'my_spec') assert not relay.my_spec.get_hookimpls() @add_implementation(specname='my_spec') def ttt(arg): return arg + 1 assert relay.my_spec.get_hookimpls()[0].specname == 'my_spec' def test_caller_from_implementation(caller_from_implementation): def test1(arg): return arg + 1 caller = caller_from_implementation(test1) assert caller.get_hookimpls()[0].function == test1 assert caller(arg=1) == [2] def test2(arg): return arg + 1 caller = caller_from_implementation(test2, {'firstresult': True}) assert caller.get_hookimpls()[0].function == test2 assert caller(arg=1) == 2 napari-napari-plugin-engine-67b3fb4/testing/test_tracer.py000066400000000000000000000033761514256550300237770ustar00rootroot00000000000000import pytest from napari_plugin_engine._tracing import TagTracer @pytest.fixture def rootlogger(): return TagTracer() def test_simple(rootlogger): log = rootlogger.get("pytest") log("hello") out = [] rootlogger.setwriter(out.append) log("world") assert len(out) == 1 assert out[0] == "world [pytest]\n" sublog = log.get("collection") sublog("hello") assert out[1] == "hello [pytest:collection]\n" def test_indent(rootlogger): log = rootlogger.get("1") out = [] log.root.setwriter(lambda arg: out.append(arg)) log("hello") log.root.indent += 1 log("line1") log("line2") log.root.indent += 1 log("line3") log("line4") log.root.indent -= 1 log("line5") log.root.indent -= 1 log("last") assert len(out) == 7 names = [x[: x.rfind(" [")] for x in out] assert names == [ "hello", " line1", " line2", " line3", " line4", " line5", "last", ] def test_readable_output_dictargs(rootlogger): out = rootlogger._format_message(["test"], [1]) assert out == "1 [test]\n" out2 = rootlogger._format_message(["test"], ["test", {"a": 1}]) assert out2 == "test [test]\n a: 1\n" def test_setprocessor(rootlogger): log = rootlogger.get("1") log2 = log.get("2") assert log2.tags == tuple("12") out = [] rootlogger.setprocessor(tuple("12"), lambda *args: out.append(args)) log("not seen") log2("seen") assert len(out) == 1 tags, args = out[0] assert "1" in tags assert "2" in tags assert args == ("seen",) l2 = [] rootlogger.setprocessor("1:2", lambda *args: l2.append(args)) log2("seen") tags, args = l2[0] assert args == ("seen",) napari-napari-plugin-engine-67b3fb4/tox.ini000066400000000000000000000020371514256550300207350ustar00rootroot00000000000000[tox] envlist = py{310,311, 312, 313}-{linux,macos,windows} toxworkdir=/tmp/.tox [gh-actions] python = 3.10: py310 3.11: py311 3.12: py312 3.13: py313 [gh-actions:env] PLATFORM = ubuntu-latest: linux macos-latest: macos windows-latest: windows ubuntu-22.04: linux [testenv] platform = linux: linux macos: darwin windows: win32 passenv = CI GITHUB_ACTIONS DISPLAY XAUTHORITY extras = test commands = pytest --color=yes -v --cov --cov-report=xml {posargs} [pytest] filterwarnings = ignore:.*has no hooks:UserWarning [testenv:napari] basepython = python3.10 deps = napari[pyqt, testing] @ git+https://github.com/napari/napari.git commands = pytest -v --color=yes --pyargs napari {posargs} [testenv:linting] skip_install = true deps = pre-commit commands = pre-commit run --all-files --show-diff-on-failure [testenv:docs] deps = sphinx pygments sphinx_autodoc_typehints sphinx_rtd_theme commands = sphinx-build -b html {toxinidir}/docs {toxinidir}/docs/_build/html