From c89024c8fa4eed8a05bbb2c0e0556ba27f2f5afa Mon Sep 17 00:00:00 2001 From: Thomas Gurung Date: Tue, 27 Aug 2024 18:14:18 +0100 Subject: [PATCH 01/14] Creating PR with the latest changes --- cloudinary_cli/modules/__init__.py | 4 +- cloudinary_cli/modules/copy.py | 146 +++++++++++++++++++++++++++++ cloudinary_cli/modules/sync.py | 44 ++++++--- cloudinary_cli/utils/api_utils.py | 34 ++++--- 4 files changed, 202 insertions(+), 26 deletions(-) create mode 100644 cloudinary_cli/modules/copy.py diff --git a/cloudinary_cli/modules/__init__.py b/cloudinary_cli/modules/__init__.py index 996eab9..1923482 100644 --- a/cloudinary_cli/modules/__init__.py +++ b/cloudinary_cli/modules/__init__.py @@ -3,11 +3,13 @@ from .sync import sync from .upload_dir import upload_dir from .regen_derived import regen_derived +from .copy import copy commands = [ upload_dir, make, migrate, sync, - regen_derived + regen_derived, + copy ] diff --git a/cloudinary_cli/modules/copy.py b/cloudinary_cli/modules/copy.py new file mode 100644 index 0000000..b5f783d --- /dev/null +++ b/cloudinary_cli/modules/copy.py @@ -0,0 +1,146 @@ +from click import command, argument, option, style +from cloudinary_cli.utils.utils import group_params, parse_option_value, \ + normalize_list_params +import cloudinary +from cloudinary_cli.utils.utils import confirm_action, run_tasks_concurrently +from cloudinary_cli.utils.json_utils import read_json_from_file +from cloudinary_cli.utils.api_utils import upload_file +from binascii import a2b_hex +from .sync import sync +from click.testing import CliRunner +from cloudinary_cli.utils.config_utils import load_config, \ + refresh_cloudinary_config +import os +from cloudinary_cli.defaults import logger +import copy as deepcopy_module + + +@command("copy", + short_help="""Copy assets, structured metadata, upload preset or named transformations from one account to another.""", + help="tbc") +@argument("search_exp") +@option("-T", "--target", multiple=True, + help="Tell the CLI the target environemnt to run the command on by specifying a saved configuration - see `config` command.") +@option("-o", "--optional_parameter", multiple=True, nargs=2, + help="Pass optional parameters as raw strings.") +@option("-O", "--optional_parameter_parsed", multiple=True, nargs=2, + help="Pass optional parameters as interpreted strings.") +@option("-w", "--concurrent_workers", type=int, default=30, + help="Specify the number of concurrent network threads.") +@option("-at", "--auth_token", help="Authentication token for base environment. Used for generating a token for assets that have access control.") +@option("-ct", "--copy_tags", is_flag=True, + help="Copy tags.") +@option("-cc", "--copy_context", is_flag=True, + help="Copy context.") +@option("-cm", "--copy_metadata", is_flag=True, + help="Copy metadata. Make sure to create the metadata in the traget account beforehand with the same external id") +@option("-usj", "--use_saved_json", is_flag=True, + help="Use the saved json file if you want to skip the initial search.") +def copy(search_exp, target, optional_parameter, optional_parameter_parsed, + concurrent_workers, auth_token, copy_tags, copy_context, + copy_metadata, use_saved_json): + + if not target: + print("-T/--target is mandatory. ") + exit() + + target = normalize_list_params(target) + for val in target: + config = load_config() + if val not in config: + raise Exception(f"Config {val} does not exist") + + if auth_token: + try: + a2b_hex(auth_token) + except Exception: + print('Auth key is not valid. Please double-check.') + exit() + else: + auth_token = "" + + if use_saved_json and os.path.exists("assets_to_copy.json"): + logger.info('Using assets_to_copy.json...') + res = read_json_from_file("assets_to_copy.json") + else: + logger.info('Searching assets...') + runner = CliRunner() + runner.invoke(sync, ['from_copy_module', search_exp, + '--pull', + '--is_search_expression'], catch_exceptions=False) + res = read_json_from_file("assets_to_copy.json") + + options = { + **group_params(optional_parameter, + ((k, parse_option_value(v)) + for k, v in optional_parameter_parsed)), + } + + upload_list = [] + for r in res: + updated_options, asset_url = process_metadata(r, auth_token, options, + copy_tags, copy_context, + copy_metadata) + upload_list.append((asset_url, {**updated_options})) + + base_cloudname = cloudinary.config().cloud_name + for val in target: + refresh_cloudinary_config(config[val]) + target_cloudname = cloudinary.config().cloud_name + if base_cloudname == target_cloudname: + if not confirm_action( + "Target environment is same as base cloud. " + "Continue? (y/N)"): + logger.info("Stopping.") + exit() + else: + logger.info("Continuing.") + logger.info(style(f'Copying {len(upload_list)} asset(s) to {val}', + fg="blue")) + run_tasks_concurrently(upload_file, upload_list, + concurrent_workers) + + return True + + +def process_metadata(res, auth_t, options, copy_tags, copy_context, + copy_metadata): + cloned_options = deepcopy_module.deepcopy(options) + if res.get('access_control'): + asset_url = generate_token(res.get('public_id'), res.get('type'), + res.get('resource_type'), res.get('format'), + auth_t) + cloned_options['access_control'] = res.get('access_control') + else: + asset_url = res.get('secure_url') + cloned_options['public_id'] = res.get('public_id') + cloned_options['type'] = res.get('type') + cloned_options['resource_type'] = res.get('resource_type') + if not cloned_options.get('overwrite'): + cloned_options['overwrite'] = True + if copy_tags: + cloned_options['tags'] = res.get('tags') + if copy_context: + cloned_options['context'] = res.get('context') + if copy_metadata: + cloned_options['metadata'] = res.get('metadata') + if res.get('folder') and not cloned_options.get('asset_folder'): + cloned_options['asset_folder'] = res.get('folder') + elif res.get('asset_folder') and not cloned_options.get('asset_folder'): + cloned_options['asset_folder'] = res.get('asset_folder') + if res.get('display_name'): + cloned_options['display_name'] = res.get('display_name') + return cloned_options, asset_url + + +def generate_token(pid, type, r_type, format, auth_t): + url = cloudinary.utils.cloudinary_url( + f"{pid}.{format}", + type=type, + resource_type=r_type, + auth_token=dict(key=auth_t, + duration=30), + secure=True, + sign_url=True, + force_version=False) + return url diff --git a/cloudinary_cli/modules/sync.py b/cloudinary_cli/modules/sync.py index 803115f..10a9850 100644 --- a/cloudinary_cli/modules/sync.py +++ b/cloudinary_cli/modules/sync.py @@ -20,7 +20,6 @@ _SYNC_META_FILE = '.cld-sync' - @command("sync", short_help="Synchronize between a local directory and a Cloudinary folder.", help="Synchronize between a local directory and a Cloudinary folder, maintaining the folder structure.") @@ -40,32 +39,37 @@ @option("-o", "--optional_parameter", multiple=True, nargs=2, help="Pass optional parameters as raw strings.") @option("-O", "--optional_parameter_parsed", multiple=True, nargs=2, help="Pass optional parameters as interpreted strings.") +@option("-se", "--is_search_expression", is_flag=True, default=False, help="Use cloudinary_folder as a search expression term.") def sync(local_folder, cloudinary_folder, push, pull, include_hidden, concurrent_workers, force, keep_unique, - deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed): + deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed, is_search_expression): if push == pull: raise UsageError("Please use either the '--push' OR '--pull' options") - if pull and not cld_folder_exists(cloudinary_folder): + if (pull and not cld_folder_exists(cloudinary_folder)) and not is_search_expression: logger.error(f"Cloudinary folder '{cloudinary_folder}' does not exist. Aborting...") return False - + resources_data = {} sync_dir = SyncDir(local_folder, cloudinary_folder, include_hidden, concurrent_workers, force, keep_unique, - deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed) + deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed, is_search_expression, + resources_data) result = True if push: result = sync_dir.push() elif pull: result = sync_dir.pull() + return True - logger.info("Done!") + if local_folder != "from_copy_module": + logger.info("Done!") return result class SyncDir: def __init__(self, local_dir, remote_dir, include_hidden, concurrent_workers, force, keep_deleted, - deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed): + deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed, is_search_expression, + resources_data): self.local_dir = local_dir self.remote_dir = remote_dir.strip('/') self.user_friendly_remote_dir = self.remote_dir if self.remote_dir else '/' @@ -79,16 +83,24 @@ def __init__(self, local_dir, remote_dir, include_hidden, concurrent_workers, fo self.optional_parameter = optional_parameter self.optional_parameter_parsed = optional_parameter_parsed + self.is_search_expression = is_search_expression + self.resources_data = resources_data self.sync_meta_file = path.join(self.local_dir, _SYNC_META_FILE) self.verbose = logger.getEffectiveLevel() < logging.INFO self.local_files = walk_dir(path.abspath(self.local_dir), include_hidden) - logger.info(f"Found {len(self.local_files)} items in local folder '{local_dir}'") - - raw_remote_files = query_cld_folder(self.remote_dir, self.folder_mode) - logger.info(f"Found {len(raw_remote_files)} items in Cloudinary folder '{self.user_friendly_remote_dir}' " + if local_dir != "from_copy_module": + logger.info(f"Found {len(self.local_files)} items in local folder '{local_dir}'") + + raw_remote_files = query_cld_folder(self.remote_dir, self.folder_mode, self.is_search_expression) + if local_dir == "from_copy_module": + self.resources_data = raw_remote_files + folder_or_search_e = "with search expression" + else: + folder_or_search_e = "in Cloudinary folder" + logger.info(f"Found {len(raw_remote_files)} items {folder_or_search_e} '{self.user_friendly_remote_dir}' " f"({self.folder_mode} folder mode)") self.remote_files = self._normalize_remote_file_names(raw_remote_files, self.local_files) self.remote_duplicate_names = duplicate_values(self.remote_files, "normalized_path", "asset_id") @@ -130,7 +142,7 @@ def __init__(self, local_dir, remote_dir, include_hidden, concurrent_workers, fo self.synced_files_count = len(common_file_names) - len(self.out_of_sync_local_file_names) - if self.synced_files_count: + if self.synced_files_count and local_dir != "from_copy_module": logger.info(f"Skipping {self.synced_files_count} items") def push(self): @@ -176,6 +188,12 @@ def pull(self): """ Pulls changes from the Cloudinary folder to the local folder. """ + if self.local_dir == "from_copy_module": + flattened_val = [] + for key, value in self.resources_data.items(): + flattened_val.append(value) + write_json_to_file(flattened_val, "assets_to_copy.json") + return True download_results = {} download_errors = {} if not self._handle_unique_local_files(): @@ -185,7 +203,6 @@ def pull(self): if not files_to_pull: return True - logger.info(f"Downloading {len(files_to_pull)} files from Cloudinary") downloads = [] for file in files_to_pull: @@ -193,7 +210,6 @@ def pull(self): local_path = path.abspath(path.join(self.local_dir, file)) downloads.append((remote_file, local_path, download_results, download_errors)) - try: run_tasks_concurrently(download_file, downloads, self.concurrent_workers) finally: diff --git a/cloudinary_cli/utils/api_utils.py b/cloudinary_cli/utils/api_utils.py index 2367817..5d08a97 100644 --- a/cloudinary_cli/utils/api_utils.py +++ b/cloudinary_cli/utils/api_utils.py @@ -1,4 +1,5 @@ import logging +import re from os import path, makedirs import requests @@ -19,13 +20,16 @@ _cursor_fields = {"resource": "derived_next_cursor"} -def query_cld_folder(folder, folder_mode): +def query_cld_folder(folder, folder_mode, is_search_expression=False): files = {} - folder = folder.strip('/') # omit redundant leading slash and duplicate trailing slashes in query - folder_query = f"{folder}/*" if folder else "*" - - expression = Search().expression(f"folder:\"{folder_query}\"").with_field("image_analysis").max_results(500) + if is_search_expression: + search_e = folder + expression = Search().expression(f'{search_e}').with_field(['tags', 'metadata', 'context']).max_results(500) + else: + folder = folder.strip('/') # omit redundant leading slash and duplicate trailing slashes in query + folder_query = f"{folder}/*" if folder else "*" + expression = Search().expression(f"folder:\"{folder_query}\"").with_field("image_analysis").max_results(500) next_cursor = True while next_cursor: @@ -48,10 +52,17 @@ def query_cld_folder(folder, folder_mode): "relative_path": rel_path, # save for inner use "access_mode": asset.get('access_mode', 'public'), "created_at": asset.get('created_at'), + "folder": asset.get('folder'), # dynamic folder mode fields "asset_folder": asset.get('asset_folder'), "display_name": asset.get('display_name'), - "relative_display_path": rel_display_path + "relative_display_path": rel_display_path, + "tags": asset.get('tags'), + "context": asset.get('context'), + "metadata": asset.get('metadata'), + "access_control": asset.get('access_control'), + "access_mode": asset.get('access_mode'), + "secure_url": asset.get('secure_url') } # use := when switch to python 3.8 next_cursor = res.get('next_cursor') @@ -72,7 +83,8 @@ def cld_folder_exists(folder): def _display_path(asset): if asset.get("display_name") is None: return "" - + if asset.get("resource_type") == "raw": + return "/".join([asset.get("asset_folder", ""), ".".join([asset["display_name"]])]) return "/".join([asset.get("asset_folder", ""), ".".join([asset["display_name"], asset["format"]])]) @@ -114,12 +126,12 @@ def upload_file(file_path, options, uploaded=None, failed=None): uploaded = uploaded if uploaded is not None else {} failed = failed if failed is not None else {} verbose = logger.getEffectiveLevel() < logging.INFO - try: - size = path.getsize(file_path) upload_func = uploader.upload - if size > 20000000: - upload_func = uploader.upload_large + if not re.match(r'^https?://', file_path): + size = path.getsize(file_path) + if size > 20000000: + upload_func = uploader.upload_large result = upload_func(file_path, **options) disp_path = _display_path(result) disp_str = f"as {result['public_id']}" if not disp_path \ From 546ed06bd3d68796cda2c95974d054b057eb354f Mon Sep 17 00:00:00 2001 From: Thomas Gurung Date: Tue, 5 Nov 2024 15:39:44 +0000 Subject: [PATCH 02/14] Updating PR --- cloudinary_cli/modules/__init__.py | 4 +- cloudinary_cli/modules/copy.py | 146 ----------------------------- cloudinary_cli/modules/sync.py | 79 +++++++++------- cloudinary_cli/utils/api_utils.py | 38 +++----- 4 files changed, 63 insertions(+), 204 deletions(-) delete mode 100644 cloudinary_cli/modules/copy.py diff --git a/cloudinary_cli/modules/__init__.py b/cloudinary_cli/modules/__init__.py index 1923482..d387802 100644 --- a/cloudinary_cli/modules/__init__.py +++ b/cloudinary_cli/modules/__init__.py @@ -3,7 +3,7 @@ from .sync import sync from .upload_dir import upload_dir from .regen_derived import regen_derived -from .copy import copy +from .clone import clone commands = [ upload_dir, @@ -11,5 +11,5 @@ migrate, sync, regen_derived, - copy + clone ] diff --git a/cloudinary_cli/modules/copy.py b/cloudinary_cli/modules/copy.py deleted file mode 100644 index b5f783d..0000000 --- a/cloudinary_cli/modules/copy.py +++ /dev/null @@ -1,146 +0,0 @@ -from click import command, argument, option, style -from cloudinary_cli.utils.utils import group_params, parse_option_value, \ - normalize_list_params -import cloudinary -from cloudinary_cli.utils.utils import confirm_action, run_tasks_concurrently -from cloudinary_cli.utils.json_utils import read_json_from_file -from cloudinary_cli.utils.api_utils import upload_file -from binascii import a2b_hex -from .sync import sync -from click.testing import CliRunner -from cloudinary_cli.utils.config_utils import load_config, \ - refresh_cloudinary_config -import os -from cloudinary_cli.defaults import logger -import copy as deepcopy_module - - -@command("copy", - short_help="""Copy assets, structured metadata, upload preset or named transformations from one account to another.""", - help="tbc") -@argument("search_exp") -@option("-T", "--target", multiple=True, - help="Tell the CLI the target environemnt to run the command on by specifying a saved configuration - see `config` command.") -@option("-o", "--optional_parameter", multiple=True, nargs=2, - help="Pass optional parameters as raw strings.") -@option("-O", "--optional_parameter_parsed", multiple=True, nargs=2, - help="Pass optional parameters as interpreted strings.") -@option("-w", "--concurrent_workers", type=int, default=30, - help="Specify the number of concurrent network threads.") -@option("-at", "--auth_token", help="Authentication token for base environment. Used for generating a token for assets that have access control.") -@option("-ct", "--copy_tags", is_flag=True, - help="Copy tags.") -@option("-cc", "--copy_context", is_flag=True, - help="Copy context.") -@option("-cm", "--copy_metadata", is_flag=True, - help="Copy metadata. Make sure to create the metadata in the traget account beforehand with the same external id") -@option("-usj", "--use_saved_json", is_flag=True, - help="Use the saved json file if you want to skip the initial search.") -def copy(search_exp, target, optional_parameter, optional_parameter_parsed, - concurrent_workers, auth_token, copy_tags, copy_context, - copy_metadata, use_saved_json): - - if not target: - print("-T/--target is mandatory. ") - exit() - - target = normalize_list_params(target) - for val in target: - config = load_config() - if val not in config: - raise Exception(f"Config {val} does not exist") - - if auth_token: - try: - a2b_hex(auth_token) - except Exception: - print('Auth key is not valid. Please double-check.') - exit() - else: - auth_token = "" - - if use_saved_json and os.path.exists("assets_to_copy.json"): - logger.info('Using assets_to_copy.json...') - res = read_json_from_file("assets_to_copy.json") - else: - logger.info('Searching assets...') - runner = CliRunner() - runner.invoke(sync, ['from_copy_module', search_exp, - '--pull', - '--is_search_expression'], catch_exceptions=False) - res = read_json_from_file("assets_to_copy.json") - - options = { - **group_params(optional_parameter, - ((k, parse_option_value(v)) - for k, v in optional_parameter_parsed)), - } - - upload_list = [] - for r in res: - updated_options, asset_url = process_metadata(r, auth_token, options, - copy_tags, copy_context, - copy_metadata) - upload_list.append((asset_url, {**updated_options})) - - base_cloudname = cloudinary.config().cloud_name - for val in target: - refresh_cloudinary_config(config[val]) - target_cloudname = cloudinary.config().cloud_name - if base_cloudname == target_cloudname: - if not confirm_action( - "Target environment is same as base cloud. " - "Continue? (y/N)"): - logger.info("Stopping.") - exit() - else: - logger.info("Continuing.") - logger.info(style(f'Copying {len(upload_list)} asset(s) to {val}', - fg="blue")) - run_tasks_concurrently(upload_file, upload_list, - concurrent_workers) - - return True - - -def process_metadata(res, auth_t, options, copy_tags, copy_context, - copy_metadata): - cloned_options = deepcopy_module.deepcopy(options) - if res.get('access_control'): - asset_url = generate_token(res.get('public_id'), res.get('type'), - res.get('resource_type'), res.get('format'), - auth_t) - cloned_options['access_control'] = res.get('access_control') - else: - asset_url = res.get('secure_url') - cloned_options['public_id'] = res.get('public_id') - cloned_options['type'] = res.get('type') - cloned_options['resource_type'] = res.get('resource_type') - if not cloned_options.get('overwrite'): - cloned_options['overwrite'] = True - if copy_tags: - cloned_options['tags'] = res.get('tags') - if copy_context: - cloned_options['context'] = res.get('context') - if copy_metadata: - cloned_options['metadata'] = res.get('metadata') - if res.get('folder') and not cloned_options.get('asset_folder'): - cloned_options['asset_folder'] = res.get('folder') - elif res.get('asset_folder') and not cloned_options.get('asset_folder'): - cloned_options['asset_folder'] = res.get('asset_folder') - if res.get('display_name'): - cloned_options['display_name'] = res.get('display_name') - return cloned_options, asset_url - - -def generate_token(pid, type, r_type, format, auth_t): - url = cloudinary.utils.cloudinary_url( - f"{pid}.{format}", - type=type, - resource_type=r_type, - auth_token=dict(key=auth_t, - duration=30), - secure=True, - sign_url=True, - force_version=False) - return url diff --git a/cloudinary_cli/modules/sync.py b/cloudinary_cli/modules/sync.py index 10a9850..042eb72 100644 --- a/cloudinary_cli/modules/sync.py +++ b/cloudinary_cli/modules/sync.py @@ -1,4 +1,5 @@ import logging +import os.path import re from collections import Counter from itertools import groupby @@ -20,6 +21,7 @@ _SYNC_META_FILE = '.cld-sync' + @command("sync", short_help="Synchronize between a local directory and a Cloudinary folder.", help="Synchronize between a local directory and a Cloudinary folder, maintaining the folder structure.") @@ -39,28 +41,21 @@ @option("-o", "--optional_parameter", multiple=True, nargs=2, help="Pass optional parameters as raw strings.") @option("-O", "--optional_parameter_parsed", multiple=True, nargs=2, help="Pass optional parameters as interpreted strings.") -@option("-se", "--is_search_expression", is_flag=True, default=False, help="Use cloudinary_folder as a search expression term.") def sync(local_folder, cloudinary_folder, push, pull, include_hidden, concurrent_workers, force, keep_unique, - deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed, is_search_expression): + deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed): if push == pull: raise UsageError("Please use either the '--push' OR '--pull' options") - if (pull and not cld_folder_exists(cloudinary_folder)) and not is_search_expression: - logger.error(f"Cloudinary folder '{cloudinary_folder}' does not exist. Aborting...") - return False - resources_data = {} sync_dir = SyncDir(local_folder, cloudinary_folder, include_hidden, concurrent_workers, force, keep_unique, - deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed, is_search_expression, - resources_data) + deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed) result = True if push: result = sync_dir.push() elif pull: result = sync_dir.pull() - return True - if local_folder != "from_copy_module": + if result: logger.info("Done!") return result @@ -68,8 +63,7 @@ def sync(local_folder, cloudinary_folder, push, pull, include_hidden, concurrent class SyncDir: def __init__(self, local_dir, remote_dir, include_hidden, concurrent_workers, force, keep_deleted, - deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed, is_search_expression, - resources_data): + deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed): self.local_dir = local_dir self.remote_dir = remote_dir.strip('/') self.user_friendly_remote_dir = self.remote_dir if self.remote_dir else '/' @@ -83,25 +77,37 @@ def __init__(self, local_dir, remote_dir, include_hidden, concurrent_workers, fo self.optional_parameter = optional_parameter self.optional_parameter_parsed = optional_parameter_parsed - self.is_search_expression = is_search_expression - self.resources_data = resources_data self.sync_meta_file = path.join(self.local_dir, _SYNC_META_FILE) self.verbose = logger.getEffectiveLevel() < logging.INFO - self.local_files = walk_dir(path.abspath(self.local_dir), include_hidden) - if local_dir != "from_copy_module": - logger.info(f"Found {len(self.local_files)} items in local folder '{local_dir}'") - - raw_remote_files = query_cld_folder(self.remote_dir, self.folder_mode, self.is_search_expression) - if local_dir == "from_copy_module": - self.resources_data = raw_remote_files - folder_or_search_e = "with search expression" + self.local_files = {} + self.local_folder_exists = os.path.isdir(path.abspath(self.local_dir)) + if not self.local_folder_exists: + logger.info(f"Local folder '{self.local_dir}' does not exist.") + else: + self.local_files = walk_dir(path.abspath(self.local_dir), include_hidden) + if len(self.local_files): + logger.info(f"Found {len(self.local_files)} items in local folder '{self.local_dir}'") + else: + logger.info(f"Local folder '{self.local_dir}' is empty.") + + raw_remote_files = {} + self.cld_folder_exists = cld_folder_exists(self.remote_dir) + if not self.cld_folder_exists: + logger.info(f"Cloudinary folder '{self.user_friendly_remote_dir}' does not exist " + f"({self.folder_mode} folder mode).") else: - folder_or_search_e = "in Cloudinary folder" - logger.info(f"Found {len(raw_remote_files)} items {folder_or_search_e} '{self.user_friendly_remote_dir}' " - f"({self.folder_mode} folder mode)") + raw_remote_files = query_cld_folder(self.remote_dir, self.folder_mode) + if len(raw_remote_files): + logger.info( + f"Found {len(raw_remote_files)} items in Cloudinary folder '{self.user_friendly_remote_dir}' " + f"({self.folder_mode} folder mode).") + else: + logger.info(f"Cloudinary folder '{self.user_friendly_remote_dir}' is empty. " + f"({self.folder_mode} folder mode)") + self.remote_files = self._normalize_remote_file_names(raw_remote_files, self.local_files) self.remote_duplicate_names = duplicate_values(self.remote_files, "normalized_path", "asset_id") self._print_duplicate_file_names() @@ -142,13 +148,18 @@ def __init__(self, local_dir, remote_dir, include_hidden, concurrent_workers, fo self.synced_files_count = len(common_file_names) - len(self.out_of_sync_local_file_names) - if self.synced_files_count and local_dir != "from_copy_module": + if self.synced_files_count: logger.info(f"Skipping {self.synced_files_count} items") def push(self): """ Pushes changes from the local folder to the Cloudinary folder. """ + + if not self.local_folder_exists: + logger.error(f"Cannot push a non-existent local folder '{self.local_dir}'. Aborting...") + return False + if not self._handle_unique_remote_files(): logger.info("Aborting...") return False @@ -188,12 +199,12 @@ def pull(self): """ Pulls changes from the Cloudinary folder to the local folder. """ - if self.local_dir == "from_copy_module": - flattened_val = [] - for key, value in self.resources_data.items(): - flattened_val.append(value) - write_json_to_file(flattened_val, "assets_to_copy.json") - return True + + if not self.cld_folder_exists: + logger.error(f"Cannot pull from a non-existent Cloudinary folder '{self.user_friendly_remote_dir}' " + f"({self.folder_mode} folder mode). Aborting...") + return False + download_results = {} download_errors = {} if not self._handle_unique_local_files(): @@ -203,6 +214,7 @@ def pull(self): if not files_to_pull: return True + logger.info(f"Downloading {len(files_to_pull)} files from Cloudinary") downloads = [] for file in files_to_pull: @@ -210,6 +222,7 @@ def pull(self): local_path = path.abspath(path.join(self.local_dir, file)) downloads.append((remote_file, local_path, download_results, download_errors)) + try: run_tasks_concurrently(download_file, downloads, self.concurrent_workers) finally: @@ -424,4 +437,4 @@ def _handle_files_deletion_decision(self, num_files, location): } ) - return decision + return decision \ No newline at end of file diff --git a/cloudinary_cli/utils/api_utils.py b/cloudinary_cli/utils/api_utils.py index 5d08a97..20e94aa 100644 --- a/cloudinary_cli/utils/api_utils.py +++ b/cloudinary_cli/utils/api_utils.py @@ -1,5 +1,4 @@ import logging -import re from os import path, makedirs import requests @@ -14,22 +13,20 @@ from cloudinary_cli.utils.json_utils import print_json, write_json_to_file from cloudinary_cli.utils.utils import log_exception, confirm_action, get_command_params, merge_responses, \ normalize_list_params, ConfigurationError, print_api_help, duplicate_values +import re PAGINATION_MAX_RESULTS = 500 _cursor_fields = {"resource": "derived_next_cursor"} -def query_cld_folder(folder, folder_mode, is_search_expression=False): +def query_cld_folder(folder, folder_mode): files = {} - if is_search_expression: - search_e = folder - expression = Search().expression(f'{search_e}').with_field(['tags', 'metadata', 'context']).max_results(500) - else: - folder = folder.strip('/') # omit redundant leading slash and duplicate trailing slashes in query - folder_query = f"{folder}/*" if folder else "*" - expression = Search().expression(f"folder:\"{folder_query}\"").with_field("image_analysis").max_results(500) + folder = folder.strip('/') # omit redundant leading slash and duplicate trailing slashes in query + folder_query = f"{folder}/*" if folder else "*" + + expression = Search().expression(f"folder:\"{folder_query}\"").with_field("image_analysis").max_results(500) next_cursor = True while next_cursor: @@ -52,17 +49,10 @@ def query_cld_folder(folder, folder_mode, is_search_expression=False): "relative_path": rel_path, # save for inner use "access_mode": asset.get('access_mode', 'public'), "created_at": asset.get('created_at'), - "folder": asset.get('folder'), # dynamic folder mode fields "asset_folder": asset.get('asset_folder'), "display_name": asset.get('display_name'), - "relative_display_path": rel_display_path, - "tags": asset.get('tags'), - "context": asset.get('context'), - "metadata": asset.get('metadata'), - "access_control": asset.get('access_control'), - "access_mode": asset.get('access_mode'), - "secure_url": asset.get('secure_url') + "relative_display_path": rel_display_path } # use := when switch to python 3.8 next_cursor = res.get('next_cursor') @@ -70,22 +60,23 @@ def query_cld_folder(folder, folder_mode, is_search_expression=False): return files + def cld_folder_exists(folder): folder = folder.strip('/') # omit redundant leading slash and duplicate trailing slashes in query if not folder: - return True # root folder + return True # root folder - res = SearchFolders().expression(f"name=\"{folder}\"").execute() + res = SearchFolders().expression(f"path=\"{folder}\"").execute() return res.get("total_count", 0) > 0 + def _display_path(asset): if asset.get("display_name") is None: return "" - if asset.get("resource_type") == "raw": - return "/".join([asset.get("asset_folder", ""), ".".join([asset["display_name"]])]) - return "/".join([asset.get("asset_folder", ""), ".".join([asset["display_name"], asset["format"]])]) + + return "/".join([asset.get("asset_folder", ""), ".".join(filter(None, [asset["display_name"], asset.get("format", None)]))]) def _relative_display_path(asset, folder): @@ -126,6 +117,7 @@ def upload_file(file_path, options, uploaded=None, failed=None): uploaded = uploaded if uploaded is not None else {} failed = failed if failed is not None else {} verbose = logger.getEffectiveLevel() < logging.INFO + try: upload_func = uploader.upload if not re.match(r'^https?://', file_path): @@ -356,4 +348,4 @@ def handle_auto_pagination(res, func, args, kwargs, force, filter_fields): all_results.pop(cursor_field, None) - return all_results + return all_results \ No newline at end of file From 11828cc4fe676193c5ae1404f9f564bbe5d9e077 Mon Sep 17 00:00:00 2001 From: Thomas Gurung Date: Tue, 5 Nov 2024 15:49:35 +0000 Subject: [PATCH 03/14] Updating PR --- cloudinary_cli/modules/clone.py | 155 ++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 cloudinary_cli/modules/clone.py diff --git a/cloudinary_cli/modules/clone.py b/cloudinary_cli/modules/clone.py new file mode 100644 index 0000000..374344f --- /dev/null +++ b/cloudinary_cli/modules/clone.py @@ -0,0 +1,155 @@ +from click import command, argument, option, style +from cloudinary_cli.utils.utils import group_params, parse_option_value, \ + normalize_list_params +import cloudinary +from cloudinary_cli.utils.utils import confirm_action, run_tasks_concurrently +from cloudinary_cli.utils.api_utils import upload_file +from binascii import a2b_hex +from cloudinary_cli.utils.config_utils import load_config, \ + refresh_cloudinary_config, verify_cloudinary_url +from cloudinary_cli.defaults import logger +import copy as deepcopy_module +from cloudinary_cli.core.search import execute_single_request, \ + handle_auto_pagination + +DEFAULT_MAX_RESULTS = 500 + + +@command("clone", + short_help="""Clone assets, structured metadata, upload preset or named transformations from one account to another.""", + help="tbc") +@argument("search_exp", nargs=-1) +@option("-T", "--target_saved", + help="Tell the CLI the target environemnt to run the command on by specifying a saved configuration - see `config` command.") +@option("-t", "--target", + help="Tell the CLI the target environemnt to run the command on by specifying an account environment variable.") +@option("-A", "--auto_paginate", is_flag=True, default=False, + help="Auto-paginate Admin API calls.") +@option("-F", "--force", is_flag=True, + help="Skip confirmation.") +@option("-n", "--max_results", nargs=1, default=10, + help="""The maximum number of results to return. + Default: 10, maximum: 500.""") +@option("-o", "--optional_parameter", multiple=True, nargs=2, + help="Pass optional parameters as raw strings.") +@option("-O", "--optional_parameter_parsed", multiple=True, nargs=2, + help="Pass optional parameters as interpreted strings.") +@option("-w", "--concurrent_workers", type=int, default=30, + help="Specify the number of concurrent network threads.") +@option("--fields", multiple=True, help="Specify whether to copy tags and context") +@option("-at", "--auth_token", help="Authentication token for base environment. Used for generating a token for assets that have access control.") +def clone(search_exp, target_saved, target, auto_paginate, force, max_results, + optional_parameter, optional_parameter_parsed, concurrent_workers, + auth_token, fields): + + if not target and not target_saved: + print("Target (-T/-t) is mandatory. ") + exit() + elif target and target_saved: + print("Please pass either -t or -T, not both.") + exit() + + if target: + verify_cloudinary_url(target) + elif target_saved: + config = load_config() + if target_saved not in config: + raise Exception(f"Config {target_saved} does not exist") + + if fields: + copy_fields = normalize_list_params(fields) + else: + copy_fields = "" + + if auth_token: + try: + a2b_hex(auth_token) + except Exception: + print('Auth key is not valid. Please double-check.') + exit() + else: + auth_token = "" + + search = cloudinary.search.Search().expression(" ".join(search_exp)) + if auto_paginate: + max_results = DEFAULT_MAX_RESULTS + search.fields(['tags', 'context', 'access_control', + 'secure_url', 'display_name']) + search.max_results(max_results) + res = execute_single_request(search, fields_to_keep="") + if auto_paginate: + res = handle_auto_pagination(res, search, force, fields_to_keep="") + + options = { + **group_params(optional_parameter, + ((k, parse_option_value(v)) + for k, v in optional_parameter_parsed)), + } + + upload_list = [] + for r in res.get('resources'): + updated_options, asset_url = process_metadata(r, auth_token, options, + copy_fields) + upload_list.append((asset_url, {**updated_options})) + + base_cloudname = cloudinary.config().cloud_name + if target: + refresh_cloudinary_config(target) + elif target_saved: + refresh_cloudinary_config(load_config()[target_saved]) + target_cloudname = cloudinary.config().cloud_name + + if base_cloudname == target_cloudname: + if not confirm_action( + "Target environment is same as base cloud. " + "Continue? (y/N)"): + logger.info("Stopping.") + exit() + else: + logger.info("Continuing.") + logger.info(style(f'Copying {len(upload_list)} asset(s) to ' + f'{target_cloudname}', fg="blue")) + run_tasks_concurrently(upload_file, upload_list, + concurrent_workers) + + return True + + +def process_metadata(res, auth_t, options, copy_fields): + cloned_options = deepcopy_module.deepcopy(options) + if res.get('access_control'): + asset_url = generate_token(res.get('public_id'), res.get('type'), + res.get('resource_type'), res.get('format'), + auth_t) + cloned_options['access_control'] = res.get('access_control') + else: + asset_url = res.get('secure_url') + cloned_options['public_id'] = res.get('public_id') + cloned_options['type'] = res.get('type') + cloned_options['resource_type'] = res.get('resource_type') + if not cloned_options.get('overwrite'): + cloned_options['overwrite'] = True + if "tags" in copy_fields: + cloned_options['tags'] = res.get('tags') + if "context" in copy_fields: + cloned_options['context'] = res.get('context') + if res.get('folder') and not cloned_options.get('asset_folder'): + cloned_options['asset_folder'] = res.get('folder') + elif res.get('asset_folder') and not cloned_options.get('asset_folder'): + cloned_options['asset_folder'] = res.get('asset_folder') + if res.get('display_name'): + cloned_options['display_name'] = res.get('display_name') + return cloned_options, asset_url + + +def generate_token(pid, type, r_type, format, auth_t): + url = cloudinary.utils.cloudinary_url( + f"{pid}.{format}", + type=type, + resource_type=r_type, + auth_token=dict(key=auth_t, + duration=30), + secure=True, + sign_url=True, + force_version=False) + return url From f0c42e576f8376b5ab832923766a747c6a05c869 Mon Sep 17 00:00:00 2001 From: Thomas Gurung Date: Tue, 5 Nov 2024 15:55:24 +0000 Subject: [PATCH 04/14] Updating PR --- cloudinary_cli/utils/api_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudinary_cli/utils/api_utils.py b/cloudinary_cli/utils/api_utils.py index 20e94aa..b832fbc 100644 --- a/cloudinary_cli/utils/api_utils.py +++ b/cloudinary_cli/utils/api_utils.py @@ -65,7 +65,7 @@ def cld_folder_exists(folder): folder = folder.strip('/') # omit redundant leading slash and duplicate trailing slashes in query if not folder: - return True # root folder + return True # root folder res = SearchFolders().expression(f"path=\"{folder}\"").execute() From 356983d5bd4898dd7e1f433f168c924c6836017d Mon Sep 17 00:00:00 2001 From: Thomas Gurung Date: Tue, 5 Nov 2024 16:02:51 +0000 Subject: [PATCH 05/14] Updating PR --- cloudinary_cli/modules/sync.py | 51 ++++++++-------------------------- 1 file changed, 11 insertions(+), 40 deletions(-) diff --git a/cloudinary_cli/modules/sync.py b/cloudinary_cli/modules/sync.py index 042eb72..803115f 100644 --- a/cloudinary_cli/modules/sync.py +++ b/cloudinary_cli/modules/sync.py @@ -1,5 +1,4 @@ import logging -import os.path import re from collections import Counter from itertools import groupby @@ -46,6 +45,10 @@ def sync(local_folder, cloudinary_folder, push, pull, include_hidden, concurrent if push == pull: raise UsageError("Please use either the '--push' OR '--pull' options") + if pull and not cld_folder_exists(cloudinary_folder): + logger.error(f"Cloudinary folder '{cloudinary_folder}' does not exist. Aborting...") + return False + sync_dir = SyncDir(local_folder, cloudinary_folder, include_hidden, concurrent_workers, force, keep_unique, deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed) @@ -55,8 +58,7 @@ def sync(local_folder, cloudinary_folder, push, pull, include_hidden, concurrent elif pull: result = sync_dir.pull() - if result: - logger.info("Done!") + logger.info("Done!") return result @@ -82,32 +84,12 @@ def __init__(self, local_dir, remote_dir, include_hidden, concurrent_workers, fo self.verbose = logger.getEffectiveLevel() < logging.INFO - self.local_files = {} - self.local_folder_exists = os.path.isdir(path.abspath(self.local_dir)) - if not self.local_folder_exists: - logger.info(f"Local folder '{self.local_dir}' does not exist.") - else: - self.local_files = walk_dir(path.abspath(self.local_dir), include_hidden) - if len(self.local_files): - logger.info(f"Found {len(self.local_files)} items in local folder '{self.local_dir}'") - else: - logger.info(f"Local folder '{self.local_dir}' is empty.") - - raw_remote_files = {} - self.cld_folder_exists = cld_folder_exists(self.remote_dir) - if not self.cld_folder_exists: - logger.info(f"Cloudinary folder '{self.user_friendly_remote_dir}' does not exist " - f"({self.folder_mode} folder mode).") - else: - raw_remote_files = query_cld_folder(self.remote_dir, self.folder_mode) - if len(raw_remote_files): - logger.info( - f"Found {len(raw_remote_files)} items in Cloudinary folder '{self.user_friendly_remote_dir}' " - f"({self.folder_mode} folder mode).") - else: - logger.info(f"Cloudinary folder '{self.user_friendly_remote_dir}' is empty. " - f"({self.folder_mode} folder mode)") + self.local_files = walk_dir(path.abspath(self.local_dir), include_hidden) + logger.info(f"Found {len(self.local_files)} items in local folder '{local_dir}'") + raw_remote_files = query_cld_folder(self.remote_dir, self.folder_mode) + logger.info(f"Found {len(raw_remote_files)} items in Cloudinary folder '{self.user_friendly_remote_dir}' " + f"({self.folder_mode} folder mode)") self.remote_files = self._normalize_remote_file_names(raw_remote_files, self.local_files) self.remote_duplicate_names = duplicate_values(self.remote_files, "normalized_path", "asset_id") self._print_duplicate_file_names() @@ -155,11 +137,6 @@ def push(self): """ Pushes changes from the local folder to the Cloudinary folder. """ - - if not self.local_folder_exists: - logger.error(f"Cannot push a non-existent local folder '{self.local_dir}'. Aborting...") - return False - if not self._handle_unique_remote_files(): logger.info("Aborting...") return False @@ -199,12 +176,6 @@ def pull(self): """ Pulls changes from the Cloudinary folder to the local folder. """ - - if not self.cld_folder_exists: - logger.error(f"Cannot pull from a non-existent Cloudinary folder '{self.user_friendly_remote_dir}' " - f"({self.folder_mode} folder mode). Aborting...") - return False - download_results = {} download_errors = {} if not self._handle_unique_local_files(): @@ -437,4 +408,4 @@ def _handle_files_deletion_decision(self, num_files, location): } ) - return decision \ No newline at end of file + return decision From 08e727f47cf06a7a66a521dae5189851de469c32 Mon Sep 17 00:00:00 2001 From: Thomas Gurung Date: Tue, 5 Nov 2024 16:04:48 +0000 Subject: [PATCH 06/14] Updating PR --- cloudinary_cli/utils/api_utils.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/cloudinary_cli/utils/api_utils.py b/cloudinary_cli/utils/api_utils.py index b832fbc..2367817 100644 --- a/cloudinary_cli/utils/api_utils.py +++ b/cloudinary_cli/utils/api_utils.py @@ -13,7 +13,6 @@ from cloudinary_cli.utils.json_utils import print_json, write_json_to_file from cloudinary_cli.utils.utils import log_exception, confirm_action, get_command_params, merge_responses, \ normalize_list_params, ConfigurationError, print_api_help, duplicate_values -import re PAGINATION_MAX_RESULTS = 500 @@ -60,23 +59,21 @@ def query_cld_folder(folder, folder_mode): return files - def cld_folder_exists(folder): folder = folder.strip('/') # omit redundant leading slash and duplicate trailing slashes in query if not folder: return True # root folder - res = SearchFolders().expression(f"path=\"{folder}\"").execute() + res = SearchFolders().expression(f"name=\"{folder}\"").execute() return res.get("total_count", 0) > 0 - def _display_path(asset): if asset.get("display_name") is None: return "" - return "/".join([asset.get("asset_folder", ""), ".".join(filter(None, [asset["display_name"], asset.get("format", None)]))]) + return "/".join([asset.get("asset_folder", ""), ".".join([asset["display_name"], asset["format"]])]) def _relative_display_path(asset, folder): @@ -119,11 +116,10 @@ def upload_file(file_path, options, uploaded=None, failed=None): verbose = logger.getEffectiveLevel() < logging.INFO try: + size = path.getsize(file_path) upload_func = uploader.upload - if not re.match(r'^https?://', file_path): - size = path.getsize(file_path) - if size > 20000000: - upload_func = uploader.upload_large + if size > 20000000: + upload_func = uploader.upload_large result = upload_func(file_path, **options) disp_path = _display_path(result) disp_str = f"as {result['public_id']}" if not disp_path \ @@ -348,4 +344,4 @@ def handle_auto_pagination(res, func, args, kwargs, force, filter_fields): all_results.pop(cursor_field, None) - return all_results \ No newline at end of file + return all_results From 0d2f1c4ef20a34d78f8246e4f317bacfee43fb18 Mon Sep 17 00:00:00 2001 From: Thomas Gurung Date: Tue, 5 Nov 2024 16:07:55 +0000 Subject: [PATCH 07/14] Updating PR --- cloudinary_cli/utils/api_utils.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cloudinary_cli/utils/api_utils.py b/cloudinary_cli/utils/api_utils.py index 2367817..a8d22bb 100644 --- a/cloudinary_cli/utils/api_utils.py +++ b/cloudinary_cli/utils/api_utils.py @@ -13,6 +13,7 @@ from cloudinary_cli.utils.json_utils import print_json, write_json_to_file from cloudinary_cli.utils.utils import log_exception, confirm_action, get_command_params, merge_responses, \ normalize_list_params, ConfigurationError, print_api_help, duplicate_values +import re PAGINATION_MAX_RESULTS = 500 @@ -116,10 +117,11 @@ def upload_file(file_path, options, uploaded=None, failed=None): verbose = logger.getEffectiveLevel() < logging.INFO try: - size = path.getsize(file_path) upload_func = uploader.upload - if size > 20000000: - upload_func = uploader.upload_large + if not re.match(r'^https?://', file_path): + size = path.getsize(file_path) + if size > 20000000: + upload_func = uploader.upload_large result = upload_func(file_path, **options) disp_path = _display_path(result) disp_str = f"as {result['public_id']}" if not disp_path \ From 711427401e5ef236eb2961b848b1ab96dc5f24c1 Mon Sep 17 00:00:00 2001 From: Thomas Gurung Date: Tue, 5 Nov 2024 16:21:23 +0000 Subject: [PATCH 08/14] Updating PR --- cloudinary_cli/utils/api_utils.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/cloudinary_cli/utils/api_utils.py b/cloudinary_cli/utils/api_utils.py index a8d22bb..2367817 100644 --- a/cloudinary_cli/utils/api_utils.py +++ b/cloudinary_cli/utils/api_utils.py @@ -13,7 +13,6 @@ from cloudinary_cli.utils.json_utils import print_json, write_json_to_file from cloudinary_cli.utils.utils import log_exception, confirm_action, get_command_params, merge_responses, \ normalize_list_params, ConfigurationError, print_api_help, duplicate_values -import re PAGINATION_MAX_RESULTS = 500 @@ -117,11 +116,10 @@ def upload_file(file_path, options, uploaded=None, failed=None): verbose = logger.getEffectiveLevel() < logging.INFO try: + size = path.getsize(file_path) upload_func = uploader.upload - if not re.match(r'^https?://', file_path): - size = path.getsize(file_path) - if size > 20000000: - upload_func = uploader.upload_large + if size > 20000000: + upload_func = uploader.upload_large result = upload_func(file_path, **options) disp_path = _display_path(result) disp_str = f"as {result['public_id']}" if not disp_path \ From ffd5285e52a16ae2dbb6c00f96a41ae3c7f203a2 Mon Sep 17 00:00:00 2001 From: Thomas Gurung Date: Tue, 5 Nov 2024 16:32:58 +0000 Subject: [PATCH 09/14] Updating PR --- cloudinary_cli/utils/api_utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cloudinary_cli/utils/api_utils.py b/cloudinary_cli/utils/api_utils.py index 2367817..d010379 100644 --- a/cloudinary_cli/utils/api_utils.py +++ b/cloudinary_cli/utils/api_utils.py @@ -116,7 +116,10 @@ def upload_file(file_path, options, uploaded=None, failed=None): verbose = logger.getEffectiveLevel() < logging.INFO try: - size = path.getsize(file_path) + if not file_path.startswith('http'): + size = path.getsize(file_path) + else: + size = 0 upload_func = uploader.upload if size > 20000000: upload_func = uploader.upload_large From bb95c457d1be32857ee96d4d602dddeb2479a380 Mon Sep 17 00:00:00 2001 From: Thomas Gurung Date: Tue, 5 Nov 2024 16:37:48 +0000 Subject: [PATCH 10/14] Updating PR --- cloudinary_cli/utils/api_utils.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/cloudinary_cli/utils/api_utils.py b/cloudinary_cli/utils/api_utils.py index d010379..ca892ec 100644 --- a/cloudinary_cli/utils/api_utils.py +++ b/cloudinary_cli/utils/api_utils.py @@ -13,6 +13,7 @@ from cloudinary_cli.utils.json_utils import print_json, write_json_to_file from cloudinary_cli.utils.utils import log_exception, confirm_action, get_command_params, merge_responses, \ normalize_list_params, ConfigurationError, print_api_help, duplicate_values +import re PAGINATION_MAX_RESULTS = 500 @@ -116,13 +117,11 @@ def upload_file(file_path, options, uploaded=None, failed=None): verbose = logger.getEffectiveLevel() < logging.INFO try: - if not file_path.startswith('http'): - size = path.getsize(file_path) - else: - size = 0 upload_func = uploader.upload - if size > 20000000: - upload_func = uploader.upload_large + if not re.match(r'^https?://', str(file_path)): + size = path.getsize(file_path) + if size > 20000000: + upload_func = uploader.upload_large result = upload_func(file_path, **options) disp_path = _display_path(result) disp_str = f"as {result['public_id']}" if not disp_path \ From ceb2a30c6d59571968eef18527583cf2f6b4e815 Mon Sep 17 00:00:00 2001 From: Thomas Gurung Date: Wed, 6 Nov 2024 23:13:08 +0000 Subject: [PATCH 11/14] Updating PR with latest logic changes --- cloudinary_cli/modules/clone.py | 164 ++++++++++++------------------ cloudinary_cli/utils/api_utils.py | 4 +- 2 files changed, 69 insertions(+), 99 deletions(-) diff --git a/cloudinary_cli/modules/clone.py b/cloudinary_cli/modules/clone.py index 374344f..44e7195 100644 --- a/cloudinary_cli/modules/clone.py +++ b/cloudinary_cli/modules/clone.py @@ -1,112 +1,91 @@ -from click import command, argument, option, style -from cloudinary_cli.utils.utils import group_params, parse_option_value, \ - normalize_list_params +from click import command, option, style +from cloudinary_cli.utils.utils import normalize_list_params, \ + print_help_and_exit import cloudinary -from cloudinary_cli.utils.utils import confirm_action, run_tasks_concurrently +from cloudinary_cli.utils.utils import run_tasks_concurrently from cloudinary_cli.utils.api_utils import upload_file -from binascii import a2b_hex from cloudinary_cli.utils.config_utils import load_config, \ refresh_cloudinary_config, verify_cloudinary_url from cloudinary_cli.defaults import logger -import copy as deepcopy_module from cloudinary_cli.core.search import execute_single_request, \ handle_auto_pagination +import os DEFAULT_MAX_RESULTS = 500 @command("clone", - short_help="""Clone assets, structured metadata, upload preset or named transformations from one account to another.""", - help="tbc") -@argument("search_exp", nargs=-1) + short_help="""Clone assets from one account to another.""", + help=""" +\b +Clone assets from one environment to another with/without tags and context (structured metadata is not currently supported). +Source will be your `CLOUDINARY_URL` environemnt variable but you also can specify a different source using `-c/-C` option. +Cloning restricted assets is also not supported currently. +Format: cld clone -t/-T +You need to specify the target cloud via `-t` or `-T` (not both) +e.g. cld clone -t cloudinary://:@ -f tags,context -O +""") @option("-T", "--target_saved", help="Tell the CLI the target environemnt to run the command on by specifying a saved configuration - see `config` command.") @option("-t", "--target", - help="Tell the CLI the target environemnt to run the command on by specifying an account environment variable.") + help="Tell the CLI the target environemnt to run the command on by specifying an environment variable.") @option("-A", "--auto_paginate", is_flag=True, default=False, help="Auto-paginate Admin API calls.") @option("-F", "--force", is_flag=True, help="Skip confirmation.") -@option("-n", "--max_results", nargs=1, default=10, - help="""The maximum number of results to return. - Default: 10, maximum: 500.""") -@option("-o", "--optional_parameter", multiple=True, nargs=2, - help="Pass optional parameters as raw strings.") -@option("-O", "--optional_parameter_parsed", multiple=True, nargs=2, - help="Pass optional parameters as interpreted strings.") +@option("-O", "--overwrite", is_flag=True, default=False, + help="Skip confirmation.") @option("-w", "--concurrent_workers", type=int, default=30, help="Specify the number of concurrent network threads.") -@option("--fields", multiple=True, help="Specify whether to copy tags and context") -@option("-at", "--auth_token", help="Authentication token for base environment. Used for generating a token for assets that have access control.") -def clone(search_exp, target_saved, target, auto_paginate, force, max_results, - optional_parameter, optional_parameter_parsed, concurrent_workers, - auth_token, fields): - - if not target and not target_saved: - print("Target (-T/-t) is mandatory. ") - exit() - elif target and target_saved: - print("Please pass either -t or -T, not both.") - exit() - +@option("-f", "--fields", multiple=True, + help="Specify whether to copy tags and context.") +@option("-se", "--search_exp", default="", + help="Define a search expression.") +@option("--async", "async_", is_flag=True, default=False, + help="Generate asynchronously.") +@option("-nu", "--notification_url", + help="Webhook notification URL.") +def clone(target_saved, target, auto_paginate, force, + overwrite, concurrent_workers, fields, search_exp, + async_, notification_url): + if bool(target) == bool(target_saved): + print_help_and_exit() + + base_cloudname_url = os.environ.get('CLOUDINARY_URL') + base_cloudname = cloudinary.config().cloud_name if target: verify_cloudinary_url(target) elif target_saved: config = load_config() if target_saved not in config: - raise Exception(f"Config {target_saved} does not exist") - - if fields: - copy_fields = normalize_list_params(fields) - else: - copy_fields = "" - - if auth_token: - try: - a2b_hex(auth_token) - except Exception: - print('Auth key is not valid. Please double-check.') - exit() - else: - auth_token = "" + logger.error(f"Config {target_saved} does not exist") + return False + else: + refresh_config(target_saved=target_saved) + target_cloudname = cloudinary.config().cloud_name + if base_cloudname == target_cloudname: + logger.info("Target environment cannot be the " + "same as source environment.") + return True + refresh_config(base_cloudname_url) - search = cloudinary.search.Search().expression(" ".join(search_exp)) - if auto_paginate: - max_results = DEFAULT_MAX_RESULTS + copy_fields = normalize_list_params(fields) + search = cloudinary.search.Search().expression(search_exp) search.fields(['tags', 'context', 'access_control', 'secure_url', 'display_name']) - search.max_results(max_results) + search.max_results(DEFAULT_MAX_RESULTS) res = execute_single_request(search, fields_to_keep="") if auto_paginate: res = handle_auto_pagination(res, search, force, fields_to_keep="") - options = { - **group_params(optional_parameter, - ((k, parse_option_value(v)) - for k, v in optional_parameter_parsed)), - } - upload_list = [] for r in res.get('resources'): - updated_options, asset_url = process_metadata(r, auth_token, options, + updated_options, asset_url = process_metadata(r, overwrite, async_, + notification_url, copy_fields) upload_list.append((asset_url, {**updated_options})) - base_cloudname = cloudinary.config().cloud_name - if target: - refresh_cloudinary_config(target) - elif target_saved: - refresh_cloudinary_config(load_config()[target_saved]) - target_cloudname = cloudinary.config().cloud_name - - if base_cloudname == target_cloudname: - if not confirm_action( - "Target environment is same as base cloud. " - "Continue? (y/N)"): - logger.info("Stopping.") - exit() - else: - logger.info("Continuing.") + refresh_config(target, target_saved) logger.info(style(f'Copying {len(upload_list)} asset(s) to ' f'{target_cloudname}', fg="blue")) run_tasks_concurrently(upload_file, upload_list, @@ -115,41 +94,32 @@ def clone(search_exp, target_saved, target, auto_paginate, force, max_results, return True -def process_metadata(res, auth_t, options, copy_fields): - cloned_options = deepcopy_module.deepcopy(options) - if res.get('access_control'): - asset_url = generate_token(res.get('public_id'), res.get('type'), - res.get('resource_type'), res.get('format'), - auth_t) - cloned_options['access_control'] = res.get('access_control') - else: - asset_url = res.get('secure_url') +def refresh_config(target="", target_saved=""): + if target: + refresh_cloudinary_config(target) + elif target_saved: + refresh_cloudinary_config(load_config()[target_saved]) + + +def process_metadata(res, overwrite, async_, notification_url, copy_fields=""): + cloned_options = {} + asset_url = res.get('secure_url') cloned_options['public_id'] = res.get('public_id') cloned_options['type'] = res.get('type') cloned_options['resource_type'] = res.get('resource_type') - if not cloned_options.get('overwrite'): - cloned_options['overwrite'] = True + cloned_options['overwrite'] = overwrite + cloned_options['async'] = async_ if "tags" in copy_fields: cloned_options['tags'] = res.get('tags') if "context" in copy_fields: cloned_options['context'] = res.get('context') - if res.get('folder') and not cloned_options.get('asset_folder'): + if res.get('folder'): cloned_options['asset_folder'] = res.get('folder') - elif res.get('asset_folder') and not cloned_options.get('asset_folder'): + elif res.get('asset_folder'): cloned_options['asset_folder'] = res.get('asset_folder') if res.get('display_name'): cloned_options['display_name'] = res.get('display_name') - return cloned_options, asset_url - + if notification_url: + cloned_options['notification_url'] = notification_url -def generate_token(pid, type, r_type, format, auth_t): - url = cloudinary.utils.cloudinary_url( - f"{pid}.{format}", - type=type, - resource_type=r_type, - auth_token=dict(key=auth_t, - duration=30), - secure=True, - sign_url=True, - force_version=False) - return url + return cloned_options, asset_url diff --git a/cloudinary_cli/utils/api_utils.py b/cloudinary_cli/utils/api_utils.py index ca892ec..7ec078c 100644 --- a/cloudinary_cli/utils/api_utils.py +++ b/cloudinary_cli/utils/api_utils.py @@ -129,7 +129,8 @@ def upload_file(file_path, options, uploaded=None, failed=None): logger.info(style(f"Successfully uploaded {file_path} {disp_str}", fg="green")) if verbose: print_json(result) - uploaded[file_path] = {"path": asset_source(result), "display_path": disp_path} + if "async" not in options: + uploaded[file_path] = {"path": asset_source(result), "display_path": disp_path} except Exception as e: log_exception(e, f"Failed uploading {file_path}") failed[file_path] = str(e) @@ -278,7 +279,6 @@ def handle_api_command( """ Used by Admin and Upload API commands """ - if doc: return launch(doc_url) From 6160549a4a933af532034c95718edecd4f31e4c3 Mon Sep 17 00:00:00 2001 From: Thomas Gurung Date: Tue, 19 Nov 2024 23:07:42 +0000 Subject: [PATCH 12/14] Updating PR with fixes for the comments --- cloudinary_cli/modules/clone.py | 69 +++++++++++++++---------------- cloudinary_cli/utils/api_utils.py | 29 ++++++++----- 2 files changed, 51 insertions(+), 47 deletions(-) diff --git a/cloudinary_cli/modules/clone.py b/cloudinary_cli/modules/clone.py index 44e7195..7f6cc7b 100644 --- a/cloudinary_cli/modules/clone.py +++ b/cloudinary_cli/modules/clone.py @@ -4,12 +4,10 @@ import cloudinary from cloudinary_cli.utils.utils import run_tasks_concurrently from cloudinary_cli.utils.api_utils import upload_file -from cloudinary_cli.utils.config_utils import load_config, \ - refresh_cloudinary_config, verify_cloudinary_url +from cloudinary_cli.utils.config_utils import load_config from cloudinary_cli.defaults import logger from cloudinary_cli.core.search import execute_single_request, \ handle_auto_pagination -import os DEFAULT_MAX_RESULTS = 500 @@ -25,16 +23,12 @@ You need to specify the target cloud via `-t` or `-T` (not both) e.g. cld clone -t cloudinary://:@ -f tags,context -O """) -@option("-T", "--target_saved", - help="Tell the CLI the target environemnt to run the command on by specifying a saved configuration - see `config` command.") -@option("-t", "--target", - help="Tell the CLI the target environemnt to run the command on by specifying an environment variable.") -@option("-A", "--auto_paginate", is_flag=True, default=False, - help="Auto-paginate Admin API calls.") +@option("-T", "--target", + help="Tell the CLI the target environemnt to run the command on.") @option("-F", "--force", is_flag=True, help="Skip confirmation.") @option("-O", "--overwrite", is_flag=True, default=False, - help="Skip confirmation.") + help="Specify whether to overwrite existing assets.") @option("-w", "--concurrent_workers", type=int, default=30, help="Specify the number of concurrent network threads.") @option("-f", "--fields", multiple=True, @@ -45,29 +39,40 @@ help="Generate asynchronously.") @option("-nu", "--notification_url", help="Webhook notification URL.") -def clone(target_saved, target, auto_paginate, force, - overwrite, concurrent_workers, fields, search_exp, +def clone(target, force, overwrite, concurrent_workers, fields, search_exp, async_, notification_url): - if bool(target) == bool(target_saved): + if not target: print_help_and_exit() - base_cloudname_url = os.environ.get('CLOUDINARY_URL') - base_cloudname = cloudinary.config().cloud_name - if target: - verify_cloudinary_url(target) - elif target_saved: - config = load_config() - if target_saved not in config: - logger.error(f"Config {target_saved} does not exist") + target_config = cloudinary.Config() + is_cloudinary_url = False + if target.startswith("cloudinary://"): + is_cloudinary_url = True + parsed_url = target_config._parse_cloudinary_url(target) + elif target in load_config(): + parsed_url = target_config._parse_cloudinary_url(load_config().get(target)) + else: + logger.error("The specified config does not exist or the " + "CLOUDINARY_URL scheme provided is invalid " + "(expecting to start with 'cloudinary://').") + return False + + target_config._setup_from_parsed_url(parsed_url) + target_config_dict = {k: v for k, v in target_config.__dict__.items() + if not k.startswith("_")} + if is_cloudinary_url: + try: + cloudinary.api.ping(**target_config_dict) + except Exception as e: + logger.error(f"{e}. Please double-check your Cloudinary URL.") return False - else: - refresh_config(target_saved=target_saved) - target_cloudname = cloudinary.config().cloud_name - if base_cloudname == target_cloudname: + + source_cloudname = cloudinary.config().cloud_name + target_cloudname = target_config.cloud_name + if source_cloudname == target_cloudname: logger.info("Target environment cannot be the " "same as source environment.") return True - refresh_config(base_cloudname_url) copy_fields = normalize_list_params(fields) search = cloudinary.search.Search().expression(search_exp) @@ -75,17 +80,16 @@ def clone(target_saved, target, auto_paginate, force, 'secure_url', 'display_name']) search.max_results(DEFAULT_MAX_RESULTS) res = execute_single_request(search, fields_to_keep="") - if auto_paginate: - res = handle_auto_pagination(res, search, force, fields_to_keep="") + res = handle_auto_pagination(res, search, force, fields_to_keep="") upload_list = [] for r in res.get('resources'): updated_options, asset_url = process_metadata(r, overwrite, async_, notification_url, copy_fields) + updated_options.update(target_config_dict) upload_list.append((asset_url, {**updated_options})) - refresh_config(target, target_saved) logger.info(style(f'Copying {len(upload_list)} asset(s) to ' f'{target_cloudname}', fg="blue")) run_tasks_concurrently(upload_file, upload_list, @@ -94,13 +98,6 @@ def clone(target_saved, target, auto_paginate, force, return True -def refresh_config(target="", target_saved=""): - if target: - refresh_cloudinary_config(target) - elif target_saved: - refresh_cloudinary_config(load_config()[target_saved]) - - def process_metadata(res, overwrite, async_, notification_url, copy_fields=""): cloned_options = {} asset_url = res.get('secure_url') diff --git a/cloudinary_cli/utils/api_utils.py b/cloudinary_cli/utils/api_utils.py index 7ec078c..e0a1389 100644 --- a/cloudinary_cli/utils/api_utils.py +++ b/cloudinary_cli/utils/api_utils.py @@ -14,6 +14,7 @@ from cloudinary_cli.utils.utils import log_exception, confirm_action, get_command_params, merge_responses, \ normalize_list_params, ConfigurationError, print_api_help, duplicate_values import re +from cloudinary.utils import is_remote_url PAGINATION_MAX_RESULTS = 500 @@ -117,20 +118,23 @@ def upload_file(file_path, options, uploaded=None, failed=None): verbose = logger.getEffectiveLevel() < logging.INFO try: + size = 0 if is_remote_url(file_path) else path.getsize(file_path) upload_func = uploader.upload - if not re.match(r'^https?://', str(file_path)): - size = path.getsize(file_path) - if size > 20000000: - upload_func = uploader.upload_large + if size > 20000000: + upload_func = uploader.upload_large result = upload_func(file_path, **options) disp_path = _display_path(result) - disp_str = f"as {result['public_id']}" if not disp_path \ - else f"as {disp_path} with public_id: {result['public_id']}" - logger.info(style(f"Successfully uploaded {file_path} {disp_str}", fg="green")) + if "batch_id" in result: + starting_msg = "Uploading" + disp_str = f"asynchnously with batch_id: {result['batch_id']}" + else: + starting_msg = "Successfully uploaded" + disp_str = f"as {result['public_id']}" if not disp_path \ + else f"as {disp_path} with public_id: {result['public_id']}" + logger.info(style(f"{starting_msg} {file_path} {disp_str}", fg="green")) if verbose: print_json(result) - if "async" not in options: - uploaded[file_path] = {"path": asset_source(result), "display_path": disp_path} + uploaded[file_path] = {"path": asset_source(result), "display_path": disp_path} except Exception as e: log_exception(e, f"Failed uploading {file_path}") failed[file_path] = str(e) @@ -213,12 +217,15 @@ def asset_source(asset_details): :return: """ - base_name = asset_details['public_id'] + base_name = asset_details.get('public_id', '') + + if not base_name: + return base_name if asset_details['resource_type'] == 'raw' or asset_details['type'] == 'fetch': return base_name - return base_name + '.' + asset_details['format'] + return base_name + '.' + asset_details.get('format', '') def get_folder_mode(): From 4f94af0a02b64ed3f511e00ebc829f25af67cf32 Mon Sep 17 00:00:00 2001 From: Thomas Gurung Date: Tue, 19 Nov 2024 23:15:45 +0000 Subject: [PATCH 13/14] Added a comment --- cloudinary_cli/modules/clone.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cloudinary_cli/modules/clone.py b/cloudinary_cli/modules/clone.py index 7f6cc7b..c115cf7 100644 --- a/cloudinary_cli/modules/clone.py +++ b/cloudinary_cli/modules/clone.py @@ -111,6 +111,10 @@ def process_metadata(res, overwrite, async_, notification_url, copy_fields=""): if "context" in copy_fields: cloned_options['context'] = res.get('context') if res.get('folder'): + # This is required to put the asset in the correct asset_folder + # when copying from a fixed to DF (dynamic folder) cloud as if + # you just pass a `folder`param to a DF cloud, it will append + # this to the `public_id` and we don't want this. cloned_options['asset_folder'] = res.get('folder') elif res.get('asset_folder'): cloned_options['asset_folder'] = res.get('asset_folder') From 9a89159b1bcd2acdf9c12b27a40b5adcab6f5036 Mon Sep 17 00:00:00 2001 From: Thomas Gurung Date: Wed, 20 Nov 2024 11:13:32 +0000 Subject: [PATCH 14/14] Updaing help --- cloudinary_cli/modules/clone.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cloudinary_cli/modules/clone.py b/cloudinary_cli/modules/clone.py index c115cf7..1500a06 100644 --- a/cloudinary_cli/modules/clone.py +++ b/cloudinary_cli/modules/clone.py @@ -16,12 +16,12 @@ short_help="""Clone assets from one account to another.""", help=""" \b -Clone assets from one environment to another with/without tags and context (structured metadata is not currently supported). +Clone assets from one environment to another with/without tags and/or context (structured metadata is not currently supported). Source will be your `CLOUDINARY_URL` environemnt variable but you also can specify a different source using `-c/-C` option. Cloning restricted assets is also not supported currently. -Format: cld clone -t/-T -You need to specify the target cloud via `-t` or `-T` (not both) -e.g. cld clone -t cloudinary://:@ -f tags,context -O +Format: cld clone -T +`` can be a CLOUDINARY_URL or a saved config (see `config` command) +e.g. cld clone -T cloudinary://:@ -f tags,context """) @option("-T", "--target", help="Tell the CLI the target environemnt to run the command on.") @@ -113,7 +113,7 @@ def process_metadata(res, overwrite, async_, notification_url, copy_fields=""): if res.get('folder'): # This is required to put the asset in the correct asset_folder # when copying from a fixed to DF (dynamic folder) cloud as if - # you just pass a `folder`param to a DF cloud, it will append + # you just pass a `folder` param to a DF cloud, it will append # this to the `public_id` and we don't want this. cloned_options['asset_folder'] = res.get('folder') elif res.get('asset_folder'):