Last active
January 21, 2025 14:52
-
-
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.
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
#!/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 | |
# Only required if expecting file arguments. | |
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