toys-core 0.9.2 → 0.10.2

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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +2 -1
  3. data/CHANGELOG.md +47 -0
  4. data/LICENSE.md +1 -1
  5. data/README.md +3 -3
  6. data/lib/toys-core.rb +14 -21
  7. data/lib/toys/acceptor.rb +0 -21
  8. data/lib/toys/arg_parser.rb +1 -22
  9. data/lib/toys/cli.rb +102 -70
  10. data/lib/toys/compat.rb +49 -41
  11. data/lib/toys/completion.rb +0 -21
  12. data/lib/toys/context.rb +0 -23
  13. data/lib/toys/core.rb +1 -22
  14. data/lib/toys/dsl/flag.rb +0 -21
  15. data/lib/toys/dsl/flag_group.rb +0 -21
  16. data/lib/toys/dsl/positional_arg.rb +0 -21
  17. data/lib/toys/dsl/tool.rb +136 -51
  18. data/lib/toys/errors.rb +1 -22
  19. data/lib/toys/flag.rb +0 -21
  20. data/lib/toys/flag_group.rb +0 -21
  21. data/lib/toys/input_file.rb +0 -21
  22. data/lib/toys/loader.rb +42 -78
  23. data/lib/toys/middleware.rb +146 -77
  24. data/lib/toys/mixin.rb +0 -21
  25. data/lib/toys/module_lookup.rb +3 -26
  26. data/lib/toys/positional_arg.rb +0 -21
  27. data/lib/toys/source_info.rb +49 -38
  28. data/lib/toys/standard_middleware/add_verbosity_flags.rb +0 -23
  29. data/lib/toys/standard_middleware/apply_config.rb +42 -0
  30. data/lib/toys/standard_middleware/handle_usage_errors.rb +7 -28
  31. data/lib/toys/standard_middleware/set_default_descriptions.rb +0 -23
  32. data/lib/toys/standard_middleware/show_help.rb +0 -23
  33. data/lib/toys/standard_middleware/show_root_version.rb +0 -23
  34. data/lib/toys/standard_mixins/bundler.rb +89 -0
  35. data/lib/toys/standard_mixins/exec.rb +478 -128
  36. data/lib/toys/standard_mixins/fileutils.rb +0 -21
  37. data/lib/toys/standard_mixins/gems.rb +2 -24
  38. data/lib/toys/standard_mixins/highline.rb +0 -21
  39. data/lib/toys/standard_mixins/terminal.rb +0 -21
  40. data/lib/toys/template.rb +0 -21
  41. data/lib/toys/tool.rb +22 -34
  42. data/lib/toys/utils/completion_engine.rb +0 -21
  43. data/lib/toys/utils/exec.rb +142 -71
  44. data/lib/toys/utils/gems.rb +181 -63
  45. data/lib/toys/utils/help_text.rb +0 -21
  46. data/lib/toys/utils/terminal.rb +46 -37
  47. data/lib/toys/wrappable_string.rb +0 -21
  48. metadata +25 -9
@@ -1,30 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Copyright 2019 Daniel Azuma
4
- #
5
- # Permission is hereby granted, free of charge, to any person obtaining a copy
6
- # of this software and associated documentation files (the "Software"), to deal
7
- # in the Software without restriction, including without limitation the rights
8
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- # copies of the Software, and to permit persons to whom the Software is
10
- # furnished to do so, subject to the following conditions:
11
- #
12
- # The above copyright notice and this permission notice shall be included in
13
- # all copies or substantial portions of the Software.
14
- #
15
- # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20
- # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
21
- # IN THE SOFTWARE.
22
- ;
3
+ require "monitor"
4
+ require "rubygems"
23
5
 
24
6
  module Toys
25
7
  module Utils
26
8
  ##
27
- # A helper module that activates and installs gems.
9
+ # A helper class that activates and installs gems and sets up bundler.
28
10
  #
29
11
  # This class is not loaded by default. Before using it directly, you should
30
12
  # `require "toys/utils/gems"`
@@ -58,6 +40,30 @@ module Toys
58
40
  end
59
41
  end
60
42
 
