Skip to content
Open
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
46 changes: 41 additions & 5 deletions gitlab/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,13 +292,16 @@ def update(
self,
id: str | int | None = None,
new_data: dict[str, Any] | None = None,
*,
_custom_path: str | None = None,
**kwargs: Any,
) -> dict[str, Any]:
"""Update an object on the server.

Args:
id: ID of the object to update (can be None if not required)
new_data: the update data for the object
_custom_path: Optional custom path for special API endpoints
**kwargs: Extra options to send to the server (e.g. sudo)

Returns:
Expand All @@ -310,7 +313,9 @@ def update(
"""
new_data = new_data or {}

if id is None:
if _custom_path is not None:
path = _custom_path
elif id is None:
path = self.path
else:
path = f"{self.path}/{utils.EncodedId(id)}"
Expand Down Expand Up @@ -357,18 +362,27 @@ def set(self, key: str, value: str, **kwargs: Any) -> base.TObjCls:

class DeleteMixin(base.RESTManager[base.TObjCls]):
@exc.on_http_error(exc.GitlabDeleteError)
def delete(self, id: str | int | None = None, **kwargs: Any) -> None:
def delete(
self,
id: str | int | None = None,
*,
_custom_path: str | None = None,
**kwargs: Any,
) -> None:
"""Delete an object on the server.

Args:
id: ID of the object to delete
_custom_path: Optional custom path for special API endpoints
**kwargs: Extra options to send to the server (e.g. sudo)

Raises:
GitlabAuthenticationError: If authentication is not correct
GitlabDeleteError: If the server cannot perform the request
"""
if id is None:
if _custom_path is not None:
path = _custom_path
elif id is None:
path = self.path
else:
path = f"{self.path}/{utils.EncodedId(id)}"
Expand Down Expand Up @@ -403,6 +417,12 @@ class SaveMixin(_RestObjectBase):
_updated_attrs: dict[str, Any]
manager: base.RESTManager[Any]

def _get_custom_path(self) -> str | None:
# NOTE(jlvillal): pylint will complain for the callers with an
# 'assignment-from-none' error, if we don't do this.
custom_path: str | None = None
return custom_path

def _get_updated_data(self) -> dict[str, Any]:
updated_data = {}
for attr in self.manager._update_attrs.required:
Expand Down Expand Up @@ -437,7 +457,13 @@ def save(self, **kwargs: Any) -> dict[str, Any] | None:
obj_id = self.encoded_id
if TYPE_CHECKING:
assert isinstance(self.manager, UpdateMixin)
server_data = self.manager.update(obj_id, updated_data, **kwargs)
custom_path = self._get_custom_path()
if custom_path is None:
server_data = self.manager.update(obj_id, updated_data, **kwargs)
else:
server_data = self.manager.update(
obj_id, updated_data, _custom_path=custom_path, **kwargs
)
self._update_attrs(server_data)
return server_data

Expand All @@ -452,6 +478,12 @@ class ObjectDeleteMixin(_RestObjectBase):
_updated_attrs: dict[str, Any]
manager: base.RESTManager[Any]

def _get_custom_path(self) -> str | None:
# NOTE(jlvillal): pylint will complain for the callers with an
# 'assignment-from-none' error, if we don't do this.
custom_path: str | None = None
return custom_path

