process_executer 3.2.4 → 4.0.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +41 -0
  4. data/README.md +177 -134
  5. data/lib/process_executer/commands/run.rb +124 -0
  6. data/lib/process_executer/commands/run_with_capture.rb +148 -0
  7. data/lib/process_executer/commands/spawn_with_timeout.rb +163 -0
  8. data/lib/process_executer/commands.rb +11 -0
  9. data/lib/process_executer/destinations/child_redirection.rb +5 -4
  10. data/lib/process_executer/destinations/close.rb +5 -4
  11. data/lib/process_executer/destinations/destination_base.rb +73 -0
  12. data/lib/process_executer/destinations/file_descriptor.rb +10 -6
  13. data/lib/process_executer/destinations/file_path.rb +12 -6
  14. data/lib/process_executer/destinations/file_path_mode.rb +10 -6
  15. data/lib/process_executer/destinations/file_path_mode_perms.rb +12 -5
  16. data/lib/process_executer/destinations/io.rb +10 -5
  17. data/lib/process_executer/destinations/monitored_pipe.rb +10 -5
  18. data/lib/process_executer/destinations/stderr.rb +8 -4
  19. data/lib/process_executer/destinations/stdout.rb +8 -4
  20. data/lib/process_executer/destinations/tee.rb +24 -17
  21. data/lib/process_executer/destinations/writer.rb +12 -7
  22. data/lib/process_executer/destinations.rb +32 -17
  23. data/lib/process_executer/errors.rb +50 -26
  24. data/lib/process_executer/monitored_pipe.rb +128 -59
  25. data/lib/process_executer/options/base.rb +118 -82
  26. data/lib/process_executer/options/option_definition.rb +5 -1
  27. data/lib/process_executer/options/run_options.rb +13 -12
  28. data/lib/process_executer/options/run_with_capture_options.rb +156 -0
  29. data/lib/process_executer/options/spawn_options.rb +31 -30
  30. data/lib/process_executer/options/{spawn_and_wait_options.rb → spawn_with_timeout_options.rb} +11 -7
  31. data/lib/process_executer/options.rb +3 -1
  32. data/lib/process_executer/result.rb +35 -77
  33. data/lib/process_executer/result_with_capture.rb +62 -0
  34. data/lib/process_executer/version.rb +2 -1
  35. data/lib/process_executer.rb +384 -346
  36. data/process_executer.gemspec +11 -2
  37. metadata +18 -8
  38. data/lib/process_executer/destination_base.rb +0 -83
  39. data/lib/process_executer/runner.rb +0 -144
@@ -5,22 +5,58 @@ require 'io/wait'
5
5
  require 'track_open_instances'
6
6
 
7
7
  module ProcessExecuter
8
- # Write data sent through a pipe to a destination
8
+ # Acts as a pipe that writes the data written to it to one or more destinations
9
+ #
10
+ # {ProcessExecuter::MonitoredPipe} was created to expand the output redirection
11
+ # options for
12
+ # [Process.spawn](https://docs.ruby-lang.org/en/3.4/Process.html#method-c-spawn)
13
+ # and methods derived from it within the `ProcessExecuter` module.
14
+ #
15
+ # This class's initializer accepts any redirection destination supported by
16
+ # [Process.spawn](https://docs.ruby-lang.org/en/3.4/Process.html#method-c-spawn)
17
+ # (this is the `value` part of the file redirection option described in [the File
18
+ # Redirection section of
19
+ # `Process.spawn`](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-File+Redirection+-28File+Descriptor-29).
20
+ #
21
+ # In addition to the standard redirection destinations, {ProcessExecuter::MonitoredPipe} also
22
+ # supports these additional types of destinations:
23
+ #
24
+ # - **Arbitrary Writers**
25
+ #
26
+ # You can redirect subprocess output to any Ruby object that implements the
27
+ # `#write` method. This is particularly useful for:
28
+ #
29
+ # - capturing command output in in-memory buffers like `StringIO`
30
+ # - sending command output to custom logging objects that do not have a file
31
+ # descriptor
32
+ # - processing with a streaming parser to parse and process command output as
33
+ # the command is running
34
+ #
35
+ # - **Multiple Destinations**
36
+ #
37
+ # MonitoredPipe supports duplicating (or "teeing") output to multiple
38
+ # destinations simultaneously. This is achieved by providing a redirection
39
+ # destination in the form `[:tee, destination1, destination2, ...]`, where each
40
+ # `destination` can be any value that `MonitoredPipe` itself supports (including
41
+ # another tee or MonitoredPipe).
9
42
  #
10
43
  # When a new MonitoredPipe is created, a pipe is created (via IO.pipe) and
11
- # a thread is created to read data written to the pipe.
44
+ # a thread is created to read data written to the pipe. As data is read from the pipe,
45
+ # it is written to the destination provided in the MonitoredPipe initializer.
12
46
  #
13
47
  # If the destination raises an exception, the monitoring thread will exit, the
14
48
  # pipe will be closed, and the exception will be saved in `#exception`.
15
49
  #
16
- # `#close` must be called to ensure that (1) the pipe is closed, (2) all data is
17
- # read from the pipe and written to the destination, and (3) the monitoring thread is
18
- # killed.
50
+ # > **⚠️ WARNING**
51
+ # >
52
+ # > `#close` must be called to ensure that (1) the pipe is closed, (2) all data is
53
+ # read from the pipe and written to the destination, and (3) the monitoring thread is
54
+ # killed.
19
55
  #
20
- # @example Collect pipe data into a string
56
+ # @example Collect pipe data into a StringIO object
21
57
  # pipe_data = StringIO.new
22
58
  # begin
23
- # pipe = MonitoredPipe.new(pipe_data)
59
+ # pipe = ProcessExecuter::MonitoredPipe.new(pipe_data)
24
60
  # pipe.write("Hello World")
25
61
  # ensure
26
62
  # pipe.close
@@ -31,14 +67,27 @@ module ProcessExecuter
31
67
  # pipe_data_string = StringIO.new
32
68
  # pipe_data_file = File.open("pipe_data.txt", "w")
33
69
  # begin
34
- # pipe = MonitoredPipe.new(pipe_data_string, pipe_data_file)
70
+ # pipe = ProcessExecuter::MonitoredPipe.new([:tee, pipe_data_string, pipe_data_file])
35
71
  # pipe.write("Hello World")
36
72
  # ensure
37
73
  # pipe.close
38
74
  # end
39
75
  # pipe_data_string.string #=> "Hello World"
76
+ # # It is your responsibility to close the file you opened
77
+ # pipe_data_file.close
40
78
  # File.read("pipe_data.txt") #=> "Hello World"
41
79
  #
80
+ # @example Using a MonitoredPipe with Process.spawn
81
+ # stdout_buffer = StringIO.new
82
+ # begin
83
+ # stdout_pipe = ProcessExecuter::MonitoredPipe.new(stdout_buffer)
84
+ # pid = Process.spawn('echo Hello World', out: stdout_pipe)
85
+ # _waited_pid, status = Process.wait2(pid)
86
+ # ensure
87
+ # stdout_pipe.close
88
+ # end
89
+ # stdout_buffer.string #=> "Hello World\n"
90
+ #
42
91
  # @api public
43
92
  #
44
93
  class MonitoredPipe
@@ -46,14 +95,28 @@ module ProcessExecuter
46
95
 
47
96
  # Create a new monitored pipe
48
97
  #
49
- # Creates a IO.pipe and starts a monitoring thread to read data written to the pipe.
98
+ # Creates an IO.pipe and starts a monitoring thread to read data written to the
99
+ # pipe.
50
100
  #
51
101
  # @example
52
- # data_collector = StringIO.new
53
- # pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
102
+ # redirection_destination = StringIO.new
103
+ # pipe = ProcessExecuter::MonitoredPipe.new(redirection_destination)
54
104
  #
55
- # @param redirection_destination [Array<#write>] as data is read from the pipe,
105
+ # @param redirection_destination [Object] as data is read from the pipe,
56
106
  # it is written to this destination
107
+ #
108
+ # Accepts any redirection destination supported by
109
+ # [`Process.spawn`](https://docs.ruby-lang.org/en/3.4/Process.html#method-c-spawn).
110
+ # This is the `value` part of the file redirection option described in [the
111
+ # File Redirection section of
112
+ # `Process.spawn`](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-File+Redirection+-28File+Descriptor-29).
113
+ #
114
+ # In addition to the standard redirection destinations, `MonitoredPipe` also
115
+ # accepts (1) another monitored pipe, (2) any object that implements a `#write` method and
116
+ # (3) an array in the form `[:tee, destination1, destination2, ...]` where each
117
+ # `destination` can be any value that `MonitoredPipe` itself supports (including
118
+ # another tee or MonitoredPipe).
119
+ #
57
120
  # @param chunk_size [Integer] the size of the chunks to read from the pipe
58
121
  #
59
122
  def initialize(redirection_destination, chunk_size: 100_000)
@@ -65,6 +128,13 @@ module ProcessExecuter
65
128
  @condition_variable = ConditionVariable.new
66
129
  @chunk_size = chunk_size
67
130
  @pipe_reader, @pipe_writer = IO.pipe
131
+
132
+ # Set the encoding of the pipe reader to ASCII_8BIT. This is not strictly
133
+ # necessary since read_nonblock always returns a String where encoding is
134
+ # Encoding::ASCII_8BIT, but it is a good practice to explicitly set the
135
+ # encoding.
136
+ pipe_reader.set_encoding(Encoding::ASCII_8BIT)
137
+
68
138
  @state = :open
69
139
  @thread = start_monitoring_thread
70
140
 
@@ -116,8 +186,6 @@ module ProcessExecuter
116
186
  #
117
187
  # @return [IO] the write end of the pipe
118
188
  #
119
- # @api private
120
- #
121
189
  def to_io
122
190
  pipe_writer
123
191
  end
@@ -134,8 +202,6 @@ module ProcessExecuter
134
202
  #
135
203
  # @return [Integer] the file descriptor for the write end of the pipe
136
204
  #
137
- # @api private
138
- #
139
205
  def fileno
140
206
  pipe_writer.fileno
141
207
  end
@@ -156,7 +222,7 @@ module ProcessExecuter
156
222
  #
157
223
  # @return [Integer] the number of bytes written to the pipe
158
224
  #
159
- # @api private
225
+ # @raise [IOError] if the pipe is not open
160
226
  #
161
227
  def write(data)
162
228
  mutex.synchronize do
@@ -174,7 +240,7 @@ module ProcessExecuter
174
240
  # require 'stringio'
175
241
  # data_collector = StringIO.new
176
242
  # pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
177
- # pipe.chunk_size #=> 1000
243
+ # pipe.chunk_size #=> 100_000
178
244
  #
179
245
  # @return [Integer] the size of the chunks to read from the pipe
180
246
  #
@@ -188,12 +254,45 @@ module ProcessExecuter
188
254
  # require 'stringio'
189
255
  # data_collector = StringIO.new
190
256
  # pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
191
- # pipe.destination #=>
257
+ # pipe.destination #=> #<ProcessExecuter::Destinations::Writer>
192
258
  #
193
- # @return [Array<ProcessExecuter::Destination::Base>]
259
+ # @return [ProcessExecuter::Destinations::DestinationBase]
194
260
  #
195
261
  attr_reader :destination
196
262
 
263
+ # @!attribute [r]
264
+ #
265
+ # The state of the pipe
266
+ #
267
+ # Must be either `:open`, `:closing`, or `:closed`
268
+ #
269
+ # * `:open` - the pipe is open and data can be written to it
270
+ # * `:closing` - the pipe is being closed and data can no longer be written to it
271
+ # * `:closed` - the pipe is closed and data can no longer be written to it
272
+ #
273
+ # @example
274
+ # pipe = ProcessExecuter::MonitoredPipe.new($stdout)
275
+ # pipe.state #=> :open
276
+ # pipe.close
277
+ # pipe.state #=> :closed
278
+ #
279
+ # @return [Symbol] the state of the pipe
280
+ #
281
+ attr_reader :state
282
+
283
+ # @!attribute [r]
284
+ #
285
+ # The exception raised by a destination
286
+ #
287
+ # If an exception is raised by a destination, it is stored here. Otherwise, it is `nil`.
288
+ #
289
+ # @example
290
+ # pipe.exception #=> nil
291
+ #
292
+ # @return [Exception, nil] the exception raised by a destination or `nil` if no exception was raised
293
+ #
294
+ attr_reader :exception
295
+
197
296
  # @!attribute [r]
198
297
  #
199
298
  # The thread that monitors the pipe
@@ -205,6 +304,9 @@ module ProcessExecuter
205
304
  # pipe.thread #=> #<Thread:0x00007f8b1a0b0e00>
206
305
  #
207
306
  # @return [Thread]
307
+ #
308
+ # @api private
309
+ #
208
310
  attr_reader :thread
209
311
 
210
312
  # @!attribute [r]
@@ -216,6 +318,9 @@ module ProcessExecuter
216
318
  # pipe.pipe_reader #=> #<IO:fd 11>
217
319
  #
218
320
  # @return [IO]
321
+ #
322
+ # @api private
323
+ #
219
324
  attr_reader :pipe_reader
220
325
 
221
326
  # @!attribute [r]
@@ -227,40 +332,10 @@ module ProcessExecuter
227
332
  # pipe.pipe_writer #=> #<IO:fd 12>
228
333
  #
229
334
  # @return [IO] the write end of the pipe
230
- attr_reader :pipe_writer
231
-
232
- # @!attribute [r]
233
- #
234
- # The state of the pipe
235
- #
236
- # Must be either `:open`, `:closing`, or `:closed`
237
- #
238
- # * `:open` - the pipe is open and data can be written to it
239
- # * `:closing` - the pipe is being closed and data can no longer be written to it
240
- # * `:closed` - the pipe is closed and data can no longer be written to it
241
335
  #
242
- # @example
243
- # pipe = ProcessExecuter::MonitoredPipe.new($stdout)
244
- # pipe.state #=> :open
245
- # pipe.close
246
- # pipe.state #=> :closed
247
- #
248
- # @return [Symbol] the state of the pipe
249
- #
250
- attr_reader :state
251
-
252
- # @!attribute [r]
253
- #
254
- # The exception raised by a destination
255
- #
256
- # If an exception is raised by a destination, it is stored here. Otherwise, it is `nil`.
257
- #
258
- # @example
259
- # pipe.exception #=> nil
260
- #
261
- # @return [Exception, nil] the exception raised by a destination or `nil` if no exception was raised
336
+ # @api private
262
337
  #
263
- attr_reader :exception
338
+ attr_reader :pipe_writer
264
339
 
265
340
  private
266
341
 
@@ -332,19 +407,13 @@ module ProcessExecuter
332
407
  # @return [void]
333
408
  # @api private
334
409
  def monitor_pipe
410
+ # read_nonblock always returns a String where encoding is Encoding::ASCII_8BIT
335
411
  new_data = pipe_reader.read_nonblock(chunk_size)
336
412
  write_data(new_data)
337
413
  rescue IO::WaitReadable
338
414
  pipe_reader.wait_readable(0.001)
339
415
  end
340
416
 
341
- # # Check if the writer is a file descriptor
342
- # #
343
- # # @param writer [#write] the writer to check
344
- # # @return [Boolean] true if the writer is a file descriptor
345
- # # @api private
346
- # def file_descriptor?(writer) = writer.is_a?(Integer) || writer.is_a?(Symbol)
347
-
348
417
  # Write the data read from the pipe to the destination
349
418
  #
350
419
  # If an exception is raised by a writer, set the state to `:closing`
@@ -14,35 +14,54 @@ module ProcessExecuter
14
14
  # # Call super to include options defined in the parent class
15
15
  # [
16
16
  # *super,
17
- # ProcessExecuter::Options::OptionDefinition.new(:option1),
18
- # ProcessExecuter::Options::OptionDefinition.new(:option2)
17
+ # ProcessExecuter::Options::OptionDefinition.new(
18
+ # :option1, default: '', validator: method(:assert_is_string)
19
+ # ),
20
+ # ProcessExecuter::Options::OptionDefinition.new(
21
+ # :option2, default: '', validator: method(:assert_is_string)
22
+ # ),
23
+ # ProcessExecuter::Options::OptionDefinition.new(
24
+ # :option3, default: '', validator: method(:assert_is_string)
25
+ # )
19
26
  # ]
20
27
  # end
28
+ # def assert_is_string(key, value)
29
+ # return if value.is_a?(String)
30
+ # errors << "#{key} must be a String but was #{value}"
31
+ # end
21
32
  # end
22
- # options_hash = { options1: 'value1', option2: 'value2' }
23
- # options = MyOptions.new(options_hash)
33
+ # options = MyOptions.new(option1: 'value1', option2: 'value2')
24
34
  # options.option1 # => 'value1'
25
35
  # options.option2 # => 'value2'
26
36
  #
37
+ # @example invalid option values
38
+ # begin
39
+ # options = MyOptions.new(option1: 1, option2: 2)
40
+ # rescue ProcessExecuter::ArgumentError => e
41
+ # e.message #=> "option1 must be a String but was 1\noption2 must be a String but was 2"
42
+ # end
43
+ #
27
44
  # @api public
28
45
  class Base
29
46
  # Create a new Options object
30
47
  #
31
- # @example
32
- # options = ProcessExecuter::Options.new(out: $stdout, err: $stderr, timeout_after: 10)
48
+ # Normally you would use a subclass instead of instantiating this class
49
+ # directly.
33
50
  #
34
- # @param options [Hash] Process.spawn options plus additional options listed below.
51
+ # @example
52
+ # options = MyOptions.new(option1: 'value1', option2: 'value2')
35
53
  #
36
- # See [Process.spawn](https://ruby-doc.org/core/Process.html#method-c-spawn)
37
- # for a list of valid options that can be passed to `Process.spawn`.
54
+ # @example with invalid option values
55
+ # begin
56
+ # options = MyOptions.new(option1: 1, option2: 2)
57
+ # rescue ProcessExecuter::ArgumentError => e
58
+ # e.message #=> "option1 must be a String but was 1\noption2 must be a String but was 2"
59
+ # end
38
60
  #
39
- # @option options [Integer, Float, nil] :timeout_after
40
- # Number of seconds to wait for the process to terminate. Any number
41
- # may be used, including Floats to specify fractional seconds. A value of 0 or nil
42
- # will allow the process to run indefinitely.
61
+ # @param options_hash [Hash] a hash of options
43
62
  #
44
- def initialize(**options)
45
- @options = allowed_options.transform_values(&:default).merge(options)
63
+ def initialize(**options_hash)
64
+ @options_hash = allowed_options.transform_values(&:default).merge(options_hash)
46
65
  @errors = []
47
66
  assert_no_unknown_options
48
67
  define_accessor_methods
@@ -51,15 +70,20 @@ module ProcessExecuter
51
70
 
52
71
  # All the allowed options as a hash whose keys are the option names
53
72
  #
54
- # The returned hash what is returned from `define_options` but with the
55
- # option names as keys. The values are instances of `OptionDefinition`.
73
+ # The returned hash what is returned from `define_options` but with the option
74
+ # names as keys. The values are instances of `OptionDefinition`.
56
75
  #
57
76
  # The returned hash is frozen and cannot be modified.
58
77
  #
59
78
  # @example
60
- # options.allowed_options # => { timeout_after: #<OptionDefinition>, ... }
79
+ # options = MyOptions.new(option1: 'value1', option2: 'value2')
80
+ # options.allowed_options # => {
81
+ # option1: #<OptionDefinition>,
82
+ # option2: #<OptionDefinition>
83
+ # }
61
84
  #
62
- # @return [Hash]
85
+ # @return [Hash<Symbol, ProcessExecuter::Options::OptionDefinition>] A hash
86
+ # where keys are option names and values are their definitions.
63
87
  #
64
88
  def allowed_options
65
89
  @allowed_options ||=
@@ -69,94 +93,98 @@ module ProcessExecuter
69
93
  end
70
94
 
71
95
  # A string representation of the object that includes the options
96
+ #
72
97
  # @example
73
- # options = ProcessExecuter::Options.new(option1: 'value1', option2: 'value2')
74
- # options.to_s # => #<ProcessExecuter::Options:0x00007f8f9b0b3d20 option1: "value1", option2: "value2">'
98
+ # options = MyOptions.new(option1: 'value1', option2: 'value2')
99
+ # options.to_s # => #<MyOptions option1: "value1", option2: "value2">'
100
+ #
75
101
  # @return [String]
102
+ #
76
103
  def to_s
77
104
  "#{super.to_s[0..-2]} #{inspect}>"
78
105
  end
79
106
 
80
107
  # A string representation of the options
108
+ #
81
109
  # @example
82
- # options = ProcessExecuter::Options.new(option1: 'value1', option2: 'value2')
110
+ # options = MyOptions.new(option1: 'value1', option2: 'value2')
83
111
  # options.inspect # => '{:option1=>"value1", :option2=>"value2"}'
112
+ #
84
113
  # @return [String]
114
+ #
85
115
  def inspect
86
- options.inspect
116
+ options_hash.inspect
87
117
  end
88
118
 
89
119
  # A hash representation of the options
120
+ #
90
121
  # @example
91
- # options = ProcessExecuter::Options.new(option1: 'value1', option2: 'value2')
122
+ # options = MyOptions.new(option1: 'value1', option2: 'value2')
92
123
  # options.to_h # => { option1: "value1", option2: "value2" }
124
+ #
93
125
  # @return [Hash]
126
+ #
94
127
  def to_h
95
- @options.dup
128
+ options_hash.dup
96
129
  end
97
130
 
98
131
  # Iterate over each option with an object
132
+ #
99
133
  # @example
100
- # options = ProcessExecuter::Options.new(option1: 'value1', option2: 'value2')
134
+ # options = MyOptions.new(option1: 'value1', option2: 'value2')
101
135
  # options.each_with_object({}) { |(option_key, option_value), obj| obj[option_key] = option_value }
102
136
  # # => { option1: "value1", option2: "value2" }
103
- # @yield [option_key, option_value, obj]
104
- # @return [void]
137
+ #
138
+ # @yield [key_value, obj]
139
+ #
140
+ # @yieldparam key_value [Array<Object, Object>] An array containing the option key and its value
141
+ #
142
+ # @yieldparam obj [Object] The object passed to the block.
143
+ #
144
+ # @return [Object] the obj passed to the block
145
+ #
105
146
  def each_with_object(obj, &)
106
- @options.each_with_object(obj, &)
147
+ options_hash.each_with_object(obj, &)
107
148
  end
108
149
 
109
- # Merge the given options into the current options
150
+ # Merge the given options into the current options object
151
+ #
152
+ # Subsequent hashes' values overwrite earlier ones for the same key.
153
+ #
110
154
  # @example
111
- # options = ProcessExecuter::Options.new(option1: 'value1', option2: 'value2')
112
- # options.merge!(option2: 'new_value2', option3: 'value3')
113
- # options.option2 # => 'new_value2'
114
- # options.option3 # => 'value3'
155
+ # options = MyOptions.new(option1: 'value1', option2: 'value2')
156
+ # h1 = { option2: 'new_value2' }
157
+ # h2 = { option3: 'value3' }
158
+ # options.merge!(h1, h2) => {option1: "value1", option2: "new_value2", option3: "value3"}
115
159
  #
116
- # @param other_options [Hash] the options to merge into the current options
117
- # @return [void]
118
- def merge!(**other_options)
119
- @options.merge!(other_options)
120
- end
121
-
122
- # A shallow copy of self with options copied but not the values they reference
160
+ # @param other_options_hashes [Array<Hash>] zero of more hashes to merge into the current options
123
161
  #
124
- # If any keyword arguments are given, the copy will be created with the
125
- # respective option values updated.
162
+ # @return [self] the current options object with the merged options
126
163
  #
127
- # @example
128
- # options_hash = { option1: 'value1', option2: 'value2' }
129
- # options = ProcessExecuter::MyOptions.new(options_hash)
130
- # copy = options.with(option1: 'new_value1')
131
- # copy.option1 # => 'new_value1'
132
- # copy.option2 # => 'value2'
133
- # options.option1 # => 'value1'
134
- # options.option2 # => 'value2'
135
- #
136
- # @options_hash [Hash] the options to merge into the current options
137
- # @return [self.class]
164
+ # @api public
138
165
  #
139
- def with(**options_hash)
140
- self.class.new(**@options, **options_hash)
166
+ def merge!(*other_options_hashes)
167
+ options_hash.merge!(*other_options_hashes)
141
168
  end
142
169
 
143
- # The list of validation errors
144
- #
145
- # Validators should add an error messages to this array.
170
+ # Returns a new options object formed by merging self with each of other_hashes
146
171
  #
147
172
  # @example
148
- # options = ProcessExecuter::Options::RunOptions.new(timeout_after: 'not_a_number', raise_errors: 'yes')
149
- # #=> raises an Argument error with the following message:
150
- # timeout_after must be nil or a non-negative real number but was "not_a_number"
151
- # raise_errors must be true or false but was "yes""
152
- # errors # => [
153
- # "timeout_after must be nil or a non-negative real number but was \"not_a_number\"",
154
- # "raise_errors must be true or false but was \"yes\""
155
- # ]
173
+ # options = MyOptions.new(option1: 'value1', option2: 'value2')
174
+ # options.object_id # => 1025
175
+ # h1 = { option2: 'new_value2' }
176
+ # h2 = { option3: 'value3' }
177
+ # merged_options = options.merge(h1, h2)
178
+ # merged_options.object_id # => 1059
156
179
  #