43
+ ##
44
+ # Failed to run Bundler
45
+ #
46
+ class BundlerFailedError < ::StandardError
47
+ end
48
+
49
+ ##
50
+ # Could not find a Gemfile
51
+ #
52
+ class GemfileNotFoundError < BundlerFailedError
53
+ end
54
+
55
+ ##
56
+ # The bundle is not and could not be installed
57
+ #
58
+ class BundleNotInstalledError < BundlerFailedError
59
+ end
60
+
61
+ ##
62
+ # Bundler has already been run; cannot do so again
63
+ #
64
+ class AlreadyBundledError < BundlerFailedError
65
+ end
66
+
61
67
  ##
62
68
  # Activate the given gem. If it is not present, attempt to install it (or
63
69
  # inform the user to update the bundle).
@@ -73,24 +79,42 @@ module Toys
73
79
  ##
74
80
  # Create a new gem activator.
75
81
  #
76
- # @param input [IO] Input IO
77
- # @param output [IO] Output IO
78
- # @param suppress_confirm [Boolean] Suppress the confirmation prompt and
79
- # just use the given `default_confirm` value. Default is false,
80
- # indicating the confirmation prompt appears by default.
81
- # @param default_confirm [Boolean] Default response for the confirmation
82
- # prompt. Default is true.
82
+ # @param on_missing [:confirm,:error,:install] What to do if a needed gem
83
+ # is not installed. Possible values:
84
+ # * `:confirm` - prompt the user on whether to install
85
+ # * `:error` - raise an exception
86
+ # * `:install` - just install the gem
87
+ # The default is `:confirm`.
88
+ # @param on_conflict [:error,:warn,:ignore] What to do if bundler has
89
+ # already been run with a different Gemfile. Possible values:
90
+ # * `:error` - raise an exception
91
+ # * `:ignore` - just silently proceed without bundling again
92
+ # * `:warn` - print a warning and proceed without bundling again
93
+ # The default is `:error`.
94
+ # @param terminal [Toys::Utils::Terminal] Terminal to use (optional)
95
+ # @param input [IO] Input IO (optional, defaults to STDIN)
96
+ # @param output [IO] Output IO (optional, defaults to STDOUT)
97
+ # @param suppress_confirm [Boolean] Deprecated. Use `on_missing` instead.
98
+ # @param default_confirm [Boolean] Deprecated. Use `on_missing` instead.
83
99
  #
84
- def initialize(input: $stdin,
85
- output: $stderr,
86
- suppress_confirm: false,
87
- default_confirm: true)
88
- require "toys/utils/terminal"
89
- require "toys/utils/exec"
90
- @terminal = Utils::Terminal.new(input: input, output: output)
91
- @exec = Utils::Exec.new
92
- @suppress_confirm = suppress_confirm ? true : false
93
- @default_confirm = default_confirm ? true : false
100
+ def initialize(on_missing: nil,
101
+ on_conflict: nil,
102
+ terminal: nil,
103
+ input: nil,
104
+ output: nil,
105
+ suppress_confirm: nil,
106
+ default_confirm: nil)
107
+ @default_confirm = default_confirm || default_confirm.nil? ? true : false
108
+ @on_missing = on_missing ||
109
+ if suppress_confirm
110
+ @default_confirm ? :install : :error
111
+ else
112
+ :confirm
113
+ end
114
+ @on_conflict = on_conflict || :error
115
+ @terminal = terminal
116
+ @input = input || ::STDIN
117
+ @output = output || ::STDOUT
94
118
  end
95
119
 
96
120
  ##
@@ -102,13 +126,56 @@ module Toys
102
126
  # @return [void]
103
127
  #
104
128
  def activate(name, *requirements)
