Repository: iterative/dvc

Issue:
dvc: CheckoutError is not properly postponed for multiple targets
On master branch.

Checkout errors are collected and postponed in `Repo._checkout()` this means that for `dvc checkout/pull` with multiple targets error will be raised on first not fully successfully checked out target and the rest will be skipped.

Also, the error collection process is very cumbersome we might want to invent something better.



Discussion:
> Checkout errors are collected and postponed in Repo._checkout() this means that for dvc checkout/pull with multiple targets error will be raised on first not fully successfully checked out target and the rest will be skipped.

Not sure I follow. So we are postponing them so that we checkout everything we can and then fail, so how are we skipping? Could you clarify, please?
For multiple targets we call ._checkout() multiple times. Any of those
might fail preventing checkout for remaining targets.

вс, 6 окт. 2019 г., 23:54 Ruslan Kuprieiev <notifications@github.com>:

> Checkout errors are collected and postponed in Repo._checkout() this means
> that for dvc checkout/pull with multiple targets error will be raised on
> first not fully successfully checked out target and the rest will be
> skipped.
>
> Not sure I follow. So we are postponing them so that we checkout
> everything we can and then fail, so how are we skipping? Could you clarify,
> please?
>
> —
> You are receiving this because you authored the thread.
> Reply to this email directly, view it on GitHub
> <https://github.com/iterative/dvc/issues/2580?email_source=notifications&email_token=AACFLR53RAMNP55SCL6VWMTQNIJ47A5CNFSM4I5XZ6GKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEAOOUHI#issuecomment-538765853>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AACFLR36BRF3B532DZLJPWLQNIJ47ANCNFSM4I5XZ6GA>
> .
>

@Suor Ah, I see. Good point, we should've supported multiple targets in checkout a long time ago, esp since `pull` already supports it, hence this discrepancy. 

Resolving patch:
diff --git a/dvc/command/checkout.py b/dvc/command/checkout.py
--- a/dvc/command/checkout.py
+++ b/dvc/command/checkout.py
@@ -7,16 +7,12 @@
 
 class CmdCheckout(CmdBase):
     def run(self):
-        if not self.args.targets:
-            self.repo.checkout(force=self.args.force)
-        else:
-            for target in self.args.targets:
-                self.repo.checkout(
-                    target=target,
-                    with_deps=self.args.with_deps,
-                    force=self.args.force,
-                    recursive=self.args.recursive,
-                )
+        self.repo.checkout(
+            targets=self.args.targets,
+            with_deps=self.args.with_deps,
+            force=self.args.force,
+            recursive=self.args.recursive,
+        )
         return 0
 
 
diff --git a/dvc/repo/checkout.py b/dvc/repo/checkout.py
--- a/dvc/repo/checkout.py
+++ b/dvc/repo/checkout.py
@@ -24,16 +24,22 @@ def get_all_files_numbers(stages):
 
 
 def _checkout(
-    self, target=None, with_deps=False, force=False, recursive=False
+    self, targets=None, with_deps=False, force=False, recursive=False
 ):
     from dvc.stage import StageFileDoesNotExistError, StageFileBadNameError
 
-    try:
-        stages = self.collect(target, with_deps=with_deps, recursive=recursive)
-    except (StageFileDoesNotExistError, StageFileBadNameError) as exc:
-        if not target:
-            raise
-        raise CheckoutErrorSuggestGit(target, exc)
+    stages = set()
+    targets = targets or [None]
+    for target in targets:
+        try:
+            new = self.collect(
+                target, with_deps=with_deps, recursive=recursive
+            )
+            stages.update(new)
+        except (StageFileDoesNotExistError, StageFileBadNameError) as exc:
+            if not target:
+                raise
+            raise CheckoutErrorSuggestGit(target, exc)
 
     _cleanup_unused_links(self, self.stages)
     total = get_all_files_numbers(stages)
diff --git a/dvc/repo/pull.py b/dvc/repo/pull.py
--- a/dvc/repo/pull.py
+++ b/dvc/repo/pull.py
@@ -24,11 +24,7 @@ def pull(
         with_deps=with_deps,
         recursive=recursive,
     )
-    for target in targets or [None]:
-        self._checkout(
-            target=target,
-            with_deps=with_deps,
-            force=force,
-            recursive=recursive,
-        )
+    self._checkout(
+        targets=targets, with_deps=with_deps, force=force, recursive=recursive
+    )
     return processed_files_count


