dry-cli-ui 0.5.1 → 0.6.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: 6d1ad3344ddfd395caef9b2ec0128f05cda8248a98afa864b1b61ed1d1db0b5e
4
- data.tar.gz: d55f3256373ec25c1201dd0c39525eeb49b50f16989806740f720ed3e59f0f13
3
+ metadata.gz: d64ee9f8fedbc7de0fcd1875acf59d07876ef6acaffbfcc8574b197de0e8d6fd
4
+ data.tar.gz: 2f7e11eca1a0dff40b3df7cba685bca7f97d4884515db1107805036e746e20bc
5
5
  SHA512:
6
- metadata.gz: fe5bb8356f594b5a8670ee7c8b9a5cdc0ec87e2480bea892f0e98389a8d43218fd2de162f77de9444990e3d197e7679352995f1da3eb234094df68a5ea5c2d25
7
- data.tar.gz: b982df4a36156203f92f39b3b3c8da0e9b5fe8672092333c09d7dd70e48759f0470fc82060dea4a16bdbc9daca3e219361ca0c7f95f114961b740b20354e2105
6
+ metadata.gz: d9105f75325a1a7f8282e989d4c847320fb481880a7a529041d98d4bff9457dc5235d08a5c3b311c0b3d442a6dc4c3923b6c72844bd2fbb55ec04f2824b906e3
7
+ data.tar.gz: d095b784083ba57db9592e51ef2d56274dd9cb187fcac350d35aaea51b8e64c519a800e3f7f899b2b248acfe8035733c4fa63930b64086cdbebea17c8af4636b
data/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## [0.6.1]
2
+
3
+ - A progress handle takes `fail(reason = nil)`, as a `Line` does: the bar ends `𝘅 label 3/3: reason` without raising, in `ui.progress` and in a row of `ui.multi_progress`, where the jobs after it still run and the headline ends `𝘅`. For work that carries on after some of its units failed.
4
+
5
+ ## [0.6.0]
6
+
7
+ - `m.spinner(label)` inside `ui.multi_progress` declares a row with a spinner in place of a bar, for a phase whose size is never known, so one widget can show phases of known and unknown size together. The row shows its `Line`'s detail while it runs and ends `[✓] label`; it counts as a job and as no units on the headline.
8
+
9
+ ## [0.6.0]
10
+
11
+ - `ui` reads the command's public `stdout`, `stderr` and `stdin`, the streams dry-cli was called with. Before, it looked for `out` and `err`, which dry-cli no longer has, and wrote to `$stdout` and `$stderr` instead. Prompts now read from the command's `stdin`.
12
+ - `ui` builds its console again when those streams change, so a command registered as an instance, or run in-process by `Dry::CLI::Launcher`, writes each call to that call's streams.
13
+ - `ui_options` configures the console `ui` builds, in place of overriding `ui`.
14
+ - Needs dry-cli with public command streams and `Dry::CLI::Launcher`; the Gemfile takes it from kigster/dry-cli until it is released.
15
+
1
16
  ## [0.5.1]
2
17
 
3
18
  - `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.
data/README.md CHANGED
@@ -86,7 +86,7 @@ class Import < ApplicationCommand
86
86
  end
87
87
  ```
88
88
 
89
- `ui` writes to the command's `out` and `err` when dry-cli has set them, and to `$stdout` and `$stderr` otherwise.
89
+ `ui` writes to the command's `stdout` and `stderr`, and reads prompt answers from its `stdin`: the streams dry-cli was called with. Outside a dry-cli command it uses `$stdout`, `$stderr` and `$stdin`. The console is built again when those streams change, so a command registered as an instance writes each call to that call's streams.
90
90
 
91
91
  ### Without dry-cli
92
92
 
@@ -399,6 +399,55 @@ Downloading...
399
399
 
400
400
  Any other `count:`, or a `total:` that is not a non-negative Integer, raises `ArgumentError`.
401
401
 