105
- gem(name, *requirements)
106
- rescue ::Gem::LoadError => e
107
- handle_activation_error(e, name, requirements)
129
+ Gems.synchronize do
130
+ begin
131
+ gem(name, *requirements)
132
+ rescue ::Gem::LoadError => e
133
+ handle_activation_error(e, name, requirements)
134
+ end
135
+ end
136
+ end
137
+
138
+ ##
139
+ # Set up the bundle.
140
+ #
141
+ # @param groups [Array<String>] The groups to include in setup
142
+ # @param search_dirs [Array<String>] Directories to search for a Gemfile
143
+ # @return [void]
144
+ #
145
+ def bundle(groups: nil,
146
+ search_dirs: nil)
147
+ Gems.synchronize do
148
+ gemfile_path = find_gemfile(Array(search_dirs))
149
+ activate("bundler", "~> 2.1")
150
+ if configure_gemfile(gemfile_path)
151
+ setup_bundle(gemfile_path, groups || [])
152
+ end
153
+ end
154
+ end
155
+
156
+ @global_mutex = ::Monitor.new
157
+
158
+ ## @private
159
+ def self.synchronize(&block)
160
+ @global_mutex.synchronize(&block)
108
161
  end
109
162
 
110
163
  private
111
164
 
165
+ def terminal
166
+ @terminal ||= begin
167
+ require "toys/utils/terminal"
168
+ Utils::Terminal.new(input: @input, output: @output)
169
+ end
170
+ end
171
+
172
+ def exec_util
173
+ @exec_util ||= begin
174
+ require "toys/utils/exec"
175
+ Utils::Exec.new
176
+ end
177
+ end
178
+
112
179
  def handle_activation_error(error, name, requirements)
113
180
  is_missing_spec =
114
181
  if defined?(::Gem::MissingSpecError)
@@ -116,11 +183,11 @@ module Toys
116
183
  else
117
184
  error.message.include?("Could not find")
118
185
  end
119
- unless is_missing_spec
186
+ if !is_missing_spec || @on_missing == :error
120
187
  report_error(name, requirements, error)
121
188
  return
122
189
  end
123
- install_gem(name, requirements)
190
+ confirm_and_install_gem(name, requirements)
124
191
  begin
125
192
  gem(name, *requirements)
126
193
  rescue ::Gem::LoadError => e
@@ -132,28 +199,16 @@ module Toys
132
199
  "#{name.inspect}, #{requirements.map(&:inspect).join(', ')}"
133
200
  end
134
201
 
135
- def install_gem(name, requirements)
136
- requirements_text = gem_requirements_text(name, requirements)
137
- response =
138
- if @suppress_confirm
139
- @default_confirm
140
- else
141
- @terminal.confirm("Gem needed: #{requirements_text}. Install? ",
142
- default: @default_confirm)
202
+ def confirm_and_install_gem(name, requirements)
203
+ if @on_missing == :confirm
204
+ requirements_text = gem_requirements_text(name, requirements)
205
+ response = terminal.confirm("Gem needed: #{requirements_text}. Install? ",
206
+ default: @default_confirm)
207
+ unless response
208
+ raise InstallFailedError, "Canceled installation of needed gem: #{requirements_text}"
143
209
  end
144
- unless response
145
- raise InstallFailedError, "Canceled installation of needed gem: #{requirements_text}"
146
210
  end
147
- perform_install(name, requirements)
148
- end
149
-
150
- def perform_install(name, requirements)
151
- result = @terminal.spinner(leading_text: "Installing gem #{name}... ",
152
- final_text: "Done.\n") do
153
- @exec.exec(["gem", "install", name, "--version", requirements.join(",")],
154
- out: :capture, err: :capture)
155
- end
156
- @terminal.puts(result.captured_out + result.captured_err)
211
+ result = exec_util.exec(["gem", "install", name, "--version", requirements.join(",")])
157
212
  if result.error?
158
213
  raise InstallFailedError, "Failed to install gem #{name}"
159
214
  end
@@ -167,6 +222,69 @@ module Toys
167
222
  end
168
223
  raise ActivationFailedError, err.message
169
224
  end
