1import datetime
2import io
3import logging
4import os
5import os.path as osp
6import shutil
7import stat
8import tempfile
9from functools import lru_cache
10
11from fsspec.compression import compr
12from fsspec.core import get_compression
13from fsspec.utils import isfilelike, stringify_path
14
15from ..spec import AbstractFileSystem
16
17logger = logging.getLogger("fsspec.local")
18
19
20class LocalFileSystem(AbstractFileSystem):
21 """Interface to files on local storage
22
23 Parameters
24 ----------
25 auto_mkdir: bool
26 Whether, when opening a file, the directory containing it should
27 be created (if it doesn't already exist). This is assumed by pyarrow
28 code.
29 """
30
31 root_marker = "/"
32 protocol = "file", "local"
33 local_file = True
34
35 def __init__(self, auto_mkdir=False, **kwargs):
36 super().__init__(**kwargs)
37 self.auto_mkdir = auto_mkdir
38
39 @property
40 def fsid(self):
41 return "local"
42
43 def mkdir(self, path, create_parents=True, **kwargs):
44 path = self._strip_protocol(path)
45 if self.exists(path):
46 raise FileExistsError(path)
47 if create_parents:
48 self.makedirs(path, exist_ok=True)
49 else:
50 os.mkdir(path, **kwargs)
51
52 def makedirs(self, path, exist_ok=False):
53 path = self._strip_protocol(path)
54 os.makedirs(path, exist_ok=exist_ok)
55
56 def rmdir(self, path):
57 path = self._strip_protocol(path)
58 os.rmdir(path)
59
60 def ls(self, path, detail=False, **kwargs):
61 path = self._strip_protocol(path)
62 path_info = self.info(path)
63 infos = []
64 if path_info["type"] == "directory":
65 with os.scandir(path) as it:
66 for f in it:
67 try:
68 # Only get the info if requested since it is a bit expensive (the stat call inside)
69 # The strip_protocol is also used in info() and calls make_path_posix to always return posix paths
70 info = self.info(f) if detail else self._strip_protocol(f.path)
71 infos.append(info)
72 except FileNotFoundError:
73 pass
74 else:
75 infos = [path_info] if detail else [path_info["name"]]
76
77 return infos
78
79 def info(self, path, **kwargs):
80 if isinstance(path, os.DirEntry):
81 # scandir DirEntry
82 out = path.stat(follow_symlinks=False)
83 link = path.is_symlink()
84 if path.is_dir(follow_symlinks=False):
85 t = "directory"
86 elif path.is_file(follow_symlinks=False):
87 t = "file"
88 else:
89 t = "other"
90
91 size = out.st_size
92 if link:
93 try:
94 out2 = path.stat(follow_symlinks=True)
95 size = out2.st_size
96 except OSError:
97 size = 0
98 path = self._strip_protocol(path.path)
99 else:
100 # str or path-like
101 path = self._strip_protocol(path)
102 out = os.stat(path, follow_symlinks=False)
103 link = stat.S_ISLNK(out.st_mode)
104 if link:
105 out = os.stat(path, follow_symlinks=True)
106 size = out.st_size
107 if stat.S_ISDIR(out.st_mode):
108 t = "directory"
109 elif stat.S_ISREG(out.st_mode):
110 t = "file"
111 else:
112 t = "other"
113
114 # Check for the 'st_birthtime' attribute, which is not always present; fallback to st_ctime
115 created_time = getattr(out, "st_birthtime", out.st_ctime)
116
117 result = {
118 "name": path,
119 "size": size,
120 "type": t,
121 "created": created_time,
122 "islink": link,
123 }
124 for field in ["mode", "uid", "gid", "mtime", "ino", "nlink"]:
125 result[field] = getattr(out, f"st_{field}")
126 if link:
127 result["destination"] = os.readlink(path)
128 return result
129
130 def lexists(self, path, **kwargs):
131 return osp.lexists(self._strip_protocol(path))
132
133 def cp_file(self, path1, path2, **kwargs):
134 path1 = self._strip_protocol(path1)
135 path2 = self._strip_protocol(path2)
136 if self.auto_mkdir:
137 self.makedirs(self._parent(path2), exist_ok=True)
138 if self.isfile(path1):
139 shutil.copyfile(path1, path2)
140 elif self.isdir(path1):
141 self.mkdirs(path2, exist_ok=True)
142 else:
143 raise FileNotFoundError(path1)
144
145 def isfile(self, path):
146 path = self._strip_protocol(path)
147 return os.path.isfile(path)
148
149 def isdir(self, path):
150 path = self._strip_protocol(path)
151 return os.path.isdir(path)
152
153 def get_file(self, path1, path2, callback=None, **kwargs):
154 if isfilelike(path2):
155 with open(self._strip_protocol(path1), "rb") as f:
156 shutil.copyfileobj(f, path2)
157 else:
158 return self.cp_file(path1, path2, **kwargs)
159
160 def put_file(self, path1, path2, callback=None, mode="overwrite", **kwargs):
161 if mode == "create" and self.exists(path2):
162 raise FileExistsError(path2)
163 return self.cp_file(path1, path2, **kwargs)
164
165 def mv(self, path1, path2, recursive: bool = True, **kwargs):
166 """Move files/directories
167 For the specific case of local, all ops on directories are recursive and
168 the recursive= kwarg is ignored.
169 """
170 path1 = self._strip_protocol(path1)
171 path2 = self._strip_protocol(path2)
172
173 if self.auto_mkdir:
174 self.makedirs(self._parent(path2), exist_ok=True)
175
176 shutil.move(path1, path2)
177
178 def link(self, src, dst, **kwargs):
179 src = self._strip_protocol(src)
180 dst = self._strip_protocol(dst)
181 os.link(src, dst, **kwargs)
182
183 def symlink(self, src, dst, **kwargs):
184 src = self._strip_protocol(src)
185 dst = self._strip_protocol(dst)
186 os.symlink(src, dst, **kwargs)
187
188 def islink(self, path) -> bool:
189 return os.path.islink(self._strip_protocol(path))
190
191 def rm_file(self, path):
192 os.remove(self._strip_protocol(path))
193
194 def rm(self, path, recursive=False, maxdepth=None):
195 if not isinstance(path, list):
196 path = [path]
197
198 for p in path:
199 p = self._strip_protocol(p)
200 if self.isdir(p) and not self.islink(p):
201 if not recursive:
202 raise ValueError("Cannot delete directory, set recursive=True")
203 if osp.abspath(p) == os.getcwd():
204 raise ValueError("Cannot delete current working directory")
205 if maxdepth is None:
206 shutil.rmtree(p)
207 else:
208 # only delete down to maxdepth; a directory still holding
209 # deeper files is kept
210 paths = self.expand_path(p, recursive=True, maxdepth=maxdepth)
211 for sub in reversed(paths):
212 if not self.isdir(sub) or self.islink(sub):
213 os.remove(sub)
214 elif not os.listdir(sub):
215 os.rmdir(sub)
216 else:
217 os.remove(p)
218
219 def unstrip_protocol(self, name):
220 protocol = self.protocol if isinstance(self.protocol, str) else self.protocol[0]
221 name = self._strip_protocol(name) # normalise for local/win/...
222 return f"{protocol}://{name}"
223
224 def _open(self, path, mode="rb", block_size=None, **kwargs):
225 path = self._strip_protocol(path)
226 if self.auto_mkdir and ("w" in mode or "x" in mode or "a" in mode):
227 self.makedirs(self._parent(path), exist_ok=True)
228 return LocalFileOpener(path, mode, fs=self, **kwargs)
229
230 def touch(self, path, truncate=True, **kwargs):
231 path = self._strip_protocol(path)
232 if self.auto_mkdir:
233 self.makedirs(self._parent(path), exist_ok=True)
234 if self.exists(path):
235 os.utime(path, None)
236 else:
237 open(path, "a").close()
238 if truncate:
239 os.truncate(path, 0)
240
241 def created(self, path):
242 info = self.info(path=path)
243 return datetime.datetime.fromtimestamp(info["created"], tz=datetime.UTC)
244
245 def modified(self, path):
246 info = self.info(path=path)
247 return datetime.datetime.fromtimestamp(info["mtime"], tz=datetime.UTC)
248
249 @classmethod
250 def _parent(cls, path):
251 path = cls._strip_protocol(path)
252 if os.sep == "/":
253 # posix native
254 return path.rsplit("/", 1)[0] or "/"
255 else:
256 # NT
257 path_ = path.rsplit("/", 1)[0]
258 if len(path_) <= 3:
259 if path_[1:2] == ":":
260 # nt root (something like c:/)
261 return path_[0] + ":/"
262 # More cases may be required here
263 return path_
264
265 @classmethod
266 def _strip_protocol(cls, path):
267 path = stringify_path(path)
268 protos = (cls.protocol,) if isinstance(cls.protocol, str) else cls.protocol
269 prefixes = (protocol + sep for protocol in protos for sep in ("://", ":"))
270 for prefix in prefixes:
271 if path.startswith(prefix):
272 path = path.removeprefix(prefix)
273 break
274
275 path = make_path_posix(path)
276 if os.sep != "/":
277 # This code-path is a stripped down version of
278 # > drive, path = ntpath.splitdrive(path)
279 if path[1:2] == ":":
280 # Absolute drive-letter path, e.g. X:\Windows
281 # Relative path with drive, e.g. X:Windows
282 drive, path = path[:2], path[2:]
283 elif path[:2] == "//":
284 # UNC drives, e.g. \\server\share or \\?\UNC\server\share
285 # Device drives, e.g. \\.\device or \\?\device
286 if (index1 := path.find("/", 2)) == -1 or (
287 index2 := path.find("/", index1 + 1)
288 ) == -1:
289 drive, path = path, ""
290 else:
291 drive, path = path[:index2], path[index2:]
292 else:
293 # Relative path, e.g. Windows
294 drive = ""
295
296 path = path.rstrip("/") or cls.root_marker
297 return drive + path
298
299 else:
300 return path.rstrip("/") or cls.root_marker
301
302 def _isfilestore(self):
303 # Inheriting from DaskFileSystem makes this False (S3, etc. were)
304 # the original motivation. But we are a posix-like file system.
305 # See https://github.com/dask/dask/issues/5526
306 return True
307
308 def chmod(self, path, mode):
309 path = self._strip_protocol(path)
310 return os.chmod(path, mode)
311
312
313def make_path_posix(path):
314 """Make path generic and absolute for current OS"""
315 if not isinstance(path, str):
316 if isinstance(path, (list, set, tuple)):
317 return type(path)(make_path_posix(p) for p in path)
318 else:
319 path = stringify_path(path)
320 if not isinstance(path, str):
321 raise TypeError(f"could not convert {path!r} to string")
322 if os.sep == "/":
323 # Native posix
324 if path.startswith("/"):
325 # most common fast case for posix
326 return path
327 elif path.startswith("~"):
328 return osp.expanduser(path)
329 elif path.startswith("./"):
330 path = path[2:]
331 elif path == ".":
332 path = ""
333 return f"{os.getcwd()}/{path}"
334 else:
335 # NT handling
336 if path[0:1] == "/" and path[2:3] == ":":
337 # path is like "/c:/local/path"
338 path = path[1:]
339 if path[1:2] == ":":
340 # windows full path like "C:\\local\\path"
341 if len(path) == 2 or (len(path) == 3 and path[2] in "/\\"):
342 # nt root (something like c:/). A three-character "c:x" is not a
343 # root: it names "x" relative to that drive, so it falls through
344 # instead of being collapsed to the drive root.
345 return path[0] + ":/"
346 path = path.replace("\\", "/")
347 return path
348 elif path[0:1] == "~":
349 return make_path_posix(osp.expanduser(path))
350 elif path.startswith(("\\\\", "//")):
351 # windows UNC/DFS-style paths
352 return "//" + path[2:].replace("\\", "/")
353 elif path.startswith(("\\", "/")):
354 # windows relative path with root
355 path = path.replace("\\", "/")
356 return f"{osp.splitdrive(os.getcwd())[0]}{path}"
357 else:
358 path = path.replace("\\", "/")
359 if path.startswith("./"):
360 path = path[2:]
361 elif path == ".":
362 path = ""
363 return f"{make_path_posix(os.getcwd())}/{path}"
364
365
366def trailing_sep(path):
367 """Return True if the path ends with a path separator.
368
369 A forward slash is always considered a path separator, even on Operating
370 Systems that normally use a backslash.
371 """
372 # TODO: if all incoming paths were posix-compliant then separator would
373 # always be a forward slash, simplifying this function.
374 # See https://github.com/fsspec/filesystem_spec/pull/1250
375 return path.endswith(os.sep) or (os.altsep is not None and path.endswith(os.altsep))
376
377
378@lru_cache(maxsize=1)
379def get_umask(mask: int = 0o666) -> int:
380 """Get the current umask.
381
382 Follows https://stackoverflow.com/a/44130549 to get the umask.
383 Temporarily sets the umask to the given value, and then resets it to the
384 original value.
385 """
386 value = os.umask(mask)
387 os.umask(value)
388 return value
389
390
391class LocalFileOpener(io.IOBase):
392 def __init__(
393 self, path, mode, autocommit=True, fs=None, compression=None, **kwargs
394 ):
395 logger.debug("open file: %s", path)
396 self.path = path
397 self.mode = mode
398 self.fs = fs
399 self.f = None
400 self.autocommit = autocommit
401 self.compression = get_compression(path, compression)
402 self.blocksize = io.DEFAULT_BUFFER_SIZE
403 self._open()
404
405 def _open(self):
406 if self.f is None or self.f.closed:
407 if self.autocommit or "w" not in self.mode:
408 self.f = open(self.path, mode=self.mode)
409 if self.compression:
410 compress = compr[self.compression]
411 self.f = compress(self.f, mode=self.mode)
412 else:
413 # TODO: check if path is writable?
414 i, name = tempfile.mkstemp()
415 os.close(i) # we want normal open and normal buffered file
416 self.temp = name
417 self.f = open(name, mode=self.mode)
418 if "r" in self.mode:
419 self.size = self.f.seek(0, 2)
420 self.f.seek(0)
421 self.f.size = self.size
422
423 def _fetch_range(self, start, end):
424 # probably only used by cached FS
425 if "r" not in self.mode:
426 raise ValueError
427 self._open()
428 self.f.seek(start)
429 return self.f.read(end - start)
430
431 def __setstate__(self, state):
432 self.f = None
433 loc = state.pop("loc", None)
434 self.__dict__.update(state)
435 if "r" in state["mode"]:
436 self.f = None
437 self._open()
438 self.f.seek(loc)
439
440 def __getstate__(self):
441 d = self.__dict__.copy()
442 d.pop("f")
443 if "r" in self.mode:
444 d["loc"] = self.f.tell()
445 else:
446 if not self.f.closed:
447 raise ValueError("Cannot serialise open write-mode local file")
448 return d
449
450 def commit(self):
451 if self.autocommit:
452 raise RuntimeError("Can only commit if not already set to autocommit")
453 if not self.f.closed:
454 # a compression wrapper (e.g., GzipFile) does not close the file
455 # object it was given, so buffered bytes and any trailer may still
456 # be pending here. Windows also refuses to rename an open file.
457 self.f.close()
458 try:
459 shutil.move(self.temp, self.path)
460 except PermissionError as e:
461 # shutil.move raises PermissionError if os.rename
462 # and the default copy2 fallback with shutil.copystats fail.
463 # The file should be there nonetheless, but without copied permissions.
464 # If it doesn't exist, there was no permission to create the file.
465 if not os.path.exists(self.path):
466 raise e
467 else:
468 # If PermissionError is not raised, permissions can be set.
469 try:
470 mask = 0o666
471 os.chmod(self.path, mask & ~get_umask(mask))
472 except RuntimeError:
473 pass
474
475 def discard(self):
476 if self.autocommit:
477 raise RuntimeError("Cannot discard if set to autocommit")
478 if not self.f.closed:
479 self.f.close()
480 os.remove(self.temp)
481
482 def readable(self) -> bool:
483 return True
484
485 def writable(self) -> bool:
486 return "r" not in self.mode
487
488 def read(self, *args, **kwargs):
489 return self.f.read(*args, **kwargs)
490
491 def write(self, *args, **kwargs):
492 return self.f.write(*args, **kwargs)
493
494 def tell(self, *args, **kwargs):
495 return self.f.tell(*args, **kwargs)
496
497 def seek(self, *args, **kwargs):
498 return self.f.seek(*args, **kwargs)
499
500 def seekable(self, *args, **kwargs):
501 return self.f.seekable(*args, **kwargs)
502
503 def readline(self, *args, **kwargs):
504 return self.f.readline(*args, **kwargs)
505
506 def readlines(self, *args, **kwargs):
507 return self.f.readlines(*args, **kwargs)
508
509 def close(self):
510 return self.f.close()
511
512 def truncate(self, size=None) -> int:
513 return self.f.truncate(size)
514
515 @property
516 def closed(self):
517 return self.f.closed
518
519 def fileno(self):
520 return self.raw.fileno()
521
522 def flush(self) -> None:
523 self.f.flush()
524
525 def __iter__(self):
526 return self.f.__iter__()
527
528 def __getattr__(self, item):
529 return getattr(self.f, item)
530
531 def __enter__(self):
532 self._incontext = True
533 return self
534
535 def __exit__(self, exc_type, exc_value, traceback):
536 self._incontext = False
537 self.f.__exit__(exc_type, exc_value, traceback)