Module: Structured::ClassMethods

Defined in:
lib/structured.rb

Overview

Methods extended to a Structured class. A class would typically use the following methods within its class body:

  • #set_description to set a textual description of the object

  • #element to define expected elements of the input hash

  • #default_element to define processing of unknown element keys

The #explain method is also useful for printing out documentation for a Structured class.

Instance Method Summary collapse

Instance Method Details

#apply_val(obj, elt, val) ⇒ Object

Applies a value to an element for an object, after all processing for the value is done.



573
574
575
576
577
578
579
# File 'lib/structured.rb', line 573

def apply_val(obj, elt, val)
  if obj.respond_to?("receive_#{elt}")
    obj.send("receive_#{elt}".to_sym, val)
  else
    obj.instance_variable_set("@#{elt}", val)
  end
end

#build_from_hash(obj, hash) ⇒ Object

Given a hash, extracts all the elements from it and updates the object accordingly. This method is called automatically upon initialization of the Structured class.

Parameters:

  • obj

    the object to update

  • hash

    the data hash.



456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
# File 'lib/structured.rb', line 456

def build_from_hash(obj, hash)
  input_err("Initializer is not a Hash") unless hash.is_a?(Hash)
  hash = try_read_file(hash)

  @elements.each do |key, data|
    Structured.trace(key.to_s) do
      val = hash[key] || hash[key.to_s]
      cval = process_value(obj, val, data)
      apply_val(obj, key, cval) if cval
    end
  end

  # Process unknown elements
  unknown_keys = hash.keys.reject { |k| @elements.include?(k.to_sym) }
  return if unknown_keys.empty?
  unless @default_element
    input_err("Unexpected element(s): #{unknown_keys.join(', ')}")
  end
  unknown_keys.each do |key|
    Structured.trace(key.to_s) do
      val = hash[key]
      ckey = process_value(obj, key, @default_key)
      cval = process_value(obj, val, @default_element)
      next unless cval
      cval.receive_key(ckey) if cval.is_a?(Structured)
      obj.receive_any(ckey, cval)
    end
  end
end

#convert_item(item, type, parent) ⇒ Object

Given an expected type and an item, checks that the item matches the expected type, and performs any necessary conversions.



593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
# File 'lib/structured.rb', line 593

def convert_item(item, type, parent)
  case type
    #
    # In the when cases, the type is not just a class object
    #
  when :boolean
    return item if item.is_a?(TrueClass) || item.is_a?(FalseClass)
    input_err("#{item} is not boolean")

  when Array
    input_err("#{item} is not Array") unless item.is_a?(Array)
    Structured.trace(Array) do
      item = try_read_array(item[1..-1]) if item.first.to_s == 'read_file'
      return item.map.with_index { |i, idx|
        Structured.trace(idx) do
          convert_item(i, type.first, parent)
        end
      }
    end

  when Hash
    input_err("#{item} is not Hash") unless item.is_a?(Hash)
    Structured.trace(Hash) do
      return item.map { |k, v|
        Structured.trace(k.to_s) do
          conv_key = convert_item(k, type.first.first, parent)
          conv_item = convert_item(v, type.first.last, parent)
          conv_item.receive_key(conv_key) if conv_item.is_a?(Structured)
          [ conv_key, conv_item ]
        end
      }.to_h
    end

  else

    #
    # In these cases, the type is a class object. It can't be tested with
    # the === operator of a case/when.
    #
    # If the item can be automatically coverted to the expected type
    citem = try_autoconvert(type, item)

    # If the item is of the expected type, then return it
    return citem if citem.is_a?(type)

    # The only remaining hope for conversion is that type is Structured and
    # item is a hash
    return convert_structured(citem, type, parent)
  end
end

#convert_structured(item, type, parent) ⇒ Object

Receive hash values that are to be converted to Structured objects



673
674
675
676
677
678
679
680
681
682
683
684
685
686
# File 'lib/structured.rb', line 673

def convert_structured(item, type, parent)
  unless item.is_a?(Hash)
    if type.include?(Structured)
      input_err("#{item.inspect} not a Structured hash for #{type}")
    else
      input_err("#{item.inspect} not a #{type}")
    end
  end

  unless type.include?(Structured) || type.include?(StructuredPolymorphic)
    input_err("#{type} is not a Structured class")
  end
  return type.new(item, parent)
end

