11from click import command , option , style , argument
22from cloudinary_cli .utils .utils import normalize_list_params , print_help_and_exit
33import cloudinary
4+ from cloudinary .auth_token import _digest
45from cloudinary_cli .utils .utils import run_tasks_concurrently
56from cloudinary_cli .utils .api_utils import upload_file , handle_api_command
67from 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
89from cloudinary_cli .defaults import logger
910from cloudinary_cli .core .search import execute_single_request , handle_auto_pagination
11+ import time
12+ import re
1013
1114DEFAULT_MAX_RESULTS = 500
15+ ALLOWED_TYPE_VALUES = ("upload" , "private" , "authenticated" )
1216
1317
1418@command ("clone" ,
1721\b
1822Clone assets from one product environment to another with/without tags and/or context (structured metadata is not currently supported).
1923Source 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.
2124Format: cld clone <target_environment> <command options>
2225`<target_environment>` can be a CLOUDINARY_URL or a saved config (see `config` command)
2326Example 1 (Copy all assets including tags and context using CLOUDINARY URL):
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-
100152def 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