wavefront-cli 0.0.5 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +2 -1
  3. data/Gemfile +1 -1
  4. data/README.md +21 -21
  5. data/Rakefile +2 -2
  6. data/bin/{wavefront → wf} +6 -2
  7. data/lib/wavefront-cli/alert.rb +7 -6
  8. data/lib/wavefront-cli/base.rb +27 -6
  9. data/lib/wavefront-cli/commands/alert.rb +2 -1
  10. data/lib/wavefront-cli/commands/base.rb +54 -29
  11. data/lib/wavefront-cli/commands/dashboard.rb +1 -0
  12. data/lib/wavefront-cli/commands/event.rb +7 -3
  13. data/lib/wavefront-cli/commands/integration.rb +2 -1
  14. data/lib/wavefront-cli/commands/link.rb +2 -2
  15. data/lib/wavefront-cli/commands/proxy.rb +2 -1
  16. data/lib/wavefront-cli/commands/savedsearch.rb +2 -1
  17. data/lib/wavefront-cli/commands/source.rb +3 -2
  18. data/lib/wavefront-cli/commands/webhook.rb +2 -1
  19. data/lib/wavefront-cli/commands/window.rb +2 -1
  20. data/lib/wavefront-cli/commands/write.rb +10 -6
  21. data/lib/wavefront-cli/constants.rb +1 -1
  22. data/lib/wavefront-cli/controller.rb +6 -4
  23. data/lib/wavefront-cli/dashboard.rb +6 -6
  24. data/lib/wavefront-cli/display/alert.rb +8 -4
  25. data/lib/wavefront-cli/display/base.rb +115 -170
  26. data/lib/wavefront-cli/display/cloudintegration.rb +2 -2
  27. data/lib/wavefront-cli/display/event.rb +1 -1
  28. data/lib/wavefront-cli/display/maintenancewindow.rb +1 -1
  29. data/lib/wavefront-cli/display/metric.rb +6 -7
  30. data/lib/wavefront-cli/display/printer/base.rb +24 -0
  31. data/lib/wavefront-cli/display/printer/long.rb +186 -0
  32. data/lib/wavefront-cli/display/printer/terse.rb +55 -0
  33. data/lib/wavefront-cli/display/query.rb +1 -2
  34. data/lib/wavefront-cli/display/savedsearch.rb +1 -1
  35. data/lib/wavefront-cli/display/source.rb +7 -3
  36. data/lib/wavefront-cli/display/webhook.rb +3 -4
  37. data/lib/wavefront-cli/event.rb +62 -23
  38. data/lib/wavefront-cli/exception.rb +1 -1
  39. data/lib/wavefront-cli/opt_handler.rb +6 -6
  40. data/lib/wavefront-cli/query.rb +5 -6
  41. data/lib/wavefront-cli/source.rb +5 -1
  42. data/lib/wavefront-cli/string.rb +59 -0
  43. data/lib/wavefront-cli/version.rb +1 -1
  44. data/lib/wavefront-cli/write.rb +4 -1
  45. data/spec/spec_helper.rb +1 -1
  46. data/spec/wavefront-cli/alert_spec.rb +16 -5
  47. data/spec/wavefront-cli/base_spec.rb +5 -2
  48. data/spec/wavefront-cli/cli_help_spec.rb +7 -5
  49. data/spec/wavefront-cli/cloudintegration_spec.rb +9 -0
  50. data/spec/wavefront-cli/commands/alert_spec.rb +16 -0
  51. data/spec/wavefront-cli/commands/base_spec.rb +133 -0
  52. data/spec/wavefront-cli/commands/dashboard_spec.rb +16 -0
  53. data/spec/wavefront-cli/commands/event_spec.rb +17 -0
  54. data/spec/wavefront-cli/commands/integration_spec.rb +21 -0
  55. data/spec/wavefront-cli/commands/link_spec.rb +21 -0
  56. data/spec/wavefront-cli/commands/message_spec.rb +16 -0
  57. data/spec/wavefront-cli/commands/metric_spec.rb +16 -0
  58. data/spec/wavefront-cli/commands/proxy_spec.rb +16 -0
  59. data/spec/wavefront-cli/commands/query_spec.rb +16 -0
  60. data/spec/wavefront-cli/commands/spec_helper.rb +4 -0
  61. data/spec/wavefront-cli/commands/webhook_spec.rb +16 -0
  62. data/spec/wavefront-cli/commands/window_spec.rb +21 -0
  63. data/spec/wavefront-cli/commands/write_spec.rb +17 -0
  64. data/spec/wavefront-cli/dashboard_spec.rb +14 -4
  65. data/spec/wavefront-cli/display/base_spec.rb +162 -0
  66. data/spec/wavefront-cli/display/printer/base_spec.rb +20 -0
  67. data/spec/wavefront-cli/display/printer/long_spec.rb +137 -0
  68. data/spec/wavefront-cli/display/printer/terse_spec.rb +46 -0
  69. data/spec/wavefront-cli/display/spec_helper.rb +5 -0
  70. data/spec/wavefront-cli/event_spec.rb +9 -0
  71. data/spec/wavefront-cli/externallink_spec.rb +9 -0
  72. data/spec/wavefront-cli/maintanancewindow_spec.rb +10 -0
  73. data/spec/wavefront-cli/proxy_spec.rb +9 -0
  74. data/spec/wavefront-cli/savedsearch_spec.rb +9 -0
  75. data/spec/wavefront-cli/source_spec.rb +13 -1
  76. data/spec/wavefront-cli/string_spec.rb +51 -0
  77. data/spec/wavefront-cli/user_spec.rb +2 -2
  78. data/spec/wavefront-cli/webhook_spec.rb +9 -0
  79. data/wavefront-cli.gemspec +5 -5
  80. metadata +59 -22
  81. data/Gemfile.lock +0 -65
