sugar_utils 0.5.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,43 @@
1
+ Feature: Read a file
2
+
3
+ Scenario: Read a missing file with a default value
4
+ When I run the following Ruby code:
5
+ """ruby
6
+ require 'sugar_utils'
7
+ MultiJson.use(:ok_json)
8
+
9
+ puts SugarUtils::File.read_json('test.json', raise_on_missing: false)
10
+ """
11
+ Then the output should contain "{}"
12
+
13
+ Scenario: Read an existing file
14
+ Given a file named "test.json" with:
15
+ """
16
+ {"key":"value"}
17
+ """
18
+ When I run the following Ruby code:
19
+ """ruby
20
+ require 'sugar_utils'
21
+ MultiJson.use(:ok_json)
22
+
23
+ puts SugarUtils::File.read_json('test.json')
24
+ """
25
+ Then the output should contain:
26
+ """
27
+ {"key"=>"value"}
28
+ """
29
+
30
+ Scenario: Read an existing file and scurb encoding errors
31
+ Given a file named "test.json" with "test"
32
+ When I run the following Ruby code:
33
+ """ruby
34
+ require 'sugar_utils'
35
+ MultiJson.use(:ok_json)
36
+
37
+ File.write('test.json', %({\"key\":\"foo\\x92bar\\x93\"}))
38
+ puts SugarUtils::File.read_json('test.json', scrub_encoding: true)
39
+ """
40
+ Then the output should contain:
41
+ """
42
+ {"key"=>"foobar"}
43
+ """
@@ -0,0 +1,25 @@
1
+ Feature: Ensure the specified value is an integer
2
+
3
+ Scenario: Pass through string without encoding errors
4
+ When I run the following Ruby code:
5
+ """ruby
6
+ require 'sugar_utils'
7
+ puts SugarUtils.scrub_encoding('foobar')
8
+ """
9
+ Then the output should contain "foobar"
10
+
11
+ Scenario: Erase encoding errors in the string
12
+ When I run the following Ruby code:
13
+ """ruby
14
+ require 'sugar_utils'
15
+ puts SugarUtils.scrub_encoding(%(foo\\x92bar\\x93))
16
+ """
17
+ Then the output should contain "foobar"
18
+
19
+ Scenario: Replace encoding errors in the string
20
+ When I run the following Ruby code:
21
+ """ruby
22
+ require 'sugar_utils'
23
+ puts SugarUtils.scrub_encoding(%(foo\\x92bar\\x93), 'xxx')
24
+ """
25
+ Then the output should contain "fooxxxbarxxx"
@@ -0,0 +1,36 @@
1
+ When('I run the following Ruby code:') do |code|
2
+ run_command_and_stop %(ruby -e "#{code}")
3
+ end
4
+
5
+ # Copying the pattern for this step from the default Aruba step for checking
6
+ # the permissions of a file.
7
+ # @see lib/aruba/cucumber/file.rb
8
+ Then(/^the (?:file|directory)(?: named)? "([^"]*)" should( not)? have owner "([^"]*)"$/) do |path, negated, owner|
9
+ if negated
10
+ expect(path).not_to have_owner(owner)
11
+ else
12
+ expect(path).to have_owner(owner)
13
+ end
14
+ end
15
+
16
+ # Copying the pattern for this step from the default Aruba step for checking
17
+ # the permissions of a file.
18
+ # @see lib/aruba/cucumber/file.rb
19
+ Then(/^the (?:file|directory)(?: named)? "([^"]*)" should( not)? have group "([^"]*)"$/) do |path, negated, group|
20
+ if negated
21
+ expect(path).not_to have_group(group)
22
+ else
23
+ expect(path).to have_group(group)
24
+ end
25
+ end
26
+
27
+ # Copying the pattern for this step from the default Aruba step for checking
28
+ # the permissions of a file.
29
+ # @see lib/aruba/cucumber/file.rb
30
+ Then(/^the (?:file|directory)(?: named)? "([^"]*)" should( not)? have modification time "([^"]*)"$/) do |path, negated, modification_time|
31
+ if negated
32
+ expect(expand_path(path)).not_to have_mtime(modification_time.to_i)
33
+ else
34
+ expect(expand_path(path)).to have_mtime(modification_time.to_i)
35
+ end
36
+ end
@@ -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"
@@ -1,19 +1,20 @@
1
- # encoding : utf-8
2
1
  # frozen_string_literal: true
3
2
 
4
3
  require 'sugar_utils/version'
5
4
  require 'sugar_utils/file'
6
5
 
6
+ # @api
7
7
  module SugarUtils
8
- # @param [Object] value
8
+ # @param value [Object]
9
9
  #
10
10
  # @return [Boolean]
11
11
  def self.ensure_boolean(value)
12
12
  return false if value.respond_to?(:to_s) && value.to_s.casecmp('false').zero?
13
+
13
14
  value ? true : false
14
15
  end
15
16
 
16
- # @param [String, Float, Integer] value
17
+ # @param value [String, Float, Integer]
17
18
  #
18
19
  # @raise [ArgumentError] if the value is a string which cannot be converted
19
20
  # @raise [TypeError] if value is type which cannot be converted
@@ -22,6 +23,33 @@ module SugarUtils
22
23
  def self.ensure_integer(value)
23
24
  return value if value.is_a?(Integer)
24
25
  return value.to_i if value.is_a?(Float)
26
+
25
27
  Float(value).to_i
26
28
  end
29
+
30
+ # @overload scrub_encoding(data)
31
+ # Scrub the string's encoding, and replace any bad characters with ''.
32
+ # @param data [String]
33
+ # @overload scrub_encoding(data, replacement_character)
34
+ # Scrub the string's encoding, and replace any bad characters with the
35
+ # specified character.
36
+ # @param data [String]
37
+ # @param replacement_character [String]
38
+ #
39
+ # @return [String]
40
+ def self.scrub_encoding(data, replacement_character = nil)
41
+ replacement_character = '' unless replacement_character.is_a?(String)
42
+
43
+ # If the Ruby version being used supports String#scrub, then just use it.
44
+ return data.scrub(replacement_character) if data.respond_to?(:scrub)
45
+
46
+ # Otherwise, fall back to String#encode.
47
+ data.encode(
48
+ data.encoding,
49
+ 'binary',
50
+ invalid: :replace,
51
+ undef: :replace, # rubocop:disable Layout/AlignHash
52
+ replace: replacement_character
53
+ )
54
+ end
27
55
  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 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 MethodLength, 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 MethodLength, 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 MethodLength, 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 MethodLength
203
342
  return if Gem::Deprecate.skip
204
343
 
205
344
  klass = is_a?(Module)
@@ -216,7 +355,7 @@ 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