process_executer 2.0.0 → 3.1.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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +159 -46
  4. data/lib/process_executer/destination_base.rb +83 -0
  5. data/lib/process_executer/destinations/child_redirection.rb +23 -0
  6. data/lib/process_executer/destinations/close.rb +23 -0
  7. data/lib/process_executer/destinations/file_descriptor.rb +36 -0
  8. data/lib/process_executer/destinations/file_path.rb +56 -0
  9. data/lib/process_executer/destinations/file_path_mode.rb +60 -0
  10. data/lib/process_executer/destinations/file_path_mode_perms.rb +61 -0
  11. data/lib/process_executer/destinations/io.rb +33 -0
  12. data/lib/process_executer/destinations/monitored_pipe.rb +39 -0
  13. data/lib/process_executer/destinations/stderr.rb +31 -0
  14. data/lib/process_executer/destinations/stdout.rb +31 -0
  15. data/lib/process_executer/destinations/tee.rb +60 -0
  16. data/lib/process_executer/destinations/writer.rb +33 -0
  17. data/lib/process_executer/destinations.rb +70 -0
  18. data/lib/process_executer/errors.rb +11 -1
  19. data/lib/process_executer/monitored_pipe.rb +40 -57
  20. data/lib/process_executer/options/base.rb +240 -0
  21. data/lib/process_executer/options/option_definition.rb +56 -0
  22. data/lib/process_executer/options/run_options.rb +48 -0
  23. data/lib/process_executer/options/spawn_and_wait_options.rb +39 -0
  24. data/lib/process_executer/options/spawn_options.rb +143 -0
  25. data/lib/process_executer/options.rb +7 -166
  26. data/lib/process_executer/result.rb +13 -23
  27. data/lib/process_executer/runner.rb +60 -56
  28. data/lib/process_executer/version.rb +1 -1
  29. data/lib/process_executer.rb +136 -26
  30. metadata +23 -4
@@ -1,171 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'pp'
3
+ require_relative 'options/base'
4
+ require_relative 'options/spawn_options'
5
+ require_relative 'options/spawn_and_wait_options'
6
+ require_relative 'options/run_options'
7
+ require_relative 'options/option_definition'
4
8
 
5
9
  module ProcessExecuter
