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