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,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProcessExecuter
4
+ module Destinations
5
+ # Handles IO objects
6
+ #
7
+ # @api private
8
+ class IO < ProcessExecuter::DestinationBase
9
+ # Writes data to the IO object
10
+ #
11
+ # @param data [String] the data to write
12
+ # @return [Integer] the number of bytes written
13
+ # @raise [IOError] if the IO object is closed
14
+ #
15
+ # @example
16
+ # io = File.open('file.txt', 'w')
17
+ # io_handler = ProcessExecuter::Destinations::IO.new(io)
18
+ # io_handler.write("Hello world")
19
+ def write(data)
20
+ super
21
+ destination.write data
22
+ end
23
+
24
+ # Determines if this class can handle the given destination
25
+ #
26
+ # @param destination [Object] the destination to check
27
+ # @return [Boolean] true if destination is an IO with a valid file descriptor
28
+ def self.handles?(destination)
29
+ destination.is_a?(::IO) && destination.respond_to?(:fileno) && destination.fileno
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProcessExecuter
4
+ module Destinations
5
+ # Handles monitored pipes
6
+ #
7
+ # @api private
8
+ class MonitoredPipe < ProcessExecuter::DestinationBase
9
+ # Writes data to the monitored pipe
10
+ #
11
+ # @param data [String] the data to write
12
+ # @return [Object] the return value of the pipe's write method
13
+ #
14
+ # @example
15
+ # pipe = ProcessExecuter::MonitoredPipe.new
16
+ # pipe_handler = ProcessExecuter::Destinations::MonitoredPipe.new(pipe)
17
+ # pipe_handler.write("Data to pipe")
18
+ def write(data)
19
+ super
20
+ destination.write data
21
+ end
22
+
23
+ # Closes the pipe if it's open
24
+ #
25
+ # @return [void]
26
+ def close
27
+ destination.close if destination.state == :open
28
+ end
29
+
30
+ # Determines if this class can handle the given destination
31
+ #
32
+ # @param destination [Object] the destination to check
33
+ # @return [Boolean] true if destination is a ProcessExecuter::MonitoredPipe
34
+ def self.handles?(destination)
35
+ destination.is_a? ProcessExecuter::MonitoredPipe
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProcessExecuter
4
+ module Destinations
5
+ # Handles standard error redirection
6
+ #
7
+ # @api private
8
+ class Stderr < ProcessExecuter::DestinationBase
9
+ # Writes data to standard error
10
+ #
11
+ # @param data [String] the data to write
12
+ # @return [Integer] the number of bytes written
13
+ #
14
+ # @example
15
+ # stderr_handler = ProcessExecuter::Destinations::Stderr.new(:err)
16
+ # stderr_handler.write("Error message")
17
+ def write(data)
18
+ super
19
+ $stderr.write data
20
+ end
21
+
22
+ # Determines if this class can handle the given destination
23
+ #
24
+ # @param destination [Object] the destination to check
25
+ # @return [Boolean] true if destination is :err or 2
26
+ def self.handles?(destination)
27
+ [:err, 2].include?(destination)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProcessExecuter
4
+ module Destinations
5
+ # Handles standard output redirection
6
+ #
7
+ # @api private
8
+ class Stdout < ProcessExecuter::DestinationBase
9
+ # Writes data to standard output
10
+ #
11
+ # @param data [String] the data to write
12
+ # @return [Integer] the number of bytes written
13
+ #
14
+ # @example
15
+ # stdout_handler = ProcessExecuter::Destinations::Stdout.new(:out)
16
+ # stdout_handler.write("Hello world")
17
+ def write(data)
18
+ super
19
+ $stdout.write data
20
+ end
21
+
22
+ # Determines if this class can handle the given destination
23
+ #
24
+ # @param destination [Object] the destination to check
25
+ # @return [Boolean] true if destination is :out or 1
26
+ def self.handles?(destination)
27
+ [:out, 1].include?(destination)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProcessExecuter
4
+ module Destinations
5
+ # Handles destination for writing to multiple destinations
6
+ #
7
+ # The destination is an array with the first element being :tee and the rest
8
+ # being the destinations.
9
+ #
10
+ # @api private
11
+ class Tee < ProcessExecuter::DestinationBase
12
+ # Initializes a new file path with mode and permissions destination handler
13
+ #
14
+ # Opens the file at the given path with the specified mode and permissions.
15
+ #
16
+ # @param destination [Array<String, String, Integer>] array with file path, mode, and permissions
17
+ # @return [FilePathModePerms] a new handler instance
18
+ # @raise [Errno::ENOENT] if the file path is invalid
19
+ # @raise [ArgumentError] if the mode is invalid
20
+ def initialize(destination)
21
+ super
22
+ @child_destinations = destination[1..].map { |dest| ProcessExecuter::Destinations.factory(dest) }
23
+ end
24
+
25
+ # The opened file object
26
+ #
27
+ # @return [File] the opened file
28
+ attr_reader :child_destinations
29
+
30
+ # Writes data to the file
31
+ #
32
+ # @param data [String] the data to write
33
+ # @return [Integer] the number of bytes written
34
+ # @raise [IOError] if the file is closed
35
+ #
36
+ # @example
37
+ # perms_handler = ProcessExecuter::Destinations::FilePathModePerms.new(["output.log", "w", 0644])
38
+ # perms_handler.write("Log entry with specific permissions")
39
+ def write(data)
40
+ super
41
+ child_destinations.each { |dest| dest.write(data) }
42
+ end
43
+
44
+ # Closes the file if it's open
45
+ #
46
+ # @return [void]
47
+ def close
48
+ child_destinations.each(&:close)
49
+ end
50
+
51
+ # Determines if this class can handle the given destination
52
+ #
53
+ # @param destination [Object] the destination to check
54
+ # @return [Boolean] true if destination is an Array with path, mode, and permissions
55
+ def self.handles?(destination)
56
+ destination.is_a?(Array) && destination.size > 1 && destination[0] == :tee
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProcessExecuter
4
+ module Destinations
5
+ # Handles generic objects that respond to write
6
+ #
7
+ # @api private
8
+ class Writer < ProcessExecuter::DestinationBase
9
+ # Writes data to the destination object
10
+ #
11
+ # @param data [String] the data to write
12
+ # @return [Object] the return value of the destination's write method
13
+ # @raise [NoMethodError] if the destination doesn't respond to write
14
+ #
15
+ # @example
16
+ # buffer = StringIO.new
17
+ # writer_handler = ProcessExecuter::Destinations::Writer.new(buffer)
18
+ # writer_handler.write("Hello world")
19
+ def write(data)
20
+ super
21
+ destination.write data
22
+ end
23
+
24
+ # Determines if this class can handle the given destination
25
+ #
26
+ # @param destination [Object] the destination to check
27
+ # @return [Boolean] true if destination responds to write but is not an IO with fileno
28
+ def self.handles?(destination)
29
+ destination.respond_to?(:write) && (!destination.respond_to?(:fileno) || destination.fileno.nil?)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'destinations/child_redirection'
4
+ require_relative 'destinations/file_descriptor'
5
+ require_relative 'destinations/file_path'
6
+ require_relative 'destinations/file_path_mode'
7
+ require_relative 'destinations/file_path_mode_perms'
8
+ require_relative 'destinations/io'
9
+ require_relative 'destinations/monitored_pipe'
10
+ require_relative 'destinations/stderr'
11
+ require_relative 'destinations/stdout'
12
+ require_relative 'destinations/tee'
13
+ require_relative 'destinations/writer'
14
+
15
+ module ProcessExecuter
16
+ # Collection of destination handler implementations
17
+ #
18
+ # @api public
19
+ module Destinations
20
+ # Creates appropriate destination objects based on the given destination
21
+ #
22
+ # This factory method dynamically finds and instantiates the appropriate
23
+ # destination class for handling the provided destination.
24
+ #
25
+ # @param destination [Object] the destination to create a handler for
26
+ # @return [DestinationBase] an instance of the appropriate destination handler
27
+ # @raise [ArgumentError] if no matching destination class is found
28
+ #
29
+ # @example
30
+ # ProcessExecuter.destination_factory(1) #=> Returns a Stdout instance
31
+ # ProcessExecuter.destination_factory("output.log") #=> Returns a FilePath instance
32
+ def self.factory(destination)
33
+ matching_class = matching_destination_class(destination)
34
+ return matching_class.new(destination) if matching_class
35
+
36
+ raise ArgumentError, 'wrong exec redirect action'
37
+ end
38
+
39
+ # Determines if the given destination is compatible with a monitored pipe
40
+ #
41
+ # If true, this destination should not be wrapped in a monitored pipe.
42
+ #
43
+ # @example
44
+ # ProcessExecuter::Destinations.compatible_with_monitored_pipe?(1) #=> true
45
+ # ProcessExecuter::Destinations.compatible_with_monitored_pipe?([:child, 6]) #=> false
46
+ # ProcessExecuter::Destinations.compatible_with_monitored_pipe?([:close]) #=> false
47
+ #
48
+ # @param destination [Object] the destination to check
49
+ # @return [Boolean, nil] true if the destination is compatible with a monitored pipe
50
+ # @raise [ArgumentError] if no matching destination class is found
51
+ # @api public
52
+ def self.compatible_with_monitored_pipe?(destination)
53
+ matching_class = matching_destination_class(destination)
54
+ matching_class&.compatible_with_monitored_pipe?
55
+ end
56
+
57
+ # Determines the destination class that can handle the given destination
58
+ # @param destination [Object] the destination to check
59
+ # @return [Class] the destination class that can handle the given destination
60
+ # @api private
61
+ def self.matching_destination_class(destination)
62
+ destination_classes =
63
+ ProcessExecuter::Destinations.constants
64
+ .map { |const| ProcessExecuter::Destinations.const_get(const) }
65
+ .select { |const| const.is_a?(Class) }
66
+
67
+ destination_classes.find { |klass| klass.handles?(destination) }
68
+ end
69
+ end
70
+ end
@@ -4,19 +4,16 @@ require 'stringio'
4
4
  require 'io/wait'
