]> git.donarmstrong.com Git - biopieces.git/blob - code_ruby/lib/maasha/biopieces.rb
bd85afb0f4a86c02eaaa8a0dc07324489d8ed6f0
[biopieces.git] / code_ruby / lib / maasha / biopieces.rb
1 raise "Ruby 1.9 or later required" if RUBY_VERSION < "1.9"
2
3 # Copyright (C) 2007-2011 Martin A. Hansen.
4
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.
9
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.
14
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.
18
19 # http://www.gnu.org/copyleft/gpl.html
20
21 # >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>><<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
22
23 # This software is part of the Biopieces framework (www.biopieces.org).
24
25 # >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>><<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
26
27 require 'date'
28 require 'fileutils'
29 require 'optparse'
30 require 'pp'
31 require 'stringio'
32 require 'zlib'
33
34 TEST = false
35
36 # Monkey patch (evil) changing the to_s method of the Hash class to
37 # return a Hash in Biopieces format; keys and value pairs on one line
38 # each seperated with ': ' and terminated by a line of '---'.
39 class Hash
40   def to_s
41     string = ""
42
43     self.each do |key, value|
44       string << "#{key.to_s}: #{value}\n"
45     end
46
47     string << "---\n"
48
49     string
50   end
51 end
52
53 # Error class for all exceptions to do with the Biopieces class.
54 class BiopiecesError < StandardError; end
55
56 # Biopieces are command line scripts and uses OptionParser to parse command line
57 # options according to a list of casts. Each cast prescribes the long and short
58 # name of the option, the type, if it is mandatory, the default value, and allowed
59 # and disallowed values. An optional list of extra casts can be supplied, and the
60 # integrity of the casts are checked. Following the command line parsing, the
61 # options are checked according to the casts. Methods are also included for handling
62 # the parsing and emitting of Biopiece records, which are ASCII text records consisting
63 # of lines with a key/value pair separated by a colon and a white space ': '.
64 # Each record is separated by a line with three dashes '---'.
65 class Biopieces
66   include Enumerable
67
68   # Class method to check the integrity of a list of casts, followed by parsing
69   # options from argv and finally checking the options according to the casts.
70   def self.options_parse(argv, cast_list=[], script_path=$0)
71     casts          = Casts.new(cast_list)
72     option_handler = OptionHandler.new(argv, casts, script_path)
73     options        = option_handler.options_parse
74
75     options
76   end
77
78   # Class method for opening data streams for reading and writing Biopiece
79   # records. Records are read from STDIN (default) or file (possibly gzipped)
80   # and written to STDOUT (default) or file.
81   def self.open(input = STDIN, output = STDOUT)
82     io_in  = self.open_input(input)
83     io_out = self.open_output(output)
84
85     if block_given?
86       begin
87         yield io_in, io_out
88       ensure
89         io_in.close
90         io_out.close
91       end
92     else
93       return io_in, io_out
94     end
95   end
96
97   # Class method to create a temporary directory inside the ENV["BP_TMP"] directory.
98   def self.mktmpdir
99     time = Time.now.to_i
100     user = ENV["USER"]
101     pid  = $$
102     path = File.join(ENV["BP_TMP"], [user, time + pid, pid, "bp_tmp"].join("_"))
103     Dir.mkdir(path)
104     Status.new.set_tmpdir(path)
105     path
106   end
107
108   # Initialize a Biopiece object for either reading or writing from _ios_.
109   def initialize(ios, stdio = nil)
110     @ios   = ios
111     @stdio = stdio
112   end
113
114   # Method to write a Biopiece record to _ios_.
115   def puts(foo)
116     @ios << foo.to_s
117   end
118
119   # Method to close _ios_.
120   def close
121     @ios.close unless @stdio
122   end
123
124   # Method to parse and yield a Biopiece record from _ios_.
125   def each_record
126     while record = get_entry
127       yield record
128     end
129
130     self # conventionally
131   end
132
133   alias :each :each_record
134
135   def get_entry
136     record = {}
137
138     @ios.each_line do |line|
139       case line
140       when /^([^:]+): (.*)$/
141         record[$1.to_sym] = $2
142       when /^---$/
143         break
144       else
145         raise BiopiecesError, "Bad record format: #{line}"
146       end
147     end
148
149     return record unless record.empty?
150   end
151
152   alias :get_record :each_entry
153
154   private
155
156   # Class method for opening data stream for reading Biopiece records.
157   # Records are read from STDIN (default) or file (possibly gzipped).
158   def self.open_input(input)
159     if input.nil?
160       if STDIN.tty?
161         input = self.new(StringIO.new)
162       else
163         input = self.new(STDIN, true)
164       end
165     elsif File.exists? input
166       ios = File.open(input, 'r')
167
168       begin
169         ios = Zlib::GzipReader.new(ios)
170       rescue
171         ios.rewind
172       end
173
174       input = self.new(ios)
175     end
176
177     input
178   end
179
180   # Class method for opening data stream for writing Biopiece records.
181   # Records are written to STDOUT (default) or file.
182   def self.open_output(output)
183     if output.nil?
184       output = self.new(STDOUT, true)
185     elsif not output.is_a? IO
186       output = self.new(File.open(output, 'w'))
187     end
188
189     output
190   end
191 end
192
193
194 # Error class for all exceptions to do with option casts.
195 class CastError < StandardError; end
196
197 # Class to handle casts of command line options. Each cast prescribes the long and
198 # short name of the option, the type, if it is mandatory, the default value, and
199 # allowed and disallowed values. An optional list of extra casts can be supplied,
200 # and the integrity of the casts are checked.
201 class Casts < Array
202   TYPES     = %w[flag string list int uint float file file! files files! dir dir! genome]
203   MANDATORY = %w[long short type mandatory default allowed disallowed]
204
205   # Initialize cast object with an optional options cast list to which
206   # ubiquitous casts are added after which all casts are checked.
207   def initialize(cast_list=[])
208     @cast_list = cast_list
209     ubiquitous
210     check
211     long_to_sym
212     self.push(*@cast_list)
213   end
214
215   private
216
217   # Add ubiquitous options casts.
218   def ubiquitous
219     @cast_list << {:long=>'help',       :short=>'?', :type=>'flag',  :mandatory=>false, :default=>nil, :allowed=>nil, :disallowed=>nil}
220     @cast_list << {:long=>'stream_in',  :short=>'I', :type=>'file!', :mandatory=>false, :default=>nil, :allowed=>nil, :disallowed=>nil}
221     @cast_list << {:long=>'stream_out', :short=>'O', :type=>'file',  :mandatory=>false, :default=>nil, :allowed=>nil, :disallowed=>nil}
222     @cast_list << {:long=>'verbose',    :short=>'v', :type=>'flag',  :mandatory=>false, :default=>nil, :allowed=>nil, :disallowed=>nil}
223   end
224
225   # Check integrity of the casts.
226   def check
227     check_keys
228     check_values
229     check_duplicates
230   end
231   
232   # Check if all mandatory keys are present in casts and raise if not.
233   def check_keys
234     @cast_list.each do |cast|
235       MANDATORY.each do |mandatory|
236         raise CastError, "Missing symbol in cast: '#{mandatory.to_sym}'" unless cast.has_key? mandatory.to_sym
237       end
238     end
239   end
240
241   # Check if all values in casts are valid.
242   def check_values
243     @cast_list.each do |cast|
244       check_val_long(cast)
245       check_val_short(cast)
246       check_val_type(cast)
247       check_val_mandatory(cast)
248       check_val_default(cast)
249       check_val_allowed(cast)
250       check_val_disallowed(cast)
251     end
252   end
253
254   # Check if the values to long are legal and raise if not.
255   def check_val_long(cast)
256     unless cast[:long].is_a? String and cast[:long].length > 1
257       raise CastError, "Illegal cast of long: '#{cast[:long]}'"
258     end
259   end
260   
261   # Check if the values to short are legal and raise if not.
262   def check_val_short(cast)
263     unless cast[:short].is_a? String and cast[:short].length == 1
264       raise CastError, "Illegal cast of short: '#{cast[:short]}'"
265     end
266   end
267
268   # Check if values to type are legal and raise if not.
269   def check_val_type(cast)
270     type_hash = {}
271     TYPES.each do |type|
272       type_hash[type] = true
273     end
274
275     unless type_hash.has_key? cast[:type]
276       raise CastError, "Illegal cast of type: '#{cast[:type]}'"
277     end
278   end
279
280   # Check if values to mandatory are legal and raise if not.
281   def check_val_mandatory(cast)
282     unless cast[:mandatory] == true or cast[:mandatory] == false
283       raise CastError, "Illegal cast of mandatory: '#{cast[:mandatory]}'"
284     end
285   end
286
287   # Check if values to default are legal and raise if not.
288   def check_val_default(cast)
289     unless cast[:default].nil?          or
290            cast[:default].is_a? String  or
291            cast[:default].is_a? Integer or
292            cast[:default].is_a? Float
293       raise CastError, "Illegal cast of default: '#{cast[:default]}'"
294     end
295   end
296
297   # Check if values to allowed are legal and raise if not.
298   def check_val_allowed(cast)
299     unless cast[:allowed].is_a? String or cast[:allowed].nil?
300       raise CastError, "Illegal cast of allowed: '#{cast[:allowed]}'"
301     end
302   end
303
304   # Check if values to disallowed are legal and raise if not.
305   def check_val_disallowed(cast)
306     unless cast[:disallowed].is_a? String or cast[:disallowed].nil?
307       raise CastError, "Illegal cast of disallowed: '#{cast[:disallowed]}'"
308     end
309   end
310
311   # Check cast for duplicate long or short options names.
312   def check_duplicates
313     check_hash = {}
314     @cast_list.each do |cast|
315       raise CastError, "Duplicate argument: '--#{cast[:long]}'" if check_hash.has_key? cast[:long]
316       raise CastError, "Duplicate argument: '-#{cast[:short]}'" if check_hash.has_key? cast[:short]
317       check_hash[cast[:long]]  = true
318       check_hash[cast[:short]] = true
319     end
320   end
321
322   # Convert values to :long keys to symbols for all casts.
323   def long_to_sym
324     @cast_list.each do |cast|
325       cast[:long] = cast[:long].to_sym
326     end
327   end
328 end
329
330
331 # Class for parsing argv using OptionParser according to given casts.
332 # Default options are set, file glob expressions expanded, and options are
333 # checked according to the casts. Usage information is printed and exit called
334 # if required.
335 class OptionHandler
336   REGEX_LIST   = /^(list|files|files!)$/
337   REGEX_INT    = /^(int|uint)$/
338   REGEX_STRING = /^(file|file!|dir|dir!|genome)$/
339
340   def initialize(argv, casts, script_path)
341     @argv        = argv
342     @casts       = casts
343     @script_path = script_path
344     @options     = {}
345   end
346
347   # Parse options from argv using OptionParser and casts denoting long and
348   # short option names. Usage information is printed and exit called.
349   # A hash with options is returned.
350   def options_parse
351     option_parser = OptionParser.new do |option|
352       @casts.each do |cast|
353         case cast[:type]
354         when 'flag'
355           option.on("-#{cast[:short]}", "--#{cast[:long]}") do |o|
356             @options[cast[:long]] = o
357           end
358         when 'float'
359           option.on("-#{cast[:short]}", "--#{cast[:long]} F", Float) do |f|
360             @options[cast[:long]] = f
361           end
362         when 'string'
363           option.on("-#{cast[:short]}", "--#{cast[:long]} S", String) do |s|
364             @options[cast[:long]] = s
365           end
366         when REGEX_LIST
367           option.on( "-#{cast[:short]}", "--#{cast[:long]} A", Array) do |a|
368             @options[cast[:long]] = a
369           end
370         when REGEX_INT
371           option.on("-#{cast[:short]}", "--#{cast[:long]} I", Integer) do |i|
372             @options[cast[:long]] = i
373           end
374         when REGEX_STRING
375           option.on("-#{cast[:short]}", "--#{cast[:long]} S", String) do |s|
376             @options[cast[:long]] = s
377           end
378         else
379           raise ArgumentError, "Unknown option type: '#{cast[:type]}'"
380         end
381       end
382     end
383
384     option_parser.parse!(@argv)
385
386     if print_usage_full?
387       print_usage_and_exit(true)
388     elsif print_usage_short?
389       print_usage_and_exit
390     end
391
392     options_default
393     options_glob
394     options_check
395
396     @options
397   end
398
399   # Given the script name determine the path of the wiki file with the usage info.
400   def wiki_path
401     path = File.join(ENV["BP_DIR"], "bp_usage", File.basename(@script_path)) + ".wiki"
402     raise "No such wiki file: #{path}" unless File.file? path
403     path
404   end
405
406   # Check if full "usage info" should be printed.
407   def print_usage_full?
408     @options[:help]
409   end
410
411   # Check if short "usage info" should be printed.
412   def print_usage_short?
413     if not STDIN.tty?
414       return false
415     elsif @options[:stream_in]
416       return false
417     elsif @options[:data_in]
418       return false
419     elsif wiki_path =~ /^(list_biopieces|list_genomes|list_mysql_databases|biostat)$/  # TODO get rid of this!
420       return false
421     else
422       return true
423     end
424   end
425
426   # Print usage info by Calling an external script 'print_wiki'
427   # using a system() call and exit. An optional 'full' flag
428   # outputs the full usage info.
429   def print_usage_and_exit(full=nil)
430     if TEST
431       return
432     else
433       if full
434         system("print_wiki --data_in #{wiki_path} --help")
435       else
436         system("print_wiki --data_in #{wiki_path}")
437       end
438
439       raise "Failed printing wiki: #{wiki_path}" unless $?.success?
440
441       exit
442     end
443   end
444
445   # Set default options value from cast unless a value is set.
446   def options_default
447     @casts.each do |cast|
448       if cast[:default]
449         unless @options.has_key? cast[:long]
450           if cast[:type] == 'list'
451             @options[cast[:long]] = cast[:default].split ','
452           else
453             @options[cast[:long]] = cast[:default]
454           end
455         end
456       end
457     end
458   end
459
460   # Expands glob expressions to a full list of paths.
461   # Examples: "*.fna" or "foo.fna,*.fna" or "foo.fna,/bar/*.fna"
462   def options_glob
463     @casts.each do |cast|
464       if cast[:type] == 'files' or cast[:type] == 'files!'
465         if @options.has_key? cast[:long]
466           files = []
467         
468           @options[cast[:long]].each do |path|
469             if path.include? "*"
470               Dir.glob(path).each do |file|
471                 files << file if File.file? file
472               end
473             else
474               files << path
475             end
476           end
477
478           @options[cast[:long]] = files
479         end
480       end
481     end
482   end
483
484   # Check all options according to casts.
485   def options_check
486     @casts.each do |cast|
487       options_check_mandatory(cast)
488       options_check_int(cast)
489       options_check_uint(cast)
490       options_check_file(cast)
491       options_check_files(cast)
492       options_check_dir(cast)
493       options_check_allowed(cast)
494       options_check_disallowed(cast)
495     end
496   end
497   
498   # Check if a mandatory option is set and raise if it isn't.
499   def options_check_mandatory(cast)
500     if cast[:mandatory]
501       raise ArgumentError, "Mandatory argument: --#{cast[:long]}" unless @options.has_key? cast[:long]
502     end
503   end
504
505   # Check int type option and raise if not an integer.
506   def options_check_int(cast)
507     if cast[:type] == 'int' and @options.has_key? cast[:long]
508       unless @options[cast[:long]].is_a? Integer
509         raise ArgumentError, "Argument to --#{cast[:long]} must be an integer, not '#{@options[cast[:long]]}'"
510       end
511     end
512   end
513   
514   # Check uint type option and raise if not an unsinged integer.
515   def options_check_uint(cast)
516     if cast[:type] == 'uint' and @options.has_key? cast[:long]
517       unless @options[cast[:long]].is_a? Integer and @options[cast[:long]] >= 0
518         raise ArgumentError, "Argument to --#{cast[:long]} must be an unsigned integer, not '#{@options[cast[:long]]}'"
519       end
520     end
521   end
522
523   # Check file! type argument and raise if file don't exists.
524   def options_check_file(cast)
525     if cast[:type] == 'file!' and @options.has_key? cast[:long]
526       raise ArgumentError, "No such file: '#{@options[cast[:long]]}'" unless File.file? @options[cast[:long]]
527     end
528   end
529
530   # Check files! type argument and raise if files don't exists.
531   def options_check_files(cast)
532     if cast[:type] == 'files!' and @options.has_key? cast[:long]
533       @options[cast[:long]].each do |path|
534         next if path == "-"
535         raise ArgumentError, "File not readable: '#{path}'" unless File.readable? path
536       end
537     end
538   end
539   
540   # Check dir! type argument and raise if directory don't exist.
541   def options_check_dir(cast)
542     if cast[:type] == 'dir!' and @options.has_key? cast[:long]
543       raise ArgumentError, "No such directory: '#{@options[cast[:long]]}'" unless File.directory? @options[cast[:long]]
544     end
545   end
546   
547   # Check options and raise unless allowed.
548   def options_check_allowed(cast)
549     if cast[:allowed] and @options.has_key? cast[:long]
550       allowed_hash = {}
551       cast[:allowed].split(',').each { |a| allowed_hash[a.to_s] = 1 }
552   
553       raise ArgumentError, "Argument '#{@options[cast[:long]]}' to --#{cast[:long]} not allowed" unless allowed_hash.has_key? @options[cast[:long]].to_s
554     end
555   end
556   
557   # Check disallowed argument values and raise if disallowed.
558   def options_check_disallowed(cast)
559     if cast[:disallowed] and @options.has_key? cast[:long]
560       cast[:disallowed].split(',').each do |val|
561         raise ArgumentError, "Argument '#{@options[cast[:long]]}' to --#{cast[:long]} is disallowed" if val.to_s == @options[cast[:long]].to_s
562       end
563     end
564   end
565 end
566
567 # Class for manipulating the execution status of Biopieces by setting a
568 # status file with a time stamp, process id, and command arguments. The
569 # status file is used for creating log entries and for displaying the
570 # runtime status of Biopieces.
571 class Status
572   # Write the status to a status file.
573   def set
574     time0  = Time.new.strftime("%Y-%m-%d %X")
575
576     File.open(path, "w") do |fh|
577       fh.flock(File::LOCK_EX)
578       fh.puts [time0, ARGV.join(" ")].join(";")
579     end
580   end
581
582   # Append the a temporary directory path to the status file.
583   def set_tmpdir(tmpdir_path)
584     status = ""
585
586     File.open(path, "r") do |fh|
587       fh.flock(File::LOCK_SH)
588       status = fh.read.chomp
589     end
590
591     status = "#{status};#{tmpdir_path}\n"
592
593     File.open(path, "w") do |fh|
594       fh.flock(File::LOCK_EX)
595       fh << status
596     end
597   end
598
599   # Extract the temporary directory path from the status file,
600   # and return this or nil if not found.
601   def get_tmpdir
602     File.open(path, "r") do |fh|
603       fh.flock(File::LOCK_SH)
604       tmpdir_path = fh.read.chomp.split(";").last
605       return tmpdir_path if File.directory?(tmpdir_path)
606     end
607
608     nil
609   end
610
611   # Write the Biopiece status to the log file.
612   def log(exit_status)
613     time1   = Time.new.strftime("%Y-%m-%d %X")
614     user    = ENV["USER"]
615     script  = File.basename($0)
616
617     time0 = nil
618     args  = nil
619
620     File.open(path, "r") do |fh|
621       fh.flock(File::LOCK_SH)
622       time0, args = fh.first.split(";")
623     end
624
625     elap     = time_diff(time0, time1)
626     command  = [script, args].join(" ") 
627     log_file = File.join(ENV["BP_LOG"], "biopieces.log")
628
629     File.open(log_file, "a") do |fh|
630       fh.flock(File::LOCK_EX)
631       fh.puts [time0, time1, elap, user, exit_status, command].join("\t")
632     end
633   end
634
635   # Delete status file.
636   def delete
637     File.delete(path)
638   end
639   
640   private
641
642   # Path to status file
643   def path
644     user   = ENV["USER"]
645     script = File.basename($0)
646     pid    = $$
647     path   = File.join(ENV["BP_TMP"], [user, script, pid, "status"].join("."))
648
649     path
650   end
651
652   # Get the elapsed time from the difference between two time stamps.
653   def time_diff(t0, t1)
654     Time.at((DateTime.parse(t1).to_time - DateTime.parse(t0).to_time).to_i).gmtime.strftime('%X')
655   end
656 end
657
658
659 # Set status when 'biopieces' is required.
660 Status.new.set
661
662 # Clean up when 'biopieces' exists.
663 at_exit do
664   exit_status = $! ? $!.inspect : "OK"
665
666   case exit_status
667   when /error|errno/i
668     exit_status = "ERROR"
669   when "Interrupt"
670     exit_status = "INTERRUPTED"
671   when /SIGTERM/
672     exit_status = "TERMINATED"
673   when /SIGQUIT/
674     exit_status = "QUIT"
675   end
676
677   status = Status.new
678   tmpdir = status.get_tmpdir
679   FileUtils.remove_entry_secure(tmpdir, true) unless tmpdir.nil?
680   status.log(exit_status)
681   status.delete
682 end
683
684
685 __END__
686