filewatcher 1.1.1 → 2.0.0.beta1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 805761615b226e14eb6431116a32bdc34e8b13cf
4
- data.tar.gz: 4c1523220b4c242d33750e145091e1e2568766ac
2
+ SHA256:
3
+ metadata.gz: 8bff81f17e2979e1f38b9a19ed820827b4f7856b018adab83f212cb7a9777011
4
+ data.tar.gz: 6ce4ec41d01b08e4be1839d7f4fc834a66403517b0430a458ccff814ef61fc23
5
5
  SHA512:
6
- metadata.gz: 6d6f196a0ce277f920732a6c4ff0071b82e5b6eb3cf0683834041e973bb3a10a6c914aba6495d0bbbdfd83d241f983f697b45aa8ddd62ad9c973e3b4294fbffa
7
- data.tar.gz: 7a75c0bb5e79efa44207f20052f8907790e2292195f65336f3d06303a8618ebe84fc658e32fec8333da461d7ccc979c74df061c735692ff7dd2647fac70b0f91
6
+ metadata.gz: 3d58c98c188d13e760d644964d25c21edbe8d863646825fe703f08e63b7ffd58b1a702fd284f7d66aa785a939c626a5d79ccc5aa4e6abd15b2e014490d9d467f
7
+ data.tar.gz: 6a66f86cc5a4357ddf0182fdf15088f6f1935430643c47e1caef509c13795cdb181d8433b213dcd76090188ca1782f5d94557c4ff59c9ed9d27a8584ca6ff6cd
@@ -1,31 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'logger'
3
4
  require_relative 'filewatcher/cycles'
5
+ require_relative 'filewatcher/snapshots'
4
6
 
5
7
  # Simple file watcher. Detect changes in files and directories.
6
8
  #
7
- # Issues: Currently doesn't monitor changes in directorynames
9
+ # Issues: Currently doesn't monitor changes in directory names
8
10
  class Filewatcher
9
11
  include Filewatcher::Cycles
12
+ include Filewatcher::Snapshots
10
13
 
11
14
  attr_accessor :interval
12
15
  attr_reader :keep_watching
13
16
 
14
- def update_spinner(label)
15
- return unless @show_spinner
16
- @spinner ||= %w[\\ | / -]
17
- print "#{' ' * 30}\r#{label} #{@spinner.rotate!.first}\r"
18
- end
19
-
20
17
  def initialize(unexpanded_filenames, options = {})
21
18
  @unexpanded_filenames = unexpanded_filenames
22
19
  @unexpanded_excluded_filenames = options[:exclude]
23
20
  @keep_watching = false
24
21
  @pausing = false
25
22
  @immediate = options[:immediate]
26
- @show_spinner = options[:spinner]
27
23
  @interval = options.fetch(:interval, 0.5)
28
- @every = options[:every]
24
+ @logger = options.fetch(:logger, Logger.new($stdout, level: :info))
25
+
26
+ after_initialize unexpanded_filenames, options
29
27
  end
30
28
 
31
29
  def watch(&on_update)
@@ -37,28 +35,31 @@ class Filewatcher
37
35
 
38
36
  @on_update = on_update
39
37
  @keep_watching = true
40
- yield('', '') if @immediate
38
+ yield({ '' => '' }) if @immediate
41
39
 
42
40
  main_cycle
43
41
 
44
- @end_snapshot = mtime_snapshot
42
+ @end_snapshot = current_snapshot
45
43
  finalize(&on_update)
46
44
  end
47
45
 
48
46
  def pause
49
47
  @pausing = true
50
- update_spinner('Initiating pause')
48
+
49
+ before_pause_sleep
50
+
51
51
  # Ensure we wait long enough to enter pause loop in #watch
52
52
  sleep @interval
53
53
  end
54
54
 
55
55
  def resume
56
- if !@keep_watching || !@pausing
57
- raise "Can't resume unless #watch and #pause were first called"
58
- end
59
- @last_snapshot = mtime_snapshot # resume with fresh snapshot
56
+ raise "Can't resume unless #watch and #pause were first called" if !@keep_watching || !@pausing
57
+
58
+ @last_snapshot = current_snapshot # resume with fresh snapshot
60
59
  @pausing = false
61
- update_spinner('Resuming')
60
+
61
+ before_resume_sleep
62
+
62
63
  sleep @interval # Wait long enough to exit pause loop in #watch
