Skip to content

Base parameter tree

base_parameter_tree.py - base classes representing a tree of parameters and accessors.

This module implements an arbitrarily-structured, recursively-managed tree of parameters and the appropriate accessor methods that are used to read and write those parameters. Its particular use is in the definition of a tree of parameters for an API adapter and help interfacing of those to the underlying device or object. These base classes are not intended to be used directly, but form the basis for concrete synchronous and asynchronous implementations.

James Hogge, Tim Nicholls, STFC Application Engineering Group.

BaseParameterAccessor

Bases: object

Base container class representing accessor methods for a parameter.

This base class implements a parameter accessor, providing set and get methods for parameters requiring calls to access them, or simply returning the appropriate value if the parameter is a read-only constant. Parameter accessors also contain metadata fields controlling access to and providing information about the parameter.

Valid specifiable metadata fields are: min : minimum allowed value for parameter max : maximum allowed value for parameter allowed_values: list of allowed values for parameter name : readable parameter name description: longer description of parameter units: parameter units display_precision: number of decimal places to display for e.g. float types

The class also maintains the following automatically-populated metadata fields: type: parameter type element_type: for list parameters, the type of the elements in the list writeable: is the parameter writable

Source code in src/odin_control/adapters/base_parameter_tree.py
class BaseParameterAccessor(object):
    """Base container class representing accessor methods for a parameter.

    This base class implements a parameter accessor, providing set and get methods
    for parameters requiring calls to access them, or simply returning the
    appropriate value if the parameter is a read-only constant. Parameter accessors also
    contain metadata fields controlling access to and providing information about the parameter.

    Valid specifiable metadata fields are:
    min : minimum allowed value for parameter
    max : maximum allowed value for parameter
    allowed_values: list of allowed values for parameter
    name : readable parameter name
    description: longer description of parameter
    units: parameter units
    display_precision: number of decimal places to display for e.g. float types

    The class also maintains the following automatically-populated metadata fields:
    type: parameter type
    element_type: for list parameters, the type of the elements in the list
    writeable: is the parameter writable
    """

    # Valid metadata arguments that can be passed to ParameterAccess __init__ method.
    VALID_METADATA_ARGS = (
        "min", "max", "allowed_values", "name", "description", "units", "display_precision"
    )
    # Automatically-populated metadata fields based on inferred type of the parameter and
    # writeable status depending on specified accessors. For list parameters, the element type is
    # also inferred and stored in the metadata.
    AUTO_METADATA_FIELDS = ("type", "element_type", "writeable")

    def __init__(self, path, getter=None, setter=None, **kwargs):
        """Initialise the BaseParameterAccessor instance.

        This constructor initialises the BaseParameterAccessor instance, storing
        the path of the parameter, its set/get accessors and setting metadata fields based
        on the the specified keyword arguments

        :param path: path of the parameter within the tree
        :param getter: get method for the parameter, or a value if read-only constant
        :param setter: set method for the parameter
        :param kwargs: keyword argument list for metadata fields to be set; these must be from
                       the allow list specified in ParameterAccessor.allowed_metadata
        """
        # Initialise path, getter and setter
        self.path = path[:-1]
        self._get = getter
        self._set = setter

        # Initialize parameter attributes that will be resolved later
        self._type = None
        self._element_type = None
        self._getter_indexable = False
        self._setter_indexable = False

        # Initialize metadata dict
        self.metadata = {}

        # Check metadata keyword arguments are valid
        for arg in kwargs:
            if arg not in BaseParameterAccessor.VALID_METADATA_ARGS:
                raise ParameterTreeError("Invalid metadata argument: {}".format(arg))

        # Update metadata keywords from arguments
        self.metadata.update(kwargs)

        # If the getter is callable, inspect its signature to determine whether it is indexable
        # (i.e. takes an optional element index as its argument).
        if callable(self._get):
            self._getter_indexable = self._is_indexable_accessor(self._get, expected_param_count=1)

        # If the setter is callable, set the writeable metadata field and determine from the
        # signature of the setter whether it is indexable (i.e. takes an element index as a second
        # argument).
        if callable(self._set):
            self.metadata["writeable"] = True
            self._setter_indexable = self._is_indexable_accessor(
                self._set, expected_param_count=2
            )
        else:
            self.metadata["writeable"] = False
            self._setter_indexable = False

    def get(self, element_idx=None, with_metadata=False):
        """Get the value of the parameter.

        This method returns the value of the parameter, or the value returned
        by the get accessor if one is defined (i.e. is callable). If the with_metadata argument
        is true, the value is returned in a dictionary including all metadata for the
        parameter.

        :param element_idx: index of element to get for list parameters, if applicable
        :param with_metadata: include metadata in the response when set to True
        :returns: value of the parameter
        """
        # Determine the value of the parameter by calling the getter or simply from the stored
        # value
        try:
            # For list parameters, convert the element index to an integer if specified
            if element_idx is not None and self._type is list:
                element_idx = int(element_idx)

            # Get the value of the parameter by calling the getter if it is callable, otherwise
            # return the stored value. If an element index is specified, return the indexed value
            # where appropriate, either via the indexable getter or by resolving the value directly.
            if callable(self._get):
                if self._getter_indexable:
                    value = self._get(element_idx)
                else:
                    value = self._get()
                    if element_idx is not None:
                        value = value[element_idx]
            else:
                value = self._get
                if element_idx is not None:
                    value = value[element_idx]
        except (TypeError, IndexError, KeyError) as e:
            raise ParameterTreeError(
                "Index error getting parameter {} at index {}: {}".format(
                    self.path, element_idx, str(e)
                )
            )

        # If metadata is requested, replace the value with a dict containing the value itself
        # plus metadata fields
        if with_metadata:
            value = {"value": value}
            value.update(self.metadata)

        return value

    def set(self, value, element_idx=None):
        """Set the value of the parameter.

        This method sets the value of the parameter by calling the set accessor
        if defined and callable, otherwise raising an exception.

        :param value: value to set
        :param element_idx: index of element to set for list parameters, if applicable
        :raises: ParameterTreeError if the parameter is not writeable, if the value is of the wrong
        type, or if a metadata constraint is violated
        """
        # Raise an error if this parameter is not writeable
        if not self.metadata["writeable"]:
            raise ParameterTreeError("Parameter {} is read-only".format(self.path))

        # If the parameter is a list, check the type of each value against the resolved element
        # type. If the parameter is a dict, allow any type to be set as a dict can be heterogeneous,
        # otherwise check against the parameter type.
        if self._type is list:
            required_type = self._element_type
            values = [value] if element_idx is not None else value
        elif self._type is dict:
            required_type = type(None)
            values = [value]
        else:
            required_type = self._type
            values = [value]

        # Loop over the value(s) to be set, checking the type and any metadata constraints
        for val in values:

            # Raise an error if the value to be set is not of the same type as the parameter. If
            # the required type is None, allow any type to be set. If the value is integer and the
            # parameter is float, also allow, as JSON does not differentiate numerics in all cases
            if required_type is not type(None) and not isinstance(val, required_type):
                if not (isinstance(val, int) and required_type is float):
                    raise ParameterTreeError(
                        "Type mismatch setting {}: got {} expected {}".format(
                            self.path, type(val).__name__, required_type.__name__
                        )
                    )

            # Raise an error if the parameter has a list of allowed values specified in metadata
            # and the value to set is not one of them
            if "allowed_values" in self.metadata and val not in self.metadata["allowed_values"]:
                raise ParameterTreeError(
                    "{} is not an allowed value for {}".format(val, self.path)
                )

            # Raise an error if the parameter has a minimum value specified in metadata and the
            # value to set is below this
            if "min" in self.metadata and val < self.metadata["min"]:
                raise ParameterTreeError(
                    "{} is below the minimum value {} for {}".format(
                        val, self.metadata["min"], self.path
                    )
                )

            # Raise an error if the parameter has a maximum value specified in metadata and the
            # value to set is above this
            if "max" in self.metadata and val > self.metadata["max"]:
                raise ParameterTreeError(
                    "{} is above the maximum value {} for {}".format(
                        val, self.metadata["max"], self.path
                    )
                )

        # Set the new parameter value by calling the setter. If an element index is specified, set
        # the specific value, either by calling and indexable setter or by doing a read-modify-write
        # of the whole parameter value.
        response = None
        try:
            if element_idx is not None and self._type is list:
                element_idx = int(element_idx)
            if callable(self._set):
                if self._setter_indexable:
                    response = self._set(value, element_idx)
                else:
                    if element_idx is not None:
                        values = self.get()
                        values[element_idx] = value
                        response = self._set(values)
                    else:
                        response = self._set(value)
        except (TypeError, IndexError) as e:
            raise ParameterTreeError(
                "Index error setting parameter {} at index {}: {}".format(
                    self.path, element_idx, str(e)
                )
            )

        return response

    @property
    def type(self):
        """Return the type of the parameter."""
        return self._type

    @staticmethod
    def _is_indexable_accessor(accessor, expected_param_count):
        """Determine if a parameter accessor is indexable.

        This method determines if the specified parameter accessor is indexable. Indexable
        accessors expose an optional index argument that defaults to None. The method inspects the
        function signature to determine if it has the correct number of parameters and that the
        final index argument has the correct default value of None. This avoids false positives for
        callables (especially lambdas) that use default arguments only to bind context and avoid
        late binding. Python does not implement signatures for all builtin methods (e.g. getattr),
        so the signature inspection is wrapped in a try-except block to avoid errors.

        :param accessor: the accessor to inspect
        :param expected_param_count: the expected number of parameters for the accessor
        :return: True if the accessor is indexable, False otherwise
        """
        try:
            params = list(inspect.signature(accessor).parameters.values())
            if len(params) != expected_param_count:
                return False

            return params[-1].default is None
        except (TypeError, ValueError):
            return False

    def _resolve_type_metadata(self, value):
        """Resolve the type of a parameter and set the appropriate metadata fields.

        This internal method resolves the type of a parameter and sets the appropriate
        metadata fields in the accessor's metadata dictionary. It is called by the constructor
        to set the type metadata field based on the resolved type of the parameter.

        :param value: value of the parameter to resolve type for
        """
        # Save the type of the parameter for type checking
        self._type = type(value)

        # Set the type metadata fields based on the resolved type
        self.metadata["type"] = self._type.__name__

        # If the parameter is a list, also save the type of the first element for type checking
        if self._type is list:
            self._element_type = type(value[0]) if len(value) > 0 else None
            self.metadata["element_type"] = (
                self._element_type.__name__ if self._element_type is not None else "none"
            )

