Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Permitted regexp/range support #158

Merged
merged 3 commits into from
May 21, 2024
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
90 changes: 71 additions & 19 deletions lib/optimist.rb
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,8 @@ def opt(name, desc = "", opts = {}, &b)
@short[short] = o.name
end

raise ArgumentError, "permitted values for option #{o.long.inspect} must be either nil or an array;" unless o.permitted.nil? or o.permitted.is_a? Array
raise ArgumentError, "permitted values for option #{o.long.long.inspect} must be either nil, Range, Regexp or an Array;" unless o.permitted_type_valid?

@specs[o.name] = o
@order << [:opt, o.name]
end
Expand Down Expand Up @@ -410,9 +410,11 @@ def parse(cmdline = ARGV)
params << (opts.array_default? ? opts.default.clone : [opts.default])
end

params[0].each do |p|
raise CommandlineError, "option '#{arg}' only accepts one of: #{opts.permitted.join(', ')}" unless opts.permitted.include? p
end unless opts.permitted.nil?
if params.first && opts.permitted
params.first.each do |val|
opts.validate_permitted(arg, val)
end
end

vals["#{sym}_given".intern] = true # mark argument as specified on the commandline

Expand Down Expand Up @@ -730,7 +732,7 @@ def add(values)

class Option

attr_accessor :name, :short, :long, :default, :permitted
attr_accessor :name, :short, :long, :default, :permitted, :permitted_response
attr_writer :multi_given

def initialize
Expand All @@ -741,6 +743,7 @@ def initialize
@hidden = false
@default = nil
@permitted = nil
@permitted_response = "option '%{arg}' only accepts %{valid_string}"
@optshash = Hash.new()
end

Expand Down Expand Up @@ -796,32 +799,80 @@ def full_description
desc_str
end

## Format stdio like objects to a string
def format_stdio(obj)
case obj
when $stdout then '<stdout>'
when $stdin then '<stdin>'
when $stderr then '<stderr>'
else obj # pass-through-case
end
end

## Generate the default value string for the educate line
private def default_description_str str
default_s = case default
when $stdout then '<stdout>'
when $stdin then '<stdin>'
when $stderr then '<stderr>'
when Array
default.join(', ')
else
default.to_s
format_stdio(default).to_s
end
defword = str.end_with?('.') ? 'Default' : 'default'
" (#{defword}: #{default_s})"
end

def permitted_valid_string
case permitted
when Array
return "one of: " + permitted.to_a.map(&:to_s).join(', ')
when Range
return "value in range of: #{permitted}"
when Regexp
return "value matching: #{permitted.inspect}"
end
raise NotImplementedError, "invalid branch"
end

def permitted_type_valid?
case permitted
when NilClass, Array, Range, Regexp then true
else false
end
end

def validate_permitted(arg, value)
return true if permitted.nil?
unless permitted_value?(value)
format_hash = {arg: arg, given: value, value: value, valid_string: permitted_valid_string(), permitted: permitted }
raise CommandlineError, permitted_response % format_hash
end
true
end

# incoming values from the command-line should be strings, so we should
# stringify any permitted types as the basis of comparison.
def permitted_value?(val)
case permitted
when nil then true
when Regexp then val.match? permitted
when Range then permitted.to_a.map(&:to_s).include? val
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a shame we can't use .include? directly on the Range. Something we might want to consider in the future somehow.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iirc I did this b/c of the typing mismatch between the given arg from the cmdline (always a string) and the range type, though maybe it could be compared the other way around?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Offhand, the way you did it is the only way I can think of, so overall I'm fine to merge. We get strings from the command line so there's really not much we can do about that.

when Array then permitted.map(&:to_s).include? val
else false
end
end

## Generate the permitted values string for the educate line
private def permitted_description_str str
permitted_s = permitted.map do |p|
case p
when $stdout then '<stdout>'
when $stdin then '<stdin>'
when $stderr then '<stderr>'
else
p.to_s
end
end.join(', ')
permitted_s = case permitted
when Array
permitted.map do |p|
format_stdio(p).to_s
end.join(', ')
when Range, Regexp
permitted.inspect
else
raise NotImplementedError
end
permword = str.end_with?('.') ? 'Permitted' : 'permitted'
" (#{permword}: #{permitted_s})"
end
Expand Down Expand Up @@ -868,6 +919,7 @@ def self.create(name, desc="", opts={}, settings={})
## autobox :default for :multi (multi-occurrence) arguments
defvalue = [defvalue] if defvalue && multi_given && !defvalue.kind_of?(Array)
opt_inst.permitted = permitted
opt_inst.permitted_response = opts[:permitted_response] if opts[:permitted_response]
opt_inst.default = defvalue
opt_inst.name = name
opt_inst.opts = opts
Expand Down
10 changes: 10 additions & 0 deletions test/optimist/parser_educate_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,16 @@ def test_help_has_grammatical_permitted_text
assert help[1] =~ /Permitted/
assert help[2] =~ /permitted/
end

