├── src └── npe2 │ ├── py.typed │ ├── __main__.py │ ├── manifest │ ├── contributions │ │ ├── _icon.py │ │ ├── _keybindings.py │ │ ├── _submenu.py │ │ ├── __init__.py │ │ ├── _menus.py │ │ ├── _widgets.py │ │ ├── _contributions.py │ │ ├── _sample_data.py │ │ ├── _readers.py │ │ ├── _themes.py │ │ ├── _commands.py │ │ └── _configuration.py │ ├── __init__.py │ ├── menus.py │ ├── package_metadata.py │ ├── _validators.py │ ├── _bases.py │ └── _npe1_adapter.py │ ├── _inspection │ ├── __init__.py │ ├── _compile.py │ └── _setuputils.py │ ├── __init__.py │ ├── _pydantic_compat.py │ ├── implements.pyi │ ├── types.py │ ├── _pytest_plugin.py │ ├── implements.py │ ├── plugin_manager.py │ ├── _command_registry.py │ └── _setuptools_plugin.py ├── _docs ├── example_plugin │ ├── __init__.py │ └── some_module.py ├── templates │ ├── _npe2_sample_data_guide.md.jinja │ ├── _npe2_manifest.md.jinja │ ├── _npe2_menus_guide.md.jinja │ ├── _npe2_contributions.md.jinja │ ├── _npe2_readers_guide.md.jinja │ ├── _npe2_widgets_guide.md.jinja │ └── _npe2_writers_guide.md.jinja ├── example_manifest.yaml └── render.py ├── tests ├── fixtures │ └── my-compiled-plugin │ │ ├── my_module │ │ ├── __init__.py │ │ ├── _b.py │ │ └── _a.py │ │ └── setup.cfg ├── sample │ ├── my_plugin-1.2.3.dist-info │ │ ├── top_level.txt │ │ ├── entry_points.txt │ │ └── METADATA │ ├── my_plugin │ │ ├── __init__.py │ │ └── napari.yaml │ └── _with_decorators.py ├── npe1-plugin │ ├── npe1-plugin-0.0.1.dist-info │ │ ├── top_level.txt │ │ ├── entry_points.txt │ │ ├── METADATA │ │ └── RECORD │ ├── setup.cfg │ └── npe1_module │ │ └── __init__.py ├── test_docs.py ├── test_pm_module.py ├── test_all_plugins.py ├── test_compile.py ├── test_package_meta.py ├── test_pytest_plugin.py ├── test_setuptools_plugin.py ├── test_implements.py ├── test_tmp_plugin.py ├── test_config_contribution.py ├── test_utils.py ├── test_contributions.py ├── test_npe1_adapter.py └── conftest.py ├── codecov.yml ├── docs ├── index.md └── _config.yml ├── .github ├── ISSUE_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── update_changelog.yml │ ├── test_all_plugins.yml │ ├── test_conversion.yml │ └── ci.yml ├── .github_changelog_generator ├── Makefile ├── .pre-commit-config.yaml ├── LICENSE ├── .gitignore ├── README.md └── pyproject.toml /src/npe2/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_docs/example_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/my-compiled-plugin/my_module/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/sample/my_plugin-1.2.3.dist-info/top_level.txt: -------------------------------------------------------------------------------- 1 | my_plugin 2 | -------------------------------------------------------------------------------- /tests/npe1-plugin/npe1-plugin-0.0.1.dist-info/top_level.txt: -------------------------------------------------------------------------------- 1 | npe1_module 2 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 98% 6 | -------------------------------------------------------------------------------- /src/npe2/__main__.py: -------------------------------------------------------------------------------- 1 | from npe2.cli import main 2 | 3 | if __name__ == "__main__": 4 | main() 5 | -------------------------------------------------------------------------------- /tests/npe1-plugin/npe1-plugin-0.0.1.dist-info/entry_points.txt: -------------------------------------------------------------------------------- 1 | [napari.plugin] 2 | npe1-plugin = npe1_module 3 | -------------------------------------------------------------------------------- /tests/sample/my_plugin-1.2.3.dist-info/entry_points.txt: -------------------------------------------------------------------------------- 1 | [napari.manifest] 2 | my-plugin = my_plugin:napari.yaml 3 | -------------------------------------------------------------------------------- /tests/npe1-plugin/npe1-plugin-0.0.1.dist-info/METADATA: -------------------------------------------------------------------------------- 1 | Metadata-Version: 2.1 2 | Name: npe1-plugin 3 | Version: 0.1.0 4 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # napari 2 | 3 | This is a stub file for building the plugin documentation. 4 | It is not used and should not be edited 5 | -------------------------------------------------------------------------------- /tests/npe1-plugin/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = npe1-plugin 3 | version = 0.1.0 4 | 5 | [options.entry_points] 6 | napari.plugin = 7 | npe1-plugin = npe1_module 8 | -------------------------------------------------------------------------------- /src/npe2/manifest/contributions/_icon.py: -------------------------------------------------------------------------------- 1 | from npe2._pydantic_compat import BaseModel 2 | 3 | 4 | class Icon(BaseModel): 5 | light: str | None = None 6 | dark: str | None = None 7 | -------------------------------------------------------------------------------- /tests/sample/my_plugin-1.2.3.dist-info/METADATA: -------------------------------------------------------------------------------- 1 | Metadata-Version: 2.1 2 | Name: my-plugin 3 | Version: 1.2.3 4 | Summary: My napari plugin 5 | License: BSD-3 6 | Author: The Black Knight 7 | -------------------------------------------------------------------------------- /src/npe2/manifest/__init__.py: -------------------------------------------------------------------------------- 1 | from ._package_metadata import PackageMetadata 2 | from .schema import ENTRY_POINT, PluginManifest 3 | 4 | __all__ = ["ENTRY_POINT", "PackageMetadata", "PluginManifest"] 5 | -------------------------------------------------------------------------------- /src/npe2/manifest/menus.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | warnings.warn( 4 | "please import menus from npe2.manifest.contributions", 5 | DeprecationWarning, 6 | stacklevel=2, 7 | ) 8 | 9 | from .contributions._menus import * # noqa 10 | -------------------------------------------------------------------------------- /tests/npe1-plugin/npe1-plugin-0.0.1.dist-info/RECORD: -------------------------------------------------------------------------------- 1 | npe1-plugin-0.0.1.dist-info/RECORD,, 2 | npe1-plugin-0.0.1.dist-info/METADATA,, 3 | npe1-plugin-0.0.1.dist-info/top_level.txt,, 4 | npe1-plugin-0.0.1.dist-info/entry_points.txt,, 5 | ../npe1_module/__init__.py,, 6 | -------------------------------------------------------------------------------- /src/npe2/manifest/package_metadata.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | warnings.warn( 4 | "Please import PackageMetadata from 'npe2' or from 'npe2.manifest'", 5 | DeprecationWarning, 6 | stacklevel=2, 7 | ) 8 | 9 | from ._package_metadata import * # noqa 10 | -------------------------------------------------------------------------------- /tests/fixtures/my-compiled-plugin/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = my_compiled_plugin 3 | version = 0.1.0 4 | 5 | [options.entry_points] 6 | napari.manifest = 7 | my-compiled-plugin = my_module:napari.yaml 8 | 9 | [options.package_data] 10 | my_module = *.yaml 11 | -------------------------------------------------------------------------------- /src/npe2/_inspection/__init__.py: -------------------------------------------------------------------------------- 1 | from ._compile import compile 2 | from ._visitors import ( 3 | NPE2PluginModuleVisitor, 4 | find_npe1_module_contributions, 5 | find_npe2_module_contributions, 6 | ) 7 | 8 | __all__ = [ 9 | "NPE2PluginModuleVisitor", 10 | "compile", 11 | "find_npe1_module_contributions", 12 | "find_npe2_module_contributions", 13 | ] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * npe2 version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | commit-message: 10 | prefix: "ci(dependabot):" 11 | 12 | groups: 13 | github-actions: 14 | patterns: 15 | - "actions/*" 16 | -------------------------------------------------------------------------------- /.github_changelog_generator: -------------------------------------------------------------------------------- 1 | # generate new changelog with 2 | # https://github.com/github-changelog-generator/github-changelog-generator 3 | # set `CHANGELOG_GITHUB_TOKEN` to github token then, 4 | # e.g. `github_changelog_generator --future-release v0.1.0` 5 | user=napari 6 | project=npe2 7 | issues=false 8 | exclude-labels=duplicate,question,invalid,wontfix,hide 9 | add-sections={"tests":{"prefix":"**Tests & CI:**","labels":["tests"]},"refactor":{"prefix":"**Refactors:**","labels":["refactor"]},"documentation":{"prefix":"**Documentation:**","labels":["documentation"]}} 10 | -------------------------------------------------------------------------------- /tests/test_docs.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | from npe2 import PluginManifest 7 | 8 | DOCS_DIR = Path(__file__).parent.parent / "_docs" 9 | 10 | 11 | def test_example_manifest(): 12 | example = DOCS_DIR / "example_manifest.yaml" 13 | assert PluginManifest.from_file(example) 14 | 15 | 16 | @pytest.mark.github_main_only 17 | def test_render_docs(tmp_path, monkeypatch): 18 | sys.path.append(str(DOCS_DIR.parent)) 19 | from _docs.render import main 20 | 21 | assert not list(tmp_path.glob("*.md")) 22 | main(tmp_path) 23 | assert list(tmp_path.glob("*.md")) 24 | -------------------------------------------------------------------------------- /tests/test_pm_module.py: -------------------------------------------------------------------------------- 1 | from npe2 import PluginManager 2 | 3 | 4 | def test_pm_module(): 5 | from npe2 import plugin_manager as pm 6 | 7 | assert pm.instance() is PluginManager.instance() 8 | 9 | # smoke-test checking that a few of the argument-free things work 10 | # they may or may-not be empty depending on other tests in this suite. 11 | pm.iter_widgets() 12 | pm.iter_sample_data() 13 | 14 | # make sure we have it covered. 15 | for k, v in vars(PluginManager).items(): 16 | if k.startswith("_") or isinstance(v, (classmethod, property)): 17 | continue 18 | assert hasattr(pm, k), f"pm.py module is missing function {k!r}" 19 | -------------------------------------------------------------------------------- /tests/test_all_plugins.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import TYPE_CHECKING 3 | 4 | import pytest 5 | 6 | from npe2.cli import app 7 | 8 | if TYPE_CHECKING: 9 | from pathlib import Path 10 | 11 | PLUGIN: str = os.getenv("TEST_PACKAGE_NAME") or "" 12 | if not PLUGIN: 13 | pytest.skip("skipping plugin specific tests", allow_module_level=True) 14 | 15 | 16 | def test_fetch(tmp_path: "Path"): 17 | from typer.testing import CliRunner 18 | 19 | mf_file = tmp_path / "manifest.yaml" 20 | 21 | result = CliRunner().invoke(app, ["fetch", PLUGIN, "-o", str(mf_file)]) 22 | assert result.exit_code == 0 23 | assert PLUGIN in mf_file.read_text() 24 | result2 = CliRunner().invoke(app, ["validate", str(mf_file)]) 25 | assert result2.exit_code == 0 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: docs 2 | 3 | docs: 4 | rm -rf docs/plugins 5 | python _docs/render.py 6 | rm -rf ./napari 7 | git clone --depth 1 --filter=blob:none --sparse -b update-plugin-docs https://github.com/tlambert03/napari.git 8 | (cd napari && git sparse-checkout set docs/plugins) 9 | cp napari/docs/plugins/* docs/plugins 10 | rm -rf ./napari 11 | jb build docs 12 | 13 | # by default this will make a minor version bump (e.g v0.4.16 -> v0.4.17) 14 | LAST := $(shell git tag -l | grep "v[0-9]+*" | awk '!/rc/' | sort -V | tail -1) 15 | SINCE := $(shell git log -1 -s --format=%cd --date=format:'%Y-%m-%d' $(LAST)) 16 | NEXT := $(shell echo $(LAST) | awk -F. -v OFS=. '{$$NF += 1 ; print}') 17 | changelog: 18 | github_changelog_generator --future-release=$(NEXT) --since-commit=$(SINCE) 19 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | autofix_commit_msg: "style: [pre-commit.ci] auto fixes [...]" 4 | autoupdate_commit_msg: "ci: [pre-commit.ci] autoupdate" 5 | 6 | exclude: _docs/example_plugin/some_module.py 7 | 8 | repos: 9 | 10 | - repo: https://github.com/astral-sh/ruff-pre-commit 11 | rev: v0.14.7 12 | hooks: 13 | - id: ruff-check 14 | - id: ruff-format 15 | 16 | - repo: https://github.com/pre-commit/pre-commit-hooks 17 | rev: v6.0.0 18 | hooks: 19 | - id: check-docstring-first 20 | - id: end-of-file-fixer 21 | - id: trailing-whitespace 22 | - id: check-yaml 23 | - id: check-toml 24 | 25 | - repo: https://github.com/pre-commit/mirrors-mypy 26 | rev: v1.19.0 27 | hooks: 28 | - id: mypy 29 | additional_dependencies: 30 | - types-toml 31 | - types-PyYAML 32 | exclude: npe2/implements.pyi|_docs/render.py 33 | -------------------------------------------------------------------------------- /tests/fixtures/my-compiled-plugin/my_module/_b.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from npe2 import implements 5 | else: 6 | D = type("D", (), {"__getattr__": lambda *_: (lambda **_: (lambda f: f))}) 7 | implements = D() 8 | 9 | 10 | @implements.widget(id="some_widget", title="Create my widget", display_name="My Widget") 11 | class SomeWidget: ... 12 | 13 | 14 | @implements.sample_data_generator( 15 | id="my-plugin.generate_random_data", # the plugin-name is optional 16 | title="Generate uniform random data", 17 | key="random_data", 18 | display_name="Some Random Data (512 x 512)", 19 | ) 20 | def random_data(): ... 21 | 22 | 23 | @implements.widget( 24 | id="some_function_widget", 25 | title="Create widget from my function", 26 | display_name="A Widget From a Function", 27 | autogenerate=True, 28 | ) 29 | def make_widget_from_function(x: int, threshold: int): ... 30 | -------------------------------------------------------------------------------- /src/npe2/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import PackageNotFoundError, version 2 | 3 | try: 4 | __version__ = version("npe2") 5 | except PackageNotFoundError: # pragma: no cover 6 | __version__ = "unknown" 7 | __author__ = "Talley Lambert" 8 | __email__ = "talley.lambert@gmail.com" 9 | 10 | 11 | from ._dynamic_plugin import DynamicPlugin 12 | from ._inspection._fetch import fetch_manifest, get_manifest_from_wheel 13 | from ._plugin_manager import PluginContext, PluginManager 14 | from .io_utils import read, read_get_reader, write, write_get_writer 15 | from .manifest import PackageMetadata, PluginManifest 16 | 17 | __all__ = [ 18 | "DynamicPlugin", 19 | "PackageMetadata", 20 | "PluginContext", 21 | "PluginManager", 22 | "PluginManifest", 23 | "__version__", 24 | "fetch_manifest", 25 | "get_manifest_from_wheel", 26 | "read", 27 | "read_get_reader", 28 | "write", 29 | "write_get_writer", 30 | ] 31 | -------------------------------------------------------------------------------- /src/npe2/manifest/contributions/_keybindings.py: -------------------------------------------------------------------------------- 1 | from npe2._pydantic_compat import Field 2 | from npe2.manifest.utils import Executable 3 | 4 | 5 | class KeyBindingContribution(Executable): 6 | command: str = Field( 7 | description="Identifier of the command to run when keybinding is triggered." 8 | ) 9 | # the existence of the command is not validated at registration-time, 10 | # but rather at call time... (since commands from other extensions can be called) 11 | key: str = Field( 12 | description="Key or key sequence (separate simultaneous key presses with " 13 | "a plus-sign e.g. Ctrl+O and sequences with a space e.g. Ctrl+L L for a chord)." 14 | ) 15 | mac: str | None = Field(description="Mac specific key or key sequence.") 16 | linux: str | None = Field(description="Linux specific key or key sequence.") 17 | win: str | None = Field(description="Windows specific key or key sequence.") 18 | when: str | None = Field(description="Condition when the key is active.") 19 | -------------------------------------------------------------------------------- /src/npe2/manifest/contributions/_submenu.py: -------------------------------------------------------------------------------- 1 | from npe2._pydantic_compat import BaseModel, Field 2 | 3 | from ._icon import Icon 4 | 5 | 6 | class SubmenuContribution(BaseModel): 7 | """Contributes a submenu that can contain menu items or other submenus. 8 | 9 | Submenus allow you to organize menu items into hierarchical structures. 10 | Each submenu defines an id, label, and optional icon that can be 11 | referenced by menu items to create nested menu structures. 12 | """ 13 | 14 | id: str = Field(description="Identifier of the menu to display as a submenu.") 15 | label: str = Field( 16 | description="The label of the menu item which leads to this submenu." 17 | ) 18 | icon: str | Icon | None = Field( 19 | None, 20 | description=( 21 | "(Optional) Icon which is used to represent the command in the UI." 22 | " Either a file path, an object with file paths for dark and light" 23 | "themes, or a theme icon references, like `$(zap)`" 24 | ), 25 | ) 26 | -------------------------------------------------------------------------------- /.github/workflows/update_changelog.yml: -------------------------------------------------------------------------------- 1 | name: Update Changelog 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | next_tag: 6 | description: "Next version tag (`vX.Y.Z`)" 7 | required: true 8 | 9 | jobs: 10 | changelog: 11 | runs-on: ubuntu-20.04 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 15 | - name: "✏️ Generate release changelog" 16 | uses: heinrichreimer/github-changelog-generator-action@e60b5a2bd9fcd88dadf6345ff8327863fb8b490f # v2.4 17 | with: 18 | futureRelease: ${{ github.event.inputs.next_tag }} 19 | token: ${{ secrets.GITHUB_TOKEN }} 20 | repo: napari/npe2 21 | - name: Create Pull Request 22 | uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 23 | with: 24 | token: ${{ secrets.GITHUB_TOKEN }} 25 | commit-message: Automatic changelog update 26 | title: changelog ${{ github.event.inputs.next_tag }} 27 | branch: update-changelog 28 | -------------------------------------------------------------------------------- /src/npe2/manifest/contributions/__init__.py: -------------------------------------------------------------------------------- 1 | from ._commands import CommandContribution 2 | from ._configuration import ConfigurationContribution, ConfigurationProperty 3 | from ._contributions import ContributionPoints 4 | from ._menus import MenuCommand, MenuItem, Submenu 5 | from ._readers import ReaderContribution 6 | from ._sample_data import SampleDataContribution, SampleDataGenerator, SampleDataURI 7 | from ._submenu import SubmenuContribution 8 | from ._themes import ThemeColors, ThemeContribution 9 | from ._widgets import WidgetContribution 10 | from ._writers import LayerType, LayerTypeConstraint, WriterContribution 11 | 12 | __all__ = [ 13 | "CommandContribution", 14 | "ConfigurationContribution", 15 | "ConfigurationProperty", 16 | "ContributionPoints", 17 | "LayerType", 18 | "LayerTypeConstraint", 19 | "MenuCommand", 20 | "MenuItem", 21 | "ReaderContribution", 22 | "SampleDataContribution", 23 | "SampleDataGenerator", 24 | "SampleDataURI", 25 | "Submenu", 26 | "SubmenuContribution", 27 | "ThemeColors", 28 | "ThemeContribution", 29 | "WidgetContribution", 30 | "WriterContribution", 31 | ] 32 | -------------------------------------------------------------------------------- /tests/test_compile.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from npe2._inspection import compile 6 | from npe2.manifest.schema import PluginManifest 7 | 8 | 9 | def test_compile(compiled_plugin_dir: Path, tmp_path: Path): 10 | """ 11 | Test that the plugin manager can be compiled. 12 | """ 13 | with pytest.raises(ValueError, match=r"must have an extension of .json, .yaml, or"): 14 | compile(compiled_plugin_dir, "bad_path") 15 | 16 | dest = tmp_path / "output.yaml" 17 | mf = compile(compiled_plugin_dir, dest=dest, packages=["my_module"]) 18 | assert isinstance(mf, PluginManifest) 19 | assert mf.name == "my_compiled_plugin" 20 | assert mf.contributions.commands and len(mf.contributions.commands) == 5 21 | assert dest.exists() 22 | assert PluginManifest.from_file(dest) == mf 23 | 24 | 25 | def test_compile_with_template(compiled_plugin_dir: Path, tmp_path: Path): 26 | """Test building from a template with npe2 compile.""" 27 | template = tmp_path / "template.yaml" 28 | template.write_text("name: my_compiled_plugin\ndisplay_name: Display Name\n") 29 | mf = compile(compiled_plugin_dir, template=template) 30 | assert mf.name == "my_compiled_plugin" 31 | assert mf.display_name == "Display Name" 32 | -------------------------------------------------------------------------------- /tests/fixtures/my-compiled-plugin/my_module/_a.py: -------------------------------------------------------------------------------- 1 | # mypy: disable-error-code=empty-body 2 | from typing import TYPE_CHECKING, Any 3 | 4 | # alternative pattern that does not require npe2 at runtime 5 | if TYPE_CHECKING: 6 | from npe2 import implements 7 | else: 8 | # create no-op `implements.anything(**kwargs)` decorator 9 | D = type("D", (), {"__getattr__": lambda *_: (lambda **_: (lambda f: f))}) 10 | implements = D() 11 | 12 | 13 | @implements.on_activate 14 | def activate(ctx): ... 15 | 16 | 17 | @implements.on_deactivate 18 | def deactivate(ctx): ... 19 | 20 | 21 | @implements.reader( 22 | id="some_reader", 23 | title="Some Reader", 24 | filename_patterns=["*.fzy", "*.fzzy"], 25 | accepts_directories=True, 26 | ) 27 | def get_reader(path: str): ... 28 | 29 | 30 | @implements.writer( 31 | id="my_writer", 32 | title="My Multi-layer Writer", 33 | filename_extensions=["*.tif", "*.tiff"], 34 | layer_types=["image{2,4}", "tracks?"], 35 | ) 36 | @implements.writer( 37 | id="my_writer", 38 | title="My Multi-layer Writer", 39 | filename_extensions=["*.pcd", "*.e57"], 40 | layer_types=["points{1}", "surface+"], 41 | ) 42 | def writer_function( 43 | path: str, layer_data: list[tuple[Any, dict, str]] 44 | ) -> list[str]: ... 45 | -------------------------------------------------------------------------------- /tests/test_package_meta.py: -------------------------------------------------------------------------------- 1 | from npe2 import PackageMetadata 2 | 3 | 4 | def test_package_metadata_version(): 5 | """Test that we intelligently pick the min required metadata version""" 6 | assert PackageMetadata(name="test", version="1.0").metadata_version == "1.0" 7 | pm2 = PackageMetadata(name="test", version="1.0", maintainer="bob") 8 | assert pm2.metadata_version == "1.2" 9 | pm3 = PackageMetadata( 10 | name="test", 11 | version="1.0", 12 | maintainer="bob", 13 | description_content_type="text/markdown", 14 | ) 15 | assert pm3.metadata_version == "2.1" 16 | 17 | 18 | def test_hashable(): 19 | hash(PackageMetadata(name="test", version="1.0")) 20 | 21 | 22 | def test_package_metadata_extra_field(): 23 | pkg = { 24 | "name": "test", 25 | "version": "1.0", 26 | "maintainer": "bob", 27 | "extra_field_that_is_definitely_not_in_the_model": False, 28 | } 29 | 30 | try: 31 | p = PackageMetadata(**pkg) 32 | except Exception as e: 33 | raise AssertionError( 34 | "failed to parse PackageMetadata from a dict with an extra field" 35 | ) from e 36 | 37 | assert p.name == "test" 38 | assert p.version == "1.0" 39 | assert p.maintainer == "bob" 40 | assert not hasattr(p, "extra_field_that_is_definitely_not_in_the_model") 41 | -------------------------------------------------------------------------------- /src/npe2/_pydantic_compat.py: -------------------------------------------------------------------------------- 1 | try: 2 | # pydantic v2 3 | from pydantic.v1 import ( 4 | BaseModel, 5 | Extra, 6 | Field, 7 | PrivateAttr, 8 | ValidationError, 9 | color, 10 | conlist, 11 | constr, 12 | root_validator, 13 | validator, 14 | ) 15 | from pydantic.v1.error_wrappers import ErrorWrapper 16 | from pydantic.v1.fields import SHAPE_LIST 17 | from pydantic.v1.generics import GenericModel 18 | from pydantic.v1.main import ModelMetaclass 19 | except ImportError: 20 | # pydantic v2 21 | from pydantic import ( 22 | BaseModel, 23 | Extra, 24 | Field, 25 | PrivateAttr, 26 | ValidationError, 27 | color, 28 | conlist, 29 | constr, 30 | root_validator, 31 | validator, 32 | ) 33 | from pydantic.error_wrappers import ErrorWrapper 34 | from pydantic.fields import SHAPE_LIST 35 | from pydantic.generics import GenericModel 36 | from pydantic.main import ModelMetaclass 37 | 38 | 39 | __all__ = ( 40 | "SHAPE_LIST", 41 | "BaseModel", 42 | "ErrorWrapper", 43 | "Extra", 44 | "Field", 45 | "GenericModel", 46 | "ModelMetaclass", 47 | "PrivateAttr", 48 | "ValidationError", 49 | "color", 50 | "conlist", 51 | "constr", 52 | "root_validator", 53 | "validator", 54 | ) 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | BSD License 3 | 4 | Copyright (c) 2021, Talley Lambert 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | 10 | 1. Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | 13 | 2. Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | 17 | 3. Neither the name of the copyright holder nor the names of its 18 | contributors may be used to endorse or promote products derived from 19 | this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 22 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 23 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 25 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 26 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 27 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 28 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 29 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /src/npe2/implements.pyi: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from typing import Any, TypeVar 3 | 4 | from npe2._pydantic_compat import BaseModel as BaseModel 5 | 6 | from .manifest import PluginManifest as PluginManifest 7 | from .manifest import contributions as contributions 8 | 9 | T = TypeVar("T", bound=Callable[..., Any]) 10 | 11 | CHECK_ARGS_PARAM: str 12 | 13 | def reader( 14 | *, 15 | id: str, 16 | title: str, 17 | filename_patterns: list[str], 18 | accepts_directories: bool = False, 19 | ensure_args_valid: bool = False, 20 | ) -> Callable[[T], T]: 21 | """Mark a function as a reader contribution""" 22 | 23 | def writer( 24 | *, 25 | id: str, 26 | title: str, 27 | layer_types: list[str], 28 | filename_extensions: list[str] = [], 29 | display_name: str = "", 30 | ensure_args_valid: bool = False, 31 | ) -> Callable[[T], T]: 32 | """Mark function as a writer contribution""" 33 | 34 | def widget( 35 | *, 36 | id: str, 37 | title: str, 38 | display_name: str, 39 | autogenerate: bool = False, 40 | ensure_args_valid: bool = False, 41 | ) -> Callable[[T], T]: 42 | """Mark a function as a widget contribution""" 43 | 44 | def sample_data_generator( 45 | *, 46 | id: str, 47 | title: str, 48 | key: str, 49 | display_name: str, 50 | ensure_args_valid: bool = False, 51 | ) -> Callable[[T], T]: 52 | """Mark a function as a sample data generator contribution""" 53 | 54 | def on_activate(func): 55 | """Mark a function to be called when a plugin is activated.""" 56 | 57 | def on_deactivate(func): 58 | """Mark a function to be called when a plugin is deactivated.""" 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | .DS_Store 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # dotenv 85 | .env 86 | 87 | # virtualenv 88 | .venv 89 | venv/ 90 | ENV/ 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | .spyproject 95 | 96 | # Rope project settings 97 | .ropeproject 98 | 99 | # mkdocs documentation 100 | /site 101 | 102 | # mypy 103 | .mypy_cache/ 104 | .ruff_cache/ 105 | 106 | # IDE settings 107 | .vscode/ 108 | src/npe2/_version.py 109 | 110 | # ignore everything that gets rendered from _docs 111 | docs/plugins/*.md 112 | schema.json 113 | -------------------------------------------------------------------------------- /tests/test_pytest_plugin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | pytest_plugins = "pytester" 4 | 5 | CASE1 = """ 6 | from npe2._pytest_plugin import TestPluginManager 7 | from npe2 import PluginManager 8 | 9 | def test_something_1(npe2pm): 10 | assert isinstance(npe2pm, TestPluginManager) 11 | assert PluginManager.instance() is npe2pm 12 | """ 13 | 14 | CASE2 = """ 15 | import pytest 16 | 17 | def test_something_2(npe2pm, caplog): 18 | npe2pm.discover() 19 | assert "TestPluginManager refusing to discover plugins" in caplog.text 20 | assert len(caplog.records) == 1 21 | assert caplog.records[0].levelname == "WARNING" 22 | """ 23 | 24 | CASE3 = """ 25 | from npe2 import DynamicPlugin 26 | 27 | def test_something_3(npe2pm): 28 | with npe2pm.tmp_plugin(name='some_name') as plugin: 29 | assert isinstance(plugin, DynamicPlugin) 30 | assert plugin.name in npe2pm._manifests 31 | """ 32 | 33 | CASE4 = """ 34 | from npe2 import PluginManifest 35 | 36 | def test_something_4(npe2pm): 37 | mf = PluginManifest(name='some_name') 38 | with npe2pm.tmp_plugin(manifest=mf) as plugin: 39 | assert plugin.name in npe2pm._manifests 40 | assert plugin.manifest is mf 41 | """ 42 | 43 | CASE5 = """ 44 | import pytest 45 | from importlib.metadata import PackageNotFoundError 46 | 47 | def test_something_5(npe2pm): 48 | with pytest.raises(PackageNotFoundError): 49 | npe2pm.tmp_plugin(package='somepackage') 50 | """ 51 | 52 | CASE6 = """ 53 | import pytest 54 | 55 | def test_something_6(npe2pm): 56 | with pytest.raises(FileNotFoundError): 57 | npe2pm.tmp_plugin(manifest='some_path.yaml') 58 | """ 59 | 60 | 61 | @pytest.mark.parametrize("case", [CASE1, CASE2, CASE3, CASE4, CASE5, CASE6]) 62 | def test_npe2pm_fixture(pytester_pretty: pytest.Pytester, case): 63 | """Make sure that the npe2pm fixture works.""" 64 | 65 | # create a temporary pytest test file 66 | pytester_pretty.makepyfile(case) 67 | pytester_pretty.runpytest().assert_outcomes(passed=1) 68 | -------------------------------------------------------------------------------- /tests/sample/my_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any 2 | 3 | from pydantic import BaseModel 4 | 5 | from npe2 import PluginContext 6 | from npe2.types import PathOrPaths 7 | 8 | if TYPE_CHECKING: 9 | import napari.types 10 | 11 | 12 | def activate(context: PluginContext): 13 | @context.register_command("my_plugin.hello_world") 14 | def _hello(): ... 15 | 16 | context.register_command("my_plugin.another_command", lambda: print("yo!")) 17 | 18 | 19 | def deactivate(context: PluginContext): 20 | """just here for tests""" 21 | 22 | 23 | def get_reader(path: PathOrPaths): 24 | if isinstance(path, list): 25 | 26 | def read(path): 27 | assert isinstance(path, list) 28 | return [(None,)] 29 | 30 | return read 31 | assert isinstance(path, str) # please mypy. 32 | if path.endswith(".fzzy"): 33 | 34 | def read(path): 35 | assert isinstance(path, str) 36 | return [(None,)] 37 | 38 | return read 39 | else: 40 | raise ValueError("Test plugin should not receive unknown data") 41 | 42 | 43 | def url_reader(path: str): 44 | if path.startswith("http"): 45 | 46 | def read(path): 47 | return [(None,)] 48 | 49 | return read 50 | 51 | 52 | def writer_function(path: str, layer_data: list[tuple[Any, dict, str]]) -> list[str]: 53 | class Arg(BaseModel): 54 | data: Any 55 | meta: dict 56 | layer_type: str 57 | 58 | for e in layer_data: 59 | Arg(data=e[0], meta=e[1], layer_type=e[2]) 60 | 61 | return [path] 62 | 63 | 64 | def writer_function_single(path: str, layer_data: Any, meta: dict) -> list[str]: 65 | class Arg(BaseModel): 66 | data: Any 67 | meta: dict 68 | 69 | Arg(data=layer_data, meta=meta) 70 | 71 | return [path] 72 | 73 | 74 | class SomeWidget: ... 75 | 76 | 77 | def random_data(): 78 | import numpy as np 79 | 80 | return [np.random.rand(10, 10)] 81 | 82 | 83 | def make_widget_from_function(image: "napari.types.ImageData", threshold: int): ... 84 | -------------------------------------------------------------------------------- /_docs/templates/_npe2_sample_data_guide.md.jinja: -------------------------------------------------------------------------------- 1 | (sample-data-contribution-guide)= 2 | ## Sample Data 3 | 4 | This contribution point allows plugin developers to contribute sample data 5 | that will be accessible in the napari interface via the `File > Open Sample` 6 | menu, or via the command line with `viewer.open_sample`. 7 | 8 | Sample data can be useful for demonstrating the functionality of a given plugin. 9 | It can take the form of a **Sample Data URI** that points to a static resource 10 | (such as a file included in the plugin distribution, or a remote resource), 11 | or **Sample Data Function** that generates layer data on demand. 12 | 13 | Note that unlike reader contributions, sample data contributions are 14 | **always** expected to return data, so returning `[(None,)]` 15 | will cause an error. 16 | 17 | ### Sample Data example 18 | 19 | ::::{tab-set} 20 | :::{tab-item} npe2 21 | **python implementation** 22 | 23 | ```python 24 | # example_plugin.some_module 25 | {{ 'sample_data'|example_implementation }} 26 | ``` 27 | 28 | **manifest** 29 | 30 | See [Sample Data contribution reference](contributions-sample-data) 31 | for field details. 32 | 33 | ```yaml 34 | {{ 'sample_data'|example_contribution }} 35 | ``` 36 | ::: 37 | 38 | :::{tab-item} napari-plugin-engine 39 | 40 | ```{admonition} Deprecated! 41 | This demonstrates the now-deprecated `napari-plugin-engine` pattern. 42 | ``` 43 | 44 | **python implementation** 45 | 46 | [hook specification](https://napari.org/stable/plugins/npe1.html#napari.plugins.hook_specifications.napari_provide_sample_data) 47 | 48 | ```python 49 | import numpy as np 50 | from napari_plugin_engine import napari_hook_implementation 51 | 52 | def _generate_random_data(shape=(512, 512)): 53 | data = np.random.rand(*shape) 54 | return [(data, {'name': 'random data'})] 55 | 56 | @napari_hook_implementation 57 | def napari_provide_sample_data(): 58 | return { 59 | 'random data': _generate_random_data, 60 | 'random image': 'https://picsum.photos/1024', 61 | 'sample_key': { 62 | 'display_name': 'Some Random Data (512 x 512)' 63 | 'data': _generate_random_data, 64 | } 65 | } 66 | ``` 67 | ::: 68 | :::: 69 | -------------------------------------------------------------------------------- /tests/test_setuptools_plugin.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | import zipfile 5 | from pathlib import Path 6 | 7 | import pytest 8 | 9 | from npe2 import PluginManifest 10 | 11 | ROOT = Path(__file__).parent.parent 12 | 13 | TEMPLATE = Path("my_module") / "_napari.yaml" 14 | PYPROJECT = f""" 15 | [build-system] 16 | requires = ["setuptools", "wheel", "npe2 @ file://{ROOT}"] 17 | build-backend = "setuptools.build_meta" 18 | 19 | [tool.npe2] 20 | template="{TEMPLATE}" 21 | """.replace("\\", "\\\\") 22 | 23 | 24 | @pytest.mark.skipif(not os.getenv("CI"), reason="slow, only run on CI") 25 | @pytest.mark.parametrize("dist_type", ["sdist", "wheel"]) 26 | def test_compile(compiled_plugin_dir: Path, tmp_path: Path, dist_type: str) -> None: 27 | """ 28 | Test that the plugin manager can be compiled. 29 | """ 30 | pyproject = compiled_plugin_dir / "pyproject.toml" 31 | pyproject.write_text(PYPROJECT) 32 | 33 | template = compiled_plugin_dir / TEMPLATE 34 | template.write_text("name: my_compiled_plugin\ndisplay_name: My Compiled Plugin\n") 35 | os.chdir(compiled_plugin_dir) 36 | subprocess.check_call([sys.executable, "-m", "build", f"--{dist_type}"]) 37 | dist_dir = compiled_plugin_dir / "dist" 38 | assert dist_dir.is_dir() 39 | if dist_type == "sdist": 40 | # for sdist, test pip install into a temporary directory 41 | # and make sure the compiled manifest is there 42 | dist = next(dist_dir.glob("*.tar.gz")) 43 | site = tmp_path / "site" 44 | subprocess.check_call( 45 | [sys.executable, "-m", "pip", "install", str(dist), "--target", str(site)] 46 | ) 47 | mf_file = site / "my_module" / "napari.yaml" 48 | else: 49 | # for wheel, make sure that the manifest is included in the wheel 50 | dist = next(dist_dir.glob("*.whl")) 51 | with zipfile.ZipFile(dist) as zip: 52 | zip.extractall(dist_dir) 53 | mf_file = dist_dir / "my_module" / "napari.yaml" 54 | 55 | assert mf_file.exists() 56 | mf = PluginManifest.from_file(mf_file) 57 | assert mf.display_name == "My Compiled Plugin" 58 | assert len(mf.contributions.readers) == 1 59 | assert len(mf.contributions.writers) == 2 60 | -------------------------------------------------------------------------------- /_docs/templates/_npe2_manifest.md.jinja: -------------------------------------------------------------------------------- 1 | # Manifest Reference 2 | 3 | ```{important} 4 | Plugin manifests are a feature of the second generation napari plugin engine 5 | ("npe2"). If you are still using the first generation `napari-plugin-engine` 6 | (i.e. the `napari.plugin` entrypoint, along with `@napari_hook_implementation` 7 | decorators) then this page does not apply to your plugin. 8 | ``` 9 | 10 | Every napari plugin needs to ship a manifest file with their package. By 11 | convention, this file is called `napari.yaml` and it is placed in the top level 12 | module of the package, but it can be named anything and placed anywhere. 13 | 14 | You tell napari where to find your manifest by adding a `napari.manifest` [entry 15 | point](https://packaging.python.org/en/latest/specifications/entry-points/) to 16 | your package metadata: 17 | 18 | ```ini 19 | # tell napari where to find to your manifest 20 | [options.entry_points] 21 | napari.manifest = 22 | example-plugin = example_plugin:napari.yaml 23 | 24 | # make sure it gets included in your package 25 | [options.package_data] 26 | example-plugin = napari.yaml 27 | ``` 28 | 29 | ## Fields 30 | 31 | All fields are optional except those in **bold**. 32 | 33 | | Name | Details | 34 | |------|---------| 35 | {%- for key, field in schema.properties.items() %} 36 | {%- if not field.hide_docs %} 37 | | {% if key in schema.required %} **`{{ key }}`** {%else%} `{{ key }}` {%endif%} | {{ field.description }}| 38 | {%- endif %} 39 | {%- endfor %} 40 | 41 | ```{note} 42 | Standard python 43 | [package metadata](https://packaging.python.org/en/latest/specifications/core-metadata/) 44 | from your `setup.cfg` file will also be parsed for version, license, and other info. 45 | ``` 46 | 47 | ## Example 48 | 49 | Here is a complete example of what the manifest of a plugin providing *all* 50 | contributions might look like. (Note: a plugin needn't implement 51 | more than a single contribution type). 52 | 53 | ```{tip} 54 | Both [YAML](https://yaml.org/) and [TOML](https://toml.io/en/) are supported 55 | manifest formats, though YAML is the "default" and more common format. 56 | ``` 57 | 58 | ::::{tab-set} 59 | {% for format in ['yaml', 'toml'] %} 60 | :::{tab-item} {{format}} 61 | ```{{format}} 62 | {{ example[format]() }} 63 | ``` 64 | ::: 65 | {% endfor -%} 66 | :::: 67 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | # Book settings 2 | # Learn more at https://jupyterbook.org/customize/config.html 3 | 4 | title: napari 5 | author: The napari team 6 | logo: images/logo.png 7 | # only_build_toc_files: true 8 | 9 | # Force re-execution of notebooks on each build. 10 | # See https://jupyterbook.org/content/execute.html 11 | execute: 12 | execute_notebooks: force 13 | 14 | # Information about where the book exists on the web 15 | repository: 16 | url: https://github.com/napari/napari # Online location of your book 17 | path_to_book: docs # Optional path to your book, relative to the repository root 18 | branch: main # Which branch of the repository should be used when creating links (optional) 19 | 20 | # Exclude files from build (prevents Sphinx warnings about missing 21 | # files in table of contents _toc.yml) 22 | exclude_patterns: [ 23 | 'ORGANIZATION.md', 24 | 'guides/_viewer_events.md', 25 | 'guides/_layer_events.md', 26 | ] 27 | 28 | # Add GitHub buttons to your book 29 | # See https://jupyterbook.org/customize/config.html#add-a-link-to-your-repository 30 | html: 31 | use_issues_button: true 32 | use_repository_button: true 33 | 34 | sphinx: 35 | extra_extensions: 36 | - sphinx.ext.intersphinx 37 | - sphinx.ext.napoleon 38 | - sphinx.ext.autodoc 39 | - sphinx_tabs.tabs 40 | 41 | config: 42 | autosummary_generate: True 43 | autosummary_imported_members: True 44 | html_theme: furo 45 | html_theme_options: {} 46 | pygments_style: solarized-dark 47 | suppress_warnings: ["myst.header"] 48 | # build the generated files in this repo to preview them 49 | # exclude_patterns: ['**/_*.md'] # includes 50 | templates_path: 51 | - '_templates' 52 | intersphinx_mapping: 53 | python: 54 | - "https://docs.python.org/3" 55 | - null 56 | numpy: 57 | - "https://numpy.org/doc/stable/" 58 | - null 59 | napari_plugin_engine: 60 | - "https://napari-plugin-engine.readthedocs.io/en/latest/" 61 | - "https://napari-plugin-engine.readthedocs.io/en/latest/objects.inv" 62 | magicgui: 63 | - "https://pyapp-kit.github.io/magicgui/" 64 | - "https://pyapp-kit.github.io/magicgui/objects.inv" 65 | napari: 66 | - "https://napari.org/" 67 | - "https://napari.org/docs/dev/objects.inv" 68 | -------------------------------------------------------------------------------- /.github/workflows/test_all_plugins.yml: -------------------------------------------------------------------------------- 1 | name: Test all plugins 2 | 3 | on: 4 | # To run this workflow, trigger it manually, or add the label 'test-all-plugins' to a pull request 5 | pull_request: 6 | types: [ labeled ] 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: test-plugins-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | get-plugins: 15 | if: github.event.label.name == 'test-all-plugins' || github.event_name == 'workflow_dispatch' 16 | runs-on: ubuntu-latest 17 | steps: 18 | - id: plugin_names 19 | # Query npe2api for index of all plugins, select the keys, turn them into an array, select 10 random 20 | # names from that array, convert the "one per line" output back into a string array 21 | # save the string array of 10 plugins into the output from this step 22 | run: | 23 | set -eux 24 | DATA=$(echo $(curl -s https://npe2api.vercel.app/api/plugins | jq -c 'keys' | jq -r '.[]' | shuf -n 10 | jq -R -s 'split("\n") | map(select(. != ""))')) 25 | echo "plugins=$DATA" >> "$GITHUB_OUTPUT" 26 | outputs: 27 | plugins: ${{ steps.plugin_names.outputs.plugins }} 28 | 29 | test_all: 30 | needs: get-plugins 31 | name: ${{ matrix.plugin }} 32 | runs-on: ubuntu-latest 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | plugin: ${{ fromJson(needs.get-plugins.outputs.plugins) }} 37 | defaults: 38 | run: 39 | shell: bash -l {0} 40 | 41 | steps: 42 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 43 | 44 | - uses: tlambert03/setup-qt-libs@19e4ef2d781d81f5f067182e228b54ec90d23b76 # v1.8 45 | 46 | - uses: conda-incubator/setup-miniconda@835234971496cad1653abb28a638a281cf32541f # v3.2.0 47 | with: 48 | python-version: '3.10' 49 | miniforge-variant: Miniforge3 50 | conda-remove-defaults: "true" 51 | miniforge-version: latest 52 | use-mamba: true 53 | 54 | - name: Install npe2 55 | run: pip install -e .[testing] 56 | 57 | - run: sudo apt-get install -y xvfb 58 | - name: Run tests 59 | run: xvfb-run --auto-servernum pytest tests/test_all_plugins.py -s -v --color=yes 60 | env: 61 | TEST_PACKAGE_NAME: ${{ matrix.plugin }} 62 | -------------------------------------------------------------------------------- /_docs/example_plugin/some_module.py: -------------------------------------------------------------------------------- 1 | # python_name: example_plugin._data:fractal 2 | 3 | from typing import TYPE_CHECKING, Any, Optional 4 | 5 | from magicgui import magic_factory 6 | from qtpy.QtWidgets import QWidget 7 | 8 | if TYPE_CHECKING: 9 | import napari.types 10 | import napari.viewer 11 | 12 | from npe2.types import LayerData, PathOrPaths, ReaderFunction 13 | 14 | 15 | def write_points(path: str, layer_data: Any, attributes: dict[str, Any]) -> list[str]: 16 | with open(path, "w"): 17 | ... # save layer_data and attributes to file 18 | 19 | # return path to any file(s) that were successfully written 20 | return [path] 21 | 22 | 23 | def get_reader(path: "PathOrPaths") -> Optional["ReaderFunction"]: 24 | # If we recognize the format, we return the actual reader function 25 | if isinstance(path, str) and path.endswith(".xyz"): 26 | return xyz_file_reader 27 | # otherwise we return None. 28 | return None 29 | 30 | 31 | def xyz_file_reader(path: "PathOrPaths") -> list["LayerData"]: 32 | data = ... # somehow read data from path 33 | layer_attributes = {"name": "etc..."} 34 | return [(data, layer_attributes)] 35 | 36 | 37 | class MyWidget(QWidget): 38 | """Any QtWidgets.QWidget or magicgui.widgets.Widget subclass can be used.""" 39 | 40 | def __init__(self, viewer: "napari.viewer.Viewer", parent=None): 41 | super().__init__(parent) 42 | ... 43 | 44 | 45 | @magic_factory 46 | def widget_factory( 47 | image: "napari.types.ImageData", threshold: int 48 | ) -> "napari.types.LabelsData": 49 | """Generate thresholded image. 50 | 51 | This pattern uses magicgui.magic_factory directly to turn a function 52 | into a callable that returns a widget. 53 | """ 54 | return (image > threshold).astype(int) 55 | 56 | 57 | def threshold( 58 | image: "napari.types.ImageData", threshold: int 59 | ) -> "napari.types.LabelsData": 60 | """Generate thresholded image. 61 | 62 | This function will be turned into a widget using `autogenerate: true`. 63 | """ 64 | return (image > threshold).astype(int) 65 | 66 | 67 | def create_fractal() -> list["LayerData"]: 68 | """An example of a Sample Data Function. 69 | 70 | Note: Sample Data with URIs don't need python code. 71 | """ 72 | data = ... # do something cool to create a fractal 73 | return [(data, {"name": "My cool fractal"})] 74 | -------------------------------------------------------------------------------- /src/npe2/manifest/contributions/_menus.py: -------------------------------------------------------------------------------- 1 | from npe2._pydantic_compat import BaseModel, Field 2 | from npe2.manifest.utils import Executable 3 | 4 | 5 | # user provides this 6 | class _MenuItem(BaseModel): 7 | """Generic menu item contribution.""" 8 | 9 | when: str | None = Field( 10 | description="Condition which must be true to *show* this item in the menu. " 11 | "Note that ``when`` clauses apply to menus and ``enablement`` clauses to " 12 | "commands. The ``enablement`` applies to all menus and even keybindings while " 13 | "the ``when`` only applies to a single menu." 14 | ) 15 | # TODO: declare groups for every menu exposed by napari: 16 | # e.g. `2_compare`, `4_search`, `6_cutcopypaste` 17 | group: str | None = Field( 18 | description="The `group` property defines sorting and grouping of menu items. " 19 | "The `'navigation'` group is special: it will always be sorted to the " 20 | "top/beginning of a menu. By default, the order *inside* a group depends on " 21 | "the `title`. The group-local order of a menu item can be specified by " 22 | "appending @ to the group identifier: e.g. `group: 'myGroup@2'`." 23 | ) 24 | 25 | 26 | class Submenu(_MenuItem): 27 | """Contributes a submenu placement in a menu.""" 28 | 29 | submenu: str = Field( 30 | ..., 31 | description="Identifier of the submenu to display in this item." 32 | "The submenu must be declared in the 'submenus' -section", 33 | ) 34 | # if submenu doesn't exist, you get: 35 | # Menu item references a submenu ...` which is not defined in the 'submenus' section 36 | 37 | 38 | class MenuCommand(_MenuItem, Executable): 39 | """Contributes a command in a menu.""" 40 | 41 | command: str = Field( 42 | ..., 43 | description="Identifier of the command to execute. " 44 | "The command must be declared in the 'commands' section", 45 | ) 46 | # if command doesn't exist, you get: 47 | # "Menu item references a command `...` which is not defined in the 48 | # 'commands' section." 49 | alt: str | None = Field( 50 | description="Identifier of an alternative command to execute. " 51 | "It will be shown and invoked when pressing Alt while opening a menu." 52 | "The command must be declared in the 'commands' section" 53 | ) 54 | 55 | 56 | MenuItem = MenuCommand | Submenu 57 | -------------------------------------------------------------------------------- /src/npe2/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable, Mapping, Sequence 4 | from typing import ( 5 | TYPE_CHECKING, 6 | Literal, 7 | NewType, 8 | Protocol, 9 | Union, 10 | ) 11 | 12 | if TYPE_CHECKING: 13 | import magicgui.widgets 14 | import numpy as np 15 | import qtpy.QtWidgets 16 | 17 | 18 | # General types 19 | 20 | # PathLike = Union[str, pathlib.Path] # we really have to pick one 21 | PathLike = str 22 | PathOrPaths = PathLike | Sequence[PathLike] 23 | PythonName = NewType("PythonName", str) 24 | 25 | # Layer-related types 26 | 27 | 28 | class ArrayLike(Protocol): 29 | @property 30 | def shape(self) -> tuple[int, ...]: ... 31 | 32 | @property 33 | def ndim(self) -> int: ... 34 | 35 | @property 36 | def dtype(self) -> np.dtype: ... 37 | 38 | def __array__(self) -> np.ndarray: ... # pragma: no cover 39 | 40 | 41 | LayerName = Literal[ 42 | "graph", "image", "labels", "points", "shapes", "surface", "tracks", "vectors" 43 | ] 44 | Metadata = Mapping 45 | DataType = ArrayLike | Sequence[ArrayLike] 46 | FullLayerData = tuple[DataType, Metadata, LayerName] 47 | LayerData = tuple[DataType] | tuple[DataType, Metadata] | FullLayerData 48 | 49 | # ########################## CONTRIBUTIONS ################################# 50 | 51 | # WidgetContribution.command must point to a WidgetCreator 52 | Widget = Union["magicgui.widgets.Widget", "qtpy.QtWidgets.QWidget"] 53 | WidgetCreator = Callable[..., Widget] 54 | 55 | # ReaderContribution.command must point to a ReaderGetter 56 | ReaderFunction = Callable[[PathOrPaths], list[LayerData]] 57 | ReaderGetter = Callable[[PathOrPaths], ReaderFunction | None] 58 | 59 | 60 | # SampleDataGenerator.command must point to a SampleDataCreator 61 | SampleDataCreator = Callable[..., list[LayerData]] 62 | 63 | # WriterContribution.command must point to a WriterFunction 64 | # Writers that take at most one layer must provide a SingleWriterFunction command. 65 | # Otherwise, they must provide a MultiWriterFunction. 66 | # where the number of layers they take is defined as 67 | # n = sum(ltc.max() for ltc in WriterContribution.layer_type_constraints()) 68 | SingleWriterFunction = Callable[[str, DataType, Metadata], list[str]] 69 | MultiWriterFunction = Callable[[str, list[FullLayerData]], list[str]] 70 | WriterFunction = SingleWriterFunction | MultiWriterFunction 71 | 72 | # ########################################################################## 73 | -------------------------------------------------------------------------------- /src/npe2/manifest/contributions/_widgets.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | from typing import TYPE_CHECKING 5 | 6 | from npe2._pydantic_compat import Extra, Field 7 | from npe2.manifest.utils import Executable 8 | from npe2.types import Widget 9 | 10 | if TYPE_CHECKING: 11 | from npe2._command_registry import CommandRegistry 12 | 13 | 14 | class WidgetContribution(Executable[Widget]): 15 | """Contribute a widget that can be added to the napari viewer. 16 | 17 | Widget contributions point to a **command** that, when called, returns a widget 18 | *instance*; this includes functions that return a widget instance, (e.g. those 19 | decorated with `magicgui.magic_factory`) and subclasses of either 20 | [`QtWidgets.QWidget`](https://doc.qt.io/qt-5/qwidget.html) or 21 | [`magicgui.widgets.Widget`](https://napari.org/magicgui/api/_autosummary/magicgui.widgets._bases.Widget.html). 22 | 23 | Optionally, **autogenerate** may be used to create a widget (using 24 | [magicgui](https://napari.org/magicgui/)) from a command. (In this case, the 25 | command needn't return a widget instance; it can be any function suitable as an 26 | argument to `magicgui.magicgui()`.) 27 | """ 28 | 29 | command: str = Field( 30 | ..., 31 | description="Identifier of a command that returns a widget instance. " 32 | "Or, if `autogenerate` is `True`, any command suitable as an argument " 33 | "to `magicgui.magicgui()`.", 34 | ) 35 | display_name: str = Field( 36 | ..., description="Name for the widget, as presented in the UI." 37 | ) 38 | autogenerate: bool = Field( 39 | default=False, 40 | description="If true, a widget will be autogenerated from the signature of " 41 | "the associated command using [magicgui](https://napari.org/magicgui/).", 42 | ) 43 | 44 | class Config: 45 | extra = Extra.forbid 46 | 47 | def get_callable( 48 | self, _registry: CommandRegistry | None = None 49 | ) -> Callable[..., Widget]: 50 | func = super().get_callable() 51 | if self.autogenerate: 52 | try: 53 | from magicgui import magic_factory 54 | except ImportError as e: 55 | raise ImportError( 56 | "To use autogeneration, you must have magicgui installed." 57 | ) from e 58 | 59 | return magic_factory(func) 60 | return func 61 | -------------------------------------------------------------------------------- /src/npe2/manifest/contributions/_contributions.py: -------------------------------------------------------------------------------- 1 | from npe2._pydantic_compat import BaseModel, Field, validator 2 | 3 | from ._commands import CommandContribution 4 | from ._configuration import ConfigurationContribution 5 | from ._keybindings import KeyBindingContribution 6 | from ._menus import MenuItem 7 | from ._readers import ReaderContribution 8 | from ._sample_data import SampleDataContribution, SampleDataGenerator, SampleDataURI 9 | from ._submenu import SubmenuContribution 10 | from ._themes import ThemeContribution 11 | from ._widgets import WidgetContribution 12 | from ._writers import WriterContribution 13 | 14 | __all__ = [ 15 | "CommandContribution", 16 | "ContributionPoints", 17 | "KeyBindingContribution", 18 | "MenuItem", 19 | "ReaderContribution", 20 | "SampleDataContribution", 21 | "SampleDataGenerator", 22 | "SampleDataURI", 23 | "SubmenuContribution", 24 | "ThemeContribution", 25 | "WidgetContribution", 26 | "WriterContribution", 27 | ] 28 | 29 | 30 | class ContributionPoints(BaseModel): 31 | commands: list[CommandContribution] | None 32 | readers: list[ReaderContribution] | None 33 | writers: list[WriterContribution] | None 34 | widgets: list[WidgetContribution] | None 35 | sample_data: list[SampleDataContribution] | None 36 | themes: list[ThemeContribution] | None 37 | menus: dict[str, list[MenuItem]] = Field( 38 | default_factory=dict, 39 | description="Add menu items to existing napari menus." 40 | "A menu item can be a command, such as open a widget, or a submenu." 41 | "Using menu items, nested hierarchies can be created within napari menus." 42 | "This allows you to organize your plugin's contributions within" 43 | "napari's menu structure.", 44 | ) 45 | submenus: list[SubmenuContribution] | None 46 | keybindings: list[KeyBindingContribution] | None = Field(None, hide_docs=True) 47 | 48 | configuration: list[ConfigurationContribution] = Field( 49 | default_factory=list, 50 | hide_docs=True, 51 | description="Configuration options for this plugin." 52 | "This section can either be a single object, representing a single category of" 53 | "settings, or an array of objects, representing multiple categories of" 54 | "settings. If there are multiple categories of settings, the Settings editor" 55 | "will show a submenu in the table of contents for that extension, and the title" 56 | "keys will be used for the submenu entry names.", 57 | ) 58 | 59 | @validator("configuration", pre=True) 60 | def _to_list(cls, v): 61 | return v if isinstance(v, list) else [v] 62 | -------------------------------------------------------------------------------- /src/npe2/manifest/contributions/_sample_data.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from typing import TYPE_CHECKING 5 | 6 | from npe2._pydantic_compat import Field, GenericModel 7 | from npe2.manifest.utils import Executable 8 | from npe2.types import LayerData 9 | 10 | if TYPE_CHECKING: 11 | from npe2._command_registry import CommandRegistry 12 | 13 | 14 | class _SampleDataContribution(GenericModel, ABC): 15 | """Contribute sample data for use in napari. 16 | 17 | Sample data can take the form of a **command** that returns layer data, or a simple 18 | path or **uri** to a local or remote resource (assuming there is a reader plugin 19 | capable of reading that path/URI). 20 | """ 21 | 22 | key: str = Field(..., description="A unique key to identify this sample.") 23 | display_name: str = Field( 24 | ..., description="String to show in the UI when referring to this sample" 25 | ) 26 | 27 | @abstractmethod 28 | def open( 29 | self, *args, _registry: CommandRegistry | None = None, **kwargs 30 | ) -> list[LayerData]: ... 31 | 32 | 33 | class SampleDataGenerator(_SampleDataContribution, Executable[list[LayerData]]): 34 | """Contribute a callable command that creates data on demand.""" 35 | 36 | command: str = Field( 37 | ..., 38 | description="Identifier of a command that returns layer data tuple. " 39 | "Note that this command cannot return `[(None,)]`.", 40 | ) 41 | 42 | def open( 43 | self, *args, _registry: CommandRegistry | None = None, **kwargs 44 | ) -> list[LayerData]: 45 | return self.exec(args, kwargs, _registry=_registry) 46 | 47 | class Config: 48 | title = "Sample Data Function" 49 | 50 | 51 | class SampleDataURI(_SampleDataContribution): 52 | """Contribute a URI to static local or remote data. This can be data included in 53 | the plugin package, or a URL to remote data. The URI must be readable by either 54 | napari's builtin reader, or by a plugin that is included/required.""" 55 | 56 | uri: str = Field( 57 | ..., 58 | description="Path or URL to a data resource. " 59 | "This URI should be a valid input to `io_utils.read`", 60 | ) 61 | reader_plugin: str | None = Field( 62 | None, 63 | description="Name of plugin to use to open URI", 64 | ) 65 | 66 | def open(self, *args, **kwargs) -> list[LayerData]: 67 | from npe2.io_utils import read 68 | 69 | return read([self.uri], plugin_name=self.reader_plugin, stack=False) 70 | 71 | class Config: 72 | title = "Sample Data URI" 73 | 74 | 75 | SampleDataContribution = SampleDataGenerator | SampleDataURI 76 | -------------------------------------------------------------------------------- /tests/npe1-plugin/npe1_module/__init__.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | import numpy as np 4 | from magicgui import magic_factory 5 | from napari_plugin_engine import napari_hook_implementation 6 | 7 | 8 | class MyWidget: ... 9 | 10 | 11 | def some_function(x: int): ... 12 | 13 | 14 | def gen_data(): ... 15 | 16 | 17 | @napari_hook_implementation 18 | def napari_get_reader(path): ... 19 | 20 | 21 | @napari_hook_implementation(specname="napari_get_reader") 22 | def napari_other_reader(path): ... 23 | 24 | 25 | @napari_hook_implementation 26 | def napari_write_image(path, data, meta): ... 27 | 28 | 29 | @napari_hook_implementation 30 | def napari_write_labels(path, data, meta): ... 31 | 32 | 33 | @napari_hook_implementation(specname="napari_write_labels") 34 | def napari_other_write_labels(path, data, meta): ... 35 | 36 | 37 | @napari_hook_implementation 38 | def napari_provide_sample_data(): 39 | return { 40 | "random data": gen_data, 41 | "local data": partial(np.ones, (4, 4)), 42 | "random image": "https://picsum.photos/1024", 43 | "sample_key": { 44 | "display_name": "Some Random Data (512 x 512)", 45 | "data": gen_data, 46 | }, 47 | "local_ones": { 48 | "display_name": "Some local ones", 49 | "data": partial(np.ones, (4, 4)), 50 | }, 51 | } 52 | 53 | 54 | @napari_hook_implementation 55 | def napari_experimental_provide_theme(): 56 | return { 57 | "super_dark": { 58 | "name": "super_dark", 59 | "background": "rgb(12, 12, 12)", 60 | "foreground": "rgb(65, 72, 81)", 61 | "primary": "rgb(90, 98, 108)", 62 | "secondary": "rgb(134, 142, 147)", 63 | "highlight": "rgb(106, 115, 128)", 64 | "text": "rgb(240, 241, 242)", 65 | "icon": "rgb(209, 210, 212)", 66 | "warning": "rgb(153, 18, 31)", 67 | "current": "rgb(0, 122, 204)", 68 | "syntax_style": "native", 69 | "console": "rgb(0, 0, 0)", 70 | "canvas": "black", 71 | }, 72 | "pretty_light": { 73 | "background": "rgb(192, 223, 139)", 74 | }, 75 | } 76 | 77 | 78 | factory = magic_factory(some_function) 79 | 80 | 81 | @napari_hook_implementation 82 | def napari_experimental_provide_dock_widget(): 83 | @magic_factory 84 | def local_widget(y: str): ... 85 | 86 | return [ 87 | MyWidget, 88 | (factory, {"name": "My Other Widget"}), 89 | (local_widget, {"name": "Local Widget"}), 90 | ] 91 | 92 | 93 | @napari_hook_implementation 94 | def napari_experimental_provide_function(): 95 | def local_function(x: int): ... 96 | 97 | return [some_function, local_function] 98 | -------------------------------------------------------------------------------- /tests/sample/_with_decorators.py: -------------------------------------------------------------------------------- 1 | # mypy: disable-error-code=empty-body 2 | """This module mimics all of the contributions my-plugin... 3 | but is used to reverse-engineer the manifest.""" 4 | 5 | from typing import TYPE_CHECKING, Any 6 | 7 | # to test various ways that this can be imported, since we're using static parsing. 8 | import npe2.implements 9 | import npe2.implements as impls 10 | from npe2 import implements 11 | from npe2.implements import reader 12 | 13 | # alternative pattern that does not require npe2 at runtime 14 | if TYPE_CHECKING: 15 | from npe2 import implements as noimport 16 | else: 17 | # create no-op `implements.anything(**kwargs)` decorator 18 | D = type("D", (), {"__getattr__": lambda *_: (lambda **_: (lambda f: f))}) 19 | noimport = D() 20 | 21 | 22 | @implements.on_activate 23 | def activate(ctx): ... 24 | 25 | 26 | @implements.on_deactivate 27 | def deactivate(ctx): ... 28 | 29 | 30 | @implements.reader( 31 | id="some_reader", 32 | title="Some Reader", 33 | filename_patterns=["*.fzy", "*.fzzy"], 34 | accepts_directories=True, 35 | ) 36 | def get_reader(path: str): ... 37 | 38 | 39 | @reader( 40 | id="url_reader", 41 | title="URL Reader", 42 | filename_patterns=["http://*", "https://*"], 43 | accepts_directories=False, 44 | ensure_args_valid=True, 45 | ) 46 | def url_reader(path: str): ... 47 | 48 | 49 | @noimport.writer( 50 | id="my_writer", 51 | title="My Multi-layer Writer", 52 | filename_extensions=["*.tif", "*.tiff"], 53 | layer_types=["image{2,4}", "tracks?"], 54 | ) 55 | @implements.writer( 56 | id="my_writer", 57 | title="My Multi-layer Writer", 58 | filename_extensions=["*.pcd", "*.e57"], 59 | layer_types=["points{1}", "surface+"], 60 | ) 61 | def writer_function( 62 | path: str, layer_data: list[tuple[Any, dict, str]] 63 | ) -> list[str]: ... 64 | 65 | 66 | @implements.writer( 67 | id="my_single_writer", 68 | title="My single-layer Writer", 69 | filename_extensions=["*.xyz"], 70 | layer_types=["labels"], 71 | ) 72 | def writer_function_single(path: str, layer_data: Any, meta: dict) -> list[str]: ... 73 | 74 | 75 | @npe2.implements.widget( 76 | id="some_widget", title="Create my widget", display_name="My Widget" 77 | ) 78 | class SomeWidget: ... 79 | 80 | 81 | @npe2.implements.sample_data_generator( 82 | id="my-plugin.generate_random_data", # the plugin-name is optional 83 | title="Generate uniform random data", 84 | key="random_data", 85 | display_name="Some Random Data (512 x 512)", 86 | ) 87 | def random_data(): ... 88 | 89 | 90 | @impls.widget( 91 | id="some_function_widget", 92 | title="Create widget from my function", 93 | display_name="A Widget From a Function", 94 | autogenerate=True, 95 | ) 96 | def make_widget_from_function(x: int, threshold: int): ... 97 | -------------------------------------------------------------------------------- /src/npe2/manifest/contributions/_readers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import wraps 4 | from typing import TYPE_CHECKING 5 | 6 | from npe2._pydantic_compat import Extra, Field 7 | from npe2.manifest.utils import Executable, v2_to_v1 8 | from npe2.types import ReaderFunction 9 | 10 | if TYPE_CHECKING: 11 | from npe2._command_registry import CommandRegistry 12 | 13 | 14 | class ReaderContribution(Executable[ReaderFunction | None]): 15 | """Contribute a file reader. 16 | 17 | Readers may be associated with specific **filename_patterns** (e.g. "*.tif", 18 | "*.zip") and are invoked whenever `viewer.open('some/path')` is used on the 19 | command line, or when a user opens a file in the graphical user interface by 20 | dropping a file into the canvas, or using `File -> Open...` 21 | """ 22 | 23 | command: str = Field( 24 | ..., description="Identifier of the command providing `napari_get_reader`." 25 | ) 26 | filename_patterns: list[str] = Field( 27 | ..., 28 | description="List of filename patterns (for fnmatch) that this reader can " 29 | "accept. Reader will be tried only if `fnmatch(filename, pattern) == True`. " 30 | "Use `['*']` to match all filenames.", 31 | ) 32 | accepts_directories: bool = Field( 33 | False, description="Whether this reader accepts directories" 34 | ) 35 | 36 | class Config: 37 | extra = Extra.forbid 38 | 39 | def __hash__(self): 40 | return hash( 41 | (self.command, tuple(self.filename_patterns), self.accepts_directories) 42 | ) 43 | 44 | def exec( 45 | self, 46 | args: tuple = (), 47 | kwargs: dict | None = None, 48 | _registry: CommandRegistry | None = None, 49 | ): 50 | """ 51 | We are trying to simplify internal npe2 logic to always deal with a 52 | (list[str], bool) pair instead of Union[PathLike, Seq[Pathlike]]. We 53 | thus wrap the Reader Contributions to still give them the old api. Later 54 | on we could add a "if manifest.version == 2" or similar to not have this 55 | backward-compatibility logic for new plugins. 56 | """ 57 | if kwargs is None: # pragma: no cover 58 | kwargs = {} 59 | kwargs = kwargs.copy() 60 | stack = kwargs.pop("stack", None) 61 | assert stack is not None 62 | kwargs["path"] = v2_to_v1(kwargs["path"], stack) 63 | callable_ = super().exec(args=args, kwargs=kwargs, _registry=_registry) 64 | 65 | if callable_ is None: # pragma: no cover 66 | return None 67 | 68 | @wraps(callable_) 69 | def npe1_compat(paths, *, stack): 70 | path = v2_to_v1(paths, stack) 71 | return callable_(path) 72 | 73 | return npe1_compat 74 | -------------------------------------------------------------------------------- /src/npe2/manifest/contributions/_themes.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Literal 3 | 4 | from npe2._pydantic_compat import BaseModel, Field, color 5 | 6 | 7 | # pydantic doesn't implement color equality? 8 | class Color(color.Color): 9 | def __eq__(self, __o: object) -> bool: 10 | if not isinstance(__o, color.Color): 11 | return False # pragma: no cover 12 | return self.as_rgb_tuple() == __o.as_rgb_tuple() 13 | 14 | 15 | class ThemeColors(BaseModel): 16 | canvas: Color | None 17 | console: Color | None 18 | background: Color | None 19 | foreground: Color | None 20 | primary: Color | None 21 | secondary: Color | None 22 | highlight: Color | None 23 | text: Color | None 24 | icon: Color | None 25 | warning: Color | None 26 | current: Color | None 27 | 28 | 29 | _color_keys = ", ".join([f"`{k}`" for k in ThemeColors.__fields__]) 30 | _color_args = """ 31 | - name: `"Black"`, `"azure"` 32 | - hexadecimal value: `"0x000"`, `"#FFFFFF"`, `"7fffd4"` 33 | - RGB/RGBA tuples: `(255, 255, 255)`, `(255, 255, 255, 0.5)` 34 | - RGB/RGBA strings: `"rgb(255, 255, 255)"`, `"rgba(255, 255, 255, 0.5)`" 35 | - HSL strings: "`hsl(270, 60%, 70%)"`, `"hsl(270, 60%, 70%, .5)`" 36 | """ 37 | 38 | 39 | class ThemeContribution(BaseModel): 40 | """Contribute a color theme to napari. 41 | 42 | You must specify an **id**, **label**, and whether the theme is a dark theme or a 43 | light theme **type** (such that the rest of napari changes to match your theme). 44 | Any color keys omitted from the theme contribution will use the default napari 45 | dark/light theme colors. 46 | """ 47 | 48 | # TODO: do we need both id and label? 49 | id: str = Field( 50 | description="Identifier of the color theme as used in the user settings." 51 | ) 52 | label: str = Field(description="Label of the color theme as shown in the UI.") 53 | type: Literal["dark", "light"] = Field( 54 | description="Base theme type, used for icons and filling in unprovided colors. " 55 | "Must be either `'dark'` or `'light'`." 56 | ) 57 | syntax_style: str | None 58 | colors: ThemeColors = Field( 59 | description=f"Theme colors. Valid keys include: {_color_keys}. All keys " 60 | "are optional. Color values can be defined via:\n" 61 | ' - name: `"Black"`, `"azure"`\n' 62 | ' - hexadecimal value: `"0x000"`, `"#FFFFFF"`, `"7fffd4"`\n' 63 | " - RGB/RGBA tuples: `(255, 255, 255)`, `(255, 255, 255, 0.5)`\n" 64 | ' - RGB/RGBA strings: `"rgb(255, 255, 255)"`, `"rgba(255, 255, 255, 0.5)`"\n' 65 | ' - HSL strings: "`hsl(270, 60%, 70%)"`, `"hsl(270, 60%, 70%, .5)`"\n' 66 | ) 67 | font_size: str = Field( 68 | default="12pt" if sys.platform == "darwin" else "9pt", 69 | description="Font size (in points, pt) used in the application.", 70 | ) 71 | -------------------------------------------------------------------------------- /_docs/templates/_npe2_menus_guide.md.jinja: -------------------------------------------------------------------------------- 1 | (menus-contribution-guide)= 2 | ## Menus 3 | 4 | Menu contributions enable plugin developers to add new items or submenus to *existing* napari menus, allowing commands to be executed directly through the menu interface. This is a great mechanism for exposing simple commands or widgets that are generally applicable to a wide range of data. 5 | 6 | ### `MenuItem` and `Submenu` Contributions 7 | 8 | Menu entries are defined using two primary mechanisms in the plugin manifest: 9 | 10 | - **MenuItem**: Adds a command to an existing menu location. 11 | - **Submenu**: Creates a new submenu (referenced by ID) that can itself contain menu items or other submenus. 12 | 13 | The structure of menu contributions requires first defining a command, then mapping it to a menu location. 14 | 15 | ### Example: Thresholding submenu 16 | 17 | ::::{tab-set} 18 | :::{tab-item} manifest 19 | 20 | ```yaml 21 | name: napari-demo 22 | display_name: Demo plugin 23 | 24 | contributions: 25 | commands: 26 | - id: napari-demo.all_thresholds 27 | title: Try All Thresholds 28 | python_name: napari_demo:all_thresholds 29 | - id: napari-demo.threshold_otsu 30 | title: Otsu Threshold 31 | python_name: napari_demo:threshold_otsu 32 | - id: napari-demo.threshold_li 33 | title: Li Threshold 34 | python_name: napari_demo:threshold_li 35 | 36 | menus: 37 | napari/layers/segment: 38 | - submenu: threshold 39 | - command: napari-demo.all_thresholds 40 | threshold: 41 | - command: napari-demo.threshold_otsu 42 | - command: napari-demo.threshold_li 43 | 44 | submenus: 45 | - id: threshold 46 | label: Thresholding 47 | ``` 48 | ::: 49 | 50 | :::{tab-item} python implementation 51 | 52 | ```python 53 | # napari_demo module 54 | def all_thresholds(viewer: 'napari.viewer.Viewer'): 55 | ... 56 | 57 | def threshold_otsu(image: 'napari.types.ImageData') -> 'napari.types.LabelsData': 58 | ... 59 | 60 | def threshold_li(image: 'napari.types.ImageData') -> 'napari.types.LabelsData': 61 | ... 62 | ``` 63 | ::: 64 | :::: 65 | 66 | ### Guidelines 67 | 68 | - **Use menus for discoverability**: Menu contributions surface useful plugin functionality in an intuitive way. 69 | - **Separate UI concerns**: Commands exposed via menus should avoid opening dialogs unnecessarily unless the user has selected them. 70 | 71 | 72 | ### Menu ID Reference 73 | 74 | Here's the full list of contributable menus. [source](https://github.com/napari/napari/blob/main/src/napari/_app_model/constants/_menus.py). 75 | 76 | ``` 77 | napari/file/new_layer 78 | napari/file/io_utilities 79 | napari/file/acquire 80 | 81 | napari/layers/visualize 82 | napari/layers/annotate 83 | 84 | napari/layers/data 85 | napari/layers/layer_type 86 | 87 | napari/layers/transform 88 | napari/layers/measure 89 | 90 | napari/layers/filter 91 | napari/layers/register 92 | napari/layers/project 93 | napari/layers/segment 94 | napari/layers/track 95 | napari/layers/classify 96 | ``` 97 | -------------------------------------------------------------------------------- /_docs/example_manifest.yaml: -------------------------------------------------------------------------------- 1 | name: example-plugin 2 | display_name: Example Plugin 3 | contributions: 4 | commands: 5 | - id: example-plugin.hello_world 6 | title: Hello World 7 | - id: example-plugin.read_xyz 8 | title: Read ".xyz" files 9 | python_name: example_plugin.some_module:get_reader 10 | - id: example-plugin.write_points 11 | title: Save points layer to csv 12 | python_name: example_plugin.some_module:write_points 13 | - id: example-plugin.my_widget 14 | title: Open my widget 15 | python_name: example_plugin.some_module:MyWidget 16 | - id: example-plugin.do_threshold 17 | title: Perform threshold on image, return new image 18 | python_name: example_plugin.some_module:threshold 19 | - id: example-plugin.threshold_otsu 20 | title: Threshold using Otsu's method 21 | python_name: example_plugin.some_module:threshold_otsu 22 | - id: example-plugin.threshold_li 23 | title: Threshold using Li's method 24 | python_name: example_plugin.some_module:threshold_li 25 | - id: example-plugin.all_thresholds 26 | title: All thresholds 27 | python_name: example_plugin.some_module:all_thresholds 28 | - id: example-plugin.threshold_widget 29 | title: Make threshold widget with magic_factory 30 | python_name: example_plugin.some_module:widget_factory 31 | - id: example-plugin.data.fractal 32 | title: Create fractal image 33 | python_name: example_plugin.some_module:create_fractal 34 | readers: 35 | - command: example-plugin.read_xyz 36 | filename_patterns: ["*.xyz"] 37 | accepts_directories: false 38 | writers: 39 | - command: example-plugin.write_points 40 | filename_extensions: [".csv"] 41 | layer_types: ["points"] 42 | widgets: 43 | - command: example-plugin.my_widget 44 | display_name: Wizard 45 | - command: example-plugin.threshold_widget 46 | display_name: Threshold 47 | - command: example-plugin.do_threshold 48 | display_name: Threshold 49 | autogenerate: true 50 | menus: 51 | napari/layers/segment: 52 | - submenu: threshold 53 | - command: example-plugin.all_thresholds 54 | threshold: 55 | - command: example-plugin.threshold_otsu 56 | - command: example-plugin.threshold_li 57 | submenus: 58 | - id: threshold 59 | label: Thresholding 60 | themes: 61 | - label: "Monokai" 62 | id: "monokai" 63 | type: "dark" 64 | syntax_style: "monokai" 65 | colors: 66 | canvas: "#000000" 67 | console: "#000000" 68 | background: "#272822" 69 | foreground: "#75715e" 70 | primary: "#cfcfc2" 71 | secondary: "#f8f8f2" 72 | highlight: "#e6db74" 73 | text: "#a1ef34" 74 | icon: "#a1ef34" 75 | warning: "#f92672" 76 | current: "#66d9ef" 77 | sample_data: 78 | - key: fractal 79 | display_name: Fractal 80 | command: example-plugin.data.fractal 81 | - key: napari 82 | display_name: Tabueran Kiribati 83 | uri: https://en.wikipedia.org/wiki/Napari#/media/File:Tabuaeran_Kiribati.jpg 84 | -------------------------------------------------------------------------------- /src/npe2/manifest/_validators.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | _package_name = "([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])" 4 | _python_identifier = "([a-zA-Z_][a-zA-Z_0-9]*)" 5 | 6 | # how do we deal with keywords ? 7 | # do we try to validate ? Or do we just 8 | # assume users won't try to create a command named 9 | # `npe2_tester.False.if.for.in` ? 10 | _identifier_plus_dash = "(?:[a-zA-Z_][a-zA-Z_0-9-]+)" 11 | _dotted_name = f"(?:(?:{_identifier_plus_dash}\\.)*{_identifier_plus_dash})" 12 | PACKAGE_NAME_PATTERN = re.compile(f"^{_package_name}$", re.IGNORECASE) 13 | DOTTED_NAME_PATTERN = re.compile(_dotted_name) 14 | DISPLAY_NAME_PATTERN = re.compile(r"^[^\W_][\w -~:.'\"]{1,88}[^\W_]$") 15 | PYTHON_NAME_PATTERN = re.compile(f"^({_dotted_name}):({_dotted_name})$") 16 | COMMAND_ID_PATTERN = re.compile( 17 | f"^(({_package_name}\\.)*{_python_identifier})$", re.IGNORECASE 18 | ) 19 | 20 | 21 | def command_id(id: str) -> str: 22 | if id and not COMMAND_ID_PATTERN.match(id): 23 | raise ValueError( 24 | f"{id!r} is not a valid command id. It must begin with the package name " 25 | "followed by a period, then may can only contain alphanumeric " 26 | "characters and underscores." 27 | ) 28 | return id 29 | 30 | 31 | def package_name(name: str) -> str: 32 | """Assert that `name` is a valid package name in accordance with PEP-0508.""" 33 | if name and not PACKAGE_NAME_PATTERN.match(name): 34 | raise ValueError( 35 | f"{name!r} is not a valid python package name. " 36 | "See https://peps.python.org/pep-0508/#names " 37 | ) 38 | return name 39 | 40 | 41 | def python_name(name: str) -> str: 42 | """Assert that `name` is a valid python name: e.g. `module.submodule:funcname`""" 43 | if name and not PYTHON_NAME_PATTERN.match(name): 44 | msg = ( 45 | f"{name!r} is not a valid python_name. A python_name must " 46 | "be of the form '{obj.__module__}:{obj.__qualname__}' (e.g. " 47 | "'my_package.a_module:some_function')." 48 | ) 49 | if ".." in name: 50 | *_, a, b = name.split("..") 51 | a = a.split(":")[-1] 52 | msg += ( 53 | " Note: functions defined in local scopes are not yet supported. " 54 | f"Please move function {b!r} to the global scope of module {a!r}" 55 | ) 56 | raise ValueError(msg) 57 | return name 58 | 59 | 60 | def display_name(v: str) -> str: 61 | if not DISPLAY_NAME_PATTERN.match(v): 62 | raise ValueError( 63 | f"{v} is not a valid display_name. It must be 3-90 characters long, " 64 | "and must not begin or end with an underscore, white space, or " 65 | "non-word character." 66 | ) 67 | return v 68 | 69 | 70 | def icon_path(v: str) -> str: 71 | if not v: 72 | return "" 73 | if v.startswith("http"): 74 | if not v.startswith("https://"): 75 | raise ValueError( 76 | f"{v} is not a valid icon URL. It must start with 'https://'" 77 | ) 78 | return v 79 | assert isinstance(v, str), f"{v} must be a string" 80 | return v 81 | -------------------------------------------------------------------------------- /tests/test_implements.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from contextlib import nullcontext 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | import npe2.implements 8 | from npe2 import PluginManifest 9 | from npe2._inspection import find_npe2_module_contributions 10 | 11 | SAMPLE_PLUGIN_NAME = "my-plugin" 12 | SAMPLE_MODULE_NAME = "my_plugin" 13 | SAMPLE_DIR = Path(__file__).parent / "sample" 14 | 15 | 16 | def test_extract_manifest(): 17 | module_with_decorators = SAMPLE_DIR / "_with_decorators.py" 18 | extracted = find_npe2_module_contributions( 19 | module_with_decorators, 20 | plugin_name=SAMPLE_PLUGIN_NAME, 21 | module_name=SAMPLE_MODULE_NAME, 22 | ) 23 | assert extracted.commands 24 | assert extracted.readers 25 | assert extracted.writers 26 | assert extracted.widgets 27 | assert extracted.sample_data 28 | 29 | # get expectations from manually created manifest 30 | known_manifest = Path(__file__).parent / "sample" / "my_plugin" / "napari.yaml" 31 | expected = PluginManifest.from_file(known_manifest).contributions 32 | non_python = ("my-plugin.hello_world", "my-plugin.another_command") 33 | expected.commands = [c for c in expected.commands if c.id not in non_python] 34 | expected.sample_data = [c for c in expected.sample_data if not hasattr(c, "uri")] 35 | 36 | # check that they're all the same 37 | _id = lambda x: x.id # noqa 38 | assert sorted(extracted.commands, key=_id) == sorted(expected.commands, key=_id) 39 | k = lambda x: x.command # noqa 40 | assert sorted(extracted.readers, key=k) == sorted(expected.readers, key=k) 41 | assert sorted(extracted.writers, key=k) == sorted(expected.writers, key=k) 42 | assert sorted(extracted.widgets, key=k) == sorted(expected.widgets, key=k) 43 | assert sorted(extracted.sample_data, key=k) == sorted(expected.sample_data, key=k) 44 | 45 | 46 | def test_dynamic(monkeypatch): 47 | with monkeypatch.context() as m: 48 | m.setattr(sys, "path", [*sys.path, str(SAMPLE_DIR)]) 49 | import _with_decorators 50 | 51 | assert hasattr(_with_decorators.get_reader, "_npe2_ReaderContribution") 52 | info = _with_decorators.get_reader._npe2_ReaderContribution 53 | assert info == { 54 | "id": "some_reader", 55 | "title": "Some Reader", 56 | "filename_patterns": ["*.fzy", "*.fzzy"], 57 | "accepts_directories": True, 58 | } 59 | 60 | # we can compile a module object as well as a string path 61 | extracted = find_npe2_module_contributions( 62 | _with_decorators, 63 | plugin_name=SAMPLE_PLUGIN_NAME, 64 | module_name=SAMPLE_MODULE_NAME, 65 | ) 66 | 67 | assert extracted.commands 68 | 69 | 70 | @pytest.mark.parametrize("check", [True, False]) 71 | def test_decorator_arg_check(check): 72 | """Check that the decorators don't check arguments at runtime unless instructed.""" 73 | # tilde is wrong and filename_patterns is missing 74 | kwargs = {"id": "some_id", "tilde": "some_title"} 75 | kwargs[npe2.implements.CHECK_ARGS_PARAM] = check 76 | ctx = pytest.raises(TypeError) if check else nullcontext() 77 | with ctx: 78 | npe2.implements.reader(**kwargs)(lambda: None) 79 | -------------------------------------------------------------------------------- /tests/sample/my_plugin/napari.yaml: -------------------------------------------------------------------------------- 1 | name: my-plugin 2 | display_name: My Plugin 3 | on_activate: my_plugin:activate 4 | on_deactivate: my_plugin:deactivate 5 | icon: https://picsum.photos/256 6 | contributions: 7 | commands: 8 | - id: my-plugin.hello_world 9 | title: Hello World 10 | - id: my-plugin.another_command 11 | title: Another Command 12 | - id: my-plugin.some_reader 13 | title: Some Reader 14 | python_name: my_plugin:get_reader 15 | - id: my-plugin.url_reader 16 | title: URL Reader 17 | python_name: my_plugin:url_reader 18 | - id: my-plugin.my_writer 19 | title: My Multi-layer Writer 20 | python_name: my_plugin:writer_function 21 | - id: my-plugin.my_single_writer 22 | title: My single-layer Writer 23 | python_name: my_plugin:writer_function_single 24 | - id: my-plugin.generate_random_data 25 | title: Generate uniform random data 26 | python_name: my_plugin:random_data 27 | - id: my-plugin.some_widget 28 | title: Create my widget 29 | python_name: my_plugin:SomeWidget 30 | - id: my-plugin.some_function_widget 31 | title: Create widget from my function 32 | python_name: my_plugin:make_widget_from_function 33 | configuration: 34 | - title: My Plugin 35 | properties: 36 | my_plugin.reader.lazy: 37 | type: boolean 38 | default: false 39 | title: Load lazily 40 | description: Whether to load images lazily with dask 41 | readers: 42 | - command: my-plugin.some_reader 43 | filename_patterns: ["*.fzy", "*.fzzy"] 44 | accepts_directories: true 45 | - command: my-plugin.url_reader 46 | filename_patterns: ["http://*", "https://*"] 47 | accepts_directories: false 48 | writers: 49 | - command: my-plugin.my_writer 50 | filename_extensions: ["*.tif", "*.tiff"] 51 | layer_types: ["image{2,4}", "tracks?"] 52 | - command: my-plugin.my_writer 53 | filename_extensions: ["*.pcd", "*.e57"] 54 | layer_types: ["points{1}", "surface+"] 55 | - command: my-plugin.my_single_writer 56 | filename_extensions: ["*.xyz"] 57 | layer_types: ["labels"] 58 | 59 | widgets: 60 | - command: my-plugin.some_widget 61 | display_name: My Widget 62 | - command: my-plugin.some_function_widget 63 | display_name: A Widget From a Function 64 | autogenerate: true 65 | menus: 66 | /napari/layer_context: 67 | - submenu: mysubmenu 68 | - command: my-plugin.hello_world 69 | mysubmenu: 70 | - command: my-plugin.another_command 71 | submenus: 72 | - id: mysubmenu 73 | label: My SubMenu 74 | themes: 75 | - label: "SampleTheme" 76 | id: "sample_theme" 77 | type: "dark" 78 | syntax_style: "default" 79 | colors: 80 | canvas: "#000000" 81 | console: "#000000" 82 | background: "#272822" 83 | foreground: "#75715e" 84 | primary: "#cfcfc2" 85 | secondary: "#f8f8f2" 86 | highlight: "#e6db74" 87 | text: "#a1ef34" 88 | icon: "#a1ef34" 89 | warning: "#f92672" 90 | current: "#66d9ef" 91 | sample_data: 92 | - display_name: Some Random Data (512 x 512) 93 | key: random_data 94 | command: my-plugin.generate_random_data 95 | - display_name: Random internet image 96 | key: internet_image 97 | uri: https://picsum.photos/1024 98 | -------------------------------------------------------------------------------- /_docs/templates/_npe2_contributions.md.jinja: -------------------------------------------------------------------------------- 1 | # Contributions Reference 2 | 3 | **Contributions** are a set of static declarations that you make in the 4 | `contributions` field of the [Plugin Manifest](./manifest). Your extension registers 5 | **Contributions** to extend various functionalities within napari. 6 | Here is a list of all available **Contributions**: 7 | {# list all contributions first #} 8 | {%- for name, contrib in contributions.items() %} 9 | {%- if not contrib.hide_docs %} 10 | - [`{{ name }}`](contributions-{{ name|replace('_', '-') }}) 11 | {%- endif -%} 12 | {% endfor %} 13 | 14 | You may add as many contributions as you'd like to a single manifest. For 15 | clarity, the following examples include only the specific contribution that 16 | is being discussed. 17 | 18 | {# now, iterate through all contributions and show fields and #} 19 | {%- for contrib_name, contrib in contributions.items() -%} 20 | {% if not contrib.hide_docs -%} 21 | (contributions-{{ contrib_name|replace('_', '-') }})= 22 | ## `contributions.{{contrib_name}}` 23 | {# check if this is a single type, or a union #} 24 | {%- if contrib.type == 'object' and contrib.additionalProperties is defined %} 25 | {# Handle object types like menus #} 26 | {%- if contrib['additionalProperties']['items']['anyOf'] is defined %} 27 | {%- set type_names = contrib['additionalProperties']['items']['anyOf']|map(attribute='$ref')|map("replace", "#/definitions/", "")|list %} 28 | {%- set union = True %} 29 | {%- else %} 30 | {%- set type_names = [contrib['additionalProperties']['items']['$ref']|replace("#/definitions/", "")] %} 31 | {%- set union = False %} 32 | {%- endif -%} 33 | {%- elif contrib['items']['anyOf'] is defined %} 34 | {# Handle array types with union #} 35 | {%- set type_names = contrib['items']['anyOf']|map(attribute='$ref')|map("replace", "#/definitions/", "")|list %} 36 | {%- set union = True %} 37 | {%- else %} 38 | {# Handle array types with single type #} 39 | {%- set type_names = [contrib['items']['$ref']|replace("#/definitions/", "")] %} 40 | {%- set union = False %} 41 | {%- endif -%} 42 | {%- if union %} 43 | ```{tip} 44 | This contribution accepts {{ type_names|length }} schema types 45 | ``` 46 | {%- endif -%} 47 | 48 | {%- if contrib.type == 'object' and contrib.additionalProperties is defined %} 49 | {# For object types like menus, use the contribution description directly #} 50 | {{ contrib.description }} 51 | {%- endif %} 52 | 53 | {%- for tname in type_names -%} 54 | {% set type = schema['definitions'][tname] %} 55 | {% if union %}##### {{loop.index}}. {{type.title}}{% endif %} 56 | {%- if contrib.type != 'object' or contrib.additionalProperties is not defined %} 57 | {{ type.description }} 58 | {%- endif %} 59 | 60 | {% if contrib_name|has_guide %} 61 | See the [{{ contrib_name.title()|replace('_', ' ') }} Guide]({{ contrib_name|replace('_', '-') }}-contribution-guide) 62 | for more details on implementing this contribution. 63 | {% endif %} 64 | 65 | {# Using bold instead of headers in this case to avoid right-side nav #} 66 | **Fields** 67 | {%- for field_name, field_props in type.properties.items() -%} 68 | {% set required = field_name in type.required %} 69 | - **`{{contrib_name}}.{{field_name}}`** : {% if not required %} 70 | *(Optional: default={{ field_props.default if field_props.default is not undefined else 'None' }}).* 71 | {% endif -%} 72 | {{- field_props.description -}} 73 | {% endfor -%} 74 | {% endfor %} 75 | 76 | ### {{ contrib_name.title()|replace("_", " ") }} example 77 | ::::{tab-set} 78 | {% for format in ['yaml', 'toml'] %} 79 | :::{tab-item} {{format}} 80 | ```{{format}} 81 | {{ contrib_name|example_contribution(format) }} 82 | ``` 83 | ::: 84 | {% endfor -%} 85 | :::: 86 | {% endif %} 87 | {% endfor %} 88 | -------------------------------------------------------------------------------- /tests/test_tmp_plugin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from npe2 import DynamicPlugin, PluginManager 4 | from npe2.manifest.contributions import SampleDataGenerator 5 | 6 | TMP = "tmp" 7 | 8 | 9 | @pytest.fixture 10 | def tmp_plugin(): 11 | local_pm = PluginManager() 12 | with DynamicPlugin(TMP, plugin_manager=local_pm) as tp: 13 | assert TMP in local_pm # make sure it registered 14 | yield tp 15 | assert TMP not in local_pm # make sure it cleaned up 16 | 17 | 18 | def test_temporary_plugin(tmp_plugin: DynamicPlugin): 19 | """Test that we can use tmp_plugin to register commands for testing""" 20 | # everything is empty to begin with 21 | pm = tmp_plugin.plugin_manager 22 | contribs = tmp_plugin.manifest.contributions 23 | # everything is empty to begin with 24 | assert not contribs.commands 25 | assert not contribs.sample_data 26 | assert not contribs.readers 27 | assert not contribs.writers 28 | 29 | # we can populate with the contribute.x decorators 30 | 31 | @tmp_plugin.contribute.sample_data 32 | def make_image(x): 33 | return x 34 | 35 | @tmp_plugin.contribute.reader 36 | def read_path(path): ... 37 | 38 | # can override args 39 | ID = f"{TMP}.random_id" 40 | 41 | @tmp_plugin.contribute.command(id=ID) 42 | def some_command(): 43 | return "hi!" 44 | 45 | # some require args 46 | 47 | with pytest.raises(AssertionError) as e: 48 | 49 | @tmp_plugin.contribute.writer 50 | def write_path_bad(path, layer_data): ... 51 | 52 | assert "layer_types must not be empty" in str(e.value) 53 | # it didn't get added 54 | assert "tmp.write_path_bad" not in pm.commands 55 | 56 | @tmp_plugin.contribute.writer(layer_types=["image"]) 57 | def write_path(path, layer_data): ... 58 | 59 | # now it did 60 | assert "tmp.write_path" in pm.commands 61 | 62 | # contributions have been populated 63 | assert contribs.commands 64 | assert contribs.sample_data 65 | assert contribs.readers 66 | assert contribs.writers 67 | 68 | # and the commands work 69 | samples = next(contribs for plg, contribs in pm.iter_sample_data() if plg == TMP) 70 | gen = samples[0] 71 | assert isinstance(gen, SampleDataGenerator) 72 | assert gen.exec((1,), _registry=pm.commands) == 1 73 | 74 | cmd = pm.get_command(ID) 75 | assert cmd.exec(_registry=pm.commands) == "hi!" 76 | 77 | 78 | def test_temporary_plugin_change_pm(tmp_plugin: DynamicPlugin): 79 | """We can change the plugin manager we're assigned to. 80 | 81 | Probably not necessary, but perhaps useful in tests. 82 | """ 83 | start_pm = tmp_plugin.plugin_manager 84 | new_pm = PluginManager() 85 | 86 | @tmp_plugin.contribute.command 87 | def some_command(): 88 | return "hi!" 89 | 90 | assert "tmp.some_command" in start_pm.commands 91 | assert "tmp.some_command" not in new_pm.commands 92 | 93 | tmp_plugin.plugin_manager = new_pm 94 | 95 | assert "tmp.some_command" not in start_pm.commands 96 | assert "tmp.some_command" in new_pm.commands 97 | 98 | tmp_plugin.clear() 99 | assert not tmp_plugin.manifest.contributions.commands 100 | 101 | 102 | def test_temporary_plugin_spawn(tmp_plugin: DynamicPlugin): 103 | new = tmp_plugin.spawn("another-name", register=True) 104 | assert new.name == "another-name" 105 | assert new.display_name == "another-name" 106 | assert new.plugin_manager == tmp_plugin.plugin_manager 107 | 108 | t1 = tmp_plugin.spawn(register=True) 109 | assert t1.name == f"{tmp_plugin.name}-1" 110 | t2 = tmp_plugin.spawn() 111 | assert t2.name == f"{tmp_plugin.name}-2" 112 | 113 | assert t1.name in tmp_plugin.plugin_manager._manifests 114 | assert t2.name not in tmp_plugin.plugin_manager._manifests 115 | -------------------------------------------------------------------------------- /.github/workflows/test_conversion.yml: -------------------------------------------------------------------------------- 1 | name: Test Plugin Conversion 2 | 3 | on: 4 | # push: 5 | # pull_request: 6 | workflow_dispatch: 7 | 8 | concurrency: 9 | group: convert-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | get-plugins: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - id: plugin_names 17 | run: echo "::set-output name=plugins::$(curl -s https://api.napari-hub.org/plugins | jq -c 'keys')" 18 | outputs: 19 | plugins: ${{ steps.plugin_names.outputs.plugins }} 20 | 21 | convert: 22 | needs: get-plugins 23 | name: convert ${{ matrix.plugin }} 24 | runs-on: ubuntu-latest 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | # plugin: ["napari-dv"] 29 | plugin: ${{ fromJson(needs.get-plugins.outputs.plugins) }} 30 | 31 | steps: 32 | - uses: tlambert03/setup-qt-libs@19e4ef2d781d81f5f067182e228b54ec90d23b76 # v1.8 33 | - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 34 | with: 35 | python-version: 3.9 36 | 37 | - name: Install dependencies 38 | run: | 39 | pip install -U pip 40 | # just in case... we ask them not to depend on this or Pyside 41 | # since it's up to the enduser to have with napari 42 | pip install PyQt5 43 | pip install git+https://github.com/napari/npe2.git@refs/pull/60/head#egg=npe2 44 | 45 | - name: Fetch repo URL 46 | run: | 47 | URL=$(curl -s https://api.napari-hub.org/plugins/${{ matrix.plugin }} | jq '.code_repository') 48 | URL=${URL#'"https://github.com/'} 49 | URL=${URL%'"'} 50 | echo "plugin_repo=$URL" >> $GITHUB_ENV 51 | 52 | - name: Checkout plugin repo 53 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 54 | with: 55 | repository: ${{ env.plugin_repo }} 56 | path: 'plugin_repo' 57 | fetch-depth: 0 58 | 59 | - name: Install ${{ matrix.plugin }} 60 | run: pip install -e ./plugin_repo 61 | 62 | - name: Test Conversion 63 | id: test-without-napari 64 | uses: aganders3/headless-gui@f85dd6316993505dfc5f21839d520ae440c84816 # v2.2 65 | continue-on-error: true 66 | with: 67 | run: npe2 convert ./plugin_repo 68 | 69 | - name: Install napari 70 | if: ${{ steps.test-without-napari.outcome == 'failure' }} 71 | run: pip install napari 72 | 73 | - name: Test Conversion again with napari 74 | id: test-with-napari 75 | if: ${{ steps.test-without-napari.outcome == 'failure' }} 76 | uses: aganders3/headless-gui@f85dd6316993505dfc5f21839d520ae440c84816 # v2.2 77 | with: 78 | run: npe2 convert ./plugin_repo 79 | 80 | - name: Test Conversion again with napari 81 | if: ${{ steps.test-without-napari.outcome == 'failure' && steps.test-with-napari.outcome == 'failure' }} 82 | uses: aganders3/headless-gui@f85dd6316993505dfc5f21839d520ae440c84816 # v2.2 83 | with: 84 | # try without modifying directory 85 | run: npe2 convert -n ${{ matrix.plugin }} 86 | 87 | # this won't work, we'd need to first fork the repo somewhere we have write permissions 88 | # then push changes that that repository, and then create a PR to the original repo 89 | # - name: Create Pull Request 90 | # if: success() 91 | # uses: peter-evans/create-pull-request@v3 92 | # with: 93 | # commit-message: convert plugin to npe2 format 94 | # title: 'Convert to npe2 plugin' 95 | # body: | 96 | # This PR adds an (autogenerated) npe2 manifest, and updates setup.cfg (if setup.cfg is used). 97 | # If you use setup.py instead, please update the entry_point manually: 98 | # entry_points = {'napari.manifest': "your-package = your_package:napari.yaml"} 99 | -------------------------------------------------------------------------------- /tests/test_config_contribution.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from npe2.manifest.contributions import ConfigurationContribution, ConfigurationProperty 4 | from npe2.manifest.contributions._json_schema import ValidationError 5 | 6 | PROPS = [ 7 | { 8 | "plugin.heatmap.location": { 9 | "type": "string", 10 | "default": "right", 11 | "enum": ["left", "right"], 12 | "enumDescriptions": [ 13 | "Adds a heatmap indicator on the left edge", 14 | "Adds a heatmap indicator on the right edge", 15 | ], 16 | } 17 | } 18 | ] 19 | 20 | 21 | @pytest.mark.parametrize("props", PROPS) 22 | def test_config_contribution(props): 23 | cc = ConfigurationContribution( 24 | title="My Plugin", 25 | properties=props, 26 | ) 27 | assert cc.title == "My Plugin" 28 | for key, val in cc.properties.items(): 29 | assert val.dict(exclude_unset=True, by_alias=True) == props[key] 30 | 31 | 32 | def test_warn_on_refs_defs(): 33 | with pytest.warns(UserWarning): 34 | ConfigurationProperty( 35 | type="string", 36 | default="baz", 37 | description="quux", 38 | ref="http://example.com", 39 | ) 40 | 41 | 42 | CASES = [ 43 | ( 44 | { 45 | "type": str, 46 | "pattern": "^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$", 47 | "pattern_error_message": "custom error", 48 | }, 49 | "555-1212", 50 | "(888)555-1212 ext. 532", 51 | ), 52 | ({"type": "string", "minLength": 2}, "AB", "A"), 53 | ({"type": "string", "maxLength": 3}, "AB", "ABCD"), 54 | ({"type": "integer"}, 42, 3.123), 55 | ({"type": float}, 42.45, "3.123"), 56 | ({"type": int, "multipleOf": 10}, 30, 23), 57 | ({"type": "number", "minimum": 100}, 100, 99), 58 | ({"type": "number", "exclusiveMaximum": 100}, 99, 100), 59 | ( 60 | {"properties": {"number": {"type": "number"}}}, 61 | {"number": 1600}, 62 | {"number": "1600"}, 63 | ), 64 | ( 65 | { 66 | "type": dict, 67 | "properties": { 68 | "number": {"type": "number"}, 69 | }, 70 | "additional_properties": False, 71 | }, 72 | {"number": 1600}, 73 | {"number": 1600, "street_name": "Pennsylvania"}, 74 | ), 75 | ({"type": "array"}, [3, "diff", {"types": "of values"}], {"Not": "an array"}), 76 | ({"items": {"type": "number"}}, [1, 2, 3, 4, 5], [1, 2, "3", 4, 5]), 77 | ( 78 | { 79 | "items": [ 80 | {"type": "number"}, 81 | {"type": "string"}, 82 | {"enum": ["Street", "Avenue", "Boulevard"]}, 83 | {"enum": ["NW", "NE", "SW", "SE"]}, 84 | ] 85 | }, 86 | [1600, "Pennsylvania", "Avenue", "NW"], 87 | [24, "Sussex", "Drive"], 88 | ), 89 | ({"type": [bool, int]}, True, "True"), 90 | ] 91 | 92 | 93 | @pytest.mark.parametrize("schema, valid, invalid", CASES) 94 | def test_config_validation(schema, valid, invalid): 95 | cfg = ConfigurationProperty(**schema) 96 | assert cfg.validate_instance(valid) == valid 97 | 98 | match = schema.get("pattern_error_message", None) 99 | with pytest.raises(ValidationError, match=match): 100 | assert cfg.validate_instance(invalid) 101 | 102 | assert cfg.is_array is ("items" in schema or cfg.type == "array") 103 | assert cfg.is_object is (cfg.type == "object") 104 | assert isinstance(cfg.has_constraint, bool) 105 | 106 | # check that we can can convert json type to python type 107 | for t in ( 108 | cfg.python_type if isinstance(cfg.python_type, list) else [cfg.python_type] 109 | ): 110 | assert t.__module__ == "builtins" 111 | assert cfg.has_default is ("default" in schema) 112 | -------------------------------------------------------------------------------- /src/npe2/_pytest_plugin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import warnings 3 | from unittest.mock import patch 4 | 5 | import pytest 6 | 7 | from npe2 import DynamicPlugin, PluginManager, PluginManifest 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class TestPluginManager(PluginManager): 13 | """A PluginManager subclass suitable for use in testing.""" 14 | 15 | def discover(self, *_, **__) -> int: 16 | """Discovery is blocked in the TestPluginManager.""" 17 | logger.warning( 18 | "NOTE: TestPluginManager refusing to discover plugins. " 19 | "You may add plugins to this test plugin manager using `tmp_plugin()`." 20 | ) 21 | return 0 22 | 23 | def tmp_plugin( 24 | self, 25 | manifest: PluginManifest | str | None = None, 26 | package: str | None = None, 27 | name: str | None = None, 28 | ) -> DynamicPlugin: 29 | """Create a DynamicPlugin instance using this plugin manager. 30 | 31 | If providing arguments, provide only one of 'manifest', 'package', 'name'. 32 | 33 | Parameters 34 | ---------- 35 | manifest : Union[PluginManifest, str] 36 | A manifest to use for this plugin. If a string, it is assumed to be the 37 | path to a manifest file (which must exist), otherwise must be a 38 | PluginManifest instance. 39 | package : str 40 | Name of an installed plugin/package. 41 | name : str 42 | If neither `manifest` or `package` is provided, a new DynamicPlugin is 43 | created with this name, by default "tmp_plugin" 44 | 45 | Returns 46 | ------- 47 | DynamicPlugin 48 | be sure to enter the DynamicPlugin context to register the plugin. 49 | 50 | Examples 51 | -------- 52 | >>> def test_something_with_only_my_plugin_registered(npe2pm): 53 | ... with npe2pm.tmp_plugin(package='my-plugin') as plugin: 54 | ... ... 55 | 56 | >>> def test_something_with_specific_manifest_file_registered(npe2pm): 57 | ... mf_file = Path(__file__).parent / 'my_manifest.yaml' 58 | ... with npe2pm.tmp_plugin(manifest=str(mf_file)) as plugin: 59 | ... ... 60 | """ 61 | if manifest is not None: 62 | if package or name: # pragma: no cover 63 | warnings.warn( 64 | "`manifest` overrides the `package` and `name` arguments. " 65 | "Please provide only one.", 66 | stacklevel=2, 67 | ) 68 | if isinstance(manifest, PluginManifest): 69 | mf = manifest 70 | else: 71 | mf = PluginManifest.from_file(manifest) 72 | elif package: 73 | if name: # pragma: no cover 74 | warnings.warn( 75 | "`package` overrides the `name` argument. Please provide only one.", 76 | stacklevel=2, 77 | ) 78 | mf = PluginManifest.from_distribution(package) 79 | else: 80 | name = name or "tmp_plugin" 81 | i = 0 82 | while name in self._manifests: # pragma: no cover 83 | # guarantee that name is unique 84 | name = f"{name}_{i}" 85 | i += 1 86 | mf = PluginManifest(name=name) 87 | return DynamicPlugin(mf.name, plugin_manager=self, manifest=mf) 88 | 89 | 90 | @pytest.fixture 91 | def npe2pm(): 92 | """Return mocked Global plugin manager instance, unable to discover plugins. 93 | 94 | Examples 95 | -------- 96 | >>> @pytest.fixture(autouse=True) 97 | ... def mock_npe2_pm(npe2pm): 98 | ... # Auto-use this fixture to prevent plugin discovery. 99 | ... return npe2pm 100 | """ 101 | _pm = TestPluginManager() 102 | with patch("npe2.PluginManager.instance", return_value=_pm): 103 | yield _pm 104 | -------------------------------------------------------------------------------- /src/npe2/manifest/_bases.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections.abc import Callable 3 | from contextlib import contextmanager 4 | from pathlib import Path 5 | 6 | import yaml 7 | 8 | from npe2._pydantic_compat import BaseModel, PrivateAttr 9 | 10 | 11 | class ImportExportModel(BaseModel): 12 | """Model mixin/base class that provides read/write from toml/yaml/json. 13 | 14 | To force the inclusion of a given field in the exported toml/yaml use: 15 | 16 | class MyModel(ImportExportModel): 17 | some_field: str = Field(..., always_export=True) 18 | """ 19 | 20 | _source_file: Path | None = PrivateAttr(None) 21 | 22 | def toml(self, pyproject=False, **kwargs) -> str: 23 | """Generate serialized `toml` string for this model. 24 | 25 | Parameters 26 | ---------- 27 | pyproject : bool, optional 28 | If `True`, output will be in pyproject format, with all data under 29 | `tool.napari`, by default `False`. 30 | **kwargs 31 | passed to `BaseModel.json()` 32 | """ 33 | import tomli_w 34 | 35 | d = self._serialized_data(**kwargs) 36 | if pyproject: 37 | d = {"tool": {"napari": d}} 38 | return tomli_w.dumps(d) 39 | 40 | def yaml(self, **kwargs) -> str: 41 | """Generate serialized `yaml` string for this model. 42 | 43 | Parameters 44 | ---------- 45 | **kwargs 46 | passed to `BaseModel.json()` 47 | """ 48 | return yaml.safe_dump(self._serialized_data(**kwargs), sort_keys=False) 49 | 50 | @classmethod 51 | def from_file(cls, path: Path | str): 52 | """Parse model from a metadata file. 53 | 54 | Parameters 55 | ---------- 56 | path : Path or str 57 | Path to file. Must have extension {'.json', '.yaml', '.yml', '.toml'} 58 | 59 | Returns 60 | ------- 61 | object 62 | The parsed model. 63 | 64 | Raises 65 | ------ 66 | FileNotFoundError 67 | If `path` does not exist. 68 | ValueError 69 | If the file extension is not in {'.json', '.yaml', '.yml', '.toml'} 70 | """ 71 | path = Path(path).expanduser().absolute().resolve() 72 | if not path.exists(): 73 | raise FileNotFoundError(f"File not found: {path}") 74 | 75 | loader: Callable 76 | if path.suffix.lower() == ".json": 77 | loader = json.load 78 | elif path.suffix.lower() == ".toml": 79 | try: 80 | import tomllib 81 | except ImportError: 82 | import tomli as tomllib # type: ignore [no-redef] 83 | 84 | loader = tomllib.load 85 | elif path.suffix.lower() in (".yaml", ".yml"): 86 | loader = yaml.safe_load 87 | else: 88 | raise ValueError(f"unrecognized file extension: {path}") # pragma: no cover 89 | 90 | with open(path, mode="rb") as f: 91 | data = loader(f) or {} 92 | 93 | if path.name == "pyproject.toml": 94 | data = data["tool"]["napari"] 95 | 96 | obj = cls(**data) 97 | obj._source_file = Path(path).expanduser().absolute().resolve() 98 | return obj 99 | 100 | def _serialized_data(self, **kwargs): 101 | """using json encoders for all outputs""" 102 | kwargs.setdefault("exclude_unset", True) 103 | with self._required_export_fields_set(): 104 | return json.loads(self.json(**kwargs)) 105 | 106 | @contextmanager 107 | def _required_export_fields_set(self): 108 | fields = self.__fields__.items() 109 | required = {k for k, v in fields if v.field_info.extra.get("always_export")} 110 | 111 | was_there: dict[str, bool] = {} 112 | for f in required: 113 | was_there[f] = f in self.__fields_set__ 114 | self.__fields_set__.add(f) 115 | try: 116 | yield 117 | finally: 118 | for f in required: 119 | if not was_there.get(f): 120 | self.__fields_set__.discard(f) 121 | -------------------------------------------------------------------------------- /src/npe2/_inspection/_compile.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterator, Sequence 2 | from pathlib import Path 3 | from typing import cast 4 | 5 | from npe2.manifest import PluginManifest, contributions 6 | from npe2.manifest.utils import merge_contributions, merge_manifests 7 | 8 | from ._setuputils import get_package_dir_info 9 | from ._visitors import find_npe2_module_contributions 10 | 11 | 12 | def find_packages(where: str | Path = ".") -> list[Path]: 13 | """Return all folders that have an __init__.py file""" 14 | return [p.parent for p in Path(where).resolve().rglob("**/__init__.py")] 15 | 16 | 17 | def get_package_name(where: str | Path = ".") -> str: 18 | return get_package_dir_info(where).package_name 19 | 20 | 21 | def compile( 22 | src_dir: str | Path, 23 | dest: str | Path | None = None, 24 | packages: Sequence[str] = (), 25 | plugin_name: str = "", 26 | template: str | Path | None = None, 27 | ) -> PluginManifest: 28 | """Compile plugin manifest from `src_dir`, where is a top-level repo. 29 | 30 | This will discover all the contribution points in the repo and output a manifest 31 | object 32 | 33 | Parameters 34 | ---------- 35 | src_dir : Union[str, Path] 36 | Repo root. Should contain a pyproject or setup.cfg file. 37 | dest : Union[str, Path, None] 38 | If provided, path where output manifest should be written. 39 | packages : Sequence[str] 40 | List of packages to include in the manifest. By default, all packages 41 | (subfolders that have an `__init__.py`) will be included. 42 | plugin_name : str 43 | Name of the plugin. If not provided, the name will be derived from the 44 | package structure (this assumes a setuptools package.) 45 | template : Union[str, Path, None] 46 | If provided, path to a template manifest file to use. This file can contain 47 | "non-command" contributions, like `display_name`, or `themes`, etc... 48 | In the case of conflicts (discovered, decoratated contributions with the same 49 | id as something in the template), discovered contributions will take 50 | precedence. 51 | 52 | Returns 53 | ------- 54 | PluginManifest 55 | Manifest including all discovered contribution points, combined with any 56 | existing contributions explicitly stated in the manifest. 57 | """ 58 | src_path = Path(src_dir) 59 | assert src_path.exists(), f"src_dir {src_dir} does not exist" 60 | 61 | if dest is not None: 62 | pdest = Path(dest) 63 | suffix = pdest.suffix.lstrip(".") 64 | if suffix not in {"json", "yaml", "toml"}: 65 | raise ValueError( 66 | f"dest {dest!r} must have an extension of .json, .yaml, or .toml" 67 | ) 68 | 69 | if template is not None: 70 | template_mf = PluginManifest.from_file(template) 71 | 72 | _packages = find_packages(src_path) 73 | if packages: 74 | _packages = [p for p in _packages if p.name in packages] 75 | 76 | if not plugin_name: 77 | plugin_name = get_package_name(src_path) 78 | 79 | contribs: list[contributions.ContributionPoints] = [] 80 | for pkg_path in _packages: 81 | top_mod = pkg_path.name 82 | # TODO: add more tests with more complicated package structures 83 | # make sure we're not double detecting and/or missing stuff. 84 | for mod_path, mod_name in _iter_modules(pkg_path): 85 | contrib = find_npe2_module_contributions( 86 | mod_path, 87 | plugin_name=plugin_name, 88 | module_name=f"{top_mod}.{mod_name}" if mod_name else top_mod, 89 | ) 90 | contribs.append(contrib) 91 | 92 | mf = PluginManifest( 93 | name=plugin_name, 94 | contributions=merge_contributions(contribs), 95 | ) 96 | 97 | if template is not None: 98 | mf = merge_manifests([template_mf, mf], overwrite=True) 99 | 100 | if dest is not None: 101 | manifest_string = getattr(mf, cast(str, suffix))(indent=2) 102 | pdest.write_text(manifest_string) 103 | 104 | return mf 105 | 106 | 107 | def _iter_modules(path: Path) -> Iterator[tuple[Path, str]]: 108 | """Return all python modules in path""" 109 | for p in path.glob("*.py"): 110 | yield p, "" if p.name == "__init__.py" else p.stem 111 | -------------------------------------------------------------------------------- /src/npe2/implements.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from collections.abc import Callable, Sequence 3 | from inspect import Parameter, Signature 4 | from typing import Any, TypeVar 5 | 6 | from npe2._pydantic_compat import BaseModel 7 | 8 | from .manifest import contributions 9 | 10 | __all__ = [ 11 | "CHECK_ARGS_PARAM", 12 | "on_activate", 13 | "on_deactivate", 14 | "reader", 15 | "sample_data_generator", 16 | "widget", 17 | "writer", 18 | ] 19 | 20 | 21 | T = TypeVar("T", bound=Callable[..., Any]) 22 | 23 | CHECK_ARGS_PARAM = "ensure_args_valid" 24 | 25 | 26 | def _build_decorator(contrib: type[BaseModel]) -> Callable: 27 | """Create a decorator (e.g. `@implements.reader`) to mark an object as a contrib. 28 | 29 | Parameters 30 | ---------- 31 | contrib : Type[BaseModel] 32 | The type of contribution this object implements. 33 | """ 34 | # build a signature based on the fields in this contribution type, mixed with 35 | # the fields in the CommandContribution 36 | contribs: Sequence[type[BaseModel]] = (contributions.CommandContribution, contrib) 37 | params: list[Parameter] = [] 38 | for contrib in contribs: 39 | # iterate over the fields in the contribution types 40 | for field in contrib.__fields__.values(): 41 | # we don't need python_name (since that will be gleaned from the function 42 | # we're decorating) ... and we don't need `command`, since that will just 43 | # be a string pointing to the contributions.commands entry that we are 44 | # creating here. 45 | if field.name not in {"python_name", "command"}: 46 | # ensure that required fields raise a TypeError if they are not provided 47 | default = Parameter.empty if field.required else field.get_default() 48 | # create the parameter and add it to the signature. 49 | param = Parameter( 50 | field.name, 51 | Parameter.KEYWORD_ONLY, 52 | default=default, 53 | annotation=field.outer_type_ or field.type_, 54 | ) 55 | params.append(param) 56 | 57 | # add one more parameter to control whether the arguments in the decorator itself 58 | # are validated at runtime 59 | params.append( 60 | Parameter( 61 | CHECK_ARGS_PARAM, 62 | kind=Parameter.KEYWORD_ONLY, 63 | default=False, 64 | annotation=bool, 65 | ) 66 | ) 67 | 68 | signature = Signature(parameters=params, return_annotation=Callable[[T], T]) 69 | 70 | # creates the actual `@npe2.implements.something` decorator 71 | # this just stores the parameters for the corresponding contribution type 72 | # as attributes on the function being decorated. 73 | def _deco(**kwargs) -> Callable[[T], T]: 74 | def _store_attrs(func: T) -> T: 75 | # If requested, assert that we've satisfied the signature when 76 | # the decorator is invoked at runtime. 77 | # TODO: improve error message to provide context 78 | if kwargs.pop(CHECK_ARGS_PARAM, False): 79 | signature.bind(**kwargs) 80 | 81 | # TODO: check if it's already there and assert the same id 82 | # store these attributes on the function 83 | with contextlib.suppress(AttributeError): 84 | setattr(func, f"_npe2_{contrib.__name__}", kwargs) 85 | 86 | # return the original decorated function 87 | return func 88 | 89 | return _store_attrs 90 | 91 | # set the signature and return the decorator 92 | _deco.__signature__ = signature # type: ignore 93 | return _deco 94 | 95 | 96 | # builds decorators for each of the contribution types that are essentially just 97 | # pointers to some command. 98 | reader = _build_decorator(contributions.ReaderContribution) 99 | writer = _build_decorator(contributions.WriterContribution) 100 | widget = _build_decorator(contributions.WidgetContribution) 101 | sample_data_generator = _build_decorator(contributions.SampleDataGenerator) 102 | 103 | 104 | def on_activate(func): 105 | """Mark a function to be called when a plugin is activated.""" 106 | func.npe2_on_activate = True 107 | return func 108 | 109 | 110 | def on_deactivate(func): 111 | """Mark a function to be called when a plugin is deactivated.""" 112 | func.npe2_on_deactivate = True 113 | return func 114 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from npe2.manifest.contributions import ContributionPoints 4 | from npe2.manifest.schema import PluginManifest 5 | from npe2.manifest.utils import ( 6 | Version, 7 | deep_update, 8 | merge_contributions, 9 | merge_manifests, 10 | ) 11 | 12 | 13 | def test_version(): 14 | v = Version.parse(b"0.1.2") 15 | 16 | assert v == "0.1.2" 17 | assert v > {"major": 0, "minor": 1, "patch": 0} 18 | assert v <= (0, 2, 0) 19 | assert v == Version(0, 1, 2) 20 | assert list(v) == [0, 1, 2, None, None] 21 | assert str(v) == "0.1.2" 22 | 23 | with pytest.raises(TypeError): 24 | assert v == 1.2 25 | 26 | with pytest.raises(ValueError): 27 | Version.parse("alkfdjs") 28 | 29 | with pytest.raises(TypeError): 30 | Version.parse(1.2) # type: ignore 31 | 32 | 33 | def test_merge_manifests(): 34 | with pytest.raises(ValueError): 35 | merge_manifests([]) 36 | 37 | with pytest.raises(AssertionError) as e: 38 | merge_manifests([PluginManifest(name="p1"), PluginManifest(name="p2")]) 39 | assert "All manifests must have same name" in str(e.value) 40 | 41 | pm1 = PluginManifest( 42 | name="plugin", 43 | contributions={ 44 | "commands": [{"id": "plugin.command", "title": "some writer"}], 45 | "writers": [{"command": "plugin.command", "layer_types": ["image"]}], 46 | }, 47 | ) 48 | pm2 = PluginManifest( 49 | name="plugin", 50 | contributions={ 51 | "commands": [{"id": "plugin.command", "title": "some reader"}], 52 | "readers": [{"command": "plugin.command", "filename_patterns": [".tif"]}], 53 | }, 54 | ) 55 | expected_merge = PluginManifest( 56 | name="plugin", 57 | contributions={ 58 | "commands": [ 59 | {"id": "plugin.command", "title": "some writer"}, 60 | {"id": "plugin.command_2", "title": "some reader"}, # no dupes 61 | ], 62 | "writers": [{"command": "plugin.command", "layer_types": ["image"]}], 63 | "readers": [{"command": "plugin.command_2", "filename_patterns": [".tif"]}], 64 | }, 65 | ) 66 | 67 | assert merge_manifests([pm1]) is pm1 68 | assert merge_manifests([pm1, pm2]) == expected_merge 69 | 70 | 71 | def test_merge_contributions(): 72 | a = ContributionPoints( 73 | commands=[ 74 | {"id": "plugin.command", "title": "some writer"}, 75 | ], 76 | writers=[{"command": "plugin.command", "layer_types": ["image"]}], 77 | ) 78 | b = ContributionPoints( 79 | commands=[ 80 | {"id": "plugin.command", "title": "some writer"}, 81 | ], 82 | writers=[{"command": "plugin.command", "layer_types": ["image"]}], 83 | ) 84 | c = ContributionPoints( 85 | commands=[ 86 | {"id": "plugin.command", "title": "some writer"}, 87 | ], 88 | writers=[{"command": "plugin.command", "layer_types": ["image"]}], 89 | ) 90 | expected = ContributionPoints( 91 | commands=[ 92 | {"id": "plugin.command", "title": "some writer"}, 93 | {"id": "plugin.command_2", "title": "some writer"}, 94 | {"id": "plugin.command_3", "title": "some writer"}, 95 | ], 96 | writers=[ 97 | {"command": "plugin.command", "layer_types": ["image"]}, 98 | {"command": "plugin.command_2", "layer_types": ["image"]}, 99 | {"command": "plugin.command_3", "layer_types": ["image"]}, 100 | ], 101 | ) 102 | 103 | d = ContributionPoints(**merge_contributions((a, b, c))) 104 | assert d == expected 105 | 106 | # with overwrite, later contributions with matching command ids take precendence. 107 | e = ContributionPoints(**merge_contributions((a, b, c), overwrite=True)) 108 | expected = ContributionPoints( 109 | commands=[ 110 | {"id": "plugin.command", "title": "some writer"}, 111 | ], 112 | writers=[ 113 | {"command": "plugin.command", "layer_types": ["image"]}, 114 | ], 115 | ) 116 | assert e == a 117 | 118 | 119 | def test_deep_update(): 120 | a = {"a": {"b": 1, "c": 2}, "e": 2} 121 | b = {"a": {"d": 4, "c": 3}, "f": 0} 122 | c = deep_update(a, b, copy=True) 123 | assert c == {"a": {"b": 1, "d": 4, "c": 3}, "e": 2, "f": 0} 124 | assert a == {"a": {"b": 1, "c": 2}, "e": 2} 125 | 126 | deep_update(a, b, copy=False) 127 | assert a == {"a": {"b": 1, "d": 4, "c": 3}, "e": 2, "f": 0} 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # npe2 - napari plugin engine version 2 2 | 3 | [![CI](https://github.com/napari/npe2/actions/workflows/ci.yml/badge.svg)](https://github.com/napari/npe2/actions/workflows/ci.yml) 4 | [![codecov](https://codecov.io/gh/napari/npe2/branch/main/graph/badge.svg?token=FTH635x542)](https://codecov.io/gh/napari/npe2) 5 | 6 | ## Project description 7 | 8 | The **napari plugin engine version 2**, **npe2** extends the functionality of 9 | [napari's core](https://github.com/napari/napari). 10 | The plugin ecosystem offers user additional functionality for napari as well 11 | as specific support for different scientific domains. 12 | 13 | This repo contains all source code and documentation required for defining, validating and managing plugins for napari. 14 | 15 | ## Getting started 16 | 17 | The [napari plugin docs landing page](https://napari.org/stable/plugins/index.html) 18 | offers comprehensive information for **plugin users** and for **plugin developers**. 19 | 20 | ### Plugin users 21 | 22 | For plugin users, the docs include information about: 23 | - [Finding and installing plugins](https://napari.org/stable/plugins/start_using_plugins/finding_and_installing_plugins.html#find-and-install-plugins) 24 | 25 | ### Plugin developers 26 | 27 | For plugin developers, the docs cover topics like: 28 | - [Building a plugin](https://napari.org/stable/plugins/building_a_plugin/index.html) 29 | - [Guides to different plugin contributions](https://napari.org/stable/plugins/building_a_plugin/guides.html) 30 | - [Technical references such as the plugin manifest](https://napari.org/stable/plugins/technical_references/manifest.html) 31 | 32 | Try the [**napari plugin template**](https://github.com/napari/napari-plugin-template) 33 | to streamline development of a new plugin. 34 | 35 | ## Installation 36 | 37 | The `npe2` command line tool can be installed with `pip` or `conda`, but will already be installed as a dependency if you have napari installed. 38 | 39 | ### Using pip 40 | 41 | 1. Create and activate a virtual environment. 42 | 43 | *If you are new to using virtual environments, visit our [virtual environments guide](https://napari.org/stable/plugins/virtual_environment_docs/1-virtual-environments.html)*. 44 | 45 | ```bash 46 | python3 -m venv .venv 47 | source .venv/bin/activate 48 | ``` 49 | 50 | 2. Install npe2. 51 | 52 | ```bash 53 | pip install npe2 54 | ``` 55 | 56 | 3. Test your installation. 57 | 58 | ```bash 59 | npe2 --help 60 | ``` 61 | 62 | ### Using conda 63 | 64 | 1. Create and activate a virtual environment. 65 | 66 | ```bash 67 | conda create -n npe-test -c conda-forge python=3.12 68 | conda activate npe-test 69 | ``` 70 | 71 | 2. Install npe2. 72 | 73 | ```bash 74 | conda install npe2 75 | ``` 76 | 77 | 3. Test your installation. 78 | 79 | ```bash 80 | npe2 --help 81 | ``` 82 | 83 | ## Usage 84 | 85 | The command line tool `npe2` offers the following commands: 86 | 87 | ```bash 88 | cache Cache utils 89 | compile Compile @npe2.implements contributions to generate a manifest. 90 | convert Convert first generation napari plugin to new (manifest) format. 91 | fetch Fetch manifest from remote package. 92 | list List currently installed plugins. 93 | parse Show parsed manifest as yaml. 94 | validate Validate manifest for a distribution name or manifest filepath. 95 | ``` 96 | 97 | ### Examples 98 | 99 | List currently installed plugins: 100 | 101 | ```bash 102 | npe2 list 103 | ``` 104 | 105 | Compile a source directory to create a plugin manifest: 106 | 107 | ```bash 108 | npe2 compile PATH_TO_SOURCE_DIRECTORY 109 | ``` 110 | 111 | Convert current directory to an npe2-ready plugin 112 | (note: the repo must also be installed and importable in the current environment.): 113 | 114 | ```bash 115 | npe2 convert . 116 | ``` 117 | 118 | Validate a plugin package. For example, a plugin named `your-plugin-package`: 119 | 120 | ```bash 121 | npe2 validate your-plugin-package 122 | ``` 123 | 124 | Show a parsed manifest of your plugin: 125 | 126 | ```bash 127 | npe2 parse your-plugin-package 128 | ``` 129 | 130 | ## License 131 | 132 | npe2 uses the [BSD License](./LICENSE). 133 | 134 | ## History 135 | 136 | This repo replaces the initial napari plugin engine v1. 137 | See also https://github.com/napari/napari/issues/3115 for 138 | motivation and technical discussion about the creation of v2. 139 | 140 | ## Contact us 141 | 142 | Visit [our community documentation](https://napari.org/stable/community/index.html) 143 | or [open a new issue on this repo](https://github.com/napari/npe2/issues/new). 144 | -------------------------------------------------------------------------------- /_docs/templates/_npe2_readers_guide.md.jinja: -------------------------------------------------------------------------------- 1 | (readers-contribution-guide)= 2 | ## Readers 3 | 4 | Reader plugins may add support for new filetypes to napari. 5 | They are invoked whenever `viewer.open('some/path')` is used on the 6 | command line, or when a user opens a file in the graphical user interface by 7 | dropping a file into the canvas, or using `File -> Open...` 8 | 9 | ### Introduction to the `reader` contribution 10 | 11 | `napari`'s reading process is motivated by the idea that a plugin should 12 | **only** attempt to read a file once we are fairly confident that this process 13 | will not fail. The determination of whether a plugin **can** read a file is 14 | based on two checks: 15 | 16 | - The `filename_patterns` field of the manifest contribution. If the given 17 | path does not match any of the specified `filename_patterns`, the plugin 18 | will never be given the file 19 | - The `command` provided by the reader contribution. The plugin developer 20 | should use this function, sometimes called `napari_get_reader` or `get_reader`, 21 | to check various properties and attributes of 22 | the file at the given path to determine whether it can be read 23 | 24 | The `get_reader` `command` provided by a reader contribution is expected to be a function 25 | that accepts a path (`str`) or a list of paths and: 26 | * returns `None` (if it does not want to accept the given path) 27 | * returns a *new function* that is capable of doing the reading. 28 | 29 | ```{admonition} Why do we need two functions? 30 | The `get_reader` command should make as many checks as possible 31 | (without loading the full file) to determine if it can read the path. For example, 32 | you might check for the presence of specific pointer files (like a `zarr.json`), or 33 | call a file format validating function to ensure the file is well-formed 34 | before reading, or inspect a few bytes at the beginning of the file to 35 | make sure it's the right file format. 36 | 37 | Another benefit of the `get_reader` function is that it allows the plugin 38 | developer to define multiple different reading functions depending on 39 | the file format or its properties, and return the appropriate one for the given path. 40 | 41 | This function should not raise exceptions, as napari has its own handlers 42 | that check for available compatible readers, and surface this information 43 | to the user. The `ReaderFunction` (described below), **can** raise errors, 44 | and napari will surface any raised errors to the user. 45 | ``` 46 | 47 | The `ReaderFunction` will be passed the same path (or list of paths) and 48 | is expected to return a list containing {ref}`LayerData tuples ` or 49 | a fully instantiated napari `Layer` objects like `Image` or `Labels`. Formally, the 50 | `ReaderFunction` type is specified as: 51 | 52 | ```python 53 | ReaderFunction = Callable[[PathOrPaths], List[LayerData]] 54 | ``` 55 | 56 | In the rare case that a reader plugin would like to "claim" a file, but *not* 57 | actually add any data to the viewer, the `ReaderFunction` may return 58 | the special value `[(None,)]`. 59 | 60 | ```{admonition} Accepting directories 61 | A reader may indicate that it accepts directories by 62 | setting `contributions.readers..accepts_directories` to `True`; 63 | otherwise, they will not be invoked when a directory is passed to `viewer.open`. 64 | ``` 65 | 66 | ### Reader example 67 | 68 | ::::{tab-set} 69 | :::{tab-item} npe2 70 | **python implementation** 71 | 72 | ```python 73 | # example_plugin.some_module 74 | {{ 'readers'|example_implementation }} 75 | ``` 76 | 77 | **manifest** 78 | 79 | See [Readers contribution reference](contributions-readers) 80 | for field details. 81 | 82 | ```yaml 83 | {{ 'readers'|example_contribution }} 84 | ``` 85 | ::: 86 | 87 | :::{tab-item} napari-plugin-engine 88 | 89 | ```{admonition} Deprecated! 90 | This demonstrates the now-deprecated `napari-plugin-engine` pattern. 91 | ``` 92 | 93 | **python implementation** 94 | 95 | [hook specification](https://napari.org/stable/plugins/npe1.html#napari.plugins.hook_specifications.napari_get_reader) 96 | 97 | ```python 98 | from napari_plugin_engine import napari_hook_implementation 99 | 100 | 101 | @napari_hook_implementation 102 | def napari_get_reader(path: PathOrPaths) -> Optional[ReaderFunction]: 103 | # If we recognize the format, we return the actual reader function 104 | if isinstance(path, str) and path.endswith(".xyz"): 105 | return xyz_file_reader 106 | # otherwise we return None. 107 | return None 108 | 109 | 110 | def xyz_file_reader(path: PathOrPaths) -> List[LayerData]: 111 | data = ... # somehow read data from path 112 | layer_properties = {"name": "etc..."} 113 | return [(data, layer_properties)] 114 | ``` 115 | ::: 116 | :::: 117 | -------------------------------------------------------------------------------- /src/npe2/manifest/contributions/_commands.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from npe2._pydantic_compat import BaseModel, Extra, Field, validator 6 | from npe2.manifest import _validators 7 | from npe2.types import PythonName 8 | 9 | from ._icon import Icon 10 | 11 | if TYPE_CHECKING: 12 | from npe2._command_registry import CommandRegistry 13 | 14 | 15 | class CommandContribution(BaseModel): 16 | """Contribute a **command** (a python callable) consisting of a unique `id`, 17 | a `title` and (optionally) a `python_path` that points to a fully qualified python 18 | callable. If a `python_path` is not included in the manifest, it *must* be 19 | registered during activation with `register_command`. 20 | 21 | Note, some other contributions (e.g. `readers`, `writers` and `widgets`) will 22 | *point* to a specific command. The command itself (i.e. the callable python 23 | object) will always appear in the `contributions.commands` section, but those 24 | contribution types may add additional contribution-specific metadata. 25 | 26 | ```{admonition} Future Plans 27 | Command contributions will eventually include an **icon**, **category**, and 28 | **enabled** state. Enablement is expressed with *when clauses*, that capture a 29 | conditional expression determining whether the command should be enabled or not, 30 | based on the current state of the program. (i.e. "*If the active layer is a 31 | `Labels` layer*") 32 | 33 | Commands will eventually be availble in a Command Palette (accessible with a 34 | hotkey) but they can also show in other menus. 35 | ``` 36 | """ 37 | 38 | id: str = Field( 39 | ..., 40 | description="A unique identifier used to reference this command. While this may" 41 | " look like a python fully qualified name this does *not* refer to a python " 42 | "object; this identifier is specific to napari. It must begin with " 43 | "the name of the package, and include only alphanumeric characters, plus " 44 | "dashes and underscores.", 45 | ) 46 | _valid_id = validator("id", allow_reuse=True)(_validators.command_id) 47 | 48 | title: str = Field( 49 | ..., 50 | description="User facing title representing the command. This might be used, " 51 | "for example, when searching in a command palette. Examples: 'Generate lily " 52 | "sample', 'Read tiff image', 'Open gaussian blur widget'. ", 53 | ) 54 | python_name: PythonName | None = Field( 55 | None, 56 | description="Fully qualified name to a callable python object " 57 | "implementing this command. This usually takes the form of " 58 | "`{obj.__module__}:{obj.__qualname__}` " 59 | "(e.g. `my_package.a_module:some_function`)", 60 | ) 61 | _valid_pyname = validator("python_name", allow_reuse=True)(_validators.python_name) 62 | 63 | short_title: str | None = Field( 64 | None, 65 | description="Short title by which the command is represented in " 66 | "the UI. Menus pick either `title` or `short_title` depending on the context " 67 | "in which they show commands.", 68 | ) 69 | category: str | None = Field( 70 | None, 71 | description="Category string by which the command may be grouped in the UI.", 72 | ) 73 | icon: str | Icon | None = Field( 74 | None, 75 | description="Icon used to represent this command in the UI, on " 76 | "buttons or in menus. These may be [superqt](https://github.com/napari/superqt)" 77 | " fonticon keys, such as `'fa6s.arrow_down'`; though note that plugins are " 78 | "expected to depend on any fonticon libraries they use, e.g " 79 | "[fonticon-fontawesome6](https://github.com/tlambert03/fonticon-fontawesome6).", 80 | ) 81 | enablement: str | None = Field( 82 | None, 83 | description=( 84 | "Expression which must evaluate as true to enable the command in the UI " 85 | "(menu and keybindings). Does not prevent executing the command " 86 | "by other means, like the `execute_command` api." 87 | ), 88 | ) 89 | 90 | class Config: 91 | extra = Extra.forbid 92 | 93 | def exec( 94 | self, 95 | args: tuple = (), 96 | kwargs: dict | None = None, 97 | _registry: CommandRegistry | None = None, 98 | ) -> Any: 99 | if kwargs is None: 100 | kwargs = {} 101 | if _registry is None: 102 | from npe2._plugin_manager import PluginManager 103 | 104 | _registry = PluginManager.instance().commands 105 | return _registry.execute(self.id, args, kwargs) 106 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # pyproject.toml 2 | [build-system] 3 | requires = ["hatchling", "hatch-vcs"] 4 | build-backend = "hatchling.build" 5 | 6 | [tool.hatch.version] 7 | source = "vcs" 8 | 9 | # https://peps.python.org/pep-0621/ 10 | [project] 11 | name = "npe2" 12 | dynamic = ["version"] 13 | description = "napari plugin engine v2" 14 | readme = "README.md" 15 | requires-python = ">=3.10" 16 | license = { text = "BSD-3-Clause" } 17 | authors = [ 18 | { name = "Talley Lambert", email = "talley.lambert@gmail.com" }, 19 | { name = "Nathan Clack" }, 20 | ] 21 | classifiers = [ 22 | "Development Status :: 3 - Alpha", 23 | "License :: OSI Approved :: BSD License", 24 | "Natural Language :: English", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python :: 3 :: Only", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: 3.12", 30 | "Programming Language :: Python :: 3.13", 31 | "Typing :: Typed", 32 | ] 33 | dependencies = [ 34 | "PyYAML", 35 | "platformdirs", 36 | "build>=1", 37 | "psygnal>=0.3.0", 38 | "pydantic", 39 | "tomli-w", 40 | "tomli; python_version < '3.11'", 41 | "rich", 42 | "typer", 43 | ] 44 | 45 | [project.urls] 46 | homepage = "https://github.com/napari/npe2" 47 | repository = "https://github.com/napari/npe2" 48 | 49 | # https://peps.python.org/pep-0621/#dependencies-optional-dependencies 50 | [project.optional-dependencies] 51 | testing = [ 52 | "magicgui", 53 | "napari-plugin-engine", 54 | "napari-svg==0.1.5", 55 | "numpy", 56 | "pytest", 57 | "pytest-cov", 58 | "jsonschema", 59 | "pytest-pretty", 60 | ] 61 | dev = ["ruff", "ipython", "isort", "mypy", "pre-commit"] 62 | docs = ["Jinja2", "magicgui>=0.3.3", "furo", "jupyter-book<2", "sphinx-tabs"] 63 | json = ["jsonschema"] 64 | 65 | # Entry points 66 | # https://peps.python.org/pep-0621/#entry-points 67 | # same as console_scripts entry point 68 | [project.scripts] 69 | npe2 = "npe2.cli:main" 70 | 71 | [project.entry-points."distutils.commands"] 72 | npe2_compile = "npe2._setuptools_plugin:npe2_compile" 73 | 74 | [project.entry-points."pytest11"] 75 | npe2 = "npe2._pytest_plugin" 76 | 77 | [project.entry-points."setuptools.finalize_distribution_options"] 78 | finalize_npe2 = "npe2._setuptools_plugin:finalize_npe2" 79 | 80 | 81 | [tool.check-manifest] 82 | ignore = [] 83 | 84 | [tool.pytest.ini_options] 85 | filterwarnings = ["error:::npe2"] 86 | addopts = "-m 'not github_main_only'" 87 | markers = [ 88 | "github_main_only: Test to run only on github main (verify it does not break latest napari docs build)", 89 | ] 90 | 91 | # https://github.com/charliermarsh/ruff 92 | [tool.ruff] 93 | line-length = 88 94 | target-version = "py310" 95 | fix = true 96 | src = ["src/npe2", "tests"] 97 | lint.select = [ 98 | "E", 99 | "F", 100 | "W", #flake8 101 | "UP", # pyupgrade 102 | "I", # isort 103 | "B", # flake8-bugbear 104 | "C4", # flake8-comprehensions 105 | "TID", # flake8-tidy-imports 106 | "RUF", # ruff-specific rules 107 | ] 108 | 109 | [tool.ruff.lint.per-file-ignores] 110 | "src/npe2/cli.py" = ["B008", "A00"] 111 | "**/test_*.py" = ["RUF018"] 112 | 113 | [tool.ruff.lint.pyupgrade] 114 | # Preserve types, even if a file imports `from __future__ import annotations`. 115 | keep-runtime-typing = true 116 | 117 | [tool.ruff.lint.isort] 118 | known-first-party = ['npe2'] 119 | 120 | # https://mypy.readthedocs.io/en/stable/config_file.html 121 | [tool.mypy] 122 | files = "src/**/*.py" 123 | warn_unused_configs = true 124 | warn_unused_ignores = true 125 | check_untyped_defs = true 126 | implicit_reexport = false 127 | show_column_numbers = true 128 | ignore_missing_imports = true 129 | show_error_codes = true 130 | pretty = true 131 | 132 | 133 | [tool.coverage.run] 134 | parallel = true 135 | source = ["src"] 136 | omit = [ 137 | "src/npe2/manifest/contributions/_keybindings.py", 138 | "src/npe2/manifest/menus.py", 139 | "src/npe2/__main__.py", 140 | "src/npe2/manifest/package_metadata.py", 141 | # due to all of the isolated sub-environments and sub-processes, 142 | # it's really hard to get coverage on the setuptools plugin. 143 | "src/npe2/_setuptools_plugin.py", 144 | ] 145 | 146 | [tool.coverage.paths] 147 | source = [ 148 | "src", 149 | "/Users/runner/work/npe2/npe2/src", 150 | "/home/runner/work/npe2/npe2/src", 151 | "D:\\a\\npe2\\npe2\\src", 152 | ] 153 | 154 | # https://coverage.readthedocs.io/en/6.4/config.html 155 | [tool.coverage.report] 156 | exclude_lines = [ 157 | "pragma: no cover", 158 | "if TYPE_CHECKING:", 159 | "raise AssertionError", 160 | "@overload", 161 | "@abstractmethod", 162 | "except ImportError", 163 | "\\.\\.\\.", 164 | "raise NotImplementedError", 165 | "if __name__ == .__main__.:", 166 | ] 167 | -------------------------------------------------------------------------------- /src/npe2/_inspection/_setuputils.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from configparser import ConfigParser 3 | from dataclasses import dataclass, field 4 | from functools import cached_property 5 | from importlib.metadata import EntryPoint 6 | from pathlib import Path 7 | from typing import Any 8 | 9 | NPE1_EP = "napari.plugin" 10 | NPE2_EP = "napari.manifest" 11 | 12 | 13 | @dataclass 14 | class PackageInfo: 15 | src_root: Path | None = None 16 | package_name: str = "" 17 | entry_points: list[EntryPoint] = field(default_factory=list) 18 | setup_cfg: Path | None = None 19 | setup_py: Path | None = None 20 | pyproject_toml: Path | None = None 21 | 22 | # @property 23 | # def packages(self) -> Optional[List[Path]]: 24 | # return Path(self.top_module) 25 | 26 | @cached_property 27 | def _ep1(self) -> EntryPoint | None: 28 | return next((ep for ep in self.entry_points if ep.group == NPE1_EP), None) 29 | 30 | @cached_property 31 | def _ep2(self) -> EntryPoint | None: 32 | return next((ep for ep in self.entry_points if ep.group == NPE2_EP), None) 33 | 34 | @property 35 | def ep_name(self): 36 | if ep := self._ep1: 37 | return ep.name 38 | 39 | @property 40 | def ep_value(self): 41 | if ep := self._ep1: 42 | return ep.value 43 | 44 | @property 45 | def top_module(self) -> str: 46 | if ep := (self._ep1 or self._ep2): 47 | return ep.value.split(".", 1)[0].split(":", 1)[0] 48 | return "" # pragma: no cover 49 | 50 | 51 | def get_package_dir_info(path: Path | str) -> PackageInfo: 52 | """Attempt to *statically* get plugin info from a package directory.""" 53 | path = Path(path).resolve() 54 | if not path.is_dir(): # pragma: no cover 55 | raise ValueError(f"Provided path is not a directory: {path}") 56 | 57 | info = PackageInfo(src_root=path) 58 | p = None 59 | 60 | # check for setup.cfg 61 | setup_cfg = path / "setup.cfg" 62 | if setup_cfg.exists(): 63 | info.setup_cfg = setup_cfg 64 | p = ConfigParser() 65 | p.read(setup_cfg) 66 | info.package_name = p.get("metadata", "name", fallback="") 67 | if p.has_section("options.entry_points"): 68 | for group, val in p.items("options.entry_points"): 69 | name, _, value = val.partition("=") 70 | info.entry_points.append(EntryPoint(name.strip(), value.strip(), group)) 71 | 72 | # check for setup.py 73 | setup_py = path / "setup.py" 74 | if setup_py.exists(): 75 | info.setup_py = setup_py 76 | node = ast.parse(setup_py.read_text()) 77 | visitor = _SetupVisitor() 78 | visitor.visit(node) 79 | if not info.package_name: 80 | info.package_name = visitor.get("name") 81 | if not info.entry_points: 82 | for group, vals in visitor.get("entry_points", {}).items(): 83 | for val in vals if isinstance(vals, list) else [vals]: 84 | name, _, value = val.partition("=") 85 | info.entry_points.append( 86 | EntryPoint(name.strip(), value.strip(), group) 87 | ) 88 | 89 | return info 90 | 91 | 92 | class _SetupVisitor(ast.NodeVisitor): 93 | """Visitor to statically determine metadata from setup.py""" 94 | 95 | def __init__(self) -> None: 96 | super().__init__() 97 | self._names: dict[str, Any] = {} 98 | self._setup_kwargs: dict[str, Any] = {} 99 | 100 | def visit_Assign(self, node: ast.Assign) -> Any: 101 | if len(node.targets) == 1: 102 | target = node.targets[0] 103 | if isinstance(target, ast.Name) and isinstance(target.ctx, ast.Store): 104 | self._names[target.id] = self._get_val(node.value) 105 | 106 | def visit_Call(self, node: ast.Call) -> Any: 107 | if getattr(node.func, "id", "") == "setup": 108 | for k in node.keywords: 109 | key = k.arg 110 | value = self._get_val(k.value) 111 | self._setup_kwargs[str(key)] = value 112 | 113 | def _get_val(self, node: ast.expr | None) -> Any: 114 | if isinstance(node, ast.Constant): 115 | return node.value 116 | if isinstance(node, ast.Name): 117 | return ( 118 | self._names.get(node.id) if isinstance(node.ctx, ast.Load) else node.id 119 | ) 120 | if isinstance(node, ast.Dict): 121 | keys = [self._get_val(k) for k in node.keys] 122 | values = [self._get_val(k) for k in node.values] 123 | return dict(zip(keys, values, strict=True)) 124 | if isinstance(node, ast.List): 125 | return [self._get_val(k) for k in node.elts] 126 | if isinstance(node, ast.Tuple): # pragma: no cover 127 | return tuple(self._get_val(k) for k in node.elts) 128 | return str(node) # pragma: no cover 129 | 130 | def get(self, key: str, default: Any | None = None) -> Any: 131 | return self._setup_kwargs.get(key, default) 132 | -------------------------------------------------------------------------------- /src/npe2/plugin_manager.py: -------------------------------------------------------------------------------- 1 | # mypy: disable-error-code=empty-body 2 | """Convenience module to access methods on the global PluginManager singleton.""" 3 | 4 | from __future__ import annotations 5 | 6 | import builtins 7 | from typing import TYPE_CHECKING 8 | 9 | if TYPE_CHECKING: 10 | from collections.abc import Iterator, Sequence 11 | from os import PathLike 12 | from typing import Any, NewType 13 | 14 | from npe2 import PluginManifest 15 | from npe2._plugin_manager import InclusionSet, PluginContext 16 | from npe2.manifest import contributions 17 | 18 | from ._plugin_manager import PluginManager 19 | 20 | PluginName = NewType("PluginName", str) 21 | 22 | 23 | def instance() -> PluginManager: 24 | """Return global PluginManager singleton instance.""" 25 | from ._plugin_manager import PluginManager 26 | 27 | return PluginManager.instance() 28 | 29 | 30 | def discover(paths: Sequence[str] = (), clear=False, include_npe1=False) -> None: 31 | """Discover and index plugin manifests in the environment.""" 32 | 33 | 34 | def dict( 35 | self, 36 | *, 37 | include: InclusionSet | None = None, 38 | exclude: InclusionSet | None = None, 39 | ) -> builtins.dict[str, Any]: 40 | """Return a dictionary with the state of the plugin manager.""" 41 | 42 | 43 | def index_npe1_adapters() -> None: 44 | """Import and index any/all npe1 adapters.""" 45 | 46 | 47 | def register(manifest: PluginManifest, warn_disabled=True) -> None: 48 | """Register a plugin manifest""" 49 | 50 | 51 | def unregister(key: PluginName) -> None: 52 | """Unregister plugin named `key`.""" 53 | 54 | 55 | def activate(key: PluginName) -> PluginContext: 56 | """Activate plugin with `key`.""" 57 | 58 | 59 | def get_context(plugin_name: PluginName) -> PluginContext: 60 | """Return PluginContext for plugin_name""" 61 | 62 | 63 | def deactivate(plugin_name: PluginName) -> None: 64 | """Deactivate `plugin_name`""" 65 | 66 | 67 | def enable(plugin_name: PluginName) -> None: 68 | """Enable a plugin (which mostly means just `un-disable` it.""" 69 | 70 | 71 | def disable(plugin_name: PluginName) -> None: 72 | """Disable a plugin""" 73 | 74 | 75 | def is_disabled(plugin_name: str) -> bool: 76 | """Return `True` if plugin_name is disabled.""" 77 | 78 | 79 | def get_manifest(plugin_name: str) -> PluginManifest: 80 | """Get manifest for `plugin_name`""" 81 | 82 | 83 | def iter_manifests(disabled: bool | None = None) -> Iterator[PluginManifest]: 84 | """Iterate through registered manifests.""" 85 | 86 | 87 | def get_command(command_id: str) -> contributions.CommandContribution: 88 | """Retrieve CommandContribution for `command_id`""" 89 | 90 | 91 | def get_submenu(submenu_id: str) -> contributions.SubmenuContribution: 92 | """Get SubmenuContribution for `submenu_id`.""" 93 | 94 | 95 | def iter_menu(menu_key: str, disabled=False) -> Iterator[contributions.MenuItem]: 96 | """Iterate over `MenuItems` in menu with id `menu_key`.""" 97 | 98 | 99 | def menus(disabled=False) -> builtins.dict[str, list[contributions.MenuItem]]: 100 | """Return all registered menu_key -> List[MenuItems].""" 101 | 102 | 103 | def iter_themes() -> Iterator[contributions.ThemeContribution]: 104 | """Iterate over discovered/enuabled `ThemeContributions`.""" 105 | 106 | 107 | def iter_compatible_readers( 108 | path: PathLike | Sequence[str], 109 | ) -> Iterator[contributions.ReaderContribution]: 110 | """Iterate over ReaderContributions compatible with `path`.""" 111 | 112 | 113 | def iter_compatible_writers( 114 | layer_types: Sequence[str], 115 | ) -> Iterator[contributions.WriterContribution]: 116 | """Iterate over compatible WriterContributions given a sequence of layer_types.""" 117 | 118 | 119 | def iter_widgets() -> Iterator[contributions.WidgetContribution]: 120 | """Iterate over discovered WidgetContributions.""" 121 | 122 | 123 | def iter_sample_data() -> Iterator[ 124 | tuple[PluginName, list[contributions.SampleDataContribution]] 125 | ]: 126 | """Iterates over (plugin_name, [sample_contribs]).""" 127 | 128 | 129 | def get_writer( 130 | path: str, layer_types: Sequence[str], plugin_name: str | None = None 131 | ) -> tuple[contributions.WriterContribution | None, str]: 132 | """Get Writer contribution appropriate for `path`, and `layer_types`.""" 133 | 134 | 135 | def get_shimmed_plugins() -> list[str]: 136 | """Return a list of all shimmed plugin names.""" 137 | 138 | 139 | def _populate_module(): 140 | """Convert all functions in this module into global plugin manager methods.""" 141 | import functools 142 | import sys 143 | 144 | from ._plugin_manager import PluginManager 145 | 146 | _module = sys.modules[__name__] 147 | for key in dir(_module): 148 | if key.startswith(("_", "instance")) or not hasattr(PluginManager, key): 149 | continue 150 | 151 | @functools.wraps(getattr(_module, key)) 152 | def _f(*args, _key=key, **kwargs): 153 | return getattr(instance(), _key)(*args, **kwargs) 154 | 155 | setattr(_module, key, _f) 156 | 157 | 158 | _populate_module() 159 | del _populate_module, TYPE_CHECKING, annotations 160 | -------------------------------------------------------------------------------- /_docs/templates/_npe2_widgets_guide.md.jinja: -------------------------------------------------------------------------------- 1 | (widgets-contribution-guide)= 2 | ## Widgets 3 | 4 | Widget plugin contributions allow developers to add novel graphical 5 | elements (aka "widgets") to the user interface. For a full introduction to 6 | creating `napari` widgets see [](creating-widgets). 7 | 8 | Widgets can request 9 | access to the viewer instance in which they are docked, enabling a broad 10 | range of functionality: essentially, anything that can be done with the 11 | napari `Viewer` and `Layer` APIs can be accomplished with widgets. 12 | 13 | ```{important} 14 | Because this is a powerful and open-ended plugin specification, we 15 | ask that plugin developers take additional care when providing widget plugins. 16 | Make sure to only use public methods on the `viewer` and `layer` instances. 17 | Also, be mindful of the fact that the user may be using your plugin along with 18 | other plugins or workflows: try to only modify layers added by your plugin, or 19 | specifically requested by the user. 20 | ``` 21 | 22 | The widget specification requires that the plugin provide napari with a 23 | *callable* object that, when called, returns an *instance* of a widget. 24 | Here "widget" means a subclass of `QtWidgets.QWidget` or `magicgui.widgets.Widget`, 25 | or a `FunctionGui`. Additionally, the plugin can provide an arbitrary function if using 26 | 'autogenerate', which requests that napari autogenerate a widget using 27 | `magicgui.magicgui` (see item 3 below). 28 | 29 | There are a few commonly used patterns that fulfill this `Callable[..., Widget]` 30 | specification: 31 | 32 | 1. Provide a `class` object that is a subclass of `QtWidgets.QWidget` or 33 | `magicgui.widgets.Widget`: 34 | 35 | ```python 36 | from qtpy.QtWidgets import QWidget 37 | 38 | class MyPluginWidget(QWidget): 39 | def __init__(self, viewer: 'napari.viewer.Viewer', parent=None): 40 | super().__init__(parent) 41 | self._viewer = viewer 42 | ``` 43 | 44 | 2. Provide a `magicgui.magic_factory` object: 45 | 46 | ```python 47 | from magicgui import magic_factory 48 | 49 | @magic_factory 50 | def create_widget(image: 'napari.types.ImageData') -> 'napari.types.ImageData': 51 | ... 52 | ``` 53 | 54 | *(reminder, in the example above, each time the `magic_factory`-decorated 55 | `create_widget()` function is called, it returns a new widget instance –– 56 | just as we need for the widget specification.)* 57 | 58 | 3. Lastly, you can provide an arbitrary function and request that napari 59 | autogenerate a widget using `magicgui.magicgui`. In the first generation 60 | `napari_plugin_engine`, this was the `napari_experimental_provide_function` 61 | hook specification. In the new `npe2` pattern, one uses the `autogenerate` 62 | field in the [WidgetContribution](contributions-widgets). 63 | 64 | For more examples see [](creating-widgets) and 65 | [GUI gallery examples](https://napari.org/stable/_tags/gui.html) (only a subset 66 | involve widgets). Additionally, 67 | [napari-plugin-template](https://github.com/napari/napari-plugin-template) 68 | has more robust widget examples that you can adapt to your needs. 69 | 70 | ```{note} 71 | Notice that `napari` type annotations are strings and not imported. This is to 72 | avoid including `napari` as a plugin dependency when not strictly required. 73 | ``` 74 | 75 | ### Widget example 76 | 77 | ::::{tab-set} 78 | :::{tab-item} npe2 79 | **python implementation** 80 | 81 | ```python 82 | # example_plugin.some_module 83 | {{ 'widgets'|example_implementation }} 84 | ``` 85 | 86 | **manifest** 87 | 88 | See [Widgets contribution reference](contributions-widgets) for field details. 89 | 90 | ```yaml 91 | {{ 'widgets'|example_contribution }} 92 | ``` 93 | ::: 94 | 95 | :::{tab-item} napari-plugin-engine 96 | 97 | ```{admonition} Deprecated! 98 | This demonstrates the now-deprecated `napari-plugin-engine` pattern. 99 | ``` 100 | 101 | **python implementation** 102 | 103 | [hook_specification](https://napari.org/stable/plugins/npe1.html#napari.plugins.hook_specifications.napari_experimental_provide_dock_widget) 104 | 105 | ```python 106 | from qtpy.QtWidgets import QWidget 107 | from napari_plugin_engine import napari_hook_implementation 108 | 109 | 110 | class AnimationWizard(QWidget): 111 | def __init__(self, viewer: "napari.viewer.Viewer", parent=None): 112 | super().__init__(parent) 113 | ... 114 | 115 | 116 | @magic_factory 117 | def widget_factory( 118 | image: "napari.types.ImageData", threshold: int 119 | ) -> "napari.types.LabelsData": 120 | """Generate thresholded image. 121 | 122 | This pattern uses magicgui.magic_factory directly to turn a function 123 | into a callable that returns a widget. 124 | """ 125 | return (image > threshold).astype(int) 126 | 127 | 128 | def threshold( 129 | image: "napari.types.ImageData", threshold: int 130 | ) -> "napari.types.LabelsData": 131 | """Generate thresholded image. 132 | 133 | This function will be turned into a widget using `autogenerate: true`. 134 | """ 135 | return (image > threshold).astype(int) 136 | 137 | 138 | # in the first generation plugin engine, these widgets were declared 139 | # using special `napari_hook_implementation`-decorated functions. 140 | 141 | @napari_hook_implementation 142 | def napari_experimental_provide_dock_widget(): 143 | return [AnimationWizard, widget_factory] 144 | 145 | 146 | @napari_hook_implementation 147 | def napari_experimental_provide_function(): 148 | return [threshold] 149 | ``` 150 | ::: 151 | :::: 152 | -------------------------------------------------------------------------------- /src/npe2/_command_registry.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | from dataclasses import dataclass 5 | from functools import partial 6 | from typing import TYPE_CHECKING, Any 7 | 8 | from psygnal import Signal 9 | 10 | from .manifest import utils 11 | from .manifest._validators import DOTTED_NAME_PATTERN 12 | from .types import PythonName 13 | 14 | PDisposable = Callable[[], None] 15 | 16 | if TYPE_CHECKING: 17 | from .manifest.schema import PluginManifest 18 | 19 | 20 | @dataclass 21 | class CommandHandler: 22 | id: str 23 | function: Callable | None = None 24 | python_name: PythonName | None = None 25 | 26 | def resolve(self) -> Callable: 27 | if self.function is not None: 28 | return self.function 29 | if self.python_name is None: 30 | raise RuntimeError("cannot resolve command without python_name") 31 | 32 | try: 33 | self.function = utils.import_python_name(self.python_name) 34 | except Exception as e: 35 | raise RuntimeError( 36 | f"Failed to import command at {self.python_name!r}: {e}" 37 | ) from e 38 | 39 | return self.function 40 | 41 | 42 | class CommandRegistry: 43 | """Registry of commands, updated on `PluginManager.activate`. 44 | 45 | `PluginManager.activate` is only run on `CommandRegistry.get` (when we resolve the 46 | callable object for a command). This means that commands from enabled plugins are 47 | not added to `CommandRegistry` until a command from that plugin is executed. 48 | """ 49 | 50 | command_registered = Signal(str) 51 | command_unregistered = Signal(str) 52 | 53 | def __init__(self) -> None: 54 | self._commands: dict[str, CommandHandler] = {} 55 | 56 | def register(self, id: str, command: Callable | str) -> PDisposable: 57 | """Register a command under `id`. 58 | 59 | Parameters 60 | ---------- 61 | id : str 62 | A unique key with which to refer to this command 63 | command : Union[Callable, str] 64 | Either a callable object, or (if a string) the fully qualified name of a 65 | python object. If a string is provided, it is not imported until 66 | the command is actually executed. 67 | 68 | Returns 69 | ------- 70 | PDisposable 71 | A callable that, when called, unregisters the command. 72 | 73 | Raises 74 | ------ 75 | ValueError 76 | If the id is not a non-empty string, or if it already exists. 77 | TypeError 78 | If `command` is not a string or a callable object. 79 | """ 80 | if not (isinstance(id, str) and id.strip()): 81 | raise ValueError( 82 | f"Invalid command id for {command}, must be non-empty string" 83 | ) 84 | if id in self._commands: 85 | raise ValueError(f"Command {id} already exists") 86 | 87 | if isinstance(command, str): 88 | if not DOTTED_NAME_PATTERN.match(command): 89 | raise ValueError( 90 | "String command {command!r} is not a valid qualified python path." 91 | ) 92 | cmd = CommandHandler(id, python_name=PythonName(command)) 93 | elif not callable(command): 94 | raise TypeError(f"Cannot register non-callable command: {command}") 95 | else: 96 | cmd = CommandHandler(id, function=command) 97 | 98 | # TODO: validate arguments and type constraints 99 | # possibly wrap command in a type validator? 100 | 101 | self._commands[id] = cmd 102 | self.command_registered.emit(id) 103 | 104 | return partial(self.unregister, id) 105 | 106 | def unregister(self, id: str): 107 | """Unregister command with key `id`. No-op if key doesn't exist.""" 108 | if id in self._commands: 109 | del self._commands[id] 110 | self.command_unregistered.emit(id) 111 | 112 | def register_manifest(self, mf: PluginManifest) -> None: 113 | """Register all commands in a manifest""" 114 | if mf.contributions and mf.contributions.commands: 115 | for cmd in mf.contributions.commands: 116 | if cmd.python_name and cmd.id not in self: 117 | self.register(cmd.id, cmd.python_name) 118 | 119 | def unregister_manifest(self, mf: PluginManifest) -> None: 120 | """Unregister all commands in a manifest""" 121 | if mf.contributions and mf.contributions.commands: 122 | for cmd in mf.contributions.commands: 123 | if cmd.id in self: 124 | self.unregister(cmd.id) 125 | 126 | def get(self, id: str) -> Callable: 127 | """Get callable object for command `id`.""" 128 | # FIXME: who should control activation? 129 | if id not in self._commands: 130 | from ._plugin_manager import PluginManager 131 | 132 | pm = PluginManager.instance() 133 | 134 | if id in pm._contrib._commands: 135 | _, plugin_key = pm._contrib._commands[id] 136 | pm.activate(plugin_key) 137 | if id not in self._commands: # sourcery skip 138 | raise KeyError(f"command {id!r} not registered") 139 | return self._commands[id].resolve() 140 | 141 | def execute(self, id: str, args=(), kwargs=None) -> Any: 142 | if kwargs is None: 143 | kwargs = {} 144 | return self.get(id)(*args, **kwargs) 145 | 146 | def __contains__(self, id: str): 147 | return id in self._commands 148 | -------------------------------------------------------------------------------- /_docs/templates/_npe2_writers_guide.md.jinja: -------------------------------------------------------------------------------- 1 | (writers-contribution-guide)= 2 | ## Writers 3 | 4 | Writer plugins add support for exporting data from napari. 5 | They are invoked whenever `viewer.layers.save('some/path.ext')` 6 | is used on the command line, or when a user requests to save one 7 | or more layers in the graphical user interface with 8 | `File -> Save Selected Layer(s)...` or `Save All Layers...` 9 | 10 | ```{important} 11 | This guide describes the second generation (`npe2`) plugin specification. 12 | New plugins should no longer use the old `napari_get_writer` hook 13 | specification from the first generation `napari_plugin_engine`. 14 | ``` 15 | 16 | ### Writer plugin function signatures 17 | 18 | Writer plugins are *functions* that: 19 | 20 | 1. Accept a destination path and data from one or more layers in the viewer 21 | 2. Write layer data and associated attributes to disk 22 | 3. Return a list of strings containing the path(s) that were successfully written. 23 | 24 | They must follow one of two calling conventions (where the convention used 25 | is determined by the [`layer_type` constraints](layer-type-constraints) provided 26 | by the corresponding writer contribution in the manifest). 27 | 28 | #### 1. single-layer writer 29 | 30 | Single-layer writers will receive a **path**, layer **data**, and a `dict` of layer 31 | **attributes**, (e.g. `{'name': 'My Layer', 'opacity': 0.6}`) 32 | 33 | ```python 34 | def single_layer_writer(path: str, data: Any, attributes: dict) -> List[str]: 35 | ... 36 | ``` 37 | 38 | The formal type is as follows: 39 | 40 | ```python 41 | DataType = Any # usually something like a numpy array, but varies by layer 42 | LayerAttributes = dict 43 | SingleWriterFunction = Callable[[str, DataType, LayerAttributes], List[str]] 44 | ``` 45 | 46 | #### 2. multi-layer writer 47 | 48 | Multi-layer writers will receive a **path**, and a list of full 49 | [layer data tuples](layer-data-tuples). 50 | 51 | ```python 52 | def multi_layer_writer(path: str, layer_data: List[FullLayerData]) -> List[str]: 53 | ... 54 | ``` 55 | 56 | The formal type is as follows: 57 | 58 | ```python 59 | DataType = Any # usually something like a numpy array, but varies by layer 60 | LayerAttributes = dict 61 | LayerName = Literal["graph", "image", "labels", "points", "shapes", "surface", "tracks", "vectors"] 62 | FullLayerData = Tuple[DataType, LayerAttributes, LayerName] 63 | MultiWriterFunction = Callable[[str, List[FullLayerData]], List[str]] 64 | ``` 65 | 66 | (layer-type-constraints)= 67 | ### Layer type constraints 68 | 69 | Individual writer contributions are determined to be **single-layer writers** or 70 | **multi-layer writers** based on their **`writer.layer_types`** constraints 71 | provided in the [contribution metadata](contributions-writers). 72 | 73 | A writer plugin declares that it can accept between *m* and *n* layers of a 74 | specific *type* (where *0 ≤ m ≤ n*), using regex-like syntax with the special 75 | characters **`?`**, **`+`** and **`*`**: 76 | 77 | - **`image`**: Writes exactly 1 image layer. 78 | - **`image?`**: Writes 0 or 1 image layers. 79 | - **`image+`**: Writes 1 or more image layers. 80 | - **`image*`**: Writes 0 or more image layers. 81 | - **`image{k}`**: Writes exactly k image layers. 82 | - **`image{m,n}`**: Writes between *m* and *n* layers (inclusive range). Must have *m <= n*. 83 | 84 | A writer plugin will *only* be invoked when its `layer_types` constraint is 85 | compatible with the layer type(s) that the user is saving. When a type is not 86 | present in the list of constraints, it is assumed the writer is **not** 87 | compatible with that type. 88 | 89 | **Consider this example contributions section in a manifest:** 90 | 91 | ```yaml 92 | contributions: 93 | writers: 94 | - command: example-plugin.some_writer 95 | layer_types: ["image+", "points?"] 96 | filename_extensions: [".ext"] 97 | ``` 98 | 99 | This writer would be considered when 1 or more `Image` layers and 0 or 1 100 | `Points` layers are selected (i.e. the `Points` layer is optional). This 101 | writer would *not* be selected when the user tries to save an `image` 102 | and a `vectors` layer, because `vectors` is not listed in the `layer_types`. 103 | 104 | 105 | ### Writer example 106 | 107 | ::::{tab-set} 108 | :::{tab-item} npe2 109 | **python implementation** 110 | 111 | ```python 112 | # example_plugin.some_module 113 | {{ 'writers'|example_implementation }} 114 | ``` 115 | 116 | **manifest** 117 | 118 | See [Writers contribution reference](contributions-writers) 119 | for field details. 120 | 121 | ```yaml 122 | {{ 'writers'|example_contribution }} 123 | ``` 124 | ::: 125 | 126 | ::::{tab-item} napari-plugin-engine 127 | 128 | ```{admonition} Deprecated! 129 | This demonstrates the now-deprecated `napari-plugin-engine` pattern. 130 | ``` 131 | 132 | **python implementation** 133 | 134 | [hook specification](https://napari.org/stable/plugins/npe1.html#single-layers-io) 135 | 136 | ```python 137 | from napari_plugin_engine import napari_hook_implementation 138 | 139 | @napari_hook_implementation 140 | def napari_write_points(path: str, data: Any, meta: dict) -> Optional[str]: 141 | """Write points data and metadata into a path. 142 | 143 | Parameters 144 | ---------- 145 | path : str 146 | Path to file, directory, or resource (like a URL). 147 | data : array (N, D) 148 | Points layer data 149 | meta : dict 150 | Points metadata. 151 | 152 | Returns 153 | ------- 154 | path : str or None 155 | If data is successfully written, return the ``path`` that was written. 156 | Otherwise, if nothing was done, return ``None``. 157 | """ 158 | ``` 159 | ::: 160 | :::: 161 | -------------------------------------------------------------------------------- /src/npe2/manifest/_npe1_adapter.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import logging 3 | import os 4 | import site 5 | import warnings 6 | from collections.abc import Sequence 7 | from importlib import metadata 8 | from pathlib import Path 9 | from shutil import rmtree 10 | 11 | from platformdirs import user_cache_dir 12 | 13 | from npe2._inspection._from_npe1 import manifest_from_npe1 14 | from npe2.manifest import PackageMetadata 15 | 16 | from .schema import PluginManifest, discovery_blocked 17 | 18 | logger = logging.getLogger(__name__) 19 | ADAPTER_CACHE = Path(user_cache_dir("napari", "napari")) / "npe2" / "adapter_manifests" 20 | NPE2_NOCACHE = "NPE2_NOCACHE" 21 | 22 | 23 | def clear_cache(names: Sequence[str] = ()) -> list[Path]: 24 | """Clear cached NPE1Adapter manifests. 25 | 26 | Parameters 27 | ---------- 28 | names : Sequence[str], optional 29 | selection of plugin names to clear, by default, all will be cleared 30 | 31 | Returns 32 | ------- 33 | List[Path] 34 | List of filepaths cleared 35 | """ 36 | _cleared: list[Path] = [] 37 | if ADAPTER_CACHE.exists(): 38 | if names: 39 | for f in ADAPTER_CACHE.glob("*.yaml"): 40 | if any(f.name.startswith(f"{n}_") for n in names): 41 | f.unlink() 42 | _cleared.append(f) 43 | else: 44 | _cleared = list(ADAPTER_CACHE.iterdir()) 45 | rmtree(ADAPTER_CACHE) 46 | return _cleared 47 | 48 | 49 | class NPE1Adapter(PluginManifest): 50 | """PluginManifest subclass that acts as an adapter for 1st gen plugins. 51 | 52 | During plugin discovery, packages that provide a first generation 53 | 'napari.plugin' entry_point (but do *not* provide a second generation 54 | 'napari.manifest' entrypoint) will be stored as `NPE1Adapter` manifests 55 | in the `PluginManager._npe1_adapters` list. 56 | 57 | This class is instantiated with only a distribution object, but lacks 58 | contributions at construction time. When `self.contributions` is accesses for the 59 | first time, `_load_contributions` is called triggering and import and indexing of 60 | all plugin modules using the same logic as `npe2 convert`. After import, the 61 | discovered contributions are cached in a manifest for use in future sessions. 62 | (The cache can be cleared using `npe2 cache --clear [plugin-name]`). 63 | 64 | 65 | 66 | Parameters 67 | ---------- 68 | dist : metadata.Distribution 69 | A Distribution object for a package installed in the environment. (Minimally, 70 | the distribution object must implement the `metadata` and `entry_points` 71 | attributes.). It will be passed to `manifest_from_npe1` 72 | """ 73 | 74 | _is_loaded: bool = False 75 | _dist: metadata.Distribution 76 | 77 | def __init__(self, dist: metadata.Distribution): 78 | """_summary_""" 79 | meta = PackageMetadata.from_dist_metadata(dist.metadata) 80 | super().__init__( 81 | name=dist.metadata["Name"], package_metadata=meta, npe1_shim=True 82 | ) 83 | self._dist = dist 84 | 85 | def __getattribute__(self, __name: str): 86 | if __name == "contributions": 87 | self._load_contributions() 88 | return super().__getattribute__(__name) 89 | 90 | def _load_contributions(self, save=True) -> None: 91 | """import and inspect package contributions.""" 92 | if self._is_loaded: 93 | return 94 | self._is_loaded = True # if we fail once, we still don't try again. 95 | if self._cache_path().exists() and not os.getenv(NPE2_NOCACHE): 96 | mf = PluginManifest.from_file(self._cache_path()) 97 | self.contributions = mf.contributions 98 | logger.debug("%r npe1 adapter loaded from cache", self.name) 99 | return 100 | 101 | with discovery_blocked(): 102 | try: 103 | mf = manifest_from_npe1(self._dist, adapter=True) 104 | except Exception as e: 105 | warnings.warn( 106 | "Error importing contributions for first-generation " 107 | f"napari plugin {self.name!r}: {e}", 108 | stacklevel=2, 109 | ) 110 | return 111 | 112 | self.contributions = mf.contributions 113 | logger.debug("%r npe1 adapter imported", self.name) 114 | 115 | if save and not _is_editable_install(self._dist): 116 | with contextlib.suppress(OSError): 117 | self._save_to_cache() 118 | 119 | def _save_to_cache(self): 120 | cache_path = self._cache_path() 121 | cache_path.parent.mkdir(exist_ok=True, parents=True) 122 | cache_path.write_text(self.yaml()) 123 | 124 | def _cache_path(self) -> Path: 125 | """Return cache path for manifest corresponding to distribution.""" 126 | return _cached_adapter_path(self.name, self.package_version or "") 127 | 128 | def _serialized_data(self, **kwargs): 129 | if not self._is_loaded: # pragma: no cover 130 | self._load_contributions(save=False) 131 | return super()._serialized_data(**kwargs) 132 | 133 | 134 | def _cached_adapter_path(name: str, version: str) -> Path: 135 | """Return cache path for manifest corresponding to distribution.""" 136 | return ADAPTER_CACHE / f"{name}_{version}.yaml" 137 | 138 | 139 | def _is_editable_install(dist: metadata.Distribution) -> bool: 140 | """Return True if dist is installed as editable. 141 | 142 | i.e: if the package isn't in site-packages or user site-packages. 143 | """ 144 | root = str(dist.locate_file("")) 145 | installed_paths = [*site.getsitepackages(), site.getusersitepackages()] 146 | return all(loc not in root for loc in installed_paths) 147 | -------------------------------------------------------------------------------- /tests/test_contributions.py: -------------------------------------------------------------------------------- 1 | import json 2 | from functools import partial 3 | from unittest.mock import Mock 4 | 5 | import pytest 6 | 7 | from npe2 import DynamicPlugin, PluginManager, PluginManifest 8 | from npe2.manifest.contributions import ( 9 | CommandContribution, 10 | SampleDataGenerator, 11 | SampleDataURI, 12 | ) 13 | 14 | SAMPLE_PLUGIN_NAME = "my-plugin" 15 | 16 | 17 | def test_writer_empty_layers(): 18 | pm = PluginManager() 19 | pm.discover() 20 | writers = list(pm.iter_compatible_writers([])) 21 | assert not writers 22 | 23 | 24 | @pytest.mark.parametrize( 25 | "param", 26 | [ 27 | (["image"] * 2, 1), 28 | (["labels"], 0), 29 | (["image"] * 4, 1), 30 | (["image"] * 5, 0), 31 | (["points", "surface"], 1), 32 | (["points", "surface", "points"], 0), 33 | ], 34 | ) 35 | def test_writer_ranges(param, uses_sample_plugin, plugin_manager: PluginManager): 36 | layer_types, expected_count = param 37 | nwriters = sum( 38 | w.command == f"{SAMPLE_PLUGIN_NAME}.my_writer" 39 | for w in plugin_manager.iter_compatible_writers(layer_types) 40 | ) 41 | 42 | assert nwriters == expected_count 43 | 44 | 45 | def test_writer_priority(): 46 | """Contributions listed earlier in the manifest should be preferred.""" 47 | pm = PluginManager() 48 | with DynamicPlugin(name="my_plugin", plugin_manager=pm) as plg: 49 | 50 | @plg.contribute.writer(filename_extensions=["*.tif"], layer_types=["image"]) 51 | def my_writer1(path, data): ... 52 | 53 | @plg.contribute.writer(filename_extensions=["*.abc"], layer_types=["image"]) 54 | def my_writer2(path, data): ... 55 | 56 | writers = list(pm.iter_compatible_writers(["image"])) 57 | assert writers[0].command == "my_plugin.my_writer1" 58 | assert len(pm) == 1 59 | 60 | 61 | @pytest.mark.parametrize( 62 | "expr", 63 | ["vectors", "vectors+", "vectors*", "vectors?", "vectors{3}", "vectors{3,8}"], 64 | ) 65 | def test_writer_valid_layer_type_expressions(expr, uses_sample_plugin): 66 | result = next( 67 | result 68 | for result in PluginManifest.discover() 69 | if result.manifest and result.manifest.name == SAMPLE_PLUGIN_NAME 70 | ) 71 | assert result.error is None 72 | assert result.manifest is not None 73 | pm = result.manifest 74 | data = json.loads(pm.json(exclude_unset=True)) 75 | 76 | assert "contributions" in data 77 | assert "writers" in data["contributions"] 78 | data["contributions"]["writers"][0]["layer_types"].append(expr) 79 | 80 | PluginManifest(**data) 81 | 82 | 83 | def test_basic_iter_reader(uses_sample_plugin, plugin_manager: PluginManager, tmp_path): 84 | tmp_path = str(tmp_path) 85 | assert not list(plugin_manager.iter_compatible_readers("")) 86 | reader = next(iter(plugin_manager.iter_compatible_readers(tmp_path))) 87 | assert reader.command == f"{SAMPLE_PLUGIN_NAME}.some_reader" 88 | 89 | reader = next(iter(plugin_manager.iter_compatible_readers([tmp_path, tmp_path]))) 90 | assert reader.command == f"{SAMPLE_PLUGIN_NAME}.some_reader" 91 | 92 | with pytest.raises(ValueError): 93 | list(plugin_manager.iter_compatible_readers(["a.tif", "b.jpg"])) 94 | 95 | 96 | def test_widgets(uses_sample_plugin, plugin_manager: PluginManager): 97 | widgets = list(plugin_manager.iter_widgets()) 98 | assert len(widgets) == 2 99 | assert widgets[0].command == f"{SAMPLE_PLUGIN_NAME}.some_widget" 100 | w = widgets[0].exec() 101 | assert type(w).__name__ == "SomeWidget" 102 | 103 | assert widgets[1].command == f"{SAMPLE_PLUGIN_NAME}.some_function_widget" 104 | w = widgets[1].get_callable() 105 | assert isinstance(w, partial) 106 | 107 | 108 | def test_sample(uses_sample_plugin, plugin_manager: PluginManager): 109 | plugin, contribs = next(iter(plugin_manager.iter_sample_data())) 110 | assert plugin == SAMPLE_PLUGIN_NAME 111 | assert len(contribs) == 2 112 | ctrbA, ctrbB = contribs 113 | # ignoring types because .command and .uri come from different sample provider 114 | # types... they don't both have "command" or "uri" 115 | 116 | assert isinstance(ctrbA, SampleDataGenerator) 117 | assert ctrbA.command == f"{SAMPLE_PLUGIN_NAME}.generate_random_data" 118 | assert ctrbA.plugin_name == SAMPLE_PLUGIN_NAME 119 | assert isinstance(ctrbB, SampleDataURI) 120 | assert ctrbB.uri == "https://picsum.photos/1024" 121 | assert isinstance(ctrbA.open(), list) 122 | assert isinstance(ctrbB.open(), list) 123 | 124 | 125 | def test_directory_reader(uses_sample_plugin, plugin_manager: PluginManager, tmp_path): 126 | reader = next(iter(plugin_manager.iter_compatible_readers(str(tmp_path)))) 127 | assert reader.command == f"{SAMPLE_PLUGIN_NAME}.some_reader" 128 | 129 | 130 | def test_themes(uses_sample_plugin, plugin_manager: PluginManager): 131 | theme = next(iter(plugin_manager.iter_themes())) 132 | assert theme.label == "SampleTheme" 133 | 134 | 135 | def test_command_exec(): 136 | """Test CommandContribution.exec()""" 137 | pm = PluginManager.instance() 138 | try: 139 | cmd_id = "pkg.some_id" 140 | cmd = CommandContribution(id=cmd_id, title="a title") 141 | mf = PluginManifest(name="pkg", contributions={"commands": [cmd]}) 142 | pm.register(mf) 143 | some_func = Mock() 144 | pm._command_registry.register(cmd_id, some_func) 145 | cmd.exec(args=("hi!",)) 146 | some_func.assert_called_once_with("hi!") 147 | finally: 148 | pm.__instance = None 149 | 150 | 151 | def test_menus(uses_sample_plugin, plugin_manager: PluginManager): 152 | menus = plugin_manager.menus() 153 | assert len(menus) == 2 154 | assert set(menus) == {"/napari/layer_context", "mysubmenu"} 155 | items = list(plugin_manager.iter_menu("/napari/layer_context")) 156 | assert len(items) == 2 157 | -------------------------------------------------------------------------------- /src/npe2/manifest/contributions/_configuration.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal 2 | 3 | from npe2._pydantic_compat import BaseModel, Field, conlist, root_validator, validator 4 | 5 | from ._json_schema import ( 6 | Draft07JsonSchema, 7 | JsonType, 8 | JsonTypeArray, 9 | ValidationError, 10 | _coerce_type_name, 11 | ) 12 | 13 | 14 | class ConfigurationProperty(Draft07JsonSchema): 15 | """Configuration for a single property in the plugin settings. 16 | 17 | This is a super-set of the JSON Schema (draft 7) specification. 18 | https://json-schema.org/understanding-json-schema/reference/index.html 19 | """ 20 | 21 | type: JsonType | JsonTypeArray = Field( 22 | None, 23 | description="The type of this variable. Either JSON Schema type names ('array'," 24 | " 'boolean', 'object', ...) or python type names ('list', 'bool', 'dict', ...) " 25 | "may be used, but they will be coerced to JSON Schema types. Numbers, strings, " 26 | "and booleans will be editable in the UI, other types (lists, dicts) *may* be " 27 | "editable in the UI depending on their content, but maby will only be editable " 28 | "as text in the napari settings file. For boolean entries, the description " 29 | "(or markdownDescription) will be used as the label for the checkbox.", 30 | ) 31 | _coerce_type_name = validator("type", pre=True, allow_reuse=True)(_coerce_type_name) 32 | 33 | default: Any = Field(None, description="The default value for this property.") 34 | 35 | description: str | None = Field( 36 | None, 37 | description="Your `description` appears after the title and before the input " 38 | "field, except for booleans, where the description is used as the label for " 39 | "the checkbox. See also `markdown_description`.", 40 | ) 41 | description_format: Literal["markdown", "plain"] = Field( 42 | "markdown", 43 | description="By default (`markdown`) your `description`, will be parsed " 44 | "as CommonMark (with `markdown_it`) and rendered as rich text. To render as " 45 | "plain text, set this value to `plain`.", 46 | ) 47 | 48 | enum: conlist(Any, min_items=1, unique_items=True) | None = Field( # type: ignore 49 | None, 50 | description="A list of valid options for this field. If you provide this field," 51 | "the settings UI will render a dropdown menu.", 52 | ) 53 | enum_descriptions: list[str] = Field( 54 | default_factory=list, 55 | description="If you provide a list of items under the `enum` field, you may " 56 | "provide `enum_descriptions` to add descriptive text for each enum.", 57 | ) 58 | enum_descriptions_format: Literal["markdown", "plain"] = Field( 59 | "markdown", 60 | description="By default (`markdown`) your `enum_description`s, will be parsed " 61 | "as CommonMark (with `markdown_it`) and rendered as rich text. To render as " 62 | "plain text, set this value to `plain`.", 63 | ) 64 | 65 | deprecation_message: str | None = Field( 66 | None, 67 | description="If you set deprecationMessage, the setting will get a warning " 68 | "underline with your specified message. It won't show up in the settings " 69 | "UI unless it is configured by the user.", 70 | ) 71 | deprecation_message_format: Literal["markdown", "plain"] = Field( 72 | "markdown", 73 | description="By default (`markdown`) your `deprecation_message`, will be " 74 | "parsed as CommonMark (with `markdown_it`) and rendered as rich text. To " 75 | "render as plain text, set this value to `plain`.", 76 | ) 77 | 78 | edit_presentation: Literal["singleline", "multiline"] = Field( 79 | "singleline", 80 | description="By default, string settings will be rendered with a single-line " 81 | "editor. To render with a multi-line editor, set this value to `multiline`.", 82 | ) 83 | order: int | None = Field( 84 | None, 85 | description="When specified, gives the order of this setting relative to other " 86 | "settings within the same category. Settings with an order property will be " 87 | "placed before settings without this property set; and settings without `order`" 88 | " will be placed in alphabetical order.", 89 | ) 90 | 91 | pattern_error_message: str | None = Field( 92 | None, 93 | description="When restricting string types to a given regular expression with " 94 | "the `pattern` field, this field may be used to provide a custom error when " 95 | "the pattern does not match.", 96 | ) 97 | 98 | @root_validator(pre=True) 99 | def _validate_root(cls, values): 100 | values = super()._validate_root(values) 101 | 102 | # we don't allow $ref and/or $defs in the schema 103 | for ignored in {"$ref", "ref", "definition", "$def"}: 104 | if ignored in values: 105 | import warnings 106 | 107 | del values[ignored] 108 | warnings.warn( 109 | f"ignoring {ignored} in configuration property. " 110 | "Configuration schemas must be self-contained.", 111 | stacklevel=2, 112 | ) 113 | return values 114 | 115 | def validate_instance(self, instance: object) -> dict: 116 | """Validate an object (instance) against this schema.""" 117 | try: 118 | return super().validate_instance(instance) 119 | except ValidationError as e: 120 | if e.validator == "pattern" and self.pattern_error_message: 121 | e.message = self.pattern_error_message 122 | raise e 123 | 124 | 125 | class ConfigurationContribution(BaseModel): 126 | """A configuration contribution for a plugin. 127 | 128 | This enables plugins to provide a schema for their configurables. 129 | Configuration contributions are used to generate the settings UI. 130 | """ 131 | 132 | title: str = Field( 133 | ..., 134 | description="The heading used for this configuration category. Words like " 135 | '"Plugin", "Configuration", and "Settings" are redundant and should not be' 136 | "used in your title.", 137 | ) 138 | properties: dict[str, ConfigurationProperty] = Field( 139 | ..., 140 | description="Configuration properties. In the settings UI, your configuration " 141 | "key will be used to namespace and construct a title. Though a plugin can " 142 | "contain multiple categories of settings, each plugin setting must still have " 143 | "its own unique key. Capital letters in your key are used to indicate word " 144 | "breaks. For example, if your key is 'gitMagic.blame.dateFormat', the " 145 | "generated title for the setting will look like 'Blame: Date Format'", 146 | ) 147 | # order: int # vscode uses this to sort multiple configurations 148 | # ... I think we can just use the order in which they are declared 149 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - "v*" 9 | pull_request: {} 10 | workflow_dispatch: 11 | 12 | concurrency: 13 | group: test-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | check-manifest: 18 | name: Check Manifest 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 22 | - name: Check 23 | run: pipx run check-manifest 24 | 25 | test: 26 | name: ${{ matrix.platform }} (${{ matrix.python-version }}) ${{ matrix.pydantic }} 27 | runs-on: ${{ matrix.platform }} 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | python-version: ["3.10", "3.11", "3.12", "3.13"] 32 | platform: [ubuntu-latest, macos-latest, windows-latest] 33 | pydantic: ["pydantic<2", "pydantic>2"] 34 | 35 | steps: 36 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 37 | 38 | - name: Set up Python ${{ matrix.python-version }} 39 | uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 40 | with: 41 | python-version: ${{ matrix.python-version }} 42 | 43 | - name: Install dependencies 44 | run: | 45 | python -m pip install --upgrade pip 46 | pip install "${{ matrix.pydantic }}" 47 | pip install -e .[json,docs,testing] 48 | 49 | - name: Test Main docs build 50 | run: pytest --color yes -m github_main_only 51 | - name: Test 52 | run: | 53 | coverage run --source=npe2 -m pytest --color yes 54 | 55 | - name: Upload coverage as artifact 56 | uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 57 | with: 58 | name: coverage reports ${{ matrix.platform }} py ${{ matrix.python-version }} ${{ (matrix.pydantic == 'pydantic<2' && 'pydantic_lt_2') || 'pydantic_gt_2' }} 59 | path: | 60 | ./.coverage.* 61 | include-hidden-files: true 62 | 63 | test_napari: 64 | name: napari tests 65 | runs-on: ubuntu-latest 66 | steps: 67 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 68 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 69 | with: 70 | repository: napari/napari 71 | path: napari-from-github 72 | fetch-depth: 0 73 | 74 | - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 75 | with: 76 | python-version: "3.10" 77 | - name: Install 78 | run: | 79 | python -m pip install --upgrade pip 80 | python -m pip install -e ./napari-from-github -c "./napari-from-github/resources/constraints/constraints_py3.10.txt" 81 | python -m pip install -e .[json] 82 | # bare minimum required to test napari/plugins 83 | python -m pip install pytest pytest-pretty scikit-image[data] zarr xarray hypothesis matplotlib 84 | 85 | - name: Run napari plugin headless tests 86 | run: pytest -W 'ignore::DeprecationWarning' src/napari/plugins src/napari/settings src/napari/layers src/napari/components 87 | working-directory: napari-from-github 88 | 89 | test_docs_render: 90 | name: docs render 91 | runs-on: ubuntu-latest 92 | steps: 93 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 94 | - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 95 | with: 96 | python-version: "3.13" 97 | - name: Build schema 98 | run: | 99 | python -m pip install --upgrade pip 100 | pip install -e .[docs] check-jsonschema 101 | python -m npe2.manifest.schema > _schema.json 102 | check-jsonschema --check-metaschema _schema.json 103 | - name: Test rendering docs 104 | run: python _docs/render.py 105 | env: 106 | NPE2_SCHEMA: "_schema.json" 107 | - name: Build jupyter book 108 | # generate toc, then build 109 | run: | 110 | cd docs 111 | jupyter-book toc from-project . -f jb-book > _toc.yml 112 | jupyter-book build . 113 | # Upload the book's HTML as an artifact 114 | - name: Upload artifact 115 | uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 116 | with: 117 | path: "docs/_build/html" 118 | 119 | upload_coverage: 120 | needs: test 121 | name: Upload coverage 122 | if: always() 123 | runs-on: ubuntu-latest 124 | steps: 125 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 126 | 127 | - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 128 | with: 129 | python-version: "3.13" 130 | cache-dependency-path: setup.cfg 131 | cache: 'pip' 132 | 133 | - name: Install Dependencies 134 | run: | 135 | pip install --upgrade pip 136 | pip install codecov 137 | 138 | - name: Download coverage data 139 | uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 140 | with: 141 | pattern: coverage reports* 142 | path: coverage 143 | merge-multiple: true 144 | 145 | - name: combine coverage data 146 | run: | 147 | python -Im coverage combine coverage 148 | python -Im coverage xml -o coverage.xml 149 | 150 | # Report and write to summary. 151 | python -Im coverage report --format=markdown --skip-empty --skip-covered >> $GITHUB_STEP_SUMMARY 152 | 153 | - name: Upload coverage data 154 | uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 155 | with: 156 | fail_ci_if_error: true 157 | token: ${{ secrets.CODECOV_TOKEN }} 158 | 159 | deploy: 160 | name: Deploy 161 | needs: test 162 | if: "success() && startsWith(github.ref, 'refs/tags/')" 163 | runs-on: ubuntu-latest 164 | permissions: 165 | contents: write 166 | id-token: write 167 | steps: 168 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 169 | 170 | - name: Set up Python 171 | uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 172 | with: 173 | python-version: "3.x" 174 | 175 | - name: install 176 | run: | 177 | git tag 178 | pip install -U pip 179 | pip install -U twine build 180 | python -m build . 181 | twine check dist/* 182 | ls -lh dist 183 | 184 | - name: write schema 185 | run: | 186 | pip install -e . check-jsonschema 187 | python -m npe2.manifest.schema > schema.json 188 | check-jsonschema --check-metaschema schema.json 189 | 190 | - name: Build and publish 191 | run: twine upload dist/* 192 | env: 193 | TWINE_USERNAME: __token__ 194 | TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }} 195 | 196 | - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 197 | if: startsWith(github.ref, 'refs/tags/') 198 | with: 199 | generate_release_notes: true 200 | files: schema.json 201 | -------------------------------------------------------------------------------- /tests/test_npe1_adapter.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from importlib import metadata 3 | from pathlib import Path 4 | from unittest.mock import patch 5 | 6 | import numpy as np 7 | import pytest 8 | 9 | from npe2 import PluginManager 10 | from npe2.manifest import _npe1_adapter, utils 11 | from npe2.manifest.contributions import SampleDataGenerator 12 | from npe2.manifest.utils import SHIM_NAME_PREFIX 13 | 14 | 15 | def test_adapter_no_npe1(): 16 | pm = PluginManager() 17 | pm.discover() 18 | assert not pm._npe1_adapters 19 | 20 | 21 | def test_npe1_adapter(uses_npe1_plugin, mock_cache: Path): 22 | """Test that the plugin manager detects npe1 plugins, and can index contribs""" 23 | pm = PluginManager() 24 | pm.discover(include_npe1=True) 25 | 26 | # we've found an adapter 27 | assert len(pm._npe1_adapters) == 1 28 | mf = pm.get_manifest("npe1-plugin") 29 | assert isinstance(mf, _npe1_adapter.NPE1Adapter) 30 | assert mf.package_metadata 31 | assert mf.package_metadata.version == "0.1.0" 32 | assert mf.package_metadata.name == "npe1-plugin" 33 | 34 | # it's currently unindexed and unstored 35 | assert not mf._cache_path().exists() 36 | assert not list(mock_cache.iterdir()) 37 | 38 | with patch.object( 39 | _npe1_adapter, 40 | "manifest_from_npe1", 41 | wraps=_npe1_adapter.manifest_from_npe1, # type: ignore 42 | ) as mock: 43 | pm.index_npe1_adapters() 44 | # the adapter has been cleared by the indexing 45 | assert len(pm._npe1_adapters) == 0 46 | # manifest_from_npe1 was called 47 | mock.assert_called_once_with(mf._dist, adapter=True) 48 | assert mf._cache_path().exists() 49 | # NOTE: accessing the `.contributions` object would have also triggered 50 | # importing, like pm.index_npe1_adapters() above, but it would not have 51 | # injected the contributions into the pm._contrib object. 52 | assert mf.contributions.sample_data 53 | 54 | mock.reset_mock() 55 | # clear and rediscover... this time we expect the cache to kick in 56 | pm.discover(clear=True, include_npe1=True) 57 | assert len(pm._npe1_adapters) == 1 58 | pm.index_npe1_adapters() 59 | assert len(pm._npe1_adapters) == 0 60 | mock.assert_not_called() 61 | 62 | 63 | def test_npe1_adapter_cache(uses_npe1_plugin, mock_cache: Path): 64 | """Test that we can clear cache, etc..""" 65 | pm = PluginManager() 66 | pm.discover(include_npe1=True) 67 | 68 | with patch.object( 69 | _npe1_adapter, 70 | "manifest_from_npe1", 71 | wraps=_npe1_adapter.manifest_from_npe1, # type: ignore 72 | ) as mock: 73 | # if we clear the cache, it should import again 74 | mf = pm.get_manifest("npe1-plugin") 75 | assert isinstance(mf, _npe1_adapter.NPE1Adapter) 76 | pm.index_npe1_adapters() 77 | mock.assert_called_once_with(mf._dist, adapter=True) 78 | assert mf._cache_path().exists() 79 | 80 | _npe1_adapter.clear_cache() 81 | assert not mf._cache_path().exists() 82 | 83 | mock.reset_mock() 84 | pm.discover(clear=True, include_npe1=True) 85 | pm.index_npe1_adapters() 86 | mf = pm.get_manifest("npe1-plugin") 87 | assert isinstance(mf, _npe1_adapter.NPE1Adapter) 88 | mock.assert_called_once_with(mf._dist, adapter=True) 89 | assert mf._cache_path().exists() 90 | _npe1_adapter.clear_cache(names=["not-our-plugin"]) 91 | assert mf._cache_path().exists() 92 | _npe1_adapter.clear_cache(names=["npe1-plugin"]) 93 | assert not mf._cache_path().exists() 94 | 95 | 96 | def _get_mf() -> _npe1_adapter.NPE1Adapter: 97 | pm = PluginManager.instance() 98 | pm.discover(include_npe1=True) 99 | pm.index_npe1_adapters() 100 | mf = pm.get_manifest("npe1-plugin") 101 | assert isinstance(mf, _npe1_adapter.NPE1Adapter) 102 | return mf 103 | 104 | 105 | def test_adapter_pyname_sample_data(uses_npe1_plugin, mock_cache): 106 | """Test that objects defined locally in npe1 hookspecs can be retrieved.""" 107 | mf = _get_mf() 108 | samples = mf.contributions.sample_data 109 | assert samples 110 | sample_generator = next(s for s in samples if s.key == "local_data") 111 | assert isinstance(sample_generator, SampleDataGenerator) 112 | 113 | ONES = np.ones((4, 4)) 114 | with patch.object(utils, "_import_npe1_shim", wraps=utils._import_npe1_shim) as m: 115 | func = sample_generator.get_callable() 116 | assert isinstance(func, partial) # this is how it was defined in npe1-plugin 117 | pyname = f"{SHIM_NAME_PREFIX}npe1_module:napari_provide_sample_data_1" 118 | m.assert_called_once_with(pyname) 119 | assert np.array_equal(func(), ONES) 120 | 121 | # test nested sample data too 122 | sample_generator = next(s for s in samples if s.display_name == "Some local ones") 123 | func = sample_generator.get_callable() 124 | assert np.array_equal(func(), ONES) 125 | 126 | 127 | def test_adapter_pyname_dock_widget(uses_npe1_plugin, mock_cache): 128 | """Test that objects defined locally in npe1 hookspecs can be retrieved.""" 129 | mf = _get_mf() 130 | widgets = mf.contributions.widgets 131 | assert widgets 132 | wdg_contrib = next(w for w in widgets if w.display_name == "Local Widget") 133 | 134 | with patch.object(utils, "_import_npe1_shim", wraps=utils._import_npe1_shim) as m: 135 | caller = wdg_contrib.get_callable() 136 | assert isinstance(caller, partial) 137 | assert ".local_widget" in caller.keywords["function"].__qualname__ 138 | pyname = ( 139 | f"{SHIM_NAME_PREFIX}npe1_module:napari_experimental_provide_dock_widget_2" 140 | ) 141 | m.assert_called_once_with(pyname) 142 | 143 | m.reset_mock() 144 | wdg_contrib2 = next( 145 | w for w in widgets if w.display_name == "local function" and w.autogenerate 146 | ) 147 | caller2 = wdg_contrib2.get_callable() 148 | assert isinstance(caller2, partial) 149 | assert ".local_function" in caller2.keywords["function"].__qualname__ 150 | pyname = f"{SHIM_NAME_PREFIX}npe1_module:napari_experimental_provide_function_1" 151 | m.assert_called_once_with(pyname) 152 | 153 | 154 | def test_adapter_error_on_import(): 155 | class FakeDist(metadata.Distribution): 156 | def read_text(self, filename): 157 | if filename == "METADATA": 158 | return "Name: fake-plugin\nVersion: 0.1.0\n" 159 | 160 | def locate_file(self, *_): ... 161 | 162 | adapter = _npe1_adapter.NPE1Adapter(FakeDist()) 163 | 164 | def err(): 165 | raise ImportError("No package found.") 166 | 167 | with pytest.warns(UserWarning) as record: 168 | with patch.object(_npe1_adapter, "manifest_from_npe1", wraps=err): 169 | _ = adapter.contributions 170 | assert "Error importing contributions for" in str(record[0]) 171 | 172 | 173 | def test_adapter_cache_fail(uses_npe1_plugin, mock_cache): 174 | pm = PluginManager() 175 | pm.discover(include_npe1=True) 176 | mf = pm.get_manifest("npe1-plugin") 177 | 178 | def err(obj): 179 | raise OSError("Can't cache") 180 | 181 | with patch.object(_npe1_adapter.NPE1Adapter, "_save_to_cache", err): 182 | # shouldn't reraise the error 183 | _ = mf.contributions 184 | -------------------------------------------------------------------------------- /src/npe2/_setuptools_plugin.py: -------------------------------------------------------------------------------- 1 | """ 2 | # pyproject.toml 3 | [build-system] 4 | requires = ["setuptools", "wheel", "setuptools_scm", "npe2"] 5 | build-backend = "setuptools.build_meta" 6 | 7 | [tool.npe2] 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | import os 13 | import re 14 | import sys 15 | import warnings 16 | from typing import TYPE_CHECKING, cast 17 | 18 | from setuptools import Distribution 19 | from setuptools.command.build_py import build_py 20 | 21 | if TYPE_CHECKING: 22 | from distutils.cmd import Command 23 | from typing import Any 24 | 25 | PathT = "os.PathLike[str]" | str 26 | 27 | NPE2_ENTRY = "napari.manifest" 28 | DEBUG = bool(os.environ.get("SETUPTOOLS_NPE2_DEBUG")) 29 | EP_PATTERN = re.compile( 30 | r"(?P[\w.]+)\s*(:\s*(?P[\w.]+)\s*)?((?P\[.*\])\s*)?$" 31 | ) 32 | 33 | 34 | def trace(*k: object) -> None: 35 | if DEBUG: 36 | print(*k, file=sys.stderr, flush=True) 37 | 38 | 39 | def _lazy_tomli_load(data: str) -> dict[str, Any]: 40 | try: 41 | import tomllib 42 | except ImportError: 43 | import tomli as tomllib # type: ignore [no-redef] 44 | 45 | return tomllib.loads(data) 46 | 47 | 48 | def _read_dist_name_from_setup_cfg() -> str | None: 49 | # minimal effort to read dist_name off setup.cfg metadata 50 | import configparser 51 | 52 | parser = configparser.ConfigParser() 53 | parser.read(["setup.cfg"]) 54 | return parser.get("metadata", "name", fallback=None) 55 | 56 | 57 | def _check_absolute_root(root: PathT, relative_to: PathT | None) -> str: # type: ignore 58 | trace("abs root", repr(locals())) 59 | if relative_to: 60 | if ( 61 | os.path.isabs(root) 62 | and os.path.commonpath([root, relative_to]) != relative_to 63 | ): 64 | warnings.warn( 65 | f"absolute root path '{root}' overrides relative_to '{relative_to}'", 66 | stacklevel=2, 67 | ) 68 | if os.path.isdir(relative_to): 69 | warnings.warn( 70 | "relative_to is expected to be a file," 71 | " its the directory {relative_to!r}\n" 72 | "assuming the parent directory was passed", 73 | stacklevel=2, 74 | ) 75 | trace("dir", relative_to) 76 | root = os.path.join(relative_to, root) 77 | else: 78 | trace("file", relative_to) 79 | root = os.path.join(os.path.dirname(relative_to), root) 80 | return os.path.abspath(root) 81 | 82 | 83 | class Configuration: 84 | """Global configuration model""" 85 | 86 | def __init__( 87 | self, 88 | relative_to: PathT | None = None, # type: ignore 89 | root: PathT = ".", # type: ignore 90 | write_to: PathT | None = None, # type: ignore 91 | write_to_template: str | None = None, 92 | dist_name: str | None = None, 93 | template: str | None = None, 94 | ): 95 | self._relative_to = None if relative_to is None else os.fspath(relative_to) 96 | self._root = "." 97 | self.root = os.fspath(root) 98 | self.write_to = write_to 99 | self.write_to_template = write_to_template 100 | self.dist_name = dist_name 101 | self.template = template 102 | 103 | @property 104 | def relative_to(self) -> str | None: 105 | return self._relative_to 106 | 107 | @property 108 | def root(self) -> str: 109 | return self._root 110 | 111 | @root.setter 112 | def root(self, value: PathT) -> None: # type: ignore 113 | self._absolute_root = _check_absolute_root(value, self._relative_to) 114 | self._root = os.fspath(value) 115 | trace("root", repr(self._absolute_root)) 116 | trace("relative_to", repr(self._relative_to)) 117 | 118 | @property 119 | def absolute_root(self) -> str: 120 | return self._absolute_root 121 | 122 | @classmethod 123 | def from_file( 124 | cls, name: str = "pyproject.toml", dist_name: str | None = None, **kwargs: Any 125 | ) -> Configuration: 126 | """ 127 | Read Configuration from pyproject.toml (or similar). 128 | Raises exceptions when file is not found or toml is 129 | not installed or the file has invalid format or does 130 | not contain the [tool.npe2] section. 131 | """ 132 | 133 | with open(name, encoding="UTF-8") as strm: 134 | data = strm.read() 135 | defn = _lazy_tomli_load(data) 136 | try: 137 | section = defn.get("tool", {})["npe2"] 138 | except LookupError as e: 139 | raise LookupError(f"{name} does not contain a tool.npe2 section") from e 140 | if "dist_name" in section: 141 | if dist_name is None: 142 | dist_name = section.pop("dist_name") 143 | else: 144 | assert dist_name == section["dist_name"] 145 | del section["dist_name"] 146 | if dist_name is None and "project" in defn: 147 | # minimal pep 621 support for figuring the pretend keys 148 | dist_name = defn["project"].get("name") 149 | if dist_name is None: 150 | dist_name = _read_dist_name_from_setup_cfg() 151 | 152 | return cls(dist_name=dist_name, **section, **kwargs) 153 | 154 | 155 | def _mf_entry_from_dist(dist: Distribution) -> tuple[str, str] | None: 156 | """Return (module, attr) for a distribution's npe2 entry point.""" 157 | eps: dict = getattr(dist, "entry_points", {}) 158 | if napari_entrys := eps.get(NPE2_ENTRY, []): 159 | if match := EP_PATTERN.search(napari_entrys[0]): 160 | return match.group("module"), match.group("attr") 161 | return None 162 | 163 | 164 | class npe2_compile(build_py): 165 | def run(self) -> None: 166 | trace("RUN npe2_compile") 167 | if ep := _mf_entry_from_dist(self.distribution): 168 | from npe2._inspection._compile import compile 169 | 170 | module, attr = ep 171 | src = self.distribution.src_root or os.getcwd() 172 | dest = os.path.join(self.get_package_dir(module), attr) 173 | compile(src, dest, template=self.distribution.config.template) 174 | else: 175 | name = self.distribution.metadata.name 176 | trace(f"no {NPE2_ENTRY!r} found in entry_points for {name}") 177 | 178 | 179 | def finalize_npe2(dist: Distribution): 180 | # this hook is declared in the setuptools.finalize_distribution_options 181 | # entry point in our setup.cfg 182 | # https://setuptools.pypa.io/en/latest/userguide/extension.html#customizing-distribution-options 183 | trace("finalize hook", vars(dist.metadata)) 184 | dist_name = dist.metadata.name 185 | if dist_name is None: 186 | dist_name = _read_dist_name_from_setup_cfg() 187 | if not os.path.isfile("pyproject.toml"): 188 | return 189 | if dist_name == "npe2": 190 | # if we're packaging npe2 itself, don't do anything 191 | return 192 | try: 193 | # config will *only* be detected if there is a [tool.npe2] 194 | # section in pyproject.toml. This is how plugins opt in 195 | # to the npe2 compile feature during build 196 | config = Configuration.from_file(dist_name=dist_name) 197 | except LookupError as e: 198 | trace(e) 199 | else: 200 | # inject our `npe2_compile` command to be called whenever we're building an 201 | # sdist or a wheel 202 | dist.config = config 203 | for cmd in ("build", "sdist"): 204 | if base := dist.get_command_class(cmd): 205 | cast("Command", base).sub_commands.insert(0, ("npe2_compile", None)) 206 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import sys 3 | from importlib import abc, metadata 4 | from pathlib import Path 5 | from unittest.mock import patch 6 | 7 | import pytest 8 | 9 | from npe2 import PluginManager, PluginManifest 10 | from npe2.manifest import _npe1_adapter 11 | 12 | FIXTURES = Path(__file__).parent / "fixtures" 13 | 14 | 15 | @pytest.fixture 16 | def sample_path(): 17 | return Path(__file__).parent / "sample" 18 | 19 | 20 | @pytest.fixture 21 | def sample_manifest(sample_path): 22 | return PluginManifest.from_file(sample_path / "my_plugin" / "napari.yaml") 23 | 24 | 25 | @pytest.fixture 26 | def compiled_plugin_dir(tmp_path): 27 | shutil.copytree(FIXTURES / "my-compiled-plugin", tmp_path, dirs_exist_ok=True) 28 | return tmp_path 29 | 30 | 31 | @pytest.fixture 32 | def uses_sample_plugin(sample_path): 33 | sys.path.append(str(sample_path)) 34 | try: 35 | pm = PluginManager.instance() 36 | pm.discover() 37 | yield 38 | finally: 39 | sys.path.remove(str(sample_path)) 40 | 41 | 42 | @pytest.fixture 43 | def plugin_manager(): 44 | pm = PluginManager() 45 | pm.discover() 46 | return pm 47 | 48 | 49 | @pytest.fixture(autouse=True) 50 | def mock_discover(): 51 | _discover = PluginManifest.discover 52 | 53 | def wrapper(*args, **kwargs): 54 | before = sys.path.copy() 55 | # only allowing things from test directory in discover 56 | sys.path = [x for x in sys.path if str(Path(__file__).parent) in x] 57 | try: 58 | yield from _discover(*args, **kwargs) 59 | finally: 60 | sys.path = before 61 | 62 | with patch("npe2.PluginManifest.discover", wraps=wrapper): 63 | yield 1 64 | 65 | 66 | @pytest.fixture 67 | def npe1_repo(): 68 | return Path(__file__).parent / "npe1-plugin" 69 | 70 | 71 | @pytest.fixture 72 | def uses_npe1_plugin(npe1_repo): 73 | import site 74 | 75 | class Importer(abc.MetaPathFinder): 76 | def find_spec(self, *_, **__): 77 | return None 78 | 79 | def find_distributions(self, ctx, **k): 80 | if ctx.name == "npe1-plugin": 81 | pth = npe1_repo / "npe1-plugin-0.0.1.dist-info" 82 | yield metadata.PathDistribution(pth) 83 | return 84 | 85 | sys.meta_path.append(Importer()) 86 | sys.path.append(str(npe1_repo)) 87 | try: 88 | pkgs = [*site.getsitepackages(), str(npe1_repo)] 89 | with patch("site.getsitepackages", return_value=pkgs): 90 | yield 91 | finally: 92 | sys.path.remove(str(npe1_repo)) 93 | 94 | 95 | @pytest.fixture 96 | def npe1_plugin_module(npe1_repo): 97 | import sys 98 | from importlib.util import module_from_spec, spec_from_file_location 99 | 100 | npe1_module_path = npe1_repo / "npe1_module" / "__init__.py" 101 | spec = spec_from_file_location("npe1_module", npe1_module_path) 102 | assert spec 103 | module = module_from_spec(spec) 104 | sys.modules[spec.name] = module 105 | spec.loader.exec_module(module) # type: ignore 106 | try: 107 | yield module 108 | finally: 109 | del sys.modules[spec.name] 110 | 111 | 112 | @pytest.fixture 113 | def mock_npe1_pm(): 114 | from napari_plugin_engine import PluginManager, napari_hook_specification 115 | 116 | # fmt: off 117 | class HookSpecs: 118 | def napari_provide_sample_data(): ... # type: ignore 119 | def napari_get_reader(path): ... 120 | def napari_get_writer(path, layer_types): ... 121 | def napari_write_graph(path, data, meta): ... 122 | def napari_write_image(path, data, meta): ... 123 | def napari_write_labels(path, data, meta): ... 124 | def napari_write_points(path, data, meta): ... 125 | def napari_write_shapes(path, data, meta): ... 126 | def napari_write_surface(path, data, meta): ... 127 | def napari_write_vectors(path, data, meta): ... 128 | def napari_experimental_provide_function(): ... # type: ignore 129 | def napari_experimental_provide_dock_widget(): ... # type: ignore 130 | def napari_experimental_provide_theme(): ... # type: ignore 131 | # fmt: on 132 | 133 | for m in dir(HookSpecs): 134 | if m.startswith("napari"): 135 | setattr(HookSpecs, m, napari_hook_specification(getattr(HookSpecs, m))) 136 | 137 | pm = PluginManager("napari") 138 | pm.add_hookspecs(HookSpecs) 139 | 140 | yield pm 141 | 142 | 143 | @pytest.fixture 144 | def mock_npe1_pm_with_plugin(npe1_repo, npe1_plugin_module): 145 | """Mocks a fully installed local repository""" 146 | from npe2._inspection._from_npe1 import plugin_packages 147 | 148 | mock_dist = metadata.PathDistribution(npe1_repo / "npe1-plugin-0.0.1.dist-info") 149 | 150 | def _dists(): 151 | return [mock_dist] 152 | 153 | def _from_name(name): 154 | if name == "npe1-plugin": 155 | return mock_dist 156 | raise metadata.PackageNotFoundError(name) 157 | 158 | setup_cfg = npe1_repo / "setup.cfg" 159 | new_manifest = npe1_repo / "npe1_module" / "napari.yaml" 160 | with patch.object(metadata, "distributions", new=_dists): 161 | with patch.object(metadata.Distribution, "from_name", new=_from_name): 162 | cfg = setup_cfg.read_text() 163 | plugin_packages.cache_clear() 164 | try: 165 | yield mock_npe1_pm 166 | finally: 167 | plugin_packages.cache_clear() 168 | setup_cfg.write_text(cfg) 169 | if new_manifest.exists(): 170 | new_manifest.unlink() 171 | if (npe1_repo / "setup.py").exists(): 172 | (npe1_repo / "setup.py").unlink() 173 | 174 | 175 | @pytest.fixture 176 | def mock_npe1_pm_with_plugin_editable(npe1_repo, npe1_plugin_module, tmp_path): 177 | """Mocks a fully installed local repository""" 178 | from npe2._inspection._from_npe1 import plugin_packages 179 | 180 | dist_path = tmp_path / "npe1-plugin-0.0.1.dist-info" 181 | shutil.copytree(npe1_repo / "npe1-plugin-0.0.1.dist-info", dist_path) 182 | 183 | record_path = dist_path / "RECORD" 184 | 185 | record_content = record_path.read_text().splitlines() 186 | record_content.pop(-1) 187 | record_content.append("__editable__.npe1-plugin-0.0.1.pth") 188 | 189 | with record_path.open("w") as f: 190 | f.write("\n".join(record_content)) 191 | 192 | with open(tmp_path / "__editable__.npe1-plugin-0.0.1.pth", "w") as f: 193 | f.write(str(npe1_repo)) 194 | 195 | mock_dist = metadata.PathDistribution(dist_path) 196 | 197 | def _dists(): 198 | return [mock_dist] 199 | 200 | def _from_name(name): 201 | if name == "npe1-plugin": 202 | return mock_dist 203 | raise metadata.PackageNotFoundError(name) 204 | 205 | setup_cfg = npe1_repo / "setup.cfg" 206 | new_manifest = npe1_repo / "npe1_module" / "napari.yaml" 207 | with patch.object(metadata, "distributions", new=_dists): 208 | with patch.object(metadata.Distribution, "from_name", new=_from_name): 209 | cfg = setup_cfg.read_text() 210 | plugin_packages.cache_clear() 211 | try: 212 | yield mock_npe1_pm 213 | finally: 214 | plugin_packages.cache_clear() 215 | setup_cfg.write_text(cfg) 216 | if new_manifest.exists(): 217 | new_manifest.unlink() 218 | if (npe1_repo / "setup.py").exists(): 219 | (npe1_repo / "setup.py").unlink() 220 | 221 | 222 | @pytest.fixture(autouse=True) 223 | def mock_cache(tmp_path, monkeypatch): 224 | with monkeypatch.context() as m: 225 | m.setattr(_npe1_adapter, "ADAPTER_CACHE", tmp_path) 226 | yield tmp_path 227 | -------------------------------------------------------------------------------- /_docs/render.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import json 5 | import os 6 | import re 7 | import sys 8 | from contextlib import contextmanager 9 | from functools import lru_cache, partial 10 | from inspect import getsource 11 | from pathlib import Path 12 | from types import FunctionType 13 | from urllib.request import urlopen 14 | 15 | import yaml 16 | from jinja2 import Environment, FileSystemLoader, select_autoescape 17 | 18 | from npe2 import PluginManager, PluginManifest 19 | from npe2.manifest.contributions import ContributionPoints 20 | from npe2.manifest.utils import Executable 21 | 22 | SCHEMA_URL = "https://github.com/napari/npe2/releases/latest/download/schema.json" 23 | DOCS = Path(__file__).parent 24 | TEMPLATES = DOCS / "templates" 25 | _BUILD = DOCS.parent / "docs" / "plugins" 26 | EXAMPLE_MANIFEST = PluginManifest.from_file(DOCS / "example_manifest.yaml") 27 | 28 | 29 | @contextmanager 30 | def _mocked_qtwidgets(): 31 | # just mocking a "qtpy.QtWidgets" so we don't need to include PyQt just to build 32 | # documentation. 33 | from types import ModuleType 34 | 35 | mock = ModuleType("qtpy.QtWidgets") 36 | mock.__dict__["QWidget"] = object 37 | before, sys.modules["qtpy.QtWidgets"] = sys.modules.get("qtpy.QtWidgets"), mock 38 | try: 39 | yield 40 | finally: 41 | if before is not None: 42 | sys.modules["qtpy.QtWidgets"] = mock 43 | else: 44 | del sys.modules["qtpy.QtWidgets"] 45 | 46 | 47 | @lru_cache 48 | def type_strings() -> dict[str, str]: 49 | """Return map of type name to source code for all types in types.py""" 50 | from npe2 import types as _t 51 | 52 | type_strings = {} 53 | type_lines = getsource(_t).splitlines() 54 | for r, line in enumerate(type_lines): 55 | if not line or line.startswith((" ", "#", "]", ")", "if", "from")): 56 | continue 57 | end = 0 58 | if r + 1 >= len(type_lines): 59 | continue 60 | next_line = type_lines[r + 1] 61 | if next_line.startswith(" "): 62 | end = next( 63 | ( 64 | i 65 | for i, x in enumerate(type_lines[r + 1 :]) 66 | if not x.startswith((" ", "#")) 67 | ) 68 | ) 69 | if end: 70 | end += 1 71 | name = line.split()[0] 72 | if name == "class": 73 | name = line.split()[1].split("(")[0] 74 | type_strings[name] = "\n".join(type_lines[r : r + end + 1]) 75 | return type_strings 76 | 77 | 78 | def _get_needed_types(source: str, so_far: set[str] | None = None) -> set[str]: 79 | """Return the names of types in the npe2.types.py that are used in `source`""" 80 | so_far = so_far or set() 81 | for name, string in type_strings().items(): 82 | # we treat LayerData specially 83 | if ( 84 | name != "LayerData" 85 | and name not in so_far 86 | and re.search(rf"\W{name}\W", source) 87 | ): 88 | so_far.add(name) 89 | so_far.update(_get_needed_types(string, so_far=so_far)) 90 | return so_far 91 | 92 | 93 | def _build_example(contrib: Executable) -> str: 94 | """Extract just the source code for a specific executable contribution""" 95 | 96 | if not isinstance(contrib, Executable): 97 | return "" 98 | 99 | with _mocked_qtwidgets(): 100 | func = contrib.get_callable() 101 | 102 | if not callable(func): 103 | return "" 104 | if isinstance(func, partial): 105 | func = func.keywords["function"] 106 | source = inspect.getsource(func) 107 | 108 | # additionally get source code of all internally referenced functions 109 | # i.e. for get_reader we also get the source for the returned reader. 110 | if isinstance(func, FunctionType): 111 | for name in func.__code__.co_names: 112 | if name in func.__globals__: 113 | f = func.__globals__[name] 114 | source += "\n\n" + inspect.getsource(f) 115 | 116 | needed = _get_needed_types(source) 117 | lines = [v for k, v in type_strings().items() if k in needed] 118 | if lines: 119 | lines.extend(["", ""]) 120 | lines.extend(source.splitlines()) 121 | return "\n".join(lines) 122 | 123 | 124 | def example_implementation(contrib_name: str) -> str: 125 | """Build an example string of python source implementing a specific contribution.""" 126 | contrib = getattr(EXAMPLE_MANIFEST.contributions, contrib_name) 127 | if isinstance(contrib, list): 128 | return "\n\n".join([_build_example(x) for x in contrib]).strip() 129 | return _build_example(contrib) 130 | 131 | 132 | def example_contribution( 133 | contrib_name: str, format="yaml", manifest: PluginManifest = EXAMPLE_MANIFEST 134 | ) -> str: 135 | """Get small manifest example for just contribution named `contrib_name`""" 136 | assert manifest.contributions 137 | contribs = getattr(manifest.contributions, contrib_name) 138 | # only take the first command example ... the rest are for executables 139 | if contrib_name == "commands": 140 | contribs = [contribs[0]] 141 | 142 | ex = ContributionPoints(**{contrib_name: contribs}) 143 | # for "executables", include associated command 144 | ExampleCommands = manifest.contributions.commands 145 | assert ExampleCommands 146 | for c in contribs or (): 147 | if isinstance(c, Executable): 148 | associated_command = next(i for i in ExampleCommands if i.id == c.command) 149 | if not ex.commands: 150 | ex.commands = [] 151 | ex.commands.append(associated_command) 152 | output = {"contributions": json.loads(ex.json(exclude_unset=True))} 153 | if format == "yaml": 154 | return yaml.safe_dump(output, sort_keys=False) 155 | if format == "toml": 156 | import tomli_w 157 | 158 | return tomli_w.dumps(output) 159 | if format == "json": 160 | return json.dumps(output) 161 | raise ValueError("Invalid format: {format}. Must be 'yaml', 'toml' or 'json'.") 162 | 163 | 164 | def has_guide(contrib_name: str) -> bool: 165 | """Return true if a guide exists for this contribution.""" 166 | return (TEMPLATES / f"_npe2_{contrib_name}_guide.md.jinja").exists() 167 | 168 | 169 | def main(dest: Path = _BUILD): 170 | """Render all jinja docs in ./templates and output to `dest`""" 171 | 172 | # register the example plugin so we can use `.get_callable()` in _build_example 173 | sys.path.append(str(DOCS.absolute())) 174 | PluginManager.instance().register(EXAMPLE_MANIFEST) 175 | 176 | env = Environment( 177 | loader=FileSystemLoader(TEMPLATES), autoescape=select_autoescape() 178 | ) 179 | env.filters["example_contribution"] = example_contribution 180 | env.filters["example_implementation"] = example_implementation 181 | env.filters["has_guide"] = has_guide 182 | 183 | dest.mkdir(exist_ok=True, parents=True) 184 | if local_schema := os.getenv("NPE2_SCHEMA"): 185 | with open(local_schema) as f: 186 | schema = json.load(f) 187 | else: 188 | try: 189 | schema = PluginManifest.schema() 190 | except Exception: 191 | with urlopen(SCHEMA_URL) as response: 192 | schema = json.load(response) 193 | 194 | contributions = schema["definitions"]["ContributionPoints"]["properties"] 195 | context = { 196 | "schema": schema, 197 | "contributions": contributions, 198 | "example": EXAMPLE_MANIFEST, 199 | # "specs": _get_specs(), 200 | "specs": {}, 201 | } 202 | 203 | for t in TEMPLATES.glob("*.jinja"): 204 | template = env.get_template(t.name) 205 | _dest = dest / f"{t.stem}" 206 | _dest.write_text(template.render(context), encoding="utf-8") 207 | print(f"Rendered {_dest}") 208 | 209 | 210 | if __name__ == "__main__": 211 | dest = Path(sys.argv[1]).absolute() if len(sys.argv) > 1 else _BUILD 212 | main(dest) 213 | --------------------------------------------------------------------------------