Repository: ResearchObject/ro-crate-py

Issue:
Empty file added through .add_file() is not present in zip
When adding a file say "empty.txt" with `rocrate.add_file("empty_txt")` it gets a correct entry in the metadata, but the resulting zip from `write_zip()` does not include that file. I would expect the file to be there.

The offending line seems to be the following, as here the file is created when a chunk is returned, which for the empty file it is not the case:
https://github.com/ResearchObject/ro-crate-py/blob/9c0d8a04b030c490d54664f5877b2a642933febc/rocrate/rocrate.py#L497

Hints from issue comments:
@simleo: @dnlbauer can you take a look at this?@dnlbauer: The streaming methods for data entities did not yield anything for empty files which resulted in the file being skipped by the zip writing method. My proposed solution #223  is to simply yield an empty byte if a file is empty to indicate to the writing method that there is indeed a file, but nothing to write for it.

Solution patch:
diff --git a/rocrate/model/dataset.py b/rocrate/model/dataset.py
index 457000d..9d97949 100644
--- a/rocrate/model/dataset.py
+++ b/rocrate/model/dataset.py
@@ -105,10 +105,16 @@ def _stream_folder_from_path(self, chunk_size=8192):
                 for name in files:
                     source = root / name
                     dest = source.relative_to(Path(path).parent)
+                    is_empty = True
                     with open(source, 'rb') as f:
                         while chunk := f.read(chunk_size):
+                            is_empty = False
                             yield str(dest), chunk
 
+                    # yield once for an empty file
+                    if is_empty:
+                        yield str(dest), b""
+
     def _stream_folder_from_url(self, chunk_size=8192):
         if not self.fetch_remote:
             if self.validate_url:
@@ -124,8 +130,14 @@ def _stream_folder_from_url(self, chunk_size=8192):
                     part_uri = f"{base}/{part}"
                     rel_out_path = Path(self.id) / part
 
+                    is_empty = True
                     with urlopen(part_uri) as response:
                         while chunk := response.read(chunk_size):
+                            is_empty = False
                             yield str(rel_out_path), chunk
+
+                    # yield once for an empty file
+                    if is_empty:
+                        yield str(rel_out_path), b""
                 except KeyError:
                     warnings.warn(f"'hasPart' entry in {self.id} is missing '@id'. Skipping.")
diff --git a/rocrate/model/file.py b/rocrate/model/file.py
index b3c3c06..c0b681d 100644
--- a/rocrate/model/file.py
+++ b/rocrate/model/file.py
@@ -116,6 +116,10 @@ def _stream_from_url(self, url, chunk_size=8192):
                         yield self.id, chunk
                         size += len(chunk)
 
+                # yield once for an empty file
+                if size == 0:
+                    yield self.id, b""
+
                 if self.record_size:
                     self._jsonld['contentSize'] = str(size)
 
@@ -126,6 +130,11 @@ def _stream_from_file(self, path, chunk_size=8192):
             while chunk := f.read(chunk_size):
                 yield self.id, chunk
                 size += len(chunk)
+
+        # yield once for an empty file
+        if size == 0:
+            yield self.id, b""
+
         if self.record_size:
             self._jsonld['contentSize'] = str(size)
 


Test patch:
diff --git a/test/test-data/empty_file_crate/empty.txt b/test/test-data/empty_file_crate/empty.txt
new file mode 100644
index 0000000..e69de29
diff --git a/test/test-data/empty_file_crate/folder/empty_not_listed.txt b/test/test-data/empty_file_crate/folder/empty_not_listed.txt
new file mode 100644
index 0000000..e69de29
diff --git a/test/test_write.py b/test/test_write.py
index 2096fe4..67a58f9 100644
--- a/test/test_write.py
+++ b/test/test_write.py
@@ -524,3 +524,27 @@ def test_percent_escape(test_data_dir, tmpdir, helpers):
     assert "subdir/a%20b/" in json_entities
     assert (out_path / "a b" / "c d.txt").is_file()
     assert (out_path / "subdir" / "a b" / "c d.txt").is_file()
+
+
+def test_stream_empty_file(test_data_dir, tmpdir):
+    """
+    Test that empty files are written correctly to the zip file.
+    """
+    crate = ROCrate()
+    crate_dir = test_data_dir / "empty_file_crate"
+    crate.add_file(crate_dir / "empty.txt")
+    crate.add_directory(crate_dir / "folder")
+
+    # write the crate to a zip file
+    out_path = tmpdir / 'ro_crate_out.zip'
+    crate.write_zip(out_path)
+
+    # Check that the zip file contains empty files
+    assert out_path.is_file()
+    files_in_zip = {}
+    with zipfile.ZipFile(out_path, "r") as zf:
+        for info in zf.infolist():
+            files_in_zip[info.filename] = info.file_size
+
+    assert files_in_zip["empty.txt"] == 0
+    assert files_in_zip["folder/empty_not_listed.txt"] == 0