Repository: drivendataorg/cloudpathlib (python)

Issue:
[GS] `download_to` with non-existent files don't raise any exception
Version: 0.18.1 

Code Example:
```py
from cloudpathlib import CloudPath

# download a non-existent file, in an existing bucket
path = CloudPath("gs://some_existent_bucket/non_existent.txt")
path.download_to(".") # should raise an exception, as the file doesn't exist

# NOTE: trying to download from a non-existing bucket will properly fail
path = CloudPath("gs://non_existent_bucket/foo.txt")
path.download_to(".") # will raise an exception
```

Pull request description:
Add exists check in download_to
Add `.exists` check before `download_to` and raise new `CloudPathNotExistsError` if the path does not exist.

Closes #430


Patch:
diff --git a/HISTORY.md b/HISTORY.md
index a21d821..046ab12 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -7,6 +7,7 @@
 - Drop support for Python 3.7; pin minimal `boto3` version to Python 3.8+ versions. (PR [#407](https://github.com/drivendataorg/cloudpathlib/pull/407))
 - fix: use native `exists()` method in `GSClient`. (PR [#420](https://github.com/drivendataorg/cloudpathlib/pull/420))
 - Enhancement: lazy instantiation of default client (PR [#432](https://github.com/drivendataorg/cloudpathlib/issues/432), Issue [#428](https://github.com/drivendataorg/cloudpathlib/issues/428))
+- Adds existence check before downloading in `download_to` (Issue [#430](https://github.com/drivendataorg/cloudpathlib/issues/430), PR [#432](https://github.com/drivendataorg/cloudpathlib/pull/432))
 
 ## v0.18.1 (2024-02-26)
 
diff --git a/cloudpathlib/cloudpath.py b/cloudpathlib/cloudpath.py
index afca5cb..8bad810 100644
--- a/cloudpathlib/cloudpath.py
+++ b/cloudpathlib/cloudpath.py
@@ -63,6 +63,7 @@ from .exceptions import (
     CloudPathFileExistsError,
     CloudPathIsADirectoryError,
     CloudPathNotADirectoryError,
+    CloudPathNotExistsError,
     CloudPathNotImplementedError,
     DirectoryNotEmptyError,
     IncompleteImplementationError,
@@ -899,6 +900,10 @@ class CloudPath(metaclass=CloudPathMeta):
     # ===========  public cloud methods, not in pathlib ===============
     def download_to(self, destination: Union[str, os.PathLike]) -> Path:
         destination = Path(destination)
+
+        if not self.exists():
+            raise CloudPathNotExistsError(f"Cannot download because path does not exist: {self}")
+
         if self.is_file():
             if destination.is_dir():
                 destination = destination / self.name
diff --git a/cloudpathlib/exceptions.py b/cloudpathlib/exceptions.py
index 802f71e..1b4499f 100644
--- a/cloudpathlib/exceptions.py
+++ b/cloudpathlib/exceptions.py
@@ -20,6 +20,10 @@ class CloudPathFileExistsError(CloudPathException, FileExistsError):
     pass
 
 
+class CloudPathNotExistsError(CloudPathException):
+    pass
+
+
 class CloudPathIsADirectoryError(CloudPathException, IsADirectoryError):
     pass
 


Test patch:
diff --git a/tests/test_cloudpath_file_io.py b/tests/test_cloudpath_file_io.py
index af8f9e6..e5546f0 100644
--- a/tests/test_cloudpath_file_io.py
+++ b/tests/test_cloudpath_file_io.py
@@ -10,6 +10,7 @@ import pytest
 from cloudpathlib import CloudPath
 
 from cloudpathlib.exceptions import (
+    CloudPathNotExistsError,
     CloudPathIsADirectoryError,
     CloudPathNotImplementedError,
     DirectoryNotEmptyError,
@@ -396,6 +397,9 @@ def test_file_read_writes(rig, tmp_path):
     )
     assert cloud_rel_paths == dled_rel_paths
 
+    with pytest.raises(CloudPathNotExistsError):
+        (p / "not_exists_file").download_to(dl_file)
+
 
 def test_dispatch_to_local_cache(rig):
     p = rig.create_cloud_path("dir_0/file0_1.txt")