Skip to content

Commit

Permalink
Add support for --dry-run option in sync command
Browse files Browse the repository at this point in the history
  • Loading branch information
miguelcsx authored Oct 27, 2024
1 parent 58d25fb commit 9d36e09
Show file tree
Hide file tree
Showing 2 changed files with 67 additions and 12 deletions.
46 changes: 34 additions & 12 deletions cloudinary_cli/modules/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@
@option("-o", "--optional_parameter", multiple=True, nargs=2, help="Pass optional parameters as raw strings.")
@option("-O", "--optional_parameter_parsed", multiple=True, nargs=2,
help="Pass optional parameters as interpreted strings.")
@option("--dry-run", is_flag=True, help="Simulate the sync operation without making any changes.")
def sync(local_folder, cloudinary_folder, push, pull, include_hidden, concurrent_workers, force, keep_unique,
deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed):
deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed, dry_run):
if push == pull:
raise UsageError("Please use either the '--push' OR '--pull' options")

sync_dir = SyncDir(local_folder, cloudinary_folder, include_hidden, concurrent_workers, force, keep_unique,
deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed)

deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed, dry_run)
result = True
if push:
result = sync_dir.push()
Expand All @@ -63,7 +63,7 @@ def sync(local_folder, cloudinary_folder, push, pull, include_hidden, concurrent

class SyncDir:
def __init__(self, local_dir, remote_dir, include_hidden, concurrent_workers, force, keep_deleted,
deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed):
deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed, dry_run):
self.local_dir = local_dir
self.remote_dir = remote_dir.strip('/')
self.user_friendly_remote_dir = self.remote_dir if self.remote_dir else '/'
Expand All @@ -72,6 +72,7 @@ def __init__(self, local_dir, remote_dir, include_hidden, concurrent_workers, fo
self.force = force
self.keep_unique = keep_deleted
self.deletion_batch_size = deletion_batch_size
self.dry_run = dry_run

self.folder_mode = folder_mode or get_folder_mode()

Expand Down Expand Up @@ -115,16 +116,16 @@ def __init__(self, local_dir, remote_dir, include_hidden, concurrent_workers, fo
local_file_names = self.local_files.keys()
remote_file_names = self.remote_files.keys()
"""
Cloudinary is a very permissive service. When uploading files that contain invalid characters,
unicode characters, etc, Cloudinary does the best effort to store those files.
Usually Cloudinary sanitizes those file names and strips invalid characters. Although it is a good best effort
for a general use case, when syncing local folder with Cloudinary, it is not the best option, since directories
Cloudinary is a very permissive service. When uploading files that contain invalid characters,
unicode characters, etc, Cloudinary does the best effort to store those files.
Usually Cloudinary sanitizes those file names and strips invalid characters. Although it is a good best effort
for a general use case, when syncing local folder with Cloudinary, it is not the best option, since directories
will be always out-of-sync.
In addition in dynamic folder mode Cloudinary allows having identical display names for differrent files.
To overcome this limitation, cloudinary-cli keeps .cld-sync hidden file in the sync directory that contains a
To overcome this limitation, cloudinary-cli keeps .cld-sync hidden file in the sync directory that contains a
mapping of the diverse file names. This file keeps tracking of the files and allows syncing in both directions.
"""

Expand Down Expand Up @@ -168,6 +169,12 @@ def push(self):
if not files_to_push:
return True

if self.dry_run:
logger.info("Dry run mode enabled. The following files would be uploaded:")
for file in files_to_push:
logger.info(f"{file}")
return True

logger.info(f"Uploading {len(files_to_push)} items to Cloudinary folder '{self.user_friendly_remote_dir}'")

options = {
Expand Down Expand Up @@ -215,6 +222,14 @@ def pull(self):
if not files_to_pull:
return True

logger.info(f"Preparing to download {len(files_to_pull)} items from Cloudinary folder ")

if self.dry_run:
logger.info("Dry run mode enabled. The following files would be downloaded:")
for file in files_to_pull:
logger.info(f"{file}")
return True

logger.info(f"Downloading {len(files_to_pull)} files from Cloudinary")
downloads = []
for file in files_to_pull:
Expand Down Expand Up @@ -348,6 +363,10 @@ def _handle_unique_remote_files(self):

# Each batch is further chunked by a deletion batch size that can be specified by the user.
for deletion_batch in chunker(batch, self.deletion_batch_size):
if self.dry_run:
logger.info(f"Dry run mode enabled. Would delete {len(deletion_batch)} resources:\n" +
"\n".join(deletion_batch))
continue
res = api.delete_resources(deletion_batch, invalidate=True, resource_type=attrs[0], type=attrs[1])
num_deleted = Counter(res['deleted'].values())["deleted"]
if self.verbose:
Expand Down Expand Up @@ -395,6 +414,9 @@ def _handle_unique_local_files(self):
logger.info(f"Deleting {len(self.unique_local_file_names)} local files...")
for file in self.unique_local_file_names:
full_path = path.abspath(self.local_files[file]['path'])
if self.dry_run:
logger.info(f"Dry run mode enabled. Would delete '{full_path}'")
continue
remove(full_path)
logger.info(f"Deleted '{full_path}'")

Expand Down
33 changes: 33 additions & 0 deletions test/test_modules/test_cli_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,36 @@ def test_cli_sync_duplicate_file_names_dynamic_folder_mode(self):
self.assertEqual(0, result.exit_code)
self.assertIn("Skipping 12 items", result.output)
self.assertIn("Done!", result.output)


@retry_assertion
def test_cli_sync_push_dry_run(self):
self._upload_sync_files(TEST_FILES_DIR)

# wait for indexing to be updated
time.sleep(self.GRACE_PERIOD)

result = self.runner.invoke(cli, ['sync', '--push', '-F', self.LOCAL_PARTIAL_SYNC_DIR, self.CLD_SYNC_DIR, '--dry-run'])

# check that no files were uploaded
self.assertEqual(0, result.exit_code)
self.assertIn("Dry run mode enabled. The following files would be uploaded:", result.output)
self.assertIn("Done!", result.output)


@retry_assertion
def test_cli_sync_pull_dry_run(self):
self._upload_sync_files(TEST_FILES_DIR)

# wait for indexing to be updated
time.sleep(self.GRACE_PERIOD)

shutil.copytree(self.LOCAL_PARTIAL_SYNC_DIR, self.LOCAL_SYNC_PULL_DIR)

result = self.runner.invoke(cli, ['sync', '--pull', '-F', self.LOCAL_SYNC_PULL_DIR, self.CLD_SYNC_DIR, '--dry-run'])

# check that no files were downloaded
self.assertEqual(0, result.exit_code)
self.assertIn("Dry run mode enabled. The following files would be downloaded:", result.output)
self.assertIn("Done!", result.output)

0 comments on commit 9d36e09

Please sign in to comment.