Skip to content

Commit eafece1

Browse files
committed
update
1 parent 110c364 commit eafece1

3 files changed

Lines changed: 101 additions & 27 deletions

File tree

bin/rparallel

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ Task = Struct.new(:number, :command, :started_at, :ended_at, :exit_code, keyword
1010
end
1111

1212
class RParallel
13+
COLORS = {
14+
bold: "\e[1m",
15+
dim: "\e[2m",
16+
green: "\e[32m",
17+
red: "\e[31m",
18+
cyan: "\e[36m",
19+
reset: "\e[0m"
20+
}.freeze
21+
1322
def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr)
1423
return usage(stdout) if argv == ["--help"] || argv == ["-h"]
1524

@@ -21,20 +30,22 @@ class RParallel
2130
commands = stdin.each_line.map(&:strip).reject(&:empty?)
2231
tasks = commands.each_with_index.map { |command, index| Task.new(number: index + 1, command:) }
2332

24-
tasks.map { |task| Thread.new { run_task(task, stdout, stderr) } }.each(&:join)
33+
color = color_enabled?
34+
output_mutex = Mutex.new
35+
tasks.map { |task| Thread.new { run_task(task, stdout, stderr, output_mutex, color) } }.each(&:join)
2536
print_report(tasks, stdout)
2637

2738
tasks.all? { _1.exit_code.zero? } ? 0 : 1
2839
end
2940

30-
def self.run_task(task, stdout, stderr)
41+
def self.run_task(task, stdout, stderr, output_mutex, color)
3142
task.started_at = monotonic_time
3243

3344
Open3.popen3("sh", "-c", task.command) do |child_stdin, child_stdout, child_stderr, wait_thread|
3445
child_stdin.close
3546
readers = [
36-
Thread.new { IO.copy_stream(child_stdout, stdout) },
37-
Thread.new { IO.copy_stream(child_stderr, stderr) }
47+
Thread.new { stream_output(child_stdout, stdout, task.number, output_mutex) },
48+
Thread.new { stream_output(child_stderr, stderr, task.number, output_mutex, color: :red, enabled: color) }
3849
]
3950
readers.each(&:join)
4051
task.exit_code = wait_thread.value.exitstatus || 1
@@ -46,26 +57,61 @@ class RParallel
4657
task.ended_at = monotonic_time
4758
end
4859

60+
def self.stream_output(source, target, job_number, output_mutex, color: nil, enabled: false)
61+
source.each_line do |line|
62+
output = "[Job #{job_number}] #{line.chomp}"
63+
output_mutex.synchronize { target.puts color ? colorize(output, color, enabled) : output }
64+
end
65+
end
66+
4967
def self.print_report(tasks, stdout)
5068
rows = tasks.map do |task|
5169
[task.number.to_s, task.status, task.exit_code.to_s, format("%.3fs", task.duration), task.command]
5270
end
53-
widths = column_widths([["#", "status", "exit", "duration", "command"], *rows])
71+
header = ["#", "status", "exit", "duration", "command"]
72+
widths = column_widths([header, *rows])
73+
color = color_enabled?
5474

5575
stdout.puts
56-
stdout.puts "rparallel report"
57-
stdout.puts format_row(["#", "status", "exit", "duration", "command"], widths)
58-
rows.each { stdout.puts format_row(_1, widths) }
76+
stdout.puts colorize("rparallel report", :bold, color)
77+
stdout.puts colorize(border(widths), :dim, color)
78+
stdout.puts format_row(header, widths, color:, header: true)
79+
stdout.puts colorize(border(widths), :dim, color)
80+
rows.each { stdout.puts format_row(_1, widths, color:) }
81+
stdout.puts colorize(border(widths), :dim, color)
5982
end
6083

6184
def self.column_widths(rows)
6285
rows.transpose.map { _1.map(&:length).max }
6386
end
6487

65-
def self.format_row(row, widths)
66-
row.each_with_index.map do |value, index|
67-
index == row.length - 1 ? value : value.ljust(widths[index])
68-
end.join(" ")
88+
def self.format_row(row, widths, color:, header: false)
89+
cells = row.each_with_index.map do |value, index|
90+
padded = value.ljust(widths[index])
91+
header ? colorize(padded, :cyan, color) : colorize_report_cell(padded, row, index, color)
92+
end
93+
94+
"| #{cells.join(' | ')} |"
95+
end
96+
97+
def self.border(widths)
98+
"+-#{widths.map { '-' * _1 }.join('-+-')}-+"
99+
end
100+
101+
def self.colorize_report_cell(value, row, index, color)
102+
return colorize(value, row[1] == "ok" ? :green : :red, color) if [1, 2].include?(index)
103+
104+
value
105+
end
106+
107+
def self.colorize(value, color_name, enabled)
108+
return value unless enabled
109+
110+
"#{COLORS.fetch(color_name)}#{value}#{COLORS.fetch(:reset)}"
111+
end
112+
113+
def self.color_enabled?
114+
ENV["NO_COLOR"].nil? && ENV["RPARALLEL_NO_COLOR"].nil?
69115
end
70116

71117
def self.usage(stdout)
@@ -77,7 +123,8 @@ class RParallel
77123

78124
def self.monotonic_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
79125

