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