multisync 0.3.6 → 0.4.0

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.
@@ -1,14 +1,13 @@
1
-
2
1
  class Multisync::Definition::Null < Multisync::Definition::Entity
3
-
4
2
  def initialize
3
+ @level = -1
5
4
  end
6
-
5
+
7
6
  def register member
8
7
  end
9
8
 
10
9
  def fullname
11
- nil
10
+ ""
12
11
  end
13
12
 
14
13
  def rsync_options
@@ -19,34 +18,33 @@ class Multisync::Definition::Null < Multisync::Definition::Entity
19
18
  def source
20
19
  raise "no source (from) defined"
21
20
  end
22
-
21
+
23
22
  def source_description
24
- ''
23
+ ""
25
24
  end
26
-
25
+
27
26
  # to (destination) is a required option and should be set at least at root level
28
27
  def destination
29
28
  raise "no destination (to) defined"
30
29
  end
31
-
30
+
32
31
  def destination_description
33
- ''
32
+ ""
34
33
  end
35
-
34
+
36
35
  def default?
37
36
  false
38
37
  end
39
-
38
+
40
39
  def checks
41
40
  []
42
41
  end
43
42
 
44
43
  def check_source?
45
44
  false
46
- end
45
+ end
47
46
 
48
47
  def check_destination?
49
48
  false
50
49
  end
51
50
  end
52
-
@@ -1,26 +1,25 @@
1
-
2
1
  class Multisync::Definition::Template
3
2
  include Multisync::Definition::Dsl
4
-
3
+
5
4
  @registered = []
6
-
5
+
7
6
  def self.register instance
8
7
  @registered << instance
9
8
  end
10
-
9
+
11
10
  def self.lookup name
12
- @registered.find {|instance| instance.name == name }
11
+ @registered.find { _1.name == name }
13
12
  end
14
-
13
+
15
14
  # The name of the template
16
15
  attr_reader :name
17
-
16
+
18
17
  # The block the template holds
19
18
  attr_reader :block
20
-
19
+
21
20
  def initialize name, &block
22
21
  @name = name
23
22
  self.class.register self
24
23
  @block = block
25
24
  end
26
- end
25
+ end
@@ -1,7 +1,6 @@
1
-
2
1
  class Multisync::Definition
3
- autoload :Dsl, 'multisync/definition/dsl'
4
- autoload :Entity, 'multisync/definition/entity'
5
- autoload :Null, 'multisync/definition/null'
6
- autoload :Template, 'multisync/definition/template'
7
- end
2
+ autoload :Dsl, "multisync/definition/dsl"
3
+ autoload :Entity, "multisync/definition/entity"
4
+ autoload :Null, "multisync/definition/null"
5
+ autoload :Template, "multisync/definition/template"
6
+ end
@@ -1,44 +1,47 @@
1
-
2
- require 'rainbow/ext/string'
3
-
4
1
  class Multisync::List
5
-
2
+ include Multisync::Colors
3
+
6
4
  # Given catalog
7
5
  attr_reader :catalog
8
-
6
+
9
7
  # Tasks
10
8
  attr_reader :tasks
11
9
 
12
- def initialize catalog
13
- @catalog = catalog
14
- @tasks = []
10
+ def initialize tasks
11
+ @tasks = tasks
15
12
  end
16
-
13
+
17
14
  def to_s
18
- catalog.traverse self
19
- table.to_s
15
+ "\n" + table.to_s
20
16
  end
21
-
17
+
22
18
  def table
23
- Terminal::Table.new(rows: tasks, style: table_style)
19
+ Terminal::Table.new(rows: rows, style: table_style)
24
20
  end
