command_mapper 0.1.0.pre1

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 (55) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ruby.yml +27 -0
  3. data/.gitignore +10 -0
  4. data/.rspec +1 -0
  5. data/.yardopts +1 -0
  6. data/ChangeLog.md +25 -0
  7. data/Gemfile +15 -0
  8. data/LICENSE.txt +20 -0
  9. data/README.md +369 -0
  10. data/Rakefile +12 -0
  11. data/commnad_mapper.gemspec +61 -0
  12. data/gemspec.yml +23 -0
  13. data/lib/command_mapper/arg.rb +75 -0
  14. data/lib/command_mapper/argument.rb +142 -0
  15. data/lib/command_mapper/command.rb +606 -0
  16. data/lib/command_mapper/exceptions.rb +19 -0
  17. data/lib/command_mapper/option.rb +282 -0
  18. data/lib/command_mapper/option_value.rb +21 -0
  19. data/lib/command_mapper/sudo.rb +73 -0
  20. data/lib/command_mapper/types/enum.rb +35 -0
  21. data/lib/command_mapper/types/hex.rb +82 -0
  22. data/lib/command_mapper/types/input_dir.rb +35 -0
  23. data/lib/command_mapper/types/input_file.rb +35 -0
  24. data/lib/command_mapper/types/input_path.rb +29 -0
  25. data/lib/command_mapper/types/key_value.rb +131 -0
  26. data/lib/command_mapper/types/key_value_list.rb +45 -0
  27. data/lib/command_mapper/types/list.rb +90 -0
  28. data/lib/command_mapper/types/map.rb +64 -0
  29. data/lib/command_mapper/types/num.rb +50 -0
  30. data/lib/command_mapper/types/str.rb +85 -0
  31. data/lib/command_mapper/types/type.rb +102 -0
  32. data/lib/command_mapper/types.rb +6 -0
  33. data/lib/command_mapper/version.rb +4 -0
  34. data/lib/command_mapper.rb +2 -0
  35. data/spec/arg_spec.rb +137 -0
  36. data/spec/argument_spec.rb +513 -0
  37. data/spec/commnad_spec.rb +1175 -0
  38. data/spec/exceptions_spec.rb +14 -0
  39. data/spec/option_spec.rb +882 -0
  40. data/spec/option_value_spec.rb +17 -0
  41. data/spec/spec_helper.rb +6 -0
  42. data/spec/sudo_spec.rb +24 -0
  43. data/spec/types/enum_spec.rb +31 -0
  44. data/spec/types/hex_spec.rb +158 -0
  45. data/spec/types/input_dir_spec.rb +30 -0
  46. data/spec/types/input_file_spec.rb +34 -0
  47. data/spec/types/input_path_spec.rb +32 -0
  48. data/spec/types/key_value_list_spec.rb +100 -0
  49. data/spec/types/key_value_spec.rb +272 -0
  50. data/spec/types/list_spec.rb +143 -0
  51. data/spec/types/map_spec.rb +62 -0
  52. data/spec/types/num_spec.rb +90 -0
  53. data/spec/types/str_spec.rb +232 -0
  54. data/spec/types/type_spec.rb +59 -0
  55. metadata +118 -0
