filewatcher 1.0.0 → 2.0.0.beta2

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: 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