From e5987626ca1643521b16658555f088412be2a339 Mon Sep 17 00:00:00 2001 From: Laurent Peuch Date: Fri, 29 Apr 2022 17:54:52 +0200 Subject: [PATCH 001/884] feat(ux): display project.name_with_namespace on project repr This change the repr from: $ gitlab.projects.get(id=some_id) To: $ gitlab.projects.get(id=some_id) This is especially useful when working on random projects or listing of projects since users generally don't remember projects ids. --- gitlab/v4/objects/projects.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/gitlab/v4/objects/projects.py b/gitlab/v4/objects/projects.py index 81eb62496..7d9c834bd 100644 --- a/gitlab/v4/objects/projects.py +++ b/gitlab/v4/objects/projects.py @@ -186,6 +186,16 @@ class Project(RefreshMixin, SaveMixin, ObjectDeleteMixin, RepositoryMixin, RESTO variables: ProjectVariableManager wikis: ProjectWikiManager + def __repr__(self) -> str: + project_repr = super().__repr__() + + if hasattr(self, "name_with_namespace"): + return ( + f'{project_repr[:-1]} name_with_namespace:"{self.name_with_namespace}">' + ) + else: + return project_repr + @cli.register_custom_action("Project", ("forked_from_id",)) @exc.on_http_error(exc.GitlabCreateError) def create_fork_relation(self, forked_from_id: int, **kwargs: Any) -> None: From 3e0d4d9006e2ca6effae2b01cef3926dd0850e52 Mon Sep 17 00:00:00 2001 From: Nazia Povey Date: Sat, 7 May 2022 11:37:48 -0700 Subject: [PATCH 002/884] docs: add missing Admin access const value As shown here, Admin access is set to 60: https://docs.gitlab.com/ee/api/protected_branches.html#protected-branches-api --- gitlab/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/gitlab/const.py b/gitlab/const.py index 2ed4fa7d4..0d35045c2 100644 --- a/gitlab/const.py +++ b/gitlab/const.py @@ -61,6 +61,7 @@ DEVELOPER_ACCESS: int = 30 MAINTAINER_ACCESS: int = 40 OWNER_ACCESS: int = 50 +ADMIN_ACCESS: int = 60 VISIBILITY_PRIVATE: str = "private" VISIBILITY_INTERNAL: str = "internal" From 2373a4f13ee4e5279a424416cdf46782a5627067 Mon Sep 17 00:00:00 2001 From: Laurent Peuch Date: Sat, 7 May 2022 11:39:46 -0700 Subject: [PATCH 003/884] docs(CONTRIBUTING.rst): fix link to conventional-changelog commit format documentation --- CONTRIBUTING.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 2a645d0fa..3b15051a7 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -27,7 +27,7 @@ Please provide your patches as GitHub pull requests. Thanks! Commit message guidelines ------------------------- -We enforce commit messages to be formatted using the `conventional-changelog `_. +We enforce commit messages to be formatted using the `conventional-changelog `_. This leads to more readable messages that are easy to follow when looking through the project history. Code-Style From 6b47c26d053fe352d68eb22a1eaf4b9a3c1c93e7 Mon Sep 17 00:00:00 2001 From: Nejc Habjan Date: Sat, 7 May 2022 22:50:11 +0200 Subject: [PATCH 004/884] feat: display human-readable attribute in `repr()` if present --- gitlab/base.py | 23 +++++++++++---- gitlab/v4/cli.py | 6 ++-- gitlab/v4/objects/applications.py | 2 +- gitlab/v4/objects/commits.py | 4 +-- gitlab/v4/objects/events.py | 2 +- gitlab/v4/objects/files.py | 2 +- gitlab/v4/objects/groups.py | 2 +- gitlab/v4/objects/hooks.py | 6 ++-- gitlab/v4/objects/issues.py | 4 +-- gitlab/v4/objects/members.py | 10 +++---- gitlab/v4/objects/merge_request_approvals.py | 2 +- gitlab/v4/objects/milestones.py | 4 +-- gitlab/v4/objects/projects.py | 12 +------- gitlab/v4/objects/snippets.py | 4 +-- gitlab/v4/objects/tags.py | 4 +-- gitlab/v4/objects/users.py | 14 ++++----- gitlab/v4/objects/wikis.py | 4 +-- tests/unit/test_base.py | 31 ++++++++++++++++++++ 18 files changed, 85 insertions(+), 51 deletions(-) diff --git a/gitlab/base.py b/gitlab/base.py index 7f685425a..a1cd30fda 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -49,8 +49,12 @@ class RESTObject: another. This allows smart updates, if the object allows it. You can redefine ``_id_attr`` in child classes to specify which attribute - must be used as uniq ID. ``None`` means that the object can be updated + must be used as the unique ID. ``None`` means that the object can be updated without ID in the url. + + Likewise, you can define a ``_repr_attr`` in subclasses to specify which + attribute should be added as a human-readable identifier when called in the + object's ``__repr__()`` method. """ _id_attr: Optional[str] = "id" @@ -58,7 +62,7 @@ class RESTObject: _created_from_list: bool # Indicates if object was created from a list() action _module: ModuleType _parent_attrs: Dict[str, Any] - _short_print_attr: Optional[str] = None + _repr_attr: Optional[str] = None _updated_attrs: Dict[str, Any] manager: "RESTManager" @@ -158,10 +162,19 @@ def pprint(self) -> None: print(self.pformat()) def __repr__(self) -> str: + name = self.__class__.__name__ + + if (self._id_attr and self._repr_attr) and (self._id_attr != self._repr_attr): + return ( + f"<{name} {self._id_attr}:{self.get_id()} " + f"{self._repr_attr}:{getattr(self, self._repr_attr)}>" + ) if self._id_attr: - return f"<{self.__class__.__name__} {self._id_attr}:{self.get_id()}>" - else: - return f"<{self.__class__.__name__}>" + return f"<{name} {self._id_attr}:{self.get_id()}>" + if self._repr_attr: + return f"<{name} {self._repr_attr}:{getattr(self, self._repr_attr)}>" + + return f"<{name}>" def __eq__(self, other: object) -> bool: if not isinstance(other, RESTObject): diff --git a/gitlab/v4/cli.py b/gitlab/v4/cli.py index 6830b0874..245897e71 100644 --- a/gitlab/v4/cli.py +++ b/gitlab/v4/cli.py @@ -449,12 +449,12 @@ def display_dict(d: Dict[str, Any], padding: int) -> None: if obj._id_attr: id = getattr(obj, obj._id_attr) print(f"{obj._id_attr.replace('_', '-')}: {id}") - if obj._short_print_attr: - value = getattr(obj, obj._short_print_attr) or "None" + if obj._repr_attr: + value = getattr(obj, obj._repr_attr, "None") value = value.replace("\r", "").replace("\n", " ") # If the attribute is a note (ProjectCommitComment) then we do # some modifications to fit everything on one line - line = f"{obj._short_print_attr}: {value}" + line = f"{obj._repr_attr}: {value}" # ellipsize long lines (comments) if len(line) > 79: line = f"{line[:76]}..." diff --git a/gitlab/v4/objects/applications.py b/gitlab/v4/objects/applications.py index c91dee188..926d18915 100644 --- a/gitlab/v4/objects/applications.py +++ b/gitlab/v4/objects/applications.py @@ -9,7 +9,7 @@ class Application(ObjectDeleteMixin, RESTObject): _url = "/applications" - _short_print_attr = "name" + _repr_attr = "name" class ApplicationManager(ListMixin, CreateMixin, DeleteMixin, RESTManager): diff --git a/gitlab/v4/objects/commits.py b/gitlab/v4/objects/commits.py index 5f13f5c73..19098af0b 100644 --- a/gitlab/v4/objects/commits.py +++ b/gitlab/v4/objects/commits.py @@ -21,7 +21,7 @@ class ProjectCommit(RESTObject): - _short_print_attr = "title" + _repr_attr = "title" comments: "ProjectCommitCommentManager" discussions: ProjectCommitDiscussionManager @@ -172,7 +172,7 @@ def get( class ProjectCommitComment(RESTObject): _id_attr = None - _short_print_attr = "note" + _repr_attr = "note" class ProjectCommitCommentManager(ListMixin, CreateMixin, RESTManager): diff --git a/gitlab/v4/objects/events.py b/gitlab/v4/objects/events.py index b7d8fd14d..048f280b1 100644 --- a/gitlab/v4/objects/events.py +++ b/gitlab/v4/objects/events.py @@ -29,7 +29,7 @@ class Event(RESTObject): _id_attr = None - _short_print_attr = "target_title" + _repr_attr = "target_title" class EventManager(ListMixin, RESTManager): diff --git a/gitlab/v4/objects/files.py b/gitlab/v4/objects/files.py index 435e71b55..e5345ce15 100644 --- a/gitlab/v4/objects/files.py +++ b/gitlab/v4/objects/files.py @@ -24,7 +24,7 @@ class ProjectFile(SaveMixin, ObjectDeleteMixin, RESTObject): _id_attr = "file_path" - _short_print_attr = "file_path" + _repr_attr = "file_path" file_path: str manager: "ProjectFileManager" diff --git a/gitlab/v4/objects/groups.py b/gitlab/v4/objects/groups.py index a3a1051b0..28f3623ed 100644 --- a/gitlab/v4/objects/groups.py +++ b/gitlab/v4/objects/groups.py @@ -48,7 +48,7 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject): - _short_print_attr = "name" + _repr_attr = "name" access_tokens: GroupAccessTokenManager accessrequests: GroupAccessRequestManager diff --git a/gitlab/v4/objects/hooks.py b/gitlab/v4/objects/hooks.py index 0b0092e3c..f37d514bc 100644 --- a/gitlab/v4/objects/hooks.py +++ b/gitlab/v4/objects/hooks.py @@ -15,7 +15,7 @@ class Hook(ObjectDeleteMixin, RESTObject): _url = "/hooks" - _short_print_attr = "url" + _repr_attr = "url" class HookManager(NoUpdateMixin, RESTManager): @@ -28,7 +28,7 @@ def get(self, id: Union[str, int], lazy: bool = False, **kwargs: Any) -> Hook: class ProjectHook(SaveMixin, ObjectDeleteMixin, RESTObject): - _short_print_attr = "url" + _repr_attr = "url" class ProjectHookManager(CRUDMixin, RESTManager): @@ -75,7 +75,7 @@ def get( class GroupHook(SaveMixin, ObjectDeleteMixin, RESTObject): - _short_print_attr = "url" + _repr_attr = "url" class GroupHookManager(CRUDMixin, RESTManager): diff --git a/gitlab/v4/objects/issues.py b/gitlab/v4/objects/issues.py index f20252bd1..693c18f3b 100644 --- a/gitlab/v4/objects/issues.py +++ b/gitlab/v4/objects/issues.py @@ -42,7 +42,7 @@ class Issue(RESTObject): _url = "/issues" - _short_print_attr = "title" + _repr_attr = "title" class IssueManager(RetrieveMixin, RESTManager): @@ -108,7 +108,7 @@ class ProjectIssue( ObjectDeleteMixin, RESTObject, ): - _short_print_attr = "title" + _repr_attr = "title" _id_attr = "iid" awardemojis: ProjectIssueAwardEmojiManager diff --git a/gitlab/v4/objects/members.py b/gitlab/v4/objects/members.py index 5ee0b0e4e..d5d8766d9 100644 --- a/gitlab/v4/objects/members.py +++ b/gitlab/v4/objects/members.py @@ -28,7 +28,7 @@ class GroupMember(SaveMixin, ObjectDeleteMixin, RESTObject): - _short_print_attr = "username" + _repr_attr = "username" class GroupMemberManager(CRUDMixin, RESTManager): @@ -50,7 +50,7 @@ def get( class GroupBillableMember(ObjectDeleteMixin, RESTObject): - _short_print_attr = "username" + _repr_attr = "username" memberships: "GroupBillableMemberMembershipManager" @@ -73,7 +73,7 @@ class GroupBillableMemberMembershipManager(ListMixin, RESTManager): class GroupMemberAll(RESTObject): - _short_print_attr = "username" + _repr_attr = "username" class GroupMemberAllManager(RetrieveMixin, RESTManager): @@ -88,7 +88,7 @@ def get( class ProjectMember(SaveMixin, ObjectDeleteMixin, RESTObject): - _short_print_attr = "username" + _repr_attr = "username" class ProjectMemberManager(CRUDMixin, RESTManager): @@ -110,7 +110,7 @@ def get( class ProjectMemberAll(RESTObject): - _short_print_attr = "username" + _repr_attr = "username" class ProjectMemberAllManager(RetrieveMixin, RESTManager): diff --git a/gitlab/v4/objects/merge_request_approvals.py b/gitlab/v4/objects/merge_request_approvals.py index d34484b2e..3617131e4 100644 --- a/gitlab/v4/objects/merge_request_approvals.py +++ b/gitlab/v4/objects/merge_request_approvals.py @@ -165,7 +165,7 @@ def set_approvers( class ProjectMergeRequestApprovalRule(SaveMixin, ObjectDeleteMixin, RESTObject): _id_attr = "approval_rule_id" - _short_print_attr = "approval_rule" + _repr_attr = "approval_rule" id: int @exc.on_http_error(exc.GitlabUpdateError) diff --git a/gitlab/v4/objects/milestones.py b/gitlab/v4/objects/milestones.py index da75826db..e415330e4 100644 --- a/gitlab/v4/objects/milestones.py +++ b/gitlab/v4/objects/milestones.py @@ -22,7 +22,7 @@ class GroupMilestone(SaveMixin, ObjectDeleteMixin, RESTObject): - _short_print_attr = "title" + _repr_attr = "title" @cli.register_custom_action("GroupMilestone") @exc.on_http_error(exc.GitlabListError) @@ -102,7 +102,7 @@ def get( class ProjectMilestone(PromoteMixin, SaveMixin, ObjectDeleteMixin, RESTObject): - _short_print_attr = "title" + _repr_attr = "title" _update_uses_post = True @cli.register_custom_action("ProjectMilestone") diff --git a/gitlab/v4/objects/projects.py b/gitlab/v4/objects/projects.py index 7d9c834bd..b7df9ab0e 100644 --- a/gitlab/v4/objects/projects.py +++ b/gitlab/v4/objects/projects.py @@ -129,7 +129,7 @@ class ProjectGroupManager(ListMixin, RESTManager): class Project(RefreshMixin, SaveMixin, ObjectDeleteMixin, RepositoryMixin, RESTObject): - _short_print_attr = "path" + _repr_attr = "path" access_tokens: ProjectAccessTokenManager accessrequests: ProjectAccessRequestManager @@ -186,16 +186,6 @@ class Project(RefreshMixin, SaveMixin, ObjectDeleteMixin, RepositoryMixin, RESTO variables: ProjectVariableManager wikis: ProjectWikiManager - def __repr__(self) -> str: - project_repr = super().__repr__() - - if hasattr(self, "name_with_namespace"): - return ( - f'{project_repr[:-1]} name_with_namespace:"{self.name_with_namespace}">' - ) - else: - return project_repr - @cli.register_custom_action("Project", ("forked_from_id",)) @exc.on_http_error(exc.GitlabCreateError) def create_fork_relation(self, forked_from_id: int, **kwargs: Any) -> None: diff --git a/gitlab/v4/objects/snippets.py b/gitlab/v4/objects/snippets.py index 9d9dcc4e6..83b1378e2 100644 --- a/gitlab/v4/objects/snippets.py +++ b/gitlab/v4/objects/snippets.py @@ -21,7 +21,7 @@ class Snippet(UserAgentDetailMixin, SaveMixin, ObjectDeleteMixin, RESTObject): - _short_print_attr = "title" + _repr_attr = "title" @cli.register_custom_action("Snippet") @exc.on_http_error(exc.GitlabGetError) @@ -91,7 +91,7 @@ def get(self, id: Union[str, int], lazy: bool = False, **kwargs: Any) -> Snippet class ProjectSnippet(UserAgentDetailMixin, SaveMixin, ObjectDeleteMixin, RESTObject): _url = "/projects/{project_id}/snippets" - _short_print_attr = "title" + _repr_attr = "title" awardemojis: ProjectSnippetAwardEmojiManager discussions: ProjectSnippetDiscussionManager diff --git a/gitlab/v4/objects/tags.py b/gitlab/v4/objects/tags.py index c76799d20..748cbad97 100644 --- a/gitlab/v4/objects/tags.py +++ b/gitlab/v4/objects/tags.py @@ -13,7 +13,7 @@ class ProjectTag(ObjectDeleteMixin, RESTObject): _id_attr = "name" - _short_print_attr = "name" + _repr_attr = "name" class ProjectTagManager(NoUpdateMixin, RESTManager): @@ -30,7 +30,7 @@ def get(self, id: Union[str, int], lazy: bool = False, **kwargs: Any) -> Project class ProjectProtectedTag(ObjectDeleteMixin, RESTObject): _id_attr = "name" - _short_print_attr = "name" + _repr_attr = "name" class ProjectProtectedTagManager(NoUpdateMixin, RESTManager): diff --git a/gitlab/v4/objects/users.py b/gitlab/v4/objects/users.py index ddcee707a..09964b1a4 100644 --- a/gitlab/v4/objects/users.py +++ b/gitlab/v4/objects/users.py @@ -66,7 +66,7 @@ class CurrentUserEmail(ObjectDeleteMixin, RESTObject): - _short_print_attr = "email" + _repr_attr = "email" class CurrentUserEmailManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): @@ -96,7 +96,7 @@ def get( class CurrentUserKey(ObjectDeleteMixin, RESTObject): - _short_print_attr = "title" + _repr_attr = "title" class CurrentUserKeyManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): @@ -112,7 +112,7 @@ def get( class CurrentUserStatus(SaveMixin, RESTObject): _id_attr = None - _short_print_attr = "message" + _repr_attr = "message" class CurrentUserStatusManager(GetWithoutIdMixin, UpdateMixin, RESTManager): @@ -128,7 +128,7 @@ def get( class CurrentUser(RESTObject): _id_attr = None - _short_print_attr = "username" + _repr_attr = "username" emails: CurrentUserEmailManager gpgkeys: CurrentUserGPGKeyManager @@ -147,7 +147,7 @@ def get( class User(SaveMixin, ObjectDeleteMixin, RESTObject): - _short_print_attr = "username" + _repr_attr = "username" customattributes: UserCustomAttributeManager emails: "UserEmailManager" @@ -373,7 +373,7 @@ class ProjectUserManager(ListMixin, RESTManager): class UserEmail(ObjectDeleteMixin, RESTObject): - _short_print_attr = "email" + _repr_attr = "email" class UserEmailManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): @@ -392,7 +392,7 @@ class UserActivities(RESTObject): class UserStatus(RESTObject): _id_attr = None - _short_print_attr = "message" + _repr_attr = "message" class UserStatusManager(GetWithoutIdMixin, RESTManager): diff --git a/gitlab/v4/objects/wikis.py b/gitlab/v4/objects/wikis.py index c4055da05..a7028cfe6 100644 --- a/gitlab/v4/objects/wikis.py +++ b/gitlab/v4/objects/wikis.py @@ -13,7 +13,7 @@ class ProjectWiki(SaveMixin, ObjectDeleteMixin, RESTObject): _id_attr = "slug" - _short_print_attr = "slug" + _repr_attr = "slug" class ProjectWikiManager(CRUDMixin, RESTManager): @@ -34,7 +34,7 @@ def get( class GroupWiki(SaveMixin, ObjectDeleteMixin, RESTObject): _id_attr = "slug" - _short_print_attr = "slug" + _repr_attr = "slug" class GroupWikiManager(CRUDMixin, RESTManager): diff --git a/tests/unit/test_base.py b/tests/unit/test_base.py index 17722a24f..0a7f353b6 100644 --- a/tests/unit/test_base.py +++ b/tests/unit/test_base.py @@ -226,6 +226,37 @@ def test_dunder_str(self, fake_manager): " => {'attr1': 'foo'}" ) + @pytest.mark.parametrize( + "id_attr,repr_attr, attrs, expected_repr", + [ + ("id", None, {"id": 1}, ""), + ( + "id", + "name", + {"id": 1, "name": "fake"}, + "", + ), + ("name", "name", {"name": "fake"}, ""), + (None, None, {}, ""), + (None, "name", {"name": "fake"}, ""), + ], + ids=[ + "GetMixin with id", + "GetMixin with id and _repr_attr", + "GetMixin with _repr_attr matching _id_attr", + "GetWithoutIDMixin", + "GetWithoutIDMixin with _repr_attr", + ], + ) + def test_dunder_repr(self, fake_manager, id_attr, repr_attr, attrs, expected_repr): + class ReprObject(FakeObject): + _id_attr = id_attr + _repr_attr = repr_attr + + fake_object = ReprObject(fake_manager, attrs) + + assert repr(fake_object) == expected_repr + def test_pformat(self, fake_manager): fake_object = FakeObject( fake_manager, {"attr1": "foo" * 10, "ham": "eggs" * 15} From f553fd3c79579ab596230edea5899dc5189b0ac6 Mon Sep 17 00:00:00 2001 From: "John L. Villalovos" Date: Mon, 9 May 2022 06:39:36 -0700 Subject: [PATCH 005/884] fix: duplicate subparsers being added to argparse Python 3.11 added an additional check in the argparse libary which detected duplicate subparsers being added. We had duplicate subparsers being added. Make sure we don't add duplicate subparsers. Closes: #2015 --- gitlab/v4/cli.py | 25 ++++++++++++++++++------- tests/unit/v4/__init__.py | 0 2 files changed, 18 insertions(+), 7 deletions(-) create mode 100644 tests/unit/v4/__init__.py diff --git a/gitlab/v4/cli.py b/gitlab/v4/cli.py index 245897e71..98430b965 100644 --- a/gitlab/v4/cli.py +++ b/gitlab/v4/cli.py @@ -200,11 +200,15 @@ def _populate_sub_parser_by_class( mgr_cls_name = f"{cls.__name__}Manager" mgr_cls = getattr(gitlab.v4.objects, mgr_cls_name) + action_parsers: Dict[str, argparse.ArgumentParser] = {} for action_name in ["list", "get", "create", "update", "delete"]: if not hasattr(mgr_cls, action_name): continue - sub_parser_action = sub_parser.add_parser(action_name) + sub_parser_action = sub_parser.add_parser( + action_name, conflict_handler="resolve" + ) + action_parsers[action_name] = sub_parser_action sub_parser_action.add_argument("--sudo", required=False) if mgr_cls._from_parent_attrs: for x in mgr_cls._from_parent_attrs: @@ -268,7 +272,11 @@ def _populate_sub_parser_by_class( if cls.__name__ in cli.custom_actions: name = cls.__name__ for action_name in cli.custom_actions[name]: - sub_parser_action = sub_parser.add_parser(action_name) + # NOTE(jlvillal): If we put a function for the `default` value of + # the `get` it will always get called, which will break things. + sub_parser_action = action_parsers.get(action_name) + if sub_parser_action is None: + sub_parser_action = sub_parser.add_parser(action_name) # Get the attributes for URL/path construction if mgr_cls._from_parent_attrs: for x in mgr_cls._from_parent_attrs: @@ -298,7 +306,11 @@ def _populate_sub_parser_by_class( if mgr_cls.__name__ in cli.custom_actions: name = mgr_cls.__name__ for action_name in cli.custom_actions[name]: - sub_parser_action = sub_parser.add_parser(action_name) + # NOTE(jlvillal): If we put a function for the `default` value of + # the `get` it will always get called, which will break things. + sub_parser_action = action_parsers.get(action_name) + if sub_parser_action is None: + sub_parser_action = sub_parser.add_parser(action_name) if mgr_cls._from_parent_attrs: for x in mgr_cls._from_parent_attrs: sub_parser_action.add_argument( @@ -326,16 +338,15 @@ def extend_parser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: subparsers.required = True # populate argparse for all Gitlab Object - classes = [] + classes = set() for cls in gitlab.v4.objects.__dict__.values(): if not isinstance(cls, type): continue if issubclass(cls, gitlab.base.RESTManager): if cls._obj_cls is not None: - classes.append(cls._obj_cls) - classes.sort(key=operator.attrgetter("__name__")) + classes.add(cls._obj_cls) - for cls in classes: + for cls in sorted(classes, key=operator.attrgetter("__name__")): arg_name = cli.cls_to_what(cls) object_group = subparsers.add_parser(arg_name) diff --git a/tests/unit/v4/__init__.py b/tests/unit/v4/__init__.py new file mode 100644 index 000000000..e69de29bb From b235bb00f3c09be5bb092a5bb7298e7ca55f2366 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 9 May 2022 14:53:42 +0000 Subject: [PATCH 006/884] chore(deps): update dependency pylint to v2.13.8 --- requirements-lint.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-lint.txt b/requirements-lint.txt index 77fcf92fc..774cc6b71 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -4,7 +4,7 @@ commitizen==2.24.0 flake8==4.0.1 isort==5.10.1 mypy==0.950 -pylint==2.13.7 +pylint==2.13.8 pytest==7.1.2 types-PyYAML==6.0.7 types-requests==2.27.25 From 18355938d1b410ad5e17e0af4ef0667ddb709832 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 9 May 2022 14:53:46 +0000 Subject: [PATCH 007/884] chore(deps): update pre-commit hook pycqa/pylint to v2.13.8 --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d67ab99d6..be18a2e75 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: hooks: - id: isort - repo: https://github.com/pycqa/pylint - rev: v2.13.7 + rev: v2.13.8 hooks: - id: pylint additional_dependencies: From d68cacfeda5599c62a593ecb9da2505c22326644 Mon Sep 17 00:00:00 2001 From: John Villalovos Date: Mon, 9 May 2022 14:53:32 -0700 Subject: [PATCH 008/884] fix(cli): changed default `allow_abbrev` value to fix arguments collision problem (#2013) fix(cli): change default `allow_abbrev` value to fix argument collision --- gitlab/cli.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gitlab/cli.py b/gitlab/cli.py index f06f49d94..cad6b6fd5 100644 --- a/gitlab/cli.py +++ b/gitlab/cli.py @@ -106,7 +106,9 @@ def cls_to_what(cls: RESTObject) -> str: def _get_base_parser(add_help: bool = True) -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - add_help=add_help, description="GitLab API Command Line Interface" + add_help=add_help, + description="GitLab API Command Line Interface", + allow_abbrev=False, ) parser.add_argument("--version", help="Display the version.", action="store_true") parser.add_argument( From 78b4f995afe99c530858b7b62d3eee620f3488f2 Mon Sep 17 00:00:00 2001 From: "John L. Villalovos" Date: Tue, 10 May 2022 08:43:45 -0700 Subject: [PATCH 009/884] chore: rename the test which runs `flake8` to be `flake8` Previously the test was called `pep8`. The test only runs `flake8` so call it `flake8` to be more precise. --- .github/workflows/lint.yml | 2 +- tox.ini | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 92ba2f29b..21d6beb52 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -32,7 +32,7 @@ jobs: - name: Run black code formatter (https://black.readthedocs.io/en/stable/) run: tox -e black -- --check - name: Run flake8 (https://flake8.pycqa.org/en/latest/) - run: tox -e pep8 + run: tox -e flake8 - name: Run mypy static typing checker (http://mypy-lang.org/) run: tox -e mypy - name: Run isort import order checker (https://pycqa.github.io/isort/) diff --git a/tox.ini b/tox.ini index 4c197abaf..2585f122b 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,7 @@ minversion = 1.6 skipsdist = True skip_missing_interpreters = True -envlist = py310,py39,py38,py37,pep8,black,twine-check,mypy,isort,cz +envlist = py310,py39,py38,py37,flake8,black,twine-check,mypy,isort,cz [testenv] passenv = GITLAB_IMAGE GITLAB_TAG PY_COLORS NO_COLOR FORCE_COLOR @@ -38,7 +38,7 @@ deps = -r{toxinidir}/requirements-lint.txt commands = mypy {posargs} -[testenv:pep8] +[testenv:flake8] basepython = python3 envdir={toxworkdir}/lint deps = -r{toxinidir}/requirements-lint.txt From 55ace1d67e75fae9d74b4a67129ff842de7e1377 Mon Sep 17 00:00:00 2001 From: "John L. Villalovos" Date: Tue, 10 May 2022 08:40:16 -0700 Subject: [PATCH 010/884] chore: run the `pylint` check by default in tox Since we require `pylint` to pass in the CI. Let's run it by default in tox. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 2585f122b..8e67068f6 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,7 @@ minversion = 1.6 skipsdist = True skip_missing_interpreters = True -envlist = py310,py39,py38,py37,flake8,black,twine-check,mypy,isort,cz +envlist = py310,py39,py38,py37,flake8,black,twine-check,mypy,isort,cz,pylint [testenv] passenv = GITLAB_IMAGE GITLAB_TAG PY_COLORS NO_COLOR FORCE_COLOR From fa47829056a71e6b9b7f2ce913f2aebc36dc69e9 Mon Sep 17 00:00:00 2001 From: Robin Berger Date: Sat, 7 May 2022 10:00:00 +0200 Subject: [PATCH 011/884] test(projects): add tests for list project methods --- tests/unit/objects/test_projects.py | 136 ++++++++++++++++++++++++---- 1 file changed, 116 insertions(+), 20 deletions(-) diff --git a/tests/unit/objects/test_projects.py b/tests/unit/objects/test_projects.py index 60693dec8..d0f588467 100644 --- a/tests/unit/objects/test_projects.py +++ b/tests/unit/objects/test_projects.py @@ -5,9 +5,30 @@ import pytest import responses -from gitlab.v4.objects import Project +from gitlab.v4.objects import ( + Project, + ProjectFork, + ProjectUser, + StarredProject, + UserProject, +) project_content = {"name": "name", "id": 1} +languages_content = { + "python": 80.00, + "ruby": 99.99, + "CoffeeScript": 0.01, +} +user_content = { + "name": "first", + "id": 1, + "state": "active", +} +forks_content = [ + { + "id": 1, + }, +] import_content = { "id": 1, "name": "project", @@ -28,6 +49,71 @@ def resp_get_project(): yield rsps +@pytest.fixture +def resp_user_projects(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/users/1/projects", + json=[project_content], + content_type="application/json", + status=200, + ) + yield rsps + + +@pytest.fixture +def resp_starred_projects(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/users/1/starred_projects", + json=[project_content], + content_type="application/json", + status=200, + ) + yield rsps + + +@pytest.fixture +def resp_list_users(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/projects/1/users", + json=[user_content], + content_type="application/json", + status=200, + ) + yield rsps + + +@pytest.fixture +def resp_list_forks(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/projects/1/forks", + json=forks_content, + content_type="application/json", + status=200, + ) + yield rsps + + +@pytest.fixture +def resp_list_languages(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/projects/1/languages", + json=languages_content, + content_type="application/json", + status=200, + ) + yield rsps + + @pytest.fixture def resp_list_projects(): with responses.RequestsMock() as rsps: @@ -98,19 +184,26 @@ def test_import_bitbucket_server(gl, resp_import_bitbucket_server): assert res["import_status"] == "scheduled" -@pytest.mark.skip(reason="missing test") -def test_list_user_projects(gl): - pass +def test_list_user_projects(user, resp_user_projects): + user_project = user.projects.list()[0] + assert isinstance(user_project, UserProject) + assert user_project.name == "name" + assert user_project.id == 1 -@pytest.mark.skip(reason="missing test") -def test_list_user_starred_projects(gl): - pass +def test_list_user_starred_projects(user, resp_starred_projects): + starred_projects = user.starred_projects.list()[0] + assert isinstance(starred_projects, StarredProject) + assert starred_projects.name == "name" + assert starred_projects.id == 1 -@pytest.mark.skip(reason="missing test") -def test_list_project_users(gl): - pass +def test_list_project_users(project, resp_list_users): + user = project.users.list()[0] + assert isinstance(user, ProjectUser) + assert user.id == 1 + assert user.name == "first" + assert user.state == "active" @pytest.mark.skip(reason="missing test") @@ -133,9 +226,10 @@ def test_fork_project(gl): pass -@pytest.mark.skip(reason="missing test") -def test_list_project_forks(gl): - pass +def test_list_project_forks(project, resp_list_forks): + fork = project.forks.list()[0] + assert isinstance(fork, ProjectFork) + assert fork.id == 1 @pytest.mark.skip(reason="missing test") @@ -153,9 +247,13 @@ def test_list_project_starrers(gl): pass -@pytest.mark.skip(reason="missing test") -def test_get_project_languages(gl): - pass +def test_get_project_languages(project, resp_list_languages): + python = project.languages().get("python") + ruby = project.languages().get("ruby") + coffee_script = project.languages().get("CoffeeScript") + assert python == 80.00 + assert ruby == 99.99 + assert coffee_script == 00.01 @pytest.mark.skip(reason="missing test") @@ -233,13 +331,11 @@ def test_delete_project_push_rule(gl): pass -def test_transfer_project(gl, resp_transfer_project): - project = gl.projects.get(1, lazy=True) +def test_transfer_project(project, resp_transfer_project): project.transfer("test-namespace") -def test_transfer_project_deprecated_warns(gl, resp_transfer_project): - project = gl.projects.get(1, lazy=True) +def test_transfer_project_deprecated_warns(project, resp_transfer_project): with pytest.warns(DeprecationWarning): project.transfer_project("test-namespace") From 422495073492fd52f4f3b854955c620ada4c1daa Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 23 May 2022 01:20:24 +0000 Subject: [PATCH 012/884] chore(deps): update dependency pylint to v2.13.9 --- requirements-lint.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-lint.txt b/requirements-lint.txt index 774cc6b71..990445271 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -4,7 +4,7 @@ commitizen==2.24.0 flake8==4.0.1 isort==5.10.1 mypy==0.950 -pylint==2.13.8 +pylint==2.13.9 pytest==7.1.2 types-PyYAML==6.0.7 types-requests==2.27.25 From 1e2279028533c3dc15995443362e290a4d2c6ae0 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 23 May 2022 01:20:28 +0000 Subject: [PATCH 013/884] chore(deps): update pre-commit hook pycqa/pylint to v2.13.9 --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index be18a2e75..dfe92e21b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: hooks: - id: isort - repo: https://github.com/pycqa/pylint - rev: v2.13.8 + rev: v2.13.9 hooks: - id: pylint additional_dependencies: From aad71d282d60dc328b364bcc951d0c9b44ab13fa Mon Sep 17 00:00:00 2001 From: Michael Sweikata Date: Mon, 23 May 2022 12:13:09 -0400 Subject: [PATCH 014/884] docs: update issue example and extend API usage docs --- docs/api-usage.rst | 11 +++++++++++ docs/gl_objects/issues.rst | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/docs/api-usage.rst b/docs/api-usage.rst index e39082d2b..06c186cc9 100644 --- a/docs/api-usage.rst +++ b/docs/api-usage.rst @@ -192,6 +192,17 @@ You can print a Gitlab Object. For example: # Or explicitly via `pformat()`. This is equivalent to the above. print(project.pformat()) +You can also extend the object if the parameter isn't explicitly listed. For example, +if you want to update a field that has been newly introduced to the Gitlab API, setting +the value on the object is accepted: + +.. code-block:: python + + issues = project.issues.list(state='opened') + for issue in issues: + issue.my_super_awesome_feature_flag = "random_value" + issue.save() + Base types ========== diff --git a/docs/gl_objects/issues.rst b/docs/gl_objects/issues.rst index dfb1ff7b5..40ce2d580 100644 --- a/docs/gl_objects/issues.rst +++ b/docs/gl_objects/issues.rst @@ -133,6 +133,17 @@ Delete an issue (admin or project owner only):: # pr issue.delete() + +Assign the issues:: + + issue = gl.issues.list()[0] + issue.assignee_ids = [25, 10, 31, 12] + issue.save() + +.. note:: + The Gitlab API explicitly references that the `assignee_id` field is deprecated, + so using a list of user IDs for `assignee_ids` is how to assign an issue to a user(s). + Subscribe / unsubscribe from an issue:: issue.subscribe() From 8867ee59884ae81d6457ad6e561a0573017cf6b2 Mon Sep 17 00:00:00 2001 From: Nejc Habjan Date: Fri, 27 May 2022 17:35:33 +0200 Subject: [PATCH 015/884] feat(objects): support get project storage endpoint --- docs/gl_objects/projects.rst | 27 +++++++++++++++++++++++++++ gitlab/v4/objects/projects.py | 19 +++++++++++++++++++ tests/functional/api/test_projects.py | 7 +++++++ tests/unit/objects/test_projects.py | 20 ++++++++++++++++++++ 4 files changed, 73 insertions(+) diff --git a/docs/gl_objects/projects.rst b/docs/gl_objects/projects.rst index 4bae08358..827ffbd4b 100644 --- a/docs/gl_objects/projects.rst +++ b/docs/gl_objects/projects.rst @@ -783,3 +783,30 @@ Get all additional statistics of a project:: Get total fetches in last 30 days of a project:: total_fetches = project.additionalstatistics.get().fetches['total'] + +Project storage +============================= + +This endpoint requires admin access. + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectStorage` + + :class:`gitlab.v4.objects.ProjectStorageManager` + + :attr:`gitlab.v4.objects.Project.storage` + +* GitLab API: https://docs.gitlab.com/ee/api/projects.html#get-the-path-to-repository-storage + +Examples +--------- + +Get the repository storage details for a project:: + + storage = project.storage.get() + +Get the repository storage disk path:: + + disk_path = project.storage.get().disk_path diff --git a/gitlab/v4/objects/projects.py b/gitlab/v4/objects/projects.py index b7df9ab0e..443eb3dc5 100644 --- a/gitlab/v4/objects/projects.py +++ b/gitlab/v4/objects/projects.py @@ -9,6 +9,7 @@ from gitlab.mixins import ( CreateMixin, CRUDMixin, + GetWithoutIdMixin, ListMixin, ObjectDeleteMixin, RefreshMixin, @@ -80,6 +81,8 @@ "ProjectForkManager", "ProjectRemoteMirror", "ProjectRemoteMirrorManager", + "ProjectStorage", + "ProjectStorageManager", ] @@ -180,6 +183,7 @@ class Project(RefreshMixin, SaveMixin, ObjectDeleteMixin, RepositoryMixin, RESTO runners: ProjectRunnerManager services: ProjectServiceManager snippets: ProjectSnippetManager + storage: "ProjectStorageManager" tags: ProjectTagManager triggers: ProjectTriggerManager users: ProjectUserManager @@ -1013,3 +1017,18 @@ class ProjectRemoteMirrorManager(ListMixin, CreateMixin, UpdateMixin, RESTManage required=("url",), optional=("enabled", "only_protected_branches") ) _update_attrs = RequiredOptional(optional=("enabled", "only_protected_branches")) + + +class ProjectStorage(RefreshMixin, RESTObject): + pass + + +class ProjectStorageManager(GetWithoutIdMixin, RESTManager): + _path = "/projects/{project_id}/storage" + _obj_cls = ProjectStorage + _from_parent_attrs = {"project_id": "id"} + + def get( + self, id: Optional[Union[int, str]] = None, **kwargs: Any + ) -> Optional[ProjectStorage]: + return cast(Optional[ProjectStorage], super().get(id=id, **kwargs)) diff --git a/tests/functional/api/test_projects.py b/tests/functional/api/test_projects.py index 8f8abbe86..50cc55422 100644 --- a/tests/functional/api/test_projects.py +++ b/tests/functional/api/test_projects.py @@ -3,6 +3,7 @@ import pytest import gitlab +from gitlab.v4.objects.projects import ProjectStorage def test_create_project(gl, user): @@ -285,6 +286,12 @@ def test_project_stars(project): assert project.star_count == 0 +def test_project_storage(project): + storage = project.storage.get() + assert isinstance(storage, ProjectStorage) + assert storage.repository_storage == "default" + + def test_project_tags(project, project_file): tag = project.tags.create({"tag_name": "v1.0", "ref": "main"}) assert len(project.tags.list()) == 1 diff --git a/tests/unit/objects/test_projects.py b/tests/unit/objects/test_projects.py index d0f588467..f964d114c 100644 --- a/tests/unit/objects/test_projects.py +++ b/tests/unit/objects/test_projects.py @@ -12,6 +12,7 @@ StarredProject, UserProject, ) +from gitlab.v4.objects.projects import ProjectStorage project_content = {"name": "name", "id": 1} languages_content = { @@ -49,6 +50,19 @@ def resp_get_project(): yield rsps +@pytest.fixture +def resp_get_project_storage(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/projects/1/storage", + json={"project_id": 1, "disk_path": "/disk/path"}, + content_type="application/json", + status=200, + ) + yield rsps + + @pytest.fixture def resp_user_projects(): with responses.RequestsMock() as rsps: @@ -256,6 +270,12 @@ def test_get_project_languages(project, resp_list_languages): assert coffee_script == 00.01 +def test_get_project_storage(project, resp_get_project_storage): + storage = project.storage.get() + assert isinstance(storage, ProjectStorage) + assert storage.disk_path == "/disk/path" + + @pytest.mark.skip(reason="missing test") def test_archive_project(gl): pass From 0ea61ccecae334c88798f80b6451c58f2fbb77c6 Mon Sep 17 00:00:00 2001 From: Nejc Habjan Date: Sat, 28 May 2022 09:17:26 +0200 Subject: [PATCH 016/884] chore(ci): pin semantic-release version --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a266662e8..1e995c3bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: fetch-depth: 0 token: ${{ secrets.RELEASE_GITHUB_TOKEN }} - name: Python Semantic Release - uses: relekang/python-semantic-release@master + uses: relekang/python-semantic-release@7.28.1 with: github_token: ${{ secrets.RELEASE_GITHUB_TOKEN }} pypi_token: ${{ secrets.PYPI_TOKEN }} From 1c021892e94498dbb6b3fa824d6d8c697fb4db7f Mon Sep 17 00:00:00 2001 From: Nejc Habjan Date: Sat, 28 May 2022 17:35:17 +0200 Subject: [PATCH 017/884] chore(ci): fix prefix for action version --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e995c3bc..d8e688d09 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: fetch-depth: 0 token: ${{ secrets.RELEASE_GITHUB_TOKEN }} - name: Python Semantic Release - uses: relekang/python-semantic-release@7.28.1 + uses: relekang/python-semantic-release@v7.28.1 with: github_token: ${{ secrets.RELEASE_GITHUB_TOKEN }} pypi_token: ${{ secrets.PYPI_TOKEN }} From 387a14028b809538530f56f136436c783667d0f1 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 28 May 2022 15:53:30 +0000 Subject: [PATCH 018/884] chore: release v3.5.0 --- CHANGELOG.md | 16 ++++++++++++++++ gitlab/_version.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 245e53c0a..027a4f8e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ +## v3.5.0 (2022-05-28) +### Feature +* **objects:** Support get project storage endpoint ([`8867ee5`](https://github.com/python-gitlab/python-gitlab/commit/8867ee59884ae81d6457ad6e561a0573017cf6b2)) +* Display human-readable attribute in `repr()` if present ([`6b47c26`](https://github.com/python-gitlab/python-gitlab/commit/6b47c26d053fe352d68eb22a1eaf4b9a3c1c93e7)) +* **ux:** Display project.name_with_namespace on project repr ([`e598762`](https://github.com/python-gitlab/python-gitlab/commit/e5987626ca1643521b16658555f088412be2a339)) + +### Fix +* **cli:** Changed default `allow_abbrev` value to fix arguments collision problem ([#2013](https://github.com/python-gitlab/python-gitlab/issues/2013)) ([`d68cacf`](https://github.com/python-gitlab/python-gitlab/commit/d68cacfeda5599c62a593ecb9da2505c22326644)) +* Duplicate subparsers being added to argparse ([`f553fd3`](https://github.com/python-gitlab/python-gitlab/commit/f553fd3c79579ab596230edea5899dc5189b0ac6)) + +### Documentation +* Update issue example and extend API usage docs ([`aad71d2`](https://github.com/python-gitlab/python-gitlab/commit/aad71d282d60dc328b364bcc951d0c9b44ab13fa)) +* **CONTRIBUTING.rst:** Fix link to conventional-changelog commit format documentation ([`2373a4f`](https://github.com/python-gitlab/python-gitlab/commit/2373a4f13ee4e5279a424416cdf46782a5627067)) +* Add missing Admin access const value ([`3e0d4d9`](https://github.com/python-gitlab/python-gitlab/commit/3e0d4d9006e2ca6effae2b01cef3926dd0850e52)) +* **merge_requests:** Add new possible merge request state and link to the upstream docs ([`e660fa8`](https://github.com/python-gitlab/python-gitlab/commit/e660fa8386ed7783da5c076bc0fef83e6a66f9a8)) + ## v3.4.0 (2022-04-28) ### Feature * Emit a warning when using a `list()` method returns max ([`1339d64`](https://github.com/python-gitlab/python-gitlab/commit/1339d645ce58a2e1198b898b9549ba5917b1ff12)) diff --git a/gitlab/_version.py b/gitlab/_version.py index 8949179af..9b6ab520f 100644 --- a/gitlab/_version.py +++ b/gitlab/_version.py @@ -3,4 +3,4 @@ __email__ = "gauvainpocentek@gmail.com" __license__ = "LGPL3" __title__ = "python-gitlab" -__version__ = "3.4.0" +__version__ = "3.5.0" From 09b3b2225361722f2439952d2dbee6a48a9f9fd9 Mon Sep 17 00:00:00 2001 From: Nejc Habjan Date: Sun, 8 May 2022 01:14:52 +0200 Subject: [PATCH 019/884] refactor(mixins): extract custom type transforms into utils --- gitlab/mixins.py | 48 ++++------------------------------------ gitlab/utils.py | 37 ++++++++++++++++++++++++++++++- tests/unit/test_utils.py | 29 +++++++++++++++++++++++- 3 files changed, 68 insertions(+), 46 deletions(-) diff --git a/gitlab/mixins.py b/gitlab/mixins.py index 1a3ff4dbf..a29c7a782 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -33,7 +33,6 @@ import gitlab from gitlab import base, cli from gitlab import exceptions as exc -from gitlab import types as g_types from gitlab import utils __all__ = [ @@ -214,8 +213,8 @@ def list(self, **kwargs: Any) -> Union[base.RESTObjectList, List[base.RESTObject GitlabListError: If the server cannot perform the request """ - # Duplicate data to avoid messing with what the user sent us - data = kwargs.copy() + data, _ = utils._transform_types(kwargs, self._types, transform_files=False) + if self.gitlab.per_page: data.setdefault("per_page", self.gitlab.per_page) @@ -226,13 +225,6 @@ def list(self, **kwargs: Any) -> Union[base.RESTObjectList, List[base.RESTObject if self.gitlab.order_by: data.setdefault("order_by", self.gitlab.order_by) - # We get the attributes that need some special transformation - if self._types: - for attr_name, type_cls in self._types.items(): - if attr_name in data.keys(): - type_obj = type_cls(data[attr_name]) - data[attr_name] = type_obj.get_for_api() - # Allow to overwrite the path, handy for custom listings path = data.pop("path", self.path) @@ -298,23 +290,7 @@ def create( data = {} self._check_missing_create_attrs(data) - files = {} - - # We get the attributes that need some special transformation - if self._types: - # Duplicate data to avoid messing with what the user sent us - data = data.copy() - for attr_name, type_cls in self._types.items(): - if attr_name in data.keys(): - type_obj = type_cls(data[attr_name]) - - # if the type if FileAttribute we need to pass the data as - # file - if isinstance(type_obj, g_types.FileAttribute): - k = type_obj.get_file_name(attr_name) - files[attr_name] = (k, data.pop(attr_name)) - else: - data[attr_name] = type_obj.get_for_api() + data, files = utils._transform_types(data, self._types) # Handle specific URL for creation path = kwargs.pop("path", self.path) @@ -394,23 +370,7 @@ def update( path = f"{self.path}/{utils.EncodedId(id)}" self._check_missing_update_attrs(new_data) - files = {} - - # We get the attributes that need some special transformation - if self._types: - # Duplicate data to avoid messing with what the user sent us - new_data = new_data.copy() - for attr_name, type_cls in self._types.items(): - if attr_name in new_data.keys(): - type_obj = type_cls(new_data[attr_name]) - - # if the type if FileAttribute we need to pass the data as - # file - if isinstance(type_obj, g_types.FileAttribute): - k = type_obj.get_file_name(attr_name) - files[attr_name] = (k, new_data.pop(attr_name)) - else: - new_data[attr_name] = type_obj.get_for_api() + new_data, files = utils._transform_types(new_data, self._types) http_method = self._get_update_method() result = http_method(path, post_data=new_data, files=files, **kwargs) diff --git a/gitlab/utils.py b/gitlab/utils.py index 197935549..a05cb22fa 100644 --- a/gitlab/utils.py +++ b/gitlab/utils.py @@ -19,10 +19,12 @@ import traceback import urllib.parse import warnings -from typing import Any, Callable, Dict, Optional, Type, Union +from typing import Any, Callable, Dict, Optional, Tuple, Type, Union import requests +from gitlab import types + class _StdoutStream: def __call__(self, chunk: Any) -> None: @@ -47,6 +49,39 @@ def response_content( return None +def _transform_types( + data: Dict[str, Any], custom_types: dict, *, transform_files: Optional[bool] = True +) -> Tuple[dict, dict]: + """Copy the data dict with attributes that have custom types and transform them + before being sent to the server. + + If ``transform_files`` is ``True`` (default), also populates the ``files`` dict for + FileAttribute types with tuples to prepare fields for requests' MultipartEncoder: + https://toolbelt.readthedocs.io/en/latest/user.html#multipart-form-data-encoder + + Returns: + A tuple of the transformed data dict and files dict""" + + # Duplicate data to avoid messing with what the user sent us + data = data.copy() + files = {} + + for attr_name, type_cls in custom_types.items(): + if attr_name not in data: + continue + + type_obj = type_cls(data[attr_name]) + + # if the type if FileAttribute we need to pass the data as file + if transform_files and isinstance(type_obj, types.FileAttribute): + key = type_obj.get_file_name(attr_name) + files[attr_name] = (key, data.pop(attr_name)) + else: + data[attr_name] = type_obj.get_for_api() + + return data, files + + def copy_dict( *, src: Dict[str, Any], diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 7641c6979..3a92604bc 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -18,7 +18,7 @@ import json import warnings -from gitlab import utils +from gitlab import types, utils class TestEncodedId: @@ -95,3 +95,30 @@ def test_warn(self): assert warn_message in str(warning.message) assert __file__ in str(warning.message) assert warn_source == warning.source + + +def test_transform_types_copies_data_with_empty_files(): + data = {"attr": "spam"} + new_data, files = utils._transform_types(data, {}) + + assert new_data is not data + assert new_data == data + assert files == {} + + +def test_transform_types_with_transform_files_populates_files(): + custom_types = {"attr": types.FileAttribute} + data = {"attr": "spam"} + new_data, files = utils._transform_types(data, custom_types) + + assert new_data == {} + assert files["attr"] == ("attr", "spam") + + +def test_transform_types_without_transform_files_populates_data_with_empty_files(): + custom_types = {"attr": types.FileAttribute} + data = {"attr": "spam"} + new_data, files = utils._transform_types(data, custom_types, transform_files=False) + + assert new_data == {"attr": "spam"} + assert files == {} From de8c6e80af218d93ca167f8b5ff30319a2781d91 Mon Sep 17 00:00:00 2001 From: "John L. Villalovos" Date: Sun, 29 May 2022 09:52:24 -0700 Subject: [PATCH 020/884] docs: use `as_list=False` or `all=True` in Getting started In the "Getting started with the API" section of the documentation, use either `as_list=False` or `all=True` in the example usages of the `list()` method. Also add a warning about the fact that `list()` by default does not return all items. --- docs/api-usage.rst | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/api-usage.rst b/docs/api-usage.rst index 06c186cc9..b072d295d 100644 --- a/docs/api-usage.rst +++ b/docs/api-usage.rst @@ -93,13 +93,13 @@ Examples: .. code-block:: python # list all the projects - projects = gl.projects.list() + projects = gl.projects.list(as_list=False) for project in projects: print(project) # get the group with id == 2 group = gl.groups.get(2) - for project in group.projects.list(): + for project in group.projects.list(as_list=False): print(project) # create a new user @@ -107,6 +107,12 @@ Examples: user = gl.users.create(user_data) print(user) +.. warning:: + Calling ``list()`` without any arguments will by default not return the complete list + of items. Use either the ``all=True`` or ``as_list=False`` parameters to get all the + items when using listing methods. See the :ref:`pagination` section for more + information. + You can list the mandatory and optional attributes for object creation and update with the manager's ``get_create_attrs()`` and ``get_update_attrs()`` methods. They return 2 tuples, the first one is the list of mandatory @@ -133,7 +139,7 @@ Some objects also provide managers to access related GitLab resources: # list the issues for a project project = gl.projects.get(1) - issues = project.issues.list() + issues = project.issues.list(all=True) python-gitlab allows to send any data to the GitLab server when making queries. In case of invalid or missing arguments python-gitlab will raise an exception @@ -150,9 +156,9 @@ conflict with python or python-gitlab when using them as kwargs: .. code-block:: python - gl.user_activities.list(from='2019-01-01') ## invalid + gl.user_activities.list(from='2019-01-01', as_list=False) ## invalid - gl.user_activities.list(query_parameters={'from': '2019-01-01'}) # OK + gl.user_activities.list(query_parameters={'from': '2019-01-01'}, as_list=False) # OK Gitlab Objects ============== @@ -233,6 +239,8 @@ a project (the previous example used 2 API calls): project = gl.projects.get(1, lazy=True) # no API call project.star() # API call +.. _pagination: + Pagination ========== From cdc6605767316ea59e1e1b849683be7b3b99e0ae Mon Sep 17 00:00:00 2001 From: "John L. Villalovos" Date: Sun, 29 May 2022 15:50:19 -0700 Subject: [PATCH 021/884] feat(client): introduce `iterator=True` and deprecate `as_list=False` in `list()` `as_list=False` is confusing as it doesn't explain what is being returned. Replace it with `iterator=True` which more clearly explains to the user that an iterator/generator will be returned. This maintains backward compatibility with `as_list` but does issue a DeprecationWarning if `as_list` is set. --- docs/api-usage.rst | 22 +++++++++++------- docs/gl_objects/search.rst | 4 ++-- docs/gl_objects/users.rst | 2 +- gitlab/client.py | 31 +++++++++++++++++++------ gitlab/mixins.py | 6 ++--- gitlab/v4/objects/ldap.py | 4 ++-- gitlab/v4/objects/merge_requests.py | 8 ++----- gitlab/v4/objects/milestones.py | 16 ++++--------- gitlab/v4/objects/repositories.py | 4 ++-- gitlab/v4/objects/runners.py | 2 +- gitlab/v4/objects/users.py | 4 ++-- tests/functional/api/test_gitlab.py | 17 +++++++++++--- tests/functional/api/test_projects.py | 2 +- tests/unit/mixins/test_mixin_methods.py | 4 ++-- tests/unit/test_gitlab.py | 8 +++---- tests/unit/test_gitlab_http_methods.py | 28 ++++++++++++++++++---- 16 files changed, 99 insertions(+), 63 deletions(-) diff --git a/docs/api-usage.rst b/docs/api-usage.rst index b072d295d..aa6c4fe2c 100644 --- a/docs/api-usage.rst +++ b/docs/api-usage.rst @@ -93,13 +93,13 @@ Examples: .. code-block:: python # list all the projects - projects = gl.projects.list(as_list=False) + projects = gl.projects.list(iterator=True) for project in projects: print(project) # get the group with id == 2 group = gl.groups.get(2) - for project in group.projects.list(as_list=False): + for project in group.projects.list(iterator=True): print(project) # create a new user @@ -109,7 +109,7 @@ Examples: .. warning:: Calling ``list()`` without any arguments will by default not return the complete list - of items. Use either the ``all=True`` or ``as_list=False`` parameters to get all the + of items. Use either the ``all=True`` or ``iterator=True`` parameters to get all the items when using listing methods. See the :ref:`pagination` section for more information. @@ -156,9 +156,9 @@ conflict with python or python-gitlab when using them as kwargs: .. code-block:: python - gl.user_activities.list(from='2019-01-01', as_list=False) ## invalid + gl.user_activities.list(from='2019-01-01', iterator=True) ## invalid - gl.user_activities.list(query_parameters={'from': '2019-01-01'}, as_list=False) # OK + gl.user_activities.list(query_parameters={'from': '2019-01-01'}, iterator=True) # OK Gitlab Objects ============== @@ -282,13 +282,13 @@ order options. At the time of writing, only ``order_by="id"`` works. Reference: https://docs.gitlab.com/ce/api/README.html#keyset-based-pagination -``list()`` methods can also return a generator object which will handle the -next calls to the API when required. This is the recommended way to iterate -through a large number of items: +``list()`` methods can also return a generator object, by passing the argument +``iterator=True``, which will handle the next calls to the API when required. This +is the recommended way to iterate through a large number of items: .. code-block:: python - items = gl.groups.list(as_list=False) + items = gl.groups.list(iterator=True) for item in items: print(item.attributes) @@ -310,6 +310,10 @@ The generator exposes extra listing information as received from the server: For more information see: https://docs.gitlab.com/ee/user/gitlab_com/index.html#pagination-response-headers +.. note:: + Prior to python-gitlab 3.6.0 the argument ``as_list`` was used instead of + ``iterator``. ``as_list=False`` is the equivalent of ``iterator=True``. + Sudo ==== diff --git a/docs/gl_objects/search.rst b/docs/gl_objects/search.rst index 4030a531a..44773099d 100644 --- a/docs/gl_objects/search.rst +++ b/docs/gl_objects/search.rst @@ -63,13 +63,13 @@ The ``search()`` methods implement the pagination support:: # get a generator that will automatically make required API calls for # pagination - for item in gl.search(gitlab.const.SEARCH_SCOPE_ISSUES, search_str, as_list=False): + for item in gl.search(gitlab.const.SEARCH_SCOPE_ISSUES, search_str, iterator=True): do_something(item) The search API doesn't return objects, but dicts. If you need to act on objects, you need to create them explicitly:: - for item in gl.search(gitlab.const.SEARCH_SCOPE_ISSUES, search_str, as_list=False): + for item in gl.search(gitlab.const.SEARCH_SCOPE_ISSUES, search_str, iterator=True): issue_project = gl.projects.get(item['project_id'], lazy=True) issue = issue_project.issues.get(item['iid']) issue.state = 'closed' diff --git a/docs/gl_objects/users.rst b/docs/gl_objects/users.rst index 7a169dc43..01efefa7e 100644 --- a/docs/gl_objects/users.rst +++ b/docs/gl_objects/users.rst @@ -413,4 +413,4 @@ Get the users activities:: activities = gl.user_activities.list( query_parameters={'from': '2018-07-01'}, - all=True, as_list=False) + all=True, iterator=True) diff --git a/gitlab/client.py b/gitlab/client.py index b8ac22223..2ac5158f6 100644 --- a/gitlab/client.py +++ b/gitlab/client.py @@ -807,7 +807,9 @@ def http_list( self, path: str, query_data: Optional[Dict[str, Any]] = None, - as_list: Optional[bool] = None, + *, + as_list: Optional[bool] = None, # Deprecated in favor of `iterator` + iterator: Optional[bool] = None, **kwargs: Any, ) -> Union["GitlabList", List[Dict[str, Any]]]: """Make a GET request to the Gitlab server for list-oriented queries. @@ -816,12 +818,13 @@ def http_list( path: Path or full URL to query ('/projects' or 'http://whatever/v4/api/projects') query_data: Data to send as query parameters + iterator: Indicate if should return a generator (True) **kwargs: Extra options to send to the server (e.g. sudo, page, per_page) Returns: - A list of the objects returned by the server. If `as_list` is - False and no pagination-related arguments (`page`, `per_page`, + A list of the objects returned by the server. If `iterator` is + True and no pagination-related arguments (`page`, `per_page`, `all`) are defined then a GitlabList object (generator) is returned instead. This object will make API calls when needed to fetch the next items from the server. @@ -832,15 +835,29 @@ def http_list( """ query_data = query_data or {} - # In case we want to change the default behavior at some point - as_list = True if as_list is None else as_list + # Don't allow both `as_list` and `iterator` to be set. + if as_list is not None and iterator is not None: + raise ValueError( + "Only one of `as_list` or `iterator` can be used. " + "Use `iterator` instead of `as_list`. `as_list` is deprecated." + ) + + if as_list is not None: + iterator = not as_list + utils.warn( + message=( + f"`as_list={as_list}` is deprecated and will be removed in a " + f"future version. Use `iterator={iterator}` instead." + ), + category=DeprecationWarning, + ) get_all = kwargs.pop("all", None) url = self._build_url(path) page = kwargs.get("page") - if as_list is False: + if iterator: # Generator requested return GitlabList(self, url, query_data, **kwargs) @@ -879,7 +896,7 @@ def should_emit_warning() -> bool: utils.warn( message=( f"Calling a `list()` method without specifying `all=True` or " - f"`as_list=False` will return a maximum of {gl_list.per_page} items. " + f"`iterator=True` will return a maximum of {gl_list.per_page} items. " f"Your query returned {len(items)} of {total_items} items. See " f"{_PAGINATION_URL} for more details. If this was done intentionally, " f"then this warning can be supressed by adding the argument " diff --git a/gitlab/mixins.py b/gitlab/mixins.py index a29c7a782..850ce8103 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -201,12 +201,12 @@ def list(self, **kwargs: Any) -> Union[base.RESTObjectList, List[base.RESTObject all: If True, return all the items, without pagination per_page: Number of items to retrieve per request page: ID of the page to return (starts with page 1) - as_list: If set to False and no pagination option is + iterator: If set to True and no pagination option is defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) Returns: - The list of objects, or a generator if `as_list` is False + The list of objects, or a generator if `iterator` is True Raises: GitlabAuthenticationError: If authentication is not correct @@ -846,8 +846,6 @@ def participants(self, **kwargs: Any) -> Dict[str, Any]: all: If True, return all the items, without pagination per_page: Number of items to retrieve per request page: ID of the page to return (starts with page 1) - as_list: If set to False and no pagination option is - defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) Raises: diff --git a/gitlab/v4/objects/ldap.py b/gitlab/v4/objects/ldap.py index 10667b476..4a01061c5 100644 --- a/gitlab/v4/objects/ldap.py +++ b/gitlab/v4/objects/ldap.py @@ -26,12 +26,12 @@ def list(self, **kwargs: Any) -> Union[List[LDAPGroup], RESTObjectList]: all: If True, return all the items, without pagination per_page: Number of items to retrieve per request page: ID of the page to return (starts with page 1) - as_list: If set to False and no pagination option is + iterator: If set to True and no pagination option is defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) Returns: - The list of objects, or a generator if `as_list` is False + The list of objects, or a generator if `iterator` is True Raises: GitlabAuthenticationError: If authentication is not correct diff --git a/gitlab/v4/objects/merge_requests.py b/gitlab/v4/objects/merge_requests.py index edd7d0195..a3c583bb5 100644 --- a/gitlab/v4/objects/merge_requests.py +++ b/gitlab/v4/objects/merge_requests.py @@ -199,8 +199,6 @@ def closes_issues(self, **kwargs: Any) -> RESTObjectList: all: If True, return all the items, without pagination per_page: Number of items to retrieve per request page: ID of the page to return (starts with page 1) - as_list: If set to False and no pagination option is - defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) Raises: @@ -211,7 +209,7 @@ def closes_issues(self, **kwargs: Any) -> RESTObjectList: List of issues """ path = f"{self.manager.path}/{self.encoded_id}/closes_issues" - data_list = self.manager.gitlab.http_list(path, as_list=False, **kwargs) + data_list = self.manager.gitlab.http_list(path, iterator=True, **kwargs) if TYPE_CHECKING: assert isinstance(data_list, gitlab.GitlabList) manager = ProjectIssueManager(self.manager.gitlab, parent=self.manager._parent) @@ -226,8 +224,6 @@ def commits(self, **kwargs: Any) -> RESTObjectList: all: If True, return all the items, without pagination per_page: Number of items to retrieve per request page: ID of the page to return (starts with page 1) - as_list: If set to False and no pagination option is - defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) Raises: @@ -239,7 +235,7 @@ def commits(self, **kwargs: Any) -> RESTObjectList: """ path = f"{self.manager.path}/{self.encoded_id}/commits" - data_list = self.manager.gitlab.http_list(path, as_list=False, **kwargs) + data_list = self.manager.gitlab.http_list(path, iterator=True, **kwargs) if TYPE_CHECKING: assert isinstance(data_list, gitlab.GitlabList) manager = ProjectCommitManager(self.manager.gitlab, parent=self.manager._parent) diff --git a/gitlab/v4/objects/milestones.py b/gitlab/v4/objects/milestones.py index e415330e4..0c4d74b59 100644 --- a/gitlab/v4/objects/milestones.py +++ b/gitlab/v4/objects/milestones.py @@ -33,8 +33,6 @@ def issues(self, **kwargs: Any) -> RESTObjectList: all: If True, return all the items, without pagination per_page: Number of items to retrieve per request page: ID of the page to return (starts with page 1) - as_list: If set to False and no pagination option is - defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) Raises: @@ -46,7 +44,7 @@ def issues(self, **kwargs: Any) -> RESTObjectList: """ path = f"{self.manager.path}/{self.encoded_id}/issues" - data_list = self.manager.gitlab.http_list(path, as_list=False, **kwargs) + data_list = self.manager.gitlab.http_list(path, iterator=True, **kwargs) if TYPE_CHECKING: assert isinstance(data_list, RESTObjectList) manager = GroupIssueManager(self.manager.gitlab, parent=self.manager._parent) @@ -62,8 +60,6 @@ def merge_requests(self, **kwargs: Any) -> RESTObjectList: all: If True, return all the items, without pagination per_page: Number of items to retrieve per request page: ID of the page to return (starts with page 1) - as_list: If set to False and no pagination option is - defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) Raises: @@ -74,7 +70,7 @@ def merge_requests(self, **kwargs: Any) -> RESTObjectList: The list of merge requests """ path = f"{self.manager.path}/{self.encoded_id}/merge_requests" - data_list = self.manager.gitlab.http_list(path, as_list=False, **kwargs) + data_list = self.manager.gitlab.http_list(path, iterator=True, **kwargs) if TYPE_CHECKING: assert isinstance(data_list, RESTObjectList) manager = GroupIssueManager(self.manager.gitlab, parent=self.manager._parent) @@ -114,8 +110,6 @@ def issues(self, **kwargs: Any) -> RESTObjectList: all: If True, return all the items, without pagination per_page: Number of items to retrieve per request page: ID of the page to return (starts with page 1) - as_list: If set to False and no pagination option is - defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) Raises: @@ -127,7 +121,7 @@ def issues(self, **kwargs: Any) -> RESTObjectList: """ path = f"{self.manager.path}/{self.encoded_id}/issues" - data_list = self.manager.gitlab.http_list(path, as_list=False, **kwargs) + data_list = self.manager.gitlab.http_list(path, iterator=True, **kwargs) if TYPE_CHECKING: assert isinstance(data_list, RESTObjectList) manager = ProjectIssueManager(self.manager.gitlab, parent=self.manager._parent) @@ -143,8 +137,6 @@ def merge_requests(self, **kwargs: Any) -> RESTObjectList: all: If True, return all the items, without pagination per_page: Number of items to retrieve per request page: ID of the page to return (starts with page 1) - as_list: If set to False and no pagination option is - defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) Raises: @@ -155,7 +147,7 @@ def merge_requests(self, **kwargs: Any) -> RESTObjectList: The list of merge requests """ path = f"{self.manager.path}/{self.encoded_id}/merge_requests" - data_list = self.manager.gitlab.http_list(path, as_list=False, **kwargs) + data_list = self.manager.gitlab.http_list(path, iterator=True, **kwargs) if TYPE_CHECKING: assert isinstance(data_list, RESTObjectList) manager = ProjectMergeRequestManager( diff --git a/gitlab/v4/objects/repositories.py b/gitlab/v4/objects/repositories.py index f2792b14e..5826d9d83 100644 --- a/gitlab/v4/objects/repositories.py +++ b/gitlab/v4/objects/repositories.py @@ -60,7 +60,7 @@ def repository_tree( all: If True, return all the items, without pagination per_page: Number of items to retrieve per request page: ID of the page to return (starts with page 1) - as_list: If set to False and no pagination option is + iterator: If set to True and no pagination option is defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) @@ -172,7 +172,7 @@ def repository_contributors( all: If True, return all the items, without pagination per_page: Number of items to retrieve per request page: ID of the page to return (starts with page 1) - as_list: If set to False and no pagination option is + iterator: If set to True and no pagination option is defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) diff --git a/gitlab/v4/objects/runners.py b/gitlab/v4/objects/runners.py index 665e7431b..51f68611a 100644 --- a/gitlab/v4/objects/runners.py +++ b/gitlab/v4/objects/runners.py @@ -81,7 +81,7 @@ def all(self, scope: Optional[str] = None, **kwargs: Any) -> List[Runner]: all: If True, return all the items, without pagination per_page: Number of items to retrieve per request page: ID of the page to return (starts with page 1) - as_list: If set to False and no pagination option is + iterator: If set to True and no pagination option is defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) diff --git a/gitlab/v4/objects/users.py b/gitlab/v4/objects/users.py index 09964b1a4..39c243a9f 100644 --- a/gitlab/v4/objects/users.py +++ b/gitlab/v4/objects/users.py @@ -542,12 +542,12 @@ def list(self, **kwargs: Any) -> Union[RESTObjectList, List[RESTObject]]: all: If True, return all the items, without pagination per_page: Number of items to retrieve per request page: ID of the page to return (starts with page 1) - as_list: If set to False and no pagination option is + iterator: If set to True and no pagination option is defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) Returns: - The list of objects, or a generator if `as_list` is False + The list of objects, or a generator if `iterator` is True Raises: GitlabAuthenticationError: If authentication is not correct diff --git a/tests/functional/api/test_gitlab.py b/tests/functional/api/test_gitlab.py index 4684e433b..c9a24a0bb 100644 --- a/tests/functional/api/test_gitlab.py +++ b/tests/functional/api/test_gitlab.py @@ -220,9 +220,20 @@ def test_list_all_true_nowarning(gl): assert len(items) > 20 -def test_list_as_list_false_nowarning(gl): - """Using `as_list=False` will disable the warning""" +def test_list_iterator_true_nowarning(gl): + """Using `iterator=True` will disable the warning""" with warnings.catch_warnings(record=True) as caught_warnings: - items = gl.gitlabciymls.list(as_list=False) + items = gl.gitlabciymls.list(iterator=True) assert len(caught_warnings) == 0 assert len(list(items)) > 20 + + +def test_list_as_list_false_warnings(gl): + """Using `as_list=False` will disable the UserWarning but cause a + DeprecationWarning""" + with warnings.catch_warnings(record=True) as caught_warnings: + items = gl.gitlabciymls.list(as_list=False) + assert len(caught_warnings) == 1 + for warning in caught_warnings: + assert isinstance(warning.message, DeprecationWarning) + assert len(list(items)) > 20 diff --git a/tests/functional/api/test_projects.py b/tests/functional/api/test_projects.py index 50cc55422..8d367de44 100644 --- a/tests/functional/api/test_projects.py +++ b/tests/functional/api/test_projects.py @@ -15,7 +15,7 @@ def test_create_project(gl, user): sudo_project = gl.projects.create({"name": "sudo_project"}, sudo=user.id) created = gl.projects.list() - created_gen = gl.projects.list(as_list=False) + created_gen = gl.projects.list(iterator=True) owned = gl.projects.list(owned=True) assert admin_project in created and sudo_project in created diff --git a/tests/unit/mixins/test_mixin_methods.py b/tests/unit/mixins/test_mixin_methods.py index 06cc3223b..241cba325 100644 --- a/tests/unit/mixins/test_mixin_methods.py +++ b/tests/unit/mixins/test_mixin_methods.py @@ -107,7 +107,7 @@ class M(ListMixin, FakeManager): # test RESTObjectList mgr = M(gl) - obj_list = mgr.list(as_list=False) + obj_list = mgr.list(iterator=True) assert isinstance(obj_list, base.RESTObjectList) for obj in obj_list: assert isinstance(obj, FakeObject) @@ -138,7 +138,7 @@ class M(ListMixin, FakeManager): ) mgr = M(gl) - obj_list = mgr.list(path="/others", as_list=False) + obj_list = mgr.list(path="/others", iterator=True) assert isinstance(obj_list, base.RESTObjectList) obj = obj_list.next() assert obj.id == 42 diff --git a/tests/unit/test_gitlab.py b/tests/unit/test_gitlab.py index 38266273e..44abfc182 100644 --- a/tests/unit/test_gitlab.py +++ b/tests/unit/test_gitlab.py @@ -87,7 +87,7 @@ def resp_page_2(): @responses.activate def test_gitlab_build_list(gl, resp_page_1, resp_page_2): responses.add(**resp_page_1) - obj = gl.http_list("/tests", as_list=False) + obj = gl.http_list("/tests", iterator=True) assert len(obj) == 2 assert obj._next_url == "http://localhost/api/v4/tests?per_page=1&page=2" assert obj.current_page == 1 @@ -122,7 +122,7 @@ def test_gitlab_build_list_missing_headers(gl, resp_page_1, resp_page_2): stripped_page_2 = _strip_pagination_headers(resp_page_2) responses.add(**stripped_page_1) - obj = gl.http_list("/tests", as_list=False) + obj = gl.http_list("/tests", iterator=True) assert len(obj) == 0 # Lazy generator has no knowledge of total items assert obj.total_pages is None assert obj.total is None @@ -133,10 +133,10 @@ def test_gitlab_build_list_missing_headers(gl, resp_page_1, resp_page_2): @responses.activate -def test_gitlab_all_omitted_when_as_list(gl, resp_page_1, resp_page_2): +def test_gitlab_all_omitted_when_iterator(gl, resp_page_1, resp_page_2): responses.add(**resp_page_1) responses.add(**resp_page_2) - result = gl.http_list("/tests", as_list=False, all=True) + result = gl.http_list("/tests", iterator=True, all=True) assert isinstance(result, gitlab.GitlabList) diff --git a/tests/unit/test_gitlab_http_methods.py b/tests/unit/test_gitlab_http_methods.py index 0f0d5d3f9..f3e298f72 100644 --- a/tests/unit/test_gitlab_http_methods.py +++ b/tests/unit/test_gitlab_http_methods.py @@ -438,12 +438,12 @@ def test_list_request(gl): ) with warnings.catch_warnings(record=True) as caught_warnings: - result = gl.http_list("/projects", as_list=True) + result = gl.http_list("/projects", iterator=False) assert len(caught_warnings) == 0 assert isinstance(result, list) assert len(result) == 1 - result = gl.http_list("/projects", as_list=False) + result = gl.http_list("/projects", iterator=True) assert isinstance(result, GitlabList) assert len(list(result)) == 1 @@ -484,12 +484,30 @@ def test_list_request(gl): } +@responses.activate +def test_as_list_deprecation_warning(gl): + responses.add(**large_list_response) + + with warnings.catch_warnings(record=True) as caught_warnings: + result = gl.http_list("/projects", as_list=False) + assert len(caught_warnings) == 1 + warning = caught_warnings[0] + assert isinstance(warning.message, DeprecationWarning) + message = str(warning.message) + assert "`as_list=False` is deprecated" in message + assert "Use `iterator=True` instead" in message + assert __file__ == warning.filename + assert not isinstance(result, list) + assert len(list(result)) == 20 + assert len(responses.calls) == 1 + + @responses.activate def test_list_request_pagination_warning(gl): responses.add(**large_list_response) with warnings.catch_warnings(record=True) as caught_warnings: - result = gl.http_list("/projects", as_list=True) + result = gl.http_list("/projects", iterator=False) assert len(caught_warnings) == 1 warning = caught_warnings[0] assert isinstance(warning.message, UserWarning) @@ -503,10 +521,10 @@ def test_list_request_pagination_warning(gl): @responses.activate -def test_list_request_as_list_false_nowarning(gl): +def test_list_request_iterator_true_nowarning(gl): responses.add(**large_list_response) with warnings.catch_warnings(record=True) as caught_warnings: - result = gl.http_list("/projects", as_list=False) + result = gl.http_list("/projects", iterator=True) assert len(caught_warnings) == 0 assert isinstance(result, GitlabList) assert len(list(result)) == 20 From df072e130aa145a368bbdd10be98208a25100f89 Mon Sep 17 00:00:00 2001 From: Nejc Habjan Date: Sun, 28 Nov 2021 00:50:49 +0100 Subject: [PATCH 022/884] test(gitlab): increase unit test coverage --- gitlab/client.py | 4 +- gitlab/config.py | 8 +-- tests/functional/cli/test_cli.py | 6 +++ tests/unit/helpers.py | 3 ++ tests/unit/mixins/test_mixin_methods.py | 55 +++++++++++++++++++++ tests/unit/test_base.py | 26 +++++++++- tests/unit/test_config.py | 66 +++++++++++++++++++++++-- tests/unit/test_exceptions.py | 12 +++++ tests/unit/test_gitlab.py | 61 ++++++++++++++++++++++- tests/unit/test_gitlab_http_methods.py | 44 ++++++++--------- tests/unit/test_utils.py | 52 +++++++++++++++++++ tox.ini | 1 + 12 files changed, 304 insertions(+), 34 deletions(-) diff --git a/gitlab/client.py b/gitlab/client.py index 2ac5158f6..bba5c1d24 100644 --- a/gitlab/client.py +++ b/gitlab/client.py @@ -208,7 +208,9 @@ def __setstate__(self, state: Dict[str, Any]) -> None: self.__dict__.update(state) # We only support v4 API at this time if self._api_version not in ("4",): - raise ModuleNotFoundError(name=f"gitlab.v{self._api_version}.objects") + raise ModuleNotFoundError( + name=f"gitlab.v{self._api_version}.objects" + ) # pragma: no cover, dead code currently # NOTE: We must delay import of gitlab.v4.objects until now or # otherwise it will cause circular import errors import gitlab.v4.objects diff --git a/gitlab/config.py b/gitlab/config.py index c85d7e5fa..337a26531 100644 --- a/gitlab/config.py +++ b/gitlab/config.py @@ -154,7 +154,7 @@ def _parse_config(self) -> None: # CA bundle. try: self.ssl_verify = _config.get("global", "ssl_verify") - except Exception: + except Exception: # pragma: no cover pass except Exception: pass @@ -166,7 +166,7 @@ def _parse_config(self) -> None: # CA bundle. try: self.ssl_verify = _config.get(self.gitlab_id, "ssl_verify") - except Exception: + except Exception: # pragma: no cover pass except Exception: pass @@ -197,7 +197,9 @@ def _parse_config(self) -> None: try: self.http_username = _config.get(self.gitlab_id, "http_username") - self.http_password = _config.get(self.gitlab_id, "http_password") + self.http_password = _config.get( + self.gitlab_id, "http_password" + ) # pragma: no cover except Exception: pass diff --git a/tests/functional/cli/test_cli.py b/tests/functional/cli/test_cli.py index a8890661f..0da50e6fe 100644 --- a/tests/functional/cli/test_cli.py +++ b/tests/functional/cli/test_cli.py @@ -27,6 +27,12 @@ def test_version(script_runner): assert ret.stdout.strip() == __version__ +def test_config_error_with_help_prints_help(script_runner): + ret = script_runner.run("gitlab", "-c", "invalid-file", "--help") + assert ret.stdout.startswith("usage:") + assert ret.returncode == 0 + + @pytest.mark.script_launch_mode("inprocess") @responses.activate def test_defaults_to_gitlab_com(script_runner, resp_get_project, monkeypatch): diff --git a/tests/unit/helpers.py b/tests/unit/helpers.py index 33a7c7824..54b2b7440 100644 --- a/tests/unit/helpers.py +++ b/tests/unit/helpers.py @@ -4,6 +4,9 @@ from typing import Optional import requests +import responses + +MATCH_EMPTY_QUERY_PARAMS = [responses.matchers.query_param_matcher({})] # NOTE: The function `httmock_response` and the class `Headers` is taken from diff --git a/tests/unit/mixins/test_mixin_methods.py b/tests/unit/mixins/test_mixin_methods.py index 241cba325..c0b0a580b 100644 --- a/tests/unit/mixins/test_mixin_methods.py +++ b/tests/unit/mixins/test_mixin_methods.py @@ -97,8 +97,17 @@ class M(ListMixin, FakeManager): pass url = "http://localhost/api/v4/tests" + headers = { + "X-Page": "1", + "X-Next-Page": "2", + "X-Per-Page": "1", + "X-Total-Pages": "2", + "X-Total": "2", + "Link": ("