@@ -5,10 +5,10 @@ require_relative './version'
5
5
  require_relative './opt_handler'
6
6
  require_relative './exception'
7
7
 
8
- #$LOAD_PATH.<< Pathname.new(__FILE__).dirname.realpath.parent.parent
9
- #.parent + 'lib'
10
- #$LOAD_PATH.<< Pathname.new(__FILE__).dirname.realpath.parent.parent
11
- #.parent + 'wavefront-sdk' + 'lib'
8
+ # $LOAD_PATH.<< Pathname.new(__FILE__).dirname.realpath.parent.parent
9
+ # .parent + 'lib'
10
+ # $LOAD_PATH.<< Pathname.new(__FILE__).dirname.realpath.parent.parent
11
+ # .parent + 'wavefront-sdk' + 'lib'
12
12
 
13
13
  CMD_DIR = Pathname.new(__FILE__).dirname + 'commands'
14
14
 
@@ -59,6 +59,8 @@ class WavefrontCliController
59
59
 
60
60
  begin
61
61
  [cmd, sanitize_keys(Docopt.docopt(usage[cmd], argv: args))]
62
+ rescue Docopt::DocoptLanguageError => e
63
+ abort "mangled command description:\n#{e.message}"
62
64
  rescue Docopt::Exit => e
63
65
  abort e.message
64
66
  end
@@ -10,13 +10,13 @@ module WavefrontCli
10
10
  end
11
11
 
12
12
  def do_delete
13
- print (if wf.describe(options[:'<id>']).status.code == 200
14
- 'Soft'
15
- else
16
- 'Permanently'
17
- end)
13
+ word = if wf.describe(options[:'<id>']).status.code == 200
14
+ 'Soft'
15
+ else
16
+ 'Permanently'
17
+ end
18
18
 
19
- puts " deleting dashboard '#{options[:'<id>']}'."
19
+ puts "#{word} deleting dashboard '#{options[:'<id>']}'."
20
20
  wf.delete(options[:'<id>'])
21
21
  end
22
22
 
@@ -24,17 +24,21 @@ module WavefrontDisplay
24
24
  long_output
25
25
  end
26
26
 
27
- def do_snooze
28
- print "Snoozed alert '#{options[:'<id>']}' "
27
+ def do_history
28
+ drop_fields(:inTrash)
29
+ long_output
30
+ end
29
31
 
30
- puts options[:time] ? "for #{options[:time]} seconds." :
31
- 'indefinitely.'
32
+ def do_snooze
33
+ w = options[:time] ? "for #{options[:time]} seconds" : 'indefinitely'
34
+ puts "Snoozed alert '#{options[:'<id>']}' #{w}."
32
35
  end
