toys-core 0.3.4 → 0.3.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,505 @@
1
+ # Copyright 2018 Daniel Azuma
2
+ #
3
+ # All rights reserved.
4
+ #
5
+ # Redistribution and use in source and binary forms, with or without
6
+ # modification, are permitted provided that the following conditions are met:
7
+ #
8
+ # * Redistributions of source code must retain the above copyright notice,
9
+ # this list of conditions and the following disclaimer.
10
+ # * Redistributions in binary form must reproduce the above copyright notice,
11
+ # this list of conditions and the following disclaimer in the documentation
12
+ # and/or other materials provided with the distribution.
13
+ # * Neither the name of the copyright holder, nor the names of any other
14
+ # contributors to this software, may be used to endorse or promote products
15
+ # derived from this software without specific prior written permission.
16
+ #
17
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
18
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
20
+ # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
21
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
22
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
23
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
24
+ # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
25
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
26
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27
+ # POSSIBILITY OF SUCH DAMAGE.
28
+ ;
29
+
30
+ require "logger"
31
+
32
+ module Toys
33
+ module Utils
34
+ ##
35
+ # A service that executes subprocesses.
36
+ #
37
+ # This service provides a convenient interface for controlling spawned
38
+ # processes and their streams. It also provides shortcuts for common cases
39
+ # such as invoking Ruby in a subprocess or capturing output in a string.
40
+ #
41
+ # ## Stream handling
42
+ #
43
+ # By default, subprocess streams are connected to the corresponding streams
44
+ # in the parent process.
45
+ #
46
+ # Alternately, input streams may be read from a string you provide, and
47
+ # you may direct output streams to be captured and their contents exposed
48
+ # in the result object.
49
+ #
50
+ # You may also connect subprocess streams to a controller, which you can
51
+ # then manipulate by providing a block. Your block may read and write
52
+ # connected streams to interact with the process. For example, to redirect
53
+ # data into a subprocess you can connect its input stream to the controller
54
+ # using the `:in_from` option (see below). Then, in your block, you can
55
+ # write to that stream via the controller.
56
+ #
57
+ # ## Configuration options
58
+ #
59
+ # A variety of options can be used to control subprocesses. These include:
60
+ #
61
+ # * **:env** (Hash) Environment variables to pass to the subprocess
62
+ # * **:logger** (Logger) Logger to use for logging the actual command.
63
+ # If not present, the command is not logged.
64
+ # * **:log_level** (Integer) Log level for logging the actual command.
65
+ # Defaults to Logger::INFO if not present.
66
+ # * **:in_from** (`:controller`,String) Connects the input stream of the
67
+ # subprocess. If set to `:controller`, the controller will control the
68
+ # input stream. If set to a string, that string will be written to the
69
+ # input stream. If not set, the input stream will be connected to the
70
+ # STDIN for the Toys process itself.
71
+ # * **:out_to** (`:controller`,`:capture`) Connects the standard output
72
+ # stream of the subprocess. If set to `:controller`, the controller
73
+ # will control the output stream. If set to `:capture`, the output will
74
+ # be captured in a string that is available in the
75
+ # {Toys::Helpers::Exec::Result} object. If not set, the subprocess
76
+ # standard out is connected to STDOUT of the Toys process.
77
+ # * **:err_to** (`:controller`,`:capture`) Connects the standard error
78
+ # stream of the subprocess. If set to `:controller`, the controller
79
+ # will control the output stream. If set to `:capture`, the output will
80
+ # be captured in a string that is available in the
81
+ # {Toys::Helpers::Exec::Result} object. If not set, the subprocess
82
+ # standard out is connected to STDERR of the Toys process.
83
+ #
84
+ # In addition, the following options recognized by `Process#spawn` are
85
+ # supported.
86
+ #
87
+ # * `:chdir`
88
+ # * `:close_others`
89
+ # * `:new_pgroup`
90
+ # * `:pgroup`
91
+ # * `:umask`
92
+ # * `:unsetenv_others`
93
+ #
94
+ # Any other options are ignored.
95
+ #
96
+ # Configuration options may be provided to any method that starts a
97
+ # subprocess. You may also modify default values by calling
98
+ # {Toys::Utils::Exec#config_defaults}.
99
+ #
100
+ class Exec
101
+ ##
102
+ # Create an exec service.
103
+ #
104
+ # @param [Hash] opts Initial default options.
105
+ #
106
+ def initialize(opts = {}, &block)
107
+ @default_opts = Opts.new(&block).add(opts)
108
+ end
109
+
110
+ ##
111
+ # Set default options
112
+ #
113
+ # @param [Hash] opts New default options to set
114
+ #
115
+ def config_defaults(opts = {})
116
+ @default_opts.add(opts)
117
+ self
118
+ end
119
+
120
+ ##
121
+ # Execute a command. The command may be given as a single string to pass
122
+ # to a shell, or an array of strings indicating a posix command.
123
+ #
124
+ # If you provide a block, a {Toys::Utils::Exec::Controller} will be
125
+ # yielded to it, allowing you to interact with the subprocess streams.
126
+ #
127
+ # @param [String,Array<String>] cmd The command to execute.
128
+ # @param [Hash] opts The command options. See the section on
129
+ # configuration options in the {Toys::Utils::Exec} module docs.
130
+ # @yieldparam controller [Toys::Utils::Exec::Controller] A controller
131
+ # for the subprocess streams.
132
+ #
133
+ # @return [Toys::Utils::Exec::Result] The subprocess result, including
134
+ # exit code and any captured output.
135
+ #
136
+ def exec(cmd, opts = {}, &block)
137
+ exec_opts = Opts.new(@default_opts).add(opts)
138
+ executor = Executor.new(exec_opts, cmd)
139
+ executor.execute(&block)
140
+ end
141
+
142
+ ##
143
+ # Spawn a ruby process and pass the given arguments to it.
144
+ #
145
+ # If you provide a block, a {Toys::Utils::Exec::Controller} will be
146
+ # yielded to it, allowing you to interact with the subprocess streams.
147
+ #
148
+ # @param [String,Array<String>] args The arguments to ruby.
149
+ # @param [Hash] opts The command options. See the section on
150
+ # configuration options in the {Toys::Utils::Exec} module docs.
151
+ # @yieldparam controller [Toys::Utils::Exec::Controller] A controller
152
+ # for the subprocess streams.
153
+ #
154
+ # @return [Toys::Utils::Result] The subprocess result, including
155
+ # exit code and any captured output.
156
+ #
157
+ def ruby(args, opts = {}, &block)
158
+ cmd =
159
+ if args.is_a?(::Array)
160
+ [[::RbConfig.ruby, "ruby"]] + args
161
+ else
162
+ "#{::RbConfig.ruby} #{args}"
163
+ end
164
+ exec(cmd, opts, &block)
165
+ end
166
+
167
+ ##
168
+ # Execute the given string in a shell. Returns the exit code.
169
+ #
170
+ # @param [String] cmd The shell command to execute.
171
+ # @param [Hash] opts The command options. See the section on
172
+ # configuration options in the {Toys::Utils::Exec} module docs.
173
+ # @yieldparam controller [Toys::Utils::Exec::Controller] A controller
174
+ # for the subprocess streams.
175
+ #
176
+ # @return [Integer] The exit code
177
+ #
178
+ def sh(cmd, opts = {})
179
+ exec(cmd, opts).exit_code
180
+ end
181
+
182
+ ##
183
+ # Execute a command. The command may be given as a single string to pass
184
+ # to a shell, or an array of strings indicating a posix command.
185
+ #
186
+ # Captures standard out and returns it as a string.
187
+ #
188
+ # @param [String,Array<String>] cmd The command to execute.
189
+ # @param [Hash] opts The command options. See the section on
190
+ # configuration options in the {Toys::Utils::Exec} module docs.
191
+ # @yieldparam controller [Toys::Utils::Exec::Controller] A controller
192
+ # for the subprocess streams.
193
+ #
194
+ # @return [String] What was written to standard out.
195
+ #
196
+ def capture(cmd, opts = {})
197
+ exec(cmd, opts.merge(out_to: :capture)).captured_out
198
+ end
199
+
200
+ ##
201
+ # An internal helper class storing the configuration of a subprocess invocation
202
+ # @private
203
+ #
204
+ class Opts
205
+ ##
206
+ # Option keys that belong to exec configuration
207
+ # @private
208
+ #
209
+ CONFIG_KEYS = %i[
210
+ env
211
+ err_to
212
+ in_from
213
+ logger
214
+ log_level
215
+ nonzero_status_handler
216
+ out_to
217
+ ].freeze
218
+
219
+ ##
220
+ # Option keys that belong to spawn configuration
221
+ # @private
222
+ #
223
+ SPAWN_KEYS = %i[
224
+ chdir
225
+ close_others
226
+ new_pgroup
227
+ pgroup
228
+ umask
229
+ unsetenv_others
230
+ ].freeze
231
+
232
+ def initialize(parent = nil)
233
+ if parent
234
+ @config_opts = ::Hash.new { |_h, k| parent.config_opts[k] }
235
+ @spawn_opts = ::Hash.new { |_h, k| parent.spawn_opts[k] }
236
+ elsif block_given?
237
+ @config_opts = ::Hash.new { |_h, k| yield k }
238
+ @spawn_opts = ::Hash.new { |_h, k| yield k }
239
+ else
240
+ @config_opts = {}
241
+ @spawn_opts = {}
242
+ end
243
+ end
244
+
245
+ def add(config)
246
+ config.each do |k, v|
247
+ if CONFIG_KEYS.include?(k)
248
+ @config_opts[k] = v
249
+ elsif SPAWN_KEYS.include?(k)
250
+ @spawn_opts[k] = v
251
+ end
252
+ end
253
+ self
254
+ end
255
+
256
+ def delete(*keys)
257
+ keys.each do |k|
258
+ if CONFIG_KEYS.include?(k)
259
+ @config_opts.delete(k)
260
+ elsif SPAWN_KEYS.include?(k)
261
+ @spawn_opts.delete(k)
262
+ end
263
+ end
264
+ self
265
+ end
266
+
267
+ attr_reader :config_opts
268
+ attr_reader :spawn_opts
269
+ end
270
+
271
+ ##
272
+ # An object of this type is passed to a subcommand control block.
273
+ # You may use this object to interact with the subcommand's streams,
274
+ # and/or send signals to the process.
275
+ #
276
+ class Controller
277
+ ## @private
278
+ def initialize(ins, out, err, pid)
279
+ @in = ins
280
+ @out = out
281
+ @err = err
282
+ @pid = pid
283
+ end
284
+
285
+ ##
286
+ # Return the subcommand's standard input stream (which can be written
287
+ # to), if the command was configured with `in_from: :controller`.
288
+ # Returns `nil` otherwise.
289
+ # @return [IO,nil]
290
+ #
291
+ attr_reader :in
292
+
293
+ ##
294
+ # Return the subcommand's standard output stream (which can be read
295
+ # from), if the command was configured with `out_to: :controller`.
296
+ # Returns `nil` otherwise.
297
+ # @return [IO,nil]
298
+ #
299
+ attr_reader :out
300
+
301
+ ##
302
+ # Return the subcommand's standard error stream (which can be read
303
+ # from), if the command was configured with `err_to: :controller`.
304
+ # Returns `nil` otherwise.
305
+ # @return [IO,nil]
306
+ #
307
+ attr_reader :err
308
+
309
+ ##
310
+ # Returns the process ID.
311
+ # @return [Integer]
312
+ #
313
+ attr_reader :pid
314
+
315
+ ##
316
+ # Send the given signal to the process. The signal may be specified
317
+ # by name or number.
318
+ #
319
+ # @param [Integer,String] signal The signal to send.
320
+ #
321
+ def kill(signal)
322
+ ::Process.kill(signal, pid)
323
+ end
324
+ end
325
+
326
+ ##
327
+ # The return result from a subcommand
328
+ #
329
+ class Result
330
+ ## @private
331
+ def initialize(out, err, status)
332
+ @captured_out = out
333
+ @captured_err = err
334
+ @status = status
335
+ end
336
+
337
+ ##
338
+ # Returns the captured output string, if the command was configured
339
+ # with `out_to: :capture`. Returns `nil` otherwise.
340
+ # @return [String,nil]
341
+ #
342
+ attr_reader :captured_out
343
+
344
+ ##
345
+ # Returns the captured error string, if the command was configured
346
+ # with `err_to: :capture`. Returns `nil` otherwise.
347
+ # @return [String,nil]
348
+ #
349
+ attr_reader :captured_err
350
+
351
+ ##
352
+ # Returns the status code object.
353
+ # @return [Process::Status]
354
+ #
355
+ attr_reader :status
356
+
357
+ ##
358
+ # Returns the numeric status code.
359
+ # @return [Integer]
360
+ #
361
+ def exit_code
362
+ status.exitstatus
363
+ end
364
+
365
+ ##
366
+ # Returns true if the subprocess terminated with a zero status.
367
+ # @return [Boolean]
368
+ #
369
+ def success?
370
+ exit_code.zero?
371
+ end
372
+
373
+ ##
374
+ # Returns true if the subprocess terminated with a nonzero status.
375
+ # @return [Boolean]
376
+ #
377
+ def error?
378
+ !exit_code.zero?
379
+ end
380
+ end
381
+
382
+ ##
383
+ # An object that manages the execution of a subcommand
384
+ # @private
385
+ #
386
+ class Executor
387
+ def initialize(exec_opts, cmd)
388
+ @cmd = Array(cmd)
389
+ @config_opts = exec_opts.config_opts
390
+ @spawn_opts = exec_opts.spawn_opts
391
+ @captures = {}
392
+ @controller_streams = {}
393
+ @join_threads = []
394
+ @child_streams = []
395
+ end
396
+
397
+ def execute(&block)
398
+ setup_in_stream
399
+ setup_out_stream(:out, :out_to, :out)
400
+ setup_out_stream(:err, :err_to, :err)
401
+ log_command
402
+ wait_thread = start_process
403
+ status = control_process(wait_thread, &block)
404
+ create_result(status)
405
+ end
406
+
407
+ private
408
+
409
+ def log_command
410
+ logger = @config_opts[:logger]
411
+ if logger && @config_opts[:log_level] != false
412
+ cmd_str = @cmd.size == 1 ? @cmd.first : @cmd.inspect
413
+ logger.add(@config_opts[:log_level] || ::Logger::INFO, cmd_str)
414
+ end
415
+ end
416
+
417
+ def start_process
418
+ args = []
419
+ args << @config_opts[:env] if @config_opts[:env]
420
+ args.concat(@cmd)
421
+ pid = ::Process.spawn(*args, @spawn_opts)
422
+ @child_streams.each(&:close)
423
+ ::Process.detach(pid)
424
+ end
425
+
426
+ def control_process(wait_thread)
427
+ begin
428
+ if block_given?
429
+ controller = Controller.new(
430
+ @controller_streams[:in], @controller_streams[:out], @controller_streams[:err],
431
+ wait_thread.pid
432
+ )
433
+ yield controller
434
+ end
435
+ ensure
436
+ @controller_streams.each_value(&:close)
437
+ end
438
+ @join_threads.each(&:join)
439
+ wait_thread.value
440
+ end
441
+
442
+ def create_result(status)
443
+ nonzero_status_handler = @config_opts[:nonzero_status_handler]
444
+ nonzero_status_handler.call(status) if nonzero_status_handler && status.exitstatus != 0
445
+ Result.new(@captures[:out], @captures[:err], status)
446
+ end
447
+
448
+ def setup_in_stream
449
+ setting = @config_opts[:in_from]
450
+ if setting
451
+ r, w = ::IO.pipe
452
+ @spawn_opts[:in] = r
453
+ w.sync = true
454
+ @child_streams << r
455
+ case setting
456
+ when :controller
457
+ @controller_streams[:in] = w
458
+ when String
459
+ write_string_thread(w, setting)
460
+ else
461
+ raise "Unknown type for in_from"
462
+ end
463
+ end
464
+ end
465
+
466
+ def setup_out_stream(stream_name, config_key, spawn_key)
467
+ setting = @config_opts[config_key]
468
+ if setting
469
+ r, w = ::IO.pipe
470
+ @spawn_opts[spawn_key] = w
471
+ @child_streams << w
472
+ case setting
473
+ when :controller
474
+ @controller_streams[stream_name] = r
475
+ when :capture
476
+ @join_threads << capture_stream_thread(r, stream_name)
477
+ else
478
+ raise "Unknown type for #{config_key}"
479
+ end
480
+ end
481
+ end
482
+
483
+ def write_string_thread(stream, string)
484
+ ::Thread.new do
485
+ begin
486
+ stream.write string
487
+ ensure
488
+ stream.close
489
+ end
490
+ end
491
+ end
492
+
493
+ def capture_stream_thread(stream, name)
494
+ ::Thread.new do
495
+ begin
496
+ @captures[name] = stream.read
497
+ ensure
498
+ stream.close
499
+ end
500
+ end
501
+ end
502
+ end
503
+ end
504
+ end
505
+ end