blob: 74314cf1f576ecd2a450ec511b34ea89cde4bc52 [file] [log] [blame]
#
# Copyright (C) 2016 Codethink Limited
# Copyright (C) 2019 Bloomberg L.P.
#
# 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>
import re
import sys
from ._exceptions import LoadError, LoadErrorReason
from . import _yaml
# Variables are allowed to have dashes here
#
PARSE_EXPANSION = re.compile(r"\%\{([a-zA-Z][a-zA-Z0-9_-]*)\}")
# Throughout this code you will see variables named things like `expstr`.
# These hold data structures called "expansion strings" and are the parsed
# form of the strings which are the input to this subsystem. Strings
# such as "Hello %{name}, how are you?" are parsed into the form:
# (3, ["Hello ", "name", ", how are you?"])
# i.e. a tuple of an integer and a list, where the integer is the cached
# length of the list, and the list consists of one or more strings.
# Strings in even indices of the list (0, 2, 4, etc) are constants which
# are copied into the output of the expansion algorithm. Strings in the
# odd indices (1, 3, 5, etc) are the names of further expansions to make.
# In the example above, first "Hello " is copied, then "name" is expanded
# and so must be another named expansion string passed in to the constructor
# of the Variables class, and whatever is yielded from the expansion of "name"
# is added to the concatenation for the result. Finally ", how are you?" is
# copied in and the whole lot concatenated for return.
#
# To see how strings are parsed, see `_parse_expstr()` after the class, and
# to see how expansion strings are expanded, see `_expand_expstr()` after that.
# The Variables helper object will resolve the variable references in
# the given dictionary, expecting that any dictionary values which contain
# variable references can be resolved from the same dictionary.
#
# Each Element creates its own Variables instance to track the configured
# variable settings for the element.
#
# Args:
# node (dict): A node loaded and composited with yaml tools
#
# Raises:
# LoadError, if unresolved variables, or cycles in resolution, occur.
#
class Variables():
def __init__(self, node):
self.original = node
self._expstr_map = self._resolve(node)
self.flat = self._flatten()
# subst():
#
# Substitutes any variables in 'string' and returns the result.
#
# Args:
# (string): The string to substitute
#
# Returns:
# (string): The new string with any substitutions made
#
# Raises:
# LoadError, if the string contains unresolved variable references.
#
def subst(self, string):
expstr = _parse_expstr(string)
try:
return _expand_expstr(self._expstr_map, expstr)
except KeyError:
unmatched = []
# Look for any unmatched variable names in the expansion string
for var in expstr[1][1::2]:
if var not in self._expstr_map:
unmatched.append(var)
if unmatched:
message = "Unresolved variable{}: {}".format(
"s" if len(unmatched) > 1 else "",
", ".join(unmatched)
)
raise LoadError(LoadErrorReason.UNRESOLVED_VARIABLE, message)
# Otherwise, re-raise the KeyError since it clearly came from some
# other unknowable cause.
raise
# Variable resolving code
#
# Here we resolve all of our inputs into a dictionary, ready for use
# in subst()
def _resolve(self, node):
# Special case, if notparallel is specified in the variables for this
# element, then override max-jobs to be 1.
# Initialize it as a string as all variables are processed as strings.
#
if _yaml.node_get(node, bool, 'notparallel', default_value=False):
_yaml.node_set(node, 'max-jobs', str(1))
ret = {}
for key, value in _yaml.node_items(node):
value = _yaml.node_get(node, str, key)
ret[sys.intern(key)] = _parse_expstr(value)
return ret
def _check_for_missing(self):
# First the check for anything unresolvable
summary = []
for key, expstr in self._expstr_map.items():
for var in expstr[1][1::2]:
if var not in self._expstr_map:
line = " unresolved variable '{unmatched}' in declaration of '{variable}' at: {provenance}"
provenance = _yaml.node_get_provenance(self.original, key)
summary.append(line.format(unmatched=var, variable=key, provenance=provenance))
if summary:
raise LoadError(LoadErrorReason.UNRESOLVED_VARIABLE,
"Failed to resolve one or more variable:\n{}\n".format("\n".join(summary)))
def _check_for_cycles(self):
# And now the cycle checks
def cycle_check(expstr, visited, cleared):
for var in expstr[1][1::2]:
if var in cleared:
continue
if var in visited:
raise LoadError(LoadErrorReason.RECURSIVE_VARIABLE,
"{}: ".format(_yaml.node_get_provenance(self.original, var)) +
("Variable '{}' expands to contain a reference to itself. " +
"Perhaps '{}' contains '%{{{}}}").format(var, visited[-1], var))
visited.append(var)
cycle_check(self._expstr_map[var], visited, cleared)
visited.pop()
cleared.add(var)
cleared = set()
for key, expstr in self._expstr_map.items():
if key not in cleared:
cycle_check(expstr, [key], cleared)
# _flatten():
#
# Turn our dictionary of expansion strings into a flattened dict
# so that we can run expansions faster in the future
#
# Raises:
# LoadError, if the string contains unresolved variable references or
# if cycles are detected in the variable references
#
def _flatten(self):
flat = {}
try:
for key, expstr in self._expstr_map.items():
if expstr[0] > 1:
expstr = (1, [sys.intern(_expand_expstr(self._expstr_map, expstr))])
self._expstr_map[key] = expstr
flat[key] = expstr[1][0]
except KeyError:
self._check_for_missing()
raise
except RecursionError:
self._check_for_cycles()
raise
return flat
# Cache for the parsed expansion strings. While this is nominally
# something which might "waste" memory, in reality each of these
# will live as long as the element which uses it, which is the
# vast majority of the memory usage across the execution of BuildStream.
PARSE_CACHE = {
# Prime the cache with the empty string since otherwise that can
# cause issues with the parser, complications to which cause slowdown
"": (1, [""]),
}
# Helper to parse a string into an expansion string tuple, caching
# the results so that future parse requests don't need to think about
# the string
def _parse_expstr(instr):
try:
return PARSE_CACHE[instr]
except KeyError:
# This use of the regex turns a string like "foo %{bar} baz" into
# a list ["foo ", "bar", " baz"]
splits = PARSE_EXPANSION.split(instr)
# If an expansion ends the string, we get an empty string on the end
# which we can optimise away, making the expansion routines not need
# a test for this.
if splits[-1] == '':
splits = splits[:-1]
# Cache an interned copy of this. We intern it to try and reduce the
# memory impact of the cache. It seems odd to cache the list length
# but this is measurably cheaper than calculating it each time during
# string expansion.
PARSE_CACHE[instr] = (len(splits), [sys.intern(s) for s in splits])
return PARSE_CACHE[instr]
# Helper to expand a given top level expansion string tuple in the context
# of the given dictionary of expansion strings.
#
# Note: Will raise KeyError if any expansion is missing
def _expand_expstr(content, topvalue):
# Short-circuit constant strings
if topvalue[0] == 1:
return topvalue[1][0]
# Short-circuit strings which are entirely an expansion of another variable
# e.g. "%{another}"
if topvalue[0] == 2 and topvalue[1][0] == "":
return _expand_expstr(content, content[topvalue[1][1]])
# Otherwise process fully...
def internal_expand(value):
(expansion_len, expansion_bits) = value
idx = 0
while idx < expansion_len:
# First yield any constant string content
yield expansion_bits[idx]
idx += 1
# Now, if there is an expansion variable left to expand, yield
# the expansion of that variable too
if idx < expansion_len:
yield from internal_expand(content[expansion_bits[idx]])
idx += 1
return "".join(internal_expand(topvalue))