sugar_utils 0.5.0 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aruba/cucumber'
4
+
5
+ # @see spec/spec_helper.rb
6
+ RSpec::Matchers.define :have_owner do |expected|
7
+ match do |actual|
8
+ next false unless File.exist?(actual)
9
+
10
+ @actual = Etc.getpwuid(File.stat(filename).uid).name
11
+ @expected = expected
12
+ values_match?(@expected, @actual)
13
+ end
14
+ end
15
+
16
+ RSpec::Matchers.define :have_group do |expected|
17
+ match do |actual|
18
+ next false unless File.exist?(actual)
19
+
20
+ @actual = Etc.getgrgid(File.stat(actual).gid).name
21
+ @expected = expected
22
+ values_match?(@expected, @actual)
23
+ end
24
+ end
25
+
26
+ RSpec::Matchers.define :have_mtime do |expected|
27
+ match do |actual|
28
+ next false unless File.exist?(actual)
29
+
30
+ @actual = File.stat(actual).mtime.to_i
31
+ @expected = expected
32
+ values_match?(@expected, @actual)
33
+ end
34
+ end
@@ -0,0 +1,28 @@
1
+ Feature: Touch a file
2
+
3
+ Scenario: Touch a file
4
+ When I run the following Ruby code:
5
+ """ruby
6
+ require 'sugar_utils'
7
+ puts SugarUtils::File.touch('dir/test')
8
+ """
9
+ Then the file named "dir/test" should exist
10
+
11
+ # TODO: Fix the owner/group setting check
12
+ Scenario: Touch a file and reset its permissions and mtime
13
+ When I run the following Ruby code:
14
+ """ruby
15
+ require 'sugar_utils'
16
+ puts SugarUtils::File.touch(
17
+ 'dir/test',
18
+ # owner: 'nobody',
19
+ # group: 'nogroup',
20
+ mode: 0o777,
21
+ mtime: 0
22
+ )
23
+ """
24
+ Then the file named "dir/test" should exist
25
+ And the file named "dir/test" should have permissions "777"
26
+ And the file named "dir/test" should have modification time "0"
27
+ # And the file named "dir/test" should have owner "nobody"
28
+ # And the file named "dir/test" should have group "nogroup"
@@ -0,0 +1,46 @@
1
+ Feature: Write to a file
2
+
3
+ Scenario: Write a file
4
+ When I run the following Ruby code:
5
+ """ruby
6
+ require 'sugar_utils'
7
+ puts SugarUtils::File.write('dir/test', 'foobar')
8
+ """
9
+ Then the file named "dir/test" should contain exactly:
10
+ """
11
+ foobar
12
+ """
13
+
14
+ Scenario: Overwrite a file
15
+ Given a file named "dir/test" with "deadbeef"
16
+ When I run the following Ruby code:
17
+ """ruby
18
+ require 'sugar_utils'
19
+ puts SugarUtils::File.write('dir/test', 'foobar')
20
+ """
21
+ Then the file named "dir/test" should contain exactly:
22
+ """
23
+ foobar
24
+ """
25
+
26
+ # TODO: Fix the owner/group setting check
27
+ Scenario: Overwrite a file and reset its permissions
28
+ Given a file named "dir/test" with "deadbeef"
29
+ When I run the following Ruby code:
30
+ """ruby
31
+ require 'sugar_utils'
32
+ puts SugarUtils::File.write(
33
+ 'dir/test',
34
+ 'foobar',
35
+ # owner: 'nobody',
36
+ # group: 'nogroup',
37
+ mode: 0o777
38
+ )
39
+ """
40
+ Then the file named "dir/test" should contain exactly:
41
+ """
42
+ foobar
43
+ """
44
+ And the file named "dir/test" should have permissions "777"
45
+ # And the file named "dir/test" should have owner "nobody"
46
+ # And the file named "dir/test" should have group "nogroup"
@@ -0,0 +1,52 @@
1
+ Feature: Write JSON to a file
2
+
3
+ Scenario: Write a file
4
+ When I run the following Ruby code:
5
+ """ruby
6
+ require 'sugar_utils'
7
+ MultiJson.use(:ok_json)
8
+
9
+ puts SugarUtils::File.write_json('dir/test.json', { key: :value })
10
+ """
11
+ Then the file named "dir/test.json" should contain exactly:
12
+ """
13
+ {"key":"value"}
14
+ """
15
+
16
+ Scenario: Overwrite a file
17
+ Given a file named "dir/test.json" with "deadbeef"
18
+ When I run the following Ruby code:
19
+ """ruby
20
+ require 'sugar_utils'
21
+ MultiJson.use(:ok_json)
22
+
23
+ puts SugarUtils::File.write_json('dir/test.json', { key: :value })
24
+ """
25
+ Then the file named "dir/test.json" should contain exactly:
26
+ """
27
+ {"key":"value"}
28
+ """
29
+
30
+ # TODO: Fix the owner/group setting check
31
+ Scenario: Overwrite a file and reset its permissions
32
+ Given a file named "dir/test.json" with "deadbeef"
33
+ When I run the following Ruby code:
34
+ """ruby
35
+ require 'sugar_utils'
36
+ MultiJson.use(:ok_json)
37
+
38
+ puts SugarUtils::File.write_json(
39
+ 'dir/test.json',
40
+ { key: :value },
41
+ # owner: 'nobody',
42
+ # group: 'nogroup',
43
+ mode: 0o777
44
+ )
45
+ """
46
+ Then the file named "dir/test.json" should contain exactly:
47
+ """
48
+ {"key":"value"}
49
+ """
50
+ And the file named "dir/test.json" should have permissions "777"
51
+ # And the file named "dir/test.json" should have owner "nobody"
52
+ # And the file named "dir/test.json" should have group "nogroup"
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SugarUtils
4
+ module File
5
+ # @api private
6
+ class WriteOptions
7
+ # @parma filename [String]
8
+ # @param options [Hash]
9
+ def initialize(filename, options)
10
+ @filename = filename
11
+ @options = options
12
+
13
+ return unless filename && ::File.exist?(filename)
14
+
15
+ file_stat = ::File::Stat.new(filename)
16
+ @existing_owner = file_stat.uid
17
+ @existing_group = file_stat.gid
18
+ end
19
+
20
+ # @return [Boolean]
21
+ def flush?
22
+ @options[:flush] || false
23
+ end
24
+
25
+ # @overload perm
26
+ # The default permission is 0o644
27
+ # @overload perm(default_value)
28
+ # @param default_value [nil, Integer]
29
+ # Override the default_value including allowing nil.
30
+ #
31
+ # @return [Integer]
32
+ def perm(default_value = 0o644)
33
+ # NOTE: We are using the variable name 'perm' because that is the name
34
+ # of the argument used by File.open.
35
+ @options[:mode] || @options[:perm] || default_value
36
+ end
37
+
38
+ # @return [String, Integer]
39
+ def owner
40
+ @options[:owner] || @existing_owner
41
+ end
42
+
43
+ # @return [String, Intekuuger]
44
+ def group
45
+ @options[:group] || @existing_group
46
+ end
47
+
48
+ # @param keys [Array]
49
+ # @return [Hash]
50
+ def slice(*args)
51
+ keys = args.flatten.compact
52
+ @options.select { |k| keys.include?(k) }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,17 +1,20 @@
1
- # encoding : utf-8
2
1
  # frozen_string_literal: true