def test_help_with_permitted_range
parser.opt :rating, 'rating', permitted: 1..5
parser.opt :hex, 'hexadecimal', permitted: /^[0-9a-f]/i
sio = StringIO.new 'w'
parser.educate sio
help = sio.string.split "\n"
assert_match %r{rating \(permitted: 1\.\.5\)}, help[1]
assert_match %r{hexadecimal \(permitted: \/\^\[0-9a-f\]\/i\)}, help[2]
end
############

private
Expand Down
93 changes: 93 additions & 0 deletions test/optimist/parser_permitted_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
require 'stringio'
require 'test_helper'

module Optimist

class ParserPermittedTest < ::Minitest::Test
def setup
@p = Parser.new
end

def test_permitted_flags_filter_inputs
@p.opt "arg", "desc", :type => :strings, :permitted => %w(foo bar)

result = @p.parse(%w(--arg foo))
assert_equal ["foo"], result["arg"]
assert_raises(CommandlineError) { @p.parse(%w(--arg baz)) }
end

def test_permitted_invalid_scalar_value
err_regexp = /permitted values for option "(bad|mad|sad)" must be either nil, Range, Regexp or an Array/
assert_raises(ArgumentError, err_regexp) {
@p.opt 'bad', 'desc', :permitted => 1
}
assert_raises(ArgumentError, err_regexp) {
@p.opt 'mad', 'desc', :permitted => "A"
}
assert_raises_errmatch(ArgumentError, err_regexp) {
@p.opt 'sad', 'desc', :permitted => :abcd
}
end

def test_permitted_with_string_array
@p.opt 'fiz', 'desc', :type => 'string', :permitted => ['foo', 'bar']
@p.parse(%w(--fiz foo))
assert_raises_errmatch(CommandlineError, /option '--fiz' only accepts one of: foo, bar/) {
@p.parse(%w(--fiz buz))
}
end
def test_permitted_with_symbol_array
@p.opt 'fiz', 'desc', :type => 'string', :permitted => %i[dog cat]
@p.parse(%w(--fiz dog))
@p.parse(%w(--fiz cat))
assert_raises_errmatch(CommandlineError, /option '--fiz' only accepts one of: dog, cat/) {
@p.parse(%w(--fiz rat))
}
end

def test_permitted_with_numeric_array
@p.opt 'mynum', 'desc', :type => Integer, :permitted => [1,2,4]
@p.parse(%w(--mynum 1))
@p.parse(%w(--mynum 4))
assert_raises_errmatch(CommandlineError, /option '--mynum' only accepts one of: 1, 2, 4/) {
@p.parse(%w(--mynum 3))
}
end

def test_permitted_with_numeric_range
@p.opt 'fiz', 'desc', :type => Integer, :permitted => 1..3
opts = @p.parse(%w(--fiz 1))
assert_equal opts['fiz'], 1
opts = @p.parse(%w(--fiz 3))
assert_equal opts['fiz'], 3
assert_raises_errmatch(CommandlineError, /option '--fiz' only accepts value in range of: 1\.\.3/) {
@p.parse(%w(--fiz 4))
}
end

def test_permitted_with_regexp
@p.opt 'zipcode', 'desc', :type => String, :permitted => /^[0-9]{5}$/
@p.parse(%w(--zipcode 39762))
err_regexp = %r|option '--zipcode' only accepts value matching: /\^\[0-9\]\{5\}\$/|
assert_raises_errmatch(CommandlineError, err_regexp) {
@p.parse(%w(--zipcode A9A9AA))
}
end
def test_permitted_with_reason
# test all keys passed into the formatter for the permitted_response
@p.opt 'zipcode', 'desc', type: String, permitted: /^[0-9]{5}$/,
permitted_response: "opt %{arg} should be a zipcode but you have %{value}"
@p.opt :wig, 'wig', type: Integer, permitted: 1..4,
permitted_response: "opt %{arg} exceeded four wigs (%{valid_string}), %{permitted}, but you gave '%{given}'"
err_regexp = %r|opt --zipcode should be a zipcode but you have A9A9AA|
assert_raises_errmatch(CommandlineError, err_regexp) {
@p.parse(%w(--zipcode A9A9AA))
}
err_regexp = %r|opt --wig exceeded four wigs \(value in range of: 1\.\.4\), 1\.\.4, but you gave '5'|
assert_raises_errmatch(CommandlineError, err_regexp) {
@p.parse(%w(--wig 5))
}
end

end
end
8 changes: 0 additions & 8 deletions test/optimist/parser_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -120,14 +120,6 @@ def test_required_flags_are_required
assert_raises(CommandlineError) { @p.parse(%w(--arg2 --arg3)) }
end

def test_permitted_flags_filter_inputs
@p.opt "arg", "desc", :type => :strings, :permitted => %w(foo bar)

result = @p.parse(%w(--arg foo))
assert_equal ["foo"], result["arg"]
assert_raises(CommandlineError) { @p.parse(%w(--arg baz)) }
end

## flags that take an argument error unless given one
def test_argflags_demand_args
@p.opt "goodarg", "desc", :type => String
Expand Down
Loading