cloudinary-cli 1.13.0__tar.gz → 1.14.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {cloudinary_cli-1.13.0/cloudinary_cli.egg-info → cloudinary_cli-1.14.0}/PKG-INFO +34 -7
  2. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/README.md +33 -6
  3. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/cli_group.py +9 -3
  4. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/core/__init__.py +1 -1
  5. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/core/overrides.py +6 -1
  6. cloudinary_cli-1.14.0/cloudinary_cli/modules/clone.py +245 -0
  7. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/modules/sync.py +1 -1
  8. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/utils/utils.py +22 -0
  9. cloudinary_cli-1.14.0/cloudinary_cli/version.py +1 -0
  10. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0/cloudinary_cli.egg-info}/PKG-INFO +34 -7
  11. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli.egg-info/SOURCES.txt +1 -0
  12. cloudinary_cli-1.14.0/test/test_modules/test_cli_clone.py +317 -0
  13. cloudinary_cli-1.13.0/cloudinary_cli/modules/clone.py +0 -112
  14. cloudinary_cli-1.13.0/cloudinary_cli/version.py +0 -1
  15. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/LICENSE +0 -0
  16. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/MANIFEST.in +0 -0
  17. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/__init__.py +0 -0
  18. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/cli.py +0 -0
  19. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/core/admin.py +0 -0
  20. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/core/config.py +0 -0
  21. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/core/provisioning.py +0 -0
  22. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/core/search.py +0 -0
  23. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/core/uploader.py +0 -0
  24. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/core/utils.py +0 -0
  25. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/defaults.py +0 -0
  26. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/modules/__init__.py +0 -0
  27. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/modules/make.py +0 -0
  28. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/modules/migrate.py +0 -0
  29. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/modules/regen_derived.py +0 -0
  30. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/modules/upload_dir.py +0 -0
  31. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/samples/__init__.py +0 -0
  32. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/templates/html/media_library_widget +0 -0
  33. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/templates/html/product_gallery +0 -0
  34. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/templates/html/upload_widget +0 -0
  35. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/templates/html/video_player +0 -0
  36. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/templates/node/upload +0 -0
  37. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/templates/python/base +0 -0
  38. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/templates/python/explicit +0 -0
  39. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/templates/python/find_all_empty_folders +0 -0
  40. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/templates/python/upload +0 -0
  41. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/templates/ruby/upload +0 -0
  42. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/utils/__init__.py +0 -0
  43. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/utils/api_utils.py +0 -0
  44. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/utils/config_utils.py +0 -0
  45. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/utils/file_utils.py +0 -0
  46. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/utils/json_utils.py +0 -0
  47. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli/utils/search_utils.py +0 -0
  48. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli.egg-info/dependency_links.txt +0 -0
  49. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli.egg-info/entry_points.txt +0 -0
  50. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli.egg-info/not-zip-safe +0 -0
  51. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli.egg-info/requires.txt +0 -0
  52. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/cloudinary_cli.egg-info/top_level.txt +0 -0
  53. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/requirements.txt +0 -0
  54. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/setup.cfg +0 -0
  55. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/setup.py +0 -0
  56. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/test/__init__.py +0 -0
  57. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/test/helper_test.py +0 -0
  58. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/test/test_cli.py +0 -0
  59. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/test/test_cli_api.py +0 -0
  60. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/test/test_cli_config.py +0 -0
  61. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/test/test_cli_samples.py +0 -0
  62. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/test/test_cli_search_api.py +0 -0
  63. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/test/test_cli_url.py +0 -0
  64. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/test/test_cli_utils.py +0 -0
  65. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/test/test_file_utils.py +0 -0
  66. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/test/test_modules/__init__.py +0 -0
  67. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/test/test_modules/test_cli_make.py +0 -0
  68. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/test/test_modules/test_cli_sync.py +0 -0
  69. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/test/test_modules/test_cli_upload_dir.py +0 -0
  70. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/test/test_search_utils.py +0 -0
  71. {cloudinary_cli-1.13.0 → cloudinary_cli-1.14.0}/test/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cloudinary-cli
3
- Version: 1.13.0
3
+ Version: 1.14.0
4
4
  Summary: A command line interface for Cloudinary with full API support
5
5
  Home-page: https://github.com/cloudinary/cloudinary-cli
6
6
  Author: Cloudinary, Brian Luk
@@ -57,7 +57,7 @@ _**Note:** you can copy and paste your account environment variable from the Acc
57
57
  api_secret: ***************<LAST_4_DIGITS>
58
58
  private_cdn: <True|False>
59
59
  ```
60
-
60
+
61
61
  If you get an error message when running `cld config`, you may need to add your Python installation to your $PATH. To do so, you can run `PATH="$PATH:/Library/Python/Versions/3.8/bin"` in your terminal, and add `export PATH="$PATH:/Library/Python/Versions/3.8/bin"` to your `/.bash_profile` or `~/.zshrc`.
62
62
 
63
63
  ## Quickstart
@@ -77,11 +77,38 @@ cld admin # Lists Admin API methods.
77
77
  cld uploader # Lists Upload API methods.
78
78
  ```
79
79
 
80
+ ## Docker Usage
81
+
82
+ The Cloudinary CLI is also available as a Docker image, perfect for containerized environments, CI/CD pipelines, or when you prefer not to install Python locally.
83
+
84
+ ### Quick Docker Examples
85
+
86
+ ```sh
87
+ # Check configuration
88
+ docker run --rm -e CLOUDINARY_URL cloudinary/cli config
89
+
90
+ # Upload files from your local machine
91
+ docker run --rm -e CLOUDINARY_URL \
92
+ -v /path/to/your/images:/app/images \
93
+ cloudinary/cli uploader upload /app/images/sample.jpg
94
+
95
+ # Search for assets
96
+ docker run --rm -e CLOUDINARY_URL \
97
+ cloudinary/cli search "resource_type:image" --max_results 5
98
+
99
+ # Interactive mode for multiple commands
100
+ docker run --rm -it -e CLOUDINARY_URL --entrypoint /bin/bash cloudinary/cli
101
+ ```
102
+
103
+ **Docker Image**: `cloudinary/cli` • **Base**: Debian 12 • **Size**: ~175MB • **Multi-arch**: amd64, arm64
104
+
105
+ For comprehensive Docker usage, examples, and troubleshooting, see [DOCKER.md](DOCKER.md).
106
+
80
107
  ## Upload API
