Skip to content

Commit fcbced8

Browse files
authored
Merge pull request #1872 from python-gitlab/jlvillal/as_dict
feat: add `asdict()` and `to_json()` methods to Gitlab Objects
2 parents 2c90fd0 + 08ac071 commit fcbced8

File tree

3 files changed

+148
-13
lines changed

3 files changed

+148
-13
lines changed

docs/api-usage.rst

+32
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,38 @@ the value on the object is accepted:
214214
issue.my_super_awesome_feature_flag = "random_value"
215215
issue.save()
216216
217+
You can get a dictionary representation copy of the Gitlab Object. Modifications made to
218+
the dictionary will have no impact on the GitLab Object.
219+
220+
* `asdict()` method. Returns a dictionary representation of the Gitlab object.
221+
* `attributes` property. Returns a dictionary representation of the Gitlab
222+
object. Also returns any relevant parent object attributes.
223+
224+
.. note::
225+
226+
`attributes` returns the parent object attributes that are defined in
227+
`object._from_parent_attrs`. What this can mean is that for example a `ProjectIssue`
228+
object will have a `project_id` key in the dictionary returned from `attributes` but
229+
`asdict()` will not.
230+
231+
232+
.. code-block:: python
233+
234+
project = gl.projects.get(1)
235+
project_dict = project.asdict()
236+
237+
# Or a dictionary representation also containing some of the parent attributes
238+
issue = project.issues.get(1)
239+
attribute_dict = issue.attributes
240+
241+
You can get a JSON string represenation of the Gitlab Object. For example:
242+
243+
.. code-block:: python
244+
245+
project = gl.projects.get(1)
246+
print(project.to_json())
247+
# Use arguments supported by `json.dump()`
248+
print(project.to_json(sort_keys=True, indent=4))
217249
218250
Base types
219251
==========

gitlab/base.py

+19-13
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
# You should have received a copy of the GNU Lesser General Public License
1616
# along with this program. If not, see <http://www.gnu.org/licenses/>.
1717

18+
import copy
1819
import importlib
20+
import json
1921
import pprint
2022
import textwrap
2123
from types import ModuleType
@@ -151,15 +153,26 @@ def __getattr__(self, name: str) -> Any:
151153
def __setattr__(self, name: str, value: Any) -> None:
152154
self.__dict__["_updated_attrs"][name] = value
153155

156+
def asdict(self, *, with_parent_attrs: bool = False) -> Dict[str, Any]:
157+
data = {}
158+
if with_parent_attrs:
159+
data.update(copy.deepcopy(self._parent_attrs))
160+
data.update(copy.deepcopy(self._attrs))
161+
data.update(copy.deepcopy(self._updated_attrs))
162+
return data
163+
164+
@property
165+
def attributes(self) -> Dict[str, Any]:
166+
return self.asdict(with_parent_attrs=True)
167+
168+
def to_json(self, *, with_parent_attrs: bool = False, **kwargs: Any) -> str:
169+
return json.dumps(self.asdict(with_parent_attrs=with_parent_attrs), **kwargs)
170+
154171
def __str__(self) -> str:
155-
data = self._attrs.copy()
156-
data.update(self._updated_attrs)
157-
return f"{type(self)} => {data}"
172+
return f"{type(self)} => {self.asdict()}"
158173

159174
def pformat(self) -> str:
160-
data = self._attrs.copy()
161-
data.update(self._updated_attrs)
162-
return f"{type(self)} => \n{pprint.pformat(data)}"
175+
return f"{type(self)} => \n{pprint.pformat(self.asdict())}"
163176

164177
def pprint(self) -> None:
165178
print(self.pformat())
@@ -250,13 +263,6 @@ def encoded_id(self) -> Optional[Union[int, str]]:
250263
obj_id = gitlab.utils.EncodedId(obj_id)
251264
return obj_id
252265

253-
@property
254-
def attributes(self) -> Dict[str, Any]:
255-
d = self.__dict__["_updated_attrs"].copy()
256-
d.update(self.__dict__["_attrs"])
257-
d.update(self.__dict__["_parent_attrs"])
258-
return d
259-
260266

