From bfab1ea7e6fcd08262cbbe45b9ab22197b03540e Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Mon, 30 Jun 2025 15:05:34 -0300 Subject: [PATCH 01/13] feat: add jupyter server extension app --- envs_manager/jupyter.py | 75 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 envs_manager/jupyter.py diff --git a/envs_manager/jupyter.py b/envs_manager/jupyter.py new file mode 100644 index 0000000..82fee4f --- /dev/null +++ b/envs_manager/jupyter.py @@ -0,0 +1,75 @@ +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(t.get_args(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["default_backend"], + root_path=self.settings["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: ManagerActions): + manager = self.get_manager() + action_options = self.get_options() + self.write_json( + manager.run_action(action, action_options), + status=200, + ) + + +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] From 9681ac43ab758102422543d1e7123e7a0c994ae8 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Mon, 30 Jun 2025 15:05:58 -0300 Subject: [PATCH 02/13] fix: typing error on ManagerActions --- envs_manager/manager.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/envs_manager/manager.py b/envs_manager/manager.py index c155522..38ccb9d 100644 --- a/envs_manager/manager.py +++ b/envs_manager/manager.py @@ -5,7 +5,7 @@ from __future__ import annotations import os from pathlib import Path -from typing import TypedDict +from typing import TypedDict, Literal from envs_manager.backends.api import BackendActionResult, BackendInstance from envs_manager.backends.venv_interface import VEnvInterface @@ -22,20 +22,20 @@ DEFAULT_ENVS_ROOT_PATH = DEFAULT_BACKENDS_ROOT_PATH / DEFAULT_BACKEND / "envs" -class ManagerActions: - """Enum with the possible actions that can be performed by the manager.""" - - CreateEnvironment = "create_environment" - DeleteEnvironment = "delete_environment" - ActivateEnvironment = "activate" - DeactivateEnvironment = "deactivate" - ExportEnvironment = "export_environment" - ImportEnvironment = "import_environment" - InstallPackages = "install" - UninstallPackages = "uninstall" - UpdatePackages = "update" - ListPackages = "list" - ListEnvironments = "list_environments" +ManagerActions = Literal[ + "create_environment", + "delete_environment", + "activate", + "deactivate", + "export_environment", + "import_environment", + "install", + "uninstall", + "update", + "list", + "list_environments", +] +"""Enum with the possible actions that can be performed by the manager.""" class ManagerOptions(TypedDict): From 89e3e7bd2d285235318234696768127e56465f2f Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Mon, 30 Jun 2025 15:06:41 -0300 Subject: [PATCH 03/13] feat: add default jupyter config and configure jupyter-server dependency --- jupyter-config/envs_manager.json | 7 +++++++ pyproject.toml | 5 +++++ 2 files changed, 12 insertions(+) create mode 100644 jupyter-config/envs_manager.json diff --git a/jupyter-config/envs_manager.json b/jupyter-config/envs_manager.json new file mode 100644 index 0000000..2e499a1 --- /dev/null +++ b/jupyter-config/envs_manager.json @@ -0,0 +1,7 @@ +{ + "ServerApp": { + "jpserver_extensions": { + "envs_manager": true + } + } +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6ac66d1..c3dda91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "py-rattler", "pyyaml", "requests", + "jupyter-server" ] dynamic = ["version"] @@ -52,9 +53,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", From ad7aa3e816c2bf10231380bbe4fccb32e54c7885 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Sun, 6 Jul 2025 18:00:00 -0300 Subject: [PATCH 04/13] fix: use enum instead of typing list --- envs_manager/manager.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/envs_manager/manager.py b/envs_manager/manager.py index 38ccb9d..cea8201 100644 --- a/envs_manager/manager.py +++ b/envs_manager/manager.py @@ -5,7 +5,8 @@ from __future__ import annotations import os from pathlib import Path -from typing import TypedDict, Literal +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,20 +23,20 @@ DEFAULT_ENVS_ROOT_PATH = DEFAULT_BACKENDS_ROOT_PATH / DEFAULT_BACKEND / "envs" -ManagerActions = Literal[ - "create_environment", - "delete_environment", - "activate", - "deactivate", - "export_environment", - "import_environment", - "install", - "uninstall", - "update", - "list", - "list_environments", -] -"""Enum with the possible actions that can be performed by the manager.""" +class ManagerActions(Enum): + """Enum with the possible actions that can be performed by the manager.""" + + CreateEnvironment = "create_environment" + DeleteEnvironment = "delete_environment" + ActivateEnvironment = "activate" + DeactivateEnvironment = "deactivate" + ExportEnvironment = "export_environment" + ImportEnvironment = "import_environment" + InstallPackages = "install" + UninstallPackages = "uninstall" + UpdatePackages = "update" + ListPackages = "list" + ListEnvironments = "list_environments" class ManagerOptions(TypedDict): @@ -118,7 +119,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: From 6b51f228ef67aa1ad25735d70b411ff3fe1ca6c2 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Sun, 6 Jul 2025 18:00:44 -0300 Subject: [PATCH 05/13] fix: get handler actions from enum --- envs_manager/jupyter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/envs_manager/jupyter.py b/envs_manager/jupyter.py index 82fee4f..0593690 100644 --- a/envs_manager/jupyter.py +++ b/envs_manager/jupyter.py @@ -19,7 +19,7 @@ class EnvManagerHandler(JupyterHandler): """Handler to list available environments.""" - _handler_action_regex = rf"(?P{'|'.join(t.get_args(ManagerActions))})" + _handler_action_regex = rf"(?P{'|'.join(action.value for action in ManagerActions)})" auth_resource = "envs_manager" From 13c5abe6cf06934e0f87485896d210eff11d9245 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Sun, 6 Jul 2025 18:01:15 -0300 Subject: [PATCH 06/13] fix: get default configuration from proper section --- envs_manager/jupyter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/envs_manager/jupyter.py b/envs_manager/jupyter.py index 0593690..6c2c49c 100644 --- a/envs_manager/jupyter.py +++ b/envs_manager/jupyter.py @@ -26,8 +26,8 @@ class EnvManagerHandler(JupyterHandler): def get_manager(self) -> Manager: """Get the environment manager instance.""" return Manager( - backend=self.get_argument("backend", None) or self.settings["default_backend"], - root_path=self.settings["root_path"], + 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), ) From cfad81becf26a37266893764b08982ee2953c915 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Sun, 6 Jul 2025 18:01:28 -0300 Subject: [PATCH 07/13] feat: add error handling --- envs_manager/jupyter.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/envs_manager/jupyter.py b/envs_manager/jupyter.py index 6c2c49c..e8c4245 100644 --- a/envs_manager/jupyter.py +++ b/envs_manager/jupyter.py @@ -42,14 +42,18 @@ def write_json(self, data, status=200): @authorized @web.authenticated - def post(self, action: ManagerActions): - manager = self.get_manager() - action_options = self.get_options() - self.write_json( - manager.run_action(action, action_options), - status=200, - ) - + 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.""" From eb2b30e732ed0ea716eb1461e428849bd8cc3c18 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Sun, 6 Jul 2025 18:02:16 -0300 Subject: [PATCH 08/13] fix: add jupyter server missing extensions point --- envs_manager/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/envs_manager/__init__.py b/envs_manager/__init__.py index 40fbbb4..39a9522 100644 --- a/envs_manager/__init__.py +++ b/envs_manager/__init__.py @@ -1,3 +1,11 @@ # SPDX-FileCopyrightText: 2022-present Spyder Development Team and envs-manager contributors # # SPDX-License-Identifier: MIT +from envs_manager.jupyter import EnvManagerApp + +def _jupyter_server_extension_points(): + """ + Returns a list of dictionaries with metadata describing + where to find the `_load_jupyter_server_extension` function. + """ + return [{"module": "envs_manager.jupyter", "app": EnvManagerApp}] From 0b91ea583a02969c335e8903961aa8ab1934397f Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Mon, 7 Jul 2025 14:32:06 -0300 Subject: [PATCH 09/13] feat: set jupyter-server as optional dependency --- envs_manager/__init__.py | 4 +++- pyproject.toml | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/envs_manager/__init__.py b/envs_manager/__init__.py index 39a9522..8aaa4d7 100644 --- a/envs_manager/__init__.py +++ b/envs_manager/__init__.py @@ -1,11 +1,13 @@ # SPDX-FileCopyrightText: 2022-present Spyder Development Team and envs-manager contributors # # SPDX-License-Identifier: MIT -from envs_manager.jupyter import EnvManagerApp + 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/pyproject.toml b/pyproject.toml index c3dda91..821e965 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,6 @@ dependencies = [ "py-rattler", "pyyaml", "requests", - "jupyter-server" ] dynamic = ["version"] From 9d9f195e0dbb652d459f5f2917b1ea6d00058cc2 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Mon, 7 Jul 2025 14:33:13 -0300 Subject: [PATCH 10/13] fix: add missing module header --- envs_manager/jupyter.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/envs_manager/jupyter.py b/envs_manager/jupyter.py index e8c4245..bb181c7 100644 --- a/envs_manager/jupyter.py +++ b/envs_manager/jupyter.py @@ -1,3 +1,7 @@ +# 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 @@ -12,21 +16,24 @@ DEFAULT_BACKENDS_ROOT_PATH, DEFAULT_BACKEND, Manager, - ManagerActions + ManagerActions, ) class EnvManagerHandler(JupyterHandler): """Handler to list available environments.""" - _handler_action_regex = rf"(?P{'|'.join(action.value for action in ManagerActions)})" + _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"], + 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), @@ -55,6 +62,7 @@ def post(self, action: str): self.finish(str(e)) self.log_exception(type(e), e, e.__traceback__) + class EnvManagerApp(ExtensionApp): """Jupyter extension for managing environments.""" @@ -75,5 +83,8 @@ class EnvManagerApp(ExtensionApp): ) handlers = [ - (rf"{extension_url}/{EnvManagerHandler._handler_action_regex}", EnvManagerHandler), + ( + rf"{extension_url}/{EnvManagerHandler._handler_action_regex}", + EnvManagerHandler, + ), ] # type: ignore[list-item] From 21ed25356e5c91387f240ee4ced9a9eeea92e73e Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Mon, 7 Jul 2025 14:47:44 -0300 Subject: [PATCH 11/13] feat: add action to create kernels specs --- envs_manager/backends/api.py | 124 +++++++++++++++++++++++++++++++++++ envs_manager/manager.py | 1 + 2 files changed, 125 insertions(+) diff --git a/envs_manager/backends/api.py b/envs_manager/backends/api.py index 39c1f89..c3a075c 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,122 @@ 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()) + + +# parser.add_argument( +# "--user", +# action="store_true", +# help="Install for the current user instead of system-wide", +# ) +# parser.add_argument( +# "--name", +# type=str, +# default=KERNEL_NAME, +# help="Specify a name for the kernelspec." +# " This is needed to have multiple IPython kernels at the same time.", +# ) +# parser.add_argument( +# "--display-name", +# type=str, +# help="Specify the display name for the kernelspec." +# " This is helpful when you have multiple IPython kernels.", +# ) +# parser.add_argument( +# "--profile", +# type=str, +# help="Specify an IPython profile to load. " +# "This can be used to create custom versions of the kernel.", +# ) +# parser.add_argument( +# "--prefix", +# type=str, +# help="Specify an install prefix for the kernelspec." +# " This is needed to install into a non-default location, such as a conda/virtual-env.", +# ) +# parser.add_argument( +# "--sys-prefix", +# action="store_const", +# const=sys.prefix, +# dest="prefix", +# help="Install to Python's sys.prefix." +# " Shorthand for --prefix='%s'. For use in conda/virtual-envs." % sys.prefix, +# ) +# parser.add_argument( +# "--env", +# action="append", +# nargs=2, +# metavar=("ENV", "VALUE"), +# help="Set environment variables for the kernel.", +# ) +# parser.add_argument( +# "--frozen_modules", +# action="store_true", +# help="Enable frozen modules for potentially faster startup." +# " This has a downside of preventing the debugger from navigating to certain built-in modules.", +# ) diff --git a/envs_manager/manager.py b/envs_manager/manager.py index cea8201..5d70a4e 100644 --- a/envs_manager/manager.py +++ b/envs_manager/manager.py @@ -37,6 +37,7 @@ class ManagerActions(Enum): UpdatePackages = "update" ListPackages = "list" ListEnvironments = "list_environments" + CreateKernelSpec = "create_kernelspec" class ManagerOptions(TypedDict): From 406c91561aba587b52fc78220fc890050a28c069 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Mon, 7 Jul 2025 14:56:39 -0300 Subject: [PATCH 12/13] refac: apply formatter --- jupyter-config/envs_manager.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jupyter-config/envs_manager.json b/jupyter-config/envs_manager.json index 2e499a1..e4cdf09 100644 --- a/jupyter-config/envs_manager.json +++ b/jupyter-config/envs_manager.json @@ -4,4 +4,4 @@ "envs_manager": true } } -} \ No newline at end of file +} From 7a47724449c530b3f7763050b58b97e08113eb1f Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Mon, 7 Jul 2025 15:15:32 -0300 Subject: [PATCH 13/13] refac: remove unnecessary comment --- envs_manager/backends/api.py | 53 ------------------------------------ 1 file changed, 53 deletions(-) diff --git a/envs_manager/backends/api.py b/envs_manager/backends/api.py index c3a075c..d292f8d 100644 --- a/envs_manager/backends/api.py +++ b/envs_manager/backends/api.py @@ -254,56 +254,3 @@ def create_kernelspec( return BackendActionResult(status=False, output=str(error)) return BackendActionResult(status=True, output=output.strip()) - - -# parser.add_argument( -# "--user", -# action="store_true", -# help="Install for the current user instead of system-wide", -# ) -# parser.add_argument( -# "--name", -# type=str, -# default=KERNEL_NAME, -# help="Specify a name for the kernelspec." -# " This is needed to have multiple IPython kernels at the same time.", -# ) -# parser.add_argument( -# "--display-name", -# type=str, -# help="Specify the display name for the kernelspec." -# " This is helpful when you have multiple IPython kernels.", -# ) -# parser.add_argument( -# "--profile", -# type=str, -# help="Specify an IPython profile to load. " -# "This can be used to create custom versions of the kernel.", -# ) -# parser.add_argument( -# "--prefix", -# type=str, -# help="Specify an install prefix for the kernelspec." -# " This is needed to install into a non-default location, such as a conda/virtual-env.", -# ) -# parser.add_argument( -# "--sys-prefix", -# action="store_const", -# const=sys.prefix, -# dest="prefix", -# help="Install to Python's sys.prefix." -# " Shorthand for --prefix='%s'. For use in conda/virtual-envs." % sys.prefix, -# ) -# parser.add_argument( -# "--env", -# action="append", -# nargs=2, -# metavar=("ENV", "VALUE"), -# help="Set environment variables for the kernel.", -# ) -# parser.add_argument( -# "--frozen_modules", -# action="store_true", -# help="Enable frozen modules for potentially faster startup." -# " This has a downside of preventing the debugger from navigating to certain built-in modules.", -# )