Skip to content

Instantly share code, notes, and snippets.

@masasakano
Created August 21, 2022 22:03
Show Gist options
  • Save masasakano/45ee0d737f2d33122e8ff44007693b40 to your computer and use it in GitHub Desktop.
Save masasakano/45ee0d737f2d33122e8ff44007693b40 to your computer and use it in GitHub Desktop.
Ruby optparse extensive sample code
require 'optparse'
require 'optparse/time' # if Time is used.
#require 'ostruct'
require 'pp'
# This example file for using Ruby optparse is based on the original example
# code in Github, specifically, commit number f2b8318, or roughly for Ruby 3.1.2.
# https://github.com/ruby/optparse
# I have added several sections and modified some parts, as well as annotated
# the code extensively with my comments.
#
# A caveat I found in Ruby-optparse is the innate ambiguity of an optional
# argument associated with (followed by) an optional arguments. Basically,
# it is possible to accept any of "--this ABC", "--this=ABC", and just "--this".
#
# Semantically, this is expressed as
# parser.on("--time [TIME]", ...)
# whereas a mandatory associated argument is as
# parser.on("--time TIME", ...)
#
# The former leads to equivocal situations; for example, when a command is
# my_command --this ABC
# "*ABC*" can be either the optional argument of *--this* or a main argument
# of *my_command*. Either interpretation is perfectly legitimate.
#
# In this sense, I personally think it is not good to allow or use the feature of
# "an optional argument associated with an optional argument"
# in the first place from a software-engineering point of view.
# Any optional argument either must or must **not** take an associated argument.
#
# However, it seems "an optional argument associated with a mandatory argument"
# in *optparse* leads to a strange situation. For example, suppose
# another optional argument *--that* takes an associated argument. Then,
# my_command --this --that ABC -x
# yields *--this=--that*, which is expected, but "ABC" silently disappears
# during processing, even though "ABC" in this case should be interpreted
# as a main argument.
#
# By contrast, for "an optional argument associated with an optional argument",
# my_command --this --that ABC -x
# yields "*--this*" and "*--that=ABC*", which is an understandable behaviour.
#
# So, I think the best general strategy when you need an optional argument
# associated with an argument is to define always
# "an optional argument associated with an optional argument"
# and manually raise an exception *OptionParser::MissingArgument*
# if no associated argument (aka, *nil*) is given to the option.
#
# There are exceptions. For example, if you explicitly evaluate
# each associated argument (well, perhaps you should in terms of security)
# and detect forbidden arguments like "*--this=--that", you don't need
# "an optional argument associated with an optional argument"
# but just
# "an optional argument associated with a mandatory argument".
# Or, if the associated argument is constrained to be one of the given list,
# then the confusion would be limited.
#
# In the following code, the problem is dealt as such.
#
# Here, the optional arguments are stored in an object of
# *OptparseExample::ScriptOptions*, which has accessors for all options
# set according to the given optional arguments and their default values.
# The original global arguments *ARGV* will reduce to contain only
# the main arguments.
#
class OptparseExample
Version = '1.0.0'
CODES = %w[iso-2022-jp shift_jis euc-jp utf8 binary]
CODE_ALIASES = { "jis" => "iso-2022-jp", "sjis" => "shift_jis" }
TYPE_OPTIONS = %w(text binary auto)
# You may put this definition at the top level and define as
# class OptionParser::InvalidRepeatNumberOption
#
# OptionParser::ParseError class accepts the attribute "additional". You may set
# an additional piece of information (you must consult the source code to know how).
# https://github.com/ruby/optparse/blob/master/lib/optparse.rb
class InvalidRepeatNumberOption < OptionParser::ParseError
const_set(:Reason, 'repeat number must be a positive integer')
end
# Default values for the command-line options.
# If nil, it means they are mandatory AS DEFINED in "def parse(args)" below,
# except for the 2 arguments of "inplace" and "extension".
# The Hash keyword (and attribute) names should be identical to the command-line
# option names, except "inplace" and "extension" in this case as demonstrated
# (in *parse()* etc).
DEF_OPTS = {
inplace: false,
extension: nil, # NOTE: inplace and extension are set with the same argument: --inplace
encoding: "utf8",
transfer_type: :auto,
verbose: false,
delay: nil,
time: Time.now,
record_separator: "\0",
list: [],
repeat: 0
}
class ScriptOptions
# attr_accessor :inplace, :encoding, :transfer_type,
# :verbose, :extension, :delay, :time, :record_separator,
# :list, :repeat
DEF_OPTS.each_key do |ek|
attr_accessor ek
end
def initialize
DEF_OPTS.each_pair do |ek, ev|
self.public_send(ek.to_s+"=", ev)
end
#self.inplace = false
end
def define_options(parser)
parser.banner = "Usage: example.rb [options] [--] ARG1..."
parser.separator ""
parser.separator "Specific options:"
# add additional options
perform_inplace_option(parser)
repeat_positive_option(parser) # added personally
delay_execution_option(parser)
execute_at_time_option(parser)
specify_record_separator_option(parser)
list_example_option(parser)
specify_encoding_option(parser)
optional_option_argument_with_keyword_completion_option(parser)
boolean_verbose_option(parser)
parser.separator ""
parser.separator "Common options:"
# No argument, shows at tail. This will print an options summary.
# Try it and see!
parser.on_tail("-h", "--help", "Show this message") do
puts parser
exit
end
# Another typical switch to print the version.
parser.on_tail("--version", "Show version") do
puts Version
exit
end
end
def perform_inplace_option(parser)
# Specifies an optional option argument
#
# The square brackets "[]" (like "--inplace [EXTENSION]",) means the argument is optional.
# So, for a command "my_command -i --verbose", parser.on gives nil.
#
# Without the square brackets "[]" means the argument is mandatory; then,
# if given "my_command -i --type=bin", this ext returns "--type=bin", and worse,
# if given "my_command -i --type bin", this ext returns "--type" and "bin" is
# sliently ignored!!! (Bug?)
#
# My conclusion: the argument should be always given as "[OPT_ARG]" with square
# brackets, and nil should be dealt by each routine!
parser.on("-i", "--inplace [EXTENSION]",
"Edit ARGV files in place",
"(make backup if EXTENSION supplied)") do |ext|
self.inplace = true
self.extension = ext || ''
self.extension.sub!(/\A\.?(?=.)/, ".") # Ensure extension begins with dot.
end
end
def repeat_positive_option(parser)
# Cast 'repeat' argument to a Integer.
# Note that "=" in the format string is recommended for negative integers; otherwise "-r -3" may be interpreted as two options of "-r" and "-3".
#
# Argument of "2.5" or "25a" results in *OptionParser::InvalidArgument* and
# arguments are converted like: "2_5" to 25, "025" to 21, "0x25" to 37,
# according to Ruby's standard interpretation. If you prefer a decimal
# interpretation, you must write your own routine of conversion.
parser.on("-r", "--repeat=NUM", Integer, "Repeat NUM times") do |n|
if n < 1
# Simple
# raise InvalidRepeatNumberOption.new(n)
# generates an Exception object *err* whose err.args is one of
# ["-r"], ["--repeat"], ["--repeat=5"]
# In other words, the argument (like 5 in this case) info is lost. However,
# raise InvalidRepeatNumberOption, n
# raise InvalidRepeatNumberOption.new(n)
# generates an Exception object *err* whose err.args is one of
# ["-r", 5], ["--repeat", 5], ["--repeat=5"]
# where the last one happens when ARGV contains "--repeat=5" in the first place.
#
# **NOTE** "n" in the first form
# raise InvalidRepeatNumberOption, n
# is the standard way to display a "message"(!), but it is slightly different
# in *OptionParser::ParseError* object! It is still printed in the message
# (*err.message*), but it will be contained in *err.args*, too.
#
# Note you can include separate object with
# InvalidRepeatNumberOption.new(n, additional: "arbitrary extra info")
# which is then retrieved with *err.additional*
raise InvalidRepeatNumberOption, n
end
self.repeat = n
end
end
def delay_execution_option(parser)
# Cast 'delay' argument to a Float.
#
# With "--delay [N]", if specified like "COMMAND --delay" ("--delay" is
# the last argument and no main arguments are specified), n is nil (expectedly).
# With "--delay NUM", if in the same situation, this routine
# is silently ignored AND yet no main argument is present!! (Bug??).
parser.on("--delay [NUM]", Float, "Delay N seconds before executing") do |n|
raise OptionParser::MissingArgument if !n # This is necessary, providing NUM is mandatory!
self.delay = n
end
end
def execute_at_time_option(parser)
# Cast 'time' argument to a Time object.
parser.on("-t", "--time [TIME]", Time, "Begin execution at given time") do |time|
raise OptionParser::MissingArgument if !time # This is necessary, providing TIME is mandatory!
self.time = time
end
end
def specify_record_separator_option(parser)
# Cast to octal integer.
parser.on("-F", "--irs [OCTAL]", OptionParser::OctalInteger,
"Specify record separator (default \\0)") do |rs|
raise OptionParser::MissingArgument if !rs # This is necessary, providing OCTAL is mandatory!
self.record_separator = rs
end
end
def list_example_option(parser)
# List of arguments.
#
# NOTE: if "--list x --list y" is specified, "x" is ignored!
parser.on("--list x,y,z", Array, "Example 'list' of arguments") do |list|
raise OptionParser::MissingArgument if !list # This is necessary, providing list is mandatory!
self.list = list
end
end
def specify_encoding_option(parser)
# Keyword completion. We are specifying a specific set of arguments (CODES
# and CODE_ALIASES - notice the latter is a Hash), and the user may provide
# the shortest unambiguous text.
#
# NOTE: two separate arguments (CODES, CODE_ALIASES) can be given. If a Hash
# is given it means aliases (both the key and content are taken into account)
# *and* the value (not key) is stored. For example,
# parser.on("--ab SO", ["xyz"], {ori: "Orion"}, "Select")
# means "--ab o" yields '@my_key = "Orion"' as opposed to just "o" or "ori".
code_list = (CODE_ALIASES.keys + CODES).join(', ')
parser.on("--code CODE", CODES, CODE_ALIASES, "Select encoding", # The argument after a comma is printed after \n.
"(#{code_list})") do |encoding|
self.encoding = encoding
end
end
def optional_option_argument_with_keyword_completion_option(parser)
# Optional '--type' option argument with keyword completion.
#
# NOTE: the "shortest unambiguous keyword" is sufficient; e.g., "--type a" is OK.
# NOTE: If TYPE_OPTIONS is Array of Symbol like [:ab, :cd, :ef],
# parser.on gives Symbol.
parser.on("--type [TYPE]", TYPE_OPTIONS, #[:text, :binary, :auto],
"Select transfer type (text, binary, auto)") do |t|
self.transfer_type = t.to_sym
end
end
def boolean_verbose_option(parser)
# Boolean switch.
parser.on("-v", "--[no-]verbose", "Run verbosely") do |v|
self.verbose = v
end
end
end # class ScriptOptions
#
# Return a structure describing the options.
#
def parse(args)
# The options specified on the command line will be collected in
# *options*.
#
# NOTE: if undefined options are specified, OptionParser::InvalidOption is raised.
@options = ScriptOptions.new
@args = OptionParser.new do |parser|
@options.define_options(parser)
begin
# new_argv = opt.parse(args) # the original args (=ARGV in this case) unchanged.
# opt.parse!(args, into: opts_hash) # Options are automatically set to Hash opts_hash
# opt.order!(args) # stops parsing once meeting a main argument.
parser.parse!(args)
# Constraints on the number of main arguments.
if ARGV.size < 1
warn "ERROR: 1 or more main arguments are mandatory."
$stderr.puts parser # help message is printed to STDERR.
exit 1
end
# Error if mandatory options are not specified.
(DEF_OPTS.keys - %i(extension inplace)).each do |ek| # 2 arguments are exceptions
if @options.public_send(ek).nil? # *.nil? is used b/c "false" must be accepted.
# Assuming the Hash keyword (and attribute) name is identical to the option name!
warn "ERROR: Optional argument --#{ek.to_s} is mandatory."
exit 1
end
end
# Cross-constraints on optional arguments.
if @options.repeat.odd? && @options.list.empty?
warn "ERROR: Optional argument --list is mandatory when --repeat=#{@options.repeat.to_s} is an odd number."
exit 1
end
rescue InvalidRepeatNumberOption => err
# My custom error
warn sprintf "ERROR: %s: %s", err.reason, err.args.join(" ")
exit 1
rescue OptionParser::MissingArgument => err
# Missing argument for optional arguments.
warn sprintf "ERROR: %s: %s", err.reason, err.args.join(" ")
exit 1
rescue OptionParser::InvalidArgument => err
# invalid argument: --type abc (OptionParser::InvalidArgument)
# For ParseError class, see https://github.com/ruby/optparse/blob/master/lib/optparse.rb
# In short,
# err.args == ["--type", "my_given_arg"]
# err.additional => usually nil?
# err.reason == "invalid argument"
msg =
case err.args[0]
when "-r", "--repeat"
# "-r 25a" or "-r 009" raises this.
sprintf "ERROR: %s (for Integer): %s", err.reason, err.args.join(" ")
when "--code"
sprintf "ERROR(%s): \"%s\" must be one of [%s] (or shortest unambiguous string).", err.reason, err.args.join(" "), (CODE_ALIASES.keys + CODES).join(', ') # like [jis, sjis, ...]
when "--type"
sprintf "ERROR(%s): \"%s\" must be one of %s.", err.reason, err.args.join(" "), TYPE_OPTIONS.inspect # like ["text", "binary", ...]; notice double-quotations.
else
# Unexpected errors
raise
end
warn msg
exit 1
rescue OptionParser::ParseError => err
# Other types of argument-handling errors
raise
end
end
@options
end
attr_reader :parser, :options
end # class OptparseExample
################# Main routine ###################
if $0 == __FILE__
# ENV['POSIXLY_CORRECT'] = true # If true, options after the main arguments are recognised as main arguments.
example = OptparseExample.new
options = example.parse(ARGV)
pp example.options
pp options # example.options
pp ARGV
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment