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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +41 -0
- data/README.md +177 -134
- data/lib/process_executer/commands/run.rb +124 -0
- data/lib/process_executer/commands/run_with_capture.rb +148 -0
- data/lib/process_executer/commands/spawn_with_timeout.rb +163 -0
- data/lib/process_executer/commands.rb +11 -0
- data/lib/process_executer/destinations/child_redirection.rb +5 -4
- data/lib/process_executer/destinations/close.rb +5 -4
- data/lib/process_executer/destinations/destination_base.rb +73 -0
- data/lib/process_executer/destinations/file_descriptor.rb +10 -6
- data/lib/process_executer/destinations/file_path.rb +12 -6
- data/lib/process_executer/destinations/file_path_mode.rb +10 -6
- data/lib/process_executer/destinations/file_path_mode_perms.rb +12 -5
- data/lib/process_executer/destinations/io.rb +10 -5
- data/lib/process_executer/destinations/monitored_pipe.rb +10 -5
- data/lib/process_executer/destinations/stderr.rb +8 -4
- data/lib/process_executer/destinations/stdout.rb +8 -4
- data/lib/process_executer/destinations/tee.rb +24 -17
- data/lib/process_executer/destinations/writer.rb +12 -7
- data/lib/process_executer/destinations.rb +32 -17
- data/lib/process_executer/errors.rb +50 -26
- data/lib/process_executer/monitored_pipe.rb +128 -59
- data/lib/process_executer/options/base.rb +118 -82
- data/lib/process_executer/options/option_definition.rb +5 -1
- data/lib/process_executer/options/run_options.rb +13 -12
- data/lib/process_executer/options/run_with_capture_options.rb +156 -0
- data/lib/process_executer/options/spawn_options.rb +31 -30
- data/lib/process_executer/options/{spawn_and_wait_options.rb → spawn_with_timeout_options.rb} +11 -7
- data/lib/process_executer/options.rb +3 -1
- data/lib/process_executer/result.rb +35 -77
- data/lib/process_executer/result_with_capture.rb +62 -0
- data/lib/process_executer/version.rb +2 -1
- data/lib/process_executer.rb +384 -346
- data/process_executer.gemspec +11 -2
- metadata +18 -8
- data/lib/process_executer/destination_base.rb +0 -83
- 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
|
-
#
|
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
|
-
#
|
17
|
-
#
|
18
|
-
#
|
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
|
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
|
98
|
+
# Creates an IO.pipe and starts a monitoring thread to read data written to the
|
99
|
+
# pipe.
|
50
100
|
#
|
51
101
|
# @example
|
52
|
-
#
|
53
|
-
# pipe = ProcessExecuter::MonitoredPipe.new(
|
102
|
+
# redirection_destination = StringIO.new
|
103
|
+
# pipe = ProcessExecuter::MonitoredPipe.new(redirection_destination)
|
54
104
|
#
|
55
|
-
# @param redirection_destination [
|
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
|
-
# @
|
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 #=>
|
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 [
|
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
|
-
# @
|
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 :
|
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(
|
18
|
-
#
|
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
|
-
#
|
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
|
-
#
|
32
|
-
#
|
48
|
+
# Normally you would use a subclass instead of instantiating this class
|
49
|
+
# directly.
|
33
50
|
#
|
34
|
-
# @
|
51
|
+
# @example
|
52
|
+
# options = MyOptions.new(option1: 'value1', option2: 'value2')
|
35
53
|
#
|
36
|
-
#
|
37
|
-
#
|
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
|
-
# @
|
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(**
|
45
|
-
@
|
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
|
-
#
|
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
|
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 =
|
74
|
-
# options.to_s # => #<
|
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 =
|
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
|
-
|
116
|
+
options_hash.inspect
|
87
117
|
end
|
88
118
|
|
89
119
|
# A hash representation of the options
|
120
|
+
#
|
90
121
|
# @example
|
91
|
-
# options =
|
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
|
-
|
128
|
+
options_hash.dup
|
96
129
|
end
|
97
130
|
|
98
131
|
# Iterate over each option with an object
|
132
|
+
#
|
99
133
|
# @example
|
100
|
-
# options =
|
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
|
-
#
|
104
|
-
# @
|
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
|
-
|
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 =
|
112
|
-
#
|
113
|
-
#
|
114
|
-
# options.
|
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
|
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
|
-
#
|
125
|
-
# respective option values updated.
|
162
|
+
# @return [self] the current options object with the merged options
|
126
163
|
#
|
127
|
-
# @
|
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
|
140
|
-
|
166
|
+
def merge!(*other_options_hashes)
|
167
|
+
options_hash.merge!(*other_options_hashes)
|
141
168
|
end
|
142
169
|
|
143
|
-
#
|
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 =
|
149
|
-
#
|
150
|
-
#
|
151
|
-
#
|
152
|
-
#
|
153
|
-
#
|
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
|
-
# @
|
158
|
-
#
|
159
|
-
|
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
|
230
|
+
# @return [Hash<Object, Object>]
|
193
231
|
#
|
194
232
|
# @api private
|
195
233
|
#
|
196
|
-
attr_reader :
|
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
|
-
|
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
|
-
|
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 =
|
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
|
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)
|