cloudinary-cli 1.10.3__tar.gz → 1.11.1__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 (69) hide show
  1. {cloudinary_cli-1.10.3/cloudinary_cli.egg-info → cloudinary_cli-1.11.1}/PKG-INFO +4 -2
  2. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/core/__init__.py +2 -1
  3. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/core/overrides.py +18 -0
  4. cloudinary_cli-1.11.1/cloudinary_cli/core/search.py +171 -0
  5. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/modules/__init__.py +3 -1
  6. cloudinary_cli-1.11.1/cloudinary_cli/modules/clone.py +112 -0
  7. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/modules/sync.py +34 -12
  8. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/utils/api_utils.py +16 -7
  9. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/utils/config_utils.py +29 -6
  10. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/utils/utils.py +35 -6
  11. cloudinary_cli-1.11.1/cloudinary_cli/version.py +1 -0
  12. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1/cloudinary_cli.egg-info}/PKG-INFO +4 -2
  13. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli.egg-info/SOURCES.txt +1 -0
  14. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli.egg-info/requires.txt +3 -1
  15. cloudinary_cli-1.11.1/requirements.txt +9 -0
  16. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_cli_search_api.py +8 -0
  17. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_modules/test_cli_sync.py +33 -0
  18. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_utils.py +39 -2
  19. cloudinary_cli-1.10.3/cloudinary_cli/core/search.py +0 -129
  20. cloudinary_cli-1.10.3/cloudinary_cli/version.py +0 -1
  21. cloudinary_cli-1.10.3/requirements.txt +0 -7
  22. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/LICENSE +0 -0
  23. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/MANIFEST.in +0 -0
  24. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/README.md +0 -0
  25. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/__init__.py +0 -0
  26. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/cli.py +0 -0
  27. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/cli_group.py +0 -0
  28. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/core/admin.py +0 -0
  29. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/core/config.py +0 -0
  30. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/core/provisioning.py +0 -0
  31. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/core/uploader.py +0 -0
  32. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/core/utils.py +0 -0
  33. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/defaults.py +0 -0
  34. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/modules/make.py +0 -0
  35. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/modules/migrate.py +0 -0
  36. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/modules/regen_derived.py +0 -0
  37. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/modules/upload_dir.py +0 -0
  38. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/samples/__init__.py +0 -0
  39. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/html/media_library_widget +0 -0
  40. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/html/product_gallery +0 -0
  41. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/html/upload_widget +0 -0
  42. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/html/video_player +0 -0
  43. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/node/upload +0 -0
  44. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/python/base +0 -0
  45. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/python/explicit +0 -0
  46. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/python/find_all_empty_folders +0 -0
  47. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/python/upload +0 -0
  48. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/ruby/upload +0 -0
  49. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/utils/__init__.py +0 -0
  50. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/utils/file_utils.py +0 -0
  51. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/utils/json_utils.py +0 -0
  52. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli.egg-info/dependency_links.txt +0 -0
  53. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli.egg-info/entry_points.txt +0 -0
  54. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli.egg-info/not-zip-safe +0 -0
  55. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli.egg-info/top_level.txt +0 -0
  56. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/setup.cfg +0 -0
  57. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/setup.py +0 -0
  58. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/__init__.py +0 -0
  59. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/helper_test.py +0 -0
  60. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_cli.py +0 -0
  61. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_cli_api.py +0 -0
  62. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_cli_config.py +0 -0
  63. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_cli_samples.py +0 -0
  64. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_cli_url.py +0 -0
  65. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_cli_utils.py +0 -0
  66. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_file_utils.py +0 -0
  67. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_modules/__init__.py +0 -0
  68. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_modules/test_cli_make.py +0 -0
  69. {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_modules/test_cli_upload_dir.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cloudinary-cli
3
- Version: 1.10.3
3
+ Version: 1.11.1
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
@@ -13,13 +13,15 @@ Classifier: Operating System :: OS Independent
13
13
  Requires-Python: >=3.6.0
14
14
  Description-Content-Type: text/markdown
15
15
  License-File: LICENSE
16
- Requires-Dist: cloudinary>=1.37.0
16
+ Requires-Dist: cloudinary>=1.42.2
17
17
  Requires-Dist: pygments
18
18
  Requires-Dist: jinja2
19
19
  Requires-Dist: click
20
20
  Requires-Dist: click-log
21
21
  Requires-Dist: requests
22
22
  Requires-Dist: docstring-parser
23
+ Requires-Dist: urllib3>=2.2.2
24
+ Requires-Dist: zipp>=3.19.1
23
25
 
24
26
  [![Build Status](https://app.travis-ci.com/cloudinary/cloudinary-cli.svg)](https://app.travis-ci.com/cloudinary/cloudinary-cli)
25
27
  [![PyPI Downloads](https://img.shields.io/pypi/dm/cloudinary-cli.svg)](https://pypistats.org/packages/cloudinary-cli)
@@ -2,7 +2,7 @@ import click
2
2
 
3
3
  from cloudinary_cli.core.admin import admin
4
4
  from cloudinary_cli.core.config import config
5
- from cloudinary_cli.core.search import search
5
+ from cloudinary_cli.core.search import search, search_folders
6
6
  from cloudinary_cli.core.uploader import uploader
7
7
  from cloudinary_cli.core.provisioning import provisioning
8
8
  from cloudinary_cli.core.utils import url, utils
@@ -13,6 +13,7 @@ setattr(click.MultiCommand, "resolve_command", resolve_command)
13
13
  commands = [
14
14
  config,
15
15
  search,
16
+ search_folders,
16
17
  admin,
17
18
  uploader,
18
19
  provisioning,
@@ -35,6 +35,24 @@ def resolve_command(self, ctx, args):
35
35
 
36
36
  # Patch to set `auto` resource type
37
37
  def upload(file, **options):
38
+ """
39
+ Uploads an asset to a Cloudinary cloud.
40
+
41
+ The asset can be:
42
+ * a local file path
43
+ * the actual data (byte array buffer)
44
+ * the Data URI (Base64 encoded), max ~60 MB (62,910,000 chars)
45
+ * the remote FTP, HTTP or HTTPS URL address of an existing file
46
+ * a private storage bucket (S3 or Google Storage) URL of a whitelisted bucket
47
+
48
+ See: https://cloudinary.com/documentation/image_upload_api_reference#upload_method
49
+ :param file: The asset to upload.
50
+ :type file: Any or str
51
+ :param options: The optional parameters. See the upload API documentation.
52
+ :type options: dict, optional
53
+ :return: The result of the upload API call.
54
+ :rtype: dict
55
+ """
38
56
  if "resource_type" not in options.keys():
39
57
  options["resource_type"] = "auto"
40
58
  return original_upload(file, **options)
@@ -0,0 +1,171 @@
1
+ import cloudinary
2
+ from click import command, argument, option, launch
3
+ from functools import wraps
4
+
5
+ from cloudinary_cli.defaults import logger
6
+ from cloudinary_cli.utils.json_utils import write_json_to_file, print_json
7
+ from cloudinary_cli.utils.utils import write_json_list_to_csv, confirm_action, whitelist_keys, \
8
+ normalize_list_params
9
+
10
+ DEFAULT_MAX_RESULTS = 500
11
+
12
+
13
+ def shared_options(func):
14
+ @option("-f", "--with_field", multiple=True, help="Specify which non-default asset attributes to include "
15
+ "in the result as a comma separated list.")
16
+ @option("-fi", "--fields", multiple=True, help="Specify which asset attributes to include in the result "
17
+ "(together with a subset of the default attributes) as a comma separated"
18
+ " list. This overrides any value specified for with_field.")
19
+ @option("-s", "--sort_by", nargs=2, help="Sort search results by (field, <asc|desc>).")
20
+ @option("-a", "--aggregate", nargs=1,
21
+ help="Specify the attribute for which an aggregation count should be calculated and returned.")
22
+ @option("-n", "--max_results", nargs=1, default=10,
23
+ help="The maximum number of results to return. Default: 10, maximum: 500.")
24
+ @option("-c", "--next_cursor", nargs=1, help="Continue a search using an existing cursor.")
25
+ @option("-A", "--auto_paginate", is_flag=True, help="Return all results. Will call Admin API multiple times.")
26
+ @option("-F", "--force", is_flag=True, help="Skip confirmation when running --auto-paginate.")
27
+ @option("-ff", "--filter_fields", multiple=True, help="Specify which attributes to show in the response. "
28
+ "None of the others will be shown.")
29
+ @option("-sq", "--search-query", is_flag=True, help="Show the search request query.", hidden=True)
30
+ @option("--json", nargs=1, help="Save JSON output to a file. Usage: --json <filename>")
31
+ @option("--csv", nargs=1, help="Save CSV output to a file. Usage: --csv <filename>")
32
+ @wraps(func)
33
+ def wrapper(*args, **kwargs):
34
+ return func(*args, **kwargs)
35
+
36
+ return wrapper
37
+
38
+
39
+ @command("search",
40
+ short_help="Run the Admin API search method.",
41
+ help="""\b
42
+ Run the Admin API search method.
43
+ Format: cld <cli options> search <command options> <Lucene query syntax string>
44
+ e.g. cld search cat AND tags:kitten -s public_id desc -f context -f tags -n 10
45
+ """)
46
+ @argument("query", nargs=-1)
47
+ @shared_options
48
+ @option("-t", "--ttl", nargs=1, default=300, help="Set the Search URL TTL in seconds. Default: 300.")
49
+ @option("-u", "--url", is_flag=True, help="Build a signed search URL.")
50
+ @option("-d", "--doc", is_flag=True, help="Open Search API documentation page.")
51
+ def search(query, with_field, fields, sort_by, aggregate, max_results, next_cursor,
52
+ auto_paginate, force, filter_fields, ttl, url, search_query, json, csv, doc):
53
+ search_instance = cloudinary.search.Search()
54
+ doc_url = "https://cloudinary.com/documentation/search_api"
55
+ result_field = 'resources'
56
+ return _perform_search(query, with_field, fields, sort_by, aggregate, max_results, next_cursor,
57
+ auto_paginate, force, filter_fields, ttl, url, search_query, json, csv, doc,
58
+ search_instance, doc_url, result_field)
59
+
60
+
61
+ @command("search_folders",
62
+ short_help="Run the Admin API search folders method.",
63
+ help="""\b
64
+ Run the Admin API search folders method.
65
+ Format: cld <cli options> search_folders <command options> <Lucene query syntax string>
66
+ e.g. cld search_folders name:folder AND path:my_parent AND created_at>4w
67
+ """)
68
+ @argument("query", nargs=-1)
69
+ @shared_options
70
+ @option("-d", "--doc", is_flag=True, help="Open Search Folders API documentation page.")
71
+ def search_folders(query, with_field, fields, sort_by, aggregate, max_results, next_cursor,
72
+ auto_paginate, force, filter_fields, search_query, json, csv, doc):
73
+ search_instance = cloudinary.search_folders.SearchFolders()
74
+ doc_url = "https://cloudinary.com/documentation/admin_api#search_folders"
75
+ result_field = 'folders'
76
+ return _perform_search(query, with_field, fields, sort_by, aggregate, max_results, next_cursor,
77
+ auto_paginate, force, filter_fields, 300, False, search_query, json, csv, doc,
78
+ search_instance, doc_url, result_field)
79
+
80
+
81
+ def _perform_search(query, with_field, fields, sort_by, aggregate, max_results, next_cursor,
82
+ auto_paginate, force, filter_fields, ttl, url, search_query, json, csv, doc,
83
+ search_instance, doc_url, result_field):
84
+ """Shared logic for running a search."""
85
+ if doc:
86
+ return launch(doc_url)
87
+
88
+ fields_to_keep = []
89
+ if filter_fields:
90
+ fields_to_keep = tuple(normalize_list_params(filter_fields)) + tuple(normalize_list_params(with_field))
91
+
92
+ search = search_instance.expression(" ".join(query))
93
+
94
+ if auto_paginate:
95
+ max_results = DEFAULT_MAX_RESULTS
96
+ if with_field:
97
+ search.with_field(normalize_list_params(with_field))
98
+ if fields:
99
+ search.fields(normalize_list_params(fields))
100
+ if sort_by:
101
+ search.sort_by(*sort_by)
102
+ if aggregate:
103
+ search.aggregate(aggregate)
104
+ if next_cursor:
105
+ search.next_cursor(next_cursor)
106
+ if ttl:
107
+ search.ttl(ttl)
108
+
109
+ search.max_results(max_results)
110
+
111
+ if url:
112
+ print(search.to_url())
113
+ return True
114
+
115
+ if search_query:
116
+ print_json(search.as_dict())
117
+ return True
118
+
119
+ res = execute_single_request(search, fields_to_keep, result_field)
120
+
121
+ if auto_paginate:
122
+ res = handle_auto_pagination(res, search, force, fields_to_keep, result_field)
123
+
124
+ print_json(res)
125
+
126
+ if json:
127
+ write_json_to_file(res[result_field], json)
128
+ logger.info(f"Saved search JSON to '{json}' file")
129
+
130
+ if csv:
131
+ write_json_list_to_csv(res[result_field], csv, fields_to_keep)
132
+ logger.info(f"Saved search to '{csv}.csv' file")
133
+
134
+
135
+ def execute_single_request(expression, fields_to_keep, result_field='resources'):
136
+ res = expression.execute()
137
+
138
+ if fields_to_keep:
139
+ res[result_field] = whitelist_keys(res[result_field], fields_to_keep)
140
+
141
+ return res
142
+
143
+
144
+ def handle_auto_pagination(res, expression, force, fields_to_keep, result_field='resources'):
145
+ if 'next_cursor' not in res:
146
+ return res
147
+
148
+ if not force:
149
+ if not confirm_action(
150
+ f"{res['total_count']} total results. "
151
+ f"{res.rate_limit_remaining + 1} Admin API rate limit remaining.\n"
152
+ f"Running this query will use {res['total_count'] // DEFAULT_MAX_RESULTS + 1} Admin API calls. "
153
+ f"Continue? (y/N)"):
154
+ logger.info("Stopping. Please run again without -A.")
155
+
156
+ return res
157
+ else:
158
+ logger.info("Continuing. You may use the -F flag to force auto_pagination.")
159
+
160
+ all_results = res
161
+ while 'next_cursor' in res.keys():
162
+ expression.next_cursor(res['next_cursor'])
163
+
164
+ res = execute_single_request(expression, fields_to_keep, result_field)
165
+
166
+ all_results[result_field] += res[result_field]
167
+ all_results['time'] += res['time']
168
+
169
+ all_results.pop('next_cursor', None) # it is empty by now
170
+
171
+ return all_results
@@ -3,11 +3,13 @@ from .migrate import migrate
3
3
  from .sync import sync
4
4
  from .upload_dir import upload_dir
5
5
  from .regen_derived import regen_derived
6
+ from .clone import clone
6
7
 
7
8
  commands = [
8
9
  upload_dir,
9
10
  make,
10
11
  migrate,
11
12
  sync,
12
- regen_derived
13
+ regen_derived,
14
+ clone
13
15
  ]
@@ -0,0 +1,112 @@
1
+ from click import command, option, style, argument
2
+ from cloudinary_cli.utils.utils import normalize_list_params, print_help_and_exit
3
+ import cloudinary
4
+ from cloudinary_cli.utils.utils import run_tasks_concurrently
5
+ from cloudinary_cli.utils.api_utils import upload_file
6
+ from cloudinary_cli.utils.config_utils import load_config, get_cloudinary_config, config_to_dict
7
+ from cloudinary_cli.defaults import logger
8
+ from cloudinary_cli.core.search import execute_single_request, handle_auto_pagination
9
+
10
+ DEFAULT_MAX_RESULTS = 500
11
+
12
+
13
+ @command("clone",
14
+ short_help="""Clone assets from one product environment to another.""",
15
+ help="""
16
+ \b
17
+ Clone assets from one product environment to another with/without tags and/or context (structured metadata is not currently supported).
18
+ Source will be your `CLOUDINARY_URL` environment variable but you also can specify a different source using the `-c/-C` option.
19
+ Cloning restricted assets is also not supported currently.
20
+ Format: cld clone <target_environment> <command options>
21
+ `<target_environment>` can be a CLOUDINARY_URL or a saved config (see `config` command)
22
+ Example 1 (Copy all assets including tags and context using CLOUDINARY URL):
23
+ cld clone cloudinary://<api_key>:<api_secret>@<cloudname> -fi tags,context
24
+ Example 2 (Copy all assets with a specific tag via a search expression using a saved config):
25
+ cld clone <config_name> -se "tags:<tag_name>"
26
+ """)
27
+ @argument("target")
28
+ @option("-F", "--force", is_flag=True,
29
+ help="Skip confirmation.")
30
+ @option("-ow", "--overwrite", is_flag=True, default=False,
31
+ help="Specify whether to overwrite existing assets.")
32
+ @option("-w", "--concurrent_workers", type=int, default=30,
33
+ help="Specify the number of concurrent network threads.")
34
+ @option("-fi", "--fields", multiple=True,
35
+ help="Specify whether to copy tags and/or context. Valid options: `tags,context`.")
36
+ @option("-se", "--search_exp", default="",
37
+ help="Define a search expression to filter the assets to clone.")
38
+ @option("--async", "async_", is_flag=True, default=False,
39
+ help="Clone the assets asynchronously.")
40
+ @option("-nu", "--notification_url",
41
+ help="Webhook notification URL.")
42
+ def clone(target, force, overwrite, concurrent_workers, fields, search_exp, async_, notification_url):
43
+ if not target:
44
+ print_help_and_exit()
45
+
46
+ target_config = get_cloudinary_config(target)
47
+ if not target_config:
48
+ logger.error("The specified config does not exist or the CLOUDINARY_URL scheme provided is invalid"
49
+ " (expecting to start with 'cloudinary://').")
50
+ return False
51
+
52
+ if cloudinary.config().cloud_name == target_config.cloud_name:
53
+ logger.error("Target environment cannot be the same as source environment.")
54
+ return False
55
+
56
+ source_assets = search_assets(force, search_exp)
57
+
58
+ upload_list = []
59
+ for r in source_assets.get('resources'):
60
+ updated_options, asset_url = process_metadata(r, overwrite, async_, notification_url,
61
+ normalize_list_params(fields))
62
+ updated_options.update(config_to_dict(target_config))
63
+ upload_list.append((asset_url, {**updated_options}))
64
+
65
+ if not upload_list:
66
+ logger.error(style(f'No assets found in {cloudinary.config().cloud_name}', fg="red"))
67
+ return False
68
+
69
+ logger.info(style(f'Copying {len(upload_list)} asset(s) from {cloudinary.config().cloud_name} to {target_config.cloud_name}', fg="blue"))
70
+
71
+ run_tasks_concurrently(upload_file, upload_list, concurrent_workers)
72
+
73
+ return True
74
+
75
+
76
+ def search_assets(force, search_exp):
77
+ search = cloudinary.search.Search().expression(search_exp)
78
+ search.fields(['tags', 'context', 'access_control', 'secure_url', 'display_name'])
79
+ search.max_results(DEFAULT_MAX_RESULTS)
80
+
81
+ res = execute_single_request(search, fields_to_keep="")
82
+ res = handle_auto_pagination(res, search, force, fields_to_keep="")
83
+
84
+ return res
85
+
86
+
87
+ def process_metadata(res, overwrite, async_, notification_url, copy_fields=""):
88
+ cloned_options = {}
89
+ asset_url = res.get('secure_url')
90
+ cloned_options['public_id'] = res.get('public_id')
91
+ cloned_options['type'] = res.get('type')
92
+ cloned_options['resource_type'] = res.get('resource_type')
93
+ cloned_options['overwrite'] = overwrite
94
+ cloned_options['async'] = async_
95
+ if "tags" in copy_fields:
96
+ cloned_options['tags'] = res.get('tags')
97
+ if "context" in copy_fields:
98
+ cloned_options['context'] = res.get('context')
99
+ if res.get('folder'):
100
+ # This is required to put the asset in the correct asset_folder
101
+ # when copying from a fixed to DF (dynamic folder) cloud as if
102
+ # you just pass a `folder` param to a DF cloud, it will append
103
+ # this to the `public_id` and we don't want this.
104
+ cloned_options['asset_folder'] = res.get('folder')
105
+ elif res.get('asset_folder'):
106
+ cloned_options['asset_folder'] = res.get('asset_folder')
107
+ if res.get('display_name'):
108
+ cloned_options['display_name'] = res.get('display_name')
109
+ if notification_url:
110
+ cloned_options['notification_url'] = notification_url
111
+
112
+ return cloned_options, asset_url
@@ -41,14 +41,14 @@ _SYNC_META_FILE = '.cld-sync'
41
41
  @option("-o", "--optional_parameter", multiple=True, nargs=2, help="Pass optional parameters as raw strings.")
42
42
  @option("-O", "--optional_parameter_parsed", multiple=True, nargs=2,
43
43
  help="Pass optional parameters as interpreted strings.")
44
+ @option("--dry-run", is_flag=True, help="Simulate the sync operation without making any changes.")
44
45
  def sync(local_folder, cloudinary_folder, push, pull, include_hidden, concurrent_workers, force, keep_unique,
45
- deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed):
46
+ deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed, dry_run):
46
47
  if push == pull:
47
48
  raise UsageError("Please use either the '--push' OR '--pull' options")
48
49
 
49
50
  sync_dir = SyncDir(local_folder, cloudinary_folder, include_hidden, concurrent_workers, force, keep_unique,
50
- deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed)
51
-
51
+ deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed, dry_run)
52
52
  result = True
53
53
  if push:
54
54
  result = sync_dir.push()
@@ -63,7 +63,7 @@ def sync(local_folder, cloudinary_folder, push, pull, include_hidden, concurrent
63
63
 
64
64
  class SyncDir:
65
65
  def __init__(self, local_dir, remote_dir, include_hidden, concurrent_workers, force, keep_deleted,
66
- deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed):
66
+ deletion_batch_size, folder_mode, optional_parameter, optional_parameter_parsed, dry_run):
67
67
  self.local_dir = local_dir
68
68
  self.remote_dir = remote_dir.strip('/')
69
69
  self.user_friendly_remote_dir = self.remote_dir if self.remote_dir else '/'
@@ -72,6 +72,7 @@ class SyncDir:
72
72
  self.force = force
73
73
  self.keep_unique = keep_deleted
74
74
  self.deletion_batch_size = deletion_batch_size
75
+ self.dry_run = dry_run
75
76
 
76
77
  self.folder_mode = folder_mode or get_folder_mode()
77
78
 
@@ -115,16 +116,16 @@ class SyncDir:
115
116
  local_file_names = self.local_files.keys()
116
117
  remote_file_names = self.remote_files.keys()
117
118
  """
118
- Cloudinary is a very permissive service. When uploading files that contain invalid characters,
119
- unicode characters, etc, Cloudinary does the best effort to store those files.
120
-
121
- Usually Cloudinary sanitizes those file names and strips invalid characters. Although it is a good best effort
122
- for a general use case, when syncing local folder with Cloudinary, it is not the best option, since directories
119
+ Cloudinary is a very permissive service. When uploading files that contain invalid characters,
120
+ unicode characters, etc, Cloudinary does the best effort to store those files.
121
+
122
+ Usually Cloudinary sanitizes those file names and strips invalid characters. Although it is a good best effort
123
+ for a general use case, when syncing local folder with Cloudinary, it is not the best option, since directories
123
124
  will be always out-of-sync.
124
-
125
+
125
126
  In addition in dynamic folder mode Cloudinary allows having identical display names for differrent files.
126
-
127
- To overcome this limitation, cloudinary-cli keeps .cld-sync hidden file in the sync directory that contains a
127
+
128
+ To overcome this limitation, cloudinary-cli keeps .cld-sync hidden file in the sync directory that contains a
128
129
  mapping of the diverse file names. This file keeps tracking of the files and allows syncing in both directions.
129
130
  """
130
131
 
@@ -168,6 +169,12 @@ class SyncDir:
168
169
  if not files_to_push:
169
170
  return True
170
171
 
172
+ if self.dry_run:
173
+ logger.info("Dry run mode enabled. The following files would be uploaded:")
174
+ for file in files_to_push:
175
+ logger.info(f"{file}")
176
+ return True
177
+
171
178
  logger.info(f"Uploading {len(files_to_push)} items to Cloudinary folder '{self.user_friendly_remote_dir}'")
172
179
 
173
180
  options = {
@@ -215,6 +222,14 @@ class SyncDir:
215
222
  if not files_to_pull:
216
223
  return True
217
224
 
225
+ logger.info(f"Preparing to download {len(files_to_pull)} items from Cloudinary folder ")
226
+
227
+ if self.dry_run:
228
+ logger.info("Dry run mode enabled. The following files would be downloaded:")
229
+ for file in files_to_pull:
230
+ logger.info(f"{file}")
231
+ return True
232
+
218
233
  logger.info(f"Downloading {len(files_to_pull)} files from Cloudinary")
219
234
  downloads = []
220
235
  for file in files_to_pull:
@@ -348,6 +363,10 @@ class SyncDir:
348
363
 
349
364
  # Each batch is further chunked by a deletion batch size that can be specified by the user.
350
365
  for deletion_batch in chunker(batch, self.deletion_batch_size):
366
+ if self.dry_run:
367
+ logger.info(f"Dry run mode enabled. Would delete {len(deletion_batch)} resources:\n" +
368
+ "\n".join(deletion_batch))
369
+ continue
351
370
  res = api.delete_resources(deletion_batch, invalidate=True, resource_type=attrs[0], type=attrs[1])
352
371
  num_deleted = Counter(res['deleted'].values())["deleted"]
353
372
  if self.verbose:
@@ -395,6 +414,9 @@ class SyncDir:
395
414
  logger.info(f"Deleting {len(self.unique_local_file_names)} local files...")
396
415
  for file in self.unique_local_file_names:
397
416
  full_path = path.abspath(self.local_files[file]['path'])
417
+ if self.dry_run:
418
+ logger.info(f"Dry run mode enabled. Would delete '{full_path}'")
419
+ continue
398
420
  remove(full_path)
399
421
  logger.info(f"Deleted '{full_path}'")
400
422
 
@@ -13,6 +13,8 @@ from cloudinary_cli.utils.file_utils import (normalize_file_extension, posix_rel
13
13
  from cloudinary_cli.utils.json_utils import print_json, write_json_to_file
14
14
  from cloudinary_cli.utils.utils import log_exception, confirm_action, get_command_params, merge_responses, \
15
15
  normalize_list_params, ConfigurationError, print_api_help, duplicate_values
16
+ import re
17
+ from cloudinary.utils import is_remote_url
16
18
 
17
19
  PAGINATION_MAX_RESULTS = 500
18
20
 
@@ -118,15 +120,20 @@ def upload_file(file_path, options, uploaded=None, failed=None):
118
120
  verbose = logger.getEffectiveLevel() < logging.INFO
119
121
 
120
122
  try:
121
- size = path.getsize(file_path)
123
+ size = 0 if is_remote_url(file_path) else path.getsize(file_path)
122
124
  upload_func = uploader.upload
123
125
  if size > 20000000:
124
126
  upload_func = uploader.upload_large
125
127
  result = upload_func(file_path, **options)
126
128
  disp_path = _display_path(result)
127
- disp_str = f"as {result['public_id']}" if not disp_path \
128
- else f"as {disp_path} with public_id: {result['public_id']}"
129
- logger.info(style(f"Successfully uploaded {file_path} {disp_str}", fg="green"))
129
+ if "batch_id" in result:
130
+ starting_msg = "Uploading"
131
+ disp_str = f"asynchnously with batch_id: {result['batch_id']}"
132
+ else:
133
+ starting_msg = "Successfully uploaded"
134
+ disp_str = f"as {result['public_id']}" if not disp_path \
135
+ else f"as {disp_path} with public_id: {result['public_id']}"
136
+ logger.info(style(f"{starting_msg} {file_path} {disp_str}", fg="green"))
130
137
  if verbose:
131
138
  print_json(result)
132
139
  uploaded[file_path] = {"path": asset_source(result), "display_path": disp_path}
@@ -212,12 +219,15 @@ def asset_source(asset_details):
212
219
 
213
220
  :return:
214
221
  """
215
- base_name = asset_details['public_id']
222
+ base_name = asset_details.get('public_id', '')
223
+
224
+ if not base_name:
225
+ return base_name
216
226
 
217
227
  if asset_details['resource_type'] == 'raw' or asset_details['type'] == 'fetch':
218
228
  return base_name
219
229
 
220
- return base_name + '.' + asset_details['format']
230
+ return base_name + '.' + asset_details.get('format', '')
221
231
 
222
232
 
223
233
  def get_folder_mode():
@@ -278,7 +288,6 @@ def handle_api_command(
278
288
  """
279
289
  Used by Admin and Upload API commands
280
290
  """
281
-
282
291
  if doc:
283
292
  return launch(doc_url)
284
293
 
@@ -44,16 +44,31 @@ def refresh_cloudinary_config(cloudinary_url):
44
44
 
45
45
  def verify_cloudinary_url(cloudinary_url):
46
46
  refresh_cloudinary_config(cloudinary_url)
47
- try:
48
- api.ping()
49
- except Exception as e:
50
- log_exception(e, f"Invalid Cloudinary URL: {cloudinary_url}")
47
+ return ping_cloudinary()
48
+
49
+
50
+ def get_cloudinary_config(target):
51
+ target_config = cloudinary.Config()
52
+ if target.startswith("cloudinary://"):
53
+ parsed_url = target_config._parse_cloudinary_url(target)
54
+ elif target in load_config():
55
+ parsed_url = target_config._parse_cloudinary_url(load_config().get(target))
56
+ else:
51
57
  return False
52
- return True
53
58
 
59
+ target_config._setup_from_parsed_url(parsed_url)
60
+
61
+ if not ping_cloudinary(**config_to_dict(target_config)):
62
+ logger.error(f"Invalid Cloudinary config: {target}")
63
+ return False
64
+
65
+ return target_config
66
+
67
+ def config_to_dict(config):
68
+ return {k: v for k, v in config.__dict__.items() if not k.startswith("_")}
54
69
 
55
70
  def show_cloudinary_config(cloudinary_config):
56
- obfuscated_config = {k: v for k, v in cloudinary_config.__dict__.items() if not k.startswith("_")}
71
+ obfuscated_config = config_to_dict(cloudinary_config)
57
72
 
58
73
  if "api_secret" in obfuscated_config:
59
74
  api_secret = obfuscated_config["api_secret"]
@@ -96,6 +111,14 @@ def is_valid_cloudinary_config():
96
111
  def initialize():
97
112
  migrate_old_config()
98
113
 
114
+ def ping_cloudinary(**options):
115
+ try:
116
+ api.ping(**options)
117
+ except Exception as e:
118
+ logger.error(f"Failed to ping Cloudinary: {e}")
119
+ return False
120
+
121
+ return True
99
122
 
100
123
  def _verify_file_path(file):
101
124
  os.makedirs(os.path.dirname(file), exist_ok=True)
@@ -7,6 +7,7 @@ from csv import DictWriter
7
7
  from functools import reduce
8
8
  from hashlib import md5
9
9
  from inspect import signature, getfullargspec
10
+ from typing import get_type_hints
10
11
  from multiprocessing import pool
11
12
 
12
13
  import click
@@ -14,10 +15,11 @@ import cloudinary
14
15
  from jinja2 import Environment, FileSystemLoader
15
16
  from docstring_parser import parse
16
17
  from cloudinary_cli.defaults import logger, TEMPLATE_FOLDER
18
+ from cloudinary.utils import build_array
17
19
 
18
20
  not_callable = ('is_appengine_sandbox', 'call_tags_api', 'call_context_api', 'call_cacheable_api', 'call_api',
19
21
  'call_metadata_api', 'call_json_api', 'only', 'transformation_string', 'account_config',
20
- 'reset_config', 'upload_large_part', 'upload_image', 'upload_resource')
22
+ 'reset_config', 'upload_large_part', 'upload_image', 'upload_resource', 'build_eager')
21
23
 
22
24
  BLOCK_SIZE = 65536
23
25
 
@@ -127,9 +129,11 @@ def parse_args_kwargs(func, params=None, kwargs=None):
127
129
  num_req = num_args - num_defaults
128
130
  num_provided_args = len(params)
129
131
  num_overall_provided = num_provided_args + len([p for p in kwargs.keys() if p in spec.args[num_provided_args:]])
132
+
130
133
  if num_overall_provided < num_req:
131
134
  func_sig = signature(func)
132
135
  raise Exception(f"Function '{func.__name__}{func_sig}' requires {num_req} positional arguments")
136
+
133
137
  # consume required args
134
138
  args = [parse_option_value(p) for p in params[:num_req]]
135
139
 
@@ -142,8 +146,30 @@ def parse_args_kwargs(func, params=None, kwargs=None):
142
146
  k, v = p.split('=', 1)
143
147
  kwargs[k] = parse_option_value(v)
144
148
 
149
+ params_specs = parse(func.__doc__).params
150
+
151
+ if len(args) > num_req:
152
+ # Here we consumed more args than the function can get,
153
+ # let's see if we have a list arg and pass everything as list.
154
+ # Otherwise, let's pass everything as is and hope for the best :)
155
+ last_positional_list_param = next((s for s in reversed(params_specs) if s.arg_name not in kwargs and s.type_name and s.type_name.startswith('list')), None)
156
+ if last_positional_list_param:
157
+ pos = get_index_by_name(spec.args, last_positional_list_param.arg_name)
158
+ args[pos] = [args[pos]] + args[num_args:]
159
+ args = args[:num_args]
160
+
161
+ for s in params_specs:
162
+ if s.type_name and s.type_name.startswith('list'):
163
+ pos = get_index_by_name(spec.args, s.arg_name)
164
+ if isinstance(pos, int) and 0 <= pos < len(args):
165
+ args[pos] = normalize_list_params(args[pos])
166
+ if s.arg_name in kwargs:
167
+ kwargs[s.arg_name] = normalize_list_params(kwargs[s.arg_name])
168
+
145
169
  return args, kwargs
146
170
 
171
+ def get_index_by_name(lst, name):
172
+ return next((i for i, item in enumerate(lst) if item == name), -1)
147
173
 
148
174
  def remove_string_prefix(string, prefix):
149
175
  return string[string.startswith(prefix) and len(prefix):]
@@ -297,18 +323,21 @@ def normalize_list_params(params):
297
323
  """
298
324
  Normalizes parameters that could be provided as strings separated by ','.
299
325
 
300
- >>> normalize_list_params(["f1,f2", "f3"])
301
- ["f1", "f2", "f3"]
326
+ >>> normalize_list_params(['f1,f2', 'f3'])
327
+ ['f1', 'f2', 'f3']
328
+
329
+ >>> normalize_list_params('f1,f2,f3')
330
+ ['f1', 'f2', 'f3']
302
331
 
303
332
  :param params: Params to normalize.
304
- :type params: list
333
+ :type params: list[string] or string
305
334
 
306
335
  :return: A list of normalized params.
307
336
  :rtype list
308
337
  """
309
338
  normalized_params = []
310
- for f in list(params):
311
- if "," in f:
339
+ for f in build_array(params):
340
+ if isinstance(f, str) and "," in f:
312
341
  normalized_params += f.split(",")
313
342
  else:
314
343
  normalized_params.append(f)
@@ -0,0 +1 @@
1
+ __version__ = "1.11.1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cloudinary-cli
3
- Version: 1.10.3
3
+ Version: 1.11.1
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
@@ -13,13 +13,15 @@ Classifier: Operating System :: OS Independent
13
13
  Requires-Python: >=3.6.0
14
14
  Description-Content-Type: text/markdown
15
15
  License-File: LICENSE
16
- Requires-Dist: cloudinary>=1.37.0
16
+ Requires-Dist: cloudinary>=1.42.2
17
17
  Requires-Dist: pygments
18
18
  Requires-Dist: jinja2
19
19
  Requires-Dist: click
20
20
  Requires-Dist: click-log
21
21
  Requires-Dist: requests
22
22
  Requires-Dist: docstring-parser
23
+ Requires-Dist: urllib3>=2.2.2
24
+ Requires-Dist: zipp>=3.19.1
23
25
 
24
26
  [![Build Status](https://app.travis-ci.com/cloudinary/cloudinary-cli.svg)](https://app.travis-ci.com/cloudinary/cloudinary-cli)
25
27
  [![PyPI Downloads](https://img.shields.io/pypi/dm/cloudinary-cli.svg)](https://pypistats.org/packages/cloudinary-cli)
@@ -24,6 +24,7 @@ cloudinary_cli/core/search.py
24
24
  cloudinary_cli/core/uploader.py
25
25
  cloudinary_cli/core/utils.py
26
26
  cloudinary_cli/modules/__init__.py
27
+ cloudinary_cli/modules/clone.py
27
28
  cloudinary_cli/modules/make.py
28
29
  cloudinary_cli/modules/migrate.py
29
30
  cloudinary_cli/modules/regen_derived.py
@@ -1,7 +1,9 @@
1
- cloudinary>=1.37.0
1
+ cloudinary>=1.42.2
2
2
  pygments
3
3
  jinja2
4
4
  click
5
5
  click-log
6
6
  requests
7
7
  docstring-parser
8
+ urllib3>=2.2.2
9
+ zipp>=3.19.1
@@ -0,0 +1,9 @@
1
+ cloudinary>=1.42.2
2
+ pygments
3
+ jinja2
4
+ click
5
+ click-log
6
+ requests
7
+ docstring-parser
8
+ urllib3>=2.2.2 # not directly required, pinned by Snyk to avoid a vulnerability
9
+ zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability
@@ -36,3 +36,11 @@ class TestCLISearchApi(unittest.TestCase):
36
36
  self.assertIn('eyJleHByZXNzaW9uIjoiY2F0IiwibWF4X3Jlc3VsdHMiOjEwfQ==', result.output)
37
37
  self.assertIn('1000', result.output)
38
38
  self.assertIn('NEXT_CURSOR', result.output)
39
+
40
+ @patch(URLLIB3_REQUEST)
41
+ def test_search_folders(self, mocker):
42
+ mocker.return_value = API_MOCK_RESPONSE
43
+ result = self.runner.invoke(cli, ['search_folders', 'cat_folder'])
44
+
45
+ self.assertEqual(0, result.exit_code)
46
+ self.assertIn('"foo": "bar"', result.output)
@@ -184,3 +184,36 @@ class TestCLISync(unittest.TestCase):
184
184
  self.assertEqual(0, result.exit_code)
185
185
  self.assertIn("Skipping 12 items", result.output)
186
186
  self.assertIn("Done!", result.output)
187
+
188
+
189
+ @retry_assertion
190
+ def test_cli_sync_push_dry_run(self):
191
+ self._upload_sync_files(TEST_FILES_DIR)
192
+
193
+ # wait for indexing to be updated
194
+ time.sleep(self.GRACE_PERIOD)
195
+
196
+ result = self.runner.invoke(cli, ['sync', '--push', '-F', self.LOCAL_PARTIAL_SYNC_DIR, self.CLD_SYNC_DIR, '--dry-run'])
197
+
198
+ # check that no files were uploaded
199
+ self.assertEqual(0, result.exit_code)
200
+ self.assertIn("Dry run mode enabled. The following files would be uploaded:", result.output)
201
+ self.assertIn("Done!", result.output)
202
+
203
+
204
+ @retry_assertion
205
+ def test_cli_sync_pull_dry_run(self):
206
+ self._upload_sync_files(TEST_FILES_DIR)
207
+
208
+ # wait for indexing to be updated
209
+ time.sleep(self.GRACE_PERIOD)
210
+
211
+ shutil.copytree(self.LOCAL_PARTIAL_SYNC_DIR, self.LOCAL_SYNC_PULL_DIR)
212
+
213
+ result = self.runner.invoke(cli, ['sync', '--pull', '-F', self.LOCAL_SYNC_PULL_DIR, self.CLD_SYNC_DIR, '--dry-run'])
214
+
215
+ # check that no files were downloaded
216
+ self.assertEqual(0, result.exit_code)
217
+ self.assertIn("Dry run mode enabled. The following files would be downloaded:", result.output)
218
+ self.assertIn("Done!", result.output)
219
+
@@ -14,7 +14,7 @@ class UtilsTest(unittest.TestCase):
14
14
  self.assertDictEqual({"foo": "bar"}, parse_option_value('{"foo":"bar"}'))
15
15
  self.assertDictEqual({"an": "object", "or": "dict"}, parse_option_value('{"an":"object","or":"dict"}'))
16
16
  self.assertListEqual(
17
- ["this", "will", "be", "read", "as","a", "list"],
17
+ ["this", "will", "be", "read", "as", "a", "list"],
18
18
  parse_option_value('["this","will","be","read","as","a","list"]')
19
19
  )
20
20
  self.assertListEqual(
@@ -62,6 +62,23 @@ class UtilsTest(unittest.TestCase):
62
62
  self.assertEqual(0, len(args))
63
63
  self.assertDictEqual({"arg1": "a1", "arg2": "a2"}, kwargs)
64
64
 
65
+ # should consume list values separated by spaces and commas
66
+ args, kwargs = parse_args_kwargs(_list_args_test_func, ["l0a0,l0a1,l0a2", "sa0", "l1a0", "sa2", "l1a1,l1a2", "l1a3"])
67
+ self.assertEqual(4, len(args))
68
+ self.assertListEqual(["l0a0", "l0a1", "l0a2"], args[0])
69
+ self.assertEqual("sa0", args[1])
70
+ self.assertListEqual(["l1a0", "l1a1", "l1a2", "l1a3"], args[2])
71
+ self.assertEqual("sa2", args[3])
72
+
73
+ # should consume list values separated by spaces and commas in kwargs
74
+ args, kwargs = parse_args_kwargs(_list_args_test_func,
75
+ ["l0a0,l0a1,l0a2", "sa0"], {"list_arg": "l1a0,l1a1", "non_list_arg2": "sa2"})
76
+ self.assertEqual(2, len(args))
77
+ self.assertListEqual(["l0a0", "l0a1", "l0a2"], args[0])
78
+ self.assertEqual("sa0", args[1])
79
+ self.assertListEqual(["l1a0", "l1a1"], kwargs["list_arg"])
80
+ self.assertEqual("sa2", kwargs["non_list_arg2"])
81
+
65
82
  def test_group_params(self):
66
83
  self.assertDictEqual({}, group_params([]))
67
84
  self.assertDictEqual({"k1": "v1", "k2": "v2"}, group_params([("k1", "v1"), ("k2", "v2")]))
@@ -113,7 +130,9 @@ class UtilsTest(unittest.TestCase):
113
130
 
114
131
  def test_normalize_list_params(self):
115
132
  """ should normalize a list of parameters """
116
- self.assertEqual(["f1", "f2", "f3"], normalize_list_params(["f1,f2", "f3"]))
133
+ self.assertListEqual(["f1"], normalize_list_params("f1"))
134
+ self.assertListEqual(["f1", "f2", "f3"], normalize_list_params(["f1,f2", "f3"]))
135
+ self.assertListEqual(["f1", "f2", "f3"], normalize_list_params("f1,f2,f3"))
117
136
 
118
137
  def test_chunker(self):
119
138
  animals = ['cat', 'dog', 'rabbit', 'duck', 'bird', 'cow', 'gnu', 'fish']
@@ -131,3 +150,21 @@ def _only_args_test_func(arg1, arg2):
131
150
 
132
151
  def _args_kwargs_test_func(arg1, arg2=None):
133
152
  return arg1, arg2
153
+
154
+
155
+ def _list_args_test_func(fist_list_arg, non_list_arg, list_arg=None, non_list_arg2=None):
156
+ """
157
+ Function for testing list args.
158
+
159
+ :param fist_list_arg: first list argument
160
+ :type fist_list_arg: list
161
+ :param non_list_arg: some non-list argument
162
+ :type non_list_arg: str
163
+ :param list_arg: some list argument
164
+ :type list_arg: list
165
+ :param non_list_arg2: another non-list argument
166
+ :type non_list_arg2: str
167
+ :return: tuple of arguments
168
+ :rtype: tuple
169
+ """
170
+ return fist_list_arg, non_list_arg, list_arg, non_list_arg2
@@ -1,129 +0,0 @@
1
- import cloudinary
2
- from click import command, argument, option, launch
3
-
4
- from cloudinary_cli.defaults import logger
5
- from cloudinary_cli.utils.json_utils import write_json_to_file, print_json
6
- from cloudinary_cli.utils.utils import write_json_list_to_csv, confirm_action, whitelist_keys, \
7
- normalize_list_params
8
-
9
- DEFAULT_MAX_RESULTS = 500
10
-
11
-
12
- @command("search",
13
- short_help="Run the admin API search method.",
14
- help="""\b
15
- Run the admin API search method.
16
- Format: cld <cli options> search <command options> <Lucene query syntax string>
17
- e.g. cld search cat AND tags:kitten -s public_id desc -f context -f tags -n 10
18
- """)
19
- @argument("query", nargs=-1)
20
- @option("-f", "--with_field", multiple=True, help="Specify which non-default asset attributes to include "
21
- "in the result as a comma separated list. ")
22
- @option("-fi", "--fields", multiple=True, help="Specify which asset attributes to include in the result "
23
- "(together with a subset of the default attributes) as a comma separated"
24
- " list. This overrides any value specified for with_field.")
25
- @option("-s", "--sort_by", nargs=2, help="Sort search results by (field, <asc|desc>).")
26
- @option("-a", "--aggregate", nargs=1,
27
- help="Specify the attribute for which an aggregation count should be calculated and returned.")
28
- @option("-n", "--max_results", nargs=1, default=10,
29
- help="The maximum number of results to return. Default: 10, maximum: 500.")
30
- @option("-c", "--next_cursor", nargs=1, help="Continue a search using an existing cursor.")
31
- @option("-A", "--auto_paginate", is_flag=True, help="Return all results. Will call Admin API multiple times.")
32
- @option("-F", "--force", is_flag=True, help="Skip confirmation when running --auto-paginate.")
33
- @option("-ff", "--filter_fields", multiple=True, help="Specify which attributes to show in the response. "
34
- "None of the others will be shown.")
35
- @option("-t", "--ttl", nargs=1, default=300, help="Set the Search URL TTL in seconds. Default: 300.")
36
- @option("-u", "--url", is_flag=True, help="Build a signed search URL.")
37
- @option("-sq", "--search-query", is_flag=True, help="Show the search request query.", hidden=True)
38
- @option("--json", nargs=1, help="Save JSON output to a file. Usage: --json <filename>")
39
- @option("--csv", nargs=1, help="Save CSV output to a file. Usage: --csv <filename>")
40
- @option("-d", "--doc", is_flag=True, help="Open Search API documentation page.")
41
- def search(query, with_field, fields, sort_by, aggregate, max_results, next_cursor,
42
- auto_paginate, force, filter_fields, ttl, url, search_query, json, csv, doc):
43
- if doc:
44
- return launch("https://cloudinary.com/documentation/search_api")
45
-
46
- fields_to_keep = []
47
- if filter_fields:
48
- fields_to_keep = tuple(normalize_list_params(filter_fields)) + tuple(normalize_list_params(with_field))
49
-
50
- search = cloudinary.search.Search().expression(" ".join(query))
51
-
52
- if auto_paginate:
53
- max_results = DEFAULT_MAX_RESULTS
54
- if with_field:
55
- search.with_field(normalize_list_params(with_field))
56
- if fields:
57
- search.fields(normalize_list_params(fields))
58
- if sort_by:
59
- search.sort_by(*sort_by)
60
- if aggregate:
61
- search.aggregate(aggregate)
62
- if next_cursor:
63
- search.next_cursor(next_cursor)
64
- if ttl:
65
- search.ttl(ttl)
66
-
67
- search.max_results(max_results)
68
-
69
- if url:
70
- print(search.to_url())
71
- return True
72
-
73
- if search_query:
74
- print_json(search.as_dict())
75
- return True
76
-
77
- res = execute_single_request(search, fields_to_keep)
78
-
79
- if auto_paginate:
80
- res = handle_auto_pagination(res, search, force, fields_to_keep)
81
-
82
- print_json(res)
83
-
84
- if json:
85
- write_json_to_file(res['resources'], json)
86
- logger.info(f"Saved search JSON to '{json}' file")
87
-
88
- if csv:
89
- write_json_list_to_csv(res['resources'], csv, fields_to_keep)
90
- logger.info(f"Saved search to '{csv}.csv' file")
91
-
92
-
93
- def execute_single_request(expression, fields_to_keep):
94
- res = expression.execute()
95
-
96
- if fields_to_keep:
97
- res['resources'] = whitelist_keys(res['resources'], fields_to_keep)
98
-
99
- return res
100
-
101
-
102
- def handle_auto_pagination(res, expression, force, fields_to_keep):
103
- if 'next_cursor' not in res:
104
- return res
105
-
106
- if not force:
107
- if not confirm_action(
108
- f"{res['total_count']} total results. "
109
- f"{res.rate_limit_remaining + 1} Admin API rate limit remaining.\n"
110
- f"Running this query will use {res['total_count'] // DEFAULT_MAX_RESULTS + 1} Admin API calls. "
111
- f"Continue? (y/N)"):
112
- logger.info("Stopping. Please run again without -A.")
113
-
114
- return res
115
- else:
116
- logger.info("Continuing. You may use the -F flag to force auto_pagination.")
117
-
118
- all_results = res
119
- while 'next_cursor' in res.keys():
120
- expression.next_cursor(res['next_cursor'])
121
-
122
- res = execute_single_request(expression, fields_to_keep)
123
-
124
- all_results['resources'] += res['resources']
125
- all_results['time'] += res['time']
126
-
127
- all_results.pop('next_cursor', None) # it is empty by now
128
-
129
- return all_results
@@ -1 +0,0 @@
1
- __version__ = "1.10.3"
@@ -1,7 +0,0 @@
1
- cloudinary>=1.37.0
2
- pygments
3
- jinja2
4
- click
5
- click-log
6
- requests
7
- docstring-parser
File without changes