261267
class RESTObjectList:
262268
"""Generator object representing a list of RESTObject's.

tests/unit/test_base.py

+97
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ class FakeManager(base.RESTManager):
3636
_path = "/tests"
3737

3838

39+
class FakeParent:
40+
id = 42
41+
42+
43+
class FakeManagerWithParent(base.RESTManager):
44+
_path = "/tests/{test_id}/cases"
45+
_obj_cls = FakeObject
46+
_from_parent_attrs = {"test_id": "id"}
47+
48+
3949
@pytest.fixture
4050
def fake_gitlab():
4151
return FakeGitlab()
@@ -46,6 +56,21 @@ def fake_manager(fake_gitlab):
4656
return FakeManager(fake_gitlab)
4757

4858

59+
@pytest.fixture
60+
def fake_manager_with_parent(fake_gitlab):
61+
return FakeManagerWithParent(fake_gitlab, parent=FakeParent)
62+
63+
64+
@pytest.fixture
65+
def fake_object(fake_manager):
66+
return FakeObject(fake_manager, {"attr1": "foo", "alist": [1, 2, 3]})
67+
68+
69+
@pytest.fixture
70+
def fake_object_with_parent(fake_manager_with_parent):
71+
return FakeObject(fake_manager_with_parent, {"attr1": "foo", "alist": [1, 2, 3]})
72+
73+
4974
class TestRESTManager:
5075
def test_computed_path_simple(self):
5176
class MGR(base.RESTManager):
@@ -306,3 +331,75 @@ def test_repr(self, fake_manager):
306331

307332
FakeObject._id_attr = None
308333
assert repr(obj) == "<FakeObject>"
334+
335+
def test_attributes_get(self, fake_object):
336+
assert fake_object.attr1 == "foo"
337+
result = fake_object.attributes
338+
assert result == {"attr1": "foo", "alist": [1, 2, 3]}
339+
340+
def test_attributes_shows_updates(self, fake_object):
341+
# Updated attribute value is reflected in `attributes`
342+
fake_object.attr1 = "hello"
343+
assert fake_object.attributes == {"attr1": "hello", "alist": [1, 2, 3]}
344+
assert fake_object.attr1 == "hello"
345+
# New attribute is in `attributes`
346+
fake_object.new_attrib = "spam"
347+
assert fake_object.attributes == {
348+
"attr1": "hello",
349+
"new_attrib": "spam",
350+
"alist": [1, 2, 3],
351+
}
352+
353+
def test_attributes_is_copy(self, fake_object):
354+
# Modifying the dictionary does not cause modifications to the object
355+
result = fake_object.attributes
356+
result["alist"].append(10)
357+
assert result == {"attr1": "foo", "alist": [1, 2, 3, 10]}
358+
assert fake_object.attributes == {"attr1": "foo", "alist": [1, 2, 3]}
359+
360+
def test_attributes_has_parent_attrs(self, fake_object_with_parent):
361+
assert fake_object_with_parent.attr1 == "foo"
362+
result = fake_object_with_parent.attributes
363+
assert result == {"attr1": "foo", "alist": [1, 2, 3], "test_id": "42"}
364+
365+
def test_asdict(self, fake_object):
366+
assert fake_object.attr1 == "foo"
367+
result = fake_object.asdict()
368+
assert result == {"attr1": "foo", "alist": [1, 2, 3]}
369+
370+
def test_asdict_no_parent_attrs(self, fake_object_with_parent):
371+
assert fake_object_with_parent.attr1 == "foo"
372+
result = fake_object_with_parent.asdict()
373+
assert result == {"attr1": "foo", "alist": [1, 2, 3]}
374+
assert "test_id" not in fake_object_with_parent.asdict()
375+
assert "test_id" not in fake_object_with_parent.asdict(with_parent_attrs=False)
376+
assert "test_id" in fake_object_with_parent.asdict(with_parent_attrs=True)
377+
378+
def test_asdict_modify_dict_does_not_change_object(self, fake_object):
379+
result = fake_object.asdict()
380+
# Demonstrate modifying the dictionary does not modify the object
381+
result["attr1"] = "testing"
382+
result["alist"].append(4)
383+
assert result == {"attr1": "testing", "alist": [1, 2, 3, 4]}
384+
assert fake_object.attr1 == "foo"
385+
assert fake_object.alist == [1, 2, 3]
386+
387+
def test_asdict_modify_dict_does_not_change_object2(self, fake_object):
388+
# Modify attribute and then ensure modifying a list in the returned dict won't
389+
# modify the list in the object.
390+
fake_object.attr1 = [9, 7, 8]
391+
assert fake_object.asdict() == {
392+
"attr1": [9, 7, 8],
393+
"alist": [1, 2, 3],
394+
}
395+
result = fake_object.asdict()
396+
result["attr1"].append(1)
397+
assert fake_object.asdict() == {
398+
"attr1": [9, 7, 8],
399+
"alist": [1, 2, 3],
400+
}
401+
402+
def test_asdict_modify_object(self, fake_object):
403+
# asdict() returns the updated value
404+
fake_object.attr1 = "spam"
405+
assert fake_object.asdict() == {"attr1": "spam", "alist": [1, 2, 3]}

0 commit comments

Comments
 (0)