3
2
 
4
3
  require 'solid_assert'
5
4
  require 'fileutils'
6
5
  require 'multi_json'
7
6
  require 'timeout'
7
+ require 'tempfile'
8
+
9
+ require 'sugar_utils/file/write_options'
8
10
 
9
11
  module SugarUtils
10
- module File
12
+ # @api
13
+ module File # rubocop:disable Metrics/ModuleLength
11
14
  class Error < StandardError; end
12
15
 
13
- # @param [File] file
14
- # @param [Hash] options
16
+ # @param file [File]
17
+ # @param options [Hash]
15
18
  # @option options [Integer] :timeout (10)
16
19
  #
17
20
  # @raise [Timeout::Error]
@@ -22,8 +25,8 @@ module SugarUtils
22
25
  Timeout.timeout(timeout) { file.flock(::File::LOCK_SH) }
23
26
  end
24
27
 
25
- # @param [File] file
26
- # @param [Hash] options
28
+ # @param file [File]
29
+ # @param options [Hash]
27
30
  # @option options [Integer] :timeout (10)
28
31
  #
29
32
  # @raise [Timeout::Error]
@@ -34,8 +37,34 @@ module SugarUtils
34
37
  Timeout.timeout(timeout) { file.flock(::File::LOCK_EX) }
