diff --git a/HISTORY.md b/HISTORY.md index 37945a78..0164f210 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,8 +1,12 @@ # cloudpathlib Changelog -## v0.4.1 (unreleased) +## v0.4.1 (2021-05-29) - Added support for custom S3-compatible object stores. This functionality is available via the `endpoint_url` keyword argument when instantiating an `S3Client` instance. See [documentation](https://cloudpathlib.drivendata.org/authentication/#accessing-custom-s3-compatible-object-stores) for more details. ([#138](https://github.com/drivendataorg/cloudpathlib/pull/138) thanks to [@YevheniiSemendiak](https://github.com/YevheniiSemendiak)) +- Added `CloudPath.upload_from` which uploads the passed path to this CloudPath (issuse [#58](https://github.com/drivendataorg/cloudpathlib/issues/58)) +- Added support for common file transfer functions based on `shutil`. Issue [#108](https://github.com/drivendataorg/cloudpathlib/issues/108). PR [#142](https://github.com/drivendataorg/cloudpathlib/pull/142). + - `CloudPath.copy` copy a file from one location to another. Can be cloud -> local or cloud -> cloud. If `client` is not the same, the file transits through the local machine. + - `CloudPath.copytree` reucrsively copy a directory from one location to another. Can be cloud -> local or cloud -> cloud. Uses `CloudPath.copy` so if `client` is not the same, the file transits through the local machine. ## v0.4.0 (2021-03-13) diff --git a/README.md b/README.md index d436904c..42132cf4 100644 --- a/README.md +++ b/README.md @@ -179,11 +179,14 @@ Most methods and properties from `pathlib.Path` are supported except for the one | `symlink_to` | ❌ | ❌ | ❌ | | `with_stem` | ❌ | ❌ | ❌ | | `cloud_prefix` | ✅ | ✅ | ✅ | +| `copy` | ✅ | ✅ | ✅ | +| `copytree` | ✅ | ✅ | ✅ | | `download_to` | ✅ | ✅ | ✅ | | `etag` | ✅ | ✅ | ✅ | | `fspath` | ✅ | ✅ | ✅ | | `is_valid_cloudpath` | ✅ | ✅ | ✅ | | `rmtree` | ✅ | ✅ | ✅ | +| `upload_from` | ✅ | ✅ | ✅ | | `blob` | ✅ | ❌ | ✅ | | `bucket` | ❌ | ✅ | ✅ | | `container` | ✅ | ❌ | ❌ | diff --git a/cloudpathlib/anypath.py b/cloudpathlib/anypath.py index a3f967d4..de56f330 100644 --- a/cloudpathlib/anypath.py +++ b/cloudpathlib/anypath.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from typing import Union @@ -52,3 +53,14 @@ def _validate(cls, value) -> Union[CloudPath, Path]: https://pydantic-docs.helpmanual.io/usage/types/#custom-data-types""" # Note __new__ is static method and not a class method return cls.__new__(cls, value) + + +def to_anypath(s: Union[str, os.PathLike]) -> Union[CloudPath, Path]: + """Convenience method to convert a str or os.PathLike to the + proper Path or CloudPath object using AnyPath. + """ + # shortcut pathlike items that are already valid Path/CloudPath + if isinstance(s, (CloudPath, Path)): + return s + + return AnyPath(s) # type: ignore diff --git a/cloudpathlib/azure/azblobclient.py b/cloudpathlib/azure/azblobclient.py index d4e79d7e..c11c83fb 100644 --- a/cloudpathlib/azure/azblobclient.py +++ b/cloudpathlib/azure/azblobclient.py @@ -98,7 +98,7 @@ def _get_metadata(self, cloud_path: AzureBlobPath) -> Dict[str, Any]: def _download_file( self, cloud_path: AzureBlobPath, local_path: Union[str, os.PathLike] - ) -> Union[str, os.PathLike]: + ) -> Path: blob = self.service_client.get_blob_client( container=cloud_path.container, blob=cloud_path.blob ) @@ -171,7 +171,9 @@ def _list_dir( yield self.CloudPath(f"az://{cloud_path.container}/{o.name}") - def _move_file(self, src: AzureBlobPath, dst: AzureBlobPath) -> AzureBlobPath: + def _move_file( + self, src: AzureBlobPath, dst: AzureBlobPath, remove_src: bool = True + ) -> AzureBlobPath: # just a touch, so "REPLACE" metadata if src == dst: blob_client = self.service_client.get_blob_client( @@ -189,7 +191,8 @@ def _move_file(self, src: AzureBlobPath, dst: AzureBlobPath) -> AzureBlobPath: target.start_copy_from_url(source.url) - self._remove(src) + if remove_src: + self._remove(src) return dst diff --git a/cloudpathlib/client.py b/cloudpathlib/client.py index 544e34ee..3d6f686c 100644 --- a/cloudpathlib/client.py +++ b/cloudpathlib/client.py @@ -60,7 +60,7 @@ def CloudPath(self, cloud_path: Union[str, BoundedCloudPath]) -> BoundedCloudPat @abc.abstractmethod def _download_file( self, cloud_path: BoundedCloudPath, local_path: Union[str, os.PathLike] - ) -> Union[str, os.PathLike]: + ) -> Path: pass @abc.abstractmethod @@ -83,7 +83,9 @@ def _list_dir( pass @abc.abstractmethod - def _move_file(self, src: BoundedCloudPath, dst: BoundedCloudPath) -> BoundedCloudPath: + def _move_file( + self, src: BoundedCloudPath, dst: BoundedCloudPath, remove_src: bool = True + ) -> BoundedCloudPath: pass @abc.abstractmethod diff --git a/cloudpathlib/cloudpath.py b/cloudpathlib/cloudpath.py index 29559d1a..5a3d3c6d 100644 --- a/cloudpathlib/cloudpath.py +++ b/cloudpathlib/cloudpath.py @@ -8,6 +8,8 @@ from urllib.parse import urlparse from warnings import warn +from . import anypath + from .exceptions import ( ClientMismatchError, CloudPathFileExistsError, @@ -585,12 +587,12 @@ def read_text(self): return self._dispatch_to_local_cache_path("read_text") # =========== public cloud methods, not in pathlib =============== - def download_to(self, destination: Union[str, os.PathLike]): + def download_to(self, destination: Union[str, os.PathLike]) -> Path: destination = Path(destination) if self.is_file(): if destination.is_dir(): destination = destination / self.name - self.client._download_file(self, destination) + return self.client._download_file(self, destination) else: destination.mkdir(exist_ok=True) for f in self.iterdir(): @@ -601,6 +603,8 @@ def download_to(self, destination: Union[str, os.PathLike]): rel_dest = str(f)[len(rel) :] f.download_to(destination / rel_dest) + return destination + def rmtree(self): """Delete an entire directory tree.""" if self.is_file(): @@ -609,6 +613,108 @@ def rmtree(self): ) self.client._remove(self) + def upload_from( + self, source: Union[str, os.PathLike], force_overwrite_to_cloud: bool = False + ) -> "CloudPath": + """Upload a file or directory to the cloud path.""" + source = Path(source) + + if source.is_dir(): + for p in source.iterdir(): + (self / p.name).upload_from(p, force_overwrite_to_cloud=force_overwrite_to_cloud) + + return self + + else: + if self.exists() and self.is_dir(): + dst = self / source.name + else: + dst = self + + dst._upload_file_to_cloud(source, force_overwrite_to_cloud=force_overwrite_to_cloud) + + return dst + + def copy( + self, + destination: Union[str, os.PathLike, "CloudPath"], + force_overwrite_to_cloud: bool = False, + ) -> Union[Path, "CloudPath"]: + """Copy self to destination folder of file, if self is a file.""" + if not self.exists() or not self.is_file(): + raise ValueError( + f"Path {self} should be a file. To copy a directory tree use the method copytree." + ) + + # handle string version of cloud paths + local paths + if isinstance(destination, (str, os.PathLike)): + destination = anypath.to_anypath(destination) + + if not isinstance(destination, CloudPath): + return self.download_to(destination) + + # if same client, use cloud-native _move_file on client to avoid downloading + elif self.client is destination.client: + if destination.exists() and destination.is_dir(): + destination: CloudPath = destination / self.name # type: ignore + + if ( + not force_overwrite_to_cloud + and destination.exists() + and destination.stat().st_mtime >= self.stat().st_mtime + ): + raise OverwriteNewerCloudError( + f"File ({destination}) is newer than ({self}). " + f"To overwrite " + f"pass `force_overwrite_to_cloud=True`." + ) + + return self.client._move_file(self, destination, remove_src=False) + + else: + if not destination.exists() or destination.is_file(): + return destination.upload_from( + self.fspath, force_overwrite_to_cloud=force_overwrite_to_cloud + ) + else: + return (destination / self.name).upload_from( + self.fspath, force_overwrite_to_cloud=force_overwrite_to_cloud + ) + + def copytree( + self, + destination: Union[str, os.PathLike, "CloudPath"], + force_overwrite_to_cloud: bool = False, + ) -> Union[Path, "CloudPath"]: + """Copy self to a directory, if self is a directory.""" + if not self.is_dir(): + raise CloudPathNotADirectoryError( + f"Origin path {self} must be a directory. To copy a single file use the method copy." + ) + + # handle string version of cloud paths + local paths + if isinstance(destination, (str, os.PathLike)): + destination = anypath.to_anypath(destination) + + if destination.exists() and destination.is_file(): + raise CloudPathFileExistsError( + "Destination path {destination} of copytree must be a directory." + ) + + destination.mkdir(parents=True, exist_ok=True) + + for subpath in self.iterdir(): + if subpath.is_file(): + subpath.copy( + destination / subpath.name, force_overwrite_to_cloud=force_overwrite_to_cloud + ) + elif subpath.is_dir(): + subpath.copytree( + destination / subpath.name, force_overwrite_to_cloud=force_overwrite_to_cloud + ) + + return destination + # =========== private cloud methods =============== @property def _local(self): @@ -673,11 +779,30 @@ def _refresh_cache(self, force_overwrite_from_cloud=False): ) def _upload_local_to_cloud(self, force_overwrite_to_cloud: bool = False): + """Uploads cache file at self._local to the cloud""" # We should never try to be syncing entire directories; we should only # cache and upload individual files. if self._local.is_dir(): raise ValueError("Only individual files can be uploaded to the cloud") + uploaded = self._upload_file_to_cloud( + self._local, force_overwrite_to_cloud=force_overwrite_to_cloud + ) + + # force cache time to match cloud times + stats = self.stat() + os.utime(self._local, times=(stats.st_mtime, stats.st_mtime)) + + # reset dirty and handle now that this is uploaded + self._dirty = False + self._handle = None + + return uploaded + + def _upload_file_to_cloud(self, local_path, force_overwrite_to_cloud: bool = False): + """Uploads file at `local_path` to the cloud if there is not a newer file + already there. + """ try: stats = self.stat() except NoStatError: @@ -686,22 +811,14 @@ def _upload_local_to_cloud(self, force_overwrite_to_cloud: bool = False): # if cloud does not exist or local is newer or we are overwriting, do the upload if ( not stats # cloud does not exist - or (self._local.stat().st_mtime > stats.st_mtime) + or (local_path.stat().st_mtime > stats.st_mtime) or force_overwrite_to_cloud ): self.client._upload_file( - self._local, + local_path, self, ) - # force cache time to match cloud times - stats = self.stat() - os.utime(self._local, times=(stats.st_mtime, stats.st_mtime)) - - # reset dirty and handle now that this is uploaded - self._dirty = False - self._handle = None - return self # cloud is newer and we are not overwriting diff --git a/cloudpathlib/gs/gsclient.py b/cloudpathlib/gs/gsclient.py index eae088ab..b226e118 100644 --- a/cloudpathlib/gs/gsclient.py +++ b/cloudpathlib/gs/gsclient.py @@ -1,6 +1,6 @@ from datetime import datetime import os -from pathlib import PurePosixPath +from pathlib import Path, PurePosixPath from typing import Any, Dict, Iterable, Optional, TYPE_CHECKING, Union from ..client import Client, register_client_class @@ -91,12 +91,12 @@ def _get_metadata(self, cloud_path: GSPath) -> Optional[Dict[str, Any]]: "updated": blob.updated, } - def _download_file( - self, cloud_path: GSPath, local_path: Union[str, os.PathLike] - ) -> Union[str, os.PathLike]: + def _download_file(self, cloud_path: GSPath, local_path: Union[str, os.PathLike]) -> Path: bucket = self.client.bucket(cloud_path.bucket) blob = bucket.get_blob(cloud_path.blob) + local_path = Path(local_path) + blob.download_to_filename(local_path) return local_path @@ -158,7 +158,7 @@ def _list_dir(self, cloud_path: GSPath, recursive=False) -> Iterable[GSPath]: yield self.CloudPath(f"gs://{cloud_path.bucket}/{o.name}") - def _move_file(self, src: GSPath, dst: GSPath) -> GSPath: + def _move_file(self, src: GSPath, dst: GSPath, remove_src: bool = True) -> GSPath: # just a touch, so "REPLACE" metadata if src == dst: bucket = self.client.bucket(src.bucket) @@ -177,7 +177,9 @@ def _move_file(self, src: GSPath, dst: GSPath) -> GSPath: src_blob = src_bucket.get_blob(src.blob) src_bucket.copy_blob(src_blob, dst_bucket, dst.blob) - src_blob.delete() + + if remove_src: + src_blob.delete() return dst diff --git a/cloudpathlib/local/localclient.py b/cloudpathlib/local/localclient.py index a6508d7f..8e58b515 100644 --- a/cloudpathlib/local/localclient.py +++ b/cloudpathlib/local/localclient.py @@ -52,9 +52,7 @@ def _local_to_cloud_path(self, local_path: Union[str, os.PathLike]) -> "LocalPat f"{cloud_prefix}{PurePosixPath(local_path.relative_to(self._local_storage_dir))}" ) - def _download_file( - self, cloud_path: "LocalPath", local_path: Union[str, os.PathLike] - ) -> Union[str, os.PathLike]: + def _download_file(self, cloud_path: "LocalPath", local_path: Union[str, os.PathLike]) -> Path: local_path = Path(local_path) local_path.parent.mkdir(exist_ok=True, parents=True) shutil.copyfile(self._cloud_path_to_local(cloud_path), local_path) @@ -83,9 +81,15 @@ def _list_dir(self, cloud_path: "LocalPath", recursive=False) -> Iterable["Local def _md5(self, cloud_path: "LocalPath") -> str: return md5(self._cloud_path_to_local(cloud_path).read_bytes()).hexdigest() - def _move_file(self, src: "LocalPath", dst: "LocalPath") -> "LocalPath": + def _move_file( + self, src: "LocalPath", dst: "LocalPath", remove_src: bool = True + ) -> "LocalPath": self._cloud_path_to_local(dst).parent.mkdir(exist_ok=True, parents=True) - self._cloud_path_to_local(src).replace(self._cloud_path_to_local(dst)) + + if remove_src: + self._cloud_path_to_local(src).replace(self._cloud_path_to_local(dst)) + else: + shutil.copy(self._cloud_path_to_local(src), self._cloud_path_to_local(dst)) return dst def _remove(self, cloud_path: "LocalPath") -> None: diff --git a/cloudpathlib/s3/s3client.py b/cloudpathlib/s3/s3client.py index 11948987..10dbacce 100644 --- a/cloudpathlib/s3/s3client.py +++ b/cloudpathlib/s3/s3client.py @@ -1,5 +1,5 @@ import os -from pathlib import PurePosixPath +from pathlib import Path, PurePosixPath from typing import Any, Dict, Iterable, Optional, Union from ..client import Client, register_client_class @@ -79,9 +79,8 @@ def _get_metadata(self, cloud_path: S3Path) -> Dict[str, Any]: "extra": data["Metadata"], } - def _download_file( - self, cloud_path: S3Path, local_path: Union[str, os.PathLike] - ) -> Union[str, os.PathLike]: + def _download_file(self, cloud_path: S3Path, local_path: Union[str, os.PathLike]) -> Path: + local_path = Path(local_path) obj = self.s3.Object(cloud_path.bucket, cloud_path.key) obj.download_file(str(local_path)) @@ -154,7 +153,7 @@ def _list_dir(self, cloud_path: S3Path, recursive=False) -> Iterable[S3Path]: for result_key in result.get("Contents", []): yield self.CloudPath(f"s3://{cloud_path.bucket}/{result_key.get('Key')}") - def _move_file(self, src: S3Path, dst: S3Path) -> S3Path: + def _move_file(self, src: S3Path, dst: S3Path, remove_src: bool = True) -> S3Path: # just a touch, so "REPLACE" metadata if src == dst: o = self.s3.Object(src.bucket, src.key) @@ -168,7 +167,8 @@ def _move_file(self, src: S3Path, dst: S3Path) -> S3Path: target = self.s3.Object(dst.bucket, dst.key) target.copy({"Bucket": src.bucket, "Key": src.key}) - self._remove(src) + if remove_src: + self._remove(src) return dst def _remove(self, cloud_path: S3Path) -> None: diff --git a/tests/test_cloudpath_upload_copy.py b/tests/test_cloudpath_upload_copy.py new file mode 100644 index 00000000..a8006105 --- /dev/null +++ b/tests/test_cloudpath_upload_copy.py @@ -0,0 +1,244 @@ +from pathlib import Path +from time import sleep + +import pytest + +from cloudpathlib.local import LocalGSPath, LocalS3Path, LocalS3Client +from cloudpathlib.exceptions import ( + CloudPathFileExistsError, + CloudPathNotADirectoryError, + OverwriteNewerCloudError, +) + + +@pytest.fixture +def upload_assets_dir(tmpdir): + tmp_assets = tmpdir.mkdir("test_upload_from_dir") + p = Path(tmp_assets) + (p / "upload_1.txt").write_text("Hello from 1") + (p / "upload_2.txt").write_text("Hello from 2") + + sub = p / "subdir" + sub.mkdir(parents=True) + (sub / "sub_upload_1.txt").write_text("Hello from sub 1") + (sub / "sub_upload_2.txt").write_text("Hello from sub 2") + + yield p + + +def assert_mirrored(cloud_path, local_path, check_no_extra=True): + # file exists and is file + if local_path.is_file(): + assert cloud_path.exists() + assert cloud_path.is_file() + else: + # all local files exist on cloud + for lp in local_path.iterdir(): + assert_mirrored(cloud_path / lp.name, lp) + + # no extra files on cloud + if check_no_extra: + assert len(list(local_path.glob("**/*"))) == len(list(cloud_path.glob("**/*"))) + + return True + + +def test_upload_from_file(rig, upload_assets_dir): + to_upload = upload_assets_dir / "upload_1.txt" + + # to file, file does not exists + p = rig.create_cloud_path("upload_test.txt") + assert not p.exists() + + p.upload_from(to_upload) + assert p.exists() + assert p.read_text() == "Hello from 1" + + # to file, file exists + to_upload_2 = upload_assets_dir / "upload_2.txt" + sleep(0.5) + to_upload_2.touch() # make sure local is newer + p.upload_from(to_upload_2) + assert p.exists() + assert p.read_text() == "Hello from 2" + + # to file, file exists and is newer + p.touch() + with pytest.raises(OverwriteNewerCloudError): + p.upload_from(upload_assets_dir / "upload_1.txt") + + # to file, file exists and is newer; overwrite + p.touch() + sleep(0.5) + p.upload_from(upload_assets_dir / "upload_1.txt", force_overwrite_to_cloud=True) + assert p.exists() + assert p.read_text() == "Hello from 1" + + # to dir, dir exists + p = rig.create_cloud_path("dir_0") # created by fixtures + assert p.exists() + p.upload_from(upload_assets_dir / "upload_1.txt") + assert (p / "upload_1.txt").exists() + assert (p / "upload_1.txt").read_text() == "Hello from 1" + + +def test_upload_from_dir(rig, upload_assets_dir): + # to dir, dir does not exists + p = rig.create_cloud_path("upload_test_dir") + assert not p.exists() + + p.upload_from(upload_assets_dir) + assert assert_mirrored(p, upload_assets_dir) + + # to dir, dir exists + p2 = rig.create_cloud_path("dir_0") # created by fixtures + assert p2.exists() + + p2.upload_from(upload_assets_dir) + assert assert_mirrored(p2, upload_assets_dir, check_no_extra=False) + + # a newer file exists on cloud + (p / "upload_1.txt").touch() + with pytest.raises(OverwriteNewerCloudError): + p.upload_from(upload_assets_dir) + + # force overwrite + (p / "upload_1.txt").touch() + (p / "upload_2.txt").unlink() + p.upload_from(upload_assets_dir, force_overwrite_to_cloud=True) + assert assert_mirrored(p, upload_assets_dir) + + +def test_copy(rig, upload_assets_dir, tmpdir): + to_upload = upload_assets_dir / "upload_1.txt" + p = rig.create_cloud_path("upload_test.txt") + assert not p.exists() + p.upload_from(to_upload) + assert p.exists() + + # cloud to local dir + dst = Path(tmpdir.mkdir("test_copy_to_local")) + out_file = p.copy(dst) + assert out_file.exists() + assert out_file.read_text() == "Hello from 1" + out_file.unlink() + + p.copy(str(out_file)) + assert out_file.exists() + assert out_file.read_text() == "Hello from 1" + + # cloud to local file + p.copy(dst / "file.txt") + + # cloud to cloud -> make sure no local cache + p_new = p.copy(p.parent / "new_upload_1.txt") + assert p_new.exists() + assert not p_new._local.exists() # cache should never have been downloaded + assert not p._local.exists() # cache should never have been downloaded + assert p_new.read_text() == "Hello from 1" + + # cloud to cloud path as string + cloud_dest = str(p.parent / "new_upload_0.txt") + p_new = p.copy(cloud_dest) + assert p_new.exists() + assert p_new.read_text() == "Hello from 1" + + # cloud to cloud directory + cloud_dest = rig.create_cloud_path("dir_1") # created by fixtures + p_new = p.copy(cloud_dest) + assert str(p_new) == str(p_new.parent / p.name) # file created + assert p_new.exists() + assert p_new.read_text() == "Hello from 1" + + # cloud to cloud overwrite + p_new.touch() + with pytest.raises(OverwriteNewerCloudError): + p_new = p.copy(p_new) + + p_new = p.copy(p_new, force_overwrite_to_cloud=True) + assert p_new.exists() + + # cloud to other cloud + p2 = rig.create_cloud_path("dir_0/file0_0.txt") + other = ( + LocalS3Path("s3://fake-bucket/new_other.txt") + if not isinstance(p2.client, LocalS3Client) + else LocalGSPath("gs://fake-bucket/new_other.txt") + ) + assert not other.exists() + + assert not p2._local.exists() # not in cache + p2.copy(other) # forces download + reupload + assert p2._local.exists() # in cache + assert other.exists() + assert other.read_text() == p2.read_text() + + other.unlink() + + # cloud to other cloud dir + other_dir = ( + LocalS3Path("s3://fake-bucket/new_other") + if not isinstance(p2.client, LocalS3Client) + else LocalGSPath("gs://fake-bucket/new_other") + ) + (other_dir / "file.txt").write_text("i am a file") # ensure other_dir exists + assert other_dir.exists() + assert not (other_dir / p2.name).exists() + + p2.copy(other_dir) + assert (other_dir / p2.name).exists() + assert (other_dir / p2.name).read_text() == p2.read_text() + (other_dir / p2.name).unlink() + + # cloud dir raises + cloud_dir = rig.create_cloud_path("dir_1") # created by fixtures + with pytest.raises(ValueError) as e: + p_new = cloud_dir.copy(Path(tmpdir.mkdir("test_copy_dir_fails"))) + assert "use the method copytree" in str(e) + + +def test_copytree(rig, tmpdir): + # cloud file raises + with pytest.raises(CloudPathNotADirectoryError): + p = rig.create_cloud_path("dir_0/file0_0.txt") + local_out = Path(tmpdir.mkdir("copytree_fail_on_file")) + p.copytree(local_out) + + with pytest.raises(CloudPathFileExistsError): + p = rig.create_cloud_path("dir_0") + p_out = rig.create_cloud_path("dir_0/file0_0.txt") + p.copytree(p_out) + + # cloud dir to local dir that exists + p = rig.create_cloud_path("dir_1") + local_out = Path(tmpdir.mkdir("copytree_from_cloud")) + p.copytree(local_out) + assert assert_mirrored(p, local_out) + + # str version of path + local_out = Path(tmpdir.mkdir("copytree_to_str_path")) + p.copytree(str(local_out)) + assert assert_mirrored(p, local_out) + + # cloud dir to local dir that does not exist + local_out = local_out / "new_folder" + p.copytree(local_out) + assert assert_mirrored(p, local_out) + + # cloud dir to cloud dir that does not exist + p2 = rig.create_cloud_path("new_dir") + p.copytree(p2) + assert assert_mirrored(p2, p) + + # cloud dir to cloud dir that exists + p2 = rig.create_cloud_path("new_dir2") + (p2 / "existing_file.txt").write_text("asdf") # ensures p2 exists + p.copytree(p2) + assert assert_mirrored(p2, p, check_no_extra=False) + + (p / "new_file.txt").write_text("hello!") # add file so we can assert mirror + with pytest.raises(OverwriteNewerCloudError): + p.copytree(p2) + + p.copytree(p2, force_overwrite_to_cloud=True) + assert assert_mirrored(p2, p, check_no_extra=False)