Skip to content

Commit 820c889

Browse files
Merge branch 'master' into devx-16946-smd-for-clone
2 parents b7486b1 + 4b64e8c commit 820c889

File tree

2 files changed

+461
-49
lines changed

2 files changed

+461
-49
lines changed

cloudinary_cli/modules/clone.py

Lines changed: 177 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
from click import command, option, style, argument
22
from cloudinary_cli.utils.utils import normalize_list_params, print_help_and_exit
33
import cloudinary
4+
from cloudinary.auth_token import _digest
45
from cloudinary_cli.utils.utils import run_tasks_concurrently
56
from cloudinary_cli.utils.api_utils import upload_file, handle_api_command
67
from cloudinary_cli.utils.json_utils import print_json
7-
from cloudinary_cli.utils.config_utils import load_config, get_cloudinary_config, config_to_dict, config_to_tuple_list
8+
from cloudinary_cli.utils.config_utils import get_cloudinary_config, config_to_dict, config_to_tuple_list
89
from cloudinary_cli.defaults import logger
910
from cloudinary_cli.core.search import execute_single_request, handle_auto_pagination
11+
import time
12+
import re
1013

1114
DEFAULT_MAX_RESULTS = 500
15+
ALLOWED_TYPE_VALUES = ("upload", "private", "authenticated")
1216

1317

1418
@command("clone",
@@ -17,7 +21,6 @@
1721
\b
1822
Clone assets from one product environment to another with/without tags and/or context (structured metadata is not currently supported).
1923
Source will be your `CLOUDINARY_URL` environment variable but you also can specify a different source using the `-c/-C` option.
20-
Cloning restricted assets is also not supported currently.
2124
Format: cld clone <target_environment> <command options>
2225
`<target_environment>` can be a CLOUDINARY_URL or a saved config (see `config` command)
2326
Example 1 (Copy all assets including tags and context using CLOUDINARY URL):
@@ -33,28 +36,63 @@
3336
@option("-w", "--concurrent_workers", type=int, default=30,
3437
help="Specify the number of concurrent network threads.")
3538
@option("-fi", "--fields", multiple=True,
36-
help="Specify whether to copy tags and/or context. Valid options: `tags,context,metadata`.")
39+
help=("Specify whether to copy tags and/or context. "
40+
"Valid options: `tags,context,metadata`."))
3741
@option("-se", "--search_exp", default="",
3842
help="Define a search expression to filter the assets to clone.")
3943
@option("--async", "async_", is_flag=True, default=False,
4044
help="Clone the assets asynchronously.")
4145
@option("-nu", "--notification_url",
4246
help="Webhook notification URL.")
43-
def clone(target, force, overwrite, concurrent_workers, fields, search_exp, async_, notification_url):
47+
@option("-ue", "--url_expiry", type=int, default=3600,
48+
help=("URL expiration duration in seconds. Only relevant if cloning "
49+
"restricted assets with an auth_key configured. "
50+
"If you do not provide an auth_key, "
51+
"a private download URL is generated which may incur additional "
52+
"bandwidth costs."))
53+
def clone(target, force, overwrite, concurrent_workers, fields,
54+
search_exp, async_, notification_url, url_expiry):
55+
target_config, auth_token = _validate_clone_inputs(target)
56+
if not target_config:
57+
return False
58+
59+
source_assets = search_assets(search_exp, force)
60+
if not source_assets:
61+
return False
62+
if not isinstance(source_assets, dict) or not source_assets.get('resources'):
63+
logger.error(style(f"No asset(s) found in {cloudinary.config().cloud_name}", fg="red"))
64+
return False
65+
66+
upload_list = _prepare_upload_list(
67+
source_assets, target_config, overwrite, async_,
68+
notification_url, auth_token, url_expiry, fields
69+
)
70+
71+
logger.info(style(f"Copying {len(upload_list)} asset(s) from "
72+
f"{cloudinary.config().cloud_name} to "
73+
f"{target_config.cloud_name}", fg="blue"))
74+
75+
run_tasks_concurrently(upload_file, upload_list, concurrent_workers)
76+
77+
return True
78+
79+
80+
def _validate_clone_inputs(target):
4481
if not target:
4582
print_help_and_exit()
4683

4784
target_config = get_cloudinary_config(target)
4885
if not target_config:
49-
logger.error("The specified config does not exist or the CLOUDINARY_URL scheme provided is invalid"
50-
" (expecting to start with 'cloudinary://').")
51-
return False
86+
logger.error("The specified config does not exist or the "
87+
"CLOUDINARY_URL scheme provided is invalid "
88+
"(expecting to start with 'cloudinary://').")
89+
return None, None
5290