63
64
  end
64
65
 
@@ -66,7 +67,9 @@ class Filewatcher
66
67
  # Used mainly in multi-threaded situations.
67
68
  def stop
68
69
  @keep_watching = false
69
- update_spinner('Stopping')
70
+
71
+ after_stop
72
+
70
73
  nil
71
74
  end
72
75
 
@@ -74,54 +77,17 @@ class Filewatcher
74
77
  # current snapshot are dealt with
75
78
  def finalize(&on_update)
76
79
  on_update = @on_update unless block_given?
77
- while filesystem_updated?(@end_snapshot || mtime_snapshot)
78
- update_spinner('Finalizing')
80
+
81
+ while file_system_updated?(@end_snapshot || current_snapshot)
82
+ finalizing
79
83
  trigger_changes(on_update)
80
84
  end
81
- @end_snapshot = nil
82
- end
83
85
 
84
- def last_found_filenames
85
- last_snapshot.keys
86
+ @end_snapshot = nil
86
87
  end
87
88
 
88
89
  private
89
90
 
90
- def last_snapshot
91
- @last_snapshot ||= mtime_snapshot
92
- end
93
-
94
- # Takes a snapshot of the current status of watched files.
95
- # (Allows avoidance of potential race condition during #finalize)
96
- def mtime_snapshot
97
- snapshot = {}
98
- filenames = expand_directories(@unexpanded_filenames)
99
-
100
- # Remove files in the exclude filenames list
101
- filenames -= expand_directories(@unexpanded_excluded_filenames)
102
-
103
- filenames.each do |filename|
104
- mtime = File.exist?(filename) ? File.mtime(filename) : Time.new(0)
105
- snapshot[filename] = mtime
106
- end
107
- snapshot
108
- end
109
-
110
- def filesystem_updated?(snapshot = mtime_snapshot)
111
- @changes = {}
112
-
113
- (snapshot.to_a - last_snapshot.to_a).each do |file, _mtime|
114
- @changes[file] = last_snapshot[file] ? :updated : :created
115
- end
116
-
117
- (last_snapshot.keys - snapshot.keys).each do |file|
118
- @changes[file] = :deleted
119
- end
120
-
121
- @last_snapshot = snapshot
122
- @changes.any?
123
- end
124
-
125
91
  def expand_directories(patterns)
126
92
  patterns = Array(patterns) unless patterns.is_a? Array
127
93
  expanded_patterns = patterns.map do |pattern|
@@ -134,6 +100,30 @@ class Filewatcher
134
100
  expanded_patterns.uniq!
135
101
  expanded_patterns
136
102
  end
103
+
104
+ def debug(data)
105
+ @logger.debug "Thread ##{Thread.current.object_id} #{data}"
106
+ end
107
+
108
+ def after_initialize(*)
109
+ super if defined?(super)
110
+ end
111
+
112
+ def before_pause_sleep
113
+ super if defined?(super)
114
+ end
115
+
116
+ def before_resume_sleep
117
+ super if defined?(super)
118
+ end
119
+
120
+ def after_stop
121
+ super if defined?(super)
122
+ end
123
+
124
+ def finalizing
125
+ super if defined?(super)
126
+ end
137
127
  end
138
128
 
139
129
  # Require at end of file to not overwrite `Filewatcher` class
@@ -7,7 +7,7 @@ class Filewatcher
7
7
 
8
8
  def main_cycle
9
9
  while @keep_watching
10
- @end_snapshot = mtime_snapshot if @pausing
10
+ @end_snapshot = current_snapshot if @pausing
11
11
 
12
12
  pausing_cycle
13
13
 
@@ -21,27 +21,36 @@ class Filewatcher
21
21
 
22
22
  def pausing_cycle
23
23
  while @keep_watching && @pausing
24
- update_spinner('Pausing')
24
+ before_pausing_sleep
25
+
25
26
  sleep @interval
26
27
  end
27
28
  end
28
29
 
30
+ def before_pausing_sleep
31
+ super if defined?(super)
32
+ end
33
+
29
34
  def watching_cycle
30
- while @keep_watching && !filesystem_updated? && !@pausing
31
- update_spinner('Watching')
35
+ @last_snapshot ||= current_snapshot
36
+ loop do
37
+ before_watching_sleep
38
+
39
+ debug "#{__method__} sleep #{@interval}"
32
40
  sleep @interval
