Move over Rake, Thor is the new King

How to improve your task runners with the Thor gem

Sep 02, 2021 | Ben Simpson

A task runner is a way to reach into an application and execute a piece of code from the outside. This means you can invoke something from the terminal instead of having to open a REPL or in our case a Rails console to invoke a task.

Why do this? There could be thousands of classes, all with different ways to invoke them. The task runner acts as a facade and only exposes what is needed to perform a specific task in isolation. Its main responsiblities are handing user input, formatting program output, and handling any options parsing.

Just use Rake

Yes, Rake is certainly the heavyweight in this arena. But it is also clunky and has some oddities, and limitations that are frustrating to work around in day to day usage. Tools are always improving and we shouldn't stick with something just because we always have.

To begin with is the structure of the rake task. You can use namespaces to try and encapsulate your logic. However you might not realize that the namespace share all methods in that namespace. This makes overwriting a method, or even worse, loading the wrong method because of load order resolution a real possiblity. Runs in isolation, but fails in aggregate.

You then have a DSL that requires a task definition similar to:

desc "Your task description here"
task :hello, [:name] => :environment do |task, args|
  puts "Hello #{args[:name]}"
end
# Running it
$ rake hello[Kenobi]
Hello Kenobi

The significance of the desc line is that your method is exposed to the rake task display (rake -T).

The task syntax is a little more complicated. You specify your task name followed by the arguments for your task (not keyword args, but positional arguments) as the key of a hash, and the value being set to the dependencies of your tasks (in the case of Rails we load the :environment task). This yields a block of the task, and the args for reading in your task logic. Not the most developer friendly as there is a lot of decoration around creating a new task (honestly most people probably just copy+paste from an existing task)

Another rake limitation is leaving the user to deal with argument parsing and validation. That args variable that we yielded to the block contains all strings. Rake just exposes stdin which does no formatting or validation. That means something numerical like a query limit being passed to a rake task like rake run_a_query[50] does no casting of "50" to 50. This is ok for ActiveRecord, but other classes often need stricter input types.

It gets even more complex with compound types like arrays where the developer is left to parse this input and split on a delimiter themselves. Even input with spaces can be problematic. If we wanted to say hello to someone using their full name we have to surround the task name and argument in quotes:

# Running it
$ rake "hello[Obi-wan Kenobi]"
Hello Obi-wan Kenobi

Awkward. There is little chance someone trying to invoke your task will know this is how it has to be called.

Another limitatation is the almost unreadable way to invoke a task during testing. Thoughtbot has covered this well with a shared_context for RSpec test suites. This hides the sausage making of a rake test with the invocation, task loading, and resetting between tests. If you want to know more check out their excellent blog post on the topic: https://thoughtbot.com/blog/test-rake-tasks-like-a-boss

Benefits of Thor

Good news for us Ruby users is that Thor is an excellent near drop in replacement for Rake tasks that we can start using today! In fact, if you look at railties, you are already using Thor without knowing it. All of those nice generator commands and beautiful output are Thor!

Thor is a mix of object oriented with some of the rake DSL included but improved upon. Lets look at an example Thor implementation:

require "thor"

class MyTask < Thor
  desc "hello", "says hello"
  def hello
    puts "Hello there!"
  end
end

The namespace is dropped in favor of using Ruby modules. The task keyword is dropped. Instead you decorate a method with desc in much the same was as Rake. It takes two arguments - the method to expose to the runner, and a short description.

Next we see where Thor starts to shine - with argument handling:

There are two ways to handle arguments - by accepting an argument on the method signature, or by using the option DSL.

The method signature is more compact, and is good for a small number of arguments. This acts just like a regular method invocation with an argument passed.

require "thor"

class MyTask < Thor
  desc "hello NAME", "says hello"
  def hello(name)
    say "Hello #{name}"
  end
end
# Running it
$ thor my_task:hello Kenobi
Hello Kenobi

The second way uses the option DSL. This provides much more configurability for the developer. The example below shows off some of the options handling:

require "thor"

class MyTask < Thor
  desc "hello", "says hello"
  option :name, required: true, type: :string, aliases: :n
  option :times, required: false, type: :numeric, default: 2
  def hello
    options[:times].times { say "Hello there #{options[:name]}!" }
  end
end
# Running it
$ thor my_task:hello -n "Obi-Wan Kenobi" --times 3

Hello there Obi-Wan Kenobi!
Hello there Obi-Wan Kenobi!
Hello there Obi-Wan Kenobi!

Our option DSL allow us to name our argument. This gives us a --name flag for the user to supply. We can also alias this with aliases: :n to provide the shorthand -n Ben and longhand --name=Ben styles with a single option.

Next you will notice we have a required: true argument. This will handle all of the checking on task invocation to ensure the arguments needed are supplied. If they are optional, you can even default them with default: "value". This makes fallbacks easier than the rake using a || operator. If we did not pass a required argument, the operation halts, and a formatted error is returned to the user instead:

