ruby-sh 2.1.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -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