# # Author:: Adam Jacob () # 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 # # http://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:: The name of the option to add # args:: 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 :: 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 :: The name of the deprecated option # replacement :: The name of the option that replaces this option. # long :: The original long flag name, or flag name with argument, eg "--user USER" # short :: The original short-form flag name, eg "-u USER" # boolean :: true if this is a boolean flag, eg "--[no-]option". # value_mapper :: 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 :: 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 # :: 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:: The current options hash. def options @options ||= {} @options end # Set the current options hash # # === Parameters # val:: The hash to set the options to # # === Returns # @options:: 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:: The string to set the banner to # # === Returns # @banner:: 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:: The array of arguments passed to the initializer # # === Returns # object:: 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:: The array of arguments to parse; defaults to ARGV # # === Returns # argv:: 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:: 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