├── ci ├── requirements.txt ├── Dockerfile.main.erb └── Dockerfile.base.erb ├── lib ├── iruby │ ├── assets │ │ ├── kernel.css │ │ ├── logo-32x32.png │ │ ├── logo-64x64.png │ │ └── kernel.js │ ├── version.rb │ ├── error.rb │ ├── input │ │ ├── textarea.rb │ │ ├── label.rb │ │ ├── cancel.rb │ │ ├── autoload.rb │ │ ├── field.rb │ │ ├── widget.rb │ │ ├── date.rb │ │ ├── button.rb │ │ ├── popup.rb │ │ ├── select.rb │ │ ├── file.rb │ │ ├── builder.rb │ │ ├── radio.rb │ │ ├── multiple.rb │ │ ├── checkbox.rb │ │ ├── form.rb │ │ ├── README.md │ │ └── README.ipynb │ ├── logger.rb │ ├── input.rb │ ├── event_manager.rb │ ├── session_adapter │ │ ├── cztop_adapter.rb │ │ ├── test_adapter.rb │ │ └── ffirzmq_adapter.rb │ ├── comm.rb │ ├── utils.rb │ ├── session │ │ ├── mixin.rb │ │ ├── cztop.rb │ │ └── ffi_rzmq.rb │ ├── ostream.rb │ ├── session_adapter.rb │ ├── jupyter.rb │ ├── kernel_app.rb │ ├── session.rb │ ├── formatter.rb │ ├── backend.rb │ ├── kernel.rb │ ├── application.rb │ └── display.rb └── iruby.rb ├── logo ├── logo-32x32.png └── logo-64x64.png ├── docker ├── test.sh └── setup.sh ├── exe └── iruby ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── run-test.sh ├── test ├── run-test.rb ├── iruby │ ├── session_adapter │ │ ├── cztop_adapter_test.rb │ │ ├── ffirzmq_adapter_test.rb │ │ └── session_adapter_test_base.rb │ ├── utils_test.rb │ ├── multi_logger_test.rb │ ├── application │ │ ├── application_test.rb │ │ ├── helper.rb │ │ ├── unregister_test.rb │ │ ├── kernel_test.rb │ │ ├── console_test.rb │ │ └── register_test.rb │ ├── jupyter_test.rb │ ├── backend_test.rb │ ├── mime_test.rb │ ├── session_test.rb │ ├── session_adapter_test.rb │ ├── event_manager_test.rb │ ├── kernel_test.rb │ └── display_test.rb ├── integration_test.rb └── helper.rb ├── Gemfile ├── Rakefile ├── LICENSE ├── iruby.gemspec ├── tasks └── ci.rake ├── README.md └── CHANGES.md /ci/requirements.txt: -------------------------------------------------------------------------------- 1 | jupyter-console>=6.0.0 2 | -------------------------------------------------------------------------------- /lib/iruby/assets/kernel.css: -------------------------------------------------------------------------------- 1 | /* Placeholder for Ruby kernel.css */ 2 | -------------------------------------------------------------------------------- /lib/iruby/version.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | VERSION = '0.8.2' 3 | end 4 | -------------------------------------------------------------------------------- /logo/logo-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SciRuby/iruby/HEAD/logo/logo-32x32.png -------------------------------------------------------------------------------- /logo/logo-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SciRuby/iruby/HEAD/logo/logo-64x64.png -------------------------------------------------------------------------------- /lib/iruby/assets/logo-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SciRuby/iruby/HEAD/lib/iruby/assets/logo-32x32.png -------------------------------------------------------------------------------- /lib/iruby/assets/logo-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SciRuby/iruby/HEAD/lib/iruby/assets/logo-64x64.png -------------------------------------------------------------------------------- /docker/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | cd /tmp/iruby 6 | bundle install --with test --without plot 7 | bundle exec rake test 8 | -------------------------------------------------------------------------------- /exe/iruby: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env ruby 2 | require "iruby" 3 | require "iruby/application" 4 | 5 | app = IRuby::Application.instance 6 | app.setup 7 | app.run 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /ci/Dockerfile.main.erb: -------------------------------------------------------------------------------- 1 | FROM iruby-test-base:ruby-<%= ruby_version %> 2 | 3 | RUN gem install cztop 4 | RUN mkdir -p /iruby 5 | ADD . /iruby 6 | WORKDIR /iruby 7 | RUN bundle install 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .ipynb_checkpoints/ 2 | *.gem 3 | *.rbc 4 | *.log 5 | .bundle 6 | .config 7 | .yardoc 8 | Gemfile.lock 9 | coverage 10 | doc/ 11 | pkg 12 | rdoc 13 | test/tmp 14 | test/version_tmp 15 | tmp 16 | venv 17 | -------------------------------------------------------------------------------- /run-test.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | set -ex 4 | 5 | export PYTHON=python3 6 | 7 | ADAPTERS="cztop ffi-rzmq" 8 | 9 | for adapter in $ADAPTERS; do 10 | export IRUBY_TEST_SESSION_ADAPTER_NAME=$adapter 11 | bundle exec rake test TESTOPTS=-v 12 | done 13 | -------------------------------------------------------------------------------- /lib/iruby/error.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | class Error < StandardError 3 | end 4 | 5 | class InvalidSubcommandError < Error 6 | def initialize(name, argv) 7 | @name = name 8 | @argv = argv 9 | super("Invalid subcommand name: #{@name}") 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /docker/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | apt-get update 6 | apt-get install -y --no-install-recommends \ 7 | libczmq-dev \ 8 | python3 \ 9 | python3-pip \ 10 | python3-setuptools \ 11 | python3-wheel 12 | 13 | cd /tmp/iruby 14 | bundle install --with test --without plot 15 | pip3 install jupyter 16 | -------------------------------------------------------------------------------- /test/run-test.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | $VERBOSE = true 4 | 5 | require "bundler/setup" 6 | require "pathname" 7 | 8 | base_dir = Pathname.new(__dir__).parent.expand_path 9 | 10 | lib_dir = base_dir + "lib" 11 | test_dir = base_dir + "test" 12 | 13 | $LOAD_PATH.unshift(lib_dir.to_s) 14 | 15 | require_relative "helper" 16 | 17 | ENV["TEST_UNIT_MAX_DIFF_TARGET_STRING_SIZE"] ||= "10000" 18 | ENV["IRUBY_TEST_SESSION_ADAPTER_NAME"] ||= "ffi-rzmq" 19 | 20 | exit Test::Unit::AutoRunner.run(true, test_dir) 21 | -------------------------------------------------------------------------------- /lib/iruby/assets/kernel.js: -------------------------------------------------------------------------------- 1 | // Ruby kernel.js 2 | 3 | define(['base/js/namespace'], function(IPython) { 4 | "use strict"; 5 | var onload = function() { 6 | IPython.CodeCell.options_default['cm_config']['indentUnit'] = 2; 7 | var cells = IPython.notebook.get_cells(); 8 | for (var i in cells){ 9 | var c = cells[i]; 10 | if (c.cell_type === 'code') 11 | c.code_mirror.setOption('indentUnit', 2); 12 | } 13 | } 14 | return {onload:onload}; 15 | }); 16 | -------------------------------------------------------------------------------- /test/iruby/session_adapter/cztop_adapter_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'session_adapter_test_base' 2 | require 'iruby' 3 | 4 | module IRubyTest 5 | if ENV['IRUBY_TEST_SESSION_ADAPTER_NAME'] == 'cztop' 6 | class CztopAdapterTest < SessionAdapterTestBase 7 | def adapter_class 8 | IRuby::SessionAdapter::CztopAdapter 9 | end 10 | 11 | def test_send 12 | assert(adapter_class.available?) 13 | end 14 | 15 | def test_recv 16 | omit 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/iruby/session_adapter/ffirzmq_adapter_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'session_adapter_test_base' 2 | require 'iruby' 3 | 4 | module IRubyTest 5 | if ENV['IRUBY_TEST_SESSION_ADAPTER_NAME'] == 'ffi-rzmq' 6 | class FfirzmqAdapterTest < SessionAdapterTestBase 7 | def adapter_class 8 | IRuby::SessionAdapter::FfirzmqAdapter 9 | end 10 | 11 | def test_send 12 | assert(adapter_class.available?) 13 | end 14 | 15 | def test_recv 16 | omit 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/iruby/input/textarea.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module Input 3 | class Textarea < Field 4 | needs rows: 5 5 | 6 | builder :textarea do |key='textarea', **params| 7 | params[:key] = unique_key key 8 | add_field Textarea.new(**params) 9 | end 10 | 11 | def widget_html 12 | widget_label do 13 | textarea( 14 | @default, 15 | rows: @rows, 16 | :'data-iruby-key' => @key, 17 | class: 'form-control iruby-field' 18 | ) 19 | end 20 | end 21 | end 22 | end 23 | end -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | plugin 'rubygems-requirements-system' 4 | 5 | gemspec 6 | 7 | group :pry do 8 | gem 'pry' 9 | gem 'pry-doc' 10 | gem 'awesome_print' 11 | end 12 | 13 | group :plot do 14 | gem 'gnuplot' 15 | gem 'rubyvis' 16 | end 17 | 18 | group :test do 19 | gem 'cztop' 20 | end 21 | 22 | # Tests are failing on Ruby 3.3 because warnings related to OpenStruct are being written to the standard error output. 23 | # This is being captured by Open3.capture2e and then mistakenly parsed as JSON. 24 | # This gem can be removed when json gem is updated 25 | gem 'ostruct' 26 | -------------------------------------------------------------------------------- /lib/iruby/input/label.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module Input 3 | class Label < Widget 4 | needs label: nil, icon: nil 5 | 6 | def widget_label 7 | div class: 'iruby-label input-group' do 8 | span class: 'input-group-addon' do 9 | text @label || to_label(@key) 10 | end 11 | 12 | yield 13 | 14 | if @icon 15 | span @icon, class: "input-group-addon" 16 | end 17 | end 18 | end 19 | 20 | private 21 | 22 | def to_label label 23 | label.to_s.tr('_',' ').capitalize 24 | end 25 | end 26 | end 27 | end -------------------------------------------------------------------------------- /lib/iruby/logger.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | 3 | module IRuby 4 | class << self 5 | attr_accessor :logger 6 | end 7 | 8 | class MultiLogger < BasicObject 9 | def initialize(*loggers, level: ::Logger::DEBUG) 10 | @loggers = loggers 11 | @level = level 12 | end 13 | 14 | attr_reader :loggers 15 | 16 | attr_reader :level 17 | 18 | def level=(new_level) 19 | @loggers.each do |l| 20 | l.level = new_level 21 | end 22 | @level = new_level 23 | end 24 | 25 | def method_missing(name, *args, &b) 26 | @loggers.map {|x| x.respond_to?(name) && x.public_send(name, *args, &b) }.any? 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/iruby/utils_test.rb: -------------------------------------------------------------------------------- 1 | module IRubyTest 2 | class UtilsTest < TestBase 3 | sub_test_case("IRuby.table") do 4 | def setup 5 | @data = { 6 | X: [ 1, 2, 3 ], 7 | Y: [ 4, 5, 6 ] 8 | } 9 | end 10 | sub_test_case("without header: option") do 11 | def test_table 12 | result = IRuby.table(@data) 13 | assert_include(result.object, "X") 14 | end 15 | end 16 | 17 | sub_test_case("with header: false") do 18 | def test_table 19 | result = IRuby.table(@data, header: false) 20 | assert_not_include(result.object, "X") 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/iruby/input/cancel.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module Input 3 | class Cancel < Widget 4 | needs :label 5 | 6 | builder :cancel do |label='Cancel'| 7 | add_button Cancel.new(label: label) 8 | end 9 | 10 | def widget_css 11 | ".iruby-cancel { margin-left: 5px; }" 12 | end 13 | 14 | def widget_js 15 | <<-JS 16 | $('.iruby-cancel').click(function(){ 17 | $('#iruby-form').remove(); 18 | }); 19 | JS 20 | end 21 | 22 | def widget_html 23 | button( 24 | @label, 25 | type: 'button', 26 | :'data-dismiss' => 'modal', 27 | class: "btn btn-danger pull-right iruby-cancel" 28 | ) 29 | end 30 | end 31 | end 32 | end -------------------------------------------------------------------------------- /lib/iruby/input/autoload.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require 'erector' 3 | rescue LoadError 4 | raise LoadError, <<-ERROR.gsub(/\s+/,' ') 5 | IRuby::Input requires the erector gem. 6 | `gem install erector` or add `gem 'erector'` 7 | it to your Gemfile to continue. 8 | ERROR 9 | end 10 | 11 | require 'iruby/input/builder' 12 | require 'iruby/input/widget' 13 | require 'iruby/input/form' 14 | require 'iruby/input/label' 15 | require 'iruby/input/field' 16 | require 'iruby/input/popup' 17 | require 'iruby/input/button' 18 | require 'iruby/input/cancel' 19 | require 'iruby/input/file' 20 | require 'iruby/input/select' 21 | require 'iruby/input/checkbox' 22 | require 'iruby/input/radio' 23 | require 'iruby/input/textarea' 24 | require 'iruby/input/date' 25 | require 'iruby/input/multiple' -------------------------------------------------------------------------------- /lib/iruby/input/field.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module Input 3 | class Field < Label 4 | needs default: nil, type: 'text', js_class: 'iruby-field' 5 | 6 | builder :input do |key='input', **params| 7 | params[:key] = unique_key key 8 | add_field Field.new(**params) 9 | end 10 | 11 | def widget_js 12 | <<-JS 13 | $('.iruby-field').keyup(function() { 14 | $(this).data('iruby-value', $(this).val()); 15 | }); 16 | JS 17 | end 18 | 19 | def widget_html 20 | widget_label do 21 | input( 22 | type: @type, 23 | :'data-iruby-key' => @key, 24 | class: "form-control #{@js_class}", 25 | value: @default 26 | ) 27 | end 28 | end 29 | end 30 | end 31 | end -------------------------------------------------------------------------------- /test/iruby/session_adapter/session_adapter_test_base.rb: -------------------------------------------------------------------------------- 1 | module IRubyTest 2 | class SessionAdapterTestBase < TestBase 3 | # https://jupyter-client.readthedocs.io/en/stable/kernels.html#connection-files 4 | def make_connection_config 5 | { 6 | "control_port" => 50160, 7 | "shell_port" => 57503, 8 | "transport" => "tcp", 9 | "signature_scheme" => "hmac-sha256", 10 | "stdin_port" => 52597, 11 | "hb_port" => 42540, 12 | "ip" => "127.0.0.1", 13 | "iopub_port" => 40885, 14 | "key" => "a0436f6c-1916-498b-8eb9-e81ab9368e84" 15 | } 16 | end 17 | 18 | def setup 19 | @config = make_connection_config 20 | @session_adapter = adapter_class.new(@config) 21 | 22 | unless adapter_class.available? 23 | omit("#{@session_adapter.name} is unavailable") 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/iruby/input/widget.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module Input 3 | class Widget < Erector::Widget 4 | needs key: nil 5 | 6 | def widget_js; end 7 | def widget_css; end 8 | def widget_html; end 9 | def content; widget_html; end 10 | 11 | def self.builder method, &block 12 | Builder.instance_eval do 13 | define_method method, &block 14 | end 15 | end 16 | 17 | def widget_join method, *args 18 | strings = args.map do |arg| 19 | arg.is_a?(String) ? arg : arg.send(method) 20 | end 21 | strings.uniq.join("\n") 22 | end 23 | 24 | def widget_display 25 | IRuby.display(IRuby.html( 26 | Erector.inline{ style raw(widget_css) }.to_html 27 | )) 28 | 29 | IRuby.display(IRuby.html(to_html)) 30 | IRuby.display(IRuby.javascript(widget_js)) 31 | end 32 | end 33 | end 34 | end -------------------------------------------------------------------------------- /test/iruby/multi_logger_test.rb: -------------------------------------------------------------------------------- 1 | class IRubyTest::MultiLoggerTest < IRubyTest::TestBase 2 | def test_multilogger 3 | out, err = StringIO.new, StringIO.new 4 | logger = IRuby::MultiLogger.new(Logger.new(out), Logger.new(err)) 5 | logger.warn 'You did a bad thing' 6 | assert_match 'WARN', out.string 7 | assert_match 'WARN', err.string 8 | assert_match 'bad thing', out.string 9 | assert_match 'bad thing', err.string 10 | end 11 | 12 | def test_level 13 | out, err = StringIO.new, StringIO.new 14 | logger = IRuby::MultiLogger.new(Logger.new(out), Logger.new(err)) 15 | 16 | logger.level = Logger::DEBUG 17 | assert_equal Logger::DEBUG, logger.level 18 | assert_all(logger.loggers) {|l| l.level == Logger::DEBUG } 19 | 20 | logger.level = Logger::INFO 21 | assert_equal Logger::INFO, logger.level 22 | assert_all(logger.loggers) {|l| l.level == Logger::INFO } 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/iruby.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | require 'mime/types' 3 | require 'json' 4 | require 'securerandom' 5 | require 'openssl' 6 | require 'tempfile' 7 | require 'set' 8 | require 'stringio' 9 | 10 | require 'iruby/version' 11 | require 'iruby/jupyter' 12 | require 'iruby/event_manager' 13 | require 'iruby/logger' 14 | require 'iruby/kernel' 15 | require 'iruby/backend' 16 | require 'iruby/ostream' 17 | require 'iruby/input' 18 | require 'iruby/formatter' 19 | require 'iruby/utils' 20 | require 'iruby/display' 21 | require 'iruby/comm' 22 | 23 | if ENV.fetch('IRUBY_OLD_SESSION', false) 24 | require 'iruby/session/mixin' 25 | begin 26 | require 'iruby/session/ffi_rzmq' 27 | rescue LoadError 28 | begin 29 | require 'iruby/session/cztop' 30 | rescue LoadError 31 | STDERR.puts "Please install ffi-rzmq or cztop before running iruby. See README." 32 | end 33 | end 34 | else 35 | require 'iruby/session' 36 | end 37 | -------------------------------------------------------------------------------- /lib/iruby/input/date.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module Input 3 | class Date < Field 4 | needs js_class: 'iruby-date', icon: '📅' 5 | 6 | builder :date do |key='date', **params| 7 | params[:default] ||= false 8 | params[:key] = unique_key key 9 | 10 | if params[:default].is_a? Time 11 | params[:default] = params[:default].strftime('%m/%d/%Y') 12 | end 13 | 14 | add_field Date.new(**params) 15 | 16 | process params[:key] do |result,key,value| 17 | result[key.to_sym] = Time.strptime(value,'%m/%d/%Y') 18 | end 19 | end 20 | 21 | def widget_css 22 | '#ui-datepicker-div { z-index: 2000 !important; }' 23 | end 24 | 25 | def widget_js 26 | <<-JS 27 | $('.iruby-date').datepicker({ 28 | dateFormat: 'mm/dd/yy', 29 | onClose: function(date) { 30 | $(this).data('iruby-value', date); 31 | } 32 | }); 33 | JS 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/iruby/input.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module Input 3 | # autoload so that erector is not a required 4 | # runtime dependency of IRuby 5 | autoload :Builder, 'iruby/input/autoload' 6 | 7 | def input prompt='Input' 8 | result = form{input label: prompt} 9 | result[:input] unless result.nil? 10 | end 11 | 12 | def password prompt='Password' 13 | result = form{password label: prompt} 14 | result[:password] unless result.nil? 15 | end 16 | 17 | def form &block 18 | builder = Builder.new(&block) 19 | form = InputForm.new( 20 | fields: builder.fields, 21 | buttons: builder.buttons 22 | ) 23 | form.widget_display 24 | builder.process_result form.submit 25 | end 26 | 27 | def popup title='Input', &block 28 | builder = Builder.new(&block) 29 | form = InputForm.new fields: builder.fields 30 | popup = Popup.new( 31 | title: title, 32 | form: form, 33 | buttons: builder.buttons 34 | ) 35 | popup.widget_display 36 | builder.process_result form.submit 37 | end 38 | end 39 | 40 | extend Input 41 | end -------------------------------------------------------------------------------- /lib/iruby/event_manager.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | class EventManager 3 | def initialize(available_events) 4 | @available_events = available_events.dup.freeze 5 | @callbacks = available_events.map {|n| [n, []] }.to_h 6 | end 7 | 8 | attr_reader :available_events 9 | 10 | def register(event, &block) 11 | check_available_event(event) 12 | @callbacks[event] << block unless block.nil? 13 | block 14 | end 15 | 16 | def unregister(event, callback) 17 | check_available_event(event) 18 | val = @callbacks[event].delete(callback) 19 | unless val 20 | raise ArgumentError, 21 | "Given callable object #{callback} is not registered as a #{event} callback" 22 | end 23 | val 24 | end 25 | 26 | def trigger(event, *args, **kwargs) 27 | check_available_event(event) 28 | @callbacks[event].each do |fn| 29 | fn.call(*args, **kwargs) 30 | end 31 | end 32 | 33 | private 34 | 35 | def check_available_event(event) 36 | return if @callbacks.key?(event) 37 | raise ArgumentError, "Unknown event name: #{event}", caller 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_helper" 2 | 3 | base_dir = File.join(File.dirname(__FILE__)) 4 | 5 | helper = Bundler::GemHelper.new(base_dir) 6 | helper.install 7 | 8 | FileList['tasks/**.rake'].each {|f| load f } 9 | 10 | desc "Run tests" 11 | task :test do 12 | test_opts = ENV.fetch("TESTOPTS", "").split 13 | cd(base_dir) do 14 | ruby("test/run-test.rb", *test_opts) 15 | end 16 | end 17 | 18 | task default: 'test' 19 | 20 | namespace :docker do 21 | def root_dir 22 | @root_dir ||= File.expand_path("..", __FILE__) 23 | end 24 | 25 | task :build do 26 | container_name = "iruby_build" 27 | image_name = "mrkn/iruby" 28 | sh "docker", "run", 29 | "--name", container_name, 30 | "-v", "#{root_dir}:/tmp/iruby", 31 | "rubylang/ruby", "/bin/bash", "/tmp/iruby/docker/setup.sh" 32 | sh "docker", "commit", container_name, image_name 33 | sh "docker", "rm", container_name 34 | end 35 | 36 | task :test do 37 | root_dir = File.expand_path("..", __FILE__) 38 | sh "docker", "run", "-it", "--rm", 39 | "-v", "#{root_dir}:/tmp/iruby", 40 | "mrkn/iruby", "/bin/bash", "/tmp/iruby/docker/test.sh" 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/iruby/application/application_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "helper" 2 | 3 | module IRubyTest::ApplicationTests 4 | class ApplicationTest < ApplicationTestBase 5 | DEFAULT_KERNEL_NAME = IRuby::Application::DEFAULT_KERNEL_NAME 6 | DEFAULT_DISPLAY_NAME = IRuby::Application::DEFAULT_DISPLAY_NAME 7 | 8 | def test_help 9 | out, status = Open3.capture2e(*iruby_command("--help")) 10 | assert status.success? 11 | assert_match(/--help/, out) 12 | assert_match(/--version/, out) 13 | assert_match(/^register\b/, out) 14 | assert_match(/^unregister\b/, out) 15 | assert_match(/^kernel\b/, out) 16 | assert_match(/^console\b/, out) 17 | end 18 | 19 | def test_version 20 | out, status = Open3.capture2e(*iruby_command("--version")) 21 | assert status.success? 22 | assert_match(/\bIRuby\s+#{Regexp.escape(IRuby::VERSION)}\b/, out) 23 | end 24 | 25 | def test_unknown_subcommand 26 | out, status = Open3.capture2e(*iruby_command("matz")) 27 | refute status.success? 28 | assert_match(/^Invalid subcommand name: matz$/, out) 29 | assert_match(/^Subcommands$/, out) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 - present IRuby contributors and the Ruby Science Foundation 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/iruby/session_adapter/cztop_adapter.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module SessionAdapter 3 | class CztopAdapter < BaseAdapter 4 | def self.load_requirements 5 | require 'cztop' 6 | end 7 | 8 | def send(sock, data) 9 | sock << data 10 | end 11 | 12 | def recv(sock) 13 | sock.receive 14 | end 15 | 16 | def heartbeat_loop(sock) 17 | loop do 18 | message = sock.receive 19 | sock << message 20 | end 21 | end 22 | 23 | private 24 | 25 | def socket_type_class(type_symbol) 26 | case type_symbol 27 | when :ROUTER, :PUB, :REP 28 | CZTop::Socket.const_get(type_symbol) 29 | else 30 | if CZTop::Socket.const_defined?(type_symbol) 31 | raise ArgumentError, "Unsupported ZMQ socket type: #{type_symbol}" 32 | else 33 | raise ArgumentError, "Invalid ZMQ socket type: #{type_symbol}" 34 | end 35 | end 36 | end 37 | 38 | def make_socket(type_symbol, protocol, host, port) 39 | uri = "#{protocol}://#{host}:#{port}" 40 | socket_class = socket_type_class(type_symbol) 41 | socket_class.new(uri) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/iruby/input/button.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module Input 3 | # extend the label class for the to_label helper 4 | class Button < Label 5 | needs color: :blue, js_class: 'iruby-button' 6 | 7 | COLORS = { 8 | blue: 'primary', 9 | gray: 'secondary', 10 | green: 'success', 11 | aqua: 'info', 12 | orange: 'warning', 13 | red: 'danger', 14 | none: 'link' 15 | } 16 | 17 | COLORS.default = 'primary' 18 | 19 | builder :button do |key='done', **params| 20 | params[:key] = unique_key(key) 21 | add_button Button.new(**params) 22 | end 23 | 24 | def widget_css 25 | ".#{@js_class} { margin-left: 5px; }" 26 | end 27 | 28 | def widget_js 29 | <<-JS 30 | $('.iruby-button').click(function(){ 31 | $(this).data('iruby-value', true); 32 | $('#iruby-form').submit(); 33 | }); 34 | JS 35 | end 36 | 37 | def widget_html 38 | button( 39 | @label || to_label(@key), 40 | type: 'button', 41 | :'data-iruby-key' => @key, 42 | class: "btn btn-#{COLORS[@color]} pull-right #{@js_class}" 43 | ) 44 | end 45 | end 46 | end 47 | end -------------------------------------------------------------------------------- /test/iruby/jupyter_test.rb: -------------------------------------------------------------------------------- 1 | require 'iruby/jupyter' 2 | 3 | module IRubyTest 4 | class JupyterDefaultKernelSpecDirectoryTest < TestBase 5 | sub_test_case("with JUPYTER_DATA_DIR") do 6 | def test_default 7 | assert_equal(File.join(ENV["JUPYTER_DATA_DIR"], "kernels"), 8 | IRuby::Jupyter.kernelspec_dir) 9 | end 10 | end 11 | 12 | sub_test_case("without JUPYTER_DATA_DIR environment variable") do 13 | def setup 14 | with_env("JUPYTER_DATA_DIR" => nil) do 15 | @kernelspec_dir = IRuby::Jupyter.kernelspec_dir 16 | yield 17 | end 18 | end 19 | 20 | def test_default_windows 21 | windows_only 22 | appdata = IRuby::Jupyter.send :windows_user_appdata 23 | assert_equal(File.join(appdata, 'jupyter/kernels'), @kernelspec_dir) 24 | end 25 | 26 | def test_default_apple 27 | apple_only 28 | assert_equal(File.expand_path('~/Library/Jupyter/kernels'), @kernelspec_dir) 29 | end 30 | 31 | def test_default_unix 32 | unix_only 33 | with_env('XDG_DATA_HOME' => nil) do 34 | assert_equal(File.expand_path('~/.local/share/jupyter/kernels'), @kernelspec_dir) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/iruby/input/popup.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module Input 3 | class Popup < Widget 4 | needs :title, :form, buttons: [] 5 | 6 | def widget_css 7 | style = '.modal-body { overflow: auto; }' 8 | widget_join :widget_css, style, @form, *@buttons 9 | end 10 | 11 | def widget_js 12 | js = <<-JS 13 | require(['base/js/dialog'], function(dialog) { 14 | var popup = dialog.modal({ 15 | title: '#{@title.gsub("'"){"\\'"}}', 16 | body: '#{@form.to_html}', 17 | destroy: true, 18 | sanitize: false, 19 | keyboard_manager: Jupyter.notebook.keyboard_manager, 20 | open: function() { 21 | #{widget_join :widget_js, @form, *@buttons} 22 | 23 | var popup = $(this); 24 | 25 | $('#iruby-form').submit(function() { 26 | popup.modal('hide'); 27 | }); 28 | 29 | Jupyter.notebook.keyboard_manager.disable(); 30 | } 31 | }); 32 | 33 | popup.find('.modal-footer').each(function(e) { 34 | $(this).append('#{@buttons.map(&:to_html).join}'); 35 | }); 36 | }); 37 | JS 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/iruby/backend_test.rb: -------------------------------------------------------------------------------- 1 | module IRubyTest 2 | class PlainBackendTest < IRubyTest::TestBase 3 | def setup 4 | @plainbackend = IRuby::PlainBackend.new 5 | end 6 | 7 | def test_eval_one_plus_two 8 | assert_equal 3, @plainbackend.eval('1+2', false) 9 | end 10 | 11 | def test_include_module 12 | assert_nothing_raised do 13 | @plainbackend.eval("include Math, Comparable", false) 14 | end 15 | end 16 | 17 | def test_complete_req 18 | assert_includes @plainbackend.complete('req'), 'require' 19 | end 20 | 21 | def test_complete_2_dot 22 | assert_includes @plainbackend.complete('2.'), '2.even?' 23 | end 24 | end 25 | 26 | class PryBackendTest < IRubyTest::TestBase 27 | def setup 28 | @prybackend = IRuby::PryBackend.new 29 | end 30 | 31 | def test_eval_one_plus_two 32 | assert_equal 3, @prybackend.eval('1+2', false) 33 | end 34 | 35 | def test_include_module 36 | assert_nothing_raised do 37 | @prybackend.eval("include Math, Comparable", false) 38 | end 39 | end 40 | 41 | def test_complete_req 42 | assert_includes @prybackend.complete('req'), 'require' 43 | end 44 | 45 | def test_complete_2_dot 46 | assert_includes @prybackend.complete('2.'), '2.even?' 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/iruby/comm.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | # Comm is a new messaging system for bidirectional communication. 3 | # Both kernel and front-end listens for messages. 4 | class Comm 5 | attr_writer :on_msg, :on_close 6 | 7 | class << self 8 | def target; @target ||= {} end 9 | def comm; @comm ||= {} end 10 | end 11 | 12 | def initialize(target_name, comm_id = SecureRandom.uuid) 13 | @target_name, @comm_id = target_name, comm_id 14 | end 15 | 16 | def open(metadata = nil, **data) 17 | Kernel.instance.session.send(:publish, :comm_open, metadata, comm_id: @comm_id, data: data, target_name: @target_name) 18 | Comm.comm[@comm_id] = self 19 | end 20 | 21 | def send(metadata = nil, **data) 22 | Kernel.instance.session.send(:publish, :comm_msg, metadata, comm_id: @comm_id, data: data) 23 | end 24 | 25 | def close(metadata = nil, **data) 26 | Kernel.instance.session.send(:publish, :comm_close, metadata, comm_id: @comm_id, data: data) 27 | Comm.comm.delete(@comm_id) 28 | end 29 | 30 | def on_msg(&b) 31 | @on_msg = b 32 | end 33 | 34 | def on_close(&b) 35 | @on_close = b 36 | end 37 | 38 | def handle_msg(data) 39 | @on_msg.call(data) if @on_msg 40 | end 41 | 42 | def handle_close(data) 43 | @on_close.call(data) if @on_close 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/iruby/session_adapter/test_adapter.rb: -------------------------------------------------------------------------------- 1 | require 'iruby/session/mixin' 2 | 3 | module IRuby 4 | module SessionAdapter 5 | class TestAdapter < BaseAdapter 6 | include IRuby::SessionSerialize 7 | 8 | DummySocket = Struct.new(:type, :protocol, :host, :port) 9 | 10 | def initialize(config) 11 | super 12 | 13 | unless config['key'].empty? || config['signature_scheme'].empty? 14 | unless config['signature_scheme'] =~ /\Ahmac-/ 15 | raise "Unknown signature_scheme: #{config['signature_scheme']}" 16 | end 17 | digest_algorithm = config['signature_scheme'][/\Ahmac-(.*)\Z/, 1] 18 | @hmac = OpenSSL::HMAC.new(config['key'], OpenSSL::Digest.new(digest_algorithm)) 19 | end 20 | 21 | @send_callback = nil 22 | @recv_callback = nil 23 | end 24 | 25 | attr_accessor :send_callback, :recv_callback 26 | 27 | def send(sock, data) 28 | unless @send_callback.nil? 29 | @send_callback.call(sock, unserialize(data)) 30 | end 31 | end 32 | 33 | def recv(sock) 34 | unless @recv_callback.nil? 35 | serialize(@recv_callback.call(sock)) 36 | end 37 | end 38 | 39 | def heartbeat_loop(sock) 40 | end 41 | 42 | private 43 | 44 | def make_socket(type, protocol, host, port) 45 | DummySocket.new(type, protocol, host, port) 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /ci/Dockerfile.base.erb: -------------------------------------------------------------------------------- 1 | FROM rubylang/ruby:<%= ruby_version %>-bionic 2 | 3 | ADD ci/requirements.txt /tmp 4 | 5 | RUN apt-get update \ 6 | && apt-get install -y --no-install-recommends \ 7 | libczmq-dev \ 8 | python3 \ 9 | python3-pip \ 10 | python3-setuptools \ 11 | libpython3.6 \ 12 | && pip3 install wheel \ 13 | && pip3 install -r /tmp/requirements.txt \ 14 | && rm -f /tmp/requirements.txt 15 | 16 | # ZeroMQ version 4.1.6 and CZMQ version 3.0.2 for rbczmq 17 | RUN apt-get update \ 18 | && apt-get install -y --no-install-recommends \ 19 | build-essential \ 20 | file \ 21 | wget \ 22 | && cd /tmp \ 23 | && wget https://github.com/zeromq/zeromq4-1/releases/download/v4.1.6/zeromq-4.1.6.tar.gz \ 24 | && wget https://archive.org/download/zeromq_czmq_3.0.2/czmq-3.0.2.tar.gz \ 25 | && tar xf zeromq-4.1.6.tar.gz \ 26 | && tar xf czmq-3.0.2.tar.gz \ 27 | && \ 28 | ( \ 29 | cd zeromq-4.1.6 \ 30 | && ./configure \ 31 | && make install \ 32 | ) \ 33 | && \ 34 | ( \ 35 | cd czmq-3.0.2 \ 36 | && wget -O 1.patch https://github.com/zeromq/czmq/commit/2594d406d8ec6f54e54d7570d7febba10a6906b2.diff \ 37 | && wget -O 2.patch https://github.com/zeromq/czmq/commit/b651cb479235751b22b8f9a822a2fc6bc1be01ab.diff \ 38 | && cat *.patch | patch -p1 \ 39 | && ./configure \ 40 | && make install \ 41 | ) 42 | -------------------------------------------------------------------------------- /iruby.gemspec: -------------------------------------------------------------------------------- 1 | require_relative 'lib/iruby/version' 2 | 3 | Gem::Specification.new do |s| 4 | s.name = 'iruby' 5 | s.version = IRuby::VERSION 6 | s.authors = ['Daniel Mendler', 'The SciRuby developers'] 7 | s.email = ['mail@daniel-mendler.de'] 8 | s.summary = 'Ruby Kernel for Jupyter' 9 | s.description = 'A Ruby kernel for Jupyter environment. Try it at try.jupyter.org.' 10 | s.homepage = 'https://github.com/SciRuby/iruby' 11 | s.license = 'MIT' 12 | 13 | s.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) 14 | s.bindir = "exe" 15 | s.executables = %w[iruby] 16 | s.test_files = s.files.grep(%r{^test/}) 17 | s.require_paths = %w[lib] 18 | 19 | s.required_ruby_version = '>= 2.3.0' 20 | 21 | s.add_dependency 'data_uri', '~> 0.1' 22 | s.add_dependency 'ffi-rzmq' 23 | s.add_dependency 'irb' 24 | s.add_dependency 'logger' 25 | s.add_dependency 'mime-types', '>= 3.3.1' 26 | 27 | s.add_development_dependency 'pycall', '>= 1.2.1' 28 | s.add_development_dependency 'rake' 29 | s.add_development_dependency 'test-unit' 30 | s.add_development_dependency 'test-unit-rr' 31 | 32 | [ 33 | ['arch_linux', 'zeromq'], 34 | ['debian', 'libzmq3-dev'], 35 | ['freebsd', 'libzmq4'], 36 | ['homebrew', 'zmq'], 37 | ['macports', 'zmq'], 38 | ].each do |platform, package| 39 | s.requirements << "system: libzmq: #{platform}: #{package}" 40 | end 41 | 42 | s.metadata['msys2_mingw_dependencies'] = 'zeromq' 43 | end 44 | -------------------------------------------------------------------------------- /test/integration_test.rb: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | require 'pty' 3 | require 'expect' 4 | 5 | class IRubyTest::IntegrationTest < IRubyTest::TestBase 6 | def setup 7 | system(*iruby_command("register", "--name=iruby-test"), out: File::NULL, err: File::NULL) 8 | kernel_json = File.join(ENV["JUPYTER_DATA_DIR"], "kernels", "iruby-test", "kernel.json") 9 | assert_path_exist kernel_json 10 | 11 | $expect_verbose = false # make true if you want to dump the output of iruby console 12 | 13 | command = iruby_command("console", "--kernel=iruby-test").map {|x| %Q["#{x}"] } 14 | @in, @out, pid = PTY.spawn(command.join(" ")) 15 | @waiter = Thread.start { Process.waitpid(pid) } 16 | expect 'In [', 30 17 | expect '1' 18 | expect ']:' 19 | end 20 | 21 | def teardown 22 | @in.close 23 | @out.close 24 | @waiter.join 25 | end 26 | 27 | def write(input) 28 | @out.puts input 29 | end 30 | 31 | def expect(pattern, timeout = 10) 32 | assert @in.expect(pattern, timeout), "#{pattern} expected, but timeout" 33 | end 34 | 35 | def wait_prompt 36 | expect 'In [' 37 | expect ']:' 38 | end 39 | 40 | def test_interaction 41 | omit("This test is too unstable") 42 | 43 | write '"Hello, world!"' 44 | expect '"Hello, world!"' 45 | 46 | wait_prompt 47 | write 'puts "Hello!"' 48 | expect 'Hello!' 49 | 50 | wait_prompt 51 | write '12 + 12' 52 | expect '24' 53 | 54 | wait_prompt 55 | write 'ls' 56 | expect 'self.methods' 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/iruby/utils.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module Utils 3 | def convert(object, options) 4 | Display.convert(object, options) 5 | end 6 | 7 | # Display the object 8 | def display(obj, options = {}) 9 | Kernel.instance.session.send(:publish, :display_data, 10 | data: Display.display(obj, options), 11 | metadata: {}) unless obj.nil? 12 | # The next `nil` is necessary to prevent unintentional displaying 13 | # the result of Session#send 14 | nil 15 | end 16 | 17 | # Clear the output area 18 | def clear_output(wait=false) 19 | Display.clear_output(wait) 20 | end 21 | 22 | # Format the given object into HTML table 23 | def table(s, **options) 24 | html(HTML.table(s, **options)) 25 | end 26 | 27 | # Treat the given string as LaTeX text 28 | def latex(s) 29 | convert(s, mime: 'text/latex') 30 | end 31 | alias tex latex 32 | 33 | # Format the given string of TeX equation into LaTeX text 34 | def math(s) 35 | convert("$$#{s}$$", mime: 'text/latex') 36 | end 37 | 38 | # Treat the given string as HTML 39 | def html(s) 40 | convert(s, mime: 'text/html') 41 | end 42 | 43 | # Treat the given string as JavaScript code 44 | def javascript(s) 45 | convert(s, mime: 'application/javascript') 46 | end 47 | 48 | # Treat the given string as SVG text 49 | def svg(s) 50 | convert(s, mime: 'image/svg+xml') 51 | end 52 | end 53 | 54 | extend Utils 55 | end 56 | -------------------------------------------------------------------------------- /lib/iruby/session_adapter/ffirzmq_adapter.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module SessionAdapter 3 | class FfirzmqAdapter < BaseAdapter 4 | def self.load_requirements 5 | require 'ffi-rzmq' 6 | end 7 | 8 | def send(sock, data) 9 | data.each_with_index do |part, i| 10 | sock.send_string(part, i == data.size - 1 ? 0 : ZMQ::SNDMORE) 11 | end 12 | end 13 | 14 | def recv(sock) 15 | msg = [] 16 | while msg.empty? || sock.more_parts? 17 | begin 18 | frame = '' 19 | rc = sock.recv_string(frame) 20 | ZMQ::Util.error_check('zmq_msg_recv', rc) 21 | msg << frame 22 | rescue 23 | end 24 | end 25 | msg 26 | end 27 | 28 | def heartbeat_loop(sock) 29 | @heartbeat_device = ZMQ::Device.new(sock, sock) 30 | end 31 | 32 | private 33 | 34 | def make_socket(type, protocol, host, port) 35 | case type 36 | when :ROUTER, :PUB, :REP 37 | type = ZMQ.const_get(type) 38 | else 39 | if ZMQ.const_defined?(type) 40 | raise ArgumentError, "Unsupported ZMQ socket type: #{type}" 41 | else 42 | raise ArgumentError, "Invalid ZMQ socket type: #{type}" 43 | end 44 | end 45 | zmq_context.socket(type).tap do |sock| 46 | sock.bind("#{protocol}://#{host}:#{port}") 47 | end 48 | end 49 | 50 | def zmq_context 51 | @zmq_context ||= ZMQ::Context.new 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/iruby/input/select.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module Input 3 | class Select < Label 4 | needs :options, :default 5 | 6 | builder :select do |*args, **params| 7 | key = :select 8 | key, *args = args if args.first.is_a? Symbol 9 | 10 | params[:key] = unique_key(key) 11 | params[:options] = args 12 | params[:default] ||= false 13 | 14 | unless params[:options].include? params[:default] 15 | params[:options] = [nil, *params[:options].compact] 16 | end 17 | 18 | add_field Select.new(**params) 19 | end 20 | 21 | def widget_css 22 | <<-CSS 23 | .iruby-select { 24 | min-width: 25%; 25 | margin-left: 0 !important; 26 | } 27 | CSS 28 | end 29 | 30 | def widget_js 31 | <<-JS 32 | $('.iruby-select').change(function(){ 33 | $(this).data('iruby-value', 34 | $(this).find('option:selected').text() 35 | ); 36 | }); 37 | JS 38 | end 39 | 40 | def widget_html 41 | widget_label do 42 | div class: 'form-control' do 43 | params = { 44 | class: 'iruby-select', 45 | :'data-iruby-key' => @key, 46 | :'data-iruby-value' => @default 47 | } 48 | 49 | select **params do 50 | @options.each do |o| 51 | option o, selected: @default == o 52 | end 53 | end 54 | end 55 | end 56 | end 57 | end 58 | end 59 | end -------------------------------------------------------------------------------- /lib/iruby/input/file.rb: -------------------------------------------------------------------------------- 1 | require 'data_uri' 2 | 3 | module IRuby 4 | module Input 5 | class File < Label 6 | builder :file do |key='file', **params| 7 | key = unique_key key 8 | add_field File.new(key: key, **params) 9 | 10 | # tell the builder to process files differently 11 | process key do |result,key,value| 12 | uri = URI::Data.new value['data'] 13 | 14 | # get rid of Chrome's silly path 15 | name = value['name'].sub('C:\\fakepath\\','') 16 | 17 | result[key.to_sym] = { 18 | name: name, 19 | data: uri.data, 20 | content_type: uri.content_type 21 | } 22 | end 23 | end 24 | 25 | def widget_js 26 | <<-JS 27 | $('.iruby-file').change(function() { 28 | var input = $(this); 29 | 30 | $.grep($(this).prop('files'), function(file) { 31 | var reader = new FileReader(); 32 | 33 | reader.addEventListener("load", function(event) { 34 | input.data('iruby-value', { 35 | name: input.val(), 36 | data: event.target.result 37 | }); 38 | }); 39 | 40 | reader.readAsDataURL(file); 41 | }); 42 | }); 43 | JS 44 | end 45 | 46 | def widget_html 47 | widget_label do 48 | input( 49 | type: 'file', 50 | :'data-iruby-key' => @key, 51 | class: 'form-control iruby-file' 52 | ) 53 | end 54 | end 55 | end 56 | end 57 | end -------------------------------------------------------------------------------- /lib/iruby/session/mixin.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module SessionSerialize 3 | DELIM = '' 4 | 5 | private 6 | 7 | def serialize(idents, header, metadata = nil, content) 8 | msg = [JSON.generate(header), 9 | JSON.generate(@last_recvd_msg ? @last_recvd_msg[:header] : {}), 10 | JSON.generate(metadata || {}), 11 | JSON.generate(content || {})] 12 | frames = ([*idents].compact.map(&:to_s) << DELIM << sign(msg)) + msg 13 | IRuby.logger.debug "Sent #{frames.inspect}" 14 | frames 15 | end 16 | 17 | def unserialize(msg) 18 | raise 'no message received' unless msg 19 | frames = msg.to_a.map(&:to_s) 20 | IRuby.logger.debug "Received #{frames.inspect}" 21 | 22 | i = frames.index(DELIM) 23 | idents, msg_list = frames[0..i-1], frames[i+1..-1] 24 | 25 | minlen = 5 26 | raise "malformed message, must have at least #{minlen} elements" unless msg_list.length >= minlen 27 | s, header, parent_header, metadata, content, buffers = *msg_list 28 | raise 'Invalid signature' unless s == sign(msg_list[1..-1]) 29 | { 30 | idents: idents, 31 | header: JSON.parse(header), 32 | parent_header: JSON.parse(parent_header), 33 | metadata: JSON.parse(metadata), 34 | content: JSON.parse(content), 35 | buffers: buffers 36 | } 37 | end 38 | 39 | # Sign using HMAC 40 | def sign(list) 41 | return '' unless @hmac 42 | @hmac.reset 43 | list.each {|m| @hmac.update(m) } 44 | @hmac.hexdigest 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/iruby/ostream.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | # IO-like object that publishes to 0MQ socket. 3 | class OStream 4 | attr_accessor :sync 5 | 6 | def initialize(session, name) 7 | @session, @name = session, name 8 | end 9 | 10 | def close 11 | @session = nil 12 | end 13 | 14 | def closed? 15 | @session.nil? 16 | end 17 | 18 | def flush 19 | end 20 | 21 | def isatty 22 | false 23 | end 24 | alias_method :tty?, :isatty 25 | 26 | def read(*args) 27 | raise IOError, 'not opened for reading' 28 | end 29 | alias_method :next, :read 30 | alias_method :readline, :read 31 | 32 | def write(*obj) 33 | str = build_string { |sio| sio.write(*obj) } 34 | session_send(str) 35 | end 36 | alias_method :<<, :write 37 | alias_method :print, :write 38 | 39 | def printf(format, *obj) 40 | str = build_string { |sio| sio.printf(format, *obj) } 41 | session_send(str) 42 | end 43 | 44 | def puts(*obj) 45 | str = build_string { |sio| sio.puts(*obj) } 46 | session_send(str) 47 | end 48 | 49 | def writelines(lines) 50 | lines.each { |s| write(s) } 51 | end 52 | 53 | # Called by irb 54 | def set_encoding(extern, intern) 55 | extern 56 | end 57 | 58 | private 59 | 60 | def build_string 61 | StringIO.open { |sio| yield(sio); sio.string } 62 | end 63 | 64 | def session_send(str) 65 | raise 'I/O operation on closed file' unless @session 66 | 67 | @session.send(:publish, :stream, name: @name, text: str) 68 | nil 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/iruby/input/builder.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module Input 3 | class Builder 4 | attr_reader :fields, :buttons 5 | 6 | def initialize &block 7 | @processors = {} 8 | @fields, @buttons = [], [] 9 | instance_eval &block 10 | end 11 | 12 | def add_field field 13 | @fields << field 14 | end 15 | 16 | def add_button button 17 | # see bit.ly/1Tsv6x4 18 | @buttons.unshift button 19 | end 20 | 21 | def html &block 22 | add_field Class.new(Widget) { 23 | define_method(:widget_html) { instance_eval &block } 24 | }.new 25 | end 26 | 27 | def text string 28 | html { label string } 29 | end 30 | 31 | def password key='password', **params 32 | input key, **params.merge(type: 'password') 33 | end 34 | 35 | def process_result result 36 | unless result.nil? 37 | result.each_with_object({}) do |(k,v),h| 38 | if @processors.has_key? k 39 | @processors[k].call h, k, v 40 | else 41 | h[k.to_sym] = v 42 | end 43 | end 44 | end 45 | end 46 | 47 | private 48 | 49 | def process key, &block 50 | @processors[key.to_s] = block 51 | end 52 | 53 | def unique_key key 54 | @keys ||= [] 55 | 56 | if @keys.include? key 57 | (2..Float::INFINITY).each do |i| 58 | test = "#{key}#{i}" 59 | break key = test unless @keys.include? test 60 | end 61 | end 62 | 63 | @keys << key; key 64 | end 65 | end 66 | end 67 | end -------------------------------------------------------------------------------- /test/iruby/application/helper.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "open3" 3 | require "rbconfig" 4 | 5 | require "iruby/application" 6 | 7 | module IRubyTest 8 | module ApplicationTests 9 | class ApplicationTestBase < TestBase 10 | DEFAULT_KERNEL_NAME = IRuby::Application::DEFAULT_KERNEL_NAME 11 | DEFAULT_DISPLAY_NAME = IRuby::Application::DEFAULT_DISPLAY_NAME 12 | 13 | def ensure_iruby_kernel_is_installed(kernel_name=nil) 14 | if kernel_name 15 | system(*iruby_command("register", "--name=#{kernel_name}"), out: File::NULL, err: File::NULL) 16 | else 17 | system(*iruby_command("register"), out: File::NULL, err: File::NULL) 18 | kernel_name = DEFAULT_KERNEL_NAME 19 | end 20 | kernel_json = File.join(ENV["JUPYTER_DATA_DIR"], "kernels", kernel_name, "kernel.json") 21 | assert_path_exist kernel_json 22 | 23 | # Insert -I option to add the lib directory in the $LOAD_PATH of the kernel process 24 | modified_content = JSON.load(File.read(kernel_json)) 25 | kernel_index = modified_content["argv"].index("kernel") 26 | modified_content["argv"].insert(kernel_index - 1, "-I#{LIB_DIR}") 27 | File.write(kernel_json, JSON.pretty_generate(modified_content)) 28 | end 29 | 30 | def add_kernel_options(*additional_argv) 31 | kernel_json = File.join(ENV["JUPYTER_DATA_DIR"], "kernels", DEFAULT_KERNEL_NAME, "kernel.json") 32 | modified_content = JSON.load(File.read(kernel_json)) 33 | modified_content["argv"].concat(additional_argv) 34 | File.write(kernel_json, JSON.pretty_generate(modified_content)) 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/iruby/mime_test.rb: -------------------------------------------------------------------------------- 1 | class IRubyTest::MimeTest < IRubyTest::TestBase 2 | sub_test_case("IRuby::Display") do 3 | sub_test_case(".display") do 4 | sub_test_case("with mime type") do 5 | test("text/html") do 6 | html = "Bold Text" 7 | 8 | obj = Object.new 9 | obj.define_singleton_method(:to_s) { html } 10 | 11 | res = IRuby::Display.display(obj, mime: "text/html") 12 | assert_equal({ plain: obj.inspect, html: html }, 13 | { plain: res["text/plain"], html: res["text/html"] }) 14 | end 15 | 16 | test("application/javascript") do 17 | data = "alert('Hello World!')" 18 | res = IRuby::Display.display(data, mime: "application/javascript") 19 | assert_equal(data, 20 | res["application/javascript"]) 21 | end 22 | 23 | test("image/svg+xml") do 24 | data = 'SVG' 25 | res = IRuby::Display.display(data, mime: "image/svg+xml") 26 | assert_equal(data, 27 | res["image/svg+xml"]) 28 | end 29 | end 30 | end 31 | end 32 | 33 | sub_test_case("Rendering a file") do 34 | def setup 35 | @html = "Bold Text" 36 | Dir.mktmpdir do |tmpdir| 37 | @file = File.join(tmpdir, "test.html") 38 | File.write(@file, @html) 39 | yield 40 | end 41 | end 42 | 43 | def test_display 44 | File.open(@file, "rb") do |f| 45 | res = IRuby::Display.display(f) 46 | assert_equal(@html, res["text/html"]) 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/iruby/session_test.rb: -------------------------------------------------------------------------------- 1 | module IRubyTest 2 | class SessionAdapterSelectionTest < TestBase 3 | def setup 4 | # https://jupyter-client.readthedocs.io/en/stable/kernels.html#connection-files 5 | @session_config = { 6 | "control_port" => 0, 7 | "shell_port" => 0, 8 | "transport" => "tcp", 9 | "signature_scheme" => "hmac-sha256", 10 | "stdin_port" => 0, 11 | "hb_port" => 0, 12 | "ip" => "127.0.0.1", 13 | "iopub_port" => 0, 14 | "key" => "a0436f6c-1916-498b-8eb9-e81ab9368e84" 15 | } 16 | end 17 | 18 | def test_new_with_session_adapter 19 | adapter_name = ENV['IRUBY_TEST_SESSION_ADAPTER_NAME'] 20 | adapter_class = case adapter_name 21 | when 'cztop' 22 | IRuby::SessionAdapter::CztopAdapter 23 | when 'ffi-rzmq' 24 | IRuby::SessionAdapter::FfirzmqAdapter 25 | else 26 | flunk "Unknown session adapter: #{adapter_name.inspect}" 27 | end 28 | 29 | session = IRuby::Session.new(@session_config, adapter_name) 30 | assert_kind_of(adapter_class, session.adapter) 31 | end 32 | 33 | def test_without_any_session_adapter 34 | assert_rr do 35 | stub(IRuby::SessionAdapter::CztopAdapter).available? { false } 36 | stub(IRuby::SessionAdapter::FfirzmqAdapter).available? { false } 37 | stub(IRuby::SessionAdapter::TestAdapter).available? { false } 38 | assert_raises IRuby::SessionAdapterNotFound do 39 | IRuby::Session.new(@session_config) 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/iruby/input/radio.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module Input 3 | class Radio < Label 4 | needs :options, :default 5 | 6 | builder :radio do |*args, **params| 7 | key = :radio 8 | key, *args = args if args.first.is_a? Symbol 9 | 10 | params[:key] = unique_key(key) 11 | params[:options] = args 12 | params[:default] ||= false 13 | add_field Radio.new(**params) 14 | end 15 | 16 | def widget_css 17 | <<-CSS 18 | .iruby-radio.form-control { display: inline-table; } 19 | .iruby-radio .radio-inline { margin: 0 15px 0 0; } 20 | CSS 21 | end 22 | 23 | def widget_js 24 | <<-JS 25 | $('.iruby-radio input').change(function(){ 26 | var parent = $(this).closest('.iruby-radio'); 27 | $(parent).data('iruby-value', 28 | $(parent).find(':checked').val() 29 | ); 30 | }); 31 | $('.iruby-radio input').trigger('change'); 32 | JS 33 | end 34 | 35 | def widget_html 36 | params = { 37 | :'data-iruby-key' => @key, 38 | :'data-iruby-value' => @options.first, 39 | class: 'iruby-radio form-control' 40 | } 41 | widget_label do 42 | div **params do 43 | @options.each do |option| 44 | label class: 'radio-inline' do 45 | input( 46 | name: @key, 47 | value: option, 48 | type: 'radio', 49 | checked: @default == option 50 | ) 51 | text option 52 | end 53 | end 54 | end 55 | end 56 | end 57 | end 58 | end 59 | end -------------------------------------------------------------------------------- /lib/iruby/input/multiple.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module Input 3 | class Multiple < Label 4 | needs :options, :default, size: nil 5 | 6 | builder :multiple do |*args, **params| 7 | key = :multiple 8 | key, *args = args if args.first.is_a? Symbol 9 | 10 | params[:key] = unique_key(key) 11 | params[:options] = args 12 | 13 | params[:default] = case params[:default] 14 | when false, nil 15 | [] 16 | when true 17 | [*params[:options]] 18 | else 19 | [*params[:default]] 20 | end 21 | 22 | add_field Multiple.new(**params) 23 | end 24 | 25 | def widget_css 26 | <<-CSS 27 | .iruby-multiple { 28 | display: table; 29 | min-width: 25%; 30 | } 31 | .form-control.iruby-multiple-container { 32 | display: table; 33 | } 34 | CSS 35 | end 36 | 37 | def widget_js 38 | <<-JS 39 | $('.iruby-multiple').change(function(){ 40 | var multiple = $(this); 41 | multiple.data('iruby-value', []); 42 | 43 | multiple.find(':selected').each(function(){ 44 | multiple.data('iruby-value').push($(this).val()); 45 | }); 46 | 47 | if (multiple.data('iruby-value').length == 0) { 48 | multiple.data('iruby-value', null); 49 | } 50 | }); 51 | 52 | $('.iruby-multiple').trigger('change'); 53 | JS 54 | end 55 | 56 | def widget_html 57 | widget_label do 58 | div class: 'form-control iruby-multiple-container' do 59 | params = { 60 | size: @size, 61 | multiple: true, 62 | class: 'iruby-multiple', 63 | :'data-iruby-key' => @key 64 | } 65 | 66 | select **params do 67 | @options.each do |o| 68 | option o, selected: @default.include?(o) 69 | end 70 | end 71 | end 72 | end 73 | end 74 | end 75 | end 76 | end -------------------------------------------------------------------------------- /lib/iruby/input/checkbox.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module Input 3 | class Checkbox < Label 4 | needs :options, :default 5 | 6 | builder :checkbox do |*args, **params| 7 | key = :checkbox 8 | key, *args = args if args.first.is_a? Symbol 9 | 10 | params[:key] = unique_key(key) 11 | params[:options] = args 12 | 13 | params[:default] = case params[:default] 14 | when false, nil 15 | [] 16 | when true 17 | [*params[:options]] 18 | else 19 | [*params[:default]] 20 | end 21 | 22 | add_field Checkbox.new(**params) 23 | end 24 | 25 | def widget_css 26 | <<-CSS 27 | .iruby-checkbox.form-control { display: inline-table; } 28 | .iruby-checkbox .checkbox-inline { margin: 0 15px 0 0; } 29 | CSS 30 | end 31 | 32 | def widget_js 33 | <<-JS 34 | $('.iruby-checkbox input').change(function(){ 35 | var parent = $(this).closest('.iruby-checkbox'); 36 | $(parent).data('iruby-value', []); 37 | 38 | $(parent).find(':checked').each(function(){ 39 | $(parent).data('iruby-value').push($(this).val()); 40 | }); 41 | 42 | if ($(parent).data('iruby-value').length == 0) { 43 | $(parent).data('iruby-value', null); 44 | } 45 | }); 46 | 47 | $('.iruby-checkbox input').trigger('change'); 48 | JS 49 | end 50 | 51 | def widget_html 52 | params = { 53 | :'data-iruby-key' => @key, 54 | class: 'iruby-checkbox form-control' 55 | } 56 | widget_label do 57 | div **params do 58 | @options.each do |option| 59 | label class: 'checkbox-inline' do 60 | input( 61 | name: @key, 62 | value: option, 63 | type: 'checkbox', 64 | checked: @default.include?(option) 65 | ) 66 | text option 67 | end 68 | end 69 | end 70 | end 71 | end 72 | end 73 | end 74 | end -------------------------------------------------------------------------------- /lib/iruby/session/cztop.rb: -------------------------------------------------------------------------------- 1 | require 'cztop' 2 | 3 | module IRuby 4 | class Session 5 | include SessionSerialize 6 | 7 | def initialize(config) 8 | connection = "#{config['transport']}://#{config['ip']}:%d" 9 | 10 | reply_socket = CZTop::Socket::ROUTER.new(connection % config['shell_port']) 11 | pub_socket = CZTop::Socket::PUB.new(connection % config['iopub_port']) 12 | stdin_socket = CZTop::Socket::ROUTER.new(connection % config['stdin_port']) 13 | 14 | Thread.new do 15 | begin 16 | hb_socket = CZTop::Socket::REP.new(connection % config['hb_port']) 17 | loop do 18 | message = hb_socket.receive 19 | hb_socket << message 20 | end 21 | rescue Exception => e 22 | IRuby.logger.fatal "Kernel heartbeat died: #{e.message}\n#{e.backtrace.join("\n")}" 23 | end 24 | end 25 | 26 | @sockets = { 27 | publish: pub_socket, 28 | reply: reply_socket, 29 | stdin: stdin_socket, 30 | } 31 | 32 | @session = SecureRandom.uuid 33 | unless config['key'].to_s.empty? || config['signature_scheme'].to_s.empty? 34 | raise 'Unknown signature scheme' unless config['signature_scheme'] =~ /\Ahmac-(.*)\Z/ 35 | @hmac = OpenSSL::HMAC.new(config['key'], OpenSSL::Digest.new($1)) 36 | end 37 | end 38 | 39 | def description 40 | 'old-style session using cztop' 41 | end 42 | 43 | # Build and send a message 44 | def send(socket, type, content) 45 | idents = 46 | if socket == :reply && @last_recvd_msg 47 | @last_recvd_msg[:idents] 48 | else 49 | type == :stream ? "stream.#{content[:name]}" : type 50 | end 51 | header = { 52 | msg_type: type, 53 | msg_id: SecureRandom.uuid, 54 | username: 'kernel', 55 | session: @session, 56 | version: '5.0' 57 | } 58 | @sockets[socket] << serialize(idents, header, content) 59 | end 60 | 61 | # Receive a message and decode it 62 | def recv(socket) 63 | @last_recvd_msg = unserialize(@sockets[socket].receive) 64 | end 65 | 66 | def recv_input 67 | unserialize(@sockets[:stdin].receive)[:content]["value"] 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /tasks/ci.rake: -------------------------------------------------------------------------------- 1 | namespace :ci do 2 | namespace :docker do 3 | def ruby_version 4 | @ruby_version ||= ENV['ruby_version'] 5 | end 6 | 7 | def ruby_image_name 8 | @ruby_image_name ||= "rubylang/ruby:#{ruby_version}-bionic" 9 | end 10 | 11 | def iruby_test_base_image_name 12 | @iruby_test_base_image_name ||= "iruby-test-base:ruby-#{ruby_version}" 13 | end 14 | 15 | def iruby_test_image_name 16 | @docker_image_name ||= begin 17 | "sciruby/iruby-test:ruby-#{ruby_version}" 18 | end 19 | end 20 | 21 | def docker_image_found?(image_name) 22 | image_id = `docker images -q #{image_name}`.chomp 23 | image_id.length > 0 24 | end 25 | 26 | directory 'tmp' 27 | 28 | desc "Build iruby-test-base docker image" 29 | task :build_test_base_image => 'tmp' do 30 | unless docker_image_found?(iruby_test_base_image_name) 31 | require 'erb' 32 | dockerfile_content = ERB.new(File.read('ci/Dockerfile.base.erb')).result(binding) 33 | File.write('tmp/Dockerfile', dockerfile_content) 34 | sh 'docker', 'build', '-t', iruby_test_base_image_name, '-f', 'tmp/Dockerfile', '.' 35 | end 36 | end 37 | 38 | desc "Pull docker image of ruby" 39 | task :pull_ruby_image do 40 | sh 'docker', 'pull', ruby_image_name 41 | end 42 | 43 | desc "Build iruby-test docker image" 44 | task :build_test_image => 'tmp' do 45 | require 'erb' 46 | dockerfile_content = ERB.new(File.read('ci/Dockerfile.main.erb')).result(binding) 47 | File.write('tmp/Dockerfile', dockerfile_content) 48 | sh 'docker', 'build', '-t', iruby_test_image_name, '-f', 'tmp/Dockerfile', '.' 49 | end 50 | 51 | desc 'before_install script for CI with Docker' 52 | task :before_install => :pull_ruby_image 53 | task :before_install => :build_test_base_image 54 | 55 | desc 'install script for CI with Docker' 56 | task :install => :build_test_image 57 | 58 | desc 'main script for CI with Docker' 59 | task :script do 60 | volumes = ['-v', "#{Dir.pwd}:/iruby"] if ENV['attach_pwd'] 61 | sh 'docker', 'run', '--rm', '-it', *volumes, 62 | iruby_test_image_name, 'bash', 'run-test.sh' 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/iruby/input/form.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | 3 | module IRuby 4 | module Input 5 | class InputForm < Widget 6 | needs :fields, buttons: [] 7 | 8 | def widget_js 9 | javascript = <<-JS 10 | var remove = function () { 11 | Jupyter.notebook.kernel.send_input_reply( 12 | JSON.stringify({ 13 | '#{@id = SecureRandom.uuid}': null 14 | }) 15 | ); 16 | }; 17 | 18 | $("#iruby-form").on("remove", remove); 19 | 20 | $('#iruby-form').submit(function() { 21 | var result = {}; 22 | $(this).off('remove', remove); 23 | 24 | $('[data-iruby-key]').each(function() { 25 | if ($(this).data('iruby-key')) { 26 | var value = $(this).data('iruby-value'); 27 | if (value) { 28 | result[$(this).data('iruby-key')] = value; 29 | } 30 | } 31 | }); 32 | 33 | Jupyter.notebook.kernel.send_input_reply( 34 | JSON.stringify({'#{@id}': result}) 35 | ); 36 | 37 | $(this).remove(); 38 | return false; 39 | }); 40 | 41 | $('#iruby-form').keydown(function(event) { 42 | if (event.keyCode == 13 && !event.shiftKey) { 43 | $('#iruby-form').submit(); 44 | } else if (event.keyCode == 27) { 45 | $('#iruby-form').remove(); 46 | } 47 | }); 48 | JS 49 | 50 | widget_join :widget_js, javascript, *@fields, *@buttons 51 | end 52 | 53 | def widget_css 54 | spacing = '#iruby-form > * { margin-bottom: 5px; }' 55 | widget_join :widget_css, spacing, *@fields, *@buttons 56 | end 57 | 58 | def widget_html 59 | form id: 'iruby-form', class: 'col-md-12' do 60 | @fields.each {|field| widget field} 61 | end 62 | @buttons.each {|button| widget button} 63 | end 64 | 65 | def submit 66 | result = JSON.parse(Kernel.instance.session.recv_input) 67 | 68 | unless result.has_key? @id 69 | submit 70 | else 71 | Display.clear_output 72 | result[@id] 73 | end 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/iruby/session_adapter.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | class SessionAdapterNotFound < RuntimeError; end 3 | 4 | module SessionAdapter 5 | class BaseAdapter 6 | def self.available? 7 | load_requirements 8 | true 9 | rescue LoadError 10 | false 11 | end 12 | 13 | def self.load_requirements 14 | # Do nothing 15 | end 16 | 17 | def initialize(config) 18 | @config = config 19 | end 20 | 21 | def name 22 | self.class.name[/::(\w+)Adapter\Z/, 1].downcase 23 | end 24 | 25 | def make_router_socket(protocol, host, port) 26 | socket, port = make_socket(:ROUTER, protocol, host, port) 27 | [socket, port] 28 | end 29 | 30 | def make_pub_socket(protocol, host, port) 31 | socket, port = make_socket(:PUB, protocol, host, port) 32 | [socket, port] 33 | end 34 | 35 | def make_rep_socket(protocol, host, port) 36 | socket, port = make_socket(:REP, protocol, host, port) 37 | [socket, port] 38 | end 39 | end 40 | 41 | require_relative 'session_adapter/ffirzmq_adapter' 42 | require_relative 'session_adapter/cztop_adapter' 43 | require_relative 'session_adapter/test_adapter' 44 | 45 | def self.select_adapter_class(name=nil) 46 | classes = { 47 | 'ffi-rzmq' => SessionAdapter::FfirzmqAdapter, 48 | 'cztop' => SessionAdapter::CztopAdapter, 49 | 'test' => SessionAdapter::TestAdapter, 50 | } 51 | if (name ||= ENV.fetch('IRUBY_SESSION_ADAPTER', nil)) 52 | cls = classes[name] 53 | unless cls.available? 54 | if ENV['IRUBY_SESSION_ADAPTER'] 55 | raise SessionAdapterNotFound, 56 | "Session adapter `#{name}` from IRUBY_SESSION_ADAPTER is unavailable" 57 | else 58 | raise SessionAdapterNotFound, 59 | "Session adapter `#{name}` is unavailable" 60 | end 61 | end 62 | if name == 'cztop' 63 | warn "WARNING: cztop is deprecated and will be removed; Use ffi-rzmq instead." 64 | end 65 | return cls 66 | end 67 | classes.each_value do |cls| 68 | return cls if cls.available? 69 | end 70 | raise SessionAdapterNotFound, "No session adapter is available" 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /test/iruby/session_adapter_test.rb: -------------------------------------------------------------------------------- 1 | module IRubyTest 2 | class SessionAdapterTest < TestBase 3 | def test_available_p_return_false_when_load_error 4 | subclass = Class.new(IRuby::SessionAdapter::BaseAdapter) 5 | class << subclass 6 | def load_requirements 7 | raise LoadError 8 | end 9 | end 10 | refute subclass.available? 11 | end 12 | 13 | def test_select_adapter_class_with_cztop 14 | assert_rr do 15 | stub(IRuby::SessionAdapter::CztopAdapter).available? { true } 16 | stub(IRuby::SessionAdapter::FfirzmqAdapter).available? { false } 17 | 18 | cls = IRuby::SessionAdapter.select_adapter_class 19 | assert_equal IRuby::SessionAdapter::CztopAdapter, cls 20 | end 21 | end 22 | 23 | def test_select_adapter_class_with_ffirzmq 24 | assert_rr do 25 | stub(IRuby::SessionAdapter::FfirzmqAdapter).available? { true } 26 | stub(IRuby::SessionAdapter::CztopAdapter).available? { false } 27 | 28 | cls = IRuby::SessionAdapter.select_adapter_class 29 | assert_equal IRuby::SessionAdapter::FfirzmqAdapter, cls 30 | end 31 | end 32 | 33 | def test_select_adapter_class_with_env 34 | with_env('IRUBY_SESSION_ADAPTER' => 'cztop') do 35 | assert_rr do 36 | stub(IRuby::SessionAdapter::CztopAdapter).available? { true } 37 | assert_equal IRuby::SessionAdapter::CztopAdapter, IRuby::SessionAdapter.select_adapter_class 38 | end 39 | 40 | assert_rr do 41 | stub(IRuby::SessionAdapter::CztopAdapter).available? { false } 42 | assert_raises IRuby::SessionAdapterNotFound do 43 | IRuby::SessionAdapter.select_adapter_class 44 | end 45 | end 46 | end 47 | 48 | with_env('IRUBY_SESSION_ADAPTER' => 'ffi-rzmq') do 49 | assert_rr do 50 | stub(IRuby::SessionAdapter::FfirzmqAdapter).available? { true } 51 | assert_equal IRuby::SessionAdapter::FfirzmqAdapter, IRuby::SessionAdapter.select_adapter_class 52 | end 53 | 54 | assert_rr do 55 | stub(IRuby::SessionAdapter::FfirzmqAdapter).available? { false } 56 | assert_raises IRuby::SessionAdapterNotFound do 57 | IRuby::SessionAdapter.select_adapter_class 58 | end 59 | end 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/iruby/event_manager_test.rb: -------------------------------------------------------------------------------- 1 | module IRubyTest 2 | class EventManagerTest < TestBase 3 | def setup 4 | @man = IRuby::EventManager.new([:foo, :bar]) 5 | end 6 | 7 | def test_available_events 8 | assert_equal([:foo, :bar], 9 | @man.available_events) 10 | end 11 | 12 | sub_test_case("#register") do 13 | sub_test_case("known event name") do 14 | def test_register 15 | fn = ->() {} 16 | assert_equal(fn, 17 | @man.register(:foo, &fn)) 18 | end 19 | end 20 | 21 | sub_test_case("unknown event name") do 22 | def test_register 23 | assert_raise_message("Unknown event name: baz") do 24 | @man.register(:baz) {} 25 | end 26 | end 27 | end 28 | end 29 | 30 | sub_test_case("#unregister") do 31 | sub_test_case("no event is registered") do 32 | def test_unregister 33 | fn = ->() {} 34 | assert_raise_message("Given callable object #{fn} is not registered as a foo callback") do 35 | @man.unregister(:foo, fn) 36 | end 37 | end 38 | end 39 | 40 | sub_test_case("the registered callable is given") do 41 | def test_unregister 42 | results = { values: [] } 43 | fn = ->(a) { values << a } 44 | 45 | @man.register(:foo, &fn) 46 | 47 | results[:retval] = @man.unregister(:foo, fn) 48 | 49 | @man.trigger(:foo, 42) 50 | 51 | assert_equal({ 52 | values: [], 53 | retval: fn 54 | }, 55 | results) 56 | end 57 | end 58 | end 59 | 60 | sub_test_case("#trigger") do 61 | sub_test_case("no event is registered") do 62 | def test_trigger 63 | assert_nothing_raised do 64 | @man.trigger(:foo) 65 | end 66 | end 67 | end 68 | 69 | sub_test_case("some events are registered") do 70 | def test_trigger 71 | values = [] 72 | @man.register(:foo) {|a| values << a } 73 | @man.register(:foo) {|a| values << 10*a } 74 | @man.register(:foo) {|a| values << 100+a } 75 | 76 | @man.trigger(:foo, 5) 77 | 78 | assert_equal([5, 50, 105], 79 | values) 80 | end 81 | end 82 | 83 | sub_test_case("unknown event name") do 84 | def test_trigger 85 | assert_raise_message("Unknown event name: baz") do 86 | @man.trigger(:baz, 100) 87 | end 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/iruby/jupyter.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module Jupyter 3 | class << self 4 | # User's default kernelspec directory is described here: 5 | # https://docs.jupyter.org/en/latest/use/jupyter-directories.html 6 | def default_data_dir 7 | data_dir = ENV["JUPYTER_DATA_DIR"] 8 | return data_dir if data_dir 9 | 10 | case 11 | when windows? 12 | appdata = windows_user_appdata 13 | if !appdata.empty? 14 | File.join(appdata, 'jupyter') 15 | else 16 | jupyter_config_dir = ENV.fetch('JUPYTER_CONFIG_DIR', File.expand_path('~/.jupyter')) 17 | File.join(jupyter_config_dir, 'data') 18 | end 19 | when apple? 20 | File.expand_path('~/Library/Jupyter') 21 | else 22 | xdg_data_home = ENV.fetch('XDG_DATA_HOME', '') 23 | data_home = xdg_data_home[0] ? xdg_data_home : File.expand_path('~/.local/share') 24 | File.join(data_home, 'jupyter') 25 | end 26 | end 27 | 28 | def kernelspec_dir(data_dir=nil) 29 | data_dir ||= default_data_dir 30 | File.join(data_dir, 'kernels') 31 | end 32 | 33 | private 34 | 35 | # returns %APPDATA% 36 | def windows_user_appdata 37 | require 'fiddle/import' 38 | check_windows 39 | path = Fiddle::Pointer.malloc(2 * 300) # uint16_t[300] 40 | csidl_appdata = 0x001a 41 | case call_SHGetFolderPathW(Fiddle::NULL, csidl_appdata, Fiddle::NULL, 0, path) 42 | when 0 43 | len = (1 ... (path.size/2)).find {|i| path[2*i, 2] == "\0\0" } 44 | path = path.to_str(2*len).encode(Encoding::UTF_8, Encoding::UTF_16LE) 45 | else 46 | ENV.fetch('APPDATA', '') 47 | end 48 | end 49 | 50 | def call_SHGetFolderPathW(hwnd, csidl, hToken, dwFlags, pszPath) 51 | require 'fiddle/import' 52 | shell32 = Fiddle::Handle.new('shell32') 53 | func = Fiddle::Function.new( 54 | shell32['SHGetFolderPathW'], 55 | [ 56 | Fiddle::TYPE_VOIDP, 57 | Fiddle::TYPE_INT, 58 | Fiddle::TYPE_VOIDP, 59 | Fiddle::TYPE_INT, 60 | Fiddle::TYPE_VOIDP 61 | ], 62 | Fiddle::TYPE_INT, 63 | Fiddle::Importer.const_get(:CALL_TYPE_TO_ABI)[:stdcall]) 64 | func.(hwnd, csidl, hToken, dwFlags, pszPath) 65 | end 66 | 67 | def check_windows 68 | raise 'the current platform is not Windows' unless windows? 69 | end 70 | 71 | def windows? 72 | /mingw|mswin/ =~ RUBY_PLATFORM 73 | end 74 | 75 | def apple? 76 | /darwin/ =~ RUBY_PLATFORM 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /test/iruby/application/unregister_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "helper" 2 | 3 | module IRubyTest::ApplicationTests 4 | class UnregisterTest < ApplicationTestBase 5 | def setup 6 | Dir.mktmpdir do |tmpdir| 7 | @kernel_json = File.join(tmpdir, "kernels", DEFAULT_KERNEL_NAME, "kernel.json") 8 | with_env( 9 | "JUPYTER_DATA_DIR" => tmpdir, 10 | "IPYTHONDIR" => nil 11 | ) do 12 | yield 13 | end 14 | end 15 | end 16 | 17 | sub_test_case("when there is no IRuby kernel in JUPYTER_DATA_DIR") do 18 | test("the command succeeds") do 19 | assert system(*iruby_command("unregister", "-f", DEFAULT_KERNEL_NAME), 20 | out: File::NULL, err: File::NULL) 21 | end 22 | end 23 | 24 | sub_test_case("when the existing IRuby kernel in JUPYTER_DATA_DIR") do 25 | def setup 26 | super do 27 | ensure_iruby_kernel_is_installed 28 | yield 29 | end 30 | end 31 | 32 | test("uninstall the existing kernel") do 33 | assert system(*iruby_command("unregister", "-f", DEFAULT_KERNEL_NAME), 34 | out: File::NULL, err: File::NULL) 35 | assert_path_not_exist @kernel_json 36 | end 37 | end 38 | 39 | sub_test_case("when the existing IRuby kernel in IPython's kernels directory") do 40 | def setup 41 | super do 42 | Dir.mktmpdir do |tmpdir| 43 | ipython_dir = File.join(tmpdir, ".ipython") 44 | 45 | # prepare the existing IRuby kernel with the default name 46 | with_env("JUPYTER_DATA_DIR" => ipython_dir) do 47 | ensure_iruby_kernel_is_installed 48 | end 49 | 50 | fake_bin_dir = File.join(tmpdir, "bin") 51 | fake_jupyter = File.join(fake_bin_dir, "jupyter") 52 | FileUtils.mkdir_p(fake_bin_dir) 53 | IO.write(fake_jupyter, <<-FAKE_JUPYTER) 54 | #!/usr/bin/env ruby 55 | puts "Fake Jupyter" 56 | FAKE_JUPYTER 57 | File.chmod(0o755, fake_jupyter) 58 | 59 | new_path = [fake_bin_dir, ENV["PATH"]].join(File::PATH_SEPARATOR) 60 | with_env( 61 | "HOME" => tmpdir, 62 | "PATH" => new_path, 63 | "IPYTHONDIR" => nil 64 | ) do 65 | yield 66 | end 67 | end 68 | end 69 | end 70 | 71 | test("the kernel in IPython's kernels directory is not removed") do 72 | assert system(*iruby_command("unregister", "-f"), out: File::NULL, err: File::NULL) 73 | assert_path_exist File.join(File.expand_path("~/.ipython"), "kernels", DEFAULT_KERNEL_NAME, "kernel.json") 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/iruby/session/ffi_rzmq.rb: -------------------------------------------------------------------------------- 1 | require 'ffi-rzmq' 2 | 3 | module IRuby 4 | class Session 5 | include SessionSerialize 6 | 7 | def initialize(config) 8 | c = ZMQ::Context.new 9 | 10 | connection = "#{config['transport']}://#{config['ip']}:%d" 11 | reply_socket = c.socket(ZMQ::XREP) 12 | reply_socket.bind(connection % config['shell_port']) 13 | 14 | pub_socket = c.socket(ZMQ::PUB) 15 | pub_socket.bind(connection % config['iopub_port']) 16 | 17 | stdin_socket = c.socket(ZMQ::XREP) 18 | stdin_socket.bind(connection % config['stdin_port']) 19 | 20 | Thread.new do 21 | begin 22 | hb_socket = c.socket(ZMQ::REP) 23 | hb_socket.bind(connection % config['hb_port']) 24 | ZMQ::Device.new(hb_socket, hb_socket) 25 | rescue Exception => e 26 | IRuby.logger.fatal "Kernel heartbeat died: #{e.message}\n#{e.backtrace.join("\n")}" 27 | end 28 | end 29 | 30 | @sockets = { 31 | publish: pub_socket, reply: reply_socket, stdin: stdin_socket 32 | } 33 | 34 | @session = SecureRandom.uuid 35 | unless config['key'].to_s.empty? || config['signature_scheme'].to_s.empty? 36 | raise 'Unknown signature scheme' unless config['signature_scheme'] =~ /\Ahmac-(.*)\Z/ 37 | @hmac = OpenSSL::HMAC.new(config['key'], OpenSSL::Digest.new($1)) 38 | end 39 | end 40 | 41 | # Build and send a message 42 | def send(socket, type, content) 43 | idents = 44 | if socket == :reply && @last_recvd_msg 45 | @last_recvd_msg[:idents] 46 | else 47 | type == :stream ? "stream.#{content[:name]}" : type 48 | end 49 | header = { 50 | msg_type: type, 51 | msg_id: SecureRandom.uuid, 52 | username: 'kernel', 53 | session: @session, 54 | version: '5.0' 55 | } 56 | socket = @sockets[socket] 57 | list = serialize(idents, header, content) 58 | list.each_with_index do |part, i| 59 | socket.send_string(part, i == list.size - 1 ? 0 : ZMQ::SNDMORE) 60 | end 61 | end 62 | 63 | # Receive a message and decode it 64 | def recv(socket) 65 | socket = @sockets[socket] 66 | msg = [] 67 | while msg.empty? || socket.more_parts? 68 | begin 69 | frame = '' 70 | rc = socket.recv_string(frame) 71 | ZMQ::Util.error_check('zmq_msg_send', rc) 72 | msg << frame 73 | rescue 74 | end 75 | end 76 | 77 | @last_recvd_msg = unserialize(msg) 78 | end 79 | 80 | def recv_input 81 | last_recvd_msg = @last_recvd_msg 82 | input = recv(:stdin)[:content]["value"] 83 | @last_recvd_msg = last_recvd_msg 84 | input 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/iruby/kernel_app.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | class KernelApplication 3 | def initialize(argv) 4 | parse_command_line(argv) 5 | end 6 | 7 | def run 8 | if @test_mode 9 | dump_connection_file 10 | return 11 | end 12 | 13 | run_kernel 14 | end 15 | 16 | DEFAULT_CONNECTION_FILE = "kernel-#{Process.pid}.json".freeze 17 | 18 | private def parse_command_line(argv) 19 | opts = OptionParser.new 20 | opts.banner = "Usage: #{$PROGRAM_NAME} [options] [subcommand] [options]" 21 | 22 | @connection_file = nil 23 | opts.on( 24 | "-f CONNECTION_FILE", String, 25 | "JSON file in which to store connection info (default: kernel-.json)" 26 | ) {|v| @connection_file = v } 27 | 28 | @test_mode = false 29 | opts.on( 30 | "--test", 31 | "Run as test mode; dump the connection file and exit." 32 | ) { @test_mode = true } 33 | 34 | @log_file = nil 35 | opts.on( 36 | "--log=FILE", String, 37 | "Specify the log file." 38 | ) {|v| @log_file = v } 39 | 40 | @log_level = Logger::INFO 41 | opts.on( 42 | "--debug", 43 | "Set log-level debug" 44 | ) { @log_level = Logger::DEBUG } 45 | 46 | opts.order!(argv) 47 | 48 | if @connection_file.nil? 49 | # Without -f option, the connection file is at the beginning of the rest arguments 50 | if argv.length <= 3 51 | @connection_file, @boot_file, @work_dir = argv 52 | else 53 | raise ArgumentError, "Too many commandline arguments" 54 | end 55 | else 56 | if argv.length <= 2 57 | @boot_file, @work_dir = argv 58 | else 59 | raise ArgumentError, "Too many commandline arguments" 60 | end 61 | end 62 | 63 | @connection_file ||= DEFAULT_CONNECTION_FILE 64 | end 65 | 66 | private def dump_connection_file 67 | puts File.read(@connection_file) 68 | end 69 | 70 | private def run_kernel 71 | IRuby.logger = MultiLogger.new(*Logger.new(STDOUT)) 72 | STDOUT.sync = true # FIXME: This can make the integration test. 73 | 74 | IRuby.logger.loggers << Logger.new(@log_file) unless @log_file.nil? 75 | IRuby.logger.level = @log_level 76 | 77 | if @work_dir 78 | IRuby.logger.debug("iruby kernel") { "Change the working directory: #{@work_dir}" } 79 | Dir.chdir(@work_dir) 80 | end 81 | 82 | if @boot_file 83 | IRuby.logger.debug("iruby kernel") { "Load the boot file: #{@boot_file}" } 84 | require @boot_file 85 | end 86 | 87 | check_bundler {|e| IRuby.logger.warn "Could not load bundler: #{e.message}" } 88 | 89 | require "iruby" 90 | Kernel.new(@connection_file).run 91 | rescue Exception => e 92 | IRuby.logger.fatal "Kernel died: #{e.message}\n#{e.backtrace.join("\n")}" 93 | exit 1 94 | end 95 | 96 | private def check_bundler 97 | require "bundler" 98 | unless Bundler.definition.specs.any? {|s| s.name == "iruby" } 99 | raise %{IRuby is missing from Gemfile. This might not work. Add `gem "iruby"` in your Gemfile to fix it.} 100 | end 101 | Bundler.setup 102 | rescue LoadError 103 | # do nothing 104 | rescue Exception => e 105 | yield e 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /test/iruby/application/kernel_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "helper" 2 | 3 | module IRubyTest::ApplicationTests 4 | class KernelTest < ApplicationTestBase 5 | def setup 6 | Dir.mktmpdir do |tmpdir| 7 | @fake_bin_dir = File.join(tmpdir, "bin") 8 | FileUtils.mkdir_p(@fake_bin_dir) 9 | 10 | @fake_data_dir = File.join(tmpdir, "data") 11 | FileUtils.mkdir_p(@fake_data_dir) 12 | 13 | new_path = [@fake_bin_dir, ENV["PATH"]].join(File::PATH_SEPARATOR) 14 | with_env("PATH" => new_path, 15 | "JUPYTER_DATA_DIR" => @fake_data_dir) do 16 | ensure_iruby_kernel_is_installed 17 | yield 18 | end 19 | end 20 | end 21 | 22 | test("--test option dumps the given connection file") do 23 | connection_info = { 24 | "control_port" => 123456, 25 | "shell_port" => 123457, 26 | "transport" => "tcp", 27 | "signature_scheme" => "hmac-sha256", 28 | "stdin_port" => 123458, 29 | "hb_port" => 123459, 30 | "ip" => "127.0.0.1", 31 | "iopub_port" => 123460, 32 | "key" => "a0436f6c-1916-498b-8eb9-e81ab9368e84" 33 | } 34 | Dir.mktmpdir do |tmpdir| 35 | connection_file = File.join(tmpdir, "connection.json") 36 | File.write(connection_file, JSON.dump(connection_info)) 37 | out, status = Open3.capture2e(*iruby_command("kernel", "-f", connection_file, "--test")) 38 | assert status.success? 39 | assert_equal connection_info, JSON.load(out) 40 | end 41 | end 42 | 43 | test("the default log level is INFO") do 44 | Dir.mktmpdir do |tmpdir| 45 | boot_file = File.join(tmpdir, "boot.rb") 46 | File.write(boot_file, <<~BOOT_SCRIPT) 47 | puts "!!! INFO: \#{Logger::INFO}" 48 | puts "!!! LOG LEVEL: \#{IRuby.logger.level}" 49 | puts "!!! LOG LEVEL IS INFO: \#{IRuby.logger.level == Logger::INFO}" 50 | BOOT_SCRIPT 51 | 52 | add_kernel_options(boot_file) 53 | 54 | out, status = Open3.capture2e(*iruby_command("console"), in: :close) 55 | assert status.success? 56 | assert_match(/^!!! LOG LEVEL IS INFO: true$/, out) 57 | end 58 | end 59 | 60 | test("--debug option makes the log level DEBUG") do 61 | Dir.mktmpdir do |tmpdir| 62 | boot_file = File.join(tmpdir, "boot.rb") 63 | File.write(boot_file, <<~BOOT_SCRIPT) 64 | puts "!!! LOG LEVEL IS DEBUG: \#{IRuby.logger.level == Logger::DEBUG}" 65 | BOOT_SCRIPT 66 | 67 | add_kernel_options("--debug", boot_file) 68 | 69 | out, status = Open3.capture2e(*iruby_command("console"), in: :close) 70 | assert status.success? 71 | assert_match(/^!!! LOG LEVEL IS DEBUG: true$/, out) 72 | end 73 | end 74 | 75 | test("--log option adds a log destination file") do 76 | Dir.mktmpdir do |tmpdir| 77 | boot_file = File.join(tmpdir, "boot.rb") 78 | File.write(boot_file, <<~BOOT_SCRIPT) 79 | IRuby.logger.info("bootfile") { "!!! LOG MESSAGE FROM BOOT FILE !!!" } 80 | BOOT_SCRIPT 81 | 82 | log_file = File.join(tmpdir, "log.txt") 83 | 84 | add_kernel_options("--log=#{log_file}", boot_file) 85 | 86 | _out, status = Open3.capture2e(*iruby_command("console"), in: :close) 87 | assert status.success? 88 | assert_path_exist log_file 89 | assert_match(/\bINFO -- bootfile: !!! LOG MESSAGE FROM BOOT FILE !!!$/, File.read(log_file)) 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/iruby/application/console_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "helper" 2 | 3 | module IRubyTest::ApplicationTests 4 | class ConsoleTest < ApplicationTestBase 5 | def setup 6 | Dir.mktmpdir do |tmpdir| 7 | @fake_bin_dir = File.join(tmpdir, "bin") 8 | FileUtils.mkdir_p(@fake_bin_dir) 9 | 10 | @fake_data_dir = File.join(tmpdir, "data") 11 | FileUtils.mkdir_p(@fake_data_dir) 12 | 13 | new_path = [@fake_bin_dir, ENV["PATH"]].join(File::PATH_SEPARATOR) 14 | with_env("PATH" => new_path, 15 | "JUPYTER_DATA_DIR" => @fake_data_dir) do 16 | yield 17 | end 18 | end 19 | end 20 | 21 | sub_test_case("there is the default IRuby kernel in JUPYTER_DATA_DIR") do 22 | def setup 23 | super do 24 | ensure_iruby_kernel_is_installed 25 | yield 26 | end 27 | end 28 | 29 | test("run `jupyter console` with the default IRuby kernel") do 30 | out, status = Open3.capture2e(*iruby_command("console"), in: :close) 31 | assert status.success? 32 | assert_match(/^Jupyter console [\d\.]+$/, out) 33 | assert_match(/^#{Regexp.escape("IRuby #{IRuby::VERSION}")}\b/, out) 34 | end 35 | end 36 | 37 | # NOTE: this case checks the priority of the default IRuby kernel when both kernels are available 38 | sub_test_case("there are both the default IRuby kernel and IRuby kernel named `ruby` in JUPYTER_DATA_DIR") do 39 | def setup 40 | super do 41 | ensure_iruby_kernel_is_installed 42 | ensure_iruby_kernel_is_installed("ruby") 43 | yield 44 | end 45 | end 46 | 47 | test("run `jupyter console` with the default IRuby kernel") do 48 | out, status = Open3.capture2e(*iruby_command("console"), in: :close) 49 | assert status.success? 50 | assert_match(/^Jupyter console [\d\.]+$/, out) 51 | assert_match(/^#{Regexp.escape("IRuby #{IRuby::VERSION}")}\b/, out) 52 | end 53 | end 54 | 55 | # NOTE: this case checks the availability of the old kernel name 56 | sub_test_case("there is the IRuby kernel, which is named `ruby`, in JUPYTER_DATA_DIR") do 57 | def setup 58 | super do 59 | ensure_iruby_kernel_is_installed("ruby") 60 | yield 61 | end 62 | end 63 | 64 | test("run `jupyter console` with the IRuby kernel `ruby`") do 65 | out, status = Open3.capture2e(*iruby_command("console"), in: :close) 66 | assert status.success? 67 | assert_match(/^Jupyter console [\d\.]+$/, out) 68 | assert_match(/^#{Regexp.escape("IRuby #{IRuby::VERSION}")}\b/, out) 69 | end 70 | end 71 | 72 | sub_test_case("with --kernel option") do 73 | test("run `jupyter console` command with the given kernel name") do 74 | kernel_name = "other-kernel-#{Process.pid}" 75 | out, status = Open3.capture2e(*iruby_command("console", "--kernel=#{kernel_name}")) 76 | refute status.success? 77 | assert_match(/\bNo such kernel named #{Regexp.escape(kernel_name)}\b/, out) 78 | end 79 | end 80 | 81 | sub_test_case("no subcommand") do 82 | def setup 83 | super do 84 | ensure_iruby_kernel_is_installed 85 | yield 86 | end 87 | end 88 | 89 | test("Run jupyter console command with the default IRuby kernel") do 90 | out, status = Open3.capture2e(*iruby_command, in: :close) 91 | assert status.success? 92 | assert_match(/^Jupyter console [\d\.]+$/, out) 93 | assert_match(/^#{Regexp.escape("IRuby #{IRuby::VERSION}")}\b/, out) 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/iruby/session.rb: -------------------------------------------------------------------------------- 1 | require 'iruby/session_adapter' 2 | require 'iruby/session/mixin' 3 | 4 | require 'securerandom' 5 | require 'time' 6 | 7 | module IRuby 8 | class Session 9 | include SessionSerialize 10 | 11 | def initialize(config, adapter_name=nil) 12 | @config = config 13 | @adapter = create_session_adapter(config, adapter_name) 14 | @last_recvd_msg = nil 15 | 16 | setup 17 | setup_sockets 18 | setup_heartbeat 19 | setup_security 20 | end 21 | 22 | attr_reader :adapter, :config 23 | 24 | def description 25 | "#{@adapter.name} session adapter" 26 | end 27 | 28 | def setup 29 | end 30 | 31 | def setup_sockets 32 | protocol, host = config.values_at('transport', 'ip') 33 | shell_port = config['shell_port'] 34 | iopub_port = config['iopub_port'] 35 | stdin_port = config['stdin_port'] 36 | 37 | @shell_socket, @shell_port = @adapter.make_router_socket(protocol, host, shell_port) 38 | @iopub_socket, @iopub_port = @adapter.make_pub_socket(protocol, host, iopub_port) 39 | @stdin_socket, @stdin_port = @adapter.make_router_socket(protocol, host, stdin_port) 40 | 41 | @sockets = { 42 | publish: @iopub_socket, 43 | reply: @shell_socket, 44 | stdin: @stdin_socket 45 | } 46 | end 47 | 48 | def setup_heartbeat 49 | protocol, host = config.values_at('transport', 'ip') 50 | hb_port = config['hb_port'] 51 | @hb_socket, @hb_port = @adapter.make_rep_socket(protocol, host, hb_port) 52 | @heartbeat_thread = Thread.start do 53 | begin 54 | # NOTE: this loop is copied from CZTop's old session code 55 | @adapter.heartbeat_loop(@hb_socket) 56 | rescue Exception => e 57 | IRuby.logger.fatal "Kernel heartbeat died: #{e.message}\n#{e.backtrace.join("\n")}" 58 | end 59 | end 60 | end 61 | 62 | def setup_security 63 | @session_id = SecureRandom.uuid 64 | unless config['key'].empty? || config['signature_scheme'].empty? 65 | unless config['signature_scheme'] =~ /\Ahmac-/ 66 | raise "Unknown signature_scheme: #{config['signature_scheme']}" 67 | end 68 | digest_algorithm = config['signature_scheme'][/\Ahmac-(.*)\Z/, 1] 69 | @hmac = OpenSSL::HMAC.new(config['key'], OpenSSL::Digest.new(digest_algorithm)) 70 | end 71 | end 72 | 73 | def send(socket_type, message_type, metadata = nil, content) 74 | sock = check_socket_type(socket_type) 75 | idents = if socket_type == :reply && @last_recvd_msg 76 | @last_recvd_msg[:idents] 77 | else 78 | message_type == :stream ? "stream.#{content[:name]}" : message_type 79 | end 80 | header = { 81 | msg_type: message_type, 82 | msg_id: SecureRandom.uuid, 83 | date: Time.now.utc.iso8601, 84 | username: 'kernel', 85 | session: @session_id, 86 | version: '5.0' 87 | } 88 | @adapter.send(sock, serialize(idents, header, metadata, content)) 89 | end 90 | 91 | def recv(socket_type) 92 | sock = check_socket_type(socket_type) 93 | data = @adapter.recv(sock) 94 | @last_recvd_msg = unserialize(data) 95 | end 96 | 97 | def recv_input 98 | sock = check_socket_type(:stdin) 99 | data = @adapter.recv(sock) 100 | unserialize(data)[:content]["value"] 101 | end 102 | 103 | private 104 | 105 | def check_socket_type(socket_type) 106 | case socket_type 107 | when :publish, :reply, :stdin 108 | @sockets[socket_type] 109 | else 110 | raise ArgumentError, "Invalid socket type #{socket_type}" 111 | end 112 | end 113 | 114 | def create_session_adapter(config, adapter_name) 115 | adapter_class = SessionAdapter.select_adapter_class(adapter_name) 116 | adapter_class.new(config) 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | ubuntu: 10 | name: Ubuntu 11 | runs-on: ${{ matrix.os }} 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: 17 | - ubuntu-latest 18 | ruby: 19 | - "3.4" 20 | - "3.3" 21 | - "3.2" 22 | - "3.1" 23 | - "3.0" 24 | - "2.7" 25 | - "2.6" 26 | - "2.5" 27 | - debug 28 | 29 | steps: 30 | - uses: actions/checkout@v6 31 | with: 32 | fetch-depth: 1 33 | 34 | - uses: ruby/setup-ruby@v1 35 | with: 36 | ruby-version: ${{ matrix.ruby }} 37 | 38 | - run: rake build 39 | 40 | - name: Install ffi (if Ruby 2.7) 41 | if: | 42 | matrix.ruby == '2.7' 43 | run: | 44 | cat < Gemfile.irb 45 | source 'https://rubygems.org' 46 | gem 'ffi' 47 | GEMFILE 48 | BUNDLE_GEMFILE=Gemfile.irb bundle install --jobs 4 --retry 3 49 | 50 | - name: Install ffi 1.6.x (if Ruby 2.6) 51 | if: | 52 | matrix.ruby == '2.6' 53 | run: | 54 | cat < Gemfile.irb 55 | source 'https://rubygems.org' 56 | gem 'ffi', '~> 1.6.0' 57 | GEMFILE 58 | BUNDLE_GEMFILE=Gemfile.irb bundle install --jobs 4 --retry 3 59 | 60 | - name: Install ffi 1.6.x and irb < 1.4.3 (if Ruby 2.5) 61 | if: | 62 | matrix.ruby == '2.5' 63 | run: | 64 | cat < Gemfile.irb 65 | source 'https://rubygems.org' 66 | gem 'ffi', '~> 1.6.0' 67 | gem 'irb', '< 1.4.3' 68 | GEMFILE 69 | BUNDLE_GEMFILE=Gemfile.irb bundle install --jobs 4 --retry 3 70 | 71 | - name: Install IRuby gem 72 | run: | 73 | gem install rubygems-requirements-system 74 | gem install pkg/*.gem 75 | 76 | - run: ruby -r iruby -e "p IRuby::SessionAdapter.select_adapter_class" 77 | env: 78 | IRUBY_SESSION_ADAPTER: ffi-rzmq 79 | 80 | - name: Install requirements on ubuntu 81 | run: | 82 | sudo apt update 83 | sudo apt install -y --no-install-recommends \ 84 | libczmq-dev \ 85 | python3 \ 86 | python3-pip \ 87 | python3-setuptools 88 | sudo pip3 install wheel 89 | sudo pip3 install -r ci/requirements.txt 90 | 91 | - run: bundle install --jobs 4 --retry 3 92 | 93 | - name: Run tests 94 | env: 95 | PYTHON: python3 96 | ADAPTERS: cztop ffi-rzmq 97 | run: | 98 | for adapter in $ADAPTERS; do 99 | export IRUBY_TEST_SESSION_ADAPTER_NAME=$adapter 100 | bundle exec rake test TESTOPTS="-v" 101 | done 102 | 103 | windows: 104 | name: Windows 105 | runs-on: windows-latest 106 | 107 | steps: 108 | - uses: actions/checkout@v6 109 | with: 110 | fetch-depth: 1 111 | 112 | - uses: ruby/setup-ruby@v1 113 | with: 114 | ruby-version: "ruby" 115 | 116 | - run: rake build 117 | 118 | - run: gem install pkg/*.gem 119 | 120 | - run: ruby -r iruby -e "p IRuby::SessionAdapter.select_adapter_class" 121 | env: 122 | IRUBY_SESSION_ADAPTER: ffi-rzmq 123 | 124 | macos: 125 | name: macOS 126 | runs-on: macos-latest 127 | 128 | steps: 129 | - uses: actions/checkout@v6 130 | with: 131 | fetch-depth: 1 132 | 133 | - uses: ruby/setup-ruby@v1 134 | with: 135 | ruby-version: "ruby" 136 | 137 | - run: rake build 138 | 139 | - run: gem install rubygems-requirements-system pkg/*.gem 140 | 141 | - run: ruby -r iruby -e "p IRuby::SessionAdapter.select_adapter_class" 142 | env: 143 | IRUBY_SESSION_ADAPTER: ffi-rzmq 144 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require "iruby" 2 | require "json" 3 | require "pathname" 4 | require "rbconfig" 5 | require "test/unit" 6 | require "test/unit/rr" 7 | require "tmpdir" 8 | 9 | IRuby.logger = IRuby::MultiLogger.new(*Logger.new(STDERR, level: Logger::Severity::INFO)) 10 | 11 | module IRubyTest 12 | class TestBase < Test::Unit::TestCase 13 | TEST_DIR = File.expand_path("..", __FILE__).freeze 14 | EXE_DIR = File.expand_path("../exe", TEST_DIR).freeze 15 | LIB_DIR = File.expand_path("../lib", TEST_DIR).freeze 16 | 17 | RUBY = RbConfig.ruby.freeze 18 | IRUBY_PATH = File.join(EXE_DIR, "iruby").freeze 19 | 20 | def iruby_command(*args) 21 | [RUBY, "-I#{LIB_DIR}", IRUBY_PATH, *args] 22 | end 23 | 24 | def self.startup 25 | @__work_dir = Dir.mktmpdir("iruby-test-data") 26 | 27 | @__jupyter_data_dir = File.join(@__work_dir, "jupyter") 28 | @__save_jupyter_data_dir = ENV["JUPYTER_DATA_DIR"] 29 | ENV["JUPYTER_DATA_DIR"] = @__jupyter_data_dir 30 | 31 | @__config_dir = File.join(@__work_dir, "config") 32 | @__config_path = Pathname.new(@__config_dir) + "config.json" 33 | @__config_path.dirname.mkpath 34 | File.write(@__config_path, { 35 | control_port: 50160, 36 | shell_port: 57503, 37 | transport: "tcp", 38 | signature_scheme: "hmac-sha256", 39 | stdin_port: 52597, 40 | hb_port: 42540, 41 | ip: "127.0.0.1", 42 | iopub_port: 40885, 43 | key: "a0436f6c-1916-498b-8eb9-e81ab9368e84" 44 | }.to_json) 45 | 46 | @__original_kernel_instance = IRuby::Kernel.instance 47 | end 48 | 49 | def self.shutdown 50 | FileUtils.remove_entry_secure(@__work_dir) 51 | ENV["JUPYTER_DATA_DIR"] = @__save_jupyter_data_dir 52 | end 53 | 54 | def self.test_config_filename 55 | @__config_path.to_s 56 | end 57 | 58 | def self.restore_kernel 59 | IRuby::Kernel.instance = @__original_kernel_instance 60 | end 61 | 62 | def teardown 63 | self.class.restore_kernel 64 | end 65 | 66 | def with_session_adapter(session_adapter_name) 67 | IRuby::Kernel.new(self.class.test_config_filename, session_adapter_name) 68 | $stdout = STDOUT 69 | $stderr = STDERR 70 | end 71 | 72 | def assert_output(stdout=nil, stderr=nil) 73 | flunk "assert_output requires a block to capture output." unless block_given? 74 | 75 | out, err = capture_io do 76 | yield 77 | end 78 | 79 | y = check_assert_output_result(stderr, err, "stderr") 80 | x = check_assert_output_result(stdout, out, "stdout") 81 | 82 | (!stdout || x) && (!stderr || y) 83 | end 84 | 85 | private 86 | 87 | def capture_io 88 | captured_stdout = StringIO.new 89 | captured_stderr = StringIO.new 90 | 91 | orig_stdout, $stdout = $stdout, captured_stdout 92 | orig_stderr, $stderr = $stderr, captured_stderr 93 | 94 | yield 95 | 96 | return captured_stdout.string, captured_stderr.string 97 | ensure 98 | $stdout = orig_stdout 99 | $stderr = orig_stderr 100 | end 101 | 102 | def check_assert_output_result(expected, actual, name) 103 | if expected 104 | message = "In #{name}" 105 | case expected 106 | when Regexp 107 | assert_match(expected, actual, message) 108 | else 109 | assert_equal(expected, actual, message) 110 | end 111 | end 112 | end 113 | 114 | def ignore_warning 115 | saved, $VERBOSE = $VERBOSE , nil 116 | yield 117 | ensure 118 | $VERBOSE = saved 119 | end 120 | 121 | def with_env(env) 122 | keys = env.keys 123 | saved_values = ENV.values_at(*keys) 124 | ENV.update(env) 125 | yield 126 | ensure 127 | if keys && saved_values 128 | keys.zip(saved_values) do |k, v| 129 | ENV[k] = v 130 | end 131 | end 132 | end 133 | 134 | def windows_only 135 | omit('windows only test') unless windows? 136 | end 137 | 138 | def apple_only 139 | omit('apple only test') unless windows? 140 | end 141 | 142 | def unix_only 143 | omit('unix only test') if windows? || apple? 144 | end 145 | 146 | def windows? 147 | /mingw|mswin/ =~ RUBY_PLATFORM 148 | end 149 | 150 | def apple? 151 | /darwin/ =~ RUBY_PLATFORM 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/iruby/formatter.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | module LaTeX 3 | extend self 4 | 5 | def vector(v) 6 | x = 'c' * v.size 7 | y = v.map(&:to_s).join(' & ') 8 | "$$\\left(\\begin{array}{#{x}} #{y} \\end{array}\\right)$$" 9 | end 10 | 11 | def matrix(m, row_count, column_count) 12 | s = "$$\\left(\\begin{array}{#{'c' * column_count}}\n" 13 | (0...row_count).each do |i| 14 | s << ' ' << m[i,0].to_s 15 | (1...column_count).each do |j| 16 | s << '&' << m[i,j].to_s 17 | end 18 | s << "\\\\\n" 19 | end 20 | s << "\\end{array}\\right)$$" 21 | end 22 | end 23 | 24 | module HTML 25 | extend self 26 | 27 | def table(obj, maxrows: 15, maxcols: 15, **options) 28 | raise ArgumentError, 'Invalid :maxrows' if maxrows && maxrows < 3 29 | raise ArgumentError, 'Invalid :maxcols' if maxcols && maxcols < 3 30 | 31 | return obj unless obj.respond_to?(:each) 32 | 33 | rows = [] 34 | 35 | if obj.respond_to?(:keys) 36 | # Hash of Arrays 37 | header = obj.keys 38 | keys = (0...obj.keys.size).to_a 39 | cols = obj.values.map {|x| [x].flatten(1) } 40 | num_rows = cols.map(&:size).max 41 | rows = [] 42 | (0...num_rows).each do |i| 43 | rows << [] 44 | (0...cols.size).each do |j| 45 | rows[i][j] = cols[j][i] 46 | end 47 | end 48 | else 49 | keys = nil 50 | array_size = 0 51 | 52 | obj.each do |row| 53 | if row.respond_to?(:keys) 54 | # Array of Hashes 55 | keys ||= Set.new 56 | keys.merge(row.keys) 57 | elsif row.respond_to?(:map) 58 | # Array of Arrays 59 | array_size = row.size if array_size < row.size 60 | end 61 | rows << row 62 | end 63 | 64 | if keys 65 | header = keys.to_a 66 | keys.merge(0...array_size) 67 | else 68 | keys = 0...array_size 69 | end 70 | keys = keys.to_a 71 | end 72 | 73 | header ||= keys if options[:header] 74 | 75 | rows1, rows2 = rows, nil 76 | keys1, keys2 = keys, nil 77 | header1, header2 = header, nil 78 | 79 | if maxcols && keys.size > maxcols 80 | keys1 = keys[0...maxcols / 2] 81 | keys2 = keys[-maxcols / 2 + 1..-1] 82 | if header 83 | header1 = header[0...maxcols / 2] 84 | header2 = header[-maxcols / 2 + 1..-1] 85 | end 86 | end 87 | 88 | if maxrows && rows.size > maxrows 89 | rows1 = rows[0...maxrows / 2] 90 | rows2 = rows[-maxrows / 2 + 1..-1] 91 | end 92 | 93 | table = +'' 94 | 95 | if header1 && options[:header] != false 96 | table << '' << header1.map {|k| "" }.join 97 | table << "" << header2.map {|k| "" }.join if keys2 98 | table << '' 99 | end 100 | 101 | row_block(table, rows1, keys1, keys2) 102 | 103 | if rows2 104 | table << " 1 ? " colspan='#{keys1.size}'" : ''}>⋮" 105 | table << " 1 ? " colspan='#{keys2.size}'" : ''}>⋮" if keys2 106 | table << '' 107 | 108 | row_block(table, rows2, keys1, keys2) 109 | end 110 | 111 | table << '
#{cell k}#{cell k}
' 112 | end 113 | 114 | private 115 | 116 | def cell(obj) 117 | obj.respond_to?(:to_html) ? obj.to_html : obj 118 | end 119 | 120 | def elem(row, k) 121 | cell((row[k] rescue nil)) 122 | end 123 | 124 | def row_block(table, rows, keys1, keys2) 125 | cols = keys1.size 126 | cols += keys2.size + 1 if keys2 127 | rows.each_with_index do |row, i| 128 | table << '' 129 | if row.respond_to?(:map) 130 | row_html = keys1.map {|k| "#{elem row, k}" }.join 131 | if keys2 132 | row_html << " 1 ? " rowspan='#{rows.size}'" : ''}>…" if i == 0 133 | row_html << keys2.map {|k| "#{elem row, k}" }.join 134 | end 135 | if row_html.empty? 136 | table << " 1 ? " colspan='#{cols}'" : ''}>" 137 | else 138 | table << row_html 139 | end 140 | else 141 | table << " 1 ? " colspan='#{cols}'" : ''}>#{cell row}" 142 | end 143 | table << '' 144 | end 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/iruby/backend.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | In, Out = [nil], [nil] 3 | 4 | class << self 5 | attr_accessor :silent_assignment 6 | end 7 | self.silent_assignment = false 8 | 9 | module History 10 | def eval(code, store_history) 11 | b = eval_binding 12 | 13 | b.local_variable_set(:_ih, In) unless b.local_variable_defined?(:_ih) 14 | b.local_variable_set(:_oh, Out) unless b.local_variable_defined?(:_oh) 15 | 16 | out = super 17 | 18 | # TODO Add IRuby.cache_size which controls the size of the Out array 19 | # and sets the oldest entries and _ variables to nil. 20 | if store_history 21 | b.local_variable_set("_#{Out.size}", out) 22 | b.local_variable_set("_i#{In.size}", code) 23 | 24 | Out << out 25 | In << code 26 | 27 | b.local_variable_set(:___, Out[-3]) 28 | b.local_variable_set(:__, Out[-2]) 29 | b.local_variable_set(:_, Out[-1]) 30 | b.local_variable_set(:_iii, In[-3]) 31 | b.local_variable_set(:_ii, In[-2]) 32 | b.local_variable_set(:_i, In[-1]) 33 | end 34 | 35 | out 36 | end 37 | end 38 | 39 | class PlainBackend 40 | attr_reader :eval_path 41 | prepend History 42 | 43 | def initialize 44 | require 'irb' 45 | require 'irb/completion' 46 | IRB.setup(nil) 47 | @main = TOPLEVEL_BINDING.eval("self").dup 48 | init_main_object(@main) 49 | @workspace = IRB::WorkSpace.new(@main) 50 | @irb = IRB::Irb.new(@workspace) 51 | @eval_path = @irb.context.irb_path 52 | IRB.conf[:MAIN_CONTEXT] = @irb.context 53 | @completor = IRB::RegexpCompletor.new if defined? IRB::RegexpCompletor # IRB::VERSION >= 1.8.2 54 | end 55 | 56 | def eval_binding 57 | @workspace.binding 58 | end 59 | 60 | def eval(code, store_history) 61 | @irb.context.evaluate(parse_code(code), 0) 62 | @irb.context.last_value unless IRuby.silent_assignment && assignment_expression?(code) 63 | end 64 | 65 | def parse_code(code) 66 | return code if Gem::Version.new(IRB::VERSION) < Gem::Version.new('1.13.0') 67 | return @irb.parse_input(code) if @irb.respond_to?(:parse_input) 68 | return @irb.build_statement(code) if @irb.respond_to?(:build_statement) 69 | end 70 | 71 | def complete(code) 72 | if @completor 73 | # preposing and postposing never used, so they are empty, pass only target as code 74 | @completor.completion_candidates('', code, '', bind: @workspace.binding) 75 | else 76 | IRB::InputCompletor::CompletionProc.call(code) 77 | end 78 | end 79 | 80 | private 81 | 82 | def init_main_object(main) 83 | wrapper_module = Module.new 84 | main.extend(wrapper_module) 85 | main.define_singleton_method(:include) do |*args| 86 | wrapper_module.include(*args) 87 | end 88 | end 89 | 90 | def assignment_expression?(code) 91 | @irb.respond_to?(:assignment_expression?) && @irb.assignment_expression?(code) 92 | end 93 | end 94 | 95 | class PryBackend 96 | attr_reader :eval_path 97 | prepend History 98 | 99 | def initialize 100 | require 'pry' 101 | Pry.memory_size = 3 102 | Pry.pager = false # Don't use the pager 103 | Pry.print = proc {|output, value|} # No result printing 104 | Pry.exception_handler = proc {|output, exception, _| } 105 | @eval_path = Pry.eval_path 106 | reset 107 | end 108 | 109 | def eval_binding 110 | TOPLEVEL_BINDING 111 | end 112 | 113 | def eval(code, store_history) 114 | Pry.current_line = 1 115 | @pry.last_result = nil 116 | unless @pry.eval(code) 117 | reset 118 | raise SystemExit 119 | end 120 | 121 | # Pry::Code.complete_expression? return false 122 | if !@pry.eval_string.empty? 123 | syntax_error = @pry.eval_string 124 | @pry.reset_eval_string 125 | @pry.evaluate_ruby(syntax_error) 126 | 127 | # Pry::Code.complete_expression? raise SyntaxError 128 | # evaluate again for current line number 129 | elsif @pry.last_result_is_exception? && 130 | @pry.last_exception.is_a?(SyntaxError) && 131 | @pry.last_exception.is_a?(Pry::UserError) 132 | @pry.evaluate_ruby(code) 133 | end 134 | 135 | raise @pry.last_exception if @pry.last_result_is_exception? 136 | @pry.push_initial_binding unless @pry.current_binding # ensure that we have a binding 137 | @pry.last_result 138 | end 139 | 140 | def complete(code) 141 | @pry.complete(code) 142 | end 143 | 144 | def reset 145 | @pry = Pry.new(output: $stdout, target: eval_binding) 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /test/iruby/application/register_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "helper" 2 | 3 | module IRubyTest::ApplicationTests 4 | class RegisterTest < ApplicationTestBase 5 | sub_test_case("when the existing IRuby kernel is in IPython's kernels directory") do 6 | def setup 7 | Dir.mktmpdir do |tmpdir| 8 | ipython_dir = File.join(tmpdir, ".ipython") 9 | # prepare the existing IRuby kernel with the default name 10 | with_env("JUPYTER_DATA_DIR" => ipython_dir) do 11 | ensure_iruby_kernel_is_installed 12 | end 13 | 14 | fake_bin_dir = File.join(tmpdir, "bin") 15 | fake_jupyter = File.join(fake_bin_dir, "jupyter") 16 | FileUtils.mkdir_p(fake_bin_dir) 17 | IO.write(fake_jupyter, <<-FAKE_JUPYTER) 18 | #!/usr/bin/env ruby 19 | puts "Fake Jupyter" 20 | FAKE_JUPYTER 21 | File.chmod(0o755, fake_jupyter) 22 | 23 | new_path = [fake_bin_dir, ENV["PATH"]].join(File::PATH_SEPARATOR) 24 | with_env( 25 | "HOME" => tmpdir, 26 | "PATH" => new_path, 27 | "JUPYTER_DATA_DIR" => nil, 28 | "IPYTHONDIR" => nil 29 | ) do 30 | yield 31 | end 32 | end 33 | end 34 | 35 | test("IRuby warns the existence of the kernel in IPython's kernels directory and executes `jupyter kernelspec install` command") do 36 | out, status = Open3.capture2e(*iruby_command("register")) 37 | assert status.success? 38 | assert_match(/^Fake Jupyter$/, out) 39 | assert_match(/^#{Regexp.escape("IRuby kernel `#{DEFAULT_KERNEL_NAME}` already exists in the deprecated IPython's data directory.")}$/, 40 | out) 41 | end 42 | end 43 | 44 | sub_test_case("when the existing IRuby kernel is in Jupyter's default kernels directory") do 45 | # TODO 46 | end 47 | 48 | sub_test_case("JUPYTER_DATA_DIR is supplied") do 49 | def setup 50 | Dir.mktmpdir do |tmpdir| 51 | @kernel_json = File.join(tmpdir, "kernels", DEFAULT_KERNEL_NAME, "kernel.json") 52 | with_env( 53 | "JUPYTER_DATA_DIR" => tmpdir, 54 | "IPYTHONDIR" => nil 55 | ) do 56 | yield 57 | end 58 | end 59 | end 60 | 61 | test("a new IRuby kernel `#{DEFAULT_KERNEL_NAME}` will be installed in JUPYTER_DATA_DIR") do 62 | assert_path_not_exist @kernel_json 63 | 64 | _out, status = Open3.capture2e(*iruby_command("register")) 65 | assert status.success? 66 | assert_path_exist @kernel_json 67 | 68 | kernel = JSON.load(File.read(@kernel_json)) 69 | assert_equal DEFAULT_DISPLAY_NAME, kernel["display_name"] 70 | end 71 | 72 | sub_test_case("there is a IRuby kernel in JUPYTER_DATA_DIR") do 73 | def setup 74 | super do 75 | FileUtils.mkdir_p(File.dirname(@kernel_json)) 76 | File.write(@kernel_json, '"dummy kernel"') 77 | assert_equal '"dummy kernel"', File.read(@kernel_json) 78 | yield 79 | end 80 | end 81 | 82 | test("warn the existence of the kernel") do 83 | out, status = Open3.capture2e(*iruby_command("register")) 84 | refute status.success? 85 | assert_match(/^#{Regexp.escape("IRuby kernel named `#{DEFAULT_KERNEL_NAME}` already exists!")}$/, 86 | out) 87 | assert_match(/^#{Regexp.escape("Use --force to force register the new kernel.")}$/, 88 | out) 89 | end 90 | 91 | test("the existing kernel is not overwritten") do 92 | _out, status = Open3.capture2e(*iruby_command("register")) 93 | refute status.success? 94 | assert_equal '"dummy kernel"', File.read(@kernel_json) 95 | end 96 | 97 | sub_test_case("`--force` option is specified") do 98 | test("the existing kernel is overwritten by the new kernel") do 99 | out, status = Open3.capture2e(*iruby_command("register", "--force")) 100 | assert status.success? 101 | assert_not_match(/^#{Regexp.escape("IRuby kernel named `#{DEFAULT_KERNEL_NAME}` already exists!")}$/, 102 | out) 103 | assert_not_match(/^#{Regexp.escape("Use --force to force register the new kernel.")}$/, 104 | out) 105 | assert_not_equal '"dummy kernel"', File.read(@kernel_json) 106 | end 107 | end 108 | end 109 | end 110 | 111 | sub_test_case("both JUPYTER_DATA_DIR and IPYTHONDIR are supplied") do 112 | def setup 113 | Dir.mktmpdir do |tmpdir| 114 | Dir.mktmpdir do |tmpdir2| 115 | with_env( 116 | "JUPYTER_DATA_DIR" => tmpdir, 117 | "IPYTHONDIR" => tmpdir2 118 | ) do 119 | yield 120 | end 121 | end 122 | end 123 | end 124 | 125 | test("warn for IPYTHONDIR") do 126 | out, status = Open3.capture2e(*iruby_command("register")) 127 | assert status.success? 128 | assert_match(/^#{Regexp.escape("both JUPYTER_DATA_DIR and IPYTHONDIR are supplied; IPYTHONDIR is ignored.")}$/, 129 | out) 130 | end 131 | 132 | test("a new kernel is installed in JUPYTER_DATA_DIR") do 133 | _out, status = Open3.capture2e(*iruby_command("register")) 134 | assert status.success? 135 | assert_path_exist File.join(ENV["JUPYTER_DATA_DIR"], "kernels", DEFAULT_KERNEL_NAME, "kernel.json") 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IRuby 2 | 3 | [![Gem Version](https://badge.fury.io/rb/iruby.svg)](https://badge.fury.io/rb/iruby) 4 | [![Build Status](https://github.com/SciRuby/iruby/workflows/CI/badge.svg)](https://github.com/SciRuby/iruby/actions) 5 | [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/RubyData/binder/master?filepath=ruby-data.ipynb) 6 | 7 | IRuby is a Ruby kernel for [Jupyter project](https://jupyter.org/). 8 | 9 | ## Try IRuby 10 | 11 | You can try IRuby with a sample notebook on Binder (the same link as the banner placed above): 12 | 13 | 14 | 15 | The following URL launches JupyterLab directly on Binder. 16 | 17 | 18 | 19 | ## Installation 20 | 21 | ### Requirements 22 | 23 | - [Jupyter](https://jupyter.org) 24 | 25 | The following requirements are automatically installed. 26 | 27 | - [ffi-rzmq](https://github.com/chuckremes/ffi-rzmq) 28 | - [libzmq](https://github.com/zeromq/libzmq) 29 | 30 | The following dependencies are optional. 31 | 32 | - [Pry][Pry], if you want to use [Pry][Pry] instead of IRB for the code execution backend 33 | 34 | ### Installing Jupyter Notebook and/or JupyterLab 35 | 36 | See the official document to know how to install Jupyter Notebook and/or JupyterLab. 37 | 38 | - 39 | - 40 | 41 | ### Ubuntu 42 | 43 | #### Ubuntu 22.04+ 44 | 45 | ```shell 46 | sudo apt install libtool libffi-dev ruby ruby-dev make 47 | 48 | gem install --user-install rubygems-requirements-system 49 | gem install --user-install iruby 50 | iruby register --force 51 | ``` 52 | 53 | ### Fedora 54 | 55 | #### Fedora 40+ 56 | 57 | ```shell 58 | sudo dnf install ruby ruby-dev make zeromq-devel 59 | 60 | gem install --user-install rubygems-requirements-system 61 | gem install --user-install iruby 62 | iruby register --force 63 | ``` 64 | 65 | ### Windows 66 | 67 | [DevKit](https://rubyinstaller.org/add-ons/devkit.html) is necessary for building RubyGems with native C-based extensions. 68 | 69 | ```shell 70 | gem install iruby 71 | iruby register --force 72 | ``` 73 | 74 | ### macOS 75 | 76 | Install ruby with rbenv or rvm. 77 | Install Jupyter. 78 | 79 | #### Homebrew 80 | 81 | ```shell 82 | gem install rubygems-requirements-system 83 | gem install iruby 84 | iruby register --force 85 | ``` 86 | 87 | #### MacPorts 88 | 89 | If you are using macports, run the following commands. 90 | 91 | ```shell 92 | port install libtool autoconf automake autogen 93 | gem install rubygems-requirements-system 94 | gem install iruby 95 | iruby register --force 96 | ``` 97 | 98 | ### Docker 99 | 100 | Try [RubyData Docker Stacks](https://github.com/RubyData/docker-stacks). 101 | Running jupyter notebook: 102 | 103 | ```shell 104 | docker run --rm -it -p 8888:8888 rubydata/datascience-notebook 105 | ``` 106 | 107 | ### Installation for JRuby 108 | 109 | You can use Java classes in your IRuby notebook. 110 | 111 | - JRuby version >= 9.0.4.0 112 | - iruby gem 113 | 114 | After installation, make sure that your `env` is set up to use jruby. 115 | 116 | ```shell 117 | env ruby -v 118 | ``` 119 | 120 | If you use RVM, it is enough to switch the current version to jruby. 121 | 122 | If you have already used IRuby with a different version, you need to generate a new kernel: 123 | 124 | ```shell 125 | iruby register --force 126 | ``` 127 | 128 | ### Install the development version of IRuby 129 | 130 | **Be careful to use the development version because it is usually unstable.** 131 | 132 | If you want to install the development version of IRuby from the source code, try [specific_install](https://github.com/rdp/specific_install). 133 | 134 | ``` 135 | gem specific_install https://github.com/SciRuby/iruby 136 | ``` 137 | 138 | ### Note for using with CZTop and CZMQ 139 | 140 | [CZTop](https://gitlab.com/paddor/cztop) adapter has been deprecated since IRuby version 0.7.4. 141 | It will be removed after several versions. 142 | 143 | If you want to use IRuby with CZTop, you need to install it and [CZMQ](https://github.com/zeromq/czmq). 144 | 145 | If both ffi-rzmq and cztop are installed, ffi-rzmq is used. If you prefer cztop, set the following environment variable. 146 | 147 | ```sh 148 | export IRUBY_SESSION_ADAPTER="cztop" 149 | ``` 150 | 151 | ## Backends 152 | 153 | There are two backends: PlainBackend and PryBackend. 154 | 155 | - PlainBackend is the default backend. It uses [IRB](https://github.com/ruby/irb). 156 | - PryBackend uses [Pry][Pry]. 157 | 158 | You can switch the backend to PryBackend by running the code below. 159 | 160 | ```ruby 161 | IRuby::Kernel.instance.switch_backend!(:pry) 162 | ``` 163 | 164 | ## Notebooks 165 | 166 | Take a look at the [example notebook](https://nbviewer.jupyter.org/urls/raw.github.com/SciRuby/sciruby-notebooks/master/getting_started.ipynb) 167 | and the [collection of notebooks](https://github.com/SciRuby/sciruby-notebooks/) which includes a Dockerfile to create a containerized installation of iruby 168 | and other scientific gems. 169 | 170 | ## Contributing 171 | 172 | Contributions to IRuby are very welcome. 173 | 174 | To former contributors 175 | 176 | In February 2021, [IRuby became the canonical repository](https://github.com/SciRuby/iruby/issues/285) and is no longer a fork from [minrk/iruby](https://github.com/minrk/iruby). Please fork from this repository again before making pull requests. 177 | 178 | ## License 179 | 180 | Copyright (c) IRuby contributors and the Ruby Science Foundation. 181 | 182 | Licensed under the [MIT](LICENSE) license. 183 | 184 | [Pry]: https://github.com/pry/pry 185 | -------------------------------------------------------------------------------- /test/iruby/kernel_test.rb: -------------------------------------------------------------------------------- 1 | module IRubyTest 2 | class KernelTest < TestBase 3 | def setup 4 | super 5 | with_session_adapter("test") 6 | @kernel = IRuby::Kernel.instance 7 | end 8 | 9 | sub_test_case("iruby_initialized event") do 10 | def setup 11 | super 12 | @initialized_kernel = nil 13 | @callback = IRuby::Kernel.events.register(:initialized) do |kernel| 14 | @initialized_kernel = kernel 15 | end 16 | end 17 | 18 | def teardown 19 | IRuby::Kernel.events.unregister(:initialized, @callback) 20 | end 21 | 22 | def test_iruby_initialized_event 23 | with_session_adapter("test") 24 | assert_same(IRuby::Kernel.instance, @initialized_kernel) 25 | end 26 | end 27 | 28 | def test_execute_request 29 | obj = Object.new 30 | 31 | class << obj 32 | def to_html 33 | "HTML" 34 | end 35 | 36 | def inspect 37 | "!!! inspect !!!" 38 | end 39 | end 40 | 41 | ::IRubyTest.define_singleton_method(:test_object) { obj } 42 | 43 | msg_types = [] 44 | execute_reply = nil 45 | execute_result = nil 46 | @kernel.session.adapter.send_callback = ->(sock, msg) do 47 | header = msg[:header] 48 | content = msg[:content] 49 | msg_types << header["msg_type"] 50 | case header["msg_type"] 51 | when "execute_reply" 52 | execute_reply = content 53 | when "execute_result" 54 | execute_result = content 55 | end 56 | end 57 | 58 | msg = { 59 | content: { 60 | "code" => "IRubyTest.test_object", 61 | "silent" => false, 62 | "store_history" => false, 63 | "user_expressions" => {}, 64 | "allow_stdin" => false, 65 | "stop_on_error" => true, 66 | } 67 | } 68 | @kernel.execute_request(msg) 69 | 70 | assert_equal({ 71 | msg_types: [ "execute_input", "execute_result", "execute_reply" ], 72 | execute_reply: { 73 | status: "ok", 74 | user_expressions: {}, 75 | }, 76 | execute_result: { 77 | data: { 78 | "text/html" => "HTML", 79 | "text/plain" => "!!! inspect !!!" 80 | }, 81 | metadata: {}, 82 | } 83 | }, 84 | { 85 | msg_types: msg_types, 86 | execute_reply: { 87 | status: execute_reply["status"], 88 | user_expressions: execute_reply["user_expressions"] 89 | }, 90 | execute_result: { 91 | data: execute_result["data"], 92 | metadata: execute_result["metadata"] 93 | } 94 | }) 95 | end 96 | 97 | def test_events_around_of_execute_request 98 | event_history = [] 99 | 100 | @kernel.events.register(:pre_execute) do 101 | event_history << :pre_execute 102 | end 103 | 104 | @kernel.events.register(:pre_run_cell) do |exec_info| 105 | event_history << [:pre_run_cell, exec_info] 106 | end 107 | 108 | @kernel.events.register(:post_execute) do 109 | event_history << :post_execute 110 | end 111 | 112 | @kernel.events.register(:post_run_cell) do |result| 113 | event_history << [:post_run_cell, result] 114 | end 115 | 116 | msg = { 117 | content: { 118 | "code" => "true", 119 | "silent" => false, 120 | "store_history" => false, 121 | "user_expressions" => {}, 122 | "allow_stdin" => false, 123 | "stop_on_error" => true, 124 | } 125 | } 126 | @kernel.execute_request(msg) 127 | 128 | msg = { 129 | content: { 130 | "code" => "true", 131 | "silent" => true, 132 | "store_history" => false, 133 | "user_expressions" => {}, 134 | "allow_stdin" => false, 135 | "stop_on_error" => true, 136 | } 137 | } 138 | @kernel.execute_request(msg) 139 | 140 | assert_equal([ 141 | :pre_execute, 142 | [:pre_run_cell, IRuby::ExecutionInfo.new("true", false, false)], 143 | :post_execute, 144 | [:post_run_cell, true], 145 | :pre_execute, 146 | :post_execute 147 | ], 148 | event_history) 149 | end 150 | 151 | sub_test_case("#switch_backend!") do 152 | sub_test_case("") do 153 | def test_switch_backend 154 | classes = [] 155 | 156 | # First pick the default backend class 157 | classes << @kernel.instance_variable_get(:@backend).class 158 | 159 | @kernel.switch_backend!(:pry) 160 | classes << @kernel.instance_variable_get(:@backend).class 161 | 162 | @kernel.switch_backend!(:irb) 163 | classes << @kernel.instance_variable_get(:@backend).class 164 | 165 | @kernel.switch_backend!(:pry) 166 | classes << @kernel.instance_variable_get(:@backend).class 167 | 168 | @kernel.switch_backend!(:plain) 169 | classes << @kernel.instance_variable_get(:@backend).class 170 | 171 | assert_equal([ 172 | IRuby::PlainBackend, 173 | IRuby::PryBackend, 174 | IRuby::PlainBackend, 175 | IRuby::PryBackend, 176 | IRuby::PlainBackend 177 | ], 178 | classes) 179 | end 180 | end 181 | end 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /test/iruby/display_test.rb: -------------------------------------------------------------------------------- 1 | module IRubyTest 2 | class DisplayTest < TestBase 3 | def setup 4 | @object = Object.new 5 | @object.instance_variable_set(:@to_html_called, false) 6 | @object.instance_variable_set(:@to_markdown_called, false) 7 | @object.instance_variable_set(:@to_iruby_called, false) 8 | @object.instance_variable_set(:@to_iruby_mimebundle_called, false) 9 | 10 | class << @object 11 | attr_reader :to_html_called 12 | attr_reader :to_markdown_called 13 | attr_reader :to_iruby_called 14 | attr_reader :to_iruby_mimebundle_called 15 | 16 | def html 17 | "html" 18 | end 19 | 20 | def markdown 21 | "*markdown*" 22 | end 23 | 24 | def inspect 25 | "!!! inspect !!!" 26 | end 27 | end 28 | end 29 | 30 | def define_to_html 31 | class << @object 32 | def to_html 33 | @to_html_called = true 34 | html 35 | end 36 | end 37 | end 38 | 39 | def define_to_markdown 40 | class << @object 41 | def to_markdown 42 | @to_markdown_called = true 43 | markdown 44 | end 45 | end 46 | end 47 | 48 | def define_to_iruby 49 | class << @object 50 | def to_iruby 51 | @to_iruby_called = true 52 | ["text/html", "to_iruby"] 53 | end 54 | end 55 | end 56 | 57 | def define_to_iruby_mimebundle 58 | class << @object 59 | def to_iruby_mimebundle(include: []) 60 | @to_iruby_mimebundle_called = true 61 | mimes = if include.empty? 62 | ["text/markdown", "application/json"] 63 | else 64 | include 65 | end 66 | formats = mimes.map { |mime| 67 | result = case mime 68 | when "text/markdown" 69 | "**markdown**" 70 | when "application/json" 71 | %Q[{"mimebundle": "json"}] 72 | end 73 | [mime, result] 74 | }.to_h 75 | metadata = {} 76 | return formats, metadata 77 | end 78 | end 79 | end 80 | 81 | def assert_iruby_display(expected) 82 | assert_equal(expected, 83 | { 84 | result: IRuby::Display.display(@object), 85 | to_html_called: @object.to_html_called, 86 | to_markdown_called: @object.to_markdown_called, 87 | to_iruby_called: @object.to_iruby_called, 88 | to_iruby_mimebundle_called: @object.to_iruby_mimebundle_called 89 | }) 90 | end 91 | 92 | sub_test_case("the object cannot handle all the mime types") do 93 | def test_display 94 | assert_iruby_display({ 95 | result: {"text/plain" => "!!! inspect !!!"}, 96 | to_html_called: false, 97 | to_markdown_called: false, 98 | to_iruby_called: false, 99 | to_iruby_mimebundle_called: false 100 | }) 101 | end 102 | end 103 | 104 | sub_test_case("the object can respond to to_iruby") do 105 | def setup 106 | super 107 | define_to_iruby 108 | end 109 | 110 | def test_display 111 | assert_iruby_display({ 112 | result: { 113 | "text/html" => "to_iruby", 114 | "text/plain" => "!!! inspect !!!" 115 | }, 116 | to_html_called: false, 117 | to_markdown_called: false, 118 | to_iruby_called: true, 119 | to_iruby_mimebundle_called: false 120 | }) 121 | end 122 | 123 | sub_test_case("the object can respond to to_markdown") do 124 | def setup 125 | super 126 | define_to_markdown 127 | end 128 | 129 | def test_display 130 | assert_iruby_display({ 131 | result: { 132 | "text/markdown" => "*markdown*", 133 | "text/plain" => "!!! inspect !!!" 134 | }, 135 | to_html_called: false, 136 | to_markdown_called: true, 137 | to_iruby_called: false, 138 | to_iruby_mimebundle_called: false 139 | }) 140 | end 141 | end 142 | 143 | sub_test_case("the object can respond to to_html") do 144 | def setup 145 | super 146 | define_to_html 147 | end 148 | 149 | def test_display 150 | assert_iruby_display({ 151 | result: { 152 | "text/html" => "html", 153 | "text/plain" => "!!! inspect !!!" 154 | }, 155 | to_html_called: true, 156 | to_markdown_called: false, 157 | to_iruby_called: false, 158 | to_iruby_mimebundle_called: false 159 | }) 160 | end 161 | 162 | sub_test_case("the object can respond to to_iruby_mimebundle") do 163 | def setup 164 | super 165 | define_to_iruby_mimebundle 166 | end 167 | 168 | def test_display 169 | assert_iruby_display({ 170 | result: { 171 | "text/markdown" => "**markdown**", 172 | "application/json" => %Q[{"mimebundle": "json"}], 173 | "text/plain" => "!!! inspect !!!" 174 | }, 175 | to_html_called: false, 176 | to_markdown_called: false, 177 | to_iruby_called: false, 178 | to_iruby_mimebundle_called: true 179 | }) 180 | end 181 | end 182 | end 183 | end 184 | end 185 | end 186 | -------------------------------------------------------------------------------- /lib/iruby/input/README.md: -------------------------------------------------------------------------------- 1 | 2 | # IRuby Input 3 | 4 | This README is generated from README.ipynb. Please do not edit this file directly. 5 | 6 | The `IRuby::Input` class makes it easier for IRuby users to get input from users. For example: 7 | 8 | 9 | ```ruby 10 | name = IRuby.input 'Enter your name' 11 | ``` 12 | 13 | The following input methods are supported on the `IRuby` module: 14 | 15 | | method | description | 16 | | -------- | -------- | 17 | | `input(prompt)` | Prompts the user for a line of input | 18 | | `password(prompt)` | Prompts the user for a password | 19 | | `form(&block)` | Presents a form to the user | 20 | | `popup(title,&block)` | Displays a form to the user as a popup | 21 | 22 | ## Forms 23 | 24 | Forms are groups of inputs to be collected from the user. For example: 25 | 26 | 27 | ```ruby 28 | result = IRuby.form do 29 | input :username 30 | password :password 31 | button 32 | end 33 | ``` 34 | 35 | The following methods are available to build forms: 36 | 37 | | method | description | 38 | | -------- | -------- | 39 | | `input(key=:input)` | Prompts the user for a line of input | 40 | | `textarea(key=:textarea),` | Adds a textarea to the form | 41 | | `password(key=:password)` | Prompts the user for a password | 42 | | `button(key=:done, color: :blue)` | Adds a submit button to the form | 43 | | `cancel(prompt='Cancel')` | Adds a cancel button to the form | 44 | | `text(string)` | Adds text to the form | 45 | | `html(&block)` | Inserts HTML from the given [erector block](https://github.com/erector/erector) | 46 | | `file(key=:file)` | Adds a file input to the form | 47 | | `date(key=:date)` | Adds a date picker to the form | 48 | | `select(*options)` | Adds a dropdown select input to the form | 49 | | `radio(*options)` | Adds a radio select input to the form | 50 | | `checkbox(*options)` | Adds checkbox inputs to the form | 51 | 52 | ## Popups 53 | 54 | Popups are just forms in a bootstrap modal. They are useful when users **Run All** in a notebook with a lot of inputs. The popups always appear in the same spot, so users don't have to scroll down to find the next input. 55 | 56 | Popups accept a `title` argument, for example: 57 | 58 | 59 | ```ruby 60 | result = IRuby.popup 'Please enter your name' do 61 | input 62 | button 63 | end 64 | ``` 65 | 66 | ## Submit and cancel 67 | 68 | The enter key will submit an input or form and the escape key will cancel it. Canceled inputs are returned as `nil`. Inputs are automatically canceled if destroyed. An input can be destroyed by clearing its cell's output. The `cancel` button will cancel a form and all other buttons will submit it. 69 | 70 | After a form destroyed, the cell's output is cleared. Be careful not to prompt for input in a block that has previous output you would like to keep. Output is cleared to prevent forms from interfering with one another and to ensure that inputs are not inadvertently saved to the notebook. 71 | 72 | 73 | ```ruby 74 | result = IRuby.popup 'Confirm' do 75 | text 'Are you sure you want to continue?' 76 | cancel 'No' 77 | button 'Yes' 78 | end 79 | ``` 80 | 81 | ## Custom keys 82 | 83 | Every widget has an entry in the final results hash. A custom key can be passed as the first parameter to the hash. If no key is provided, the widget name is used as the key. The `cancel` widget has no key; it's first parameter is its label. 84 | 85 | 86 | ```ruby 87 | result = IRuby.form do 88 | input :username 89 | password :password 90 | end 91 | ``` 92 | 93 | ## Custom labels 94 | 95 | Field labels appear to the left of the field. Button labels appear as the text on the button. `cancel` labels are passed as the first argument. All other widgets' labels are set using the `label` parameter. 96 | 97 | 98 | ```ruby 99 | result = IRuby.form do 100 | input :name, label: 'Please enter your name' 101 | cancel 'None of your business!' 102 | button :submit, label: 'All done' 103 | end 104 | ``` 105 | 106 | ## Defaults 107 | 108 | Most inputs will accept a `default` parameter. If no default is given, the default is `nil`. Since checkboxes can have multiple values selected, you can pass an array of values. To check everything, pass `true` as the default. 109 | 110 | 111 | ```ruby 112 | result = IRuby.form do 113 | checkbox :one, 'Fish', 'Cat', 'Dog', default: 'Fish' 114 | checkbox :many, 'Fish', 'Cat', 'Dog', default: ['Cat', 'Dog'] 115 | checkbox :all, 'Fish', 'Cat', 'Dog', default: true 116 | button :submit, label: 'All done' 117 | end 118 | ``` 119 | 120 | ## Dates 121 | 122 | The `date` widget provides a calendar popup and returns a `Time` object. It's default should also be a `Time` object. 123 | 124 | 125 | ```ruby 126 | result = IRuby.form do 127 | date :birthday 128 | date :today, default: Time.now 129 | button 130 | end 131 | ``` 132 | 133 | ## Buttons 134 | 135 | Buttons do not appear in the final hash unless they are clicked. If clicked, their value is `true`. Here are the various colors a button can be: 136 | 137 | 138 | ```ruby 139 | result = IRuby.form do 140 | IRuby::Input::Button::COLORS.each_key do |color| 141 | button color, color: color 142 | end 143 | end 144 | ``` 145 | 146 | ## Textareas 147 | 148 | Textareas are multiline inputs that are convenient for larger inputs. If you need a line return when typing in a textarea, use shift+enter since enter will submit the form. 149 | 150 | 151 | ```ruby 152 | result = IRuby.form do 153 | text 'Enter email addresses, one per line (use shift+enter for newlines)' 154 | textarea :emails 155 | end 156 | ``` 157 | 158 | ## Text and HTML 159 | 160 | You can insert lines of text or custom html using their respective helpers: 161 | 162 | 163 | ```ruby 164 | result = IRuby.form do 165 | html { h1 'Choose a Stooge' } 166 | text 'Choose your favorite stooge' 167 | select :stooge, 'Moe', 'Larry', 'Curly' 168 | button 169 | end 170 | ``` 171 | 172 | ## Dropdowns 173 | 174 | A `select` is a dropdown of options. Use a `multiple` to allow multiple selections. `multiple` widgets accept an additional `size` parameters that determines the number of rows. The default is 4. 175 | 176 | 177 | ```ruby 178 | result = IRuby.form do 179 | select :stooge, 'Moe', 'Larry', 'Curly' 180 | select :stooge, 'Moe', 'Larry', 'Curly', default: 'Moe' 181 | multiple :stooges, 'Moe', 'Larry', 'Curly', default: true, size: 3 182 | multiple :stooges, 'Moe', 'Larry', 'Curly', default: ['Moe','Curly'] 183 | button 184 | end 185 | ``` 186 | 187 | ## Radio selects and checkboxes 188 | 189 | Like selects, radio selects and checkboxes take multiple arguments, each one an option. If the first argument is a symbol, it is used as the key. 190 | 191 | Note that the `checkbox` widget will always return `nil` or an array. 192 | 193 | 194 | ```ruby 195 | result = IRuby.form do 196 | radio :children, *(0..12), label: 'How many children do you have?' 197 | checkbox :gender, 'Male', 'Female', 'Intersex', label: 'Select the genders of your children' 198 | button 199 | end 200 | ``` 201 | 202 | ## Files 203 | 204 | Since file widgets capture the enter key, you should include a button when creating forms that contain only a file input: 205 | 206 | 207 | ```ruby 208 | IRuby.form do 209 | file :avatar, label: 'Choose an Avatar' 210 | button :submit 211 | end 212 | ``` 213 | 214 | File widgets return a hash with three keys: 215 | 216 | * `data`: The contents of the file as a string 217 | * `content_type`: The type of file, such as `text/plain` or `image/jpeg` 218 | * `name`: The name of the uploaded file 219 | 220 | ## Example 221 | 222 | Here is an example form that uses every built-in widget. 223 | 224 | 225 | ```ruby 226 | result = IRuby.form do 227 | html { h1 'The Everything Form' } 228 | text 'Marvel at the strange and varied inputs!' 229 | date 230 | file 231 | input :username 232 | password 233 | textarea 234 | radio *(1..10) 235 | checkbox 'Fish', 'Cat', 'Dog', label: 'Animals' 236 | select :color, *IRuby::Input::Button::COLORS.keys 237 | cancel 238 | button 239 | end 240 | ``` 241 | 242 | ## Writing your own widget 243 | 244 | Most form methods are `IRuby::Input::Widget` instances. A `Widget` is an [`Erector::Widget`](https://github.com/erector/erector) with some additional helpers. Here is the `cancel` widget: 245 | 246 | ```ruby 247 | module IRuby 248 | module Input 249 | class Cancel < Widget 250 | needs :label 251 | 252 | builder :cancel do |label='Cancel'| 253 | add_button Cancel.new(label: label) 254 | end 255 | 256 | def widget_css 257 | ".iruby-cancel { margin-left: 5px; }" 258 | end 259 | 260 | def widget_js 261 | <<-JS 262 | $('.iruby-cancel').click(function(){ 263 | $('#iruby-form').remove(); 264 | }); 265 | JS 266 | end 267 | 268 | def widget_html 269 | button( 270 | @label, 271 | type: 'button', 272 | :'data-dismiss' => 'modal', 273 | class: "btn btn-danger pull-right iruby-cancel" 274 | ) 275 | end 276 | end 277 | end 278 | end 279 | ``` 280 | 281 | The following methods are available for widgets to use or override: 282 | 283 | | method | description | 284 | | -------- | -------- | 285 | | `widget_js` | Returns the widget's Javascript | 286 | | `widget_css` | Returns the widget's CSS | 287 | | `widget_html` | Returns the widget's | 288 | | `builder(method,&block)` | Class method to add form building helpers. | 289 | 290 | The following methods are available in the `builder` block: 291 | 292 | | method | description | 293 | | -------- | -------- | 294 | | `add_field(field)` | Adds a widget to the form's field area | 295 | | `add_button(button)` | Adds a button to the form's button area | 296 | | `process(key,&block)` | Register a custom processing block for the given key in the results hash | 297 | | `unique_key(key)` | Returns a unique key for the given key. Use this to make sure that there are no key collisions in the final results hash. | 298 | 299 | A canceled form always returns `nil`. Otherwise, the form collects any element with a `data-iruby-key` and non-falsey `data-iruby-value` and passes those to the processor proc registered for the key. See the `File` widget for a more involved example of processing results. 300 | -------------------------------------------------------------------------------- /lib/iruby/kernel.rb: -------------------------------------------------------------------------------- 1 | module IRuby 2 | ExecutionInfo = Struct.new(:raw_cell, :store_history, :silent) 3 | 4 | class Kernel 5 | RED = "\e[31m" 6 | RESET = "\e[0m" 7 | 8 | @events = EventManager.new([:initialized]) 9 | 10 | class << self 11 | # Return the event manager defined in the `IRuby::Kernel` class. 12 | # This event manager can handle the following event: 13 | # 14 | # - `initialized`: The event occurred after the initialization of 15 | # a `IRuby::Kernel` instance is finished 16 | # 17 | # @example Registering initialized event 18 | # IRuby::Kernel.events.register(:initialized) do |result| 19 | # STDERR.puts "IRuby kernel has been initialized" 20 | # end 21 | # 22 | # @see IRuby::EventManager 23 | # @see IRuby::Kernel#events 24 | attr_reader :events 25 | 26 | # Returns the singleton kernel instance 27 | attr_accessor :instance 28 | end 29 | 30 | # Returns a session object 31 | attr_reader :session 32 | 33 | EVENTS = [ 34 | :pre_execute, 35 | :pre_run_cell, 36 | :post_run_cell, 37 | :post_execute 38 | ].freeze 39 | 40 | def initialize(config_file, session_adapter_name=nil) 41 | @config = JSON.parse(File.read(config_file)) 42 | IRuby.logger.debug("IRuby kernel start with config #{@config}") 43 | Kernel.instance = self 44 | 45 | @session = Session.new(@config, session_adapter_name) 46 | $stdout = OStream.new(@session, :stdout) 47 | $stderr = OStream.new(@session, :stderr) 48 | 49 | init_parent_process_poller 50 | 51 | @events = EventManager.new(EVENTS) 52 | @execution_count = 0 53 | @backend = PlainBackend.new 54 | @running = true 55 | 56 | self.class.events.trigger(:initialized, self) 57 | end 58 | 59 | # Returns the event manager defined in a `IRuby::Kernel` instance. 60 | # This event manager can handle the following events: 61 | # 62 | # - `pre_execute`: The event occurred before running the code 63 | # 64 | # - `pre_run_cell`: The event occurred before running the code and 65 | # if the code execution is not silent 66 | # 67 | # - `post_execute`: The event occurred after running the code 68 | # 69 | # - `post_run_cell`: The event occurred after running the code and 70 | # if the code execution is not silent 71 | # 72 | # The callback functions of `pre_run_cell` event must take one argument 73 | # to get an `ExecutionInfo` object. 74 | # The callback functions of `post_run_cell` event must take one argument 75 | # to get the result of the code execution. 76 | # 77 | # @example Registering post_run_cell event 78 | # IRuby::Kernel.instance.events.register(:post_run_cell) do |result| 79 | # STDERR.puts "The result of the last execution: %p" % result 80 | # end 81 | # 82 | # @see IRuby::EventManager 83 | # @see IRuby::ExecutionInfo 84 | # @see IRuby::Kernel.events 85 | attr_reader :events 86 | 87 | # Switch the backend (interactive shell) system 88 | # 89 | # @param backend [:irb,:plain,:pry] Specify the backend name switched to 90 | # 91 | # @return [true,false] true if the switching succeeds, otherwise false 92 | def switch_backend!(backend) 93 | name = case backend 94 | when String, Symbol 95 | name = backend.downcase 96 | else 97 | name = backend 98 | end 99 | 100 | backend_class = case name 101 | when :irb, :plain 102 | PlainBackend 103 | when :pry 104 | PryBackend 105 | else 106 | raise ArgumentError, 107 | "Unknown backend name: %p" % backend 108 | end 109 | 110 | begin 111 | new_backend = backend_class.new 112 | @backend = new_backend 113 | true 114 | rescue Exception => e 115 | unless LoadError === e 116 | IRuby.logger.warn "Could not load #{backend_class}: " + 117 | "#{e.message}\n#{e.backtrace.join("\n")}" 118 | end 119 | return false 120 | end 121 | end 122 | 123 | # @private 124 | def run 125 | send_status :starting 126 | while @running 127 | dispatch 128 | end 129 | end 130 | 131 | # @private 132 | def dispatch 133 | msg = @session.recv(:reply) 134 | IRuby.logger.debug "Kernel#dispatch: msg = #{msg}" 135 | type = msg[:header]['msg_type'] 136 | raise "Unknown message type: #{msg.inspect}" unless type =~ /comm_|_request\Z/ && respond_to?(type) 137 | begin 138 | send_status :busy 139 | send(type, msg) 140 | ensure 141 | send_status :idle 142 | end 143 | rescue Exception => e 144 | IRuby.logger.debug "Kernel error: #{e.message}\n#{e.backtrace.join("\n")}" 145 | @session.send(:publish, :error, error_content(e)) 146 | end 147 | 148 | # @private 149 | def kernel_info_request(msg) 150 | @session.send(:reply, :kernel_info_reply, 151 | protocol_version: '5.0', 152 | implementation: 'iruby', 153 | implementation_version: IRuby::VERSION, 154 | language_info: { 155 | name: 'ruby', 156 | version: RUBY_VERSION, 157 | mimetype: 'application/x-ruby', 158 | file_extension: '.rb' 159 | }, 160 | banner: "IRuby #{IRuby::VERSION} (with #{@session.description})", 161 | help_links: [ 162 | { 163 | text: "Ruby Documentation", 164 | url: "https://ruby-doc.org/" 165 | } 166 | ], 167 | status: :ok) 168 | end 169 | 170 | # @private 171 | def send_status(status) 172 | IRuby.logger.debug "Send status: #{status}" 173 | @session.send(:publish, :status, execution_state: status) 174 | end 175 | 176 | # @private 177 | def execute_request(msg) 178 | code = msg[:content]['code'] 179 | silent = msg[:content]['silent'] 180 | # https://jupyter-client.readthedocs.io/en/stable/messaging.html#execute 181 | store_history = silent ? false : msg[:content].fetch('store_history', true) 182 | 183 | @execution_count += 1 if store_history 184 | 185 | unless silent 186 | @session.send(:publish, :execute_input, code: code, execution_count: @execution_count) 187 | end 188 | 189 | events.trigger(:pre_execute) 190 | unless silent 191 | exec_info = ExecutionInfo.new(code, store_history, silent) 192 | events.trigger(:pre_run_cell, exec_info) 193 | end 194 | 195 | content = { 196 | status: :ok, 197 | payload: [], 198 | user_expressions: {}, 199 | execution_count: @execution_count 200 | } 201 | 202 | result = nil 203 | begin 204 | result = @backend.eval(code, store_history) 205 | rescue SystemExit 206 | content[:payload] << { source: :ask_exit } 207 | rescue Exception => e 208 | content = error_content(e) 209 | @session.send(:publish, :error, content) 210 | content[:status] = :error 211 | content[:execution_count] = @execution_count 212 | end 213 | 214 | unless result.nil? || silent 215 | @session.send(:publish, :execute_result, 216 | data: Display.display(result), 217 | metadata: {}, 218 | execution_count: @execution_count) 219 | end 220 | 221 | events.trigger(:post_execute) 222 | # **{} is for Ruby2.7. Gnuplot#to_hash returns an Array. 223 | events.trigger(:post_run_cell, result, **{}) unless silent 224 | 225 | @session.send(:reply, :execute_reply, content) 226 | end 227 | 228 | # @private 229 | def error_content(e) 230 | rindex = e.backtrace.rindex{|line| line.start_with?(@backend.eval_path)} || -1 231 | backtrace = SyntaxError === e && rindex == -1 ? [] : e.backtrace[0..rindex] 232 | { ename: e.class.to_s, 233 | evalue: e.message, 234 | traceback: ["#{RED}#{e.class}#{RESET}: #{e.message}", *backtrace] } 235 | end 236 | 237 | # @private 238 | def is_complete_request(msg) 239 | # FIXME: the code completeness should be judged by using ripper or other Ruby parser 240 | @session.send(:reply, :is_complete_reply, 241 | status: :unknown) 242 | end 243 | 244 | # @private 245 | def complete_request(msg) 246 | # HACK for #26, only complete last line 247 | code = msg[:content]['code'] 248 | if start = code.rindex(/\s|\R/) 249 | code = code[start+1..-1] 250 | start += 1 251 | end 252 | @session.send(:reply, :complete_reply, 253 | matches: @backend.complete(code), 254 | cursor_start: start.to_i, 255 | cursor_end: msg[:content]['cursor_pos'], 256 | metadata: {}, 257 | status: :ok) 258 | end 259 | 260 | # @private 261 | def connect_request(msg) 262 | @session.send(:reply, :connect_reply, Hash[%w(shell_port iopub_port stdin_port hb_port).map {|k| [k, @config[k]] }]) 263 | end 264 | 265 | # @private 266 | def shutdown_request(msg) 267 | @session.send(:reply, :shutdown_reply, msg[:content]) 268 | @running = false 269 | end 270 | 271 | # @private 272 | def history_request(msg) 273 | # we will just send back empty history for now, pending clarification 274 | # as requested in ipython/ipython#3806 275 | @session.send(:reply, :history_reply, history: []) 276 | end 277 | 278 | # @private 279 | def inspect_request(msg) 280 | # not yet implemented. See (#119). 281 | @session.send(:reply, :inspect_reply, status: :ok, found: false, data: {}, metadata: {}) 282 | end 283 | 284 | # @private 285 | def comm_open(msg) 286 | comm_id = msg[:content]['comm_id'] 287 | target_name = msg[:content]['target_name'] 288 | Comm.comm[comm_id] = Comm.target[target_name].new(target_name, comm_id) 289 | end 290 | 291 | # @private 292 | def comm_msg(msg) 293 | Comm.comm[msg[:content]['comm_id']].handle_msg(msg[:content]['data']) 294 | end 295 | 296 | # @private 297 | def comm_close(msg) 298 | comm_id = msg[:content]['comm_id'] 299 | Comm.comm[comm_id].handle_close(msg[:content]['data']) 300 | Comm.comm.delete(comm_id) 301 | end 302 | 303 | private 304 | 305 | def init_parent_process_poller 306 | pid = ENV.fetch('JPY_PARENT_PID', 0).to_i 307 | return unless pid > 1 308 | 309 | case RUBY_PLATFORM 310 | when /mswin/, /mingw/ 311 | # TODO 312 | else 313 | @parent_poller = start_parent_process_pollar_unix 314 | end 315 | end 316 | 317 | def start_parent_process_pollar_unix 318 | Thread.start do 319 | IRuby.logger.warn("parent process poller thread started.") 320 | loop do 321 | begin 322 | current_ppid = Process.ppid 323 | if current_ppid == 1 324 | IRuby.logger.warn("parent process appears to exited, shutting down.") 325 | exit!(1) 326 | end 327 | sleep 1 328 | rescue Errno::EINTR 329 | # ignored 330 | end 331 | end 332 | end 333 | end 334 | end 335 | end 336 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # 0.8.2 (2025-04-10) 2 | 3 | * Update CI to refresh apt packages before installing IRuby gem by @kojix2 in https://github.com/SciRuby/iruby/pull/367 4 | * Fix missing `OStream#closed?` method by @RobinDaugherty in https://github.com/SciRuby/iruby/pull/368 5 | * Use rubygems-requirements-system to install system dependencies automatically by @kou in https://github.com/SciRuby/iruby/pull/369 6 | * Various typo corrections by @kojix2 in https://github.com/SciRuby/iruby/pull/370 7 | * Minor changes to README by @kojix2 in https://github.com/SciRuby/iruby/pull/371 8 | 9 | # 0.8.1 (2025-02-16) 10 | 11 | * Add support for jupyter widgets by @matt-do-it in https://github.com/SciRuby/iruby/pull/350 12 | * Suppress "literal string will be frozen in the future" warning by @tikkss in https://github.com/SciRuby/iruby/pull/353 13 | * Fix warnings in project by @simpl1g in https://github.com/SciRuby/iruby/pull/356 14 | * restore support for IRB <= v1.13.0 by @sealocal in https://github.com/SciRuby/iruby/pull/358 15 | * Restore ruby 2.6 and 2.5 in CI by @sealocal in https://github.com/SciRuby/iruby/pull/359 16 | * Add Ruby 3.4 to CI by @kojix2 in https://github.com/SciRuby/iruby/pull/360 17 | * Fix NoMethodError in backend by @edsinclair in https://github.com/SciRuby/iruby/pull/364 18 | 19 | # 0.8.0 (2024-07-28) 20 | 21 | * Hide output on assignment by @ankane in https://github.com/SciRuby/iruby/pull/312 22 | * Introduce the new Application classes by @mrkn in https://github.com/SciRuby/iruby/pull/317 23 | * Fix Gnuplot issues in Ruby 2.7 (#321) by @kojix2 in https://github.com/SciRuby/iruby/pull/322 24 | * Add Ruby3.1 to CI by @kojix2 in https://github.com/SciRuby/iruby/pull/323 25 | * Update README.md by @marek-witkowski in https://github.com/SciRuby/iruby/pull/324 26 | * ci: upgrade actions/checkout by @kojix2 in https://github.com/SciRuby/iruby/pull/325 27 | * Add Ruby 3.2 to CI for ubuntu by @petergoldstein in https://github.com/SciRuby/iruby/pull/327 28 | * Default to true for `store_history` if not in silent mode by @gartens in https://github.com/SciRuby/iruby/pull/330 29 | * Add Ruby 3.3 to CI for Ubuntu by @kojix2 in https://github.com/SciRuby/iruby/pull/331 30 | * Remove Ruby 2.3 and 2.4 from CI by @kojix2 in https://github.com/SciRuby/iruby/pull/332 31 | * Fix typos by @kojix2 in https://github.com/SciRuby/iruby/pull/335 32 | * Format README.md and ci.yml by @kojix2 in https://github.com/SciRuby/iruby/pull/337 33 | * Fix PlainBackend for irb v1.13.0 by @zalt50 in https://github.com/SciRuby/iruby/pull/339 34 | * Added `date` to header by @ebababi in https://github.com/SciRuby/iruby/pull/342 35 | * Update CI Configuration for IRuby by @kojix2 in https://github.com/SciRuby/iruby/pull/344 36 | * Add logger and Remove base64 to Fix CI Tests by @kojix2 in https://github.com/SciRuby/iruby/pull/345 37 | * Update CI trigger configuration by @kojix2 in https://github.com/SciRuby/iruby/pull/346 38 | 39 | # 0.7.4 (2021-08-18) 40 | 41 | ## Enhancements 42 | 43 | * Install zeromq library automatically https://github.com/SciRuby/iruby/pull/307, https://github.com/SciRuby/iruby/pull/308 (@mrkn, @kou) 44 | * Remove pyzmq session adapter (@mrkn) 45 | * Make cztop session adapter deprecated (@mrkn) 46 | 47 | # 0.7.3 (2021-07-08) 48 | 49 | ## Bug Fixes 50 | 51 | * Do not call default renderers when to_iruby_mimebundle is available (@mrkn) 52 | 53 | # 0.7.2 (2021-06-23) 54 | 55 | ## Bug Fixes 56 | 57 | * Fix IRuby.table for Ruby >= 2.7 https://github.com/SciRuby/iruby/pull/305 (@topofocus) 58 | * Fix PlainBackend to include modules https://github.com/SciRuby/iruby/issues/303 (@UliKuch, @mrkn) 59 | 60 | # 0.7.1 (2021-06-21) 61 | 62 | ## Enhancements 63 | 64 | * Add support of `to_iruby_mimebundle` format method https://github.com/SciRuby/iruby/pull/304 (@mrkn) 65 | 66 | ## Bug Fixes 67 | 68 | * Prevent unintentional display the result of Session#send (@mrkn) 69 | * Update display formatter for Gruff::Base to prevent warning (@mrkn) 70 | 71 | # 0.7.0 (2021-05-28) 72 | 73 | ## Enhancements 74 | 75 | * The default backend is changed to IRB (@mrkn) 76 | * Add IRuby::Kernel#switch_backend! method (@mrkn) 77 | 78 | ## Bug Fixes 79 | 80 | * Fix the handling of image/svg+xml https://github.com/SciRuby/iruby/pull/300, https://github.com/SciRuby/iruby/pull/301 (@kojix2) 81 | 82 | # 0.6.1 (2021-05-26) 83 | 84 | ## Bug Fixes 85 | 86 | * Follow the messages and hooks orders during execute_request processing (@mrkn) 87 | 88 | # 0.6.0 (2021-05-25) 89 | 90 | ## Bug Fixes 91 | 92 | * Fix the handling of application/javascript https://github.com/SciRuby/iruby/issues/292, https://github.com/SciRuby/iruby/pull/294 (@kylekyle, @mrkn) 93 | 94 | ## Enhancements 95 | 96 | * Add the `initialized` event in `IRuby::Kernel` class https://github.com/SciRuby/iruby/pull/168, https://github.com/SciRuby/iruby/pull/296 (@Yuki-Inoue, @mrkn) 97 | * Add the following four events https://github.com/SciRuby/iruby/pull/295 (@mrkn): 98 | * `pre-execute` -- occurs before every code execution 99 | * `pre-run-cell` -- occurs before every non-silent code execution 100 | * `post-execute` -- occurs after every code execution 101 | * `post-run-cell` -- occurs after every non-silent code execution 102 | * Replace Bond with IRB in PlainBackend https://github.com/SciRuby/iruby/pull/276, https://github.com/SciRuby/iruby/pull/297 (@cfis, @mrkn) 103 | 104 | # 0.5.0 (2021-03-25) 105 | 106 | ## Bug Fixes 107 | 108 | * Fix Jupyter console crashes issue https://github.com/SciRuby/iruby/pull/210 (@kojix2) 109 | * Fix syntax highlighting issue on Jpyter Lab https://github.com/SciRuby/iruby/issues/224 (@kojix2) 110 | * Fix interoperability issue with ruby-git https://github.com/SciRuby/iruby/pull/139 (@habemus-papadum) 111 | * Fix the issue of `$stderr.write` that cannot handle multiple arguments https://github.com/SciRuby/iruby/issues/206 (@kojix2) 112 | * Remove a buggy `inspect_request` implementation https://github.com/SciRuby/iruby/pull/119 (@LunarLanding) 113 | * Fix uninitialized constant `Fiddle` caused in initialization phase https://github.com/SciRuby/iruby/issues/264 (@MatthewSteen, @kjoix2) 114 | * Fix the issue on displaying a table https://github.com/SciRuby/iruby/pull/281 (@ankane) 115 | 116 | ## Enhancements 117 | 118 | * Add `IRuby.clear_output` method https://github.com/SciRuby/iruby/pull/220 (@kojix2) 119 | * Make backtrace on exception simplify and more appropriate for code in a cell https://github.com/SciRuby/iruby/pull/249 (@zheng-yongping) 120 | * Make syntax error message more appropriate https://github.com/SciRuby/iruby/pull/251 (@zheng-yongping) 121 | * Remove top-level `In` and `Out` constants https://github.com/SciRuby/iruby/pull/229 (@kojix2) 122 | * Use text/plain for the default format of `Numo::NArray` objects https://github.com/SciRuby/iruby/pull/255 (@kojix2) 123 | * Use ffi-rzmq as the default ZeroMQ adapter https://github.com/SciRuby/iruby/pull/256 (@kojix2) 124 | * Drop rbczmq support https://github.com/SciRuby/iruby/pull/260 (@rstammer) 125 | * Add ruby-vips image support https://github.com/SciRuby/iruby/pull/279 (@ankane) 126 | * Replace mimemagic with mime-types https://github.com/SciRuby/iruby/pull/291 (@mrkn) 127 | 128 | # 0.4.0 (2019-07-31) 129 | 130 | (TBD) 131 | 132 | # 0.3 (2017-03-26) 133 | 134 | ## Bug Fixes 135 | 136 | * Disable Jupyter keyboard manager for all popups made using IRuby.popup (@kylekyle). 137 | * Fix Iruby/Input date values bug that set date fields to whatever the last date value was (@kylekyle). 138 | * Fix a bug where time strings put into prompter would give an 'out of range' error (@kylekyle). 139 | 140 | ## Enhancements 141 | 142 | * Improvements to IRuby dependency detection using `Bundler::Dependencies#specs` (@kou). 143 | * Use less memory forcing pry to store only the last 3 commands in memory (@kylekyle). 144 | * Use bigger z-index that is used across all browsers (@kylekyle). 145 | * Ability to input date values as DateTime objects in IRuby/Input (@kylekyle). 146 | * Add option to have check boxes checked by default (@kylekyle). 147 | * Option for multi-select in drop down menus in the prompter (@kylekyle). 148 | * Add support for multiple widgets using `IRuby::Input::Multiple` (@kylekyle). 149 | * Calender icon for date selector icon (@kylekyle). 150 | * Add support for Numo/NArray (@zalt50). 151 | * Text now only completes after a space (@zalt50). 152 | * Remove the DONTWAIT flag when receiving a message (@cloud-oak). 153 | * Add support for CZTop (@kou). 154 | 155 | # 0.2.9 (2016-05-02) 156 | 157 | ## Bug Fixes 158 | 159 | * Fix an error where a NoMethodError was being raised where a table rendered using an Array of Hashes has more than `maxcols` columns. (@CGamesPlay) 160 | * Patch PryBackend to throw unterminated string and unexpected end-of-file syntax errors (@kylekyle) 161 | 162 | ## Enhnacements 163 | 164 | * Add an IRuby::Input class which provides widgets for getting inputs from users. (@kylekyle) 165 | * Add data_uri dependency (@kylekyle) 166 | * Added a clear_output display function (@mrkn) 167 | * Doc fixes for installation (@kozo2, @generall) 168 | 169 | # 0.2.8 (2015-12-06) 170 | 171 | * Add compatibility with ffi-rzmq 172 | * Windows support 173 | 174 | # 0.2.7 (2015-07-02) 175 | 176 | * Fix problem with autoloaded constants in Display, problem with sciruby gem 177 | 178 | # 0.2.6 (2015-06-21) 179 | 180 | * Check registered kernel and Gemfile to prevent gem loading problems 181 | * Support to_tex method for the rendering 182 | 183 | # 0.2.5 (2015-06-07) 184 | 185 | * Fix #29, empty signatures 186 | * Move iruby utils to IRuby::Utils module 187 | * Add IRuby.tex alias for IRuby.latex 188 | * Remove example notebooks from gem 189 | 190 | # 0.2.4 (2015-06-02) 191 | 192 | * Better exception handling 193 | * Fix ctrl-C issue #17 194 | * Fix timeout issue #19 195 | 196 | # 0.2.3 (2015-05-31) 197 | 198 | * Fix notebook indentation 199 | * Fix tab completion for multiple lines 200 | 201 | # 0.2.2 (2015-05-26) 202 | 203 | * Support history variables In, Out, _, _i etc 204 | * Internal refactoring and minor bugfixes 205 | 206 | # 0.2.1 (2015-05-26) 207 | 208 | * Copy Ruby logo to kernel specification 209 | 210 | # 0.2.0 (2015-05-25) 211 | 212 | * Dropped IPython2 support 213 | * Dropped Ruby < 2.0.0 support 214 | * Supports and requires now IPython3/Jupyter 215 | * Switch from ffi-rzmq to rbczmq 216 | * Added IRuby::Conn (experimental, to be used by widgets) 217 | * iruby register/unregister commands to register IRuby kernel in Jupyter 218 | 219 | # 0.1.13 (2014-08-19) 220 | 221 | * Improved IRuby.table, supports :maxrows and :maxcols 222 | * IRuby#javascript workaround (https://github.com/ipython/ipython/issues/6259) 223 | 224 | # 0.1.12 (2014-08-01) 225 | 226 | * IRuby#table add option maxrows 227 | * powerful display system with format and datatype registry, see #25 228 | * Add IRuby#javascript 229 | * Add IRuby#svg 230 | 231 | # 0.1.11 (2014-07-08) 232 | 233 | * Push binding if pry binding stack is empty 234 | 235 | # 0.1.10 (2014-07-08) 236 | 237 | * Fix #19 (pp) 238 | * Handle exception when symlink cannot be created 239 | * Fix dependencies and Pry backend 240 | 241 | # 0.1.9 (2014-02-28) 242 | 243 | * Check IPython version 244 | 245 | # 0.1.7/0.1.8 246 | 247 | * Bugfixes #11, #12, #13 248 | 249 | # 0.1.6 (2013-10-11) 250 | 251 | * Print Matrix and GSL::Matrix as LaTeX 252 | * Add check for Pry version 253 | 254 | # 0.1.5 (2013-10-03) 255 | 256 | * Implement a rich display system 257 | * Fix error output 258 | 259 | # 0.1.4 (2013-10-03) 260 | 261 | * Extract display handler from kernel 262 | * Always return a text/plain response 263 | 264 | # 0.1.3 (2013-10-03) 265 | 266 | * Implement missing request handlers 267 | * Detect if Bundler is running and set kernel_cmd appropriately 268 | * Improve Pry integration 269 | * Add support for the gems gnuplot, gruff, rmagick and mini_magick 270 | 271 | # 0.1.2 (2013-10-02) 272 | 273 | * Support for Pry added 274 | * Launch `iruby console` if plain `iruby` is started 275 | 276 | # 0.1.0 (2013-10-02) 277 | 278 | * Cleanup and rewrite of some parts 279 | -------------------------------------------------------------------------------- /lib/iruby/application.rb: -------------------------------------------------------------------------------- 1 | require "fileutils" 2 | require "json" 3 | require "optparse" 4 | require "rbconfig" 5 | require "singleton" 6 | 7 | require_relative "error" 8 | require_relative "kernel_app" 9 | 10 | module IRuby 11 | class Application 12 | include Singleton 13 | 14 | # Set the application instance up. 15 | def setup(argv=nil) 16 | @iruby_executable = File.expand_path($PROGRAM_NAME) 17 | parse_command_line(argv) 18 | end 19 | 20 | # Parse the command line arguments 21 | # 22 | # @param argv [Array, nil] The array of arguments. 23 | private def parse_command_line(argv) 24 | argv = ARGV.dup if argv.nil? 25 | @argv = argv # save the original 26 | 27 | case argv[0] 28 | when "help" 29 | # turn `iruby help notebook` into `iruby notebook -h` 30 | argv = [*argv[1..-1], "-h"] 31 | when "version" 32 | # turn `iruby version` into `iruby -v` 33 | argv = ["-v", *argv[1..-1]] 34 | else 35 | argv = argv.dup # prevent to break @argv 36 | end 37 | 38 | opts = OptionParser.new 39 | opts.program_name = "IRuby" 40 | opts.version = ::IRuby::VERSION 41 | opts.banner = "Usage: #{$PROGRAM_NAME} [options] [subcommand] [options]" 42 | 43 | opts.on_tail("-h", "--help") do 44 | print_help(opts) 45 | exit 46 | end 47 | 48 | opts.on_tail("-v", "--version") do 49 | puts opts.ver 50 | exit 51 | end 52 | 53 | opts.order!(argv) 54 | 55 | if argv.length == 0 || argv[0].start_with?("-") 56 | # If no subcommand is given, we use the console 57 | argv = ["console", *argv] 58 | end 59 | 60 | begin 61 | parse_sub_command(argv) if argv.length > 0 62 | rescue InvalidSubcommandError => err 63 | $stderr.puts err.message 64 | print_help(opts, $stderr) 65 | abort 66 | end 67 | end 68 | 69 | SUB_COMMANDS = { 70 | "register" => "Register IRuby kernel.", 71 | "unregister" => "Unregister the existing IRuby kernel.", 72 | "kernel" => "Launch IRuby kernel", 73 | "console" => "Launch jupyter console with IRuby kernel" 74 | }.freeze.each_value(&:freeze) 75 | 76 | private_constant :SUB_COMMANDS 77 | 78 | private def parse_sub_command(argv) 79 | sub_cmd, *sub_argv = argv 80 | case sub_cmd 81 | when *SUB_COMMANDS.keys 82 | @sub_cmd = sub_cmd.to_sym 83 | @sub_argv = sub_argv 84 | else 85 | raise InvalidSubcommandError.new(sub_cmd, sub_argv) 86 | end 87 | end 88 | 89 | private def print_help(opts, out=$stdout) 90 | out.puts opts.help 91 | out.puts 92 | out.puts "Subcommands" 93 | out.puts "===========" 94 | SUB_COMMANDS.each do |name, description| 95 | out.puts "#{name}" 96 | out.puts " #{description}" 97 | end 98 | end 99 | 100 | def run 101 | case @sub_cmd 102 | when :register 103 | register_kernel(@sub_argv) 104 | when :unregister 105 | unregister_kernel(@sub_argv) 106 | when :console 107 | exec_jupyter(@sub_cmd.to_s, @sub_argv) 108 | when :kernel 109 | @sub_app = KernelApplication.new(@sub_argv) 110 | @sub_app.run 111 | else 112 | raise "[IRuby][BUG] Unknown subcommand: #{@sub_cmd}; this must be treated in parse_command_line." 113 | end 114 | end 115 | 116 | ruby_version_info = RUBY_VERSION.split('.') 117 | DEFAULT_KERNEL_NAME = "ruby#{ruby_version_info[0]}".freeze 118 | DEFAULT_DISPLAY_NAME = "Ruby #{ruby_version_info[0]} (iruby kernel)" 119 | 120 | RegisterParams = Struct.new( 121 | :name, 122 | :display_name, 123 | :profile, 124 | :env, 125 | :user, 126 | :prefix, 127 | :sys_prefix, 128 | :force, 129 | :ipython_dir 130 | ) do 131 | def initialize(*args) 132 | super 133 | self.name ||= DEFAULT_KERNEL_NAME 134 | self.force = false 135 | self.user = true 136 | end 137 | end 138 | 139 | def register_kernel(argv) 140 | params = parse_register_command_line(argv) 141 | 142 | if params.name != DEFAULT_KERNEL_NAME 143 | # `--name` is specified and `--display-name` is not 144 | # default `params.display_name` to `params.name` 145 | params.display_name ||= params.name 146 | end 147 | 148 | check_and_warn_kernel_in_default_ipython_directory(params) 149 | 150 | if installed_kernel_exist?(params.name, params.ipython_dir) 151 | unless params.force 152 | $stderr.puts "IRuby kernel named `#{params.name}` already exists!" 153 | $stderr.puts "Use --force to force register the new kernel." 154 | exit 1 155 | end 156 | end 157 | 158 | Dir.mktmpdir("iruby_kernel") do |tmpdir| 159 | path = File.join(tmpdir, DEFAULT_KERNEL_NAME) 160 | FileUtils.mkdir_p(path) 161 | 162 | # Stage assets 163 | assets_dir = File.expand_path("../assets", __FILE__) 164 | FileUtils.cp_r(Dir.glob(File.join(assets_dir, "*")), path) 165 | 166 | kernel_dict = { 167 | "argv" => make_iruby_cmd(), 168 | "display_name" => params.display_name || DEFAULT_DISPLAY_NAME, 169 | "language" => "ruby", 170 | "metadata" => {"debugger": false} 171 | } 172 | 173 | # TODO: Support params.profile 174 | # TODO: Support params.env 175 | 176 | kernel_content = JSON.pretty_generate(kernel_dict) 177 | File.write(File.join(path, "kernel.json"), kernel_content) 178 | 179 | args = ["--name=#{params.name}"] 180 | args << "--user" if params.user 181 | args << path 182 | 183 | # TODO: Support params.prefix 184 | # TODO: Support params.sys_prefix 185 | 186 | system("jupyter", "kernelspec", "install", *args) 187 | end 188 | end 189 | 190 | # Warn the existence of the IRuby kernel in the default IPython's kernels directory 191 | private def check_and_warn_kernel_in_default_ipython_directory(params) 192 | default_ipython_kernels_dir = File.expand_path("~/.ipython/kernels") 193 | [params.name, "ruby"].each do |name| 194 | if File.exist?(File.join(default_ipython_kernels_dir, name, "kernel.json")) 195 | warn "IRuby kernel `#{name}` already exists in the deprecated IPython's data directory." 196 | end 197 | end 198 | end 199 | 200 | alias __system__ system 201 | 202 | private def system(*cmdline, dry_run: false) 203 | $stderr.puts "EXECUTE: #{cmdline.map {|x| x.include?(' ') ? x.inspect : x}.join(' ')}" 204 | __system__(*cmdline) unless dry_run 205 | end 206 | 207 | private def installed_kernel_exist?(name, ipython_dir) 208 | kernels_dir = resolve_kernelspec_dir(ipython_dir) 209 | kernel_dir = File.join(kernels_dir, name) 210 | File.file?(File.join(kernel_dir, "kernel.json")) 211 | end 212 | 213 | private def resolve_kernelspec_dir(ipython_dir) 214 | if ENV.has_key?("JUPYTER_DATA_DIR") 215 | if ENV.has_key?("IPYTHONDIR") 216 | warn "both JUPYTER_DATA_DIR and IPYTHONDIR are supplied; IPYTHONDIR is ignored." 217 | end 218 | jupyter_data_dir = ENV["JUPYTER_DATA_DIR"] 219 | return File.join(jupyter_data_dir, "kernels") 220 | end 221 | 222 | if ipython_dir.nil? && ENV.key?("IPYTHONDIR") 223 | warn 'IPYTHONDIR is deprecated. Use JUPYTER_DATA_DIR instead.' 224 | ipython_dir = ENV["IPYTHONDIR"] 225 | end 226 | 227 | if ipython_dir 228 | File.join(ipython_dir, 'kernels') 229 | else 230 | Jupyter.kernelspec_dir 231 | end 232 | end 233 | 234 | private def make_iruby_cmd(executable: nil, extra_arguments: nil) 235 | executable ||= default_executable 236 | extra_arguments ||= [] 237 | [*Array(executable), "kernel", "-f", "{connection_file}", *extra_arguments] 238 | end 239 | 240 | private def default_executable 241 | [RbConfig.ruby, @iruby_executable] 242 | end 243 | 244 | private def parse_register_command_line(argv) 245 | opts = OptionParser.new 246 | opts.banner = "Usage: #{$PROGRAM_NAME} register [options]" 247 | 248 | params = RegisterParams.new 249 | 250 | opts.on( 251 | "--force", 252 | "Force register a new kernel spec. The existing kernel spec will be removed." 253 | ) { params.force = true } 254 | 255 | opts.on( 256 | "--user", 257 | "Register for the current user instead of system-wide." 258 | ) { params.user = true } 259 | 260 | opts.on( 261 | "--name=VALUE", String, 262 | "Specify a name for the kernelspec. This is needed to have multiple IRuby kernels at the same time." 263 | ) {|v| params.name = v } 264 | 265 | opts.on( 266 | "--display-name=VALUE", String, 267 | "Specify the display name for the kernelspec. This is helpful when you have multiple IRuby kernels." 268 | ) {|v| params.display_name = v } 269 | 270 | # TODO: --profile 271 | # TODO: --prefix 272 | # TODO: --sys-prefix 273 | # TODO: --env 274 | 275 | define_ipython_dir_option(opts, params) 276 | 277 | opts.order!(argv) 278 | 279 | params 280 | end 281 | 282 | UnregisterParams = Struct.new( 283 | :names, 284 | #:profile, 285 | #:user, 286 | #:prefix, 287 | #:sys_prefix, 288 | :ipython_dir, 289 | :force, 290 | :yes 291 | ) do 292 | def initialize(*args, **kw) 293 | super 294 | self.names = [] 295 | # self.user = true 296 | self.force = false 297 | self.yes = false 298 | end 299 | end 300 | 301 | def unregister_kernel(argv) 302 | params = parse_unregister_command_line(argv) 303 | opts = [] 304 | opts << "-y" if params.yes 305 | opts << "-f" if params.force 306 | system("jupyter", "kernelspec", "uninstall", *opts, *params.names) 307 | end 308 | 309 | private def parse_unregister_command_line(argv) 310 | opts = OptionParser.new 311 | opts.banner = "Usage: #{$PROGRAM_NAME} unregister [options] NAME [NAME ...]" 312 | 313 | params = UnregisterParams.new 314 | 315 | opts.on( 316 | "-f", "--force", 317 | "Force removal, don't prompt for confirmation." 318 | ) { params.force = true} 319 | 320 | opts.on( 321 | "-y", "--yes", 322 | "Answer yes to any prompts." 323 | ) { params.yes = true } 324 | 325 | # TODO: --user 326 | # TODO: --profile 327 | # TODO: --prefix 328 | # TODO: --sys-prefix 329 | 330 | define_ipython_dir_option(opts, params) 331 | 332 | opts.order!(argv) 333 | 334 | params.names = argv.dup 335 | 336 | params 337 | end 338 | 339 | def exec_jupyter(sub_cmd, argv) 340 | opts = OptionParser.new 341 | opts.banner = "Usage: #{$PROGRAM_NAME} unregister [options]" 342 | 343 | kernel_name = resolve_installed_kernel_name(DEFAULT_KERNEL_NAME) 344 | opts.on( 345 | "--kernel=NAME", String, 346 | "The name of the default kernel to start." 347 | ) {|v| kernel_name = v } 348 | 349 | opts.order!(argv) 350 | 351 | opts = ["--kernel=#{kernel_name}"] 352 | exec("jupyter", "console", *opts) 353 | end 354 | 355 | private def resolve_installed_kernel_name(default_name) 356 | kernels = IO.popen(["jupyter", "kernelspec", "list", "--json"], "r", err: File::NULL) do |jupyter_out| 357 | JSON.load(jupyter_out.read) 358 | end 359 | unless kernels["kernelspecs"].key?(default_name) 360 | return "ruby" if kernels["kernelspecs"].key?("ruby") 361 | end 362 | default_name 363 | end 364 | 365 | private def define_ipython_dir_option(opts, params) 366 | opts.on( 367 | "--ipython-dir=DIR", String, 368 | "Specify the IPython's data directory (DEPRECATED)." 369 | ) do |v| 370 | if ENV.key?("JUPYTER_DATA_DIR") 371 | warn 'Both JUPYTER_DATA_DIR and --ipython-dir are supplied; --ipython-dir is ignored.' 372 | else 373 | warn '--ipython-dir is deprecated. Use JUPYTER_DATA_DIR environment variable instead.' 374 | end 375 | 376 | params.ipython_dir = v 377 | end 378 | end 379 | end 380 | end 381 | -------------------------------------------------------------------------------- /lib/iruby/display.rb: -------------------------------------------------------------------------------- 1 | require "set" 2 | 3 | module IRuby 4 | module Display 5 | DEFAULT_MIME_TYPE_FORMAT_METHODS = { 6 | "text/html" => :to_html, 7 | "text/markdown" => :to_markdown, 8 | "image/svg+xml" => :to_svg, 9 | "image/png" => :to_png, 10 | "application/pdf" => :to_pdf, 11 | "image/jpeg" => :to_jpeg, 12 | "text/latex" => [:to_latex, :to_tex], 13 | # NOTE: Do not include the entry of "application/json" because 14 | # all objects can respond to `to_json` due to json library 15 | # "application/json" => :to_json, 16 | "application/javascript" => :to_javascript, 17 | nil => :to_iruby, 18 | "text/plain" => :inspect 19 | }.freeze 20 | 21 | class << self 22 | # @private 23 | def convert(obj, options) 24 | Representation.new(obj, options) 25 | end 26 | 27 | # @private 28 | def display(obj, options = {}) 29 | obj = convert(obj, options) 30 | options = obj.options 31 | obj = obj.object 32 | 33 | fuzzy_mime = options[:format] # Treated like a fuzzy mime type 34 | unless !fuzzy_mime || String === fuzzy_mime 35 | raise 'Invalid argument :format' 36 | end 37 | 38 | if exact_mime = options[:mime] 39 | raise 'Invalid argument :mime' unless String === exact_mime 40 | raise 'Invalid mime type' unless exact_mime.include?('/') 41 | end 42 | 43 | data = if obj.respond_to?(:to_iruby_mimebundle) 44 | render_mimebundle(obj, exact_mime, fuzzy_mime) 45 | else 46 | {} 47 | end 48 | 49 | # Render by additional formatters 50 | render_by_registry(data, obj, exact_mime, fuzzy_mime) 51 | 52 | # Render by to_xxx methods 53 | default_renderers = if obj.respond_to?(:to_iruby_mimebundle) 54 | # Do not use Hash#slice for Ruby < 2.5 55 | {"text/plain" => DEFAULT_MIME_TYPE_FORMAT_METHODS["text/plain"]} 56 | else 57 | DEFAULT_MIME_TYPE_FORMAT_METHODS 58 | end 59 | default_renderers.each do |mime, methods| 60 | next if mime.nil? && !data.empty? # for to_iruby 61 | 62 | next if mime && data.key?(mime) # do not overwrite 63 | 64 | method = Array(methods).find {|m| obj.respond_to?(m) } 65 | next if method.nil? 66 | 67 | result = obj.send(method) 68 | case mime 69 | when nil # to_iruby 70 | case result 71 | when nil 72 | # do nothing 73 | next 74 | when Array 75 | mime, result = result 76 | else 77 | warn(("Ignore the result of to_iruby method of %p because " + 78 | "it does not return a pair of mime-type and formatted representation") % obj) 79 | next 80 | end 81 | end 82 | data[mime] = result 83 | end 84 | 85 | # As a last resort, interpret string representation of the object 86 | # as the given mime type. 87 | if exact_mime && !data.key?(exact_mime) 88 | data[exact_mime] = protect(exact_mime, obj) 89 | end 90 | 91 | data 92 | end 93 | 94 | # @private 95 | def clear_output(wait = false) 96 | IRuby::Kernel.instance.session.send(:publish, :clear_output, wait: wait) 97 | end 98 | 99 | private 100 | 101 | def protect(mime, data) 102 | ascii?(mime) ? data.to_s : [data.to_s].pack('m0') 103 | end 104 | 105 | # Each of the following mime types must be a text type, 106 | # but mime-types library tells us it is a non-text type. 107 | FORCE_TEXT_TYPES = Set[ 108 | "application/javascript", 109 | "image/svg+xml" 110 | ].freeze 111 | 112 | def ascii?(mime) 113 | if FORCE_TEXT_TYPES.include?(mime) 114 | true 115 | else 116 | MIME::Type.new("content-type" => mime).ascii? 117 | end 118 | end 119 | 120 | private def render_mimebundle(obj, exact_mime, fuzzy_mime) 121 | data = {} 122 | include_mime = [exact_mime].compact 123 | formats, _metadata = obj.to_iruby_mimebundle(include: include_mime) 124 | formats.each do |mime, value| 125 | if fuzzy_mime.nil? || mime.include?(fuzzy_mime) 126 | data[mime] = value 127 | end 128 | end 129 | data 130 | end 131 | 132 | private def render_by_registry(data, obj, exact_mime, fuzzy_mime) 133 | # Filter matching renderer by object type 134 | renderer = Registry.renderer.select { |r| r.match?(obj) } 135 | 136 | matching_renderer = nil 137 | 138 | # Find exactly matching display by exact_mime 139 | if exact_mime 140 | matching_renderer = renderer.find { |r| exact_mime == r.mime } 141 | end 142 | 143 | # Find fuzzy matching display by fuzzy_mime 144 | if fuzzy_mime 145 | matching_renderer ||= renderer.find { |r| r.mime&.include?(fuzzy_mime) } 146 | end 147 | 148 | renderer.unshift matching_renderer if matching_renderer 149 | 150 | # Return first render result which has the right mime type 151 | renderer.each do |r| 152 | mime, result = r.render(obj) 153 | next if data.key?(mime) 154 | 155 | if mime && result && (!exact_mime || exact_mime == mime) && (!fuzzy_mime || mime.include?(fuzzy_mime)) 156 | data[mime] = protect(mime, result) 157 | break 158 | end 159 | end 160 | 161 | nil 162 | end 163 | end 164 | 165 | private def render_by_to_iruby(data, obj) 166 | if obj.respond_to?(:to_iruby) 167 | result = obj.to_iruby 168 | mime, rep = case result 169 | when Array 170 | result 171 | else 172 | [nil, result] 173 | end 174 | data[mime] = rep 175 | end 176 | end 177 | 178 | class Representation 179 | attr_reader :object, :options 180 | 181 | def initialize(object, options) 182 | @object = object 183 | @options = options 184 | end 185 | 186 | class << self 187 | alias old_new new 188 | 189 | def new(obj, options) 190 | options = { format: options } if String === options 191 | if Representation === obj 192 | options = obj.options.merge(options) 193 | obj = obj.object 194 | end 195 | old_new(obj, options) 196 | end 197 | end 198 | end 199 | 200 | class FormatMatcher 201 | def initialize(&block) 202 | @block = block 203 | end 204 | 205 | def call(obj) 206 | @block.(obj) 207 | end 208 | 209 | def inspect 210 | "#{self.class.name}[%p]" % @block 211 | end 212 | end 213 | 214 | class RespondToFormatMatcher < FormatMatcher 215 | def initialize(name) 216 | super() {|obj| obj.respond_to?(name) } 217 | @name = name 218 | end 219 | 220 | attr_reader :name 221 | 222 | def inspect 223 | "#{self.class.name}[respond_to?(%p)]" % name 224 | end 225 | end 226 | 227 | class TypeFormatMatcher < FormatMatcher 228 | def initialize(class_block) 229 | super() do |obj| 230 | begin 231 | self.klass === obj 232 | # We have to rescue all exceptions since constant autoloading could fail with a different error 233 | rescue Exception 234 | false 235 | end 236 | end 237 | @class_block = class_block 238 | end 239 | 240 | def klass 241 | @class_block.() 242 | end 243 | 244 | def inspect 245 | klass = begin 246 | @class_block.() 247 | rescue Exception 248 | @class_block 249 | end 250 | "#{self.class.name}[%p]" % klass 251 | end 252 | end 253 | 254 | class Renderer 255 | attr_reader :match, :mime, :priority 256 | 257 | def initialize(match, mime, render, priority) 258 | @match = match 259 | @mime = mime 260 | @render = render 261 | @priority = priority 262 | end 263 | 264 | def match?(obj) 265 | @match.call(obj) 266 | end 267 | 268 | def render(obj) 269 | result = @render.call(obj) 270 | Array === result ? result : [@mime, result] 271 | end 272 | end 273 | 274 | module Registry 275 | extend self 276 | 277 | def renderer 278 | @renderer ||= [] 279 | end 280 | 281 | def match(&block) 282 | @match = FormatMatcher.new(&block) 283 | priority 0 284 | nil 285 | end 286 | 287 | def respond_to(name) 288 | @match = RespondToFormatMatcher.new(name) 289 | priority 0 290 | nil 291 | end 292 | 293 | def type(&block) 294 | @match = TypeFormatMatcher.new(block) 295 | priority 0 296 | nil 297 | end 298 | 299 | def priority(p) 300 | @priority = p 301 | nil 302 | end 303 | 304 | def format(mime = nil, &block) 305 | renderer << Renderer.new(@match, mime, block, @priority) 306 | renderer.sort_by! { |r| -r.priority } 307 | 308 | # Decrease priority implicitly for all formats 309 | # which are added later for a type. 310 | # Overwrite with the `priority` method! 311 | @priority -= 1 312 | nil 313 | end 314 | 315 | type { NMatrix } 316 | format 'text/latex' do |obj| 317 | obj.dim == 2 ? 318 | LaTeX.matrix(obj, obj.shape[0], obj.shape[1]) : 319 | LaTeX.vector(obj.to_a) 320 | end 321 | 322 | type { Numo::NArray } 323 | format 'text/plain', &:inspect 324 | format 'text/latex' do |obj| 325 | obj.ndim == 2 ? 326 | LaTeX.matrix(obj, obj.shape[0], obj.shape[1]) : 327 | LaTeX.vector(obj.to_a) 328 | end 329 | format 'text/html' do |obj| 330 | HTML.table(obj.to_a) 331 | end 332 | 333 | type { NArray } 334 | format 'text/plain', &:inspect 335 | format 'text/latex' do |obj| 336 | obj.dim == 2 ? 337 | LaTeX.matrix(obj.transpose(1, 0), obj.shape[1], obj.shape[0]) : 338 | LaTeX.vector(obj.to_a) 339 | end 340 | format 'text/html' do |obj| 341 | HTML.table(obj.to_a) 342 | end 343 | 344 | type { Matrix } 345 | format 'text/latex' do |obj| 346 | LaTeX.matrix(obj, obj.row_size, obj.column_size) 347 | end 348 | format 'text/html' do |obj| 349 | HTML.table(obj.to_a) 350 | end 351 | 352 | type { GSL::Matrix } 353 | format 'text/latex' do |obj| 354 | LaTeX.matrix(obj, obj.size1, obj.size2) 355 | end 356 | format 'text/html' do |obj| 357 | HTML.table(obj.to_a) 358 | end 359 | 360 | type { GSL::Vector } 361 | format 'text/latex' do |obj| 362 | LaTeX.vector(obj.to_a) 363 | end 364 | format 'text/html' do |obj| 365 | HTML.table(obj.to_a) 366 | end 367 | 368 | type { GSL::Complex } 369 | format 'text/latex' do |obj| 370 | "$#{obj.re}+#{obj.im}\\imath$" 371 | end 372 | 373 | type { Complex } 374 | format 'text/latex' do |obj| 375 | "$#{obj.real}+#{obj.imag}\\imath$" 376 | end 377 | 378 | type { Gnuplot::Plot } 379 | format 'image/svg+xml' do |obj| 380 | Tempfile.open('plot') do |f| 381 | terminal = obj['terminal'].to_s.split(' ') 382 | terminal[0] = 'svg' 383 | terminal << 'enhanced' unless terminal.include?('noenhanced') 384 | obj.terminal terminal.join(' ') 385 | obj.output f.path 386 | Gnuplot.open do |io| 387 | io << obj.to_gplot 388 | io << obj.store_datasets 389 | end 390 | File.read(f.path) 391 | end 392 | end 393 | 394 | format_magick_image = ->(obj) do 395 | format = obj.format || 'PNG' 396 | [ 397 | format == 'PNG' ? 'image/png' : 'image/jpeg', 398 | obj.to_blob {|i| i.format = format } 399 | ] 400 | end 401 | 402 | match do |obj| 403 | defined?(Magick::Image) && Magick::Image === obj || 404 | defined?(MiniMagick::Image) && MiniMagick::Image === obj 405 | end 406 | format 'image', &format_magick_image 407 | 408 | type { Gruff::Base } 409 | format 'image' do |obj| 410 | format_magick_image.(obj.to_image) 411 | end 412 | 413 | match do |obj| 414 | defined?(Vips::Image) && Vips::Image === obj 415 | end 416 | format do |obj| 417 | # handles Vips::Error, vips_image_get: field "vips-loader" not found 418 | loader = obj.get('vips-loader') rescue nil 419 | if loader == 'jpegload' 420 | ['image/jpeg', obj.write_to_buffer('.jpg')] 421 | else 422 | # falls back to png for other/unknown types 423 | ['image/png', obj.write_to_buffer('.png')] 424 | end 425 | end 426 | 427 | type { Rubyvis::Mark } 428 | format 'image/svg+xml' do |obj| 429 | obj.render 430 | obj.to_svg 431 | end 432 | 433 | match { |obj| obj.respond_to?(:path) && obj.method(:path).arity == 0 && File.readable?(obj.path) } 434 | format do |obj| 435 | mime = MIME::Types.of(obj.path).first.to_s 436 | if mime && DEFAULT_MIME_TYPE_FORMAT_METHODS.key?(mime) 437 | [mime, File.read(obj.path)] 438 | end 439 | end 440 | end 441 | end 442 | end 443 | -------------------------------------------------------------------------------- /lib/iruby/input/README.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# IRuby Input\n", 8 | "\n", 9 | "The `IRuby::Input` class makes it easier for IRuby users to get input from users. For example:" 10 | ] 11 | }, 12 | { 13 | "cell_type": "code", 14 | "execution_count": null, 15 | "metadata": { 16 | "collapsed": false 17 | }, 18 | "outputs": [], 19 | "source": [ 20 | "name = IRuby.input 'Enter your name'" 21 | ] 22 | }, 23 | { 24 | "cell_type": "markdown", 25 | "metadata": {}, 26 | "source": [ 27 | "The following input methods are supported on the `IRuby` module:\n", 28 | "\n", 29 | "| method | description |\n", 30 | "| -------- | -------- |\n", 31 | "| `input(prompt)` | Prompts the user for a line of input |\n", 32 | "| `password(prompt)` | Prompts the user for a password |\n", 33 | "| `form(&block)` | Presents a form to the user |\n", 34 | "| `popup(title,&block)` | Displays a form to the user as a popup |" 35 | ] 36 | }, 37 | { 38 | "cell_type": "markdown", 39 | "metadata": {}, 40 | "source": [ 41 | "## Forms\n", 42 | "\n", 43 | "Forms are groups of inputs to be collected from the user. For example:" 44 | ] 45 | }, 46 | { 47 | "cell_type": "code", 48 | "execution_count": null, 49 | "metadata": { 50 | "collapsed": false 51 | }, 52 | "outputs": [], 53 | "source": [ 54 | "result = IRuby.form do \n", 55 | " input :username\n", 56 | " password :password\n", 57 | " button\n", 58 | "end" 59 | ] 60 | }, 61 | { 62 | "cell_type": "markdown", 63 | "metadata": {}, 64 | "source": [ 65 | "The following methods are available to build forms: \n", 66 | "\n", 67 | "| method | description |\n", 68 | "| -------- | -------- |\n", 69 | "| `input(key=:input)` | Prompts the user for a line of input |\n", 70 | "| `textarea(key=:textarea),` | Adds a textarea to the form |\n", 71 | "| `password(key=:password)` | Prompts the user for a password |\n", 72 | "| `button(key=:done, color: :blue)` | Adds a submit button to the form |\n", 73 | "| `cancel(prompt='Cancel')` | Adds a cancel button to the form |\n", 74 | "| `text(string)` | Adds text to the form |\n", 75 | "| `html(&block)` | Inserts HTML from the given [erector block](https://github.com/erector/erector) |\n", 76 | "| `file(key=:file)` | Adds a file input to the form |\n", 77 | "| `date(key=:date)` | Adds a date picker to the form |\n", 78 | "| `select(*options)` | Adds a dropdown select input to the form |\n", 79 | "| `radio(*options)` | Adds a radio select input to the form |\n", 80 | "| `checkbox(*options)` | Adds checkbox inputs to the form |" 81 | ] 82 | }, 83 | { 84 | "cell_type": "markdown", 85 | "metadata": {}, 86 | "source": [ 87 | "## Popups\n", 88 | " \n", 89 | "Popups are just forms in a bootstrap modal. They are useful when users **Run All** in a notebook with a lot of inputs. The popups always appear in the same spot, so users don't have to scroll down to find the next input. \n", 90 | "\n", 91 | "Popups accept a `title` argument, for example: " 92 | ] 93 | }, 94 | { 95 | "cell_type": "code", 96 | "execution_count": null, 97 | "metadata": { 98 | "collapsed": false 99 | }, 100 | "outputs": [], 101 | "source": [ 102 | "result = IRuby.popup 'Please enter your name' do \n", 103 | " input\n", 104 | " button\n", 105 | "end" 106 | ] 107 | }, 108 | { 109 | "cell_type": "markdown", 110 | "metadata": {}, 111 | "source": [ 112 | "## Submit and cancel\n", 113 | "\n", 114 | "The enter key will submit an input or form and the escape key will cancel it. Canceled inputs are returned as `nil`. Inputs are automatically canceled if destroyed. An input can be destroyed by clearing its cell's output. The `cancel` button will cancel a form and all other buttons will submit it. \n", 115 | "\n", 116 | "After a form destroyed, the cell's output is cleared. Be careful not to prompt for input in a block that has previous output you would like to keep. Output is cleared to prevent forms from interfering with one another and to ensure that inputs are not inadvertently saved to the notebook. " 117 | ] 118 | }, 119 | { 120 | "cell_type": "code", 121 | "execution_count": null, 122 | "metadata": { 123 | "collapsed": false 124 | }, 125 | "outputs": [], 126 | "source": [ 127 | "result = IRuby.popup 'Confirm' do \n", 128 | " text 'Are you sure you want to continue?'\n", 129 | " cancel 'No'\n", 130 | " button 'Yes'\n", 131 | "end" 132 | ] 133 | }, 134 | { 135 | "cell_type": "markdown", 136 | "metadata": {}, 137 | "source": [ 138 | "## Custom keys\n", 139 | "\n", 140 | "Every widget has an entry in the final results hash. A custom key can be passed as the first parameter to the hash. If no key is provided, the widget name is used as the key. The `cancel` widget has no key; it's first parameter is its label. " 141 | ] 142 | }, 143 | { 144 | "cell_type": "code", 145 | "execution_count": null, 146 | "metadata": { 147 | "collapsed": false 148 | }, 149 | "outputs": [], 150 | "source": [ 151 | "result = IRuby.form do\n", 152 | " input :username\n", 153 | " password :password\n", 154 | "end" 155 | ] 156 | }, 157 | { 158 | "cell_type": "markdown", 159 | "metadata": {}, 160 | "source": [ 161 | "## Custom labels\n", 162 | "\n", 163 | "Field labels appear to the left of the field. Button labels appear as the text on the button. `cancel` labels are passed as the first argument. All other widgets' labels are set using the `label` parameter. " 164 | ] 165 | }, 166 | { 167 | "cell_type": "code", 168 | "execution_count": null, 169 | "metadata": { 170 | "collapsed": false 171 | }, 172 | "outputs": [], 173 | "source": [ 174 | "result = IRuby.form do \n", 175 | " input :name, label: 'Please enter your name'\n", 176 | " cancel 'None of your business!'\n", 177 | " button :submit, label: 'All done'\n", 178 | "end" 179 | ] 180 | }, 181 | { 182 | "cell_type": "markdown", 183 | "metadata": {}, 184 | "source": [ 185 | "## Defaults\n", 186 | "\n", 187 | "Most inputs will accept a `default` parameter. If no default is given, the default is `nil`. Since checkboxes can have multiple values selected, you can pass an array of values. To check everything, pass `true` as the default. " 188 | ] 189 | }, 190 | { 191 | "cell_type": "code", 192 | "execution_count": null, 193 | "metadata": { 194 | "collapsed": false 195 | }, 196 | "outputs": [], 197 | "source": [ 198 | "result = IRuby.form do \n", 199 | " checkbox :one, 'Fish', 'Cat', 'Dog', default: 'Fish'\n", 200 | " checkbox :many, 'Fish', 'Cat', 'Dog', default: ['Cat', 'Dog']\n", 201 | " checkbox :all, 'Fish', 'Cat', 'Dog', default: true\n", 202 | " button :submit, label: 'All done'\n", 203 | "end" 204 | ] 205 | }, 206 | { 207 | "cell_type": "markdown", 208 | "metadata": {}, 209 | "source": [ 210 | "## Dates\n", 211 | "\n", 212 | "The `date` widget provides a calendar popup and returns a `Time` object. It's default should also be a `Time` object. " 213 | ] 214 | }, 215 | { 216 | "cell_type": "code", 217 | "execution_count": null, 218 | "metadata": { 219 | "collapsed": false 220 | }, 221 | "outputs": [], 222 | "source": [ 223 | "result = IRuby.form do \n", 224 | " date :birthday\n", 225 | " date :today, default: Time.now\n", 226 | " button\n", 227 | "end" 228 | ] 229 | }, 230 | { 231 | "cell_type": "markdown", 232 | "metadata": {}, 233 | "source": [ 234 | "## Buttons\n", 235 | "\n", 236 | "Buttons do not appear in the final hash unless they are clicked. If clicked, their value is `true`. Here are the various colors a button can be:" 237 | ] 238 | }, 239 | { 240 | "cell_type": "code", 241 | "execution_count": null, 242 | "metadata": { 243 | "collapsed": false 244 | }, 245 | "outputs": [], 246 | "source": [ 247 | "result = IRuby.form do \n", 248 | " IRuby::Input::Button::COLORS.each_key do |color|\n", 249 | " button color, color: color\n", 250 | " end\n", 251 | "end" 252 | ] 253 | }, 254 | { 255 | "cell_type": "markdown", 256 | "metadata": {}, 257 | "source": [ 258 | "## Textareas\n", 259 | "\n", 260 | "Textareas are multiline inputs that are convenient for larger inputs. If you need a line return when typing in a textarea, use shift+enter since enter will submit the form." 261 | ] 262 | }, 263 | { 264 | "cell_type": "code", 265 | "execution_count": null, 266 | "metadata": { 267 | "collapsed": false 268 | }, 269 | "outputs": [], 270 | "source": [ 271 | "result = IRuby.form do \n", 272 | " text 'Enter email addresses, one per line (use shift+enter for newlines)'\n", 273 | " textarea :emails\n", 274 | "end" 275 | ] 276 | }, 277 | { 278 | "cell_type": "markdown", 279 | "metadata": {}, 280 | "source": [ 281 | "## Text and HTML\n", 282 | "\n", 283 | "You can insert lines of text or custom html using their respective helpers:" 284 | ] 285 | }, 286 | { 287 | "cell_type": "code", 288 | "execution_count": null, 289 | "metadata": { 290 | "collapsed": false 291 | }, 292 | "outputs": [], 293 | "source": [ 294 | "result = IRuby.form do \n", 295 | " html { h1 'Choose a Stooge' }\n", 296 | " text 'Choose your favorite stooge'\n", 297 | " select :stooge, 'Moe', 'Larry', 'Curly'\n", 298 | " button\n", 299 | "end" 300 | ] 301 | }, 302 | { 303 | "cell_type": "markdown", 304 | "metadata": {}, 305 | "source": [ 306 | "## Dropdowns\n", 307 | "\n", 308 | "A `select` is a dropdown of options. Use a `multiple` to allow multiple selections. `multiple` widgets accept an additional `size` parameters that determines the number of rows. The default is 4. " 309 | ] 310 | }, 311 | { 312 | "cell_type": "code", 313 | "execution_count": null, 314 | "metadata": { 315 | "collapsed": false 316 | }, 317 | "outputs": [], 318 | "source": [ 319 | "result = IRuby.form do \n", 320 | " select :stooge, 'Moe', 'Larry', 'Curly'\n", 321 | " select :stooge, 'Moe', 'Larry', 'Curly', default: 'Moe'\n", 322 | " multiple :stooges, 'Moe', 'Larry', 'Curly', default: true, size: 3\n", 323 | " multiple :stooges, 'Moe', 'Larry', 'Curly', default: ['Moe','Curly']\n", 324 | " button\n", 325 | "end" 326 | ] 327 | }, 328 | { 329 | "cell_type": "markdown", 330 | "metadata": {}, 331 | "source": [ 332 | "## Radio selects and checkboxes\n", 333 | "\n", 334 | "Like selects, radio selects and checkboxes take multiple arguments, each one an option. If the first argument is a symbol, it is used as the key. \n", 335 | "\n", 336 | "Note that the `checkbox` widget will always return `nil` or an array. " 337 | ] 338 | }, 339 | { 340 | "cell_type": "code", 341 | "execution_count": null, 342 | "metadata": { 343 | "collapsed": false 344 | }, 345 | "outputs": [], 346 | "source": [ 347 | "result = IRuby.form do \n", 348 | " radio :children, *(0..12), label: 'How many children do you have?'\n", 349 | " checkbox :gender, 'Male', 'Female', 'Intersex', label: 'Select the genders of your children'\n", 350 | " button\n", 351 | "end" 352 | ] 353 | }, 354 | { 355 | "cell_type": "markdown", 356 | "metadata": {}, 357 | "source": [ 358 | "## Files\n", 359 | "\n", 360 | "Since file widgets capture the enter key, you should include a button when creating forms that contain only a file input:" 361 | ] 362 | }, 363 | { 364 | "cell_type": "code", 365 | "execution_count": null, 366 | "metadata": { 367 | "collapsed": true 368 | }, 369 | "outputs": [], 370 | "source": [ 371 | "IRuby.form do \n", 372 | " file :avatar, label: 'Choose an Avatar'\n", 373 | " button :submit\n", 374 | "end" 375 | ] 376 | }, 377 | { 378 | "cell_type": "markdown", 379 | "metadata": {}, 380 | "source": [ 381 | "File widgets return a hash with three keys: \n", 382 | "\n", 383 | "* `data`: The contents of the file as a string\n", 384 | "* `content_type`: The type of file, such as `text/plain` or `image/jpeg`\n", 385 | "* `name`: The name of the uploaded file" 386 | ] 387 | }, 388 | { 389 | "cell_type": "markdown", 390 | "metadata": {}, 391 | "source": [ 392 | "## Example\n", 393 | "\n", 394 | "Here is an example form that uses every built-in widget. " 395 | ] 396 | }, 397 | { 398 | "cell_type": "code", 399 | "execution_count": null, 400 | "metadata": { 401 | "collapsed": false, 402 | "scrolled": false 403 | }, 404 | "outputs": [], 405 | "source": [ 406 | "result = IRuby.form do \n", 407 | " html { h1 'The Everything Form' }\n", 408 | " text 'Marvel at the strange and varied inputs!'\n", 409 | " date\n", 410 | " file\n", 411 | " input :username\n", 412 | " password\n", 413 | " textarea\n", 414 | " radio *(1..10)\n", 415 | " checkbox 'Fish', 'Cat', 'Dog', label: 'Animals'\n", 416 | " select :color, *IRuby::Input::Button::COLORS.keys\n", 417 | " cancel \n", 418 | " button \n", 419 | "end" 420 | ] 421 | }, 422 | { 423 | "cell_type": "markdown", 424 | "metadata": {}, 425 | "source": [ 426 | "## Writing your own widget\n", 427 | "\n", 428 | "Most form methods are `IRuby::Input::Widget` instances. A `Widget` is an [`Erector::Widget`](https://github.com/erector/erector) with some additional helpers. Here is the `cancel` widget:\n", 429 | "\n", 430 | "```ruby\n", 431 | "module IRuby\n", 432 | " module Input\n", 433 | " class Cancel < Widget\n", 434 | " needs :label\n", 435 | "\n", 436 | " builder :cancel do |label='Cancel'|\n", 437 | " add_button Cancel.new(label: label)\n", 438 | " end\n", 439 | "\n", 440 | " def widget_css\n", 441 | " \".iruby-cancel { margin-left: 5px; }\"\n", 442 | " end\n", 443 | "\n", 444 | " def widget_js\n", 445 | " <<-JS\n", 446 | " $('.iruby-cancel').click(function(){\n", 447 | " $('#iruby-form').remove();\n", 448 | " });\n", 449 | " JS\n", 450 | " end\n", 451 | "\n", 452 | " def widget_html\n", 453 | " button(\n", 454 | " @label,\n", 455 | " type: 'button', \n", 456 | " :'data-dismiss' => 'modal',\n", 457 | " class: \"btn btn-danger pull-right iruby-cancel\"\n", 458 | " )\n", 459 | " end\n", 460 | " end\n", 461 | " end\n", 462 | "end\n", 463 | "```\n", 464 | "\n", 465 | "The following methods are available for widgets to use or override: \n", 466 | "\n", 467 | "| method | description |\n", 468 | "| -------- | -------- |\n", 469 | "| `widget_js` | Returns the widget's Javascript |\n", 470 | "| `widget_css` | Returns the widget's CSS |\n", 471 | "| `widget_html` | Returns the widget's |\n", 472 | "| `builder(method,&block)` | Class method to add form building helpers. |\n", 473 | "\n", 474 | "The following methods are available in the `builder` block:\n", 475 | "\n", 476 | "| method | description |\n", 477 | "| -------- | -------- |\n", 478 | "| `add_field(field)` | Adds a widget to the form's field area |\n", 479 | "| `add_button(button)` | Adds a button to the form's button area |\n", 480 | "| `process(key,&block)` | Register a custom processing block for the given key in the results hash |\n", 481 | "| `unique_key(key)` | Returns a unique key for the given key. Use this to make sure that there are no key collisions in the final results hash. |\n", 482 | "\n", 483 | "A canceled form always returns `nil`. Otherwise, the form collects any element with a `data-iruby-key` and non-falsey `data-iruby-value` and passes those to the processor proc registered for the key. See the `File` widget for a more involved example of processing results." 484 | ] 485 | } 486 | ], 487 | "metadata": { 488 | "kernelspec": { 489 | "display_name": "Ruby 2.2.1", 490 | "language": "ruby", 491 | "name": "ruby" 492 | }, 493 | "language_info": { 494 | "file_extension": ".rb", 495 | "mimetype": "application/x-ruby", 496 | "name": "ruby", 497 | "version": "2.2.3" 498 | } 499 | }, 500 | "nbformat": 4, 501 | "nbformat_minor": 0 502 | } 503 | --------------------------------------------------------------------------------