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