Skip to content

Commit 1653552

Browse files
authored
LocalFileSystem fix _strip_protocol for root directory (fsspec#1477)
1 parent ecc5765 commit 1653552

File tree

5 files changed

+109
-77
lines changed

5 files changed

+109
-77
lines changed

fsspec/implementations/local.py

Lines changed: 65 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import logging
44
import os
55
import os.path as osp
6-
import re
76
import shutil
87
import stat
98
import tempfile
@@ -16,6 +15,12 @@
1615
logger = logging.getLogger("fsspec.local")
1716

1817

18+
def _remove_prefix(text: str, prefix: str):
19+
if text.startswith(prefix):
20+
return text[len(prefix) :]
21+
return text
22+
23+
1924
class LocalFileSystem(AbstractFileSystem):
2025
"""Interface to files on local storage
2126
@@ -116,8 +121,8 @@ def lexists(self, path, **kwargs):
116121
return osp.lexists(path)
117122

118123
def cp_file(self, path1, path2, **kwargs):
119-
path1 = self._strip_protocol(path1).rstrip("/")
120-
path2 = self._strip_protocol(path2).rstrip("/")
124+
path1 = self._strip_protocol(path1, remove_trailing_slash=True)
125+
path2 = self._strip_protocol(path2, remove_trailing_slash=True)
121126
if self.auto_mkdir:
122127
self.makedirs(self._parent(path2), exist_ok=True)
123128
if self.isfile(path1):
@@ -138,8 +143,8 @@ def put_file(self, path1, path2, callback=None, **kwargs):
138143
return self.cp_file(path1, path2, **kwargs)
139144

140145
def mv_file(self, path1, path2, **kwargs):
141-
path1 = self._strip_protocol(path1).rstrip("/")
142-
path2 = self._strip_protocol(path2).rstrip("/")
146+
path1 = self._strip_protocol(path1, remove_trailing_slash=True)
147+
path2 = self._strip_protocol(path2, remove_trailing_slash=True)
143148
shutil.move(path1, path2)
144149

145150
def link(self, src, dst, **kwargs):
@@ -163,7 +168,7 @@ def rm(self, path, recursive=False, maxdepth=None):
163168
path = [path]
164169

165170
for p in path:
166-
p = self._strip_protocol(p).rstrip("/")
171+
p = self._strip_protocol(p, remove_trailing_slash=True)
167172
if self.isdir(p):
168173
if not recursive:
169174
raise ValueError("Cannot delete directory, set recursive=True")
@@ -206,24 +211,32 @@ def modified(self, path):
206211

207212
@classmethod
208213
def _parent(cls, path):
209-
path = cls._strip_protocol(path).rstrip("/")
210-
if "/" in path:
211-
return path.rsplit("/", 1)[0]
214+
path = cls._strip_protocol(path, remove_trailing_slash=True)
215+
if os.sep == "/":
216+
# posix native
217+
return path.rsplit("/", 1)[0] or "/"
212218
else:
213-
return cls.root_marker
219+
# NT
220+
path_ = path.rsplit("/", 1)[0]
221+
if len(path_) <= 3:
222+
if path_[1:2] == ":":
223+
# nt root (something like c:/)
224+
return path_[0] + ":/"
225+
# More cases may be required here
226+
return path_
214227

215228
@classmethod
216-
def _strip_protocol(cls, path):
229+
def _strip_protocol(cls, path, remove_trailing_slash=False):
217230
path = stringify_path(path)
218-
if path.startswith("file://"):
219-
path = path[7:]
220-
elif path.startswith("file:"):
221-
path = path[5:]
222-
elif path.startswith("local://"):
223-
path = path[8:]
231+
if path.startswith("file:"):
232+
path = _remove_prefix(_remove_prefix(path, "file://"), "file:")
233+
if os.sep == "\\":
234+
path = path.lstrip("/")
224235
elif path.startswith("local:"):
225-
path = path[6:]
226-
return make_path_posix(path).rstrip("/") or cls.root_marker
236+
path = _remove_prefix(_remove_prefix(path, "local://"), "local:")
237+
if os.sep == "\\":
238+
path = path.lstrip("/")
239+
return make_path_posix(path, remove_trailing_slash)
227240

228241
def _isfilestore(self):
229242
# Inheriting from DaskFileSystem makes this False (S3, etc. were)
@@ -236,47 +249,42 @@ def chmod(self, path, mode):
236249
return os.chmod(path, mode)
237250

238251

239-
def make_path_posix(path, sep=os.sep):
240-
"""Make path generic"""
241-
if isinstance(path, (list, set, tuple)):
242-
return type(path)(make_path_posix(p) for p in path)
243-
if "~" in path:
244-
path = osp.expanduser(path)
245-
if sep == "/":
246-
# most common fast case for posix
252+
def make_path_posix(path, remove_trailing_slash=False):
253+
"""Make path generic for current OS"""
254+
if not isinstance(path, str):
255+
if isinstance(path, (list, set, tuple)):
256+
return type(path)(make_path_posix(p, remove_trailing_slash) for p in path)
257+
else:
258+
path = str(stringify_path(path))
259+
if os.sep == "/":
260+
# Native posix
247261
if path.startswith("/"):
248-
return path
249-
if path.startswith("./"):
262+
# most common fast case for posix
263+
return path.rstrip("/") or "/" if remove_trailing_slash else path
264+
elif path.startswith("~"):
265+
return make_path_posix(osp.expanduser(path), remove_trailing_slash)
266+
elif path.startswith("./"):
250267
path = path[2:]
268+
path = f"{os.getcwd()}/{path}"
269+
return path.rstrip("/") or "/" if remove_trailing_slash else path
251270
return f"{os.getcwd()}/{path}"
252-
if (
253-
(sep not in path and "/" not in path)
254-
or (sep == "/" and not path.startswith("/"))
255-
or (sep == "\\" and ":" not in path and not path.startswith("\\\\"))
256-
):
257-
# relative path like "path" or "rel\\path" (win) or rel/path"
258-
if os.sep == "\\":
259-
# abspath made some more '\\' separators
260-
return make_path_posix(osp.abspath(path))
261-
else:
262-
return f"{os.getcwd()}/{path}"
263-
if path.startswith("file://"):
264-
path = path[7:]
265-
if re.match("/[A-Za-z]:", path):
266-
# for windows file URI like "file:///C:/folder/file"
267-
# or "file:///C:\\dir\\file"
268-
path = path[1:].replace("\\", "/").replace("//", "/")
269-
if path.startswith("\\\\"):
270-
# special case for windows UNC/DFS-style paths, do nothing,
271-
# just flip the slashes around (case below does not work!)
272-
return path.replace("\\", "/")
273-
if re.match("[A-Za-z]:", path):
274-
# windows full path like "C:\\local\\path"
275-
return path.lstrip("\\").replace("\\", "/").replace("//", "/")
276-
if path.startswith("\\"):
277-
# windows network path like "\\server\\path"
278-
return "/" + path.lstrip("\\").replace("\\", "/").replace("//", "/")
279-
return path
271+
else:
272+
# NT handling
273+
if len(path) > 1:
274+
if path[1] == ":":
275+
# windows full path like "C:\\local\\path"
276+
if len(path) <= 3:
277+
# nt root (something like c:/)
278+
return path[0] + ":/"
279+
path = path.replace("\\", "/").replace("//", "/")
280+
return path.rstrip("/") if remove_trailing_slash else path
281+
elif path[0] == "~":
282+
return make_path_posix(osp.expanduser(path), remove_trailing_slash)
283+
elif path.startswith(("\\\\", "//")):
284+
# windows UNC/DFS-style paths
285+
path = "//" + path[2:].replace("\\", "/").replace("//", "/")
286+
return path.rstrip("/") if remove_trailing_slash else path
287+
return make_path_posix(osp.abspath(path), remove_trailing_slash)
280288

281289

282290
def trailing_sep(path):

fsspec/implementations/tests/test_jupyter.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,10 @@
1313

1414
@pytest.fixture()
1515
def jupyter(tmpdir):
16-
1716
tmpdir = str(tmpdir)
1817
os.environ["JUPYTER_TOKEN"] = "blah"
1918
try:
20-
cmd = f"jupyter notebook --notebook-dir={tmpdir} --no-browser --port=5566"
19+
cmd = f'jupyter notebook --notebook-dir="{tmpdir}" --no-browser --port=5566'
2120
P = subprocess.Popen(shlex.split(cmd))
2221
except FileNotFoundError:
2322
pytest.skip("notebook not installed correctly")

fsspec/implementations/tests/test_local.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -472,33 +472,48 @@ def test_make_path_posix():
472472
drive = cwd[0]
473473
assert make_path_posix("/a/posix/path") == f"{drive}:/a/posix/path"
474474
assert make_path_posix("/posix") == f"{drive}:/posix"
475+
# Windows drive requires trailing slash
476+
assert make_path_posix("C:\\") == "C:/"
477+
assert make_path_posix("C:\\", remove_trailing_slash=True) == "C:/"
475478
else:
476479
assert make_path_posix("/a/posix/path") == "/a/posix/path"
477480
assert make_path_posix("/posix") == "/posix"
478481
assert make_path_posix("relpath") == posixpath.join(make_path_posix(cwd), "relpath")
479482
assert make_path_posix("rel/path") == posixpath.join(
480483
make_path_posix(cwd), "rel/path"
481484
)
485+
# NT style
482486
if WIN:
483487
assert make_path_posix("C:\\path") == "C:/path"
484-
assert make_path_posix("file://C:\\path\\file") == "C:/path/file"
485-
if WIN:
486488
assert (
487489
make_path_posix(
488-
"\\\\windows-server\\someshare\\path\\more\\path\\dir\\foo.parquet"
490+
"\\\\windows-server\\someshare\\path\\more\\path\\dir\\foo.parquet",
489491
)
490492
== "//windows-server/someshare/path/more/path/dir/foo.parquet"
491493
)
492494
assert (
493495
make_path_posix(
494-
r"\\SERVER\UserHomeFolder$\me\My Documents\project1\data\filen.csv"
496+
"\\\\SERVER\\UserHomeFolder$\\me\\My Documents\\proj\\data\\fname.csv",
495497
)
496-
== "//SERVER/UserHomeFolder$/me/My Documents/project1/data/filen.csv"
498+
== "//SERVER/UserHomeFolder$/me/My Documents/proj/data/fname.csv"
497499
)
498500
assert "/" in make_path_posix("rel\\path")
499-
501+
# Relative
500502
pp = make_path_posix("./path")
501-
assert "./" not in pp and ".\\" not in pp
503+
cd = make_path_posix(cwd)
504+
assert pp == cd + "/path"
505+
# Userpath
506+
userpath = make_path_posix("~/path")
507+
assert userpath.endswith("/path")
508+
509+
510+
def test_parent():
511+
if WIN:
512+
assert LocalFileSystem._parent("C:\\file or folder") == "C:/"
513+
assert LocalFileSystem._parent("C:\\") == "C:/"
514+
else:
515+
assert LocalFileSystem._parent("/file or folder") == "/"
516+
assert LocalFileSystem._parent("/") == "/"
502517

503518

504519
def test_linked_files(tmpdir):
@@ -638,16 +653,22 @@ def test_strip_protocol_expanduser():
638653
path = "file://~\\foo\\bar" if WIN else "file://~/foo/bar"
639654
stripped = LocalFileSystem._strip_protocol(path)
640655
assert path != stripped
656+
assert "~" not in stripped
641657
assert "file://" not in stripped
642658
assert stripped.startswith(os.path.expanduser("~").replace("\\", "/"))
643-
assert not LocalFileSystem._strip_protocol("./").endswith("/")
659+
path = LocalFileSystem._strip_protocol("./", remove_trailing_slash=True)
660+
assert not path.endswith("/")
644661

645662

646663
def test_strip_protocol_no_authority():
647664
path = "file:\\foo\\bar" if WIN else "file:/foo/bar"
648665
stripped = LocalFileSystem._strip_protocol(path)
649666
assert "file:" not in stripped
650667
assert stripped.endswith("/foo/bar")
668+
if WIN:
669+
assert (
670+
LocalFileSystem._strip_protocol("file://C:\\path\\file") == "C:/path/file"
671+
)
651672

652673

653674
def test_mkdir_twice_faile(tmpdir):

fsspec/mapping.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class FSMap(MutableMapping):
4040

4141
def __init__(self, root, fs, check=False, create=False, missing_exceptions=None):
4242
self.fs = fs
43-
self.root = fs._strip_protocol(root).rstrip("/")
43+
self.root = fs._strip_protocol(root)
4444
self._root_key_to_str = fs._strip_protocol(posixpath.join(root, "x"))[:-1]
4545
if missing_exceptions is None:
4646
missing_exceptions = (
@@ -142,7 +142,7 @@ def _key_to_str(self, key):
142142
if isinstance(key, list):
143143
key = tuple(key)
144144
key = str(key)
145-
return f"{self._root_key_to_str}{key}"
145+
return f"{self._root_key_to_str}{key}".rstrip("/")
146146

147147
def _str_to_key(self, s):
148148
"""Strip path of to leave key name"""

fsspec/tests/test_mapping.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -200,19 +200,23 @@ def test_fsmap_error_on_protocol_keys():
200200
_ = m[f"memory://{root}/a"]
201201

202202

203-
# on Windows opening a directory will raise PermissionError
204-
# see: https://bugs.python.org/issue43095
205-
@pytest.mark.skipif(
206-
platform.system() == "Windows", reason="raises PermissionError on windows"
207-
)
208203
def test_fsmap_access_with_suffix(tmp_path):
209204
tmp_path.joinpath("b").mkdir()
210205
tmp_path.joinpath("b", "a").write_bytes(b"data")
211-
m = fsspec.get_mapper(f"file://{tmp_path}")
212-
206+
if platform.system() == "Windows":
207+
# on Windows opening a directory will raise PermissionError
208+
# see: https://bugs.python.org/issue43095
209+
missing_exceptions = (
210+
FileNotFoundError,
211+
IsADirectoryError,
212+
NotADirectoryError,
213+
PermissionError,
214+
)
215+
else:
216+
missing_exceptions = None
217+
m = fsspec.get_mapper(f"file://{tmp_path}", missing_exceptions=missing_exceptions)
213218
with pytest.raises(KeyError):
214219
_ = m["b/"]
215-
216220
assert m["b/a/"] == b"data"
217221

218222

0 commit comments

Comments
 (0)