Skip to content

Commit bbdb8bb

Browse files
fix: Resolve issues with nested explicit list and null value serialization (#574)
1 parent 947ee0d commit bbdb8bb

File tree

5 files changed

+140
-22
lines changed

5 files changed

+140
-22
lines changed

linodecli/api_request.py

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,19 @@
77
import sys
88
import time
99
from sys import version_info
10-
from typing import Iterable, List, Optional
10+
from typing import Any, Iterable, List, Optional
1111

1212
import requests
1313
from packaging import version
1414
from requests import Response
1515

1616
from linodecli.helpers import API_CA_PATH
1717

18-
from .baked.operation import ExplicitNullValue, OpenAPIOperation
18+
from .baked.operation import (
19+
ExplicitEmptyListValue,
20+
ExplicitNullValue,
21+
OpenAPIOperation,
22+
)
1923
from .helpers import handle_url_overrides
2024

2125

@@ -199,6 +203,47 @@ def _build_request_url(ctx, operation, parsed_args) -> str:
199203
return result
200204

201205

206+
def _traverse_request_body(o: Any) -> Any:
207+
"""
208+
This function traverses is intended to be called immediately before
209+
request body serialization and contains special handling for dropping
210+
keys with null values and translating ExplicitNullValue instances into
211+
serializable null values.
212+
"""
213+
if isinstance(o, dict):
214+
result = {}
215+
for k, v in o.items():
216+
# Implicit null values should be dropped from the request
217+
if v is None:
218+
continue
219+
220+
# Values that are expected to be serialized as empty lists
221+
# and explicit None values are converted here.
222+
# See: operation.py
223+
# NOTE: These aren't handled at the top-level of this function
224+
# because we don't want them filtered out in the step below.
225+
if isinstance(v, ExplicitEmptyListValue):
226+
result[k] = []
227+
continue
228+
229+
if isinstance(v, ExplicitNullValue):
230+
result[k] = None
231+
continue
232+
233+
value = _traverse_request_body(v)
234+
235+
# We should exclude implicit empty lists
236+
if not (isinstance(value, (dict, list)) and len(value) < 1):
237+
result[k] = value
238+
239+
return result
240+
241+
if isinstance(o, list):
242+
return [_traverse_request_body(v) for v in o]
243+
244+
return o
245+
246+
202247
def _build_request_body(ctx, operation, parsed_args) -> Optional[str]:
203248
if operation.method == "get":
204249
# Get operations don't have a body
@@ -208,32 +253,18 @@ def _build_request_body(ctx, operation, parsed_args) -> Optional[str]:
208253
if ctx.defaults:
209254
parsed_args = ctx.config.update(parsed_args, operation.allowed_defaults)
210255

211-
to_json = {}
212-
213-
for k, v in vars(parsed_args).items():
214-
# Skip null values
215-
if v is None:
216-
continue
217-
218-
# Explicitly include ExplicitNullValues
219-
if isinstance(v, ExplicitNullValue):
220-
to_json[k] = None
221-
continue
222-
223-
to_json[k] = v
224-
225256
expanded_json = {}
226257

227258
# expand paths
228-
for k, v in to_json.items():
259+
for k, v in vars(parsed_args).items():
229260
cur = expanded_json
230261
for part in k.split(".")[:-1]:
231262
if part not in cur:
232263
cur[part] = {}
233264
cur = cur[part]
234265
cur[k.split(".")[-1]] = v
235266

236-
return json.dumps(expanded_json)
267+
return json.dumps(_traverse_request_body(expanded_json))
237268

238269

239270
def _print_request_debug_info(method, url, headers, body):

linodecli/baked/operation.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ class ExplicitNullValue:
6060
"""
6161

6262

63+
class ExplicitEmptyListValue:
64+
"""
65+
A special type used to explicitly pass empty lists to the API.
66+
"""
67+
68+
6369
def wrap_parse_nullable_value(arg_type):
6470
"""
6571
A helper function to parse `null` as None for nullable CLI args.
@@ -91,9 +97,16 @@ def __call__(self, parser, namespace, values, option_string=None):
9197

9298
output_list = getattr(namespace, self.dest)
9399

100+
# If a user has already specified an [] but is specifying
101+
# another value, assume "[]" was intended to be a literal.
102+
if isinstance(output_list, ExplicitEmptyListValue):
103+
setattr(namespace, self.dest, ["[]", values])
104+
return
105+
94106
# If the output list is empty and the user specifies a []
95-
# argument, keep the list empty
107+
# argument, set the list to an explicitly empty list.
96108
if values == "[]" and len(output_list) < 1:
109+
setattr(namespace, self.dest, ExplicitEmptyListValue())
97110
return
98111

99112
output_list.append(values)

tests/fixtures/api_request_test_foobar_post.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,7 @@ components:
120120
field_int:
121121
type: number
122122
description: An arbitrary field.
123+
nullable_string:
124+
type: string
125+
description: An arbitrary nullable string.
126+
nullable: true

tests/unit/test_api_request.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import requests
1313

1414
from linodecli import api_request
15-
from linodecli.baked.operation import ExplicitNullValue
15+
from linodecli.baked.operation import ExplicitEmptyListValue, ExplicitNullValue
1616

1717

1818
class TestAPIRequest:
@@ -102,6 +102,36 @@ def test_build_request_body_null_field(self, mock_cli, create_operation):
102102
== result
103103
)
104104

