Skip to content

Commit

Permalink
Replace entry_point with activate/deactive function (#68)
Browse files Browse the repository at this point in the history
* add activate/deactivate_function

* Update npe2/manifest/schema.py

Co-authored-by: Nathan Clack <[email protected]>

* rename

Co-authored-by: Nathan Clack <[email protected]>
  • Loading branch information
tlambert03 and nclack authored Dec 17, 2021
1 parent c3ba912 commit 1fa80fa
Show file tree
Hide file tree
Showing 7 changed files with 78 additions and 52 deletions.
13 changes: 5 additions & 8 deletions npe2/_command_registry.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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."
)
Expand Down
31 changes: 27 additions & 4 deletions npe2/_plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
Callable,
DefaultDict,
Dict,
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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)
21 changes: 21 additions & 0 deletions npe2/manifest/_validators.py
Original file line number Diff line number Diff line change
@@ -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
22 changes: 3 additions & 19 deletions npe2/manifest/commands.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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
Expand Down
39 changes: 20 additions & 19 deletions npe2/manifest/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion tests/sample/my_plugin/napari.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_npe2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down

0 comments on commit 1fa80fa

Please sign in to comment.