6
- # Validate ProcessExecuter::Executer#spawn options and return Process.spawn options
7
- #
8
- # Valid options are those accepted by Process.spawn plus the following additions:
9
- #
10
- # * `:timeout_after`: the number of seconds to allow a process to run before killing it
11
- #
12
- # @api public
13
- #
14
- class Options
15
- # :nocov:
16
- # SimpleCov on JRuby seems to hav a bug that causes hashes declared on multiple lines
17
- # to not be counted as covered.
18
-
19
- # These options should be passed to `Process.spawn`
20
- #
21
- # Additionally, any options whose key is an Integer or an IO object will
22
- # be passed to `Process.spawn`.
23
- #
24
- SPAWN_OPTIONS = %i[
25
- in out err unsetenv_others pgroup new_pgroup rlimit_resourcename umask
26
- close_others chdir
27
- ].freeze
28
-
29
- # These options are allowed by `ProcessExecuter.spawn` but should NOT be passed
30
- # to `Process.spawn`
31
- #
32
- NON_SPAWN_OPTIONS = %i[
33
- timeout_after raise_errors
34
- ].freeze
35
-
36
- # Any `SPAWN_OPTIONS` set to `NOT_SET` will not be passed to `Process.spawn`
37
- #
38
- NOT_SET = :not_set
39
-
40
- # The default values for all options
41
- # @return [Hash]
42
- DEFAULTS = {
43
- in: NOT_SET,
44
- out: NOT_SET,
45
- err: NOT_SET,
46
- unsetenv_others: NOT_SET,
47
- pgroup: NOT_SET,
48
- new_pgroup: NOT_SET,
49
- rlimit_resourcename: NOT_SET,
50
- umask: NOT_SET,
51
- close_others: NOT_SET,
52
- chdir: NOT_SET,
53
- raise_errors: true,
54
- timeout_after: nil
55
- }.freeze
56
-
57
- # :nocov:
58
-
59
- # All options allowed by this class
60
- #
61
- ALL_OPTIONS = (SPAWN_OPTIONS + NON_SPAWN_OPTIONS).freeze
62
-
63
- # Create accessor functions for all options. Assumes that the options are stored
64
- # in a hash named `@options`
65
- #
66
- ALL_OPTIONS.each do |option|
67
- define_method(option) do
68
- @options[option]
69
- end
70
- end
71
-
72
- # Create a new Options object
73
- #
74
- # @example
75
- # options = ProcessExecuter::Options.new(out: $stdout, err: $stderr, timeout_after: 10)
76
- #
77
- # @param options [Hash] Process.spawn options plus additional options listed below.
78
- #
79
- # See [Process.spawn](https://ruby-doc.org/core/Process.html#method-c-spawn)
80
- # for a list of valid options that can be passed to `Process.spawn`.
81
- #
82
- # @option options [Integer, Float, nil] :timeout_after
83
- # Number of seconds to wait for the process to terminate. Any number
84
- # may be used, including Floats to specify fractional seconds. A value of 0 or nil
85
- # will allow the process to run indefinitely.
86
- #
87
- def initialize(**options)
88
- assert_no_unknown_options(options)
89
- @options = DEFAULTS.merge(options)
90
- assert_timeout_is_valid
91
- end
92
-
93
- # Returns the options to be passed to Process.spawn
94
- #
95
- # @example
96
- # options = ProcessExecuter::Options.new(out: $stdout, err: $stderr, timeout_after: 10)
97
- # options.spawn_options # => { out: $stdout, err: $stderr }
98
- #
99
- # @return [Hash]
100
- #
101
- def spawn_options
102
- {}.tap do |spawn_options|
103
- options.each do |option, value|
104
- spawn_options[option] = value if include_spawn_option?(option, value)
105
- end
106
- end
107
- end
108
-
109
- private
110
-
111
- # @!attribute [r]
112
- #
113
- # Options with values
114
- #
115
- # All options have values. If an option is not given in the initializer, it
116
- # will have the value `NOT_SET`.
117
- #
118
- # @return [Hash<Symbol, Object>]
119
- #
120
- # @api private
121
- #
122
- attr_reader :options
123
-
124
- # Determine if the options hash contains any unknown options
125
- # @param options [Hash] the hash of options
126
- # @return [void]
127
- # @raise [ArgumentError] if the options hash contains any unknown options
128
- # @api private
129
- def assert_no_unknown_options(options)
130
- unknown_options = options.keys.reject { |key| valid_option?(key) }
131
- raise ArgumentError, "Unknown options: #{unknown_options.join(', ')}" unless unknown_options.empty?
132
- end
133
-
134
- # Raise an error if timeout_after is not a non-negative real number
135
- # @return [void]
136
- # @raise [ArgumentError] if timeout_after is not a non-negative real number
137
- # @api private
138
- def assert_timeout_is_valid
139
- return if @options[:timeout_after].nil?
140
- return if @options[:timeout_after].is_a?(Numeric) &&
141
- @options[:timeout_after].real? &&
142
- !@options[:timeout_after].negative?
143
-
144
- raise ArgumentError, invalid_timeout_after_message
145
- end
146
-
147
- # The message to be used when raising an error for an invalid timeout_after
148
- # @return [String]
149
- # @api private
150
- def invalid_timeout_after_message
151
- "timeout_after must be nil or a non-negative real number but was #{options[:timeout_after].pretty_inspect}"
152
- end
153
-
154
- # Determine if the given option is a valid option
155
- # @param option [Symbol] the option to be tested
156
- # @return [Boolean] true if the given option is a valid option
157
- # @api private
158
- def valid_option?(option)
159
- ALL_OPTIONS.include?(option) || option.is_a?(Integer) || option.respond_to?(:fileno)
160
- end
161
-
162
- # Determine if the given option should be passed to `Process.spawn`
163
- # @param option [Symbol, Integer, IO] the option to be tested
164
- # @param value [Object] the value of the option
165
- # @return [Boolean] true if the given option should be passed to `Process.spawn`
166
- # @api private
167
- def include_spawn_option?(option, value)
168
- (option.is_a?(Integer) || option.is_a?(IO) || SPAWN_OPTIONS.include?(option)) && value != NOT_SET
169
- end
170
- end
10
+ # Options related to spawning or running a command
11
+ module Options; end
171
12
  end
