Skip to content

Commit 1af44ce

Browse files
fix: use the [] after key names for array variables in params
1. If a value is of type ArrayAttribute then append '[]' to the name of the value for query parameters (`params`). This is step 3 in a series of steps of our goal to add full support for the GitLab API data types[1]: * array * hash * array of hashes Step one was: commit 5127b15 Step two was: commit a57334f Fixes: #1698 [1] https://docs.gitlab.com/ee/api/#encoding-api-parameters-of-array-and-hash-types
1 parent 194ee01 commit 1af44ce

File tree

7 files changed

+134
-31
lines changed

7 files changed

+134
-31
lines changed

gitlab/mixins.py

+12-3
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,12 @@ def list(self, **kwargs: Any) -> Union[base.RESTObjectList, List[base.RESTObject
238238
GitlabListError: If the server cannot perform the request
239239
"""
240240

241-
data, _ = utils._transform_types(kwargs, self._types, transform_files=False)
241+
data, _ = utils._transform_types(
242+
data=kwargs,
243+
custom_types=self._types,
244+
transform_data=True,
245+
transform_files=False,
246+
)
242247

243248
if self.gitlab.per_page:
244249
data.setdefault("per_page", self.gitlab.per_page)
@@ -303,7 +308,9 @@ def create(
303308
data = {}
304309

305310
self._create_attrs.validate_attrs(data=data)
306-
data, files = utils._transform_types(data, self._types)
311+
data, files = utils._transform_types(
312+
data=data, custom_types=self._types, transform_data=False
313+
)
307314

308315
# Handle specific URL for creation
309316
path = kwargs.pop("path", self.path)
@@ -370,7 +377,9 @@ def update(
370377
if self._obj_cls is not None and self._obj_cls._id_attr is not None:
371378
excludes = [self._obj_cls._id_attr]
372379
self._update_attrs.validate_attrs(data=new_data, excludes=excludes)
373-
new_data, files = utils._transform_types(new_data, self._types)
380+
new_data, files = utils._transform_types(
381+
data=new_data, custom_types=self._types, transform_data=False
382+
)
374383

375384
http_method = self._get_update_method()
376385
result = http_method(path, post_data=new_data, files=files, **kwargs)

gitlab/types.py

+15-7
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ def get(self) -> Any:
6363
def set_from_cli(self, cli_value: Any) -> None:
6464
self._value = cli_value
6565

66-
def get_for_api(self) -> Any:
67-
return self._value
66+
def get_for_api(self, *, key: str) -> Tuple[str, Any]:
67+
return (key, self._value)
6868

6969

7070
class _ListArrayAttribute(GitlabAttribute):
@@ -76,20 +76,28 @@ def set_from_cli(self, cli_value: str) -> None:
7676
else:
7777
self._value = [item.strip() for item in cli_value.split(",")]
7878

79-
def get_for_api(self) -> str:
79+
def get_for_api(self, *, key: str) -> Tuple[str, str]:
8080
# Do not comma-split single value passed as string
8181
if isinstance(self._value, str):
82-
return self._value
82+
return (key, self._value)
8383

8484
if TYPE_CHECKING:
8585
assert isinstance(self._value, list)
86-
return ",".join([str(x) for x in self._value])
86+
return (key, ",".join([str(x) for x in self._value]))
8787

8888

8989
class ArrayAttribute(_ListArrayAttribute):
9090
"""To support `array` types as documented in
9191
https://docs.gitlab.com/ee/api/#array"""
9292

93+
def get_for_api(self, *, key: str) -> Tuple[str, Any]:
94+
if isinstance(self._value, str):
95+
return (f"{key}[]", self._value)
96+
97+
if TYPE_CHECKING:
98+
assert isinstance(self._value, list)
99+
return (f"{key}[]", self._value)
100+
93101

94102
class CommaSeparatedListAttribute(_ListArrayAttribute):
95103
"""For values which are sent to the server as a Comma Separated Values
@@ -98,8 +106,8 @@ class CommaSeparatedListAttribute(_ListArrayAttribute):
98106

99107

100108
class LowercaseStringAttribute(GitlabAttribute):
101-
def get_for_api(self) -> str:
102-
return str(self._value).lower()
109+
def get_for_api(self, *, key: str) -> Tuple[str, str]:
110+
return (key, str(self._value).lower())
103111

104112

105113
class FileAttribute(GitlabAttribute):

gitlab/utils.py

+30-9
Original file line numberDiff line numberDiff line change
@@ -55,34 +55,53 @@ def response_content(
5555

5656

5757
def _transform_types(
58-
data: Dict[str, Any], custom_types: dict, *, transform_files: Optional[bool] = True
58+
data: Dict[str, Any],
59+
custom_types: dict,
60+
*,
61+
transform_data: bool,
62+
transform_files: Optional[bool] = True,
5963
) -> Tuple[dict, dict]:
6064
"""Copy the data dict with attributes that have custom types and transform them
6165
before being sent to the server.
6266
63-
If ``transform_files`` is ``True`` (default), also populates the ``files`` dict for
67+
``transform_files``: If ``True`` (default), also populates the ``files`` dict for
6468
FileAttribute types with tuples to prepare fields for requests' MultipartEncoder:
6569
https://toolbelt.readthedocs.io/en/latest/user.html#multipart-form-data-encoder
6670
71+
``transform_data``: If ``True`` transforms the ``data`` dict with fields
72+
suitable for encoding as query parameters for GitLab's API:
73+
https://docs.gitlab.com/ee/api/#encoding-api-parameters-of-array-and-hash-types
74+
6775
Returns:
6876
A tuple of the transformed data dict and files dict"""
6977

7078
# Duplicate data to avoid messing with what the user sent us
7179
data = data.copy()
80+
if not transform_files and not transform_data:
81+
return data, {}
82+
7283
files = {}
7384

74-
for attr_name, type_cls in custom_types.items():
85+
for attr_name, attr_class in custom_types.items():
7586
if attr_name not in data:
7687
continue
7788

78-
type_obj = type_cls(data[attr_name])
89+
gitlab_attribute = attr_class(data[attr_name])
7990

80-
# if the type if FileAttribute we need to pass the data as file
81-
if transform_files and isinstance(type_obj, types.FileAttribute):
82-
key = type_obj.get_file_name(attr_name)
91+
# if the type is FileAttribute we need to pass the data as file
92+
if isinstance(gitlab_attribute, types.FileAttribute) and transform_files:
93+
key = gitlab_attribute.get_file_name(attr_name)
8394
files[attr_name] = (key, data.pop(attr_name))
84-
else:
85-
data[attr_name] = type_obj.get_for_api()
95+
continue
96+
97+
if not transform_data:
98+
continue
99+
100+
if isinstance(gitlab_attribute, types.GitlabAttribute):
101+
key, value = gitlab_attribute.get_for_api(key=attr_name)
102+
if key != attr_name:
103+
del data[attr_name]
104+
data[key] = value
86105

87106
return data, files
88107

@@ -94,6 +113,8 @@ def copy_dict(
94113
) -> None:
95114
for k, v in src.items():
96115
if isinstance(v, dict):
116+
# NOTE(jlvillal): This provides some support for the `hash` type
117+
# https://docs.gitlab.com/ee/api/#hash
97118
# Transform dict values to new attributes. For example:
98119
# custom_attributes: {'foo', 'bar'} =>
99120
# "custom_attributes['foo']": "bar"

tests/functional/api/test_groups.py

+5
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ def test_groups(gl):
9999
assert len(group1.members.list()) == 3
100100
assert len(group2.members.list()) == 2
101101

102+
# Test `user_ids` array
103+
result = group1.members.list(user_ids=[user.id, 99999])
104+
assert len(result) == 1
105+
assert result[0].id == user.id
106+
102107
group1.members.delete(user.id)
103108
assert user not in group1.members.list()
104109
assert group1.members_all.list()

tests/unit/mixins/test_mixin_methods.py

+38
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,25 @@ class M(ListMixin, FakeManager):
205205
assert responses.assert_call_count(url, 2) is True
206206

207207

208+
@responses.activate
209+
def test_list_mixin_with_attributes(gl):
210+
class M(ListMixin, FakeManager):
211+
_types = {"my_array": gl_types.ArrayAttribute}
212+
213+
url = "http://localhost/api/v4/tests"
214+
responses.add(
215+
method=responses.GET,
216+
headers={},
217+
url=url,
218+
json=[],
219+
status=200,
220+
match=[responses.matchers.query_param_matcher({"my_array[]": ["1", "2", "3"]})],
221+
)
222+
223+
mgr = M(gl)
224+
mgr.list(iterator=True, my_array=[1, 2, 3])
225+
226+
208227
@responses.activate
209228
def test_list_other_url(gl):
210229
class M(ListMixin, FakeManager):
@@ -295,6 +314,25 @@ class M(CreateMixin, FakeManager):
295314
assert responses.assert_call_count(url, 1) is True
296315

297316

317+
@responses.activate
318+
def test_create_mixin_with_attributes(gl):
319+
class M(CreateMixin, FakeManager):
320+
_types = {"my_array": gl_types.ArrayAttribute}
321+
322+
url = "http://localhost/api/v4/tests"
323+
responses.add(
324+
method=responses.POST,
325+
headers={},
326+
url=url,
327+
json={},
328+
status=200,
329+
match=[responses.matchers.json_params_matcher({"my_array": [1, 2, 3]})],
330+
)
331+
332+
mgr = M(gl)
333+
mgr.create({"my_array": [1, 2, 3]})
334+
335+
298336
def test_update_mixin_missing_attrs(gl):
299337
class M(UpdateMixin, FakeManager):
300338
_update_attrs = gl_types.RequiredOptional(

tests/unit/test_types.py

+9-9
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def test_gitlab_attribute_get():
7373

7474
o.set_from_cli("whatever2")
7575
assert o.get() == "whatever2"
76-
assert o.get_for_api() == "whatever2"
76+
assert o.get_for_api(key="spam") == ("spam", "whatever2")
7777

7878
o = types.GitlabAttribute()
7979
assert o._value is None
@@ -100,42 +100,42 @@ def test_array_attribute_empty_input():
100100
def test_array_attribute_get_for_api_from_cli():
101101
o = types.ArrayAttribute()
102102
o.set_from_cli("foo,bar,baz")
103-
assert o.get_for_api() == "foo,bar,baz"
103+
assert o.get_for_api(key="spam") == ("spam[]", ["foo", "bar", "baz"])
104104

105105

106106
def test_array_attribute_get_for_api_from_list():
107107
o = types.ArrayAttribute(["foo", "bar", "baz"])
108-
assert o.get_for_api() == "foo,bar,baz"
108+
assert o.get_for_api(key="spam") == ("spam[]", ["foo", "bar", "baz"])
109109

110110

111111
def test_array_attribute_get_for_api_from_int_list():
112112
o = types.ArrayAttribute([1, 9, 7])
113-
assert o.get_for_api() == "1,9,7"
113+
assert o.get_for_api(key="spam") == ("spam[]", [1, 9, 7])
114114

115115

116116
def test_array_attribute_does_not_split_string():
117117
o = types.ArrayAttribute("foo")
118-
assert o.get_for_api() == "foo"
118+
assert o.get_for_api(key="spam") == ("spam[]", "foo")
119119

120120

121121
# CommaSeparatedListAttribute tests
122122
def test_csv_string_attribute_get_for_api_from_cli():
123123
o = types.CommaSeparatedListAttribute()
124124
o.set_from_cli("foo,bar,baz")
125-
assert o.get_for_api() == "foo,bar,baz"
125+
assert o.get_for_api(key="spam") == ("spam", "foo,bar,baz")
126126

127127

128128
def test_csv_string_attribute_get_for_api_from_list():
129129
o = types.CommaSeparatedListAttribute(["foo", "bar", "baz"])
130-
assert o.get_for_api() == "foo,bar,baz"
130+
assert o.get_for_api(key="spam") == ("spam", "foo,bar,baz")
131131

132132

133133
def test_csv_string_attribute_get_for_api_from_int_list():
134134
o = types.CommaSeparatedListAttribute([1, 9, 7])
135-
assert o.get_for_api() == "1,9,7"
135+
assert o.get_for_api(key="spam") == ("spam", "1,9,7")
136136

137137

138138
# LowercaseStringAttribute tests
139139
def test_lowercase_string_attribute_get_for_api():
140140
o = types.LowercaseStringAttribute("FOO")
141-
assert o.get_for_api() == "foo"
141+
assert o.get_for_api(key="spam") == ("spam", "foo")

tests/unit/test_utils.py

+25-3
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def test_remove_none_from_dict(dictionary, expected):
155155

156156
def test_transform_types_copies_data_with_empty_files():
157157
data = {"attr": "spam"}
158-
new_data, files = utils._transform_types(data, {})
158+
new_data, files = utils._transform_types(data, {}, transform_data=True)
159159

160160
assert new_data is not data
161161
assert new_data == data
@@ -165,7 +165,7 @@ def test_transform_types_copies_data_with_empty_files():
165165
def test_transform_types_with_transform_files_populates_files():
166166
custom_types = {"attr": types.FileAttribute}
167167
data = {"attr": "spam"}
168-
new_data, files = utils._transform_types(data, custom_types)
168+
new_data, files = utils._transform_types(data, custom_types, transform_data=True)
169169

170170
assert new_data == {}
171171
assert files["attr"] == ("attr", "spam")
@@ -174,7 +174,29 @@ def test_transform_types_with_transform_files_populates_files():
174174
def test_transform_types_without_transform_files_populates_data_with_empty_files():
175175
custom_types = {"attr": types.FileAttribute}
176176
data = {"attr": "spam"}
177-
new_data, files = utils._transform_types(data, custom_types, transform_files=False)
177+
new_data, files = utils._transform_types(
178+
data, custom_types, transform_files=False, transform_data=True
179+
)
178180

179181
assert new_data == {"attr": "spam"}
180182
assert files == {}
183+
184+
185+
def test_transform_types_params_array():
186+
data = {"attr": [1, 2, 3]}
187+
custom_types = {"attr": types.ArrayAttribute}
188+
new_data, files = utils._transform_types(data, custom_types, transform_data=True)
189+
190+
assert new_data is not data
191+
assert new_data == {"attr[]": [1, 2, 3]}
192+
assert files == {}
193+
194+
195+
def test_transform_types_not_params_array():
196+
data = {"attr": [1, 2, 3]}
197+
custom_types = {"attr": types.ArrayAttribute}
198+
new_data, files = utils._transform_types(data, custom_types, transform_data=False)
199+
200+
assert new_data is not data
201+
assert new_data == data
202+
assert files == {}

0 commit comments

Comments
 (0)