Improve CLI usage:

* Rewrite arguments parsing with OptionParser;
* Allow processing of multiple recipes.
This commit is contained in:
Thibault Jouan 2014-09-26 14:16:26 +00:00
parent 85ee79ab88
commit 7a7c8379ff
4 changed files with 124 additions and 90 deletions

View File

@ -6,5 +6,10 @@ Feature: CLI usage
Then the exit status must be 64 Then the exit status must be 64
And the output must contain exactly: And the output must contain exactly:
""" """
Usage: producer [-v] [-n] [-t host.example] recipe_file Usage: producer [options] [recipes]
options:
-v, --verbose enable verbose mode
-n, --dry-run enable dry run mode
-t, --target HOST target host
""" """

View File

@ -1,5 +1,6 @@
require 'etc' require 'etc'
require 'forwardable' require 'forwardable'
require 'optparse'
require 'pathname' require 'pathname'
require 'net/ssh' require 'net/ssh'

View File

@ -3,8 +3,7 @@ module Producer
class CLI class CLI
ArgumentError = Class.new(::ArgumentError) ArgumentError = Class.new(::ArgumentError)
OPTIONS_USAGE = '[-v] [-n] [-t host.example]'.freeze USAGE = "Usage: #{File.basename $0} [options] [recipes]".freeze
USAGE = "Usage: #{File.basename $0} #{OPTIONS_USAGE} recipe_file".freeze
EX_USAGE = 64 EX_USAGE = 64
EX_SOFTWARE = 70 EX_SOFTWARE = 70
@ -15,8 +14,8 @@ module Producer
begin begin
cli.parse_arguments! cli.parse_arguments!
cli.run cli.run
rescue ArgumentError rescue ArgumentError => e
stderr.puts USAGE stderr.puts e.message
exit EX_USAGE exit EX_USAGE
rescue RuntimeError => e rescue RuntimeError => e
stderr.puts "#{e.class.name.split('::').last}: #{e.message}" stderr.puts "#{e.class.name.split('::').last}: #{e.message}"
@ -25,40 +24,55 @@ module Producer
end end
end end
attr_reader :arguments, :env attr_reader :arguments, :stdin, :stdout, :stderr, :env
def initialize(args, stdin: $stdin, stdout: $stdout, stderr: $stderr) def initialize(args, stdin: $stdin, stdout: $stdout, stderr: $stderr)
@arguments = args @arguments = args
@stdin = stdin @stdin = stdin
@stdout = stdout @stdout = stdout
@env = Env.new(input: stdin, output: stdout, error_output: stderr) @stderr = stderr
@env = build_env
end end
def parse_arguments! def parse_arguments!
@arguments = arguments.each_with_index.inject([]) do |m, (e, i)| option_parser.parse!(@arguments)
case e fail ArgumentError, option_parser if @arguments.empty?
when '-v'
env.verbose = true
when '-n'
env.dry_run = true
when '-t'
env.target = arguments.delete_at i + 1
else
m << e
end
m
end
fail ArgumentError unless @arguments.any?
end end
def run(worker: Worker.new(@env)) def run(worker: Worker.new(@env))
worker.process recipe.tasks evaluate_recipes.each { |recipe| worker.process recipe.tasks }
@env.cleanup @env.cleanup
end end
def recipe def evaluate_recipes
@recipe ||= Recipe::FileEvaluator.evaluate(@arguments.first, env) @arguments.map { |e| Recipe::FileEvaluator.evaluate(e, @env) }
end
private
def build_env
Env.new(input: @stdin, output: @stdout, error_output: @stderr)
end
def option_parser
OptionParser.new do |opts|
opts.banner = USAGE
opts.separator ''
opts.separator 'options:'
opts.on '-v', '--verbose', 'enable verbose mode' do |e|
env.verbose = true
end
opts.on '-n', '--dry-run', 'enable dry run mode' do |e|
env.dry_run = true
end
opts.on '-t', '--target HOST', 'target host' do |e|
env.target = e
end
end
end end
end end
end end

View File

