Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions cloudinary_cli/modules/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from cloudinary_cli.utils.api_utils import query_cld_folder, upload_file, download_file, get_folder_mode, \
get_default_upload_options, get_destination_folder_options, cld_folder_exists, call_api
from cloudinary_cli.utils.file_utils import (walk_dir, delete_empty_dirs, normalize_file_extension, posix_rel_path,
populate_duplicate_name)
populate_duplicate_name, exclude_file)
from cloudinary_cli.utils.json_utils import print_json, read_json_from_file, write_json_to_file
from cloudinary_cli.utils.utils import logger, run_tasks_concurrently, get_user_action, invert_dict, chunker, \
group_params, parse_option_value, duplicate_values, should_dump_responses
Expand Down Expand Up @@ -92,7 +92,7 @@ def __init__(self, local_dir, remote_dir, include_hidden, concurrent_workers, fo
if not self.local_folder_exists:
logger.info(f"Local folder '{self.local_dir}' does not exist.")
else:
self.local_files = walk_dir(path.abspath(self.local_dir), include_hidden)
self.local_files = exclude_file(walk_dir(path.abspath(self.local_dir), include_hidden), _SYNC_META_FILE)
if len(self.local_files):
logger.info(f"Found {len(self.local_files)} items in local folder '{self.local_dir}'")
else:
Expand All @@ -113,7 +113,8 @@ def __init__(self, local_dir, remote_dir, include_hidden, concurrent_workers, fo
logger.info(f"Cloudinary folder '{self.user_friendly_remote_dir}' is empty. "
f"({self.folder_mode} folder mode)")

self.remote_files = self._normalize_remote_file_names(raw_remote_files, self.local_files)
self.remote_files = exclude_file(self._normalize_remote_file_names(raw_remote_files, self.local_files),
_SYNC_META_FILE)
self.remote_duplicate_names = duplicate_values(self.remote_files, "normalized_path", "asset_id")
self._print_duplicate_file_names()

Expand Down
4 changes: 4 additions & 0 deletions cloudinary_cli/utils/file_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ def walk_dir(root_dir, include_hidden=False):
return all_files


def exclude_file(files, file_name):
return {file_path: dt for file_path, dt in files.items() if path.basename(file_path) != file_name}


def is_hidden(root, relative_path):
return is_hidden_path(path.join(root, relative_path))

Expand Down
3 changes: 2 additions & 1 deletion test/helper_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ def retry_func(*args, **kwargs):

def delete_cld_folder_if_exists(folder, folder_mode = "fixed"):
if folder_mode == "fixed":
cloudinary.api.delete_resources_by_prefix(folder)
for resource_type in ("image", "raw", "video"):
cloudinary.api.delete_resources_by_prefix(folder, resource_type=resource_type)
else:
assets = query_cld_folder(folder, folder_mode)
for resource_type in {f["resource_type"] for f in assets.values()}:
Expand Down
70 changes: 70 additions & 0 deletions test/test_modules/test_cli_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,13 @@ def test_cli_sync_raw_file_keeps_extension(self):
self.assertTrue(Path(self.LOCAL_SYNC_PULL_DIR, "notes.txt").is_file())
self._assert_nothing_to_sync('--push', self.LOCAL_SYNC_PULL_DIR, 1)

def test_cli_sync_push_include_hidden_skips_meta_file(self):
local_dir = self._local_files({"notes.txt": "txt", ".hidden.txt": "hidden", ".cld-sync": "{}"})
result = self.runner.invoke(cli, ['sync', '--push', '-F', '-H', local_dir, self.CLD_SYNC_DIR])
self.assertEqual(0, result.exit_code, result.output)
self.assertIn("Synced | 2", result.output)
self._wait_for_cld_files(2)

@retry_assertion
def test_cli_sync_push_dry_run(self):
self._upload_sync_files(TEST_FILES_DIR)
Expand Down Expand Up @@ -370,3 +377,66 @@ def test_sync_push_does_not_delete_all_duplicates_of_synced_file(self, _, query_
uploaded = [c.args[0] for c in upload_mock.call_args_list]
# notes.txt must stay on Cloudinary: either a remote copy is kept, or the file is uploaded again.
self.assertTrue(len(deleted) < 2 or uploaded, f"deleted {deleted}, uploaded {uploaded}")


class TestCLISyncMetaFileOffline(unittest.TestCase):
runner = CliRunner()

def setUp(self) -> None:
self.local_dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.local_dir, True)
self.notes_path = os.path.join(self.local_dir, "notes.txt")
Path(self.notes_path).write_text("notes")
os.mkdir(os.path.join(self.local_dir, "sub"))
for meta_file in [".cld-sync", "sub/.cld-sync"]:
Path(self.local_dir, meta_file).write_text("{}")

self.query_mock = self._patch("query_cld_folder", return_value={})
self._patch("cld_folder_exists", return_value=True)
self.call_api_mock = self._patch("call_api")
self.upload_mock = self._patch("upload_file")
self.download_mock = self._patch("download_file")

def _patch(self, name, **kwargs):
patcher = patch.object(sync_module, name, **kwargs)
self.addCleanup(patcher.stop)
return patcher.start()

def _remote_files(self, *names):
return {name: {
"asset_id": name, "normalized_path": name, "normalized_unique_path": name,
"type": "upload", "resource_type": "raw", "public_id": name, "format": None,
"etag": etag(self.notes_path), "relative_path": name, "access_mode": "public",
"created_at": "2026-01-01",
} for name in names}

def _sync(self, direction):
result = self.runner.invoke(cli, ['sync', direction, '-F', '-H', '-fm', 'fixed', self.local_dir, 'folder'])
self.assertEqual(0, result.exit_code, result.output)

def test_sync_push_include_hidden_does_not_upload_meta_file(self):
self._sync('--push')

self.assertEqual([self.notes_path], [c.args[0] for c in self.upload_mock.call_args_list])

def test_sync_pull_include_hidden_does_not_delete_meta_file(self):
self.query_mock.return_value = self._remote_files("notes.txt")

self._sync('--pull')

self.assertTrue(Path(self.local_dir, ".cld-sync").is_file())
self.assertTrue(Path(self.local_dir, "sub/.cld-sync").is_file())

def test_sync_push_does_not_delete_remote_meta_file(self):
self.query_mock.return_value = self._remote_files("notes.txt", ".cld-sync", "sub/.cld-sync")

self._sync('--push')

self.call_api_mock.assert_not_called()

def test_sync_pull_does_not_download_remote_meta_file(self):
self.query_mock.return_value = self._remote_files("notes.txt", ".cld-sync", "sub/.cld-sync")

self._sync('--pull')

self.download_mock.assert_not_called()
Loading