Skip to content

Commit 535c697

Browse files
lukitsbrianBrian Lukconst-cloudinary
authored
Add support for auto-paginated Admin API calls
* Add support for auto-paginated API calls * Simplify auto pagination usage * Remove unused import * Add tests * Update help string * Rename known_field to paginate_field * Align code with SearchApi Co-authored-by: Brian Luk <[email protected]> Co-authored-by: Constantine Nathanson <[email protected]>
1 parent 7c9e6ec commit 535c697

File tree

5 files changed

+216
-35
lines changed

5 files changed

+216
-35
lines changed

cloudinary_cli/core/admin.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,20 @@
1919
@option("-o", "--optional_parameter", multiple=True, nargs=2, help="Pass optional parameters as raw strings.")
2020
@option("-O", "--optional_parameter_parsed", multiple=True, nargs=2,
2121
help="Pass optional parameters as interpreted strings.")
22+
@option("-A", "--auto_paginate", is_flag=True, help="Will auto paginate Admin API calls.", default=False)
23+
@option("-ff", "--filter_fields", multiple=True, help="Filter fields to return when using auto pagination.")
24+
@option("-F", "--force", is_flag=True, help="Skip confirmation when running --auto-paginate.")
2225
@option("-ls", "--ls", is_flag=True, help="List all available methods in the Admin API.")
2326
@option("--save", nargs=1, help="Save output to a file.")
2427
@option("-d", "--doc", is_flag=True, help="Open the Admin API reference in a browser.")
25-
def admin(params, optional_parameter, optional_parameter_parsed, ls, save, doc):
26-
return handle_api_command(params, optional_parameter, optional_parameter_parsed, ls, save, doc,
28+
def admin(params, optional_parameter, optional_parameter_parsed,
29+
auto_paginate, force, filter_fields,
30+
ls, save, doc):
31+
return handle_api_command(params, optional_parameter, optional_parameter_parsed,
32+
ls, save, doc,
2733
doc_url="https://cloudinary.com/documentation/admin_api",
2834
api_instance=api,
29-
api_name="admin")
35+
api_name="admin",
36+
auto_paginate=auto_paginate,
37+
force=force,
38+
filter_fields=filter_fields)

cloudinary_cli/core/search.py

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55

66
from cloudinary_cli.defaults import logger
77
from cloudinary_cli.utils.json_utils import write_json_to_file, print_json
8-
from cloudinary_cli.utils.utils import write_json_list_to_csv, confirm_action
8+
from cloudinary_cli.utils.utils import write_json_list_to_csv, confirm_action, whitelist_keys, \
9+
normalize_list_params
910

1011
DEFAULT_MAX_RESULTS = 500
1112

@@ -38,12 +39,7 @@ def search(query, with_field, sort_by, aggregate, max_results, next_cursor,
3839

3940
fields_to_keep = []
4041
if filter_fields:
41-
for f in list(filter_fields):
42-
if "," in f:
43-
fields_to_keep += f.split(",")
44-
else:
45-
fields_to_keep.append(f)
46-
fields_to_keep = tuple(fields_to_keep) + with_field
42+
fields_to_keep = tuple(normalize_list_params(filter_fields)) + with_field
4743

4844
expression = cloudinary.search.Search().expression(" ".join(query))
4945

@@ -81,15 +77,13 @@ def execute_single_request(expression, fields_to_keep):
8177
res = expression.execute()
8278

8379
if fields_to_keep:
84-
res['resources'] = list(
85-
map(lambda x: {k: x[k] if k in x.keys() else None for k in fields_to_keep}, res['resources'])
86-
)
80+
res['resources'] = whitelist_keys(res['resources'], fields_to_keep)
8781

8882
return res
8983

9084

9185
def handle_auto_pagination(res, expression, force, fields_to_keep):
92-
if 'next_cursor' not in res.keys():
86+
if 'next_cursor' not in res:
9387
return res
9488

9589
if not force:
@@ -99,6 +93,7 @@ def handle_auto_pagination(res, expression, force, fields_to_keep):
9993
f"Running this query will use {res['total_count'] // DEFAULT_MAX_RESULTS + 1} Admin API calls. "
10094
f"Continue? (y/N)"):
10195
logger.info("Stopping. Please run again without -A.")
96+
10297
return res
10398
else:
10499
logger.info("Continuing. You may use the -F flag to force auto_pagination.")
@@ -112,6 +107,6 @@ def handle_auto_pagination(res, expression, force, fields_to_keep):
112107
all_results['resources'] += res['resources']
113108
all_results['time'] += res['time']
114109

115-
all_results.pop('next_cursor') # it is empty by now
110+
all_results.pop('next_cursor', None) # it is empty by now
116111

117112
return all_results

cloudinary_cli/utils/api_utils.py

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99

1010
from cloudinary_cli.defaults import logger
1111
from cloudinary_cli.utils.json_utils import print_json, write_json_to_file
12-
from cloudinary_cli.utils.utils import print_help, parse_args_kwargs, parse_option_value, log_exception
12+
from cloudinary_cli.utils.utils import print_help, log_exception, confirm_action, \
13+
get_command_params, merge_responses, normalize_list_params
14+
15+
PAGINATION_MAX_RESULTS = 500
16+
17+
_cursor_fields = {"resource": "derived_next_cursor"}
1318