35
38
  end
36
39
 
37
- # @param [String] filename
38
- # @param [Hash] options
40
+ # Change all of the access values for the specified file including:
41
+ # * owner
42
+ # * group
43
+ # * permissions
44
+ #
45
+ # @note Although the are all required, nil can be passed to any of them and
46
+ # those nils will be skipped. Hopefully, this will avoid conditions in the
47
+ # calling code because the optional parameters will just be passed in and
48
+ # skipped when they are missing.
49
+ #
50
+ # @param filename [String]
51
+ # @param owner [nil, Integer, String]
52
+ # @param group [nil, Integer, String]
53
+ # @param permission [nil, Integer]
54
+ #
55
+ # @raise [SugarUtils::File::Error]
56
+ #
57
+ # @return [void]
58
+ def self.change_access(filename, owner, group, permission)
59
+ FileUtils.chown(owner, group, filename)
60
+ FileUtils.chmod(permission, filename) if permission
61
+ nil
62
+ rescue SystemCallError, IOError
63
+ raise(Error, "Unable to change access on #{filename}")
64
+ end
65
+
66
+ # @param filename [String]
67
+ # @param options [Hash]
39
68
  # @option options [Integer] :timeout (10)
40
69
  # @option options [Boolean] :raise_on_missing (true)
41
70
  # @option options [String] :value_on_missing ('') which specifies the
@@ -46,7 +75,7 @@ module SugarUtils
46
75
  # @raise [SugarUtils::File::Error]
47
76
  #
48
77
  # @return [String]
49
- def self.read(filename, options = {}) # rubocop:disable MethodLength, AbcSize, CyclomaticComplexity, PerceivedComplexity
78
+ def self.read(filename, options = {}) # rubocop:disable Metrics/MethodLength
50
79
  options[:value_on_missing] ||= ''
51
80
  options[:raise_on_missing] = true if options[:raise_on_missing].nil?
52
81
 
@@ -58,32 +87,17 @@ module SugarUtils
58
87
 
59
88
  return result unless options[:scrub_encoding]
60
89
 
61
- replacement_character =
62
- if options[:scrub_encoding].is_a?(String)
63
- options[:scrub_encoding]
64
- else
65
- ''
66
- end
67
- if result.respond_to?(:scrub)
68
- result.scrub(replacement_character)
69
- else
70
- result.encode(
71
- result.encoding,
72
- 'binary',
73
- invalid: :replace,
74
- undef: :replace,
75
- replace: replacement_character
76
- )
77
- end
90
+ SugarUtils.scrub_encoding(result, options[:scrub_encoding])
78
91
  rescue SystemCallError, IOError
79
92
  raise(Error, "Cannot read #{filename}") if options[:raise_on_missing]
93
+
80
94
  options[:value_on_missing]
81
95
  rescue Timeout::Error
82
96
  raise(Error, "Cannot read #{filename} because it is locked")
83
97
  end
84
98
 
85
- # @param [String] filename
86
- # @param [Hash] options
99
+ # @param filename [String]
100
+ # @param options [Hash]
87
101
  # @option options [Integer] :timeout (10)
88
102
  # @option options [Boolean] :raise_on_missing (true)
89
103
  #
@@ -101,62 +115,57 @@ module SugarUtils
101
115
  raise(Error, "Cannot parse #{filename}")
102
116
  end
103
117
 
104
- # @param [String] filename
105
- # @param [Hash] options
118
+ # Touch the specified file.
119
+ #
120
+ # @param filename [String]
121
+ # @param options [Hash]
106
122
  # @option options [String, Integer] :owner
107
123
  # @option options [String, Integer] :group
108
- # @option options [Integer] :mode @deprecated
124
+ # @option options [Integer] :mode
109
125
  # @option options [Integer] :perm
110
126
  # @option options [Integer] :mtime
111
127
  #
112
128
  # @return [void]
113
129
  def self.touch(filename, options = {})
114
- owner = options[:owner]
115
- group = options[:group]
116
- perm = options[:perm]
117
- touch_options = options.select { |k| %i[mtime].include?(k) }
118
-
119
- if options[:mode].is_a?(Integer)
120
- perm = options[:mode]
121
- deprecate_option(:touch, :mode, :perm, 2018, 7)
122
- end
130
+ write_options = WriteOptions.new(filename, options)
123
131
 
124
132
  FileUtils.mkdir_p(::File.dirname(filename))
125
- FileUtils.touch(filename, touch_options)
126
- FileUtils.chown(owner, group, filename)
127
- FileUtils.chmod(perm, filename) if perm
133
+ FileUtils.touch(filename, **write_options.slice(:mtime))
134
+ change_access(
135
+ filename,
136
+ write_options.owner,
137
+ write_options.group,
138
+ write_options.perm(nil)
139
+ )
128
140
  end
129
141
 
130
- # @param [String] filename
131
- # @param [#to_s] data
132
- # @param [Hash] options
142
+ # Write to an existing file, overwriting it, or create the file if it does
143
+ # not exist.
144
+ #
145
+ # @note Either option :mode or :perm can be used to specific the permissions
146
+ # on the file being written to. This aliasing is used because both these
147
+ # names are used in the standard library, File.open uses :perm and FileUtils
148
+ # uses :mode. The user can choose whichever alias makes their code most
149
+ # readable.
150
+ #
151
+ # @param filename [String]
152
+ # @param data [#to_s]
153
+ # @param options [Hash]
133
154
  # @option options [Integer] :timeout (10)
134
155
  # @option options [Boolean] :flush (false)
135
156
  # @option options [String, Integer] :owner
136
157
  # @option options [String, Integer] :group
137
- # @option options [String] :mode (w+)
158
+ # @option options [Integer] :mode (0o644)
138
159
  # @option options [Integer] :perm (0o644)
139
160
  #
140
161
  # @raise [SugarUtils::File::Error]
141
162
  #
142
163
  # @return [void]
143
- def self.write(filename, data, options = {}) # rubocop:disable MethodLength, AbcSize, CyclomaticComplexity
144
- flush = options[:flush] || false
145
- owner = options[:owner]
146
- group = options[:group]
147
- perm = options[:perm] || 0o644
148
- mode = 'w+'
149
-
150
- if options[:mode].is_a?(Integer)
151
- perm = options[:mode]
152
-
153
- deprecate_option(:write, :mode, ' with an integer value; use perm instead', 2018, 7)
154
- elsif !options[:mode].nil?
155
- mode = options[:mode]
156
- end
164
+ def self.write(filename, data, options = {}) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
165
+ write_options = WriteOptions.new(filename, options)
157
166
 
158
167
  FileUtils.mkdir_p(::File.dirname(filename))
159
- ::File.open(filename, mode, perm) do |file|
168
+ ::File.open(filename, 'w+', write_options.perm) do |file|
160
169
  flock_exclusive(file, options)
161
170
 
162
171
  file.puts(data.to_s)
@@ -165,33 +174,163 @@ module SugarUtils
165
174
  # are often reading it immediately and if the OS is buffering, it is
166
175
  # possible we might read it before it is been physically written to
167
176
  # disk. We are not worried about speed here, so this should be OKAY.
168
- if flush
177
+ if write_options.flush?
169
178
  file.flush
170
179
  file.fsync
171
180
  end
181
+ end
182
+
183
+ change_access(
184
+ filename,
185
+ write_options.owner,
186
+ write_options.group,
187
+ write_options.perm
188
+ )
189
+ rescue Timeout::Error
190
+ raise(Error, "Unable to write #{filename} because it is locked")
191
+ rescue SystemCallError, IOError => e
192
+ raise(Error, "Unable to write #{filename} with #{e}")
193
+ end
172
194
 
173
- # Ensure that the permissions are correct if the file already existed.
174
- file.chmod(perm)
195
+ # Atomically write to an existing file, overwriting it, or create the file
196
+ # if it does not exist.
197
+ #
198
+ # @note Either option :mode or :perm can be used to specific the permissions
199
+ # on the file being written to. This aliasing is used because both these
200
+ # names are used in the standard library, File.open uses :perm and FileUtils
201
+ # uses :mode. The user can choose whichever alias makes their code most
202
+ # readable.
203
+ #
204
+ # @param filename [String]
205
+ # @param data [#to_s]
206
+ # @param options [Hash]
207
+ # @option options [Integer] :timeout (10)
208
+ # @option options [Boolean] :flush (false)
209
+ # @option options [String, Integer] :owner
210
+ # @option options [String, Integer] :group
211
+ # @option options [Integer] :mode (0o644)
212
+ # @option options [Integer] :perm (0o644)
213
+ #
214
+ # @raise [SugarUtils::File::Error]
215
+ #
216
+ # @return [void]
217
+ def self.atomic_write(filename, data, options = {}) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
218
+ write_options = WriteOptions.new(filename, options)
219
+
220
+ # @note This method is similar to the atomic_write which is implemented in
221
+ # ActiveSupport. We re-implemented the method because of the following:
222
+ # * we needed the method, but wanted to avoid pulling in the entire
223
+ # ActiveSupport gem.
224
+ # * we wnated to keep the behaviour and interface consistent with the other
225
+ # SugarUtils write methods
226
+ #
227
+ # @see https://apidock.com/rails/File/atomic_write/class
228
+ FileUtils.mkdir_p(::File.dirname(filename))
229
+ Tempfile.open(::File.basename(filename, '.*'), ::File.dirname(filename)) do |temp_file|
230
+ temp_file.puts(data.to_s)
231
+ # Flush and fsync to be 100% sure we write this data out now because we
232
+ # are often reading it immediately and if the OS is buffering, it is
233
+ # possible we might read it before it is been physically written to
234
+ # disk. We are not worried about speed here, so this should be OKAY.
235
+ if write_options.flush?
236
+ temp_file.flush
237
+ temp_file.fsync
238
+ end
239
+ temp_file.close
240
+
241
+ ::File.open(filename, 'w+', write_options.perm) do |file|
242
+ flock_exclusive(file, options)
243
+ FileUtils.move(temp_file.path, filename)
244
+ end
175
245
  end
176
- FileUtils.chown(owner, group, filename)
246
+
247
+ change_access(
248
+ filename,
249
+ write_options.owner,
250
+ write_options.group,
251
+ write_options.perm
252
+ )
177
253
  rescue Timeout::Error
178
254
  raise(Error, "Unable to write #{filename} because it is locked")
179
- rescue SystemCallError, IOError => boom
180
- raise(Error, "Unable to write #{filename} with #{boom}")
255
+ rescue SystemCallError, IOError => e
256
+ raise(Error, "Unable to write #{filename} with #{e}")
181
257
  end
182
258
 
183
- # @param [String] filename
184
- # @param [#to_json] data
185
- # @param [Hash] options
259
+ # Write the data parameter as JSON to the filename path.
260
+ #
261
+ # @note Either option :mode or :perm can be used to specific the permissions
262
+ # on the file being written to. This aliasing is used because both these
263
+ # names are used in the standard library, File.open uses :perm and FileUtils
264
+ # uses :mode. The user can choose whichever alias makes their code most
265
+ # readable.
266
+ #
267
+ # @param filename [String]
268
+ # @param data [#to_json]
269
+ # @param options [Hash]
186
270
  # @option options [Integer] :timeout (10)
187
271
  # @option options [Boolean] :flush (false)
188
- # @option options [Integer] :perm (0644)
272
+ # @option options [String, Integer] :owner
273
+ # @option options [String, Integer] :group
274
+ # @option options [Integer] :mode (0o644)
275
+ # @option options [Integer] :perm (0o644)
189
276
  #