81
108
 
82
109
  Enables you to run any methods that can be called through the upload API.
83
110
 
84
- You can find documentation for each of the Upload API methods at [https://cloudinary.com/documentation/image_upload_api_reference](https://cloudinary.com/documentation/image_upload_api_reference).
111
+ You can find documentation for each of the Upload API methods at [https://cloudinary.com/documentation/image_upload_api_reference](https://cloudinary.com/documentation/image_upload_api_reference).
85
112
 
86
113
  The basic syntax using the Upload API is as follows:
87
114
 
@@ -152,7 +179,7 @@ cld [cli options] url [command options] public_id [transformation]
152
179
 
153
180
  For details, see the [Cloudinary CLI documentation](https://cloudinary.com/documentation/cloudinary_cli#url).
154
181
 
155
- Example: generate a URL that displays the image in your media library that has the public ID of 'sample', with a width of 500 pixels and transformed using the cartoonify effect, then open this URL in a browser.
182
+ Example: generate a URL that displays the image in your media library that has the public ID of 'sample', with a width of 500 pixels and transformed using the cartoonify effect, then open this URL in a browser.
156
183
 
157
184
  ```
158
185
  cld url -rt image -t upload -o sample w_500,e_cartoonify
@@ -187,9 +214,9 @@ Uploads a folder of assets, maintaining the folder structure.
187
214
  cld [cli options] upload_dir [command options] [local_folder]
188
215
  ```
189
216
 
190
- For details, see the [Cloudinary CLI documentation](https://cloudinary.com/documentation/cloudinary_cli#upload_dir).
217
+ For details, see the [Cloudinary CLI documentation](https://cloudinary.com/documentation/cloudinary_cli#upload_dir).
191
218
 
192
- Example: upload the local folder, my_images, and all its contents and sub-folders to your Cloudinary folder my_images_on_cloudinary.
219
+ Example: upload the local folder, my_images, and all its contents and sub-folders to your Cloudinary folder my_images_on_cloudinary.
193
220
 
194
221
  ```
195
222
  cld upload_dir -f my_images_on_cloudinary my_images
@@ -227,7 +254,7 @@ A configuration is a reference to a specified Cloudinary account or cloud name v
227
254
 
228
255
  The `config` command displays the current configuration and lets you manage additional configurations.
229
256
 
230
- You can specify the environment variable of additional Cloudinary accounts either explicitly (`-c` option) or as a saved configuration (`-C` option).
257
+ You can specify the environment variable of additional Cloudinary accounts either explicitly (`-c` option) or as a saved configuration (`-C` option).
231
258
 
232
259
  For example, using the `-c` option:
233
260
 
@@ -32,7 +32,7 @@ _**Note:** you can copy and paste your account environment variable from the Acc
32
32
  api_secret: ***************<LAST_4_DIGITS>
33
33
  private_cdn: <True|False>
34
34
  ```
35
-
35
+
36
36
  If you get an error message when running `cld config`, you may need to add your Python installation to your $PATH. To do so, you can run `PATH="$PATH:/Library/Python/Versions/3.8/bin"` in your terminal, and add `export PATH="$PATH:/Library/Python/Versions/3.8/bin"` to your `/.bash_profile` or `~/.zshrc`.
37
37
 
38
38
  ## Quickstart
@@ -52,11 +52,38 @@ cld admin # Lists Admin API methods.
52
52
  cld uploader # Lists Upload API methods.
53
53
  ```
54
54
 
55
+ ## Docker Usage
56
+
57
+ The Cloudinary CLI is also available as a Docker image, perfect for containerized environments, CI/CD pipelines, or when you prefer not to install Python locally.
58
+
59
+ ### Quick Docker Examples
60
+
61
+ ```sh
62
+ # Check configuration
63
+ docker run --rm -e CLOUDINARY_URL cloudinary/cli config
64
+
65
+ # Upload files from your local machine
66
+ docker run --rm -e CLOUDINARY_URL \
67
+ -v /path/to/your/images:/app/images \
68
+ cloudinary/cli uploader upload /app/images/sample.jpg
69
+
70
+ # Search for assets
71
+ docker run --rm -e CLOUDINARY_URL \
72
+ cloudinary/cli search "resource_type:image" --max_results 5
73
+
74
+ # Interactive mode for multiple commands
75
+ docker run --rm -it -e CLOUDINARY_URL --entrypoint /bin/bash cloudinary/cli
76
+ ```
77
+
78
+ **Docker Image**: `cloudinary/cli` • **Base**: Debian 12 • **Size**: ~175MB • **Multi-arch**: amd64, arm64
79
+
80
+ For comprehensive Docker usage, examples, and troubleshooting, see [DOCKER.md](DOCKER.md).
81
+
55
82
  ## Upload API
56
83
 
57
84
  Enables you to run any methods that can be called through the upload API.
58
85
 
59
- You can find documentation for each of the Upload API methods at [https://cloudinary.com/documentation/image_upload_api_reference](https://cloudinary.com/documentation/image_upload_api_reference).
86
+ You can find documentation for each of the Upload API methods at [https://cloudinary.com/documentation/image_upload_api_reference](https://cloudinary.com/documentation/image_upload_api_reference).
60
87
 
61
88
  The basic syntax using the Upload API is as follows:
62
89
 
@@ -127,7 +154,7 @@ cld [cli options] url [command options] public_id [transformation]
127
154
 
128
155
  For details, see the [Cloudinary CLI documentation](https://cloudinary.com/documentation/cloudinary_cli#url).
129
156
 
130
- Example: generate a URL that displays the image in your media library that has the public ID of 'sample', with a width of 500 pixels and transformed using the cartoonify effect, then open this URL in a browser.
157
+ Example: generate a URL that displays the image in your media library that has the public ID of 'sample', with a width of 500 pixels and transformed using the cartoonify effect, then open this URL in a browser.
131
158
 
132
159
  ```
133
160
  cld url -rt image -t upload -o sample w_500,e_cartoonify
@@ -162,9 +189,9 @@ Uploads a folder of assets, maintaining the folder structure.
162
189
  cld [cli options] upload_dir [command options] [local_folder]
163
190
  ```
164
191
 
165
- For details, see the [Cloudinary CLI documentation](https://cloudinary.com/documentation/cloudinary_cli#upload_dir).
192
+ For details, see the [Cloudinary CLI documentation](https://cloudinary.com/documentation/cloudinary_cli#upload_dir).
166
193
 
167
- Example: upload the local folder, my_images, and all its contents and sub-folders to your Cloudinary folder my_images_on_cloudinary.
194
+ Example: upload the local folder, my_images, and all its contents and sub-folders to your Cloudinary folder my_images_on_cloudinary.
168
195
 
169
196
  ```
170
197
  cld upload_dir -f my_images_on_cloudinary my_images
@@ -202,7 +229,7 @@ A configuration is a reference to a specified Cloudinary account or cloud name v
202
229
 
203
230
  The `config` command displays the current configuration and lets you manage additional configurations.
204
231
 
205
- You can specify the environment variable of additional Cloudinary accounts either explicitly (`-c` option) or as a saved configuration (`-C` option).
232
+ You can specify the environment variable of additional Cloudinary accounts either explicitly (`-c` option) or as a saved configuration (`-C` option).
206
233
 
207
234
  For example, using the `-c` option:
208
235
 
@@ -14,7 +14,7 @@ from cloudinary_cli.version import __version__ as cli_version
14
14
  CONTEXT_SETTINGS = dict(max_content_width=shutil.get_terminal_size()[0], terminal_width=shutil.get_terminal_size()[0])
15
15
 
16
16
 
17
- @click.group(context_settings=CONTEXT_SETTINGS)
17
+ @click.group(context_settings=CONTEXT_SETTINGS, invoke_without_command=True)
18
18
  @click.help_option()
19
19
  @click.version_option(cli_version, prog_name="Cloudinary CLI",
20
20
  message=f"%(prog)s, version %(version)s\n"
@@ -24,10 +24,11 @@ CONTEXT_SETTINGS = dict(max_content_width=shutil.get_terminal_size()[0], termina
24
24
  help="""Tell the CLI which account to run the command on by specifying an account environment variable."""
25
25
  )
26
26
  @click.option("-C", "--config_saved",
27
- help="""Tell the CLI which account to run the command on by specifying a saved configuration - see
27
+ help="""Tell the CLI which account to run the command on by specifying a saved configuration - see
28
28
  `config` command.""")
29
29
  @click_log.simple_verbosity_option(logger)
30
- def cli(config, config_saved):
30
+ @click.pass_context
31
+ def cli(ctx, config, config_saved):
31
32
  if config:
32
33
  refresh_cloudinary_config(config)
33
34
  elif config_saved:
@@ -40,4 +41,9 @@ def cli(config, config_saved):
40
41
  if not is_valid_cloudinary_config():
41
42
  logger.warning("No Cloudinary configuration found.")
42
43
 
44
+ # If no subcommand was invoked, show help and exit with code 0
45
+ if ctx.invoked_subcommand is None:
46
+ click.echo(ctx.get_help())
47
+ ctx.exit(0)
48
+
43
49
  return True
@@ -8,7 +8,7 @@ from cloudinary_cli.core.provisioning import provisioning
8
8
  from cloudinary_cli.core.utils import url, utils
9
9
  from cloudinary_cli.core.overrides import resolve_command
10
10
 
11
- setattr(click.MultiCommand, "resolve_command", resolve_command)
11
+ setattr(click.Group, "resolve_command", resolve_command)
12
12
 
13
13
  commands = [
14
14
  config,
@@ -1,14 +1,19 @@
1
- from click.parser import split_opt
2
1
  from click.utils import make_str
3
2
  from cloudinary import api, uploader
4
3
  from cloudinary.uploader import upload as original_upload
5
4
  from cloudinary.utils import cloudinary_url as original_cloudinary_url
5
+ from cloudinary_cli.utils.utils import split_opt
6
6
 
7
7
 
8
8
  # overrides click.MultiCommand.resolve_command
9
9
  def resolve_command(self, ctx, args):
10
10
  # Patch the `resolve_command` function to enable simple commands (eg. cld resource)
11
11
  # Only core commands from API and modules are registered (eg. cld admin)
12
+
13
+ # Handle empty args (when CLI is invoked with no arguments)
14
+ if not args:
15
+ return None, None, []
16
+
12
17
  cmd_name = make_str(args[0])
13
18
  original_cmd_name = cmd_name
14
19
 
@@ -0,0 +1,245 @@
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.auth_token import _digest
5
+ from cloudinary_cli.utils.utils import run_tasks_concurrently
6
+ from cloudinary_cli.utils.api_utils import upload_file
7
+ from cloudinary_cli.utils.config_utils import get_cloudinary_config, config_to_dict
8
+ from cloudinary_cli.defaults import logger
9
+ from cloudinary_cli.core.search import execute_single_request, handle_auto_pagination
10
+ import time
11
+ import re
12
+
13
+ DEFAULT_MAX_RESULTS = 500
14
+ ALLOWED_TYPE_VALUES = ("upload", "private", "authenticated")
15
+
16
+
17
+ @command("clone",
18
+ short_help="""Clone assets from one product environment to another.""",
19
+ help="""
20
+ \b
21
+ Clone assets from one product environment to another with/without tags and/or context (structured metadata is not currently supported).
22
+ Source will be your `CLOUDINARY_URL` environment variable but you also can specify a different source using the `-c/-C` option.
23
+ Format: cld clone <target_environment> <command options>
24
+ `<target_environment>` can be a CLOUDINARY_URL or a saved config (see `config` command)
25
+ Example 1 (Copy all assets including tags and context using CLOUDINARY URL):
26
+ cld clone cloudinary://<api_key>:<api_secret>@<cloudname> -fi tags,context
27
+ Example 2 (Copy all assets with a specific tag via a search expression using a saved config):
28
+ cld clone <config_name> -se "tags:<tag_name>"
29
+ """)
30
+ @argument("target")
31
+ @option("-F", "--force", is_flag=True,
32
+ help="Skip confirmation.")
33
+ @option("-ow", "--overwrite", is_flag=True, default=False,
34
+ help="Specify whether to overwrite existing assets.")
35
+ @option("-w", "--concurrent_workers", type=int, default=30,
36
+ help="Specify the number of concurrent network threads.")
37
+ @option("-fi", "--fields", multiple=True,
38
+ help=("Specify whether to copy tags and/or context. "
39
+ "Valid options: `tags,context`."))
40
+ @option("-se", "--search_exp", default="",
41
+ help="Define a search expression to filter the assets to clone.")
42
+ @option("--async", "async_", is_flag=True, default=False,
43
+ help="Clone the assets asynchronously.")
44
+ @option("-nu", "--notification_url",
45
+ help="Webhook notification URL.")
46
+ @option("-ue", "--url_expiry", type=int, default=3600,
47
+ help=("URL expiration duration in seconds. Only relevant if cloning "
48
+ "restricted assets with an auth_key configured. "
49
+ "If you do not provide an auth_key, "
50
+ "a private download URL is generated which may incur additional "
51
+ "bandwidth costs."))
52
+ def clone(target, force, overwrite, concurrent_workers, fields,
53
+ search_exp, async_, notification_url, url_expiry):
54
+ target_config, auth_token = _validate_clone_inputs(target)
55
+ if not target_config:
56
+ return False
57
+
58
+ source_assets = search_assets(search_exp, force)
59
+ if not source_assets:
60
+ return False
61
+ if not isinstance(source_assets, dict) or not source_assets.get('resources'):
62
+ logger.error(style(f"No asset(s) found in {cloudinary.config().cloud_name}", fg="red"))
63
+ return False
64
+
65
+ upload_list = _prepare_upload_list(
66
+ source_assets, target_config, overwrite, async_,
67
+ notification_url, auth_token, url_expiry, fields
68
+ )
69
+
70
+ logger.info(style(f"Copying {len(upload_list)} asset(s) from "
71
+ f"{cloudinary.config().cloud_name} to "
72
+ f"{target_config.cloud_name}", fg="blue"))
73
+
74
+ run_tasks_concurrently(upload_file, upload_list, concurrent_workers)
75
+
76
+ return True
77
+
78
+
79
+ def _validate_clone_inputs(target):
80
+ if not target:
81
+ print_help_and_exit()
82
+
83
+ target_config = get_cloudinary_config(target)
84
+ if not target_config:
85
+ logger.error("The specified config does not exist or the "
86
+ "CLOUDINARY_URL scheme provided is invalid "
87
+ "(expecting to start with 'cloudinary://').")
88
+ return None, None
89
+
90
+ if cloudinary.config().cloud_name == target_config.cloud_name:
91
+ logger.error("Target environment cannot be the same "
92
+ "as source environment.")
93
+ return None, None
94
+
95
+ auth_token = cloudinary.config().auth_token
96
+ if auth_token:
97
+ # It is important to validate auth_token if provided as this prevents
98
+ # customer from having to re-run the command as well as
99
+ # saving Admin API calls and time.
100
+ try:
101
+ cloudinary.utils.generate_auth_token(acl="/image/*")
102
+ except Exception as e:
103
+ logger.error(f"{e} - auth_token validation failed. "
104
+ "Please double-check your auth_token parameters.")
105
+ return None, None
106
+
107
+ return target_config, auth_token
108
+
109
+
110
+ def _prepare_upload_list(source_assets, target_config, overwrite, async_,
111
+ notification_url, auth_token, url_expiry, fields):
112
+ upload_list = []
113
+ for r in source_assets.get('resources'):
114
+ updated_options, asset_url = process_metadata(r, overwrite, async_,
115
+ notification_url,
116
+ auth_token, url_expiry,
117
+ normalize_list_params(fields))
118
+ updated_options.update(config_to_dict(target_config))
119
+ upload_list.append((asset_url, {**updated_options}))
120
+ return upload_list
121
+
122
+
123
+ def search_assets(search_exp, force):
124
+ search_exp = _normalize_search_expression(search_exp)
125
+ if not search_exp:
126
+ return False
127
+
128
+ search = cloudinary.search.Search().expression(search_exp)
129
+ search.fields(['tags', 'context', 'access_control',
130
+ 'secure_url', 'display_name', 'format'])
131
+ search.max_results(DEFAULT_MAX_RESULTS)
132
+
133
+ res = execute_single_request(search, fields_to_keep="")
134
+ res = handle_auto_pagination(res, search, force, fields_to_keep="")
135
+
136
+ return res
137
+
138
+
139
+ def _normalize_search_expression(search_exp):
140
+ """
141
+ Ensures the search expression has a valid 'type' filter.
142
+
143
+ - If no expression is given, a default is created.
144
+ - If 'type' filters exist, they are validated.
145
+ - If no 'type' filters exist, the default is appended.
146
+ """
147
+ default_types_str = " OR ".join(f"type:{t}" for t in ALLOWED_TYPE_VALUES)
148
+
149
+ if not search_exp:
150
+ return default_types_str
151
+
152
+ # Use a simple regex to find all 'type' filters
153
+ found_types = re.findall(r"\btype\s*[:=]\s*(\w+)", search_exp)
154
+
155
+ if not found_types:
156
+ # No 'type' filter found, so append the default
157
+ return f"{search_exp} AND ({default_types_str})"
158
+
159
+ # A 'type' filter was found, so validate it
160
+ invalid_types = {t for t in found_types if t not in ALLOWED_TYPE_VALUES}
161
+
162
+ if invalid_types:
163
+ error_msg = ", ".join(f"type:{t}" for t in invalid_types)
164
+ logger.error(
165
+ f"Unsupported type(s) in search expression: {error_msg}. "
166
+ f"Only {', '.join(ALLOWED_TYPE_VALUES)} types allowed."
167
+ )
168
+ return None
169
+
170
+ # All found types are valid, so return the original expression
171
+ return search_exp
172
+
173
+
174
+ def process_metadata(res, overwrite, async_, notification_url, auth_token, url_expiry, copy_fields=None):
175
+ if copy_fields is None:
176
+ copy_fields = []
177
+ asset_url = _get_asset_url(res, auth_token, url_expiry)
178
+ cloned_options = _build_cloned_options(res, overwrite, async_, notification_url, copy_fields)
179
+
180
+ return cloned_options, asset_url
181
+
182
+
183
+ def _get_asset_url(res, auth_token, url_expiry):
184
+ if not (isinstance(res.get('access_control'), list) and
185
+ len(res.get('access_control')) > 0 and
186
+ isinstance(res['access_control'][0], dict) and
187
+ res['access_control'][0].get("access_type") == "token"):
188
+ return res.get('secure_url')
189
+
190
+ reso_type = res.get('resource_type')
191
+ del_type = res.get('type')
192
+ pub_id = res.get('public_id')
193
+ file_format = res.get('format')
194
+
195
+ if auth_token:
196
+ # Raw assets already have the format in the public_id
197
+ pub_id_format = pub_id if reso_type == "raw" else f"{pub_id}.{file_format}"
198
+ return cloudinary.utils.cloudinary_url(
199
+ pub_id_format,
200
+ type=del_type,
201
+ resource_type=reso_type,
202
+ auth_token={"duration": url_expiry},
203
+ secure=True,
204
+ sign_url=True
205
+ )
206
+
207
+ # Use private url if no auth_token provided
208
+ return cloudinary.utils.private_download_url(
209
+ pub_id,
210
+ file_format,
211
+ resource_type=reso_type,
212
+ type=del_type,
213
+ expires_at=int(time.time()) + url_expiry
214
+ )
215
+
216
+
217
+ def _build_cloned_options(res, overwrite, async_, notification_url, copy_fields):
218
+ # 1. Start with mandatory options
219
+ cloned_options = {
220
+ 'overwrite': overwrite,
221
+ 'async': async_,
222
+ }
223
+
224
+ # 2. Copy fields from source asset. Some are standard, others are from user input.
225
+ fields_to_copy = {'public_id', 'type', 'resource_type', 'access_control'}.union(copy_fields)
226
+ cloned_options.update({field: res.get(field) for field in fields_to_copy})
227
+
228
+ # 3. Handle fields that are added only if they have a truthy value
229
+ if res.get('display_name'):
230
+ cloned_options['display_name'] = res['display_name']
231
+
232
+ # This is required to put the asset in the correct asset_folder
233
+ # when copying from a fixed to DF (dynamic folder) cloud as if
234
+ # you just pass a `folder` param to a DF cloud, it will append
235
+ # this to the `public_id` and we don't want this.
236
+ if res.get('folder'):
237
+ cloned_options['asset_folder'] = res['folder']
238
+ elif res.get('asset_folder'):
239
+ cloned_options['asset_folder'] = res['asset_folder']
240
+
241
+ if notification_url:
242
+ cloned_options['notification_url'] = notification_url
243
+
244
+ # 4. Clean up any None values before returning
245
+ return {k: v for k, v in cloned_options.items() if v is not None}
@@ -300,7 +300,7 @@ class SyncDir:
300
300
 
301
301
  def _local_candidates(self, candidate_path):
302
302
  filename, extension = path.splitext(candidate_path)
303
- r = re.compile(f"({candidate_path}|{filename} \(\d+\){extension})")
303
+ r = re.compile(f"({candidate_path}|{filename} \\(\\d+\\){extension})")
304
304
  # sort local files by base name (without ext) for accurate results.
305
305
  return dict(sorted({f: self.local_files[f]["etag"] for f in filter(r.match, self.local_files.keys())}.items(),
306
306
  key=lambda f: path.splitext(f[0])[0]))
@@ -379,3 +379,25 @@ def duplicate_values(items, value_key, key_of_interest=None):
379
379
  rev_multidict.setdefault(value[value_key], set()).add(value[key_of_interest] if key_of_interest is not None else key)
380
380
 
381
381
  return {key: values for key, values in rev_multidict.items() if len(values) > 1}
382
+
383
+
384
+ def split_opt(opt):
385
+ """
386
+ Splits an option string into prefix and value parts.
387
+
388
+ This function replaces the deprecated click.parser.split_opt import.
389
+ Returns a tuple of (prefix, value) where prefix is the option prefix
390
+ (like '-' or '--') and value is the remaining part, or ('', opt)
391
+ if it doesn't look like an option.
392
+
393
+ :param opt: The option string to parse.
394
+ :type opt: str
395
+ :return: Tuple of (prefix, value)
396
+ :rtype: tuple
397
+ """
398
+ first = opt[:1]
399
+ if first.isalnum():
400
+ return '', opt
401
+ if opt[1:2] == first:
402
+ return opt[:2], opt[2:]
403
+ return first, opt[1:]
@@ -0,0 +1 @@
1
+ __version__ = "1.14.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cloudinary-cli
3
- Version: 1.13.0
3
+ Version: 1.14.0
4
4
  Summary: A command line interface for Cloudinary with full API support
5
5
  Home-page: https://github.com/cloudinary/cloudinary-cli
6
6
  Author: Cloudinary, Brian Luk
@@ -57,7 +57,7 @@ _**Note:** you can copy and paste your account environment variable from the Acc
57
57
  api_secret: ***************<LAST_4_DIGITS>
58
58
  private_cdn: <True|False>
59
59
  ```
60
-
60
+
61
61
  If you get an error message when running `cld config`, you may need to add your Python installation to your $PATH. To do so, you can run `PATH="$PATH:/Library/Python/Versions/3.8/bin"` in your terminal, and add `export PATH="$PATH:/Library/Python/Versions/3.8/bin"` to your `/.bash_profile` or `~/.zshrc`.
62
62
 
63
63
  ## Quickstart
@@ -77,11 +77,38 @@ cld admin # Lists Admin API methods.
77
77
  cld uploader # Lists Upload API methods.
78
78
  ```
79
79
 
80
+ ## Docker Usage
81
+
82
+ The Cloudinary CLI is also available as a Docker image, perfect for containerized environments, CI/CD pipelines, or when you prefer not to install Python locally.
83
+
84
+ ### Quick Docker Examples
85
+
86
+ ```sh
87
+ # Check configuration
88
+ docker run --rm -e CLOUDINARY_URL cloudinary/cli config
89
+
90
+ # Upload files from your local machine
91
+ docker run --rm -e CLOUDINARY_URL \
92
+ -v /path/to/your/images:/app/images \
93
+ cloudinary/cli uploader upload /app/images/sample.jpg
94
+
95
+ # Search for assets
96
+ docker run --rm -e CLOUDINARY_URL \
97
+ cloudinary/cli search "resource_type:image" --max_results 5
98
+
99
+ # Interactive mode for multiple commands
100
+ docker run --rm -it -e CLOUDINARY_URL --entrypoint /bin/bash cloudinary/cli
101
+ ```
102
+
103
+ **Docker Image**: `cloudinary/cli` • **Base**: Debian 12 • **Size**: ~175MB • **Multi-arch**: amd64, arm64
104
+
105
+ For comprehensive Docker usage, examples, and troubleshooting, see [DOCKER.md](DOCKER.md).
106
+
80
107
  ## Upload API
81
108
 
82
109
  Enables you to run any methods that can be called through the upload API.
83
110
 
84
- You can find documentation for each of the Upload API methods at [https://cloudinary.com/documentation/image_upload_api_reference](https://cloudinary.com/documentation/image_upload_api_reference).
111
+ You can find documentation for each of the Upload API methods at [https://cloudinary.com/documentation/image_upload_api_reference](https://cloudinary.com/documentation/image_upload_api_reference).
85
112
 
86
113
  The basic syntax using the Upload API is as follows:
87
114
 
@@ -152,7 +179,7 @@ cld [cli options] url [command options] public_id [transformation]
152
179
 
153
180
  For details, see the [Cloudinary CLI documentation](https://cloudinary.com/documentation/cloudinary_cli#url).
154
181
 
155
- Example: generate a URL that displays the image in your media library that has the public ID of 'sample', with a width of 500 pixels and transformed using the cartoonify effect, then open this URL in a browser.
182
+ Example: generate a URL that displays the image in your media library that has the public ID of 'sample', with a width of 500 pixels and transformed using the cartoonify effect, then open this URL in a browser.
156
183
 
157
184
  ```
158
185
  cld url -rt image -t upload -o sample w_500,e_cartoonify
@@ -187,9 +214,9 @@ Uploads a folder of assets, maintaining the folder structure.
187
214
  cld [cli options] upload_dir [command options] [local_folder]
188
215
  ```
189
216
 
190
- For details, see the [Cloudinary CLI documentation](https://cloudinary.com/documentation/cloudinary_cli#upload_dir).
217
+ For details, see the [Cloudinary CLI documentation](https://cloudinary.com/documentation/cloudinary_cli#upload_dir).
191
218
 
192
- Example: upload the local folder, my_images, and all its contents and sub-folders to your Cloudinary folder my_images_on_cloudinary.
219
+ Example: upload the local folder, my_images, and all its contents and sub-folders to your Cloudinary folder my_images_on_cloudinary.
193
220
 
194
221
  ```
195
222
  cld upload_dir -f my_images_on_cloudinary my_images
@@ -227,7 +254,7 @@ A configuration is a reference to a specified Cloudinary account or cloud name v
227
254
 
228
255
  The `config` command displays the current configuration and lets you manage additional configurations.
229
256
 
230
- You can specify the environment variable of additional Cloudinary accounts either explicitly (`-c` option) or as a saved configuration (`-C` option).
257
+ You can specify the environment variable of additional Cloudinary accounts either explicitly (`-c` option) or as a saved configuration (`-C` option).
231
258
 
232
259
  For example, using the `-c` option:
233
260
 
@@ -61,6 +61,7 @@ test/test_file_utils.py
61
61
  test/test_search_utils.py
62
62
  test/test_utils.py
63
63
  test/test_modules/__init__.py
64
+ test/test_modules/test_cli_clone.py
64
65
  test/test_modules/test_cli_make.py
65
66
  test/test_modules/test_cli_sync.py
66
67
  test/test_modules/test_cli_upload_dir.py
@@ -0,0 +1,317 @@
1
+ import unittest
2
+ from unittest.mock import patch, MagicMock
3
+ import re
4
+ import sys
5
+
6
+ # Import the modules package, which will load the clone module.
7
+ # The 'clone' name in the package is the command object, so we get the module from sys.modules.
8
+ import cloudinary_cli.modules
9
+ clone_module = sys.modules['cloudinary_cli.modules.clone']
10
+
11
+ from cloudinary_cli.defaults import logger
12
+
13
+
14
+ class TestCLIClone(unittest.TestCase):
15
+
16
+ def setUp(self):
17
+ self.mock_search_result = {
18
+ 'resources': [
19
+ {
20
+ 'public_id': 'sample',
21
+ 'type': 'upload',
22
+ 'resource_type': 'image',
23
+ 'format': 'jpg',
24
+ 'secure_url': 'https://res.cloudinary.com/demo/image/upload/v1234567890/sample.jpg',
25
+ 'tags': ['tag1', 'tag2'],
26
+ 'context': {'key': 'value'},
27
+ 'folder': 'test_folder',
28
+ 'display_name': 'Test Asset'
29
+ }
30
+ ]
31
+ }
32
+
33
+ @patch.object(clone_module, 'handle_auto_pagination')
34
+ @patch.object(clone_module, 'execute_single_request')
35
+ @patch('cloudinary.search.Search')
36
+ def test_search_assets_default_expression(self, mock_search_class, mock_execute, mock_pagination):
37
+ """Test search_assets with empty search expression uses default"""
38
+ mock_search = MagicMock()
39
+ mock_search_class.return_value = mock_search
40
+ mock_execute.return_value = self.mock_search_result
41
+ mock_pagination.return_value = self.mock_search_result
42
+
43
+ result = clone_module.search_assets(force=True, search_exp="")
44
+
45
+ # Verify default search expression is used
46
+ mock_search.expression.assert_called_with("type:upload OR type:private OR type:authenticated")
47
+ self.assertEqual(result, self.mock_search_result)
48
+
49
+ @patch.object(clone_module, 'handle_auto_pagination')
50
+ @patch.object(clone_module, 'execute_single_request')
51
+ @patch('cloudinary.search.Search')
52
+ def test_search_assets_with_custom_expression(self, mock_search_class, mock_execute, mock_pagination):
53
+ """Test search_assets appends default types to custom expression"""
54
+ mock_search = MagicMock()
55
+ mock_search_class.return_value = mock_search
56
+ mock_execute.return_value = self.mock_search_result
57
+ mock_pagination.return_value = self.mock_search_result
58
+
59
+ result = clone_module.search_assets(force=True, search_exp="tags:test")
60
+
61
+ # Verify custom expression gets default types appended
62
+ expected_exp = "tags:test AND (type:upload OR type:private OR type:authenticated)"
63
+ mock_search.expression.assert_called_with(expected_exp)
64
+ self.assertEqual(result, self.mock_search_result)
65
+
66
+ @patch.object(clone_module, 'handle_auto_pagination')
67
+ @patch.object(clone_module, 'execute_single_request')
68
+ @patch('cloudinary.search.Search')
69
+ def test_search_assets_with_allowed_type(self, mock_search_class, mock_execute, mock_pagination):
70
+ """Test search_assets accepts allowed types"""
71
+ mock_search = MagicMock()
72
+ mock_search_class.return_value = mock_search
73
+ mock_execute.return_value = self.mock_search_result
74
+ mock_pagination.return_value = self.mock_search_result
75
+
76
+ result = clone_module.search_assets(force=True, search_exp="type:upload")
77
+
78
+ # Verify allowed type is accepted as-is
79
+ mock_search.expression.assert_called_with("type:upload")
80
+ self.assertEqual(result, self.mock_search_result)
81
+
82
+ @patch.object(clone_module, 'logger')
83
+ def test_search_assets_with_disallowed_type(self, mock_logger):
84
+ """Test search_assets rejects disallowed types"""
85
+ result = clone_module.search_assets(force=True, search_exp="type:facebook")
86
+
87
+ # Verify error is logged and False is returned
88
+ mock_logger.error.assert_called_once()
89
+ error_call = mock_logger.error.call_args[0][0]
90
+ self.assertIn("Unsupported type(s) in search expression", error_call)
91
+ self.assertIn("facebook", error_call)
92
+ self.assertEqual(result, False)
93
+
94
+ @patch.object(clone_module, 'logger')
95
+ def test_search_assets_with_mixed_types(self, mock_logger):
96
+ """Test search_assets with mix of allowed and disallowed types"""
97
+ result = clone_module.search_assets(force=True, search_exp="type:upload OR type:facebook")
98
+
99
+ # Verify error is logged for disallowed type
100
+ mock_logger.error.assert_called_once()
101
+ error_call = mock_logger.error.call_args[0][0]
102
+ self.assertIn("facebook", error_call)
103
+ # Verify that only the disallowed type is mentioned in the error part
104
+ self.assertIn("Unsupported type(s) in search expression: type:facebook", error_call)
105
+ self.assertEqual(result, False)
106
+
107
+ def test_search_assets_type_validation_regex(self):
108
+ """Test the regex used for type validation"""
109
+ # Test various type formats
110
+ test_cases = [
111
+ ("type:upload", ["upload"]),
112
+ ("type=upload", ["upload"]),
113
+ ("type: upload", ["upload"]), # with space
114
+ ("type = upload", ["upload"]), # with spaces
115
+ ("type:upload OR type:private", ["upload", "private"]),
116
+ ("tags:test AND type:authenticated", ["authenticated"]),
117
+ ]
118
+
119
+ for search_exp, expected_types in test_cases:
120
+ with self.subTest(search_exp=search_exp):
121
+ found_types = re.findall(r"\btype\s*[:=]\s*\w+", search_exp)
122
+ cleaned_types = [''.join(t.split()) for t in found_types]
123
+ # Extract just the type names
124
+ type_names = [t.split(':')[-1].split('=')[-1] for t in cleaned_types]
125
+ self.assertEqual(sorted(type_names), sorted(expected_types))
126
+
127
+ def test_process_metadata_basic(self):
128
+ """Test process_metadata with basic asset"""
129
+ res = {
130
+ 'public_id': 'sample',
131
+ 'type': 'upload',
132
+ 'resource_type': 'image',
133
+ 'format': 'jpg',
134
+ 'secure_url': 'https://res.cloudinary.com/demo/image/upload/v1234567890/sample.jpg'
135
+ }
136
+
137
+ options, url = clone_module.process_metadata(
138
+ res, overwrite=True, async_=False, notification_url=None,
139
+ auth_token=None, url_expiry=3600, copy_fields=[]
140
+ )
141
+
142
+ self.assertEqual(url, 'https://res.cloudinary.com/demo/image/upload/v1234567890/sample.jpg')
143
+ self.assertEqual(options['public_id'], 'sample')
144
+ self.assertEqual(options['type'], 'upload')
145
+ self.assertEqual(options['resource_type'], 'image')
146
+ self.assertEqual(options['overwrite'], True)
147
+ self.assertEqual(options['async'], False)
148
+
149
+ def test_process_metadata_with_tags_and_context(self):
150
+ """Test process_metadata copying tags and context"""
151
+ res = {
152
+ 'public_id': 'sample',
153
+ 'type': 'upload',
154
+ 'resource_type': 'image',
155
+ 'format': 'jpg',
156
+ 'secure_url': 'https://res.cloudinary.com/demo/image/upload/v1234567890/sample.jpg',
157
+ 'tags': ['tag1', 'tag2'],
158
+ 'context': {'key': 'value'}
159
+ }
160
+
161
+ options, url = clone_module.process_metadata(
162
+ res, overwrite=False, async_=True, notification_url='http://webhook.com',
163
+ auth_token=None, url_expiry=3600, copy_fields=['tags', 'context']
164
+ )
165
+
166
+ self.assertEqual(options['tags'], ['tag1', 'tag2'])
167
+ self.assertEqual(options['context'], {'key': 'value'})
168
+ self.assertEqual(options['notification_url'], 'http://webhook.com')
169
+ self.assertEqual(options['overwrite'], False)
170
+ self.assertEqual(options['async'], True)
171
+
172
+ def test_process_metadata_with_folder(self):
173
+ """Test process_metadata with folder handling"""
174
+ res = {
175
+ 'public_id': 'sample',
176
+ 'type': 'upload',
177
+ 'resource_type': 'image',
178
+ 'format': 'jpg',
179
+ 'secure_url': 'https://res.cloudinary.com/demo/image/upload/v1234567890/sample.jpg',
180
+ 'folder': 'test_folder'
181
+ }
182
+
183
+ options, url = clone_module.process_metadata(
184
+ res, overwrite=False, async_=False, notification_url=None,
185
+ auth_token=None, url_expiry=3600, copy_fields=[]
186
+ )
187
+
188
+ self.assertEqual(options['asset_folder'], 'test_folder')
189
+
190
+ def test_process_metadata_with_asset_folder(self):
191
+ """Test process_metadata with asset_folder"""
192
+ res = {
193
+ 'public_id': 'sample',
194
+ 'type': 'upload',
195
+ 'resource_type': 'image',
196
+ 'format': 'jpg',
197
+ 'secure_url': 'https://res.cloudinary.com/demo/image/upload/v1234567890/sample.jpg',
198
+ 'asset_folder': 'asset_folder_test'
199
+ }
200
+
201
+ options, url = clone_module.process_metadata(
202
+ res, overwrite=False, async_=False, notification_url=None,
203
+ auth_token=None, url_expiry=3600, copy_fields=[]
204
+ )
205
+
206
+ self.assertEqual(options['asset_folder'], 'asset_folder_test')
207
+
208
+ def test_process_metadata_with_display_name(self):
209
+ """Test process_metadata with display_name"""
210
+ res = {
211
+ 'public_id': 'sample',
212
+ 'type': 'upload',
213
+ 'resource_type': 'image',
214
+ 'format': 'jpg',
215
+ 'secure_url': 'https://res.cloudinary.com/demo/image/upload/v1234567890/sample.jpg',
216
+ 'display_name': 'Test Asset Display Name'
217
+ }
218
+
219
+ options, url = clone_module.process_metadata(
220
+ res, overwrite=False, async_=False, notification_url=None,
221
+ auth_token=None, url_expiry=3600, copy_fields=[]
222
+ )
223
+
224
+ self.assertEqual(options['display_name'], 'Test Asset Display Name')
225
+
226
+ @patch('time.time')
227
+ @patch('cloudinary.utils.private_download_url')
228
+ def test_process_metadata_restricted_asset_no_auth_token(self, mock_private_url, mock_time):
229
+ """Test process_metadata with restricted asset and no auth token"""
230
+ mock_time.return_value = 1000
231
+ mock_private_url.return_value = 'https://api.cloudinary.com/v1_1/demo/image/download?api_key=123456789012345&format=jpg&public_id=sample&signature=abcdef123456789&timestamp=1234567890'
232
+
233
+ res = {
234
+ 'public_id': 'sample',
235
+ 'type': 'upload',
236
+ 'resource_type': 'image',
237
+ 'format': 'jpg',
238
+ 'secure_url': 'https://res.cloudinary.com/demo/image/upload/v1234567890/sample.jpg',
239
+ 'access_control': [{'access_type': 'token'}]
240
+ }
241
+
242
+ options, url = clone_module.process_metadata(
243
+ res, overwrite=False, async_=False, notification_url=None,
244
+ auth_token=None, url_expiry=3600, copy_fields=[]
245
+ )
246
+
247
+ # Should use private download URL
248
+ mock_private_url.assert_called_once_with(
249
+ 'sample', 'jpg', resource_type='image', type='upload', expires_at=4600
250
+ )
251
+ self.assertEqual(url, 'https://api.cloudinary.com/v1_1/demo/image/download?api_key=123456789012345&format=jpg&public_id=sample&signature=abcdef123456789&timestamp=1234567890')
252
+
253
+ @patch('cloudinary.utils.cloudinary_url')
254
+ def test_process_metadata_restricted_asset_with_auth_token(self, mock_cloudinary_url):
255
+ """Test process_metadata with restricted asset and auth token"""
256
+ mock_cloudinary_url.return_value = ('https://res.cloudinary.com/demo/image/upload/s--AbCdEfGhI--/sample.jpg', {})
257
+
258
+ res = {
259
+ 'public_id': 'sample',
260
+ 'type': 'upload',
261
+ 'resource_type': 'image',
262
+ 'format': 'jpg',
263
+ 'secure_url': 'https://res.cloudinary.com/demo/image/upload/v1234567890/sample.jpg',
264
+ 'access_control': [{'access_type': 'token'}]
265
+ }
266
+
267
+ options, url = clone_module.process_metadata(
268
+ res, overwrite=False, async_=False, notification_url=None,
269
+ auth_token={'key': 'value'}, url_expiry=3600, copy_fields=[]
270
+ )
271
+
272
+ # Should use signed URL
273
+ mock_cloudinary_url.assert_called_once_with(
274
+ 'sample.jpg',
275
+ type='upload',
276
+ resource_type='image',
277
+ auth_token={'duration': 3600},
278
+ secure=True,
279
+ sign_url=True
280
+ )
281
+ # The current implementation assigns the tuple directly, so we expect the tuple
282
+ self.assertEqual(url, ('https://res.cloudinary.com/demo/image/upload/s--AbCdEfGhI--/sample.jpg', {}))
283
+
284
+ @patch('cloudinary.utils.cloudinary_url')
285
+ def test_process_metadata_restricted_raw_asset_with_auth_token(self, mock_cloudinary_url):
286
+ """Test process_metadata with restricted raw asset and auth token"""
287
+ mock_cloudinary_url.return_value = ('https://res.cloudinary.com/demo/raw/upload/s--XyZaBcDeF--/sample_document', {})
288
+
289
+ res = {
290
+ 'public_id': 'sample_document',
291
+ 'type': 'upload',
292
+ 'resource_type': 'raw',
293
+ 'format': 'pdf',
294
+ 'secure_url': 'https://res.cloudinary.com/demo/raw/upload/v1234567890/sample_document.pdf',
295
+ 'access_control': [{'access_type': 'token'}]
296
+ }
297
+
298
+ options, url = clone_module.process_metadata(
299
+ res, overwrite=False, async_=False, notification_url=None,
300
+ auth_token={'key': 'value'}, url_expiry=3600, copy_fields=[]
301
+ )
302
+
303
+ # For raw assets, should not append format to public_id
304
+ mock_cloudinary_url.assert_called_once_with(
305
+ 'sample_document', # No .pdf extension for raw assets
306
+ type='upload',
307
+ resource_type='raw',
308
+ auth_token={'duration': 3600},
309
+ secure=True,
310
+ sign_url=True
311
+ )
312
+ # The current implementation assigns the tuple directly, so we expect the tuple
313
+ self.assertEqual(url, ('https://res.cloudinary.com/demo/raw/upload/s--XyZaBcDeF--/sample_document', {}))
314
+
315
+
316
+ if __name__ == '__main__':
317
+ unittest.main()
@@ -1,112 +0,0 @@
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
@@ -1 +0,0 @@
1
- __version__ = "1.13.0"
File without changes