From e5affc8749797293c1373c6af96334f194875038 Mon Sep 17 00:00:00 2001 From: "John L. Villalovos" Date: Wed, 20 Jul 2022 08:38:31 -0700 Subject: [PATCH 1/2] fix: results returned by `attributes` property to show updates Previously the `attributes` method would show the original values in a Gitlab Object even if they had been updated. Correct this so that the updated value will be returned. Also use copy.deepcopy() to ensure that modifying the dictionary returned can not also modify the object. --- gitlab/base.py | 10 ++++++---- tests/unit/test_base.py | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/gitlab/base.py b/gitlab/base.py index 920617b33..7de76e068 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -15,6 +15,7 @@ # 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 pprint import textwrap @@ -243,10 +244,11 @@ def encoded_id(self) -> Optional[Union[int, str]]: @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 + data = {} + data.update(copy.deepcopy(self._parent_attrs)) + data.update(copy.deepcopy(self._attrs)) + data.update(copy.deepcopy(self._updated_attrs)) + return data class RESTObjectList: diff --git a/tests/unit/test_base.py b/tests/unit/test_base.py index ea4ad9ee6..53e484e67 100644 --- a/tests/unit/test_base.py +++ b/tests/unit/test_base.py @@ -46,6 +46,11 @@ def fake_manager(fake_gitlab): return FakeManager(fake_gitlab) +@pytest.fixture +def fake_object(fake_manager): + return FakeObject(fake_manager, {"attr1": [1, 2, 3]}) + + class TestRESTManager: def test_computed_path_simple(self): class MGR(base.RESTManager): @@ -306,3 +311,24 @@ def test_repr(self, fake_manager): FakeObject._id_attr = None assert repr(obj) == "" + + def test_attributes_get(self, fake_object): + assert fake_object.attr1 == [1, 2, 3] + result = fake_object.attributes + assert result == {"attr1": [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"} + assert fake_object.attr1 == "hello" + # New attribute is in `attributes` + fake_object.new_attrib = "spam" + assert fake_object.attributes == {"attr1": "hello", "new_attrib": "spam"} + + def test_attributes_is_copy(self, fake_object): + # Modifying the dictionary does not cause modifications to the object + result = fake_object.attributes + result["attr1"].append(10) + assert result == {"attr1": [1, 2, 3, 10]} + assert fake_object.attributes == {"attr1": [1, 2, 3]} From 08ac071abcbc28af04c0fa655576e25edbdaa4e2 Mon Sep 17 00:00:00 2001 From: "John L. Villalovos" Date: Wed, 20 Jul 2022 08:38:43 -0700 Subject: [PATCH 2/2] feat: add `asdict()` and `to_json()` methods to Gitlab Objects Add an `asdict()` method that returns a dictionary representation copy of the Gitlab Object. This is a copy and changes made to it will have no impact on the Gitlab Object. The `asdict()` method name was chosen as both the `dataclasses` and `attrs` libraries have an `asdict()` function which has the similar purpose of creating a dictionary represenation of an object. Also add a `to_json()` method that returns a JSON string representation of the object. Closes: #1116 --- docs/api-usage.rst | 32 +++++++++++++++ gitlab/base.py | 32 ++++++++------- tests/unit/test_base.py | 87 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 129 insertions(+), 22 deletions(-) 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 7de76e068..dd69d0095 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -17,6 +17,7 @@ import copy import importlib +import json import pprint import textwrap from types import ModuleType @@ -143,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()) @@ -242,14 +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]: - data = {} - data.update(copy.deepcopy(self._parent_attrs)) - data.update(copy.deepcopy(self._attrs)) - data.update(copy.deepcopy(self._updated_attrs)) - return data - 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 53e484e67..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,9 +56,19 @@ 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": [1, 2, 3]}) + 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: @@ -313,22 +333,73 @@ def test_repr(self, fake_manager): assert repr(obj) == "" def test_attributes_get(self, fake_object): - assert fake_object.attr1 == [1, 2, 3] + assert fake_object.attr1 == "foo" result = fake_object.attributes - assert result == {"attr1": [1, 2, 3]} + 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"} + 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"} + 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["attr1"].append(10) - assert result == {"attr1": [1, 2, 3, 10]} - assert fake_object.attributes == {"attr1": [1, 2, 3]} + 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]}