# Running it
$ thor my_task:hello
No value provided for required options '--name'

Finally, you may have spotted that wonderful casting hint - type: :numeric. This takes the input, and casts it into the format desired all automatically without the developer needing any special code. The available types are :string, :hash, :array, :numeric, or :boolean. With the compound types like array the splitting on the delimeter is done for the developer automatically. Note that the example calls options[:times].times directly without any special handling.

Lets see an array argument in action:

require "thor"

class MyTask < Thor
  desc "hello", "says hello"
  option :names, required: true, type: :array
  def hello
    options[:names].each do |name|
      say "Hello there #{name}"
    end
  end
end

# Running it
thor my_task:hello --names "Obi-Wan" "General Grievous" "Qui-Gon Jinn"
Hello there Obi-Wan
Hello there General Grievous
Hello there Qui-Gon Jinn

Compare that to the equivalent rake invocation where the quotation delimeters are not understood, and comma usage needs to be escaped:

rake 'hello[Obi-Wan\,General Grievous\,Qui-Gon Jinn]'

A final benefit is the collection of actions available to you within the task. You can do things like ask for input, say some status update, create_file somewhere on disk, or even chmod for some permissions management. Thor has many commands to explore. Because the say command isn't printed out to STDOUT in the tests, you can have clean testing run output without the puts statement artifacts you have with other ahem problematic task runners.

Integrating with Rails

You use Ruby, so you probably use Rails. Its covered! Integrating with Rails is pretty straightforward. You will first need to add the thor gem to your Gemfile and then bundle install. It is already there as a dependency to railties, but declaring it at a top level will allow you to resolve the thor command.

Next you can create a Thorfile at your project root. It would contain something like:

# /Thorfile
require File.expand_path("../config/environment", __FILE__)

Dir["./lib/tasks/**/*.thor"].sort.each { |f| load f }

This is similar to the Rakefile that Rails includes by default. To compare, the Rakefile has a similar setup process:

# /Rakefile
require_relative "config/application"

Rails.application.load_tasks

This just instructs the Thor task display where to look for thor tasks. Note that they would not show up with a rake -T but instead with a thor -T. Putting them in lib/tasks allows them to live side by side with your legacy rake tasks.

Loading the Rails environment in config/environment allows us to have access to ActiveRecord, and other classes the same as if we were inside a Rails console from our Thor tasks.

Expanding on thor -T for a bit, we have the list of commands:

my_task
-------
thor my_task:hello  # says hello

You can get additional help on a command with thor help <task name> e.g.:

thor help my_task:hello
Usage:
  thor my_task:hello

Options:
  n, [--name=NAME]
      [--times=N]
                    # Default: 2

says hello

Just a little more revealing on the arguments rather than trying to cram usage into a description, or code comments.

Testing

This is one of the best areas of Thor. Testing is very readable and without any need for a shared_example to tuck away task runner weirdness.

A Thor Rspec test might look like:

require "rspec"
load "my_task.thor"

RSpec.describe MyTask do
  it "says hello" do
    allow(subject.shell).to receive(:say)

    result = subject.invoke(:hello, [], { name: "Ben", times: 1 })
    expect(subject.shell).to have_received(:say).with("Hello there Ben!").once
  end
end

The Thor file can be loaded normally. There is no special registration dance with Thor like there is with Rake to load the task. You also do not need to clear this task after having run it so it will run again.

The invoke method is the most magic we have in calling a Thor task. The method takes three arguments - the task name, the arguments you want to pass directly to the task, and the options you want to pass in through the options DSL. This operates very much like a PORO in terms of testability.

Note - the commands that operate on the shell (:ask, :error, :set_color, :yes?, :no?, :say, :say_status, :print_in_columns, :print_table, :print_wrapped, :file_collision, :terminal_width) will delegate assertions from your Thor class instance to the Thor::Shell instance. This is why we expect the subject.shell to have received the command, not just the subject.

Conclusion

If you need to build complex task runners Thor is a great replacement for Rake. No more fighting with argument parsing, namespace collisions, or testing oddities. The griping about Rake is one area of the Ruby ecosystem that has largely been fixed with better tooling yet the Rake task runner is the default that people reach for. As with many things, the default is not always the best tool.

Some instances might be better suited to Rake still, specifically around task dependency management. In this case it might be better to stick with Rake, and declare those task dependencies. Rake handles invoking prerequisite tasks very well.

If you like the Thor actions, and you are using Rails and want to stick with Rake for a task, you might benefit from including the Rails::Command::Base class.

Give Thor a try on your next task runner. You can get started from the Thor homepage: http://whatisthor.com/ or jump straight into the Rdoc specifications for specific method calls: https://www.rubydoc.info/gems/thor/0.14.6/Thor

Rails even has some Thor love with their own getting started page: https://github.com/rails/thor/wiki/Getting-Started