diff --git a/napari_plugin_manager/npe2api.py b/napari_plugin_manager/npe2api.py new file mode 100644 index 0000000..24e01f9 --- /dev/null +++ b/napari_plugin_manager/npe2api.py @@ -0,0 +1,123 @@ +""" +These convenience functions will be useful for searching pypi for packages +that match the plugin naming convention, and retrieving related metadata. +""" + +import json +from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor +from functools import lru_cache +from typing import ( + Optional, + TypedDict, + cast, +) +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from napari.plugins.utils import normalized_name +from napari.utils.notifications import show_warning +from npe2 import PackageMetadata +from typing_extensions import NotRequired + +PyPIname = str + + +@lru_cache +def _user_agent() -> str: + """Return a user agent string for use in http requests.""" + import platform + + from napari import __version__ + from napari.utils import misc + + if misc.running_as_constructor_app(): + env = 'constructor' + elif misc.in_jupyter(): + env = 'jupyter' + elif misc.in_ipython(): + env = 'ipython' + else: + env = 'python' + + parts = [ + ('napari', __version__), + ('runtime', env), + (platform.python_implementation(), platform.python_version()), + (platform.system(), platform.release()), + ] + return ' '.join(f'{k}/{v}' for k, v in parts) + + +class _ShortSummaryDict(TypedDict): + """Objects returned at https://npe2api.vercel.app/api/extended_summary .""" + + name: NotRequired[PyPIname] + version: str + summary: str + author: str + license: str + home_page: str + + +class SummaryDict(_ShortSummaryDict): + display_name: NotRequired[str] + pypi_versions: NotRequired[list[str]] + conda_versions: NotRequired[list[str]] + + +def plugin_summaries() -> list[SummaryDict]: + """Return PackageMetadata object for all known napari plugins.""" + url = 'https://npe2api.vercel.app/api/extended_summary' + with urlopen(Request(url, headers={'User-Agent': _user_agent()})) as resp: + return json.load(resp) + + +@lru_cache +def conda_map() -> dict[PyPIname, Optional[str]]: + """Return map of PyPI package name to conda_channel/package_name ().""" + url = 'https://npe2api.vercel.app/api/conda' + with urlopen(Request(url, headers={'User-Agent': _user_agent()})) as resp: + return json.load(resp) + + +def iter_napari_plugin_info() -> Iterator[tuple[PackageMetadata, bool, dict]]: + """Iterator of tuples of ProjectInfo, Conda availability for all napari plugins.""" + try: + with ThreadPoolExecutor() as executor: + data = executor.submit(plugin_summaries) + _conda = executor.submit(conda_map) + + conda = _conda.result() + data_set = data.result() + except (HTTPError, URLError): + show_warning( + 'There seems to be an issue with network connectivity. ' + 'Remote plugins cannot be installed, only local ones.\n' + ) + return + + conda_set = {normalized_name(x) for x in conda} + for info in data_set: + info_copy = dict(info) + info_copy.pop('display_name', None) + pypi_versions = info_copy.pop('pypi_versions') + conda_versions = info_copy.pop('conda_versions') + info_ = cast(_ShortSummaryDict, info_copy) + + # TODO: use this better. + # this would require changing the api that qt_plugin_dialog expects to + # receive + + # TODO: once the new version of npe2 is out, this can be refactored + # to all the metadata includes the conda and pypi versions. + extra_info = { + 'home_page': info_.get('home_page', ''), + 'display_name': info.get('display_name', ''), + 'pypi_versions': pypi_versions, + 'conda_versions': conda_versions, + } + info_['name'] = normalized_name(info_['name']) + meta = PackageMetadata(**info_) # type:ignore[call-arg] + + yield meta, (info_['name'] in conda_set), extra_info diff --git a/napari_plugin_manager/qt_plugin_dialog.py b/napari_plugin_manager/qt_plugin_dialog.py index 3568846..d86762a 100644 --- a/napari_plugin_manager/qt_plugin_dialog.py +++ b/napari_plugin_manager/qt_plugin_dialog.py @@ -12,7 +12,6 @@ from napari._qt.qthreading import create_worker from napari._qt.widgets.qt_message_popup import WarnPopup from napari._qt.widgets.qt_tooltip import QtToolTipLabel -from napari.plugins.npe2api import iter_napari_plugin_info from napari.plugins.utils import normalized_name from napari.settings import get_settings from napari.utils.misc import ( @@ -42,6 +41,7 @@ ) from superqt import QCollapsible, QElidingLabel +from napari_plugin_manager.npe2api import iter_napari_plugin_info from napari_plugin_manager.qt_package_installer import ( InstallerActions, InstallerQueue,