Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
dvc-objects / fs / base.py
Size: Mime:
import asyncio
import datetime
import logging
import ntpath
import os
import posixpath
import shutil
from collections.abc import Iterable, Iterator, Sequence
from functools import partial
from multiprocessing import cpu_count
from typing import (
    IO,
    TYPE_CHECKING,
    Any,
    ClassVar,
    Literal,
    Optional,
    Union,
    overload,
)
from urllib.parse import urlsplit, urlunsplit

import fsspec
from fsspec.asyn import get_loop
from fsspec.callbacks import DEFAULT_CALLBACK

from dvc_objects.compat import cached_property
from dvc_objects.executors import ThreadPoolExecutor, batch_coros

from .callbacks import wrap_file
from .errors import RemoteMissingDepsError

if TYPE_CHECKING:
    from typing import BinaryIO, TextIO

    from fsspec.spec import AbstractFileSystem


logger = logging.getLogger(__name__)


FSPath = str
AnyFSPath = str

# An info() entry, might evolve to a TypedDict
# in the future (e.g for properly type 'size' etc).
Entry = dict[str, Any]


class LinkError(OSError):
    def __init__(self, link: str, fs: "FileSystem", path: str) -> None:
        import errno

        super().__init__(
            errno.EPERM,
            f"{link} is not supported for {fs.protocol} by {type(fs)}",
            path,
        )


