import argparse import logging import os import stat import threading import time from errno import EIO, ENOENT from fuse import FUSE, FuseOSError, LoggingMixIn, Operations from fsspec import __version__ from fsspec.core import url_to_fs logger = logging.getLogger("fsspec.fuse") class FUSEr(Operations): def __init__(self, fs, path, ready_file=False): self.fs = fs self.cache = {} self.root = path.rstrip("/") + "/" self.counter = 0 logger.info("Starting FUSE at %s", path) self._ready_file = ready_file def getattr(self, path, fh=None): logger.debug("getattr %s", path) if self._ready_file and path in ["/.fuse_ready", ".fuse_ready"]: return {"type": "file", "st_size": 5} path = "".join([self.root, path.lstrip("/")]).rstrip("/") try: info = self.fs.info(path) except FileNotFoundError as exc: raise FuseOSError(ENOENT) from exc data = {"st_uid": info.get("uid", 1000), "st_gid": info.get("gid", 1000)} perm = info.get("mode", 0o777) if info["type"] != "file": data["st_mode"] = stat.S_IFDIR | perm data["st_size"] = 0 data["st_blksize"] = 0 else: data["st_mode"] = stat.S_IFREG | perm data["st_size"] = info["size"] data["st_blksize"] = 5 * 2**20 data["st_nlink"] = 1 data["st_atime"] = info["atime"] if "atime" in info else time.time() data["st_ctime"] = info["ctime"] if "ctime" in info else time.time() data["st_mtime"] = info["mtime"] if "mtime" in info else time.time() return data def readdir(self, path, fh): logger.debug("readdir %s", path) path = "".join([self.root, path.lstrip("/")]) files = self.fs.ls(path, False) files = [os.path.basename(f.rstrip("/")) for f in files] return [".", ".."] + files def mkdir(self, path, mode): path = "".join([self.root, path.lstrip("/")]) self.fs.mkdir(path) return 0 def rmdir(self, path): path = "".join([self.root, path.lstrip("/")]) self.fs.rmdir(path) return 0 def read(self, path, size, offset, fh): logger.debug("read %s", (path, size, offset)) if self._ready_file and path in ["/.fuse_ready", ".fuse_ready"]: # status indicator return b"ready" f = self.cache[fh] f.seek(offset) out = f.read(size) return out def write(self, path, data, offset, fh): logger.debug("write %s", (path, offset)) f = self.cache[fh] f.seek(offset) f.write(data) return len(data) def create(self, path, flags, fi=None): logger.debug("create %s", (path, flags)) fn = "".join([self.root, path.lstrip("/")]) self.fs.touch(fn) # OS will want to get attributes immediately f = self.fs.open(fn, "wb") self.cache[self.counter] = f self.counter += 1 return self.counter - 1 def open(self, path, flags): logger.debug("open %s", (path, flags)) fn = "".join([self.root, path.lstrip("/")]) if flags % 2 == 0: # read mode = "rb" else: # write/create mode = "wb" self.cache[self.counter] = self.fs.open(fn, mode) self.counter += 1 return self.counter - 1 def truncate(self, path, length, fh=None): fn = "".join([self.root, path.lstrip("/")]) if length != 0: raise NotImplementedError # maybe should be no-op since open with write sets size to zero anyway self.fs.touch(fn) def unlink(self, path): fn = "".join([self.root, path.lstrip("/")]) try: self.fs.rm(fn, False) except (OSError, FileNotFoundError) as exc: raise FuseOSError(EIO) from exc def release(self, path, fh): try: if fh in self.cache: f = self.cache[fh] f.close() self.cache.pop(fh) except Exception as e: print(e) return 0 def chmod(self, path, mode): if hasattr(self.fs, "chmod"): path = "".join([self.root, path.lstrip("/")]) return self.fs.chmod(path, mode) raise NotImplementedError def run( fs, path, mount_point, foreground=True, threads=False, ready_file=False, ops_class=FUSEr, ): """Mount stuff in a local directory This uses fusepy to make it appear as if a given path on an fsspec instance is in fact resident within the local file-system. This requires that fusepy by installed, and that FUSE be available on the system (typically requiring a package to be installed with apt, yum, brew, etc.). Parameters ---------- fs: file-system instance From one of the compatible implementations path: str Location on that file-system to regard as the root directory to mount. Note that you typically should include the terminating "/" character. mount_point: str An empty directory on the local file-system where the contents of the remote path will appear. foreground: bool Whether or not calling this function will block. Operation will typically be more stable if True. threads: bool Whether or not to create threads when responding to file operations within the mounter directory. Operation will typically be more stable if False. ready_file: bool Whether the FUSE process is ready. The ``.fuse_ready`` file will exist in the ``mount_point`` directory if True. Debugging purpose. ops_class: FUSEr or Subclass of FUSEr To override the default behavior of FUSEr. For Example, logging to file. """ func = lambda: FUSE( ops_class(fs, path, ready_file=ready_file), mount_point, nothreads=not threads, foreground=foreground, ) if not foreground: th = threading.Thread(target=func) th.daemon = True th.start() return th else: # pragma: no cover try: func() except KeyboardInterrupt: pass def main(args): """Mount filesystem from chained URL to MOUNT_POINT. Examples: python3 -m fsspec.fuse memory /usr/share /tmp/mem python3 -m fsspec.fuse local /tmp/source /tmp/local \\ -l /tmp/fsspecfuse.log You can also mount chained-URLs and use special settings: python3 -m fsspec.fuse 'filecache::zip::file://data.zip' \\ / /tmp/zip \\ -o 'filecache-cache_storage=/tmp/simplecache' You can specify the type of the setting by using `[int]` or `[bool]`, (`true`, `yes`, `1` represents the Boolean value `True`): python3 -m fsspec.fuse 'simplecache::ftp://ftp1.at.proftpd.org' \\ /historic/packages/RPMS /tmp/ftp \\ -o 'simplecache-cache_storage=/tmp/simplecache' \\ -o 'simplecache-check_files=false[bool]' \\ -o 'ftp-listings_expiry_time=60[int]' \\ -o 'ftp-username=anonymous' \\ -o 'ftp-password=xieyanbo' """ class RawDescriptionArgumentParser(argparse.ArgumentParser): def format_help(self): usage = super().format_help() parts = usage.split("\n\n") parts[1] = self.description.rstrip() return "\n\n".join(parts) parser = RawDescriptionArgumentParser(prog="fsspec.fuse", description=main.__doc__) parser.add_argument("--version", action="version", version=__version__) parser.add_argument("url", type=str, help="fs url") parser.add_argument("source_path", type=str, help="source directory in fs") parser.add_argument("mount_point", type=str, help="local directory") parser.add_argument( "-o", "--option", action="append", help="Any options of protocol included in the chained URL", ) parser.add_argument( "-l", "--log-file", type=str, help="Logging FUSE debug info (Default: '')" ) parser.add_argument( "-f", "--foreground", action="store_false", help="Running in foreground or not (Default: False)", ) parser.add_argument( "-t", "--threads", action="store_false", help="Running with threads support (Default: False)", ) parser.add_argument( "-r", "--ready-file", action="store_false", help="The `.fuse_ready` file will exist after FUSE is ready. " "(Debugging purpose, Default: False)", ) args = parser.parse_args(args) kwargs = {} for item in args.option or []: key, sep, value = item.partition("=") if not sep: parser.error(message=f"Wrong option: {item!r}") val = value.lower() if val.endswith("[int]"): value = int(value[: -len("[int]")]) elif val.endswith("[bool]"): value = val[: -len("[bool]")] in ["1", "yes", "true"] if "-" in key: fs_name, setting_name = key.split("-", 1) if fs_name in kwargs: kwargs[fs_name][setting_name] = value else: kwargs[fs_name] = {setting_name: value} else: kwargs[key] = value if args.log_file: logging.basicConfig( level=logging.DEBUG, filename=args.log_file, format="%(asctime)s %(message)s", ) class LoggingFUSEr(FUSEr, LoggingMixIn): pass fuser = LoggingFUSEr else: fuser = FUSEr fs, url_path = url_to_fs(args.url, **kwargs) logger.debug("Mounting %s to %s", url_path, str(args.mount_point)) run( fs, args.source_path, args.mount_point, foreground=args.foreground, threads=args.threads, ready_file=args.ready_file, ops_class=fuser, ) if __name__ == "__main__": import sys main(sys.argv[1:])