type property

Return the type of the parameter.

__init__(path, getter=None, setter=None, **kwargs)

Initialise the BaseParameterAccessor instance.

This constructor initialises the BaseParameterAccessor instance, storing the path of the parameter, its set/get accessors and setting metadata fields based on the the specified keyword arguments

Parameters:

Name Type Description Default
path

path of the parameter within the tree

required
getter

get method for the parameter, or a value if read-only constant

None
setter

set method for the parameter

None
kwargs

keyword argument list for metadata fields to be set; these must be from the allow list specified in ParameterAccessor.allowed_metadata

{}
Source code in src/odin_control/adapters/base_parameter_tree.py
def __init__(self, path, getter=None, setter=None, **kwargs):
    """Initialise the BaseParameterAccessor instance.

    This constructor initialises the BaseParameterAccessor instance, storing
    the path of the parameter, its set/get accessors and setting metadata fields based
    on the the specified keyword arguments

    :param path: path of the parameter within the tree
    :param getter: get method for the parameter, or a value if read-only constant
    :param setter: set method for the parameter
    :param kwargs: keyword argument list for metadata fields to be set; these must be from
                   the allow list specified in ParameterAccessor.allowed_metadata
    """
    # Initialise path, getter and setter
    self.path = path[:-1]
    self._get = getter
    self._set = setter

    # Initialize parameter attributes that will be resolved later
    self._type = None
    self._element_type = None
    self._getter_indexable = False
    self._setter_indexable = False

    # Initialize metadata dict
    self.metadata = {}

    # Check metadata keyword arguments are valid
    for arg in kwargs:
        if arg not in BaseParameterAccessor.VALID_METADATA_ARGS:
            raise ParameterTreeError("Invalid metadata argument: {}".format(arg))

    # Update metadata keywords from arguments
    self.metadata.update(kwargs)

    # If the getter is callable, inspect its signature to determine whether it is indexable
    # (i.e. takes an optional element index as its argument).
    if callable(self._get):
        self._getter_indexable = self._is_indexable_accessor(self._get, expected_param_count=1)

    # If the setter is callable, set the writeable metadata field and determine from the
    # signature of the setter whether it is indexable (i.e. takes an element index as a second
    # argument).
    if callable(self._set):
        self.metadata["writeable"] = True
        self._setter_indexable = self._is_indexable_accessor(
            self._set, expected_param_count=2
        )
    else:
        self.metadata["writeable"] = False
        self._setter_indexable = False

