toys-core 0.3.4 → 0.3.5

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,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