blob: 48f07399bd2547ed47d4067c244a70be3a070e18 [file] [log] [blame]
#
# Copyright (C) 2018 Codethink Limited
# Copyright (C) 2019 Bloomberg LLP
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library. If not, see <http://www.gnu.org/licenses/>.
#
# Authors:
# Tristan Van Berkom <tristan.vanberkom@codethink.co.uk>
# Daniel Silverstone <daniel.silverstone@codethink.co.uk>
# James Ennis <james.ennis@codethink.co.uk>
# Benjamin Schubert <bschubert@bloomberg.net>
"""
Node - Parsed YAML configuration
================================
This module contains the building blocks for handling YAML configuration.
Everything that is loaded from YAML is encapsulated in such nodes, which
provide helper methods to validate configuration on access.
Using node methods when reading configuration will ensure that errors
are always coherently notified to the user.
.. note:: Plugins are not expected to handle exceptions thrown by node
methods for the above reason; They are private. There should
always be a way to acquire information without resorting to
exception handling.
Node types
----------
The most important classes defined here are:
* :class:`.MappingNode`: represents a YAML Mapping (dictionary)
* :class:`.ScalarNode`: represents a YAML Scalar (string, boolean, integer)
* :class:`.SequenceNode`: represents a YAML Sequence (list)
Class Reference
---------------
"""
import string
from ._exceptions import LoadError
from .exceptions import LoadErrorReason
# A sentinel to be used as a default argument for functions that need
# to distinguish between a kwarg set to None and an unset kwarg.
_sentinel = object()
cdef class Node:
"""This is the base class for YAML document nodes.
YAML Nodes contain information to describe the provenance of the YAML
which resulted in the Node, allowing mapping back from a Node to the place
in the file it came from.
.. note:: You should never need to create a :class:`.Node` manually.
If you do, you can create :class:`.Node` from dictionaries with
:func:`Node.from_dict() <buildstream.node.Node.from_dict>`.
If something else is needed, please open an issue.
"""
def __init__(self):
raise NotImplementedError("Please do not construct nodes like this. Use Node.from_dict(dict) instead.")
def __cinit__(self, int file_index, int line, int column, *args):
self.file_index = file_index
self.line = line
self.column = column
# This is in order to ensure we never add a `Node` to a cache key
# as ujson will try to convert objects if they have a `__json__`
# attribute.
def __json__(self):
raise ValueError("Nodes should not be allowed when jsonify-ing data", self)
def __str__(self):
return "{}: {}".format(self.get_provenance(), self.strip_node_info())
#############################################################
# Abstract Public Methods #
#############################################################
cpdef Node clone(self):
"""Clone the node and return the copy.
Returns:
:class:`.Node`: a clone of the current node
"""
raise NotImplementedError()
cpdef object strip_node_info(self):
""" Remove all the node information (provenance) and return the underlying data as plain python objects
Returns:
(list, dict, str, None): the underlying data that was held in the node structure.
"""
raise NotImplementedError()
#############################################################
# Public Methods #
#############################################################
@classmethod
def from_dict(cls, dict value):
"""from_dict(value)
Create a new node from the given dictionary.
This is a recursive operation, and will transform every value in the
dictionary to a :class:`.Node` instance
Valid values for keys are `str`
Valid values for values are `list`, `dict`, `str`, `int`, `bool` or None.
`list` and `dict` can also only contain such types.
Args:
value (dict): dictionary from which to create a node.
Raises:
:class:`TypeError`: when the value cannot be converted to a :class:`Node`
Returns:
:class:`.MappingNode`: a new mapping containing the value
"""
if value:
return __new_node_from_dict(value, MappingNode.__new__(
MappingNode, _SYNTHETIC_FILE_INDEX, 0, __next_synthetic_counter(), {}))
else:
# We got an empty dict, we can shortcut
return MappingNode.__new__(MappingNode, _SYNTHETIC_FILE_INDEX, 0, __next_synthetic_counter(), {})
cpdef ProvenanceInformation get_provenance(self):
"""A convenience accessor to obtain the node's :class:`.ProvenanceInformation`
The provenance information allows you to inform the user of where
a node came. Transforming the information to a string will show the file, line and column
in the file where the node is.
An example usage would be:
.. code-block:: python
# With `config` being your node
max_jobs_node = config.get_node('max-jobs')
max_jobs = max_jobs_node.as_int()
if max_jobs < 1: # We can't get a negative number of jobs
raise LoadError("Error at {}: Max jobs needs to be >= 1".format(
max_jobs_node.get_provenance()
)
# Will print something like:
# element.bst [line 4, col 7]: Max jobs needs to be >= 1
Returns:
:class:`.ProvenanceInformation`: the provenance information for the node.
"""
return ProvenanceInformation(self)
#############################################################
# Abstract Private Methods used in BuildStream #
#############################################################
# _assert_fully_composited()
#
# This must be called on a fully loaded and composited node,
# after all composition has completed.
#
# This checks that no more composition directives are present
# in the data.
#
# Raises:
# (LoadError): If any assertions fail
#
cpdef void _assert_fully_composited(self) except *:
raise NotImplementedError()
#############################################################
# Abstract Protected Methods #
#############################################################
# _compose_on(key, target, path)
#
# Compose the current node on the given target.
#
# Args:
# key (str): key on the target on which to compose the current value
# target (.Node): target node on which to compose
# path (list): path from the root of the target when composing recursively
# in order to give accurate error reporting.
#
# Raises:
# (_CompositeError): if an error is encountered during composition
#
cdef void _compose_on(self, str key, MappingNode target, list path) except *:
raise NotImplementedError()
# _is_composite_list
#
# Checks if the node is a Mapping with list composition
# directives.
#
# Returns:
# (bool): True if node was a Mapping containing only
# list composition directives
#
# Raises:
# (LoadError): If node was a mapping and contained a mix of
# list composition directives and other keys
#
cdef bint _is_composite_list(self) except *:
raise NotImplementedError()
# _walk_find(target, path)
#
# Walk the node to search for `target`.
#
# When this returns `True`, the `path` argument will contain the full path
# to the target from the root node.
#
# Args:
# target (.Node): target to find in the node tree
# path (list): current path from the root
#
# Returns:
# (bool): whether the target was found in the tree or not
#
cdef bint _walk_find(self, Node target, list path) except *:
raise NotImplementedError()
#############################################################
# Protected Methods #
#############################################################
# _shares_position_with(target)
#
# Check whether the current node is at the same position in its tree as the target.
#
# This is useful when we want to know if two nodes are 'identical', that is they
# are at the exact same position in each respective tree, but do not necessarily
# have the same content.
#
# Args:
# target (.Node): the target to compare with the current node.
#
# Returns:
# (bool): whether the two nodes share the same position
#
cdef bint _shares_position_with(self, Node target):
return (self.file_index == target.file_index and
self.line == target.line and
self.column == target.column)
cdef class ScalarNode(Node):
"""This class represents a Scalar (int, str, bool, None) in a YAML document.
.. note:: If you need to store another type of scalars, please open an issue
on the project.
.. note:: You should never have to create a :class:`.ScalarNode` directly
"""
def __cinit__(self, int file_index, int line, int column, object value):
cdef value_type = type(value)
if value_type is str:
value = value.strip()
elif value_type is bool:
if value:
value = "True"
else:
value = "False"
elif value_type is int:
value = str(value)
elif value is None:
pass
else:
raise ValueError("ScalarNode can only hold str, int, bool or None objects")
self.value = value
def __reduce__(self):
return (
ScalarNode.__new__,
(ScalarNode, self.file_index, self.line, self.column, self.value),
)
#############################################################
# Public Methods #
#############################################################
cpdef bint as_bool(self) except *:
"""Get the value of the node as a boolean.
.. note:: BuildStream treats the values 'True' and 'true' as True,
and the values 'False' and 'false' as False. Any other
string values (such as the valid YAML 'TRUE' or 'FALSE'
will be considered as an error)
Raises:
:class:`buildstream._exceptions.LoadError`: if the value cannot be coerced to
a bool correctly.
Returns:
:class:`bool`: the value contained in the node, as a boolean
"""
if type(self.value) is bool:
return self.value
# Don't coerce strings to booleans, this makes "False" strings evaluate to True
if self.value in ('True', 'true'):
return True
elif self.value in ('False', 'false'):
return False
else:
provenance = self.get_provenance()
path = provenance._toplevel._find(self)[-1]
raise LoadError("{}: Value of '{}' is not of the expected type '{}'"
.format(provenance, path, bool.__name__, self.value),
LoadErrorReason.INVALID_DATA)
cpdef object as_enum(self, object constraint):
"""Get the value of the node as an enum member from `constraint`
The constraint must be a :class:`buildstream.types.FastEnum` or a plain python Enum.
For example you could do:
.. code-block:: python
from buildstream.types import FastEnum
class SupportedCompressions(FastEnum):
NONE = "none"
GZIP = "gzip"
XZ = "xz"
x = config.get_scalar('compress').as_enum(SupportedCompressions)
if x == SupportedCompressions.GZIP:
print("Using GZIP")
Args:
constraint (:class:`buildstream.types.FastEnum` or :class:`Enum`): an enum from which to extract the value
for the current node.
Returns:
:class:`FastEnum` or :class:`Enum`: the value contained in the node, as a member of `constraint`
"""
try:
return constraint(self.value)
except ValueError:
provenance = self.get_provenance()
path = provenance._toplevel._find(self)[-1]
valid_values = [str(v.value) for v in constraint]
raise LoadError("{}: Value of '{}' should be one of '{}'".format(
provenance, path, ", ".join(valid_values)),
LoadErrorReason.INVALID_DATA)
cpdef int as_int(self) except *:
"""Get the value of the node as an integer.
Raises:
:class:`buildstream._exceptions.LoadError`: if the value cannot be coerced to
an integer correctly.
Returns:
:class:`int`: the value contained in the node, as a integer
"""
try:
return int(self.value)
except ValueError:
provenance = self.get_provenance()
path = provenance._toplevel._find(self)[-1]
raise LoadError("{}: Value of '{}' is not of the expected type '{}'"
.format(provenance, path, int.__name__),
LoadErrorReason.INVALID_DATA)
cpdef str as_str(self):
"""Get the value of the node as a string.
Returns:
:class:`str`: the value contained in the node, as a string, or `None` if the content
is `None`.
"""
# We keep 'None' as 'None' to simplify the API's usage and allow chaining for users
if self.value is None:
return None
return str(self.value)
cpdef bint is_none(self):
"""Determine whether the current scalar is `None`.
Returns:
:class:`bool`: `True` if the value of the scalar is `None`, else `False`
"""
return self.value is None
#############################################################
# Public Methods implementations #
#############################################################
cpdef ScalarNode clone(self):
return ScalarNode.__new__(
ScalarNode, self.file_index, self.line, self.column, self.value
)
cpdef object strip_node_info(self):
return self.value
#############################################################
# Private Methods implementations #
#############################################################
cpdef void _assert_fully_composited(self) except *:
pass
#############################################################
# Protected Methods #
#############################################################
cdef void _compose_on(self, str key, MappingNode target, list path) except *:
cdef Node target_value = target.value.get(key)
if target_value is not None and type(target_value) is not ScalarNode:
raise __CompositeError(path,
"{}: Cannot compose scalar on non-scalar at {}".format(
self.get_provenance(),
target_value.get_provenance()))
target.value[key] = self
cdef bint _is_composite_list(self) except *:
return False
cdef bint _walk_find(self, Node target, list path) except *:
return self._shares_position_with(target)
cdef class MappingNode(Node):
"""This class represents a Mapping (dict) in a YAML document.
It behaves mostly like a :class:`dict`, but doesn't allow untyped value access
(Nothing of the form :code:`my_dict[my_value]`.
It also doesn't allow anything else than :class:`str` as keys, to align with YAML.
You can however use common dict operations in it:
.. code-block:: python
# Assign a new value to a key
my_mapping[key] = my_value
# Delete an entry
del my_mapping[key]
When assigning a key/value pair, the key must be a string,
and the value can be any of:
* a :class:`Node`, in which case the node is just assigned like normally
* a :class:`list`, :class:`dict`, :class:`int`, :class:`str`, :class:`bool` or :class:`None`.
In which case, the value will be converted to a :class:`Node` for you.
Therefore, all values in a :class:`.MappingNode` will be :class:`Node`.
.. note:: You should never create an instance directly. Use :func:`Node.from_dict() <buildstream.node.Node.from_dict>`
instead, which will ensure your node is correctly formatted.
"""
def __cinit__(self, int file_index, int line, int column, dict value):
self.value = value
def __reduce__(self):
return (
MappingNode.__new__,
(MappingNode, self.file_index, self.line, self.column, self.value),
)
def __contains__(self, what):
return what in self.value
def __delitem__(self, str key):
del self.value[key]
def __setitem__(self, str key, object value):
cdef Node old_value
if type(value) in [MappingNode, ScalarNode, SequenceNode]:
self.value[key] = value
else:
node = __create_node_recursive(value, self)
# FIXME: Do we really want to override provenance?
#
# Related to https://gitlab.com/BuildStream/buildstream/issues/1058
#
# There are only two cases were nodes are set in the code (hence without provenance):
# - When automatic variables are set by the core (e-g: max-jobs)
# - when plugins call Element.set_public_data
#
# The first case should never throw errors, so it is of limited interests.
#
# The second is more important. What should probably be done here is to have 'set_public_data'
# able of creating a fake provenance with the name of the plugin, the project and probably the
# element name.
#
# We would therefore have much better error messages, and would be able to get rid of most synthetic
# nodes.
old_value = self.value.get(key)
if old_value:
node.file_index = old_value.file_index
node.line = old_value.line
node.column = old_value.column
self.value[key] = node
#############################################################
# Public Methods #
#############################################################
cpdef bint get_bool(self, str key, object default=_sentinel) except *:
"""get_bool(key, default=sentinel)
Get the value of the node for `key` as a boolean.
This is equivalent to: :code:`mapping.get_scalar(my_key, my_default).as_bool()`.
Args:
key (str): key for which to get the value
default (bool): default value to return if `key` is not in the mapping
Raises:
:class:`buildstream._exceptions.LoadError`: if the value at `key` is not a
:class:`.ScalarNode` or isn't a
valid `boolean`
Returns:
:class:`bool`: the value at `key` or the default
"""
cdef ScalarNode scalar = self.get_scalar(key, default)
return scalar.as_bool()
cpdef object get_enum(self, str key, object constraint, object default=_sentinel):
"""Get the value of the node as an enum member from `constraint`
Args:
key (str): key for which to get the value
constraint (:class:`buildstream.types.FastEnum` or :class:`Enum`): an enum from which to extract the value
for the current node.
default (object): default value to return if `key` is not in the mapping
Raises:
:class:`buildstream._exceptions.LoadError`: if the value is not is not found or not part of the
provided enum.
Returns:
:class:`buildstream.types.Enum` or :class:`Enum`: the value contained in the node, as a member of
`constraint`
"""
cdef object value = self.value.get(key, _sentinel)
if value is _sentinel:
if default is _sentinel:
provenance = self.get_provenance()
raise LoadError("{}: Dictionary did not contain expected key '{}'".format(provenance, key),
LoadErrorReason.INVALID_DATA)
if default is None:
return None
else:
return constraint(default)
if type(value) is not ScalarNode:
provenance = value.get_provenance()
raise LoadError("{}: Value of '{}' is not of the expected type 'scalar'"
.format(provenance, key),
LoadErrorReason.INVALID_DATA)
return (<ScalarNode> value).as_enum(constraint)
cpdef object get_int(self, str key, object default=_sentinel):
"""get_int(key, default=sentinel)
Get the value of the node for `key` as an integer.
This is equivalent to: :code:`mapping.get_scalar(my_key, my_default).as_int()`.
Args:
key (str): key for which to get the value
default (int, None): default value to return if `key` is not in the mapping
Raises:
:class:`buildstream._exceptions.LoadError`: if the value at `key` is not a
:class:`.ScalarNode` or isn't a
valid `integer`
Returns:
:class:`int` or :class:`None`: the value at `key` or the default
"""
cdef ScalarNode scalar = self.get_scalar(key, default)
if default is None and scalar.is_none():
return None
return scalar.as_int()
cpdef MappingNode get_mapping(self, str key, object default=_sentinel):
"""get_mapping(key, default=sentinel)
Get the value of the node for `key` as a :class:`.MappingNode`.
Args:
key (str): key for which to get the value
default (dict): default value to return if `key` is not in the mapping. It will be converted
to a :class:`.MappingNode` before being returned
Raises:
:class:`buildstream._exceptions.LoadError`: if the value at `key` is not a
:class:`.MappingNode`
Returns:
:class:`.MappingNode`: the value at `key` or the default
"""
value = self._get(key, default, MappingNode)
if type(value) is not MappingNode and value is not None:
provenance = value.get_provenance()
raise LoadError("{}: Value of '{}' is not of the expected type 'dict'"
.format(provenance, key), LoadErrorReason.INVALID_DATA)
return value
cpdef Node get_node(self, str key, list allowed_types = None, bint allow_none = False):
"""get_node(key, allowed_types=None, allow_none=False)
Get the value of the node for `key` as a :class:`.Node`.
This is useful if you have configuration that can be either a :class:`.ScalarNode` or
a :class:`.MappingNode` for example.
This method will validate that the value is indeed exactly one of those types (not a subclass)
and raise an exception accordingly.
Args:
key (str): key for which to get the value
allowed_types (list): list of valid subtypes of :class:`.Node` that are valid return values.
If this is `None`, no checks are done on the return value.
allow_none (bool): whether to allow the return value to be `None` or not
Raises:
:class:`buildstream._exceptions.LoadError`: if the value at `key` is not one
of the expected types or if it doesn't
exist.
Returns:
:class:`.Node`: the value at `key` or `None`
"""
cdef value = self.value.get(key, _sentinel)
if value is _sentinel:
if allow_none:
return None
provenance = self.get_provenance()
raise LoadError("{}: Dictionary did not contain expected key '{}'".format(provenance, key),
LoadErrorReason.INVALID_DATA)
__validate_node_type(value, allowed_types, key)
return value
cpdef ScalarNode get_scalar(self, str key, object default=_sentinel):
"""get_scalar(key, default=sentinel)
Get the value of the node for `key` as a :class:`.ScalarNode`.
Args:
key (str): key for which to get the value
default (str, int, bool, None): default value to return if `key` is not in the mapping.
It will be converted to a :class:`.ScalarNode` before being
returned.
Raises:
:class:`buildstream._exceptions.LoadError`: if the value at `key` is not a
:class:`.MappingNode`
Returns:
:class:`.ScalarNode`: the value at `key` or the default
"""
value = self._get(key, default, ScalarNode)
if type(value) is not ScalarNode:
if value is None:
value = ScalarNode.__new__(ScalarNode, self.file_index, 0, __next_synthetic_counter(), None)
else:
provenance = value.get_provenance()
raise LoadError("{}: Value of '{}' is not of the expected type 'scalar'"
.format(provenance, key), LoadErrorReason.INVALID_DATA)
return value
cpdef SequenceNode get_sequence(self, str key, object default=_sentinel, list allowed_types = None):
"""get_sequence(key, default=sentinel)
Get the value of the node for `key` as a :class:`.SequenceNode`.
Args:
key (str): key for which to get the value
default (list): default value to return if `key` is not in the mapping. It will be converted
to a :class:`.SequenceNode` before being returned
allowed_types (list): list of valid subtypes of :class:`.Node` that are valid for nodes in the sequence.
Raises:
:class:`buildstream._exceptions.LoadError`: if the value at `key` is not a
:class:`.SequenceNode`
Returns:
:class:`.SequenceNode`: the value at `key` or the default
"""
cdef Node value = self._get(key, default, SequenceNode)
cdef Node node
if type(value) is not SequenceNode and value is not None:
provenance = value.get_provenance()
raise LoadError("{}: Value of '{}' is not of the expected type 'list'"
.format(provenance, key), LoadErrorReason.INVALID_DATA)
if allowed_types:
for node in value:
__validate_node_type(node, allowed_types)
return <SequenceNode> value
cpdef str get_str(self, str key, object default=_sentinel):
"""get_str(key, default=sentinel)
Get the value of the node for `key` as an string.
This is equivalent to: :code:`mapping.get_scalar(my_key, my_default).as_str()`.
Args:
key (str): key for which to get the value
default (str): default value to return if `key` is not in the mapping
Raises:
:class:`buildstream._exceptions.LoadError`: if the value at `key` is not a
:class:`.ScalarNode` or isn't a
valid `str`
Returns:
:class:`str`: the value at `key` or the default
"""
cdef ScalarNode scalar = self.get_scalar(key, default)
return scalar.as_str()
cpdef list get_str_list(self, str key, object default=_sentinel):
"""get_str_list(key, default=sentinel)
Get the value of the node for `key` as a list of strings.
This is equivalent to: :code:`mapping.get_sequence(my_key, my_default).as_str_list()`.
Args:
key (str): key for which to get the value
default (str): default value to return if `key` is not in the mapping
Raises:
:class:`buildstream._exceptions.LoadError`: if the value at `key` is not a
:class:`.SequenceNode` or if any
of its internal values is not a ScalarNode.
Returns:
:class:`list`: the value at `key` or the default
"""
cdef SequenceNode sequence = self.get_sequence(key, default)
if sequence is not None:
return sequence.as_str_list()
return None
cpdef object items(self):
"""Get a new view of the mapping items ((key, value) pairs).
This is equivalent to running :code:`my_dict.item()` on a `dict`.
Returns:
:class:`dict_items`: a view on the underlying dictionary
"""
return self.value.items()
cpdef list keys(self):
"""Get the list of all keys in the mapping.
This is equivalent to running :code:`my_dict.keys()` on a `dict`.
Returns:
:class:`list`: a list of all keys in the mapping
"""
return list(self.value.keys())
cpdef void safe_del(self, str key):
"""safe_del(key)
Remove the entry at `key` in the dictionary if it exists.
This method is a safe equivalent to :code:`del mapping[key]`, that doesn't
throw anything if the key doesn't exist.
Args:
key (str): key to remove from the mapping
"""
self.value.pop(key, None)
cpdef void validate_keys(self, list valid_keys) except *:
"""validate_keys(valid_keys)
Validate that the node doesn't contain extra keys
This validates the node so as to ensure the user has not specified
any keys which are unrecognized by BuildStream (usually this
means a typo which would otherwise not trigger an error).
Args:
valid_keys (list): A list of valid keys for the specified node
Raises:
:class:`buildstream._exceptions.LoadError`: In the case that the specified node contained
one or more invalid keys
"""
# Probably the fastest way to do this: https://stackoverflow.com/a/23062482
cdef set valid_keys_set = set(valid_keys)
cdef str key
for key in self.value:
if key not in valid_keys_set:
provenance = self.get_node(key).get_provenance()
raise LoadError("{}: Unexpected key: {}".format(provenance, key),
LoadErrorReason.INVALID_DATA)
cpdef object values(self):
"""Get the values in the mapping.
This is equivalent to running :code:`my_dict.values()` on a `dict`.
Returns:
:class:`dict_values`: a list of all values in the mapping
"""
return self.value.values()
#############################################################
# Public Methods implementations #
#############################################################
cpdef MappingNode clone(self):
cdef dict copy = {}
cdef str key
cdef Node value
for key, value in self.value.items():
copy[key] = value.clone()
return MappingNode.__new__(MappingNode, self.file_index, self.line, self.column, copy)
cpdef object strip_node_info(self):
cdef str key
cdef Node value
return {key: value.strip_node_info() for key, value in self.value.items()}
#############################################################
# Private Methods used in BuildStream #
#############################################################
# _composite()
#
# Compose one mapping node onto another
#
# Args:
# target (Node): The target to compose into
#
# Raises: LoadError
#
cpdef void _composite(self, MappingNode target) except *:
try:
self.__composite(target, [])
except __CompositeError as e:
source_provenance = self.get_provenance()
error_prefix = ""
if source_provenance:
error_prefix = "{}: ".format(source_provenance)
raise LoadError("{}Failure composing {}: {}"
.format(error_prefix,
e.path,
e.message),
LoadErrorReason.ILLEGAL_COMPOSITE) from e
# Like self._composite(target), but where values in the target don't get overridden by values in self.
#
cpdef void _composite_under(self, MappingNode target) except *:
target._composite(self)
cdef str key
cdef Node value
cdef list to_delete = [key for key in target.value.keys() if key not in self.value]
for key, value in self.value.items():
target.value[key] = value
for key in to_delete:
del target.value[key]
# _find()
#
# Searches the given node tree for the given target node.
#
# This is typically used when trying to walk a path to a given node
# for the purpose of then modifying a similar tree of objects elsewhere
#
# Args:
# target (Node): The node you are looking for in that tree
#
# Returns:
# (list): A path from `node` to `target` or None if `target` is not in the subtree
cpdef list _find(self, Node target):
cdef list path = []
if self._walk_find(target, path):
return path
return None
#############################################################
# Private Methods implementations #
#############################################################
cpdef void _assert_fully_composited(self) except *:
cdef str key
cdef Node value
for key, value in self.value.items():
# Assert that list composition directives dont remain, this
# indicates that the user intended to override a list which
# never existed in the underlying data
#
if key in ('(>)', '(<)', '(=)'):
provenance = value.get_provenance()
raise LoadError("{}: Attempt to override non-existing list".format(provenance),
LoadErrorReason.TRAILING_LIST_DIRECTIVE)
value._assert_fully_composited()
#############################################################
# Protected Methods #
#############################################################
cdef void _compose_on(self, str key, MappingNode target, list path) except *:
cdef Node target_value
if self._is_composite_list():
if key not in target.value:
# Composite list clobbers empty space
target.value[key] = self
else:
target_value = target.value[key]
if type(target_value) is SequenceNode:
# Composite list composes into a list
self._compose_on_list(target_value)
elif target_value._is_composite_list():
# Composite list merges into composite list
self._compose_on_composite_dict(target_value)
else:
# Else composing on top of normal dict or a scalar, so raise...
raise __CompositeError(path,
"{}: Cannot compose lists onto {}".format(
self.get_provenance(),
target_value.get_provenance()))
else:
# We're composing a dict into target now
if key not in target.value:
# Target lacks a dict at that point, make a fresh one with
# the same provenance as the incoming dict
target.value[key] = MappingNode.__new__(MappingNode, self.file_index, self.line, self.column, {})
self.__composite(target.value[key], path)
# _compose_on_list(target)
#
# Compose the current node on the given sequence.
#
# Args:
# target (.SequenceNode): sequence on which to compose the current composite dict
#
cdef void _compose_on_list(self, SequenceNode target):
cdef SequenceNode clobber = self.value.get("(=)")
cdef SequenceNode prefix = self.value.get("(<)")
cdef SequenceNode suffix = self.value.get("(>)")
if clobber is not None:
target.value.clear()
target.value.extend(clobber.value)
if prefix is not None:
for v in reversed(prefix.value):
target.value.insert(0, v)
if suffix is not None:
target.value.extend(suffix.value)
# _compose_on_composite_dict(target)
#
# Compose the current node on the given composite dict.
#
# A composite dict is a dict that contains composition directives.
#
# Args:
# target (.MappingNode): sequence on which to compose the current composite dict
#
cdef void _compose_on_composite_dict(self, MappingNode target):
cdef SequenceNode clobber = self.value.get("(=)")
cdef SequenceNode prefix = self.value.get("(<)")
cdef SequenceNode suffix = self.value.get("(>)")
if clobber is not None:
# We want to clobber the target list
# which basically means replacing the target list
# with ourselves
target.value["(=)"] = clobber
if prefix is not None:
target.value["(<)"] = prefix
elif "(<)" in target.value:
(<SequenceNode> target.value["(<)"]).value.clear()
if suffix is not None:
target.value["(>)"] = suffix
elif "(>)" in target.value:
(<SequenceNode> target.value["(>)"]).value.clear()
else:
# Not clobbering, so prefix the prefix and suffix the suffix
if prefix is not None:
if "(<)" in target.value:
for v in reversed(prefix.value):
(<SequenceNode> target.value["(<)"]).value.insert(0, v)
else:
target.value["(<)"] = prefix
if suffix is not None:
if "(>)" in target.value:
(<SequenceNode> target.value["(>)"]).value.extend(suffix.value)
else:
target.value["(>)"] = suffix
cdef bint _is_composite_list(self) except *:
cdef bint has_directives = False
cdef bint has_keys = False
cdef str key
for key in self.value.keys():
if key in ["(>)", "(<)", "(=)"]:
has_directives = True
else:
has_keys = True
if has_keys and has_directives:
provenance = self.get_provenance()
raise LoadError("{}: Dictionary contains list composition directives and arbitrary keys"
.format(provenance), LoadErrorReason.INVALID_DATA)
return has_directives
cdef bint _walk_find(self, Node target, list path) except *:
cdef str k
cdef Node v
if self._shares_position_with(target):
return True
for k, v in self.value.items():
path.append(k)
if v._walk_find(target, path):
return True
del path[-1]
return False
#############################################################
# Private Methods #
#############################################################
# __composite(target, path)
#
# Helper method to compose the current node on another.
#
# Args:
# target (.MappingNode): target on which to compose the current node
# path (list): path from the root of the target when composing recursively
# in order to give accurate error reporting.
#
cdef void __composite(self, MappingNode target, list path) except *:
cdef str key
cdef Node value
for key, value in self.value.items():
path.append(key)
value._compose_on(key, target, path)
path.pop()
# Clobber the provenance of the target mapping node if we're not
# synthetic.
if self.file_index != _SYNTHETIC_FILE_INDEX:
target.file_index = self.file_index
target.line = self.line
target.column = self.column
# _get(key, default, default_constructor)
#
# Internal helper method to get an entry from the underlying dictionary.
#
# Args:
# key (str): the key for which to retrieve the entry
# default (object): default value if the entry is not present
# default_constructor (object): method to transform the `default` into a Node
# if the entry is not present
#
# Raises:
# (LoadError): if the key is not present and no default has been given.
#
cdef Node _get(self, str key, object default, object default_constructor):
value = self.value.get(key, _sentinel)
if value is _sentinel:
if default is _sentinel:
provenance = self.get_provenance()
raise LoadError("{}: Dictionary did not contain expected key '{}'".format(provenance, key),
LoadErrorReason.INVALID_DATA)
if default is None:
value = None
else:
value = default_constructor.__new__(
default_constructor, _SYNTHETIC_FILE_INDEX, 0, __next_synthetic_counter(), default)
return value
cdef class SequenceNode(Node):
"""This class represents a Sequence (list) in a YAML document.
It behaves mostly like a :class:`list`, but doesn't allow untyped value access
(Nothing of the form :code:`my_list[my_value]`).
You can however perform common list operations on it:
.. code-block:: python
# Assign a value
my_sequence[key] = value
# Get the length
len(my_sequence)
# Reverse it
reversed(my_sequence)
# And iter over it
for value in my_sequence:
print(value)
All values in a :class:`SequenceNode` will be :class:`Node`.
"""
def __cinit__(self, int file_index, int line, int column, list value):
self.value = value
def __reduce__(self):
return (
SequenceNode.__new__,
(SequenceNode, self.file_index, self.line, self.column, self.value),
)
def __iter__(self):
return iter(self.value)
def __len__(self):
return len(self.value)
def __reversed__(self):
return reversed(self.value)
def __setitem__(self, int key, object value):
cdef Node old_value
if type(value) in [MappingNode, ScalarNode, SequenceNode]:
self.value[key] = value
else:
node = __create_node_recursive(value, self)
# FIXME: Do we really want to override provenance?
# See __setitem__ on 'MappingNode' for more context
old_value = self.value[key]
if old_value:
node.file_index = old_value.file_index
node.line = old_value.line
node.column = old_value.column
self.value[key] = node
#############################################################
# Public Methods #
#############################################################
cpdef void append(self, object value):
"""append(value)
Append the given object to the sequence.
Args:
value (object): the value to append to the list. This can either be:
- a :class:`Node`
- a :class:`int`, :class:`bool`, :class:`str`, :class:`None`,
:class:`dict` or :class:`list`. In which case, this will be
converted into a :class:`Node` beforehand
Raises:
:class:`TypeError`: when the value cannot be converted to a :class:`Node`
"""
if type(value) in [MappingNode, ScalarNode, SequenceNode]:
self.value.append(value)
else:
node = __create_node_recursive(value, self)
self.value.append(node)
cpdef list as_str_list(self):
"""Get the values of the sequence as a list of strings.
Raises:
:class:`buildstream._exceptions.LoadError`: if the sequence contains more than
:class:`ScalarNode`
Returns:
:class:`list`: the content of the sequence as a list of strings
"""
cdef list str_list = []
cdef Node node
for node in self.value:
if type(node) is not ScalarNode:
provenance = node.get_provenance()
raise LoadError("{}: List item is not of the expected type 'scalar'"
.format(provenance), LoadErrorReason.INVALID_DATA)
str_list.append(node.as_str())
return str_list
cpdef MappingNode mapping_at(self, int index):
"""mapping_at(index)
Retrieve the entry at `index` as a :class:`.MappingNode`.
Args:
index (int): index for which to get the value
Raises:
:class:`buildstream._exceptions.LoadError`: if the value at `key` is not a
:class:`.MappingNode`
:class:`IndexError`: if no value exists at this index
Returns:
:class:`.MappingNode`: the value at `index`
"""
value = self.value[index]
if type(value) is not MappingNode:
provenance = self.get_provenance()
path = ["[{}]".format(p) for p in provenance._toplevel._find(self)] + ["[{}]".format(index)]
raise LoadError("{}: Value of '{}' is not of the expected type '{}'"
.format(provenance, path, MappingNode.__name__),
LoadErrorReason.INVALID_DATA)
return value
cpdef Node node_at(self, int index, list allowed_types = None):
"""node_at(index, allowed_types=None)
Retrieve the entry at `index` as a :class:`.Node`.
This is useful if you have configuration that can be either a :class:`.ScalarNode` or
a :class:`.MappingNode` for example.
This method will validate that the value is indeed exactly one of those types (not a subclass)
and raise an exception accordingly.
Args:
index (int): index for which to get the value
allowed_types (list): list of valid subtypes of :class:`.Node` that are valid return values.
If this is `None`, no checks are done on the return value.
Raises:
:class:`buildstream._exceptions.LoadError`: if the value at `index` is not of one of the
expected types
:class:`IndexError`: if no value exists at this index
Returns:
:class:`.Node`: the value at `index`
"""
cdef value = self.value[index]
__validate_node_type(value, allowed_types, str(index))
return value
cpdef ScalarNode scalar_at(self, int index):
"""scalar_at(index)
Retrieve the entry at `index` as a :class:`.ScalarNode`.
Args:
index (int): index for which to get the value
Raises:
:class:`buildstream._exceptions.LoadError`: if the value at `key` is not a
:class:`.ScalarNode`
:class:`IndexError`: if no value exists at this index
Returns:
:class:`.ScalarNode`: the value at `index`
"""
value = self.value[index]
if type(value) is not ScalarNode:
provenance = self.get_provenance()
path = ["[{}]".format(p) for p in provenance._toplevel._find(self)] + ["[{}]".format(index)]
raise LoadError("{}: Value of '{}' is not of the expected type '{}'"
.format(provenance, path, ScalarNode.__name__),
LoadErrorReason.INVALID_DATA)
return value
cpdef SequenceNode sequence_at(self, int index):
"""sequence_at(index)
Retrieve the entry at `index` as a :class:`.SequenceNode`.
Args:
index (int): index for which to get the value
Raises:
:class:`buildstream._exceptions.LoadError`: if the value at `key` is not a
:class:`.SequenceNode`
:class:`IndexError`: if no value exists at this index
Returns:
:class:`.SequenceNode`: the value at `index`
"""
value = self.value[index]
if type(value) is not SequenceNode:
provenance = self.get_provenance()
path = ["[{}]".format(p) for p in provenance.toplevel._find(self)] + ["[{}]".format(index)]
raise LoadError("{}: Value of '{}' is not of the expected type '{}'"
.format(provenance, path, SequenceNode.__name__),
LoadErrorReason.INVALID_DATA)
return value
#############################################################
# Public Methods implementations #
#############################################################
cpdef SequenceNode clone(self):
cdef list copy = []
cdef Node entry
for entry in self.value:
copy.append(entry.clone())
return SequenceNode.__new__(SequenceNode, self.file_index, self.line, self.column, copy)
cpdef object strip_node_info(self):
cdef Node value
return [value.strip_node_info() for value in self.value]
#############################################################
# Private Methods implementations #
#############################################################
cpdef void _assert_fully_composited(self) except *:
cdef Node value
for value in self.value:
value._assert_fully_composited()
#############################################################
# Protected Methods #
#############################################################
cdef void _compose_on(self, str key, MappingNode target, list path) except *:
# List clobbers anything list-like
cdef Node target_value = target.value.get(key)
if not (target_value is None or
type(target_value) is SequenceNode or
target_value._is_composite_list()):
raise __CompositeError(path,
"{}: List cannot overwrite {} at: {}"
.format(self.get_provenance(),
key,
target_value.get_provenance()))
# If the target is a list of conditional statements, then we are
# also conditional statements, and we need to append ourselves
# to that list instead of overwriting it in order to preserve the
# conditional for later evaluation.
if type(target_value) is SequenceNode and key == "(?)":
(<SequenceNode> target.value[key]).value.extend(self.value)
else:
# Looks good, clobber it
target.value[key] = self
cdef bint _is_composite_list(self) except *:
return False
cdef bint _walk_find(self, Node target, list path) except *:
cdef int i
cdef Node v
if self._shares_position_with(target):
return True
for i, v in enumerate(self.value):
path.append(i)
if v._walk_find(target, path):
return True
del path[-1]
return False
# Returned from Node.get_provenance
cdef class ProvenanceInformation:
"""Represents the location of a YAML node in a file.
This can effectively be used as a pretty print to display those information in
errors consistently.
You can retrieve this information for a :class:`Node` with
:func:`Node.get_provenance() <buildstream.node.Node.get_provenance()>`
"""
def __init__(self, Node nodeish):
cdef __FileInfo fileinfo
self._node = nodeish
if (nodeish is None) or (nodeish.file_index == _SYNTHETIC_FILE_INDEX):
self._filename = ""
self._shortname = ""
self._displayname = ""
self._line = 1
self._col = 0
self._toplevel = None
self._project = None
else:
fileinfo = <__FileInfo> __FILE_LIST[nodeish.file_index]
self._filename = fileinfo.filename
self._shortname = fileinfo.shortname
self._displayname = fileinfo.displayname
# We add 1 here to convert from computerish to humanish
self._line = nodeish.line + 1
self._col = nodeish.column
self._toplevel = fileinfo.toplevel
self._project = fileinfo.project
self._is_synthetic = (self._filename == '') or (self._col < 0)
# Convert a Provenance to a string for error reporting
def __str__(self):
if self._is_synthetic:
return "{} [synthetic node]".format(self._displayname)
else:
return "{} [line {:d} column {:d}]".format(self._displayname, self._line, self._col)
#############################################################
# BuildStream Private methods #
#############################################################
# Purely synthetic nodes will have _SYNTHETIC_FILE_INDEX for the file number, have line number
# zero, and a negative column number which comes from inverting the next value
# out of this counter. Synthetic nodes created with a reference node will
# have a file number from the reference node, some unknown line number, and
# a negative column number from this counter.
cdef int _SYNTHETIC_FILE_INDEX = -1
# _assert_symbol_name()
#
# A helper function to check if a loaded string is a valid symbol
# name and to raise a consistent LoadError if not. For strings which
# are required to be symbols.
#
# Args:
# symbol_name (str): The loaded symbol name
# purpose (str): The purpose of the string, for an error message
# ref_node (Node): The node of the loaded symbol, or None
# allow_dashes (bool): Whether dashes are allowed for this symbol
#
# Raises:
# LoadError: If the symbol_name is invalid
#
# Note that dashes are generally preferred for variable names and
# usage in YAML, but things such as option names which will be
# evaluated with jinja2 cannot use dashes.
def _assert_symbol_name(str symbol_name, str purpose, *, Node ref_node=None, bint allow_dashes=True):
cdef str valid_chars = string.digits + string.ascii_letters + '_'
if allow_dashes:
valid_chars += '-'
cdef bint valid = True
if not symbol_name:
valid = False
elif any(x not in valid_chars for x in symbol_name):
valid = False
elif symbol_name[0] in string.digits:
valid = False
if not valid:
detail = "Symbol names must contain only alphanumeric characters, " + \
"may not start with a digit, and may contain underscores"
if allow_dashes:
detail += " or dashes"
message = "Invalid symbol name for {}: '{}'".format(purpose, symbol_name)
if ref_node:
provenance = ref_node.get_provenance()
if provenance is not None:
message = "{}: {}".format(provenance, message)
raise LoadError(message, LoadErrorReason.INVALID_SYMBOL_NAME, detail=detail)
# _create_new_file(filename, shortname, displayname, toplevel, project)
#
# Create a new synthetic file and return it's index in the `._FILE_LIST`.
#
# Args:
# filename (str): the name to give to the file
# shortname (str): a shorter name used when showing information on the screen
# displayname (str): the name to give when reporting errors
# project (object): project with which to associate the current file (when dealing with junctions)
#
# Returns:
# (int): the index in the `._FILE_LIST` that identifies the new file
#
cdef Py_ssize_t _create_new_file(str filename, str shortname, str displayname, object project):
cdef Py_ssize_t file_number = len(__FILE_LIST)
__FILE_LIST.append(__FileInfo(filename, shortname, displayname, None, project))
return file_number
# _set_root_node_for_file(file_index, contents)
#
# Set the root node for the given file
#
# Args:
# file_index (int): the index in the `._FILE_LIST` for the file for which to set the root
# contents (.MappingNode): node that should be the root for the file
#
cdef void _set_root_node_for_file(Py_ssize_t file_index, MappingNode contents) except *:
cdef __FileInfo f_info
if file_index != _SYNTHETIC_FILE_INDEX:
f_info = <__FileInfo> __FILE_LIST[file_index]
f_info.toplevel = contents
# _new_synthetic_file()
#
# Create a new synthetic mapping node, with an associated file entry
# (in _FILE_LIST) such that later tracking can correctly determine which
# file needs writing to in order to persist the changes.
#
# Args:
# filename (str): The name of the synthetic file to create
# project (Project): The optional project to associate this synthetic file with
#
# Returns:
# (Node): An empty YAML mapping node, whose provenance is to this new
# synthetic file
#
def _new_synthetic_file(str filename, object project=None):
cdef Py_ssize_t file_index = len(__FILE_LIST)
cdef Node node = MappingNode.__new__(MappingNode, file_index, 0, 0, {})
__FILE_LIST.append(__FileInfo(filename,
filename,
"<synthetic {}>".format(filename),
node,
project))
return node
# _reset_global_state()
#
# This resets the global variables __FILE_LIST and __counter to their initial
# state. This is used by the test suite to improve isolation between tests
# running in the same process.
#
def _reset_global_state():
global __FILE_LIST, __counter
__FILE_LIST = []
__counter = 0
#############################################################
# Module local helper Methods #
#############################################################
# File name handling
cdef list __FILE_LIST = []
# synthetic counter for synthetic nodes
cdef int __counter = 0
class __CompositeError(Exception):
def __init__(self, path, message):
super().__init__(message)
self.path = path
self.message = message
# Metadata container for a yaml toplevel node.
#
# This class contains metadata around a yaml node in order to be able
# to trace back the provenance of a node to the file.
#
cdef class __FileInfo:
cdef str filename, shortname, displayname
cdef MappingNode toplevel,
cdef object project
def __init__(self, str filename, str shortname, str displayname, MappingNode toplevel, object project):
self.filename = filename
self.shortname = shortname
self.displayname = displayname
self.toplevel = toplevel
self.project = project
cdef int __next_synthetic_counter():
global __counter
__counter -= 1
return __counter
cdef Node __create_node_recursive(object value, Node ref_node):
cdef value_type = type(value)
if value_type is list:
node = __new_node_from_list(value, ref_node)
elif value_type in [int, str, bool]:
node = ScalarNode.__new__(ScalarNode, ref_node.file_index, ref_node.line, __next_synthetic_counter(), value)
elif value_type is dict:
node = __new_node_from_dict(value, ref_node)
else:
raise TypeError(
"Unable to assign a value of type {} to a Node.".format(value_type))
return node
# _new_node_from_dict()
#
# Args:
# indict (dict): The input dictionary
# ref_node (Node): The dictionary to take as reference for position
#
# Returns:
# (Node): A new synthetic YAML tree which represents this dictionary
#
cdef Node __new_node_from_dict(dict indict, Node ref_node):
cdef MappingNode ret = MappingNode.__new__(
MappingNode, ref_node.file_index, ref_node.line, __next_synthetic_counter(), {})
cdef str k
for k, v in indict.items():
ret.value[k] = __create_node_recursive(v, ref_node)
return ret
# Internal function to help new_node_from_dict() to handle lists
cdef Node __new_node_from_list(list inlist, Node ref_node):
cdef SequenceNode ret = SequenceNode.__new__(
SequenceNode, ref_node.file_index, ref_node.line, __next_synthetic_counter(), [])
for v in inlist:
ret.value.append(__create_node_recursive(v, ref_node))
return ret
# __validate_node_type(node, allowed_types, key)
#
# Validates that this node is of the expected node type,
# and raises a user facing LoadError if not.
#
# Args:
# allowed_types (list): list of valid subtypes of Node, or None
# key (str): A key, in case the validated node is a value for a key
#
# Raises:
# (LoadError): If this node is not of the expected type
#
cdef void __validate_node_type(Node node, list allowed_types = None, str key = None) except *:
cdef ProvenanceInformation provenance
cdef list human_types
cdef str message
if allowed_types and type(node) not in allowed_types:
provenance = node.get_provenance()
human_types = []
if MappingNode in allowed_types:
human_types.append("dict")
if SequenceNode in allowed_types:
human_types.append('list')
if ScalarNode in allowed_types:
human_types.append('scalar')
message = "{}: Value ".format(provenance)
if key:
message += "of '{}' ".format(key)
message += "is not one of the following: {}.".format(", ".join(human_types))
raise LoadError(message, LoadErrorReason.INVALID_DATA)