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