diff --git a/.github/workflows/fix-license-header.yml b/.github/workflows/fix-license-header.yml index eb102b8..7018b92 100644 --- a/.github/workflows/fix-license-header.yml +++ b/.github/workflows/fix-license-header.yml @@ -6,7 +6,7 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true - + jobs: header-license-fix: runs-on: ubuntu-latest diff --git a/.licenserc.yaml b/.licenserc.yaml index f4ea347..0b1b564 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -14,4 +14,4 @@ header: - '**/.*' - 'LICENSE' - comment: on-failure \ No newline at end of file + comment: on-failure diff --git a/Makefile b/Makefile old mode 100755 new mode 100644 diff --git a/README.md b/README.md index 46998fd..7f49359 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Jupyter Kernel Client allows you to connect via WebSocket and HTTP to Jupyter Ke To install the library, run the following command. -```bash +```sh pip install jupyter_kernel_client ``` @@ -31,13 +31,13 @@ pip install jupyter-server ipykernel ### Kernel Client -2. Start a Jupyter Server. +1. Start a Jupyter Server. ```sh jupyter server --port 8888 --IdentityProvider.token MY_TOKEN ``` -3. Launch `python` in a terminal and execute the following snippet (update the server_url and token). +2. Launch `python` in a terminal and execute the following snippet (update the server_url and token). ```py import os @@ -77,13 +77,16 @@ pip install jupyter-kernel-client[konsole] 2. Start a Jupyter Server. ```sh -jupyter server --port 8888 --token MY_TOKEN +jupyter server --port 8888 --IdentityProvider.token MY_TOKEN ``` 3. Start the konsole and execute code. -```bash -$ jupyter konsole --url http://localhost:8888 --IdentityProvider.token MY_TOKEN +```sh +jupyter konsole --url http://localhost:8888 --token MY_TOKEN +``` + +```sh [KonsoleApp] KernelHttpManager created a new kernel: ... Jupyter Kernel console 0.2.0 @@ -94,14 +97,14 @@ IPython 8.30.0 -- An enhanced Interactive Python. Type '?' for help. In [1]: print("hello") hello -In [2]: +In [2]: ``` ## Uninstall To remove the library, execute: -```bash +```sh pip uninstall jupyter_kernel_client ``` @@ -109,7 +112,7 @@ pip uninstall jupyter_kernel_client ### Development install -```bash +```sh # Clone the repo to your local environment # Change directory to the jupyter_kernel_client directory # Install package in development mode - will automatically enable @@ -121,19 +124,19 @@ pip install -e ".[konsole,test,lint,typing]" Install dependencies: -```bash +```sh pip install -e ".[test]" ``` To run the python tests, use: -```bash +```sh pytest ``` ### Development uninstall -```bash +```sh pip uninstall jupyter_kernel_client ``` diff --git a/jupyter_kernel_client/__init__.py b/jupyter_kernel_client/__init__.py index 5745e94..2db3823 100644 --- a/jupyter_kernel_client/__init__.py +++ b/jupyter_kernel_client/__init__.py @@ -7,12 +7,14 @@ from .client import KernelClient from .konsoleapp import KonsoleApp from .manager import KernelHttpManager +from .snippets import SNIPPETS_REGISTRY, LanguageSnippets from .wsclient import KernelWebSocketClient - __all__ = [ + "SNIPPETS_REGISTRY", "KernelClient", "KernelHttpManager", "KernelWebSocketClient", "KonsoleApp", + "LanguageSnippets", ] diff --git a/jupyter_kernel_client/__version__.py b/jupyter_kernel_client/__version__.py index 76e1646..7717d04 100644 --- a/jupyter_kernel_client/__version__.py +++ b/jupyter_kernel_client/__version__.py @@ -4,5 +4,4 @@ """Jupyter Kernel Client through websocket.""" - __version__ = "0.3.1" diff --git a/jupyter_kernel_client/client.py b/jupyter_kernel_client/client.py index 98d08d6..8c468ed 100644 --- a/jupyter_kernel_client/client.py +++ b/jupyter_kernel_client/client.py @@ -14,6 +14,7 @@ from .constants import REQUEST_TIMEOUT from .manager import KernelHttpManager +from .snippets import SNIPPETS_REGISTRY from .utils import UTC logger = logging.getLogger("jupyter_kernel_client") @@ -149,7 +150,7 @@ class KernelClient(LoggingConfigurable): Client user name; default to environment variable USER kernel_id: str | None ID of the kernel to connect to - """ + """ # noqa E501 kernel_manager_class = Type( default_value=KernelHttpManager, @@ -187,6 +188,18 @@ def id(self) -> str | None: """Kernel ID""" return self._manager.kernel["id"] if self._manager.kernel else None + @property + def kernel_info(self) -> dict[str, t.Any] | None: + """Kernel information. + + This is the dictionary returned by the kernel for a kernel_info_request. + + Returns: + The kernel information + """ + if self._manager.kernel: + return self._manager.client.kernel_info_interactive(timeout=REQUEST_TIMEOUT) + @property def last_activity(self) -> datetime.datetime | None: """Kernel process last activity. @@ -337,7 +350,7 @@ def restart(self, timeout: float = REQUEST_TIMEOUT) -> None: """Restarts a kernel.""" return self._manager.restart_kernel(timeout=timeout) - def __enter__(self) -> "KernelClient": + def __enter__(self) -> KernelClient: self.start() return self @@ -383,3 +396,91 @@ def stop( shutdown = self._own_kernel if shutdown_kernel is None else shutdown_kernel if shutdown: self._manager.shutdown_kernel(now=shutdown_now, timeout=timeout) + + # + # Variables related methods + # + def get_variable(self, name: str, mimetype: str | None = None) -> dict[str, t.Any]: + """Get a kernel variable. + + Args: + name: Variable name + mimetype: optional, type of variable value serialization; default ``None``, + i.e. returns all known serialization. + + Returns: + A dictionary for which keys are mimetype and values the variable value + serialized in that mimetype. + Even if a mimetype is specified, the dictionary may not contain it if + the kernel introspection failed to get the variable in the specified format. + Raises: + ValueError: If the kernel programming language is not supported + RuntimeError: If the kernel introspection failed + """ + kernel_language = (self.kernel_info or {}).get("language_info", {}).get("name") + if kernel_language not in SNIPPETS_REGISTRY.available_languages: + raise ValueError(f"""Code snippet for language {kernel_language} are not available. +You can set them yourself using: + + from jupyter_kernel_client import SNIPPETS_REGISTRY, LanguageSnippets + SNIPPETS_REGISTRY.register("my-language", LanguageSnippets(list_variables="", get_variable="")) +""") + + snippet = SNIPPETS_REGISTRY.get_get_variable(kernel_language) + results = self.execute(snippet.format(name=name, mimetype=mimetype), silent=True) + + self.log.debug("Kernel variables: %s", results) + + if results["status"] == "ok" and results["outputs"]: + if mimetype is None: + return results["outputs"][0]["data"] + else: + has_mimetype = mimetype in results["outputs"][0]["data"] + return {mimetype: results["outputs"][0]["data"][mimetype]} if has_mimetype else {} + else: + raise RuntimeError(f"Failed to get variable {name} with type {mimetype}.") + + def list_variables(self) -> list[dict[str, t.Any]]: + """List the kernel global variables. + + A variable is defined by a dictionary with the schema: + { + "type": "object", + "properties": { + "name": {"title": "Variable name", "type": "string"}, + "type": {"title": "Variable type", "type": "string"}, + "size": {"title": "Variable size in bytes.", "type": "number"} + }, + "required": ["name", "type"] + } + + Returns: + The list of global variables. + Raises: + ValueError: If the kernel programming language is not supported + RuntimeError: If the kernel introspection failed + """ + kernel_language = (self.kernel_info or {}).get("language_info", {}).get("name") + if kernel_language not in SNIPPETS_REGISTRY.available_languages: + raise ValueError(f"""Code snippet for language {kernel_language} are not available. +You can set them yourself using: + + from jupyter_kernel_client import SNIPPETS_REGISTRY, LanguageSnippets + SNIPPETS_REGISTRY.register("my-language", LanguageSnippets(list_variables="", get_variable="")) +""") + + snippet = SNIPPETS_REGISTRY.get_list_variables(kernel_language) + results = self.execute(snippet, silent=True) + + self.log.debug("Kernel variables: %s", results) + + if ( + results["status"] == "ok" + and results["outputs"] + and "application/json" in results["outputs"][0]["data"] + ): + return sorted( + results["outputs"][0]["data"]["application/json"], key=lambda v: v["name"] + ) + else: + raise RuntimeError("Failed to list variables.") diff --git a/jupyter_kernel_client/konsoleapp.py b/jupyter_kernel_client/konsoleapp.py index b45f9a7..6b6a4ae 100644 --- a/jupyter_kernel_client/konsoleapp.py +++ b/jupyter_kernel_client/konsoleapp.py @@ -16,7 +16,6 @@ from .manager import KernelHttpManager from .shell import WSTerminalInteractiveShell - # ----------------------------------------------------------------------------- # Globals # ----------------------------------------------------------------------------- @@ -27,7 +26,7 @@ # Start a console connected to a distant Jupyter Server with a new python kernel. jupyter konsole --url https://my.jupyter-server.xzy --token -""" +""" # noqa E501 # ----------------------------------------------------------------------------- # Flags and Aliases @@ -102,7 +101,7 @@ class KonsoleApp(JupyterApp): """ examples = _examples - classes = [WSTerminalInteractiveShell] + classes = [WSTerminalInteractiveShell] # noqa RUF012 flags = Dict(flags) aliases = Dict(aliases) diff --git a/jupyter_kernel_client/shell.py b/jupyter_kernel_client/shell.py index bcbfea8..1a951c6 100644 --- a/jupyter_kernel_client/shell.py +++ b/jupyter_kernel_client/shell.py @@ -34,7 +34,7 @@ async def handle_external_iopub(self, loop=None): await asyncio.sleep(0.5) def show_banner(self): - print( + print( # noqa T201 self.banner.format( version=__version__, kernel_banner=self.kernel_info.get("banner", "") ), @@ -57,7 +57,7 @@ def __init__(self): self._executing = False def show_banner(self): - return "You must install `jupyter_console` to use the console:\n\n\tpip install jupyter-console\n" + return "You must install `jupyter_console` to use the console:\n\n\tpip install jupyter-console\n" # noqa E501 def mainloop(self) -> None: raise ModuleNotFoundError("jupyter_console") diff --git a/jupyter_kernel_client/snippets.py b/jupyter_kernel_client/snippets.py new file mode 100644 index 0000000..8b454d6 --- /dev/null +++ b/jupyter_kernel_client/snippets.py @@ -0,0 +1,145 @@ +# Copyright (c) 2023-2024 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Kernel language dependent code snippets.""" + +import warnings +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LanguageSnippets: + """Per kernel language snippets.""" + + list_variables: str + """Snippet to list kernel variables. + + Its execution must return an output of 'application/json' mimetype with + a list of variables definition according to the schema: + + { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"title": "Variable name", "type": "string"}, + "type": {"title": "Variable type", "type": "string"}, + "size": {"title": "Variable size in bytes.", "type": "number"} + }, + "required": ["name", "type"] + } + } + """ + get_variable: str + """Snippet to get a kernel variable value. + + The snippet will be formatted with the variables: + - ``name``: Variable name + - ``mimetype``: Wanted mimetype for the variable; default ``None``, i.e. unspecified format. + + Its execution must return an output of the wanted mimetype or the best possible + views if ``None`` is specified. + """ + + +class SnippetsRegistry: + """Registry for kernel language dependent code snippets.""" + + def __init__(self): + self._snippets: dict[str, LanguageSnippets] = {} + + @property + def available_languages(self) -> frozenset[str]: + """List the available languages.""" + return frozenset(self._snippets) + + def register(self, language: str, snippets: LanguageSnippets) -> None: + """Register snippets for a new language. + + Args: + language: Language name (as known by the Jupyter kernel) + snippets: Language snippets + """ + if language in self._snippets: + warnings.warn(f"Snippets for language {language} will be overridden.", stacklevel=2) + self._snippets[language] = snippets + + def get_list_variables(self, language: str) -> str: + """Get list variables snippet for the given language. + + Args: + language: the targeted programming language + Returns: + The list variables snippet + Raises: + ValueError: if no snippet is defined for ``language`` + """ + if language not in self._snippets: + raise ValueError(f"No snippet for language '{language}'.") + + return self._snippets[language].list_variables + + def get_get_variable(self, language: str) -> str: + """Get get variable snippet for the given language. + + Args: + language: the targeted programming language + Returns: + The get variable snippet + Raises: + ValueError: if no snippet is defined for ``language`` + """ + if language not in self._snippets: + raise ValueError(f"No snippet for language '{language}'.") + + return self._snippets[language].get_variable + + +PYTHON_SNIPPETS = LanguageSnippets( + list_variables="""def _list_variables(): + from IPython.display import display + import json + from types import BuiltinFunctionType, BuiltinMethodType, FunctionType, MethodType, MethodWrapperType, ModuleType, TracebackType + + _FORBIDDEN_TYPES = [type, BuiltinFunctionType, BuiltinMethodType, FunctionType, MethodType, MethodWrapperType, ModuleType, TracebackType] + try: + from IPython.core.autocall import ExitAutocall + _FORBIDDEN_TYPES.append(ExitAutocall) + except ImportError: + pass + _exclude = tuple(_FORBIDDEN_TYPES) + + _all = frozenset(globals()) + _vars = [] + for _n in _all: + _v = globals()[_n] + + if not ( + _n.startswith('_') or + isinstance(_v, _exclude) or + # Special IPython variables + (_n == 'In' and isinstance(_v, list)) or + (_n == 'Out' and isinstance(_v, dict)) + ): + try: + _vars.append({"name": _n, "type": type(_v).__qualname__}) + except BaseException: + ... + + display({"application/json": _vars}, raw=True) + +_list_variables() +""", # noqa E501 + get_variable="""def _get_variable(name, mimetype): + from IPython.display import display + + variable = globals()[name] + include = None if mimetype is None else [mimetype] + display(variable, include=include) +_get_variable("{name}", "{mimetype}" if "{mimetype}" != "None" else None) +""", +) + +SNIPPETS_REGISTRY = SnippetsRegistry() +SNIPPETS_REGISTRY.register("python", PYTHON_SNIPPETS) diff --git a/jupyter_kernel_client/tests/conftest.py b/jupyter_kernel_client/tests/conftest.py index 5ddb3f6..38547b1 100644 --- a/jupyter_kernel_client/tests/conftest.py +++ b/jupyter_kernel_client/tests/conftest.py @@ -13,6 +13,9 @@ import pytest import requests +logging.basicConfig(level=logging.DEBUG) +logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING) + def find_free_port(): with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: diff --git a/jupyter_kernel_client/tests/test_client.py b/jupyter_kernel_client/tests/test_client.py index bcc3379..a7d77da 100644 --- a/jupyter_kernel_client/tests/test_client.py +++ b/jupyter_kernel_client/tests/test_client.py @@ -5,6 +5,8 @@ import os from platform import node +import pytest + from jupyter_kernel_client import KernelClient @@ -54,3 +56,77 @@ def test_execution_no_context_manager(jupyter_server): } ] assert reply["status"] == "ok" + + +def test_list_variables(jupyter_server): + port, token = jupyter_server + + with KernelClient(server_url=f"http://localhost:{port}", token=token) as kernel: + kernel.execute( + """a = 1.0 +b = "hello the world" +c = {3, 4, 5} +d = {"name": "titi"} +""" + ) + + variables = kernel.list_variables() + + assert variables == [ + { + "name": "a", + "type": "float", + }, + { + "name": "b", + "type": "str", + }, + { + "name": "c", + "type": "set", + }, + { + "name": "d", + "type": "dict", + }, + ] + + +@pytest.mark.parametrize( + "variable,set_variable,expected", + ( + ("a", "a = 1.0", {"text/plain": "1.0"}), + ("b", 'b = "hello the world"', {"text/plain": "'hello the world'"}), + ("c", "c = {3, 4, 5}", {"text/plain": "{3, 4, 5}"}), + ("d", "d = {'name': 'titi'}", {"text/plain": "{'name': 'titi'}"}), + ), +) +def test_get_all_mimetype_variables(jupyter_server, variable, set_variable, expected): + port, token = jupyter_server + + with KernelClient(server_url=f"http://localhost:{port}", token=token) as kernel: + kernel.execute(set_variable) + + values = kernel.get_variable(variable) + + assert values == expected + + +@pytest.mark.parametrize( + "variable,set_variable,expected", + ( + ("a", "a = 1.0", {"text/plain": "1.0"}), + ("b", 'b = "hello the world"', {"text/plain": "'hello the world'"}), + ("c", "c = {3, 4, 5}", {"text/plain": "{3, 4, 5}"}), + ("d", "d = {'name': 'titi'}", {"text/plain": "{'name': 'titi'}"}), + ), +) +def test_get_textplain_variables(jupyter_server, variable, set_variable, expected): + port, token = jupyter_server + + with KernelClient(server_url=f"http://localhost:{port}", token=token) as kernel: + kernel.execute(set_variable) + + values = kernel.get_variable(variable, "text/plain") + + assert values == expected diff --git a/jupyter_kernel_client/utils.py b/jupyter_kernel_client/utils.py index 35de54a..8f28b3e 100644 --- a/jupyter_kernel_client/utils.py +++ b/jupyter_kernel_client/utils.py @@ -14,7 +14,7 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone, tzinfo -from typing import Any, List +from typing import Any def serialize_msg_to_ws_v1(msg_or_list, channel, pack=None): @@ -29,7 +29,7 @@ def serialize_msg_to_ws_v1(msg_or_list, channel, pack=None): else: msg_list = msg_or_list channel = channel.encode("utf-8") - offsets: List[Any] = [] + offsets: list[Any] = [] offsets.append(8 * (1 + 1 + len(msg_list) + 1)) offsets.append(len(channel) + offsets[-1]) for msg in msg_list: diff --git a/jupyter_kernel_client/wsclient.py b/jupyter_kernel_client/wsclient.py index 9f1533e..8614d00 100644 --- a/jupyter_kernel_client/wsclient.py +++ b/jupyter_kernel_client/wsclient.py @@ -803,7 +803,7 @@ def history( self.shell_channel.send(msg) return msg["header"]["msg_id"] - def kernel_info(self) -> str: + def kernel_info(self, force: bool = False) -> str: """Request kernel info Returns @@ -1154,7 +1154,7 @@ def wait_for_ready(self, timeout: float | None = None) -> None: # noqa: C901 # Check if current time is ready check time plus timeout if time.time() > abs_timeout: emsg = f"Kernel didn't respond in {timeout:d} seconds" - raise RuntimeError(emsg) + raise TimeoutError(emsg) # Flush IOPub channel while True: diff --git a/pyproject.toml b/pyproject.toml index c191f4c..cb99051 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dependencies = ["jupyter_core", "jupyter_client>=7.4.4", "requests", "traitlets> [project.optional-dependencies] konsole = ["jupyter_console"] test = ["ipykernel", "jupyter_server>=1.6,<3", "pytest>=7.0"] -lint = ["mdformat>0.7", "mdformat-gfm>=0.3.5", "ruff"] +lint = ["pre_commit", "mdformat>0.7", "mdformat-gfm>=0.3.5", "ruff"] typing = ["mypy>=0.990"] [project.scripts] @@ -64,6 +64,7 @@ warn_redundant_casts = true [tool.ruff] target-version = "py39" line-length = 100 +exclude = ["tests"] [tool.ruff.lint] select = [