rake-toolkit_program 0.1.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.
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ task :default => :test
3
+
4
+ task :test do
5
+ sh *%w[rspec -fd spec]
6
+ end
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler"
4
+ Bundler.setup(:default, :development)
5
+ require "rake/toolkit_program"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # Pry (if it's in the Gemfile)
11
+ begin
12
+ require "pry"
13
+ rescue LoadError
14
+ require "irb"
15
+ IRB.start
16
+ else
17
+ Pry.start
18
+ end
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,56 @@
1
+ # coding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ # Copyright 2019 PayTrace, Inc.
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+ # This file defines a way to handle errors when running a CLI.
19
+
20
+ ##
21
+ # Idiomatic support for dealing with errors during CLI use
22
+ #
23
+ # Rather than allowing an error to propagate to the top level of the
24
+ # interpreter, this module provides the #exit_program! method, which
25
+ # prints a message to STDERR and exits with an exit code of 1 (unless
26
+ # otherwise specified with .exit_with_code).
27
+ #
28
+ # For debugging support, this module also exposes the #exit_code it
29
+ # would use to exit the program were #exit_program! called.
30
+ #
31
+ module ProgramExitFromError
32
+ def self.included(klass)
33
+ klass.extend(ClassMethods)
34
+ end
35
+
36
+ module ClassMethods
37
+ def exit_with_code(n)
38
+ @exit_code = n
39
+ end
40
+
41
+ attr_reader :exit_code
42
+ end
43
+
44
+ def exit_code
45
+ self.class.instance_variable_get(:@exit_code) || 1
46
+ end
47
+
48
+ def exit_program!
49
+ print_error_message
50
+ Kernel.exit(exit_code)
51
+ end
52
+
53
+ def print_error_message
54
+ $stderr.puts("! ERROR: #{self}".bold.red)
55
+ end
56
+ end
@@ -0,0 +1,231 @@
1
+ # coding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ # Copyright 2019 PayTrace, Inc.
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+ # This is the main file of the rake-toolkit_program library.
19
+
20
+ require "rake/toolkit_program/version"
21
+ require 'dedent'
22
+ require 'optparse'
23
+ require 'pathname'
24
+ require 'rake'
25
+ require 'shellwords'
26
+ require 'program_exit_from_error'
27
+
28
+ %w[
29
+ command_option_parser
30
+ errors
31
+ help_styling
32
+ task_ext
33
+ utils
34
+ ].each do |support_file|
35
+ require "rake/toolkit_program/#{support_file}"
36
+ end
37
+
38
+ # From https://stackoverflow.com/a/8781522/160072
39
+ Rake::TaskManager.record_task_metadata = true
40
+
41
+ module Rake
42
+ ##
43
+ # Tools to easily build CLI programs for UNIX-like systems
44
+ #
45
+ # In addition to any commands defined, this module provides:
46
+ #
47
+ # * a <tt>--install-completions</tt> command
48
+ # * a <tt>help</tt> command
49
+ # * <tt>-h</tt> and <tt>--help</tt> flags (until <tt>--</tt> is encountered)
50
+ #
51
+ # Use .command_tasks to define commands and .run to execute the CLI.
52
+ #
53
+ module ToolkitProgram
54
+ extend Rake::DSL
55
+ NAMESPACE="cli_cmd"
56
+
57
+ def self.title=(s)
58
+ @title = s
59
+ end
60
+
61
+ def self.title
62
+ @title || "#{script_name.capitalize} Toolkit Program"
63
+ end
64
+
65
+ @help_styling = HelpStyling.new
66
+ ##
67
+ # Access the HelpStyling object used for styling generated help
68
+ #
69
+ def self.help_styling
70
+ if block_given?
71
+ yield @help_styling
72
+ end
73
+ @help_styling
74
+ end
75
+
76
+ def self.task_name(name)
77
+ "#{NAMESPACE}:#{name}"
78
+ end
79
+
80
+ def self.is_task_name?(name)
81
+ name.to_s.start_with?(NAMESPACE + ':')
82
+ end
83
+
84
+ def self.known_command?(name)
85
+ !name.nil? && Rake::Task.task_defined?(task_name(name))
86
+ end
87
+
88
+ def self.available_commands(include: :all)
89
+ Rake::Task.tasks.select {|t| is_task_name?(t.name)}.reject do |t|
90
+ case include
91
+ when :all then false
92
+ when :listable then t.comment.to_s.empty?
93
+ else raise ArgumentError, "#{include.inspect} not valid as include:"
94
+ end
95
+ end
96
+ end
97
+
98
+ def self.find(name, raise_if_missing: false)
99
+ case
100
+ when known_command?(name)
101
+ Rake::Task[task_name(name)]
102
+ when raise_if_missing
103
+ raise UnknownName.new(name)
104
+ end
105
+ end
106
+
107
+ ##
108
+ # Run a CLI
109
+ #
110
+ # Idiomatically, this is usually invoked as:
111
+ #
112
+ # if __FILE__ == $0
113
+ # Rake::ToolkitProgram.run(on_error: :exit_program!)
114
+ # end
115
+ #
116
+ # The first element of +args+ (which defaults to ARGV) names the command to
117
+ # execute. Additional arguments are available via .args.
118
+ #
119
+ # +on_error+ may be anything supporting #to_proc (including a Proc or
120
+ # lambda) and accepts one parameter, which is an error object that is
121
+ # guaranteed to have an #exit_program! method. Since Symbol#to_proc
122
+ # creates a Proc that sends the target Symbol to the single argument of the
123
+ # created Proc, passing <tt>:exit_program!</tt> (as in the idiomatic
124
+ # invocation) results in program exit according to the error being handled.
125
+ #
126
+ # If the error to be handled by +on_error+ does not #respond_to?
127
+ # <tt>:exit_program!</tt>, it will be extended with ProgramExitFromError,
128
+ # giving it default #exit_program! behavior of printing the error message
129
+ # on STDERR and exiting with code 1.
130
+ #
131
+ # When +on_error+ is nil, any errors are allowed to propagate out of #run.
132
+ #
133
+ def self.run(argv=ARGV, on_error: nil)
134
+ name, *@args = argv
135
+ raise NoCommand if name.nil?
136
+ if_help_request {name, args[0] = 'help', name}
137
+ specified_task = find(name, raise_if_missing: true)
138
+ if specified_task.kind_of?(ArgParsingTask)
139
+ new_args = specified_task.parsed_arguments
140
+ specified_task.argument_parser.parse(
141
+ *case new_args
142
+ when Hash then [args, {into: new_args}]
143
+ else [args]
144
+ end
145
+ )
146
+ @args = new_args
147
+ end
148
+ specified_task.invoke
149
+ rescue StandardError => error
150
+ error.extend(ProgramExitFromError) unless error.respond_to?(:exit_program!)
151
+
152
+ case
153
+ when on_error then on_error.to_proc
154
+ else method(:raise)
155
+ end.call(error)
156
+ end
157
+
158
+ def self.if_help_request
159
+ if args[0] == 'help'
160
+ yield
161
+ else
162
+ args.each do |a|
163
+ case a
164
+ when '--'
165
+ break
166
+ when '-h', '--help'
167
+ yield
168
+ break
169
+ end
170
+ end
171
+ end
172
+ end
173
+
174
+ def self.args
175
+ @args
176
+ end
177
+
178
+ ##
179
+ # Specify a standard type for parsed argument accumulation
180
+ #
181
+ # If this is called, the block is used to construct the argument
182
+ # accumulator if no accumulator object is explicitly specified when calling
183
+ # Rake::Task(Rake::ToolkitProgram::TaskExt)#parse_args. This helps
184
+ # Rake::ToolkitProgram.args be more consistent throughout the client
185
+ # program.
186
+ #
187
+ def self.default_parsed_args(&blk)
188
+ @default_parsed_args_creator = blk
189
+ end
190
+
191
+ ##
192
+ # Construct a parsed argument accumulator
193
+ #
194
+ # The constructor can be defined via the block of .default_parsed_args
195
+ #
196
+ def self.new_default_parsed_args(&blk)
197
+ (@default_parsed_args_creator || blk).call
198
+ end
199
+
200
+ def self.script_name(placeholder_ok: true)
201
+ case
202
+ when $0 != '-' then $0
203
+ when ENV['THIS_SCRIPT'] then ENV['THIS_SCRIPT']
204
+ when placeholder_ok then '<script-name>'
205
+ else raise "Script name unknown"
206
+ end
207
+ end
208
+
209
+ ##
210
+ # Access the Rake namespace for CLI tasks/commands
211
+ #
212
+ # Defining Rake tasks inside the block of this method (with #task) defines
213
+ # the tasks such that they are recognized as invocable from the command
214
+ # line (via the first command line argument, or the first element of +args+
215
+ # passed to .run).
216
+ #
217
+ # To make commands/tasks defined in this block visible in the help and
218
+ # shell completion, use #desc to describe the task.
219
+ #
220
+ def self.command_tasks
221
+ namespace(NAMESPACE) {yield}
222
+ end
223
+ end
224
+ end
225
+
226
+ # Require this at the end because it defines tasks, using some methods defined
227
+ # above
228
+ %w[
229
+ completion
230
+ help
231
+ ].each {|task_file| require "rake/toolkit_program/#{task_file}"}
@@ -0,0 +1,256 @@
1
+ # coding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ # Copyright 2019 PayTrace, Inc.
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+ # This file creates the Rake::ToolkitProgram::CommandOptionParser subclass
19
+ # of the standard library's OptionParser with supporting operations for a
20
+ # toolkit program, particularly around dealing with positional arguments.
21
+
22
+ require 'optparse'
23
+
24
+ module Rake
25
+ module ToolkitProgram
26
+ class CommandOptionParser < OptionParser
27
+ IDENTITY = ->(v) {v}
28
+ RUBY_GTE_2_4 = Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('2.4')
29
+
30
+ def initialize(arg_dest)
31
+ super()
32
+ @argument_destination = arg_dest
33
+ @positional_mapper = IDENTITY
34
+ end
35
+
36
+ attr_reader :argument_destination
37
+
38
+ # Method override, see OptionParser#order!
39
+ if RUBY_GTE_2_4
40
+ def order!(argv = default_argv, into: nil, &nonopt)
41
+ super(argv, into: into) do |arg|
42
+ nonopt.call(@positional_mapper.call(arg))
43
+ end
44
+ end
45
+ else
46
+ def order!(argv = default_args, into: nil, &nonopt)
47
+ raise ArgumentError, "Ruby #{RUBY_VERSION} cannot accept 'into' for OptionParser#order!" unless into.nil?
48
+ super(argv) do |arg|
49
+ nonopt.call(@positional_mapper.call(arg))
50
+ end
51
+ end
52
+ end
53
+
54
+ # Method override, see OptionParser#parse! -- though we don't do POSIXLY_COMPLIANT
55
+ def parse!(argv = default_argv, into: nil)
56
+ positionals = []
57
+ do_positional_capture(positionals) if @precapture_positionals_array
58
+ order!(argv, into: into, &positionals.method(:<<)).tap do
59
+ argv[0, 0] = positionals
60
+ do_positional_capture(argv) if !@precapture_positionals_array
61
+
62
+ unless positional_cardinality_ok?(positionals.length)
63
+ raise WrongArgumentCount.new(
64
+ @positional_cardinality_test,
65
+ positionals.length
66
+ )
67
+ end
68
+ end
69
+ return argv
70
+ end
71
+
72
+ ##
73
+ # Query whether a given number of positional arguments is acceptable
74
+ #
75
+ # The result is based on the test established by
76
+ # #expect_positional_cardinality, and returns true if no such test
77
+ # has been established.
78
+ #
79
+ def positional_cardinality_ok?(n)
80
+ pc_test = @positional_cardinality_test
81
+ !pc_test || pc_test === n
82
+ end
83
+
84
+ ##
85
+ # True unless positional arguments have been prohibited
86
+ #
87
+ # Technically, this test can only check that the established cardinality
88
+ # test is for 0, given as an Integer. If the test established by
89
+ # #expect_positional_cardinality is a Proc that only returns true for
90
+ # 0 or the Range <tt>0..0</tt>, this method will report incorrect
91
+ # results.
92
+ #
93
+ def positional_arguments_allowed?
94
+ @positional_cardinality_test != 0
95
+ end
96
+
97
+ ##
98
+ # Return the established test for positional cardinality (or nil)
99
+ #
100
+ # If a test has been established by #expect_positional_cardinality,
101
+ # this method returns that test. Otherwise, it returns nil.
102
+ #
103
+ def positional_cardinality
104
+ @positional_cardinality_test
105
+ end
106
+
107
+ ##
108
+ # String explanation of the positional cardinality, for help
109
+ #
110
+ def positional_cardinality_explanation
111
+ @positional_cardinality_explanation.tap do |explicit|
112
+ return explicit if explicit
113
+ end
114
+ obscure_answer = "A rule exists about the number of positional arguments."
115
+
116
+ case (pc_test = @positional_cardinality_test)
117
+ when nil, 0 then nil
118
+ when 1 then "Requires 1 positional argument."
119
+ when Integer then "Requires #{pc_test} positional arguments."
120
+ when Range then "Requires #{pc_test.to_inclusive} (inclusive) positional arguments."
121
+ when Proc then begin
122
+ [pc_test.call(:explain)].map do |exp|
123
+ case exp
124
+ when String then exp
125
+ else obscure_answer
126
+ end
127
+ end[0]
128
+ rescue StandardError
129
+ obscure_answer
130
+ end
131
+ else obscure_answer
132
+ end
133
+ end
134
+
135
+ ##
136
+ # Explicitly define capture of positional (non-option) arguments
137
+ #
138
+ # When parsing into a Hash, the default is to store the Array of
139
+ # remaining positional arguments in the +nil+ key. This method
140
+ # overrides that behavior by either specifying a specific key to
141
+ # use or by specifying a block to call with the positional arguments,
142
+ # which is much more useful when accumulating arguments to a non-Hash
143
+ # object. Passing +key+ or a block are mutually exclusive.
144
+ #
145
+ # +precapture_dest_array+ can be set to +true+ to cause the capture
146
+ # to take place before the positional arguments are accumulated. In this
147
+ # case, the Array object yielded to the block (if this method is called
148
+ # with a block) _must_ be stored, as it will be the recipient of all
149
+ # positional arguments. In any case, when this option is passed to this
150
+ # method, capture behavior for the (empty) Array into which the
151
+ # positional arguments will be stored is carried out _before_ option
152
+ # parsing, and values are (after any transformation dictated by
153
+ # #map_positional_args) stored in the positional arguments Array; there
154
+ # is no option to store positionals for consumption by the command task
155
+ # code in anything other than an Array. Note that the Array into which
156
+ # arguments are captured <i>is not</i> the same array either passed to
157
+ # or returned from the #parse! (or #parse, for that matter) method.
158
+ #
159
+ # If multiple calls to this method are made, the last one is the one
160
+ # that defines the behavior.
161
+ #
162
+ # +key+:: Key under which positionals shall be accumulated in a Hash
163
+ # +precapture_dest_array+:: Capture before argument accumulation
164
+ #
165
+ def capture_positionals(key=nil, precapture_dest_array: false, &blk)
166
+ if blk && !key.nil?
167
+ raise ArgumentError, "either specify key or block"
168
+ end
169
+ @positionals_key = key
170
+ @positionals_capture = blk
171
+ @precapture_positionals_array = precapture_dest_array
172
+ end
173
+
174
+ ##
175
+ # Constrain the number of positional arguments
176
+ #
177
+ # This is a declarative way of expressing how many positional (i.e.
178
+ # non-option) arguments should be accepted by the command. The
179
+ # #=== (i.e. "case match") method of +cardinality_test+ is used to test
180
+ # the length of the positional argument Array, raising
181
+ # Rake::ToolkitProgram::WrongArgumentCount if #=== returns false.
182
+ #
183
+ # A special case exists when +cardinality_test+ is a Symbol: because
184
+ # a Symbol could never match an Integer, Symbol#to_proc is called to
185
+ # create a useful test.
186
+ #
187
+ # *NOTE* It is worth attention that Proc#=== is an alias for Proc#call,
188
+ # so the operator argument is passed to the Proc. This enables arbitrary
189
+ # computation for the validity of the positional argument count,
190
+ # syntactically aided by the "stabby-lambda" notation.
191
+ #
192
+ # While this gem will do its best to explain the argument cardinality,
193
+ # +explanation+ provides an opportunity to explicitly provide a
194
+ # sentence to be included in the help about the allowed cardinality
195
+ # (i.e. count) of positional arguments.
196
+ #
197
+ def expect_positional_cardinality(cardinality_test, explanation=nil)
198
+ @positional_cardinality_explanation = explanation
199
+ if cardinality_test.kind_of?(Symbol)
200
+ cardinality_test.to_s.tap do |test_name|
201
+ if explanation.nil? && test_name.end_with?('?')
202
+ @positional_cardinality_explanation = (
203
+ "Positional argument count must be #{test_name[0..-2].gsub('_', ' ')}."
204
+ )
205
+ end
206
+ end
207
+ cardinality_test = cardinality_test.to_proc
208
+ end
209
+ @positional_cardinality_test = cardinality_test
210
+ end
211
+
212
+ ##
213
+ # Disallow positional arguments
214
+ #
215
+ # The command will raise Rake::ToolkitProgram::WrongArgumentCount if
216
+ # any positional arguments are given.
217
+ #
218
+ def no_positional_args!
219
+ expect_positional_cardinality(0)
220
+ end
221
+
222
+ ##
223
+ # Convenience method for raising Rake::ToolkitProgram::InvalidCommandLine
224
+ #
225
+ # The error raised is the standard error for an invalid command line
226
+ # when using Rake::ToolkitProgram.
227
+ #
228
+ def invalid_args!(message)
229
+ raise InvalidCommandLine, message
230
+ end
231
+
232
+ ##
233
+ # Define a mapping function for positional arguments during accumulation
234
+ #
235
+ # The block given to this method will be called with each positional
236
+ # argument in turn; the return value of the block will be acculumated
237
+ # as the positional argument. The block's computation may be purely
238
+ # functional or it may refer to outside factors in its binding such as
239
+ # accumulated values for options or preceding positional arguments.
240
+ #
241
+ def map_positional_args(&blk)
242
+ raise ArgumentError, "block required" if blk.nil?
243
+ @positional_mapper = blk
244
+ end
245
+
246
+ private
247
+ def do_positional_capture(positionals)
248
+ if @positionals_capture
249
+ @positionals_capture.call(positionals)
250
+ elsif argument_destination.kind_of?(Hash)
251
+ argument_destination[@positionals_key] = positionals
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end