5
5
 
6
6
  module ProcessExecuter
7
- # Stream data sent through a pipe to one or more writers
7
+ # Write data sent through a pipe to a destination
8
8
  #
9
9
  # When a new MonitoredPipe is created, a pipe is created (via IO.pipe) and
10
10
  # a thread is created to read data written to the pipe.
11
11
  #
12
- # Data that is read from that pipe is written one or more writers passed to
13
- # `#initialize`.
14
- #
15
- # If any of the writers raise an exception, the monitoring thread will exit, the
12
+ # If the destination raises an exception, the monitoring thread will exit, the
16
13
  # pipe will be closed, and the exception will be saved in `#exception`.
17
14
  #
18
15
  # `#close` must be called to ensure that (1) the pipe is closed, (2) all data is
19
- # read from the pipe and written to the writers, and (3) the monitoring thread is
16
+ # read from the pipe and written to the destination, and (3) the monitoring thread is
20
17
  # killed.
21
18
  #
22
19
  # @example Collect pipe data into a string
@@ -52,11 +49,15 @@ module ProcessExecuter
52
49
  # data_collector = StringIO.new
53
50
  # pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
54
51
  #
55
- # @param writers [Array<#write>] as data is read from the pipe, it is written to these writers
52
+ # @param redirection_destination [Array<#write>] as data is read from the pipe,
53
+ # it is written to this destination
56
54
  # @param chunk_size [Integer] the size of the chunks to read from the pipe