1419

1520
def query_cld_folder(folder):
@@ -93,28 +98,22 @@ def asset_source(asset_details):
9398
return base_name + '.' + asset_details['format']
9499

95100

101+
def call_api(func, args, kwargs):
102+
return func(*args, **kwargs)
103+
104+
96105
def handle_command(
97106
params,
98107
optional_parameter,
99108
optional_parameter_parsed,
100109
module,
101110
module_name):
102-
try:
103-
func = module.__dict__[params[0]]
104-
except KeyError:
105-
raise Exception(f"Method {params[0]} does not exist in {module_name.capitalize()}.")
106-
107-
if not callable(func):
108-
raise Exception(f"{params[0]} is not callable.")
109-
110-
args, kwargs = parse_args_kwargs(func, params[1:]) if len(params) > 1 else ([], {})
111-
kwargs = {
112-
**kwargs,
113-
**{k: v for k, v in optional_parameter},
114-
**{k: parse_option_value(v) for k, v in optional_parameter_parsed},
115-
}
116-
117-
return func(*args, **kwargs)
111+
func, args, kwargs = get_command_params(params,
112+
optional_parameter,
113+
optional_parameter_parsed,
114+
module,
115+
module_name)
116+
return call_api(func, args, kwargs)
118117

119118

120119
def handle_api_command(
@@ -126,7 +125,10 @@ def handle_api_command(
126125
doc,
127126
doc_url,
128127
api_instance,
129-
api_name):
128+
api_name,
129+
auto_paginate=False,
130+
force=False,
131+
filter_fields=None):
130132
"""
131133
Used by Admin and Upload API commands
132134
"""
@@ -136,14 +138,56 @@ def handle_api_command(
136138
if ls or len(params) < 1:
137139
return print_help(api_instance)
138140

139-
res = handle_command(
141+
func, args, kwargs = get_command_params(
140142
params,
141143
optional_parameter,
142144
optional_parameter_parsed,
143145
api_instance,
144146
api_name)
145147

148+
res = call_api(func, args, kwargs)
149+
150+
if auto_paginate:
151+
res = handle_auto_pagination(res, func, args, kwargs, force, filter_fields)
152+
146153
print_json(res)
147154

148155
if save:
149156
write_json_to_file(res, save)
157+
158+
159+
def handle_auto_pagination(res, func, args, kwargs, force, filter_fields):
160+
cursor_field = _cursor_fields.get(func.__name__, "next_cursor")
161+
162+
if cursor_field not in res:
163+
return res
164+
165+
if not force:
166+
if not confirm_action(
167+
"Using auto pagination will use multiple API calls.\n" +
168+
f"You currently have {res.rate_limit_remaining} Admin API calls remaining. Continue? (y/N)"):
169+
logger.info("Stopping. Please run again without -A.")
170+
171+
return res
172+
else:
173+
logger.info("Continuing. You may use the -F flag to force auto_pagination.")
174+
175+
fields_to_keep = []
176+
if filter_fields:
177+
fields_to_keep = normalize_list_params(filter_fields)
178+
179+
kwargs['max_results'] = PAGINATION_MAX_RESULTS
180+
181+
all_results = res
182+
# We have many different APIs that have different fields that we paginate.
183+
# The field is unknown before we perform the second call and then compare results and find the field.
184+
pagination_field = None
185+
while res.get(cursor_field, None):
186+
kwargs[cursor_field] = res.get(cursor_field, None)
187+
res = call_api(func, args, kwargs)
188+
all_results, pagination_field = merge_responses(all_results, res, fields_to_keep=fields_to_keep,
189+
pagination_field=pagination_field)
190+
191+
all_results.pop(cursor_field, None)
192+
193+
return all_results

cloudinary_cli/utils/utils.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,93 @@ def get_user_action(message, options):
154154
"""
155155
r = input(message).lower()
156156
return options.get(r, options.get("default"))
157+
158+
159+
def get_command_params(
160+
params,
161+
optional_parameter,
162+
optional_parameter_parsed,
163+
module,
164+
module_name):
165+
method = params[0]
166+
if method not in module.__dict__:
167+
raise Exception(f"Method {params[0]} does not exist in {module_name.capitalize()}.")
168+
169+
func = module.__dict__.get(method)
170+
171+
if not callable(func):
172+
raise Exception(f"{params[0]} is not callable.")
173+
174+
args, kwargs = parse_args_kwargs(func, params[1:]) if len(params) > 1 else ([], {})
175+
176+
kwargs = {
177+
**kwargs,
178+
**{k: v for k, v in optional_parameter},
179+
**{k: parse_option_value(v) for k, v in optional_parameter_parsed},
180+
}
181+
182+
return func, args, kwargs
183+
184+
185+
def whitelist_keys(data, keys):
186+
"""
187+
Iterates over a list of dictionaries and keeps only the keys that were specified.
188+
189+
:param data: A list of dictionaries.
190+
:type data: list
191+
:param keys: a list of keys to keep in each dictionary.
192+
:type keys: list
193+
194+
:return: The whitelisted list.
195+
:rtype list
196+
"""
197+
# no whitelist when fields are not provided or on a list of non-dictionary items.
198+
if not keys or any(not isinstance(i, dict) for i in data):
199+
return data
200+
201+
return list(
202+
map(lambda x: {
203+
k: x[k]
204+
for k in keys if k in x},
205+
data)
206+
)
207+
208+
209+
def merge_responses(all_res, paginated_res, fields_to_keep=None, pagination_field=None):
210+
if not pagination_field:
211+
for key in all_res:
212+
if all_res[key] != paginated_res.get(key, 0) and type(all_res[key]) == list:
213+
pagination_field = key
214+
215+
if not pagination_field: # should not happen
216+
raise Exception("Failed to detect pagination_field")
217+
218+
# whitelist fields of the initial response
219+
all_res[pagination_field] = whitelist_keys(all_res[pagination_field], fields_to_keep)
220+
221+
all_res[pagination_field] += whitelist_keys(paginated_res[pagination_field], fields_to_keep)
222+
223+
return all_res, pagination_field
224+
225+
226+
def normalize_list_params(params):
227+
"""
228+
Normalizes parameters that could be provided as strings separated by ','.
229+
230+
>>> normalize_list_params(["f1,f2", "f3"])
231+
["f1", "f2", "f3"]
232+
233+
:param params: Params to normalize.
234+
:type params: list
235+
236+
:return: A list of normalized params.
237+
:rtype list
238+
"""
239+
normalized_params = []
240+
for f in list(params):
241+
if "," in f:
242+
normalized_params += f.split(",")
243+
else:
244+
normalized_params.append(f)
245+
246+
return normalized_params

test/test_utils.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import unittest
22

3-
from cloudinary_cli.utils.utils import parse_option_value
3+
from cloudinary_cli.utils.utils import parse_option_value, whitelist_keys, merge_responses, normalize_list_params
44

55

66
class UtilsTest(unittest.TestCase):
@@ -18,3 +18,46 @@ def test_parse_option_value(self):
1818
def test_parse_option_value_converts_int_to_str(self):
1919
""" should convert a parsed int to a str """
2020
self.assertEqual("1", parse_option_value(1))
21+
22+
def test_whitelist_keys(self):
23+
""" should whitelist keys correctly """
24+
self.assertEqual([{"k1": "v1"}], whitelist_keys([{"k1": "v1", "k2": "v2"}], ["k1"]))
25+
self.assertEqual([{"k1": "v1", "k2": "v2"}], whitelist_keys([{"k1": "v1", "k2": "v2"}], []))
26+
self.assertEqual([{"k1": "v1"}], whitelist_keys([{"k1": "v1", "k2": "v2"}], ["k1", "k3"]))
27+
28+
def test_merge_responses(self):
29+
""" should merge responses based with or without additional kwargs """
30+
31+
merged_1 = ({"a": "b", "c": [{"1": "2"}, {"1": "3"}, {"1": "4"}, {"1": "5"}]}, "c")
32+
merged_1_2 = (
33+
{"a": "b", "c": [{"1": "2", "2": "2"}, {"1": "3", "2": "2"}, {"1": "4", "2": "2"}, {"1": "5", "2": "2"}]},
34+
"c"
35+
)
36+
self.assertEqual(
37+
merged_1,
38+
merge_responses(
39+
{"a": "b", "c": [{"1": "2", "2": "2"}, {"1": "3", "2": "2"}]},
40+
{"a": "b", "c": [{"1": "4", "2": "2"}, {"1": "5", "2": "2"}]},
41+
fields_to_keep=["1"]))
42+
self.assertEqual(
43+
merged_1,
44+
merge_responses(
45+
{"a": "b", "c": [{"1": "2"}, {"1": "3"}]},
46+
{"a": "b", "c": [{"1": "4", "2": "2"}, {"1": "5", "2": "2"}]},
47+
fields_to_keep=["1"],
48+
pagination_field="c"))
49+
self.assertEqual(
50+
merged_1_2,
51+
merge_responses(
52+
{"a": "b", "c": [{"1": "2", "2": "2"}, {"1": "3", "2": "2"}]},
53+
{"a": "b", "c": [{"1": "4", "2": "2"}, {"1": "5", "2": "2"}]},
54+
pagination_field="c"))
55+
self.assertEqual(
56+
merged_1_2,
57+
merge_responses(
58+
{"a": "b", "c": [{"1": "2", "2": "2"}, {"1": "3", "2": "2"}]},
59+
{"a": "b", "c": [{"1": "4", "2": "2"}, {"1": "5", "2": "2"}]}))
60+
61+
def test_normalize_list_params(self):
62+
""" should normalize a list of parameters """
63+
self.assertEqual(["f1", "f2", "f3"], normalize_list_params(["f1,f2", "f3"]))

0 commit comments

Comments
 (0)