Skip to content

Commit 04781d2

Browse files
Add support for custom aggregations in search command
1 parent 8c4800e commit 04781d2

File tree

3 files changed

+335
-1
lines changed

3 files changed

+335
-1
lines changed

cloudinary_cli/core/search.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from cloudinary_cli.utils.json_utils import write_json_to_file, print_json
77
from cloudinary_cli.utils.utils import write_json_list_to_csv, confirm_action, whitelist_keys, \
88
normalize_list_params
9+
from cloudinary_cli.utils.search_utils import parse_aggregate
910

1011
DEFAULT_MAX_RESULTS = 500
1112

@@ -100,7 +101,7 @@ def _perform_search(query, with_field, fields, sort_by, aggregate, max_results,
100101
if sort_by:
101102
search.sort_by(*sort_by)
102103
if aggregate:
103-
search.aggregate(aggregate)
104+
search.aggregate(parse_aggregate(aggregate))
104105
if next_cursor:
105106
search.next_cursor(next_cursor)
106107
if ttl:
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import json
2+
from cloudinary.utils import build_array
3+
4+
5+
def parse_aggregate(agg_input):
6+
"""
7+
Parses an aggregator definition or list of definitions into structured aggregator objects.
8+
9+
Accepts:
10+
- Full JSON (if a string starts with '{')
11+
- Transformation-style string (if a string contains ':')
12+
- Simple aggregate string
13+
- A list (or tuple) of any of the above
14+
15+
:param agg_input: Aggregator definition(s) as a string or list of strings.
16+
:type agg_input: str or list or dict
17+
:return: List of parsed aggregator objects.
18+
:rtype: list
19+
"""
20+
agg_list = build_array(agg_input)
21+
parsed_aggregators = []
22+
23+
for agg in agg_list:
24+
if isinstance(agg, str):
25+
s = agg.strip()
26+
27+
if s.startswith("{"):
28+
parsed = parse_json_aggregate(s)
29+
else:
30+
parsed = parse_aggregate_string(s)
31+
32+
parsed_aggregators.append(parsed)
33+
else:
34+
parsed_aggregators.append(agg)
35+
36+
return parsed_aggregators
37+
38+
39+
def parse_json_aggregate(s):
40+
"""
41+
Parses a JSON aggregator string.
42+
43+
:param s: JSON aggregator string.
44+
:type s: str
45+
:return: Parsed aggregator object.
46+
:rtype: dict
47+
:raises: ValueError if JSON is invalid or missing the required 'type' key.
48+
"""
49+
try:
50+
agg_obj = json.loads(s)
51+
except json.JSONDecodeError as e:
52+
raise ValueError("Invalid JSON provided for aggregate: " + str(e))
53+
54+
if not (isinstance(agg_obj, dict) and "type" in agg_obj):
55+
raise ValueError("Full JSON aggregate must be an object with a 'type' key.")
56+
57+
return agg_obj
58+
59+
60+
def parse_aggregate_string(s):
61+
"""
62+
Parses a transformation-style aggregator string into a structured aggregator.
63+
64+
Expected format:
65+
"agg_type:range1,range2,..."
66+
where each range is in the format "<key>_<from>-<to>".
67+
68+
If the string does not contain a colon, it is returned as-is.
69+
70+
:param s: Aggregator string.
71+
:type s: str
72+
:return: Aggregator object (dict) if colon is present, else the original string.
73+
"""
74+
if ":" not in s:
75+
return s
76+
77+
try:
78+
agg_type, range_str = s.split(":", 1)
79+
except ValueError:
80+
raise ValueError("Aggregator string must contain a colon separating type and ranges.")
81+
82+
agg_type = agg_type.strip()
83+
ranges = []
84+
85+
for part in range_str.split(","):
86+
part = part.strip()
87+
if not part:
88+
continue
89+
90+
range_dict = parse_range_definition(part)
91+
ranges.append(range_dict)
92+
93+
result = {"type": agg_type, "ranges": ranges}
94+
return result
95+
96+
97+
def parse_range_definition(part):
98+
"""
99+
Parses a single range definition in the format "<key>_<range_value>".
100+
101+
:param part: Range definition string.
102+
:type part: str
103+
:return: Dict with 'key' and parsed 'from' and/or 'to' values.
104+
"""
105+
if "_" not in part:
106+
raise ValueError("Range definition '{}' must contain an underscore separating key and value.".format(part))
107+
108+
key, value = part.split("_", 1)
109+
key = key.strip()
110+
value = value.strip()
111+
112+
if "-" not in value:
113+
raise ValueError("Range value in '{}' must contain a dash (-) separating from and to values.".format(part))
114+
115+
from_val, to_val = parse_range_bounds(value, part)
116+
range_dict = {"key": key}
117+
118+
if from_val is not None:
119+
range_dict["from"] = from_val
120+
121+
if to_val is not None:
122+
range_dict["to"] = to_val
123+
124+
return range_dict
125+
126+
127+
def parse_range_bounds(value, part):
128+
"""
129+
Parses a range value in the format "from-to", where either may be omitted.
130+
Returns numeric values (int if whole number, else float) or None.
131+
132+
:param value: Range value string.
133+
:type value: str
134+
:param part: Original range definition string.
135+
:type part: str
136+
:return: Tuple (from_val, to_val) as numbers or None.
137+
"""
138+
parts = value.split("-", 1)
139+
from_val = parse_numeric_value(parts[0], "from", part)
140+
to_val = parse_numeric_value(parts[1], "to", part)
141+
142+
return from_val, to_val
143+
144+
def parse_numeric_value(value, label, part):
145+
"""
146+
Parses a numeric value (int or float) or returns None if the value is empty.
147+
148+
:param value: The string to parse.
149+
:type value: str
150+
:param label: The label ('from' or 'to') for error messages.
151+
:type label: str
152+
:param part: The original range definition string for error context.
153+
:type part: str
154+
:return: Parsed numeric value (int or float) or None.
155+
:rtype: int, float, or None
156+
:raises ValueError: If the value is not a valid number.
157+
"""
158+
value = value.strip() if value else value
159+
try:
160+
num = float(value) if value else None
161+
return int(num) if num is not None and num.is_integer() else num
162+
except ValueError:
163+
raise ValueError(f"Invalid numeric value for '{label}' in range '{part}'.")

test/test_search_utils.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import unittest
2+
from cloudinary_cli.utils.search_utils import (
3+
parse_aggregate,
4+
parse_json_aggregate,
5+
parse_aggregate_string,
6+
parse_range_definition,
7+
parse_range_bounds
8+
)
9+
10+
11+
class TestAggregateParsing(unittest.TestCase):
12+
13+
# --- Tests for parse_json_aggregate ---
14+
15+
def test_parse_json_aggregate_valid(self):
16+
s = '{"type": "bytes", "ranges": [{"key": "tiny", "to": 500}]}'
17+
result = parse_json_aggregate(s)
18+
expected = {"type": "bytes", "ranges": [{"key": "tiny", "to": 500}]}
19+
self.assertEqual(expected, result)
20+
21+
def test_parse_json_aggregate_invalid_json(self):
22+
s = '{"type": "bytes", "ranges": [{"key": "tiny", "to": 500}' # missing closing ]
23+
with self.assertRaises(ValueError):
24+
parse_json_aggregate(s)
25+
26+
def test_parse_json_aggregate_missing_type(self):
27+
s = '{"ranges": [{"key": "tiny", "to": 500}]}'
28+
with self.assertRaises(ValueError):
29+
parse_json_aggregate(s)
30+
31+
# --- Tests for parse_aggregate_string ---
32+
33+
def test_parse_aggregate_string_valid(self):
34+
s = "bytes:tiny_-500,medium_501-1999,big_2000-"
35+
result = parse_aggregate_string(s)
36+
expected = {
37+
"type": "bytes",
38+
"ranges": [
39+
{"key": "tiny", "to": 500},
40+
{"key": "medium", "from": 501, "to": 1999},
41+
{"key": "big", "from": 2000}
42+
]
43+
}
44+
self.assertEqual(expected, result)
45+
46+
def test_parse_aggregate_string_no_colon(self):
47+
s = "format"
48+
result = parse_aggregate_string(s)
49+
self.assertEqual(s, result)
50+
51+
# --- Tests for parse_aggregate (supports list and non-string inputs) ---
52+
53+
def test_parse_aggregate_simple_string(self):
54+
s = "format"
55+
result = parse_aggregate(s)
56+
self.assertEqual([s], result)
57+
58+
def test_parse_aggregate_json(self):
59+
s = '{"type": "bytes", "ranges": [{"key": "tiny", "to": 500}]}'
60+
result = parse_aggregate(s)
61+
expected = [{"type": "bytes", "ranges": [{"key": "tiny", "to": 500}]}]
62+
self.assertEqual(expected, result)
63+
64+
def test_parse_aggregate_transformation_string(self):
65+
s = "bytes:tiny_-500,medium_501-1999,big_2000-"
66+
result = parse_aggregate(s)
67+
expected = [{
68+
"type": "bytes",
69+
"ranges": [
70+
{"key": "tiny", "to": 500},
71+
{"key": "medium", "from": 501, "to": 1999},
72+
{"key": "big", "from": 2000}
73+
]
74+
}]
75+
self.assertEqual(expected, result)
76+
77+
def test_parse_aggregate_list_input(self):
78+
input_list = [
79+
"format",
80+
"bytes:tiny_-500,medium_501-1999,big_2000-"
81+
]
82+
result = parse_aggregate(input_list)
83+
expected = [
84+
"format",
85+
{
86+
"type": "bytes",
87+
"ranges": [
88+
{"key": "tiny", "to": 500},
89+
{"key": "medium", "from": 501, "to": 1999},
90+
{"key": "big", "from": 2000}
91+
]
92+
}
93+
]
94+
self.assertEqual(expected, result)
95+
96+
def test_parse_aggregate_non_string(self):
97+
# If a non-string (e.g. dict) is passed, build_array wraps it, and it is returned as is.
98+
d = {"type": "custom", "value": 123}
99+
result = parse_aggregate(d)
100+
self.assertEqual([d], result)
101+
102+
# --- Tests for parse_range_definition ---
103+
104+
def test_parse_range_definition_valid_tiny(self):
105+
part = "tiny_-500"
106+
result = parse_range_definition(part)
107+
expected = {"key": "tiny", "to": 500}
108+
self.assertEqual(expected, result)
109+
110+
def test_parse_range_definition_valid_medium(self):
111+
part = "medium_501-1999"
112+
result = parse_range_definition(part)
113+
expected = {"key": "medium", "from": 501, "to": 1999}
114+
self.assertEqual(expected, result)
115+
116+
def test_parse_range_definition_valid_big(self):
117+
part = "big_2000-"
118+
result = parse_range_definition(part)
119+
expected = {"key": "big", "from": 2000}
120+
self.assertEqual(expected, result)
121+
122+
def test_parse_range_definition_missing_underscore(self):
123+
part = "big2000-"
124+
with self.assertRaises(ValueError):
125+
parse_range_definition(part)
126+
127+
def test_parse_range_definition_missing_dash(self):
128+
part = "big_2000"
129+
with self.assertRaises(ValueError):
130+
parse_range_definition(part)
131+
132+
# --- Tests for parse_range_bounds ---
133+
134+
def test_parse_range_bounds_whole_numbers(self):
135+
value = "501-1999"
136+
result = parse_range_bounds(value, "test")
137+
expected = (501, 1999)
138+
self.assertEqual(expected, result)
139+
140+
def test_parse_range_bounds_floats(self):
141+
value = "24.5-29.97"
142+
result = parse_range_bounds(value, "test")
143+
expected = (24.5, 29.97)
144+
self.assertEqual(expected, result)
145+
146+
def test_parse_range_bounds_empty_from(self):
147+
value = "-500"
148+
result = parse_range_bounds(value, "test")
149+
expected = (None, 500)
150+
self.assertEqual(expected, result)
151+
152+
def test_parse_range_bounds_empty_to(self):
153+
value = "2000-"
154+
result = parse_range_bounds(value, "test")
155+
expected = (2000, None)
156+
self.assertEqual(expected, result)
157+
158+
def test_parse_range_bounds_invalid_from(self):
159+
value = "abc-100"
160+
with self.assertRaises(ValueError):
161+
parse_range_bounds(value, "test")
162+
163+
def test_parse_range_bounds_invalid_to(self):
164+
value = "100-abc"
165+
with self.assertRaises(ValueError):
166+
parse_range_bounds(value, "test")
167+
168+
169+
if __name__ == '__main__':
170+
unittest.main()

0 commit comments

Comments
 (0)