blob: 94ee9078b03f3ff6037bcc2645b802aaa127d290 [file] [log] [blame]
#
# Copyright (C) 2018 Codethink Limited
#
# 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>
import os
from contextlib import suppress
from .._exceptions import LoadError
from ..exceptions import LoadErrorReason
from .. import _yaml
from ..element import Element
from ..node import Node
from .._profile import Topics, PROFILER
from .._includes import Includes
from ._loader import valid_chars_name
from .types import Symbol
from . import loadelement
from .loadelement import LoadElement, Dependency, extract_depends_from_node
from ..types import CoreWarnings, _KeyStrength
from .._message import Message, MessageType
# Loader():
#
# The Loader class does the heavy lifting of parsing target
# bst files and ultimately transforming them into a list of LoadElements
# ready for instantiation by the core.
#
# Args:
# project (Project): The toplevel Project object
# parent (Loader): A parent Loader object, in the case this is a junctioned Loader
# provenance (ProvenanceInformation): The provenance of the reference to this project's junction
#
class Loader:
def __init__(self, project, *, parent=None, provenance=None):
# Ensure we have an absolute path for the base directory
basedir = project.element_path
if not os.path.isabs(basedir):
basedir = os.path.abspath(basedir)
#
# Public members
#
self.load_context = project.load_context # The LoadContext
self.project = project # The associated Project
self.provenance = provenance # The provenance of whence this loader was instantiated
self.loaded = None # The number of loaded Elements
#
# Private members
#
self._options = project.options # Project options (OptionPool)
self._basedir = basedir # Base project directory
self._first_pass_options = project.first_pass_config.options # Project options (OptionPool)
self._parent = parent # The parent loader
self._alternative_parents = [] # Overridden parent loaders
self._meta_elements = {} # Dict of resolved meta elements by name
self._elements = {} # Dict of elements
self._loaders = {} # Dict of junction loaders
self._loader_search_provenances = {} # Dictionary of provenances of ongoing child loader searches
self._includes = Includes(self, copy_tree=True)
assert project.name is not None
self.load_context.register_loader(self)
# The __str__ of a Loader is used to clearly identify the Loader,
# the junction is was loaded as, and the provenance causing the
# junction to be loaded.
#
def __str__(self):
project_name = self.project.name
if self.project.junction:
junction_name = self.project.junction._get_full_name()
if self.provenance:
provenance = "({}): {}".format(junction_name, self.provenance)
else:
provenance = "({})".format(junction_name)
else:
provenance = "(toplevel)"
return "{} {}".format(project_name, provenance)
# load():
#
# Loads the project based on the parameters given to the constructor
#
# Args:
# targets (list of str): Target, element-path relative bst filenames in the project
#
# Raises: LoadError
#
# Returns:
# (list): The corresponding LoadElement instances matching the `targets`
#
def load(self, targets):
for filename in targets:
if os.path.isabs(filename):
# XXX Should this just be an assertion ?
# Expect that the caller gives us the right thing at least ?
raise LoadError(
"Target '{}' was not specified as a relative "
"path to the base project directory: {}".format(filename, self._basedir),
LoadErrorReason.INVALID_DATA,
)
self._warn_invalid_elements(targets)
# First pass, recursively load files and populate our table of LoadElements
#
target_elements = []
for target in targets:
with PROFILER.profile(Topics.LOAD_PROJECT, target):
_junction, name, loader = self._parse_name(target, None)
element = loader._load_file(name, None)
target_elements.append(element)
#
# Now that we've resolved the dependencies, scan them for circular dependencies
#
# Set up a dummy element that depends on all top-level targets
# to resolve potential circular dependencies between them
dummy_target = LoadElement(Node.from_dict({}), "", self)
# Pylint is not very happy with Cython and can't understand 'dependencies' is a list
dummy_target.dependencies.extend( # pylint: disable=no-member
Dependency(element, Symbol.RUNTIME) for element in target_elements
)
with PROFILER.profile(Topics.CIRCULAR_CHECK, "_".join(targets)):
self._check_circular_deps(dummy_target)
#
# Sort direct dependencies of elements by their dependency ordering
#
# Keep a list of all visited elements, to not sort twice the same
visited_elements = set()
for element in target_elements:
loader = element._loader
with PROFILER.profile(Topics.SORT_DEPENDENCIES, element.name):
loadelement.sort_dependencies(element, visited_elements)
self._clean_caches()
# Cache how many Elements have just been loaded
if self.load_context.task:
self.loaded = self.load_context.task.current_progress
return target_elements
# get_loader():
#
# Obtains the appropriate loader for the specified junction
#
# If `load_subprojects` is enabled, then this function will
# either return the desired loader or raise a LoadError. If
# `load_subprojects` is disabled, then it can also return None
# in the case that a loader could not be found. In either case,
# a non-existant file in a loaded project will result in a LoadError.
#
# Args:
# name (str): Name of junction, may have multiple `:` in the name
# provenance (ProvenanceInformation): The provenance
# load_subprojects (bool): Whether to load subprojects on demand
#
# Returns:
# (Loader): loader for sub-project
#
def get_loader(self, name, provenance, *, load_subprojects=True):
junction_path = name.split(":")
loader = self
circular_provenance = self._loader_search_provenances.get(name, None)
if circular_provenance:
assert provenance
detail = None
if circular_provenance is not provenance:
detail = "Already searching for '{}' at: {}".format(name, circular_provenance)
raise LoadError(
"{}: Circular reference while searching for '{}'".format(provenance, name),
LoadErrorReason.CIRCULAR_REFERENCE,
detail=detail,
)
self._loader_search_provenances[name] = provenance
for junction_name in junction_path:
loader = loader._get_loader(junction_name, provenance, load_subprojects=load_subprojects)
del self._loader_search_provenances[name]
return loader
# ancestors()
#
# This will traverse all active loaders in the ancestry for which this
# project is reachable using a relative path.
#
# Yields:
# (Loader): Each loader in the ancestry
#
def ancestors(self):
traversed = {}
def foreach_parent(parent):
while parent:
if parent in traversed:
return
traversed[parent] = True
yield parent
parent = parent._parent
# Yield from the direct/active ancestry
yield from foreach_parent(self._parent)
# Yield from alternative parents which have been replaced by
# overrides in the ancestry.
for parent in self._alternative_parents:
yield from foreach_parent(parent)
###########################################
# Private Methods #
###########################################
# _load_file_no_deps():
#
# Load a bst file as a LoadElement
#
# This loads a bst file into a LoadElement but does no work to resolve
# the element's dependencies. The dependencies must be resolved properly
# before the LoadElement makes its way out of the loader.
#
# Args:
# filename (str): The element-path relative bst file
# provenance (Provenance): The location from where the file was referred to, or None
#
# Returns:
# (LoadElement): A partially-loaded LoadElement
#
def _load_file_no_deps(self, filename, provenance=None):
# Load the data and process any conditional statements therein
fullpath = os.path.join(self._basedir, filename)
try:
node = _yaml.load(
fullpath, shortname=filename, copy_tree=self.load_context.rewritable, project=self.project
)
except LoadError as e:
if e.reason == LoadErrorReason.MISSING_FILE:
if self.project.junction:
message = "Could not find element '{}' in project referred to by junction element '{}'".format(
filename, self.project.junction.name
)
else:
message = "Could not find element '{}' in elements directory '{}'".format(filename, self._basedir)
if provenance:
message = "{}: {}".format(provenance, message)
# If we can't find the file, try to suggest plausible
# alternatives by stripping the element-path from the given
# filename, and verifying that it exists.
detail = None
elements_dir = os.path.relpath(self._basedir, self.project.directory)
element_relpath = os.path.relpath(filename, elements_dir)
if filename.startswith(elements_dir) and os.path.exists(os.path.join(self._basedir, element_relpath)):
detail = "Did you mean '{}'?".format(element_relpath)
raise LoadError(message, LoadErrorReason.MISSING_FILE, detail=detail) from e
if e.reason == LoadErrorReason.LOADING_DIRECTORY:
# If a <directory>.bst file exists in the element path,
# let's suggest this as a plausible alternative.
message = str(e)
if provenance:
message = "{}: {}".format(provenance, message)
detail = None
if os.path.exists(os.path.join(self._basedir, filename + ".bst")):
element_name = filename + ".bst"
detail = "Did you mean '{}'?\n".format(element_name)
raise LoadError(message, LoadErrorReason.LOADING_DIRECTORY, detail=detail) from e
# Otherwise, we don't know the reason, so just raise
raise
kind = node.get_str(Symbol.KIND)
if kind in ("junction", "link"):
self._first_pass_options.process_node(node)
else:
self.project.ensure_fully_loaded()
self._includes.process(node)
element = LoadElement(node, filename, self)
self._elements[filename] = element
return element
# _load_file():
#
# Semi-Iteratively load bst files
#
# The "Semi-" qualification is because where junctions get involved there
# is a measure of recursion, though this is limited only to the points at
# which junctions are crossed.
#
# Args:
# filename (str): The element-path relative bst file
# load_subprojects (bool): Whether to load subprojects
# provenance (Provenance): The location from where the file was referred to, or None
#
# Returns:
# (LoadElement): A loaded LoadElement
#
def _load_file(self, filename, provenance, *, load_subprojects=True):
# Silently ignore already loaded files
with suppress(KeyError):
return self._elements[filename]
top_element = self._load_file_no_deps(filename, provenance)
# If this element is a link then we need to resolve it
# and replace the dependency we've processed with this one
if top_element.link_target is not None:
_, filename, loader = self._parse_name(
top_element.link_target, top_element.link_target_provenance, load_subprojects=load_subprojects
)
top_element = loader._load_file(
filename, top_element.link_target_provenance, load_subprojects=load_subprojects
)
dependencies = extract_depends_from_node(top_element.node)
# The loader queue is a stack of tuples
# [0] is the LoadElement instance
# [1] is a stack of Dependency objects to load
# [2] is a list of dependency names used to warn when all deps are loaded
loader_queue = [(top_element, list(reversed(dependencies)), [])]
# Load all dependency files for the new LoadElement
while loader_queue:
if loader_queue[-1][1]:
current_element = loader_queue[-1]
# Process the first dependency of the last loaded element
dep = current_element[1].pop()
# And record its name for checking later
current_element[2].append(dep.name)
if dep.junction:
loader = self.get_loader(dep.junction, dep.provenance)
dep_element = loader._load_file(dep.name, dep.provenance)
else:
dep_element = self._elements.get(dep.name)
if dep_element is None:
# The loader does not have this available so we need to
# either recursively cause it to be loaded, or else we
# need to push this onto the loader queue in this loader
dep_element = self._load_file_no_deps(dep.name, dep.provenance)
dep_deps = extract_depends_from_node(dep_element.node)
loader_queue.append((dep_element, list(reversed(dep_deps)), []))
# Pylint is not very happy about Cython and can't understand 'node' is a 'MappingNode'
if dep_element.node.get_str(Symbol.KIND) == "junction": # pylint: disable=no-member
raise LoadError(
"{}: Cannot depend on junction".format(dep.provenance), LoadErrorReason.INVALID_DATA
)
# If this dependency is a link then we need to resolve it
# and replace the dependency we've processed with this one
if dep_element.link_target:
_, filename, loader = self._parse_name(dep_element.link_target, dep_element.link_target_provenance)
dep_element = loader._load_file(filename, dep_element.link_target_provenance)
# We've now resolved the element for this dependency, lets set the resolved
# LoadElement on the dependency and append the dependency to the owning
# LoadElement dependency list.
dep.set_element(dep_element)
current_element[0].dependencies.append(dep)
else:
# We do not have any more dependencies to load for this
# element on the queue, report any invalid dep names
self._warn_invalid_elements(loader_queue[-1][2])
# And pop the element off the queue
loader_queue.pop()
# Nothing more in the queue, return the top level element we loaded.
return top_element
# _check_circular_deps():
#
# Detect circular dependencies on LoadElements with
# dependencies already resolved.
#
# Args:
# element (str): The element to check
#
# Raises:
# (LoadError): In case there was a circular dependency error
#
@staticmethod
def _check_circular_deps(top_element):
sequence = [top_element]
sequence_indices = [0]
check_elements = set(sequence)
validated = set()
while sequence:
this_element = sequence[-1]
index = sequence_indices[-1]
if index < len(this_element.dependencies):
element = this_element.dependencies[index].element
sequence_indices[-1] = index + 1
if element in check_elements:
# Create `chain`, the loop of element dependencies from this
# element back to itself, by trimming everything before this
# element from the sequence under consideration.
chain = [element.full_name for element in sequence[sequence.index(element) :]]
chain.append(element.full_name)
raise LoadError(
("Circular dependency detected at element: {}\n" + "Dependency chain: {}").format(
element.full_name, " -> ".join(chain)
),
LoadErrorReason.CIRCULAR_DEPENDENCY,
)
if element not in validated:
# We've not already validated this element, so let's
# descend into it to check it out
sequence.append(element)
sequence_indices.append(0)
check_elements.add(element)
# Otherwise we'll head back around the loop to validate the
# next dependency in this entry
else:
# Done with entry, pop it off, indicate we're no longer
# in its chain, and mark it valid
sequence.pop()
sequence_indices.pop()
check_elements.remove(this_element)
validated.add(this_element)
# _search_for_override():
#
# Search parent projects for an overridden subproject to replace this junction.
#
# This function is called once for each direct child while looking up
# child loaders, after which point the child loader is cached in the `_loaders`
# table. This function also has the side effect of recording alternative parents
# of a child loader in the case that the child loader is overridden.
#
# Args:
# filename (str): Junction name
#
def _search_for_override(self, filename):
loader = self
override_path = filename
# Collect any overrides to this junction in the ancestry
#
overriding_loaders = []
while loader._parent:
junction = loader.project.junction
override_filename, override_provenance = junction.overrides.get(override_path, (None, None))
if override_filename:
overriding_loaders.append((loader._parent, override_filename, override_provenance))
override_path = junction.name + ":" + override_path
loader = loader._parent
# If there are any overriding loaders, use the highest one in
# the ancestry to lookup the loader for this project.
#
if overriding_loaders:
overriding_loader, override_filename, provenance = overriding_loaders[-1]
loader = overriding_loader.get_loader(override_filename, provenance)
#
# Record alternative loaders which were overridden.
#
# When a junction is overridden by another higher priority junction,
# the resulting loader is still reachable with the original element paths,
# which will now traverse override redirections.
#
# In order to iterate over every project/loader in the ancestry which can
# reach the actually selected loader, we need to keep track of the parent
# loaders of all overridden junctions.
#
if loader is not self:
loader._alternative_parents.append(self)
del overriding_loaders[-1]
loader._alternative_parents.extend(l for l, _, _ in overriding_loaders)
return loader
# No overrides were found in the ancestry
#
return None
# _get_loader():
#
# Return loader for specified junction
#
# Args:
# filename (str): Junction name
# load_subprojects (bool): Whether to load subprojects
# provenance (Provenance): The location from where the file was referred to, or None
#
# Raises: LoadError
#
# Returns: A Loader or None if specified junction does not exist
#
def _get_loader(self, filename, provenance, *, load_subprojects=True):
loader = None
provenance_str = ""
if provenance is not None:
provenance_str = "{}: ".format(provenance)
# return previously determined result
if filename in self._loaders:
return self._loaders[filename]
#
# Search the ancestry for an overridden loader to use in place
# of using the locally defined junction.
#
override_loader = self._search_for_override(filename)
if override_loader:
self._loaders[filename] = override_loader
return override_loader
#
# Load the junction file
#
self._load_file(filename, provenance, load_subprojects=load_subprojects)
# At this point we've loaded the LoadElement
load_element = self._elements[filename]
# If the loaded element is a link, then just follow it
# immediately and move on to the target.
#
if load_element.link_target:
_, filename, loader = self._parse_name(
load_element.link_target, load_element.link_target_provenance, load_subprojects=load_subprojects
)
return loader.get_loader(filename, load_element.link_target_provenance, load_subprojects=load_subprojects)
# If we're only performing a lookup, we're done here.
#
if not load_subprojects:
return None
if load_element.kind != "junction":
raise LoadError(
"{}{}: Expected junction but element kind is {}".format(provenance_str, filename, load_element.kind),
LoadErrorReason.INVALID_DATA,
)
# We check that junctions have no dependencies a little
# early. This is cheating, since we don't technically know
# that junctions aren't allowed to have dependencies.
#
# However, this makes progress reporting more intuitive
# because we don't need to load dependencies of an element
# that shouldn't have any, and therefore don't need to
# duplicate the load count for elements that shouldn't be.
#
# We also fail slightly earlier (since we don't need to go
# through the entire loading process), which is nice UX. It
# would be nice if this could be done for *all* element types,
# but since we haven't loaded those yet that's impossible.
if load_element.dependencies:
# Use the first dependency in the list as provenance
p = load_element.dependencies[0].provenance
raise LoadError(
"{}: Dependencies are forbidden for 'junction' elements".format(p), LoadErrorReason.INVALID_JUNCTION
)
element = Element._new_from_load_element(load_element)
element._initialize_state()
# Handle the case where a subproject has no ref
#
if not element._has_all_sources_resolved():
detail = "Try tracking the junction element with `bst source track {}`".format(filename)
raise LoadError(
"{}Subproject has no ref for junction: {}".format(provenance_str, filename),
LoadErrorReason.SUBPROJECT_INCONSISTENT,
detail=detail,
)
# Handle the case where a subproject needs to be fetched
#
if element._should_fetch():
self.load_context.fetch_subprojects([element])
sources = list(element.sources())
if len(sources) == 1 and sources[0]._get_local_path():
# Optimization for junctions with a single local source
basedir = sources[0]._get_local_path()
else:
# Stage sources
element._set_required()
# Note: We use _KeyStrength.WEAK here because junctions
# cannot have dependencies, therefore the keys are
# equivalent.
#
# Since the element has not necessarily been given a
# strong cache key at this point (in a non-strict build
# that is set *after* we complete building/pulling, which
# we haven't yet for this element),
# element._get_cache_key() can fail if used with the
# default _KeyStrength.STRONG.
basedir = os.path.join(
self.project.directory, ".bst", "staged-junctions", filename, element._get_cache_key(_KeyStrength.WEAK)
)
if not os.path.exists(basedir):
os.makedirs(basedir, exist_ok=True)
element._stage_sources_at(basedir)
# Load the project
project_dir = os.path.join(basedir, element.path)
try:
from .._project import Project # pylint: disable=cyclic-import
project = Project(
project_dir,
self.load_context.context,
junction=element,
parent_loader=self,
search_for_project=False,
provenance=provenance,
)
except LoadError as e:
if e.reason == LoadErrorReason.MISSING_PROJECT_CONF:
message = (
provenance_str + "Could not find the project.conf file in the project "
"referred to by junction element '{}'.".format(element.name)
)
if element.path:
message += " Was expecting it at path '{}' in the junction's source.".format(element.path)
raise LoadError(message=message, reason=LoadErrorReason.INVALID_JUNCTION) from e
# Otherwise, we don't know the reason, so just raise
raise
loader = project.loader
self._loaders[filename] = loader
return loader
# _parse_name():
#
# Get junction and base name of element along with loader for the sub-project
#
# Args:
# name (str): Name of target
# provenance (ProvenanceInformation): The provenance
# load_subprojects (bool): Whether to load subprojects
#
# Returns:
# (tuple): - (str): name of the junction element
# - (str): name of the element
# - (Loader): loader for sub-project
#
def _parse_name(self, name, provenance, *, load_subprojects=True):
# We allow to split only once since deep junctions names are forbidden.
# Users who want to refer to elements in sub-sub-projects are required
# to create junctions on the top level project.
junction_path = name.rsplit(":", 1)
if len(junction_path) == 1:
return None, junction_path[-1], self
else:
loader = self.get_loader(junction_path[-2], provenance, load_subprojects=load_subprojects)
return junction_path[-2], junction_path[-1], loader
# Print a warning message, checks warning_token against project configuration
#
# Args:
# brief (str): The brief message
# warning_token (str): An optional configurable warning assosciated with this warning,
# this will cause PluginError to be raised if this warning is configured as fatal.
#
# Raises:
# (:class:`.LoadError`): When warning_token is considered fatal by the project configuration
#
def _warn(self, brief, *, warning_token=None):
if warning_token:
if self.project._warning_is_fatal(warning_token):
raise LoadError(brief, warning_token)
message = Message(MessageType.WARN, brief)
self.load_context.context.messenger.message(message)
# Print warning messages if any of the specified elements have invalid names.
#
# Valid filenames should end with ".bst" extension.
#
# Args:
# elements (list): List of element names
#
# Raises:
# (:class:`.LoadError`): When warning_token is considered fatal by the project configuration
#
def _warn_invalid_elements(self, elements):
# invalid_elements
#
# A dict that maps warning types to the matching elements.
invalid_elements = {
CoreWarnings.BAD_ELEMENT_SUFFIX: [],
CoreWarnings.BAD_CHARACTERS_IN_NAME: [],
}
for filename in elements:
if not filename.endswith(".bst"):
invalid_elements[CoreWarnings.BAD_ELEMENT_SUFFIX].append(filename)
if not valid_chars_name(filename):
invalid_elements[CoreWarnings.BAD_CHARACTERS_IN_NAME].append(filename)
if invalid_elements[CoreWarnings.BAD_ELEMENT_SUFFIX]:
self._warn(
"Target elements '{}' do not have expected file extension `.bst` "
"Improperly named elements will not be discoverable by commands".format(
invalid_elements[CoreWarnings.BAD_ELEMENT_SUFFIX]
),
warning_token=CoreWarnings.BAD_ELEMENT_SUFFIX,
)
if invalid_elements[CoreWarnings.BAD_CHARACTERS_IN_NAME]:
self._warn(
"Target elements '{}' have invalid characerts in their name.".format(
invalid_elements[CoreWarnings.BAD_CHARACTERS_IN_NAME]
),
warning_token=CoreWarnings.BAD_CHARACTERS_IN_NAME,
)
# _clean_caches()
#
# Clean internal loader caches, recursively
#
# When loading the elements, the loaders use caches in order to not load the
# same element twice. These are kept after loading and prevent garbage
# collection. Cleaning them explicitely is required.
#
def _clean_caches(self):
for loader in self._loaders.values():
# value may be None with nested junctions without overrides
if loader is not None:
loader._clean_caches()
self._meta_elements = {}
self._elements = {}