diff --git a/ament_package/__init__.py b/ament_package/__init__.py index 98871c8..f819bf5 100644 --- a/ament_package/__init__.py +++ b/ament_package/__init__.py @@ -123,7 +123,7 @@ def parse_package_string(data, *, filename=None): "Unable to handle '%s' format version '%d', please update the " \ 'manifest file to at least format version 2' % \ (filename, pkg.package_format) - assert pkg.package_format in [2], \ + assert pkg.package_format in [2, 3], \ "Unable to handle '%s' format version '%d', please update " \ "'ament_package' (e.g. on Ubuntu/Debian use: sudo apt-get update && " \ 'sudo apt-get install --only-upgrade python-ament-package)' % \ @@ -135,6 +135,8 @@ def parse_package_string(data, *, filename=None): # version version_node = _get_node(root, 'version') pkg.version = _get_node_value(version_node) + pkg.version_compatibility = _get_node_attr( + version_node, 'compatibility', default=None) # description pkg.description = _get_node_value( @@ -209,6 +211,10 @@ def parse_package_string(data, *, filename=None): 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') + # exports export_node = _get_optional_node(root, 'export') if export_node is not None: @@ -242,7 +248,7 @@ def parse_package_string(data, *, filename=None): ] known = { 'name': [], - 'version': [], + 'version': ['compatibility'], 'description': [], 'maintainer': ['email'], 'license': [], @@ -260,6 +266,11 @@ def parse_package_string(data, *, filename=None): 'replace': depend_attributes, 'export': [], } + if pkg.package_format > 2: + known.update({ + 'group_depend': [], + 'member_of_group': [], + }) nodes = [n for n in root.childNodes if n.nodeType == n.ELEMENT_NODE] unknown_tags = {n.tagName for n in nodes if n.tagName not in known.keys()} if unknown_tags: @@ -359,3 +370,25 @@ def _get_dependencies(parent, tagname): setattr(depend, attr, _get_node_attr(node, attr, default=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 diff --git a/ament_package/condition.py b/ament_package/condition.py new file mode 100644 index 0000000..2f3fbd2 --- /dev/null +++ b/ament_package/condition.py @@ -0,0 +1,76 @@ +# Copyright 2014 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pyparsing + + +def evaluate_condition(condition, context): + if condition is None: + return True + expr = _get_condition_expression() + try: + parse_results = expr.parseString(condition, parseAll=True) + except pyparsing.ParseException as e: + raise ValueError( + "condition '%s' failed to parse: %s" % (condition, e)) + return _evaluate(parse_results.asList()[0], context) + + +_condition_expression = None + + +def _get_condition_expression(): + global _condition_expression + if not _condition_expression: + pp = pyparsing + operator = pp.Regex('==|!=').setName('operator') + identifier = pp.Word('$', pp.alphanums + '_', min=2) + value = pp.Word(pp.alphanums + '_-') + comparison_term = identifier | value + condition = pp.Group(comparison_term + operator + comparison_term) + _condition_expression = pp.operatorPrecedence( + condition, [ + ('and', 2, pp.opAssoc.LEFT, ), + ('or', 2, pp.opAssoc.LEFT, ), + ]) + return _condition_expression + + +def _evaluate(parse_results, context): + if not isinstance(parse_results, list): + if parse_results.startswith('$'): + # get variable from context + return str(context.get(parse_results[1:], '')) + # return literal value + return parse_results + + # recursion + assert len(parse_results) == 3 + + # handle logical operators + if parse_results[1] == 'and': + return _evaluate(parse_results[0], context) and \ + _evaluate(parse_results[2], context) + if parse_results[1] == 'or': + return _evaluate(parse_results[0], context) or \ + _evaluate(parse_results[2], context) + + # handle comparison operators + assert parse_results[1] in ('==', '!=') + if parse_results[1] == '==': + return _evaluate(parse_results[0], context) == \ + _evaluate(parse_results[2], context) + if parse_results[1] == '!=': + return _evaluate(parse_results[0], context) != \ + _evaluate(parse_results[2], context) diff --git a/ament_package/dependency.py b/ament_package/dependency.py index 9f43463..884fa0b 100644 --- a/ament_package/dependency.py +++ b/ament_package/dependency.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from ament_package.condition import evaluate_condition + class Dependency: __slots__ = [ @@ -21,9 +23,12 @@ class Dependency: '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) @@ -41,3 +46,18 @@ def __eq__(self, other): def __str__(self): return self.name + + 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 diff --git a/ament_package/group_dependency.py b/ament_package/group_dependency.py new file mode 100644 index 0000000..0e7838d --- /dev/null +++ b/ament_package/group_dependency.py @@ -0,0 +1,60 @@ +# Copyright 2017 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ament_package.condition import evaluate_condition + + +class GroupDependency: + __slots__ = [ + 'name', + 'condition', + 'evaluated_condition', + 'members', + ] + + def __init__(self, name, condition=None, members=None): + self.name = name + self.condition = condition + self.members = members + self.evaluated_condition = None + + def __eq__(self, other): + if not isinstance(other, GroupDependency): + return False + return all(getattr(self, attr) == getattr(other, attr) + for attr in self.__slots__) + + def __str__(self): + return self.name + + 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 + + def extract_group_members(self, packages): + self.members = set() + for pkg in packages: + if self.name in [g.name for g in pkg.member_of_groups]: + self.members.add(pkg.name) diff --git a/ament_package/group_membership.py b/ament_package/group_membership.py new file mode 100644 index 0000000..6aec2ec --- /dev/null +++ b/ament_package/group_membership.py @@ -0,0 +1,52 @@ +# Copyright 2017 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ament_package.condition import evaluate_condition + + +class GroupMembership: + __slots__ = [ + 'name', + 'condition', + 'evaluated_condition', + ] + + def __init__(self, name, condition=None): + self.name = name + self.condition = condition + self.evaluated_condition = None + + def __eq__(self, other): + if not isinstance(other, GroupMembership): + return False + return all(getattr(self, attr) == getattr(other, attr) + for attr in self.__slots__) + + def __str__(self): + return self.name + + 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 diff --git a/ament_package/package.py b/ament_package/package.py index 6107512..1c23f5e 100644 --- a/ament_package/package.py +++ b/ament_package/package.py @@ -25,6 +25,7 @@ class Package: 'package_format', 'name', 'version', + 'version_compatibility', 'description', 'maintainers', 'licenses', @@ -39,6 +40,8 @@ class Package: 'doc_depends', 'conflicts', 'replaces', + 'group_depends', + 'member_of_groups', 'exports', 'filename', ] @@ -98,6 +101,32 @@ def get_build_type(self): return build_type_exports[0] raise InvalidPackage('Only one element is permitted.') + 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) + def validate(self): """ Ensure that all standards for packages are met. @@ -124,11 +153,17 @@ def validate(self): errors.append("Package name '%s' does not follow naming " 'conventions' % 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('^[0-9]+\.[0-9_]+\.[0-9_]+$', self.version): + elif not re.match(version_regexp, self.version): errors.append("Package version '%s' does not follow version " 'conventions' % self.version) + 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') @@ -170,5 +205,13 @@ def validate(self): "The package must not '%s_depend' on a package with " 'the same name as this package' % dep_type) + if ( + {d.name for d in self.group_depends} & + {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 errors: raise InvalidPackage('\n'.join(errors)) diff --git a/test/test_package.py b/test/test_package.py index e0b4e27..c0b6eb7 100644 --- a/test/test_package.py +++ b/test/test_package.py @@ -33,6 +33,11 @@ def get_maintainer(self): maint.name = 'John Doe' return maint + def get_group_dependency(self, name): + group = Mock() + group.name = name + return group + def test_init(self): maint = self.get_maintainer() pack = Package(name='foo', @@ -40,6 +45,8 @@ def test_init(self): maintainers=[maint], licenses=['BSD']) self.assertEqual(None, pack.filename) + self.assertEqual('0.0.0', pack.version) + self.assertEqual(None, pack.version_compatibility) self.assertEqual([], pack.urls) self.assertEqual([], pack.authors) self.assertEqual([maint], pack.maintainers) @@ -50,6 +57,8 @@ def test_init(self): self.assertEqual([], pack.conflicts) self.assertEqual([], pack.replaces) self.assertEqual([], pack.exports) + self.assertEqual([], pack.group_depends) + self.assertEqual([], pack.member_of_groups) pack = Package(filename='foo', name='bar', version='0.0.0', @@ -65,27 +74,34 @@ def test_init_dependency(self): version_lte=2, version_eq=3, version_gte=4, - version_gt=5) + version_gt=5, + condition='$foo == 23 and $bar != 42') self.assertEqual('foo', dep.name) self.assertEqual(1, dep.version_lt) self.assertEqual(2, dep.version_lte) self.assertEqual(3, dep.version_eq) self.assertEqual(4, dep.version_gte) self.assertEqual(5, dep.version_gt) + self.assertFalse(dep.evaluate_condition({'foo': 23, 'bar': 42})) + self.assertFalse(dep.evaluated_condition) + self.assertTrue(dep.evaluate_condition({'foo': 23, 'bar': 43})) + self.assertTrue(dep.evaluated_condition) self.assertRaises(TypeError, Dependency, 'foo', unknownattribute=42) def test_init_kwargs_string(self): pack = Package(filename='foo', name='bar', package_format='1', - version='0.0.0', + version='1.0.1', + version_compatibility='1.0.0', description='pdesc', licenses=['BSD'], maintainers=[self.get_maintainer()]) self.assertEqual('foo', pack.filename) self.assertEqual('bar', pack.name) self.assertEqual('1', pack.package_format) - self.assertEqual('0.0.0', pack.version) + self.assertEqual('1.0.1', pack.version) + self.assertEqual('1.0.0', pack.version_compatibility) self.assertEqual('pdesc', pack.description) def test_init_kwargs_object(self): @@ -99,7 +115,12 @@ def test_init_kwargs_object(self): mconf = [Mock(), Mock()] mrepl = [Mock(), Mock()] mexp = [Mock(), Mock()] - pack = Package(name='bar', + mgroup = [ + self.get_group_dependency('group1'), + self.get_group_dependency('group2')] + mmember = ['member1', 'member2'] + pack = Package(package_format='3', + name='bar', version='0.0.0', maintainers=mmain, licenses=mlis, @@ -110,6 +131,8 @@ def test_init_kwargs_object(self): test_depends=mtestdep, conflicts=mconf, replaces=mrepl, + group_depends=mgroup, + member_of_groups=mmember, exports=mexp) self.assertEqual(mmain, pack.maintainers) self.assertEqual(mlis, pack.licenses) @@ -121,6 +144,8 @@ def test_init_kwargs_object(self): self.assertEqual(mconf, pack.conflicts) self.assertEqual(mrepl, pack.replaces) self.assertEqual(mexp, pack.exports) + self.assertEqual(mgroup, pack.group_depends) + self.assertEqual(mmember, pack.member_of_groups) def test_validate_package(self): maint = self.get_maintainer()