41
+ break if !@keep_watching || file_system_updated? || @pausing
33
42
  end
34
43
  end
35
44
 
45
+ def before_watching_sleep
46
+ super if defined?(super)
47
+ end
48
+
36
49
  def trigger_changes(on_update = @on_update)
37
- thread = Thread.new do
38
- changes = @every ? @changes : @changes.first(1)
39
- changes.each do |filename, event|
40
- on_update.call(filename, event)
41
- end
42
- @changes.clear
43
- end
44
- thread.join
50
+ debug __method__
51
+ on_update.call(@changes.dup) unless @changes.empty?
52
+ @changes.clear
53
+ debug '@changes cleared'
45
54
  end
46
55
  end
47
56
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ class Filewatcher
6
+ # Class for snapshots of file system
7
+ class Snapshot
8
+ extend Forwardable
9
+ def_delegators :@data, :[], :each, :keys
10
+
11
+ def initialize(filenames)
12
+ @data = filenames.each_with_object({}) do |filename, data|
13
+ data[filename] = SnapshotFile.new(filename)
14
+ end
15
+ end
16
+
17
+ def -(other)
18
+ changes = {}
19
+
20
+ each do |filename, snapshot_file|
21
+ changes[filename] = snapshot_file - other[filename]
22
+ end
23
+
24
+ other.each do |filename, _snapshot_file|
25
+ changes[filename] = :deleted unless self[filename]
26
+ end
27
+
28
+ changes.reject! { |_filename, event| event.nil? }
29
+ changes
30
+ end
31
+
32
+ # Class for one file from snapshot
33
+ class SnapshotFile
34
+ STATS = %i[mtime].freeze
35
+
36
+ attr_reader(*STATS)
37
+
38
+ def initialize(filename)
39
+ @filename = filename
40
+ STATS.each do |stat|
41
+ time = File.public_send(stat, filename) if File.exist?(filename)
42
+ instance_variable_set :"@#{stat}", time || Time.new(0)
43
+ end
44
+ end
45
+
46
+ def -(other)
47
+ if other.nil?
48
+ :created
49
+ elsif other.mtime < mtime
50
+ :updated
51
+ end
52
+ end
53
+
54
+ def inspect
55
+ <<~OUTPUT
56
+ #<Filewatcher::Snapshot::SnapshotFile:#{object_id}
57
+ @filename=#{@filename.inspect}, mtime=#{mtime.strftime('%F %T.%9N').inspect}
58
+ >
59
+ OUTPUT
60
+ end
61
+ end
62
+
63
+ private_constant :SnapshotFile
64
+ end
65
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'snapshot'
4
+
5
+ # Helpers in Filewatcher class itself
6
+ class Filewatcher
7
+ class << self
8
+ def system_stat(filename)
9
+ case Gem::Platform.local.os
10
+ when 'linux' then `stat --printf 'Modification: %y, Change: %z\n' #{filename}`
11
+ when 'darwin' then `stat #{filename}`
12
+ else 'Unknown OS for system `stat`'
13
+ end
14
+ end
15
+ end
16
+
17
+ # Module for snapshot logic inside Filewatcher
18
+ module Snapshots
19
+ def found_filenames
20
+ current_snapshot.keys
21
+ end
22
+
23
+ private
24
+
25
+ def watching_files
26
+ expand_directories(@unexpanded_filenames) - expand_directories(@unexpanded_excluded_filenames)
27
+ end
28
+
29
+ # Takes a snapshot of the current status of watched files.
30
+ # (Allows avoidance of potential race condition during #finalize)
31
+ def current_snapshot
32
+ Filewatcher::Snapshot.new(watching_files)
33
+ end
34
+
35
+ def file_mtime(filename)
36
+ return Time.new(0) unless File.exist?(filename)
37
+
38
+ result = File.mtime(filename)
39
+ if @logger.level <= Logger::DEBUG
40
+ debug "File.mtime = #{result.strftime('%F %T.%9N')}"
41
+ debug "stat #{filename}: #{self.class.system_stat(filename)}"
42
+ end
43
+ result
44
+ end
45
+
46
+ def file_system_updated?(snapshot = current_snapshot)
47
+ debug __method__
48
+
49
+ @changes = snapshot - @last_snapshot
50
+
51
+ @last_snapshot = snapshot
52
+
53
+ @changes.any?
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ begin
6
+ require 'pry-byebug'
7
+ rescue LoadError
8
+ nil
9
+ end
10
+
11
+ require_relative 'spec_helper/watch_run'
12
+
13
+ class Filewatcher
14
+ ## Helper for common spec features between plugins
15
+ module SpecHelper
16
+ def logger
17
+ @logger ||= Logger.new($stdout, level: :debug)
18
+ end
19
+
20
+ def environment_specs_coefficients
21
+ @environment_specs_coefficients ||= {
22
+ -> { ENV['CI'] } => 1,
23
+ -> { RUBY_PLATFORM == 'java' } => 3,
24
+ -> { Gem::Platform.local.os == 'darwin' } => 1
25
+ }
26
+ end
27
+
28
+ def wait(seconds: 1, interval: 1, &block)
29
+ environment_specs_coefficients.each do |condition, coefficient|
30
+ next unless instance_exec(&condition)
31
+
32
+ interval *= coefficient
33
+ seconds *= coefficient
34
+ end
35
+
36
+ if block_given?
37
+ wait_with_block seconds, interval, &block
38
+ else
39
+ wait_without_block seconds
40
+ end
41
+ end
42
+
43
+ def wait_with_block(seconds, interval, &_block)
44
+ (seconds / interval).ceil.times do
45
+ break if yield
46
+
47
+ debug "sleep interval #{interval}"
48
+ sleep interval
49
+ end
50
+ end
51
+
52
+ def wait_without_block(seconds)
53
+ debug "sleep without intervals #{seconds}"
54
+ sleep seconds
55
+ end
56
+
57
+ def debug(string)
58
+ logger.debug "Thread ##{Thread.current.object_id} #{string}"
59
+ end
60
+
61
+ ## https://github.com/rubocop-hq/ruby-style-guide/issues/556#issuecomment-691274359
62
+ # rubocop:disable Style/ModuleFunction
63
+ extend self
64
+ # rubocop:enable Style/ModuleFunction
65
+ end
66
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Filewatcher
4
+ module SpecHelper
5
+ ## Base class for Filewatcher runners in specs
6
+ class WatchRun
7
+ extend Forwardable
8
+
9
+ TMP_DIR = "#{Dir.getwd}/spec/tmp"
10
+
11
+ def_delegators Filewatcher::SpecHelper, :debug, :wait
12
+
13
+ attr_reader :filename
14
+
15
+ def initialize(filename:, action:, directory:)
16
+ @filename =
17
+ if filename.match? %r{^(/|~|[A-Z]:)} then filename
18
+ else File.join(TMP_DIR, filename)
19
+ end
20
+ @directory = directory
21
+ @action = action
22
+ debug "action = #{action}"
23
+ end
24
+
25
+ def start
26
+ debug 'start'
27
+ File.write(@filename, 'content1') unless @action == :create
28
+
29
+ wait seconds: 1
30
+ end
31
+
32
+ def run(make_changes_times: 1)
33
+ start
34
+
35
+ make_changes_times.times do
36
+ make_changes
37
+
38
+ wait seconds: 2
39
+ end
40
+
41
+ stop
42
+ end
43
+
44
+ def stop
45
+ debug 'stop'
46
+ FileUtils.rm_r(@filename) if File.exist?(@filename)
47
+ end
48
+
49
+ private
50
+
51
+ def make_changes
52
+ debug "make changes, @action = #{@action}, @filename = #{@filename}"
53
+
54
+ if @action == :delete
55
+ FileUtils.remove(@filename)
56
+ elsif @directory
57
+ FileUtils.mkdir_p(@filename)
58
+ else
59
+ ## There is no `File.write` because of strange difference in parallel `File.mtime`
60
+ ## https://cirrus-ci.com/task/6107605053472768?command=test#L497-L511
61
+ system "echo 'content2' > #{@filename}"
62
+ debug_file_mtime
63
+ end
64
+
65
+ wait seconds: 1
66
+ end
67
+
68
+ def debug_file_mtime
69
+ debug "stat #{@filename}: #{Filewatcher.system_stat(@filename)}"
70
+ debug "File.mtime = #{File.mtime(@filename).strftime('%F %T.%9N')}"
71
+ end
72
+ end
73
+ end
74
+ end