tap 0.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. data/MIT-LICENSE +21 -0
  2. data/README +71 -0
  3. data/Rakefile +117 -0
  4. data/bin/tap +63 -0
  5. data/lib/tap.rb +15 -0
  6. data/lib/tap/app.rb +739 -0
  7. data/lib/tap/file_task.rb +354 -0
  8. data/lib/tap/generator.rb +29 -0
  9. data/lib/tap/generator/generators/config/USAGE +0 -0
  10. data/lib/tap/generator/generators/config/config_generator.rb +23 -0
  11. data/lib/tap/generator/generators/config/templates/config.erb +2 -0
  12. data/lib/tap/generator/generators/file_task/USAGE +0 -0
  13. data/lib/tap/generator/generators/file_task/file_task_generator.rb +21 -0
  14. data/lib/tap/generator/generators/file_task/templates/task.erb +27 -0
  15. data/lib/tap/generator/generators/file_task/templates/test.erb +12 -0
  16. data/lib/tap/generator/generators/root/USAGE +0 -0
  17. data/lib/tap/generator/generators/root/root_generator.rb +36 -0
  18. data/lib/tap/generator/generators/root/templates/Rakefile +48 -0
  19. data/lib/tap/generator/generators/root/templates/app.yml +19 -0
  20. data/lib/tap/generator/generators/root/templates/config/process_tap_request.yml +4 -0
  21. data/lib/tap/generator/generators/root/templates/lib/process_tap_request.rb +26 -0
  22. data/lib/tap/generator/generators/root/templates/public/images/nav.jpg +0 -0
  23. data/lib/tap/generator/generators/root/templates/public/stylesheets/color.css +57 -0
  24. data/lib/tap/generator/generators/root/templates/public/stylesheets/layout.css +108 -0
  25. data/lib/tap/generator/generators/root/templates/public/stylesheets/normalize.css +40 -0
  26. data/lib/tap/generator/generators/root/templates/public/stylesheets/typography.css +21 -0
  27. data/lib/tap/generator/generators/root/templates/server/config/environment.rb +60 -0
  28. data/lib/tap/generator/generators/root/templates/server/lib/tasks/clear_database_prerequisites.rake +5 -0
  29. data/lib/tap/generator/generators/root/templates/server/test/test_helper.rb +53 -0
  30. data/lib/tap/generator/generators/root/templates/test/tap_test_helper.rb +3 -0
  31. data/lib/tap/generator/generators/root/templates/test/tap_test_suite.rb +4 -0
  32. data/lib/tap/generator/generators/task/USAGE +0 -0
  33. data/lib/tap/generator/generators/task/task_generator.rb +21 -0
  34. data/lib/tap/generator/generators/task/templates/task.erb +21 -0
  35. data/lib/tap/generator/generators/task/templates/test.erb +29 -0
  36. data/lib/tap/generator/generators/workflow/USAGE +0 -0
  37. data/lib/tap/generator/generators/workflow/templates/task.erb +16 -0
  38. data/lib/tap/generator/generators/workflow/templates/test.erb +7 -0
  39. data/lib/tap/generator/generators/workflow/workflow_generator.rb +21 -0
  40. data/lib/tap/generator/options.rb +26 -0
  41. data/lib/tap/generator/usage.rb +26 -0
  42. data/lib/tap/root.rb +275 -0
  43. data/lib/tap/script/console.rb +7 -0
  44. data/lib/tap/script/destroy.rb +8 -0
  45. data/lib/tap/script/generate.rb +8 -0
  46. data/lib/tap/script/run.rb +111 -0
  47. data/lib/tap/script/server.rb +12 -0
  48. data/lib/tap/support/audit.rb +415 -0
  49. data/lib/tap/support/batch_queue.rb +165 -0
  50. data/lib/tap/support/combinator.rb +114 -0
  51. data/lib/tap/support/logger.rb +91 -0
  52. data/lib/tap/support/rap.rb +38 -0
  53. data/lib/tap/support/run_error.rb +20 -0
  54. data/lib/tap/support/template.rb +81 -0
  55. data/lib/tap/support/templater.rb +155 -0
  56. data/lib/tap/support/versions.rb +63 -0
  57. data/lib/tap/task.rb +448 -0
  58. data/lib/tap/test.rb +320 -0
  59. data/lib/tap/test/env_vars.rb +16 -0
  60. data/lib/tap/test/inference_methods.rb +298 -0
  61. data/lib/tap/test/subset_methods.rb +260 -0
  62. data/lib/tap/version.rb +3 -0
  63. data/lib/tap/workflow.rb +73 -0
  64. data/test/app/config/addition_template.yml +6 -0
  65. data/test/app/config/batch.yml +2 -0
  66. data/test/app/config/empty.yml +0 -0
  67. data/test/app/config/erb.yml +1 -0
  68. data/test/app/config/template.yml +6 -0
  69. data/test/app/config/version-0.1.yml +1 -0
  70. data/test/app/config/version.yml +1 -0
  71. data/test/app/lib/app_test_task.rb +2 -0
  72. data/test/app_class_test.rb +33 -0
  73. data/test/app_test.rb +1372 -0
  74. data/test/file_task/config/batch.yml +2 -0
  75. data/test/file_task/config/configured.yml +1 -0
  76. data/test/file_task/old_file_one.txt +0 -0
  77. data/test/file_task/old_file_two.txt +0 -0
  78. data/test/file_task_test.rb +1041 -0
  79. data/test/root/alt_lib/alt_module.rb +4 -0
  80. data/test/root/lib/absolute_alt_filepath.rb +2 -0
  81. data/test/root/lib/alternative_filepath.rb +2 -0
  82. data/test/root/lib/another_module.rb +2 -0
  83. data/test/root/lib/nested/some_module.rb +4 -0
  84. data/test/root/lib/no_module_included.rb +0 -0
  85. data/test/root/lib/some/module.rb +4 -0
  86. data/test/root/lib/some_class.rb +2 -0
  87. data/test/root/lib/some_module.rb +3 -0
  88. data/test/root/load_path/load_path_module.rb +2 -0
  89. data/test/root/load_path/skip_module.rb +2 -0
  90. data/test/root/mtime/older.txt +0 -0
  91. data/test/root/unload/full_path.rb +2 -0
  92. data/test/root/unload/loaded_by_nested.rb +2 -0
  93. data/test/root/unload/nested/nested_load.rb +6 -0
  94. data/test/root/unload/nested/nested_with_ext.rb +4 -0
  95. data/test/root/unload/nested/relative_path.rb +4 -0
  96. data/test/root/unload/older.rb +2 -0
  97. data/test/root/unload/unload_base.rb +9 -0
  98. data/test/root/versions/another.yml +0 -0
  99. data/test/root/versions/file-0.1.2.yml +0 -0
  100. data/test/root/versions/file-0.1.yml +0 -0
  101. data/test/root/versions/file.yml +0 -0
  102. data/test/root_test.rb +483 -0
  103. data/test/support/audit_test.rb +449 -0
  104. data/test/support/batch_queue_test.rb +320 -0
  105. data/test/support/combinator_test.rb +249 -0
  106. data/test/support/logger_test.rb +31 -0
  107. data/test/support/template_test.rb +122 -0
  108. data/test/support/templater/erb.txt +2 -0
  109. data/test/support/templater/erb.yml +2 -0
  110. data/test/support/templater/somefile.txt +2 -0
  111. data/test/support/templater_test.rb +192 -0
  112. data/test/support/versions_test.rb +71 -0
  113. data/test/tap_test_helper.rb +4 -0
  114. data/test/tap_test_suite.rb +4 -0
  115. data/test/task/config/batch.yml +2 -0
  116. data/test/task/config/batched.yml +2 -0
  117. data/test/task/config/configured.yml +1 -0
  118. data/test/task/config/example.yml +1 -0
  119. data/test/task/config/overriding.yml +2 -0
  120. data/test/task/config/task_with_config.yml +1 -0
  121. data/test/task/config/template.yml +4 -0
  122. data/test/task_class_test.rb +118 -0
  123. data/test/task_execute_test.rb +233 -0
  124. data/test/task_test.rb +424 -0
  125. data/test/test/inference_methods/test_assert_expected/expected/file.txt +1 -0
  126. data/test/test/inference_methods/test_assert_expected/expected/folder/file.txt +1 -0
  127. data/test/test/inference_methods/test_assert_expected/input/file.txt +1 -0
  128. data/test/test/inference_methods/test_assert_expected/input/folder/file.txt +1 -0
  129. data/test/test/inference_methods/test_assert_files_exist/input/input_1.txt +0 -0
  130. data/test/test/inference_methods/test_assert_files_exist/input/input_2.txt +0 -0
  131. data/test/test/inference_methods/test_file_compare/expected/output_1.txt +3 -0
  132. data/test/test/inference_methods/test_file_compare/expected/output_2.txt +1 -0
  133. data/test/test/inference_methods/test_file_compare/input/input_1.txt +3 -0
  134. data/test/test/inference_methods/test_file_compare/input/input_2.txt +3 -0
  135. data/test/test/inference_methods/test_infer_glob/expected/file.yml +0 -0
  136. data/test/test/inference_methods/test_infer_glob/expected/file_1.txt +0 -0
  137. data/test/test/inference_methods/test_infer_glob/expected/file_2.txt +0 -0
  138. data/test/test/inference_methods/test_yml_compare/expected/output_1.yml +6 -0
  139. data/test/test/inference_methods/test_yml_compare/expected/output_2.yml +6 -0
  140. data/test/test/inference_methods/test_yml_compare/input/input_1.yml +4 -0
  141. data/test/test/inference_methods/test_yml_compare/input/input_2.yml +4 -0
  142. data/test/test/inference_methods_test.rb +311 -0
  143. data/test/test/subset_methods_test.rb +115 -0
  144. data/test/test_test.rb +233 -0
  145. data/test/workflow_test.rb +108 -0
  146. metadata +274 -0
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ Copyright (c) 2006-2007, Regents of the University of Colorado.
2
+ Developer:: Simon Chiang, Biomolecular Structure Program
3
+ Support:: UCHSC School of Medicine Deans Academic Enrichment Fund
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this
6
+ software and associated documentation files (the "Software"), to deal in the Software
7
+ without restriction, including without limitation the rights to use, copy, modify, merge,
8
+ publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
9
+ to whom the Software is furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or
12
+ substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
18
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
19
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
21
+ OTHER DEALINGS IN THE SOFTWARE.
data/README ADDED
@@ -0,0 +1,71 @@
1
+ = Tap
2
+
3
+ Task Application: A framework for configurable, file based, web ready workflow applications.
4
+
5
+ == Description
6
+
7
+ Tap fundamentally is a basic workflow engine wherein you can define tasks and rules to pass
8
+ outputs from one task into another. Tap uses a standardized folder layout to assist in
9
+ configuring tasks and managing files.
10
+
11
+ Tap can be run from the console or from an integrated web server built on the camping[link to camping]
12
+ framework. The Tap Server makes it easy to build tools and share them within an organization,
13
+ or with a wider audience as you please.
14
+
15
+ == Info
16
+
17
+ Copyright (c) 2006-2007, Regents of the University of Colorado.
18
+ Developer:: Simon Chiang, Biomolecular Structure Program
19
+ Support:: UCHSC School of Medicine Deans Academic Enrichment Fund
20
+ Licence:: MIT-Style
21
+
22
+ == Installation
23
+
24
+ Tap is available as a gem on RubyForge[http://rubyforge.org/projects/tap]. Use:
25
+
26
+ % gem install tap
27
+
28
+ In addition, Tap may be installed through a {Firefox Add-On}[http://prosperity.uchsc.edu/tap/addon].
29
+ The Tap add-on provides shortcuts for starting and running Tap in a console or as a server.
30
+
31
+ == Usage
32
+
33
+ Begin by creating a Tap folder:
34
+
35
+ % tap --generate root path/to/folder
36
+
37
+ This command generates the standard file structure for Tap, including folders for
38
+ configurations, data, task libraries, documentation, and testing. Navigate into your
39
+ folder and generate a task:
40
+
41
+ % tap --generate task say_hello
42
+
43
+ The task generator makes a stub configuration file, task, and test. Open up each file to
44
+ see the basics of task creation. Everthing is set up for the test run, so give it a go:
45
+
46
+ % tap say_hello "from me"
47
+ # => I[hh::mm::ss] message hello from me!
48
+
49
+ You can start an IRB session to interact with your tap tasks...
50
+
51
+ % tap --console
52
+ > Tap::App.run(:say_hello, "from me")
53
+ # => I[hh::mm::ss] message hello from me!
54
+
55
+ Or start the server...
56
+
57
+ % tap --server
58
+
59
+ Now you can go to {http://localhost:3000/tap}[http://localhost:3000/tap] to see your
60
+ Tap Server in action.
61
+
62
+ == Auditing
63
+ == Notes
64
+
65
+ Tap takes a simplified approach to workflows where the idea is to setup
66
+ tasks, their configs, conditions, and on_complete behavior before running,
67
+ and then to let the application act out this predefined logic. Changing
68
+ task setup during execution can naturally cause unexpected behavior.
69
+ Various safeguards should let you get away with this inadvisable practice,
70
+ but on the other hand you may just thread lock the whole caboodle.
71
+
data/Rakefile ADDED
@@ -0,0 +1,117 @@
1
+ require 'rake'
2
+ require 'rake/testtask'
3
+ require 'rake/rdoctask'
4
+ require 'rake/gempackagetask'
5
+
6
+ desc 'Default: Run tests.'
7
+ task :default => :test
8
+
9
+ desc 'Run tests.'
10
+ Rake::TestTask.new(:test) do |t|
11
+ t.libs << 'lib'
12
+ t.pattern = File.join('test', ENV['subset'] || '', ENV['pattern'] || '**/*_test.rb')
13
+ t.verbose = true
14
+ t.warning = true
15
+ end
16
+
17
+ desc 'Generate documentation.'
18
+ Rake::RDocTask.new(:rdoc) do |rdoc|
19
+ rdoc.rdoc_dir = 'rdoc'
20
+ rdoc.title = 'tap'
21
+ rdoc.options << '--line-numbers' << '--inline-source'
22
+ rdoc.rdoc_files.include('README', 'MIT-LICENSE')
23
+ rdoc.rdoc_files.include('lib/tap/**/*.rb')
24
+ end
25
+
26
+ #
27
+ # Gem specification
28
+ #
29
+ require './lib/tap/version.rb'
30
+
31
+ Gem::manage_gems
32
+
33
+ spec = Gem::Specification.new do |s|
34
+ s.name = "tap"
35
+ s.version = Tap::VERSION
36
+ s.author = "Simon Chiang"
37
+ s.email = "simon.chiang@uchsc.edu"
38
+ s.homepage = "http://rubyforge.org/projects/tap/"
39
+ s.platform = Gem::Platform::RUBY
40
+ s.summary = "Framework for configurable, file-based applications."
41
+ s.files = Dir.glob("{bin,test,lib}/**/*") + ["MIT-LICENSE", "README", "Rakefile"]
42
+ s.require_path = "lib"
43
+ s.autorequire = "tap"
44
+ s.test_file = "test/tap_test_suite.rb"
45
+ s.bindir = "bin"
46
+ s.executables = ["tap"]
47
+ s.default_executable = "tap"
48
+
49
+ s.has_rdoc = true
50
+ s.rdoc_options << '--title' << 'Tap - Task Application' << '--main' << 'README'
51
+ s.extra_rdoc_files = ["README", "MIT-LICENSE"]
52
+ s.add_dependency("rails", ">= 1.2.3") # only for generators?
53
+ s.add_dependency("activesupport", ">=1.4.2")
54
+ end
55
+
56
+ Rake::GemPackageTask.new(spec) do |pkg|
57
+ pkg.need_tar = true
58
+ end
59
+
60
+ #
61
+ # Hoe tasks
62
+ #
63
+
64
+ desc 'Install the package as a gem'
65
+ task :install_gem => [:package] do
66
+ #task :install_gem => [:clean, :package] do
67
+ sh "#{'sudo ' unless WINDOZE}gem install pkg/*.gem"
68
+ end
69
+
70
+ desc "Publish RDoc to RubyForge"
71
+ task :publish_docs => [:clean, :docs] do
72
+ config = YAML.load(File.read(File.expand_path("~/.rubyforge/user-config.yml")))
73
+ host = "#{config["username"]}@rubyforge.org"
74
+
75
+ remote_dir = "/var/www/gforge-projects/#{rubyforge_name}/#{remote_rdoc_dir}"
76
+ local_dir = 'doc'
77
+
78
+ sh %{rsync #{rsync_args} #{local_dir}/ #{host}:#{remote_dir}}
79
+ end
80
+
81
+ desc 'Package and upload the release to rubyforge.'
82
+ task :release => [:package] do |t|
83
+ require 'rubyforge'
84
+
85
+ #task :release => [:clean, :package] do |t|
86
+ v = ENV["VERSION"] or abort "Must supply VERSION=x.y.z"
87
+ abort "Versions don't match #{v} vs #{spec.version}" if v != spec.version.to_s
88
+ pkg = "pkg/#{spec.name}-#{spec.version}"
89
+
90
+ if $DEBUG then
91
+ puts "release_id = rf.add_release #{rubyforge_name.inspect}, #{name.inspect}, #{version.inspect}, \"#{pkg}.tgz\""
92
+ puts "rf.add_file #{rubyforge_name.inspect}, #{name.inspect}, release_id, \"#{pkg}.gem\""
93
+ end
94
+
95
+ rf = RubyForge.new
96
+ puts "Logging in"
97
+ rf.login
98
+
99
+ c = rf.userconfig
100
+ c["release_notes"] = description if description
101
+ c["release_changes"] = changes if changes
102
+ c["preformatted"] = true
103
+
104
+ #files = [(@need_tar ? "#{pkg}.tgz" : nil),
105
+ # (@need_zip ? "#{pkg}.zip" : nil),
106
+ # "#{pkg}.gem"].compact
107
+
108
+ files = "#{pkg}.gem"
109
+
110
+ puts "Releasing #{name} v. #{version}"
111
+ rf.add_release rubyforge_name, name, version, *files
112
+ end
113
+
114
+ desc 'Show information about the gem.'
115
+ task :debug_gem do
116
+ puts spec.to_ruby
117
+ end
data/bin/tap ADDED
@@ -0,0 +1,63 @@
1
+ #require 'rdoc/usage'
2
+ #require 'getoptlong'
3
+
4
+ # add options as needed
5
+ #opts = GetoptLong.new(
6
+ # [ '--help', '-h', GetoptLong::NO_ARGUMENT ])
7
+ # opts.each do |opt, arg|
8
+ # case opt
9
+ # when '--help' then nil
10
+ # end
11
+ # end
12
+
13
+ lib_dir = File.join( File.dirname(__FILE__), "../lib/")
14
+ require lib_dir + "tap.rb"
15
+
16
+ # configure the app to app.yml if it exists
17
+ if File.exists?("app.yml")
18
+ config = ERB.new( File.read("app.yml") ).result
19
+ config = YAML.load(config)
20
+ Tap::App.instance.reconfigure(config) if config
21
+ end
22
+
23
+ begin
24
+ command = ARGV.shift
25
+ case command
26
+ when "help", "?", nil
27
+ puts "usage: tap <command> [options] [args]"
28
+ puts "available commands:"
29
+
30
+ commands = Tap::App.instance.glob(:script).collect do |script|
31
+ next unless File.extname(script) == ".rb"
32
+ Tap::App.instance.relative_filepath(:script, script).chomp(".rb")
33
+ end
34
+ Tap::App.glob(lib_dir + "tap/script/**/*").each do |script|
35
+ next unless File.extname(script) == ".rb"
36
+ command = Tap::App.relative_filepath(lib_dir + "tap/script", script).chomp(".rb")
37
+ commands << command unless commands.include?(command)
38
+ end
39
+
40
+ print " "
41
+ puts commands.sort.join("\n ")
42
+ puts
43
+
44
+ else
45
+ app_script_filepath = Tap::App.instance.filepath(:script, command + ".rb")
46
+ tap_script_filepath = lib_dir + "tap/script/#{command}.rb"
47
+
48
+ if File.exists?(app_script_filepath)
49
+ load app_script_filepath
50
+ elsif File.exists?(tap_script_filepath)
51
+ load tap_script_filepath
52
+ else
53
+ raise "Unknown command: '#{command}'"
54
+ end
55
+ end
56
+ rescue
57
+ if Tap::App.instance.options.debug
58
+ raise
59
+ else
60
+ puts $!.message
61
+ puts "Type 'tap help' for usage information."
62
+ end
63
+ end
data/lib/tap.rb ADDED
@@ -0,0 +1,15 @@
1
+ require 'active_support'
2
+
3
+ $:.unshift File.dirname(__FILE__)
4
+
5
+ require 'tap/version'
6
+ require 'tap/root'
7
+ require 'tap/app'
8
+ require 'tap/task'
9
+ require 'tap/workflow'
10
+ require 'tap/file_task'
11
+ require 'tap/support/audit'
12
+ require 'tap/support/logger'
13
+ require 'tap/support/templater'
14
+ require 'tap/support/batch_queue'
15
+ require 'tap/support/run_error'
data/lib/tap/app.rb ADDED
@@ -0,0 +1,739 @@
1
+ require 'yaml'
2
+ require 'logger'
3
+ require 'ostruct'
4
+ require 'getoptlong'
5
+ require 'monitor'
6
+ require 'thread'
7
+ require 'thwait'
8
+ require 'erb'
9
+
10
+ module Tap
11
+
12
+ # == Overview
13
+ #
14
+ # App coordinates the setup and running of tasks, and provides an interface
15
+ # to the application directory structure. App is convenient for use within
16
+ # scripts, and can be extended with various modules to provide command line
17
+ # utilites or to be integrated within a tap server.
18
+ #
19
+ # All tasks have an App (by default App.instance) which helps set the task
20
+ # up by loading configuration templates from the application directory, and
21
+ # to which all queue commands are issued. Tasks also access application-
22
+ # wide resources like the logger and various options through App.
23
+ #
24
+ # task = Task.new {|task, input| input += 1 }
25
+ # task.app # => App.instance
26
+ # task.queue 1,2,3
27
+ #
28
+ # app.run
29
+ #
30
+ # task.outputs.collect do |audit|
31
+ # audit._current
32
+ # end # => [2,3,4]
33
+ #
34
+ # === Running Tasks
35
+ #
36
+ # Tasks are run in the order they are originally queued. Batched tasks are
37
+ # all queued at the same time and therefore will execute in succession.
38
+ # Multithreaded tasks execute cosynchronously, each on their own thread.
39
+ #
40
+ # Workflows can be achieved by setting the condition and on_complete blocks
41
+ # for a task. Tasks are skipped in the queue until the condition block is
42
+ # met with the currently queued inputs. When a task finishes, it will
43
+ # execute the on_complete block where results can be dispacted to other
44
+ # tasks, and these tasks queued for execution.
45
+ #
46
+ # In this system, the workflow logic exists among the tasks. App keeps on
47
+ # running as long as it finds executable tasks in the queue.
48
+ #
49
+ # === Error Handling
50
+ #
51
+ # When unhandled errors arise during a run, App enters a termination (rescue)
52
+ # routine. During termination a TerminationError is raised for each executing
53
+ # task so that the task immediately exits, or begins executing its internal
54
+ # error handling code (perhaps performing rollbacks).
55
+ #
56
+ # Additional errors that arise during termination are collected and packaged
57
+ # with the orignal error into a RunError. By default all errors are logged
58
+ # and the run exits. If options.debug == true, then the RunError will be raised
59
+ # for further handling.
60
+ #
61
+ # Note: the task that caused the original unhandled error is no longer executing
62
+ # when termination begins and thus will not recieve a TerminationError.
63
+ #
64
+ class App < Root
65
+ include MonitorMixin
66
+
67
+ class << self
68
+ attr_writer :instance
69
+
70
+ # Returns the current instance of App. If no instance has been set,
71
+ # then a new App with the default configuration will be initialized
72
+ # to instance.
73
+ def instance
74
+ @instance ||= App.new
75
+ end
76
+
77
+ # Runs the specified task and inputs with the current instance.
78
+ def run(task, *inputs)
79
+ instance.run(task, *inputs)
80
+ end
81
+
82
+ # Parses the input argument as YAML, if arg is a string that matches
83
+ # the YAML document specifier (ie it begins with "---\n"). Otherwise
84
+ # returns the argument.
85
+ def parse_yaml(arg)
86
+ arg =~ /^---\s*\n/ ? YAML.load(arg) : arg
87
+ end
88
+
89
+ def read_erb_yaml(filepath)
90
+ return nil unless File.exists?(filepath)
91
+
92
+ input = File.read(filepath)
93
+ input = ERB.new(input).result
94
+ YAML.load(input)
95
+ end
96
+
97
+ def make_config(task, config, version=nil)
98
+
99
+ end
100
+ end
101
+
102
+ attr_reader :options, :logger, :queue, :state
103
+ attr_accessor :map
104
+
105
+ module State
106
+ READY = 0
107
+ RUN = 1
108
+ STOP = 2
109
+ TERMINATE = 3
110
+ end
111
+
112
+ DEFAULT_MAX_THREADS = 10
113
+
114
+ # Creates a new App with the given configuration. See reconfigure for
115
+ # configuration options.
116
+ def initialize(config={})
117
+ super()
118
+
119
+ @queue = Support::BatchQueue.new
120
+ @threads = []
121
+ @thread_queue = nil
122
+ @main_thread = nil
123
+ @state = State::READY
124
+
125
+ # defaults must be provided for options and logging to ensure
126
+ # that they will be initialized by reconfigure
127
+ self.reconfigure( {
128
+ :options => {}, :logger => {}, :map => {},
129
+ }.merge(config) )
130
+ end
131
+
132
+ # Returns the current configuration for the App.
133
+ def config
134
+ {:root => self.root,
135
+ :directories => self.directories,
136
+ :absolute_paths => self.absolute_paths,
137
+ :options => self.options.marshal_dump,
138
+ :logger => {
139
+ :device => self.logger.logdev.dev,
140
+ :level => self.logger.level,
141
+ :datetime_format => self.logger.datetime_format},
142
+ :load_paths => self.load_paths}
143
+ end
144
+
145
+ # Reconfigures App with the input configurations; other configurations are not affected.
146
+ #
147
+ # Available configurations:
148
+ # root:: resets the root directory of the app using root=
149
+ # directories:: resets the app directories using directories= (note ALL direcotries
150
+ # are reset. use app[dir]= to set a single directory)
151
+ # options:: resets the application options (note ALL options are reset. use
152
+ # app.options.opt= to set a single option)
153
+ # logger:: creates and sets a new logger from the configuration
154
+ #
155
+ # Available logger configurations and defaults:
156
+ # device:: STDOUT
157
+ # level:: INFO (1)
158
+ # datetime_format:: %H:%M:%S
159
+ #
160
+ # Notes:
161
+ # - Unknown configurations raise an error.
162
+ # - Additional configurations may be added in subclasses using handle_configuration
163
+ def reconfigure(config={})
164
+ config = config.symbolize_keys
165
+
166
+ # ensure critical keys are evaluated in the proper order
167
+ keys = [:root, :directories, :absolute_paths, :options, :load_paths, :loadable]
168
+ config.keys.each do |key|
169
+ keys << key unless keys.include?(key)
170
+ end
171
+
172
+ keys.each do |key|
173
+ next unless config.has_key?(key)
174
+ value = config[key]
175
+
176
+ case key
177
+ when :root
178
+ self.root = value
179
+ when :directories
180
+ self.directories = value
181
+ when :absolute_paths
182
+ self.absolute_paths = value
183
+ when :options
184
+ @options = OpenStruct.new
185
+ value.each_pair {|k,v| options.send("#{k}=", v) }
186
+ when :logger
187
+ log_config = {
188
+ :device => STDOUT,
189
+ :level => 'INFO',
190
+ :datetime_format => '%H:%M:%S'
191
+ }.merge(value.symbolize_keys)
192
+
193
+ logger = Logger.new(log_config[:device])
194
+ logger.level = log_config[:level].kind_of?(String) ? Logger.const_get(log_config[:level]) : log_config[:level]
195
+ logger.datetime_format = log_config[:datetime_format]
196
+ self.logger = logger
197
+ when :map
198
+ self.map = value
199
+ when :load_paths
200
+ self.load_paths = value
201
+ when :loadable
202
+ value.each {|dir| self.load_paths << self[dir]}
203
+ else
204
+ raise "Unknown configuration: #{key}" unless handle_configuation(key, value)
205
+ end
206
+ end
207
+ end
208
+
209
+ #
210
+ # Dependencies and reloading
211
+ #
212
+
213
+ def load_paths
214
+ Dependencies.load_paths
215
+ end
216
+
217
+ def load_paths=(paths)
218
+ Dependencies.load_paths = paths
219
+ end
220
+
221
+ def reload
222
+ # TODO - logging, maybe through Dependencies as
223
+ # RAILS_DEFAULT_LOGGER = logger (problem with this during server?)
224
+ # Dependencies.log_activity = true
225
+ Dependencies.clear
226
+ end
227
+
228
+ #
229
+ # Logging methods
230
+ #
231
+
232
+ # Sets the current logger. The logger is extended with Support::Logger to provide
233
+ # additional logging capabilities.
234
+ def logger=(logger)
235
+ @logger = logger
236
+ @logger.extend Support::Logger unless @logger.nil?
237
+ @logger
238
+ end
239
+
240
+ # Logs the action and message at the input level (default INFO).
241
+ # Logging is suppressed if app.options.quiet
242
+ def log(action, msg="", level=Logger::INFO)
243
+ logger.add(level, msg, action.to_s) unless options.quiet
244
+ end
245
+
246
+ def monitor(action="beginning", msg="", &block)
247
+ if options.quiet
248
+ yield
249
+ nil
250
+ else
251
+ @monitoring = true
252
+ result = logger.monitor(action, msg, &block)
253
+ @monitoring = false
254
+ result
255
+ end
256
+ end
257
+
258
+ def tick_monitor(action=nil, msg="", level=Logger::INFO)
259
+ unless options.quiet || !@monitoring
260
+ action.nil? ?
261
+ logger.tick :
262
+ logger.format_add(level, msg, action.to_s) {|format| "\n" + format}
263
+ end
264
+ end
265
+
266
+ def monitor_stats(hash)
267
+ unless options.quiet || !@monitoring
268
+ logger << "\n"
269
+ logger << hash.stringify_keys.to_yaml
270
+ end
271
+ end
272
+
273
+ #
274
+ # Task methods
275
+ #
276
+
277
+ # Returns the specifed task, reconfigured with config (if provided).
278
+ #
279
+ # t = Task.new :key => 'value'
280
+ # t = app.task(t, :key => 'another')
281
+ # t.config[:key] # => 'another'
282
+ #
283
+ # If a task name is given, then the appropriate class will be loaded and
284
+ # instantiated into a task with given name. The class is the de-versioned
285
+ # and camelized name, or the class as specified in map.
286
+ #
287
+ # app.map # => {"mapped-task" => "Task"}
288
+ #
289
+ # t = app.task('mapped-task-1.0', :key => 'value')
290
+ # t.class # => Task
291
+ # t.name # => "mapped-task-1.0"
292
+ # t.config[:key] # => 'value'
293
+ #
294
+ def task(td, config=nil)
295
+ t = if td.kind_of?(Tap::Task)
296
+ td
297
+ else
298
+ # lookup the corresponding class and instantiate
299
+ constants = task_class_name(td).split('::')
300
+ task_class = constants.inject(Object) do |klass, const|
301
+ begin
302
+ klass.const_get(const)
303
+ rescue(NameError)
304
+ raise "unknown task '#{td}'"
305
+ end
306
+ end
307
+ task_class.new(td)
308
+ end
309
+
310
+ # reconfigure if a config is provided
311
+ t.config = config unless config.nil?
312
+ t
313
+ end
314
+
315
+ def task_class_name(td)
316
+ case td
317
+ when Tap::Task then td.class.to_s
318
+ else
319
+ # de-version and resolve using map
320
+ name, version = deversion(td)
321
+ map.has_key?(name) ? map[name] : name.camelize
322
+ end
323
+ end
324
+
325
+ # Creates and returns an array of configuration templates for the specified file.
326
+ # To make templates, the contents of the file are processed using ERB, then
327
+ # loaded as YAML and assembled into an array of hashes.
328
+ #
329
+ # # simple.yml => "key: value"
330
+ # app.config_templates("simple.yml") # => [{"key" => "value"}]
331
+ #
332
+ # # array_with_erb.yml => %Q{
333
+ # # - key: <%= 1 %>
334
+ # # - key: <%= 1 + 1 %>}
335
+ # app.config_templates("array_with_erb.yml") # => [{"key" => 1}, {"key" => 2}]
336
+ #
337
+ # If no config templates are loaded (as when the filepath does not exist, or the
338
+ # file is empty), then the return will be a single empty template -- [{}].
339
+ def config_templates(filepath)
340
+ config_templates = App.read_erb_yaml(filepath)
341
+
342
+ config_templates = case config_templates
343
+ when Array then config_templates
344
+ when Hash then [config_templates]
345
+ else
346
+ return [{}]
347
+ end
348
+
349
+ config_templates
350
+ end
351
+
352
+ #
353
+ # Execution methods
354
+ #
355
+
356
+ # Dequeues the task and inputs and executes the task with the current task inputs.
357
+ # Only the provided task will be executed (ie the task batch will not be executed)
358
+ # and execution is on the current thread even if the task is multithreaded.
359
+ #
360
+ # execute can only run if the application state is READY or RUN, and does not
361
+ # perform any error handling
362
+ def execute(task)
363
+ synchronize do
364
+ #log(:execute, task.to_dir, Logger::DEBUG) if options.debug
365
+
366
+ unless state == State::READY || state == State::RUN
367
+ raise "cannot execute unless application state is READY or RUN"
368
+ end
369
+
370
+ if deq = queue.deq(task)
371
+ task, inputs = deq
372
+ task.execute(*inputs)
373
+ end
374
+ end
375
+ end
376
+
377
+ # Enqueues the task and inputs (if provided), then iterates through the queue
378
+ # executing tasks using this execution cycle:
379
+ #
380
+ # for each enqueued task:
381
+ # next unless executable?(task)
382
+ #
383
+ # dequeue task and inputs
384
+ # if multithreading is allowed and task.multithread?
385
+ # execute task on a thread
386
+ # else
387
+ # execute task
388
+ #
389
+ # At the end of run, the queue may still contain unexecuted tasks. These
390
+ # tasks require additional inputs or conditions in order to execute. Tasks
391
+ # may be specified as a descriptor (ex: "some/task" => SomeTask.new) or
392
+ # as the task itself; run returns the specified task on completion.
393
+ def run(td=nil, *inputs)
394
+ synchronize do
395
+ # TODO: log starting run
396
+
397
+ # lookup and enqueue the task, if provided
398
+ specified_task = task(td) if td
399
+ queue.enq(specified_task, *inputs) if specified_task
400
+
401
+ # generate threading variables
402
+ self.state = State::RUN
403
+ self.main_thread = Thread.current
404
+ max_threads = options.max_threads || DEFAULT_MAX_THREADS
405
+ self.thread_queue = max_threads > 0 ? Queue.new : nil
406
+
407
+ begin
408
+ execution_loop do
409
+ task, inputs = queue.deq
410
+
411
+ # if no tasks were in the queue
412
+ # then clear the threads and
413
+ # check for tasks again
414
+ unless task
415
+ clear_threads
416
+ task, inputs = queue.deq
417
+ end
418
+
419
+ # break -- no executable task was found
420
+ break if task.nil?
421
+
422
+ if thread_queue && task.multithread?
423
+ # TODO: log enqueuing task to thread
424
+
425
+ # generate threads as needed and allowed
426
+ # to execute the threads in the thread queue
427
+ start_thread if threads.size < max_threads
428
+
429
+ # NOTE: the producer-consumer relationship of execution
430
+ # threads and the thread_queue means that tasks will sit
431
+ # waiting until an execution thread opens up. in the most
432
+ # extreme case all executing tasks and all tasks in the
433
+ # task_queue could be the same task, each with different
434
+ # inputs. this deviates from the idea of batch processing,
435
+ # but should be rare and not at all fatal given execute
436
+ # synchronization.
437
+ thread_queue.enq [task, inputs]
438
+
439
+ else
440
+ # TODO: log execute task
441
+
442
+ # wait for threads to complete
443
+ # before executing the main thread
444
+ clear_threads
445
+ task.execute(*inputs)
446
+ end
447
+ end
448
+
449
+ # if the run loop exited due to a STOP state,
450
+ # tasks may still be in the thread queue and/or
451
+ # running. be sure these are cleared
452
+ if state == State::STOP
453
+ clear_thread_queue
454
+ clear_threads
455
+ end
456
+
457
+ rescue
458
+ # when an error is generated, be sure to terminate
459
+ # all threads so they can clean up after themselves.
460
+ # clear the thread queue first so no more tasks are
461
+ # executed. collect any errors that arise during
462
+ # termination.
463
+ clear_thread_queue
464
+ errors = terminate_threads
465
+
466
+ # handle the errors accordingly
467
+ if options.debug
468
+ raise Tap::Support::RunError.new($!, errors)
469
+ else
470
+ log($!.class, $!.message)
471
+ errors.each_with_index do |err, index|
472
+ next if err.nil?
473
+ log("[#{index}] #{err.class}", err.message)
474
+ end
475
+ end
476
+ end
477
+
478
+ # reset run variables
479
+ self.thread_queue = nil
480
+ self.main_thread = nil
481
+ self.state = State::READY
482
+
483
+ # TODO: log run complete
484
+
485
+ specified_task
486
+ end
487
+ end
488
+
489
+ # Signals a running application to stop executing tasks in the
490
+ # queue by setting state = State::STOP. Currently executing
491
+ # tasks will continue their execution uninterrupted.
492
+ #
493
+ # Does nothing unless state is State::RUN.
494
+ def stop
495
+ self.state = State::STOP if state == State::RUN
496
+ end
497
+
498
+ # Terminates a running application by raising an error on the
499
+ # main execution thread which enters the application into a
500
+ # terminate (rescue) mode. By default the error will be a
501
+ # TerminateError, but another error can be provided as cause
502
+ # for the termination. The application state is set to
503
+ # State::TERMINATE.
504
+ #
505
+ # Termination in this way allows executing tasks to invoke
506
+ # their specific error handling code, perhaps performing
507
+ # rollbacks. (ie terminate should be used as a graceful
508
+ # exit)
509
+ #
510
+ # Does nothing unless state is State::RUN or State::STOP.
511
+ def terminate(error=TerminateError.new)
512
+ if state == State::RUN || state == State::STOP
513
+ self.state = State::TERMINATE
514
+
515
+ # the terminate error must be raised on the main thread
516
+ # so that the App will enter the termination (rescue) block
517
+ main_thread.raise(error)
518
+ end
519
+ end
520
+
521
+ # Returns an information string for the App.
522
+ #
523
+ # App.new().info # => 'state: 0 (READY) queue: 0 waiting: 0 (0) threads: 0'
524
+ #
525
+ # Provided information:
526
+ #
527
+ # state:: the integer and string values of the application state
528
+ # queue:: the number of tasks currently in the queue
529
+ # waiting:: the number of executable tasks in the queue, in parenthesis is the
530
+ # number of objects in the thread queue (tasks, and perhaps nils to
531
+ # signal threads to clear)
532
+ # threads:: the number of execution threads
533
+ #
534
+ def info
535
+ state_str = State.constants.inject(nil) {|str, s| State.const_get(s) == state ? s : str}
536
+ "state: #{state} (#{state_str}) queue: #{queue.size} waiting: #{queue.num_executable} (#{thread_queue ? thread_queue.size : 0}) threads: #{threads.size}"
537
+ end
538
+
539
+ #
540
+ # workflow related
541
+ #
542
+
543
+ # Sets the condition block for the task. If the task is batched,
544
+ # then the condition will be set for each task in the batch.
545
+ def condition(task, &block) # :yields: task, inputs
546
+ task.batch.each {|t| t.condition(&block)}
547
+ end
548
+
549
+ # Sets the on_complete block for the task. If the task is batched,
550
+ # then on_complete will be set for each task in the batch.
551
+ def on_complete(task, &block) # :yields: results
552
+ task.batch.each {|t| t.on_complete(&block)}
553
+ end
554
+
555
+ # Sets multithread=true for each input task. Batched tasks will
556
+ # have each task in the batch multithreaded.
557
+ def multithread(*tasks)
558
+ tasks.each do |task|
559
+ task.batch.each {|t| t.multithread = true}
560
+ end
561
+ end
562
+
563
+ # Sets a sequence workflow pattern for the tasks such that the
564
+ # completion of a task enqueues the next task with it's results.
565
+ # The condition block between these tasks will be set to the
566
+ # input block, if provided. Batched tasks will have the pattern
567
+ # set for each task in the batch.
568
+ def sequence(*tasks, &block) # :yields: task, inputs
569
+ tasks.collect! {|td| task(td)}
570
+ current_task = tasks.shift
571
+
572
+ tasks.each do |next_task|
573
+ # simply pass results from one task to the next.
574
+ on_complete(current_task) {|results| queue.enq(next_task, *results) }
575
+ condition(next_task, &block)
576
+ current_task = next_task
577
+ end
578
+ end
579
+
580
+ # Sets a fork workflow pattern for the tasks such that each of the
581
+ # targets will be enqueued with the results of the source when the
582
+ # source completes. The condition block for the targets will
583
+ # be set to the input block, if provided. Batched tasks will have
584
+ # the pattern set for each task in the batch.
585
+ def fork(source, *targets, &block) # :yields: task, inputs
586
+ on_complete(source) do |results|
587
+ targets.each do |target|
588
+ # forking requires new audit trails because the same
589
+ # inputs are getting sent to multiple tasks
590
+ forked_results = results.collect {|result| result._fork }
591
+ queue.enq(target, *forked_results)
592
+ end
593
+ end
594
+ targets.each do |target|
595
+ condition(target, &block)
596
+ end if block_given?
597
+ end
598
+
599
+ # Sets a merge workflow pattern for the tasks such that the results
600
+ # of each source will be enqueued to the target when the source
601
+ # completes. The condition block for the target will be set to the
602
+ # input block, if provided. Batched tasks will have the pattern set
603
+ # for each task in the batch.
604
+ def merge(target, *sources, &block) # :yields: task, inputs
605
+ sources.each do |source|
606
+ # merging can use the existing audit trails... each distinct
607
+ # input is getting sent to one place (the target)
608
+ on_complete(source) {|results| queue.enq(target, *results) }
609
+ end
610
+ condition(target, &block) if block_given?
611
+ end
612
+
613
+ protected
614
+
615
+ # A hook for handling unknown configurations in subclasses, called from
616
+ # reconfigure. If handle_configuration evaluates to false, then reconfigure
617
+ # raises an error.
618
+ def handle_configuation(key, value)
619
+ false
620
+ end
621
+
622
+ attr_writer :state
623
+ attr_accessor :thread_queue, :main_thread, :threads
624
+
625
+ private
626
+
627
+ def execution_loop
628
+ while true
629
+ case state
630
+ when State::STOP
631
+ break
632
+ when State::TERMINATE
633
+ # if an execution thread (main or multi) handles the
634
+ # termination error, then the thread may end up here --
635
+ # terminated but still running. Raise another
636
+ # termination error to enter the termination
637
+ # (rescue) code.
638
+ raise TerminateError.new
639
+ end
640
+
641
+ yield
642
+ end
643
+ end
644
+
645
+ def clear_thread_queue
646
+ return unless thread_queue
647
+
648
+ # clear the queue and enque the thread complete
649
+ # signals, so that the thread will exit normally
650
+ dequeued = []
651
+ while !thread_queue.empty?
652
+ dequeued << thread_queue.deq
653
+ end
654
+
655
+ # add dequeued tasks back, in order, to the task
656
+ # queue so no tasks get lost due to the stop
657
+ #
658
+ # BUG: this will result in an already-newly-queued
659
+ # task being promoted along with it's inputs
660
+ dequeued.reverse_each do |task, inputs|
661
+ # TODO: log about not executing
662
+ queue.priority_enq(task, *inputs) unless task.nil?
663
+ end
664
+ end
665
+
666
+ def clear_threads
667
+ return if threads.empty?
668
+
669
+ # clears threads gracefully by enqueuing nils, to break
670
+ # the threads out of their loops, then waiting for the
671
+ # threads to work through the queue to the nils
672
+ threads.size.times { thread_queue.enq nil }
673
+ ThreadsWait.all_waits(*threads) do |thread|
674
+ # TODO - log thread clear
675
+ end
676
+ threads.clear
677
+ end
678
+
679
+ def start_thread
680
+ # start a new thread and add it to threads.
681
+ # threads simply loop and wait for a task to
682
+ # be queued. the thread will block until a
683
+ # task is available (due to thread_queue.deq)
684
+ threads << Thread.new do
685
+ # TODO - log thread start
686
+
687
+ execution_loop do
688
+ task, inputs = thread_queue.deq
689
+ break if task.nil?
690
+
691
+ # TODO: log execute task on thread #
692
+ begin
693
+ task.execute(*inputs)
694
+ rescue
695
+ # unless you're already terminating,
696
+ # an unhandled error should immediately
697
+ # terminate all threads
698
+ if state == State::TERMINATE
699
+ raise $!
700
+ else
701
+ terminate($!)
702
+ end
703
+ end
704
+ end
705
+ end
706
+ end
707
+
708
+ def terminate_threads
709
+ # terminate each thread by raising an exception
710
+ # wait for the thread to exit and gather any
711
+ # errors that arise that are not from the
712
+ # termination exception.
713
+ errors = []
714
+ threads.each_with_index do |thread, index|
715
+ next unless thread.alive?
716
+
717
+ begin
718
+ thread.raise TerminateError.new
719
+ thread.join
720
+ rescue TerminateError
721
+ # do not track these as errors
722
+ # arising from termination
723
+ rescue
724
+ # track the thread of the error
725
+ # with the error itself
726
+ errors[index] = $!
727
+ end
728
+ end
729
+ threads.clear
730
+
731
+ errors
732
+ end
733
+
734
+ # TerminateErrors are raised to kill executing tasks when terminate
735
+ # is called on an running App.
736
+ class TerminateError < RuntimeError
737
+ end
738
+ end
739
+ end