Skip to content

Commit ba57de8

Browse files
new: Add support for explicit nullable arguments (#421)
## 📝 Description This change allows users to specify `null` as a value for fields that are marked as `nullable` in the API spec. This is useful for situations where the API may have certain functionality tied to having a certain value be explicitly `null` (e.g. RDNS resets) e.g. ```bash linode-cli networking ip-update --rdns null 127.0.0.1 ``` **NOTE: This will need to be partially reworked after the spec parser refactor, but the change should be relatively straightforward.** Resolves #266 ## ✔️ How to Test ``` make testunit ```
1 parent 2fbcfa6 commit ba57de8

File tree

6 files changed

+246
-79
lines changed

6 files changed

+246
-79
lines changed

linodecli/api_request.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from linodecli.helpers import API_CA_PATH
1616

17-
from .baked.operation import OpenAPIOperation
17+
from .baked.operation import ExplicitNullValue, OpenAPIOperation
1818
from .helpers import handle_url_overrides
1919

2020

@@ -199,7 +199,19 @@ def _build_request_body(ctx, operation, parsed_args) -> Optional[str]:
199199
parsed_args, operation.allowed_defaults, operation.action
200200
)
201201

202-
to_json = {k: v for k, v in vars(parsed_args).items() if v is not None}
202+
to_json = {}
203+
204+
for k, v in vars(parsed_args).items():
205+
# Skip null values
206+
if v is None:
207+
continue
208+
209+
# Explicitly include ExplicitNullValues
210+
if isinstance(v, ExplicitNullValue):
211+
to_json[k] = None
212+
continue
213+
214+
to_json[k] = v
203215

204216
expanded_json = {}
205217

linodecli/baked/operation.py

Lines changed: 127 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import re
99
from getpass import getpass
1010
from os import environ, path
11+
from typing import List, Tuple
1112

1213
from linodecli.baked.request import OpenAPIFilteringRequest, OpenAPIRequest
1314
from linodecli.baked.response import OpenAPIResponse
@@ -49,6 +50,31 @@ def parse_dict(value):
4950
}
5051

5152

