Repository: ResearchObject/ro-crate-py
Pull request #48 (2021-02-17 11:48:18)

CLI add workflow
Adds a CLI command to add a workflow to the crate. If the file is already present in the directory tree (actually this is the only supported scenario for now), the ro-crate metadata file is changed to register the file as a workflow.

Example from the Docker container:

```
root@b5ef27b0dbc7:/ro-crate-py# cp -rf test/test-data/ro-crate-galaxy-sortchangecase /tmp/test_crate
root@b5ef27b0dbc7:/ro-crate-py# cd /tmp/test_crate/
root@b5ef27b0dbc7:/tmp/test_crate# rm -fv ro-crate-metadata.json 
removed 'ro-crate-metadata.json'
root@b5ef27b0dbc7:/tmp/test_crate# rocrate init
root@b5ef27b0dbc7:/tmp/test_crate# cat ro-crate-metadata.json 
{
    "@context": "https://w3id.org/ro/crate/1.1/context",
    "@graph": [
        {
            "@id": "./",
            "@type": "Dataset",
            "datePublished": "2021-02-17T11:42:06.081868",
            "hasPart": [
                {
                    "@id": "test/"
                },
                {
                    "@id": "LICENSE"
                },
                {
                    "@id": "sort-and-change-case.ga"
                },
                {
                    "@id": "README.md"
                },
                {
                    "@id": "test/test1/"
                },
                {
                    "@id": "test/test1/sort-and-change-case-test.yml"
                },
                {
                    "@id": "test/test1/input.bed"
                },
                {
                    "@id": "test/test1/output_exp.bed"
                }
            ]
        },
        {
            "@id": "ro-crate-metadata.json",
            "@type": "CreativeWork",
            "about": {
                "@id": "./"
            },
            "conformsTo": {
                "@id": "https://w3id.org/ro/crate/1.1"
            }
        },
        {
            "@id": "test/",
            "@type": "Dataset"
        },
        {
            "@id": "LICENSE",
            "@type": "File"
        },
        {
            "@id": "sort-and-change-case.ga",
            "@type": "File"
        },
        {
            "@id": "README.md",
            "@type": "File"
        },
        {
            "@id": "test/test1/",
            "@type": "Dataset"
        },
        {
            "@id": "test/test1/sort-and-change-case-test.yml",
            "@type": "File"
        },
        {
            "@id": "test/test1/input.bed",
            "@type": "File"
        },
        {
            "@id": "test/test1/output_exp.bed",
            "@type": "File"
        }
    ]
}root@b5ef27b0dbc7:/tmp/test_crate# rocrate add workflow -l galaxy sort-and-change-case.ga
root@b5ef27b0dbc7:/tmp/test_crate# cat ro-crate-metadata.json 
{
    "@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",
            "datePublished": "2021-02-17T11:42:06.081868",
            "hasPart": [
                {
                    "@id": "test/"
                },
                {
                    "@id": "LICENSE"
                },
                {
                    "@id": "sort-and-change-case.ga"
                },
                {
                    "@id": "README.md"
                },
                {
                    "@id": "test/test1/"
                },
                {
                    "@id": "test/test1/sort-and-change-case-test.yml"
                },
                {
                    "@id": "test/test1/input.bed"
                },
                {
                    "@id": "test/test1/output_exp.bed"
                }
            ],
            "mainEntity": {
                "@id": "sort-and-change-case.ga"
            }
        },
        {
            "@id": "ro-crate-preview.html",
            "@type": "CreativeWork",
            "about": {
                "@id": "./"
            }
        },
        {
            "@id": "test/",
            "@type": "Dataset"
        },
        {
            "@id": "LICENSE",
            "@type": "File"
        },
        {
            "@id": "sort-and-change-case.ga",
            "@type": [
                "File",
                "SoftwareSourceCode",
                "ComputationalWorkflow"
            ],
            "name": "sort-and-change-case",
            "programmingLanguage": {
                "@id": "#galaxy"
            }
        },
        {
            "@id": "README.md",
            "@type": "File"
        },
        {
            "@id": "test/test1/",
            "@type": "Dataset"
        },
        {
            "@id": "test/test1/sort-and-change-case-test.yml",
            "@type": "File"
        },
        {
            "@id": "test/test1/input.bed",
            "@type": "File"
        },
        {
            "@id": "test/test1/output_exp.bed",
            "@type": "File"
        },
        {
            "@id": "#galaxy",
            "@type": "ComputerLanguage",
            "identifier": {
                "@id": "https://galaxyproject.org/"
            },
            "name": "Galaxy",
            "url": {
                "@id": "https://galaxyproject.org/"
            },
            "version": "v19_09"
        }
    ]
}
```


