dry-cli-ui 0.5.0 → 0.5.1
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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +76 -17
- data/examples/bin/mycli +124 -69
- data/lib/dry/cli/ui/console.rb +31 -5
- data/lib/dry/cli/ui/status_bar.rb +1 -1
- data/lib/dry/cli/ui/stop.rb +52 -0
- data/lib/dry/cli/ui/version.rb +1 -1
- data/lib/dry/cli/ui/widgets/multi.rb +63 -14
- data/lib/dry/cli/ui/widgets/multi_progress.rb +58 -15
- data/lib/dry/cli/ui/widgets/pool.rb +15 -11
- data/lib/dry/cli/ui/widgets/progress.rb +28 -6
- data/lib/dry/cli/ui.rb +1 -0
- data/sig/dry/cli/ui.rbs +9 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d1ad3344ddfd395caef9b2ec0128f05cda8248a98afa864b1b61ed1d1db0b5e
|
|
4
|
+
data.tar.gz: d55f3256373ec25c1201dd0c39525eeb49b50f16989806740f720ed3e59f0f13
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fe5bb8356f594b5a8670ee7c8b9a5cdc0ec87e2480bea892f0e98389a8d43218fd2de162f77de9444990e3d197e7679352995f1da3eb234094df68a5ea5c2d25
|
|
7
|
+
data.tar.gz: b982df4a36156203f92f39b3b3c8da0e9b5fe8672092333c09d7dd70e48759f0470fc82060dea4a16bdbc9daca3e219361ca0c7f95f114961b740b20354e2105
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## [0.5.1]
|
|
2
|
+
|
|
3
|
+
- `ui.stoppable { |stop| ... }` makes Ctrl-C ask for a stop instead of interrupting, and `ui.multi_spinner` and `ui.multi_progress` take `stop:`. Once it is set, the jobs running finish, the rest are skipped, and the headline says `stopping`, then ends skipped. A second Ctrl-C interrupts. `Dry::CLI::UI::Stop` is the object behind it.
|
|
4
|
+
- `ui.multi_spinner` and `ui.multi_progress` stay animated when their rows do not fit on the screen. Only the running jobs are shown under the headline, as many as fit. Before, every row was printed one by one.
|
|
5
|
+
- `m.progress` inside `ui.multi_progress` takes `total: nil` for a job that learns its size as it runs. Its bar is empty and counts `12/?` until the job sets `bar.total =`, which every progress handle now has.
|
|
6
|
+
- `ui.multi_progress` takes `count: :jobs`, so the headline bar counts the jobs that ended, and `total:`, the headline's own total for bars that overlap.
|
|
7
|
+
- `examples/bin/mycli download-urls` sends each URL's `HEAD` request when its turn comes, not all of them first, and writes each file as its body arrives. `find-hosts --progress` gives its headline a total of the addresses scanned, not twice that. Both take `-c/--concurrency`, from 1 to 100, 10 by default, and on Ctrl-C finish what is running and report how many URLs were downloaded or addresses scanned.
|
|
8
|
+
|
|
1
9
|
## [0.5.0]
|
|
2
10
|
|
|
3
11
|
- The README documents every public method, option and error, with the plain output each widget prints when piped, and a section on testing a command with `StringIO`.
|
data/README.md
CHANGED
|
@@ -267,6 +267,7 @@ end
|
|
|
267
267
|
```
|
|
268
268
|
|
|
269
269
|
- `advance(step = 1)` adds to `current` and returns the handle; `current` never passes `total`.
|
|
270
|
+
- `total = n` sets the total once the work finds out, such as a download learning its size, and lowers `current` to fit. Anything but a non-negative Integer raises `ArgumentError`.
|
|
270
271
|
- `total:` must be a non-negative Integer, or `progress` raises `ArgumentError`.
|
|
271
272
|
- `total: 0` draws no bar, and ends `✓ Copying 0/0`.
|
|
272
273
|
- `color:` paints this bar's finished part in any Pastel style, such as `color: :red`, instead of the configured `bar_color`. A style Pastel does not know raises `ArgumentError` before the block runs.
|
|
@@ -328,7 +329,13 @@ Fetching...
|
|
|
328
329
|
𝘅 Fetching (0.1s)
|
|
329
330
|
```
|
|
330
331
|
|
|
331
|
-
|
|
332
|
+
On a terminal with fewer rows than the widget needs, the cursor cannot reach them all, so only the jobs running are shown under the headline, as many as fit. Jobs leave the screen as they end, and the headline is all that remains:
|
|
333
|
+
|
|
334
|
+
```text
|
|
335
|
+
[⠋] Fetching
|
|
336
|
+
├─ [⠋] video
|
|
337
|
+
└─ [⠋] audio
|
|
338
|
+
```
|
|
332
339
|
|
|
333
340
|
### Several progress bars at once
|
|
334
341
|
|
|
@@ -354,13 +361,43 @@ The same shape as `multi_spinner`, with a bar per job and a headline bar that co
|
|
|
354
361
|
Each job is given the same handle as `ui.progress`, with `advance(step = 1)`, `current` and `total`. `m.progress` takes `color:` as `ui.progress` does, so bars side by side can differ:
|
|
355
362
|
|
|
356
363
|
```ruby
|
|
357
|
-
ui.multi_progress("Probing #{hosts.size} hosts") do |m|
|
|
364
|
+
ui.multi_progress("Probing #{hosts.size} hosts", total: hosts.size) do |m|
|
|
358
365
|
m.progress("Answered", total: hosts.size, color: :green) { |bar| ... }
|
|
359
366
|
m.progress("No answer", total: hosts.size, color: :red) { |bar| ... }
|
|
360
367
|
end
|
|
361
368
|
```
|
|
362
369
|
|
|
363
|
-
The headline bar keeps the configured colour. A finished job's row reads `[✓] fonts.zip 40/40 (0.1s)`, and the headline's `[✓] Downloading 240/240 (0.3s)`. It returns what each job returned, in declaration order, and takes `concurrent:` as `multi_spinner` does. Every `m.progress` needs a block and a non-negative Integer
|
|
370
|
+
The headline bar keeps the configured colour. A finished job's row reads `[✓] fonts.zip 40/40 (0.1s)`, and the headline's `[✓] Downloading 240/240 (0.3s)`. It returns what each job returned, in declaration order, and takes `concurrent:` as `multi_spinner` does. Every `m.progress` needs a block, and a `total:` that is nil or a non-negative Integer, or raises `ArgumentError`.
|
|
371
|
+
|
|
372
|
+
A job that learns its size only as it runs declares `total: nil` and sets `bar.total =` once it knows. Until then its bar is empty and its count reads `12/?`.
|
|
373
|
+
|
|
374
|
+
The headline bar adds up every job's bar. Two options change that:
|
|
375
|
+
|
|
376
|
+
- `count: :jobs` counts the jobs that have ended, out of all of them, whatever each bar measures.
|
|
377
|
+
- `total: n` sets the headline's total, for bars that overlap, such as two bars that each count some of the same hosts.
|
|
378
|
+
|
|
379
|
+
```ruby
|
|
380
|
+
ui.multi_progress("Downloading", concurrent: 2, count: :jobs) do |m|
|
|
381
|
+
files.each do |file|
|
|
382
|
+
m.progress(file.name, total: nil) do |bar|
|
|
383
|
+
bar.total = size_of(file)
|
|
384
|
+
download(file) { |bytes| bar.advance(bytes) }
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Piped, where `images.tar.gz` never learned its size:
|
|
391
|
+
|
|
392
|
+
```text
|
|
393
|
+
Downloading...
|
|
394
|
+
[✓] fonts.zip 40/40 (0.1s)
|
|
395
|
+
[✓] images.tar.gz 120/? (0.1s)
|
|
396
|
+
[✓] video.mp4 80/80 (0.1s)
|
|
397
|
+
✓ Downloading 3/3 (0.1s)
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Any other `count:`, or a `total:` that is not a non-negative Integer, raises `ArgumentError`.
|
|
364
401
|
|
|
365
402
|
Piped:
|
|
366
403
|
|
|
@@ -384,21 +421,16 @@ end
|
|
|
384
421
|
File.write("urls.txt", bodies.join("\n"))
|
|
385
422
|
```
|
|
386
423
|
|
|
387
|
-
With `multi_progress`, each URL gets a bar that fills
|
|
424
|
+
With `multi_progress`, each URL gets a bar that fills as the body arrives, and the headline counts URLs. When a URL's turn comes, a `HEAD` request asks for its size, which becomes the bar's total:
|
|
388
425
|
|
|
389
426
|
```ruby
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
bodies = ui.multi_progress("Fetching #{found.size} URLs", concurrent: 8) do |m|
|
|
398
|
-
found.each do |url, size|
|
|
399
|
-
m.progress(url, total: size || 1) do |bar|
|
|
400
|
-
body = download(url) { |bytes| bytes.times { bar.advance } if size }
|
|
401
|
-
bar.advance unless size # no Content-Length: done in one step
|
|
427
|
+
bodies = ui.multi_progress("Fetching #{urls.size} URLs", concurrent: 8, count: :jobs) do |m|
|
|
428
|
+
urls.each do |url|
|
|
429
|
+
m.progress(url, total: nil) do |bar|
|
|
430
|
+
size = content_length(url)
|
|
431
|
+
bar.total = size if size
|
|
432
|
+
body = download(url) { |bytes| bar.advance(bytes) }
|
|
433
|
+
bar.total = bar.current unless size # no Content-Length: full once done
|
|
402
434
|
body
|
|
403
435
|
end
|
|
404
436
|
end
|
|
@@ -434,7 +466,34 @@ def download(url)
|
|
|
434
466
|
end
|
|
435
467
|
```
|
|
436
468
|
|
|
437
|
-
In both, at most eight requests run at once and the rest wait as `[ ]` rows.
|
|
469
|
+
In both, at most eight requests run at once, in the order of `urls`, and the rest wait as `[ ]` rows. A server that sends no `Content-Length` leaves its bar empty, counting `12/?`, until its download ends. These helpers are kept short: a real one follows redirects, sends `Accept-Encoding: identity` so the bytes counted match `Content-Length`, and writes each body to its file as it arrives rather than holding it in memory; `examples/bin/mycli download-urls` does all three. With more URLs than the screen has rows, only the URLs downloading are shown.
|
|
470
|
+
|
|
471
|
+
### Stopping on Ctrl-C
|
|
472
|
+
|
|
473
|
+
`ui.stoppable` runs its block with Ctrl-C asking for a stop instead of killing the command. Hand the `stop` it yields to `multi_spinner` or `multi_progress` as `stop:`: once Ctrl-C is pressed, the jobs running finish, no more start, and the command carries on to report what it did. A second Ctrl-C interrupts as usual.
|
|
474
|
+
|
|
475
|
+
```ruby
|
|
476
|
+
ui.stoppable do |stop|
|
|
477
|
+
files = ui.multi_spinner("Downloading #{urls.size} URLs", concurrent: 10, stop: stop) do |m|
|
|
478
|
+
urls.each { |url| m.spinner(url) { download(url) } }
|
|
479
|
+
end
|
|
480
|
+
next ui.success("Downloaded #{files.size} URLs") unless stop.stopped?
|
|
481
|
+
|
|
482
|
+
ui.info "Stopped", "Downloaded #{files.compact.size} of #{urls.size} URLs"
|
|
483
|
+
end
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
While the running jobs finish, the headline reads `[⠋] Downloading 1628 URLs stopping`. Jobs that never started are marked skipped and return `nil`, and the headline ends skipped too, unless a job failed. Piped:
|
|
487
|
+
|
|
488
|
+
```text
|
|
489
|
+
Fetching...
|
|
490
|
+
[✓] fonts (0.1s)
|
|
491
|
+
[—] images
|
|
492
|
+
[—] video
|
|
493
|
+
— Fetching (0.1s)
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
`stop.stopped?` says whether a stop was asked for, and `stop.stop!` asks for one from code, from any thread. `Dry::CLI::UI::Stop.new.trap(signal)` traps a signal other than `INT`. Your own worker threads can check `stop.stopped?` before taking the next item, as `examples/bin/mycli find-hosts` does. With `concurrent: true` every job has started already, so there is nothing left to skip.
|
|
438
497
|
|
|
439
498
|
### Task trees
|
|
440
499
|
|
data/examples/bin/mycli
CHANGED
|
@@ -28,6 +28,23 @@ module Foo
|
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Adds -c/--concurrency, how many items a command works on at once.
|
|
32
|
+
module Concurrency
|
|
33
|
+
DEFAULT = 10
|
|
34
|
+
MAX = 100
|
|
35
|
+
|
|
36
|
+
def self.included(command)
|
|
37
|
+
command.option :concurrency, aliases: ["-c"], type: :integer, default: DEFAULT, desc: "How many at once, from 1 to #{MAX}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# @return [Integer, nil] the value as an Integer; nil when out of range
|
|
43
|
+
def concurrency(value)
|
|
44
|
+
Integer(value, exception: false)&.then { |count| count if count.between?(1, MAX) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
31
48
|
# Used by download-urls: follows up to REDIRECTS redirects on every
|
|
32
49
|
# request, and asks for the body as it is stored, so the bytes counted
|
|
33
50
|
# match Content-Length.
|
|
@@ -52,28 +69,24 @@ module Foo
|
|
|
52
69
|
[uri, response.content_length]
|
|
53
70
|
end
|
|
54
71
|
|
|
55
|
-
# @yieldparam
|
|
56
|
-
#
|
|
72
|
+
# @yieldparam response [Net::HTTPSuccess] the response, whose body is
|
|
73
|
+
# read in the block
|
|
74
|
+
# @return [Object] what the block returned
|
|
57
75
|
# @raise [RuntimeError] for a response that is not a success
|
|
58
|
-
def
|
|
59
|
-
|
|
60
|
-
type = nil
|
|
76
|
+
def get(uri, hops = REDIRECTS, &)
|
|
77
|
+
result = nil
|
|
61
78
|
location = nil
|
|
62
79
|
start(uri) do |http|
|
|
63
80
|
http.request_get(uri.request_uri, HEADERS) do |response|
|
|
64
81
|
next location = redirect(uri, response, hops) if response.is_a?(Net::HTTPRedirection)
|
|
65
82
|
raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
66
83
|
|
|
67
|
-
|
|
68
|
-
response.read_body do |chunk|
|
|
69
|
-
body << chunk
|
|
70
|
-
yield chunk.bytesize if block_given?
|
|
71
|
-
end
|
|
84
|
+
result = yield response
|
|
72
85
|
end
|
|
73
86
|
end
|
|
74
|
-
return
|
|
87
|
+
return get(URI(location), hops - 1, &) if location
|
|
75
88
|
|
|
76
|
-
|
|
89
|
+
result
|
|
77
90
|
end
|
|
78
91
|
|
|
79
92
|
# @return [String] the absolute URL a redirect points to
|
|
@@ -90,7 +103,9 @@ module Foo
|
|
|
90
103
|
end
|
|
91
104
|
end
|
|
92
105
|
|
|
93
|
-
# Downloads URLs, each to its own file
|
|
106
|
+
# Downloads URLs, each to its own file as its body arrives, at most
|
|
107
|
+
# --concurrency at once and in the order given, under a spinner or a
|
|
108
|
+
# progress bar.
|
|
94
109
|
class DownloadUrls < Dry::CLI::Command
|
|
95
110
|
include Dry::CLI::UI
|
|
96
111
|
|
|
@@ -116,21 +131,21 @@ module Foo
|
|
|
116
131
|
"text/xml" => ".xml"
|
|
117
132
|
}.freeze
|
|
118
133
|
|
|
119
|
-
# The most URLs downloaded at once.
|
|
120
|
-
CONCURRENCY = 10
|
|
121
|
-
|
|
122
134
|
desc "Download URLs, each to its own file"
|
|
123
135
|
argument :urls, type: :array, required: false, desc: "URLs to download, separated by spaces"
|
|
124
136
|
option :'urls-file', aliases: ["-u"], desc: "A file with one URL per line; blank lines and # comments are skipped"
|
|
125
|
-
option :
|
|
126
|
-
option :progress, type: :flag, default:
|
|
127
|
-
option :spinner, type: :flag, default: false, desc: "Show a spinner per URL; used when neither flag is given"
|
|
137
|
+
option :output, aliases: ["-o"], default: ".", desc: "A directory to save the files in, created when missing"
|
|
138
|
+
option :progress, aliases: ["-p"], type: :flag, default: true, desc: "Show a progress bar per URL"
|
|
139
|
+
option :spinner, aliases: ["-s"], type: :flag, default: false, desc: "Show a spinner per URL; used when neither flag is given"
|
|
140
|
+
include Concurrency
|
|
128
141
|
|
|
129
142
|
def call(urls: [], progress: false, spinner: false, **options)
|
|
130
|
-
|
|
143
|
+
progress = false if spinner
|
|
144
|
+
@concurrency = concurrency(options[:concurrency]) or
|
|
145
|
+
return ui.error("--concurrency must be from 1 to #{Concurrency::MAX}, got #{options[:concurrency]}")
|
|
131
146
|
|
|
132
147
|
urls_file = options[:'urls-file']
|
|
133
|
-
save_to = options.fetch(:
|
|
148
|
+
save_to = options.fetch(:output, ".")
|
|
134
149
|
|
|
135
150
|
begin
|
|
136
151
|
urls += read_urls(urls_file) if urls_file
|
|
@@ -153,16 +168,18 @@ module Foo
|
|
|
153
168
|
return ui.error("Cannot create #{save_to}", e.message)
|
|
154
169
|
end
|
|
155
170
|
|
|
156
|
-
downloads
|
|
157
|
-
files =
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
File.join(save_to, filename(url, type)).tap { |file| File.write(file, body) }
|
|
171
|
+
# Ctrl-C lets the downloads running finish, and starts no more.
|
|
172
|
+
files = ui.stoppable do |stop|
|
|
173
|
+
@stop = stop
|
|
174
|
+
(progress ? with_progress(urls, save_to) : with_spinner(urls, save_to)).compact
|
|
161
175
|
end
|
|
176
|
+
return ui.info("Stopped", "Downloaded #{files.size} of #{urls.size} URLs into #{save_to}") if @stop.stopped?
|
|
177
|
+
|
|
162
178
|
ui.success "Wrote #{files.size} of #{urls.size} URLs", files.join("\n")
|
|
163
179
|
end
|
|
164
180
|
|
|
165
181
|
# An http or https URL with a host.
|
|
182
|
+
|
|
166
183
|
def valid?(url)
|
|
167
184
|
uri = URI.parse(url)
|
|
168
185
|
uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
@@ -170,14 +187,15 @@ module Foo
|
|
|
170
187
|
false
|
|
171
188
|
end
|
|
172
189
|
|
|
173
|
-
|
|
174
|
-
|
|
190
|
+
# A spinner per URL; a URL that fails is marked failed.
|
|
191
|
+
#
|
|
192
|
+
# @return [Array<String, nil>] the file written for each URL
|
|
193
|
+
def with_spinner(urls, save_to)
|
|
194
|
+
ui.multi_spinner("Downloading #{urls.size} URLs", concurrent: @concurrency, stop: @stop) do |m|
|
|
175
195
|
urls.each do |url|
|
|
176
196
|
m.spinner(url) do |line|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
line.detail = "downloading #{uri.host}"
|
|
180
|
-
HTTP.download(uri)
|
|
197
|
+
received = 0
|
|
198
|
+
save(url, URI(url), save_to) { |bytes| line.detail = "#{received += bytes} bytes" }
|
|
181
199
|
rescue StandardError => e
|
|
182
200
|
line.fail(e.message)
|
|
183
201
|
nil
|
|
@@ -186,36 +204,53 @@ module Foo
|
|
|
186
204
|
end
|
|
187
205
|
end
|
|
188
206
|
|
|
189
|
-
# A bar
|
|
190
|
-
#
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
ui.status "#{url}: #{e.message}", level: :warn
|
|
196
|
-
[url, nil]
|
|
197
|
-
end
|
|
198
|
-
ready = found.select { |_, (uri, _)| uri }
|
|
199
|
-
return urls.map { nil } if ready.empty?
|
|
200
|
-
|
|
207
|
+
# A bar per URL, and a headline bar counting the URLs done. When a
|
|
208
|
+
# URL's turn comes, a HEAD request finds its size for the bar's total,
|
|
209
|
+
# then the file is downloaded.
|
|
210
|
+
#
|
|
211
|
+
# @return [Array<String, nil>] the file written for each URL
|
|
212
|
+
def with_progress(urls, save_to)
|
|
201
213
|
# A bar cannot be marked failed, so a download that fails is
|
|
202
214
|
# reported once every bar has finished.
|
|
203
215
|
errors = Concurrent::Array.new
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
m.progress(url, total:
|
|
207
|
-
|
|
208
|
-
bar.
|
|
209
|
-
|
|
216
|
+
files = ui.multi_progress("Downloading #{urls.size} URLs", concurrent: @concurrency, count: :jobs, stop: @stop) do |m|
|
|
217
|
+
urls.each do |url|
|
|
218
|
+
m.progress(url, total: nil) do |bar|
|
|
219
|
+
uri, size = HTTP.resolve(url)
|
|
220
|
+
bar.total = size if size
|
|
221
|
+
save(url, uri, save_to) { |bytes| bar.advance(bytes) }.tap { bar.total = bar.current unless size }
|
|
210
222
|
rescue StandardError => e
|
|
211
223
|
errors << "#{url}: #{e.message}"
|
|
212
224
|
nil
|
|
213
225
|
end
|
|
214
226
|
end
|
|
215
227
|
end
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
228
|
+
ui.warn "#{errors.size} URLs failed", errors.join("\n") if errors.any?
|
|
229
|
+
files
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Downloads a URL straight into its file, removing what was written
|
|
233
|
+
# when the download fails.
|
|
234
|
+
#
|
|
235
|
+
# @param url [String] what the file is named after
|
|
236
|
+
# @param uri [URI] where to download it from
|
|
237
|
+
# @yieldparam bytes [Integer] the size of each chunk as it is written
|
|
238
|
+
# @return [String] the file written
|
|
239
|
+
def save(url, uri, save_to)
|
|
240
|
+
file = nil
|
|
241
|
+
HTTP.get(uri) do |response|
|
|
242
|
+
file = File.join(save_to, filename(url, response.content_type))
|
|
243
|
+
File.open(file, "wb") do |io|
|
|
244
|
+
response.read_body do |chunk|
|
|
245
|
+
io.write(chunk)
|
|
246
|
+
yield chunk.bytesize
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
file
|
|
251
|
+
rescue StandardError
|
|
252
|
+
FileUtils.rm_f(file) if file
|
|
253
|
+
raise
|
|
219
254
|
end
|
|
220
255
|
|
|
221
256
|
def read_urls(file)
|
|
@@ -243,19 +278,19 @@ module Foo
|
|
|
243
278
|
# RDP, and the two AirPlay and Synology use.
|
|
244
279
|
COMMON_PORTS = [22, 53, 80, 123, 139, 443, 445, 631, 3389, 5000, 7000].freeze
|
|
245
280
|
|
|
246
|
-
# How many addresses are probed at once. Each probes its ports all at
|
|
247
|
-
# once, so this keeps open sockets well under the default limit of 256.
|
|
248
|
-
CONCURRENCY = 10
|
|
249
|
-
|
|
250
281
|
desc "Find hosts on the local network that answer on TCP ports"
|
|
251
282
|
option :port, type: :integer, desc: "Probe only this TCP port, instead of #{COMMON_PORTS.join(', ')}"
|
|
252
283
|
option :timeout, type: :float, default: 0.5, aliases: ["-t"], desc: "Seconds to wait for each port"
|
|
253
284
|
option :output, aliases: ["-o"], desc: "Also write the hosts to this file"
|
|
254
|
-
option :progress, type: :flag, default:
|
|
255
|
-
option :spinner, type: :flag, default: false,
|
|
285
|
+
option :progress, aliases: ["-p"], type: :flag, default: true, desc: "Show progress bars"
|
|
286
|
+
option :spinner, aliases: ["-s"], type: :flag, default: false, desc: "Show spinners"
|
|
287
|
+
include Concurrency
|
|
288
|
+
|
|
289
|
+
def call(timeout:, port: nil, output: nil, progress: false, spinner: false, concurrency: Concurrency::DEFAULT, **)
|
|
290
|
+
progress = false if spinner
|
|
291
|
+
@concurrency = concurrency(concurrency) or return ui.error("--concurrency must be from 1 to #{Concurrency::MAX}, got #{concurrency}")
|
|
256
292
|
|
|
257
|
-
|
|
258
|
-
return ui.error("Pick one of --progress and --spinner, not both") if progress && spinner
|
|
293
|
+
open_files_for(@concurrency * COMMON_PORTS.size)
|
|
259
294
|
|
|
260
295
|
ip = local_ip or return ui.error("No local IPv4 address found")
|
|
261
296
|
subnet = ip.split(".").first(3).join(".")
|
|
@@ -264,17 +299,36 @@ module Foo
|
|
|
264
299
|
ui.info "Local IP: #{ip}, scanning #{subnet}.1-254 on #{ports.size == 1 ? 'port' : 'ports'} #{ports.join(', ')}"
|
|
265
300
|
|
|
266
301
|
progress ||= port && !spinner
|
|
267
|
-
|
|
302
|
+
@scanned = Concurrent::AtomicFixnum.new
|
|
303
|
+
# Ctrl-C lets the addresses being probed finish, and probes no more.
|
|
304
|
+
results = ui.stoppable do |stop|
|
|
305
|
+
@stop = stop
|
|
306
|
+
progress ? with_progress(ips, ports, timeout.to_f) : with_spinner(ips, ports, timeout.to_f)
|
|
307
|
+
end
|
|
268
308
|
hosts = ips.zip(results).select { |_, open| open }
|
|
309
|
+
lines = hosts.map { |host, open| "#{host}: #{open.empty? ? 'no open ports' : open.join(', ')}" }
|
|
310
|
+
File.write(output, lines.join("\n") << "\n") if output && hosts.any?
|
|
311
|
+
if @stop.stopped?
|
|
312
|
+
return ui.info("Stopped", "Scanned #{@scanned.value} of #{ips.size} addresses, and found #{hosts.size} hosts", *lines)
|
|
313
|
+
end
|
|
269
314
|
return ui.warn("No hosts answered on #{ports.join(', ')}") if hosts.empty?
|
|
270
315
|
|
|
271
|
-
lines = hosts.map { |host, open| "#{host}: #{open.empty? ? 'no open ports' : open.join(', ')}" }
|
|
272
|
-
File.write(output, lines.join("\n") << "\n") if output
|
|
273
316
|
ui.success "Found #{hosts.size} hosts", lines.join("\n"), *("Written to #{output}" if output)
|
|
274
317
|
end
|
|
275
318
|
|
|
276
319
|
private
|
|
277
320
|
|
|
321
|
+
# Each address probes its ports all at once, so raise the open file limit
|
|
322
|
+
# when the default, often 256, is too low; a probe that cannot open a
|
|
323
|
+
# socket would read as no answer.
|
|
324
|
+
def open_files_for(sockets)
|
|
325
|
+
soft, hard = Process.getrlimit(:NOFILE)
|
|
326
|
+
wanted = sockets + 64
|
|
327
|
+
Process.setrlimit(:NOFILE, [wanted, hard].min, hard) if soft < wanted
|
|
328
|
+
rescue SystemCallError
|
|
329
|
+
nil
|
|
330
|
+
end
|
|
331
|
+
|
|
278
332
|
def local_ip
|
|
279
333
|
Socket.getifaddrs
|
|
280
334
|
.map(&:addr)
|
|
@@ -285,7 +339,7 @@ module Foo
|
|
|
285
339
|
# A spinner per address, counting the ports answered so far. An
|
|
286
340
|
# address where nothing answered is marked failed.
|
|
287
341
|
def with_spinner(ips, ports, timeout)
|
|
288
|
-
ui.multi_spinner("Probing #{ips.size} addresses", concurrent:
|
|
342
|
+
ui.multi_spinner("Probing #{ips.size} addresses", concurrent: @concurrency, stop: @stop) do |m|
|
|
289
343
|
ips.each do |ip|
|
|
290
344
|
m.spinner(ip) do |line|
|
|
291
345
|
answered = Concurrent::AtomicFixnum.new
|
|
@@ -306,7 +360,7 @@ module Foo
|
|
|
306
360
|
silent_bar = Concurrent::Promises.resolvable_future
|
|
307
361
|
scanned = Concurrent::Promises.resolvable_future
|
|
308
362
|
results = nil
|
|
309
|
-
ui.multi_progress("Probing #{ips.size} addresses") do |m|
|
|
363
|
+
ui.multi_progress("Probing #{ips.size} addresses", total: ips.size) do |m|
|
|
310
364
|
m.progress("Answered", total: ips.size, color: :green) do |answered|
|
|
311
365
|
silent = silent_bar.value!
|
|
312
366
|
results = in_parallel(ips) do |ip|
|
|
@@ -323,7 +377,7 @@ module Foo
|
|
|
323
377
|
results
|
|
324
378
|
end
|
|
325
379
|
|
|
326
|
-
# Runs the block for each item, at most
|
|
380
|
+
# Runs the block for each item, at most --concurrency at once.
|
|
327
381
|
#
|
|
328
382
|
# @return [Array] what the block returned for each item, in order
|
|
329
383
|
def in_parallel(items)
|
|
@@ -331,9 +385,9 @@ module Foo
|
|
|
331
385
|
items.each_with_index { |item, index| queue << [item, index] }
|
|
332
386
|
queue.close
|
|
333
387
|
results = Array.new(items.size)
|
|
334
|
-
Array.new(
|
|
388
|
+
Array.new(@concurrency) do
|
|
335
389
|
Thread.new do
|
|
336
|
-
while (pair = queue.pop)
|
|
390
|
+
while !@stop.stopped? && (pair = queue.pop)
|
|
337
391
|
item, index = pair
|
|
338
392
|
results[index] = yield item
|
|
339
393
|
end
|
|
@@ -353,6 +407,7 @@ module Foo
|
|
|
353
407
|
probe(ip, port, timeout).tap(&)
|
|
354
408
|
end
|
|
355
409
|
end.map(&:value)
|
|
410
|
+
@scanned.increment
|
|
356
411
|
return if states.all?(:silent)
|
|
357
412
|
|
|
358
413
|
ports.zip(states).filter_map { |port, state| port if state == :open }
|
data/lib/dry/cli/ui/console.rb
CHANGED
|
@@ -158,13 +158,14 @@ module Dry
|
|
|
158
158
|
# @param title [String] the headline
|
|
159
159
|
# @param concurrent [Boolean, Integer] all at once (the default), one at
|
|
160
160
|
# a time, or at most this many at once
|
|
161
|
+
# @param stop [Stop, nil] once set, no more jobs start; see {#stoppable}
|
|
161
162
|
# @yieldparam spinners [Widgets::MultiSpinner::Builder] declares each `spinner`
|
|
162
163
|
# @return [Array<Object>] what each job returned, in declaration order
|
|
163
164
|
# @raise [ArgumentError] without a block, or with an invalid concurrent
|
|
164
|
-
def multi_spinner(title, concurrent: true, &)
|
|
165
|
+
def multi_spinner(title, concurrent: true, stop: nil, &)
|
|
165
166
|
raise ArgumentError, "multi_spinner needs a block" unless block_given?
|
|
166
167
|
|
|
167
|
-
Widgets::MultiSpinner.new(err, clock: clock, config: config).run(title, concurrent: concurrent, &)
|
|
168
|
+
Widgets::MultiSpinner.new(err, clock: clock, config: config).run(title, concurrent: concurrent, stop: stop, &)
|
|
168
169
|
end
|
|
169
170
|
|
|
170
171
|
# Runs a block with a progress bar showing percent, count and ETA.
|
|
@@ -197,13 +198,18 @@ module Dry
|
|
|
197
198
|
# @param title [String] the headline
|
|
198
199
|
# @param concurrent [Boolean, Integer] all at once (the default), one at
|
|
199
200
|
# a time, or at most this many at once
|
|
201
|
+
# @param count [Symbol] what the headline bar counts: `:units`, the sum
|
|
202
|
+
# of every bar (the default), or `:jobs`, how many jobs have ended
|
|
203
|
+
# @param total [Integer, nil] the headline bar's total, when the jobs'
|
|
204
|
+
# totals do not add up to it; nil to work it out
|
|
205
|
+
# @param stop [Stop, nil] once set, no more jobs start; see {#stoppable}
|
|
200
206
|
# @yieldparam bars [Widgets::MultiProgress::Builder] declares each `progress`
|
|
201
207
|
# @return [Array<Object>] what each job returned, in declaration order
|
|
202
|
-
# @raise [ArgumentError] without a block, or with an invalid concurrent
|
|
203
|
-
def multi_progress(title, concurrent: true, &)
|
|
208
|
+
# @raise [ArgumentError] without a block, or with an invalid concurrent, count or total
|
|
209
|
+
def multi_progress(title, concurrent: true, count: :units, total: nil, stop: nil, &)
|
|
204
210
|
raise ArgumentError, "multi_progress needs a block" unless block_given?
|
|
205
211
|
|
|
206
|
-
Widgets::MultiProgress.new(err, clock: clock, config: config).run(title, concurrent: concurrent, &)
|
|
212
|
+
Widgets::MultiProgress.new(err, clock: clock, config: config).run(title, concurrent: concurrent, count: count, total: total, stop: stop, &)
|
|
207
213
|
end
|
|
208
214
|
|
|
209
215
|
# Declares a tree of tasks, then runs it, showing each task's state
|
|
@@ -256,6 +262,26 @@ module Dry
|
|
|
256
262
|
StatusBar.new(err, others: others, title: title, hints: Array(hints), clock: clock, config: config).run(&)
|
|
257
263
|
end
|
|
258
264
|
|
|
265
|
+
# Runs the block with Ctrl-C asking for a stop instead of interrupting.
|
|
266
|
+
# Hand the stop to `multi_spinner` or `multi_progress` as `stop:`, and
|
|
267
|
+
# the jobs running finish while the rest are skipped. A second Ctrl-C
|
|
268
|
+
# interrupts as usual. See {Stop}.
|
|
269
|
+
#
|
|
270
|
+
# @example
|
|
271
|
+
# ui.stoppable do |stop|
|
|
272
|
+
# done = ui.multi_spinner("Fetching", concurrent: 4, stop: stop) { |m| ... }
|
|
273
|
+
# ui.info "Stopped after #{done.compact.size}" if stop.stopped?
|
|
274
|
+
# end
|
|
275
|
+
#
|
|
276
|
+
# @yieldparam stop [Stop]
|
|
277
|
+
# @return [Object] whatever the block returns
|
|
278
|
+
# @raise [ArgumentError] without a block
|
|
279
|
+
def stoppable(&)
|
|
280
|
+
raise ArgumentError, "stoppable needs a block" unless block_given?
|
|
281
|
+
|
|
282
|
+
Stop.new.trap(&)
|
|
283
|
+
end
|
|
284
|
+
|
|
259
285
|
# Prints a table to `out`.
|
|
260
286
|
#
|
|
261
287
|
# @example
|
|
@@ -284,7 +284,7 @@ module Dry
|
|
|
284
284
|
# @return [String, nil] a bar over every progress reported so far
|
|
285
285
|
def meter
|
|
286
286
|
progress = @finished + @running.values.filter_map(&:last)
|
|
287
|
-
total = progress.sum
|
|
287
|
+
total = progress.sum { |item| item.total || item.current }
|
|
288
288
|
return if total.zero?
|
|
289
289
|
|
|
290
290
|
ratio = progress.sum(&:current).fdiv(total)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
class CLI
|
|
5
|
+
module UI
|
|
6
|
+
# A request to stop starting new work, which any thread may make or
|
|
7
|
+
# check. The multi widgets take one as `stop:`: once it is set, jobs
|
|
8
|
+
# already running finish, and jobs not yet started are skipped.
|
|
9
|
+
#
|
|
10
|
+
# {#trap} sets it on Ctrl-C, so a command can end early and still report
|
|
11
|
+
# what it did. A second Ctrl-C interrupts as usual.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# ui.stoppable do |stop|
|
|
15
|
+
# files = ui.multi_spinner("Downloading", concurrent: 10, stop: stop) { |m| ... }
|
|
16
|
+
# ui.info "Stopped after #{files.compact.size} files" if stop.stopped?
|
|
17
|
+
# end
|
|
18
|
+
class Stop
|
|
19
|
+
# A plain flag rather than an atomic one, which takes a lock, and a
|
|
20
|
+
# trap handler may not. Setting it once, to true, is safe to race.
|
|
21
|
+
def initialize
|
|
22
|
+
@stopped = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Boolean] whether a stop was asked for
|
|
26
|
+
def stopped? = @stopped
|
|
27
|
+
|
|
28
|
+
# Asks for a stop.
|
|
29
|
+
#
|
|
30
|
+
# @return [self]
|
|
31
|
+
def stop!
|
|
32
|
+
@stopped = true
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Runs the block with the signal trapped: the first one asks for a
|
|
37
|
+
# stop, and the next raises `Interrupt`. The previous handler is put
|
|
38
|
+
# back when the block ends.
|
|
39
|
+
#
|
|
40
|
+
# @param signal [String]
|
|
41
|
+
# @yieldparam stop [Stop] this stop
|
|
42
|
+
# @return [Object] whatever the block returns
|
|
43
|
+
def trap(signal = "INT")
|
|
44
|
+
previous = Signal.trap(signal) { stopped? ? raise(Interrupt) : stop! }
|
|
45
|
+
yield self
|
|
46
|
+
ensure
|
|
47
|
+
Signal.trap(signal, previous || "DEFAULT")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/dry/cli/ui/version.rb
CHANGED
|
@@ -16,8 +16,13 @@ module Dry
|
|
|
16
16
|
# limit, is drawn before the first job starts.
|
|
17
17
|
#
|
|
18
18
|
# On an animated terminal the rows are drawn once and redrawn in place.
|
|
19
|
-
#
|
|
20
|
-
#
|
|
19
|
+
# When there are more rows than the screen has, only the jobs running
|
|
20
|
+
# are shown under the headline, as many as fit. Without animation it
|
|
21
|
+
# prints `Title...`, then each job's outcome as it ends, then the headline's.
|
|
22
|
+
#
|
|
23
|
+
# Given a {Stop} that is set, jobs already running finish, jobs not yet
|
|
24
|
+
# started are marked skipped, and so is the headline; while the running
|
|
25
|
+
# jobs finish, the headline says `stopping`.
|
|
21
26
|
#
|
|
22
27
|
# When a job raises, jobs already running finish, jobs not yet started
|
|
23
28
|
# are marked skipped, the headline is marked failed, and the first error
|
|
@@ -72,6 +77,7 @@ module Dry
|
|
|
72
77
|
@live = nil
|
|
73
78
|
@lock = Mutex.new
|
|
74
79
|
@frame = 0
|
|
80
|
+
@drawn = 0
|
|
75
81
|
end
|
|
76
82
|
|
|
77
83
|
# Declares the jobs with the block, then runs them.
|
|
@@ -79,19 +85,21 @@ module Dry
|
|
|
79
85
|
# @param title [String] the headline above the jobs
|
|
80
86
|
# @param concurrent [Boolean, Integer] all at once, one at a time, or
|
|
81
87
|
# at most this many at once
|
|
88
|
+
# @param stop [Stop, nil] once set, no more jobs start
|
|
82
89
|
# @yieldparam builder [Object] declares the jobs
|
|
83
90
|
# @return [Array<Object>] what each job returned, in declaration
|
|
84
91
|
# order; nil for a job that never ran
|
|
85
92
|
# @raise [ArgumentError] with an invalid concurrent
|
|
86
93
|
# @raise [Exception] the first error a job raised
|
|
87
|
-
def run(title, concurrent: true)
|
|
94
|
+
def run(title, concurrent: true, stop: nil)
|
|
88
95
|
Pool.concurrency(concurrent)
|
|
89
96
|
yield builder
|
|
90
97
|
@title = title
|
|
98
|
+
@stop = stop
|
|
91
99
|
@started = clock.call
|
|
92
100
|
ticker = start
|
|
93
101
|
begin
|
|
94
|
-
Pool.run(jobs, concurrent) { |job| execute(job) }
|
|
102
|
+
Pool.run(jobs, concurrent, stop: stop) { |job| execute(job) }
|
|
95
103
|
ensure
|
|
96
104
|
ticker&.shutdown
|
|
97
105
|
ticker&.wait_for_termination(1)
|
|
@@ -117,6 +125,9 @@ module Dry
|
|
|
117
125
|
# @return [String]
|
|
118
126
|
attr_reader :title
|
|
119
127
|
|
|
128
|
+
# @return [Stop, nil]
|
|
129
|
+
attr_reader :stop
|
|
130
|
+
|
|
120
131
|
# @return [Float] when the headline started, by the clock
|
|
121
132
|
attr_reader :started
|
|
122
133
|
|
|
@@ -182,7 +193,9 @@ module Dry
|
|
|
182
193
|
return
|
|
183
194
|
end
|
|
184
195
|
|
|
185
|
-
|
|
196
|
+
drawn = rows
|
|
197
|
+
drawn.each { |row| terminal.puts(row) }
|
|
198
|
+
@drawn = drawn.size
|
|
186
199
|
Concurrent::TimerTask.new(execution_interval: config.spinner_frame_seconds) { tick }.tap(&:execute)
|
|
187
200
|
end
|
|
188
201
|
|
|
@@ -206,12 +219,21 @@ module Dry
|
|
|
206
219
|
def finish
|
|
207
220
|
jobs.each { |job| change(job, :skipped) if job.state == :pending }
|
|
208
221
|
lock.synchronize do
|
|
209
|
-
self.state =
|
|
222
|
+
self.state = outcome
|
|
210
223
|
@seconds = clock.call - started
|
|
211
224
|
live? ? redraw : terminal.puts(Outcome.line(terminal, state, headline_summary, @seconds))
|
|
212
225
|
end
|
|
213
226
|
end
|
|
214
227
|
|
|
228
|
+
# @return [Symbol] the headline's state once every job has ended:
|
|
229
|
+
# failed when any job failed, skipped when any was skipped
|
|
230
|
+
def outcome
|
|
231
|
+
states = jobs.map(&:state)
|
|
232
|
+
return :failed if states.include?(:failed)
|
|
233
|
+
|
|
234
|
+
states.include?(:skipped) ? :skipped : :done
|
|
235
|
+
end
|
|
236
|
+
|
|
215
237
|
# @param job [Job]
|
|
216
238
|
# @param state [Symbol]
|
|
217
239
|
# @param seconds [Float, nil]
|
|
@@ -228,15 +250,23 @@ module Dry
|
|
|
228
250
|
end
|
|
229
251
|
end
|
|
230
252
|
|
|
231
|
-
# Whether to redraw in place
|
|
232
|
-
# the screen, since the cursor cannot move above the top row.
|
|
253
|
+
# Whether to redraw in place, which needs cursor movement.
|
|
233
254
|
#
|
|
234
255
|
# @return [Boolean]
|
|
235
256
|
def live?
|
|
236
|
-
@live = terminal.animated?
|
|
257
|
+
@live = terminal.animated? if @live.nil?
|
|
237
258
|
@live
|
|
238
259
|
end
|
|
239
260
|
|
|
261
|
+
# Whether some rows must be left out, since the cursor cannot move
|
|
262
|
+
# above the top row of the screen.
|
|
263
|
+
#
|
|
264
|
+
# @return [Boolean]
|
|
265
|
+
def crowded?
|
|
266
|
+
@crowded = jobs.size + 1 >= terminal.height if @crowded.nil?
|
|
267
|
+
@crowded
|
|
268
|
+
end
|
|
269
|
+
|
|
240
270
|
# @return [void]
|
|
241
271
|
def tick
|
|
242
272
|
lock.synchronize do
|
|
@@ -245,18 +275,34 @@ module Dry
|
|
|
245
275
|
end
|
|
246
276
|
end
|
|
247
277
|
|
|
278
|
+
# Draws the rows over the ones drawn last, clearing the screen below
|
|
279
|
+
# first, since there may be fewer rows than before.
|
|
280
|
+
#
|
|
248
281
|
# @return [void]
|
|
249
282
|
def redraw
|
|
250
|
-
|
|
283
|
+
drawn = rows
|
|
284
|
+
terminal.print(terminal.cursor.up(@drawn) + terminal.cursor.clear_screen_down + drawn.map { |row| "#{terminal.cursor.clear_line}#{row}\n" }.join)
|
|
285
|
+
@drawn = drawn.size
|
|
251
286
|
end
|
|
252
287
|
|
|
253
|
-
# The headline, then one row per job with its tree branch.
|
|
288
|
+
# The headline, then one row per job shown, with its tree branch.
|
|
254
289
|
#
|
|
255
290
|
# @return [Array<String>]
|
|
256
291
|
def rows
|
|
257
292
|
width = label_width
|
|
258
|
-
|
|
259
|
-
|
|
293
|
+
shown = visible
|
|
294
|
+
branches = shown.each_index.map { |index| index == shown.size - 1 ? "└─ " : "├─ " }
|
|
295
|
+
[headline(width), *shown.zip(branches).map { |job, branch| terminal.pastel.bright_black(branch) + row(job, width - branch.length) }]
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Every job; when crowded, only the running ones, as many as leave
|
|
299
|
+
# the bottom row free.
|
|
300
|
+
#
|
|
301
|
+
# @return [Array<Job>]
|
|
302
|
+
def visible
|
|
303
|
+
return jobs unless crowded?
|
|
304
|
+
|
|
305
|
+
jobs.select { |job| job.state == :running }.first([terminal.height - 2, 0].max)
|
|
260
306
|
end
|
|
261
307
|
|
|
262
308
|
# The columns every label is padded to, so what follows lines up.
|
|
@@ -269,12 +315,15 @@ module Dry
|
|
|
269
315
|
# @param width [Integer]
|
|
270
316
|
# @return [String]
|
|
271
317
|
def headline(width)
|
|
272
|
-
return "#{glyph(state)} #{running_headline(width)}" if state == :running
|
|
318
|
+
return "#{glyph(state)} #{running_headline(width)}#{stopping}" if state == :running
|
|
273
319
|
|
|
274
320
|
elapsed = " #{terminal.pastel.bright_black("(#{Duration.format(@seconds)})")}"
|
|
275
321
|
"#{glyph(state)} #{headline_summary}#{elapsed}"
|
|
276
322
|
end
|
|
277
323
|
|
|
324
|
+
# @return [String] ` stopping` once a stop is asked for, or nothing
|
|
325
|
+
def stopping = stop&.stopped? ? " #{terminal.pastel.yellow('stopping')}" : ""
|
|
326
|
+
|
|
278
327
|
# @param job [Job]
|
|
279
328
|
# @param width [Integer] the columns its label is padded to
|
|
280
329
|
# @return [String]
|
|
@@ -32,22 +32,49 @@ module Dry
|
|
|
32
32
|
# Declares a job with a progress bar of its own.
|
|
33
33
|
#
|
|
34
34
|
# @param label [String]
|
|
35
|
-
# @param total [Integer] units of work
|
|
35
|
+
# @param total [Integer, nil] units of work; nil when the job finds
|
|
36
|
+
# out as it runs, and sets `total=` on its handle
|
|
36
37
|
# @param color [Symbol, nil] the finished part's Pastel style; nil for
|
|
37
38
|
# {Configuration#bar_color}
|
|
38
39
|
# @yieldparam progress [Progress::Handle] call `advance` as units complete
|
|
39
40
|
# @return [self]
|
|
40
|
-
# @raise [ArgumentError] without a block, when total is
|
|
41
|
-
# non-negative Integer, or when color is not a Pastel style
|
|
41
|
+
# @raise [ArgumentError] without a block, when total is neither nil
|
|
42
|
+
# nor a non-negative Integer, or when color is not a Pastel style
|
|
42
43
|
def progress(label, total:, color: nil, &work)
|
|
43
44
|
raise ArgumentError, "progress #{label.inspect} needs a block" unless work
|
|
44
|
-
|
|
45
|
+
|
|
46
|
+
Progress.total(total) unless total.nil?
|
|
45
47
|
|
|
46
48
|
@jobs << Multi::Job.new(label, work, Progress::Handle.new(total, nil, color: Progress.color(color)))
|
|
47
49
|
self
|
|
48
50
|
end
|
|
49
51
|
end
|
|
50
52
|
|
|
53
|
+
# What the headline bar can count.
|
|
54
|
+
COUNTS = %i[units jobs].freeze
|
|
55
|
+
|
|
56
|
+
# Declares the jobs with the block, then runs them.
|
|
57
|
+
#
|
|
58
|
+
# @param title [String] the headline above the jobs
|
|
59
|
+
# @param concurrent [Boolean, Integer] see {Multi#run}
|
|
60
|
+
# @param count [Symbol] what the headline bar counts: `:units`, the
|
|
61
|
+
# sum of every bar, or `:jobs`, how many jobs have ended
|
|
62
|
+
# @param total [Integer, nil] the headline bar's total; nil for the
|
|
63
|
+
# sum of every bar's, or the number of jobs when counting jobs
|
|
64
|
+
# @param stop [Stop, nil] see {Multi#run}
|
|
65
|
+
# @yieldparam builder [Builder] declares the jobs
|
|
66
|
+
# @return [Array<Object>] what each job returned, in declaration order
|
|
67
|
+
# @raise [ArgumentError] with an invalid concurrent, count or total
|
|
68
|
+
# @raise [Exception] the first error a job raised
|
|
69
|
+
def run(title, concurrent: true, count: :units, total: nil, stop: nil, &)
|
|
70
|
+
raise ArgumentError, "count must be one of #{COUNTS.inspect}, got #{count.inspect}" unless COUNTS.include?(count)
|
|
71
|
+
|
|
72
|
+
Progress.total(total) unless total.nil?
|
|
73
|
+
@count = count
|
|
74
|
+
@total = total
|
|
75
|
+
super(title, concurrent: concurrent, stop: stop, &)
|
|
76
|
+
end
|
|
77
|
+
|
|
51
78
|
private
|
|
52
79
|
|
|
53
80
|
# @param job [Job]
|
|
@@ -63,7 +90,7 @@ module Dry
|
|
|
63
90
|
|
|
64
91
|
# @param job [Job]
|
|
65
92
|
# @return [String]
|
|
66
|
-
def summary(job) = "#{job.label} #{job.handle.current}/#{job.handle.total}"
|
|
93
|
+
def summary(job) = "#{job.label} #{job.handle.current}/#{job.handle.total || '?'}"
|
|
67
94
|
|
|
68
95
|
# @param width [Integer]
|
|
69
96
|
# @return [String]
|
|
@@ -74,25 +101,40 @@ module Dry
|
|
|
74
101
|
# @return [String]
|
|
75
102
|
def headline_summary = "#{title} #{current}/#{total}"
|
|
76
103
|
|
|
77
|
-
# @return [Integer] units completed across every job
|
|
78
|
-
def current
|
|
104
|
+
# @return [Integer] units completed across every job, or jobs ended
|
|
105
|
+
def current
|
|
106
|
+
return jobs.count(&:seconds) if @count == :jobs
|
|
79
107
|
|
|
80
|
-
|
|
81
|
-
|
|
108
|
+
jobs.sum { |job| job.handle.current }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @return [Integer] the total given; or units across every job,
|
|
112
|
+
# counting what a job without a total has done so far; or every job
|
|
113
|
+
def total
|
|
114
|
+
return @total if @total
|
|
115
|
+
return jobs.size if @count == :jobs
|
|
116
|
+
|
|
117
|
+
jobs.sum { |job| job.handle.total || job.handle.current }
|
|
118
|
+
end
|
|
82
119
|
|
|
83
120
|
# `[◼◼◼ ] 48% 96/200 ETA 3.1s`, with the count right-aligned to
|
|
84
121
|
# the widest any row can show, so every count ends in one column.
|
|
85
122
|
#
|
|
123
|
+
# A bar whose total is not known yet is drawn empty, counting `12/?`.
|
|
124
|
+
#
|
|
86
125
|
# @param done [Integer]
|
|
87
|
-
# @param all [Integer]
|
|
126
|
+
# @param all [Integer, nil]
|
|
88
127
|
# @param since [Float, nil] when the work started, by the clock
|
|
89
128
|
# @param color [Symbol, nil] the bar's own colour, if any
|
|
90
129
|
# @return [String]
|
|
91
130
|
def meter(done, all, since, color = nil)
|
|
92
|
-
ratio = all.
|
|
131
|
+
ratio = if all.nil? then 0.0
|
|
132
|
+
elsif all.zero? then 1.0
|
|
133
|
+
else done.fdiv(all)
|
|
134
|
+
end
|
|
93
135
|
bar = Progress.bar(terminal.pastel, config, ratio, bar_columns, color: color)
|
|
94
136
|
format("%<bar>s %<percent>3d%% %<count>s ETA %<eta>s",
|
|
95
|
-
bar: bar, percent: (ratio * 100).floor, count: "#{done}/#{all}".rjust(count_width), eta: eta(done, all, since))
|
|
137
|
+
bar: bar, percent: (ratio * 100).floor, count: "#{done}/#{all || '?'}".rjust(count_width), eta: eta(done, all, since))
|
|
96
138
|
end
|
|
97
139
|
|
|
98
140
|
# The widest count any row shows: the headline's, once every job is done.
|
|
@@ -108,11 +150,12 @@ module Dry
|
|
|
108
150
|
end
|
|
109
151
|
|
|
110
152
|
# @param done [Integer]
|
|
111
|
-
# @param all [Integer]
|
|
153
|
+
# @param all [Integer, nil]
|
|
112
154
|
# @param since [Float, nil]
|
|
113
|
-
# @return [String] the time left at the rate so far, or `--` before
|
|
155
|
+
# @return [String] the time left at the rate so far, or `--` before
|
|
156
|
+
# any progress or while the total is not known
|
|
114
157
|
def eta(done, all, since)
|
|
115
|
-
return "--" if since.nil? || done.zero?
|
|
158
|
+
return "--" if since.nil? || done.zero? || all.nil?
|
|
116
159
|
|
|
117
160
|
Duration.format((clock.call - since) / done * (all - done))
|
|
118
161
|
end
|
|
@@ -11,7 +11,8 @@ module Dry
|
|
|
11
11
|
#
|
|
12
12
|
# When one raises, items already running finish, items not yet
|
|
13
13
|
# started are never started, and the first error is re-raised once
|
|
14
|
-
# everything running has stopped.
|
|
14
|
+
# everything running has stopped. Once a {Stop} is set, items not yet
|
|
15
|
+
# started are never started either.
|
|
15
16
|
module Pool
|
|
16
17
|
# Checks a `concurrent:` setting.
|
|
17
18
|
#
|
|
@@ -29,13 +30,14 @@ module Dry
|
|
|
29
30
|
#
|
|
30
31
|
# @param items [Array]
|
|
31
32
|
# @param concurrent [Boolean, Integer] see {.concurrency}
|
|
33
|
+
# @param stop [Stop, nil] checked before each item starts
|
|
32
34
|
# @yieldparam item [Object] one of the items
|
|
33
35
|
# @return [void]
|
|
34
36
|
# @raise [Exception] the first error any block raised
|
|
35
|
-
def self.run(items, concurrent, &)
|
|
36
|
-
return items.each(
|
|
37
|
+
def self.run(items, concurrent, stop: nil, &each)
|
|
38
|
+
return items.each { |item| stop&.stopped? ? break : each.call(item) } unless concurrent
|
|
37
39
|
|
|
38
|
-
futures = concurrent == true ? all_at_once(items, &) : at_most(concurrent, items, &)
|
|
40
|
+
futures = concurrent == true ? all_at_once(items, &each) : at_most(concurrent, items, stop, &each)
|
|
39
41
|
futures.each(&:wait)
|
|
40
42
|
failed = futures.find(&:rejected?)
|
|
41
43
|
raise failed.reason if failed
|
|
@@ -52,26 +54,28 @@ module Dry
|
|
|
52
54
|
#
|
|
53
55
|
# @param limit [Integer]
|
|
54
56
|
# @param items [Array]
|
|
57
|
+
# @param stop [Stop, nil]
|
|
55
58
|
# @return [Array<Concurrent::Promises::Future>] one per worker
|
|
56
|
-
def self.at_most(limit, items, &)
|
|
59
|
+
def self.at_most(limit, items, stop, &)
|
|
57
60
|
queue = Queue.new
|
|
58
61
|
items.each { |item| queue << item }
|
|
59
62
|
queue.close
|
|
60
|
-
|
|
61
|
-
Array.new([limit, items.size].min) { Concurrent::Promises.future { work(queue, stop, &) } }
|
|
63
|
+
failed = Concurrent::AtomicBoolean.new
|
|
64
|
+
Array.new([limit, items.size].min) { Concurrent::Promises.future { work(queue, failed, stop, &) } }
|
|
62
65
|
end
|
|
63
66
|
|
|
64
67
|
# @param queue [Queue] closed, so `pop` returns nil once it is empty
|
|
65
|
-
# @param
|
|
68
|
+
# @param failed [Concurrent::AtomicBoolean] set once any worker raises
|
|
69
|
+
# @param stop [Stop, nil]
|
|
66
70
|
# @return [void]
|
|
67
|
-
def self.work(queue, stop, &each)
|
|
71
|
+
def self.work(queue, failed, stop, &each)
|
|
68
72
|
ok = false
|
|
69
|
-
while (item = queue.pop) &&
|
|
73
|
+
while !stop&.stopped? && (item = queue.pop) && failed.false?
|
|
70
74
|
each.call(item)
|
|
71
75
|
end
|
|
72
76
|
ok = true
|
|
73
77
|
ensure
|
|
74
|
-
|
|
78
|
+
failed.make_true unless ok
|
|
75
79
|
end
|
|
76
80
|
|
|
77
81
|
private_class_method :all_at_once, :at_most, :work
|
|
@@ -14,7 +14,7 @@ module Dry
|
|
|
14
14
|
class Progress
|
|
15
15
|
# What the block is given to report progress through.
|
|
16
16
|
class Handle
|
|
17
|
-
# @param total [Integer]
|
|
17
|
+
# @param total [Integer, nil] nil until the work finds out
|
|
18
18
|
# @param bar [TTY::ProgressBar, nil]
|
|
19
19
|
# @param color [Symbol, nil] see {Progress.color}
|
|
20
20
|
def initialize(total, bar, color: nil)
|
|
@@ -24,7 +24,8 @@ module Dry
|
|
|
24
24
|
@current = 0
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
# @return [Integer] the number of units the operation has
|
|
27
|
+
# @return [Integer, nil] the number of units the operation has; nil
|
|
28
|
+
# while it is not known
|
|
28
29
|
attr_reader :total
|
|
29
30
|
|
|
30
31
|
# @return [Symbol, nil] the Pastel style of the bar's finished part;
|
|
@@ -34,7 +35,18 @@ module Dry
|
|
|
34
35
|
# @return [Integer] the number of units completed so far
|
|
35
36
|
attr_reader :current
|
|
36
37
|
|
|
37
|
-
#
|
|
38
|
+
# Sets the number of units once the work finds out, such as a download
|
|
39
|
+
# learning its size. {#current} is lowered to fit.
|
|
40
|
+
#
|
|
41
|
+
# @param value [Integer]
|
|
42
|
+
# @raise [ArgumentError] when value is not a non-negative Integer
|
|
43
|
+
def total=(value)
|
|
44
|
+
@total = Progress.total(value)
|
|
45
|
+
self.current = current.clamp(0, value)
|
|
46
|
+
bar&.update(total: value)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Marks units as complete. Progress never passes {#total} once it is known.
|
|
38
50
|
#
|
|
39
51
|
# @param step [Integer]
|
|
40
52
|
# @return [self]
|
|
@@ -58,6 +70,17 @@ module Dry
|
|
|
58
70
|
# Narrowest bar drawn.
|
|
59
71
|
MIN_BAR = 10
|
|
60
72
|
|
|
73
|
+
# Checks a bar's total.
|
|
74
|
+
#
|
|
75
|
+
# @param value [Integer]
|
|
76
|
+
# @return [Integer] the value
|
|
77
|
+
# @raise [ArgumentError] for anything but a non-negative Integer
|
|
78
|
+
def self.total(value)
|
|
79
|
+
return value if value.is_a?(Integer) && value >= 0
|
|
80
|
+
|
|
81
|
+
raise ArgumentError, "total must be a non-negative Integer, got #{value.inspect}"
|
|
82
|
+
end
|
|
83
|
+
|
|
61
84
|
# Checks a bar's own colour.
|
|
62
85
|
#
|
|
63
86
|
# @param value [Symbol, nil] any Pastel style, or nil for {Configuration#bar_color}
|
|
@@ -119,8 +142,7 @@ module Dry
|
|
|
119
142
|
# @raise [ArgumentError] when total is not a non-negative Integer, or
|
|
120
143
|
# color is not a Pastel style
|
|
121
144
|
def run(label, total:, color: nil)
|
|
122
|
-
|
|
123
|
-
|
|
145
|
+
Progress.total(total)
|
|
124
146
|
Progress.color(color)
|
|
125
147
|
started = clock.call
|
|
126
148
|
bar = start(label, total, color)
|
|
@@ -134,7 +156,7 @@ module Dry
|
|
|
134
156
|
if handle
|
|
135
157
|
bar&.stop
|
|
136
158
|
terminal.finished(handle, ok)
|
|
137
|
-
summary = "#{label} #{handle.current}/#{total}"
|
|
159
|
+
summary = "#{label} #{handle.current}/#{handle.total}"
|
|
138
160
|
terminal.puts(Outcome.line(terminal, ok ? :done : :failed, summary, clock.call - started))
|
|
139
161
|
end
|
|
140
162
|
end
|
data/lib/dry/cli/ui.rb
CHANGED
|
@@ -35,6 +35,7 @@ module Dry
|
|
|
35
35
|
autoload :Console, File.expand_path("ui/console", __dir__)
|
|
36
36
|
autoload :Duration, File.expand_path("ui/duration", __dir__)
|
|
37
37
|
autoload :Line, File.expand_path("ui/line", __dir__)
|
|
38
|
+
autoload :Stop, File.expand_path("ui/stop", __dir__)
|
|
38
39
|
autoload :StatusBar, File.expand_path("ui/status_bar", __dir__)
|
|
39
40
|
autoload :Terminal, File.expand_path("ui/terminal", __dir__)
|
|
40
41
|
autoload :Theme, File.expand_path("ui/theme", __dir__)
|
data/sig/dry/cli/ui.rbs
CHANGED
|
@@ -44,6 +44,12 @@ module Dry
|
|
|
44
44
|
def summary: (String label) -> String
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
class Stop
|
|
48
|
+
def stopped?: () -> bool
|
|
49
|
+
def stop!: () -> self
|
|
50
|
+
def trap: [T] (?String signal) { (Stop stop) -> T } -> T
|
|
51
|
+
end
|
|
52
|
+
|
|
47
53
|
class Console
|
|
48
54
|
def debug: (*_ToS paragraphs, ?width: Integer?) -> nil
|
|
49
55
|
def info: (*_ToS paragraphs, ?width: Integer?) -> nil
|
|
@@ -55,9 +61,10 @@ module Dry
|
|
|
55
61
|
def status: (*_ToS words, ?level: Symbol) -> nil
|
|
56
62
|
def popup: (*_ToS paragraphs, ?title: String?, ?width: Integer?) -> nil
|
|
57
63
|
def spinner: [T] (String label) { (Line line) -> T } -> T
|
|
58
|
-
def multi_spinner: (String title, ?concurrent: (bool | Integer)) { (untyped spinners) -> void } -> Array[untyped]
|
|
64
|
+
def multi_spinner: (String title, ?concurrent: (bool | Integer), ?stop: Stop?) { (untyped spinners) -> void } -> Array[untyped]
|
|
59
65
|
def progress: [T] (String label, total: Integer, ?color: Symbol?) { (untyped progress) -> T } -> T
|
|
60
|
-
def multi_progress: (String title, ?concurrent: (bool | Integer)) { (untyped bars) -> void } -> Array[untyped]
|
|
66
|
+
def multi_progress: (String title, ?concurrent: (bool | Integer), ?count: (:units | :jobs), ?total: Integer?, ?stop: Stop?) { (untyped bars) -> void } -> Array[untyped]
|
|
67
|
+
def stoppable: [T] () { (Stop stop) -> T } -> T
|
|
61
68
|
def status_bar: [T] (?String? title, ?hints: Array[String] | String) { () -> T } -> T
|
|
62
69
|
def tasks: (?String? title, ?concurrent: (bool | Integer)) { (untyped tasks) -> void } -> nil
|
|
63
70
|
def table: (Array[Array[_ToS]] rows, ?header: Array[_ToS]?) -> nil
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dry-cli-ui
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Konstantin Gredeskoul
|
|
@@ -189,6 +189,7 @@ files:
|
|
|
189
189
|
- lib/dry/cli/ui/duration.rb
|
|
190
190
|
- lib/dry/cli/ui/line.rb
|
|
191
191
|
- lib/dry/cli/ui/status_bar.rb
|
|
192
|
+
- lib/dry/cli/ui/stop.rb
|
|
192
193
|
- lib/dry/cli/ui/terminal.rb
|
|
193
194
|
- lib/dry/cli/ui/theme.rb
|
|
194
195
|
- lib/dry/cli/ui/version.rb
|