]> git.donarmstrong.com Git - biopieces.git/blob - code_ruby/Maasha/lib/biopieces.rb
ruby unit tests refactored
[biopieces.git] / code_ruby / Maasha / lib / biopieces.rb
1 require 'optparse'
2 require 'open3'
3 require 'pp'
4
5 # Error class for all exceptions to do with option casts.
6 class CastError < StandardError
7 end
8
9 # Biopieces are command line scripts and uses OptionParser to parse command line
10 # options according to a list of casts. Each cast prescribes the long and short
11 # name of the option, the type, if it is mandatory, the default value, and allowed
12 # and disallowed values. An optional list of extra casts can be supplied, and the
13 # integrity of the casts are checked. Following the command line parsing, the
14 # options are checked according to the casts. Methods are also included for handling
15 # the parsing and emitting of Biopiece records, which are ASCII text records consisting
16 # of lines with a key/value pair seperated by a colon and a white space ': '.
17 # Each record is separated by a line with three dashes '---'.
18 class Biopieces
19   TYPES        = %w[flag string list int uint float file file! files files! dir dir! genome]
20   MANDATORY    = %w[long short type mandatory default allowed disallowed]
21   REGEX_LIST   = /^(list|files|files!)$/
22   REGEX_INT    = /^(int|uint)$/
23   REGEX_STRING = /^(string|file|file!|dir|dir!|genome)$/
24
25   # Initialize a Biopiece and write the status to file.
26   def initialize(no_status=nil)
27     status_set unless no_status
28   end
29
30   # Check the integrity of a list of casts, followed by parsion options from argv
31   # and finally checking the options according to the casts. Returns nil if
32   # argv is empty, otherwise an options hash.
33   def parse(argv,casts=[],script_path=$0)
34     @casts       = casts
35     @script_path = script_path
36
37     cast_ubiquitous
38     cast_check
39
40     @options = {}
41
42     options_template = OptionParser.new do |option|
43       @casts.each do |cast|
44         if cast[:type] == 'flag'
45           option.on("-#{cast[:short]}", "--#{cast[:long]}") do |o|
46             @options[cast[:long]] = o
47           end
48         elsif cast[:type] =~ REGEX_LIST
49           option.on( "-#{cast[:short]}", "--#{cast[:long]} A", Array) do |a|
50             @options[cast[:long]] = a
51           end
52         elsif cast[:type] =~ REGEX_INT
53           option.on("-#{cast[:short]}", "--#{cast[:long]} I", Integer) do |i|
54             @options[cast[:long]] = i
55           end
56         elsif cast[:type] =~ REGEX_STRING
57           option.on("-#{cast[:short]}", "--#{cast[:long]} S", String) do |s|
58             @options[cast[:long]] = s
59           end
60         elsif cast[:type] == 'float'
61           option.on("-#{cast[:short]}", "--#{cast[:long]} F", Float) do |f|
62             @options[cast[:long]] = f
63           end
64         else
65           raise ArgumentError, "Unknown option type: '#{cast[:type]}'"
66         end
67       end
68     end
69
70     options_template.parse!(argv)
71
72     if print_usage_full?
73       print_usage_and_exit(true)
74       return # break for unit testing.
75     elsif print_usage_short?
76       print_usage_and_exit
77       return # break for unit testing.
78     end
79
80     options_default
81     options_glob
82     options_check
83
84     @options
85   end
86
87   # Open Biopiece input stream if not open and iterate over all Biopiece
88   # records in the stream.
89   def each_record
90     @in = stream_in_open unless @in.is_a? IO
91
92     record = {}
93
94     @in.each_line do |line|
95       case line
96       when /^([^:]+): (.*)$/
97         record[$1] = $2
98       when /^---$/
99         yield record unless record.empty?
100         record = {}
101       else
102         raise "Bad record format: #{line}"
103       end
104     end
105
106     yield record unless record.empty?
107
108     self # conventionally
109   end
110
111   alias :each :each_record
112
113   # Open Biopiece output stream if not open and puts record to the stream.
114   def puts(record)
115     @out = stream_out_open unless @out.is_a? IO
116
117     record.each do |key,value|
118       @out.print "#{key}: #{value}\n"
119     end
120
121     @out.print "---\n"
122   end
123
124   # Close Biopiece streams, remove tmp_dir, end log status.
125   def clean
126     @in.close  if @in.respond_to?  :close
127     @out.close if @out.respond_to? :close
128     # remove tmpdir if found
129     status_log
130     # remove status file
131   end
132
133   private
134
135   # Given the script name determine the path of the wiki file with the usage info.
136   def wiki_path
137     path = ENV["BP_DIR"] + "/bp_usage/" + File.basename(@script_path, ".rb") + ".wiki"
138     raise "No such wiki file: #{path}" unless File.file? path
139
140     path
141   end
142
143   # Add ubiquitous options casts.
144   def cast_ubiquitous
145     @casts << {:long => 'help',       :short => '?', :type => 'flag',   :mandatory => false, :default => nil, :allowed => nil, :disallowed => nil}
146     @casts << {:long => 'stream_in',  :short => 'I', :type => 'files!', :mandatory => false, :default => nil, :allowed => nil, :disallowed => nil}
147     @casts << {:long => 'stream_out', :short => 'O', :type => 'file',   :mandatory => false, :default => nil, :allowed => nil, :disallowed => nil}
148     @casts << {:long => 'verbose',    :short => 'v', :type => 'flag',   :mandatory => false, :default => nil, :allowed => nil, :disallowed => nil}
149   end
150
151   # Check integrity of the casts.
152   def cast_check
153     cast_check_keys
154     cast_check_values
155     cast_check_duplicates
156   end
157   
158   # Check if all mandatory keys are present in casts and raise if not.
159   def cast_check_keys
160     @casts.each do |cast|
161       MANDATORY.each do |mandatory|
162         raise CastError, "Missing symbol in cast: '#{mandatory.to_sym}'" unless cast.has_key? mandatory.to_sym
163       end
164     end
165   end
166
167   # Check if all values in casts are valid.
168   def cast_check_values
169     @casts.each do |cast|
170       cast_check_val_long(cast)
171       cast_check_val_short(cast)
172       cast_check_val_type(cast)
173       cast_check_val_mandatory(cast)
174       cast_check_val_default(cast)
175       cast_check_val_allowed(cast)
176       cast_check_val_disallowed(cast)
177     end
178   end
179
180   # Check if the values to long are legal and raise if not.
181   def cast_check_val_long(cast)
182     unless cast[:long].is_a? String and cast[:long].length > 1
183       raise CastError, "Illegal cast of long: '#{cast[:long]}'"
184     end
185   end
186   
187   # Check if the values to short are legal and raise if not.
188   def cast_check_val_short(cast)
189     unless cast[:short].is_a? String and cast[:short].length == 1
190       raise CastError, "Illegal cast of short: '#{cast[:short]}'"
191     end
192   end
193
194   # Check if values to type are legal and raise if not.
195   def cast_check_val_type(cast)
196     type_hash = {}
197     TYPES.each do |type|
198       type_hash[type] = true
199     end
200
201     unless type_hash.has_key? cast[:type]
202       raise CastError, "Illegal cast of type: '#{cast[:type]}'"
203     end
204   end
205
206   # Check if values to mandatory are legal and raise if not.
207   def cast_check_val_mandatory(cast)
208     unless cast[:mandatory] == true or cast[:mandatory] == false
209       raise CastError, "Illegal cast of mandatory: '#{cast[:mandatory]}'"
210     end
211   end
212
213   # Check if values to default are legal and raise if not.
214   def cast_check_val_default(cast)
215     unless cast[:default].nil? or
216            cast[:default].is_a? String or
217            cast[:default].is_a? Integer or
218            cast[:default].is_a? Float
219       raise CastError, "Illegal cast of default: '#{cast[:default]}'"
220     end
221   end
222
223   # Check if values to allowed are legal and raise if not.
224   def cast_check_val_allowed(cast)
225     unless cast[:allowed].is_a? String or cast[:allowed].nil?
226       raise CastError, "Illegal cast of allowed: '#{cast[:allowed]}'"
227     end
228   end
229
230   # Check if values to disallowed are legal and raise if not.
231   def cast_check_val_disallowed(cast)
232     unless cast[:disallowed].is_a? String or cast[:disallowed].nil?
233       raise CastError, "Illegal cast of disallowed: '#{cast[:disallowed]}'"
234     end
235   end
236
237   # Check cast for duplicate long or short options names.
238   def cast_check_duplicates
239     check_hash = {}
240     @casts.each do |cast|
241       raise CastError, "Duplicate argument: '--#{cast[:long]}'" if check_hash.has_key? cast[:long]
242       raise CastError, "Duplicate argument: '-#{cast[:short]}'" if check_hash.has_key? cast[:short]
243       check_hash[cast[:long]]  = true
244       check_hash[cast[:short]] = true
245     end
246   end
247
248   # Check if full "usage info" should be printed.
249   def print_usage_full?
250     @options["help"]
251   end
252
253   # Check if short "usage info" should be printed.
254   def print_usage_short?
255     if not $stdin.tty?
256       return false
257     elsif @options["stream_in"]
258       return false
259     elsif @options["data_in"]
260       return false
261     elsif wiki_path =~ /^(list_biopieces|list_genomes|list_mysql_databases|biostat)$/  # TODO get rid of this!
262       return false
263     else
264       return true
265     end
266   end
267
268   # Print usage info by Calling an external script 'print_wiki'
269   # using a system() call and exit. An optional 'full' flag
270   # outputs the full usage info.
271   def print_usage_and_exit(full=nil)
272     if full
273       system("print_wiki --data_in #{wiki_path} --help")
274     else
275       system("print_wiki --data_in #{wiki_path}")
276     end
277
278     raise "Failed printing wiki: #{wiki_path}" unless $?.success?
279
280     exit
281   end
282
283   # Set default options value from cast unless a value is set.
284   def options_default
285     @casts.each do |cast|
286       if cast[:default]
287         @options[cast[:long]] = cast[:default] unless @options.has_key? cast[:long]
288       end
289     end
290   end
291
292   # Expands glob expressions to a full list of paths.
293   # Examples: "*.fna" or "foo.fna,*.fna" or "foo.fna,/bar/*.fna"
294   def options_glob
295     @casts.each do |cast|
296       if cast[:type] == 'files' or cast[:type] == 'files!'
297         if @options.has_key? cast[:long]
298           files = []
299         
300           @options[cast[:long]].each do |path|
301             if path.include? "*"
302               Dir.glob(path).each do |file|
303                 files << file if File.file? file
304               end
305             else
306               files << path
307             end
308           end
309
310           @options[cast[:long]] = files
311         end
312       end
313     end
314   end
315
316   # Check all options according to casts.
317   def options_check
318     @casts.each do |cast|
319       options_check_mandatory(cast)
320       options_check_int(cast)
321       options_check_uint(cast)
322       options_check_file(cast)
323       options_check_files(cast)
324       options_check_dir(cast)
325       options_check_allowed(cast)
326       options_check_disallowed(cast)
327     end
328   end
329   
330   # Check if a mandatory option is set and raise if it isn't.
331   def options_check_mandatory(cast)
332     if cast[:mandatory]
333       raise ArgumentError, "Mandatory argument: --#{cast[:long]}" unless @options.has_key? cast[:long]
334     end
335   end
336
337   # Check int type option and raise if not an integer.
338   def options_check_int(cast)
339     if cast[:type] == 'int' and @options.has_key? cast[:long]
340       unless @options[cast[:long]].is_a? Integer
341         raise ArgumentError, "Argument to --#{cast[:long]} must be an integer, not '#{@options[cast[:long]]}'"
342       end
343     end
344   end
345   
346   # Check uint type option and raise if not an unsinged integer.
347   def options_check_uint(cast)
348     if cast[:type] == 'uint' and @options.has_key? cast[:long]
349       unless @options[cast[:long]].is_a? Integer and @options[cast[:long]] >= 0
350         raise ArgumentError, "Argument to --#{cast[:long]} must be an unsigned integer, not '#{@options[cast[:long]]}'"
351       end
352     end
353   end
354
355   # Check file! type argument and raise if file don't exists.
356   def options_check_file(cast)
357     if cast[:type] == 'file!' and @options.has_key? cast[:long]
358       raise ArgumentError, "No such file: '#{@options[cast[:long]]}'" unless File.file? @options[cast[:long]]
359     end
360   end
361
362   # Check files! type argument and raise if files don't exists.
363   def options_check_files(cast)
364     if cast[:type] == 'files!' and @options.has_key? cast[:long]
365       @options[cast[:long]].each do |path|
366         raise ArgumentError, "No such file: '#{path}'" unless File.file? path
367       end
368     end
369   end
370   
371   # Check dir! type argument and raise if directory don't exist.
372   def options_check_dir(cast)
373     if cast[:type] == 'dir!' and @options.has_key? cast[:long]
374       raise ArgumentError, "No such directory: '#{@options[cast[:long]]}'" unless File.directory? @options[cast[:long]]
375     end
376   end
377   
378   # Check options and raise unless allowed.
379   def options_check_allowed(cast)
380     if cast[:allowed] and @options.has_key? cast[:long]
381       allowed_hash = {}
382       cast[:allowed].split(',').each { |a| allowed_hash[a] = 1 }
383   
384       raise ArgumentError, "Argument '#{@options[cast[:long]]}' to --#{cast[:long]} not allowed" unless allowed_hash.has_key? @options[cast[:long]]
385     end
386   end
387   
388   # Check disallowed argument values and raise if disallowed.
389   def options_check_disallowed(cast)
390     if cast[:disallowed] and @options.has_key? cast[:long]
391       cast[:disallowed].split(',').each do |val|
392         raise ArgumentError, "Argument '#{@options[cast[:long]]}' to --#{cast[:long]} is disallowed" if val == @options[cast[:long]]
393       end
394     end
395   end
396
397   # Open Biopieces input data stream for reading from either
398   # stdin or from a list of files specified in options["stream_in"].
399   def stream_in_open
400     if not $stdin.tty?
401       stream = $stdin
402     else
403       stream = read(@options["stream_in"])
404     end
405
406     stream
407   end
408
409   # Open Biopieces output data stream for writing to stdout
410   # or a file specified in options["stream_out"].
411   def stream_out_open
412     if @options["stream_out"]
413       stream = write(@options["stream_out"], @options["compress"])
414     else
415       stream = $stdout
416     end
417
418     stream
419   end
420
421   # Opens a reads stream to a list of files.
422   def read(files)
423     if zipped?(files)
424       stream = zread(files)
425     else
426       stream = nread(files)
427     end
428
429     stream
430   end
431
432   # Opens a write stream to a file and returns a _io_ object.
433   def write(file, zip=nil)
434     zip ? zwrite(file) : nwrite(file)
435   end
436
437   # Test if a list of files are gzipped or not.
438   # Raises if files are mixed zipped and unzipped.
439   def zipped?(files)
440     type_hash = {}
441
442     files.each do |file|
443       type = `file #{file}`
444
445       if type =~ /gzip compressed/
446         type_hash[:gzip] = true
447       else
448         type_hash[:ascii] = true
449       end
450     end
451
452     raise "Mixture of zipped and unzipped files" if type_hash.size == 2
453
454     type_hash[:gzip]
455   end
456
457   # Opens a list of gzipped files for reading and return an _io_ object.
458   def zread(files)
459     stdin, stdout, stderr = Open3.popen3("zcat " + files.join(' '));
460     stdin.close
461     stderr.close
462     stdout
463   end
464
465   # Opens a file for gzipped writing and return an _io_ object.
466   def zwrite(file)
467     stdin, stdout, stderr = Open3.popen3("gzip -f > #{file}")
468     stderr.close
469     stdout.close
470     stdin
471   end
472
473   # Opens a list of files for reading and return an _io_ object.
474   def nread(files)
475     stdin, stdout, stderr = Open3.popen3("cat " + files.join(' '));
476     stdin.close
477     stderr.close
478     stdout
479   end
480
481   # Opens a file for writing and return an _io_ object.
482   def nwrite(file)
483     File.open(file, mode="w")
484   end
485
486   # Write the status to a status file.
487   def status_set
488     now    = Time.new
489     time   = now.strftime("%Y-%m-%d %X")
490     user   = ENV["USER"]
491     script = File.basename($0, ".rb")
492     pid    = $$
493     path   = ENV["BP_TMP"] + "/" + [user, script, pid, "status"].join(".")
494
495     File.open(path, mode="w") { |file| file.puts [time, ARGV.join(" ")].join(";") }
496   end
497
498   # Write the Biopiece status to the log file.
499   def status_log(status="OK")
500     now     = Time.new
501     time1   = now.strftime("%Y-%m-%d %X")
502     user    = ENV["USER"]
503     script  = File.basename($0, ".rb")
504     pid     = $$
505     path    = ENV["BP_TMP"] + "/" + [user, script, pid, "status"].join(".")
506
507     stream = File.open(path)
508     time0, args, tmp_dir = stream.first.split(";")
509
510     # Dir.rmdir(tmp_dir) unless tmp_dir.nil? and File.directory? tmp_dir   #TODO fix this!
511
512     elap     = time_diff(time0, time1)
513     command  = [script, args].join(" ") 
514     log_file = ENV["BP_LOG"] + "/biopieces.log"
515
516     File.open(log_file, mode="a") { |file| file.puts [time0, time1, elap, user, status, command].join("\t") }
517   end
518
519   # Get the elapsed time from the difference between two time stamps.
520   def time_diff(t0, t1)
521     t0 =~ /(\d+)-(\d+)-(\d+) (\d+):(\d+):(\d+)/
522     year0 = $1.to_i
523     mon0  = $2.to_i
524     day0  = $3.to_i
525     hour0 = $4.to_i
526     min0  = $5.to_i
527     sec0  = $6.to_i
528
529     sec0 += day0  * 24 * 60 * 60
530     sec0 += hour0 * 60 * 60
531     sec0 += min0  * 60
532
533     t1 =~ /(\d+)-(\d+)-(\d+) (\d+):(\d+):(\d+)/
534     year1 = $1.to_i
535     mon1  = $2.to_i
536     day1  = $3.to_i
537     hour1 = $4.to_i
538     min1  = $5.to_i
539     sec1  = $6.to_i
540
541     sec1 += day1  * 24 * 60 * 60
542     sec1 += hour1 * 60 * 60
543     sec1 += min1  * 60
544
545     year = year1 - year0
546     mon  = mon1  - mon0
547     day  = day1  - day0 
548
549     sec  = sec1 - sec0
550
551     hour = ( sec / ( 60 * 60 ) ).to_i
552     sec -= hour * 60 * 60
553
554     min  = ( sec / 60 ).to_i
555     sec -= min * 60
556
557     [sprintf("%02d", hour), sprintf("%02d", min), sprintf("%02d", sec)].join(":")
558   end
559 end
560
561 __END__