Class: Tap::FileTask

Inherits:
Task show all
Includes:
Support::ShellUtils
Defined in:
lib/tap/file_task.rb

Overview

FileTask provides methods for creating/modifying files such that you can rollback changes if an error occurs.

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"}

t = FileTask.intern do |task|
  task.mkdir("some/dir")                         # marked for rollback
  task.prepare("file.txt", "path/to/file.txt")   # marked for rollback

  File.open("file.txt", "w") {|f| f << "new content"}
  File.touch("path/to/file.txt")

  # raise an error to start rollback
  raise "error!"
end

begin
  File.exists?("some/dir")              # => false
  File.exists?("path/to/file.txt")      # => false
  t.execute(nil)
rescue
  $!.message                            # => "error!"
  File.exists?("some/dir")              # => false
  File.exists?("path/to/file.txt")      # => false
  File.read("file.txt")                 # => "original content"
end

Direct Known Subclasses

Tasks::Dump

Constant Summary

Constants inherited from Task

Task::DEFAULT_HELP_TEMPLATE

Instance Attribute Summary collapse

Attributes inherited from Task

#name

Attributes included from Support::Executable

#_method_name, #app, #batch, #dependencies, #on_complete_block

Attributes included from Support::Configurable

#config

Instance Method Summary collapse

Methods included from Support::ShellUtils

capture_sh, redirect_sh, sh

Methods inherited from Task

execute, help, inherited, #initialize_batch_obj, #inspect, instance, intern, lazydoc, #log, parse, parse!, #process, #to_s

Methods included from Support::Executable

#_execute, #batch_index, #batch_with, #batched?, #check_terminate, #depends_on, #enq, #execute, #fork, initialize, #initialize_batch_obj, #inspect, #merge, #on_complete, #reset_dependencies, #resolve_dependencies, #sequence, #switch, #sync_merge, #unbatched_depends_on, #unbatched_enq, #unbatched_on_complete

Methods included from Support::Configurable

included, #reconfigure

Constructor Details

#initialize(config = {}, name = nil, app = App.instance) ⇒ FileTask

Returns a new instance of FileTask.



69
70
71
72
73
74
# File 'lib/tap/file_task.rb', line 69

def initialize(config={}, name=nil, app=App.instance)
  super
  
  @backed_up_files = {}
  @added_files = []
end

Instance Attribute Details

#added_filesObject

An array of files added during task execution.



56
57
58
# File 'lib/tap/file_task.rb', line 56

def added_files
  @added_files
end

#backed_up_filesObject

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.



53
54
55
# File 'lib/tap/file_task.rb', line 53

def backed_up_files
  @backed_up_files
end

Instance Method Details

#backup(list, backup_using_copy = false) ⇒ Object

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.

Returns a list of the backup_filepaths.

file = "file.txt"
File.open(file, "w") {|f| f << "file content"}

t = FileTask.new
backed_up_file = t.backup(file).first   

File.exists?(file)                       # => false
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"


234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/tap/file_task.rb', line 234

def backup(list, backup_using_copy=false)
  fu_list(list).collect do |filepath|
    next unless File.exists?(filepath)
    
    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
  end
end

#backup_filepath(filepath, time = Time.now) ⇒ Object

Makes a backup filepath relative to backup_dir by using self.name, the basename of filepath plus a timestamp.

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"


179
180
181
182
183
# File 'lib/tap/file_task.rb', line 179

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)
end

#basename(path, extname = true) ⇒ Object

Returns the basename of path, exchanging the extension with extname. A false or nil extname removes the extension, while true preserves the existing extension.

t = FileTask.new
t.basename('path/to/file.txt')           # => 'file.txt'
t.basename('path/to/file.txt', '.html')  # => 'file.html'

t.basename('path/to/file.txt', false)    # => 'file'
t.basename('path/to/file.txt', true)     # => 'file.txt'

Compare to basepath.



155
156
157
# File 'lib/tap/file_task.rb', line 155

def basename(path, extname=true)
  basepath(File.basename(path), extname)
end

#basepath(path, extname = false) ⇒ Object

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).

t = FileTask.new
t.basepath('path/to/file.txt')           # => 'path/to/file'
t.basepath('path/to/file.txt', '.html')  # => 'path/to/file.html'

