Repository: iterative/dvc

Issue:
api.open: Write mode doesn't work with rev argument
# Bug Report

## Description

Error when using `dvc.api.open("foo", rev="HEAD", mode="w")`. See below for full error.

It's easy enough to work around this, but we should either fix it or not support write mode.

### Reproduce

In a fresh git+dvc repo, I added a `foo` file and committed:

```bash
$ echo foo > foo
$ git add foo
$ git commit -m "adds foo"
```

Then I tried to write a copy with `dvc.api.open()`:

```python
>>> with dvc.api.open("foo", mode="w") as f:
...     f.write("foo_copy")
...
8
>>> with dvc.api.open("foo", rev="HEAD", mode="w") as f:
...     f.write("foo_copy")
...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/dave/miniforge3/envs/example-get-started/lib/python3.8/contextlib.py", line 113, in __enter__
    return next(self.gen)
  File "/Users/dave/Code/dvc/dvc/api.py", line 76, in _open
    with _repo.open_by_relpath(
  File "/Users/dave/miniforge3/envs/example-get-started/lib/python3.8/contextlib.py", line 113, in __enter__
    return next(self.gen)
  File "/Users/dave/Code/dvc/dvc/repo/__init__.py", line 499, in open_by_relpath
    with fs.open(
  File "/Users/dave/Code/dvc/dvc/fs/repo.py", line 228, in open
    return fs.open(path, mode=mode, encoding=encoding)
  File "/Users/dave/Code/dvc/dvc/fs/fsspec_wrapper.py", line 80, in open
    return self.fs.open(path, mode=mode)
  File "/Users/dave/miniforge3/envs/example-get-started/lib/python3.8/site-packages/fsspec/spec.py", line 997, in open
    self.open(
  File "/Users/dave/miniforge3/envs/example-get-started/lib/python3.8/site-packages/fsspec/spec.py", line 1009, in open
    f = self._open(
  File "/Users/dave/miniforge3/envs/example-get-started/lib/python3.8/site-packages/scmrepo/fs.py", line 84, in _open
    raise NotImplementedError
NotImplementedError
```

### Expected

Being able to write a file from a specified revision.

### Environment information

**Output of `dvc doctor`:**

```console
$ dvc doctor
DVC version: 2.9.4.dev58+g47481219
---------------------------------
Platform: Python 3.8.5 on macOS-10.16-x86_64-i386-64bit
Supports:
        gdrive (pydrive2 = 1.7.3),
        hdfs (fsspec = 2022.1.0, pyarrow = 3.0.0),
        webhdfs (fsspec = 2022.1.0),
        http (aiohttp = 3.7.3, aiohttp-retry = 2.4.6),
        https (aiohttp = 3.7.3, aiohttp-retry = 2.4.6),
        ssh (sshfs = 2021.11.2)
Cache types: <https://error.dvc.org/no-dvc-cache>
Caches: local
Remotes: local
Workspace directory: apfs on /dev/disk3s1s1
Repo: dvc, git
```


Comments:
It seems like write mode should just not be supported at all here. If you are writing to a file associated with a specific git revision, wouldn't this require rewriting git history?

Actually, is write mode even something that should be supported in `api.open` at all (with or without rev)?
If anything this sounds more like a `dvcx` scenario where a write actually requires
- modify the file
- `dvc commit` the changes if it's a DVC tracked file
- `git add + git commit` the changes to relevant files
Yeah, it shouldn't be supported. Going through too many issues 😄 . It would be nice to have a better error message and maybe document when write mode isn't supported.

Patch:
diff --git a/dvc/api.py b/dvc/api.py
--- a/dvc/api.py
+++ b/dvc/api.py
@@ -1,5 +1,6 @@
 import os
 from contextlib import _GeneratorContextManager as GCM
+from typing import Optional
 
 from funcy import reraise
 
@@ -33,21 +34,155 @@ def get_url(path, repo=None, rev=None, remote=None):
         return dvc_repo.cloud.get_url_for(remote, checksum=md5)
 
 
