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.
- {cloudinary-cli-1.10.0/cloudinary_cli.egg-info → cloudinary_cli-1.10.2}/PKG-INFO +1 -1
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/modules/sync.py +116 -10
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/utils/api_utils.py +26 -9
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/utils/file_utils.py +15 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/utils/utils.py +19 -2
- cloudinary_cli-1.10.2/cloudinary_cli/version.py +1 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2/cloudinary_cli.egg-info}/PKG-INFO +1 -1
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/helper_test.py +9 -2
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_modules/test_cli_sync.py +51 -4
- cloudinary-cli-1.10.0/cloudinary_cli/version.py +0 -1
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/LICENSE +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/MANIFEST.in +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/README.md +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/__init__.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/cli.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/cli_group.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/core/__init__.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/core/admin.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/core/config.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/core/overrides.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/core/provisioning.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/core/search.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/core/uploader.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/core/utils.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/defaults.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/modules/__init__.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/modules/make.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/modules/migrate.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/modules/regen_derived.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/modules/upload_dir.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/samples/__init__.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/html/media_library_widget +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/html/product_gallery +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/html/upload_widget +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/html/video_player +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/node/upload +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/python/base +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/python/explicit +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/python/find_all_empty_folders +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/python/upload +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/ruby/upload +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/utils/__init__.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/utils/config_utils.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/utils/json_utils.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli.egg-info/SOURCES.txt +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli.egg-info/dependency_links.txt +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli.egg-info/entry_points.txt +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli.egg-info/not-zip-safe +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli.egg-info/requires.txt +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli.egg-info/top_level.txt +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/requirements.txt +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/setup.cfg +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/setup.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/__init__.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_cli.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_cli_api.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_cli_config.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_cli_samples.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_cli_search_api.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_cli_url.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_cli_utils.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_file_utils.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_modules/__init__.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_modules/test_cli_make.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_modules/test_cli_upload_dir.py +0 -0
- {cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/test/test_utils.py +0 -0
|
@@ -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
|
-
|
|
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 =
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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"
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/html/media_library_widget
RENAMED
|
File without changes
|
{cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli/templates/html/product_gallery
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cloudinary-cli-1.10.0 → cloudinary_cli-1.10.2}/cloudinary_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|