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