diff --git a/npe2/_command_registry.py b/npe2/_command_registry.py index 7b8b5817..c66342c2 100644 --- a/npe2/_command_registry.py +++ b/npe2/_command_registry.py @@ -1,19 +1,16 @@ # flake8: noqa from __future__ import annotations -from functools import partial -from typing import Any, Callable, Dict, Optional, Union - -PDisposable = Callable[[], None] -import re from dataclasses import dataclass +from functools import partial from importlib import import_module +from typing import Any, Callable, Dict, Optional, Union from psygnal import Signal -from .manifest.commands import _dotted_name +from .manifest._validators import DOTTED_NAME_PATTERN -_dotted_name_pattern = re.compile(_dotted_name) +PDisposable = Callable[[], None] @dataclass @@ -76,7 +73,7 @@ def register(self, id: str, command: Union[Callable, str]) -> PDisposable: raise ValueError(f"Command {id} already exists") if isinstance(command, str): - if not _dotted_name_pattern.match(command): + if not DOTTED_NAME_PATTERN.match(command): raise ValueError( "String command {command!r} is not a valid qualified python path." ) diff --git a/npe2/_plugin_manager.py b/npe2/_plugin_manager.py index 57746102..10963ebd 100644 --- a/npe2/_plugin_manager.py +++ b/npe2/_plugin_manager.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import ( TYPE_CHECKING, + Any, Callable, DefaultDict, Dict, @@ -24,7 +25,7 @@ from intervaltree import IntervalTree from ._command_registry import CommandRegistry -from .manifest import PluginManifest +from .manifest import PluginManifest, _validators from .manifest.io import LayerType if TYPE_CHECKING: @@ -172,8 +173,9 @@ def activate(self, key: PluginName) -> PluginContext: return ctx try: - mf._call_func_in_plugin_entrypoint("activate", args=(ctx,)) - ctx._activated = True + if mf.on_activate: + _call_python_name(mf.on_activate, args=(ctx,)) + ctx._activated = True except Exception as e: # pragma: no cover self._contexts.pop(key, None) raise type(e)(f"Activating plugin {key!r} failed: {e}") from e @@ -190,7 +192,9 @@ def deactivate(self, key: PluginName) -> None: return mf = self._manifests[key] ctx = self._contexts.pop(key) - mf._call_func_in_plugin_entrypoint("deactivate", args=(ctx,)) + if mf.on_deactivate: + _call_python_name(mf.on_deactivate, args=(ctx,)) + ctx._activated = False ctx._dispose() def iter_compatible_readers( @@ -353,3 +357,22 @@ def _inner(command): return command return _inner if command is None else _inner(command) + + +def _call_python_name(python_name: str, args=()) -> Any: + """convenience to call `python_name` function. eg `module.submodule:funcname`.""" + from importlib import import_module + + if not python_name: + return None + + match = _validators.PYTHON_NAME_PATTERN.match(python_name) + if not match: + raise ValueError(f"Invalid python name: {python_name}") + + module_name, funcname = match.groups() + + mod = import_module(module_name) + func = getattr(mod, funcname, None) + if callable(func): + return func(*args) diff --git a/npe2/manifest/_validators.py b/npe2/manifest/_validators.py new file mode 100644 index 00000000..b064cfc4 --- /dev/null +++ b/npe2/manifest/_validators.py @@ -0,0 +1,21 @@ +import re + +# how do we deal with keywords ? +# do we try to validate ? Or do we just +# assume users won't try to create a command named +# `npe2_tester.False.if.for.in` ? +_identifier_plus_dash = "(?:[a-zA-Z_][a-zA-Z_0-9-]+)" +_dotted_name = f"(?:(?:{_identifier_plus_dash}\\.)*{_identifier_plus_dash})" +PYTHON_NAME_PATTERN = re.compile(f"^({_dotted_name}):({_dotted_name})$") +DOTTED_NAME_PATTERN = re.compile(_dotted_name) + + +def python_name(name: str) -> str: + """Assert that `name` is a valid python name: e.g. `module.submodule:funcname`""" + if name and not PYTHON_NAME_PATTERN.match(name): + raise ValueError( + f"{name} is not a valid python_name. A python_name must " + "be of the form `{obj.__module__}:{obj.__qualname__} `(e.g. " + "`my_package.a_module:some_function`). " + ) + return name diff --git a/npe2/manifest/commands.py b/npe2/manifest/commands.py index b5ecd460..d53c548d 100644 --- a/npe2/manifest/commands.py +++ b/npe2/manifest/commands.py @@ -1,22 +1,15 @@ -import re from textwrap import dedent from typing import TYPE_CHECKING, Any, Optional from pydantic import BaseModel, Extra, Field, validator +from . import _validators + if TYPE_CHECKING: from .._command_registry import CommandRegistry _distname = "([a-zA-Z_][a-zA-Z0-9_-]+)" _identifier = "([a-zA-Z_][a-zA-Z_0-9]+)" -_identifier_plus_dash = "([a-zA-Z_][a-zA-Z_0-9-]+)" - -# how do we deal with keywords ? -# do we try to validate ? Or do we just -# assume users won't try to create a command named -# `npe2_tester.False.if.for.in` ? -_dotted_name = f"(({_identifier_plus_dash}\\.)*{_identifier_plus_dash})" -_python_name_pattern = re.compile(f"^{_dotted_name}:{_dotted_name}$") class CommandContribution(BaseModel): @@ -93,16 +86,7 @@ class CommandContribution(BaseModel): "in the plugin activate function is optional (but takes precedence).", ) - @validator("python_name") - def validate_python_name(cls, v): - # test for regex validation. - if v and not _python_name_pattern.match(v): - raise ValueError( - f"{v} is not a valid python_name. A python_name must " - "be of the form `{obj.__module__}:{obj.__qualname__} `(e.g. " - "`my_package.a_module:some_function`). " - ) - return v + _valid_pyname = validator("python_name", allow_reuse=True)(_validators.python_name) class Config: extra = Extra.forbid diff --git a/npe2/manifest/schema.py b/npe2/manifest/schema.py index 7af96c49..33d06e8a 100644 --- a/npe2/manifest/schema.py +++ b/npe2/manifest/schema.py @@ -5,7 +5,7 @@ import sys from contextlib import contextmanager from enum import Enum -from importlib import import_module, util +from importlib import util from logging import getLogger from pathlib import Path from textwrap import dedent @@ -23,6 +23,7 @@ import yaml from pydantic import BaseModel, Extra, Field, ValidationError, root_validator, validator +from . import _validators from .contributions import ContributionPoints from .package_metadata import PackageMetadata from .utils import Version @@ -96,16 +97,25 @@ class PluginManifest(BaseModel): # the actual mechanism/consumption of plugin information) independently # of napari itself - # TODO: refactor entry_point to binding points for activate,deactivate - # TODO: Point to activate function - # TODO: Point to deactivate function - - # The module that has the activate() function - entry_point: Optional[str] = Field( + on_activate: Optional[str] = Field( + default=None, + description="Fully qualified python path to a function that will be called " + "upon plugin activation (e.g. my_plugin._some_module:activate). The activate " + "function can be used to connect command ids to python callables, or perform " + "other side-effects. A plugin will be 'activated' when one of its " + "contributions is requested by the user (such as a widget, or reader).", + ) + _validate_activate_func = validator("on_activate", allow_reuse=True)( + _validators.python_name + ) + on_deactivate: Optional[str] = Field( default=None, - description="The extension entry point. This should be a fully " - "qualified module string. e.g. `foo.bar.baz` for a module containing " - "the plugin's activate() function.", + description="Fully qualified python path to a function that will be called " + "when a user deactivates a plugin (e.g. my_plugin._some_module:deactivate). " + "This is optional, and may be used to perform any plugin cleanup.", + ) + _validate_deactivate_func = validator("on_deactivate", allow_reuse=True)( + _validators.python_name ) contributions: Optional[ContributionPoints] @@ -269,15 +279,6 @@ class Config: underscore_attrs_are_private = True extra = Extra.forbid - def _call_func_in_plugin_entrypoint(self, funcname: str, args=()) -> None: - """convenience to call a function in the plugins entry_point, if declared.""" - if not self.entry_point: - return None - mod = import_module(self.entry_point) - func = getattr(mod, funcname, None) - if callable(func): - return func(*args) - @classmethod def discover( cls, diff --git a/tests/sample/my_plugin/napari.yaml b/tests/sample/my_plugin/napari.yaml index d7722913..42ab6dae 100644 --- a/tests/sample/my_plugin/napari.yaml +++ b/tests/sample/my_plugin/napari.yaml @@ -1,6 +1,6 @@ name: my_plugin display_name: My Plugin -entry_point: my_plugin +on_activate: my_plugin:activate contributions: commands: - id: my_plugin.hello_world diff --git a/tests/test_npe2.py b/tests/test_npe2.py index 76e33676..2159c6fd 100644 --- a/tests/test_npe2.py +++ b/tests/test_npe2.py @@ -239,7 +239,7 @@ def test_valid_display_names(display_name, uses_sample_plugin): def test_display_name_default_is_valid(): - PluginManifest(name="", entry_point="") + PluginManifest(name="") def test_writer_empty_layers():