opto 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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