dry-cli 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,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'dry/cli/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'dry-cli'
9
+ spec.version = Dry::CLI::VERSION
10
+ spec.authors = ['Luca Guidi']
11
+ spec.email = ['me@lucaguidi.com']
12
+ spec.licenses = ['MIT']
13
+
14
+ spec.summary = 'Dry CLI'
15
+ spec.description = 'Common framework to build command line interfaces with Ruby'
16
+ spec.homepage = 'http://dry-rb.org'
17
+
18
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
19
+ spec.metadata['source_code_uri'] = 'https://github.com/dry-rb/dry-cli'
20
+
21
+ spec.bindir = 'exe'
22
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
23
+ spec.require_paths = ['lib']
24
+
25
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
26
+ f.match(%r{^(test|spec|features)/})
27
+ end
28
+
29
+ spec.add_dependency 'concurrent-ruby', '~> 1.0'
30
+ spec.add_dependency 'hanami-utils', '~> 1.3'
31
+
32
+ spec.add_development_dependency 'bundler', '>= 1.6', '< 3'
33
+ spec.add_development_dependency 'rake', '~> 13.0'
34
+ spec.add_development_dependency 'rspec', '~> 3.7'
35
+ spec.add_development_dependency 'simplecov', '~> 0.17.1'
36
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Dry
4
+ #
5
+ # @since 0.1.0
6
+ module Dry
7
+ # General purpose Command Line Interface (CLI) framework for Ruby
8
+ #
9
+ # @since 0.1.0
10
+ class CLI
11
+ require 'dry/cli/version'
12
+ require 'dry/cli/errors'
13
+ require 'dry/cli/command'
14
+ require 'dry/cli/registry'
15
+ require 'dry/cli/parser'
16
+ require 'dry/cli/usage'
17
+ require 'dry/cli/banner'
18
+
19
+ # Check if command
20
+ #
21
+ # @param command [Object] the command to check
22
+ #
23
+ # @return [TrueClass,FalseClass] true if instance of `Dry::CLI::Command`
24
+ #
25
+ # @since 0.1.0
26
+ # @api private
27
+ def self.command?(command)
28
+ case command
29
+ when Class
30
+ command.ancestors.include?(Command)
31
+ else
32
+ command.is_a?(Command)
33
+ end
34
+ end
35
+
36
+ # Create a new instance
37
+ #
38
+ # @param registry [Dry::CLI::Registry] a registry
39
+ #
40
+ # @return [Dry::CLI] the new instance
41
+ # @since 0.1.0
42
+ def initialize(registry)
43
+ @commands = registry
44
+ end
45
+
46
+ # Invoke the CLI
47
+ #
48
+ # @param arguments [Array<string>] the command line arguments (defaults to `ARGV`)
49
+ # @param out [IO] the standard output (defaults to `$stdout`)
50
+ #
51
+ # @since 0.1.0
52
+ def call(arguments: ARGV, out: $stdout)
53
+ result = commands.get(arguments)
54
+
55
+ if result.found?
56
+ command, args = parse(result, out)
57
+
58
+ result.before_callbacks.run(command, args)
59
+ command.call(args)
60
+ result.after_callbacks.run(command, args)
61
+ else
62
+ usage(result, out)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ # @since 0.1.0
69
+ # @api private
70
+ attr_reader :commands
71
+
72
+ # Parse arguments for a command.
73
+ #
74
+ # It may exit in case of error, or in case of help.
75
+ #
76
+ # @param result [Dry::CLI::CommandRegistry::LookupResult]
77
+ # @param out [IO] sta output
78
+ #
79
+ # @return [Array<Dry:CLI::Command, Array>] returns an array where the
80
+ # first element is a command and the second one is the list of arguments
81
+ #
82
+ # @since 0.1.0
83
+ # @api private
84
+ def parse(result, out)
85
+ command = result.command
86
+ return [command, result.arguments] unless command?(command)
87
+
88
+ result = Parser.call(command, result.arguments, result.names)
89
+
90
+ if result.help?
91
+ Banner.call(command, out)
92
+ exit(0)
93
+ end
94
+
95
+ if result.error?
96
+ out.puts(result.error)
97
+ exit(1)
98
+ end
99
+
100
+ [command, result.arguments]
101
+ end
102
+
103
+ # Prints the command usage and exit.
104
+ #
105
+ # @param result [Dry::CLI::CommandRegistry::LookupResult]
106
+ # @param out [IO] sta output
107
+ #
108
+ # @since 0.1.0
109
+ # @api private
110
+ def usage(result, out)
111
+ Usage.call(result, out)
112
+ exit(1)
113
+ end
114
+
115
+ # Check if command
116
+ #
117
+ # @param command [Object] the command to check
118
+ #
119
+ # @return [TrueClass,FalseClass] true if instance of `Dry::CLI::Command`
120
+ #
121
+ # @since 0.1.0
122
+ # @api private
123
+ #
124
+ # @see .command?
125
+ def command?(command)
126
+ CLI.command?(command)
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli/program_name'
4
+
5
+ module Dry
6
+ class CLI
7
+ # Command banner
8
+ #
9
+ # @since 0.1.0
10
+ # @api private
11
+ module Banner
12
+ # Prints command banner
13
+ #
14
+ # @param command [Dry::CLI::Command] the command
15
+ # @param out [IO] standard output
16
+ #
17
+ # @since 0.1.0
18
+ # @api private
19
+ def self.call(command, out)
20
+ output = [
21
+ command_name(command),
22
+ command_name_and_arguments(command),
23
+ command_description(command),
24
+ command_arguments(command),
25
+ command_options(command),
26
+ command_examples(command)
27
+ ].compact.join("\n")
28
+
29
+ out.puts output
30
+ end
31
+
32
+ # @since 0.1.0
33
+ # @api private
34
+ def self.command_name(command)
35
+ "Command:\n #{full_command_name(command)}"
36
+ end
37
+
38
+ # @since 0.1.0
39
+ # @api private
40
+ def self.command_name_and_arguments(command)
41
+ "\nUsage:\n #{full_command_name(command)}#{arguments(command)}"
42
+ end
43
+
44
+ # @since 0.1.0
45
+ # @api private
46
+ def self.command_examples(command)
47
+ return if command.examples.empty?
48
+
49
+ "\nExamples:\n#{command.examples.map { |example| " #{full_command_name(command)} #{example}" }.join("\n")}" # rubocop:disable Metrics/LineLength
50
+ end
51
+
52
+ # @since 0.1.0
53
+ # @api private
54
+ def self.command_description(command)
55
+ return if command.description.nil?
56
+
57
+ "\nDescription:\n #{command.description}"
58
+ end
59
+
60
+ # @since 0.1.0
61
+ # @api private
62
+ def self.command_arguments(command)
63
+ return if command.arguments.empty?
64
+
65
+ "\nArguments:\n#{extended_command_arguments(command)}"
66
+ end
67
+
68
+ # @since 0.1.0
69
+ # @api private
70
+ def self.command_options(command)
71
+ "\nOptions:\n#{extended_command_options(command)}"
72
+ end
73
+
74
+ # @since 0.1.0
75
+ # @api private
76
+ def self.full_command_name(command)
77
+ ProgramName.call(command.command_name)
78
+ end
79
+
80
+ # @since 0.1.0
81
+ # @api private
82
+ def self.arguments(command)
83
+ required_arguments = command.required_arguments
84
+ optional_arguments = command.optional_arguments
85
+
86
+ required = required_arguments.map { |arg| arg.name.upcase }.join(' ') if required_arguments.any? # rubocop:disable Metrics/LineLength
87
+ optional = optional_arguments.map { |arg| "[#{arg.name.upcase}]" }.join(' ') if optional_arguments.any? # rubocop:disable Metrics/LineLength
88
+ result = [required, optional].compact
89
+
90
+ " #{result.join(' ')}" unless result.empty?
91
+ end
92
+
93
+ # @since 0.1.0
94
+ # @api private
95
+ def self.extended_command_arguments(command)
96
+ command.arguments.map do |argument|
97
+ " #{argument.name.to_s.upcase.ljust(20)}\t# #{'REQUIRED ' if argument.required?}#{argument.desc}" # rubocop:disable Metrics/LineLength
98
+ end.join("\n")
99
+ end
100
+
101
+ # @since 0.1.0
102
+ # @api private
103
+ #
104
+ # rubocop:disable Metrics/AbcSize
105
+ def self.extended_command_options(command)
106
+ result = command.options.map do |option|
107
+ name = Hanami::Utils::String.dasherize(option.name)
108
+ name = if option.boolean?
109
+ "[no-]#{name}"
110
+ else
111
+ "#{name}=VALUE"
112
+ end
113
+
114
+ name = "#{name}, #{option.aliases.map { |a| a.start_with?('--') ? "#{a}=VALUE" : "#{a} VALUE" }.join(', ')}" unless option.aliases.empty? # rubocop:disable Metrics/LineLength
115
+ name = " --#{name.ljust(30)}"
116
+ name = "#{name}\t# #{option.desc}"
117
+ name = "#{name}, default: #{option.default.inspect}" unless option.default.nil?
118
+ name
119
+ end
120
+
121
+ result << " --#{'help, -h'.ljust(30)}\t# Print this help"
122
+ result.join("\n")
123
+ end
124
+ # rubocop:enable Metrics/AbcSize
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'concurrent/array'
5
+ require 'dry/cli/option'
6
+
7
+ module Dry
8
+ class CLI
9
+ # Base class for commands
10
+ #
11
+ # @since 0.1.0
12
+ class Command
13
+ # @since 0.1.0
14
+ # @api private
15
+ def self.inherited(base)
16
+ super
17
+ base.extend ClassMethods
18
+ end
19
+
20
+ # @since 0.1.0
21
+ # @api private
22
+ module ClassMethods
23
+ # @since 0.1.0
24
+ # @api private
25
+ def self.extended(base)
26
+ super
27
+
28
+ base.class_eval do
29
+ @description = nil
30
+ @examples = Concurrent::Array.new
31
+ @arguments = Concurrent::Array.new
32
+ @options = Concurrent::Array.new
33
+ end
34
+ end
35
+
36
+ # @since 0.1.0
37
+ # @api private
38
+ attr_reader :description
39
+
40
+ # @since 0.1.0
41
+ # @api private
42
+ attr_reader :examples
43
+
44
+ # @since 0.1.0
45
+ # @api private
46
+ attr_reader :arguments
47
+
48
+ # @since 0.1.0
49
+ # @api private
50
+ attr_reader :options
51
+ end
52
+
53
+ # Set the description of the command
54
+ #
55
+ # @param description [String] the description
56
+ #
57
+ # @since 0.1.0
58
+ #
59
+ # @example
60
+ # require "dry/cli"
61
+ #
62
+ # class Echo < Dry::CLI::Command
63
+ # desc "Prints given input"
64
+ #
65
+ # def call(*)
66
+ # # ...
67
+ # end
68
+ # end
69
+ def self.desc(description)
70
+ @description = description
71
+ end
72
+
73
+ # Describe the usage of the command
74
+ #
75
+ # @param examples [Array<String>] one or more examples
76
+ #
77
+ # @since 0.1.0
78
+ #
79
+ # @example
80
+ # require "dry/cli"
81
+ #
82
+ # class Server < Dry::CLI::Command
83
+ # example [
84
+ # " # Basic usage (it uses the bundled server engine)",
85
+ # "--server=webrick # Force `webrick` server engine",
86
+ # "--host=0.0.0.0 # Bind to a host",
87
+ # "--port=2306 # Bind to a port",
88
+ # "--no-code-reloading # Disable code reloading"
89
+ # ]
90
+ #
91
+ # def call(*)
92
+ # # ...
93
+ # end
94
+ # end
95
+ #
96
+ # # $ foo server --help
97
+ # # # ...
98
+ # #
99
+ # # Examples:
100
+ # # foo server # Basic usage (it uses the bundled server engine)
101
+ # # foo server --server=webrick # Force `webrick` server engine
102
+ # # foo server --host=0.0.0.0 # Bind to a host
103
+ # # foo server --port=2306 # Bind to a port
104
+ # # foo server --no-code-reloading # Disable code reloading
105
+ def self.example(*examples)
106
+ @examples += examples.flatten
107
+ end
108
+
109
+ # Specify an argument
110
+ #
111
+ # @param name [Symbol] the argument name
112
+ # @param options [Hash] a set of options
113
+ #
114
+ # @since 0.1.0
115
+ #
116
+ # @example Optional argument
117
+ # require "dry/cli"
118
+ #
119
+ # class Hello < Dry::CLI::Command
120
+ # argument :name
121
+ #
122
+ # def call(name: nil, **)
123
+ # if name.nil?
124
+ # puts "Hello, stranger"
125
+ # else
126
+ # puts "Hello, #{name}"
127
+ # end
128
+ # end
129
+ # end
130
+ #
131
+ # # $ foo hello
132
+ # # Hello, stranger
133
+ #
134
+ # # $ foo hello Luca
135
+ # # Hello, Luca
136
+ #
137
+ # @example Required argument
138
+ # require "dry/cli"
139
+ #
140
+ # class Hello < Dry::CLI::Command
141
+ # argument :name, required: true
142
+ #
143
+ # def call(name:, **)
144
+ # puts "Hello, #{name}"
145
+ # end
146
+ # end
147
+ #
148
+ # # $ foo hello Luca
149
+ # # Hello, Luca
150
+ #
151
+ # # $ foo hello
152
+ # # ERROR: "foo hello" was called with no arguments
153
+ # # Usage: "foo hello NAME"
154
+ #
155
+ # @example Multiple arguments
156
+ # require "dry/cli"
157
+ #
158
+ # module Generate
159
+ # class Action < Dry::CLI::Command
160
+ # argument :app, required: true
161
+ # argument :action, required: true
162
+ #
163
+ # def call(app:, action:, **)
164
+ # puts "Generating action: #{action} for app: #{app}"
165
+ # end
166
+ # end
167
+ # end
168
+ #
169
+ # # $ foo generate action web home
170
+ # # Generating action: home for app: web
171
+ #
172
+ # # $ foo generate action
173
+ # # ERROR: "foo generate action" was called with no arguments
174
+ # # Usage: "foo generate action APP ACTION"
175
+ #
176
+ # @example Description
177
+ # require "dry/cli"
178
+ #
179
+ # class Hello < Dry::CLI::Command
180
+ # argument :name, desc: "The name of the person to greet"
181
+ #
182
+ # def call(name: nil, **)
183
+ # # ...
184
+ # end
185
+ # end
186
+ #
187
+ # # $ foo hello --help
188
+ # # Command:
189
+ # # foo hello
190
+ # #
191
+ # # Usage:
192
+ # # foo hello [NAME]
193
+ # #
194
+ # # Arguments:
195
+ # # NAME # The name of the person to greet
196
+ # #
197
+ # # Options:
198
+ # # --help, -h # Print this help
199
+ def self.argument(name, options = {})
200
+ @arguments << Argument.new(name, options)
201
+ end
202
+
203
+ # Command line option (aka optional argument)
204
+ #
205
+ # @param name [Symbol] the param name
206
+ # @param options [Hash] a set of options
207
+ #
208
+ # @since 0.1.0
209
+ #
210
+ # @example Basic usage
211
+ # require "dry/cli"
212
+ #
213
+ # class Console < Dry::CLI::Command
214
+ # param :engine
215
+ #
216
+ # def call(engine: nil, **)
217
+ # puts "starting console (engine: #{engine || :irb})"
218
+ # end
219
+ # end
220
+ #
221
+ # # $ foo console
222
+ # # starting console (engine: irb)
223
+ #
224
+ # # $ foo console --engine=pry
225
+ # # starting console (engine: pry)
226
+ #
227
+ # @example List values
228
+ # require "dry/cli"
229
+ #
230
+ # class Console < Dry::CLI::Command
231
+ # param :engine, values: %w(irb pry ripl)
232
+ #
233
+ # def call(engine: nil, **)
234
+ # puts "starting console (engine: #{engine || :irb})"
235
+ # end
236
+ # end
237
+ #
238
+ # # $ foo console
239
+ # # starting console (engine: irb)
240
+ #
241
+ # # $ foo console --engine=pry
242
+ # # starting console (engine: pry)
243
+ #
244
+ # # $ foo console --engine=foo
245
+ # # Error: Invalid param provided
246
+ #
247
+ # @example Description
248
+ # require "dry/cli"
249
+ #
250
+ # class Console < Dry::CLI::Command
251
+ # param :engine, desc: "Force a console engine"
252
+ #
253
+ # def call(engine: nil, **)
254
+ # # ...
255
+ # end
256
+ # end
257
+ #
258
+ # # $ foo console --help
259
+ # # # ...
260
+ # #
261
+ # # Options:
262
+ # # --engine=VALUE # Force a console engine: (irb/pry/ripl)
263
+ # # --help, -h # Print this help
264
+ #
265
+ # @example Boolean
266
+ # require "dry/cli"
267
+ #
268
+ # class Server < Dry::CLI::Command
269
+ # param :code_reloading, type: :boolean, default: true
270
+ #
271
+ # def call(code_reloading:, **)
272
+ # puts "staring server (code reloading: #{code_reloading})"
273
+ # end
274
+ # end
275
+ #
276
+ # # $ foo server
277
+ # # starting server (code reloading: true)
278
+ #
279
+ # # $ foo server --no-code-reloading
280
+ # # starting server (code reloading: false)
281
+ #
282
+ # # $ foo server --help
283
+ # # # ...
284
+ # #
285
+ # # Options:
286
+ # # --[no]-code-reloading
287
+ #
288
+ # @example Aliases
289
+ # require "dry/cli"
290
+ #
291
+ # class Server < Dry::CLI::Command
292
+ # param :port, aliases: ["-p"]
293
+ #
294
+ # def call(options)
295
+ # puts "staring server (port: #{options.fetch(:port, 2300)})"
296
+ # end
297
+ # end
298
+ #
299
+ # # $ foo server
300
+ # # starting server (port: 2300)
301
+ #
302
+ # # $ foo server --port=2306
303
+ # # starting server (port: 2306)
304
+ #
305
+ # # $ foo server -p 2306
306
+ # # starting server (port: 2306)
307
+ #
308
+ # # $ foo server --help
309
+ # # # ...
310
+ # #
311
+ # # Options:
312
+ # # --port=VALUE, -p VALUE
313
+ def self.option(name, options = {})
314
+ @options << Option.new(name, options)
315
+ end
316
+
317
+ # @since 0.1.0
318
+ # @api private
319
+ def self.params
320
+ (@arguments + @options).uniq
321
+ end
322
+
323
+ # @since 0.1.0
324
+ # @api private
325
+ def self.default_params
326
+ params.each_with_object({}) do |param, result|
327
+ result[param.name] = param.default unless param.default.nil?
328
+ end
329
+ end
330
+
331
+ # @since 0.1.0
332
+ # @api private
333
+ def self.required_arguments
334
+ arguments.select(&:required?)
335
+ end
336
+
337
+ # @since 0.1.0
338
+ # @api private
339
+ def self.optional_arguments
340
+ arguments.reject(&:required?)
341
+ end
342
+
343
+ extend Forwardable
344
+
345
+ delegate %i[
346
+ description
347
+ examples
348
+ arguments
349
+ options
350
+ params
351
+ default_params
352
+ required_arguments
353
+ optional_arguments
354
+ ] => 'self.class'
355
+
356
+ # @since 0.1.0
357
+ # @api private
358
+ attr_reader :command_name
359
+
360
+ # @since 0.1.0
361
+ # @api private
362
+ def initialize(command_name:, **)
363
+ @command_name = command_name
364
+ end
365
+ end
366
+ end
367
+ end