Patch:
diff --git a/rocrate/cli.py b/rocrate/cli.py
index 799064e..0986665 100644
--- a/rocrate/cli.py
+++ b/rocrate/cli.py
@@ -1,27 +1,60 @@
-import click
+import os
+from pathlib import Path
 
+import click
 from .rocrate import ROCrate
+from .model.preview import Preview
+from .model.computerlanguage import LANG_MAP
 
 
-@click.group()
-def cli():
-    pass
-
-
-@click.command()
-@click.argument('top_dir')
-def init(top_dir):
-    crate = ROCrate(top_dir, init=True, load_preview=True)
-    crate.metadata.write(top_dir)
+LANG_CHOICES = list(LANG_MAP)
 
 
-@click.command()
-def add():
-    click.echo('Not implemented yet')
+class State:
+    pass
 
 
-cli.add_command(init)
-cli.add_command(add)
+@click.group()
+@click.option('-c', '--crate-dir', type=click.Path())
+@click.pass_context
+def cli(ctx, crate_dir):
+    ctx.obj = state = State()
+    state.crate_dir = crate_dir
+
+
+@cli.command()
+@click.pass_obj
+def init(state):
+    crate_dir = state.crate_dir or os.getcwd()
+    crate = ROCrate(crate_dir, init=True, load_preview=True)
+    crate.metadata.write(crate_dir)
+
+
+@cli.group()
+@click.pass_obj
+def add(state):
+    crate_dir = state.crate_dir or os.getcwd()
+    state.crate = ROCrate(crate_dir, init=False, load_preview=True)
+
+
+@add.command()
+@click.argument('path', type=click.Path(exists=True))
+@click.option('-l', '--language', type=click.Choice(LANG_CHOICES))
+@click.pass_obj
+def workflow(state, path, language):
+    crate_dir = state.crate_dir or os.getcwd()
+    source = Path(path).resolve(strict=True)
+    try:
+        dest_path = source.relative_to(crate_dir)
+    except ValueError:
+        # For now, only support marking an existing file as a workflow
+        raise ValueError(f"{source} is not in the crate dir")
+    # TODO: add command options for main and gen_cwl
+    state.crate.add_workflow(source, dest_path, main=True, lang=language, gen_cwl=False)
+    state.crate.metadata.write(crate_dir)
+    # FIXME: change preview generation to be optional when reading a crate
+    if not (Path(crate_dir) / Preview.BASENAME).is_file():
+        state.crate.preview.write(crate_dir)
 
 
 if __name__ == '__main__':
diff --git a/rocrate/model/computationalworkflow.py b/rocrate/model/computationalworkflow.py
index dff684f..722a8af 100644
--- a/rocrate/model/computationalworkflow.py
+++ b/rocrate/model/computationalworkflow.py
@@ -17,7 +17,11 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import atexit
 import os
+import tempfile
+from contextlib import redirect_stdout
+from galaxy2cwl import get_cwl_interface
 
 from .file import File
 
@@ -36,7 +40,34 @@ class ComputationalWorkflow(File):
             "name": os.path.splitext(self.id)[0],
         }
 
+    @property
+    def programmingLanguage(self):
+        return self["programmingLanguage"]
+
+    @programmingLanguage.setter
+    def programmingLanguage(self, programmingLanguage):
+        self["programmingLanguage"] = programmingLanguage
+
+    language = lang = programmingLanguage
+
+    @property
+    def subjectOf(self):
+        return self["subjectOf"]
+
+    @subjectOf.setter
+    def subjectOf(self, subjectOf):
+        self["subjectOf"] = subjectOf
+
 
 class Workflow(ComputationalWorkflow):
 
     TYPES = ["File", "SoftwareSourceCode", "Workflow"]
