dry-cli-ui 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Load the gems examples/Gemfile names, even when run as bin/mycli without
5
+ # bundle exec; otherwise Ruby picks the newest installed dry-cli-ui instead.
6
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
7
+ require "bundler/setup"
8
+
9
+ require "fileutils"
10
+ require "dry/cli"
11
+ require "dry/cli/help"
12
+ require "dry/cli/autocomplete/command"
13
+ require "dry/cli/ui"
14
+ require "net/http"
15
+ require "prime"
16
+
17
+ module Foo
18
+ module CLI
19
+ module Commands
20
+ extend Dry::CLI::Registry
21
+
22
+ class Version < Dry::CLI::Command
23
+ desc "Print version"
24
+
25
+ def call(*)
26
+ puts "1.0.0"
27
+ end
28
+ end
29
+
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.
32
+ module HTTP
33
+ HEADERS = { "Accept-Encoding" => "identity" }.freeze
34
+ REDIRECTS = 5
35
+
36
+ module_function
37
+
38
+ # @return [Array(URI, Integer, nil)] where the URL ends up, and its size when the server says
39
+ # @raise [RuntimeError] for a response that is neither a redirect nor a success
40
+ def resolve(url, hops = REDIRECTS)
41
+ uri = URI(url)
42
+ 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
46
+ raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
47
+
48
+ [uri, response.content_length]
49
+ end
50
+
51
+ # @yieldparam bytes [Integer] the size of each chunk as it arrives
52
+ # @return [String] the body
53
+ # @raise [RuntimeError] for a response that is not a success
54
+ def download(uri)
55
+ body = +""
56
+ start(uri) do |http|
57
+ http.request_get(uri.request_uri, HEADERS) do |response|
58
+ raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
59
+
60
+ response.read_body do |chunk|
61
+ body << chunk
62
+ yield chunk.bytesize if block_given?
63
+ end
64
+ end
65
+ end
66
+ body
67
+ end
68
+
69
+ def start(uri, &)
70
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 10, &)
71
+ end
72
+ end
73
+
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
77
+ include Dry::CLI::UI
78
+
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
90
+ 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
103
+ end
104
+
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)
109
+ end
110
+ end
111
+
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
115
+
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"
118
+
119
+ def call(urls:, **)
120
+ bodies = ui.multi_spinner("Fetching #{urls.size} URLs", concurrent: 8) do |m|
121
+ urls.each do |url|
122
+ 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)
128
+ rescue StandardError => e
129
+ line.fail(e.message)
130
+ nil
131
+ end
132
+ end
133
+ end
134
+
135
+ files = urls.zip(bodies).filter_map do |url, body|
136
+ next unless body
137
+
138
+ "#{friendly_filename(url)}.txt".tap { |file| File.write(file, body) }
139
+ end
140
+ ui.success "Wrote #{files.size} of #{urls.size} URLs", files.join("\n")
141
+ end
142
+
143
+ private
144
+
145
+ def friendly_filename(url)
146
+ url.sub(%r{\Ahttps?://}, "").gsub(/[^\w.-]+/, "_").delete_suffix("_")
147
+ end
148
+ end
149
+
150
+ class ComputePrimes < Dry::CLI::Command
151
+ include Dry::CLI::UI
152
+
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)
157
+
158
+ distance = 1
159
+ loop do
160
+ lower = n - distance
161
+ upper = n + distance
162
+
163
+ primes = []
164
+ primes << lower if lower > 1 && ::Prime.prime?(lower)
165
+ primes << upper if ::Prime.prime?(upper)
166
+
167
+ # Return the prime(s) found at the shortest distance
168
+ return primes.size == 1 ? primes.first : primes if primes.any?
169
+
170
+ distance += 1
171
+ end
172
+ end
173
+
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
176
+
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
186
+ end
187
+ end
188
+ 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)
192
+ end
193
+ end
194
+
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
200
+ end
201
+ end
202
+ end
203
+
204
+ Dry::CLI.new(Foo::CLI::Commands).call
205
+
206
+ FileUtils.rm_f(Dir.glob("#{File.expand_path('../', __dir__)}/*.txt"))
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "tty/spinner/formats"
5
+ require "tty/progressbar/formats"
6
+
7
+ module Dry
8
+ class CLI
9
+ module UI
10
+ # Process-wide settings, made once through {UI.configure}:
11
+ #
12
+ # Dry::CLI::UI.configure do
13
+ # spinner_format :dots # any TTY::Spinner format name
14
+ # bar_format :box # any TTY::ProgressBar bar format name
15
+ # bar_color :cyan # any Pastel style, or nil
16
+ # bar_background nil # any Pastel style, or nil
17
+ # end
18
+ #
19
+ # Dry::CLI::UI.configure do |config|
20
+ # config.spinner_format = { interval: 8, frames: %w[◐ ◓ ◑ ◒] }
21
+ # config.bar_format = { complete: "#", incomplete: "." }
22
+ # end
23
+ #
24
+ # Anything not set reads from {DEFAULTS}.
25
+ class Configuration
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.
28
+ DEFAULTS = {
29
+ spinner_format: :dots,
30
+ bar_format: { complete: "◼", incomplete: " " }.freeze,
31
+ bar_color: :green,
32
+ bar_background: :on_bright_black
33
+ }.freeze
34
+
35
+ # Every style name Pastel knows, for checking colour settings.
36
+ STYLES = Pastel.new(enabled: true).styles.keys.freeze
37
+
38
+ # Marks a DSL call made without a value, which reads instead of writes.
39
+ UNSET = Object.new.freeze
40
+ private_constant :UNSET
41
+
42
+ def initialize
43
+ @values = {}
44
+ end
45
+
46
+ # @!method spinner_format(value = UNSET)
47
+ # Reads the spinner format, or sets it when given a value.
48
+ # @param value [Symbol, Hash] a key of `TTY::Formats::FORMATS`, or
49
+ # `{ interval:, frames: }`: frames per second, and the frames
50
+ # @return [Symbol, Hash]
51
+ # @!method bar_format(value = UNSET)
52
+ # Reads the bar format, or sets it when given a value.
53
+ # @param value [Symbol, Hash] a key of `TTY::ProgressBar::Formats::FORMATS`,
54
+ # or `{ complete:, incomplete: }`
55
+ # @return [Symbol, Hash]
56
+ # @!method bar_color(value = UNSET)
57
+ # Reads the colour a bar's finished part is drawn in, or sets it.
58
+ # @param value [Symbol, nil] a Pastel style, such as :green; nil for none
59
+ # @return [Symbol, nil]
60
+ # @!method bar_background(value = UNSET)
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
63
+ # @return [Symbol, nil]
64
+ DEFAULTS.each_key do |name|
65
+ define_method(name) do |value = UNSET|
66
+ return @values.fetch(name) { DEFAULTS.fetch(name) } if UNSET.equal?(value)
67
+
68
+ public_send(:"#{name}=", value)
69
+ end
70
+ end
71
+
72
+ # @param value [Symbol, Hash] see {#spinner_format}
73
+ # @raise [ArgumentError] for an unknown name or a malformed Hash
74
+ def spinner_format=(value)
75
+ @values[:spinner_format] = spinner_definition(value) && value
76
+ end
77
+
78
+ # @param value [Symbol, Hash] see {#bar_format}
79
+ # @raise [ArgumentError] for an unknown name or a malformed Hash
80
+ def bar_format=(value)
81
+ @values[:bar_format] = bar_definition(value) && value
82
+ end
83
+
84
+ # @param value [Symbol, nil] see {#bar_color}
85
+ # @raise [ArgumentError] for a style Pastel does not know
86
+ def bar_color=(value)
87
+ @values[:bar_color] = style(:bar_color, value)
88
+ end
89
+
90
+ # @param value [Symbol, nil] see {#bar_background}
91
+ # @raise [ArgumentError] for a style Pastel does not know
92
+ def bar_background=(value)
93
+ @values[:bar_background] = style(:bar_background, value)
94
+ end
95
+
96
+ # @return [Array<String>] the frames a spinner cycles through
97
+ def spinner_frames
98
+ frames = spinner_definition(spinner_format).fetch(:frames)
99
+ frames.is_a?(String) ? frames.chars : frames
100
+ end
101
+
102
+ # @return [Float] seconds between two spinner frames
103
+ def spinner_frame_seconds
104
+ 1.0 / spinner_definition(spinner_format).fetch(:interval)
105
+ end
106
+
107
+ # @return [String] what a finished part of a bar is drawn with
108
+ def bar_complete
109
+ bar_definition(bar_format).fetch(:complete)
110
+ end
111
+
112
+ # @return [String] what an unfinished part of a bar is drawn with
113
+ def bar_incomplete
114
+ bar_definition(bar_format).fetch(:incomplete)
115
+ end
116
+
117
+ private
118
+
119
+ # @param value [Symbol, Hash]
120
+ # @return [Hash{Symbol => Object}] with :interval and :frames
121
+ def spinner_definition(value)
122
+ return TTY::Formats::FORMATS.fetch(value) { unknown(:spinner_format, value) } if value.is_a?(Symbol)
123
+
124
+ valid = value.is_a?(Hash) && value[:interval].is_a?(Numeric) && value[:interval].positive? &&
125
+ (value[:frames].is_a?(String) || value[:frames].is_a?(Array)) && !value[:frames].empty?
126
+ valid ? value : malformed(:spinner_format, value, "{ interval: Numeric, frames: Array }")
127
+ end
128
+
129
+ # @param value [Symbol, Hash]
130
+ # @return [Hash{Symbol => String}] with :complete and :incomplete
131
+ def bar_definition(value)
132
+ return TTY::ProgressBar::Formats::FORMATS.fetch(value) { unknown(:bar_format, value) } if value.is_a?(Symbol)
133
+
134
+ valid = value.is_a?(Hash) && value[:complete].is_a?(String) && value[:incomplete].is_a?(String)
135
+ valid ? value : malformed(:bar_format, value, "{ complete: String, incomplete: String }")
136
+ end
137
+
138
+ # @param setting [Symbol]
139
+ # @param value [Symbol, nil]
140
+ # @return [Symbol, nil] the value
141
+ # @raise [ArgumentError] for anything but nil or a style Pastel knows
142
+ def style(setting, value)
143
+ return value if value.nil? || STYLES.include?(value)
144
+
145
+ raise ArgumentError, "#{setting} must be a Pastel style or nil, got #{value.inspect}"
146
+ end
147
+
148
+ # @raise [ArgumentError]
149
+ def unknown(setting, value)
150
+ raise ArgumentError, "#{setting} #{value.inspect} is not a known format"
151
+ end
152
+
153
+ # @raise [ArgumentError]
154
+ def malformed(setting, value, shape)
155
+ raise ArgumentError, "#{setting} must be a format name or #{shape}, got #{value.inspect}"
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -64,13 +64,15 @@ module Dry
64
64
  # @param width [Integer, nil] force the terminal width; nil asks the terminal
