This article will give you an overview of how to use jupyter.net client, an overview of the Jupyter framework, and how the code of jupyter.net client is structured. Along with the source code, key information has been provided. Some links have been given for those who would like to gain more in-depth knowledge.
Introduction
In this article, I will present a C# library called jupyter.net client which allows to interact with a Jupyter Kernel.
Jupyter is a project that provides a framework for doing interactive computation in any programming language. The main components of this framework are reported in the figure below. The client interacts with the user asking code to execute and shows the output received from the kernel. The kernel executes the code and keeps the computation status (local variables, user functions, …). The communication between client and server takes place via ZeroMQ sockets and using the protocol described below.
It's possible to connect multiple clients to a single kernel, but in this article, I’ll consider only the case in which one kernel is connected to one client.
The Jupyter project also defines the Notebook specification: a file format that the client can use to save in a file the source code, the results of the computation and other data.
There are many existing implementations of Jupyter clients. Here are the most used standalone applications:
- Jupyter notebook
- Jupyter console
- JupyterLab
- Nteract
- CoCalc
- Spyder
In addition, there is the jupyter_client
library (https://pypi.org/project/jupyter-client/) to interact with a kernel programmatically in Python. The project presented here is intended to be a C# alternative to this library.
Some possible uses for jupyter.net client are:
- Create a customized frontend in C# for any scripting language. For example, you could write a custom tool for data analysis exploiting the python libraries,
numpy
and panda
. - Use it as a script engine, to run a script in any language for which there is a jupyter kernel available in a C# application.
In this article, I’ll try to give an overview of the following topics:
- How to use jupyter.net client
- An overview of the Jupyter framework
- How the code of jupyter.net client is structured
It will require much more than an article to explain these arguments deeply, so I’m going to provide just the key information and I will give some links to learn more. You can also look at the attached source code, which is quite simple.
jupyter.net client is available on GitHub (https://github.com/andreaschiavinato/jupyter.net_client) or as a NuGet package named JupiterNetClient
.
This article will be followed by another one that will present a complete C# Windows application that allows to interact with a Jupyter kernel using jupyter.net client.
Jupyter Software Installation
There is a suite of software useful for Jupyer that can be installed, which includes:
- The Jupyter notebook: a web application for doing interactive computation
- The Jupyter console: a simple console application for doing interactive computation
- The Python kernel: a kernel for the Python language, which is used by default with Jupyter notebook and Jupyter console
- Some utilities, like jupyter-kernelspec.exe which is used in python.net client to get the available kernels.
To install it, first install python, then run python -m pip install jupyter
”.
To test if you installed Jupyter correctly, you can run python -m jupyter notebook
. After a while, the Jupyter Notebook web application should start.
Hello World Application
The code below is a simple C# application that executes the code print("Hello from Jupyter")
on a Python kernel. To compile it, you need to import the JupiterNetClient
NuGet package, and to run it, you need to install the software described in the previous section.
using JupiterNetClient;
using JupiterNetClient.Nbformat;
using System;
using System.Linq;
class Program
{
static void Main(string[] args)
{
var client = new JupyterBlockingClient();
var kernels = client.GetKernels();
if (kernels.Count == 0)
throw new Exception("No kernels found");
Console.WriteLine($"Connecting to kernel {kernels.First().Value.spec.display_name}");
client.StartKernel(kernels.First().Key);
Console.WriteLine("Connected\n");
client.OnOutputMessage += Client_OnOutputMessage;
client.Execute("print(\"Hello from Jupyter\")");
client.Shutdown();
Console.WriteLine("Press enter to exit");
Console.ReadLine();
}
private static void Client_OnOutputMessage(object sender, JupyterMessage message)
{
switch (message.content)
{
case JupyterMessage.ExecuteResultContent executeResultContent:
Console.WriteLine($"[{executeResultContent.execution_count}] -
{executeResultContent.data[MimeTypes.TextPlain]}");
break;
case JupyterMessage.StreamContent streamContent:
Console.WriteLine(streamContent.text);
break;
default:
break;
}
}
}
If the program runs successfully, you'll see the following output:
Connecting to kernel Python 3
Connected
Hello from Jupyter
Press enter to exit
Main Classes Description
Below are the main classes of jupyter.net client library:
JupyterClientBase
is the main class, it implements the methods to connect to a Kernel, get the available kernels, execute code, etc.
There are two versions of it that you can use: one blocking (JupyterBlockingClient
) and one not blocking (JupyterClient
). In short, on the blocking version, the function that executes a code waits until the code execution is completed, conversely the non blocking variant returns immediately and raises an event on completion. This architecture is similar to the one of the official implementation of the Jupyter client (https://pypi.org/project/jupyter-client/). I found it useful to look at his code to better understand how a Jupyter client is supposed to work.
The KernelManager
class is used to discover and connect to a Jupyter kernel and it is used internally in the library.
The Notebook
class can be used to read or save a jupyter notebook file.
The JupyterMessage
class is used to handle the messages exchanged between the Kernel and the Client.
Finding a Kernel and Connecting to It
In the following sections, I'll provide some technical details about the Jupyter framework. Let's start by looking at how a Jupyter client can connect to a kernel.
Each kernel is defined by a json file like this:
{
"argv": [
"python",
"-m",
"ipykernel_launcher",
"-f",
"{connection_file}"
],
"display_name": "Python 3",
"language": "python"
}
This structure is called Kernelspec
and is defined here.
In my computer, those files are located in the folder C:\Users\Andrea\AppData\Local\Programs\Python\Python37\share\jupyter\kernels\, but a better way to retrieve this information is to run jupyter-kernelspec.exe list
(it is located on the subfolder Scripts of the Python directory, and it is used on the function KernelManager.GetKernels()
).
The args
member of the kernelspec
defines the command line to execute to start the kernel. Once a kernel is started, it should create another file called connection file which identifies the kernel instance and contains all the information required to connect to it (see https://jupyter-client.readthedocs.io/en/latest/kernels.html#connection-files and the code of the function KernelManager.StartKernel()
).
Below is an example of connection file:
{
"shell_port": 64656,
"iopub_port": 64665,
"stdin_port": 64659,
"control_port": 64662,
"hb_port": 64675,
"ip": "127.0.0.1",
"key": "5f5313c7-fb04d880c4e5756a645e1a97",
"transport": "tcp",
"signature_scheme": "hmac-sha256",
"kernel_name": ""
}
The connection file contains the port number for five ZMQ sockets, used to communicate with the kernel, and a key, used to create a signature to add on all the messages.
ZeroMQ Sockets
ZeroMQ sockets are the tool used to communicate with the kernel. A good description of the ZMQ sockets is given at http://zguide.zeromq.org:
ZeroMQ (also known as ØMQ, 0MQ, or zmq) looks like an embeddable networking library but acts like a concurrency framework. It gives you sockets that carry atomic messages across various transports like in-process, inter-process, TCP, and multicast. You can connect sockets N-to-N with patterns like fan-out, pub-sub, task distribution, and request-reply. It's fast enough to be the fabric for clustered products. Its asynchronous I/O model gives you scalable multicore applications, built as asynchronous message-processing tasks. It has a score of language APIs and runs on most operating systems. ZeroMQ is from iMatix and is LGPLv3 open source.
As you can read, ZeroMQ provides different communication patterns. Below are the ones used in Jupyter:
- Request/Response: A RESPONSE socket waits for requests from any REQUEST socket. Then the RESPONSE socket may provide a reply to the REQUEST socket
- Publisher/Subscriber: A PUBLISHER socket is used to publish messages and other SUBSCRIBER sockets can subscribe to it to receive these messages.
- Router/Dealer: This is a more sophisticated version of the Request/Response pattern, where the ROUTER can be seen as an asynchronous version of a REQUEST socket and the DEALER as an asynchronous version of the RESPONSE. By asynchronous, I mean that the DEALER can handle multiple requests at the same time, from different nodes. However, for the purpose of this article, it’s enough to consider this equivalent to the Request/Response.
Below is a picture showing the ZeroMQ sockets that a Kernel and a Client can use to communicate. A more detailed description is given at https://jupyter-client.readthedocs.io/en/stable/messaging.html.
There are two main C# implementations of ZeroMQ sockets:
In this project, I’m using ZeroMQ
, but for our proposes, they look equivalent.
Jupyter Protocol
The messages exchanged through these sockets use the JSON format. A message has the following fields:
Header
: contains a unique identifier for the message, a string containing the username, a session identifier, a timestamp, the type of message and the protocol version Parent_header
: It’s a copy of the header of the parent cell (if any). For example, the code execution response message has as parent the code execution request message. Metadata
: additional metadata associated with the message. On the specification, it’s not specified clearly how to use this information, and on jupyter.net client, the metadata is not used. Content
: the content of the message, that depends on the message type Buffers
: list of binary data buffers for implementations that support binary extensions to the protocol. On jupyter.net, this information is not used.
The types of messages available at the time are:
execute_request
: used by the client to ask the kernel to perform a certain action execute_reply
: used by the kernel to inform the client that the action requested has been completed status
: used by the kernel to communicate to the clients its status display_data
: used by the kernel to communicate to the clients that there is some data to show to the user execute_result
: used by the kernel to communicate to the client the result of a computation input_request
: used by the kernel to communicate to the client that it needs an input from the user; execute_input
: used by the kernel to broadcast to all the connected clients the code that it is being executed error
: used by the kernel to communicate that an error occurred during the computation
For more information, see https://jupyter-client.readthedocs.io/en/stable/messaging.html#messages-on-the-shell-router-dealer-channel.
On jupyter.net client, the class JupyterMessage
has been created to handle these messages. As shown in the figure below, there is an abstract
class to handle the content which has different subclasses depending on the message type.
The interaction between client and server follows this schema:
- The client sends to the kernel an
execute_request
message through the shell
socket. - The kernel publishes on the
iopub
socket a status
message indicating it is busy. - The kernel publishes on the
iopub
socket any required display_data
/execute_result
/error
message, or on the stdin
socket any execure_input
message. - The kernel sends on the
shell
socket an execute_reply
message indicating the computation has been completed. - The kernel publishes on the
iopub
socket a status
message indicating it is ready again.
Commands Available
The Jupyter protocol provides the following commands that the client can use on an execute_request
message:
- Execute: execute some code
- Introspection: provide information about a code (for instance, the type of a variable, but it’s up to the kernel to decide what information to provide)
- Completion: provide a
string
to complete the current code - History: provides a list of the recent executed statements
- Code completeness: indicate whether the current code can be executed as it is, or if the client should ask the user to enter one more line
- Kernel info: provides information about the kernel
- Kernel shutdown: closes the kernel
- Kernel interrupt: interrupts the current computation
For each of these commands, a method on the class JupyterClient
/ JupyterBlokingClient
is provided.
Executing Code
The sequence diagram below illustrates the messages exchanged between the Client and the Kernel to execute a code.
The Client sends a ZeroMQ
message of type execute_request
on the shell socket containing the code to execute.
The kernel sends a message of type status
on the IOPub
socket indicating it is busy, then confirms the message has been received by sending a message of type execute_input
with a copy of the code received and a progressive number to identify this statement.
Then it sends the result of the execution by sending a message of type execute_result
. Some code may not produce an execute_result
and instead the kernel may send a stream
or a display_data
message.
Then it sends a message of type execute_reply
on the shell
socket, indicating that the execution is completed.
Finally, it sends a status
message on the IOPub
socket, indicating that it is ready to process the next request.
Command Line Client
Below, I report the code of a command line client that can be used to interact with a Jupyter kernel. It is a C# version of the Jupyter console application (https://github.com/jupyter/jupyter_console).
using JupiterNetClient;
using JupiterNetClient.Nbformat;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
class Program
{
private const string Prompt = ">>> ";
private const string PromptWhite = "... ";
private static JupyterBlockingClient client;
private static Dictionary<string, KernelSpec> kernels;
static void Main(string[] args)
{
client = new JupyterBlockingClient();
kernels = client.GetKernels();
if (kernels.Count == 0)
throw new Exception("No kernels found");
Console.WriteLine($"Connecting to kernel {kernels.First().Value.spec.display_name}");
client.StartKernel(kernels.First().Key);
DisplayKernelInfo(client.KernelInfo);
client.OnOutputMessage += Client_OnOutputMessage;
client.OnInputRequest += Client_OnInputRequest;
Console.WriteLine("\n\nEnter code to execute or Q <enter> to terminate:");
MainLoop(client);
Console.WriteLine("SHUTTING DOWN KERNEL");
client.Shutdown();
}
Below is the MainLoop
procedure:
private static void MainLoop(JupyterBlockingClient client)
{
ReadLine.HistoryEnabled = true;
ReadLine.AutoCompletionHandler = new AutoCompletionHandler(client);
var enteredCode = new StringBuilder();
var startNewCode = true;
var lineIdent = string.Empty;
while (true)
{
enteredCode.Append(ReadLine.Read
(startNewCode ? Prompt : PromptWhite + lineIdent));
var code = enteredCode.ToString();
if (code == "Q")
{
return;
}
else if (string.IsNullOrWhiteSpace(code))
{
}
else
{
var isComplete = client.IsComplete(code);
switch (isComplete.status)
{
case JupyterMessage.IsCompleteStatusEnum.complete:
client.Execute(code);
startNewCode = true;
break;
case JupyterMessage.IsCompleteStatusEnum.incomplete:
lineIdent = isComplete.indent;
enteredCode.Append("\n" + lineIdent);
startNewCode = false;
break;
case JupyterMessage.IsCompleteStatusEnum.invalid:
case JupyterMessage.IsCompleteStatusEnum.unknown:
Console.WriteLine("Invalid code: " + code);
startNewCode = true;
break;
}
}
if (startNewCode)
{
enteredCode.Clear();
}
}
}
The AutoCompletionHandler
class is used by the Readline
component to support the autocompletion:
private class AutoCompletionHandler : IAutoCompleteHandler
{
private readonly JupyterBlockingClient _client;
public AutoCompletionHandler(JupyterBlockingClient client)
{
_client = client;
}
public char[] Separators { get; set; } = new char[] { };
public string[] GetSuggestions(string text, int index)
{
var result = _client.Complete(text, text.Length);
return result.matches
.Select(s => text.Substring(0, result.cursor_start) + s)
.ToArray();
}
}
This is the callback to display the output messages:
private static void Client_OnOutputMessage(object sender, JupyterMessage message)
{
switch (message.content)
{
case JupyterMessage.ExecuteInputContent executeInputContent:
Console.WriteLine($"Executing
[{executeInputContent.execution_count}] - {executeInputContent.code}");
break;
case JupyterMessage.ExecuteResultContent executeResultContent:
Console.WriteLine($"Result
[{executeResultContent.execution_count}] -
{executeResultContent.data[MimeTypes.TextPlain]}");
break;
case JupyterMessage.DisplayDataContent displayDataContent:
Console.WriteLine($"Data {displayDataContent.data}");
break;
case JupyterMessage.StreamContent streamContent:
Console.WriteLine($"Stream {streamContent.name} {streamContent.text}");
break;
case JupyterMessage.ErrorContent errorContent:
Console.WriteLine($"Error {errorContent.ename} {errorContent.evalue}");
Console.WriteLine(errorContent.traceback);
break;
case JupyterMessage.ExecuteReplyContent executeReplyContent:
Console.WriteLine($"Executed
[{executeReplyContent.execution_count}] - {executeReplyContent.status}");
break;
default:
break;
}
}
This is the callback to ask the user for an input:
private static void Client_OnInputRequest
(object sender, (string prompt, bool password) e)
{
var input = e.password
? ReadLine.ReadPassword(e.prompt)
: ReadLine.Read(e.prompt);
client.SendInputReply(input);
}
And finally, this is the method used to display information about the kernel:
private static void DisplayKernelInfo(JupyterMessage.KernelInfoReplyContent kernelInfo)
{
Console.WriteLine("");
Console.WriteLine(" KERNEL INFO");
Console.WriteLine("============");
Console.WriteLine($"Banner: {kernelInfo.banner}");
Console.WriteLine($"Status: {kernelInfo.status}");
Console.WriteLine($"Protocol version: {kernelInfo.protocol_version}");
Console.WriteLine($"Implementation: {kernelInfo.implementation}");
Console.WriteLine($"Implementation version: {kernelInfo.implementation_version}");
Console.WriteLine($"Language name: {kernelInfo.language_info.name}");
Console.WriteLine($"Language version: {kernelInfo.language_info.version}");
Console.WriteLine($"Language mimetype: {kernelInfo.language_info.mimetype}");
Console.WriteLine($"Language file_extension: {kernelInfo.language_info.file_extension}");
Console.WriteLine($"Language pygments_lexer: {kernelInfo.language_info.pygments_lexer}");
Console.WriteLine($"Language nbconvert_exporter:
{kernelInfo.language_info.nbconvert_exporter}");
}
Notebook Format
A Jupyter notebook is a Json file like the one below:
{
"metadata": {
"kernel_info": {
"name": "Python 3"
},
"language_info": {
"name": "python",
"version": "3.7.2"
}
},
"nbformat": 4,
"nbformat_minor": 2,
"cells": [{
"execution_count": 1,
"outputs": [{
"execution_count": 1,
"data": {
"text/plain": ["4"]
},
"output_type": "execute_result"
}],
"cell_type": "code",
"source": ["2+2"],
}]
}
It has a metadata
object containing information about the kernel and the language used. It is the responsibility of the client to make sure that any notebook file opened is compatible with the kernel in use.
Then there is a list of cell elements, that can be of one of the following types:
- Code: contains the code to run, and may contain the outputs of the execution
- Markdown: contains text formatted using the markdown syntax
- Raw: special cells that contains data for the nconvert utility
The following example reads a notebook and prints its content on the screen:
using JupiterNetClient.Nbformat;
using System;
class Program
{
static void Main(string[] args)
{
var nb = Notebook.ReadFromFile(@"test.ipynb");
Console.WriteLine($"Langauge: {nb.metadata.language_info.name}
{nb.metadata.language_info.version}");
Console.WriteLine($"Kernel: {nb.metadata.kernel_info.name}");
Console.WriteLine($"Notebook format: {nb.nbformat}.{nb.nbformat_minor}");
Console.WriteLine("\nContent:\n");
foreach (var cell in nb.cells)
{
switch (cell)
{
case MarkdownCell markdownCell:
Console.WriteLine(markdownCell.source);
break;
case CodeCell codeCell:
Console.WriteLine(codeCell.source);
foreach (var output in codeCell.outputs)
{
Console.Write(" " + output.output_type + ": ");
switch (output)
{
case StreamOutputCellOutput streamOutputCellOutput:
Console.WriteLine($"{streamOutputCellOutput.name}
{streamOutputCellOutput.text}");
break;
case DisplayDataCellOutput displayDataCellOutput:
Console.WriteLine
(displayDataCellOutput.data[MimeTypes.TextPlain]);
break;
case ExecuteResultCellOutput executeResultCellOutput:
Console.WriteLine
(executeResultCellOutput.data[MimeTypes.TextPlain]);
break;
case ErrorCellOutput errorCellOutput:
Console.WriteLine($"{errorCellOutput.ename}
{errorCellOutput.evalue}");
break;
}
}
break;
case RawCell _:
Console.WriteLine($"(raw cell)");
break;
}
}
Console.ReadLine();
}
}
The following program shows how to create a simple notebook:
using JupiterNetClient;
using JupiterNetClient.Nbformat;
using System;
using System.Linq;
class Program
{
static void Main(string[] args)
{
var client = new JupyterBlockingClient();
var kernels = client.GetKernels();
if (kernels.Count == 0)
throw new Exception("No kernels found");
Console.WriteLine($"Connecting to kernel {kernels.First().Value.spec.display_name}");
client.StartKernel(kernels.First().Key);
Console.WriteLine("Connected");
var nb = new Notebook(client.KernelSpec, client.KernelInfo.language_info);
var cell = nb.AddCode("print(\"Hello from Jupyter\")");
client.OnOutputMessage += (sender, message) =>
{ if (ShouldWrite(message)) cell.AddOutputFromMessage(message); };
client.Execute(cell.source);
nb.Save("test.ipynb");
Console.WriteLine("File test.ipynb written");
client.Shutdown();
Console.WriteLine("Press enter to exit");
Console.ReadLine();
}
private static bool ShouldWrite(JupyterMessage message) =>
message.header.msg_type == JupyterMessage.Header.MsgType.execute_result
|| message.header.msg_type == JupyterMessage.Header.MsgType.display_data
|| message.header.msg_type == JupyterMessage.Header.MsgType.stream
|| message.header.msg_type == JupyterMessage.Header.MsgType.error;
}
Executing a Python Script from a C# App and Getting the Result
You can use python.net client to run a Python script from your C# application. Unlike libraries like IronPython or Python for .NET (pythonnet), with jupyter.net client (and a Python kernel), the Python code is executed on a separated process, so it is safer because it cannot crash your application and it can eventually be executed in a remote machine. You can also easily update the python kernel without recompiling the C# application.
Below is an example that shows how python.net client can be used to run a function written in a .py file and get the result.
using JupiterNetClient;
using JupiterNetClient.Nbformat;
using System;
using System.Linq;
using System.Threading.Tasks;
class Program
{
static void Main(string[] args)
{
var client = new JupyterBlockingClient();
var kernels = client.GetKernels();
if (kernels.Count == 0)
throw new Exception("No kernels found");
Console.WriteLine($"Connecting to kernel {kernels.First().Value.spec.display_name}");
client.StartKernel(kernels.First().Key);
Console.WriteLine("Connected\n");
client.Execute("%run script.py");
var promise = new TaskCompletionSource<string>();
EventHandler<JupyterMessage> hanlder = (sender, message) =>
{
if (message.header.msg_type == JupyterMessage.Header.MsgType.execute_result)
{
var content = (JupyterMessage.ExecuteResultContent)message.content;
promise.SetResult(content.data[MimeTypes.TextPlain]);
}
else if (message.header.msg_type == JupyterMessage.Header.MsgType.error)
{
var content = (JupyterMessage.ErrorContent)message.content;
promise.SetException(new Exception
($"Jupyter kenel error: {content.ename} {content.evalue}"));
}
};
client.OnOutputMessage += hanlder;
client.Execute("do_something(2)");
client.OnOutputMessage -= hanlder;
try
{
Console.WriteLine("Result:");
if (promise.Task.IsCompleted)
Console.WriteLine(promise.Task.Result);
else
Console.WriteLine("No result received");
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
finally
{
client.Shutdown();
Console.WriteLine("Press enter to exit");
Console.ReadLine();
}
}
}
The expected output is:
Connecting to kernel Python 3
Connected
Result:
4
Press enter to exit
History
- 3rd November, 2019: Initial version