require 'tap/support/executable'
require 'tap/support/lazydoc/method'
require 'tap/support/lazydoc/definition'
require 'tap/support/intern'
autoload(:OptionParser, 'optparse')
module Tap
# === Task Definition
#
# Tasks specify executable code by overridding the process method in
# subclasses. The number of inputs to process corresponds to the inputs
# given to execute or enq.
#
# class NoInput < Tap::Task
# def process(); []; end
# end
#
# class OneInput < Tap::Task
# def process(input); [input]; end
# end
#
# class MixedInputs < Tap::Task
# def process(a, b, *args); [a,b,args]; end
# end
#
# NoInput.new.execute # => []
# OneInput.new.execute(:a) # => [:a]
# MixedInputs.new.execute(:a, :b) # => [:a, :b, []]
# MixedInputs.new.execute(:a, :b, 1, 2, 3) # => [:a, :b, [1,2,3]]
#
# Tasks may be create with new, or with intern. Intern overrides
# process with a custom block that gets called with the task instance
# and the inputs.
#
# no_inputs = Task.intern {|task| [] }
# one_input = Task.intern {|task, input| [input] }
# mixed_inputs = Task.intern {|task, a, b, *args| [a, b, args] }
#
# no_inputs.execute # => []
# one_input.execute(:a) # => [:a]
# mixed_inputs.execute(:a, :b) # => [:a, :b, []]
# mixed_inputs.execute(:a, :b, 1, 2, 3) # => [:a, :b, [1,2,3]]
#
# === Configuration
#
# Tasks are configurable. By default each task will be configured as
# specified in the class definition. Configurations may be accessed
# through config, or through accessors.
#
# class ConfiguredTask < Tap::Task
# config :one, 'one'
# config :two, 'two'
# end
#
# t = ConfiguredTask.new
# t.config # => {:one => 'one', :two => 'two'}
# t.one # => 'one'
# t.one = 'ONE'
# t.config # => {:one => 'ONE', :two => 'two'}
#
# Overrides and even unspecified configurations may be provided during
# initialization. Unspecified configurations do not have accessors.
#
# t = ConfiguredTask.new(:one => 'ONE', :three => 'three')
# t.config # => {:one => 'ONE', :two => 'two', :three => 'three'}
# t.respond_to?(:three) # => false
#
# Configurations can be validated/transformed using an optional block.
# Tap::Support::Validation pre-packages many common blocks which may
# be accessed through the class method 'c':
#
# class ValidatingTask < Tap::Task
# # string config validated to be a string
# config :string, 'str', &c.check(String)
#
# # integer config; string inputs are converted using YAML
# config :integer, 1, &c.yaml(Integer)
# end
#
# t = ValidatingTask.new
# t.string = 1 # !> ValidationError
# t.integer = 1.1 # !> ValidationError
#
# t.integer = "1"
# t.integer == 1 # => true
#
#--
# === Subclassing
# Tasks can be subclassed normally, with one reminder related to batching.
#
# Batched tasks are generated by duplicating an existing instance, hence
# all instance variables will point to the same object in the batched
# and original task. At times (as with configurations), this is
# undesirable; the batched task should have it's own copy of an
# instance variable.
#
# In these cases, the initialize_copy should be overridden
# and should re-initialize the appropriate variables. Be sure to call
# super to invoke the default initialize_copy:
#
# class SubclassTask < Tap::Task
# attr_accessor :array
# def initialize(*args)
# @array = []
# super
# end
#
# def initialize_copy(orig)
# @array = orig.array.dup
# super
# end
# end
#
# t1 = SubclassTask.new
# t2 = t1.initialize_batch_obj
# t1.array == t2.array # => true
# t1.array.object_id == t2.array.object_id # => false
#
class Task
include Support::Configurable
include Support::Executable
class << self
# Returns class dependencies
attr_reader :dependencies
# Sets the class default_name
attr_writer :default_name
# Returns the default name for the class: to_s.underscore
def default_name
# lazy-setting default_name like this (rather than
# within inherited, for example) is an optimization
# since many subclass operations end up setting
# default_name themselves.
@default_name ||= to_s.underscore
end
# Returns an instance of self; the instance is a kind of 'global'
# instance used in class-level dependencies. See depends_on.
def instance
@instance ||= new
end
def inherited(child) # :nodoc:
unless child.instance_variable_defined?(:@source_file)
caller.first =~ Support::Lazydoc::CALLER_REGEXP
child.instance_variable_set(:@source_file, File.expand_path($1))
end
child.instance_variable_set(:@dependencies, dependencies.dup)
super
end
# Instantiates a new task with the input arguments and overrides
# process with the block. The block will be called with the
# instance, plus any inputs.
#
# Simply instantiates a new task if no block is given.
def intern(*args, &block) # :yields: task, inputs...
instance = new(*args)
if block_given?
instance.extend Support::Intern
instance.process_block = block
end
instance
end
# Parses the argv into an instance of self and an array of arguments
# (implicitly to be enqued to the instance). Yields a help string to
# the block when the argv indicates 'help'.
def parse(argv=ARGV, app=Tap::App.instance, &block) # :yields: help_str
parse!(argv.dup, &block)
end
# Same as parse, but removes switches destructively.
def parse!(argv=ARGV, app=Tap::App.instance) # :yields: help_str
opts = OptionParser.new
# Add configurations
argv_config = {}
unless configurations.empty?
opts.separator ""
opts.separator "configurations:"
end
configurations.each do |receiver, key, config|
opts.on(*config.to_optparse_argv) do |value|
argv_config[key] = value
end
end
# Add options on_tail, giving priority to configurations
opts.separator ""
opts.separator "options:"
opts.on_tail("-h", "--help", "Print this help") do
opts.banner = "#{help}usage: tap run -- #{to_s.underscore} #{args.subject}"
if block_given?
yield(opts.to_s)
else
puts opts
exit
end
end
# Add option for name
name = default_name
opts.on_tail('--name NAME', /^[^-].*/, 'Specify a name') do |value|
name = value
end
# Add option to add args
use_args = []
opts.on_tail('--use FILE', /^[^-].*/, 'Loads inputs from file') do |value|
obj = YAML.load_file(value)
case obj
when Hash
obj.values.each do |array|
# error if value isn't an array
use_args.concat(array)
end
when Array
use_args.concat(obj)
else
use_args << obj
end
end
# parse the argv
opts.parse!(argv)
# build and reconfigure the instance and any associated
# batch objects as specified in the file configurations
obj = new({}, name, app)
path_configs = load_config(app.config_filepath(name))
if path_configs.kind_of?(Array)
path_configs.each_with_index do |path_config, i|
next if i == 0
batch_obj = obj.initialize_batch_obj(path_config, "#{name}_#{i}")
batch_obj.reconfigure(argv_config)
end
path_configs = path_configs[0]
end
obj.reconfigure(path_configs).reconfigure(argv_config)
[obj, (argv + use_args)]
end
# A convenience method to parse the argv and execute the instance
# with the remaining arguments. If 'help' is specified in the argv,
# execute prints the help and exits.
def execute(argv=ARGV)
instance, args = parse(ARGV) do |help|
puts help
exit
end
instance.execute(*args)
end
# Returns the class lazydoc, resolving if specified.
def lazydoc(resolve=true)
lazydoc = super(false)
lazydoc[self.to_s]['args'] ||= lazydoc.register_method(:process, Support::Lazydoc::Method)
super
end
DEFAULT_HELP_TEMPLATE = %Q{<% manifest = task_class.manifest %>
<%= task_class %><%= manifest.subject.to_s.strip.empty? ? '' : ' -- ' %><%= manifest.subject %>
<% unless manifest.empty? %>
<%= '-' * 80 %>
<% manifest.wrap(77, 2, nil).each do |line| %>
<%= line %>
<% end %>
<%= '-' * 80 %>
<% end %>
}
# Returns the class help.
def help
Tap::Support::Templater.new(DEFAULT_HELP_TEMPLATE, :task_class => self).build
end
protected
# Sets a class-level dependency. When task class B depends_on another task
# class A, instances of B are initialized to depend on A.instance, with the
# specified arguments. Returns self.
def depends_on(name, dependency_class)
unless dependencies.include?(dependency_class)
dependencies << dependency_class
end
# returns the resolved result of the dependency
define_method(name) do
instance = dependency_class.instance
instance.resolve
instance._result._current
end
public(name)
self
end
# Defines a task subclass with the specified configurations and process block.
# During initialization the subclass is instantiated and made accessible
# through a reader by the specified name.
#
# Defined tasks may be configured during initialization, through config, or
# directly through the instance; in effect you get tasks with nested configs
# which greatly facilitates workflows. Indeed, defined tasks are often
# joined in the workflow method.
#
# class AddALetter < Tap::Task
# config :letter, 'a'
# def process(input); input << letter; end
# end
#
# class AlphabetSoup < Tap::Task
# define :a, AddALetter, {:letter => 'a'}
# define :b, AddALetter, {:letter => 'b'}
# define :c, AddALetter, {:letter => 'c'}
#
# def workflow
# a.sequence(b, c)
# end
#
# def process
# a.execute("")
# end
# end
#
# AlphabetSoup.new.process # => 'abc'
#
# i = AlphabetSoup.new(:a => {:letter => 'x'}, :b => {:letter => 'y'}, :c => {:letter => 'z'})
# i.process # => 'xyz'
#
# i.config[:a] = {:letter => 'p'}
# i.config[:b][:letter] = 'q'
# i.c.letter = 'r'
# i.process # => 'pqr'
#
# ==== Usage
#
# Define is basically the equivalent of:
#
# class Sample < Tap::Task
# Name = baseclass.subclass(config, &block)
#
# # accesses an instance of Name
# attr_reader :name
#
# # register name as a config, but with a
# # non-standard reader and writer
# config :name, {}, {:reader => :name_config, :writer => :name_config=}.merge(options)
#
# # reader for name.config
# def name_config; ...; end
#
# # reconfigures name with input
# def name_config=(input); ...; end
#
# def initialize(*args)
# super
# @name = Name.new(config[:name])
# end
# end
#
# Note the following:
# * define will set a constant like name.camelize
# * the block defines the process method in the subclass
# * three methods are created by define: name, name_config, name_config=
#
def define(name, baseclass=Tap::Task, configs={}, options={}, &block)
# define the subclass
const_name = options.delete(:const_name) || name.to_s.camelize
subclass = const_set(const_name, Class.new(baseclass))
subclass.default_name = name.to_s
configs.each_pair do |key, value|
subclass.send(:config, key, value)
end
if block_given?
subclass.send(:define_method, :process, &block)
end
# define methods
instance_var = "@#{name}".to_sym
reader = (options[:reader] ||= "#{name}_config".to_sym)
writer = (options[:writer] ||= "#{name}_config=".to_sym)
attr_reader name
define_method(reader) do
# return the config for the instance
instance_variable_get(instance_var).config
end
define_method(writer) do |value|
# initialize or reconfigure the instance of subclass
if instance_variable_defined?(instance_var)
instance_variable_get(instance_var).reconfigure(value)
else
instance_variable_set(instance_var, subclass.new(value))
end
end
public(name, reader, writer)
# add the configuration
if options[:desc] == nil
caller[0] =~ Support::Lazydoc::CALLER_REGEXP
desc = Support::Lazydoc.register($1, $3.to_i - 1, Support::Lazydoc::Definition)
desc.subclass = subclass
options[:desc] = desc
end
configurations.add(name, subclass.configurations.instance_config, options)
end
end
instance_variable_set(:@source_file, __FILE__)
instance_variable_set(:@default_name, 'tap/task')
instance_variable_set(:@dependencies, [])
lazy_attr :manifest
lazy_attr :args
# The name of self.
#--
# Currently names may be any object. Audit makes use of name
# via to_s, as does app when figuring configuration filepaths.
attr_accessor :name
# Initializes a new Task.
def initialize(config={}, name=nil, app=App.instance)
super()
@name = name || self.class.default_name
@app = app
@_method_name = :execute_with_callbacks
@on_complete_block = nil
@dependencies = []
@batch = [self]
case config
when Support::InstanceConfiguration
# update is prudent to ensure all configs have an input
# (and hence, all configs will be initialized)
@config = config.update(self.class.configurations)
config.bind(self)
else
initialize_config(config)
end
self.class.dependencies.each do |dependency_class|
depends_on(dependency_class.instance)
end
workflow
end
# Creates a new batched object and adds the object to batch. The batched
# object will be a duplicate of the current object but with a new name
# and/or configurations.
def initialize_batch_obj(overrides={}, name=nil)
obj = super().reconfigure(overrides)
obj.name = name if name
obj
end
# The method for processing inputs into outputs. Override this method in
# subclasses to provide class-specific process logic. The number of
# arguments specified by process corresponds to the number of arguments
# the task should have when enqued or executed.
#
# class TaskWithTwoInputs < Tap::Task
# def process(a, b)
# [b,a]
# end
# end
#
# t = TaskWithTwoInputs.new
# t.enq(1,2).enq(3,4)
# t.app.run
# t.app.results(t) # => [[2,1], [4,3]]
#
# By default, process simply returns the inputs.
def process(*inputs)
inputs
end
# Logs the inputs to the application logger (via app.log)
def log(action, msg="", level=Logger::INFO)
# TODO - add a task identifier?
app.log(action, msg, level)
end
# Returns self.name
def to_s
name.to_s
end
# Provides an abbreviated version of the default inspect, with only
# the task class, object_id, name, and configurations listed.
def inspect
"#<#{self.class.to_s}:#{object_id} #{name} #{config.to_hash.inspect} >"
end
protected
# Hook to define a workflow for defined tasks.
def workflow
end
# Hook to execute code before inputs are processed.
def before_execute() end
# Hook to execute code after inputs are processed.
def after_execute() end
# Hook to handle unhandled errors from processing inputs on a task level.
# By default on_execute_error simply re-raises the unhandled error.
def on_execute_error(err)
raise err
end
private
# execute_with_callbacks is the method called by _execute
def execute_with_callbacks(*inputs) # :nodoc:
before_execute
begin
result = process(*inputs)
rescue
on_execute_error($!)
end
after_execute
result
end
end
end