Tagged With   post:lang=ruby , post:type=tutorial , gem:name=thor , gem:name=jeweler

Building DRY Gems With Thor And Jeweler

Being DRY is a concept I try to embrace as a programmer. Lately when creating new gems, I’ve noticed that I copy, paste and tweak the same boilerplate gemspecs/Rakefiles. Fed up with this non-DRY gem configuration, I came up with a gemspec config file solution. With this yaml config file and some thor tasks, I’m able to generate gem specifications for any of my gems.

Before going any further, let me disclaim that while this post uses thor and jeweler, they could be replaced with similar gems i.e sake for thor and any gem creator that takes Gem::Specification objects for jeweler. Having said that, the idea of a gemspec config file is solely dependent on my GemspecBuilder class. If there’s interest from others I can package it up as gem.

Rant

If you’ve been using jeweler like I have, you start each new gem by copying your boilerplate Rakefile which contains a special section for your jeweler config. Taken from the jeweler readme, a Rakefile can be as easy as:

  begin
    require 'jeweler'
    Jeweler::Tasks.new do |gemspec|
      gemspec.name = "the-perfect-gem"
      gemspec.summary = "TODO"
      gemspec.email = "[email protected]"
      gemspec.homepage = "http://github.com/technicalpickles/the-perfect-gem"
      gemspec.description = "TODO"
      gemspec.authors = ["Josh Nichols"]
    end
  rescue LoadError
    puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
  end

While this is easy, it’s not DRY once you start copying and pasting Rakefiles across your gems. Sure, your name and email are unlikely to change. But what if you want to have other default gemspec attributes such as files, has_rdoc and extra_rdoc_files? And if you want to change some of your default attributes later? What if you’d like to reuse some of the information in your gem specifications to create a gem’s web page? Do you feel yet that maybe you should’ve had a more DRY setup? So instead of having copy and paste configurations spread across my gems, I have one configuration file for them. Since this config file has one section for common gemspec attributes, gemspec management becomes easy and DRY.

How It Works

Using the gemspec config file and a given or detected gem name, GemspecBuilder.build (Line 17) creates a Gem::Specification object. Jeweler’s task class uses this object to create a Jeweler object. To run a jeweler task, I invoke thor to delegate the correct task to the Jeweler object. My thor tasks look like this:

  # cd into one of my gems
  bash> cd hirb
  
  # equivalent to jeweler's rake version
  bash> thor jeweler:version
  Current version: 0.1.2
  
  # equivalent to jeweler's rake gemspec
  bash> thor jeweler:gemspec
  hirb.gemspec is valid.
  Generated: hirb.gemspec
  
  # equivalent to jeweler's rake build
  bash> thor jeweler:build
  Successfully built RubyGem
  Name: hirb
  Version: 0.1.2
  File: hirb-0.1.2.gem
  
  # equivalent to jeweler's rake version:bump:patch
  bash> thor jeweler:bump patch
  Current version: 0.1.2
  Updated version: 0.1.3  

If you aren’t familiar with thor, you may be wondering why I didn’t just stop with making the above Rakefile leaner:

  begin
    require 'jeweler'
    require 'gemspec_builder' #if it were packaged as a gem
    Jeweler::Tasks.new(GemspecBuilder.build)
  rescue LoadError
    puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
  end

What thor provides that rake and sake can’t provide is letting you write your tasks as Ruby. This is quite powerful when you want pass arguments and options to your tasks (and testing tasks and sharing task libraries, etc…). Also, thor like sake, allows you to install your ruby tasks to be invoked from anywhere. This allows me to use my jeweler tasks without putting any jeweler (gem creator specific) tasks in my gem’s Rakefile. I think this is a good thing since I leave potential forkers of my gem with a choice of what gem creator they want to use for recreating my gemspec. In the same vein, I forgot to mention that one gemspec config file gives you the painless choice of switching gem creators when the need arises.

UPDATE: While discussing this one gemspec config file idea, technicalpickles brought up a valid concern. If I only ship my gemspec and not the meta-gemspec (like in the first Rakefile above), it makes it harder for my contributors to regenerate my gemspec i.e. they have to edit it by hand. If you’re familiar with gemspecs, you know the main attributes that are hard to regenerate are ones that list files. For those I agree it would be nice to give your potential contributers the file globs. My proposal is to ship an optional meta-gemspec yaml file which contains those precious file globs (unless Gem::Specification offers a globs attribute hash any time soon). You could then provide a rake task in your Rakefile as follows:

desc "Update gemspec from existing one by regenerating path globs specified in *.gemspec.yml or defaults to liberal file globs."
task :gemspec_update  do
  if (gemspec_file = Dir['*.gemspec'][0])
    original_gemspec = eval(File.read(gemspec_file))
    if File.exists?("#{gemspec_file}.yml")
      require 'yaml'
      YAML::load_file("#{gemspec_file}.yml").each do |attribute, globs|
        original_gemspec.send("#{attribute}=", FileList[globs])
      end
    else
      # liberal defaults
      original_gemspec.files = FileList["**/*"]
      test_directories = original_gemspec.test_files.grep(/\//).map {|e| e[/^[^\/]+/]}.compact.uniq
      original_gemspec.test_files = FileList["{#{test_directories.join(',')}}/**/*"] unless test_directories.empty?
    end
    File.open(gemspec_file, 'w') {|f| f.write(original_gemspec.to_ruby) }
    puts "Updated gemspec."
  else
    puts "No existing gemspec file found."
  end
end

The meta-gemspec yaml file would literally just be extracted from my gemspec config file:

  :files:
    - "[A-Z]*"
    - "{bin,lib,test}/**/*"
  :test_files:
    - "test/**/*"

Enjoyed this post? Tell others! hacker newsHacker News | twitterTwitter | DeliciousDelicious | redditReddit
blog comments powered by Disqus