diff --git a/npe2/_from_npe1.py b/npe2/_from_npe1.py index ff9f609b..0ac70439 100644 --- a/npe2/_from_npe1.py +++ b/npe2/_from_npe1.py @@ -1,18 +1,20 @@ import ast -import itertools +import inspect import re import sys import warnings from configparser import ConfigParser from dataclasses import dataclass from functools import lru_cache +from importlib import import_module from pathlib import Path +from types import ModuleType from typing import ( Any, Callable, DefaultDict, Dict, - Iterable, + Iterator, List, Optional, Tuple, @@ -20,17 +22,14 @@ cast, ) -from napari_plugin_engine import ( - HookCaller, - HookImplementation, - PluginManager, - napari_hook_specification, -) +import magicgui from npe2.manifest import PluginManifest from npe2.manifest.commands import CommandContribution from npe2.manifest.themes import ThemeColors +from npe2.manifest.utils import SHIM_NAME_PREFIX, import_python_name, merge_manifests from npe2.manifest.widgets import WidgetContribution +from npe2.types import WidgetCreator try: from importlib import metadata @@ -39,28 +38,42 @@ NPE1_EP = "napari.plugin" NPE2_EP = "napari.manifest" +NPE1_IMPL_TAG = "napari_impl" # same as HookImplementation.format_tag("napari") + + +class HookImplementation: + def __init__( + self, + function: Callable, + plugin: Optional[ModuleType] = None, + plugin_name: Optional[str] = None, + **kwargs, + ): + self.function = function + self.plugin = plugin + self.plugin_name = plugin_name + self._specname = kwargs.get("specname") + def __repr__(self) -> str: # pragma: no cover + return ( + f"" + ) -# fmt: off -class HookSpecs: - def napari_provide_sample_data(): ... # type: ignore # noqa: E704 - def napari_get_reader(path): ... # noqa: E704 - def napari_get_writer(path, layer_types): ... # noqa: E704 - def napari_write_image(path, data, meta): ... # noqa: E704 - def napari_write_labels(path, data, meta): ... # noqa: E704 - def napari_write_points(path, data, meta): ... # noqa: E704 - def napari_write_shapes(path, data, meta): ... # noqa: E704 - def napari_write_surface(path, data, meta): ... # noqa: E704 - def napari_write_vectors(path, data, meta): ... # noqa: E704 - def napari_experimental_provide_function(): ... # type: ignore # noqa: E704 - def napari_experimental_provide_dock_widget(): ... # type: ignore # noqa: E704 - def napari_experimental_provide_theme(): ... # type: ignore # noqa: E704 -# fmt: on + @property + def specname(self) -> str: + return self._specname or self.function.__name__ -for m in dir(HookSpecs): - if m.startswith("napari"): - setattr(HookSpecs, m, napari_hook_specification(getattr(HookSpecs, m))) +def iter_hookimpls( + module: ModuleType, plugin_name: Optional[str] = None +) -> Iterator[HookImplementation]: + # yield all routines in module that have "{self.project_name}_impl" attr + for name in dir(module): + method = getattr(module, name) + if hasattr(method, NPE1_IMPL_TAG) and inspect.isroutine(method): + hookimpl_opts = getattr(method, NPE1_IMPL_TAG) + if isinstance(hookimpl_opts, dict): + yield HookImplementation(method, module, plugin_name, **hookimpl_opts) @dataclass @@ -71,11 +84,6 @@ class PluginPackage: top_module: str setup_cfg: Optional[Path] = None - @property - def name_pairs(self): - names = (self.ep_name, self.package_name, self.top_module) - return itertools.product(names, repeat=2) - @lru_cache() def plugin_packages() -> List[PluginPackage]: @@ -98,102 +106,112 @@ def plugin_packages() -> List[PluginPackage]: return packages -def ensure_package_name(name: str): - """Try all the tricks we know to find a package name given a plugin name.""" - for attr in ("package_name", "ep_name", "top_module"): - for p in plugin_packages(): - if name == getattr(p, attr): - return p.package_name - raise KeyError( # pragma: no cover - f"Unable to find a locally installed package for plugin {name!r}" - ) - - -@lru_cache() -def npe1_plugin_manager() -> Tuple[PluginManager, Tuple[int, list]]: - pm = PluginManager("napari", discover_entry_point=NPE1_EP) - pm.add_hookspecs(HookSpecs) - result = pm.discover() - return pm, result - - -def norm_plugin_name(plugin_name: Optional[str] = None, module: Any = None) -> str: - """Try all the things we know to detect something called `plugin_name`.""" - plugin_manager, (_, errors) = npe1_plugin_manager() - - # directly providing a module is mostly for testing. - if module is not None: - if plugin_name: # pragma: no cover - warnings.warn("module provided, plugin_name ignored") - plugin_name = getattr(module, "__name__", "dynamic_plugin") - if not plugin_manager.is_registered(plugin_name): - plugin_manager.register(module, plugin_name) - return cast(str, plugin_name) - - if plugin_name in plugin_manager.plugins: - return cast(str, plugin_name) - - for pkg in plugin_packages(): - for a, b in pkg.name_pairs: - if plugin_name == a and b in plugin_manager.plugins: - return b - - # we couldn't find it: - for e in errors: # pragma: no cover - if module and e.plugin == module: - raise type(e)(e.format()) - for pkg in plugin_packages(): - if plugin_name in (pkg.ep_name, pkg.package_name, pkg.top_module): - raise type(e)(e.format()) - - msg = f"We tried hard! but could not detect a plugin named {plugin_name!r}." - if plugin_manager.plugins: - msg += f" Plugins found include: {list(plugin_manager.plugins)}" - raise metadata.PackageNotFoundError(msg) - - def manifest_from_npe1( - plugin_name: Optional[str] = None, module: Any = None + plugin: Union[str, metadata.Distribution, None] = None, + module: Any = None, + shim=False, ) -> PluginManifest: - """Return manifest object given npe1 plugin_name or package name. + """Return manifest object given npe1 plugin or package name. - One of `plugin_name` or `module` must be provide. + One of `plugin` or `module` must be provide. Parameters ---------- - plugin_name : str - Name of package/plugin to convert, by default None - module : Module + plugin : Union[str, metadata.Distribution, None] + Name of package/plugin to convert. Or a `metadata.Distribution` object. + If a string, this function should be prepared to accept both the name of the + 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 + 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 """ - plugin_manager, _ = npe1_plugin_manager() - plugin_name = norm_plugin_name(plugin_name, module) - - _module = plugin_manager.plugins[plugin_name] - package = ensure_package_name(plugin_name) if module is None else "dynamic" + if module is not None: + modules: List[str] = [module] + package_name = "dynamic" + plugin_name = getattr(module, "__name__", "dynamic_plugin") + elif isinstance(plugin, str): + + modules = [] + plugin_name = plugin + for pp in plugin_packages(): + if plugin in (pp.ep_name, pp.package_name): + modules.append(pp.ep_value) + package_name = pp.package_name + if not modules: + _avail = [f" {p.package_name} ({p.ep_name})" for p in plugin_packages()] + avail = "\n".join(_avail) + raise metadata.PackageNotFoundError( + f"No package or entry point found with name {plugin!r}: " + f"\nFound packages (entry_point):\n{avail}" + ) + elif hasattr(plugin, "entry_points") and hasattr(plugin, "metadata"): + plugin = cast(metadata.Distribution, plugin) + # don't use isinstance(Distribution), setuptools monkeypatches sys.meta_path: + # https://github.com/pypa/setuptools/issues/3169 + NPE1_ENTRY_POINT = "napari.plugin" + plugin_name = package_name = plugin.metadata["Name"] + modules = [ + ep.value for ep in plugin.entry_points if ep.group == NPE1_ENTRY_POINT + ] + assert modules, f"No npe1 entry points found in distribution {plugin_name!r}" + else: + raise ValueError("one of plugin or module must be provided") # pragma: no cover - parser = HookImplParser(package, plugin_name) - parser.parse_callers(plugin_manager._plugin2hookcallers[_module]) + manifests: List[PluginManifest] = [] + for mod_name in modules: + parser = HookImplParser(package_name, plugin_name or "", shim=shim) + _mod = import_module(mod_name) if isinstance(mod_name, str) else mod_name + parser.parse_module(_mod) + manifests.append(parser.manifest()) - return PluginManifest(name=package, contributions=dict(parser.contributions)) + assert manifests, "No npe1 entry points found in distribution {name}" + return merge_manifests(manifests) class HookImplParser: - def __init__(self, package: str, plugin_name: str) -> None: + def __init__(self, package: str, plugin_name: str, shim: bool = False) -> None: + """A visitor class to convert npe1 hookimpls to a npe2 manifest + + Parameters + ---------- + package : str + [description] + plugin_name : str + [description] + shim : bool, optional + If True, the resulting manifest will be used internally by NPE1Adaptor, 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.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 - def parse_callers(self, callers: Iterable[HookCaller]): - for caller in callers: - for impl in caller.get_hookimpls(): - if self.plugin_name and impl.plugin_name != self.plugin_name: - continue # pragma: no cover + def manifest(self) -> PluginManifest: + return PluginManifest(name=self.package, contributions=dict(self.contributions)) + + def parse_module(self, module: ModuleType): + for impl in iter_hookimpls(module, plugin_name=self.plugin_name): + if impl.plugin_name == self.plugin_name: # call the corresponding hookimpl parser try: getattr(self, impl.specname)(impl) except Exception as e: # pragma: no cover - warnings.warn(f"Failed to convert {impl.specname}: {e}") + warnings.warn( + f"Failed to convert {impl.specname} in {self.package!r}: {e}" + ) def napari_experimental_provide_theme(self, impl: HookImplementation): ThemeDict = Dict[str, Union[str, Tuple, List]] @@ -212,11 +230,14 @@ def napari_experimental_provide_theme(self, impl: HookImplementation): ) def napari_get_reader(self, impl: HookImplementation): + + patterns = _guess_fname_patterns(impl.function) + self.contributions["readers"].append( { "command": self.add_command(impl), "accepts_directories": True, - "filename_patterns": [""], + "filename_patterns": patterns, } ) @@ -224,7 +245,7 @@ def napari_provide_sample_data(self, impl: HookImplementation): module = sys.modules[impl.function.__module__.split(".", 1)[0]] samples: Dict[str, Union[dict, str, Callable]] = impl.function() - for key, sample in samples.items(): + for idx, (key, sample) in enumerate(samples.items()): _sample: Union[str, Callable] if isinstance(sample, dict): display_name = sample.get("display_name") @@ -238,9 +259,12 @@ def napari_provide_sample_data(self, impl: HookImplementation): if callable(_sample): # 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 + ) cmd_contrib = CommandContribution( id=id, - python_name=_python_name(_sample), + python_name=py_name, title=f"{key} sample", ) self.contributions["commands"].append(cmd_contrib) @@ -254,14 +278,15 @@ def napari_provide_sample_data(self, impl: HookImplementation): def napari_experimental_provide_function(self, impl: HookImplementation): items: Union[Callable, List[Callable]] = impl.function() - if not isinstance(items, list): - items = [items] + items = [items] if not isinstance(items, list) else items + for idx, item in enumerate(items): try: cmd = f"{self.package}.{item.__name__}" - py_name = _python_name(item) - + py_name = _python_name( + item, impl.function, shim_idx=idx if self.shim else None + ) docsum = item.__doc__.splitlines()[0] if item.__doc__ else None cmd_contrib = CommandContribution( id=cmd, python_name=py_name, title=docsum or item.__name__ @@ -288,6 +313,8 @@ def napari_experimental_provide_dock_widget(self, impl: HookImplementation): if not isinstance(items, list): items = [items] # pragma: no cover + # "wdg_creator" will be the function given by the plugin that returns a widget + # while `impl` is the hook implementation that returned all the `wdg_creators` for idx, item in enumerate(items): if isinstance(item, tuple): wdg_creator = item[0] @@ -301,7 +328,11 @@ def napari_experimental_provide_dock_widget(self, impl: HookImplementation): continue try: - self._create_widget_contrib(impl, wdg_creator, kwargs) + func_name = getattr(wdg_creator, "__name__", "") + wdg_name = str(kwargs.get("name", "")) or _camel_to_spaces(func_name) + self._create_widget_contrib( + wdg_creator, display_name=wdg_name, idx=idx, hook=impl.function + ) except Exception as e: # pragma: no cover msg = ( f"Error converting dock widget [{idx}] " @@ -309,29 +340,18 @@ def napari_experimental_provide_dock_widget(self, impl: HookImplementation): ) warnings.warn(msg) - def _create_widget_contrib(self, impl, wdg_creator, kwargs, is_function=False): - # Get widget name - func_name = getattr(wdg_creator, "__name__", "") - wdg_name = str(kwargs.get("name", "")) or _camel_to_spaces(func_name) - - # in some cases, like partials and magic_factories, there might not be an - # easily accessible python name (from __module__.__qualname__)... - # so first we look for this object in the module namespace - py_name = None - cmd = None - for local_name, val in impl.function.__globals__.items(): - if val is wdg_creator: - py_name = f"{impl.function.__module__}:{local_name}" - cmd = f"{self.package}.{local_name}" - break - else: - try: - py_name = _python_name(wdg_creator) - cmd = ( - f"{self.package}.{func_name or wdg_name.lower().replace(' ', '_')}" - ) - except AttributeError: # pragma: no cover - pass + def _create_widget_contrib( + self, + wdg_creator: WidgetCreator, + display_name: str, + idx: int, + hook: Callable, + ): + # we provide both the wdg_creator object itself, as well as the hook impl that + # 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) if not py_name: # pragma: no cover raise ValueError( @@ -339,18 +359,21 @@ def _create_widget_contrib(self, impl, wdg_creator, kwargs, is_function=False): "Is this a locally defined function or partial?" ) + func_name = getattr(wdg_creator, "__name__", "") + cmd = f"{self.package}.{func_name or display_name.lower().replace(' ', '_')}" + # let these raise exceptions here immediately if they don't validate cmd_contrib = CommandContribution( - id=cmd, python_name=py_name, title=f"Create {wdg_name}" + id=cmd, python_name=py_name, title=f"Create {display_name}" ) - wdg_contrib = WidgetContribution(command=cmd, display_name=wdg_name) + wdg_contrib = WidgetContribution(command=cmd, display_name=display_name) self.contributions["commands"].append(cmd_contrib) self.contributions["widgets"].append(wdg_contrib) def napari_get_writer(self, impl: HookImplementation): warnings.warn( - "Found a multi-layer writer, but it's not convertable. " - "Please add the writer manually." + f"Found a multi-layer writer in {self.package!r} - {impl.specname!r}, " + "but it's not convertable. Please add the writer manually." ) return NotImplemented # pragma: no cover @@ -376,7 +399,7 @@ def _parse_writer(self, impl: HookImplementation, layer: str): "command": id, "layer_types": [layer], "display_name": layer, - "filename_extensions": [""], + "filename_extensions": [], } ) @@ -403,8 +426,79 @@ def _safe_key(key: str) -> str: ) -def _python_name(object): - return f"{object.__module__}:{object.__qualname__}" +def _python_name( + obj: Any, hook: Callable = None, shim_idx: Optional[int] = None +) -> str: + """Get resolvable python name for `obj` returned from an npe1 `hook` implentation. + + Parameters + ---------- + obj : Any + a python obj + hook : Callable, optional + 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. + by default None. + + Returns + ------- + str + a string that can be imported with npe2.manifest.utils.import_python_name + + Raises + ------ + AttributeError + If a resolvable string cannot be found + """ + obj_name: Optional[str] = None + mod_name: Optional[str] = None + # first, check the global namespace of the module where the hook was declared + # if we find `obj` itself, we can just use it. + if hasattr(hook, "__module__"): + hook_mod = sys.modules.get(hook.__module__) + if hook_mod: + for local_name, _obj in vars(hook_mod).items(): + if _obj is obj: + obj_name = local_name + mod_name = hook_mod.__name__ + break + + # trick if it's a magic_factory + if isinstance(obj, magicgui._magicgui.MagicFactory): + f = obj.keywords.get("function") + if f: + v = getattr(f, "__globals__", {}).get(getattr(f, "__name__", "")) + if v is obj: + mod_name = f.__module__ + obj_name = f.__qualname__ + + # if that didn't work get the qualname of the object + # and, if it's not a locally defined qualname, get the name of the module + # in which it is defined + if not (mod_name and obj_name): + obj_name = getattr(obj, "__qualname__", "") + if obj_name and "" not in obj_name: + mod = inspect.getmodule(obj) or inspect.getmodule(hook) + if mod: + mod_name = mod.__name__ + + if not (mod_name and obj_name) and (hook and shim_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}" + + if obj_name and "" in obj_name: + raise ValueError("functions defined in local scopes are not yet supported.") + if not mod_name: + raise AttributeError(f"could not get resolvable python name for {obj}") + pyname = f"{mod_name}:{obj_name}" + if import_python_name(pyname) is not obj: # pragma: no cover + raise AttributeError(f"could not get resolvable python name for {obj}") + return pyname def _luma(r, g, b): @@ -572,3 +666,25 @@ def visit_Call(self, node: ast.Call) -> Any: self._entry_points.append( [i.strip() for i in item.split("=")] ) + + +def _guess_fname_patterns(func): + """Try to guess filename extension patterns from source code. Fallback to "*".""" + + patterns = ["*"] + # try to look at source code to guess file extensions + _, *b = inspect.getsource(func).split("endswith(") + if b: + try: + middle = b[0].split(")")[0] + if middle.startswith("("): + middle += ")" + files = ast.literal_eval(middle) + if isinstance(files, str): + files = [files] + if files: + patterns = [f"*{f}" for f in files] + except Exception: # pragma: no cover + # couldn't do it... just accept all filename patterns + pass + return patterns diff --git a/npe2/manifest/utils.py b/npe2/manifest/utils.py index 57cf0681..bcae439c 100644 --- a/npe2/manifest/utils.py +++ b/npe2/manifest/utils.py @@ -10,18 +10,23 @@ Dict, Generic, Optional, + Sequence, SupportsInt, Tuple, TypeVar, Union, ) +if TYPE_CHECKING: + from npe2.manifest.schema import PluginManifest + from ..types import PythonName if TYPE_CHECKING: from typing_extensions import Protocol from .._command_registry import CommandRegistry + from .contributions import ContributionPoints class ProvidesCommand(Protocol): command: str @@ -31,6 +36,7 @@ def get_callable(self, _registry: Optional[CommandRegistry] = None): R = TypeVar("R") +SHIM_NAME_PREFIX = "__npe1shim__." # TODO: add ParamSpec when it's supported better by mypy @@ -153,16 +159,131 @@ def __str__(self) -> str: return v -def import_python_name(python_name: PythonName) -> Any: +def _import_npe1_shim(shim_name: str) -> Any: + """Import npe1 shimmed python_name + + Some objects returned by npe1 hooks (such as locally defined partials or other + objects) don't have globally accessible python names. In such cases, we create + a "shim" python_name of the form: + + `__npe1shim__._` + + The implication is that the hook should be imported, called, and indexed to return + the corresponding item in the hook results. + + Parameters + ---------- + shim_name : str + A string in the form `__npe1shim__._` + + Returns + ------- + Any + The th object returned from the callable . + + Raises + ------ + IndexError + If len(()) <= + """ + + assert shim_name.startswith(SHIM_NAME_PREFIX), f"Invalid shim name: {shim_name}" + python_name, idx = shim_name[13:].rsplit("_", maxsplit=1) # TODO, make a function + index = int(idx) + + hook = import_python_name(python_name) + result = hook() + if isinstance(result, dict): + # things like sample_data hookspec return a dict, in which case we want the + # "idxth" item in the dict (assumes ordered dict, which is safe now) + result = list(result.values()) + if not isinstance(result, list): + result = [result] # pragma: no cover + + try: + out = result[index] + except IndexError as e: # pragma: no cover + raise IndexError(f"invalid npe1 shim index {index} for hook {hook}") from e + + if "dock_widget" in python_name and isinstance(out, tuple): + return out[0] + if "sample_data" in python_name and isinstance(out, dict): + # this was a nested sample data + return out.get("data") + + return out + + +def import_python_name(python_name: Union[PythonName, str]) -> Any: from importlib import import_module - from ._validators import PYTHON_NAME_PATTERN + from . import _validators - match = PYTHON_NAME_PATTERN.match(python_name) - if not match: # pragma: no cover - raise ValueError(f"Invalid python name: {python_name}") + if python_name.startswith(SHIM_NAME_PREFIX): + return _import_npe1_shim(python_name) - module_name, funcname = match.groups() + _validators.python_name(python_name) # shows the best error message + match = _validators.PYTHON_NAME_PATTERN.match(python_name) + module_name, funcname = match.groups() # type: ignore [union-attr] mod = import_module(module_name) return getattr(mod, funcname) + + +def deep_update(dct: dict, merge_dct: dict, copy=True) -> dict: + """Merge possibly nested dicts""" + from copy import deepcopy + + _dct = deepcopy(dct) if copy else dct + for k, v in merge_dct.items(): + if k in _dct and isinstance(dct[k], dict) and isinstance(v, dict): + deep_update(_dct[k], v, copy=False) + elif isinstance(v, list): + if k not in _dct: + _dct[k] = [] + _dct[k].extend(v) + else: + _dct[k] = v + return _dct + + +def merge_manifests(manifests: Sequence[PluginManifest]): + from npe2.manifest.schema import PluginManifest + + if not manifests: + raise ValueError("Cannot merge empty sequence of manifests") + if len(manifests) == 1: + return manifests[0] + + assert len({mf.name for mf in manifests}) == 1, "All manifests must have same name" + assert ( + len({mf.package_version for mf in manifests}) == 1 + ), "All manifests must have same version" + assert ( + len({mf.display_name for mf in manifests}) == 1 + ), "All manifests must have same display_name" + + mf0 = manifests[0] + info = mf0.dict(exclude={"contributions"}, exclude_unset=True) + info["contributions"] = merge_contributions([m.contributions for m in manifests]) + return PluginManifest(**info) + + +def merge_contributions(contribs: Sequence[Optional[ContributionPoints]]) -> dict: + _contribs = [c for c in contribs if c and c.dict(exclude_unset=True)] + if not _contribs: + return {} # pragma: no cover + + out = _contribs[0].dict(exclude_unset=True) + if len(_contribs) > 1: + for n, ctrb in enumerate(_contribs[1:]): + c = ctrb.dict(exclude_unset=True) + for cmd in c.get("commands", ()): + cmd["id"] = cmd["id"] + f"_{n + 2}" + for name, val in c.items(): + if isinstance(val, list): + for item in val: + if "command" in item: + item["command"] = item["command"] + f"_{n + 2}" + out = deep_update(out, c) + return out diff --git a/tests/conftest.py b/tests/conftest.py index 93fcc9fa..8cf39683 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import sys +from importlib import abc from pathlib import Path from unittest.mock import patch @@ -6,6 +7,11 @@ from npe2 import PluginManager, PluginManifest +try: + from importlib import metadata +except ImportError: + import importlib_metadata as metadata # type: ignore + @pytest.fixture def sample_path(): @@ -20,10 +26,12 @@ def sample_manifest(sample_path): @pytest.fixture def uses_sample_plugin(sample_path): sys.path.append(str(sample_path)) - pm = PluginManager.instance() - pm.discover() - yield - sys.path.remove(str(sample_path)) + try: + pm = PluginManager.instance() + pm.discover() + yield + finally: + sys.path.remove(str(sample_path)) @pytest.fixture @@ -55,6 +63,30 @@ def npe1_repo(): return Path(__file__).parent / "npe1-plugin" +@pytest.fixture +def uses_npe1_plugin(npe1_repo): + import site + + class Importer(abc.MetaPathFinder): + def find_spec(self, *_, **__): + return None + + def find_distributions(self, ctx, **k): + if ctx.name == "npe1-plugin": + pth = npe1_repo / "npe1-plugin-0.0.1.dist-info" + yield metadata.PathDistribution(pth) + return + + sys.meta_path.append(Importer()) + sys.path.append(str(npe1_repo)) + try: + pkgs = site.getsitepackages() + [str(npe1_repo)] + with patch("site.getsitepackages", return_value=pkgs): + yield + finally: + sys.path.remove(str(npe1_repo)) + + @pytest.fixture def npe1_plugin_module(npe1_repo): import sys @@ -74,23 +106,39 @@ def npe1_plugin_module(npe1_repo): @pytest.fixture def mock_npe1_pm(): - from napari_plugin_engine import PluginManager - - from npe2._from_npe1 import HookSpecs + from napari_plugin_engine import PluginManager, napari_hook_specification + + # fmt: off + class HookSpecs: + def napari_provide_sample_data(): ... # type: ignore # noqa: E704 + def napari_get_reader(path): ... # noqa: E704 + def napari_get_writer(path, layer_types): ... # noqa: E704 + def napari_write_image(path, data, meta): ... # noqa: E704 + def napari_write_labels(path, data, meta): ... # noqa: E704 + def napari_write_points(path, data, meta): ... # noqa: E704 + def napari_write_shapes(path, data, meta): ... # noqa: E704 + def napari_write_surface(path, data, meta): ... # noqa: E704 + def napari_write_vectors(path, data, meta): ... # noqa: E704 + def napari_experimental_provide_function(): ... # type: ignore # noqa: E704 + def napari_experimental_provide_dock_widget(): ... # type: ignore # noqa: E704 + def napari_experimental_provide_theme(): ... # type: ignore # noqa: E704 + # fmt: on + + for m in dir(HookSpecs): + if m.startswith("napari"): + setattr(HookSpecs, m, napari_hook_specification(getattr(HookSpecs, m))) pm = PluginManager("napari") pm.add_hookspecs(HookSpecs) - with patch("npe2._from_npe1.npe1_plugin_manager", new=lambda: (pm, (1, []))): - yield pm + yield pm @pytest.fixture -def mock_npe1_pm_with_plugin(npe1_repo, mock_npe1_pm, npe1_plugin_module): +def mock_npe1_pm_with_plugin(npe1_repo, npe1_plugin_module): """Mocks a fully installed local repository""" from npe2._from_npe1 import metadata, plugin_packages - mock_npe1_pm.register(npe1_plugin_module, "npe1-plugin") mock_dist = metadata.PathDistribution(npe1_repo / "npe1-plugin-0.0.1.dist-info") def _dists(): diff --git a/tests/npe1-plugin/npe1-plugin-0.0.1.dist-info/METADATA b/tests/npe1-plugin/npe1-plugin-0.0.1.dist-info/METADATA index a3006648..0e40ff76 100644 --- a/tests/npe1-plugin/npe1-plugin-0.0.1.dist-info/METADATA +++ b/tests/npe1-plugin/npe1-plugin-0.0.1.dist-info/METADATA @@ -1,2 +1,3 @@ Metadata-Version: 2.1 Name: npe1-plugin +Version: 0.1.0 diff --git a/tests/npe1-plugin/npe1_module/__init__.py b/tests/npe1-plugin/npe1_module/__init__.py index ad52238d..2dfe0fe4 100644 --- a/tests/npe1-plugin/npe1_module/__init__.py +++ b/tests/npe1-plugin/npe1_module/__init__.py @@ -1,3 +1,6 @@ +from functools import partial + +import numpy as np from magicgui import magic_factory from napari_plugin_engine import napari_hook_implementation @@ -33,11 +36,16 @@ def napari_write_labels(path, data, meta): def napari_provide_sample_data(): return { "random data": gen_data, + "local data": partial(np.ones, (4, 4)), "random image": "https://picsum.photos/1024", "sample_key": { "display_name": "Some Random Data (512 x 512)", "data": gen_data, }, + "local_ones": { + "display_name": "Some local ones", + "data": partial(np.ones, (4, 4)), + }, } @@ -65,11 +73,25 @@ def napari_experimental_provide_theme(): } +factory = magic_factory(some_function) + + @napari_hook_implementation def napari_experimental_provide_dock_widget(): - return [MyWidget, (magic_factory(some_function), {"name": "My Other Widget"})] + @magic_factory + def local_widget(y: str): + ... + + return [ + MyWidget, + (factory, {"name": "My Other Widget"}), + (local_widget, {"name": "Local Widget"}), + ] @napari_hook_implementation def napari_experimental_provide_function(): - return some_function + def local_function(x: int): + ... + + return [some_function, local_function] diff --git a/tests/npe1-plugin/setup.cfg b/tests/npe1-plugin/setup.cfg index 3df0296b..08691809 100644 --- a/tests/npe1-plugin/setup.cfg +++ b/tests/npe1-plugin/setup.cfg @@ -1,5 +1,6 @@ [metadata] name = npe1-plugin +version = 0.1.0 [options.entry_points] napari.plugin = diff --git a/tests/test_cli.py b/tests/test_cli.py index c0a17946..6c9934aa 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -63,7 +63,7 @@ def test_cli_convert_repo_dry_run(npe1_repo, mock_npe1_pm_with_plugin): def test_cli_convert_svg(): result = runner.invoke(app, ["convert", "napari-svg"]) assert "Some issues occured:" in result.stdout - assert "Found a multi-layer writer, but it's not convertable" in result.stdout + assert "Found a multi-layer writer in 'napari-svg'" in result.stdout assert result.exit_code == 0 diff --git a/tests/test_conversion.py b/tests/test_conversion.py index ada5dcfc..8a410023 100644 --- a/tests/test_conversion.py +++ b/tests/test_conversion.py @@ -1,5 +1,6 @@ import pytest +from npe2 import _from_npe1 from npe2._from_npe1 import convert_repository, get_top_module_path, manifest_from_npe1 try: @@ -9,12 +10,15 @@ @pytest.mark.filterwarnings("ignore:The distutils package is deprecated") -@pytest.mark.filterwarnings("ignore:Found a multi-layer writer, but it's not") +@pytest.mark.filterwarnings("ignore:Found a multi-layer writer in") @pytest.mark.parametrize("package", ["svg"]) def test_conversion(package): assert manifest_from_npe1(package) +@pytest.mark.filterwarnings("ignore:Failed to convert napari_provide_sample_data") +@pytest.mark.filterwarnings("ignore:Error converting function") +@pytest.mark.filterwarnings("ignore:Error converting dock widget") def test_conversion_from_module(mock_npe1_pm, npe1_plugin_module): mf = manifest_from_npe1(module=npe1_plugin_module) assert isinstance(mf.dict(), dict) @@ -39,6 +43,9 @@ def f(x: int): assert isinstance(mf.dict(), dict) +@pytest.mark.filterwarnings("ignore:Failed to convert napari_provide_sample_data") +@pytest.mark.filterwarnings("ignore:Error converting function") +@pytest.mark.filterwarnings("ignore:Error converting dock widget") def test_conversion_from_package(npe1_repo, mock_npe1_pm_with_plugin): setup_cfg = npe1_repo / "setup.cfg" before = setup_cfg.read_text() @@ -60,6 +67,20 @@ def test_conversion_from_package(npe1_repo, mock_npe1_pm_with_plugin): assert "Is this package already converted?" in str(e.value) +def _assert_expected_errors(record: pytest.WarningsRecorder): + assert len(record) == 4 + msg = str(record[0].message) + assert "Error converting dock widget [2] from 'npe1_module'" in msg + msg = str(record[1].message) + assert "Error converting function [1] from 'npe1_module'" in msg + msg = str(record[2].message) + assert "Failed to convert napari_provide_sample_data in 'npe1-plugin'" in msg + assert "could not get resolvable python name" in msg + msg = str(record[3].message) + assert "Cannot auto-update setup.py, please edit setup.py as follows" in msg + assert "npe1-plugin = npe1_module:napari.yaml" in msg + + def test_conversion_from_package_setup_py(npe1_repo, mock_npe1_pm_with_plugin): (npe1_repo / "setup.cfg").unlink() (npe1_repo / "setup.py").write_text( @@ -73,9 +94,7 @@ def test_conversion_from_package_setup_py(npe1_repo, mock_npe1_pm_with_plugin): ) with pytest.warns(UserWarning) as record: convert_repository(npe1_repo) - msg = record[0].message - assert "Cannot auto-update setup.py, please edit setup.py as follows" in str(msg) - assert "npe1-plugin = npe1_module:napari.yaml" in str(msg) + _assert_expected_errors(record) def test_conversion_entry_point_string(npe1_repo, mock_npe1_pm_with_plugin): @@ -91,9 +110,7 @@ def test_conversion_entry_point_string(npe1_repo, mock_npe1_pm_with_plugin): ) with pytest.warns(UserWarning) as record: convert_repository(npe1_repo) - msg = record[0].message - assert "Cannot auto-update setup.py, please edit setup.py as follows" in str(msg) - assert "npe1-plugin = npe1_module:napari.yaml" in str(msg) + _assert_expected_errors(record) def test_conversion_missing(): @@ -112,3 +129,26 @@ def test_convert_repo(): def test_get_top_module_path(mock_npe1_pm_with_plugin): get_top_module_path("npe1-plugin") + + +def test_python_name_local(): + def f(): + return lambda x: None + + with pytest.raises(ValueError) as e: + _from_npe1._python_name(f()) + + assert "functions defined in local scopes are not yet supported" in str(e.value) + + +def test_guess_fname_patterns(): + def get_reader1(path): + if isinstance(path, str) and path.endswith((".tiff", ".tif")): + return 1 + + def get_reader2(path): + if path.endswith(".xyz"): + return 1 + + assert _from_npe1._guess_fname_patterns(get_reader1) == ["*.tiff", "*.tif"] + assert _from_npe1._guess_fname_patterns(get_reader2) == ["*.xyz"] diff --git a/tests/test_utils.py b/tests/test_utils.py index f1613388..7525f404 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,7 @@ import pytest -from npe2.manifest.utils import Version +from npe2.manifest.schema import PluginManifest +from npe2.manifest.utils import Version, deep_update, merge_manifests def test_version(): @@ -21,3 +22,52 @@ def test_version(): with pytest.raises(TypeError): Version.parse(1.2) # type: ignore + + +def test_merge_manifests(): + with pytest.raises(ValueError): + merge_manifests([]) + + with pytest.raises(AssertionError) as e: + merge_manifests([PluginManifest(name="p1"), PluginManifest(name="p2")]) + assert "All manifests must have same name" in str(e.value) + + pm1 = PluginManifest( + name="plugin", + contributions={ + "commands": [{"id": "plugin.command", "title": "some writer"}], + "writers": [{"command": "plugin.command", "layer_types": ["image"]}], + }, + ) + pm2 = PluginManifest( + name="plugin", + contributions={ + "commands": [{"id": "plugin.command", "title": "some reader"}], + "readers": [{"command": "plugin.command", "filename_patterns": [".tif"]}], + }, + ) + expected_merge = PluginManifest( + name="plugin", + contributions={ + "commands": [ + {"id": "plugin.command", "title": "some writer"}, + {"id": "plugin.command_2", "title": "some reader"}, # no dupes + ], + "writers": [{"command": "plugin.command", "layer_types": ["image"]}], + "readers": [{"command": "plugin.command_2", "filename_patterns": [".tif"]}], + }, + ) + + assert merge_manifests([pm1]) is pm1 + assert merge_manifests([pm1, pm2]) == expected_merge + + +def test_deep_update(): + a = {"a": {"b": 1, "c": 2}, "e": 2} + b = {"a": {"d": 4, "c": 3}, "f": 0} + c = deep_update(a, b, copy=True) + assert c == {"a": {"b": 1, "d": 4, "c": 3}, "e": 2, "f": 0} + assert a == {"a": {"b": 1, "c": 2}, "e": 2} + + deep_update(a, b, copy=False) + assert a == {"a": {"b": 1, "d": 4, "c": 3}, "e": 2, "f": 0} diff --git a/tests/test_validations.py b/tests/test_validations.py index 66d8b56d..c2413f1f 100644 --- a/tests/test_validations.py +++ b/tests/test_validations.py @@ -36,6 +36,12 @@ def _mutator_python_name_no_colon(data): data["contributions"]["commands"][0]["python_name"] = "this.has.no.colon" +def _mutator_python_name_locals(data): + """functions defined in local scopes are not yet supported""" + assert "contributions" in data + data["contributions"]["commands"][0]["python_name"] = "mod:func..another" + + def _mutator_python_name_starts_with_number(data): """'1starts_with_number' is not a valid python_name.""" assert "contributions" in data @@ -81,6 +87,7 @@ def _mutator_schema_version_too_high(data): _mutator_invalid_package_name2, _mutator_command_not_begin_with_package_name, _mutator_python_name_no_colon, + _mutator_python_name_locals, _mutator_python_name_starts_with_number, _mutator_no_contributes_extra_field, _mutator_writer_requires_non_empty_layer_types,