-
Notifications
You must be signed in to change notification settings - Fork 40
/
cli.rb
453 lines (409 loc) · 17.8 KB
/
cli.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
#
# Author:: Adam Jacob (<[email protected]>)
# Copyright:: Copyright (c) 2008-2019 Chef Software, Inc.
# License:: Apache License, Version 2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
require "optparse" unless defined?(OptionParser)
require_relative "cli/formatter"
module Mixlib
# == Mixlib::CLI
# Adds a DSL for defining command line options and methods for parsing those
# options to the including class.
#
# Mixlib::CLI does some setup in #initialize, so the including class must
# call `super()` if it defines a custom initializer.
#
# === DSL
# When included, Mixlib::CLI also extends the including class with its
# ClassMethods, which define the DSL. The primary methods of the DSL are
# ClassMethods#option, which defines a command line option;
# ClassMethods#banner, which defines the "usage" banner;
# and ClassMethods#deprecated_option, which defines a deprecated command-line option.
#
# === Parsing
# Command line options are parsed by calling the instance method
# #parse_options. After calling this method, the attribute #config will
# contain a hash of `:option_name => value` pairs.
module CLI
module InheritMethods
def inherited(receiver)
receiver.options = deep_dup(options)
receiver.extend(Mixlib::CLI::InheritMethods)
end
# object:: Instance to clone
# This method will return a "deep clone" of the provided
# `object`. If the provided `object` is an enumerable type the
# contents will be iterated and cloned as well.
def deep_dup(object)
cloned_object = object.respond_to?(:dup) ? object.dup : object
if cloned_object.is_a?(Enumerable)
if cloned_object.is_a?(Hash)
new_hash = cloned_object.class.new
cloned_object.each do |key, value|
cloned_key = deep_dup(key)
cloned_value = deep_dup(value)
new_hash[cloned_key] = cloned_value
end
cloned_object.replace(new_hash)
else
cloned_object.map! do |shallow_instance|
deep_dup(shallow_instance)
end
end
end
cloned_object
rescue TypeError
# Symbol will happily provide a `#dup` method even though
# attempts to clone it will result in an exception (atoms!).
# So if we run into an issue of TypeErrors, just return the
# original object as we gave our "best effort"
object
end
end
module ClassMethods
# When this setting is set to +true+, default values supplied to the
# mixlib-cli DSL will be stored in a separate Hash
def use_separate_default_options(true_or_false)
@separate_default_options = true_or_false
end
def use_separate_defaults?
@separate_default_options ||= false
end
# Add a command line option.
#
# === Parameters
# name<Symbol>:: The name of the option to add
# args<Hash>:: A hash of arguments for the option, specifying how it should be parsed.
# Supported arguments:
# :short - The short option, just like from optparse. Example: "-l LEVEL"
# :long - The long option, just like from optparse. Example: "--level LEVEL"
# :description - The description for this item, just like from optparse.
# :default - A default value for this option. Default values will be populated
# on parse into `config` or `default_default`, depending `use_separate_defaults`
# :boolean - indicates the flag is a boolean. You can use this if the flag takes no arguments
# The config value will be set to 'true' if the flag is provided on the CLI and this
# argument is set to true. The config value will be set to false only
# if it has a default value of false
# :required - When set, the option is required. If the command is run without this option,
# it will print a message informing the user of the missing requirement, and exit. Default is false.
# :proc - Proc that will be invoked if the human has specified this option.
# Two forms are supported:
# Proc/1 - provided value is passed in.
# Proc/2 - first argument is provided value. Second is the cli flag option hash.
# Both versions return the value to be assigned to the option.
# :show_options - this option is designated as one that shows all supported options/help when invoked.
# :exit - exit your program with the exit code when this option is given. Example: 0
# :in - array containing a list of valid values. The value provided at run-time for the option is
# validated against this. If it is not in the list, it will print a message and exit.
# :on :head OR :tail - force this option to display at the beginning or end of the
# option list, respectively
# =
# @return <Hash> :: the config hash for the created option
# i
def option(name, args)
@options ||= {}
raise(ArgumentError, "Option name must be a symbol") unless name.is_a?(Symbol)
@options[name.to_sym] = args
end
# Declare a deprecated option
#
# Add a deprecated command line option.
#
# name<Symbol> :: The name of the deprecated option
# replacement<Symbol> :: The name of the option that replaces this option.
# long<String> :: The original long flag name, or flag name with argument, eg "--user USER"
# short<String> :: The original short-form flag name, eg "-u USER"
# boolean<String> :: true if this is a boolean flag, eg "--[no-]option".
# value_mapper<Proc/1> :: a block that accepts the original value from the deprecated option,
# and converts it to a value suitable for the new option.
# If not provided, the value provided to the deprecated option will be
# assigned directly to the converted option.
# keep<Boolean> :: Defaults to true, this ensures that `options[:deprecated_flag]` is
# populated when the deprecated flag is used. If set to false,
# only the value in `replacement` will be set. Results undefined
# if no replacement is provided. You can use this to enforce the transition
# to non-deprecated keys in your code.
#
# === Returns
# <Hash> :: The config hash for the created option.
def deprecated_option(name,
replacement: nil,
long: nil,
short: nil,
boolean: false,
value_mapper: nil,
keep: true)
description = if replacement
replacement_cfg = options[replacement]
display_name = CLI::Formatter.combined_option_display_name(replacement_cfg[:short], replacement_cfg[:long])
"This flag is deprecated. Use #{display_name} instead."
else
"This flag is deprecated and will be removed in a future release."
end
value_mapper ||= Proc.new { |v| v }
option(name,
long: long,
short: short,
boolean: boolean,
description: description,
on: :tail,
deprecated: true,
keep: keep,
replacement: replacement,
value_mapper: value_mapper)
end
# Get the hash of current options.
#
# === Returns
# @options<Hash>:: The current options hash.
def options
@options ||= {}
@options
end
# Set the current options hash
#
# === Parameters
# val<Hash>:: The hash to set the options to
#
# === Returns
# @options<Hash>:: The current options hash.
def options=(val)
raise(ArgumentError, "Options must receive a hash") unless val.is_a?(Hash)
@options = val
end
# Change the banner. Defaults to:
# Usage: #{0} (options)
#
# === Parameters
# bstring<String>:: The string to set the banner to
#
# === Returns
# @banner<String>:: The current banner
def banner(bstring = nil)
if bstring
@banner = bstring
else
@banner ||= "Usage: #{$0} (options)"
@banner
end
end
end
# Gives the command line options definition as configured in the DSL. These
# are used by #parse_options to generate the option parsing code. To get
# the values supplied by the user, see #config.
attr_accessor :options
# A Hash containing the values supplied by command line options.
#
# The behavior and contents of this Hash vary depending on whether
# ClassMethods#use_separate_default_options is enabled.
# ==== use_separate_default_options *disabled*
# After initialization, +config+ will contain any default values defined
# via the mixlib-config DSL. When #parse_options is called, user-supplied
# values (from ARGV) will be merged in.
# ==== use_separate_default_options *enabled*
# After initialization, this will be an empty hash. When #parse_options is
# called, +config+ is populated *only* with user-supplied values.
attr_accessor :config
# If ClassMethods#use_separate_default_options is enabled, this will be a
# Hash containing key value pairs of `:option_name => default_value`
# (populated during object initialization).
#
# If use_separate_default_options is disabled, it will always be an empty
# hash.
attr_accessor :default_config
# Any arguments which were not parsed and placed in "config"--the leftovers.
attr_accessor :cli_arguments
# Banner for the option parser. If the option parser is printed, e.g., by
# `puts opt_parser`, this string will be used as the first line.
attr_accessor :banner
# Create a new Mixlib::CLI class. If you override this, make sure you call super!
#
# === Parameters
# *args<Array>:: The array of arguments passed to the initializer
#
# === Returns
# object<Mixlib::Config>:: Returns an instance of whatever you wanted :)
def initialize(*args)
@options = {}
@config = {}
@default_config = {}
@opt_parser = nil
# Set the banner
@banner = self.class.banner
# Dupe the class options for this instance
klass_options = self.class.options
klass_options.keys.inject(@options) { |memo, key| memo[key] = klass_options[key].dup; memo }
# If use_separate_defaults? is on, default values go in @default_config
defaults_container = if self.class.use_separate_defaults?
@default_config
else
@config
end
# Set the default configuration values for this instance
@options.each do |config_key, config_opts|
config_opts[:on] ||= :on
config_opts[:boolean] ||= false
config_opts[:required] ||= false
config_opts[:proc] ||= nil
config_opts[:show_options] ||= false
config_opts[:exit] ||= nil
config_opts[:in] ||= nil
if config_opts.key?(:default)
defaults_container[config_key] = config_opts[:default]
end
end
super(*args)
end
# Parses an array, by default ARGV, for command line options (as configured at
# the class level).
# === Parameters
# argv<Array>:: The array of arguments to parse; defaults to ARGV
#
# === Returns
# argv<Array>:: Returns any un-parsed elements.
def parse_options(argv = ARGV, show_deprecations: true)
argv = argv.dup
opt_parser.parse!(argv)
# Do this before our custom validations, so that custom
# validations apply to any converted deprecation values;
# but after parse! so that everything is populated.
handle_deprecated_options(show_deprecations)
# Deal with any required values
options.each do |opt_key, opt_config|
if opt_config[:required] && !config.key?(opt_key)
reqarg = opt_config[:short] || opt_config[:long]
puts "You must supply #{reqarg}!"
puts @opt_parser
exit 2
end
if opt_config[:in]
unless opt_config[:in].is_a?(Array)
raise(ArgumentError, "Options config key :in must receive an Array")
end
if config[opt_key] && !opt_config[:in].include?(config[opt_key])
reqarg = Formatter.combined_option_display_name(opt_config[:short], opt_config[:long])
puts "#{reqarg}: #{config[opt_key]} is not one of the allowed values: #{Formatter.friendly_opt_list(opt_config[:in])}"
# TODO - get rid of this. nobody wants to be spammed with a ton of information, particularly since we just told them the exact problem and how to fix it.
puts @opt_parser
exit 2
end
end
end
@cli_arguments = argv
argv
end
# The option parser generated from the mixlib-cli DSL. +opt_parser+ can be
# used to print a help message including the banner and any CLI options via
# `puts opt_parser`.
# === Returns
# opt_parser<OptionParser>:: The option parser object.
def opt_parser
@opt_parser ||= OptionParser.new do |opts|
# Set the banner
opts.banner = banner
# Create new options
options.sort { |a, b| a[0].to_s <=> b[0].to_s }.each do |opt_key, opt_val|
opt_args = build_option_arguments(opt_val)
opt_method = case opt_val[:on]
when :on
:on
when :tail
:on_tail
when :head
:on_head
else
raise ArgumentError, "You must pass :on, :tail, or :head to :on"
end
parse_block =
Proc.new do |c|
config[opt_key] = if opt_val[:proc]
if opt_val[:proc].arity == 2
# New hotness to allow for reducer-style procs.
opt_val[:proc].call(c, config[opt_key])
else
# Older single-argument proc.
opt_val[:proc].call(c)
end
else
# No proc.
c
end
puts opts if opt_val[:show_options]
exit opt_val[:exit] if opt_val[:exit]
end
full_opt = [ opt_method ]
opt_args.inject(full_opt) { |memo, arg| memo << arg; memo }
full_opt << parse_block
opts.send(*full_opt)
end
end
end
# Iterates through options declared as deprecated,
# maps values to their replacement options,
# and prints deprecation warnings.
#
# @return NilClass
def handle_deprecated_options(show_deprecations)
merge_in_values = {}
config.each_key do |opt_key|
opt_cfg = options[opt_key]
# Deprecated entries do not have defaults so no matter what
# separate_default_options are set, if we see a 'config'
# entry that contains a deprecated indicator, then the option was
# explicitly provided by the caller.
#
# opt_cfg may not exist if an inheriting application
# has directly inserted values info config.
next unless opt_cfg && opt_cfg[:deprecated]
replacement_key = opt_cfg[:replacement]
if replacement_key
# This is the value passed into the deprecated flag. We'll use
# the declared value mapper (defaults to return the same value if caller hasn't
# provided a mapper).
deprecated_val = config[opt_key]
# We can't modify 'config' since we're iterating it, apply updates
# at the end.
merge_in_values[replacement_key] = opt_cfg[:value_mapper].call(deprecated_val)
config.delete(opt_key) unless opt_cfg[:keep]
end
# Warn about the deprecation.
if show_deprecations
# Description is also the deprecation message.
display_name = CLI::Formatter.combined_option_display_name(opt_cfg[:short], opt_cfg[:long])
puts "#{display_name}: #{opt_cfg[:description]}"
end
end
config.merge!(merge_in_values)
nil
end
def build_option_arguments(opt_setting)
arguments = []
arguments << opt_setting[:short] if opt_setting[:short]
arguments << opt_setting[:long] if opt_setting[:long]
if opt_setting.key?(:description)
description = opt_setting[:description].dup
description << " (required)" if opt_setting[:required]
description << " (valid options: #{Formatter.friendly_opt_list(opt_setting[:in])})" if opt_setting[:in]
opt_setting[:description] = description
arguments << description
end
arguments
end
def self.included(receiver)
receiver.extend(Mixlib::CLI::ClassMethods)
receiver.extend(Mixlib::CLI::InheritMethods)
end
end
end