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(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(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, **kwargs):
161 return self.cp_file(path1, path2, **kwargs)
162
163 def mv(self, path1, path2, recursive: bool = True, **kwargs):
164 """Move files/directories
165 For the specific case of local, all ops on directories are recursive and
166 the recursive= kwarg is ignored.
167 """
168 path1 = self._strip_protocol(path1)
169 path2 = self._strip_protocol(path2)
170
171 if self.auto_mkdir:
172 self.makedirs(self._parent(path2), exist_ok=True)
173
174 shutil.move(path1, path2)
175
176 def link(self, src, dst, **kwargs):
177 src = self._strip_protocol(src)
178 dst = self._strip_protocol(dst)
179 os.link(src, dst, **kwargs)
180
181 def symlink(self, src, dst, **kwargs):
182 src = self._strip_protocol(src)
183 dst = self._strip_protocol(dst)
184 os.symlink(src, dst, **kwargs)
185
186 def islink(self, path) -> bool:
187 return os.path.islink(self._strip_protocol(path))
188
189 def rm_file(self, path):
190 os.remove(self._strip_protocol(path))
191
192 def rm(self, path, recursive=False, maxdepth=None):
193 if not isinstance(path, list):
194 path = [path]
195
196 for p in path:
197 p = self._strip_protocol(p)
198 if self.isdir(p) and not self.islink(p):
199 if not recursive:
200 raise ValueError("Cannot delete directory, set recursive=True")
201 if osp.abspath(p) == os.getcwd():
202 raise ValueError("Cannot delete current working directory")
203 shutil.rmtree(p)
204 else:
205 os.remove(p)
206
207 def unstrip_protocol(self, name):
208 protocol = self.protocol if isinstance(self.protocol, str) else self.protocol[0]
209 name = self._strip_protocol(name) # normalise for local/win/...
210 return f"{protocol}://{name}"
211
212 def _open(self, path, mode="rb", block_size=None, **kwargs):
213 path = self._strip_protocol(path)
214 if self.auto_mkdir and ("w" in mode or "x" in mode or "a" in mode):
215 self.makedirs(self._parent(path), exist_ok=True)
216 return LocalFileOpener(path, mode, fs=self, **kwargs)
217
218 def touch(self, path, truncate=True, **kwargs):
219 path = self._strip_protocol(path)
220 if self.auto_mkdir:
221 self.makedirs(self._parent(path), exist_ok=True)
222 if self.exists(path):
223 os.utime(path, None)
224 else:
225 open(path, "a").close()
226 if truncate:
227 os.truncate(path, 0)
228
229 def created(self, path):
230 info = self.info(path=path)
231 return datetime.datetime.fromtimestamp(
232 info["created"], tz=datetime.timezone.utc
233 )
234
235 def modified(self, path):
236 info = self.info(path=path)
237 return datetime.datetime.fromtimestamp(info["mtime"], tz=datetime.timezone.utc)
238
239 @classmethod
240 def _parent(cls, path):
241 path = cls._strip_protocol(path)
242 if os.sep == "/":
243 # posix native
244 return path.rsplit("/", 1)[0] or "/"
245 else:
246 # NT
247 path_ = path.rsplit("/", 1)[0]
248 if len(path_) <= 3:
249 if path_[1:2] == ":":
250 # nt root (something like c:/)
251 return path_[0] + ":/"
252 # More cases may be required here
253 return path_
254
255 @classmethod
256 def _strip_protocol(cls, path):
257 path = stringify_path(path)
258 protos = (cls.protocol,) if isinstance(cls.protocol, str) else cls.protocol
259 prefixes = (protocol + sep for protocol in protos for sep in ("://", ":"))
260 for prefix in prefixes:
261 if path.startswith(prefix):
262 path = path.removeprefix(prefix)
263 break
264
265 path = make_path_posix(path)
266 if os.sep != "/":
267 # This code-path is a stripped down version of
268 # > drive, path = ntpath.splitdrive(path)
269 if path[1:2] == ":":
270 # Absolute drive-letter path, e.g. X:\Windows
271 # Relative path with drive, e.g. X:Windows
272 drive, path = path[:2], path[2:]
273 elif path[:2] == "//":
274 # UNC drives, e.g. \\server\share or \\?\UNC\server\share
275 # Device drives, e.g. \\.\device or \\?\device
276 if (index1 := path.find("/", 2)) == -1 or (
277 index2 := path.find("/", index1 + 1)
278 ) == -1:
279 drive, path = path, ""
280 else:
281 drive, path = path[:index2], path[index2:]
282 else:
283 # Relative path, e.g. Windows
284 drive = ""
285
286 path = path.rstrip("/") or cls.root_marker
287 return drive + path
288
289 else:
290 return path.rstrip("/") or cls.root_marker
291
292 def _isfilestore(self):
293 # Inheriting from DaskFileSystem makes this False (S3, etc. were)
294 # the original motivation. But we are a posix-like file system.
295 # See https://github.com/dask/dask/issues/5526
296 return True
297
298 def chmod(self, path, mode):
299 path = stringify_path(path)
300 return os.chmod(path, mode)
301
302
303def make_path_posix(path):
304 """Make path generic and absolute for current OS"""
305 if not isinstance(path, str):
306 if isinstance(path, (list, set, tuple)):
307 return type(path)(make_path_posix(p) for p in path)
308 else:
309 path = stringify_path(path)
310 if not isinstance(path, str):
311 raise TypeError(f"could not convert {path!r} to string")
312 if os.sep == "/":
313 # Native posix
314 if path.startswith("/"):
315 # most common fast case for posix
316 return path
317 elif path.startswith("~"):
318 return osp.expanduser(path)
319 elif path.startswith("./"):
320 path = path[2:]
321 elif path == ".":
322 path = ""
323 return f"{os.getcwd()}/{path}"
324 else:
325 # NT handling
326 if path[0:1] == "/" and path[2:3] == ":":
327 # path is like "/c:/local/path"
328 path = path[1:]
329 if path[1:2] == ":":
330 # windows full path like "C:\\local\\path"
331 if len(path) == 2 or (len(path) == 3 and path[2] in "/\\"):
332 # nt root (something like c:/). A three-character "c:x" is not a
333 # root: it names "x" relative to that drive, so it falls through
334 # instead of being collapsed to the drive root.
335 return path[0] + ":/"
336 path = path.replace("\\", "/")
337 return path
338 elif path[0:1] == "~":
339 return make_path_posix(osp.expanduser(path))
340 elif path.startswith(("\\\\", "//")):
341 # windows UNC/DFS-style paths
342 return "//" + path[2:].replace("\\", "/")
343 elif path.startswith(("\\", "/")):
344 # windows relative path with root
345 path = path.replace("\\", "/")
346 return f"{osp.splitdrive(os.getcwd())[0]}{path}"
347 else:
348 path = path.replace("\\", "/")
349 if path.startswith("./"):
350 path = path[2:]
351 elif path == ".":
352 path = ""
353 return f"{make_path_posix(os.getcwd())}/{path}"
354
355
356def trailing_sep(path):
357 """Return True if the path ends with a path separator.
358
359 A forward slash is always considered a path separator, even on Operating
360 Systems that normally use a backslash.
361 """
362 # TODO: if all incoming paths were posix-compliant then separator would
363 # always be a forward slash, simplifying this function.
364 # See https://github.com/fsspec/filesystem_spec/pull/1250
365 return path.endswith(os.sep) or (os.altsep is not None and path.endswith(os.altsep))
366
367
368@lru_cache(maxsize=1)
369def get_umask(mask: int = 0o666) -> int:
370 """Get the current umask.
371
372 Follows https://stackoverflow.com/a/44130549 to get the umask.
373 Temporarily sets the umask to the given value, and then resets it to the
374 original value.
375 """
376 value = os.umask(mask)
377 os.umask(value)
378 return value
379
380
381class LocalFileOpener(io.IOBase):
382 def __init__(
383 self, path, mode, autocommit=True, fs=None, compression=None, **kwargs
384 ):
385 logger.debug("open file: %s", path)
386 self.path = path
387 self.mode = mode
388 self.fs = fs
389 self.f = None
390 self.autocommit = autocommit
391 self.compression = get_compression(path, compression)
392 self.blocksize = io.DEFAULT_BUFFER_SIZE
393 self._open()
394
395 def _open(self):
396 if self.f is None or self.f.closed:
397 if self.autocommit or "w" not in self.mode:
398 self.f = open(self.path, mode=self.mode)
399 if self.compression:
400 compress = compr[self.compression]
401 self.f = compress(self.f, mode=self.mode)
402 else:
403 # TODO: check if path is writable?
404 i, name = tempfile.mkstemp()
405 os.close(i) # we want normal open and normal buffered file
406 self.temp = name
407 self.f = open(name, mode=self.mode)
408 if "w" not in self.mode:
409 self.size = self.f.seek(0, 2)
410 self.f.seek(0)
411 self.f.size = self.size
412
413 def _fetch_range(self, start, end):
414 # probably only used by cached FS
415 if "r" not in self.mode:
416 raise ValueError
417 self._open()
418 self.f.seek(start)
419 return self.f.read(end - start)
420
421 def __setstate__(self, state):
422 self.f = None
423 loc = state.pop("loc", None)
424 self.__dict__.update(state)
425 if "r" in state["mode"]:
426 self.f = None
427 self._open()
428 self.f.seek(loc)
429
430 def __getstate__(self):
431 d = self.__dict__.copy()
432 d.pop("f")
433 if "r" in self.mode:
434 d["loc"] = self.f.tell()
435 else:
436 if not self.f.closed:
437 raise ValueError("Cannot serialise open write-mode local file")
438 return d
439
440 def commit(self):
441 if self.autocommit:
442 raise RuntimeError("Can only commit if not already set to autocommit")
443 if not self.f.closed:
444 # a compression wrapper (e.g., GzipFile) does not close the file
445 # object it was given, so buffered bytes and any trailer may still
446 # be pending here. Windows also refuses to rename an open file.
447 self.f.close()
448 try:
449 shutil.move(self.temp, self.path)
450 except PermissionError as e:
451 # shutil.move raises PermissionError if os.rename
452 # and the default copy2 fallback with shutil.copystats fail.
453 # The file should be there nonetheless, but without copied permissions.
454 # If it doesn't exist, there was no permission to create the file.
455 if not os.path.exists(self.path):
456 raise e
457 else:
458 # If PermissionError is not raised, permissions can be set.
459 try:
460 mask = 0o666
461 os.chmod(self.path, mask & ~get_umask(mask))
462 except RuntimeError:
463 pass
464
465 def discard(self):
466 if self.autocommit:
467 raise RuntimeError("Cannot discard if set to autocommit")
468 os.remove(self.temp)
469
470 def readable(self) -> bool:
471 return True
472
473 def writable(self) -> bool:
474 return "r" not in self.mode
475
476 def read(self, *args, **kwargs):
477 return self.f.read(*args, **kwargs)
478
479 def write(self, *args, **kwargs):
480 return self.f.write(*args, **kwargs)
481
482 def tell(self, *args, **kwargs):
483 return self.f.tell(*args, **kwargs)
484
485 def seek(self, *args, **kwargs):
486 return self.f.seek(*args, **kwargs)
487
488 def seekable(self, *args, **kwargs):
489 return self.f.seekable(*args, **kwargs)
490
491 def readline(self, *args, **kwargs):
492 return self.f.readline(*args, **kwargs)
493
494 def readlines(self, *args, **kwargs):
495 return self.f.readlines(*args, **kwargs)
496
497 def close(self):
498 return self.f.close()
499
500 def truncate(self, size=None) -> int:
501 return self.f.truncate(size)
502
503 @property
504 def closed(self):
505 return self.f.closed
506
507 def fileno(self):
508 return self.raw.fileno()
509
510 def flush(self) -> None:
511 self.f.flush()
512
513 def __iter__(self):
514 return self.f.__iter__()
515
516 def __getattr__(self, item):
517 return getattr(self.f, item)
518
519 def __enter__(self):
520 self._incontext = True
521 return self
522
523 def __exit__(self, exc_type, exc_value, traceback):
524 self._incontext = False
525 self.f.__exit__(exc_type, exc_value, traceback)