Skip to content

Commit 8d05d98

Browse files
Merge pull request #1521 from lochhh/fix-storage-win
Use POSIX separators in file-protocol storage URLs and paths
2 parents afc350f + 1852c1a commit 8d05d98

4 files changed

Lines changed: 100 additions & 18 deletions

File tree

src/datajoint/storage.py

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,27 @@ def is_url(path: str) -> bool:
4545
return path.lower().startswith(URL_PROTOCOLS)
4646

4747

48+
def _path_to_file_url(resolved_path: Path | PurePosixPath) -> str:
49+
"""
50+
Convert an already-resolved absolute path to a ``file://`` URL.
51+
52+
Uses ``as_posix()`` so the same logic handles both POSIX paths (which
53+
already start with ``/``) and Windows paths (``C:/...``, no leading
54+
slash) without OS-specific branching.
55+
56+
Parameters
57+
----------
58+
resolved_path : Path or PurePosixPath
59+
Absolute, already-resolved path.
60+
61+
Returns
62+
-------
63+
str
64+
``file://`` URL.
65+
"""
66+
return f"file:///{resolved_path.as_posix().lstrip('/')}"
67+
68+
4869
def normalize_to_url(path: str) -> str:
4970
"""
5071
Normalize a path to URL form.
@@ -72,15 +93,7 @@ def normalize_to_url(path: str) -> str:
7293
"""
7394
if is_url(path):
7495
return path
75-
# Convert local path to file:// URL
76-
# Ensure absolute path and proper format
77-
abs_path = str(Path(path).resolve())
78-
# Handle Windows paths (C:\...) vs Unix paths (/...)
79-
if abs_path.startswith("/"):
80-
return f"file://{abs_path}"
81-
else:
82-
# Windows: file:///C:/path
83-
return f"file:///{abs_path.replace(chr(92), '/')}"
96+
return _path_to_file_url(Path(path).resolve())
8497

8598

8699
def parse_url(url: str) -> tuple[str, str]:
@@ -418,7 +431,7 @@ def _full_path(self, path: str | PurePosixPath) -> str:
418431
elif self.protocol == "file":
419432
location = self.spec.get("location", "")
420433
if location:
421-
return str(Path(location) / path)
434+
return (Path(location) / path).as_posix()
422435
return path
423436
else:
424437
return self._require_adapter().full_path(self.spec, path)
@@ -453,13 +466,7 @@ def get_url(self, path: str | PurePosixPath) -> str:
453466
full_path = self._full_path(path)
454467

455468
if self.protocol == "file":
456-
# Ensure absolute path for file:// URL
457-
abs_path = str(Path(full_path).resolve())
458-
if abs_path.startswith("/"):
459-
return f"file://{abs_path}"
460-
else:
461-
# Windows path
462-
return f"file:///{abs_path.replace(chr(92), '/')}"
469+
return _path_to_file_url(Path(full_path).resolve())
463470
elif self.protocol == "s3":
464471
return f"s3://{full_path}"
465472
elif self.protocol == "gcs":