157
- # @return [Array<String>]
158
- # @api private
159
- attr_reader :errors
180
+ # @param other_options_hashes [Array<Hash>] the options to merge into the current options
181
+ #
182
+ # @return [self.class]
183
+ #
184
+ def merge(*other_options_hashes)
185
+ merged_options = other_options_hashes.reduce(options_hash, :merge)
186
+ self.class.new(**merged_options)
187
+ end
160
188
 
161
189
  protected
162
190
 
@@ -185,27 +213,37 @@ module ProcessExecuter
185
213
 
186
214
  private
187
215
 
216
+ # The list of validation errors
217
+ #
218
+ # Validators should add error messages to this array.
219
+ #
220
+ # @return [Array<String>]
221
+ #
222
+ # @api private
223
+ #
224
+ attr_reader :errors
225
+
188
226
  # @!attribute [r]
189
227
  #
190
228
  # A hash of all options keyed by the option name
191
229
  #
192
- # @return [Hash{Symbol => Object}]
230
+ # @return [Hash<Object, Object>]
193
231
  #
194
232
  # @api private
195
233
  #
196
- attr_reader :options
234
+ attr_reader :options_hash
197
235
 
198
236
  # Raise an argument error for invalid option values
199
237
  # @return [void]
200
- # @raise [ArgumentError] if any invalid option values are found
238
+ # @raise [ProcessExecuter::ArgumentError] if any invalid option values are found
201
239
  # @api private
202
240
  def validate_options
203
- options.each_key do |option_key|
241
+ options_hash.each_key do |option_key|
204
242
  validator = allowed_options[option_key]&.validator
205
- instance_exec(&validator.to_proc) unless validator.nil?
243
+ instance_exec(option_key, send(option_key), &validator.to_proc) unless validator.nil?
206
244
  end
207
245
 
208
- raise ArgumentError, errors.join("\n") unless errors.empty?
246
+ raise ProcessExecuter::ArgumentError, errors.join("\n") unless errors.empty?
209
247
  end
210
248
 
211
249
  # Define accessor methods for each option
@@ -214,26 +252,24 @@ module ProcessExecuter
214
252
  def define_accessor_methods
215
253
  allowed_options.each_key do |option|
216
254
  define_singleton_method(option) do
217
- @options[option]
255
+ options_hash[option]
218
256
  end
219
257
  end
220
258
  end
221
259
 
222
260
  # Determine if the options hash contains any unknown options
223
261
  # @return [void]
224
- # @raise [ArgumentError] if the options hash contains any unknown options
262
+ # @raise [ProcessExecuter::ArgumentError] if the options hash contains any unknown options
225
263
  # @api private
226
264
  def assert_no_unknown_options
227
- unknown_options = options.keys.reject { |key| valid_option?(key) }
265
+ unknown_options = options_hash.keys.reject { |key| valid_option?(key) }
228
266
 
229
267
  return if unknown_options.empty?
230
268
 
231
- # :nocov: SimpleCov on JRuby reports the last with the last argument line is not covered
232
269
  raise(
233
270
  ArgumentError,
234
- "Unknown option#{unknown_options.count > 1 ? 's' : ''}: #{unknown_options.join(', ')}"
271
+ "Unknown option#{'s' if unknown_options.count > 1}: #{unknown_options.join(', ')}"
235
272
  )
236
- # :nocov:
237
273
  end
238
274
  end
239
275
  end
@@ -29,6 +29,10 @@ module ProcessExecuter
29
29
 
30
30
  # A method or proc that validates the option
31
31
  #
32
+ # A callable that receives `option_key`, `option_value` and is executed in the
33
+ # context of the options instance. It should add messages to an `errors` array
34
+ # if validation fails.
35
+ #
32
36
  # @example
33
37
  # option = ProcessExecuter::Options::OptionDefinition.new(
34
38
  # :timeout_after, validator: method(:validate_timeout_after)
@@ -43,7 +47,7 @@ module ProcessExecuter
43
47
  #
44
48
  # @example
45
49
  # option = ProcessExecuter::Options::OptionDefinition.new(
46
- # :timeout_after, default: 10, validator: -> { timeout_after.is_a?(Numeric) }
50
+ # :timeout_after, default: 10, validator: ->(_k, _v) { timeout_after.is_a?(Numeric) }
47
51
  # )
48
52
  #
49
53
  def initialize(name, default: nil, validator: nil)