class FileSystem:
    sep = "/"

    flavour = posixpath
    protocol = "base"
    REQUIRES: ClassVar[dict[str, str]] = {}
    _JOBS = 4 * cpu_count()

    HASH_JOBS = max(1, min(4, cpu_count() // 2))
    LIST_OBJECT_PAGE_SIZE = 1000
    TRAVERSE_WEIGHT_MULTIPLIER = 5
    TRAVERSE_PREFIX_LEN = 2
    TRAVERSE_THRESHOLD_SIZE = 500000
    CAN_TRAVERSE = True

    PARAM_CHECKSUM: ClassVar[Optional[str]] = None

    def __init__(self, fs=None, **kwargs: Any):
        self._check_requires(**kwargs)

        self.jobs = kwargs.get("jobs") or self._JOBS
        self.hash_jobs = kwargs.get("checksum_jobs") or self.HASH_JOBS
        self._config = kwargs
        if fs:
            self.fs = fs

    @cached_property
    def fs_args(self) -> dict[str, Any]:
        ret = {"skip_instance_cache": True}
        ret.update(self._prepare_credentials(**self._config))
        return ret

    @property
    def config(self) -> dict[str, Any]:
        return self._config

    @property
    def root_marker(self) -> str:
        return self.fs.root_marker

    def getcwd(self) -> str:
        return ""

    def chdir(self, path: str):
        raise NotImplementedError

    @classmethod
    def join(cls, *parts: str) -> str:
        return cls.flavour.join(*parts)

    @classmethod
    def split(cls, path: str) -> tuple[str, str]:
        return cls.flavour.split(path)

    @classmethod
    def splitext(cls, path: str) -> tuple[str, str]:
        return cls.flavour.splitext(path)

    def normpath(self, path: str) -> str:
        if self.flavour == ntpath:
            return self.flavour.normpath(path)

        parts = list(urlsplit(path))
        parts[2] = self.flavour.normpath(parts[2])
        return urlunsplit(parts)

    @classmethod
    def isabs(cls, path: str) -> bool:
        return cls.flavour.isabs(path)

    def abspath(self, path: str) -> str:
        if not self.isabs(path):
            path = self.join(self.getcwd(), path)
        return self.normpath(path)

    @classmethod
    def commonprefix(cls, paths: Sequence[str]) -> str:
        return cls.flavour.commonprefix(paths)

    @classmethod
    def commonpath(cls, paths: Iterable[str]) -> str:
        return cls.flavour.commonpath(list(paths))

    @classmethod
    def parts(cls, path: str) -> tuple[str, ...]:
        drive, path = cls.flavour.splitdrive(path.rstrip(cls.flavour.sep))

        ret = []
        while True:
            path, part = cls.flavour.split(path)

            if part:
                ret.append(part)
                continue

            if path:
                ret.append(path)

            break

        ret.reverse()

        if drive:
            ret = [drive, *ret]

        return tuple(ret)

    @classmethod
    def parent(cls, path: str) -> str:
        return cls.flavour.dirname(path)

    @classmethod
    def dirname(cls, path: str) -> str:
        return cls.parent(path)

    @classmethod
    def parents(cls, path: str) -> Iterator[str]:
        while True:
            parent = cls.flavour.dirname(path)
            if parent == path:
                break
            yield parent
            path = parent

    @classmethod
    def name(cls, path: str) -> str:
        return cls.flavour.basename(path)

    @classmethod
    def suffix(cls, path: str) -> str:
        name = cls.name(path)
        _, dot, suffix = name.partition(".")
        return dot + suffix

    @classmethod
    def with_name(cls, path: str, name: str) -> str:
        return cls.join(cls.parent(path), name)

    @classmethod
    def with_suffix(cls, path: str, suffix: str) -> str:
        return cls.splitext(path)[0] + suffix

    @classmethod
    def isin(cls, left: str, right: str) -> bool:
        if left == right:
            return False
        try:
            common = cls.commonpath([left, right])
        except ValueError:
            # Paths don't have the same drive
            return False
        return common == right

    @classmethod
    def isin_or_eq(cls, left: str, right: str) -> bool:
        return left == right or cls.isin(left, right)

    @classmethod
    def overlaps(cls, left: str, right: str) -> bool:
        return cls.isin_or_eq(left, right) or cls.isin(right, left)

    def relpath(self, path: str, start: Optional[str] = None) -> str:
        if start is None:
            start = "."
        return self.flavour.relpath(self.abspath(path), start=self.abspath(start))

    def relparts(self, path: str, start: Optional[str] = None) -> tuple[str, ...]:
        return self.parts(self.relpath(path, start=start))

    @classmethod
    def as_posix(cls, path: str) -> str:
        return path.replace(cls.flavour.sep, posixpath.sep)

    @classmethod
    def _strip_protocol(cls, path: str) -> str:
        return path

    def unstrip_protocol(self, path: str) -> str:
        return path

    @cached_property
    def fs(self) -> "AbstractFileSystem":
        raise NotImplementedError

    @property
    def version_aware(self) -> bool:
        return self._config.get("version_aware", False)

    @staticmethod
    def _get_kwargs_from_urls(urlpath: str) -> "dict[str, Any]":
        from fsspec.utils import infer_storage_options

        options = infer_storage_options(urlpath)
        options.pop("path", None)
        options.pop("protocol", None)
        return options

    def _prepare_credentials(
        self,
        **config: dict[str, Any],
    ) -> dict[str, Any]:
        """Prepare the arguments for authentication to the
        host filesystem"""
        return {}

    @classmethod
    def get_missing_deps(cls) -> list[str]:
        from importlib.util import find_spec

        return [pkg for pkg, mod in cls.REQUIRES.items() if not find_spec(mod)]

    def _check_requires(self, **kwargs):
        from .scheme import Schemes

        missing = self.get_missing_deps()
        if not missing:
            return

        proto = self.protocol
        if proto == Schemes.WEBDAVS:
            proto = Schemes.WEBDAV

        url = kwargs.get("url", f"{self.protocol}://")
        raise RemoteMissingDepsError(self, proto, url, missing)

    def isdir(self, path: AnyFSPath) -> bool:
        return self.fs.isdir(path)

    def isfile(self, path: AnyFSPath) -> bool:
        return self.fs.isfile(path)

    def is_empty(self, path: AnyFSPath) -> bool:
        entry = self.info(path)
        if entry["type"] == "directory":
            return not self.fs.ls(path)
        return entry["size"] == 0

    @overload
    def open(
        self,
        path: AnyFSPath,
        mode: Literal["rb", "br", "wb"],
        **kwargs: Any,
    ) -> "BinaryIO":
        return self.open(path, mode, **kwargs)

    @overload
    def open(
        self,
        path: AnyFSPath,
        mode: Literal["r", "rt", "w"] = "r",
        **kwargs: Any,
    ) -> "TextIO":
        ...

    def open(
        self,
        path: AnyFSPath,
        mode: str = "r",
        **kwargs: Any,
    ) -> "IO[Any]":
        if "b" in mode:
            kwargs.pop("encoding", None)
        return self.fs.open(path, mode=mode, **kwargs)

    def read_block(
        self,
        path: AnyFSPath,
        offset: int,
        length: int,
        delimiter: Optional[bytes] = None,
    ) -> bytes:
        return self.fs.read_block(path, offset, length, delimiter=delimiter)

    def cat(
        self,
        path: Union[AnyFSPath, list[AnyFSPath]],
        recursive: bool = False,
        on_error: Literal["raise", "omit", "return"] = "raise",
        **kwargs: Any,
    ) -> Union[bytes, dict[AnyFSPath, bytes]]:
        return self.fs.cat(path, recursive=recursive, on_error=on_error, **kwargs)

    def cat_ranges(
        self,
        paths: list[AnyFSPath],
        starts: list[int],
        ends: list[int],
        max_gap: Optional[int] = None,
        **kwargs,
    ) -> list[bytes]:
        return self.fs.cat_ranges(paths, starts, ends, max_gap=max_gap, **kwargs)

    def cat_file(
        self,
        path: AnyFSPath,
        start: Optional[int] = None,
        end: Optional[int] = None,
        **kwargs: Any,
    ) -> bytes:
        return self.fs.cat_file(path, start=start, end=end, **kwargs)

    def head(self, path: AnyFSPath, size: int = 1024) -> bytes:
        return self.fs.head(path, size=size)

    def tail(self, path: AnyFSPath, size: int = 1024) -> bytes:
        return self.fs.tail(path, size=size)

    def pipe_file(self, path: AnyFSPath, value: bytes, **kwargs: Any) -> None:
        return self.fs.pipe_file(path, value, **kwargs)

    write_bytes = pipe_file
    read_bytes = cat_file

    def read_text(
        self,
        path: AnyFSPath,
        encoding: Optional[str] = None,
        errors: Optional[str] = None,
        newline: Optional[str] = None,
        **kwargs: Any,
    ) -> str:
        return self.fs.read_text(
            path, encoding=encoding, errors=errors, newline=newline, **kwargs
        )

    def write_text(
        self,
        path: AnyFSPath,
        value: str,
        encoding: Optional[str] = None,
        errors: Optional[str] = None,
        newline: Optional[str] = None,
        **kwargs: Any,
    ) -> None:
        self.fs.write_text(
            path,
            value,
            encoding=encoding,
            errors=errors,
            newline=newline,
            **kwargs,
        )

    def pipe(
        self,
        path: Union[AnyFSPath, dict[AnyFSPath, bytes]],
        value: Optional[bytes] = None,
        **kwargs: Any,
    ) -> None:
        return self.fs.pipe(path, value=value, **kwargs)

    def touch(self, path: AnyFSPath, truncate: bool = True, **kwargs: Any) -> None:
        return self.fs.touch(path, truncate=truncate, **kwargs)

    def checksum(self, path: AnyFSPath) -> str:
        return self.fs.checksum(path)

    def copy(self, from_info: AnyFSPath, to_info: AnyFSPath) -> None:
        self.makedirs(self.parent(to_info))
        self.fs.copy(from_info, to_info)

    def cp_file(self, from_info: AnyFSPath, to_info: AnyFSPath, **kwargs: Any) -> None:
        self.fs.cp_file(from_info, to_info, **kwargs)

    @overload
    def exists(
        self,
        path: AnyFSPath,
        callback: fsspec.Callback = ...,
        batch_size: Optional[int] = ...,
    ) -> bool:
        ...

    @overload
    def exists(
        self,
        path: list[AnyFSPath],
        callback: fsspec.Callback = ...,
        batch_size: Optional[int] = ...,
    ) -> list[bool]:
        ...

    def exists(
        self,
        path: Union[AnyFSPath, list[AnyFSPath]],
        callback: fsspec.Callback = DEFAULT_CALLBACK,
        batch_size: Optional[int] = None,
    ) -> Union[bool, list[bool]]:
        if isinstance(path, str):
            return self.fs.exists(path)
        callback.set_size(len(path))
        jobs = batch_size or self.jobs
        if self.fs.async_impl:
            loop = get_loop()
            fut = asyncio.run_coroutine_threadsafe(
                batch_coros(
                    [self.fs._exists(p) for p in path],
                    batch_size=jobs,
                    callback=callback,
                ),
                loop,
            )
            return fut.result()

        with ThreadPoolExecutor(max_workers=jobs, cancel_on_error=True) as executor:
            it = executor.map(self.fs.exists, path)
            return list(callback.wrap(it))

    def lexists(self, path: AnyFSPath) -> bool:
        return self.fs.lexists(path)

    def symlink(self, from_info: AnyFSPath, to_info: AnyFSPath) -> None:
        try:
            return self.fs.symlink(from_info, to_info)
        except AttributeError as e:
            raise LinkError("symlink", self, from_info) from e

    def link(self, from_info: AnyFSPath, to_info: AnyFSPath) -> None:
        try:
            return self.fs.link(from_info, to_info)
        except AttributeError as e:
            raise LinkError("hardlink", self, from_info) from e

    hardlink = link

    def reflink(self, from_info: AnyFSPath, to_info: AnyFSPath) -> None:
        try:
            return self.fs.reflink(from_info, to_info)
        except AttributeError as e:
            raise LinkError("reflink", self, from_info) from e

    def islink(self, path: AnyFSPath) -> bool:
        try:
            return self.fs.islink(path)
        except AttributeError:
            return False

    is_symlink = islink

    def is_hardlink(self, path: AnyFSPath) -> bool:
        try:
            return self.fs.is_hardlink(path)
        except AttributeError:
            return False

    def iscopy(self, path: AnyFSPath) -> bool:
        return not (self.is_symlink(path) or self.is_hardlink(path))

    @overload
    def ls(self, path: AnyFSPath, detail: Literal[True], **kwargs) -> "Iterator[Entry]":
        ...

    @overload
    def ls(self, path: AnyFSPath, detail: Literal[False], **kwargs) -> Iterator[str]:
        ...

    def ls(self, path, detail=False, **kwargs):
        return self.fs.ls(path, detail=detail, **kwargs)

    def find(
        self,
        path: Union[AnyFSPath, list[AnyFSPath]],
        prefix: bool = False,
        batch_size: Optional[int] = None,
        **kwargs,
    ) -> Iterator[str]:
        if isinstance(path, str):
            yield from self.fs.find(path)
            return
        jobs = batch_size or self.jobs
        if self.fs.async_impl:
            loop = get_loop()
            fut = asyncio.run_coroutine_threadsafe(
                batch_coros(
                    [self.fs._find(p) for p in path],
                    batch_size=jobs,
                ),
                loop,
            )
            for result in fut.result():
                yield from result
            return
        executor = ThreadPoolExecutor(max_workers=jobs, cancel_on_error=True)
        with executor:
            find = partial(self.fs.find)
            for result in executor.imap_unordered(find, path):
                yield from result

    def mv(self, from_info: AnyFSPath, to_info: AnyFSPath, **kwargs: Any) -> None:
        self.fs.mv(from_info, to_info)

    move = mv

    def rmdir(self, path: AnyFSPath) -> None:
        self.fs.rmdir(path)

    def rm_file(self, path: AnyFSPath) -> None:
        self.fs.rm_file(path)

    def rm(
        self,
        path: Union[AnyFSPath, list[AnyFSPath]],
        recursive: bool = False,
        **kwargs,
    ) -> None:
        self.fs.rm(path, recursive=recursive, **kwargs)

    remove = rm

    @overload
    def info(
        self,
        path: AnyFSPath,
        callback: fsspec.Callback = ...,
        batch_size: Optional[int] = ...,
        **kwargs,
    ) -> "Entry":
        ...

    @overload
    def info(
        self,
        path: list[AnyFSPath],
        callback: fsspec.Callback = ...,
        batch_size: Optional[int] = ...,
    ) -> list["Entry"]:
        ...

    def info(self, path, callback=DEFAULT_CALLBACK, batch_size=None, **kwargs):
        if isinstance(path, str):
            return self.fs.info(path, **kwargs)
        callback.set_size(len(path))
        jobs = batch_size or self.jobs
        if self.fs.async_impl:
            loop = get_loop()
            fut = asyncio.run_coroutine_threadsafe(
                batch_coros(
                    [self.fs._info(p, **kwargs) for p in path],
                    batch_size=jobs,
                    callback=callback,
                ),
                loop,
            )
            return fut.result()

        func = partial(self.fs.info, **kwargs)
        with ThreadPoolExecutor(max_workers=jobs, cancel_on_error=True) as executor:
            it = executor.map(func, path)
            return list(callback.wrap(it))

    def mkdir(
        self, path: AnyFSPath, create_parents: bool = True, **kwargs: Any
    ) -> None:
        self.fs.mkdir(path, create_parents=create_parents, **kwargs)

    def makedirs(self, path: AnyFSPath, **kwargs: Any) -> None:
        self.fs.makedirs(path, exist_ok=kwargs.pop("exist_ok", True))

    def put_file(
        self,
        from_file: Union[AnyFSPath, "BinaryIO"],
        to_info: AnyFSPath,
        callback: fsspec.Callback = DEFAULT_CALLBACK,
        size: Optional[int] = None,
        **kwargs,
    ) -> None:
        if size:
            callback.set_size(size)
        if hasattr(from_file, "read"):
            stream = wrap_file(from_file, callback)
            self.upload_fobj(stream, to_info, size=size)
        else:
            assert isinstance(from_file, str)
            self.fs.put_file(os.fspath(from_file), to_info, callback=callback, **kwargs)
        self.fs.invalidate_cache(self.parent(to_info))

    def get_file(
        self,
        from_info: AnyFSPath,
        to_info: AnyFSPath,
        callback: fsspec.Callback = DEFAULT_CALLBACK,
        **kwargs,
    ) -> None:
        self.fs.get_file(from_info, to_info, callback=callback, **kwargs)

    def upload_fobj(self, fobj: IO, to_info: AnyFSPath, **kwargs) -> None:
        self.makedirs(self.parent(to_info))
        with self.open(to_info, "wb") as fdest:
            shutil.copyfileobj(
                fobj,
                fdest,
                length=getattr(fdest, "blocksize", None),  # type: ignore[arg-type]
            )

    def walk(self, path: AnyFSPath, **kwargs: Any):
        return self.fs.walk(path, **kwargs)

    def glob(self, path: AnyFSPath, **kwargs: Any):
        return self.fs.glob(path, **kwargs)

    def size(self, path: AnyFSPath) -> Optional[int]:
        return self.fs.size(path)

    def sizes(self, paths: list[AnyFSPath]) -> list[Optional[int]]:
        return self.fs.sizes(paths)

    def du(
        self,
        path: AnyFSPath,
        total: bool = True,
        maxdepth: Optional[int] = None,
        **kwargs: Any,
    ) -> Union[int, dict[AnyFSPath, int]]:
        return self.fs.du(path, total=total, maxdepth=maxdepth, **kwargs)

    def put(
        self,
        from_info: Union[AnyFSPath, list[AnyFSPath]],
        to_info: Union[AnyFSPath, list[AnyFSPath]],
        callback: fsspec.Callback = DEFAULT_CALLBACK,
        recursive: bool = False,
        batch_size: Optional[int] = None,
    ):
        jobs = batch_size or self.jobs
        if self.fs.async_impl:
            return self.fs.put(
                from_info,
                to_info,
                callback=callback,
                batch_size=jobs,
                recursive=recursive,
            )

        assert not recursive, "not implemented yet"
        from_infos = [from_info] if isinstance(from_info, str) else from_info
        to_infos = [to_info] if isinstance(to_info, str) else to_info

        callback.set_size(len(from_infos))
        executor = ThreadPoolExecutor(max_workers=jobs, cancel_on_error=True)

        def put_file(from_path, to_path):
            with callback.branched(from_path, to_path) as child:
                return self.put_file(from_path, to_path, callback=child)

        with executor:
            it = executor.imap_unordered(put_file, from_infos, to_infos)
            list(callback.wrap(it))

    def get(
        self,
        from_info: Union[AnyFSPath, list[AnyFSPath]],
        to_info: Union[AnyFSPath, list[AnyFSPath]],
        callback: fsspec.Callback = DEFAULT_CALLBACK,
        recursive: bool = False,
        batch_size: Optional[int] = None,
    ) -> None:
        # Currently, the implementation is non-recursive if the paths are
        # provided as a list, and recursive if it's a single path.
        from .local import localfs

        def get_file(rpath, lpath, **kwargs):
            localfs.makedirs(localfs.parent(lpath), exist_ok=True)
            with callback.branched(rpath, lpath) as child:
                self.fs.get_file(rpath, lpath, callback=child, **kwargs)

        if isinstance(from_info, list) and isinstance(to_info, list):
            from_infos: list[AnyFSPath] = from_info
            to_infos: list[AnyFSPath] = to_info
        else:
            assert isinstance(from_info, str)
            assert isinstance(to_info, str)

            if not self.isdir(from_info):
                callback.set_size(1)
                get_file(from_info, to_info)
                callback.relative_update()
                return

            from_infos = list(self.find(from_info))
            if not from_infos:
                return localfs.makedirs(to_info, exist_ok=True)

            to_infos = [
                localfs.join(to_info, *self.relparts(info, from_info))
                for info in from_infos
            ]

        jobs = batch_size or self.jobs
        if self.fs.async_impl:
            return self.fs.get(
                from_infos,
                to_infos,
                callback=callback,
                batch_size=jobs,
            )

        callback.set_size(len(from_infos))
        executor = ThreadPoolExecutor(max_workers=jobs, cancel_on_error=True)
        with executor:
            it = executor.imap_unordered(get_file, from_infos, to_infos)
            list(callback.wrap(it))

    def ukey(self, path: AnyFSPath) -> str:
        return self.fs.ukey(path)

    def created(self, path: AnyFSPath) -> datetime.datetime:
        return self.fs.created(path)

    def modified(self, path: AnyFSPath) -> datetime.datetime:
        return self.fs.modified(path)

    def sign(self, path: AnyFSPath, expiration: int = 100, **kwargs: Any) -> str:
        return self.fs.sign(path, expiration=expiration, **kwargs)


class ObjectFileSystem(FileSystem):
    TRAVERSE_PREFIX_LEN = 3

    def makedirs(self, path: AnyFSPath, **kwargs: Any) -> None:
        # For object storages make this method a no-op. The original
        # fs.makedirs() method will only check if the bucket exists
        # and create if it doesn't though we don't want to support
        # that behavior, and the check will cost some time so we'll
        # simply ignore all mkdir()/makedirs() calls.
        return None

    def mkdir(
        self, path: AnyFSPath, create_parents: bool = True, **kwargs: Any
    ) -> None:
        return None

    def find(
        self,
        path: Union[AnyFSPath, list[AnyFSPath]],
        prefix: bool = False,
        batch_size: Optional[int] = None,
        **kwargs,
    ) -> Iterator[str]:
        if isinstance(path, str):
            paths = [path]
        else:
            paths = path

        def _make_args(paths: list[AnyFSPath]) -> Iterator[tuple[str, str]]:
            for path in paths:
                if prefix and not path.endswith(self.flavour.sep):
                    parent = self.parent(path)
                    yield parent, self.parts(path)[-1]
                else:
                    yield path, ""

        args = list(_make_args(paths))
        if len(args) == 1:
            path, prefix_str = args[0]
            yield from self.fs.find(path, prefix=prefix_str)
            return

        jobs = batch_size or self.jobs
        if self.fs.async_impl:
            loop = get_loop()
            fut = asyncio.run_coroutine_threadsafe(
                batch_coros(
                    [
                        self.fs._find(path, prefix=prefix_str)
                        for path, prefix_str in args
                    ],
                    batch_size=jobs,
                ),
                loop,
            )
            for result in fut.result():
                yield from result
            return
        # NOTE: this is not parallelized yet since imap_unordered does not
        # handle kwargs. We do not actually support any non-async object
        # storages, so this can be addressed when it is actually needed
        for path, prefix_str in args:
            yield from self.fs.find(path, prefix=prefix_str)