Repository: ResearchObject/ro-crate-py

Issue:
Add function to add a CreateAction to the crate
e.g. `add_action()` which takes the inputs, outputs, instrument, etc as arguments and builds a CreateAction entity.

CreateAction entities are fiddly to create, but essential for WRROC - so many workflow managers need to create them and have written the code themselves (e.g. see #148). It would be useful to offer a built-in function that can help with wiring all the entities together, to make adoption easier for more workflow managers.

Discussion:

Solution patch:
diff --git a/rocrate/model/entity.py b/rocrate/model/entity.py
index c18096ed..22e69a55 100644
--- a/rocrate/model/entity.py
+++ b/rocrate/model/entity.py
@@ -34,12 +34,13 @@ def __init__(self, crate, identifier=None, properties=None):
             self.__id = self.format_id(identifier)
         else:
             self.__id = f"#{uuid.uuid4()}"
+        self._jsonld = self._empty()
         if properties:
-            empty = self._empty()
-            empty.update(properties)
-            self._jsonld = empty
-        else:
-            self._jsonld = self._empty()
+            for name, value in properties.items():
+                if name.startswith("@"):
+                    self._jsonld[name] = value
+                else:
+                    self[name] = value
 
     @property
     def id(self):
diff --git a/rocrate/rocrate.py b/rocrate/rocrate.py
index 5597d54f..63f2eb34 100644
--- a/rocrate/rocrate.py
+++ b/rocrate/rocrate.py
@@ -556,6 +556,22 @@ def add_test_definition(
         self.metadata.extra_terms.update(TESTING_EXTRA_TERMS)
         return definition
 
+    def add_action(self, instrument, identifier=None, object=None, result=None, properties=None):
+        if properties is None:
+            properties = {}
+        if "@type" not in properties:
+            properties["@type"] = "CreateAction"
+        action = self.add(ContextEntity(self, identifier, properties=properties))
+        action["instrument"] = instrument
+        if "name" not in properties:
+            action.name = action.id.lstrip("#")
+        if object:
+            action["object"] = object
+        if result:
+            action["result"] = result
+        self.root_dataset.append_to("mentions", action)
+        return action
+
     def add_jsonld(self, jsonld):
         """Add a JSON-LD dictionary as a contextual entity to the RO-Crate.
 


Test patch:
diff --git a/test/test_model.py b/test/test_model.py
index 1935cc8f..4465ecee 100644
--- a/test/test_model.py
+++ b/test/test_model.py
@@ -513,3 +513,52 @@ def test_immutable_id():
     p = crate.add(Person(crate, "#foo"))
     with pytest.raises(AttributeError):
         p.id = "#bar"
+
+
+def test_entity_in_properties(tmpdir):
+    file_id = "data.csv"
+    file_path = tmpdir / file_id
+    with open(file_path, "wt") as fout:
+        fout.write("DATA\n")
+    crate = ROCrate()
+    alice_id = "https://orcid.org/0000-0000-0000-0000"
+    bob_id = "https://orcid.org/0000-0000-0000-0001"
+    alice = crate.add(Person(crate, alice_id, properties={
+        "name": "Alice Doe",
+        "affiliation": "University of Flatland",
+    }))
+    bob = crate.add(Person(crate, bob_id, properties={
+        "name": "Bob Doe",
+        "affiliation": "University of Flatland",
+    }))
+    data = crate.add_file(file_path, properties={
+        "name": "Data file",
+        "encodingFormat": "text/csv",
+        "author": [alice, bob],
+    })
+    authors = data["author"]
+    assert len(authors) == 2
+    assert authors[0] is alice
+    assert authors[1] is bob
+
+    book = crate.add(ContextEntity(crate, "#44cats", properties={
+        "@type": "Book",
+        "name": "44 Cats",
+        "author": alice,
+    }))
+    assert book.type == "Book"
+    assert book["author"] is alice
+
+    out_path = tmpdir / "ro_crate_out"
+    crate.write(out_path)
+    assert (out_path / file_id).is_file()
+    out_crate = ROCrate(out_path)
+    out_alice = out_crate.get(alice_id)
+    out_bob = out_crate.get(bob_id)
+    out_data = out_crate.get(file_id)
+    out_authors = out_data["author"]
+    assert len(out_authors) == 2
+    assert out_authors[0] is out_alice
+    assert out_authors[1] is out_bob
+    assert out_alice == alice
+    assert out_bob == bob
diff --git a/test/test_wrroc.py b/test/test_wrroc.py
new file mode 100644
index 00000000..3b9d7df1
--- /dev/null
+++ b/test/test_wrroc.py
@@ -0,0 +1,79 @@
+# Copyright 2019-2024 The University of Manchester, UK
+# Copyright 2020-2024 Vlaams Instituut voor Biotechnologie (VIB), BE
+# Copyright 2020-2024 Barcelona Supercomputing Center (BSC), ES
+# Copyright 2020-2024 Center for Advanced Studies, Research and Development in Sardinia (CRS4), IT
+# Copyright 2022-2024 École Polytechnique Fédérale de Lausanne, CH
+# Copyright 2024 Data Centre, SciLifeLab, SE
+#
+# 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 rocrate.rocrate import ROCrate
+from rocrate.model import ContextEntity, SoftwareApplication
+
+
+def test_add_action(tmpdir):
+    crate = ROCrate()
+    instrument = SoftwareApplication(crate)
+    crate.add(instrument)
+    f_in_name = "f_in"
+    f_in_path = tmpdir / f_in_name
+    with open(f_in_path, "wt") as f:
+        f.write("IN\n")
+    f_in = crate.add_file(f_in_path)
+    f_out_name = "f_out"
+    f_out_path = tmpdir / f_out_name
+    with open(f_out_path, "wt") as f:
+        f.write("OUT\n")
+    f_out = crate.add_file(f_out_path)
+    param = crate.add(ContextEntity(crate, "#param", properties={
+        "@type": "PropertyValue",
+        "name": "param_name",
+        "value": "param_value",
+    }))
+    create_action = crate.add_action(
+        instrument,
+        object=[f_in, param],
+        result=[f_out],
+        properties={
+            "name": f"Run 1 of {instrument.id}",
+            "startTime": "2018-10-25T15:46:35.211153",
+            "endTime": "2018-10-25T15:46:43.020168",
+        }
+    )
+    assert create_action.type == "CreateAction"
+    create_actions = crate.get_by_type("CreateAction")
+    assert crate.root_dataset.get("mentions") == create_actions
+    assert create_actions == [create_action]
+    assert create_action.get("instrument") is instrument
+    assert create_action.get("object") == [f_in, param]
+    assert create_action.get("result") == [f_out]
+    assert create_action.get("name") == f"Run 1 of {instrument.id}"
+    assert create_action.get("startTime") == "2018-10-25T15:46:35.211153"
+    assert create_action.get("endTime") == "2018-10-25T15:46:43.020168"
+
+    activate_action = crate.add_action(
+        instrument,
+        object=[f_out],
+        properties={
+            "@type": "ActivateAction",
+            "name": f"Run 2 of {instrument.id}",
+            "endTime": "2018-10-25T16:48:41.021563",
+        }
+    )
+    assert activate_action.type == "ActivateAction"
+    assert crate.root_dataset.get("mentions") == [create_action, activate_action]
+    assert activate_action.get("instrument") is instrument
+    assert activate_action.get("object") == [f_out]
+    assert "result" not in activate_action
+    assert activate_action.get("name") == f"Run 2 of {instrument.id}"
+    assert activate_action.get("endTime") == "2018-10-25T16:48:41.021563"