Repository: ResearchObject/ro-crate-py (python)

Issue:
FEATURE: Load a crate from a (json) Object 
Unless I misunderstand this library can only read crates from disk. It would be very useful to be able to instantiate the library with an object (that would serialise in JSON to an RO-Crate Metadata Document).  Eg if I have fetched RO-Crate metadata from an API.

Pull request:
Add ro-crate instantiation from a dictionary
Fixes #178.

```python
from rocrate.rocrate import ROCrate

metadata = {
    "@context": "https://w3id.org/ro/crate/1.1/context",
    "@graph": [
        {
            "@id": "ro-crate-metadata.json",
            "@type": "CreativeWork",
            "about": {"@id": "./"},
            "conformsTo": {"@id": "https://w3id.org/ro/crate/1.1"}
        },
        {
            "@id": "./",
            "@type": "Dataset",
            "creator": {"@id": "#josiah"},
            "hasPart": {"@id": "http://example.org/foo"}
        },
        {
            "@id": "http://example.org/foo",
            "@type": "File"
        },
        {
            "@id": "#josiah",
            "@type": "Person",
            'name': 'Josiah Carberry'
        },
    ]
}

crate = ROCrate(metadata)
crate.write("crate")
```

If the metadata points to nonexistent local files or directories, the behavior when writing the crate is as described in #136 (see also the unit test added in this PR).

Patch:
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index 936525e..2069e54 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -19,7 +19,7 @@ jobs:
     strategy:
       matrix:
         os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
-        python-version: ['3.7', '3.8', '3.9', '3.10']
+        python-version: ['3.8', '3.9', '3.10', '3.11']
 
     runs-on: ${{ matrix.os }}
 
diff --git a/rocrate/metadata.py b/rocrate/metadata.py
index 2cbce35..4e15571 100644
--- a/rocrate/metadata.py
+++ b/rocrate/metadata.py
@@ -30,8 +30,11 @@ def read_metadata(metadata_path):
     Return a tuple of two elements: the context; a dictionary that maps entity
     ids to the entities themselves.
     """
-    with open(metadata_path) as f:
-        metadata = json.load(f)
+    if isinstance(metadata_path, dict):
+        metadata = metadata_path
+    else:
+        with open(metadata_path) as f:
+            metadata = json.load(f)
     try:
         context = metadata['@context']
         graph = metadata['@graph']
diff --git a/rocrate/rocrate.py b/rocrate/rocrate.py
index 6b622b8..827c913 100644
--- a/rocrate/rocrate.py
+++ b/rocrate/rocrate.py
@@ -113,26 +113,31 @@ class ROCrate():
                     self.add(Preview(self, source))
 
     def __read(self, source, gen_preview=False):
-        source = Path(source)
-        if not source.exists():
-            raise FileNotFoundError(errno.ENOENT, f"'{source}' not found")
-        if zipfile.is_zipfile(source):
-            zip_path = tempfile.mkdtemp(prefix="rocrate_")
-            atexit.register(shutil.rmtree, zip_path)
-            with zipfile.ZipFile(source, "r") as zf:
-                zf.extractall(zip_path)
-            source = Path(zip_path)
-        metadata_path = source / Metadata.BASENAME
-        if not metadata_path.is_file():
-            metadata_path = source / LegacyMetadata.BASENAME
-        if not metadata_path.is_file():
-            raise ValueError(f"Not a valid RO-Crate: missing {Metadata.BASENAME}")
+        if isinstance(source, dict):
+            metadata_path = source
+        else:
+            source = Path(source)
+            if not source.exists():
+                raise FileNotFoundError(errno.ENOENT, f"'{source}' not found")
+            if zipfile.is_zipfile(source):
+                zip_path = tempfile.mkdtemp(prefix="rocrate_")
+                atexit.register(shutil.rmtree, zip_path)
+                with zipfile.ZipFile(source, "r") as zf:
+                    zf.extractall(zip_path)
+                source = Path(zip_path)
+            metadata_path = source / Metadata.BASENAME
+            if not metadata_path.is_file():
+                metadata_path = source / LegacyMetadata.BASENAME
+            if not metadata_path.is_file():
+                raise ValueError(f"Not a valid RO-Crate: missing {Metadata.BASENAME}")
         _, entities = read_metadata(metadata_path)
         self.__read_data_entities(entities, source, gen_preview)
         self.__read_contextual_entities(entities)
         return source
 
     def __read_data_entities(self, entities, source, gen_preview):
+        if isinstance(source, dict):
+            source = Path("")
         metadata_id, root_id = find_root_entity_id(entities)
         root_entity = entities.pop(root_id)
         assert root_id == root_entity.pop('@id')
@@ -447,7 +452,7 @@ class ROCrate():
     def write(self, base_path):
         base_path = Path(base_path)
         base_path.mkdir(parents=True, exist_ok=True)
-        if self.source:
+        if self.source and not isinstance(self.source, dict):
             self._copy_unlisted(self.source, base_path)
         for writable_entity in self.data_entities + self.default_entities:
             writable_entity.write(base_path)


Test patch:
diff --git a/test/test_read.py b/test/test_read.py
index 48be710..a116451 100644
--- a/test/test_read.py
+++ b/test/test_read.py
@@ -535,3 +535,61 @@ def test_indirect_data_entity(tmpdir):
     d2_e = crate.dereference("d1/d2")
     assert d2_e
     assert d2_e in crate.data_entities
+
+
+@pytest.mark.filterwarnings("ignore")
+def test_from_dict(tmpdir):
+    metadata = {
+        "@context": "https://w3id.org/ro/crate/1.1/context",
+        "@graph": [
+            {
+                "@id": "ro-crate-metadata.json",
+                "@type": "CreativeWork",
+                "about": {"@id": "./"},
+                "conformsTo": {"@id": "https://w3id.org/ro/crate/1.1"}
+            },
+            {
+                "@id": "./",
+                "@type": "Dataset",
+                "creator": {"@id": "#josiah"},
+                "hasPart": {"@id": "d1"}
+            },
+            {
+                "@id": "d1",
+                "@type": "Dataset",
+                "hasPart": {"@id": "d1/d2"}
+            },
+            {
+                "@id": "d1/d2",
+                "@type": "Dataset",
+                "hasPart": {"@id": "d1/d2/f1"}
+            },
+            {
+                "@id": "d1/d2/f1",
+                "@type": "File"
+            },
+            {
+                "@id": "#josiah",
+                "@type": "Person",
+                'name': 'Josiah Carberry'
+            },
+        ]
+    }
+    crate = ROCrate(metadata)
+    d1 = crate.dereference("d1")
+    assert d1
+    d2 = crate.dereference("d1/d2")
+    assert d2
+    f1 = crate.dereference("d1/d2/f1")
+    assert f1
+    p = crate.dereference("#josiah")
+    assert p
+    assert set(crate.data_entities) == {d1, d2, f1}
+    assert set(crate.contextual_entities) == {p}
+    out_path = tmpdir / 'out_crate'
+    with pytest.raises(OSError):
+        crate.write(out_path)
+    # make it writable
+    for entity in d1, d2, f1:
+        entity.source = None
+    crate.write(out_path)