sugar_utils 0.5.0 → 0.7.0

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.
@@ -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