You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			691 lines
		
	
	
		
			26 KiB
		
	
	
	
		
			Python
		
	
			
		
		
	
	
			691 lines
		
	
	
		
			26 KiB
		
	
	
	
		
			Python
		
	
| """Base classes for the extension manager."""
 | |
| 
 | |
| # Copyright (c) Jupyter Development Team.
 | |
| # Distributed under the terms of the Modified BSD License.
 | |
| 
 | |
| import json
 | |
| import re
 | |
| from dataclasses import dataclass, field, fields, replace
 | |
| from pathlib import Path
 | |
| from typing import Optional, Union
 | |
| 
 | |
| import tornado
 | |
| from jupyterlab_server.translation_utils import translator
 | |
| from traitlets import Enum
 | |
| from traitlets.config import Configurable, LoggingConfigurable
 | |
| 
 | |
| from jupyterlab.commands import (
 | |
|     _AppHandler,
 | |
|     _ensure_options,
 | |
|     disable_extension,
 | |
|     enable_extension,
 | |
|     get_app_info,
 | |
| )
 | |
| 
 | |
| PYTHON_TO_SEMVER = {"a": "-alpha.", "b": "-beta.", "rc": "-rc."}
 | |
| 
 | |
| 
 | |
| def _ensure_compat_errors(info, app_options):
 | |
|     """Ensure that the app info has compat_errors field"""
 | |
|     handler = _AppHandler(app_options)
 | |
|     info["compat_errors"] = handler._get_extension_compat()
 | |
| 
 | |
| 
 | |
| _message_map = {
 | |
|     "install": re.compile(r"(?P<name>.*) needs to be included in build"),
 | |
|     "uninstall": re.compile(r"(?P<name>.*) needs to be removed from build"),
 | |
|     "update": re.compile(r"(?P<name>.*) changed from (?P<oldver>.*) to (?P<newver>.*)"),
 | |
| }
 | |
| 
 | |
| 
 | |
| def _build_check_info(app_options):
 | |
|     """Get info about packages scheduled for (un)install/update"""
 | |
|     handler = _AppHandler(app_options)
 | |
|     messages = handler.build_check(fast=True)
 | |
|     # Decode the messages into a dict:
 | |
|     status = {"install": [], "uninstall": [], "update": []}
 | |
|     for msg in messages:
 | |
|         for key, pattern in _message_map.items():
 | |
|             match = pattern.match(msg)
 | |
|             if match:
 | |
|                 status[key].append(match.group("name"))
 | |
|     return status
 | |
| 
 | |
| 
 | |
| @dataclass(frozen=True)
 | |
| class ExtensionPackage:
 | |
|     """Extension package entry.
 | |
| 
 | |
|     Attributes:
 | |
|         name: Package name
 | |
|         description: Package description
 | |
|         homepage_url: Package home page
 | |
|         pkg_type: Type of package - ["prebuilt", "source"]
 | |
|         allowed: [optional] Whether this extension is allowed or not - default True
 | |
|         approved: [optional] Whether the package is approved by your administrators - default False
 | |
|         companion: [optional] Type of companion for the frontend extension - [None, "kernel", "server"]; default None
 | |
|         core: [optional] Whether the package is a core package or not - default False
 | |
|         enabled: [optional] Whether the package is enabled or not - default False
 | |
|         install: [optional] Extension package installation instructions - default None
 | |
|         installed: [optional] Whether the extension is currently installed - default None
 | |
|         installed_version: [optional] Installed version - default ""
 | |
|         latest_version: [optional] Latest available version - default ""
 | |
|         status: [optional] Package status - ["ok", "warning", "error"]; default "ok"
 | |
|         author: [optional] Package author - default None
 | |
|         license: [optional] Package license - default None
 | |
|         bug_tracker_url: [optional] Package bug tracker URL - default None
 | |
|         documentation_url: [optional] Package documentation URL - default None
 | |
|         package_manager_url: Package home page in the package manager - default None
 | |
|         repository_url: [optional] Package code repository URL - default None
 | |
|     """
 | |
| 
 | |
|     name: str
 | |
|     description: str
 | |
|     homepage_url: str
 | |
|     pkg_type: str
 | |
|     allowed: bool = True
 | |
|     approved: bool = False
 | |
|     companion: Optional[str] = None
 | |
|     core: bool = False
 | |
|     enabled: bool = False
 | |
|     install: Optional[dict] = None
 | |
|     installed: Optional[bool] = None
 | |
|     installed_version: str = ""
 | |
|     latest_version: str = ""
 | |
|     status: str = "ok"
 | |
|     author: Optional[str] = None
 | |
|     license: Optional[str] = None
 | |
|     bug_tracker_url: Optional[str] = None
 | |
|     documentation_url: Optional[str] = None
 | |
|     package_manager_url: Optional[str] = None
 | |
|     repository_url: Optional[str] = None
 | |
| 
 | |
| 
 | |
| @dataclass(frozen=True)
 | |
| class ActionResult:
 | |
|     """Action result
 | |
| 
 | |
|     Attributes:
 | |
|         status: Action status - ["ok", "warning", "error"]
 | |
|         message: Action status explanation
 | |
|         needs_restart: Required action follow-up - Valid follow-up are "frontend", "kernel" and "server"
 | |
|     """
 | |
| 
 | |
|     # Note: no simple way to use Enum in dataclass - https://stackoverflow.com/questions/72859557/typing-dataclass-that-can-only-take-enum-values
 | |
|     #       keeping str for simplicity
 | |
|     status: str
 | |
|     message: Optional[str] = None
 | |
|     needs_restart: list[str] = field(default_factory=list)
 | |
| 
 | |
| 
 | |
| @dataclass(frozen=True)
 | |
| class PluginManagerOptions:
 | |
|     """Plugin manager options.
 | |
| 
 | |
|     Attributes:
 | |
|         lock_all: Whether to lock (prevent enabling/disabling) all plugins.
 | |
|         lock_rules: A list of plugins or extensions that cannot be toggled.
 | |
|             If extension name is provided, all its plugins will be disabled.
 | |
|             The plugin names need to follow colon-separated format of `extension:plugin`.
 | |
|     """
 | |
| 
 | |
|     lock_rules: frozenset[str] = field(default_factory=frozenset)
 | |
|     lock_all: bool = False
 | |
| 
 | |
| 
 | |
| @dataclass(frozen=True)
 | |
| class ExtensionManagerOptions(PluginManagerOptions):
 | |
|     """Extension manager options.
 | |
| 
 | |
|     Attributes:
 | |
|         allowed_extensions_uris: A list of comma-separated URIs to get the allowed extensions list
 | |
|         blocked_extensions_uris: A list of comma-separated URIs to get the blocked extensions list
 | |
|         listings_refresh_seconds: The interval delay in seconds to refresh the lists
 | |
|         listings_tornado_options: The optional kwargs to use for the listings HTTP requests as described on https://www.tornadoweb.org/en/stable/httpclient.html#tornado.httpclient.HTTPRequest
 | |
|     """
 | |
| 
 | |
|     allowed_extensions_uris: set[str] = field(default_factory=set)
 | |
|     blocked_extensions_uris: set[str] = field(default_factory=set)
 | |
|     listings_refresh_seconds: int = 60 * 60
 | |
|     listings_tornado_options: dict = field(default_factory=dict)
 | |
| 
 | |
| 
 | |
| @dataclass(frozen=True)
 | |
| class ExtensionManagerMetadata:
 | |
|     """Extension manager metadata.
 | |
| 
 | |
|     Attributes:
 | |
|         name: Extension manager name to be displayed
 | |
|         can_install: Whether the extension manager can un-/install packages (default False)
 | |
|         install_path: Installation path for the extensions (default None); e.g. environment path
 | |
|     """
 | |
| 
 | |
|     name: str
 | |
|     can_install: bool = False
 | |
|     install_path: Optional[str] = None
 | |
| 
 | |
| 
 | |
| @dataclass
 | |
| class ExtensionsCache:
 | |
|     """Extensions cache
 | |
| 
 | |
|     Attributes:
 | |
|         cache: Extension list per page
 | |
|         last_page: Last available page result
 | |
|     """
 | |
| 
 | |
|     cache: dict[int, Optional[dict[str, ExtensionPackage]]] = field(default_factory=dict)
 | |
|     last_page: int = 1
 | |
| 
 | |
| 
 | |
| class PluginManager(LoggingConfigurable):
 | |
|     """Plugin manager enables or disables plugins unless locked.
 | |
| 
 | |
|     It can also disable/enable all plugins in an extension.
 | |
| 
 | |
|     Args:
 | |
|         app_options: Application options
 | |
|         ext_options: Plugin manager (subset of extension manager) options
 | |
|         parent: Configurable parent
 | |
| 
 | |
|     Attributes:
 | |
|         app_options: Application options
 | |
|         options: Plugin manager options
 | |
|     """
 | |
| 
 | |
|     level = Enum(
 | |
|         values=["sys_prefix", "user", "system"],
 | |
|         default_value="sys_prefix",
 | |
|         help="Level at which to manage plugins: sys_prefix, user, system",
 | |
|     ).tag(config=True)
 | |
| 
 | |
|     def __init__(
 | |
|         self,
 | |
|         app_options: Optional[dict] = None,
 | |
|         ext_options: Optional[dict] = None,
 | |
|         parent: Optional[Configurable] = None,
 | |
|     ) -> None:
 | |
|         super().__init__(parent=parent)
 | |
|         self.log.debug(
 | |
|             f"Plugins in {self.__class__.__name__} will managed on the {self.level} level"
 | |
|         )
 | |
|         self.app_options = _ensure_options(app_options)
 | |
|         plugin_options_field = {f.name for f in fields(PluginManagerOptions)}
 | |
|         plugin_options = {
 | |
|             option: value
 | |
|             for option, value in (ext_options or {}).items()
 | |
|             if option in plugin_options_field
 | |
|         }
 | |
|         self.options = PluginManagerOptions(**plugin_options)
 | |
| 
 | |
|     async def plugin_locks(self) -> dict:
 | |
|         """Get information about locks on plugin enabling/disabling"""
 | |
|         return {
 | |
|             "lockRules": list(self.options.lock_rules),
 | |
|             "allLocked": self.options.lock_all,
 | |
|         }
 | |
| 
 | |
|     def _find_locked(self, plugins_or_extensions: list[str]) -> frozenset[str]:
 | |
|         """Find a subset of plugins (or extensions) which are locked"""
 | |
|         if self.options.lock_all:
 | |
|             return set(plugins_or_extensions)
 | |
|         locked_subset = set()
 | |
|         extensions_with_locked_plugins = {
 | |
|             plugin.split(":")[0] for plugin in self.options.lock_rules
 | |
|         }
 | |
|         for plugin in plugins_or_extensions:
 | |
|             if ":" in plugin:
 | |
|                 # check directly if this is a plugin identifier (has colon)
 | |
|                 if plugin in self.options.lock_rules:
 | |
|                     locked_subset.add(plugin)
 | |
|             elif plugin in extensions_with_locked_plugins:
 | |
|                 # this is an extension - we need to check for >any< plugin
 | |
|                 # belonging to said extension
 | |
|                 locked_subset.add(plugin)
 | |
|         return locked_subset
 | |
| 
 | |
|     async def disable(self, plugins: Union[str, list[str]]) -> ActionResult:
 | |
|         """Disable a set of plugins (or an extension).
 | |
| 
 | |
|         Args:
 | |
|             plugins: The list of plugins to disable
 | |
|         Returns:
 | |
|             The action result
 | |
|         """
 | |
|         plugins = plugins if isinstance(plugins, list) else [plugins]
 | |
|         locked = self._find_locked(plugins)
 | |
|         trans = translator.load("jupyterlab")
 | |
|         if locked:
 | |
|             return ActionResult(
 | |
|                 status="error",
 | |
|                 message=trans.gettext(
 | |
|                     "The following plugins cannot be disabled as they are locked: "
 | |
|                 )
 | |
|                 + ", ".join(locked),
 | |
|             )
 | |
|         try:
 | |
|             for plugin in plugins:
 | |
|                 disable_extension(plugin, app_options=self.app_options, level=self.level)
 | |
|             return ActionResult(status="ok", needs_restart=["frontend"])
 | |
|         except Exception as err:
 | |
|             return ActionResult(status="error", message=repr(err))
 | |
| 
 | |
|     async def enable(self, plugins: Union[str, list[str]]) -> ActionResult:
 | |
|         """Enable a set of plugins (or an extension).
 | |
| 
 | |
|         Args:
 | |
|             plugins: The list of plugins to enable
 | |
|         Returns:
 | |
|             The action result
 | |
|         """
 | |
|         plugins = plugins if isinstance(plugins, list) else [plugins]
 | |
|         locked = self._find_locked(plugins)
 | |
|         trans = translator.load("jupyterlab")
 | |
|         if locked:
 | |
|             return ActionResult(
 | |
|                 status="error",
 | |
|                 message=trans.gettext(
 | |
|                     "The following plugins cannot be enabled as they are locked: "
 | |
|                 )
 | |
|                 + ", ".join(locked),
 | |
|             )
 | |
|         try:
 | |
|             for plugin in plugins:
 | |
|                 enable_extension(plugin, app_options=self.app_options, level=self.level)
 | |
|             return ActionResult(status="ok", needs_restart=["frontend"])
 | |
|         except Exception as err:
 | |
|             return ActionResult(status="error", message=repr(err))
 | |
| 
 | |
| 
 | |
| class ExtensionManager(PluginManager):
 | |
|     """Base abstract extension manager.
 | |
| 
 | |
|     Note:
 | |
|         Any concrete implementation will need to implement the five
 | |
|         following abstract methods:
 | |
|         - :ref:`metadata`
 | |
|         - :ref:`get_latest_version`
 | |
|         - :ref:`list_packages`
 | |
|         - :ref:`install`
 | |
|         - :ref:`uninstall`
 | |
| 
 | |
|         It could be interesting to override the :ref:`get_normalized_name`
 | |
|         method too.
 | |
| 
 | |
|     Args:
 | |
|         app_options: Application options
 | |
|         ext_options: Extension manager options
 | |
|         parent: Configurable parent
 | |
| 
 | |
|     Attributes:
 | |
|         log: Logger
 | |
|         app_dir: Application directory
 | |
|         core_config: Core configuration
 | |
|         app_options: Application options
 | |
|         options: Extension manager options
 | |
|     """
 | |
| 
 | |
|     def __init__(
 | |
|         self,
 | |
|         app_options: Optional[dict] = None,
 | |
|         ext_options: Optional[dict] = None,
 | |
|         parent: Optional[Configurable] = None,
 | |
|     ) -> None:
 | |
|         super().__init__(app_options=app_options, ext_options=ext_options, parent=parent)
 | |
|         self.log = self.app_options.logger
 | |
|         self.app_dir = Path(self.app_options.app_dir)
 | |
|         self.core_config = self.app_options.core_config
 | |
|         self.options = ExtensionManagerOptions(**(ext_options or {}))
 | |
|         self._extensions_cache: dict[Optional[str], ExtensionsCache] = {}
 | |
|         self._listings_cache: Optional[dict] = None
 | |
|         self._listings_block_mode = True
 | |
|         self._listing_fetch: Optional[tornado.ioloop.PeriodicCallback] = None
 | |
| 
 | |
|         if len(self.options.allowed_extensions_uris) or len(self.options.blocked_extensions_uris):
 | |
|             self._listings_block_mode = len(self.options.allowed_extensions_uris) == 0
 | |
|             if not self._listings_block_mode and len(self.options.blocked_extensions_uris) > 0:
 | |
|                 self.log.warning(
 | |
|                     "You have define simultaneously blocked and allowed extensions listings. The allowed listing will take precedence."
 | |
|                 )
 | |
| 
 | |
|             self._listing_fetch = tornado.ioloop.PeriodicCallback(
 | |
|                 self._fetch_listings,
 | |
|                 callback_time=self.options.listings_refresh_seconds * 1000,
 | |
|                 jitter=0.1,
 | |
|             )
 | |
|             self._listing_fetch.start()
 | |
| 
 | |
|     def __del__(self):
 | |
|         if self._listing_fetch is not None:
 | |
|             self._listing_fetch.stop()
 | |
| 
 | |
|     @property
 | |
|     def metadata(self) -> ExtensionManagerMetadata:
 | |
|         """Extension manager metadata."""
 | |
|         raise NotImplementedError()
 | |
| 
 | |
|     async def get_latest_version(self, extension: str) -> Optional[str]:
 | |
|         """Return the latest available version for a given extension.
 | |
| 
 | |
|         Args:
 | |
|             pkg: The extension name
 | |
|         Returns:
 | |
|             The latest available version
 | |
|         """
 | |
|         raise NotImplementedError()
 | |
| 
 | |
|     async def list_packages(
 | |
|         self, query: str, page: int, per_page: int
 | |
|     ) -> tuple[dict[str, ExtensionPackage], Optional[int]]:
 | |
|         """List the available extensions.
 | |
| 
 | |
|         Args:
 | |
|             query: The search extension query
 | |
|             page: The result page
 | |
|             per_page: The number of results per page
 | |
|         Returns:
 | |
|             The available extensions in a mapping {name: metadata}
 | |
|             The results last page; None if the manager does not support pagination
 | |
|         """
 | |
|         raise NotImplementedError()
 | |
| 
 | |
|     async def install(self, extension: str, version: Optional[str] = None) -> ActionResult:
 | |
|         """Install the required extension.
 | |
| 
 | |
|         Note:
 | |
|             If the user must be notified with a message (like asking to restart the
 | |
|             server), the result should be
 | |
|             {"status": "warning", "message": "<explanation for the user>"}
 | |
| 
 | |
|         Args:
 | |
|             extension: The extension name
 | |
|             version: The version to install; default None (i.e. the latest possible)
 | |
|         Returns:
 | |
|             The action result
 | |
|         """
 | |
|         raise NotImplementedError()
 | |
| 
 | |
|     async def uninstall(self, extension: str) -> ActionResult:
 | |
|         """Uninstall the required extension.
 | |
| 
 | |
|         Note:
 | |
|             If the user must be notified with a message (like asking to restart the
 | |
|             server), the result should be
 | |
|             {"status": "warning", "message": "<explanation for the user>"}
 | |
| 
 | |
|         Args:
 | |
|             extension: The extension name
 | |
|         Returns:
 | |
|             The action result
 | |
|         """
 | |
|         raise NotImplementedError()
 | |
| 
 | |
|     @staticmethod
 | |
|     def get_semver_version(version: str) -> str:
 | |
|         """Convert a Python version to Semver version.
 | |
| 
 | |
|         It:
 | |
| 
 | |
|         - drops ``.devN`` and ``.postN``
 | |
|         - converts ``aN``, ``bN`` and ``rcN`` to ``-alpha.N``, ``-beta.N``, ``-rc.N`` respectively
 | |
| 
 | |
|         Args:
 | |
|             version: Version to convert
 | |
|         Returns
 | |
|             Semver compatible version
 | |
|         """
 | |
|         return re.sub(
 | |
|             r"(a|b|rc)(\d+)$",
 | |
|             lambda m: f"{PYTHON_TO_SEMVER[m.group(1)]}{m.group(2)}",
 | |
|             re.subn(r"\.(dev|post)\d+", "", version)[0],
 | |
|         )
 | |
| 
 | |
|     def get_normalized_name(self, extension: ExtensionPackage) -> str:
 | |
|         """Normalize extension name.
 | |
| 
 | |
|         Extension have multiple parts, npm package, Python package,...
 | |
|         Sub-classes may override this method to ensure the name of
 | |
|         an extension from the service provider and the local installed
 | |
|         listing is matching.
 | |
| 
 | |
|         Args:
 | |
|             extension: The extension metadata
 | |
|         Returns:
 | |
|             The normalized name
 | |
|         """
 | |
|         return extension.name
 | |
| 
 | |
|     async def list_extensions(
 | |
|         self, query: Optional[str] = None, page: int = 1, per_page: int = 30
 | |
|     ) -> tuple[list[ExtensionPackage], Optional[int]]:
 | |
|         """List extensions for a given ``query`` search term.
 | |
| 
 | |
|         This will return the extensions installed (if ``query`` is None) or
 | |
|         available if allowed by the listing settings.
 | |
| 
 | |
|         Args:
 | |
|             query: [optional] Query search term.
 | |
| 
 | |
|         Returns:
 | |
|             The extensions
 | |
|             Last page of results
 | |
|         """
 | |
|         if query not in self._extensions_cache or page not in self._extensions_cache[query].cache:
 | |
|             await self.refresh(query, page, per_page)
 | |
| 
 | |
|         # filter using listings settings
 | |
|         if self._listings_cache is None and self._listing_fetch is not None:
 | |
|             await self._listing_fetch.callback()
 | |
| 
 | |
|         cache = self._extensions_cache[query].cache[page]
 | |
|         if cache is None:
 | |
|             cache = {}
 | |
|         extensions = list(cache.values())
 | |
|         if query is not None and self._listings_cache is not None:
 | |
|             listing = list(self._listings_cache)
 | |
|             extensions = []
 | |
|             if self._listings_block_mode:
 | |
|                 for name, ext in cache.items():
 | |
|                     if name not in listing:
 | |
|                         extensions.append(replace(ext, allowed=True))
 | |
|                     elif ext.installed_version:
 | |
|                         self.log.warning(f"Blocked extension '{name}' is installed.")
 | |
|                         extensions.append(replace(ext, allowed=False))
 | |
|             else:
 | |
|                 for name, ext in cache.items():
 | |
|                     if name in listing:
 | |
|                         extensions.append(replace(ext, allowed=True))
 | |
|                     elif ext.installed_version:
 | |
|                         self.log.warning(f"Not allowed extension '{name}' is installed.")
 | |
|                         extensions.append(replace(ext, allowed=False))
 | |
| 
 | |
|         return extensions, self._extensions_cache[query].last_page
 | |
| 
 | |
|     async def refresh(self, query: Optional[str], page: int, per_page: int) -> None:
 | |
|         """Refresh the list of extensions."""
 | |
|         if query in self._extensions_cache:
 | |
|             self._extensions_cache[query].cache[page] = None
 | |
|         await self._update_extensions_list(query, page, per_page)
 | |
| 
 | |
|     async def _fetch_listings(self) -> None:
 | |
|         """Fetch the listings for the extension manager."""
 | |
|         rules = []
 | |
|         client = tornado.httpclient.AsyncHTTPClient()
 | |
|         if self._listings_block_mode:
 | |
|             if len(self.options.blocked_extensions_uris):
 | |
|                 self.log.info(
 | |
|                     f"Fetching blocked extensions from {self.options.blocked_extensions_uris}"
 | |
|                 )
 | |
|                 for blocked_extensions_uri in self.options.blocked_extensions_uris:
 | |
|                     r = await client.fetch(
 | |
|                         blocked_extensions_uri,
 | |
|                         **self.options.listings_tornado_options,
 | |
|                     )
 | |
|                     j = json.loads(r.body)
 | |
|                     rules.extend(j.get("blocked_extensions", []))
 | |
|         elif len(self.options.allowed_extensions_uris):
 | |
|             self.log.info(
 | |
|                 f"Fetching allowed extensions from {self.options.allowed_extensions_uris}"
 | |
|             )
 | |
|             for allowed_extensions_uri in self.options.allowed_extensions_uris:
 | |
|                 r = await client.fetch(
 | |
|                     allowed_extensions_uri,
 | |
|                     **self.options.listings_tornado_options,
 | |
|                 )
 | |
|                 j = json.loads(r.body)
 | |
|                 rules.extend(j.get("allowed_extensions", []))
 | |
| 
 | |
|         self._listings_cache = {r["name"]: r for r in rules}
 | |
| 
 | |
|     async def _get_installed_extensions(
 | |
|         self, get_latest_version=True
 | |
|     ) -> dict[str, ExtensionPackage]:
 | |
|         """Get the installed extensions.
 | |
| 
 | |
|         Args:
 | |
|             get_latest_version: Whether to fetch the latest extension version or not.
 | |
|         Returns:
 | |
|             The installed extensions as a mapping {name: metadata}
 | |
|         """
 | |
|         app_options = self.app_options
 | |
|         info = get_app_info(app_options=app_options)
 | |
|         build_check_info = _build_check_info(app_options)
 | |
|         _ensure_compat_errors(info, app_options)
 | |
|         extensions = {}
 | |
| 
 | |
|         # TODO: the three for-loops below can be run concurrently
 | |
|         for name, data in info["federated_extensions"].items():
 | |
|             status = "ok"
 | |
|             pkg_info = data
 | |
|             if info["compat_errors"].get(name, None):
 | |
|                 status = "error"
 | |
| 
 | |
|             normalized_name = self._normalize_name(name)
 | |
|             pkg = ExtensionPackage(
 | |
|                 name=normalized_name,
 | |
|                 description=pkg_info.get("description", ""),
 | |
|                 homepage_url=data.get("url", ""),
 | |
|                 enabled=(name not in info["disabled"]),
 | |
|                 core=False,
 | |
|                 latest_version=ExtensionManager.get_semver_version(data["version"]),
 | |
|                 installed=True,
 | |
|                 installed_version=ExtensionManager.get_semver_version(data["version"]),
 | |
|                 status=status,
 | |
|                 install=data.get("install", {}),
 | |
|                 pkg_type="prebuilt",
 | |
|                 companion=self._get_companion(data),
 | |
|                 author=data.get("author", {}).get("name", data.get("author")),
 | |
|                 license=data.get("license"),
 | |
|                 bug_tracker_url=data.get("bugs", {}).get("url"),
 | |
|                 repository_url=data.get("repository", {}).get("url", data.get("repository")),
 | |
|             )
 | |
| 
 | |
|             if get_latest_version:
 | |
|                 pkg = replace(pkg, latest_version=await self.get_latest_version(pkg.name))
 | |
| 
 | |
|             extensions[normalized_name] = pkg
 | |
| 
 | |
|         for name, data in info["extensions"].items():
 | |
|             if name in info["shadowed_exts"]:
 | |
|                 continue
 | |
|             status = "ok"
 | |
| 
 | |
|             if info["compat_errors"].get(name, None):
 | |
|                 status = "error"
 | |
|             else:
 | |
|                 for packages in build_check_info.values():
 | |
|                     if name in packages:
 | |
|                         status = "warning"
 | |
| 
 | |
|             normalized_name = self._normalize_name(name)
 | |
|             pkg = ExtensionPackage(
 | |
|                 name=normalized_name,
 | |
|                 description=data.get("description", ""),
 | |
|                 homepage_url=data["url"],
 | |
|                 enabled=(name not in info["disabled"]),
 | |
|                 core=False,
 | |
|                 latest_version=ExtensionManager.get_semver_version(data["version"]),
 | |
|                 installed=True,
 | |
|                 installed_version=ExtensionManager.get_semver_version(data["version"]),
 | |
|                 status=status,
 | |
|                 pkg_type="source",
 | |
|                 companion=self._get_companion(data),
 | |
|                 author=data.get("author", {}).get("name", data.get("author")),
 | |
|                 license=data.get("license"),
 | |
|                 bug_tracker_url=data.get("bugs", {}).get("url"),
 | |
|                 repository_url=data.get("repository", {}).get("url", data.get("repository")),
 | |
|             )
 | |
|             if get_latest_version:
 | |
|                 pkg = replace(pkg, latest_version=await self.get_latest_version(pkg.name))
 | |
|             extensions[normalized_name] = pkg
 | |
| 
 | |
|         for name in build_check_info["uninstall"]:
 | |
|             data = self._get_scheduled_uninstall_info(name)
 | |
|             if data is not None:
 | |
|                 normalized_name = self._normalize_name(name)
 | |
|                 pkg = ExtensionPackage(
 | |
|                     name=normalized_name,
 | |
|                     description=data.get("description", ""),
 | |
|                     homepage_url=data.get("homepage", ""),
 | |
|                     installed=False,
 | |
|                     enabled=False,
 | |
|                     core=False,
 | |
|                     latest_version=ExtensionManager.get_semver_version(data["version"]),
 | |
|                     installed_version=ExtensionManager.get_semver_version(data["version"]),
 | |
|                     status="warning",
 | |
|                     pkg_type="prebuilt",
 | |
|                     author=data.get("author", {}).get("name", data.get("author")),
 | |
|                     license=data.get("license"),
 | |
|                     bug_tracker_url=data.get("bugs", {}).get("url"),
 | |
|                     repository_url=data.get("repository", {}).get("url", data.get("repository")),
 | |
|                 )
 | |
|                 extensions[normalized_name] = pkg
 | |
| 
 | |
|         return extensions
 | |
| 
 | |
|     def _get_companion(self, data: dict) -> Optional[str]:
 | |
|         companion = None
 | |
|         if "discovery" in data["jupyterlab"]:
 | |
|             if "server" in data["jupyterlab"]["discovery"]:
 | |
|                 companion = "server"
 | |
|             elif "kernel" in data["jupyterlab"]["discovery"]:
 | |
|                 companion = "kernel"
 | |
|         return companion
 | |
| 
 | |
|     def _get_scheduled_uninstall_info(self, name) -> Optional[dict]:
 | |
|         """Get information about a package that is scheduled for uninstallation"""
 | |
|         target = self.app_dir / "staging" / "node_modules" / name / "package.json"
 | |
|         if target.exists():
 | |
|             with target.open() as fid:
 | |
|                 return json.load(fid)
 | |
|         else:
 | |
|             return None
 | |
| 
 | |
|     def _normalize_name(self, name: str) -> str:
 | |
|         """Normalize extension name; by default does nothing.
 | |
| 
 | |
|         Args:
 | |
|             name: Extension name
 | |
|         Returns:
 | |
|             Normalized name
 | |
|         """
 | |
|         return name
 | |
| 
 | |
|     async def _update_extensions_list(
 | |
|         self, query: Optional[str] = None, page: int = 1, per_page: int = 30
 | |
|     ) -> None:
 | |
|         """Update the list of extensions"""
 | |
|         last_page = None
 | |
|         if query is not None:
 | |
|             # Get the available extensions
 | |
|             extensions, last_page = await self.list_packages(query, page, per_page)
 | |
|         else:
 | |
|             # Get the installed extensions
 | |
|             extensions = await self._get_installed_extensions()
 | |
| 
 | |
|         if query in self._extensions_cache:
 | |
|             self._extensions_cache[query].cache[page] = extensions
 | |
|             self._extensions_cache[query].last_page = last_page or 1
 | |
|         else:
 | |
|             self._extensions_cache[query] = ExtensionsCache({page: extensions}, last_page or 1)
 |