+
+
+def galaxy_to_abstract_cwl(workflow_path, delete=True):
+    with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix=".cwl") as f:
+        with redirect_stdout(f):
+            get_cwl_interface.main(['1', str(workflow_path)])
+    if delete:
+        atexit.register(os.unlink, f.name)
+    return f.name
diff --git a/rocrate/model/root_dataset.py b/rocrate/model/root_dataset.py
index 4e51f1c..106e128 100644
--- a/rocrate/model/root_dataset.py
+++ b/rocrate/model/root_dataset.py
@@ -43,14 +43,3 @@ class RootDataset(Dataset):
             "@type": "Dataset",
         }
         return val
-
-    # def write(self, base_path):
-        # os.mkdir(base_path)
-
-    def properties(self):
-        parts = []
-        for entity in self.crate.data_entities:
-            parts.append(entity.reference())
-        properties = self._jsonld
-        properties.update({'hasPart': parts})
-        return properties
diff --git a/rocrate/rocrate.py b/rocrate/rocrate.py
index 81b5858..b201278 100644
--- a/rocrate/rocrate.py
+++ b/rocrate/rocrate.py
@@ -37,8 +37,8 @@ from .model.dataset import Dataset
 from .model.metadata import Metadata, LegacyMetadata
 from .model.preview import Preview
 from .model.testdefinition import TestDefinition
-
-# Imports for the __subclasses__ hack below
+from .model.computationalworkflow import ComputationalWorkflow, galaxy_to_abstract_cwl
+from .model.computerlanguage import ComputerLanguage, get_lang
 from .model.testinstance import TestInstance
 from .model.testservice import TestService, get_service
 from .model.softwareapplication import SoftwareApplication, get_app, PLANEMO_DEFAULT_VERSION
@@ -167,7 +167,7 @@ class ROCrate():
         # add data and contextual entities to the crate
         (metadata_id, root_id) = self.find_root_entity_id(entities)
         root_entity = entities[root_id]
-        root_entity_parts = root_entity['hasPart']
+        root_entity_parts = root_entity.get('hasPart', [])
 
         # remove hasPart and id from root_entity and add the rest of the
         # properties to the build
@@ -380,7 +380,8 @@ class ROCrate():
 
     def add_file(self, source=None, dest_path=None, fetch_remote=False,
                  validate_url=True, properties=None):
-        return self.add(File(self, source=source, dest_path=dest_path, fetch_remote=fetch_remote, properties=properties))
+        return self.add(File(self, source=source, dest_path=dest_path, fetch_remote=fetch_remote,
+                             validate_url=validate_url, properties=properties))
 
     def add_dataset(self, source=None, dest_path=None, properties=None):
         return self.add(Dataset(self, source=source, dest_path=dest_path, properties=properties))
@@ -388,12 +389,20 @@ class ROCrate():
     add_directory = add_dataset
 
     def add(self, *entities):
+        """\
+        Add one or more entities to this RO-Crate.
+
+        If an entity with the same (canonical) id is already present in the
+        crate, it will be replaced (as in Python dictionaries).
+
+        Note that, according to the specs, "The RO-Crate Metadata JSON @graph
+        MUST NOT list multiple entities with the same @id; behaviour of
+        consumers of an RO-Crate encountering multiple entities with the same
+        @id is undefined". In practice, due to the replacement semantics, the
+        entity for a given id is the last one added to the crate with that id.
+        """
         for e in entities:
             key = e.canonical_id()
-            # crate MUST NOT list multiple entities with the same @id
-            if key in self.__entity_map:
-                raise ValueError(f'duplicate entity id: "{key}"')
-            self.__entity_map[key] = e
             if isinstance(e, RootDataset):
                 self.root_dataset = e
             if isinstance(e, (Metadata, LegacyMetadata)):
@@ -404,8 +413,12 @@ class ROCrate():
                 self.default_entities.append(e)
             elif hasattr(e, "write"):
                 self.data_entities.append(e)
+                if key not in self.__entity_map:
+                    self.root_dataset._jsonld.setdefault("hasPart", [])
+                    self.root_dataset["hasPart"] += [e]
             else:
                 self.contextual_entities.append(e)
+            self.__entity_map[key] = e
         return entities[0] if len(entities) == 1 else entities
 
     # TODO
@@ -433,6 +446,35 @@ class ROCrate():
         zf.close()
         return zf.filename
 