Test patch:
diff --git a/tests/func/test_checkout.py b/tests/func/test_checkout.py
--- a/tests/func/test_checkout.py
+++ b/tests/func/test_checkout.py
@@ -391,14 +391,14 @@ class TestCheckoutSuggestGit(TestRepro):
     def test(self):
 
         try:
-            self.dvc.checkout(target="gitbranch")
+            self.dvc.checkout(targets=["gitbranch"])
         except DvcException as exc:
             self.assertIsInstance(exc, CheckoutErrorSuggestGit)
             self.assertIsInstance(exc.cause, StageFileDoesNotExistError)
             self.assertIsNone(exc.cause.cause)
 
         try:
-            self.dvc.checkout(target=self.FOO)
+            self.dvc.checkout(targets=[self.FOO])
         except DvcException as exc:
             self.assertIsInstance(exc, CheckoutErrorSuggestGit)
             self.assertIsInstance(exc.cause, StageFileBadNameError)
@@ -422,7 +422,7 @@ def test(self):
         ret = main(["add", self.FOO])
         self.assertEqual(0, ret)
 
-        self.dvc.checkout(target=self.FOO + ".dvc", recursive=True)
+        self.dvc.checkout(targets=[self.FOO + ".dvc"], recursive=True)
 
 
 class TestCheckoutMovedCacheDirWithSymlinks(TestDvc):
@@ -477,7 +477,7 @@ def test(self):
 def test_checkout_no_checksum(repo_dir, dvc_repo):
     stage = dvc_repo.run(outs=[repo_dir.FOO], no_exec=True, cmd="somecmd")
     with pytest.raises(CheckoutError):
-        dvc_repo.checkout(stage.path, force=True)
+        dvc_repo.checkout([stage.path], force=True)
     assert not os.path.exists(repo_dir.FOO)
 
 
@@ -491,7 +491,7 @@ def test_should_relink_on_checkout(link, link_test_func, repo_dir, dvc_repo):
     dvc_repo.add(repo_dir.DATA_DIR)
     dvc_repo.unprotect(repo_dir.DATA_SUB)
 
-    dvc_repo.checkout(repo_dir.DATA_DIR + Stage.STAGE_FILE_SUFFIX)
+    dvc_repo.checkout([repo_dir.DATA_DIR + Stage.STAGE_FILE_SUFFIX])
 
     assert link_test_func(repo_dir.DATA_SUB)
 
@@ -504,7 +504,7 @@ def test_should_protect_on_checkout(link, dvc_repo, repo_dir):
     dvc_repo.add(repo_dir.FOO)
     dvc_repo.unprotect(repo_dir.FOO)
 
-    dvc_repo.checkout(repo_dir.FOO + Stage.STAGE_FILE_SUFFIX)
+    dvc_repo.checkout([repo_dir.FOO + Stage.STAGE_FILE_SUFFIX])
 
     assert not os.access(repo_dir.FOO, os.W_OK)
 
@@ -517,7 +517,7 @@ def test_should_relink_only_one_file_in_dir(dvc_repo, repo_dir):
 
     link_spy = spy(System.symlink)
     with patch.object(dvc_repo.cache.local, "symlink", link_spy):
-        dvc_repo.checkout(repo_dir.DATA_DIR + Stage.STAGE_FILE_SUFFIX)
+        dvc_repo.checkout([repo_dir.DATA_DIR + Stage.STAGE_FILE_SUFFIX])
 
     assert link_spy.mock.call_count == 1
 
@@ -529,6 +529,6 @@ def test_should_not_relink_on_unchanged_dependency(link, dvc_repo, repo_dir):
     dvc_repo.add(repo_dir.DATA_DIR)
 
     with patch.object(dvc_repo.cache.local, "link") as mock_link:
-        dvc_repo.checkout(repo_dir.DATA_DIR + Stage.STAGE_FILE_SUFFIX)
+        dvc_repo.checkout([repo_dir.DATA_DIR + Stage.STAGE_FILE_SUFFIX])
 
     assert not mock_link.called
diff --git a/tests/func/test_repro.py b/tests/func/test_repro.py
--- a/tests/func/test_repro.py
+++ b/tests/func/test_repro.py
@@ -943,7 +943,7 @@ def test(self, mock_prompt):
         self.dvc.remove(cmd_stage.path, outs_only=True)
         self.assertNotEqual(self.dvc.status([cmd_stage.path]), {})
 
-        self.dvc.checkout(cmd_stage.path, force=True)
+        self.dvc.checkout([cmd_stage.path], force=True)
         self.assertEqual(self.dvc.status([cmd_stage.path]), {})