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