Repository: fair-workflows/nanopub

Issue:
Remove uri argument from Nanopub.from_assertion

Comments:

Patch:
diff --git a/README.md b/README.md
index f6dbd4b..85dc909 100644
--- a/README.md
+++ b/README.md
@@ -96,13 +96,35 @@ for s, p, o in np.provenance:
 print(np.introduces_concept)
 ```
 
-### Specifying more information
-You can optionally specify that the ```Publication``` introduces a particular concept, or is derived from another nanopublication:
+### Specifying concepts relative to the nanopublication namespace
+You can optionally specify that the ```Publication``` introduces a particular concept using blank nodes. 
+The pubinfo graph will note that this nanopub npx:introduces the concept. The concept should be a blank node 
+(rdflib.term.BNode), and is converted to a URI derived from the nanopub's URI with a fragment (#) made from the blank
+node's name.
 ```python
+from rdflib.term import BNode
 publication = Publication.from_assertion(assertion_rdf=my_assertion,
-                                         introduces_concept=(URIRef('www.example.org/timbernerslee'),
-                                         derived_from=URIRef('www.example.org/another-nanopublication') )
+                                         introduces_concept=BNode('timbernerslee'))
 ```
+Upon publication, any blank nodes in the rdf graph are replaced with the nanopub's URI, with the blank node name as a
+fragment. For example, if the blank node is called 'step', that would result in a URI composed of the nanopub's (base)
+URI, followed by #step. In case you are basing your publication on rdf that has a lot of concepts specific to this 
+nanopublication that are not blank nodes you could use `replace_in_rdf` to easily replace them with blank nodes:
+```python
+from nanopub import replace_in_rdf
+replace_in_rdf(rdf=my_assertion, oldvalue=URIRef('www.example.org/timbernerslee'), newvalue=BNode('timbernerslee'))
+``` 
+
+### Specifying derived_from
+You can specify that the nanopub's assertion is derived from another URI (such as an existing nanopublication):
+```python
+publication = Publication.from_assertion(assertion_rdf=my_assertion,
+                                         derived_from=rdflib.URIRef('www.example.org/another-nanopublication'))
+```
+Note that ```derived_from``` may also be passed a list of URIs.
+                                         
+
+
 
 ## Dependencies
 The ```nanopub``` library currently uses the [```nanopub-java```](https://github.com/Nanopublication/nanopub-java) tool for signing and publishing new nanopublications. This is automatically installed by the library.
diff --git a/nanopub/__init__.py b/nanopub/__init__.py
index bfa5ee7..eecd042 100644
--- a/nanopub/__init__.py
+++ b/nanopub/__init__.py
@@ -1,3 +1,3 @@
-from .publication import Publication
+from .publication import Publication, replace_in_rdf
 from .client import NanopubClient
 from ._version import __version__
diff --git a/nanopub/client.py b/nanopub/client.py
index edf1a52..d7b5b78 100644
--- a/nanopub/client.py
+++ b/nanopub/client.py
@@ -7,7 +7,8 @@ import rdflib
 import requests
 
 from nanopub import namespaces, profile
-from nanopub.definitions import DEFAULT_NANOPUB_URI
+from nanopub.definitions import DUMMY_NANOPUB_URI
+from nanopub.publication import Publication
 from nanopub.java_wrapper import JavaWrapper
 from nanopub.publication import Publication
 
@@ -197,7 +198,7 @@ class NanopubClient:
             # and appends a fragment, given by the 'name' of the blank node. For example, if a
             # blank node with name 'step' was passed as introduces_concept, the concept will be
             # published with a URI that looks like [published nanopub URI]#step.
-            concept_uri = concept_uri.replace(DEFAULT_NANOPUB_URI, nanopub_uri)
+            concept_uri = concept_uri.replace(DUMMY_NANOPUB_URI, nanopub_uri)
             publication_info['concept_uri'] = concept_uri
             print(f'Published concept to {concept_uri}')
 
@@ -224,7 +225,7 @@ class NanopubClient:
         #  from_assertion method.
         publication.provenance.add((rdflib.URIRef(profile.get_orcid_id()),
                                     namespaces.HYCL.claims,
-                                    rdflib.URIRef(DEFAULT_NANOPUB_URI + '#mystatement')))
+                                    rdflib.URIRef(DUMMY_NANOPUB_URI + '#mystatement')))
         self.publish(publication)
 
     def _check_public_keys_match(self, uri):
diff --git a/nanopub/definitions.py b/nanopub/definitions.py
index f60f5bc..94c41ff 100644
--- a/nanopub/definitions.py
+++ b/nanopub/definitions.py
@@ -7,5 +7,5 @@ TEST_RESOURCES_FILEPATH = TESTS_FILEPATH / 'resources'
 USER_CONFIG_DIR = Path.home() / '.nanopub'
 PROFILE_PATH = USER_CONFIG_DIR / 'profile.yml'
 
-# Default URI when referring to a nanopub, will be replaced with published URI when publishing.
-DEFAULT_NANOPUB_URI = 'http://purl.org/nanopub/temp/mynanopub'
+# Dummy URI when referring to a nanopub, will be replaced with published URI when publishing.
+DUMMY_NANOPUB_URI = 'http://purl.org/nanopub/temp/mynanopub'
diff --git a/nanopub/publication.py b/nanopub/publication.py
index 49e5df4..e65aac2 100644
--- a/nanopub/publication.py
+++ b/nanopub/publication.py
@@ -1,11 +1,12 @@
 from datetime import datetime
+from typing import Union
 from urllib.parse import urldefrag
 
 import rdflib
 from rdflib.namespace import RDF, DC, DCTERMS, XSD
 
 from nanopub import namespaces, profile
-from nanopub.definitions import DEFAULT_NANOPUB_URI
+from nanopub.definitions import DUMMY_NANOPUB_URI
 
 
 class Publication:
@@ -30,22 +31,49 @@ class Publication:
                 raise ValueError(
                     f'Expected to find {expected} graph in nanopub rdf, but not found. Graphs found: {list(self._graphs.keys())}.')
 
+    @staticmethod
+    def _replace_blank_nodes(dummy_namespace, assertion_rdf):
+        """ Replace blank nodes.
+
+        Replace any blank nodes in the supplied RDF with a corresponding uri in the
+        dummy_namespace.'Blank nodes' here refers specifically to rdflib.term.BNode objects. When
+        publishing, the dummy_namespace is replaced with the URI of the actual nanopublication.
+
+        For example, if the nanopub's URI is www.purl.org/ABC123 then the blank node will be
+        replaced with a concrete URIRef of the form www.purl.org/ABC123#blanknodename where
+        'blanknodename' is the name of the rdflib.term.BNode object.
+
+        This is to solve the problem that a user may wish to use the nanopublication to introduce
+        a new concept. This new concept needs its own URI (it cannot simply be given the
+        nanopublication's URI), but it should still lie within the space of the nanopub.
+        Furthermore, the URI the nanopub is published to is not known ahead of time.
+        """
+        for s, p, o in assertion_rdf:
+            assertion_rdf.remove((s, p, o))
+            if isinstance(s, rdflib.term.BNode):
+                s = dummy_namespace[str(s)]
+            if isinstance(o, rdflib.term.BNode):
+                o = dummy_namespace[str(o)]
+            assertion_rdf.add((s, p, o))
+
     @classmethod
-    def from_assertion(cls, assertion_rdf, uri=DEFAULT_NANOPUB_URI, introduces_concept=None,
+    def from_assertion(cls, assertion_rdf: rdflib.Graph,
+                       introduces_concept: rdflib.term.BNode = None,
                        derived_from=None, assertion_attributed_to=None,
                        attribute_assertion_to_profile: bool = False):
         """
-        Construct Nanopub object based on given assertion, with given assertion and (defrag'd) URI.
-        Any blank nodes in the rdf graph are replaced with the nanopub's URI, with the blank node name
-        as a fragment. For example, if the blank node is called 'step', that would result in a URI composed of the
-        nanopub's (base) URI, followed by #step.
-
-        If introduces_concept is given (string, or rdflib.URIRef), the pubinfo graph will note that this nanopub npx:introduces the given URI.
-        If a blank node (rdflib.term.BNode) is given instead of a URI, the blank node will be converted to a URI
-        derived from the nanopub's URI with a fragment (#) made from the blank node's name.
+        Construct Nanopub object based on given assertion. Any blank nodes in the rdf graph are
+        replaced with the nanopub's URI, with the blank node name as a fragment. For example, if
+        the blank node is called 'step', that would result in a URI composed of the nanopub's (base)
+        URI, followed by #step.
 
         Args:
-            derived_from: Add that this nanopub prov:wasDerivedFrom the given URI to the provenance graph.
+            assertion_rdf: The assertion RDF graph.
+            introduces_concept: the pubinfo graph will note that this nanopub npx:introduces the
+                concept. The concept should be a blank node (rdflib.term.BNode), and is converted
+                to a URI derived from the nanopub's URI with a fragment (#) made from the blank
+                node's name.
+            derived_from: Add a triple to the provenance graph stating that this nanopub's assertion prov:wasDerivedFrom the given URI.
                           If a list of URIs is passed, a provenance triple will be generated for each.
             assertion_attributed_to: the provenance graph will note that this nanopub's assertion
                 prov:wasAttributedTo the given URI.
@@ -62,32 +90,16 @@ class Publication:
         if attribute_assertion_to_profile:
             assertion_attributed_to = rdflib.URIRef(profile.get_orcid_id())
 
-        # Make sure passed URI is defrag'd
-        uri = str(uri)
-        uri, _ = urldefrag(uri)
-        this_np = rdflib.Namespace(uri + '#')
-
-        # Replace any blank nodes in the supplied RDF, with a URI derived from the nanopub's uri.
-        # 'Blank nodes' here refers specifically to rdflib.term.BNode objects.
-        # For example, if the nanopub's URI is www.purl.org/ABC123 then the blank node will be replaced with a
-        # concrete URIRef of the form www.purl.org/ABC123#blanknodename where 'blanknodename' is the name of the
-        # the rdflib.term.BNode object. If blanknodename is 'step', then the URI will have a fragment '#step' after it.
-        #
-        # The problem that this is designed to solve is that a user may wish to use the nanopublication to introduce
-        # a new concept. This new concept needs its own URI (it cannot simply be given the nanopublication's URI),
-        # but it should still lie within the space of the nanopub. Furthermore, the URI the nanopub is published
-        # is not known ahead of time. The variable 'this_np', for example, is holding a dummy URI that is swapped
-        # with the true, published URI of the nanopub by the 'np' tool at the moment of publication.
-        #
-        # We wish to replace any blank nodes in the rdf with URIs that are based on this same dummy URI, so that
-        # they too are transformed to the correct URI upon publishing.
-        for s, p, o in assertion_rdf:
-            assertion_rdf.remove((s, p, o))
-            if isinstance(s, rdflib.term.BNode):
-                s = this_np[str(s)]
-            if isinstance(o, rdflib.term.BNode):
-                o = this_np[str(o)]
-            assertion_rdf.add((s, p, o))
+        if introduces_concept and not isinstance(introduces_concept, rdflib.term.BNode):
+            raise ValueError('If you want a nanopublication to introduce a concept, you need to '
+                             'pass it as an rdflib.term.BNode("concept_name"). This will make '
+                             'sure it is referred to from the nanopublication uri namespace upon '
+                             'publishing.')
+
+        # To be replaced with the published uri upon publishing
+        this_np = rdflib.Namespace(DUMMY_NANOPUB_URI + '#')
+
+        cls._replace_blank_nodes(dummy_namespace=this_np, assertion_rdf=assertion_rdf)
 
         # Set up different contexts
         rdf = rdflib.ConjunctiveGraph()
@@ -157,8 +169,7 @@ class Publication:
                           namespaces.NPX.introduces,
                           introduces_concept))
 
-        obj = cls(rdf=rdf, source_uri=uri)
-        return obj
+        return cls(rdf=rdf)
 
     @property
     def rdf(self):
@@ -197,3 +208,16 @@ class Publication:
         s = f'Original source URI = {self._source_uri}\n'
         s += self._rdf.serialize(format='trig').decode('utf-8')
         return s
+
+
+def replace_in_rdf(rdf: rdflib.Graph, oldvalue, newvalue):
+    """
+    Replace subjects or objects of oldvalue with newvalue
+    """
+    for s, p, o in rdf:
+        if s == oldvalue:
+            rdf.remove((s, p, o))
+            rdf.add((newvalue, p, o))
+        elif o == oldvalue:
+            rdf.remove((s, p, o))
+            rdf.add((s, p, newvalue))


Test patch:
diff --git a/tests/test_client.py b/tests/test_client.py
index 3c02c11..9666e1b 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -108,24 +108,6 @@ class TestNanopubClient:
         client.claim(statement_text='Some controversial statement')
 
     def test_nanopub_publish(self):
-        test_uri = 'http://www.example.com/my-nanopub'
-        test_concept_uri = 'http://purl.org/person#DrBob'  # This nanopub introduced DrBob
-        client = NanopubClient()
-        client.java_wrapper.publish = mock.MagicMock(return_value=test_uri)
-        assertion_rdf = rdflib.Graph()
-        assertion_rdf.add(TEST_ASSERTION)
-
-        nanopub = Publication.from_assertion(
-            assertion_rdf=assertion_rdf,
-            uri=rdflib.term.URIRef(test_uri),
-            introduces_concept=namespaces.AUTHOR.DrBob,
-            derived_from=rdflib.term.URIRef('http://www.example.com/another-nanopub')
-        )
-        pubinfo = client.publish(nanopub)
-        assert pubinfo['nanopub_uri'] == test_uri
-        assert pubinfo['concept_uri'] == test_concept_uri
-
-    def test_nanopub_publish_blanknode(self):
         test_concept = rdflib.term.BNode('test')
         test_published_uri = 'http://www.example.com/my-nanopub'
         expected_concept_uri = 'http://www.example.com/my-nanopub#test'
diff --git a/tests/test_publication.py b/tests/test_publication.py
index 1fb2032..77e8e48 100644
--- a/tests/test_publication.py
+++ b/tests/test_publication.py
@@ -1,87 +1,73 @@
 from unittest import mock
 
+import pytest
 import rdflib
 from rdflib.namespace import RDF
 
-from nanopub import namespaces, Publication
+from nanopub import namespaces, Publication, replace_in_rdf
+from nanopub.definitions import DUMMY_NANOPUB_URI
 
-TEST_ASSERTION = (namespaces.AUTHOR.DrBob, namespaces.HYCL.claims, rdflib.Literal('This is a test'))
 TEST_ORCID_ID = 'https://orcid.org/0000-0000-0000-0000'
 
 
 class TestPublication:
-    def test_construction_with_bnode_introduced_concept(self):
+    test_assertion = rdflib.Graph()
+    test_assertion.add((namespaces.AUTHOR.DrBob,
+                        namespaces.HYCL.claims,
+                        rdflib.Literal('This is a test')))
+
+    def test_from_assertion_introduced_concept_not_bnode(self):
+        with pytest.raises(ValueError):
+            Publication.from_assertion(self.test_assertion, introduces_concept='not a blank node')
+
+    def test_from_assertion_with_bnode_introduced_concept(self):
         """
         Test Publication construction from assertion where a BNode is introduced as a concept.
         """
-        test_uri = 'http://www.example.com/my-nanopub'
-        test_concept_uri = 'http://www.example.com/my-nanopub#DrBob'  # This nanopub introduced DrBob
-        assertion_rdf = rdflib.Graph()
-        assertion_rdf.add(TEST_ASSERTION)
-
-        publication = Publication.from_assertion(
-            assertion_rdf=assertion_rdf,
-            uri=rdflib.term.URIRef(test_uri),
-            introduces_concept=rdflib.term.BNode('DrBob'),
-            derived_from=rdflib.term.URIRef('http://www.example.com/another-nanopub'),
-            assertion_attributed_to=TEST_ORCID_ID
+        publication = Publication.from_assertion(assertion_rdf=self.test_assertion,
+                                                 introduces_concept=rdflib.term.BNode('DrBob'),
         )
+        test_concept_uri = DUMMY_NANOPUB_URI + '#DrBob'  # This nanopub introduced DrBob
         assert str(publication.introduces_concept) == test_concept_uri
+        assert (None, namespaces.NPX.introduces, rdflib.URIRef(test_concept_uri)) in publication.rdf
 
     def test_construction_with_derived_from_as_list(self):
         """
         Test Publication construction from assertion where derived_from is a list.
         """
-        test_uri = 'http://www.example.com/my-nanopub'
         derived_from_list = [   'http://www.example.com/another-nanopub', # This nanopub is derived from several sources
                                 'http://www.example.com/and-another-nanopub',
                                 'http://www.example.com/and-one-more' ]
-        assertion_rdf = rdflib.Graph()
-        assertion_rdf.add(TEST_ASSERTION)
 
-        publication = Publication.from_assertion(
-            assertion_rdf=assertion_rdf,
-            uri=rdflib.term.URIRef(test_uri),
-            derived_from=derived_from_list,
-            assertion_attributed_to=TEST_ORCID_ID
-        )
+        publication = Publication.from_assertion(assertion_rdf=self.test_assertion,
+                                                 derived_from=derived_from_list)
 
         for uri in derived_from_list:
             assert (None, namespaces.PROV.wasDerivedFrom, rdflib.URIRef(uri)) in publication.rdf
 
-    def test_from_assertion(self):
+    @mock.patch('nanopub.publication.profile')
+    def test_from_assertion(self, mock_profile):
         """
         Test that Publication.from_assertion is creating an rdf graph with the right features (
         contexts) for a publication.
         """
-        assertion_rdf = rdflib.Graph()
-        assertion_rdf.add(TEST_ASSERTION)
-
-        nanopub = Publication.from_assertion(assertion_rdf)
-
-        assert nanopub.rdf is not None
-        assert (None, RDF.type, namespaces.NP.Nanopublication) in nanopub.rdf
-        assert (None, namespaces.NP.hasAssertion, None) in nanopub.rdf
-        assert (None, namespaces.NP.hasProvenance, None) in nanopub.rdf
-        assert (None, namespaces.NP.hasPublicationInfo, None) in nanopub.rdf
-
-        new_concept = rdflib.term.URIRef('www.purl.org/new/concept/test')
-        nanopub = Publication.from_assertion(assertion_rdf, introduces_concept=new_concept)
-
-        assert nanopub.rdf is not None
-        assert (None, RDF.type, namespaces.NP.Nanopublication) in nanopub.rdf
-        assert (None, namespaces.NP.hasAssertion, None) in nanopub.rdf
-        assert (None, namespaces.NP.hasProvenance, None) in nanopub.rdf
-        assert (None, namespaces.NP.hasPublicationInfo, None) in nanopub.rdf
-
-        assert (None, namespaces.NPX.introduces, new_concept) in nanopub.rdf
-
-    @mock.patch('nanopub.publication.profile')
-    def test_from_assertion_use_profile(self, mock_profile):
         mock_profile.get_orcid_id.return_value = TEST_ORCID_ID
-        assertion = rdflib.Graph()
-        assertion.add(TEST_ASSERTION)
-
-        result = Publication.from_assertion(assertion_rdf=assertion)
-
-        assert (None, None, rdflib.URIRef(TEST_ORCID_ID)) in result.rdf
+        publication = Publication.from_assertion(self.test_assertion)
+
+        assert publication.rdf is not None
+        assert (None, RDF.type, namespaces.NP.Nanopublication) in publication.rdf
+        assert (None, namespaces.NP.hasAssertion, None) in publication.rdf
+        assert (None, namespaces.NP.hasProvenance, None) in publication.rdf
+        assert (None, namespaces.NP.hasPublicationInfo, None) in publication.rdf
+        assert (None, None, rdflib.URIRef(TEST_ORCID_ID)) in publication.rdf
+
+
+def test_replace_in_rdf():
+    g = rdflib.Graph()
+    g.add((rdflib.Literal('DrBob'), rdflib.RDF.type, rdflib.Literal('Doctor')))
+    g.add((rdflib.Literal('Alice'), rdflib.FOAF.knows, rdflib.Literal('DrBob')))
+    replace_in_rdf(g, rdflib.Literal('DrBob'), rdflib.Literal('Alfonso'))
+    assert (rdflib.Literal('Alfonso'), rdflib.RDF.type, rdflib.Literal('Doctor')) in g
+    assert (rdflib.Literal('Alice'), rdflib.FOAF.knows, rdflib.Literal('Alfonso')) in g
+    assert (rdflib.Literal('DrBob'), rdflib.RDF.type, rdflib.Literal('Doctor')) not in g
+    assert (rdflib.Literal('Alice'), rdflib.FOAF.knows, rdflib.Literal('DrBob')) not in g