Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ and this project adheres to [Break Versioning](https://www.taoensso.com/break-ve
```ruby
style.bold.red["%s"] % "boom" # => "\e[1;31mboom\e[0m"
```
- Enforce `required: true` on options. (@capripot and @timriley in #122)

Before, `required: true` was accepted on options but did nothing. Now, if a required option is not given, the command does not run, and prints an error that names the missing option. Required options are marked as REQUIRED in the help output. A required option with a `default:` always has a value, so it is not enforced and not marked as required.

If your commands already declare options with `required: true` and no default, they will now fail when the user leaves those options out.

### Changed

Expand Down
29 changes: 17 additions & 12 deletions lib/dry/cli/banner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ def self.command_option_entries(command)

# @api private
def self.option_description(option)
description = option.desc
description = "#{"REQUIRED " if option.required?}#{option.desc}"
unless option.default.nil?
description = "#{description}, default: #{option.default.inspect}"
end
Expand All @@ -154,21 +154,26 @@ def self.option_description(option)

# @api private
def self.option_label(option)
base = Inflector.dasherize(option.name)
label =
if option.boolean?
"--[no-]#{base}"
elsif option.flag?
"--#{base}"
elsif option.array?
"--#{base}=VALUE1,VALUE2,.."
else
"--#{base}=VALUE"
end
label = option_usage(option)
label = "#{label}, #{option.alias_names.join(", ")}" if option.aliases.any?
label
end

# @api private
def self.option_usage(option)
base = Inflector.dasherize(option.name)

if option.boolean?
"--[no-]#{base}"
elsif option.flag?
"--#{base}"
elsif option.array?
"--#{base}=VALUE1,VALUE2,.."
else
"--#{base}=VALUE"
end
end

def self.subcommands_list(subcommands)
subcommands.map do |subcommand_name, subcommand|
" #{subcommand_name.ljust(32)} # #{subcommand.command.description}"
Expand Down
42 changes: 38 additions & 4 deletions lib/dry/cli/command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,34 @@ def self.argument(name, options = {})
# #
# # Options:
# # --port=VALUE, -p VALUE
#
# @example Required
# require "dry/cli"
#
# class Deploy < Dry::CLI::Command
# option :env, required: true, desc: "The target environment"
#
# def call(env:, **)
# puts "deploying to #{env}"
# end
# end
#
# # $ foo deploy --env=production
# # deploying to production
#
# # $ foo deploy
# # ERROR: "foo deploy" is missing required option --env
# # Usage: "foo deploy --env=VALUE"
#
# # $ foo deploy --help
# # # ...
# #
# # Options:
# # --env=VALUE # REQUIRED The target environment
# # --help, -h # Print this help
#
# # A required option with a default always has a value, so it is not
# # enforced and not shown as REQUIRED.
def self.option(name, options = {})
new_op = Option.new(name, options)

Expand Down Expand Up @@ -436,6 +464,11 @@ def self.arguments_sorted_by_usage_order
end
# rubocop:enable Metrics/PerceivedComplexity

# @api private
def self.required_options
options.select(&:required?)
end

# @since 0.7.0
# @api private
def self.subcommands
Expand Down Expand Up @@ -550,16 +583,17 @@ def self.new(*args, **kwargs, &block)
extend Forwardable

delegate %i[
arguments
arguments_sorted_by_usage_order
default_params
description
long_description
examples
arguments
optional_arguments
options
params
default_params
required_arguments
optional_arguments
arguments_sorted_by_usage_order
required_options
subcommands
] => "self.class"

Expand Down
5 changes: 5 additions & 0 deletions lib/dry/cli/namespace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ def self.required_arguments
[]
end

# @api private
def self.required_options
[]
end

# @since 1.1.1
# @api private
def self.subcommands
Expand Down
14 changes: 12 additions & 2 deletions lib/dry/cli/option.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ def desc
values ? "#{desc}: (#{values_description})" : desc
end

# An option with a default always has a value, so the user never needs to give it.
def required?
options[:required]
options[:required] && default.nil?
end

def type
Expand Down Expand Up @@ -79,7 +80,9 @@ def argument?
# compares meaningfully. `:desc`, `:label` and `:aliases` are excluded because they don't
# change how a value is parsed; the first declaration wins.
def compatibility_options
{type: type, required: !!required?, values: values, default: default}
# Compare the declared :required rather than `#required?`, which is false whenever there's
# a default
{type: type, required: !!options[:required], values: values, default: default}
end

# The names of the `#compatibility_options` that stop this and `other` being interchangeable,
Expand Down Expand Up @@ -169,6 +172,13 @@ class Argument < Option
def argument?
true
end

# Unlike options, treat an argument with a default as required.
#
# @api private
def required?
options[:required]
end
end
end
end
78 changes: 57 additions & 21 deletions lib/dry/cli/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,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 => exception
Result.failure("ERROR: \"#{prog_name}\" was called with #{exception.reason} \"#{exception.args.join(" ")}\"")
Expand All @@ -55,38 +54,75 @@ 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)

missing_arguments = command.required_arguments.select { |param| parsed_required_params[param.name].nil? }
missing_options = command.required_options.select { |option| parsed_options_with_defaults[option.name].nil? }

unused_arguments = arguments.drop(command.required_arguments.length)

unless all_required_params_satisfied
if missing_arguments.any? || missing_options.any?
return missing_params_failure(
command, prog_name, parsed_required_params, missing_arguments, missing_options
)
end

parsed_params.reject! { |_key, value| value.nil? }
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

# @api private
def self.short_usage(command, prog_name)
usage = [
prog_name,
*command.required_arguments.map(&:description_name),
*command.required_options.map { |option| Banner.option_usage(option) }
].join(" ")
usage += " | #{prog_name} SUBCOMMAND" if command.subcommands.any?

"Usage: \"#{usage}\""
end

# @api private
def self.missing_params_failure(command, prog_name, parsed_required_params, missing_arguments, missing_options)
lines = []

if missing_arguments.any?
# Drop nils as well as empty arrays; an array argument that consumed nothing was not
# given, so it shouldn't be listed among the arguments that were.
parsed_required_params_values = parsed_required_params.values.compact.reject { |v| v.is_a?(Array) && v.empty? }
parsed_required_params_values =
parsed_required_params.values.compact.reject { |v| v.is_a?(Array) && v.empty? }

usage = "\nUsage: \"#{prog_name} #{command.required_arguments.map(&:description_name).join(" ")}"
lines <<
if parsed_required_params_values.empty?
"ERROR: \"#{prog_name}\" was called with no arguments"
else
"ERROR: \"#{prog_name}\" was called with arguments #{parsed_required_params_values}"
end
lines << "Missing #{missing_options_label(missing_options)}" if missing_options.any?
else
lines << "ERROR: \"#{prog_name}\" is missing #{missing_options_label(missing_options, separator: " ")}"
end

usage += " | #{prog_name} SUBCOMMAND" if command.subcommands.any?
lines << short_usage(command, prog_name)

usage += '"'
Result.failure(lines.join("\n"))
end

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
end
# @api private
def self.missing_options_label(options, separator: ": ")
label = options.one? ? "required option" : "required options"
names = options.map { |option| "--#{Inflector.dasherize(option.name)}" }

parsed_params.reject! { |_key, value| value.nil? }
parsed_options = parsed_options.merge(parsed_params)
parsed_options = parsed_options.merge(args: unused_arguments) if unused_arguments.any?
Result.success(parsed_options)
"#{label}#{separator}#{names.join(", ")}"
end
# rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity, Layout/LineLength

# rubocop:disable Metrics/PerceivedComplexity
def self.match_arguments(command_arguments, arguments, default_values)
Expand Down
Loading
Loading