mirror of
https://github.com/Stone-Red-Code/dvc.git
synced 2026-11-12 07:18:23 +01:00
595 lines
18 KiB
Python
595 lines
18 KiB
Python
import datetime
|
|
import logging
|
|
import os
|
|
import shutil
|
|
from multiprocessing import cpu_count
|
|
from typing import (
|
|
IO,
|
|
TYPE_CHECKING,
|
|
Any,
|
|
ClassVar,
|
|
Dict,
|
|
Iterator,
|
|
List,
|
|
Optional,
|
|
Union,
|
|
cast,
|
|
overload,
|
|
)
|
|
|
|
from funcy import cached_property
|
|
|
|
from dvc.exceptions import DvcException
|
|
from dvc.utils.threadpool import ThreadPoolExecutor
|
|
|
|
from ._callback import DEFAULT_CALLBACK, FsspecCallback
|
|
|
|
if TYPE_CHECKING:
|
|
from typing import BinaryIO, TextIO
|
|
|
|
from fsspec.spec import AbstractFileSystem
|
|
from typing_extensions import Literal
|
|
|
|
|
|
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 RemoteActionNotImplemented(DvcException):
|
|
def __init__(self, action, scheme):
|
|
m = f"{action} is not supported for {scheme} remotes"
|
|
super().__init__(m)
|
|
|
|
|
|
class RemoteMissingDepsError(DvcException):
|
|
pass
|
|
|
|
|
|
class FileSystem:
|
|
sep = "/"
|
|
|
|
scheme = "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, **kwargs):
|
|
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
|
|
self.fs_args = {"skip_instance_cache": True}
|
|
self.fs_args.update(self._prepare_credentials(**kwargs))
|
|
|
|
@property
|
|
def config(self) -> Dict[str, Any]:
|
|
return self._config
|
|
|
|
@cached_property
|
|
def path(self):
|
|
from .path import Path
|
|
|
|
def _getcwd():
|
|
return self.fs.root_marker
|
|
|
|
return Path(self.sep, getcwd=_getcwd)
|
|
|
|
@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
|
|
|
|
@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] # pylint: disable=unused-argument
|
|
) -> Dict[str, Any]:
|
|
"""Prepare the arguments for authentication to the
|
|
host filesystem"""
|
|
return {}
|
|
|
|
@classmethod
|
|
def get_missing_deps(cls) -> List[str]:
|
|
import importlib
|
|
|
|
missing: List[str] = []
|
|
for package, module in cls.REQUIRES.items():
|
|
try:
|
|
importlib.import_module(module)
|
|
except ImportError:
|
|
missing.append(package)
|
|
|
|
return missing
|
|
|
|
def _check_requires(self, **kwargs):
|
|
from dvc.scheme import Schemes
|
|
from dvc.utils import format_link
|
|
from dvc.utils.pkg import PKG
|
|
|
|
missing = self.get_missing_deps()
|
|
if not missing:
|
|
return
|
|
|
|
url = kwargs.get("url", f"{self.scheme}://")
|
|
|
|
scheme = self.scheme
|
|
if scheme == Schemes.WEBDAVS:
|
|
scheme = Schemes.WEBDAV
|
|
|
|
by_pkg = {
|
|
"pip": f"pip install 'dvc[{scheme}]'",
|
|
"conda": f"conda install -c conda-forge dvc-{scheme}",
|
|
}
|
|
|
|
cmd = by_pkg.get(PKG)
|
|
if cmd:
|
|
link = format_link("https://dvc.org/doc/install")
|
|
hint = (
|
|
f"To install dvc with those dependencies, run:\n"
|
|
"\n"
|
|
f"\t{cmd}\n"
|
|
"\n"
|
|
f"See {link} for more info."
|
|
)
|
|
else:
|
|
link = format_link("https://github.com/iterative/dvc/issues")
|
|
hint = f"Please report this bug to {link}. Thank you!"
|
|
|
|
raise RemoteMissingDepsError(
|
|
f"URL '{url}' is supported but requires these missing "
|
|
f"dependencies: {missing}. {hint}"
|
|
)
|
|
|
|
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": # pylint: disable=arguments-differ
|
|
return self.open(path, mode, **kwargs)
|
|
|
|
@overload
|
|
def open(
|
|
self,
|
|
path: AnyFSPath,
|
|
mode: "Literal['r', 'rt', 'w']",
|
|
**kwargs: Any,
|
|
) -> "TextIO": # pylint: disable=arguments-differ
|
|
...
|
|
|
|
def open(
|
|
self,
|
|
path: AnyFSPath,
|
|
mode: str = "r",
|
|
**kwargs: Any,
|
|
) -> "IO[Any]": # pylint: disable=arguments-differ
|
|
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: 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: 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: int = None,
|
|
end: 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)
|
|
|
|
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.path.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)
|
|
|
|
def exists(self, path: AnyFSPath) -> bool:
|
|
return self.fs.exists(path)
|
|
|
|
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:
|
|
raise RemoteActionNotImplemented("symlink", self.scheme)
|
|
|
|
def hardlink(self, from_info: AnyFSPath, to_info: AnyFSPath) -> None:
|
|
try:
|
|
return self.fs.hardlink(from_info, to_info)
|
|
except AttributeError:
|
|
raise RemoteActionNotImplemented("hardlink", self.scheme)
|
|
|
|
def reflink(self, from_info: AnyFSPath, to_info: AnyFSPath) -> None:
|
|
try:
|
|
return self.fs.reflink(from_info, to_info)
|
|
except AttributeError:
|
|
raise RemoteActionNotImplemented("reflink", self.scheme)
|
|
|
|
def is_symlink(self, path: AnyFSPath) -> bool:
|
|
try:
|
|
return self.fs.is_symlink(path)
|
|
except AttributeError:
|
|
return False
|
|
|
|
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]"
|
|
) -> "Iterator[Entry]":
|
|
...
|
|
|
|
@overload
|
|
def ls(self, path: AnyFSPath, detail: "Literal[False]") -> Iterator[str]:
|
|
...
|
|
|
|
def ls(self, path, detail=False, **kwargs):
|
|
return self.fs.ls(path, detail=detail)
|
|
|
|
def find(
|
|
self,
|
|
path: AnyFSPath,
|
|
prefix: bool = False, # pylint: disable=unused-argument
|
|
) -> Iterator[str]:
|
|
yield from self.fs.find(path)
|
|
|
|
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: AnyFSPath) -> None:
|
|
self.fs.rm(path, recursive=True)
|
|
|
|
remove = rm
|
|
|
|
def info(self, path: AnyFSPath) -> "Entry":
|
|
return self.fs.info(path)
|
|
|
|
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: FsspecCallback = DEFAULT_CALLBACK,
|
|
size: int = None,
|
|
**kwargs,
|
|
) -> None:
|
|
if size:
|
|
callback.set_size(size)
|
|
if hasattr(from_file, "read"):
|
|
stream = callback.wrap_attr(cast("BinaryIO", from_file))
|
|
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.path.parent(to_info))
|
|
|
|
def get_file(
|
|
self,
|
|
from_info: AnyFSPath,
|
|
to_info: AnyFSPath,
|
|
callback: FsspecCallback = 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.path.parent(to_info))
|
|
with self.open(to_info, "wb") as fdest:
|
|
shutil.copyfileobj(
|
|
fobj,
|
|
fdest,
|
|
length=getattr(fdest, "blocksize", None), # type: ignore
|
|
)
|
|
|
|
def walk(
|
|
self,
|
|
path: AnyFSPath,
|
|
topdown: bool = True,
|
|
**kwargs: Any,
|
|
):
|
|
return self.fs.walk(path, topdown=topdown, **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: 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: "FsspecCallback" = DEFAULT_CALLBACK,
|
|
recursive: bool = False, # pylint: disable=unused-argument
|
|
batch_size: 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)
|
|
with executor:
|
|
put_file = callback.wrap_and_branch(self.put_file)
|
|
list(executor.imap_unordered(put_file, from_infos, to_infos))
|
|
|
|
def get(
|
|
self,
|
|
from_info: Union[AnyFSPath, List[AnyFSPath]],
|
|
to_info: Union[AnyFSPath, List[AnyFSPath]],
|
|
callback: "FsspecCallback" = DEFAULT_CALLBACK,
|
|
recursive: bool = False, # pylint: disable=unused-argument
|
|
batch_size: 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 .implementations.local import localfs
|
|
|
|
def get_file(rpath, lpath, **kwargs):
|
|
localfs.makedirs(localfs.path.parent(lpath), exist_ok=True)
|
|
self.fs.get_file(rpath, lpath, **kwargs)
|
|
|
|
get_file = callback.wrap_and_branch(get_file)
|
|
|
|
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)
|
|
return get_file(from_info, to_info)
|
|
|
|
from_infos = list(self.find(from_info))
|
|
if not from_infos:
|
|
return localfs.makedirs(to_info, exist_ok=True)
|
|
|
|
to_infos = [
|
|
localfs.path.join(
|
|
to_info, *self.path.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:
|
|
list(executor.imap_unordered(get_file, from_infos, to_infos))
|
|
|
|
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): # pylint: disable=abstract-method
|
|
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 _isdir(self, path: AnyFSPath) -> bool:
|
|
# Directory in object storages are interpreted differently
|
|
# among different fsspec providers, so this logic is a temporary
|
|
# measure for us to adapt as of now. It checks whether it is a
|
|
# directory (as in a prefix with contents) or whether it is an empty
|
|
# file where it's name ends with a forward slash
|
|
|
|
entry = self.info(path)
|
|
return entry["type"] == "directory" or (
|
|
entry["size"] == 0
|
|
and entry["type"] == "file"
|
|
and entry["name"].endswith("/")
|
|
)
|
|
|
|
def isdir(self, path: AnyFSPath) -> bool:
|
|
try:
|
|
return self._isdir(path)
|
|
except FileNotFoundError:
|
|
return False
|
|
|
|
def isfile(self, path: AnyFSPath) -> bool:
|
|
try:
|
|
return not self._isdir(path)
|
|
except FileNotFoundError:
|
|
return False
|
|
|
|
def find(self, path: AnyFSPath, prefix: bool = False) -> Iterator[str]:
|
|
if prefix:
|
|
with_prefix = self.path.parent(path)
|
|
files = self.fs.find(with_prefix, prefix=self.path.parts(path)[-1])
|
|
else:
|
|
with_prefix = path
|
|
files = self.fs.find(path)
|
|
|
|
# When calling find() on a file, it returns the same file in a list.
|
|
# For object-based storages, the same behavior applies to empty
|
|
# directories since they are represented as files. This condition
|
|
# checks whether we should yield an empty list (if it is an empty
|
|
# directory) or just yield the file itself.
|
|
if len(files) == 1 and files[0] == with_prefix and self.isdir(path):
|
|
return None
|
|
|
|
yield from files
|