@@ -80,7 +80,9 @@ module ProcessExecuter
80
80
  # result.timed_out? # => true
81
81
  # @return [Boolean]
82
82
  #
83
- def timed_out? = @timed_out
83
+ def timed_out?
84
+ @timed_out
85
+ end
84
86
 
85
87
  # Overrides the default success? method to return nil if the process timed out
86
88
  #
@@ -107,9 +109,8 @@ module ProcessExecuter
107
109
 
108
110
  # Return the captured stdout output
109
111
  #
110
- # This output is only returned if the `:out` option was set to a
111
- # `ProcessExecuter::MonitoredPipe` that includes a writer that implements `#string`
112
- # method (e.g. a StringIO).
112
+ # This output is only returned if the `:out` option value is a
113
+ # `ProcessExecuter::MonitoredPipe`.
113
114
  #
114
115
  # @example
115
116
  # # Note that `ProcessExecuter.run` will wrap the given out: object in a
@@ -120,22 +121,16 @@ module ProcessExecuter
120
121
  # @return [String, nil]
121
122
  #
122
123
  def stdout
123
- Array(options.out).each do |pipe|
124
- next unless pipe.is_a?(ProcessExecuter::MonitoredPipe)
125
-
126
- pipe.writers.each do |writer|
127
- return writer.string if writer.respond_to?(:string)
128
- end
129
- end
124
+ pipe = options.stdout_redirection_value
125
+ return nil unless pipe.is_a?(ProcessExecuter::MonitoredPipe)
130
126
 
131
- nil
127
+ pipe.destination.string
132
128
  end
133
129
 
134
130
  # Return the captured stderr output
135
131
  #
136
- # This output is only returned if the `:err` option was set to a
137
- # `ProcessExecuter::MonitoredPipe` that includes a writer that implements `#string`
138
- # method (e.g. a StringIO).
132
+ # This output is only returned if the `:err` option value is a
133
+ # `ProcessExecuter::MonitoredPipe`.
139
134
  #
140
135
  # @example
141
136
  # # Note that `ProcessExecuter.run` will wrap the given err: object in a
@@ -146,15 +141,10 @@ module ProcessExecuter
146
141
  # @return [String, nil]
147
142
  #
148
143
  def stderr
149
- Array(options.err).each do |pipe|
150
- next unless pipe.is_a?(ProcessExecuter::MonitoredPipe)
151
-
152
- pipe.writers.each do |writer|
153
- return writer.string if writer.respond_to?(:string)
154
- end
155
- end
144
+ pipe = options.stderr_redirection_value
145
+ return nil unless pipe.is_a?(ProcessExecuter::MonitoredPipe)
156
146
 
157
- nil
147
+ pipe.destination.string
158
148
  end
159
149
  end
160
150
  end
@@ -15,24 +15,6 @@ module ProcessExecuter
15
15
  # @api public
16
16
  #
17
17
  class Runner
18
- # Create a new RunCommand instance
19
- #
20
- # @example
21
- # runner = Runner.new()
22
- # result = runner.call('echo', 'hello')
23
- #
24
- # @param logger [Logger] The logger to use. Defaults to a no-op logger if nil.
25
- #
26
- def initialize(logger = Logger.new(nil))
27
- @logger = logger
28
- end
29
-
30
- # The logger to use
31
- # @example
32
- # runner.logger #=> #<Logger:0x00007f9b1b8b3d20>
33
- # @return [Logger]
34
- attr_reader :logger
35
-
36
18
  # Run a command and return the status including stdout and stderr output
37
19
  #
38
20
  # @example
@@ -45,20 +27,12 @@ module ProcessExecuter
45
27
  # result.stderr # => ""
