dry-cli-ui 0.4.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.
data/examples/.envrc ADDED
@@ -0,0 +1 @@
1
+ PATH_add bin
@@ -0,0 +1 @@
1
+ *.txt
data/examples/Gemfile CHANGED
@@ -10,5 +10,5 @@ group :development, :test do
10
10
  # multi_spinner, multi_progress and status_bar live on the
11
11
  # kig/add-multi-spinner-progress branch until it is merged; switch this
12
12
  # back to path: "../" then.
13
- gem 'dry-cli-ui', path: File.expand_path("~/.agents.worktrees/dry-cli-ui-add-multi-spinner-progress")
13
+ gem 'dry-cli-ui', path: '../'
14
14
  end
@@ -1,7 +1,7 @@
1
1
  PATH
2
- remote: /Users/kig/.agents.worktrees/dry-cli-ui-add-multi-spinner-progress
2
+ remote: ..
3
3
  specs:
4
- dry-cli-ui (0.3.0)
4
+ dry-cli-ui (0.5.0)
5
5
  concurrent-ruby (~> 1.3)
6
6
  dry-cli (>= 1.0)
7
7
  pastel (~> 0.8)
@@ -22,7 +22,7 @@ GEM
22
22
  dry-cli-autocomplete (0.1.3)
23
23
  dry-cli (>= 1.0)
24
24
  dry-inflector (>= 1.0)
25
- dry-cli-help (0.2.0)
25
+ dry-cli-help (0.2.1)
26
26
  dry-cli (>= 1.1.1, < 2)
27
27
  pastel (~> 0.8)
28
28
  dry-inflector (1.3.1)
@@ -83,8 +83,8 @@ CHECKSUMS
83
83
  concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1
84
84
  dry-cli (1.4.1) sha256=b8015bb76c708aa8705a36faf694973e75eeeffca39b89c8e172dc6f66a7d874
85
85
  dry-cli-autocomplete (0.1.3) sha256=14f76447bda0000c71739b585c827dc0a0a763e30a68a8b4b2dff91f640768e4
86
- dry-cli-help (0.2.0)
87
- dry-cli-ui (0.3.0)
86
+ dry-cli-help (0.2.1) sha256=eaaaf6edb787ffe810425b4e4bb1a675d89397ccc759102d70b5b26e7bc692da
87
+ dry-cli-ui (0.5.0)
88
88
  dry-inflector (1.3.1) sha256=7fb0c2bb04f67638f25c52e7ba39ab435d922a3a5c3cd196120f63accb682dcc
89
89
  forwardable (1.4.0) sha256=f1cd40cc9812937980e1c76f1aa053660990a7c9b6a98fc37d945468afcce838
90
90
  pastel (0.8.0) sha256=481da9fb7d2f6e6b1a08faf11fa10363172dc40fd47848f096ae21209f805a75
data/examples/README.md CHANGED
@@ -1,15 +1,13 @@
1
1
  # Examples
2
2
 
3
- These examples offers two commands that are are indicative of the added behavior.
3
+ A small `dry-cli` app, `bin/mycli`, with two commands that show off the UI widgets.
4
4
 
5
- But first,
5
+ First:
6
6
 
7
7
  ```bash
8
8
  bundle install
9
9
  ```
10
10
 
11
- Then:
12
-
13
11
  ## Help
14
12
 
