opto 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "opto"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
@@ -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,51 @@
1
+ require "opto/version"
2
+ require 'opto/extensions/snake_case'
3
+ require 'opto/extensions/hash_string_or_symbol_key'
4
+
5
+ if RUBY_VERSION < '2.1'
6
+ using Opto::Extension::SnakeCase
7
+ using Opto::Extension::HashStringOrSymbolKey
8
+ end
9
+
10
+ require "opto/option"
11
+ require "opto/group"
12
+
13
+ require 'yaml'
14
+
15
+ # An option parser/validator/resolver
16
+ #
17
+ module Opto
18
+ # Initialize a new Opto::Option (when input is hash) or an Opto::Group (when input is an array of hashes)
19
+ def self.new(opts)
20
+ case opts
21
+ when Hash
22
+ if opts.has_key?('name') || opts.has_key?(:name)
23
+ Option.new(opts)
24
+ else
25
+ Group.new(opts)
26
+ end
27
+ when Array
28
+ if opts.all? {|o| o.kind_of?(Hash) }
29
+ Group.new(opts)
30
+ else
31
+ raise TypeError, "Invalid input, an option hash or an array of option hashes required"
32
+ end
33
+ else
34
+ raise TypeError, "Invalid input, an option hash or an array of option hashes required"
35
+ end
36
+ end
37
+
38
+ # Read an option (or option group) from a YAML file
39
+ # @param [String] path_to_file
40
+ # @param [String,Symbol] a key in the hash representation of the file, such as :variables to read the options from (instead of using the root)
41
+ # @example
42
+ # Opto.read('/tmp/foo.yml', :options)
43
+ def self.read(yaml_path, key=nil)
44
+ opts = YAML.load(File.read(yaml_path))
45
+ new(key.nil? ? opts : opts[key])
46
+ end
47
+
48
+ singleton_class.send(:alias_method, :load, :read)
49
+
50
+ end
51
+
@@ -0,0 +1,18 @@
1
+ module Opto
2
+ module Extension
3
+ # Refines Hash so that [] and delete work with :symbol or 'string' keys
4
+ module HashStringOrSymbolKey
5
+ refine Hash do
6
+ def [](key)
7
+ return nil if key.nil?
8
+ super(key.to_s) || super(key.to_sym)
9
+ end
10
+
11
+ def delete(key)
12
+ return nil if key.nil?
13
+ super(key) || super(key.to_s) || super(key.to_sym)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,22 @@
1
+ module Opto
2
+ module Extension
3
+ # Refines String to have .snakecase method that turns
4
+ # StringLikeThis into a string_like_this
5
+ module SnakeCase
6
+ refine String do
7
+ def snakecase
8
+ gsub(/::/, '/')
9
+ gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
10
+ gsub(/([a-z\d])([A-Z])/,'\1_\2').
11
+ tr('-', '_').
12
+ gsub(/\s/, '_').
13
+ gsub(/__+/, '_').
14
+ downcase
15
+ end
16
+
17
+ alias_method :underscore, :snakecase
18
+ alias_method :snakeize, :snakecase
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,112 @@
1
+ require 'opto/extensions/snake_case'
2
+ require 'opto/extensions/hash_string_or_symbol_key'
3
+
4
+ if RUBY_VERSION < '2.1'
5
+ using Opto::Extension::SnakeCase
6
+ using Opto::Extension::HashStringOrSymbolKey
7
+ end
8
+
9
+ module Opto
10
+ # A group of Opto::Option instances. Members of Groups can see their relatives
11
+ # and their values. Such as `option.value_of('another_option')`
12
+ #
13
+ # Most Array instance methods are delegated, such as .map, .each, .find etc.
14
+ class Group
15
+
16
+ using Opto::Extension::HashStringOrSymbolKey unless RUBY_VERSION < '2.1'
17
+
18
+ attr_reader :options, :defaults
19
+
20
+ extend Forwardable
21
+
22
+ # Initialize a new Option Group. You can also pass in :defaults.
23
+ #
24
+ # @param [Array<Hash,Opto::Option>,Hash,NilClass] opts An array of Option definition hashes or Option objects or a hash like { var_name: { opts } }.
25
+ # @return [Opto::Group]
26
+ def initialize(*options)
27
+ if options.size > 0
28
+ if options.last.kind_of?(Hash) && options.last[:defaults]
29
+ @defaults = options.pop[:defaults]
30
+ end
31
+ @options =
32
+ case options.first
33
+ when NilClass
34
+ []
35
+ when Hash
36
+ options.first.map {|k,v| Option.new({name: k.to_s, group: self}.merge(v))}
37
+ when Array
38
+ options.first.map {|opt| opt.kind_of?(Opto::Option) ? opt : Option.new(opt.merge(group: self)) }
39
+ else
40
+ raise TypeError, "Invalid type #{options.first.class} for Opto::Group.new"
41
+ end
42
+ else
43
+ @options = []
44
+ end
45
+
46
+ end
47
+
48
+ # Are all options valid? (Option value passes validation)
49
+ # @return [Boolean]
50
+ def valid?
51
+ options.all? {|o| o.valid? }
52
+ end
53
+
54
+ # Collect validation errors from members
55
+ # @return [Hash] { option_name => { validator_name => "Too short" } }
56
+ def errors
57
+ Hash[*options_with_errors.flat_map {|o| [o.name, o.errors] }]
58
+ end
59
+
60
+ # Enumerate over all the options that are not valid
61
+ # @return [Array]
62
+ def options_with_errors
63
+ options.reject(&:valid?)
64
+ end
65
+
66
+ # Convert Group to an Array of Hashes (by calling .to_h on each member)
67
+ # @return [Array<Hash>]
68
+ def to_a(with_errors: false, with_value: false)
69
+ options.map {|opt| opt.to_h(with_errors: with_errors, with_value: with_value) }
70
+ end
71
+
72
+ # Convert a Group to a hash that has { option_name => option_value }
73
+ # @return [Hash]
74
+ def to_h(values_only: false, with_values: false, with_errors: false)
75
+ if values_only
76
+ Hash[*options.flat_map {|opt| [opt.name, opt.value]}]
77
+ else
78
+ Hash[*options.flat_map {|opt| [opt.name, opt.to_h(with_value: with_values, with_errors: with_errors).reject {|k,_| k==:name}]}]
79
+ end
80
+ end
81
+
82
+ # Runs outputters for all valid non-skipped options
83
+ def run
84
+ options.reject(&:skip?).select(&:valid?).each(&:output)
85
+ end
86
+
87
+ # Initialize a new Option to this group. Takes the same arguments as Opto::Option
88
+ # @param [Hash] option_definition
89
+ # @return [Opto::Option]
90
+ def build_option(args={})
91
+ options << Option.new(args.merge(group: self))
92
+ options.last
93
+ end
94
+
95
+ # Find a member by name
96
+ # @param [String] option_name
97
+ # @return [Opto::Option]
98
+ def option(option_name)
99
+ options.find { |opt| opt.name == option_name }
100
+ end
101
+
102
+ # Get a value of a member by option name
103
+ # @param [String] option_name
104
+ # @return [option_value, NilClass]
105
+ def value_of(option_name)
106
+ opt = option(option_name)
107
+ opt.nil? ? nil : opt.value
108
+ end
109
+
110
+ def_delegators :@options, *(Array.instance_methods - [:__send__, :object_id, :to_h, :to_a, :is_a?, :kind_of?, :instance_of?])
111
+ end
112
+ end
@@ -0,0 +1,298 @@
1
+ require_relative 'type'
2
+ require_relative 'resolver'
3
+ require_relative 'setter'
4
+ require_relative 'extensions/snake_case'
5
+ require_relative 'extensions/hash_string_or_symbol_key'
6
+
7
+ if RUBY_VERSION < '2.1'
8
+ using Opto::Extension::SnakeCase
9
+ using Opto::Extension::HashStringOrSymbolKey
10
+ end
11
+
12
+ module Opto
13
+ # What is an option? It's like a variable that has a value, which can be validated or
14
+ # manipulated on creation. The value can be resolved from a number of origins, such as
15
+ # an environment variable or random string generator.
16
+ class Option
17
+
18
+ unless RUBY_VERSION < '2.1'
19
+ using Opto::Extension::SnakeCase
20
+ using Opto::Extension::HashStringOrSymbolKey
21
+ end
22
+
23
+ attr_accessor :type
24
+ attr_accessor :name
25
+ attr_accessor :label
26
+ attr_accessor :description
27
+ attr_accessor :required
28
+ attr_accessor :default
29
+ attr_reader :from
30
+ attr_reader :to
31
+ attr_reader :group
32
+ attr_reader :skip_if
33
+ attr_reader :only_if
34
+ attr_reader :initial_value
35
+ attr_reader :type_options
36
+
37
+ # Initialize an instance of Opto::Option
38
+ # @param [Hash] options
39
+ # @option [String] :name Option name
40
+ # @option [String,Symbol] :type Option type, such as :integer, :string, :boolean, :enum
41
+ # @option [String] :label A label for this field, to be used in for example an interactive prompt
42
+ # @option [String] :description Same as label, but more detailed
43
+ # @option [*] :default Default value for option
44
+ # @option [String,Symbol,Array<String,Symbol,Hash>,Hash] :from Resolver origins
45
+ # @option [String,Symbol,Array<String,Symbol,Hash>,Hash] :to Setter targets
46
+ # @option [String,Symbol,Array<String,Symbol,Hash>,Hash] :skip_if Conditionals that define if this option should be skipped
47
+ # @option [String,Symbol,Array<String,Symbol,Hash>,Hash] :only_if Conditionals that define if this option should be included
48
+ # @option [Opto::Group] :group Parent group reference
49
+ # @option [...] Type definition options, such as { min_length: 3, strip: true }
50
+ #
51
+ # @example Create an option
52
+ # Opto::Option.new(
53
+ # name: 'cat_name',
54
+ # type: 'string',
55
+ # label: 'Name of your Cat',
56
+ # required: true,
57
+ # description: 'Enter a name for your cat',
58
+ # from:
59
+ # env: 'CAT_NAME'
60
+ # only_if:
61
+ # pet: 'cat'
62
+ # min_length: 2
63
+ # max_length: 20
64
+ # )
65
+ #
66
+ # @example Create a random string
67
+ # Opto::Option.new(
68
+ # name: 'random_string',
69
+ # type: :string,
70
+ # from:
71
+ # random_string:
72
+ # length: 20
73
+ # charset: ascii_printable
74
+ # )
75
+ def initialize(options = {})
76
+ opts = options.dup
77
+
78
+ @group = opts.delete(:group)
79
+ if @group && @group.defaults
80
+ opts = @group.defaults.reject{|k,_| [:from, :to].include?(k)}.merge(opts)
81
+ end
82
+
83
+ @name = opts.delete(:name).to_s
84
+
85
+ type = opts.delete(:type)
86
+ @type = type.to_s.snakecase unless type.nil?
87
+
88
+ @label = opts.delete(:label) || @name
89
+ @description = opts.delete(:description)
90
+ @default = opts.delete(:default)
91
+ val = opts.delete(:value)
92
+ @skip_if = opts.delete(:skip_if)
93
+ @only_if = opts.delete(:only_if)
94
+ @skip_lambdas = normalize_ifs(@skip_if)
95
+ @only_lambdas = normalize_ifs(@only_if)
96
+ @from = { default: self }.merge(normalize_from_to(opts.delete(:from)))
97
+ @to = normalize_from_to(opts.delete(:to))
98
+ @type_options = opts
99
+
100
+ set_initial(val) if val
101
+ deep_merge_defaults
102
+ end
103
+
104
+ def deep_merge_defaults
105
+ return nil unless group && group.defaults
106
+ if group.defaults[:from]
107
+ normalize_from_to(group.defaults[:from]).each do |k,v|
108
+ from[k] ||= v
109
+ end
110
+ end
111
+ if group.defaults[:to]
112
+ normalize_from_to(group.defaults[:to]).each do |k,v|
113
+ to[k] ||= v
114
+ end
115
+ end
116
+ end
117
+
118
+ # Hash representation of Opto::Option. Can be passed back to Opto::Option.new
119
+ # @param [Boolean] with_errors Include possible validation errors hash
120
+ # @param [Boolean] with_value Include current value
121
+ # @return [Hash]
122
+ def to_h(with_errors: false, with_value: true)
123
+ hash = {
124
+ name: name,
125
+ label: label,
126
+ type: type,
127
+ description: description,
128
+ default: default,
129
+ from: from.reject { |k,_| k == :default},
130
+ to: to
131
+ }.merge(type_options).reject { |_,v| v.nil? }
132
+ hash[:skip_if] = skip_if if skip_if
133
+ hash[:only_if] = only_if if only_if
134
+ hash[:errors] = errors if with_errors
135
+ hash[:value] = value if with_value
136
+ hash
137
+ end
138
+
139
+ # Set option value. Also aliased as #value=
140
+ # @param value
141
+ def set(value)
142
+ @value = handler.sanitize(value)
143
+ validate
144
+ @value
145
+ end
146
+
147
+ alias_method :value=, :set
148
+
149
+ # Returns true if this field should not be processed because of the conditionals
150
+ # @return [Boolean]
151
+ def skip?
152
+ return true if @skip_lambdas.any? { |s| s.call(self) }
153
+ return true unless @only_lambdas.all? { |s| s.call(self) }
154
+ false
155
+ end
156
+
157
+ # Get a value of another Opto::Group member
158
+ # @param [String] option_name
159
+ def value_of(option_name)
160
+ group.nil? ? nil : group.value_of(option_name)
161
+ end
162
+
163
+ # Run validators
164
+ # @raise [TypeError, ArgumentError]
165
+ def validate
166
+ handler.validate(@value)
167
+ rescue StandardError => ex
168
+ raise ex, "Validation for #{name} : #{ex.message}"
169
+ end
170
+
171
+ # Access the Opto::Type handler for this option
172
+ # @return [Opto::Type]
173
+ def handler
174
+ @handler ||= Type.for(type).new(type_options)
175
+ end
176
+
177
+ # The value of this option. Will try to run resolvers.
178
+ # @return option_value
179
+ def value
180
+ return @value unless @value.nil?
181
+ return nil if skip?
182
+ set(resolve)
183
+ @value
184
+ end
185
+
186
+ # Accessor to defined resolvers for this option.
187
+ # @return [Array<Opto::Resolver>]
188
+ def resolvers
189
+ @resolvers ||= from.map { |origin, hint| Resolver.for(origin).new(hint, self) }
190
+ end
191
+
192
+ def setters
193
+ @setters ||= to.map { |target, hint| Setter.for(target).new(hint, self) }
194
+ end
195
+
196
+ # True if this field is defined as required: true
197
+ # @return [Boolean]
198
+ def required?
199
+ handler.required?
200
+ end
201
+
202
+ # Run resolvers
203
+ # @raise [TypeError, ArgumentError]
204
+ def resolve
205
+ resolvers.each do |resolver|
206
+ begin
207
+ resolver.respond_to?(:before) && resolver.before(self)
208
+ result = resolver.resolve
209
+ resolver.respond_to?(:after) && resolver.after(self)
210
+ rescue StandardError => ex
211
+ raise ex, "Resolver '#{resolver.origin}' for '#{name}' : #{ex.message}"
212
+ end
213
+ if result
214
+ @origin = resolver.origin
215
+ return result
216
+ end
217
+ end
218
+ nil
219
+ end
220
+
221
+ # Run setters
222
+ def output
223
+ setters.each do |setter|
224
+ begin
225
+ setter.respond_to?(:before) && setter.before(self)
226
+ setter.set(value)
227
+ setter.respond_to?(:after) && setter.after(self)
228
+ rescue StandardError => ex
229
+ raise ex, "Setter '#{setter.target}' for '#{name}' : #{ex.message}"
230
+ end
231
+ end
232
+ end
233
+
234
+ # True if value is valid
235
+ # @return [Boolean]
236
+ def valid?
237
+ return true if skip?
238
+ handler.valid?(value)
239
+ end
240
+
241
+ # Validation errors
242
+ # @return [Hash]
243
+ def errors
244
+ handler.errors
245
+ end
246
+
247
+ def normalize_ifs(ifs)
248
+ case ifs
249
+ when NilClass
250
+ []
251
+ when Array
252
+ ifs.map do |iff|
253
+ lambda { |opt| !opt.value_of(iff).nil? }
254
+ end
255
+ when Hash
256
+ ifs.each_with_object([]) do |(k, v), arr|
257
+ arr << lambda { |opt| opt.value_of(k.to_s) == v }
258
+ end
259
+ when String, Symbol
260
+ [lambda { |opt| !opt.value_of(ifs.to_s).nil? }]
261
+ else
262
+ raise TypeError, "Invalid syntax for if"
263
+ end
264
+ end
265
+
266
+ def normalize_from_to(inputs)
267
+ case inputs
268
+ when Array
269
+ case inputs.first
270
+ when String, Symbol
271
+ inputs.each_with_object({}) { |o, hash| hash[o.to_s.snakecase.to_sym] = name }
272
+ when Hash
273
+ inputs.each_with_object({}) { |o, hash| o.each { |k,v| hash[k.to_s.snakecase.to_sym] = v } }
274
+ when NilClass
275
+ {}
276
+ else
277
+ raise TypeError, "Invalid format #{inputs.inspect}"
278
+ end
279
+ when Hash
280
+ inputs.each_with_object({}) { |(k, v), hash| hash[k.to_s.snakecase.to_sym] = v }
281
+ when String, Symbol
282
+ { inputs.to_s.snakecase.to_sym => name }
283
+ when NilClass
284
+ {}
285
+ else
286
+ raise TypeError, "Invalid format #{inputs.inspect}"
287
+ end
288
+ end
289
+
290
+ private
291
+
292
+ def set_initial(value)
293
+ return nil if value.nil?
294
+ @origin = :initial
295
+ set(value)
296
+ end
297
+ end
298
+ end