402
+ A phase whose size is never known goes on a row of its own with `m.spinner(label)`, beside the bars. Its row turns and shows the detail its `Line` is given, as in `multi_spinner`, with no bar, and it ends `[✓] label (0.1s)`, or `[𝘅] label: reason` after `line.fail`. It counts as a job and as no units, so `count: :jobs` suits a headline over phases run one at a time:
403
+
404
+ ```ruby
405
+ ui.multi_progress("Generating text", concurrent: false, count: :jobs) do |m|
406
+ m.spinner("Finding PDFs") { |line| pdfs = find_pdfs { |dir| line.detail = dir } }
407
+ m.progress("Extracting", total: nil) do |bar|
408
+ bar.total = pdfs.size
409
+ pdfs.each { |pdf| extract(pdf) && bar.advance }
410
+ end
411
+ m.spinner("Indexing") { index }
412
+ end
413
+ ```
414
+
415
+ Piped:
416
+
417
+ ```text
418
+ Generating text...
419
+ [✓] Finding PDFs (0.0s)
420
+ [✓] Extracting 3/3 (0.0s)
421
+ [✓] Indexing (0.0s)
422
+ ✓ Generating text 3/3 (0.0s)
423
+ ```
424
+
425
+ `m.spinner` without a block raises `ArgumentError`.
426
+
427
+ A job that carries on after some of its units failed ends its row with `bar.fail(reason)` rather than raising, so the jobs after it still run. Its row ends `[𝘅] label 3/3: reason`, the headline ends `𝘅`, and the call returns as usual. `ui.progress` takes the same `bar.fail`.
428
+
429
+ ```ruby
430
+ ui.multi_progress("Generating text", concurrent: false, count: :jobs) do |m|
431
+ m.spinner("Finding PDFs") { pdfs = find_pdfs }
432
+ m.progress("Extracting", total: nil) do |bar|
433
+ bar.total = pdfs.size
434
+ failed = pdfs.count { |pdf| !extract(pdf).tap { bar.advance } }
435
+ bar.fail("#{failed} failed") if failed.positive?
436
+ end
437
+ m.spinner("Indexing") { index }
438
+ end
439
+ ```
440
+
441
+ Piped, with one PDF that would not extract:
442
+
443
+ ```text
444
+ Generating text...
445
+ [✓] Finding PDFs (0.0s)
446
+ [𝘅] Extracting 3/3: 1 failed (0.0s)
447
+ [✓] Indexing (0.0s)
448
+ 𝘅 Generating text 3/3 (0.0s)
449
+ ```
450
+
402
451
  Piped:
403
452
 
404
453
  ```text
@@ -725,7 +774,7 @@ Errors raised inside a block are never swallowed: the widget marks itself failed
725
774
  | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
726
775
  | `info`, `success`, `box`, `table`, `status` at those levels | `debug`, `warn`, `error`, `fatal`, `popup`, spinners, progress bars, their multi forms, task trees, the status bar, prompts |
727
776
 
728
- `mycli export > rules.csv` therefore writes only the command's results to the file, while its progress stays on the screen. `ui` writes to the streams dry-cli was called with, so `Dry::CLI.new(registry).call(out: io, err: io)` captures everything.
777
+ `mycli export > rules.csv` therefore writes only the command's results to the file, while its progress stays on the screen. `ui` writes to the streams dry-cli was called with, so `Dry::CLI.new(registry).call(stdout: io, stderr: io)` captures everything.
729
778
 
730
779
  A stream that is not a terminal, or runs under `TERM=dumb`, gets no animation, no cursor movement and no escape codes. [`NO_COLOR`](https://no-color.org) turns colour off and leaves animation on.
731
780
 
@@ -780,20 +829,18 @@ end
780
829
 
781
830
  ### Console options
782
831
 
783
- Override `ui` to configure the console:
832
+ Override `ui_options` to configure the console `ui` builds. The streams come from the command:
784
833
 
785
834
  ```ruby
786
835
  class ApplicationCommand < Dry::CLI::Command
787
836
  include Dry::CLI::UI
788
837
 
789
- def ui
790
- @ui ||= Dry::CLI::UI::Console.new(
791
- out: out || $stdout,
792
- err: err || $stderr,
838
+ private def ui_options
839
+ {
793
840
  box_width: 72, # boxes are 72 columns rather than the whole terminal
794
841
  color: nil, # true or false to override detection
795
842
  animate: nil # true or false to override detection
796
- )
843
+ }
797
844
  end
798
845
  end
799
846
  ```
@@ -837,7 +884,15 @@ err.string # => "Loading...\n✓ Loading (0.0s)\nContinue? (y/N) "
837
884
  out.string # => the Success box
838
885
  ```
839
886
 