15
13
  ```bash
@@ -19,9 +17,8 @@ USAGE
19
17
 
20
18
  COMMANDS
21
19
  version, v Print version
22
- urls_progress Fetch URLs with a progress bar each, and write them all to urls.txt
23
- urls_spinner Fetch URLs with a spinner each, and write each to its own file
24
- primes Compute closest prime to a given number in the array
20
+ download-urls Download URLs, each to its own file
21
+ find-hosts Find hosts on the local network that answer on TCP ports
25
22
 
26
23
  OPTIONS
27
24
  -h, --help Show help
@@ -30,6 +27,23 @@ OPTIONS
30
27
 
31
28
  ## Commands to Try
32
29
 
33
- - primes
34
- - urls_progress url1 url2 ...
35
- - urls_spinner url1 url2 ...
30
+ - `download-urls URL1 URL2 ...` downloads each URL to its own file in the current folder, under a spinner per URL.
31
+ - `download-urls -u urls.txt` reads the URLs from `urls.txt`, one per line, skipping blank lines and `#` comments. URLs given as arguments are downloaded too. Invalid URLs, from either place, are skipped and listed in a warning at the end.
32
+ - `download-urls --progress --save-to=downloads URL1 URL2 ...` shows a progress bar per URL instead, and saves the files in `downloads/`, creating it when missing.
33
+ - `find-hosts` probes every address on the local /24 subnet on the common TCP ports (22, 53, 80, 123, 139, 443, 445, 631, 3389, 5000, 7000), 10 addresses at a time, with a spinner per address. It ends with a box listing each host that answered and its open ports.
34
+ - `find-hosts --progress --output=hosts.txt` shows two bars instead, one counting the addresses that answered and one those that did not, and also writes the result to `hosts.txt`.
35
+ - `find-hosts --port=22` probes only port 22, with the two bars.
36
+
37
+ `download-urls` downloads at most 10 URLs at a time. `--progress` and `--spinner` are mutually exclusive. `download-urls` defaults to a spinner. `find-hosts` defaults to spinners, or to progress bars with `--port`.
38
+
39
+ ## File names
40
+
41
+ `download-urls` names each file after the URL's host, path and query, with anything unsafe replaced by `_`:
42
+
43
+ | URL | File |
44
+ | ------------------------------------------------------- | ----------------------------------------------- |
45
+ | `https://www.ruby-lang.org/images/header-ruby-logo.png` | `www.ruby-lang.org_images_header-ruby-logo.png` |
46
+ | `https://example.com` | `example.com.html` |
47
+ | `https://httpbin.org/json` | `httpbin.org_json.json` |
48
+
49
+ A file keeps the extension its URL path has. Without one, the extension comes from the response's `Content-Type`, and is `.txt` for a type the command does not know.
data/examples/bin/mycli CHANGED
@@ -12,7 +12,8 @@ require "dry/cli/help"
12
12
  require "dry/cli/autocomplete/command"
13
13
  require "dry/cli/ui"
14
14
  require "net/http"
15
- require "prime"
15
+ require "concurrent"
16
+ require "socket"
16
17
 
17
18
  module Foo
18
19
  module CLI
@@ -27,43 +28,74 @@ module Foo
27
28
  end
28
29
  end
29
30
 
30
- # Shared by the two URL commands: follows redirects, and asks for the
31
- # body as it is stored, so the bytes counted match Content-Length.
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
+
48
+ # Used by download-urls: follows up to REDIRECTS redirects on every
49
+ # request, and asks for the body as it is stored, so the bytes counted
50
+ # match Content-Length.
32
51
  module HTTP
33
52
  HEADERS = { "Accept-Encoding" => "identity" }.freeze
34
53
  REDIRECTS = 5
35
54
 
36
55
  module_function
37
56
 
57
+ # Statuses a server answers HEAD with when it only serves GET.
58
+ NO_HEAD = %w[403 405 501].freeze
59
+
38
60
  # @return [Array(URI, Integer, nil)] where the URL ends up, and its size when the server says
39
61
  # @raise [RuntimeError] for a response that is neither a redirect nor a success
40
62
  def resolve(url, hops = REDIRECTS)
41
63
  uri = URI(url)
42
64
  response = start(uri) { |http| http.head(uri.request_uri, HEADERS) }
43
- if response.is_a?(Net::HTTPRedirection) && hops.positive?
44
- return resolve(URI.join(uri, response["location"]).to_s, hops - 1)
45
- end
65
+ return resolve(redirect(uri, response, hops), hops - 1) if response.is_a?(Net::HTTPRedirection)
66
+ return [uri, nil] if NO_HEAD.include?(response.code) # the GET finds out
46
67
  raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
47
68
 
48
69
  [uri, response.content_length]
49
70
  end
50
71
 
51
- # @yieldparam bytes [Integer] the size of each chunk as it arrives
52
- # @return [String] the body
72
+ # @yieldparam response [Net::HTTPSuccess] the response, whose body is
73
+ # read in the block
74
+ # @return [Object] what the block returned
53
75
  # @raise [RuntimeError] for a response that is not a success
54
- def download(uri)
55
- body = +""
76
+ def get(uri, hops = REDIRECTS, &)
77
+ result = nil
78
+ location = nil
56
79
  start(uri) do |http|
57
80
  http.request_get(uri.request_uri, HEADERS) do |response|
81
+ next location = redirect(uri, response, hops) if response.is_a?(Net::HTTPRedirection)
58
82
  raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
59
83
 