33
36
 
34
37
  def do_unsnooze
35
38
  puts "Unsnoozed alert '#{options[:'<id>']}'."
36
39
  end
37
40
 
41
+ # rubocop:disable Metrics/AbcSize
38
42
  def do_summary
39
43
  kw = data.keys.map(&:size).max + 2
40
44
  data.delete_if { |_k, v| v.zero? } unless options[:all]
@@ -7,45 +7,33 @@ module WavefrontDisplay
7
7
  # as that which fetches the data, in a WavefrontDisplay class,
8
8
  # extending this one.
9
9
  #
10
- # We provide long_output() and terse_output() methods to solve
10
+ # We provide #long_output() and #multicolumn() methods to solve
11
11
  # standard formatting problems. To use them, define a do_() method
12
12
  # but rather than printing the output, have it call the method.
13
13
  #
14
14
  class Base
15
15
  include WavefrontCli::Constants
16
16
 
17
- attr_reader :data, :options, :indent, :kw, :indent_str, :indent_step,
18
- :hide_blank
17
+ attr_reader :data, :options
19
18
 
20
- # Display classes can provide a do_method_code() method, which
21
- # handles <code> errors when running do_method()
19
+ # @param data [Map, Hash, Array] the data returned by the SDK
20
+ # response.
21
+ # @param options [Hash] options from docopt
22
22
  #
23
- def run_error(method)
24
- return unless respond_to?(method)
25
- send(method)
26
- exit 1
27
- end
28
-
29
23
  def initialize(data, options = {})
30
- @data = data
24
+ @data = data.is_a?(Map) ? Map(put_id_first(data)) : data
31
25
  @options = options
32
- @indent = 0
33
- @indent_step = options[:indent_step] || 2
34
- @hide_blank = options[:hide_blank] || true
35
26
  end
36
27
 
28
+ # find the correct method to deal with the output of the user's
29
+ # command.
30
+ #
37
31
  def run(method)
38
32
  if method == 'do_list'
39
- if options[:long]
40
- do_list
41
- else
42
- do_list_brief
43
- end
44
-
45
- return
46
- end
47
-
48
- if respond_to?("#{method}_brief")
33
+ run_list
34
+ elsif method == 'do_search'
35
+ run_search
36
+ elsif respond_to?("#{method}_brief")
49
37
  send("#{method}_brief")
50
38
  elsif respond_to?(method)
51
39
  send(method)
@@ -54,134 +42,64 @@ module WavefrontDisplay
54
42
  end
55
43
  end
56
44
 
57
- def long_output(fields = nil, modified_data = nil)
58
- _two_columns(modified_data || data, nil, fields)
45
+ # Choose the correct list handler. The user can specifiy a long
46
+ # listing with the --long options.
47
+ #
48
+ def run_list
49
+ if options[:long]
50
+ do_list
51
+ else
52
+ do_list_brief
53
+ end
59
54
  end
60
55
 
61
- # Extract two fields from a hash and print a list of them as
62
- # pairs.
56
+ # Choose the correct search handler. The user can specifiy a long
57
+ # listing with the --long options.
63
58
  #
64
- # @param col1 [String] the field to use in the first column
65
- # @param col2 [String] the field to use in the second column
66
- # @return [Nil]
67
- #
68
- def terse_output(col1 = :id, col2 = :name, modified_data = nil)
69
- d = modified_data || data
70
- want = d.each_with_object({}) { |r, a| a[r[col1]] = r[col2] }
71
- @indent_str = ''
72
- @kw = key_width(want)
73
-
74
- want.each do |k, v|
75
- v = v.join(', ') if v.is_a?(Array)
76
- print_line(k, v)
59
+ def run_search
60
+ if options[:long]
61
+ do_search
62
+ else
63
+ do_search_brief
77
64
  end
78
65
  end
79
66
 
80
- # Print multiple column output. Currently this method does no
81
- # word wrapping.
67
+ # Display classes can provide a do_method_code() method, which
68
+ # handles <code> errors when running do_method(). (Code is 404
69
+ # etc.)
82
70
  #
83
- # @param keys [Symbol] the keys you want in the output. They
84
- # will be printed in the order given.
71
+ # @param method [Symbol] the error method we wish to call
85
72
  #