get(element_idx=None, with_metadata=False)

Get the value of the parameter.

This method returns the value of the parameter, or the value returned by the get accessor if one is defined (i.e. is callable). If the with_metadata argument is true, the value is returned in a dictionary including all metadata for the parameter.

Parameters:

Name Type Description Default
element_idx

index of element to get for list parameters, if applicable

None
with_metadata

include metadata in the response when set to True

False

Returns:

Type Description

value of the parameter

Source code in src/odin_control/adapters/base_parameter_tree.py
def get(self, element_idx=None, with_metadata=False):
    """Get the value of the parameter.

    This method returns the value of the parameter, or the value returned
    by the get accessor if one is defined (i.e. is callable). If the with_metadata argument
    is true, the value is returned in a dictionary including all metadata for the
    parameter.

    :param element_idx: index of element to get for list parameters, if applicable
    :param with_metadata: include metadata in the response when set to True
    :returns: value of the parameter
    """
    # Determine the value of the parameter by calling the getter or simply from the stored
    # value
    try:
        # For list parameters, convert the element index to an integer if specified
        if element_idx is not None and self._type is list:
            element_idx = int(element_idx)

        # Get the value of the parameter by calling the getter if it is callable, otherwise
        # return the stored value. If an element index is specified, return the indexed value
        # where appropriate, either via the indexable getter or by resolving the value directly.
        if callable(self._get):
            if self._getter_indexable:
                value = self._get(element_idx)
            else:
                value = self._get()
                if element_idx is not None:
                    value = value[element_idx]
        else:
            value = self._get
            if element_idx is not None:
                value = value[element_idx]
    except (TypeError, IndexError, KeyError) as e:
        raise ParameterTreeError(
            "Index error getting parameter {} at index {}: {}".format(
                self.path, element_idx, str(e)
            )
        )

    # If metadata is requested, replace the value with a dict containing the value itself
    # plus metadata fields
    if with_metadata:
        value = {"value": value}
        value.update(self.metadata)

    return value

set(value, element_idx=None)

Set the value of the parameter.