65
65
  # @param box_width [Integer, nil] box width in columns; nil fills the terminal
66
66
  # @param clock [#call] returns monotonic seconds
67
+ # @param config [Configuration] spinner and bar formats; {UI.config} by default
67
68
  def initialize(out: $stdout, err: $stderr, input: $stdin, env: ENV, color: nil, animate: nil,
68
- width: nil, box_width: nil, clock: Duration::CLOCK)
69
+ width: nil, box_width: nil, clock: Duration::CLOCK, config: UI.config)
69
70
  @out = Terminal.new(out, env: env, color: color, animate: animate, width: width)
70
71
  @err = Terminal.new(err, env: env, color: color, animate: animate, width: width)
71
72
  @input = input
72
73
  @box_width = box_width
73
74
  @clock = clock
75
+ @config = config
74
76
  end
75
77
 
76
78
  # A framed panel. Given a level, it takes that level's title, colour
@@ -142,7 +144,27 @@ module Dry
142
144
  def spinner(label, &)
143
145
  raise ArgumentError, "spinner needs a block" unless block_given?
144
146
 
145
- Widgets::Spinner.new(err, clock: clock).run(label, &)
147
+ Widgets::Spinner.new(err, clock: clock, config: config).run(label, &)
148
+ end
149
+
150
+ # Runs several jobs at once, each under a spinner of its own, beneath a
151
+ # headline spinner. Each job is given a {Line}. See {Widgets::MultiSpinner}.
152
+ #
153
+ # @example
154
+ # ui.multi_spinner("Fetching", concurrent: 3) do |m|
155
+ # assets.each { |asset| m.spinner(asset.name) { fetch(asset) } }
156
+ # end
157
+ #
158
+ # @param title [String] the headline
159
+ # @param concurrent [Boolean, Integer] all at once (the default), one at
160
+ # a time, or at most this many at once
161
+ # @yieldparam spinners [Widgets::MultiSpinner::Builder] declares each `spinner`
162
+ # @return [Array<Object>] what each job returned, in declaration order
163
+ # @raise [ArgumentError] without a block, or with an invalid concurrent
164
+ def multi_spinner(title, concurrent: true, &)
165
+ raise ArgumentError, "multi_spinner needs a block" unless block_given?
166
+
167
+ Widgets::MultiSpinner.new(err, clock: clock, config: config).run(title, concurrent: concurrent, &)
146
168
  end