t.basepath('path/to/file.txt', false)    # => 'path/to/file'
t.basepath('path/to/file.txt', true)     # => 'path/to/file.txt'

Compare to basename.



131
132
133
134
135
136
137
138
139
140
141
# File 'lib/tap/file_task.rb', line 131

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}"
  end
end

#cleanup(pattern = /.*/) ⇒ Object

Removes backed-up files matching the pattern.



514
515
516
517
518
519
520
521
522
523
524
# File 'lib/tap/file_task.rb', line 514

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 
end

#dir_empty?(dir) ⇒ Boolean

Returns:

  • (Boolean)


387
388
389
# File 'lib/tap/file_task.rb', line 387

def dir_empty?(dir)
  Dir.entries(dir).delete_if {|d| d == "." || d == ".."}.empty?
end

#filepath(dir, *paths) ⇒ Object

Constructs a filepath using the dir, name, and the specified paths.

t = FileTask.new 
t.app[:data, true] = "/data" 
t.name                              # => "tap/file_task"
t.filepath(:data, "result.txt")     # => "/data/tap/file_task/result.txt"


166
167
168
# File 'lib/tap/file_task.rb', line 166

def filepath(dir, *paths) 
  app.filepath(dir, name, *paths)
end

#initialize_copy(orig) ⇒ Object



76
77
78
79
80
# File 'lib/tap/file_task.rb', line 76

def initialize_copy(orig)
  super
  @backed_up_files = {}
  @added_files = []
end

#log_basename(action, filepaths, level = Logger::INFO) ⇒ Object

Logs the given action, with the basenames of the input filepaths.



527
528
529
530
531
532
533
534
535
# File 'lib/tap/file_task.rb', line 527

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
  
  log(action, msg, level)
end

#mkdir(list) ⇒ Object

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


330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
# File 'lib/tap/file_task.rb', line 330

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
  
    make_paths.reverse_each do |path|
      log :mkdir, path, Logger::DEBUG 
      FileUtils.mkdir(path) 
      added_files << path 
    end
  end
end

#open(list, mode = "rb") ⇒ Object

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"


105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/tap/file_task.rb', line 105

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

#prepare(list, backup_using_copy = false) ⇒ Object

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


422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
# File 'lib/tap/file_task.rb', line 422

def prepare(list, backup_using_copy=false) 
  list = fu_list(list)
  existing_files, non_existant_files = list.partition do |filepath| 
    File.exists?(filepath)
  end
  
  # backup existing files

  existing_files.each do |filepath|
    backup(filepath, backup_using_copy)
  end
  
  # ensure the parent directory exists  

  # for non-existant files 

  non_existant_files.each do |filepath| 
    dir = File.dirname(filepath)
    mkdir(dir)
  end
  
  list.each do |filepath|
    added_files << File.expand_path(filepath)
  end
   
  list
end

#restore(list) ⇒ Object

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"


283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
# File 'lib/tap/file_task.rb', line 283

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)
    
    log :restore, "#{target} to #{filepath}", Logger::DEBUG
    FileUtils.mv(target, filepath, :force => true)

    dir = File.dirname(target)
    rmdir(dir)
    
    filepath
  end.compact
end

#rm(list) ⇒ Object

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


464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
# File 'lib/tap/file_task.rb', line 464

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

    removed << added_files.delete(filepath)
    removed.concat rmdir(File.dirname(filepath))
  end
  removed
end

#rmdir(list) ⇒ Object

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.

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


365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
# File 'lib/tap/file_task.rb', line 365

def rmdir(list) 
  removed = []
  fu_list(list).each do |dir|  
    dir = File.expand_path(dir)
  
    # 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
  end
  removed
end

#rollbackObject

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.



486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
# File 'lib/tap/file_task.rb', line 486

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 $!
    end
  end
   
  backed_up_files.keys.each do |filepath|
    begin
      restore(filepath)
    rescue
      yield $!
    end
  end
end

#uptodate?(targets, sources = []) ⇒ Boolean

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 false (ie ‘not up to date’) if force? is true.

Returns:

  • (Boolean)


191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/tap/file_task.rb', line 191

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