Dry-cli: How to reuse a base class with predefined options

I’m looking for some ideas how to solve the following issue.

I have the following classes

class A < Dry::CLI::Command
  desc 'my class A'
  option :host, type: :string, default: 'localhost', desc: 'set the host'
end

class B < Dry::CLI::Command
  desc 'my class B'
  option :host, type: :string, default: 'localhost', desc: 'set the host'
end

and because they share the same option I tried to refactor to something like:

class BaseCommand < Dry::Cli::Command
  option :host, type: :string, default: 'localhost', desc: 'set the host'
end

class A < BaseCommand
  desc 'my fancy A class'
end

class B < BaseCommand
  desc 'my fancy B class'
end

However calling my application, I see that the option host isn’t recognized for class A and B. There is any trick to do this kind of refactoring?

1 Like

There is! Instead of using inheritance, you can solve this problem with composition - aka “mixins”.
We want to extract the common options into a module, and include that module into each command.

If you want to see how this would work, just skip to the section Solution. If you’d like a brief journey on how you might have figured this out yourself, read on :smiley:

Attempt #1: Just Put The Code In a Module

If we try this naively, it won’t work:

module CommonOptions
  option :host, type: :string, default: 'localhost', desc: 'set the host'
end

class A < Dry::CLI::Command
  ...
  include CommonOptions
end

# .my-cli a --host "8.8.8.8"
# 🔥 undefined method `option' for CommonOptions:Module (NoMethodError)

option is actually a method call, and the module gets evaluated outside a Dry::CLI::Command. so it has no idea what that method should be! We want the module code to be evaluated only when inside the Command object.

Attempt #2: eval? :grimacing:

Our instincts tell us that this might require some kind of eval, and our instincts would be right. So we try this:

module CommonOptions
  self.instance_eval do
    option :host, type: :string, default: 'localhost', desc: 'set the host'
  end
end
# ...
# 🔥 undefined method `option' for CommonOptions:Module (NoMethodError)

but this doesn’t help. (In fact, conceptually, this is almost exactly the same as Attempt 1! Why is that? :thinking: ) Calling instance_eval on ourself doesn’t work - it’s too early! We’re still operating within the context of the module, and the module still doesn’t know what option means. We want this to be executed later, in the context of the including Command. But how would we know which Command that is?

Solution: Module.included

Modules in Ruby can define callback methods, which are called whenever certain events happen. One of those is included, which is called whenever (you guessed it) its module gets included by something - exactly what we want!

If we define this method for our Options module, we can evaluate that call to option inside the Command, where it’s defined:

module CommonOptions
  # Called when CommonOptions is included somewhere.
  # mod is the object (a Class, or Module) doing the 
  # including
  def self.included(mod)
    mod.instance_eval do
      option :host, type: :string, default: 'localhost', desc: 'set the host'
    end
  end
end

And this works!

class A < Dry::CLI::Command
  include CommonOptions

  def call(host:)
    puts "A: #{host}"
  end
end

class B < Dry::CLI::Command
  include CommonOptions

  def call(host:)
    puts "B: #{host}"
  end
end

# my-cli b --host "8.8.8.8"
#  ✅ => "B: 8.8.8.8"
# my-cli a
#  ✅ => "A: localhost"

PS: Side notes

Some things to ponder: (perhaps! I don’t know what you already know - this is not meant to be at all condescending!)

  • What’s the difference between just putting the code in a module (Attempt 1) and wrapping the code in self.instance_eval (Attempt 2)? What does this tell you about what instance_eval means?
  • We noticed that each option line is actually a method call. But on what object? And if it’s a method, how did it get defined?
1 Like

This functionality is now merged into master, will be in 0.7.0