Source code for dbusdeviation.interfacecomparator

# -*- coding: utf-8 -*-
#
# Copyright © 2015 Collabora Ltd.
#
# This library 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.1 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 <http://www.gnu.org/licenses/>.

"""
Module providing a `InterfaceComparator` object for comparing two D-Bus APIs
(provided as abstract syntax trees from the introspection XML), to determine if
they differ in API-incompatible ways.
"""

from dbusapi import ast


# Warning categories.
WARNING_CATEGORIES = [
    'info',
    'backwards-compatibility',
    'forwards-compatibility',
]


[docs]class InterfaceComparator(object): """ Compare two D-Bus interface descriptions and determine how they differ. Differences are given different severity levels, depending on whether they affect * nothing, and are purely decorative; for example, changing the name of a method argument * forwards compatibility, where code written against the new interface may not work against the old interface; for example, because it uses a newly added method * backwards compatibility, where code written against the old interface may not work against the new interface; for example, because it changes the type of a property """ # Output severity levels. OUTPUT_INFO = 'info' OUTPUT_FORWARDS_INCOMPATIBLE = 'forwards-compatibility' OUTPUT_BACKWARDS_INCOMPATIBLE = 'backwards-compatibility' def __init__(self, old_interfaces, new_interfaces, enabled_warnings=None, disabled_warnings=None, new_filename=None): """ Construct a new InterfaceComparator. Args: old_interfaces: non-empty dict of old interfaces, mapping interface name to an ast.Interface instance new_interfaces: non-empty dict of new interfaces, mapping interface name to an ast.Interface instance enabled_warnings: potentially empty list of warning categories and codes to enable disabled_warnings: potentially empty list of warning categories and codes to disable new_filename: path to the new D-Bus interface file, or None if unknown """ self._old_interfaces = old_interfaces self._new_interfaces = new_interfaces self._new_filename = new_filename self._output = [] if enabled_warnings is not None: self._enabled_warnings = enabled_warnings else: self._enabled_warnings = WARNING_CATEGORIES if disabled_warnings is not None: self._disabled_warnings = disabled_warnings else: self._disabled_warnings = [] @staticmethod
[docs] def get_output_codes(): """Return a list of all possible output codes.""" # FIXME: Hard-coded for the moment. return [ 'interface-added', 'interface-removed', 'deprecated', 'undeprecated' 'c-symbol-changed', 'reply-added', 'reply-removed', 'ecs-changed-true-invalidates', 'ecs-changed-true-false', 'ecs-changed-true-const', 'ecs-changed-invalidates-true', 'ecs-changed-invalidates-false', 'ecs-changed-invalidates-const', 'ecs-changed-false-invalidates', 'ecs-changed-false-true', 'ecs-changed-false-const', 'ecs-changed-const-invalidates', 'ecs-changed-const-true', 'ecs-changed-const-false', 'method-added', 'method-removed', 'property-added', 'property-removed', 'signal-added', 'signal-removed', 'argument-added', 'argument-removed', 'property-type-changed', 'property-access-changed-read-readwrite', 'property-access-changed-read-write', 'property-access-changed-write-read', 'property-access-changed-write-readwrite', 'property-access-changed-readwrite-read', 'property-access-changed-readwrite-write', 'argument-name-changed', 'argument-type-changed', 'argument-direction-changed-in-out', 'argument-direction-changed-out-in', ]
def _issue_output(self, level, code, message): """Append a message to the comparator output.""" self._output.append((self._new_filename, level, code, message)) def _warning_enabled(self, level, code): """Determine whether the given output level is enabled for output.""" return ((level in self._enabled_warnings and level not in self._disabled_warnings and code not in self._disabled_warnings) or (code in self._enabled_warnings and code not in self._disabled_warnings))
[docs] def get_output(self): """ Return all the log messages generated by the latest call to compare(). Disabled warnings will not be returned. """ out = [] for (filename, level, code, message) in self._output: if not self._warning_enabled(level, code): continue out.append((filename, level, code, message)) return out
[docs] def compare(self): """ Compare the two interfaces and store the results. Returns: The list of relevant warnings to output; an empty list otherwise. The return value is affected by the categories of enabled warnings. """ self._output = [] for (name, interface) in self._old_interfaces.items(): # See if the old interface exists in the new file. if name not in self._new_interfaces: self._issue_output(self.OUTPUT_BACKWARDS_INCOMPATIBLE, 'interface-removed', 'Interface ‘%s’ has been removed.' % name) else: # Compare the two. self._compare_interfaces(interface, self._new_interfaces[name]) for (name, interface) in self._new_interfaces.items(): # See if the new interface exists in the old file. if name not in self._old_interfaces: self._issue_output(self.OUTPUT_FORWARDS_INCOMPATIBLE, 'interface-added', 'Interface ‘%s’ has been added.' % name) # Work out the exit status. return self.get_output()
# pylint: disable=too-many-branches def _compare_annotations(self, old_node, new_node): # noqa """Compare two ast.Annotation instances.""" def _get_string_annotation(node, annotation_name, default): """ Get an annotation value as a string. Reference: http://goo.gl/3EtdNf Returns: The value of the `annotation_name` annotation as a string, or `default` if no annotation exists by that name. """ if annotation_name in node.annotations: return node.annotations[annotation_name].value return default def _get_bool_annotation(node, annotation_name, default): """ Get an annotation value as a boolean. Reference: http://goo.gl/3EtdNf Returns: The value of the `annotation_name` annotation as a boolean, or `default` if no annotation exists by that name. """ if annotation_name in node.annotations: return node.annotations[annotation_name].value == 'true' return default def _get_ecs_annotation(node): """ Get the value of the EmitsChangedSignal annotation. Reference: http://goo.gl/3EtdNf Returns: The value of the `org.freedesktop.DBus.Property.EmitsChangedSignal` annotation, if it exists, or the default, calculated as per the specification. """ name = 'org.freedesktop.DBus.Property.EmitsChangedSignal' if name in node.annotations: return node.annotations[name].value elif isinstance(node, ast.Property): assert node.interface is not None return _get_ecs_annotation(node.interface) else: return 'true' old_deprecated = \ _get_bool_annotation(old_node, 'org.freedesktop.DBus.Deprecated', False) new_deprecated = \ _get_bool_annotation(new_node, 'org.freedesktop.DBus.Deprecated', False) if old_deprecated and not new_deprecated: self._issue_output(self.OUTPUT_INFO, 'undeprecated', 'Node ‘%s’ has been un-deprecated.' % old_node.format_name()) elif not old_deprecated and new_deprecated: self._issue_output(self.OUTPUT_INFO, 'deprecated', 'Node ‘%s’ has been deprecated.' % old_node.format_name()) old_c_symbol = \ _get_string_annotation(old_node, 'org.freedesktop.DBus.GLib.CSymbol', '') new_c_symbol = \ _get_string_annotation(new_node, 'org.freedesktop.DBus.GLib.CSymbol', '') if old_c_symbol != new_c_symbol: self._issue_output(self.OUTPUT_INFO, 'c-symbol-changed', 'Node ‘%s’ has changed its C symbol from ‘%s’ ' 'to ‘%s’.' % (old_node.format_name(), old_c_symbol, new_c_symbol)) old_no_reply = \ _get_bool_annotation(old_node, 'org.freedesktop.DBus.Method.NoReply', False) new_no_reply = \ _get_bool_annotation(new_node, 'org.freedesktop.DBus.Method.NoReply', False) if old_no_reply and not new_no_reply: self._issue_output(self.OUTPUT_BACKWARDS_INCOMPATIBLE, 'reply-added', 'Node ‘%s’ has been marked as returning a ' 'reply.' % old_node.format_name()) elif not old_no_reply and new_no_reply: self._issue_output(self.OUTPUT_BACKWARDS_INCOMPATIBLE, 'reply-removed', 'Node ‘%s’ has been marked as not returning a ' 'reply.' % old_node.format_name()) old_ecs = _get_ecs_annotation(old_node) new_ecs = _get_ecs_annotation(new_node) output_code = 'ecs-changed-%s-%s' % (old_ecs, new_ecs) # New # | true | invalidates | const | false # | true | xxxx | B2 | F3 | F3 # Old | invalidates | B2 | xxxxxxxxxxx | F3 | F3 # | const | B1 | B1 | xxxxx | B4 # | false | B1 | B1 | F4 | xxxxx # # B = Backwards-compatible; F = Forwards-compatible # 1 = Started notifying # 2 = Property switched lists in PropertiesChanged # 3 = Stopped notifying # 4 = const semantics changed if old_ecs in ['true', 'invalidates'] and \ new_ecs in ['false', 'const']: self._issue_output(self.OUTPUT_FORWARDS_INCOMPATIBLE, output_code, 'Node ‘%s’ stopped emitting ' 'org.freedesktop.DBus.Properties.' 'PropertiesChanged.' % old_node.format_name()) elif (old_ecs in ['false', 'const'] and new_ecs in ['true', 'invalidates']): self._issue_output(self.OUTPUT_BACKWARDS_INCOMPATIBLE, output_code, 'Node ‘%s’ started emitting ' 'org.freedesktop.DBus.Properties.' 'PropertiesChanged.' % old_node.format_name()) elif old_ecs == 'true' and new_ecs == 'invalidates': self._issue_output(self.OUTPUT_BACKWARDS_INCOMPATIBLE, output_code, 'Node ‘%s’ stopped emitting its new value in ' 'org.freedesktop.DBus.Properties.' 'PropertiesChanged.' % old_node.format_name()) elif old_ecs == 'invalidates' and new_ecs == 'true': self._issue_output(self.OUTPUT_BACKWARDS_INCOMPATIBLE, output_code, 'Node ‘%s’ started emitting its new value in ' 'org.freedesktop.DBus.Properties.' 'PropertiesChanged.' % old_node.format_name()) elif old_ecs == 'const' and new_ecs == 'false': self._issue_output(self.OUTPUT_BACKWARDS_INCOMPATIBLE, output_code, 'Node ‘%s’ stopped being a constant.' % old_node.format_name()) elif old_ecs == 'false' and new_ecs == 'const': self._issue_output(self.OUTPUT_FORWARDS_INCOMPATIBLE, output_code, 'Node ‘%s’ became a constant.' % old_node.format_name()) # pylint: disable=too-many-branches def _compare_interfaces(self, old_interface, new_interface): # noqa """Compare two ast.Interface instances.""" # Precondition of calling this method. assert old_interface.name == new_interface.name # Compare methods. for (name, method) in old_interface.methods.items(): if name not in new_interface.methods: self._issue_output(self.OUTPUT_BACKWARDS_INCOMPATIBLE, 'method-removed', 'Method ‘%s’ has been removed.' % method.format_name()) else: self._compare_methods(method, new_interface.methods[name]) for (name, method) in new_interface.methods.items(): if name not in old_interface.methods: self._issue_output(self.OUTPUT_FORWARDS_INCOMPATIBLE, 'method-added', 'Method ‘%s’ has been added.' % method.format_name()) # Compare properties for (name, prop) in old_interface.properties.items(): if name not in new_interface.properties: self._issue_output(self.OUTPUT_BACKWARDS_INCOMPATIBLE, 'property-removed', 'Property ‘%s’ has been removed.' % prop.format_name()) else: self._compare_properties(prop, new_interface.properties[name]) for (name, prop) in new_interface.properties.items(): if name not in old_interface.properties: self._issue_output(self.OUTPUT_FORWARDS_INCOMPATIBLE, 'property-added', 'Property ‘%s’ has been added.' % prop.format_name()) # Compare signals for (name, signal) in old_interface.signals.items(): if name not in new_interface.signals: self._issue_output(self.OUTPUT_BACKWARDS_INCOMPATIBLE, 'signal-removed', 'Signal ‘%s’ has been removed.' % signal.format_name()) else: self._compare_signals(signal, new_interface.signals[name]) for (name, signal) in new_interface.signals.items(): if name not in old_interface.signals: self._issue_output(self.OUTPUT_FORWARDS_INCOMPATIBLE, 'signal-added', 'Signal ‘%s’ has been added.' % signal.format_name()) # Compare annotations self._compare_annotations(old_interface, new_interface) def _compare_methods(self, old_method, new_method): """Compare two ast.Method instances.""" # Precondition of calling this method. assert old_method.name == new_method.name # Compare the argument lists. n_old_args = len(old_method.arguments) n_new_args = len(new_method.arguments) for i in range(max(n_old_args, n_new_args)): if i >= n_old_args: self._issue_output(self.OUTPUT_BACKWARDS_INCOMPATIBLE, 'argument-added', 'Argument %s ' 'has been added.' % new_method.arguments[i].format_name()) elif i >= n_new_args: self._issue_output(self.OUTPUT_BACKWARDS_INCOMPATIBLE, 'argument-removed', 'Argument %s ' 'has been removed.' % old_method.arguments[i].format_name()) else: self._compare_arguments(old_method.arguments[i], new_method.arguments[i]) # Compare annotations self._compare_annotations(old_method, new_method) def _compare_properties(self, old_property, new_property): """Compare two ast.Property instances.""" # Precondition of calling this method. assert old_property.name == new_property.name if old_property.type != new_property.type: self._issue_output(self.OUTPUT_BACKWARDS_INCOMPATIBLE, 'property-type-changed', 'Property ‘%s’ has changed type from ‘%s’ ' 'to ‘%s’.' % (old_property.format_name(), old_property.type, new_property.type)) error_code = 'property-access-changed-%s-%s' % \ (old_property.access, new_property.access) if (old_property.access == ast.Property.ACCESS_READ or old_property.access == ast.Property.ACCESS_WRITE) and \ new_property.access == ast.Property.ACCESS_READWRITE: # Property has become less restrictive. self._issue_output(self.OUTPUT_FORWARDS_INCOMPATIBLE, error_code, 'Property ‘%s’ has changed access from ' '‘%s’ to ‘%s’, becoming less restrictive.' % (old_property.format_name(), old_property.access, new_property.access)) elif old_property.access != new_property.access: # Access has changed incompatibly. self._issue_output(self.OUTPUT_BACKWARDS_INCOMPATIBLE, error_code, 'Property ‘%s’ has changed access from ' '‘%s’ to ‘%s’.' % (old_property.format_name(), old_property.access, new_property.access)) # Compare annotations self._compare_annotations(old_property, new_property) def _compare_signals(self, old_signal, new_signal): """Compare two ast.Signal instances.""" # Precondition of calling this method. assert old_signal.name == new_signal.name # Compare the argument lists. n_old_args = len(old_signal.arguments) n_new_args = len(new_signal.arguments) for i in range(max(n_old_args, n_new_args)): if i >= n_old_args: self._issue_output(self.OUTPUT_BACKWARDS_INCOMPATIBLE, 'argument-added', 'Argument %s ' 'has been added.' % new_signal.arguments[i].format_name()) elif i >= n_new_args: self._issue_output(self.OUTPUT_BACKWARDS_INCOMPATIBLE, 'argument-removed', 'Argument %s ' 'has been removed.' % old_signal.arguments[i].format_name()) else: self._compare_arguments(old_signal.arguments[i], new_signal.arguments[i]) # Compare annotations self._compare_annotations(old_signal, new_signal) def _compare_arguments(self, old_arg, new_arg): """Compare two ast.Argument instances.""" if old_arg.name != new_arg.name: self._issue_output(self.OUTPUT_INFO, 'argument-name-changed', 'Argument %s has changed ' 'name from ‘%s’ to ‘%s’.' % (old_arg.pretty_name, old_arg.name, new_arg.name)) if old_arg.type != new_arg.type: self._issue_output(self.OUTPUT_BACKWARDS_INCOMPATIBLE, 'argument-type-changed', 'Argument %s has changed ' 'type from ‘%s’ to ‘%s’.' % (old_arg.pretty_name, old_arg.type, new_arg.type)) if old_arg.direction != new_arg.direction: self._issue_output(self.OUTPUT_BACKWARDS_INCOMPATIBLE, 'argument-direction-changed-%s-%s' % (old_arg.direction, new_arg.direction), 'Argument %s has changed ' 'direction from ‘%s’ to ‘%s’.' % (old_arg.pretty_name, old_arg.direction, new_arg.direction)) # Compare annotations self._compare_annotations(old_arg, new_arg)