| # Copyright (c) 2018, Nordic Semiconductor ASA |
| # Copyright 2018, Foundries.io Ltd |
| # |
| # SPDX-License-Identifier: Apache-2.0 |
| |
| '''Parser and abstract data types for west manifests. |
| |
| The main class is Manifest. The recommended method for creating a |
| Manifest instance is via its from_file() or from_data() helper |
| methods. |
| |
| There are additionally Defaults, Remote, and Project types defined, |
| which represent the values by the same names in a west |
| manifest. (I.e. "Remote" represents one of the elements in the |
| "remote" sequence in the manifest, and so on.) Some Default values, |
| such as the default project revision, may be supplied by this module |
| if they are not present in the manifest data.''' |
| |
| import os |
| |
| import pykwalify.core |
| import yaml |
| |
| from west import util, log |
| |
| # Todo: take from _bootstrap? |
| # Default west repository URL. |
| WEST_URL_DEFAULT = 'https://github.com/zephyrproject-rtos/west' |
| # Default revision to check out of the west repository. |
| WEST_REV_DEFAULT = 'master' |
| |
| META_NAMES = ['west', 'manifest'] |
| '''Names of the special "meta-projects", which are reserved and cannot |
| be used to name a project in the manifest file.''' |
| |
| MANIFEST_SECTIONS = ['manifest', 'west'] |
| '''Sections in the manifest file''' |
| |
| |
| def default_path(): |
| '''Return the path to the default manifest in the west directory. |
| |
| Raises WestNotFound if called from outside of a west working directory.''' |
| return os.path.join(util.west_dir(), 'manifest', 'default.yml') |
| |
| |
| class Manifest: |
| '''Represents the contents of a West manifest file. |
| |
| The most convenient way to construct an instance is using the |
| from_file and from_data helper methods.''' |
| |
| @staticmethod |
| def from_file(source_file=None, sections=MANIFEST_SECTIONS): |
| '''Create and return a new Manifest object given a source YAML file. |
| |
| :param source_file: Path to a YAML file containing the manifest. |
| :param sections: Only parse specified sections from YAML file, |
| default: all sections are parsed. |
| |
| If source_file is None, the value returned by default_path() |
| is used. |
| |
| Raises MalformedManifest in case of validation errors.''' |
| if source_file is None: |
| source_file = default_path() |
| return Manifest(source_file=source_file, sections=sections) |
| |
| @staticmethod |
| def from_data(source_data, sections=MANIFEST_SECTIONS): |
| '''Create and return a new Manifest object given parsed YAML data. |
| |
| :param source_data: Parsed YAML data as a Python object. |
| :param sections: Only parse specified sections from YAML data, |
| default: all sections are parsed. |
| |
| Raises MalformedManifest in case of validation errors.''' |
| return Manifest(source_data=source_data, sections=sections) |
| |
| def __init__(self, source_file=None, source_data=None, |
| sections=MANIFEST_SECTIONS): |
| '''Create a new Manifest object. |
| |
| :param source_file: Path to a YAML file containing the manifest. |
| :param source_data: Parsed YAML data as a Python object. |
| :param sections: Only parse specified sections from YAML file, |
| default: all sections are parsed. |
| |
| Normally, it is more convenient to use the `from_file` and |
| `from_data` convenience factories than calling the constructor |
| directly. |
| |
| Exactly one of the source_file and source_data parameters must |
| be given. |
| |
| Raises MalformedManifest in case of validation errors.''' |
| if source_file and source_data: |
| raise ValueError('both source_file and source_data were given') |
| |
| if source_file: |
| with open(source_file, 'r') as f: |
| self._data = yaml.safe_load(f.read()) |
| path = source_file |
| else: |
| self._data = source_data |
| path = None |
| |
| self.path = path |
| '''Path to the file containing the manifest, or None if created |
| from data rather than the file system.''' |
| |
| if not self._data: |
| self._malformed('manifest contains no data') |
| |
| if 'manifest' not in self._data: |
| self._malformed('manifest contains no manifest element') |
| |
| for key in self._data: |
| if key in sections: |
| try: |
| pykwalify.core.Core( |
| source_data=self._data[key], |
| schema_files=[_SCHEMA_PATH[key]] |
| ).validate() |
| except pykwalify.errors.SchemaError as e: |
| self._malformed(e, key) |
| |
| self.defaults = None |
| '''west.manifest.Defaults object representing default values |
| in the manifest, either as specified by the user or west itself.''' |
| |
| self.remotes = None |
| '''Sequence of west.manifest.Remote objects representing manifest |
| remotes.''' |
| |
| self.projects = None |
| '''Sequence of west.manifest.Project objects representing manifest |
| projects. |
| |
| Each element's values are fully initialized; there is no need |
| to consult the defaults field to supply missing values.''' |
| |
| self.west_project = None |
| '''west.manifest.SpecialProject object representing the west meta |
| project.''' |
| |
| # Set up the public attributes documented above, as well as |
| # any internal attributes needed to implement the public API. |
| self._load(self._data, sections) |
| |
| def get_remote(self, name): |
| '''Get a manifest Remote, given its name.''' |
| return self._remotes_dict[name] |
| |
| def _malformed(self, complaint, section='manifest'): |
| context = (' file {} '.format(self.path) if self.path |
| else ' data:\n{}\n'.format(self._data)) |
| raise MalformedManifest('Malformed manifest{}(schema: {}):\n{}' |
| .format(context, _SCHEMA_PATH[section], |
| complaint)) |
| |
| def _load(self, data, sections): |
| # Initialize this instance's fields from values given in the |
| # manifest data, which must be validated according to the schema. |
| if 'west' in sections: |
| west = data.get('west', {}) |
| |
| url = west.get('url') or WEST_URL_DEFAULT |
| revision = west.get('revision') or WEST_REV_DEFAULT |
| |
| self.west_project = SpecialProject('west', |
| url=url, |
| revision=revision, |
| path=os.path.join('west', |
| 'west')) |
| |
| # Next is the manifest section |
| if 'manifest' not in sections: |
| return |
| |
| projects = [] |
| project_abspaths = set() |
| |
| manifest = data.get('manifest') |
| |
| # Map from each remote's name onto that remote's data in the manifest. |
| remotes = tuple(Remote(r['name'], r['url-base']) for r in |
| manifest['remotes']) |
| remotes_dict = {r.name: r for r in remotes} |
| |
| # Get any defaults out of the manifest. |
| # |
| # md = manifest defaults (dictionary with values parsed from |
| # the manifest) |
| md = manifest.get('defaults', dict()) |
| mdrem = md.get('remote') |
| if mdrem: |
| # The default remote name, if provided, must refer to a |
| # well-defined remote. |
| if mdrem not in remotes_dict: |
| self._malformed('default remote {} is not defined'. |
| format(mdrem)) |
| default_remote = remotes_dict[mdrem] |
| default_remote_name = mdrem |
| else: |
| default_remote = None |
| default_remote_name = None |
| defaults = Defaults(remote=default_remote, revision=md.get('revision')) |
| |
| # mp = manifest project (dictionary with values parsed from |
| # the manifest) |
| for mp in manifest['projects']: |
| # Validate the project name. |
| name = mp['name'] |
| if name in META_NAMES: |
| self._malformed('the name "{}" is reserved and cannot '. |
| format(name) + |
| 'be used to name a manifest project') |
| |
| # Validate the project remote. |
| remote_name = mp.get('remote', default_remote_name) |
| if remote_name is None: |
| self._malformed('project {} does not specify a remote'. |
| format(name)) |
| if remote_name not in remotes_dict: |
| self._malformed('project {} remote {} is not defined'. |
| format(name, remote_name)) |
| project = Project(name, |
| remotes_dict[remote_name], |
| defaults, |
| path=mp.get('path'), |
| clone_depth=mp.get('clone-depth'), |
| revision=mp.get('revision')) |
| |
| # Two projects cannot have the same path. We use absolute |
| # paths to check for collisions to ensure paths are |
| # normalized (e.g. for case-insensitive file systems or |
| # in cases like on Windows where / or \ may serve as a |
| # path component separator). |
| if project.abspath in project_abspaths: |
| self._malformed('project {} path {} is already in use'. |
| format(project.name, project.path)) |
| |
| project_abspaths.add(project.abspath) |
| projects.append(project) |
| |
| self.defaults = defaults |
| self.remotes = remotes |
| self._remotes_dict = remotes_dict |
| self.projects = tuple(projects) |
| |
| |
| class MalformedManifest(Exception): |
| '''Exception indicating that west manifest parsing failed due to a |
| malformed value.''' |
| |
| |
| # Definitions for Manifest attribute types. |
| |
| class Defaults: |
| '''Represents default values in a manifest, either specified by the |
| user or by west itself. |
| |
| Defaults are neither comparable nor hashable.''' |
| |
| __slots__ = 'remote revision'.split() |
| |
| def __init__(self, remote=None, revision=None): |
| '''Initialize a defaults value from manifest data. |
| |
| :param remote: Remote instance corresponding to the default remote, |
| or None (an actual Remote object, not the name of |
| a remote as a string). |
| :param revision: Default Git revision; 'master' if not given.''' |
| if remote is not None: |
| _wrn_if_not_remote(remote) |
| if revision is None: |
| revision = 'master' |
| |
| self.remote = remote |
| self.revision = revision |
| |
| def __eq__(self, other): |
| return NotImplemented |
| |
| def __repr__(self): |
| return 'Defaults(remote={}, revision={})'.format(repr(self.remote), |
| repr(self.revision)) |
| |
| |
| class Remote: |
| '''Represents a remote defined in a west manifest. |
| |
| Remotes may be compared for equality, but are not hashable.''' |
| |
| __slots__ = 'name url_base'.split() |
| |
| def __init__(self, name, url_base): |
| '''Initialize a remote from manifest data. |
| |
| :param name: remote's name |
| :param url_base: remote's URL base.''' |
| if url_base.endswith('/'): |
| log.wrn('Remote', name, 'URL base', url_base, |
| 'ends with a slash ("/"); these are automatically', |
| 'appended by West') |
| |
| self.name = name |
| self.url_base = url_base |
| |
| def __eq__(self, other): |
| return self.name == other.name and self.url_base == other.url_base |
| |
| def __repr__(self): |
| return 'Remote(name={}, url_base={})'.format(repr(self.name), |
| repr(self.url_base)) |
| |
| |
| class Project: |
| '''Represents a project defined in a west manifest. |
| |
| Projects are neither comparable nor hashable.''' |
| |
| __slots__ = 'name remote url path abspath clone_depth revision'.split() |
| |
| def __init__(self, name, remote, defaults, path=None, clone_depth=None, |
| revision=None): |
| '''Specify a Project by name, Remote, and optional information. |
| |
| :param name: Project's user-defined name in the manifest. |
| :param remote: Remote instance corresponding to this Project as |
| specified in the manifest. This is used to build |
| the project's URL, and is also stored as an attribute. |
| :param defaults: If the revision parameter is not given, the project's |
| revision is set to defaults.revision. |
| :param path: Relative path to the project in the west |
| installation, if present in the manifest. If not given, |
| the project's ``name`` is used. |
| :param clone_depth: Nonnegative integer clone depth if present in |
| the manifest. |
| :param revision: Project revision as given in the manifest, if present. |
| If not given, defaults.revision is used instead. |
| ''' |
| _wrn_if_not_remote(remote) |
| |
| self.name = name |
| self.remote = remote |
| self.url = remote.url_base + '/' + name |
| self.path = os.path.normpath(path or name) |
| self.abspath = os.path.realpath(os.path.join(util.west_topdir(), |
| self.path)) |
| self.clone_depth = clone_depth |
| self.revision = revision or defaults.revision |
| |
| def __eq__(self, other): |
| return NotImplemented |
| |
| def __repr__(self): |
| reprs = [repr(x) for x in |
| (self.name, self.remote, self.url, self.path, |
| self.abspath, self.clone_depth, self.revision)] |
| return ('Project(name={}, remote={}, url={}, path={}, abspath={}, ' |
| 'clone_depth={}, revision={})').format(*reprs) |
| |
| |
| class SpecialProject(Project): |
| '''Represents a special project, e.g. the west or manifest project. |
| |
| Projects are neither comparable nor hashable.''' |
| |
| def __init__(self, name, path=None, revision=None, url=None): |
| '''Specify a Special Project by name, and url, and optional information. |
| |
| :param name: Special Project's user-defined name in the manifest |
| :param path: Relative path to the project in the west |
| installation, if present in the manifest. If None, |
| the project's ``name`` is used. |
| :param revision: Project revision as given in the manifest, if present. |
| :param url: Complete URL for special project. |
| ''' |
| self.name = name |
| self.url = url |
| self.path = path or name |
| self.abspath = os.path.realpath(os.path.join(util.west_topdir(), |
| self.path)) |
| self.revision = revision |
| self.remote = None |
| self.clone_depth = None |
| |
| |
| def _wrn_if_not_remote(remote): |
| if not isinstance(remote, Remote): |
| log.wrn('Remote', remote, 'is not a Remote instance') |
| |
| |
| _SCHEMA_PATH = {'manifest': os.path.join(os.path.dirname(__file__), |
| "manifest-schema.yml"), |
| 'west': os.path.join(os.path.dirname(__file__), |
| "_bootstrap", |
| "west-schema.yml")} |