if RUBY_PLATFORM == 'java'
require 'java'
require 'ostruct'
elsif RUBY_VERSION =~ /^1.8/
begin
require 'open4'
rescue LoadError
warn "For Ruby #{RUBY_VERSION}, the open4 library must be installed or SH won't work"
end
else
require 'open3'
end
require 'methadone/process_status'
module Methadone
# Module with various helper methods for executing external commands.
# In most cases, you can use #sh to run commands and have decent logging
# done. You will likely use this in a class that also mixes-in
# Methadone::CLILogging (remembering that Methadone::Main mixes this in for you).
# If you don't, you must provide a logger via #set_sh_logger.
#
# == Examples
#
# include Methadone::SH
#
# sh 'cp foo.txt /tmp'
# # => logs the command to DEBUG, executes the command, logs its output to DEBUG and its
# # error output to WARN, returns 0
#
# sh 'cp non_existent_file.txt /nowhere_good'
# # => logs the command to DEBUG, executes the command, logs its output to INFO and
# # its error output to WARN, returns the nonzero exit status of the underlying command
#
# sh! 'cp non_existent_file.txt /nowhere_good'
# # => same as above, EXCEPT, raises a Methadone::FailedCommandError
#
# sh 'cp foo.txt /tmp' do
# # Behaves exactly as before, but this block is called after
# end
#
# sh 'cp non_existent_file.txt /nowhere_good' do
# # This block isn't called, since the command failed
# end
#
# sh 'ls -l /tmp/' do |stdout|
# # stdout contains the output of the command
# end
# sh 'ls -l /tmp/ /non_existent_dir' do |stdout,stderr|
# # stdout contains the output of the command,
# # stderr contains the standard error output.
# end
#
# == Handling process execution
#
# In order to work on as many Rubies as possible, this class defers the actual execution
# to an execution strategy. See #set_execution_strategy if you think you'd like to override
# that, or just want to know how it works.
#
# == More complex execution and subprocess management
#
# This is not intended to be a complete replacement for Open3 or an enhanced means of managing subprocesses.
# This is to make it easy for you to shell-out to external commands and have your app be robust and
# easy to maintain.
module SH
def self.included(k)
k.extend(self)
end
# Run a shell command, capturing and logging its output.
# If the command completed successfully, it's output is logged at DEBUG.
# If not, its output as logged at INFO. In either case, its
# error output is logged at WARN.
#
# command:: the command to run as a String or Array of String. The String form is simplest, but
# is open to injection. If you need to execute a command that is assembled from some portion
# of user input, consider using an Array of String. This form prevents tokenization that occurs
# in the String form. The first element is the command to execute,
# and the remainder are the arguments. See Methadone::ExecutionStrategy::Base for more info.
# options:: options to control the call. Currently responds to:
# +:expected+:: an Int or Array of Int representing error codes, in addition to 0, that are
# expected and therefore constitute success. Useful for commands that don't use
# exit codes the way you'd like
# block:: if provided, will be called if the command exited nonzero. The block may take 0, 1, 2, or 3 arguments.
# The arguments provided are the standard output as a string, standard error as a string, and
# the exitstatus as an Int.
# You should be safe to pass in a lambda instead of a block, as long as your
# lambda doesn't take more than three arguments
#
# Example
#
# sh "cp foo /tmp"
# sh "ls /tmp" do |stdout|
# # stdout contains the output of ls /tmp
# end
# sh "ls -l /tmp foobar" do |stdout,stderr|
# # ...
# end
#
# Returns the exit status of the command. Note that if the command doesn't exist, this returns 127.
def sh(command,options={},&block)
sh_logger.debug("Executing '#{command}'")
stdout,stderr,status = execution_strategy.run_command(command)
process_status = Methadone::ProcessStatus.new(status,options[:expected])
sh_logger.warn("Error output of '#{command}': #{stderr}") unless stderr.strip.length == 0
if process_status.success?
sh_logger.debug("Output of '#{command}': #{stdout}") unless stdout.strip.length == 0
call_block(block,stdout,stderr,process_status.exitstatus) unless block.nil?
else
sh_logger.info("Output of '#{command}': #{stdout}") unless stdout.strip.length == 0
sh_logger.warn("Error running '#{command}'")
end
process_status.exitstatus
rescue *exception_meaning_command_not_found => ex
sh_logger.error("Error running '#{command}': #{ex.message}")
127
end
# Run a command, throwing an exception if the command exited nonzero.
# Otherwise, behaves exactly like #sh.
#
# options:: options hash, responding to:
# :expected:: same as for #sh
# :on_fail:: a custom error message. This allows you to have your
# app exit on shell command failures, but customize the error
# message that they see.
#
# Raises Methadone::FailedCommandError if the command exited nonzero.
#
# Examples:
#
# sh!("rsync foo bar")
# # => if command fails, app exits and user sees: "error: Command 'rsync foo bar' exited 12"
# sh!("rsync foo bar", :on_fail => "Couldn't rsync, check log for details")
# # => if command fails, app exits and user sees: "error: Couldn't rsync, check log for details
def sh!(command,options={},&block)
sh(command,options,&block).tap do |exitstatus|
process_status = Methadone::ProcessStatus.new(exitstatus,options[:expected])
unless process_status.success?
raise Methadone::FailedCommandError.new(exitstatus,command,options[:on_fail])
end
end
end
# Override the default logger (which is the one provided by CLILogging).
# You would do this if you want a custom logger or you aren't mixing-in
# CLILogging.
#
# Note that this method is *not* called sh_logger= to avoid annoying situations
# where Ruby thinks you are setting a local variable
def set_sh_logger(logger)
@sh_logger = logger
end
# Set the strategy to use for executing commands. In general, you don't need to set this
# since this module chooses an appropriate implementation based on your Ruby platform:
#
# 1.8 Rubies, including 1.8, and REE:: Open4 is used via Methadone::ExecutionStrategy::Open_4. open4 will not be
# installed as a dependency. RubyGems doesn't allow conditional dependencies,
# so make sure that your app declares it as a dependency if you think you'll be
# running on 1.8 or REE.
# Rubinius:: Open4 is used, but we handle things a bit differently; see Methadone::ExecutionStrategy::RBXOpen_4.
# Same warning on dependencies applies.
# JRuby:: Use JVM calls to +Runtime+ via Methadone::ExecutionStrategy::JVM
# Windows:: Currently no support for Windows
# All others:: we use Open3 from the standard library, via Methadone::ExecutionStrategy::Open_3
#
# See Methadone::ExecutionStrategy::Base for how to implement your own.
def set_execution_strategy(strategy)
@execution_strategy = strategy
end
private
def exception_meaning_command_not_found
execution_strategy.exception_meaning_command_not_found
end
def self.default_execution_strategy_class
if RUBY_PLATFORM == 'java'
Methadone::ExecutionStrategy::JVM
elsif defined?(RUBY_ENGINE) && RUBY_ENGINE == 'rbx'
Methadone::ExecutionStrategy::RBXOpen_4
elsif RUBY_VERSION =~ /^1.8/
Methadone::ExecutionStrategy::Open_4
else
Methadone::ExecutionStrategy::Open_3
end
end
def execution_strategy
@execution_strategy ||= SH.default_execution_strategy_class.new
end
def sh_logger
@sh_logger ||= self.logger
end
# Safely call our block, even if the user passed in a lambda
def call_block(block,stdout,stderr,exitstatus)
# blocks that take no arguments have arity -1. Or 0. Ugh.
if block.arity > 0
case block.arity
when 1
block.call(stdout)
when 2
block.call(stdout,stderr)
else
# Let it fail for lambdas
block.call(stdout,stderr,exitstatus)
end
else
block.call
end
end
end
end