pypi.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. # Copyright (c) Jupyter Development Team.
  2. # Distributed under the terms of the Modified BSD License.
  3. """Extension manager using pip as package manager and PyPi.org as packages source."""
  4. import asyncio
  5. import http.client
  6. import io
  7. import json
  8. import math
  9. import re
  10. import sys
  11. import tempfile
  12. import xmlrpc.client
  13. from datetime import datetime, timedelta, timezone
  14. from functools import partial
  15. from itertools import groupby
  16. from os import environ
  17. from pathlib import Path
  18. from subprocess import CalledProcessError, run
  19. from tarfile import TarFile
  20. from typing import Any, Callable, Optional
  21. from urllib.parse import urlparse
  22. from zipfile import ZipFile
  23. import httpx
  24. import tornado
  25. from async_lru import alru_cache
  26. from packaging.version import Version
  27. from traitlets import CFloat, CInt, Unicode, config, observe
  28. from jupyterlab._version import __version__
  29. from jupyterlab.extensions.manager import (
  30. ActionResult,
  31. ExtensionManager,
  32. ExtensionManagerMetadata,
  33. ExtensionPackage,
  34. )
  35. class ProxiedTransport(xmlrpc.client.Transport):
  36. def set_proxy(self, host, port=None, headers=None):
  37. self.proxy = host, port
  38. self.proxy_headers = headers
  39. def make_connection(self, host):
  40. connection = http.client.HTTPConnection(*self.proxy)
  41. connection.set_tunnel(host, headers=self.proxy_headers)
  42. self._connection = host, connection
  43. return connection
  44. all_proxy_url = environ.get("ALL_PROXY")
  45. # For historical reasons, we also support the lowercase environment variables.
  46. # Info: https://about.gitlab.com/blog/2021/01/27/we-need-to-talk-no-proxy/
  47. http_proxy_url = environ.get("http_proxy") or environ.get("HTTP_PROXY") or all_proxy_url
  48. https_proxy_url = (
  49. environ.get("https_proxy") or environ.get("HTTPS_PROXY") or http_proxy_url or all_proxy_url
  50. )
  51. # sniff ``httpx`` version for version-sensitive API
  52. _httpx_version = Version(httpx.__version__)
  53. _httpx_client_args = {}
  54. xmlrpc_transport_override = None
  55. if http_proxy_url:
  56. http_proxy = urlparse(http_proxy_url)
  57. proxy_host, _, proxy_port = http_proxy.netloc.partition(":")
  58. if _httpx_version >= Version("0.28.0"):
  59. _httpx_client_args = {
  60. "mounts": {
  61. "http://": httpx.AsyncHTTPTransport(proxy=http_proxy_url),
  62. "https://": httpx.AsyncHTTPTransport(proxy=https_proxy_url),
  63. }
  64. }
  65. else:
  66. _httpx_client_args = {
  67. "proxies": {
  68. "http://": http_proxy_url,
  69. "https://": https_proxy_url,
  70. }
  71. }
  72. xmlrpc_transport_override = ProxiedTransport()
  73. xmlrpc_transport_override.set_proxy(proxy_host, proxy_port)
  74. async def _fetch_package_metadata(
  75. client: httpx.AsyncClient,
  76. name: str,
  77. latest_version: str,
  78. base_url: str,
  79. ) -> dict:
  80. response = await client.get(
  81. base_url + f"/{name}/{latest_version}/json",
  82. headers={"Content-Type": "application/json"},
  83. )
  84. if response.status_code < 400: # noqa PLR2004
  85. data = json.loads(response.text).get("info")
  86. # Keep minimal information to limit cache size
  87. return {
  88. k: data.get(k)
  89. for k in [
  90. "author",
  91. "bugtrack_url",
  92. "docs_url",
  93. "home_page",
  94. "license",
  95. "package_url",
  96. "project_url",
  97. "project_urls",
  98. "summary",
  99. ]
  100. }
  101. else:
  102. return {}
  103. class PyPIExtensionManager(ExtensionManager):
  104. """Extension manager using pip as package manager and PyPi.org as packages source."""
  105. base_url = Unicode("https://pypi.org/pypi", config=True, help="The base URL of PyPI index.")
  106. cache_timeout = CFloat(
  107. 5 * 60.0, config=True, help="PyPI extensions list cache timeout in seconds."
  108. )
  109. package_metadata_cache_size = CInt(
  110. 1500, config=True, help="The cache size for package metadata."
  111. )
  112. rpc_request_throttling = CFloat(
  113. 1.0,
  114. config=True,
  115. help="Throttling time in seconds between PyPI requests using the XML-RPC API.",
  116. )
  117. def __init__(
  118. self,
  119. app_options: Optional[dict] = None,
  120. ext_options: Optional[dict] = None,
  121. parent: Optional[config.Configurable] = None,
  122. ) -> None:
  123. super().__init__(app_options, ext_options, parent)
  124. self._httpx_client = httpx.AsyncClient(**_httpx_client_args)
  125. # Set configurable cache size to fetch function
  126. self._fetch_package_metadata = partial(_fetch_package_metadata, self._httpx_client)
  127. self._observe_package_metadata_cache_size({"new": self.package_metadata_cache_size})
  128. # Combine XML RPC API and JSON API to reduce throttling by PyPI.org
  129. self._rpc_client = xmlrpc.client.ServerProxy(
  130. self.base_url, transport=xmlrpc_transport_override
  131. )
  132. self.__last_all_packages_request_time = datetime.now(tz=timezone.utc) - timedelta(
  133. seconds=self.cache_timeout * 1.01
  134. )
  135. self.__all_packages_cache = None
  136. self.log.debug(f"Extensions list will be fetched from {self.base_url}.")
  137. if xmlrpc_transport_override:
  138. self.log.info(
  139. f"Extensions will be fetched using proxy, proxy host and port: {xmlrpc_transport_override.proxy}"
  140. )
  141. @property
  142. def metadata(self) -> ExtensionManagerMetadata:
  143. """Extension manager metadata."""
  144. return ExtensionManagerMetadata("PyPI", True, sys.prefix)
  145. async def get_latest_version(self, pkg: str) -> Optional[str]:
  146. """Return the latest available version for a given extension.
  147. Args:
  148. pkg: The extension to search for
  149. Returns:
  150. The latest available version
  151. """
  152. try:
  153. response = await self._httpx_client.get(
  154. self.base_url + f"/{pkg}/json", headers={"Content-Type": "application/json"}
  155. )
  156. if response.status_code < 400: # noqa PLR2004
  157. data = json.loads(response.content).get("info", {})
  158. else:
  159. self.log.debug(f"Failed to get package information on PyPI; {response!s}")
  160. return None
  161. except Exception:
  162. return None
  163. else:
  164. return ExtensionManager.get_semver_version(data.get("version", "")) or None
  165. def get_normalized_name(self, extension: ExtensionPackage) -> str:
  166. """Normalize extension name.
  167. Extension have multiple parts, npm package, Python package,...
  168. Sub-classes may override this method to ensure the name of
  169. an extension from the service provider and the local installed
  170. listing is matching.
  171. Args:
  172. extension: The extension metadata
  173. Returns:
  174. The normalized name
  175. """
  176. if extension.install is not None:
  177. install_metadata = extension.install
  178. if install_metadata["packageManager"] == "python":
  179. return self._normalize_name(install_metadata["packageName"])
  180. return self._normalize_name(extension.name)
  181. async def __throttleRequest(self, recursive: bool, fn: Callable, *args) -> Any: # noqa
  182. """Throttle XMLRPC API request
  183. Args:
  184. recursive: Whether to call the throttling recursively once or not.
  185. fn: API method to call
  186. *args: API method arguments
  187. Returns:
  188. Result of the method
  189. Raises:
  190. xmlrpc.client.Fault
  191. """
  192. current_loop = tornado.ioloop.IOLoop.current()
  193. try:
  194. data = await current_loop.run_in_executor(None, fn, *args)
  195. except xmlrpc.client.Fault as err:
  196. if err.faultCode == -32500 and err.faultString.startswith( # noqa PLR2004
  197. "HTTPTooManyRequests:"
  198. ):
  199. delay = 1.01
  200. match = re.search(r"Limit may reset in (\d+) seconds.", err.faultString)
  201. if match is not None:
  202. delay = int(match.group(1) or "1")
  203. self.log.info(
  204. f"HTTPTooManyRequests - Perform next call to PyPI XMLRPC API in {delay}s."
  205. )
  206. await asyncio.sleep(delay * self.rpc_request_throttling + 0.01)
  207. if recursive:
  208. data = await self.__throttleRequest(False, fn, *args)
  209. else:
  210. data = await current_loop.run_in_executor(None, fn, *args)
  211. return data
  212. @observe("package_metadata_cache_size")
  213. def _observe_package_metadata_cache_size(self, change):
  214. self._fetch_package_metadata = alru_cache(maxsize=change["new"])(
  215. partial(_fetch_package_metadata, self._httpx_client)
  216. )
  217. async def list_packages(
  218. self, query: str, page: int, per_page: int
  219. ) -> tuple[dict[str, ExtensionPackage], Optional[int]]:
  220. """List the available extensions.
  221. Note:
  222. This will list the packages based on the classifier
  223. Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt
  224. Then it filters it with the query and sorts by organization priority:
  225. 1. Project Jupyter (@jupyter)
  226. 2. JupyterLab Community (@jupyterlab-contrib)
  227. 3. Others
  228. Args:
  229. query: The search extension query
  230. page: The result page
  231. per_page: The number of results per page
  232. Returns:
  233. The available extensions in a mapping {name: metadata}
  234. The results last page; None if the manager does not support pagination
  235. """
  236. matches = await self.__get_all_extensions()
  237. extensions = {}
  238. all_matches = []
  239. for name, group in groupby(filter(lambda m: query in m[0], matches), lambda e: e[0]):
  240. _, latest_version = list(group)[-1]
  241. data = await self._fetch_package_metadata(name, latest_version, self.base_url)
  242. normalized_name = self._normalize_name(name)
  243. package_urls = data.get("project_urls") or {}
  244. source_url = package_urls.get("Source Code")
  245. homepage_url = data.get("home_page") or package_urls.get("Homepage")
  246. documentation_url = data.get("docs_url") or package_urls.get("Documentation")
  247. bug_tracker_url = data.get("bugtrack_url") or package_urls.get("Bug Tracker")
  248. best_guess_home_url = (
  249. homepage_url
  250. or data.get("project_url")
  251. or data.get("package_url")
  252. or documentation_url
  253. or source_url
  254. or bug_tracker_url
  255. )
  256. extension = ExtensionPackage(
  257. name=normalized_name,
  258. description=data.get("summary"),
  259. homepage_url=best_guess_home_url,
  260. author=data.get("author"),
  261. license=data.get("license"),
  262. latest_version=ExtensionManager.get_semver_version(latest_version),
  263. pkg_type="prebuilt",
  264. bug_tracker_url=bug_tracker_url,
  265. documentation_url=documentation_url,
  266. package_manager_url=data.get("package_url"),
  267. repository_url=source_url,
  268. )
  269. # Determine organization priority
  270. priority = 3 # Default priority for other packages
  271. urls_to_check = [
  272. str(url).lower() for url in [source_url, homepage_url, best_guess_home_url] if url
  273. ]
  274. exclude = [
  275. "https://github.com/jupyterlab/jupyterlab_apod",
  276. "https://github.com/jupyterlab/extension-examples",
  277. ]
  278. for url in urls_to_check:
  279. if url in exclude:
  280. priority = 4
  281. break
  282. if any(
  283. org in url
  284. for org in ["github.com/jupyter/", "jupyter.org", "github.com/jupyterlab/"]
  285. ):
  286. priority = 1
  287. break
  288. elif "github.com/jupyterlab-contrib/" in url:
  289. priority = 2
  290. break
  291. all_matches.append((priority, extension))
  292. sorted_matches = sorted(all_matches, key=lambda x: (x[0], x[1].name))
  293. # Apply pagination
  294. start_idx = (page - 1) * per_page
  295. end_idx = start_idx + per_page
  296. page_matches = sorted_matches[start_idx:end_idx]
  297. for _, extension in page_matches:
  298. extensions[extension.name] = extension
  299. total_pages = math.ceil(len(sorted_matches) / per_page)
  300. return extensions, total_pages
  301. async def __get_all_extensions(self) -> list[tuple[str, str]]:
  302. if self.__all_packages_cache is None or datetime.now(
  303. tz=timezone.utc
  304. ) > self.__last_all_packages_request_time + timedelta(seconds=self.cache_timeout):
  305. self.log.debug("Requesting PyPI.org RPC API for prebuilt JupyterLab extensions.")
  306. self.__all_packages_cache = await self.__throttleRequest(
  307. True,
  308. self._rpc_client.browse,
  309. ["Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt"],
  310. )
  311. self.__last_all_packages_request_time = datetime.now(tz=timezone.utc)
  312. return self.__all_packages_cache
  313. async def install(self, name: str, version: Optional[str] = None) -> ActionResult: # noqa
  314. """Install the required extension.
  315. Note:
  316. If the user must be notified with a message (like asking to restart the
  317. server), the result should be
  318. {"status": "warning", "message": "<explanation for the user>"}
  319. Args:
  320. name: The extension name
  321. version: The version to install; default None (i.e. the latest possible)
  322. Returns:
  323. The action result
  324. """
  325. current_loop = tornado.ioloop.IOLoop.current()
  326. with (
  327. tempfile.TemporaryDirectory() as ve_dir,
  328. tempfile.NamedTemporaryFile(mode="w+", dir=ve_dir, delete=False) as fconstraint,
  329. ):
  330. fconstraint.write(f"jupyterlab=={__version__}")
  331. fconstraint.flush()
  332. cmdline = [
  333. sys.executable,
  334. "-m",
  335. "pip",
  336. "install",
  337. "--no-input",
  338. "--quiet",
  339. "--progress-bar",
  340. "off",
  341. "--constraint",
  342. fconstraint.name,
  343. ]
  344. if version is not None:
  345. cmdline.append(f"{name}=={version}")
  346. else:
  347. cmdline.append(name)
  348. pkg_action = {}
  349. try:
  350. tmp_cmd = cmdline.copy()
  351. tmp_cmd.insert(-1, "--dry-run")
  352. tmp_cmd.insert(-1, "--report")
  353. tmp_cmd.insert(-1, "-")
  354. result = await current_loop.run_in_executor(
  355. None, partial(run, tmp_cmd, capture_output=True, check=True)
  356. )
  357. action_info = json.loads(result.stdout.decode("utf-8"))
  358. pkg_action = next(
  359. filter(
  360. lambda p: p.get("metadata", {}).get("name") == name.replace("_", "-"),
  361. action_info.get("install", []),
  362. )
  363. )
  364. except CalledProcessError as e:
  365. self.log.debug(f"Fail to get installation report: {e.stderr}", exc_info=e)
  366. except Exception as err:
  367. self.log.debug("Fail to get installation report.", exc_info=err)
  368. else:
  369. self.log.debug(f"Actions to be executed by pip {json.dumps(action_info)}.")
  370. self.log.debug(f"Executing '{' '.join(cmdline)}'")
  371. result = await current_loop.run_in_executor(
  372. None, partial(run, cmdline, capture_output=True)
  373. )
  374. self.log.debug(f"return code: {result.returncode}")
  375. self.log.debug(f"stdout: {result.stdout.decode('utf-8')}")
  376. error = result.stderr.decode("utf-8")
  377. if result.returncode == 0:
  378. self.log.debug(f"stderr: {error}")
  379. # Figure out if the package has server or kernel parts
  380. jlab_metadata = None
  381. try:
  382. download_url: str = pkg_action.get("download_info", {}).get("url")
  383. if download_url is not None:
  384. response = await self._httpx_client.get(download_url)
  385. if response.status_code < 400: # noqa PLR2004
  386. if download_url.endswith(".whl"):
  387. with ZipFile(io.BytesIO(response.content)) as wheel:
  388. for filename in filter(
  389. lambda f: Path(f).name == "package.json",
  390. wheel.namelist(),
  391. ):
  392. data = json.loads(wheel.read(filename))
  393. jlab_metadata = data.get("jupyterlab")
  394. if jlab_metadata is not None:
  395. break
  396. elif download_url.endswith("tar.gz"):
  397. with TarFile(io.BytesIO(response.content)) as sdist:
  398. for filename in filter(
  399. lambda f: Path(f).name == "package.json",
  400. sdist.getnames(),
  401. ):
  402. data = json.load(
  403. sdist.extractfile(sdist.getmember(filename))
  404. )
  405. jlab_metadata = data.get("jupyterlab")
  406. if jlab_metadata is not None:
  407. break
  408. else:
  409. self.log.debug(f"Failed to get '{download_url}'; {response!s}")
  410. except Exception as e:
  411. self.log.debug("Fail to get package.json.", exc_info=e)
  412. follow_ups = [
  413. "frontend",
  414. ]
  415. if jlab_metadata is not None:
  416. discovery = jlab_metadata.get("discovery", {})
  417. if "kernel" in discovery:
  418. follow_ups.append("kernel")
  419. if "server" in discovery:
  420. follow_ups.append("server")
  421. return ActionResult(status="ok", needs_restart=follow_ups)
  422. else:
  423. self.log.error(f"Failed to install {name}: code {result.returncode}\n{error}")
  424. return ActionResult(status="error", message=error)
  425. async def uninstall(self, extension: str) -> ActionResult:
  426. """Uninstall the required extension.
  427. Note:
  428. If the user must be notified with a message (like asking to restart the
  429. server), the result should be
  430. {"status": "warning", "message": "<explanation for the user>"}
  431. Args:
  432. extension: The extension name
  433. Returns:
  434. The action result
  435. """
  436. current_loop = tornado.ioloop.IOLoop.current()
  437. cmdline = [
  438. sys.executable,
  439. "-m",
  440. "pip",
  441. "uninstall",
  442. "--yes",
  443. "--no-input",
  444. extension,
  445. ]
  446. # Figure out if the package has server or kernel parts
  447. jlab_metadata = None
  448. try:
  449. tmp_cmd = cmdline.copy()
  450. tmp_cmd.remove("--yes")
  451. result = await current_loop.run_in_executor(
  452. None, partial(run, tmp_cmd, capture_output=True)
  453. )
  454. lines = filter(
  455. lambda line: line.endswith("package.json"),
  456. map(lambda line: line.strip(), result.stdout.decode("utf-8").splitlines()), # noqa
  457. )
  458. for filepath in filter(
  459. lambda f: f.name == "package.json",
  460. map(Path, lines),
  461. ):
  462. data = json.loads(filepath.read_bytes())
  463. jlab_metadata = data.get("jupyterlab")
  464. if jlab_metadata is not None:
  465. break
  466. except Exception as e:
  467. self.log.debug("Fail to list files to be uninstalled.", exc_info=e)
  468. self.log.debug(f"Executing '{' '.join(cmdline)}'")
  469. result = await current_loop.run_in_executor(
  470. None, partial(run, cmdline, capture_output=True)
  471. )
  472. self.log.debug(f"return code: {result.returncode}")
  473. self.log.debug(f"stdout: {result.stdout.decode('utf-8')}")
  474. error = result.stderr.decode("utf-8")
  475. if result.returncode == 0:
  476. self.log.debug(f"stderr: {error}")
  477. follow_ups = [
  478. "frontend",
  479. ]
  480. if jlab_metadata is not None:
  481. discovery = jlab_metadata.get("discovery", {})
  482. if "kernel" in discovery:
  483. follow_ups.append("kernel")
  484. if "server" in discovery:
  485. follow_ups.append("server")
  486. return ActionResult(status="ok", needs_restart=follow_ups)
  487. else:
  488. self.log.error(f"Failed to installed {extension}: code {result.returncode}\n{error}")
  489. return ActionResult(status="error", message=error)
  490. def _normalize_name(self, name: str) -> str:
  491. """Normalize extension name.
  492. Remove `@` from npm scope and replace `/` and `_` by `-`.
  493. Args:
  494. name: Extension name
  495. Returns:
  496. Normalized name
  497. """
  498. return name.replace("@", "").replace("/", "-").replace("_", "-")