190
277
  # @raise [SugarUtils::File::Error]
191
278
  #
192
279
  # @return [void]
193
280
  def self.write_json(filename, data, options = {})
194
- write(filename, MultiJson.dump(data, pretty: true), options)
281
+ atomic_write(filename, MultiJson.dump(data, pretty: true), options)
282
+ end
283
+
284
+ # Append to an existing file, or create the file if it does not exist.
285
+ #
286
+ # @note Either option :mode or :perm can be used to specific the permissions
287
+ # on the file being written to. This aliasing is used because both these
288
+ # names are used in the standard library, File.open uses :perm and FileUtils
289
+ # uses :mode. The user can choose whichever alias makes their code most
290
+ # readable.
291
+ #
292
+ # @param filename [String]
293
+ # @param data [#to_s]
294
+ # @param options [Hash]
295
+ # @option options [Integer] :timeout (10)
296
+ # @option options [Boolean] :flush (false)
297
+ # @option options [String, Integer] :owner
298
+ # @option options [String, Integer] :group
299
+ # @option options [Integer] :mode (0o644)
300
+ # @option options [Integer] :perm (0o644)
301
+ #
302
+ # @raise [SugarUtils::File::Error]
303
+ #
304
+ # @return [void]
305
+ def self.append(filename, data, options = {}) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
306
+ write_options = WriteOptions.new(filename, options)
307
+
308
+ FileUtils.mkdir_p(::File.dirname(filename))
309
+ ::File.open(filename, 'a', write_options.perm) do |file|
310
+ flock_exclusive(file, options)
311
+
312
+ file.puts(data.to_s)
313
+
314
+ # Flush and fsync to be 100% sure we write this data out now because we
315
+ # are often reading it immediately and if the OS is buffering, it is
316
+ # possible we might read it before it is been physically written to
317
+ # disk. We are not worried about speed here, so this should be OKAY.
318
+ if write_options.flush?
319
+ file.flush
320
+ file.fsync
321
+ end
322
+ end
323
+
324
+ change_access(
325
+ filename,
326
+ write_options.owner,
327
+ write_options.group,
328
+ write_options.perm
329
+ )
330
+ rescue Timeout::Error
331
+ raise(Error, "Unable to write #{filename} because it is locked")
332
+ rescue SystemCallError, IOError => e
333
+ raise(Error, "Unable to write #{filename} with #{e}")
195
334
  end
196
335
 
197
336
  ############################################################################
@@ -199,7 +338,7 @@ module SugarUtils
199
338
  # Following the same pattern as the existing stdlib method deprecation
200
339
  # module.
201
340
  # @see http://ruby-doc.org/stdlib-2.0.0/libdoc/rubygems/rdoc/Gem/Deprecate.html
202
- def self.deprecate_option(_method, option_name, option_repl, year, month) # rubocop:disable MethodLength, AbcSize
341
+ def self.deprecate_option(_method, option_name, option_repl, year, month) # rubocop:disable Metrics/MethodLength
203
342
  return if Gem::Deprecate.skip
204
343
 
205
344
  klass = is_a?(Module)
@@ -216,13 +355,17 @@ module SugarUtils
216
355
  "NOTE: #{target}#{method} option :#{option_name} is deprecated",
217
356
  case option_repl
218
357
  when :none
219
- ' with no replacement'
358
+ ' with no replacement'
220
359
  when String
221
360
  option_repl
222
361
  else
223
362
  "; use :#{option_repl} instead"
224
363
  end,
225
- format('. It will be removed on or after %4d-%02d-01.', year, month),
364
+ format(
365
+ '. It will be removed on or after %<year>4d-%<month>02d-01.',
366
+ year: year,
367
+ month: month
368
+ ),
226
369
  "\n#{target}#{method} called from #{location_of_external_caller}"
227
370
  ]
228
371
  warn("#{msg.join}.")
@@ -1,6 +1,5 @@
1
- # encoding: utf-8
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module SugarUtils
5
- VERSION = '0.5.0'
4
+ VERSION = '0.7.0'
6
5
  end