Repository: ResearchObject/ro-crate-py

Issue:
Special character in filename/path not escaped in id, when using `ROCrate(init=True)`
I have installed the rocrate package from the github URL so I have the version of the last commit, so including the fixes in this past PR https://github.com/ResearchObject/ro-crate-py/pull/217.     

Still I think the special characters (spaces..) in the filename or path are not escaped/quoted, when a crate is created from an existing directory, containing files with such characters.

Here is an example reproducing the issue.

```python
from rocrate.rocrate import ROCrate
import os

new_crate_root = "./test_crate"

# create a directory with a single text file
if not os.path.exists(new_crate_root):
    os.mkdir(new_crate_root)

# create a file with spaces in the name
with open(os.path.join(new_crate_root, "file with space.txt"), "w") as f:
    f.write("empty")

# Initialize a crate from the directory and save it to create the json
crate = ROCrate(new_crate_root, init=True)
crate.write(new_crate_root) 

# then reload it, this time parsing it
crate = ROCrate(new_crate_root)
file1 = crate.get_by_type("File")[0]

print(f"{file1.id=}") #  missing the % !
assert "%" in file1.id 
```

I believe the quoting happens in all other cases (see below).  
But when `init=True` in the `ROCrate` constructor then `crate.source` evaluate to `True` and the identifier is not quoted.   
https://github.com/ResearchObject/ro-crate-py/blob/ae7d12e0831b397f8b477b1edb7479159bb37344/rocrate/model/file_or_dir.py#L48-L49

Hints from issue comments:

Solution patch:
diff --git a/rocrate/model/dataset.py b/rocrate/model/dataset.py
index 9d97949..54b06ba 100644
--- a/rocrate/model/dataset.py
+++ b/rocrate/model/dataset.py
@@ -30,7 +30,7 @@
 from urllib.parse import unquote
 
 from .file_or_dir import FileOrDir
-from ..utils import is_url, iso_now
+from ..utils import is_url, iso_now, Mode
 
 
 class Dataset(FileOrDir):
@@ -75,7 +75,7 @@ def _copy_folder(self, base_path):
                     errno.ENOENT, os.strerror(errno.ENOENT), path
                 )
             abs_out_path.mkdir(parents=True, exist_ok=True)
-            if not self.crate.source:
+            if self.crate.mode == Mode.CREATE:
                 self.crate._copy_unlisted(path, abs_out_path)
 
     def write(self, base_path):
@@ -99,7 +99,7 @@ def _stream_folder_from_path(self, chunk_size=8192):
             raise FileNotFoundError(
                 errno.ENOENT, os.strerror(errno.ENOENT), str(path)
             )
-        if not self.crate.source:
+        if self.crate.mode == Mode.CREATE:
             for root, _, files in os.walk(path):
                 root = Path(root)
                 for name in files:
diff --git a/rocrate/model/file_or_dir.py b/rocrate/model/file_or_dir.py
index 83eaf60..0ded41e 100644
--- a/rocrate/model/file_or_dir.py
+++ b/rocrate/model/file_or_dir.py
@@ -27,7 +27,7 @@
 from urllib.parse import quote
 
 from .data_entity import DataEntity
-from ..utils import is_url
+from ..utils import is_url, Mode
 
 
 class FileOrDir(DataEntity):
@@ -45,7 +45,7 @@ def __init__(self, crate, source=None, dest_path=None, fetch_remote=False,
             if dest_path.is_absolute():
                 raise ValueError("if provided, dest_path must be relative")
             identifier = dest_path.as_posix()
-            if not crate.source:
+            if not crate.mode == Mode.READ:
                 identifier = quote(identifier)
         else:
             if not isinstance(source, (str, Path)):
@@ -54,6 +54,6 @@ def __init__(self, crate, source=None, dest_path=None, fetch_remote=False,
                 identifier = os.path.basename(source) if fetch_remote else source
             else:
                 identifier = os.path.basename(str(source).rstrip("/"))
-                if not crate.source:
+                if not crate.mode == Mode.READ:
                     identifier = quote(identifier)
         super().__init__(crate, identifier, properties)
diff --git a/rocrate/rocrate.py b/rocrate/rocrate.py
index 372660e..5fea305 100644
--- a/rocrate/rocrate.py
+++ b/rocrate/rocrate.py
@@ -60,7 +60,7 @@
 from .model.testservice import get_service
 from .model.softwareapplication import get_app
 
-from .utils import is_url, subclasses, get_norm_value, walk, as_list
+from .utils import is_url, subclasses, get_norm_value, walk, as_list, Mode
 from .metadata import read_metadata, find_root_entity_id
 
 
@@ -79,6 +79,7 @@ def pick_type(json_entity, type_map, fallback=None):
 class ROCrate():
 
     def __init__(self, source=None, gen_preview=False, init=False, exclude=None):
+        self.mode = None
         self.source = source
         self.exclude = exclude
         self.__entity_map = {}
@@ -90,13 +91,15 @@ def __init__(self, source=None, gen_preview=False, init=False, exclude=None):
         if gen_preview:
             self.add(Preview(self))
         if not source:
-            # create a new ro-crate
+            self.mode = Mode.CREATE
             self.add(RootDataset(self), Metadata(self))
         elif init:
+            self.mode = Mode.INIT
             if isinstance(source, dict):
                 raise ValueError("parameter 'init' is not compatible with a dict source")
             self.__init_from_tree(source, gen_preview=gen_preview)
         else:
+            self.mode = Mode.READ
             source = self.__read(source, gen_preview=gen_preview)
         # in the zip case, self.source is the extracted dir
         self.source = source
diff --git a/rocrate/utils.py b/rocrate/utils.py
index b40bfe9..730ec60 100644
--- a/rocrate/utils.py
+++ b/rocrate/utils.py
@@ -22,6 +22,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from enum import Enum
 import os
 from datetime import datetime, timezone
 from urllib.parse import urlsplit
@@ -77,3 +78,9 @@ def walk(top, topdown=True, onerror=None, followlinks=False, exclude=None):
             dirs[:] = [_ for _ in dirs if _ not in exclude]
             files[:] = [_ for _ in files if _ not in exclude]
         yield root, dirs, files
+
+
+class Mode(Enum):
+    READ = 1
+    INIT = 2
+    CREATE = 3


Test patch:
diff --git a/test/test_read.py b/test/test_read.py
index c0dd702..4396d89 100644
--- a/test/test_read.py
+++ b/test/test_read.py
@@ -662,3 +662,24 @@ def test_no_data_entity_link_from_file():
     assert t1
     assert t1 not in crate.data_entities
     assert t1 in crate.contextual_entities
+
+
+def test_init_percent_escape(tmpdir, helpers):
+    in_crate = tmpdir / "in_crate"
+    in_file = in_crate / "in file.txt"
+    in_dir = in_crate / "in dir"
+    deep_file = in_crate / "in dir" / "deep file.txt"
+    out_crate = tmpdir / "out_crate"
+    in_crate.mkdir()
+    in_file.write_text("IN FILE\n")
+    in_dir.mkdir()
+    deep_file.write_text("DEEP FILE\n")
+    crate = ROCrate(in_crate, init=True)
+    crate.write(out_crate)
+    json_entities = helpers.read_json_entities(out_crate)
+    assert "in%20file.txt" in json_entities
+    assert "in%20dir/" in json_entities
+    assert "in%20dir/deep%20file.txt" in json_entities
+    assert (out_crate / "in file.txt").is_file()
+    assert (out_crate / "in dir").is_dir()
+    assert (out_crate / "in dir" / "deep file.txt").is_file()