diff --git a/README.md b/README.md index e6312c8..c5edee4 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,6 @@ Client to interact with Jupyter notebook model. -## Requirements - -- Jupyter Server - ## Install To install the extension, execute: @@ -16,6 +12,62 @@ To install the extension, execute: pip install jupyter_nbmodel_client ``` +## Usage + +1. Ensure you have an environment with `jupyter-server-ydoc` installed. + +> To reproduce the above video you will need to install `jupyterlab`, `jupyter-collaboration` and `scikit-learn` and `matplotlib` for the notebook demo. + +1. Start the server `jupyter server` (or JupyterLab like in the video) + +1. Write down the URL (usually `http://localhost:8888`) and the server token + +1. Open a Python terminal + +1. Execute the following snippet to add a cell + +```py +from jupyter_nbmodel_client import NbModelClient + +with NbModelClient(server_url="http://localhost:8888", token="...", path="test.ipynb") as notebook: + notebook.add_code_cell("print('hello world')") +``` + +1. Another example adding a cell and executing within a kernel process + +```py +from jupyter_kernel_client import KernelClient +from jupyter_nbmodel_client import NbModelClient + +with KernelClient(server_url="http://localhost:8888", token="...") as kernel: + with NbModelClient(server_url="http://localhost:8888", token="...", path="test.ipynb") as notebook: + cell_index = notebook.add_code_cell("print('hello world')") + results = notebook.execute_cell(cell_index, kernel) + + assert results["status"] == "ok" + assert len(results["outputs"]) > 0 +``` + +> [!NOTE] +> Instead of using the clients as context manager, you can call the ``start()`` and ``stop()`` methods. + +```py +from jupyter_nbmodel_client import NbModelClient + +kernel = KernelClient(server_url="http://localhost:8888", token="...") +kernel.start() +try: + notebook = NbModelClient(server_url="http://localhost:8888", token="...", path="test.ipynb"): + notebook.start() + try: + cell_index = notebook.add_code_cell("print('hello world')") + results = notebook.execute_cell(cell_index, kernel) + finally: + notebook.stop() +finally: + kernel.stop() +``` + ## Uninstall To remove the extension, execute: diff --git a/jupyter_nbmodel_client/__init__.py b/jupyter_nbmodel_client/__init__.py index 1092371..d1c406c 100644 --- a/jupyter_nbmodel_client/__init__.py +++ b/jupyter_nbmodel_client/__init__.py @@ -1,9 +1,10 @@ """Client to interact with Jupyter notebook model.""" -from .client import NbModelClient -from .model import NotebookModel from nbformat import NotebookNode +from .client import NbModelClient +from .model import KernelClient, NotebookModel + __version__ = "0.1.0" -__all__ = ["NbModelClient", "NotebookModel", "NotebookNode"] +__all__ = ["KernelClient", "NbModelClient", "NotebookModel", "NotebookNode"] diff --git a/jupyter_nbmodel_client/client.py b/jupyter_nbmodel_client/client.py index 3e37040..f9e6c65 100644 --- a/jupyter_nbmodel_client/client.py +++ b/jupyter_nbmodel_client/client.py @@ -24,7 +24,15 @@ class NbModelClient(NotebookModel): - """Client to one Jupyter notebook model.""" + """Client to one Jupyter notebook model. + + Args: + server_url: Jupyter Server URL + token: Jupyter Server authentication token + path: Notebook path relative to the server root directory + timeout: Request timeout in seconds; default to environment variable REQUEST_TIMEOUT + log: Custom logger + """ def __init__( self, diff --git a/jupyter_nbmodel_client/model.py b/jupyter_nbmodel_client/model.py index f256010..1dfb923 100644 --- a/jupyter_nbmodel_client/model.py +++ b/jupyter_nbmodel_client/model.py @@ -11,7 +11,70 @@ current_api = versions[current_nbformat] -def output_hook(ycell: pycrdt.Map, msg: dict) -> None: +class KernelClient(t.Protocol): + def execute_interactive( + self, + code: str, + silent: bool = False, + store_history: bool = True, + user_expressions: dict[str, t.Any] | None = None, + allow_stdin: bool | None = None, + stop_on_error: bool = True, + timeout: float | None = None, + output_hook: t.Callable | None = None, + stdin_hook: t.Callable | None = None, + ) -> dict[str, t.Any]: + """Execute code in the kernel with low-level API + + Output will be redisplayed, and stdin prompts will be relayed as well. + + You can pass a custom output_hook callable that will be called + with every IOPub message that is produced instead of the default redisplay. + + Parameters + ---------- + code : str + A string of code in the kernel's language. + + silent : bool, optional (default False) + If set, the kernel will execute the code as quietly possible, and + will force store_history to be False. + + store_history : bool, optional (default True) + If set, the kernel will store command history. This is forced + to be False if silent is True. + + user_expressions : dict, optional + A dict mapping names to expressions to be evaluated in the user's + dict. The expression values are returned as strings formatted using + :func:`repr`. + + allow_stdin : bool, optional (default self.allow_stdin) + Flag for whether the kernel can send stdin requests to frontends. + + stop_on_error: bool, optional (default True) + Flag whether to abort the execution queue, if an exception is encountered. + + timeout: float or None (default: None) + Timeout to use when waiting for a reply + + output_hook: callable(msg) + Function to be called with output messages. + If not specified, output will be redisplayed. + + stdin_hook: callable(msg) + Function to be called with stdin_request messages. + If not specified, input/getpass will be called. + + Returns + ------- + reply: dict + The reply message for this request + """ + ... + + +def output_hook(outputs: list[dict], ycell: pycrdt.Map, msg: dict) -> None: """Callback on execution request when an output is emitted. Args: @@ -19,10 +82,12 @@ def output_hook(ycell: pycrdt.Map, msg: dict) -> None: ycell: The cell being executed msg: The output message """ + # FIXME converge with output_hook in KernelClient msg_type = msg["header"]["msg_type"] if msg_type in ("display_data", "stream", "execute_result", "error"): # FIXME support for version output = nbformat.v4.output_from_msg(msg) + outputs.append(output) if ycell is not None: cell_outputs = ycell["outputs"] @@ -46,7 +111,10 @@ def output_hook(ycell: pycrdt.Map, msg: dict) -> None: cell_outputs.append(output) elif msg_type == "clear_output": - # FIXME msg.content.wait - if true should clear at the next message + # msg.content.wait is ignored - if true should clear at the next message + # FIXME this is to fake some animation do we care? --> probably yes if output is captured + # in the server + outputs.clear() del ycell["outputs"][:] elif msg_type == "update_display_data": @@ -55,6 +123,14 @@ def output_hook(ycell: pycrdt.Map, msg: dict) -> None: class NotebookModel(MutableSequence): + """Notebook model. + + Its API is based on a mutable sequence of cells. + """ + + # FIXME add notebook metadata + # FIXME add notebook state (TBC) + def __init__(self) -> None: self._doc = YNotebook() @@ -70,7 +146,7 @@ def __getitem__(self, index: int) -> NotebookNode: nbcell = NotebookNode(**cell) return nbcell - def __setitem__(self, index: int, value: NotebookNode) -> None: + def __setitem__(self, index: int, value: dict[str, t.Any]) -> None: self._doc.set_cell(index, value) def __len__(self) -> int: @@ -78,6 +154,14 @@ def __len__(self) -> int: return self._doc.cell_number def add_code_cell(self, source: str, **kwargs) -> int: + """Add a code cell + + Args: + source: Code cell source + + Returns: + Index of the newly added cell + """ cell = current_api.new_code_cell(source, **kwargs) self._doc.append_cell(cell) @@ -85,6 +169,14 @@ def add_code_cell(self, source: str, **kwargs) -> int: return len(self) - 1 def add_markdown_cell(self, source: str, **kwargs) -> int: + """Add a markdown cell + + Args: + source: Markdown cell source + + Returns: + Index of the newly added cell + """ cell = current_api.new_markdown_cell(source, **kwargs) self._doc.append_cell(cell) @@ -92,17 +184,34 @@ def add_markdown_cell(self, source: str, **kwargs) -> int: return len(self) - 1 def add_raw_cell(self, source: str, **kwargs) -> int: + """Add a raw cell + + Args: + source: Raw cell source + + Returns: + Index of the newly added cell + """ cell = current_api.new_raw_cell(source, **kwargs) self._doc.append_cell(cell) return len(self) - 1 - def _reset_y_model(self) -> None: - """Reset the Y model.""" - self._doc = YNotebook() + def execute_cell(self, index: int, kernel_client: KernelClient) -> dict: + """Execute a cell given by its index with the provided kernel client. + + The outputs will directly be stored within the notebook model. + + Args: + index: Index of the cell to be executed + kernel_client: Kernel client to use + + Returns: + Execution results {"execution_count": int | None, "status": str, "outputs": list[dict]} - def execute_cell(self, index: int, kernel_client: t.Any) -> None: + The outputs will follow the structure of nbformat outputs. + """ ycell = t.cast(pycrdt.Map, self._doc.ycells[index]) source = ycell["source"].to_py() @@ -112,8 +221,9 @@ def execute_cell(self, index: int, kernel_client: t.Any) -> None: ycell["execution_count"] = None ycell["execution_state"] = "running" + outputs = [] reply = kernel_client.execute_interactive( - source, output_hook=partial(output_hook, ycell), allow_stdin=False + source, output_hook=partial(output_hook, outputs, ycell), allow_stdin=False ) reply_content = reply["content"] @@ -122,6 +232,22 @@ def execute_cell(self, index: int, kernel_client: t.Any) -> None: ycell["execution_count"] = reply_content.get("execution_count") ycell["execution_state"] = "idle" - def insert(self, index: int, value: NotebookNode) -> None: + return { + "execution_count": reply_content.get("execution_count"), + "outputs": outputs, + "status": reply_content["status"], + } + + def insert(self, index: int, value: dict[str, t.Any]) -> None: + """Insert a new cell at position index. + + Args: + index: The position of the inserted cell + value: A mapping describing the cell + """ ycell = self._doc.create_ycell(value) self._doc.ycells.insert(index, ycell) + + def _reset_y_model(self) -> None: + """Reset the Y model.""" + self._doc = YNotebook()