diff --git a/docs/api-usage.rst b/docs/api-usage.rst
index 000633f76..24d089098 100644
--- a/docs/api-usage.rst
+++ b/docs/api-usage.rst
@@ -214,6 +214,38 @@ the value on the object is accepted:
issue.my_super_awesome_feature_flag = "random_value"
issue.save()
+You can get a dictionary representation copy of the Gitlab Object. Modifications made to
+the dictionary will have no impact on the GitLab Object.
+
+ * `asdict()` method. Returns a dictionary representation of the Gitlab object.
+ * `attributes` property. Returns a dictionary representation of the Gitlab
+ object. Also returns any relevant parent object attributes.
+
+.. note::
+
+ `attributes` returns the parent object attributes that are defined in
+ `object._from_parent_attrs`. What this can mean is that for example a `ProjectIssue`
+ object will have a `project_id` key in the dictionary returned from `attributes` but
+ `asdict()` will not.
+
+
+.. code-block:: python
+
+ project = gl.projects.get(1)
+ project_dict = project.asdict()
+
+ # Or a dictionary representation also containing some of the parent attributes
+ issue = project.issues.get(1)
+ attribute_dict = issue.attributes
+
+You can get a JSON string represenation of the Gitlab Object. For example:
+
+.. code-block:: python
+
+ project = gl.projects.get(1)
+ print(project.to_json())
+ # Use arguments supported by `json.dump()`
+ print(project.to_json(sort_keys=True, indent=4))
Base types
==========
diff --git a/gitlab/base.py b/gitlab/base.py
index 920617b33..dd69d0095 100644
--- a/gitlab/base.py
+++ b/gitlab/base.py
@@ -15,7 +15,9 @@
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see .
+import copy
import importlib
+import json
import pprint
import textwrap
from types import ModuleType
@@ -142,15 +144,26 @@ def __getattr__(self, name: str) -> Any:
def __setattr__(self, name: str, value: Any) -> None:
self.__dict__["_updated_attrs"][name] = value
+ def asdict(self, *, with_parent_attrs: bool = False) -> Dict[str, Any]:
+ data = {}
+ if with_parent_attrs:
+ data.update(copy.deepcopy(self._parent_attrs))
+ data.update(copy.deepcopy(self._attrs))
+ data.update(copy.deepcopy(self._updated_attrs))
+ return data
+
+ @property
+ def attributes(self) -> Dict[str, Any]:
+ return self.asdict(with_parent_attrs=True)
+
+ def to_json(self, *, with_parent_attrs: bool = False, **kwargs: Any) -> str:
+ return json.dumps(self.asdict(with_parent_attrs=with_parent_attrs), **kwargs)
+
def __str__(self) -> str:
- data = self._attrs.copy()
- data.update(self._updated_attrs)
- return f"{type(self)} => {data}"
+ return f"{type(self)} => {self.asdict()}"
def pformat(self) -> str:
- data = self._attrs.copy()
- data.update(self._updated_attrs)
- return f"{type(self)} => \n{pprint.pformat(data)}"
+ return f"{type(self)} => \n{pprint.pformat(self.asdict())}"
def pprint(self) -> None:
print(self.pformat())
@@ -241,13 +254,6 @@ def encoded_id(self) -> Optional[Union[int, str]]:
obj_id = gitlab.utils.EncodedId(obj_id)
return obj_id
- @property
- def attributes(self) -> Dict[str, Any]:
- d = self.__dict__["_updated_attrs"].copy()
- d.update(self.__dict__["_attrs"])
- d.update(self.__dict__["_parent_attrs"])
- return d
-
class RESTObjectList:
"""Generator object representing a list of RESTObject's.
diff --git a/tests/unit/test_base.py b/tests/unit/test_base.py
index ea4ad9ee6..529135afe 100644
--- a/tests/unit/test_base.py
+++ b/tests/unit/test_base.py
@@ -36,6 +36,16 @@ class FakeManager(base.RESTManager):
_path = "/tests"
+class FakeParent:
+ id = 42
+
+
+class FakeManagerWithParent(base.RESTManager):
+ _path = "/tests/{test_id}/cases"
+ _obj_cls = FakeObject
+ _from_parent_attrs = {"test_id": "id"}
+
+
@pytest.fixture
def fake_gitlab():
return FakeGitlab()
@@ -46,6 +56,21 @@ def fake_manager(fake_gitlab):
return FakeManager(fake_gitlab)
+@pytest.fixture
+def fake_manager_with_parent(fake_gitlab):
+ return FakeManagerWithParent(fake_gitlab, parent=FakeParent)
+
+
+@pytest.fixture
+def fake_object(fake_manager):
+ return FakeObject(fake_manager, {"attr1": "foo", "alist": [1, 2, 3]})
+
+
+@pytest.fixture
+def fake_object_with_parent(fake_manager_with_parent):
+ return FakeObject(fake_manager_with_parent, {"attr1": "foo", "alist": [1, 2, 3]})
+
+
class TestRESTManager:
def test_computed_path_simple(self):
class MGR(base.RESTManager):
@@ -306,3 +331,75 @@ def test_repr(self, fake_manager):
FakeObject._id_attr = None
assert repr(obj) == ""
+
+ def test_attributes_get(self, fake_object):
+ assert fake_object.attr1 == "foo"
+ result = fake_object.attributes
+ assert result == {"attr1": "foo", "alist": [1, 2, 3]}
+
+ def test_attributes_shows_updates(self, fake_object):
+ # Updated attribute value is reflected in `attributes`
+ fake_object.attr1 = "hello"
+ assert fake_object.attributes == {"attr1": "hello", "alist": [1, 2, 3]}
+ assert fake_object.attr1 == "hello"
+ # New attribute is in `attributes`
+ fake_object.new_attrib = "spam"
+ assert fake_object.attributes == {
+ "attr1": "hello",
+ "new_attrib": "spam",
+ "alist": [1, 2, 3],
+ }
+
+ def test_attributes_is_copy(self, fake_object):
+ # Modifying the dictionary does not cause modifications to the object
+ result = fake_object.attributes
+ result["alist"].append(10)
+ assert result == {"attr1": "foo", "alist": [1, 2, 3, 10]}
+ assert fake_object.attributes == {"attr1": "foo", "alist": [1, 2, 3]}
+
+ def test_attributes_has_parent_attrs(self, fake_object_with_parent):
+ assert fake_object_with_parent.attr1 == "foo"
+ result = fake_object_with_parent.attributes
+ assert result == {"attr1": "foo", "alist": [1, 2, 3], "test_id": "42"}
+
+ def test_asdict(self, fake_object):
+ assert fake_object.attr1 == "foo"
+ result = fake_object.asdict()
+ assert result == {"attr1": "foo", "alist": [1, 2, 3]}
+
+ def test_asdict_no_parent_attrs(self, fake_object_with_parent):
+ assert fake_object_with_parent.attr1 == "foo"
+ result = fake_object_with_parent.asdict()
+ assert result == {"attr1": "foo", "alist": [1, 2, 3]}
+ assert "test_id" not in fake_object_with_parent.asdict()
+ assert "test_id" not in fake_object_with_parent.asdict(with_parent_attrs=False)
+ assert "test_id" in fake_object_with_parent.asdict(with_parent_attrs=True)
+
+ def test_asdict_modify_dict_does_not_change_object(self, fake_object):
+ result = fake_object.asdict()
+ # Demonstrate modifying the dictionary does not modify the object
+ result["attr1"] = "testing"
+ result["alist"].append(4)
+ assert result == {"attr1": "testing", "alist": [1, 2, 3, 4]}
+ assert fake_object.attr1 == "foo"
+ assert fake_object.alist == [1, 2, 3]
+
+ def test_asdict_modify_dict_does_not_change_object2(self, fake_object):
+ # Modify attribute and then ensure modifying a list in the returned dict won't
+ # modify the list in the object.
+ fake_object.attr1 = [9, 7, 8]
+ assert fake_object.asdict() == {
+ "attr1": [9, 7, 8],
+ "alist": [1, 2, 3],
+ }
+ result = fake_object.asdict()
+ result["attr1"].append(1)
+ assert fake_object.asdict() == {
+ "attr1": [9, 7, 8],
+ "alist": [1, 2, 3],
+ }
+
+ def test_asdict_modify_object(self, fake_object):
+ # asdict() returns the updated value
+ fake_object.attr1 = "spam"
+ assert fake_object.asdict() == {"attr1": "spam", "alist": [1, 2, 3]}