Skip to content

Commit bf74654

Browse files
Add support for dynamic folders mode in sync and upload_dir commands
* Bump `pycloudinary` to `1.35.0`
1 parent 2171e0a commit bf74654

File tree

7 files changed

+169
-47
lines changed

7 files changed

+169
-47
lines changed

cloudinary_cli/modules/sync.py

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
from itertools import groupby
44
from os import path, remove
55

6-
from click import command, argument, option, style, UsageError
6+
from click import command, argument, option, style, UsageError, Choice
77
from cloudinary import api
88

9-
from cloudinary_cli.utils.api_utils import query_cld_folder, upload_file, download_file
10-
from cloudinary_cli.utils.file_utils import walk_dir, delete_empty_dirs, get_destination_folder, \
11-
normalize_file_extension, posix_rel_path
9+
from cloudinary_cli.utils.api_utils import query_cld_folder, upload_file, download_file, get_folder_mode, \
10+
get_default_upload_options, get_destination_folder_options
11+
from cloudinary_cli.utils.file_utils import walk_dir, delete_empty_dirs, normalize_file_extension, posix_rel_path
1212
from cloudinary_cli.utils.json_utils import print_json, read_json_from_file, write_json_to_file
13-
from cloudinary_cli.utils.utils import logger, run_tasks_concurrently, get_user_action, invert_dict, chunker
13+
from cloudinary_cli.utils.utils import logger, run_tasks_concurrently, get_user_action, invert_dict, chunker, \
14+
group_params, parse_option_value
1415

1516
_DEFAULT_DELETION_BATCH_SIZE = 30
1617
_DEFAULT_CONCURRENT_WORKERS = 30
@@ -32,13 +33,18 @@
3233
@option("-K", "--keep-unique", is_flag=True, help="Keep unique files in the destination folder.")
3334
@option("-D", "--deletion-batch-size", type=int, default=_DEFAULT_DELETION_BATCH_SIZE,
3435
help="Specify the batch size for deleting remote assets.")
36+
@option("-fm", "--folder-mode", type=Choice(['fixed', 'dynamic'], case_sensitive=False),
37+
help="Specify folder mode explicitly. By default uses cloud mode configured in your cloud.", hidden=True)
38+
@option("-o", "--optional_parameter", multiple=True, nargs=2, help="Pass optional parameters as raw strings.")
39+
@option("-O", "--optional_parameter_parsed", multiple=True, nargs=2,
40+
help="Pass optional parameters as interpreted strings.")
3541
def sync(local_folder, cloudinary_folder, push, pull, include_hidden, concurrent_workers, force, keep_unique,
36-
deletion_batch_size):
42+
deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed):
3743
if push == pull:
3844
raise UsageError("Please use either the '--push' OR '--pull' options")
3945

4046
sync_dir = SyncDir(local_folder, cloudinary_folder, include_hidden, concurrent_workers, force, keep_unique,
41-
deletion_batch_size)
47+
deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed)
4248

