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