Skip to content

Commit 2fb0ccf

Browse files
committed
feat: refactor options classes
Splits the Options class into a heirarchy of options classes so that RunOptions builds upon SpawnOptions. Also provides for better defaults and validation.
1 parent bcf35d5 commit 2fb0ccf

File tree

10 files changed

+1251
-215
lines changed

10 files changed

+1251
-215
lines changed

lib/process_executer.rb

Lines changed: 122 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,35 @@
11
# frozen_string_literal: true
22

3+
require 'logger'
4+
require 'timeout'
5+
36
require 'process_executer/errors'
47
require 'process_executer/monitored_pipe'
58
require 'process_executer/options'
9+
require 'process_executer/option_definition'
610
require 'process_executer/result'
711
require 'process_executer/runner'
812

9-
require 'logger'
10-
require 'timeout'
11-
1213
# The `ProcessExecuter` module provides methods to execute subprocess commands
1314
# with enhanced features such as output capture, timeout handling, and custom
1415
# environment variables.
1516
#
1617
# Methods:
18+
#
1719
# * {run}: Executes a command and returns the result which includes the process
1820
# status and output
1921
# * {spawn_and_wait}: a thin wrapper around `Process.spawn` that blocks until the
2022
# command finishes
2123
#
2224
# Features:
25+
#
2326
# * Supports executing commands via a shell or directly.
2427
# * Captures stdout and stderr to buffers, files, or custom objects.
2528
# * Optionally enforces timeouts and terminates long-running commands.
2629
# * Provides detailed status information, including the command that was run, the
2730
# options that were given, and success, failure, or timeout states.
2831
#
2932
# @api public
30-
#
3133
module ProcessExecuter
3234
# Run a command in a subprocess, wait for it to finish, then return the result
3335
#
@@ -70,7 +72,20 @@ module ProcessExecuter
7072
# @return [ProcessExecuter::Result] The result of the completed subprocess
7173
#
7274
def self.spawn_and_wait(*command, **options_hash)
73-
options = ProcessExecuter::Options.new(**options_hash)
75+
options = ProcessExecuter.spawn_and_wait_options(options_hash)
76+
spawn_and_wait_with_options(command, options)
77+
end
78+
79+
# Run a command in a subprocess, wait for it to finish, then return the result
80+
#
81+
# @see ProcessExecuter.spawn_and_wait for full documentation
82+
#
83+
# @param command [Array<String>] The command to run
84+
# @param options [ProcessExecuter::SpawnAndWaitOptions] The options to use when running the command
85+
#
86+
# @return [ProcessExecuter::Result] The result of the completed subprocess
87+
# @api private
88+
def self.spawn_and_wait_with_options(command, options)
7489
pid = Process.spawn(*command, **options.spawn_options)
7590
wait_for_process(pid, command, options)
7691
end
@@ -252,7 +267,6 @@ def self.spawn_and_wait(*command, **options_hash)
252267
# Otherwise, the command is run bypassing the shell. When bypassing the shell, shell expansions
253268
# and redirections are not supported.
254269
#
255-
# @param logger [Logger] The logger to use
256270
# @param options_hash [Hash] Additional options
257271
# @option options_hash [Numeric] :timeout_after The maximum seconds to wait for the
258272
# command to complete
@@ -276,6 +290,7 @@ def self.spawn_and_wait(*command, **options_hash)
276290
# @option options_hash [Integer] :umask (nil) Set the umask (see File.umask)
277291
# @option options_hash [Boolean] :close_others (false) If true, close non-standard file descriptors
278292
# @option options_hash [String] :chdir (nil) The directory to run the command in
293+
# @option options_hash [Logger] :logger The logger to use
279294
#
280295
# @raise [ProcessExecuter::FailedError] if the command returned a non-zero exit status
281296
# @raise [ProcessExecuter::SignaledError] if the command exited because of an unhandled signal
@@ -284,8 +299,23 @@ def self.spawn_and_wait(*command, **options_hash)
284299
#
285300
# @return [ProcessExecuter::Result] The result of the completed subprocess
286301
#
287-
def self.run(*command, logger: Logger.new(nil), **options_hash)
288-
ProcessExecuter::Runner.new(logger).call(*command, **options_hash)
302+
def self.run(*command, **options_hash)
303+
options = ProcessExecuter.run_options(options_hash)
304+
run_with_options(command, options)
305+
end
306+
307+
# Run a command with the given options
308+
#
309+
# @see ProcessExecuter.run for full documentation
310+
#
311+
# @param command [Array<String>] The command to run
312+
# @param options [ProcessExecuter::RunOptions] The options to use when running the command
313+
#
314+
# @return [ProcessExecuter::Result] The result of the completed subprocess
315+
#
316+
# @api private
317+
def self.run_with_options(command, options)
318+
ProcessExecuter::Runner.new.call(command, options)
289319
end
290320

291321
# Wait for process to terminate
@@ -328,4 +358,88 @@ def self.run(*command, logger: Logger.new(nil), **options_hash)
328358

329359
[process_status, timed_out]
330360
end
361+
362+
# Convert a hash to a SpawnOptions object
363+
#
364+
# @example
365+
# options_hash = { out: $stdout }
366+
# options = ProcessExecuter.spawn_options(options_hash) # =>
367+
# #<ProcessExecuter::SpawnOptions:0x00007f8f9b0b3d20 out: $stdout>
368+
# ProcessExecuter.spawn_options(options) # =>
369+
# #<ProcessExecuter::SpawnOptions:0x00007f8f9b0b3d20 out: $stdout>
370+
#
371+
# @param obj [Hash, SpawnOptions] the object to be converted
372+
#
373+
# @return [SpawnOptions]
374+
#
375+
# @raise [ArgumentError] if obj is not a Hash or SpawnOptions
376+
#
377+
# @api public
378+
#
379+
def self.spawn_options(obj)
380+
case obj
381+
when ProcessExecuter::SpawnOptions
382+
obj
383+
when Hash
384+
ProcessExecuter::SpawnOptions.new(**obj)
385+
else
386+
raise ArgumentError, "Expected a Hash or ProcessExecuter::SpawnOptions but got a #{obj.class}"
387+
end
388+
end
389+
390+
# Convert a hash to a SpawnAndWaitOptions object
391+
#
392+
# @example
393+
# options_hash = { out: $stdout }
394+
# options = ProcessExecuter.spawn_and_wait_options(options_hash) # =>
395+
# #<ProcessExecuter::SpawnAndWaitOptions:0x00007f8f9b0b3d20 out: $stdout>
396+
# ProcessExecuter.spawn_and_wait_options(options) # =>
397+
# #<ProcessExecuter::SpawnAndWaitOptions:0x00007f8f9b0b3d20 out: $stdout>
398+
#
399+
# @param obj [Hash, SpawnAndWaitOptions] the object to be converted
400+
#
401+
# @return [SpawnAndWaitOptions]
402+
#
403+
# @raise [ArgumentError] if obj is not a Hash or SpawnOptions
404+
#
405+
# @api public
406+
#
407+
def self.spawn_and_wait_options(obj)
408+
case obj
409+
when ProcessExecuter::SpawnAndWaitOptions
410+
obj
411+
when Hash
412+
ProcessExecuter::SpawnAndWaitOptions.new(**obj)
413+
else
414+
raise ArgumentError, "Expected a Hash or ProcessExecuter::SpawnAndWaitOptions but got a #{obj.class}"
415+
end
416+
end
417+
418+
# Convert a hash to a RunOptions object
419+
#
420+
# @example
421+
# options_hash = { out: $stdout }
422+
# options = ProcessExecuter.run_options(options_hash) # =>
423+
# #<ProcessExecuter::RunOptions:0x00007f8f9b0b3d20 out: $stdout>
424+
# ProcessExecuter.run_options(options) # =>
425+
# #<ProcessExecuter::RunOptions:0x00007f8f9b0b3d20 out: $stdout>
426+
#
427+
# @param obj [Hash, RunOptions] the object to be converted
428+
#
429+
# @return [RunOptions]
430+
#
431+
# @raise [ArgumentError] if obj is not a Hash or SpawnOptions
432+
#
433+
# @api public
434+
#
435+
def self.run_options(obj)
436+
case obj
437+
when ProcessExecuter::RunOptions
438+
obj
439+
when Hash
440+
ProcessExecuter::RunOptions.new(**obj)
441+
else
442+
raise ArgumentError, "Expected a Hash or ProcessExecuter::RunOptions but got a #{obj.class}"
443+
end
444+
end
331445
end

lib/process_executer/monitored_pipe.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,11 @@ class MonitoredPipe
5252
# data_collector = StringIO.new
5353
# pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
5454
#
55-
# @param writers [Array<#write>] as data is read from the pipe, it is written to these writers
55+
# @param writers [#write, Array<#write>] as data is read from the pipe, it is written to these writers
5656
# @param chunk_size [Integer] the size of the chunks to read from the pipe
5757
#
5858
def initialize(*writers, chunk_size: 100_000)
59-
@writers = writers
59+
@writers = writers.is_a?(Array) ? writers : [writers]
6060
@chunk_size = chunk_size
6161
@pipe_reader, @pipe_writer = IO.pipe
6262
@state = :open
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# frozen_string_literal: true
2+
3+
module ProcessExecuter
4+
# Defines an option that can be used by an Options object
5+
#
6+
# @api public
7+
#
8+
class OptionDefinition
9+
# The name of the option
10+
#
11+
# @example
12+
# option = ProcessExecuter::OptionDefinition.new(:timeout_after)
13+
# option.name # => :timeout_after
14+
#
15+
# @return [Symbol]
16+
#
17+
attr_reader :name
18+
19+
# The default value of the option
20+
#
21+
# @example
22+
# option = ProcessExecuter::OptionDefinition.new(:timeout_after, default: 10)
23+
# option.default # => 10
24+
#
25+
# @return [Object]
26+
#
27+
attr_reader :default
28+
29+
# A method or proc that validates the option
30+
#
31+
# @example
32+
# option = ProcessExecuter::OptionDefinition.new(:timeout_after, validator: method(:validate_timeout_after))
33+
# option.validator # => #<Method: ProcessExecuter#validate_timeout_after>
34+
#
35+
# @return [Method, Proc, nil]
36+
#
37+
attr_reader :validator
38+
39+
# Create a new option definition
40+
#
41+
# @example
42+
# option = ProcessExecuter::OptionDefinition.new(
43+
# :timeout_after, default: 10, validator: -> { timeout_after.is_a?(Numeric) }
44+
# )
45+
#
46+
def initialize(name, default: nil, validator: nil)
47+
@name = name
48+
@default = default
49+
@validator = validator
50+
end
51+
end
52+
end

0 commit comments

Comments
 (0)