225
+
226
+ def find_gemfile(search_dirs)
227
+ search_dirs.each do |dir|
228
+ gemfile_path = ::File.join(dir, "Gemfile")
229
+ return gemfile_path if ::File.readable?(gemfile_path)
230
+ end
231
+ raise GemfileNotFoundError, "Gemfile not found"
232
+ end
233
+
234
+ def configure_gemfile(gemfile_path)
235
+ old_path = ::ENV["BUNDLE_GEMFILE"]
236
+ if old_path && gemfile_path != old_path
237
+ case @on_conflict
238
+ when :warn
239
+ terminal.puts("Warning: could not set up bundler because it is already set up.", :red)
240
+ when :error
241
+ raise AlreadyBundledError, "Could not set up bundler because it is already set up"
242
+ end
243
+ return false
244
+ end
245
+ ::ENV["BUNDLE_GEMFILE"] = gemfile_path
246
+ true
247
+ end
248
+
249
+ def setup_bundle(gemfile_path, groups)
250
+ require "bundler"
251
+ begin
252
+ ::Bundler.setup(*groups)
253
+ rescue ::Bundler::GemNotFound
254
+ restore_toys_libs
255
+ install_bundle(gemfile_path)
256
+ ::Bundler.reset!
257
+ ::Bundler.setup(*groups)
258
+ end
259
+ restore_toys_libs
260
+ end
261
+
262
+ def restore_toys_libs
263
+ $LOAD_PATH.unshift(::Toys::CORE_LIB_PATH)
264
+ $LOAD_PATH.unshift(::Toys::LIB_PATH) if ::Toys.const_defined?(:LIB_PATH)
265
+ end
266
+
267
+ def permission_to_bundle?
268
+ case @on_missing
269
+ when :install
270
+ true
271
+ when :error
272
+ false
273
+ else
274
+ terminal.confirm("Your bundle is not complete. Install? ", default: @default_confirm)
275
+ end
276
+ end
277
+
278
+ def install_bundle(gemfile_path)
279
+ gemfile_dir = ::File.dirname(gemfile_path)
280
+ unless permission_to_bundle?
281
+ raise BundleNotInstalledError,
282
+ "Your bundle is not installed. Consider running" \
283
+ " `cd #{gemfile_dir} && bundle install`"
284
+ end
285
+ require "bundler/cli"
286
+ ::Bundler::CLI.start(["install"])
287
+ end
170
288
  end
171
289
  end
172
290
  end
@@ -1,26 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Copyright 2019 Daniel Azuma
4
- #
5
- # Permission is hereby granted, free of charge, to any person obtaining a copy
6
- # of this software and associated documentation files (the "Software"), to deal
7
- # in the Software without restriction, including without limitation the rights
8
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- # copies of the Software, and to permit persons to whom the Software is
10
- # furnished to do so, subject to the following conditions:
11
- #
12
- # The above copyright notice and this permission notice shall be included in
13
- # all copies or substantial portions of the Software.
14
- #
15
- # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20
- # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
21
- # IN THE SOFTWARE.
22
- ;
23
-
24
3
  module Toys
25
4
  module Utils
26
5
  ##
@@ -1,26 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Copyright 2019 Daniel Azuma
4
- #
5
- # Permission is hereby granted, free of charge, to any person obtaining a copy
6
- # of this software and associated documentation files (the "Software"), to deal
7
- # in the Software without restriction, including without limitation the rights
8
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- # copies of the Software, and to permit persons to whom the Software is
10
- # furnished to do so, subject to the following conditions:
11
- #
12
- # The above copyright notice and this permission notice shall be included in
13
- # all copies or substantial portions of the Software.
14
- #
15
- # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20
- # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
21
- # IN THE SOFTWARE.
22
- ;
23
-
24
3
  require "stringio"
25
4
  require "monitor"
26
5
 
@@ -146,6 +125,8 @@ module Toys
146
125
  styled ? true : false
147
126
  end
148
127
  @named_styles = BUILTIN_STYLE_NAMES.dup
128
+ @output_mutex = ::Monitor.new
129
+ @input_mutex = ::Monitor.new
149
130
  end
150
131
 
151
132
  ##
@@ -164,7 +145,7 @@ module Toys
164
145
  # Whether output is styled
165
146
  # @return [Boolean]
166
147
  #
167
- attr_accessor :styled
148
+ attr_reader :styled
168
149
 
169
150
  ##
170
151
  # Write a partial line without appending a newline.
@@ -175,11 +156,41 @@ module Toys
175
156
  # @return [self]
176
157
  #
177
158
  def write(str = "", *styles)
178
- output&.write(apply_styles(str, *styles))
179
- output&.flush
159
+ @output_mutex.synchronize do
160
+ begin
161
+ output&.write(apply_styles(str, *styles))
162
+ output&.flush
163
+ rescue ::IOError
164
+ nil
165
+ end
166
+ end
180
167
  self
181
168
  end
182
169
 
170
+ ##
171
+ # Read a line, blocking until one is available.
172
+ #
173
+ # @return [String] the entire string including the temrinating newline
174
+ # @return [nil] if the input is closed or at eof, or there is no input
175
+ #
176
+ def readline
177
+ @input_mutex.synchronize do
178
+ begin
179
+ input&.gets
180
+ rescue ::IOError
181
+ nil
182
+ end
183
+ end
184
+ end
185
+
186
+ ##
187
+ # This method is defined so that `::Logger` will recognize a terminal as
188
+ # a log device target, but it does not actually close anything.
189
+ #
190
+ def close
191
+ nil
192
+ end
193
+
183
194
  ##
184
195
  # Write a line, appending a newline if one is not already present.
185
196
  #
@@ -233,7 +244,7 @@ module Toys
233
244
  prompt = "#{ptext} #{trailing_text}#{pspaces}"
234
245
  end
235
246
  write(prompt, *styles)
236
- resp = input&.gets.to_s.chomp
247
+ resp = readline.to_s.chomp
237
248
  resp.empty? ? default.to_s : resp
238
249
  end
239
250
 
@@ -296,13 +307,13 @@ module Toys
296
307
  return nil unless block_given?
297
308
  frame_length ||= DEFAULT_SPINNER_FRAME_LENGTH
298
309
  frames ||= DEFAULT_SPINNER_FRAMES
299
- output.write(leading_text) unless leading_text.empty?
310
+ write(leading_text) unless leading_text.empty?
300
311
  spin = SpinDriver.new(self, frames, Array(style), frame_length)
301
312
  begin
302
313
  yield
303
314
  ensure
304
315
  spin.stop
305
- output.write(final_text) unless final_text.empty?
316
+ write(final_text) unless final_text.empty?
306
317
  end
307
318
  end
308
319
 
@@ -312,8 +323,8 @@ module Toys
312
323
  # @return [Array(Integer,Integer)]
313
324
  #
314
325
  def size
315
- if @output.respond_to?(:tty?) && @output.tty? && @output.respond_to?(:winsize)
316
- @output.winsize.reverse
326
+ if output.respond_to?(:tty?) && output.tty? && output.respond_to?(:winsize)
327
+ output.winsize.reverse
317
328
  else
318
329
  [80, 25]
319
330
  end
@@ -422,10 +433,8 @@ module Toys
422
433
 
423
434
  ## @private
424
435
  class SpinDriver
425
- include ::MonitorMixin
426
-
427
436
  def initialize(terminal, frames, style, frame_length)
428
- super()
437
+ @mutex = ::Monitor.new
429
438
  @terminal = terminal
430
439
  @frames = frames.map do |f|
431
440
  [@terminal.apply_styles(f, *style), Terminal.remove_style_escapes(f).size]
@@ -433,12 +442,12 @@ module Toys
433
442
  @frame_length = frame_length
434
443
  @cur_frame = 0
435
444
  @stopping = false
436
- @cond = new_cond
445
+ @cond = @mutex.new_cond
437
446
  @thread = @terminal.output.tty? ? start_thread : nil
438
447
  end
439
448
 
440
449
  def stop
441
- synchronize do
450
+ @mutex.synchronize do
442
451
  @stopping = true
443
452
  @cond.broadcast
444
453
  end
@@ -450,12 +459,12 @@ module Toys
450
459
 
451
460
  def start_thread
452
461
  ::Thread.new do
453
- synchronize do
462
+ @mutex.synchronize do
454
463
  until @stopping
455
- @terminal.output.write(@frames[@cur_frame][0])
464
+ @terminal.write(@frames[@cur_frame][0])
456
465
  @cond.wait(@frame_length)
457
466
  size = @frames[@cur_frame][1]
458
- @terminal.output.write("\b" * size + " " * size + "\b" * size)
467
+ @terminal.write("\b" * size + " " * size + "\b" * size)
459
468
  @cur_frame += 1
460
469
  @cur_frame = 0 if @cur_frame >= @frames.size
461
470
  end