This method sets the value of the parameter by calling the set accessor if defined and callable, otherwise raising an exception.

Parameters:

Name Type Description Default
value

value to set

required
element_idx

index of element to set for list parameters, if applicable

None
Source code in src/odin_control/adapters/base_parameter_tree.py
def set(self, value, element_idx=None):
    """Set the value of the parameter.

    This method sets the value of the parameter by calling the set accessor
    if defined and callable, otherwise raising an exception.

    :param value: value to set
    :param element_idx: index of element to set for list parameters, if applicable
    :raises: ParameterTreeError if the parameter is not writeable, if the value is of the wrong
    type, or if a metadata constraint is violated
    """
    # Raise an error if this parameter is not writeable
    if not self.metadata["writeable"]:
        raise ParameterTreeError("Parameter {} is read-only".format(self.path))

    # If the parameter is a list, check the type of each value against the resolved element
    # type. If the parameter is a dict, allow any type to be set as a dict can be heterogeneous,
    # otherwise check against the parameter type.
    if self._type is list:
        required_type = self._element_type
        values = [value] if element_idx is not None else value
    elif self._type is dict:
        required_type = type(None)
        values = [value]
    else:
        required_type = self._type
        values = [value]

    # Loop over the value(s) to be set, checking the type and any metadata constraints
    for val in values:

        # Raise an error if the value to be set is not of the same type as the parameter. If
        # the required type is None, allow any type to be set. If the value is integer and the
        # parameter is float, also allow, as JSON does not differentiate numerics in all cases
        if required_type is not type(None) and not isinstance(val, required_type):
            if not (isinstance(val, int) and required_type is float):
                raise ParameterTreeError(
                    "Type mismatch setting {}: got {} expected {}".format(
                        self.path, type(val).__name__, required_type.__name__
                    )
                )

        # Raise an error if the parameter has a list of allowed values specified in metadata
        # and the value to set is not one of them
        if "allowed_values" in self.metadata and val not in self.metadata["allowed_values"]:
            raise ParameterTreeError(
                "{} is not an allowed value for {}".format(val, self.path)
            )

        # Raise an error if the parameter has a minimum value specified in metadata and the
        # value to set is below this
        if "min" in self.metadata and val < self.metadata["min"]:
            raise ParameterTreeError(
                "{} is below the minimum value {} for {}".format(
                    val, self.metadata["min"], self.path
                )
            )

        # Raise an error if the parameter has a maximum value specified in metadata and the
        # value to set is above this
        if "max" in self.metadata and val > self.metadata["max"]:
            raise ParameterTreeError(
                "{} is above the maximum value {} for {}".format(
                    val, self.metadata["max"], self.path
                )
            )

    # Set the new parameter value by calling the setter. If an element index is specified, set
    # the specific value, either by calling and indexable setter or by doing a read-modify-write
    # of the whole parameter value.
    response = None
    try:
        if element_idx is not None and self._type is list:
            element_idx = int(element_idx)
        if callable(self._set):
            if self._setter_indexable:
                response = self._set(value, element_idx)
            else:
                if element_idx is not None:
                    values = self.get()
                    values[element_idx] = value
                    response = self._set(values)
                else:
                    response = self._set(value)
    except (TypeError, IndexError) as e:
        raise ParameterTreeError(
            "Index error setting parameter {} at index {}: {}".format(
                self.path, element_idx, str(e)
            )
        )

    return response

BaseParameterTree

Bases: object

Base class implementing a tree of parameters and their accessors.

This base class implements an arbitrarily-structured, recursively-managed tree of parameters and the appropriate accessor methods that are used to read and write those parameters. Its particular use is in the definition of a tree of parameters for an API adapter and help interfacing of those to the underlying device or object.

