Repository: fair-workflows/fairworkflows

Issue:
Add more arguments to publish_as_nanopub method
The `Nanopub.from_assertion` method that the `publish_as_nanopub` method is based on accepts arguments `attributed_to`, `attribute_to_profile`, and `nanopub_author`. We should allow the user to pass those arguments also to `publish_as_nanopub` so when publishing a workflow or step users can control the author of the nanopub and the assertion.

Comments:

Patch:
diff --git a/.github/workflows/fairworkflows.yml b/.github/workflows/build.yml
similarity index 97%
rename from .github/workflows/fairworkflows.yml
rename to .github/workflows/build.yml
index d6edf63..2c62740 100644
--- a/.github/workflows/fairworkflows.yml
+++ b/.github/workflows/build.yml
@@ -3,9 +3,8 @@
 
 name: Python application
 
-on:
-  push:
-    branches: [ '**' ]
+
+on: [push, pull_request]
 
 jobs:
   build:
diff --git a/README.md b/README.md
index 59751c2..4363185 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-![Build Status](https://github.com/fair-workflows/FAIRWorkbench/workflows/Python%20application/badge.svg)
-[![Coverage Status](https://coveralls.io/repos/github/fair-workflows/FAIRWorkbench/badge.svg?branch=master)](https://coveralls.io/github/fair-workflows/FAIRWorkbench?branch=master)
+![Build Status](https://github.com/fair-workflows/fairworkflows/workflows/Python%20application/badge.svg)
+[![Coverage Status](https://coveralls.io/repos/github/fair-workflows/fairworkflows/badge.svg?branch=main)](https://coveralls.io/github/fair-workflows/fairworkflows?branch=main)
 [![PyPI version](https://badge.fury.io/py/fairworkflows.svg)](https://badge.fury.io/py/fairworkflows)
 [![fair-software.eu](https://img.shields.io/badge/fair--software.eu-%E2%97%8F%20%20%E2%97%8F%20%20%E2%97%8F%20%20%E2%97%8F%20%20%E2%97%8B-yellow)](https://fair-software.eu)
 
diff --git a/fairworkflows/fairstep.py b/fairworkflows/fairstep.py
index f3222a4..10c6c1f 100644
--- a/fairworkflows/fairstep.py
+++ b/fairworkflows/fairstep.py
@@ -257,14 +257,21 @@ class FairStep(RdfWrapper):
         if shacl:
             self.shacl_validate()
 
-    def publish_as_nanopub(self, use_test_server=False):
+    def publish_as_nanopub(self, use_test_server=False, **kwargs):
         """
         Publish this rdf as a nanopublication.
 
+        Args:
+            use_test_server (bool): Toggle using the test nanopub server.
+            kwargs: Keyword arguments to be passed to [nanopub.Publication.from_assertion](
+                https://nanopub.readthedocs.io/en/latest/reference/publication.html#
+                nanopub.publication.Publication.from_assertion).
+                This allows for more control over the nanopublication RDF.
+
         Returns:
             a dictionary with publication info, including 'nanopub_uri', and 'concept_uri'
         """
-        return self._publish_as_nanopub(use_test_server=use_test_server)
+        return self._publish_as_nanopub(use_test_server=use_test_server, **kwargs)
 
     def __str__(self):
         """
diff --git a/fairworkflows/fairworkflow.py b/fairworkflows/fairworkflow.py
index 3da768e..386a569 100644
--- a/fairworkflows/fairworkflow.py
+++ b/fairworkflows/fairworkflow.py
@@ -414,12 +414,19 @@ class FairWorkflow(RdfWrapper):
                 'Cannot produce visualization of RDF, you need to install '
                 'graphviz dependency https://graphviz.org/')
 
-    def publish_as_nanopub(self, use_test_server=False):
+    def publish_as_nanopub(self, use_test_server=False, **kwargs):
         """Publish to nanopub server.
 
         First publish the steps, use the URIs of the published steps in the workflow. Then
         publish the workflow.
 
+        Args:
+            use_test_server (bool): Toggle using the test nanopub server.
+            kwargs: Keyword arguments to be passed to [nanopub.Publication.from_assertion](
+                https://nanopub.readthedocs.io/en/latest/reference/publication.html#
+                nanopub.publication.Publication.from_assertion).
+                This allows for more control over the nanopublication RDF.
+
         Returns:
             a dictionary with publication info, including 'nanopub_uri', and 'concept_uri' of the
                 published workflow
@@ -428,13 +435,13 @@ class FairWorkflow(RdfWrapper):
             if step.is_modified or not step._is_published:
                 self._is_modified = True  # If one of the steps is modified the workflow is too.
                 old_uri = step.uri
-                step.publish_as_nanopub(use_test_server=use_test_server)
+                step.publish_as_nanopub(use_test_server=use_test_server, **kwargs)
                 published_step_uri = step.uri
                 replace_in_rdf(self.rdf, oldvalue=rdflib.URIRef(old_uri),
                                newvalue=rdflib.URIRef(published_step_uri))
                 del self._steps[old_uri]
                 self._steps[published_step_uri] = step
-        return self._publish_as_nanopub(use_test_server=use_test_server)
+        return self._publish_as_nanopub(use_test_server=use_test_server, **kwargs)
 
     def __str__(self):
         """
diff --git a/fairworkflows/rdf_wrapper.py b/fairworkflows/rdf_wrapper.py
index 375ddd7..c241c1a 100644
--- a/fairworkflows/rdf_wrapper.py
+++ b/fairworkflows/rdf_wrapper.py
@@ -4,6 +4,8 @@ from urllib.parse import urldefrag
 
 import rdflib
 import pyshacl
+
+from fairworkflows import namespaces
 from fairworkflows.config import ROOT_DIR
 from nanopub import Publication, NanopubClient
 
@@ -15,6 +17,18 @@ class RdfWrapper:
         self.self_ref = rdflib.term.BNode(ref_name)
         self._is_modified = False
         self._is_published = False
+        self._bind_namespaces()
+
+    def _bind_namespaces(self):
+        """Bind namespaces used often in fair step and fair workflow.
+
+        Unused namespaces will be removed upon serialization.
+        """
+        self.rdf.bind("npx", namespaces.NPX)
+        self.rdf.bind("pplan", namespaces.PPLAN)
+        self.rdf.bind("dul", namespaces.DUL)
+        self.rdf.bind("bpmn", namespaces.BPMN)
+        self.rdf.bind("pwo", namespaces.PWO)
 
     @property
     def rdf(self) -> rdflib.Graph:
@@ -164,15 +178,23 @@ class RdfWrapper:
         self._is_published = True
         return self
 
-    def _publish_as_nanopub(self, use_test_server=False):
+    def _publish_as_nanopub(self, use_test_server=False, **kwargs):
         """
         Publishes this rdf as a nanopublication.
 
+        Args:
+            use_test_server (bool): Toggle using the test nanopub server.
+            kwargs: Keyword arguments to be passed to [nanopub.Publication.from_assertion](
+                https://nanopub.readthedocs.io/en/latest/reference/publication.html#
+                nanopub.publication.Publication.from_assertion).
+                This allows for more control over the nanopublication RDF.
+
         Returns:
             a dictionary with publication info, including 'nanopub_uri', and 'concept_uri'
         """
 
-        # If this RDF has been modified from something that was previously published, include the original URI in the derived_from PROV (if applicable)
+        # If this RDF has been modified from something that was previously published,
+        # include the original URI in the derived_from PROV (if applicable)
         derived_from = None
         if self._is_published:
             if self.is_modified:
@@ -181,10 +203,21 @@ class RdfWrapper:
                 warnings.warn(f'Cannot publish() this Fair object. This rdf is already published (at {self._uri}) and has not been modified locally.')
                 return {'nanopub_uri': None, 'concept_uri': None}
 
+        for invalid_kwarg in ['introduces_concept', 'assertion_rdf']:
+            if invalid_kwarg in kwargs:
+                raise ValueError(f'{invalid_kwarg} is automatically filled by fairworkflows '
+                                 f'library, you cannot set it.')
+
+        if 'derived_from' in kwargs:
+            derived_from = self._merge_derived_from(user_derived_from=kwargs['derived_from'],
+                                                    our_derived_from=derived_from)
+            del kwargs['derived_from']  # We do not want to pass derived_from multiple times.
+
         # Publish the rdf of this step as a nanopublication
         nanopub = Publication.from_assertion(assertion_rdf=self.rdf,
-                                         introduces_concept=self.self_ref,
-                                         derived_from=derived_from)
+                                             introduces_concept=self.self_ref,
+                                             derived_from=derived_from,
+                                             **kwargs)
         client = NanopubClient(use_test_server=use_test_server)
         publication_info = client.publish(nanopub)
 
@@ -198,6 +231,20 @@ class RdfWrapper:
 
         return publication_info
 
+    @staticmethod
+    def _merge_derived_from(user_derived_from, our_derived_from):
+        """
+        Merge user-provided `derived_from` with `derived_from` that is based on self.uri .
+
+        Returns:
+             A list of derived_from URIRefs.
+        """
+        if our_derived_from is None:
+            return user_derived_from
+        if not isinstance(user_derived_from, list):
+            user_derived_from = [user_derived_from]
+        return user_derived_from + [our_derived_from]
+
 
 def replace_in_rdf(rdf: rdflib.Graph, oldvalue, newvalue):
     """


Test patch:
diff --git a/tests/test_rdf_wrapper.py b/tests/test_rdf_wrapper.py
index cad79be..ef42d0d 100644
--- a/tests/test_rdf_wrapper.py
+++ b/tests/test_rdf_wrapper.py
@@ -1,5 +1,7 @@
 import warnings
+from unittest import mock
 
+import pytest
 import rdflib
 
 from fairworkflows.rdf_wrapper import RdfWrapper
@@ -25,3 +27,30 @@ class TestRdfWrapper:
             assert issubclass(w[-1].category, UserWarning)
 
         assert wrapper.get_attribute(test_predicate) == test_value_2
+
+    def test_publish_as_nanopub_invalid_kwargs(self):
+        wrapper = RdfWrapper(uri='test')
+        with pytest.raises(ValueError):
+            wrapper._publish_as_nanopub(introduces_concept='test')
+        with pytest.raises(ValueError):
+            wrapper._publish_as_nanopub(assertion_rdf='test')
+
+    @mock.patch('fairworkflows.rdf_wrapper.NanopubClient.publish')
+    def test_publish_as_nanopub_with_kwargs(self, nanopub_wrapper_publish_mock):
+        wrapper = RdfWrapper(uri='test')
+        wrapper.rdf.add((rdflib.Literal('test'), rdflib.Literal('test'), rdflib.Literal('test')))
+        # These are kwargs for nanopub.Publication.from_assertion()
+        wrapper._publish_as_nanopub(attribute_assertion_to_profile=True,
+                                    derived_from=rdflib.Literal('test'))
+
+    def test_merge_derived_from(self):
+        wrapper = RdfWrapper(uri='test')
+        result = wrapper._merge_derived_from(user_derived_from='test1', our_derived_from='test2')
+        assert result == ['test1', 'test2']
+
+        result = wrapper._merge_derived_from(user_derived_from=['test1', 'test2'],
+                                             our_derived_from='test3')
+        assert result == ['test1', 'test2', 'test3']
+
+        result = wrapper._merge_derived_from(user_derived_from='test1', our_derived_from=None)
+        assert result == 'test1'