| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 659c648 commit f187c43
5 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -292,13 +292,16 @@ def update( | |||
| 292 | 292 | self, | |
| 293 | 293 | id: str | int | None = None, | |
| 294 | 294 | new_data: dict[str, Any] | None = None, | |
| 295 | + *, | ||
| 296 | + _custom_path: str | None = None, | ||
| 295 | 297 | **kwargs: Any, | |
| 296 | 298 | ) -> dict[str, Any]: | |
| 297 | 299 | """Update an object on the server. | |
| 298 | 300 | ||
| 299 | 301 | Args: | |
| 300 | 302 | id: ID of the object to update (can be None if not required) | |
| 301 | 303 | new_data: the update data for the object | |
| 304 | + _custom_path: Optional custom path for special API endpoints | ||
| 302 | 305 | **kwargs: Extra options to send to the server (e.g. sudo) | |
| 303 | 306 | ||
| 304 | 307 | Returns: | |
@@ -310,7 +313,9 @@ def update( | |||
| 310 | 313 | """ | |
| 311 | 314 | new_data = new_data or {} | |
| 312 | 315 | ||
| 313 | - if id is None: | ||
| 316 | + if _custom_path is not None: | ||
| 317 | + path = _custom_path | ||
| 318 | + elif id is None: | ||
| 314 | 319 | path = self.path | |
| 315 | 320 | else: | |
| 316 | 321 | path = f"{self.path}/{utils.EncodedId(id)}" | |
@@ -357,18 +362,27 @@ def set(self, key: str, value: str, **kwargs: Any) -> base.TObjCls: | |||
| 357 | 362 | ||
| 358 | 363 | class DeleteMixin(base.RESTManager[base.TObjCls]): | |
| 359 | 364 | @exc.on_http_error(exc.GitlabDeleteError) | |
| 360 | - def delete(self, id: str | int | None = None, **kwargs: Any) -> None: | ||
| 365 | + def delete( | ||
| 366 | + self, | ||
| 367 | + id: str | int | None = None, | ||
| 368 | + *, | ||
| 369 | + _custom_path: str | None = None, | ||
| 370 | + **kwargs: Any, | ||
| 371 | + ) -> None: | ||
| 361 | 372 | """Delete an object on the server. | |
| 362 | 373 | ||
| 363 | 374 | Args: | |
| 364 | 375 | id: ID of the object to delete | |
| 376 | + _custom_path: Optional custom path for special API endpoints | ||
| 365 | 377 | **kwargs: Extra options to send to the server (e.g. sudo) | |
| 366 | 378 | ||
| 367 | 379 | Raises: | |
| 368 | 380 | GitlabAuthenticationError: If authentication is not correct | |
| 369 | 381 | GitlabDeleteError: If the server cannot perform the request | |
| 370 | 382 | """ | |
| 371 | - if id is None: | ||
| 383 | + if _custom_path is not None: | ||
| 384 | + path = _custom_path | ||
| 385 | + elif id is None: | ||
| 372 | 386 | path = self.path | |
| 373 | 387 | else: | |
| 374 | 388 | path = f"{self.path}/{utils.EncodedId(id)}" | |
@@ -403,6 +417,10 @@ class SaveMixin(_RestObjectBase): | |||
| 403 | 417 | _updated_attrs: dict[str, Any] | |
| 404 | 418 | manager: base.RESTManager[Any] | |
| 405 | 419 | ||
| 420 | + def _get_custom_path(self) -> str | None: | ||
| 421 | + custom_path: str | None = None | ||
| 422 | + return custom_path | ||
| 423 | + | ||
| 406 | 424 | def _get_updated_data(self) -> dict[str, Any]: | |
| 407 | 425 | updated_data = {} | |
| 408 | 426 | for attr in self.manager._update_attrs.required: | |
@@ -437,7 +455,13 @@ def save(self, **kwargs: Any) -> dict[str, Any] | None: | |||
| 437 | 455 | obj_id = self.encoded_id | |
| 438 | 456 | if TYPE_CHECKING: | |
| 439 | 457 | assert isinstance(self.manager, UpdateMixin) | |
| 440 | - server_data = self.manager.update(obj_id, updated_data, **kwargs) | ||
| 458 | + custom_path = self._get_custom_path() | ||
| 459 | + if custom_path is None: | ||
| 460 | + server_data = self.manager.update(obj_id, updated_data, **kwargs) | ||
| 461 | + else: | ||
| 462 | + server_data = self.manager.update( | ||
| 463 | + obj_id, updated_data, _custom_path=custom_path, **kwargs | ||
| 464 | + ) | ||
| 441 | 465 | self._update_attrs(server_data) | |
| 442 | 466 | return server_data | |
| 443 | 467 | ||
@@ -452,6 +476,10 @@ class ObjectDeleteMixin(_RestObjectBase): | |||
| 452 | 476 | _updated_attrs: dict[str, Any] | |
| 453 | 477 | manager: base.RESTManager[Any] | |
| 454 | 478 | ||
| 479 | + def _get_custom_path(self) -> str | None: | ||
| 480 | + custom_path: str | None = None | ||
| 481 | + return custom_path | ||
| 482 | + | ||
| 455 | 483 | def delete(self, **kwargs: Any) -> None: | |
| 456 | 484 | """Delete the object from the server. | |
| 457 | 485 | ||
@@ -465,7 +493,11 @@ def delete(self, **kwargs: Any) -> None: | |||
| 465 | 493 | if TYPE_CHECKING: | |
| 466 | 494 | assert isinstance(self.manager, DeleteMixin) | |
| 467 | 495 | assert self.encoded_id is not None | |
| 468 | - self.manager.delete(self.encoded_id, **kwargs) | ||
| 496 | + custom_path = self._get_custom_path() | ||
| 497 | + if custom_path is None: | ||
| 498 | + self.manager.delete(self.encoded_id, **kwargs) | ||
| 499 | + else: | ||
| 500 | + self.manager.delete(self.encoded_id, _custom_path=custom_path, **kwargs) | ||
| 469 | 501 | ||
| 470 | 502 | ||
| 471 | 503 | class UserAgentDetailMixin(_RestObjectBase): | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -2,6 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | from typing import Any, TYPE_CHECKING | |
| 4 | 4 | ||
| 5 | + import gitlab.utils | ||
| 5 | 6 | from gitlab import exceptions as exc | |
| 6 | 7 | from gitlab import types | |
| 7 | 8 | from gitlab.base import RESTObject | |
@@ -29,6 +30,18 @@ class GroupEpic(ObjectDeleteMixin, SaveMixin, RESTObject): | |||
| 29 | 30 | resourcelabelevents: GroupEpicResourceLabelEventManager | |
| 30 | 31 | notes: GroupEpicNoteManager | |
| 31 | 32 | ||
| 33 | + def _epic_path(self) -> str: | ||
| 34 | + """Return the API path for this epic using its real group.""" | ||
| 35 | + if not hasattr(self, "group_id") or self.group_id is None: | ||
| 36 | + raise AttributeError( | ||
| 37 | + "Cannot compute epic path: attribute 'group_id' is missing." | ||
| 38 | + ) | ||
| 39 | + encoded_group_id = gitlab.utils.EncodedId(self.group_id) | ||
| 40 | + return f"/groups/{encoded_group_id}/epics/{self.encoded_id}" | ||
| 41 | + | ||
| 42 | + def _get_custom_path(self) -> str | None: | ||
| 43 | + return self._epic_path() | ||
| 44 | + | ||
| 32 | 45 | ||
| 33 | 46 | class GroupEpicManager(CRUDMixin[GroupEpic]): | |
| 34 | 47 | _path = "/groups/{group_id}/epics" | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1,5 +1,14 @@ | |||
| 1 | + import collections.abc | ||
| 2 | + import dataclasses | ||
| 3 | + import uuid | ||
| 4 | + | ||
| 1 | 5 | import pytest | |
| 2 | 6 | ||
| 7 | + import gitlab | ||
| 8 | + import gitlab.v4.objects.epics | ||
| 9 | + import gitlab.v4.objects.groups | ||
| 10 | + from tests.functional import helpers | ||
| 11 | + | ||
| 3 | 12 | pytestmark = pytest.mark.gitlab_premium | |
| 4 | 13 | ||
| 5 | 14 | ||
@@ -32,3 +41,54 @@ def test_epic_notes(epic): | |||
| 32 | 41 | epic.notes.create({"body": "Test note"}) | |
| 33 | 42 | new_notes = epic.notes.list(get_all=True) | |
| 34 | 43 | assert len(new_notes) == (len(notes) + 1), f"{new_notes} {notes}" | |
| 44 | + | ||
| 45 | + | ||
| 46 | + @dataclasses.dataclass(frozen=True) | ||
| 47 | + class NestedEpicInSubgroup: | ||
| 48 | + subgroup: gitlab.v4.objects.groups.Group | ||
| 49 | + nested_epic: gitlab.v4.objects.epics.GroupEpic | ||
| 50 | + | ||
| 51 | + | ||
| 52 | + @pytest.fixture | ||
| 53 | + def nested_epic_in_subgroup( | ||
| 54 | + gl: gitlab.Gitlab, group: gitlab.v4.objects.groups.Group | ||
| 55 | + ) -> collections.abc.Generator[NestedEpicInSubgroup, None, None]: | ||
| 56 | + subgroup_id = uuid.uuid4().hex | ||
| 57 | + subgroup = gl.groups.create( | ||
| 58 | + { | ||
| 59 | + "name": f"subgroup-{subgroup_id}", | ||
| 60 | + "path": f"sg-{subgroup_id}", | ||
| 61 | + "parent_id": group.id, | ||
| 62 | + } | ||
| 63 | + ) | ||
| 64 | + | ||
| 65 | + nested_epic = subgroup.epics.create( | ||
| 66 | + {"title": f"Nested epic {subgroup_id}", "description": "Nested epic"} | ||
| 67 | + ) | ||
| 68 | + | ||
| 69 | + try: | ||
| 70 | + yield NestedEpicInSubgroup(subgroup=subgroup, nested_epic=nested_epic) | ||
| 71 | + finally: | ||
| 72 | + helpers.safe_delete(nested_epic) | ||
| 73 | + helpers.safe_delete(subgroup) | ||
| 74 | + | ||
| 75 | + | ||
| 76 | + def test_epic_save_from_parent_group_updates_subgroup_epic( | ||
| 77 | + group: gitlab.v4.objects.groups.Group, nested_epic_in_subgroup: NestedEpicInSubgroup | ||
| 78 | + ) -> None: | ||
| 79 | + fetched_epics = group.epics.list(search=nested_epic_in_subgroup.nested_epic.title) | ||
| 80 | + assert fetched_epics, "Expected to discover nested epic via parent group list" | ||
| 81 | + | ||
| 82 | + fetched_epic = fetched_epics[0] | ||
| 83 | + assert ( | ||
| 84 | + fetched_epic.id == nested_epic_in_subgroup.nested_epic.id | ||
| 85 | + ), "Parent group listing did not include nested epic" | ||
| 86 | + | ||
| 87 | + new_label = f"nested-{uuid.uuid4().hex}" | ||
| 88 | + fetched_epic.labels = [new_label] | ||
| 89 | + fetched_epic.save() | ||
| 90 | + | ||
| 91 | + refreshed_epic = nested_epic_in_subgroup.subgroup.epics.get( | ||
| 92 | + nested_epic_in_subgroup.nested_epic.iid | ||
| 93 | + ) | ||
| 94 | + assert new_label in refreshed_epic.labels | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -12,6 +12,7 @@ | |||
| 12 | 12 | GetMixin, | |
| 13 | 13 | GetWithoutIdMixin, | |
| 14 | 14 | ListMixin, | |
| 15 | + ObjectDeleteMixin, | ||
| 15 | 16 | RefreshMixin, | |
| 16 | 17 | SaveMixin, | |
| 17 | 18 | SetMixin, | |
@@ -421,6 +422,27 @@ class M(UpdateMixin, FakeManager): | |||
| 421 | 422 | assert responses.assert_call_count(url, 1) is True | |
| 422 | 423 | ||
| 423 | 424 | ||
| 425 | + @responses.activate | ||
| 426 | + def test_update_mixin_custom_path(gl): | ||
| 427 | + class M(UpdateMixin, FakeManager): | ||
| 428 | + pass | ||
| 429 | + | ||
| 430 | + url = "http://localhost/api/v4/others/42" | ||
| 431 | + responses.add( | ||
| 432 | + method=responses.PUT, | ||
| 433 | + url=url, | ||
| 434 | + json={"id": 42, "foo": "baz"}, | ||
| 435 | + status=200, | ||
| 436 | + match=[responses.matchers.query_param_matcher({})], | ||
| 437 | + ) | ||
| 438 | + | ||
| 439 | + mgr = M(gl) | ||
| 440 | + server_data = mgr.update(42, {"foo": "baz"}, _custom_path="/others/42") | ||
| 441 | + assert isinstance(server_data, dict) | ||
| 442 | + assert server_data["foo"] == "baz" | ||
| 443 | + assert responses.assert_call_count(url, 1) is True | ||
| 444 | + | ||
| 445 | + | ||
| 424 | 446 | @responses.activate | |
| 425 | 447 | def test_delete_mixin(gl): | |
| 426 | 448 | class M(DeleteMixin, FakeManager): | |
@@ -440,6 +462,25 @@ class M(DeleteMixin, FakeManager): | |||
| 440 | 462 | assert responses.assert_call_count(url, 1) is True | |
| 441 | 463 | ||
| 442 | 464 | ||
| 465 | + @responses.activate | ||
| 466 | + def test_delete_mixin_custom_path(gl): | ||
| 467 | + class M(DeleteMixin, FakeManager): | ||
| 468 | + pass | ||
| 469 | + | ||
| 470 | + url = "http://localhost/api/v4/others/42" | ||
| 471 | + responses.add( | ||
| 472 | + method=responses.DELETE, | ||
| 473 | + url=url, | ||
| 474 | + json="", | ||
| 475 | + status=200, | ||
| 476 | + match=[responses.matchers.query_param_matcher({})], | ||
| 477 | + ) | ||
| 478 | + | ||
| 479 | + mgr = M(gl) | ||
| 480 | + mgr.delete(42, _custom_path="/others/42") | ||
| 481 | + assert responses.assert_call_count(url, 1) is True | ||
| 482 | + | ||
| 483 | + | ||
| 443 | 484 | @responses.activate | |
| 444 | 485 | def test_save_mixin(gl): | |
| 445 | 486 | class M(UpdateMixin, FakeManager): | |
@@ -466,6 +507,32 @@ class TestClass(SaveMixin, base.RESTObject): | |||
| 466 | 507 | assert responses.assert_call_count(url, 1) is True | |
| 467 | 508 | ||
| 468 | 509 | ||
| 510 | + @responses.activate | ||
| 511 | + def test_save_mixin_custom_path(gl): | ||
| 512 | + class M(UpdateMixin, FakeManager): | ||
| 513 | + pass | ||
| 514 | + | ||
| 515 | + class TestClass(SaveMixin, base.RESTObject): | ||
| 516 | + def _get_custom_path(self): | ||
| 517 | + return "/others/42" | ||
| 518 | + | ||
| 519 | + url = "http://localhost/api/v4/others/42" | ||
| 520 | + responses.add( | ||
| 521 | + method=responses.PUT, | ||
| 522 | + url=url, | ||
| 523 | + json={"id": 42, "foo": "baz"}, | ||
| 524 | + status=200, | ||
| 525 | + match=[responses.matchers.query_param_matcher({})], | ||
| 526 | + ) | ||
| 527 | + | ||
| 528 | + mgr = M(gl) | ||
| 529 | + obj = TestClass(mgr, {"id": 42, "foo": "bar"}) | ||
| 530 | + obj.foo = "baz" | ||
| 531 | + obj.save() | ||
| 532 | + assert obj._attrs["foo"] == "baz" | ||
| 533 | + assert responses.assert_call_count(url, 1) is True | ||
| 534 | + | ||
| 535 | + | ||
| 469 | 536 | @responses.activate | |
| 470 | 537 | def test_save_mixin_without_new_data(gl): | |
| 471 | 538 | class M(UpdateMixin, FakeManager): | |
@@ -485,6 +552,30 @@ class TestClass(SaveMixin, base.RESTObject): | |||
| 485 | 552 | assert responses.assert_call_count(url, 0) is True | |
| 486 | 553 | ||
| 487 | 554 | ||
| 555 | + @responses.activate | ||
| 556 | + def test_object_delete_mixin_custom_path(gl): | ||
| 557 | + class M(DeleteMixin, FakeManager): | ||
| 558 | + pass | ||
| 559 | + | ||
| 560 | + class TestClass(ObjectDeleteMixin, base.RESTObject): | ||
| 561 | + def _get_custom_path(self): | ||
| 562 | + return "/others/42" | ||
| 563 | + | ||
| 564 | + url = "http://localhost/api/v4/others/42" | ||
| 565 | + responses.add( | ||
| 566 | + method=responses.DELETE, | ||
| 567 | + url=url, | ||
| 568 | + json="", | ||
| 569 | + status=200, | ||
| 570 | + match=[responses.matchers.query_param_matcher({})], | ||
| 571 | + ) | ||
| 572 | + | ||
| 573 | + mgr = M(gl) | ||
| 574 | + obj = TestClass(mgr, {"id": 42}) | ||
| 575 | + obj.delete() | ||
| 576 | + assert responses.assert_call_count(url, 1) is True | ||
| 577 | + | ||
| 578 | + | ||
| 488 | 579 | @responses.activate | |
| 489 | 580 | def test_set_mixin(gl): | |
| 490 | 581 | class M(SetMixin, FakeManager): | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,67 @@ | |||
| 1 | + from typing import Any | ||
| 2 | + | ||
| 3 | + import pytest | ||
| 4 | + import responses | ||
| 5 | + | ||
| 6 | + import gitlab.base | ||
| 7 | + import gitlab.v4.objects.epics | ||
| 8 | + import gitlab.v4.objects.groups | ||
| 9 | + | ||
| 10 | + | ||
| 11 | + def _build_epic( | ||
| 12 | + manager: gitlab.v4.objects.epics.GroupEpicManager, | ||
| 13 | + iid: int = 3, | ||
| 14 | + group_id: int = 2, | ||
| 15 | + title: str = "Epic", | ||
| 16 | + ) -> gitlab.v4.objects.epics.GroupEpic: | ||
| 17 | + data: dict[str, int | str] = {"iid": iid, "group_id": group_id, "title": title} | ||
| 18 | + return gitlab.v4.objects.epics.GroupEpic(manager, data) | ||
| 19 | + | ||
| 20 | + | ||
| 21 | + def test_group_epic_save_uses_actual_group_path( | ||
| 22 | + group: gitlab.v4.objects.groups.Group, | ||
| 23 | + ) -> None: | ||
| 24 | + epic_manager = group.epics | ||
| 25 | + epic = _build_epic(epic_manager, title="Original") | ||
| 26 | + epic.title = "Updated" | ||
| 27 | + | ||
| 28 | + with responses.RequestsMock() as rsps: | ||
| 29 | + rsps.add( | ||
| 30 | + method=responses.PUT, | ||
| 31 | + url="http://localhost/api/v4/groups/2/epics/3", | ||
| 32 | + json={"iid": 3, "group_id": 2, "title": "Updated"}, | ||
| 33 | + content_type="application/json", | ||
| 34 | + status=200, | ||
| 35 | + match=[responses.matchers.json_params_matcher({"title": "Updated"})], | ||
| 36 | + ) | ||
| 37 | + | ||
| 38 | + epic.save() | ||
| 39 | + | ||
| 40 | + assert epic.title == "Updated" | ||
| 41 | + | ||
| 42 | + | ||
| 43 | + def test_group_epic_delete_uses_actual_group_path( | ||
| 44 | + group: gitlab.v4.objects.groups.Group, | ||
| 45 | + ) -> None: | ||
| 46 | + epic_manager = group.epics | ||
| 47 | + epic = _build_epic(epic_manager) | ||
| 48 | + | ||
| 49 | + with responses.RequestsMock() as rsps: | ||
| 50 | + rsps.add( | ||
| 51 | + method=responses.DELETE, | ||
| 52 | + url="http://localhost/api/v4/groups/2/epics/3", | ||
| 53 | + status=204, | ||
| 54 | + ) | ||
| 55 | + | ||
| 56 | + epic.delete() | ||
| 57 | + | ||
| 58 | + assert len(epic._updated_attrs) == 0 | ||
| 59 | + | ||
| 60 | + | ||
| 61 | + def test_group_epic_path_requires_group_id( | ||
| 62 | + fake_manager: gitlab.base.RESTManager[Any], | ||
| 63 | + ) -> None: | ||
| 64 | + epic = gitlab.v4.objects.epics.GroupEpic(manager=fake_manager, attrs={"iid": 5}) | ||
| 65 | + | ||
| 66 | + with pytest.raises(AttributeError): | ||
| 67 | + epic._epic_path() | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments