manager.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690
  1. """Base classes for the extension manager."""
  2. # Copyright (c) Jupyter Development Team.
  3. # Distributed under the terms of the Modified BSD License.
  4. import json
  5. import re
  6. from dataclasses import dataclass, field, fields, replace
  7. from pathlib import Path
  8. from typing import Optional, Union
  9. import tornado
  10. from jupyterlab_server.translation_utils import translator
  11. from traitlets import Enum
  12. from traitlets.config import Configurable, LoggingConfigurable
  13. from jupyterlab.commands import (
  14. _AppHandler,
  15. _ensure_options,
  16. disable_extension,
  17. enable_extension,
  18. get_app_info,
  19. )
  20. PYTHON_TO_SEMVER = {"a": "-alpha.", "b": "-beta.", "rc": "-rc."}
  21. def _ensure_compat_errors(info, app_options):
  22. """Ensure that the app info has compat_errors field"""
  23. handler = _AppHandler(app_options)
  24. info["compat_errors"] = handler._get_extension_compat()
  25. _message_map = {
  26. "install": re.compile(r"(?P<name>.*) needs to be included in build"),
  27. "uninstall": re.compile(r"(?P<name>.*) needs to be removed from build"),
  28. "update": re.compile(r"(?P<name>.*) changed from (?P<oldver>.*) to (?P<newver>.*)"),
  29. }
  30. def _build_check_info(app_options):
  31. """Get info about packages scheduled for (un)install/update"""
  32. handler = _AppHandler(app_options)
  33. messages = handler.build_check(fast=True)
  34. # Decode the messages into a dict:
  35. status = {"install": [], "uninstall": [], "update": []}
  36. for msg in messages:
  37. for key, pattern in _message_map.items():
  38. match = pattern.match(msg)
  39. if match:
  40. status[key].append(match.group("name"))
  41. return status
  42. @dataclass(frozen=True)
  43. class ExtensionPackage:
  44. """Extension package entry.
  45. Attributes:
  46. name: Package name
  47. description: Package description
  48. homepage_url: Package home page
  49. pkg_type: Type of package - ["prebuilt", "source"]
  50. allowed: [optional] Whether this extension is allowed or not - default True
  51. approved: [optional] Whether the package is approved by your administrators - default False
  52. companion: [optional] Type of companion for the frontend extension - [None, "kernel", "server"]; default None
  53. core: [optional] Whether the package is a core package or not - default False
  54. enabled: [optional] Whether the package is enabled or not - default False
  55. install: [optional] Extension package installation instructions - default None
  56. installed: [optional] Whether the extension is currently installed - default None
  57. installed_version: [optional] Installed version - default ""
  58. latest_version: [optional] Latest available version - default ""
  59. status: [optional] Package status - ["ok", "warning", "error"]; default "ok"
  60. author: [optional] Package author - default None
  61. license: [optional] Package license - default None
  62. bug_tracker_url: [optional] Package bug tracker URL - default None
  63. documentation_url: [optional] Package documentation URL - default None
  64. package_manager_url: Package home page in the package manager - default None
  65. repository_url: [optional] Package code repository URL - default None
  66. """
  67. name: str
  68. description: str
  69. homepage_url: str
  70. pkg_type: str
  71. allowed: bool = True
  72. approved: bool = False
  73. companion: Optional[str] = None
  74. core: bool = False
  75. enabled: bool = False
  76. install: Optional[dict] = None
  77. installed: Optional[bool] = None
  78. installed_version: str = ""
  79. latest_version: str = ""
  80. status: str = "ok"
  81. author: Optional[str] = None
  82. license: Optional[str] = None
  83. bug_tracker_url: Optional[str] = None
  84. documentation_url: Optional[str] = None
  85. package_manager_url: Optional[str] = None
  86. repository_url: Optional[str] = None
  87. @dataclass(frozen=True)
  88. class ActionResult:
  89. """Action result
  90. Attributes:
  91. status: Action status - ["ok", "warning", "error"]
  92. message: Action status explanation
  93. needs_restart: Required action follow-up - Valid follow-up are "frontend", "kernel" and "server"
  94. """
  95. # Note: no simple way to use Enum in dataclass - https://stackoverflow.com/questions/72859557/typing-dataclass-that-can-only-take-enum-values
  96. # keeping str for simplicity
  97. status: str
  98. message: Optional[str] = None
  99. needs_restart: list[str] = field(default_factory=list)
  100. @dataclass(frozen=True)
  101. class PluginManagerOptions:
  102. """Plugin manager options.
  103. Attributes:
  104. lock_all: Whether to lock (prevent enabling/disabling) all plugins.
  105. lock_rules: A list of plugins or extensions that cannot be toggled.
  106. If extension name is provided, all its plugins will be disabled.
  107. The plugin names need to follow colon-separated format of `extension:plugin`.
  108. """
  109. lock_rules: frozenset[str] = field(default_factory=frozenset)
  110. lock_all: bool = False
  111. @dataclass(frozen=True)
  112. class ExtensionManagerOptions(PluginManagerOptions):
  113. """Extension manager options.
  114. Attributes:
  115. allowed_extensions_uris: A list of comma-separated URIs to get the allowed extensions list
  116. blocked_extensions_uris: A list of comma-separated URIs to get the blocked extensions list
  117. listings_refresh_seconds: The interval delay in seconds to refresh the lists
  118. 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
  119. """
  120. allowed_extensions_uris: set[str] = field(default_factory=set)
  121. blocked_extensions_uris: set[str] = field(default_factory=set)
  122. listings_refresh_seconds: int = 60 * 60
  123. listings_tornado_options: dict = field(default_factory=dict)
  124. @dataclass(frozen=True)
  125. class ExtensionManagerMetadata:
  126. """Extension manager metadata.
  127. Attributes:
  128. name: Extension manager name to be displayed
  129. can_install: Whether the extension manager can un-/install packages (default False)
  130. install_path: Installation path for the extensions (default None); e.g. environment path
  131. """
  132. name: str
  133. can_install: bool = False
  134. install_path: Optional[str] = None
  135. @dataclass
  136. class ExtensionsCache:
  137. """Extensions cache
  138. Attributes:
  139. cache: Extension list per page
  140. last_page: Last available page result
  141. """
  142. cache: dict[int, Optional[dict[str, ExtensionPackage]]] = field(default_factory=dict)
  143. last_page: int = 1
  144. class PluginManager(LoggingConfigurable):
  145. """Plugin manager enables or disables plugins unless locked.
  146. It can also disable/enable all plugins in an extension.
  147. Args:
  148. app_options: Application options
  149. ext_options: Plugin manager (subset of extension manager) options
  150. parent: Configurable parent
  151. Attributes:
  152. app_options: Application options
  153. options: Plugin manager options
  154. """
  155. level = Enum(
  156. values=["sys_prefix", "user", "system"],
  157. default_value="sys_prefix",
  158. help="Level at which to manage plugins: sys_prefix, user, system",
  159. ).tag(config=True)
  160. def __init__(
  161. self,
  162. app_options: Optional[dict] = None,
  163. ext_options: Optional[dict] = None,
  164. parent: Optional[Configurable] = None,
  165. ) -> None:
  166. super().__init__(parent=parent)
  167. self.log.debug(
  168. f"Plugins in {self.__class__.__name__} will managed on the {self.level} level"
  169. )
  170. self.app_options = _ensure_options(app_options)
  171. plugin_options_field = {f.name for f in fields(PluginManagerOptions)}
  172. plugin_options = {
  173. option: value
  174. for option, value in (ext_options or {}).items()
  175. if option in plugin_options_field
  176. }
  177. self.options = PluginManagerOptions(**plugin_options)
  178. async def plugin_locks(self) -> dict:
  179. """Get information about locks on plugin enabling/disabling"""
  180. return {
  181. "lockRules": list(self.options.lock_rules),
  182. "allLocked": self.options.lock_all,
  183. }
  184. def _find_locked(self, plugins_or_extensions: list[str]) -> frozenset[str]:
  185. """Find a subset of plugins (or extensions) which are locked"""
  186. if self.options.lock_all:
  187. return set(plugins_or_extensions)
  188. locked_subset = set()
  189. extensions_with_locked_plugins = {
  190. plugin.split(":")[0] for plugin in self.options.lock_rules
  191. }
  192. for plugin in plugins_or_extensions:
  193. if ":" in plugin:
  194. # check directly if this is a plugin identifier (has colon)
  195. if plugin in self.options.lock_rules:
  196. locked_subset.add(plugin)
  197. elif plugin in extensions_with_locked_plugins:
  198. # this is an extension - we need to check for >any< plugin
  199. # belonging to said extension
  200. locked_subset.add(plugin)
  201. return locked_subset
  202. async def disable(self, plugins: Union[str, list[str]]) -> ActionResult:
  203. """Disable a set of plugins (or an extension).
  204. Args:
  205. plugins: The list of plugins to disable
  206. Returns:
  207. The action result
  208. """
  209. plugins = plugins if isinstance(plugins, list) else [plugins]
  210. locked = self._find_locked(plugins)
  211. trans = translator.load("jupyterlab")
  212. if locked:
  213. return ActionResult(
  214. status="error",
  215. message=trans.gettext(
  216. "The following plugins cannot be disabled as they are locked: "
  217. )
  218. + ", ".join(locked),
  219. )
  220. try:
  221. for plugin in plugins:
  222. disable_extension(plugin, app_options=self.app_options, level=self.level)
  223. return ActionResult(status="ok", needs_restart=["frontend"])
  224. except Exception as err:
  225. return ActionResult(status="error", message=repr(err))
  226. async def enable(self, plugins: Union[str, list[str]]) -> ActionResult:
  227. """Enable a set of plugins (or an extension).
  228. Args:
  229. plugins: The list of plugins to enable
  230. Returns:
  231. The action result
  232. """
  233. plugins = plugins if isinstance(plugins, list) else [plugins]
  234. locked = self._find_locked(plugins)
  235. trans = translator.load("jupyterlab")
  236. if locked:
  237. return ActionResult(
  238. status="error",
  239. message=trans.gettext(
  240. "The following plugins cannot be enabled as they are locked: "
  241. )
  242. + ", ".join(locked),
  243. )
  244. try:
  245. for plugin in plugins:
  246. enable_extension(plugin, app_options=self.app_options, level=self.level)
  247. return ActionResult(status="ok", needs_restart=["frontend"])
  248. except Exception as err:
  249. return ActionResult(status="error", message=repr(err))
  250. class ExtensionManager(PluginManager):
  251. """Base abstract extension manager.
  252. Note:
  253. Any concrete implementation will need to implement the five
  254. following abstract methods:
  255. - :ref:`metadata`
  256. - :ref:`get_latest_version`
  257. - :ref:`list_packages`
  258. - :ref:`install`
  259. - :ref:`uninstall`
  260. It could be interesting to override the :ref:`get_normalized_name`
  261. method too.
  262. Args:
  263. app_options: Application options
  264. ext_options: Extension manager options
  265. parent: Configurable parent
  266. Attributes:
  267. log: Logger
  268. app_dir: Application directory
  269. core_config: Core configuration
  270. app_options: Application options
  271. options: Extension manager options
  272. """
  273. def __init__(
  274. self,
  275. app_options: Optional[dict] = None,
  276. ext_options: Optional[dict] = None,
  277. parent: Optional[Configurable] = None,
  278. ) -> None:
  279. super().__init__(app_options=app_options, ext_options=ext_options, parent=parent)
  280. self.log = self.app_options.logger
  281. self.app_dir = Path(self.app_options.app_dir)
  282. self.core_config = self.app_options.core_config
  283. self.options = ExtensionManagerOptions(**(ext_options or {}))
  284. self._extensions_cache: dict[Optional[str], ExtensionsCache] = {}
  285. self._listings_cache: Optional[dict] = None
  286. self._listings_block_mode = True
  287. self._listing_fetch: Optional[tornado.ioloop.PeriodicCallback] = None
  288. if len(self.options.allowed_extensions_uris) or len(self.options.blocked_extensions_uris):
  289. self._listings_block_mode = len(self.options.allowed_extensions_uris) == 0
  290. if not self._listings_block_mode and len(self.options.blocked_extensions_uris) > 0:
  291. self.log.warning(
  292. "You have define simultaneously blocked and allowed extensions listings. The allowed listing will take precedence."
  293. )
  294. self._listing_fetch = tornado.ioloop.PeriodicCallback(
  295. self._fetch_listings,
  296. callback_time=self.options.listings_refresh_seconds * 1000,
  297. jitter=0.1,
  298. )
  299. self._listing_fetch.start()
  300. def __del__(self):
  301. if self._listing_fetch is not None:
  302. self._listing_fetch.stop()
  303. @property
  304. def metadata(self) -> ExtensionManagerMetadata:
  305. """Extension manager metadata."""
  306. raise NotImplementedError()
  307. async def get_latest_version(self, extension: str) -> Optional[str]:
  308. """Return the latest available version for a given extension.
  309. Args:
  310. pkg: The extension name
  311. Returns:
  312. The latest available version
  313. """
  314. raise NotImplementedError()
  315. async def list_packages(
  316. self, query: str, page: int, per_page: int
  317. ) -> tuple[dict[str, ExtensionPackage], Optional[int]]:
  318. """List the available extensions.
  319. Args:
  320. query: The search extension query
  321. page: The result page
  322. per_page: The number of results per page
  323. Returns:
  324. The available extensions in a mapping {name: metadata}
  325. The results last page; None if the manager does not support pagination
  326. """
  327. raise NotImplementedError()
  328. async def install(self, extension: str, version: Optional[str] = None) -> ActionResult:
  329. """Install the required extension.
  330. Note:
  331. If the user must be notified with a message (like asking to restart the
  332. server), the result should be
  333. {"status": "warning", "message": "<explanation for the user>"}
  334. Args:
  335. extension: The extension name
  336. version: The version to install; default None (i.e. the latest possible)
  337. Returns:
  338. The action result
  339. """
  340. raise NotImplementedError()
  341. async def uninstall(self, extension: str) -> ActionResult:
  342. """Uninstall the required extension.
  343. Note:
  344. If the user must be notified with a message (like asking to restart the
  345. server), the result should be
  346. {"status": "warning", "message": "<explanation for the user>"}
  347. Args:
  348. extension: The extension name
  349. Returns:
  350. The action result
  351. """
  352. raise NotImplementedError()
  353. @staticmethod
  354. def get_semver_version(version: str) -> str:
  355. """Convert a Python version to Semver version.
  356. It:
  357. - drops ``.devN`` and ``.postN``
  358. - converts ``aN``, ``bN`` and ``rcN`` to ``-alpha.N``, ``-beta.N``, ``-rc.N`` respectively
  359. Args:
  360. version: Version to convert
  361. Returns
  362. Semver compatible version
  363. """
  364. return re.sub(
  365. r"(a|b|rc)(\d+)$",
  366. lambda m: f"{PYTHON_TO_SEMVER[m.group(1)]}{m.group(2)}",
  367. re.subn(r"\.(dev|post)\d+", "", version)[0],
  368. )
  369. def get_normalized_name(self, extension: ExtensionPackage) -> str:
  370. """Normalize extension name.
  371. Extension have multiple parts, npm package, Python package,...
  372. Sub-classes may override this method to ensure the name of
  373. an extension from the service provider and the local installed
  374. listing is matching.
  375. Args:
  376. extension: The extension metadata
  377. Returns:
  378. The normalized name
  379. """
  380. return extension.name
  381. async def list_extensions(
  382. self, query: Optional[str] = None, page: int = 1, per_page: int = 30
  383. ) -> tuple[list[ExtensionPackage], Optional[int]]:
  384. """List extensions for a given ``query`` search term.
  385. This will return the extensions installed (if ``query`` is None) or
  386. available if allowed by the listing settings.
  387. Args:
  388. query: [optional] Query search term.
  389. Returns:
  390. The extensions
  391. Last page of results
  392. """
  393. if query not in self._extensions_cache or page not in self._extensions_cache[query].cache:
  394. await self.refresh(query, page, per_page)
  395. # filter using listings settings
  396. if self._listings_cache is None and self._listing_fetch is not None:
  397. await self._listing_fetch.callback()
  398. cache = self._extensions_cache[query].cache[page]
  399. if cache is None:
  400. cache = {}
  401. extensions = list(cache.values())
  402. if query is not None and self._listings_cache is not None:
  403. listing = list(self._listings_cache)
  404. extensions = []
  405. if self._listings_block_mode:
  406. for name, ext in cache.items():
  407. if name not in listing:
  408. extensions.append(replace(ext, allowed=True))
  409. elif ext.installed_version:
  410. self.log.warning(f"Blocked extension '{name}' is installed.")
  411. extensions.append(replace(ext, allowed=False))
  412. else:
  413. for name, ext in cache.items():
  414. if name in listing:
  415. extensions.append(replace(ext, allowed=True))
  416. elif ext.installed_version:
  417. self.log.warning(f"Not allowed extension '{name}' is installed.")
  418. extensions.append(replace(ext, allowed=False))
  419. return extensions, self._extensions_cache[query].last_page
  420. async def refresh(self, query: Optional[str], page: int, per_page: int) -> None:
  421. """Refresh the list of extensions."""
  422. if query in self._extensions_cache:
  423. self._extensions_cache[query].cache[page] = None
  424. await self._update_extensions_list(query, page, per_page)
  425. async def _fetch_listings(self) -> None:
  426. """Fetch the listings for the extension manager."""
  427. rules = []
  428. client = tornado.httpclient.AsyncHTTPClient()
  429. if self._listings_block_mode:
  430. if len(self.options.blocked_extensions_uris):
  431. self.log.info(
  432. f"Fetching blocked extensions from {self.options.blocked_extensions_uris}"
  433. )
  434. for blocked_extensions_uri in self.options.blocked_extensions_uris:
  435. r = await client.fetch(
  436. blocked_extensions_uri,
  437. **self.options.listings_tornado_options,
  438. )
  439. j = json.loads(r.body)
  440. rules.extend(j.get("blocked_extensions", []))
  441. elif len(self.options.allowed_extensions_uris):
  442. self.log.info(
  443. f"Fetching allowed extensions from {self.options.allowed_extensions_uris}"
  444. )
  445. for allowed_extensions_uri in self.options.allowed_extensions_uris:
  446. r = await client.fetch(
  447. allowed_extensions_uri,
  448. **self.options.listings_tornado_options,
  449. )
  450. j = json.loads(r.body)
  451. rules.extend(j.get("allowed_extensions", []))
  452. self._listings_cache = {r["name"]: r for r in rules}
  453. async def _get_installed_extensions(
  454. self, get_latest_version=True
  455. ) -> dict[str, ExtensionPackage]:
  456. """Get the installed extensions.
  457. Args:
  458. get_latest_version: Whether to fetch the latest extension version or not.
  459. Returns:
  460. The installed extensions as a mapping {name: metadata}
  461. """
  462. app_options = self.app_options
  463. info = get_app_info(app_options=app_options)
  464. build_check_info = _build_check_info(app_options)
  465. _ensure_compat_errors(info, app_options)
  466. extensions = {}
  467. # TODO: the three for-loops below can be run concurrently
  468. for name, data in info["federated_extensions"].items():
  469. status = "ok"
  470. pkg_info = data
  471. if info["compat_errors"].get(name, None):
  472. status = "error"
  473. normalized_name = self._normalize_name(name)
  474. pkg = ExtensionPackage(
  475. name=normalized_name,
  476. description=pkg_info.get("description", ""),
  477. homepage_url=data.get("url", ""),
  478. enabled=(name not in info["disabled"]),
  479. core=False,
  480. latest_version=ExtensionManager.get_semver_version(data["version"]),
  481. installed=True,
  482. installed_version=ExtensionManager.get_semver_version(data["version"]),
  483. status=status,
  484. install=data.get("install", {}),
  485. pkg_type="prebuilt",
  486. companion=self._get_companion(data),
  487. author=data.get("author", {}).get("name", data.get("author")),
  488. license=data.get("license"),
  489. bug_tracker_url=data.get("bugs", {}).get("url"),
  490. repository_url=data.get("repository", {}).get("url", data.get("repository")),
  491. )
  492. if get_latest_version:
  493. pkg = replace(pkg, latest_version=await self.get_latest_version(pkg.name))
  494. extensions[normalized_name] = pkg
  495. for name, data in info["extensions"].items():
  496. if name in info["shadowed_exts"]:
  497. continue
  498. status = "ok"
  499. if info["compat_errors"].get(name, None):
  500. status = "error"
  501. else:
  502. for packages in build_check_info.values():
  503. if name in packages:
  504. status = "warning"
  505. normalized_name = self._normalize_name(name)
  506. pkg = ExtensionPackage(
  507. name=normalized_name,
  508. description=data.get("description", ""),
  509. homepage_url=data["url"],
  510. enabled=(name not in info["disabled"]),
  511. core=False,
  512. latest_version=ExtensionManager.get_semver_version(data["version"]),
  513. installed=True,
  514. installed_version=ExtensionManager.get_semver_version(data["version"]),
  515. status=status,
  516. pkg_type="source",
  517. companion=self._get_companion(data),
  518. author=data.get("author", {}).get("name", data.get("author")),
  519. license=data.get("license"),
  520. bug_tracker_url=data.get("bugs", {}).get("url"),
  521. repository_url=data.get("repository", {}).get("url", data.get("repository")),
  522. )
  523. if get_latest_version:
  524. pkg = replace(pkg, latest_version=await self.get_latest_version(pkg.name))
  525. extensions[normalized_name] = pkg
  526. for name in build_check_info["uninstall"]:
  527. data = self._get_scheduled_uninstall_info(name)
  528. if data is not None:
  529. normalized_name = self._normalize_name(name)
  530. pkg = ExtensionPackage(
  531. name=normalized_name,
  532. description=data.get("description", ""),
  533. homepage_url=data.get("homepage", ""),
  534. installed=False,
  535. enabled=False,
  536. core=False,
  537. latest_version=ExtensionManager.get_semver_version(data["version"]),
  538. installed_version=ExtensionManager.get_semver_version(data["version"]),
  539. status="warning",
  540. pkg_type="prebuilt",
  541. author=data.get("author", {}).get("name", data.get("author")),
  542. license=data.get("license"),
  543. bug_tracker_url=data.get("bugs", {}).get("url"),
  544. repository_url=data.get("repository", {}).get("url", data.get("repository")),
  545. )
  546. extensions[normalized_name] = pkg
  547. return extensions
  548. def _get_companion(self, data: dict) -> Optional[str]:
  549. companion = None
  550. if "discovery" in data["jupyterlab"]:
  551. if "server" in data["jupyterlab"]["discovery"]:
  552. companion = "server"
  553. elif "kernel" in data["jupyterlab"]["discovery"]:
  554. companion = "kernel"
  555. return companion
  556. def _get_scheduled_uninstall_info(self, name) -> Optional[dict]:
  557. """Get information about a package that is scheduled for uninstallation"""
  558. target = self.app_dir / "staging" / "node_modules" / name / "package.json"
  559. if target.exists():
  560. with target.open() as fid:
  561. return json.load(fid)
  562. else:
  563. return None
  564. def _normalize_name(self, name: str) -> str:
  565. """Normalize extension name; by default does nothing.
  566. Args:
  567. name: Extension name
  568. Returns:
  569. Normalized name
  570. """
  571. return name
  572. async def _update_extensions_list(
  573. self, query: Optional[str] = None, page: int = 1, per_page: int = 30
  574. ) -> None:
  575. """Update the list of extensions"""
  576. last_page = None
  577. if query is not None:
  578. # Get the available extensions
  579. extensions, last_page = await self.list_packages(query, page, per_page)
  580. else:
  581. # Get the installed extensions
  582. extensions = await self._get_installed_extensions()
  583. if query in self._extensions_cache:
  584. self._extensions_cache[query].cache[page] = extensions
  585. self._extensions_cache[query].last_page = last_page or 1
  586. else:
  587. self._extensions_cache[query] = ExtensionsCache({page: extensions}, last_page or 1)