filewatcher 1.0.0 → 2.0.0.beta2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 025e651355a5bbbae353527112232253ee4f682d
4
- data.tar.gz: 95ca7990a7e002768ac0a7a814a4129841efe3d6
2
+ SHA256:
3
+ metadata.gz: cbf8702009840bc579d652246d0a0540b9ce7bcfc29ebf9d94995cf53c1343db
4
+ data.tar.gz: 4a2b253229620ae6ed2522a03d46105ce266161d116192615ce0bc3ecb396bf2
5
5
  SHA512:
6
- metadata.gz: b8576d1ff169c063a664ae9c538c51ad079ccfae4a604e0c277b84df261dc438b25f912863e5bd9b2ae542bac48026ea8f5a9a0990ccb81265206751b6138a1a
7
- data.tar.gz: 25b04df22331575a7ffbfce5c1acc5168c0ee58e9d15fa4d4f7f3dff309fe1382be5920c315015309217bbf108882d994777cda67376c511c1197d6840952e7e
6
+ metadata.gz: a711c6cfce133aad2892381de2804c4c3fd4f9405d280df85c2c1a496fceb8f77055559637728560ed96afb0cd4588b66a397b331e8006f416ea630501fb27d7
7
+ data.tar.gz: ba768df36f5fd263645dfd5421473bd5b54d71472b808fd279ea844d76e517584123314fd1af4d246f056ec088463fccb45106e71cc80404594af9fdfbd68b06
@@ -1,20 +1,18 @@
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
- attr_writer :interval
12
-
13
- def update_spinner(label)
14
- return unless @show_spinner
15
- @spinner ||= %w[\\ | / -]
16
- print "#{' ' * 30}\r#{label} #{@spinner.rotate!.first}\r"
17
- end
14
+ attr_accessor :interval
15
+ attr_reader :keep_watching
18
16
 
19
17
  def initialize(unexpanded_filenames, options = {})
20
18
  @unexpanded_filenames = unexpanded_filenames
@@ -22,37 +20,46 @@ class Filewatcher
22
20
  @keep_watching = false
23
21
  @pausing = false
24
22
  @immediate = options[:immediate]
25
- @show_spinner = options[:spinner]
26
23
  @interval = options.fetch(:interval, 0.5)
27
- @every = options[:every]
24
+ @logger = options.fetch(:logger, Logger.new($stdout, level: :info))
25
+
26
+ after_initialize unexpanded_filenames, options
28
27
  end
29
28
 
30
29
  def watch(&on_update)
31
- trap('SIGINT') { return }
30
+ ## The set of available signals depends on the OS
31
+ ## Windows doesn't support `HUP` signal, for example
32
+ (%w[HUP INT TERM] & Signal.list.keys).each do |signal|
33
+ trap(signal) { exit }
34
+ end
35
+
32
36
  @on_update = on_update
33
37
  @keep_watching = true
34
- yield('', '') if @immediate
38
+ yield({ '' => '' }) if @immediate
35
39
 
36
40
  main_cycle
37
41
 
38
- @end_snapshot = mtime_snapshot
42
+ @end_snapshot = current_snapshot
39
43
  finalize(&on_update)
40
44
  end
41
45
 
42
46
  def pause
43
47
  @pausing = true
44
- update_spinner('Initiating pause')
48
+
49
+ before_pause_sleep
50
+
45
51
  # Ensure we wait long enough to enter pause loop in #watch
46
52
  sleep @interval
47
53
  end
48
54
 
49
55
  def resume
50
- if !@keep_watching || !@pausing
51
- raise "Can't resume unless #watch and #pause were first called"
52
- end
53
- @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
54
59
  @pausing = false
55
- update_spinner('Resuming')
60
+
61
+ before_resume_sleep
62
+
56
63
  sleep @interval # Wait long enough to exit pause loop in #watch
57
64
  end
58
65
 
@@ -60,7 +67,9 @@ class Filewatcher
60
67
  # Used mainly in multi-threaded situations.
61
68
  def stop
62
69
  @keep_watching = false
63
- update_spinner('Stopping')
70
+
71
+ after_stop
72
+
64
73
  nil
65
74
  end
66
75
 
@@ -68,56 +77,17 @@ class Filewatcher
68
77
  # current snapshot are dealt with
69
78
  def finalize(&on_update)
70
79
  on_update = @on_update unless block_given?