86
- def multicolumn(*keys)
87
- len = Hash[*keys.map {|k| [k, 0]}.flatten]
88
-
89
- keys.each do |k|
90
- data.each do |obj|
91
- val = obj[k]
92
- val = val.join(', ') if val.is_a?(Array)
93
- len[k] = val.size if val.size > len[k]
94
- end
95
- end
96
-
97
- fmt = keys.each_with_object('') { |k, out| out.<< "%-#{len[k]}s " }
98
-
99
- data.each do |obj|
100
- args = keys.map do |k|
101
- obj[k].is_a?(Array) ? obj[k].join(', ') : obj[k]
102
- end
103
-
104
- puts format(fmt, *args)
105
- end
73
+ def run_error(method)
74
+ return unless respond_to?(method)
75
+ send(method)
76
+ exit 1
106
77
  end
107
78
 
108
- def set_indent(indent)
109
- @indent_str = ' ' * indent
79
+ # If the data contains an 'id' key, move it to the start.
80
+ #
81
+ def put_id_first(data)
82
+ data.key?(:id) ? { id: data[:id] }.merge(data) : data
110
83
  end
111
84
 
112
- # A recursive function which displays a key-value hash in two
113
- # columns. The key column width is automatically calculated.
114
- # Multiple-value 'v's are printed one per line. Hashes are nested.
85
+ # Default display method for 'describe' and long-list methods.
86
+ # Wraps around #_two_columns() giving you the chance to modify
87
+ # @data on the fly
115
88
  #
116
- # @param data [Array] and array of objects to display. Each object
117
- # should be a hash.
118
- # @param indent [Integer] how many characters to indent the current
119
- # data.
120
- # @kw [Integer] the width of the first (key) column.
121
- # @returns [Nil]
89
+ # @param fields [Array[Symbol]] a list of fields you wish to
90
+ # display. If this is nil, all fields are displayed.
91
+ # @param modified_data [Hash, Array] lets you modify @data
92
+ # in-line. If this is truthy, it is used. Passing
93
+ # modified_data means that any fields parameter is ignored.
122
94
  #
123
- def _two_columns(data, kw = nil, fields = nil)
124
- [data].flatten.each do |row|
125
- row.keep_if { |k, _v| fields.include?(k) } unless fields.nil?
126
- kw = key_width(row) unless kw
127
- @kw = kw unless @kw
128
- set_indent(indent)
129
-
130
- row.each do |k, v|
131
- next if v.respond_to?(:empty?) && v.empty? && hide_blank
132
-
133
- if v.is_a?(String) && v.match(/<.*>/)
134
- v = v.gsub(%r{<\/?[^>]*>}, '').delete("\n")
135
- end
136
-
137
- if v.is_a?(Hash)
138
- print_line(k)
139
- @indent += indent_step
140
- @kw -= 2
141
- _two_columns([v], kw - indent_step)
142
- elsif v.is_a?(Array)
143
- print_array(k, v)
144
- else
145
- print_line(k, v)
146
- end
147
- end
148
- puts if indent.zero?
149
- end
150
-
151
- @indent -= indent_step if indent > 0
152
- @kw += 2
153
- set_indent(indent)
154
- end
155
-
156
- def print_array(k, v)
157
- v.each_with_index do |w, i|
158
- if w.is_a?(Hash)
159
- print_line(k) if i.zero?
160
- @indent += indent_step
161
- @kw -= 2
162
- _two_columns([w], kw - indent_step)
163
- print_line('', '---') unless i == v.size - 1
164
- else
165
- if i.zero?
166
- print_line(k, v.shift)
167
- else
168
- print_line('', w)
169
- end
170
- end
171
- end
95
+ def long_output(fields = nil, modified_data = nil)
96
+ require_relative 'printer/long'
97
+ puts WavefrontDisplayPrinter::Long.new(data, fields, modified_data)
172
98
  end
173
99
 
174
- # Print a single line of output
175
- # @param key [String] what to print in the first (key) column
176
- # @param val [String, Numeric] what to print in the second column
177
- # @param indent [Integer] number of leading spaces on line
178
- #
179
- def print_line(key, value = '')
180
- if key.empty?
181
- puts ' ' * kw + value
182
- else
183
- puts indent_str + format("%-#{kw}s%s", key, value).fold(TW, kw)
184
- end
100
+ def multicolumn(*columns)
101
+ require_relative 'printer/terse'
102
+ puts WavefrontDisplayPrinter::Terse.new(data, *columns)
185
103
  end
