mirror of
https://github.com/Stone-Red-Code/dvc.git
synced 2026-11-10 15:28:34 +01:00
checkout: show summary by default and introduce --show-changes flag (#3401)
* checkout: implement --show-changes flag to show detailed checkout details Show summary by default on checkout checkout: show directory modified even when files are removed from inside it checkout: refactor template logic checkout: change output format checkout: do not show summary if nothing's changed checkout: do not show summary if show_changes is specified checkout: show '/' at the end if it's a directory rename variable name test: cover stats for checkout checkout: show relink as not modified checkout: remove unwanted message during empty checkout pull: show summary on checkout import/get: change external_repo to catch CheckoutError fix tests and check if path is url checkout: support external deps/outputs tests: checkout on external outputs * fix tests * checkout: make show changes default and introduce summary flag to show summary * address suggestions * exceptions: remove extra spacing on erorr * Update dvc/command/checkout.py Co-Authored-By: Jorge Orpinel <[email protected]> Co-authored-by: Jorge Orpinel <[email protected]>
This commit is contained in:
co-authored by
Jorge Orpinel
parent
d2734dfe1e
commit
066ccea733
+81
-7
@@ -1,18 +1,86 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
import colorama
|
||||
|
||||
from dvc.command.base import append_doc_link
|
||||
from dvc.command.base import CmdBase
|
||||
from dvc.exceptions import CheckoutError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _human_join(words):
|
||||
words = list(words)
|
||||
if not words:
|
||||
return ""
|
||||
|
||||
return (
|
||||
"{before} and {after}".format(
|
||||
before=", ".join(words[:-1]), after=words[-1],
|
||||
)
|
||||
if len(words) > 1
|
||||
else words[0]
|
||||
)
|
||||
|
||||
|
||||
def log_summary(stats):
|
||||
states = ("added", "deleted", "modified")
|
||||
summary = (
|
||||
"{} {}".format(len(stats[state]), state)
|
||||
for state in states
|
||||
if stats.get(state)
|
||||
)
|
||||
logger.info(_human_join(summary))
|
||||
|
||||
|
||||
def log_changes(stats):
|
||||
colors = [
|
||||
("modified", colorama.Fore.YELLOW,),
|
||||
("added", colorama.Fore.GREEN),
|
||||
("deleted", colorama.Fore.RED,),
|
||||
]
|
||||
|
||||
for state, color in colors:
|
||||
entries = stats.get(state)
|
||||
|
||||
if not entries:
|
||||
continue
|
||||
|
||||
for entry in entries:
|
||||
logger.info(
|
||||
"{color}{state}{nc}{spacing}{entry}".format(
|
||||
color=color,
|
||||
state=state[0].upper(),
|
||||
nc=colorama.Fore.RESET,
|
||||
spacing="\t",
|
||||
entry=entry,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class CmdCheckout(CmdBase):
|
||||
def run(self):
|
||||
self.repo.checkout(
|
||||
targets=self.args.targets,
|
||||
with_deps=self.args.with_deps,
|
||||
force=self.args.force,
|
||||
relink=self.args.relink,
|
||||
recursive=self.args.recursive,
|
||||
)
|
||||
stats, exc = None, None
|
||||
try:
|
||||
stats = self.repo.checkout(
|
||||
targets=self.args.targets,
|
||||
with_deps=self.args.with_deps,
|
||||
force=self.args.force,
|
||||
relink=self.args.relink,
|
||||
recursive=self.args.recursive,
|
||||
)
|
||||
except CheckoutError as _exc:
|
||||
exc = _exc
|
||||
stats = exc.stats
|
||||
|
||||
if self.args.summary:
|
||||
log_summary(stats)
|
||||
else:
|
||||
log_changes(stats)
|
||||
|
||||
if exc:
|
||||
raise exc
|
||||
return 0
|
||||
|
||||
|
||||
@@ -26,6 +94,12 @@ def add_parser(subparsers, parent_parser):
|
||||
help=CHECKOUT_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
checkout_parser.add_argument(
|
||||
"--summary",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Show summary of the changes.",
|
||||
)
|
||||
checkout_parser.add_argument(
|
||||
"-d",
|
||||
"--with-deps",
|
||||
|
||||
@@ -3,7 +3,8 @@ import logging
|
||||
|
||||
from dvc.command.base import append_doc_link
|
||||
from dvc.command.base import CmdBase
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.command.checkout import log_summary
|
||||
from dvc.exceptions import DvcException, CheckoutError
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -19,7 +20,7 @@ class CmdDataBase(CmdBase):
|
||||
class CmdDataPull(CmdDataBase):
|
||||
def run(self):
|
||||
try:
|
||||
processed_files_count = self.repo.pull(
|
||||
stats = self.repo.pull(
|
||||
targets=self.args.targets,
|
||||
jobs=self.args.jobs,
|
||||
remote=self.args.remote,
|
||||
@@ -29,10 +30,13 @@ class CmdDataPull(CmdDataBase):
|
||||
force=self.args.force,
|
||||
recursive=self.args.recursive,
|
||||
)
|
||||
except DvcException:
|
||||
log_summary(stats)
|
||||
except (CheckoutError, DvcException) as exc:
|
||||
log_summary(getattr(exc, "stats", {}))
|
||||
logger.exception("failed to pull data from the cloud")
|
||||
return 1
|
||||
self.check_up_to_date(processed_files_count)
|
||||
|
||||
self.check_up_to_date(stats["downloaded"])
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+4
-2
@@ -256,10 +256,12 @@ class UploadError(DvcException):
|
||||
|
||||
|
||||
class CheckoutError(DvcException):
|
||||
def __init__(self, target_infos):
|
||||
def __init__(self, target_infos, stats=None):
|
||||
self.target_infos = target_infos
|
||||
self.stats = stats
|
||||
targets = [str(t) for t in target_infos]
|
||||
m = (
|
||||
"Checkout failed for following targets:\n {}\nDid you "
|
||||
"Checkout failed for following targets:\n{}\nDid you "
|
||||
"forget to fetch?".format("\n".join(targets))
|
||||
)
|
||||
super().__init__(m)
|
||||
|
||||
@@ -11,7 +11,7 @@ from dvc.path_info import PathInfo
|
||||
from dvc.compat import fspath
|
||||
from dvc.repo import Repo
|
||||
from dvc.config import NoRemoteError, NotDvcRepoError
|
||||
from dvc.exceptions import NoRemoteInExternalRepoError
|
||||
from dvc.exceptions import NoRemoteInExternalRepoError, CheckoutError
|
||||
from dvc.exceptions import OutputNotFoundError, NoOutputInExternalRepoError
|
||||
from dvc.exceptions import FileMissingError, PathMissingError
|
||||
from dvc.utils.fs import remove, fs_copy, move
|
||||
@@ -106,14 +106,14 @@ class ExternalRepo(Repo):
|
||||
if out.changed_cache(filter_info=src):
|
||||
self.cloud.pull(out.get_used_cache(filter_info=src))
|
||||
|
||||
failed = out.checkout(filter_info=src)
|
||||
try:
|
||||
out.checkout(filter_info=src)
|
||||
except CheckoutError:
|
||||
raise FileNotFoundError
|
||||
|
||||
move(src, dest)
|
||||
remove(tmp)
|
||||
|
||||
if failed:
|
||||
raise FileNotFoundError
|
||||
|
||||
@wrap_with(threading.Lock())
|
||||
def _set_cache_dir(self):
|
||||
try:
|
||||
|
||||
+27
-8
@@ -14,6 +14,7 @@ from shortuuid import uuid
|
||||
|
||||
import dvc.prompt as prompt
|
||||
from dvc.exceptions import (
|
||||
CheckoutError,
|
||||
DvcException,
|
||||
ConfirmRemoveError,
|
||||
DvcIgnoreInCollectedDirError,
|
||||
@@ -851,13 +852,15 @@ class RemoteBASE(object):
|
||||
self.remove(path_info)
|
||||
|
||||
def _checkout_file(
|
||||
self, path_info, checksum, force, progress_callback=None
|
||||
self, path_info, checksum, force, progress_callback=None, relink=False
|
||||
):
|
||||
"""The file is changed we need to checkout a new copy"""
|
||||
added, modified = True, False
|
||||
cache_info = self.checksum_to_path_info(checksum)
|
||||
if self.exists(path_info):
|
||||
logger.debug("data '{}' will be replaced.", path_info)
|
||||
self.safe_remove(path_info, force=force)
|
||||
added, modified = False, True
|
||||
|
||||
self.link(cache_info, path_info)
|
||||
self.state.save_link(path_info)
|
||||
@@ -865,6 +868,8 @@ class RemoteBASE(object):
|
||||
if progress_callback:
|
||||
progress_callback(str(path_info))
|
||||
|
||||
return added, modified and not relink
|
||||
|
||||
def makedirs(self, path_info):
|
||||
"""Optional: Implement only if the remote needs to create
|
||||
directories before copying/linking/moving data
|
||||
@@ -879,9 +884,11 @@ class RemoteBASE(object):
|
||||
relink=False,
|
||||
filter_info=None,
|
||||
):
|
||||
added, modified = False, False
|
||||
# Create dir separately so that dir is created
|
||||
# even if there are no files in it
|
||||
if not self.exists(path_info):
|
||||
added = True
|
||||
self.makedirs(path_info)
|
||||
|
||||
dir_info = self.get_dir_cache(checksum)
|
||||
@@ -899,27 +906,36 @@ class RemoteBASE(object):
|
||||
|
||||
entry_checksum_info = {self.PARAM_CHECKSUM: entry_checksum}
|
||||
if relink or self.changed(entry_info, entry_checksum_info):
|
||||
modified = True
|
||||
self.safe_remove(entry_info, force=force)
|
||||
self.link(entry_cache_info, entry_info)
|
||||
self.state.save(entry_info, entry_checksum)
|
||||
if progress_callback:
|
||||
progress_callback(str(entry_info))
|
||||
|
||||
self._remove_redundant_files(path_info, dir_info, force)
|
||||
modified = (
|
||||
self._remove_redundant_files(path_info, dir_info, force)
|
||||
or modified
|
||||
)
|
||||
|
||||
self.state.save_link(path_info)
|
||||
self.state.save(path_info, checksum)
|
||||
|
||||
# relink is not modified, assume it as nochange
|
||||
return added, not added and modified and not relink
|
||||
|
||||
def _remove_redundant_files(self, path_info, dir_info, force):
|
||||
existing_files = set(self.walk_files(path_info))
|
||||
|
||||
needed_files = {
|
||||
path_info / entry[self.PARAM_RELPATH] for entry in dir_info
|
||||
}
|
||||
|
||||
for path in existing_files - needed_files:
|
||||
redundant_files = existing_files - needed_files
|
||||
for path in redundant_files:
|
||||
self.safe_remove(path, force)
|
||||
|
||||
return bool(redundant_files)
|
||||
|
||||
def checkout(
|
||||
self,
|
||||
path_info,
|
||||
@@ -966,12 +982,14 @@ class RemoteBASE(object):
|
||||
self.path_info, checksum, filter_info
|
||||
),
|
||||
)
|
||||
return failed
|
||||
if failed:
|
||||
raise CheckoutError([failed])
|
||||
return
|
||||
|
||||
logger.debug("Checking out '{}' with cache '{}'.", path_info, checksum)
|
||||
|
||||
self._checkout(
|
||||
path_info, checksum, force, progress_callback, relink, filter_info
|
||||
return self._checkout(
|
||||
path_info, checksum, force, progress_callback, relink, filter_info,
|
||||
)
|
||||
|
||||
def _checkout(
|
||||
@@ -985,8 +1003,9 @@ class RemoteBASE(object):
|
||||
):
|
||||
if not self.is_dir_checksum(checksum):
|
||||
return self._checkout_file(
|
||||
path_info, checksum, force, progress_callback=progress_callback
|
||||
path_info, checksum, force, progress_callback, relink
|
||||
)
|
||||
|
||||
return self._checkout_dir(
|
||||
path_info, checksum, force, progress_callback, relink, filter_info
|
||||
)
|
||||
|
||||
+39
-16
@@ -1,21 +1,31 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from dvc.compat import fspath
|
||||
from dvc.exceptions import CheckoutError
|
||||
from dvc.exceptions import CheckoutErrorSuggestGit
|
||||
from dvc.progress import Tqdm
|
||||
|
||||
from dvc.utils import relpath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _cleanup_unused_links(repo):
|
||||
def _get_unused_links(repo):
|
||||
used = [
|
||||
out.fspath
|
||||
for stage in repo.stages
|
||||
for out in stage.outs
|
||||
if out.scheme == "local"
|
||||
]
|
||||
repo.state.remove_unused_links(used)
|
||||
return repo.state.get_unused_links(used)
|
||||
|
||||
|
||||
def _fspath_dir(path, root):
|
||||
if not os.path.exists(str(path)):
|
||||
return str(path)
|
||||
|
||||
path = relpath(fspath(path), root)
|
||||
return os.path.join(path, "") if os.path.isdir(path) else path
|
||||
|
||||
|
||||
def get_all_files_numbers(pairs):
|
||||
@@ -34,9 +44,19 @@ def _checkout(
|
||||
):
|
||||
from dvc.stage import StageFileDoesNotExistError, StageFileBadNameError
|
||||
|
||||
unused = []
|
||||
stats = {
|
||||
"added": [],
|
||||
"deleted": [],
|
||||
"modified": [],
|
||||
"failed": [],
|
||||
}
|
||||
if not targets:
|
||||
targets = [None]
|
||||
_cleanup_unused_links(self)
|
||||
unused = _get_unused_links(self)
|
||||
|
||||
stats["deleted"] = [_fspath_dir(u, self.root_dir) for u in unused]
|
||||
self.state.remove_links(unused)
|
||||
|
||||
pairs = set()
|
||||
for target in targets:
|
||||
@@ -52,20 +72,23 @@ def _checkout(
|
||||
raise CheckoutErrorSuggestGit(target) from exc
|
||||
|
||||
total = get_all_files_numbers(pairs)
|
||||
if total == 0:
|
||||
logger.info("Nothing to do")
|
||||
failed = []
|
||||
with Tqdm(
|
||||
total=total, unit="file", desc="Checkout", disable=total == 0
|
||||
) as pbar:
|
||||
for stage, filter_info in pairs:
|
||||
failed.extend(
|
||||
stage.checkout(
|
||||
force=force,
|
||||
progress_callback=pbar.update_desc,
|
||||
relink=relink,
|
||||
filter_info=filter_info,
|
||||
)
|
||||
result = stage.checkout(
|
||||
force=force,
|
||||
progress_callback=pbar.update_desc,
|
||||
relink=relink,
|
||||
filter_info=filter_info,
|
||||
)
|
||||
if failed:
|
||||
raise CheckoutError(failed)
|
||||
for data in ["failed", "added", "modified"]:
|
||||
stats[data].extend(
|
||||
_fspath_dir(path, self.root_dir) for path in result[data]
|
||||
)
|
||||
|
||||
if stats.get("failed"):
|
||||
raise CheckoutError(stats["failed"], stats)
|
||||
|
||||
del stats["failed"]
|
||||
return stats
|
||||
|
||||
+4
-2
@@ -27,7 +27,9 @@ def pull(
|
||||
with_deps=with_deps,
|
||||
recursive=recursive,
|
||||
)
|
||||
self._checkout(
|
||||
stats = self._checkout(
|
||||
targets=targets, with_deps=with_deps, force=force, recursive=recursive
|
||||
)
|
||||
return processed_files_count
|
||||
|
||||
stats["downloaded"] = processed_files_count
|
||||
return stats
|
||||
|
||||
+19
-12
@@ -15,7 +15,7 @@ from voluptuous import Any, Schema, MultipleInvalid
|
||||
import dvc.dependency as dependency
|
||||
import dvc.output as output
|
||||
import dvc.prompt as prompt
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.exceptions import CheckoutError, DvcException
|
||||
from dvc.utils import dict_md5
|
||||
from dvc.utils import fix_env
|
||||
from dvc.utils import relpath
|
||||
@@ -982,18 +982,25 @@ class Stage(object):
|
||||
relink=False,
|
||||
filter_info=None,
|
||||
):
|
||||
failed_checkouts = []
|
||||
checkouts = {"failed": [], "added": [], "modified": []}
|
||||
for out in self._filter_outs(filter_info):
|
||||
failed = out.checkout(
|
||||
force=force,
|
||||
tag=self.tag,
|
||||
progress_callback=progress_callback,
|
||||
relink=relink,
|
||||
filter_info=filter_info,
|
||||
)
|
||||
if failed:
|
||||
failed_checkouts.append(failed)
|
||||
return failed_checkouts
|
||||
try:
|
||||
result = out.checkout(
|
||||
force=force,
|
||||
tag=self.tag,
|
||||
progress_callback=progress_callback,
|
||||
relink=relink,
|
||||
filter_info=filter_info,
|
||||
)
|
||||
added, modified = result or (None, None)
|
||||
if modified:
|
||||
checkouts["modified"].append(out.path_info)
|
||||
elif added:
|
||||
checkouts["added"].append(out.path_info)
|
||||
except CheckoutError as exc:
|
||||
checkouts["failed"].extend(exc.target_infos)
|
||||
|
||||
return checkouts
|
||||
|
||||
@staticmethod
|
||||
def _status(entries):
|
||||
|
||||
+9
-7
@@ -439,7 +439,7 @@ class State(object): # pylint: disable=too-many-instance-attributes
|
||||
)
|
||||
self._execute(cmd, (relative_path, self._to_sqlite(inode), mtime))
|
||||
|
||||
def remove_unused_links(self, used):
|
||||
def get_unused_links(self, used):
|
||||
"""Removes all saved links except the ones that are used.
|
||||
|
||||
Args:
|
||||
@@ -453,20 +453,22 @@ class State(object): # pylint: disable=too-many-instance-attributes
|
||||
inode = self._from_sqlite(inode)
|
||||
path = os.path.join(self.root_dir, relpath)
|
||||
|
||||
if path in used:
|
||||
continue
|
||||
|
||||
if not os.path.exists(path):
|
||||
if path in used or not os.path.exists(path):
|
||||
continue
|
||||
|
||||
actual_inode = get_inode(path)
|
||||
actual_mtime, _ = get_mtime_and_size(path, self.repo.tree)
|
||||
|
||||
if inode == actual_inode and mtime == actual_mtime:
|
||||
if (inode, mtime) == (actual_inode, actual_mtime):
|
||||
logger.debug("Removing '{}' as unused link.", path)
|
||||
remove(path)
|
||||
unused.append(relpath)
|
||||
|
||||
return unused
|
||||
|
||||
def remove_links(self, unused):
|
||||
for path in unused:
|
||||
remove(path)
|
||||
|
||||
for chunk_unused in to_chunks(
|
||||
unused, chunk_size=SQLITE_MAX_VARIABLES_NUMBER
|
||||
):
|
||||
|
||||
+239
-6
@@ -22,6 +22,8 @@ from dvc.utils import relpath
|
||||
from dvc.utils.fs import walk_files
|
||||
from dvc.utils.stage import dump_stage_file
|
||||
from dvc.utils.stage import load_stage_file
|
||||
from dvc.remote import RemoteS3
|
||||
from tests.remotes import S3
|
||||
from tests.basic_env import TestDvc
|
||||
from tests.basic_env import TestDvcGit
|
||||
from tests.func.test_repro import TestRepro
|
||||
@@ -321,7 +323,8 @@ class TestCheckoutEmptyDir(TestDvc):
|
||||
stage.outs[0].remove()
|
||||
self.assertFalse(os.path.exists(dname))
|
||||
|
||||
self.dvc.checkout(force=True)
|
||||
stats = self.dvc.checkout(force=True)
|
||||
assert stats["added"] == [dname + os.sep]
|
||||
|
||||
self.assertTrue(os.path.isdir(dname))
|
||||
self.assertEqual(len(os.listdir(dname)), 0)
|
||||
@@ -337,7 +340,8 @@ class TestCheckoutNotCachedFile(TestDvc):
|
||||
)
|
||||
self.assertTrue(stage is not None)
|
||||
|
||||
self.dvc.checkout(force=True)
|
||||
stats = self.dvc.checkout(force=True)
|
||||
assert not any(stats.values())
|
||||
|
||||
|
||||
class TestCheckoutWithDeps(TestRepro):
|
||||
@@ -421,7 +425,8 @@ class TestCheckoutRecursiveNotDirectory(TestDvc):
|
||||
ret = main(["add", self.FOO])
|
||||
self.assertEqual(0, ret)
|
||||
|
||||
self.dvc.checkout(targets=[self.FOO + ".dvc"], recursive=True)
|
||||
stats = self.dvc.checkout(targets=[self.FOO + ".dvc"], recursive=True)
|
||||
assert stats == {"added": [], "modified": [], "deleted": []}
|
||||
|
||||
|
||||
class TestCheckoutMovedCacheDirWithSymlinks(TestDvc):
|
||||
@@ -489,7 +494,8 @@ def test_checkout_relink(tmp_dir, dvc, link, link_test_func):
|
||||
dvc.unprotect("dir/data")
|
||||
assert not link_test_func("dir/data")
|
||||
|
||||
dvc.checkout(["dir.dvc"], relink=True)
|
||||
stats = dvc.checkout(["dir.dvc"], relink=True)
|
||||
assert stats == empty_checkout
|
||||
assert link_test_func("dir/data")
|
||||
|
||||
|
||||
@@ -502,7 +508,8 @@ def test_checkout_relink_protected(tmp_dir, dvc, link):
|
||||
dvc.unprotect("foo")
|
||||
assert os.access("foo", os.W_OK)
|
||||
|
||||
dvc.checkout(["foo.dvc"], relink=True)
|
||||
stats = dvc.checkout(["foo.dvc"], relink=True)
|
||||
assert stats == empty_checkout
|
||||
assert not os.access("foo", os.W_OK)
|
||||
|
||||
|
||||
@@ -513,5 +520,231 @@ def test_checkout_relink_protected(tmp_dir, dvc, link):
|
||||
def test_partial_checkout(tmp_dir, dvc, target):
|
||||
tmp_dir.dvc_gen({"dir": {"subdir": {"file": "file"}, "other": "other"}})
|
||||
shutil.rmtree("dir")
|
||||
dvc.checkout([target])
|
||||
stats = dvc.checkout([target])
|
||||
assert stats["added"] == ["dir" + os.sep]
|
||||
assert list(walk_files("dir")) == [os.path.join("dir", "subdir", "file")]
|
||||
|
||||
|
||||
empty_checkout = {"added": [], "deleted": [], "modified": []}
|
||||
|
||||
|
||||
def test_stats_on_empty_checkout(tmp_dir, dvc, scm):
|
||||
assert dvc.checkout() == empty_checkout
|
||||
tmp_dir.dvc_gen(
|
||||
{"dir": {"subdir": {"file": "file"}, "other": "other"}},
|
||||
commit="initial",
|
||||
)
|
||||
assert dvc.checkout() == empty_checkout
|
||||
|
||||
|
||||
def test_stats_on_checkout(tmp_dir, dvc, scm):
|
||||
tmp_dir.dvc_gen(
|
||||
{
|
||||
"dir": {"subdir": {"file": "file"}, "other": "other"},
|
||||
"foo": "foo",
|
||||
"bar": "bar",
|
||||
},
|
||||
commit="initial",
|
||||
)
|
||||
scm.checkout("HEAD~")
|
||||
stats = dvc.checkout()
|
||||
assert set(stats["deleted"]) == {"dir" + os.sep, "foo", "bar"}
|
||||
|
||||
scm.checkout("-")
|
||||
stats = dvc.checkout()
|
||||
assert set(stats["added"]) == {"bar", "dir" + os.sep, "foo"}
|
||||
|
||||
tmp_dir.gen({"lorem": "lorem", "bar": "new bar", "dir2": {"file": "file"}})
|
||||
(tmp_dir / "foo").unlink()
|
||||
scm.repo.git.rm("foo.dvc")
|
||||
tmp_dir.dvc_add(["bar", "lorem", "dir2"], commit="second")
|
||||
|
||||
scm.checkout("HEAD~")
|
||||
stats = dvc.checkout()
|
||||
assert set(stats["modified"]) == {"bar"}
|
||||
assert set(stats["added"]) == {"foo"}
|
||||
assert set(stats["deleted"]) == {"lorem", "dir2" + os.sep}
|
||||
|
||||
scm.checkout("-")
|
||||
stats = dvc.checkout()
|
||||
assert set(stats["modified"]) == {"bar"}
|
||||
assert set(stats["added"]) == {"dir2" + os.sep, "lorem"}
|
||||
assert set(stats["deleted"]) == {"foo"}
|
||||
|
||||
|
||||
def test_checkout_stats_on_failure(tmp_dir, dvc, scm):
|
||||
tmp_dir.dvc_gen(
|
||||
{"foo": "foo", "dir": {"subdir": {"file": "file"}}, "other": "other"},
|
||||
commit="initial",
|
||||
)
|
||||
stage = Stage.load(dvc, "foo.dvc")
|
||||
tmp_dir.dvc_gen({"foo": "foobar", "other": "other other"}, commit="second")
|
||||
|
||||
# corrupt cache
|
||||
cache = stage.outs[0].cache_path
|
||||
with open(cache, "a") as fd:
|
||||
fd.write("destroy cache")
|
||||
|
||||
scm.checkout("HEAD~")
|
||||
with pytest.raises(CheckoutError) as exc:
|
||||
dvc.checkout(force=True)
|
||||
|
||||
assert exc.value.stats == {
|
||||
**empty_checkout,
|
||||
"failed": ["foo"],
|
||||
"modified": ["other"],
|
||||
}
|
||||
|
||||
|
||||
def test_stats_on_added_file_from_tracked_dir(tmp_dir, dvc, scm):
|
||||
tmp_dir.dvc_gen(
|
||||
{"dir": {"subdir": {"file": "file"}, "other": "other"}},
|
||||
commit="initial",
|
||||
)
|
||||
|
||||
tmp_dir.gen("dir/subdir/newfile", "newfile")
|
||||
tmp_dir.dvc_add("dir", commit="add newfile")
|
||||
scm.checkout("HEAD~")
|
||||
assert dvc.checkout() == {**empty_checkout, "modified": ["dir" + os.sep]}
|
||||
assert dvc.checkout() == empty_checkout
|
||||
|
||||
scm.checkout("-")
|
||||
assert dvc.checkout() == {**empty_checkout, "modified": ["dir" + os.sep]}
|
||||
assert dvc.checkout() == empty_checkout
|
||||
|
||||
|
||||
def test_stats_on_updated_file_from_tracked_dir(tmp_dir, dvc, scm):
|
||||
tmp_dir.dvc_gen(
|
||||
{"dir": {"subdir": {"file": "file"}, "other": "other"}},
|
||||
commit="initial",
|
||||
)
|
||||
|
||||
tmp_dir.gen("dir/subdir/file", "what file?")
|
||||
tmp_dir.dvc_add("dir", commit="update file")
|
||||
scm.checkout("HEAD~")
|
||||
assert dvc.checkout() == {**empty_checkout, "modified": ["dir" + os.sep]}
|
||||
assert dvc.checkout() == empty_checkout
|
||||
|
||||
scm.checkout("-")
|
||||
assert dvc.checkout() == {**empty_checkout, "modified": ["dir" + os.sep]}
|
||||
assert dvc.checkout() == empty_checkout
|
||||
|
||||
|
||||
def test_stats_on_removed_file_from_tracked_dir(tmp_dir, dvc, scm):
|
||||
tmp_dir.dvc_gen(
|
||||
{"dir": {"subdir": {"file": "file"}, "other": "other"}},
|
||||
commit="initial",
|
||||
)
|
||||
|
||||
(tmp_dir / "dir" / "subdir" / "file").unlink()
|
||||
tmp_dir.dvc_add("dir", commit="removed file from subdir")
|
||||
scm.checkout("HEAD~")
|
||||
assert dvc.checkout() == {**empty_checkout, "modified": ["dir" + os.sep]}
|
||||
assert dvc.checkout() == empty_checkout
|
||||
|
||||
scm.checkout("-")
|
||||
assert dvc.checkout() == {**empty_checkout, "modified": ["dir" + os.sep]}
|
||||
assert dvc.checkout() == empty_checkout
|
||||
|
||||
|
||||
def test_stats_on_show_changes_does_not_show_summary(
|
||||
tmp_dir, dvc, scm, caplog
|
||||
):
|
||||
tmp_dir.dvc_gen(
|
||||
{"dir": {"subdir": {"file": "file"}}, "other": "other"},
|
||||
commit="initial",
|
||||
)
|
||||
scm.checkout("HEAD~")
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="dvc"):
|
||||
caplog.clear()
|
||||
assert main(["checkout"]) == 0
|
||||
for out in ["D\tdir" + os.sep, "D\tother"]:
|
||||
assert out in caplog.text
|
||||
assert "modified" not in caplog.text
|
||||
assert "deleted" not in caplog.text
|
||||
assert "added" not in caplog.text
|
||||
|
||||
|
||||
def test_stats_does_not_show_changes_by_default(tmp_dir, dvc, scm, caplog):
|
||||
tmp_dir.dvc_gen(
|
||||
{"dir": {"subdir": {"file": "file"}}, "other": "other"},
|
||||
commit="initial",
|
||||
)
|
||||
scm.checkout("HEAD~")
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="dvc"):
|
||||
caplog.clear()
|
||||
assert main(["checkout", "--summary"]) == 0
|
||||
assert "2 deleted" in caplog.text
|
||||
assert "dir" not in caplog.text
|
||||
assert "other" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("link", ["hardlink", "symlink", "copy"])
|
||||
def test_checkout_with_relink_existing(tmp_dir, dvc, link):
|
||||
tmp_dir.dvc_gen("foo", "foo")
|
||||
(tmp_dir / "foo").unlink()
|
||||
|
||||
tmp_dir.dvc_gen("bar", "bar")
|
||||
dvc.cache.local.cache_types = [link]
|
||||
|
||||
stats = dvc.checkout(relink=True)
|
||||
assert stats == {**empty_checkout, "added": ["foo"]}
|
||||
|
||||
|
||||
def test_checkout_with_deps(tmp_dir, dvc):
|
||||
tmp_dir.dvc_gen({"foo": "foo"})
|
||||
dvc.run(
|
||||
fname="copy_file.dvc", cmd="echo foo > bar", outs=["bar"], deps=["foo"]
|
||||
)
|
||||
|
||||
(tmp_dir / "bar").unlink()
|
||||
(tmp_dir / "foo").unlink()
|
||||
|
||||
stats = dvc.checkout(["copy_file.dvc"], with_deps=False)
|
||||
assert stats == {**empty_checkout, "added": ["bar"]}
|
||||
|
||||
(tmp_dir / "bar").unlink()
|
||||
stats = dvc.checkout(["copy_file.dvc"], with_deps=True)
|
||||
assert set(stats["added"]) == {"foo", "bar"}
|
||||
|
||||
|
||||
def test_checkout_recursive(tmp_dir, dvc):
|
||||
tmp_dir.gen({"dir": {"foo": "foo", "bar": "bar"}})
|
||||
dvc.add("dir", recursive=True)
|
||||
|
||||
(tmp_dir / "dir" / "foo").unlink()
|
||||
(tmp_dir / "dir" / "bar").unlink()
|
||||
|
||||
stats = dvc.checkout(["dir"], recursive=True)
|
||||
assert set(stats["added"]) == {
|
||||
os.path.join("dir", "foo"),
|
||||
os.path.join("dir", "bar"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not S3.should_test(), reason="Only run with S3 credentials"
|
||||
)
|
||||
def test_checkout_for_external_outputs(tmp_dir, dvc):
|
||||
dvc.cache.s3 = RemoteS3(dvc, {"url": S3.get_url()})
|
||||
|
||||
remote = RemoteS3(dvc, {"url": S3.get_url()})
|
||||
file_path = remote.path_info / "foo"
|
||||
remote.s3.put_object(
|
||||
Bucket=remote.path_info.bucket, Key=file_path.path, Body="foo"
|
||||
)
|
||||
|
||||
dvc.add(str(remote.path_info / "foo"))
|
||||
|
||||
remote.remove(file_path)
|
||||
stats = dvc.checkout(force=True)
|
||||
assert stats == {**empty_checkout, "added": [str(file_path)]}
|
||||
assert remote.exists(file_path)
|
||||
|
||||
remote.s3.put_object(
|
||||
Bucket=remote.path_info.bucket, Key=file_path.path, Body="foo\nfoo"
|
||||
)
|
||||
stats = dvc.checkout(force=True)
|
||||
assert stats == {**empty_checkout, "modified": [str(file_path)]}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import mock
|
||||
import os
|
||||
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.state import State
|
||||
@@ -69,16 +70,33 @@ def test_get_state_record_for_inode(get_inode_mock, tmp_dir, dvc):
|
||||
assert ret is not None
|
||||
|
||||
|
||||
def test_remove_unused_links(tmp_dir, dvc):
|
||||
assert len(tmp_dir.dvc_gen("foo", "foo_content")) == 1
|
||||
assert len(tmp_dir.dvc_gen("bar", "bar_content")) == 1
|
||||
def test_remove_links(tmp_dir, dvc):
|
||||
tmp_dir.dvc_gen({"foo": "foo_content", "bar": "bar_content"})
|
||||
|
||||
cmd_count_links = "SELECT count(*) FROM {}".format(State.LINK_STATE_TABLE)
|
||||
with dvc.state:
|
||||
cmd_count_links = "SELECT count(*) FROM {}".format(
|
||||
State.LINK_STATE_TABLE
|
||||
)
|
||||
result = dvc.state._execute(cmd_count_links).fetchone()[0]
|
||||
assert result == 2
|
||||
|
||||
dvc.state.remove_unused_links([])
|
||||
dvc.state.remove_links(["foo", "bar"])
|
||||
|
||||
result = dvc.state._execute(cmd_count_links).fetchone()[0]
|
||||
assert result == 0
|
||||
|
||||
|
||||
def test_get_unused_links(tmp_dir, dvc):
|
||||
tmp_dir.dvc_gen({"foo": "foo_content", "bar": "bar_content"})
|
||||
|
||||
with dvc.state:
|
||||
links = [os.path.join(dvc.root_dir, link) for link in ["foo", "bar"]]
|
||||
assert set(dvc.state.get_unused_links([])) == {"foo", "bar"}
|
||||
assert set(dvc.state.get_unused_links(links[:1])) == {"bar"}
|
||||
assert set(dvc.state.get_unused_links(links)) == set()
|
||||
assert set(
|
||||
dvc.state.get_unused_links(
|
||||
used=links[:1]
|
||||
+ [os.path.join(dvc.root_dir, "not-existing-file")]
|
||||
)
|
||||
) == {"bar"}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import logging
|
||||
|
||||
from dvc.cli import parse_args
|
||||
from dvc.command.checkout import CmdCheckout
|
||||
from dvc.command.checkout import CmdCheckout, log_summary, log_changes
|
||||
|
||||
|
||||
def test_checkout(tmp_dir, dvc, mocker):
|
||||
@@ -19,3 +21,56 @@ def test_checkout(tmp_dir, dvc, mocker):
|
||||
relink=True,
|
||||
with_deps=True,
|
||||
)
|
||||
|
||||
|
||||
def test_log_summary(caplog):
|
||||
stats = {
|
||||
"added": ["file1", "file2", "file3"],
|
||||
"deleted": ["file4", "file5"],
|
||||
"modified": ["file6", "file7"],
|
||||
}
|
||||
|
||||
def _assert_output(stats, expected_text):
|
||||
with caplog.at_level(logging.INFO, logger="dvc"):
|
||||
caplog.clear()
|
||||
log_summary(stats)
|
||||
assert expected_text in caplog.text
|
||||
|
||||
_assert_output(stats, "3 added, 2 deleted and 2 modified")
|
||||
|
||||
del stats["deleted"][1]
|
||||
_assert_output(stats, "3 added, 1 deleted and 2 modified")
|
||||
|
||||
del stats["deleted"][0]
|
||||
_assert_output(stats, "3 added and 2 modified")
|
||||
|
||||
del stats["modified"]
|
||||
_assert_output(stats, "3 added")
|
||||
|
||||
_assert_output({}, "")
|
||||
|
||||
|
||||
def test_log_changes(caplog):
|
||||
stats = {
|
||||
"added": ["file1", "dir1/"],
|
||||
"deleted": ["dir2/"],
|
||||
"modified": ["file2"],
|
||||
}
|
||||
|
||||
from itertools import zip_longest
|
||||
|
||||
def _assert_output(stats, expected_outs):
|
||||
with caplog.at_level(logging.INFO, logger="dvc"):
|
||||
caplog.clear()
|
||||
log_changes(stats)
|
||||
actual_output = caplog.text.splitlines()
|
||||
for out, line in zip_longest(expected_outs, actual_output):
|
||||
assert out in line
|
||||
|
||||
_assert_output(stats, ["M\tfile2", "A\tfile1", "A\tdir1/", "D\tdir2/"])
|
||||
|
||||
del stats["deleted"][0]
|
||||
_assert_output(stats, ["M\tfile2", "A\tfile1", "A\tdir1/"])
|
||||
|
||||
del stats["modified"]
|
||||
_assert_output(stats, ["A\tfile1", "A\tdir1/"])
|
||||
|
||||
Reference in New Issue
Block a user