+    def add_workflow(
+            self, source=None, dest_path=None, fetch_remote=False, validate_url=True, properties=None,
+            main=False, lang="cwl", lang_version=None, gen_cwl=False
+    ):
+        workflow = self.add(ComputationalWorkflow(
+            self, source=source, dest_path=dest_path, fetch_remote=fetch_remote,
+            validate_url=validate_url, properties=properties
+        ))
+        if isinstance(lang, ComputerLanguage):
+            assert lang.crate is self
+        else:
+            kwargs = {"version": lang_version} if lang_version else {}
+            lang = get_lang(self, lang, **kwargs)
+            self.add(lang)
+        workflow.lang = lang
+        if main:
+            self.mainEntity = workflow
+        if gen_cwl and lang.id != "#cwl":
+            if lang.id != "#galaxy":
+                raise ValueError(f"conversion from {lang.name} to abstract CWL not supported")
+            cwl_source = galaxy_to_abstract_cwl(source)
+            cwl_dest_path = Path(source).with_suffix(".cwl").name
+            cwl_workflow = self.add_workflow(
+                source=cwl_source, dest_path=cwl_dest_path, fetch_remote=fetch_remote, properties=properties,
+                main=False, lang="cwl", gen_cwl=False
+            )
+            workflow.subjectOf = cwl_workflow
+        return workflow
+
     def add_test_suite(self, identifier=None, name=None, main_entity=None):
         if not main_entity:
             main_entity = self.mainEntity
@@ -458,8 +500,7 @@ class ROCrate():
             assert service.crate is self
         else:
             service = get_service(self, service)
-            if not self.dereference(service.id):
-                self.add(service)
+            self.add(service)
         instance.service = service
         instance.name = name or instance.id.lstrip("#")
         instance_set = set(suite.instance or [])
@@ -473,14 +514,14 @@ class ROCrate():
     ):
         suite = self.__validate_suite(suite)
         definition = self.add(
-            TestDefinition(self, source=source, dest_path=dest_path, fetch_remote=fetch_remote, properties=properties)
+            TestDefinition(self, source=source, dest_path=dest_path, fetch_remote=fetch_remote,
+                           validate_url=validate_url, properties=properties)
         )
         if isinstance(engine, SoftwareApplication):
             assert engine.crate is self
         else:
             engine = get_app(self, engine)
-            if not self.dereference(engine.id):
-                self.add(engine)
+            self.add(engine)
         definition.engine = engine
         definition.engineVersion = engine_version
         suite.definition = definition
diff --git a/rocrate/rocrate_api.py b/rocrate/rocrate_api.py
index de9d67a..23e3e37 100644
--- a/rocrate/rocrate_api.py
+++ b/rocrate/rocrate_api.py
@@ -17,25 +17,9 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-import atexit
-import os
-import tempfile
-from contextlib import redirect_stdout
 from pathlib import Path
 
 import rocrate.rocrate as roc