5391
if cloudinary.config().cloud_name == target_config.cloud_name:
54-
logger.error("Target environment cannot be the same as source environment.")
55-
return False
92+
logger.error("Target environment cannot be the same "
93+
"as source environment.")
94+
return None, None
5695

57-
source_assets = search_assets(force, search_exp)
5896
if 'metadata' in fields:
5997
source_metadata = list_metadata_items("metadata_fields")
6098
if source_metadata.get('metadata_fields'):
@@ -69,34 +107,48 @@ def clone(target, force, overwrite, concurrent_workers, fields, search_exp, asyn
69107
else:
70108
logger.info(style(f"No metadata found in {cloudinary.config().cloud_name}", fg="yellow"))
71109

110+
auth_token = cloudinary.config().auth_token
111+
if auth_token:
112+
# It is important to validate auth_token if provided as this prevents
113+
# customer from having to re-run the command as well as
114+
# saving Admin API calls and time.
115+
try:
116+
cloudinary.utils.generate_auth_token(acl="/image/*")
117+
except Exception as e:
118+
logger.error(f"{e} - auth_token validation failed. "
119+
"Please double-check your auth_token parameters.")
120+
return None, None
121+
122+
return target_config, auth_token
123+
124+
125+
def _prepare_upload_list(source_assets, target_config, overwrite, async_,
126+
notification_url, auth_token, url_expiry, fields):
72127
upload_list = []
73128
for r in source_assets.get('resources'):
74-
updated_options, asset_url = process_metadata(r, overwrite, async_, notification_url,
129+
updated_options, asset_url = process_metadata(r, overwrite, async_,
130+
notification_url,
131+
auth_token, url_expiry,
75132
normalize_list_params(fields))
76133
updated_options.update(config_to_dict(target_config))
77134
upload_list.append((asset_url, {**updated_options}))
78-
if not upload_list:
79-
logger.error(style(f"No assets found in {cloudinary.config().cloud_name}", fg="red"))
80-
return False
81-
82-
logger.info(style(f"Copying {len(upload_list)} asset(s) from {cloudinary.config().cloud_name} to {target_config.cloud_name}", fg="blue"))
83-
84-
run_tasks_concurrently(upload_file, upload_list, concurrent_workers)
85-
86-
return True
135+
return upload_list
87136

137+
def search_assets(search_exp, force):
138+
search_exp = _normalize_search_expression(search_exp)
139+
if not search_exp:
140+
return False
88141

89-
def search_assets(force, search_exp):
90142
search = cloudinary.search.Search().expression(search_exp)
91-
search.fields(['tags', 'context', 'access_control', 'secure_url', 'display_name','metadata'])
143+
search.fields(['tags', 'context', 'access_control',
144+
'secure_url', 'display_name', 'metadata', 'format'])
92145
search.max_results(DEFAULT_MAX_RESULTS)
93146

94147
res = execute_single_request(search, fields_to_keep="")
95148
res = handle_auto_pagination(res, search, force, fields_to_keep="")
96149

97150
return res
98151

99-
100152
def list_metadata_items(method_key, *options):
101153
api_method_name = 'list_' + method_key
102154
params = [api_method_name]
@@ -170,32 +222,110 @@ def compare_create_metadata_items(json_source, json_target, target_config, key,
170222
"differences": diffs
171223
}
172224

173-
174-
def process_metadata(res, overwrite, async_, notification_url, copy_fields=""):
175-
cloned_options = {}
176-
asset_url = res.get('secure_url')
177-
cloned_options['public_id'] = res.get('public_id')
178-
cloned_options['type'] = res.get('type')
179-
cloned_options['resource_type'] = res.get('resource_type')
180-
cloned_options['overwrite'] = overwrite
181-
cloned_options['async'] = async_
182-
if "tags" in copy_fields:
183-
cloned_options['tags'] = res.get('tags')
184-
if "context" in copy_fields:
185-
cloned_options['context'] = res.get('context')
186-
if "metadata" in copy_fields:
187-
cloned_options['metadata'] = res.get('metadata')
225+
def _normalize_search_expression(search_exp):
226+
"""
227+
Ensures the search expression has a valid 'type' filter.
228+
229+
- If no expression is given, a default is created.
230+
- If 'type' filters exist, they are validated.
231+
- If no 'type' filters exist, the default is appended.
232+
"""
233+
default_types_str = " OR ".join(f"type:{t}" for t in ALLOWED_TYPE_VALUES)
234+
235+
if not search_exp:
236+
return default_types_str
237+
238+
# Use a simple regex to find all 'type' filters
239+
found_types = re.findall(r"\btype\s*[:=]\s*(\w+)", search_exp)
240+
241+
if not found_types:
242+
# No 'type' filter found, so append the default
243+
return f"{search_exp} AND ({default_types_str})"
244+
245+
# A 'type' filter was found, so validate it
246+
invalid_types = {t for t in found_types if t not in ALLOWED_TYPE_VALUES}
247+
248+
if invalid_types:
249+
error_msg = ", ".join(f"type:{t}" for t in invalid_types)
250+
logger.error(
251+
f"Unsupported type(s) in search expression: {error_msg}. "
252+
f"Only {', '.join(ALLOWED_TYPE_VALUES)} types allowed."
253+
)
254+
return None
255+
256+
# All found types are valid, so return the original expression
257+
return search_exp
258+
259+
260+
def process_metadata(res, overwrite, async_, notification_url, auth_token, url_expiry, copy_fields=None):
261+
if copy_fields is None:
262+
copy_fields = []
263+
asset_url = _get_asset_url(res, auth_token, url_expiry)
264+
cloned_options = _build_cloned_options(res, overwrite, async_, notification_url, copy_fields)
265+
266+
return cloned_options, asset_url
267+
268+
269+
def _get_asset_url(res, auth_token, url_expiry):
270+
if not (isinstance(res.get('access_control'), list) and
271+
len(res.get('access_control')) > 0 and
272+
isinstance(res['access_control'][0], dict) and
273+
res['access_control'][0].get("access_type") == "token"):
274+
return res.get('secure_url')
275+
276+
reso_type = res.get('resource_type')
277+
del_type = res.get('type')
278+
pub_id = res.get('public_id')
279+
file_format = res.get('format')
280+
281+
if auth_token:
282+
# Raw assets already have the format in the public_id
283+
pub_id_format = pub_id if reso_type == "raw" else f"{pub_id}.{file_format}"
284+
return cloudinary.utils.cloudinary_url(
285+
pub_id_format,
286+
type=del_type,
287+
resource_type=reso_type,
288+
auth_token={"duration": url_expiry},
289+
secure=True,
290+
sign_url=True
291+
)
292+
293+
# Use private url if no auth_token provided
294+
return cloudinary.utils.private_download_url(
295+
pub_id,
296+
file_format,
297+
resource_type=reso_type,
298+
type=del_type,
299+
expires_at=int(time.time()) + url_expiry
300+
)
301+
302+
303+
def _build_cloned_options(res, overwrite, async_, notification_url, copy_fields):
304+
# 1. Start with mandatory options
305+
cloned_options = {
306+
'overwrite': overwrite,
307+
'async': async_,
308+
}
309+
310+
# 2. Copy fields from source asset. Some are standard, others are from user input.
311+
fields_to_copy = {'public_id', 'type', 'resource_type', 'access_control'}.union(copy_fields)
312+
cloned_options.update({field: res.get(field) for field in fields_to_copy})
313+
314+
# 3. Handle fields that are added only if they have a truthy value
315+
if res.get('display_name'):
316+
cloned_options['display_name'] = res['display_name']
317+
318+
# This is required to put the asset in the correct asset_folder
319+
# when copying from a fixed to DF (dynamic folder) cloud as if
320+
# you just pass a `folder` param to a DF cloud, it will append
321+
# this to the `public_id` and we don't want this.
188322
if res.get('folder'):
189-
# This is required to put the asset in the correct asset_folder
190-
# when copying from a fixed to DF (dynamic folder) cloud as if
191-
# you just pass a `folder` param to a DF cloud, it will append
192-
# this to the `public_id` and we don't want this.
193-
cloned_options['asset_folder'] = res.get('folder')
323+
cloned_options['asset_folder'] = res['folder']
194324
elif res.get('asset_folder'):
195-
cloned_options['asset_folder'] = res.get('asset_folder')
196-
if res.get('display_name'):
197-
cloned_options['display_name'] = res.get('display_name')
325+
cloned_options['asset_folder'] = res['asset_folder']
326+
198327
if notification_url:
199328
cloned_options['notification_url'] = notification_url
200329

201-
return cloned_options, asset_url
330+
# 4. Clean up any None values before returning
331+
return {k: v for k, v in cloned_options.items() if v is not None}

0 commit comments

Comments
 (0)