840
- Through dry-cli, `Dry::CLI.new(registry).call(arguments: %w[import], out: out, err: err)` gives every command's `ui` those streams.
887
+ Through dry-cli, `Dry::CLI.new(registry).call(arguments: %w[import], stdin: input, stdout: out, stderr: err)` gives every command's `ui` those streams.
888
+
889
+ To run the whole CLI in the same process as its specs, as Aruba's in-process launcher does, bind a `Dry::CLI::Launcher` to it. Each run gets its own streams, and exits are recorded rather than ending the process:
890
+
891
+ ```ruby
892
+ Launcher = Dry::CLI::Launcher[MyCLI::Commands]
893
+
894
+ Launcher.new(%w[import], StringIO.new("y\n"), out, err, kernel).execute!
895
+ ```
841
896
 
842
897
  ## Relationship to dry-cli-help
843
898
 
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- dry-cli-ui (0.5.0)
4
+ dry-cli-ui (0.6.1)
5
5
  concurrent-ruby (~> 1.3)
6
6
  dry-cli (>= 1.0)
7
7
  pastel (~> 0.8)
@@ -84,7 +84,7 @@ CHECKSUMS
84
84
  dry-cli (1.4.1) sha256=b8015bb76c708aa8705a36faf694973e75eeeffca39b89c8e172dc6f66a7d874
85
85
  dry-cli-autocomplete (0.1.3) sha256=14f76447bda0000c71739b585c827dc0a0a763e30a68a8b4b2dff91f640768e4
86
86
  dry-cli-help (0.2.1) sha256=eaaaf6edb787ffe810425b4e4bb1a675d89397ccc759102d70b5b26e7bc692da
87
- dry-cli-ui (0.5.0)
87
+ dry-cli-ui (0.6.1)
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
@@ -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.1"
14
+ VERSION = "0.6.1"
15
15
  end
16
16
  end
17
17
  end
@@ -11,6 +11,12 @@ module Dry
11
11
  # runs, and is replaced by `✓ label 120/120 (1.1s)` when it ends. The
12
12
  # bar's characters come from {Configuration#bar_format}.
13
13
  #
14
+ # A job declared with `spinner` instead has no bar: its row turns and
15
+ # shows its {Line}'s detail, as in {MultiSpinner}, and ends
16
+ # `✓ label (0.4s)`. It suits a phase whose size is never known, among
17
+ # phases whose size is. It counts as a job and as no units, so a
18
+ # headline over such rows reads best with `count: :jobs`.
19
+ #
14
20
  # @example
15
21
  # ui.multi_progress("Downloading") do |m|
16
22
  # files.each do |file|
@@ -20,6 +26,13 @@ module Dry
20
26
  # end
21
27
  # end
22
28
  #
29
+ # @example Phases, one at a time, some of known size
30
+ # ui.multi_progress("Generating", concurrent: false, count: :jobs) do |m|
31
+ # m.spinner("Finding") { |line| found = find { |dir| line.detail = dir } }
32
+ # m.progress("Extracting", total: nil) { |bar| bar.total = found.size; found.each { extract(it) && bar.advance } }
33
+ # m.spinner("Indexing") { index }
34
+ # end
35
+ #
23
36
  # See {Multi} for how the jobs run and how the rows are drawn.
24
37
  class MultiProgress < Multi
25
38
  # What the declaration block is given.
@@ -48,6 +61,20 @@ module Dry
48
61
  @jobs << Multi::Job.new(label, work, Progress::Handle.new(total, nil, color: Progress.color(color)))
49
62
  self
50
63
  end
64
+
65
+ # Declares a job with a spinner in place of a bar, for work whose
66
+ # size is never known.
67
+ #
68
+ # @param label [String]
69
+ # @yieldparam line [Line] reports on the work while it runs
70
+ # @return [self]
71
+ # @raise [ArgumentError] without a block
72
+ def spinner(label, &work)
73
+ raise ArgumentError, "spinner #{label.inspect} needs a block" unless work
74
+
75
+ @jobs << Multi::Job.new(label, work, Line.new)
76
+ self
77
+ end
51
78
  end
52
79
 
53
80
  # What the headline bar can count.
@@ -78,19 +105,33 @@ module Dry
78
105
  private
79
106
 
80
107
  # @param job [Job]
