lib/tap/file_task.rb in bahuvrihi-tap-0.11.2 vs lib/tap/file_task.rb in bahuvrihi-tap-0.12.0
- old
+ new
@@ -1,122 +1,59 @@
require 'tap/support/shell_utils'
autoload(:FileUtils, "fileutils")
module Tap
- # FileTask provides methods for creating/modifying files such that you can
- # rollback changes if an error occurs.
+ # FileTask is a base class for tasks that work with a file system. FileTask
+ # tracks changes it makes so they may be rolled back to their original state.
+ # Rollback automatically occurs on an execute error.
#
- # === Creating Files/Rolling Back Changes
- #
- # FileTask tracks which files to roll back using the added_files array
- # and the backed_up_files hash. On an execute error, all added files are
- # removed and then all backed up files (backed_up_files.keys) are restored
- # using the corresponding backup files (backed_up_files.values).
- #
- # For consistency, all filepaths in added_files and backed_up_files should
- # be expanded using File.expand_path. The easiest way to ensure files are
- # properly set up for rollback is to use prepare before working with files
- # and to create directories with mkdir.
- #
- # # this file will be backed up and restored
- # File.open("file.txt", "w") {|f| f << "original content"}
+ # File.open("file.txt", "w") {|file| file << "original content"}
#
- # t = FileTask.intern do |task|
- # task.mkdir("some/dir") # marked for rollback
- # task.prepare("file.txt", "path/to/file.txt") # marked for rollback
+ # t = FileTask.intern do |task, raise_error|
+ # task.mkdir_p("some/dir") # marked for rollback
+ # task.prepare("file.txt") do |file| # marked for rollback
+ # file << "new content"
+ # end
#
- # File.open("file.txt", "w") {|f| f << "new content"}
- # File.touch("path/to/file.txt")
- #
# # raise an error to start rollback
- # raise "error!"
+ # raise "error!" if raise_error
# end
#
# begin
- # File.exists?("some/dir") # => false
- # File.exists?("path/to/file.txt") # => false
- # t.execute(nil)
+ # t.execute(true)
# rescue
# $!.message # => "error!"
# File.exists?("some/dir") # => false
- # File.exists?("path/to/file.txt") # => false
# File.read("file.txt") # => "original content"
# end
#
+ # t.execute(false)
+ # File.exists?("some/dir") # => true
+ # File.read("file.txt") # => "new content"
+ #
class FileTask < Task
include Tap::Support::ShellUtils
- # A hash of backup [source, target] pairs, such that the
- # backed-up files are backed_up_files.keys and the actual
- # backup files are backed_up_files.values. All filepaths
- # in backed_up_files should be expanded.
- attr_reader :backed_up_files
-
- # An array of files added during task execution.
- attr_reader :added_files
-
- # The backup directory, defaults to the class backup_dir
+ # The backup directory
config_attr :backup_dir, 'backup' # the backup directory
- # A timestamp format used to mark backup files, defaults
- # to the class backup_timestamp
- config :timestamp, "%Y%m%d_%H%M%S" # the backup timestamp format
-
- # A flag indicating whether or not to rollback changes on
- # error, defaults to the class rollback_on_error
+ # A flag indicating whether or track changes
+ # for rollback on execution error
config :rollback_on_error, true, &c.switch # rollback changes on error
def initialize(config={}, name=nil, app=App.instance)
super
-
- @backed_up_files = {}
- @added_files = []
+ @actions = []
end
+ # Initializes a copy that will rollback independent of self.
def initialize_copy(orig)
super
- @backed_up_files = {}
- @added_files = []
+ @actions = []
end
- # A batch File.open method. If a block is given, each file in the list will be
- # opened the open files passed to the block. Files are automatically closed when
- # the block returns. If no block is given, the open files are returned.
- #
- # t = FileTask.new
- # t.open(["one.txt", "two.txt"], "w") do |one, two|
- # one << "one"
- # two << "two"
- # end
- #
- # File.read("one.txt") # => "one"
- # File.read("two.txt") # => "two"
- #
- # Note that open normally takes and passes a list (ie an Array). If you provide
- # a single argument, it will be translated into an Array, and passed AS AN ARRAY
- # to the block.
- #
- # t.open("file.txt", "w") do |array|
- # array.first << "content"
- # end
- #
- # File.read("file.txt") # => "content"
- #
- def open(list, mode="rb")
- open_files = []
- begin
- [list].flatten.map {|path| path.to_str }.each do |filepath|
- open_files << File.open(filepath, mode)
- end
-
- block_given? ? yield(open_files) : open_files
- ensure
- open_files.each {|file| file.close } if block_given?
- end
- end
-
# Returns the path, exchanging the extension with extname.
# A false or nil extname removes the extension, while true
# preserves the existing extension (and effectively does
# nothing).
#
@@ -128,17 +65,13 @@
# t.basepath('path/to/file.txt', true) # => 'path/to/file.txt'
#
# Compare to basename.
def basepath(path, extname=false)
case extname
- when false, nil
- path.chomp(File.extname(path))
- when true
- path
- else
- extname = extname[1, extname.length-1] if extname[0] == ?.
- "#{path.chomp(File.extname(path))}.#{extname}"
+ when false, nil then path.chomp(File.extname(path))
+ when true then path
+ else Root.exchange(path, extname)
end
end
# Returns the basename of path, exchanging the extension
# with extname. A false or nil extname removes the
@@ -165,408 +98,289 @@
#
def filepath(dir, *paths)
app.filepath(dir, name, *paths)
end
- # Makes a backup filepath relative to backup_dir by using self.name, the
- # basename of filepath plus a timestamp.
+ # Makes a backup filepath relative to backup_dir by using name, the
+ # basename of filepath, and an index.
#
- # t = FileTask.new({:timestamp => "%Y%m%d"}, 'name')
- # t.app['backup', true] = "/backup"
- # time = Time.utc(2008,8,8)
- #
- # t.backup_filepath("path/to/file.txt", time) # => "/backup/name/file_20080808.txt"
+ # t = FileTask.new({:backup_dir => "/backup"}, "name")
+ # t.backup_filepath("path/to/file.txt", time) # => "/backup/name/file.0.txt"
#
- def backup_filepath(filepath, time=Time.now)
- extname = File.extname(filepath)
- backup_path = "#{File.basename(filepath).chomp(extname)}_#{time.strftime(timestamp)}#{extname}"
- filepath(backup_dir, backup_path)
+ def backup_filepath(path)
+ extname = File.extname(path)
+ backup_path = filepath(backup_dir, File.basename(path).chomp(extname))
+ next_indexed_path(backup_path, 0, extname)
end
- # Returns true if all of the targets are up to date relative to all of the listed
- # sources AND date_reference. Single values or arrays can be provided for both
- # targets and sources.
+ # Returns true if all of the targets are up to date relative to all of the
+ # listed sources. Single values or arrays can be provided for both targets
+ # and sources.
#
- #---
- # Returns false (ie 'not up to date') if +force?+ is true.
+ # Returns false (ie 'not up to date') if app.force is true.
+ #
+ #--
+ # TODO: add check vs date reference (ex config_file date)
def uptodate?(targets, sources=[])
if app.force
log_basename(:force, *targets)
false
else
targets = [targets] unless targets.kind_of?(Array)
sources = [sources] unless sources.kind_of?(Array)
- # should be able to specify this somehow, externally set
- # sources << config_file unless config_file == nil
-
targets.each do |target|
return false unless FileUtils.uptodate?(target, sources)
end
true
end
end
- # Makes a backup of each file in list to backup_filepath(file) and registers
- # the files so that they can be restored using restore. If backup_using_copy
- # is true, the files will be copied to backup_filepath, otherwise the file is
- # moved to backup_filepath. Raises an error if the file is already listed
- # in backed_up_files.
+ # Makes a backup of path to backup_filepath(path) and returns the backup path.
+ # If backup_using_copy is true, the backup is a copy of path, otherwise the
+ # file or directory at path is moved to the backup path. Raises an error if
+ # the backup file already exists.
#
- # Returns a list of the backup_filepaths.
+ # Backups are restored on rollback.
#
# file = "file.txt"
# File.open(file, "w") {|f| f << "file content"}
#
# t = FileTask.new
- # backed_up_file = t.backup(file).first
+ # backup_file = t.backup(file)
#
# File.exists?(file) # => false
- # File.exists?(backed_up_file) # => true
- # File.read(backed_up_file) # => "file content"
+ # File.exists?(backup_file) # => true
+ # File.read(backup_file) # => "file content"
#
# File.open(file, "w") {|f| f << "new content"}
- # t.restore(file)
+ # t.rollback
#
# File.exists?(file) # => true
- # File.exists?(backed_up_file) # => false
+ # File.exists?(backup_file ) # => false
# File.read(file) # => "file content"
#
- def backup(list, backup_using_copy=false)
- fu_list(list).collect do |filepath|
- next unless File.exists?(filepath)
+ def backup(path, backup_using_copy=false)
+ return nil unless File.exists?(path)
- filepath = File.expand_path(filepath)
- if backed_up_files.include?(filepath)
- raise "Backup for #{filepath} already exists."
- end
-
- target = File.expand_path(backup_filepath(filepath))
- dir = File.dirname(target)
- mkdir(dir)
-
- if backup_using_copy
- log :cp, "#{filepath} to #{target}", Logger::DEBUG
- FileUtils.cp(filepath, target)
- else
- log :mv, "#{filepath} to #{target}", Logger::DEBUG
- FileUtils.mv(filepath, target)
- end
-
- # track the target for restores
- backed_up_files[filepath] = target
- target
+ source = File.expand_path(path)
+ target = backup_filepath(source)
+ raise "backup file already exists: #{target}" if File.exists?(target)
+
+ mkdir_p File.dirname(target)
+
+ log :backup, "#{source} to #{target}", Logger::DEBUG
+ if backup_using_copy
+ FileUtils.cp(source, target)
+ else
+ FileUtils.mv(source, target)
end
+
+ actions << [:backup, source, target]
+ target
end
- # Restores each file in the input list using the backup file from
- # backed_up_files. The backup directory is removed if it is empty.
- #
- # Returns a list of the restored files.
- #
- # file = "file.txt"
- # File.open(file, "w") {|f| f << "file content"}
- #
- # t = FileTask.new
- # backed_up_file = t.backup(file).first
- #
- # File.exists?(file) # => true
- # File.exists?(backed_up_file) # => true
- # File.read(backed_up_file) # => "file content"
- #
- # File.open(file, "w") {|f| f << "new content"}
- # t.restore(file)
- #
- # File.exists?(file) # => true
- # File.exists?(backed_up_file) # => false
- # File.read(file) # => "file content"
- #
- def restore(list)
- fu_list(list).collect do |filepath|
- filepath = File.expand_path(filepath)
- next unless backed_up_files.has_key?(filepath)
-
- target = backed_up_files.delete(filepath)
-
- dir = File.dirname(filepath)
- mkdir(dir)
+ # Creates a directory and all its parent directories. Directories created
+ # by mkdir_p removed on rollback.
+ def mkdir_p(dir)
+ dir = File.expand_path(dir)
- log :restore, "#{target} to #{filepath}", Logger::DEBUG
- FileUtils.mv(target, filepath, :force => true)
-
- dir = File.dirname(target)
- rmdir(dir)
+ dirs = []
+ while !File.exists?(dir)
+ dirs.unshift(dir)
+ dir = File.dirname(dir)
+ end
- filepath
- end.compact
+ dirs.each {|dir| mkdir(dir) }
end
- # Creates the directories in list if they do not exist and adds
- # them to added_files so they can be removed using rmdir. Creating
- # directories in this way causes them to be rolled back upon an
- # execution error.
- #
- # Returns the made directories.
- #
- # t = FileTask.new do |task, inputs|
- # File.exists?("path") # => false
- #
- # task.mkdir("path/to/dir") # will be rolled back
- # File.exists?("path/to/dir") # => true
- #
- # FileUtils.mkdir("path/to/another") # will not be rolled back
- # File.exists?("path/to/another") # => true
- #
- # raise "error!"
- # end
- #
- # begin
- # t.execute(nil)
- # rescue
- # $!.message # => "error!"
- # File.exists?("path/to/dir") # => false
- # File.exists?("path/to/another") # => true
- # end
- #
- def mkdir(list)
- fu_list(list).each do |dir|
- dir = File.expand_path(dir)
-
- make_paths = []
- while !File.exists?(dir)
- make_paths << dir
- dir = File.dirname(dir)
- end
+ # Creates a directory. Directories created by mkdir removed on rollback.
+ def mkdir(dir)
+ dir = File.expand_path(dir)
- make_paths.reverse_each do |path|
- log :mkdir, path, Logger::DEBUG
- FileUtils.mkdir(path)
- added_files << path
- end
+ unless File.exists?(dir)
+ log :mkdir, dir, Logger::DEBUG
+ FileUtils.mkdir(dir)
+ actions << [:make, dir]
end
end
- # Removes each directory in the input list, provided the directory is in
- # added_files and the directory is empty. When checking if the directory
- # is empty, rmdir checks for regular files and hidden files. Removed
- # directories are removed from added_files.
+ # Prepares the path by backing up any existing file and ensuring that
+ # the parent directory for path exists. If a block is given, a file
+ # is opened and yielded to it (as in File.open). Prepared paths are
+ # removed and the backups restored on rollback.
#
- # Returns a list of the removed directories.
- #
- # t = FileTask.new
- # File.exists?("path") # => false
- # FileUtils.mkdir("path") # will not be removed
- #
- # t.mkdir("path/to/dir")
- # File.exists?("path/to/dir") # => true
- #
- # t.rmdir("path/to/dir")
- # File.exists?("path") # => true
- # File.exists?("path/to") # => false
- def rmdir(list)
- removed = []
- fu_list(list).each do |dir|
- dir = File.expand_path(dir)
+ # Returns the expanded path.
+ def prepare(path, backup_using_copy=false)
+ raise "not a file: #{path}" if File.directory?(path)
+ path = File.expand_path(path)
+
+ if File.exists?(path)
+ # backup or remove existing files
+ backup(path, backup_using_copy)
+ else
+ # ensure the parent directory exists
+ # for non-existant files
+ mkdir_p File.dirname(path)
+ end
+ log :prepare, path, Logger::DEBUG
+ actions << [:make, path]
- # remove directories and parents until the
- # directory was not made by the task
- while added_files.include?(dir)
- break unless dir_empty?(dir)
-
- if File.exists?(dir)
- log :rmdir, dir, Logger::DEBUG
- FileUtils.rmdir(dir)
- end
-
- removed << added_files.delete(dir)
- dir = File.dirname(dir)
- end
+ if block_given?
+ File.open(path, "w") {|file| yield(file) }
end
- removed
+
+ path
end
- def dir_empty?(dir)
- Dir.entries(dir).delete_if {|d| d == "." || d == ".."}.empty?
+ # Removes a file. If a directory is provided, it's contents are removed
+ # recursively. Files and directories removed by rm_r are restored
+ # upon an execution error.
+ def rm_r(path)
+ path = File.expand_path(path)
+
+ backup(path, false)
+ log :rm_r, path, Logger::DEBUG
end
- # Prepares the input list of files by backing them up (if they exist),
- # ensuring that the parent directory for the file exists, and adding
- # each file to added_files. As a result the files can be removed
- # using rm, restored using restore, and will be rolled back upon an
- # execution error.
- #
- # Returns the prepared files.
- #
- # File.open("file.txt", "w") {|f| f << "original content"}
- #
- # t = FileTask.new do |task, inputs|
- # File.exists?("path") # => false
- #
- # # backup... make parent dirs... prepare for restore
- # task.prepare(["file.txt", "path/to/file.txt"])
- #
- # File.open("file.txt", "w") {|f| f << "new content"}
- # File.touch("path/to/file.txt")
- #
- # raise "error!"
- # end
- #
- # begin
- # t.execute(nil)
- # rescue
- # $!.message # => "error!"
- # File.exists?("file.txt") # => true
- # File.read("file.txt") # => "original content"
- # File.exists?("path") # => false
- # end
- #
- def prepare(list, backup_using_copy=false)
- list = fu_list(list)
- existing_files, non_existant_files = list.partition do |filepath|
- File.exists?(filepath)
- end
+ # Removes an empty directory. Directories removed by rmdir are restored
+ # upon an execution error.
+ def rmdir(dir)
+ dir = File.expand_path(dir)
- # backup existing files
- existing_files.each do |filepath|
- backup(filepath, backup_using_copy)
+ unless Root.empty?(dir)
+ raise "not an empty directory: #{dir}"
end
- # ensure the parent directory exists
- # for non-existant files
- non_existant_files.each do |filepath|
- dir = File.dirname(filepath)
- mkdir(dir)
- end
+ backup(dir, false)
+ log :rmdir, dir, Logger::DEBUG
+ end
+
+ # Removes a file. Directories cannot be removed by this method.
+ # Files removed by rm are restored upon an execution error.
+ def rm(path)
+ path = File.expand_path(path)
- list.each do |filepath|
- added_files << File.expand_path(filepath)
+ unless File.file?(path)
+ raise "not a file: #{path}"
end
-
- list
+
+ backup(path, false)
+ log :rm, path, Logger::DEBUG
end
- # Removes each file in the input list, provided the file is in added_files.
- # The parent directory of each file is removed using rmdir. Removed files
- # are removed from added_files.
- #
- # Returns the removed files and directories.
- #
- # t = FileTask.new
- # File.exists?("path") # => false
- # FileUtils.mkdir("path") # will not be removed
- #
- # t.prepare("path/to/file.txt")
- # FileUtils.touch("path/to/file.txt")
- # File.exists?("path/to/file.txt") # => true
- #
- # t.rm("path/to/file.txt")
- # File.exists?("path") # => true
- # File.exists?("path/to") # => false
- def rm(list)
- removed = []
- fu_list(list).each do |filepath|
- filepath = File.expand_path(filepath)
- next unless added_files.include?(filepath)
-
- # if the file exists, remove it
- if File.exists?(filepath)
- log :rm, filepath, Logger::DEBUG
- FileUtils.rm(filepath, :force => true)
- end
+ # Copies source to target. Files and directories copied by cp are
+ # restored upon an execution error.
+ def cp(source, target)
+ target = File.join(target, File.basename(source)) if File.directory?(target)
+ prepare(target)
+
+ log :cp, "#{source} to #{target}", Logger::DEBUG
+ FileUtils.cp(source, target)
+ end
+
+ # Copies source to target. If source is a directory, the contents
+ # are copied recursively. If target is a directory, copies source
+ # to target/source. Files and directories copied by cp are restored
+ # upon an execution error.
+ def cp_r(source, target)
+ target = File.join(target, File.basename(source)) if File.directory?(target)
+ prepare(target)
+
+ log :cp_r, "#{source} to #{target}", Logger::DEBUG
+ FileUtils.cp_r(source, target)
+ end
+
+ # Moves source to target. Files and directories moved by mv are
+ # restored upon an execution error.
+ def mv(source, target, backup_source=true)
+ backup(source, true) if backup_source
+ prepare(target)
+
+ log :mv, "#{source} to #{target}", Logger::DEBUG
+ FileUtils.mv(source, target)
+ end
+
+ # Rolls back any actions capable of being rolled back. Rollback
+ # is forceful; for instance if you make a folder using mkdir
+ # rollback removes that directory using FileUtils.rm_r. Any
+ # files added to the folder will be removed even if they were
+ # not added by self.
+ def rollback
+ while !actions.empty?
+ action, source, target = actions.pop
- removed << added_files.delete(filepath)
- removed.concat rmdir(File.dirname(filepath))
+ case action
+ when :make
+ log :rollback, "#{source}", Logger::DEBUG
+ FileUtils.rm_r(source)
+ when :backup
+ log :rollback, "#{target} to #{source}", Logger::DEBUG
+ dir = File.dirname(source)
+ FileUtils.mkdir_p(dir) unless File.exists?(dir)
+ FileUtils.mv(target, source, :force => true)
+ else
+ raise "unknown action: #{[action, source, target].inspect}"
+ end
end
- removed
end
- # Rolls back changes by removing added_files and restoring backed_up_files.
- # Rollback is performed on an execute error if rollback_on_error == true,
- # but is provided as a separate method for flexibility when needed.
- # Yields errors to the block, which must be provided.
- def rollback # :yields: error
- added_files.dup.each do |filepath|
- begin
- case
- when File.file?(filepath)
- rm(filepath)
- when File.directory?(filepath)
- rmdir(filepath)
- else
- # assures non-existant files are cleared from added_files
- # this is automatically done by rm and rmdir for existing files
- added_files.delete(filepath)
- end
- rescue
- yield $!
+ # Removes backup files. Cleanup cannot be rolled back and prevents
+ # rollback of actions up to when cleanup is called. If cleanup_dirs
+ # is true, empty directories containing the backup files will be
+ # removed.
+ def cleanup(cleanup_dirs=true)
+ actions.each do |action, source, target|
+ if action == :backup
+ log :cleanup, target, Logger::DEBUG
+ FileUtils.rm_r(target) if File.exists?(target)
+ cleanup_dir(File.dirname(target)) if cleanup_dirs
end
end
-
- backed_up_files.keys.each do |filepath|
- begin
- restore(filepath)
- rescue
- yield $!
- end
- end
+ actions.clear
end
- # Removes backed-up files matching the pattern.
- def cleanup(pattern=/.*/)
- backed_up_files.each do |filepath, target|
- next unless target =~ pattern
-
- # the filepath needs to be added to added_files
- # before it can be removed by rm
- added_files << target
- rm(target)
- backed_up_files.delete(filepath)
- end
+ # Removes the directory if empty, and all empty parent directories. This
+ # method cannot be rolled back.
+ def cleanup_dir(dir)
+ while Root.empty?(dir)
+ log :rmdir, dir, Logger::DEBUG
+ FileUtils.rmdir(dir)
+ dir = File.dirname(dir)
+ end
end
- # Logs the given action, with the basenames of the input filepaths.
- def log_basename(action, filepaths, level=Logger::INFO)
- msg = case filepaths
- when Array then filepaths.collect {|filepath| File.basename(filepath) }.join(',')
- else
- File.basename(filepaths)
- end
-
+ # Logs the given action, with the basenames of the input paths.
+ def log_basename(action, paths, level=Logger::INFO)
+ msg = [paths].flatten.collect {|path| File.basename(path) }.join(',')
log(action, msg, level)
end
protected
-
- attr_writer :backed_up_files, :added_files
- # Clears added_files and backed_up_files so that
- # a failure will not affect previous executions
+ # An array tracking actions (backup, rm, mv, etc) performed by self,
+ # allowing rollback on an execution error. Not intended to be
+ # modified manually.
+ attr_reader :actions
+
+ # Clears actions so that a failure will not affect previous executions
def before_execute
- added_files.clear
- backed_up_files.clear
+ actions.clear
end
# Removes made files/dirs and restores backed-up files upon
- # an execute error. Collects any errors raised along the way
- # and raises them in a Tap::Support::RunError.
+ # an execute error.
def on_execute_error(original_error)
- rollback_errors = []
- if rollback_on_error
- rollback {|error| rollback_errors << error}
- end
-
- # Re-raise the error if no rollback errors occured,
- # otherwise, raise a RunError tracking the errors.
- if rollback_errors.empty?
- raise original_error
- else
- rollback_errors.unshift(original_error)
- raise Support::RunError.new(rollback_errors)
- end
+ rollback if rollback_on_error
+ raise original_error
end
- # Lifted from FileUtils
- def fu_list(arg)
- [arg].flatten.map {|path| path.to_str }
+ private
+
+ # utility method for backup_filepath; increments index until the
+ # path base.indexext does not exist.
+ def next_indexed_path(base, index, ext) # :nodoc:
+ path = sprintf('%s.%d%s', base, index, ext)
+ File.exists?(path) ? next_indexed_path(base, index + 1, ext) : path
end
end
end