4349
result = True
4450
if push:
@@ -53,7 +59,7 @@ def sync(local_folder, cloudinary_folder, push, pull, include_hidden, concurrent
5359

5460
class SyncDir:
5561
def __init__(self, local_dir, remote_dir, include_hidden, concurrent_workers, force, keep_deleted,
56-
deletion_batch_size):
62+
deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed):
5763
self.local_dir = local_dir
5864
self.remote_dir = remote_dir.strip('/')
5965
self.user_friendly_remote_dir = self.remote_dir if self.remote_dir else '/'
@@ -63,15 +69,21 @@ def __init__(self, local_dir, remote_dir, include_hidden, concurrent_workers, fo
6369
self.keep_unique = keep_deleted
6470
self.deletion_batch_size = deletion_batch_size
6571

72+
self.folder_mode = folder_mode or get_folder_mode()
73+
74+
self.optional_parameter = optional_parameter
75+
self.optional_parameter_parsed = optional_parameter_parsed
76+
6677
self.sync_meta_file = path.join(self.local_dir, _SYNC_META_FILE)
6778

6879
self.verbose = logger.getEffectiveLevel() < logging.INFO
6980

7081
self.local_files = walk_dir(path.abspath(self.local_dir), include_hidden)
7182
logger.info(f"Found {len(self.local_files)} items in local folder '{local_dir}'")
7283

73-
self.remote_files = query_cld_folder(self.remote_dir)
74-
logger.info(f"Found {len(self.remote_files)} items in Cloudinary folder '{self.user_friendly_remote_dir}'")
84+
self.remote_files = query_cld_folder(self.remote_dir, self.folder_mode)
85+
logger.info(f"Found {len(self.remote_files)} items in Cloudinary folder '{self.user_friendly_remote_dir}' "
86+
f"({self.folder_mode} folder mode)")
7587

7688
local_file_names = self.local_files.keys()
7789
remote_file_names = self.remote_files.keys()
@@ -123,19 +135,20 @@ def push(self):
123135
logger.info(f"Uploading {len(files_to_push)} items to Cloudinary folder '{self.user_friendly_remote_dir}'")
124136

125137
options = {
126-
'use_filename': True,
127-
'unique_filename': False,
128-
'invalidate': True,
129-
'resource_type': 'auto'
138+
**get_default_upload_options(self.folder_mode),
139+
**group_params(
140+
self.optional_parameter,
141+
((k, parse_option_value(v)) for k, v in self.optional_parameter_parsed))
130142
}
143+
131144
upload_results = {}
132145
upload_errors = {}
133146
uploads = []
134147
for file in files_to_push:
135-
folder = get_destination_folder(self.remote_dir, file)
148+
folder_options = get_destination_folder_options(file, self.remote_dir, self.folder_mode)
136149

137150
uploads.append(
138-
(self.local_files[file]['path'], {**options, 'folder': folder}, upload_results, upload_errors))
151+
(self.local_files[file]['path'], {**options, **folder_options}, upload_results, upload_errors))
139152

140153
try:
141154
run_tasks_concurrently(upload_file, uploads, self.concurrent_workers)
@@ -186,7 +199,8 @@ def _print_sync_status(self, success, errors):
186199

187200
def _save_sync_meta_file(self, upload_results):
188201
diverse_filenames = {}
189-
for local_path, remote_path in upload_results.items():
202+
for local_path, remote_res in upload_results.items():
203+
remote_path = remote_res["display_path"] if self.folder_mode == "dynamic" else remote_res["path"]
190204
local = normalize_file_extension(posix_rel_path(local_path, self.local_dir))
191205
remote = normalize_file_extension(posix_rel_path(remote_path, self.remote_dir))
192206
if local != remote:
@@ -254,7 +268,10 @@ def _get_out_of_sync_file_names(self, common_file_names):
254268
out_of_sync_file_names.add(f)
255269
continue
256270
logger.debug(f"'{f}' is in sync" +
257-
(f" with '{self.diverse_file_names[f]}'" if f in self.diverse_file_names else ""))
271+
(f" with '{self.diverse_file_names[f]}'" if f in self.diverse_file_names else "") +
272+
(f". Public ID: {self.recovered_remote_files[f]['public_id']}"
273+
if self.folder_mode == "dynamic" else "")
274+
)
258275

259276
return out_of_sync_file_names
260277

cloudinary_cli/modules/upload_dir.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
from os.path import dirname, join as path_join
33
from pathlib import Path
44

5-
from click import command, argument, option, style, launch
5+
from click import command, argument, option, style, launch, Choice
66

7-
from cloudinary_cli.utils.api_utils import upload_file
7+
from cloudinary_cli.utils.api_utils import upload_file, get_default_upload_options, get_folder_mode, \
8+
get_destination_folder_options
89
from cloudinary_cli.utils.file_utils import get_destination_folder, is_hidden_path
910
from cloudinary_cli.utils.utils import parse_option_value, logger, run_tasks_concurrently, group_params
1011

@@ -25,6 +26,8 @@
2526
"The path you specify will be pre-pended to the public IDs of the uploaded assets. "
2627
"You can specify a whole path, for example path1/path2/path3. "
2728
"Any folders that do not exist are automatically created.")
29+
@option("-fm", "--folder-mode", type=Choice(['fixed', 'dynamic'], case_sensitive=False),
30+
help="Specify folder mode explicitly. By default uses cloud mode configured in your cloud.", hidden=True)
2831
@option("-p", "--preset", help="The upload preset to use.")
2932
@option("-e", "--exclude-dir-name", is_flag=True, default=False,
3033
help="When this option is used, the contents of the parent directory are uploaded but not the parent "
@@ -33,7 +36,7 @@
3336
@option("-w", "--concurrent_workers", type=int, default=30, help="Specify the number of concurrent network threads.")
3437
@option("-d", "--doc", is_flag=True, help="Open upload_dir command documentation page.")
3538
def upload_dir(directory, glob_pattern, include_hidden, optional_parameter, optional_parameter_parsed, transformation,
36-
folder, preset, concurrent_workers, exclude_dir_name, doc):
39+
folder, folder_mode, preset, concurrent_workers, exclude_dir_name, doc):
3740
items, skipped = {}, {}
3841

