diff --git a/envs_manager/__init__.py b/envs_manager/__init__.py index 40fbbb4..8aaa4d7 100644 --- a/envs_manager/__init__.py +++ b/envs_manager/__init__.py @@ -1,3 +1,13 @@ # SPDX-FileCopyrightText: 2022-present Spyder Development Team and envs-manager contributors # # SPDX-License-Identifier: MIT + + +def _jupyter_server_extension_points(): + """ + Returns a list of dictionaries with metadata describing + where to find the `_load_jupyter_server_extension` function. + """ + from envs_manager.jupyter import EnvManagerApp + + return [{"module": "envs_manager.jupyter", "app": EnvManagerApp}] diff --git a/envs_manager/backends/api.py b/envs_manager/backends/api.py index 39c1f89..d292f8d 100644 --- a/envs_manager/backends/api.py +++ b/envs_manager/backends/api.py @@ -4,12 +4,17 @@ from __future__ import annotations +import logging from pathlib import Path import subprocess from typing import TypedDict import requests + +logger = logging.getLogger("envs-manager") + + PYPI_API_PACKAGE_INFO_URL = "https://pypi.org/pypi/{package_name}/json" ANACONDA_API_PACKAGE_INFO = "https://api.anaconda.org/package/{channel}/{package_name}" @@ -183,3 +188,69 @@ def list_packages(self) -> BackendActionResult: def list_environments(self) -> BackendActionResult: raise NotImplementedError + + def create_kernelspec( + self, + name: str, + display_name: str | None = None, + profile: str | None = None, + prefix: str | None = None, + user: bool = True, + env: dict[str, str] | None = None, + frozen_modules: bool = False, + ) -> BackendActionResult: + """ + Create a Jupyter kernelspec for the environment. + + Parameters + ---------- + name : str + Name of the kernelspec. + display_name : str, optional + Display name of the kernelspec. If None, defaults to the environment name. + profile : str, optional + IPython profile to load. If None, defaults to the default profile. + prefix : str, optional + Install prefix for the kernelspec. If None, defaults to sys.prefix. + user : bool, optional + Install for the current user instead of system-wide. The default is True. + env : dict[str, str], optional + Environment variables to set for the kernel. The default is None. + frozen_modules : bool, optional + Enable frozen modules for potentially faster startup. The default is False. + + Returns + ------- + BackendActionResult + Result of the action. + """ + command = [self.python_executable_path, "-m", "ipykernel", "install"] + if user: + command.append("--user") + if name: + command.extend(["--name", name]) + if display_name: + command.extend(["--display-name", display_name]) + if profile: + command.extend(["--profile", profile]) + if prefix: + command.extend(["--prefix", prefix]) + if env: + for key, value in env.items(): + command.extend(["--env", f"{key}={value}"]) + if frozen_modules: + command.append("--frozen_modules") + + try: + result = run_command(command, capture_output=True) + output = result.stdout or result.stderr + logger.info(output.strip()) + except subprocess.CalledProcessError as error: + error_text = error.stderr.strip() + logger.error(error_text) + return BackendActionResult(status=False, output=error_text) + except Exception as error: + logger.error(error, exc_info=True) + return BackendActionResult(status=False, output=str(error)) + + return BackendActionResult(status=True, output=output.strip()) diff --git a/envs_manager/jupyter.py b/envs_manager/jupyter.py new file mode 100644 index 0000000..bb181c7 --- /dev/null +++ b/envs_manager/jupyter.py @@ -0,0 +1,90 @@ +# SPDX-FileCopyrightText: 2022-present Spyder Development Team and envs-manager contributors +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations +import json +import typing as t + +from traitlets import Unicode +from tornado import web +from jupyter_server.auth.decorator import authorized +from jupyter_server.extension.application import ExtensionApp +from jupyter_server.base.handlers import JupyterHandler + +from envs_manager.manager import ( + DEFAULT_BACKENDS_ROOT_PATH, + DEFAULT_BACKEND, + Manager, + ManagerActions, +) + + +class EnvManagerHandler(JupyterHandler): + """Handler to list available environments.""" + + _handler_action_regex = ( + rf"(?P{'|'.join(action.value for action in ManagerActions)})" + ) + + auth_resource = "envs_manager" + + def get_manager(self) -> Manager: + """Get the environment manager instance.""" + return Manager( + backend=self.get_argument("backend", None) + or self.settings["envs_manager_config"]["default_backend"], + root_path=self.settings["envs_manager_config"]["root_path"], + env_name=self.get_argument("env_name", None), + env_directory=self.get_argument("env_directory", None), + ) + + def get_options(self) -> dict[str, t.Any]: + return self.get_json_body() or {} + + def write_json(self, data, status=200): + self.set_status(status) + self.set_header("Content-Type", "application/json") + self.finish(json.dumps(data)) + + @authorized + @web.authenticated + def post(self, action: str): + try: + manager = self.get_manager() + action_options = self.get_options() + self.write_json( + manager.run_action(ManagerActions(action), action_options), + status=200, + ) + except Exception as e: + self.set_status(501) + self.finish(str(e)) + self.log_exception(type(e), e, e.__traceback__) + + +class EnvManagerApp(ExtensionApp): + """Jupyter extension for managing environments.""" + + name = "envs_manager" + extension_url = "/envs_manager" + open_browser = False + + root_path = Unicode( + str(DEFAULT_BACKENDS_ROOT_PATH), + config=True, + help="Root path for the extension. Defaults to the Jupyter server root path.", + ) + + default_backend = Unicode( + DEFAULT_BACKEND, + config=True, + help="Default backend to use for managing environments.", + ) + + handlers = [ + ( + rf"{extension_url}/{EnvManagerHandler._handler_action_regex}", + EnvManagerHandler, + ), + ] # type: ignore[list-item] diff --git a/envs_manager/manager.py b/envs_manager/manager.py index c155522..5d70a4e 100644 --- a/envs_manager/manager.py +++ b/envs_manager/manager.py @@ -6,6 +6,7 @@ import os from pathlib import Path from typing import TypedDict +from enum import Enum from envs_manager.backends.api import BackendActionResult, BackendInstance from envs_manager.backends.venv_interface import VEnvInterface @@ -22,7 +23,7 @@ DEFAULT_ENVS_ROOT_PATH = DEFAULT_BACKENDS_ROOT_PATH / DEFAULT_BACKEND / "envs" -class ManagerActions: +class ManagerActions(Enum): """Enum with the possible actions that can be performed by the manager.""" CreateEnvironment = "create_environment" @@ -36,6 +37,7 @@ class ManagerActions: UpdatePackages = "update" ListPackages = "list" ListEnvironments = "list_environments" + CreateKernelSpec = "create_kernelspec" class ManagerOptions(TypedDict): @@ -118,7 +120,7 @@ def __init__( ) def run_action(self, action: ManagerActions, action_options: dict | None = None): - method = getattr(self, action) + method = getattr(self, action.value) if action_options is not None: return method(**action_options) else: diff --git a/jupyter-config/envs_manager.json b/jupyter-config/envs_manager.json new file mode 100644 index 0000000..e4cdf09 --- /dev/null +++ b/jupyter-config/envs_manager.json @@ -0,0 +1,7 @@ +{ + "ServerApp": { + "jpserver_extensions": { + "envs_manager": true + } + } +} diff --git a/pyproject.toml b/pyproject.toml index 6ac66d1..821e965 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,9 +52,13 @@ pre-commit = [ [tool.hatch.version] path = "envs_manager/__about__.py" + [tool.hatch.build.targets.sdist] [tool.hatch.build.targets.wheel] +[tool.hatch.build.targets.wheel.shared-data] +"jupyter-config" = "etc/jupyter/jupyter_server_config.d" + [tool.hatch.envs.default] dependencies = [ "pytest",