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.
- checksums.yaml +7 -0
- data/.overcommit.yml +26 -0
- data/.rspec +3 -0
- data/.rubocop +0 -0
- data/.rubocop.yml +10 -0
- data/.yardopts +2 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +62 -0
- data/LICENSE.txt +21 -0
- data/README.md +383 -0
- data/Rakefile +12 -0
- data/lib/rubsh/argument.rb +47 -0
- data/lib/rubsh/command.rb +86 -0
- data/lib/rubsh/exceptions.rb +22 -0
- data/lib/rubsh/option.rb +73 -0
- data/lib/rubsh/running_command.rb +337 -0
- data/lib/rubsh/running_pipeline.rb +243 -0
- data/lib/rubsh/shell/env.rb +15 -0
- data/lib/rubsh/shell.rb +24 -0
- data/lib/rubsh/stream_reader.rb +39 -0
- data/lib/rubsh/version.rb +5 -0
- data/lib/rubsh.rb +12 -0
- data/rubsh.gemspec +38 -0
- data/sig/rubsh.rbs +4 -0
- metadata +73 -0
@@ -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
|
data/lib/rubsh/option.rb
ADDED
@@ -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
|