From eee476ffc8f3e24f117112818cc952faf22581ec Mon Sep 17 00:00:00 2001 From: Jim Youngquist Date: Thu, 19 Mar 2015 18:08:03 -0700 Subject: [PATCH] finished with documentation first draft --- CMakeLists.txt | 38 +++--- README.md | 82 +++++++++++-- screenshot.png | Bin 0 -> 54261 bytes src/ReqRepConnection.cc | 25 ---- src/ReqRepConnection.hpp | 26 ----- src/RequestSink.cc | 44 +++++++ src/RequestSink.hpp | 65 +++++++++++ src/cpp_plot.cc | 44 +++---- src/cpp_plot.hpp | 23 +++- src/ipython_protocol.cc | 199 +++++++++++++++++++++++-------- src/ipython_protocol.hpp | 245 ++++++++++++++++++++++++++++++--------- src/ipython_run.py | 6 + src/main.cc | 11 +- src/pyplot_listener.py | 51 ++++---- src/test.py | 6 + 15 files changed, 627 insertions(+), 238 deletions(-) create mode 100644 screenshot.png delete mode 100644 src/ReqRepConnection.cc delete mode 100644 src/ReqRepConnection.hpp create mode 100644 src/RequestSink.cc create mode 100644 src/RequestSink.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 0316219..38c9ae0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,45 +2,33 @@ cmake_minimum_required (VERSION 2.6) set (PROJECT_NAME cpp-matplotlib) -SET (CMAKE_C_COMPILER "/usr/bin/clang-3.5") -SET (CMAKE_CXX_COMPILER "/usr/bin/clang++-3.5") +# If you want to use clang, uncomment these. +#SET (CMAKE_C_COMPILER "/usr/bin/clang-3.5") +#SET (CMAKE_CXX_COMPILER "/usr/bin/clang++-3.5") project (${PROJECT_NAME}) +set (EXAMPLE_BIN ${PROJECT_NAME}-example) include_directories ("${PROJECT_SOURCE_DIR}/src") -add_executable (${PROJECT_NAME} src/main.cc) +add_executable (${EXAMPLE_BIN} src/main.cc) ## Support for Clang's CompilationDatabase system set (CMAKE_EXPORT_COMPILE_COMMANDS 1) -## Set optional features. This will show up as a preprocessor variable -## USE_MY_LIBRARY in source. -#option (USE_MY_LIBRARY -# "Use the provided library" ON) - ## Compile and create a library. STATIC is default unless BUILD_SHARED_LIBS ## is on. -add_library (cpp_plot src/cpp_plot.cc src/ReqRepConnection.cc) -add_library (ipython_protocol src/ipython_protocol.cc) -set (EXTRA_LIBS ${EXTRA_LIBS} cpp_plot ipython_protocol) - -#if (USE_MY_LIBRARY) - -## Search for include files here as well -#include_directories ("${PROJECT_SOURCE_DIR}/some_sub_path") - -## Run Cmake also in this dir -#add_subdirectory (some_sub_path) - -#set (EXTRA_LIBS ${EXTRA_LIBS} LibraryName) +add_library (cpp_plot + src/cpp_plot.cc + src/RequestSink.cc + src/ipython_protocol.cc) -#endif (USE_MYMATH) +set (EXTRA_LIBS ${EXTRA_LIBS} cpp_plot) ## Libraries to link with -target_link_libraries (${PROJECT_NAME} - zmq jsoncpp uuid ssl crypto - ${EXTRA_LIBS}) +target_link_libraries (${EXAMPLE_BIN} + ${EXTRA_LIBS} + zmq jsoncpp uuid ssl crypto) SET (CMAKE_C_FLAGS "-Wall -std=c11 -Wextra -Werror") SET (CMAKE_C_FLAGS_DEBUG "${CMAKE_CFLAGS} -g") diff --git a/README.md b/README.md index 7d3a2cb..da10038 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,65 @@ +# Contents + +[About](#about) +[Usage] (#usage) +[Prereqs](#prereqs) +[Building](#building) +[Example](#example) + + # About An easy-to-use library for simple plotting from C++ via a ZeroMQ bridge to -Python's Matplotlib. +an [IPython](http://ipython.org/) kernel. -# Prereqs +It provides the ability to send [NumPy](http://www.numpy.org/) array +compatible data to an IPython kernel session as well as execute arbitrary +code. + +Execution of arbitrary code is "protected" by IPython's kernel HMAC signing +mechanism: only code signed by a secret key provided by IPython will run. As +of 2015-03-19 it has been tested with IPython version 1.2.1 on Ubuntu 14.04. + + +# Usage + +Here we create some 1D data and plot it. The variable "A" will be available +for working with in the IPython session, even after the C++ program finishes. + + CppMatplotlib mpl{"/path/to/kernel-NNN.json"}; + mpl.Connect(); + + // Create a nice curve + std::vector raw_data; + double x = 0.0; + while (x < 3.14159 * 4) { + raw_data.push_back(std::sin(x)); + x += 0.05; + } + + // Send it to IPython for plotting + NumpyArray data("A", raw_data); + mpl.SendData(data); + mpl.RunCode("plot(A)\n" + "title('f(x) = sin(x)')\n" + "xlabel('x')\n" + "ylabel('f(x)')\n"); -## Ubuntu +And the result will be ![Screenshot](screenshot.png?raw=true "Screenshot of +sin(x)") - sudo apt-get install libzmq3-dev python-zmq python-matplotlib +See [src/main.cc](src/main.cc) for a complete program. -Some sort of pyqt - sudo apt-get install python-pyside.qtcore python-pyside.qtgui +# Prereqs + +## Ubuntu 14.04 + + sudo apt-get install ipython python-matplotlib libzmq3-dev \ + libjsoncpp-dev uuid-dev libssl-dev -# Build + +# Building git clone https://bitbucket.org/james_youngquist/cpp-matplotlib.git cd cpp-matplotlib @@ -22,13 +68,29 @@ Some sort of pyqt cmake .. make -# Run (alpha demo) +## Generating the documentation + + cd cpp-matplotlib + doxygen Doxyfile + # open html/index.html + +# Example In terminal 1: - python cpp-matplotlib/pyplot_listener.py + ipython kernel --pylab + # this will print out the kernel PID to connect to, NNN below. In terminal 2: - cpp-matplotlib/build/cpp-matplotlib + # Once per kernel invocation: + export KERNEL_CONFIG=`find ~/ -name kernel-NNN.json` + + # Each time you run the program + build/cpp-matplotlib-example $KERNEL_CONFIG + + +In terminal 3 (if desired): + + ipython console --existing kernel-NNN.json diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..5c2a98fb5e957f40a01839e758de932c29771d05 GIT binary patch literal 54261 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 P00000NkvXXu0mjf!})0e literal 0 HcmV?d00001 diff --git a/src/ReqRepConnection.cc b/src/ReqRepConnection.cc deleted file mode 100644 index 3a88d09..0000000 --- a/src/ReqRepConnection.cc +++ /dev/null @@ -1,25 +0,0 @@ -#include "ReqRepConnection.hpp" - -bool ReqRepConnection::Send(const std::string &buffer) { - std::vector byte_buffer(buffer.begin(), buffer.end()); - return Send(byte_buffer); -} - -bool ReqRepConnection::Send(const std::vector &buffer) { - zmq::message_t request((void*)&buffer[0], buffer.size(), nullptr); - socket_.send(request); - - // Get the reply - zmq::message_t reply; - socket_.recv(&reply); - - return true; -} - -bool ReqRepConnection::Connect(void) { - socket_.connect(url_.c_str()); - connected_ = true; - - return true; -} - diff --git a/src/ReqRepConnection.hpp b/src/ReqRepConnection.hpp deleted file mode 100644 index 7539c9f..0000000 --- a/src/ReqRepConnection.hpp +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include -#include - -#include - -class ReqRepConnection { -public: - ReqRepConnection(const std::string &url) : - context_{1}, - socket_{context_, ZMQ_REQ}, - url_{url}, - connected_{false} - {} - - bool Send(const std::string &buffer); - bool Send(const std::vector &buffer); - bool Connect(void); - -private: - zmq::context_t context_; - zmq::socket_t socket_; - const std::string url_; - bool connected_; -}; diff --git a/src/RequestSink.cc b/src/RequestSink.cc new file mode 100644 index 0000000..d3f343a --- /dev/null +++ b/src/RequestSink.cc @@ -0,0 +1,44 @@ +// +// Copyright (c) 2015 Jim Youngquist +// under The MIT License (MIT) +// full text in LICENSE file in root folder of this project. +// + +#include + +#include "RequestSink.hpp" + +RequestSink::RequestSink(const std::string &url) : + context_{1}, + socket_{context_, ZMQ_REQ}, + url_{url}, + connected_{false} +{} + +bool RequestSink::Send(const std::string &buffer) { + std::vector byte_buffer(buffer.begin(), buffer.end()); + return Send(byte_buffer); +} + +bool RequestSink::Send(const std::vector &buffer) { + zmq::message_t request((void*)&buffer[0], buffer.size(), nullptr); + socket_.send(request); + + // Get the reply + zmq::message_t reply; + socket_.recv(&reply); + std::string value{reinterpret_cast(reply.data()), reply.size()}; + if (value != "Success") { + std::runtime_error(value.c_str()); + } + + return true; +} + +bool RequestSink::Connect(void) { + socket_.connect(url_.c_str()); + connected_ = true; + + return true; +} + diff --git a/src/RequestSink.hpp b/src/RequestSink.hpp new file mode 100644 index 0000000..4d20ba7 --- /dev/null +++ b/src/RequestSink.hpp @@ -0,0 +1,65 @@ +// +// Copyright (c) 2015 Jim Youngquist +// under The MIT License (MIT) +// full text in LICENSE file in root folder of this project. +// + +#pragma once + +#include +#include + +#include + +//====================================================================== +/** \brief This class wraps a ZeroMQ request-response socket connection. + * + * It acts as a one-way sink for data. Responses are not actually returned, + * only checked that the "request" was successfully transmitted. + * + * Usage: +\code + RequestSink conn{"tcp://hostname:port"}; + conn.Connect(); + conn.Send("oh my goodness!"); +\endcode + */ +class RequestSink { +public: + //-------------------------------------------------- + /** \brief Initializes for a connection to a valid ZeroMQ endpoint, but does + * not actually connect to it. + * + * \param url the ZeroMQ url to connect to. + */ + RequestSink(const std::string &url); + + //-------------------------------------------------- + /** \brief Transmits a string. + * + * \param buffer the string to send. + * + * \returns whether transmission was successful. + */ + bool Send(const std::string &buffer); + + //-------------------------------------------------- + /** \brief Transmits a byte vector + * + * \param buffer the bytes to send. + * + * \returns whether transmission was successful. + */ + bool Send(const std::vector &buffer); + + //-------------------------------------------------- + /** \brief Actually connects to a Request socket. + */ + bool Connect(void); + +private: + zmq::context_t context_; + zmq::socket_t socket_; + const std::string url_; + bool connected_; +}; diff --git a/src/cpp_plot.cc b/src/cpp_plot.cc index 6dbfe4c..3940d8a 100644 --- a/src/cpp_plot.cc +++ b/src/cpp_plot.cc @@ -1,3 +1,9 @@ +// +// Copyright (c) 2015 Jim Youngquist +// under The MIT License (MIT) +// full text in LICENSE file in root folder of this project. +// + #include #include #include @@ -10,11 +16,12 @@ #include "cpp_plot.hpp" #include "ipython_protocol.hpp" -#include "ReqRepConnection.hpp" +#include "RequestSink.hpp" -const uint32_t NAME_LEN = 16; +// Names of the python variables +static const std::string THREAD_VAR_NAME{"cpp_ipython_listener_thread"}; +static const std::string PORT_VAR_NAME{"cpp_ipython_listener_thread_port"}; -// Reads an entire file into a string std::string LoadFile(std::string filename) { std::ifstream infile(filename); std::stringstream buffer; @@ -22,19 +29,6 @@ std::string LoadFile(std::string filename) { return buffer.str(); } -// Randomly generates a name. -std::string GetName(void) { - std::default_random_engine generator; - std::uniform_int_distribution letters_distribution('A', 'Z'); - std::string salt{"cppmpl_"}; - std::string random_name(NAME_LEN - salt.size(), 'a'); - for (size_t i = 0; i != NAME_LEN - salt.size(); ++i) { - random_name[i] = letters_distribution(generator); - } - std::string name = salt + random_name; - return name; -} - //====================================================================== void NumpyArray::SetData (const dtype *data, size_t rows, size_t cols) { @@ -92,7 +86,7 @@ std::string NumpyArray::Name (void) const { //====================================================================== CppMatplotlib::CppMatplotlib (const std::string &config_filename) : upConfig_{new IPyKernelConfig(config_filename)}, - upData_conn_{new ReqRepConnection("tcp://localhost:5555")}, + upData_conn_{nullptr}, // don't know what port listener thread will be on upSession_{new IPythonSession(*upConfig_)} {} @@ -102,25 +96,25 @@ CppMatplotlib::~CppMatplotlib (void) { void CppMatplotlib::Connect () { upSession_->Connect(); - upData_conn_->Connect(); auto &shell = upSession_->Shell(); - if (!shell.HasVariable("data_listener_thread")) { + if (!shell.HasVariable(THREAD_VAR_NAME)) { shell.RunCode(LoadFile("../src/pyplot_listener.py")); - shell.RunCode("data_listener_thread = ipython_run(globals())"); + shell.RunCode("cpp_ipython_start_thread(globals())"); } + std::string port_str = shell.GetVariable(PORT_VAR_NAME); + + upData_conn_.reset(new RequestSink("tcp://localhost:" + port_str)), + upData_conn_->Connect(); } bool CppMatplotlib::SendData(const NumpyArray &data) { std::vector buffer(data.WireSize()); data.SerializeTo(&buffer); - upData_conn_->Send(buffer); - return true; + return upData_conn_->Send(buffer); } - -bool CppMatplotlib::RunCode(const std::string &code) { +void CppMatplotlib::RunCode(const std::string &code) { upSession_->Shell().RunCode(code); - return true; } diff --git a/src/cpp_plot.hpp b/src/cpp_plot.hpp index fefcf65..e61ed6a 100644 --- a/src/cpp_plot.hpp +++ b/src/cpp_plot.hpp @@ -1,3 +1,9 @@ +// +// Copyright (c) 2015 Jim Youngquist +// under The MIT License (MIT) +// full text in LICENSE file in root folder of this project. +// + #pragma once #include @@ -9,9 +15,18 @@ // Forward declarations struct IPyKernelConfig; class IPythonSession; -class ReqRepConnection; +class RequestSink; + -std::string GetName(void); +// Reads an entire file into a string +//-------------------------------------------------- +/** \brief Reads an entire file into a string. Make sure you have enough + * memory... + * + * \param filename the name of the file. + * + * \returns contents of the file. + */ std::string LoadFile(std::string filename); @@ -207,7 +222,7 @@ class CppMatplotlib { * \param code the code as a single string. If it spans multiple lines, it * must explicitly contain newline characters and appropriate whitespace. */ - bool RunCode (const std::string &code); + void RunCode (const std::string &code); //---------------------------------------------------------------------- /** \brief Sends a Numpy compatible array to the iPython kernel's global @@ -220,6 +235,6 @@ class CppMatplotlib { private: std::unique_ptr upConfig_; - std::unique_ptr upData_conn_; + std::unique_ptr upData_conn_; std::unique_ptr upSession_; }; diff --git a/src/ipython_protocol.cc b/src/ipython_protocol.cc index 037093b..15665cd 100644 --- a/src/ipython_protocol.cc +++ b/src/ipython_protocol.cc @@ -1,3 +1,9 @@ +// +// Copyright (c) 2015 Jim Youngquist +// under The MIT License (MIT) +// full text in LICENSE file in root folder of this project. +// + #include #include @@ -32,6 +38,7 @@ std::string BuildUri (const IPyKernelConfig &config, PortType port) { } +//====================================================================== IPyKernelConfig::IPyKernelConfig (const std::string &jsonConfigFile) { // Parse the config file into JSON std::ifstream infile(jsonConfigFile); @@ -52,11 +59,31 @@ IPyKernelConfig::IPyKernelConfig (const std::string &jsonConfigFile) { } -std::vector IPythonMessage::GetMessageParts (void) const { - return {header, parent, metadata, content}; +//====================================================================== +// Note gcc < 4.9 doesn't allow uniform initialization of reference members so +// I have to do it the old fashioned way. +// See https://gcc.gnu.org/bugzilla/show_bug.cgi?id=50025 for discussion. +IPythonMessage::IPythonMessage(const std::vector &message_parts) : + message_parts_{{message_parts[0], message_parts[1], message_parts[2], + message_parts[3]}}, + header(message_parts_[0]), + parent(message_parts_[1]), + metadata(message_parts_[2]), + content(message_parts_[3]) +{} + +IPythonMessage::IPythonMessage(const std::string &ident) : + IPythonMessage{std::vector(4, Json::objectValue)} +{ + char username[80]; + getlogin_r(username, 80); + header["username"] = std::string(username); + header["session"] = ident; + header["msg_id"] = GetUuid(); } +//====================================================================== IPythonMessage MessageBuilder::BuildExecuteRequest ( const std::string &code) const { IPythonMessage message{ident_}; @@ -72,25 +99,136 @@ IPythonMessage MessageBuilder::BuildExecuteRequest ( return message; } + +//====================================================================== +IPythonHmac::IPythonHmac (const IPyKernelConfig &config) : + key_(config.key), + evp_type_fn_(GetEvpTypeFnFromConfig_(config)) +{} + +std::string IPythonHmac::operator() (const IPythonMessage &message) const { + ENGINE_load_builtin_engines(); + ENGINE_register_all_complete(); + + HMAC_CTX hmac_ctx; + HMAC_CTX_init(&hmac_ctx); + HMAC_Init_ex(&hmac_ctx, key_.data(), key_.size(), + evp_type_fn_(), nullptr); + + Json::FastWriter writer; + for (const auto& part : message) { + std::string serialized{writer.write(part)}; + HMAC_Update(&hmac_ctx, (const uint8_t*)(serialized.data()), + serialized.size()); + } + + unsigned int result_len = 32; + uint8_t result[32]; + HMAC_Final(&hmac_ctx, result, &result_len); + HMAC_CTX_cleanup(&hmac_ctx); + + std::stringstream hmac; + for (size_t i = 0; i != 32; ++i) { + hmac << std::setfill('0') << std::setw(2) << std::hex + << static_cast(result[i]); + } + + return hmac.str(); +} + +const IPythonHmac::EvpTypeFn IPythonHmac::GetEvpTypeFnFromConfig_ ( + const IPyKernelConfig &config) const { + if (config.signature_scheme == "hmac-sha256") { + return EVP_sha256; + } + else { + // default + throw std::runtime_error("Unknown signature scheme " + + config.signature_scheme); + } + return nullptr; +} + + +//====================================================================== void ShellConnection::Connect (void) { socket_.setsockopt(ZMQ_DEALER, ident_.data(), ident_.size()); socket_.connect(uri_.c_str()); } -IPythonMessage ShellConnection::Send (const IPythonMessage &message) { +void ShellConnection::RunCode (const std::string &code) { + GenericRun_(code, {}); +} + +bool ShellConnection::HasVariable (const std::string &variable_name) { + // The response looks like + // { + // "variable_name" : + // { + // "data" : + // { + // # data type (alway seems to be text/plain) : value + // "text/plain" : "<__main__.ListenerThread at 0x7fd6dabaef28>" + // }, + // "metadata" : {}, + // "status" : "ok" + // } + // } + IPythonMessage response = GenericRun_("None", {variable_name}); + return response.content["user_variables"][variable_name]["status"] + .asString() == "ok"; +} + +std::string ShellConnection::GetVariable (const std::string &variable_name) { + IPythonMessage response = GenericRun_("None", {variable_name}); + Json::Value variable_dict = response.content["user_variables"][variable_name]; + + // Check that the variable exists + if (variable_dict["status"] == "error") { + for (const auto &json : variable_dict["traceback"]) { + std::cerr << json.asString() << std::endl; + } + throw std::runtime_error("Error with looking up variable"); + } + + return response.content["user_variables"][variable_name]["data"] + ["text/plain"].asString(); +} + +// This is a helper for the specific methods that execute code or look for +// variables. +IPythonMessage ShellConnection::GenericRun_ ( + const std::string &code, const std::vector &variable_names) { + IPythonMessage command = message_builder_.BuildExecuteRequest(code); + for (const std::string &variable : variable_names) { + command.content["user_variables"].append(variable); + } + IPythonMessage response = Send_(command); + + // Error check code execution + if (response.content["status"].asString() == "error") { + for (const auto &json : response.content["traceback"]) { + std::cerr << json.asString() << std::endl; + } + throw std::runtime_error("Error with execute_request"); + } + return response; +} + +IPythonMessage ShellConnection::Send_ (const IPythonMessage &message) { if (!socket_.connected()) { // TODO throw an exception? return IPythonMessage("None"); } - std::vector message_parts = message.GetMessageParts(); - + // Fill in the byte buffer vector with all the data required for the message + // line format. std::vector> parts; parts.push_back(std::vector(DELIM.begin(), DELIM.end())); - std::string hmac = hmac_fn_(message_parts); + std::string hmac = hmac_fn_(message); parts.push_back(std::vector(hmac.begin(), hmac.end())); Json::FastWriter writer; - for (const Json::Value §ion : message_parts) { + for (const Json::Value §ion : message) { std::string serialized = writer.write(section); parts.push_back(std::vector(serialized.begin(), serialized.end())); @@ -138,51 +276,16 @@ IPythonMessage ShellConnection::Send (const IPythonMessage &message) { return IPythonMessage{response_message_parts}; } -void ShellConnection::RunCode (const std::string &code) { - IPythonMessage command = message_builder_.BuildExecuteRequest(code); - Send(command); -} - -bool ShellConnection::HasVariable (const std::string &variable_name) { - IPythonMessage command = message_builder_.BuildExecuteRequest("None"); - command.content["user_variables"].append(variable_name); - IPythonMessage response = Send(command); - return response.content["user_variables"][variable_name]["status"] - .asString() == "ok"; -} +//====================================================================== +IPythonSession::IPythonSession (const IPyKernelConfig &config) : + config_{config}, + zmq_context_{1}, + shell_connection_{config, zmq_context_} +{} void IPythonSession::Connect (void) { shell_connection_.Connect(); } -std::string IPythonSession::ComputeHMAC_ ( - const std::vector &parts) const { - ENGINE_load_builtin_engines(); - ENGINE_register_all_complete(); - - HMAC_CTX hmac_ctx; - HMAC_CTX_init(&hmac_ctx); - HMAC_Init_ex(&hmac_ctx, config_.key.data(), config_.key.size(), - EVP_sha256(), nullptr); - - Json::FastWriter writer; - for (const auto& part : parts) { - std::string serialized{writer.write(part)}; - HMAC_Update(&hmac_ctx, (const uint8_t*)(serialized.data()), - serialized.size()); - } - - unsigned int result_len = 32; - uint8_t result[32]; - HMAC_Final(&hmac_ctx, result, &result_len); - HMAC_CTX_cleanup(&hmac_ctx); - - std::stringstream hmac; - for (size_t i = 0; i != 32; ++i) { - hmac << std::setfill('0') << std::setw(2) << std::hex - << static_cast(result[i]); - } - return hmac.str(); -} diff --git a/src/ipython_protocol.hpp b/src/ipython_protocol.hpp index 846aa44..2bda5b1 100644 --- a/src/ipython_protocol.hpp +++ b/src/ipython_protocol.hpp @@ -1,3 +1,9 @@ +// +// Copyright (c) 2015 Jim Youngquist +// under The MIT License (MIT) +// full text in LICENSE file in root folder of this project. +// + #pragma once #include @@ -18,10 +24,12 @@ extern "C" { #include #include -struct IPyKernelConfig; // forward def +// forward declarations +struct IPyKernelConfig; +struct IPythonMessage; -/// Function object that computes an HMAC hash from a vector of JSON values. -typedef std::function)> HmacFn; +/// Function object that computes an HMAC hash from an IPythonMessage. +typedef std::function HmacFn; /// The different kinds of socket ports that an iPython kernel listens on. enum class PortType {SHELL, IOPUB, STDIN, HB}; @@ -87,35 +95,23 @@ struct IPyKernelConfig { * for more detail. */ struct IPythonMessage { + /// Convenience typedef + typedef std::array MessageParts; + + /// Underlying storage for the different parts + MessageParts message_parts_; + /// The header = {'msg_id', 'username', 'session', 'msg_type'} - Json::Value header; + Json::Value &header; /// The header of this message's parent. To associate response with request - Json::Value parent; + Json::Value &parent; /// Metadata for this message. Seems to be unused? - Json::Value metadata; + Json::Value &metadata; /// Content payload for this message. msg_type dependant. - Json::Value content; - - //-------------------------------------------------- - /** \brief Constructs an empty message for a specific session. - * - * \param ident identity of the session this message belongs to. - */ - explicit IPythonMessage(const std::string &ident) - : header{Json::objectValue}, - parent{Json::objectValue}, - metadata{Json::objectValue}, - content{Json::objectValue} - { - char username[80]; - getlogin_r(username, 80); - header["username"] = std::string(username); - header["session"] = ident; - header["msg_id"] = GetUuid(); - } + Json::Value &content; //-------------------------------------------------- /** \brief Constructs a message with predefined fields. @@ -124,14 +120,25 @@ struct IPythonMessage { * fields. It assumes the elements will be in order * [header, parent_header, metadata, content]. */ - explicit IPythonMessage(const std::vector &message_parts) - : header(message_parts[0]), - parent(message_parts[1]), - metadata(message_parts[2]), - content(message_parts[3]) - {} + explicit IPythonMessage(const std::vector &message_parts); + + //-------------------------------------------------- + /** \brief Constructs an empty message with the header appropriately filled + * in for the session associated with ident and the current user. + * + * \param ident identity of the session this message belongs to. + */ + explicit IPythonMessage(const std::string &ident); + + // Iterator passthroughs so the IPythonMessage class can be iterated over. + MessageParts::const_iterator cbegin() const { + return message_parts_.cbegin(); } + MessageParts::const_iterator cend() const { return message_parts_.cend(); } + MessageParts::const_iterator begin() const { return message_parts_.begin(); } + MessageParts::const_iterator end() const { return message_parts_.end(); } + MessageParts::iterator begin() { return message_parts_.begin(); } + MessageParts::iterator end() { return message_parts_.end(); } - std::vector GetMessageParts(void) const; }; @@ -167,29 +174,139 @@ class MessageBuilder { }; +//====================================================================== +/** \brief HMAC computing function object for IPython message signing. + * + * Provides operator() for creating HMACs from IPython messages. + * + * Usage: +\code + IPyKernelConfig config{"/path/to/kernel-NNN.json"}; + MessageBuilder builder{"my_identity"}; + IPythonMessage message = builder.BuildExecuteRequest("data = 15"); + IPythonHmac hmac; + std::string message_signature = hmac(message.GetMessageParts()); +\endcode + */ +class IPythonHmac { +public: + /// Functional that returns an appropriate const EVP_MD* + typedef std::function EvpTypeFn; + + //-------------------------------------------------- + /** \brief Constructs a new HMAC functional based on an IPython + * configuration. + * + * \param config the configuration which contains the secret key and hash + * function type. + */ + IPythonHmac (const IPyKernelConfig &config); + + //-------------------------------------------------- + /** \brief Returns the HMAC signature for a message + * + * \param message the IPythonMessage whose signature it computes. + */ + std::string operator() (const IPythonMessage &message) const; + +private: + const EvpTypeFn GetEvpTypeFnFromConfig_(const IPyKernelConfig &config) const; + + const std::string key_; + const EvpTypeFn evp_type_fn_; +}; + + //====================================================================== /** \brief A connection to the shell socket of an iPython kernel. * - * TODO fill this out! + * The shell socket is the primary way to transfer code for execution on the + * iPython kernel, as well as introspect by asking for object information and + * code completion, and control of the kernel itself. + * + * As of 2015.03.18 only the code execution message has been implemented. + * + * This class could be used alone, but is intended to provide lower lever + * functionality for the IPythonSession class. + * + * Usage: +\code + // Set up parameters + IPyKernelConfig config("path/to/kernel-NNN.json") + zmq::context_t zmq_context(1); + HmacFn hmac_fn{IPythonHmac("secret key", EVP_sha256)}; + + // Create the connection. The host info is in the config. + ShellConnection conn{config, zmq_context, hmac_fn}; + conn.Connect(); + + if (!conn.HasVariable("my_var")) { + conn.RunCode("my_var = 50"); + } else { + conn.RunCode("my_var -= 1"); + } +\endcode */ class ShellConnection { public: - // - ShellConnection(const IPyKernelConfig &config, zmq::context_t &context, - const HmacFn &hmac_fn) - : hmac_fn_{hmac_fn}, - ident_{GetUuid()}, - message_builder_{ident_}, - socket_(context, ZMQ_DEALER), - uri_{BuildUri(config, PortType::SHELL)} + //-------------------------------------------------- + /** \brief Configure a new shell connection without actually connecting any + * sockets. + * + * \param config a valid IPyKernelConfig configuration + * \param context the ZeroMQ context to use for socket connections + */ + ShellConnection (const IPyKernelConfig &config, zmq::context_t &context) : + hmac_fn_{IPythonHmac(config)}, + ident_{GetUuid()}, + message_builder_{ident_}, + socket_(context, ZMQ_DEALER), + uri_{BuildUri(config, PortType::SHELL)} {} - void Connect(void); - IPythonMessage Send(const IPythonMessage &message); + //-------------------------------------------------- + /** \brief Connect to the shell socket on a running IPython kernel. + */ + void Connect (void); + + //-------------------------------------------------- + /** \brief Runs code in the associated IPython kernel. + * + * \throws std::runtime_error if IPython has a problem running the code. + * Will also dump the response to stderr. + * + * \param code the valid python code to run. Multiple lines must be + * explicitly separated by newline characters. + */ void RunCode (const std::string &code); + + //-------------------------------------------------- + /** \brief Returns whether a variable exists in the global namespace of the + * associated IPython kernel. + * + * \param variable_name the variable to check for. + */ bool HasVariable (const std::string &variable_name); + //-------------------------------------------------- + /** \brief Returns a variable's value as a string in the global namespace of + * the associated IPython kernel. It is up to the caller to know what type + * to convert the response to. + * + * \throws std::runtime_error if the variable does not exists, e.g. + * KeyError. + * + * \param variable_name the variable to check for. + * + * \returns variable's string representation from __str__ + */ + std::string GetVariable (const std::string &variable_name); + private: + IPythonMessage GenericRun_ (const std::string &code, + const std::vector &variable_names); + IPythonMessage Send_ (const IPythonMessage &message); + const HmacFn hmac_fn_; const std::string ident_; const MessageBuilder message_builder_; @@ -198,24 +315,46 @@ class ShellConnection { }; +//====================================================================== +/** \brief High level wrapper for a client connection to a running IPython + * kernel. + * + * It is incomplete. TODO: implement all the remaining socket connection + * types and messages. But, it suffices for simply running code. + * + * Usage: +\code + // Connect to the running IPython kernel with PID NNN. + IPyKernelConfig config("/path/to/kernel-NNN.json"); + IPythonSession session(config); + session.Connect(); + + // Run some code + session.Shell().RunCode("print 'hello world!'"); +\endcode + */ class IPythonSession { public: - explicit IPythonSession (const IPyKernelConfig &config) - : config_{config}, - zmq_context_{1}, - hmac_fn_{std::bind(&IPythonSession::ComputeHMAC_, this, - std::placeholders::_1)}, - shell_connection_{config, zmq_context_, hmac_fn_} - {} + //-------------------------------------------------- + /** \brief Constructs a session associated with a specific IPython kernel, + * but does not connect sockets. + * + * \param config configuration for the IPython kernel. + */ + explicit IPythonSession (const IPyKernelConfig &config); + //-------------------------------------------------- + /** \brief Connect to the IPython kernel. + */ void Connect (void); + + //-------------------------------------------------- + /** \brief Returns a reference to the shell socket connection. + */ ShellConnection& Shell (void) { return shell_connection_; } private: - std::string ComputeHMAC_(const std::vector &parts) const; - const IPyKernelConfig config_; zmq::context_t zmq_context_; - HmacFn hmac_fn_; ShellConnection shell_connection_; }; diff --git a/src/ipython_run.py b/src/ipython_run.py index c37e46a..5910aa5 100644 --- a/src/ipython_run.py +++ b/src/ipython_run.py @@ -1,3 +1,9 @@ +# +# Copyright (c) 2015 Jim Youngquist +# under The MIT License (MIT) +# full text in LICENSE file in root folder of this project. +# + import pyplot_listener as pltlis reload(pltlis) diff --git a/src/main.cc b/src/main.cc index 28036cb..a15984b 100644 --- a/src/main.cc +++ b/src/main.cc @@ -1,3 +1,9 @@ +// +// Copyright (c) 2015 Jim Youngquist +// under The MIT License (MIT) +// full text in LICENSE file in root folder of this project. +// + #include #include @@ -27,7 +33,10 @@ int main(int argc, char **argv) { NumpyArray data("A", raw_data); mpl.SendData(data); - mpl.RunCode("plot(A)"); + mpl.RunCode("plot(A)\n" + "title('f(x) = sin(x)')\n" + "xlabel('x')\n" + "ylabel('f(x)')\n"); return 0; } diff --git a/src/pyplot_listener.py b/src/pyplot_listener.py index 096baa7..96ca15f 100644 --- a/src/pyplot_listener.py +++ b/src/pyplot_listener.py @@ -1,3 +1,9 @@ +# +# Copyright (c) 2015 Jim Youngquist +# under The MIT License (MIT) +# full text in LICENSE file in root folder of this project. +# + import os import time import signal @@ -11,26 +17,30 @@ import threading class ListenerThread(threading.Thread): - def __init__(self, code_uri, global_env): + def __init__(self, global_env): super(ListenerThread, self).__init__() self.running = True self.global_env = global_env - self.code_uri = code_uri + self.port = None def decodeData(self, message): - assert len(message) >= 9, "Message is not long enough" + if len(message) < 9: + return False, "Message is not long enough" rows, cols, size = struct.unpack('IIB', message[:9]) length = rows*cols*size - assert len(message) >= 9+length, "Message contains insufficient data" + if len(message) < 9 + length: + return False, "Message contains insufficient data" dtype = {4 : np.float32, 8 : np.float64}[size] data = np.fromstring(message[9:9+length], dtype=dtype) data = data.reshape(rows, cols) - assert len(message[9+length:]) > 0, "Message has zero length name field" + if len(message[9+length:]) == 0: + return False, "Message has zero length name field" + name = message[9+length:] return data, name @@ -52,27 +62,24 @@ def sendSuccess(self, socket): return self.sendString(socket, "Success") - def sendFailure(self, socket): - return self.sendString(socket, "Failure") + def sendFailure(self, socket, message): + return self.sendString(socket, message) def processData(self, data_message): data, name = self.decodeData(data_message) - self.global_env[name] = data - return True + if data is False: + return data, name - - def processCode(self, code_message): - print "I'm running:\n", code_message - exec(code_message, self.global_env) - return True + self.global_env[name] = data + return True, name def run(self): context = zmq.Context() data_socket = context.socket(zmq.REP) - data_socket.bind("tcp://*:5555") + self.port = data_socket.bind_to_random_port("tcp://*") poller = zmq.Poller() poller.register(data_socket, flags = zmq.POLLIN) @@ -86,19 +93,21 @@ def run(self): try: if event: message = socket.recv() - success = processors[socket](message) + success, message = processors[socket](message) if success: self.sendSuccess(socket) else: - self.sendFailure(socket) + self.sendFailure(socket, message) except zmq.error.ZMQError as e: # there was a transmit error...oops...die self.running = False continue -def ipython_run(global_env): - cpp_ipython_listener_thread = ListenerThread(global_env) - cpp_ipython_listener_thread.start() - return lt +def cpp_ipython_start_thread(global_env): + listener_thread = ListenerThread(global_env) + listener_thread.start() + global_env["cpp_ipython_listener_thread"] = listener_thread + global_env["cpp_ipython_listener_thread_port"] = listener_thread.port + return True diff --git a/src/test.py b/src/test.py index cf03329..34030de 100644 --- a/src/test.py +++ b/src/test.py @@ -1,3 +1,9 @@ +# +# Copyright (c) 2015 Jim Youngquist +# under The MIT License (MIT) +# full text in LICENSE file in root folder of this project. +# + t = linspace(0, 2, 50) y = t**2