186
104
 
187
105
  # Give it a key-value hash, and it will return the size of the first
@@ -191,31 +109,30 @@ module WavefrontDisplay
191
109
  # @param pad [Integer] the number of spaces you want between columns
192
110
  # @return [Integer] length of longest key + pad
193
111
  #
194
- def key_width(hash, pad = 2)
112
+ def key_width(hash = {}, pad = 2)
195
113
  return 0 if hash.keys.empty?
196
114
  hash.keys.map(&:size).max + pad
197
115
  end
198
116
 
199
- def indent_wrap(line, cols = 78, offset = 22)
200
- #
201
- # hanging indent long lines to fit in an 80-column terminal
202
- #
203
- return unless line
204
- line.gsub(/(.{1,#{cols - offset}})(\s+|\Z)/, "\\1\n#{' ' *
205
- offset}").rstrip
206
- end
207
-
117
+ # return [String] the name of the thing we're operating on, like
118
+ # 'alert' or 'dashboard'.
119
+ #
208
120
  def friendly_name
209
121
  self.class.name.split('::').last.gsub(/([a-z])([A-Z])/, '\\1 \\2')
210
122
  .downcase
211
123
  end
212
124
 
125
+ # The following do_ methods are default handlers called
126
+ # following their namesake operation in the corresponding
127
+ # WavefrontCli class. They can be overriden in the inheriting
128
+ # class.
129
+ #
213
130
  def do_list
214
131
  long_output
215
132
  end
216
133
 
217
134
  def do_list_brief
218
- terse_output
135
+ multicolumn(:id, :name)
219
136
  end
220
137
 
221
138
  def do_import
@@ -231,6 +148,26 @@ module WavefrontDisplay
231
148
  puts "Undeleted #{friendly_name} '#{options[:'<id>']}'."
232
149
  end
233
150
 
151
+ def do_search_brief
152
+ display_keys = ([:id] + options[:'<condition>'].map do |c|
153
+ c.split(/\W/, 2).first.to_sym
154
+ end).uniq
155
+
156
+ if data.empty?
157
+ puts 'No matches.'
158
+ else
159
+ multicolumn(*display_keys)
160
+ end
161
+ end
162
+
163
+ def do_search
164
+ if data.empty?
165
+ puts 'No matches.'
166
+ else
167
+ long_output
168
+ end
169
+ end
170
+
234
171
  def do_tag_add
235
172
  puts "Tagged #{friendly_name} '#{options[:'<id>']}'."
236
173
  end
@@ -259,46 +196,54 @@ module WavefrontDisplay
259
196
  # we deem not of interest to the user.
260
197
  #
261
198
  # @param keys [Symbol] keys you do not wish to be shown.
199
+ # @return [Nil]
262
200
  #
263
201
  def drop_fields(*keys)
264
- data.delete_if { |k, _v| keys.include?(k.to_sym) }
202
+ if data.is_a?(Array)
203
+ data.each { |i| i.delete_if { |k, _v| keys.include?(k.to_sym) } }
204
+ else
205
+ data.delete_if { |k, _v| keys.include?(k.to_sym) }
206
+ end
265
207
  end
266
208
 
267
209
  # Modify, in-place, the data structure to make times
268
210
  # human-readable. Automatically handles second and millisecond
269
- # epoch times.
211
+ # epoch times. Currently only operates on top-level keys.
212
+ #
213
+ # param keys [Symbol, Array[Symbol]] the keys you wish to be
214
+ # turned into readable times.
215
+ # return [Nil]
270
216
  #
271
217
  def readable_time(*keys)
272
- keys.each do |k|
273
- next unless data.key?(k)
274
- data[k] = human_time(data[k])
275
- end
218
+ keys.each { |k| data[k] = human_time(data[k]) if data.key?(k) }
276
219
  end
277
220
 
278
- def human_time(t)
221
+ # Make a time human-readable. Automatically deals with epoch
222
+ # seconds and epoch milliseconds
223
+ #
224
+ # param t [Integer, String] a timestamp. If it's a string, it is
225
+ # converted to an int.
226
+ # param force_utc [Boolean] force output in UTC. Currently only
227
+ # used for unit tests.
228
+ # return [String] a human-readable timestamp
229
+ #
230
+ def human_time(t, force_utc = false)
231
+ raise ArgumentError unless t.is_a?(Numeric) || t.is_a?(String)
279
232
  str = t.to_s
280
233
 
281
- if str.length == 13
234
+ if str =~ /^\d{13}$/
282
235
  fmt = '%Q'
283
236
  out_fmt = HUMAN_TIME_FORMAT_MS
284
- else
237
+ elsif str =~ /^\d{10}$/
285
238
  fmt = '%s'
286
239
  out_fmt = HUMAN_TIME_FORMAT
240
+ else
241
+ raise ArgumentError
287
242
  end
288
243
 
289
- DateTime.strptime(str, fmt).strftime(out_fmt)
244
+ ret = DateTime.strptime(str, fmt).to_time
245
+ ret = ret.utc if force_utc
246
+ ret.strftime(out_fmt)
290
247
  end
291
-
292
- end
293
- end
294
-
295
- # Extensions to the String class to help with formatting.
296
- #
297
- class String
298
-
299
- # Fold long command lines and suitably indent
300
- #
301
- def fold(width = TW, indent = 10)
302
- scan(/\S.{0,#{width - 2}}\S(?=\s|$)|\S+/).join("\n" + ' ' * indent)
303
248
  end
304
249
  end
@@ -6,12 +6,12 @@ module WavefrontDisplay
6
6
  #
7
7
  class CloudIntegration < Base
8
8
  def do_list_brief
9
- terse_output(:id, :service)
9
+ multicolumn(:id, :service)
10
10
  end
11
11
 
12
12
  def do_describe
13
13
  readable_time(:lastReceivedDataPointMs, :lastProcessingTimestamp)
14
- drop_fields(:forceSave)
14
+ drop_fields(:forceSave, :inTrash, :deleted)
15
15
  long_output
16
16
  end
17
17
  end
@@ -13,7 +13,7 @@ module WavefrontDisplay
13
13
  end
14
14
 
15
15
  def do_list_brief
16
- terse_output(:id, :runningState)
16
+ multicolumn(:id, :runningState)
17
17
  end
18
18
  end
19
19
  end
@@ -13,7 +13,7 @@ module WavefrontDisplay
13
13
  end
14
14
 
15
15
  def do_list_brief
16
- terse_output(:id, :title)
16
+ multicolumn(:id, :title)
17
17
  end
18
18
  end
19
19
  end
@@ -6,17 +6,16 @@ module WavefrontDisplay
6
6
  #
7
7
  class Metric < Base
8
8
  def do_describe
9
- if data.hosts.empty?
10
- puts "No matches."
9
+ if data.empty? || data.hosts.empty?
10
+ puts 'No matches.'
11
11
  exit
12
12
  end
13
13
 
14
- modified_data = data['hosts'].map do |h, aggr|
15
- { host: h[:host],
16
- last_update: human_time(h[:last_update]) }
17
- end.sort_by{ |h| h[:last_update] }.reverse
14
+ @data = data['hosts'].map do |h, _aggr|
15
+ { host: h[:host], last_update: human_time(h[:last_update]) }
16
+ end.sort_by { |h| h[:last_update] }.reverse
18
17
 
19
- terse_output(:host, :last_update, modified_data)
18
+ multicolumn(:host, :last_update)
20
19
  end
21
20
  end
22
21
  end
@@ -0,0 +1,24 @@
1
+ module WavefrontDisplayPrinter
2
+ #
3
+ # Base class for the two printer classes
4
+ #
5
+ class Base
6
+ attr_reader :out
7
+
8
+ # Give it a key-value hash, and it will return the size of the first
9
+ # column to use when formatting that data.
10
+ #
11
+ # @param hash [Hash] the data for which you need a column width
12
+ # @param pad [Integer] the number of spaces you want between columns
13
+ # @return [Integer] length of longest key + pad
14
+ #
15
+ def key_width(hash = {}, pad = 2)
16
+ return 0 if hash.keys.empty?
17
+ hash.keys.map(&:size).max + pad
18
+ end
19
+
20
+ def to_s
21
+ out.join("\n")
22
+ end
23
+ end
24
+ end