Ruby Gem Recipe

One of the first hurdles new ruby programmers run into is creating a project as a gem. Today there are some great tools that trivialize gem creation and publication: jeweler, bundler, github, gemcutter. In this article I’ll show the creation of an example application with a command line interface all packaged in a gem.

Example code available at: http://github.com/royw/creating-gem-example

 ∴ git clone git://github.com/royw/creating-gem-example.git

First, I really like Ruby Version Manager so I highly recommend installing it and the versions of ruby you prefer. Keeping it simple for this article:

 ∴ rvm install 1.8.7
 ∴ rvm 1.8.7

Second, install the prerequisites Jeweler and Bundler :

 ∴ gem install jeweler
 ∴ gem install bundler

Now it’s time to code generate the project. Let’s see what jeweler can do for us:

 ∴ jeweler --help
Usage: jeweler [options] reponame
e.g. jeweler the-perfect-gem
        --directory [DIRECTORY]      specify the directory to generate into

        --rspec                      generate rspec code examples
        --shoulda                    generate shoulda tests
        --testunit                   generate test/unit tests
        --bacon                      generate bacon specifications
        --testspec                   generate test/spec tests
        --minitest                   generate minitest tests
        --micronaut                  generate micronaut examples
        --riot                       generate riot tests
 
        --cucumber                   generate cucumber stories in addition to the other tests

        --reek                       generate rake task for reek
        --roodi                      generate rake task for roodi

        --[no-]gemcutter             setup project for gemcutter
        --rubyforge                  setup project for rubyforge
        --summary [SUMMARY]          specify the summary of the project
        --description [DESCRIPTION]  specify a description of the project

        --user-name [USER_NAME]      the user's name, ie that is credited in the LICENSE
        --user-email [USER_EMAIL]    the user's email, ie that is credited in the Gem specification

        --github-username [GITHUB_USERNAME]
                                     name of the user on GitHub to set the project up under
        --github-token [GITHUB_TOKEN]
                                     GitHub token to use for interacting with the GitHub API
        --git-remote [GIT_REMOTE]    URI to set the git origin remote to
        --homepage [HOMEPAGE]        the homepage for your project (defaults to the GitHub repo)
        --create-repo                create the repository on GitHub

        --yard                       use yard for documentation
        --rdoc                       use rdoc for documentation
    -h, --help                       display this help and exit

Wow! Pretty impressive. As you can see, jeweler let’s you define your project your way. For this article:

∴ jeweler --rspec --no-gemcutter --summary "example gem for blog article" --description "This is an example gem for my blog article showing how to create a gem" --rdoc --user-name "Roy Wright" --user-email "roy@wright.org" example
        create  .gitignore
        create  Rakefile
        create  LICENSE
        create  README.rdoc
        create  .document
        create  lib
        create  lib/example.rb
        create  spec
        create  spec/spec_helper.rb
        create  spec/example_spec.rb
        create  spec/spec.opts
Jeweler has prepared your gem in example
∴ cd example
∴ ls
LICENSE         README.rdoc     Rakefile        lib             spec
 ∴ rake -T
(in /Users/royw/views/example)
rake build                           # Build gem
rake check_dependencies              # Check that runtime and development dependencies are installed
rake check_dependencies:development  # Check that development dependencies are installed
rake check_dependencies:runtime      # Check that runtime dependencies are installed
rake clobber_rcov                    # Remove rcov products for rcov
rake clobber_rdoc                    # Remove rdoc products
rake gemspec                         # Generate and validates gemspec
rake gemspec:debug                   # Display the gemspec for debugging purposes
rake gemspec:generate                # Generates the gemspec, using version from VERSION
rake gemspec:validate                # Validates the gemspec
rake git:release                     # Tag a release in Git
rake github:release                  # Release Gem to GitHub
rake install                         # Install gem using sudo
rake rcov                            # Run specs using RCov
rake rdoc                            # Build the rdoc HTML Files
rake release                         # Release gem
rake rerdoc                          # Force a rebuild of the RDOC files
rake spec                            # Run specs
rake version                         # Displays the current version
rake version:bump:major              # Bump the gemspec by a major version.
rake version:bump:minor              # Bump the gemspec by a minor version.
rake version:bump:patch              # Bump the gemspec by a patch version.
rake version:write                   # Writes out an explicit version.

As you can see, most of the infrastructure is ready to use. Ah, you caught the “most” did you? OK, let’s complete it.

First, let’s get a VERSION file installed:

 ∴ rake version:write
(in /Users/royw/views/example)
Updated version: 0.0.0
 ∴ ln -s ../VERSION VERSION

This puts a copy of the version in our gem so we can have a –version feature.

If your gem is going to furnish an executable, then do something like:

 ∴ mkdir bin
 ∴ mate bin/example # add the code below, changing "example" to your project name
 ∴ chmod +x bin/example
 ∴ cat bin/example