147
169
 
148
170
  # Runs a block with a progress bar showing percent, count and ETA.
@@ -155,7 +177,30 @@ module Dry
155
177
  def progress(label, total:, &)
156
178
  raise ArgumentError, "progress needs a block" unless block_given?
157
179
 
158
- Widgets::Progress.new(err, clock: clock).run(label, total: total, &)
180
+ Widgets::Progress.new(err, clock: clock, config: config).run(label, total: total, &)
181
+ end
182
+
183
+ # Runs several jobs at once, each with a progress bar of its own,
184
+ # beneath a headline bar that counts them all. Each job is given a
185
+ # {Widgets::Progress::Handle}. See {Widgets::MultiProgress}.
186
+ #
187
+ # @example
188
+ # ui.multi_progress("Downloading") do |m|
189
+ # files.each do |file|
190
+ # m.progress(file.name, total: file.size) { |bar| download(file) { |n| bar.advance(n) } }
191
+ # end
192
+ # end
193
+ #
194
+ # @param title [String] the headline
195
+ # @param concurrent [Boolean, Integer] all at once (the default), one at
196
+ # a time, or at most this many at once
197
+ # @yieldparam bars [Widgets::MultiProgress::Builder] declares each `progress`
198
+ # @return [Array<Object>] what each job returned, in declaration order
199
+ # @raise [ArgumentError] without a block, or with an invalid concurrent
200
+ def multi_progress(title, concurrent: true, &)
201
+ raise ArgumentError, "multi_progress needs a block" unless block_given?
202
+
203
+ Widgets::MultiProgress.new(err, clock: clock, config: config).run(title, concurrent: concurrent, &)
159
204
  end