81
- # @return [Progress::Handle]
82
- def progress_of(job) = job.handle
108
+ # @return [Boolean] whether the job was declared with `spinner`
109
+ def spinner?(job) = job.handle.is_a?(Line)
110
+
111
+ # @param job [Job]
112
+ # @return [Object]
113
+ def call(job) = spinner?(job) ? Line.call(job.work, job.handle) : super
114
+
115
+ # @param job [Job]
116
+ # @return [Boolean] whether its line or its bar was told to fail
117
+ def reported_failure?(job) = job.handle.failed?
118
+
119
+ # @param job [Job]
120
+ # @return [Progress::Handle, nil] nil for a spinner, which has no progress
121
+ def progress_of(job) = spinner?(job) ? nil : job.handle
83
122
 
84
123
  # @param job [Job]
85
124
  # @param width [Integer]
86
125
  # @return [String]
87
126
  def running(job, width)
127
+ return [job.label, job.handle.detail].reject(&:empty?).join(" ") if spinner?(job)
128
+
88
129
  "#{job.label.ljust(width)} #{meter(job.handle.current, job.handle.total, job.started, job.handle.color)}"
89
130
  end
90
131
 
91
132
  # @param job [Job]
92
133
  # @return [String]
93
- def summary(job) = "#{job.label} #{job.handle.current}/#{job.handle.total || '?'}"
134
+ def summary(job) = job.handle.summary(job.label)
94
135
 
95
136
  # @param width [Integer]
96
137
  # @return [String]
@@ -105,7 +146,7 @@ module Dry
105
146
  def current
106
147
  return jobs.count(&:seconds) if @count == :jobs
107
148
 
108
- jobs.sum { |job| job.handle.current }
149
+ bars.sum { |job| job.handle.current }
109
150
  end
110
151
 
111
152
  # @return [Integer] the total given; or units across every job,
@@ -114,9 +155,12 @@ module Dry
114
155
  return @total if @total
115
156
  return jobs.size if @count == :jobs
116
157
 
117
- jobs.sum { |job| job.handle.total || job.handle.current }
158
+ bars.sum { |job| job.handle.total || job.handle.current }
118
159
  end
119
160
 
161
+ # @return [Array<Job>] the jobs that have a bar
162
+ def bars = jobs.reject { spinner?(it) }
163
+
120
164
  # `[◼◼◼ ] 48% 96/200 ETA 3.1s`, with the count right-aligned to
121
165
  # the widest any row can show, so every count ends in one column.
122
166
  #
@@ -56,6 +56,33 @@ module Dry
56
56
  self
57
57
  end
58
58
 
59
+ # Ends the work as a failure when the block returns, without
60
+ # raising: for work that went on after some of its units failed.
61
+ #
62
+ # @param reason [#to_s, nil] said after the count, such as "2 failed"
63
+ # @return [self]
64
+ def fail(reason = nil)
65
+ @failed = true
66
+ @reason = reason&.to_s
67
+ self
68
+ end
69
+
70
+ # @return [Boolean] whether {#fail} was called
71
+ def failed? = @failed == true
72
+
73
+ # @return [String, nil] what {#fail} was given
74
+ attr_reader :reason
75
+
76
+ # The label and the count, with the failure's reason after it when
77
+ # there is one: `Extracting 1002/1002: 2 failed`.
78
+ #
79
+ # @param label [String]
80
+ # @return [String]
81
+ def summary(label)
82
+ count = "#{label} #{current}/#{total || '?'}"
83
+ failed? && !reason.to_s.empty? ? "#{count}: #{reason}" : count
84
+ end
85
+
59
86
  private
60
87
 
61
88
  # @return [TTY::ProgressBar, nil]
@@ -150,14 +177,13 @@ module Dry
150
177
  terminal.started(handle, label, progress: handle)
151
178
  ok = false
152
179
  result = yield handle
153
- ok = true
180
+ ok = !handle.failed?
154
181
  result
155
182
  ensure
156
183
  if handle
157
184
  bar&.stop
158
185
  terminal.finished(handle, ok)
159
- summary = "#{label} #{handle.current}/#{handle.total}"
160
- terminal.puts(Outcome.line(terminal, ok ? :done : :failed, summary, clock.call - started))
186
+ terminal.puts(Outcome.line(terminal, ok ? :done : :failed, handle.summary(label), clock.call - started))
161
187
  end
162
188
  end
163
189
 
data/lib/dry/cli/ui.rb CHANGED
@@ -69,23 +69,65 @@ module Dry
69
69
  def reset!