53+
# pylint: disable=too-few-public-methods
54+
class ExplicitNullValue:
55+
"""
56+
A special type class used to explicitly pass null values to the API.
57+
"""
58+
59+
60+
def wrap_parse_nullable_value(arg_type):
61+
"""
62+
A helper function to parse `null` as None for nullable CLI args.
63+
This is intended to be called and passed to the `type=` kwarg for ArgumentParser.add_argument.
64+
"""
65+
66+
def type_func(value):
67+
if not value:
68+
return None
69+
70+
if value == "null":
71+
return ExplicitNullValue()
72+
73+
return TYPES[arg_type](value)
74+
75+
return type_func
76+
77+
5278
class ListArgumentAction(argparse.Action):
5379
"""
5480
This action is intended to be used only with list arguments.
@@ -294,93 +320,93 @@ def process_response_json(
294320
json = self.response_model.fix_json(json)
295321
handler.print(self.response_model, json)
296322

297-
def parse_args(
298-
self, args
299-
): # pylint: disable=too-many-locals,too-many-branches,too-many-statements
300-
"""
301-
Given sys.argv after the operation name, parse args based on the params
302-
and args of this operation
303-
"""
304-
list_items = []
323+
def _add_args_filter(self, parser):
324+
# build args for filtering
325+
for attr in self.response_model.attrs:
326+
if not attr.filterable:
327+
continue
328+
329+
expected_type = TYPES[attr.datatype]
330+
if expected_type == list:
331+
parser.add_argument(
332+
"--" + attr.name,
333+
type=TYPES[attr.item_type],
334+
metavar=attr.name,
335+
action="append",
336+
nargs="?",
337+
)
338+
else:
339+
parser.add_argument(
340+
"--" + attr.name,
341+
type=expected_type,
342+
metavar=attr.name,
343+
)
305344

306-
# build an argparse
307-
parser = argparse.ArgumentParser(
308-
prog=f"linode-cli {self.command} {self.action}",
309-
description=self.summary,
310-
)
311-
for param in self.params:
312-
parser.add_argument(
313-
param.name, metavar=param.name, type=TYPES[param.type]
314-
)
345+
def _add_args_post_put(self, parser) -> List[Tuple[str, str]]:
346+
list_items = []
315347

316-
if self.method == "get":
317-
# build args for filtering
318-
for attr in self.response_model.attrs:
319-
if attr.filterable:
320-
expected_type = TYPES[attr.datatype]
321-
if expected_type == list:
322-
parser.add_argument(
323-
"--" + attr.name,
324-
type=TYPES[attr.item_type],
325-
metavar=attr.name,
326-
action="append",
327-
nargs="?",
328-
)
329-
else:
330-
parser.add_argument(
331-
"--" + attr.name,
332-
type=expected_type,
333-
metavar=attr.name,
334-
)
348+
# build args for body JSON
349+
for arg in self.args:
350+
if arg.read_only:
351+
continue
335352

336-
elif self.method in ("post", "put"):
337-
# build args for body JSON
338-
for arg in self.args:
339-
if arg.read_only:
340-
continue
341-
if arg.datatype == "array":
342-
# special handling for input arrays
353+
arg_type = (
354+
arg.item_type if arg.datatype == "array" else arg.datatype
355+
)
356+
arg_type_handler = TYPES[arg_type]
357+
358+
if arg.nullable:
359+
arg_type_handler = wrap_parse_nullable_value(arg_type)
360+
361+
if arg.datatype == "array":
362+
# special handling for input arrays
363+
parser.add_argument(
364+
"--" + arg.path,
365+
metavar=arg.name,
366+
action="append",
367+
type=arg_type_handler,
368+
)
369+
elif arg.list_item:
370+
parser.add_argument(
371+
"--" + arg.path,
372+
metavar=arg.name,
373+
action=ListArgumentAction,
374+
type=arg_type_handler,
375+
)
376+
list_items.append((arg.path, arg.prefix))
377+
else:
378+
if arg.datatype == "string" and arg.format == "password":
379+
# special case - password input
343380
parser.add_argument(
344381
"--" + arg.path,
345-
metavar=arg.name,
346-
action="append",
347-
type=TYPES[arg.item_type],
382+
nargs="?",
383+
action=PasswordPromptAction,
348384
)
349-
elif arg.list_item:
385+
elif arg.datatype == "string" and arg.format in (
386+
"file",
387+
"ssl-cert",
388+
"ssl-key",
389+
):
350390
parser.add_argument(
351391
"--" + arg.path,
352392
metavar=arg.name,
353-
action=ListArgumentAction,
354-
type=TYPES[arg.datatype],
393+
action=OptionalFromFileAction,
394+
type=arg_type_handler,
355395
)
356-
list_items.append((arg.path, arg.prefix))
357396
else:
358-
if arg.datatype == "string" and arg.format == "password":
359-
# special case - password input
360-
parser.add_argument(
361-
"--" + arg.path,
362-
nargs="?",
363-
action=PasswordPromptAction,
364-
)
365-
elif arg.datatype == "string" and arg.format in (
366-
"file",
367-
"ssl-cert",
368-
"ssl-key",
369-
):
370-
parser.add_argument(
371-
"--" + arg.path,
372-
metavar=arg.name,
373-
action=OptionalFromFileAction,
374-
type=TYPES[arg.datatype],
375-
)
376-
else:
377-
parser.add_argument(
378-
"--" + arg.path,
379-
metavar=arg.name,
380-
type=TYPES[arg.datatype],
381-
)
382-
383-
parsed = parser.parse_args(args)
397+
parser.add_argument(
398+
"--" + arg.path,
399+
metavar=arg.name,
400+
type=arg_type_handler,
401+
)
402+
403+
return list_items
404+
405+
@staticmethod
406+
def _handle_list_items(
407+
list_items,
408+
parsed,
409+
): # pylint: disable=too-many-locals,too-many-branches,too-many-statements
384410
lists = {}
385411
# group list items as expected
386412
for arg_name, list_name in list_items:
@@ -441,3 +467,28 @@ def parse_args(
441467
parsed = argparse.Namespace(**parsed)
442468

443469
return parsed
470+
471+
def parse_args(self, args):
472+
"""
473+
Given sys.argv after the operation name, parse args based on the params
474+
and args of this operation
475+
"""
476+
477+
# build an argparse
478+
parser = argparse.ArgumentParser(
479+
prog=f"linode-cli {self.command} {self.action}",
480+
description=self.summary,
481+
)
482+
for param in self.params:
483+
parser.add_argument(
484+
param.name, metavar=param.name, type=TYPES[param.type]
485+
)
486+
487+
list_items = []
488+
489+
if self.method == "get":
490+
self._add_args_filter(parser)
491+
elif self.method in ("post", "put"):
492+
list_items = self._add_args_post_put(parser)
493+
494+
return self._handle_list_items(list_items, parser.parse_args(args))

linodecli/baked/request.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ def __init__(
6565
#: The path of the path element in the schema.
6666
self.prefix = prefix
6767

68+
#: Whether null is an acceptable value for this attribute
69+
self.nullable = schema.nullable
70+
6871
# handle the type for list values if this is an array
6972
if self.datatype == "array" and schema.items:
7073
self.item_type = schema.items.type

tests/fixtures/api_request_test_foobar_post.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,15 @@ components:
8585
x-linode-filterable: true
8686
type: string
8787
description: The region
88+
nullable_int:
89+
type: integer
90+
nullable: true
91+
description: An arbitrary nullable int
92+
nullable_string:
93+
type: string
94+
nullable: true
95+
description: An arbitrary nullable string
96+
nullable_float:
97+
type: number
98+
nullable: true
99+
description: An arbitrary nullable float

tests/unit/test_api_request.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import requests
1212

1313
from linodecli import api_request
14+
from linodecli.baked.operation import ExplicitNullValue
1415

1516

1617
class TestAPIRequest:
@@ -56,17 +57,74 @@ def test_request_debug_info(self):
5657
def test_build_request_body(self, mock_cli, create_operation):
5758
create_operation.allowed_defaults = ["region", "engine"]
5859
create_operation.action = "mysql-create"
60+
5961
result = api_request._build_request_body(
6062
mock_cli,
6163
create_operation,
62-
SimpleNamespace(generic_arg="foo", region=None, engine=None),
64+
SimpleNamespace(
65+
generic_arg="foo",
66+
region=None,
67+
engine=None,
68+
),
69+
)
70+
assert (
71+
json.dumps(
72+
{
73+
"generic_arg": "foo",
74+
"region": "us-southeast",
75+
"engine": "mysql/8.0.26",
76+
}
77+
)
78+
== result
79+
)
80+
81+
def test_build_request_body_null_field(self, mock_cli, create_operation):
82+
create_operation.allowed_defaults = ["region", "engine"]
83+
create_operation.action = "mysql-create"
84+
result = api_request._build_request_body(
85+
mock_cli,
86+
create_operation,
87+
SimpleNamespace(
88+
generic_arg="foo",
89+
region=None,
90+
engine=None,
91+
nullable_int=ExplicitNullValue(),
92+
),
93+
)
94+
assert (
95+
json.dumps(
96+
{
97+
"generic_arg": "foo",
98+
"region": "us-southeast",
99+
"engine": "mysql/8.0.26",
100+
"nullable_int": None,
101+
}
102+
)
103+
== result
104+
)
105+
106+
def test_build_request_body_non_null_field(
107+
self, mock_cli, create_operation
108+
):
109+
create_operation.allowed_defaults = ["region", "engine"]
110+
create_operation.action = "mysql-create"
111+
result = api_request._build_request_body(
112+
mock_cli,
113+
create_operation,
114+
SimpleNamespace(
115+
generic_arg="foo",
116+
region=None,
117+
engine=None,
118+
nullable_int=12345,
119+
),
63120
)
64121
assert (
65122
json.dumps(
66123
{
67124
"generic_arg": "foo",
68125
"region": "us-southeast",
69126
"engine": "mysql/8.0.26",
127+
"nullable_int": 12345,
70128
}
71129
)
72130
== result

0 commit comments

Comments
 (0)