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]}