Repository: iterative/dvc

Issue:
dvc.api.open unexpectedly fails on files absent from a dvc tracked directory
### Bug Report
`dvc.api.open` (from the Python API) unexpectedly fails with an `AssertionError` when trying to open a file that should be contained in a dvc tracked directory but that misses in cache.

### Setup
```console
$ dvc version
DVC version: 1.8.0 (pip)
---------------------------------
Platform: Python 3.6.10 on Darwin-19.6.0-x86_64-i386-64bit
Supports: http, https, s3, ssh
Cache types: reflink, hardlink, symlink
Cache directory: apfs on /dev/disk1s5
Workspace directory: apfs on /dev/disk1s5
Repo: dvc, git
```

### Steps to reproduce
- Within an initialised and configured (especially the remotes) `git` and `dvc` repository; create a `test` directory containing two file `test/foo` and `test/bar`;
- `dvc add test && dvc push && git commit -m "test" && git push`
- Pull a **fresh copy** of the repository and then run:
```python3
from dvc.api import open

with open('test/foo') as file:
    pass
```
which results in the following uncaught exception:
```python3
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
<ipython-input-x-xxxxxxxxxxxx> in <module>
----> 1 with open('test/foo') as file:
      2     pass
      3

.../python3.6/contextlib.py in __enter__(self)
     79     def __enter__(self):
     80         try:
---> 81             return next(self.gen)
     82         except StopIteration:
     83             raise RuntimeError("generator didn't yield") from None

.../python3.6/site-packages/dvc/api.py in _open(path, repo, rev, remote, mode, encoding)
     82     with _make_repo(repo, rev=rev) as _repo:
     83         with _repo.open_by_relpath(
---> 84             path, remote=remote, mode=mode, encoding=encoding
     85         ) as fd:
     86             yield fd

.../python3.6/contextlib.py in __enter__(self)
     79     def __enter__(self):
     80         try:
---> 81             return next(self.gen)
     82         except StopIteration:
     83             raise RuntimeError("generator didn't yield") from None

.../python3.6/site-packages/dvc/repo/__init__.py in open_by_relpath(self, path, remote, mode, encoding)
    616             with self.state:
    617                 with tree.open(
--> 618                     path, mode=mode, encoding=encoding, remote=remote,
    619                 ) as fobj:
    620                     yield fobj

.../python3.6/site-packages/dvc/tree/repo.py in open(self, path, mode, encoding, **kwargs)
    157                 raise
    158
--> 159         return dvc_tree.open(path, mode=mode, encoding=encoding, **kwargs)
    160
    161     def exists(

.../python3.6/site-packages/dvc/tree/dvc.py in open(self, path, mode, encoding, remote)
     80             if self.stream:
     81                 if out.is_dir_checksum:
---> 82                     checksum = self._get_granular_checksum(path, out)
     83                 else:
     84                     checksum = out.hash_info.value

.../python3.6/site-packages/dvc/tree/dvc.py in _get_granular_checksum(self, path, out, remote)
     47
     48     def _get_granular_checksum(self, path, out, remote=None):
---> 49         assert isinstance(path, PathInfo)
     50         if not self.fetch and not self.stream:
     51             raise FileNotFoundError

AssertionError:
```
Maybe this is an expected failure, but this bare `AssertionError` suggests it's not.


Discussion:
@hugo-ricateau-tiime, thanks for the bug report. This is not expected, looks like we failed to wrap arguments somewhere (for `PathInfo`). I'll take a look. 

Resolving patch:
diff --git a/dvc/repo/__init__.py b/dvc/repo/__init__.py
--- a/dvc/repo/__init__.py
+++ b/dvc/repo/__init__.py
@@ -611,7 +611,7 @@ def open_by_relpath(self, path, remote=None, mode="r", encoding=None):
         """Opens a specified resource as a file descriptor"""
 
         tree = RepoTree(self, stream=True, subrepos=True)
