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