Repository: iterative/dvc

Issue:
dvc get/import shows temporary paths
```
$ dvc -V
0.62.1
$ dvc get https://github.com/iterative/example-get-started data/data.xml
Multi-Threaded:
 79%|███████▉  |../../../../private/var/folders/hq/h82mgrhx37nc89_1rcg6gm30081024/37916850 [00:08<00:03,    2.25MB/s]

$ dvc get https://github.com/iterative/example-get-started model.pkl
Multi-Threaded:
 62%|██████▏   |../../../../private/var/folders/hq/h82mgrhx37nc89_1rcg6gm283866624/6262877 [00:00<00:02 ...
```

Is there a reason to show temporary paths and what actions we can make on this? I'd expect to see just a file name like `model.pkl`?


Discussion:
For the record: we can either `chdir()` into the temporary repo root or to make a global variable that we can adjust that will make `PathInfo.str()` use it for `relpath` (Kudos @Suor).
This is not only about `dvc get`. Import are also affected, see #2691
An addition about global variable, which might be used as base for `PathInfo.__str__()`, it should be:
- really a thread local, not a simple variable
- not accessed nor changed outside `dvc/path_info.py` directly
- managed with a context manager:
```python
with path_info_base(some_dir):
    # ... path_infos are stringified differently here
```

This will protect us from usual global var issues making it a dynamic scope variable if you know what I mean ;)
From discord:

---
@kurianbenoy:
I am interested to fix that issue, even if it takes more time to understand the codebase
 
@efiop:
Glad to hear that! :slight_smile: Got it. Okay, let's think about some clever ways to fix that then.
ok, so we are using named cache for that. We could make it check if we are inside of a repo or not and if not then print a relpath computed relative to the repo.root_dir instead of cwd.
the piece of the code that is responsible for that is https://github.com/iterative/dvc/blob/0.66.4/dvc/output/base.py#L410

specifically `str(self)` that is passed as a name
so if we could do something like:
```python
if os.getcwd().startswith(self.stage.repo.root_dir):
    name = str(self)
else:
    name = relpath(self.path_info, self.stage.repo.root_dir)
```
and then use that `name` instead of `str(self)` in that line, then it would automatically start working in an acceptable way.

@Suor: Some outs might be outside repo dir, but still "in repo", referencing them simply by name would be confusing
So the core culprit is stringifying `PathInfo` objects, which don't know about repo and so can't show sane paths, stringifying output objects is, however, ok. So:
- it's ok to use `str(self)` where @efiop mentioned
- `OutputLocal.__str__()` should be fixed though
- the code Ruslan showed above is almost right, the issue is that a dvc file might refer outside repo dir with a rel path

I propose this logic for `OutputLocal.__str__()`:
```python
if not self.is_in_repo:
    return self.def_path
elif <curdir isin self.repo.root_dir>:
    return relpath(self.path_info, curdir)
else:
    return relpath(self.path_info, self.repo.root_dir)
```  
Which means we show relative paths for the repo we are in and rel to repo root paths for all other repos. Remotes and absolute paths are always shown as is.

On `<curdir isin ...>` this not quire `.startswith()` as that is wrong for paths:
```python
"a/b/c" isin "a/b" == True
"a/bc" isin "a/b" == False
```
This would fix the issue and we won't luckily need chdirs nor a global var.

**P.S.** As a second cleanup/refactor stage I would propose completely stop using `PathInfo.__str__()`, since it fundamentally doesn't know how to stringify itself. Raise `TypeError()` there and fix all the usages. This is definitely separate, don't think about it until the issue itself is resolved. 

Resolving patch:
diff --git a/dvc/output/local.py b/dvc/output/local.py
--- a/dvc/output/local.py
+++ b/dvc/output/local.py
@@ -7,9 +7,11 @@
 from dvc.istextfile import istextfile
 from dvc.output.base import OutputBase
 from dvc.remote.local import RemoteLOCAL
+from dvc.utils import relpath
 from dvc.utils.compat import fspath_py35
 from dvc.utils.compat import str
 from dvc.utils.compat import urlparse
+from dvc.utils.fs import path_isin
 
 
 logger = logging.getLogger(__name__)
@@ -38,7 +40,14 @@ def _parse_path(self, remote, path):
         return self.REMOTE.path_cls(abs_p)
 
     def __str__(self):