Source code in src/odin_control/adapters/base_parameter_tree.py
class BaseParameterTree(object):
    """Base class implementing a tree of parameters and their accessors.

    This base class implements an arbitrarily-structured, recursively-managed tree of parameters and
    the appropriate accessor methods that are used to read and write those parameters. Its
    particular use is in the definition of a tree of parameters for an API adapter and help
    interfacing of those to the underlying device or object.
    """

    def __init__(self, tree, mutable=False):
        """Initialise the BaseParameterTree object.

        This constructor recursively initialises the BaseParameterTree object, based on the
        parameter tree dictionary passed as an argument. This is done recursively, so that a
        parameter tree can have arbitrary depth and contain other BaseParameterTree instances
        as necessary.

        Initialisation syntax for BaseParameterTree is made by passing a dict representing the tree
        as an argument. Children of a node at any level of the tree are described with
        dictionaries/lists e.g.

          {"parent" : {"childA" : {...}, "childB" : {...}}}
          {"parent" : [{...}, {...}]}

        Leaf nodes can be one of the following formats:

          value   -  (value,)  -  (value, {metadata})
          getter  -  (getter,) -  (getter, {metadata})
          (getter, setter)     -  (getter, setter, {metadata})

        The following tags will also be treated as metadata:

          name - A printable name for that branch of the tree
          description - A printable description for that branch of the tree

        :param tree: dict representing the parameter tree
        :param mutable: Flag, setting the tree
        """
        # Flag, if set to true, allows nodes to be replaced and new nodes created
        self.mutable = mutable

        # list of paths to mutable parts. Not sure this is best solution
        self.mutable_paths = []

        # Recursively check and initialise the tree
        self._tree = self._build_tree(tree)

    @property
    def tree(self):
        """Return tree object for this parameter tree node.

        Used internally for recursive descent of parameter trees.
        """
        return self._tree

    def get(self, path, with_metadata=False):
        """Get the values of parameters in a tree.

        This method returns the values at and below a specified path in the parameter tree.
        This is done by recursively populating the tree with the current values of parameters,
        returning the result as a dictionary.

        :param path: path in tree to get parameter values for
        :param with_metadata: include metadata in the response when set to True
        :returns: dict of parameter tree at the specified path
        """
        # Split the path by levels, truncating the last level if path ends in trailing slash
        levels = path.split('/')
        if levels[-1] == '':
            del levels[-1]

        # Initialise the subtree before descent
        subtree = self._tree

        # Descend the specified levels in the path, checking for a valid subtree of the appropriate
        # type, A single level path returns the populated tree at the top level.
        for level in levels:
            try:
                if isinstance(subtree, dict):
                    subtree = subtree[level]
                elif isinstance(subtree, self.accessor_cls):
                    subtree = subtree.get(element_idx=level, with_metadata=with_metadata)
                else:
                    subtree = subtree[int(level)]
            except (KeyError, ValueError, IndexError):
                raise ParameterTreeError("Invalid path: {}".format(path))

        # Return the populated tree at the appropriate path
        values = self._populate_tree(subtree, with_metadata)

        # If this is a request for a single leaf node (i.e. depth is 1 and only one value
        # returned) without metadata, return a value dict rather than just the value itself
        if not with_metadata and not isinstance(values, dict):
            values = {'value': values}

        return values

    def set(self, path, data, replace=False):
        """Set the values of the parameters in a tree.

        This method sets the values of parameters in a tree, based on the data passed to it
        as a nested dictionary of parameter and value pairs. The updated parameters are merged
        into the existing tree recursively.

        :param path: path to set parameters for in the tree
        :param data: nested dictionary representing values to update at the path
        :param replace: if set to true then the structure is replaced rather than merged
        """
        # Expand out any lists/tuples
        data = self._build_tree(data)

        # Get subtree from the node the path points to
        levels = path.split('/')
        if levels[-1] == '':
            del levels[-1]

        # Initialise variables used during descent of the tree
        merge_parent = self._tree
        merge_child = self._tree
        element_idx = None
        parent_level = -1

        # Descend the tree and validate each element of the path
        for level in levels:
            try:
                if isinstance(merge_child, dict):
                    merge_parent = merge_child
                    merge_child = merge_child[level]
                elif isinstance(merge_child, self.accessor_cls):
                    if merge_child.type in (list, dict):
                        element_idx = level
                        parent_level = -2
                else:
                    merge_parent = merge_child
                    merge_child = merge_child[int(level)]
            except (KeyError, ValueError, IndexError):
                raise ParameterTreeError("Invalid path: {}".format(path))

        # Add trailing / to paths where necessary
        if path and path[-1] != '/':
            path += '/'

        # Merge data with tree
        if replace:
            if not self.mutable:
                raise ParameterTreeError("Invalid replace attempt: tree not mutable")
            merged = data
        else:
            merged = self._merge_tree(merge_child, data, path, element_idx)

        # Add merged part to tree, either at the top of the tree or at the
        # appropriate level specified by the path
        if not levels:
            self._tree = merged
            return
        if isinstance(merge_parent, dict):
            merge_parent[levels[parent_level]] = merged
        else:
            merge_parent[int(levels[parent_level])] = merged

    def replace(self, path, data):
        """Replace a branch of parameters in a tree.

        This method sets the values of parameters in a tree, based on the data passed to it
        as a nested dictionary of parameter and value pairs. Any structure below the insertion
        point in the existing tree is replaced with this new structure.

        :param path: path to set parameters for in the tree
        :param data: nested dictionary representing structure to replace at the path
        """
        self.set(path, data, replace=True)

    def delete(self, path=''):
        """Delete parameters from a mutable tree.

        This method deletes selected parameters from a tree, if that tree has been flagged as
        Mutable. Deletion of Branch Nodes means all child nodes of that Branch Node are also deleted

        :param path: Path to selected Parameter Node in the tree
        """
        if not self.mutable and not any(path.startswith(part) for part in self.mutable_paths):
            raise ParameterTreeError("Invalid Delete Attempt: Tree Not Mutable")

        # Split the path by levels, truncating the last level if path ends in trailing slash
        levels = path.split('/')
        if levels[-1] == '':
            del levels[-1]

        subtree = self._tree

        if not levels:
            subtree.clear()
            return
        try:
            # Traverse down the path, based on hwo path navigation works in the Set Method above
            for level in levels[:-1]:

                # If the subtree is a dict, the subtree is a normal branch, continue traversal. If
                # it is not a dict the subtree is a list so the next path is indexed by the level
                if isinstance(subtree, dict):
                    subtree = subtree[level]
                else:
                    subtree = subtree[int(level)]

            # Once at the second to last part of the path, delete whatever comes next
            if isinstance(subtree, list):
                subtree.pop(int(levels[-1]))
            else:
                subtree.pop(levels[-1])
        except (KeyError, ValueError, IndexError):
            raise ParameterTreeError("Invalid path: {}".format(path))

    def _build_tree(self, node, path=''):
        """Recursively build and expand out a tree or node.

        This internal method is used to recursively build and expand a tree or node,
        replacing elements as found with appropriate types, e.g. ParameterAccessor for
        a set/get pair, the internal tree of a nested ParameterTree.

        :param node: node to recursively build
        :param path: path to node within overall tree
        :returns: built node
        """
        # If the node is a parameter tree instance, replace with its own built tree
        if isinstance(node, type(self)):
            if node.mutable:
                self.mutable_paths.append(path)
            return node.tree  # this breaks the mutability of the sub-tree. hmm

        # Convert node tuple into the corresponding ParameterAccessor, depending on type of
        # fields
        if isinstance(node, tuple):
            if len(node) == 1:
                # Node is (value)
                param = self.accessor_cls(path, node[0])

            elif len(node) == 2:
                if isinstance(node[1], dict):
                    # Node is (value, {metadata})
                    param = self.accessor_cls(path, node[0], **node[1])
                else:
                    # Node is (getter, setter)
                    param = self.accessor_cls(path, node[0], node[1])

            elif len(node) == 3 and isinstance(node[2], dict):
                # Node is (getter, setter, {metadata})
                param = self.accessor_cls(path, node[0], node[1], **node[2])

            else:
                raise ParameterTreeError("{} is not a valid leaf node".format(repr(node)))

            return param

        # Convert list or non-callable tuple to enumerated dict
        if isinstance(node, list):
            return [self._build_tree(elem, path=path) for elem in node]

        # Recursively check child elements
        if isinstance(node, dict):
            return {k: self._build_tree(
                v, path=path + str(k) + '/') for k, v in node.items()}

        return node

    def _populate_tree(self, node, with_metadata=False):
        """Recursively populate a tree with values.

        This internal method recursively populates the tree with parameter values, or
        the results of the accessor getters for nodes. It is called by the get() method to
        return the values of parameters in the tree.

        :param node: tree node to populate and return
        :param with_metadata: include parameter metadata with the tree
        :returns: populated node as a dict
        """
        # If this is a branch node recurse down the tree
        if isinstance(node, dict):
            return {k: self._populate_tree(v, with_metadata) for k, v in node.items()}

        if isinstance(node, list):
            return [self._populate_tree(item, with_metadata) for item in node]

        # If this is a leaf node, check if the leaf is a r/w tuple and substitute the
        # read element of that tuple into the node
        if isinstance(node, self.accessor_cls):
            return node.get(with_metadata=with_metadata)

        return node

    def _merge_tree(self, node, new_data, cur_path, element_idx=None):
        """Recursively merge a tree with new values.

        This internal method recursively merges a tree with new values. Called by the set()
        method, this allows parameters to be updated in place with the specified values,
        calling the parameter setter in specified in an accessor. The type of any updated
        parameters is checked against the existing parameter type.

        :param node: tree node to populate and return
        :param new_data: dict of new data to be merged in at this path in the tree
        :param cur_path: current path in the tree
        :param element_idx: index of element to set for list parameters, if applicable
        :returns: the update node at this point in the tree
        """
        # If new data is a dict with a single 'value' field, extract that value for updating
        if isinstance(new_data, dict) and len(new_data) == 1 and 'value' in new_data:
            new_data = new_data['value']

        # Recurse down tree if this is a branch node
        if isinstance(node, dict) and isinstance(new_data, dict):
            try:
                update = {}
                for k, v in new_data.items():
                    mutable = self.mutable or any(
                        cur_path.startswith(part) for part in self.mutable_paths
                    )
                    if mutable and k not in node:
                        node[k] = {}
                    update[k] = self._merge_tree(node[k], v, cur_path + k + '/')
                    node.update(update)
                return node
            except KeyError as key_error:
                raise ParameterTreeError(
                    'Invalid path: {}{}'.format(cur_path, str(key_error)[1:-1])
                )
        if isinstance(node, list) and isinstance(new_data, (dict, list)):
            try:
                for i, val in enumerate(new_data):
                    node[i] = self._merge_tree(node[i], val, cur_path + str(i) + '/')
                return node
            except IndexError as index_error:
                raise ParameterTreeError(
                    'Invalid path: {}{} {}'.format(cur_path, str(i), str(index_error))
                )

        # Update the value of the current parameter, calling the set accessor if specified and
        # validating the type if necessary.
        if isinstance(node, self.accessor_cls):
            self._set_node(node, new_data, element_idx)
        else:
            # Validate type of new node matches existing
            if not self.mutable and type(node) is not type(new_data):
                if not any(cur_path.startswith(part) for part in self.mutable_paths):
                    raise ParameterTreeError('Type mismatch updating {}: got {} expected {}'.format(
                        cur_path[:-1], type(new_data).__name__, type(node).__name__
                    ))
            node = new_data

        return node

    def _set_node(self, node, data, element_idx=None):
        """Set the value of a node to the specified data.

        This method trivially sets a specified node to the data supplied. It is exposed as a method
        to allow derived classes to override it and add behaviour as necessary.

        :param node: tree node to set value of
        :param data: data to node value to
        :param element_idx: index of element to set for list parameters, if applicable
        """
        node.set(data, element_idx)

tree property

Return tree object for this parameter tree node.

Used internally for recursive descent of parameter trees.

__init__(tree, mutable=False)

Initialise the BaseParameterTree object.

This constructor recursively initialises the BaseParameterTree object, based on the parameter tree dictionary passed as an argument. This is done recursively, so that a parameter tree can have arbitrary depth and contain other BaseParameterTree instances as necessary.

Initialisation syntax for BaseParameterTree is made by passing a dict representing the tree as an argument. Children of a node at any level of the tree are described with dictionaries/lists e.g.

{"parent" : {"childA" : {...}, "childB" : {...}}} {"parent" : [{...}, {...}]}

Leaf nodes can be one of the following formats:

value - (value,) - (value, {metadata}) getter - (getter,) - (getter, {metadata}) (getter, setter) - (getter, setter, {metadata})

The following tags will also be treated as metadata:

name - A printable name for that branch of the tree description - A printable description for that branch of the tree

Parameters:

Name Type Description Default
tree

dict representing the parameter tree

required
mutable

Flag, setting the tree

False
Source code in src/odin_control/adapters/base_parameter_tree.py
def __init__(self, tree, mutable=False):
    """Initialise the BaseParameterTree object.

    This constructor recursively initialises the BaseParameterTree object, based on the
    parameter tree dictionary passed as an argument. This is done recursively, so that a
    parameter tree can have arbitrary depth and contain other BaseParameterTree instances
    as necessary.

    Initialisation syntax for BaseParameterTree is made by passing a dict representing the tree
    as an argument. Children of a node at any level of the tree are described with
    dictionaries/lists e.g.

      {"parent" : {"childA" : {...}, "childB" : {...}}}
      {"parent" : [{...}, {...}]}

    Leaf nodes can be one of the following formats:

      value   -  (value,)  -  (value, {metadata})
      getter  -  (getter,) -  (getter, {metadata})
      (getter, setter)     -  (getter, setter, {metadata})

    The following tags will also be treated as metadata:

      name - A printable name for that branch of the tree
      description - A printable description for that branch of the tree

    :param tree: dict representing the parameter tree
    :param mutable: Flag, setting the tree
    """
    # Flag, if set to true, allows nodes to be replaced and new nodes created
    self.mutable = mutable

    # list of paths to mutable parts. Not sure this is best solution
    self.mutable_paths = []

    # Recursively check and initialise the tree
    self._tree = self._build_tree(tree)