+class _OpenContextManager(GCM):
+    def __init__(
+        self, func, args, kwds
+    ):  # pylint: disable=super-init-not-called
+        self.gen = func(*args, **kwds)
+        self.func, self.args, self.kwds = func, args, kwds
+
+    def __getattr__(self, name):
+        raise AttributeError(
+            "dvc.api.open() should be used in a with statement."
+        )
+
+
 def open(  # noqa, pylint: disable=redefined-builtin
-    path, repo=None, rev=None, remote=None, mode="r", encoding=None
+    path: str,
+    repo: Optional[str] = None,
+    rev: Optional[str] = None,
+    remote: Optional[str] = None,
+    mode: str = "r",
+    encoding: Optional[str] = None,
 ):
     """
-    Open file in the supplied path tracked in a repo (both DVC projects and
-    plain Git repos are supported). For Git repos, HEAD is used unless a rev
-    argument is supplied. The default remote is tried unless a remote argument
-    is supplied. It may only be used as a context manager:
-
-        with dvc.api.open(
-                'path/to/file',
-                repo='https://example.com/url/to/repo'
-                ) as fd:
-            # ... Handle file object fd
+    Opens a file tracked in a DVC project.
+
+    This function may only be used as a context manager (using the `with`
+    keyword, as shown in the examples).
+
+    This function makes a direct connection to the remote storage, so the file
+    contents can be streamed. Your code can process the data buffer as it's
+    streamed, which optimizes memory usage.
+
+    Note:
+        Use dvc.api.read() to load the complete file contents
+        in a single function call, no context manager involved.
+        Neither function utilizes disc space.
+
+    Args:
+        path (str): location and file name of the target to open,
+        relative to the root of `repo`.
+        repo (str, optional): location of the DVC project or Git Repo.
+            Defaults to the current DVC project (found by walking up from the
+            current working directory tree).
+            It can be a URL or a file system path.
+            Both HTTP and SSH protocols are supported for online Git repos
+            (e.g. [user@]server:project.git).
+        rev (str, optional): Any `Git revision`_ such as a branch or tag name,
+            a commit hash or a dvc experiment name.
+            Defaults to HEAD.
+            If `repo` is not a Git repo, this option is ignored.
+        remote (str, optional): Name of the `DVC remote`_ used to form the
+            returned URL string.
+            Defaults to the `default remote`_ of `repo`.
+            For local projects, the cache is tried before the default remote.
+        mode (str, optional): Specifies the mode in which the file is opened.
+            Defaults to "r" (read).
+            Mirrors the namesake parameter in builtin `open()`_.
+            Only reading `mode` is supported.
+        encoding(str, optional): `Codec`_ used to decode the file contents.
+            Defaults to None.
+            This should only be used in text mode.
+            Mirrors the namesake parameter in builtin `open()`_.
+
+    Returns:
+        _OpenContextManager: A context manager that generatse a corresponding
+            `file object`_.
+            The exact type of file object depends on the mode used.
+            For more details, please refer to Python's `open()`_ built-in,
+            which is used under the hood.
+
+    Raises:
+        AttributeError: If this method is not used as a context manager.
+        ValueError: If non-read `mode` is used.
+
+    Examples:
+
+        - Use data or models from a DVC repository.
+
+        Any file tracked in a DVC project (and stored remotely) can be
+        processed directly in your Python code with this API.
+        For example, an XML file tracked in a public DVC repo on GitHub can be
+        processed like this:
+
+        >>> from xml.sax import parse
+        >>> import dvc.api
+        >>> from mymodule import mySAXHandler
+
+        >>> with dvc.api.open(
+        ...     'get-started/data.xml',
+        ...     repo='https://github.com/iterative/dataset-registry'
+        ... ) as fd:
+        ...     parse(fd, mySAXHandler)
+
+        We use a SAX XML parser here because dvc.api.open() is able to stream
+        the data from remote storage.
+        The mySAXHandler object should handle the event-driven parsing of the
+        document in this case.
+        This increases the performance of the code (minimizing memory usage),
+        and is typically faster than loading the whole data into memory.
+
+        - Accessing private repos
+
+        This is just a matter of using the right repo argument, for example an
+        SSH URL (requires that the credentials are configured locally):
+
+        >>> import dvc.api
+
+        >>> with dvc.api.open(
+        ...     'features.dat',
+        ...     repo='git@server.com:path/to/repo.git'
+        ... ) as fd:
+        ...     # ... Process 'features'
+        ...     pass
+
+        - Use different versions of data
+
+        Any git revision (see `rev`) can be accessed programmatically.
+        For example, if your DVC repo has tagged releases of a CSV dataset:
+
+        >>> import csv
+        >>> import dvc.api
+        >>> with dvc.api.open(
+        ...     'clean.csv',
+        ...     rev='v1.1.0'
+        ... ) as fd:
+        ...     reader = csv.reader(fd)
+        ...     # ... Process 'clean' data from version 1.1.0
+
+    .. _Git revision:
+        https://git-scm.com/docs/revisions
+
+    .. _DVC remote:
+        https://dvc.org/doc/command-reference/remote
+
+    .. _default remote:
+        https://dvc.org/doc/command-reference/remote/default
+
+    .. _open():
+        https://docs.python.org/3/library/functions.html#open
+
+    .. _Codec:
+        https://docs.python.org/3/library/codecs.html#standard-encodings
+
+    .. _file object:
+        https://docs.python.org/3/glossary.html#term-file-object
+
     """
+    if "r" not in mode:
+        raise ValueError("Only reading `mode` is supported.")
+
     args = (path,)
     kwargs = {
         "repo": repo,
@@ -59,19 +194,6 @@ def open(  # noqa, pylint: disable=redefined-builtin
     return _OpenContextManager(_open, args, kwargs)
 
 
-class _OpenContextManager(GCM):
-    def __init__(
-        self, func, args, kwds
-    ):  # pylint: disable=super-init-not-called
-        self.gen = func(*args, **kwds)
-        self.func, self.args, self.kwds = func, args, kwds
-
-    def __getattr__(self, name):
-        raise AttributeError(
-            "dvc.api.open() should be used in a with statement."
-        )
-
-
 def _open(path, repo=None, rev=None, remote=None, mode="r", encoding=None):
     with Repo.open(repo, rev=rev, subrepos=True, uninitialized=True) as _repo:
         with _repo.open_by_relpath(


Test patch:
diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/test_api.py
@@ -0,0 +1,21 @@
+import pytest
+
+from dvc import api
+
+
+def test_open_raises_error_if_no_context(tmp_dir, dvc):
+    tmp_dir.dvc_gen("foo", "foo-text")
+
+    with pytest.raises(
+        AttributeError, match="should be used in a with statement."
+    ):
+        fd = api.open("foo")
+        fd.read()
+
+
+def test_open_rev_raises_error_on_wrong_mode(tmp_dir, dvc):
+    tmp_dir.dvc_gen("foo", "foo-text")
+
+    with pytest.raises(ValueError, match="Only reading `mode` is supported."):
+        with api.open("foo", mode="w"):
+            pass