80-
private_class_method :run_task, :print_report, :column_widths, :format_row, :usage, :monotonic_time
126+
private_class_method :run_task, :stream_output, :print_report, :column_widths, :format_row, :border,
127+
:colorize_report_cell, :colorize, :color_enabled?, :usage, :monotonic_time
81128
end
82129

83130
exit RParallel.run(ARGV)

lib/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
module Dry
22
class Stack
3-
VERSION = '0.1.55'
3+
VERSION = '0.1.56'
44
end
55
end

spec/rparallel_spec.rb

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,36 +4,60 @@
44

55
describe "rparallel" do
66
let(:bin) { File.expand_path("../bin/rparallel", __dir__) }
7-
def run_rparallel(input, *args)
8-
Open3.capture3(bin, *args, stdin_data: input)
7+
8+
def run_rparallel(input, *args, env: {})
9+
Open3.capture3({ "NO_COLOR" => nil, "RPARALLEL_NO_COLOR" => nil }.merge(env), bin, *args, stdin_data: input)
10+
end
11+
12+
def strip_ansi(value)
13+
value.gsub(/\e\[[\d;]+m/, "")
914
end
1015

1116
it "runs stdin lines as tasks and prints a report" do
1217
stdout, stderr, status = run_rparallel("echo first\necho second\n")
18+
plain_stdout = strip_ansi(stdout)
1319

1420
expect(status).to be_success
1521
expect(stderr).to eq("")
16-
expect(stdout).to include("first\n")
17-
expect(stdout).to include("second\n")
18-
expect(stdout).to include("rparallel report")
19-
expect(stdout).to match(/^# status exit duration command$/)
20-
expect(stdout).to match(/^1 ok\s+0\s+\d+\.\d{3}s\s+echo first$/)
21-
expect(stdout).to match(/^2 ok\s+0\s+\d+\.\d{3}s\s+echo second$/)
22+
expect(stdout).to include("[Job 1] first\n")
23+
expect(stdout).to include("[Job 2] second\n")
24+
expect(stdout).to include("\e[1mrparallel report\e[0m")
25+
expect(stdout).to include("\e[32mok")
26+
expect(plain_stdout).to include("rparallel report")
27+
expect(plain_stdout).to match(/^\+-+\+-+\+-+\+-+\+-+\+$/)
28+
expect(plain_stdout).to match(/^\| # \| status \| exit \| duration \| command\s+\|$/)
29+
expect(plain_stdout).to match(/^\| 1 \| ok\s+\| 0\s+\| \d+\.\d{3}s\s+\| echo first\s+\|$/)
30+
expect(plain_stdout).to match(/^\| 2 \| ok\s+\| 0\s+\| \d+\.\d{3}s\s+\| echo second \|$/)
2231
end
2332

2433
it "ignores blank lines" do
2534
stdout, = run_rparallel("\n \necho only\n\n")
35+
plain_stdout = strip_ansi(stdout)
2636

27-
expect(stdout).to match(/^1 ok\s+0\s+\d+\.\d{3}s\s+echo only$/)
28-
expect(stdout).not_to match(/^2\s/)
37+
expect(plain_stdout).to match(/^\| 1 \| ok\s+\| 0\s+\| \d+\.\d{3}s\s+\| echo only \|$/)
38+
expect(plain_stdout).not_to match(/^\| 2 \|/)
2939
end
3040

3141
it "executes tasks through the shell" do
3242
stdout, stderr, status = run_rparallel("printf hi | tr h H\n")
3343

3444
expect(status).to be_success
3545
expect(stderr).to eq("")
36-
expect(stdout).to include("Hi")
46+
expect(stdout).to include("[Job 1] Hi\n")
47+
end
48+
49+
it "prefixes stderr output with the job number" do
50+
_stdout, stderr, status = run_rparallel("echo Error >&2\n")
51+
52+
expect(status).to be_success
53+
expect(stderr).to eq("\e[31m[Job 1] Error\e[0m\n")
54+
end
55+
56+
it "can disable colors for stderr output" do
57+
_stdout, stderr, status = run_rparallel("echo Error >&2\n", env: { "NO_COLOR" => "1" })
58+
59+
expect(status).to be_success
60+
expect(stderr).to eq("[Job 1] Error\n")
3761
end
3862

3963
it "runs tasks concurrently" do
@@ -49,10 +73,13 @@ def run_rparallel(input, *args)
4973

5074
it "returns a failure when any task fails" do
5175
stdout, _stderr, status = run_rparallel("echo ok\nfalse\n")
76+
plain_stdout = strip_ansi(stdout)
5277

5378
expect(status.exitstatus).to eq(1)
54-
expect(stdout).to match(/^1 ok\s+0\s+\d+\.\d{3}s\s+echo ok$/)
55-
expect(stdout).to match(/^2 failed\s+1\s+\d+\.\d{3}s\s+false$/)
79+
expect(stdout).to include("[Job 1] ok\n")
80+
expect(stdout).to include("\e[31mfailed")
81+
expect(plain_stdout).to match(/^\| 1 \| ok\s+\| 0\s+\| \d+\.\d{3}s\s+\| echo ok \|$/)
82+
expect(plain_stdout).to match(/^\| 2 \| failed \| 1\s+\| \d+\.\d{3}s\s+\| false\s+\|$/)
5683
end
5784

5885
it "rejects unsupported arguments" do

0 commit comments

Comments
 (0)