delete(path='')

Delete parameters from a mutable tree.

This method deletes selected parameters from a tree, if that tree has been flagged as Mutable. Deletion of Branch Nodes means all child nodes of that Branch Node are also deleted

Parameters:

Name Type Description Default
path

Path to selected Parameter Node in the tree

''
Source code in src/odin_control/adapters/base_parameter_tree.py
def delete(self, path=''):
    """Delete parameters from a mutable tree.

    This method deletes selected parameters from a tree, if that tree has been flagged as
    Mutable. Deletion of Branch Nodes means all child nodes of that Branch Node are also deleted

    :param path: Path to selected Parameter Node in the tree
    """
    if not self.mutable and not any(path.startswith(part) for part in self.mutable_paths):
        raise ParameterTreeError("Invalid Delete Attempt: Tree Not Mutable")

    # Split the path by levels, truncating the last level if path ends in trailing slash
    levels = path.split('/')
    if levels[-1] == '':
        del levels[-1]

    subtree = self._tree

    if not levels:
        subtree.clear()
        return
    try:
        # Traverse down the path, based on hwo path navigation works in the Set Method above
        for level in levels[:-1]:

            # If the subtree is a dict, the subtree is a normal branch, continue traversal. If
            # it is not a dict the subtree is a list so the next path is indexed by the level
            if isinstance(subtree, dict):
                subtree = subtree[level]
            else:
                subtree = subtree[int(level)]

        # Once at the second to last part of the path, delete whatever comes next
        if isinstance(subtree, list):
            subtree.pop(int(levels[-1]))
        else:
            subtree.pop(levels[-1])
    except (KeyError, ValueError, IndexError):
        raise ParameterTreeError("Invalid path: {}".format(path))

get(path, with_metadata=False)

Get the values of parameters in a tree.

This method returns the values at and below a specified path in the parameter tree. This is done by recursively populating the tree with the current values of parameters, returning the result as a dictionary.

Parameters:

Name Type Description Default
path

path in tree to get parameter values for

