fun_with_files 0.0.15 → 0.0.18
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 +5 -5
- data/CHANGELOG.markdown +15 -3
- data/Gemfile +17 -7
- data/{README.rdoc → README.markdown} +11 -10
- data/VERSION +1 -1
- data/lib/fun_with/files/bootstrapper.rb +87 -0
- data/lib/fun_with/files/digest_methods.rb +30 -16
- data/lib/fun_with/files/directory_builder.rb +4 -0
- data/lib/fun_with/files/downloader.rb +3 -19
- data/lib/fun_with/files/errors.rb +9 -1
- data/lib/fun_with/files/file_manipulation_methods.rb +25 -15
- data/lib/fun_with/files/file_path.rb +147 -150
- data/lib/fun_with/files/file_path_class_methods.rb +23 -2
- data/lib/fun_with/files/file_permission_methods.rb +18 -7
- data/lib/fun_with/files/file_requirements.rb +63 -7
- data/lib/fun_with/files/requirements/manager.rb +104 -0
- data/lib/fun_with/files/root_path.rb +3 -3
- data/lib/fun_with/files/stat_methods.rb +33 -0
- data/lib/fun_with/files/string_behavior.rb +6 -2
- data/lib/fun_with/files/utils/byte_size.rb +143 -0
- data/lib/fun_with/files/utils/opts.rb +26 -0
- data/lib/fun_with/files/utils/succession.rb +47 -0
- data/lib/fun_with/files/utils/timestamp.rb +47 -0
- data/lib/fun_with/files/utils/timestamp_format.rb +31 -0
- data/lib/fun_with/files/watcher.rb +157 -0
- data/lib/fun_with/files/watchers/directory_watcher.rb +67 -0
- data/lib/fun_with/files/watchers/file_watcher.rb +45 -0
- data/lib/fun_with/files/watchers/missing_watcher.rb +23 -0
- data/lib/fun_with/files/watchers/node_watcher.rb +44 -0
- data/lib/fun_with/testing/assertions/fun_with_files.rb +91 -0
- data/lib/fun_with/testing/test_case_extensions.rb +12 -0
- data/lib/fun_with_files.rb +5 -75
- data/test/helper.rb +13 -5
- data/test/test_core_extensions.rb +5 -0
- data/test/test_directory_builder.rb +29 -10
- data/test/test_extension_methods.rb +62 -0
- data/test/test_file_manipulation.rb +2 -2
- data/test/test_file_path.rb +18 -39
- data/test/test_file_requirements.rb +36 -0
- data/test/test_fun_with_files.rb +1 -1
- data/test/test_fwf_assertions.rb +62 -0
- data/test/test_moving_files.rb +111 -0
- data/test/test_permission_methods.rb +22 -0
- data/test/test_root_path.rb +9 -0
- data/test/test_stat_methods.rb +17 -0
- data/test/test_timestamping.rb +74 -0
- data/test/test_utils_bytesize.rb +71 -0
- data/test/test_utils_succession.rb +30 -0
- data/test/test_watchers.rb +196 -0
- metadata +54 -16
@@ -0,0 +1,104 @@
|
|
1
|
+
module FunWith
|
2
|
+
module Files
|
3
|
+
module Requirements
|
4
|
+
class Manager
|
5
|
+
def self.require_files( path )
|
6
|
+
self.new( path ).require_files
|
7
|
+
end
|
8
|
+
|
9
|
+
def initialize( path )
|
10
|
+
case path
|
11
|
+
when Array
|
12
|
+
@required_files = path
|
13
|
+
when FilePath
|
14
|
+
if path.directory?
|
15
|
+
@required_files = path.glob( :recursive => true, :ext => "rb" )
|
16
|
+
else
|
17
|
+
@required_files = [path]
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
@required_files.map!{|f| f.expand.gsub( /\.rb$/, '' ) }
|
22
|
+
@successfully_required = []
|
23
|
+
@missing_constants = {}
|
24
|
+
end
|
25
|
+
|
26
|
+
def require_files
|
27
|
+
while @required_files.length > 0
|
28
|
+
file = @required_files.shift
|
29
|
+
|
30
|
+
if try_requiring_file( file )
|
31
|
+
check_for_needed_constants
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Ran into a situation where it was failing because the missing constant was incorrectly being guessed
|
36
|
+
# to be M1::M2::M3::M4 instead of M1::M2::M4. Had the file been required, it would have gone through.
|
37
|
+
# So I'm adding a last-chance round, using the ugly old approach of simply trying to require everything
|
38
|
+
# over and over again until it's clear no progress ins being made.
|
39
|
+
unless @missing_constants.fwf_blank?
|
40
|
+
unless require_files_messily( @missing_constants.values.flatten )
|
41
|
+
raise NameError.new( "The following constants could not be defined: #{@missing_constants.inspect}")
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# If it's not the sort of error we're looking for, re-raise the error
|
47
|
+
def uninitialized_constant_error( e, &block )
|
48
|
+
if e.message =~ /^uninitialized constant/
|
49
|
+
yield
|
50
|
+
else
|
51
|
+
raise e
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def try_requiring_file( file )
|
56
|
+
begin
|
57
|
+
require file
|
58
|
+
@successfully_required << file
|
59
|
+
true
|
60
|
+
rescue NameError => e
|
61
|
+
uninitialized_constant_error( e ) do
|
62
|
+
konst = e.message.split.last
|
63
|
+
|
64
|
+
@missing_constants[konst] ||= []
|
65
|
+
@missing_constants[konst] << file
|
66
|
+
false
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def check_for_needed_constants
|
72
|
+
for konst, files in @missing_constants
|
73
|
+
if Object.const_defined?( konst )
|
74
|
+
@required_files = files + @required_files
|
75
|
+
@missing_constants.delete( konst )
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# returns true if all the files given got required
|
81
|
+
def require_files_messily( files )
|
82
|
+
while true
|
83
|
+
files_remaining = files.length
|
84
|
+
return true if files_remaining == 0
|
85
|
+
|
86
|
+
files.length.times do
|
87
|
+
begin
|
88
|
+
file = files.shift
|
89
|
+
require file
|
90
|
+
@successfully_required << file
|
91
|
+
rescue NameError => e
|
92
|
+
uninitialized_constant_error( e ) do
|
93
|
+
files.push( file )
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
return false if files.length == files_remaining
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -1,12 +1,12 @@
|
|
1
1
|
module FunWith
|
2
2
|
module Files
|
3
3
|
module RootPathExtensions
|
4
|
-
def root( *args )
|
4
|
+
def root( *args, &block )
|
5
5
|
if args.length > 0
|
6
6
|
args.unshift( @root_path )
|
7
|
-
FilePath.new( *args )
|
7
|
+
FilePath.new( *args, &block )
|
8
8
|
else
|
9
|
-
FilePath.new( @root_path )
|
9
|
+
FilePath.new( @root_path, &block )
|
10
10
|
end
|
11
11
|
end
|
12
12
|
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module FunWith
|
2
|
+
module Files
|
3
|
+
module StatMethods
|
4
|
+
def stat
|
5
|
+
File.stat( self )
|
6
|
+
end
|
7
|
+
|
8
|
+
def inode
|
9
|
+
self.stat.ino
|
10
|
+
end
|
11
|
+
|
12
|
+
# def older_than?( time, &block )
|
13
|
+
# end
|
14
|
+
#
|
15
|
+
# def newer_than?( time, &block )
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
# def bigger_than?( sz, units = :B, &block )
|
19
|
+
# end
|
20
|
+
#
|
21
|
+
# def smaller_than?( sz, units = :B, &block )
|
22
|
+
# end
|
23
|
+
#
|
24
|
+
# def modified_before?( time, &block )
|
25
|
+
# end
|
26
|
+
#
|
27
|
+
# def modified_since?( time, &block )
|
28
|
+
# end
|
29
|
+
|
30
|
+
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,143 @@
|
|
1
|
+
module FunWith
|
2
|
+
module Files
|
3
|
+
module Utils
|
4
|
+
module ByteSize
|
5
|
+
"ideas"
|
6
|
+
"format: %u - units (lowercase), %k - units (uppercase), %b - units (lower, with b), %B"
|
7
|
+
" %3 - humanized value, three sigfigs, meaning that if it's under 10, you get '7.32'"
|
8
|
+
" but if it's over 100, rounds to the nearest full number. 10-99, it goes"
|
9
|
+
" down to the tenths prare"
|
10
|
+
"or just give an example: 7.32 kb"
|
11
|
+
|
12
|
+
# format: "your filesize is (%n.nn %u)"
|
13
|
+
|
14
|
+
|
15
|
+
UNITS = {
|
16
|
+
:B => 1,
|
17
|
+
:KB => 1_000,
|
18
|
+
:MB => 1_000_000,
|
19
|
+
:GB => 1_000_000_000,
|
20
|
+
:TB => 1_000_000_000_000,
|
21
|
+
:PB => 1_000_000_000_000_000,
|
22
|
+
:EB => 1_000_000_000_000_000_000,
|
23
|
+
:ZB => 1_000_000_000_000_000_000_000
|
24
|
+
}
|
25
|
+
|
26
|
+
UNIT_STANDARDIZERS = { "" => :B, "B" => :B, "b" => :B }
|
27
|
+
|
28
|
+
for s in %w(K M G T P E Z KB MB GB TB PB EB ZB)
|
29
|
+
unit_sym = s.length == 1 ? :"#{s}B" : :"#{s}"
|
30
|
+
UNIT_STANDARDIZERS[s] = unit_sym
|
31
|
+
UNIT_STANDARDIZERS[s.downcase] = unit_sym
|
32
|
+
UNIT_STANDARDIZERS[s.to_sym] = unit_sym
|
33
|
+
end
|
34
|
+
|
35
|
+
def convert( expr, units = :B )
|
36
|
+
to_units( to_bytes( expr ), units )
|
37
|
+
end
|
38
|
+
|
39
|
+
# Takes a string of the form "<NUMBER><UNIT>"
|
40
|
+
# and returns the number of bytes represented.
|
41
|
+
# See UNITS constant for valid constants
|
42
|
+
def to_bytes( expr )
|
43
|
+
regexp = /^\s*(?<num>\d+(\.\d+)?)\s*(?<unit>(k|m|g|t|p|z|)b?)\s*$/i
|
44
|
+
|
45
|
+
if m = expr.upcase.match( regexp )
|
46
|
+
num = m[:num].to_f
|
47
|
+
units = standardize_unit( m[:unit] )
|
48
|
+
# units = case units.length
|
49
|
+
# when 0
|
50
|
+
# :B
|
51
|
+
# when 1
|
52
|
+
# (units == "B" ? units : units + "B").to_sym
|
53
|
+
# when 2
|
54
|
+
# units.to_sym
|
55
|
+
# end
|
56
|
+
debugger unless UNITS.has_key?(units)
|
57
|
+
(num * UNITS[units]).to_i
|
58
|
+
else
|
59
|
+
raise ArgumentError.new( "#{expr} is not in a format that to_bytes recognizes")
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# Looking for a human-friendly vibe more than accuracy.
|
64
|
+
# At most one unit of post-decimal precision, and only
|
65
|
+
# for small numbers. If the tenths place is a zero,
|
66
|
+
# the trailing zero is dropped.
|
67
|
+
def to_units( byte_count, unit )
|
68
|
+
num = byte_count.to_f / UNITS[standardize_unit(unit)]
|
69
|
+
# the first comparison gets rid of leading zeros
|
70
|
+
# the second comparison prevents the decimal from being printed
|
71
|
+
# when it doesn't make a big difference
|
72
|
+
if num == num.to_i || num >= 100 # 9.9k 10k
|
73
|
+
num_str = num.to_i.to_s
|
74
|
+
else
|
75
|
+
num_str = sprintf( "%0.01f", num )
|
76
|
+
end
|
77
|
+
|
78
|
+
num_str = num_str[0..-3] if num_str[-2..-1] == ".0"
|
79
|
+
|
80
|
+
num_str + unit.to_s
|
81
|
+
end
|
82
|
+
|
83
|
+
def standardize_unit( unit )
|
84
|
+
# So the caller can add a space if desired, but ultimately it might be
|
85
|
+
# better to offer more flexible formatting options.
|
86
|
+
unit = unit.strip if unit.respond_to?(:strip)
|
87
|
+
|
88
|
+
if UNIT_STANDARDIZERS.has_key?( unit )
|
89
|
+
UNIT_STANDARDIZERS[unit]
|
90
|
+
else
|
91
|
+
raise ArgumentError.new( "ByteSize.to_units doesn't understand the unit #{unit.inspect}(unit.class)" )
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
|
96
|
+
def humanize_bytes( bytes )
|
97
|
+
return "?" unless bytes.is_a?( Integer ) && bytes >= 0
|
98
|
+
|
99
|
+
bytes = bytes.to_f
|
100
|
+
|
101
|
+
if bytes > 1_000_000_000
|
102
|
+
exp = "G"
|
103
|
+
amt = bytes / 1_000_000_000
|
104
|
+
elsif bytes > 1_000_000
|
105
|
+
exp = "M"
|
106
|
+
amt = bytes / 1_000_000
|
107
|
+
elsif bytes > 1_000
|
108
|
+
exp = "K"
|
109
|
+
amt = bytes / 1_000
|
110
|
+
else
|
111
|
+
exp = "B"
|
112
|
+
amt = bytes
|
113
|
+
end
|
114
|
+
|
115
|
+
if amt > 10
|
116
|
+
digits = 0
|
117
|
+
elsif amt > 1
|
118
|
+
digits = 1
|
119
|
+
end
|
120
|
+
|
121
|
+
sprintf( "%0.#{digits}f", amt ) + exp
|
122
|
+
end
|
123
|
+
|
124
|
+
# returns a string of numbers, representing the float
|
125
|
+
# d - number of figures after the zero (max)
|
126
|
+
def limited_precision_value( f, d )
|
127
|
+
# 4, 1234.5 -> 1234
|
128
|
+
# 4, 123.45 -> 123.4
|
129
|
+
# 4, 12.3423 -> 12.34
|
130
|
+
# 4, 0.0001 -> 0.0001 -> - 1234.5, 123.45, 12.345, 1.2345 0.1234 0.0123
|
131
|
+
# 2 - 1234, 123, 12.3, 1.23, 0.12, 0.01
|
132
|
+
# 1 - 12.3, 12, 1
|
133
|
+
# 0 - 12, 1, 0
|
134
|
+
#
|
135
|
+
#
|
136
|
+
#
|
137
|
+
#
|
138
|
+
#
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module FunWith
|
2
|
+
module Files
|
3
|
+
module Utils
|
4
|
+
class Opts
|
5
|
+
# It's tradition to pass an options hash as the last argument (creaky old tradition, named variables getting more popular)
|
6
|
+
# Separates out that last configuration hash, if it's been given.
|
7
|
+
def self.extract_opts_from_args( args )
|
8
|
+
if args.last.is_a?( Hash )
|
9
|
+
[args[0..-2], args.last ]
|
10
|
+
else
|
11
|
+
[args, {}]
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
# Given a hash and a list of keys, return a hash that only includes the keys listed.
|
16
|
+
def self.narrow_options( opts, keys )
|
17
|
+
opts.keep_if{ |k,v| keys.include?( k ) }
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.narrow_file_utils_options( opts, cmd )
|
21
|
+
self.narrow_options( opts, FileUtils::OPT_TABLE[ cmd.to_s ] )
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module FunWith
|
2
|
+
module Files
|
3
|
+
module Utils
|
4
|
+
class Succession
|
5
|
+
def self.get_successor_name( basename, digit_count )
|
6
|
+
pieces = basename.to_s.split(".")
|
7
|
+
|
8
|
+
if pieces.length == 0
|
9
|
+
pieces = [ self.format_counter( 0, digit_count ) ]
|
10
|
+
elsif is_counter?( pieces.last, digit_count )
|
11
|
+
pieces = self.increment_position( pieces, pieces.length - 1 )
|
12
|
+
elsif is_counter?( pieces[-2], digit_count )
|
13
|
+
pieces = self.increment_position( pieces, pieces.length - 2 )
|
14
|
+
else
|
15
|
+
pieces = self.install_counter( pieces, digit_count )
|
16
|
+
end
|
17
|
+
|
18
|
+
pieces.join(".")
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.is_counter?( str, digit_count )
|
22
|
+
return false if str.nil?
|
23
|
+
(str =~ /^\d{#{digit_count}}$/) != nil
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.format_counter( i, len )
|
27
|
+
sprintf( "%0#{len}i", i )
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.increment_position( arr, pos_to_increment )
|
31
|
+
arr.map.each_with_index do |str, i|
|
32
|
+
if i == pos_to_increment
|
33
|
+
self.format_counter( str.to_i + 1, str.length )
|
34
|
+
else
|
35
|
+
str
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.install_counter( arr, count )
|
41
|
+
counter = self.format_counter( 0, count )
|
42
|
+
arr[0..-2] + [counter] + [arr[-1]]
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module FunWith
|
2
|
+
module Files
|
3
|
+
module Utils
|
4
|
+
class Timestamp
|
5
|
+
# Currently exactly one format is supported. Laaaaame!
|
6
|
+
|
7
|
+
def self.format( key )
|
8
|
+
@formats ||= {
|
9
|
+
:default => TimestampFormat.new.recognizer( /^\d{17}$/ ).strftime("%Y%m%d%H%M%S%L"),
|
10
|
+
:ymd => TimestampFormat.new.recognizer( /^\d{4}-\d{2}-\d{2}$/ ).strftime("%Y-%m-%d"),
|
11
|
+
:ym => TimestampFormat.new.recognizer( /^\d{4}-\d{2}$/ ).strftime("%Y-%m"),
|
12
|
+
:y => TimestampFormat.new.recognizer( /^\d{4}$/ ).strftime("%Y"),
|
13
|
+
|
14
|
+
# UNIX timestamp
|
15
|
+
:s => TimestampFormat.new.recognizer( /^\d{10}$/ ).strftime("%s")
|
16
|
+
}
|
17
|
+
|
18
|
+
if @formats.has_key?(key)
|
19
|
+
@formats[key]
|
20
|
+
else
|
21
|
+
raise Errors::TimestampFormatUnrecognized.new( "Unrecognized timestamp format (#{key.inspect}). Choose from #{@formats.keys.inspect}" )
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.timestamp( basename, format: :default, splitter: ".", time: Time.now )
|
26
|
+
filename_chunks = basename.to_s.split( splitter )
|
27
|
+
format = format.is_a?( TimestampFormat ) ? format : self.format( format )
|
28
|
+
new_timestamp = format.format_time( time )
|
29
|
+
|
30
|
+
timestamp_index = filename_chunks.map.each_with_index{ |str,i|
|
31
|
+
format.matches?( str ) ? i : nil
|
32
|
+
}.compact.last
|
33
|
+
|
34
|
+
if timestamp_index
|
35
|
+
filename_chunks[timestamp_index] = new_timestamp
|
36
|
+
elsif filename_chunks.length == 1
|
37
|
+
filename_chunks << new_timestamp
|
38
|
+
else
|
39
|
+
filename_chunks.insert( -2, new_timestamp )
|
40
|
+
end
|
41
|
+
|
42
|
+
filename_chunks.join( splitter )
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module FunWith
|
2
|
+
module Files
|
3
|
+
module Utils
|
4
|
+
class TimestampFormat
|
5
|
+
# the timestamp identifies a chunk of the filename
|
6
|
+
# to be its kind of timestamp by checking it against a
|
7
|
+
# regular expression.
|
8
|
+
def recognizer( regex )
|
9
|
+
@recognizer = regex
|
10
|
+
self
|
11
|
+
end
|
12
|
+
|
13
|
+
# The strftime format used to output the timestamp
|
14
|
+
def strftime( s )
|
15
|
+
@strftime_format = s
|
16
|
+
self
|
17
|
+
end
|
18
|
+
|
19
|
+
def format_time( t )
|
20
|
+
t.strftime( @strftime_format )
|
21
|
+
end
|
22
|
+
|
23
|
+
# does the given chunk look like a timestamp using this format?
|
24
|
+
# returns true or false.
|
25
|
+
def matches?( str, &block )
|
26
|
+
@recognizer.match( str ) != nil
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,157 @@
|
|
1
|
+
# You want to watch a list of files or directories for changes
|
2
|
+
# (files created, deleted, or modified), and then return a... list?
|
3
|
+
# of changes.
|
4
|
+
#
|
5
|
+
# Tricky part is, what constitutes a "change" for a directory.
|
6
|
+
# or a file, for that matter.
|
7
|
+
#
|
8
|
+
# Cool expansion: customize the sorts of changes that trigger actions.
|
9
|
+
# You can already do that to some extent with the user-code that the
|
10
|
+
# change list is delivered to
|
11
|
+
#
|
12
|
+
# usr/ (modified, watched)
|
13
|
+
# bin/ (modified)
|
14
|
+
# new_directory/ (created)
|
15
|
+
# README.txt (created)
|
16
|
+
# bash (unchanged)
|
17
|
+
#
|
18
|
+
# lib/ (modified)
|
19
|
+
# libgpg.so (unchanged)
|
20
|
+
# libmysql.so (deleted)
|
21
|
+
# libzip.so (modified)
|
22
|
+
# libjpeg.so (file_created)
|
23
|
+
# you_have_been_hacked_by_chinese.txt (file_created)
|
24
|
+
# cache/ (deleted)
|
25
|
+
# firefox/ (deleted)
|
26
|
+
# cached_item.jpg (deleted)
|
27
|
+
# cached_folder/ (deleted)
|
28
|
+
# cache_file.csv (deleted)
|
29
|
+
#
|
30
|
+
# When you find a change in a subdirector/file, the :modified status
|
31
|
+
# propagates up the tree.
|
32
|
+
# Feels like a job for a visitor.
|
33
|
+
#
|
34
|
+
# If you create the top-level watcher, it could create any sub-watchers
|
35
|
+
# for the files and folders. It asks its watchers to update their
|
36
|
+
# statuses and report back.
|
37
|
+
#
|
38
|
+
# But the top-level one should know that it's the top level, so it
|
39
|
+
# shouldn't be deleting its watchers that might be, for example,
|
40
|
+
# waiting for a file to come into being.
|
41
|
+
#
|
42
|
+
# Hence, anyone using this code probably ought to stick to using the main Watcher
|
43
|
+
# class, and not worry about the ones it uses in the background
|
44
|
+
#
|
45
|
+
# Filters can be added
|
46
|
+
module FunWith
|
47
|
+
module Files
|
48
|
+
class Watcher
|
49
|
+
def self.watch( *paths, interval: 1.0, notice: [], ignore: [], &block )
|
50
|
+
watcher = self.new( paths ).sleep_interval( interval ).filter( notice: notice, ignore: ignore )
|
51
|
+
|
52
|
+
if block_given?
|
53
|
+
watcher.watch( &block )
|
54
|
+
else
|
55
|
+
watcher
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def self.factory( path )
|
60
|
+
path = path.fwf_filepath
|
61
|
+
|
62
|
+
if path.exist?
|
63
|
+
if path.directory?
|
64
|
+
Watchers::DirectoryWatcher.new( path )
|
65
|
+
elsif path.file?
|
66
|
+
Watchers::FileWatcher.new( path )
|
67
|
+
end
|
68
|
+
else
|
69
|
+
Watchers::MissingWatcher.new( path )
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def initialize( paths )
|
74
|
+
@sleep_interval = 1.0
|
75
|
+
@notice_filters = []
|
76
|
+
@ignore_filters = []
|
77
|
+
|
78
|
+
# Create a watcher for every single thing that we're
|
79
|
+
# asking it to watch
|
80
|
+
@watchers = paths.inject({}) do |watchers, path|
|
81
|
+
watchers[path.fwf_filepath] = self.class.factory( path )
|
82
|
+
watchers
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def sleep_interval( i )
|
87
|
+
@sleep_interval = i
|
88
|
+
self
|
89
|
+
end
|
90
|
+
|
91
|
+
def watch( &block )
|
92
|
+
while true
|
93
|
+
sleep( @sleep_interval )
|
94
|
+
yield self.update
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
def filter( notice: [], ignore: [] )
|
99
|
+
@notice_filters += [notice].flatten
|
100
|
+
@ignore_filters += [ignore].flatten
|
101
|
+
|
102
|
+
self
|
103
|
+
end
|
104
|
+
|
105
|
+
# returns a hash of the changes that have happened in the file system being monitored,
|
106
|
+
def update
|
107
|
+
{}.tap do |changes|
|
108
|
+
for path, watcher in @watchers
|
109
|
+
changes.merge!( watcher.update )
|
110
|
+
replace_watcher( path, changes[path] ) # a DirectoryWatcher might need to be replaced with a MissingWatcher, for example, or vice-versa
|
111
|
+
|
112
|
+
# corner case: if a directory is created, everything created under the directory
|
113
|
+
# is deemed to have also been created at the same time
|
114
|
+
if path.directory? && changes[path] == :created
|
115
|
+
changes.merge!( path.glob(:all).inject({}){ |memo,path| memo[path] = :created ; memo } )
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
apply_filters( changes )
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def replace_watcher( path, change )
|
124
|
+
case change
|
125
|
+
when nil
|
126
|
+
# didn't change
|
127
|
+
when :deleted, :created
|
128
|
+
@watchers[path] = self.class.factory( path )
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
#
|
133
|
+
def apply_filters( changes )
|
134
|
+
apply_notice_filters( changes )
|
135
|
+
apply_ignore_filters( changes )
|
136
|
+
changes
|
137
|
+
end
|
138
|
+
|
139
|
+
def apply_notice_filters( changes )
|
140
|
+
for filter in @notice_filters
|
141
|
+
for path in changes.keys
|
142
|
+
changes.delete( path ) if path !~ filter
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
def apply_ignore_filters( changes )
|
148
|
+
for filter in @ignore_filters
|
149
|
+
for path in changes.keys
|
150
|
+
changes.delete( path ) if path =~ filter
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
@@ -0,0 +1,67 @@
|
|
1
|
+
module FunWith
|
2
|
+
module Files
|
3
|
+
module Watchers
|
4
|
+
class DirectoryWatcher < NodeWatcher
|
5
|
+
def initialize( path )
|
6
|
+
set_path( path )
|
7
|
+
@watchers = create_watchers( self.path.entries )
|
8
|
+
end
|
9
|
+
|
10
|
+
|
11
|
+
# returns a hash of changes
|
12
|
+
def update
|
13
|
+
new_changeset do
|
14
|
+
if self.path.exist?
|
15
|
+
update_existing_files
|
16
|
+
find_new_files
|
17
|
+
else
|
18
|
+
# If the directory is gone, you can assume the same is true
|
19
|
+
# for all the files it held.
|
20
|
+
report_files( self.all_paths, :deleted )
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def update_existing_files
|
26
|
+
# first, check on the files we're supposed to be keeping track of
|
27
|
+
for path, watcher in @watchers
|
28
|
+
@changes[path] = :deleted unless path.exist?
|
29
|
+
@changes.merge!( watcher.update )
|
30
|
+
|
31
|
+
# While the main Watcher will continue to monitor the places it's
|
32
|
+
# been told, even if they're missing, the subwatchers just disappear
|
33
|
+
# when the files they're watching do.
|
34
|
+
@watchers.delete( path ) unless path.exist?
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def find_new_files
|
39
|
+
# next, get the updated list of files/folders beneath this directory
|
40
|
+
current_paths = self.path.entries
|
41
|
+
|
42
|
+
for path in current_paths
|
43
|
+
unless @watchers.has_key?( path )
|
44
|
+
w = Watcher.factory( path )
|
45
|
+
|
46
|
+
report_files( w.all_paths, :created )
|
47
|
+
|
48
|
+
@watchers[path] = w
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# modify the current list of changes by adding "deleted" for
|
54
|
+
# every file/folder below this one.
|
55
|
+
def report_files( paths, status )
|
56
|
+
for path in paths
|
57
|
+
@changes[path] = status
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def all_paths
|
62
|
+
@watchers.map{|path, watcher| watcher.all_paths }.flatten + [self.path]
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|