Skip to content

Commit ede396a

Browse files
fix: Resolve bug that prevented nested dict fields in object lists from being included in request body (#571)
1 parent e6db9c2 commit ede396a

File tree

7 files changed

+175
-39
lines changed

7 files changed

+175
-39
lines changed

linodecli/baked/operation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,7 @@ def _add_args_post_put(self, parser) -> List[Tuple[str, str]]:
420420
action=ListArgumentAction,
421421
type=arg_type_handler,
422422
)
423-
list_items.append((arg.path, arg.prefix))
423+
list_items.append((arg.path, arg.list_parent))
424424
else:
425425
if arg.datatype == "string" and arg.format == "password":
426426
# special case - password input

linodecli/baked/request.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class OpenAPIRequestArg:
99
"""
1010

1111
def __init__(
12-
self, name, schema, required, prefix=None, list_item=False
12+
self, name, schema, required, prefix=None, list_parent=None
1313
): # pylint: disable=too-many-arguments
1414
"""
1515
Parses a single Schema node into a argument the CLI can use when making
@@ -60,7 +60,12 @@ def __init__(
6060
self.item_type = None
6161

6262
#: Whether the argument is a field in a nested list.
63-
self.list_item = list_item
63+
self.list_item = list_parent is not None
64+
65+
#: The name of the list this argument falls under.
66+
#: This allows nested dictionaries to be specified in lists of objects.
67+
#: e.g. --interfaces.ipv4.nat_1_1
68+
self.list_parent = list_parent
6469

6570
#: The path of the path element in the schema.
6671
self.prefix = prefix
@@ -80,7 +85,7 @@ def __init__(
8085
)
8186

8287

83-
def _parse_request_model(schema, prefix=None, list_of_objects=False):
88+
def _parse_request_model(schema, prefix=None, list_parent=None):
8489
"""
8590
Parses a schema into a list of OpenAPIRequest objects
8691
:param schema: The schema to parse as a request model
@@ -102,7 +107,9 @@ def _parse_request_model(schema, prefix=None, list_of_objects=False):
102107
if v.type == "object" and not v.readOnly and v.properties:
103108
# nested objects receive a prefix and are otherwise parsed normally
104109
pref = prefix + "." + k if prefix else k
105-
args += _parse_request_model(v, prefix=pref)
110+
args += _parse_request_model(
111+
v, prefix=pref, list_parent=list_parent
112+
)
106113
elif (
107114
v.type == "array"
108115
and v.items
@@ -113,7 +120,7 @@ def _parse_request_model(schema, prefix=None, list_of_objects=False):
113120
# of the object in the list is its own argument
114121
pref = prefix + "." + k if prefix else k
115122
args += _parse_request_model(
116-
v.items, prefix=pref, list_of_objects=True
123+
v.items, prefix=pref, list_parent=pref
117124
)
118125
else:
119126
# required fields are defined in the schema above the property, so
@@ -124,7 +131,7 @@ def _parse_request_model(schema, prefix=None, list_of_objects=False):
124131
required = k in schema.required
125132
args.append(
126133
OpenAPIRequestArg(
127-
k, v, required, prefix=prefix, list_item=list_of_objects
134+
k, v, required, prefix=prefix, list_parent=list_parent
128135
)
129136
)
130137

tests/fixtures/api_request_test_foobar_post.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,16 @@ components:
104104
type: object
105105
description: An arbitrary object.
106106
properties:
107+
field_dict:
108+
type: object
109+
description: An arbitrary nested dict.
110+
properties:
111+
nested_string:
112+
type: string
113+
description: A deeply nested string.
114+
nested_int:
115+
type: number
116+
description: A deeply nested integer.
107117
field_string:
108118
type: string
109119
description: An arbitrary field.

tests/integration/conftest.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Use random integer as the start point here to avoid
22
# id conflicts when multiple testings are running.
3+
import json
34
import logging
45
import os
56
import subprocess
@@ -402,3 +403,43 @@ def get_regions_with_capabilities(capabilities):
402403
regions_with_all_caps.append(region_name)
403404

404405
return regions_with_all_caps
406+
407+
408+
def create_vpc_w_subnet():
409+
"""
410+
Creates and returns a VPC and a corresponding subnet.
411+
412+
This is not directly implemented as a fixture because the teardown
413+
order cannot be guaranteed, causing issues when attempting to
414+
assign Linodes to a VPC in a separate fixture.
415+
416+
See: https://github.com/pytest-dev/pytest/issues/1216
417+
"""
418+
419+
region = get_regions_with_capabilities(["VPCs"])[0]
420+
vpc_label = str(time.time_ns()) + "label"
421+
subnet_label = str(time.time_ns()) + "label"
422+
423+
vpc_json = json.loads(
424+
exec_test_command(
425+
[
426+
"linode-cli",
427+
"vpcs",
428+
"create",
429+
"--label",
430+
vpc_label,
431+
"--region",
432+
region,
433+
"--subnets.ipv4",
434+
"10.0.0.0/24",
435+
"--subnets.label",
436+
subnet_label,
437+
"--json",
438+
"--suppress-warnings",
439+
]
440+
)
441+
.stdout.decode()
442+
.rstrip()
443+
)[0]
444+
445+
return vpc_json
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import json
2+
import time
3+
4+
import pytest
5+
6+
from tests.integration.conftest import create_vpc_w_subnet
7+
from tests.integration.helpers import delete_target_id, exec_test_command
8+
from tests.integration.linodes.helpers_linodes import (
9+
BASE_CMD,
10+
DEFAULT_LABEL,
11+
DEFAULT_RANDOM_PASS,
12+
DEFAULT_TEST_IMAGE,
13+
)
14+
15+
timestamp = str(time.time_ns())
16+
linode_label = DEFAULT_LABEL + timestamp
17+
18+
19+
@pytest.fixture
20+
def linode_with_vpc_interface():
21+
vpc_json = create_vpc_w_subnet()
22+
23+
vpc_region = vpc_json["region"]
24+
vpc_id = str(vpc_json["id"])
25+
subnet_id = str(vpc_json["subnets"][0]["id"])
26+
27+
linode_json = json.loads(
28+
exec_test_command(
29+
BASE_CMD
30+
+ [
31+
"create",
32+
"--type",
33+
"g6-nanode-1",
34+
"--region",
35+
vpc_region,
36+
"--image",
37+
DEFAULT_TEST_IMAGE,
38+
"--root_pass",
39+
DEFAULT_RANDOM_PASS,
40+
"--interfaces.purpose",
41+
"vpc",
42+
"--interfaces.primary",
43+
"true",
44+
"--interfaces.subnet_id",
45+
subnet_id,
46+
"--interfaces.ipv4.nat_1_1",
47+
"any",
48+
"--interfaces.ipv4.vpc",
49+
"10.0.0.5",
50+
"--interfaces.purpose",
51+
"public",
52+
"--json",
53+
"--suppress-warnings",
54+
]
55+
)
56+
.stdout.decode()
57+
.rstrip()
58+
)[0]
59+
60+
yield linode_json, vpc_json
61+
62+
delete_target_id(target="linodes", id=str(linode_json["id"]))
63+
delete_target_id(target="vpcs", id=vpc_id)
64+
65+
66+
def test_with_vpc_interface(linode_with_vpc_interface):
67+
linode_json, vpc_json = linode_with_vpc_interface
68+
69+
config_json = json.loads(
70+
exec_test_command(
71+
BASE_CMD
72+
+ [
73+
"configs-list",
74+
str(linode_json["id"]),
75+
"--json",
76+
"--suppress-warnings",
77+
]
78+
)
79+
.stdout.decode()
80+
.rstrip()
81+
)[0]
82+
83+
vpc_interface = config_json["interfaces"][0]
84+
public_interface = config_json["interfaces"][1]
85+
86+
assert vpc_interface["primary"]
87+
assert vpc_interface["purpose"] == "vpc"
88+
assert vpc_interface["subnet_id"] == vpc_json["subnets"][0]["id"]
89+
assert vpc_interface["vpc_id"] == vpc_json["id"]
90+
assert vpc_interface["ipv4"]["vpc"] == "10.0.0.5"
91+
assert vpc_interface["ipv4"]["nat_1_1"] == linode_json["ipv4"][0]
92+
93+
assert not public_interface["primary"]
94+
assert public_interface["purpose"] == "public"

tests/integration/vpc/conftest.py

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,17 @@
22

33
import pytest
44

5-
from tests.integration.conftest import get_regions_with_capabilities
5+
from tests.integration.conftest import (
6+
create_vpc_w_subnet,
7+
get_regions_with_capabilities,
8+
)
69
from tests.integration.helpers import delete_target_id, exec_test_command
710

811

912
@pytest.fixture
1013
def test_vpc_w_subnet():
11-
region = get_regions_with_capabilities(["VPCs"])[0]
12-
13-
vpc_label = str(time.time_ns()) + "label"
14-
15-
subnet_label = str(time.time_ns()) + "label"
16-
17-
vpc_id = (
18-
exec_test_command(
19-
[
20-
"linode-cli",
21-
"vpcs",
22-
"create",
23-
"--label",
24-
vpc_label,
25-
"--region",
26-
region,
27-
"--subnets.ipv4",
28-
"10.0.0.0/24",
29-
"--subnets.label",
30-
subnet_label,
31-
"--no-headers",
32-
"--text",
33-
"--format=id",
34-
]
35-
)
36-
.stdout.decode()
37-
.rstrip()
38-
)
14+
vpc_json = create_vpc_w_subnet()
15+
vpc_id = str(vpc_json["id"])
3916

4017
yield vpc_id
4118

tests/unit/test_operation.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,22 +162,29 @@ def test_parse_args_nullable_float(self, create_operation):
162162
def test_parse_args_object_list(self, create_operation):
163163
result = create_operation.parse_args(
164164
[
165+
# First object
165166
"--object_list.field_string",
166167
"test1",
167168
"--object_list.field_int",
168169
"123",
170+
"--object_list.field_dict.nested_string",
171+
"test2",
172+
"--object_list.field_dict.nested_int",
173+
"789",
174+
# Second object
169175
"--object_list.field_int",
170176
"456",
177+
"--object_list.field_dict.nested_string",
178+
"test3",
171179
]
172180
)
173181
assert result.object_list == [
174182
{
175183
"field_string": "test1",
176184
"field_int": 123,
185+
"field_dict": {"nested_string": "test2", "nested_int": 789},
177186
},
178-
{
179-
"field_int": 456,
180-
},
187+
{"field_int": 456, "field_dict": {"nested_string": "test3"}},
181188
]
182189

183190
def test_array_arg_action_basic(self):

0 commit comments

Comments
 (0)