Slop 4
I had an older post about ruby and slop but that’s with Slop 3 which is basically locked to Ruby 1.9.
No doubt, this post will bitrot too so please pay attention to the post date. The current ruby
is about 2.3.0
, slop 4.3
is current, it’s 2016 and the US election cycle is awful.
update This CLI has since been ported to crystal as an example of that process. Porting a Rubygem to Crystal
It’s ok that you need help
I think the most confusing thing about slop is that it has great examples and documentation but when you try to break this apart in a real app with small methods and single responsibilities some things sort of get weird. I think this is because of exception handling as logic control but I’m not sure enough to say slop is doing something wrong that makes this weird. In my example
I refer back to MY OWN BLOG quite often for slop examples so it’s ok that you need help.
Slop’s example
Let’s look at the example from the README
.
opts = Slop.parse do |o|
o.string '-h', '--host', 'a hostname'
o.integer '--port', 'custom port', default: 80
o.bool '-v', '--verbose', 'enable verbose mode'
o.bool '-q', '--quiet', 'suppress output (quiet mode)'
o.bool '-c', '--check-ssl-certificate', 'check SSL certificate for host'
o.on '--version', 'print the version' do
puts Slop::VERSION
exit
end
end
I disagree with -h
here for hosts. I think -h
should always be help. This is especially true
when switching contexts. When I switch to java or node or go or python, I have no idea
what those communities’ standards are. I rely on what unix expects: dash aitch.
I disagree also with this example because figuring out how to handle -h
for help
is the most confusing thing about using slop because you have to use exceptions
as flow control (sort of an anti-pattern).
A real example
Let’s write a wrapper program called What the Fi? for our Internet connection. When Internet things get wonky there are a few sites and tools I use to see is it just me?. This wrapper will combine all those things into a CLI. We’ll use Slop 4.3 to parse the CLI options. We’ll even write tests!
The main structure of this program is subcommand based. It’s a particular type of CLI example similar to git where there are branches of main commands. After the main branching logic, you could have options on each of the subcommands but I’ll leave that as an exercise to you. I’d also recommend thor if you want to build a complicated CLI with subcommands. What I mean to say is, the following is just a CLI example that happens to follow this subcommand pattern.
Here’s how you use it.
# paste in the script below into a file named whatthefi
# put it in ~/bin if you want, or put it $PATH
wget -O ~/bin/whatthefi https://raw.githubusercontent.com/squarism/whatthefi/v0.1.0/whatthefi
# make it executable
chmod u+x ~/bin/whatthefi
whatthefi -h
The relevant Slop options are in this bit.
require 'slop'
require 'net/http'
require 'json'
class CLI
# set up defaults in its own method
def cli_flags
options = Slop::Options.new
options.banner = "usage: tubes [options] ..."
options.separator ""
options.separator "Options:"
options.boolean "-i", "--ip", "What is my ip?"
options.string "-p", "--port", "Can I get to a port?"
options.string "-d", "--down", "Is this URL down for everyone or just me?"
options
end
def parse_arguments(command_line_options, parser)
begin
# slop has the advantage over optparse that it can do strings and not just ARGV
result = parser.parse command_line_options
result.to_hash
# Very important to not bury this begin/rescue logic in another method
# otherwise it will be difficult to check to see if -h or --help was passed
# in this case -h acts as an unknown option as long as we don't define it
# in cli_flags.
rescue Slop::UnknownOption
# print help
puts cli_flags
exit
# If, for your program, you can't exit here, then reraise Slop::UnknownOption
# raise a custom exception, push the rescue up to main or track that "help was invoked"
end
end
Notice that the rescue Slop::UnknownOption
needed for Slop parsing is inside of a method called parse_arguments.
On many tools/projects I’ve done this isn’t enough to handle all cases. Then, what I’ll do
is roll a custom error class and throw that instead. You could also instead just not
begin;rescue;end
here and do it higher up in the main. If you find yourself losing
data/context, it means you are at the wrong level of method calls. In the slop examples, this isn’t
explicitly mentioned but I find this way to be the most unixy. It print help on an unknown option
and it prints help if you don’t define -h or --help
. If you have a -h
option you want to use
then use the on '--help'
example Slop mentions.
o.on '--help' do
puts o
exit
end
Full Example
I hesitate to post the whole script here because it is very long. But here it is anyway. If you prefer a git repo to puruse like a sane and reasonable person then here it is.
Note that master has switched to Crystal for a different blog post.
#!/usr/bin/env ruby
require 'slop'
require 'net/http'
require 'json'
class CLI
# set up defaults in its own method
def cli_flags
options = Slop::Options.new
options.banner = "usage: tubes [options] ..."
options.separator ""
options.separator "Options:"
options.boolean "-i", "--ip", "What is my ip?"
options.string "-p", "--port", "Can I get to a port?"
options.string "-d", "--down", "Is this URL down for everyone or just me?"
options
end
def parse_arguments(command_line_options, parser)
begin
# slop has the advantage over optparse that it can do strings and not just ARGV
result = parser.parse command_line_options
result.to_hash
# Very important to not bury this begin/rescue logic in another method
# otherwise it will be difficult to check to see if -h or --help was passed
# in this case -h acts as an unknown option as long as we don't define it
# in cli_flags.
rescue Slop::UnknownOption
# print help
puts cli_flags
exit
# If, for your program, you can't exit here, then reraise Slop::UnknownOption
# raise a custom exception, push the rescue up to main or track that "help was invoked"
end
end
def flags
[:ip, :port, :down]
end
def flags_error
switches = flags.collect {|f| "--#{f}"}
puts cli_flags
puts
abort "please set one of #{switches}"
end
# In a cli app where you essentially have subcommands like git
# this method makes sure that one of the main "modes" is set.
# Something like:
# person --run
# person --walk
# person --stop
def number_of_required_flags_set(arguments)
# --ip isn't required
minimum_flags = flags - [:ip]
valid_flags = minimum_flags.collect {|a| arguments.fetch(a) }.compact
valid_flags.count
end
# slop does not take on the job of requiring arguments to be set
# this method represents our validation rules
def validate_arguments(arguments)
# --ip is false by default because it's a Slop boolean
if number_of_required_flags_set(arguments) < 1 && !arguments.fetch(:ip)
flags_error
end
end
def set?(arguments, flag)
!arguments.fetch(flag).nil?
end
# main style entry point
def main(command_line_options=ARGV)
parser = Slop::Parser.new cli_flags
arguments = parse_arguments(command_line_options, parser)
validate_arguments arguments
# --ip is a boolean, it is set to false even if left off by slop
if arguments.fetch(:ip)
puts what_is_my_ip
elsif set?(arguments, :port)
puts portquiz arguments[:port]
elsif set?(arguments, :down)
puts is_it_up arguments[:down]
end
end
def http_get(url)
response = nil
begin
response = Net::HTTP.get(URI(url))
rescue SocketError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError => e
puts e.inspect
end
response
end
# outside of scope but you can see these are action methods here
# these could easily be broken out to classes
def what_is_my_ip
response = http_get("https://httpbin.org/ip")
"Your IP is #{JSON.parse(response)['origin']}"
end
def portquiz(port)
response = http_get("http://portquiz.net:#{port}")
if response
"I can get to port #{port} on the Internet. :)"
else
"I can't reach port #{port} on the Internet. :("
end
end
def is_it_up(url)
response = http_get("http://www.downforeveryoneorjustme.com/#{url}")
# lazy html parsing to avoid nokogiri
html_match = response.match(/class="domain"\>.*\<\/a\>(.*)\./)
if html_match[1].include? "is up"
"#{url} seems up. :)"
elsif html_match[1].include? "looks down"
"#{url} seems down. :("
end
end
end
# this kind of sucks, you shouldn't ever change your code to accomodate tests
# but this is a main
CLI.new.main if !defined?(RSpec)
If you look at main
and validate_arguments
, you’ll see that --ip
being a boolean and not
a string caused special logic to spew everywhere. It’s because it’s a switch and not a parameter with
a string value (it’s not --ip=1.2.3.4
, it’s just --ip
or nothing). Because of this, we have
to treat this option differently. Sometimes we need to know if it’s been set but because Slop
will set an unset boolean to false, we can’t check for nil like all the other flags.
I hope this post helps the googlers write their CLIs. My older post about slop had bit-rotted and at the same time gotten high up on the google rankings. I hope I have avenged myself (against myself?). All hail the bit rot.