# Software License Agreement (BSD License)
#
# Copyright (c) 2012, Willow Garage, Inc.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
#
# * Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
# * Neither the name of Willow Garage, Inc. nor the names of its
# contributors may be used to endorse or promote products derived
# from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
"""Library for parsing package.xml and providing an object representation."""
from __future__ import print_function
from copy import deepcopy
import os
import re
import sys
import xml.dom.minidom as dom
from catkin_pkg.condition import evaluate_condition
PACKAGE_MANIFEST_FILENAME = 'package.xml'
[docs]class Package(object):
"""Object representation of a package manifest file."""
__slots__ = [
'package_format',
'name',
'version',
'version_compatibility',
'description',
'maintainers',
'licenses',
'urls',
'authors',
'build_depends',
'buildtool_depends',
'build_export_depends',
'buildtool_export_depends',
'exec_depends',
'test_depends',
'doc_depends',
'conflicts',
'replaces',
'group_depends',
'member_of_groups',
'exports',
'filename'
]
def __init__(self, filename=None, **kwargs):
"""
Initialize Package.
:param filename: location of package.xml. Necessary if
converting ``${prefix}`` in ``<export>`` values, ``str``.
"""
# initialize all slots ending with "s" with lists, all other with plain values
for attr in self.__slots__:
if attr.endswith('s'):
value = list(kwargs[attr]) if attr in kwargs else []
setattr(self, attr, value)
else:
value = kwargs[attr] if attr in kwargs else None
setattr(self, attr, value)
if 'depends' in kwargs:
for d in kwargs['depends']:
for slot in [self.build_depends, self.build_export_depends, self.exec_depends]:
if d not in slot:
slot.append(deepcopy(d))
del kwargs['depends']
if 'run_depends' in kwargs:
for d in kwargs['run_depends']:
for slot in [self.build_export_depends, self.exec_depends]:
if d not in slot:
slot.append(deepcopy(d))
del kwargs['run_depends']
self.filename = filename
self.licenses = [l if isinstance(l, License) else License(l) for l in self.licenses]
# verify that no unknown keywords are passed
unknown = set(kwargs.keys()).difference(self.__slots__)
if unknown:
raise TypeError('Unknown properties: %s' % ', '.join(unknown))
def __getattr__(self, name):
if name == 'run_depends':
# merge different dependencies if they are not exactly equal
# potentially having the same dependency name multiple times with different attributes
run_depends = []
[run_depends.append(deepcopy(d)) for d in self.exec_depends + self.build_export_depends if d not in run_depends]
return run_depends
raise AttributeError(name)
def __getitem__(self, key):
if key in self.__slots__ + ['run_depends']:
return getattr(self, key)
raise KeyError('Unknown key "%s"' % key)
def __iter__(self):
for slot in self.__slots__:
yield slot
def __str__(self):
data = {}
for attr in self.__slots__:
data[attr] = getattr(self, attr)
return str(data)
[docs] def get_build_type(self):
"""
Return value of export/build_type element, or 'catkin' if unspecified.
:returns: package build type
:rtype: str
:raises: :exc:`InvalidPackage`
"""
build_type_exports = [e.content for e in self.exports if e.tagname == 'build_type']
if not build_type_exports:
return 'catkin'
if len(build_type_exports) == 1:
return build_type_exports[0]
raise InvalidPackage('Only one <build_type> element is permitted.', self.filename)
[docs] def evaluate_conditions(self, context):
"""
Evaluate the conditions of all dependencies and memberships.
:param context: A dictionary with key value pairs to replace variables
starting with $ in the condition.
:raises: :exc:`ValueError` if any condition fails to parse
"""
for attr in (
'build_depends',
'buildtool_depends',
'build_export_depends',
'buildtool_export_depends',
'exec_depends',
'test_depends',
'doc_depends',
'conflicts',
'replaces',
'group_depends',
'member_of_groups',
):
conditionals = getattr(self, attr)
for conditional in conditionals:
conditional.evaluate_condition(context)
[docs] def validate(self, warnings=None):
"""
Make sure all standards for packages are met.
:param package: Package to check
:param warnings: Print warnings if None or return them in the given list
:raises InvalidPackage: in case validation fails
"""
errors = []
new_warnings = []
if self.package_format:
if not re.match('^[1-9][0-9]*$', str(self.package_format)):
errors.append('The "format" attribute of the package must contain a positive integer if present')
if not self.name:
errors.append('Package name must not be empty')
# accepting upper case letters and hyphens only for backward compatibility
if not re.match('^[a-zA-Z0-9][a-zA-Z0-9_-]*$', self.name):
errors.append('Package name "%s" does not follow naming conventions' % self.name)
else:
if not re.match('^[a-z][a-z0-9_-]*$', self.name):
new_warnings.append(
'Package name "%s" does not follow the naming conventions. It should start with '
'a lower case letter and only contain lower case letters, digits, underscores, and dashes.' % self.name)
version_regexp = '^[0-9]+\.[0-9]+\.[0-9]+$'
if not self.version:
errors.append('Package version must not be empty')
elif not re.match(version_regexp, self.version):
errors.append('Package version "%s" does not follow version conventions' % self.version)
elif not re.match('^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$', self.version):
new_warnings.append('Package "%s" does not follow the version conventions. It should not contain leading zeros (unless the number is 0).' % self.name)
if self.version_compatibility:
if not re.match(version_regexp, self.version_compatibility):
errors.append(
"Package compatibility version '%s' does not follow "
'version conventions' % self.version_compatibility)
if not self.description:
errors.append('Package description must not be empty')
if not self.maintainers:
errors.append("Package '{0}' must declare at least one maintainer".format(self.name))
for maintainer in self.maintainers:
try:
maintainer.validate()
except InvalidPackage as e:
errors.append(e.msg)
if not maintainer.email:
errors.append('Maintainers must have an email address')
if not self.licenses:
errors.append('The package node must contain at least one "license" tag')
if [l for l in self.licenses if not l.strip()]:
errors.append('The license tag must neither be empty nor only contain whitespaces')
if self.authors is not None:
for author in self.authors:
try:
author.validate()
except InvalidPackage as e:
errors.append(e.msg)
dep_types = {
'build': self.build_depends,
'buildtool': self.buildtool_depends,
'build_export': self.build_export_depends,
'buildtool_export': self.buildtool_export_depends,
'exec': self.exec_depends,
'test': self.test_depends,
'doc': self.doc_depends
}
for dep_type, depends in dep_types.items():
for depend in depends:
if depend.name == self.name:
errors.append('The package "%s" must not "%s_depend" on a package with the same name as this package' % (self.name, dep_type))
if (
set([d.name for d in self.group_depends]) &
set([g.name for g in self.member_of_groups])
):
errors.append(
"The package must not 'group_depend' on a package which it "
'also declares to be a member of')
if self.is_metapackage():
if not self.has_buildtool_depend_on_catkin():
# TODO escalate to error in the future, or use metapackage.validate_metapackage
new_warnings.append('Metapackage "%s" must buildtool_depend on catkin.' % self.name)
if self.has_invalid_metapackage_dependencies():
new_warnings.append('Metapackage "%s" should not have other dependencies besides a '
'buildtool_depend on catkin and run_depends.' % self.name)
for warning in new_warnings:
if warnings is None:
print('WARNING: ' + warning, file=sys.stderr)
elif warning not in warnings:
warnings.append(warning)
if errors:
raise InvalidPackage('\n'.join(errors), self.filename)
[docs]class Dependency(object):
__slots__ = [
'name',
'version_lt', 'version_lte', 'version_eq', 'version_gte', 'version_gt',
'condition',
'evaluated_condition',
]
def __init__(self, name, **kwargs):
self.evaluated_condition = None
for attr in self.__slots__:
value = kwargs[attr] if attr in kwargs else None
setattr(self, attr, value)
self.name = name
# verify that no unknown keywords are passed
unknown = set(kwargs.keys()).difference(self.__slots__)
if unknown:
raise TypeError('Unknown properties: %s' % ', '.join(unknown))
def __eq__(self, other):
if not isinstance(other, Dependency):
return False
return all(getattr(self, attr) == getattr(other, attr) for attr in self.__slots__)
def __hash__(self):
return hash(tuple(getattr(self, slot) for slot in self.__slots__))
def __str__(self):
return self.name
[docs] def evaluate_condition(self, context):
"""
Evaluate the condition.
The result is also stored in the member variable `evaluated_condition`.
:param context: A dictionary with key value pairs to replace variables
starting with $ in the condition.
:returns: True if the condition evaluates to True, else False
:raises: :exc:`ValueError` if the condition fails to parse
"""
self.evaluated_condition = evaluate_condition(self.condition, context)
return self.evaluated_condition
[docs]class Export(object):
__slots__ = ['tagname', 'attributes', 'content']
def __init__(self, tagname, content=None):
self.tagname = tagname
self.attributes = {}
self.content = content
def __str__(self):
txt = '<%s' % self.tagname
for key in sorted(self.attributes.keys()):
txt += ' %s="%s"' % (key, self.attributes[key])
if self.content:
txt += '>%s</%s>' % (self.content, self.tagname)
else:
txt += '/>'
return txt
# Subclassing ``str`` to keep backward compatibility.
[docs]class License(str):
def __new__(cls, value, file_=None):
obj = str.__new__(cls, str(value))
obj.file = file_
return obj
[docs]class Person(object):
__slots__ = ['name', 'email']
def __init__(self, name, email=None):
self.name = name
self.email = email
def __str__(self):
name = self.name
if not isinstance(name, str):
name = name.encode('utf-8')
if self.email is not None:
return '%s <%s>' % (name, self.email)
else:
return '%s' % name
[docs] def validate(self):
if self.email is None:
return
if not re.match('^[-a-zA-Z0-9_%+]+(\.[-a-zA-Z0-9_%+]+)*@[-a-zA-Z0-9%]+(\.[-a-zA-Z0-9%]+)*\.[a-zA-Z]{2,}$', self.email):
raise InvalidPackage('Invalid email "%s" for person "%s"' % (self.email, self.name))
[docs]class Url(object):
__slots__ = ['url', 'type']
def __init__(self, url, type_=None):
self.url = url
self.type = type_
def __str__(self):
return self.url
[docs]def parse_package_for_distutils(path=None):
print('WARNING: %s/setup.py: catkin_pkg.package.parse_package_for_distutils() is deprecated. Please use catkin_pkg.python_setup.generate_distutils_setup(**kwargs) instead.' %
os.path.basename(os.path.abspath('.')))
from .python_setup import generate_distutils_setup
data = {}
if path is not None:
data['package_xml_path'] = path
return generate_distutils_setup(**data)
[docs]class InvalidPackage(Exception):
def __init__(self, msg, package_path=None):
self.msg = msg
self.package_path = package_path
Exception.__init__(self, self.msg)
def __str__(self):
result = '' if not self.package_path else "Error(s) in package '%s':\n" % self.package_path
return result + Exception.__str__(self)
[docs]def package_exists_at(path):
"""
Check that a package exists at the given path.
:param path: path to a package
:type path: str
:returns: True if package exists in given path, else False
:rtype: bool
"""
return os.path.isdir(path) and os.path.isfile(os.path.join(path, PACKAGE_MANIFEST_FILENAME))
def _get_package_xml(path):
"""
Get xml of package manifest.
:param path: The path of the package.xml file, it may or may not
include the filename
:returns: a tuple with the xml as well as the path of the read file
:raises: :exc:`IOError`
"""
if os.path.isfile(path):
filename = path
elif package_exists_at(path):
filename = os.path.join(path, PACKAGE_MANIFEST_FILENAME)
if not os.path.isfile(filename):
raise IOError('Directory "%s" does not contain a "%s"' % (path, PACKAGE_MANIFEST_FILENAME))
else:
raise IOError('Path "%s" is neither a directory containing a "%s" file nor a file' % (path, PACKAGE_MANIFEST_FILENAME))
# Force utf8 encoding for python3.
# This way unicode files can still be processed on non-unicode locales.
kwargs = {}
if sys.version_info[0] >= 3:
kwargs['encoding'] = 'utf8'
with open(filename, 'r', **kwargs) as f:
return f.read(), filename
[docs]def parse_package(path, warnings=None):
"""
Parse package manifest.
:param path: The path of the package.xml file, it may or may not
include the filename
:param warnings: Print warnings if None or return them in the given list
:returns: return :class:`Package` instance, populated with parsed fields
:raises: :exc:`InvalidPackage`
:raises: :exc:`IOError`
"""
xml, filename = _get_package_xml(path)
return parse_package_string(xml, filename, warnings=warnings)
def _check_known_attributes(node, known):
if node.hasAttributes():
attrs = map(str, node.attributes.keys())
# colon is the namespace separator in attributes, xmlns can be added to any tag
unknown_attrs = [attr for attr in attrs if not (attr in known or attr == 'xmlns' or ':' in attr)]
if unknown_attrs:
return ['The "%s" tag must not have the following attributes: %s' % (node.tagName, ', '.join(unknown_attrs))]
return []
[docs]def parse_package_string(data, filename=None, warnings=None):
"""
Parse package.xml string contents.
:param data: package.xml contents, ``str``
:param filename: full file path for debugging, ``str``
:param warnings: Print warnings if None or return them in the given list
:returns: return parsed :class:`Package`
:raises: :exc:`InvalidPackage`
"""
try:
root = dom.parseString(data)
except Exception as ex:
raise InvalidPackage('The manifest contains invalid XML:\n%s' % ex, filename)
pkg = Package(filename)
# verify unique root node
nodes = _get_nodes(root, 'package')
if len(nodes) != 1:
raise InvalidPackage('The manifest must contain a single "package" root tag', filename)
root = nodes[0]
# format attribute
value = _get_node_attr(root, 'format', default=1)
pkg.package_format = int(value)
assert pkg.package_format in (1, 2, 3), \
"Unable to handle package.xml format version '%d', please update catkin_pkg " \
"(e.g. on Ubuntu/Debian use: sudo apt-get update && sudo apt-get install --only-upgrade python-catkin-pkg)" % pkg.package_format
# name
pkg.name = _get_node_value(_get_node(root, 'name', filename))
# version and optional compatibility
version_node = _get_node(root, 'version', filename)
pkg.version = _get_node_value(version_node)
pkg.version_compatibility = _get_node_attr(
version_node, 'compatibility', default=None)
# description
pkg.description = _get_node_value(_get_node(root, 'description', filename), allow_xml=True, apply_str=False)
# at least one maintainer, all must have email
maintainers = _get_nodes(root, 'maintainer')
for node in maintainers:
pkg.maintainers.append(Person(
_get_node_value(node, apply_str=False),
_get_node_attr(node, 'email')
))
# urls with optional type
urls = _get_nodes(root, 'url')
for node in urls:
pkg.urls.append(Url(
_get_node_value(node),
_get_node_attr(node, 'type', default='website')
))
# authors with optional email
authors = _get_nodes(root, 'author')
for node in authors:
pkg.authors.append(Person(
_get_node_value(node, apply_str=False),
_get_node_attr(node, 'email', default=None)
))
# at least one license
licenses = _get_nodes(root, 'license')
for node in licenses:
pkg.licenses.append(License(
_get_node_value(node),
_get_node_attr(node, 'file', default=None)
))
errors = []
# dependencies and relationships
pkg.build_depends = _get_dependencies(root, 'build_depend')
pkg.buildtool_depends = _get_dependencies(root, 'buildtool_depend')
if pkg.package_format == 1:
run_depends = _get_dependencies(root, 'run_depend')
for d in run_depends:
pkg.build_export_depends.append(deepcopy(d))
pkg.exec_depends.append(deepcopy(d))
if pkg.package_format != 1:
pkg.build_export_depends = _get_dependencies(root, 'build_export_depend')
pkg.buildtool_export_depends = _get_dependencies(root, 'buildtool_export_depend')
pkg.exec_depends = _get_dependencies(root, 'exec_depend')
depends = _get_dependencies(root, 'depend')
for dep in depends:
# check for collisions with specific dependencies
same_build_depends = ['build_depend' for d in pkg.build_depends if d.name == dep.name]
same_build_export_depends = ['build_export_depend' for d in pkg.build_export_depends if d.name == dep.name]
same_exec_depends = ['exec_depend' for d in pkg.exec_depends if d.name == dep.name]
if same_build_depends or same_build_export_depends or same_exec_depends:
errors.append("The generic dependency on '%s' is redundant with: %s" % (dep.name, ', '.join(same_build_depends + same_build_export_depends + same_exec_depends)))
# only append non-duplicates
if not same_build_depends:
pkg.build_depends.append(deepcopy(dep))
if not same_build_export_depends:
pkg.build_export_depends.append(deepcopy(dep))
if not same_exec_depends:
pkg.exec_depends.append(deepcopy(dep))
pkg.doc_depends = _get_dependencies(root, 'doc_depend')
pkg.test_depends = _get_dependencies(root, 'test_depend')
pkg.conflicts = _get_dependencies(root, 'conflict')
pkg.replaces = _get_dependencies(root, 'replace')
# group dependencies and memberships
pkg.group_depends = _get_group_dependencies(root, 'group_depend')
pkg.member_of_groups = _get_group_memberships(root, 'member_of_group')
if pkg.package_format == 1:
for test_depend in pkg.test_depends:
same_build_depends = ['build_depend' for d in pkg.build_depends if d.name == test_depend.name]
same_run_depends = ['run_depend' for d in pkg.run_depends if d.name == test_depend.name]
if same_build_depends or same_run_depends:
errors.append('The test dependency on "%s" is redundant with: %s' % (test_depend.name, ', '.join(same_build_depends + same_run_depends)))
# exports
export_node = _get_optional_node(root, 'export', filename)
if export_node is not None:
exports = []
for node in [n for n in export_node.childNodes if n.nodeType == n.ELEMENT_NODE]:
export = Export(str(node.tagName), _get_node_value(node, allow_xml=True))
for key, value in node.attributes.items():
export.attributes[str(key)] = str(value)
exports.append(export)
pkg.exports = exports
# verify that no unsupported tags and attributes are present
errors += _check_known_attributes(root, ['format'])
depend_attributes = ['version_lt', 'version_lte', 'version_eq', 'version_gte', 'version_gt']
if pkg.package_format > 2:
depend_attributes.append('condition')
known = {
'name': [],
'version': ['compatibility'],
'description': [],
'maintainer': ['email'],
'license': [],
'url': ['type'],
'author': ['email'],
'build_depend': depend_attributes,
'buildtool_depend': depend_attributes,
'test_depend': depend_attributes,
'conflict': depend_attributes,
'replace': depend_attributes,
'export': [],
}
if pkg.package_format == 1:
known.update({
'run_depend': depend_attributes,
})
if pkg.package_format != 1:
known.update({
'build_export_depend': depend_attributes,
'buildtool_export_depend': depend_attributes,
'depend': depend_attributes,
'exec_depend': depend_attributes,
'doc_depend': depend_attributes,
})
if pkg.package_format > 2:
known.update({
'group_depend': ['condition'],
'member_of_group': ['condition']
})
if pkg.package_format > 2:
known.update({
'license': ['file'],
})
nodes = [n for n in root.childNodes if n.nodeType == n.ELEMENT_NODE]
unknown_tags = set([n.tagName for n in nodes if n.tagName not in known.keys()])
if unknown_tags:
errors.append('The manifest (with format version %d) must not contain the following tags: %s' % (pkg.package_format, ', '.join(unknown_tags)))
for node in [n for n in nodes if n.tagName in known.keys()]:
errors += _check_known_attributes(node, known[node.tagName])
if node.tagName not in ['description', 'export']:
subnodes = [n for n in node.childNodes if n.nodeType == n.ELEMENT_NODE]
if subnodes:
errors.append('The "%s" tag must not contain the following children: %s' % (node.tagName, ', '.join([n.tagName for n in subnodes])))
if errors:
raise InvalidPackage('Error(s):%s' % (''.join(['\n- %s' % e for e in errors])), filename)
pkg.validate(warnings=warnings)
return pkg
def _get_nodes(parent, tagname):
return [n for n in parent.childNodes if n.nodeType == n.ELEMENT_NODE and n.tagName == tagname]
def _get_node(parent, tagname, filename):
nodes = _get_nodes(parent, tagname)
if len(nodes) != 1:
raise InvalidPackage('The manifest must contain exactly one "%s" tag' % tagname, filename)
return nodes[0]
def _get_optional_node(parent, tagname, filename):
nodes = _get_nodes(parent, tagname)
if len(nodes) > 1:
raise InvalidPackage('The manifest must not contain more than one "%s" tags' % tagname, filename)
return nodes[0] if nodes else None
def _get_node_value(node, allow_xml=False, apply_str=True):
if allow_xml:
value = (''.join([n.toxml() for n in node.childNodes])).strip(' \n\r\t')
else:
value = (''.join([n.data for n in node.childNodes if n.nodeType == n.TEXT_NODE])).strip(' \n\r\t')
if apply_str:
value = str(value)
return value
def _get_node_attr(node, attr, default=False):
""":param default: False means value is required."""
if node.hasAttribute(attr):
return str(node.getAttribute(attr))
if default is False:
raise InvalidPackage('The "%s" tag must have the attribute "%s"' % (node.tagName, attr))
return default
def _get_dependencies(parent, tagname):
depends = []
for node in _get_nodes(parent, tagname):
depend = Dependency(_get_node_value(node))
for attr in ('version_lt', 'version_lte', 'version_eq', 'version_gte', 'version_gt', 'condition'):
setattr(depend, attr, _get_node_attr(node, attr, None))
depends.append(depend)
return depends
def _get_group_dependencies(parent, tagname):
from .group_dependency import GroupDependency
depends = []
for node in _get_nodes(parent, tagname):
depends.append(
GroupDependency(
_get_node_value(node),
condition=_get_node_attr(node, 'condition', default=None)))
return depends
def _get_group_memberships(parent, tagname):
from .group_membership import GroupMembership
memberships = []
for node in _get_nodes(parent, tagname):
memberships.append(
GroupMembership(
_get_node_value(node),
condition=_get_node_attr(node, 'condition', default=None)))
return memberships