diff options
-rw-r--r-- | gitlab/client.py | 30 | ||||
-rw-r--r-- | gitlab/mixins.py | 8 | ||||
-rw-r--r-- | gitlab/types.py | 45 | ||||
-rw-r--r-- | gitlab/v4/objects/deploy_tokens.py | 4 | ||||
-rw-r--r-- | gitlab/v4/objects/epics.py | 2 | ||||
-rw-r--r-- | gitlab/v4/objects/groups.py | 4 | ||||
-rw-r--r-- | gitlab/v4/objects/issues.py | 6 | ||||
-rw-r--r-- | gitlab/v4/objects/members.py | 4 | ||||
-rw-r--r-- | gitlab/v4/objects/merge_requests.py | 22 | ||||
-rw-r--r-- | gitlab/v4/objects/milestones.py | 4 | ||||
-rw-r--r-- | gitlab/v4/objects/projects.py | 7 | ||||
-rw-r--r-- | gitlab/v4/objects/runners.py | 6 | ||||
-rw-r--r-- | gitlab/v4/objects/settings.py | 12 | ||||
-rw-r--r-- | gitlab/v4/objects/users.py | 2 | ||||
-rw-r--r-- | tests/unit/test_types.py | 77 |
15 files changed, 178 insertions, 55 deletions
diff --git a/gitlab/client.py b/gitlab/client.py index b791c8f..a3e0cc4 100644 --- a/gitlab/client.py +++ b/gitlab/client.py @@ -16,6 +16,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. """Wrapper for the GitLab API.""" +import copy import os import time from typing import Any, cast, Dict, List, Optional, Tuple, TYPE_CHECKING, Union @@ -27,6 +28,7 @@ from requests_toolbelt.multipart.encoder import MultipartEncoder # type: ignore import gitlab.config import gitlab.const import gitlab.exceptions +from gitlab import types as gl_types from gitlab import utils REDIRECT_MSG = ( @@ -604,6 +606,28 @@ class Gitlab(object): return (post_data, None, "application/json") + @staticmethod + def _prepare_dict_for_api(*, in_dict: Dict[str, Any]) -> Dict[str, Any]: + result: Dict[str, Any] = {} + for key, value in in_dict.items(): + if isinstance(value, gl_types.GitlabAttribute): + result[key] = value.get_for_api() + else: + result[key] = copy.deepcopy(in_dict[key]) + return result + + @staticmethod + def _param_dict_to_param_tuples(*, params: Dict[str, Any]) -> List[Tuple[str, Any]]: + """Convert a dict to a list of key/values. This will be used to pass + values to requests""" + result: List[Tuple[str, Any]] = [] + for key, value in params.items(): + if isinstance(value, gl_types.GitlabAttribute): + result.extend(value.get_as_tuple_list(key=key)) + else: + result.append((key, value)) + return result + def http_request( self, verb: str, @@ -663,6 +687,10 @@ class Gitlab(object): else: utils.copy_dict(params, kwargs) + tuple_params = self._param_dict_to_param_tuples(params=params) + if isinstance(post_data, dict): + post_data = self._prepare_dict_for_api(in_dict=post_data) + opts = self._get_session_opts() verify = opts.pop("verify") @@ -682,7 +710,7 @@ class Gitlab(object): url=url, json=json, data=data, - params=params, + params=tuple_params, timeout=timeout, verify=verify, stream=streamed, diff --git a/gitlab/mixins.py b/gitlab/mixins.py index d66b2eb..9f75b54 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -230,8 +230,7 @@ class ListMixin(_RestManagerBase): 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() + data[attr_name] = type_cls(data[attr_name]) # Allow to overwrite the path, handy for custom listings path = data.pop("path", self.path) @@ -307,14 +306,13 @@ class CreateMixin(_RestManagerBase): 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[attr_name] = type_obj # Handle specific URL for creation path = kwargs.pop("path", self.path) @@ -410,7 +408,7 @@ class UpdateMixin(_RestManagerBase): 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[attr_name] = type_obj http_method = self._get_update_method() result = http_method(path, post_data=new_data, files=files, **kwargs) diff --git a/gitlab/types.py b/gitlab/types.py index 5a15090..b610104 100644 --- a/gitlab/types.py +++ b/gitlab/types.py @@ -15,7 +15,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -from typing import Any, Optional, TYPE_CHECKING +from typing import Any, List, Optional, Tuple, TYPE_CHECKING class GitlabAttribute(object): @@ -31,8 +31,43 @@ class GitlabAttribute(object): def get_for_api(self) -> Any: return self._value + def get_as_tuple_list(self, *, key: str) -> List[Tuple[str, Any]]: + return [(key, self._value)] + + +class ArrayAttribute(GitlabAttribute): + """To support `array` types as documented in + https://docs.gitlab.com/ee/api/#array""" + + def set_from_cli(self, cli_value: str) -> None: + if not cli_value.strip(): + self._value = [] + else: + self._value = [item.strip() for item in cli_value.split(",")] + + def get_for_api(self) -> str: + # Do not comma-split single value passed as string + if isinstance(self._value, str): + return self._value + + if TYPE_CHECKING: + assert isinstance(self._value, list) + return ",".join([str(x) for x in self._value]) + + def get_as_tuple_list(self, *, key: str) -> List[Tuple[str, str]]: + if isinstance(self._value, str): + return [(f"{key}[]", self._value)] + + if TYPE_CHECKING: + assert isinstance(self._value, list) + return [(f"{key}[]", str(value)) for value in self._value] + + +class CommaSeparatedListAttribute(GitlabAttribute): + """For values which are sent to the server as a Comma Separated Values + (CSV) string. We allow them to be specified as a list and we convert it + into a CSV""" -class ListAttribute(GitlabAttribute): def set_from_cli(self, cli_value: str) -> None: if not cli_value.strip(): self._value = [] @@ -48,11 +83,17 @@ class ListAttribute(GitlabAttribute): assert isinstance(self._value, list) return ",".join([str(x) for x in self._value]) + def get_as_tuple_list(self, *, key: str) -> List[Tuple[str, str]]: + return [(key, self.get_for_api())] + class LowercaseStringAttribute(GitlabAttribute): def get_for_api(self) -> str: return str(self._value).lower() + def get_as_tuple_list(self, *, key: str) -> List[Tuple[str, str]]: + return [(key, self.get_for_api())] + class FileAttribute(GitlabAttribute): def get_file_name(self, attr_name: Optional[str] = None) -> Optional[str]: diff --git a/gitlab/v4/objects/deploy_tokens.py b/gitlab/v4/objects/deploy_tokens.py index 97f3270..563c1d6 100644 --- a/gitlab/v4/objects/deploy_tokens.py +++ b/gitlab/v4/objects/deploy_tokens.py @@ -39,7 +39,7 @@ class GroupDeployTokenManager(ListMixin, CreateMixin, DeleteMixin, RESTManager): "username", ), ) - _types = {"scopes": types.ListAttribute} + _types = {"scopes": types.CommaSeparatedListAttribute} class ProjectDeployToken(ObjectDeleteMixin, RESTObject): @@ -60,4 +60,4 @@ class ProjectDeployTokenManager(ListMixin, CreateMixin, DeleteMixin, RESTManager "username", ), ) - _types = {"scopes": types.ListAttribute} + _types = {"scopes": types.CommaSeparatedListAttribute} diff --git a/gitlab/v4/objects/epics.py b/gitlab/v4/objects/epics.py index bb0bb79..d33821c 100644 --- a/gitlab/v4/objects/epics.py +++ b/gitlab/v4/objects/epics.py @@ -42,7 +42,7 @@ class GroupEpicManager(CRUDMixin, RESTManager): _update_attrs = RequiredOptional( optional=("title", "labels", "description", "start_date", "end_date"), ) - _types = {"labels": types.ListAttribute} + _types = {"labels": types.CommaSeparatedListAttribute} def get(self, id: Union[str, int], lazy: bool = False, **kwargs: Any) -> GroupEpic: return cast(GroupEpic, super().get(id=id, lazy=lazy, **kwargs)) diff --git a/gitlab/v4/objects/groups.py b/gitlab/v4/objects/groups.py index f2b90d1..99d10ce 100644 --- a/gitlab/v4/objects/groups.py +++ b/gitlab/v4/objects/groups.py @@ -314,7 +314,7 @@ class GroupManager(CRUDMixin, RESTManager): "shared_runners_setting", ), ) - _types = {"avatar": types.ImageAttribute, "skip_groups": types.ListAttribute} + _types = {"avatar": types.ImageAttribute, "skip_groups": types.ArrayAttribute} def get(self, id: Union[str, int], lazy: bool = False, **kwargs: Any) -> Group: return cast(Group, super().get(id=id, lazy=lazy, **kwargs)) @@ -374,7 +374,7 @@ class GroupSubgroupManager(ListMixin, RESTManager): "with_custom_attributes", "min_access_level", ) - _types = {"skip_groups": types.ListAttribute} + _types = {"skip_groups": types.ArrayAttribute} class GroupDescendantGroup(RESTObject): diff --git a/gitlab/v4/objects/issues.py b/gitlab/v4/objects/issues.py index 585e02e..f20252b 100644 --- a/gitlab/v4/objects/issues.py +++ b/gitlab/v4/objects/issues.py @@ -65,7 +65,7 @@ class IssueManager(RetrieveMixin, RESTManager): "updated_after", "updated_before", ) - _types = {"iids": types.ListAttribute, "labels": types.ListAttribute} + _types = {"iids": types.ArrayAttribute, "labels": types.CommaSeparatedListAttribute} def get(self, id: Union[str, int], lazy: bool = False, **kwargs: Any) -> Issue: return cast(Issue, super().get(id=id, lazy=lazy, **kwargs)) @@ -95,7 +95,7 @@ class GroupIssueManager(ListMixin, RESTManager): "updated_after", "updated_before", ) - _types = {"iids": types.ListAttribute, "labels": types.ListAttribute} + _types = {"iids": types.ArrayAttribute, "labels": types.CommaSeparatedListAttribute} class ProjectIssue( @@ -233,7 +233,7 @@ class ProjectIssueManager(CRUDMixin, RESTManager): "discussion_locked", ), ) - _types = {"iids": types.ListAttribute, "labels": types.ListAttribute} + _types = {"iids": types.ArrayAttribute, "labels": types.CommaSeparatedListAttribute} def get( self, id: Union[str, int], lazy: bool = False, **kwargs: Any diff --git a/gitlab/v4/objects/members.py b/gitlab/v4/objects/members.py index c7be039..5ee0b0e 100644 --- a/gitlab/v4/objects/members.py +++ b/gitlab/v4/objects/members.py @@ -41,7 +41,7 @@ class GroupMemberManager(CRUDMixin, RESTManager): _update_attrs = RequiredOptional( required=("access_level",), optional=("expires_at",) ) - _types = {"user_ids": types.ListAttribute} + _types = {"user_ids": types.ArrayAttribute} def get( self, id: Union[str, int], lazy: bool = False, **kwargs: Any @@ -101,7 +101,7 @@ class ProjectMemberManager(CRUDMixin, RESTManager): _update_attrs = RequiredOptional( required=("access_level",), optional=("expires_at",) ) - _types = {"user_ids": types.ListAttribute} + _types = {"user_ids": types.ArrayAttribute} def get( self, id: Union[str, int], lazy: bool = False, **kwargs: Any diff --git a/gitlab/v4/objects/merge_requests.py b/gitlab/v4/objects/merge_requests.py index 9a4f8c8..9f2368b 100644 --- a/gitlab/v4/objects/merge_requests.py +++ b/gitlab/v4/objects/merge_requests.py @@ -95,10 +95,10 @@ class MergeRequestManager(ListMixin, RESTManager): "deployed_after", ) _types = { - "approver_ids": types.ListAttribute, - "approved_by_ids": types.ListAttribute, - "in": types.ListAttribute, - "labels": types.ListAttribute, + "approver_ids": types.ArrayAttribute, + "approved_by_ids": types.ArrayAttribute, + "in": types.CommaSeparatedListAttribute, + "labels": types.CommaSeparatedListAttribute, } @@ -133,9 +133,9 @@ class GroupMergeRequestManager(ListMixin, RESTManager): "wip", ) _types = { - "approver_ids": types.ListAttribute, - "approved_by_ids": types.ListAttribute, - "labels": types.ListAttribute, + "approver_ids": types.ArrayAttribute, + "approved_by_ids": types.ArrayAttribute, + "labels": types.CommaSeparatedListAttribute, } @@ -455,10 +455,10 @@ class ProjectMergeRequestManager(CRUDMixin, RESTManager): "wip", ) _types = { - "approver_ids": types.ListAttribute, - "approved_by_ids": types.ListAttribute, - "iids": types.ListAttribute, - "labels": types.ListAttribute, + "approver_ids": types.ArrayAttribute, + "approved_by_ids": types.ArrayAttribute, + "iids": types.ArrayAttribute, + "labels": types.CommaSeparatedListAttribute, } def get( diff --git a/gitlab/v4/objects/milestones.py b/gitlab/v4/objects/milestones.py index 6b1e28d..da75826 100644 --- a/gitlab/v4/objects/milestones.py +++ b/gitlab/v4/objects/milestones.py @@ -93,7 +93,7 @@ class GroupMilestoneManager(CRUDMixin, RESTManager): optional=("title", "description", "due_date", "start_date", "state_event"), ) _list_filters = ("iids", "state", "search") - _types = {"iids": types.ListAttribute} + _types = {"iids": types.ArrayAttribute} def get( self, id: Union[str, int], lazy: bool = False, **kwargs: Any @@ -177,7 +177,7 @@ class ProjectMilestoneManager(CRUDMixin, RESTManager): optional=("title", "description", "due_date", "start_date", "state_event"), ) _list_filters = ("iids", "state", "search") - _types = {"iids": types.ListAttribute} + _types = {"iids": types.ArrayAttribute} def get( self, id: Union[str, int], lazy: bool = False, **kwargs: Any diff --git a/gitlab/v4/objects/projects.py b/gitlab/v4/objects/projects.py index f9988db..23f3d3c 100644 --- a/gitlab/v4/objects/projects.py +++ b/gitlab/v4/objects/projects.py @@ -125,7 +125,7 @@ class ProjectGroupManager(ListMixin, RESTManager): "shared_min_access_level", "shared_visible_only", ) - _types = {"skip_groups": types.ListAttribute} + _types = {"skip_groups": types.ArrayAttribute} class Project(RefreshMixin, SaveMixin, ObjectDeleteMixin, RepositoryMixin, RESTObject): @@ -807,7 +807,10 @@ class ProjectManager(CRUDMixin, RESTManager): "with_merge_requests_enabled", "with_programming_language", ) - _types = {"avatar": types.ImageAttribute, "topic": types.ListAttribute} + _types = { + "avatar": types.ImageAttribute, + "topic": types.CommaSeparatedListAttribute, + } def get(self, id: Union[str, int], lazy: bool = False, **kwargs: Any) -> Project: return cast(Project, super().get(id=id, lazy=lazy, **kwargs)) diff --git a/gitlab/v4/objects/runners.py b/gitlab/v4/objects/runners.py index d340b99..1826945 100644 --- a/gitlab/v4/objects/runners.py +++ b/gitlab/v4/objects/runners.py @@ -68,7 +68,7 @@ class RunnerManager(CRUDMixin, RESTManager): ), ) _list_filters = ("scope", "tag_list") - _types = {"tag_list": types.ListAttribute} + _types = {"tag_list": types.CommaSeparatedListAttribute} @cli.register_custom_action("RunnerManager", tuple(), ("scope",)) @exc.on_http_error(exc.GitlabListError) @@ -130,7 +130,7 @@ class GroupRunnerManager(ListMixin, RESTManager): _from_parent_attrs = {"group_id": "id"} _create_attrs = RequiredOptional(required=("runner_id",)) _list_filters = ("scope", "tag_list") - _types = {"tag_list": types.ListAttribute} + _types = {"tag_list": types.CommaSeparatedListAttribute} class ProjectRunner(ObjectDeleteMixin, RESTObject): @@ -143,4 +143,4 @@ class ProjectRunnerManager(CreateMixin, DeleteMixin, ListMixin, RESTManager): _from_parent_attrs = {"project_id": "id"} _create_attrs = RequiredOptional(required=("runner_id",)) _list_filters = ("scope", "tag_list") - _types = {"tag_list": types.ListAttribute} + _types = {"tag_list": types.CommaSeparatedListAttribute} diff --git a/gitlab/v4/objects/settings.py b/gitlab/v4/objects/settings.py index 96f2539..7612403 100644 --- a/gitlab/v4/objects/settings.py +++ b/gitlab/v4/objects/settings.py @@ -80,12 +80,12 @@ class ApplicationSettingsManager(GetWithoutIdMixin, UpdateMixin, RESTManager): ), ) _types = { - "asset_proxy_allowlist": types.ListAttribute, - "disabled_oauth_sign_in_sources": types.ListAttribute, - "domain_allowlist": types.ListAttribute, - "domain_denylist": types.ListAttribute, - "import_sources": types.ListAttribute, - "restricted_visibility_levels": types.ListAttribute, + "asset_proxy_allowlist": types.ArrayAttribute, + "disabled_oauth_sign_in_sources": types.ArrayAttribute, + "domain_allowlist": types.ArrayAttribute, + "domain_denylist": types.ArrayAttribute, + "import_sources": types.ArrayAttribute, + "restricted_visibility_levels": types.ArrayAttribute, } @exc.on_http_error(exc.GitlabUpdateError) diff --git a/gitlab/v4/objects/users.py b/gitlab/v4/objects/users.py index f5b8f6c..b2de337 100644 --- a/gitlab/v4/objects/users.py +++ b/gitlab/v4/objects/users.py @@ -369,7 +369,7 @@ class ProjectUserManager(ListMixin, RESTManager): _obj_cls = ProjectUser _from_parent_attrs = {"project_id": "id"} _list_filters = ("search", "skip_users") - _types = {"skip_users": types.ListAttribute} + _types = {"skip_users": types.ArrayAttribute} class UserEmail(ObjectDeleteMixin, RESTObject): diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index a2e5ff5..768db19 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -25,13 +25,15 @@ def test_gitlab_attribute_get(): o.set_from_cli("whatever2") assert o.get() == "whatever2" assert o.get_for_api() == "whatever2" + assert o.get_as_tuple_list(key="foo") == [("foo", "whatever2")] o = types.GitlabAttribute() assert o._value is None -def test_list_attribute_input(): - o = types.ListAttribute() +# ArrayAttribute tests +def test_array_attribute_input(): + o = types.ArrayAttribute() o.set_from_cli("foo,bar,baz") assert o.get() == ["foo", "bar", "baz"] @@ -39,8 +41,8 @@ def test_list_attribute_input(): assert o.get() == ["foo"] -def test_list_attribute_empty_input(): - o = types.ListAttribute() +def test_array_attribute_empty_input(): + o = types.ArrayAttribute() o.set_from_cli("") assert o.get() == [] @@ -48,27 +50,78 @@ def test_list_attribute_empty_input(): assert o.get() == [] -def test_list_attribute_get_for_api_from_cli(): - o = types.ListAttribute() +def test_array_attribute_get_for_api_from_cli(): + o = types.ArrayAttribute() o.set_from_cli("foo,bar,baz") assert o.get_for_api() == "foo,bar,baz" -def test_list_attribute_get_for_api_from_list(): - o = types.ListAttribute(["foo", "bar", "baz"]) +def test_array_attribute_get_for_api_from_list(): + o = types.ArrayAttribute(["foo", "bar", "baz"]) assert o.get_for_api() == "foo,bar,baz" -def test_list_attribute_get_for_api_from_int_list(): - o = types.ListAttribute([1, 9, 7]) +def test_array_attribute_get_for_api_from_int_list(): + o = types.ArrayAttribute([1, 9, 7]) assert o.get_for_api() == "1,9,7" -def test_list_attribute_does_not_split_string(): - o = types.ListAttribute("foo") +def test_array_attribute_get_as_tuple_list_from_list(): + o = types.ArrayAttribute(["foo", "bar", "baz"]) + assert o.get_as_tuple_list(key="identifier") == [ + ("identifier[]", "foo"), + ("identifier[]", "bar"), + ("identifier[]", "baz"), + ] + + +def test_array_attribute_get_as_tuple_list_from_int_list(): + o = types.ArrayAttribute([1, 9, 7]) + assert o.get_as_tuple_list(key="identifier") == [ + ("identifier[]", "1"), + ("identifier[]", "9"), + ("identifier[]", "7"), + ] + + +def test_array_attribute_does_not_split_string(): + o = types.ArrayAttribute("foo") assert o.get_for_api() == "foo" +# CommaSeparatedListAttribute tests +def test_csv_string_attribute_get_for_api_from_cli(): + o = types.CommaSeparatedListAttribute() + o.set_from_cli("foo,bar,baz") + assert o.get_for_api() == "foo,bar,baz" + + +def test_csv_string_attribute_get_for_api_from_list(): + o = types.CommaSeparatedListAttribute(["foo", "bar", "baz"]) + assert o.get_for_api() == "foo,bar,baz" + + +def test_csv_string_attribute_get_for_api_from_int_list(): + o = types.CommaSeparatedListAttribute([1, 9, 7]) + assert o.get_for_api() == "1,9,7" + + +def test_csv_string_attribute_get_as_tuple_list_from_list(): + o = types.CommaSeparatedListAttribute(["foo", "bar", "baz"]) + assert o.get_as_tuple_list(key="identifier") == [("identifier", "foo,bar,baz")] + + +def test_csv_string_attribute_get_as_tuple_list_from_int_list(): + o = types.CommaSeparatedListAttribute([1, 9, 7]) + assert o.get_as_tuple_list(key="identifier") == [("identifier", "1,9,7")] + + +# LowercaseStringAttribute tests def test_lowercase_string_attribute_get_for_api(): o = types.LowercaseStringAttribute("FOO") assert o.get_for_api() == "foo" + + +def test_lowercase_string_attribute_get_as_tuple(): + o = types.LowercaseStringAttribute("FOO") + assert o.get_as_tuple_list(key="user_name") == [("user_name", "foo")] |