Class: Aspera::Cli::Formatter

Inherits:
Object
  • Object
show all
Defined in:
lib/aspera/cli/formatter.rb

Overview

Take care of CLI output on terminal

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeFormatter

initialize the formatter



66
67
68
69
# File 'lib/aspera/cli/formatter.rb', line 66

def initialize
  @options = {}
  @spinner = nil
end

Class Method Details

.all_but(list) ⇒ Object



60
61
62
# File 'lib/aspera/cli/formatter.rb', line 60

def all_but(list)
  Array(list).map{ |i| "#{FIELDS_LESS}#{i}"}.unshift(SpecialValues::ALL)
end

.declare_options(options) ⇒ nil

Declare all formatter CLI options (metadata only - no handler binding yet).

Parameters:

Returns:

  • (nil)


116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/aspera/cli/formatter.rb', line 116

def declare_options(options)
  options.declare(:display,      description: 'Output only some information',                                                                      allowed: DISPLAY_LEVELS,             default: :data)
  options.declare(:format,       description: 'Output format',                                                                                     allowed: DISPLAY_FORMATS,            default: :table)
  options.declare(:output,       description: 'Destination for results')
  options.declare(:fields,       description: "Comma separated list of: fields, or #{SpecialValues::ALL}, or #{SpecialValues::DEF}", allowed: [String, Array, Regexp, Proc], default: SpecialValues::DEF)
  options.declare(:select,       description: 'Select only some items in lists: column, value',                                                    allowed: [Hash, Proc])
  options.declare(:table_style,  description: '(Table) Display style',                                                                             allowed: [Hash])
  options.declare(:flat_hash,    description: '(Table) Display deep values as additional keys',                                                    allowed: Allowed::TYPES_BOOLEAN,     default: true)
  options.declare(:multi_single, description: '(Table) Control how object list is displayed as single table, or multiple objects',                 allowed: i[no yes single],          default: :no)
  options.declare(:show_secrets, description: 'Show secrets on command output',                                                                    allowed: Allowed::TYPES_BOOLEAN,     default: false)
  options.declare(:image,        schema: Schema::Registry::IMAGE_OPTIONS)
  nil
end

.replace_specific_for_terminal(input_hash, string_list_separator) ⇒ Object

Replace special values with a readable version on terminal



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/aspera/cli/formatter.rb', line 27

def replace_specific_for_terminal(input_hash, string_list_separator)
  hash_to_process = [input_hash]
  until hash_to_process.empty?
    current = hash_to_process.pop
    current.each do |key, value|
      case value
      when NilClass
        current[key] = TerminalFormatter.special_format('null')
      when String
        current[key] = TerminalFormatter.special_format('empty string') if value.empty?
      when Proc
        current[key] = TerminalFormatter.special_format('lambda')
      when Array
        if value.empty?
          current[key] = TerminalFormatter.special_format('empty list')
        elsif value.all?(String)
          current[key] = value.join(string_list_separator)
        else
          value.each do |item|
            hash_to_process.push(item) if item.is_a?(Hash)
          end
        end
      when Hash
        if value.empty?
          current[key] = TerminalFormatter.special_format('empty dict')
        else
          hash_to_process.push(value)
        end
      end
    end
  end
end

Instance Method Details

#all_fields(data) ⇒ Array<String>

Returns all fields of all objects in list of objects.

Returns:

  • (Array<String>)

    all fields of all objects in list of objects



260
261
262
# File 'lib/aspera/cli/formatter.rb', line 260

def all_fields(data)
  data.each_with_object({}){ |v, m| v.each_key{ |c| m[c] = true}}.keys
end

#bind_options(options) ⇒ nil

Bind all formatter options to this instance using set_handler. Called from Runner after Formatter.new.

Parameters:

Returns:

  • (nil)


135
136
137
138
139
140
# File 'lib/aspera/cli/formatter.rb', line 135

def bind_options(options)
  i[display format output fields select table_style flat_hash multi_single show_secrets image].each do |opt|
    options.set_handler(opt, object: self, method: :option_handler)
  end
  nil
end

#compute_fields(data, default) ⇒ Array<String>

Returns the list of fields to display.

Parameters:

  • data (Array<Hash>)

    data to display

  • default (Array<String>, Proc)

    list of fields to display by default (may contain special values)

Returns:

  • (Array<String>)

    the list of fields to display



267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
# File 'lib/aspera/cli/formatter.rb', line 267

def compute_fields(data, default)
  Log.log.debug{"compute_fields: data:#{data.class} default:#{default.class} #{default}"}
  Log.dump(:compute_fields_default, default, level: :trace1)
  # the requested list of fields, but if can contain special values
  request =
    case @options[:fields]
    # when NilClass then [SpecialValues::DEF]
    when String then @options[:fields].split(',')
    when Array then @options[:fields]
    when Regexp then return all_fields(data).select{ |i| i.match(@options[:fields])}
    when Proc then return all_fields(data).select{ |i| @options[:fields].call(i)}
    else Aspera.error_unexpected_value(@options[:fields])
    end
  Aspera.assert_array_all(request, String)
  result = []
  until request.empty?
    item = request.shift
    removal = false
    if item[0].eql?(FIELDS_LESS)
      removal = true
      item = item.delete_prefix(FIELDS_LESS)
    end
    case item
    when SpecialValues::ALL
      # get the list of all column names used in all lines, not just first one, as all lines may have different columns
      request.unshift(*all_fields(data))
    when SpecialValues::DEF
      default = all_fields(data).select{ |i| default.call(i)} if default.is_a?(Proc)
      default = all_fields(data) if default.nil?
      request.unshift(*default)
    else
      if removal
        result = result.reject{ |i| i.eql?(item)}
      else
        result.push(item)
      end
    end
  end
  Log.dump(:compute_fields, result, level: :trace1)
  return result
end

#display_item_count(count, total) ⇒ Object

Display item count if total is provided



72
73
74
75
76
77
78
79
80
# File 'lib/aspera/cli/formatter.rb', line 72

def display_item_count(count, total)
  return if total.nil?
  count = count.to_i
  total = total.to_i
  return if total.eql?(0) && count.eql?(0)
  count_msg = "Items: #{count}/#{total}"
  count_msg = count_msg.bg_red unless count.eql?(total)
  display_status(count_msg)
end

#display_message(message_level, message, hide_secrets: true) ⇒ nil

Note:

Message display behavior depends on the message_level:

  • :data messages are displayed unless display level is :error
  • :info messages are only displayed when display level is :info
  • :error messages are always displayed on stderr

Main output method for displaying messages to the user This method is used by Result classes to output formatted data

Parameters:

  • message_level (Symbol)

    The level of the message - must be one of: :data, :info, :error

  • message (String)

    The message to display

  • hide_secrets (Boolean) (defaults to: true)

    Whether to hide secrets in the message (default: true)

Returns:

  • (nil)


185
186
187
188
189
190
191
192
193
# File 'lib/aspera/cli/formatter.rb', line 185

def display_message(message_level, message, hide_secrets: true)
  message = SecretHider.instance.hide_secrets_in_string(message) if hide_secrets && message.is_a?(String) && hide_secrets?
  case message_level
  when :data then $stdout.puts(message) unless @options[:display].eql?(:error)
  when :info then $stdout.puts(message) if @options[:display].eql?(:info)
  when :error then $stderr.puts(message) # rubocop:disable Style/StderrPuts
  else Aspera.error_unexpected_value(message_level)
  end
end

#display_results(result) ⇒ Object

Display results using the Visitor pattern Each Result subclass knows how to format itself by calling the appropriate formatter method, eliminating the need for type checking in the formatter.

Parameters:

  • result (Result)

    Result object to display



213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/aspera/cli/formatter.rb', line 213

def display_results(result)
  require 'aspera/cli/result'
  Aspera.assert_type(result, Cli::Result){'result must be a Result object'}

  Log.log.debug{"display_results: result class=#{result.class.name}"}
  Log.dump(:data, result.data, level: :trace1)
  Log.dump(:fields, result.fields, level: :trace1)

  # Hide secrets in data
  hide_secrets(result.data)

  # Use the Visitor pattern: delegate formatting to the result object
  result.format(self)
end

#display_status(status, **kwargs) ⇒ Object



195
196
197
# File 'lib/aspera/cli/formatter.rb', line 195

