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
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 'forwardable'
require 'optparse'
require 'pathname'
require 'net/ssh'

View File

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

View File

@ -5,28 +5,42 @@ module Producer::Core
include ExitHelpers
include FixturesHelpers
let(:recipe_file) { fixture_path_for 'recipes/some_recipe.rb' }
let(:options) { [] }
let(:recipe_file) { fixture_path_for 'recipes/some_recipe.rb' }
let(:arguments) { [*options, recipe_file] }
subject(:cli) { described_class.new(arguments) }
describe '.run!' do
let(:stderr) { StringIO.new }
subject(:run!) { described_class.run! arguments, stderr: stderr }
subject(:run!) { described_class.run! arguments }
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) { [] }
it 'exits with a return status of 64' do
expect { run! }.to raise_error(SystemExit) do |e|
expect(e.status).to eq 64
end
expect { described_class.run! arguments, stderr: StringIO.new }
.to raise_error(SystemExit) { |e| expect(e.status).to eq 64 }
end
it 'prints the usage on the error stream' do
trap_exit { run! }
expect(stderr.string).to match /\AUsage: .+/
expect { trap_exit { run! } }
.to output(/\AUsage: .+/).to_stderr
end
end
@ -34,92 +48,95 @@ module Producer::Core
let(:recipe_file) { fixture_path_for 'recipes/raise.rb' }
it 'exits with a return status of 70' do
expect { run! }.to raise_error(SystemExit) do |e|
expect(e.status).to eq 70
end
expect { described_class.run! arguments, stderr: StringIO.new }
.to raise_error(SystemExit) { |e| expect(e.status).to eq 70 }
end
it 'prints exception name and message and the error stream' do
trap_exit { run! }
expect(stderr.string).to eq "RemoteCommandExecutionError: false\n"
expect { trap_exit { run! } }
.to output("RemoteCommandExecutionError: false\n").to_stderr
end
end
end
describe '#env' do
let(:stdin) { StringIO.new }
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
describe '#initialize' do
it 'assigns an env' do
expect(cli.env).to be_an Env
end
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
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
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
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
expect(cli.arguments).to eq [recipe_file]
end
context 'with verbose option' do
let(:options) { %w[-v] }
context 'verbose' do
let(:options) { %w[-v] }
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
it 'enables env verbose mode' do
cli.parse_arguments!
expect(cli.env).to be_verbose
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) { [] }
it 'raises the argument error exception' do
expect { cli.parse_arguments! }.to raise_error described_class::ArgumentError
it 'raises an error' do
expect { cli.parse_arguments! }
.to raise_error described_class::ArgumentError
end
end
end
describe '#run' do
it 'processes recipe tasks with a worker' do
it 'processes recipes tasks with a worker' do
worker = instance_spy 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
it 'cleans up the env' do
@ -128,12 +145,9 @@ module Producer::Core
end
end
describe '#recipe' do
it 'returns the evaluated recipe' do
expect(cli.recipe.tasks).to match [
an_object_having_attributes(name: :some_task),
an_object_having_attributes(name: :another_task)
]
describe '#evaluate_recipes' do
it 'returns the evaluated recipes' do
expect(cli.evaluate_recipes).to all be_an_instance_of Recipe
end
end
end