25
-
26
- def visit subject, level
27
- if level > 0
28
- tab = ''.ljust(2*(level-1), ' ')
29
- default = subject.default? ? ' *' : ''
30
- name = "#{tab}#{subject.name}#{default}"
31
- tasks << [name, *description(subject).map(&:faint)]
32
- # puts "#{name.ljust(32, ' ')}#{description(subject)}"
21
+
22
+ def rows
23
+ tasks.map do |task|
24
+ next unless task.level > 0
25
+
26
+ indent = "".ljust(2 * (task.level - 1), " ")
27
+ name = task.executeable? ? task.name : "#{task.name}#{as_note("/")}"
28
+ default = task.default? ? as_note(" *") : ""
29
+ [
30
+ [indent, name, default].join,
31
+ *descriptions(task)
32
+ ]
33
33
  end
34
34
  end
35
-
36
- def description subject
37
- desc = [subject.source_description, subject.destination_description]
38
- desc.any?(&:empty?) ? [] : [desc.first, ['--> ', desc.last].join]
35
+
36
+ def descriptions task
37
+ if [task.source_description, task.destination_description].any?(&:empty?)
38
+ ["", "", ""]
39
+ else
40
+ [task.source_description, "-->", task.destination_description].map(&method(:as_note))
41
+ end
39
42
  end
40
43
 
41
44
  def table_style
42
- { border_top: false, border_bottom: false, border_x: '–', border_y: '', border_i: '', padding_left: 0, padding_right: 3 }
45
+ {border_x: as_note("─"), border_y: "", border_i: "", border_top: false, border_bottom: false, padding_left: 0, padding_right: 3}
43
46
  end
44
47
  end
@@ -1,58 +1,99 @@
1
-
2
- require 'filesize'
1
+ require "filesize"
3
2
 
4
3
  class Multisync::RsyncStat
5
-
4
+ # Keep track of totals
5
+ def self.total
6
+ @total ||= Hash.new 0
7
+ end
8
+
9
+ # Sample output
10
+ # ...
11
+ # Number of files: 89,633 (reg: 59,322, dir: 30,311)
12
+ # Number of created files: 356 (reg: 281, dir: 75)
13
+ # Number of deleted files: 114 (reg: 101, dir: 13)
14
+ # Number of regular files transferred: 344
15
+ # Total file size: 546,410,522,192 bytes
16
+ # Total transferred file size: 7,991,491,676 bytes
17
+ # Literal data: 7,952,503,842 bytes
18
+ # Matched data: 38,987,834 bytes
19
+ # File list size: 3,063,808
20
+ # File list generation time: 2.414 seconds
21
+ # File list transfer time: 0.000 seconds
22
+ # Total bytes sent: 7,957,645,803
23
+ # Total bytes received: 101,299
24
+ #
25
+ # sent 7,957,645,803 bytes received 101,299 bytes 23,719,067.37 bytes/sec
26
+ # total size is 546,410,522,192 speedup is 68.66
6
27
  def initialize output
7
28
  @output = output
8
29
  end
9
30
 
10
- # Build an internal hash with normalized stats
11
- def parse
12
- @stats = definitions.each_with_object({}) do |definition, stats|
13
- value = scan[definition[:match]]
14
- stats[definition[:key]] = value ? (definition[:coerce] ? definition[:coerce].call(value) : value) : definition[:default]
15
- end
16
- self
17
- end
18
-
19
- # Scan output and return a hash
31
+ # extracted returns a hash with labels as keys and extracted strings as values
20
32
  # {
21
33
  # "Number of files" => "35,648",
22
- # "Number of created files" => "0",
23
- # "Number of deleted files" => "0",
24
- # "Number of regular files transferred"=>"0",
34
+ # "Number of created files" => "2,120",
25
35
  # ...
26
36
  # }
