Skip to content

Commit 80e4c6d

Browse files
new: Improve handling for list structures (#456)
## 📝 Description This change improves the handling for nested list structures (e.g. Instance Interfaces) by implicitly detecting when the next list entry should be populated. This means that users do not have to explicitly specify empty values for null fields. For example, see this command: ```bash linode-cli linodes config-update 1234 5678 \ --interfaces.purpose public \ --interfaces.purpose vlan --interfaces.label my-vlan ``` A user would intuitively expect this command to set the first interface to public and the second interface to a VLAN with the label `my-vlan`, however executing this command would generate this request body: ```json { "interfaces":[ { "label":"cool", "purpose":"public" } ] } ``` After this change, the request body is now generated as expected: ```json { "interfaces":[ { "label":null, "ipam_address":null, "purpose":"public" }, { "label":"cool", "purpose":"vlan" } ] } ``` ## ✔️ How to Test ``` make test ```
1 parent 7bdf730 commit 80e4c6d

File tree

3 files changed

+143
-1
lines changed

3 files changed

+143
-1
lines changed

README.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,16 @@ you can execute the following::
179179

180180
linode-cli linodes create --region us-east --type g6-nanode-1 --tags tag1 --tags tag2
181181

182+
Lists consisting of nested structures can also be expressed through the command line.
183+
For example, to create a Linode with a public interface on ``eth0`` and a VLAN interface
184+
on ``eth1`` you can execute the following::
185+
186+
linode-cli linodes create \
187+
--region us-east --type g6-nanode-1 --image linode/ubuntu22.04 \
188+
--root_pass "myr00tp4ss123" \
189+
--interfaces.purpose public \
190+
--interfaces.purpose vlan --interfaces.label my-vlan
191+
182192
Specifying Nested Arguments
183193
"""""""""""""""""""""""""""
184194

linodecli/operation.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,58 @@ def __call__(self, parser, namespace, values, option_string=None):
100100
raise argparse.ArgumentTypeError("Expected a string")
101101

102102

103+
class ListArgumentAction(argparse.Action):
104+
"""
105+
This action is intended to be used only with list arguments.
106+
Its purpose is to aggregate adjacent object fields and produce consistent
107+
lists in the output namespace.
108+
"""
109+
110+
def __call__(self, parser, namespace, values, option_string=None):
111+
if getattr(namespace, self.dest) is None:
112+
setattr(namespace, self.dest, [])
113+
114+
dest_list = getattr(namespace, self.dest)
115+
dest_length = len(dest_list)
116+
dest_parent = self.dest.split(".")[:-1]
117+
118+
# If this isn't a nested structure,
119+
# append and return early
120+
if len(dest_parent) < 1:
121+
dest_list.append(values)
122+
return
123+
124+
# A list of adjacent fields
125+
adjacent_keys = [
126+
k
127+
for k in vars(namespace).keys()
128+
if k.split(".")[:-1] == dest_parent
129+
]
130+
131+
# Let's populate adjacent fields ahead of time
132+
for k in adjacent_keys:
133+
if getattr(namespace, k) is None:
134+
setattr(namespace, k, [])
135+
136+
adjacent_items = {k: getattr(namespace, k) for k in adjacent_keys}
137+
138+
# Find the deepest field so we can know if
139+
# we're starting a new object.
140+
deepest_length = max(len(x) for x in adjacent_items.values())
141+
142+
# If we're creating a new list object, append
143+
# None to every non-populated field.
144+
if dest_length >= deepest_length:
145+
for k, item in adjacent_items.items():
146+
if k == self.dest:
147+
continue
148+
149+
if len(item) < dest_length:
150+
item.append(None)
151+
152+
dest_list.append(values)
153+
154+
103155
TYPES = {
104156
"string": str,
105157
"integer": int,
@@ -244,7 +296,7 @@ def parse_args(
244296
parser.add_argument(
245297
"--" + arg.path,
246298
metavar=arg.name,
247-
action="append",
299+
action=ListArgumentAction,
248300
type=TYPES[arg.arg_type],
249301
)
250302
list_items.append((arg.path, arg.list_item))

tests/unit/test_operation.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import argparse
2+
3+
from linodecli import operation
4+
5+
6+
class TestOperation:
7+
def test_list_arg_action_basic(self):
8+
"""
9+
Tests a basic list argument condition.
10+
"""
11+
12+
parser = argparse.ArgumentParser(
13+
prog=f"foo",
14+
)
15+
16+
for arg_name in ["foo", "bar", "aaa"]:
17+
parser.add_argument(
18+
f"--foo.{arg_name}",
19+
metavar=arg_name,
20+
action=operation.ListArgumentAction,
21+
type=str,
22+
)
23+
24+
result = parser.parse_args(
25+
[
26+
"--foo.foo",
27+
"cool",
28+
"--foo.bar",
29+
"wow",
30+
"--foo.aaa",
31+
"computer",
32+
"--foo.foo",
33+
"test",
34+
"--foo.bar",
35+
"wow",
36+
"--foo.aaa",
37+
"akamai",
38+
]
39+
)
40+
assert getattr(result, "foo.foo") == ["cool", "test"]
41+
assert getattr(result, "foo.bar") == ["wow", "wow"]
42+
assert getattr(result, "foo.aaa") == ["computer", "akamai"]
43+
44+
def test_list_arg_action_missing_attr(self):
45+
"""
46+
Tests that a missing attribute for the first element will be
47+
implicitly populated.
48+
"""
49+
50+
parser = argparse.ArgumentParser(
51+
prog=f"foo",
52+
)
53+
54+
for arg_name in ["foo", "bar", "aaa"]:
55+
parser.add_argument(
56+
f"--foo.{arg_name}",
57+
metavar=arg_name,
58+
action=operation.ListArgumentAction,
59+
type=str,
60+
)
61+
62+
result = parser.parse_args(
63+
[
64+
"--foo.foo",
65+
"cool",
66+
"--foo.aaa",
67+
"computer",
68+
"--foo.foo",
69+
"test",
70+
"--foo.bar",
71+
"wow",
72+
"--foo.foo",
73+
"linode",
74+
"--foo.aaa",
75+
"akamai",
76+
]
77+
)
78+
assert getattr(result, "foo.foo") == ["cool", "test", "linode"]
79+
assert getattr(result, "foo.bar") == [None, "wow"]
80+
assert getattr(result, "foo.aaa") == ["computer", None, "akamai"]

0 commit comments

Comments
 (0)