71
- while filesystem_updated?(@end_snapshot || mtime_snapshot)
72
- update_spinner('Finalizing')
80
+
81
+ while file_system_updated?(@end_snapshot || current_snapshot)
82
+ finalizing
73
83
  trigger_changes(on_update)
74
84
  end
75
- @end_snapshot = nil
76
- end
77
85
 
78
- def last_found_filenames
79
- last_snapshot.keys
86
+ @end_snapshot = nil
80
87
  end
81
88
 
82
89
  private
83
90
 
84
- def last_snapshot
85
- @last_snapshot ||= mtime_snapshot
86
- end
87
-
88
- # Takes a snapshot of the current status of watched files.
89
- # (Allows avoidance of potential race condition during #finalize)
90
- def mtime_snapshot
91
- snapshot = {}
92
- filenames = expand_directories(@unexpanded_filenames)
93
-
94
- # Remove files in the exclude filenames list
95
- filenames -= expand_directories(@unexpanded_excluded_filenames)
96
-
97
- filenames.each do |filename|
98
- mtime = File.exist?(filename) ? File.mtime(filename) : Time.new(0)
99
- snapshot[filename] = mtime
100
- end
101
- snapshot
102
- end
103
-
104
- def filesystem_updated?(snapshot = mtime_snapshot)
105
- @changes = {}
106
-
107
- # rubocop:disable Perfomance/HashEachMethods
108
- ## https://github.com/bbatsov/rubocop/issues/4732
109
- (snapshot.to_a - last_snapshot.to_a).each do |file, _mtime|
110
- @changes[file] = last_snapshot[file] ? :updated : :created
111
- end
112
-
113
- (last_snapshot.keys - snapshot.keys).each do |file|
114
- @changes[file] = :deleted
115
- end
116
-
117
- @last_snapshot = snapshot
118
- @changes.any?
119
- end
120
-
121
91
  def expand_directories(patterns)
122
92
  patterns = Array(patterns) unless patterns.is_a? Array
123
93
  expanded_patterns = patterns.map do |pattern|
@@ -130,6 +100,30 @@ class Filewatcher
130
100
  expanded_patterns.uniq!
131
101
  expanded_patterns
132
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
133
127
  end
134
128
 
135
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,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ class Filewatcher
6
+ module SpecHelper
7
+ ## Base class for Filewatcher runners in specs
8
+ class WatchRun
9
+ extend Forwardable
10
+
11
+ TMP_DIR = "#{Dir.getwd}/spec/tmp"
12
+
13
+ def_delegators Filewatcher::SpecHelper, :debug, :wait
14
+
15
+ attr_reader :filename
16
+
17
+ def initialize(filename:, action:, directory:)
18
+ @filename =
19
+ if filename.match? %r{^(/|~|[A-Z]:)} then filename
20
+ else File.join(TMP_DIR, filename)
21
+ end
22
+ @directory = directory
23
+ @action = action
24
+ debug "action = #{action}"
25
+ end
26
+
27
+ def start
28
+ debug 'start'
29
+ File.write(@filename, 'content1') unless @action == :create
30
+
31
+ wait seconds: 1
32
+ end
33
+
34
+ def run(make_changes_times: 1)
35
+ start
36
+
37
+ make_changes_times.times do
38
+ make_changes
39
+
40
+ wait seconds: 2
41
+ end
42
+
43
+ stop
44
+ end
45
+
46
+ def stop
47
+ debug 'stop'
48
+ FileUtils.rm_r(@filename) if File.exist?(@filename)
49
+ end
50
+
51
+ private
52
+
53
+ def make_changes
54
+ debug "make changes, @action = #{@action}, @filename = #{@filename}"
55
+
56
+ if @action == :delete
57
+ FileUtils.remove(@filename)
58
+ elsif @directory
59
+ FileUtils.mkdir_p(@filename)
60
+ else
61
+ ## There is no `File.write` because of strange difference in parallel `File.mtime`
62
+ ## https://cirrus-ci.com/task/6107605053472768?command=test#L497-L511
63
+ system "echo 'content2' > #{@filename}"
64
+ debug_file_mtime
65
+ end
66
+
67
+ wait seconds: 1
68
+ end
69
+
70
+ def debug_file_mtime
71
+ debug "stat #{@filename}: #{Filewatcher.system_stat(@filename)}"
72
+ debug "File.mtime = #{File.mtime(@filename).strftime('%F %T.%9N')}"
73
+ end
74
+ end
75
+ end
76
+ end