Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NPE1Adapter Part 2 - adding the NPE1Adapter object. #125

Merged
merged 15 commits into from
Mar 23, 2022
Merged
2 changes: 1 addition & 1 deletion codecov.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
coverage:
status:
patch:
project:
default:
target: 100%
51 changes: 30 additions & 21 deletions npe2/_from_npe1.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from dataclasses import dataclass
from functools import lru_cache
from importlib import import_module
from logging import getLogger
from pathlib import Path
from types import ModuleType
from typing import (
Expand Down Expand Up @@ -36,6 +37,7 @@
except ImportError:
import importlib_metadata as metadata # type: ignore

logger = getLogger(__name__)
NPE1_EP = "napari.plugin"
NPE2_EP = "napari.manifest"
NPE1_IMPL_TAG = "napari_impl" # same as HookImplementation.format_tag("napari")
Expand Down Expand Up @@ -109,7 +111,7 @@ def plugin_packages() -> List[PluginPackage]:
def manifest_from_npe1(
plugin: Union[str, metadata.Distribution, None] = None,
module: Any = None,
shim=False,
adapter=False,
) -> PluginManifest:
"""Return manifest object given npe1 plugin or package name.

Expand All @@ -123,8 +125,8 @@ def manifest_from_npe1(
package, and the name of an npe1 `napari.plugin` entry_point. by default None
module : Optional[Module]
namespace object, to directly import (mostly for testing.), by default None
shim : bool
If True, the resulting manifest will be used internally by NPE1Adaptor, but
adapter : bool
If True, the resulting manifest will be used internally by NPE1Adapter, but
is NOT necessarily suitable for export as npe2 manifest. This will handle
cases of locally defined functions and partials that don't have global
python_names that are not supported natively by npe2. by default False
Expand Down Expand Up @@ -163,7 +165,12 @@ def manifest_from_npe1(

manifests: List[PluginManifest] = []
for mod_name in modules:
parser = HookImplParser(package_name, plugin_name or "", shim=shim)
logger.debug(
"Discovering contributions for npe1 plugin %r: module %r",
package_name,
mod_name,
)
parser = HookImplParser(package_name, plugin_name or "", adapter=adapter)
_mod = import_module(mod_name) if isinstance(mod_name, str) else mod_name
parser.parse_module(_mod)
manifests.append(parser.manifest())
Expand All @@ -173,31 +180,31 @@ def manifest_from_npe1(


class HookImplParser:
def __init__(self, package: str, plugin_name: str, shim: bool = False) -> None:
def __init__(self, package: str, plugin_name: str, adapter: bool = False) -> None:
"""A visitor class to convert npe1 hookimpls to a npe2 manifest

Parameters
----------
package : str
[description]
Name of package
plugin_name : str
[description]
shim : bool, optional
If True, the resulting manifest will be used internally by NPE1Adaptor, but
Name of plugin (will almost always be name of package)
adapter : bool, optional
If True, the resulting manifest will be used internally by NPE1Adapter, but
is NOT necessarily suitable for export as npe2 manifest. This will handle
cases of locally defined functions and partials that don't have global
python_names that are not supported natively by npe2. by default False

Examples
--------
>>> parser = HookImplParser(package, plugin_name, shim=shim)
>>> parser = HookImplParser(package, plugin_name)
>>> parser.parse_callers(plugin_manager._plugin2hookcallers[_module])
>>> mf = PluginManifest(name=package, contributions=dict(parser.contributions))
"""
self.package = package
self.plugin_name = plugin_name
self.contributions: DefaultDict[str, list] = DefaultDict(list)
self.shim = shim
self.adapter = adapter

def manifest(self) -> PluginManifest:
return PluginManifest(name=self.package, contributions=dict(self.contributions))
Expand Down Expand Up @@ -260,7 +267,7 @@ def napari_provide_sample_data(self, impl: HookImplementation):
# let these raise exceptions here immediately if they don't validate
id = f"{self.package}.data.{_key}"
py_name = _python_name(
_sample, impl.function, shim_idx=idx if self.shim else None
_sample, impl.function, hook_idx=idx if self.adapter else None
)
cmd_contrib = CommandContribution(
id=id,
Expand All @@ -285,7 +292,7 @@ def napari_experimental_provide_function(self, impl: HookImplementation):

cmd = f"{self.package}.{item.__name__}"
py_name = _python_name(
item, impl.function, shim_idx=idx if self.shim else None
item, impl.function, hook_idx=idx if self.adapter else None
)
docsum = item.__doc__.splitlines()[0] if item.__doc__ else None
cmd_contrib = CommandContribution(
Expand Down Expand Up @@ -351,7 +358,9 @@ def _create_widget_contrib(
# returned it... In the case that we can't get an absolute python name to the
# wdg_creator itself (e.g. it's defined in a local scope), then the py_name
# will use the hookimpl itself, and the index of the object returned.
py_name = _python_name(wdg_creator, hook, shim_idx=idx if self.shim else None)
py_name = _python_name(
wdg_creator, hook, hook_idx=idx if self.adapter else None
)

if not py_name: # pragma: no cover
raise ValueError(
Expand Down Expand Up @@ -427,7 +436,7 @@ def _safe_key(key: str) -> str:


def _python_name(
obj: Any, hook: Callable = None, shim_idx: Optional[int] = None
obj: Any, hook: Callable = None, hook_idx: Optional[int] = None
) -> str:
"""Get resolvable python name for `obj` returned from an npe1 `hook` implentation.

Expand All @@ -439,9 +448,9 @@ def _python_name(
the npe1 hook implementation that returned `obj`, by default None.
This is used both to search the module namespace for `obj`, and also
in the shim python name if `obj` cannot be found.
shim_idx : int, optional
If `obj` cannot be found and `shim_idx` is not None, then a shim name.
of the form "__npe1shim__.{_python_name(hook)}_{shim_idx}" will be returned.
hook_idx : int, optional
If `obj` cannot be found and `hook_idx` is not None, then a shim name.
of the form "__npe1shim__.{_python_name(hook)}_{hook_idx}" will be returned.
by default None.

Returns
Expand Down Expand Up @@ -472,7 +481,7 @@ def _python_name(
f = obj.keywords.get("function")
if f:
v = getattr(f, "__globals__", {}).get(getattr(f, "__name__", ""))
if v is obj:
if v is obj: # pragma: no cover
mod_name = f.__module__
obj_name = f.__qualname__

Expand All @@ -486,10 +495,10 @@ def _python_name(
if mod:
mod_name = mod.__name__

if not (mod_name and obj_name) and (hook and shim_idx is not None):
if not (mod_name and obj_name) and (hook and hook_idx is not None):
# we weren't able to resolve an absolute name... if we are shimming, then we
# can create a special py_name of the form `__npe1shim__.hookfunction_idx`
return f"{SHIM_NAME_PREFIX}{_python_name(hook)}_{shim_idx}"
return f"{SHIM_NAME_PREFIX}{_python_name(hook)}_{hook_idx}"

if obj_name and "<locals>" in obj_name:
raise ValueError("functions defined in local scopes are not yet supported.")
Expand Down
12 changes: 11 additions & 1 deletion npe2/_plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
from psygnal import Signal, SignalGroup

from ._command_registry import CommandRegistry
from .manifest import PluginManifest
from .manifest._npe1_adapter import NPE1Adapter
from .manifest.schema import PluginManifest
from .manifest.writers import LayerType, WriterContribution
from .types import PathLike, PythonName, _ensure_str_or_seq_str

Expand Down Expand Up @@ -224,6 +225,7 @@ def __init__(
self._contrib = _ContributionsIndex()
self._manifests: Dict[PluginName, PluginManifest] = {}
self.events = PluginManagerEvents(self)
self._npe1_adapters: List[NPE1Adapter] = []

# up to napari 0.4.15, discovery happened in the init here
# so if we're running on an older version of napari, we need to discover
Expand Down Expand Up @@ -276,6 +278,12 @@ def discover(self, paths: Sequence[str] = (), clear=False) -> None:
if result.manifest and result.manifest.name not in self._manifests:
self.register(result.manifest, warn_disabled=False)

def index_npe1_adapters(self):
with warnings.catch_warnings():
warnings.showwarning = lambda e, *_: print(str(e).split(" Please add")[0])
while self._npe1_adapters:
self._contrib.index_contributions(self._npe1_adapters.pop())

def register(self, manifest: PluginManifest, warn_disabled=True) -> None:
"""Register a plugin manifest"""
if manifest.name in self._manifests:
Expand All @@ -288,6 +296,8 @@ def register(self, manifest: PluginManifest, warn_disabled=True) -> None:
f"Disabled plugin {manifest.name!r} was registered, but will not "
"be indexed. Use `warn_disabled=False` to suppress this message."
)
elif isinstance(manifest, NPE1Adapter):
self._npe1_adapters.append(manifest)
else:
self._contrib.index_contributions(manifest)
self.events.plugins_registered.emit({manifest})
Expand Down
71 changes: 71 additions & 0 deletions npe2/manifest/_npe1_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import logging
import warnings

from .._from_npe1 import manifest_from_npe1
from .package_metadata import PackageMetadata
from .schema import PluginManifest, discovery_blocked

try:
from importlib import metadata
except ImportError:
import importlib_metadata as metadata # type: ignore


logger = logging.getLogger(__name__)


class NPE1Adapter(PluginManifest):
"""PluginManifest subclass that acts as an adapter for 1st gen plugins.

During plugin discovery, packages that provide a first generation
'napari.plugin' entry_point (but do *not* provide a second generation
'napari.manifest' entrypoint) will be stored as `NPE1Adapter` manifests
in the `PluginManager._npe1_adapters` list.

This class is instantiated with only a distribution object, but lacks
contributions at construction time. When `self.contributions` is accesses for the
first time, `_load_contributions` is called triggering and import and indexing of
all plugin modules using the same logic as `npe2 convert`. After import, the
discovered contributions are cached in a manifest for use in future sessions.
(The cache can be cleared using `npe2 cache --clear [plugin-name]`).



Parameters
----------
dist : metadata.Distribution
A Distribution object for a package installed in the environment. (Minimally,
the distribution object must implement the `metadata` and `entry_points`
attributes.). It will be passed to `manifest_from_npe1`
"""

_is_loaded: bool = False
_dist: metadata.Distribution

def __init__(self, dist: metadata.Distribution):
"""_summary_"""
meta = PackageMetadata.from_dist_metadata(dist.metadata)
super().__init__(name=dist.metadata["Name"], package_metadata=meta)
self._dist = dist

def __getattribute__(self, __name: str):
if __name == "contributions" and not self._is_loaded:
self._load_contributions()
return super().__getattribute__(__name)

def _load_contributions(self) -> None:
"""import and inspect package contributions."""

with discovery_blocked():
self._is_loaded = True # if we fail once, we still don't try again.
try:
mf = manifest_from_npe1(self._dist, adapter=True)
except Exception as e:
warnings.warn(
"Error importing contributions for first-generation "
f"napari plugin {self.name!r}: {e}"
)
return

self.contributions = mf.contributions
logger.debug("%r npe1 adapter imported", self.name)
Loading