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.
- {cloudinary_cli-1.10.3/cloudinary_cli.egg-info → cloudinary_cli-1.11.1}/PKG-INFO +4 -2
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/core/__init__.py +2 -1
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/core/overrides.py +18 -0
- cloudinary_cli-1.11.1/cloudinary_cli/core/search.py +171 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/modules/__init__.py +3 -1
- cloudinary_cli-1.11.1/cloudinary_cli/modules/clone.py +112 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/modules/sync.py +34 -12
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/utils/api_utils.py +16 -7
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/utils/config_utils.py +29 -6
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/utils/utils.py +35 -6
- cloudinary_cli-1.11.1/cloudinary_cli/version.py +1 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1/cloudinary_cli.egg-info}/PKG-INFO +4 -2
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli.egg-info/SOURCES.txt +1 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli.egg-info/requires.txt +3 -1
- cloudinary_cli-1.11.1/requirements.txt +9 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_cli_search_api.py +8 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_modules/test_cli_sync.py +33 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_utils.py +39 -2
- cloudinary_cli-1.10.3/cloudinary_cli/core/search.py +0 -129
- cloudinary_cli-1.10.3/cloudinary_cli/version.py +0 -1
- cloudinary_cli-1.10.3/requirements.txt +0 -7
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/LICENSE +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/MANIFEST.in +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/README.md +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/__init__.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/cli.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/cli_group.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/core/admin.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/core/config.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/core/provisioning.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/core/uploader.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/core/utils.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/defaults.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/modules/make.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/modules/migrate.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/modules/regen_derived.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/modules/upload_dir.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/samples/__init__.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/html/media_library_widget +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/html/product_gallery +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/html/upload_widget +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/html/video_player +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/node/upload +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/python/base +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/python/explicit +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/python/find_all_empty_folders +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/python/upload +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/ruby/upload +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/utils/__init__.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/utils/file_utils.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/utils/json_utils.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli.egg-info/dependency_links.txt +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli.egg-info/entry_points.txt +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli.egg-info/not-zip-safe +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli.egg-info/top_level.txt +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/setup.cfg +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/setup.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/__init__.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/helper_test.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_cli.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_cli_api.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_cli_config.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_cli_samples.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_cli_url.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_cli_utils.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_file_utils.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_modules/__init__.py +0 -0
- {cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/test/test_modules/test_cli_make.py +0 -0
- {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.
|
|
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.
|
|
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
|
[](https://app.travis-ci.com/cloudinary/cloudinary-cli)
|
|
25
27
|
[](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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 =
|
|
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([
|
|
301
|
-
[
|
|
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
|
|
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.
|
|
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.
|
|
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
|
[](https://app.travis-ci.com/cloudinary/cloudinary-cli)
|
|
25
27
|
[](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
|
|
@@ -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.
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/html/media_library_widget
RENAMED
|
File without changes
|
{cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli/templates/html/product_gallery
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cloudinary_cli-1.10.3 → cloudinary_cli-1.11.1}/cloudinary_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|