#!/usr/bin/env ruby

require File.expand_path(File.dirname(__FILE__) + "/../lib/example")
exit CLI.execute

Now let’s switch over to the lib directory and do a little infrastructure improvements.

 ∴ mkdir lib/example

And this directory is where our code goes. To access our code we modify the lib/example.rb to handle our requires:

 ∴ cat lib/example.rb 
# not the best solution, probably should use require_relative in ruby 1.9 or the backported
# version from Programming Ruby v3 for ruby 1.8.
$:.unshift(File.dirname(__FILE__)) unless
  $:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__)))

# a few of the gems I use in most projects
require 'rubygems'
require 'extlib'
require 'versionomy'
require 'log4r'
require 'configliere'
require 'singleton'
require 'pp'

# require all of the library files
# note, you may need to specify these explicitly if there are any load order dependencies
Dir['example/**/*.rb'].each do |name| 
  require name
end

And we will toss the start of a command line interface object (with support for command line arguments, environment variables, config files, and logging) into lib/example/cli.rb:

 ∴ cat lib/example/cli.rb 
# Command Line Interface to the Example application
#
# Usage:
#
#  exit CLI.execute
#
# Note, this is a Singleton class, only use CLI.execute to access this class.
#
class CLI
  include Singleton
  
  # The application can monitor this attribute to know if an interrupt (^c) has been signalled.
  attr_reader :interrupt_signalled
  
  APP_NAME = 'example'
  
  # The main entry point for the CLI
  # returns the exit code
  def self.execute
    instance.execute
  end

  # protected by the Singleton.  I.e., do not attempt CLI.new  
  def initialize
    @interrupt_signalled = false
  end
  
  # execute the app from the CLI
  # returns the exit code
  def execute
    exit_code = 0
    # ^c handler
    Signal.trap("INT") { @interrupt_signalled = true }
    begin
      setup_settings
      Settings[:app_name] = APP_NAME
      if Settings[:version]
        puts Example.version.to_s
      else
        Settings[:logger] = init_logger
        app = Example.new
        app.execute
      end
    rescue SystemExit
      # exit() calls from within the block actually raise SystemExit
    rescue Exception => e
      Settings[:logger].error e.to_s if Settings[:logger]
      exit_code = 1
    end
    exit_code
  end
  
  # log4r setup
  def init_logger
    # Initial setup of logger
    logger = Log4r::Logger.new(APP_NAME)
    level_map = {'DEBUG' => Log4r::DEBUG, 'INFO' => Log4r::INFO, 'WARN' => Log4r::WARN}
    
    # console messages
    logger.outputters = Log4r::StdoutOutputter.new(:console)
    Log4r::Outputter[:console].formatter  = Log4r::PatternFormatter.new(:pattern => "%m")
    Log4r::Outputter[:console].level = Log4r::INFO
    Log4r::Outputter[:console].level = Log4r::WARN if Settings[:quiet]
    Log4r::Outputter[:console].level = Log4r::DEBUG if Settings[:debug]
    Log4r::Outputter[:console].formatter = Log4r::PatternFormatter.new(:pattern => "%m")
    logger.trace = true if Settings[:trace]

    if Settings[:logfile]
      logfile_outputter = Log4r::RollingFileOutputter.new(:logfile, :filename => Settings[:logfile], :maxsize => 1000000 )
      logger.add logfile_outputter
      Settings[:loglevel] ||= 'INFO'
      Log4r::Outputter[:logfile].formatter = Log4r::PatternFormatter.new(:pattern => "[%l] %d :: %M")
      Log4r::Outputter[:logfile].level = level_map[Settings[:loglevel].upcase] || Log4r::INFO
    end
    logger
  end
  
  # Configliere setup
  def setup_settings
    Settings.use :all
     
    Settings.define :logfile, :type => String, :description => 'Log filename, default is: media2nfo.log', :required => false
    Settings.define :loglevel, :type => String, :description => 'Logfile logging level, must be one of: DEBUG, INFO, WARN, ERROR', :required => false
    Settings.define :debug, :type => :boolean, :description => 'Log debug messages to console', :required => false
    Settings.define :quiet, :type => :boolean, :description => 'Only log errors to console', :required => false
    Settings.define :trace, :type => :boolean, :description => 'Trace logging', :required => false
    Settings.define :version, :type => :boolean, :description => 'Application Version', :required => false
     
    Settings({
              :logfile => "#{APP_NAME}.log",
              :loglevel => 'debug'
             })
             
    Settings.read("#{APP_NAME}.yaml") if File.exist?(File.expand_path("~/.configliere/#{APP_NAME}.yaml"))
    Settings.resolve!
  end
end

And a really basic application:

 ∴ cat lib/example/example.rb 
