blob: e30860298e8760465ac2588f276070d2956436ce [file] [log] [blame]
# Copyright 2018 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Common functions for interacting with repo manifest XML files."""
from xml.etree import ElementTree
class Error(Exception):
"""An error related to repo manifests."""
class InvalidManifest(Error):
"""The manifest data has invalid values or structure."""
class UnsupportedFeature(Error):
"""The manifest data uses features that are not supported by this code."""
class Manifest(object):
"""Manifest represents the contents of a repo manifest XML file."""
# https://chromium.googlesource.com/external/repo/+/HEAD/docs/manifest-format.md
def __init__(self, etree, allow_unsupported_features=False):
"""Initialize Manifest.
Args:
etree: An ElementTree object representing a manifest XML file.
allow_unsupported_features: If true, will not explode if manifest
contains unsupported features such as includes.
"""
self._etree = etree
self._allow_unsupported_features = allow_unsupported_features
self._ValidateTree()
# These __*state__ pickle protocol methods are intended for multiprocessing.
def __getstate__(self):
"""Return pickleable state for this Manifest."""
return (
ElementTree.tostring(self._etree.getroot(), encoding="unicode"),
self._allow_unsupported_features,
)
def __setstate__(self, state):
"""Set the state from pickle for this Manifest."""
tree_string, allow_unsupported_features = state
root = ElementTree.fromstring(tree_string)
self._etree = ElementTree.ElementTree(root)
self._allow_unsupported_features = allow_unsupported_features
def _ValidateTree(self):
"""Raise Error if self._etree is not a valid manifest tree."""
root = self._etree.getroot()
if root is None:
raise InvalidManifest("no data")
if root.tag != "manifest":
raise InvalidManifest(
"expected root <manifest>, got <%s>" % root.tag
)
if not self._allow_unsupported_features:
if self._etree.find("./project/project") is not None:
raise UnsupportedFeature("nested <project>")
for unsupported_tag in (
"extend-project",
"remove-project",
"include",
):
if self._etree.find(unsupported_tag) is not None:
raise UnsupportedFeature("<%s>" % unsupported_tag)
@classmethod
def FromFile(cls, source, allow_unsupported_features=False):
"""Parse XML into a Manifest.
Args:
source: A string path or file object containing XML data.
allow_unsupported_features: If true, will not explode if manifest
contains unsupported features such as includes.
Raises:
Error: If source couldn't be parsed into a Manifest.
"""
return cls(
ElementTree.parse(source),
allow_unsupported_features=allow_unsupported_features,
)
@classmethod
def FromString(cls, data, allow_unsupported_features=False):
"""Parse XML into a Manifest.
Args:
data: A string containing XML data.
allow_unsupported_features: If true, will not explode if manifest
contains unsupported features such as includes.
Raises:
Error: If data couldn't be parsed into a Manifest.
"""
root = ElementTree.fromstring(data)
return cls(
ElementTree.ElementTree(root),
allow_unsupported_features=allow_unsupported_features,
)
def Write(self, dest):
"""Write the Manifest as XML.
Args:
dest: A string path or file object to write to.
Raises:
Error: If the Manifest contains invalid data.
"""
self._ValidateTree()
self._etree.write(dest, encoding="UTF-8", xml_declaration=True)
def Default(self):
"""Return this Manifest's Default or an empty Default."""
default_element = self._etree.find("default")
if default_element is None:
default_element = ElementTree.fromstring("<default/>")
return Default(self, default_element)
def Includes(self):
"""Yield an Include for each <include> element in the manifest.
This class does NOT process includes, so they are considered an unsupported
feature. You must set allow_unsupported_features to use this function.
"""
if not self._allow_unsupported_features:
raise UnsupportedFeature(
"allow unsupported features to use Includes()"
)
for include_element in self._etree.iterfind("include"):
yield Include(self, include_element)
def Remotes(self):
"""Yield a Remote for each <remote> element in the manifest."""
for remote_element in self._etree.iterfind("remote"):
yield Remote(self, remote_element)
def GetRemote(self, name):
"""Return the Remote with the given name.
Raises:
ValueError: If no Remote has the given name.
"""
for remote in self.Remotes():
if remote.name == name:
return remote
raise ValueError("no remote named %s" % name)
def Projects(self):
"""Yield a Project for each <project> element in the manifest."""
for project_element in self._etree.iterfind("project"):
yield Project(self, project_element)
def GetUniqueProject(self, name, branch=None):
"""Return the unique Project with the given name and optional branch.
Args:
name: The name of the project to search for.
branch: The optional branch to search (without the 'refs/heads/').
Raises:
ValueError: If there is not exactly one Project with the given name/branch
"""
projects = []
for project in self.Projects():
# Strip the 'refs/heads/' from the branch name
stripped_branch = project.Revision() or "master"
if stripped_branch.startswith("refs/heads/"):
stripped_branch = stripped_branch[len("refs/heads/") :]
if (
branch is None or stripped_branch == branch
) and project.name == name:
projects.append(project)
if len(projects) != 1:
raise ValueError(
"%s projects named %s on branch %s"
% (len(projects), name, branch)
)
return projects[0]
class _ManifestElement(object):
"""Subclasses of _ManifestElement wrap Manifest child XML elements."""
ATTRS = ()
TAG = None
def __init__(self, manifest, element):
tag = self.TAG or self.__class__.__name__.lower()
if element.tag != tag:
raise ValueError(
"expected a <%s> but got a <%s>" % (tag, element.tag)
)
self._manifest = manifest
self._el = element
# These __*state__ pickle protocol methods are intended for multiprocessing.
def __getstate__(self):
"""Return pickleable state for this element."""
return (
self._manifest,
ElementTree.tostring(self._el, encoding="unicode"),
)
def __setstate__(self, state):
"""Set the state from pickle for this element."""
self._manifest, xml_data = state
self._el = ElementTree.fromstring(xml_data)
def _XMLAttrName(self, name):
"""Return the XML attr name for the given Python attr name."""
if name not in self.ATTRS:
raise AttributeError(
"%r has no attribute %r" % (self.__class__.__name__, name)
)
return name.replace("_", "-")
def __getattr__(self, name):
return self._el.get(self._XMLAttrName(name))
def __setattr__(self, name, value):
if name.startswith("_"):
super().__setattr__(name, value)
else:
self._el.set(self._XMLAttrName(name), value)
def __delattr__(self, name):
if name.startswith("_"):
super().__delattr__(name)
else:
self._el.attrib.pop(self._XMLAttrName(name))
def __str__(self):
s = self.__class__.__name__
if "name" in self.ATTRS:
s = "%s %r" % (s, self.name)
return "<%s>" % s
def __repr__(self):
return ElementTree.tostring(self._el, encoding="unicode")
class Remote(_ManifestElement):
"""Remote represents a <remote> element of a Manifest."""
ATTRS = ("name", "alias", "fetch", "pushurl", "review", "revision")
def GitName(self):
"""Return the git remote name for this Remote."""
return self.alias or self.name
def PushURL(self):
"""Return the effective push URL of this Remote."""
return self.pushurl or self.fetch
class Include(_ManifestElement):
"""Include represents an <include> element of the manifest."""
ATTRS = ("name",)
class Default(_ManifestElement):
"""Default represents a <default> element of a Manifest."""
ATTRS = ("remote", "revision", "dest_branch", "sync_j", "sync_c", "sync_s")
class Project(_ManifestElement):
"""Project represents a <project> element of a Manifest."""
ATTRS = (
"name",
"path",
"remote",
"revision",
"dest_branch",
"groups",
"sync_c",
"sync_s",
"upstream",
"clone_depth",
"force_path",
)
def Path(self):
"""Return the effective filesystem path of this Project."""
return self.path or self.name
def RemoteName(self):
"""Return the effective remote name of this Project."""
return self.remote or self._manifest.Default().remote
def Remote(self):
"""Return the Remote for this Project."""
return self._manifest.GetRemote(self.RemoteName())
def Revision(self):
"""Return the effective revision of this Project."""
return (
self.revision
or self.Remote().revision
or self._manifest.Default().revision
)
def Annotations(self):
"""Return a dictionary from annotation key to annotation value."""
return {
child.get("name"): child.get("value")
for child in self._el
if child.tag == "annotation"
}