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