#!/usr/bin/env python3 # # Copyright (C) 2016 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 . # # Authors: # Tristan Van Berkom import os import copy from functools import cmp_to_key from collections import Mapping from . import LoadError, LoadErrorReason from . import _yaml from ._yaml import CompositePolicy, CompositeTypeError, CompositeOverrideError from ._metaelement import MetaElement from ._metasource import MetaSource from ._profile import Topics, profile_start, profile_end ################################################# # Local Types # ################################################# # # List of symbols we recognize # class Symbol(): FILENAME = "filename" KIND = "kind" DEPENDS = "depends" VARIANT = "variant" VARIANTS = "variants" PROJECT_VARIANT = "project-variant" ARCHES = "arches" HOST_ARCHES = "host-arches" SOURCES = "sources" CONFIG = "config" VARIABLES = "variables" ENVIRONMENT = "environment" ENV_NOCACHE = "environment-nocache" PUBLIC = "public" TYPE = "type" BUILD = "build" RUNTIME = "runtime" ALL = "all" DIRECTORY = "directory" # A simple dependency object # class Dependency(): def __init__(self, owner_name, name, variant_name=None, filename=None, dep_type=None, provenance=None): self.owner = owner_name self.name = name self.variant_name = variant_name self.filename = filename self.dep_type = dep_type self.provenance = provenance # Holds a variant dictionary and normalized Dependency list # for later compositing, after resolving which variants to choose # class Variant(): def __init__(self, owner, data, project_variants): self.data = data self.name = _yaml.node_get(self.data, str, Symbol.VARIANT) self.dependencies = extract_depends_from_node(owner, self.data) # Assert valid project variants assert_project_variant(self.data, project_variants) del self.data[Symbol.VARIANT] # VariantDisagreement is raised to indicate that 2 elements # depend on a given element in a way that conflicts # class VariantDisagreement(Exception): def __init__(self, element_config1, element_config2, variant=None): path1 = element_config1.make_path() if variant: path2 = "Expected variant {}".format(variant) else: path2 = element_config2.make_path() super(VariantDisagreement, self).__init__( "Variant disagreement occurred.\n {}\n {}" .format(path1, path2)) # VariantInvalid is raised to indicate that a nonexisting # variant name on an element was requested # class VariantInvalid(Exception): def __init__(self, dependency, element, variant_name): message = "Variant '{}' is invalid for element {}" \ .format(variant_name, element.name) if dependency: message = "{}: {}".format(dependency.provenance, message) super(VariantInvalid, self).__init__(message) # A utility object wrapping the LoadElement, this represents # a hypothetical configuration of an element, it describes: # # o The dependency pulling in the element # o The chosen variant # o The dependencies the element has when configured for the given variant # class LoadElementConfig(): def __init__(self, parent_config, element, variant_name=None): self.parent_config = parent_config self.element = element self.filename = element.filename self.variant_name = variant_name self.deps = element.deps_for_variant(variant_name) def __str__(self): if self.variant_name: return "{} ({})".format(self.filename, self.variant_name) return self.filename def make_path(self): path = str(self) if self.parent_config: path = self.parent_config.make_path() + ' -> ' + path return path # resolve_arch() # # Composites the data node with the active arch dict and discards # the arches dict from the data node afterwards, this is shared # with project.py # def resolve_arch(data, host_arch, target_arch=None): def resolve_single_arch_conditional(symbol, active_arch): arches = _yaml.node_get(data, Mapping, symbol, default_value={}) arch = {} if arches: arch = _yaml.node_get(arches, Mapping, active_arch, default_value={}) if arch: try: _yaml.composite_dict(data, arch, policy=CompositePolicy.ARRAY_APPEND, typesafe=True) except CompositeTypeError as e: provenance = _yaml.node_get_provenance(arch, key=active_arch) raise LoadError(LoadErrorReason.ILLEGAL_COMPOSITE, "%s: Arch %s specifies type '%s' for path '%s', expected '%s'" % (str(provenance), active_arch, e.actual_type.__name__, e.path, e.expected_type.__name__)) from e del data[symbol] resolve_single_arch_conditional(Symbol.HOST_ARCHES, active_arch=host_arch) resolve_single_arch_conditional(Symbol.ARCHES, active_arch=target_arch or host_arch) def assert_project_variant(node, project_variants): variant = _yaml.node_get(node, str, Symbol.PROJECT_VARIANT, default_value="") or None if variant and variant not in project_variants: provenance = _yaml.node_get_provenance(node, key=Symbol.PROJECT_VARIANT) raise LoadError(LoadErrorReason.INVALID_DATA, "{}: Specified invalid project variant '{}'".format(provenance, variant)) # A transient object breaking down what is loaded # allowing us to do complex operations in multiple # passes # class LoadElement(): def __init__(self, data, filename, basedir, host_arch, target_arch, elements, project_variants): self.filename = filename self.data = data self.host_arch = host_arch self.target_arch = target_arch self.name = filename self.elements = elements # These are shared with the owning Loader object self.basedir = basedir # Process arch conditionals resolve_arch(self.data, self.host_arch, self.target_arch) # Assert valid project variant, if any assert_project_variant(self.data, project_variants) # Dependency objects after resolving variants self.variant_name = None self.deps = [] self.dep_cache = None # Base dependencies self.base_deps = extract_depends_from_node(self.name, self.data) # Load the Variants self.variants = [] variants_node = _yaml.node_get(self.data, list, Symbol.VARIANTS, default_value=[]) for variant_node in variants_node: index = variants_node.index(variant_node) variant_node = _yaml.node_get(self.data, Mapping, Symbol.VARIANTS, indices=[index]) variant = Variant(self.name, variant_node, project_variants) # Process arch conditionals on individual variants resolve_arch(variant.data, self.host_arch, self.target_arch) self.variants.append(variant) if len(self.variants) == 1: provenance = _yaml.node_get_provenance(self.data, key=Symbol.VARIANTS) raise LoadError(LoadErrorReason.INVALID_DATA, "%s: Only one variant declared, an element " "declaring variants must declare at least two variants" % str(provenance)) # Strip em from the data now del self.data[Symbol.VARIANTS] ############################################# # Routines used by the Loader # ############################################# # Checks if this element depends on another element, directly # or indirectly. This does NOT follow variants and is only # useful after variants are resolved. # def depends(self, other): self.ensure_depends_cache() return self.dep_cache.get(other.name) is not None def ensure_depends_cache(self): if self.dep_cache: return self.dep_cache = {} for dep in self.deps: elt = self.elements[dep.name] # Ensure the cache of the element we depend on elt.ensure_depends_cache() # We depend on this element self.dep_cache[dep.name] = True # And we depend on everything this element depends on self.dep_cache.update(elt.dep_cache) # Fetch a Variant by name # def lookup_variant(self, variant_name): for variant in self.variants: if variant.name == variant_name: return variant # deps_for_variant() # # Fetches the set of Dependency objects for a given variant name # def deps_for_variant(self, variant_name): deps = copy.copy(self.base_deps) variant = None if variant_name: variant = self.lookup_variant(variant_name) # If the Dependency is already mentioned in the base dependencies # a variant may modify it by overriding the dependency variant if variant: for variant_dep in variant.dependencies: override = False for dep in deps: if dep.filename == variant_dep.filename: index = deps.index(dep) deps[index] = variant_dep override = True break # Dependency not already declared, append new one if not override: deps.append(variant_dep) # Return the list of dependencies for this variant return deps # Apply the chosen variant into the element data # def apply_element_config(self, element_config): # Save the final decision on Dependencies self.element_config = element_config self.variant_name = element_config.variant_name self.deps = element_config.deps variant = None if self.variant_name: variant = self.lookup_variant(self.variant_name) if variant: provenance = _yaml.node_get_provenance(variant.data) # Composite anything from the variant data into the element data # # Possibly this should not be typesafe, since branch names can # possibly be strings or interpreted by YAML as integers (for # numeric branch names) # try: _yaml.composite_dict(self.data, variant.data, policy=CompositePolicy.ARRAY_APPEND, typesafe=True) except CompositeTypeError as e: raise LoadError( LoadErrorReason.ILLEGAL_COMPOSITE, "%s: Variant '%s' specifies type '%s' for path '%s', expected '%s'" % (str(provenance), element_config.variant_name, e.actual_type.__name__, e.path, e.expected_type.__name__)) from e # Creates an array of dependency dicts from a given dict node 'data', # allows both strings and dicts for expressing the dependency and # throws a comprehensive LoadError in the case that the data is malformed. # # After extracting depends, they are removed from the data node # # Returns a normalized array of Dependency objects def extract_depends_from_node(owner, data): depends = _yaml.node_get(data, list, Symbol.DEPENDS, default_value=[]) output_deps = [] for dep in depends: dep_provenance = _yaml.node_get_provenance(data, key=Symbol.DEPENDS, indices=[depends.index(dep)]) if isinstance(dep, str): dependency = Dependency(owner, dep, filename=dep, provenance=dep_provenance) elif isinstance(dep, Mapping): # Make variant optional, for this we set it to None after variant = _yaml.node_get(dep, str, Symbol.VARIANT, default_value="") if not variant: variant = None # Make type optional, for this we set it to None after dep_type = _yaml.node_get(dep, str, Symbol.TYPE, default_value="") if not dep_type or dep_type == Symbol.ALL: dep_type = None elif dep_type not in [Symbol.BUILD, Symbol.RUNTIME]: provenance = _yaml.node_get_provenance(dep, key=Symbol.TYPE) raise LoadError(LoadErrorReason.INVALID_DATA, "%s: Dependency type '%s' is not 'build', 'runtime' or 'all'" % (str(provenance), dep_type)) filename = _yaml.node_get(dep, str, Symbol.FILENAME) dependency = Dependency(owner, filename, variant_name=variant, filename=filename, dep_type=dep_type, provenance=dep_provenance) else: index = depends.index(dep) provenance = _yaml.node_get_provenance(data, key=Symbol.DEPENDS, indices=[index]) raise LoadError(LoadErrorReason.INVALID_DATA, "%s: List '%s' element %d is not a list or dict" % (str(provenance), Symbol.DEPENDS, index)) output_deps.append(dependency) # Now delete "depends", we dont want it anymore del data[Symbol.DEPENDS] return output_deps ################################################# # The Loader # ################################################# # # The Loader class does the heavy lifting of parsing a target # bst file and creating a tree of LoadElements # class Loader(): def __init__(self, basedir, filename, variant, host_arch, target_arch, project_variants): # Ensure we have an absolute path for the base directory # if not os.path.isabs(basedir): basedir = os.path.abspath(basedir) 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(LoadErrorReason.INVALID_DATA, "Target '%s' was not specified as a relative " "path to the base project directory: %s" % (filename, basedir)) # Base project directory self.basedir = basedir # Target bst filename self.target_filename = filename self.target = filename # Optional variant self.target_variant = variant # Project Variant self.project_variants = project_variants self.project_variant = None self.project_variant_requester = None self.host_arch = host_arch self.target_arch = target_arch self.meta_elements = {} # Dict of resolved meta elements by name self.elements = {} # Dict of elements ######################################## # Main Entry Point # ######################################## # load(): # # Loads the project based on the parameters given to the constructor # # Args: # rewritable (bool): Whether the loaded files should be rewritable # this is a bit more expensive due to deep copies # ticker (callable): An optional function for tracking load progress # # Raises: LoadError # # Returns: The toplevel LoadElement def load(self, rewritable=False, ticker=None): # First pass, recursively load files and populate our table of LoadElements # profile_start(Topics.LOAD_PROJECT, self.target_filename) try: target = self.load_file(self.target_filename, rewritable, ticker) if self.target_variant and target.lookup_variant(self.target_variant) is None: raise VariantInvalid(None, target, self.target_variant) except VariantInvalid as e: raise LoadError(LoadErrorReason.INVALID_VARIANT, str(e)) from e profile_end(Topics.LOAD_PROJECT, self.target_filename) # # Deal with variants # profile_start(Topics.VARIANTS, self.target_filename) self.resolve_variants() profile_end(Topics.VARIANTS, self.target_filename) # # Now that we've resolve the dependencies, scan them for circular dependencies # profile_start(Topics.CIRCULAR_CHECK, self.target_filename) self.check_circular_deps(self.target) profile_end(Topics.CIRCULAR_CHECK, self.target_filename) # # Resolve the project variant, if any # self.resolve_project_variant(self.target) # # Sort direct dependencies of elements by their dependency ordering # profile_start(Topics.SORT_DEPENDENCIES, self.target_filename) self.sort_dependencies(self.target) profile_end(Topics.SORT_DEPENDENCIES, self.target_filename) # Finally, wrap what we have into LoadElements and return the target # return self.collect_element(self.target) ######################################## # Loading Files # ######################################## # Recursively load bst files # def load_file(self, filename, rewritable, ticker): # Silently ignore already loaded files if filename in self.elements: return self.elements[filename] # Call the ticker if ticker: ticker(filename) fullpath = os.path.join(self.basedir, filename) # Load the element and track it in our elements table data = _yaml.load(fullpath, shortname=filename, copy_tree=rewritable) element = LoadElement(data, filename, self.basedir, self.host_arch, self.target_arch, self.elements, self.project_variants) self.elements[filename] = element # Load all possible dependency files for the new LoadElement for dep in element.base_deps: self.load_dependency_file(dep, rewritable, ticker) for variant in element.variants: for dep in variant.dependencies: self.load_dependency_file(dep, rewritable, ticker) return element def load_dependency_file(self, dependency, rewritable, ticker): element = self.load_file(dependency.filename, rewritable, ticker) # Check for invalid variant dependencies if dependency.variant_name and element.lookup_variant(dependency.variant_name) is None: raise VariantInvalid(dependency, element, dependency.variant_name) ######################################## # Resolving Variants # ######################################## # # The first rule of variants is that for any given element provided by # itself as a pipeline target, all variants of that element must be # buildable and not present any variant conflict. # # However, any variant of a given element that is not the target may # end up being built differently - this is because siblings in the pipeline # may prefer a variant of a dependency for which the given element's # dependency was ambivalent. # # Considering that variants can effect what and how an element depends # on other elements; resolving the variants is a trial and error activity, # even if this is not true for the target element (as stated as the first # rule), it is true of every other element in a given pipeline. # # As such, resolving the variants is a recursive process of trial and error # # 1.) Construct a "variant tree" # # The variant tree is a tree of elements dicts, these refer to the # element filename and contain an array of variants; each member of # the variant array holds an array of the dependencies which would be # chosen if the given variant of the given element were chosen. # # 2.) Starting at the top level, try to resolve the # # For each element; collect an array of its variants; each member of # the variant array speaks for the dependencies of the given element # def resolve_variants(self): target_variant = self.target_variant target_element = self.elements[self.target] # If a target was not specified, this is an explicit request for the # first variant if not target_variant and target_element.variants: target_variant = target_element.variants[0].name # Recurse until the cows come home ! # toplevel_config = LoadElementConfig(None, target_element, target_variant) try: pool, _ = self.try_element_configuration(toplevel_config, {}, {}) except VariantDisagreement as e: raise LoadError(LoadErrorReason.VARIANT_DISAGREEMENT, str(e)) from e # Now apply the chosen variant configurations # for _, element_config in pool.items(): element_config.element.apply_element_config(element_config) # # try_element_configuration() # # Args: # element_config (LoadElementConfig): the element to try # pool (dict): A dict of LoadElementConfig objects # restruction_pool: A dict of variant names explicitly required # # Returns: # A new configuration # # With a given configuration in context, reports whether the configuration # is a valid one for the given element and all of the possible elements on # which this element depends, returning a new configuration comprised of # the given configuration and the first valid configuration of its # dependencies # def try_element_configuration(self, element_config, pool, restriction_pool): if element_config.filename in pool: config = pool[element_config.filename] # The configuration pool can have only one selected configuration # for each element, handle intersections and conflicts. # if config.element is element_config.element: if config.variant_name != element_config.variant_name: # Two different variants of the same element should be reached # on a path of variant agreement. raise VariantDisagreement(element_config, config) else: # A path converges on the same element configuration, # no need to recurse as we already have a result for this. return (pool, restriction_pool) if element_config.filename in restriction_pool: restrict_variant = restriction_pool[element_config.filename] if element_config.variant_name and element_config.variant_name != restrict_variant: raise VariantDisagreement(element_config, None, restrict_variant) # Now add ourselves to the pool and recurse into the dependency list new_pool = dict(pool) new_pool[element_config.filename] = element_config # Add restriction pool to go along with this pool # new_restriction_pool = dict(restriction_pool) for dep in element_config.deps: if dep.variant_name: new_restriction_pool[dep.name] = dep.variant_name return self.configure_dependency_variants(element_config, element_config.deps, new_pool, new_restriction_pool) def configure_dependency_variants(self, parent_config, deps, pool, restriction_pool): # This is just the end of the list if not deps: return (pool, restriction_pool) # Loop over the possible variants for this dependency dependency = deps[0] element = self.elements[dependency.name] # First create one list of element configurations to try, one for # each possible variant under this element configuration # element_configs_to_try = [] if dependency.variant_name: config = LoadElementConfig(parent_config, element, dependency.variant_name) element_configs_to_try.append(config) elif len(element.variants) == 0: config = LoadElementConfig(parent_config, element, None) element_configs_to_try.append(config) else: for variant in element.variants: config = LoadElementConfig(parent_config, element, variant.name) element_configs_to_try.append(config) # Loop over every possible element configuration for this dependency # result_pool = None result_restrict = None last_error = None for element_config in element_configs_to_try: # Reset the attempted new pool for each try result_pool = None result_restrict = None try: # If this configuration of the this element succeeds... try_pool, try_restrict = self.try_element_configuration(element_config, pool, restriction_pool) # ... Then recurse into sibling elements result_pool, result_restrict = self.configure_dependency_variants(parent_config, deps[1:], try_pool, try_restrict) except VariantDisagreement as e: last_error = e continue # If we've reached here without any disagreement, then we've found the # first valid configuration, which should take priority over any others. break # If unable to find any valid configuration, raise a VariantDisagreement if not result_pool: raise last_error return (result_pool, result_restrict) ######################################## # Checking Circular Dependencies # ######################################## # # Detect circular dependencies on LoadElements with # dependencies already resolved. # def check_circular_deps(self, element_name, check_elements=None, validated=None): if check_elements is None: check_elements = {} if validated is None: validated = {} element = self.elements[element_name] # Skip already validated branches if validated.get(element_name) is not None: return if check_elements.get(element_name) is not None: raise LoadError(LoadErrorReason.CIRCULAR_DEPENDENCY, "Circular dependency detected for element: %s" % element.filename) # Push / Check each dependency / Pop check_elements[element_name] = True for dep in element.deps: self.check_circular_deps(dep.name, check_elements, validated) del check_elements[element_name] # Eliminate duplicate paths validated[element_name] = True ######################################## # Resolve Project Variant # ######################################## def resolve_project_variant(self, element_name, resolved=None): if resolved is None: resolved = {} if resolved.get(element_name) is not None: return element = self.elements[element_name] project_variant = _yaml.node_get(element.data, str, Symbol.PROJECT_VARIANT, default_value="") or None if project_variant: if not self.project_variant: self.project_variant = project_variant self.project_variant_requester = element_name elif project_variant != self.project_variant: request1 = "{} requested project variant '{}'" \ .format(self.project_variant_requester, self.project_variant) request2 = "{} requested project variant '{}'" \ .format(element_name, project_variant) raise LoadError(LoadErrorReason.VARIANT_DISAGREEMENT, "Project variant disagreement occurred.\n {}\n {}" .format(request1, request2)) for dep in element.deps: self.resolve_project_variant(dep.name, resolved) resolved[element_name] = True ######################################## # Element Sorting # ######################################## # # Sort dependencies of each element by their dependencies, # so that direct dependencies which depend on other direct # dependencies (directly or indirectly) appear later in the # list. # # This avoids the need for performing multiple topological # sorts throughout the build process. def sort_dependencies(self, element_name, visited=None): if visited is None: visited = {} if visited.get(element_name) is not None: return element = self.elements[element_name] for dep in element.deps: self.sort_dependencies(dep.name, visited=visited) def dependency_cmp(dep_a, dep_b): element_a = self.elements[dep_a.name] element_b = self.elements[dep_b.name] # Sort on inter element dependency first if element_a.depends(element_b): return 1 elif element_b.depends(element_a): return -1 # If there are no inter element dependencies, place # runtime only dependencies last if dep_a.dep_type != dep_b.dep_type: if dep_a.dep_type == Symbol.RUNTIME: return 1 elif dep_b.dep_type == Symbol.RUNTIME: return -1 # All things being equal, string comparison. if dep_a.name > dep_b.name: return 1 elif dep_a.name < dep_b.name: return -1 # This wont ever happen return 0 # Now dependency sort, we ensure that if any direct dependency # directly or indirectly depends on another direct dependency, # it is found later in the list. element.deps.sort(key=cmp_to_key(dependency_cmp)) visited[element_name] = True ######################################## # Element Collection # ######################################## # Collect the toplevel elements we have, resolve their deps and return ! # def collect_element(self, element_name): element = self.elements[element_name] # Return the already built one, if we already built it meta_element = self.meta_elements.get(element_name) if meta_element: return meta_element data = element.data elt_provenance = _yaml.node_get_provenance(data) meta_sources = [] sources = _yaml.node_get(data, list, Symbol.SOURCES, default_value=[]) # Safe loop calling into _yaml.node_get() for each element ensures # we have good error reporting for i in range(len(sources)): source = _yaml.node_get(data, Mapping, Symbol.SOURCES, indices=[i]) provenance = _yaml.node_get_provenance(source) kind = _yaml.node_get(source, str, Symbol.KIND) del source[Symbol.KIND] # Directory is optional directory = _yaml.node_get(source, str, Symbol.DIRECTORY, default_value='') if directory: del source[Symbol.DIRECTORY] else: directory = None index = sources.index(source) source_name = "{}-{}".format(element_name, index) meta_source = MetaSource(source_name, kind, source, directory, provenance.node, provenance.toplevel, provenance.filename) meta_sources.append(meta_source) kind = _yaml.node_get(data, str, Symbol.KIND) meta_element = MetaElement(element_name, kind, element.variant_name, elt_provenance, meta_sources, _yaml.node_get(data, Mapping, Symbol.CONFIG, default_value={}), _yaml.node_get(data, Mapping, Symbol.VARIABLES, default_value={}), _yaml.node_get(data, Mapping, Symbol.ENVIRONMENT, default_value={}), _yaml.node_get(data, list, Symbol.ENV_NOCACHE, default_value=[]), _yaml.node_get(data, Mapping, Symbol.PUBLIC, default_value={})) # Cache it now, make sure it's already there before recursing self.meta_elements[element_name] = meta_element # Descend for dep in element.deps: meta_dep = self.collect_element(dep.name) if dep.dep_type != 'runtime': meta_element.build_dependencies.append(meta_dep) if dep.dep_type != 'build': meta_element.dependencies.append(meta_dep) return meta_element