@ -5,28 +5,42 @@ module Producer::Core
include ExitHelpers include ExitHelpers
include FixturesHelpers include FixturesHelpers
let(:recipe_file) { fixture_path_for 'recipes/some_recipe.rb' }
let(:options) { [] } let(:options) { [] }
let(:recipe_file) { fixture_path_for 'recipes/some_recipe.rb' }
let(:arguments) { [*options, recipe_file] } let(:arguments) { [*options, recipe_file] }
subject(:cli) { described_class.new(arguments) } subject(:cli) { described_class.new(arguments) }
describe '.run!' do describe '.run!' do
let(:stderr) { StringIO.new } subject(:run!) { described_class.run! arguments }
subject(:run!) { described_class.run! arguments, stderr: stderr }
context 'when given arguments are invalid' do it 'builds a new CLI instance with given arguments' do
expect(described_class)
.to receive(:new).with(arguments, anything).and_call_original
run!
end
it 'parses new CLI instance arguments' do
expect_any_instance_of(described_class).to receive :parse_arguments!
run!
end
it 'runs the new CLI instance' do
expect_any_instance_of(described_class).to receive :run
run!
end
context 'when no recipe is given' do
let(:arguments) { [] } let(:arguments) { [] }
it 'exits with a return status of 64' do it 'exits with a return status of 64' do
expect { run! }.to raise_error(SystemExit) do |e| expect { described_class.run! arguments, stderr: StringIO.new }
expect(e.status).to eq 64 .to raise_error(SystemExit) { |e| expect(e.status).to eq 64 }
end
end end
it 'prints the usage on the error stream' do it 'prints the usage on the error stream' do
trap_exit { run! } expect { trap_exit { run! } }
expect(stderr.string).to match /\AUsage: .+/ .to output(/\AUsage: .+/).to_stderr
end end
end end
@ -34,92 +48,95 @@ module Producer::Core
let(:recipe_file) { fixture_path_for 'recipes/raise.rb' } let(:recipe_file) { fixture_path_for 'recipes/raise.rb' }
it 'exits with a return status of 70' do it 'exits with a return status of 70' do
expect { run! }.to raise_error(SystemExit) do |e| expect { described_class.run! arguments, stderr: StringIO.new }
expect(e.status).to eq 70 .to raise_error(SystemExit) { |e| expect(e.status).to eq 70 }
end
end end
it 'prints exception name and message and the error stream' do it 'prints exception name and message and the error stream' do
trap_exit { run! } expect { trap_exit { run! } }
expect(stderr.string).to eq "RemoteCommandExecutionError: false\n" .to output("RemoteCommandExecutionError: false\n").to_stderr
end end
end end
end end
describe '#env' do describe '#initialize' do
let(:stdin) { StringIO.new } it 'assigns an env' do
let(:stdout) { StringIO.new }
let(:stderr) { StringIO.new }
subject(:cli) { described_class.new(arguments,
stdin: stdin, stdout: stdout, stderr: stderr) }
it 'returns an env' do
expect(cli.env).to be_an Env expect(cli.env).to be_an Env
end end
it 'assigns CLI stdin as the env input' do it 'assigns CLI stdin as the env input' do
expect(cli.env.input).to be stdin expect(cli.env.input).to be cli.stdin
end end
it 'assigns CLI stdout as the env output' do it 'assigns CLI stdout as the env output' do
expect(cli.env.output).to be stdout expect(cli.env.output).to be cli.stdout
end end
it 'assigns CLI stderr as the env error output' do it 'assigns CLI stderr as the env error output' do
expect(cli.env.error_output).to be stderr expect(cli.env.error_output).to be cli.stderr
end end
end end
describe '#parse_arguments!' do describe '#parse_arguments!' do
context 'with options' do let(:options) { %w[-v -t some_host.example] }
let(:options) { %w[-v -t some_host.example] }
before { cli.parse_arguments! } it 'removes options from arguments' do
cli.parse_arguments!
expect(cli.arguments).to eq [recipe_file]
end
it 'removes options from arguments' do context 'with verbose option' do
expect(cli.arguments).to eq [recipe_file] let(:options) { %w[-v] }
end
context 'verbose' do it 'enables env verbose mode' do
let(:options) { %w[-v] } cli.parse_arguments!
expect(cli.env).to be_verbose
it 'enables env verbose mode' do
expect(cli.env).to be_verbose
end
end
context 'dry run' do
let(:options) { %w[-n] }
it 'enables env dry run mode' do
expect(cli.env).to be_dry_run
end
end
context 'target' do
let(:options) { %w[-t some_host.example] }
it 'assigns the given target to the env' do
expect(cli.env.target).to eq 'some_host.example'
end
end end
end end
context 'without arguments' do context 'with dry run option' do
let(:options) { %w[-n] }
it 'enables env dry run mode' do
cli.parse_arguments!
expect(cli.env).to be_dry_run
end
end
context 'with target option' do
let(:options) { %w[-t some_host.example] }
it 'assigns the given target to the env' do
cli.parse_arguments!
expect(cli.env.target).to eq 'some_host.example'
end
end
context 'with combined options' do
let(:options) { %w[-vn]}
it 'handles combined options' do
cli.parse_arguments!
expect(cli.env).to be_verbose.and be_dry_run
end
end
context 'when no arguments remains after parsing' do
let(:arguments) { [] } let(:arguments) { [] }
it 'raises the argument error exception' do it 'raises an error' do
expect { cli.parse_arguments! }.to raise_error described_class::ArgumentError expect { cli.parse_arguments! }
.to raise_error described_class::ArgumentError
end end
end end
end end
describe '#run' do describe '#run' do
it 'processes recipe tasks with a worker' do it 'processes recipes tasks with a worker' do
worker = instance_spy Worker worker = instance_spy Worker
cli.run worker: worker cli.run worker: worker
expect(worker).to have_received(:process).with cli.recipe.tasks expect(worker).to have_received(:process)
.with all be_an_instance_of Task
end end
it 'cleans up the env' do it 'cleans up the env' do
@ -128,12 +145,9 @@ module Producer::Core
end end
end end
describe '#recipe' do describe '#evaluate_recipes' do
it 'returns the evaluated recipe' do it 'returns the evaluated recipes' do
expect(cli.recipe.tasks).to match [ expect(cli.evaluate_recipes).to all be_an_instance_of Recipe
an_object_having_attributes(name: :some_task),
an_object_having_attributes(name: :another_task)
]
end end
end end
end end