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/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()}: diff --git a/test/test_modules/test_cli_sync.py b/test/test_modules/test_cli_sync.py index 36ccac4..177503b 100644 --- a/test/test_modules/test_cli_sync.py +++ b/test/test_modules/test_cli_sync.py @@ -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) @@ -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()