cloudinary-cli 1.10.0__tar.gz → 1.10.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {cloudinary-cli-1.10.0/cloudinary_cli.egg-info → cloudinary_cli-1.10.2}/PKG-INFO +1 -1
  2. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/modules/sync.py +116 -10
  3. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/utils/api_utils.py +26 -9
  4. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/utils/file_utils.py +15 -0
  5. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/utils/utils.py +19 -2
  6. cloudinary_cli-1.10.2/cloudinary_cli/version.py +1 -0
  7. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2/cloudinary_cli.egg-info}/PKG-INFO +1 -1
  8. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/helper_test.py +9 -2
  9. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_modules/test_cli_sync.py +51 -4
  10. cloudinary-cli-1.10.0/cloudinary_cli/version.py +0 -1
  11. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/LICENSE +0 -0
  12. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/MANIFEST.in +0 -0
  13. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/README.md +0 -0
  14. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/__init__.py +0 -0
  15. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/cli.py +0 -0
  16. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/cli_group.py +0 -0
  17. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/core/__init__.py +0 -0
  18. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/core/admin.py +0 -0
  19. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/core/config.py +0 -0
  20. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/core/overrides.py +0 -0
  21. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/core/provisioning.py +0 -0
  22. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/core/search.py +0 -0
  23. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/core/uploader.py +0 -0
  24. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/core/utils.py +0 -0
  25. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/defaults.py +0 -0
  26. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/modules/__init__.py +0 -0
  27. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/modules/make.py +0 -0
  28. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/modules/migrate.py +0 -0
  29. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/modules/regen_derived.py +0 -0
  30. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/modules/upload_dir.py +0 -0
  31. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/samples/__init__.py +0 -0
  32. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/html/media_library_widget +0 -0
  33. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/html/product_gallery +0 -0
  34. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/html/upload_widget +0 -0
  35. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/html/video_player +0 -0
  36. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/node/upload +0 -0
  37. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/python/base +0 -0
  38. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/python/explicit +0 -0
  39. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/python/find_all_empty_folders +0 -0
  40. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/python/upload +0 -0
  41. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/ruby/upload +0 -0
  42. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/utils/__init__.py +0 -0
  43. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/utils/config_utils.py +0 -0
  44. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/utils/json_utils.py +0 -0
  45. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli.egg-info/SOURCES.txt +0 -0
  46. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli.egg-info/dependency_links.txt +0 -0
  47. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli.egg-info/entry_points.txt +0 -0
  48. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli.egg-info/not-zip-safe +0 -0
  49. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli.egg-info/requires.txt +0 -0
  50. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli.egg-info/top_level.txt +0 -0
  51. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/requirements.txt +0 -0
  52. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/setup.cfg +0 -0
  53. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/setup.py +0 -0
  54. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/__init__.py +0 -0
  55. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_cli.py +0 -0
  56. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_cli_api.py +0 -0
  57. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_cli_config.py +0 -0
  58. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_cli_samples.py +0 -0
  59. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_cli_search_api.py +0 -0
  60. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_cli_url.py +0 -0
  61. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_cli_utils.py +0 -0
  62. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_file_utils.py +0 -0
  63. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_modules/__init__.py +0 -0
  64. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_modules/test_cli_make.py +0 -0
  65. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_modules/test_cli_upload_dir.py +0 -0
  66. {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cloudinary-cli
3
- Version: 1.10.0
3
+ Version: 1.10.2
4
4
  Summary: A command line interface for Cloudinary with full API support
5
5
  Home-page: https://github.com/cloudinary/cloudinary-cli
6
6
  Author: Cloudinary, Brian Luk
@@ -1,4 +1,6 @@
1
1
  import logging
2
+ import os.path
3
+ import re
2
4
  from collections import Counter
3
5
  from itertools import groupby
4
6
  from os import path, remove
@@ -7,11 +9,12 @@ from click import command, argument, option, style, UsageError, Choice
7
9
  from cloudinary import api
8
10
 
9
11
  from cloudinary_cli.utils.api_utils import query_cld_folder, upload_file, download_file, get_folder_mode, \
10
- get_default_upload_options, get_destination_folder_options
11
- from cloudinary_cli.utils.file_utils import walk_dir, delete_empty_dirs, normalize_file_extension, posix_rel_path
12
+ get_default_upload_options, get_destination_folder_options, cld_folder_exists
13
+ from cloudinary_cli.utils.file_utils import (walk_dir, delete_empty_dirs, normalize_file_extension, posix_rel_path,
14
+ populate_duplicate_name)
12
15
  from cloudinary_cli.utils.json_utils import print_json, read_json_from_file, write_json_to_file
13
16
  from cloudinary_cli.utils.utils import logger, run_tasks_concurrently, get_user_action, invert_dict, chunker, \
14
- group_params, parse_option_value
17
+ group_params, parse_option_value, duplicate_values
15
18
 
16
19
  _DEFAULT_DELETION_BATCH_SIZE = 30
17
20
  _DEFAULT_CONCURRENT_WORKERS = 30
@@ -52,7 +55,8 @@ def sync(local_folder, cloudinary_folder, push, pull, include_hidden, concurrent
52
55
  elif pull:
53
56
  result = sync_dir.pull()
54
57
 
55
- logger.info("Done!")
58
+ if result:
59
+ logger.info("Done!")
56
60
 
57
61
  return result
58
62
 
@@ -78,12 +82,35 @@ class SyncDir:
78
82
 
79
83
  self.verbose = logger.getEffectiveLevel() < logging.INFO
80
84
 
81
- self.local_files = walk_dir(path.abspath(self.local_dir), include_hidden)
82
- logger.info(f"Found {len(self.local_files)} items in local folder '{local_dir}'")
83
-
84
- self.remote_files = query_cld_folder(self.remote_dir, self.folder_mode)
85
- logger.info(f"Found {len(self.remote_files)} items in Cloudinary folder '{self.user_friendly_remote_dir}' "
86
- f"({self.folder_mode} folder mode)")
85
+ self.local_files = {}
86
+ self.local_folder_exists = os.path.isdir(path.abspath(self.local_dir))
87
+ if not self.local_folder_exists:
88
+ logger.info(f"Local folder '{self.local_dir}' does not exist.")
89
+ else:
90
+ self.local_files = walk_dir(path.abspath(self.local_dir), include_hidden)
91
+ if len(self.local_files):
92
+ logger.info(f"Found {len(self.local_files)} items in local folder '{self.local_dir}'")
93
+ else:
94
+ logger.info(f"Local folder '{self.local_dir}' is empty.")
95
+
96
+ raw_remote_files = {}
97
+ self.cld_folder_exists = cld_folder_exists(self.remote_dir)
98
+ if not self.cld_folder_exists:
99
+ logger.info(f"Cloudinary folder '{self.user_friendly_remote_dir}' does not exist "
100
+ f"({self.folder_mode} folder mode).")
101
+ else:
102
+ raw_remote_files = query_cld_folder(self.remote_dir, self.folder_mode)
103
+ if len(raw_remote_files):
104
+ logger.info(
105
+ f"Found {len(raw_remote_files)} items in Cloudinary folder '{self.user_friendly_remote_dir}' "
106
+ f"({self.folder_mode} folder mode).")
107
+ else:
108
+ logger.info(f"Cloudinary folder '{self.user_friendly_remote_dir}' is empty. "
109
+ f"({self.folder_mode} folder mode)")
110
+
111
+ self.remote_files = self._normalize_remote_file_names(raw_remote_files, self.local_files)
112
+ self.remote_duplicate_names = duplicate_values(self.remote_files, "normalized_path", "asset_id")
113
+ self._print_duplicate_file_names()
87
114
 
88
115
  local_file_names = self.local_files.keys()
89
116
  remote_file_names = self.remote_files.keys()
@@ -94,10 +121,14 @@ class SyncDir:
94
121
  Usually Cloudinary sanitizes those file names and strips invalid characters. Although it is a good best effort
95
122
  for a general use case, when syncing local folder with Cloudinary, it is not the best option, since directories
96
123
  will be always out-of-sync.
124
+
125
+ In addition in dynamic folder mode Cloudinary allows having identical display names for differrent files.
97
126
 
98
127
  To overcome this limitation, cloudinary-cli keeps .cld-sync hidden file in the sync directory that contains a
99
128
  mapping of the diverse file names. This file keeps tracking of the files and allows syncing in both directions.
100
129
  """
130
+
131
+ # handle fixed folder mode public_id differences
101
132
  diverse_file_names = read_json_from_file(self.sync_meta_file, does_not_exist_ok=True)
102
133
  self.diverse_file_names = dict(
103
134
  (normalize_file_extension(k), normalize_file_extension(v)) for k, v in diverse_file_names.items())
@@ -124,6 +155,11 @@ class SyncDir:
124
155
  """
125
156
  Pushes changes from the local folder to the Cloudinary folder.
126
157
  """
158
+
159
+ if not self.local_folder_exists:
160
+ logger.error(f"Cannot push a non-existent local folder '{self.local_dir}'. Aborting...")
161
+ return False
162
+
127
163
  if not self._handle_unique_remote_files():
128
164
  logger.info("Aborting...")
129
165
  return False
@@ -163,6 +199,12 @@ class SyncDir:
163
199
  """
164
200
  Pulls changes from the Cloudinary folder to the local folder.
165
201
  """
202
+
203
+ if not self.cld_folder_exists:
204
+ logger.error(f"Cannot pull from a non-existent Cloudinary folder '{self.user_friendly_remote_dir}' "
205
+ f"({self.folder_mode} folder mode). Aborting...")
206
+ return False
207
+
166
208
  download_results = {}
167
209
  download_errors = {}
168
210
  if not self._handle_unique_local_files():
@@ -189,6 +231,70 @@ class SyncDir:
189
231
  if download_errors:
190
232
  raise Exception("Sync did not finish successfully")
191
233
 
234
+ def _normalize_remote_file_names(self, remote_files, local_files):
235
+ """
236
+ When multiple remote files have duplicate display name, we save them locally by appending index at the end
237
+ of the base name, e.g. Image (1).jpg, Image (2).jpg, etc.
238
+
239
+ For consistency, we sort files by `created_at` date.
240
+
241
+ For partially synced files, when a remote file in the middle was deleted, we want to avoid resync
242
+ of the remaining files.
243
+
244
+ For example, if we had: Image (1), Image (2),..., Image(5), Image (10) on Cloudinary.
245
+ If we delete "Image (2)" and resync - that would cause all files from Image (3) to Image (10) to be resynced.
246
+ (Image (3) would become Image (2), ... Image (10) -> Image (9))
247
+
248
+ Instead, since those indexes are arbitrary, we map local files to the remote files by etag (md5sum).
249
+ Synced files will keep their indexes, out-of-sync files will be synced.
250
+
251
+ :param remote_files: Remote files.
252
+ :param local_files: Local files.
253
+ :return:
254
+ """
255
+ duplicate_ids = duplicate_values(remote_files, "normalized_path")
256
+ for duplicate_name, asset_ids in duplicate_ids.items():
257
+ duplicate_dts = sorted([remote_files[asset_id] for asset_id in asset_ids], key=lambda f: f['created_at'])
258
+ local_candidates = self._local_candidates(duplicate_name)
259
+ remainng_duplicate_dts = []
260
+ for duplicate_dt in duplicate_dts:
261
+ matched_name = next((f for f in local_candidates.keys() if local_candidates[f] == duplicate_dt["etag"]),
262
+ None)
263
+ if matched_name is None:
264
+ remainng_duplicate_dts.append(duplicate_dt)
265
+ continue
266
+ # found local synced file.
267
+ remote_files[duplicate_dt["asset_id"]]["normalized_unique_path"] = matched_name
268
+ local_candidates.pop(matched_name)
269
+
270
+ unique_paths = {v["normalized_unique_path"] for v in remote_files.values()}
271
+ curr_index = 0
272
+ for dup in remainng_duplicate_dts:
273
+ # here we check for collisions with other existing files.
274
+ # remote file can have both "Image.jpg" and "Image (1).jpg", which are valid names, skip those.
275
+ candidate_path = populate_duplicate_name(dup['normalized_path'], curr_index)
276
+ while candidate_path in unique_paths:
277
+ curr_index += 1
278
+ candidate_path = populate_duplicate_name(dup['normalized_path'], curr_index)
279
+ remote_files[dup["asset_id"]]["normalized_unique_path"] = candidate_path
280
+ curr_index += 1
281
+
282
+ return {dt["normalized_unique_path"]: dt for dt in remote_files.values()}
283
+
284
+ def _local_candidates(self, candidate_path):
285
+ filename, extension = path.splitext(candidate_path)
286
+ r = re.compile(f"({candidate_path}|{filename} \(\d+\){extension})")
287
+ # sort local files by base name (without ext) for accurate results.
288
+ return dict(sorted({f: self.local_files[f]["etag"] for f in filter(r.match, self.local_files.keys())}.items(),
289
+ key=lambda f: path.splitext(f[0])[0]))
290
+
291
+ def _print_duplicate_file_names(self):
292
+ if (len(self.remote_duplicate_names) > 0):
293
+ logger.warning(f"Cloudinary folder '{self.user_friendly_remote_dir}' "
294
+ f"contains {len(self.remote_duplicate_names)} duplicate asset names")
295
+ for normalized_path, asset_ids in self.remote_duplicate_names.items():
296
+ logger.debug(f"Duplicate name: '{normalized_path}', asset ids: {', '.join(asset_ids)}")
297
+
192
298
  def _print_sync_status(self, success, errors):
193
299
  logger.info("==Sync Status==")
194
300
  logger.info("===============")
@@ -3,15 +3,16 @@ from os import path, makedirs
3
3
 
4
4
  import requests
5
5
  from click import style, launch
6
- from cloudinary import Search, uploader, api
6
+ from cloudinary import Search, SearchFolders, uploader, api
7
7
  from cloudinary.utils import cloudinary_url
8
8
 
9
9
  from cloudinary_cli.defaults import logger
10
10
  from cloudinary_cli.utils.config_utils import is_valid_cloudinary_config
11
- from cloudinary_cli.utils.file_utils import normalize_file_extension, posix_rel_path, get_destination_folder
11
+ from cloudinary_cli.utils.file_utils import (normalize_file_extension, posix_rel_path, get_destination_folder,
12
+ populate_duplicate_name)
12
13
  from cloudinary_cli.utils.json_utils import print_json, write_json_to_file
13
14
  from cloudinary_cli.utils.utils import log_exception, confirm_action, get_command_params, merge_responses, \
14
- normalize_list_params, ConfigurationError, print_api_help
15
+ normalize_list_params, ConfigurationError, print_api_help, duplicate_values
15
16
 
16
17
  PAGINATION_MAX_RESULTS = 500
17
18
 
@@ -34,7 +35,11 @@ def query_cld_folder(folder, folder_mode):
34
35
  rel_path = _relative_path(asset, folder)
35
36
  rel_display_path = _relative_display_path(asset, folder)
36
37
  path_key = rel_display_path if folder_mode == "dynamic" else rel_path
37
- files[normalize_file_extension(path_key)] = {
38
+ normalized_path_key = normalize_file_extension(path_key)
39
+ files[asset["asset_id"]] = {
40
+ "asset_id": asset['asset_id'],
41
+ "normalized_path": normalized_path_key,
42
+ "normalized_unique_path": normalized_path_key,
38
43
  "type": asset['type'],
39
44
  "resource_type": asset['resource_type'],
40
45
  "public_id": asset['public_id'],
@@ -42,6 +47,7 @@ def query_cld_folder(folder, folder_mode):
42
47
  "etag": asset.get('etag', '0'),
43
48
  "relative_path": rel_path, # save for inner use
44
49
  "access_mode": asset.get('access_mode', 'public'),
50
+ "created_at": asset.get('created_at'),
45
51
  # dynamic folder mode fields
46
52
  "asset_folder": asset.get('asset_folder'),
47
53
  "display_name": asset.get('display_name'),
@@ -54,11 +60,22 @@ def query_cld_folder(folder, folder_mode):
54
60
  return files
55
61
 
56
62
 
63
+ def cld_folder_exists(folder):
64
+ folder = folder.strip('/') # omit redundant leading slash and duplicate trailing slashes in query
65
+
66
+ if not folder:
67
+ return True # root folder
68
+
69
+ res = SearchFolders().expression(f"path=\"{folder}\"").execute()
70
+
71
+ return res.get("total_count", 0) > 0
72
+
73
+
57
74
  def _display_path(asset):
58
75
  if asset.get("display_name") is None:
59
76
  return ""
60
77
 
61
- return "/".join([asset.get("asset_folder", ""), ".".join([asset["display_name"], asset["format"]])])
78
+ return "/".join([asset.get("asset_folder", ""), ".".join(filter(None, [asset["display_name"], asset.get("format", None)]))])
62
79
 
63
80
 
64
81
  def _relative_display_path(asset, folder):
@@ -80,9 +97,9 @@ def regen_derived_version(public_id, delivery_type, res_type,
80
97
  eager_trans, eager_async,
81
98
  eager_notification_url):
82
99
  options = {"type": delivery_type, "resource_type": res_type,
83
- "eager": eager_trans, "eager_async": eager_async,
84
- "eager_notification_url": eager_notification_url,
85
- "overwrite": True, "invalidate": True}
100
+ "eager": eager_trans, "eager_async": eager_async,
101
+ "eager_notification_url": eager_notification_url,
102
+ "overwrite": True, "invalidate": True}
86
103
  try:
87
104
  exp_res = uploader.explicit(public_id, **options)
88
105
  derived_url = f'{exp_res.get("eager")[0].get("secure_url")}'
@@ -224,7 +241,7 @@ def call_api(func, args, kwargs):
224
241
  try:
225
242
  return func(*args, **kwargs)
226
243
  except Exception as e:
227
- log_exception(e, f"Failed calling '{func.__name__}' with args: {args} and optional args {kwargs}")
244
+ log_exception(e, debug_message=f"Failed calling '{func.__name__}' with args: {args} and optional args {kwargs}")
228
245
  raise
229
246
 
230
247
 
@@ -130,6 +130,21 @@ def normalize_file_extension(filename: str) -> str:
130
130
  return ".".join([p for p in [filename, extension_alias] if p])
131
131
 
132
132
 
133
+ def populate_duplicate_name(filename, index=0):
134
+ """
135
+ Adds index to the filename in order to avoid duplicates.
136
+
137
+ :param filename: The file name to modify.
138
+ :param index: The desired index.
139
+ :return: Modified file name.
140
+ """
141
+ filename, extension = os.path.splitext(filename)
142
+ if index != 0:
143
+ filename = f"{filename} ({index})"
144
+
145
+ return ".".join([p for p in [filename, extension[1:]] if p])
146
+
147
+
133
148
  def posix_rel_path(end, start) -> str:
134
149
  """
135
150
  Returns a relative path in posix style on any system.
@@ -68,9 +68,10 @@ def print_api_help(api, block_list=not_callable, allow_list=()):
68
68
  logger.info(get_help_str(api, block_list=block_list, allow_list=allow_list))
69
69
 
70
70
 
71
- def log_exception(e, message=None):
71
+ def log_exception(e, message=None, debug_message=None):
72
72
  message = f"{message}, error: {str(e)}" if message is not None else str(e)
73
- logger.debug(message, exc_info=True)
73
+ debug_message = debug_message or message
74
+ logger.debug(debug_message, exc_info=True)
74
75
  logger.error(message)
75
76
 
76
77
 
@@ -333,3 +334,19 @@ def chunker(seq, size):
333
334
  :return: a single chunk
334
335
  """
335
336
  return (seq[pos:pos + size] for pos in range(0, len(seq), size))
337
+
338
+
339
+ def duplicate_values(items, value_key, key_of_interest=None):
340
+ """
341
+ Finds duplicate values in a dictionary of objects.
342
+
343
+ :param items: All items.
344
+ :param value_key: The duplicate value key to search.
345
+ :param key_of_interest: The key to add to the resulting list.
346
+ :return:
347
+ """
348
+ rev_multidict = {}
349
+ for key, value in items.items():
350
+ rev_multidict.setdefault(value[value_key], set()).add(value[key_of_interest] if key_of_interest is not None else key)
351
+
352
+ return {key: values for key, values in rev_multidict.items() if len(values) > 1}
@@ -0,0 +1 @@
1
+ __version__ = "1.10.2"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cloudinary-cli
3
- Version: 1.10.0
3
+ Version: 1.10.2
4
4
  Summary: A command line interface for Cloudinary with full API support
5
5
  Home-page: https://github.com/cloudinary/cloudinary-cli
6
6
  Author: Cloudinary, Brian Luk
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
 
8
8
  import cloudinary.api
9
9
  from cloudinary import logger
10
+ from cloudinary_cli.utils.api_utils import query_cld_folder
10
11
  from urllib3 import HTTPResponse, disable_warnings
11
12
  from urllib3._collections import HTTPHeaderDict
12
13
 
@@ -116,8 +117,14 @@ def retry_assertion(num_tries=3, delay=3):
116
117
  return retry_decorator
117
118
 
118
119
 
119
- def delete_cld_folder_if_exists(folder):
120
- cloudinary.api.delete_resources_by_prefix(folder)
120
+ def delete_cld_folder_if_exists(folder, folder_mode = "fixed"):
121
+ if folder_mode == "fixed":
122
+ cloudinary.api.delete_resources_by_prefix(folder)
123
+ else:
124
+ assets = query_cld_folder(folder, folder_mode)
125
+ if (len(assets)):
126
+ cloudinary.api.delete_resources([f["public_id"] for f in assets.values()])
127
+
121
128
  try:
122
129
  cloudinary.api.delete_folder(folder)
123
130
  except cloudinary.exceptions.NotFound:
@@ -10,6 +10,7 @@ from cloudinary_cli.cli import cli
10
10
  from test.helper_test import unique_suffix, RESOURCES_DIR, TEST_FILES_DIR, delete_cld_folder_if_exists, retry_assertion, \
11
11
  get_request_url, get_params, URLLIB3_REQUEST
12
12
  from test.test_modules.test_cli_upload_dir import UPLOAD_MOCK_RESPONSE
13
+ from cloudinary_cli.utils.api_utils import get_folder_mode
13
14
 
14
15
 
15
16
  class TestCLISync(unittest.TestCase):
@@ -19,14 +20,19 @@ class TestCLISync(unittest.TestCase):
19
20
  LOCAL_SYNC_PULL_DIR = str(Path.joinpath(RESOURCES_DIR, unique_suffix("test_sync_pull")))
20
21
  CLD_SYNC_DIR = unique_suffix("test_sync")
21
22
 
23
+ DUPLICATE_NAME = unique_suffix("duplicate_name")
24
+
22
25
  GRACE_PERIOD = 3 # seconds
23
26
 
27
+ folder_mode = "fixed"
28
+
24
29
  def setUp(self) -> None:
25
- delete_cld_folder_if_exists(self.CLD_SYNC_DIR)
30
+ self.folder_mode = get_folder_mode()
31
+ delete_cld_folder_if_exists(self.CLD_SYNC_DIR, self.folder_mode)
26
32
  time.sleep(1)
27
33
 
28
34
  def tearDown(self) -> None:
29
- delete_cld_folder_if_exists(self.CLD_SYNC_DIR)
35
+ delete_cld_folder_if_exists(self.CLD_SYNC_DIR, self.folder_mode)
30
36
  time.sleep(1)
31
37
  shutil.rmtree(self.LOCAL_SYNC_PULL_DIR, ignore_errors=True)
32
38
 
@@ -38,6 +44,13 @@ class TestCLISync(unittest.TestCase):
38
44
  self.assertIn("Synced | 12", result.output)
39
45
  self.assertIn("Done!", result.output)
40
46
 
47
+ def test_cli_sync_push_non_existing_folder(self):
48
+ non_existing_dir = self.LOCAL_SYNC_PULL_DIR + "non_existing"
49
+ result = self.runner.invoke(cli, ['sync', '--push', non_existing_dir, self.CLD_SYNC_DIR])
50
+
51
+ self.assertIn(f"Cannot push a non-existent local folder '{non_existing_dir}'", result.output)
52
+ self.assertIn("Aborting...", result.output)
53
+
41
54
  @retry_assertion
42
55
  def test_cli_sync_push_twice(self):
43
56
  self._upload_sync_files(TEST_FILES_DIR)
@@ -81,6 +94,14 @@ class TestCLISync(unittest.TestCase):
81
94
  self.assertIn("Synced | 12", result.output)
82
95
  self.assertIn("Done!", result.output)
83
96
 
97
+
98
+ def test_cli_sync_pull_non_existing_folder(self):
99
+ non_existing_dir = self.CLD_SYNC_DIR + "non_existing"
100
+ result = self.runner.invoke(cli, ['sync', '--pull', self.LOCAL_SYNC_PULL_DIR, non_existing_dir])
101
+
102
+ self.assertIn(f"Cannot pull from a non-existent Cloudinary folder '{non_existing_dir}'", result.output)
103
+ self.assertIn("Aborting...", result.output)
104
+
84
105
  @retry_assertion
85
106
  def test_cli_sync_pull_twice(self):
86
107
  self._upload_sync_files(TEST_FILES_DIR)
@@ -119,8 +140,10 @@ class TestCLISync(unittest.TestCase):
119
140
  self.assertIn("Synced | 11", result.output)
120
141
  self.assertIn("Done!", result.output)
121
142
 
122
- def _upload_sync_files(self, dir):
123
- result = self.runner.invoke(cli, ['sync', '--push', '-F', dir, self.CLD_SYNC_DIR])
143
+ def _upload_sync_files(self, dir, optional_params=None):
144
+ if optional_params is None:
145
+ optional_params = []
146
+ result = self.runner.invoke(cli, ['sync', '--push', '-F', dir, self.CLD_SYNC_DIR] + optional_params)
124
147
 
125
148
  self.assertEqual(0, result.exit_code)
126
149
  self.assertIn("Synced | 12", result.output)
@@ -137,3 +160,27 @@ class TestCLISync(unittest.TestCase):
137
160
 
138
161
  self.assertIn("raw/upload", get_request_url(mocker))
139
162
  self.assertTrue(get_params(mocker)['unique_filename'])
163
+
164
+
165
+ @unittest.skipUnless(get_folder_mode() == "dynamic", "requires dynamic folder mode")
166
+ @retry_assertion
167
+ def test_cli_sync_duplicate_file_names_dynamic_folder_mode(self):
168
+ self._upload_sync_files(TEST_FILES_DIR, ['-o', 'display_name', self.DUPLICATE_NAME])
169
+
170
+ # wait for indexing to be updated
171
+ time.sleep(self.GRACE_PERIOD)
172
+
173
+ result = self.runner.invoke(cli, ['sync', '--pull', '-F', self.LOCAL_SYNC_PULL_DIR, self.CLD_SYNC_DIR])
174
+
175
+ self.assertEqual(0, result.exit_code)
176
+ self.assertIn("Found 0 items in local folder", result.output)
177
+ self.assertIn("Downloading 12 files", result.output)
178
+ for index in range(1, 6):
179
+ self.assertIn(f"{self.DUPLICATE_NAME} ({index})", result.output)
180
+ self.assertIn("Done!", result.output)
181
+
182
+ result = self.runner.invoke(cli, ['sync', '--push', '-F', self.LOCAL_SYNC_PULL_DIR, self.CLD_SYNC_DIR])
183
+
184
+ self.assertEqual(0, result.exit_code)
185
+ self.assertIn("Skipping 12 items", result.output)
186
+ self.assertIn("Done!", result.output)
@@ -1 +0,0 @@
1
- __version__ = "1.10.0"
File without changes