tests/unit/test_gc.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Tests for garbage collection module."""
2+
3+
from pathlib import PurePosixPath, PureWindowsPath
4+
from unittest.mock import MagicMock
5+
6+
import pytest
7+
8+
from datajoint import storage
9+
from datajoint.gc import GarbageCollector
10+
from datajoint.storage import StorageBackend
11+
12+
13+
@pytest.mark.parametrize(
14+
("path_cls", "location"),
15+
[
16+
pytest.param(PureWindowsPath, "data\\blobs", id="windows"),
17+
pytest.param(PurePosixPath, "data/blobs", id="posix"),
18+
],
19+
)
20+
def test_delete_schema_path_prunes_parent_dir(monkeypatch, path_cls, location):
21+
"""Pruning's `rsplit("/", 1)` needs forward slashes; `windows` param catches
22+
a regression to `str(Path(...))`, `posix` pins the already-correct case."""
23+
monkeypatch.setattr(storage, "Path", path_cls)
24+
backend = StorageBackend.__new__(StorageBackend)
25+
backend.spec = {"protocol": "file", "location": location}
26+
backend.protocol = "file"
27+
backend._fs = MagicMock()
28+
backend.fs.exists.return_value = True
29+
# Ensure rmdir called only once: non-empty dir stops the walk one level up
30+
backend.fs.ls.side_effect = lambda p: [] if p == "data/blobs/schema/ab/cd" else ["sibling"]
31+
collector = GarbageCollector.__new__(GarbageCollector)
32+
collector.backend = backend
33+
assert collector.delete_schema_path("schema/ab/cd/hash123") is True
34+
backend.fs.rm.assert_called_once_with("data/blobs/schema/ab/cd/hash123")
35+
backend.fs.rmdir.assert_called_once_with("data/blobs/schema/ab/cd")

tests/unit/test_storage_adapter.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
"""Tests for the StorageAdapter plugin system."""
22

3+
import re
4+
from pathlib import PureWindowsPath
5+
36
import pytest
47

58
import datajoint as dj
9+
from datajoint import storage
610
from datajoint.errors import DataJointError
711
from datajoint.storage import StorageBackend
812
from datajoint.storage_adapter import (
13+
_COMMON_STORE_KEYS,
914
StorageAdapter,
1015
_adapter_registry,
11-
_COMMON_STORE_KEYS,
1216
get_storage_adapter,
1317
)
1418

@@ -193,6 +197,27 @@ def test_unsupported_protocol_get_url_raises(self):
193197
with pytest.raises(DataJointError, match="Unsupported storage protocol"):
194198
backend.get_url("schema/file.dat")
195199

200+
def test_file_protocol_full_path_uses_forward_slashes(self, monkeypatch):
201+
"""`_full_path` must return forward slashes to match fsspec's walk()
202+
output (gc.py relies on this for string-prefix stripping)."""
203+
# monkeypatch to PureWindowsPath so that the test is platform-independent
204+
monkeypatch.setattr(storage, "Path", PureWindowsPath)
205+
backend = StorageBackend.__new__(StorageBackend)
206+
backend.spec = {"protocol": "file", "location": "data\\blobs"}
207+
backend.protocol = "file"
208+
result = backend._full_path("schema/ab/cd/hash123")
209+
assert result == "data/blobs/schema/ab/cd/hash123"
210+
211+
def test_file_protocol_get_url_no_backslash(self, tmp_path):
212+
"""`get_url` must produce a valid file:// URL (forward slashes only)
213+
on whatever OS the test runs on, including Windows."""
214+
backend = StorageBackend.__new__(StorageBackend)
215+
backend.spec = {"protocol": "file", "location": str(tmp_path)}
216+
backend.protocol = "file"
217+
result = backend.get_url("schema/ab/cd/hash123")
218+
# exactly 3 slashes, no backslash and disregard tmp_path
219+
assert re.fullmatch(r"file:///[^/\\][^\\]*/schema/ab/cd/hash123", result)
220+
196221

197222
class TestGetStoreSpecPluginDelegation:
198223
"""Tests for plugin protocol handling in Config.get_store_spec()."""

tests/unit/test_storage_urls.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
"""Unit tests for storage URL functions."""
22

3+
from pathlib import PurePosixPath, PureWindowsPath
4+
35
import pytest
46

57
from datajoint.storage import (
68
URL_PROTOCOLS,
9+
_path_to_file_url,
710
is_url,
811
normalize_to_url,
912
parse_url,
@@ -79,6 +82,18 @@ def test_relative_path_becomes_absolute(self):
7982
assert "/" in url[7:] # After "file://"
8083

8184

85+
class TestPathToFileUrl:
86+
"""Test _path_to_file_url function (platform-independent via Pure*Path)."""
87+
88+
def test_posix_path(self):
89+
url = _path_to_file_url(PurePosixPath("/data/file.dat"))
90+
assert url == "file:///data/file.dat"
91+
92+
def test_windows_path_no_backslash_in_result(self):
93+
url = _path_to_file_url(PureWindowsPath("C:\\data\\file.dat"))
94+
assert url == "file:///C:/data/file.dat"
95+
96+
8297
class TestParseUrl:
8398
"""Test parse_url function."""
8499

0 commit comments

Comments
 (0)