3942
if doc:
@@ -44,24 +47,27 @@ def upload_dir(directory, glob_pattern, include_hidden, optional_parameter, opti
4447
logger.error(f"Directory: {dir_to_upload} does not exist")
4548
return False
4649

50+
folder_mode = folder_mode or get_folder_mode()
51+
4752
if exclude_dir_name:
48-
logger.info(f"Uploading contents of directory '{dir_to_upload}'")
53+
contents_str = "contents of"
4954
parent = dir_to_upload
5055
else:
51-
logger.info(f"Uploading directory '{dir_to_upload}'")
56+
contents_str = ""
5257
parent = dirname(dir_to_upload)
5358

54-
defaults = {
55-
"resource_type": "auto",
56-
"invalidate": True,
57-
"unique_filename": False,
58-
"use_filename": True,
59+
logger.info(f"Uploading {contents_str} directory '{dir_to_upload}' ({folder_mode} folder mode)")
60+
61+
defaults = get_default_upload_options(folder_mode)
62+
63+
upload_dir_options = {
5964
"raw_transformation": transformation,
6065
"upload_preset": preset
6166
}
6267

6368
options = {
6469
**defaults,
70+
**upload_dir_options,
6571
**group_params(optional_parameter, ((k, parse_option_value(v)) for k, v in optional_parameter_parsed)),
6672
}
6773

@@ -72,8 +78,8 @@ def upload_dir(directory, glob_pattern, include_hidden, optional_parameter, opti
7278
if not include_hidden and is_hidden_path(file_path):
7379
continue
7480

75-
options = {**options, "folder": get_destination_folder(folder, str(file_path), parent=parent)}
76-
uploads.append((file_path, options, items, skipped))
81+
folder_options = get_destination_folder_options(str(file_path), folder, folder_mode, parent)
82+
uploads.append((file_path, {**options, **folder_options}, items, skipped))
7783

7884
run_tasks_concurrently(upload_file, uploads, concurrent_workers)
7985

cloudinary_cli/utils/api_utils.py

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33

44
import requests
55
from click import style, launch
6-
from cloudinary import Search, uploader
6+
from cloudinary import Search, uploader, api
77
from cloudinary.utils import cloudinary_url
88

99
from cloudinary_cli.defaults import logger
1010
from cloudinary_cli.utils.config_utils import is_valid_cloudinary_config
11-
from cloudinary_cli.utils.file_utils import normalize_file_extension, posix_rel_path
11+
from cloudinary_cli.utils.file_utils import normalize_file_extension, posix_rel_path, get_destination_folder
1212
from cloudinary_cli.utils.json_utils import print_json, write_json_to_file
1313
from cloudinary_cli.utils.utils import log_exception, confirm_action, get_command_params, merge_responses, \
1414
normalize_list_params, ConfigurationError, print_api_help
@@ -18,7 +18,7 @@
1818
_cursor_fields = {"resource": "derived_next_cursor"}
1919

2020

21-
def query_cld_folder(folder):
21+
def query_cld_folder(folder, folder_mode):
2222
files = {}
2323

2424
folder = folder.strip('/') # omit redundant leading slash and duplicate trailing slashes in query
@@ -31,15 +31,21 @@ def query_cld_folder(folder):
3131
res = expression.execute()
3232

3333
for asset in res['resources']:
34-
rel_path = posix_rel_path(asset_source(asset), folder)
35-
files[normalize_file_extension(rel_path)] = {
34+
rel_path = _relative_path(asset, folder)
35+
rel_display_path = _relative_display_path(asset, folder)
36+
path_key = rel_display_path if folder_mode == "dynamic" else rel_path
37+
files[normalize_file_extension(path_key)] = {
3638
"type": asset['type'],
3739
"resource_type": asset['resource_type'],
3840
"public_id": asset['public_id'],
3941
"format": asset['format'],
4042
"etag": asset.get('etag', '0'),
4143
"relative_path": rel_path, # save for inner use
4244
"access_mode": asset.get('access_mode', 'public'),
45+
# dynamic folder mode fields
46+
"asset_folder": asset.get('asset_folder'),
47+
"display_name": asset.get('display_name'),
48+
"relative_display_path": rel_display_path
4349
}
4450
# use := when switch to python 3.8
4551
next_cursor = res.get('next_cursor')
@@ -48,6 +54,28 @@ def query_cld_folder(folder):
4854
return files
4955

5056

57+
def _display_path(asset):
58+
if asset.get("display_name") is None:
59+
return ""
60+
61+
return "/".join([asset["asset_folder"], ".".join([asset["display_name"], asset["format"]])])
62+
63+
64+
def _relative_display_path(asset, folder):
65+
if asset.get("display_name") is None:
66+
return ""
67+
68+
return posix_rel_path(_display_path(asset), folder)
69+
70+
71+
def _relative_path(asset, folder):
72+
source = asset_source(asset)
73+
if not source.startswith(folder):
74+
return source
75+
76+
return posix_rel_path(asset_source(asset), folder)
77+
78+
5179
def regen_derived_version(public_id, delivery_type, res_type,
5280
eager_trans, eager_async,
5381
eager_notification_url):
@@ -78,15 +106,49 @@ def upload_file(file_path, options, uploaded=None, failed=None):
78106
if size > 20000000:
79107
upload_func = uploader.upload_large
80108
result = upload_func(file_path, **options)
81-
logger.info(style(f"Successfully uploaded {file_path} as {result['public_id']}", fg="green"))
109+
disp_path = _display_path(result)
110+
disp_str = f"as {result['public_id']}" if not disp_path \
111+
else f"as {disp_path} with public_id: {result['public_id']}"
112+
logger.info(style(f"Successfully uploaded {file_path} {disp_str}", fg="green"))
82113
if verbose:
83114
print_json(result)
84-
uploaded[file_path] = asset_source(result)
115+
uploaded[file_path] = {"path": asset_source(result), "display_path": disp_path}
85116
except Exception as e:
86117
log_exception(e, f"Failed uploading {file_path}")
87118
failed[file_path] = str(e)
88119

89120

121+
def get_default_upload_options(folder_mode):
122+
options = {
123+
'resource_type': 'auto'
124+
}
125+
126+
if folder_mode == 'fixed':
127+
options = {
128+
**options,
129+
'use_filename': True,
130+
'unique_filename': False,
131+
'invalidate': True,
132+
}
133+
134+
if folder_mode == 'dynamic':
135+
options = {
136+
**options,
137+
'use_filename_as_display_name': True,
138+
}
139+
140+
return options
141+
142+
143+
def get_destination_folder_options(file, remote_dir, folder_mode, parent=None):
144+
destination_folder = get_destination_folder(remote_dir, file, parent)
145+
146+
if folder_mode == "dynamic":
147+
return {"asset_folder": destination_folder}
148+
149+
return {"folder": destination_folder}
150+
151+
90152
def download_file(remote_file, local_path, downloaded=None, failed=None):
91153
downloaded = downloaded if downloaded is not None else {}
92154
failed = failed if failed is not None else {}
@@ -134,12 +196,29 @@ def asset_source(asset_details):
134196
:return:
135197
"""
136198
base_name = asset_details['public_id']
199+
137200
if asset_details['resource_type'] == 'raw' or asset_details['type'] == 'fetch':
138201
return base_name
139202

140203
return base_name + '.' + asset_details['format']
141204

142205

206+
def get_folder_mode():
207+
"""
208+
Returns folder mode of the cloud.
209+
210+
:return: String representing folder mode. Can be "fixed" or "dynamic".
211+
"""
212+
try:
213+
config_res = api.config(settings="true")
214+
mode = config_res["settings"]["folder_mode"]
215+
except Exception as e:
216+
log_exception(e, f"Failed getting cloud configuration")
217+
raise
218+
219+
return mode
220+
221+
143222
def call_api(func, args, kwargs):
144223
try:
145224
return func(*args, **kwargs)

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
cloudinary>=1.33.0
1+
cloudinary>=1.35.0
22
pygments
33
jinja2
44
click

test/helper_test.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ def uploader_response_mock():
4343
"resource_type": "raw",
4444
"type": "upload",
4545
"format":"bin",
46-
"foo": "bar"
46+
"foo": "bar",
47+
"resources": []
4748
}''')
4849

4950

0 commit comments

Comments
 (0)