Repository: iterative/dvc

Issue:
cache: fails for stage with wdir having non-cached outputs
```sh
$ mkdir data
$ echo "hello world > data/foo
$ dvc run -n copy-foo-bar -d foo --wdir data -O bar "cp foo bar"
ERROR: unexpected error - [Errno 2] No such file or directory: '/home/saugat/dvc_try/test_multistage/bar
```

Also, fails when tried to run from separate directory than where `.dvc/.yaml` is.

A quick patch can be having `wdir` in run-cache:
```diff
diff --git a/dvc/stage/cache.py b/dvc/stage/cache.py
index 7a9bd216..f52a8ce2 100644
--- a/dvc/stage/cache.py
+++ b/dvc/stage/cache.py
@@ -85,6 +85,7 @@ class StageCache:
             repo=self.repo,
             path="dvc.yaml",
             cmd=cache["cmd"],
+            wdir=os.path.join(self.repo.root_dir, cache["wdir"]),
             deps=[dep["path"] for dep in cache["deps"]],
             outs=[out["path"] for out in cache["outs"]],
         )
@@ -122,6 +123,7 @@ class StageCache:
             return

         cache = to_single_stage_lockfile(stage)
+        cache["wdir"] = relpath(stage.wdir, stage.repo.root_dir)
         cache_value = _get_cache_hash(cache)

         existing_cache = self._load_cache(cache_key, cache_value)
@@ -134,7 +136,7 @@ class StageCache:
             return

         # sanity check
-        COMPILED_LOCK_FILE_STAGE_SCHEMA(cache)
+        # COMPILED_LOCK_FILE_STAGE_SCHEMA(cache)
+        # fix schema

         path = self._get_cache_path(cache_key, cache_value)
         dpath = os.path.dirname(path)
```



Discussion:

Patch:
diff --git a/dvc/stage/cache.py b/dvc/stage/cache.py
index 63c326fab..f126d2060 100644
--- a/dvc/stage/cache.py
+++ b/dvc/stage/cache.py
@@ -77,7 +77,7 @@ class StageCache:
 
         return None
 
-    def _create_stage(self, cache):
+    def _create_stage(self, cache, wdir=None):
         from dvc.stage import create_stage, PipelineStage
 
         params = []
@@ -98,6 +98,7 @@ class StageCache:
             repo=self.repo,
             path="dvc.yaml",
             cmd=cache["cmd"],
+            wdir=wdir,
             params=params,
             deps=[dep["path"] for dep in cache.get("deps", [])],
             outs=[out["path"] for out in cache["outs"]],
@@ -118,7 +119,7 @@ class StageCache:
         # NOTE: using temporary stage to avoid accidentally modifying original
         # stage and to workaround `commit/checkout` not working for uncached
         # outputs.
-        cached_stage = self._create_stage(cache)
+        cached_stage = self._create_stage(cache, wdir=stage.wdir)
 
         outs_no_cache = [
             out.def_path for out in stage.outs if not out.use_cache


Test patch:
diff --git a/tests/unit/stage/test_cache.py b/tests/unit/stage/test_cache.py
index 7b923c630..8a92020fc 100644
--- a/tests/unit/stage/test_cache.py
+++ b/tests/unit/stage/test_cache.py
@@ -104,3 +104,57 @@ def test_stage_cache_params(tmp_dir, dvc, run_copy, mocker):
     assert (tmp_dir / "out_no_cache").exists()
     assert (tmp_dir / "out").read_text() == "out"
     assert (tmp_dir / "out_no_cache").read_text() == "out_no_cache"
+
+
+def test_stage_cache_wdir(tmp_dir, dvc, run_copy, mocker):
+    tmp_dir.gen("dep", "dep")
+    tmp_dir.gen(
+        "script.py",
+        (
+            'open("out", "w+").write("out"); '
+            'open("out_no_cache", "w+").write("out_no_cache")'
+        ),
+    )
+    tmp_dir.gen({"wdir": {}})
+    stage = dvc.run(
+        cmd="python ../script.py",
+        deps=["../script.py", "../dep"],
+        outs=["out"],
+        outs_no_cache=["out_no_cache"],
+        single_stage=True,
+        wdir="wdir",
+    )
+
+    with dvc.lock, dvc.state:
+        stage.remove(remove_outs=True, force=True)
+
+    assert not (tmp_dir / "wdir" / "out").exists()
+    assert not (tmp_dir / "wdir" / "out_no_cache").exists()
+    assert not (tmp_dir / "wdir" / "out.dvc").exists()
+
+    cache_dir = os.path.join(
+        dvc.stage_cache.cache_dir,
+        "d2",
+        "d2b5da199f4da73a861027f5f76020a948794011db9704814fdb2a488ca93ec2",
+    )
+    cache_file = os.path.join(
+        cache_dir,
+        "65cc63ade5ab338541726b26185ebaf42331141ec3a670a7d6e8a227505afade",
+    )
+
+    assert os.path.isdir(cache_dir)
+    assert os.listdir(cache_dir) == [os.path.basename(cache_file)]
+    assert os.path.isfile(cache_file)
+
+    run_spy = mocker.patch("dvc.stage.run.cmd_run")
+    checkout_spy = mocker.spy(stage, "checkout")
+    with dvc.lock, dvc.state:
+        stage.run()
+
+    assert not run_spy.called
+    assert checkout_spy.call_count == 1
+
+    assert (tmp_dir / "wdir" / "out").exists()
+    assert (tmp_dir / "wdir" / "out_no_cache").exists()
+    assert (tmp_dir / "wdir" / "out").read_text() == "out"
+    assert (tmp_dir / "wdir" / "out_no_cache").read_text() == "out_no_cache"