22from cloudinary_cli .utils .utils import normalize_list_params , print_help_and_exit
33import cloudinary
44from cloudinary_cli .utils .utils import run_tasks_concurrently
5- from cloudinary_cli .utils .api_utils import upload_file
6- from cloudinary_cli .utils .config_utils import load_config , get_cloudinary_config , config_to_dict
5+ from cloudinary_cli .utils .api_utils import upload_file , handle_api_command
6+ 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
78from cloudinary_cli .defaults import logger
89from cloudinary_cli .core .search import execute_single_request , handle_auto_pagination
910
3233@option ("-w" , "--concurrent_workers" , type = int , default = 30 ,
3334 help = "Specify the number of concurrent network threads." )
3435@option ("-fi" , "--fields" , multiple = True ,
35- help = "Specify whether to copy tags and/or context. Valid options: `tags,context`." )
36+ help = "Specify whether to copy tags and/or context. Valid options: `tags,context,metadata `." )
3637@option ("-se" , "--search_exp" , default = "" ,
3738 help = "Define a search expression to filter the assets to clone." )
3839@option ("--async" , "async_" , is_flag = True , default = False ,
@@ -54,19 +55,31 @@ def clone(target, force, overwrite, concurrent_workers, fields, search_exp, asyn
5455 return False
5556
5657 source_assets = search_assets (force , search_exp )
58+ if 'metadata' in fields :
59+ source_metadata = list_metadata_items ("metadata_fields" )
60+ if source_metadata .get ('metadata_fields' ):
61+ target_metadata = list_metadata_items ("metadata_fields" , config_to_tuple_list (target_config ))
62+ fields_compare = compare_create_metadata_items (source_metadata , target_metadata , config_to_tuple_list (target_config ), key = "metadata_fields" )
63+ source_metadata_rules = list_metadata_items ("metadata_rules" )
64+ if source_metadata_rules .get ('metadata_rules' ):
65+ target_metadata_rules = list_metadata_items ("metadata_rules" , config_to_tuple_list (target_config ))
66+ rules_compare = compare_create_metadata_items (source_metadata_rules ,target_metadata_rules , config_to_tuple_list (target_config ), key = "metadata_rules" , id_field = "name" )
67+ else :
68+ logger .info (style (f"No metadata rules found in { cloudinary .config ().cloud_name } " , fg = "yellow" ))
69+ else :
70+ logger .info (style (f"No metadata found in { cloudinary .config ().cloud_name } " , fg = "yellow" ))
5771
5872 upload_list = []
5973 for r in source_assets .get ('resources' ):
6074 updated_options , asset_url = process_metadata (r , overwrite , async_ , notification_url ,
6175 normalize_list_params (fields ))
6276 updated_options .update (config_to_dict (target_config ))
6377 upload_list .append ((asset_url , {** updated_options }))
64-
6578 if not upload_list :
66- logger .error (style (f' No assets found in { cloudinary .config ().cloud_name } ' , fg = "red" ))
79+ logger .error (style (f" No assets found in { cloudinary .config ().cloud_name } " , fg = "red" ))
6780 return False
6881
69- logger .info (style (f' Copying { len (upload_list )} asset(s) from { cloudinary .config ().cloud_name } to { target_config .cloud_name } ' , fg = "blue" ))
82+ logger .info (style (f" Copying { len (upload_list )} asset(s) from { cloudinary .config ().cloud_name } to { target_config .cloud_name } " , fg = "blue" ))
7083
7184 run_tasks_concurrently (upload_file , upload_list , concurrent_workers )
7285
@@ -75,7 +88,7 @@ def clone(target, force, overwrite, concurrent_workers, fields, search_exp, asyn
7588
7689def search_assets (force , search_exp ):
7790 search = cloudinary .search .Search ().expression (search_exp )
78- search .fields (['tags' , 'context' , 'access_control' , 'secure_url' , 'display_name' ])
91+ search .fields (['tags' , 'context' , 'access_control' , 'secure_url' , 'display_name' , 'metadata' ])
7992 search .max_results (DEFAULT_MAX_RESULTS )
8093
8194 res = execute_single_request (search , fields_to_keep = "" )
@@ -84,6 +97,80 @@ def search_assets(force, search_exp):
8497 return res
8598
8699
100+ def list_metadata_items (method_key , * options ):
101+ api_method_name = 'list_' + method_key
102+ params = [api_method_name ]
103+ if options :
104+ options = options [0 ]
105+ res = handle_api_command (params , (), options , None , None , None ,
106+ doc_url = "" , api_instance = cloudinary .api ,
107+ api_name = "admin" ,
108+ auto_paginate = True ,
109+ force = True , return_data = True )
110+ res .get (method_key , []).sort (key = lambda x : x ["external_id" ])
111+
112+ return res
113+
114+
115+ def create_metadata_item (api_method_name , item , * options ):
116+ params = (api_method_name , item )
117+ if options :
118+ options = options [0 ]
119+ res = handle_api_command (params , (), options , None , None , None ,
120+ doc_url = "" , api_instance = cloudinary .api ,
121+ api_name = "admin" ,
122+ return_data = True )
123+
124+ return res
125+
126+
127+ def deep_diff (obj_source , obj_target ):
128+ diffs = {}
129+ for k in set (obj_source .keys ()).union (obj_target .keys ()):
130+ if obj_source .get (k ) != obj_target .get (k ):
131+ diffs [k ] = {"json_source" : obj_source .get (k ), "json_target" : obj_target .get (k )}
132+
133+ return diffs
134+
135+
136+ def compare_create_metadata_items (json_source , json_target , target_config , key , id_field = "external_id" ):
137+ list_source = {item [id_field ]: item for item in json_source .get (key , [])}
138+ list_target = {item [id_field ]: item for item in json_target .get (key , [])}
139+
140+ only_in_source = list (list_source .keys () - list_target .keys ())
141+ common = list_source .keys () & list_target .keys ()
142+
143+ if not len (only_in_source ):
144+ logger .info (style (f"{ (' ' .join (key .split ('_' )))} in { dict (target_config )['cloud_name' ]} and in { cloudinary .config ().cloud_name } are identical. No { (' ' .join (key .split ('_' )))} will be cloned" , fg = "yellow" ))
145+ else :
146+ logger .info (style (f"Copying { len (only_in_source )} { (' ' .join (key .split ('_' )))} from { cloudinary .config ().cloud_name } to { dict (target_config )['cloud_name' ]} " , fg = "blue" ))
147+
148+ for key_field in only_in_source :
149+ if key == 'metadata_fields' :
150+ try :
151+ res = create_metadata_item ('add_metadata_field' , list_source [key_field ],target_config )
152+ logger .info (style (f"Successfully created { (' ' .join (key .split ('_' )))} { key_field } to { dict (target_config )['cloud_name' ]} " , fg = "green" ))
153+ except Exception as e :
154+ logger .error (style (f"Error when creating { (' ' .join (key .split ('_' )))} { key_field } to { dict (target_config )['cloud_name' ]} " , fg = "red" ))
155+ else :
156+ try :
157+ res = create_metadata_item ('add_metadata_rule' , list_source [key_field ],target_config )
158+ logger .info (style (f"Successfully created { (' ' .join (key .split ('_' )))} { key_field } to { dict (target_config )['cloud_name' ]} " , fg = "green" ))
159+ except Exception as e :
160+ logger .error (style (f"Error when creating { (' ' .join (key .split ('_' )))} { key_field } to { dict (target_config )['cloud_name' ]} " , fg = "red" ))
161+
162+
163+ diffs = {}
164+ for id_ in common :
165+ if list_source [id_ ] != list_target [id_ ]:
166+ diffs [id_ ] = deep_diff (list_source [id_ ], list_target [id_ ])
167+
168+ return {
169+ "only_in_json_source" : only_in_source ,
170+ "differences" : diffs
171+ }
172+
173+
87174def process_metadata (res , overwrite , async_ , notification_url , copy_fields = "" ):
88175 cloned_options = {}
89176 asset_url = res .get ('secure_url' )
@@ -96,6 +183,8 @@ def process_metadata(res, overwrite, async_, notification_url, copy_fields=""):
96183 cloned_options ['tags' ] = res .get ('tags' )
97184 if "context" in copy_fields :
98185 cloned_options ['context' ] = res .get ('context' )
186+ if "metadata" in copy_fields :
187+ cloned_options ['metadata' ] = res .get ('metadata' )
99188 if res .get ('folder' ):
100189 # This is required to put the asset in the correct asset_folder
101190 # when copying from a fixed to DF (dynamic folder) cloud as if
0 commit comments