@@ -0,0 +1,606 @@
1
+ require 'command_mapper/types'
2
+ require 'command_mapper/argument'
3
+ require 'command_mapper/option'
4
+
5
+ require 'shellwords'
6
+
7
+ module CommandMapper
8
+ class Command
9
+
10
+ include Types
11
+
12
+ # The command name.
13
+ #
14
+ # @return [String]
15
+ attr_reader :command_name
16
+
17
+ # The optional path to the command.
18
+ #
19
+ # @return [String, nil]
20
+ attr_reader :command_path
21
+
22
+ # The environment variables to execute the command with.
23
+ #
24
+ # @return [Hash{String => String}]
25
+ attr_reader :command_env
26
+
27
+ # The option values to execute the command with.
28
+ #
29
+ # @return [Hash{String => Object}]
30
+ attr_reader :command_options
31
+
32
+ # The argument values to execute the command with.
33
+ #
34
+ # @return [Hash{String => Object}]
35
+ attr_reader :command_arguments
36
+
37
+ # The subcommand's options and arguments.
38
+ #
39
+ # @return [Command, nil]
40
+ attr_reader :command_subcommand
41
+
42
+ #
43
+ # Initializes the command.
44
+ #
45
+ # @param [Hash{Symbol => Object}] params
46
+ # The option and argument values.
47
+ #
48
+ # @param [String] command_name
49
+ # Overrides the command with a custom command name.
50
+ #
51
+ # @param [String, nil] command_path
52
+ # Overrides the command with a custom path to the command.
53
+ #
54
+ # @param [Hash{String => String}] env
55
+ # Custom environment variables to pass to the command.
56
+ #
57
+ # @param [Hash{Symbol => Object}] kwargs
58
+ # Additional keywords arguments. These will be used to populate
59
+ # {#options} and {#arguments}, along with `params`.
60
+ #
61
+ # @yield [self]
62
+ # The newly initialized command.
63
+ #
64
+ # @yieldparam [Command] self
65
+ #
66
+ # @example with a symbol Hash
67
+ # MyCommand.new({foo: 'bar', baz: 'qux'})
68
+ #
69
+ # @example with a keyword arguments
70
+ # MyCommand.new(foo: 'bar', baz: 'qux')
71
+ #
72
+ # @example with a custom env Hash:
73
+ # MyCommand.new({foo: 'bar', baz: 'qux'}, env: {'FOO' =>'bar'})
74
+ # MyCommand.new(foo: 'bar', baz: 'qux', env: {'FOO' => 'bar'})
75
+ #
76
+ def initialize(params={}, command_name: self.class.command_name,
77
+ command_path: nil,
78
+ command_env: {},
79
+ **kwargs)
80
+ @command_name = command_name
81
+ @command_path = command_path
82
+ @command_env = command_env
83
+
84
+ params = params.merge(kwargs)
85
+
86
+ params.each do |name,value|
87
+ self[name] = value
88
+ end
89
+
90
+ yield self if block_given?
91
+ end
92
+
93
+ #
94
+ # Initializes and runs the command.
95
+ #
96
+ # @param [Hash{Symbol => Object}] params
97
+ # The option values.
98
+ #
99
+ # @yield [self]
100
+ # The newly initialized command.
101
+ #
102
+ # @yieldparam [Command] self
103
+ #
104
+ # @return [Boolean, nil]
105
+ #
106
+ def self.run(params={},**kwargs,&block)
107
+ command = new(params,**kwargs,&block)
108
+ command.run_command
109
+ end
110
+
111
+ #
112
+ # Runs the command in a shell and captures all stdout output.
113
+ #
114
+ # @param [Hash{Symbol => Object}] params
115
+ # The option values.
116
+ #
117
+ # @yield [self]
118
+ # The newly initialized command.
119
+ #
120
+ # @yieldparam [Command] self
121
+ #
122
+ # @return [String]
123
+ # The stdout output of the command.
124
+ #
125
+ def self.capture(params={},**kwargs,&block)
126
+ command = new(params,**kwargs,&block)
127
+ command.capture_command
128
+ end
129
+
130
+ #
131
+ # Executes the command and returns an IO object to it.
132
+ #
133
+ # @param [Hash{Symbol => Object}] params
134
+ # The option values.
135
+ #
136
+ # @yield [self]
137
+ # The newly initialized command.
138
+ #
139
+ # @yieldparam [Command] self
140
+ #
141
+ # @return [IO]
142
+ #
143
+ def self.popen(params={}, mode: 'r', **kwargs,&block)
144
+ command = new(params,**kwargs,&block)
145
+ command.popen_command
146
+ end
147
+
148
+ #
149
+ # Initializes and runs the command through sudo.
150
+ #
151
+ # @param [Hash{Symbol => Object}] params
152
+ # The option values.
153
+ #
154
+ # @param [Hash{Symbol => Object}] kwargs
155
+ # Additional keyword arguments for {#initialize}.
156
+ #
157
+ # @yield [self]
158
+ # The newly initialized command.
159
+ #
160
+ # @yieldparam [Command] self
161
+ #
162
+ # @return [Boolean, nil]
163
+ #
164
+ def self.sudo(params={}, sudo: {}, **kwargs,&block)
165
+ command = new(params,**kwargs,&block)
166
+ command.sudo_command(**sudo)
167
+ end
168
+
169
+ #
170
+ # Gets or sets the command name.
171
+ #
172
+ # @param [#to_s] new_name
173
+ # The optional new command name.
174
+ #
175
+ # @return [String]
176
+ # The command name.
177
+ #
178
+ # @raise [NotImplementedError]
179
+ # The command class did not call {command}.
180
+ #
181
+ # @api semipublic
182
+ #
183
+ def self.command_name
184
+ @command_name || raise(NotImplementedError,"#{self} did not call command(...)")
185
+ end
186
+
187
+ #
188
+ # @param [#to_s] new_command_name
189
+ #
190
+ # @yield [self]
191
+ #
192
+ # @example
193
+ # command 'grep'
194
+ # # ...
195
+ #
196
+ # @example
197
+ # command 'grep' do
198
+ # option "--regexp", equals: true, value: true
199
+ # # ...
200
+ # end
201
+ #
202
+ # @api public
203
+ #
204
+ def self.command(new_command_name,&block)
205
+ @command_name = new_command_name.to_s.freeze
206
+ yield self if block_given?
207
+ end
208
+
209
+ #
210
+ # All defined options.
211
+ #
212
+ # @return [Hash{Symbol => Option}]
213
+ #
214
+ # @api semipublic
215
+ #
216
+ def self.options
217
+ @options ||= if superclass < Command
218
+ superclass.options.dup
219
+ else
220
+ {}
221
+ end
222
+ end
223
+
224
+ #
225
+ # Defines an option for the command.
226
+ #
227
+ # @param [String] flag
228
+ # The option's command-line flag.
229
+ #
230
+ # @param [Symbol, nil] name
231
+ # The option's name.
232
+ #
233
+ # @param [Boolean] equals
234
+ # Specifies whether the option's flag and value should be separated with a
235
+ # `=` character.
236
+ #
237
+ # @param [Hash, nil] value
238
+ # The option's value.
239
+ #
240
+ # @option value [Boolean] :required
241
+ # Specifies whether the option requires a value or not.
242
+ #
243
+ # @option value [Types:Type, Hash, nil] :type
244
+ # The explicit type for the option's value.
245
+ #
246
+ # @param [Boolean] repeats
247
+ # Specifies whether the option can be given multiple times.
248
+ #
249
+ # @api public
250
+ #
251
+ # @example Defining an option:
252
+ # option '--foo'
253
+ #
254
+ # @example Defining an option with a custom name:
255
+ # option '-F', name: :foo
256
+ #
257
+ # @example Defining an option who's value is required:
258
+ # option '--file', value: true
259
+ #
260
+ # @example Defining an option who's value is optional:
261
+ # option '--file', value: {required: false}
262
+ #
263
+ # @example Defining an `--opt=value` option:
264
+ # option '--foo', equals: true, value: true
265
+ #
266
+ # @example Defining an option that can be repeated multiple times:
267
+ # option '--foo', repeats: true
268
+ #
269
+ # @example Defining an option that takes a comma-separated list:
270
+ # option '--list', value: List.new
271
+ #
272
+ # @raise [ArgumentError]
273
+ # The option flag conflicts with a pre-existing internal method.
274
+ #
275
+ def self.option(flag, name: nil, equals: nil, value: nil, repeats: false, &block)
276
+ option = Option.new(flag, name: name,
277
+ equals: equals,
278
+ value: value,
279
+ repeats: repeats,
280
+ &block)
281
+
282
+ self.options[option.name] = option
283
+
284
+ if is_internal_method?(option.name)
285
+ if name
286
+ raise(ArgumentError,"option #{flag.inspect} with name #{name.inspect} cannot override the internal method with same name: ##{option.name}")
287
+ else
288
+ raise(ArgumentError,"option #{flag.inspect} maps to method name ##{option.name} and cannot override the internal method with same name: ##{option.name}")
289
+ end
290
+ end
291
+
292
+ attr_accessor option.name
293
+ end
294
+
295
+ #
296
+ # All defined options.
297
+ #
298
+ # @return [Hash{Symbol => Argument}]
299
+ #
300
+ # @api semipublic
301
+ #
302
+ def self.arguments
303
+ @arguments ||= if superclass < Command
304
+ superclass.arguments.dup
305
+ else
306
+ {}
307
+ end
308
+ end
309
+
310
+ #
311
+ # Defines an option for the command.
312
+ #
313
+ # @param [Symbol] name
314
+ #
315
+ # @param [Boolean] required
316
+ # Specifies whether the argument is required or can be omitted.
317
+ #
318
+ # @param [Types::Type, Hash, nil] type
319
+ # The explicit type for the argument.
320
+ #
321
+ # @param [Boolean] repeats
322
+ # Specifies whether the option can be repeated multiple times.
323
+ #
324
+ # @api public
325
+ #
326
+ # @example Define an argument:
327
+ # argument :file
328
+ #
329
+ # @example Define an argument that can be specified multiple times:
330
+ # argument :files, repeats: true
331
+ #
332
+ # @example Define an optional argument:
333
+ # argument :file, required: false
334
+ #
335
+ # @raise [ArgumentError]
336
+ # The argument name conflicts with a pre-existing internal method.
337
+ #
338
+ def self.argument(name, required: true, type: Str.new, repeats: false)
339
+ name = name.to_sym
340
+ argument = Argument.new(name, required: required,
341
+ type: type,
342
+ repeats: repeats)
343
+
344
+ self.arguments[argument.name] = argument
345
+
346
+ if is_internal_method?(argument.name)
347
+ raise(ArgumentError,"argument #{name.inspect} cannot override internal method with same name: ##{argument.name}")
348
+ end
349
+
350
+ attr_accessor name
351
+ end
352
+
353
+ #
354
+ # All defined subcommands.
355
+ #
356
+ # @return [Hash{Symbol => Command}]
357
+ #
358
+ # @api semipublic
359
+ #
360
+ def self.subcommands
361
+ @subcommands ||= if superclass < Command
362
+ superclass.subcommands.dup
363
+ else
364
+ {}
365
+ end
366
+ end
367
+
368
+ #
369
+ # Defines a subcommand.
370
+ #
371
+ # @param [String] name
372
+ # The name of the subcommand.
373
+ #
374
+ # @yield [subcommand]
375
+ # The given block will be used to populate the subcommand's options.
376
+ #
377
+ # @yieldparam [Command] subcommand
378
+ # The newly created subcommand class.
379
+ #
380
+ # @note
381
+ # Also defines a class within the command class using the subcommand's
382
+ # name.
383
+ #
384
+ # @example Defining a sub-command:
385
+ # class Git
386
+ # command 'git' do
387
+ # subcommand 'clone' do
388
+ # option '--bare'
389
+ # # ...
390
+ # end
391
+ # end
392
+ # end
393
+ #
394
+ # @raise [ArgumentError]
395
+ # The subcommand name conflicts with a pre-existing internal method.
396
+ #
397
+ def self.subcommand(name,&block)
398
+ name = name.to_s
399
+
400
+ subcommand_class = Class.new(Command)
401
+ subcommand_class.command(name)
402
+ subcommand_class.class_eval(&block)
403
+
404
+ method_name = name.tr('-','_')
405
+ class_name = name.split(/[_-]+/).map(&:capitalize).join
406
+
407
+ self.subcommands[method_name.to_sym] = subcommand_class
408
+ const_set(class_name,subcommand_class)
409
+
410
+ if is_internal_method?(method_name)
411
+ raise(ArgumentError,"subcommand #{name.inspect} maps to method name ##{method_name} and cannot override the internal method with same name: ##{method_name}")
412
+ end
413
+
414
+ define_method(method_name) do |&block|
415
+ if block then @command_subcommand = subcommand_class.new(&block)
416
+ else @command_subcommand
417
+ end
418
+ end
419
+
420
+ define_method(:"#{method_name}=") do |options|
421
+ @command_subcommand = if options
422
+ subcommand_class.new(options)
423
+ end
424
+ end
425
+ end
426
+
427
+ #
428
+ # Gets the value of an option or an argument.
429
+ #
430
+ # @param [Symbol] name
431
+ #
432
+ # @return [Object]
433
+ #
434
+ # @raise [ArgumentError]
435
+ # The given name was not match any option or argument.
436
+ #
437
+ def [](name)
438
+ name = name.to_s
439
+
440
+ if respond_to?(name)
441
+ send(name)
442
+ else
443
+ raise(ArgumentError,"#{self.class} does not define ##{name}")
444
+ end
445
+ end
446
+
447
+ #
448
+ # Sets an option or an argument with the given name.
449
+ #
450
+ # @param [Symbol] name
451
+ #
452
+ # @param [Object] value
453
+ #
454
+ # @return [Object]
455
+ #
456
+ # @raise [ArgumentError]
457
+ # The given name was not match any option or argument.
458
+ #
459
+ def []=(name,value)
460
+ if respond_to?("#{name}=")
461
+ send("#{name}=",value)
462
+ else
463
+ raise(ArgumentError,"#{self.class} does not define ##{name}=")
464
+ end
465
+ end
466
+
467
+ #
468
+ # Returns an Array of command-line arguments for the command.
469
+ #
470
+ # @return [Array<String>]
471
+ #
472
+ # @raise [ArgumentReqired]
473
+ # A required argument was not set.
474
+ #
475
+ def command_argv
476
+ argv = [@command_path || @command_name]
477
+
478
+ self.class.options.each do |name,option|
479
+ unless (value = self[name]).nil?
480
+ option.argv(argv,value)
481
+ end
482
+ end
483
+
484
+ if @command_subcommand
485
+ # a subcommand takes precedence over any command arguments
486
+ argv.concat(@command_subcommand.command_argv)
487
+ else
488
+ additional_args = []
489
+
490
+ self.class.arguments.each do |name,argument|
491
+ value = self[name]
492
+
493
+ if value.nil? && argument.required?
494
+ raise(ArgumentRequired,"argument #{name} is required")
495
+ else
496
+ argument.argv(additional_args,value)
497
+ end
498
+ end
499
+
500
+ if additional_args.any? { |arg| arg.start_with?('-') }
501
+ # append a '--' separator if any of the arguments start with a '-'
502
+ argv << '--'
503
+ end
504
+
505
+ argv.concat(additional_args)
506
+ end
507
+
508
+ return argv
509
+ end
510
+
511
+ #
512
+ # Escapes any shell control-characters so that it can be ran in a shell.
513
+ #
514
+ # @return [String]
515
+ # The shell-escaped command.
516
+ #
517
+ def command_string
518
+ escaped_command = Shellwords.shelljoin(command_argv)
519
+
520
+ unless @command_env.empty?
521
+ escaped_env = @command_env.map { |name,value|
522
+ "#{Shellwords.shellescape(name)}=#{Shellwords.shellescape(value)}"
523
+ }.join(' ')
524
+
525
+ escaped_command = "#{escaped_env} #{escaped_command}"
526
+ end
527
+
528
+ return escaped_command
529
+ end
530
+
531
+ #
532
+ # Initializes and runs the command.
533
+ #
534
+ # @return [Boolean, nil]
535
+ #
536
+ def run_command
537
+ system(@command_env,*command_argv)
538
+ end
539
+
540
+ #
541
+ # Runs the command in a shell and captures all stdout output.
542
+ #
543
+ # @return [String]
544
+ # The stdout output of the command.
545
+ #
546
+ def capture_command
547
+ `#{command_string}`
548
+ end
549
+
550
+ #
551
+ # Executes the command and returns an IO object to it.
552
+ #
553
+ # @return [IO]
554
+ #
555
+ def popen_command(mode=nil)
556
+ if mode then IO.popen(@command_env,command_argv,mode)
557
+ else IO.popen(@command_env,command_argv)
558
+ end
559
+ end
560
+
561
+ #
562
+ # Initializes and runs the command through sudo.
563
+ #
564
+ # @param [Hash{Symbol => Object}] sudo_params
565
+ # Additional keyword arguments for {Sudo#initialize}.
566
+ #
567
+ # @return [Boolean, nil]
568
+ #
569
+ def sudo_command(**sudo_kwargs,&block)
570
+ sudo_params = sudo_kwargs.merge(command: command_argv)
571
+
572
+ Sudo.run(sudo_params, command_env: @command_env, &block)
573
+ end
574
+
575
+ #
576
+ # @see #argv
577
+ #
578
+ def to_a
579
+ command_argv
580
+ end
581
+
582
+ #
583
+ # @see #shellescape
584
+ #
585
+ def to_s
586
+ command_string
587
+ end
588
+
589
+ private
590
+
591
+ #
592
+ # Determines if there is an internal method of the same name.
593
+ #
594
+ # @param [#to_sym] name
595
+ # The method name.
596
+ #
597
+ # @return [Boolean]
598
+ #
599
+ def self.is_internal_method?(name)
600
+ Command.instance_methods(false).include?(name.to_sym)
601
+ end
602
+
603
+ end
604
+ end
605
+
606
+ require 'command_mapper/sudo'
@@ -0,0 +1,19 @@
1
+ module CommandMapper
2
+ #
3
+ # Commaon base class for all {CommandMapper} exceptions.
4
+ #
5
+ class Error < RuntimeError
6
+ end
7
+
8
+ #
9
+ # Represents a argument or option value validation error.
10
+ #
11
+ class ValidationError < Error
12
+ end
13
+
14
+ #
15
+ # Indicates that a required argument was not set.
16
+ #
17
+ class ArgumentRequired < Error
18
+ end
19
+ end