27
- def scan
28
- @scan ||= @output.scan(/(#{definitions.map{|d| d[:match] }.join('|')}):\s+([,0-9]+)/).each_with_object({}) {|(k,v), o| o[k] = v }
29
- end
30
-
31
- def to_a
32
- [
33
- @stats[:files],
34
- @stats[:created],
35
- @stats[:deleted],
36
- @stats[:transferred],
37
- @stats[:file_size],
38
- @stats[:transferred_size],
39
- ]
40
- end
41
-
42
- def method_missing name
43
- key = name.to_sym
44
- return @stats[key] if @stats.keys.include? key
45
- super
46
- end
47
-
48
- def definitions
49
- [
50
- { key: :files, match: 'Number of files', coerce: ->(x) { x.gsub(',',"'") }, default: '0' },
51
- { key: :created, match: 'Number of created files', coerce: ->(x) { x.gsub(',',"'") }, default: '0' },
52
- { key: :deleted, match: 'Number of deleted files', coerce: ->(x) { x.gsub(',',"'") }, default: '0' },
53
- { key: :transferred, match: 'Number of regular files transferred', coerce: ->(x) { x.gsub(',',"'") }, default: '0' },
54
- { key: :file_size, match: 'Total file size', coerce: ->(x) { Filesize.new(x.gsub(',','').to_i).pretty }, default: '0 B' },
55
- { key: :transferred_size, match: 'Total transferred file size', coerce: ->(x) { Filesize.new(x.gsub(',','').to_i).pretty }, default: '0 B' },
56
- ]
57
- end
58
- end
37
+ def extracted
38
+ @extraced ||= @output.scan(/(#{labels.join("|")}):\s+([,0-9]+)/).to_h
39
+ end
40
+
41
+ # stats returns a hash with the follwing keys (and updates class total)
42
+ # {
43
+ # "Number of files" => 35648,
44
+ # "Number of created files" => 2120,
45
+ # "Number of deleted files" => 37,
46
+ # "Number of regular files transferred" => 394,
47
+ # "Total file size" => 204936349,
48
+ # "Total transferred file size" => 49239,
49
+ # }
50
+ def stats
51
+ @stats ||= labels.each_with_object({}) do |label, h|
52
+ value = extracted[label]&.delete(",").to_i
53
+
54
+ self.class.total[label] += value # update total
55
+ h[label] = value
56
+ end
57
+ end
58
+
59
+ def labels
60
+ self.class.format_map.keys
61
+ end
62
+
63
+ def formatted_values
64
+ self.class.format_values do |label|
65
+ stats[label]
66
+ end
67
+ end
68
+
69
+ def self.formatted_totals
70
+ format_values do |label|
71
+ total[label]
72
+ end
73
+ end
74
+
75
+ def self.format_values
76
+ format_map.map do |label, format|
77
+ format.call(yield label)
78
+ end
79
+ end
80
+
81
+ def self.format_map
82
+ {
83
+ "Number of files" => to_numbers,
84
+ "Number of created files" => to_numbers,
85
+ "Number of deleted files" => to_numbers,
86
+ "Number of regular files transferred" => to_numbers,
87
+ "Total file size" => to_filesize,
88
+ "Total transferred file size" => to_filesize
89
+ }
90
+ end
91
+
92
+ def self.to_numbers
93
+ ->(x) { x.to_s.gsub(/\B(?=(...)*\b)/, "'") }
94
+ end
95
+
96
+ def self.to_filesize
97
+ ->(x) { Filesize.new(x).pretty }
98
+ end
99
+ end
@@ -1,82 +1,77 @@
1
-
2
- require 'mixlib/shellout'
1
+ require "mixlib/shellout"
3
2
 
4
3
  class Multisync::Runtime
4
+ include Multisync::Colors
5
5
 
6
6
  # Runtime options
7
7
  # dryrun: true|false
8
8
  # show: true|false
9
9
  attr_reader :options
10
-
10
+
11
11
  def initialize options
12
12
  @options = options
13
13
  end
14
-
15
- def dryrun?
16
- options[:dryrun]
17
- end
18
-
19
- def show_only?
20
- options[:print]
21
- end
22
-
23
- def quiet?
24
- options[:quiet]
25
- end
26
-
27
- def timeout
28
- options[:timeout]
29
- end
30
-
14
+
15
+ def dryrun? = options[:dryrun]
16
+
17
+ def show_only? = options[:print]
18
+
19
+ def quiet? = options[:quiet]
20
+
21
+ def timeout = options[:timeout]
22
+
31
23
  def run sync
32
24
  rsync_options = sync.rsync_options.dup
33
- rsync_options.unshift '--stats'
34
- rsync_options.unshift '--verbose' unless quiet?
35
- rsync_options.unshift '--dry-run' if dryrun?
25
+ rsync_options.unshift "--stats"
26
+ rsync_options.unshift "--verbose" unless quiet?
27
+ rsync_options.unshift "--dry-run" if dryrun?
36
28
 
37
29
  # escape path by hand, shellescape escapes also ~, but we want to keep its
38
30
  # special meaning for home, instead of passing it as literal char
39
- source, destination = [sync.source, sync.destination].map {|path| path.gsub(/\s+/, '\\ ') }
40
- cmd = "rsync #{rsync_options.join(' ')} #{source} #{destination}"
41
- cmd_options = { timeout: timeout }
42
- cmd_options.merge!({live_stdout: $stdout, live_stderr: $stderr}) unless quiet?
31
+ source, destination = [sync.source, sync.destination].map { _1.gsub(/\s+/, "\\ ") }
32
+ cmd = "rsync #{rsync_options.join(" ")} #{source} #{destination}"
33
+ cmd_options = {timeout: timeout}
34
+ unless quiet?
35
+ cmd_options[:live_stdout] = $stdout
36
+ cmd_options[:live_stderr] = $stderr
37
+ end
43
38
  rsync = Mixlib::ShellOut.new(cmd, cmd_options)
44
39
  sync.result[:cmd] = rsync.command
45
40
 
46
41
  unless quiet?
47
42
  puts
48
- puts [sync.source_description, sync.destination_description].join(' --> ').color(:cyan)
43
+ puts as_main([sync.source_description, sync.destination_description].join(" --> "))
49
44
  end
50
-
45
+
51
46
  # Perform all only_if checks, from top to bottom
52
47
  sync.checks.each do |check|
53
48
  next unless Mixlib::ShellOut.new(check[:cmd]).run_command.error?
54
49
 
55
- puts check[:cmd] + ' (failed)'
56
- puts "Skip: ".color(:yellow) + rsync.command
50
+ puts as_skipped("#{check[:cmd]} (failed)")
51
+ puts as_note("Skip: #{rsync.command}")
57
52
  sync.result[:action] = :skip
58
53
  sync.result[:skip_message] = check[:message]
59
- return
54
+ return false
60
55
  end
61
-
56
+
62
57
  # source check
63
- if sync.check_source? && ! check_path(sync.source, :source)
64
- puts "Source #{sync.source} is not accessible"
65
- puts "Skip: ".color(:yellow) + rsync.command
58
+ if sync.check_source? && !check_path(sync.source, :source)
59
+ puts as_skipped("Source #{sync.source} is not accessible")
60
+ puts as_note("Skip: #{rsync.command}")
66
61
  sync.result[:action] = :skip
67
62
  sync.result[:skip_message] = "Source is not accessible"
68
63
  return
69
64
  end
70
-
65
+
71
66
  # target check
72
- if sync.check_destination? && ! check_path(sync.destination, :destination)
73
- puts "Destination #{sync.destination} is not accessible"
74
- puts "Skip: ".color(:yellow) + rsync.command
67
+ if sync.check_destination? && !check_path(sync.destination, :destination)
68
+ puts as_skipped("Destination #{sync.destination} is not accessible")
69
+ puts as_note("Skip: #{rsync.command}")
75
70
  sync.result[:action] = :skip
76
71
  sync.result[:skip_message] = "Destination is not accessible"
77
72
  return
78
73
  end
79
-
74
+
80
75
  if show_only?
81
76
  puts rsync.command
82
77
  else
@@ -88,20 +83,20 @@ class Multisync::Runtime
88
83
  sync.result[:stderr] = rsync.stderr
89
84
  end
90
85
  end
91
-
86
+
92
87
  # checks a path
93
88
  # if path includes a host, the reachability of the host will be checked
94
89
  # the existence of the remote path will not be checked
95
90
  # if path is a local source path, its existence will be checked
96
91
  # if path is a local destination path, the existence of the parent will be checked
97
92
  def check_path path, type = :source
98
- if path.include? ':'
99
- host = path.split(':').first.split('@').last
93
+ if path.include? ":"
94
+ host = path.split(":").first.split("@").last
100
95
  Mixlib::ShellOut.new("ping -o -t 1 #{host}").run_command.status.success?
101
96
  else
102
- abs_path = File.expand_path path
103
- abs_path = File.dirname abs_path if type == :destination
104
- File.exist? abs_path
97
+ File.expand_path(path)
98
+ .then { (type == :destination) ? File.dirname(_1) : _1 }
99
+ .then { File.exist? _1 }
105
100
  end
106
101
  end
107
- end
102
+ end
@@ -1,36 +1,58 @@
1
-
2
1
  class Multisync::Selector
3
-
4
2
  # Given catalog
5
3
  attr_reader :catalog
6
-
7
- # Given set names
8
- attr_reader :sets
9
-
4
+
5
+ # Given queries
6
+ attr_reader :queries
7
+
10
8
  # Selected tasks
11
- attr_reader :result
9
+ attr_reader :results
12
10
 
13
- def initialize catalog, sets
11
+ def initialize catalog, queries
14
12
  @catalog = catalog
15
- @sets = sets
16
- @result = []
13
+ @queries = queries
14
+ @results = []
15
+ @all_subjects = []
16
+ @subjects_by_name = []
17
17
  end
18
-
19
- def tasks
18
+
19
+ def tasks parents: false
20
20
  catalog.traverse self
21
- result
21
+ parents ? selected_with_parents : selected
22
22
  end
23
-
24
- def visit subject, _level
25
- result << subject if selected?(subject)
23
+
24
+ def visit subject
25
+ results << subject
26
+ end
27
+
28
+ def selected_with_parents
29
+ @selected_with_parents ||= results.select { selected_or_parent_of_selected? _1 }
30
+ end
31
+
32
+ def selected_or_parent_of_selected? subject
33
+ !subject.fullname.empty? &&
34
+ selected_fullnames.any? { %r{^#{subject.fullname}(?:/|$)}.match _1 }
26
35
  end
27
-
36
+
37
+ def selected_fullnames
38
+ @selected_fullnames ||= selected.map(&:fullname)
39
+ end
40
+
41
+ def selected
42
+ @selected ||= results.select { selected? _1 }
43
+ end
44
+
28
45
  def selected? subject
46
+ # return only subjects with a fullname
47
+ return false if subject.fullname.empty?
48
+
49
+ # no queries defined, but subject is in the default set
50
+ return true if queries.empty? && subject.default?
51
+
29
52
  # only return the leaves of the definition tree
30
- return false unless subject.members.empty?
31
- # no sets defined, but subject is in the default set
32
- return true if sets.empty? && subject.default?
33
- # subject matches any of the given sets
34
- sets.any? {|set| /\b#{set}\b/.match subject.fullname }
53
+ # return false unless subject.members.any?
54
+
55
+ # subject matches any of the given queries
56
+ queries.any? { /\b#{_1}\b/.match subject.fullname }
35
57
  end
36
58
  end
@@ -1,55 +1,90 @@
1
-
2
1
  class Multisync::Summary
3
-
2
+ include Multisync::Colors
3
+
4
4
  # All tasks to include in the summary
5
5
  attr_reader :tasks
6
-
6
+
7
7
  def initialize tasks
8
8
  @tasks = tasks
9
9
  end
10
-
10
+
11
11
  def to_s
12
- table.to_s
12
+ ["", as_main("Summary"), table.to_s, failures].compact.join("\n")
13
13
  end
14
-
14
+
15
15
  def table
16
16
  Terminal::Table.new(headings: headings, rows: data, style: table_style)
17
17
  end
18
-
18
+
19
19
  def headings
20
- %w( Source Destination Files + - → ∑ ↑ ).zip(%i( left left right right right right right right )).map{|v,a| {value: v, alignment: a} }
20
+ %w[SOURCE DESTINATION FILES + - → ∑ ↑]
21
+ .map(&method(:as_note))
22
+ .zip(%i[left left right right right right right right])
23
+ .map { |v, a| {value: v, alignment: a} }
21
24
  end
22
-
25
+
23
26
  def data
24
- # Exclude tasks with an empty result (> not run) first
25
27
  tasks.map do |task|
26
28
  result = task.result
27
- desc = [task.source_description, "--> #{task.destination_description}"]
29
+ desc = [task.source_description, task.destination_description]
28
30
 
29
31
  case result[:action]
30
32
  when :run
31
- if result[:status] && result[:status].success?
33
+ if result[:status]&.success?
32
34
  # successfull run
33
- stat = Multisync::RsyncStat.new(result[:stdout]).parse
34
- [*desc, *stat.to_a.map{|e| {value: e.color(:green), alignment: :right} } ]
35
+ stats = Multisync::RsyncStat.new(result[:stdout])
36
+ [*desc, *stats.formatted_values.map { {value: as_success(_1), alignment: :right} }]
35
37
  else
36
38
  # failed or interrupted run
37
- [*desc, { value: (result[:stderr] || 'n/a').strip.color(:red), colspan: 6 } ]
39
+ [*desc, as_message(as_fail("Failed, for more information see details below"))]
40
+ # [*desc, as_message(as_fail(result[:stderr] || "n/a").strip)]
38
41
  end
39
42
 
40
43
  when :skip
41
44
  # skiped sync
42
- [*desc, { value: result[:skip_message].color(:yellow), colspan: 6 } ]
45
+ [*desc, as_message(as_skipped(result[:skip_message]))]
43
46
 
44
47
  else
45
48
  # not executed
46
- [*desc, { value: 'not executed'.faint, colspan: 6 } ]
49
+ [*desc, as_message(as_note("not executed"))]
47
50
  end
48
- end
51
+ end.push(
52
+ [
53
+ as_note("Total"),
54
+ "",
55
+ *Multisync::RsyncStat
56
+ .formatted_totals
57
+ .map { {value: as_note(_1), alignment: :right} }
58
+ ]
59
+ )
49
60
  end
50
-
51
- def table_style
52
- { border_top: false, border_bottom: false, border_x: '–', border_y: '', border_i: '', padding_left: 0, padding_right: 3 }
61
+
62
+ def failures
63
+ tasks
64
+ .select { _1.result[:action] == :run && !_1.result[:status]&.success? }
65
+ .flat_map do |task|
66
+ [
67
+ as_fail([task.source_description, task.destination_description].join(" --> ")),
68
+ message_or_nil(task.result[:stdout]),
69
+ message_or_nil(task.result[:stderr]),
70
+ ""
71
+ ].compact
72
+ end
73
+ # Add title if any failures
74
+ .tap { _1.unshift "\n#{as_main("Failures")}" if _1.any? }
75
+ # Return failures as string or nil
76
+ .then { _1.any? ? _1.join("\n") : nil }
77
+ end
78
+
79
+ def as_message message
80
+ {value: message, colspan: 6}
53
81
  end
54
82
 
83
+ def message_or_nil message
84
+ (message.nil? || message.empty?) ? nil : message
85
+ end
86
+
87
+ def table_style
88
+ {border_x: as_note("─"), border_y: "", border_i: "", border_top: false, border_bottom: false, padding_left: 0, padding_right: 3}
89
+ end
55
90
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Multisync
2
- VERSION = "0.3.6"
4
+ VERSION = "0.4.0"
3
5
  end