cli-dispatcher 1.1.11

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 275e6d346e13d40131484394f8da7d754df96294e559ad1461628274d14755fb
4
+ data.tar.gz: c7eb8d374b5eda6861b70465413574b67ed8e6fd9fa57d1b837a0df3d73c79a0
5
+ SHA512:
6
+ metadata.gz: 781dd82a1715c122bd8293605bd0bb2480e51128efdec7758b982bfe3cd7c02b7cf93f25427716b1d4e302664524b329bcbebaa74d9ee956ba1a46e392d9c050
7
+ data.tar.gz: c3221e641bbc6b2461b500ab626de8954ab7f8e87cef2bb5bdcdebf910b2ba38bd7e0a4f069a9bd67ee2f82212cba7c2f49dacbca23f78e8439fa7a31b564b8e
@@ -0,0 +1,187 @@
1
+ require 'optparse'
2
+
3
+ #
4
+ # Constructs a program that can operate a number of user-provided commands. To
5
+ # use this class, subclass it and define methods of the form:
6
+ #
7
+ # def cmd_name(args...)
8
+ #
9
+ # Then create an instance of the class and call one of the dispatch methods.
10
+ #
11
+ # To provide help for a command, define a method:
12
+ #
13
+ # def help_name
14
+ #
15
+ # The first line should be a short description of the command, which will be
16
+ # used in a summary table describing the command.
17
+ #
18
+ # This class incorporates optparse, providing the commands setup_options and
19
+ # add_options to pass
20
+ # through options specifications.
21
+ #
22
+ class Dispatcher
23
+
24
+ #
25
+ # Reads ARGV and dispatches a command. If no arguments are given, an
26
+ # appropriate warning is issued and the program terminates.
27
+ #
28
+ def dispatch_argv
29
+ @option_parser ||= OptionParser.new
30
+ add_options(@option_parser)
31
+ @option_parser.banner = <<~EOF
32
+ Usage: #$0 [options] command [arguments...]
33
+ Run '#$0 help' for a list of commands.
34
+
35
+ Options:
36
+ EOF
37
+ @option_parser.on_tail('-h', '--help', 'Show this help') do
38
+ warn(@option_parser)
39
+ warn("\nCommands:")
40
+ cmd_help
41
+ exit 1
42
+ end
43
+
44
+ @option_parser.parse!
45
+ if ARGV.empty?
46
+ STDERR.puts(@option_parser)
47
+ exit 1
48
+ end
49
+ dispatch(*ARGV)
50
+ end
51
+
52
+ #
53
+ # Dispatches a single command with given arguments. If the command is not
54
+ # found, then issues a help warning.
55
+ #
56
+ def dispatch(cmd, *args)
57
+ cmd_sym = "cmd_#{cmd}".to_sym
58
+ begin
59
+ if respond_to?(cmd_sym)
60
+ send(cmd_sym, *args)
61
+ else
62
+ warn("Usage: #$0 [options] command [arguments...]")
63
+ warn("Run '#$0 help' for a list of commands.")
64
+ exit(1)
65
+ end
66
+ rescue ArgumentError
67
+ if $!.backtrace_locations.first.base_label == cmd_sym.to_s
68
+ warn("#{cmd}: wrong number of arguments")
69
+ warn("Usage: #{signature_string(cmd)}")
70
+ exit(1)
71
+ else
72
+ raise $!
73
+ end
74
+ end
75
+ end
76
+
77
+ def help_string(cmd, all: true)
78
+ cmd_sym = "help_#{cmd}".to_sym
79
+ return signature_string(cmd) unless respond_to?(cmd_sym)
80
+ if all
81
+ return $0 + " " + signature_string(cmd) + "\n\n" + send(cmd_sym)
82
+ else
83
+ return send(cmd_sym).to_s.split("\n", 2).first
84
+ end
85
+ end
86
+
87
+ def signature_string(cmd)
88
+ cmd_sym = "cmd_#{cmd}".to_sym
89
+ raise "No such command" unless respond_to?(cmd_sym)
90
+ return cmd + " " + method(cmd_sym).parameters.map { |type, name|
91
+ case type
92
+ when :req then name.to_s
93
+ when :opt then "[#{name}]"
94
+ when :rest then "*#{name}"
95
+ when :keyreq, :key, :keyrest, :block then nil
96
+ else raise "Unknown parameter type #{type}"
97
+ end
98
+ }.compact.join(" ")
99
+ end
100
+
101
+ def help_help
102
+ return <<~EOF
103
+ Displays help on commands.
104
+
105
+ Run 'help [command]' for further help on that command.
106
+ EOF
107
+ end
108
+
109
+ def cmd_help(cmd = nil, all: true)
110
+
111
+ if cmd
112
+ warn("")
113
+ warn(help_string(cmd))
114
+ warn("")
115
+ exit(1)
116
+ end
117
+
118
+ warn("Run 'help [command]' for further help on that command.")
119
+ warn("")
120
+
121
+ methods.map { |m|
122
+ s = m.to_s
123
+ s.start_with?("cmd_") ? s.delete_prefix("cmd_") : nil
124
+ }.compact.sort.each do |cmd|
125
+ warn("%-10s %s" % [ cmd, help_string(cmd, all: false) ])
126
+ end
127
+ end
128
+
129
+ #
130
+ # Adds commands relevant when this dispatcher uses Structured data inputs.
131
+ #
132
+ def self.add_structured_commands
133
+ def help_explain
134
+ return <<~EOF
135
+ Displays an explanation of a Structured class.
136
+
137
+ Use this to assist in generating or checking a Rubric file.
138
+ EOF
139
+ end
140
+
141
+ def cmd_explain(class_name)
142
+ c = Object.const_get(class_name)
143
+ unless c.is_a?(Class) && c.include?(Structured)
144
+ raise "Invalid class #{class_name}"
145
+ end
146
+ c.explain
147
+ end
148
+
149
+ def help_template
150
+ return <<~EOF
151
+ Produces a template for the given Structured class.
152
+ EOF
153
+ end
154
+
155
+ def cmd_template(class_name)
156
+ c = Object.const_get(class_name)
157
+ unless c.is_a?(Class) && c.include?(Structured)
158
+ raise("Invalid class #{class_name}")
159
+ end
160
+ puts c.template
161
+ end
162
+ end
163
+
164
+
165
+ # Receives options, passing them to OptionParser. The options are processed
166
+ # when dispatch_argv is called. The usage of this method is that after the
167
+ # Dispatcher object is created, this method is called to instantiate the
168
+ # options for the class. See #add_options for another way of doing this.
169
+ #
170
+ # The banner and -h/--help options will be added automatically.
171
+ #
172
+ def setup_options
173
+ @option_parser = OptionParser.new do |opts|
174
+ yield(opts)
175
+ end
176
+ end
177
+
178
+ #
179
+ # Given an OptionParser object, add options. By default, this method does
180
+ # nothing. The usage of this method, in contrast to #setup_options, is to
181
+ # override this method, invoking calls to the +opts+ argument to add options.
182
+ # The method will be called automatically when the Dispatcher is invoked.
183
+ #
184
+ def add_options(opts)
185
+ end
186
+
187
+ end
@@ -0,0 +1,179 @@
1
+ #
2
+ # Creates a Structured class that can turn into multiple kinds of Structured
3
+ # objects.
4
+ #
5
+ # To use, include StructuredPolymorphic in a relevant class, and then within the
6
+ # class body use ClassMethods#type or ClassMethods#types to specify the
7
+ # different types of Structured objects that this class can produce.
8
+ #
9
+ # When a StructuredPolymorphic object is initialized based on a hash, the hash
10
+ # is checked for a key called +type+. (The key can be changed using
11
+ # ClassMethods#set_type_key.) The value of that +type+ key is used to determine
12
+ # what type of Structured object to create.
13
+ #
14
+ module StructuredPolymorphic
15
+
16
+ #
17
+ # This should never be called because the +new+ method is overridden.
18
+ #
19
+ def initialize(*args, **params)
20
+ raise TypeError, "Abstract StructuredPolymorphic class"
21
+ end
22
+
23
+ module ClassMethods
24
+
25
+ def reset
26
+ @subclasses = {}
27
+ @class_description = nil
28
+ @type_key = :type
29
+ end
30
+
31
+ #
32
+ # Provides a description of this class, for use with the #explain method.
33
+ #
34
+ def set_description(desc)
35
+ @class_description = desc
36
+ end
37
+
38
+ #
39
+ # Returns the class's description. The given number can be used to limit the
40
+ # length of the description.
41
+ #
42
+ def description(len = nil)
43
+ desc = @class_description || ''
44
+ if len && desc.length > len
45
+ return desc[0, len] if len <= 5
46
+ return desc[0, len - 3] + '...'
47
+ end
48
+ return desc
49
+ end
50
+
51
+ #
52
+ # Sets the hash key in which the polymorphic subtype is identified. By
53
+ # default the key is +:type+.
54
+ #
55
+ def set_type_key(key)
56
+ @type_key = key.to_sym
57
+ end
58
+
59
+ #
60
+ # Adds a new subtype to this polymorphic superclass.
61
+ #
62
+ # @param name The textual name for identifying the subclass.
63
+ # @param subclass The Structured Class object to be created.
64
+ #
65
+ def type(name, subclass)
66
+ unless subclass.include?(Structured)
67
+ raise ArgumentError, "#{subclass} is not Structured"
68
+ end
69
+ @subclasses[name.to_sym] = subclass
70
+ end
71
+
72
+ #
73
+ # Adds multiple subtypes by repeatedly calling #type for all key-value
74
+ # pairs.
75
+ #
76
+ def types(**params)
77
+ params.each do |name, subclass|
78
+ type(name, subclass)
79
+ end
80
+ end
81
+
82
+ #
83
+ # Returns the class corresponding to the given type.
84
+ #
85
+ def type_for(name)
86
+ return @subclasses[name.to_sym]
87
+ end
88
+
89
+ #
90
+ # Iterates through all the types.
91
+ #
92
+ def each
93
+ @subclasses.sort.each do |type, c| yield(type, c) end
94
+ end
95
+
96
+ #
97
+ # Prints out documentation for this class.
98
+ #
99
+ def explain(io = STDOUT)
100
+ io.puts("Polymorphic Structured Class #{self}:")
101
+ if @class_description
102
+ io.puts("\n" + TextTools.line_break(@class_description, prefix: ' '))
103
+ end
104
+ io.puts
105
+ io.puts "Available subtypes:"
106
+ max_type_len = @subclasses.keys.map(&:to_s).map(&:length).max
107
+ @subclasses.sort.each do |type, c|
108
+ desc = c.description(80 - max_type_len - 5)
109
+ desc = c.name if desc == ''
110
+ io.puts " #{type.to_s.ljust(max_type_len)} #{desc}"
111
+ end
112
+ end
113
+
114
+ def template(indent: '')
115
+ res = "#{indent}# #{name}\n"
116
+ if @class_description
117
+ res << indent
118
+ res << TextTools.line_break(@class_description, prefix: "#{indent}# ")
119
+ res << "\n"
120
+ end
121
+ res << indent << "type: \n"
122
+ res << indent << "...\n"
123
+ return res
124
+ end
125
+
126
+ #
127
+ # Constructs a new object of this StructuredPolymorphic type, by inspecting
128
+ # the hash's type identifier and calling the corresponding class's
129
+ # constructor.
130
+ #
131
+ def new(hash, parent = nil)
132
+
133
+ # For subclasses, don't use this overridden new method.
134
+ if self.include?(Structured)
135
+ return super(hash, parent)
136
+ end
137
+
138
+ Structured.trace(self) do
139
+
140
+ type = hash[@type_key] || hash[@type_key.to_s]
141
+ input_err("no type") unless type
142
+ type_class = @subclasses[type.to_sym]
143
+ input_err("Unknown #{name} type #{type}") unless type_class
144
+
145
+ # Remove the type key when initializing the subclass
146
+ new_hash = hash.dup
147
+ new_hash.delete(@type_key)
148
+ new_hash.delete(@type_key.to_s)
149
+ o = type_class.new(new_hash, parent)
150
+
151
+ # Set the type value
152
+ o.instance_variable_set(:@type, type)
153
+ return o
154
+
155
+ end
156
+ end
157
+
158
+ def inherited(base)
159
+ base.include(Structured)
160
+ end
161
+
162
+ def input_err(text)
163
+ raise Structured::InputError, text
164
+ end
165
+ end
166
+
167
+
168
+ #
169
+ # Extends ClassMethods to the including class's class methods.
170
+ #
171
+ def self.included(base)
172
+ if base.is_a?(Class)
173
+ base.extend(ClassMethods)
174
+ base.reset
175
+ end
176
+ end
177
+
178
+
179
+ end
data/lib/structured.rb ADDED
@@ -0,0 +1,731 @@
1
+ require_relative 'texttools'
2
+ require 'yaml'
3
+
4
+ #
5
+ # Sets up a class to receive an initializing hash and to populate information
6
+ # about the class from that hash. The expected hash elements are
7
+ # self-documenting and type-checking to facilitate future generation of hash
8
+ # elements.
9
+ #
10
+ # The basic usage is to include the +Structured+ module in a class, which gives
11
+ # the class a method ClassMethods#element, used to declare elements expected in
12
+ # the initializing hash. Once an element is declared, a few things happen:
13
+ #
14
+ # * The element is looked for upon initialization
15
+ #
16
+ # * If found, the element's value is type-checked and possibly converted to a
17
+ # new object. In particular:
18
+ #
19
+ # * If the expected type is a Structured object, then the value is expected to
20
+ # be a hash, which is used as input to construct the expected Structured
21
+ # object. This subsidiary Structured object has its +@parent+ instance
22
+ # variable set so that a complete two-way tree of objects is maintained.
23
+ #
24
+ # * If the expected type is an Array of Structured objects, then the value is
25
+ # expected to be an array of hashes, each of which is converted to the
26
+ # expected Structured object. The +@parent+ variable is also set.
27
+ #
28
+ # * If the expected type is a Hash including Structured object, then the value
29
+ # is similarly converted to a hash of Structured objects. As an added
30
+ # benefit, besides +@parent+ being set, hash values have the +@key+ instance
31
+ # variable set, so that the values are aware of the hash key with which they
32
+ # are associated.
33
+ #
34
+ # * An instance variable +@[element]+ is set to the given value.
35
+ #
36
+ # As a result, at the end of the initialization of a Structured object, it will
37
+ # have instance variables set corresponding to all the defined elements.
38
+ #
39
+ # The above explanation is default behavior, and several customizations are
40
+ # available.
41
+ #
42
+ # * Methods +receive_[element]+ can be defined, taking a single parameter. By
43
+ # default, the method sets an instance variable +@[name]+ with the parameter
44
+ # value. Classes may override this method to provide different initialization
45
+ # actions. (Alternately, classes can accept the default initialization methods
46
+ # and override #initialize for further processing.)
47
+ #
48
+ # * Methods receive_parent and receive_key can be similarly redefined to change
49
+ # the processing of parent Structured objects and hash keys, respectively.
50
+ #
51
+ # * To process unknown elements, call ClassMethods#default_element to specify
52
+ # their expected type. (It should typically be just a class name, as that
53
+ # method's documentation explains.) Then define +receive_any+ to handle
54
+ # undefined elements, for example by placing them in a hash. For these
55
+ # elements, the +@key+ instance variable is also set for them if the expected
56
+ # type is a Structured class.
57
+ #
58
+ # Please read the documentation for Structured::ClassMethods for more on
59
+ # defining expected elements, type checking, and so on.
60
+ #
61
+ module Structured
62
+
63
+ #
64
+ # Error class when there is a defect in Structured input. This class will
65
+ # eventually provide more robust tracing information about where the error
66
+ # occurred.
67
+ #
68
+ class InputError < StandardError
69
+ attr_accessor :structured_stack
70
+
71
+ def to_s
72
+
73
+ res = [ [ nil, nil ] ]
74
+
75
+ return super unless @structured_stack
76
+
77
+ @structured_stack.each do |item|
78
+ if item.is_a?(Class)
79
+ res.last[0] = item
80
+ else
81
+ res.push([ nil, nil ])
82
+ res.last[1] = item
83
+ end
84
+ end
85
+
86
+ return res.map { |cls, item|
87
+ case
88
+ when item && cls then "\"#{item}\" (#{cls})"
89
+ when item then "\"#{item}\""
90
+ when cls then "#{cls}"
91
+ else nil
92
+ end
93
+ }.compact.join(" -> ") + ": " + super
94
+ end
95
+
96
+ def backtrace
97
+ return []
98
+ end
99
+
100
+ def cause
101
+ return nil
102
+ end
103
+
104
+ end
105
+
106
+
107
+ #
108
+ # Initializes the object based on an initialization hash. All methods that
109
+ # include Structured should retain this initialization signature to the extent
110
+ # possible, because downstream Structured objects expect to be initialized
111
+ # this way.
112
+ #
113
+ # @param hash The initializing hash for this object.
114
+ # @param parent The parent object to this Structured object.
115
+ #
116
+ def initialize(hash, parent = nil)
117
+ Structured.trace(self.class) do
118
+ pre_initialize
119
+ receive_parent(parent) if parent
120
+ self.class.build_from_hash(self, hash)
121
+ post_initialize
122
+ end
123
+ end
124
+
125
+ #
126
+ # Subclasses may override this method to provide pre-initialization routines,
127
+ # run before the initializing hash is processed.
128
+ #
129
+ def pre_initialize
130
+ end
131
+
132
+ #
133
+ # Subclasses may override this method to provide post-initialization routines,
134
+ # run after the initializing hash is processed. This may be useful for global
135
+ # data checks (that depend on several values).
136
+ #
137
+ def post_initialize
138
+ end
139
+
140
+ #
141
+ # Processes the parent object for this Structured class. The parent is
142
+ # automatically given for subsidiary Structured objects, triggering a call to
143
+ # this method.
144
+ #
145
+ # By default, +@parent+ is set to the given object. Classes may override this
146
+ # method to do other things with the parent object (for example, test the
147
+ # parent object type).
148
+ #
149
+ def receive_parent(parent)
150
+ @parent = parent
151
+ end
152
+
153
+ attr_reader :parent
154
+
155
+ #
156
+ # Processes the key object for this Structured class. The key is automatically
157
+ # given when this Structured object is a subsidiary of another, within a
158
+ # key-value hash. It is also automatically given when this Structured object
159
+ # is created while processing a default element.
160
+ #
161
+ # By default, this method sets +@key+ to the given object. Classes may
162
+ # override this method to do other things with the key object.
163
+ #
164
+ def receive_key(key)
165
+ @key = key
166
+ end
167
+
168
+ attr_reader :key
169
+
170
+ #
171
+ # Processes an undefined element in the initializing hash. By default, this
172
+ # raises an error, but classes may override this method to use the undefined
173
+ # elements.
174
+ #
175
+ # @param element The unknown element name, converted to a symbol.
176
+ #
177
+ # @param val The value associated with the unknown element.
178
+ #
179
+ def receive_any(element, val)
180
+ raise NameError, "Unexpected element for #{self.class}: #{element}"
181
+ end
182
+
183
+ #
184
+ # Raises an InputError.
185
+ #
186
+ def input_err(text)
187
+ raise InputError, text
188
+ end
189
+
190
+ #
191
+ # Methods extended to a Structured class. A class would typically use the
192
+ # following methods within its class body:
193
+ #
194
+ # * #set_description to set a textual description of the object
195
+ #
196
+ # * #element to define expected elements of the input hash
197
+ #
198
+ # * #default_element to define processing of unknown element keys
199
+ #
200
+ # The #explain method is also useful for printing out documentation for a
201
+ # Structured class.
202
+ #
203
+ module ClassMethods
204
+
205
+ #
206
+ # Sets up a class to manage elements. This method is called when
207
+ # Structured is included in the class.
208
+ #
209
+ # As an implementation note: Information about a Structured class is stored
210
+ # in instance variables of the class's object.
211
+ #
212
+ def reset_elements
213
+ @elements = {}
214
+ @default_element = nil
215
+ @class_description = nil
216
+ end
217
+
218
+ #
219
+ # Provides a description of this class, for use with the #explain method.
220
+ #
221
+ def set_description(desc)
222
+ @class_description = desc
223
+ end
224
+
225
+ #
226
+ # Returns the class's description. The given number can be used to limit the
227
+ # length of the description.
228
+ #
229
+ def description(len = nil)
230
+ desc = @class_description || ''
231
+ if len && desc.length > len
232
+ return desc[0, len] if len <= 5
233
+ return desc[0, len - 3] + '...'
234
+ end
235
+ return desc
236
+ end
237
+
238
+ #
239
+ # Declares that the class expects an element with the given name and type.
240
+ # See element_data for an explanation of +*args+ and +**params+.
241
+ #
242
+ # @param [Symbol] name The name of the element.
243
+ # @param attr Whether to create an attribute (i.e., call +attr_reader+) for
244
+ # the given element. Default is true.
245
+ #
246
+ def element(name, *args, attr: true, **params)
247
+ @elements[name.to_sym] = element_data(*args, **params)
248
+ #
249
+ # By default, when an element is received, a corresponding instance
250
+ # variable is set. Classes using Structured can define +receive_[name]+ so
251
+ # that the element declaration will perform other tasks.
252
+ #
253
+ # This creates the reader attribute only if there is no other method of
254
+ # the same name.
255
+ #
256
+ attr_reader(name) if attr && !method_defined?(name)
257
+ end
258
+
259
+ #
260
+ # Removes an element. Note that the attribute definition if any and the
261
+ # +receive_[name]+ method are left intact.
262
+ #
263
+ def remove_element(name)
264
+ @elements.delete(name.to_sym)
265
+ end
266
+
267
+ #
268
+ # Accepts a default element for this class. The arguments are the same as
269
+ # those for element_data.
270
+ #
271
+ # **Caution**: The type argument should almost always be a single class, and
272
+ # not a hash. This is because the default arguments are automatically
273
+ # treated like a hash, with the otherwise-undefined element names being the
274
+ # keys of the hash.
275
+ #
276
+ def default_element(*args, **params)
277
+ @default_element = element_data(*args, **params)
278
+ end
279
+
280
+ #
281
+ # Processes the definition of an element.
282
+ #
283
+ # @param type The expected type of the element value. This may be:
284
+ #
285
+ # * A class.
286
+ #
287
+ # * The value +:boolean+, indicating that a boolean is acceptable.
288
+ #
289
+ # * An array containing a single element being a class, signifying that the
290
+ # expected type is an array of elements matching that class.
291
+ #
292
+ # * A hash containing a single +Class1 => Class2+ pair, signifying that the
293
+ # expected type is a hash of key-value pairs matching the indicated
294
+ # classes. If Class2 is a Structured class, then Class2 objects will have
295
+ # their Structured#receive_key method called, with the corresponding
296
+ # Class1 object as the argument.
297
+ #
298
+ # @param optional Whether the element is optional. Set to :omit to omit it
299
+ # from templates.
300
+ #
301
+ # @param description A text description of the element.
302
+ #
303
+ # @param preproc A Proc that will be executed on the element value to
304
+ # convert it. The proc will be executed in the context of the receiving
305
+ # object.
306
+ #
307
+ # @param default A default value, entered into templates. The default value
308
+ # is also used for optional elements that are not specified in an input
309
+ # hash.
310
+ #
311
+ # @param check A mechanism for checking for the validity of an element
312
+ # value. This may be:
313
+ #
314
+ # * A Proc, in which case it should return true for valid values.
315
+ # * An Array of valid values (tested by +===+}).
316
+ # * Any other object, in which case validity is determined by whether the
317
+ # check value +===+ the element value.
318
+ #
319
+ def element_data(
320
+ type,
321
+ optional: false, description: nil,
322
+ preproc: nil, default: nil, check: nil
323
+ )
324
+ # Check the type argument
325
+ case type
326
+ when Class, :boolean
327
+ when Array
328
+ unless type.count == 1 && type.first.is_a?(Class)
329
+ raise TypeError, "Invalid Array type declaration"
330
+ end
331
+ when Hash
332
+ unless type.count == 1 && type.first.all? { |x| x.is_a?(Class) }
333
+ raise TypeError, "Invalid Hash type declaration"
334
+ end
335
+ else
336
+ raise TypeError, "Invalid type declaration #{type.inspect}"
337
+ end
338
+
339
+ if preproc
340
+ raise TypeError, "preproc must be a Proc" unless preproc.is_a?(Proc)
341
+ end
342
+
343
+ case check
344
+ when nil, Proc then check_obj = check # Pass through
345
+ when Array then check_obj = proc { |o| check.any? { |c| c === o } }
346
+ else check_obj = proc { |o| check === o }
347
+ end
348
+
349
+ return {
350
+ :type => type,
351
+ :optional => optional,
352
+ :description => description,
353
+ :preproc => preproc,
354
+ :default => default,
355
+ :check => check_obj,
356
+ }
357
+
358
+ end
359
+
360
+ #
361
+ # Iterates elements in a useful sorted order.
362
+ #
363
+ def each_element
364
+ @elements.sort_by { |e, data|
365
+ if data[:optional] == :omit
366
+ [ 3, e.to_s ]
367
+ else
368
+ [ data[:optional] ? 2 : 1, e.to_s ]
369
+ end
370
+ }.each do |e, data|
371
+ yield(e, data)
372
+ end
373
+ end
374
+
375
+ #
376
+ # Given a hash, extracts all the elements from it and updates the object
377
+ # accordingly. This method is called automatically upon initialization of
378
+ # the Structured class.
379
+ #
380
+ # @param obj the object to update
381
+ # @param hash the data hash.
382
+ #
383
+ def build_from_hash(obj, hash)
384
+ input_err("Initializer is not a Hash") unless hash.is_a?(Hash)
385
+ hash = try_read_file(hash)
386
+
387
+ @elements.each do |elt, data|
388
+ Structured.trace(elt.to_s) do
389
+ val = hash[elt] || hash[elt.to_s]
390
+ next if process_nil_val(obj, elt, val, data)
391
+
392
+ if data[:preproc]
393
+ val = try_run(data[:preproc], obj, val, "preproc")
394
+ next if process_nil_val(obj, elt, val, data)
395
+ end
396
+
397
+ cval = convert_item(val, data[:type], obj)
398
+
399
+ # Check for validity after preproc and conversion are run
400
+ if data[:check] && !try_run(data[:check], obj, cval, "check")
401
+ input_err "Value #{cval} failed check for #{elt}"
402
+ end
403
+
404
+ # Use the converted value
405
+ apply_val(obj, elt, cval)
406
+ end
407
+ end
408
+
409
+ # Process unknown elements
410
+ unknown_elts = (hash.keys.map(&:to_sym) - @elements.keys)
411
+ return if unknown_elts.empty?
412
+ unless @default_element
413
+ input_err("Unexpected element(s): #{unknown_elts.join(', ')}")
414
+ end
415
+ unknown_elts.each do |elt|
416
+ Structured.trace(elt.to_s) do
417
+ de = @default_element
418
+ val = hash[elt] || hash[elt.to_s]
419
+ if de[:preproc]
420
+ val = try_run(de[:preproc], obj, val, "default preproc")
421
+ end
422
+ item = convert_item(val, de[:type], obj)
423
+ if de[:check] && !try_run(de[:check], obj, item, "check")
424
+ input_err "Value #{item} failed default element check"
425
+ end
426
+ item.receive_key(elt) if item.is_a?(Structured)
427
+ obj.receive_any(elt, item)
428
+ end
429
+ end
430
+ end
431
+
432
+ #
433
+ # If the hash contains a key :read_file, then try reading a file containing
434
+ # additional keys, and return a new hash merging the two. This will not work
435
+ # recursively; the input file may not further contain a :read_file key.
436
+ #
437
+ # If the given hash and the :read_file hash contain duplicate keys, the
438
+ # given hash overrides the file values.
439
+ #
440
+ def try_read_file(hash)
441
+ file = hash['read_file'] || hash[:read_file]
442
+ return hash unless file
443
+ begin
444
+ res = YAML.load_file(file).merge(hash)
445
+ res.delete('read_file')
446
+ res.delete(:read_file)
447
+ return res
448
+ rescue
449
+ input_err("Failed to read Structured YAML input from #{file}: #$!")
450
+ end
451
+ end
452
+
453
+ # Deals with a nil value (either because no value was given, or because a
454
+ # preproc deleted it).
455
+ #
456
+ # * If val is non-nil, then this method returns false.
457
+ # * If val is nil and this element is non-optional, then this method raises
458
+ # an error.
459
+ # * If val is nil and the element is optional, *and* the element has a
460
+ # default value, then the object has the default value applied to the
461
+ # element.
462
+ # * In any event, if val is nil and the element is optional, returns true
463
+ # which should signal to the caller to stop further processing of the
464
+ # element.
465
+ #
466
+ def process_nil_val(obj, elt, val, data)
467
+ return false if val
468
+ input_err("Missing (or preproc deleted) #{elt}") unless data[:optional]
469
+ apply_val(obj, elt, data[:default]) unless data[:default].nil?
470
+ return true
471
+ end
472
+
473
+ # Applies a value to an element for an object, after all processing for the
474
+ # value is done.
475
+ def apply_val(obj, elt, val)
476
+ if obj.respond_to?("receive_#{elt}")
477
+ obj.send("receive_#{elt}".to_sym, val)
478
+ else
479
+ obj.instance_variable_set("@#{elt}", val)
480
+ end
481
+ end
482
+
483
+ def try_run(block, obj, val, err_name)
484
+ begin
485
+ val = obj.instance_exec(val, &block)
486
+ rescue StandardError => e
487
+ input_err("#{err_name} failed: #{e.to_s}")
488
+ end
489
+ end
490
+
491
+ #
492
+ # Given an expected type and an item, checks that the item matches the
493
+ # expected type, and performs any necessary conversions.
494
+ #
495
+ def convert_item(item, type, parent)
496
+ case type
497
+ #
498
+ # In the when cases, the type is not just a class object
499
+ #
500
+ when :boolean
501
+ return item if item.is_a?(TrueClass) || item.is_a?(FalseClass)
502
+ input_err("#{item} is not boolean")
503
+
504
+ when Array
505
+ input_err("#{item} is not Array") unless item.is_a?(Array)
506
+ Structured.trace(Array) do
507
+ return item.map.with_index { |i, idx|
508
+ Structured.trace(idx) do
509
+ convert_item(i, type.first, parent)
510
+ end
511
+ }
512
+ end
513
+
514
+ when Hash
515
+ input_err("#{item} is not Hash") unless item.is_a?(Hash)
516
+ Structured.trace(Hash) do
517
+ return item.map { |k, v|
518
+ Structured.trace(k.to_s) do
519
+ conv_key = convert_item(k, type.first.first, parent)
520
+ conv_item = convert_item(v, type.first.last, parent)
521
+ conv_item.receive_key(conv_key) if conv_item.is_a?(Structured)
522
+ [ conv_key, conv_item ]
523
+ end
524
+ }.to_h
525
+ end
526
+
527
+ else
528
+
529
+ #
530
+ # In these cases, the type is a class object. It can't be tested with
531
+ # the === operator of a case/when.
532
+ #
533
+ # If the item can be automatically coverted to the expected type
534
+ citem = try_autoconvert(type, item)
535
+
536
+ # If the item is of the expected type, then return it
537
+ return citem if citem.is_a?(type)
538
+
539
+ # The only remaining hope for conversion is that type is Structured and
540
+ # item is a hash
541
+ return convert_structured(citem, type, parent)
542
+ end
543
+ end
544
+
545
+ def try_autoconvert(type, item)
546
+
547
+ if type == String && item.is_a?(Symbol)
548
+ return item.to_s
549
+ end
550
+
551
+ # Special case in which strings will be converted to Regexps
552
+ if type == Regexp && item.is_a?(String)
553
+ begin
554
+ return Regexp.new(item)
555
+ rescue RegexpError
556
+ input_err("#{item} is not a valid regular expression")
557
+ end
558
+ end
559
+
560
+ return item
561
+ end
562
+
563
+ # Receive hash values that are to be converted to Structured objects
564
+ def convert_structured(item, type, parent)
565
+ unless item.is_a?(Hash)
566
+ input_err("#{item.inspect} not a #{type} or Structured hash")
567
+ end
568
+
569
+ unless type.include?(Structured) || type.include?(StructuredPolymorphic)
570
+ input_err("#{type} is not a Structured class")
571
+ end
572
+ return type.new(item, parent)
573
+ end
574
+
575
+
576
+ #
577
+ # Raises an InputError.
578
+ #
579
+ def input_err(text)
580
+ raise InputError, text
581
+ end
582
+
583
+
584
+ #
585
+ # Prints out documentation for this class.
586
+ #
587
+ def explain(io = STDOUT)
588
+ io.puts("Structured Class #{self}:")
589
+ if @class_description
590
+ io.puts("\n" + TextTools.line_break(@class_description, prefix: ' '))
591
+ end
592
+ io.puts
593
+
594
+ each_element do |elt, data|
595
+ io.puts(
596
+ " #{elt}: #{describe_type(data[:type])}" + \
597
+ "#{data[:optional] ? ' (optional)' : ''}"
598
+ )
599
+ if data[:description]
600
+ io.puts(TextTools.line_break(data[:description], prefix: ' '))
601
+ io.puts()
602
+ end
603
+ end
604
+
605
+ if @default_element
606
+ io.puts(
607
+ " All other elements: #{describe_type(@default_element[:type])}"
608
+ )
609
+ if @default_element[:description]
610
+ io.puts(TextTools.line_break(
611
+ @default_element[:description], prefix: ' '
612
+ ))
613
+ end
614
+ io.puts()
615
+ end
616
+
617
+ end
618
+
619
+ #
620
+ # Provides a textual description of a type.
621
+ #
622
+ def describe_type(type)
623
+ case type
624
+ when :boolean then 'Boolean'
625
+ when Array then "Array of #{describe_type(type.first)}"
626
+ when Hash
627
+ desc1, desc2 = type.first.map { |x| describe_type(x) }
628
+ "Hash of #{desc1} => #{desc2}"
629
+ else return type.to_s
630
+ end
631
+ end
632
+
633
+ #
634
+ # Produces a template YAML file for this Structured object.
635
+ def template(indent: '')
636
+ res = "#{indent}# #{name}\n"
637
+ if @class_description
638
+ res << TextTools.line_break(@class_description, prefix: "#{indent}# ")
639
+ res << "\n"
640
+ end
641
+
642
+ in_opt = false
643
+ max_len = @elements.keys.map { |e| e.to_s.length }.max
644
+
645
+ each_element do |elt, data|
646
+ next if data[:optional] == :omit
647
+ if data[:optional] && !in_opt
648
+ res << "#{indent}#\n#{indent}# Optional\n"
649
+ in_opt = true
650
+ end
651
+
652
+ res << "#{indent}#{elt}:"
653
+ spacing = ' ' * (max_len - elt.to_s.length + 1)
654
+ if data[:default]
655
+ res << spacing << data[:default].inspect << "\n"
656
+ else
657
+ res << template_type(data[:type], indent, spacing)
658
+ end
659
+ end
660
+ return res
661
+ end
662
+
663
+ #
664
+ # @param type The Structured data type specification.
665
+ # @param indent The indent string before new lines.
666
+ # @param sp Spacing after the colon, if any.
667
+ def template_type(type, indent, sp = ' ')
668
+ res = ''
669
+ case type
670
+ when :boolean
671
+ res << " true/false\n"
672
+ when Class
673
+ if type == String
674
+ res << "#{sp}\"\"\n"
675
+ elsif type.include?(Structured)
676
+ res << "\n" << type.template(indent: indent + ' ')
677
+ else
678
+ res << "#{sp}# #{type}\n"
679
+ end
680
+ when Array
681
+ if type.first == String
682
+ res << "#{sp}[ \"\", ... ]\n"
683
+ else
684
+ res << "\n#{indent} -" << template_type(type.first, indent + ' ')
685
+ end
686
+ when Hash
687
+ if type.first.first == String
688
+ res << "\n#{indent} \"\":"
689
+ else
690
+ res << "\n#{indent} [#{type.first.first}]:"
691
+ end
692
+ res << template_type(type.first.last, indent + ' ')
693
+ end
694
+ return res
695
+ end
696
+ end
697
+
698
+ #
699
+ # Includes ClassMethods.
700
+ #
701
+ def self.included(base)
702
+ if base.is_a?(Class)
703
+ base.extend(ClassMethods)
704
+ base.reset_elements
705
+ end
706
+ end
707
+
708
+ #
709
+ # Enable tracing of object creation.
710
+ #
711
+ def self.trace(note)
712
+ begin
713
+ @trace_stack.push(note)
714
+ return yield
715
+ rescue InputError => e
716
+ e.structured_stack ||= @trace_stack.dup
717
+ raise e
718
+ ensure
719
+ @trace_stack.pop
720
+ end
721
+ end
722
+
723
+ # Stack of traced items
724
+ @trace_stack = []
725
+
726
+ end
727
+
728
+
729
+
730
+
731
+ require_relative 'structured-poly'
data/lib/texttools.rb ADDED
@@ -0,0 +1,94 @@
1
+ module TextTools
2
+
3
+ extend TextTools
4
+
5
+ #
6
+ # Breaks a text into lines of a given length. If preserve_lines is set, then
7
+ # all line breaks are preserved; otherwise line breaks are treated as spaces.
8
+ # However, two consecutive line breaks are always preserved, treating them as
9
+ # paragraph breaks. Line breaks at the end of the text are never preserved.
10
+ #
11
+ def line_break(
12
+ text, len: 80, prefix: '', first_prefix: nil, preserve_lines: false
13
+ )
14
+ res = ''
15
+ text = text.split(/\s*\n\s*\n\s*/).map { |para|
16
+ preserve_lines ? para : para.gsub(/\s*\n\s*/, " ")
17
+ }.join("\n\n")
18
+
19
+ cur_prefix = first_prefix || prefix
20
+ strlen = len - cur_prefix.length
21
+ while text.length > strlen
22
+ if (m = /\A([^\n]{0,#{strlen}})(\s+)/.match(text))
23
+ res << cur_prefix + m[1]
24
+ res << (m[2].include?("\n") ? m[2].gsub(/[^\n]/, '') : "\n")
25
+ text = m.post_match
26
+ else
27
+ res << cur_prefix + text[0, strlen] + "\n"
28
+ text = text[strlen..-1]
29
+ end
30
+ cur_prefix = prefix
31
+ strlen = len - cur_prefix.length
32
+ end
33
+
34
+ # If there's no text left, then there were trailing spaces and the final \n
35
+ # is superfluous.
36
+ if text.length > 0
37
+ res << cur_prefix + text
38
+ else
39
+ res.rstrip!
40
+ end
41
+
42
+ return res
43
+ end
44
+
45
+
46
+ #
47
+ # Joins a list of items into a textual phrase. If there are two items, then
48
+ # +amp+ is used to join them. If there are three or more items, then +comma+
49
+ # is used for all but the last pair, for which +commaamp+ is used.
50
+ #
51
+ def text_join(list, comma: ", ", amp: " & ", commaamp: " & ")
52
+ return list unless list.is_a?(Array)
53
+ case list.count
54
+ when 0 then raise "Can't textjoin empty list"
55
+ when 1 then list.first
56
+ when 2 then list.join(amp)
57
+ else
58
+ list[0..-2].join(comma) + commaamp + list.last
59
+ end
60
+ end
61
+
62
+ #
63
+ # Processes simple markdown for a given text.
64
+ #
65
+ # @param i A two-element array of the starting and ending text for italicized
66
+ # content.
67
+ # @param b A two-element array of the starting and ending text for bold
68
+ # content.
69
+ #
70
+ def markdown(text, i: [ '<i>', '</i>' ], b: [ '<b>', '</b>' ])
71
+ return text.gsub(/(?<!\w)\*\*([^*]+)\*\*(?!\w)/) { |t|
72
+ "#{b.first}#$1#{b.last}"
73
+ }.gsub(/(?<!\w)\*([^*]+)\*(?!\w)/) { |t|
74
+ "#{i.first}#$1#{i.last}"
75
+ }
76
+ end
77
+
78
+ #
79
+ # Computes the ordinal number (using digits).
80
+ #
81
+ # @param legal Whether to use legal ordinals (2d, 3d)
82
+ #
83
+ def ordinal(num, legal: true)
84
+ case num.to_s
85
+ when /1\d\z/ then "#{num}th"
86
+ when /1\z/ then "#{num}st"
87
+ when /2\z/ then legal ? "#{num}d" : "#{num}nd"
88
+ when /3\z/ then legal ? "#{num}d" : "#{num}rd"
89
+ else "#{num}th"
90
+ end
91
+ end
92
+
93
+
94
+ end
metadata ADDED
@@ -0,0 +1,49 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cli-dispatcher
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.1.11
5
+ platform: ruby
6
+ authors:
7
+ - Charles Duan
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-11-13 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |
14
+ Library for creating command-line programs that accept commands. Also
15
+ includes the Structured class for processing YAML files containing
16
+ structured data.
17
+ email: rubygems.org@cduan.com
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - lib/cli-dispatcher.rb
23
+ - lib/structured-poly.rb
24
+ - lib/structured.rb
25
+ - lib/texttools.rb
26
+ homepage: https://github.com/charlesduan/cli-dispatcher
27
+ licenses:
28
+ - MIT
29
+ metadata: {}
30
+ post_install_message:
31
+ rdoc_options: []
32
+ require_paths:
33
+ - lib
34
+ required_ruby_version: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: 2.6.0
39
+ required_rubygems_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ requirements: []
45
+ rubygems_version: 3.0.3.1
46
+ signing_key:
47
+ specification_version: 4
48
+ summary: Command-line command dispatcher
49
+ test_files: []