Skip to content

Commit 44e33cb

Browse files
committed
Parse file://a/b/ as a relative path, like fsspec
1 parent ec2c359 commit 44e33cb

3 files changed

Lines changed: 85 additions & 14 deletions

File tree

‎changes/4533.bugfix.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
Mimic fsspec behavior when parsing file:// URLs
2+
3+
File URLs like
4+
5+
`file://a`
6+
7+
were being parsed in `parse_store_url` with a path like
8+
9+
`ParsedStoreUrl(..., path="")`
10+
11+
instead of
12+
13+
`ParsedStoreUrl(..., path="./a")`
14+
15+
which is the behavior in `fsspec`. This also makes resolves the parsed
16+
paths to their absolute representation immediately, to prevent eventual
17+
changes in CWD from affecting the resolution of the path later.

‎src/zarr/storage/_utils.py‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,24 @@ def parse_store_url(url: str) -> ParsedStoreUrl:
9090

9191
parsed = urlparse(url)
9292

93-
# netloc is the "host" part (store name for memory://, bucket for s3://, etc.)
94-
name = parsed.netloc or None
95-
96-
# For URLs with a scheme and netloc (like memory://store/path or s3://bucket/key),
97-
# strip the leading slash from the path component.
98-
# For local paths (no scheme), preserve the path as-is.
99-
if parsed.scheme and parsed.netloc:
100-
path = parsed.path.lstrip("/")
93+
# Since we're allowing windows paths to carry URL separator characters,
94+
# we'll do the same for posix paths as well
95+
if parsed.scheme == "":
96+
abs_path = Path(url).absolute()
97+
return ParsedStoreUrl(scheme="", name=None, path=str(abs_path), raw=url)
98+
99+
if parsed.scheme == "file":
100+
if parsed.query:
101+
raise ValueError(f"Query is not supported in file:// URLs: {parsed.query}")
102+
if parsed.fragment:
103+
raise ValueError(f"Fragments are not supported in file:// URLs: {parsed.fragment}")
104+
name = None
105+
# mimic fsspec and interpret file://a/b as ./a/b
106+
path = str(Path(parsed.netloc + parsed.path).absolute())
101107
else:
102-
path = parsed.path
108+
# netloc is the "host" part (store name for memory://, bucket for s3://, etc.)
109+
name = parsed.netloc or None
110+
path = parsed.path.lstrip("/")
103111

104112
return ParsedStoreUrl(scheme=parsed.scheme, name=name, path=path, raw=url)
105113

‎tests/test_store/test_utils.py‎

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import sys
4+
from typing import TYPE_CHECKING
45
from unittest.mock import patch
56

67
import pytest
@@ -9,6 +10,9 @@
910
from zarr.core.buffer.core import default_buffer_prototype
1011
from zarr.storage._utils import ParsedStoreUrl, _normalize_byte_range_index, parse_store_url
1112

13+
if TYPE_CHECKING:
14+
from pathlib import Path
15+
1216

1317
class TestParseStoreUrl:
1418
"""Tests for parse_store_url."""
@@ -39,18 +43,60 @@ def test_s3_url(self) -> None:
3943
scheme="s3", name="bucket", path="key", raw="s3://bucket/key"
4044
)
4145

42-
def test_file_url(self) -> None:
43-
result = parse_store_url("file:///tmp/test")
44-
assert result.scheme == "file"
46+
@pytest.mark.parametrize(
47+
("url", "expected_path"),
48+
[
49+
("file:///tmp/test", "/tmp/test"),
50+
("file:///a/b", "/a/b"),
51+
("file:///a/b/", "/a/b"),
52+
("file:///", "/"),
53+
],
54+
)
55+
def test_absolute_file_url(self, url: str, expected_path: str) -> None:
56+
"""file:///a/b (empty netloc) is an absolute path."""
57+
result = parse_store_url(url)
58+
assert result == ParsedStoreUrl(scheme="file", name=None, path=expected_path, raw=url)
59+
60+
@pytest.mark.parametrize(
61+
("url", "relative_path"),
62+
[
63+
("file://a/b", "a/b"),
64+
("file://a/b/", "a/b"),
65+
("file://a", "a"),
66+
("file://./a/b", "a/b"),
67+
("file://", ""),
68+
],
69+
)
70+
def test_relative_file_url(
71+
self, url: str, relative_path: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
72+
) -> None:
73+
"""file://a/b is a path relative to the cwd, not a host named "a"."""
74+
monkeypatch.chdir(tmp_path)
75+
result = parse_store_url(url)
76+
assert result == ParsedStoreUrl(
77+
scheme="file", name=None, path=str(tmp_path / relative_path), raw=url
78+
)
79+
80+
@pytest.mark.parametrize(
81+
"url",
82+
[
83+
"file:///a/b?query=1",
84+
"file:///a/b#fragment",
85+
],
86+
)
87+
def test_file_url_rejects_params_query_fragment(self, url: str) -> None:
88+
with pytest.raises(ValueError, match="not supported in file:// URLs"):
89+
parse_store_url(url)
4590

4691
def test_local_absolute_path(self) -> None:
4792
result = parse_store_url("/local/path")
4893
assert result == ParsedStoreUrl(scheme="", name=None, path="/local/path", raw="/local/path")
4994

50-
def test_local_relative_path(self) -> None:
95+
def test_local_relative_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
96+
monkeypatch.chdir(tmp_path)
5197
result = parse_store_url("relative/path")
5298
assert result == ParsedStoreUrl(
53-
scheme="", name=None, path="relative/path", raw="relative/path"
99+
scheme="", name=None, path=str(tmp_path / "relative/path"), raw="relative/path"
54100
)
55101

56102
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)