def display_status(status, **kwargs)
  display_message(:info, status, **kwargs)
end

#display_table(object_array, fields, single: false) ⇒ Object

Displays a list of objects

Parameters:

  • object_array (Array<Hash>)

    Array of hash

  • fields (Array<String>)

    List of column names

  • single (Boolean) (defaults to: false)

    Contains a single object to display



342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
# File 'lib/aspera/cli/formatter.rb', line 342

def display_table(object_array, fields, single: false)
  Aspera.assert_array_all(object_array, Hash)
  Aspera.assert_array_all(fields, String)
  if object_array.empty?
    # no  display for csv
    display_message(:info, TerminalFormatter.special_format('empty')) if @options[:format].eql?(:table)
    return
  end
  filter_columns_on_select(object_array)
  format_style = @options[:table_style].symbolize_keys
  string_list_separator = format_style.delete(:str_lst_sep) || STR_LST_SEP_VERT
  # convert data to string, and keep only display fields
  object_array.each{ |i| self.class.replace_specific_for_terminal(i, string_list_separator)}
  # if table has only one element, and only one field, display the value
  if object_array.length == 1 && fields.length == 1
    Log.log.debug("single element, field: #{fields.first}")
    data = object_array.first[fields.first]
    unless data.is_a?(Array) && data.all?(Hash)
      display_message(:data, data)
      return
    end
    object_array = data
    fields = all_fields(object_array)
    single = false
  end
  Log.dump(:object_array, object_array)
  Log.dump(:fields, fields)
  # convert data to string, and keep only display fields
  final_table_rows = object_array.map{ |r| fields.map{ |c| r[c].to_s}}
  # remove empty rows
  final_table_rows.select!{ |i| !(i.is_a?(Hash) && i.empty?)}
  # fields: list of column names to display
  case @options[:format]
  when :table
    format_style[:border] = :unicode_round if Environment.terminal_supports_unicode?
    if single || @options[:multi_single].eql?(:yes) ||
        (@options[:multi_single].eql?(:single) && final_table_rows.length.eql?(1))
      # display multiple objects as multiple transposed tables
      final_table_rows.each do |row|
        display_message(:data, Terminal::Table.new(
          headings:  SINGLE_OBJECT_COLUMN_NAMES,
          rows:      fields.zip(row),
          style:     format_style
        ))
      end
    else
      # display the table as single table
      display_message(:data, Terminal::Table.new(
        headings:  fields,
        rows:      final_table_rows,
        style:     format_style
      ))
    end
  when :csv
    add_headers = format_style.delete(:headers)
    output = CSV.generate(**format_style) do |csv|
      csv << fields if add_headers
      final_table_rows.each do |row|
        csv << row
      end
    end
    display_message(:data, output)
  else
    raise "not expected: #{@options[:format]}"
  end
  nil
end

#filter_columns_on_select(data) ⇒ Object

filter the list of items on the select option

Parameters:

  • data (Array<Hash>)

    list of items



325
326
327
328
329
330
331
332
333
334
335
336
# File 'lib/aspera/cli/formatter.rb', line 325

def filter_columns_on_select(data)
  case @options[:select]
  when Proc
    begin
      data.select!{ |i| @options[:select].call(i)}
    rescue StandardError => e
      raise Cli::BadArgument, "Error in user-provided ruby lambda code during select: #{e.message}"
    end
  when Hash
    @options[:select].each{ |k, v| data.select!{ |i| i[k].eql?(v)}}
  end
end

#filter_list_on_fields(data) ⇒ Object

filter the list of items on the fields option



310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/aspera/cli/formatter.rb', line 310

def filter_list_on_fields(data)
  # no filter for single element
  return data unless data.is_a?(Array)
  # by default, keep all data intact
  return data if @options[:fields].eql?(SpecialValues::DEF) && @options[:select].nil?
  Aspera.assert_array_all(data, Hash){'filter or select'}
  filter_columns_on_select(data)
  return data if @options[:fields].eql?(SpecialValues::DEF)
  selected_fields = compute_fields(data, @options[:fields])
  return data.map{ |i| i[selected_fields.first]} if selected_fields.length == 1
  return data.map{ |i| i.slice(*selected_fields)}
end

#flat_hash?Boolean

Check if flat_hash option is enabled

