From 9e91ed4a97591a2ad195956a290eb29f6e211c73 Mon Sep 17 00:00:00 2001 From: Constantine Nathanson Date: Sun, 4 Oct 2026 15:24:42 +0300 Subject: [PATCH 1/3] Fix `sync -H` syncing the `.cld-sync` meta file With `--include-hidden`, `walk_dir` returned the `.cld-sync` meta file as a regular local file, so: - `sync --push -H` uploaded it as a raw asset, before it was updated, and found it out of sync on every later push; - `sync --pull -H` deleted the local meta file as unique, or overwrote it with a copy pushed earlier. Add `exclude_file` to `file_utils` and drop `.cld-sync` files, at any depth, from the local and remote file lists in `SyncDir`. A copy already pushed by an earlier version is left on Cloudinary. Co-Authored-By: Claude Opus 5.5 (1M context) --- cloudinary_cli/modules/sync.py | 7 +-- cloudinary_cli/utils/file_utils.py | 4 ++ test/test_modules/test_cli_sync.py | 74 ++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/cloudinary_cli/modules/sync.py b/cloudinary_cli/modules/sync.py index a49a92c..af3b107 100644 --- a/cloudinary_cli/modules/sync.py +++ b/cloudinary_cli/modules/sync.py @@ -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 @@ -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: @@ -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() diff --git a/cloudinary_cli/utils/file_utils.py b/cloudinary_cli/utils/file_utils.py index a13a62e..f9f445c 100644 --- a/cloudinary_cli/utils/file_utils.py +++ b/cloudinary_cli/utils/file_utils.py @@ -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)) diff --git a/test/test_modules/test_cli_sync.py b/test/test_modules/test_cli_sync.py index 36ccac4..03ba332 100644 --- a/test/test_modules/test_cli_sync.py +++ b/test/test_modules/test_cli_sync.py @@ -290,6 +290,17 @@ 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) + + remote_paths = [f["normalized_path"] for f in query_cld_folder(self.CLD_SYNC_DIR, self.folder_mode).values()] + self.assertEqual(2, len(remote_paths), remote_paths) + self.assertNotIn(".cld-sync", remote_paths) + @retry_assertion def test_cli_sync_push_dry_run(self): self._upload_sync_files(TEST_FILES_DIR) @@ -370,3 +381,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() From b8681b16b3926de2b4de3c8e4deb8b41b4495d27 Mon Sep 17 00:00:00 2001 From: Constantine Nathanson Date: Sun, 4 Oct 2026 15:34:02 +0300 Subject: [PATCH 2/3] Delete raw and video test assets in fixed folder mode `delete_cld_folder_if_exists` called `delete_resources_by_prefix` with the default `image` resource type, so raw files pushed by a test in fixed folder mode were left behind and `delete_folder` failed with "Folder is not empty" for every following test. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/helper_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/helper_test.py b/test/helper_test.py index 5e12dd1..a5d97fb 100644 --- a/test/helper_test.py +++ b/test/helper_test.py @@ -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()}: From 57c510cc618f591af118cfc1d11eb43758a61650 Mon Sep 17 00:00:00 2001 From: Constantine Nathanson Date: Sun, 4 Oct 2026 17:15:24 +0300 Subject: [PATCH 3/3] Check the upload count only in the `sync -H` meta file test The Search API indexes new assets with a delay, so counting remote assets right after the push was flaky. `Synced | 2` already shows that the meta file was not uploaded. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/test_modules/test_cli_sync.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/test_modules/test_cli_sync.py b/test/test_modules/test_cli_sync.py index 03ba332..177503b 100644 --- a/test/test_modules/test_cli_sync.py +++ b/test/test_modules/test_cli_sync.py @@ -297,10 +297,6 @@ def test_cli_sync_push_include_hidden_skips_meta_file(self): self.assertIn("Synced | 2", result.output) self._wait_for_cld_files(2) - remote_paths = [f["normalized_path"] for f in query_cld_folder(self.CLD_SYNC_DIR, self.folder_mode).values()] - self.assertEqual(2, len(remote_paths), remote_paths) - self.assertNotIn(".cld-sync", remote_paths) - @retry_assertion def test_cli_sync_push_dry_run(self): self._upload_sync_files(TEST_FILES_DIR)