outstand-tty-command 0.10.0.pre

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,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'result'
4
+
5
+ module TTY
6
+ class Command
7
+ class DryRunner
8
+ attr_reader :cmd
9
+
10
+ def initialize(cmd, printer)
11
+ @cmd = cmd
12
+ @printer = printer
13
+ end
14
+
15
+ # Show command without running
16
+ #
17
+ # @api public
18
+ def run!(*)
19
+ cmd.to_command
20
+ message = "#{@printer.decorate('(dry run)', :blue)} " +
21
+ @printer.decorate(cmd.to_command, :yellow, :bold)
22
+ @printer.write(cmd, message, cmd.uuid)
23
+ Result.new(0, '', '')
24
+ end
25
+ end # DryRunner
26
+ end # Command
27
+ end # TTY
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TTY
4
+ class Command
5
+ # An ExitError reports an unsuccessful exit by command.
6
+ #
7
+ # The error message includes:
8
+ # * the name of command executed
9
+ # * the exit status
10
+ # * stdout bytes
11
+ # * stderr bytes
12
+ #
13
+ # @api private
14
+ class ExitError < RuntimeError
15
+ def initialize(cmd_name, result)
16
+ super(info(cmd_name, result))
17
+ end
18
+
19
+ def info(cmd_name, result)
20
+ "Running `#{cmd_name}` failed with\n" \
21
+ " exit status: #{result.exit_status}\n" \
22
+ " stdout: #{extract_output(result.out)}\n" \
23
+ " stderr: #{extract_output(result.err)}\n"
24
+ end
25
+
26
+ def extract_output(value)
27
+ (value || '').strip.empty? ? 'Nothing written' : value.strip
28
+ end
29
+ end # ExitError
30
+ end # Command
31
+ end # TTY
@@ -0,0 +1,54 @@
1
+ # encoding: utf-8
2
+
3
+ require 'pastel'
4
+
5
+ module TTY
6
+ class Command
7
+ module Printers
8
+ class Abstract
9
+ extend Forwardable
10
+
11
+ def_delegators :@color, :decorate
12
+
13
+ attr_reader :output, :options
14
+ attr_accessor :out_data, :err_data
15
+
16
+ # Initialize a Printer object
17
+ #
18
+ # @param [IO] output
19
+ # the printer output
20
+ #
21
+ # @api public
22
+ def initialize(output, options = {})
23
+ @output = output
24
+ @options = options
25
+ @enabled = options.fetch(:color) { true }
26
+ @color = ::Pastel.new(enabled: @enabled)
27
+
28
+ @out_data = ''
29
+ @err_data = ''
30
+ end
31
+
32
+ def print_command_start(cmd, *args)
33
+ write(cmd.to_command + "#{args.join}")
34
+ end
35
+
36
+ def print_command_out_data(cmd, *args)
37
+ write(args.join(' '))
38
+ end
39
+
40
+ def print_command_err_data(cmd, *args)
41
+ write(args.join(' '))
42
+ end
43
+
44
+ def print_command_exit(cmd, *args)
45
+ write(args.join(' '))
46
+ end
47
+
48
+ def write(cmd, message)
49
+ raise NotImplemented, "Abstract printer cannot be used"
50
+ end
51
+ end # Abstract
52
+ end # Printers
53
+ end # Command
54
+ end # TTY
@@ -0,0 +1,16 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'abstract'
5
+
6
+ module TTY
7
+ class Command
8
+ module Printers
9
+ class Null < Abstract
10
+ def write(*)
11
+ # Do nothing
12
+ end
13
+ end # Null
14
+ end # Printers
15
+ end # Command
16
+ end # TTY
@@ -0,0 +1,83 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'pastel'
5
+
6
+ require_relative 'abstract'
7
+
8
+ module TTY
9
+ class Command
10
+ module Printers
11
+ class Pretty < Abstract
12
+ TIME_FORMAT = "%5.3f %s".freeze
13
+
14
+ def initialize(*)
15
+ super
16
+ @uuid = options.fetch(:uuid) { true }
17
+ end
18
+
19
+ def print_command_start(cmd, *args)
20
+ message = ["Running #{decorate(cmd.to_command, :yellow, :bold)}"]
21
+ message << args.map(&:chomp).join(' ') unless args.empty?
22
+ write(cmd, message.join)
23
+ end
24
+
25
+ def print_command_out_data(cmd, *args)
26
+ message = args.map(&:chomp).join(' ')
27
+ write(cmd, "\t#{message}", out_data)
28
+ end
29
+
30
+ def print_command_err_data(cmd, *args)
31
+ message = args.map(&:chomp).join(' ')
32
+ write(cmd, "\t" + decorate(message, :red), err_data)
33
+ end
34
+
35
+ def print_command_exit(cmd, status, runtime, *args)
36
+ if cmd.only_output_on_error && !status.zero?
37
+ output << out_data
38
+ output << err_data
39
+ end
40
+
41
+ runtime = TIME_FORMAT % [runtime, pluralize(runtime, 'second')]
42
+ message = ["Finished in #{runtime}"]
43
+ message << " with exit status #{status}" if status
44
+ message << " (#{success_or_failure(status)})"
45
+ write(cmd, message.join)
46
+ end
47
+
48
+ # Write message out to output
49
+ #
50
+ # @api private
51
+ def write(cmd, message, data = nil)
52
+ cmd_set_uuid = cmd.options.fetch(:uuid, true)
53
+ uuid_needed = cmd.options[:uuid].nil? ? @uuid : cmd_set_uuid
54
+ out = []
55
+ if uuid_needed
56
+ out << "[#{decorate(cmd.uuid, :green)}] " unless cmd.uuid.nil?
57
+ end
58
+ out << "#{message}\n"
59
+ target = (cmd.only_output_on_error && !data.nil?) ? data : output
60
+ target << out.join
61
+ end
62
+
63
+ private
64
+
65
+ # Pluralize word based on a count
66
+ #
67
+ # @api private
68
+ def pluralize(count, word)
69
+ "#{word}#{'s' unless count.to_f == 1}"
70
+ end
71
+
72
+ # @api private
73
+ def success_or_failure(status)
74
+ if status == 0
75
+ decorate('successful', :green, :bold)
76
+ else
77
+ decorate('failed', :red, :bold)
78
+ end
79
+ end
80
+ end # Pretty
81
+ end # Printers
82
+ end # Command
83
+ end # TTY
@@ -0,0 +1,32 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'pastel'
5
+ require_relative 'abstract'
6
+
7
+ module TTY
8
+ class Command
9
+ module Printers
10
+ class Progress < Abstract
11
+
12
+ def print_command_exit(cmd, status, runtime, *args)
13
+ output.print(success_or_failure(status))
14
+ end
15
+
16
+ def write(*)
17
+ end
18
+
19
+ private
20
+
21
+ # @api private
22
+ def success_or_failure(status)
23
+ if status == 0
24
+ decorate('.', :green)
25
+ else
26
+ decorate('F', :red)
27
+ end
28
+ end
29
+ end # Progress
30
+ end # Printers
31
+ end # Command
32
+ end # TTY
@@ -0,0 +1,39 @@
1
+ # encoding: utf-8
2
+
3
+ require_relative 'abstract'
4
+
5
+ module TTY
6
+ class Command
7
+ module Printers
8
+ class Quiet < Abstract
9
+ attr_reader :output, :options
10
+
11
+ def print_command_start(cmd)
12
+ # quiet
13
+ end
14
+
15
+ def print_command_out_data(cmd, *args)
16
+ write(cmd, args.join(' '), out_data)
17
+ end
18
+
19
+ def print_command_err_data(cmd, *args)
20
+ write(cmd, args.join(' '), err_data)
21
+ end
22
+
23
+ def print_command_exit(cmd, status, *args)
24
+ unless !cmd.only_output_on_error || status.zero?
25
+ output << out_data
26
+ output << err_data
27
+ end
28
+
29
+ # quiet
30
+ end
31
+
32
+ def write(cmd, message, data = nil)
33
+ target = (cmd.only_output_on_error && !data.nil?) ? data : output
34
+ target << message
35
+ end
36
+ end # Progress
37
+ end # Printers
38
+ end # Command
39
+ end # TTY
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thread'
4
+
5
+ require_relative 'child_process'
6
+ require_relative 'result'
7
+ require_relative 'truncator'
8
+
9
+ module TTY
10
+ class Command
11
+ class ProcessRunner
12
+ # the command to be spawned
13
+ attr_reader :cmd
14
+
15
+ # Initialize a Runner object
16
+ #
17
+ # @param [Printer] printer
18
+ # the printer to use for logging
19
+ #
20
+ # @api private
21
+ def initialize(cmd, printer, &block)
22
+ @cmd = cmd
23
+ @timeout = cmd.options[:timeout]
24
+ @input = cmd.options[:input]
25
+ @signal = cmd.options[:signal] || "SIGKILL"
26
+ @binmode = cmd.options[:binmode]
27
+ @printer = printer
28
+ @block = block
29
+ end
30
+
31
+ # Execute child process
32
+ #
33
+ # Write the input if provided to the child's stdin and read
34
+ # the contents of both the stdout and stderr.
35
+ #
36
+ # If a block is provided then yield the stdout and stderr content
37
+ # as its being read.
38
+ #
39
+ # @api public
40
+ def run!
41
+ @printer.print_command_start(cmd)
42
+ start = Time.now
43
+
44
+ pid, stdin, stdout, stderr = ChildProcess.spawn(cmd)
45
+
46
+ write_stream(stdin, @input)
47
+
48
+ stdout_data, stderr_data = read_streams(stdout, stderr)
49
+
50
+ status = waitpid(pid)
51
+ runtime = Time.now - start
52
+
53
+ @printer.print_command_exit(cmd, status, runtime)
54
+
55
+ Result.new(status, stdout_data, stderr_data, runtime)
56
+ ensure
57
+ [stdin, stdout, stderr].each { |fd| fd.close if fd && !fd.closed? }
58
+ if pid # Ensure no zombie processes
59
+ ::Process.detach(pid)
60
+ terminate(pid)
61
+ end
62
+ end
63
+
64
+ # Stop a process marked by pid
65
+ #
66
+ # @param [Integer] pid
67
+ #
68
+ # @api public
69
+ def terminate(pid)
70
+ ::Process.kill(@signal, pid) rescue nil
71
+ end
72
+
73
+ private
74
+
75
+ # The buffer size for reading stdout and stderr
76
+ BUFSIZE = 16 * 1024
77
+
78
+ # @api private
79
+ def handle_timeout(runtime)
80
+ return unless @timeout
81
+
82
+ t = @timeout - runtime
83
+ raise TimeoutExceeded if t < 0.0
84
+ end
85
+
86
+ # Write the input to the process stdin
87
+ #
88
+ # @api private
89
+ def write_stream(stream, input)
90
+ start = Time.now
91
+ writers = [input && stream].compact
92
+
93
+ while writers.any?
94
+ ready = IO.select(nil, writers, writers, @timeout)
95
+ raise TimeoutExceeded if ready.nil?
96
+
97
+ ready[1].each do |writer|
98
+ begin
99
+ err = nil
100
+ size = writer.write(@input)
101
+ input = input.byteslice(size..-1)
102
+ rescue IO::WaitWritable
103
+ rescue Errno::EPIPE => err
104
+ # The pipe closed before all input written
105
+ # Probably process exited prematurely
106
+ writer.close
107
+ writers.delete(writer)
108
+ end
109
+ if err || input.bytesize == 0
110
+ writer.close
111
+ writers.delete(writer)
112
+ end
113
+
114
+ # control total time spent writing
115
+ runtime = Time.now - start
116
+ handle_timeout(runtime)
117
+ end
118
+ end
119
+ end
120
+
121
+ # Read stdout & stderr streams in the background
122
+ #
123
+ # @param [IO] stdout
124
+ # @param [IO] stderr
125
+ #
126
+ # @api private
127
+ def read_streams(stdout, stderr)
128
+ stdout_data = []
129
+ stderr_data = Truncator.new
130
+
131
+ out_buffer = ->(line) {
132
+ stdout_data << line
133
+ @printer.print_command_out_data(cmd, line)
134
+ @block.(line, nil) if @block
135
+ }
136
+
137
+ err_buffer = ->(line) {
138
+ stderr_data << line
139
+ @printer.print_command_err_data(cmd, line)
140
+ @block.(nil, line) if @block
141
+ }
142
+
143
+ stdout_thread = read_stream(stdout, out_buffer)
144
+ stderr_thread = read_stream(stderr, err_buffer)
145
+
146
+ stdout_thread.join
147
+ stderr_thread.join
148
+
149
+ encoding = @binmode ? Encoding::BINARY : Encoding::UTF_8
150
+
151
+ [
152
+ stdout_data.join.force_encoding(encoding),
153
+ stderr_data.read.dup.force_encoding(encoding)
154
+ ]
155
+ end
156
+
157
+ def read_stream(stream, buffer)
158
+ Thread.new do
159
+ if Thread.current.respond_to?(:report_on_exception)
160
+ Thread.current.report_on_exception = false
161
+ end
162
+ Thread.current[:cmd_start] = Time.now
163
+ readers = [stream]
164
+
165
+ while readers.any?
166
+ ready = IO.select(readers, nil, readers, @timeout)
167
+ raise TimeoutExceeded if ready.nil?
168
+
169
+ ready[0].each do |reader|
170
+ begin
171
+ line = reader.readpartial(BUFSIZE)
172
+ buffer.(line)
173
+
174
+ # control total time spent reading
175
+ runtime = Time.now - Thread.current[:cmd_start]
176
+ handle_timeout(runtime)
177
+ rescue Errno::EAGAIN, Errno::EINTR
178
+ rescue EOFError, Errno::EPIPE, Errno::EIO # thrown by PTY
179
+ readers.delete(reader)
180
+ reader.close
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
186
+
187
+ # @api private
188
+ def waitpid(pid)
189
+ _pid, status = ::Process.waitpid2(pid, ::Process::WUNTRACED)
190
+ status.exitstatus || status.termsig if _pid
191
+ rescue Errno::ECHILD
192
+ # In JRuby, waiting on a finished pid raises.
193
+ end
194
+ end # ProcessRunner
195
+ end # Command
196
+ end # TTY