class Example
  def self.version
    ver = Versionomy.parse(IO.read(File.expand_path(File.dirname(__FILE__) + "/../VERSION")).strip)
    ver
  end
  def execute
    Settings[:logger].debug "Example.execute"
  end
end

And to be good, an rspec:

 ∴ cat spec/example_spec.rb 
require File.expand_path(File.dirname(__FILE__) + '/spec_helper')

describe "Example" do
  it "should return a Versionomy object as the version" do
    ver = Example.version
    ver.instance_of?(Versionomy::Value).should be_true
  end
  it "should return the version stored in the VERSION file" do
    v1 = Example.version.to_s.strip
    v2 = IO.read(File.expand_path(File.dirname(__FILE__) + "/../VERSION")).strip
    v1.should == v2
  end
end

And the last change to the infrastructure is to bundle the gems for two reasons, first to ease deployment, but second, more importantly to resolve dependencies of all gems together instead of individually (which can lead to dependency problems). So here’s a real simple Gemfile to get started with:

 ∴ cat Gemfile 
source :gemcutter

gem 'configliere',  '>=0.0.9'
gem 'extlib',       '>=0.9.15'
gem 'versionomy',   '>=0.4.0'
gem 'log4r',        '>=1.1.7'

gem 'jeweler',      '>=1.4.0'
gem 'rake',         '>=0.8.7'
gem 'rspec',        '>=1.3.0',  :require => 'spec'

Then to install the gems:

 ∴ bundler install

Try it

 ∴ ls *.log
ls: *.log: No such file or directory
 ∴ bin/example
 ∴ ls *.log
example000001.log
 ∴ cat example000001.log 
[DEBUG] 2010-06-07 01:41:12 :: Example.execute
 ∴ bin/example --version
0.1.0
 ∴ bin/example --help
usage: example command [...--param=val...]

Params:
  --debug:                    Log debug messages to console
  --logfile:                  Log filename, default is: media2nfo.log
  --loglevel:                 Logfile logging level, must be one of: DEBUG, INFO, WARN, ERROR
  --quiet:                    Only log errors to console
  --trace:                    Trace logging
  --version:                  Application Version

Environment Variables can be used to set:


Commands:

Workflow

And now the work flow for building and publishing our gem:

∴ rake spec
(in /Users/royw/views/example)
/Users/royw/.rvm/gems/ruby-1.8.7-p174/gems/jeweler-1.4.0/lib/jeweler/commands/check_dependencies.rb:13:Warning: Gem::Dependency#version_requirements is deprecated and will be removed on or after August 2010.  Use #requirement
All dependencies seem to be installed.
..

Finished in 0.004048 seconds

2 examples, 0 failures
 ∴ rake version:bump:patch
(in /Users/royw/views/example)
Current version: 0.0.0
Updated version: 0.0.1
 ∴ echo "*.log" >> .gitignore
 ∴ git status
# On branch master
# Changed but not updated:
#   (use "git add ..." to update what will be committed)
#
#       modified:   .gitignore
#       modified:   lib/example.rb
#       modified:   spec/example_spec.rb
#
# Untracked files:
#   (use "git add ..." to include in what will be committed)
#
#       Gemfile
#       bin/
#       example.gemspec
#       lib/VERSION
#       lib/example/
no changes added to commit (use "git add" and/or "git commit -a")
 ∴ git add Gemfile bin example.gemspec lib .gitignore spec
 ∴ git status
# On branch master
# Changes to be committed:
#   (use "git reset HEAD ..." to unstage)
#
#       modified:   .gitignore
#       new file:   Gemfile
#       new file:   bin/example
#       new file:   example.gemspec
#       new file:   lib/VERSION
#       modified:   lib/example.rb
#       new file:   lib/example/cli.rb
#       new file:   lib/example/example.rb
#       modified:   spec/example_spec.rb
#
 ∴ git commit -m "initial coding"
Created commit 3aa37db: initial coding
 9 files changed, 208 insertions(+), 2 deletions(-)
 create mode 100644 Gemfile
 create mode 100755 bin/example
 create mode 100644 example.gemspec
 create mode 120000 lib/VERSION
 create mode 100644 lib/example/cli.rb
 create mode 100644 lib/example/example.rb
 ∴ rake build
(in /Users/royw/views/example)
Generated: example.gemspec
example.gemspec is valid.
WARNING:  no rubyforge_project specified
  Successfully built RubyGem
  Name: example
  Version: 0.0.1
  File: example-0.0.1.gem

And optionally if you are using github as your master and/or releasing to gemcutter:

 ∴ rake release

Conclusion

While we have just touched the surface of these powerful tools, hopefully I have shown that creating your project as a gem is pretty easy. Just a few minor infrastructure enhancements and you’ve run out of excuses not to gemify your next project. 🙂

Have fun,
Roy

Advertisements

3 thoughts on “Ruby Gem Recipe

Leave a Reply

Please log in using one of these methods to post your comment:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s