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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be9415991dfb76bcec0253194fd2964b2f3781ca06cfc36be0653a9dd70bbba4
4
- data.tar.gz: c3cd053b4e9a8e49cb6b33ebf2b41df1f3ead240e0f4b74184695914c85850ec
3
+ metadata.gz: 6d1ad3344ddfd395caef9b2ec0128f05cda8248a98afa864b1b61ed1d1db0b5e
4
+ data.tar.gz: d55f3256373ec25c1201dd0c39525eeb49b50f16989806740f720ed3e59f0f13
5
5
  SHA512:
6
- metadata.gz: c55ab258c17e0fc192e16995374190cafd81d0f493057af61e3b186bac96e12b7f5736f28f4ff8f19d551298de830637c7001d6f49f240bb1a9db5ace54e401d
7
- data.tar.gz: 01ab36292c51f456c16c313a7fe45607b50ddc63906f0aa356eb98b4dd31b522ef1df064c147a6a57fdf6df3e95278abd9366e09762e5cfc5d6fab773e209dcc
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
- The rows are also printed one by one on a terminal that has fewer rows than the widget needs, since the cursor cannot move above the top of the screen.
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 `total:`, or raises `ArgumentError`.
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 one byte at a time as the body arrives. A bar's `total` is fixed when it is declared, so a `HEAD` request asks each URL for its size first:
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
- found = urls.filter_map do |url|
391
- [url, content_length(url)]
392
- rescue StandardError => e
393
- ui.status "#{url}: #{e.message}", level: :warn
394
- nil
395
- end
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. The headline counts every URL, and in `multi_progress` every byte. A server that sends no `Content-Length` gets a bar of one unit, which sits at 0% and fills when its download ends. These helpers are kept short: a real one follows redirects, and sends `Accept-Encoding: identity` so the bytes counted match `Content-Length`. With more URLs than the screen has rows, each finished URL prints one line instead.
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 bytes [Integer] the size of each chunk as it arrives
56
- # @return [Array(String, String, nil)] the body, and its media type when the server says
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 download(uri, hops = REDIRECTS, &)
59
- body = +""
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
- type = response.content_type
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 download(URI(location), hops - 1, &) if location
87
+ return get(URI(location), hops - 1, &) if location
75
88
 
76
- [body, type]
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, under a spinner or a progress bar.
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 :'save-to', default: ".", desc: "Directory to save the files in, created when missing"
126
- option :progress, type: :flag, default: false, desc: "Show a progress bar per URL"
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
- return ui.error("Pick one of --progress and --spinner, not both") if progress && spinner
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(:'save-to', ".")
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 = progress ? with_progress(urls) : with_spinner(urls)
157
- files = urls.zip(downloads).filter_map do |url, (body, type)|
158
- next unless body
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
- def with_spinner(urls)
174
- ui.multi_spinner("Downloading #{urls.size} URLs", concurrent: CONCURRENCY) do |m|
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
- line.detail = "resolving"
178
- uri, = HTTP.resolve(url)
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's total is fixed when it is declared, so ask each URL for its
190
- # size first, with a HEAD request. A URL that fails here is left out.
191
- def with_progress(urls)
192
- found = urls.to_h do |url|
193
- [url, HTTP.resolve(url)]
194
- rescue StandardError => e
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
- bodies = ui.multi_progress("Downloading #{ready.size} URLs", concurrent: CONCURRENCY) do |m|
205
- ready.each do |url, (uri, size)|
206
- m.progress(url, total: size || 1) do |bar|
207
- download = HTTP.download(uri) { |bytes| bar.advance(bytes) if size }
208
- bar.advance unless size # no Content-Length: done in one step
209
- download
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
- errors.each { |error| ui.status error, level: :warn }
217
- downloads = ready.keys.zip(bodies).to_h
218
- urls.map { |url| downloads[url] }
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: false, aliases: ["-p"], desc: "Show progress bars; the default with --port"
255
- option :spinner, type: :flag, default: false, aliases: ["-s"], desc: "Show spinners; the default without --port"
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
- def call(timeout:, port: nil, output: nil, progress: false, spinner: false, **)
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
- results = progress ? with_progress(ips, ports, timeout.to_f) : with_spinner(ips, ports, timeout.to_f)
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: CONCURRENCY) do |m|
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 CONCURRENCY at once.
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(CONCURRENCY) do
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 }
@@ -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(&:total)
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
@@ -11,7 +11,7 @@ module Dry
11
11
  # Presentation helpers for Dry::CLI commands.
12
12
  module UI
13
13
  # The gem version.
14
- VERSION = "0.5.0"
14
+ VERSION = "0.5.1"
15
15
  end
16
16
  end
17
17
  end
@@ -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
- # Otherwise, or when there are more rows than the screen has, it prints
20
- # `Title...`, then each job's outcome as it ends, then the headline's.
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
- rows.each { |row| terminal.puts(row) }
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 = jobs.all? { |job| job.state == :done } ? :done : :failed
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: needs cursor movement, and every row on
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? && jobs.size + 1 < terminal.height if @live.nil?
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
- terminal.print(terminal.cursor.up(jobs.size + 1) + rows.map { |row| "#{terminal.cursor.clear_line}#{row}\n" }.join)
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
- branches = jobs.each_with_index.map { |_, index| index == jobs.size - 1 ? "└─ " : "├─ " }
259
- [headline(width), *jobs.zip(branches).map { |job, branch| terminal.pastel.bright_black(branch) + row(job, width - branch.length) }]
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 not a
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
- raise ArgumentError, "total must be a non-negative Integer, got #{total.inspect}" unless total.is_a?(Integer) && total >= 0
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 = jobs.sum { |job| job.handle.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
- # @return [Integer] units across every job
81
- def total = jobs.sum { |job| job.handle.total }
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.zero? ? 1.0 : done.fdiv(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 any progress
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(&) unless concurrent
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
- stop = Concurrent::AtomicBoolean.new
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 stop [Concurrent::AtomicBoolean] set once any worker raises
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) && stop.false?
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
- stop.make_true unless ok
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
- # Marks units as complete. Progress never passes {#total}.
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
- raise ArgumentError, "total must be a non-negative Integer, got #{total.inspect}" unless total.is_a?(Integer) && total >= 0
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.0
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