def _compare(self, message_original, message_update): # 1. If original message is None, then a new message is added. if self.message_original is None: msg = 'A new message {} is added.'.format(self.message_update.name) FindingContainer.addFinding(FindingCategory.MESSAGE_ADDITION, "", msg, False) return # 2. If updated message is None, then the original message is removed. if self.message_update is None: msg = 'A message {} is removed'.format(self.message_original.name) FindingContainer.addFinding(FindingCategory.MESSAGE_REMOVAL, "", msg, True) return # 3. Check breaking changes in each fields. Note: Fields are identified by number, not by name. # Descriptor.fields_by_number (dict int -> FieldDescriptor) indexed by number. if message_original.fields_by_number or message_update.fields_by_number: self._compareNestedFields(message_original.fields_by_number, message_update.fields_by_number) # 4. Check breaking changes in nested message. # Descriptor.nested_types_by_name (dict str -> Descriptor) indexed by name. # Recursively call _compare for nested message type comparison. if (message_original.nested_types_by_name or message_update.nested_types_by_name): self._compareNestedMessages(message_original.nested_types_by_name, message_update.nested_types_by_name)
class Detector: """Detect the breaking changes in the two versions of FileDescriptorSet""" def __init__( self, descriptor_set_original: desc.FileDescriptorSet, descriptor_set_update: desc.FileDescriptorSet, opts: Optional[Options] = None, ): self.descriptor_set_original = descriptor_set_original self.descriptor_set_update = descriptor_set_update self.opts = opts self.finding_container = FindingContainer() def detect_breaking_changes(self): # Init FileSetComparator and compare the two FileDescriptorSet. FileSetComparator( FileSet(self.descriptor_set_original), FileSet(self.descriptor_set_update), self.finding_container, ).compare() if not self.opts: return self.finding_container.getActionableFindings() # Output json file of findings and human-readable messages if the # command line option is enabled. with open(self.opts.output_json_path, "w") as write_json_file: json.dump(self.finding_container.toDictArr(), write_json_file) if self.opts.human_readable_message: sys.stdout.write(self.finding_container.toHumanReadableMessage())
def compare(self): # 1. If original service is None, then a new service is added. if self.service_original is None: FindingContainer.addFinding( category=FindingCategory.SERVICE_ADDITION, location=f"{self.service_update.proto_file_name} Line: {self.service_update.source_code_line}", message=f"A new service {self.service_update.name} is added.", actionable=False, ) return # 2. If updated service is None, then the original service is removed. if self.service_update is None: FindingContainer.addFinding( category=FindingCategory.SERVICE_REMOVAL, location=f"{self.service_original.proto_file_name} Line: {self.service_original.source_code_line}", message=f"A service {self.service_original.name} is removed", actionable=True, ) return self.messages_map_original = self.service_original.messages_map self.messages_map_update = self.service_update.messages_map # 3. Check the methods list self._compareRpcMethods( self.service_original, self.service_update, self.messages_map_original, self.messages_map_update, )
class EnumValueComparatorTest(unittest.TestCase): def setUp(self): L = descriptor_pb2.SourceCodeInfo.Location locations = [L(path=(2, 1), span=(1, 2))] self.enum_foo = make_enum_value( name="FOO", number=1, proto_file_name="test.proto", locations=locations, path=(2, 1), ) self.enum_bar = make_enum_value( name="BAR", number=1, proto_file_name="test_update.proto", locations=locations, path=(2, 1), ) self.finding_container = FindingContainer() def test_enum_value_removal(self): EnumValueComparator( self.enum_foo, None, self.finding_container, ).compare() finding = self.finding_container.getAllFindings()[0] self.assertEqual(finding.message, "An existing EnumValue `FOO` is removed.") self.assertEqual(finding.category.name, "ENUM_VALUE_REMOVAL") self.assertEqual(finding.change_type.name, "MAJOR") self.assertEqual(finding.location.proto_file_name, "test.proto") self.assertEqual(finding.location.source_code_line, 2) def test_enum_value_addition(self): EnumValueComparator(None, self.enum_foo, self.finding_container).compare() finding = self.finding_container.getAllFindings()[0] self.assertEqual(finding.message, "A new EnumValue `FOO` is added.") self.assertEqual(finding.category.name, "ENUM_VALUE_ADDITION") self.assertEqual(finding.change_type.name, "MINOR") self.assertEqual(finding.location.proto_file_name, "test.proto") self.assertEqual(finding.location.source_code_line, 2) def test_name_change(self): EnumValueComparator(self.enum_foo, self.enum_bar, self.finding_container).compare() finding = self.finding_container.getAllFindings()[0] self.assertEqual( finding.message, "Name of the EnumValue is changed from `FOO` to `BAR`.") self.assertEqual(finding.category.name, "ENUM_VALUE_NAME_CHANGE") self.assertEqual(finding.change_type.name, "MAJOR") self.assertEqual(finding.location.proto_file_name, "test_update.proto") self.assertEqual(finding.location.source_code_line, 2) def test_no_api_change(self): EnumValueComparator(self.enum_foo, self.enum_foo, self.finding_container).compare() self.assertEqual(len(self.finding_container.getAllFindings()), 0)
def setUp(self): L = descriptor_pb2.SourceCodeInfo.Location # fmt: off locations = [ L(path=( 4, 0, ), span=(1, 2, 3, 4)), # Enum will add (2, 1) for each EnumValue in the path. L(path=(4, 0, 2, 1), span=(2, 3, 4, 5)), ] self.enum_foo = make_enum( name="Foo", values=(("VALUE1", 1), ), proto_file_name="test.proto", locations=locations, path=( 4, 0, ), ) self.enum_bar = make_enum( name="Bar", values=( ("VALUE1", 1), ("VALUE2", 2), ), proto_file_name="test_update.proto", locations=locations, path=( 4, 0, ), ) self.finding_container = FindingContainer()
def nestedMessageChange(self): # Field `type` in nested message `PhoneNumber` is re-numbered. So it is taken as one field removed and one field added. DescriptorComparator(self.person_msg, self.person_msg_update).compare() findingLength = len(FindingContainer.getAllFindings()) self.assertEqual( FindingContainer.getAllFindings()[findingLength - 1].category.name, 'FIELD_ADDITION') self.assertEqual( FindingContainer.getAllFindings()[findingLength - 2].category.name, 'FIELD_REMOVAL')
def __init__( self, descriptor_set_original: desc.FileDescriptorSet, descriptor_set_update: desc.FileDescriptorSet, opts: Optional[Options] = None, ): self.descriptor_set_original = descriptor_set_original self.descriptor_set_update = descriptor_set_update self.opts = opts self.finding_container = FindingContainer()
def _compare(self, message_original, message_update): # 1. If original message is None, then a new message is added. if message_original is None: FindingContainer.addFinding( category=FindingCategory.MESSAGE_ADDITION, location= f"{message_update.proto_file_name} Line: {message_update.source_code_line}", message=f"A new message {message_update.name} is added.", actionable=False, ) return # 2. If updated message is None, then the original message is removed. if message_update is None: FindingContainer.addFinding( category=FindingCategory.MESSAGE_REMOVAL, location= f"{message_original.proto_file_name} Line: {message_original.source_code_line}", message=f"A message {message_original.name} is removed", actionable=True, ) return self.global_resources_original = self.message_original.file_resources self.global_resources_update = self.message_update.file_resources # 3. Check breaking changes in each fields. Note: Fields are # identified by number, not by name. Descriptor.fields_by_number # (dict int -> FieldDescriptor) indexed by number. if message_original.fields or message_update.fields: self._compare_nested_fields( message_original.fields, message_update.fields, ) # 4. Check breaking changes in nested message. # Descriptor.nested_types_by_name (dict str -> Descriptor) # indexed by name. Recursively call _compare for nested # message type comparison. if message_original.nested_messages or message_update.nested_messages: self._compare_nested_messages( message_original.nested_messages, message_update.nested_messages, ) # 5. Check breaking changes in nested enum. if message_original.nested_enums or message_update.nested_enums: self._compare_nested_enums( message_original.nested_enums, message_update.nested_enums, ) # 6. Check `google.api.resource` annotation. self._compare_resources(message_original.resource, message_update.resource)
def fieldChange(self): DescriptorComparator(self.addressBook_msg, self.addressBook_msg_update).compare() finding = FindingContainer.getAllFindings()[0] self.assertEqual(finding.message, 'The Field deprecated is moved out of one-of') self.assertEqual(finding.category.name, 'FIELD_ONEOF_REMOVAL')
def test_http_annotation_change(self): ServiceComparator( self.service_annotation_original, self.service_annotation_update, ).compare() findings_map = {f.message: f for f in FindingContainer.getAllFindings()} # TODO(xiaozhenliu): This should be removed once we have version updates # support. The URI update from `v1/example:foo` to `v1beta1/example:foo` # is allowed. uri_change_finding = findings_map["An existing http method URI is changed."] method_change_finding = findings_map["An existing http method is changed."] body_change_finding = findings_map["An existing http method body is changed."] self.assertEqual(uri_change_finding.category.name, "HTTP_ANNOTATION_CHANGE") self.assertEqual( uri_change_finding.location.path, "service_annotation_v1beta1.proto Line: 14", ) self.assertEqual(method_change_finding.category.name, "HTTP_ANNOTATION_CHANGE") self.assertEqual( method_change_finding.location.path, "service_annotation_v1beta1.proto Line: 14", ) self.assertEqual(body_change_finding.category.name, "HTTP_ANNOTATION_CHANGE") self.assertEqual( body_change_finding.location.path, "service_annotation_v1beta1.proto Line: 22", )
def test_enum_addition(self): EnumComparator(None, self.enum_update).compare() finding = FindingContainer.getAllFindings()[0] self.assertEqual(finding.message, "A new Enum PhoneType is added.") self.assertEqual(finding.category.name, "ENUM_ADDITION") self.assertEqual(finding.location.path, "message_v1beta1.proto Line: 10")
def test_enum_value_change(self): EnumComparator(self.enum_original, self.enum_update).compare() finding = FindingContainer.getAllFindings()[0] self.assertEqual(finding.message, "A new EnumValue SCHOOL is added.") self.assertEqual(finding.category.name, "ENUM_VALUE_ADDITION") self.assertEqual(finding.location.path, "message_v1beta1.proto Line: 14")
def fieldAddition(self): field_married = update_version.DESCRIPTOR.message_types_by_name[ "Person"].fields_by_name['married'] FieldComparator(None, field_married).compare() finding = FindingContainer.getAllFindings()[0] self.assertEqual(finding.message, 'A new Field married is added.') self.assertEqual(finding.category.name, 'FIELD_ADDITION')
def fieldRemoval(self): field_company_address = update_version.DESCRIPTOR.message_types_by_name[ "Person"].fields_by_name['company_address'] FieldComparator(field_company_address, None).compare() finding = FindingContainer.getAllFindings()[0] self.assertEqual(finding.message, 'A Field company_address is removed') self.assertEqual(finding.category.name, 'FIELD_REMOVAL')
def test_resource_reference_change(self): _INVOKER_ORIGNAL = UnittestInvoker( ["resource_reference_v1.proto"], "resource_reference_v1_descriptor_set.pb", True, ) _INVOKER_UPDATE = UnittestInvoker( ["resource_reference_v1beta1.proto"], "resource_reference_v1beta1_descriptor_set.pb", True, ) FileSetComparator(FileSet(_INVOKER_ORIGNAL.run()), FileSet(_INVOKER_UPDATE.run())).compare() findings_map = { f.message: f for f in FindingContainer.getAllFindings() } self.assertEqual( findings_map[ "The child_type 'example.googleapis.com/t1' and type 'example.googleapis.com/t1' of resource reference option in field 'topic' cannot be resolved to the identical resource."] .category.name, "RESOURCE_REFERENCE_CHANGE", ) _INVOKER_ORIGNAL.cleanup() _INVOKER_UPDATE.cleanup()
def enumNameChange(self): EnumComparator(self.enum_original, self.enum_update).compare() finding = FindingContainer.getAllFindings()[0] self.assertEqual( finding.message, 'Name of the Enum is changed, the original is PhoneType, but the updated is PhoneTypeUpdate' ) self.assertEqual(finding.category.name, 'ENUM_NAME_CHANGE')
class FindingContainerTest(unittest.TestCase): # The unit tests are executed in alphabetical order by test name # automatically, so test_reset() will be run in the middle which will break us. # This is a monolithic test, so we give numbers to indicate # the steps and also ensure the execution orders. finding_container = FindingContainer() def test1_add_findings(self): self.finding_container.add_finding( category=FindingCategory.METHOD_REMOVAL, proto_file_name="my_proto.proto", source_code_line=12, change_type=ChangeType.MAJOR, subject="subject", context="context", ) self.assertEqual(len(self.finding_container.get_all_findings()), 1) def test2_get_actionable_findings(self): self.finding_container.add_finding( category=FindingCategory.FIELD_ADDITION, proto_file_name="my_proto.proto", source_code_line=15, change_type=ChangeType.MINOR, ) self.assertEqual(len(self.finding_container.get_actionable_findings()), 1) def test3_to_dict_arr(self): dict_arr_output = self.finding_container.to_dict_arr() self.assertEqual(dict_arr_output[0]["category"], "METHOD_REMOVAL") self.assertEqual(dict_arr_output[1]["category"], "FIELD_ADDITION") def test4_to_human_readable_message(self): self.finding_container.add_finding( category=FindingCategory.RESOURCE_DEFINITION_REMOVAL, proto_file_name="my_proto.proto", source_code_line=5, change_type=ChangeType.MAJOR, subject="subject", ) self.finding_container.add_finding( category=FindingCategory.METHOD_SIGNATURE_REMOVAL, proto_file_name="my_other_proto.proto", source_code_line=-1, change_type=ChangeType.MAJOR, type="type", subject="subject", context="context", ) self.assertEqual( self.finding_container.to_human_readable_message(), "my_other_proto.proto: An existing method_signature `type` is removed from method `subject` in service `context`.\n" + "my_proto.proto L5: An existing resource_definition `subject` is removed.\n" + "my_proto.proto L12: An existing method `subject` is removed from service `context`.\n", )
def test_service_removal(self): ServiceComparator( self.service_original, None, ).compare() finding = FindingContainer.getAllFindings()[0] self.assertEqual(finding.message, "A service Example is removed") self.assertEqual(finding.category.name, "SERVICE_REMOVAL") self.assertEqual(finding.location.path, "service_v1.proto Line: 5")
def setUp(self): L = descriptor_pb2.SourceCodeInfo.Location locations = [L(path=(2, 1), span=(1, 2))] self.enum_foo = make_enum_value( name="FOO", number=1, proto_file_name="test.proto", locations=locations, path=(2, 1), ) self.enum_bar = make_enum_value( name="BAR", number=1, proto_file_name="test_update.proto", locations=locations, path=(2, 1), ) self.finding_container = FindingContainer()
def enumValueNameChange(self): EnumValueComparator(self.enumValue_mobile, self.enumValue_home).compare() finding = FindingContainer.getAllFindings()[0] self.assertEqual( finding.message, 'Name of the EnumValue is changed, the original is MOBILE, but the updated is HOME' ) self.assertEqual(finding.category.name, 'ENUM_VALUE_NAME_CHANGE')
def _compare_method_signatures(self, method_original, method_update): signatures_original = method_original.method_signatures.value signatures_update = method_update.method_signatures.value if len(signatures_original) > len(signatures_update): FindingContainer.addFinding( category=FindingCategory.METHOD_SIGNATURE_CHANGE, location=f"{method_original.proto_file_name} Line: {method_original.method_signatures.source_code_line}", message="An existing method_signature is removed.", actionable=True, ) for old_sig, new_sig in zip(signatures_original, signatures_update): if old_sig != new_sig: FindingContainer.addFinding( category=FindingCategory.METHOD_SIGNATURE_CHANGE, location=f"{method_update.proto_file_name} Line: {method_update.method_signatures.source_code_line}", message=f"An existing method_signature is changed from '{old_sig}' to '{new_sig}'.", actionable=True, )
def test_service_addition(self): ServiceComparator( None, self.service_original, ).compare() finding = FindingContainer.getAllFindings()[0] self.assertEqual(finding.message, "A new service Example is added.") self.assertEqual(finding.category.name, "SERVICE_ADDITION") self.assertEqual(finding.location.path, "service_v1.proto Line: 5")
def compare(self): # 1. If original EnumDescriptor is None, then a new # EnumDescriptor is added. if self.enum_original is None: FindingContainer.addFinding( category=FindingCategory.ENUM_ADDITION, location= f"{self.enum_update.proto_file_name} Line: {self.enum_update.source_code_line}", message=f"A new Enum {self.enum_update.name} is added.", actionable=False, ) # 2. If updated EnumDescriptor is None, then the original # EnumDescriptor is removed. elif self.enum_update is None: FindingContainer.addFinding( category=FindingCategory.ENUM_REMOVAL, location= f"{self.enum_original.proto_file_name} Line: {self.enum_original.source_code_line}", message=f"An Enum {self.enum_original.name} is removed", actionable=True, ) # 3. If the EnumDescriptors have the same name, check the values # of them stay the same. Enum values are identified by number, # not by name. else: enum_values_dict_original = self.enum_original.values enum_values_dict_update = self.enum_update.values enum_values_keys_set_original = set( enum_values_dict_original.keys()) enum_values_keys_set_update = set(enum_values_dict_update.keys()) # Compare Enum values that only exist in original version for number in enum_values_keys_set_original - enum_values_keys_set_update: EnumValueComparator(enum_values_dict_original[number], None).compare() # Compare Enum values that only exist in update version for number in enum_values_keys_set_update - enum_values_keys_set_original: EnumValueComparator(None, enum_values_dict_update[number]).compare() # Compare Enum values that exist both in original and update versions for number in enum_values_keys_set_original & enum_values_keys_set_update: EnumValueComparator(enum_values_dict_original[number], enum_values_dict_update[number]).compare()
def compare(self): # 1. If original service is None, then a new service is added. if self.service_original is None: msg = 'A new service {} is added.'.format(self.service_update.name) FindingContainer.addFinding(FindingCategory.SERVICE_ADDITION, "", msg, False) return # 2. If updated service is None, then the original service is removed. if self.service_update is None: msg = 'A service {} is removed'.format(self.service_original.name) FindingContainer.addFinding(FindingCategory.SERVICE_REMOVAL, "", msg, True) return # 3. TODO(xiaozhenliu): method_signature annotation # 4. TODO(xiaozhenliu): LRO operation_info annotation # 5. TODO(xiaozhenliu): google.api.http annotation # 6. Check the methods list self._compareRpcMethods(self.service_original, self.service_update)
def moveExistingFieldOutofOneof(self): field_email_original = original_version.DESCRIPTOR.message_types_by_name[ "AddressBook"].fields_by_name['deprecated'] field_email_update = update_version.DESCRIPTOR.message_types_by_name[ "AddressBook"].fields_by_name['deprecated'] FieldComparator(field_email_original, field_email_update).compare() finding = FindingContainer.getAllFindings()[0] self.assertEqual(finding.message, 'The Field deprecated is moved out of one-of') self.assertEqual(finding.category.name, 'FIELD_ONEOF_REMOVAL')
def moveExistingFieldIntoOneof(self): field_email_original = original_version.DESCRIPTOR.message_types_by_name[ "Person"].fields_by_name['home_address'] field_email_update = update_version.DESCRIPTOR.message_types_by_name[ "Person"].fields_by_name['home_address'] FieldComparator(field_email_original, field_email_update).compare() finding = FindingContainer.getAllFindings()[0] self.assertEqual(finding.message, 'The Field home_address is moved into one-of') self.assertEqual(finding.category.name, 'FIELD_ONEOF_ADDITION')
class FindingContainerTest(unittest.TestCase): # The unit tests are executed in alphabetical order by test name # automatically, so test_reset() will be run in the middle which will break us. # This is a monolithic test, so we give numbers to indicate # the steps and also ensure the execution orders. finding_container = FindingContainer() def test1_add_findings(self): self.finding_container.addFinding( category=FindingCategory.METHOD_REMOVAL, proto_file_name="my_proto.proto", source_code_line=12, message="An rpc method bar is removed.", change_type=ChangeType.MAJOR, ) self.assertEqual(len(self.finding_container.getAllFindings()), 1) def test2_get_actionable_findings(self): self.finding_container.addFinding( category=FindingCategory.FIELD_ADDITION, proto_file_name="my_proto.proto", source_code_line=15, message="Not breaking change.", change_type=ChangeType.MINOR, ) self.assertEqual(len(self.finding_container.getActionableFindings()), 1) def test3_toDictArr(self): dict_arr_output = self.finding_container.toDictArr() self.assertEqual(dict_arr_output[0]["category"], "METHOD_REMOVAL") self.assertEqual(dict_arr_output[1]["category"], "FIELD_ADDITION") def test4_toHumanReadableMessage(self): self.finding_container.addFinding( category=FindingCategory.RESOURCE_DEFINITION_CHANGE, proto_file_name="my_proto.proto", source_code_line=5, message="An existing file-level resource definition has changed.", change_type=ChangeType.MAJOR, ) self.finding_container.addFinding( category=FindingCategory.METHOD_SIGNATURE_CHANGE, proto_file_name="my_other_proto.proto", source_code_line=-1, message= "An existing method_signature is changed from 'sig1' to 'sig2'.", change_type=ChangeType.MAJOR, ) self.assertEqual( self.finding_container.toHumanReadableMessage(), "my_proto.proto L5: An existing file-level resource definition has changed.\n" + "my_proto.proto L12: An rpc method bar is removed.\n" + "my_other_proto.proto: An existing method_signature is changed from 'sig1' to 'sig2'.\n", )
def test_name_change(self): EnumValueComparator(self.enumValue_mobile, self.enumValue_home).compare() finding = FindingContainer.getAllFindings()[0] self.assertEqual( finding.message, "Name of the EnumValue is changed, the original " "is MOBILE, but the updated is HOME", ) self.assertEqual(finding.category.name, "ENUM_VALUE_NAME_CHANGE") self.assertEqual(finding.location.path, "message_v1.proto Line: 12")
def test_type_change(self): # Field `id` is `int32` type in `message_v1.proto`, # but updated to `string` in `message_v1beta1.proto`. FieldComparator(self.person_fields_v1[2], self.person_fields_v1beta1[2]).compare() finding = FindingContainer.getAllFindings()[0] self.assertEqual( finding.message, "Type of the field is changed, the original is TYPE_INT32," " but the updated is TYPE_STRING", ) self.assertEqual(finding.category.name, "FIELD_TYPE_CHANGE")
def test_repeated_label_change(self): # Field `phones` in `message_v1.proto` has `repeated` label, # but it's removed in the `message_v1beta1.proto`. FieldComparator(self.person_fields_v1[4], self.person_fields_v1beta1[4]).compare() finding = FindingContainer.getAllFindings()[0] self.assertEqual( finding.message, "Repeated state of the Field is changed, the original is LABEL_REPEATED," " but the updated is LABEL_OPTIONAL", ) self.assertEqual(finding.category.name, "FIELD_REPEATED_CHANGE")