60
- response.read_body do |chunk|
61
- body << chunk
62
- yield chunk.bytesize if block_given?
63
- end
84
+ result = yield response
64
85
  end
65
86
  end
66
- body
87
+ return get(URI(location), hops - 1, &) if location
88
+
89
+ result
90
+ end
91
+
92
+ # @return [String] the absolute URL a redirect points to
93
+ # @raise [RuntimeError] once there are no hops left, or without a Location
94
+ def redirect(uri, response, hops)
95
+ raise "Too many redirects" unless hops.positive?
96
+ raise "HTTP #{response.code} without a Location" unless response["location"]
97
+
98
+ URI.join(uri, response["location"]).to_s
67
99
  end
68
100
 
69
101
  def start(uri, &)
@@ -71,136 +103,335 @@ module Foo
71
103
  end
72
104
  end
73
105
 
74
- # One progress bar per URL, filling as bytes arrive, under a headline
75
- # bar that counts every byte.
76
- class FetchUrlsProgress < Dry::CLI::Command
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.
109
+ class DownloadUrls < Dry::CLI::Command
77
110
  include Dry::CLI::UI
78
111
 
79
- desc "Fetch URLs with a progress bar each, and write them all to urls.txt"
80
- argument :urls, type: :array, required: true, desc: "URLs to fetch, separated by spaces"
81
-
82
- def call(urls:, **)
83
- # A bar's total is fixed when it is declared, so ask each URL for its
84
- # size first, with a HEAD request. A URL that fails here is left out.
85
- found = urls.filter_map do |url|
86
- [url, *HTTP.resolve(url)]
87
- rescue StandardError => e
88
- ui.status "#{url}: #{e.message}", level: :warn
89
- nil
112
+ # File extensions for the media types a URL's own name may not reveal.
113
+ EXTENSIONS = {
114
+ "application/gzip" => ".gz",
115
+ "application/javascript" => ".js",
116
+ "application/json" => ".json",
117
+ "application/pdf" => ".pdf",
118
+ "application/xml" => ".xml",
119
+ "application/zip" => ".zip",
120
+ "image/gif" => ".gif",
121
+ "image/jpeg" => ".jpg",
122
+ "image/png" => ".png",
123
+ "image/svg+xml" => ".svg",
124
+ "image/webp" => ".webp",
125
+ "text/css" => ".css",
126
+ "text/csv" => ".csv",
127
+ "text/html" => ".html",
128
+ "text/javascript" => ".js",
129
+ "text/markdown" => ".md",
130
+ "text/plain" => ".txt",
131
+ "text/xml" => ".xml"
132
+ }.freeze
133
+
134
+ desc "Download URLs, each to its own file"
135
+ argument :urls, type: :array, required: false, desc: "URLs to download, separated by spaces"
136
+ option :'urls-file', aliases: ["-u"], desc: "A file with one URL per line; blank lines and # comments are skipped"
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
141
+
142
+ def call(urls: [], progress: false, spinner: false, **options)
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]}")
146
+
147
+ urls_file = options[:'urls-file']
148
+ save_to = options.fetch(:output, ".")
149
+
150
+ begin
151
+ urls += read_urls(urls_file) if urls_file
152
+ rescue SystemCallError => e
153
+ return ui.error("Cannot read #{urls_file}", e.message)
90
154
  end
91
- return ui.error("None of the #{urls.size} URLs could be fetched") if found.empty?
92
-
93
- bodies = ui.multi_progress("Fetching #{found.size} URLs", concurrent: 8) do |m|
94
- found.each do |url, uri, size|
95
- m.progress(url, total: size || 1) do |bar|
96
- sleep rand(1..4)
97
- # Move the bar one byte at a time, as each chunk arrives.
98
- body = HTTP.download(uri) { |bytes| bytes.times { bar.advance } if size }
99
- bar.advance unless size # no Content-Length: done in one step
100
- body
101
- end
102
- end
155
+ urls, invalid = urls.partition { |url| valid?(url) }
156
+ download(urls, save_to, progress)
157
+ ui.warn "Skipped #{invalid.size} invalid URLs", invalid.join("\n") if invalid.any?
158
+ end
159
+
160
+ private
161
+
162
+ def download(urls, save_to, progress)
163
+ return ui.error("No URLs to download", "Pass them as arguments, or in a file with --urls-file") if urls.empty?
164
+
165
+ begin
166
+ FileUtils.mkdir_p(save_to)
167
+ rescue SystemCallError => e
168
+ return ui.error("Cannot create #{save_to}", e.message)
169
+ end
170
+
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
103
175
  end
