lib/pdk/config/namespace.rb in pdk-2.4.0 vs lib/pdk/config/namespace.rb in pdk-2.5.0

- old
+ new

@@ -1,354 +1,354 @@ -require 'pdk' - -module PDK - class Config - class Namespace - # @param value [String] the new name of this namespace. - attr_writer :name - - # @return [String] the path to the file associated with the contents of - # this namespace. - attr_reader :file - - # @return [self] the parent namespace of this namespace. - attr_accessor :parent - - # Initialises the PDK::Config::Namespace object. - # - # @param name [String] the name of the namespace (defaults to nil). - # @param params [Hash{Symbol => Object}] keyword parameters for the - # method. - # @option params [String] :file the path to the file associated with the - # contents of the namespace (defaults to nil). - # @option params [self] :parent the parent {self} that this namespace is - # a child of (defaults to nil). - # @option params [self] :persistent_defaults whether default values should be persisted - # to disk when evaluated. By default they are not persisted to disk. This is typically - # used for settings which a randomly generated, instead of being deterministic, e.g. analytics user-id - # @param block [Proc] a block that is evaluated within the new instance. - def initialize(name = nil, file: nil, parent: nil, persistent_defaults: false, &block) - @file = PDK::Util::Filesystem.expand_path(file) unless file.nil? - @settings = {} - @name = name.to_s - @parent = parent - @persistent_defaults = persistent_defaults - @mounts = {} - @loaded_from_file = false - @read_only = false - - instance_eval(&block) if block_given? - end - - # Pre-configure a value in the namespace. - # - # Allows you to specify validators and a default value for value in the - # namespace (see PDK::Config::Value#initialize). - # - # @param key [String,Symbol] the name of the value. - # @param block [Proc] a block that is evaluated within the new [self]. - # - # @return [nil] - def setting(key, &block) - @settings[key.to_s] ||= default_setting_class.new(key.to_s, self) - @settings[key.to_s].instance_eval(&block) if block_given? - end - - # Mount a provided [self] (or subclass) into the namespace. - # - # @param key [String,Symbol] the name of the namespace to be mounted. - # @param obj [self] the namespace to be mounted. - # @param block [Proc] a block to be evaluated within the instance of the - # newly mounted namespace. - # - # @raise [ArgumentError] if the object to be mounted is not a {self} or - # subclass thereof. - # - # @return [self] the mounted namespace. - def mount(key, obj, &block) - raise ArgumentError, _('Only PDK::Config::Namespace objects can be mounted into a namespace') unless obj.is_a?(PDK::Config::Namespace) - obj.parent = self - obj.name = key.to_s - obj.instance_eval(&block) if block_given? - @mounts[key.to_s] = obj - end - - # Create and mount a new child namespace. - # - # @param name [String,Symbol] the name of the new namespace. - # @param block [Proc] - def namespace(name, &block) - mount(name, PDK::Config::Namespace.new, &block) - end - - # Get the value of the named key. - # - # If there is a value for that key, return it. If not, follow the logic - # described in {#default_config_value} to determine the default value to - # return. - # - # @note Unlike a Ruby Hash, this will not return `nil` in the event that - # the key does not exist (see #fetch). - # - # @param key [String,Symbol] the name of the value to retrieve. - # - # @return [Object] the requested value. - def [](key) - # Check if it's a mount first... - return @mounts[key.to_s] unless @mounts[key.to_s].nil? - # Check if it's a setting, otherwise nil - return nil if settings[key.to_s].nil? - return settings[key.to_s].value unless settings[key.to_s].value.nil? - # Duplicate arrays and hashes so that they are isolated from changes being made - default_value = PDK::Util.deep_duplicate(settings[key.to_s].default) - return default_value if default_value.nil? || !@persistent_defaults - # Persist the default value - settings[key.to_s].value = default_value - save_data - default_value - end - - # Get the value of the named key or the provided default value if not - # present. Note that this does not trigger persistent defaults - # - # This differs from {#[]} in an important way in that it allows you to - # return a default value, which is not possible using `[] || default` as - # non-existent values when accessed normally via {#[]} will be defaulted - # to a new Hash. - # - # @param key [String,Symbol] the name of the value to fetch. - # @param default_value [Object] the value to return if the namespace does - # not contain the requested value. - # - # @return [Object] the requested value. - def fetch(key, default_value) - # Check if it's a mount first... - return @mounts[key.to_s] unless @mounts[key.to_s].nil? - # Check if it's a setting, otherwise default_value - return default_value if settings[key.to_s].nil? - # Check if has a value, otherwise default_value - settings[key.to_s].value.nil? ? default_value : settings[key.to_s].value - end - - # After the value has been set in memory, the value will then be - # persisted to disk. - # - # @param key [String,Symbol] the name of the configuration value. - # @param value [Object] the value of the configuration value. - # - # @return [nil] - def []=(key, value) - # You can't set the value of a mount - raise ArgumentError, _('Namespace mounts can not be set a value') unless @mounts[key.to_s].nil? - set_volatile_value(key, value) - # Persist the change - save_data - end - - # Convert the namespace into a Hash of values, suitable for serialising - # and persisting to disk. - # - # Child namespaces that are associated with their own files are excluded - # from the Hash (as their values will be persisted to their own files) - # and nil values are removed from the Hash. - # - # @return [Hash{String => Object}] the values from the namespace that - # should be persisted to disk. - def to_h - new_hash = {} - settings.each_pair { |k, v| new_hash[k] = v.value } - @mounts.each_pair { |k, mount_point| new_hash[k] = mount_point.to_h if mount_point.include_in_parent? } - new_hash.delete_if { |_, v| v.nil? } - new_hash - end - - # Resolves all filtered settings, including child namespaces, fully namespaced and filling in default values. - # - # @param filter [String] Only resolve setting names which match the filter. See #be_resolved? for matching rules - # @return [Hash{String => Object}] All resolved settings for example {'user.module_defaults.author' => 'johndoe'} - def resolve(filter = nil) - resolved = {} - # Resolve the settings - settings.values.each do |setting| - setting_name = setting.qualified_name - if be_resolved?(setting_name, filter) - resolved[setting_name] = setting.value.nil? ? setting.default : setting.value - end - end - # Resolve the mounts - @mounts.values.each { |mount| resolved.merge!(mount.resolve(filter)) } - resolved - end - - # @return [Boolean] true if the namespace has a parent, otherwise false. - def child_namespace? - !parent.nil? - end - - # Determines the fully qualified name of the namespace. - # - # If this is a child namespace, then fully qualified name for the - # namespace will be "<parent>.<child>". - # - # @return [String] the fully qualifed name of the namespace. - def name - child_namespace? ? [parent.name, @name].join('.') : @name - end - - # Determines if the contents of the namespace should be included in the - # parent namespace when persisting to disk. - # - # If the namespace has been mounted into a parent namespace and is not - # associated with its own file on disk, then the values in the namespace - # should be included in the parent namespace when persisting to disk. - # - # @return [Boolean] true if the values should be included in the parent - # namespace. - def include_in_parent? - child_namespace? && file.nil? - end - - # Disables the namespace, and child namespaces, from writing changes to disk. - # Typically this is only needed for unit testing. - # @api private - def read_only! - @read_only = true - @mounts.each { |_, child| child.read_only! } - end - - private - - # Returns the object class to create settings with. Subclasses may override this to use specific setting classes - # - # @return [Class[PDK::Config::Setting]] - # - # @abstract - # @private - def default_setting_class - PDK::Config::Setting - end - - # Determines whether a setting name should be resolved using the filter - # Returns true when filter is nil. - # Returns true if the filter is exactly the same name as the setting. - # Returns true if the name is a sub-key of the filter e.g. - # Given a filter of user.module_defaults, `user.module_defaults.author` will return true, but `user.analytics.disabled` will return false. - # - # @param name [String] The setting name to test. - # @param filter [String] The filter used to test on the name. - # @return [Boolean] Whether the name passes the filter. - def be_resolved?(name, filter = nil) - return true if filter.nil? # If we're not filtering, this value should always be resolved - return true if name == filter # If it's exactly the same name then it should be resolved - name.start_with?(filter + '.') # If name is a subkey of the filter then it should be resolved - end - - # @abstract Subclass and override {#parse_file} to implement parsing logic - # for a particular config file format. - # - # @param data [String] The content of the file to be parsed. - # @param filename [String] The path to the file to be parsed. - # - # @yield [String, Object] the data to be loaded into the - # namespace. - def parse_file(_filename); end - - # @abstract Subclass and override {#serialize_data} to implement generating - # logic for a particular config file format. - # - # @param data [Hash{String => Object}] the data stored in the namespace - # - # @return [String] the serialized contents of the namespace suitable for - # writing to disk. - def serialize_data(_data); end - - # @abstract Subclass and override {#create_missing_setting} to implement logic - # when a setting is dynamically created, for example when attempting to - # set the value of an unknown setting - # - # @param data [Hash{String => Object}] the data stored in the namespace - # - # @return [String] the serialized contents of the namespace suitable for - # writing to disk. - def create_missing_setting(key, initial_value = nil) - # Need to use `@settings` and `@mounts` here to stop recursive calls - return unless @mounts[key.to_s].nil? - return unless @settings[key.to_s].nil? - @settings[key.to_s] = default_setting_class.new(key.to_s, self, initial_value) - end - - # Set the value of the named key. - # - # If the key has been pre-configured with {#value}, then the value of the - # key will be validated against any validators that have been configured. - # - # @param key [String,Symbol] the name of the configuration value. - # @param value [Object] the value of the configuration value. - def set_volatile_value(key, value) - # Need to use `settings` here to force the backing file to be loaded - return create_missing_setting(key, value) if settings[key.to_s].nil? - # Need to use `@settings` here to stop recursive calls from []= - @settings[key.to_s].value = value - end - - # Helper method to read files. - # - # @raise [PDK::Config::LoadError] if the file is removed during read. - # @raise [PDK::Config::LoadError] if the user doesn't have the - # permissions needed to read the file. - # @return [String,nil] the contents of the file or nil if the file does - # not exist. - def load_data(filename) - return if filename.nil? - return unless PDK::Util::Filesystem.file?(filename) - - PDK::Util::Filesystem.read_file(filename) - rescue Errno::ENOENT => e - raise PDK::Config::LoadError, e.message - rescue Errno::EACCES - raise PDK::Config::LoadError, _('Unable to open %{file} for reading') % { - file: filename, - } - end - - # Persist the contents of the namespace to disk. - # - # Directories will be automatically created and the contents of the - # namespace will be serialized automatically with {#serialize_data}. - # - # @raise [PDK::Config::LoadError] if one of the intermediary path components - # exist but is not a directory. - # @raise [PDK::Config::LoadError] if the user does not have the - # permissions needed to write the file. - # - # @return [nil] - def save_data - return if file.nil? || @read_only - - PDK::Util::Filesystem.mkdir_p(File.dirname(file)) - - PDK::Util::Filesystem.write_file(file, serialize_data(to_h)) - rescue Errno::EACCES - raise PDK::Config::LoadError, _('Unable to open %{file} for writing') % { - file: file, - } - rescue SystemCallError => e - raise PDK::Config::LoadError, e.message - end - - # Memoised accessor for the loaded data. - # - # @return [Hash<String => PDK::Config::Setting>] the contents of the namespace. - def settings - return @settings if @loaded_from_file - @loaded_from_file = true - return @settings if file.nil? - parse_file(file) do |key, parsed_setting| - # Create a settings chain if a setting already exists - parsed_setting.previous_setting = @settings[key] unless @settings[key].nil? - @settings[key] = parsed_setting - end - @settings - end - end - end -end +require 'pdk' + +module PDK + class Config + class Namespace + # @param value [String] the new name of this namespace. + attr_writer :name + + # @return [String] the path to the file associated with the contents of + # this namespace. + attr_reader :file + + # @return [self] the parent namespace of this namespace. + attr_accessor :parent + + # Initialises the PDK::Config::Namespace object. + # + # @param name [String] the name of the namespace (defaults to nil). + # @param params [Hash{Symbol => Object}] keyword parameters for the + # method. + # @option params [String] :file the path to the file associated with the + # contents of the namespace (defaults to nil). + # @option params [self] :parent the parent {self} that this namespace is + # a child of (defaults to nil). + # @option params [self] :persistent_defaults whether default values should be persisted + # to disk when evaluated. By default they are not persisted to disk. This is typically + # used for settings which a randomly generated, instead of being deterministic, e.g. analytics user-id + # @param block [Proc] a block that is evaluated within the new instance. + def initialize(name = nil, file: nil, parent: nil, persistent_defaults: false, &block) + @file = PDK::Util::Filesystem.expand_path(file) unless file.nil? + @settings = {} + @name = name.to_s + @parent = parent + @persistent_defaults = persistent_defaults + @mounts = {} + @loaded_from_file = false + @read_only = false + + instance_eval(&block) if block_given? + end + + # Pre-configure a value in the namespace. + # + # Allows you to specify validators and a default value for value in the + # namespace (see PDK::Config::Value#initialize). + # + # @param key [String,Symbol] the name of the value. + # @param block [Proc] a block that is evaluated within the new [self]. + # + # @return [nil] + def setting(key, &block) + @settings[key.to_s] ||= default_setting_class.new(key.to_s, self) + @settings[key.to_s].instance_eval(&block) if block_given? + end + + # Mount a provided [self] (or subclass) into the namespace. + # + # @param key [String,Symbol] the name of the namespace to be mounted. + # @param obj [self] the namespace to be mounted. + # @param block [Proc] a block to be evaluated within the instance of the + # newly mounted namespace. + # + # @raise [ArgumentError] if the object to be mounted is not a {self} or + # subclass thereof. + # + # @return [self] the mounted namespace. + def mount(key, obj, &block) + raise ArgumentError, _('Only PDK::Config::Namespace objects can be mounted into a namespace') unless obj.is_a?(PDK::Config::Namespace) + obj.parent = self + obj.name = key.to_s + obj.instance_eval(&block) if block_given? + @mounts[key.to_s] = obj + end + + # Create and mount a new child namespace. + # + # @param name [String,Symbol] the name of the new namespace. + # @param block [Proc] + def namespace(name, &block) + mount(name, PDK::Config::Namespace.new, &block) + end + + # Get the value of the named key. + # + # If there is a value for that key, return it. If not, follow the logic + # described in {#default_config_value} to determine the default value to + # return. + # + # @note Unlike a Ruby Hash, this will not return `nil` in the event that + # the key does not exist (see #fetch). + # + # @param key [String,Symbol] the name of the value to retrieve. + # + # @return [Object] the requested value. + def [](key) + # Check if it's a mount first... + return @mounts[key.to_s] unless @mounts[key.to_s].nil? + # Check if it's a setting, otherwise nil + return nil if settings[key.to_s].nil? + return settings[key.to_s].value unless settings[key.to_s].value.nil? + # Duplicate arrays and hashes so that they are isolated from changes being made + default_value = PDK::Util.deep_duplicate(settings[key.to_s].default) + return default_value if default_value.nil? || !@persistent_defaults + # Persist the default value + settings[key.to_s].value = default_value + save_data + default_value + end + + # Get the value of the named key or the provided default value if not + # present. Note that this does not trigger persistent defaults + # + # This differs from {#[]} in an important way in that it allows you to + # return a default value, which is not possible using `[] || default` as + # non-existent values when accessed normally via {#[]} will be defaulted + # to a new Hash. + # + # @param key [String,Symbol] the name of the value to fetch. + # @param default_value [Object] the value to return if the namespace does + # not contain the requested value. + # + # @return [Object] the requested value. + def fetch(key, default_value) + # Check if it's a mount first... + return @mounts[key.to_s] unless @mounts[key.to_s].nil? + # Check if it's a setting, otherwise default_value + return default_value if settings[key.to_s].nil? + # Check if has a value, otherwise default_value + settings[key.to_s].value.nil? ? default_value : settings[key.to_s].value + end + + # After the value has been set in memory, the value will then be + # persisted to disk. + # + # @param key [String,Symbol] the name of the configuration value. + # @param value [Object] the value of the configuration value. + # + # @return [nil] + def []=(key, value) + # You can't set the value of a mount + raise ArgumentError, _('Namespace mounts can not be set a value') unless @mounts[key.to_s].nil? + set_volatile_value(key, value) + # Persist the change + save_data + end + + # Convert the namespace into a Hash of values, suitable for serialising + # and persisting to disk. + # + # Child namespaces that are associated with their own files are excluded + # from the Hash (as their values will be persisted to their own files) + # and nil values are removed from the Hash. + # + # @return [Hash{String => Object}] the values from the namespace that + # should be persisted to disk. + def to_h + new_hash = {} + settings.each_pair { |k, v| new_hash[k] = v.value } + @mounts.each_pair { |k, mount_point| new_hash[k] = mount_point.to_h if mount_point.include_in_parent? } + new_hash.delete_if { |_, v| v.nil? } + new_hash + end + + # Resolves all filtered settings, including child namespaces, fully namespaced and filling in default values. + # + # @param filter [String] Only resolve setting names which match the filter. See #be_resolved? for matching rules + # @return [Hash{String => Object}] All resolved settings for example {'user.module_defaults.author' => 'johndoe'} + def resolve(filter = nil) + resolved = {} + # Resolve the settings + settings.values.each do |setting| + setting_name = setting.qualified_name + if be_resolved?(setting_name, filter) + resolved[setting_name] = setting.value.nil? ? setting.default : setting.value + end + end + # Resolve the mounts + @mounts.values.each { |mount| resolved.merge!(mount.resolve(filter)) } + resolved + end + + # @return [Boolean] true if the namespace has a parent, otherwise false. + def child_namespace? + !parent.nil? + end + + # Determines the fully qualified name of the namespace. + # + # If this is a child namespace, then fully qualified name for the + # namespace will be "<parent>.<child>". + # + # @return [String] the fully qualifed name of the namespace. + def name + child_namespace? ? [parent.name, @name].join('.') : @name + end + + # Determines if the contents of the namespace should be included in the + # parent namespace when persisting to disk. + # + # If the namespace has been mounted into a parent namespace and is not + # associated with its own file on disk, then the values in the namespace + # should be included in the parent namespace when persisting to disk. + # + # @return [Boolean] true if the values should be included in the parent + # namespace. + def include_in_parent? + child_namespace? && file.nil? + end + + # Disables the namespace, and child namespaces, from writing changes to disk. + # Typically this is only needed for unit testing. + # @api private + def read_only! + @read_only = true + @mounts.each { |_, child| child.read_only! } + end + + private + + # Returns the object class to create settings with. Subclasses may override this to use specific setting classes + # + # @return [Class[PDK::Config::Setting]] + # + # @abstract + # @private + def default_setting_class + PDK::Config::Setting + end + + # Determines whether a setting name should be resolved using the filter + # Returns true when filter is nil. + # Returns true if the filter is exactly the same name as the setting. + # Returns true if the name is a sub-key of the filter e.g. + # Given a filter of user.module_defaults, `user.module_defaults.author` will return true, but `user.analytics.disabled` will return false. + # + # @param name [String] The setting name to test. + # @param filter [String] The filter used to test on the name. + # @return [Boolean] Whether the name passes the filter. + def be_resolved?(name, filter = nil) + return true if filter.nil? # If we're not filtering, this value should always be resolved + return true if name == filter # If it's exactly the same name then it should be resolved + name.start_with?(filter + '.') # If name is a subkey of the filter then it should be resolved + end + + # @abstract Subclass and override {#parse_file} to implement parsing logic + # for a particular config file format. + # + # @param data [String] The content of the file to be parsed. + # @param filename [String] The path to the file to be parsed. + # + # @yield [String, Object] the data to be loaded into the + # namespace. + def parse_file(_filename); end + + # @abstract Subclass and override {#serialize_data} to implement generating + # logic for a particular config file format. + # + # @param data [Hash{String => Object}] the data stored in the namespace + # + # @return [String] the serialized contents of the namespace suitable for + # writing to disk. + def serialize_data(_data); end + + # @abstract Subclass and override {#create_missing_setting} to implement logic + # when a setting is dynamically created, for example when attempting to + # set the value of an unknown setting + # + # @param data [Hash{String => Object}] the data stored in the namespace + # + # @return [String] the serialized contents of the namespace suitable for + # writing to disk. + def create_missing_setting(key, initial_value = nil) + # Need to use `@settings` and `@mounts` here to stop recursive calls + return unless @mounts[key.to_s].nil? + return unless @settings[key.to_s].nil? + @settings[key.to_s] = default_setting_class.new(key.to_s, self, initial_value) + end + + # Set the value of the named key. + # + # If the key has been pre-configured with {#value}, then the value of the + # key will be validated against any validators that have been configured. + # + # @param key [String,Symbol] the name of the configuration value. + # @param value [Object] the value of the configuration value. + def set_volatile_value(key, value) + # Need to use `settings` here to force the backing file to be loaded + return create_missing_setting(key, value) if settings[key.to_s].nil? + # Need to use `@settings` here to stop recursive calls from []= + @settings[key.to_s].value = value + end + + # Helper method to read files. + # + # @raise [PDK::Config::LoadError] if the file is removed during read. + # @raise [PDK::Config::LoadError] if the user doesn't have the + # permissions needed to read the file. + # @return [String,nil] the contents of the file or nil if the file does + # not exist. + def load_data(filename) + return if filename.nil? + return unless PDK::Util::Filesystem.file?(filename) + + PDK::Util::Filesystem.read_file(filename) + rescue Errno::ENOENT => e + raise PDK::Config::LoadError, e.message + rescue Errno::EACCES + raise PDK::Config::LoadError, _('Unable to open %{file} for reading') % { + file: filename, + } + end + + # Persist the contents of the namespace to disk. + # + # Directories will be automatically created and the contents of the + # namespace will be serialized automatically with {#serialize_data}. + # + # @raise [PDK::Config::LoadError] if one of the intermediary path components + # exist but is not a directory. + # @raise [PDK::Config::LoadError] if the user does not have the + # permissions needed to write the file. + # + # @return [nil] + def save_data + return if file.nil? || @read_only + + PDK::Util::Filesystem.mkdir_p(File.dirname(file)) + + PDK::Util::Filesystem.write_file(file, serialize_data(to_h)) + rescue Errno::EACCES + raise PDK::Config::LoadError, _('Unable to open %{file} for writing') % { + file: file, + } + rescue SystemCallError => e + raise PDK::Config::LoadError, e.message + end + + # Memoised accessor for the loaded data. + # + # @return [Hash<String => PDK::Config::Setting>] the contents of the namespace. + def settings + return @settings if @loaded_from_file + @loaded_from_file = true + return @settings if file.nil? + parse_file(file) do |key, parsed_setting| + # Create a settings chain if a setting already exists + parsed_setting.previous_setting = @settings[key] unless @settings[key].nil? + @settings[key] = parsed_setting + end + @settings + end + end + end +end