Skip to content

Commit cf3e0f2

Browse files
tommyg-cldconst-cloudinarycarlevison
authored
Add support for clone command
--------- Co-authored-by: Constantine Nathanson <[email protected]> Co-authored-by: carlevison <[email protected]>
1 parent 18bbea9 commit cf3e0f2

File tree

5 files changed

+163
-17
lines changed

5 files changed

+163
-17
lines changed

.github/workflows/cloudinary-cli-test.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ jobs:
1313
strategy:
1414
fail-fast: false
1515
matrix:
16-
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
16+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
1717

1818
steps:
19-
- uses: actions/checkout@v2
19+
- uses: actions/checkout@v4
2020
- name: Set up Python ${{ matrix.python-version }}
21-
uses: actions/setup-python@v2
21+
uses: actions/setup-python@v5
2222
with:
2323
python-version: ${{ matrix.python-version }}
2424
- name: Install dependencies

cloudinary_cli/modules/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
from .sync import sync
44
from .upload_dir import upload_dir
55
from .regen_derived import regen_derived
6+
from .clone import clone
67

78
commands = [
89
upload_dir,
910
make,
1011
migrate,
1112
sync,
12-
regen_derived
13+
regen_derived,
14+
clone
1315
]

cloudinary_cli/modules/clone.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from click import command, option, style, argument
2+
from cloudinary_cli.utils.utils import normalize_list_params, print_help_and_exit
3+
import cloudinary
4+
from 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
7+
from cloudinary_cli.defaults import logger
8+
from cloudinary_cli.core.search import execute_single_request, handle_auto_pagination
9+
10+
DEFAULT_MAX_RESULTS = 500
11+
12+
13+
@command("clone",
14+
short_help="""Clone assets from one product environment to another.""",
15+
help="""
16+
\b
17+
Clone assets from one product environment to another with/without tags and/or context (structured metadata is not currently supported).
18+
Source will be your `CLOUDINARY_URL` environment variable but you also can specify a different source using the `-c/-C` option.
19+
Cloning restricted assets is also not supported currently.
20+
Format: cld clone <target_environment> <command options>
21+
`<target_environment>` can be a CLOUDINARY_URL or a saved config (see `config` command)
22+
Example 1 (Copy all assets including tags and context using CLOUDINARY URL):
23+
cld clone cloudinary://<api_key>:<api_secret>@<cloudname> -fi tags,context
24+
Example 2 (Copy all assets with a specific tag via a search expression using a saved config):
25+
cld clone <config_name> -se "tags:<tag_name>"
26+
""")
27+
@argument("target")
28+
@option("-F", "--force", is_flag=True,
29+
help="Skip confirmation.")
30+
@option("-ow", "--overwrite", is_flag=True, default=False,
31+
help="Specify whether to overwrite existing assets.")
32+
@option("-w", "--concurrent_workers", type=int, default=30,
33+
help="Specify the number of concurrent network threads.")
34+
@option("-fi", "--fields", multiple=True,
35+
help="Specify whether to copy tags and/or context. Valid options: `tags,context`.")
36+
@option("-se", "--search_exp", default="",
37+
help="Define a search expression to filter the assets to clone.")
38+
@option("--async", "async_", is_flag=True, default=False,
39+
help="Clone the assets asynchronously.")
40+
@option("-nu", "--notification_url",
41+
help="Webhook notification URL.")
42+
def clone(target, force, overwrite, concurrent_workers, fields, search_exp, async_, notification_url):
43+
if not target:
44+
print_help_and_exit()
45+
46+
target_config = get_cloudinary_config(target)
47+
if not target_config:
48+
logger.error("The specified config does not exist or the CLOUDINARY_URL scheme provided is invalid"
49+
" (expecting to start with 'cloudinary://').")
50+
return False
51+
52+
if cloudinary.config().cloud_name == target_config.cloud_name:
53+
logger.error("Target environment cannot be the same as source environment.")
54+
return False
55+
56+
source_assets = search_assets(force, search_exp)
57+
58+
upload_list = []
59+
for r in source_assets.get('resources'):
60+
updated_options, asset_url = process_metadata(r, overwrite, async_, notification_url,
61+
normalize_list_params(fields))
62+
updated_options.update(config_to_dict(target_config))
63+
upload_list.append((asset_url, {**updated_options}))
64+
65+
if not upload_list:
66+
logger.error(style(f'No assets found in {cloudinary.config().cloud_name}', fg="red"))
67+
return False
68+
69+
logger.info(style(f'Copying {len(upload_list)} asset(s) from {cloudinary.config().cloud_name} to {target_config.cloud_name}', fg="blue"))
70+
71+
run_tasks_concurrently(upload_file, upload_list, concurrent_workers)
72+
73+
return True
74+
75+
76+
def search_assets(force, search_exp):
77+
search = cloudinary.search.Search().expression(search_exp)
78+
search.fields(['tags', 'context', 'access_control', 'secure_url', 'display_name'])
79+
search.max_results(DEFAULT_MAX_RESULTS)
80+
81+
res = execute_single_request(search, fields_to_keep="")
82+
res = handle_auto_pagination(res, search, force, fields_to_keep="")
83+
84+
return res
85+
86+
87+
def process_metadata(res, overwrite, async_, notification_url, copy_fields=""):
88+
cloned_options = {}
89+
asset_url = res.get('secure_url')
90+
cloned_options['public_id'] = res.get('public_id')
91+
cloned_options['type'] = res.get('type')
92+
cloned_options['resource_type'] = res.get('resource_type')
93+
cloned_options['overwrite'] = overwrite
94+
cloned_options['async'] = async_
95+
if "tags" in copy_fields:
96+
cloned_options['tags'] = res.get('tags')
97+
if "context" in copy_fields:
98+
cloned_options['context'] = res.get('context')
99+
if res.get('folder'):
100+
# This is required to put the asset in the correct asset_folder
101+
# when copying from a fixed to DF (dynamic folder) cloud as if
102+
# you just pass a `folder` param to a DF cloud, it will append
103+
# this to the `public_id` and we don't want this.
104+
cloned_options['asset_folder'] = res.get('folder')
105+
elif res.get('asset_folder'):
106+
cloned_options['asset_folder'] = res.get('asset_folder')
107+
if res.get('display_name'):
108+
cloned_options['display_name'] = res.get('display_name')
109+
if notification_url:
110+
cloned_options['notification_url'] = notification_url
111+
112+
return cloned_options, asset_url

cloudinary_cli/utils/api_utils.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from cloudinary_cli.utils.json_utils import print_json, write_json_to_file
1414
from cloudinary_cli.utils.utils import log_exception, confirm_action, get_command_params, merge_responses, \
1515
normalize_list_params, ConfigurationError, print_api_help, duplicate_values
16+
import re
17+
from cloudinary.utils import is_remote_url
1618

1719
PAGINATION_MAX_RESULTS = 500
1820

@@ -118,15 +120,20 @@ def upload_file(file_path, options, uploaded=None, failed=None):
118120
verbose = logger.getEffectiveLevel() < logging.INFO
119121

120122
try:
121-
size = path.getsize(file_path)
123+
size = 0 if is_remote_url(file_path) else path.getsize(file_path)
122124
upload_func = uploader.upload
123125
if size > 20000000:
124126
upload_func = uploader.upload_large
125127
result = upload_func(file_path, **options)
126128
disp_path = _display_path(result)
127-
disp_str = f"as {result['public_id']}" if not disp_path \
128-
else f"as {disp_path} with public_id: {result['public_id']}"
129-
logger.info(style(f"Successfully uploaded {file_path} {disp_str}", fg="green"))
129+
if "batch_id" in result:
130+
starting_msg = "Uploading"
131+
disp_str = f"asynchnously with batch_id: {result['batch_id']}"
132+
else:
133+
starting_msg = "Successfully uploaded"
134+
disp_str = f"as {result['public_id']}" if not disp_path \
135+
else f"as {disp_path} with public_id: {result['public_id']}"
136+
logger.info(style(f"{starting_msg} {file_path} {disp_str}", fg="green"))
130137
if verbose:
131138
print_json(result)
132139
uploaded[file_path] = {"path": asset_source(result), "display_path": disp_path}
@@ -212,12 +219,15 @@ def asset_source(asset_details):
212219
213220
:return:
214221
"""
215-
base_name = asset_details['public_id']
222+
base_name = asset_details.get('public_id', '')
223+
224+
if not base_name:
225+
return base_name
216226

217227
if asset_details['resource_type'] == 'raw' or asset_details['type'] == 'fetch':
218228
return base_name
219229

220-
return base_name + '.' + asset_details['format']
230+
return base_name + '.' + asset_details.get('format', '')
221231

222232

223233
def get_folder_mode():
@@ -278,7 +288,6 @@ def handle_api_command(
278288
"""
279289
Used by Admin and Upload API commands
280290
"""
281-
282291
if doc:
283292
return launch(doc_url)
284293

cloudinary_cli/utils/config_utils.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,31 @@ def refresh_cloudinary_config(cloudinary_url):
4444

4545
def verify_cloudinary_url(cloudinary_url):
4646
refresh_cloudinary_config(cloudinary_url)
47-
try:
48-
api.ping()
49-
except Exception as e:
50-
log_exception(e, f"Invalid Cloudinary URL: {cloudinary_url}")
47+
return ping_cloudinary()
48+
49+
50+
def get_cloudinary_config(target):
51+
target_config = cloudinary.Config()
52+
if target.startswith("cloudinary://"):
53+
parsed_url = target_config._parse_cloudinary_url(target)
54+
elif target in load_config():
55+
parsed_url = target_config._parse_cloudinary_url(load_config().get(target))
56+
else:
5157
return False
52-
return True
5358

59+
target_config._setup_from_parsed_url(parsed_url)
60+
61+
if not ping_cloudinary(**config_to_dict(target_config)):
62+
logger.error(f"Invalid Cloudinary config: {target}")
63+
return False
64+
65+
return target_config
66+
67+
def config_to_dict(config):
68+
return {k: v for k, v in config.__dict__.items() if not k.startswith("_")}
5469

5570
def show_cloudinary_config(cloudinary_config):
56-
obfuscated_config = {k: v for k, v in cloudinary_config.__dict__.items() if not k.startswith("_")}
71+
obfuscated_config = config_to_dict(cloudinary_config)
5772

5873
if "api_secret" in obfuscated_config:
5974
api_secret = obfuscated_config["api_secret"]
@@ -96,6 +111,14 @@ def is_valid_cloudinary_config():
96111
def initialize():
97112
migrate_old_config()
98113

114+
def ping_cloudinary(**options):
115+
try:
116+
api.ping(**options)
117+
except Exception as e:
118+
logger.error(f"Failed to ping Cloudinary: {e}")
119+
return False
120+
121+
return True
99122

100123
def _verify_file_path(file):
101124
os.makedirs(os.path.dirname(file), exist_ok=True)

0 commit comments

Comments
 (0)