← 返回首页
fix(epics): use actual group_id for save/delete operations on nested epics by JohnVillalovos · Pull Request #3279 · python-gitlab/python-gitlab · GitHub
Skip to content

Navigation Menu

Toggle navigation
Sign in
Appearance settings
Search or jump to...

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Include my email address so I can be contacted

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Resetting focus
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension .py  (5) All 1 file type selected Viewed files
Conversations
Failed to load comments. Retry
Loading
Jump to
Jump to file
Failed to load files. Retry
Loading
Diff view
Unified
Split
Hide whitespace
Apply and reload
Show whitespace
Diff view
Unified
Split
Hide whitespace
Apply and reload
46 changes: 41 additions & 5 deletions gitlab/mixins.py
Show comments View file Edit file Delete file Open in desktop
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
Show comments View file Edit file Delete file Open in desktop
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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.
Show resolved Hide 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
Show comments View file Edit file Delete file Open in desktop
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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.
Show resolved Hide 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
Show comments View file Edit file Delete file Open in desktop
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
Toggle all file notes Toggle all file annotations

Footer

© 2026 GitHub, Inc.