#default_element(*args, **params) ⇒ Object

Accepts a default element for this class. The arguments are the same as those for element_data except as noted below.

If this method is called, then for any keys found in an input hash that have no corresponding #element declaration in the Structured class, the method receive_any will be invoked. The value from the input hash will be processed based on any type declaration, ‘preproc`, and `check` given to default_element.

The default element keys can be processed based on the argument ‘key`, which should be a hash corresponding to the element_data arguments plus the key :type with the default key’s expected type. If ‘key` is not given, then the key must be and is automatically converted to a Symbol.

Caution: The ‘type` argument should almost always be a single class, and not a hash. This is because the default arguments are automatically treated like a hash, with the otherwise-undefined element names being the keys of the hash.



342
343
344
345
346
347
348
349
350
351
# File 'lib/structured.rb', line 342

def default_element(*args, **params)
  if (key_params = params.delete(:key))
    @default_key = element_data(
      key_params.delete(:type) || Object, **key_params
    )
  else
    @default_key = element_data(Symbol, preproc: proc { |s| s.to_sym })
  end
  @default_element = element_data(*args, **params)
end

#describe_type(type) ⇒ Object

Provides a textual description of a type.



736
737
738
739
740
741
742
743
744
745
# File 'lib/structured.rb', line 736

def describe_type(type)
  case type
  when :boolean then 'Boolean'
  when Array then "Array of #{describe_type(type.first)}"
  when Hash
    desc1, desc2 = type.first.map { |x| describe_type(x) }
    "Hash of #{desc1} => #{desc2}"
  else return type.to_s
  end
end

#description(len = nil) ⇒ Object

Returns the class’s description. The given number can be used to limit the length of the description.



284
285
286
287
288
289
290
291
# File 'lib/structured.rb', line 284

def description(len = nil)
  desc = @class_description || ''
  if len && desc.length > len
    return desc[0, len] if len <= 5
    return desc[0, len - 3] + '...'
  end
  return desc
end

#each_elementObject

Iterates elements in a useful sorted order.



436
437
438
439
440
441
442
443
444
445
446
# File 'lib/structured.rb', line 436

def each_element
  @elements.sort_by { |e, data|
    if data[:optional] == :omit
      [ 3, e.to_s ]
    else
      [ data[:optional] ? 2 : 1, e.to_s ]
    end
  }.each do |e, data|
    yield(e, data)
  end
end

#element(name, *args, attr: true, **params) ⇒ Object

Declares that the class expects an element with the given name and type. See element_data for an explanation of *args and **params.

the given element. Default is true.

Parameters:

  • name (Symbol)

    The name of the element.

  • attr (defaults to: true)

    Whether to create an attribute (i.e., call attr_reader) for



301
302
303
304
305
306
307
308
309
310
311
312
# File 'lib/structured.rb', line 301

def element(name, *args, attr: true, **params)
  @elements[name.to_sym] = element_data(*args, **params)
  #
  # By default, when an element is received, a corresponding instance
  # variable is set. Classes using Structured can define +receive_[name]+ so
  # that the element declaration will perform other tasks.
  #
  # This creates the reader attribute only if there is no other method of
  # the same name.
  #
  attr_reader(name) if attr && !method_defined?(name)
end

#element_data(type, optional: false, description: nil, preproc: nil, default: nil, check: nil) ⇒ Object

Processes the definition of an element.

  • A class.

  • The value :boolean, indicating that a boolean is acceptable.

  • An array containing a single element being a class, signifying that the expected type is an array of elements matching that class.

  • A hash containing a single Class1 => Class2 pair, signifying that the expected type is a hash of key-value pairs matching the indicated classes. If Class2 is a Structured class, then Class2 objects will have their Structured#receive_key method called, with the corresponding Class1 object as the argument.

convert it. The proc will be executed in the context of the receiving object.

is also used for optional elements that are not specified in an input hash.

value. This may be:

  • A Proc, in which case it should return true for valid values.

  • An Array of valid values (tested by ===}).

  • Any other object, in which case validity is determined by whether the check value === the element value.

Parameters:

  • type

    The expected type of the element value. This may be:

  • optional (defaults to: false)

    Whether the element is optional. Set to :omit to omit it from templates.

  • description (defaults to: nil)

    A text description of the element.

  • preproc (defaults to: nil)

    A Proc that will be executed on the element value to

  • default (defaults to: nil)

    A default value, entered into templates. The default value

  • check (defaults to: nil)

    A mechanism for checking for the validity of an element



392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
# File 'lib/structured.rb', line 392

def element_data(
  type,
  optional: false, description: nil,
  preproc: nil, default: nil, check: nil
)
  # Check the type argument
  case type
  when Class, :boolean
  when Array
    unless type.count == 1 && type.first.is_a?(Class)
      raise TypeError, "Invalid Array type declaration"
    end
  when Hash
    unless type.count == 1 && type.first.all? { |x| x.is_a?(Class) }
      raise TypeError, "Invalid Hash type declaration"
    end
  else
    raise TypeError, "Invalid type declaration #{type.inspect}"
  end

  if preproc
    raise TypeError, "preproc must be a Proc" unless preproc.is_a?(Proc)
  end

  case check
  when nil, Proc then check_obj = check # Pass through
  when Array then check_obj = proc { |o| check.any? { |c| c === o } }
  else check_obj = proc { |o| check === o }
  end

  return {
    :type => type,
    :optional => optional,
    :description => description,
    :preproc => preproc,
    :default => default,
    :check => check_obj,
  }

end

#explain(io = STDOUT) ⇒ Object

Prints out documentation for this class.



700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
# File 'lib/structured.rb', line 700

def explain(io = STDOUT)
  io.puts("Structured Class #{self}:")
  if @class_description
    io.puts("\n" + TextTools.line_break(@class_description, prefix: '  '))
  end
  io.puts

  each_element do |elt, data|
    io.puts(
      "  #{elt}: #{describe_type(data[:type])}" + \
      "#{data[:optional] ? ' (optional)' : ''}"
    )
    if data[:description]
      io.puts(TextTools.line_break(data[:description], prefix: '    '))
      io.puts()
    end
  end

  if @default_element
    io.puts(
      "  All other elements: #{describe_type(@default_key[:type])} => " \
      "#{describe_type(@default_element[:type])}"
    )
    if @default_element[:description]
      io.puts(TextTools.line_break(
        @default_element[:description], prefix: '    '
      ))
    end
    io.puts()
  end

end

#input_err(text) ⇒ Object

Raises an InputError.

Raises:



692
693
694
# File 'lib/structured.rb', line 692

def input_err(text)
  raise InputError, text
end

#process_nil_val(val, data) ⇒ Object

Performs processing of an element value to deal with the possibility that the value is nil. This method returns [ the new value, boolean of whether to stop processing ] according to the following rules:

  • If val is non-nil, then this method returns val itself, and processing should not stop.

  • If val is nil and this element is non-optional, then this method raises an error.

  • If val is nil and the element is optional, then the object’s default value is returned, and processing should stop.

  • If there is no default value for an optional element, then nil is returned, and processing should also stop.



563
564
565
566
567
568
569
# File 'lib/structured.rb', line 563

def process_nil_val(val, data)
  return [ val, false ] unless val.nil?
  unless data[:optional]
    input_err("Required element is missing (or was deleted by a preproc)")
  end
  return [ data[:default], true ]
end

#process_value(obj, val, data) ⇒ Object

Given an element value and an #element_data hash of processing tools element, applies those processing tools. Namely, apply any preproc, check the type and perform other checks, and perform any conversions. The return value should be usable as the received value for the corresponding element.

If this method returns nil, then there is no element to process. This method may also raise an InputError.



533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
# File 'lib/structured.rb', line 533

def process_value(obj, val, data)
  val, ret = process_nil_val(val, data)
  return val if ret
  if data[:preproc]
    val = try_run(data[:preproc], obj, val, "preproc")
    val, ret = process_nil_val(val, data)
    return val if ret
  end

  cval = convert_item(val, data[:type], obj)
  if data[:check] && !try_run(data[:check], obj, cval, "check")
    input_err "Value #{cval} failed check"
  end
  return cval
end

#remove_element(name) ⇒ Object

Removes an element. Note that the attribute definition if any and the receive_[name] method are left intact.



318
319
320
# File 'lib/structured.rb', line 318

def remove_element(name)
  @elements.delete(name.to_sym)
end

#reset_elementsObject

Sets up a class to manage elements. This method is called when Structured is included in the class.

As an implementation note: Information about a Structured class is stored in instance variables of the class’s object.



266
267
268
269
270
271
# File 'lib/structured.rb', line 266

def reset_elements
  @elements = {}
  @default_element = nil
  @default_key = nil
  @class_description = nil
end

#set_description(desc) ⇒ Object

Provides a description of this class, for use with the #explain method.



276
277
278
# File 'lib/structured.rb', line 276

def set_description(desc)
  @class_description = desc
end

#template(indent: '') ⇒ Object

Produces a template YAML file for this Structured object.



749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
# File 'lib/structured.rb', line 749

def template(indent: '')
  res = "#{indent}# #{name}\n"
  if @class_description
    res << TextTools.line_break(@class_description, prefix: "#{indent}# ")
    res << "\n"
  end

  in_opt = false
  max_len = @elements.keys.map { |e| e.to_s.length }.max

  each_element do |elt, data|
    next if data[:optional] == :omit
    if data[:optional] && !in_opt
      res << "#{indent}#\n#{indent}# Optional\n"
      in_opt = true
    end

    res << "#{indent}#{elt}:"
    spacing = ' ' * (max_len - elt.to_s.length + 1)
    if data[:default]
      res << spacing << data[:default].inspect << "\n"
    else
      res << template_type(data[:type], indent, spacing)
    end
  end
  return res
end

#template_type(type, indent, sp = ' ') ⇒ Object

Parameters:

  • type

    The Structured data type specification.

  • indent

    The indent string before new lines.

  • sp (defaults to: ' ')

    Spacing after the colon, if any.



781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
# File 'lib/structured.rb', line 781

def template_type(type, indent, sp = ' ')
  res = ''
  case type
  when :boolean
    res << " true/false\n"
  when Class
    if type == String
      res << "#{sp}\"\"\n"
    elsif type.include?(Structured)
      res << "\n" << type.template(indent: indent + '  ')
    else
      res << "#{sp}# #{type}\n"
    end
  when Array
    if type.first == String
      res << "#{sp}[ \"\", ... ]\n"
    else
      res << "\n#{indent}  -" << template_type(type.first, indent + '  ')
    end
  when Hash
    if type.first.first == String
      res << "\n#{indent}  \"\":"
    else
      res << "\n#{indent}  [#{type.first.first}]:"
    end
    res << template_type(type.first.last, indent + '  ')
  end
  return res
end

#try_autoconvert(type, item) ⇒ Object

Several types can be automatically converted:

  • Symbol into String

  • String into Regexp



650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
# File 'lib/structured.rb', line 650

def try_autoconvert(type, item)

  if type == String && item.is_a?(Symbol)
    return item.to_s
  end

  if type == Symbol && item.is_a?(String)
    return item.to_sym
  end

  # Special case in which strings will be converted to Regexps
  if type == Regexp && item.is_a?(String)
    begin
      return Regexp.new(item)
    rescue RegexpError
      input_err("#{item} is not a valid regular expression")
    end
  end

  return item
end

#try_read_array(filenames) ⇒ Object



507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
# File 'lib/structured.rb', line 507

def try_read_array(filenames)
  new_item = []
  begin
    filenames.each do |file|
      begin
        res = YAML.load_file(file)
        raise InputError unless res.is_a?(Array)
        new_item.concat(res)
      rescue
        input_err("Failed to read array from #{file}: #$!")
      end
    end
  end
  return new_item
end

#try_read_file(hash) ⇒ Object

If the hash contains a key :read_file, then try reading a file containing additional keys, and return a new hash merging the two. This will not work recursively; the input file may not further contain a :read_file key.

If the given hash and the :read_file hash contain duplicate keys, the given hash overrides the file values.



494
495
496
497
498
499
500
501
502
503
504
505
# File 'lib/structured.rb', line 494

def try_read_file(hash)
  file = hash['read_file'] || hash[:read_file]
  return hash unless file
  begin
    res = YAML.load_file(file).merge(hash)
    res.delete('read_file')
    res.delete(:read_file)
    return res
  rescue
    input_err("Failed to read Structured YAML input from #{file}: #$!")
  end
end

#try_run(block, obj, val, err_name) ⇒ Object



581
582
583
584
585
586
587
# File 'lib/structured.rb', line 581

def try_run(block, obj, val, err_name)
  begin
    val = obj.instance_exec(val, &block)
  rescue StandardError => e
    input_err("#{err_name} failed: #{e.to_s}")
  end
end