curses! conditional ruby gem installation within a gemspec

2014-01-05 · Computing

Update 2014-03-26: curses 1.0.1, containing ruby/curses#4, removes the need for this workaround. Hurrah!


From Ruby 2.1.0 onwards, curses has been removed from the Ruby standard library and extracted to the curses Ruby gem. For sidekiq-spy, the Ruby gem providing Sidekiq monitoring in the console, that necessitated some enthusiastic dancing using conditional Ruby gem installation within a gemspec, to maintain support for Ruby 1.9.3 and Ruby 2.0.0.

To use curses in Ruby 1.9.3 and Ruby 2.0.0, all that is needed is require ‘curses’. In Ruby 2.1.0, specifying the dependency gem ‘curses’ lets you carry on unharmed. But the curses gem does not appear to work properly in Ruby 1.9.3. Thus, the Ruby gem can be installed conditionally, perhaps for Ruby 2.1.0 onwards. This same approach could be used to install different gems or versions based on which way the wind is blowing, or the Ruby version used. In a non-gem project, this could be accomplished using

# Gemfile

gem 'curses', :platform => :ruby_21

or, because the :ruby_21 platform does not appear to exist in Ruby 1.9.3, maybe something like

# Gemfile

gem 'curses' if RUBY_VERSION >= '2.1'

For a gem, however, this would only be evaluated at build-time, not install-time. The Wikibook Ruby Programming has an excellent chapter entitled RubyGems. This describes an approach where the gem is installed within the umbrella of native extensions building. I found that not all of this appeared to be necessary for my use-case, but the approach seems to work well.

First, register the extension in the gemspec:

# sidekiq-spy.gemspec

spec.extensions << 'ext/mkrf_conf.rb'

Then, create the extension file itself. In my case, I wanted to install curses only for Ruby 2.1.0 or later, but this can easily be adapted.

# ext/mkrf_conf.rb

require 'rubygems/dependency_installer'

di = Gem::DependencyInstaller.new

begin
  if RUBY_VERSION >= '2.1'
    puts "Installing curses because Ruby #{RUBY_VERSION}"
    
    di.install "curses", "~> 1.0"
  else
    puts "Not installing curses because Ruby #{RUBY_VERSION}"
  end
rescue => e
  warn "#{$0}: #{e}"
  
  exit!
end

puts "Writing fake Rakefile"

# Write fake Rakefile for rake since Makefile isn't used
File.open(File.join(File.dirname(__FILE__), 'Rakefile'), 'w') do |f|
  f.write("task :default" + $/)
end

The puts messages won’t usually show up during installation, but are useful for gem install using the -V flag. This should be all that’s necessary for handling the dependency at install-time, in this case installing the curses gem for Ruby 2.1.0 or later and not otherwise.

Next, ensure that the gem is loaded before being required. I found a variety of information online saying to use a variant of gem ‘curses’ whilst rescuing Gem::LoadError, but this did not appear to be necessary in my case. Merely requiring as before seems to be working fine (spaghetti crossed).

However, this still doesn’t handle the dependency for development-time, without which, curses isn’t available with Ruby 2.1.0. For this, a conditional development-dependency declaration ensures that the curses gem is available, whilst having no effect on install-time (at least, with the usual bundle install --without development test). Alternatively, the Gemfile could be used similarly. As mentioned, I did not have success with using the :platform => ruby_21 approach for Ruby 1.9.3.

# sidekiq-spy.gemspec

if RUBY_VERSION >= '2.1'
  spec.add_development_dependency "curses", "~> 1.0"
end

Of course, it’s very possible that there are better ways of doing all this. If you know of one, I would be very interested to hear it. :)