diff --git a/plugin/openassetio_manager_bal/BasicAssetLibraryInterface.py b/plugin/openassetio_manager_bal/BasicAssetLibraryInterface.py index d87867c..93fa5dc 100644 --- a/plugin/openassetio_manager_bal/BasicAssetLibraryInterface.py +++ b/plugin/openassetio_manager_bal/BasicAssetLibraryInterface.py @@ -149,6 +149,7 @@ def hasCapability(self, capability): ManagerInterface.Capability.kPublishing, ManagerInterface.Capability.kRelationshipQueries, ManagerInterface.Capability.kExistenceQueries, + ManagerInterface.Capability.kDefaultEntityReferences, ): return True @@ -233,6 +234,41 @@ def managementPolicy(self, traitSets, access, context, hostSession): def isEntityReferenceString(self, someString, hostSession): return someString.startswith(self.__entity_refrence_prefix()) + @simulated_delay + def defaultEntityReference( + self, traitSets, defaultEntityAccess, context, hostSession, successCallback, errorCallback + ): + if not self.hasCapability(self.Capability.kDefaultEntityReferences): + super().defaultEntityReference( + traitSets, + defaultEntityAccess, + context, + hostSession, + successCallback, + errorCallback, + ) + return + + for idx, trait_set in enumerate(traitSets): + try: + entity_name = bal.default_entity( + trait_set, kAccessNames[defaultEntityAccess], self.__library + ) + entity_ref = None + # Entity can legitimately be None, meaning query was OK + # but there is no suitable default. + if entity_name is not None: + entity_ref = self.__build_entity_ref( + bal.EntityInfo( + name=entity_name, + access=kAccessNames[defaultEntityAccess], + version=None, + ) + ) + successCallback(idx, entity_ref) + except Exception as exc: # pylint: disable=broad-except + self.__handle_exception(exc, idx, errorCallback) + @simulated_delay def entityExists(self, entityRefs, context, _hostSession, successCallback, errorCallback): if not self.hasCapability(self.Capability.kExistenceQueries): @@ -752,6 +788,8 @@ def __handle_exception(exc, idx, error_callback): code = BatchElementError.ErrorCode.kEntityResolutionError elif isinstance(exc, bal.InaccessibleEntity): code = BatchElementError.ErrorCode.kEntityAccessError + elif isinstance(exc, bal.UnknownTraitSet): + code = BatchElementError.ErrorCode.kInvalidTraitSet else: raise exc diff --git a/plugin/openassetio_manager_bal/bal.py b/plugin/openassetio_manager_bal/bal.py index d0da7d6..921bb68 100644 --- a/plugin/openassetio_manager_bal/bal.py +++ b/plugin/openassetio_manager_bal/bal.py @@ -112,6 +112,19 @@ def exists(entity_info: EntityInfo, library: dict) -> bool: return True +def default_entity(trait_set: Set[str], access: str, library: dict) -> str: + """ + Retrieves the default entity for the supplied trait set and access + mode, if one exists in the library, otherwise raises an exception. + """ + default_entities_for_access = library.get("defaultEntities", {}).get(access, []) + # Find the first default entity that matches the trait set + for default_entity_for_trait_set in default_entities_for_access: + if set(default_entity_for_trait_set["traits"]) == trait_set: + return default_entity_for_trait_set["entity"] + raise UnknownTraitSet(trait_set) + + def entity(entity_info: EntityInfo, library: dict) -> Entity: """ Retrieves the Entity data addressed by the supplied EntityInfo @@ -412,3 +425,12 @@ class InaccessibleEntity(RuntimeError): def __init__(self, entity_info: EntityInfo): super().__init__(f"Entity '{entity_info.name}' is inaccessible for {entity_info.access}") + + +class UnknownTraitSet(RuntimeError): + """ + An exception raised when BAL doesn't understand a given trait set. + """ + + def __init__(self, trait_set: Set[str]): + super().__init__(f"Unknown trait set {trait_set}") diff --git a/schema.json b/schema.json index cd39fa8..f25e836 100644 --- a/schema.json +++ b/schema.json @@ -359,6 +359,75 @@ "required": ["read", "write"], "additionalProperties": false }, + "defaultEntities": { + "description": "A mapping of intended access and trait set to appropriate default entity", + "type": "object", + "properties": { + "read": { + "type": "array", + "items": { + "type": "object", + "properties": { + "traits": { + "type": "array", + "items": { + "type": "string" + } + }, + "entity": { + "type": ["string", "null"] + } + }, + "required": [ + "traits", + "entity" + ] + } + }, + "write": { + "type": "array", + "items": { + "type": "object", + "properties": { + "traits": { + "type": "array", + "items": { + "type": "string" + } + }, + "entity": { + "type": ["string", "null"] + } + }, + "required": [ + "traits", + "entity" + ] + } + }, + "createRelated": { + "type": "array", + "items": { + "type": "object", + "properties": { + "traits": { + "type": "array", + "items": { + "type": "string" + } + }, + "entity": { + "type": ["string", "null"] + } + }, + "required": [ + "traits", + "entity" + ] + } + } + } + }, "entities": { "description": "The entities in the library, they key is used as the entity name.", "type": "object", diff --git a/tests/bal_business_logic_suite.py b/tests/bal_business_logic_suite.py index 93cd4a4..12668a6 100644 --- a/tests/bal_business_logic_suite.py +++ b/tests/bal_business_logic_suite.py @@ -380,12 +380,12 @@ class Test_hasCapability_default(FixtureAugmentedTestCase): def test_when_hasCapability_called_then_expected_capabilities_reported(self): self.assertFalse(self._manager.hasCapability(Manager.Capability.kStatefulContexts)) self.assertFalse(self._manager.hasCapability(Manager.Capability.kCustomTerminology)) - self.assertFalse(self._manager.hasCapability(Manager.Capability.kDefaultEntityReferences)) self.assertTrue(self._manager.hasCapability(Manager.Capability.kResolution)) self.assertTrue(self._manager.hasCapability(Manager.Capability.kPublishing)) self.assertTrue(self._manager.hasCapability(Manager.Capability.kRelationshipQueries)) self.assertTrue(self._manager.hasCapability(Manager.Capability.kExistenceQueries)) + self.assertTrue(self._manager.hasCapability(Manager.Capability.kDefaultEntityReferences)) def test_when_hasCapability_called_on_managerInterface_then_has_mandatory_capabilities(self): interface = BasicAssetLibraryInterface() @@ -615,6 +615,86 @@ def test_when_read_only_entity_then_EntityAccessError_returned(self): self.assertEqual(actual_result, expected_result) +class Test_defaultEntityReference(FixtureAugmentedTestCase): + """ + Tests for the defaultEntityReference method. + + Uses the `defaultEntities` entry in library_apiComplianceSuite.json. + """ + + def test_when_read_trait_set_known_then_expected_reference_returned(self): + expected = [ + self._manager.createEntityReference("bal:///a_default_read_entity_for_a_and_b"), + self._manager.createEntityReference("bal:///a_default_read_entity_for_b_and_c"), + ] + access = DefaultEntityAccess.kRead + + self.assert_expected_entity_refs_for_access(expected, access) + + def test_when_write_trait_set_known_then_expected_reference_returned(self): + expected = [ + self._manager.createEntityReference("bal:///a_default_write_entity_for_a_and_b"), + self._manager.createEntityReference("bal:///a_default_write_entity_for_b_and_c"), + ] + access = DefaultEntityAccess.kWrite + + self.assert_expected_entity_refs_for_access(expected, access) + + def test_when_createRelated_trait_set_known_then_expected_reference_returned(self): + expected = [ + self._manager.createEntityReference("bal:///a_default_relatable_entity_for_a_and_b"), + self._manager.createEntityReference("bal:///a_default_relatable_entity_for_b_and_c"), + ] + access = DefaultEntityAccess.kCreateRelated + + self.assert_expected_entity_refs_for_access(expected, access) + + def test_when_no_default_then_entity_ref_is_None(self): + results = [0] # Don't initialise to None because that's the value we expect. + + self._manager.defaultEntityReference( + [{"c", "d"}], + DefaultEntityAccess.kRead, + self.createTestContext(), + lambda idx, value: operator.setitem(results, idx, value), + lambda idx, error: self.fail("defaultEntityReference should not fail"), + ) + + [actual] = results + + self.assertIsNone(actual) + + def test_when_trait_set_not_known_then_InvalidTraitSet_error(self): + results = [None] + + self._manager.defaultEntityReference( + [{"a", "b", "c"}], + DefaultEntityAccess.kRead, + self.createTestContext(), + lambda idx, value: self.fail("defaultEntityReference should not succeed"), + lambda idx, error: operator.setitem(results, idx, error), + ) + + [actual] = results + + self.assertIsInstance(actual, BatchElementError) + self.assertEqual(actual.code, BatchElementError.ErrorCode.kInvalidTraitSet) + self.assertRegex(actual.message, r"^Unknown trait set {'[abc]', '[abc]', '[abc]'}") + + def assert_expected_entity_refs_for_access(self, expected, access): + actual = [None, None] + + self._manager.defaultEntityReference( + [{"a", "b"}, {"b", "c"}], + access, + self.createTestContext(), + lambda idx, value: operator.setitem(actual, idx, value), + lambda idx, error: self.fail("defaultEntityReference should not fail"), + ) + + self.assertEqual(actual, expected) + + class Test_resolve(FixtureAugmentedTestCase): """ Tests that resolution returns the expected values. diff --git a/tests/resources/library_apiComplianceSuite.json b/tests/resources/library_apiComplianceSuite.json index e432f18..258e891 100644 --- a/tests/resources/library_apiComplianceSuite.json +++ b/tests/resources/library_apiComplianceSuite.json @@ -23,6 +23,63 @@ "default": {} } }, + "defaultEntities": { + "read": [ + { + "traits": [ + "b", + "c" + ], + "entity": "a_default_read_entity_for_b_and_c" + }, + { + "traits": [ + "a", + "b" + ], + "entity": "a_default_read_entity_for_a_and_b" + }, + { + "traits": [ + "c", + "d" + ], + "entity": null + } + ], + "write": [ + { + "traits": [ + "b", + "c" + ], + "entity": "a_default_write_entity_for_b_and_c" + }, + { + "traits": [ + "a", + "b" + ], + "entity": "a_default_write_entity_for_a_and_b" + } + ], + "createRelated": [ + { + "traits": [ + "b", + "c" + ], + "entity": "a_default_relatable_entity_for_b_and_c" + }, + { + "traits": [ + "a", + "b" + ], + "entity": "a_default_relatable_entity_for_a_and_b" + } + ] + }, "entities": { "anAsset⭐︎": { "versions": [