105+
def test_build_request_body_null_field_nested(
106+
self, mock_cli, create_operation
107+
):
108+
create_operation.allowed_defaults = ["region", "image"]
109+
result = api_request._build_request_body(
110+
mock_cli,
111+
create_operation,
112+
SimpleNamespace(
113+
generic_arg="foo",
114+
region=None,
115+
image=None,
116+
object_list=[{"nullable_string": ExplicitNullValue()}],
117+
),
118+
)
119+
assert (
120+
json.dumps(
121+
{
122+
"generic_arg": "foo",
123+
"region": "us-southeast",
124+
"image": "linode/ubuntu21.10",
125+
"object_list": [
126+
{
127+
"nullable_string": None,
128+
}
129+
],
130+
}
131+
)
132+
== result
133+
)
134+
105135
def test_build_request_body_non_null_field(
106136
self, mock_cli, create_operation
107137
):
@@ -517,3 +547,41 @@ def test_get_retry_after(self):
517547
headers = {}
518548
output = api_request._get_retry_after(headers)
519549
assert output == 0
550+
551+
def test_traverse_request_body(self):
552+
result = api_request._traverse_request_body(
553+
{
554+
"test_string": "sdf",
555+
"test_obj_list": [
556+
{
557+
"populated_field": "cool",
558+
"foo": None,
559+
"bar": ExplicitNullValue(),
560+
}
561+
],
562+
"test_dict": {
563+
"foo": "bar",
564+
"bar": None,
565+
"baz": ExplicitNullValue(),
566+
},
567+
"cool": [],
568+
"cooler": ExplicitEmptyListValue(),
569+
"coolest": ExplicitNullValue(),
570+
}
571+
)
572+
573+
assert result == {
574+
"test_string": "sdf",
575+
"test_obj_list": [
576+
{
577+
"populated_field": "cool",
578+
"bar": None,
579+
}
580+
],
581+
"test_dict": {
582+
"foo": "bar",
583+
"baz": None,
584+
},
585+
"cooler": [],
586+
"coolest": None,
587+
}

tests/unit/test_operation.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import argparse
22

33
from linodecli.baked import operation
4-
from linodecli.baked.operation import ExplicitNullValue
4+
from linodecli.baked.operation import ExplicitEmptyListValue, ExplicitNullValue
55

66

77
class TestOperation:
@@ -178,11 +178,13 @@ def test_parse_args_object_list(self, create_operation):
178178
"test3",
179179
]
180180
)
181+
181182
assert result.object_list == [
182183
{
183184
"field_string": "test1",
184185
"field_int": 123,
185186
"field_dict": {"nested_string": "test2", "nested_int": 789},
187+
"nullable_string": None, # We expect this to be filtered out later
186188
},
187189
{"field_int": 456, "field_dict": {"nested_string": "test3"}},
188190
]
@@ -209,7 +211,7 @@ def test_array_arg_action_basic(self):
209211

210212
# User wants an explicitly empty list
211213
result = parser.parse_args(["--foo", "[]"])
212-
assert getattr(result, "foo") == []
214+
assert isinstance(getattr(result, "foo"), ExplicitEmptyListValue)
213215

214216
# User doesn't specify the list
215217
result = parser.parse_args([])

0 commit comments

Comments
 (0)