-        return str(self.path_info)
+        if not self.is_in_repo:
+            return str(self.def_path)
+
+        cur_dir = os.getcwd()
+        if path_isin(cur_dir, self.repo.root_dir):
+            return relpath(self.path_info, cur_dir)
+
+        return relpath(self.path_info, self.repo.root_dir)
 
     @property
     def fspath(self):
diff --git a/dvc/utils/fs.py b/dvc/utils/fs.py
--- a/dvc/utils/fs.py
+++ b/dvc/utils/fs.py
@@ -134,3 +134,14 @@ def remove(path):
     except OSError as exc:
         if exc.errno != errno.ENOENT:
             raise
+
+
+def path_isin(child, parent):
+    """Check if given `child` path is inside `parent`."""
+
+    def normalize_path(path):
+        return os.path.normpath(fspath_py35(path))
+
+    parent = os.path.join(normalize_path(parent), "")
+    child = normalize_path(child)
+    return child != parent and child.startswith(parent)


Test patch:
diff --git a/tests/unit/output/test_local.py b/tests/unit/output/test_local.py
--- a/tests/unit/output/test_local.py
+++ b/tests/unit/output/test_local.py
@@ -1,8 +1,10 @@
-from mock import patch
+import os
 
+from mock import patch
 from dvc.output import OutputLOCAL
 from dvc.remote.local import RemoteLOCAL
 from dvc.stage import Stage
+from dvc.utils import relpath
 from tests.basic_env import TestDvc
 
 
@@ -21,6 +23,34 @@ def test_save_missing(self):
                 o.save()
 
 
+def test_str_workdir_outside_repo(erepo):
+    stage = Stage(erepo.dvc)
+    output = OutputLOCAL(stage, "path", cache=False)
+
+    assert relpath("path", erepo.dvc.root_dir) == str(output)
+
+
+def test_str_workdir_inside_repo(dvc_repo):
+    stage = Stage(dvc_repo)
+    output = OutputLOCAL(stage, "path", cache=False)
+
+    assert "path" == str(output)
+
+    stage = Stage(dvc_repo, wdir="some_folder")
+    output = OutputLOCAL(stage, "path", cache=False)
+
+    assert os.path.join("some_folder", "path") == str(output)
+
+
+def test_str_on_absolute_path(dvc_repo):
+    stage = Stage(dvc_repo)
+
+    path = os.path.abspath(os.path.join("path", "to", "file"))
+    output = OutputLOCAL(stage, path, cache=False)
+
+    assert path == str(output)
+
+
 class TestGetFilesNumber(TestDvc):
     def _get_output(self):
         stage = Stage(self.dvc)
diff --git a/tests/unit/utils/test_fs.py b/tests/unit/utils/test_fs.py
--- a/tests/unit/utils/test_fs.py
+++ b/tests/unit/utils/test_fs.py
@@ -16,7 +16,7 @@
 from dvc.utils.fs import get_inode
 from dvc.utils.fs import get_mtime_and_size
 from dvc.utils.fs import move
-from dvc.utils.fs import remove
+from dvc.utils.fs import path_isin, remove
 from tests.basic_env import TestDir
 from tests.utils import spy
 
@@ -164,3 +164,44 @@ def test_remove(repo_dir):
 
     remove(path_info)
     assert not os.path.isfile(path_info.fspath)
+
+
+def test_path_isin_positive():
+    child = os.path.join("path", "to", "folder")
+
+    assert path_isin(child, os.path.join("path", "to", ""))
+    assert path_isin(child, os.path.join("path", "to"))
+    assert path_isin(child, os.path.join("path", ""))
+    assert path_isin(child, os.path.join("path"))
+
+
+def test_path_isin_on_same_path():
+    path = os.path.join("path", "to", "folder")
+    path_with_sep = os.path.join(path, "")
+
+    assert not path_isin(path, path)
+    assert not path_isin(path, path_with_sep)
+    assert not path_isin(path_with_sep, path)
+    assert not path_isin(path_with_sep, path_with_sep)
+
+
+def test_path_isin_on_common_substring_path():
+    path1 = os.path.join("path", "to", "folder1")
+    path2 = os.path.join("path", "to", "folder")
+
+    assert not path_isin(path1, path2)
+
+
+def test_path_isin_accepts_pathinfo():
+    child = os.path.join("path", "to", "folder")
+    parent = PathInfo(child) / ".."
+
+    assert path_isin(child, parent)
+    assert not path_isin(parent, child)
+
+
+def test_path_isin_with_absolute_path():
+    parent = os.path.abspath("path")
+    child = os.path.join(parent, "to", "folder")
+
+    assert path_isin(child, parent)