required
with_metadata

include metadata in the response when set to True

False

Returns:

Type Description

dict of parameter tree at the specified path

Source code in src/odin_control/adapters/base_parameter_tree.py
def get(self, path, with_metadata=False):
    """Get the values of parameters in a tree.

    This method returns the values at and below a specified path in the parameter tree.
    This is done by recursively populating the tree with the current values of parameters,
    returning the result as a dictionary.

    :param path: path in tree to get parameter values for
    :param with_metadata: include metadata in the response when set to True
    :returns: dict of parameter tree at the specified path
    """
    # Split the path by levels, truncating the last level if path ends in trailing slash
    levels = path.split('/')
    if levels[-1] == '':
        del levels[-1]

    # Initialise the subtree before descent
    subtree = self._tree

    # Descend the specified levels in the path, checking for a valid subtree of the appropriate
    # type, A single level path returns the populated tree at the top level.
    for level in levels:
        try:
            if isinstance(subtree, dict):
                subtree = subtree[level]
            elif isinstance(subtree, self.accessor_cls):
                subtree = subtree.get(element_idx=level, with_metadata=with_metadata)
            else:
                subtree = subtree[int(level)]
        except (KeyError, ValueError, IndexError):
            raise ParameterTreeError("Invalid path: {}".format(path))

    # Return the populated tree at the appropriate path
    values = self._populate_tree(subtree, with_metadata)

    # If this is a request for a single leaf node (i.e. depth is 1 and only one value
    # returned) without metadata, return a value dict rather than just the value itself
    if not with_metadata and not isinstance(values, dict):
        values = {'value': values}

    return values

replace(path, data)

Replace a branch of parameters in a tree.

This method sets the values of parameters in a tree, based on the data passed to it as a nested dictionary of parameter and value pairs. Any structure below the insertion point in the existing tree is replaced with this new structure.

Parameters:

Name Type Description Default
path

path to set parameters for in the tree

required
data

nested dictionary representing structure to replace at the path

required
Source code in src/odin_control/adapters/base_parameter_tree.py
def replace(self, path, data):
    """Replace a branch of parameters in a tree.

    This method sets the values of parameters in a tree, based on the data passed to it
    as a nested dictionary of parameter and value pairs. Any structure below the insertion
    point in the existing tree is replaced with this new structure.

    :param path: path to set parameters for in the tree
    :param data: nested dictionary representing structure to replace at the path
    """
    self.set(path, data, replace=True)

set(path, data, replace=False)

Set the values of the parameters in a tree.

This method sets the values of parameters in a tree, based on the data passed to it as a nested dictionary of parameter and value pairs. The updated parameters are merged into the existing tree recursively.

Parameters:

Name Type Description Default
path

path to set parameters for in the tree

required
data

nested dictionary representing values to update at the path

required
replace

if set to true then the structure is replaced rather than merged

False
Source code in src/odin_control/adapters/base_parameter_tree.py
def set(self, path, data, replace=False):
    """Set the values of the parameters in a tree.

    This method sets the values of parameters in a tree, based on the data passed to it
    as a nested dictionary of parameter and value pairs. The updated parameters are merged
    into the existing tree recursively.

    :param path: path to set parameters for in the tree
    :param data: nested dictionary representing values to update at the path
    :param replace: if set to true then the structure is replaced rather than merged
    """
    # Expand out any lists/tuples
    data = self._build_tree(data)

    # Get subtree from the node the path points to
    levels = path.split('/')
    if levels[-1] == '':
        del levels[-1]

    # Initialise variables used during descent of the tree
    merge_parent = self._tree
    merge_child = self._tree
    element_idx = None
    parent_level = -1

    # Descend the tree and validate each element of the path
    for level in levels:
        try:
            if isinstance(merge_child, dict):
                merge_parent = merge_child
                merge_child = merge_child[level]
            elif isinstance(merge_child, self.accessor_cls):
                if merge_child.type in (list, dict):
                    element_idx = level
                    parent_level = -2
            else:
                merge_parent = merge_child
                merge_child = merge_child[int(level)]
        except (KeyError, ValueError, IndexError):
            raise ParameterTreeError("Invalid path: {}".format(path))

    # Add trailing / to paths where necessary
    if path and path[-1] != '/':
        path += '/'

    # Merge data with tree
    if replace:
        if not self.mutable:
            raise ParameterTreeError("Invalid replace attempt: tree not mutable")
        merged = data
    else:
        merged = self._merge_tree(merge_child, data, path, element_idx)

    # Add merged part to tree, either at the top of the tree or at the
    # appropriate level specified by the path
    if not levels:
        self._tree = merged
        return
    if isinstance(merge_parent, dict):
        merge_parent[levels[parent_level]] = merged
    else:
        merge_parent[int(levels[parent_level])] = merged

ParameterTreeError

Bases: Exception

Simple error class for raising parameter tree parameter tree exceptions.

Source code in src/odin_control/adapters/base_parameter_tree.py
class ParameterTreeError(Exception):
    """Simple error class for raising parameter tree parameter tree exceptions."""

    pass