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