Returns:

  • (Boolean)


250
251
252
# File 'lib/aspera/cli/formatter.rb', line 250

def flat_hash?
  @options[:flat_hash]
end

#format_typeObject

Get the current format type



230
231
232
# File 'lib/aspera/cli/formatter.rb', line 230

def format_type
  @options[:format]
end

#format_type=(format) ⇒ Object

Set the format type (used by Image result)



235
236
237
# File 'lib/aspera/cli/formatter.rb', line 235

def format_type=(format)
  @options[:format] = format
end

#hide_secrets(data) ⇒ Object

Hides secrets in Hash or Array



205
206
207
# File 'lib/aspera/cli/formatter.rb', line 205

def hide_secrets(data)
  SecretHider.instance.deep_remove_secret(data) if hide_secrets?
end

#hide_secrets?Boolean

Check if secrets should be hidden

Returns:

  • (Boolean)


200
201
202
# File 'lib/aspera/cli/formatter.rb', line 200

def hide_secrets?
  !@options[:show_secrets] && !@options[:display].eql?(:data)
end

#hide_secrets_in_string(string) ⇒ Object

Hide secrets in a string



240
241
242
# File 'lib/aspera/cli/formatter.rb', line 240

def hide_secrets_in_string(string)
  SecretHider.instance.hide_secrets_in_string(string)
end

#image_optionsObject

Get image options



255
256
257
# File 'lib/aspera/cli/formatter.rb', line 255

def image_options
  @options[:image].symbolize_keys
end

#long_operation(title = nil, action: :spin) ⇒ Object



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/aspera/cli/formatter.rb', line 82

def long_operation(title = nil, action: :spin)
  return unless Environment.terminal?
  return if i[error data].include?(@options[:display])

  # Handle the "delayed start" state
  return @spinner = :starting if action == :spin && @spinner.nil?

  # Cleanup if we try to stop a spinner that never actually started
  @spinner = nil if action != :spin && @spinner == :starting
  return if @spinner.nil?

  # Initialize the real TTY object if it's currently just the :starting symbol
  if @spinner == :starting
    @spinner = TTY::Spinner.new('[:spinner] :title', format: :classic)
    @spinner.update(title: '')
    @spinner.start
  end

  @spinner.update(title: title) if title

  case action
  when :spin
    @spinner.spin
  when :success, :fail
    action == :success ? @spinner.success : @spinner.error
    @spinner.stop
    @spinner = nil
  end
end

#option_handler(option_symbol, operation, value = nil) ⇒ Object?

Getter/setter handler called by the option manager for all formatter options

Parameters:

  • option_symbol (Symbol)

    Option name (one of :format, :output, :display, :fields, :select, :table_style, :flat_hash, :multi_single, :show_secrets, :image)

  • operation (Symbol)

    :get or :set

  • value (Object, nil) (defaults to: nil)

    Value to set (only used when operation is :set)

Returns:

  • (Object, nil)

    Current option value when operation is :get; nil otherwise



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/aspera/cli/formatter.rb', line 147

def option_handler(option_symbol, operation, value = nil)
  Aspera.assert_values(operation, i[set get])
  case operation
  when :set
    @options[option_symbol] = value
    # special handling of some options
    case option_symbol
    when :format
      @options[:display] = value.eql?(:table) ? :info : :data
    when :output
      $stdout = if value.eql?('-')
        STDOUT # rubocop:disable Style/GlobalStdStream
      else
        File.open(value, 'w')
      end
    when :image
      # get list if key arguments of method
      allowed_options = Preview::Terminal.method(:build).parameters.select{ |i| i[0].eql?(:key)}.map{ |i| i[1]}
      # check that only supported options are given
      unknown_options = value.keys.map(&:to_sym) - allowed_options
      Aspera.assert(unknown_options.empty?){"Invalid parameter(s) for option image: #{unknown_options.join(', ')}, use #{allowed_options.join(', ')}"}
    end
  when :get then return @options[option_symbol]
  else Aspera.error_unreachable_line
  end
  nil
end

#special_format(text) ⇒ Object

Get special format string



245
246
247
# File 'lib/aspera/cli/formatter.rb', line 245

def special_format(text)
  TerminalFormatter.special_format(text)
end