176
+ return ui.info("Stopped", "Downloaded #{files.size} of #{urls.size} URLs into #{save_to}") if @stop.stopped?
104
177
 
105
- File.write("urls.txt", bodies.join("\n"))
106
- ui.success "Wrote #{bodies.sum(&:bytesize)} bytes from #{found.size} of #{urls.size} URLs to urls.txt"
107
- rescue StandardError => e
108
- ui.error("Fetching failed", e.message)
178
+ ui.success "Wrote #{files.size} of #{urls.size} URLs", files.join("\n")
109
179
  end
110
- end
111
180
 
112
- # One spinner per URL, each saying what it is doing, and one file per URL.
113
- class FetchUrls < Dry::CLI::Command
114
- include Dry::CLI::UI
181
+ # An http or https URL with a host.
115
182
 
116
- desc "Fetch URLs with a spinner each, and write each to its own file"
117
- argument :urls, type: :array, required: true, desc: "URLs to fetch, separated by spaces"
183
+ def valid?(url)
184
+ uri = URI.parse(url)
185
+ uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
186
+ rescue URI::InvalidURIError
187
+ false
188
+ end
118
189
 
119
- def call(urls:, **)
120
- bodies = ui.multi_spinner("Fetching #{urls.size} URLs", concurrent: 8) 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|
121
195
  urls.each do |url|
122
196
  m.spinner(url) do |line|
123
- line.detail = "resolving"
124
- uri, = HTTP.resolve(url)
125
- line.detail = "downloading #{uri.host}"
126
- sleep rand(1..4) # slow enough to watch
127
- HTTP.download(uri)
197
+ received = 0
198
+ save(url, URI(url), save_to) { |bytes| line.detail = "#{received += bytes} bytes" }
128
199
  rescue StandardError => e
129
200
  line.fail(e.message)
130
201
  nil
131
202
  end
132
203
  end
133
204
  end
205
+ end
134
206
 
135
- files = urls.zip(bodies).filter_map do |url, body|
136
- next unless body
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)
213
+ # A bar cannot be marked failed, so a download that fails is
214
+ # reported once every bar has finished.
215
+ errors = Concurrent::Array.new
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 }
222
+ rescue StandardError => e
223
+ errors << "#{url}: #{e.message}"
224
+ nil
225
+ end
226
+ end
227
+ end
228
+ ui.warn "#{errors.size} URLs failed", errors.join("\n") if errors.any?
229
+ files
230
+ end
137
231
 
138
- "#{friendly_filename(url)}.txt".tap { |file| File.write(file, body) }
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
139
249
  end
140
- ui.success "Wrote #{files.size} of #{urls.size} URLs", files.join("\n")
250
+ file
251
+ rescue StandardError
252
+ FileUtils.rm_f(file) if file
253
+ raise
141
254
  end
142
255
 
143
- private
256
+ def read_urls(file)
257
+ File.readlines(file, chomp: true).map(&:strip).reject { |line| line.empty? || line.start_with?("#") }
258
+ end
144
259
 