70
70
  @config = nil
71
71
  end
72
+
73
+ # The IO beneath a dry-cli stream, or the stream itself when it is not one.
74
+ #
75
+ # Asks for the class rather than for `raw`, which `io/console` also defines on every IO, to
76
+ # put a terminal into raw mode.
77
+ #
78
+ # @param stream [IO, Dry::CLI::Stream]
79
+ # @return [IO]
80
+ def raw(stream)
81
+ defined?(Dry::CLI::Stream) && stream.is_a?(Dry::CLI::Stream) ? stream.raw : stream
82
+ end
72
83
  end
73
84
 
74
- # The console this command presents through. Writes to the command's own
75
- # `out` and `err` when dry-cli has set them, and to `$stdout` and
76
- # `$stderr` otherwise.
85
+ # The console this command presents through.
77
86
  #
78
- # Override it to configure the console:
87
+ # In a dry-cli command it writes to the command's own `stdout` and `stderr`, and reads prompt
88
+ # answers from its `stdin`: the streams the CLI was called with. Anywhere else it uses
89
+ # `$stdout`, `$stderr` and `$stdin`.
79
90
  #
80
- # @example
81
- # def ui = @ui ||= Dry::CLI::UI::Console.new(box_width: 72)
91
+ # The console is built again whenever those streams change. A command registered as an
92
+ # instance is used for every call to its CLI, each time with that call's streams, as when a
93
+ # test suite runs the CLI in-process with a StringIO per test.
94
+ #
95
+ # Configure the console by overriding {#ui_options}.
82
96
  #
83
97
  # @return [Dry::CLI::UI::Console]
84
98
  def ui
85
- @ui ||= Console.new(
86
- out: (respond_to?(:out, true) && __send__(:out)) || $stdout,
87
- err: (respond_to?(:err, true) && __send__(:err)) || $stderr
88
- )
99
+ streams = ui_streams
100
+ @ui = nil unless @ui_streams && streams.all? { |name, io| @ui_streams[name].equal?(io) }
101
+ @ui_streams = streams
102
+ @ui ||= Console.new(**streams, **ui_options)
103
+ end
104
+
105
+ private
106
+
107
+ # The streams {#ui} presents through, as keywords for {Console#initialize}.
108
+ #
109
+ # A dry-cli command gives its own public `stdout`, `stderr` and `stdin`. Its output streams
110
+ # render dry-cli's own styles; the console writes to the IO beneath them, since it decides
111
+ # colour for itself.
112
+ #
113
+ # @return [Hash{Symbol => IO}] `out:`, `err:` and `input:`
114
+ def ui_streams
115
+ {
116
+ out: UI.raw(respond_to?(:stdout) ? stdout : $stdout),
117
+ err: UI.raw(respond_to?(:stderr) ? stderr : $stderr),
118
+ input: respond_to?(:stdin) ? stdin : $stdin
119
+ }
120
+ end
121
+
122
+ # Options for {Console#initialize} other than its streams. Override it to configure the
123
+ # console {#ui} builds.
124
+ #
125
+ # @example
126
+ # def ui_options = { box_width: 72 }
127
+ #
128
+ # @return [Hash{Symbol => Object}]
129
+ def ui_options
130
+ {}
89
131
  end
90
132
  end
91
133
  end
data/sig/dry/cli/ui.rbs CHANGED
@@ -11,6 +11,11 @@ module Dry
11
11
 
12
12
  def ui: () -> Console
13
13
 
14
+ private def ui_streams: () -> { out: untyped, err: untyped, input: untyped }
15
+ private def ui_options: () -> Hash[Symbol, untyped]
16
+
17
+ def self.raw: (untyped stream) -> untyped
18
+
14
19
  def self.configure: () { (Configuration config) -> void } -> Configuration
15
20
  def self.config: () -> Configuration
16
21
  def self.reset!: () -> void
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.1
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Gredeskoul
@@ -229,7 +229,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
229
229
  - !ruby/object:Gem::Version
230
230
  version: '0'
231
231
  requirements: []
232
- rubygems_version: 4.0.20
232
+ rubygems_version: 4.0.22
233
233
  specification_version: 4
234
234
  summary: 'Runtime terminal UI for dry-cli commands: spinners, progress, boxes, task
235
235
  trees, tables, prompts'