fs.upload/download: simplify, remove unused kwargs, callbacks

1. This PR simplifies `fs.upload`/`fs.download`/`fs.download_file`.
2. Removes unused kwargs.
3. `download` uses `dvc.utils.threadpool.ThreadPoolExecutor` which
   supports canceling futures.
4. `download` for directories supports nested/branched callbacks
   and progress bars.
This commit is contained in:
Saugat Pachhai (सौगात)
2022-04-26 06:57:10 +05:45
committed by Saugat Pachhai
parent 7211bd02ed
commit f39e35445a
6 changed files with 136 additions and 155 deletions
+9 -4
View File
@@ -25,7 +25,8 @@ logger = logging.getLogger(__name__)
_STAGING_MEMFS_PATH = "dvc-staging" _STAGING_MEMFS_PATH = "dvc-staging"
def _upload_file(from_fs_path, fs, odb, upload_odb): def _upload_file(from_fs_path, fs, odb, upload_odb, callback=None):
from dvc.fs._callback import FsspecCallback
from dvc.utils import tmp_fname from dvc.utils import tmp_fname
from dvc.utils.stream import HashedStreamReader from dvc.utils.stream import HashedStreamReader
@@ -34,9 +35,13 @@ def _upload_file(from_fs_path, fs, odb, upload_odb):
with fs.open(from_fs_path, mode="rb", chunk_size=fs.CHUNK_SIZE) as stream: with fs.open(from_fs_path, mode="rb", chunk_size=fs.CHUNK_SIZE) as stream:
stream = HashedStreamReader(stream) stream = HashedStreamReader(stream)
size = fs.size(from_fs_path) size = fs.size(from_fs_path)
upload_odb.fs.upload( with FsspecCallback.as_tqdm_callback(
stream, tmp_info, desc=fs_path.name(from_fs_path), total=size callback,
) desc=fs_path.name(from_fs_path),
bytes=True,
total=size,
) as cb:
upload_odb.fs.upload(stream, tmp_info, size=size, callback=cb)
odb.add(tmp_info, upload_odb.fs, stream.hash_info) odb.add(tmp_info, upload_odb.fs, stream.hash_info)
meta = Meta(size=size) meta = Meta(size=size)
+58 -18
View File
@@ -1,15 +1,16 @@
from functools import wraps from functools import wraps
from typing import IO, TYPE_CHECKING, Optional, TypeVar, cast from typing import IO, TYPE_CHECKING, Any, Optional, TypeVar, cast
import fsspec import fsspec
from dvc.progress import Tqdm
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Callable from typing import Callable
from typing_extensions import ParamSpec from typing_extensions import ParamSpec
from dvc.fs.base import FileSystem
from dvc.progress import Tqdm
_P = ParamSpec("_P") _P = ParamSpec("_P")
_R = TypeVar("_R") _R = TypeVar("_R")
@@ -32,6 +33,39 @@ class FsspecCallback(fsspec.Callback):
return wrapped return wrapped
def wrap_and_branch(
self, fn: "Callable", fs: "FileSystem" = None
) -> "Callable":
"""
Wraps a function, and pass a new child callback to it.
When the function completes, we increment the parent callback by 1.
"""
from .local import localfs
fs = fs or localfs
wrapped = self.wrap_fn(fn)
def make_callback(path1, path2):
# pylint: disable=assignment-from-none
child = self.branch(fs.path.name(path1), path2, {})
return self.as_callback(child)
@wraps(fn)
def func(path1, path2, **kwargs):
with make_callback(path1, path2) as callback:
return wrapped(path1, path2, callback=callback, **kwargs)
return func
def __enter__(self):
return self
def __exit__(self, *exc_args):
self.close()
def close(self):
"""Handle here on exit."""
@classmethod @classmethod
def as_callback( def as_callback(
cls, maybe_callback: Optional["FsspecCallback"] = None cls, maybe_callback: Optional["FsspecCallback"] = None
@@ -40,16 +74,33 @@ class FsspecCallback(fsspec.Callback):
return DEFAULT_CALLBACK return DEFAULT_CALLBACK
return maybe_callback return maybe_callback
@classmethod
def as_tqdm_callback(
cls,
callback: Optional["FsspecCallback"] = None,
**tqdm_kwargs: Any,
) -> "FsspecCallback":
return callback or TqdmCallback(**tqdm_kwargs)
class NoOpCallback(FsspecCallback, fsspec.callbacks.NoOpCallback): class NoOpCallback(FsspecCallback, fsspec.callbacks.NoOpCallback):
pass pass
class TqdmCallback(FsspecCallback): class TqdmCallback(FsspecCallback):
def __init__(self, progress_bar): def __init__(self, progress_bar: "Tqdm" = None, **tqdm_kwargs):
self.progress_bar = progress_bar from dvc.progress import Tqdm
self.progress_bar = progress_bar or Tqdm(**tqdm_kwargs)
super().__init__() super().__init__()
def __enter__(self):
self.progress_bar.__enter__()
return self
def close(self):
self.progress_bar.close()
def set_size(self, size): def set_size(self, size):
if size is not None: if size is not None:
self.progress_bar.total = size self.progress_bar.total = size
@@ -64,19 +115,8 @@ class TqdmCallback(FsspecCallback):
self.progress_bar.update_to(value) self.progress_bar.update_to(value)
super().absolute_update(value) super().absolute_update(value)
def branch(self, path_1: str, path_2: str, kwargs):
def tdqm_or_callback_wrapped( return TqdmCallback(bytes=True, total=-1, desc=path_1, **kwargs)
fobj, method, total, callback=None, **pbar_kwargs
):
if callback:
from funcy import nullcontext
from tqdm.utils import CallbackIOWrapper
callback.set_size(total)
wrapper = CallbackIOWrapper(callback.relative_update, fobj, method)
return nullcontext(wrapper)
return Tqdm.wrapattr(fobj, method, total=total, bytes=True, **pbar_kwargs)
DEFAULT_CALLBACK = NoOpCallback() DEFAULT_CALLBACK = NoOpCallback()
+55 -118
View File
@@ -1,9 +1,6 @@
import contextlib
import logging import logging
import os import os
import shutil import shutil
from concurrent.futures import ThreadPoolExecutor, as_completed
from functools import partialmethod
from multiprocessing import cpu_count from multiprocessing import cpu_count
from typing import ( from typing import (
IO, IO,
@@ -22,9 +19,8 @@ from typing import (
from funcy import cached_property from funcy import cached_property
from dvc.exceptions import DvcException from dvc.exceptions import DvcException
from dvc.ui import ui from dvc.utils.fs import as_atomic, makedirs
from dvc.utils import tmp_fname from dvc.utils.threadpool import ThreadPoolExecutor
from dvc.utils.fs import makedirs, move
from ._callback import DEFAULT_CALLBACK, FsspecCallback from ._callback import DEFAULT_CALLBACK, FsspecCallback
@@ -325,145 +321,86 @@ class FileSystem:
self, self,
from_info: Union[AnyFSPath, IO], from_info: Union[AnyFSPath, IO],
to_info: AnyFSPath, to_info: AnyFSPath,
total: int = None, size: int = None,
desc: str = None, callback: FsspecCallback = None,
callback=None,
no_progress_bar: bool = False,
**pbar_args: Any,
): ):
from .local import localfs
if not hasattr(from_info, "read"): if not hasattr(from_info, "read"):
from .local import localfs
desc = desc or localfs.path.name(from_info)
logger.debug("Uploading '%s' to '%s'", from_info, to_info) logger.debug("Uploading '%s' to '%s'", from_info, to_info)
desc = localfs.path.name(from_info)
else:
desc = self.path.name(to_info)
stack = contextlib.ExitStack() with FsspecCallback.as_tqdm_callback(
if not callback: callback,
pbar = ui.progress( desc=desc,
desc=desc, bytes=True,
disable=no_progress_bar, total=size or -1,
bytes=True, ) as cb:
total=total or -1, return self.put_file(from_info, to_info, callback=cb, size=size)
**pbar_args,
)
stack.enter_context(pbar)
callback = pbar.as_callback()
with stack:
if total:
callback.set_size(total)
return self.put_file(
from_info, to_info, callback=callback, size=total
)
def download( def download(
self, self,
from_info: AnyFSPath, from_info: AnyFSPath,
to_info: AnyFSPath, to_info: AnyFSPath,
name: str = None, callback: "FsspecCallback" = None,
callback=None,
no_progress_bar: bool = False,
jobs: int = None, jobs: int = None,
_only_file: bool = False,
**kwargs: Any,
): ):
if not self.isdir(from_info):
return self.download_file(from_info, to_info, callback=callback)
from .local import localfs from .local import localfs
if not hasattr(self, "get_file"): pairs = {
raise RemoteActionNotImplemented("get_file", self.scheme) info: localfs.path.join(
to_info, *self.path.relparts(info, from_info)
download_dir = not _only_file and self.isdir(from_info)
desc = name or localfs.path.name(to_info)
stack = contextlib.ExitStack()
if not callback:
pbar_kwargs = {"unit": "Files"} if download_dir else {}
pbar = ui.progress(
total=-1,
desc="Downloading directory" if download_dir else desc,
bytes=not download_dir,
disable=no_progress_bar,
**pbar_kwargs,
) )
stack.enter_context(pbar) for info in self.find(from_info)
callback = pbar.as_callback() }
if not pairs:
with stack:
if download_dir:
return self._download_dir(
from_info, to_info, callback=callback, jobs=jobs, **kwargs
)
return self._download_file(from_info, to_info, callback=callback)
download_file = partialmethod(download, _only_file=True)
def _download_dir(
self,
from_info: AnyFSPath,
to_info: AnyFSPath,
callback: FsspecCallback = DEFAULT_CALLBACK,
jobs: int = None,
**kwargs,
):
from .local import localfs
from_infos = list(self.find(from_info, **kwargs))
if not from_infos:
makedirs(to_info, exist_ok=True) makedirs(to_info, exist_ok=True)
return None return
to_infos = (
localfs.path.join(to_info, *self.path.relparts(info, from_info))
for info in from_infos
)
callback.set_size(len(from_infos))
download_files = callback.wrap_fn(self._download_file)
max_workers = jobs or self.jobs
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [
executor.submit(download_files, from_info, to_info)
for from_info, to_info in zip(from_infos, to_infos)
]
with FsspecCallback.as_tqdm_callback(
callback,
total=-1,
desc=f"Downloading directory {self.path.name(from_info)}",
unit="files",
) as cb:
cb.set_size(len(pairs))
download_files = cb.wrap_and_branch(self.download_file, fs=self)
max_workers = jobs or self.jobs
# NOTE: unlike pulling/fetching cache, where we need to # NOTE: unlike pulling/fetching cache, where we need to
# download everything we can, not raising an error here might # download everything we can, not raising an error here might
# turn very ugly, as the user might think that he has # turn very ugly, as the user might think that he has
# downloaded a complete directory, while having a partial one, # downloaded a complete directory, while having a partial one,
# which might cause unexpected results in his pipeline. # which might cause unexpected results in his pipeline.
for future in as_completed(futures): with ThreadPoolExecutor(
# NOTE: executor won't let us raise until all futures that max_workers=max_workers, cancel_on_error=True
# it has are finished, so we need to cancel them ourselves ) as executor:
# before re-raising. list(
exc = future.exception() executor.imap_unordered(
if exc: lambda args: download_files(*args), pairs.items()
for entry in futures: )
entry.cancel() )
raise exc
def _download_file( def download_file(
self, self,
from_info: AnyFSPath, from_info: AnyFSPath,
to_info: AnyFSPath, to_info: AnyFSPath,
callback=DEFAULT_CALLBACK, callback: FsspecCallback = None,
) -> None: ):
from .local import localfs from .local import localfs
makedirs(localfs.path.parent(to_info), exist_ok=True) with FsspecCallback.as_tqdm_callback(
tmp_file = tmp_fname(to_info) callback,
total=-1,
logger.debug("Downloading '%s' to '%s'", from_info, to_info) desc=self.path.name(from_info),
try: bytes=True,
# noqa, pylint: disable=no-member ) as cb:
self.get_file(from_info, tmp_file, callback=callback) with as_atomic(localfs, to_info, create_parents=True) as tmp_file:
except Exception: # pylint: disable=broad-except self.get_file(from_info, tmp_file, callback=cb)
# do we need to rollback makedirs for previously not-existing
# directories?
with contextlib.suppress(FileNotFoundError):
os.unlink(tmp_file)
raise
move(tmp_file, to_info)
class ObjectFileSystem(FileSystem): # pylint: disable=abstract-method class ObjectFileSystem(FileSystem): # pylint: disable=abstract-method
+1 -1
View File
@@ -48,7 +48,7 @@ def _copy(
with from_fs.open(from_path, mode="rb") as fobj: with from_fs.open(from_path, mode="rb") as fobj:
size = from_fs.size(from_path) size = from_fs.size(from_path)
return to_fs.upload(fobj, to_path, total=size) return to_fs.upload(fobj, to_path, size=size)
def _try_links( def _try_links(
-5
View File
@@ -135,11 +135,6 @@ class Tqdm(tqdm):
return wrapped return wrapped
def as_callback(self):
from dvc.fs._callback import TqdmCallback
return TqdmCallback(self)
def close(self): def close(self):
self.postfix["info"] = "" self.postfix["info"] = ""
# remove ETA (either unknown or zero); remove completed bar # remove ETA (either unknown or zero); remove completed bar
+13 -9
View File
@@ -200,17 +200,17 @@ def copyfile(src, dest, callback=None, no_progress_bar=False, name=None):
try: try:
System.reflink(src, dest) System.reflink(src, dest)
except OSError: except OSError:
from dvc.fs._callback import tdqm_or_callback_wrapped from dvc.fs._callback import FsspecCallback
with open(src, "rb") as fsrc, open(dest, "wb+") as fdest: with open(src, "rb") as fsrc, open(dest, "wb+") as fdest:
with tdqm_or_callback_wrapped( with FsspecCallback.as_tqdm_callback(
fdest, callback,
"write", total=total,
total, bytes=True,
callback=callback,
disable=no_progress_bar, disable=no_progress_bar,
desc=name, desc=name,
) as wrapped: ) as cb:
wrapped = cb.wrap_attr(fdest, "write")
while True: while True:
buf = fsrc.read(LOCAL_CHUNK_SIZE) buf = fsrc.read(LOCAL_CHUNK_SIZE)
if not buf: if not buf:
@@ -234,10 +234,14 @@ def walk_files(directory):
@contextmanager @contextmanager
def as_atomic(fs, to_info): def as_atomic(fs, to_info, create_parents=False):
from dvc.utils import tmp_fname from dvc.utils import tmp_fname
tmp_info = fs.path.join(fs.path.parent(to_info), tmp_fname()) parent = fs.path.parent(to_info)
if create_parents:
fs.makedirs(parent, exist_ok=True)
tmp_info = fs.path.join(parent, tmp_fname())
try: try:
yield tmp_info yield tmp_info
except BaseException: except BaseException: