1 raise "Ruby 1.9 or later required" if RUBY_VERSION < "1.9"
3 # Copyright (C) 2007-2011 Martin A. Hansen.
5 # This program is free software; you can redistribute it and/or
6 # modify it under the terms of the GNU General Public License
7 # as published by the Free Software Foundation; either version 2
8 # of the License, or (at your option) any later version.
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License
16 # along with this program; if not, write to the Free Software
17 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 # http://www.gnu.org/copyleft/gpl.html
21 # >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>><<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
23 # This software is part of the Biopieces framework (www.biopieces.org).
25 # >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>><<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
34 BEGIN { Dir.mkdir ENV["BP_TMP"] unless File.directory? ENV["BP_TMP"] }
38 # Monkey patch (evil) changing the to_s method of the Hash class to
39 # return a Hash in Biopieces format; keys and value pairs on one line
40 # each seperated with ': ' and terminated by a line of '---'.
45 self.each do |key, value|
46 string << "#{key.to_s}: #{value}\n"
55 # Error class for all exceptions to do with the Biopieces class.
56 class BiopiecesError < StandardError; end
58 # Biopieces are command line scripts and uses OptionParser to parse command line
59 # options according to a list of casts. Each cast prescribes the long and short
60 # name of the option, the type, if it is mandatory, the default value, and allowed
61 # and disallowed values. An optional list of extra casts can be supplied, and the
62 # integrity of the casts are checked. Following the command line parsing, the
63 # options are checked according to the casts. Methods are also included for handling
64 # the parsing and emitting of Biopiece records, which are ASCII text records consisting
65 # of lines with a key/value pair separated by a colon and a white space ': '.
66 # Each record is separated by a line with three dashes '---'.
70 # Class method to check the integrity of a list of casts, followed by parsing
71 # options from argv and finally checking the options according to the casts.
72 def self.options_parse(argv, cast_list=[], script_path=$0)
73 casts = Casts.new(cast_list)
74 option_handler = OptionHandler.new(argv, casts, script_path)
75 options = option_handler.options_parse
80 # Class method for opening data streams for reading and writing Biopiece
81 # records. Records are read from STDIN (default) or file (possibly gzipped)
82 # and written to STDOUT (default) or file.
83 def self.open(input = STDIN, output = STDOUT)
84 io_in = self.open_input(input)
85 io_out = self.open_output(output)
87 if block_given? # FIXME begin block outmost?
99 # Class method to create a temporary directory inside the ENV["BP_TMP"] directory.
104 path = File.join(ENV["BP_TMP"], [user, time + pid, pid, "bp_tmp"].join("_"))
106 Status.new.set_tmpdir(path)
110 # Initialize a Biopiece object for either reading or writing from _ios_.
111 def initialize(ios, stdio = nil)
116 # Method to write a Biopiece record to _ios_.
121 # Method to close _ios_.
123 @ios.close unless @stdio
126 # Method to parse and yield a Biopiece record from _ios_.
128 while record = get_entry
132 self # conventionally
135 alias :each :each_record
140 @ios.each_line do |line|
142 when /^([^:]+): (.*)$/
143 record[$1.to_sym] = $2
147 raise BiopiecesError, "Bad record format: #{line}"
151 return record unless record.empty?
154 alias :get_record :each_entry
158 # Class method for opening data stream for reading Biopiece records.
159 # Records are read from STDIN (default) or file (possibly gzipped).
160 def self.open_input(input)
163 input = self.new(StringIO.new)
165 input = self.new(STDIN, true)
167 elsif File.exists? input
168 ios = File.open(input, 'r')
171 ios = Zlib::GzipReader.new(ios)
176 input = self.new(ios)
182 # Class method for opening data stream for writing Biopiece records.
183 # Records are written to STDOUT (default) or file.
184 def self.open_output(output)
186 output = self.new(STDOUT, true)
187 elsif not output.is_a? IO
188 output = self.new(File.open(output, 'w'))
196 # Error class for all exceptions to do with option casts.
197 class CastError < StandardError; end
199 # Class to handle casts of command line options. Each cast prescribes the long and
200 # short name of the option, the type, if it is mandatory, the default value, and
201 # allowed and disallowed values. An optional list of extra casts can be supplied,
202 # and the integrity of the casts are checked.
204 TYPES = %w[flag string list int uint float file file! files files! dir dir! genome]
205 MANDATORY = %w[long short type mandatory default allowed disallowed]
207 # Initialize cast object with an optional options cast list to which
208 # ubiquitous casts are added after which all casts are checked.
209 def initialize(cast_list=[])
210 @cast_list = cast_list
214 self.push(*@cast_list)
219 # Add ubiquitous options casts.
221 @cast_list << {:long=>'help', :short=>'?', :type=>'flag', :mandatory=>false, :default=>nil, :allowed=>nil, :disallowed=>nil}
222 @cast_list << {:long=>'stream_in', :short=>'I', :type=>'file!', :mandatory=>false, :default=>nil, :allowed=>nil, :disallowed=>nil}
223 @cast_list << {:long=>'stream_out', :short=>'O', :type=>'file', :mandatory=>false, :default=>nil, :allowed=>nil, :disallowed=>nil}
224 @cast_list << {:long=>'verbose', :short=>'v', :type=>'flag', :mandatory=>false, :default=>nil, :allowed=>nil, :disallowed=>nil}
227 # Check integrity of the casts.
234 # Check if all mandatory keys are present in casts and raise if not.
236 @cast_list.each do |cast|
237 MANDATORY.each do |mandatory|
238 raise CastError, "Missing symbol in cast: '#{mandatory}'" unless cast.has_key? mandatory.to_sym
243 # Check if all values in casts are valid.
245 @cast_list.each do |cast|
247 check_val_short(cast)
249 check_val_mandatory(cast)
250 check_val_default(cast)
251 check_val_allowed(cast)
252 check_val_disallowed(cast)
256 # Check if the values to long are legal and raise if not.
257 def check_val_long(cast)
258 unless cast[:long].is_a? String and cast[:long].length > 1
259 raise CastError, "Illegal cast of long: '#{cast[:long]}'"
263 # Check if the values to short are legal and raise if not.
264 def check_val_short(cast)
265 unless cast[:short].is_a? String and cast[:short].length == 1
266 raise CastError, "Illegal cast of short: '#{cast[:short]}'"
270 # Check if values to type are legal and raise if not.
271 def check_val_type(cast)
274 type_hash[type] = true
277 unless type_hash[cast[:type]]
278 raise CastError, "Illegal cast of type: '#{cast[:type]}'"
282 # Check if values to mandatory are legal and raise if not.
283 def check_val_mandatory(cast)
284 unless cast[:mandatory] == true or cast[:mandatory] == false
285 raise CastError, "Illegal cast of mandatory: '#{cast[:mandatory]}'"
289 # Check if values to default are legal and raise if not.
290 def check_val_default(cast)
291 unless cast[:default].nil? or
292 cast[:default].is_a? String or
293 cast[:default].is_a? Integer or
294 cast[:default].is_a? Float
295 raise CastError, "Illegal cast of default: '#{cast[:default]}'"
299 # Check if values to allowed are legal and raise if not.
300 def check_val_allowed(cast)
301 unless cast[:allowed].is_a? String or cast[:allowed].nil?
302 raise CastError, "Illegal cast of allowed: '#{cast[:allowed]}'"
306 # Check if values to disallowed are legal and raise if not.
307 def check_val_disallowed(cast)
308 unless cast[:disallowed].is_a? String or cast[:disallowed].nil?
309 raise CastError, "Illegal cast of disallowed: '#{cast[:disallowed]}'"
313 # Check cast for duplicate long or short options names.
316 @cast_list.each do |cast|
317 raise CastError, "Duplicate argument: '--#{cast[:long]}'" if check_hash[cast[:long]]
318 raise CastError, "Duplicate argument: '-#{cast[:short]}'" if check_hash[cast[:short]]
319 check_hash[cast[:long]] = true
320 check_hash[cast[:short]] = true
324 # Convert values to :long keys to symbols for all casts.
326 @cast_list.each do |cast|
327 cast[:long] = cast[:long].to_sym
333 # Class for parsing argv using OptionParser according to given casts.
334 # Default options are set, file glob expressions expanded, and options are
335 # checked according to the casts. Usage information is printed and exit called
338 REGEX_LIST = /^(list|files|files!)$/
339 REGEX_INT = /^(int|uint)$/
340 REGEX_STRING = /^(file|file!|dir|dir!|genome)$/
342 def initialize(argv, casts, script_path)
345 @script_path = script_path
349 # Parse options from argv using OptionParser and casts denoting long and
350 # short option names. Usage information is printed and exit called.
351 # A hash with options is returned.
353 option_parser = OptionParser.new do |option|
354 @casts.each do |cast|
357 option.on("-#{cast[:short]}", "--#{cast[:long]}") do |o|
358 @options[cast[:long]] = o
361 option.on("-#{cast[:short]}", "--#{cast[:long]} F", Float) do |f|
362 @options[cast[:long]] = f
365 option.on("-#{cast[:short]}", "--#{cast[:long]} S", String) do |s|
366 @options[cast[:long]] = s
369 option.on( "-#{cast[:short]}", "--#{cast[:long]} A", Array) do |a|
370 @options[cast[:long]] = a
373 option.on("-#{cast[:short]}", "--#{cast[:long]} I", Integer) do |i|
374 @options[cast[:long]] = i
377 option.on("-#{cast[:short]}", "--#{cast[:long]} S", String) do |s|
378 @options[cast[:long]] = s
381 raise ArgumentError, "Unknown option type: '#{cast[:type]}'"
386 option_parser.parse!(@argv)
389 print_usage_and_exit(true)
390 elsif print_usage_short?
401 # Given the script name determine the path of the wiki file with the usage info.
403 path = File.join(ENV["BP_DIR"], "bp_usage", File.basename(@script_path)) + ".wiki"
404 raise "No such wiki file: #{path}" unless File.file? path
408 # Check if full "usage info" should be printed.
409 def print_usage_full?
413 # Check if short "usage info" should be printed.
414 def print_usage_short?
417 elsif @options[:stream_in]
419 elsif @options[:data_in]
421 elsif wiki_path =~ /^(list_biopieces|list_genomes|list_mysql_databases|biostat)$/ # TODO get rid of this!
428 # Print usage info by Calling an external script 'print_wiki'
429 # using a system() call and exit. An optional 'full' flag
430 # outputs the full usage info.
431 def print_usage_and_exit(full=nil)
436 system("print_wiki --data_in #{wiki_path} --help")
438 system("print_wiki --data_in #{wiki_path}")
441 raise "Failed printing wiki: #{wiki_path}" unless $?.success?
447 # Set default options value from cast unless a value is set.
449 @casts.each do |cast|
451 unless @options[cast[:long]]
452 if cast[:type] == 'list'
453 @options[cast[:long]] = cast[:default].split ','
455 @options[cast[:long]] = cast[:default]
462 # Expands glob expressions to a full list of paths.
463 # Examples: "*.fna" or "foo.fna,*.fna" or "foo.fna,/bar/*.fna"
465 @casts.each do |cast|
466 if cast[:type] == 'files' or cast[:type] == 'files!'
467 if @options[cast[:long]]
470 @options[cast[:long]].each do |path|
472 Dir.glob(path).each do |file|
473 files << file if File.file? file
480 @options[cast[:long]] = files
486 # Check all options according to casts.
488 @casts.each do |cast|
489 options_check_mandatory(cast)
490 options_check_int(cast)
491 options_check_uint(cast)
492 options_check_file(cast)
493 options_check_files(cast)
494 options_check_dir(cast)
495 options_check_allowed(cast)
496 options_check_disallowed(cast)
500 # Check if a mandatory option is set and raise if it isn't.
501 def options_check_mandatory(cast)
503 raise ArgumentError, "Mandatory argument: --#{cast[:long]}" unless @options[cast[:long]]
507 # Check int type option and raise if not an integer.
508 def options_check_int(cast)
509 if cast[:type] == 'int' and @options[cast[:long]]
510 unless @options[cast[:long]].is_a? Integer
511 raise ArgumentError, "Argument to --#{cast[:long]} must be an integer, not '#{@options[cast[:long]]}'"
516 # Check uint type option and raise if not an unsinged integer.
517 def options_check_uint(cast)
518 if cast[:type] == 'uint' and @options[cast[:long]]
519 unless @options[cast[:long]].is_a? Integer and @options[cast[:long]] >= 0
520 raise ArgumentError, "Argument to --#{cast[:long]} must be an unsigned integer, not '#{@options[cast[:long]]}'"
525 # Check file! type argument and raise if file don't exists.
526 def options_check_file(cast)
527 if cast[:type] == 'file!' and @options[cast[:long]]
528 raise ArgumentError, "No such file: '#{@options[cast[:long]]}'" unless File.file? @options[cast[:long]]
532 # Check files! type argument and raise if files don't exists.
533 def options_check_files(cast)
534 if cast[:type] == 'files!' and @options[cast[:long]]
535 @options[cast[:long]].each do |path|
537 raise ArgumentError, "File not readable: '#{path}'" unless File.readable? path
542 # Check dir! type argument and raise if directory don't exist.
543 def options_check_dir(cast)
544 if cast[:type] == 'dir!' and @options[cast[:long]]
545 raise ArgumentError, "No such directory: '#{@options[cast[:long]]}'" unless File.directory? @options[cast[:long]]
549 # Check options and raise unless allowed.
550 def options_check_allowed(cast)
551 if cast[:allowed] and @options[cast[:long]]
553 cast[:allowed].split(',').each { |a| allowed_hash[a.to_s] = 1 }
555 raise ArgumentError, "Argument '#{@options[cast[:long]]}' to --#{cast[:long]} not allowed" unless allowed_hash[@options[cast[:long]].to_s]
559 # Check disallowed argument values and raise if disallowed.
560 def options_check_disallowed(cast)
561 if cast[:disallowed] and @options[cast[:long]]
562 cast[:disallowed].split(',').each do |val|
563 raise ArgumentError, "Argument '#{@options[cast[:long]]}' to --#{cast[:long]} is disallowed" if val.to_s == @options[cast[:long]].to_s
569 # Class for manipulating the execution status of Biopieces by setting a
570 # status file with a time stamp, process id, and command arguments. The
571 # status file is used for creating log entries and for displaying the
572 # runtime status of Biopieces.
574 # Write the status to a status file.
576 time0 = Time.new.strftime("%Y-%m-%d %X")
578 File.open(path, "w") do |fh|
579 fh.flock(File::LOCK_EX)
580 fh.puts [time0, ARGV.join(" ")].join(";")
584 # Append the a temporary directory path to the status file.
585 def set_tmpdir(tmpdir_path)
588 File.open(path, "r") do |fh|
589 fh.flock(File::LOCK_SH)
590 status = fh.read.chomp
593 status = "#{status};#{tmpdir_path}\n"
595 File.open(path, "w") do |fh|
596 fh.flock(File::LOCK_EX)
601 # Extract the temporary directory path from the status file,
602 # and return this or nil if not found.
604 File.open(path, "r") do |fh|
605 fh.flock(File::LOCK_SH)
606 tmpdir_path = fh.read.chomp.split(";").last
607 return tmpdir_path if File.directory?(tmpdir_path)
613 # Write the Biopiece status to the log file.
615 time1 = Time.new.strftime("%Y-%m-%d %X")
617 script = File.basename($0)
622 File.open(path, "r") do |fh|
623 fh.flock(File::LOCK_SH)
624 time0, args = fh.first.split(";")
627 elap = time_diff(time0, time1)
628 command = [script, args].join(" ")
629 log_file = File.join(ENV["BP_LOG"], "biopieces.log")
631 File.open(log_file, "a") do |fh|
632 fh.flock(File::LOCK_EX)
633 fh.puts [time0, time1, elap, user, exit_status, command].join("\t")
637 # Delete status file.
644 # Path to status file
647 script = File.basename($0)
649 path = File.join(ENV["BP_TMP"], [user, script, pid, "status"].join("."))
654 # Get the elapsed time from the difference between two time stamps.
655 def time_diff(t0, t1)
656 Time.at((DateTime.parse(t1).to_time - DateTime.parse(t0).to_time).to_i).gmtime.strftime('%X')
661 # Set status when 'biopieces' is required.
664 # Clean up when 'biopieces' exists.
666 exit_status = $! ? $!.inspect : "OK"
670 exit_status = "ERROR"
672 exit_status = "INTERRUPTED"
674 exit_status = "TERMINATED"
680 tmpdir = status.get_tmpdir
681 FileUtils.remove_entry_secure(tmpdir, true) unless tmpdir.nil?
682 status.log(exit_status)