145
- def friendly_filename(url)
146
- url.sub(%r{\Ahttps?://}, "").gsub(/[^\w.-]+/, "_").delete_suffix("_")
260
+ # The URL's host, path and query as one safe name, keeping the path's
261
+ # extension. Without one, the extension comes from the media type,
262
+ # and is .txt when that is unknown too.
263
+ def filename(url, type)
264
+ uri = URI(url)
265
+ extension = File.extname(uri.path)
266
+ stem = "#{uri.host}#{uri.path.delete_suffix(extension)}#{"_#{uri.query}" if uri.query}"
267
+ extension = EXTENSIONS.fetch(type, ".txt") if extension.empty?
268
+ "#{stem.gsub(/[^\w.-]+/, '_').delete_suffix('_')}#{extension}"
147
269
  end
148
270
  end
149
271
 
150
- class ComputePrimes < Dry::CLI::Command
272
+ # Scans the local /24 subnet for hosts that answer on TCP ports, with a
273
+ # spinner or a progress bar for every address.
274
+ class FindHosts < Dry::CLI::Command
151
275
  include Dry::CLI::UI
152
276
 
153
- def self.closest_prime(n)
154
- # Primes must be greater than 1
155
- return 2 if n <= 2
156
- return n if ::Prime.prime?(n)
277
+ # Well-known TCP ports: SSH, DNS, HTTP, NTP, NetBIOS, HTTPS, SMB, IPP,
278
+ # RDP, and the two AirPlay and Synology use.
279
+ COMMON_PORTS = [22, 53, 80, 123, 139, 443, 445, 631, 3389, 5000, 7000].freeze
280
+
281
+ desc "Find hosts on the local network that answer on TCP ports"
282
+ option :port, type: :integer, desc: "Probe only this TCP port, instead of #{COMMON_PORTS.join(', ')}"
283
+ option :timeout, type: :float, default: 0.5, aliases: ["-t"], desc: "Seconds to wait for each port"
284
+ option :output, aliases: ["-o"], desc: "Also write the hosts to this file"
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}")
292
+
293
+ open_files_for(@concurrency * COMMON_PORTS.size)
294
+
295
+ ip = local_ip or return ui.error("No local IPv4 address found")
296
+ subnet = ip.split(".").first(3).join(".")
297
+ ips = (1..254).map { |i| "#{subnet}.#{i}" }
298
+ ports = port ? [port.to_i] : COMMON_PORTS
299
+ ui.info "Local IP: #{ip}, scanning #{subnet}.1-254 on #{ports.size == 1 ? 'port' : 'ports'} #{ports.join(', ')}"
300
+
301
+ progress ||= port && !spinner
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
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
314
+ return ui.warn("No hosts answered on #{ports.join(', ')}") if hosts.empty?
157
315
 
158
- distance = 1
159
- loop do
160
- lower = n - distance
161
- upper = n + distance
316
+ ui.success "Found #{hosts.size} hosts", lines.join("\n"), *("Written to #{output}" if output)
317
+ end
162
318
 
163
- primes = []
164
- primes << lower if lower > 1 && ::Prime.prime?(lower)
165
- primes << upper if ::Prime.prime?(upper)
319
+ private
166
320
 
167
- # Return the prime(s) found at the shortest distance
168
- return primes.size == 1 ? primes.first : primes if primes.any?
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
169
331
 
170
- distance += 1
171
- end
332
+ def local_ip
333
+ Socket.getifaddrs
334
+ .map(&:addr)
335
+ .find { |addr| addr&.ipv4? && !addr.ipv4_loopback? }
336
+ &.ip_address
172
337
  end
173
338
 
174
- desc 'Compute closest prime to a given number in the array'
175
- option :max, default: 100_000, desc: "Maximum integer to compute closest primes for", type: :integer
339
+ # A spinner per address, counting the ports answered so far. An
340
+ # address where nothing answered is marked failed.
341
+ def with_spinner(ips, ports, timeout)
342
+ ui.multi_spinner("Probing #{ips.size} addresses", concurrent: @concurrency, stop: @stop) do |m|
343
+ ips.each do |ip|
344
+ m.spinner(ip) do |line|
345
+ answered = Concurrent::AtomicFixnum.new
346
+ open = scan(ip, ports, timeout) { line.detail = "#{answered.increment} of #{ports.size} ports" }
347
+ line.fail("no answer") unless open
348
+ open
349
+ end
350
+ end
351
+ end
352
+ end
176
353
 
177
- def call(max:)
178
- ui.info "Computing closest primes..."
179
- File.open('primes.txt', 'w') do |f|
180
- integers = (1..max.to_i)
181
- ui.progress("Computing Primes", total: integers.size) do |bar|
182
- integers.each do |i|
183
- p = ComputePrimes.closest_prime(i)
184
- f.puts "closest prime to #{i} is #{p}"
185
- bar.advance
354
+ # A progress bar per address, advancing as each port answers or times out.
355
+ # Two bars, one counting the addresses that answered and one those
356
+ # that did not. Each bar's block is given only its own handle, so the
357
+ # "No answer" job hands its bar to the "Answered" job, which runs the
358
+ # scan, and then waits for the scan to end.
359
+ def with_progress(ips, ports, timeout)
360
+ silent_bar = Concurrent::Promises.resolvable_future
361
+ scanned = Concurrent::Promises.resolvable_future
362
+ results = nil
363
+ ui.multi_progress("Probing #{ips.size} addresses", total: ips.size) do |m|
364
+ m.progress("Answered", total: ips.size, color: :green) do |answered|
365
+ silent = silent_bar.value!
366
+ results = in_parallel(ips) do |ip|
367
+ scan(ip, ports, timeout) { nil }.tap { |open| (open ? answered : silent).advance }
186
368
  end
369
+ ensure
370
+ scanned.fulfill(true)
371
+ end
372
+ m.progress("No answer", total: ips.size, color: :red) do |silent|
373
+ silent_bar.fulfill(silent)
374
+ scanned.wait
187
375
  end
188
376
  end
189
- ui.success("Computed #{max} primes, they are in the 'primes.txt' files.")
190
- rescue StandardError => e
191
- ui.error("Import failed", e.message)
377
+ results
378
+ end
379
+
380
+ # Runs the block for each item, at most --concurrency at once.
381
+ #
382
+ # @return [Array] what the block returned for each item, in order
383
+ def in_parallel(items)
384
+ queue = Queue.new
385
+ items.each_with_index { |item, index| queue << [item, index] }
386
+ queue.close
387
+ results = Array.new(items.size)
388
+ Array.new(@concurrency) do
389
+ Thread.new do
390
+ while !@stop.stopped? && (pair = queue.pop)
391
+ item, index = pair
392
+ results[index] = yield item
393
+ end
394
+ end
395
+ end.each(&:join)
396
+ results
397
+ end
398
+
399
+ # Probes every port on an address at once.
400
+ #
401
+ # @yield once for each port, as its probe ends
402
+ # @return [Array<Integer>, nil] the open ports, empty when every port
403
+ # refused; nil when nothing answered at all
404
+ def scan(ip, ports, timeout, &)
405
+ states = ports.map do |port|
406
+ Thread.new do
407
+ probe(ip, port, timeout).tap(&)
408
+ end
409
+ end.map(&:value)
410
+ @scanned.increment
411
+ return if states.all?(:silent)
412
+
413
+ ports.zip(states).filter_map { |port, state| port if state == :open }
414
+ end
415
+
416
+ # A refused connection still means something is at that address.
417
+ #
418
+ # @return [Symbol] :open, :closed, or :silent when nothing answered
419
+ def probe(ip, port, timeout)
420
+ Socket.tcp(ip, port, connect_timeout: timeout).close
421
+ :open
422
+ rescue Errno::ECONNREFUSED
423
+ :closed
424
+ rescue SystemCallError, IOError, SocketError
425
+ :silent
192
426
  end
193
427
  end
194
428
 
195
- register "version", Version, aliases: ["v", "-v", "--version"]
196
- register "urls_progress", FetchUrlsProgress
197
- register "urls_spinner", FetchUrls
198
- register "completion", ::Dry::CLI::Autocomplete::Command[::Foo::CLI::Commands], hidden: true
199
- register "primes", ComputePrimes
429
+ register "version", Version, aliases: ["v", "-v", "--version"]
430
+ register "download-urls", DownloadUrls
431
+ register "find-hosts", FindHosts
432
+ register "completion", ::Dry::CLI::Autocomplete::Command[::Foo::CLI::Commands], hidden: true
200
433
  end
201
434
  end
202
435
  end
203
436
 
204
437
  Dry::CLI.new(Foo::CLI::Commands).call
205
-
206
- FileUtils.rm_f(Dir.glob("#{File.expand_path('../', __dir__)}/*.txt"))
@@ -24,12 +24,12 @@ module Dry
24
24
  # Anything not set reads from {DEFAULTS}.
25
25
  class Configuration
26
26
  # What a setting reads before it is set: a green `◼` for each finished
27
- # part of a bar, over a gray track the whole bar's width.
27
+ # part of a bar, with no background behind it.
28
28
  DEFAULTS = {
29
29
  spinner_format: :dots,
30
30
  bar_format: { complete: "◼", incomplete: " " }.freeze,
31
31
  bar_color: :green,
32
- bar_background: :on_bright_black
32
+ bar_background: nil
33
33
  }.freeze
34
34
 
35
35
  # Every style name Pastel knows, for checking colour settings.
@@ -59,7 +59,7 @@ module Dry
59
59
  # @return [Symbol, nil]
60
60
  # @!method bar_background(value = UNSET)
61
61
  # Reads the background the whole bar is drawn on, or sets it.
62
- # @param value [Symbol, nil] a Pastel style, such as :on_bright_black; nil for none
62
+ # @param value [Symbol, nil] a Pastel style, such as :on_blue; nil for none
63
63
  # @return [Symbol, nil]
64
64
  DEFAULTS.each_key do |name|
65
65
  define_method(name) do |value = UNSET|