57
55
  #
58
- def initialize(*writers, chunk_size: 100_000)
59
- @writers = writers
56
+ def initialize(redirection_destination, chunk_size: 100_000)
57
+ @destination = Destinations.factory(redirection_destination)
58
+
59
+ assert_destination_is_compatible_with_monitored_pipe
60
+
60
61
  @chunk_size = chunk_size
61
62
  @pipe_reader, @pipe_writer = IO.pipe
62
63
  @state = :open
@@ -87,12 +88,14 @@ module ProcessExecuter
87
88
 
88
89
  @state = :closing
89
90
  sleep 0.001 until state == :closed
91
+
92
+ destination.close
90
93
  end
91
94
 
92
95
  # Return the write end of the pipe so that data can be written to it
93
96
  #
94
97
  # Data written to this end of the pipe will be read by the monitor thread and
95
- # written to the writers passed to `#initialize`.
98
+ # written to the destination.
96
99
  #
97
100
  # This is so we can provide a MonitoredPipe to Process.spawn as a FD
98
101
  #
@@ -170,24 +173,17 @@ module ProcessExecuter
170
173
 
171
174
  # @!attribute [r]
172
175
  #
173
- # An array of writers to write data that is read from the pipe
176
+ # The redirection destination to write data that is read from the pipe
174
177
  #
175
- # @example with one writer
178
+ # @example
176
179
  # require 'stringio'
177
180
  # data_collector = StringIO.new
178
181
  # pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
179
- # pipe.writers #=> [data_collector]
182
+ # pipe.destination #=>
180
183
  #