def delete(self, **kwargs: Any) -> None:
"""Delete the object from the server.

Expand All @@ -465,7 +497,11 @@ def delete(self, **kwargs: Any) -> None:
if TYPE_CHECKING:
assert isinstance(self.manager, DeleteMixin)
assert self.encoded_id is not None
self.manager.delete(self.encoded_id, **kwargs)
custom_path = self._get_custom_path()
if custom_path is None:
self.manager.delete(self.encoded_id, **kwargs)
else:
self.manager.delete(self.encoded_id, _custom_path=custom_path, **kwargs)


class UserAgentDetailMixin(_RestObjectBase):
Expand Down
23 changes: 23 additions & 0 deletions gitlab/v4/objects/epics.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from typing import Any, TYPE_CHECKING

import gitlab.utils
from gitlab import exceptions as exc
from gitlab import types
Comment thread
JohnVillalovos marked this conversation as resolved.
from gitlab.base import RESTObject
Expand Down Expand Up @@ -29,6 +30,28 @@ class GroupEpic(ObjectDeleteMixin, SaveMixin, RESTObject):
resourcelabelevents: GroupEpicResourceLabelEventManager
notes: GroupEpicNoteManager

def _epic_path(self) -> str:
"""Return the API path for this epic using its real group."""
if self._lazy:
raise AttributeError(
"Cannot compute epic path for a lazy epic: attribute 'group_id' "
"is missing. Fetch the epic without lazy=True before saving or "
"deleting it."
)

try:
group_id = self._attrs["group_id"]
except KeyError as error:
raise AttributeError(
"Cannot compute epic path: attribute 'group_id' is missing."
) from error

encoded_group_id = gitlab.utils.EncodedId(group_id)
return f"/groups/{encoded_group_id}/epics/{self.encoded_id}"

def _get_custom_path(self) -> str | None:
return self._epic_path()


class GroupEpicManager(CRUDMixin[GroupEpic]):
_path = "/groups/{group_id}/epics"
Expand Down
60 changes: 60 additions & 0 deletions tests/functional/api/test_epics.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import collections.abc
import dataclasses
import uuid

import pytest

import gitlab
import gitlab.v4.objects.epics
import gitlab.v4.objects.groups
from tests.functional import helpers

pytestmark = pytest.mark.gitlab_premium


Expand Down Expand Up @@ -32,3 +41,54 @@ def test_epic_notes(epic):
epic.notes.create({"body": "Test note"})
new_notes = epic.notes.list(get_all=True)
assert len(new_notes) == (len(notes) + 1), f"{new_notes} {notes}"


@dataclasses.dataclass(frozen=True)
class NestedEpicInSubgroup:
subgroup: gitlab.v4.objects.groups.Group
nested_epic: gitlab.v4.objects.epics.GroupEpic


@pytest.fixture
def nested_epic_in_subgroup(
gl: gitlab.Gitlab, group: gitlab.v4.objects.groups.Group
) -> collections.abc.Generator[NestedEpicInSubgroup, None, None]:
subgroup_id = uuid.uuid4().hex
subgroup = gl.groups.create(
{
"name": f"subgroup-{subgroup_id}",
"path": f"sg-{subgroup_id}",
"parent_id": group.id,
}
)

nested_epic = subgroup.epics.create(
Comment thread
JohnVillalovos marked this conversation as resolved.
{"title": f"Nested epic {subgroup_id}", "description": "Nested epic"}
)

try:
yield NestedEpicInSubgroup(subgroup=subgroup, nested_epic=nested_epic)
finally:
helpers.safe_delete(nested_epic)
helpers.safe_delete(subgroup)


def test_epic_save_from_parent_group_updates_subgroup_epic(
group: gitlab.v4.objects.groups.Group, nested_epic_in_subgroup: NestedEpicInSubgroup
) -> None:
fetched_epics = group.epics.list(search=nested_epic_in_subgroup.nested_epic.title)
assert fetched_epics, "Expected to discover nested epic via parent group list"

fetched_epic = fetched_epics[0]
assert (
fetched_epic.id == nested_epic_in_subgroup.nested_epic.id
), "Parent group listing did not include nested epic"

new_label = f"nested-{uuid.uuid4().hex}"
fetched_epic.labels = [new_label]
fetched_epic.save()

refreshed_epic = nested_epic_in_subgroup.subgroup.epics.get(
nested_epic_in_subgroup.nested_epic.iid
)
assert new_label in refreshed_epic.labels
91 changes: 91 additions & 0 deletions tests/unit/mixins/test_mixin_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
GetMixin,
GetWithoutIdMixin,
ListMixin,
ObjectDeleteMixin,
RefreshMixin,
SaveMixin,
SetMixin,
Expand Down Expand Up @@ -421,6 +422,27 @@ class M(UpdateMixin, FakeManager):
assert responses.assert_call_count(url, 1) is True


@responses.activate
def test_update_mixin_custom_path(gl):
class M(UpdateMixin, FakeManager):
pass

url = "http://localhost/api/v4/others/42"
responses.add(
method=responses.PUT,
url=url,
json={"id": 42, "foo": "baz"},
status=200,
match=[responses.matchers.query_param_matcher({})],
)

mgr = M(gl)
server_data = mgr.update(42, {"foo": "baz"}, _custom_path="/others/42")
assert isinstance(server_data, dict)
assert server_data["foo"] == "baz"
assert responses.assert_call_count(url, 1) is True


@responses.activate
def test_delete_mixin(gl):
class M(DeleteMixin, FakeManager):
Expand All @@ -440,6 +462,25 @@ class M(DeleteMixin, FakeManager):
assert responses.assert_call_count(url, 1) is True


@responses.activate
def test_delete_mixin_custom_path(gl):
class M(DeleteMixin, FakeManager):
pass

url = "http://localhost/api/v4/others/42"
responses.add(
method=responses.DELETE,
url=url,
json="",
status=200,
match=[responses.matchers.query_param_matcher({})],
)

mgr = M(gl)
mgr.delete(42, _custom_path="/others/42")
assert responses.assert_call_count(url, 1) is True


@responses.activate
def test_save_mixin(gl):
class M(UpdateMixin, FakeManager):
Expand All @@ -466,6 +507,32 @@ class TestClass(SaveMixin, base.RESTObject):
assert responses.assert_call_count(url, 1) is True


@responses.activate
def test_save_mixin_custom_path(gl):
class M(UpdateMixin, FakeManager):
pass

class TestClass(SaveMixin, base.RESTObject):
def _get_custom_path(self):
return "/others/42"

url = "http://localhost/api/v4/others/42"
responses.add(
method=responses.PUT,
url=url,
json={"id": 42, "foo": "baz"},
status=200,
match=[responses.matchers.query_param_matcher({})],
)

mgr = M(gl)
obj = TestClass(mgr, {"id": 42, "foo": "bar"})
obj.foo = "baz"
obj.save()
assert obj._attrs["foo"] == "baz"
assert responses.assert_call_count(url, 1) is True


@responses.activate
def test_save_mixin_without_new_data(gl):
class M(UpdateMixin, FakeManager):
Expand All @@ -485,6 +552,30 @@ class TestClass(SaveMixin, base.RESTObject):
assert responses.assert_call_count(url, 0) is True


@responses.activate
def test_object_delete_mixin_custom_path(gl):
class M(DeleteMixin, FakeManager):
pass

class TestClass(ObjectDeleteMixin, base.RESTObject):
def _get_custom_path(self):
return "/others/42"

url = "http://localhost/api/v4/others/42"
responses.add(
method=responses.DELETE,
url=url,
json="",
status=200,
match=[responses.matchers.query_param_matcher({})],
)

mgr = M(gl)
obj = TestClass(mgr, {"id": 42})
obj.delete()
assert responses.assert_call_count(url, 1) is True


@responses.activate
def test_set_mixin(gl):
class M(SetMixin, FakeManager):
Expand Down
Loading