ruby-sh 2.1.4

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,86 @@
1
+ module Rubsh
2
+ # Represents an un-run system program, like "ls" or "cd". Because it represents
3
+ # the program itself (and not a running instance of it), it should hold very
4
+ # little state. In fact, the only state it does hold is baked options.
5
+ #
6
+ # When a Command object is called, the result that is returned is a RunningCommand
7
+ # object, which represents the Command put into an execution state.
8
+ class Command
9
+ def initialize(sh, prog)
10
+ @sh = sh
11
+ @prog = prog.to_s
12
+ @progpath = resolve_progpath(@prog)
13
+ @baked_opts = []
14
+ end
15
+
16
+ # @param args [String, Symbol, #to_s, Hash]
17
+ # @param kwargs [Hash]
18
+ # @return [RunningCommand] An new instance of RunningCommand with execution state.
19
+ # @example
20
+ #
21
+ # sh = Rubsh::Shell.new
22
+ # git = Rubsh::Command.new(sh, "git")
23
+ # git.call() # => ["git"]
24
+ # git.call("") # => ["git", ""]
25
+ # git.call("status") # => ["git", "status"]
26
+ # git.call(:status) # => ["git", "status"]
27
+ # git.call(:status, "-v") # => ["git", "status", "-v"]
28
+ # git.call(:status, v: true) # => ["git", "status", "-v"]
29
+ # git.call(:status, { v: true }, "--", ".") # => ["git", "status", "-v", "--", "."]
30
+ # git.call(:status, { v: proc{ true }, short: true }, "--", ".") # => ["git", "status", "-v", "--short=true", "--", "."]
31
+ # git.call(:status, { untracked_files: "normal" }, "--", ".") # => ["git", "status", "--untracked-files=normal", "--", "."]
32
+ def call(*args, **kwargs)
33
+ rcmd = RunningCommand.new(@sh, @prog, @progpath, *@baked_opts, *args, **kwargs)
34
+ rcmd.__run
35
+ rcmd
36
+ end
37
+ alias_method :call_with, :call
38
+
39
+ # @param args [String, Symbol, #to_s, Hash]
40
+ # @param kwargs [Hash]
41
+ # @return [Command] a new instance of Command with baked options.
42
+ def bake(*args, **kwargs)
43
+ cmd = Command.new(@sh, @prog)
44
+ cmd.__bake!(*@baked_opts, *args, **kwargs)
45
+ cmd
46
+ end
47
+
48
+ # @return [String]
49
+ def inspect
50
+ format("#<Rubsh::Command '%s'>", @progpath)
51
+ end
52
+
53
+ # @!visibility private
54
+ def __bake!(*args, **kwargs)
55
+ args.each do |arg|
56
+ if arg.is_a?(::Hash)
57
+ arg.each { |k, v| @baked_opts << Option.build(k, v) }
58
+ else
59
+ @baked_opts << Option.build(arg)
60
+ end
61
+ end
62
+ kwargs.each { |k, v| @baked_opts << Option.build(k, v) }
63
+ end
64
+
65
+ private
66
+
67
+ def resolve_progpath(prog)
68
+ if ::File.expand_path(prog) == prog
69
+ if ::File.executable?(prog) && ::File.file?(prog)
70
+ progpath = prog
71
+ end
72
+ else
73
+ @sh.env.path.each do |path|
74
+ filepath = ::File.join(path, prog)
75
+ if ::File.executable?(filepath) && ::File.file?(filepath)
76
+ progpath = filepath
77
+ break
78
+ end
79
+ end
80
+ end
81
+
82
+ raise Exceptions::CommandNotFoundError, format("no command `%s'", prog) if progpath.nil?
83
+ progpath
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,22 @@
1
+ module Rubsh
2
+ module Exceptions
3
+ # Base error class.
4
+ class Error < ::StandardError; end
5
+
6
+ # Raised when a command not found.
7
+ class CommandNotFoundError < Error; end
8
+
9
+ # Raised when a command return failure.
10
+ class CommandReturnFailureError < Error
11
+ attr_reader :exit_code
12
+
13
+ def initialize(exit_code, message)
14
+ @exit_code = exit_code
15
+ super(message)
16
+ end
17
+ end
18
+
19
+ # Raised when a command is killed because a specified timeout was hit.
20
+ class CommandTimeoutError < Error; end
21
+ end
22
+ end
@@ -0,0 +1,73 @@
1
+ module Rubsh
2
+ class Option
3
+ # @!attribute [r] k
4
+ # @return [String]
5
+ attr_reader :k
6
+
7
+ # @!attribute [r] v
8
+ attr_reader :v
9
+
10
+ # .
11
+ private_class_method :new
12
+
13
+ # @overload build(option)
14
+ # @param option [Option]
15
+ #
16
+ # @overload build(name)
17
+ # @param name [String, Symbol, #to_s]
18
+ #
19
+ # @overload build(name, value)
20
+ # @param name [String, Symbol, #to_s]
21
+ # @param value [nil, Boolean, Numeric, String, Symbol, Proc]
22
+ #
23
+ # @return [Option]
24
+ def self.build(*args)
25
+ if args[0].is_a?(Option)
26
+ o = args[0]
27
+ new(o.k, o.v, positional: o.positional?, is_special_kwarg: o.special_kwarg?)
28
+ else
29
+ new(args[0], args[1], positional: args.length < 2)
30
+ end
31
+ end
32
+
33
+ # @param k [String, Symbol, #to_s]
34
+ # @param v [nil, Boolean, Numeric, String, Symbol, Proc]
35
+ def initialize(k, v, positional:, is_special_kwarg: nil)
36
+ @is_positional = positional
37
+ @is_special_kwarg = is_special_kwarg.nil? ? false : is_special_kwarg
38
+
39
+ if positional
40
+ @k, @v = k.to_s, nil
41
+ else
42
+ case k
43
+ when ::String
44
+ @k, @v = k, v
45
+ when ::Symbol
46
+ if k.to_s[0] == "_"
47
+ @k, @v = k.to_s, v
48
+ @is_special_kwarg = true
49
+ else
50
+ @k, @v = k.to_s.tr("_", "-"), v
51
+ end
52
+ else raise ::ArgumentError, format("unsupported option type `%s (%s)'", k, k.class)
53
+ end
54
+ end
55
+ end
56
+
57
+ def positional?
58
+ @is_positional
59
+ end
60
+
61
+ def kwarg?
62
+ !positional?
63
+ end
64
+
65
+ def special_kwarg?(sk = nil)
66
+ if sk.nil?
67
+ @is_special_kwarg
68
+ else
69
+ @is_special_kwarg && k == sk.to_s
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,337 @@
1
+ require "timeout"
2
+
3
+ module Rubsh
4
+ class RunningCommand
5
+ SPECIAL_KWARGS = %i[
6
+ _in_data
7
+ _in
8
+ _out
9
+ _err
10
+ _err_to_out
11
+ _capture
12
+ _bg
13
+ _env
14
+ _timeout
15
+ _cwd
16
+ _ok_code
17
+ _out_bufsize
18
+ _err_bufsize
19
+ _no_out
20
+ _no_err
21
+ _long_sep
22
+ _long_prefix
23
+ _pipeline
24
+ ]
25
+
26
+ SPECIAL_KWARGS_WITHIN_PIPELINE = %i[
27
+ _env
28
+ _cwd
29
+ _long_sep
30
+ _long_prefix
31
+ _pipeline
32
+ ]
33
+
34
+ # @!attribute [r] pid
35
+ # @return [Number]
36
+ attr_reader :pid
37
+
38
+ # @!attribute [r] exit_code
39
+ # @return [Number]
40
+ attr_reader :exit_code
41
+
42
+ # @!attribute [r] stdout_data
43
+ # @return [String]
44
+ attr_reader :stdout_data
45
+
46
+ # @!attribute [r] stderr_data
47
+ # @return [String]
48
+ attr_reader :stderr_data
49
+
50
+ def initialize(sh, prog, progpath, *args, **kwargs)
51
+ @sh = sh
52
+ @prog = prog
53
+ @progpath = progpath
54
+ @args = []
55
+
56
+ # Runtime
57
+ @prog_with_args = nil
58
+ @pid = nil
59
+ @exit_code = nil
60
+ @stdout_data = "".force_encoding(::Encoding.default_external)
61
+ @stderr_data = "".force_encoding(::Encoding.default_external)
62
+ @in_rd = nil
63
+ @in_wr = nil
64
+ @out_rd = nil
65
+ @out_wr = nil
66
+ @err_rd = nil
67
+ @err_wr = nil
68
+ @out_rd_reader = nil
69
+ @err_rd_reader = nil
70
+
71
+ # Special Kwargs - Controlling Input/Output
72
+ @_in_data = nil
73
+ @_in = nil
74
+ @_out = nil
75
+ @_err = nil
76
+ @_err_to_out = false
77
+ @_capture = nil
78
+
79
+ # Special Kwargs - Execution
80
+ @_bg = false
81
+ @_env = nil
82
+ @_timeout = nil
83
+ @_cwd = nil
84
+ @_ok_code = [0]
85
+
86
+ # Special Kwargs - Performance & Optimization
87
+ @_out_bufsize = 0
88
+ @_err_bufsize = 0
89
+ @_no_out = false
90
+ @_no_err = false
91
+
92
+ # Special Kwargs - Program Arguments
93
+ @_long_sep = "="
94
+ @_long_prefix = "--"
95
+
96
+ # Special Kwargs - Misc
97
+ @_pipeline = nil
98
+
99
+ opts = []
100
+ args.each do |arg|
101
+ if arg.is_a?(::Hash)
102
+ arg.each { |k, v| opts << Option.build(k, v) }
103
+ else
104
+ opts << Option.build(arg)
105
+ end
106
+ end
107
+ kwargs.each { |k, v| opts << Option.build(k, v) }
108
+ validate_opts(opts)
109
+ extract_opts(opts)
110
+ end
111
+
112
+ # @return [void]
113
+ def wait(timeout: nil)
114
+ timeout_occurred = false
115
+ _, status = nil, nil
116
+
117
+ if timeout
118
+ begin
119
+ ::Timeout.timeout(timeout) { _, status = ::Process.wait2(@pid) }
120
+ rescue ::Timeout::Error
121
+ timeout_occurred = true
122
+
123
+ ::Process.kill("TERM", @pid) # graceful stop
124
+ 30.times do
125
+ _, status = ::Process.wait2(@pid, ::Process::WNOHANG | ::Process::WUNTRACED)
126
+ break if status
127
+ sleep 0.1
128
+ end
129
+ failure = @pid if status.nil?
130
+ failure && ::Process.kill("KILL", failure) # forceful stop
131
+ end
132
+ else
133
+ _, status = ::Process.wait2(@pid)
134
+ end
135
+
136
+ @exit_code = status&.exitstatus
137
+ raise Exceptions::CommandTimeoutError, "execution expired" if timeout_occurred
138
+ rescue Errno::ECHILD, Errno::ESRCH
139
+ raise Exceptions::CommandTimeoutError, "execution expired" if timeout_occurred
140
+ ensure
141
+ @out_rd_reader&.wait
142
+ @err_rd_reader&.wait
143
+ end
144
+
145
+ # @return [String]
146
+ def inspect
147
+ format("#<Rubsh::RunningCommand '%s'>", @prog_with_args)
148
+ end
149
+
150
+ # @!visibility private
151
+ def __run
152
+ if @_pipeline
153
+ @_pipeline.__add_running_command(self)
154
+ else
155
+ @_bg ? run_in_background : run_in_foreground
156
+ end
157
+ end
158
+
159
+ # @!visibility private
160
+ def __spawn_arguments(env: nil, cwd: nil, redirection_args: nil)
161
+ env ||= @_env
162
+ cmd_args = compile_cmd_args
163
+ redirection_args ||= compile_redirection_args
164
+ extra_args = compile_extra_args(cwd: cwd)
165
+
166
+ # For logging
167
+ @prog_with_args = [@progpath].concat(cmd_args).join(" ")
168
+
169
+ # .
170
+ _args =
171
+ if env
172
+ [env, [@progpath, @prog], *cmd_args, **redirection_args, **extra_args, unsetenv_others: true]
173
+ else
174
+ [[@progpath, @prog], *cmd_args, **redirection_args, **extra_args]
175
+ end
176
+ end
177
+
178
+ # @!visibility private
179
+ def __prog_with_args
180
+ @prog_with_args
181
+ end
182
+
183
+ private
184
+
185
+ def validate_opts(opts)
186
+ within_pipeline = opts.any? { |opt| opt.special_kwarg?(:_pipeline) }
187
+ within_pipeline && opts.each do |opt|
188
+ if opt.special_kwarg? && !SPECIAL_KWARGS_WITHIN_PIPELINE.include?(opt.k.to_sym)
189
+ raise ::ArgumentError, format("unsupported special kwargs within _pipeline `%s'", opt.k)
190
+ end
191
+ end
192
+ end
193
+
194
+ def extract_opts(opts)
195
+ args_hash = {}
196
+ opts.each do |opt|
197
+ if opt.positional? # positional argument
198
+ @args << Argument.new(opt.k)
199
+ elsif opt.special_kwarg? # keyword argument - Special Kwargs
200
+ raise ::ArgumentError, format("unsupported special kwargs `%s'", opt.k) unless SPECIAL_KWARGS.include?(opt.k.to_sym)
201
+ extract_special_kwargs_opts(opt)
202
+ elsif args_hash.key?(opt.k) # keyword argument
203
+ arg = args_hash[opt.k]
204
+ arg.value = opt.v
205
+ else
206
+ arg = Argument.new(opt.k, opt.v)
207
+ args_hash[opt.k] = arg
208
+ @args << arg
209
+ end
210
+ end
211
+ end
212
+
213
+ def extract_special_kwargs_opts(opt)
214
+ case opt.k.to_sym
215
+ when :_in_data
216
+ @_in_data = opt.v
217
+ when :_in
218
+ @_in = opt.v
219
+ when :_out
220
+ @_out = opt.v
221
+ when :_err
222
+ @_err = opt.v
223
+ when :_err_to_out
224
+ @_err_to_out = opt.v
225
+ when :_capture
226
+ @_capture = opt.v
227
+ when :_bg
228
+ @_bg = opt.v
229
+ when :_env
230
+ @_env = opt.v.transform_keys(&:to_s).transform_values(&:to_s)
231
+ when :_timeout
232
+ @_timeout = opt.v
233
+ when :_cwd
234
+ @_cwd = opt.v
235
+ when :_ok_code
236
+ @_ok_code = [*opt.v]
237
+ when :_out_bufsize
238
+ @_out_bufsize = opt.v
239
+ when :_err_bufsize
240
+ @_err_bufsize = opt.v
241
+ when :_no_out
242
+ @_no_out = opt.v
243
+ when :_no_err
244
+ @_no_err = opt.v
245
+ when :_long_sep
246
+ @_long_sep = opt.v
247
+ when :_long_prefix
248
+ @_long_prefix = opt.v
249
+ when :_pipeline
250
+ @_pipeline = opt.v
251
+ end
252
+ end
253
+
254
+ def compile_cmd_args
255
+ @args.map { |arg| arg.compile(long_sep: @_long_sep, long_prefix: @_long_prefix) }.compact.flatten
256
+ end
257
+
258
+ def compile_redirection_args
259
+ args = {}
260
+
261
+ if @_in
262
+ args[:in] = @_in
263
+ else
264
+ @in_rd, @in_wr = ::IO.pipe
265
+ @in_wr.sync = true
266
+ args[:in] = @in_rd.fileno
267
+ end
268
+
269
+ if @_out
270
+ args[:out] = @_out
271
+ else
272
+ @out_rd, @out_wr = ::IO.pipe
273
+ args[:out] = @out_wr.fileno
274
+ end
275
+
276
+ if @_err_to_out
277
+ args[:err] = [:child, :out]
278
+ elsif @_err
279
+ args[:err] = @_err
280
+ else
281
+ @err_rd, @err_wr = ::IO.pipe
282
+ args[:err] = @err_wr.fileno
283
+ end
284
+
285
+ args
286
+ end
287
+
288
+ def compile_extra_args(cwd: nil)
289
+ chdir = cwd || @_cwd
290
+
291
+ args = {}
292
+ args[:chdir] = chdir if chdir
293
+ args
294
+ end
295
+
296
+ def spawn
297
+ args = __spawn_arguments
298
+ @pid = ::Process.spawn(*args)
299
+ @in_wr&.write(@_in_data) if @_in_data
300
+ @in_wr&.close
301
+
302
+ if @out_rd
303
+ @out_rd_reader = StreamReader.new(@out_rd, bufsize: @_capture ? @_out_bufsize : nil, &proc { |chunk|
304
+ @stdout_data << chunk unless @_no_out
305
+ @_capture&.call(chunk, nil)
306
+ })
307
+ end
308
+ if @err_rd
309
+ @err_rd_reader = StreamReader.new(@err_rd, bufsize: @_capture ? @_err_bufsize : nil, &proc { |chunk|
310
+ @stderr_data << chunk unless @_no_err
311
+ @_capture&.call(nil, chunk)
312
+ })
313
+ end
314
+ ensure
315
+ @in_rd&.close
316
+ @out_wr&.close
317
+ @err_wr&.close
318
+ end
319
+
320
+ def handle_return_code
321
+ return if @_ok_code.include?(@exit_code)
322
+ message = format("\n\n RAN: %s\n\n STDOUT:\n%s\n STDERR:\n%s\n", @prog_with_args, @stdout_data, @stderr_data)
323
+ raise Exceptions::CommandReturnFailureError.new(@exit_code, message)
324
+ end
325
+
326
+ def run_in_background
327
+ spawn
328
+ Process.detach(@pid)
329
+ end
330
+
331
+ def run_in_foreground
332
+ spawn
333
+ wait(timeout: @_timeout)
334
+ handle_return_code
335
+ end
336
+ end
337
+ end