-from rocrate.model.computationalworkflow import ComputationalWorkflow
-from rocrate.model.computerlanguage import get_lang
-from galaxy2cwl import get_cwl_interface
-
-
-def galaxy_to_abstract_cwl(crate, workflow_path):
-    with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix=".cwl") as f:
-        with redirect_stdout(f):
-            get_cwl_interface.main(['1', str(workflow_path)])
-    atexit.register(os.unlink, f.name)
-    abstract_wf_id = workflow_path.with_suffix(".cwl").name
-    return ComputationalWorkflow(crate, f.name, abstract_wf_id)
 
 
 def make_workflow_rocrate(workflow_path, wf_type, include_files=[],
@@ -74,19 +58,12 @@ def make_workflow_rocrate(workflow_path, wf_type, include_files=[],
     # diagram: an image/graphical workflow representation.
     # If a CWL/CWLAbstract file is provided, this is generated using cwltool
 
-    wf_type = wf_type.lower()
     wf_crate = roc.ROCrate()
     workflow_path = Path(workflow_path)
-    # should this be added in a special path within the crate?
-    wf_file = wf_crate.add(ComputationalWorkflow(wf_crate, str(workflow_path), workflow_path.name))
-    wf_crate.mainEntity = wf_file
-    lang = get_lang(wf_crate, wf_type)
-    wf_crate.add(lang)
-    wf_file['programmingLanguage'] = lang
-    if wf_type == 'galaxy' and not cwl:
-        abstract_wf = galaxy_to_abstract_cwl(wf_crate, workflow_path)
-        wf_crate.add(abstract_wf)
-        wf_file["subjectOf"] = abstract_wf
+    wf_file = wf_crate.add_workflow(
+        str(workflow_path), workflow_path.name, fetch_remote=fetch_remote,
+        main=True, lang=wf_type, gen_cwl=(cwl is None)
+    )
 
     # if the source is a remote URL then add https://schema.org/codeRepository
     # property to it this can be checked by checking if the source is a URL


Test patch:
diff --git a/test/test_api.py b/test/test_api.py
index d96dbe9..73a0860 100644
--- a/test/test_api.py
+++ b/test/test_api.py
@@ -17,6 +17,7 @@
 
 from rocrate import rocrate_api as roc_api
 from rocrate.rocrate import ROCrate
+from rocrate.model.computerlanguage import CWL_DEFAULT_VERSION, GALAXY_DEFAULT_VERSION
 
 
 def test_galaxy_wf_crate(test_data_dir, tmpdir, helpers):
@@ -30,6 +31,7 @@ def test_galaxy_wf_crate(test_data_dir, tmpdir, helpers):
     assert wf_crate.mainEntity is wf
     lang = wf_crate.dereference("#galaxy")
     assert hasattr(lang, "name")
+    assert lang.version == GALAXY_DEFAULT_VERSION
     assert wf["programmingLanguage"] is lang
     assert wf["subjectOf"] is not None
     assert helpers.WORKFLOW_TYPES.issubset(wf["subjectOf"].type)
@@ -64,6 +66,7 @@ def test_cwl_wf_crate(test_data_dir, tmpdir, helpers):
     assert wf_crate.mainEntity is wf
     lang = wf_crate.dereference("#cwl")
     assert hasattr(lang, "name")
+    assert lang.version == CWL_DEFAULT_VERSION
     assert wf["programmingLanguage"] is lang
     assert wf["subjectOf"] is None
 
@@ -94,6 +97,7 @@ def test_create_wf_include(test_data_dir, tmpdir, helpers):
     assert wf_crate.mainEntity is wf
     lang = wf_crate.dereference("#galaxy")
     assert hasattr(lang, "name")
+    assert lang.version == GALAXY_DEFAULT_VERSION
     assert wf["programmingLanguage"] is lang
     assert wf["subjectOf"] is not None
     assert helpers.WORKFLOW_TYPES.issubset(wf["subjectOf"].type)
diff --git a/test/test_cli.py b/test/test_cli.py
new file mode 100644
index 0000000..8062d82
--- /dev/null
+++ b/test/test_cli.py
@@ -0,0 +1,72 @@
+# Copyright 2019-2020 The University of Manchester, UK
+# Copyright 2020 Vlaams Instituut voor Biotechnologie (VIB), BE
+# Copyright 2020 Barcelona Supercomputing Center (BSC), ES
+# Copyright 2020 Center for Advanced Studies, Research and Development in Sardinia (CRS4), IT
+#
+# 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.
+
+import pytest
+from click.testing import CliRunner
+
+from rocrate.cli import cli
+
+
+@pytest.mark.parametrize("cwd", [False, True])
+def test_cli_init(test_data_dir, helpers, monkeypatch, cwd):
+    crate_dir = test_data_dir / "ro-crate-galaxy-sortchangecase"
+    metadata_path = crate_dir / helpers.METADATA_FILE_NAME
+    metadata_path.unlink()
+
+    runner = CliRunner()
+    args = []
+    if cwd:
+        monkeypatch.chdir(str(crate_dir))
+    else:
+        args.extend(["-c", str(crate_dir)])
+    args.append("init")
+    result = runner.invoke(cli, args)
+    assert result.exit_code == 0
+    assert metadata_path.is_file()
+
+    json_entities = helpers.read_json_entities(crate_dir)
+    assert "sort-and-change-case.ga" in json_entities
+    assert json_entities["sort-and-change-case.ga"]["@type"] == "File"
+
+
+@pytest.mark.parametrize("cwd", [False, True])
+def test_cli_add_workflow(test_data_dir, helpers, monkeypatch, cwd):
+    # init
+    crate_dir = test_data_dir / "ro-crate-galaxy-sortchangecase"
+    runner = CliRunner()
+    assert runner.invoke(cli, ["-c", str(crate_dir), "init"]).exit_code == 0
+    json_entities = helpers.read_json_entities(crate_dir)
+    assert "sort-and-change-case.ga" in json_entities
+    assert json_entities["sort-and-change-case.ga"]["@type"] == "File"
+    # add
+    wf_path = crate_dir / "sort-and-change-case.ga"
+    args = []
+    if cwd:
+        monkeypatch.chdir(str(crate_dir))
+        wf_path = wf_path.relative_to(crate_dir)
+    else:
+        args.extend(["-c", str(crate_dir)])
+    for lang in "cwl", "galaxy":
+        extra_args = ["add", "workflow", "-l", lang, str(wf_path)]
+        result = runner.invoke(cli, args + extra_args)
+        assert result.exit_code == 0
+        json_entities = helpers.read_json_entities(crate_dir)
+        helpers.check_wf_crate(json_entities, wf_path.name)
+        assert "sort-and-change-case.ga" in json_entities
+        lang_id = f"#{lang}"
+        assert lang_id in json_entities
+        assert json_entities["sort-and-change-case.ga"]["programmingLanguage"]["@id"] == lang_id
diff --git a/test/test_model.py b/test/test_model.py
index 2666c9a..bd8d798 100644
--- a/test/test_model.py
+++ b/test/test_model.py
@@ -21,6 +21,7 @@ import uuid
 import pytest
 from rocrate.rocrate import ROCrate
 from rocrate.model.file import File
+from rocrate.model.computationalworkflow import ComputationalWorkflow
 from rocrate.model.person import Person
 
 
@@ -125,3 +126,29 @@ def test_uuid():
     # Check it made a valid UUIDv4
     u = uuid.UUID(jsonld["@id"][1:])
     assert 4 == u.version
+
+
+def test_update(test_data_dir, tmpdir, helpers):
+    crate = ROCrate()
+    assert not crate.root_dataset["hasPart"]
+    wf_path = test_data_dir / "test_galaxy_wf.ga"
+    john = crate.add(Person(crate, '#john', {'name': 'John Doe'}))
+    file_ = crate.add_file(wf_path)
+    assert crate.root_dataset["hasPart"] == [file_]
+    file_["author"] = john
+    assert isinstance(file_, File)
+    assert crate.mainEntity is None
+    wf = crate.add_workflow(wf_path, main=True, lang="galaxy")
+    assert isinstance(wf, ComputationalWorkflow)
+    assert wf["author"] is None
+    assert crate.mainEntity is wf
+    assert crate.dereference(john.id) is john
+    assert crate.dereference(file_.id) is wf
+    assert crate.root_dataset["hasPart"] == [wf]
+    assert crate.root_dataset.properties()["hasPart"] == [{"@id": "test_galaxy_wf.ga"}]
+
+    out_path = tmpdir / "ro_crate_out"
+    out_path.mkdir()
+    crate.write_crate(out_path)
+    json_entities = helpers.read_json_entities(out_path)
+    helpers.check_wf_crate(json_entities, wf.id)
diff --git a/test/test_read.py b/test/test_read.py
index f844987..22f393c 100644
--- a/test/test_read.py
+++ b/test/test_read.py
@@ -230,3 +230,14 @@ def test_init_preview(test_data_dir, tmpdir, helpers, load_preview, preview_exis
         assert out_prev_path.is_file()
         if load_preview:
             assert out_prev_path.open().read() == dummy_prev_content
+
+
+def test_no_parts(tmpdir):
+    crate = ROCrate()
+
+    out_path = tmpdir / 'ro_crate_out'
+    out_path.mkdir()
+    crate.write_crate(out_path)
+
+    crate = ROCrate(out_path)
+    assert not crate.root_dataset["hasPart"]
diff --git a/test/test_write.py b/test/test_write.py
index ceef9a8..babe16f 100644
--- a/test/test_write.py
+++ b/test/test_write.py
@@ -20,7 +20,6 @@ import pytest
 import sys
 import uuid
 import zipfile
-from pathlib import Path
 from urllib.error import URLError
 
 from rocrate.model.dataset import Dataset
@@ -202,3 +201,15 @@ def test_dataset(test_data_dir, tmpdir):
 
     assert (out_path / "b").is_dir()
     assert (out_path / "a" / "b").is_dir()
+
+
+def test_no_parts(tmpdir, helpers):
+    crate = ROCrate()
+
+    out_path = tmpdir / 'ro_crate_out'
+    out_path.mkdir()
+    crate.write_crate(out_path)
+
+    json_entities = helpers.read_json_entities(out_path)
+    helpers.check_crate(json_entities)
+    assert "hasPart" not in json_entities["./"]