Created
August 21, 2022 22:03
-
-
Save masasakano/45ee0d737f2d33122e8ff44007693b40 to your computer and use it in GitHub Desktop.
Ruby optparse extensive sample code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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