cli-dispatcher 1.1.11

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.
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: []