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