diff options
Diffstat (limited to 'gitlab')
-rw-r--r-- | gitlab/__init__.py | 38 | ||||
-rw-r--r-- | gitlab/base.py | 2 | ||||
-rw-r--r-- | gitlab/config.py | 6 | ||||
-rw-r--r-- | gitlab/exceptions.py | 4 | ||||
-rw-r--r-- | gitlab/mixins.py | 5 | ||||
-rw-r--r-- | gitlab/tests/test_base.py | 3 | ||||
-rw-r--r-- | gitlab/tests/test_gitlab.py | 6 | ||||
-rw-r--r-- | gitlab/tests/test_gitlabobject.py | 44 | ||||
-rw-r--r-- | gitlab/tests/test_manager.py | 3 | ||||
-rw-r--r-- | gitlab/tests/test_mixins.py | 86 | ||||
-rw-r--r-- | gitlab/v3/objects.py | 15 | ||||
-rw-r--r-- | gitlab/v4/objects.py | 416 |
12 files changed, 422 insertions, 206 deletions
diff --git a/gitlab/__init__.py b/gitlab/__init__.py index aac4837..c909f9f 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -34,7 +34,7 @@ from gitlab.exceptions import * # noqa from gitlab.v3.objects import * # noqa __title__ = 'python-gitlab' -__version__ = '1.1.0' +__version__ = '1.2.0' __author__ = 'Gauvain Pocentek' __email__ = 'gauvain@pocentek.net' __license__ = 'LGPL3' @@ -73,7 +73,7 @@ class Gitlab(object): def __init__(self, url, private_token=None, oauth_token=None, email=None, password=None, ssl_verify=True, http_username=None, - http_password=None, timeout=None, api_version='3', + http_password=None, timeout=None, api_version='4', session=None): self._api_version = str(api_version) @@ -125,6 +125,9 @@ class Gitlab(object): self.teams = objects.TeamManager(self) else: self.dockerfiles = objects.DockerfileManager(self) + self.events = objects.EventManager(self) + self.features = objects.FeatureManager(self) + self.pagesdomains = objects.PagesDomainManager(self) self.user_activities = objects.UserActivitiesManager(self) if self._api_version == '3': @@ -144,6 +147,12 @@ class Gitlab(object): manager = getattr(objects, cls_name)(self) setattr(self, var_name, manager) + def __enter__(self): + return self + + def __exit__(self, *args): + self.session.close() + def __getstate__(self): state = self.__dict__.copy() state.pop('_objects') @@ -640,8 +649,22 @@ class Gitlab(object): return parsed._replace(path=new_path).geturl() url = self._build_url(path) - params = query_data.copy() - params.update(kwargs) + + def copy_dict(dest, src): + for k, v in src.items(): + if isinstance(v, dict): + # Transform dict values in new attributes. For example: + # custom_attributes: {'foo', 'bar'} => + # custom_attributes['foo']: 'bar' + for dict_k, dict_v in v.items(): + dest['%s[%s]' % (k, dict_k)] = dict_v + else: + dest[k] = v + + params = {} + copy_dict(params, query_data) + copy_dict(params, kwargs) + opts = self._get_session_opts(content_type='application/json') # don't set the content-type header when uploading files @@ -661,8 +684,9 @@ class Gitlab(object): files=files, **opts) prepped = self.session.prepare_request(req) prepped.url = sanitized_url(prepped.url) - result = self.session.send(prepped, stream=streamed, verify=verify, - timeout=timeout) + settings = self.session.merge_environment_settings( + prepped.url, {}, streamed, verify, None) + result = self.session.send(prepped, timeout=timeout, **settings) if 200 <= result.status_code < 300: return result @@ -743,7 +767,7 @@ class Gitlab(object): if get_all is True: return list(GitlabList(self, url, query_data, **kwargs)) - if 'page' in kwargs or 'per_page' in kwargs or as_list is True: + if 'page' in kwargs or as_list is True: # pagination requested, we return a list return list(GitlabList(self, url, query_data, get_next=False, **kwargs)) diff --git a/gitlab/base.py b/gitlab/base.py index ec5f698..fd79c53 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -764,7 +764,7 @@ class RESTManager(object): if self._parent is None or not hasattr(self, '_from_parent_attrs'): return path - data = {self_attr: getattr(self._parent, parent_attr) + data = {self_attr: getattr(self._parent, parent_attr, None) for self_attr, parent_attr in self._from_parent_attrs.items()} self._parent_attrs = data return path % data diff --git a/gitlab/config.py b/gitlab/config.py index 9cf208c..0f4c424 100644 --- a/gitlab/config.py +++ b/gitlab/config.py @@ -128,7 +128,11 @@ class GitlabConfigParser(object): except Exception: pass - self.api_version = '3' + self.api_version = '4' + try: + self.api_version = self._config.get('global', 'api_version') + except Exception: + pass try: self.api_version = self._config.get(self.gitlab_id, 'api_version') except Exception: diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py index 9a423dd..5825d23 100644 --- a/gitlab/exceptions.py +++ b/gitlab/exceptions.py @@ -193,6 +193,10 @@ class GitlabHousekeepingError(GitlabOperationError): pass +class GitlabOwnershipError(GitlabOperationError): + pass + + def raise_error_from_response(response, error, expected_code=200): """Tries to parse gitlab error message from response and raises error. diff --git a/gitlab/mixins.py b/gitlab/mixins.py index c9243ed..cb35efc 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -242,7 +242,7 @@ class SetMixin(object): GitlabSetError: If an error occured Returns: - UserCustomAttribute: The created/updated user attribute + obj: The created/updated attribute """ path = '%s/%s' % (self.path, key.replace('/', '%2F')) data = {'value': value} @@ -303,6 +303,9 @@ class SaveMixin(object): GitlabUpdateError: If the server cannot perform the request """ updated_data = self._get_updated_data() + # Nothing to update. Server fails if sent an empty dict. + if not updated_data: + return # call the manager obj_id = self.get_id() diff --git a/gitlab/tests/test_base.py b/gitlab/tests/test_base.py index 31dd967..36cb63b 100644 --- a/gitlab/tests/test_base.py +++ b/gitlab/tests/test_base.py @@ -61,9 +61,6 @@ class TestRESTManager(unittest.TestCase): mgr = MGR(FakeGitlab(), parent=Parent()) self.assertEqual(mgr._computed_path, '/tests/42/cases') - self.assertRaises(AttributeError, MGR, FakeGitlab(), - parent=BrokenParent()) - def test_path_property(self): class MGR(base.RESTManager): _path = '/tests' diff --git a/gitlab/tests/test_gitlab.py b/gitlab/tests/test_gitlab.py index d33df99..1a1f3d8 100644 --- a/gitlab/tests/test_gitlab.py +++ b/gitlab/tests/test_gitlab.py @@ -53,7 +53,7 @@ class TestGitlabRawMethods(unittest.TestCase): def setUp(self): self.gl = Gitlab("http://localhost", private_token="private_token", email="testuser@test.com", password="testpassword", - ssl_verify=True) + ssl_verify=True, api_version=3) @urlmatch(scheme="http", netloc="localhost", path="/api/v3/known_path", method="get") @@ -454,7 +454,7 @@ class TestGitlabMethods(unittest.TestCase): def setUp(self): self.gl = Gitlab("http://localhost", private_token="private_token", email="testuser@test.com", password="testpassword", - ssl_verify=True) + ssl_verify=True, api_version=3) def test_list(self): @urlmatch(scheme="http", netloc="localhost", @@ -938,7 +938,7 @@ class TestGitlab(unittest.TestCase): def setUp(self): self.gl = Gitlab("http://localhost", private_token="private_token", email="testuser@test.com", password="testpassword", - ssl_verify=True) + ssl_verify=True, api_version=3) def test_pickability(self): original_gl_objects = self.gl._objects diff --git a/gitlab/tests/test_gitlabobject.py b/gitlab/tests/test_gitlabobject.py index f7fd187..844ba9e 100644 --- a/gitlab/tests/test_gitlabobject.py +++ b/gitlab/tests/test_gitlabobject.py @@ -34,7 +34,7 @@ from httmock import urlmatch # noqa from gitlab import * # noqa -@urlmatch(scheme="http", netloc="localhost", path="/api/v3/projects/1", +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects/1", method="get") def resp_get_project(url, request): headers = {'content-type': 'application/json'} @@ -42,7 +42,7 @@ def resp_get_project(url, request): return response(200, content, headers, None, 5, request) -@urlmatch(scheme="http", netloc="localhost", path="/api/v3/projects", +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", method="get") def resp_list_project(url, request): headers = {'content-type': 'application/json'} @@ -50,7 +50,7 @@ def resp_list_project(url, request): return response(200, content, headers, None, 5, request) -@urlmatch(scheme="http", netloc="localhost", path="/api/v3/issues/1", +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/issues/1", method="get") def resp_get_issue(url, request): headers = {'content-type': 'application/json'} @@ -58,7 +58,7 @@ def resp_get_issue(url, request): return response(200, content, headers, None, 5, request) -@urlmatch(scheme="http", netloc="localhost", path="/api/v3/users/1", +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/users/1", method="put") def resp_update_user(url, request): headers = {'content-type': 'application/json'} @@ -67,7 +67,7 @@ def resp_update_user(url, request): return response(200, content, headers, None, 5, request) -@urlmatch(scheme="http", netloc="localhost", path="/api/v3/projects", +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", method="post") def resp_create_project(url, request): headers = {'content-type': 'application/json'} @@ -75,7 +75,7 @@ def resp_create_project(url, request): return response(201, content, headers, None, 5, request) -@urlmatch(scheme="http", netloc="localhost", path="/api/v3/groups/2/members", +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/groups/2/members", method="post") def resp_create_groupmember(url, request): headers = {'content-type': 'application/json'} @@ -84,14 +84,14 @@ def resp_create_groupmember(url, request): @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/snippets/3", method="get") + path="/api/v4/projects/2/snippets/3", method="get") def resp_get_projectsnippet(url, request): headers = {'content-type': 'application/json'} content = '{"title": "test", "id": 3}'.encode("utf-8") return response(200, content, headers, None, 5, request) -@urlmatch(scheme="http", netloc="localhost", path="/api/v3/groups/1", +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/groups/1", method="delete") def resp_delete_group(url, request): headers = {'content-type': 'application/json'} @@ -100,7 +100,7 @@ def resp_delete_group(url, request): @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/groups/2/projects/3", + path="/api/v4/groups/2/projects/3", method="post") def resp_transfer_project(url, request): headers = {'content-type': 'application/json'} @@ -109,7 +109,7 @@ def resp_transfer_project(url, request): @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/groups/2/projects/3", + path="/api/v4/groups/2/projects/3", method="post") def resp_transfer_project_fail(url, request): headers = {'content-type': 'application/json'} @@ -118,7 +118,7 @@ def resp_transfer_project_fail(url, request): @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/repository/branches/branchname/protect", + path="/api/v4/projects/2/repository/branches/branchname/protect", method="put") def resp_protect_branch(url, request): headers = {'content-type': 'application/json'} @@ -127,7 +127,7 @@ def resp_protect_branch(url, request): @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/repository/branches/branchname/unprotect", + path="/api/v4/projects/2/repository/branches/branchname/unprotect", method="put") def resp_unprotect_branch(url, request): headers = {'content-type': 'application/json'} @@ -136,7 +136,7 @@ def resp_unprotect_branch(url, request): @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/repository/branches/branchname/protect", + path="/api/v4/projects/2/repository/branches/branchname/protect", method="put") def resp_protect_branch_fail(url, request): headers = {'content-type': 'application/json'} @@ -157,7 +157,7 @@ class TestGitlabObject(unittest.TestCase): data = json.loads(json_str) self.assertIn("id", data) self.assertEqual(data["username"], "testname") - self.assertEqual(data["gitlab"]["url"], "http://localhost/api/v3") + self.assertEqual(data["gitlab"]["url"], "http://localhost/api/v4") def test_pickability(self): gl_object = CurrentUser(self.gl, data={"username": "testname"}) @@ -381,7 +381,7 @@ class TestProjectCommit(unittest.TestCase): self.obj = ProjectCommit(self.gl, data={"id": 3, "project_id": 2}) @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/repository/commits/3/diff", + path="/api/v4/projects/2/repository/commits/3/diff", method="get") def resp_diff(self, url, request): headers = {'content-type': 'application/json'} @@ -389,7 +389,7 @@ class TestProjectCommit(unittest.TestCase): return response(200, content, headers, None, 5, request) @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/repository/commits/3/diff", + path="/api/v4/projects/2/repository/commits/3/diff", method="get") def resp_diff_fail(self, url, request): headers = {'content-type': 'application/json'} @@ -397,7 +397,7 @@ class TestProjectCommit(unittest.TestCase): return response(400, content, headers, None, 5, request) @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/repository/blobs/3", + path="/api/v4/projects/2/repository/blobs/3", method="get") def resp_blob(self, url, request): headers = {'content-type': 'application/json'} @@ -405,7 +405,7 @@ class TestProjectCommit(unittest.TestCase): return response(200, content, headers, None, 5, request) @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/repository/blobs/3", + path="/api/v4/projects/2/repository/blobs/3", method="get") def resp_blob_fail(self, url, request): headers = {'content-type': 'application/json'} @@ -440,7 +440,7 @@ class TestProjectSnippet(unittest.TestCase): self.obj = ProjectSnippet(self.gl, data={"id": 3, "project_id": 2}) @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/snippets/3/raw", + path="/api/v4/projects/2/snippets/3/raw", method="get") def resp_content(self, url, request): headers = {'content-type': 'application/json'} @@ -448,7 +448,7 @@ class TestProjectSnippet(unittest.TestCase): return response(200, content, headers, None, 5, request) @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/snippets/3/raw", + path="/api/v4/projects/2/snippets/3/raw", method="get") def resp_content_fail(self, url, request): headers = {'content-type': 'application/json'} @@ -474,7 +474,7 @@ class TestSnippet(unittest.TestCase): self.obj = Snippet(self.gl, data={"id": 3}) @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/snippets/3/raw", + path="/api/v4/snippets/3/raw", method="get") def resp_content(self, url, request): headers = {'content-type': 'application/json'} @@ -482,7 +482,7 @@ class TestSnippet(unittest.TestCase): return response(200, content, headers, None, 5, request) @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/snippets/3/raw", + path="/api/v4/snippets/3/raw", method="get") def resp_content_fail(self, url, request): headers = {'content-type': 'application/json'} diff --git a/gitlab/tests/test_manager.py b/gitlab/tests/test_manager.py index 5cd3130..c6ef299 100644 --- a/gitlab/tests/test_manager.py +++ b/gitlab/tests/test_manager.py @@ -52,7 +52,8 @@ class TestGitlabManager(unittest.TestCase): def setUp(self): self.gitlab = Gitlab("http://localhost", private_token="private_token", email="testuser@test.com", - password="testpassword", ssl_verify=True) + password="testpassword", ssl_verify=True, + api_version=3) def test_set_parent_args(self): @urlmatch(scheme="http", netloc="localhost", path="/api/v3/fake", diff --git a/gitlab/tests/test_mixins.py b/gitlab/tests/test_mixins.py index e78c757..c51322a 100644 --- a/gitlab/tests/test_mixins.py +++ b/gitlab/tests/test_mixins.py @@ -434,89 +434,3 @@ class TestMixinMethods(unittest.TestCase): self.assertIsInstance(obj, FakeObject) self.assertEqual(obj.key, 'foo') self.assertEqual(obj.value, 'bar') - - -class TestExceptions(unittest.TestCase): - def setUp(self): - self.gl = Gitlab("http://localhost", private_token="private_token", - api_version=4) - - def test_get_mixin(self): - class M(GetMixin, FakeManager): - pass - - m = M(self.gl) - self.assertRaises(GitlabGetError, m.get, 1) - - def test_get_without_id_mixin(self): - class M(GetWithoutIdMixin, FakeManager): - pass - - m = M(self.gl) - self.assertRaises(GitlabGetError, m.get) - - def test_list_mixin(self): - class M(ListMixin, FakeManager): - pass - - m = M(self.gl) - self.assertRaises(GitlabListError, m.list) - - def test_get_from_list_mixin(self): - class M(GetFromListMixin, FakeManager): - pass - - m = M(self.gl) - self.assertRaises(GitlabListError, m.list) - self.assertRaises(GitlabGetError, m.get, 1) - - def test_create_mixin(self): - class M(CreateMixin, FakeManager): - pass - - m = M(self.gl) - self.assertRaises(GitlabCreateError, m.create, {}) - - def test_update_mixin(self): - class M(UpdateMixin, FakeManager): - pass - - m = M(self.gl) - self.assertRaises(GitlabUpdateError, m.update, 1, {}) - - def test_set_mixin(self): - class M(SetMixin, FakeManager): - pass - - m = M(self.gl) - self.assertRaises(GitlabSetError, m.set, 'foo', 'bar') - - def test_delete_mixin(self): - class M(DeleteMixin, FakeManager): - pass - - m = M(self.gl) - self.assertRaises(GitlabDeleteError, m.delete, 1) - - def test_object_mixin(self): - class M(UpdateMixin, DeleteMixin, FakeManager): - pass - - class O(SaveMixin, ObjectDeleteMixin, AccessRequestMixin, - SubscribableMixin, TodoMixin, TimeTrackingMixin, RESTObject): - pass - - mgr = M(self.gl) - obj = O(mgr, {'id': 42, 'foo': 'bar'}) - obj.foo = 'baz' - self.assertRaises(GitlabUpdateError, obj.save) - self.assertRaises(GitlabDeleteError, obj.delete) - self.assertRaises(GitlabUpdateError, obj.approve) - self.assertRaises(GitlabSubscribeError, obj.subscribe) - self.assertRaises(GitlabUnsubscribeError, obj.unsubscribe) - self.assertRaises(GitlabTodoError, obj.todo) - self.assertRaises(GitlabTimeTrackingError, obj.time_stats) - self.assertRaises(GitlabTimeTrackingError, obj.time_estimate, '1d') - self.assertRaises(GitlabTimeTrackingError, obj.reset_time_estimate) - self.assertRaises(GitlabTimeTrackingError, obj.add_spent_time, '1d') - self.assertRaises(GitlabTimeTrackingError, obj.reset_spent_time) diff --git a/gitlab/v3/objects.py b/gitlab/v3/objects.py index 014714e..0db9dfd 100644 --- a/gitlab/v3/objects.py +++ b/gitlab/v3/objects.py @@ -934,7 +934,7 @@ class ProjectIssue(GitlabObject): {'project_id': self.project_id, 'issue_id': self.id}) r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabSubscribeError) + raise_error_from_response(r, GitlabSubscribeError, 201) self._set_from_dict(r.json()) def unsubscribe(self, **kwargs): @@ -1496,18 +1496,6 @@ class ProjectFileManager(BaseManager): obj_cls = ProjectFile -class ProjectPipelineSchedule(GitlabObject): - _url = '/projects/%(project_id)s/pipeline_schedules' - _create_url = '/projects/%(project_id)s/pipeline_schedules' - - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['description', 'ref', 'cron'] - - -class ProjectPipelineSchedulesManager(BaseManager): - obj_cls = ProjectPipelineSchedule - - class ProjectPipeline(GitlabObject): _url = '/projects/%(project_id)s/pipelines' _create_url = '/projects/%(project_id)s/pipeline' @@ -1815,7 +1803,6 @@ class Project(GitlabObject): ('notificationsettings', 'ProjectNotificationSettingsManager', [('project_id', 'id')]), ('pipelines', 'ProjectPipelineManager', [('project_id', 'id')]), - ('pipeline_schedules', 'ProjectPipelineSchedulesManager', [('project_id', 'id')]), ('runners', 'ProjectRunnerManager', [('project_id', 'id')]), ('services', 'ProjectServiceManager', [('project_id', 'id')]), ('snippets', 'ProjectSnippetManager', [('project_id', 'id')]), diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 17e987c..aff5e17 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -112,6 +112,17 @@ class SidekiqManager(RESTManager): return self.gitlab.http_get('/sidekiq/compound_metrics', **kwargs) +class Event(RESTObject): + _id_attr = None + _short_print_attr = 'target_title' + + +class EventManager(ListMixin, RESTManager): + _path = '/events' + _obj_cls = Event + _list_filters = ('action', 'target_type', 'before', 'after', 'sort') + + class UserActivities(RESTObject): _id_attr = 'username' @@ -143,6 +154,16 @@ class UserEmailManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): _create_attrs = (('email', ), tuple()) +class UserEvent(Event): + pass + + +class UserEventManager(EventManager): + _path = '/users/%(user_id)s/events' + _obj_cls = UserEvent + _from_parent_attrs = {'user_id': 'id'} + + class UserGPGKey(ObjectDeleteMixin, RESTObject): pass @@ -181,7 +202,7 @@ class UserProject(RESTObject): pass -class UserProjectManager(CreateMixin, RESTManager): +class UserProjectManager(ListMixin, CreateMixin, RESTManager): _path = '/projects/user/%(user_id)s' _obj_cls = UserProject _from_parent_attrs = {'user_id': 'id'} @@ -192,6 +213,31 @@ class UserProjectManager(CreateMixin, RESTManager): 'public', 'visibility', 'description', 'builds_enabled', 'public_builds', 'import_url', 'only_allow_merge_if_build_succeeds') ) + _list_filters = ('archived', 'visibility', 'order_by', 'sort', 'search', + 'simple', 'owned', 'membership', 'starred', 'statistics', + 'with_issues_enabled', 'with_merge_requests_enabled') + + def list(self, **kwargs): + """Retrieve a list of objects. + + Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): If set to False and no pagination option is + defined, return a generator instead of a list + **kwargs: Extra options to send to the Gitlab server (e.g. sudo) + + Returns: + list: The list of objects, or a generator if `as_list` is False + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the server cannot perform the request + """ + + path = '/users/%s/projects' % self._parent.id + return ListMixin.list(self, path=path, **kwargs) class User(SaveMixin, ObjectDeleteMixin, RESTObject): @@ -199,6 +245,7 @@ class User(SaveMixin, ObjectDeleteMixin, RESTObject): _managers = ( ('customattributes', 'UserCustomAttributeManager'), ('emails', 'UserEmailManager'), + ('events', 'UserEventManager'), ('gpgkeys', 'UserGPGKeyManager'), ('impersonationtokens', 'UserImpersonationTokenManager'), ('keys', 'UserKeyManager'), @@ -253,7 +300,7 @@ class UserManager(CRUDMixin, RESTManager): _obj_cls = User _list_filters = ('active', 'blocked', 'username', 'extern_uid', 'provider', - 'external', 'search') + 'external', 'search', 'custom_attributes') _create_attrs = ( tuple(), ('email', 'username', 'name', 'password', 'reset_password', 'skype', @@ -400,6 +447,38 @@ class DockerfileManager(RetrieveMixin, RESTManager): _obj_cls = Dockerfile +class Feature(RESTObject): + _id_attr = 'name' + + +class FeatureManager(ListMixin, RESTManager): + _path = '/features/' + _obj_cls = Feature + + @exc.on_http_error(exc.GitlabSetError) + def set(self, name, value, feature_group=None, user=None, **kwargs): + """Create or update the object. + + Args: + name (str): The value to set for the object + value (bool/int): The value to set for the object + feature_group (str): A feature group name + user (str): A GitLab username + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabSetError: If an error occured + + Returns: + obj: The created/updated attribute + """ + path = '%s/%s' % (self.path, name.replace('/', '%2F')) + data = {'value': value, 'feature_group': feature_group, 'user': user} + server_data = self.gitlab.http_post(path, post_data=data, **kwargs) + return self._obj_cls(self, server_data) + + class Gitignore(RESTObject): _id_attr = 'name' @@ -429,6 +508,17 @@ class GroupAccessRequestManager(GetFromListMixin, CreateMixin, DeleteMixin, _from_parent_attrs = {'group_id': 'id'} +class GroupCustomAttribute(ObjectDeleteMixin, RESTObject): + _id_attr = 'key' + + +class GroupCustomAttributeManager(RetrieveMixin, SetMixin, DeleteMixin, + RESTManager): + _path = '/groups/%(group_id)s/custom_attributes' + _obj_cls = GroupCustomAttribute + _from_parent_attrs = {'group_id': 'id'} + + class GroupIssue(RESTObject): pass @@ -470,6 +560,11 @@ class GroupMilestone(SaveMixin, ObjectDeleteMixin, RESTObject): """List issues related to this milestone. Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): 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: @@ -494,6 +589,11 @@ class GroupMilestone(SaveMixin, ObjectDeleteMixin, RESTObject): """List the merge requests related to this milestone. Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): 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: @@ -544,6 +644,18 @@ class GroupProjectManager(GetFromListMixin, RESTManager): 'ci_enabled_first') +class GroupSubgroup(RESTObject): + pass + + +class GroupSubgroupManager(GetFromListMixin, RESTManager): + _path = '/groups/%(group_id)s/subgroups' + _obj_cls = GroupSubgroup + _from_parent_attrs = {'group_id': 'id'} + _list_filters = ('skip_groups', 'all_available', 'search', 'order_by', + 'sort', 'statistics', 'owned') + + class GroupVariable(SaveMixin, ObjectDeleteMixin, RESTObject): _id_attr = 'key' @@ -560,11 +672,13 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject): _short_print_attr = 'name' _managers = ( ('accessrequests', 'GroupAccessRequestManager'), + ('customattributes', 'GroupCustomAttributeManager'), + ('issues', 'GroupIssueManager'), ('members', 'GroupMemberManager'), ('milestones', 'GroupMilestoneManager'), ('notificationsettings', 'GroupNotificationSettingsManager'), ('projects', 'GroupProjectManager'), - ('issues', 'GroupIssueManager'), + ('subgroups', 'GroupSubgroupManager'), ('variables', 'GroupVariableManager'), ) @@ -588,6 +702,8 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject): class GroupManager(CRUDMixin, RESTManager): _path = '/groups' _obj_cls = Group + _list_filters = ('skip_groups', 'all_available', 'search', 'order_by', + 'sort', 'statistics', 'owned', 'custom_attributes') _create_attrs = ( ('name', 'path'), ('description', 'visibility', 'parent_id', 'lfs_enabled', @@ -698,6 +814,15 @@ class NamespaceManager(GetFromListMixin, RESTManager): _list_filters = ('search', ) +class PagesDomain(RESTObject): + _id_attr = 'domain' + + +class PagesDomainManager(ListMixin, RESTManager): + _path = '/pages/domains' + _obj_cls = PagesDomain + + class ProjectBoardList(SaveMixin, ObjectDeleteMixin, RESTObject): pass @@ -773,6 +898,17 @@ class ProjectBranchManager(NoUpdateMixin, RESTManager): _create_attrs = (('branch', 'ref'), tuple()) +class ProjectCustomAttribute(ObjectDeleteMixin, RESTObject): + _id_attr = 'key' + + +class ProjectCustomAttributeManager(RetrieveMixin, SetMixin, DeleteMixin, + RESTManager): + _path = '/projects/%(project_id)s/custom_attributes' + _obj_cls = ProjectCustomAttribute + _from_parent_attrs = {'project_id': 'id'} + + class ProjectJob(RESTObject): @cli.register_custom_action('ProjectJob') @exc.on_http_error(exc.GitlabJobCancelError) @@ -1047,12 +1183,11 @@ class ProjectKeyManager(NoUpdateMixin, RESTManager): self.gitlab.http_post(path, **kwargs) -class ProjectEvent(RESTObject): - _id_attr = None - _short_print_attr = 'target_title' +class ProjectEvent(Event): + pass -class ProjectEventManager(ListMixin, RESTManager): +class ProjectEventManager(EventManager): _path = '/projects/%(project_id)s/events' _obj_cls = ProjectEvent _from_parent_attrs = {'project_id': 'id'} @@ -1091,10 +1226,35 @@ class ProjectHookManager(CRUDMixin, RESTManager): ) -class ProjectIssueNote(SaveMixin, ObjectDeleteMixin, RESTObject): +class ProjectIssueAwardEmoji(ObjectDeleteMixin, RESTObject): + pass + + +class ProjectIssueAwardEmojiManager(NoUpdateMixin, RESTManager): + _path = '/projects/%(project_id)s/issues/%(issue_iid)s/award_emoji' + _obj_cls = ProjectIssueAwardEmoji + _from_parent_attrs = {'project_id': 'project_id', 'issue_iid': 'iid'} + _create_attrs = (('name', ), tuple()) + + +class ProjectIssueNoteAwardEmoji(ObjectDeleteMixin, RESTObject): pass +class ProjectIssueNoteAwardEmojiManager(NoUpdateMixin, RESTManager): + _path = ('/projects/%(project_id)s/issues/%(issue_iid)s' + '/notes/%(note_id)s/award_emoji') + _obj_cls = ProjectIssueNoteAwardEmoji + _from_parent_attrs = {'project_id': 'project_id', + 'issue_iid': 'issue_iid', + 'note_id': 'id'} + _create_attrs = (('name', ), tuple()) + + +class ProjectIssueNote(SaveMixin, ObjectDeleteMixin, RESTObject): + _managers = (('awardemojis', 'ProjectIssueNoteAwardEmojiManager'),) + + class ProjectIssueNoteManager(CRUDMixin, RESTManager): _path = '/projects/%(project_id)s/issues/%(issue_iid)s/notes' _obj_cls = ProjectIssueNote @@ -1107,7 +1267,10 @@ class ProjectIssue(SubscribableMixin, TodoMixin, TimeTrackingMixin, SaveMixin, ObjectDeleteMixin, RESTObject): _short_print_attr = 'title' _id_attr = 'iid' - _managers = (('notes', 'ProjectIssueNoteManager'), ) + _managers = ( + ('notes', 'ProjectIssueNoteManager'), + ('awardemojis', 'ProjectIssueAwardEmojiManager'), + ) @cli.register_custom_action('ProjectIssue') @exc.on_http_error(exc.GitlabUpdateError) @@ -1196,6 +1359,18 @@ class ProjectNotificationSettingsManager(NotificationSettingsManager): _from_parent_attrs = {'project_id': 'id'} +class ProjectPagesDomain(SaveMixin, ObjectDeleteMixin, RESTObject): + _id_attr = 'domain' + + +class ProjectPagesDomainManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/pages/domains' + _obj_cls = ProjectPagesDomain + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('domain', ), ('certificate', 'key')) + _update_attrs = (tuple(), ('certificate', 'key')) + + class ProjectTag(ObjectDeleteMixin, RESTObject): _id_attr = 'name' _short_print_attr = 'name' @@ -1243,6 +1418,17 @@ class ProjectTagManager(NoUpdateMixin, RESTManager): _create_attrs = (('tag_name', 'ref'), ('message',)) +class ProjectMergeRequestAwardEmoji(ObjectDeleteMixin, RESTObject): + pass + + +class ProjectMergeRequestAwardEmojiManager(NoUpdateMixin, RESTManager): + _path = '/projects/%(project_id)s/merge_requests/%(mr_iid)s/award_emoji' + _obj_cls = ProjectMergeRequestAwardEmoji + _from_parent_attrs = {'project_id': 'project_id', 'mr_iid': 'iid'} + _create_attrs = (('name', ), tuple()) + + class ProjectMergeRequestDiff(RESTObject): pass @@ -1253,10 +1439,24 @@ class ProjectMergeRequestDiffManager(RetrieveMixin, RESTManager): _from_parent_attrs = {'project_id': 'project_id', 'mr_iid': 'iid'} -class ProjectMergeRequestNote(SaveMixin, ObjectDeleteMixin, RESTObject): +class ProjectMergeRequestNoteAwardEmoji(ObjectDeleteMixin, RESTObject): pass +class ProjectMergeRequestNoteAwardEmojiManager(NoUpdateMixin, RESTManager): + _path = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s' + '/notes/%(note_id)s/award_emoji') + _obj_cls = ProjectMergeRequestNoteAwardEmoji + _from_parent_attrs = {'project_id': 'project_id', + 'mr_iid': 'issue_iid', + 'note_id': 'id'} + _create_attrs = (('name', ), tuple()) + + +class ProjectMergeRequestNote(SaveMixin, ObjectDeleteMixin, RESTObject): + _managers = (('awardemojis', 'ProjectMergeRequestNoteAwardEmojiManager'),) + + class ProjectMergeRequestNoteManager(CRUDMixin, RESTManager): _path = '/projects/%(project_id)s/merge_requests/%(mr_iid)s/notes' _obj_cls = ProjectMergeRequestNote @@ -1270,8 +1470,9 @@ class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, _id_attr = 'iid' _managers = ( + ('awardemojis', 'ProjectMergeRequestAwardEmojiManager'), + ('diffs', 'ProjectMergeRequestDiffManager'), ('notes', 'ProjectMergeRequestNoteManager'), - ('diffs', 'ProjectMergeRequestDiffManager') ) @cli.register_custom_action('ProjectMergeRequest') @@ -1299,6 +1500,11 @@ class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, """List issues that will close on merge." Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): 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: @@ -1321,6 +1527,11 @@ class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, """List the merge request commits. Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): 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: @@ -1392,6 +1603,30 @@ class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, **kwargs) self._update_attrs(server_data) + @cli.register_custom_action('ProjectMergeRequest') + @exc.on_http_error(exc.GitlabListError) + def participants(self, **kwargs): + """List the merge request participants. + + Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): 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: + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the list could not be retrieved + + Returns: + RESTObjectList: The list of participants + """ + + path = '%s/%s/participants' % (self.manager.path, self.get_id()) + return self.manager.gitlab.http_get(path, **kwargs) + class ProjectMergeRequestManager(CRUDMixin, RESTManager): _path = '/projects/%(project_id)s/merge_requests' @@ -1423,6 +1658,11 @@ class ProjectMilestone(SaveMixin, ObjectDeleteMixin, RESTObject): """List issues related to this milestone. Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): 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: @@ -1447,6 +1687,11 @@ class ProjectMilestone(SaveMixin, ObjectDeleteMixin, RESTObject): """List the merge requests related to this milestone. Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): 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: @@ -1625,9 +1870,10 @@ class ProjectFileManager(GetMixin, CreateMixin, UpdateMixin, DeleteMixin, """ self._check_missing_create_attrs(data) - file_path = data.pop('file_path').replace('/', '%2F') + new_data = data.copy() + file_path = new_data.pop('file_path').replace('/', '%2F') path = '%s/%s' % (self.path, file_path) - server_data = self.gitlab.http_post(path, post_data=data, **kwargs) + server_data = self.gitlab.http_post(path, post_data=new_data, **kwargs) return self._obj_cls(self, server_data) @exc.on_http_error(exc.GitlabUpdateError) @@ -1706,22 +1952,8 @@ class ProjectFileManager(GetMixin, CreateMixin, UpdateMixin, DeleteMixin, return utils.response_content(result, streamed, action, chunk_size) -class ProjectPipelineJob(ProjectJob): - pass - - -class ProjectPipelineJobsManager(ListMixin, RESTManager): - _path = '/projects/%(project_id)s/pipelines/%(pipeline_id)s/jobs' - _obj_cls = ProjectPipelineJob - _from_parent_attrs = {'project_id': 'project_id', - 'pipeline_id' : 'id'} - _list_filters = ('scope',) - - class ProjectPipeline(RESTObject): - _managers = ( - ('jobs', 'ProjectPipelineJobsManager'), - ) + _managers = (('jobs', 'ProjectPipelineJobManager'), ) @cli.register_custom_action('ProjectPipeline') @exc.on_http_error(exc.GitlabPipelineCancelError) @@ -1784,65 +2016,79 @@ class ProjectPipelineScheduleVariable(SaveMixin, ObjectDeleteMixin, RESTObject): _id_attr = 'key' -class ProjectPipelineScheduleVariableManager(CRUDMixin, RESTManager): - _path = '/projects/%(project_id)s/pipeline_schedules/%(pipeline_schedule_id)s/variables' +class ProjectPipelineScheduleVariableManager(CreateMixin, UpdateMixin, + DeleteMixin, RESTManager): + _path = ('/projects/%(project_id)s/pipeline_schedules/' + '%(pipeline_schedule_id)s/variables') _obj_cls = ProjectPipelineScheduleVariable _from_parent_attrs = {'project_id': 'project_id', 'pipeline_schedule_id' : 'id'} - _create_attrs = (('pipeline_schedule_id', 'key', 'value'), tuple()) _create_attrs = (('key', 'value'), tuple()) + _update_attrs = (('key', 'value'), tuple()) - def list(self): - array = [] - if 'variables' in self._parent._attrs: - for variable in self._parent._attrs['variables']: - schedule_variable = self._obj_cls(self, variable) - array.append(schedule_variable) - else: - obj = self._parent.manager.get(self._parent.id) - for variable in obj._attrs['variables']: - schedule_variable = self._obj_cls(self, variable) - array.append(schedule_variable) - return array +class ProjectPipelineSchedule(SaveMixin, ObjectDeleteMixin, RESTObject): + _managers = (('variables', 'ProjectPipelineScheduleVariableManager'),) + @cli.register_custom_action('ProjectPipelineSchedule') + @exc.on_http_error(exc.GitlabOwnershipError) + def take_ownership(self, **kwargs): + """Update the owner of a pipeline schedule. -class ProjectPipelineSchedule(RESTObject): - _managers = ( - ('variables', 'ProjectPipelineScheduleVariableManager'), - ) + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabOwnershipError: If the request failed + """ + path = '%s/%s/take_ownership' % (self.manager.path, self.get_id()) + server_data = self.manager.gitlab.http_post(path, **kwargs) + self._update_attrs(server_data) -class ProjectPipelineSchedulesManager(RetrieveMixin, CreateMixin, RESTManager): + +class ProjectPipelineScheduleManager(CRUDMixin, RESTManager): _path = '/projects/%(project_id)s/pipeline_schedules' _obj_cls = ProjectPipelineSchedule _from_parent_attrs = {'project_id': 'id'} _create_attrs = (('description', 'ref', 'cron'), ('cron_timezone', 'active')) + _update_attrs = (tuple(), + ('description', 'ref', 'cron', 'cron_timezone', 'active')) - def create(self, data, **kwargs): - """Creates a new object. - Args: - data (dict): Parameters to send to the server to create the - resource - **kwargs: Extra options to send to the server (e.g. sudo) +class ProjectSnippetNote(SaveMixin, ObjectDeleteMixin, RESTObject): + pass - Raises: - GitlabAuthenticationError: If authentication is not correct - GitlabCreateError: If the server cannot perform the request - Returns: - RESTObject: A new instance of the managed object class build with - the data sent by the server - """ - return CreateMixin.create(self, data, path=self.path, **kwargs) +class ProjectPipelineJob(ProjectJob): + pass -class ProjectSnippetNote(SaveMixin, ObjectDeleteMixin, RESTObject): +class ProjectPipelineJobManager(GetFromListMixin, RESTManager): + _path = '/projects/%(project_id)s/pipelines/%(pipeline_id)s/jobs' + _obj_cls = ProjectPipelineJob + _from_parent_attrs = {'project_id': 'project_id', 'pipeline_id': 'id'} + + +class ProjectSnippetNoteAwardEmoji(ObjectDeleteMixin, RESTObject): pass +class ProjectSnippetNoteAwardEmojiManager(NoUpdateMixin, RESTManager): + _path = ('/projects/%(project_id)s/snippets/%(snippet_id)s' + '/notes/%(note_id)s/award_emoji') + _obj_cls = ProjectSnippetNoteAwardEmoji + _from_parent_attrs = {'project_id': 'project_id', + 'snippet_id': 'snippet_id', + 'note_id': 'id'} + _create_attrs = (('name', ), tuple()) + + +class ProjectSnippetNote(SaveMixin, ObjectDeleteMixin, RESTObject): + _managers = (('awardemojis', 'ProjectSnippetNoteAwardEmojiManager'),) + + class ProjectSnippetNoteManager(CRUDMixin, RESTManager): _path = '/projects/%(project_id)s/snippets/%(snippet_id)s/notes' _obj_cls = ProjectSnippetNote @@ -1852,10 +2098,24 @@ class ProjectSnippetNoteManager(CRUDMixin, RESTManager): _update_attrs = (('body', ), tuple()) +class ProjectSnippetAwardEmoji(ObjectDeleteMixin, RESTObject): + pass + + +class ProjectSnippetAwardEmojiManager(NoUpdateMixin, RESTManager): + _path = '/projects/%(project_id)s/snippets/%(snippet_id)s/award_emoji' + _obj_cls = ProjectSnippetAwardEmoji + _from_parent_attrs = {'project_id': 'project_id', 'snippet_id': 'id'} + _create_attrs = (('name', ), tuple()) + + class ProjectSnippet(SaveMixin, ObjectDeleteMixin, RESTObject): _url = '/projects/%(project_id)s/snippets' _short_print_attr = 'title' - _managers = (('notes', 'ProjectSnippetNoteManager'), ) + _managers = ( + ('awardemojis', 'ProjectSnippetAwardEmojiManager'), + ('notes', 'ProjectSnippetNoteManager'), + ) @cli.register_custom_action('ProjectSnippet') @exc.on_http_error(exc.GitlabGetError) @@ -1895,8 +2155,17 @@ class ProjectSnippetManager(CRUDMixin, RESTManager): class ProjectTrigger(SaveMixin, ObjectDeleteMixin, RESTObject): @cli.register_custom_action('ProjectTrigger') + @exc.on_http_error(exc.GitlabOwnershipError) def take_ownership(self, **kwargs): - """Update the owner of a trigger.""" + """Update the owner of a trigger. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabOwnershipError: If the request failed + """ path = '%s/%s/take_ownership' % (self.manager.path, self.get_id()) server_data = self.manager.gitlab.http_post(path, **kwargs) self._update_attrs(server_data) @@ -2094,6 +2363,7 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): ('branches', 'ProjectBranchManager'), ('jobs', 'ProjectJobManager'), ('commits', 'ProjectCommitManager'), + ('customattributes', 'ProjectCustomAttributeManager'), ('deployments', 'ProjectDeploymentManager'), ('environments', 'ProjectEnvironmentManager'), ('events', 'ProjectEventManager'), @@ -2108,9 +2378,10 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): ('milestones', 'ProjectMilestoneManager'), ('notes', 'ProjectNoteManager'), ('notificationsettings', 'ProjectNotificationSettingsManager'), + ('pagesdomains', 'ProjectPagesDomainManager'), ('pipelines', 'ProjectPipelineManager'), ('protectedbranches', 'ProjectProtectedBranchManager'), - ('pipeline_schedules', 'ProjectPipelineSchedulesManager'), + ('pipelineschedules', 'ProjectPipelineScheduleManager'), ('runners', 'ProjectRunnerManager'), ('services', 'ProjectServiceManager'), ('snippets', 'ProjectSnippetManager'), @@ -2129,6 +2400,11 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): Args: path (str): Path of the top folder (/ by default) ref (str): Reference to a commit or branch + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): 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: @@ -2223,6 +2499,11 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): """Return a list of contributors for the project. Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): 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: @@ -2499,7 +2780,8 @@ class ProjectManager(CRUDMixin, RESTManager): ) _list_filters = ('search', 'owned', 'starred', 'archived', 'visibility', 'order_by', 'sort', 'simple', 'membership', 'statistics', - 'with_issues_enabled', 'with_merge_requests_enabled') + 'with_issues_enabled', 'with_merge_requests_enabled', + 'custom_attributes') class Runner(SaveMixin, ObjectDeleteMixin, RESTObject): |