46
28
  #
47
29
  # @param command [Array<String>] The command to run
48
- # @param out [#write, Array<#write>, nil] The object (or array of objects) to which stdout is written
49
- # @param err [#write, Array<#write>, nil] The object (or array of objects) to which stderr is written
50
- # @param merge [Boolean] Write both stdout and stderr into the buffer for stdout
51
- # @param options_hash [Hash] Additional options to pass to Process.spawn
52
- #
53
- # See {ProcessExecuter.run} for a full list of options.
30
+ # @param options [ProcessExecuter::Options::RunOptions] Options for running the command
54
31
  #
55
32
  # @return [ProcessExecuter::Result] The result of the completed subprocess
56
33
  #
57
- def call(*command, out: nil, err: nil, merge: false, **options_hash)
58
- out ||= StringIO.new
59
- err ||= (merge ? out : StringIO.new)
60
-
61
- spawn(command, out:, err:, **options_hash).tap { |result| process_result(result) }
34
+ def call(command, options)
35
+ spawn(command, options).tap { |result| process_result(result) }
62
36
  end
63
37
 
64
38
  private
@@ -66,30 +40,55 @@ module ProcessExecuter
66
40
  # Wrap the output buffers in pipes and then execute the command
67
41
  #
68
42
  # @param command [Array<String>] The command to execute
69
- # @param out [#write, Array<#write>] The object (or array of objects) to which stdout is written
70
- # @param err [#write, Array<#write>] The object (or array of objects) to which stderr is written
71
- # @param options_hash [Hash] Additional options to pass to Process.spawn
72
- #
73
- # See {ProcessExecuter.run} for a full list of options.
43
+ # @param options [ProcessExecuter::Options::RunOptions] Options for running the command
74
44
  #
75
- # @raise [ProcessExecuter::ProcessIOError] If an exception was raised while collecting subprocess output
76
- # @raise [ProcessExecuter::TimeoutError] If the command times out
45
+ # @raise [ProcessExecuter::Error] if the command could not be executed or failed
77
46
  #
78
47
  # @return [ProcessExecuter::Result] The result of the completed subprocess
79
48
  #
80
49
  # @api private
81
50
  #
82
- def spawn(command, out:, err:, **options_hash)
83
- out = [out] unless out.is_a?(Array)
84
- err = [err] unless err.is_a?(Array)
85
- out_pipe = ProcessExecuter::MonitoredPipe.new(*out)
86
- err_pipe = ProcessExecuter::MonitoredPipe.new(*err)
87
- ProcessExecuter.spawn_and_wait(*command, out: out_pipe, err: err_pipe, **options_hash)
51
+ def spawn(command, options)
52
+ opened_pipes = wrap_stdout_stderr(options)
53
+ ProcessExecuter.spawn_and_wait_with_options(command, options)
88
54
  ensure
89
- out_pipe.close
90
- err_pipe.close
91
- raise_pipe_error(command, :stdout, out_pipe) if out_pipe.exception
92
- raise_pipe_error(command, :stderr, err_pipe) if err_pipe.exception
55
+ opened_pipes.each { |key, value| close_pipe(command, key, value) }
56
+ end
57
+
58
+ # Wrap the stdout and stderr redirection options with a MonitoredPipe
59
+ # @param options [ProcessExecuter::Options::RunOptions] Options for running the command
60
+ # @return [Hash<Object, ProcessExecuter::MonitoredPipe>] The opened pipes (the Object is the option key)
61
+ # @api private
62
+ def wrap_stdout_stderr(options)
63
+ options.each_with_object({}) do |key_value, opened_pipes|
64
+ key, value = key_value
65
+
66
+ next unless should_wrap?(options, key, value)
67
+
68
+ wrapped_destination = ProcessExecuter::MonitoredPipe.new(value)
69
+ opened_pipes[key] = wrapped_destination
70
+ options.merge!(key => wrapped_destination)
71
+ end
72
+ end
73
+
74
+ # Should the redirection option be wrapped by a MonitoredPipe
75
+ # @param key [Object] The option key
76
+ # @param value [Object] The option value
77
+ # @return [Boolean] Whether the option should be wrapped
78
+ # @api private
79
+ def should_wrap?(options, key, value)
80
+ (options.stdout_redirection?(key) || options.stderr_redirection?(key)) &&
81
+ ProcessExecuter::Destinations.compatible_with_monitored_pipe?(value)
82
+ end
83
+
84
+ # Close the pipe and raise an error if the pipe raised an exception
85
+ # @return [void]
86
+ # @raise [ProcessExecuter::ProcessIOError] If an exception was raised while
87
+ # collecting subprocess output
88
+ # @api private
89
+ def close_pipe(command, option_key, pipe)
90
+ pipe.close
91
+ raise_pipe_error(command, option_key, pipe) if pipe.exception
93
92
  end
94
93
 
95
94
  # Process the result of the command and return a ProcessExecuter::Result
@@ -100,18 +99,23 @@ module ProcessExecuter
100
99
  #
101
100
  # @return [Void]
102
101
  #
103
- # @raise [ProcessExecuter::FailedError] If the command failed
104
- # @raise [ProcessExecuter::SignaledError] If the command was signaled
105
- # @raise [ProcessExecuter::TimeoutError] If the command times out
106
- # @raise [ProcessExecuter::ProcessIOError] If an exception was raised while collecting subprocess output
102
+ # @raise [ProcessExecuter::Error] if the command could not be executed or failed
107
103
  #
108
104
  # @api private
109
105
  #
110
106
  def process_result(result)
111
107
  log_result(result)
112
108
 
113
- return unless result.options.raise_errors
109
+ raise_errors(result) if result.options.raise_errors
110
+ end
114
111
 
112
+ # Raise an error if the command failed
113
+ # @return [void]
114
+ # @raise [ProcessExecuter::FailedError] If the command failed
115
+ # @raise [ProcessExecuter::SignaledError] If the command was signaled
116
+ # @raise [ProcessExecuter::TimeoutError] If the command times out
117
+ # @api private
118
+ def raise_errors(result)
115
119
  raise TimeoutError, result if result.timed_out?
116
120
  raise SignaledError, result if result.signaled?
117
121
  raise FailedError, result unless result.success?
@@ -123,14 +127,14 @@ module ProcessExecuter
123
127
  # @return [void]
124
128
  # @api private
125
129
  def log_result(result)
126
- logger.info { "#{result.command} exited with status #{result}" }
127
- logger.debug { "stdout:\n#{result.stdout.inspect}\nstderr:\n#{result.stderr.inspect}" }
130
+ result.options.logger.info { "#{result.command} exited with status #{result}" }
131
+ result.options.logger.debug { "stdout:\n#{result.stdout.inspect}\nstderr:\n#{result.stderr.inspect}" }
128
132
  end
129
133
 
130
134
  # Raise an error when there was exception while collecting the subprocess output
131
135
  #
132
136
  # @param command [Array<String>] The command that was executed
133
- # @param pipe_name [Symbol] The name of the pipe that raised the exception
137
+ # @param option_key [Symbol] The name of the pipe that raised the exception
134
138
  # @param pipe [ProcessExecuter::MonitoredPipe] The pipe that raised the exception
135
139
  #
136
140
  # @raise [ProcessExecuter::ProcessIOError]
@@ -139,8 +143,8 @@ module ProcessExecuter
139
143
  #
140
144
  # @api private
141
145
  #
142
- def raise_pipe_error(command, pipe_name, pipe)
143
- error = ProcessExecuter::ProcessIOError.new("Pipe Exception for #{command}: #{pipe_name}")
146
+ def raise_pipe_error(command, option_key, pipe)
147
+ error = ProcessExecuter::ProcessIOError.new("Pipe Exception for #{command}: #{option_key.inspect}")
144
148
  raise(error, cause: pipe.exception)
145
149
  end
146
150
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module ProcessExecuter
4
4
  # The current Gem version
5
- VERSION = '2.0.0'
5
+ VERSION = '3.1.0'
6
6
  end