160
205
 
161
206
  # Declares a tree of tasks, then runs it, showing each task's state
@@ -181,7 +226,31 @@ module Dry
181
226
  def tasks(title = nil, concurrent: false, &)
182
227
  raise ArgumentError, "tasks needs a block" unless block_given?
183
228
 
184
- Widgets::Tasks.new(err, clock: clock).run(title, concurrent: concurrent, &)
229
+ Widgets::Tasks.new(err, clock: clock, config: config).run(title, concurrent: concurrent, &)
230
+ end
231
+
232
+ # Keeps a status line at the bottom of the screen while the block runs,
233
+ # saying how the command is doing overall. Every spinner, progress
234
+ # bar, multi widget and task tree started inside the block reports to
235
+ # it. Without an animated `err` it does nothing but run the block, and
236
+ # inside another status bar it does the same. See {StatusBar}.
237
+ #
238
+ # @example
239
+ # ui.status_bar("deploy", hints: ["^C cancel"]) do
240
+ # ui.multi_progress("Uploading") { |m| ... }
241
+ # ui.tasks("Migrate") { |t| ... }
242
+ # end
243
+ #
244
+ # @param title [String, nil] shown first, in bold
245
+ # @param hints [Array<String>] shown at the right edge
246
+ # @return [Object] whatever the block returns
247
+ # @raise [ArgumentError] without a block
248
+ def status_bar(title = nil, hints: [], &)
249
+ raise ArgumentError, "status_bar needs a block" unless block_given?
250
+ return yield if !err.animated? || err.reporter
251
+
252
+ others = out.animated? ? [out] : []
253
+ StatusBar.new(err, others: others, title: title, hints: Array(hints), clock: clock, config: config).run(&)
185
254
  end
186
255
 
187
256
  # Prints a table to `out`.
@@ -237,6 +306,9 @@ module Dry
237
306
  # @return [#call]
238
307
  attr_reader :clock
239
308
 
309
+ # @return [Configuration]
310
+ attr_reader :config
311
+
240
312
  # @param theme [Theme::Level]
241
313
  # @return [Terminal]
242
314
  def stream(theme)