Skip to content

Instantly share code, notes, and snippets.

@bradland
Last active December 30, 2023 16:05
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bradland/f216c923ae8d1aca1243 to your computer and use it in GitHub Desktop.
Save bradland/f216c923ae8d1aca1243 to your computer and use it in GitHub Desktop.
Base Ruby shell scripting template. Uses std-lib only; parses options; traps common signals.
#!/usr/bin/env ruby
# frozen_string_literal: true
require "ostruct"
require "optparse"
require "bigdecimal"
require "csv"
require "pry"
## Embedded ScriptUtils library; because, scripting!
module ShellScriptUtils
VERSION = '2023.06.08.001' # YYYY.MM.DD.vvv
def status(msg, type = :info, indent = 0, io = $stderr)
case type
when :error
msg = red(msg)
when :success
msg = green(msg)
when :warning
msg = yellow(msg)
when :info
msg = blue(msg)
when :speak
msg = blue(say(msg))
end
io.puts "#{' ' * indent} #{msg}"
end
def say(msg)
if ismac?
`say '#{msg}'`
msg
else
"#{msg}\a\a\a"
end
end
def validation_error(group, msg)
@validation_errors[group] = [] unless @validation_errors.key?(group)
@validation_errors[group] << msg
end
def validation_error_report(options = {})
opts = {
indent: 0,
format: :txt
}.merge(options)
return if @validation_errors.empty?
case opts[:format]
when :txt
@validation_errors.each do |group, messages|
status "Validation errors for group: #{group}", :info, opts[:indent]
messages.each do |msg|
status msg, :info, opts[:indent] + 1
end
end
when :tsv
# tsv output
@validation_errors.each do |group, messages|
messages.each do |message|
puts "#{group}\t#{message}"
end
end
end
end
def confirm(conf_char = "y")
c = gets.chomp
c == conf_char
end
def colorize(text, color_code)
"\e[#{color_code}m#{text}\e[0m"
end
def red(text); colorize(text, 31); end
def green(text); colorize(text, 32); end
def blue(text); colorize(text, 34); end
def yellow(text); colorize(text, 33); end
def ismac?
if `uname -a` =~ /^Darwin/
true
else
false
end
end
# Report builder utility class
class Report
include ShellScriptUtils
attr_accessor :rows
VERSION = '2023.06.08.001' # YYYY.MM.DD.vvv
# Optionally accepts an array of hash rows
def initialize(data = nil, options = {})
@options = options
@rows = data || []
end
def <<(row)
@rows << row
end
def to_csv
CSV.generate do |csv|
csv << @rows.first.keys
@rows.each.with_index do |row, i|
# status "Writing row: #{i}", :info
csv << row.values
end
end
end
def to_tsv
out = []
out << @rows.first.keys.join("\t")
@rows.each.with_index do |row, i|
# status "Writing row: #{i}", :info
out << row.values.join("\t")
end
out.join("\n")
end
def save(basename = nil, opts = {})
basename ||= 'report'
opts = {
format: :csv,
dir: './tmp/reports'
}.merge(opts)
case opts[:format]
when :csv
data = to_csv
when :tsv
data = to_tsv
else
status "Invalid format specified (#{opts[:format]}); must specify :csv or :tsv.", :error
return nil
end
if data.empty?
status "Nothing to write to file", :warning
return nil
end
filename ||= "#{basename}-#{Time.now.strftime('%Y%m%d%H%M%S')}.csv"
outfile = File.join(File.expand_path(opts[:dir]), filename)
shortpath = Pathname(outfile).relative_path_from(Pathname(Dir.pwd))
status "Writing report to: #{shortpath}"
FileUtils.mkdir_p(opts[:dir])
File.open(outfile, 'w') do |file|
file.puts data
end
status "...file written to #{shortpath}\a", :success, 1
return true
end
end
##
# ProgressBar provides a simple progress indicator using unicode emoji
# characters "white circle" (U+26AA U+FE0F) and "blue circle" (U+1F535).
#
#
class ProgressBar
PCT_FMT = "%5.1f%%"
CLEAR_LINE = "\r\e[K"
##
# Creates a new progress bar with optional parameters:
#
# @param percentage [Integer, BigDecimal, Float, String] starting percentage.
# @param prefix [String] will be prefixed to the bar output.
#
def initialize(percentage = 0, prefix = nil, &block)
@prefix = prefix
self.percentage = percentage
return unless block_given?
yield self
puts ""
end
def update(percentage)
self.percentage = percentage
print CLEAR_LINE
print bar
end
private
def bar
unlit = "⚪️"
lit = "🔵"
complete = (@percentage * 10).round
incomplete = 10 - complete
pb = (lit * complete) + (unlit * incomplete)
if @prefix
"%s [#{PCT_FMT}] %s" % [@prefix, @percentage * 100, pb]
else
"[#{PCT_FMT}] %s" % [@percentage * 100, pb]
end
end
def percentage=(percentage)
case percentage
when BigDecimal
percentage = percentage
when String
percentage = BigDecimal(percentage)
when Integer
percentage = BigDecimal(percentage)
when Float
percentage = BigDecimal(percentage.to_s)
else
raise ArgumentError, "Percentage argument (#{percentage.inspect} #{percentage.class}) cannot be coerced to BigDecimal"
end
raise ArgumentError, "Percentage argument value (#{percentage.to_s("F")}) must be >0 and <1" if ( percentage < BigDecimal("0") || percentage > BigDecimal("1") )
@percentage = percentage
end
end
end
##
# ShellScript wrapper is a class-based impelmentation for shell scripts that
# accept a list of files as arguments.
class ShellScript
include ShellScriptUtils
::VERSION = [0, 0, 1].freeze
attr_accessor :options
def initialize
@options = OpenStruct.new
opt_parser = OptionParser.new do |opt|
opt.banner = "Usage: #{$0} [OPTION]... [FILE]..."
opt.on_tail("-h", "--help", "Print usage information.") do
$stderr.puts opt_parser
exit 0
end
opt.on_tail("--version", "Show version") do
puts ::VERSION.join('.')
exit 0
end
end
begin
opt_parser.parse!
rescue OptionParser::InvalidOption => e
$stderr.puts "Specified #{e}"
$stderr.puts opt_parser
exit 64 # EX_USAGE
end
if ARGV.empty?
$stderr.puts "No file provided."
$stderr.puts opt_parser
exit 64 # EX_USAGE
end
@files = ARGV
@validation_errors = {}
end
def run!
# Main execution method! Start here and refactor complexity out to other methods/classes.
status "Hello world! This script was passed the files #{@files.join(', ')}", :info, 0
status "This is a warning.", :warning, 1
status "This is an error.", :error, 1
status "This is success.", :success, 1
end
end
begin
ShellScript.new.run! if $0 == __FILE__
rescue Interrupt
# Ctrl^C
exit 130
rescue Errno::EPIPE
# STDOUT was closed
exit 74 # EX_IOERR
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment