From e5affc8749797293c1373c6af96334f194875038 Mon Sep 17 00:00:00 2001 From: "John L. Villalovos" Date: Wed, 20 Jul 2022 08:38:31 -0700 Subject: 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 920617b..7de76e0 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 @@ class RESTObject: @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 ea4ad9e..53e484e 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 @@ class TestRESTObject: 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]} -- cgit v1.2.1 From 08ac071abcbc28af04c0fa655576e25edbdaa4e2 Mon Sep 17 00:00:00 2001 From: "John L. Villalovos" Date: Wed, 20 Jul 2022 08:38:43 -0700 Subject: 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 000633f..24d0890 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 7de76e0..dd69d00 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 @@ class RESTObject: 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 @@ class RESTObject: 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 53e484e..529135a 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 @@ class TestRESTObject: 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]} -- cgit v1.2.1