Skip to content

Commit 30c8f6b

Browse files
revamp code to make it more reusable and improve tests
1 parent a626d18 commit 30c8f6b

File tree

5 files changed

+258
-395
lines changed

5 files changed

+258
-395
lines changed

cloudinary_cli/modules/clone.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
Format: cld clone <target_environment> <command options>
2626
`<target_environment>` can be a CLOUDINARY_URL or a saved config (see `config` command)
2727
Example 1 (Copy all assets including tags and context using CLOUDINARY URL):
28-
cld clone cloudinary://<api_key>:<api_secret>@<cloudname> -fi tags,context
28+
cld clone cloudinary://<api_key>:<api_secret>@<cloudname> -fi tags,context,metadata
2929
Example 2 (Copy all assets with a specific tag via a search expression using a saved config):
3030
cld clone <config_name> -se "tags:<tag_name>"
3131
""")
@@ -57,12 +57,11 @@ def clone(target, force, overwrite, concurrent_workers, fields,
5757
if not target_config:
5858
return False
5959
if 'metadata' in normalize_list_params(fields):
60-
metadata_clone = clone_metadata(target_config)
60+
metadata_clone = clone_metadata(target_config, force)
6161
if not metadata_clone:
62-
logger.error(style(f"The operation has been aborted due to your answer.", fg="red"))
6362
return False
6463
else:
65-
logger.info(style(f"Metadata cloned successfully from {cloudinary.config().cloud_name} to {target_config.cloud_name}. We will now proceed with cloning the assets.", fg="green"))
64+
logger.info(style(f"The metadata process from {cloudinary.config().cloud_name} to {target_config.cloud_name} is now done. We will now proceed with cloning the assets.", fg="green"))
6665
source_assets = search_assets(search_exp, force)
6766
if not source_assets:
6867
return False

cloudinary_cli/utils/api_utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,3 +366,5 @@ def handle_auto_pagination(res, func, args, kwargs, force, filter_fields):
366366
all_results.pop(cursor_field, None)
367367

368368
return all_results
369+
370+
Lines changed: 127 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,113 +1,144 @@
11
import cloudinary
22
from cloudinary_cli.utils.config_utils import config_to_dict
3-
from cloudinary_cli.utils.api_utils import handle_auto_pagination
3+
from cloudinary_cli.utils.api_utils import call_api
44
from cloudinary_cli.defaults import logger
5-
from cloudinary_cli.utils.utils import confirm_action
5+
from cloudinary_cli.utils.utils import confirm_action, compare_dicts
66
from click import style
77

8-
def clone_metadata(config):
9-
"""
10-
Clone metadata from the source to the destination.
11-
"""
12-
target_config = config_to_dict(config)
13-
source_metadata = list_metadata_items("metadata_fields")
14-
if source_metadata.get('metadata_fields'):
15-
target_metadata = list_metadata_items("metadata_fields", **target_config)
16-
fields_compare = compare_create_metadata_items(source_metadata, target_metadata, key="metadata_fields", **target_config)
17-
if not fields_compare:
18-
return False
19-
else:
20-
source_metadata_rules = list_metadata_items("metadata_rules")
21-
if source_metadata_rules.get('metadata_rules'):
22-
target_metadata_rules = list_metadata_items("metadata_rules", **target_config)
23-
rules_compare = compare_create_metadata_items(source_metadata_rules,target_metadata_rules, key="metadata_rules", id_field="name", **target_config)
24-
if not rules_compare:
25-
return False
26-
else:
27-
logger.info(style(f"No metadata rules found in {cloudinary.config().cloud_name}", fg="yellow"))
28-
else:
29-
logger.info(style(f"No metadata found in {cloudinary.config().cloud_name}", fg="yellow"))
30-
31-
return True # Return True to indicate that the metadata was cloned successfully or False if there were no items to clone.
32-
33-
def list_metadata_items(method_key, **options):
34-
if method_key == 'metadata_fields':
35-
res = cloudinary.api.list_metadata_fields(**options)
36-
res = handle_auto_pagination(res, cloudinary.api.list_metadata_fields, options, None, force=True, filter_fields="")
37-
else:
38-
res = cloudinary.api.list_metadata_rules(**options)
39-
res = handle_auto_pagination(res, cloudinary.api.list_metadata_rules, options, None, force=True, filter_fields="")
40-
41-
return res
42-
43-
def create_metadata_items(api_method_name, item, **options):
44-
if api_method_name == 'add_metadata_field':
45-
res = cloudinary.api.add_metadata_field(item, **options)
46-
else:
47-
res = cloudinary.api.add_metadata_rule(item, **options)
48-
return res
8+
METADATA_FIELDS = "fields"
9+
METADATA_RULES = "rules"
10+
COMPARE_KEY_FIELDS = "external_id"
11+
COMPARE_KEY_RULES = "name"
12+
METADATA_TYPE_SINGULAR = {
13+
"fields": "field",
14+
"rules": "rule"
15+
}
16+
METADATA_API_METHODS = {
17+
"fields": cloudinary.api.add_metadata_field,
18+
"rules": cloudinary.api.add_metadata_rule
19+
}
4920

50-
def deep_diff(obj_source, obj_target):
51-
diffs = {}
52-
for k in set(obj_source.keys()).union(obj_target.keys()):
53-
if obj_source.get(k) != obj_target.get(k):
54-
diffs[k] = {"json_source": obj_source.get(k), "json_target": obj_target.get(k)}
21+
def clone_metadata(config, force):
22+
"""Clone metadata fields and rules from source to target."""
23+
target_config = config_to_dict(config)
24+
25+
# Clone fields (required)
26+
fields_result = _clone_metadata_type(METADATA_FIELDS, COMPARE_KEY_FIELDS, target_config, force)
27+
if fields_result is False:
28+
return False
5529

56-
return diffs
30+
# Clone rules (optional)
31+
rules_result = _clone_metadata_type(METADATA_RULES, COMPARE_KEY_RULES, target_config, force)
32+
if rules_result is False:
33+
return False
34+
35+
return True
5736

37+
def _clone_metadata_type(item_type, compare_key, target_config, force):
38+
"""
39+
Generic function to clone a metadata type (fields or rules).
40+
41+
:param item_type: 'fields' or 'rules'
42+
:param compare_key: Key to use for comparison ('external_id' or 'name')
43+
:param target_config: Target configuration dict
44+
:param force: Skip confirmation if True
45+
:return: True on success, False on failure, None if nothing to clone
46+
"""
47+
source_cloud = cloudinary.config().cloud_name
48+
target_cloud = target_config['cloud_name']
49+
50+
# List source items
51+
logger.info(style(f"Listing metadata {item_type} in `{source_cloud}`.", fg="blue"))
52+
source_items = list_metadata_items(item_type)
53+
54+
if not source_items:
55+
logger.info(style(f"No metadata {item_type} found in `{source_cloud}`.", fg="yellow"))
56+
return False
57+
58+
logger.info(style(f"{len(source_items)} metadata {item_type} found in `{source_cloud}`.", fg="green"))
59+
60+
# List target items
61+
logger.info(style(f"Listing metadata {item_type} in `{target_cloud}`.", fg="blue"))
62+
target_items = list_metadata_items(item_type, **target_config)
63+
logger.info(style(f"{len(target_items)} metadata {item_type} found in `{target_cloud}`.", fg="green"))
64+
65+
# Compare and sync
66+
source_map, only_in_source, common = compare_dicts(source_items, target_items, compare_key)
67+
return sync_metadata_items(source_map, only_in_source, common, item_type, force, **target_config)
5868

59-
def compare_create_metadata_items(json_source, json_target, key, id_field = "external_id", **options):
60-
list_source = {item[id_field]: item for item in json_source.get(key, [])}
61-
list_target = {item[id_field]: item for item in json_target.get(key, [])}
69+
def list_metadata_items(item_type, **options):
70+
"""
71+
List metadata fields or rules.
72+
73+
:param item_type: Either 'fields' or 'rules'
74+
:param options: Cloudinary API options (cloud_name, api_key, etc.)
75+
:return: List of metadata items
76+
"""
77+
api_method = getattr(cloudinary.api, f'list_metadata_{item_type}')
78+
res = api_method(**options)
79+
return res.get(f'metadata_{item_type}', [])
6280

63-
only_in_source = list(list_source.keys() - list_target.keys())
64-
common = list_source.keys() & list_target.keys()
81+
def sync_metadata_items(source_metadata_items, only_in_source_items, common_items, item_type, force, **options):
82+
source_cloud = cloudinary.config().cloud_name
83+
target_cloud = options['cloud_name']
84+
succeeded = []
85+
failed = []
6586

66-
if not len(only_in_source):
67-
logger.info(style(f"{(' '.join(key.split('_')))} in `{dict(options)['cloud_name']}` and in `{cloudinary.config().cloud_name}` are identical. No {(' '.join(key.split('_')))} will be cloned", fg="yellow"))
68-
if not confirm_action(
69-
f"If you had some {key} in the target environment, "
70-
f"new values from the source environment won't be cloned.\n"
71-
f"Would you like to still proceed with the cloning of assets? (y/N).\n"):
72-
logger.info("Stopping.")
73-
return False
74-
else:
75-
logger.info("Continuing.")
76-
else:
77-
logger.info(style(f"{only_in_source} are only in `{dict(options)['cloud_name']}` and will be cloned to `{cloudinary.config().cloud_name}`.", fg="blue"))
87+
88+
if not only_in_source_items:
89+
logger.info(style(
90+
f"All metadata {item_type} from `{source_cloud}` already exist in `{target_cloud}`. "
91+
f"No metadata {item_type} cloning needed.",
92+
fg="yellow"
93+
))
94+
return True
95+
96+
logger.info(style(
97+
f"Metadata {item_type} {only_in_source_items} will be cloned from `{source_cloud}` to `{target_cloud}`.",
98+
fg="yellow"
99+
))
100+
101+
if common_items:
102+
logger.info(style(
103+
f"Metadata {item_type} {list(common_items)} exist in both clouds and will be skipped.",
104+
fg="yellow"
105+
))
106+
if not force:
78107
if not confirm_action(
79-
f"You have a {key} mismatch between the source and target environment.\n"
80-
f"Confirming this action will create the missing {key} and their values.\n"
81-
f"If you currently have some {key} in the target environment, "
82-
f"new values from the source environment won't be cloned.\n"
108+
f"Based on the analysis above, \n"
109+
f"The module will now copy the metadata {item_type} from {cloudinary.config().cloud_name} to {dict(options)['cloud_name']}.\n"
83110
f"Continue? (y/N)"):
84111
logger.info("Stopping.")
85112
return False
86113
else:
87-
logger.info("Continuing.")
88-
logger.info(style(f"Copying {len(only_in_source)} {(' '.join(key.split('_')))} from {cloudinary.config().cloud_name} to {dict(options)['cloud_name']}", fg="blue"))
89-
for key_field in only_in_source:
90-
if key == 'metadata_fields':
91-
try:
92-
res = create_metadata_items('add_metadata_field', list_source[key_field], **options)
93-
logger.info(style(f"Successfully created {(' '.join(key.split('_')))[:-1]} `{res.get('label')}` to {dict(options)['cloud_name']}", fg="green"))
94-
except Exception as e:
95-
logger.error(style(f"Error when creating {(' '.join(key.split('_')))[:-1]} `{res.get('label')}`` to {dict(options)['cloud_name']}", fg="red"))
96-
else:
97-
try:
98-
res = create_metadata_items('add_metadata_rule', list_source[key_field],**options)
99-
logger.info(style(f"Successfully created {(' '.join(key.split('_')))[:-1]} `{res.get('name')}` to {dict(options)['cloud_name']}", fg="green"))
100-
except Exception as e:
101-
logger.error(style(f"Error when creating {(' '.join(key.split('_')))[:-1]} `{res.get('name')}` to {dict(options)['cloud_name']}", fg="red"))
102-
103-
# for Phase 3
104-
#diffs = {}
105-
#for id_ in common:
106-
# if list_source[id_] != list_target[id_]:
107-
# diffs[id_] = deep_diff(list_source[id_], list_target[id_])
114+
logger.info("Continuing. You may use the -F "
115+
"flag to skip confirmation.")
108116

109-
#return {
110-
# "only_in_json_source": only_in_source,
111-
# "differences": diffs
112-
#}
113-
return True # Return True to indicate that the metadata items were compared and created successfully.
117+
add_method = METADATA_API_METHODS.get(item_type)
118+
singular = METADATA_TYPE_SINGULAR.get(item_type)
119+
120+
for key_field in only_in_source_items:
121+
try:
122+
add_method(source_metadata_items[key_field], **options)
123+
succeeded.append(key_field)
124+
logger.info(style(f"Successfully created metadata {singular} `{key_field}` in `{target_cloud}`", fg="green"))
125+
except Exception as e:
126+
failed.append((key_field, str(e)))
127+
logger.error(style(
128+
f"Failed to create metadata {singular} `{key_field}` in `{target_cloud}`: {e}",
129+
fg="red"
130+
))
131+
132+
# Summary
133+
if failed:
134+
logger.warning(style(
135+
f"Cloned {len(succeeded)}/{len(only_in_source_items)} {item_type} successfully. "
136+
f"{len(failed)} failed.",
137+
fg="yellow"
138+
))
139+
return False # Or consider partial success handling
140+
141+
if succeeded:
142+
logger.info(style(f"Successfully cloned {len(succeeded)} metadata {item_type}.", fg="green"))
143+
144+
return True

cloudinary_cli/utils/utils.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,3 +401,27 @@ def split_opt(opt):
401401
if opt[1:2] == first:
402402
return opt[:2], opt[2:]
403403
return first, opt[1:]
404+
405+
406+
def compare_dicts(dict1, dict2, compare_key):#
407+
"""
408+
Diff between two dictionaries.
409+
410+
This function is used to compare two dictionaries and return the keys that are only in the first dictionary,
411+
the keys that are only in the second dictionary, and the keys that are in both dictionaries.
412+
The compare_key is a unique key to compare the dictionaries by.
413+
For Phase 3 - add deep diff between two lists of dictionaries.
414+
Example for phase 3: compare metadata fields and their datasource
415+
diffs = {}
416+
for k in set(dict1.keys()).union(dict2.keys()):
417+
if dict1.get(k) != dict2.get(k):
418+
diffs[k] = {"json_source": dict1.get(k), "json_target": dict2.get(k)}
419+
"""
420+
list_dict1 = {item[compare_key]: item for item in dict1}
421+
list_dict2 = {item[compare_key]: item for item in dict2}
422+
423+
only_in_dict1 = list(list_dict1.keys() - list_dict2.keys())
424+
#only_in_dict2 = list(list_dict2.keys() - list_dict1.keys()) not needed for now
425+
common = list_dict1.keys() & list_dict2.keys()
426+
427+
return list_dict1, only_in_dict1, common

0 commit comments

Comments
 (0)