-        path = os.path.join(self.root_dir, path)
+        path = PathInfo(self.root_dir) / path
         try:
             with self.state:
                 with tree.open(
diff --git a/dvc/tree/dvc.py b/dvc/tree/dvc.py
--- a/dvc/tree/dvc.py
+++ b/dvc/tree/dvc.py
@@ -1,5 +1,6 @@
 import logging
 import os
+import typing
 
 from dvc.exceptions import OutputNotFoundError
 from dvc.hash_info import HashInfo
@@ -8,6 +9,10 @@
 from ._metadata import Metadata
 from .base import BaseTree, RemoteActionNotImplemented
 
+if typing.TYPE_CHECKING:
+    from dvc.output.base import BaseOutput
+
+
 logger = logging.getLogger(__name__)
 
 
@@ -45,8 +50,10 @@ def _is_cached(out):
 
         return outs
 
-    def _get_granular_checksum(self, path, out, remote=None):
-        assert isinstance(path, PathInfo)
+    def _get_granular_checksum(
+        self, path_info: PathInfo, out: "BaseOutput", remote=None
+    ):
+        assert isinstance(path_info, PathInfo)
         if not self.fetch and not self.stream:
             raise FileNotFoundError
         dir_cache = out.get_dir_cache(remote=remote)
@@ -54,12 +61,12 @@ def _get_granular_checksum(self, path, out, remote=None):
             entry_relpath = entry[out.tree.PARAM_RELPATH]
             if os.name == "nt":
                 entry_relpath = entry_relpath.replace("/", os.sep)
-            if path == out.path_info / entry_relpath:
+            if path_info == out.path_info / entry_relpath:
                 return entry[out.tree.PARAM_CHECKSUM]
         raise FileNotFoundError
 
     def open(
-        self, path, mode="r", encoding="utf-8", remote=None
+        self, path: PathInfo, mode="r", encoding="utf-8", remote=None
     ):  # pylint: disable=arguments-differ
         try:
             outs = self._find_outs(path, strict=False)
diff --git a/dvc/tree/repo.py b/dvc/tree/repo.py
--- a/dvc/tree/repo.py
+++ b/dvc/tree/repo.py
@@ -150,13 +150,14 @@ def open(
             encoding = None
 
         tree, dvc_tree = self._get_tree_pair(path)
+        path_info = PathInfo(path)
         try:
-            return tree.open(path, mode=mode, encoding=encoding)
+            return tree.open(path_info, mode=mode, encoding=encoding)
         except FileNotFoundError:
             if not dvc_tree:
                 raise
 
-        return dvc_tree.open(path, mode=mode, encoding=encoding, **kwargs)
+        return dvc_tree.open(path_info, mode=mode, encoding=encoding, **kwargs)
 
     def exists(
         self, path, use_dvcignore=True


Test patch:
diff --git a/tests/func/test_api.py b/tests/func/test_api.py
--- a/tests/func/test_api.py
+++ b/tests/func/test_api.py
@@ -2,6 +2,7 @@
 import platform
 
 import pytest
+from funcy import first, get_in
 
 from dvc import api
 from dvc.exceptions import DvcException, FileMissingError, OutputNotFoundError
@@ -176,6 +177,31 @@ def test_open_not_cached(dvc):
         api.read(metric_file)
 
 
+@pytest.mark.parametrize("as_external", [True, False])
+@pytest.mark.parametrize("remote", [pytest.lazy_fixture("ssh")], indirect=True)
+@pytest.mark.parametrize(
+    "files, to_read",
+    [
+        ({"foo": "foo"}, "foo"),
+        ({"dir": {"foo": "foo", "bar": "bar"}}, os.path.join("dir", "foo")),
+    ],
+    ids=["file", "inside-dir"],
+)
+def test_api_missing_local_cache_exists_on_remote(
+    tmp_dir, scm, dvc, as_external, remote, files, to_read,
+):
+    tmp_dir.dvc_gen(files, commit="DVC track files")
+    dvc.push()
+
+    # Remove cache to make foo missing
+    remove(dvc.cache.local.cache_dir)
+    remove(first(files))
+
+    repo_url = f"file://{tmp_dir}" if as_external else None
+    file_content = get_in(files, to_read.split(os.sep))
+    assert api.read(to_read, repo=repo_url) == file_content
+
+
 @pytest.mark.parametrize("local_repo", [False, True])
 def test_read_with_subrepos(tmp_dir, scm, local_cloud, local_repo):
     tmp_dir.scm_gen("foo.txt", "foo.txt", commit="add foo.txt")