process_executer 2.0.0 → 3.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.
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'option_definition'
4
+
5
+ module ProcessExecuter
6
+ module Options
7
+ # Defines, validates, and holds a set of option values
8
+ #
9
+ # Options are defined by subclasses by overriding the `define_options` method.
10
+ #
11
+ # @example Define an options class with two options
12
+ # class MyOptions < ProcessExecuter::Options::Base
13
+ # def define_options
14
+ # # Call super to include options defined in the parent class
15
+ # [
16
+ # *super,
17
+ # ProcessExecuter::Options::OptionDefinition.new(:option1),
18
+ # ProcessExecuter::Options::OptionDefinition.new(:option2)
19
+ # ]
20
+ # end
21
+ # end
22
+ # options_hash = { options1: 'value1', option2: 'value2' }
23
+ # options = MyOptions.new(options_hash)
24
+ # options.option1 # => 'value1'
25
+ # options.option2 # => 'value2'
26
+ #
27
+ # @api public
28
+ class Base
29
+ # Create a new Options object
30
+ #
31
+ # @example
32
+ # options = ProcessExecuter::Options.new(out: $stdout, err: $stderr, timeout_after: 10)
33
+ #
34
+ # @param options [Hash] Process.spawn options plus additional options listed below.
35
+ #
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`.
38
+ #
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.
43
+ #
44
+ def initialize(**options)
45
+ @options = allowed_options.transform_values(&:default).merge(options)
46
+ @errors = []
47
+ assert_no_unknown_options
48
+ define_accessor_methods
49
+ validate_options
50
+ end
51
+
52
+ # All the allowed options as a hash whose keys are the option names
53
+ #
54
+ # The returned hash what is returned from `define_options` but with the
55
+ # option names as keys. The values are instances of `OptionDefinition`.
56
+ #
57
+ # The returned hash is frozen and cannot be modified.
58
+ #
59
+ # @example
60
+ # options.allowed_options # => { timeout_after: #<OptionDefinition>, ... }
61
+ #
62
+ # @return [Hash]
63
+ #
64
+ def allowed_options
65
+ @allowed_options ||=
66
+ define_options.each_with_object({}) do |option, hash|
67
+ hash[option.name] = option
68
+ end.freeze
69
+ end
70
+
71
+ # A string representation of the object that includes the options
72
+ # @example
73
+ # options = ProcessExecuter::Options.new(option1: 'value1', option2: 'value2')
74
+ # options.to_s # => #<ProcessExecuter::Options:0x00007f8f9b0b3d20 option1: "value1", option2: "value2">'
75
+ # @return [String]
76
+ def to_s
77
+ "#{super.to_s[0..-2]} #{inspect}>"
78
+ end
79
+
80
+ # A string representation of the options
81
+ # @example
82
+ # options = ProcessExecuter::Options.new(option1: 'value1', option2: 'value2')
83
+ # options.inspect # => '{:option1=>"value1", :option2=>"value2"}'
84
+ # @return [String]
85
+ def inspect
86
+ options.inspect
87
+ end
88
+
89
+ # A hash representation of the options
90
+ # @example
91
+ # options = ProcessExecuter::Options.new(option1: 'value1', option2: 'value2')
92
+ # options.to_h # => { option1: "value1", option2: "value2" }
93
+ # @return [Hash]
94
+ def to_h
95
+ @options.dup
96
+ end
97
+
98
+ # Iterate over each option with an object
99
+ # @example
100
+ # options = ProcessExecuter::Options.new(option1: 'value1', option2: 'value2')
101
+ # options.each_with_object({}) { |(option_key, option_value), obj| obj[option_key] = option_value }
102
+ # # => { option1: "value1", option2: "value2" }
103
+ # @yield [option_key, option_value, obj]
104
+ # @return [void]
105
+ def each_with_object(obj, &)
106
+ @options.each_with_object(obj, &)
107
+ end
108
+
109
+ # Merge the given options into the current options
110
+ # @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'
115
+ #
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
123
+ #
124
+ # If any keyword arguments are given, the copy will be created with the
125
+ # respective option values updated.
126
+ #
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]
138
+ #
139
+ def with(**options_hash)
140
+ self.class.new(**@options, **options_hash)
141
+ end
142
+
143
+ # The list of validation errors
144
+ #
145
+ # Validators should add an error messages to this array.
146
+ #
147
+ # @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
+ # ]
156
+ #
157
+ # @return [Array<String>]
158
+ # @api private
159
+ attr_reader :errors
160
+
161
+ protected
162
+
163
+ # An array of OptionDefinition objects that define the allowed options
164
+ #
165
+ # Subclasses MUST override this method to define the allowed options.
166
+ #
167
+ # @return [Array<OptionDefinition>]
168
+ #
169
+ # @api private
170
+ #
171
+ def define_options
172
+ [].freeze
173
+ end
174
+
175
+ # Determine if the given option is a valid option
176
+ #
177
+ # May be overridden by subclasses to add additional validation.
178
+ #
179
+ # @param option [Symbol] the option to be tested
180
+ # @return [Boolean] true if the given option is a valid option
181
+ # @api private
182
+ def valid_option?(option)
183
+ allowed_options.keys.include?(option)
184
+ end
185
+
186
+ private
187
+
188
+ # @!attribute [r]
189
+ #
190
+ # A hash of all options keyed by the option name
191
+ #
192
+ # @return [Hash{Symbol => Object}]
193
+ #
194
+ # @api private
195
+ #
196
+ attr_reader :options
197
+
198
+ # Raise an argument error for invalid option values
199
+ # @return [void]
200
+ # @raise [ArgumentError] if any invalid option values are found
201
+ # @api private
202
+ def validate_options
203
+ options.each_key do |option_key|
204
+ validator = allowed_options[option_key]&.validator
205
+ instance_exec(&validator.to_proc) unless validator.nil?
206
+ end
207
+
208
+ raise ArgumentError, errors.join("\n") unless errors.empty?
209
+ end
210
+
211
+ # Define accessor methods for each option
212
+ # @return [void]
213
+ # @api private
214
+ def define_accessor_methods
215
+ allowed_options.each_key do |option|
216
+ define_singleton_method(option) do
217
+ @options[option]
218
+ end
219
+ end
220
+ end
221
+
222
+ # Determine if the options hash contains any unknown options
223
+ # @return [void]
224
+ # @raise [ArgumentError] if the options hash contains any unknown options
225
+ # @api private
226
+ def assert_no_unknown_options
227
+ unknown_options = options.keys.reject { |key| valid_option?(key) }
228
+
229
+ return if unknown_options.empty?
230
+
231
+ # :nocov: SimpleCov on JRuby reports the last with the last argument line is not covered
232
+ raise(
233
+ ArgumentError,
234
+ "Unknown option#{unknown_options.count > 1 ? 's' : ''}: #{unknown_options.join(', ')}"
235
+ )
236
+ # :nocov:
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProcessExecuter
4
+ module Options
5
+ # Defines an option that can be used by an Options object
6
+ #
7
+ # @api public
8
+ #
9
+ class OptionDefinition
10
+ # The name of the option
11
+ #
12
+ # @example
13
+ # option = ProcessExecuter::Options::OptionDefinition.new(:timeout_after)
14
+ # option.name # => :timeout_after
15
+ #
16
+ # @return [Symbol]
17
+ #
18
+ attr_reader :name
19
+
20
+ # The default value of the option
21
+ #
22
+ # @example
23
+ # option = ProcessExecuter::Options::OptionDefinition.new(:timeout_after, default: 10)
24
+ # option.default # => 10
25
+ #
26
+ # @return [Object]
27
+ #
28
+ attr_reader :default
29
+
30
+ # A method or proc that validates the option
31
+ #
32
+ # @example
33
+ # option = ProcessExecuter::Options::OptionDefinition.new(
34
+ # :timeout_after, validator: method(:validate_timeout_after)
35
+ # )
36
+ # option.validator # => #<Method: ProcessExecuter#validate_timeout_after>
37
+ #
38
+ # @return [Method, Proc, nil]
39
+ #
40
+ attr_reader :validator
41
+
42
+ # Create a new option definition
43
+ #
44
+ # @example
45
+ # option = ProcessExecuter::Options::OptionDefinition.new(
46
+ # :timeout_after, default: 10, validator: -> { timeout_after.is_a?(Numeric) }
47
+ # )
48
+ #
49
+ def initialize(name, default: nil, validator: nil)
50
+ @name = name
51
+ @default = default
52
+ @validator = validator
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'spawn_and_wait_options'
4
+ require_relative 'option_definition'
5
+
6
+ module ProcessExecuter
7
+ module Options
8
+ # Define options for the `ProcessExecuter.run`
9
+ #
10
+ # @api public
11
+ #
12
+ class RunOptions < SpawnAndWaitOptions
13
+ private
14
+
15
+ # :nocov: SimpleCov on JRuby reports the last with the last argument line is not covered
16
+
17
+ # The options allowed for objects of this class
18
+ # @return [Array<OptionDefinition>]
19
+ # @api private
20
+ def define_options
21
+ [
22
+ *super,
23
+ OptionDefinition.new(:raise_errors, default: true, validator: method(:validate_raise_errors)),
24
+ OptionDefinition.new(:logger, default: Logger.new(nil), validator: method(:validate_logger))
25
+ ].freeze
26
+ end
27
+ # :nocov:
28
+
29
+ # Validate the raise_errors option value
30
+ # @return [String, nil] the error message if the value is not valid
31
+ # @api private
32
+ def validate_raise_errors
33
+ return if [true, false].include?(raise_errors)
34
+
35
+ errors << "raise_errors must be true or false but was #{raise_errors.inspect}"
36
+ end
37
+
38
+ # Validate the logger option value
39
+ # @return [String, nil] the error message if the value is not valid
40
+ # @api private
41
+ def validate_logger
42
+ return if logger.respond_to?(:info) && logger.respond_to?(:debug)
43
+
44
+ errors << "logger must respond to #info and #debug but was #{logger.inspect}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'spawn_options'
4
+ require_relative 'option_definition'
5
+
6
+ module ProcessExecuter
7
+ module Options
8
+ # Define options for the `ProcessExecuter.spawn_and_wait`
9
+ #
10
+ # @api public
11
+ #
12
+ class SpawnAndWaitOptions < SpawnOptions
13
+ private
14
+
15
+ # The options allowed for objects of this class
16
+ # @return [Array<OptionDefinition>]
17
+ # @api private
18
+ def define_options
19
+ # :nocov: SimpleCov on JRuby reports the last with the last argument line is not covered
20
+ [
21
+ *super,
22
+ OptionDefinition.new(:timeout_after, default: nil, validator: method(:validate_timeout_after))
23
+ ].freeze
24
+ # :nocov:
25
+ end
26
+
27
+ # Raise an error unless timeout_after is nil or a non-negative real number
28
+ # @return [void]
29
+ # @raise [ArgumentError] if timeout_after is not a non-negative real number
30
+ # @api private
31
+ def validate_timeout_after
32
+ return if timeout_after.nil?
33
+ return if timeout_after.is_a?(Numeric) && timeout_after.real? && !timeout_after.negative?
34
+
35
+ errors << "timeout_after must be nil or a non-negative real number but was #{timeout_after.inspect}"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative 'option_definition'
5
+
6
+ module ProcessExecuter
7
+ module Options
8
+ # Validate Process.spawn options and return Process.spawn options
9
+ #
10
+ # Allow subclasses to add additional options that are not passed to `Process.spawn`
11
+ #
12
+ # Valid options are those accepted by Process.spawn.
13
+ #
14
+ # @api public
15
+ #
16
+ class SpawnOptions < Base
17
+ # :nocov: SimpleCov on JRuby reports hashes declared on multiple lines as not covered
18
+ SPAWN_OPTIONS = [
19
+ OptionDefinition.new(:unsetenv_others, default: :not_set),
20
+ OptionDefinition.new(:pgroup, default: :not_set),
21
+ OptionDefinition.new(:new_pgroup, default: :not_set),
22
+ OptionDefinition.new(:rlimit_resourcename, default: :not_set),
23
+ OptionDefinition.new(:umask, default: :not_set),
24
+ OptionDefinition.new(:close_others, default: :not_set),
25
+ OptionDefinition.new(:chdir, default: :not_set)
26
+ ].freeze
27
+ # :nocov:
28
+
29
+ # Returns the options to be passed to Process.spawn
30
+ #
31
+ # @example
32
+ # options = ProcessExecuter::Options.new(out: $stdout, err: $stderr, timeout_after: 10)
33
+ # options.spawn_options # => { out: $stdout, err: $stderr }
34
+ #
35
+ # @return [Hash]
36
+ #
37
+ def spawn_options
38
+ {}.tap do |spawn_options|
39
+ options.each do |option_key, value|
40
+ spawn_options[option_key] = value if include_spawn_option?(option_key, value)
41
+ end
42
+ end
43
+ end
44
+
45
+ # Determine if the given option key indicates a redirection option
46
+ # @param option_key [Symbol, Integer, IO, Array] the option key to be tested
47
+ # @return [Boolean]
48
+ # @api private
49
+ def redirection?(option_key)
50
+ test = ->(key) { %i[in out err].include?(key) || key.is_a?(Integer) || (key.is_a?(IO) && !key.fileno.nil?) }
51
+ test.call(option_key) || (option_key.is_a?(Array) && option_key.all? { |key| test.call(key) })
52
+ end
53
+
54
+ # Does option_key indicate a standard redirection such as stdin, stdout, or stderr
55
+ # @param option_key [Symbol, Integer, IO, Array] the option key to be tested
56
+ # @param symbol [:in, :out, :err] the symbol to test for
57
+ # @param fileno [Integer] the file descriptor number to test for
58
+ # @return [Boolean]
59
+ # @api private
60
+ def std_redirection?(option_key, symbol, fileno)
61
+ test = ->(key) { key == symbol || key == fileno || (key.is_a?(IO) && key.fileno == fileno) }
62
+ test.call(option_key) || (option_key.is_a?(Array) && option_key.any? { |key| test.call(key) })
63
+ end
64
+
65
+ # Determine if the given option key indicates a redirection option for stdout
66
+ # @param option_key [Symbol, Integer, IO, Array] the option key to be tested
67
+ # @return [Boolean]
68
+ # @api private
69
+ def stdout_redirection?(option_key) = std_redirection?(option_key, :out, 1)
70
+
71
+ # Determine the option key that indicates a redirection option for stdout
72
+ # @return [Symbol, Integer, IO, Array, nil] nil if not found
73
+ # @api private
74
+ def stdout_redirection_key
75
+ options.keys.find { |option_key| option_key if stdout_redirection?(option_key) }
76
+ end
77
+
78
+ # Determine the value of the redirection option for stdout
79
+ # @return [Object]
80
+ # @api private
81
+ def stdout_redirection_value
82
+ options[stdout_redirection_key]
83
+ end
84
+
85
+ # Determine if the given option key indicates a redirection option for stderr
86
+ # @param option_key [Symbol, Integer, IO, Array] the option key to be tested
87
+ # @return [Boolean]
88
+ # @api private
89
+ def stderr_redirection?(option_key) = std_redirection?(option_key, :err, 2)
90
+
91
+ # Determine the option key that indicates a redirection option for stderr
92
+ # @return [Symbol, Integer, IO, Array, nil] nil if not found
93
+ # @api private
94
+ def stderr_redirection_key
95
+ options.keys.find { |option_key| option_key if stderr_redirection?(option_key) }
96
+ end
97
+
98
+ # Determine the value of the redirection option for stderr
99
+ # @return [Object]
100
+ # @api private
101
+ def stderr_redirection_value
102
+ options[stderr_redirection_key]
103
+ end
104
+
105
+ private
106
+
107
+ # Define the allowed options
108
+ #
109
+ # @example Adding new options in a subclass
110
+ # class MyOptions < SpawnOptions
111
+ # def define_options
112
+ # super.merge(timeout_after: { default: nil, validator: nil })
113
+ # end
114
+ # end
115
+ #
116
+ # @return [Hash<Symbol, Hash>]
117
+ #
118
+ # @api private
119
+ def define_options
120
+ [*super, *SPAWN_OPTIONS].freeze
121
+ end
122
+
123
+ # Determine if the given option should be passed to `Process.spawn`
124
+ # @param option_key [Symbol, Integer, IO] the option to be tested
125
+ # @param value [Object] the value of the option
126
+ # @return [Boolean] true if the given option should be passed to `Process.spawn`
127
+ # @api private
128
+ def include_spawn_option?(option_key, value)
129
+ return false if value == :not_set
130
+
131
+ redirection?(option_key) || SPAWN_OPTIONS.any? { |o| o.name == option_key }
132
+ end
133
+
134
+ # Spawn allows IO object and integers as options
135
+ # @param option_key [Symbol] the option to be tested
136
+ # @return [Boolean] true if the given option is a valid option
137
+ # @api private
138
+ def valid_option?(option_key)
139
+ super || redirection?(option_key)
140
+ end
141
+ end
142
+ end
143
+ end