diff --git a/sbol3/document.py b/sbol3/document.py index 4a8193b..ad2e01b 100644 --- a/sbol3/document.py +++ b/sbol3/document.py @@ -223,7 +223,9 @@ def _parse_attributes(objects, graph) -> dict[str, Identified]: obj._referenced_objects[str_p].append(other) else: # If an external reference, create a base SBOLObject to represent it - obj._referenced_objects[str_p].append(SBOLObject(reference)) + stub = SBOLObject(reference) + obj._referenced_objects[str_p].append(stub) + stub._references.append(obj) # Add to reference counter elif str_p == RDF_TYPE: # Handle rdf:type specially because the main type(s) # will already be in the list from the build_object @@ -369,7 +371,12 @@ def _add(self, obj: TopLevel) -> TopLevel: # in the TopLevel being added def assign_document(x: Identified): x.document = self + obj.traverse(assign_document) + + # Update any external references to this object + # replacing stub SBOLObjects with this one + self._resolve_references(obj) return obj def _add_all(self, objects: pytyping.Sequence[TopLevel]) -> pytyping.Sequence[TopLevel]: @@ -433,6 +440,11 @@ def find(self, search_string: str) -> Optional[Identified]: return obj return self._find_in_objects(search_string) + def _resolve_references(self, new_obj): + """Update all unresolved references to this object, replacing stub SBOLObject with this one.""" + for updated in self.objects: + updated._resolve_references(new_obj) + def join_lines(self, lines: List[Union[bytes, str]]) -> Union[bytes, str]: """Join lines for either bytes or strings. Joins a list of lines together whether they are bytes or strings. Returns a bytes if the input was @@ -690,6 +702,10 @@ def remove(self, objects: Iterable[TopLevel]): # Now do the removal of each top level object and all of its children for obj in objects_to_remove: obj.remove_from_document() + # If the removed object is referenced anywhere, + # leave a stub + stub_obj = SBOLObject(obj.identity) + self._resolve_references(stub_obj) def remove_object(self, top_level: TopLevel): """Removes the given TopLevel from this document. No referential diff --git a/sbol3/object.py b/sbol3/object.py index 671e9ed..b8f73c6 100644 --- a/sbol3/object.py +++ b/sbol3/object.py @@ -106,6 +106,20 @@ def find(self, search_string: str) -> Optional['SBOLObject']: return result return None + def _resolve_references(self, new_obj): + NEW_OBJ = new_obj + def resolve_references(x): + for property_id, references in x._referenced_objects.items(): + needs_updating = False + for ref_obj in references: + if ref_obj.identity == NEW_OBJ.identity: + needs_updating = True + break + if needs_updating: + references.remove(ref_obj) + references.append(new_obj) + self.traverse(resolve_references) + def copy(self, target_doc=None, target_namespace=None): # Delete this method in v1.1 diff --git a/sbol3/refobj_property.py b/sbol3/refobj_property.py index 1dffec4..4bb68be 100644 --- a/sbol3/refobj_property.py +++ b/sbol3/refobj_property.py @@ -41,13 +41,16 @@ def from_user(self, value: Any) -> rdflib.URIRef: if type(value) is str: if self.property_owner.document: referenced_obj = self.property_owner.document.find(value) - # TODO: warn user referenced object is not in document if referenced_obj is not None: + # Keep track of this reference to the object if self not in referenced_obj._references: referenced_obj._references += [self.property_owner] return referenced_obj - # If not found in Document + # The given URI refers to an object not currently in this + # Document, so create a stub + # TODO: warn user referenced object is not in document value = SBOLObject(value) + if not isinstance(value, SBOLObject): raise TypeError('Cannot set property, the value must be str or instance of SBOLObect') if value.identity is None: @@ -55,6 +58,7 @@ def from_user(self, value: Any) -> rdflib.URIRef: msg = f'Cannot set reference to {value}.' msg += ' Object identity is uninitialized.' raise ValueError(msg) + # Keep track of this reference to the object value._references += [self.property_owner] return value diff --git a/test/test_referenced_object.py b/test/test_referenced_object.py index d742f8a..c371a8f 100644 --- a/test/test_referenced_object.py +++ b/test/test_referenced_object.py @@ -21,20 +21,6 @@ def __init__(self, identity: str, type_uri: str = SRO_URI): class TestReferencedObject(unittest.TestCase): - TEST_SBOL = ''' -@base . -@prefix : . -@prefix sbol: . -@prefix SBO: . - -:toggle_switch a sbol:Component ; - sbol:description "Toggle Switch genetic circuit" ; - sbol:displayId "toggle_switch" ; - sbol:hasModel :model1 ; - sbol:hasNamespace ; - sbol:name "Toggle Switch" ; - sbol:type SBO:0000241 .''' - def setUp(self) -> None: sbol3.set_defaults() @@ -59,37 +45,6 @@ def test_parse_refobj(self): model_lookup = model.lookup() self.assertTrue(model_lookup is model) - def test_parse_external_reference(self): - # When parsing a document, if we encounter a reference to an object - # not in this document, create a stub object using SBOLObject - test_format = sbol3.TURTLE - - doc = sbol3.Document() - doc.read_string(TestReferencedObject.TEST_SBOL, file_format=test_format) - component = doc.find('toggle_switch') - model = component.models[0] - self.assertTrue(type(model) is sbol3.SBOLObject) - self.assertEqual(model.identity, 'https://sbolstandard.org/examples/model1') - - def test_serialize_external_reference(self): - # When serializing a document, if we encounter a reference to an object - # not in this document, serialize it as a URI - test_format = sbol3.TURTLE - - doc = sbol3.Document() - doc2 = sbol3.Document() - - doc.read_string(TestReferencedObject.TEST_SBOL, file_format=test_format) - component = doc.find('toggle_switch') - model = component.models[0] - self.assertTrue(type(model) is sbol3.SBOLObject) - self.assertEqual(model.identity, 'https://sbolstandard.org/examples/model1') - - doc2.read_string(doc.write_string(file_format=test_format), file_format=test_format) - component = doc2.find('toggle_switch') - model = component.models[0] - - def test_copy(self): test_path = os.path.join(SBOL3_LOCATION, 'entity', 'model', 'model.ttl') test_format = sbol3.TURTLE @@ -108,7 +63,6 @@ def test_copy(self): model = component_copy.models[0] self.assertTrue(type(model) is sbol3.SBOLObject) - def test_insert_into_list(self): # Test assignment using list indices sbol3.set_namespace('https://github.com/synbiodex/pysbol3') @@ -290,10 +244,81 @@ def test_list_property_reference_counter(self): component.sequences.remove(seq1) self.assertListEqual(seq1._references, []) + +class TestExternalReferences(unittest.TestCase): + + TEST_SBOL = ''' +@base . +@prefix : . +@prefix sbol: . +@prefix SBO: . + +:toggle_switch a sbol:Component ; + sbol:description "Toggle Switch genetic circuit" ; + sbol:displayId "toggle_switch" ; + sbol:hasModel :model1 ; + sbol:hasNamespace ; + sbol:name "Toggle Switch" ; + sbol:type SBO:0000241 .''' + TEST_FORMAT = sbol3.TURTLE + + def setUp(self) -> None: + sbol3.set_namespace('https://sbolstandard.org/examples') + self.doc = sbol3.Document() + self.doc.read_string(TestExternalReferences.TEST_SBOL, + file_format=TestExternalReferences.TEST_FORMAT) + + def test_parse_external_reference(self): + # When parsing a document, if we encounter a reference to an object + # not in this document, create a stub object using SBOLObject + component = self.doc.find('toggle_switch') + model = component.models[0] + self.assertTrue(type(model) is sbol3.SBOLObject) + self.assertEqual(model.identity, 'https://sbolstandard.org/examples/model1') + self.assertListEqual(model._references, [component]) + + def test_serialize_external_reference(self): + # When serializing a document, if we encounter a reference to an object + # not in this document, serialize it as a URI + + roundtrip_doc = sbol3.Document() + roundtrip_doc.read_string(self.doc.write_string(file_format=TestExternalReferences.TEST_FORMAT), file_format=TestExternalReferences.TEST_FORMAT) + component = roundtrip_doc.find('toggle_switch') + model = component.models[0] + def test_update(self): - # Update and resolve references to an external object when the object is - # added to the Document - pass + # Update and resolve references to an external object when the + # object is added to the Document. Upon resolving the reference, + # the SBOLObject that represents the unresolved reference will be + # downcasted to the specific SBOL type + component = self.doc.find('toggle_switch') + model = component.models[0] + self.assertTrue(type(model) is sbol3.SBOLObject) + + # now add an object which resolves the reference + model = sbol3.Model('model1', source='foo', language='foo', framework='foo') + self.assertEqual(model.identity, 'https://sbolstandard.org/examples/model1') + self.doc.add(model) + + # Check whether dereferencing now returns a Model + # instead of SBOLObject + model = component.models[0] + self.assertFalse(type(model) is sbol3.SBOLObject) + self.assertTrue(type(model) is sbol3.Model) + + def test_remove(self): + # The reverse of test_update, removing an object from a Document + # creates an unresolved, external reference. The object in the + # Document should be upcast to an SBOLObject + component = self.doc.find('toggle_switch') + model = sbol3.Model('model1', source='foo', language='foo', framework='foo') + self.doc.add(model) + self.doc.remove([model]) + + model = component.models[0] + self.assertFalse(type(model) is sbol3.Model) + self.assertTrue(type(model) is sbol3.SBOLObject) + if __name__ == '__main__': unittest.main()