181
- # @example with an array of writers
182
- # require 'stringio'
183
- # data_collector1 = StringIO.new
184
- # data_collector2 = StringIO.new
185
- # pipe = ProcessExecuter::MonitoredPipe.new(data_collector1, data_collector2)
186
- # pipe.writers #=> [data_collector1, data_collector2]]
187
- #
188
- # @return [Array<#write>]
184
+ # @return [Array<ProcessExecuter::Destination::Base>]
189
185
  #
190
- attr_reader :writers
186
+ attr_reader :destination
191
187
 
192
188
  # @!attribute [r]
193
189
  #
@@ -246,19 +242,29 @@ module ProcessExecuter
246
242
 
247
243
  # @!attribute [r]
248
244
  #
249
- # The exception raised by a writer
245
+ # The exception raised by a destination
250
246
  #
251
- # If an exception is raised by a writer, it is stored here. Otherwise, it is `nil`.
247
+ # If an exception is raised by a destination, it is stored here. Otherwise, it is `nil`.
252
248
  #
253
249
  # @example
254
250
  # pipe.exception #=> nil
255
251
  #
256
- # @return [Exception, nil] the exception raised by a writer or `nil` if no exception was raised
252
+ # @return [Exception, nil] the exception raised by a destination or `nil` if no exception was raised
257
253
  #
258
254
  attr_reader :exception
259
255
 
260
256
  private
261
257
 
258
+ # Raise an error if the destination is not compatible with MonitoredPipe
259
+ # @return [void]
260
+ # @raise [ArgumentError] if the destination is not compatible with MonitoredPipe
261
+ # @api private
262
+ def assert_destination_is_compatible_with_monitored_pipe
263
+ return if destination.compatible_with_monitored_pipe?
264
+
265
+ raise ArgumentError, "Destination #{destination.destination} is not compatible with MonitoredPipe"
266
+ end
267
+
262
268
  # Read data from the pipe until `#state` is changed to `:closing`
263
269
  #
264
270
  # The state is changed to `:closed` by calling `#close`.
@@ -275,7 +281,7 @@ module ProcessExecuter
275
281
 
276
282
  # Read data from the pipe until `#state` is changed to `:closing`
277
283
  #
278
- # Data read from the pipe is written to the writers given to the constructor.
284
+ # Data read from the pipe is written to the destination.
279
285
  #
280
286
  # @return [void]
281
287
  # @api private
@@ -286,14 +292,14 @@ module ProcessExecuter
286
292
  pipe_reader.wait_readable(0.001)
287
293
  end
288
294
 
289
- # Check if the writer is a file descriptor
290
- #
291
- # @param writer [#write] the writer to check
292
- # @return [Boolean] true if the writer is a file descriptor
293
- # @api private
294
- def file_descriptor?(writer) = writer.is_a?(Integer) || writer.is_a?(Symbol)
295
+ # # Check if the writer is a file descriptor
296
+ # #
297
+ # # @param writer [#write] the writer to check
298
+ # # @return [Boolean] true if the writer is a file descriptor
299
+ # # @api private
300
+ # def file_descriptor?(writer) = writer.is_a?(Integer) || writer.is_a?(Symbol)
295
301
 
296
- # Write the data read from the pipe to all destinations
302
+ # Write the data read from the pipe to the destination
297
303
  #
298
304
  # If an exception is raised by a writer, set the state to `:closing`
299
305
  # so that the pipe can be closed.
@@ -302,35 +308,12 @@ module ProcessExecuter
302
308
  # @return [void]
303
309
  # @api private
304
310
  def write_data(data)
305
- writers.each do |w|
306
- file_descriptor?(w) ? write_data_to_fd(w, data) : w.write(data)
307
- end
311
+ destination.write(data)
308
312
  rescue StandardError => e
309
313
  @exception = e
310
314
  @state = :closing
311
315
  end
312
316
 
313
- # Write data to the given file_descriptor correctly handling stdout and stderr
314
- # @param file_descriptor [Integer, Symbol] the file descriptor to write to (either an integer or :out or :err)
315
- # @param data [String] the data to write
316
- # @return [void]
317
- # @api private
318
- def write_data_to_fd(file_descriptor, data)
319
- # The case line is not marked as not covered only when using TruffleRuby
320
- # :nocov:
321
- case file_descriptor
322
- # :nocov:
323
- when :out, 1
324
- $stdout.write data
325
- when :err, 2
326
- $stderr.write data
327
- else
328
- io = IO.open(file_descriptor, mode: 'a', autoclose: false)
329
- io.write(data)
330
- io.close
331
- end
332
- end
333
-
334
317
  # Read any remaining data from the pipe and close it
335
318
  #
336
319
  # @return [void]