wavefront-cli 0.0.5 → 1.0.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.
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