Skip to content

feat: add asdict() and to_json() methods to Gitlab Objects #1872

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/api-usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
==========
Expand Down
32 changes: 19 additions & 13 deletions gitlab/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import copy
import importlib
import json
import pprint
import textwrap
from types import ModuleType
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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.
Expand Down
97 changes: 97 additions & 0 deletions tests/unit/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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):
Expand Down Expand Up @@ -306,3 +331,75 @@ def test_repr(self, fake_manager):

FakeObject._id_attr = None
assert repr(obj) == "<FakeObject>"

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