From 8502eaf4da33e79cf66e07ca8432af3a41a87e2b Mon Sep 17 00:00:00 2001 From: Ronan Potage Date: Wed, 12 Oct 2022 13:25:13 -0700 Subject: [PATCH] Implement option required: true - Adds "# REQUIRED" to option description when required - Check is required options is passed - Refactor Usage and Banner option to reuse in short usage when erroring --- lib/dry/cli/banner.rb | 41 +++++++----- lib/dry/cli/command.rb | 15 +++-- lib/dry/cli/namespace.rb | 6 ++ lib/dry/cli/parser.rb | 71 +++++++++++++++------ spec/integration/single_command_spec.rb | 44 ++++++++++--- spec/support/fixtures/baz_command.rb | 3 + spec/unit/dry/cli/cli_spec.rb | 84 ++++++++++++++++++------- 7 files changed, 194 insertions(+), 70 deletions(-) diff --git a/lib/dry/cli/banner.rb b/lib/dry/cli/banner.rb index 39e20192..a7f1058b 100644 --- a/lib/dry/cli/banner.rb +++ b/lib/dry/cli/banner.rb @@ -135,26 +135,37 @@ def self.extended_command_arguments(command) end.join("\n") end + # @since 1.5.0 + # @api private + def self.simple_option(option) + name = Inflector.dasherize(option.name) + name = if option.boolean? + "[no-]#{name}" + elsif option.flag? + name + elsif option.array? + "#{name}=VALUE1,VALUE2,.." + else + "#{name}=VALUE" + end + name = "#{name}, #{option.alias_names.join(", ")}" if option.aliases.any? + "--#{name}" + end + + # @since 1.5.0 + # @api private + def self.extended_option(option) + name = " #{simple_option(option).ljust(32)} # #{"REQUIRED " if option.required?}#{option.desc}" + name = "#{name}, default: #{option.default.inspect}" unless option.default.nil? + name + end + # @since 0.1.0 # @api private # def self.extended_command_options(command) result = command.options.map do |option| - name = Inflector.dasherize(option.name) - name = if option.boolean? - "[no-]#{name}" - elsif option.flag? - name - elsif option.array? - "#{name}=VALUE1,VALUE2,.." - else - "#{name}=VALUE" - end - name = "#{name}, #{option.alias_names.join(", ")}" if option.aliases.any? - name = " --#{name.ljust(30)}" - name = "#{name} # #{option.desc}" - name = "#{name}, default: #{option.default.inspect}" unless option.default.nil? - name + extended_option(option) end result << " --#{"help, -h".ljust(30)} # Print this help" diff --git a/lib/dry/cli/command.rb b/lib/dry/cli/command.rb index 84e50d70..2621e841 100644 --- a/lib/dry/cli/command.rb +++ b/lib/dry/cli/command.rb @@ -375,6 +375,12 @@ def self.arguments_sorted_by_usage_order end # rubocop:enable Metrics/PerceivedComplexity + # @since 1.5.0 + # @api private + def self.required_options + options.select(&:required?) + end + # @since 0.7.0 # @api private def self.subcommands @@ -404,15 +410,16 @@ def self.superclass_options extend Forwardable delegate %i[ + arguments + arguments_sorted_by_usage_order + default_params description examples - arguments + optional_arguments options params - default_params required_arguments - optional_arguments - arguments_sorted_by_usage_order + required_options subcommands ] => "self.class" diff --git a/lib/dry/cli/namespace.rb b/lib/dry/cli/namespace.rb index f89f15c8..368a3b52 100644 --- a/lib/dry/cli/namespace.rb +++ b/lib/dry/cli/namespace.rb @@ -76,6 +76,12 @@ def self.required_arguments [] end + # @since 1.5.0 + # @api private + def self.required_options + [] + end + # @since 1.1.1 # @api private def self.subcommands diff --git a/lib/dry/cli/parser.rb b/lib/dry/cli/parser.rb index 0b63ea5c..acd77df0 100644 --- a/lib/dry/cli/parser.rb +++ b/lib/dry/cli/parser.rb @@ -29,7 +29,6 @@ def self.call(command, arguments, prog_name) end end.parse!(arguments) - parsed_options = command.default_params.merge(parsed_options) parse_required_params(command, arguments, prog_name, parsed_options) rescue ::OptionParser::ParseError, ValueError Result.failure("ERROR: \"#{prog_name}\" was called with arguments \"#{original_arguments.join(" ")}\"") @@ -38,36 +37,68 @@ def self.call(command, arguments, prog_name) # @since 0.1.0 # @api private # - # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity, Layout/LineLength + # rubocop:disable Metrics/AbcSize def self.parse_required_params(command, arguments, prog_name, parsed_options) - parsed_params = match_arguments(command.arguments, arguments, parsed_options) - parsed_required_params = match_arguments(command.required_arguments, arguments, parsed_options) - all_required_params_satisfied = command.required_arguments.all? { |param| !parsed_required_params[param.name].nil? } + parsed_options_with_defaults = command.default_params.merge(parsed_options) + parsed_params = match_arguments(command.arguments, arguments, parsed_options_with_defaults) + parsed_required_params = match_arguments(command.required_arguments, arguments, parsed_options_with_defaults) + + all_required_params_satisfied = + command.required_arguments.all? { |param| !parsed_required_params[param.name].nil? } && + command.required_options.all? { |option| !parsed_options_with_defaults[option.name].nil? } unused_arguments = arguments.drop(command.required_arguments.length) unless all_required_params_satisfied - parsed_required_params_values = parsed_required_params.values.compact - - usage = "\nUsage: \"#{prog_name} #{command.required_arguments.map(&:description_name).join(" ")}" - - usage += " | #{prog_name} SUBCOMMAND" if command.subcommands.any? - - usage += '"' - - if parsed_required_params_values.empty? - return Result.failure("ERROR: \"#{prog_name}\" was called with no arguments#{usage}") - else - return Result.failure("ERROR: \"#{prog_name}\" was called with arguments #{parsed_required_params_values}#{usage}") - end + return error_message( + command, prog_name, parsed_required_params, parsed_options, parsed_options_with_defaults + ) end parsed_params.reject! { |_key, value| value.nil? } - parsed_options = parsed_options.merge(parsed_params) + parsed_options = parsed_options_with_defaults.merge(parsed_params) parsed_options = parsed_options.merge(args: unused_arguments) if unused_arguments.any? Result.success(parsed_options) end - # rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity, Layout/LineLength + # rubocop:enable Metrics/AbcSize + + def self.short_usage(command, prog_name) + usage = "\nUsage: \"#{prog_name} #{command.required_arguments.map(&:description_name).join(" ")}" + usage += " | #{prog_name} SUBCOMMAND" if command.subcommands.any? + if command.required_options.any? + usage += " #{command.required_options.map { |opt| + Banner.simple_option(opt) + }.join(" ")}" + end + usage += '"' + usage + end + + def self.error_message(command, prog_name, parsed_required_params, parsed_options, parsed_options_with_defaults) + parsed_required_params_values = parsed_required_params.values.compact + + missing_options = command.required_options.select { |option| + parsed_options_with_defaults[option.name].nil? + } + + error_msg = "ERROR: \"#{prog_name}\" was called with " + error_msg += if parsed_required_params_values.empty? + "no arguments" + else + "arguments #{parsed_required_params_values}" + end + error_msg += " and options #{parsed_options}" if parsed_options.any? + error_msg += short_usage(command, prog_name) + + if missing_options.any? + error_msg += "\nMissing required options:" + missing_options.each do |missing_option| + error_msg += "\n #{Banner.extended_option(missing_option)}" + end + end + + Result.failure(error_msg) + end def self.match_arguments(command_arguments, arguments, default_values) result = {} diff --git a/spec/integration/single_command_spec.rb b/spec/integration/single_command_spec.rb index d0a5ba7a..f484e9f3 100644 --- a/spec/integration/single_command_spec.rb +++ b/spec/integration/single_command_spec.rb @@ -9,7 +9,9 @@ it "shows usage" do _, stderr, = Open3.capture3("baz") expect(stderr).to eq( - "ERROR: \"#{cmd}\" was called with no arguments\nUsage: \"#{cmd} MANDATORY_ARG\"\n" + "ERROR: \"#{cmd}\" was called with no arguments\n"\ + "Usage: \"#{cmd} MANDATORY_ARG --mandatory-option=VALUE --mandatory-option-with-default=VALUE\"\n"\ + "Missing required options:\n --mandatory-option=VALUE # REQUIRED Mandatory option\n" ) end @@ -33,39 +35,67 @@ --option-one=VALUE, -1 VALUE # Option one --[no-]boolean-option, -b # Option boolean --option-with-default=VALUE, -d VALUE # Option default, default: "test" + --mandatory-option=VALUE # REQUIRED Mandatory option + --mandatory-option-with-default=VALUE # REQUIRED Mandatory option, default: "mandatory default" --help, -h # Print this help OUTPUT expect(output).to eq(expected_output) end + it "with mandatory arg and non-required option" do + _, stderr, = Open3.capture3("baz first_arg --option_one=test2") + + if RUBY_VERSION < "3.4" + expect(stderr).to eq( + "ERROR: \"#{cmd}\" was called with arguments [\"first_arg\"] and options {:option_one=>\"test2\"}\n" \ + "Usage: \"#{cmd} MANDATORY_ARG --mandatory-option=VALUE --mandatory-option-with-default=VALUE\"\n"\ + "Missing required options:\n --mandatory-option=VALUE # REQUIRED Mandatory option\n" + ) + else + expect(stderr).to eq( + "ERROR: \"#{cmd}\" was called with arguments [\"first_arg\"] and options {option_one: \"test2\"}\n" \ + "Usage: \"#{cmd} MANDATORY_ARG --mandatory-option=VALUE --mandatory-option-with-default=VALUE\"\n"\ + "Missing required options:\n --mandatory-option=VALUE # REQUIRED Mandatory option\n" + ) + end + end + it "with option_one" do - output = `baz first_arg --option-one=test2` + output = `baz first_arg --option-one=test2 --mandatory-option=required` if RUBY_VERSION < "3.4" expect(output).to eq( "mandatory_arg: first_arg. optional_arg: optional_arg. " \ - "Options: {:option_with_default=>\"test\", :option_one=>\"test2\"}\n" + "mandatory_option: required. " \ + "Options: {:option_with_default=>\"test\", :mandatory_option_with_default=>\"mandatory default\", " \ + ":option_one=>\"test2\", :mandatory_option=>\"required\"}\n" ) else expect(output).to eq( "mandatory_arg: first_arg. optional_arg: optional_arg. " \ - "Options: {option_with_default: \"test\", option_one: \"test2\"}\n" + "mandatory_option: required. " \ + "Options: {option_with_default: \"test\", mandatory_option_with_default: \"mandatory default\", " \ + "option_one: \"test2\", mandatory_option: \"required\"}\n" ) end end it "with combination of aliases" do - output = `baz first_arg -bd test3` + output = `baz first_arg -bd test3 --mandatory-option=required` if RUBY_VERSION < "3.4" expect(output).to eq( "mandatory_arg: first_arg. optional_arg: optional_arg. " \ - "Options: {:option_with_default=>\"test3\", :boolean_option=>true}\n" + "mandatory_option: required. " \ + "Options: {:option_with_default=>\"test3\", :mandatory_option_with_default=>\"mandatory default\", " \ + ":boolean_option=>true, :mandatory_option=>\"required\"}\n" ) else expect(output).to eq( "mandatory_arg: first_arg. optional_arg: optional_arg. " \ - "Options: {option_with_default: \"test3\", boolean_option: true}\n" + "mandatory_option: required. " \ + "Options: {option_with_default: \"test3\", mandatory_option_with_default: \"mandatory default\", " \ + "boolean_option: true, mandatory_option: \"required\"}\n" ) end end diff --git a/spec/support/fixtures/baz_command.rb b/spec/support/fixtures/baz_command.rb index bc1923f7..aa67e3bb 100644 --- a/spec/support/fixtures/baz_command.rb +++ b/spec/support/fixtures/baz_command.rb @@ -9,10 +9,13 @@ class CLI < Dry::CLI::Command option :option_one, aliases: %w[1], desc: "Option one" option :boolean_option, aliases: %w[b], desc: "Option boolean", type: :boolean option :option_with_default, aliases: %w[d], desc: "Option default", default: "test" + option :mandatory_option, desc: "Mandatory option", required: true + option :mandatory_option_with_default, desc: "Mandatory option", required: true, default: "mandatory default" def call(mandatory_arg:, optional_arg: "optional_arg", **options) puts "mandatory_arg: #{mandatory_arg}. " \ "optional_arg: #{optional_arg}. " \ + "mandatory_option: #{options[:mandatory_option]}. "\ "Options: #{options.inspect}" end end diff --git a/spec/unit/dry/cli/cli_spec.rb b/spec/unit/dry/cli/cli_spec.rb index b48a73ce..8c12b4ff 100644 --- a/spec/unit/dry/cli/cli_spec.rb +++ b/spec/unit/dry/cli/cli_spec.rb @@ -51,135 +51,171 @@ --option-one=VALUE, -1 VALUE # Option one --[no-]boolean-option, -b # Option boolean --option-with-default=VALUE, -d VALUE # Option default, default: "test" + --mandatory-option=VALUE # REQUIRED Mandatory option + --mandatory-option-with-default=VALUE # REQUIRED Mandatory option, default: "mandatory default" --help, -h # Print this help OUTPUT expect(output).to eq(expected_output) end it "with required_argument" do - output = capture_output { cli.call(arguments: ["first_arg"]) } + output = capture_output { cli.call(arguments: %w[first_arg --mandatory-option=required]) } if RUBY_VERSION < "3.4" expect(output).to eq( "mandatory_arg: first_arg. optional_arg: optional_arg. " \ - "Options: {:option_with_default=>\"test\"}\n" + "mandatory_option: required. " \ + "Options: {:option_with_default=>\"test\", :mandatory_option_with_default=>\"mandatory default\", " \ + ":mandatory_option=>\"required\"}\n" ) else expect(output).to eq( "mandatory_arg: first_arg. optional_arg: optional_arg. " \ - "Options: {option_with_default: \"test\"}\n" + "mandatory_option: required. " \ + "Options: {option_with_default: \"test\", mandatory_option_with_default: \"mandatory default\", " \ + "mandatory_option: \"required\"}\n" ) end end it "with optional_arg" do - output = capture_output { cli.call(arguments: %w[first_arg opt_arg]) } + output = capture_output { cli.call(arguments: %w[first_arg opt_arg --mandatory-option=required]) } if RUBY_VERSION < "3.4" expect(output).to eq( "mandatory_arg: first_arg. optional_arg: opt_arg. " \ - "Options: {:option_with_default=>\"test\", :args=>[\"opt_arg\"]}\n" + "mandatory_option: required. " \ + "Options: {:option_with_default=>\"test\", :mandatory_option_with_default=>\"mandatory default\", " \ + ":mandatory_option=>\"required\", :args=>[\"opt_arg\"]}\n" ) else expect(output).to eq( "mandatory_arg: first_arg. optional_arg: opt_arg. " \ - "Options: {option_with_default: \"test\", args: [\"opt_arg\"]}\n" + "mandatory_option: required. " \ + "Options: {option_with_default: \"test\", mandatory_option_with_default: \"mandatory default\", " \ + "mandatory_option: \"required\", args: [\"opt_arg\"]}\n" ) end end it "with underscored option_one" do - output = capture_output { cli.call(arguments: %w[first_arg --option_one=test2]) } + output = capture_output { cli.call(arguments: %w[first_arg --option_one=test2 --mandatory-option=required]) } if RUBY_VERSION < "3.4" expect(output).to eq( "mandatory_arg: first_arg. optional_arg: optional_arg. " \ - "Options: {:option_with_default=>\"test\", :option_one=>\"test2\"}\n" + "mandatory_option: required. " \ + "Options: {:option_with_default=>\"test\", :mandatory_option_with_default=>\"mandatory default\", " \ + ":option_one=>\"test2\", :mandatory_option=>\"required\"}\n" ) else expect(output).to eq( "mandatory_arg: first_arg. optional_arg: optional_arg. " \ - "Options: {option_with_default: \"test\", option_one: \"test2\"}\n" + "mandatory_option: required. " \ + "Options: {option_with_default: \"test\", mandatory_option_with_default: \"mandatory default\", " \ + "option_one: \"test2\", mandatory_option: \"required\"}\n" ) end end it "with option_one alias" do - output = capture_output { cli.call(arguments: %w[first_arg -1 test2]) } + output = capture_output { cli.call(arguments: %w[first_arg -1 test2 --mandatory-option=required]) } if RUBY_VERSION < "3.4" expect(output).to eq( "mandatory_arg: first_arg. optional_arg: optional_arg. " \ - "Options: {:option_with_default=>\"test\", :option_one=>\"test2\"}\n" + "mandatory_option: required. " \ + "Options: {:option_with_default=>\"test\", :mandatory_option_with_default=>\"mandatory default\", " \ + ":option_one=>\"test2\", :mandatory_option=>\"required\"}\n" ) else expect(output).to eq( "mandatory_arg: first_arg. optional_arg: optional_arg. " \ - "Options: {option_with_default: \"test\", option_one: \"test2\"}\n" + "mandatory_option: required. " \ + "Options: {option_with_default: \"test\", mandatory_option_with_default: \"mandatory default\", " \ + "option_one: \"test2\", mandatory_option: \"required\"}\n" ) end end it "with underscored boolean_option" do - output = capture_output { cli.call(arguments: %w[first_arg --boolean_option]) } + output = capture_output { cli.call(arguments: %w[first_arg --boolean_option --mandatory-option=required]) } if RUBY_VERSION < "3.4" expect(output).to eq( "mandatory_arg: first_arg. optional_arg: optional_arg. " \ - "Options: {:option_with_default=>\"test\", :boolean_option=>true}\n" + "mandatory_option: required. " \ + "Options: {:option_with_default=>\"test\", :mandatory_option_with_default=>\"mandatory default\", " \ + ":boolean_option=>true, :mandatory_option=>\"required\"}\n" ) else expect(output).to eq( "mandatory_arg: first_arg. optional_arg: optional_arg. " \ - "Options: {option_with_default: \"test\", boolean_option: true}\n" + "mandatory_option: required. " \ + "Options: {option_with_default: \"test\", mandatory_option_with_default: \"mandatory default\", " \ + "boolean_option: true, mandatory_option: \"required\"}\n" ) end end it "with boolean_option alias" do - output = capture_output { cli.call(arguments: %w[first_arg -b]) } + output = capture_output { cli.call(arguments: %w[first_arg -b --mandatory-option=required]) } if RUBY_VERSION < "3.4" expect(output).to eq( "mandatory_arg: first_arg. optional_arg: optional_arg. " \ - "Options: {:option_with_default=>\"test\", :boolean_option=>true}\n" + "mandatory_option: required. " \ + "Options: {:option_with_default=>\"test\", :mandatory_option_with_default=>\"mandatory default\", " \ + ":boolean_option=>true, :mandatory_option=>\"required\"}\n" ) else expect(output).to eq( "mandatory_arg: first_arg. optional_arg: optional_arg. " \ - "Options: {option_with_default: \"test\", boolean_option: true}\n" + "mandatory_option: required. " \ + "Options: {option_with_default: \"test\", mandatory_option_with_default: \"mandatory default\", " \ + "boolean_option: true, mandatory_option: \"required\"}\n" ) end end it "with underscoreed option_with_default alias" do - output = capture_output { cli.call(arguments: %w[first_arg --option_with_default=test3]) } + output = capture_output do + cli.call(arguments: %w[first_arg --option_with_default=test3 --mandatory-option=required]) + end if RUBY_VERSION < "3.4" expect(output).to eq( "mandatory_arg: first_arg. optional_arg: optional_arg. " \ - "Options: {:option_with_default=>\"test3\"}\n" + "mandatory_option: required. " \ + "Options: {:option_with_default=>\"test3\", :mandatory_option_with_default=>\"mandatory default\", " \ + ":mandatory_option=>\"required\"}\n" ) else expect(output).to eq( "mandatory_arg: first_arg. optional_arg: optional_arg. " \ - "Options: {option_with_default: \"test3\"}\n" + "mandatory_option: required. " \ + "Options: {option_with_default: \"test3\", mandatory_option_with_default: \"mandatory default\", " \ + "mandatory_option: \"required\"}\n" ) end end it "with combination of aliases" do - output = capture_output { cli.call(arguments: %w[first_arg -bd test3]) } + output = capture_output { cli.call(arguments: %w[first_arg -bd test3 --mandatory-option=required]) } if RUBY_VERSION < "3.4" expect(output).to eq( "mandatory_arg: first_arg. optional_arg: optional_arg. " \ - "Options: {:option_with_default=>\"test3\", :boolean_option=>true}\n" + "mandatory_option: required. " \ + "Options: {:option_with_default=>\"test3\", :mandatory_option_with_default=>\"mandatory default\", " \ + ":boolean_option=>true, :mandatory_option=>\"required\"}\n" ) else expect(output).to eq( "mandatory_arg: first_arg. optional_arg: optional_arg. " \ - "Options: {option_with_default: \"test3\", boolean_option: true}\n" + "mandatory_option: required. " \ + "Options: {option_with_default: \"test3\", mandatory_option_with_default: \"mandatory default\", " \ + "boolean_option: true, mandatory_option: \"required\"}\n" ) end end