From 7ddbd5e5e124be1d93fbc77da7229fc80062b35f Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sat, 27 May 2017 11:48:36 +0200 Subject: [PATCH 01/93] Add lower-level methods for Gitlab() Multiple goals: * Support making direct queries to the Gitlab server, without objects and managers. * Progressively remove the need to know about managers and objects in the Gitlab class; the Gitlab should only be an HTTP proxy to the gitlab server. * With this the objects gain control on how they should do requests. The complexities of dealing with object specifics will be moved in the object classes where they belong. --- gitlab/__init__.py | 221 +++++++++++++++++++++++++++++++++++++++++++ gitlab/exceptions.py | 8 ++ 2 files changed, 229 insertions(+) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 4adc5630d..7bc9ad3f5 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -599,3 +599,224 @@ def update(self, obj, **kwargs): r = self._raw_put(url, data=data, content_type='application/json') raise_error_from_response(r, GitlabUpdateError) return r.json() + + def _build_url(self, path): + """Returns the full url from path. + + If path is already a url, return it unchanged. If it's a path, append + it to the stored url. + + This is a low-level method, different from _construct_url _build_url + have no knowledge of GitlabObject's. + + Returns: + str: The full URL + """ + if path.startswith('http://') or path.startswith('https://'): + return path + else: + return '%s%s' % (self._url, path) + + def http_request(self, verb, path, query_data={}, post_data={}, + streamed=False, **kwargs): + """Make an HTTP request to the Gitlab server. + + Args: + verb (str): The HTTP method to call ('get', 'post', 'put', + 'delete') + path (str): Path or full URL to query ('/projects' or + 'http://whatever/v4/api/projecs') + query_data (dict): Data to send as query parameters + post_data (dict): Data to send in the body (will be converted to + json) + streamed (bool): Whether the data should be streamed + **kwargs: Extra data to make the query (e.g. sudo, per_page, page) + + Returns: + A requests result object. + + Raises: + GitlabHttpError: When the return code is not 2xx + """ + url = self._build_url(path) + params = query_data.copy() + params.update(kwargs) + opts = self._get_session_opts(content_type='application/json') + result = self.session.request(verb, url, json=post_data, + params=params, stream=streamed, **opts) + if not (200 <= result.status_code < 300): + raise GitlabHttpError(response_code=result.status_code) + return result + + def http_get(self, path, query_data={}, streamed=False, **kwargs): + """Make a GET request to the Gitlab server. + + Args: + path (str): Path or full URL to query ('/projects' or + 'http://whatever/v4/api/projecs') + query_data (dict): Data to send as query parameters + streamed (bool): Whether the data should be streamed + **kwargs: Extra data to make the query (e.g. sudo, per_page, page) + + Returns: + A requests result object is streamed is True or the content type is + not json. + The parsed json data otherwise. + + Raises: + GitlabHttpError: When the return code is not 2xx + GitlabParsingError: IF the json data could not be parsed + """ + result = self.http_request('get', path, query_data=query_data, + streamed=streamed, **kwargs) + if (result.headers['Content-Type'] == 'application/json' and + not streamed): + try: + return result.json() + except Exception as e: + raise GitlaParsingError( + message="Failed to parse the server message") + else: + return r + + def http_list(self, path, query_data={}, **kwargs): + """Make a GET request to the Gitlab server for list-oriented queries. + + Args: + path (str): Path or full URL to query ('/projects' or + 'http://whatever/v4/api/projecs') + query_data (dict): Data to send as query parameters + **kwargs: Extra data to make the query (e.g. sudo, per_page, page, + all) + + Returns: + GitlabList: A generator giving access to the objects. If an ``all`` + kwarg is defined and True, returns a list of all the objects (will + possibly make numerous calls to the Gtilab server and eat a lot of + memory) + + Raises: + GitlabHttpError: When the return code is not 2xx + GitlabParsingError: IF the json data could not be parsed + """ + url = self._build_url(path) + get_all = kwargs.pop('all', False) + obj_gen = GitlabList(self, url, query_data, **kwargs) + return list(obj_gen) if get_all else obj_gen + + def http_post(self, path, query_data={}, post_data={}, **kwargs): + """Make a POST request to the Gitlab server. + + Args: + path (str): Path or full URL to query ('/projects' or + 'http://whatever/v4/api/projecs') + query_data (dict): Data to send as query parameters + post_data (dict): Data to send in the body (will be converted to + json) + **kwargs: Extra data to make the query (e.g. sudo, per_page, page) + + Returns: + The parsed json returned by the server. + + Raises: + GitlabHttpError: When the return code is not 2xx + GitlabParsingError: IF the json data could not be parsed + """ + result = self.http_request('post', path, query_data=query_data, + post_data=post_data, **kwargs) + try: + return result.json() + except Exception as e: + raise GitlabParsingError(message="Failed to parse the server message") + + def http_put(self, path, query_data={}, post_data={}, **kwargs): + """Make a PUT request to the Gitlab server. + + Args: + path (str): Path or full URL to query ('/projects' or + 'http://whatever/v4/api/projecs') + query_data (dict): Data to send as query parameters + post_data (dict): Data to send in the body (will be converted to + json) + **kwargs: Extra data to make the query (e.g. sudo, per_page, page) + + Returns: + The parsed json returned by the server. + + Raises: + GitlabHttpError: When the return code is not 2xx + GitlabParsingError: IF the json data could not be parsed + """ + result = self.hhtp_request('put', path, query_data=query_data, + post_data=post_data, **kwargs) + try: + return result.json() + except Exception as e: + raise GitlabParsingError(message="Failed to parse the server message") + + def http_delete(self, path, **kwargs): + """Make a PUT request to the Gitlab server. + + Args: + path (str): Path or full URL to query ('/projects' or + 'http://whatever/v4/api/projecs') + **kwargs: Extra data to make the query (e.g. sudo, per_page, page) + + Returns: + True. + + Raises: + GitlabHttpError: When the return code is not 2xx + """ + result = self.http_request('delete', path, **kwargs) + return True + + +class GitlabList(object): + """Generator representing a list of remote objects. + + The object handles the links returned by a query to the API, and will call + the API again when needed. + """ + + def __init__(self, gl, url, query_data, **kwargs): + self._gl = gl + self._query(url, query_data, **kwargs) + + def _query(self, url, query_data={}, **kwargs): + result = self._gl.http_request('get', url, query_data=query_data, + **kwargs) + try: + self._next_url = result.links['next']['url'] + except KeyError: + self._next_url = None + self._current_page = result.headers.get('X-Page') + self._next_page = result.headers.get('X-Next-Page') + self._per_page = result.headers.get('X-Per-Page') + self._total_pages = result.headers.get('X-Total-Pages') + self._total = result.headers.get('X-Total') + + try: + self._data = result.json() + except Exception as e: + raise GitlabParsingError(message="Failed to parse the server message") + + self._current = 0 + + def __iter__(self): + return self + + def __next__(self): + return self.next() + + def next(self): + try: + item = self._data[self._current] + self._current += 1 + return item + except IndexError: + if self._next_url: + self._query(self._next_url) + return self._data[self._current] + + raise StopIteration diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py index c7d1da66e..401e44c56 100644 --- a/gitlab/exceptions.py +++ b/gitlab/exceptions.py @@ -47,6 +47,14 @@ class GitlabOperationError(GitlabError): pass +class GitlabHttpError(GitlabError): + pass + + +class GitlaParsingError(GitlabHttpError): + pass + + class GitlabListError(GitlabOperationError): pass From b7298dea19f37d3ae0dfb3e233f3bc7cf5bda10d Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sat, 27 May 2017 12:06:07 +0200 Subject: [PATCH 02/93] pep8 again --- gitlab/__init__.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 7bc9ad3f5..dbb7f856f 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -673,7 +673,7 @@ def http_get(self, path, query_data={}, streamed=False, **kwargs): not streamed): try: return result.json() - except Exception as e: + except Exception: raise GitlaParsingError( message="Failed to parse the server message") else: @@ -726,8 +726,9 @@ def http_post(self, path, query_data={}, post_data={}, **kwargs): post_data=post_data, **kwargs) try: return result.json() - except Exception as e: - raise GitlabParsingError(message="Failed to parse the server message") + except Exception: + raise GitlabParsingError( + message="Failed to parse the server message") def http_put(self, path, query_data={}, post_data={}, **kwargs): """Make a PUT request to the Gitlab server. @@ -751,8 +752,9 @@ def http_put(self, path, query_data={}, post_data={}, **kwargs): post_data=post_data, **kwargs) try: return result.json() - except Exception as e: - raise GitlabParsingError(message="Failed to parse the server message") + except Exception: + raise GitlabParsingError( + message="Failed to parse the server message") def http_delete(self, path, **kwargs): """Make a PUT request to the Gitlab server. @@ -763,13 +765,12 @@ def http_delete(self, path, **kwargs): **kwargs: Extra data to make the query (e.g. sudo, per_page, page) Returns: - True. + The requests object. Raises: GitlabHttpError: When the return code is not 2xx """ - result = self.http_request('delete', path, **kwargs) - return True + return self.http_request('delete', path, **kwargs) class GitlabList(object): @@ -798,8 +799,9 @@ def _query(self, url, query_data={}, **kwargs): try: self._data = result.json() - except Exception as e: - raise GitlabParsingError(message="Failed to parse the server message") + except Exception: + raise GitlabParsingError( + message="Failed to parse the server message") self._current = 0 From 29e0baee39728472abd6b67822b04518c3985d97 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sat, 27 May 2017 21:45:02 +0200 Subject: [PATCH 03/93] Rework the manager and object classes Add new RESTObject and RESTManager base class, linked to a bunch of Mixin class to implement the actual CRUD methods. Object are generated by the managers, and special cases are handled in the derivated classes. Both ways (old and new) can be used together, migrate only a few v4 objects to the new method as a POC. TODO: handle managers on generated objects (have to deal with attributes in the URLs). --- gitlab/__init__.py | 16 ++- gitlab/base.py | 314 +++++++++++++++++++++++++++++++++++++++++++ gitlab/v4/objects.py | 175 ++++++++++-------------- 3 files changed, 399 insertions(+), 106 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index dbb7f856f..d27fcf7e6 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -644,9 +644,12 @@ def http_request(self, verb, path, query_data={}, post_data={}, opts = self._get_session_opts(content_type='application/json') result = self.session.request(verb, url, json=post_data, params=params, stream=streamed, **opts) - if not (200 <= result.status_code < 300): - raise GitlabHttpError(response_code=result.status_code) - return result + if 200 <= result.status_code < 300: + return result + + + raise GitlabHttpError(response_code=result.status_code, + error_message=result.content) def http_get(self, path, query_data={}, streamed=False, **kwargs): """Make a GET request to the Gitlab server. @@ -748,7 +751,7 @@ def http_put(self, path, query_data={}, post_data={}, **kwargs): GitlabHttpError: When the return code is not 2xx GitlabParsingError: IF the json data could not be parsed """ - result = self.hhtp_request('put', path, query_data=query_data, + result = self.http_request('put', path, query_data=query_data, post_data=post_data, **kwargs) try: return result.json() @@ -808,6 +811,9 @@ def _query(self, url, query_data={}, **kwargs): def __iter__(self): return self + def __len__(self): + return self._total_pages + def __next__(self): return self.next() @@ -819,6 +825,6 @@ def next(self): except IndexError: if self._next_url: self._query(self._next_url) - return self._data[self._current] + return self.next() raise StopIteration diff --git a/gitlab/base.py b/gitlab/base.py index 0d82cf1fc..2e26c6490 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -531,3 +531,317 @@ def __eq__(self, other): def __ne__(self, other): return not self.__eq__(other) + + +class SaveMixin(object): + """Mixin for RESTObject's that can be updated.""" + def save(self, **kwargs): + """Saves the changes made to the object to the server. + + Args: + **kwargs: Extra option to send to the server (e.g. sudo) + + The object is updated to match what the server returns. + """ + updated_data = {} + required, optional = self.manager.get_update_attrs() + for attr in required: + # Get everything required, no matter if it's been updated + updated_data[attr] = getattr(self, attr) + # Add the updated attributes + updated_data.update(self._updated_attrs) + + # class the manager + obj_id = self.get_id() + server_data = self.manager.update(obj_id, updated_data, **kwargs) + self._updated_attrs = {} + self._attrs.update(server_data) + + +class RESTObject(object): + """Represents an object built from server data. + + It holds the attributes know from te server, and the updated attributes in + 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 without + ID in the url. + """ + _id_attr = 'id' + + def __init__(self, manager, attrs): + self.__dict__.update({ + 'manager': manager, + '_attrs': attrs, + '_updated_attrs': {}, + }) + + def __getattr__(self, name): + try: + return self.__dict__['_updated_attrs'][name] + except KeyError: + try: + return self.__dict__['_attrs'][name] + except KeyError: + raise AttributeError(name) + + def __setattr__(self, name, value): + self.__dict__['_updated_attrs'][name] = value + + def __str__(self): + data = self._attrs.copy() + data.update(self._updated_attrs) + return '%s => %s' % (type(self), data) + + def __repr__(self): + if self._id_attr : + return '<%s %s:%s>' % (self.__class__.__name__, + self._id_attr, + self.get_id()) + else: + return '<%s>' % self.__class__.__name__ + + def get_id(self): + if self._id_attr is None: + return None + return getattr(self, self._id_attr) + + +class RESTObjectList(object): + """Generator object representing a list of RESTObject's. + + This generator uses the Gitlab pagination system to fetch new data when + required. + + Note: you should not instanciate such objects, they are returned by calls + to RESTManager.list() + + Args: + manager: Manager to attach to the created objects + obj_cls: Type of objects to create from the json data + _list: A GitlabList object + """ + def __init__(self, manager, obj_cls, _list): + self.manager = manager + self._obj_cls = obj_cls + self._list = _list + + def __iter__(self): + return self + + def __len__(self): + return len(self._list) + + def __next__(self): + return self.next() + + def next(self): + data = self._list.next() + return self._obj_cls(self.manager, data) + + +class GetMixin(object): + def get(self, id, **kwargs): + """Retrieve a single object. + + Args: + id (int or str): ID of the object to retrieve + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + object: The generated RESTObject. + + Raises: + GitlabGetError: If the server cannot perform the request. + """ + path = '%s/%s' % (self._path, id) + server_data = self.gitlab.http_get(path, **kwargs) + return self._obj_cls(self, server_data) + + +class GetWithoutIdMixin(object): + def get(self, **kwargs): + """Retrieve a single object. + + Args: + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + object: The generated RESTObject. + + Raises: + GitlabGetError: If the server cannot perform the request. + """ + server_data = self.gitlab.http_get(self._path, **kwargs) + return self._obj_cls(self, server_data) + + +class ListMixin(object): + def list(self, **kwargs): + """Retrieves a list of objects. + + Args: + **kwargs: Extra data to send to the Gitlab server (e.g. sudo). + If ``all`` is passed and set to True, the entire list of + objects will be returned. + + Returns: + RESTObjectList: Generator going through the list of objects, making + queries to the server when required. + If ``all=True`` is passed as argument, returns + list(RESTObjectList). + """ + + obj = self.gitlab.http_list(self._path, **kwargs) + if isinstance(obj, list): + return [self._obj_cls(self, item) for item in obj] + else: + return RESTObjectList(self, self._obj_cls, obj) + + +class GetFromListMixin(ListMixin): + def get(self, id, **kwargs): + """Retrieve a single object. + + Args: + id (int or str): ID of the object to retrieve + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + object: The generated RESTObject. + + Raises: + GitlabGetError: If the server cannot perform the request. + """ + gen = self.list() + for obj in gen: + if str(obj.get_id()) == str(id): + return obj + + +class RetrieveMixin(ListMixin, GetMixin): + pass + + +class CreateMixin(object): + def _check_missing_attrs(self, data): + required, optional = self.get_create_attrs() + missing = [] + for attr in required: + if attr not in data: + missing.append(attr) + continue + if missing: + raise AttributeError("Missing attributes: %s" % ", ".join(missing)) + + def get_create_attrs(self): + """Returns the required and optional arguments. + + Returns: + tuple: 2 items: list of required arguments and list of optional + arguments for creation (in that order) + """ + if hasattr(self, '_create_attrs'): + return (self._create_attrs['required'], + self._create_attrs['optional']) + return (tuple(), tuple()) + + def create(self, data, **kwargs): + """Created a new object. + + Args: + data (dict): parameters to send to the server to create the + resource + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + RESTObject: a new instance of the manage object class build with + the data sent by the server + """ + self._check_missing_attrs(data) + if hasattr(self, '_sanitize_data'): + data = self._sanitize_data(data, 'create') + server_data = self.gitlab.http_post(self._path, post_data=data, **kwargs) + return self._obj_cls(self, server_data) + + +class UpdateMixin(object): + def _check_missing_attrs(self, data): + required, optional = self.get_update_attrs() + missing = [] + for attr in required: + if attr not in data: + missing.append(attr) + continue + if missing: + raise AttributeError("Missing attributes: %s" % ", ".join(missing)) + + def get_update_attrs(self): + """Returns the required and optional arguments. + + Returns: + tuple: 2 items: list of required arguments and list of optional + arguments for update (in that order) + """ + if hasattr(self, '_update_attrs'): + return (self._update_attrs['required'], + self._update_attrs['optional']) + return (tuple(), tuple()) + + def update(self, id=None, new_data={}, **kwargs): + """Update an object on the server. + + Args: + id: ID of the object to update (can be None if not required) + new_data: the update data for the object + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + dict: The new object data (*not* a RESTObject) + """ + + if id is None: + path = self._path + else: + path = '%s/%s' % (self._path, id) + + self._check_missing_attrs(new_data) + if hasattr(self, '_sanitize_data'): + data = self._sanitize_data(new_data, 'update') + server_data = self.gitlab.http_put(self._path, post_data=data, + **kwargs) + return server_data + + +class DeleteMixin(object): + def delete(self, id, **kwargs): + """Deletes an object on the server. + + Args: + id: ID of the object to delete + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + """ + path = '%s/%s' % (self._path, id) + self.gitlab.http_delete(path, **kwargs) + + +class CRUDMixin(GetMixin, ListMixin, CreateMixin, UpdateMixin, DeleteMixin): + pass + + +class RESTManager(object): + """Base class for CRUD operations on objects. + + Derivated class must define ``_path`` and ``_obj_cls``. + + ``_path``: Base URL path on which requests will be sent (e.g. '/projects') + ``_obj_cls``: The class of objects that will be created + """ + + _path = None + _obj_cls = None + + def __init__(self, gl, parent_attrs={}): + self.gitlab = gl + self._parent_attrs = {} # for nested managers diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 83790bfac..030a7c7c2 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -40,7 +40,7 @@ ACCESS_OWNER = 50 -class SidekiqManager(object): +class SidekiqManager(RESTManager): """Manager for the Sidekiq methods. This manager doesn't actually manage objects but provides helper fonction @@ -212,133 +212,106 @@ class CurrentUser(GitlabObject): ) -class ApplicationSettings(GitlabObject): - _url = '/application/settings' - _id_in_update_url = False - getRequiresId = False - optionalUpdateAttrs = ['after_sign_out_path', - 'container_registry_token_expire_delay', - 'default_branch_protection', - 'default_project_visibility', - 'default_projects_limit', - 'default_snippet_visibility', - 'domain_blacklist', - 'domain_blacklist_enabled', - 'domain_whitelist', - 'enabled_git_access_protocol', - 'gravatar_enabled', - 'home_page_url', - 'max_attachment_size', - 'repository_storage', - 'restricted_signup_domains', - 'restricted_visibility_levels', - 'session_expire_delay', - 'sign_in_text', - 'signin_enabled', - 'signup_enabled', - 'twitter_sharing_enabled', - 'user_oauth_applications'] - canList = False - canCreate = False - canDelete = False +class ApplicationSettings(SaveMixin, RESTObject): + _id_attr = None + + +class ApplicationSettingsManager(GetWithoutIdMixin, UpdateMixin, RESTManager): + _path = '/application/settings' + _obj_cls = ApplicationSettings + _update_attrs = { + 'required': tuple(), + 'optional': ('after_sign_out_path', + 'container_registry_token_expire_delay', + 'default_branch_protection', 'default_project_visibility', + 'default_projects_limit', 'default_snippet_visibility', + 'domain_blacklist', 'domain_blacklist_enabled', + 'domain_whitelist', 'enabled_git_access_protocol', + 'gravatar_enabled', 'home_page_url', + 'max_attachment_size', 'repository_storage', + 'restricted_signup_domains', + 'restricted_visibility_levels', 'session_expire_delay', + 'sign_in_text', 'signin_enabled', 'signup_enabled', + 'twitter_sharing_enabled', 'user_oauth_applications') + } - def _data_for_gitlab(self, extra_parameters={}, update=False, - as_json=True): - data = (super(ApplicationSettings, self) - ._data_for_gitlab(extra_parameters, update=update, - as_json=False)) - if not self.domain_whitelist: - data.pop('domain_whitelist', None) - return json.dumps(data) + def _sanitize_data(self, data, action): + new_data = data.copy() + if 'domain_whitelist' in data and data['domain_whitelist'] is None: + new_data.pop('domain_whitelist') + return new_data -class ApplicationSettingsManager(BaseManager): - obj_cls = ApplicationSettings +class BroadcastMessage(SaveMixin, RESTObject): + pass -class BroadcastMessage(GitlabObject): - _url = '/broadcast_messages' - requiredCreateAttrs = ['message'] - optionalCreateAttrs = ['starts_at', 'ends_at', 'color', 'font'] - requiredUpdateAttrs = [] - optionalUpdateAttrs = ['message', 'starts_at', 'ends_at', 'color', 'font'] +class BroadcastMessageManager(CRUDMixin, RESTManager): + _path = '/broadcast_messages' + _obj_cls = BroadcastMessage + _create_attrs = { + 'required': ('message', ), + 'optional': ('starts_at', 'ends_at', 'color', 'font'), + } + _update_attrs = { + 'required': tuple(), + 'optional': ('message', 'starts_at', 'ends_at', 'color', 'font'), + } -class BroadcastMessageManager(BaseManager): - obj_cls = BroadcastMessage +class DeployKey(RESTObject): + pass -class DeployKey(GitlabObject): - _url = '/deploy_keys' - canGet = 'from_list' - canCreate = False - canUpdate = False - canDelete = False +class DeployKeyManager(GetFromListMixin, RESTManager): + _path = '/deploy_keys' + _obj_cls = DeployKey -class DeployKeyManager(BaseManager): - obj_cls = DeployKey +class NotificationSettings(SaveMixin, RESTObject): + _id_attr = None -class NotificationSettings(GitlabObject): - _url = '/notification_settings' - _id_in_update_url = False - getRequiresId = False - optionalUpdateAttrs = ['level', - 'notification_email', - 'new_note', - 'new_issue', - 'reopen_issue', - 'close_issue', - 'reassign_issue', - 'new_merge_request', - 'reopen_merge_request', - 'close_merge_request', - 'reassign_merge_request', - 'merge_merge_request'] - canList = False - canCreate = False - canDelete = False +class NotificationSettingsManager(GetWithoutIdMixin, UpdateMixin, RESTManager): + _path = '/notification_settings' + _obj_cls = NotificationSettings -class NotificationSettingsManager(BaseManager): - obj_cls = NotificationSettings + _update_attrs = { + 'required': tuple(), + 'optional': ('level', 'notification_email', 'new_note', 'new_issue', + 'reopen_issue', 'close_issue', 'reassign_issue', + 'new_merge_request', 'reopen_merge_request', + 'close_merge_request', 'reassign_merge_request', + 'merge_merge_request') + } -class Dockerfile(GitlabObject): - _url = '/templates/dockerfiles' - canDelete = False - canUpdate = False - canCreate = False - idAttr = 'name' +class Dockerfile(RESTObject): + _id_attr = 'name' -class DockerfileManager(BaseManager): - obj_cls = Dockerfile +class DockerfileManager(RetrieveMixin, RESTManager): + _path = '/templates/dockerfiles' + _obj_cls = Dockerfile -class Gitignore(GitlabObject): - _url = '/templates/gitignores' - canDelete = False - canUpdate = False - canCreate = False - idAttr = 'name' +class Gitignore(RESTObject): + _id_attr = 'name' -class GitignoreManager(BaseManager): - obj_cls = Gitignore +class GitignoreManager(RetrieveMixin, RESTManager): + _path = '/templates/gitignores' + _obj_cls = Gitignore -class Gitlabciyml(GitlabObject): - _url = '/templates/gitlab_ci_ymls' - canDelete = False - canUpdate = False - canCreate = False - idAttr = 'name' +class Gitlabciyml(RESTObject): + _id_attr = 'name' -class GitlabciymlManager(BaseManager): - obj_cls = Gitlabciyml +class GitlabciymlManager(RetrieveMixin, RESTManager): + _path = '/templates/gitlab_ci_ymls' + _obj_cls = Gitlabciyml class GroupIssue(GitlabObject): From 0748c8993f0afa6ca89836601a19c7aeeaaf8397 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 28 May 2017 09:40:01 +0200 Subject: [PATCH 04/93] Move the mixins in their own module --- gitlab/base.py | 189 --------------------------------------- gitlab/mixins.py | 207 +++++++++++++++++++++++++++++++++++++++++++ gitlab/v4/objects.py | 1 + 3 files changed, 208 insertions(+), 189 deletions(-) create mode 100644 gitlab/mixins.py diff --git a/gitlab/base.py b/gitlab/base.py index 2e26c6490..ee54f2ac7 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -641,195 +641,6 @@ def next(self): return self._obj_cls(self.manager, data) -class GetMixin(object): - def get(self, id, **kwargs): - """Retrieve a single object. - - Args: - id (int or str): ID of the object to retrieve - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) - - Returns: - object: The generated RESTObject. - - Raises: - GitlabGetError: If the server cannot perform the request. - """ - path = '%s/%s' % (self._path, id) - server_data = self.gitlab.http_get(path, **kwargs) - return self._obj_cls(self, server_data) - - -class GetWithoutIdMixin(object): - def get(self, **kwargs): - """Retrieve a single object. - - Args: - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) - - Returns: - object: The generated RESTObject. - - Raises: - GitlabGetError: If the server cannot perform the request. - """ - server_data = self.gitlab.http_get(self._path, **kwargs) - return self._obj_cls(self, server_data) - - -class ListMixin(object): - def list(self, **kwargs): - """Retrieves a list of objects. - - Args: - **kwargs: Extra data to send to the Gitlab server (e.g. sudo). - If ``all`` is passed and set to True, the entire list of - objects will be returned. - - Returns: - RESTObjectList: Generator going through the list of objects, making - queries to the server when required. - If ``all=True`` is passed as argument, returns - list(RESTObjectList). - """ - - obj = self.gitlab.http_list(self._path, **kwargs) - if isinstance(obj, list): - return [self._obj_cls(self, item) for item in obj] - else: - return RESTObjectList(self, self._obj_cls, obj) - - -class GetFromListMixin(ListMixin): - def get(self, id, **kwargs): - """Retrieve a single object. - - Args: - id (int or str): ID of the object to retrieve - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) - - Returns: - object: The generated RESTObject. - - Raises: - GitlabGetError: If the server cannot perform the request. - """ - gen = self.list() - for obj in gen: - if str(obj.get_id()) == str(id): - return obj - - -class RetrieveMixin(ListMixin, GetMixin): - pass - - -class CreateMixin(object): - def _check_missing_attrs(self, data): - required, optional = self.get_create_attrs() - missing = [] - for attr in required: - if attr not in data: - missing.append(attr) - continue - if missing: - raise AttributeError("Missing attributes: %s" % ", ".join(missing)) - - def get_create_attrs(self): - """Returns the required and optional arguments. - - Returns: - tuple: 2 items: list of required arguments and list of optional - arguments for creation (in that order) - """ - if hasattr(self, '_create_attrs'): - return (self._create_attrs['required'], - self._create_attrs['optional']) - return (tuple(), tuple()) - - def create(self, data, **kwargs): - """Created a new object. - - Args: - data (dict): parameters to send to the server to create the - resource - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) - - Returns: - RESTObject: a new instance of the manage object class build with - the data sent by the server - """ - self._check_missing_attrs(data) - if hasattr(self, '_sanitize_data'): - data = self._sanitize_data(data, 'create') - server_data = self.gitlab.http_post(self._path, post_data=data, **kwargs) - return self._obj_cls(self, server_data) - - -class UpdateMixin(object): - def _check_missing_attrs(self, data): - required, optional = self.get_update_attrs() - missing = [] - for attr in required: - if attr not in data: - missing.append(attr) - continue - if missing: - raise AttributeError("Missing attributes: %s" % ", ".join(missing)) - - def get_update_attrs(self): - """Returns the required and optional arguments. - - Returns: - tuple: 2 items: list of required arguments and list of optional - arguments for update (in that order) - """ - if hasattr(self, '_update_attrs'): - return (self._update_attrs['required'], - self._update_attrs['optional']) - return (tuple(), tuple()) - - def update(self, id=None, new_data={}, **kwargs): - """Update an object on the server. - - Args: - id: ID of the object to update (can be None if not required) - new_data: the update data for the object - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) - - Returns: - dict: The new object data (*not* a RESTObject) - """ - - if id is None: - path = self._path - else: - path = '%s/%s' % (self._path, id) - - self._check_missing_attrs(new_data) - if hasattr(self, '_sanitize_data'): - data = self._sanitize_data(new_data, 'update') - server_data = self.gitlab.http_put(self._path, post_data=data, - **kwargs) - return server_data - - -class DeleteMixin(object): - def delete(self, id, **kwargs): - """Deletes an object on the server. - - Args: - id: ID of the object to delete - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) - """ - path = '%s/%s' % (self._path, id) - self.gitlab.http_delete(path, **kwargs) - - -class CRUDMixin(GetMixin, ListMixin, CreateMixin, UpdateMixin, DeleteMixin): - pass - - class RESTManager(object): """Base class for CRUD operations on objects. diff --git a/gitlab/mixins.py b/gitlab/mixins.py new file mode 100644 index 000000000..761227630 --- /dev/null +++ b/gitlab/mixins.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2013-2017 Gauvain Pocentek +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +from gitlab import base + + +class GetMixin(object): + def get(self, id, **kwargs): + """Retrieve a single object. + + Args: + id (int or str): ID of the object to retrieve + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + object: The generated RESTObject. + + Raises: + GitlabGetError: If the server cannot perform the request. + """ + path = '%s/%s' % (self._path, id) + server_data = self.gitlab.http_get(path, **kwargs) + return self._obj_cls(self, server_data) + + +class GetWithoutIdMixin(object): + def get(self, **kwargs): + """Retrieve a single object. + + Args: + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + object: The generated RESTObject. + + Raises: + GitlabGetError: If the server cannot perform the request. + """ + server_data = self.gitlab.http_get(self._path, **kwargs) + return self._obj_cls(self, server_data) + + +class ListMixin(object): + def list(self, **kwargs): + """Retrieves a list of objects. + + Args: + **kwargs: Extra data to send to the Gitlab server (e.g. sudo). + If ``all`` is passed and set to True, the entire list of + objects will be returned. + + Returns: + RESTObjectList: Generator going through the list of objects, making + queries to the server when required. + If ``all=True`` is passed as argument, returns + list(RESTObjectList). + """ + + obj = self.gitlab.http_list(self._path, **kwargs) + if isinstance(obj, list): + return [self._obj_cls(self, item) for item in obj] + else: + return base.RESTObjectList(self, self._obj_cls, obj) + + +class GetFromListMixin(ListMixin): + def get(self, id, **kwargs): + """Retrieve a single object. + + Args: + id (int or str): ID of the object to retrieve + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + object: The generated RESTObject. + + Raises: + GitlabGetError: If the server cannot perform the request. + """ + gen = self.list() + for obj in gen: + if str(obj.get_id()) == str(id): + return obj + + +class RetrieveMixin(ListMixin, GetMixin): + pass + + +class CreateMixin(object): + def _check_missing_attrs(self, data): + required, optional = self.get_create_attrs() + missing = [] + for attr in required: + if attr not in data: + missing.append(attr) + continue + if missing: + raise AttributeError("Missing attributes: %s" % ", ".join(missing)) + + def get_create_attrs(self): + """Returns the required and optional arguments. + + Returns: + tuple: 2 items: list of required arguments and list of optional + arguments for creation (in that order) + """ + if hasattr(self, '_create_attrs'): + return (self._create_attrs['required'], + self._create_attrs['optional']) + return (tuple(), tuple()) + + def create(self, data, **kwargs): + """Created a new object. + + Args: + data (dict): parameters to send to the server to create the + resource + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + RESTObject: a new instance of the manage object class build with + the data sent by the server + """ + self._check_missing_attrs(data) + if hasattr(self, '_sanitize_data'): + data = self._sanitize_data(data, 'create') + server_data = self.gitlab.http_post(self._path, post_data=data, **kwargs) + return self._obj_cls(self, server_data) + + +class UpdateMixin(object): + def _check_missing_attrs(self, data): + required, optional = self.get_update_attrs() + missing = [] + for attr in required: + if attr not in data: + missing.append(attr) + continue + if missing: + raise AttributeError("Missing attributes: %s" % ", ".join(missing)) + + def get_update_attrs(self): + """Returns the required and optional arguments. + + Returns: + tuple: 2 items: list of required arguments and list of optional + arguments for update (in that order) + """ + if hasattr(self, '_update_attrs'): + return (self._update_attrs['required'], + self._update_attrs['optional']) + return (tuple(), tuple()) + + def update(self, id=None, new_data={}, **kwargs): + """Update an object on the server. + + Args: + id: ID of the object to update (can be None if not required) + new_data: the update data for the object + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + dict: The new object data (*not* a RESTObject) + """ + + if id is None: + path = self._path + else: + path = '%s/%s' % (self._path, id) + + self._check_missing_attrs(new_data) + if hasattr(self, '_sanitize_data'): + data = self._sanitize_data(new_data, 'update') + server_data = self.gitlab.http_put(self._path, post_data=data, + **kwargs) + return server_data + + +class DeleteMixin(object): + def delete(self, id, **kwargs): + """Deletes an object on the server. + + Args: + id: ID of the object to delete + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + """ + path = '%s/%s' % (self._path, id) + self.gitlab.http_delete(path, **kwargs) + + +class CRUDMixin(GetMixin, ListMixin, CreateMixin, UpdateMixin, DeleteMixin): + pass diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 030a7c7c2..5e1e351d9 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -27,6 +27,7 @@ import gitlab from gitlab.base import * # noqa from gitlab.exceptions import * # noqa +from gitlab.mixins import * # noqa from gitlab import utils VISIBILITY_PRIVATE = 'private' From 29cb0e42116ad066e6aabb39362785fd61c65924 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 28 May 2017 09:42:07 +0200 Subject: [PATCH 05/93] pep8 --- gitlab/__init__.py | 1 - gitlab/base.py | 2 +- gitlab/mixins.py | 6 +++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index d27fcf7e6..50928ee94 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -647,7 +647,6 @@ def http_request(self, verb, path, query_data={}, post_data={}, if 200 <= result.status_code < 300: return result - raise GitlabHttpError(response_code=result.status_code, error_message=result.content) diff --git a/gitlab/base.py b/gitlab/base.py index ee54f2ac7..2ecf1d255 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -595,7 +595,7 @@ def __str__(self): return '%s => %s' % (type(self), data) def __repr__(self): - if self._id_attr : + if self._id_attr: return '<%s %s:%s>' % (self.__class__.__name__, self._id_attr, self.get_id()) diff --git a/gitlab/mixins.py b/gitlab/mixins.py index 761227630..a81b2ae0e 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -139,7 +139,8 @@ def create(self, data, **kwargs): self._check_missing_attrs(data) if hasattr(self, '_sanitize_data'): data = self._sanitize_data(data, 'create') - server_data = self.gitlab.http_post(self._path, post_data=data, **kwargs) + server_data = self.gitlab.http_post(self._path, post_data=data, + **kwargs) return self._obj_cls(self, server_data) @@ -186,8 +187,7 @@ def update(self, id=None, new_data={}, **kwargs): self._check_missing_attrs(new_data) if hasattr(self, '_sanitize_data'): data = self._sanitize_data(new_data, 'update') - server_data = self.gitlab.http_put(self._path, post_data=data, - **kwargs) + server_data = self.gitlab.http_put(path, post_data=data, **kwargs) return server_data From 5319d0de2fa13e6ed7c65b4d8e9dc26ccb6f18eb Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 28 May 2017 10:53:54 +0200 Subject: [PATCH 06/93] Add support for managers in objects for new API Convert User* to the new REST* API. --- gitlab/base.py | 33 ++++++++- gitlab/mixins.py | 14 ++-- gitlab/v4/objects.py | 160 ++++++++++++++++++++++--------------------- 3 files changed, 119 insertions(+), 88 deletions(-) diff --git a/gitlab/base.py b/gitlab/base.py index 2ecf1d255..afbcd38b4 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -575,8 +575,14 @@ def __init__(self, manager, attrs): 'manager': manager, '_attrs': attrs, '_updated_attrs': {}, + '_module': importlib.import_module(self.__module__) }) + # TODO(gpocentek): manage the creation of new objects from the received + # data (_constructor_types) + + self._create_managers() + def __getattr__(self, name): try: return self.__dict__['_updated_attrs'][name] @@ -602,6 +608,16 @@ def __repr__(self): else: return '<%s>' % self.__class__.__name__ + def _create_managers(self): + managers = getattr(self, '_managers', None) + if managers is None: + return + + for attr, cls_name in self._managers: + cls = getattr(self._module, cls_name) + manager = cls(self.manager.gitlab, parent=self) + self.__dict__[attr] = manager + def get_id(self): if self._id_attr is None: return None @@ -653,6 +669,19 @@ class RESTManager(object): _path = None _obj_cls = None - def __init__(self, gl, parent_attrs={}): + def __init__(self, gl, parent=None): self.gitlab = gl - self._parent_attrs = {} # for nested managers + self._parent = parent # for nested managers + self._computed_path = self._compute_path() + + def _compute_path(self): + if self._parent is None or not hasattr(self, '_from_parent_attrs'): + return self._path + + data = {self_attr: getattr(self._parent, parent_attr) + for self_attr, parent_attr in self._from_parent_attrs.items()} + return self._path % data + + @property + def path(self): + return self._computed_path diff --git a/gitlab/mixins.py b/gitlab/mixins.py index a81b2ae0e..80ce6c95a 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -32,7 +32,7 @@ def get(self, id, **kwargs): Raises: GitlabGetError: If the server cannot perform the request. """ - path = '%s/%s' % (self._path, id) + path = '%s/%s' % (self.path, id) server_data = self.gitlab.http_get(path, **kwargs) return self._obj_cls(self, server_data) @@ -50,7 +50,7 @@ def get(self, **kwargs): Raises: GitlabGetError: If the server cannot perform the request. """ - server_data = self.gitlab.http_get(self._path, **kwargs) + server_data = self.gitlab.http_get(self.path, **kwargs) return self._obj_cls(self, server_data) @@ -70,7 +70,7 @@ def list(self, **kwargs): list(RESTObjectList). """ - obj = self.gitlab.http_list(self._path, **kwargs) + obj = self.gitlab.http_list(self.path, **kwargs) if isinstance(obj, list): return [self._obj_cls(self, item) for item in obj] else: @@ -139,7 +139,7 @@ def create(self, data, **kwargs): self._check_missing_attrs(data) if hasattr(self, '_sanitize_data'): data = self._sanitize_data(data, 'create') - server_data = self.gitlab.http_post(self._path, post_data=data, + server_data = self.gitlab.http_post(self.path, post_data=data, **kwargs) return self._obj_cls(self, server_data) @@ -180,9 +180,9 @@ def update(self, id=None, new_data={}, **kwargs): """ if id is None: - path = self._path + path = self.path else: - path = '%s/%s' % (self._path, id) + path = '%s/%s' % (self.path, id) self._check_missing_attrs(new_data) if hasattr(self, '_sanitize_data'): @@ -199,7 +199,7 @@ def delete(self, id, **kwargs): id: ID of the object to delete **kwargs: Extra data to send to the Gitlab server (e.g. sudo) """ - path = '%s/%s' % (self._path, id) + path = '%s/%s' % (self.path, id) self.gitlab.http_delete(path, **kwargs) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 5e1e351d9..4d59e6257 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -77,105 +77,107 @@ def compound_metrics(self, **kwargs): return self._simple_get('/sidekiq/compound_metrics', **kwargs) -class UserEmail(GitlabObject): - _url = '/users/%(user_id)s/emails' - canUpdate = False - shortPrintAttr = 'email' - requiredUrlAttrs = ['user_id'] - requiredCreateAttrs = ['email'] +class UserEmail(RESTObject): + _short_print_attr = 'email' -class UserEmailManager(BaseManager): - obj_cls = UserEmail +class UserEmailManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): + _path = '/users/%(user_id)s/emails' + _obj_cls = UserEmail + _from_parent_attrs = {'user_id': 'id'} + _create_attrs = {'required': ('email', ), 'optional': tuple()} -class UserKey(GitlabObject): - _url = '/users/%(user_id)s/keys' - canGet = 'from_list' - canUpdate = False - requiredUrlAttrs = ['user_id'] - requiredCreateAttrs = ['title', 'key'] +class UserKey(RESTObject): + pass -class UserKeyManager(BaseManager): - obj_cls = UserKey +class UserKeyManager(GetFromListMixin, CreateMixin, DeleteMixin, RESTManager): + _path = '/users/%(user_id)s/emails' + _obj_cls = UserKey + _from_parent_attrs = {'user_id': 'id'} + _create_attrs = {'required': ('title', 'key'), 'optional': tuple()} -class UserProject(GitlabObject): - _url = '/projects/user/%(user_id)s' - _constructorTypes = {'owner': 'User', 'namespace': 'Group'} - canUpdate = False - canDelete = False - canList = False - canGet = False - requiredUrlAttrs = ['user_id'] - requiredCreateAttrs = ['name'] - optionalCreateAttrs = ['default_branch', 'issues_enabled', 'wall_enabled', - 'merge_requests_enabled', 'wiki_enabled', - 'snippets_enabled', 'public', 'visibility', - 'description', 'builds_enabled', 'public_builds', - 'import_url', 'only_allow_merge_if_build_succeeds'] +class UserProject(RESTObject): + _constructor_types = {'owner': 'User', 'namespace': 'Group'} -class UserProjectManager(BaseManager): - obj_cls = UserProject +class UserProjectManager(CreateMixin, RESTManager): + _path = '/projects/user/%(user_id)s' + _obj_cls = UserProject + _from_parent_attrs = {'user_id': 'id'} + _create_attrs = { + 'required': ('name', ), + 'optional': ('default_branch', 'issues_enabled', 'wall_enabled', + 'merge_requests_enabled', 'wiki_enabled', + 'snippets_enabled', 'public', 'visibility', 'description', + 'builds_enabled', 'public_builds', 'import_url', + 'only_allow_merge_if_build_succeeds') + } -class User(GitlabObject): - _url = '/users' - shortPrintAttr = 'username' - optionalListAttrs = ['active', 'blocked', 'username', 'extern_uid', - 'provider', 'external'] - requiredCreateAttrs = ['email', 'username', 'name'] - optionalCreateAttrs = ['password', 'reset_password', 'skype', 'linkedin', - 'twitter', 'projects_limit', 'extern_uid', - 'provider', 'bio', 'admin', 'can_create_group', - 'website_url', 'skip_confirmation', 'external', - 'organization', 'location'] - requiredUpdateAttrs = ['email', 'username', 'name'] - optionalUpdateAttrs = ['password', 'skype', 'linkedin', 'twitter', - 'projects_limit', 'extern_uid', 'provider', 'bio', - 'admin', 'can_create_group', 'website_url', - 'skip_confirmation', 'external', 'organization', - 'location'] - managers = ( - ('emails', 'UserEmailManager', [('user_id', 'id')]), - ('keys', 'UserKeyManager', [('user_id', 'id')]), - ('projects', 'UserProjectManager', [('user_id', 'id')]), +class User(SaveMixin, RESTObject): + _short_print_attr = 'username' + _managers = ( + ('emails', 'UserEmailManager'), + ('keys', 'UserKeyManager'), + ('projects', 'UserProjectManager'), ) - def _data_for_gitlab(self, extra_parameters={}, update=False, - as_json=True): - if hasattr(self, 'confirm'): - self.confirm = str(self.confirm).lower() - return super(User, self)._data_for_gitlab(extra_parameters) - def block(self, **kwargs): - """Blocks the user.""" - url = '/users/%s/block' % self.id - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabBlockError, 201) - self.state = 'blocked' + """Blocks the user. + + Returns: + bool: whether the user status has been changed. + """ + path = '/users/%s/block' % self.id + server_data = self.manager.gitlab.http_post(path, **kwargs) + if server_data is True: + self._attrs['state'] = 'blocked' + return server_data def unblock(self, **kwargs): - """Unblocks the user.""" - url = '/users/%s/unblock' % self.id - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabUnblockError, 201) - self.state = 'active' + """Unblocks the user. + + Returns: + bool: whether the user status has been changed. + """ + path = '/users/%s/unblock' % self.id + server_data = self.manager.gitlab.http_post(path, **kwargs) + if server_data is True: + self._attrs['state'] = 'active' + return server_data - def __eq__(self, other): - if type(other) is type(self): - selfdict = self.as_dict() - otherdict = other.as_dict() - selfdict.pop('password', None) - otherdict.pop('password', None) - return selfdict == otherdict - return False +class UserManager(CRUDMixin, RESTManager): + _path = '/users' + _obj_cls = User -class UserManager(BaseManager): - obj_cls = User + _list_filters = ('active', 'blocked', 'username', 'extern_uid', 'provider', + 'external') + _create_attrs = { + 'required': ('email', 'username', 'name'), + 'optional': ('password', 'reset_password', 'skype', 'linkedin', + 'twitter', 'projects_limit', 'extern_uid', 'provider', + 'bio', 'admin', 'can_create_group', 'website_url', + 'skip_confirmation', 'external', 'organization', + 'location') + } + _update_attrs = { + 'required': ('email', 'username', 'name'), + 'optional': ('password', 'skype', 'linkedin', 'twitter', + 'projects_limit', 'extern_uid', 'provider', 'bio', + 'admin', 'can_create_group', 'website_url', + 'skip_confirmation', 'external', 'organization', + 'location') + } + + def _sanitize_data(self, data, action): + new_data = data.copy() + if 'confirm' in data: + new_data['confirm'] = str(new_data['confirm']).lower() + return new_data class CurrentUserEmail(GitlabObject): From 71930345be5b7a1a89f7f823a563cb6cd4bd790b Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 28 May 2017 11:40:44 +0200 Subject: [PATCH 07/93] New API: handle gl.auth() and CurrentUser* classes --- gitlab/__init__.py | 20 ++++++++++------- gitlab/v4/objects.py | 53 ++++++++++++++++++++++++-------------------- 2 files changed, 41 insertions(+), 32 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 50928ee94..2ea5e1471 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -94,6 +94,7 @@ def __init__(self, url, private_token=None, email=None, password=None, objects = importlib.import_module('gitlab.v%s.objects' % self._api_version) + self._objects = objects self.broadcastmessages = objects.BroadcastMessageManager(self) self.deploykeys = objects.DeployKeyManager(self) @@ -191,13 +192,16 @@ def _credentials_auth(self): if not self.email or not self.password: raise GitlabAuthenticationError("Missing email/password") - data = json.dumps({'email': self.email, 'password': self.password}) - r = self._raw_post('/session', data, content_type='application/json') - raise_error_from_response(r, GitlabAuthenticationError, 201) - self.user = CurrentUser(self, r.json()) - """(gitlab.objects.CurrentUser): Object representing the user currently - logged. - """ + if self.api_version == '3': + data = json.dumps({'email': self.email, 'password': self.password}) + r = self._raw_post('/session', data, + content_type='application/json') + raise_error_from_response(r, GitlabAuthenticationError, 201) + self.user = objects.CurrentUser(self, r.json()) + else: + manager = self._objects.CurrentUserManager() + self.user = credentials_auth(self.email, self.password) + self._set_token(self.user.private_token) def token_auth(self): @@ -207,7 +211,7 @@ def token_auth(self): self._token_auth() def _token_auth(self): - self.user = CurrentUser(self) + self.user = self._objects.CurrentUserManager(self).get() def version(self): """Returns the version and revision of the gitlab server. diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 4d59e6257..d04aade68 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -180,41 +180,46 @@ def _sanitize_data(self, data, action): return new_data -class CurrentUserEmail(GitlabObject): - _url = '/user/emails' - canUpdate = False - shortPrintAttr = 'email' - requiredCreateAttrs = ['email'] +class CurrentUserEmail(RESTObject): + _short_print_attr = 'email' -class CurrentUserEmailManager(BaseManager): - obj_cls = CurrentUserEmail +class CurrentUserEmailManager(RetrieveMixin, CreateMixin, DeleteMixin, + RESTManager): + _path = '/user/emails' + _obj_cls = CurrentUserEmail + _create_attrs = {'required': ('email', ), 'optional': tuple()} -class CurrentUserKey(GitlabObject): - _url = '/user/keys' - canUpdate = False - shortPrintAttr = 'title' - requiredCreateAttrs = ['title', 'key'] +class CurrentUserKey(RESTObject): + _short_print_attr = 'title' -class CurrentUserKeyManager(BaseManager): - obj_cls = CurrentUserKey +class CurrentUserKeyManager(RetrieveMixin, CreateMixin, DeleteMixin, + RESTManager): + _path = '/user/keys' + _obj_cls = CurrentUserKey + _create_attrs = {'required': ('title', 'key'), 'optional': tuple()} -class CurrentUser(GitlabObject): - _url = '/user' - canList = False - canCreate = False - canUpdate = False - canDelete = False - shortPrintAttr = 'username' - managers = ( - ('emails', 'CurrentUserEmailManager', [('user_id', 'id')]), - ('keys', 'CurrentUserKeyManager', [('user_id', 'id')]), +class CurrentUser(RESTObject): + _id_attr = None + _short_print_attr = 'username' + _managers = ( + ('emails', 'CurrentUserEmailManager'), + ('keys', 'CurrentUserKeyManager'), ) +class CurrentUserManager(GetWithoutIdMixin, RESTManager): + _path = '/user' + _obj_cls = CurrentUser + + def credentials_auth(self, email, password): + data = {'email': email, 'password': password} + server_data = self.gitlab.http_post('/session', post_data=data) + return CurrentUser(self, server_data) + class ApplicationSettings(SaveMixin, RESTObject): _id_attr = None From 230b5679ee083dc8a5f3a8deb0bef2dab0fe12d6 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 28 May 2017 11:48:26 +0200 Subject: [PATCH 08/93] Simplify SidekiqManager --- gitlab/v4/objects.py | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index d04aade68..7fe61559d 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -47,34 +47,21 @@ class SidekiqManager(RESTManager): This manager doesn't actually manage objects but provides helper fonction for the sidekiq metrics API. """ - def __init__(self, gl): - """Constructs a Sidekiq manager. - - Args: - gl (gitlab.Gitlab): Gitlab object referencing the GitLab server. - """ - self.gitlab = gl - - def _simple_get(self, url, **kwargs): - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabGetError) - return r.json() - def queue_metrics(self, **kwargs): """Returns the registred queues information.""" - return self._simple_get('/sidekiq/queue_metrics', **kwargs) + return self.gitlab.http_get('/sidekiq/queue_metrics', **kwargs) def process_metrics(self, **kwargs): """Returns the registred sidekiq workers.""" - return self._simple_get('/sidekiq/process_metrics', **kwargs) + return self.gitlab.http_get('/sidekiq/process_metrics', **kwargs) def job_stats(self, **kwargs): """Returns statistics about the jobs performed.""" - return self._simple_get('/sidekiq/job_stats', **kwargs) + return self.gitlab.http_get('/sidekiq/job_stats', **kwargs) def compound_metrics(self, **kwargs): """Returns all available metrics and statistics.""" - return self._simple_get('/sidekiq/compound_metrics', **kwargs) + return self.gitlab.http_get('/sidekiq/compound_metrics', **kwargs) class UserEmail(RESTObject): From 6be990cef8725eca6954e9098f83ff8f4ad202a8 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 28 May 2017 22:10:27 +0200 Subject: [PATCH 09/93] Migrate all v4 objects to new API Some things are probably broken. Next step is writting unit and functional tests. And fix. --- gitlab/__init__.py | 18 +- gitlab/base.py | 37 +- gitlab/exceptions.py | 4 + gitlab/mixins.py | 175 +++- gitlab/v4/objects.py | 1853 +++++++++++++++++------------------------- 5 files changed, 926 insertions(+), 1161 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 2ea5e1471..e9a7e9a8d 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -683,7 +683,7 @@ def http_get(self, path, query_data={}, streamed=False, **kwargs): raise GitlaParsingError( message="Failed to parse the server message") else: - return r + return result def http_list(self, path, query_data={}, **kwargs): """Make a GET request to the Gitlab server for list-oriented queries. @@ -722,7 +722,8 @@ def http_post(self, path, query_data={}, post_data={}, **kwargs): **kwargs: Extra data to make the query (e.g. sudo, per_page, page) Returns: - The parsed json returned by the server. + The parsed json returned by the server if json is return, else the + raw content. Raises: GitlabHttpError: When the return code is not 2xx @@ -730,11 +731,14 @@ def http_post(self, path, query_data={}, post_data={}, **kwargs): """ result = self.http_request('post', path, query_data=query_data, post_data=post_data, **kwargs) - try: - return result.json() - except Exception: - raise GitlabParsingError( - message="Failed to parse the server message") + if result.headers.get('Content-Type', None) == 'application/json': + try: + return result.json() + except Exception: + raise GitlabParsingError( + message="Failed to parse the server message") + else: + return result.content def http_put(self, path, query_data={}, post_data={}, **kwargs): """Make a PUT request to the Gitlab server. diff --git a/gitlab/base.py b/gitlab/base.py index afbcd38b4..89495544f 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -533,31 +533,6 @@ def __ne__(self, other): return not self.__eq__(other) -class SaveMixin(object): - """Mixin for RESTObject's that can be updated.""" - def save(self, **kwargs): - """Saves the changes made to the object to the server. - - Args: - **kwargs: Extra option to send to the server (e.g. sudo) - - The object is updated to match what the server returns. - """ - updated_data = {} - required, optional = self.manager.get_update_attrs() - for attr in required: - # Get everything required, no matter if it's been updated - updated_data[attr] = getattr(self, attr) - # Add the updated attributes - updated_data.update(self._updated_attrs) - - # class the manager - obj_id = self.get_id() - server_data = self.manager.update(obj_id, updated_data, **kwargs) - self._updated_attrs = {} - self._attrs.update(server_data) - - class RESTObject(object): """Represents an object built from server data. @@ -618,6 +593,10 @@ def _create_managers(self): manager = cls(self.manager.gitlab, parent=self) self.__dict__[attr] = manager + def _update_attrs(self, new_attrs): + self._updated_attrs = {} + self._attrs.update(new_attrs) + def get_id(self): if self._id_attr is None: return None @@ -674,13 +653,15 @@ def __init__(self, gl, parent=None): self._parent = parent # for nested managers self._computed_path = self._compute_path() - def _compute_path(self): + def _compute_path(self, path=None): + if path is None: + path = self._path if self._parent is None or not hasattr(self, '_from_parent_attrs'): - return self._path + return path data = {self_attr: getattr(self._parent, parent_attr) for self_attr, parent_attr in self._from_parent_attrs.items()} - return self._path % data + return path % data @property def path(self): diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py index 401e44c56..9f27c21f5 100644 --- a/gitlab/exceptions.py +++ b/gitlab/exceptions.py @@ -39,6 +39,10 @@ class GitlabAuthenticationError(GitlabError): pass +class GitlabParsingError(GitlabError): + pass + + class GitlabConnectionError(GitlabError): pass diff --git a/gitlab/mixins.py b/gitlab/mixins.py index 80ce6c95a..0a16a92d5 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -15,6 +15,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . +import gitlab from gitlab import base @@ -70,7 +71,10 @@ def list(self, **kwargs): list(RESTObjectList). """ - obj = self.gitlab.http_list(self.path, **kwargs) + # Allow to overwrite the path, handy for custom listings + path = kwargs.pop('path', self.path) + + obj = self.gitlab.http_list(path, **kwargs) if isinstance(obj, list): return [self._obj_cls(self, item) for item in obj] else: @@ -102,7 +106,7 @@ class RetrieveMixin(ListMixin, GetMixin): class CreateMixin(object): - def _check_missing_attrs(self, data): + def _check_missing_create_attrs(self, data): required, optional = self.get_create_attrs() missing = [] for attr in required: @@ -119,13 +123,10 @@ def get_create_attrs(self): tuple: 2 items: list of required arguments and list of optional arguments for creation (in that order) """ - if hasattr(self, '_create_attrs'): - return (self._create_attrs['required'], - self._create_attrs['optional']) - return (tuple(), tuple()) + return getattr(self, '_create_attrs', (tuple(), tuple())) def create(self, data, **kwargs): - """Created a new object. + """Creates a new object. Args: data (dict): parameters to send to the server to create the @@ -136,16 +137,17 @@ def create(self, data, **kwargs): RESTObject: a new instance of the manage object class build with the data sent by the server """ - self._check_missing_attrs(data) + self._check_missing_create_attrs(data) if hasattr(self, '_sanitize_data'): data = self._sanitize_data(data, 'create') - server_data = self.gitlab.http_post(self.path, post_data=data, - **kwargs) + # Handle specific URL for creation + path = kwargs.get('path', self.path) + server_data = self.gitlab.http_post(path, post_data=data, **kwargs) return self._obj_cls(self, server_data) class UpdateMixin(object): - def _check_missing_attrs(self, data): + def _check_missing_update_attrs(self, data): required, optional = self.get_update_attrs() missing = [] for attr in required: @@ -162,10 +164,7 @@ def get_update_attrs(self): tuple: 2 items: list of required arguments and list of optional arguments for update (in that order) """ - if hasattr(self, '_update_attrs'): - return (self._update_attrs['required'], - self._update_attrs['optional']) - return (tuple(), tuple()) + return getattr(self, '_update_attrs', (tuple(), tuple())) def update(self, id=None, new_data={}, **kwargs): """Update an object on the server. @@ -184,9 +183,11 @@ def update(self, id=None, new_data={}, **kwargs): else: path = '%s/%s' % (self.path, id) - self._check_missing_attrs(new_data) + self._check_missing_update_attrs(new_data) if hasattr(self, '_sanitize_data'): data = self._sanitize_data(new_data, 'update') + else: + data = new_data server_data = self.gitlab.http_put(path, post_data=data, **kwargs) return server_data @@ -205,3 +206,145 @@ def delete(self, id, **kwargs): class CRUDMixin(GetMixin, ListMixin, CreateMixin, UpdateMixin, DeleteMixin): pass + + +class NoUpdateMixin(GetMixin, ListMixin, CreateMixin, DeleteMixin): + pass + + +class SaveMixin(object): + """Mixin for RESTObject's that can be updated.""" + def _get_updated_data(self): + updated_data = {} + required, optional = self.manager.get_update_attrs() + for attr in required: + # Get everything required, no matter if it's been updated + updated_data[attr] = getattr(self, attr) + # Add the updated attributes + updated_data.update(self._updated_attrs) + + return updated_data + + def save(self, **kwargs): + """Saves the changes made to the object to the server. + + Args: + **kwargs: Extra option to send to the server (e.g. sudo) + + The object is updated to match what the server returns. + """ + updated_data = self._get_updated_data() + + # call the manager + obj_id = self.get_id() + server_data = self.manager.update(obj_id, updated_data, **kwargs) + self._update_attrs(server_data) + + +class AccessRequestMixin(object): + def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs): + """Approve an access request. + + Attrs: + access_level (int): The access level for the user. + + Raises: + GitlabConnectionError: If the server cannot be reached. + GitlabUpdateError: If the server fails to perform the request. + """ + + path = '%s/%s/approve' % (self.manager.path, self.id) + data = {'access_level': access_level} + server_data = self.manager.gitlab.http_put(url, post_data=data, + **kwargs) + self._update_attrs(server_data) + + +class SubscribableMixin(object): + def subscribe(self, **kwarg): + """Subscribe to the object notifications. + + raises: + gitlabconnectionerror: if the server cannot be reached. + gitlabsubscribeerror: if the subscription cannot be done + """ + path = '%s/%s/subscribe' % (self.manager.path, self.get_id()) + server_data = self.manager.gitlab.http_post(path, **kwargs) + self._update_attrs(server_data) + + def unsubscribe(self, **kwargs): + """Unsubscribe from the object notifications. + + raises: + gitlabconnectionerror: if the server cannot be reached. + gitlabunsubscribeerror: if the unsubscription cannot be done + """ + path = '%s/%s/unsubscribe' % (self.manager.path, self.get_id()) + server_data = self.manager.gitlab.http_post(path, **kwargs) + self._update_attrs(server_data) + + +class TodoMixin(object): + def todo(self, **kwargs): + """Create a todo associated to the object. + + Raises: + GitlabConnectionError: If the server cannot be reached. + """ + path = '%s/%s/todo' % (self.manager.path, self.get_id()) + self.manager.gitlab.http_post(path, **kwargs) + + +class TimeTrackingMixin(object): + def time_stats(self, **kwargs): + """Get time stats for the object. + + Raises: + GitlabConnectionError: If the server cannot be reached. + """ + path = '%s/%s/time_stats' % (self.manager.path, self.get_id()) + return self.manager.gitlab.http_get(path, **kwargs) + + def time_estimate(self, duration, **kwargs): + """Set an estimated time of work for the object. + + Args: + duration (str): duration in human format (e.g. 3h30) + + Raises: + GitlabConnectionError: If the server cannot be reached. + """ + path = '%s/%s/time_estimate' % (self.manager.path, self.get_id()) + data = {'duration': duration} + return self.manager.gitlab.http_post(path, post_data=data, **kwargs) + + def reset_time_estimate(self, **kwargs): + """Resets estimated time for the object to 0 seconds. + + Raises: + GitlabConnectionError: If the server cannot be reached. + """ + path = '%s/%s/rest_time_estimate' % (self.manager.path, self.get_id()) + return self.manager.gitlab.http_post(path, **kwargs) + + def add_spent_time(self, duration, **kwargs): + """Add time spent working on the object. + + Args: + duration (str): duration in human format (e.g. 3h30) + + Raises: + GitlabConnectionError: If the server cannot be reached. + """ + path = '%s/%s/add_spent_time' % (self.manager.path, self.get_id()) + data = {'duration': duration} + return self.manager.gitlab.http_post(path, post_data=data, **kwargs) + + def reset_spent_time(self, **kwargs): + """Resets the time spent working on the object. + + Raises: + GitlabConnectionError: If the server cannot be reached. + """ + path = '%s/%s/reset_spent_time' % (self.manager.path, self.get_id()) + return self.manager.gitlab.http_post(path, **kwargs) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 7fe61559d..b547d81a4 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -20,7 +20,6 @@ from __future__ import absolute_import import base64 import json -import urllib import six @@ -72,7 +71,7 @@ class UserEmailManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): _path = '/users/%(user_id)s/emails' _obj_cls = UserEmail _from_parent_attrs = {'user_id': 'id'} - _create_attrs = {'required': ('email', ), 'optional': tuple()} + _create_attrs = (('email', ), tuple()) class UserKey(RESTObject): @@ -83,7 +82,7 @@ class UserKeyManager(GetFromListMixin, CreateMixin, DeleteMixin, RESTManager): _path = '/users/%(user_id)s/emails' _obj_cls = UserKey _from_parent_attrs = {'user_id': 'id'} - _create_attrs = {'required': ('title', 'key'), 'optional': tuple()} + _create_attrs = (('title', 'key'), tuple()) class UserProject(RESTObject): @@ -94,14 +93,13 @@ class UserProjectManager(CreateMixin, RESTManager): _path = '/projects/user/%(user_id)s' _obj_cls = UserProject _from_parent_attrs = {'user_id': 'id'} - _create_attrs = { - 'required': ('name', ), - 'optional': ('default_branch', 'issues_enabled', 'wall_enabled', - 'merge_requests_enabled', 'wiki_enabled', - 'snippets_enabled', 'public', 'visibility', 'description', - 'builds_enabled', 'public_builds', 'import_url', - 'only_allow_merge_if_build_succeeds') - } + _create_attrs = ( + ('name', ), + ('default_branch', 'issues_enabled', 'wall_enabled', + 'merge_requests_enabled', 'wiki_enabled', 'snippets_enabled', + 'public', 'visibility', 'description', 'builds_enabled', + 'public_builds', 'import_url', 'only_allow_merge_if_build_succeeds') + ) class User(SaveMixin, RESTObject): @@ -143,22 +141,20 @@ class UserManager(CRUDMixin, RESTManager): _list_filters = ('active', 'blocked', 'username', 'extern_uid', 'provider', 'external') - _create_attrs = { - 'required': ('email', 'username', 'name'), - 'optional': ('password', 'reset_password', 'skype', 'linkedin', - 'twitter', 'projects_limit', 'extern_uid', 'provider', - 'bio', 'admin', 'can_create_group', 'website_url', - 'skip_confirmation', 'external', 'organization', - 'location') - } - _update_attrs = { - 'required': ('email', 'username', 'name'), - 'optional': ('password', 'skype', 'linkedin', 'twitter', - 'projects_limit', 'extern_uid', 'provider', 'bio', - 'admin', 'can_create_group', 'website_url', - 'skip_confirmation', 'external', 'organization', - 'location') - } + _create_attrs = ( + ('email', 'username', 'name'), + ('password', 'reset_password', 'skype', 'linkedin', 'twitter', + 'projects_limit', 'extern_uid', 'provider', 'bio', 'admin', + 'can_create_group', 'website_url', 'skip_confirmation', 'external', + 'organization', 'location') + ) + _update_attrs = ( + ('email', 'username', 'name'), + ('password', 'skype', 'linkedin', 'twitter', 'projects_limit', + 'extern_uid', 'provider', 'bio', 'admin', 'can_create_group', + 'website_url', 'skip_confirmation', 'external', 'organization', + 'location') + ) def _sanitize_data(self, data, action): new_data = data.copy() @@ -175,7 +171,7 @@ class CurrentUserEmailManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): _path = '/user/emails' _obj_cls = CurrentUserEmail - _create_attrs = {'required': ('email', ), 'optional': tuple()} + _create_attrs = (('email', ), tuple()) class CurrentUserKey(RESTObject): @@ -186,7 +182,7 @@ class CurrentUserKeyManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): _path = '/user/keys' _obj_cls = CurrentUserKey - _create_attrs = {'required': ('title', 'key'), 'optional': tuple()} + _create_attrs = (('title', 'key'), tuple()) class CurrentUser(RESTObject): @@ -214,21 +210,19 @@ class ApplicationSettings(SaveMixin, RESTObject): class ApplicationSettingsManager(GetWithoutIdMixin, UpdateMixin, RESTManager): _path = '/application/settings' _obj_cls = ApplicationSettings - _update_attrs = { - 'required': tuple(), - 'optional': ('after_sign_out_path', - 'container_registry_token_expire_delay', - 'default_branch_protection', 'default_project_visibility', - 'default_projects_limit', 'default_snippet_visibility', - 'domain_blacklist', 'domain_blacklist_enabled', - 'domain_whitelist', 'enabled_git_access_protocol', - 'gravatar_enabled', 'home_page_url', - 'max_attachment_size', 'repository_storage', - 'restricted_signup_domains', - 'restricted_visibility_levels', 'session_expire_delay', - 'sign_in_text', 'signin_enabled', 'signup_enabled', - 'twitter_sharing_enabled', 'user_oauth_applications') - } + _update_attrs = ( + tuple(), + ('after_sign_out_path', 'container_registry_token_expire_delay', + 'default_branch_protection', 'default_project_visibility', + 'default_projects_limit', 'default_snippet_visibility', + 'domain_blacklist', 'domain_blacklist_enabled', 'domain_whitelist', + 'enabled_git_access_protocol', 'gravatar_enabled', 'home_page_url', + 'max_attachment_size', 'repository_storage', + 'restricted_signup_domains', 'restricted_visibility_levels', + 'session_expire_delay', 'sign_in_text', 'signin_enabled', + 'signup_enabled', 'twitter_sharing_enabled', + 'user_oauth_applications') + ) def _sanitize_data(self, data, action): new_data = data.copy() @@ -245,14 +239,9 @@ class BroadcastMessageManager(CRUDMixin, RESTManager): _path = '/broadcast_messages' _obj_cls = BroadcastMessage - _create_attrs = { - 'required': ('message', ), - 'optional': ('starts_at', 'ends_at', 'color', 'font'), - } - _update_attrs = { - 'required': tuple(), - 'optional': ('message', 'starts_at', 'ends_at', 'color', 'font'), - } + _create_attrs = (('message', ), ('starts_at', 'ends_at', 'color', 'font')) + _update_attrs = (tuple(), ('message', 'starts_at', 'ends_at', 'color', + 'font')) class DeployKey(RESTObject): @@ -272,14 +261,13 @@ class NotificationSettingsManager(GetWithoutIdMixin, UpdateMixin, RESTManager): _path = '/notification_settings' _obj_cls = NotificationSettings - _update_attrs = { - 'required': tuple(), - 'optional': ('level', 'notification_email', 'new_note', 'new_issue', - 'reopen_issue', 'close_issue', 'reassign_issue', - 'new_merge_request', 'reopen_merge_request', - 'close_merge_request', 'reassign_merge_request', - 'merge_merge_request') - } + _update_attrs = ( + tuple(), + ('level', 'notification_email', 'new_note', 'new_issue', + 'reopen_issue', 'close_issue', 'reassign_issue', 'new_merge_request', + 'reopen_merge_request', 'close_merge_request', + 'reassign_merge_request', 'merge_merge_request') + ) class Dockerfile(RESTObject): @@ -309,128 +297,92 @@ class GitlabciymlManager(RetrieveMixin, RESTManager): _obj_cls = Gitlabciyml -class GroupIssue(GitlabObject): - _url = '/groups/%(group_id)s/issues' - canGet = 'from_list' - canCreate = False - canUpdate = False - canDelete = False - requiredUrlAttrs = ['group_id'] - optionalListAttrs = ['state', 'labels', 'milestone', 'order_by', 'sort'] - - -class GroupIssueManager(BaseManager): - obj_cls = GroupIssue +class GroupIssue(RESTObject): + pass +class GroupIssueManager(GetFromListMixin, RESTManager): + _path = '/groups/%(group_id)s/issues' + _obj_cls = GroupIssue + _from_parent_attrs = {'group_id': 'id'} + _list_filters = ('state', 'labels', 'milestone', 'order_by', 'sort') -class GroupMember(GitlabObject): - _url = '/groups/%(group_id)s/members' - canGet = 'from_list' - requiredUrlAttrs = ['group_id'] - requiredCreateAttrs = ['access_level', 'user_id'] - optionalCreateAttrs = ['expires_at'] - requiredUpdateAttrs = ['access_level'] - optionalCreateAttrs = ['expires_at'] - shortPrintAttr = 'username' - def _update(self, **kwargs): - self.user_id = self.id - super(GroupMember, self)._update(**kwargs) +class GroupMember(SaveMixin, RESTObject): + _short_print_attr = 'username' -class GroupMemberManager(BaseManager): - obj_cls = GroupMember +class GroupMemberManager(GetFromListMixin, CreateMixin, UpdateMixin, + RESTManager): + _path = '/groups/%(group_id)s/members' + _obj_cls = GroupMember + _from_parent_attrs = {'group_id': 'id'} + _create_attrs = (('access_level', 'user_id'), ('expires_at', )) + _update_attrs = (('access_level', ), ('expires_at', )) class GroupNotificationSettings(NotificationSettings): - _url = '/groups/%(group_id)s/notification_settings' - requiredUrlAttrs = ['group_id'] - - -class GroupNotificationSettingsManager(BaseManager): - obj_cls = GroupNotificationSettings - - -class GroupAccessRequest(GitlabObject): - _url = '/groups/%(group_id)s/access_requests' - canGet = 'from_list' - canUpdate = False + pass - def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs): - """Approve an access request. - Attrs: - access_level (int): The access level for the user. +class GroupNotificationSettingsManager(NotificationSettingsManager): + _path = '/groups/%(group_id)s/notification_settings' + _obj_cls = GroupNotificationSettings + _from_parent_attrs = {'group_id': 'id'} - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabUpdateError: If the server fails to perform the request. - """ - url = ('/groups/%(group_id)s/access_requests/%(id)s/approve' % - {'group_id': self.group_id, 'id': self.id}) - data = {'access_level': access_level} - r = self.gitlab._raw_put(url, data=data, **kwargs) - raise_error_from_response(r, GitlabUpdateError, 201) - self._set_from_dict(r.json()) +class GroupAccessRequest(AccessRequestMixin, RESTObject): + pass -class GroupAccessRequestManager(BaseManager): - obj_cls = GroupAccessRequest +class GroupAccessRequestManager(GetFromListMixin, CreateMixin, DeleteMixin, + RESTManager): + _path = '/groups/%(group_id)s/access_requests' + _obj_cls = GroupAccessRequest + _from_parent_attrs = {'group_id': 'id'} -class Hook(GitlabObject): +class Hook(RESTObject): _url = '/hooks' - canUpdate = False - requiredCreateAttrs = ['url'] - shortPrintAttr = 'url' + _short_print_attr = 'url' -class HookManager(BaseManager): - obj_cls = Hook +class HookManager(NoUpdateMixin, RESTManager): + _path = '/hooks' + _obj_cls = Hook + _create_attrs = (('url', ), tuple()) -class Issue(GitlabObject): +class Issue(RESTObject): _url = '/issues' - _constructorTypes = {'author': 'User', 'assignee': 'User', - 'milestone': 'ProjectMilestone'} - canGet = 'from_list' - canDelete = False - canUpdate = False - canCreate = False - shortPrintAttr = 'title' - optionalListAttrs = ['state', 'labels', 'order_by', 'sort'] + _constructor_types = {'author': 'User', + 'assignee': 'User', + 'milestone': 'ProjectMilestone'} + _short_print_attr = 'title' -class IssueManager(BaseManager): - obj_cls = Issue +class IssueManager(GetFromListMixin, RESTManager): + _path = '/issues' + _obj_cls = Issue + _list_filters = ('state', 'labels', 'order_by', 'sort') -class License(GitlabObject): - _url = '/templates/licenses' - canDelete = False - canUpdate = False - canCreate = False - idAttr = 'key' +class License(RESTObject): + _id_attr = 'key' - optionalListAttrs = ['popular'] - optionalGetAttrs = ['project', 'fullname'] +class LicenseManager(RetrieveMixin, RESTManager): + _path = '/templates/licenses' + _obj_cls = License + _list_filters =('popular') + _optional_get_attrs = ('project', 'fullname') -class LicenseManager(BaseManager): - obj_cls = License +class Snippet(SaveMixin, RESTObject): + _constructor_types = {'author': 'User'} + _short_print_attr = 'title' -class Snippet(GitlabObject): - _url = '/snippets' - _constructorTypes = {'author': 'User'} - requiredCreateAttrs = ['title', 'file_name', 'content'] - optionalCreateAttrs = ['lifetime', 'visibility'] - optionalUpdateAttrs = ['title', 'file_name', 'content', 'visibility'] - shortPrintAttr = 'title' - - def raw(self, streamed=False, action=None, chunk_size=1024, **kwargs): - """Return the raw content of a snippet. + def content(self, streamed=False, action=None, chunk_size=1024, **kwargs): + """Return the content of a snippet. Args: streamed (bool): If True the data will be processed by chunks of @@ -447,14 +399,19 @@ def raw(self, streamed=False, action=None, chunk_size=1024, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ - url = ("/snippets/%(snippet_id)s/raw" % {'snippet_id': self.id}) - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabGetError) + path = '/snippets/%s/raw' % self.get_id() + result = self.manager.gitlab.http_get(path, streamed=streamed, + **kwargs) return utils.response_content(r, streamed, action, chunk_size) -class SnippetManager(BaseManager): - obj_cls = Snippet +class SnippetManager(CRUDMixin, RESTManager): + _path = '/snippets' + _obj_cls = Snippet + _create_attrs = (('title', 'file_name', 'content'), + ('lifetime', 'visibility')) + _update_attrs = (tuple(), + ('title', 'file_name', 'content', 'visibility')) def public(self, **kwargs): """List all the public snippets. @@ -466,116 +423,101 @@ def public(self, **kwargs): Returns: list(gitlab.Gitlab.Snippet): The list of snippets. """ - return self.gitlab._raw_list("/snippets/public", Snippet, **kwargs) + return self.list(path='/snippets/public', **kwargs) -class Namespace(GitlabObject): - _url = '/namespaces' - canGet = 'from_list' - canUpdate = False - canDelete = False - canCreate = False - optionalListAttrs = ['search'] +class Namespace(RESTObject): + pass -class NamespaceManager(BaseManager): - obj_cls = Namespace +class NamespaceManager(GetFromListMixin, RESTManager): + _path = '/namespaces' + _obj_cls = Namespace + _list_filters = ('search', ) -class ProjectBoardList(GitlabObject): - _url = '/projects/%(project_id)s/boards/%(board_id)s/lists' - requiredUrlAttrs = ['project_id', 'board_id'] - _constructorTypes = {'label': 'ProjectLabel'} - requiredCreateAttrs = ['label_id'] - requiredUpdateAttrs = ['position'] +class ProjectBoardList(SaveMixin, RESTObject): + _constructor_types = {'label': 'ProjectLabel'} -class ProjectBoardListManager(BaseManager): - obj_cls = ProjectBoardList +class ProjectBoardListManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/boards/%(board_id)s/lists' + _obj_cls = ProjectBoardList + _from_parent_attrs = {'project_id': 'project_id', + 'board_id': 'id'} + _create_attrs = (('label_id', ), tuple()) + _update_attrs = (('position', ), tuple()) -class ProjectBoard(GitlabObject): - _url = '/projects/%(project_id)s/boards' - requiredUrlAttrs = ['project_id'] - _constructorTypes = {'labels': 'ProjectBoardList'} - canGet = 'from_list' - canUpdate = False - canCreate = False - canDelete = False - managers = ( - ('lists', 'ProjectBoardListManager', - [('project_id', 'project_id'), ('board_id', 'id')]), - ) +class ProjectBoard(RESTObject): + _constructor_types = {'labels': 'ProjectBoardList'} + _managers = (('lists', 'ProjectBoardListManager'), ) -class ProjectBoardManager(BaseManager): - obj_cls = ProjectBoard +class ProjectBoardManager(GetFromListMixin, RESTManager): + _path = '/projects/%(project_id)s/boards' + _obj_cls = ProjectBoard + _from_parent_attrs = {'project_id': 'id'} -class ProjectBranch(GitlabObject): - _url = '/projects/%(project_id)s/repository/branches' - _constructorTypes = {'author': 'User', "committer": "User"} +class ProjectBranch(RESTObject): + _constructor_types = {'author': 'User', "committer": "User"} + _id_attr = 'name' - idAttr = 'name' - canUpdate = False - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['branch', 'ref'] - - def protect(self, protect=True, **kwargs): - """Protects the branch.""" - url = self._url % {'project_id': self.project_id} - action = 'protect' if protect else 'unprotect' - url = "%s/%s/%s" % (url, self.name, action) - r = self.gitlab._raw_put(url, data=None, content_type=None, **kwargs) - raise_error_from_response(r, GitlabProtectError) - - if protect: - self.protected = protect - else: - del self.protected + def protect(self, developers_can_push=False, developers_can_merge=False, + **kwargs): + """Protects the branch. + + Args: + developers_can_push (bool): Set to True if developers are allowed + to push to the branch + developers_can_merge (bool): Set to True if developers are allowed + to merge to the branch + """ + path = '%s/%s/protect' % (self.manager.path, self.get_id()) + post_data = {'developers_can_push': developers_can_push, + 'developers_can_merge': developers_can_merge} + self.manager.gitlab.http_put(path, post_data=post_data, **kwargs) + self._attrs['protected'] = True def unprotect(self, **kwargs): """Unprotects the branch.""" - self.protect(False, **kwargs) + path = '%s/%s/protect' % (self.manager.path, self.get_id()) + self.manager.gitlab.http_put(path, **kwargs) + self._attrs['protected'] = False -class ProjectBranchManager(BaseManager): - obj_cls = ProjectBranch +class ProjectBranchManager(NoUpdateMixin, RESTManager): + _path = '/projects/%(project_id)s/repository/branches' + _obj_cls = ProjectBranch + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('branch', 'ref'), tuple()) -class ProjectJob(GitlabObject): - _url = '/projects/%(project_id)s/jobs' - _constructorTypes = {'user': 'User', - 'commit': 'ProjectCommit', - 'runner': 'Runner'} - requiredUrlAttrs = ['project_id'] - canDelete = False - canUpdate = False - canCreate = False +class ProjectJob(RESTObject): + _constructor_types = {'user': 'User', + 'commit': 'ProjectCommit', + 'runner': 'Runner'} def cancel(self, **kwargs): """Cancel the job.""" - url = '/projects/%s/jobs/%s/cancel' % (self.project_id, self.id) - r = self.gitlab._raw_post(url) - raise_error_from_response(r, GitlabJobCancelError, 201) + path = '%s/%s/cancel' % (self.manager.path, self.get_id()) + self.manager.gitlab.http_post(path) def retry(self, **kwargs): """Retry the job.""" - url = '/projects/%s/jobs/%s/retry' % (self.project_id, self.id) - r = self.gitlab._raw_post(url) - raise_error_from_response(r, GitlabJobRetryError, 201) + path = '%s/%s/retry' % (self.manager.path, self.get_id()) + self.manager.gitlab.http_post(path) def play(self, **kwargs): """Trigger a job explicitly.""" - url = '/projects/%s/jobs/%s/play' % (self.project_id, self.id) - r = self.gitlab._raw_post(url) - raise_error_from_response(r, GitlabJobPlayError) + path = '%s/%s/play' % (self.manager.path, self.get_id()) + self.manager.gitlab.http_post(path) def erase(self, **kwargs): """Erase the job (remove job artifacts and trace).""" - url = '/projects/%s/jobs/%s/erase' % (self.project_id, self.id) - r = self.gitlab._raw_post(url) - raise_error_from_response(r, GitlabJobEraseError, 201) + path = '%s/%s/erase' % (self.manager.path, self.get_id()) + self.manager.gitlab.http_post(path) def keep_artifacts(self, **kwargs): """Prevent artifacts from being delete when expiration is set. @@ -584,10 +526,8 @@ def keep_artifacts(self, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabCreateError: If the request failed. """ - url = ('/projects/%s/jobs/%s/artifacts/keep' % - (self.project_id, self.id)) - r = self.gitlab._raw_post(url) - raise_error_from_response(r, GitlabGetError, 200) + path = '%s/%s/artifacts/keep' % (self.manager.path, self.get_id()) + self.manager.gitlab.http_post(path) def artifacts(self, streamed=False, action=None, chunk_size=1024, **kwargs): @@ -608,10 +548,10 @@ def artifacts(self, streamed=False, action=None, chunk_size=1024, GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the artifacts are not available. """ - url = '/projects/%s/jobs/%s/artifacts' % (self.project_id, self.id) - r = self.gitlab._raw_get(url, streamed=streamed, **kwargs) - raise_error_from_response(r, GitlabGetError, 200) - return utils.response_content(r, streamed, action, chunk_size) + path = '%s/%s/artifacts' % (self.manager.path, self.get_id()) + result = self.manager.gitlab.get_http(path, streamed=streamed, + **kwargs) + return utils.response_content(result, streamed, action, chunk_size) def trace(self, streamed=False, action=None, chunk_size=1024, **kwargs): """Get the job trace. @@ -631,96 +571,70 @@ def trace(self, streamed=False, action=None, chunk_size=1024, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the trace is not available. """ - url = '/projects/%s/jobs/%s/trace' % (self.project_id, self.id) - r = self.gitlab._raw_get(url, streamed=streamed, **kwargs) - raise_error_from_response(r, GitlabGetError, 200) - return utils.response_content(r, streamed, action, chunk_size) + path = '%s/%s/trace' % (self.manager.path, self.get_id()) + result = self.manager.gitlab.get_http(path, streamed=streamed, + **kwargs) + return utils.response_content(result, streamed, action, chunk_size) -class ProjectJobManager(BaseManager): - obj_cls = ProjectJob +class ProjectJobManager(RetrieveMixin, RESTManager): + _path = '/projects/%(project_id)s/jobs' + _obj_cls = ProjectJob + _from_parent_attrs = {'project_id': 'id'} -class ProjectCommitStatus(GitlabObject): - _url = '/projects/%(project_id)s/repository/commits/%(commit_id)s/statuses' - _create_url = '/projects/%(project_id)s/statuses/%(commit_id)s' - canUpdate = False - canDelete = False - requiredUrlAttrs = ['project_id', 'commit_id'] - optionalGetAttrs = ['ref_name', 'stage', 'name', 'all'] - requiredCreateAttrs = ['state'] - optionalCreateAttrs = ['description', 'name', 'context', 'ref', - 'target_url'] - - -class ProjectCommitStatusManager(BaseManager): - obj_cls = ProjectCommitStatus +class ProjectCommitStatus(RESTObject): + pass -class ProjectCommitComment(GitlabObject): - _url = '/projects/%(project_id)s/repository/commits/%(commit_id)s/comments' - canUpdate = False - canGet = False - canDelete = False - requiredUrlAttrs = ['project_id', 'commit_id'] - requiredCreateAttrs = ['note'] - optionalCreateAttrs = ['path', 'line', 'line_type'] +class ProjectCommitStatusManager(RetrieveMixin, CreateMixin, RESTManager): + _path = '/projects/%(project_id)s/repository/commits/%(commit_id)s/statuses' + _obj_cls = ProjectCommitStatus + _from_parent_attrs = {'project_id': 'project_id', 'commit_id': 'id'} + _create_attrs = (('state', ), + ('description', 'name', 'context', 'ref', 'target_url')) + def create(self, data, **kwargs): + """Creates a new object. -class ProjectCommitCommentManager(BaseManager): - obj_cls = ProjectCommitComment + Args: + data (dict): parameters to send to the server to create the + resource + **kwargs: Extra data to send to the Gitlab server (e.g. sudo or + 'ref_name', 'stage', 'name', 'all'. + Returns: + RESTObject: a new instance of the manage object class build with + the data sent by the server + """ + path = '/projects/%(project_id)s/statuses/%(commit_id)s' + computed_path = self._compute_path(path) + return CreateMixin.create(self, data, path=computed_path, **kwargs) -class ProjectCommit(GitlabObject): - _url = '/projects/%(project_id)s/repository/commits' - canDelete = False - canUpdate = False - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['branch', 'commit_message', 'actions'] - optionalCreateAttrs = ['author_email', 'author_name'] - shortPrintAttr = 'title' - managers = ( - ('comments', 'ProjectCommitCommentManager', - [('project_id', 'project_id'), ('commit_id', 'id')]), - ('statuses', 'ProjectCommitStatusManager', - [('project_id', 'project_id'), ('commit_id', 'id')]), - ) - def diff(self, **kwargs): - """Generate the commit diff.""" - url = ('/projects/%(project_id)s/repository/commits/%(commit_id)s/diff' - % {'project_id': self.project_id, 'commit_id': self.id}) - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabGetError) +class ProjectCommitComment(RESTObject): + pass - return r.json() - def blob(self, filepath, streamed=False, action=None, chunk_size=1024, - **kwargs): - """Generate the content of a file for this commit. +class ProjectCommitCommentManager(ListMixin, CreateMixin, RESTManager): + _path = ('/projects/%(project_id)s/repository/commits/%(commit_id)s' + '/comments') + _obj_cls = ProjectCommitComment + _from_parent_attrs = {'project_id': 'project_id', 'commit_id': 'id'} + _create_attrs = (('note', ), ('path', 'line', 'line_type')) - Args: - filepath (str): Path of the file to request. - streamed (bool): If True the data will be processed by chunks of - `chunk_size` and each chunk is passed to `action` for - treatment. - action (callable): Callable responsible of dealing with chunk of - data. - chunk_size (int): Size of each chunk. - Returns: - str: The content of the file +class ProjectCommit(RESTObject): + _short_print_attr = 'title' + _managers = ( + ('comments', 'ProjectCommitCommentManager'), + ('statuses', 'ProjectCommitStatusManager'), + ) - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the server fails to perform the request. - """ - url = ('/projects/%(project_id)s/repository/blobs/%(commit_id)s' % - {'project_id': self.project_id, 'commit_id': self.id}) - url += '?filepath=%s' % filepath - r = self.gitlab._raw_get(url, streamed=streamed, **kwargs) - raise_error_from_response(r, GitlabGetError) - return utils.response_content(r, streamed, action, chunk_size) + def diff(self, **kwargs): + """Generate the commit diff.""" + path = '%s/%s/diff' % (self.manager.path, self.get_id()) + return self.manager.gitlab.http_get(path, **kwargs) def cherry_pick(self, branch, **kwargs): """Cherry-pick a commit into a branch. @@ -731,151 +645,121 @@ def cherry_pick(self, branch, **kwargs): Raises: GitlabCherryPickError: If the cherry pick could not be applied. """ - url = ('/projects/%s/repository/commits/%s/cherry_pick' % - (self.project_id, self.id)) + path = '%s/%s/cherry_pick' % (self.manager.path, self.get_id()) + post_data = {'branch': branch} + self.manager.gitlab.http_post(path, post_data=post_data, **kwargs) - r = self.gitlab._raw_post(url, data={'project_id': self.project_id, - 'branch': branch}, **kwargs) - errors = {400: GitlabCherryPickError} - raise_error_from_response(r, errors, expected_code=201) +class ProjectCommitManager(RetrieveMixin, CreateMixin, RESTManager): + _path = '/projects/%(project_id)s/repository/commits' + _obj_cls = ProjectCommit + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('branch', 'commit_message', 'actions'), + ('author_email', 'author_name')) -class ProjectCommitManager(BaseManager): - obj_cls = ProjectCommit +class ProjectEnvironment(SaveMixin, RESTObject): + pass -class ProjectEnvironment(GitlabObject): - _url = '/projects/%(project_id)s/environments' - canGet = 'from_list' - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['name'] - optionalCreateAttrs = ['external_url'] - optionalUpdateAttrs = ['name', 'external_url'] +class ProjectEnvironmentManager(GetFromListMixin, CreateMixin, UpdateMixin, + DeleteMixin, RESTManager): + _path = '/projects/%(project_id)s/environments' + _obj_cls = ProjectEnvironment + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('name', ), ('external_url', )) + _update_attrs = (tuple(), ('name', 'external_url')) -class ProjectEnvironmentManager(BaseManager): - obj_cls = ProjectEnvironment +class ProjectKey(RESTObject): + pass -class ProjectKey(GitlabObject): - _url = '/projects/%(project_id)s/deploy_keys' - canUpdate = False - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['title', 'key'] +class ProjectKeyManager(NoUpdateMixin, RESTManager): + _path = '/projects/%(project_id)s/deploy_keys' + _obj_cls = ProjectKey + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('title', 'key'), tuple()) -class ProjectKeyManager(BaseManager): - obj_cls = ProjectKey + def enable(self, key_id, **kwargs): + """Enable a deploy key for a project. - def enable(self, key_id): - """Enable a deploy key for a project.""" - url = '/projects/%s/deploy_keys/%s/enable' % (self.parent.id, key_id) - r = self.gitlab._raw_post(url) - raise_error_from_response(r, GitlabProjectDeployKeyError, 201) + Args: + key_id (int): The ID of the key to enable + """ + path = '%s/%s/enable' % (self.manager.path, key_id) + self.manager.gitlab.http_post(path, **kwargs) -class ProjectEvent(GitlabObject): - _url = '/projects/%(project_id)s/events' - canGet = 'from_list' - canDelete = False - canUpdate = False - canCreate = False - requiredUrlAttrs = ['project_id'] - shortPrintAttr = 'target_title' +class ProjectEvent(RESTObject): + _short_print_attr = 'target_title' -class ProjectEventManager(BaseManager): - obj_cls = ProjectEvent +class ProjectEventManager(GetFromListMixin, RESTManager): + _path ='/projects/%(project_id)s/events' + _obj_cls = ProjectEvent + _from_parent_attrs = {'project_id': 'id'} -class ProjectFork(GitlabObject): - _url = '/projects/%(project_id)s/fork' - canUpdate = False - canDelete = False - canList = False - canGet = False - requiredUrlAttrs = ['project_id'] - optionalCreateAttrs = ['namespace'] +class ProjectFork(RESTObject): + pass -class ProjectForkManager(BaseManager): - obj_cls = ProjectFork +class ProjectForkManager(CreateMixin, RESTManager): + _path = '/projects/%(project_id)s/fork' + _obj_cls = ProjectFork + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (tuple(), ('namespace', )) -class ProjectHook(GitlabObject): - _url = '/projects/%(project_id)s/hooks' +class ProjectHook(SaveMixin, RESTObject): requiredUrlAttrs = ['project_id'] requiredCreateAttrs = ['url'] optionalCreateAttrs = ['push_events', 'issues_events', 'note_events', 'merge_requests_events', 'tag_push_events', 'build_events', 'enable_ssl_verification', 'token', 'pipeline_events'] - shortPrintAttr = 'url' - - -class ProjectHookManager(BaseManager): - obj_cls = ProjectHook - - -class ProjectIssueNote(GitlabObject): - _url = '/projects/%(project_id)s/issues/%(issue_iid)s/notes' - _constructorTypes = {'author': 'User'} - canDelete = False - requiredUrlAttrs = ['project_id', 'issue_iid'] - requiredCreateAttrs = ['body'] - optionalCreateAttrs = ['created_at'] - - -class ProjectIssueNoteManager(BaseManager): - obj_cls = ProjectIssueNote - - -class ProjectIssue(GitlabObject): - _url = '/projects/%(project_id)s/issues/' - _constructorTypes = {'author': 'User', 'assignee': 'User', - 'milestone': 'ProjectMilestone'} - optionalListAttrs = ['state', 'labels', 'milestone', 'order_by', 'sort'] - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['title'] - optionalCreateAttrs = ['description', 'assignee_id', 'milestone_id', - 'labels', 'created_at', 'due_date'] - optionalUpdateAttrs = ['title', 'description', 'assignee_id', - 'milestone_id', 'labels', 'created_at', - 'updated_at', 'state_event', 'due_date'] - shortPrintAttr = 'title' - idAttr = 'iid' - managers = ( - ('notes', 'ProjectIssueNoteManager', - [('project_id', 'project_id'), ('issue_iid', 'iid')]), + _short_print_attr = 'url' + + +class ProjectHookManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/hooks' + _obj_cls = ProjectHook + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = ( + ('url', ), + ('push_events', 'issues_events', 'note_events', + 'merge_requests_events', 'tag_push_events', 'build_events', + 'enable_ssl_verification', 'token', 'pipeline_events') + ) + _update_attrs = ( + ('url', ), + ('push_events', 'issues_events', 'note_events', + 'merge_requests_events', 'tag_push_events', 'build_events', + 'enable_ssl_verification', 'token', 'pipeline_events') ) - def subscribe(self, **kwargs): - """Subscribe to an issue. - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabSubscribeError: If the subscription cannot be done - """ - url = ('/projects/%(project_id)s/issues/%(issue_iid)s/subscribe' % - {'project_id': self.project_id, 'issue_iid': self.iid}) +class ProjectIssueNote(SaveMixin, RESTObject): + _constructor_types= {'author': 'User'} - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabSubscribeError, [201, 304]) - self._set_from_dict(r.json()) - def unsubscribe(self, **kwargs): - """Unsubscribe an issue. +class ProjectIssueNoteManager(RetrieveMixin, CreateMixin, UpdateMixin, + RESTManager): + _path = '/projects/%(project_id)s/issues/%(issue_iid)s/notes' + _obj_cls = ProjectIssueNote + _from_parent_attrs = {'project_id': 'project_id', 'issue_iid': 'iid'} + _create_attrs = (('body', ), ('created_at')) + _update_attrs = (('body', ), tuple()) - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabUnsubscribeError: If the unsubscription cannot be done - """ - url = ('/projects/%(project_id)s/issues/%(issue_iid)s/unsubscribe' % - {'project_id': self.project_id, 'issue_iid': self.iid}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabUnsubscribeError, [201, 304]) - self._set_from_dict(r.json()) +class ProjectIssue(SubscribableMixin, TodoMixin, TimeTrackingMixin, SaveMixin, + RESTObject): + _constructor_types = {'author': 'User', 'assignee': 'User', 'milestone': + 'ProjectMilestone'} + _short_print_attr = 'title' + _id_attr = 'iid' + _managers = (('notes', 'ProjectIssueNoteManager'), ) def move(self, to_project_id, **kwargs): """Move the issue to another project. @@ -883,160 +767,70 @@ def move(self, to_project_id, **kwargs): Raises: GitlabConnectionError: If the server cannot be reached. """ - url = ('/projects/%(project_id)s/issues/%(issue_iid)s/move' % - {'project_id': self.project_id, 'issue_iid': self.iid}) - + path = '%s/%s/move' % (self.manager.path, self.get_id()) data = {'to_project_id': to_project_id} - data.update(**kwargs) - r = self.gitlab._raw_post(url, data=data) - raise_error_from_response(r, GitlabUpdateError, 201) - self._set_from_dict(r.json()) + server_data = self.manager.gitlab.http_post(url, post_data=data, + **kwargs) + self._update_attrs(server_data) - def todo(self, **kwargs): - """Create a todo for the issue. - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/issues/%(issue_iid)s/todo' % - {'project_id': self.project_id, 'issue_iid': self.iid}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabTodoError, [201, 304]) - - def time_stats(self, **kwargs): - """Get time stats for the issue. - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/issues/%(issue_iid)s/time_stats' % - {'project_id': self.project_id, 'issue_iid': self.iid}) - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabGetError) - return r.json() +class ProjectIssueManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/issues/' + _obj_cls = ProjectIssue + _from_parent_attrs = {'project_id': 'id'} + _list_filters = ('state', 'labels', 'milestone', 'order_by', 'sort') + _create_attrs = (('title', ), + ('description', 'assignee_id', 'milestone_id', 'labels', + 'created_at', 'due_date')) + _update_attrs = (tuple(), ('title', 'description', 'assignee_id', + 'milestone_id', 'labels', 'created_at', + 'updated_at', 'state_event', 'due_date')) - def time_estimate(self, duration, **kwargs): - """Set an estimated time of work for the issue. - Args: - duration (str): duration in human format (e.g. 3h30) - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/issues/%(issue_iid)s/time_estimate' % - {'project_id': self.project_id, 'issue_iid': self.iid}) - data = {'duration': duration} - r = self.gitlab._raw_post(url, data, **kwargs) - raise_error_from_response(r, GitlabTimeTrackingError, 200) - return r.json() - - def reset_time_estimate(self, **kwargs): - """Resets estimated time for the issue to 0 seconds. - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/issues/%(issue_iid)s/' - 'reset_time_estimate' % - {'project_id': self.project_id, 'issue_iid': self.iid}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabTimeTrackingError, 200) - return r.json() - - def add_spent_time(self, duration, **kwargs): - """Set an estimated time of work for the issue. - - Args: - duration (str): duration in human format (e.g. 3h30) - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/issues/%(issue_iid)s/' - 'add_spent_time' % - {'project_id': self.project_id, 'issue_iid': self.iid}) - data = {'duration': duration} - r = self.gitlab._raw_post(url, data, **kwargs) - raise_error_from_response(r, GitlabTimeTrackingError, 201) - return r.json() - - def reset_spent_time(self, **kwargs): - """Set an estimated time of work for the issue. - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/issues/%(issue_iid)s/' - 'reset_spent_time' % - {'project_id': self.project_id, 'issue_iid': self.iid}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabTimeTrackingError, 200) - return r.json() - - -class ProjectIssueManager(BaseManager): - obj_cls = ProjectIssue - - -class ProjectMember(GitlabObject): - _url = '/projects/%(project_id)s/members' - requiredUrlAttrs = ['project_id'] +class ProjectMember(SaveMixin, RESTObject): requiredCreateAttrs = ['access_level', 'user_id'] optionalCreateAttrs = ['expires_at'] requiredUpdateAttrs = ['access_level'] optionalCreateAttrs = ['expires_at'] - shortPrintAttr = 'username' + _short_print_attr = 'username' -class ProjectMemberManager(BaseManager): - obj_cls = ProjectMember +class ProjectMemberManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/members' + _obj_cls = ProjectMember + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('access_level', 'user_id'), ('expires_at', )) + _update_attrs = (('access_level', ), ('expires_at', )) -class ProjectNote(GitlabObject): - _url = '/projects/%(project_id)s/notes' - _constructorTypes = {'author': 'User'} - canUpdate = False - canDelete = False - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['body'] +class ProjectNote(RESTObject): + _constructor_types = {'author': 'User'} -class ProjectNoteManager(BaseManager): - obj_cls = ProjectNote +class ProjectNoteManager(RetrieveMixin, RESTManager): + _path ='/projects/%(project_id)s/notes' + _obj_cls = ProjectNote + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('body', ), tuple()) class ProjectNotificationSettings(NotificationSettings): - _url = '/projects/%(project_id)s/notification_settings' - requiredUrlAttrs = ['project_id'] - - -class ProjectNotificationSettingsManager(BaseManager): - obj_cls = ProjectNotificationSettings + pass -class ProjectTagRelease(GitlabObject): - _url = '/projects/%(project_id)s/repository/tags/%(tag_name)/release' - canDelete = False - canList = False - requiredUrlAttrs = ['project_id', 'tag_name'] - requiredCreateAttrs = ['description'] - shortPrintAttr = 'description' +class ProjectNotificationSettingsManager(NotificationSettingsManager): + _path = '/projects/%(project_id)s/notification_settings' + _obj_cls = ProjectNotificationSettings + _from_parent_attrs = {'project_id': 'id'} -class ProjectTag(GitlabObject): - _url = '/projects/%(project_id)s/repository/tags' - _constructorTypes = {'release': 'ProjectTagRelease', - 'commit': 'ProjectCommit'} - idAttr = 'name' - canGet = 'from_list' - canUpdate = False - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['tag_name', 'ref'] - optionalCreateAttrs = ['message'] - shortPrintAttr = 'name' +class ProjectTag(RESTObject): + _constructor_types = {'release': 'ProjectTagRelease', + 'commit': 'ProjectCommit'} + _id_attr = 'name' + _short_print_attr = 'name' - def set_release_description(self, description): + def set_release_description(self, description, **kwargs): """Set the release notes on the tag. If the release doesn't exist yet, it will be created. If it already @@ -1050,121 +844,64 @@ def set_release_description(self, description): GitlabCreateError: If the server fails to create the release. GitlabUpdateError: If the server fails to update the release. """ - url = '/projects/%s/repository/tags/%s/release' % (self.project_id, - self.name) + _path = '%s/%s/release' % (self.manager.path, self.get_id()) + data = {'description': description} if self.release is None: - r = self.gitlab._raw_post(url, data={'description': description}) - raise_error_from_response(r, GitlabCreateError, 201) + result = self.manager.gitlab.http_post(url, post_data=data, + **kwargs) else: - r = self.gitlab._raw_put(url, data={'description': description}) - raise_error_from_response(r, GitlabUpdateError, 200) - self.release = ProjectTagRelease(self, r.json()) - + result = self.manager.gitlab.http_put(url, post_data=data, + **kwargs) + self.release = result.json() -class ProjectTagManager(BaseManager): - obj_cls = ProjectTag +class ProjectTagManager(GetFromListMixin, CreateMixin, DeleteMixin, + RESTManager): + _path = '/projects/%(project_id)s/repository/tags' + _obj_cls = ProjectTag + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('tag_name', 'ref'), ('message',)) -class ProjectMergeRequestDiff(GitlabObject): - _url = ('/projects/%(project_id)s/merge_requests/' - '%(merge_request_iid)s/versions') - canCreate = False - canUpdate = False - canDelete = False - requiredUrlAttrs = ['project_id', 'merge_request_iid'] - -class ProjectMergeRequestDiffManager(BaseManager): - obj_cls = ProjectMergeRequestDiff - - -class ProjectMergeRequestNote(GitlabObject): - _url = ('/projects/%(project_id)s/merge_requests/%(merge_request_iid)s' - '/notes') - _constructorTypes = {'author': 'User'} - requiredUrlAttrs = ['project_id', 'merge_request_iid'] - requiredCreateAttrs = ['body'] - - -class ProjectMergeRequestNoteManager(BaseManager): - obj_cls = ProjectMergeRequestNote +class ProjectMergeRequestDiff(RESTObject): + pass -class ProjectMergeRequest(GitlabObject): - _url = '/projects/%(project_id)s/merge_requests' - _constructorTypes = {'author': 'User', 'assignee': 'User'} - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['source_branch', 'target_branch', 'title'] - optionalCreateAttrs = ['assignee_id', 'description', 'target_project_id', - 'labels', 'milestone_id', 'remove_source_branch'] - optionalUpdateAttrs = ['target_branch', 'assignee_id', 'title', - 'description', 'state_event', 'labels', - 'milestone_id'] - optionalListAttrs = ['iids', 'state', 'order_by', 'sort'] - idAttr = 'iid' - - managers = ( - ('notes', 'ProjectMergeRequestNoteManager', - [('project_id', 'project_id'), ('merge_request_iid', 'iid')]), - ('diffs', 'ProjectMergeRequestDiffManager', - [('project_id', 'project_id'), ('merge_request_iid', 'iid')]), - ) +class ProjectMergeRequestDiffManager(RetrieveMixin, RESTManager): + _path = '/projects/%(project_id)s/merge_requests/%(mr_iid)s/versions' + _obj_cls = ProjectMergeRequestDiff + _from_parent_attrs = {'project_id': 'project_id', 'mr_iid': 'iid'} - def _data_for_gitlab(self, extra_parameters={}, update=False, - as_json=True): - data = (super(ProjectMergeRequest, self) - ._data_for_gitlab(extra_parameters, update=update, - as_json=False)) - if update: - # Drop source_branch attribute as it is not accepted by the gitlab - # server (Issue #76) - data.pop('source_branch', None) - return json.dumps(data) - def subscribe(self, **kwargs): - """Subscribe to a MR. +class ProjectMergeRequestNote(SaveMixin, RESTObject): + _constructor_types = {'author': 'User'} - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabSubscribeError: If the subscription cannot be done - """ - url = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s/' - 'subscribe' % - {'project_id': self.project_id, 'mr_iid': self.iid}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabSubscribeError, [201, 304]) - if r.status_code == 201: - self._set_from_dict(r.json()) +class ProjectMergeRequestNoteManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/merge_requests/%(mr_iid)s/notes' + _obj_cls = ProjectMergeRequestNote + _from_parent_attrs = {'project_id': 'project_id', 'mr_iid': 'iid'} + _create_attrs = (('body', ), tuple()) + _update_attrs = (('body', ), tuple()) - def unsubscribe(self, **kwargs): - """Unsubscribe a MR. - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabUnsubscribeError: If the unsubscription cannot be done - """ - url = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s/' - 'unsubscribe' % - {'project_id': self.project_id, 'mr_iid': self.iid}) +class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, + SaveMixin, RESTObject): + _constructor_types = {'author': 'User', 'assignee': 'User'} + _id_attr = 'iid' - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabUnsubscribeError, [201, 304]) - if r.status_code == 200: - self._set_from_dict(r.json()) + _managers = ( + ('notes', 'ProjectMergeRequestNoteManager'), + ('diffs', 'ProjectMergeRequestDiffManager') + ) def cancel_merge_when_pipeline_succeeds(self, **kwargs): """Cancel merge when build succeeds.""" - u = ('/projects/%s/merge_requests/%s/' - 'cancel_merge_when_pipeline_succeeds' - % (self.project_id, self.iid)) - r = self.gitlab._raw_put(u, **kwargs) - errors = {401: GitlabMRForbiddenError, - 405: GitlabMRClosedError, - 406: GitlabMROnBuildSuccessError} - raise_error_from_response(r, errors) - return ProjectMergeRequest(self, r.json()) + path = ('%s/%s/cancel_merge_when_pipeline_succeeds' % + (self.manager.path, self.get_id())) + server_data = self.manager.gitlab.http_put(path, **kwargs) + self._update_attrs(server_data) def closes_issues(self, **kwargs): """List issues closed by the MR. @@ -1176,6 +913,7 @@ def closes_issues(self, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ + # FIXME(gpocentek) url = ('/projects/%s/merge_requests/%s/closes_issues' % (self.project_id, self.iid)) return self.gitlab._raw_list(url, ProjectIssue, **kwargs) @@ -1190,6 +928,7 @@ def commits(self, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabListError: If the server fails to perform the request. """ + # FIXME(gpocentek) url = ('/projects/%s/merge_requests/%s/commits' % (self.project_id, self.iid)) return self.gitlab._raw_list(url, ProjectCommit, **kwargs) @@ -1204,11 +943,8 @@ def changes(self, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabListError: If the server fails to perform the request. """ - url = ('/projects/%s/merge_requests/%s/changes' % - (self.project_id, self.iid)) - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabListError) - return r.json() + path = '%s/%s/changes' % (self.manager.path, self.get_id()) + return self.manager.gitlab.http_get(path, **kwargs) def merge(self, merge_commit_message=None, should_remove_source_branch=False, @@ -1231,8 +967,7 @@ def merge(self, merge_commit_message=None, close thr MR GitlabMRClosedError: If the MR is already closed """ - url = '/projects/%s/merge_requests/%s/merge' % (self.project_id, - self.iid) + path = '%s/%s/merge' % (self.manager.path, self.get_id()) data = {} if merge_commit_message: data['merge_commit_message'] = merge_commit_message @@ -1241,114 +976,31 @@ def merge(self, merge_commit_message=None, if merged_when_build_succeeds: data['merged_when_build_succeeds'] = True - r = self.gitlab._raw_put(url, data=data, **kwargs) - errors = {401: GitlabMRForbiddenError, - 405: GitlabMRClosedError} - raise_error_from_response(r, errors) - self._set_from_dict(r.json()) - - def todo(self, **kwargs): - """Create a todo for the merge request. - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s/todo' % - {'project_id': self.project_id, 'mr_iid': self.iid}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabTodoError, [201, 304]) - - def time_stats(self, **kwargs): - """Get time stats for the merge request. - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s/' - 'time_stats' % - {'project_id': self.project_id, 'mr_iid': self.iid}) - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabGetError) - return r.json() - - def time_estimate(self, duration, **kwargs): - """Set an estimated time of work for the merge request. - - Args: - duration (str): duration in human format (e.g. 3h30) - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s/' - 'time_estimate' % - {'project_id': self.project_id, 'mr_iid': self.iid}) - data = {'duration': duration} - r = self.gitlab._raw_post(url, data, **kwargs) - raise_error_from_response(r, GitlabTimeTrackingError, 200) - return r.json() - - def reset_time_estimate(self, **kwargs): - """Resets estimated time for the merge request to 0 seconds. - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s/' - 'reset_time_estimate' % - {'project_id': self.project_id, 'mr_iid': self.iid}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabTimeTrackingError, 200) - return r.json() - - def add_spent_time(self, duration, **kwargs): - """Set an estimated time of work for the merge request. - - Args: - duration (str): duration in human format (e.g. 3h30) - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s/' - 'add_spent_time' % - {'project_id': self.project_id, 'mr_iid': self.iid}) - data = {'duration': duration} - r = self.gitlab._raw_post(url, data, **kwargs) - raise_error_from_response(r, GitlabTimeTrackingError, 201) - return r.json() - - def reset_spent_time(self, **kwargs): - """Set an estimated time of work for the merge request. + server_data = self.manager.gitlab.http_put(path, post_data=data, + **kwargs) + self._update_attrs(server_data) - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s/' - 'reset_spent_time' % - {'project_id': self.project_id, 'mr_iid': self.iid}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabTimeTrackingError, 200) - return r.json() - -class ProjectMergeRequestManager(BaseManager): - obj_cls = ProjectMergeRequest +class ProjectMergeRequestManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/merge_requests' + _obj_cls = ProjectMergeRequest + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = ( + ('source_branch', 'target_branch', 'title'), + ('assignee_id', 'description', 'target_project_id', 'labels', + 'milestone_id', 'remove_source_branch') + ) + _update_attrs = (tuple(), ('target_branch', 'assignee_id', 'title', + 'description', 'state_event', 'labels', + 'milestone_id')) + _list_filters = ('iids', 'state', 'order_by', 'sort') -class ProjectMilestone(GitlabObject): - _url = '/projects/%(project_id)s/milestones' - canDelete = False - requiredUrlAttrs = ['project_id'] - optionalListAttrs = ['iids', 'state'] - requiredCreateAttrs = ['title'] - optionalCreateAttrs = ['description', 'due_date', 'start_date', - 'state_event'] - optionalUpdateAttrs = requiredCreateAttrs + optionalCreateAttrs - shortPrintAttr = 'title' +class ProjectMilestone(SaveMixin, RESTObject): + _short_print_attr = 'title' def issues(self, **kwargs): - url = "/projects/%s/milestones/%s/issues" % (self.project_id, self.id) + url = '/projects/%s/milestones/%s/issues' % (self.project_id, self.id) return self.gitlab._raw_list(url, ProjectIssue, **kwargs) def merge_requests(self, **kwargs): @@ -1361,71 +1013,70 @@ def merge_requests(self, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabListError: If the server fails to perform the request. """ + # FIXME(gpocentek) url = ('/projects/%s/milestones/%s/merge_requests' % (self.project_id, self.id)) return self.gitlab._raw_list(url, ProjectMergeRequest, **kwargs) -class ProjectMilestoneManager(BaseManager): - obj_cls = ProjectMilestone +class ProjectMilestoneManager(RetrieveMixin, CreateMixin, DeleteMixin, + RESTManager): + _path = '/projects/%(project_id)s/milestones' + _obj_cls = ProjectMilestone + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('title', ), ('description', 'due_date', 'start_date', + 'state_event')) + _update_attrs = (tuple(), ('title', 'description', 'due_date', + 'start_date', 'state_event')) + _list_filters = ('iids', 'state') -class ProjectLabel(GitlabObject): - _url = '/projects/%(project_id)s/labels' - _id_in_delete_url = False - _id_in_update_url = False - canGet = 'from_list' - requiredUrlAttrs = ['project_id'] - idAttr = 'name' - requiredDeleteAttrs = ['name'] +class ProjectLabel(SubscribableMixin, SaveMixin, RESTObject): + _id_attr = 'name' requiredCreateAttrs = ['name', 'color'] optionalCreateAttrs = ['description', 'priority'] requiredUpdateAttrs = ['name'] optionalUpdateAttrs = ['new_name', 'color', 'description', 'priority'] - def subscribe(self, **kwargs): - """Subscribe to a label. - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabSubscribeError: If the subscription cannot be done - """ - url = ('/projects/%(project_id)s/labels/%(label_id)s/subscribe' % - {'project_id': self.project_id, 'label_id': self.name}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabSubscribeError, [201, 304]) - self._set_from_dict(r.json()) +class ProjectLabelManager(GetFromListMixin, CreateMixin, UpdateMixin, + RESTManager): + _path = '/projects/%(project_id)s/labels' + _obj_cls = ProjectLabel + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('name', 'color'), ('description', 'priority')) + _update_attrs = (('name', ), + ('new_name', 'color', 'description', 'priority')) - def unsubscribe(self, **kwargs): - """Unsubscribe a label. + # Delete without ID. + def delete(self, name, **kwargs): + """Deletes a Label on the server. - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabUnsubscribeError: If the unsubscription cannot be done + Args: + name: The name of the label. + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) """ - url = ('/projects/%(project_id)s/labels/%(label_id)s/unsubscribe' % - {'project_id': self.project_id, 'label_id': self.name}) + self.gitlab.http_delete(path, query_data={'name': self.name}, **kwargs) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabUnsubscribeError, [201, 304]) - self._set_from_dict(r.json()) + # Update without ID, but we need an ID to get from list. + def save(self, **kwargs): + """Saves the changes made to the object to the server. + Args: + **kwargs: Extra option to send to the server (e.g. sudo) -class ProjectLabelManager(BaseManager): - obj_cls = ProjectLabel + The object is updated to match what the server returns. + """ + updated_data = self._get_updated_data() + # call the manager + server_data = self.manager.update(None, updated_data, **kwargs) + self._update_attrs(server_data) -class ProjectFile(GitlabObject): - _url = '/projects/%(project_id)s/repository/files' - canList = False - requiredUrlAttrs = ['project_id'] - requiredGetAttrs = ['ref'] - requiredCreateAttrs = ['file_path', 'branch', 'content', - 'commit_message'] - optionalCreateAttrs = ['encoding'] - requiredDeleteAttrs = ['branch', 'commit_message', 'file_path'] - shortPrintAttr = 'file_path' + +class ProjectFile(SaveMixin, RESTObject): + _id_attr = 'file_path' + _short_print_attr = 'file_path' def decode(self): """Returns the decoded content of the file. @@ -1436,10 +1087,33 @@ def decode(self): return base64.b64decode(self.content) -class ProjectFileManager(BaseManager): - obj_cls = ProjectFile +class ProjectFileManager(GetMixin, CreateMixin, UpdateMixin, DeleteMixin, + RESTManager): + _path = '/projects/%(project_id)s/repository/files' + _obj_cls = ProjectFile + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('file_path', 'branch', 'content', 'commit_message'), + ('encoding', 'author_email', 'author_name')) + _update_attrs = (('file_path', 'branch', 'content', 'commit_message'), + ('encoding', 'author_email', 'author_name')) + + def get(self, file_path, **kwargs): + """Retrieve a single object. + + Args: + id (int or str): ID of the object to retrieve + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + object: The generated RESTObject. + + Raises: + GitlabGetError: If the server cannot perform the request. + """ + file_path = file_path.replace('/', '%2F') + return GetMixin.get(self, file_path, **kwargs) - def raw(self, filepath, ref, streamed=False, action=None, chunk_size=1024, + def raw(self, file_path, ref, streamed=False, action=None, chunk_size=1024, **kwargs): """Return the content of a file for a commit. @@ -1460,80 +1134,65 @@ def raw(self, filepath, ref, streamed=False, action=None, chunk_size=1024, GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ - url = ("/projects/%s/repository/files/%s/raw" % - (self.parent.id, filepath.replace('/', '%2F'))) - url += '?ref=%s' % ref - r = self.gitlab._raw_get(url, streamed=streamed, **kwargs) - raise_error_from_response(r, GitlabGetError) - return utils.response_content(r, streamed, action, chunk_size) - + file_path = file_path.replace('/', '%2F') + path = '%s/%s/raw' % (self.path, file_path) + query_data = {'ref': ref} + result = self.gitlab.http_get(path, query_data=query_data, + streamed=streamed, **kwargs) + return utils.response_content(result, streamed, action, chunk_size) -class ProjectPipeline(GitlabObject): - _url = '/projects/%(project_id)s/pipelines' - _create_url = '/projects/%(project_id)s/pipeline' - canUpdate = False - canDelete = False - - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['ref'] +class ProjectPipeline(RESTObject): + def cancel(self, **kwargs): + """Cancel the job.""" + path = '%s/%s/cancel' % (self.manager.path, self.get_id()) + self.manager.gitlab.http_post(path) def retry(self, **kwargs): - """Retries failed builds in a pipeline. + """Retry the job.""" + path = '%s/%s/retry' % (self.manager.path, self.get_id()) + self.manager.gitlab.http_post(path) - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabPipelineRetryError: If the retry cannot be done. - """ - url = ('/projects/%(project_id)s/pipelines/%(id)s/retry' % - {'project_id': self.project_id, 'id': self.id}) - r = self.gitlab._raw_post(url, data=None, content_type=None, **kwargs) - raise_error_from_response(r, GitlabPipelineRetryError, 201) - self._set_from_dict(r.json()) - def cancel(self, **kwargs): - """Cancel builds in a pipeline. +class ProjectPipelineManager(RetrieveMixin, CreateMixin, RESTManager): + _path = '/projects/%(project_id)s/pipelines' + _obj_cls = ProjectPipeline + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('ref', ), tuple()) - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabPipelineCancelError: If the retry cannot be done. - """ - url = ('/projects/%(project_id)s/pipelines/%(id)s/cancel' % - {'project_id': self.project_id, 'id': self.id}) - r = self.gitlab._raw_post(url, data=None, content_type=None, **kwargs) - raise_error_from_response(r, GitlabPipelineRetryError, 200) - self._set_from_dict(r.json()) + def create(self, data, **kwargs): + """Creates a new object. + Args: + data (dict): parameters to send to the server to create the + resource + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) -class ProjectPipelineManager(BaseManager): - obj_cls = ProjectPipeline + Returns: + RESTObject: a new instance of the manage object class build with + the data sent by the server + """ + path = self.path[:-1] # drop the 's' + return CreateMixin.create(self, data, path=path, **kwargs) -class ProjectSnippetNote(GitlabObject): - _url = '/projects/%(project_id)s/snippets/%(snippet_id)s/notes' - _constructorTypes = {'author': 'User'} - canUpdate = False - canDelete = False - requiredUrlAttrs = ['project_id', 'snippet_id'] - requiredCreateAttrs = ['body'] +class ProjectSnippetNote(RESTObject): + _constructor_types = {'author': 'User'} -class ProjectSnippetNoteManager(BaseManager): - obj_cls = ProjectSnippetNote +class ProjectSnippetNoteManager(RetrieveMixin, CreateMixin, RESTManager): + _path = '/projects/%(project_id)s/snippets/%(snippet_id)s/notes' + _obj_cls = ProjectSnippetNote + _from_parent_attrs = {'project_id': 'project_id', + 'snippet_id': 'id'} + _create_attrs = (('body', ), tuple()) -class ProjectSnippet(GitlabObject): +class ProjectSnippet(SaveMixin, RESTObject): _url = '/projects/%(project_id)s/snippets' - _constructorTypes = {'author': 'User'} - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['title', 'file_name', 'code'] - optionalCreateAttrs = ['lifetime', 'visibility'] - optionalUpdateAttrs = ['title', 'file_name', 'code', 'visibility'] - shortPrintAttr = 'title' - managers = ( - ('notes', 'ProjectSnippetNoteManager', - [('project_id', 'project_id'), ('snippet_id', 'id')]), - ) + _constructor_types = {'author': 'User'} + _short_print_attr = 'title' + _managers = (('notes', 'ProjectSnippetNoteManager'), ) def content(self, streamed=False, action=None, chunk_size=1024, **kwargs): """Return the raw content of a snippet. @@ -1553,23 +1212,22 @@ def content(self, streamed=False, action=None, chunk_size=1024, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ - url = ("/projects/%(project_id)s/snippets/%(snippet_id)s/raw" % - {'project_id': self.project_id, 'snippet_id': self.id}) - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabGetError) + path = "%s/%s/raw" % (self.manager.path, self.get_id()) + result = self.manager.gitlab.http_get(path, streamed=streamed, + **kwargs) return utils.response_content(r, streamed, action, chunk_size) -class ProjectSnippetManager(BaseManager): - obj_cls = ProjectSnippet - +class ProjectSnippetManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/snippets' + _obj_cls = ProjectSnippet + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('title', 'file_name', 'code'), + ('lifetime', 'visibility')) + _update_attrs = (tuple(), ('title', 'file_name', 'code', 'visibility')) -class ProjectTrigger(GitlabObject): - _url = '/projects/%(project_id)s/triggers' - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['description'] - optionalUpdateAttrs = ['description'] +class ProjectTrigger(SaveMixin, RESTObject): def take_ownership(self, **kwargs): """Update the owner of a trigger. @@ -1577,26 +1235,29 @@ def take_ownership(self, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ - url = ('/projects/%(project_id)s/triggers/%(id)s/take_ownership' % - {'project_id': self.project_id, 'id': self.id}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabUpdateError, 200) - self._set_from_dict(r.json()) + 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 ProjectTriggerManager(BaseManager): - obj_cls = ProjectTrigger +class ProjectTriggerManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/triggers' + _obj_cls = ProjectTrigger + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('description', ), tuple()) + _update_attrs = (('description', ), tuple()) -class ProjectVariable(GitlabObject): - _url = '/projects/%(project_id)s/variables' - idAttr = 'key' - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['key', 'value'] +class ProjectVariable(SaveMixin, RESTObject): + _id_attr = 'key' -class ProjectVariableManager(BaseManager): - obj_cls = ProjectVariable +class ProjectVariableManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/variables' + _obj_cls = ProjectVariable + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('key', 'vaule'), tuple()) + _update_attrs = (('key', 'vaule'), tuple()) class ProjectService(GitlabObject): @@ -1688,113 +1349,70 @@ def available(self, **kwargs): return list(ProjectService._service_attrs.keys()) -class ProjectAccessRequest(GitlabObject): - _url = '/projects/%(project_id)s/access_requests' - canGet = 'from_list' - canUpdate = False +class ProjectAccessRequest(AccessRequestMixin, RESTObject): + pass - def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs): - """Approve an access request. - Attrs: - access_level (int): The access level for the user. +class ProjectAccessRequestManager(GetFromListMixin, CreateMixin, DeleteMixin, + RESTManager): + _path = '/projects/%(project_id)s/access_requests' + _obj_cls = ProjectAccessRequest + _from_parent_attrs = {'project_id': 'id'} - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabUpdateError: If the server fails to perform the request. - """ - url = ('/projects/%(project_id)s/access_requests/%(id)s/approve' % - {'project_id': self.project_id, 'id': self.id}) - data = {'access_level': access_level} - r = self.gitlab._raw_put(url, data=data, **kwargs) - raise_error_from_response(r, GitlabUpdateError, 201) - self._set_from_dict(r.json()) +class ProjectDeployment(RESTObject): + pass -class ProjectAccessRequestManager(BaseManager): - obj_cls = ProjectAccessRequest +class ProjectDeploymentManager(RetrieveMixin, RESTManager): + _path = '/projects/%(project_id)s/deployments' + _obj_cls = ProjectDeployment + _from_parent_attrs = {'project_id': 'id'} -class ProjectDeployment(GitlabObject): - _url = '/projects/%(project_id)s/deployments' - canCreate = False +class ProjectRunner(RESTObject): canUpdate = False - canDelete = False + requiredCreateAttrs = ['runner_id'] -class ProjectDeploymentManager(BaseManager): - obj_cls = ProjectDeployment +class ProjectRunnerManager(NoUpdateMixin, RESTManager): + _path = '/projects/%(project_id)s/runners' + _obj_cls = ProjectRunner + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('runner_id', ), tuple()) -class ProjectRunner(GitlabObject): - _url = '/projects/%(project_id)s/runners' - canUpdate = False - requiredCreateAttrs = ['runner_id'] - -class ProjectRunnerManager(BaseManager): - obj_cls = ProjectRunner - - -class Project(GitlabObject): - _url = '/projects' - _constructorTypes = {'owner': 'User', 'namespace': 'Group'} - optionalListAttrs = ['search'] - requiredCreateAttrs = ['name'] - optionalListAttrs = ['search', 'owned', 'starred', 'archived', - 'visibility', 'order_by', 'sort', 'simple', - 'membership', 'statistics'] - optionalCreateAttrs = ['path', 'namespace_id', 'description', - 'issues_enabled', 'merge_requests_enabled', - 'builds_enabled', 'wiki_enabled', - 'snippets_enabled', 'container_registry_enabled', - 'shared_runners_enabled', 'visibility', - 'import_url', 'public_builds', - 'only_allow_merge_if_build_succeeds', - 'only_allow_merge_if_all_discussions_are_resolved', - 'lfs_enabled', 'request_access_enabled'] - optionalUpdateAttrs = ['name', 'path', 'default_branch', 'description', - 'issues_enabled', 'merge_requests_enabled', - 'builds_enabled', 'wiki_enabled', - 'snippets_enabled', 'container_registry_enabled', - 'shared_runners_enabled', 'visibility', - 'import_url', 'public_builds', - 'only_allow_merge_if_build_succeeds', - 'only_allow_merge_if_all_discussions_are_resolved', - 'lfs_enabled', 'request_access_enabled'] - shortPrintAttr = 'path' - managers = ( - ('accessrequests', 'ProjectAccessRequestManager', - [('project_id', 'id')]), - ('boards', 'ProjectBoardManager', [('project_id', 'id')]), - ('board_lists', 'ProjectBoardListManager', [('project_id', 'id')]), - ('branches', 'ProjectBranchManager', [('project_id', 'id')]), - ('jobs', 'ProjectJobManager', [('project_id', 'id')]), - ('commits', 'ProjectCommitManager', [('project_id', 'id')]), - ('deployments', 'ProjectDeploymentManager', [('project_id', 'id')]), - ('environments', 'ProjectEnvironmentManager', [('project_id', 'id')]), - ('events', 'ProjectEventManager', [('project_id', 'id')]), - ('files', 'ProjectFileManager', [('project_id', 'id')]), - ('forks', 'ProjectForkManager', [('project_id', 'id')]), - ('hooks', 'ProjectHookManager', [('project_id', 'id')]), - ('keys', 'ProjectKeyManager', [('project_id', 'id')]), - ('issues', 'ProjectIssueManager', [('project_id', 'id')]), - ('labels', 'ProjectLabelManager', [('project_id', 'id')]), - ('members', 'ProjectMemberManager', [('project_id', 'id')]), - ('mergerequests', 'ProjectMergeRequestManager', - [('project_id', 'id')]), - ('milestones', 'ProjectMilestoneManager', [('project_id', 'id')]), - ('notes', 'ProjectNoteManager', [('project_id', 'id')]), - ('notificationsettings', 'ProjectNotificationSettingsManager', - [('project_id', 'id')]), - ('pipelines', 'ProjectPipelineManager', [('project_id', 'id')]), - ('runners', 'ProjectRunnerManager', [('project_id', 'id')]), - ('services', 'ProjectServiceManager', [('project_id', 'id')]), - ('snippets', 'ProjectSnippetManager', [('project_id', 'id')]), - ('tags', 'ProjectTagManager', [('project_id', 'id')]), - ('triggers', 'ProjectTriggerManager', [('project_id', 'id')]), - ('variables', 'ProjectVariableManager', [('project_id', 'id')]), +class Project(SaveMixin, RESTObject): + _constructor_types = {'owner': 'User', 'namespace': 'Group'} + _short_print_attr = 'path' + _managers = ( + ('accessrequests', 'ProjectAccessRequestManager'), + ('boards', 'ProjectBoardManager'), + ('branches', 'ProjectBranchManager'), + ('jobs', 'ProjectJobManager'), + ('commits', 'ProjectCommitManager'), + ('deployments', 'ProjectDeploymentManager'), + ('environments', 'ProjectEnvironmentManager'), + ('events', 'ProjectEventManager'), + ('files', 'ProjectFileManager'), + ('forks', 'ProjectForkManager'), + ('hooks', 'ProjectHookManager'), + ('keys', 'ProjectKeyManager'), + ('issues', 'ProjectIssueManager'), + ('labels', 'ProjectLabelManager'), + ('members', 'ProjectMemberManager'), + ('mergerequests', 'ProjectMergeRequestManager'), + ('milestones', 'ProjectMilestoneManager'), + ('notes', 'ProjectNoteManager'), + ('notificationsettings', 'ProjectNotificationSettingsManager'), + ('pipelines', 'ProjectPipelineManager'), + ('runners', 'ProjectRunnerManager'), + ('services', 'ProjectServiceManager'), + ('snippets', 'ProjectSnippetManager'), + ('tags', 'ProjectTagManager'), + ('triggers', 'ProjectTriggerManager'), + ('variables', 'ProjectVariableManager'), ) def repository_tree(self, path='', ref='', **kwargs): @@ -1811,17 +1429,14 @@ def repository_tree(self, path='', ref='', **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ - url = "/projects/%s/repository/tree" % (self.id) - params = [] + path = '/projects/%s/repository/tree' % self.get_id() + query_data = {} if path: - params.append(urllib.urlencode({'path': path})) + query_data['path'] = path if ref: - params.append("ref=%s" % ref) - if params: - url += '?' + "&".join(params) - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabGetError) - return r.json() + query_data['ref'] = ref + return self.manager.gitlab.http_get(path, query_data=query_data, + **kwargs) def repository_raw_blob(self, sha, streamed=False, action=None, chunk_size=1024, **kwargs): @@ -1843,10 +1458,9 @@ def repository_raw_blob(self, sha, streamed=False, action=None, GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ - url = "/projects/%s/repository/raw_blobs/%s" % (self.id, sha) - r = self.gitlab._raw_get(url, streamed=streamed, **kwargs) - raise_error_from_response(r, GitlabGetError) - return utils.response_content(r, streamed, action, chunk_size) + path = '/projects/%s/repository/raw_blobs/%s' % (self.get_id(), sha) + result = self.gitlab._raw_get(url, streamed=streamed, **kwargs) + return utils.response_content(result, streamed, action, chunk_size) def repository_compare(self, from_, to, **kwargs): """Returns a diff between two branches/commits. @@ -1862,13 +1476,12 @@ def repository_compare(self, from_, to, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ - url = "/projects/%s/repository/compare" % self.id - url = "%s?from=%s&to=%s" % (url, from_, to) - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabGetError) - return r.json() + path = '/projects/%s/repository/compare' % self.get_id() + query_data = {'from': from_, 'to': to} + return self.manager.gitlab.http_get(path, query_data=query_data, + **kwargs) - def repository_contributors(self): + def repository_contributors(self, **kwargs): """Returns a list of contributors for the project. Returns: @@ -1878,10 +1491,8 @@ def repository_contributors(self): GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ - url = "/projects/%s/repository/contributors" % self.id - r = self.gitlab._raw_get(url) - raise_error_from_response(r, GitlabListError) - return r.json() + path = '/projects/%s/repository/contributors' % self.get_id() + return self.manager.gitlab.http_get(path, **kwargs) def repository_archive(self, sha=None, streamed=False, action=None, chunk_size=1024, **kwargs): @@ -1903,14 +1514,15 @@ def repository_archive(self, sha=None, streamed=False, action=None, GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ - url = '/projects/%s/repository/archive' % self.id + path = '/projects/%s/repository/archive' % self.get_id() + query_data = {} if sha: - url += '?sha=%s' % sha - r = self.gitlab._raw_get(url, streamed=streamed, **kwargs) - raise_error_from_response(r, GitlabGetError) - return utils.response_content(r, streamed, action, chunk_size) + query_data['sha'] = sha + result = self.gitlab._raw_get(path, query_data=query_data, + streamed=streamed, **kwargs) + return utils.response_content(result, streamed, action, chunk_size) - def create_fork_relation(self, forked_from_id): + def create_fork_relation(self, forked_from_id, **kwargs): """Create a forked from/to relation between existing projects. Args: @@ -1920,20 +1532,18 @@ def create_fork_relation(self, forked_from_id): GitlabConnectionError: If the server cannot be reached. GitlabCreateError: If the server fails to perform the request. """ - url = "/projects/%s/fork/%s" % (self.id, forked_from_id) - r = self.gitlab._raw_post(url) - raise_error_from_response(r, GitlabCreateError, 201) + path = '/projects/%s/fork/%s' % (self.get_id(), forked_from_id) + self.manager.gitlab.http_post(path, **kwargs) - def delete_fork_relation(self): + def delete_fork_relation(self, **kwargs): """Delete a forked relation between existing projects. Raises: GitlabConnectionError: If the server cannot be reached. GitlabDeleteError: If the server fails to perform the request. """ - url = "/projects/%s/fork" % self.id - r = self.gitlab._raw_delete(url) - raise_error_from_response(r, GitlabDeleteError) + path = '/projects/%s/fork' % self.get_id() + self.manager.gitlab.http_delete(path, **kwargs) def star(self, **kwargs): """Star a project. @@ -1945,10 +1555,9 @@ def star(self, **kwargs): GitlabCreateError: If the action cannot be done GitlabConnectionError: If the server cannot be reached. """ - url = "/projects/%s/star" % self.id - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabCreateError, [201, 304]) - return Project(self.gitlab, r.json()) if r.status_code == 201 else self + path = '/projects/%s/star' % self.get_id() + server_data = self.manager.gitlab.http_post(path, **kwargs) + self._update_attrs(server_data) def unstar(self, **kwargs): """Unstar a project. @@ -1960,10 +1569,9 @@ def unstar(self, **kwargs): GitlabDeleteError: If the action cannot be done GitlabConnectionError: If the server cannot be reached. """ - url = "/projects/%s/unstar" % self.id - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabDeleteError, [201, 304]) - return Project(self.gitlab, r.json()) if r.status_code == 201 else self + path = '/projects/%s/unstar' % self.get_id() + server_data = self.manager.gitlab.http_post(path, **kwargs) + self._update_attrs(server_data) def archive(self, **kwargs): """Archive a project. @@ -1975,10 +1583,9 @@ def archive(self, **kwargs): GitlabCreateError: If the action cannot be done GitlabConnectionError: If the server cannot be reached. """ - url = "/projects/%s/archive" % self.id - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabCreateError, 201) - return Project(self.gitlab, r.json()) if r.status_code == 201 else self + path = '/projects/%s/archive' % self.get_id() + server_data = self.manager.gitlab.http_post(path, **kwargs) + self._update_attrs(server_data) def unarchive(self, **kwargs): """Unarchive a project. @@ -1990,12 +1597,11 @@ def unarchive(self, **kwargs): GitlabDeleteError: If the action cannot be done GitlabConnectionError: If the server cannot be reached. """ - url = "/projects/%s/unarchive" % self.id - r = self.gitlab._raw_delete(url, **kwargs) - raise_error_from_response(r, GitlabCreateError, 201) - return Project(self.gitlab, r.json()) if r.status_code == 201 else self + path = '/projects/%s/unarchive' % self.get_id() + server_data = self.manager.gitlab.http_post(url, **kwargs) + self._update_attrs(server_data) - def share(self, group_id, group_access, **kwargs): + def share(self, group_id, group_access, expires_at=None, **kwargs): """Share the project with a group. Args: @@ -2006,10 +1612,11 @@ def share(self, group_id, group_access, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabCreateError: If the server fails to perform the request. """ - url = "/projects/%s/share" % self.id - data = {'group_id': group_id, 'group_access': group_access} - r = self.gitlab._raw_post(url, data=data, **kwargs) - raise_error_from_response(r, GitlabCreateError, 201) + path = '/projects/%s/share' % self.get_id() + data = {'group_id': group_id, + 'group_access': group_access, + 'expires_at': expires_at} + self.manager.gitlab.http_post(path, post_data=data, **kwargs) def trigger_pipeline(self, ref, token, variables={}, **kwargs): """Trigger a CI build. @@ -2025,23 +1632,23 @@ def trigger_pipeline(self, ref, token, variables={}, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabCreateError: If the server fails to perform the request. """ - url = "/projects/%s/trigger/pipeline" % self.id + path = '/projects/%s/trigger/pipeline' % self.get_id() form = {r'variables[%s]' % k: v for k, v in six.iteritems(variables)} - data = {'ref': ref, 'token': token} - data.update(form) - r = self.gitlab._raw_post(url, data=data, **kwargs) - raise_error_from_response(r, GitlabCreateError, 201) + post_data = {'ref': ref, 'token': token} + post_data.update(form) + self.manager.gitlab.http_post(path, post_data=post_data, **kwargs) -class Runner(GitlabObject): - _url = '/runners' - canCreate = False - optionalUpdateAttrs = ['description', 'active', 'tag_list'] - optionalListAttrs = ['scope'] +class Runner(SaveMixin, RESTObject): + pass -class RunnerManager(BaseManager): - obj_cls = Runner +class RunnerManager(RetrieveMixin, UpdateMixin, DeleteMixin, RESTManager): + _path = '/runners' + _obj_cls = Runner + _update_attrs = (tuple(), ('description', 'active', 'tag_list')) + _list_filters = ('scope', ) + def all(self, scope=None, **kwargs): """List all the runners. @@ -2057,79 +1664,95 @@ def all(self, scope=None, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabListError: If the resource cannot be found """ - url = '/runners/all' + path = '/runners/all' + query_data = {} if scope is not None: - url += '?scope=' + scope - return self.gitlab._raw_list(url, self.obj_cls, **kwargs) + query_data['scope'] = scope + return self.gitlab.http_list(path, query_data, **kwargs) -class Todo(GitlabObject): - _url = '/todos' - canGet = 'from_list' - canUpdate = False - canCreate = False - optionalListAttrs = ['action', 'author_id', 'project_id', 'state', 'type'] +class Todo(RESTObject): + def mark_as_done(self, **kwargs): + """Mark the todo as done. + + Args: + **kwargs: Additional data to send to the server (e.g. sudo) + """ + path = '%s/%s/mark_as_done' % (self.manager.path, self.id) + server_data = self.manager.gitlab.http_post(path, **kwargs) + self._update_attrs(server_data) -class TodoManager(BaseManager): - obj_cls = Todo +class TodoManager(GetFromListMixin, DeleteMixin, RESTManager): + _path = '/todos' + _obj_cls = Todo + _list_filters = ('action', 'author_id', 'project_id', 'state', 'type') - def delete_all(self, **kwargs): + def mark_all_as_done(self, **kwargs): """Mark all the todos as done. + Returns: + The number of todos maked done. + Raises: GitlabConnectionError: If the server cannot be reached. GitlabDeleteError: If the resource cannot be found - - Returns: - The number of todos maked done. """ - url = '/todos' - r = self.gitlab._raw_delete(url, **kwargs) - raise_error_from_response(r, GitlabDeleteError) - return int(r.text) - - -class ProjectManager(BaseManager): - obj_cls = Project - + self.gitlab.http_post('/todos/mark_as_done', **kwargs) + + +class ProjectManager(CRUDMixin, RESTManager): + _path = '/projects' + _obj_cls = Project + _create_attrs = ( + ('name', ), + ('path', 'namespace_id', 'description', 'issues_enabled', + 'merge_requests_enabled', 'builds_enabled', 'wiki_enabled', + 'snippets_enabled', 'container_registry_enabled', + 'shared_runners_enabled', 'visibility', 'import_url', 'public_builds', + 'only_allow_merge_if_build_succeeds', + 'only_allow_merge_if_all_discussions_are_resolved', 'lfs_enabled', + 'request_access_enabled') + ) + _update_attrs = ( + tuple(), + ('name', 'path', 'default_branch', 'description', 'issues_enabled', + 'merge_requests_enabled', 'builds_enabled', 'wiki_enabled', + 'snippets_enabled', 'container_registry_enabled', + 'shared_runners_enabled', 'visibility', 'import_url', 'public_builds', + 'only_allow_merge_if_build_succeeds', + 'only_allow_merge_if_all_discussions_are_resolved', 'lfs_enabled', + 'request_access_enabled') + ) + _list_filters = ('search', 'owned', 'starred', 'archived', 'visibility', + 'order_by', 'sort', 'simple', 'membership', 'statistics') -class GroupProject(Project): - _url = '/groups/%(group_id)s/projects' - canGet = 'from_list' - canCreate = False - canDelete = False - canUpdate = False - optionalListAttrs = ['archived', 'visibility', 'order_by', 'sort', - 'search', 'ci_enabled_first'] +class GroupProject(RESTObject): def __init__(self, *args, **kwargs): Project.__init__(self, *args, **kwargs) -class GroupProjectManager(ProjectManager): - obj_cls = GroupProject - - -class Group(GitlabObject): - _url = '/groups' - requiredCreateAttrs = ['name', 'path'] - optionalCreateAttrs = ['description', 'visibility', 'parent_id', - 'lfs_enabled', 'request_access_enabled'] - optionalUpdateAttrs = ['name', 'path', 'description', 'visibility', - 'lfs_enabled', 'request_access_enabled'] - shortPrintAttr = 'name' - managers = ( - ('accessrequests', 'GroupAccessRequestManager', [('group_id', 'id')]), - ('members', 'GroupMemberManager', [('group_id', 'id')]), - ('notificationsettings', 'GroupNotificationSettingsManager', - [('group_id', 'id')]), - ('projects', 'GroupProjectManager', [('group_id', 'id')]), - ('issues', 'GroupIssueManager', [('group_id', 'id')]), +class GroupProjectManager(GetFromListMixin, RESTManager): + _path = '/groups/%(group_id)s/projects' + _obj_cls = GroupProject + _from_parent_attrs = {'group_id': 'id'} + _list_filters = ('archived', 'visibility', 'order_by', 'sort', 'search', + 'ci_enabled_first') + + +class Group(SaveMixin, RESTObject): + _short_print_attr = 'name' + _managers = ( + ('accessrequests', 'GroupAccessRequestManager'), + ('members', 'GroupMemberManager'), + ('notificationsettings', 'GroupNotificationSettingsManager'), + ('projects', 'GroupProjectManager'), + ('issues', 'GroupIssueManager'), ) def transfer_project(self, id, **kwargs): - """Transfers a project to this new groups. + """Transfers a project to this group. Attrs: id (int): ID of the project to transfer. @@ -2139,10 +1762,20 @@ def transfer_project(self, id, **kwargs): GitlabTransferProjectError: If the server fails to perform the request. """ - url = '/groups/%d/projects/%d' % (self.id, id) - r = self.gitlab._raw_post(url, None, **kwargs) - raise_error_from_response(r, GitlabTransferProjectError, 201) + path = '/groups/%d/projects/%d' % (self.id, id) + self.manager.gitlab.http_post(path, **kwargs) -class GroupManager(BaseManager): - obj_cls = Group +class GroupManager(CRUDMixin, RESTManager): + _path = '/groups' + _obj_cls = Group + _create_attrs = ( + ('name', 'path'), + ('description', 'visibility', 'parent_id', 'lfs_enabled', + 'request_access_enabled') + ) + _update_attrs = ( + tuple(), + ('name', 'path', 'description', 'visibility', 'lfs_enabled', + 'request_access_enabled') + ) From d0a933404f4acec28956e1f07e9dcc3261fae87e Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Mon, 29 May 2017 07:01:59 +0200 Subject: [PATCH 10/93] make the tests pass --- gitlab/__init__.py | 9 ++++++--- gitlab/mixins.py | 4 ++-- gitlab/v4/objects.py | 36 ++++++++++++++++++------------------ 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index e9a7e9a8d..d42dbd339 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -197,10 +197,10 @@ def _credentials_auth(self): r = self._raw_post('/session', data, content_type='application/json') raise_error_from_response(r, GitlabAuthenticationError, 201) - self.user = objects.CurrentUser(self, r.json()) + self.user = self._objects.CurrentUser(self, r.json()) else: manager = self._objects.CurrentUserManager() - self.user = credentials_auth(self.email, self.password) + self.user = manager.get(self.email, self.password) self._set_token(self.user.private_token) @@ -211,7 +211,10 @@ def token_auth(self): self._token_auth() def _token_auth(self): - self.user = self._objects.CurrentUserManager(self).get() + if self.api_version == '3': + self.user = self._objects.CurrentUser(self) + else: + self.user = self._objects.CurrentUserManager(self).get() def version(self): """Returns the version and revision of the gitlab server. diff --git a/gitlab/mixins.py b/gitlab/mixins.py index 0a16a92d5..ed3b204a2 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -255,13 +255,13 @@ def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs): path = '%s/%s/approve' % (self.manager.path, self.id) data = {'access_level': access_level} - server_data = self.manager.gitlab.http_put(url, post_data=data, + server_data = self.manager.gitlab.http_put(path, post_data=data, **kwargs) self._update_attrs(server_data) class SubscribableMixin(object): - def subscribe(self, **kwarg): + def subscribe(self, **kwargs): """Subscribe to the object notifications. raises: diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index b547d81a4..8eb977b36 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -23,7 +23,6 @@ import six -import gitlab from gitlab.base import * # noqa from gitlab.exceptions import * # noqa from gitlab.mixins import * # noqa @@ -203,6 +202,7 @@ def credentials_auth(self, email, password): server_data = self.gitlab.http_post('/session', post_data=data) return CurrentUser(self, server_data) + class ApplicationSettings(SaveMixin, RESTObject): _id_attr = None @@ -300,6 +300,7 @@ class GitlabciymlManager(RetrieveMixin, RESTManager): class GroupIssue(RESTObject): pass + class GroupIssueManager(GetFromListMixin, RESTManager): _path = '/groups/%(group_id)s/issues' _obj_cls = GroupIssue @@ -373,7 +374,7 @@ class License(RESTObject): class LicenseManager(RetrieveMixin, RESTManager): _path = '/templates/licenses' _obj_cls = License - _list_filters =('popular') + _list_filters = ('popular', ) _optional_get_attrs = ('project', 'fullname') @@ -402,7 +403,7 @@ def content(self, streamed=False, action=None, chunk_size=1024, **kwargs): path = '/snippets/%s/raw' % self.get_id() result = self.manager.gitlab.http_get(path, streamed=streamed, **kwargs) - return utils.response_content(r, streamed, action, chunk_size) + return utils.response_content(result, streamed, action, chunk_size) class SnippetManager(CRUDMixin, RESTManager): @@ -467,7 +468,7 @@ class ProjectBranch(RESTObject): def protect(self, developers_can_push=False, developers_can_merge=False, **kwargs): """Protects the branch. - + Args: developers_can_push (bool): Set to True if developers are allowed to push to the branch @@ -588,7 +589,8 @@ class ProjectCommitStatus(RESTObject): class ProjectCommitStatusManager(RetrieveMixin, CreateMixin, RESTManager): - _path = '/projects/%(project_id)s/repository/commits/%(commit_id)s/statuses' + _path = ('/projects/%(project_id)s/repository/commits/%(commit_id)s' + '/statuses') _obj_cls = ProjectCommitStatus _from_parent_attrs = {'project_id': 'project_id', 'commit_id': 'id'} _create_attrs = (('state', ), @@ -696,7 +698,7 @@ class ProjectEvent(RESTObject): class ProjectEventManager(GetFromListMixin, RESTManager): - _path ='/projects/%(project_id)s/events' + _path = '/projects/%(project_id)s/events' _obj_cls = ProjectEvent _from_parent_attrs = {'project_id': 'id'} @@ -741,7 +743,7 @@ class ProjectHookManager(CRUDMixin, RESTManager): class ProjectIssueNote(SaveMixin, RESTObject): - _constructor_types= {'author': 'User'} + _constructor_types = {'author': 'User'} class ProjectIssueNoteManager(RetrieveMixin, CreateMixin, UpdateMixin, @@ -754,7 +756,7 @@ class ProjectIssueNoteManager(RetrieveMixin, CreateMixin, UpdateMixin, class ProjectIssue(SubscribableMixin, TodoMixin, TimeTrackingMixin, SaveMixin, - RESTObject): + RESTObject): _constructor_types = {'author': 'User', 'assignee': 'User', 'milestone': 'ProjectMilestone'} _short_print_attr = 'title' @@ -769,7 +771,7 @@ def move(self, to_project_id, **kwargs): """ path = '%s/%s/move' % (self.manager.path, self.get_id()) data = {'to_project_id': to_project_id} - server_data = self.manager.gitlab.http_post(url, post_data=data, + server_data = self.manager.gitlab.http_post(path, post_data=data, **kwargs) self._update_attrs(server_data) @@ -808,7 +810,7 @@ class ProjectNote(RESTObject): class ProjectNoteManager(RetrieveMixin, RESTManager): - _path ='/projects/%(project_id)s/notes' + _path = '/projects/%(project_id)s/notes' _obj_cls = ProjectNote _from_parent_attrs = {'project_id': 'id'} _create_attrs = (('body', ), tuple()) @@ -844,13 +846,13 @@ def set_release_description(self, description, **kwargs): GitlabCreateError: If the server fails to create the release. GitlabUpdateError: If the server fails to update the release. """ - _path = '%s/%s/release' % (self.manager.path, self.get_id()) + path = '%s/%s/release' % (self.manager.path, self.get_id()) data = {'description': description} if self.release is None: - result = self.manager.gitlab.http_post(url, post_data=data, + result = self.manager.gitlab.http_post(path, post_data=data, **kwargs) else: - result = self.manager.gitlab.http_put(url, post_data=data, + result = self.manager.gitlab.http_put(path, post_data=data, **kwargs) self.release = result.json() @@ -1215,7 +1217,7 @@ def content(self, streamed=False, action=None, chunk_size=1024, **kwargs): path = "%s/%s/raw" % (self.manager.path, self.get_id()) result = self.manager.gitlab.http_get(path, streamed=streamed, **kwargs) - return utils.response_content(r, streamed, action, chunk_size) + return utils.response_content(result, streamed, action, chunk_size) class ProjectSnippetManager(CRUDMixin, RESTManager): @@ -1382,7 +1384,6 @@ class ProjectRunnerManager(NoUpdateMixin, RESTManager): _create_attrs = (('runner_id', ), tuple()) - class Project(SaveMixin, RESTObject): _constructor_types = {'owner': 'User', 'namespace': 'Group'} _short_print_attr = 'path' @@ -1459,7 +1460,7 @@ def repository_raw_blob(self, sha, streamed=False, action=None, GitlabGetError: If the server fails to perform the request. """ path = '/projects/%s/repository/raw_blobs/%s' % (self.get_id(), sha) - result = self.gitlab._raw_get(url, streamed=streamed, **kwargs) + result = self.gitlab._raw_get(path, streamed=streamed, **kwargs) return utils.response_content(result, streamed, action, chunk_size) def repository_compare(self, from_, to, **kwargs): @@ -1598,7 +1599,7 @@ def unarchive(self, **kwargs): GitlabConnectionError: If the server cannot be reached. """ path = '/projects/%s/unarchive' % self.get_id() - server_data = self.manager.gitlab.http_post(url, **kwargs) + server_data = self.manager.gitlab.http_post(path, **kwargs) self._update_attrs(server_data) def share(self, group_id, group_access, expires_at=None, **kwargs): @@ -1649,7 +1650,6 @@ class RunnerManager(RetrieveMixin, UpdateMixin, DeleteMixin, RESTManager): _update_attrs = (tuple(), ('description', 'active', 'tag_list')) _list_filters = ('scope', ) - def all(self, scope=None, **kwargs): """List all the runners. From 904c9fadaa892cb4a2dbd12e564841281aa86c51 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Mon, 29 May 2017 22:48:53 +0200 Subject: [PATCH 11/93] Tests and fixes for the http_* methods --- gitlab/__init__.py | 23 ++-- gitlab/exceptions.py | 4 - gitlab/tests/test_gitlab.py | 220 ++++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 17 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index d42dbd339..57a91edcf 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -683,8 +683,8 @@ def http_get(self, path, query_data={}, streamed=False, **kwargs): try: return result.json() except Exception: - raise GitlaParsingError( - message="Failed to parse the server message") + raise GitlabParsingError( + error_message="Failed to parse the server message") else: return result @@ -734,14 +734,11 @@ def http_post(self, path, query_data={}, post_data={}, **kwargs): """ result = self.http_request('post', path, query_data=query_data, post_data=post_data, **kwargs) - if result.headers.get('Content-Type', None) == 'application/json': - try: - return result.json() - except Exception: - raise GitlabParsingError( - message="Failed to parse the server message") - else: - return result.content + try: + return result.json() + except Exception: + raise GitlabParsingError( + error_message="Failed to parse the server message") def http_put(self, path, query_data={}, post_data={}, **kwargs): """Make a PUT request to the Gitlab server. @@ -767,7 +764,7 @@ def http_put(self, path, query_data={}, post_data={}, **kwargs): return result.json() except Exception: raise GitlabParsingError( - message="Failed to parse the server message") + error_message="Failed to parse the server message") def http_delete(self, path, **kwargs): """Make a PUT request to the Gitlab server. @@ -814,7 +811,7 @@ def _query(self, url, query_data={}, **kwargs): self._data = result.json() except Exception: raise GitlabParsingError( - message="Failed to parse the server message") + error_message="Failed to parse the server message") self._current = 0 @@ -822,7 +819,7 @@ def __iter__(self): return self def __len__(self): - return self._total_pages + return int(self._total_pages) def __next__(self): return self.next() diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py index 9f27c21f5..c9048a556 100644 --- a/gitlab/exceptions.py +++ b/gitlab/exceptions.py @@ -55,10 +55,6 @@ class GitlabHttpError(GitlabError): pass -class GitlaParsingError(GitlabHttpError): - pass - - class GitlabListError(GitlabOperationError): pass diff --git a/gitlab/tests/test_gitlab.py b/gitlab/tests/test_gitlab.py index c2cd19bf4..1710fff05 100644 --- a/gitlab/tests/test_gitlab.py +++ b/gitlab/tests/test_gitlab.py @@ -171,6 +171,226 @@ def resp_cont(url, request): self.assertEqual(resp.status_code, 404) +class TestGitlabHttpMethods(unittest.TestCase): + def setUp(self): + self.gl = Gitlab("http://localhost", private_token="private_token", + api_version=4) + + def test_build_url(self): + r = self.gl._build_url('http://localhost/api/v4') + self.assertEqual(r, 'http://localhost/api/v4') + r = self.gl._build_url('https://localhost/api/v4') + self.assertEqual(r, 'https://localhost/api/v4') + r = self.gl._build_url('/projects') + self.assertEqual(r, 'http://localhost/api/v4/projects') + + def test_http_request(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="get") + def resp_cont(url, request): + headers = {'content-type': 'application/json'} + content = '[{"name": "project1"}]' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + http_r = self.gl.http_request('get', '/projects') + http_r.json() + self.assertEqual(http_r.status_code, 200) + + def test_http_request_404(self): + @urlmatch(scheme="http", netloc="localhost", + path="/api/v4/not_there", method="get") + def resp_cont(url, request): + content = {'Here is wh it failed'} + return response(404, content, {}, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabHttpError, + self.gl.http_request, + 'get', '/not_there') + + def test_get_request(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="get") + def resp_cont(url, request): + headers = {'content-type': 'application/json'} + content = '{"name": "project1"}' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + result = self.gl.http_get('/projects') + self.assertIsInstance(result, dict) + self.assertEqual(result['name'], 'project1') + + def test_get_request_raw(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="get") + def resp_cont(url, request): + headers = {'content-type': 'application/octet-stream'} + content = 'content' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + result = self.gl.http_get('/projects') + self.assertEqual(result.content.decode('utf-8'), 'content') + + def test_get_request_404(self): + @urlmatch(scheme="http", netloc="localhost", + path="/api/v4/not_there", method="get") + def resp_cont(url, request): + content = {'Here is wh it failed'} + return response(404, content, {}, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabHttpError, self.gl.http_get, '/not_there') + + def test_get_request_invalid_data(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="get") + def resp_cont(url, request): + headers = {'content-type': 'application/json'} + content = '["name": "project1"]' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabParsingError, self.gl.http_get, + '/projects') + + def test_list_request(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="get") + def resp_cont(url, request): + headers = {'content-type': 'application/json', 'X-Total-Pages': 1} + content = '[{"name": "project1"}]' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + result = self.gl.http_list('/projects') + self.assertIsInstance(result, GitlabList) + self.assertEqual(len(result), 1) + + with HTTMock(resp_cont): + result = self.gl.http_list('/projects', all=True) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + + def test_list_request_404(self): + @urlmatch(scheme="http", netloc="localhost", + path="/api/v4/not_there", method="get") + def resp_cont(url, request): + content = {'Here is wh it failed'} + return response(404, content, {}, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabHttpError, self.gl.http_list, '/not_there') + + def test_list_request_invalid_data(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="get") + def resp_cont(url, request): + headers = {'content-type': 'application/json'} + content = '["name": "project1"]' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabParsingError, self.gl.http_list, + '/projects') + + def test_post_request(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="post") + def resp_cont(url, request): + headers = {'content-type': 'application/json'} + content = '{"name": "project1"}' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + result = self.gl.http_post('/projects') + self.assertIsInstance(result, dict) + self.assertEqual(result['name'], 'project1') + + def test_post_request_404(self): + @urlmatch(scheme="http", netloc="localhost", + path="/api/v4/not_there", method="post") + def resp_cont(url, request): + content = {'Here is wh it failed'} + return response(404, content, {}, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabHttpError, self.gl.http_post, '/not_there') + + def test_post_request_invalid_data(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="post") + def resp_cont(url, request): + headers = {'content-type': 'application/json'} + content = '["name": "project1"]' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabParsingError, self.gl.http_post, + '/projects') + + def test_put_request(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="put") + def resp_cont(url, request): + headers = {'content-type': 'application/json'} + content = '{"name": "project1"}' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + result = self.gl.http_put('/projects') + self.assertIsInstance(result, dict) + self.assertEqual(result['name'], 'project1') + + def test_put_request_404(self): + @urlmatch(scheme="http", netloc="localhost", + path="/api/v4/not_there", method="put") + def resp_cont(url, request): + content = {'Here is wh it failed'} + return response(404, content, {}, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabHttpError, self.gl.http_put, '/not_there') + + def test_put_request_invalid_data(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="put") + def resp_cont(url, request): + headers = {'content-type': 'application/json'} + content = '["name": "project1"]' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabParsingError, self.gl.http_put, + '/projects') + + def test_delete_request(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="delete") + def resp_cont(url, request): + headers = {'content-type': 'application/json'} + content = 'true' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + result = self.gl.http_delete('/projects') + self.assertIsInstance(result, requests.Response) + self.assertEqual(result.json(), True) + + def test_delete_request_404(self): + @urlmatch(scheme="http", netloc="localhost", + path="/api/v4/not_there", method="delete") + def resp_cont(url, request): + content = {'Here is wh it failed'} + return response(404, content, {}, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabHttpError, self.gl.http_delete, + '/not_there') + + class TestGitlabMethods(unittest.TestCase): def setUp(self): self.gl = Gitlab("http://localhost", private_token="private_token", From c5ad54062ad767c0d2882f64381ad15c034e8872 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sat, 27 May 2017 11:48:36 +0200 Subject: [PATCH 12/93] Add lower-level methods for Gitlab() Multiple goals: * Support making direct queries to the Gitlab server, without objects and managers. * Progressively remove the need to know about managers and objects in the Gitlab class; the Gitlab should only be an HTTP proxy to the gitlab server. * With this the objects gain control on how they should do requests. The complexities of dealing with object specifics will be moved in the object classes where they belong. --- gitlab/__init__.py | 221 +++++++++++++++++++++++++++++++++++++++++++ gitlab/exceptions.py | 8 ++ 2 files changed, 229 insertions(+) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 4adc5630d..7bc9ad3f5 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -599,3 +599,224 @@ def update(self, obj, **kwargs): r = self._raw_put(url, data=data, content_type='application/json') raise_error_from_response(r, GitlabUpdateError) return r.json() + + def _build_url(self, path): + """Returns the full url from path. + + If path is already a url, return it unchanged. If it's a path, append + it to the stored url. + + This is a low-level method, different from _construct_url _build_url + have no knowledge of GitlabObject's. + + Returns: + str: The full URL + """ + if path.startswith('http://') or path.startswith('https://'): + return path + else: + return '%s%s' % (self._url, path) + + def http_request(self, verb, path, query_data={}, post_data={}, + streamed=False, **kwargs): + """Make an HTTP request to the Gitlab server. + + Args: + verb (str): The HTTP method to call ('get', 'post', 'put', + 'delete') + path (str): Path or full URL to query ('/projects' or + 'http://whatever/v4/api/projecs') + query_data (dict): Data to send as query parameters + post_data (dict): Data to send in the body (will be converted to + json) + streamed (bool): Whether the data should be streamed + **kwargs: Extra data to make the query (e.g. sudo, per_page, page) + + Returns: + A requests result object. + + Raises: + GitlabHttpError: When the return code is not 2xx + """ + url = self._build_url(path) + params = query_data.copy() + params.update(kwargs) + opts = self._get_session_opts(content_type='application/json') + result = self.session.request(verb, url, json=post_data, + params=params, stream=streamed, **opts) + if not (200 <= result.status_code < 300): + raise GitlabHttpError(response_code=result.status_code) + return result + + def http_get(self, path, query_data={}, streamed=False, **kwargs): + """Make a GET request to the Gitlab server. + + Args: + path (str): Path or full URL to query ('/projects' or + 'http://whatever/v4/api/projecs') + query_data (dict): Data to send as query parameters + streamed (bool): Whether the data should be streamed + **kwargs: Extra data to make the query (e.g. sudo, per_page, page) + + Returns: + A requests result object is streamed is True or the content type is + not json. + The parsed json data otherwise. + + Raises: + GitlabHttpError: When the return code is not 2xx + GitlabParsingError: IF the json data could not be parsed + """ + result = self.http_request('get', path, query_data=query_data, + streamed=streamed, **kwargs) + if (result.headers['Content-Type'] == 'application/json' and + not streamed): + try: + return result.json() + except Exception as e: + raise GitlaParsingError( + message="Failed to parse the server message") + else: + return r + + def http_list(self, path, query_data={}, **kwargs): + """Make a GET request to the Gitlab server for list-oriented queries. + + Args: + path (str): Path or full URL to query ('/projects' or + 'http://whatever/v4/api/projecs') + query_data (dict): Data to send as query parameters + **kwargs: Extra data to make the query (e.g. sudo, per_page, page, + all) + + Returns: + GitlabList: A generator giving access to the objects. If an ``all`` + kwarg is defined and True, returns a list of all the objects (will + possibly make numerous calls to the Gtilab server and eat a lot of + memory) + + Raises: + GitlabHttpError: When the return code is not 2xx + GitlabParsingError: IF the json data could not be parsed + """ + url = self._build_url(path) + get_all = kwargs.pop('all', False) + obj_gen = GitlabList(self, url, query_data, **kwargs) + return list(obj_gen) if get_all else obj_gen + + def http_post(self, path, query_data={}, post_data={}, **kwargs): + """Make a POST request to the Gitlab server. + + Args: + path (str): Path or full URL to query ('/projects' or + 'http://whatever/v4/api/projecs') + query_data (dict): Data to send as query parameters + post_data (dict): Data to send in the body (will be converted to + json) + **kwargs: Extra data to make the query (e.g. sudo, per_page, page) + + Returns: + The parsed json returned by the server. + + Raises: + GitlabHttpError: When the return code is not 2xx + GitlabParsingError: IF the json data could not be parsed + """ + result = self.http_request('post', path, query_data=query_data, + post_data=post_data, **kwargs) + try: + return result.json() + except Exception as e: + raise GitlabParsingError(message="Failed to parse the server message") + + def http_put(self, path, query_data={}, post_data={}, **kwargs): + """Make a PUT request to the Gitlab server. + + Args: + path (str): Path or full URL to query ('/projects' or + 'http://whatever/v4/api/projecs') + query_data (dict): Data to send as query parameters + post_data (dict): Data to send in the body (will be converted to + json) + **kwargs: Extra data to make the query (e.g. sudo, per_page, page) + + Returns: + The parsed json returned by the server. + + Raises: + GitlabHttpError: When the return code is not 2xx + GitlabParsingError: IF the json data could not be parsed + """ + result = self.hhtp_request('put', path, query_data=query_data, + post_data=post_data, **kwargs) + try: + return result.json() + except Exception as e: + raise GitlabParsingError(message="Failed to parse the server message") + + def http_delete(self, path, **kwargs): + """Make a PUT request to the Gitlab server. + + Args: + path (str): Path or full URL to query ('/projects' or + 'http://whatever/v4/api/projecs') + **kwargs: Extra data to make the query (e.g. sudo, per_page, page) + + Returns: + True. + + Raises: + GitlabHttpError: When the return code is not 2xx + """ + result = self.http_request('delete', path, **kwargs) + return True + + +class GitlabList(object): + """Generator representing a list of remote objects. + + The object handles the links returned by a query to the API, and will call + the API again when needed. + """ + + def __init__(self, gl, url, query_data, **kwargs): + self._gl = gl + self._query(url, query_data, **kwargs) + + def _query(self, url, query_data={}, **kwargs): + result = self._gl.http_request('get', url, query_data=query_data, + **kwargs) + try: + self._next_url = result.links['next']['url'] + except KeyError: + self._next_url = None + self._current_page = result.headers.get('X-Page') + self._next_page = result.headers.get('X-Next-Page') + self._per_page = result.headers.get('X-Per-Page') + self._total_pages = result.headers.get('X-Total-Pages') + self._total = result.headers.get('X-Total') + + try: + self._data = result.json() + except Exception as e: + raise GitlabParsingError(message="Failed to parse the server message") + + self._current = 0 + + def __iter__(self): + return self + + def __next__(self): + return self.next() + + def next(self): + try: + item = self._data[self._current] + self._current += 1 + return item + except IndexError: + if self._next_url: + self._query(self._next_url) + return self._data[self._current] + + raise StopIteration diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py index c7d1da66e..401e44c56 100644 --- a/gitlab/exceptions.py +++ b/gitlab/exceptions.py @@ -47,6 +47,14 @@ class GitlabOperationError(GitlabError): pass +class GitlabHttpError(GitlabError): + pass + + +class GitlaParsingError(GitlabHttpError): + pass + + class GitlabListError(GitlabOperationError): pass From d809fefaf5b382f13f8f9da344320741e553ced1 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sat, 27 May 2017 12:06:07 +0200 Subject: [PATCH 13/93] pep8 again --- gitlab/__init__.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 7bc9ad3f5..dbb7f856f 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -673,7 +673,7 @@ def http_get(self, path, query_data={}, streamed=False, **kwargs): not streamed): try: return result.json() - except Exception as e: + except Exception: raise GitlaParsingError( message="Failed to parse the server message") else: @@ -726,8 +726,9 @@ def http_post(self, path, query_data={}, post_data={}, **kwargs): post_data=post_data, **kwargs) try: return result.json() - except Exception as e: - raise GitlabParsingError(message="Failed to parse the server message") + except Exception: + raise GitlabParsingError( + message="Failed to parse the server message") def http_put(self, path, query_data={}, post_data={}, **kwargs): """Make a PUT request to the Gitlab server. @@ -751,8 +752,9 @@ def http_put(self, path, query_data={}, post_data={}, **kwargs): post_data=post_data, **kwargs) try: return result.json() - except Exception as e: - raise GitlabParsingError(message="Failed to parse the server message") + except Exception: + raise GitlabParsingError( + message="Failed to parse the server message") def http_delete(self, path, **kwargs): """Make a PUT request to the Gitlab server. @@ -763,13 +765,12 @@ def http_delete(self, path, **kwargs): **kwargs: Extra data to make the query (e.g. sudo, per_page, page) Returns: - True. + The requests object. Raises: GitlabHttpError: When the return code is not 2xx """ - result = self.http_request('delete', path, **kwargs) - return True + return self.http_request('delete', path, **kwargs) class GitlabList(object): @@ -798,8 +799,9 @@ def _query(self, url, query_data={}, **kwargs): try: self._data = result.json() - except Exception as e: - raise GitlabParsingError(message="Failed to parse the server message") + except Exception: + raise GitlabParsingError( + message="Failed to parse the server message") self._current = 0 From 993d576ba794a29aacd56a7610e79a331789773d Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sat, 27 May 2017 21:45:02 +0200 Subject: [PATCH 14/93] Rework the manager and object classes Add new RESTObject and RESTManager base class, linked to a bunch of Mixin class to implement the actual CRUD methods. Object are generated by the managers, and special cases are handled in the derivated classes. Both ways (old and new) can be used together, migrate only a few v4 objects to the new method as a POC. TODO: handle managers on generated objects (have to deal with attributes in the URLs). --- gitlab/__init__.py | 16 ++- gitlab/base.py | 314 +++++++++++++++++++++++++++++++++++++++++++ gitlab/v4/objects.py | 175 ++++++++++-------------- 3 files changed, 399 insertions(+), 106 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index dbb7f856f..d27fcf7e6 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -644,9 +644,12 @@ def http_request(self, verb, path, query_data={}, post_data={}, opts = self._get_session_opts(content_type='application/json') result = self.session.request(verb, url, json=post_data, params=params, stream=streamed, **opts) - if not (200 <= result.status_code < 300): - raise GitlabHttpError(response_code=result.status_code) - return result + if 200 <= result.status_code < 300: + return result + + + raise GitlabHttpError(response_code=result.status_code, + error_message=result.content) def http_get(self, path, query_data={}, streamed=False, **kwargs): """Make a GET request to the Gitlab server. @@ -748,7 +751,7 @@ def http_put(self, path, query_data={}, post_data={}, **kwargs): GitlabHttpError: When the return code is not 2xx GitlabParsingError: IF the json data could not be parsed """ - result = self.hhtp_request('put', path, query_data=query_data, + result = self.http_request('put', path, query_data=query_data, post_data=post_data, **kwargs) try: return result.json() @@ -808,6 +811,9 @@ def _query(self, url, query_data={}, **kwargs): def __iter__(self): return self + def __len__(self): + return self._total_pages + def __next__(self): return self.next() @@ -819,6 +825,6 @@ def next(self): except IndexError: if self._next_url: self._query(self._next_url) - return self._data[self._current] + return self.next() raise StopIteration diff --git a/gitlab/base.py b/gitlab/base.py index 0d82cf1fc..2e26c6490 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -531,3 +531,317 @@ def __eq__(self, other): def __ne__(self, other): return not self.__eq__(other) + + +class SaveMixin(object): + """Mixin for RESTObject's that can be updated.""" + def save(self, **kwargs): + """Saves the changes made to the object to the server. + + Args: + **kwargs: Extra option to send to the server (e.g. sudo) + + The object is updated to match what the server returns. + """ + updated_data = {} + required, optional = self.manager.get_update_attrs() + for attr in required: + # Get everything required, no matter if it's been updated + updated_data[attr] = getattr(self, attr) + # Add the updated attributes + updated_data.update(self._updated_attrs) + + # class the manager + obj_id = self.get_id() + server_data = self.manager.update(obj_id, updated_data, **kwargs) + self._updated_attrs = {} + self._attrs.update(server_data) + + +class RESTObject(object): + """Represents an object built from server data. + + It holds the attributes know from te server, and the updated attributes in + 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 without + ID in the url. + """ + _id_attr = 'id' + + def __init__(self, manager, attrs): + self.__dict__.update({ + 'manager': manager, + '_attrs': attrs, + '_updated_attrs': {}, + }) + + def __getattr__(self, name): + try: + return self.__dict__['_updated_attrs'][name] + except KeyError: + try: + return self.__dict__['_attrs'][name] + except KeyError: + raise AttributeError(name) + + def __setattr__(self, name, value): + self.__dict__['_updated_attrs'][name] = value + + def __str__(self): + data = self._attrs.copy() + data.update(self._updated_attrs) + return '%s => %s' % (type(self), data) + + def __repr__(self): + if self._id_attr : + return '<%s %s:%s>' % (self.__class__.__name__, + self._id_attr, + self.get_id()) + else: + return '<%s>' % self.__class__.__name__ + + def get_id(self): + if self._id_attr is None: + return None + return getattr(self, self._id_attr) + + +class RESTObjectList(object): + """Generator object representing a list of RESTObject's. + + This generator uses the Gitlab pagination system to fetch new data when + required. + + Note: you should not instanciate such objects, they are returned by calls + to RESTManager.list() + + Args: + manager: Manager to attach to the created objects + obj_cls: Type of objects to create from the json data + _list: A GitlabList object + """ + def __init__(self, manager, obj_cls, _list): + self.manager = manager + self._obj_cls = obj_cls + self._list = _list + + def __iter__(self): + return self + + def __len__(self): + return len(self._list) + + def __next__(self): + return self.next() + + def next(self): + data = self._list.next() + return self._obj_cls(self.manager, data) + + +class GetMixin(object): + def get(self, id, **kwargs): + """Retrieve a single object. + + Args: + id (int or str): ID of the object to retrieve + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + object: The generated RESTObject. + + Raises: + GitlabGetError: If the server cannot perform the request. + """ + path = '%s/%s' % (self._path, id) + server_data = self.gitlab.http_get(path, **kwargs) + return self._obj_cls(self, server_data) + + +class GetWithoutIdMixin(object): + def get(self, **kwargs): + """Retrieve a single object. + + Args: + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + object: The generated RESTObject. + + Raises: + GitlabGetError: If the server cannot perform the request. + """ + server_data = self.gitlab.http_get(self._path, **kwargs) + return self._obj_cls(self, server_data) + + +class ListMixin(object): + def list(self, **kwargs): + """Retrieves a list of objects. + + Args: + **kwargs: Extra data to send to the Gitlab server (e.g. sudo). + If ``all`` is passed and set to True, the entire list of + objects will be returned. + + Returns: + RESTObjectList: Generator going through the list of objects, making + queries to the server when required. + If ``all=True`` is passed as argument, returns + list(RESTObjectList). + """ + + obj = self.gitlab.http_list(self._path, **kwargs) + if isinstance(obj, list): + return [self._obj_cls(self, item) for item in obj] + else: + return RESTObjectList(self, self._obj_cls, obj) + + +class GetFromListMixin(ListMixin): + def get(self, id, **kwargs): + """Retrieve a single object. + + Args: + id (int or str): ID of the object to retrieve + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + object: The generated RESTObject. + + Raises: + GitlabGetError: If the server cannot perform the request. + """ + gen = self.list() + for obj in gen: + if str(obj.get_id()) == str(id): + return obj + + +class RetrieveMixin(ListMixin, GetMixin): + pass + + +class CreateMixin(object): + def _check_missing_attrs(self, data): + required, optional = self.get_create_attrs() + missing = [] + for attr in required: + if attr not in data: + missing.append(attr) + continue + if missing: + raise AttributeError("Missing attributes: %s" % ", ".join(missing)) + + def get_create_attrs(self): + """Returns the required and optional arguments. + + Returns: + tuple: 2 items: list of required arguments and list of optional + arguments for creation (in that order) + """ + if hasattr(self, '_create_attrs'): + return (self._create_attrs['required'], + self._create_attrs['optional']) + return (tuple(), tuple()) + + def create(self, data, **kwargs): + """Created a new object. + + Args: + data (dict): parameters to send to the server to create the + resource + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + RESTObject: a new instance of the manage object class build with + the data sent by the server + """ + self._check_missing_attrs(data) + if hasattr(self, '_sanitize_data'): + data = self._sanitize_data(data, 'create') + server_data = self.gitlab.http_post(self._path, post_data=data, **kwargs) + return self._obj_cls(self, server_data) + + +class UpdateMixin(object): + def _check_missing_attrs(self, data): + required, optional = self.get_update_attrs() + missing = [] + for attr in required: + if attr not in data: + missing.append(attr) + continue + if missing: + raise AttributeError("Missing attributes: %s" % ", ".join(missing)) + + def get_update_attrs(self): + """Returns the required and optional arguments. + + Returns: + tuple: 2 items: list of required arguments and list of optional + arguments for update (in that order) + """ + if hasattr(self, '_update_attrs'): + return (self._update_attrs['required'], + self._update_attrs['optional']) + return (tuple(), tuple()) + + def update(self, id=None, new_data={}, **kwargs): + """Update an object on the server. + + Args: + id: ID of the object to update (can be None if not required) + new_data: the update data for the object + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + dict: The new object data (*not* a RESTObject) + """ + + if id is None: + path = self._path + else: + path = '%s/%s' % (self._path, id) + + self._check_missing_attrs(new_data) + if hasattr(self, '_sanitize_data'): + data = self._sanitize_data(new_data, 'update') + server_data = self.gitlab.http_put(self._path, post_data=data, + **kwargs) + return server_data + + +class DeleteMixin(object): + def delete(self, id, **kwargs): + """Deletes an object on the server. + + Args: + id: ID of the object to delete + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + """ + path = '%s/%s' % (self._path, id) + self.gitlab.http_delete(path, **kwargs) + + +class CRUDMixin(GetMixin, ListMixin, CreateMixin, UpdateMixin, DeleteMixin): + pass + + +class RESTManager(object): + """Base class for CRUD operations on objects. + + Derivated class must define ``_path`` and ``_obj_cls``. + + ``_path``: Base URL path on which requests will be sent (e.g. '/projects') + ``_obj_cls``: The class of objects that will be created + """ + + _path = None + _obj_cls = None + + def __init__(self, gl, parent_attrs={}): + self.gitlab = gl + self._parent_attrs = {} # for nested managers diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 628314994..9e2574e3a 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -40,7 +40,7 @@ ACCESS_OWNER = 50 -class SidekiqManager(object): +class SidekiqManager(RESTManager): """Manager for the Sidekiq methods. This manager doesn't actually manage objects but provides helper fonction @@ -212,133 +212,106 @@ class CurrentUser(GitlabObject): ) -class ApplicationSettings(GitlabObject): - _url = '/application/settings' - _id_in_update_url = False - getRequiresId = False - optionalUpdateAttrs = ['after_sign_out_path', - 'container_registry_token_expire_delay', - 'default_branch_protection', - 'default_project_visibility', - 'default_projects_limit', - 'default_snippet_visibility', - 'domain_blacklist', - 'domain_blacklist_enabled', - 'domain_whitelist', - 'enabled_git_access_protocol', - 'gravatar_enabled', - 'home_page_url', - 'max_attachment_size', - 'repository_storage', - 'restricted_signup_domains', - 'restricted_visibility_levels', - 'session_expire_delay', - 'sign_in_text', - 'signin_enabled', - 'signup_enabled', - 'twitter_sharing_enabled', - 'user_oauth_applications'] - canList = False - canCreate = False - canDelete = False +class ApplicationSettings(SaveMixin, RESTObject): + _id_attr = None + + +class ApplicationSettingsManager(GetWithoutIdMixin, UpdateMixin, RESTManager): + _path = '/application/settings' + _obj_cls = ApplicationSettings + _update_attrs = { + 'required': tuple(), + 'optional': ('after_sign_out_path', + 'container_registry_token_expire_delay', + 'default_branch_protection', 'default_project_visibility', + 'default_projects_limit', 'default_snippet_visibility', + 'domain_blacklist', 'domain_blacklist_enabled', + 'domain_whitelist', 'enabled_git_access_protocol', + 'gravatar_enabled', 'home_page_url', + 'max_attachment_size', 'repository_storage', + 'restricted_signup_domains', + 'restricted_visibility_levels', 'session_expire_delay', + 'sign_in_text', 'signin_enabled', 'signup_enabled', + 'twitter_sharing_enabled', 'user_oauth_applications') + } - def _data_for_gitlab(self, extra_parameters={}, update=False, - as_json=True): - data = (super(ApplicationSettings, self) - ._data_for_gitlab(extra_parameters, update=update, - as_json=False)) - if not self.domain_whitelist: - data.pop('domain_whitelist', None) - return json.dumps(data) + def _sanitize_data(self, data, action): + new_data = data.copy() + if 'domain_whitelist' in data and data['domain_whitelist'] is None: + new_data.pop('domain_whitelist') + return new_data -class ApplicationSettingsManager(BaseManager): - obj_cls = ApplicationSettings +class BroadcastMessage(SaveMixin, RESTObject): + pass -class BroadcastMessage(GitlabObject): - _url = '/broadcast_messages' - requiredCreateAttrs = ['message'] - optionalCreateAttrs = ['starts_at', 'ends_at', 'color', 'font'] - requiredUpdateAttrs = [] - optionalUpdateAttrs = ['message', 'starts_at', 'ends_at', 'color', 'font'] +class BroadcastMessageManager(CRUDMixin, RESTManager): + _path = '/broadcast_messages' + _obj_cls = BroadcastMessage + _create_attrs = { + 'required': ('message', ), + 'optional': ('starts_at', 'ends_at', 'color', 'font'), + } + _update_attrs = { + 'required': tuple(), + 'optional': ('message', 'starts_at', 'ends_at', 'color', 'font'), + } -class BroadcastMessageManager(BaseManager): - obj_cls = BroadcastMessage +class DeployKey(RESTObject): + pass -class DeployKey(GitlabObject): - _url = '/deploy_keys' - canGet = 'from_list' - canCreate = False - canUpdate = False - canDelete = False +class DeployKeyManager(GetFromListMixin, RESTManager): + _path = '/deploy_keys' + _obj_cls = DeployKey -class DeployKeyManager(BaseManager): - obj_cls = DeployKey +class NotificationSettings(SaveMixin, RESTObject): + _id_attr = None -class NotificationSettings(GitlabObject): - _url = '/notification_settings' - _id_in_update_url = False - getRequiresId = False - optionalUpdateAttrs = ['level', - 'notification_email', - 'new_note', - 'new_issue', - 'reopen_issue', - 'close_issue', - 'reassign_issue', - 'new_merge_request', - 'reopen_merge_request', - 'close_merge_request', - 'reassign_merge_request', - 'merge_merge_request'] - canList = False - canCreate = False - canDelete = False +class NotificationSettingsManager(GetWithoutIdMixin, UpdateMixin, RESTManager): + _path = '/notification_settings' + _obj_cls = NotificationSettings -class NotificationSettingsManager(BaseManager): - obj_cls = NotificationSettings + _update_attrs = { + 'required': tuple(), + 'optional': ('level', 'notification_email', 'new_note', 'new_issue', + 'reopen_issue', 'close_issue', 'reassign_issue', + 'new_merge_request', 'reopen_merge_request', + 'close_merge_request', 'reassign_merge_request', + 'merge_merge_request') + } -class Dockerfile(GitlabObject): - _url = '/templates/dockerfiles' - canDelete = False - canUpdate = False - canCreate = False - idAttr = 'name' +class Dockerfile(RESTObject): + _id_attr = 'name' -class DockerfileManager(BaseManager): - obj_cls = Dockerfile +class DockerfileManager(RetrieveMixin, RESTManager): + _path = '/templates/dockerfiles' + _obj_cls = Dockerfile -class Gitignore(GitlabObject): - _url = '/templates/gitignores' - canDelete = False - canUpdate = False - canCreate = False - idAttr = 'name' +class Gitignore(RESTObject): + _id_attr = 'name' -class GitignoreManager(BaseManager): - obj_cls = Gitignore +class GitignoreManager(RetrieveMixin, RESTManager): + _path = '/templates/gitignores' + _obj_cls = Gitignore -class Gitlabciyml(GitlabObject): - _url = '/templates/gitlab_ci_ymls' - canDelete = False - canUpdate = False - canCreate = False - idAttr = 'name' +class Gitlabciyml(RESTObject): + _id_attr = 'name' -class GitlabciymlManager(BaseManager): - obj_cls = Gitlabciyml +class GitlabciymlManager(RetrieveMixin, RESTManager): + _path = '/templates/gitlab_ci_ymls' + _obj_cls = Gitlabciyml class GroupIssue(GitlabObject): From fb5782e691a11aad35e57f55af139ec4b951a225 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 28 May 2017 09:40:01 +0200 Subject: [PATCH 15/93] Move the mixins in their own module --- gitlab/base.py | 189 --------------------------------------- gitlab/mixins.py | 207 +++++++++++++++++++++++++++++++++++++++++++ gitlab/v4/objects.py | 1 + 3 files changed, 208 insertions(+), 189 deletions(-) create mode 100644 gitlab/mixins.py diff --git a/gitlab/base.py b/gitlab/base.py index 2e26c6490..ee54f2ac7 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -641,195 +641,6 @@ def next(self): return self._obj_cls(self.manager, data) -class GetMixin(object): - def get(self, id, **kwargs): - """Retrieve a single object. - - Args: - id (int or str): ID of the object to retrieve - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) - - Returns: - object: The generated RESTObject. - - Raises: - GitlabGetError: If the server cannot perform the request. - """ - path = '%s/%s' % (self._path, id) - server_data = self.gitlab.http_get(path, **kwargs) - return self._obj_cls(self, server_data) - - -class GetWithoutIdMixin(object): - def get(self, **kwargs): - """Retrieve a single object. - - Args: - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) - - Returns: - object: The generated RESTObject. - - Raises: - GitlabGetError: If the server cannot perform the request. - """ - server_data = self.gitlab.http_get(self._path, **kwargs) - return self._obj_cls(self, server_data) - - -class ListMixin(object): - def list(self, **kwargs): - """Retrieves a list of objects. - - Args: - **kwargs: Extra data to send to the Gitlab server (e.g. sudo). - If ``all`` is passed and set to True, the entire list of - objects will be returned. - - Returns: - RESTObjectList: Generator going through the list of objects, making - queries to the server when required. - If ``all=True`` is passed as argument, returns - list(RESTObjectList). - """ - - obj = self.gitlab.http_list(self._path, **kwargs) - if isinstance(obj, list): - return [self._obj_cls(self, item) for item in obj] - else: - return RESTObjectList(self, self._obj_cls, obj) - - -class GetFromListMixin(ListMixin): - def get(self, id, **kwargs): - """Retrieve a single object. - - Args: - id (int or str): ID of the object to retrieve - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) - - Returns: - object: The generated RESTObject. - - Raises: - GitlabGetError: If the server cannot perform the request. - """ - gen = self.list() - for obj in gen: - if str(obj.get_id()) == str(id): - return obj - - -class RetrieveMixin(ListMixin, GetMixin): - pass - - -class CreateMixin(object): - def _check_missing_attrs(self, data): - required, optional = self.get_create_attrs() - missing = [] - for attr in required: - if attr not in data: - missing.append(attr) - continue - if missing: - raise AttributeError("Missing attributes: %s" % ", ".join(missing)) - - def get_create_attrs(self): - """Returns the required and optional arguments. - - Returns: - tuple: 2 items: list of required arguments and list of optional - arguments for creation (in that order) - """ - if hasattr(self, '_create_attrs'): - return (self._create_attrs['required'], - self._create_attrs['optional']) - return (tuple(), tuple()) - - def create(self, data, **kwargs): - """Created a new object. - - Args: - data (dict): parameters to send to the server to create the - resource - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) - - Returns: - RESTObject: a new instance of the manage object class build with - the data sent by the server - """ - self._check_missing_attrs(data) - if hasattr(self, '_sanitize_data'): - data = self._sanitize_data(data, 'create') - server_data = self.gitlab.http_post(self._path, post_data=data, **kwargs) - return self._obj_cls(self, server_data) - - -class UpdateMixin(object): - def _check_missing_attrs(self, data): - required, optional = self.get_update_attrs() - missing = [] - for attr in required: - if attr not in data: - missing.append(attr) - continue - if missing: - raise AttributeError("Missing attributes: %s" % ", ".join(missing)) - - def get_update_attrs(self): - """Returns the required and optional arguments. - - Returns: - tuple: 2 items: list of required arguments and list of optional - arguments for update (in that order) - """ - if hasattr(self, '_update_attrs'): - return (self._update_attrs['required'], - self._update_attrs['optional']) - return (tuple(), tuple()) - - def update(self, id=None, new_data={}, **kwargs): - """Update an object on the server. - - Args: - id: ID of the object to update (can be None if not required) - new_data: the update data for the object - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) - - Returns: - dict: The new object data (*not* a RESTObject) - """ - - if id is None: - path = self._path - else: - path = '%s/%s' % (self._path, id) - - self._check_missing_attrs(new_data) - if hasattr(self, '_sanitize_data'): - data = self._sanitize_data(new_data, 'update') - server_data = self.gitlab.http_put(self._path, post_data=data, - **kwargs) - return server_data - - -class DeleteMixin(object): - def delete(self, id, **kwargs): - """Deletes an object on the server. - - Args: - id: ID of the object to delete - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) - """ - path = '%s/%s' % (self._path, id) - self.gitlab.http_delete(path, **kwargs) - - -class CRUDMixin(GetMixin, ListMixin, CreateMixin, UpdateMixin, DeleteMixin): - pass - - class RESTManager(object): """Base class for CRUD operations on objects. diff --git a/gitlab/mixins.py b/gitlab/mixins.py new file mode 100644 index 000000000..761227630 --- /dev/null +++ b/gitlab/mixins.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2013-2017 Gauvain Pocentek +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +from gitlab import base + + +class GetMixin(object): + def get(self, id, **kwargs): + """Retrieve a single object. + + Args: + id (int or str): ID of the object to retrieve + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + object: The generated RESTObject. + + Raises: + GitlabGetError: If the server cannot perform the request. + """ + path = '%s/%s' % (self._path, id) + server_data = self.gitlab.http_get(path, **kwargs) + return self._obj_cls(self, server_data) + + +class GetWithoutIdMixin(object): + def get(self, **kwargs): + """Retrieve a single object. + + Args: + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + object: The generated RESTObject. + + Raises: + GitlabGetError: If the server cannot perform the request. + """ + server_data = self.gitlab.http_get(self._path, **kwargs) + return self._obj_cls(self, server_data) + + +class ListMixin(object): + def list(self, **kwargs): + """Retrieves a list of objects. + + Args: + **kwargs: Extra data to send to the Gitlab server (e.g. sudo). + If ``all`` is passed and set to True, the entire list of + objects will be returned. + + Returns: + RESTObjectList: Generator going through the list of objects, making + queries to the server when required. + If ``all=True`` is passed as argument, returns + list(RESTObjectList). + """ + + obj = self.gitlab.http_list(self._path, **kwargs) + if isinstance(obj, list): + return [self._obj_cls(self, item) for item in obj] + else: + return base.RESTObjectList(self, self._obj_cls, obj) + + +class GetFromListMixin(ListMixin): + def get(self, id, **kwargs): + """Retrieve a single object. + + Args: + id (int or str): ID of the object to retrieve + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + object: The generated RESTObject. + + Raises: + GitlabGetError: If the server cannot perform the request. + """ + gen = self.list() + for obj in gen: + if str(obj.get_id()) == str(id): + return obj + + +class RetrieveMixin(ListMixin, GetMixin): + pass + + +class CreateMixin(object): + def _check_missing_attrs(self, data): + required, optional = self.get_create_attrs() + missing = [] + for attr in required: + if attr not in data: + missing.append(attr) + continue + if missing: + raise AttributeError("Missing attributes: %s" % ", ".join(missing)) + + def get_create_attrs(self): + """Returns the required and optional arguments. + + Returns: + tuple: 2 items: list of required arguments and list of optional + arguments for creation (in that order) + """ + if hasattr(self, '_create_attrs'): + return (self._create_attrs['required'], + self._create_attrs['optional']) + return (tuple(), tuple()) + + def create(self, data, **kwargs): + """Created a new object. + + Args: + data (dict): parameters to send to the server to create the + resource + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + RESTObject: a new instance of the manage object class build with + the data sent by the server + """ + self._check_missing_attrs(data) + if hasattr(self, '_sanitize_data'): + data = self._sanitize_data(data, 'create') + server_data = self.gitlab.http_post(self._path, post_data=data, **kwargs) + return self._obj_cls(self, server_data) + + +class UpdateMixin(object): + def _check_missing_attrs(self, data): + required, optional = self.get_update_attrs() + missing = [] + for attr in required: + if attr not in data: + missing.append(attr) + continue + if missing: + raise AttributeError("Missing attributes: %s" % ", ".join(missing)) + + def get_update_attrs(self): + """Returns the required and optional arguments. + + Returns: + tuple: 2 items: list of required arguments and list of optional + arguments for update (in that order) + """ + if hasattr(self, '_update_attrs'): + return (self._update_attrs['required'], + self._update_attrs['optional']) + return (tuple(), tuple()) + + def update(self, id=None, new_data={}, **kwargs): + """Update an object on the server. + + Args: + id: ID of the object to update (can be None if not required) + new_data: the update data for the object + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + dict: The new object data (*not* a RESTObject) + """ + + if id is None: + path = self._path + else: + path = '%s/%s' % (self._path, id) + + self._check_missing_attrs(new_data) + if hasattr(self, '_sanitize_data'): + data = self._sanitize_data(new_data, 'update') + server_data = self.gitlab.http_put(self._path, post_data=data, + **kwargs) + return server_data + + +class DeleteMixin(object): + def delete(self, id, **kwargs): + """Deletes an object on the server. + + Args: + id: ID of the object to delete + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + """ + path = '%s/%s' % (self._path, id) + self.gitlab.http_delete(path, **kwargs) + + +class CRUDMixin(GetMixin, ListMixin, CreateMixin, UpdateMixin, DeleteMixin): + pass diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 9e2574e3a..9f16a50be 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -27,6 +27,7 @@ import gitlab from gitlab.base import * # noqa from gitlab.exceptions import * # noqa +from gitlab.mixins import * # noqa from gitlab import utils VISIBILITY_PRIVATE = 'private' From 9fbdb9461a660181a3a268cd398865cafd0b4a89 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 28 May 2017 09:42:07 +0200 Subject: [PATCH 16/93] pep8 --- gitlab/__init__.py | 1 - gitlab/base.py | 2 +- gitlab/mixins.py | 6 +++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index d27fcf7e6..50928ee94 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -647,7 +647,6 @@ def http_request(self, verb, path, query_data={}, post_data={}, if 200 <= result.status_code < 300: return result - raise GitlabHttpError(response_code=result.status_code, error_message=result.content) diff --git a/gitlab/base.py b/gitlab/base.py index ee54f2ac7..2ecf1d255 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -595,7 +595,7 @@ def __str__(self): return '%s => %s' % (type(self), data) def __repr__(self): - if self._id_attr : + if self._id_attr: return '<%s %s:%s>' % (self.__class__.__name__, self._id_attr, self.get_id()) diff --git a/gitlab/mixins.py b/gitlab/mixins.py index 761227630..a81b2ae0e 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -139,7 +139,8 @@ def create(self, data, **kwargs): self._check_missing_attrs(data) if hasattr(self, '_sanitize_data'): data = self._sanitize_data(data, 'create') - server_data = self.gitlab.http_post(self._path, post_data=data, **kwargs) + server_data = self.gitlab.http_post(self._path, post_data=data, + **kwargs) return self._obj_cls(self, server_data) @@ -186,8 +187,7 @@ def update(self, id=None, new_data={}, **kwargs): self._check_missing_attrs(new_data) if hasattr(self, '_sanitize_data'): data = self._sanitize_data(new_data, 'update') - server_data = self.gitlab.http_put(self._path, post_data=data, - **kwargs) + server_data = self.gitlab.http_put(path, post_data=data, **kwargs) return server_data From a50690288f9c03ec37ff374839d1f465c74ecf0a Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 28 May 2017 10:53:54 +0200 Subject: [PATCH 17/93] Add support for managers in objects for new API Convert User* to the new REST* API. --- gitlab/base.py | 33 ++++++++- gitlab/mixins.py | 14 ++-- gitlab/v4/objects.py | 160 ++++++++++++++++++++++--------------------- 3 files changed, 119 insertions(+), 88 deletions(-) diff --git a/gitlab/base.py b/gitlab/base.py index 2ecf1d255..afbcd38b4 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -575,8 +575,14 @@ def __init__(self, manager, attrs): 'manager': manager, '_attrs': attrs, '_updated_attrs': {}, + '_module': importlib.import_module(self.__module__) }) + # TODO(gpocentek): manage the creation of new objects from the received + # data (_constructor_types) + + self._create_managers() + def __getattr__(self, name): try: return self.__dict__['_updated_attrs'][name] @@ -602,6 +608,16 @@ def __repr__(self): else: return '<%s>' % self.__class__.__name__ + def _create_managers(self): + managers = getattr(self, '_managers', None) + if managers is None: + return + + for attr, cls_name in self._managers: + cls = getattr(self._module, cls_name) + manager = cls(self.manager.gitlab, parent=self) + self.__dict__[attr] = manager + def get_id(self): if self._id_attr is None: return None @@ -653,6 +669,19 @@ class RESTManager(object): _path = None _obj_cls = None - def __init__(self, gl, parent_attrs={}): + def __init__(self, gl, parent=None): self.gitlab = gl - self._parent_attrs = {} # for nested managers + self._parent = parent # for nested managers + self._computed_path = self._compute_path() + + def _compute_path(self): + if self._parent is None or not hasattr(self, '_from_parent_attrs'): + return self._path + + data = {self_attr: getattr(self._parent, parent_attr) + for self_attr, parent_attr in self._from_parent_attrs.items()} + return self._path % data + + @property + def path(self): + return self._computed_path diff --git a/gitlab/mixins.py b/gitlab/mixins.py index a81b2ae0e..80ce6c95a 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -32,7 +32,7 @@ def get(self, id, **kwargs): Raises: GitlabGetError: If the server cannot perform the request. """ - path = '%s/%s' % (self._path, id) + path = '%s/%s' % (self.path, id) server_data = self.gitlab.http_get(path, **kwargs) return self._obj_cls(self, server_data) @@ -50,7 +50,7 @@ def get(self, **kwargs): Raises: GitlabGetError: If the server cannot perform the request. """ - server_data = self.gitlab.http_get(self._path, **kwargs) + server_data = self.gitlab.http_get(self.path, **kwargs) return self._obj_cls(self, server_data) @@ -70,7 +70,7 @@ def list(self, **kwargs): list(RESTObjectList). """ - obj = self.gitlab.http_list(self._path, **kwargs) + obj = self.gitlab.http_list(self.path, **kwargs) if isinstance(obj, list): return [self._obj_cls(self, item) for item in obj] else: @@ -139,7 +139,7 @@ def create(self, data, **kwargs): self._check_missing_attrs(data) if hasattr(self, '_sanitize_data'): data = self._sanitize_data(data, 'create') - server_data = self.gitlab.http_post(self._path, post_data=data, + server_data = self.gitlab.http_post(self.path, post_data=data, **kwargs) return self._obj_cls(self, server_data) @@ -180,9 +180,9 @@ def update(self, id=None, new_data={}, **kwargs): """ if id is None: - path = self._path + path = self.path else: - path = '%s/%s' % (self._path, id) + path = '%s/%s' % (self.path, id) self._check_missing_attrs(new_data) if hasattr(self, '_sanitize_data'): @@ -199,7 +199,7 @@ def delete(self, id, **kwargs): id: ID of the object to delete **kwargs: Extra data to send to the Gitlab server (e.g. sudo) """ - path = '%s/%s' % (self._path, id) + path = '%s/%s' % (self.path, id) self.gitlab.http_delete(path, **kwargs) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 9f16a50be..34100d860 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -77,105 +77,107 @@ def compound_metrics(self, **kwargs): return self._simple_get('/sidekiq/compound_metrics', **kwargs) -class UserEmail(GitlabObject): - _url = '/users/%(user_id)s/emails' - canUpdate = False - shortPrintAttr = 'email' - requiredUrlAttrs = ['user_id'] - requiredCreateAttrs = ['email'] +class UserEmail(RESTObject): + _short_print_attr = 'email' -class UserEmailManager(BaseManager): - obj_cls = UserEmail +class UserEmailManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): + _path = '/users/%(user_id)s/emails' + _obj_cls = UserEmail + _from_parent_attrs = {'user_id': 'id'} + _create_attrs = {'required': ('email', ), 'optional': tuple()} -class UserKey(GitlabObject): - _url = '/users/%(user_id)s/keys' - canGet = 'from_list' - canUpdate = False - requiredUrlAttrs = ['user_id'] - requiredCreateAttrs = ['title', 'key'] +class UserKey(RESTObject): + pass -class UserKeyManager(BaseManager): - obj_cls = UserKey +class UserKeyManager(GetFromListMixin, CreateMixin, DeleteMixin, RESTManager): + _path = '/users/%(user_id)s/emails' + _obj_cls = UserKey + _from_parent_attrs = {'user_id': 'id'} + _create_attrs = {'required': ('title', 'key'), 'optional': tuple()} -class UserProject(GitlabObject): - _url = '/projects/user/%(user_id)s' - _constructorTypes = {'owner': 'User', 'namespace': 'Group'} - canUpdate = False - canDelete = False - canList = False - canGet = False - requiredUrlAttrs = ['user_id'] - requiredCreateAttrs = ['name'] - optionalCreateAttrs = ['default_branch', 'issues_enabled', 'wall_enabled', - 'merge_requests_enabled', 'wiki_enabled', - 'snippets_enabled', 'public', 'visibility', - 'description', 'builds_enabled', 'public_builds', - 'import_url', 'only_allow_merge_if_build_succeeds'] +class UserProject(RESTObject): + _constructor_types = {'owner': 'User', 'namespace': 'Group'} -class UserProjectManager(BaseManager): - obj_cls = UserProject +class UserProjectManager(CreateMixin, RESTManager): + _path = '/projects/user/%(user_id)s' + _obj_cls = UserProject + _from_parent_attrs = {'user_id': 'id'} + _create_attrs = { + 'required': ('name', ), + 'optional': ('default_branch', 'issues_enabled', 'wall_enabled', + 'merge_requests_enabled', 'wiki_enabled', + 'snippets_enabled', 'public', 'visibility', 'description', + 'builds_enabled', 'public_builds', 'import_url', + 'only_allow_merge_if_build_succeeds') + } -class User(GitlabObject): - _url = '/users' - shortPrintAttr = 'username' - optionalListAttrs = ['active', 'blocked', 'username', 'extern_uid', - 'provider', 'external'] - requiredCreateAttrs = ['email', 'username', 'name'] - optionalCreateAttrs = ['password', 'reset_password', 'skype', 'linkedin', - 'twitter', 'projects_limit', 'extern_uid', - 'provider', 'bio', 'admin', 'can_create_group', - 'website_url', 'skip_confirmation', 'external', - 'organization', 'location'] - requiredUpdateAttrs = ['email', 'username', 'name'] - optionalUpdateAttrs = ['password', 'skype', 'linkedin', 'twitter', - 'projects_limit', 'extern_uid', 'provider', 'bio', - 'admin', 'can_create_group', 'website_url', - 'skip_confirmation', 'external', 'organization', - 'location'] - managers = ( - ('emails', 'UserEmailManager', [('user_id', 'id')]), - ('keys', 'UserKeyManager', [('user_id', 'id')]), - ('projects', 'UserProjectManager', [('user_id', 'id')]), +class User(SaveMixin, RESTObject): + _short_print_attr = 'username' + _managers = ( + ('emails', 'UserEmailManager'), + ('keys', 'UserKeyManager'), + ('projects', 'UserProjectManager'), ) - def _data_for_gitlab(self, extra_parameters={}, update=False, - as_json=True): - if hasattr(self, 'confirm'): - self.confirm = str(self.confirm).lower() - return super(User, self)._data_for_gitlab(extra_parameters) - def block(self, **kwargs): - """Blocks the user.""" - url = '/users/%s/block' % self.id - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabBlockError, 201) - self.state = 'blocked' + """Blocks the user. + + Returns: + bool: whether the user status has been changed. + """ + path = '/users/%s/block' % self.id + server_data = self.manager.gitlab.http_post(path, **kwargs) + if server_data is True: + self._attrs['state'] = 'blocked' + return server_data def unblock(self, **kwargs): - """Unblocks the user.""" - url = '/users/%s/unblock' % self.id - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabUnblockError, 201) - self.state = 'active' + """Unblocks the user. + + Returns: + bool: whether the user status has been changed. + """ + path = '/users/%s/unblock' % self.id + server_data = self.manager.gitlab.http_post(path, **kwargs) + if server_data is True: + self._attrs['state'] = 'active' + return server_data - def __eq__(self, other): - if type(other) is type(self): - selfdict = self.as_dict() - otherdict = other.as_dict() - selfdict.pop('password', None) - otherdict.pop('password', None) - return selfdict == otherdict - return False +class UserManager(CRUDMixin, RESTManager): + _path = '/users' + _obj_cls = User -class UserManager(BaseManager): - obj_cls = User + _list_filters = ('active', 'blocked', 'username', 'extern_uid', 'provider', + 'external') + _create_attrs = { + 'required': ('email', 'username', 'name'), + 'optional': ('password', 'reset_password', 'skype', 'linkedin', + 'twitter', 'projects_limit', 'extern_uid', 'provider', + 'bio', 'admin', 'can_create_group', 'website_url', + 'skip_confirmation', 'external', 'organization', + 'location') + } + _update_attrs = { + 'required': ('email', 'username', 'name'), + 'optional': ('password', 'skype', 'linkedin', 'twitter', + 'projects_limit', 'extern_uid', 'provider', 'bio', + 'admin', 'can_create_group', 'website_url', + 'skip_confirmation', 'external', 'organization', + 'location') + } + + def _sanitize_data(self, data, action): + new_data = data.copy() + if 'confirm' in data: + new_data['confirm'] = str(new_data['confirm']).lower() + return new_data class CurrentUserEmail(GitlabObject): From a1c9e2bce1d0df0eff0468fabad4919d0565f09f Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 28 May 2017 11:40:44 +0200 Subject: [PATCH 18/93] New API: handle gl.auth() and CurrentUser* classes --- gitlab/__init__.py | 20 ++++++++++------- gitlab/v4/objects.py | 53 ++++++++++++++++++++++++-------------------- 2 files changed, 41 insertions(+), 32 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 50928ee94..2ea5e1471 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -94,6 +94,7 @@ def __init__(self, url, private_token=None, email=None, password=None, objects = importlib.import_module('gitlab.v%s.objects' % self._api_version) + self._objects = objects self.broadcastmessages = objects.BroadcastMessageManager(self) self.deploykeys = objects.DeployKeyManager(self) @@ -191,13 +192,16 @@ def _credentials_auth(self): if not self.email or not self.password: raise GitlabAuthenticationError("Missing email/password") - data = json.dumps({'email': self.email, 'password': self.password}) - r = self._raw_post('/session', data, content_type='application/json') - raise_error_from_response(r, GitlabAuthenticationError, 201) - self.user = CurrentUser(self, r.json()) - """(gitlab.objects.CurrentUser): Object representing the user currently - logged. - """ + if self.api_version == '3': + data = json.dumps({'email': self.email, 'password': self.password}) + r = self._raw_post('/session', data, + content_type='application/json') + raise_error_from_response(r, GitlabAuthenticationError, 201) + self.user = objects.CurrentUser(self, r.json()) + else: + manager = self._objects.CurrentUserManager() + self.user = credentials_auth(self.email, self.password) + self._set_token(self.user.private_token) def token_auth(self): @@ -207,7 +211,7 @@ def token_auth(self): self._token_auth() def _token_auth(self): - self.user = CurrentUser(self) + self.user = self._objects.CurrentUserManager(self).get() def version(self): """Returns the version and revision of the gitlab server. diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 34100d860..62bb0468b 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -180,41 +180,46 @@ def _sanitize_data(self, data, action): return new_data -class CurrentUserEmail(GitlabObject): - _url = '/user/emails' - canUpdate = False - shortPrintAttr = 'email' - requiredCreateAttrs = ['email'] +class CurrentUserEmail(RESTObject): + _short_print_attr = 'email' -class CurrentUserEmailManager(BaseManager): - obj_cls = CurrentUserEmail +class CurrentUserEmailManager(RetrieveMixin, CreateMixin, DeleteMixin, + RESTManager): + _path = '/user/emails' + _obj_cls = CurrentUserEmail + _create_attrs = {'required': ('email', ), 'optional': tuple()} -class CurrentUserKey(GitlabObject): - _url = '/user/keys' - canUpdate = False - shortPrintAttr = 'title' - requiredCreateAttrs = ['title', 'key'] +class CurrentUserKey(RESTObject): + _short_print_attr = 'title' -class CurrentUserKeyManager(BaseManager): - obj_cls = CurrentUserKey +class CurrentUserKeyManager(RetrieveMixin, CreateMixin, DeleteMixin, + RESTManager): + _path = '/user/keys' + _obj_cls = CurrentUserKey + _create_attrs = {'required': ('title', 'key'), 'optional': tuple()} -class CurrentUser(GitlabObject): - _url = '/user' - canList = False - canCreate = False - canUpdate = False - canDelete = False - shortPrintAttr = 'username' - managers = ( - ('emails', 'CurrentUserEmailManager', [('user_id', 'id')]), - ('keys', 'CurrentUserKeyManager', [('user_id', 'id')]), +class CurrentUser(RESTObject): + _id_attr = None + _short_print_attr = 'username' + _managers = ( + ('emails', 'CurrentUserEmailManager'), + ('keys', 'CurrentUserKeyManager'), ) +class CurrentUserManager(GetWithoutIdMixin, RESTManager): + _path = '/user' + _obj_cls = CurrentUser + + def credentials_auth(self, email, password): + data = {'email': email, 'password': password} + server_data = self.gitlab.http_post('/session', post_data=data) + return CurrentUser(self, server_data) + class ApplicationSettings(SaveMixin, RESTObject): _id_attr = None From 0467f779eb1d2649f3626e3817531511d3397038 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 28 May 2017 11:48:26 +0200 Subject: [PATCH 19/93] Simplify SidekiqManager --- gitlab/v4/objects.py | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 62bb0468b..8dec461bd 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -47,34 +47,21 @@ class SidekiqManager(RESTManager): This manager doesn't actually manage objects but provides helper fonction for the sidekiq metrics API. """ - def __init__(self, gl): - """Constructs a Sidekiq manager. - - Args: - gl (gitlab.Gitlab): Gitlab object referencing the GitLab server. - """ - self.gitlab = gl - - def _simple_get(self, url, **kwargs): - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabGetError) - return r.json() - def queue_metrics(self, **kwargs): """Returns the registred queues information.""" - return self._simple_get('/sidekiq/queue_metrics', **kwargs) + return self.gitlab.http_get('/sidekiq/queue_metrics', **kwargs) def process_metrics(self, **kwargs): """Returns the registred sidekiq workers.""" - return self._simple_get('/sidekiq/process_metrics', **kwargs) + return self.gitlab.http_get('/sidekiq/process_metrics', **kwargs) def job_stats(self, **kwargs): """Returns statistics about the jobs performed.""" - return self._simple_get('/sidekiq/job_stats', **kwargs) + return self.gitlab.http_get('/sidekiq/job_stats', **kwargs) def compound_metrics(self, **kwargs): """Returns all available metrics and statistics.""" - return self._simple_get('/sidekiq/compound_metrics', **kwargs) + return self.gitlab.http_get('/sidekiq/compound_metrics', **kwargs) class UserEmail(RESTObject): From f418767ec94c430aabd132d189d1c5e9e2370e68 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 28 May 2017 22:10:27 +0200 Subject: [PATCH 20/93] Migrate all v4 objects to new API Some things are probably broken. Next step is writting unit and functional tests. And fix. --- gitlab/__init__.py | 18 +- gitlab/base.py | 37 +- gitlab/exceptions.py | 4 + gitlab/mixins.py | 175 +++- gitlab/v4/objects.py | 1853 +++++++++++++++++------------------------- 5 files changed, 926 insertions(+), 1161 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 2ea5e1471..e9a7e9a8d 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -683,7 +683,7 @@ def http_get(self, path, query_data={}, streamed=False, **kwargs): raise GitlaParsingError( message="Failed to parse the server message") else: - return r + return result def http_list(self, path, query_data={}, **kwargs): """Make a GET request to the Gitlab server for list-oriented queries. @@ -722,7 +722,8 @@ def http_post(self, path, query_data={}, post_data={}, **kwargs): **kwargs: Extra data to make the query (e.g. sudo, per_page, page) Returns: - The parsed json returned by the server. + The parsed json returned by the server if json is return, else the + raw content. Raises: GitlabHttpError: When the return code is not 2xx @@ -730,11 +731,14 @@ def http_post(self, path, query_data={}, post_data={}, **kwargs): """ result = self.http_request('post', path, query_data=query_data, post_data=post_data, **kwargs) - try: - return result.json() - except Exception: - raise GitlabParsingError( - message="Failed to parse the server message") + if result.headers.get('Content-Type', None) == 'application/json': + try: + return result.json() + except Exception: + raise GitlabParsingError( + message="Failed to parse the server message") + else: + return result.content def http_put(self, path, query_data={}, post_data={}, **kwargs): """Make a PUT request to the Gitlab server. diff --git a/gitlab/base.py b/gitlab/base.py index afbcd38b4..89495544f 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -533,31 +533,6 @@ def __ne__(self, other): return not self.__eq__(other) -class SaveMixin(object): - """Mixin for RESTObject's that can be updated.""" - def save(self, **kwargs): - """Saves the changes made to the object to the server. - - Args: - **kwargs: Extra option to send to the server (e.g. sudo) - - The object is updated to match what the server returns. - """ - updated_data = {} - required, optional = self.manager.get_update_attrs() - for attr in required: - # Get everything required, no matter if it's been updated - updated_data[attr] = getattr(self, attr) - # Add the updated attributes - updated_data.update(self._updated_attrs) - - # class the manager - obj_id = self.get_id() - server_data = self.manager.update(obj_id, updated_data, **kwargs) - self._updated_attrs = {} - self._attrs.update(server_data) - - class RESTObject(object): """Represents an object built from server data. @@ -618,6 +593,10 @@ def _create_managers(self): manager = cls(self.manager.gitlab, parent=self) self.__dict__[attr] = manager + def _update_attrs(self, new_attrs): + self._updated_attrs = {} + self._attrs.update(new_attrs) + def get_id(self): if self._id_attr is None: return None @@ -674,13 +653,15 @@ def __init__(self, gl, parent=None): self._parent = parent # for nested managers self._computed_path = self._compute_path() - def _compute_path(self): + def _compute_path(self, path=None): + if path is None: + path = self._path if self._parent is None or not hasattr(self, '_from_parent_attrs'): - return self._path + return path data = {self_attr: getattr(self._parent, parent_attr) for self_attr, parent_attr in self._from_parent_attrs.items()} - return self._path % data + return path % data @property def path(self): diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py index 401e44c56..9f27c21f5 100644 --- a/gitlab/exceptions.py +++ b/gitlab/exceptions.py @@ -39,6 +39,10 @@ class GitlabAuthenticationError(GitlabError): pass +class GitlabParsingError(GitlabError): + pass + + class GitlabConnectionError(GitlabError): pass diff --git a/gitlab/mixins.py b/gitlab/mixins.py index 80ce6c95a..0a16a92d5 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -15,6 +15,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . +import gitlab from gitlab import base @@ -70,7 +71,10 @@ def list(self, **kwargs): list(RESTObjectList). """ - obj = self.gitlab.http_list(self.path, **kwargs) + # Allow to overwrite the path, handy for custom listings + path = kwargs.pop('path', self.path) + + obj = self.gitlab.http_list(path, **kwargs) if isinstance(obj, list): return [self._obj_cls(self, item) for item in obj] else: @@ -102,7 +106,7 @@ class RetrieveMixin(ListMixin, GetMixin): class CreateMixin(object): - def _check_missing_attrs(self, data): + def _check_missing_create_attrs(self, data): required, optional = self.get_create_attrs() missing = [] for attr in required: @@ -119,13 +123,10 @@ def get_create_attrs(self): tuple: 2 items: list of required arguments and list of optional arguments for creation (in that order) """ - if hasattr(self, '_create_attrs'): - return (self._create_attrs['required'], - self._create_attrs['optional']) - return (tuple(), tuple()) + return getattr(self, '_create_attrs', (tuple(), tuple())) def create(self, data, **kwargs): - """Created a new object. + """Creates a new object. Args: data (dict): parameters to send to the server to create the @@ -136,16 +137,17 @@ def create(self, data, **kwargs): RESTObject: a new instance of the manage object class build with the data sent by the server """ - self._check_missing_attrs(data) + self._check_missing_create_attrs(data) if hasattr(self, '_sanitize_data'): data = self._sanitize_data(data, 'create') - server_data = self.gitlab.http_post(self.path, post_data=data, - **kwargs) + # Handle specific URL for creation + path = kwargs.get('path', self.path) + server_data = self.gitlab.http_post(path, post_data=data, **kwargs) return self._obj_cls(self, server_data) class UpdateMixin(object): - def _check_missing_attrs(self, data): + def _check_missing_update_attrs(self, data): required, optional = self.get_update_attrs() missing = [] for attr in required: @@ -162,10 +164,7 @@ def get_update_attrs(self): tuple: 2 items: list of required arguments and list of optional arguments for update (in that order) """ - if hasattr(self, '_update_attrs'): - return (self._update_attrs['required'], - self._update_attrs['optional']) - return (tuple(), tuple()) + return getattr(self, '_update_attrs', (tuple(), tuple())) def update(self, id=None, new_data={}, **kwargs): """Update an object on the server. @@ -184,9 +183,11 @@ def update(self, id=None, new_data={}, **kwargs): else: path = '%s/%s' % (self.path, id) - self._check_missing_attrs(new_data) + self._check_missing_update_attrs(new_data) if hasattr(self, '_sanitize_data'): data = self._sanitize_data(new_data, 'update') + else: + data = new_data server_data = self.gitlab.http_put(path, post_data=data, **kwargs) return server_data @@ -205,3 +206,145 @@ def delete(self, id, **kwargs): class CRUDMixin(GetMixin, ListMixin, CreateMixin, UpdateMixin, DeleteMixin): pass + + +class NoUpdateMixin(GetMixin, ListMixin, CreateMixin, DeleteMixin): + pass + + +class SaveMixin(object): + """Mixin for RESTObject's that can be updated.""" + def _get_updated_data(self): + updated_data = {} + required, optional = self.manager.get_update_attrs() + for attr in required: + # Get everything required, no matter if it's been updated + updated_data[attr] = getattr(self, attr) + # Add the updated attributes + updated_data.update(self._updated_attrs) + + return updated_data + + def save(self, **kwargs): + """Saves the changes made to the object to the server. + + Args: + **kwargs: Extra option to send to the server (e.g. sudo) + + The object is updated to match what the server returns. + """ + updated_data = self._get_updated_data() + + # call the manager + obj_id = self.get_id() + server_data = self.manager.update(obj_id, updated_data, **kwargs) + self._update_attrs(server_data) + + +class AccessRequestMixin(object): + def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs): + """Approve an access request. + + Attrs: + access_level (int): The access level for the user. + + Raises: + GitlabConnectionError: If the server cannot be reached. + GitlabUpdateError: If the server fails to perform the request. + """ + + path = '%s/%s/approve' % (self.manager.path, self.id) + data = {'access_level': access_level} + server_data = self.manager.gitlab.http_put(url, post_data=data, + **kwargs) + self._update_attrs(server_data) + + +class SubscribableMixin(object): + def subscribe(self, **kwarg): + """Subscribe to the object notifications. + + raises: + gitlabconnectionerror: if the server cannot be reached. + gitlabsubscribeerror: if the subscription cannot be done + """ + path = '%s/%s/subscribe' % (self.manager.path, self.get_id()) + server_data = self.manager.gitlab.http_post(path, **kwargs) + self._update_attrs(server_data) + + def unsubscribe(self, **kwargs): + """Unsubscribe from the object notifications. + + raises: + gitlabconnectionerror: if the server cannot be reached. + gitlabunsubscribeerror: if the unsubscription cannot be done + """ + path = '%s/%s/unsubscribe' % (self.manager.path, self.get_id()) + server_data = self.manager.gitlab.http_post(path, **kwargs) + self._update_attrs(server_data) + + +class TodoMixin(object): + def todo(self, **kwargs): + """Create a todo associated to the object. + + Raises: + GitlabConnectionError: If the server cannot be reached. + """ + path = '%s/%s/todo' % (self.manager.path, self.get_id()) + self.manager.gitlab.http_post(path, **kwargs) + + +class TimeTrackingMixin(object): + def time_stats(self, **kwargs): + """Get time stats for the object. + + Raises: + GitlabConnectionError: If the server cannot be reached. + """ + path = '%s/%s/time_stats' % (self.manager.path, self.get_id()) + return self.manager.gitlab.http_get(path, **kwargs) + + def time_estimate(self, duration, **kwargs): + """Set an estimated time of work for the object. + + Args: + duration (str): duration in human format (e.g. 3h30) + + Raises: + GitlabConnectionError: If the server cannot be reached. + """ + path = '%s/%s/time_estimate' % (self.manager.path, self.get_id()) + data = {'duration': duration} + return self.manager.gitlab.http_post(path, post_data=data, **kwargs) + + def reset_time_estimate(self, **kwargs): + """Resets estimated time for the object to 0 seconds. + + Raises: + GitlabConnectionError: If the server cannot be reached. + """ + path = '%s/%s/rest_time_estimate' % (self.manager.path, self.get_id()) + return self.manager.gitlab.http_post(path, **kwargs) + + def add_spent_time(self, duration, **kwargs): + """Add time spent working on the object. + + Args: + duration (str): duration in human format (e.g. 3h30) + + Raises: + GitlabConnectionError: If the server cannot be reached. + """ + path = '%s/%s/add_spent_time' % (self.manager.path, self.get_id()) + data = {'duration': duration} + return self.manager.gitlab.http_post(path, post_data=data, **kwargs) + + def reset_spent_time(self, **kwargs): + """Resets the time spent working on the object. + + Raises: + GitlabConnectionError: If the server cannot be reached. + """ + path = '%s/%s/reset_spent_time' % (self.manager.path, self.get_id()) + return self.manager.gitlab.http_post(path, **kwargs) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 8dec461bd..b547d81a4 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -22,7 +22,6 @@ import json import six -from six.moves import urllib import gitlab from gitlab.base import * # noqa @@ -72,7 +71,7 @@ class UserEmailManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): _path = '/users/%(user_id)s/emails' _obj_cls = UserEmail _from_parent_attrs = {'user_id': 'id'} - _create_attrs = {'required': ('email', ), 'optional': tuple()} + _create_attrs = (('email', ), tuple()) class UserKey(RESTObject): @@ -83,7 +82,7 @@ class UserKeyManager(GetFromListMixin, CreateMixin, DeleteMixin, RESTManager): _path = '/users/%(user_id)s/emails' _obj_cls = UserKey _from_parent_attrs = {'user_id': 'id'} - _create_attrs = {'required': ('title', 'key'), 'optional': tuple()} + _create_attrs = (('title', 'key'), tuple()) class UserProject(RESTObject): @@ -94,14 +93,13 @@ class UserProjectManager(CreateMixin, RESTManager): _path = '/projects/user/%(user_id)s' _obj_cls = UserProject _from_parent_attrs = {'user_id': 'id'} - _create_attrs = { - 'required': ('name', ), - 'optional': ('default_branch', 'issues_enabled', 'wall_enabled', - 'merge_requests_enabled', 'wiki_enabled', - 'snippets_enabled', 'public', 'visibility', 'description', - 'builds_enabled', 'public_builds', 'import_url', - 'only_allow_merge_if_build_succeeds') - } + _create_attrs = ( + ('name', ), + ('default_branch', 'issues_enabled', 'wall_enabled', + 'merge_requests_enabled', 'wiki_enabled', 'snippets_enabled', + 'public', 'visibility', 'description', 'builds_enabled', + 'public_builds', 'import_url', 'only_allow_merge_if_build_succeeds') + ) class User(SaveMixin, RESTObject): @@ -143,22 +141,20 @@ class UserManager(CRUDMixin, RESTManager): _list_filters = ('active', 'blocked', 'username', 'extern_uid', 'provider', 'external') - _create_attrs = { - 'required': ('email', 'username', 'name'), - 'optional': ('password', 'reset_password', 'skype', 'linkedin', - 'twitter', 'projects_limit', 'extern_uid', 'provider', - 'bio', 'admin', 'can_create_group', 'website_url', - 'skip_confirmation', 'external', 'organization', - 'location') - } - _update_attrs = { - 'required': ('email', 'username', 'name'), - 'optional': ('password', 'skype', 'linkedin', 'twitter', - 'projects_limit', 'extern_uid', 'provider', 'bio', - 'admin', 'can_create_group', 'website_url', - 'skip_confirmation', 'external', 'organization', - 'location') - } + _create_attrs = ( + ('email', 'username', 'name'), + ('password', 'reset_password', 'skype', 'linkedin', 'twitter', + 'projects_limit', 'extern_uid', 'provider', 'bio', 'admin', + 'can_create_group', 'website_url', 'skip_confirmation', 'external', + 'organization', 'location') + ) + _update_attrs = ( + ('email', 'username', 'name'), + ('password', 'skype', 'linkedin', 'twitter', 'projects_limit', + 'extern_uid', 'provider', 'bio', 'admin', 'can_create_group', + 'website_url', 'skip_confirmation', 'external', 'organization', + 'location') + ) def _sanitize_data(self, data, action): new_data = data.copy() @@ -175,7 +171,7 @@ class CurrentUserEmailManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): _path = '/user/emails' _obj_cls = CurrentUserEmail - _create_attrs = {'required': ('email', ), 'optional': tuple()} + _create_attrs = (('email', ), tuple()) class CurrentUserKey(RESTObject): @@ -186,7 +182,7 @@ class CurrentUserKeyManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): _path = '/user/keys' _obj_cls = CurrentUserKey - _create_attrs = {'required': ('title', 'key'), 'optional': tuple()} + _create_attrs = (('title', 'key'), tuple()) class CurrentUser(RESTObject): @@ -214,21 +210,19 @@ class ApplicationSettings(SaveMixin, RESTObject): class ApplicationSettingsManager(GetWithoutIdMixin, UpdateMixin, RESTManager): _path = '/application/settings' _obj_cls = ApplicationSettings - _update_attrs = { - 'required': tuple(), - 'optional': ('after_sign_out_path', - 'container_registry_token_expire_delay', - 'default_branch_protection', 'default_project_visibility', - 'default_projects_limit', 'default_snippet_visibility', - 'domain_blacklist', 'domain_blacklist_enabled', - 'domain_whitelist', 'enabled_git_access_protocol', - 'gravatar_enabled', 'home_page_url', - 'max_attachment_size', 'repository_storage', - 'restricted_signup_domains', - 'restricted_visibility_levels', 'session_expire_delay', - 'sign_in_text', 'signin_enabled', 'signup_enabled', - 'twitter_sharing_enabled', 'user_oauth_applications') - } + _update_attrs = ( + tuple(), + ('after_sign_out_path', 'container_registry_token_expire_delay', + 'default_branch_protection', 'default_project_visibility', + 'default_projects_limit', 'default_snippet_visibility', + 'domain_blacklist', 'domain_blacklist_enabled', 'domain_whitelist', + 'enabled_git_access_protocol', 'gravatar_enabled', 'home_page_url', + 'max_attachment_size', 'repository_storage', + 'restricted_signup_domains', 'restricted_visibility_levels', + 'session_expire_delay', 'sign_in_text', 'signin_enabled', + 'signup_enabled', 'twitter_sharing_enabled', + 'user_oauth_applications') + ) def _sanitize_data(self, data, action): new_data = data.copy() @@ -245,14 +239,9 @@ class BroadcastMessageManager(CRUDMixin, RESTManager): _path = '/broadcast_messages' _obj_cls = BroadcastMessage - _create_attrs = { - 'required': ('message', ), - 'optional': ('starts_at', 'ends_at', 'color', 'font'), - } - _update_attrs = { - 'required': tuple(), - 'optional': ('message', 'starts_at', 'ends_at', 'color', 'font'), - } + _create_attrs = (('message', ), ('starts_at', 'ends_at', 'color', 'font')) + _update_attrs = (tuple(), ('message', 'starts_at', 'ends_at', 'color', + 'font')) class DeployKey(RESTObject): @@ -272,14 +261,13 @@ class NotificationSettingsManager(GetWithoutIdMixin, UpdateMixin, RESTManager): _path = '/notification_settings' _obj_cls = NotificationSettings - _update_attrs = { - 'required': tuple(), - 'optional': ('level', 'notification_email', 'new_note', 'new_issue', - 'reopen_issue', 'close_issue', 'reassign_issue', - 'new_merge_request', 'reopen_merge_request', - 'close_merge_request', 'reassign_merge_request', - 'merge_merge_request') - } + _update_attrs = ( + tuple(), + ('level', 'notification_email', 'new_note', 'new_issue', + 'reopen_issue', 'close_issue', 'reassign_issue', 'new_merge_request', + 'reopen_merge_request', 'close_merge_request', + 'reassign_merge_request', 'merge_merge_request') + ) class Dockerfile(RESTObject): @@ -309,128 +297,92 @@ class GitlabciymlManager(RetrieveMixin, RESTManager): _obj_cls = Gitlabciyml -class GroupIssue(GitlabObject): - _url = '/groups/%(group_id)s/issues' - canGet = 'from_list' - canCreate = False - canUpdate = False - canDelete = False - requiredUrlAttrs = ['group_id'] - optionalListAttrs = ['state', 'labels', 'milestone', 'order_by', 'sort'] - - -class GroupIssueManager(BaseManager): - obj_cls = GroupIssue +class GroupIssue(RESTObject): + pass +class GroupIssueManager(GetFromListMixin, RESTManager): + _path = '/groups/%(group_id)s/issues' + _obj_cls = GroupIssue + _from_parent_attrs = {'group_id': 'id'} + _list_filters = ('state', 'labels', 'milestone', 'order_by', 'sort') -class GroupMember(GitlabObject): - _url = '/groups/%(group_id)s/members' - canGet = 'from_list' - requiredUrlAttrs = ['group_id'] - requiredCreateAttrs = ['access_level', 'user_id'] - optionalCreateAttrs = ['expires_at'] - requiredUpdateAttrs = ['access_level'] - optionalCreateAttrs = ['expires_at'] - shortPrintAttr = 'username' - def _update(self, **kwargs): - self.user_id = self.id - super(GroupMember, self)._update(**kwargs) +class GroupMember(SaveMixin, RESTObject): + _short_print_attr = 'username' -class GroupMemberManager(BaseManager): - obj_cls = GroupMember +class GroupMemberManager(GetFromListMixin, CreateMixin, UpdateMixin, + RESTManager): + _path = '/groups/%(group_id)s/members' + _obj_cls = GroupMember + _from_parent_attrs = {'group_id': 'id'} + _create_attrs = (('access_level', 'user_id'), ('expires_at', )) + _update_attrs = (('access_level', ), ('expires_at', )) class GroupNotificationSettings(NotificationSettings): - _url = '/groups/%(group_id)s/notification_settings' - requiredUrlAttrs = ['group_id'] - - -class GroupNotificationSettingsManager(BaseManager): - obj_cls = GroupNotificationSettings - - -class GroupAccessRequest(GitlabObject): - _url = '/groups/%(group_id)s/access_requests' - canGet = 'from_list' - canUpdate = False + pass - def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs): - """Approve an access request. - Attrs: - access_level (int): The access level for the user. +class GroupNotificationSettingsManager(NotificationSettingsManager): + _path = '/groups/%(group_id)s/notification_settings' + _obj_cls = GroupNotificationSettings + _from_parent_attrs = {'group_id': 'id'} - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabUpdateError: If the server fails to perform the request. - """ - url = ('/groups/%(group_id)s/access_requests/%(id)s/approve' % - {'group_id': self.group_id, 'id': self.id}) - data = {'access_level': access_level} - r = self.gitlab._raw_put(url, data=data, **kwargs) - raise_error_from_response(r, GitlabUpdateError, 201) - self._set_from_dict(r.json()) +class GroupAccessRequest(AccessRequestMixin, RESTObject): + pass -class GroupAccessRequestManager(BaseManager): - obj_cls = GroupAccessRequest +class GroupAccessRequestManager(GetFromListMixin, CreateMixin, DeleteMixin, + RESTManager): + _path = '/groups/%(group_id)s/access_requests' + _obj_cls = GroupAccessRequest + _from_parent_attrs = {'group_id': 'id'} -class Hook(GitlabObject): +class Hook(RESTObject): _url = '/hooks' - canUpdate = False - requiredCreateAttrs = ['url'] - shortPrintAttr = 'url' + _short_print_attr = 'url' -class HookManager(BaseManager): - obj_cls = Hook +class HookManager(NoUpdateMixin, RESTManager): + _path = '/hooks' + _obj_cls = Hook + _create_attrs = (('url', ), tuple()) -class Issue(GitlabObject): +class Issue(RESTObject): _url = '/issues' - _constructorTypes = {'author': 'User', 'assignee': 'User', - 'milestone': 'ProjectMilestone'} - canGet = 'from_list' - canDelete = False - canUpdate = False - canCreate = False - shortPrintAttr = 'title' - optionalListAttrs = ['state', 'labels', 'order_by', 'sort'] + _constructor_types = {'author': 'User', + 'assignee': 'User', + 'milestone': 'ProjectMilestone'} + _short_print_attr = 'title' -class IssueManager(BaseManager): - obj_cls = Issue +class IssueManager(GetFromListMixin, RESTManager): + _path = '/issues' + _obj_cls = Issue + _list_filters = ('state', 'labels', 'order_by', 'sort') -class License(GitlabObject): - _url = '/templates/licenses' - canDelete = False - canUpdate = False - canCreate = False - idAttr = 'key' +class License(RESTObject): + _id_attr = 'key' - optionalListAttrs = ['popular'] - optionalGetAttrs = ['project', 'fullname'] +class LicenseManager(RetrieveMixin, RESTManager): + _path = '/templates/licenses' + _obj_cls = License + _list_filters =('popular') + _optional_get_attrs = ('project', 'fullname') -class LicenseManager(BaseManager): - obj_cls = License +class Snippet(SaveMixin, RESTObject): + _constructor_types = {'author': 'User'} + _short_print_attr = 'title' -class Snippet(GitlabObject): - _url = '/snippets' - _constructorTypes = {'author': 'User'} - requiredCreateAttrs = ['title', 'file_name', 'content'] - optionalCreateAttrs = ['lifetime', 'visibility'] - optionalUpdateAttrs = ['title', 'file_name', 'content', 'visibility'] - shortPrintAttr = 'title' - - def raw(self, streamed=False, action=None, chunk_size=1024, **kwargs): - """Return the raw content of a snippet. + def content(self, streamed=False, action=None, chunk_size=1024, **kwargs): + """Return the content of a snippet. Args: streamed (bool): If True the data will be processed by chunks of @@ -447,14 +399,19 @@ def raw(self, streamed=False, action=None, chunk_size=1024, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ - url = ("/snippets/%(snippet_id)s/raw" % {'snippet_id': self.id}) - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabGetError) + path = '/snippets/%s/raw' % self.get_id() + result = self.manager.gitlab.http_get(path, streamed=streamed, + **kwargs) return utils.response_content(r, streamed, action, chunk_size) -class SnippetManager(BaseManager): - obj_cls = Snippet +class SnippetManager(CRUDMixin, RESTManager): + _path = '/snippets' + _obj_cls = Snippet + _create_attrs = (('title', 'file_name', 'content'), + ('lifetime', 'visibility')) + _update_attrs = (tuple(), + ('title', 'file_name', 'content', 'visibility')) def public(self, **kwargs): """List all the public snippets. @@ -466,116 +423,101 @@ def public(self, **kwargs): Returns: list(gitlab.Gitlab.Snippet): The list of snippets. """ - return self.gitlab._raw_list("/snippets/public", Snippet, **kwargs) + return self.list(path='/snippets/public', **kwargs) -class Namespace(GitlabObject): - _url = '/namespaces' - canGet = 'from_list' - canUpdate = False - canDelete = False - canCreate = False - optionalListAttrs = ['search'] +class Namespace(RESTObject): + pass -class NamespaceManager(BaseManager): - obj_cls = Namespace +class NamespaceManager(GetFromListMixin, RESTManager): + _path = '/namespaces' + _obj_cls = Namespace + _list_filters = ('search', ) -class ProjectBoardList(GitlabObject): - _url = '/projects/%(project_id)s/boards/%(board_id)s/lists' - requiredUrlAttrs = ['project_id', 'board_id'] - _constructorTypes = {'label': 'ProjectLabel'} - requiredCreateAttrs = ['label_id'] - requiredUpdateAttrs = ['position'] +class ProjectBoardList(SaveMixin, RESTObject): + _constructor_types = {'label': 'ProjectLabel'} -class ProjectBoardListManager(BaseManager): - obj_cls = ProjectBoardList +class ProjectBoardListManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/boards/%(board_id)s/lists' + _obj_cls = ProjectBoardList + _from_parent_attrs = {'project_id': 'project_id', + 'board_id': 'id'} + _create_attrs = (('label_id', ), tuple()) + _update_attrs = (('position', ), tuple()) -class ProjectBoard(GitlabObject): - _url = '/projects/%(project_id)s/boards' - requiredUrlAttrs = ['project_id'] - _constructorTypes = {'labels': 'ProjectBoardList'} - canGet = 'from_list' - canUpdate = False - canCreate = False - canDelete = False - managers = ( - ('lists', 'ProjectBoardListManager', - [('project_id', 'project_id'), ('board_id', 'id')]), - ) +class ProjectBoard(RESTObject): + _constructor_types = {'labels': 'ProjectBoardList'} + _managers = (('lists', 'ProjectBoardListManager'), ) -class ProjectBoardManager(BaseManager): - obj_cls = ProjectBoard +class ProjectBoardManager(GetFromListMixin, RESTManager): + _path = '/projects/%(project_id)s/boards' + _obj_cls = ProjectBoard + _from_parent_attrs = {'project_id': 'id'} -class ProjectBranch(GitlabObject): - _url = '/projects/%(project_id)s/repository/branches' - _constructorTypes = {'author': 'User', "committer": "User"} +class ProjectBranch(RESTObject): + _constructor_types = {'author': 'User', "committer": "User"} + _id_attr = 'name' - idAttr = 'name' - canUpdate = False - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['branch', 'ref'] - - def protect(self, protect=True, **kwargs): - """Protects the branch.""" - url = self._url % {'project_id': self.project_id} - action = 'protect' if protect else 'unprotect' - url = "%s/%s/%s" % (url, self.name, action) - r = self.gitlab._raw_put(url, data=None, content_type=None, **kwargs) - raise_error_from_response(r, GitlabProtectError) - - if protect: - self.protected = protect - else: - del self.protected + def protect(self, developers_can_push=False, developers_can_merge=False, + **kwargs): + """Protects the branch. + + Args: + developers_can_push (bool): Set to True if developers are allowed + to push to the branch + developers_can_merge (bool): Set to True if developers are allowed + to merge to the branch + """ + path = '%s/%s/protect' % (self.manager.path, self.get_id()) + post_data = {'developers_can_push': developers_can_push, + 'developers_can_merge': developers_can_merge} + self.manager.gitlab.http_put(path, post_data=post_data, **kwargs) + self._attrs['protected'] = True def unprotect(self, **kwargs): """Unprotects the branch.""" - self.protect(False, **kwargs) + path = '%s/%s/protect' % (self.manager.path, self.get_id()) + self.manager.gitlab.http_put(path, **kwargs) + self._attrs['protected'] = False -class ProjectBranchManager(BaseManager): - obj_cls = ProjectBranch +class ProjectBranchManager(NoUpdateMixin, RESTManager): + _path = '/projects/%(project_id)s/repository/branches' + _obj_cls = ProjectBranch + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('branch', 'ref'), tuple()) -class ProjectJob(GitlabObject): - _url = '/projects/%(project_id)s/jobs' - _constructorTypes = {'user': 'User', - 'commit': 'ProjectCommit', - 'runner': 'Runner'} - requiredUrlAttrs = ['project_id'] - canDelete = False - canUpdate = False - canCreate = False +class ProjectJob(RESTObject): + _constructor_types = {'user': 'User', + 'commit': 'ProjectCommit', + 'runner': 'Runner'} def cancel(self, **kwargs): """Cancel the job.""" - url = '/projects/%s/jobs/%s/cancel' % (self.project_id, self.id) - r = self.gitlab._raw_post(url) - raise_error_from_response(r, GitlabJobCancelError, 201) + path = '%s/%s/cancel' % (self.manager.path, self.get_id()) + self.manager.gitlab.http_post(path) def retry(self, **kwargs): """Retry the job.""" - url = '/projects/%s/jobs/%s/retry' % (self.project_id, self.id) - r = self.gitlab._raw_post(url) - raise_error_from_response(r, GitlabJobRetryError, 201) + path = '%s/%s/retry' % (self.manager.path, self.get_id()) + self.manager.gitlab.http_post(path) def play(self, **kwargs): """Trigger a job explicitly.""" - url = '/projects/%s/jobs/%s/play' % (self.project_id, self.id) - r = self.gitlab._raw_post(url) - raise_error_from_response(r, GitlabJobPlayError) + path = '%s/%s/play' % (self.manager.path, self.get_id()) + self.manager.gitlab.http_post(path) def erase(self, **kwargs): """Erase the job (remove job artifacts and trace).""" - url = '/projects/%s/jobs/%s/erase' % (self.project_id, self.id) - r = self.gitlab._raw_post(url) - raise_error_from_response(r, GitlabJobEraseError, 201) + path = '%s/%s/erase' % (self.manager.path, self.get_id()) + self.manager.gitlab.http_post(path) def keep_artifacts(self, **kwargs): """Prevent artifacts from being delete when expiration is set. @@ -584,10 +526,8 @@ def keep_artifacts(self, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabCreateError: If the request failed. """ - url = ('/projects/%s/jobs/%s/artifacts/keep' % - (self.project_id, self.id)) - r = self.gitlab._raw_post(url) - raise_error_from_response(r, GitlabGetError, 200) + path = '%s/%s/artifacts/keep' % (self.manager.path, self.get_id()) + self.manager.gitlab.http_post(path) def artifacts(self, streamed=False, action=None, chunk_size=1024, **kwargs): @@ -608,10 +548,10 @@ def artifacts(self, streamed=False, action=None, chunk_size=1024, GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the artifacts are not available. """ - url = '/projects/%s/jobs/%s/artifacts' % (self.project_id, self.id) - r = self.gitlab._raw_get(url, streamed=streamed, **kwargs) - raise_error_from_response(r, GitlabGetError, 200) - return utils.response_content(r, streamed, action, chunk_size) + path = '%s/%s/artifacts' % (self.manager.path, self.get_id()) + result = self.manager.gitlab.get_http(path, streamed=streamed, + **kwargs) + return utils.response_content(result, streamed, action, chunk_size) def trace(self, streamed=False, action=None, chunk_size=1024, **kwargs): """Get the job trace. @@ -631,96 +571,70 @@ def trace(self, streamed=False, action=None, chunk_size=1024, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the trace is not available. """ - url = '/projects/%s/jobs/%s/trace' % (self.project_id, self.id) - r = self.gitlab._raw_get(url, streamed=streamed, **kwargs) - raise_error_from_response(r, GitlabGetError, 200) - return utils.response_content(r, streamed, action, chunk_size) + path = '%s/%s/trace' % (self.manager.path, self.get_id()) + result = self.manager.gitlab.get_http(path, streamed=streamed, + **kwargs) + return utils.response_content(result, streamed, action, chunk_size) -class ProjectJobManager(BaseManager): - obj_cls = ProjectJob +class ProjectJobManager(RetrieveMixin, RESTManager): + _path = '/projects/%(project_id)s/jobs' + _obj_cls = ProjectJob + _from_parent_attrs = {'project_id': 'id'} -class ProjectCommitStatus(GitlabObject): - _url = '/projects/%(project_id)s/repository/commits/%(commit_id)s/statuses' - _create_url = '/projects/%(project_id)s/statuses/%(commit_id)s' - canUpdate = False - canDelete = False - requiredUrlAttrs = ['project_id', 'commit_id'] - optionalGetAttrs = ['ref_name', 'stage', 'name', 'all'] - requiredCreateAttrs = ['state'] - optionalCreateAttrs = ['description', 'name', 'context', 'ref', - 'target_url'] - - -class ProjectCommitStatusManager(BaseManager): - obj_cls = ProjectCommitStatus +class ProjectCommitStatus(RESTObject): + pass -class ProjectCommitComment(GitlabObject): - _url = '/projects/%(project_id)s/repository/commits/%(commit_id)s/comments' - canUpdate = False - canGet = False - canDelete = False - requiredUrlAttrs = ['project_id', 'commit_id'] - requiredCreateAttrs = ['note'] - optionalCreateAttrs = ['path', 'line', 'line_type'] +class ProjectCommitStatusManager(RetrieveMixin, CreateMixin, RESTManager): + _path = '/projects/%(project_id)s/repository/commits/%(commit_id)s/statuses' + _obj_cls = ProjectCommitStatus + _from_parent_attrs = {'project_id': 'project_id', 'commit_id': 'id'} + _create_attrs = (('state', ), + ('description', 'name', 'context', 'ref', 'target_url')) + def create(self, data, **kwargs): + """Creates a new object. -class ProjectCommitCommentManager(BaseManager): - obj_cls = ProjectCommitComment + Args: + data (dict): parameters to send to the server to create the + resource + **kwargs: Extra data to send to the Gitlab server (e.g. sudo or + 'ref_name', 'stage', 'name', 'all'. + Returns: + RESTObject: a new instance of the manage object class build with + the data sent by the server + """ + path = '/projects/%(project_id)s/statuses/%(commit_id)s' + computed_path = self._compute_path(path) + return CreateMixin.create(self, data, path=computed_path, **kwargs) -class ProjectCommit(GitlabObject): - _url = '/projects/%(project_id)s/repository/commits' - canDelete = False - canUpdate = False - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['branch', 'commit_message', 'actions'] - optionalCreateAttrs = ['author_email', 'author_name'] - shortPrintAttr = 'title' - managers = ( - ('comments', 'ProjectCommitCommentManager', - [('project_id', 'project_id'), ('commit_id', 'id')]), - ('statuses', 'ProjectCommitStatusManager', - [('project_id', 'project_id'), ('commit_id', 'id')]), - ) - def diff(self, **kwargs): - """Generate the commit diff.""" - url = ('/projects/%(project_id)s/repository/commits/%(commit_id)s/diff' - % {'project_id': self.project_id, 'commit_id': self.id}) - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabGetError) +class ProjectCommitComment(RESTObject): + pass - return r.json() - def blob(self, filepath, streamed=False, action=None, chunk_size=1024, - **kwargs): - """Generate the content of a file for this commit. +class ProjectCommitCommentManager(ListMixin, CreateMixin, RESTManager): + _path = ('/projects/%(project_id)s/repository/commits/%(commit_id)s' + '/comments') + _obj_cls = ProjectCommitComment + _from_parent_attrs = {'project_id': 'project_id', 'commit_id': 'id'} + _create_attrs = (('note', ), ('path', 'line', 'line_type')) - Args: - filepath (str): Path of the file to request. - streamed (bool): If True the data will be processed by chunks of - `chunk_size` and each chunk is passed to `action` for - treatment. - action (callable): Callable responsible of dealing with chunk of - data. - chunk_size (int): Size of each chunk. - Returns: - str: The content of the file +class ProjectCommit(RESTObject): + _short_print_attr = 'title' + _managers = ( + ('comments', 'ProjectCommitCommentManager'), + ('statuses', 'ProjectCommitStatusManager'), + ) - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the server fails to perform the request. - """ - url = ('/projects/%(project_id)s/repository/blobs/%(commit_id)s' % - {'project_id': self.project_id, 'commit_id': self.id}) - url += '?filepath=%s' % filepath - r = self.gitlab._raw_get(url, streamed=streamed, **kwargs) - raise_error_from_response(r, GitlabGetError) - return utils.response_content(r, streamed, action, chunk_size) + def diff(self, **kwargs): + """Generate the commit diff.""" + path = '%s/%s/diff' % (self.manager.path, self.get_id()) + return self.manager.gitlab.http_get(path, **kwargs) def cherry_pick(self, branch, **kwargs): """Cherry-pick a commit into a branch. @@ -731,151 +645,121 @@ def cherry_pick(self, branch, **kwargs): Raises: GitlabCherryPickError: If the cherry pick could not be applied. """ - url = ('/projects/%s/repository/commits/%s/cherry_pick' % - (self.project_id, self.id)) + path = '%s/%s/cherry_pick' % (self.manager.path, self.get_id()) + post_data = {'branch': branch} + self.manager.gitlab.http_post(path, post_data=post_data, **kwargs) - r = self.gitlab._raw_post(url, data={'project_id': self.project_id, - 'branch': branch}, **kwargs) - errors = {400: GitlabCherryPickError} - raise_error_from_response(r, errors, expected_code=201) +class ProjectCommitManager(RetrieveMixin, CreateMixin, RESTManager): + _path = '/projects/%(project_id)s/repository/commits' + _obj_cls = ProjectCommit + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('branch', 'commit_message', 'actions'), + ('author_email', 'author_name')) -class ProjectCommitManager(BaseManager): - obj_cls = ProjectCommit +class ProjectEnvironment(SaveMixin, RESTObject): + pass -class ProjectEnvironment(GitlabObject): - _url = '/projects/%(project_id)s/environments' - canGet = 'from_list' - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['name'] - optionalCreateAttrs = ['external_url'] - optionalUpdateAttrs = ['name', 'external_url'] +class ProjectEnvironmentManager(GetFromListMixin, CreateMixin, UpdateMixin, + DeleteMixin, RESTManager): + _path = '/projects/%(project_id)s/environments' + _obj_cls = ProjectEnvironment + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('name', ), ('external_url', )) + _update_attrs = (tuple(), ('name', 'external_url')) -class ProjectEnvironmentManager(BaseManager): - obj_cls = ProjectEnvironment +class ProjectKey(RESTObject): + pass -class ProjectKey(GitlabObject): - _url = '/projects/%(project_id)s/deploy_keys' - canUpdate = False - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['title', 'key'] +class ProjectKeyManager(NoUpdateMixin, RESTManager): + _path = '/projects/%(project_id)s/deploy_keys' + _obj_cls = ProjectKey + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('title', 'key'), tuple()) -class ProjectKeyManager(BaseManager): - obj_cls = ProjectKey + def enable(self, key_id, **kwargs): + """Enable a deploy key for a project. - def enable(self, key_id): - """Enable a deploy key for a project.""" - url = '/projects/%s/deploy_keys/%s/enable' % (self.parent.id, key_id) - r = self.gitlab._raw_post(url) - raise_error_from_response(r, GitlabProjectDeployKeyError, 201) + Args: + key_id (int): The ID of the key to enable + """ + path = '%s/%s/enable' % (self.manager.path, key_id) + self.manager.gitlab.http_post(path, **kwargs) -class ProjectEvent(GitlabObject): - _url = '/projects/%(project_id)s/events' - canGet = 'from_list' - canDelete = False - canUpdate = False - canCreate = False - requiredUrlAttrs = ['project_id'] - shortPrintAttr = 'target_title' +class ProjectEvent(RESTObject): + _short_print_attr = 'target_title' -class ProjectEventManager(BaseManager): - obj_cls = ProjectEvent +class ProjectEventManager(GetFromListMixin, RESTManager): + _path ='/projects/%(project_id)s/events' + _obj_cls = ProjectEvent + _from_parent_attrs = {'project_id': 'id'} -class ProjectFork(GitlabObject): - _url = '/projects/%(project_id)s/fork' - canUpdate = False - canDelete = False - canList = False - canGet = False - requiredUrlAttrs = ['project_id'] - optionalCreateAttrs = ['namespace'] +class ProjectFork(RESTObject): + pass -class ProjectForkManager(BaseManager): - obj_cls = ProjectFork +class ProjectForkManager(CreateMixin, RESTManager): + _path = '/projects/%(project_id)s/fork' + _obj_cls = ProjectFork + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (tuple(), ('namespace', )) -class ProjectHook(GitlabObject): - _url = '/projects/%(project_id)s/hooks' +class ProjectHook(SaveMixin, RESTObject): requiredUrlAttrs = ['project_id'] requiredCreateAttrs = ['url'] optionalCreateAttrs = ['push_events', 'issues_events', 'note_events', 'merge_requests_events', 'tag_push_events', 'build_events', 'enable_ssl_verification', 'token', 'pipeline_events'] - shortPrintAttr = 'url' - - -class ProjectHookManager(BaseManager): - obj_cls = ProjectHook - - -class ProjectIssueNote(GitlabObject): - _url = '/projects/%(project_id)s/issues/%(issue_iid)s/notes' - _constructorTypes = {'author': 'User'} - canDelete = False - requiredUrlAttrs = ['project_id', 'issue_iid'] - requiredCreateAttrs = ['body'] - optionalCreateAttrs = ['created_at'] - - -class ProjectIssueNoteManager(BaseManager): - obj_cls = ProjectIssueNote - - -class ProjectIssue(GitlabObject): - _url = '/projects/%(project_id)s/issues/' - _constructorTypes = {'author': 'User', 'assignee': 'User', - 'milestone': 'ProjectMilestone'} - optionalListAttrs = ['state', 'labels', 'milestone', 'order_by', 'sort'] - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['title'] - optionalCreateAttrs = ['description', 'assignee_id', 'milestone_id', - 'labels', 'created_at', 'due_date'] - optionalUpdateAttrs = ['title', 'description', 'assignee_id', - 'milestone_id', 'labels', 'created_at', - 'updated_at', 'state_event', 'due_date'] - shortPrintAttr = 'title' - idAttr = 'iid' - managers = ( - ('notes', 'ProjectIssueNoteManager', - [('project_id', 'project_id'), ('issue_iid', 'iid')]), + _short_print_attr = 'url' + + +class ProjectHookManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/hooks' + _obj_cls = ProjectHook + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = ( + ('url', ), + ('push_events', 'issues_events', 'note_events', + 'merge_requests_events', 'tag_push_events', 'build_events', + 'enable_ssl_verification', 'token', 'pipeline_events') + ) + _update_attrs = ( + ('url', ), + ('push_events', 'issues_events', 'note_events', + 'merge_requests_events', 'tag_push_events', 'build_events', + 'enable_ssl_verification', 'token', 'pipeline_events') ) - def subscribe(self, **kwargs): - """Subscribe to an issue. - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabSubscribeError: If the subscription cannot be done - """ - url = ('/projects/%(project_id)s/issues/%(issue_iid)s/subscribe' % - {'project_id': self.project_id, 'issue_iid': self.iid}) +class ProjectIssueNote(SaveMixin, RESTObject): + _constructor_types= {'author': 'User'} - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabSubscribeError, [201, 304]) - self._set_from_dict(r.json()) - def unsubscribe(self, **kwargs): - """Unsubscribe an issue. +class ProjectIssueNoteManager(RetrieveMixin, CreateMixin, UpdateMixin, + RESTManager): + _path = '/projects/%(project_id)s/issues/%(issue_iid)s/notes' + _obj_cls = ProjectIssueNote + _from_parent_attrs = {'project_id': 'project_id', 'issue_iid': 'iid'} + _create_attrs = (('body', ), ('created_at')) + _update_attrs = (('body', ), tuple()) - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabUnsubscribeError: If the unsubscription cannot be done - """ - url = ('/projects/%(project_id)s/issues/%(issue_iid)s/unsubscribe' % - {'project_id': self.project_id, 'issue_iid': self.iid}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabUnsubscribeError, [201, 304]) - self._set_from_dict(r.json()) +class ProjectIssue(SubscribableMixin, TodoMixin, TimeTrackingMixin, SaveMixin, + RESTObject): + _constructor_types = {'author': 'User', 'assignee': 'User', 'milestone': + 'ProjectMilestone'} + _short_print_attr = 'title' + _id_attr = 'iid' + _managers = (('notes', 'ProjectIssueNoteManager'), ) def move(self, to_project_id, **kwargs): """Move the issue to another project. @@ -883,160 +767,70 @@ def move(self, to_project_id, **kwargs): Raises: GitlabConnectionError: If the server cannot be reached. """ - url = ('/projects/%(project_id)s/issues/%(issue_iid)s/move' % - {'project_id': self.project_id, 'issue_iid': self.iid}) - + path = '%s/%s/move' % (self.manager.path, self.get_id()) data = {'to_project_id': to_project_id} - data.update(**kwargs) - r = self.gitlab._raw_post(url, data=data) - raise_error_from_response(r, GitlabUpdateError, 201) - self._set_from_dict(r.json()) + server_data = self.manager.gitlab.http_post(url, post_data=data, + **kwargs) + self._update_attrs(server_data) - def todo(self, **kwargs): - """Create a todo for the issue. - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/issues/%(issue_iid)s/todo' % - {'project_id': self.project_id, 'issue_iid': self.iid}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabTodoError, [201, 304]) - - def time_stats(self, **kwargs): - """Get time stats for the issue. - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/issues/%(issue_iid)s/time_stats' % - {'project_id': self.project_id, 'issue_iid': self.iid}) - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabGetError) - return r.json() +class ProjectIssueManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/issues/' + _obj_cls = ProjectIssue + _from_parent_attrs = {'project_id': 'id'} + _list_filters = ('state', 'labels', 'milestone', 'order_by', 'sort') + _create_attrs = (('title', ), + ('description', 'assignee_id', 'milestone_id', 'labels', + 'created_at', 'due_date')) + _update_attrs = (tuple(), ('title', 'description', 'assignee_id', + 'milestone_id', 'labels', 'created_at', + 'updated_at', 'state_event', 'due_date')) - def time_estimate(self, duration, **kwargs): - """Set an estimated time of work for the issue. - Args: - duration (str): duration in human format (e.g. 3h30) - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/issues/%(issue_iid)s/time_estimate' % - {'project_id': self.project_id, 'issue_iid': self.iid}) - data = {'duration': duration} - r = self.gitlab._raw_post(url, data, **kwargs) - raise_error_from_response(r, GitlabTimeTrackingError, 200) - return r.json() - - def reset_time_estimate(self, **kwargs): - """Resets estimated time for the issue to 0 seconds. - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/issues/%(issue_iid)s/' - 'reset_time_estimate' % - {'project_id': self.project_id, 'issue_iid': self.iid}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabTimeTrackingError, 200) - return r.json() - - def add_spent_time(self, duration, **kwargs): - """Set an estimated time of work for the issue. - - Args: - duration (str): duration in human format (e.g. 3h30) - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/issues/%(issue_iid)s/' - 'add_spent_time' % - {'project_id': self.project_id, 'issue_iid': self.iid}) - data = {'duration': duration} - r = self.gitlab._raw_post(url, data, **kwargs) - raise_error_from_response(r, GitlabTimeTrackingError, 201) - return r.json() - - def reset_spent_time(self, **kwargs): - """Set an estimated time of work for the issue. - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/issues/%(issue_iid)s/' - 'reset_spent_time' % - {'project_id': self.project_id, 'issue_iid': self.iid}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabTimeTrackingError, 200) - return r.json() - - -class ProjectIssueManager(BaseManager): - obj_cls = ProjectIssue - - -class ProjectMember(GitlabObject): - _url = '/projects/%(project_id)s/members' - requiredUrlAttrs = ['project_id'] +class ProjectMember(SaveMixin, RESTObject): requiredCreateAttrs = ['access_level', 'user_id'] optionalCreateAttrs = ['expires_at'] requiredUpdateAttrs = ['access_level'] optionalCreateAttrs = ['expires_at'] - shortPrintAttr = 'username' + _short_print_attr = 'username' -class ProjectMemberManager(BaseManager): - obj_cls = ProjectMember +class ProjectMemberManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/members' + _obj_cls = ProjectMember + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('access_level', 'user_id'), ('expires_at', )) + _update_attrs = (('access_level', ), ('expires_at', )) -class ProjectNote(GitlabObject): - _url = '/projects/%(project_id)s/notes' - _constructorTypes = {'author': 'User'} - canUpdate = False - canDelete = False - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['body'] +class ProjectNote(RESTObject): + _constructor_types = {'author': 'User'} -class ProjectNoteManager(BaseManager): - obj_cls = ProjectNote +class ProjectNoteManager(RetrieveMixin, RESTManager): + _path ='/projects/%(project_id)s/notes' + _obj_cls = ProjectNote + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('body', ), tuple()) class ProjectNotificationSettings(NotificationSettings): - _url = '/projects/%(project_id)s/notification_settings' - requiredUrlAttrs = ['project_id'] - - -class ProjectNotificationSettingsManager(BaseManager): - obj_cls = ProjectNotificationSettings + pass -class ProjectTagRelease(GitlabObject): - _url = '/projects/%(project_id)s/repository/tags/%(tag_name)/release' - canDelete = False - canList = False - requiredUrlAttrs = ['project_id', 'tag_name'] - requiredCreateAttrs = ['description'] - shortPrintAttr = 'description' +class ProjectNotificationSettingsManager(NotificationSettingsManager): + _path = '/projects/%(project_id)s/notification_settings' + _obj_cls = ProjectNotificationSettings + _from_parent_attrs = {'project_id': 'id'} -class ProjectTag(GitlabObject): - _url = '/projects/%(project_id)s/repository/tags' - _constructorTypes = {'release': 'ProjectTagRelease', - 'commit': 'ProjectCommit'} - idAttr = 'name' - canGet = 'from_list' - canUpdate = False - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['tag_name', 'ref'] - optionalCreateAttrs = ['message'] - shortPrintAttr = 'name' +class ProjectTag(RESTObject): + _constructor_types = {'release': 'ProjectTagRelease', + 'commit': 'ProjectCommit'} + _id_attr = 'name' + _short_print_attr = 'name' - def set_release_description(self, description): + def set_release_description(self, description, **kwargs): """Set the release notes on the tag. If the release doesn't exist yet, it will be created. If it already @@ -1050,121 +844,64 @@ def set_release_description(self, description): GitlabCreateError: If the server fails to create the release. GitlabUpdateError: If the server fails to update the release. """ - url = '/projects/%s/repository/tags/%s/release' % (self.project_id, - self.name) + _path = '%s/%s/release' % (self.manager.path, self.get_id()) + data = {'description': description} if self.release is None: - r = self.gitlab._raw_post(url, data={'description': description}) - raise_error_from_response(r, GitlabCreateError, 201) + result = self.manager.gitlab.http_post(url, post_data=data, + **kwargs) else: - r = self.gitlab._raw_put(url, data={'description': description}) - raise_error_from_response(r, GitlabUpdateError, 200) - self.release = ProjectTagRelease(self, r.json()) - + result = self.manager.gitlab.http_put(url, post_data=data, + **kwargs) + self.release = result.json() -class ProjectTagManager(BaseManager): - obj_cls = ProjectTag +class ProjectTagManager(GetFromListMixin, CreateMixin, DeleteMixin, + RESTManager): + _path = '/projects/%(project_id)s/repository/tags' + _obj_cls = ProjectTag + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('tag_name', 'ref'), ('message',)) -class ProjectMergeRequestDiff(GitlabObject): - _url = ('/projects/%(project_id)s/merge_requests/' - '%(merge_request_iid)s/versions') - canCreate = False - canUpdate = False - canDelete = False - requiredUrlAttrs = ['project_id', 'merge_request_iid'] - -class ProjectMergeRequestDiffManager(BaseManager): - obj_cls = ProjectMergeRequestDiff - - -class ProjectMergeRequestNote(GitlabObject): - _url = ('/projects/%(project_id)s/merge_requests/%(merge_request_iid)s' - '/notes') - _constructorTypes = {'author': 'User'} - requiredUrlAttrs = ['project_id', 'merge_request_iid'] - requiredCreateAttrs = ['body'] - - -class ProjectMergeRequestNoteManager(BaseManager): - obj_cls = ProjectMergeRequestNote +class ProjectMergeRequestDiff(RESTObject): + pass -class ProjectMergeRequest(GitlabObject): - _url = '/projects/%(project_id)s/merge_requests' - _constructorTypes = {'author': 'User', 'assignee': 'User'} - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['source_branch', 'target_branch', 'title'] - optionalCreateAttrs = ['assignee_id', 'description', 'target_project_id', - 'labels', 'milestone_id', 'remove_source_branch'] - optionalUpdateAttrs = ['target_branch', 'assignee_id', 'title', - 'description', 'state_event', 'labels', - 'milestone_id'] - optionalListAttrs = ['iids', 'state', 'order_by', 'sort'] - idAttr = 'iid' - - managers = ( - ('notes', 'ProjectMergeRequestNoteManager', - [('project_id', 'project_id'), ('merge_request_iid', 'iid')]), - ('diffs', 'ProjectMergeRequestDiffManager', - [('project_id', 'project_id'), ('merge_request_iid', 'iid')]), - ) +class ProjectMergeRequestDiffManager(RetrieveMixin, RESTManager): + _path = '/projects/%(project_id)s/merge_requests/%(mr_iid)s/versions' + _obj_cls = ProjectMergeRequestDiff + _from_parent_attrs = {'project_id': 'project_id', 'mr_iid': 'iid'} - def _data_for_gitlab(self, extra_parameters={}, update=False, - as_json=True): - data = (super(ProjectMergeRequest, self) - ._data_for_gitlab(extra_parameters, update=update, - as_json=False)) - if update: - # Drop source_branch attribute as it is not accepted by the gitlab - # server (Issue #76) - data.pop('source_branch', None) - return json.dumps(data) - def subscribe(self, **kwargs): - """Subscribe to a MR. +class ProjectMergeRequestNote(SaveMixin, RESTObject): + _constructor_types = {'author': 'User'} - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabSubscribeError: If the subscription cannot be done - """ - url = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s/' - 'subscribe' % - {'project_id': self.project_id, 'mr_iid': self.iid}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabSubscribeError, [201, 304]) - if r.status_code == 201: - self._set_from_dict(r.json()) +class ProjectMergeRequestNoteManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/merge_requests/%(mr_iid)s/notes' + _obj_cls = ProjectMergeRequestNote + _from_parent_attrs = {'project_id': 'project_id', 'mr_iid': 'iid'} + _create_attrs = (('body', ), tuple()) + _update_attrs = (('body', ), tuple()) - def unsubscribe(self, **kwargs): - """Unsubscribe a MR. - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabUnsubscribeError: If the unsubscription cannot be done - """ - url = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s/' - 'unsubscribe' % - {'project_id': self.project_id, 'mr_iid': self.iid}) +class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, + SaveMixin, RESTObject): + _constructor_types = {'author': 'User', 'assignee': 'User'} + _id_attr = 'iid' - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabUnsubscribeError, [201, 304]) - if r.status_code == 200: - self._set_from_dict(r.json()) + _managers = ( + ('notes', 'ProjectMergeRequestNoteManager'), + ('diffs', 'ProjectMergeRequestDiffManager') + ) def cancel_merge_when_pipeline_succeeds(self, **kwargs): """Cancel merge when build succeeds.""" - u = ('/projects/%s/merge_requests/%s/' - 'cancel_merge_when_pipeline_succeeds' - % (self.project_id, self.iid)) - r = self.gitlab._raw_put(u, **kwargs) - errors = {401: GitlabMRForbiddenError, - 405: GitlabMRClosedError, - 406: GitlabMROnBuildSuccessError} - raise_error_from_response(r, errors) - return ProjectMergeRequest(self, r.json()) + path = ('%s/%s/cancel_merge_when_pipeline_succeeds' % + (self.manager.path, self.get_id())) + server_data = self.manager.gitlab.http_put(path, **kwargs) + self._update_attrs(server_data) def closes_issues(self, **kwargs): """List issues closed by the MR. @@ -1176,6 +913,7 @@ def closes_issues(self, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ + # FIXME(gpocentek) url = ('/projects/%s/merge_requests/%s/closes_issues' % (self.project_id, self.iid)) return self.gitlab._raw_list(url, ProjectIssue, **kwargs) @@ -1190,6 +928,7 @@ def commits(self, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabListError: If the server fails to perform the request. """ + # FIXME(gpocentek) url = ('/projects/%s/merge_requests/%s/commits' % (self.project_id, self.iid)) return self.gitlab._raw_list(url, ProjectCommit, **kwargs) @@ -1204,11 +943,8 @@ def changes(self, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabListError: If the server fails to perform the request. """ - url = ('/projects/%s/merge_requests/%s/changes' % - (self.project_id, self.iid)) - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabListError) - return r.json() + path = '%s/%s/changes' % (self.manager.path, self.get_id()) + return self.manager.gitlab.http_get(path, **kwargs) def merge(self, merge_commit_message=None, should_remove_source_branch=False, @@ -1231,8 +967,7 @@ def merge(self, merge_commit_message=None, close thr MR GitlabMRClosedError: If the MR is already closed """ - url = '/projects/%s/merge_requests/%s/merge' % (self.project_id, - self.iid) + path = '%s/%s/merge' % (self.manager.path, self.get_id()) data = {} if merge_commit_message: data['merge_commit_message'] = merge_commit_message @@ -1241,114 +976,31 @@ def merge(self, merge_commit_message=None, if merged_when_build_succeeds: data['merged_when_build_succeeds'] = True - r = self.gitlab._raw_put(url, data=data, **kwargs) - errors = {401: GitlabMRForbiddenError, - 405: GitlabMRClosedError} - raise_error_from_response(r, errors) - self._set_from_dict(r.json()) - - def todo(self, **kwargs): - """Create a todo for the merge request. - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s/todo' % - {'project_id': self.project_id, 'mr_iid': self.iid}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabTodoError, [201, 304]) - - def time_stats(self, **kwargs): - """Get time stats for the merge request. - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s/' - 'time_stats' % - {'project_id': self.project_id, 'mr_iid': self.iid}) - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabGetError) - return r.json() - - def time_estimate(self, duration, **kwargs): - """Set an estimated time of work for the merge request. - - Args: - duration (str): duration in human format (e.g. 3h30) - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s/' - 'time_estimate' % - {'project_id': self.project_id, 'mr_iid': self.iid}) - data = {'duration': duration} - r = self.gitlab._raw_post(url, data, **kwargs) - raise_error_from_response(r, GitlabTimeTrackingError, 200) - return r.json() - - def reset_time_estimate(self, **kwargs): - """Resets estimated time for the merge request to 0 seconds. - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s/' - 'reset_time_estimate' % - {'project_id': self.project_id, 'mr_iid': self.iid}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabTimeTrackingError, 200) - return r.json() - - def add_spent_time(self, duration, **kwargs): - """Set an estimated time of work for the merge request. - - Args: - duration (str): duration in human format (e.g. 3h30) - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s/' - 'add_spent_time' % - {'project_id': self.project_id, 'mr_iid': self.iid}) - data = {'duration': duration} - r = self.gitlab._raw_post(url, data, **kwargs) - raise_error_from_response(r, GitlabTimeTrackingError, 201) - return r.json() - - def reset_spent_time(self, **kwargs): - """Set an estimated time of work for the merge request. + server_data = self.manager.gitlab.http_put(path, post_data=data, + **kwargs) + self._update_attrs(server_data) - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - url = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s/' - 'reset_spent_time' % - {'project_id': self.project_id, 'mr_iid': self.iid}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabTimeTrackingError, 200) - return r.json() - -class ProjectMergeRequestManager(BaseManager): - obj_cls = ProjectMergeRequest +class ProjectMergeRequestManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/merge_requests' + _obj_cls = ProjectMergeRequest + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = ( + ('source_branch', 'target_branch', 'title'), + ('assignee_id', 'description', 'target_project_id', 'labels', + 'milestone_id', 'remove_source_branch') + ) + _update_attrs = (tuple(), ('target_branch', 'assignee_id', 'title', + 'description', 'state_event', 'labels', + 'milestone_id')) + _list_filters = ('iids', 'state', 'order_by', 'sort') -class ProjectMilestone(GitlabObject): - _url = '/projects/%(project_id)s/milestones' - canDelete = False - requiredUrlAttrs = ['project_id'] - optionalListAttrs = ['iids', 'state'] - requiredCreateAttrs = ['title'] - optionalCreateAttrs = ['description', 'due_date', 'start_date', - 'state_event'] - optionalUpdateAttrs = requiredCreateAttrs + optionalCreateAttrs - shortPrintAttr = 'title' +class ProjectMilestone(SaveMixin, RESTObject): + _short_print_attr = 'title' def issues(self, **kwargs): - url = "/projects/%s/milestones/%s/issues" % (self.project_id, self.id) + url = '/projects/%s/milestones/%s/issues' % (self.project_id, self.id) return self.gitlab._raw_list(url, ProjectIssue, **kwargs) def merge_requests(self, **kwargs): @@ -1361,71 +1013,70 @@ def merge_requests(self, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabListError: If the server fails to perform the request. """ + # FIXME(gpocentek) url = ('/projects/%s/milestones/%s/merge_requests' % (self.project_id, self.id)) return self.gitlab._raw_list(url, ProjectMergeRequest, **kwargs) -class ProjectMilestoneManager(BaseManager): - obj_cls = ProjectMilestone +class ProjectMilestoneManager(RetrieveMixin, CreateMixin, DeleteMixin, + RESTManager): + _path = '/projects/%(project_id)s/milestones' + _obj_cls = ProjectMilestone + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('title', ), ('description', 'due_date', 'start_date', + 'state_event')) + _update_attrs = (tuple(), ('title', 'description', 'due_date', + 'start_date', 'state_event')) + _list_filters = ('iids', 'state') -class ProjectLabel(GitlabObject): - _url = '/projects/%(project_id)s/labels' - _id_in_delete_url = False - _id_in_update_url = False - canGet = 'from_list' - requiredUrlAttrs = ['project_id'] - idAttr = 'name' - requiredDeleteAttrs = ['name'] +class ProjectLabel(SubscribableMixin, SaveMixin, RESTObject): + _id_attr = 'name' requiredCreateAttrs = ['name', 'color'] optionalCreateAttrs = ['description', 'priority'] requiredUpdateAttrs = ['name'] optionalUpdateAttrs = ['new_name', 'color', 'description', 'priority'] - def subscribe(self, **kwargs): - """Subscribe to a label. - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabSubscribeError: If the subscription cannot be done - """ - url = ('/projects/%(project_id)s/labels/%(label_id)s/subscribe' % - {'project_id': self.project_id, 'label_id': self.name}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabSubscribeError, [201, 304]) - self._set_from_dict(r.json()) +class ProjectLabelManager(GetFromListMixin, CreateMixin, UpdateMixin, + RESTManager): + _path = '/projects/%(project_id)s/labels' + _obj_cls = ProjectLabel + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('name', 'color'), ('description', 'priority')) + _update_attrs = (('name', ), + ('new_name', 'color', 'description', 'priority')) - def unsubscribe(self, **kwargs): - """Unsubscribe a label. + # Delete without ID. + def delete(self, name, **kwargs): + """Deletes a Label on the server. - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabUnsubscribeError: If the unsubscription cannot be done + Args: + name: The name of the label. + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) """ - url = ('/projects/%(project_id)s/labels/%(label_id)s/unsubscribe' % - {'project_id': self.project_id, 'label_id': self.name}) + self.gitlab.http_delete(path, query_data={'name': self.name}, **kwargs) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabUnsubscribeError, [201, 304]) - self._set_from_dict(r.json()) + # Update without ID, but we need an ID to get from list. + def save(self, **kwargs): + """Saves the changes made to the object to the server. + Args: + **kwargs: Extra option to send to the server (e.g. sudo) -class ProjectLabelManager(BaseManager): - obj_cls = ProjectLabel + The object is updated to match what the server returns. + """ + updated_data = self._get_updated_data() + # call the manager + server_data = self.manager.update(None, updated_data, **kwargs) + self._update_attrs(server_data) -class ProjectFile(GitlabObject): - _url = '/projects/%(project_id)s/repository/files' - canList = False - requiredUrlAttrs = ['project_id'] - requiredGetAttrs = ['ref'] - requiredCreateAttrs = ['file_path', 'branch', 'content', - 'commit_message'] - optionalCreateAttrs = ['encoding'] - requiredDeleteAttrs = ['branch', 'commit_message', 'file_path'] - shortPrintAttr = 'file_path' + +class ProjectFile(SaveMixin, RESTObject): + _id_attr = 'file_path' + _short_print_attr = 'file_path' def decode(self): """Returns the decoded content of the file. @@ -1436,10 +1087,33 @@ def decode(self): return base64.b64decode(self.content) -class ProjectFileManager(BaseManager): - obj_cls = ProjectFile +class ProjectFileManager(GetMixin, CreateMixin, UpdateMixin, DeleteMixin, + RESTManager): + _path = '/projects/%(project_id)s/repository/files' + _obj_cls = ProjectFile + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('file_path', 'branch', 'content', 'commit_message'), + ('encoding', 'author_email', 'author_name')) + _update_attrs = (('file_path', 'branch', 'content', 'commit_message'), + ('encoding', 'author_email', 'author_name')) + + def get(self, file_path, **kwargs): + """Retrieve a single object. + + Args: + id (int or str): ID of the object to retrieve + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + + Returns: + object: The generated RESTObject. + + Raises: + GitlabGetError: If the server cannot perform the request. + """ + file_path = file_path.replace('/', '%2F') + return GetMixin.get(self, file_path, **kwargs) - def raw(self, filepath, ref, streamed=False, action=None, chunk_size=1024, + def raw(self, file_path, ref, streamed=False, action=None, chunk_size=1024, **kwargs): """Return the content of a file for a commit. @@ -1460,80 +1134,65 @@ def raw(self, filepath, ref, streamed=False, action=None, chunk_size=1024, GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ - url = ("/projects/%s/repository/files/%s/raw" % - (self.parent.id, filepath.replace('/', '%2F'))) - url += '?ref=%s' % ref - r = self.gitlab._raw_get(url, streamed=streamed, **kwargs) - raise_error_from_response(r, GitlabGetError) - return utils.response_content(r, streamed, action, chunk_size) - + file_path = file_path.replace('/', '%2F') + path = '%s/%s/raw' % (self.path, file_path) + query_data = {'ref': ref} + result = self.gitlab.http_get(path, query_data=query_data, + streamed=streamed, **kwargs) + return utils.response_content(result, streamed, action, chunk_size) -class ProjectPipeline(GitlabObject): - _url = '/projects/%(project_id)s/pipelines' - _create_url = '/projects/%(project_id)s/pipeline' - canUpdate = False - canDelete = False - - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['ref'] +class ProjectPipeline(RESTObject): + def cancel(self, **kwargs): + """Cancel the job.""" + path = '%s/%s/cancel' % (self.manager.path, self.get_id()) + self.manager.gitlab.http_post(path) def retry(self, **kwargs): - """Retries failed builds in a pipeline. + """Retry the job.""" + path = '%s/%s/retry' % (self.manager.path, self.get_id()) + self.manager.gitlab.http_post(path) - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabPipelineRetryError: If the retry cannot be done. - """ - url = ('/projects/%(project_id)s/pipelines/%(id)s/retry' % - {'project_id': self.project_id, 'id': self.id}) - r = self.gitlab._raw_post(url, data=None, content_type=None, **kwargs) - raise_error_from_response(r, GitlabPipelineRetryError, 201) - self._set_from_dict(r.json()) - def cancel(self, **kwargs): - """Cancel builds in a pipeline. +class ProjectPipelineManager(RetrieveMixin, CreateMixin, RESTManager): + _path = '/projects/%(project_id)s/pipelines' + _obj_cls = ProjectPipeline + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('ref', ), tuple()) - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabPipelineCancelError: If the retry cannot be done. - """ - url = ('/projects/%(project_id)s/pipelines/%(id)s/cancel' % - {'project_id': self.project_id, 'id': self.id}) - r = self.gitlab._raw_post(url, data=None, content_type=None, **kwargs) - raise_error_from_response(r, GitlabPipelineRetryError, 200) - self._set_from_dict(r.json()) + def create(self, data, **kwargs): + """Creates a new object. + Args: + data (dict): parameters to send to the server to create the + resource + **kwargs: Extra data to send to the Gitlab server (e.g. sudo) -class ProjectPipelineManager(BaseManager): - obj_cls = ProjectPipeline + Returns: + RESTObject: a new instance of the manage object class build with + the data sent by the server + """ + path = self.path[:-1] # drop the 's' + return CreateMixin.create(self, data, path=path, **kwargs) -class ProjectSnippetNote(GitlabObject): - _url = '/projects/%(project_id)s/snippets/%(snippet_id)s/notes' - _constructorTypes = {'author': 'User'} - canUpdate = False - canDelete = False - requiredUrlAttrs = ['project_id', 'snippet_id'] - requiredCreateAttrs = ['body'] +class ProjectSnippetNote(RESTObject): + _constructor_types = {'author': 'User'} -class ProjectSnippetNoteManager(BaseManager): - obj_cls = ProjectSnippetNote +class ProjectSnippetNoteManager(RetrieveMixin, CreateMixin, RESTManager): + _path = '/projects/%(project_id)s/snippets/%(snippet_id)s/notes' + _obj_cls = ProjectSnippetNote + _from_parent_attrs = {'project_id': 'project_id', + 'snippet_id': 'id'} + _create_attrs = (('body', ), tuple()) -class ProjectSnippet(GitlabObject): +class ProjectSnippet(SaveMixin, RESTObject): _url = '/projects/%(project_id)s/snippets' - _constructorTypes = {'author': 'User'} - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['title', 'file_name', 'code'] - optionalCreateAttrs = ['lifetime', 'visibility'] - optionalUpdateAttrs = ['title', 'file_name', 'code', 'visibility'] - shortPrintAttr = 'title' - managers = ( - ('notes', 'ProjectSnippetNoteManager', - [('project_id', 'project_id'), ('snippet_id', 'id')]), - ) + _constructor_types = {'author': 'User'} + _short_print_attr = 'title' + _managers = (('notes', 'ProjectSnippetNoteManager'), ) def content(self, streamed=False, action=None, chunk_size=1024, **kwargs): """Return the raw content of a snippet. @@ -1553,23 +1212,22 @@ def content(self, streamed=False, action=None, chunk_size=1024, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ - url = ("/projects/%(project_id)s/snippets/%(snippet_id)s/raw" % - {'project_id': self.project_id, 'snippet_id': self.id}) - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabGetError) + path = "%s/%s/raw" % (self.manager.path, self.get_id()) + result = self.manager.gitlab.http_get(path, streamed=streamed, + **kwargs) return utils.response_content(r, streamed, action, chunk_size) -class ProjectSnippetManager(BaseManager): - obj_cls = ProjectSnippet - +class ProjectSnippetManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/snippets' + _obj_cls = ProjectSnippet + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('title', 'file_name', 'code'), + ('lifetime', 'visibility')) + _update_attrs = (tuple(), ('title', 'file_name', 'code', 'visibility')) -class ProjectTrigger(GitlabObject): - _url = '/projects/%(project_id)s/triggers' - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['description'] - optionalUpdateAttrs = ['description'] +class ProjectTrigger(SaveMixin, RESTObject): def take_ownership(self, **kwargs): """Update the owner of a trigger. @@ -1577,26 +1235,29 @@ def take_ownership(self, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ - url = ('/projects/%(project_id)s/triggers/%(id)s/take_ownership' % - {'project_id': self.project_id, 'id': self.id}) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabUpdateError, 200) - self._set_from_dict(r.json()) + 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 ProjectTriggerManager(BaseManager): - obj_cls = ProjectTrigger +class ProjectTriggerManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/triggers' + _obj_cls = ProjectTrigger + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('description', ), tuple()) + _update_attrs = (('description', ), tuple()) -class ProjectVariable(GitlabObject): - _url = '/projects/%(project_id)s/variables' - idAttr = 'key' - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['key', 'value'] +class ProjectVariable(SaveMixin, RESTObject): + _id_attr = 'key' -class ProjectVariableManager(BaseManager): - obj_cls = ProjectVariable +class ProjectVariableManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/variables' + _obj_cls = ProjectVariable + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('key', 'vaule'), tuple()) + _update_attrs = (('key', 'vaule'), tuple()) class ProjectService(GitlabObject): @@ -1688,113 +1349,70 @@ def available(self, **kwargs): return list(ProjectService._service_attrs.keys()) -class ProjectAccessRequest(GitlabObject): - _url = '/projects/%(project_id)s/access_requests' - canGet = 'from_list' - canUpdate = False +class ProjectAccessRequest(AccessRequestMixin, RESTObject): + pass - def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs): - """Approve an access request. - Attrs: - access_level (int): The access level for the user. +class ProjectAccessRequestManager(GetFromListMixin, CreateMixin, DeleteMixin, + RESTManager): + _path = '/projects/%(project_id)s/access_requests' + _obj_cls = ProjectAccessRequest + _from_parent_attrs = {'project_id': 'id'} - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabUpdateError: If the server fails to perform the request. - """ - url = ('/projects/%(project_id)s/access_requests/%(id)s/approve' % - {'project_id': self.project_id, 'id': self.id}) - data = {'access_level': access_level} - r = self.gitlab._raw_put(url, data=data, **kwargs) - raise_error_from_response(r, GitlabUpdateError, 201) - self._set_from_dict(r.json()) +class ProjectDeployment(RESTObject): + pass -class ProjectAccessRequestManager(BaseManager): - obj_cls = ProjectAccessRequest +class ProjectDeploymentManager(RetrieveMixin, RESTManager): + _path = '/projects/%(project_id)s/deployments' + _obj_cls = ProjectDeployment + _from_parent_attrs = {'project_id': 'id'} -class ProjectDeployment(GitlabObject): - _url = '/projects/%(project_id)s/deployments' - canCreate = False +class ProjectRunner(RESTObject): canUpdate = False - canDelete = False + requiredCreateAttrs = ['runner_id'] -class ProjectDeploymentManager(BaseManager): - obj_cls = ProjectDeployment +class ProjectRunnerManager(NoUpdateMixin, RESTManager): + _path = '/projects/%(project_id)s/runners' + _obj_cls = ProjectRunner + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('runner_id', ), tuple()) -class ProjectRunner(GitlabObject): - _url = '/projects/%(project_id)s/runners' - canUpdate = False - requiredCreateAttrs = ['runner_id'] - -class ProjectRunnerManager(BaseManager): - obj_cls = ProjectRunner - - -class Project(GitlabObject): - _url = '/projects' - _constructorTypes = {'owner': 'User', 'namespace': 'Group'} - optionalListAttrs = ['search'] - requiredCreateAttrs = ['name'] - optionalListAttrs = ['search', 'owned', 'starred', 'archived', - 'visibility', 'order_by', 'sort', 'simple', - 'membership', 'statistics'] - optionalCreateAttrs = ['path', 'namespace_id', 'description', - 'issues_enabled', 'merge_requests_enabled', - 'builds_enabled', 'wiki_enabled', - 'snippets_enabled', 'container_registry_enabled', - 'shared_runners_enabled', 'visibility', - 'import_url', 'public_builds', - 'only_allow_merge_if_build_succeeds', - 'only_allow_merge_if_all_discussions_are_resolved', - 'lfs_enabled', 'request_access_enabled'] - optionalUpdateAttrs = ['name', 'path', 'default_branch', 'description', - 'issues_enabled', 'merge_requests_enabled', - 'builds_enabled', 'wiki_enabled', - 'snippets_enabled', 'container_registry_enabled', - 'shared_runners_enabled', 'visibility', - 'import_url', 'public_builds', - 'only_allow_merge_if_build_succeeds', - 'only_allow_merge_if_all_discussions_are_resolved', - 'lfs_enabled', 'request_access_enabled'] - shortPrintAttr = 'path' - managers = ( - ('accessrequests', 'ProjectAccessRequestManager', - [('project_id', 'id')]), - ('boards', 'ProjectBoardManager', [('project_id', 'id')]), - ('board_lists', 'ProjectBoardListManager', [('project_id', 'id')]), - ('branches', 'ProjectBranchManager', [('project_id', 'id')]), - ('jobs', 'ProjectJobManager', [('project_id', 'id')]), - ('commits', 'ProjectCommitManager', [('project_id', 'id')]), - ('deployments', 'ProjectDeploymentManager', [('project_id', 'id')]), - ('environments', 'ProjectEnvironmentManager', [('project_id', 'id')]), - ('events', 'ProjectEventManager', [('project_id', 'id')]), - ('files', 'ProjectFileManager', [('project_id', 'id')]), - ('forks', 'ProjectForkManager', [('project_id', 'id')]), - ('hooks', 'ProjectHookManager', [('project_id', 'id')]), - ('keys', 'ProjectKeyManager', [('project_id', 'id')]), - ('issues', 'ProjectIssueManager', [('project_id', 'id')]), - ('labels', 'ProjectLabelManager', [('project_id', 'id')]), - ('members', 'ProjectMemberManager', [('project_id', 'id')]), - ('mergerequests', 'ProjectMergeRequestManager', - [('project_id', 'id')]), - ('milestones', 'ProjectMilestoneManager', [('project_id', 'id')]), - ('notes', 'ProjectNoteManager', [('project_id', 'id')]), - ('notificationsettings', 'ProjectNotificationSettingsManager', - [('project_id', 'id')]), - ('pipelines', 'ProjectPipelineManager', [('project_id', 'id')]), - ('runners', 'ProjectRunnerManager', [('project_id', 'id')]), - ('services', 'ProjectServiceManager', [('project_id', 'id')]), - ('snippets', 'ProjectSnippetManager', [('project_id', 'id')]), - ('tags', 'ProjectTagManager', [('project_id', 'id')]), - ('triggers', 'ProjectTriggerManager', [('project_id', 'id')]), - ('variables', 'ProjectVariableManager', [('project_id', 'id')]), +class Project(SaveMixin, RESTObject): + _constructor_types = {'owner': 'User', 'namespace': 'Group'} + _short_print_attr = 'path' + _managers = ( + ('accessrequests', 'ProjectAccessRequestManager'), + ('boards', 'ProjectBoardManager'), + ('branches', 'ProjectBranchManager'), + ('jobs', 'ProjectJobManager'), + ('commits', 'ProjectCommitManager'), + ('deployments', 'ProjectDeploymentManager'), + ('environments', 'ProjectEnvironmentManager'), + ('events', 'ProjectEventManager'), + ('files', 'ProjectFileManager'), + ('forks', 'ProjectForkManager'), + ('hooks', 'ProjectHookManager'), + ('keys', 'ProjectKeyManager'), + ('issues', 'ProjectIssueManager'), + ('labels', 'ProjectLabelManager'), + ('members', 'ProjectMemberManager'), + ('mergerequests', 'ProjectMergeRequestManager'), + ('milestones', 'ProjectMilestoneManager'), + ('notes', 'ProjectNoteManager'), + ('notificationsettings', 'ProjectNotificationSettingsManager'), + ('pipelines', 'ProjectPipelineManager'), + ('runners', 'ProjectRunnerManager'), + ('services', 'ProjectServiceManager'), + ('snippets', 'ProjectSnippetManager'), + ('tags', 'ProjectTagManager'), + ('triggers', 'ProjectTriggerManager'), + ('variables', 'ProjectVariableManager'), ) def repository_tree(self, path='', ref='', **kwargs): @@ -1811,17 +1429,14 @@ def repository_tree(self, path='', ref='', **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ - url = "/projects/%s/repository/tree" % (self.id) - params = [] + path = '/projects/%s/repository/tree' % self.get_id() + query_data = {} if path: - params.append(urllib.parse.urlencode({'path': path})) + query_data['path'] = path if ref: - params.append("ref=%s" % ref) - if params: - url += '?' + "&".join(params) - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabGetError) - return r.json() + query_data['ref'] = ref + return self.manager.gitlab.http_get(path, query_data=query_data, + **kwargs) def repository_raw_blob(self, sha, streamed=False, action=None, chunk_size=1024, **kwargs): @@ -1843,10 +1458,9 @@ def repository_raw_blob(self, sha, streamed=False, action=None, GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ - url = "/projects/%s/repository/raw_blobs/%s" % (self.id, sha) - r = self.gitlab._raw_get(url, streamed=streamed, **kwargs) - raise_error_from_response(r, GitlabGetError) - return utils.response_content(r, streamed, action, chunk_size) + path = '/projects/%s/repository/raw_blobs/%s' % (self.get_id(), sha) + result = self.gitlab._raw_get(url, streamed=streamed, **kwargs) + return utils.response_content(result, streamed, action, chunk_size) def repository_compare(self, from_, to, **kwargs): """Returns a diff between two branches/commits. @@ -1862,13 +1476,12 @@ def repository_compare(self, from_, to, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ - url = "/projects/%s/repository/compare" % self.id - url = "%s?from=%s&to=%s" % (url, from_, to) - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabGetError) - return r.json() + path = '/projects/%s/repository/compare' % self.get_id() + query_data = {'from': from_, 'to': to} + return self.manager.gitlab.http_get(path, query_data=query_data, + **kwargs) - def repository_contributors(self): + def repository_contributors(self, **kwargs): """Returns a list of contributors for the project. Returns: @@ -1878,10 +1491,8 @@ def repository_contributors(self): GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ - url = "/projects/%s/repository/contributors" % self.id - r = self.gitlab._raw_get(url) - raise_error_from_response(r, GitlabListError) - return r.json() + path = '/projects/%s/repository/contributors' % self.get_id() + return self.manager.gitlab.http_get(path, **kwargs) def repository_archive(self, sha=None, streamed=False, action=None, chunk_size=1024, **kwargs): @@ -1903,14 +1514,15 @@ def repository_archive(self, sha=None, streamed=False, action=None, GitlabConnectionError: If the server cannot be reached. GitlabGetError: If the server fails to perform the request. """ - url = '/projects/%s/repository/archive' % self.id + path = '/projects/%s/repository/archive' % self.get_id() + query_data = {} if sha: - url += '?sha=%s' % sha - r = self.gitlab._raw_get(url, streamed=streamed, **kwargs) - raise_error_from_response(r, GitlabGetError) - return utils.response_content(r, streamed, action, chunk_size) + query_data['sha'] = sha + result = self.gitlab._raw_get(path, query_data=query_data, + streamed=streamed, **kwargs) + return utils.response_content(result, streamed, action, chunk_size) - def create_fork_relation(self, forked_from_id): + def create_fork_relation(self, forked_from_id, **kwargs): """Create a forked from/to relation between existing projects. Args: @@ -1920,20 +1532,18 @@ def create_fork_relation(self, forked_from_id): GitlabConnectionError: If the server cannot be reached. GitlabCreateError: If the server fails to perform the request. """ - url = "/projects/%s/fork/%s" % (self.id, forked_from_id) - r = self.gitlab._raw_post(url) - raise_error_from_response(r, GitlabCreateError, 201) + path = '/projects/%s/fork/%s' % (self.get_id(), forked_from_id) + self.manager.gitlab.http_post(path, **kwargs) - def delete_fork_relation(self): + def delete_fork_relation(self, **kwargs): """Delete a forked relation between existing projects. Raises: GitlabConnectionError: If the server cannot be reached. GitlabDeleteError: If the server fails to perform the request. """ - url = "/projects/%s/fork" % self.id - r = self.gitlab._raw_delete(url) - raise_error_from_response(r, GitlabDeleteError) + path = '/projects/%s/fork' % self.get_id() + self.manager.gitlab.http_delete(path, **kwargs) def star(self, **kwargs): """Star a project. @@ -1945,10 +1555,9 @@ def star(self, **kwargs): GitlabCreateError: If the action cannot be done GitlabConnectionError: If the server cannot be reached. """ - url = "/projects/%s/star" % self.id - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabCreateError, [201, 304]) - return Project(self.gitlab, r.json()) if r.status_code == 201 else self + path = '/projects/%s/star' % self.get_id() + server_data = self.manager.gitlab.http_post(path, **kwargs) + self._update_attrs(server_data) def unstar(self, **kwargs): """Unstar a project. @@ -1960,10 +1569,9 @@ def unstar(self, **kwargs): GitlabDeleteError: If the action cannot be done GitlabConnectionError: If the server cannot be reached. """ - url = "/projects/%s/unstar" % self.id - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabDeleteError, [201, 304]) - return Project(self.gitlab, r.json()) if r.status_code == 201 else self + path = '/projects/%s/unstar' % self.get_id() + server_data = self.manager.gitlab.http_post(path, **kwargs) + self._update_attrs(server_data) def archive(self, **kwargs): """Archive a project. @@ -1975,10 +1583,9 @@ def archive(self, **kwargs): GitlabCreateError: If the action cannot be done GitlabConnectionError: If the server cannot be reached. """ - url = "/projects/%s/archive" % self.id - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabCreateError, 201) - return Project(self.gitlab, r.json()) if r.status_code == 201 else self + path = '/projects/%s/archive' % self.get_id() + server_data = self.manager.gitlab.http_post(path, **kwargs) + self._update_attrs(server_data) def unarchive(self, **kwargs): """Unarchive a project. @@ -1990,12 +1597,11 @@ def unarchive(self, **kwargs): GitlabDeleteError: If the action cannot be done GitlabConnectionError: If the server cannot be reached. """ - url = "/projects/%s/unarchive" % self.id - r = self.gitlab._raw_delete(url, **kwargs) - raise_error_from_response(r, GitlabCreateError, 201) - return Project(self.gitlab, r.json()) if r.status_code == 201 else self + path = '/projects/%s/unarchive' % self.get_id() + server_data = self.manager.gitlab.http_post(url, **kwargs) + self._update_attrs(server_data) - def share(self, group_id, group_access, **kwargs): + def share(self, group_id, group_access, expires_at=None, **kwargs): """Share the project with a group. Args: @@ -2006,10 +1612,11 @@ def share(self, group_id, group_access, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabCreateError: If the server fails to perform the request. """ - url = "/projects/%s/share" % self.id - data = {'group_id': group_id, 'group_access': group_access} - r = self.gitlab._raw_post(url, data=data, **kwargs) - raise_error_from_response(r, GitlabCreateError, 201) + path = '/projects/%s/share' % self.get_id() + data = {'group_id': group_id, + 'group_access': group_access, + 'expires_at': expires_at} + self.manager.gitlab.http_post(path, post_data=data, **kwargs) def trigger_pipeline(self, ref, token, variables={}, **kwargs): """Trigger a CI build. @@ -2025,23 +1632,23 @@ def trigger_pipeline(self, ref, token, variables={}, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabCreateError: If the server fails to perform the request. """ - url = "/projects/%s/trigger/pipeline" % self.id + path = '/projects/%s/trigger/pipeline' % self.get_id() form = {r'variables[%s]' % k: v for k, v in six.iteritems(variables)} - data = {'ref': ref, 'token': token} - data.update(form) - r = self.gitlab._raw_post(url, data=data, **kwargs) - raise_error_from_response(r, GitlabCreateError, 201) + post_data = {'ref': ref, 'token': token} + post_data.update(form) + self.manager.gitlab.http_post(path, post_data=post_data, **kwargs) -class Runner(GitlabObject): - _url = '/runners' - canCreate = False - optionalUpdateAttrs = ['description', 'active', 'tag_list'] - optionalListAttrs = ['scope'] +class Runner(SaveMixin, RESTObject): + pass -class RunnerManager(BaseManager): - obj_cls = Runner +class RunnerManager(RetrieveMixin, UpdateMixin, DeleteMixin, RESTManager): + _path = '/runners' + _obj_cls = Runner + _update_attrs = (tuple(), ('description', 'active', 'tag_list')) + _list_filters = ('scope', ) + def all(self, scope=None, **kwargs): """List all the runners. @@ -2057,79 +1664,95 @@ def all(self, scope=None, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabListError: If the resource cannot be found """ - url = '/runners/all' + path = '/runners/all' + query_data = {} if scope is not None: - url += '?scope=' + scope - return self.gitlab._raw_list(url, self.obj_cls, **kwargs) + query_data['scope'] = scope + return self.gitlab.http_list(path, query_data, **kwargs) -class Todo(GitlabObject): - _url = '/todos' - canGet = 'from_list' - canUpdate = False - canCreate = False - optionalListAttrs = ['action', 'author_id', 'project_id', 'state', 'type'] +class Todo(RESTObject): + def mark_as_done(self, **kwargs): + """Mark the todo as done. + + Args: + **kwargs: Additional data to send to the server (e.g. sudo) + """ + path = '%s/%s/mark_as_done' % (self.manager.path, self.id) + server_data = self.manager.gitlab.http_post(path, **kwargs) + self._update_attrs(server_data) -class TodoManager(BaseManager): - obj_cls = Todo +class TodoManager(GetFromListMixin, DeleteMixin, RESTManager): + _path = '/todos' + _obj_cls = Todo + _list_filters = ('action', 'author_id', 'project_id', 'state', 'type') - def delete_all(self, **kwargs): + def mark_all_as_done(self, **kwargs): """Mark all the todos as done. + Returns: + The number of todos maked done. + Raises: GitlabConnectionError: If the server cannot be reached. GitlabDeleteError: If the resource cannot be found - - Returns: - The number of todos maked done. """ - url = '/todos' - r = self.gitlab._raw_delete(url, **kwargs) - raise_error_from_response(r, GitlabDeleteError) - return int(r.text) - - -class ProjectManager(BaseManager): - obj_cls = Project - + self.gitlab.http_post('/todos/mark_as_done', **kwargs) + + +class ProjectManager(CRUDMixin, RESTManager): + _path = '/projects' + _obj_cls = Project + _create_attrs = ( + ('name', ), + ('path', 'namespace_id', 'description', 'issues_enabled', + 'merge_requests_enabled', 'builds_enabled', 'wiki_enabled', + 'snippets_enabled', 'container_registry_enabled', + 'shared_runners_enabled', 'visibility', 'import_url', 'public_builds', + 'only_allow_merge_if_build_succeeds', + 'only_allow_merge_if_all_discussions_are_resolved', 'lfs_enabled', + 'request_access_enabled') + ) + _update_attrs = ( + tuple(), + ('name', 'path', 'default_branch', 'description', 'issues_enabled', + 'merge_requests_enabled', 'builds_enabled', 'wiki_enabled', + 'snippets_enabled', 'container_registry_enabled', + 'shared_runners_enabled', 'visibility', 'import_url', 'public_builds', + 'only_allow_merge_if_build_succeeds', + 'only_allow_merge_if_all_discussions_are_resolved', 'lfs_enabled', + 'request_access_enabled') + ) + _list_filters = ('search', 'owned', 'starred', 'archived', 'visibility', + 'order_by', 'sort', 'simple', 'membership', 'statistics') -class GroupProject(Project): - _url = '/groups/%(group_id)s/projects' - canGet = 'from_list' - canCreate = False - canDelete = False - canUpdate = False - optionalListAttrs = ['archived', 'visibility', 'order_by', 'sort', - 'search', 'ci_enabled_first'] +class GroupProject(RESTObject): def __init__(self, *args, **kwargs): Project.__init__(self, *args, **kwargs) -class GroupProjectManager(ProjectManager): - obj_cls = GroupProject - - -class Group(GitlabObject): - _url = '/groups' - requiredCreateAttrs = ['name', 'path'] - optionalCreateAttrs = ['description', 'visibility', 'parent_id', - 'lfs_enabled', 'request_access_enabled'] - optionalUpdateAttrs = ['name', 'path', 'description', 'visibility', - 'lfs_enabled', 'request_access_enabled'] - shortPrintAttr = 'name' - managers = ( - ('accessrequests', 'GroupAccessRequestManager', [('group_id', 'id')]), - ('members', 'GroupMemberManager', [('group_id', 'id')]), - ('notificationsettings', 'GroupNotificationSettingsManager', - [('group_id', 'id')]), - ('projects', 'GroupProjectManager', [('group_id', 'id')]), - ('issues', 'GroupIssueManager', [('group_id', 'id')]), +class GroupProjectManager(GetFromListMixin, RESTManager): + _path = '/groups/%(group_id)s/projects' + _obj_cls = GroupProject + _from_parent_attrs = {'group_id': 'id'} + _list_filters = ('archived', 'visibility', 'order_by', 'sort', 'search', + 'ci_enabled_first') + + +class Group(SaveMixin, RESTObject): + _short_print_attr = 'name' + _managers = ( + ('accessrequests', 'GroupAccessRequestManager'), + ('members', 'GroupMemberManager'), + ('notificationsettings', 'GroupNotificationSettingsManager'), + ('projects', 'GroupProjectManager'), + ('issues', 'GroupIssueManager'), ) def transfer_project(self, id, **kwargs): - """Transfers a project to this new groups. + """Transfers a project to this group. Attrs: id (int): ID of the project to transfer. @@ -2139,10 +1762,20 @@ def transfer_project(self, id, **kwargs): GitlabTransferProjectError: If the server fails to perform the request. """ - url = '/groups/%d/projects/%d' % (self.id, id) - r = self.gitlab._raw_post(url, None, **kwargs) - raise_error_from_response(r, GitlabTransferProjectError, 201) + path = '/groups/%d/projects/%d' % (self.id, id) + self.manager.gitlab.http_post(path, **kwargs) -class GroupManager(BaseManager): - obj_cls = Group +class GroupManager(CRUDMixin, RESTManager): + _path = '/groups' + _obj_cls = Group + _create_attrs = ( + ('name', 'path'), + ('description', 'visibility', 'parent_id', 'lfs_enabled', + 'request_access_enabled') + ) + _update_attrs = ( + tuple(), + ('name', 'path', 'description', 'visibility', 'lfs_enabled', + 'request_access_enabled') + ) From f754f21dd9138142b923cf3b919187a4638b674a Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Mon, 29 May 2017 07:01:59 +0200 Subject: [PATCH 21/93] make the tests pass --- gitlab/__init__.py | 9 ++++++--- gitlab/mixins.py | 4 ++-- gitlab/v4/objects.py | 36 ++++++++++++++++++------------------ 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index e9a7e9a8d..d42dbd339 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -197,10 +197,10 @@ def _credentials_auth(self): r = self._raw_post('/session', data, content_type='application/json') raise_error_from_response(r, GitlabAuthenticationError, 201) - self.user = objects.CurrentUser(self, r.json()) + self.user = self._objects.CurrentUser(self, r.json()) else: manager = self._objects.CurrentUserManager() - self.user = credentials_auth(self.email, self.password) + self.user = manager.get(self.email, self.password) self._set_token(self.user.private_token) @@ -211,7 +211,10 @@ def token_auth(self): self._token_auth() def _token_auth(self): - self.user = self._objects.CurrentUserManager(self).get() + if self.api_version == '3': + self.user = self._objects.CurrentUser(self) + else: + self.user = self._objects.CurrentUserManager(self).get() def version(self): """Returns the version and revision of the gitlab server. diff --git a/gitlab/mixins.py b/gitlab/mixins.py index 0a16a92d5..ed3b204a2 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -255,13 +255,13 @@ def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs): path = '%s/%s/approve' % (self.manager.path, self.id) data = {'access_level': access_level} - server_data = self.manager.gitlab.http_put(url, post_data=data, + server_data = self.manager.gitlab.http_put(path, post_data=data, **kwargs) self._update_attrs(server_data) class SubscribableMixin(object): - def subscribe(self, **kwarg): + def subscribe(self, **kwargs): """Subscribe to the object notifications. raises: diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index b547d81a4..8eb977b36 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -23,7 +23,6 @@ import six -import gitlab from gitlab.base import * # noqa from gitlab.exceptions import * # noqa from gitlab.mixins import * # noqa @@ -203,6 +202,7 @@ def credentials_auth(self, email, password): server_data = self.gitlab.http_post('/session', post_data=data) return CurrentUser(self, server_data) + class ApplicationSettings(SaveMixin, RESTObject): _id_attr = None @@ -300,6 +300,7 @@ class GitlabciymlManager(RetrieveMixin, RESTManager): class GroupIssue(RESTObject): pass + class GroupIssueManager(GetFromListMixin, RESTManager): _path = '/groups/%(group_id)s/issues' _obj_cls = GroupIssue @@ -373,7 +374,7 @@ class License(RESTObject): class LicenseManager(RetrieveMixin, RESTManager): _path = '/templates/licenses' _obj_cls = License - _list_filters =('popular') + _list_filters = ('popular', ) _optional_get_attrs = ('project', 'fullname') @@ -402,7 +403,7 @@ def content(self, streamed=False, action=None, chunk_size=1024, **kwargs): path = '/snippets/%s/raw' % self.get_id() result = self.manager.gitlab.http_get(path, streamed=streamed, **kwargs) - return utils.response_content(r, streamed, action, chunk_size) + return utils.response_content(result, streamed, action, chunk_size) class SnippetManager(CRUDMixin, RESTManager): @@ -467,7 +468,7 @@ class ProjectBranch(RESTObject): def protect(self, developers_can_push=False, developers_can_merge=False, **kwargs): """Protects the branch. - + Args: developers_can_push (bool): Set to True if developers are allowed to push to the branch @@ -588,7 +589,8 @@ class ProjectCommitStatus(RESTObject): class ProjectCommitStatusManager(RetrieveMixin, CreateMixin, RESTManager): - _path = '/projects/%(project_id)s/repository/commits/%(commit_id)s/statuses' + _path = ('/projects/%(project_id)s/repository/commits/%(commit_id)s' + '/statuses') _obj_cls = ProjectCommitStatus _from_parent_attrs = {'project_id': 'project_id', 'commit_id': 'id'} _create_attrs = (('state', ), @@ -696,7 +698,7 @@ class ProjectEvent(RESTObject): class ProjectEventManager(GetFromListMixin, RESTManager): - _path ='/projects/%(project_id)s/events' + _path = '/projects/%(project_id)s/events' _obj_cls = ProjectEvent _from_parent_attrs = {'project_id': 'id'} @@ -741,7 +743,7 @@ class ProjectHookManager(CRUDMixin, RESTManager): class ProjectIssueNote(SaveMixin, RESTObject): - _constructor_types= {'author': 'User'} + _constructor_types = {'author': 'User'} class ProjectIssueNoteManager(RetrieveMixin, CreateMixin, UpdateMixin, @@ -754,7 +756,7 @@ class ProjectIssueNoteManager(RetrieveMixin, CreateMixin, UpdateMixin, class ProjectIssue(SubscribableMixin, TodoMixin, TimeTrackingMixin, SaveMixin, - RESTObject): + RESTObject): _constructor_types = {'author': 'User', 'assignee': 'User', 'milestone': 'ProjectMilestone'} _short_print_attr = 'title' @@ -769,7 +771,7 @@ def move(self, to_project_id, **kwargs): """ path = '%s/%s/move' % (self.manager.path, self.get_id()) data = {'to_project_id': to_project_id} - server_data = self.manager.gitlab.http_post(url, post_data=data, + server_data = self.manager.gitlab.http_post(path, post_data=data, **kwargs) self._update_attrs(server_data) @@ -808,7 +810,7 @@ class ProjectNote(RESTObject): class ProjectNoteManager(RetrieveMixin, RESTManager): - _path ='/projects/%(project_id)s/notes' + _path = '/projects/%(project_id)s/notes' _obj_cls = ProjectNote _from_parent_attrs = {'project_id': 'id'} _create_attrs = (('body', ), tuple()) @@ -844,13 +846,13 @@ def set_release_description(self, description, **kwargs): GitlabCreateError: If the server fails to create the release. GitlabUpdateError: If the server fails to update the release. """ - _path = '%s/%s/release' % (self.manager.path, self.get_id()) + path = '%s/%s/release' % (self.manager.path, self.get_id()) data = {'description': description} if self.release is None: - result = self.manager.gitlab.http_post(url, post_data=data, + result = self.manager.gitlab.http_post(path, post_data=data, **kwargs) else: - result = self.manager.gitlab.http_put(url, post_data=data, + result = self.manager.gitlab.http_put(path, post_data=data, **kwargs) self.release = result.json() @@ -1215,7 +1217,7 @@ def content(self, streamed=False, action=None, chunk_size=1024, **kwargs): path = "%s/%s/raw" % (self.manager.path, self.get_id()) result = self.manager.gitlab.http_get(path, streamed=streamed, **kwargs) - return utils.response_content(r, streamed, action, chunk_size) + return utils.response_content(result, streamed, action, chunk_size) class ProjectSnippetManager(CRUDMixin, RESTManager): @@ -1382,7 +1384,6 @@ class ProjectRunnerManager(NoUpdateMixin, RESTManager): _create_attrs = (('runner_id', ), tuple()) - class Project(SaveMixin, RESTObject): _constructor_types = {'owner': 'User', 'namespace': 'Group'} _short_print_attr = 'path' @@ -1459,7 +1460,7 @@ def repository_raw_blob(self, sha, streamed=False, action=None, GitlabGetError: If the server fails to perform the request. """ path = '/projects/%s/repository/raw_blobs/%s' % (self.get_id(), sha) - result = self.gitlab._raw_get(url, streamed=streamed, **kwargs) + result = self.gitlab._raw_get(path, streamed=streamed, **kwargs) return utils.response_content(result, streamed, action, chunk_size) def repository_compare(self, from_, to, **kwargs): @@ -1598,7 +1599,7 @@ def unarchive(self, **kwargs): GitlabConnectionError: If the server cannot be reached. """ path = '/projects/%s/unarchive' % self.get_id() - server_data = self.manager.gitlab.http_post(url, **kwargs) + server_data = self.manager.gitlab.http_post(path, **kwargs) self._update_attrs(server_data) def share(self, group_id, group_access, expires_at=None, **kwargs): @@ -1649,7 +1650,6 @@ class RunnerManager(RetrieveMixin, UpdateMixin, DeleteMixin, RESTManager): _update_attrs = (tuple(), ('description', 'active', 'tag_list')) _list_filters = ('scope', ) - def all(self, scope=None, **kwargs): """List all the runners. From ff82c88df5794dbf0020989cfc52412cefc4c176 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Mon, 29 May 2017 22:48:53 +0200 Subject: [PATCH 22/93] Tests and fixes for the http_* methods --- gitlab/__init__.py | 23 ++-- gitlab/exceptions.py | 4 - gitlab/tests/test_gitlab.py | 220 ++++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 17 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index d42dbd339..57a91edcf 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -683,8 +683,8 @@ def http_get(self, path, query_data={}, streamed=False, **kwargs): try: return result.json() except Exception: - raise GitlaParsingError( - message="Failed to parse the server message") + raise GitlabParsingError( + error_message="Failed to parse the server message") else: return result @@ -734,14 +734,11 @@ def http_post(self, path, query_data={}, post_data={}, **kwargs): """ result = self.http_request('post', path, query_data=query_data, post_data=post_data, **kwargs) - if result.headers.get('Content-Type', None) == 'application/json': - try: - return result.json() - except Exception: - raise GitlabParsingError( - message="Failed to parse the server message") - else: - return result.content + try: + return result.json() + except Exception: + raise GitlabParsingError( + error_message="Failed to parse the server message") def http_put(self, path, query_data={}, post_data={}, **kwargs): """Make a PUT request to the Gitlab server. @@ -767,7 +764,7 @@ def http_put(self, path, query_data={}, post_data={}, **kwargs): return result.json() except Exception: raise GitlabParsingError( - message="Failed to parse the server message") + error_message="Failed to parse the server message") def http_delete(self, path, **kwargs): """Make a PUT request to the Gitlab server. @@ -814,7 +811,7 @@ def _query(self, url, query_data={}, **kwargs): self._data = result.json() except Exception: raise GitlabParsingError( - message="Failed to parse the server message") + error_message="Failed to parse the server message") self._current = 0 @@ -822,7 +819,7 @@ def __iter__(self): return self def __len__(self): - return self._total_pages + return int(self._total_pages) def __next__(self): return self.next() diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py index 9f27c21f5..c9048a556 100644 --- a/gitlab/exceptions.py +++ b/gitlab/exceptions.py @@ -55,10 +55,6 @@ class GitlabHttpError(GitlabError): pass -class GitlaParsingError(GitlabHttpError): - pass - - class GitlabListError(GitlabOperationError): pass diff --git a/gitlab/tests/test_gitlab.py b/gitlab/tests/test_gitlab.py index c2cd19bf4..1710fff05 100644 --- a/gitlab/tests/test_gitlab.py +++ b/gitlab/tests/test_gitlab.py @@ -171,6 +171,226 @@ def resp_cont(url, request): self.assertEqual(resp.status_code, 404) +class TestGitlabHttpMethods(unittest.TestCase): + def setUp(self): + self.gl = Gitlab("http://localhost", private_token="private_token", + api_version=4) + + def test_build_url(self): + r = self.gl._build_url('http://localhost/api/v4') + self.assertEqual(r, 'http://localhost/api/v4') + r = self.gl._build_url('https://localhost/api/v4') + self.assertEqual(r, 'https://localhost/api/v4') + r = self.gl._build_url('/projects') + self.assertEqual(r, 'http://localhost/api/v4/projects') + + def test_http_request(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="get") + def resp_cont(url, request): + headers = {'content-type': 'application/json'} + content = '[{"name": "project1"}]' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + http_r = self.gl.http_request('get', '/projects') + http_r.json() + self.assertEqual(http_r.status_code, 200) + + def test_http_request_404(self): + @urlmatch(scheme="http", netloc="localhost", + path="/api/v4/not_there", method="get") + def resp_cont(url, request): + content = {'Here is wh it failed'} + return response(404, content, {}, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabHttpError, + self.gl.http_request, + 'get', '/not_there') + + def test_get_request(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="get") + def resp_cont(url, request): + headers = {'content-type': 'application/json'} + content = '{"name": "project1"}' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + result = self.gl.http_get('/projects') + self.assertIsInstance(result, dict) + self.assertEqual(result['name'], 'project1') + + def test_get_request_raw(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="get") + def resp_cont(url, request): + headers = {'content-type': 'application/octet-stream'} + content = 'content' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + result = self.gl.http_get('/projects') + self.assertEqual(result.content.decode('utf-8'), 'content') + + def test_get_request_404(self): + @urlmatch(scheme="http", netloc="localhost", + path="/api/v4/not_there", method="get") + def resp_cont(url, request): + content = {'Here is wh it failed'} + return response(404, content, {}, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabHttpError, self.gl.http_get, '/not_there') + + def test_get_request_invalid_data(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="get") + def resp_cont(url, request): + headers = {'content-type': 'application/json'} + content = '["name": "project1"]' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabParsingError, self.gl.http_get, + '/projects') + + def test_list_request(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="get") + def resp_cont(url, request): + headers = {'content-type': 'application/json', 'X-Total-Pages': 1} + content = '[{"name": "project1"}]' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + result = self.gl.http_list('/projects') + self.assertIsInstance(result, GitlabList) + self.assertEqual(len(result), 1) + + with HTTMock(resp_cont): + result = self.gl.http_list('/projects', all=True) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + + def test_list_request_404(self): + @urlmatch(scheme="http", netloc="localhost", + path="/api/v4/not_there", method="get") + def resp_cont(url, request): + content = {'Here is wh it failed'} + return response(404, content, {}, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabHttpError, self.gl.http_list, '/not_there') + + def test_list_request_invalid_data(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="get") + def resp_cont(url, request): + headers = {'content-type': 'application/json'} + content = '["name": "project1"]' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabParsingError, self.gl.http_list, + '/projects') + + def test_post_request(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="post") + def resp_cont(url, request): + headers = {'content-type': 'application/json'} + content = '{"name": "project1"}' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + result = self.gl.http_post('/projects') + self.assertIsInstance(result, dict) + self.assertEqual(result['name'], 'project1') + + def test_post_request_404(self): + @urlmatch(scheme="http", netloc="localhost", + path="/api/v4/not_there", method="post") + def resp_cont(url, request): + content = {'Here is wh it failed'} + return response(404, content, {}, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabHttpError, self.gl.http_post, '/not_there') + + def test_post_request_invalid_data(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="post") + def resp_cont(url, request): + headers = {'content-type': 'application/json'} + content = '["name": "project1"]' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabParsingError, self.gl.http_post, + '/projects') + + def test_put_request(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="put") + def resp_cont(url, request): + headers = {'content-type': 'application/json'} + content = '{"name": "project1"}' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + result = self.gl.http_put('/projects') + self.assertIsInstance(result, dict) + self.assertEqual(result['name'], 'project1') + + def test_put_request_404(self): + @urlmatch(scheme="http", netloc="localhost", + path="/api/v4/not_there", method="put") + def resp_cont(url, request): + content = {'Here is wh it failed'} + return response(404, content, {}, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabHttpError, self.gl.http_put, '/not_there') + + def test_put_request_invalid_data(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="put") + def resp_cont(url, request): + headers = {'content-type': 'application/json'} + content = '["name": "project1"]' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabParsingError, self.gl.http_put, + '/projects') + + def test_delete_request(self): + @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", + method="delete") + def resp_cont(url, request): + headers = {'content-type': 'application/json'} + content = 'true' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + result = self.gl.http_delete('/projects') + self.assertIsInstance(result, requests.Response) + self.assertEqual(result.json(), True) + + def test_delete_request_404(self): + @urlmatch(scheme="http", netloc="localhost", + path="/api/v4/not_there", method="delete") + def resp_cont(url, request): + content = {'Here is wh it failed'} + return response(404, content, {}, None, 5, request) + + with HTTMock(resp_cont): + self.assertRaises(GitlabHttpError, self.gl.http_delete, + '/not_there') + + class TestGitlabMethods(unittest.TestCase): def setUp(self): self.gl = Gitlab("http://localhost", private_token="private_token", From 0d94ee228b6ac1ffef4c4cac68a4e4757a6a824c Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Mon, 5 Jun 2017 08:48:18 +0200 Subject: [PATCH 23/93] Unit tests for REST* classes --- gitlab/base.py | 15 +++-- gitlab/tests/test_base.py | 129 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 gitlab/tests/test_base.py diff --git a/gitlab/base.py b/gitlab/base.py index 89495544f..c318c1dc1 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -540,8 +540,8 @@ class RESTObject(object): 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 without - ID in the url. + must be used as uniq ID. ``None`` means that the object can be updated + without ID in the url. """ _id_attr = 'id' @@ -594,8 +594,8 @@ def _create_managers(self): self.__dict__[attr] = manager def _update_attrs(self, new_attrs): - self._updated_attrs = {} - self._attrs.update(new_attrs) + self.__dict__['_updated_attrs'] = {} + self.__dict__['_attrs'].update(new_attrs) def get_id(self): if self._id_attr is None: @@ -649,6 +649,13 @@ class RESTManager(object): _obj_cls = None def __init__(self, gl, parent=None): + """REST manager constructor. + + Args: + gl (Gitlab): :class:`~gitlab.Gitlab` connection to use to make + requests. + parent: REST object to which the manager is attached. + """ self.gitlab = gl self._parent = parent # for nested managers self._computed_path = self._compute_path() diff --git a/gitlab/tests/test_base.py b/gitlab/tests/test_base.py new file mode 100644 index 000000000..c55f0003c --- /dev/null +++ b/gitlab/tests/test_base.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2017 Gauvain Pocentek +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +try: + import unittest +except ImportError: + import unittest2 as unittest + +from gitlab import base + + +class FakeGitlab(object): + pass + + +class FakeObject(base.RESTObject): + pass + + +class FakeManager(base.RESTManager): + _obj_cls = FakeObject + _path = '/tests' + + +class TestRESTManager(unittest.TestCase): + def test_computed_path_simple(self): + class MGR(base.RESTManager): + _path = '/tests' + _obj_cls = object + + mgr = MGR(FakeGitlab()) + self.assertEqual(mgr._computed_path, '/tests') + + def test_computed_path_with_parent(self): + class MGR(base.RESTManager): + _path = '/tests/%(test_id)s/cases' + _obj_cls = object + _from_parent_attrs = {'test_id': 'id'} + + class Parent(object): + id = 42 + + class BrokenParent(object): + no_id = 0 + + 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' + _obj_cls = object + + mgr = MGR(FakeGitlab()) + self.assertEqual(mgr.path, '/tests') + + +class TestRESTObject(unittest.TestCase): + def setUp(self): + self.gitlab = FakeGitlab() + self.manager = FakeManager(self.gitlab) + + def test_instanciate(self): + obj = FakeObject(self.manager, {'foo': 'bar'}) + + self.assertDictEqual({'foo': 'bar'}, obj._attrs) + self.assertDictEqual({}, obj._updated_attrs) + self.assertEqual(None, obj._create_managers()) + self.assertEqual(self.manager, obj.manager) + self.assertEqual(self.gitlab, obj.manager.gitlab) + + def test_attrs(self): + obj = FakeObject(self.manager, {'foo': 'bar'}) + + self.assertEqual('bar', obj.foo) + self.assertRaises(AttributeError, getattr, obj, 'bar') + + obj.bar = 'baz' + self.assertEqual('baz', obj.bar) + self.assertDictEqual({'foo': 'bar'}, obj._attrs) + self.assertDictEqual({'bar': 'baz'}, obj._updated_attrs) + + def test_get_id(self): + obj = FakeObject(self.manager, {'foo': 'bar'}) + obj.id = 42 + self.assertEqual(42, obj.get_id()) + + obj.id = None + self.assertEqual(None, obj.get_id()) + + def test_custom_id_attr(self): + class OtherFakeObject(FakeObject): + _id_attr = 'foo' + + obj = OtherFakeObject(self.manager, {'foo': 'bar'}) + self.assertEqual('bar', obj.get_id()) + + def test_update_attrs(self): + obj = FakeObject(self.manager, {'foo': 'bar'}) + obj.bar = 'baz' + obj._update_attrs({'foo': 'foo', 'bar': 'bar'}) + self.assertDictEqual({'foo': 'foo', 'bar': 'bar'}, obj._attrs) + self.assertDictEqual({}, obj._updated_attrs) + + def test_create_managers(self): + class ObjectWithManager(FakeObject): + _managers = (('fakes', 'FakeManager'), ) + + obj = ObjectWithManager(self.manager, {'foo': 'bar'}) + self.assertIsInstance(obj.fakes, FakeManager) + self.assertEqual(obj.fakes.gitlab, self.gitlab) + self.assertEqual(obj.fakes._parent, obj) From 15511bfba32685b7c67ca8886626076cdf3561ab Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Mon, 5 Jun 2017 08:49:04 +0200 Subject: [PATCH 24/93] Fix GitlabList.__len__ --- gitlab/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 57a91edcf..e6a151a87 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -819,7 +819,7 @@ def __iter__(self): return self def __len__(self): - return int(self._total_pages) + return int(self._total) def __next__(self): return self.next() From f2c4a6e0e27eb5af795dd1a4281014502c1ff1e4 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Mon, 5 Jun 2017 09:28:06 +0200 Subject: [PATCH 25/93] Basic test for GitlabList --- gitlab/tests/test_gitlab.py | 48 ++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/gitlab/tests/test_gitlab.py b/gitlab/tests/test_gitlab.py index 1710fff05..d642eaf42 100644 --- a/gitlab/tests/test_gitlab.py +++ b/gitlab/tests/test_gitlab.py @@ -171,6 +171,52 @@ def resp_cont(url, request): self.assertEqual(resp.status_code, 404) +class TestGitlabList(unittest.TestCase): + def setUp(self): + self.gl = Gitlab("http://localhost", private_token="private_token", + api_version=4) + + def test_build_list(self): + @urlmatch(scheme='http', netloc="localhost", path="/api/v4/tests", + method="get") + def resp_1(url, request): + headers = {'content-type': 'application/json', + 'X-Page': 1, + 'X-Next-Page': 2, + 'X-Per-Page': 1, + 'X-Total-Pages': 2, + 'X-Total': 2, + 'Link': ( + ';' + ' rel="next"')} + content = '[{"a": "b"}]' + return response(200, content, headers, None, 5, request) + + @urlmatch(scheme='http', netloc="localhost", path="/api/v4/tests", + method='get', query=r'.*page=2') + def resp_2(url, request): + headers = {'content-type': 'application/json', + 'X-Page': 2, + 'X-Next-Page': 2, + 'X-Per-Page': 1, + 'X-Total-Pages': 2, + 'X-Total': 2} + content = '[{"c": "d"}]' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_1): + obj = self.gl.http_list('/tests') + self.assertEqual(len(obj), 2) + self.assertEqual(obj._next_url, + 'http://localhost/api/v4/tests?per_page=1&page=2') + + with HTTMock(resp_2): + l = list(obj) + self.assertEqual(len(l), 2) + self.assertEqual(l[0]['a'], 'b') + self.assertEqual(l[1]['c'], 'd') + + class TestGitlabHttpMethods(unittest.TestCase): def setUp(self): self.gl = Gitlab("http://localhost", private_token="private_token", @@ -260,7 +306,7 @@ def test_list_request(self): @urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", method="get") def resp_cont(url, request): - headers = {'content-type': 'application/json', 'X-Total-Pages': 1} + headers = {'content-type': 'application/json', 'X-Total': 1} content = '[{"name": "project1"}]' return response(200, content, headers, None, 5, request) From b776c5ee66a84f89acd4126ea729c77196e07f66 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Mon, 5 Jun 2017 11:14:13 +0200 Subject: [PATCH 26/93] Add tests for managers mixins --- gitlab/mixins.py | 7 +- gitlab/tests/test_mixins.py | 354 ++++++++++++++++++++++++++++++++++++ 2 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 gitlab/tests/test_mixins.py diff --git a/gitlab/mixins.py b/gitlab/mixins.py index ed3b204a2..670f33d10 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -17,6 +17,7 @@ import gitlab from gitlab import base +from gitlab import exceptions class GetMixin(object): @@ -93,13 +94,15 @@ def get(self, id, **kwargs): object: The generated RESTObject. Raises: - GitlabGetError: If the server cannot perform the request. + AttributeError: If the object could not be found in the list """ gen = self.list() for obj in gen: if str(obj.get_id()) == str(id): return obj + raise exceptions.GitlabHttpError(404, "Not found") + class RetrieveMixin(ListMixin, GetMixin): pass @@ -141,7 +144,7 @@ def create(self, data, **kwargs): if hasattr(self, '_sanitize_data'): data = self._sanitize_data(data, 'create') # Handle specific URL for creation - path = kwargs.get('path', self.path) + path = kwargs.pop('path', self.path) server_data = self.gitlab.http_post(path, post_data=data, **kwargs) return self._obj_cls(self, server_data) diff --git a/gitlab/tests/test_mixins.py b/gitlab/tests/test_mixins.py new file mode 100644 index 000000000..e202ffa8d --- /dev/null +++ b/gitlab/tests/test_mixins.py @@ -0,0 +1,354 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 Mika Mäenpää , +# Tampere University of Technology +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +from __future__ import print_function + +try: + import unittest +except ImportError: + import unittest2 as unittest + +from httmock import HTTMock # noqa +from httmock import response # noqa +from httmock import urlmatch # noqa + +from gitlab import * # noqa +from gitlab.base import * # noqa +from gitlab.mixins import * # noqa + + +class TestMetaMixins(unittest.TestCase): + def test_retrieve_mixin(self): + class M(RetrieveMixin): + pass + + obj = M() + self.assertTrue(hasattr(obj, 'list')) + self.assertTrue(hasattr(obj, 'get')) + self.assertFalse(hasattr(obj, 'create')) + self.assertFalse(hasattr(obj, 'update')) + self.assertFalse(hasattr(obj, 'delete')) + self.assertIsInstance(obj, ListMixin) + self.assertIsInstance(obj, GetMixin) + + def test_crud_mixin(self): + class M(CRUDMixin): + pass + + obj = M() + self.assertTrue(hasattr(obj, 'get')) + self.assertTrue(hasattr(obj, 'list')) + self.assertTrue(hasattr(obj, 'create')) + self.assertTrue(hasattr(obj, 'update')) + self.assertTrue(hasattr(obj, 'delete')) + self.assertIsInstance(obj, ListMixin) + self.assertIsInstance(obj, GetMixin) + self.assertIsInstance(obj, CreateMixin) + self.assertIsInstance(obj, UpdateMixin) + self.assertIsInstance(obj, DeleteMixin) + + def test_no_update_mixin(self): + class M(NoUpdateMixin): + pass + + obj = M() + self.assertTrue(hasattr(obj, 'get')) + self.assertTrue(hasattr(obj, 'list')) + self.assertTrue(hasattr(obj, 'create')) + self.assertFalse(hasattr(obj, 'update')) + self.assertTrue(hasattr(obj, 'delete')) + self.assertIsInstance(obj, ListMixin) + self.assertIsInstance(obj, GetMixin) + self.assertIsInstance(obj, CreateMixin) + self.assertNotIsInstance(obj, UpdateMixin) + self.assertIsInstance(obj, DeleteMixin) + + +class FakeObject(base.RESTObject): + pass + + +class FakeManager(base.RESTManager): + _path = '/tests' + _obj_cls = FakeObject + + +class TestMixinMethods(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 + + @urlmatch(scheme="http", netloc="localhost", path='/api/v4/tests/42', + method="get") + def resp_cont(url, request): + headers = {'Content-Type': 'application/json'} + content = '{"id": 42, "foo": "bar"}' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + mgr = M(self.gl) + obj = mgr.get(42) + self.assertIsInstance(obj, FakeObject) + self.assertEqual(obj.foo, 'bar') + self.assertEqual(obj.id, 42) + + def test_get_without_id_mixin(self): + class M(GetWithoutIdMixin, FakeManager): + pass + + @urlmatch(scheme="http", netloc="localhost", path='/api/v4/tests', + method="get") + def resp_cont(url, request): + headers = {'Content-Type': 'application/json'} + content = '{"foo": "bar"}' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + mgr = M(self.gl) + obj = mgr.get() + self.assertIsInstance(obj, FakeObject) + self.assertEqual(obj.foo, 'bar') + self.assertFalse(hasattr(obj, 'id')) + + def test_list_mixin(self): + class M(ListMixin, FakeManager): + pass + + @urlmatch(scheme="http", netloc="localhost", path='/api/v4/tests', + method="get") + def resp_cont(url, request): + headers = {'Content-Type': 'application/json'} + content = '[{"id": 42, "foo": "bar"},{"id": 43, "foo": "baz"}]' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + # test RESTObjectList + mgr = M(self.gl) + obj_list = mgr.list() + self.assertIsInstance(obj_list, base.RESTObjectList) + for obj in obj_list: + self.assertIsInstance(obj, FakeObject) + self.assertIn(obj.id, (42, 43)) + + # test list() + obj_list = mgr.list(all=True) + self.assertIsInstance(obj_list, list) + self.assertEqual(obj_list[0].id, 42) + self.assertEqual(obj_list[1].id, 43) + self.assertIsInstance(obj_list[0], FakeObject) + self.assertEqual(len(obj_list), 2) + + def test_list_other_url(self): + class M(ListMixin, FakeManager): + pass + + @urlmatch(scheme="http", netloc="localhost", path='/api/v4/others', + method="get") + def resp_cont(url, request): + headers = {'Content-Type': 'application/json'} + content = '[{"id": 42, "foo": "bar"}]' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + mgr = M(self.gl) + obj_list = mgr.list(path='/others') + self.assertIsInstance(obj_list, base.RESTObjectList) + obj = obj_list.next() + self.assertEqual(obj.id, 42) + self.assertEqual(obj.foo, 'bar') + self.assertRaises(StopIteration, obj_list.next) + + def test_get_from_list_mixin(self): + class M(GetFromListMixin, FakeManager): + pass + + @urlmatch(scheme="http", netloc="localhost", path='/api/v4/tests', + method="get") + def resp_cont(url, request): + headers = {'Content-Type': 'application/json'} + content = '[{"id": 42, "foo": "bar"},{"id": 43, "foo": "baz"}]' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + mgr = M(self.gl) + obj = mgr.get(42) + self.assertIsInstance(obj, FakeObject) + self.assertEqual(obj.foo, 'bar') + self.assertEqual(obj.id, 42) + + self.assertRaises(GitlabHttpError, mgr.get, 44) + + def test_create_mixin_get_attrs(self): + class M1(CreateMixin, FakeManager): + pass + + class M2(CreateMixin, FakeManager): + _create_attrs = (('foo',), ('bar', 'baz')) + _update_attrs = (('foo',), ('bam', )) + + mgr = M1(self.gl) + required, optional = mgr.get_create_attrs() + self.assertEqual(len(required), 0) + self.assertEqual(len(optional), 0) + + mgr = M2(self.gl) + required, optional = mgr.get_create_attrs() + self.assertIn('foo', required) + self.assertIn('bar', optional) + self.assertIn('baz', optional) + self.assertNotIn('bam', optional) + + def test_create_mixin_missing_attrs(self): + class M(CreateMixin, FakeManager): + _create_attrs = (('foo',), ('bar', 'baz')) + + mgr = M(self.gl) + data = {'foo': 'bar', 'baz': 'blah'} + mgr._check_missing_create_attrs(data) + + data = {'baz': 'blah'} + with self.assertRaises(AttributeError) as error: + mgr._check_missing_create_attrs(data) + self.assertIn('foo', str(error.exception)) + + def test_create_mixin(self): + class M(CreateMixin, FakeManager): + _create_attrs = (('foo',), ('bar', 'baz')) + _update_attrs = (('foo',), ('bam', )) + + @urlmatch(scheme="http", netloc="localhost", path='/api/v4/tests', + method="post") + def resp_cont(url, request): + headers = {'Content-Type': 'application/json'} + content = '{"id": 42, "foo": "bar"}' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + mgr = M(self.gl) + obj = mgr.create({'foo': 'bar'}) + self.assertIsInstance(obj, FakeObject) + self.assertEqual(obj.id, 42) + self.assertEqual(obj.foo, 'bar') + + def test_create_mixin_custom_path(self): + class M(CreateMixin, FakeManager): + _create_attrs = (('foo',), ('bar', 'baz')) + _update_attrs = (('foo',), ('bam', )) + + @urlmatch(scheme="http", netloc="localhost", path='/api/v4/others', + method="post") + def resp_cont(url, request): + headers = {'Content-Type': 'application/json'} + content = '{"id": 42, "foo": "bar"}' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + mgr = M(self.gl) + obj = mgr.create({'foo': 'bar'}, path='/others') + self.assertIsInstance(obj, FakeObject) + self.assertEqual(obj.id, 42) + self.assertEqual(obj.foo, 'bar') + + def test_update_mixin_get_attrs(self): + class M1(UpdateMixin, FakeManager): + pass + + class M2(UpdateMixin, FakeManager): + _create_attrs = (('foo',), ('bar', 'baz')) + _update_attrs = (('foo',), ('bam', )) + + mgr = M1(self.gl) + required, optional = mgr.get_update_attrs() + self.assertEqual(len(required), 0) + self.assertEqual(len(optional), 0) + + mgr = M2(self.gl) + required, optional = mgr.get_update_attrs() + self.assertIn('foo', required) + self.assertIn('bam', optional) + self.assertNotIn('bar', optional) + self.assertNotIn('baz', optional) + + def test_update_mixin_missing_attrs(self): + class M(UpdateMixin, FakeManager): + _update_attrs = (('foo',), ('bar', 'baz')) + + mgr = M(self.gl) + data = {'foo': 'bar', 'baz': 'blah'} + mgr._check_missing_update_attrs(data) + + data = {'baz': 'blah'} + with self.assertRaises(AttributeError) as error: + mgr._check_missing_update_attrs(data) + self.assertIn('foo', str(error.exception)) + + def test_update_mixin(self): + class M(UpdateMixin, FakeManager): + _create_attrs = (('foo',), ('bar', 'baz')) + _update_attrs = (('foo',), ('bam', )) + + @urlmatch(scheme="http", netloc="localhost", path='/api/v4/tests/42', + method="put") + def resp_cont(url, request): + headers = {'Content-Type': 'application/json'} + content = '{"id": 42, "foo": "baz"}' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + mgr = M(self.gl) + server_data = mgr.update(42, {'foo': 'baz'}) + self.assertIsInstance(server_data, dict) + self.assertEqual(server_data['id'], 42) + self.assertEqual(server_data['foo'], 'baz') + + def test_update_mixin_no_id(self): + class M(UpdateMixin, FakeManager): + _create_attrs = (('foo',), ('bar', 'baz')) + _update_attrs = (('foo',), ('bam', )) + + @urlmatch(scheme="http", netloc="localhost", path='/api/v4/tests', + method="put") + def resp_cont(url, request): + headers = {'Content-Type': 'application/json'} + content = '{"foo": "baz"}' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + mgr = M(self.gl) + server_data = mgr.update(new_data={'foo': 'baz'}) + self.assertIsInstance(server_data, dict) + self.assertEqual(server_data['foo'], 'baz') + + def test_delete_mixin(self): + class M(DeleteMixin, FakeManager): + pass + + @urlmatch(scheme="http", netloc="localhost", path='/api/v4/tests/42', + method="delete") + def resp_cont(url, request): + headers = {'Content-Type': 'application/json'} + content = '' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + mgr = M(self.gl) + mgr.delete(42) From 68f411478f0d693f7d37436a9280847cb610a15b Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Mon, 5 Jun 2017 11:25:41 +0200 Subject: [PATCH 27/93] tests for objects mixins --- gitlab/tests/test_mixins.py | 57 +++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/gitlab/tests/test_mixins.py b/gitlab/tests/test_mixins.py index e202ffa8d..dd456eb88 100644 --- a/gitlab/tests/test_mixins.py +++ b/gitlab/tests/test_mixins.py @@ -32,6 +32,41 @@ from gitlab.mixins import * # noqa +class TestObjectMixinsAttributes(unittest.TestCase): + def test_access_request_mixin(self): + class O(AccessRequestMixin): + pass + + obj = O() + self.assertTrue(hasattr(obj, 'approve')) + + def test_subscribable_mixin(self): + class O(SubscribableMixin): + pass + + obj = O() + self.assertTrue(hasattr(obj, 'subscribe')) + self.assertTrue(hasattr(obj, 'unsubscribe')) + + def test_todo_mixin(self): + class O(TodoMixin): + pass + + obj = O() + self.assertTrue(hasattr(obj, 'todo')) + + def test_time_tracking_mixin(self): + class O(TimeTrackingMixin): + pass + + obj = O() + self.assertTrue(hasattr(obj, 'time_stats')) + self.assertTrue(hasattr(obj, 'time_estimate')) + self.assertTrue(hasattr(obj, 'reset_time_estimate')) + self.assertTrue(hasattr(obj, 'add_spent_time')) + self.assertTrue(hasattr(obj, 'reset_spent_time')) + + class TestMetaMixins(unittest.TestCase): def test_retrieve_mixin(self): class M(RetrieveMixin): @@ -352,3 +387,25 @@ def resp_cont(url, request): with HTTMock(resp_cont): mgr = M(self.gl) mgr.delete(42) + + def test_save_mixin(self): + class M(UpdateMixin, FakeManager): + pass + + class O(SaveMixin, RESTObject): + pass + + @urlmatch(scheme="http", netloc="localhost", path='/api/v4/tests/42', + method="put") + def resp_cont(url, request): + headers = {'Content-Type': 'application/json'} + content = '{"id": 42, "foo": "baz"}' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + mgr = M(self.gl) + obj = O(mgr, {'id': 42, 'foo': 'bar'}) + obj.foo = 'baz' + obj.save() + self.assertEqual(obj._attrs['foo'], 'baz') + self.assertDictEqual(obj._updated_attrs, {}) From 3488c5cf137b0dbe6e96a4412698bafaaa640143 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 11 Jun 2017 12:10:56 +0200 Subject: [PATCH 28/93] Fix a few remaining methods --- gitlab/base.py | 12 +++- gitlab/v4/objects.py | 150 +++++++++++++++---------------------------- 2 files changed, 62 insertions(+), 100 deletions(-) diff --git a/gitlab/base.py b/gitlab/base.py index c318c1dc1..d72a93395 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -552,6 +552,7 @@ def __init__(self, manager, attrs): '_updated_attrs': {}, '_module': importlib.import_module(self.__module__) }) + self.__dict__['_parent_attrs'] = self.manager.parent_attrs # TODO(gpocentek): manage the creation of new objects from the received # data (_constructor_types) @@ -565,7 +566,10 @@ def __getattr__(self, name): try: return self.__dict__['_attrs'][name] except KeyError: - raise AttributeError(name) + try: + return self.__dict__['_parent_attrs'][name] + except: + raise AttributeError(name) def __setattr__(self, name, value): self.__dict__['_updated_attrs'][name] = value @@ -660,7 +664,12 @@ def __init__(self, gl, parent=None): self._parent = parent # for nested managers self._computed_path = self._compute_path() + @property + def parent_attrs(self): + return self._parent_attrs + def _compute_path(self, path=None): + self._parent_attrs = {} if path is None: path = self._path if self._parent is None or not hasattr(self, '_from_parent_attrs'): @@ -668,6 +677,7 @@ def _compute_path(self, path=None): data = {self_attr: getattr(self._parent, parent_attr) for self_attr, parent_attr in self._from_parent_attrs.items()} + self._parent_attrs = data return path % data @property diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 8eb977b36..e9d1d03d6 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -906,44 +906,35 @@ def cancel_merge_when_pipeline_succeeds(self, **kwargs): self._update_attrs(server_data) def closes_issues(self, **kwargs): - """List issues closed by the MR. + """List issues that will close on merge." Returns: - list (ProjectIssue): List of closed issues - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the server fails to perform the request. + list (ProjectIssue): List of issues """ - # FIXME(gpocentek) - url = ('/projects/%s/merge_requests/%s/closes_issues' % - (self.project_id, self.iid)) - return self.gitlab._raw_list(url, ProjectIssue, **kwargs) + path = '%s/%s/closes_issues' % (self.manager.path, self.get_id()) + data_list = self.manager.gitlab.http_list(path, **kwargs) + manager = ProjectIssueManager(self.manager.gitlab, + parent=self.manager._parent) + return RESTObjectList(manager, ProjectIssue, data_list) def commits(self, **kwargs): """List the merge request commits. Returns: list (ProjectCommit): List of commits - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabListError: If the server fails to perform the request. """ - # FIXME(gpocentek) - url = ('/projects/%s/merge_requests/%s/commits' % - (self.project_id, self.iid)) - return self.gitlab._raw_list(url, ProjectCommit, **kwargs) + + path = '%s/%s/commits' % (self.manager.path, self.get_id()) + data_list = self.manager.gitlab.http_list(path, **kwargs) + manager = ProjectCommitManager(self.manager.gitlab, + parent=self.manager._parent) + return RESTObjectList(manager, ProjectCommit, data_list) def changes(self, **kwargs): """List the merge request changes. Returns: list (dict): List of changes - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabListError: If the server fails to perform the request. """ path = '%s/%s/changes' % (self.manager.path, self.get_id()) return self.manager.gitlab.http_get(path, **kwargs) @@ -960,14 +951,6 @@ def merge(self, merge_commit_message=None, branch merged_when_build_succeeds (bool): Wait for the build to succeed, then merge - - Returns: - ProjectMergeRequest: The updated MR - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabMRForbiddenError: If the user doesn't have permission to - close thr MR - GitlabMRClosedError: If the MR is already closed """ path = '%s/%s/merge' % (self.manager.path, self.get_id()) data = {} @@ -1002,23 +985,31 @@ class ProjectMilestone(SaveMixin, RESTObject): _short_print_attr = 'title' def issues(self, **kwargs): - url = '/projects/%s/milestones/%s/issues' % (self.project_id, self.id) - return self.gitlab._raw_list(url, ProjectIssue, **kwargs) + """List issues related to this milestone + + Returns: + list (ProjectIssue): The list of issues + """ + + path = '%s/%s/issues' % (self.manager.path, self.get_id()) + data_list = self.manager.gitlab.http_list(path, **kwargs) + manager = ProjectCommitManager(self.manager.gitlab, + parent=self.manager._parent) + # FIXME(gpocentek): the computed manager path is not correct + return RESTObjectList(manager, ProjectIssue, data_list) def merge_requests(self, **kwargs): """List the merge requests related to this milestone Returns: list (ProjectMergeRequest): List of merge requests - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabListError: If the server fails to perform the request. """ - # FIXME(gpocentek) - url = ('/projects/%s/milestones/%s/merge_requests' % - (self.project_id, self.id)) - return self.gitlab._raw_list(url, ProjectMergeRequest, **kwargs) + path = '%s/%s/merge_requests' % (self.manager.path, self.get_id()) + data_list = self.manager.gitlab.http_list(path, **kwargs) + manager = ProjectCommitManager(self.manager.gitlab, + parent=self.manager._parent) + # FIXME(gpocentek): the computed manager path is not correct + return RESTObjectList(manager, ProjectMergeRequest, data_list) class ProjectMilestoneManager(RetrieveMixin, CreateMixin, DeleteMixin, @@ -1425,20 +1416,29 @@ def repository_tree(self, path='', ref='', **kwargs): Returns: str: The json representation of the tree. - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the server fails to perform the request. """ - path = '/projects/%s/repository/tree' % self.get_id() + gl_path = '/projects/%s/repository/tree' % self.get_id() query_data = {} if path: query_data['path'] = path if ref: query_data['ref'] = ref - return self.manager.gitlab.http_get(path, query_data=query_data, + return self.manager.gitlab.http_get(gl_path, query_data=query_data, **kwargs) + def repository_blob(self, sha, **kwargs): + """Returns a blob by blob SHA. + + Args: + sha(str): ID of the blob + + Returns: + str: The blob as json + """ + + path = '/projects/%s/repository/blobs/%s' % (self.get_id(), sha) + return self.manager.gitlab.http_get(path, **kwargs) + def repository_raw_blob(self, sha, streamed=False, action=None, chunk_size=1024, **kwargs): """Returns the raw file contents for a blob by blob SHA. @@ -1454,13 +1454,9 @@ def repository_raw_blob(self, sha, streamed=False, action=None, Returns: str: The blob content - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the server fails to perform the request. """ - path = '/projects/%s/repository/raw_blobs/%s' % (self.get_id(), sha) - result = self.gitlab._raw_get(path, streamed=streamed, **kwargs) + path = '/projects/%s/repository/blobs/%s/raw' % (self.get_id(), sha) + result = self.manager.gitlab.http_get(path, streamed=streamed, **kwargs) return utils.response_content(result, streamed, action, chunk_size) def repository_compare(self, from_, to, **kwargs): @@ -1472,10 +1468,6 @@ def repository_compare(self, from_, to, **kwargs): Returns: str: The diff - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the server fails to perform the request. """ path = '/projects/%s/repository/compare' % self.get_id() query_data = {'from': from_, 'to': to} @@ -1486,11 +1478,7 @@ def repository_contributors(self, **kwargs): """Returns a list of contributors for the project. Returns: - list: The contibutors - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the server fails to perform the request. + list: The contributors """ path = '/projects/%s/repository/contributors' % self.get_id() return self.manager.gitlab.http_get(path, **kwargs) @@ -1510,17 +1498,13 @@ def repository_archive(self, sha=None, streamed=False, action=None, Returns: str: The binary data of the archive. - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the server fails to perform the request. """ path = '/projects/%s/repository/archive' % self.get_id() query_data = {} if sha: query_data['sha'] = sha - result = self.gitlab._raw_get(path, query_data=query_data, - streamed=streamed, **kwargs) + result = self.manager.gitlab.http_get(path, query_data=query_data, + streamed=streamed, **kwargs) return utils.response_content(result, streamed, action, chunk_size) def create_fork_relation(self, forked_from_id, **kwargs): @@ -1528,20 +1512,12 @@ def create_fork_relation(self, forked_from_id, **kwargs): Args: forked_from_id (int): The ID of the project that was forked from - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabCreateError: If the server fails to perform the request. """ path = '/projects/%s/fork/%s' % (self.get_id(), forked_from_id) self.manager.gitlab.http_post(path, **kwargs) def delete_fork_relation(self, **kwargs): """Delete a forked relation between existing projects. - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabDeleteError: If the server fails to perform the request. """ path = '/projects/%s/fork' % self.get_id() self.manager.gitlab.http_delete(path, **kwargs) @@ -1551,10 +1527,6 @@ def star(self, **kwargs): Returns: Project: the updated Project - - Raises: - GitlabCreateError: If the action cannot be done - GitlabConnectionError: If the server cannot be reached. """ path = '/projects/%s/star' % self.get_id() server_data = self.manager.gitlab.http_post(path, **kwargs) @@ -1565,10 +1537,6 @@ def unstar(self, **kwargs): Returns: Project: the updated Project - - Raises: - GitlabDeleteError: If the action cannot be done - GitlabConnectionError: If the server cannot be reached. """ path = '/projects/%s/unstar' % self.get_id() server_data = self.manager.gitlab.http_post(path, **kwargs) @@ -1579,10 +1547,6 @@ def archive(self, **kwargs): Returns: Project: the updated Project - - Raises: - GitlabCreateError: If the action cannot be done - GitlabConnectionError: If the server cannot be reached. """ path = '/projects/%s/archive' % self.get_id() server_data = self.manager.gitlab.http_post(path, **kwargs) @@ -1593,10 +1557,6 @@ def unarchive(self, **kwargs): Returns: Project: the updated Project - - Raises: - GitlabDeleteError: If the action cannot be done - GitlabConnectionError: If the server cannot be reached. """ path = '/projects/%s/unarchive' % self.get_id() server_data = self.manager.gitlab.http_post(path, **kwargs) @@ -1608,10 +1568,6 @@ def share(self, group_id, group_access, expires_at=None, **kwargs): Args: group_id (int): ID of the group. group_access (int): Access level for the group. - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabCreateError: If the server fails to perform the request. """ path = '/projects/%s/share' % self.get_id() data = {'group_id': group_id, @@ -1628,10 +1584,6 @@ def trigger_pipeline(self, ref, token, variables={}, **kwargs): ref (str): Commit to build; can be a commit SHA, a branch name, ... token (str): The trigger token variables (dict): Variables passed to the build script - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabCreateError: If the server fails to perform the request. """ path = '/projects/%s/trigger/pipeline' % self.get_id() form = {r'variables[%s]' % k: v for k, v in six.iteritems(variables)} From a0f215c2deb16ce5d9e96de5b36e4f360ac1b168 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 11 Jun 2017 13:25:36 +0200 Subject: [PATCH 29/93] Add new event types to ProjectHook --- gitlab/v4/objects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index e9d1d03d6..cf06b8e5f 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -720,7 +720,7 @@ class ProjectHook(SaveMixin, RESTObject): optionalCreateAttrs = ['push_events', 'issues_events', 'note_events', 'merge_requests_events', 'tag_push_events', 'build_events', 'enable_ssl_verification', 'token', - 'pipeline_events'] + 'pipeline_events', 'job_events', 'wiki_page_events'] _short_print_attr = 'url' From 197ffd70814ddf577655b3fdb7865f4416201353 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 11 Jun 2017 13:38:53 +0200 Subject: [PATCH 30/93] Drop invalid doc about raised exceptions --- gitlab/v4/objects.py | 61 ++------------------------------------------ 1 file changed, 2 insertions(+), 59 deletions(-) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index cf06b8e5f..fc05ec02a 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -395,10 +395,6 @@ def content(self, streamed=False, action=None, chunk_size=1024, **kwargs): Returns: str: The snippet content. - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the server fails to perform the request. """ path = '/snippets/%s/raw' % self.get_id() result = self.manager.gitlab.http_get(path, streamed=streamed, @@ -522,10 +518,6 @@ def erase(self, **kwargs): def keep_artifacts(self, **kwargs): """Prevent artifacts from being delete when expiration is set. - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabCreateError: If the request failed. """ path = '%s/%s/artifacts/keep' % (self.manager.path, self.get_id()) self.manager.gitlab.http_post(path) @@ -544,10 +536,6 @@ def artifacts(self, streamed=False, action=None, chunk_size=1024, Returns: str: The artifacts if `streamed` is False, None otherwise. - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the artifacts are not available. """ path = '%s/%s/artifacts' % (self.manager.path, self.get_id()) result = self.manager.gitlab.get_http(path, streamed=streamed, @@ -567,10 +555,6 @@ def trace(self, streamed=False, action=None, chunk_size=1024, **kwargs): Returns: str: The trace. - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the trace is not available. """ path = '%s/%s/trace' % (self.manager.path, self.get_id()) result = self.manager.gitlab.get_http(path, streamed=streamed, @@ -643,9 +627,6 @@ def cherry_pick(self, branch, **kwargs): Args: branch (str): Name of target branch. - - Raises: - GitlabCherryPickError: If the cherry pick could not be applied. """ path = '%s/%s/cherry_pick' % (self.manager.path, self.get_id()) post_data = {'branch': branch} @@ -764,11 +745,7 @@ class ProjectIssue(SubscribableMixin, TodoMixin, TimeTrackingMixin, SaveMixin, _managers = (('notes', 'ProjectIssueNoteManager'), ) def move(self, to_project_id, **kwargs): - """Move the issue to another project. - - Raises: - GitlabConnectionError: If the server cannot be reached. - """ + """Move the issue to another project.""" path = '%s/%s/move' % (self.manager.path, self.get_id()) data = {'to_project_id': to_project_id} server_data = self.manager.gitlab.http_post(path, post_data=data, @@ -840,11 +817,6 @@ def set_release_description(self, description, **kwargs): Args: description (str): Description of the release. - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabCreateError: If the server fails to create the release. - GitlabUpdateError: If the server fails to update the release. """ path = '%s/%s/release' % (self.manager.path, self.get_id()) data = {'description': description} @@ -1099,9 +1071,6 @@ def get(self, file_path, **kwargs): Returns: object: The generated RESTObject. - - Raises: - GitlabGetError: If the server cannot perform the request. """ file_path = file_path.replace('/', '%2F') return GetMixin.get(self, file_path, **kwargs) @@ -1122,10 +1091,6 @@ def raw(self, file_path, ref, streamed=False, action=None, chunk_size=1024, Returns: str: The file content - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the server fails to perform the request. """ file_path = file_path.replace('/', '%2F') path = '%s/%s/raw' % (self.path, file_path) @@ -1200,10 +1165,6 @@ def content(self, streamed=False, action=None, chunk_size=1024, **kwargs): Returns: str: The snippet content - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the server fails to perform the request. """ path = "%s/%s/raw" % (self.manager.path, self.get_id()) result = self.manager.gitlab.http_get(path, streamed=streamed, @@ -1222,12 +1183,7 @@ class ProjectSnippetManager(CRUDMixin, RESTManager): class ProjectTrigger(SaveMixin, RESTObject): def take_ownership(self, **kwargs): - """Update the owner of a trigger. - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the server fails to perform the request. - """ + """Update the owner of a trigger.""" 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) @@ -1611,10 +1567,6 @@ def all(self, scope=None, **kwargs): Returns: list(Runner): a list of runners matching the scope. - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabListError: If the resource cannot be found """ path = '/runners/all' query_data = {} @@ -1645,10 +1597,6 @@ def mark_all_as_done(self, **kwargs): Returns: The number of todos maked done. - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabDeleteError: If the resource cannot be found """ self.gitlab.http_post('/todos/mark_as_done', **kwargs) @@ -1708,11 +1656,6 @@ def transfer_project(self, id, **kwargs): Attrs: id (int): ID of the project to transfer. - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabTransferProjectError: If the server fails to perform the - request. """ path = '/groups/%d/projects/%d' % (self.id, id) self.manager.gitlab.http_post(path, **kwargs) From 61fba8431d0471128772429b9a8921d8092fa71b Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 11 Jun 2017 13:41:14 +0200 Subject: [PATCH 31/93] Add laziness to get() The goal is to create empty objects (no API called) but give access to the managers. Using this users can reduce the number of API calls but still use the same API to access children objects. For example the following will only make one API call but will still get the result right: gl.projects.get(49, lazy=True).issues.get(2, lazy=True).notes.list() This removes the need for more complex managers attributes (e.g. gl.project_issue_notes) --- gitlab/mixins.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/gitlab/mixins.py b/gitlab/mixins.py index 670f33d10..9a84021b9 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -21,11 +21,14 @@ class GetMixin(object): - def get(self, id, **kwargs): + def get(self, id, lazy=False, **kwargs): """Retrieve a single object. Args: id (int or str): ID of the object to retrieve + lazy (bool): If True, don't request the server, but create a + shallow object giving access to the managers. This is + useful if you want to avoid useless calls to the API. **kwargs: Extra data to send to the Gitlab server (e.g. sudo) Returns: @@ -35,6 +38,9 @@ def get(self, id, **kwargs): GitlabGetError: If the server cannot perform the request. """ path = '%s/%s' % (self.path, id) + if lazy is True: + return self._obj_cls(self, {self._obj_cls._id_attr: id}) + server_data = self.gitlab.http_get(path, **kwargs) return self._obj_cls(self, server_data) From 76e9b1211fd23a3565ab00be0b169d782a14dca7 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 11 Jun 2017 16:27:05 +0200 Subject: [PATCH 32/93] 0.10 is old history: remove the upgrade doc --- docs/index.rst | 1 - docs/upgrade-from-0.10.rst | 125 ------------------------------------- 2 files changed, 126 deletions(-) delete mode 100644 docs/upgrade-from-0.10.rst diff --git a/docs/index.rst b/docs/index.rst index 219802589..9b3be2bd8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,7 +15,6 @@ Contents: cli api-usage api-objects - upgrade-from-0.10 api/modules release_notes changelog diff --git a/docs/upgrade-from-0.10.rst b/docs/upgrade-from-0.10.rst deleted file mode 100644 index 7ff80ab38..000000000 --- a/docs/upgrade-from-0.10.rst +++ /dev/null @@ -1,125 +0,0 @@ -############################################# -Upgrading from python-gitlab 0.10 and earlier -############################################# - -``python-gitlab`` 0.11 introduces new objects which make the API cleaner and -easier to use. The feature set is unchanged but some methods have been -deprecated in favor of the new manager objects. - -Deprecated methods will be remove in a future release. - -Gitlab object migration -======================= - -The objects constructor methods are deprecated: - -* ``Hook()`` -* ``Project()`` -* ``UserProject()`` -* ``Group()`` -* ``Issue()`` -* ``User()`` -* ``Team()`` - -Use the new managers objects instead. For example: - -.. code-block:: python - - # Deprecated syntax - p1 = gl.Project({'name': 'myCoolProject'}) - p1.save() - p2 = gl.Project(id=1) - p_list = gl.Project() - - # New syntax - p1 = gl.projects.create({'name': 'myCoolProject'}) - p2 = gl.projects.get(1) - p_list = gl.projects.list() - -The following methods are also deprecated: - -* ``search_projects()`` -* ``owned_projects()`` -* ``all_projects()`` - -Use the ``projects`` manager instead: - -.. code-block:: python - - # Deprecated syntax - l1 = gl.search_projects('whatever') - l2 = gl.owned_projects() - l3 = gl.all_projects() - - # New syntax - l1 = gl.projects.search('whatever') - l2 = gl.projects.owned() - l3 = gl.projects.all() - -GitlabObject objects migration -============================== - -The following constructor methods are deprecated in favor of the matching -managers: - -.. list-table:: - :header-rows: 1 - - * - Deprecated method - - Matching manager - * - ``User.Key()`` - - ``User.keys`` - * - ``CurrentUser.Key()`` - - ``CurrentUser.keys`` - * - ``Group.Member()`` - - ``Group.members`` - * - ``ProjectIssue.Note()`` - - ``ProjectIssue.notes`` - * - ``ProjectMergeRequest.Note()`` - - ``ProjectMergeRequest.notes`` - * - ``ProjectSnippet.Note()`` - - ``ProjectSnippet.notes`` - * - ``Project.Branch()`` - - ``Project.branches`` - * - ``Project.Commit()`` - - ``Project.commits`` - * - ``Project.Event()`` - - ``Project.events`` - * - ``Project.File()`` - - ``Project.files`` - * - ``Project.Hook()`` - - ``Project.hooks`` - * - ``Project.Key()`` - - ``Project.keys`` - * - ``Project.Issue()`` - - ``Project.issues`` - * - ``Project.Label()`` - - ``Project.labels`` - * - ``Project.Member()`` - - ``Project.members`` - * - ``Project.MergeRequest()`` - - ``Project.mergerequests`` - * - ``Project.Milestone()`` - - ``Project.milestones`` - * - ``Project.Note()`` - - ``Project.notes`` - * - ``Project.Snippet()`` - - ``Project.snippets`` - * - ``Project.Tag()`` - - ``Project.tags`` - * - ``Team.Member()`` - - ``Team.members`` - * - ``Team.Project()`` - - ``Team.projects`` - -For example: - -.. code-block:: python - - # Deprecated syntax - p = gl.Project(id=2) - issues = p.Issue() - - # New syntax - p = gl.projects.get(2) - issues = p.issues.list() From 186e11a2135ae7df759641982fd42b3bc1bb944d Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 11 Jun 2017 17:31:47 +0200 Subject: [PATCH 33/93] Document switching to v4 --- docs/index.rst | 1 + docs/switching-to-v4.rst | 125 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 docs/switching-to-v4.rst diff --git a/docs/index.rst b/docs/index.rst index 9b3be2bd8..ebfc8fe23 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,6 +14,7 @@ Contents: install cli api-usage + switching-to-v4 api-objects api/modules release_notes diff --git a/docs/switching-to-v4.rst b/docs/switching-to-v4.rst new file mode 100644 index 000000000..fcec8a8ce --- /dev/null +++ b/docs/switching-to-v4.rst @@ -0,0 +1,125 @@ +########################## +Switching to GtiLab API v4 +########################## + +GitLab provides a new API version (v4) since its 9.0 release. ``python-gitlab`` +provides support for this new version, but the python API has been modified to +solve some problems with the existing one. + +GitLab will stop supporting the v3 API soon, and you should consider switching +to v4 if you use a recent version of GitLab (>= 9.0), or if you use +http://gitlab.com. + +The new v4 API is available in the `rework_api branch on github +`_, and will be +released soon. + + +Using the v4 API +================ + +To use the new v4 API, explicitly use it in the ``Gitlab`` constructor: + +.. code-block:: python + + gl = gitlab.Gitlab(..., api_version=4) + + +If you use the configuration file, also explicitly define the version: + +.. code-block:: ini + + [my_gitlab] + ... + api_version = 4 + + +Changes between v3 and v4 API +============================= + +For a list of GtiLab (upstream) API changes, see +https://docs.gitlab.com/ce/api/v3_to_v4.html. + +The ``python-gitlab`` API reflects these changes. But also consider the +following important changes in the python API: + +* managers and objects don't inherit from ``GitlabObject`` and ``BaseManager`` + anymore. They inherit from :class:`~gitlab.base.RESTManager` and + :class:`~gitlab.base.RESTObject`. + +* You should only use the managers to perform CRUD operations. + + The following v3 code: + + .. code-block:: python + + gl = gitlab.Gitlab(...) + p = Project(gl, project_id) + + Should be replaced with: + + .. code-block:: python + + gl = gitlab.Gitlab(...) + p = gl.projects.get(project_id) + +* Listing methods (``manager.list()`` for instance) now return generators + (:class:`~gitlab.base.RESTObjectList`). They handle the calls to the API when + needed. + + If you need to get all the items at once, use the ``all=True`` parameter: + + .. code-block:: python + + all_projects = gl.projects.list(all=True) + +* The "nested" managers (for instance ``gl.project_issues`` or + ``gl.group_members``) are not available anymore. Their goal was to provide a + direct way to manage nested objects, and to limit the number of needed API + calls. + + To limit the number of API calls, you can now use ``get()`` methods with the + ``lazy=True`` parameter. This creates shallow objects that provide usual + managers. + + The following v3 code: + + .. code-block:: python + + issues = gl.project_issues.list(project_id=project_id) + + Should be replaced with: + + .. code-block:: python + + issues = gl.projects.get(project_id, lazy=True).issues.list() + + This will make only one API call, instead of two if ``lazy`` is not used. + +* The :class:`~gitlab.Gitlab` folowwing methods should not be used anymore for + v4: + + + ``list()`` + + ``get()`` + + ``create()`` + + ``update()`` + + ``delete()`` + +* If you need to perform HTTP requests to the GitLab server (which you + shouldn't), you can use the following :class:`~gitlab.Gitlab` methods: + + + :attr:`~gitlab.Gitlab.http_request` + + :attr:`~gitlab.Gitlab.http_get` + + :attr:`~gitlab.Gitlab.http_list` + + :attr:`~gitlab.Gitlab.http_post` + + :attr:`~gitlab.Gitlab.http_put` + + :attr:`~gitlab.Gitlab.http_delete` + + +Undergoing work +=============== + +* The ``delete()`` method for objects is not yet available. For now you need to + use ``manager.delete(obj.id)``. +* The ``page`` and ``per_page`` arguments for listing don't behave as they used + to. Their behavior will be restored. From 26c0441a875c566685bb55a12825ae622a002e2a Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 11 Jun 2017 18:12:10 +0200 Subject: [PATCH 34/93] pep8 fixes --- gitlab/base.py | 2 +- gitlab/v4/objects.py | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/gitlab/base.py b/gitlab/base.py index d72a93395..ec7091b0d 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -568,7 +568,7 @@ def __getattr__(self, name): except KeyError: try: return self.__dict__['_parent_attrs'][name] - except: + except KeyError: raise AttributeError(name) def __setattr__(self, name, value): diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index fc05ec02a..403c105ae 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -517,8 +517,7 @@ def erase(self, **kwargs): self.manager.gitlab.http_post(path) def keep_artifacts(self, **kwargs): - """Prevent artifacts from being delete when expiration is set. - """ + """Prevent artifacts from being delete when expiration is set.""" path = '%s/%s/artifacts/keep' % (self.manager.path, self.get_id()) self.manager.gitlab.http_post(path) @@ -1412,7 +1411,8 @@ def repository_raw_blob(self, sha, streamed=False, action=None, str: The blob content """ path = '/projects/%s/repository/blobs/%s/raw' % (self.get_id(), sha) - result = self.manager.gitlab.http_get(path, streamed=streamed, **kwargs) + result = self.manager.gitlab.http_get(path, streamed=streamed, + **kwargs) return utils.response_content(result, streamed, action, chunk_size) def repository_compare(self, from_, to, **kwargs): @@ -1473,8 +1473,7 @@ def create_fork_relation(self, forked_from_id, **kwargs): self.manager.gitlab.http_post(path, **kwargs) def delete_fork_relation(self, **kwargs): - """Delete a forked relation between existing projects. - """ + """Delete a forked relation between existing projects.""" path = '/projects/%s/fork' % self.get_id() self.manager.gitlab.http_delete(path, **kwargs) From 32c704c7737f0699e1c6979c6b4a8798ae41e930 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 11 Jun 2017 18:58:56 +0200 Subject: [PATCH 35/93] add support for objects delete() --- gitlab/mixins.py | 11 +++++++ gitlab/v4/objects.py | 74 ++++++++++++++++++++++---------------------- 2 files changed, 48 insertions(+), 37 deletions(-) diff --git a/gitlab/mixins.py b/gitlab/mixins.py index 9a84021b9..6b5475cfe 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -250,6 +250,17 @@ def save(self, **kwargs): self._update_attrs(server_data) +class ObjectDeleteMixin(object): + """Mixin for RESTObject's that can be deleted.""" + def delete(self, **kwargs): + """Delete the object from the server. + + Args: + **kwargs: Extra option to send to the server (e.g. sudo) + """ + self.manager.delete(self.get_id()) + + class AccessRequestMixin(object): def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs): """Approve an access request. diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 403c105ae..b27636812 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -62,7 +62,7 @@ def compound_metrics(self, **kwargs): return self.gitlab.http_get('/sidekiq/compound_metrics', **kwargs) -class UserEmail(RESTObject): +class UserEmail(ObjectDeleteMixin, RESTObject): _short_print_attr = 'email' @@ -73,7 +73,7 @@ class UserEmailManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): _create_attrs = (('email', ), tuple()) -class UserKey(RESTObject): +class UserKey(ObjectDeleteMixin, RESTObject): pass @@ -101,7 +101,7 @@ class UserProjectManager(CreateMixin, RESTManager): ) -class User(SaveMixin, RESTObject): +class User(SaveMixin, ObjectDeleteMixin, RESTObject): _short_print_attr = 'username' _managers = ( ('emails', 'UserEmailManager'), @@ -162,7 +162,7 @@ def _sanitize_data(self, data, action): return new_data -class CurrentUserEmail(RESTObject): +class CurrentUserEmail(ObjectDeleteMixin, RESTObject): _short_print_attr = 'email' @@ -173,7 +173,7 @@ class CurrentUserEmailManager(RetrieveMixin, CreateMixin, DeleteMixin, _create_attrs = (('email', ), tuple()) -class CurrentUserKey(RESTObject): +class CurrentUserKey(ObjectDeleteMixin, RESTObject): _short_print_attr = 'title' @@ -231,7 +231,7 @@ def _sanitize_data(self, data, action): return new_data -class BroadcastMessage(SaveMixin, RESTObject): +class BroadcastMessage(SaveMixin, ObjectDeleteMixin, RESTObject): pass @@ -308,12 +308,12 @@ class GroupIssueManager(GetFromListMixin, RESTManager): _list_filters = ('state', 'labels', 'milestone', 'order_by', 'sort') -class GroupMember(SaveMixin, RESTObject): +class GroupMember(SaveMixin, ObjectDeleteMixin, RESTObject): _short_print_attr = 'username' class GroupMemberManager(GetFromListMixin, CreateMixin, UpdateMixin, - RESTManager): + DeleteMixin, RESTManager): _path = '/groups/%(group_id)s/members' _obj_cls = GroupMember _from_parent_attrs = {'group_id': 'id'} @@ -331,7 +331,7 @@ class GroupNotificationSettingsManager(NotificationSettingsManager): _from_parent_attrs = {'group_id': 'id'} -class GroupAccessRequest(AccessRequestMixin, RESTObject): +class GroupAccessRequest(AccessRequestMixin, ObjectDeleteMixin, RESTObject): pass @@ -342,7 +342,7 @@ class GroupAccessRequestManager(GetFromListMixin, CreateMixin, DeleteMixin, _from_parent_attrs = {'group_id': 'id'} -class Hook(RESTObject): +class Hook(ObjectDeleteMixin, RESTObject): _url = '/hooks' _short_print_attr = 'url' @@ -378,7 +378,7 @@ class LicenseManager(RetrieveMixin, RESTManager): _optional_get_attrs = ('project', 'fullname') -class Snippet(SaveMixin, RESTObject): +class Snippet(SaveMixin, ObjectDeleteMixin, RESTObject): _constructor_types = {'author': 'User'} _short_print_attr = 'title' @@ -433,7 +433,7 @@ class NamespaceManager(GetFromListMixin, RESTManager): _list_filters = ('search', ) -class ProjectBoardList(SaveMixin, RESTObject): +class ProjectBoardList(SaveMixin, ObjectDeleteMixin, RESTObject): _constructor_types = {'label': 'ProjectLabel'} @@ -457,7 +457,7 @@ class ProjectBoardManager(GetFromListMixin, RESTManager): _from_parent_attrs = {'project_id': 'id'} -class ProjectBranch(RESTObject): +class ProjectBranch(ObjectDeleteMixin, RESTObject): _constructor_types = {'author': 'User', "committer": "User"} _id_attr = 'name' @@ -640,7 +640,7 @@ class ProjectCommitManager(RetrieveMixin, CreateMixin, RESTManager): ('author_email', 'author_name')) -class ProjectEnvironment(SaveMixin, RESTObject): +class ProjectEnvironment(SaveMixin, ObjectDeleteMixin, RESTObject): pass @@ -653,7 +653,7 @@ class ProjectEnvironmentManager(GetFromListMixin, CreateMixin, UpdateMixin, _update_attrs = (tuple(), ('name', 'external_url')) -class ProjectKey(RESTObject): +class ProjectKey(ObjectDeleteMixin, RESTObject): pass @@ -694,7 +694,7 @@ class ProjectForkManager(CreateMixin, RESTManager): _create_attrs = (tuple(), ('namespace', )) -class ProjectHook(SaveMixin, RESTObject): +class ProjectHook(SaveMixin, ObjectDeleteMixin, RESTObject): requiredUrlAttrs = ['project_id'] requiredCreateAttrs = ['url'] optionalCreateAttrs = ['push_events', 'issues_events', 'note_events', @@ -722,12 +722,11 @@ class ProjectHookManager(CRUDMixin, RESTManager): ) -class ProjectIssueNote(SaveMixin, RESTObject): +class ProjectIssueNote(SaveMixin, ObjectDeleteMixin, RESTObject): _constructor_types = {'author': 'User'} -class ProjectIssueNoteManager(RetrieveMixin, CreateMixin, UpdateMixin, - RESTManager): +class ProjectIssueNoteManager(CRUDMixin, RESTManager): _path = '/projects/%(project_id)s/issues/%(issue_iid)s/notes' _obj_cls = ProjectIssueNote _from_parent_attrs = {'project_id': 'project_id', 'issue_iid': 'iid'} @@ -736,7 +735,7 @@ class ProjectIssueNoteManager(RetrieveMixin, CreateMixin, UpdateMixin, class ProjectIssue(SubscribableMixin, TodoMixin, TimeTrackingMixin, SaveMixin, - RESTObject): + ObjectDeleteMixin, RESTObject): _constructor_types = {'author': 'User', 'assignee': 'User', 'milestone': 'ProjectMilestone'} _short_print_attr = 'title' @@ -765,7 +764,7 @@ class ProjectIssueManager(CRUDMixin, RESTManager): 'updated_at', 'state_event', 'due_date')) -class ProjectMember(SaveMixin, RESTObject): +class ProjectMember(SaveMixin, ObjectDeleteMixin, RESTObject): requiredCreateAttrs = ['access_level', 'user_id'] optionalCreateAttrs = ['expires_at'] requiredUpdateAttrs = ['access_level'] @@ -802,7 +801,7 @@ class ProjectNotificationSettingsManager(NotificationSettingsManager): _from_parent_attrs = {'project_id': 'id'} -class ProjectTag(RESTObject): +class ProjectTag(ObjectDeleteMixin, RESTObject): _constructor_types = {'release': 'ProjectTagRelease', 'commit': 'ProjectCommit'} _id_attr = 'name' @@ -846,7 +845,7 @@ class ProjectMergeRequestDiffManager(RetrieveMixin, RESTManager): _from_parent_attrs = {'project_id': 'project_id', 'mr_iid': 'iid'} -class ProjectMergeRequestNote(SaveMixin, RESTObject): +class ProjectMergeRequestNote(SaveMixin, ObjectDeleteMixin, RESTObject): _constructor_types = {'author': 'User'} @@ -859,7 +858,7 @@ class ProjectMergeRequestNoteManager(CRUDMixin, RESTManager): class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, - SaveMixin, RESTObject): + SaveMixin, ObjectDeleteMixin, RESTObject): _constructor_types = {'author': 'User', 'assignee': 'User'} _id_attr = 'iid' @@ -952,7 +951,7 @@ class ProjectMergeRequestManager(CRUDMixin, RESTManager): _list_filters = ('iids', 'state', 'order_by', 'sort') -class ProjectMilestone(SaveMixin, RESTObject): +class ProjectMilestone(SaveMixin, ObjectDeleteMixin, RESTObject): _short_print_attr = 'title' def issues(self, **kwargs): @@ -995,7 +994,8 @@ class ProjectMilestoneManager(RetrieveMixin, CreateMixin, DeleteMixin, _list_filters = ('iids', 'state') -class ProjectLabel(SubscribableMixin, SaveMixin, RESTObject): +class ProjectLabel(SubscribableMixin, SaveMixin, ObjectDeleteMixin, + RESTObject): _id_attr = 'name' requiredCreateAttrs = ['name', 'color'] optionalCreateAttrs = ['description', 'priority'] @@ -1004,7 +1004,7 @@ class ProjectLabel(SubscribableMixin, SaveMixin, RESTObject): class ProjectLabelManager(GetFromListMixin, CreateMixin, UpdateMixin, - RESTManager): + DeleteMixin, RESTManager): _path = '/projects/%(project_id)s/labels' _obj_cls = ProjectLabel _from_parent_attrs = {'project_id': 'id'} @@ -1038,7 +1038,7 @@ def save(self, **kwargs): self._update_attrs(server_data) -class ProjectFile(SaveMixin, RESTObject): +class ProjectFile(SaveMixin, ObjectDeleteMixin, RESTObject): _id_attr = 'file_path' _short_print_attr = 'file_path' @@ -1145,7 +1145,7 @@ class ProjectSnippetNoteManager(RetrieveMixin, CreateMixin, RESTManager): _create_attrs = (('body', ), tuple()) -class ProjectSnippet(SaveMixin, RESTObject): +class ProjectSnippet(SaveMixin, ObjectDeleteMixin, RESTObject): _url = '/projects/%(project_id)s/snippets' _constructor_types = {'author': 'User'} _short_print_attr = 'title' @@ -1180,7 +1180,7 @@ class ProjectSnippetManager(CRUDMixin, RESTManager): _update_attrs = (tuple(), ('title', 'file_name', 'code', 'visibility')) -class ProjectTrigger(SaveMixin, RESTObject): +class ProjectTrigger(SaveMixin, ObjectDeleteMixin, RESTObject): def take_ownership(self, **kwargs): """Update the owner of a trigger.""" path = '%s/%s/take_ownership' % (self.manager.path, self.get_id()) @@ -1196,7 +1196,7 @@ class ProjectTriggerManager(CRUDMixin, RESTManager): _update_attrs = (('description', ), tuple()) -class ProjectVariable(SaveMixin, RESTObject): +class ProjectVariable(SaveMixin, ObjectDeleteMixin, RESTObject): _id_attr = 'key' @@ -1297,7 +1297,7 @@ def available(self, **kwargs): return list(ProjectService._service_attrs.keys()) -class ProjectAccessRequest(AccessRequestMixin, RESTObject): +class ProjectAccessRequest(AccessRequestMixin, ObjectDeleteMixin, RESTObject): pass @@ -1318,7 +1318,7 @@ class ProjectDeploymentManager(RetrieveMixin, RESTManager): _from_parent_attrs = {'project_id': 'id'} -class ProjectRunner(RESTObject): +class ProjectRunner(ObjectDeleteMixin, RESTObject): canUpdate = False requiredCreateAttrs = ['runner_id'] @@ -1330,7 +1330,7 @@ class ProjectRunnerManager(NoUpdateMixin, RESTManager): _create_attrs = (('runner_id', ), tuple()) -class Project(SaveMixin, RESTObject): +class Project(SaveMixin, ObjectDeleteMixin, RESTObject): _constructor_types = {'owner': 'User', 'namespace': 'Group'} _short_print_attr = 'path' _managers = ( @@ -1547,7 +1547,7 @@ def trigger_pipeline(self, ref, token, variables={}, **kwargs): self.manager.gitlab.http_post(path, post_data=post_data, **kwargs) -class Runner(SaveMixin, RESTObject): +class Runner(SaveMixin, ObjectDeleteMixin, RESTObject): pass @@ -1574,7 +1574,7 @@ def all(self, scope=None, **kwargs): return self.gitlab.http_list(path, query_data, **kwargs) -class Todo(RESTObject): +class Todo(ObjectDeleteMixin, RESTObject): def mark_as_done(self, **kwargs): """Mark the todo as done. @@ -1640,7 +1640,7 @@ class GroupProjectManager(GetFromListMixin, RESTManager): 'ci_enabled_first') -class Group(SaveMixin, RESTObject): +class Group(SaveMixin, ObjectDeleteMixin, RESTObject): _short_print_attr = 'name' _managers = ( ('accessrequests', 'GroupAccessRequestManager'), From 2a0afc50311c727ee3bef700553fb60924439ef4 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 11 Jun 2017 19:19:22 +0200 Subject: [PATCH 36/93] Remove unused future.division import We don't do math. --- gitlab/__init__.py | 1 - gitlab/cli.py | 1 - gitlab/tests/test_gitlabobject.py | 1 - gitlab/v3/objects.py | 1 - gitlab/v4/objects.py | 1 - 5 files changed, 5 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index e6a151a87..d5aa92d9c 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -17,7 +17,6 @@ """Wrapper for the GitLab API.""" from __future__ import print_function -from __future__ import division from __future__ import absolute_import import importlib import inspect diff --git a/gitlab/cli.py b/gitlab/cli.py index 8cc89c2c6..142ccfa4d 100644 --- a/gitlab/cli.py +++ b/gitlab/cli.py @@ -17,7 +17,6 @@ # along with this program. If not, see . from __future__ import print_function -from __future__ import division from __future__ import absolute_import import argparse import inspect diff --git a/gitlab/tests/test_gitlabobject.py b/gitlab/tests/test_gitlabobject.py index 3bffb825d..695f900d8 100644 --- a/gitlab/tests/test_gitlabobject.py +++ b/gitlab/tests/test_gitlabobject.py @@ -18,7 +18,6 @@ # along with this program. If not, see . from __future__ import print_function -from __future__ import division from __future__ import absolute_import import json diff --git a/gitlab/v3/objects.py b/gitlab/v3/objects.py index 84b9cb558..68c2858e8 100644 --- a/gitlab/v3/objects.py +++ b/gitlab/v3/objects.py @@ -16,7 +16,6 @@ # along with this program. If not, see . from __future__ import print_function -from __future__ import division from __future__ import absolute_import import base64 import json diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index b27636812..f3d1dce98 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -16,7 +16,6 @@ # along with this program. If not, see . from __future__ import print_function -from __future__ import division from __future__ import absolute_import import base64 import json From d41e9728c0f583e031313419bcf998bfdfb8688a Mon Sep 17 00:00:00 2001 From: Eli Sarver Date: Fri, 16 Jun 2017 16:21:50 -0400 Subject: [PATCH 37/93] Missing expires_at in GroupMembers update CreateAttrs was set twice in GroupMember due to possible copy-paste error. --- gitlab/v4/objects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index d1d589e71..01c453f86 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -362,7 +362,7 @@ class GroupMember(GitlabObject): requiredCreateAttrs = ['access_level', 'user_id'] optionalCreateAttrs = ['expires_at'] requiredUpdateAttrs = ['access_level'] - optionalCreateAttrs = ['expires_at'] + optionalUpdateAttrs = ['expires_at'] shortPrintAttr = 'username' def _update(self, **kwargs): From 1a7f67274c9175f46a76c5ae0d8bde7ca2731014 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 18 Jun 2017 12:16:08 +0200 Subject: [PATCH 38/93] Rework documentation --- docs/api/gitlab.rst | 78 ++++++++++++++++++++++++++---------------- docs/api/modules.rst | 7 ---- docs/ext/docstrings.py | 14 ++++++-- docs/index.rst | 2 +- gitlab/v4/objects.py | 4 --- 5 files changed, 61 insertions(+), 44 deletions(-) delete mode 100644 docs/api/modules.rst diff --git a/docs/api/gitlab.rst b/docs/api/gitlab.rst index d34d56fc6..e75f84349 100644 --- a/docs/api/gitlab.rst +++ b/docs/api/gitlab.rst @@ -1,55 +1,48 @@ gitlab package ============== -Module contents ---------------- +Subpackages +----------- -.. automodule:: gitlab +.. toctree:: + + gitlab.v3 + gitlab.v4 + +Submodules +---------- + +gitlab.base module +------------------ + +.. automodule:: gitlab.base :members: :undoc-members: :show-inheritance: - :exclude-members: Hook, UserProject, Group, Issue, Team, User, - all_projects, owned_projects, search_projects -gitlab.base ------------ +gitlab.cli module +----------------- -.. automodule:: gitlab.base +.. automodule:: gitlab.cli :members: :undoc-members: :show-inheritance: -gitlab.v3.objects module ------------------------- +gitlab.config module +-------------------- -.. automodule:: gitlab.v3.objects +.. automodule:: gitlab.config :members: :undoc-members: :show-inheritance: - :exclude-members: Branch, Commit, Content, Event, File, Hook, Issue, Key, - Label, Member, MergeRequest, Milestone, Note, Snippet, - Tag, canGet, canList, canUpdate, canCreate, canDelete, - requiredUrlAttrs, requiredListAttrs, optionalListAttrs, - optionalGetAttrs, requiredGetAttrs, requiredDeleteAttrs, - requiredCreateAttrs, optionalCreateAttrs, - requiredUpdateAttrs, optionalUpdateAttrs, getRequiresId, - shortPrintAttr, idAttr -gitlab.v4.objects module ------------------------- +gitlab.const module +------------------- -.. automodule:: gitlab.v4.objects +.. automodule:: gitlab.const :members: :undoc-members: :show-inheritance: - :exclude-members: Branch, Commit, Content, Event, File, Hook, Issue, Key, - Label, Member, MergeRequest, Milestone, Note, Snippet, - Tag, canGet, canList, canUpdate, canCreate, canDelete, - requiredUrlAttrs, requiredListAttrs, optionalListAttrs, - optionalGetAttrs, requiredGetAttrs, requiredDeleteAttrs, - requiredCreateAttrs, optionalCreateAttrs, - requiredUpdateAttrs, optionalUpdateAttrs, getRequiresId, - shortPrintAttr, idAttr gitlab.exceptions module ------------------------ @@ -58,3 +51,28 @@ gitlab.exceptions module :members: :undoc-members: :show-inheritance: + +gitlab.mixins module +-------------------- + +.. automodule:: gitlab.mixins + :members: + :undoc-members: + :show-inheritance: + +gitlab.utils module +------------------- + +.. automodule:: gitlab.utils + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: gitlab + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/modules.rst b/docs/api/modules.rst deleted file mode 100644 index 3ec5a68fe..000000000 --- a/docs/api/modules.rst +++ /dev/null @@ -1,7 +0,0 @@ -API documentation -================= - -.. toctree:: - :maxdepth: 4 - - gitlab diff --git a/docs/ext/docstrings.py b/docs/ext/docstrings.py index fc95eeb76..32c5da1e7 100644 --- a/docs/ext/docstrings.py +++ b/docs/ext/docstrings.py @@ -10,6 +10,8 @@ def classref(value, short=True): + return value + if not inspect.isclass(value): return ':class:%s' % value tilde = '~' if short else '' @@ -46,8 +48,13 @@ def _build_doc(self, tmpl, **kwargs): return output.split('\n') - def __init__(self, *args, **kwargs): - super(GitlabDocstring, self).__init__(*args, **kwargs) + def __init__(self, docstring, config=None, app=None, what='', name='', + obj=None, options=None): + super(GitlabDocstring, self).__init__(docstring, config, app, what, + name, obj, options) + + if name and name.startswith('gitlab.v4.objects'): + return if getattr(self._obj, '__name__', None) == 'Gitlab': mgrs = [] @@ -57,9 +64,12 @@ def __init__(self, *args, **kwargs): mgrs.append(item) self._parsed_lines.extend(self._build_doc('gl_tmpl.j2', mgrs=sorted(mgrs))) + + # BaseManager elif hasattr(self._obj, 'obj_cls') and self._obj.obj_cls is not None: self._parsed_lines.extend(self._build_doc('manager_tmpl.j2', cls=self._obj.obj_cls)) + # GitlabObject elif hasattr(self._obj, 'canUpdate') and self._obj.canUpdate: self._parsed_lines.extend(self._build_doc('object_tmpl.j2', obj=self._obj)) diff --git a/docs/index.rst b/docs/index.rst index ebfc8fe23..7805fcfde 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,7 +16,7 @@ Contents: api-usage switching-to-v4 api-objects - api/modules + api/gitlab release_notes changelog diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index f3d1dce98..2370de08e 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -996,10 +996,6 @@ class ProjectMilestoneManager(RetrieveMixin, CreateMixin, DeleteMixin, class ProjectLabel(SubscribableMixin, SaveMixin, ObjectDeleteMixin, RESTObject): _id_attr = 'name' - requiredCreateAttrs = ['name', 'color'] - optionalCreateAttrs = ['description', 'priority'] - requiredUpdateAttrs = ['name'] - optionalUpdateAttrs = ['new_name', 'color', 'description', 'priority'] class ProjectLabelManager(GetFromListMixin, CreateMixin, UpdateMixin, From 1922cd5d9b182902586170927acb758f8a6f614c Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 18 Jun 2017 12:17:09 +0200 Subject: [PATCH 39/93] Fix changelog and release notes inclusion in sdist --- MANIFEST.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index e677be789..3cc3cdcc3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include COPYING AUTHORS ChangeLog requirements.txt test-requirements.txt rtd-requirements.txt +include COPYING AUTHORS ChangeLog.rst RELEASE_NOTES.rst requirements.txt test-requirements.txt rtd-requirements.txt include tox.ini .testr.conf .travis.yml recursive-include tools * recursive-include docs *j2 *.py *.rst api/*.rst Makefile make.bat From 6e5a6ec1f7c2993697c359b2bcab0e1324e219bc Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 18 Jun 2017 13:11:12 +0200 Subject: [PATCH 40/93] minor doc updates --- gitlab/base.py | 11 +++++++++++ gitlab/v4/objects.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/gitlab/base.py b/gitlab/base.py index ec7091b0d..df25a368a 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -602,6 +602,7 @@ def _update_attrs(self, new_attrs): self.__dict__['_attrs'].update(new_attrs) def get_id(self): + """Returns the id of the resource.""" if self._id_attr is None: return None return getattr(self, self._id_attr) @@ -622,6 +623,16 @@ class RESTObjectList(object): _list: A GitlabList object """ def __init__(self, manager, obj_cls, _list): + """Creates an objects list from a GitlabList. + + You should not create objects of this type, but use managers list() + methods instead. + + Args: + manager: the RESTManager to attach to the objects + obj_cls: the class of the created objects + _list: the GitlabList holding the data + """ self.manager = manager self._obj_cls = obj_cls self._list = _list diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 2370de08e..87a197f0e 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -1648,7 +1648,7 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject): def transfer_project(self, id, **kwargs): """Transfers a project to this group. - Attrs: + Args: id (int): ID of the project to transfer. """ path = '/groups/%d/projects/%d' % (self.id, id) From afe4b05de9833d450b9bb52f572be5663d8f4dd7 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sat, 24 Jun 2017 09:49:34 +0200 Subject: [PATCH 41/93] Fix GroupProject constructor --- gitlab/v4/objects.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 87a197f0e..37e818ff3 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -1622,9 +1622,8 @@ class ProjectManager(CRUDMixin, RESTManager): 'order_by', 'sort', 'simple', 'membership', 'statistics') -class GroupProject(RESTObject): - def __init__(self, *args, **kwargs): - Project.__init__(self, *args, **kwargs) +class GroupProject(Project): + pass class GroupProjectManager(GetFromListMixin, RESTManager): From ea79bdc287429791e70f2e855d70cbbbe463dd3c Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sat, 24 Jun 2017 09:49:46 +0200 Subject: [PATCH 42/93] build submanagers for v3 only --- gitlab/__init__.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index d5aa92d9c..3f61c5fd5 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -118,21 +118,22 @@ def __init__(self, url, private_token=None, email=None, password=None, else: self.dockerfiles = objects.DockerfileManager(self) - # build the "submanagers" - for parent_cls in six.itervalues(vars(objects)): - if (not inspect.isclass(parent_cls) - or not issubclass(parent_cls, objects.GitlabObject) - or parent_cls == objects.CurrentUser): - continue - - if not parent_cls.managers: - continue - - for var, cls_name, attrs in parent_cls.managers: - var_name = '%s_%s' % (self._cls_to_manager_prefix(parent_cls), - var) - manager = getattr(objects, cls_name)(self) - setattr(self, var_name, manager) + if self._api_version == '3': + # build the "submanagers" + for parent_cls in six.itervalues(vars(objects)): + if (not inspect.isclass(parent_cls) + or not issubclass(parent_cls, objects.GitlabObject) + or parent_cls == objects.CurrentUser): + continue + + if not parent_cls.managers: + continue + + for var, cls_name, attrs in parent_cls.managers: + prefix = self._cls_to_manager_prefix(parent_cls) + var_name = '%s_%s' % (perfix, var) + manager = getattr(objects, cls_name)(self) + setattr(self, var_name, manager) @property def api_version(self): From 67be226cb3f5e00aef35aacfd08c63de0389a5d7 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sat, 24 Jun 2017 17:07:38 +0200 Subject: [PATCH 43/93] typo --- gitlab/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 3f61c5fd5..4dd7e293a 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -131,7 +131,7 @@ def __init__(self, url, private_token=None, email=None, password=None, for var, cls_name, attrs in parent_cls.managers: prefix = self._cls_to_manager_prefix(parent_cls) - var_name = '%s_%s' % (perfix, var) + var_name = '%s_%s' % (prefix, var) manager = getattr(objects, cls_name)(self) setattr(self, var_name, manager) From fd5ac4d5eaed1a174ba8c086d0db3ee2001ab3b9 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 25 Jun 2017 08:37:09 +0200 Subject: [PATCH 44/93] Add missing doc files --- docs/api/gitlab.v3.rst | 22 ++++++++++++++++++++++ docs/api/gitlab.v4.rst | 22 ++++++++++++++++++++++ gitlab/{ => v3}/cli.py | 0 3 files changed, 44 insertions(+) create mode 100644 docs/api/gitlab.v3.rst create mode 100644 docs/api/gitlab.v4.rst rename gitlab/{ => v3}/cli.py (100%) diff --git a/docs/api/gitlab.v3.rst b/docs/api/gitlab.v3.rst new file mode 100644 index 000000000..61879bc03 --- /dev/null +++ b/docs/api/gitlab.v3.rst @@ -0,0 +1,22 @@ +gitlab.v3 package +================= + +Submodules +---------- + +gitlab.v3.objects module +------------------------ + +.. automodule:: gitlab.v3.objects + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: gitlab.v3 + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/gitlab.v4.rst b/docs/api/gitlab.v4.rst new file mode 100644 index 000000000..70358c110 --- /dev/null +++ b/docs/api/gitlab.v4.rst @@ -0,0 +1,22 @@ +gitlab.v4 package +================= + +Submodules +---------- + +gitlab.v4.objects module +------------------------ + +.. automodule:: gitlab.v4.objects + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: gitlab.v4 + :members: + :undoc-members: + :show-inheritance: diff --git a/gitlab/cli.py b/gitlab/v3/cli.py similarity index 100% rename from gitlab/cli.py rename to gitlab/v3/cli.py From e3d50b5e768fd398eee4a099125b1f87618f7428 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 25 Jun 2017 08:37:52 +0200 Subject: [PATCH 45/93] Refactor the CLI v3 and v4 CLI will be very different, so start moving things in their own folders. For now v4 isn't working at all. --- gitlab/cli.py | 105 ++++++++++++++++ gitlab/tests/test_cli.py | 37 +++--- gitlab/v3/cli.py | 257 +++++++++++++++------------------------ 3 files changed, 222 insertions(+), 177 deletions(-) create mode 100644 gitlab/cli.py diff --git a/gitlab/cli.py b/gitlab/cli.py new file mode 100644 index 000000000..f23fff9d3 --- /dev/null +++ b/gitlab/cli.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2013-2017 Gauvain Pocentek +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +from __future__ import print_function +from __future__ import absolute_import +import argparse +import importlib +import re +import sys + +import gitlab.config + +camel_re = re.compile('(.)([A-Z])') + + +def die(msg, e=None): + if e: + msg = "%s (%s)" % (msg, e) + sys.stderr.write(msg + "\n") + sys.exit(1) + + +def what_to_cls(what): + return "".join([s.capitalize() for s in what.split("-")]) + + +def cls_to_what(cls): + return camel_re.sub(r'\1-\2', cls.__name__).lower() + + +def _get_base_parser(): + parser = argparse.ArgumentParser( + description="GitLab API Command Line Interface") + parser.add_argument("--version", help="Display the version.", + action="store_true") + parser.add_argument("-v", "--verbose", "--fancy", + help="Verbose mode", + action="store_true") + parser.add_argument("-c", "--config-file", action='append', + help=("Configuration file to use. Can be used " + "multiple times.")) + parser.add_argument("-g", "--gitlab", + help=("Which configuration section should " + "be used. If not defined, the default selection " + "will be used."), + required=False) + + return parser + + +def _get_parser(cli_module): + parser = _get_base_parser() + return cli_module.extend_parser(parser) + + +def main(): + if "--version" in sys.argv: + print(gitlab.__version__) + exit(0) + + parser = _get_base_parser() + (options, args) = parser.parse_known_args(sys.argv) + + config = gitlab.config.GitlabConfigParser(options.gitlab, + options.config_file) + cli_module = importlib.import_module('gitlab.v%s.cli' % config.api_version) + parser = _get_parser(cli_module) + args = parser.parse_args(sys.argv[1:]) + config_files = args.config_file + gitlab_id = args.gitlab + verbose = args.verbose + action = args.action + what = args.what + + args = args.__dict__ + # Remove CLI behavior-related args + for item in ('gitlab', 'config_file', 'verbose', 'what', 'action', + 'version'): + args.pop(item) + args = {k: v for k, v in args.items() if v is not None} + + try: + gl = gitlab.Gitlab.from_config(gitlab_id, config_files) + gl.auth() + except Exception as e: + die(str(e)) + + cli_module.run(gl, what, action, args, verbose) + + sys.exit(0) diff --git a/gitlab/tests/test_cli.py b/gitlab/tests/test_cli.py index 701655d25..e6e290a4a 100644 --- a/gitlab/tests/test_cli.py +++ b/gitlab/tests/test_cli.py @@ -28,12 +28,13 @@ import unittest2 as unittest from gitlab import cli +import gitlab.v3.cli class TestCLI(unittest.TestCase): def test_what_to_cls(self): - self.assertEqual("Foo", cli._what_to_cls("foo")) - self.assertEqual("FooBar", cli._what_to_cls("foo-bar")) + self.assertEqual("Foo", cli.what_to_cls("foo")) + self.assertEqual("FooBar", cli.what_to_cls("foo-bar")) def test_cls_to_what(self): class Class(object): @@ -42,32 +43,33 @@ class Class(object): class TestClass(object): pass - self.assertEqual("test-class", cli._cls_to_what(TestClass)) - self.assertEqual("class", cli._cls_to_what(Class)) + self.assertEqual("test-class", cli.cls_to_what(TestClass)) + self.assertEqual("class", cli.cls_to_what(Class)) def test_die(self): with self.assertRaises(SystemExit) as test: - cli._die("foobar") + cli.die("foobar") self.assertEqual(test.exception.code, 1) - def test_extra_actions(self): - for cls, data in six.iteritems(cli.EXTRA_ACTIONS): - for key in data: - self.assertIsInstance(data[key], dict) - - def test_parsing(self): - args = cli._parse_args(['-v', '-g', 'gl_id', - '-c', 'foo.cfg', '-c', 'bar.cfg', - 'project', 'list']) + def test_base_parser(self): + parser = cli._get_base_parser() + args = parser.parse_args(['-v', '-g', 'gl_id', + '-c', 'foo.cfg', '-c', 'bar.cfg']) self.assertTrue(args.verbose) self.assertEqual(args.gitlab, 'gl_id') self.assertEqual(args.config_file, ['foo.cfg', 'bar.cfg']) + + +class TestV3CLI(unittest.TestCase): + def test_parse_args(self): + parser = cli._get_parser(gitlab.v3.cli) + args = parser.parse_args(['project', 'list']) self.assertEqual(args.what, 'project') self.assertEqual(args.action, 'list') def test_parser(self): - parser = cli._build_parser() + parser = cli._get_parser(gitlab.v3.cli) subparsers = None for action in parser._actions: if type(action) == argparse._SubParsersAction: @@ -93,3 +95,8 @@ def test_parser(self): actions = user_subparsers.choices['create']._option_string_actions self.assertFalse(actions['--twitter'].required) self.assertTrue(actions['--username'].required) + + def test_extra_actions(self): + for cls, data in six.iteritems(gitlab.v3.cli.EXTRA_ACTIONS): + for key in data: + self.assertIsInstance(data[key], dict) diff --git a/gitlab/v3/cli.py b/gitlab/v3/cli.py index 142ccfa4d..b0450e8bf 100644 --- a/gitlab/v3/cli.py +++ b/gitlab/v3/cli.py @@ -18,145 +18,124 @@ from __future__ import print_function from __future__ import absolute_import -import argparse import inspect import operator -import re import sys import six import gitlab +import gitlab.base +from gitlab import cli +import gitlab.v3.objects -camel_re = re.compile('(.)([A-Z])') EXTRA_ACTIONS = { - gitlab.Group: {'search': {'required': ['query']}}, - gitlab.ProjectBranch: {'protect': {'required': ['id', 'project-id']}, - 'unprotect': {'required': ['id', 'project-id']}}, - gitlab.ProjectBuild: {'cancel': {'required': ['id', 'project-id']}, - 'retry': {'required': ['id', 'project-id']}, - 'artifacts': {'required': ['id', 'project-id']}, - 'trace': {'required': ['id', 'project-id']}}, - gitlab.ProjectCommit: {'diff': {'required': ['id', 'project-id']}, - 'blob': {'required': ['id', 'project-id', - 'filepath']}, - 'builds': {'required': ['id', 'project-id']}, - 'cherrypick': {'required': ['id', 'project-id', - 'branch']}}, - gitlab.ProjectIssue: {'subscribe': {'required': ['id', 'project-id']}, - 'unsubscribe': {'required': ['id', 'project-id']}, - 'move': {'required': ['id', 'project-id', - 'to-project-id']}}, - gitlab.ProjectMergeRequest: { + gitlab.v3.objects.Group: { + 'search': {'required': ['query']}}, + gitlab.v3.objects.ProjectBranch: { + 'protect': {'required': ['id', 'project-id']}, + 'unprotect': {'required': ['id', 'project-id']}}, + gitlab.v3.objects.ProjectBuild: { + 'cancel': {'required': ['id', 'project-id']}, + 'retry': {'required': ['id', 'project-id']}, + 'artifacts': {'required': ['id', 'project-id']}, + 'trace': {'required': ['id', 'project-id']}}, + gitlab.v3.objects.ProjectCommit: { + 'diff': {'required': ['id', 'project-id']}, + 'blob': {'required': ['id', 'project-id', 'filepath']}, + 'builds': {'required': ['id', 'project-id']}, + 'cherrypick': {'required': ['id', 'project-id', 'branch']}}, + gitlab.v3.objects.ProjectIssue: { + 'subscribe': {'required': ['id', 'project-id']}, + 'unsubscribe': {'required': ['id', 'project-id']}, + 'move': {'required': ['id', 'project-id', 'to-project-id']}}, + gitlab.v3.objects.ProjectMergeRequest: { 'closes-issues': {'required': ['id', 'project-id']}, 'cancel': {'required': ['id', 'project-id']}, 'merge': {'required': ['id', 'project-id'], 'optional': ['merge-commit-message', 'should-remove-source-branch', - 'merged-when-build-succeeds']} - }, - gitlab.ProjectMilestone: {'issues': {'required': ['id', 'project-id']}}, - gitlab.Project: {'search': {'required': ['query']}, - 'owned': {}, - 'all': {'optional': [('all', bool)]}, - 'starred': {}, - 'star': {'required': ['id']}, - 'unstar': {'required': ['id']}, - 'archive': {'required': ['id']}, - 'unarchive': {'required': ['id']}, - 'share': {'required': ['id', 'group-id', - 'group-access']}}, - gitlab.User: {'block': {'required': ['id']}, - 'unblock': {'required': ['id']}, - 'search': {'required': ['query']}, - 'get-by-username': {'required': ['query']}}, + 'merged-when-build-succeeds']}}, + gitlab.v3.objects.ProjectMilestone: { + 'issues': {'required': ['id', 'project-id']}}, + gitlab.v3.objects.Project: { + 'search': {'required': ['query']}, + 'owned': {}, + 'all': {'optional': [('all', bool)]}, + 'starred': {}, + 'star': {'required': ['id']}, + 'unstar': {'required': ['id']}, + 'archive': {'required': ['id']}, + 'unarchive': {'required': ['id']}, + 'share': {'required': ['id', 'group-id', 'group-access']}}, + gitlab.v3.objects.User: { + 'block': {'required': ['id']}, + 'unblock': {'required': ['id']}, + 'search': {'required': ['query']}, + 'get-by-username': {'required': ['query']}}, } -def _die(msg, e=None): - if e: - msg = "%s (%s)" % (msg, e) - sys.stderr.write(msg + "\n") - sys.exit(1) - - -def _what_to_cls(what): - return "".join([s.capitalize() for s in what.split("-")]) - - -def _cls_to_what(cls): - return camel_re.sub(r'\1-\2', cls.__name__).lower() - - -def do_auth(gitlab_id, config_files): - try: - gl = gitlab.Gitlab.from_config(gitlab_id, config_files) - gl.auth() - return gl - except Exception as e: - _die(str(e)) - - class GitlabCLI(object): def _get_id(self, cls, args): try: id = args.pop(cls.idAttr) except Exception: - _die("Missing --%s argument" % cls.idAttr.replace('_', '-')) + cli.die("Missing --%s argument" % cls.idAttr.replace('_', '-')) return id def do_create(self, cls, gl, what, args): if not cls.canCreate: - _die("%s objects can't be created" % what) + cli.die("%s objects can't be created" % what) try: o = cls.create(gl, args) except Exception as e: - _die("Impossible to create object", e) + cli.die("Impossible to create object", e) return o def do_list(self, cls, gl, what, args): if not cls.canList: - _die("%s objects can't be listed" % what) + cli.die("%s objects can't be listed" % what) try: l = cls.list(gl, **args) except Exception as e: - _die("Impossible to list objects", e) + cli.die("Impossible to list objects", e) return l def do_get(self, cls, gl, what, args): if cls.canGet is False: - _die("%s objects can't be retrieved" % what) + cli.die("%s objects can't be retrieved" % what) id = None - if cls not in [gitlab.CurrentUser] and cls.getRequiresId: + if cls not in [gitlab.v3.objects.CurrentUser] and cls.getRequiresId: id = self._get_id(cls, args) try: o = cls.get(gl, id, **args) except Exception as e: - _die("Impossible to get object", e) + cli.die("Impossible to get object", e) return o def do_delete(self, cls, gl, what, args): if not cls.canDelete: - _die("%s objects can't be deleted" % what) + cli.die("%s objects can't be deleted" % what) id = args.pop(cls.idAttr) try: gl.delete(cls, id, **args) except Exception as e: - _die("Impossible to destroy object", e) + cli.die("Impossible to destroy object", e) def do_update(self, cls, gl, what, args): if not cls.canUpdate: - _die("%s objects can't be updated" % what) + cli.die("%s objects can't be updated" % what) o = self.do_get(cls, gl, what, args) try: @@ -164,7 +143,7 @@ def do_update(self, cls, gl, what, args): o.__dict__[k] = v o.save() except Exception as e: - _die("Impossible to update object", e) + cli.die("Impossible to update object", e) return o @@ -172,171 +151,171 @@ def do_group_search(self, cls, gl, what, args): try: return gl.groups.search(args['query']) except Exception as e: - _die("Impossible to search projects", e) + cli.die("Impossible to search projects", e) def do_project_search(self, cls, gl, what, args): try: return gl.projects.search(args['query']) except Exception as e: - _die("Impossible to search projects", e) + cli.die("Impossible to search projects", e) def do_project_all(self, cls, gl, what, args): try: return gl.projects.all(all=args.get('all', False)) except Exception as e: - _die("Impossible to list all projects", e) + cli.die("Impossible to list all projects", e) def do_project_starred(self, cls, gl, what, args): try: return gl.projects.starred() except Exception as e: - _die("Impossible to list starred projects", e) + cli.die("Impossible to list starred projects", e) def do_project_owned(self, cls, gl, what, args): try: return gl.projects.owned() except Exception as e: - _die("Impossible to list owned projects", e) + cli.die("Impossible to list owned projects", e) def do_project_star(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) o.star() except Exception as e: - _die("Impossible to star project", e) + cli.die("Impossible to star project", e) def do_project_unstar(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) o.unstar() except Exception as e: - _die("Impossible to unstar project", e) + cli.die("Impossible to unstar project", e) def do_project_archive(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) o.archive_() except Exception as e: - _die("Impossible to archive project", e) + cli.die("Impossible to archive project", e) def do_project_unarchive(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) o.unarchive_() except Exception as e: - _die("Impossible to unarchive project", e) + cli.die("Impossible to unarchive project", e) def do_project_share(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) o.share(args['group_id'], args['group_access']) except Exception as e: - _die("Impossible to share project", e) + cli.die("Impossible to share project", e) def do_user_block(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) o.block() except Exception as e: - _die("Impossible to block user", e) + cli.die("Impossible to block user", e) def do_user_unblock(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) o.unblock() except Exception as e: - _die("Impossible to block user", e) + cli.die("Impossible to block user", e) def do_project_commit_diff(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) return [x['diff'] for x in o.diff()] except Exception as e: - _die("Impossible to get commit diff", e) + cli.die("Impossible to get commit diff", e) def do_project_commit_blob(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) return o.blob(args['filepath']) except Exception as e: - _die("Impossible to get commit blob", e) + cli.die("Impossible to get commit blob", e) def do_project_commit_builds(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) return o.builds() except Exception as e: - _die("Impossible to get commit builds", e) + cli.die("Impossible to get commit builds", e) def do_project_commit_cherrypick(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) o.cherry_pick(branch=args['branch']) except Exception as e: - _die("Impossible to cherry-pick commit", e) + cli.die("Impossible to cherry-pick commit", e) def do_project_build_cancel(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) return o.cancel() except Exception as e: - _die("Impossible to cancel project build", e) + cli.die("Impossible to cancel project build", e) def do_project_build_retry(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) return o.retry() except Exception as e: - _die("Impossible to retry project build", e) + cli.die("Impossible to retry project build", e) def do_project_build_artifacts(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) return o.artifacts() except Exception as e: - _die("Impossible to get project build artifacts", e) + cli.die("Impossible to get project build artifacts", e) def do_project_build_trace(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) return o.trace() except Exception as e: - _die("Impossible to get project build trace", e) + cli.die("Impossible to get project build trace", e) def do_project_issue_subscribe(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) o.subscribe() except Exception as e: - _die("Impossible to subscribe to issue", e) + cli.die("Impossible to subscribe to issue", e) def do_project_issue_unsubscribe(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) o.unsubscribe() except Exception as e: - _die("Impossible to subscribe to issue", e) + cli.die("Impossible to subscribe to issue", e) def do_project_issue_move(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) o.move(args['to_project_id']) except Exception as e: - _die("Impossible to move issue", e) + cli.die("Impossible to move issue", e) def do_project_merge_request_closesissues(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) return o.closes_issues() except Exception as e: - _die("Impossible to list issues closed by merge request", e) + cli.die("Impossible to list issues closed by merge request", e) def do_project_merge_request_cancel(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) return o.cancel_merge_when_build_succeeds() except Exception as e: - _die("Impossible to cancel merge request", e) + cli.die("Impossible to cancel merge request", e) def do_project_merge_request_merge(self, cls, gl, what, args): try: @@ -348,26 +327,26 @@ def do_project_merge_request_merge(self, cls, gl, what, args): should_remove_source_branch=should_remove, merged_when_build_succeeds=build_succeeds) except Exception as e: - _die("Impossible to validate merge request", e) + cli.die("Impossible to validate merge request", e) def do_project_milestone_issues(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) return o.issues() except Exception as e: - _die("Impossible to get milestone issues", e) + cli.die("Impossible to get milestone issues", e) def do_user_search(self, cls, gl, what, args): try: return gl.users.search(args['query']) except Exception as e: - _die("Impossible to search users", e) + cli.die("Impossible to search users", e) def do_user_getbyusername(self, cls, gl, what, args): try: return gl.users.search(args['query']) except Exception as e: - _die("Impossible to get user %s" % args['query'], e) + cli.die("Impossible to get user %s" % args['query'], e) def _populate_sub_parser_by_class(cls, sub_parser): @@ -391,7 +370,7 @@ def _populate_sub_parser_by_class(cls, sub_parser): action='store_true') if action_name in ["get", "delete"]: - if cls not in [gitlab.CurrentUser]: + if cls not in [gitlab.v3.objects.CurrentUser]: if cls.getRequiresId: id_attr = cls.idAttr.replace('_', '-') sub_parser_action.add_argument("--%s" % id_attr, @@ -456,39 +435,23 @@ def _add_arg(parser, required, data): for arg in d.get('optional', [])] -def _build_parser(args=sys.argv[1:]): - parser = argparse.ArgumentParser( - description="GitLab API Command Line Interface") - parser.add_argument("--version", help="Display the version.", - action="store_true") - parser.add_argument("-v", "--verbose", "--fancy", - help="Verbose mode", - action="store_true") - parser.add_argument("-c", "--config-file", action='append', - help=("Configuration file to use. Can be used " - "multiple times.")) - parser.add_argument("-g", "--gitlab", - help=("Which configuration section should " - "be used. If not defined, the default selection " - "will be used."), - required=False) - +def extend_parser(parser): subparsers = parser.add_subparsers(title='object', dest='what', help="Object to manipulate.") subparsers.required = True # populate argparse for all Gitlab Object classes = [] - for cls in gitlab.__dict__.values(): + for cls in gitlab.v3.objects.__dict__.values(): try: - if gitlab.GitlabObject in inspect.getmro(cls): + if gitlab.base.GitlabObject in inspect.getmro(cls): classes.append(cls) except AttributeError: pass classes.sort(key=operator.attrgetter("__name__")) for cls in classes: - arg_name = _cls_to_what(cls) + arg_name = cli.cls_to_what(cls) object_group = subparsers.add_parser(arg_name) object_subparsers = object_group.add_subparsers( @@ -499,47 +462,19 @@ def _build_parser(args=sys.argv[1:]): return parser -def _parse_args(args=sys.argv[1:]): - parser = _build_parser() - return parser.parse_args(args) - - -def main(): - if "--version" in sys.argv: - print(gitlab.__version__) - exit(0) - - arg = _parse_args() - args = arg.__dict__ - - config_files = arg.config_file - gitlab_id = arg.gitlab - verbose = arg.verbose - action = arg.action - what = arg.what - - # Remove CLI behavior-related args - for item in ("gitlab", "config_file", "verbose", "what", "action", - "version"): - args.pop(item) - - args = {k: v for k, v in args.items() if v is not None} - - cls = None +def run(gl, what, action, args, verbose): try: - cls = gitlab.__dict__[_what_to_cls(what)] - except Exception: - _die("Unknown object: %s" % what) + cls = gitlab.v3.objects.__dict__[cli.what_to_cls(what)] + except ImportError: + cli.die("Unknown object: %s" % what) - gl = do_auth(gitlab_id, config_files) - - cli = GitlabCLI() + g_cli = GitlabCLI() method = None what = what.replace('-', '_') action = action.lower().replace('-', '') for test in ["do_%s_%s" % (what, action), "do_%s" % action]: - if hasattr(cli, test): + if hasattr(g_cli, test): method = test break @@ -547,7 +482,7 @@ def main(): sys.stderr.write("Don't know how to deal with this!\n") sys.exit(1) - ret_val = getattr(cli, method)(cls, gl, what, args) + ret_val = getattr(g_cli, method)(cls, gl, what, args) if isinstance(ret_val, list): for o in ret_val: @@ -556,9 +491,7 @@ def main(): print("") else: print(o) - elif isinstance(ret_val, gitlab.GitlabObject): + elif isinstance(ret_val, gitlab.base.GitlabObject): ret_val.display(verbose) elif isinstance(ret_val, six.string_types): print(ret_val) - - sys.exit(0) From fe3a06c2a6a9776c22ff9120c99b3654e02e5e50 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 25 Jun 2017 09:44:30 +0200 Subject: [PATCH 46/93] remove useless attributes --- gitlab/v4/objects.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 37e818ff3..9c5289788 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -694,12 +694,6 @@ class ProjectForkManager(CreateMixin, RESTManager): class ProjectHook(SaveMixin, ObjectDeleteMixin, RESTObject): - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['url'] - optionalCreateAttrs = ['push_events', 'issues_events', 'note_events', - 'merge_requests_events', 'tag_push_events', - 'build_events', 'enable_ssl_verification', 'token', - 'pipeline_events', 'job_events', 'wiki_page_events'] _short_print_attr = 'url' @@ -764,10 +758,6 @@ class ProjectIssueManager(CRUDMixin, RESTManager): class ProjectMember(SaveMixin, ObjectDeleteMixin, RESTObject): - requiredCreateAttrs = ['access_level', 'user_id'] - optionalCreateAttrs = ['expires_at'] - requiredUpdateAttrs = ['access_level'] - optionalCreateAttrs = ['expires_at'] _short_print_attr = 'username' @@ -1314,8 +1304,7 @@ class ProjectDeploymentManager(RetrieveMixin, RESTManager): class ProjectRunner(ObjectDeleteMixin, RESTObject): - canUpdate = False - requiredCreateAttrs = ['runner_id'] + pass class ProjectRunnerManager(NoUpdateMixin, RESTManager): From 261db178f2e91b68f45a6535009367b56af75769 Mon Sep 17 00:00:00 2001 From: Aron Pammer Date: Mon, 26 Jun 2017 11:01:27 +0200 Subject: [PATCH 47/93] fixed repository_compare examples --- docs/gl_objects/projects.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/gl_objects/projects.py b/docs/gl_objects/projects.py index c9593cc5f..8db4ea79c 100644 --- a/docs/gl_objects/projects.py +++ b/docs/gl_objects/projects.py @@ -178,11 +178,11 @@ result = project.repository_compare('master', 'branch1') # get the commits -for i in commit: - print(result.commits) +for commit in result.commits: + print(commit) # get the diffs -for file_diff in commit.diffs: +for file_diff in result.diffs: print(file_diff) # end repository compare From 4c916b893e84993369d06dee5523cd00ea6b626a Mon Sep 17 00:00:00 2001 From: Jon Banafato Date: Fri, 7 Jul 2017 11:55:37 -0400 Subject: [PATCH 48/93] Declare support for Python 3.6 Add Python 3.6 environments to `tox.ini` and `.travis.yml`. --- .travis.yml | 1 + tox.ini | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index dd405f523..7c8b9fdc8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,6 +10,7 @@ addons: language: python python: 2.7 env: + - TOX_ENV=py36 - TOX_ENV=py35 - TOX_ENV=py34 - TOX_ENV=py27 diff --git a/tox.ini b/tox.ini index ef3e68a9c..bb1b84cc6 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] minversion = 1.6 skipsdist = True -envlist = py35,py34,py27,pep8 +envlist = py36,py35,py34,py27,pep8 [testenv] setenv = VIRTUAL_ENV={envdir} From 116e3d42c9e94c6d23128533da6c25920ff04d0f Mon Sep 17 00:00:00 2001 From: Guyzmo Date: Sat, 8 Jul 2017 13:45:03 +0200 Subject: [PATCH 49/93] Added dependency injection support for Session fixes #280 Signed-off-by: Guyzmo --- gitlab/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 97e937d70..b419cb855 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -71,7 +71,7 @@ class Gitlab(object): def __init__(self, url, private_token=None, email=None, password=None, ssl_verify=True, http_username=None, http_password=None, - timeout=None, api_version='3'): + timeout=None, api_version='3', session=None): self._api_version = str(api_version) self._url = '%s/api/v%s' % (url, api_version) @@ -90,7 +90,7 @@ def __init__(self, url, private_token=None, email=None, password=None, self.http_password = http_password #: Create a session object for requests - self.session = requests.Session() + self.session = session or requests.Session() objects = importlib.import_module('gitlab.v%s.objects' % self._api_version) From 67d9a8989b76af25fca1b5f0f82c4af5e81332eb Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 9 Jul 2017 09:16:27 +0200 Subject: [PATCH 50/93] Fix merge_when_build_succeeds attribute name Fixes #285 --- gitlab/v3/objects.py | 10 +++++----- gitlab/v4/objects.py | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/gitlab/v3/objects.py b/gitlab/v3/objects.py index 84b9cb558..65015fc45 100644 --- a/gitlab/v3/objects.py +++ b/gitlab/v3/objects.py @@ -1286,7 +1286,7 @@ def changes(self, **kwargs): def merge(self, merge_commit_message=None, should_remove_source_branch=False, - merged_when_build_succeeds=False, + merge_when_build_succeeds=False, **kwargs): """Accept the merge request. @@ -1294,8 +1294,8 @@ def merge(self, merge_commit_message=None, merge_commit_message (bool): Commit message should_remove_source_branch (bool): If True, removes the source branch - merged_when_build_succeeds (bool): Wait for the build to succeed, - then merge + merge_when_build_succeeds (bool): Wait for the build to succeed, + then merge Returns: ProjectMergeRequest: The updated MR @@ -1312,8 +1312,8 @@ def merge(self, merge_commit_message=None, data['merge_commit_message'] = merge_commit_message if should_remove_source_branch: data['should_remove_source_branch'] = True - if merged_when_build_succeeds: - data['merged_when_build_succeeds'] = True + if merge_when_build_succeeds: + data['merge_when_build_succeeds'] = True r = self.gitlab._raw_put(url, data=data, **kwargs) errors = {401: GitlabMRForbiddenError, diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 01c453f86..89321c930 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -1244,7 +1244,7 @@ def changes(self, **kwargs): def merge(self, merge_commit_message=None, should_remove_source_branch=False, - merged_when_build_succeeds=False, + merge_when_pipeline_succeeds=False, **kwargs): """Accept the merge request. @@ -1252,8 +1252,8 @@ def merge(self, merge_commit_message=None, merge_commit_message (bool): Commit message should_remove_source_branch (bool): If True, removes the source branch - merged_when_build_succeeds (bool): Wait for the build to succeed, - then merge + merge_when_pipeline_succeeds (bool): Wait for the build to succeed, + then merge Returns: ProjectMergeRequest: The updated MR @@ -1270,8 +1270,8 @@ def merge(self, merge_commit_message=None, data['merge_commit_message'] = merge_commit_message if should_remove_source_branch: data['should_remove_source_branch'] = True - if merged_when_build_succeeds: - data['merged_when_build_succeeds'] = True + if merge_when_pipeline_succeeds: + data['merge_when_pipeline_succeeds'] = True r = self.gitlab._raw_put(url, data=data, **kwargs) errors = {401: GitlabMRForbiddenError, From 374a6c4544931a564221cccabb6abbda9e6bc558 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 9 Jul 2017 09:16:27 +0200 Subject: [PATCH 51/93] Fix merge_when_build_succeeds attribute name Fixes #285 --- gitlab/v3/objects.py | 10 +++++----- gitlab/v4/objects.py | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/gitlab/v3/objects.py b/gitlab/v3/objects.py index 68c2858e8..69a972154 100644 --- a/gitlab/v3/objects.py +++ b/gitlab/v3/objects.py @@ -1285,7 +1285,7 @@ def changes(self, **kwargs): def merge(self, merge_commit_message=None, should_remove_source_branch=False, - merged_when_build_succeeds=False, + merge_when_build_succeeds=False, **kwargs): """Accept the merge request. @@ -1293,8 +1293,8 @@ def merge(self, merge_commit_message=None, merge_commit_message (bool): Commit message should_remove_source_branch (bool): If True, removes the source branch - merged_when_build_succeeds (bool): Wait for the build to succeed, - then merge + merge_when_build_succeeds (bool): Wait for the build to succeed, + then merge Returns: ProjectMergeRequest: The updated MR @@ -1311,8 +1311,8 @@ def merge(self, merge_commit_message=None, data['merge_commit_message'] = merge_commit_message if should_remove_source_branch: data['should_remove_source_branch'] = True - if merged_when_build_succeeds: - data['merged_when_build_succeeds'] = True + if merge_when_build_succeeds: + data['merge_when_build_succeeds'] = True r = self.gitlab._raw_put(url, data=data, **kwargs) errors = {401: GitlabMRForbiddenError, diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 9c5289788..d4b039594 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -900,7 +900,7 @@ def changes(self, **kwargs): def merge(self, merge_commit_message=None, should_remove_source_branch=False, - merged_when_build_succeeds=False, + merge_when_pipeline_succeeds=False, **kwargs): """Accept the merge request. @@ -908,8 +908,8 @@ def merge(self, merge_commit_message=None, merge_commit_message (bool): Commit message should_remove_source_branch (bool): If True, removes the source branch - merged_when_build_succeeds (bool): Wait for the build to succeed, - then merge + merge_when_pipeline_succeeds (bool): Wait for the build to succeed, + then merge """ path = '%s/%s/merge' % (self.manager.path, self.get_id()) data = {} @@ -917,8 +917,8 @@ def merge(self, merge_commit_message=None, data['merge_commit_message'] = merge_commit_message if should_remove_source_branch: data['should_remove_source_branch'] = True - if merged_when_build_succeeds: - data['merged_when_build_succeeds'] = True + if merge_when_pipeline_succeeds: + data['merge_when_pipeline_succeeds'] = True server_data = self.manager.gitlab.http_put(path, post_data=data, **kwargs) From 73be8f9a64b8a8db39f1a9d39b7bd677e1c68b0a Mon Sep 17 00:00:00 2001 From: Aron Pammer Date: Sun, 9 Jul 2017 11:07:47 +0200 Subject: [PATCH 52/93] Changed attribution reference --- docs/gl_objects/projects.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/gl_objects/projects.py b/docs/gl_objects/projects.py index 8db4ea79c..428f3578a 100644 --- a/docs/gl_objects/projects.py +++ b/docs/gl_objects/projects.py @@ -178,11 +178,11 @@ result = project.repository_compare('master', 'branch1') # get the commits -for commit in result.commits: +for commit in result['commits']: print(commit) # get the diffs -for file_diff in result.diffs: +for file_diff in result['diffs']: print(file_diff) # end repository compare From c15ba3b61065973da983ff792a34268a3ba75e12 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sat, 15 Jul 2017 17:05:44 +0200 Subject: [PATCH 53/93] Restore correct exceptions Match the exceptions raised in v3 for v4. Also update the doc strings with correct information. --- gitlab/__init__.py | 18 +- gitlab/exceptions.py | 20 ++ gitlab/mixins.py | 155 ++++++--- gitlab/tests/test_mixins.py | 2 +- gitlab/v4/objects.py | 608 ++++++++++++++++++++++++++++++------ 5 files changed, 655 insertions(+), 148 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 0696f3491..6a55feed9 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -654,6 +654,10 @@ def http_request(self, verb, path, query_data={}, post_data={}, if 200 <= result.status_code < 300: return result + if result.status_code == 401: + raise GitlabAuthenticationError(response_code=result.status_code, + error_message=result.content) + raise GitlabHttpError(response_code=result.status_code, error_message=result.content) @@ -674,7 +678,7 @@ def http_get(self, path, query_data={}, streamed=False, **kwargs): Raises: GitlabHttpError: When the return code is not 2xx - GitlabParsingError: IF the json data could not be parsed + GitlabParsingError: If the json data could not be parsed """ result = self.http_request('get', path, query_data=query_data, streamed=streamed, **kwargs) @@ -706,7 +710,7 @@ def http_list(self, path, query_data={}, **kwargs): Raises: GitlabHttpError: When the return code is not 2xx - GitlabParsingError: IF the json data could not be parsed + GitlabParsingError: If the json data could not be parsed """ url = self._build_url(path) get_all = kwargs.pop('all', False) @@ -726,19 +730,21 @@ def http_post(self, path, query_data={}, post_data={}, **kwargs): Returns: The parsed json returned by the server if json is return, else the - raw content. + raw content Raises: GitlabHttpError: When the return code is not 2xx - GitlabParsingError: IF the json data could not be parsed + GitlabParsingError: If the json data could not be parsed """ result = self.http_request('post', path, query_data=query_data, post_data=post_data, **kwargs) try: - return result.json() + if result.headers.get('Content-Type', None) == 'application/json': + return result.json() except Exception: raise GitlabParsingError( error_message="Failed to parse the server message") + return result def http_put(self, path, query_data={}, post_data={}, **kwargs): """Make a PUT request to the Gitlab server. @@ -756,7 +762,7 @@ def http_put(self, path, query_data={}, post_data={}, **kwargs): Raises: GitlabHttpError: When the return code is not 2xx - GitlabParsingError: IF the json data could not be parsed + GitlabParsingError: If the json data could not be parsed """ result = self.http_request('put', path, query_data=query_data, post_data=post_data, **kwargs) diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py index c9048a556..6c0012972 100644 --- a/gitlab/exceptions.py +++ b/gitlab/exceptions.py @@ -210,3 +210,23 @@ class to raise. Should be inherited from GitLabError raise error(error_message=message, response_code=response.status_code, response_body=response.content) + + +def on_http_error(error): + """Manage GitlabHttpError exceptions. + + This decorator function can be used to catch GitlabHttpError exceptions + raise specialized exceptions instead. + + Args: + error(Exception): The exception type to raise -- must inherit from + GitlabError + """ + def wrap(f): + def wrapped_f(*args, **kwargs): + try: + return f(*args, **kwargs) + except GitlabHttpError as e: + raise error(e.response_code, e.error_message) + return wrapped_f + return wrap diff --git a/gitlab/mixins.py b/gitlab/mixins.py index 6b5475cfe..cc9eb5120 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -17,10 +17,11 @@ import gitlab from gitlab import base -from gitlab import exceptions +from gitlab import exceptions as exc class GetMixin(object): + @exc.on_http_error(exc.GitlabGetError) def get(self, id, lazy=False, **kwargs): """Retrieve a single object. @@ -29,45 +30,48 @@ def get(self, id, lazy=False, **kwargs): lazy (bool): If True, don't request the server, but create a shallow object giving access to the managers. This is useful if you want to avoid useless calls to the API. - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + **kwargs: Extra options to send to the Gitlab server (e.g. sudo) Returns: object: The generated RESTObject. Raises: - GitlabGetError: If the server cannot perform the request. + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the server cannot perform the request """ path = '%s/%s' % (self.path, id) if lazy is True: return self._obj_cls(self, {self._obj_cls._id_attr: id}) - server_data = self.gitlab.http_get(path, **kwargs) return self._obj_cls(self, server_data) class GetWithoutIdMixin(object): + @exc.on_http_error(exc.GitlabGetError) def get(self, **kwargs): """Retrieve a single object. Args: - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + **kwargs: Extra options to send to the Gitlab server (e.g. sudo) Returns: - object: The generated RESTObject. + object: The generated RESTObject Raises: - GitlabGetError: If the server cannot perform the request. + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the server cannot perform the request """ server_data = self.gitlab.http_get(self.path, **kwargs) return self._obj_cls(self, server_data) class ListMixin(object): + @exc.on_http_error(exc.GitlabListError) def list(self, **kwargs): - """Retrieves a list of objects. + """Retrieve a list of objects. Args: - **kwargs: Extra data to send to the Gitlab server (e.g. sudo). + **kwargs: Extra options to send to the Gitlab server (e.g. sudo). If ``all`` is passed and set to True, the entire list of objects will be returned. @@ -76,11 +80,14 @@ def list(self, **kwargs): queries to the server when required. If ``all=True`` is passed as argument, returns list(RESTObjectList). + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the server cannot perform the request """ # Allow to overwrite the path, handy for custom listings path = kwargs.pop('path', self.path) - obj = self.gitlab.http_list(path, **kwargs) if isinstance(obj, list): return [self._obj_cls(self, item) for item in obj] @@ -94,20 +101,21 @@ def get(self, id, **kwargs): Args: id (int or str): ID of the object to retrieve - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + **kwargs: Extra options to send to the Gitlab server (e.g. sudo) Returns: - object: The generated RESTObject. + object: The generated RESTObject Raises: - AttributeError: If the object could not be found in the list + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the server cannot perform the request """ gen = self.list() for obj in gen: if str(obj.get_id()) == str(id): return obj - raise exceptions.GitlabHttpError(404, "Not found") + raise exc.GitlabGetError(response_code=404, error_message="Not found") class RetrieveMixin(ListMixin, GetMixin): @@ -126,7 +134,7 @@ def _check_missing_create_attrs(self, data): raise AttributeError("Missing attributes: %s" % ", ".join(missing)) def get_create_attrs(self): - """Returns the required and optional arguments. + """Return the required and optional arguments. Returns: tuple: 2 items: list of required arguments and list of optional @@ -134,17 +142,22 @@ def get_create_attrs(self): """ return getattr(self, '_create_attrs', (tuple(), tuple())) + @exc.on_http_error(exc.GitlabCreateError) def create(self, data, **kwargs): - """Creates a new object. + """Create a new object. Args: data (dict): parameters to send to the server to create the resource - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + **kwargs: Extra options to send to the Gitlab server (e.g. sudo) Returns: - RESTObject: a new instance of the manage object class build with - the data sent by the server + RESTObject: a new instance of the managed object class build with + the data sent by the server + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabCreateError: If the server cannot perform the request """ self._check_missing_create_attrs(data) if hasattr(self, '_sanitize_data'): @@ -167,7 +180,7 @@ def _check_missing_update_attrs(self, data): raise AttributeError("Missing attributes: %s" % ", ".join(missing)) def get_update_attrs(self): - """Returns the required and optional arguments. + """Return the required and optional arguments. Returns: tuple: 2 items: list of required arguments and list of optional @@ -175,16 +188,21 @@ def get_update_attrs(self): """ return getattr(self, '_update_attrs', (tuple(), tuple())) + @exc.on_http_error(exc.GitlabUpdateError) def update(self, id=None, new_data={}, **kwargs): """Update an object on the server. Args: id: ID of the object to update (can be None if not required) new_data: the update data for the object - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + **kwargs: Extra options to send to the Gitlab server (e.g. sudo) Returns: dict: The new object data (*not* a RESTObject) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabUpdateError: If the server cannot perform the request """ if id is None: @@ -197,17 +215,22 @@ def update(self, id=None, new_data={}, **kwargs): data = self._sanitize_data(new_data, 'update') else: data = new_data - server_data = self.gitlab.http_put(path, post_data=data, **kwargs) - return server_data + + return self.gitlab.http_put(path, post_data=data, **kwargs) class DeleteMixin(object): + @exc.on_http_error(exc.GitlabDeleteError) def delete(self, id, **kwargs): - """Deletes an object on the server. + """Delete an object on the server. Args: id: ID of the object to delete - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + **kwargs: Extra options to send to the Gitlab server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabDeleteError: If the server cannot perform the request """ path = '%s/%s' % (self.path, id) self.gitlab.http_delete(path, **kwargs) @@ -235,12 +258,16 @@ def _get_updated_data(self): return updated_data def save(self, **kwargs): - """Saves the changes made to the object to the server. + """Save the changes made to the object to the server. + + The object is updated to match what the server returns. Args: - **kwargs: Extra option to send to the server (e.g. sudo) + **kwargs: Extra options to send to the server (e.g. sudo) - The object is updated to match what the server returns. + Raise: + GitlabAuthenticationError: If authentication is not correct + GitlabUpdateError: If the server cannot perform the request """ updated_data = self._get_updated_data() @@ -256,21 +283,27 @@ def delete(self, **kwargs): """Delete the object from the server. Args: - **kwargs: Extra option to send to the server (e.g. sudo) + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabDeleteError: If the server cannot perform the request """ self.manager.delete(self.get_id()) class AccessRequestMixin(object): + @exc.on_http_error(exc.GitlabUpdateError) def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs): """Approve an access request. Attrs: - access_level (int): The access level for the user. + access_level (int): The access level for the user + **kwargs: Extra options to send to the server (e.g. sudo) Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabUpdateError: If the server fails to perform the request. + GitlabAuthenticationError: If authentication is not correct + GitlabUpdateError: If the server fails to perform the request """ path = '%s/%s/approve' % (self.manager.path, self.id) @@ -281,23 +314,31 @@ def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs): class SubscribableMixin(object): + @exc.on_http_error(exc.GitlabSubscribeError) def subscribe(self, **kwargs): """Subscribe to the object notifications. + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + raises: - gitlabconnectionerror: if the server cannot be reached. - gitlabsubscribeerror: if the subscription cannot be done + GitlabAuthenticationError: If authentication is not correct + GitlabSubscribeError: If the subscription cannot be done """ path = '%s/%s/subscribe' % (self.manager.path, self.get_id()) server_data = self.manager.gitlab.http_post(path, **kwargs) self._update_attrs(server_data) + @exc.on_http_error(exc.GitlabUnsubscribeError) def unsubscribe(self, **kwargs): """Unsubscribe from the object notifications. + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + raises: - gitlabconnectionerror: if the server cannot be reached. - gitlabunsubscribeerror: if the unsubscription cannot be done + GitlabAuthenticationError: If authentication is not correct + GitlabUnsubscribeError: If the unsubscription cannot be done """ path = '%s/%s/unsubscribe' % (self.manager.path, self.get_id()) server_data = self.manager.gitlab.http_post(path, **kwargs) @@ -305,66 +346,92 @@ def unsubscribe(self, **kwargs): class TodoMixin(object): + @exc.on_http_error(exc.GitlabHttpError) def todo(self, **kwargs): """Create a todo associated to the object. + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + Raises: - GitlabConnectionError: If the server cannot be reached. + GitlabAuthenticationError: If authentication is not correct + GitlabTodoError: If the todo cannot be set """ path = '%s/%s/todo' % (self.manager.path, self.get_id()) self.manager.gitlab.http_post(path, **kwargs) class TimeTrackingMixin(object): + @exc.on_http_error(exc.GitlabTimeTrackingError) def time_stats(self, **kwargs): """Get time stats for the object. + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + Raises: - GitlabConnectionError: If the server cannot be reached. + GitlabAuthenticationError: If authentication is not correct + GitlabTimeTrackingError: If the time tracking update cannot be done """ path = '%s/%s/time_stats' % (self.manager.path, self.get_id()) return self.manager.gitlab.http_get(path, **kwargs) + @exc.on_http_error(exc.GitlabTimeTrackingError) def time_estimate(self, duration, **kwargs): """Set an estimated time of work for the object. Args: - duration (str): duration in human format (e.g. 3h30) + duration (str): Duration in human format (e.g. 3h30) + **kwargs: Extra options to send to the server (e.g. sudo) Raises: - GitlabConnectionError: If the server cannot be reached. + GitlabAuthenticationError: If authentication is not correct + GitlabTimeTrackingError: If the time tracking update cannot be done """ path = '%s/%s/time_estimate' % (self.manager.path, self.get_id()) data = {'duration': duration} return self.manager.gitlab.http_post(path, post_data=data, **kwargs) + @exc.on_http_error(exc.GitlabTimeTrackingError) def reset_time_estimate(self, **kwargs): """Resets estimated time for the object to 0 seconds. + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + Raises: - GitlabConnectionError: If the server cannot be reached. + GitlabAuthenticationError: If authentication is not correct + GitlabTimeTrackingError: If the time tracking update cannot be done """ path = '%s/%s/rest_time_estimate' % (self.manager.path, self.get_id()) return self.manager.gitlab.http_post(path, **kwargs) + @exc.on_http_error(exc.GitlabTimeTrackingError) def add_spent_time(self, duration, **kwargs): """Add time spent working on the object. Args: - duration (str): duration in human format (e.g. 3h30) + duration (str): Duration in human format (e.g. 3h30) + **kwargs: Extra options to send to the server (e.g. sudo) Raises: - GitlabConnectionError: If the server cannot be reached. + GitlabAuthenticationError: If authentication is not correct + GitlabTimeTrackingError: If the time tracking update cannot be done """ path = '%s/%s/add_spent_time' % (self.manager.path, self.get_id()) data = {'duration': duration} return self.manager.gitlab.http_post(path, post_data=data, **kwargs) + @exc.on_http_error(exc.GitlabTimeTrackingError) def reset_spent_time(self, **kwargs): """Resets the time spent working on the object. + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + Raises: - GitlabConnectionError: If the server cannot be reached. + GitlabAuthenticationError: If authentication is not correct + GitlabTimeTrackingError: If the time tracking update cannot be done """ path = '%s/%s/reset_spent_time' % (self.manager.path, self.get_id()) return self.manager.gitlab.http_post(path, **kwargs) diff --git a/gitlab/tests/test_mixins.py b/gitlab/tests/test_mixins.py index dd456eb88..de853d7cc 100644 --- a/gitlab/tests/test_mixins.py +++ b/gitlab/tests/test_mixins.py @@ -230,7 +230,7 @@ def resp_cont(url, request): self.assertEqual(obj.foo, 'bar') self.assertEqual(obj.id, 42) - self.assertRaises(GitlabHttpError, mgr.get, 44) + self.assertRaises(GitlabGetError, mgr.get, 44) def test_create_mixin_get_attrs(self): class M1(CreateMixin, FakeManager): diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index d4b039594..9de18ee93 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -44,20 +44,69 @@ class SidekiqManager(RESTManager): This manager doesn't actually manage objects but provides helper fonction for the sidekiq metrics API. """ + + @exc.on_http_error(exc.GitlabGetError) def queue_metrics(self, **kwargs): - """Returns the registred queues information.""" + """Return the registred queues information. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the information couldn't be retrieved + + Returns: + dict: Information about the Sidekiq queues + """ return self.gitlab.http_get('/sidekiq/queue_metrics', **kwargs) + @exc.on_http_error(exc.GitlabGetError) def process_metrics(self, **kwargs): - """Returns the registred sidekiq workers.""" + """Return the registred sidekiq workers. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the information couldn't be retrieved + + Returns: + dict: Information about the register Sidekiq worker + """ return self.gitlab.http_get('/sidekiq/process_metrics', **kwargs) + @exc.on_http_error(exc.GitlabGetError) def job_stats(self, **kwargs): - """Returns statistics about the jobs performed.""" + """Return statistics about the jobs performed. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the information couldn't be retrieved + + Returns: + dict: Statistics about the Sidekiq jobs performed + """ return self.gitlab.http_get('/sidekiq/job_stats', **kwargs) + @exc.on_http_error(exc.GitlabGetError) def compound_metrics(self, **kwargs): - """Returns all available metrics and statistics.""" + """Return all available metrics and statistics. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the information couldn't be retrieved + + Returns: + dict: All available Sidekiq metrics and statistics + """ return self.gitlab.http_get('/sidekiq/compound_metrics', **kwargs) @@ -108,11 +157,19 @@ class User(SaveMixin, ObjectDeleteMixin, RESTObject): ('projects', 'UserProjectManager'), ) + @exc.on_http_error(exc.GitlabBlockError) def block(self, **kwargs): - """Blocks the user. + """Block the user. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabBlockError: If the user could not be blocked Returns: - bool: whether the user status has been changed. + bool: Whether the user status has been changed """ path = '/users/%s/block' % self.id server_data = self.manager.gitlab.http_post(path, **kwargs) @@ -120,11 +177,19 @@ def block(self, **kwargs): self._attrs['state'] = 'blocked' return server_data + @exc.on_http_error(exc.GitlabUnblockError) def unblock(self, **kwargs): - """Unblocks the user. + """Unblock the user. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabUnblockError: If the user could not be unblocked Returns: - bool: whether the user status has been changed. + bool: Whether the user status has been changed """ path = '/users/%s/unblock' % self.id server_data = self.manager.gitlab.http_post(path, **kwargs) @@ -381,6 +446,7 @@ class Snippet(SaveMixin, ObjectDeleteMixin, RESTObject): _constructor_types = {'author': 'User'} _short_print_attr = 'title' + @exc.on_http_error(exc.GitlabGetError) def content(self, streamed=False, action=None, chunk_size=1024, **kwargs): """Return the content of a snippet. @@ -389,11 +455,16 @@ def content(self, streamed=False, action=None, chunk_size=1024, **kwargs): `chunk_size` and each chunk is passed to `action` for treatment. action (callable): Callable responsible of dealing with chunk of - data. - chunk_size (int): Size of each chunk. + data + chunk_size (int): Size of each chunk + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the content could not be retrieved Returns: - str: The snippet content. + str: The snippet content """ path = '/snippets/%s/raw' % self.get_id() result = self.manager.gitlab.http_get(path, streamed=streamed, @@ -413,11 +484,14 @@ def public(self, **kwargs): """List all the public snippets. Args: - all (bool): If True, return all the items, without pagination - **kwargs: Additional arguments to send to GitLab. + all (bool): If True the returned object will be a list + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabListError: If the list could not be retrieved Returns: - list(gitlab.Gitlab.Snippet): The list of snippets. + RESTObjectList: A generator for the snippets list """ return self.list(path='/snippets/public', **kwargs) @@ -460,15 +534,21 @@ class ProjectBranch(ObjectDeleteMixin, RESTObject): _constructor_types = {'author': 'User', "committer": "User"} _id_attr = 'name' + @exc.on_http_error(exc.GitlabProtectError) def protect(self, developers_can_push=False, developers_can_merge=False, **kwargs): - """Protects the branch. + """Protect the branch. Args: developers_can_push (bool): Set to True if developers are allowed to push to the branch developers_can_merge (bool): Set to True if developers are allowed to merge to the branch + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabProtectError: If the branch could not be protected """ path = '%s/%s/protect' % (self.manager.path, self.get_id()) post_data = {'developers_can_push': developers_can_push, @@ -476,8 +556,17 @@ def protect(self, developers_can_push=False, developers_can_merge=False, self.manager.gitlab.http_put(path, post_data=post_data, **kwargs) self._attrs['protected'] = True + @exc.on_http_error(exc.GitlabProtectError) def unprotect(self, **kwargs): - """Unprotects the branch.""" + """Unprotect the branch. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabProtectError: If the branch could not be unprotected + """ path = '%s/%s/protect' % (self.manager.path, self.get_id()) self.manager.gitlab.http_put(path, **kwargs) self._attrs['protected'] = False @@ -495,31 +584,77 @@ class ProjectJob(RESTObject): 'commit': 'ProjectCommit', 'runner': 'Runner'} + @exc.on_http_error(exc.GitlabJobCancelError) def cancel(self, **kwargs): - """Cancel the job.""" + """Cancel the job. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabJobCancelError: If the job could not be canceled + """ path = '%s/%s/cancel' % (self.manager.path, self.get_id()) self.manager.gitlab.http_post(path) + @exc.on_http_error(exc.GitlabJobRetryError) def retry(self, **kwargs): - """Retry the job.""" + """Retry the job. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabJobRetryError: If the job could not be retried + """ path = '%s/%s/retry' % (self.manager.path, self.get_id()) self.manager.gitlab.http_post(path) + @exc.on_http_error(exc.GitlabJobPlayError) def play(self, **kwargs): - """Trigger a job explicitly.""" + """Trigger a job explicitly. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabJobPlayError: If the job could not be triggered + """ path = '%s/%s/play' % (self.manager.path, self.get_id()) self.manager.gitlab.http_post(path) + @exc.on_http_error(exc.GitlabJobEraseError) def erase(self, **kwargs): - """Erase the job (remove job artifacts and trace).""" + """Erase the job (remove job artifacts and trace). + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabJobEraseError: If the job could not be erased + """ path = '%s/%s/erase' % (self.manager.path, self.get_id()) self.manager.gitlab.http_post(path) + @exc.on_http_error(exc.GitlabCreateError) def keep_artifacts(self, **kwargs): - """Prevent artifacts from being delete when expiration is set.""" + """Prevent artifacts from being deleted when expiration is set. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabCreateError: If the request could not be performed + """ path = '%s/%s/artifacts/keep' % (self.manager.path, self.get_id()) self.manager.gitlab.http_post(path) + @exc.on_http_error(exc.GitlabGetError) def artifacts(self, streamed=False, action=None, chunk_size=1024, **kwargs): """Get the job artifacts. @@ -527,10 +662,15 @@ def artifacts(self, streamed=False, action=None, chunk_size=1024, Args: streamed (bool): If True the data will be processed by chunks of `chunk_size` and each chunk is passed to `action` for - treatment. + treatment action (callable): Callable responsible of dealing with chunk of - data. - chunk_size (int): Size of each chunk. + data + chunk_size (int): Size of each chunk + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the artifacts could not be retrieved Returns: str: The artifacts if `streamed` is False, None otherwise. @@ -540,19 +680,25 @@ def artifacts(self, streamed=False, action=None, chunk_size=1024, **kwargs) return utils.response_content(result, streamed, action, chunk_size) + @exc.on_http_error(exc.GitlabGetError) def trace(self, streamed=False, action=None, chunk_size=1024, **kwargs): """Get the job trace. Args: streamed (bool): If True the data will be processed by chunks of `chunk_size` and each chunk is passed to `action` for - treatment. + treatment action (callable): Callable responsible of dealing with chunk of - data. - chunk_size (int): Size of each chunk. + data + chunk_size (int): Size of each chunk + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the artifacts could not be retrieved Returns: - str: The trace. + str: The trace """ path = '%s/%s/trace' % (self.manager.path, self.get_id()) result = self.manager.gitlab.get_http(path, streamed=streamed, @@ -579,16 +725,20 @@ class ProjectCommitStatusManager(RetrieveMixin, CreateMixin, RESTManager): ('description', 'name', 'context', 'ref', 'target_url')) def create(self, data, **kwargs): - """Creates a new object. + """Create a new object. Args: - data (dict): parameters to send to the server to create the + data (dict): Parameters to send to the server to create the resource **kwargs: Extra data to send to the Gitlab server (e.g. sudo or 'ref_name', 'stage', 'name', 'all'. + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabCreateError: If the server cannot perform the request + Returns: - RESTObject: a new instance of the manage object class build with + RESTObject: A new instance of the manage object class build with the data sent by the server """ path = '/projects/%(project_id)s/statuses/%(commit_id)s' @@ -615,16 +765,34 @@ class ProjectCommit(RESTObject): ('statuses', 'ProjectCommitStatusManager'), ) + @exc.on_http_error(exc.GitlabGetError) def diff(self, **kwargs): - """Generate the commit diff.""" + """Generate the commit diff. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raise: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the diff could not be retrieved + + Returns: + list: The changes done in this commit + """ path = '%s/%s/diff' % (self.manager.path, self.get_id()) return self.manager.gitlab.http_get(path, **kwargs) + @exc.on_http_error(exc.GitlabCherryPickError) def cherry_pick(self, branch, **kwargs): """Cherry-pick a commit into a branch. Args: - branch (str): Name of target branch. + branch (str): Name of target branch + **kwargs: Extra options to send to the server (e.g. sudo) + + Raise: + GitlabAuthenticationError: If authentication is not correct + GitlabCherryPickError: If the cherry-pick could not be performed """ path = '%s/%s/cherry_pick' % (self.manager.path, self.get_id()) post_data = {'branch': branch} @@ -662,11 +830,17 @@ class ProjectKeyManager(NoUpdateMixin, RESTManager): _from_parent_attrs = {'project_id': 'id'} _create_attrs = (('title', 'key'), tuple()) + @exc.on_http_error(exc.GitlabProjectDeployKeyError) def enable(self, key_id, **kwargs): """Enable a deploy key for a project. Args: key_id (int): The ID of the key to enable + **kwargs: Extra options to send to the server (e.g. sudo) + + Raise: + GitlabAuthenticationError: If authentication is not correct + GitlabProjectDeployKeyError: If the key could not be enabled """ path = '%s/%s/enable' % (self.manager.path, key_id) self.manager.gitlab.http_post(path, **kwargs) @@ -735,8 +909,18 @@ class ProjectIssue(SubscribableMixin, TodoMixin, TimeTrackingMixin, SaveMixin, _id_attr = 'iid' _managers = (('notes', 'ProjectIssueNoteManager'), ) + @exc.on_http_error(exc.GitlabUpdateError) def move(self, to_project_id, **kwargs): - """Move the issue to another project.""" + """Move the issue to another project. + + Args: + to_project_id(int): ID of the target project + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabUpdateError: If the issue could not be moved + """ path = '%s/%s/move' % (self.manager.path, self.get_id()) data = {'to_project_id': to_project_id} server_data = self.manager.gitlab.http_post(path, post_data=data, @@ -804,15 +988,27 @@ def set_release_description(self, description, **kwargs): Args: description (str): Description of the release. + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabCreateError: If the server fails to create the release + GitlabUpdateError: If the server fails to update the release """ path = '%s/%s/release' % (self.manager.path, self.get_id()) data = {'description': description} if self.release is None: - result = self.manager.gitlab.http_post(path, post_data=data, - **kwargs) + try: + result = self.manager.gitlab.http_post(path, post_data=data, + **kwargs) + except exc.GitlabHttpError as e: + raise exc.GitlabCreateError(e.response_code, e.error_message) else: - result = self.manager.gitlab.http_put(path, post_data=data, - **kwargs) + try: + result = self.manager.gitlab.http_put(path, post_data=data, + **kwargs) + except exc.GitlabHttpError as e: + raise exc.GitlabUpdateError(e.response_code, e.error_message) self.release = result.json() @@ -856,19 +1052,37 @@ class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, ('diffs', 'ProjectMergeRequestDiffManager') ) + @exc.on_http_error(exc.GitlabMROnBuildSuccessError) def cancel_merge_when_pipeline_succeeds(self, **kwargs): - """Cancel merge when build succeeds.""" + """Cancel merge when the pipeline succeeds. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabMROnBuildSuccessError: If the server could not handle the + request + """ path = ('%s/%s/cancel_merge_when_pipeline_succeeds' % (self.manager.path, self.get_id())) server_data = self.manager.gitlab.http_put(path, **kwargs) self._update_attrs(server_data) + @exc.on_http_error(exc.GitlabListError) def closes_issues(self, **kwargs): """List issues that will close on merge." + Args: + **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: - list (ProjectIssue): List of issues + RESTObjectList: List of issues """ path = '%s/%s/closes_issues' % (self.manager.path, self.get_id()) data_list = self.manager.gitlab.http_list(path, **kwargs) @@ -876,11 +1090,19 @@ def closes_issues(self, **kwargs): parent=self.manager._parent) return RESTObjectList(manager, ProjectIssue, data_list) + @exc.on_http_error(exc.GitlabListError) def commits(self, **kwargs): """List the merge request commits. + Args: + **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: - list (ProjectCommit): List of commits + RESTObjectList: The list of commits """ path = '%s/%s/commits' % (self.manager.path, self.get_id()) @@ -889,15 +1111,24 @@ def commits(self, **kwargs): parent=self.manager._parent) return RESTObjectList(manager, ProjectCommit, data_list) + @exc.on_http_error(exc.GitlabListError) def changes(self, **kwargs): """List the merge request changes. + Args: + **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: - list (dict): List of changes + RESTObjectList: List of changes """ path = '%s/%s/changes' % (self.manager.path, self.get_id()) return self.manager.gitlab.http_get(path, **kwargs) + @exc.on_http_error(exc.GitlabMRClosedError) def merge(self, merge_commit_message=None, should_remove_source_branch=False, merge_when_pipeline_succeeds=False, @@ -910,6 +1141,11 @@ def merge(self, merge_commit_message=None, branch merge_when_pipeline_succeeds (bool): Wait for the build to succeed, then merge + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabMRClosedError: If the merge failed """ path = '%s/%s/merge' % (self.manager.path, self.get_id()) data = {} @@ -943,11 +1179,19 @@ class ProjectMergeRequestManager(CRUDMixin, RESTManager): class ProjectMilestone(SaveMixin, ObjectDeleteMixin, RESTObject): _short_print_attr = 'title' + @exc.on_http_error(exc.GitlabListError) def issues(self, **kwargs): - """List issues related to this milestone + """List issues related to this milestone. + + Args: + **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: - list (ProjectIssue): The list of issues + RESTObjectList: The list of issues """ path = '%s/%s/issues' % (self.manager.path, self.get_id()) @@ -957,11 +1201,19 @@ def issues(self, **kwargs): # FIXME(gpocentek): the computed manager path is not correct return RESTObjectList(manager, ProjectIssue, data_list) + @exc.on_http_error(exc.GitlabListError) def merge_requests(self, **kwargs): - """List the merge requests related to this milestone + """List the merge requests related to this milestone. + + Args: + **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: - list (ProjectMergeRequest): List of merge requests + RESTObjectList: The list of merge requests """ path = '%s/%s/merge_requests' % (self.manager.path, self.get_id()) data_list = self.manager.gitlab.http_list(path, **kwargs) @@ -998,23 +1250,33 @@ class ProjectLabelManager(GetFromListMixin, CreateMixin, UpdateMixin, ('new_name', 'color', 'description', 'priority')) # Delete without ID. + @exc.on_http_error(exc.GitlabDeleteError) def delete(self, name, **kwargs): - """Deletes a Label on the server. + """Delete a Label on the server. Args: - name: The name of the label. - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + name: The name of the label + **kwargs: Extra options to send to the Gitlab server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct. + GitlabDeleteError: If the server cannot perform the request. """ self.gitlab.http_delete(path, query_data={'name': self.name}, **kwargs) # Update without ID, but we need an ID to get from list. + @exc.on_http_error(exc.GitlabUpdateError) def save(self, **kwargs): """Saves the changes made to the object to the server. + The object is updated to match what the server returns. + Args: - **kwargs: Extra option to send to the server (e.g. sudo) + **kwargs: Extra options to send to the server (e.g. sudo) - The object is updated to match what the server returns. + Raises: + GitlabAuthenticationError: If authentication is not correct. + GitlabUpdateError: If the server cannot perform the request. """ updated_data = self._get_updated_data() @@ -1047,18 +1309,23 @@ class ProjectFileManager(GetMixin, CreateMixin, UpdateMixin, DeleteMixin, ('encoding', 'author_email', 'author_name')) def get(self, file_path, **kwargs): - """Retrieve a single object. + """Retrieve a single file. Args: id (int or str): ID of the object to retrieve - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + **kwargs: Extra options to send to the Gitlab server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the file could not be retrieved Returns: - object: The generated RESTObject. + object: The generated RESTObject """ file_path = file_path.replace('/', '%2F') return GetMixin.get(self, file_path, **kwargs) + @exc.on_http_error(exc.GitlabGetError) def raw(self, file_path, ref, streamed=False, action=None, chunk_size=1024, **kwargs): """Return the content of a file for a commit. @@ -1068,10 +1335,15 @@ def raw(self, file_path, ref, streamed=False, action=None, chunk_size=1024, filepath (str): Path of the file to return streamed (bool): If True the data will be processed by chunks of `chunk_size` and each chunk is passed to `action` for - treatment. + treatment action (callable): Callable responsible of dealing with chunk of - data. - chunk_size (int): Size of each chunk. + data + chunk_size (int): Size of each chunk + **kwargs: Extra options to send to the Gitlab server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the file could not be retrieved Returns: str: The file content @@ -1085,13 +1357,31 @@ def raw(self, file_path, ref, streamed=False, action=None, chunk_size=1024, class ProjectPipeline(RESTObject): + @exc.on_http_error(exc.GitlabPipelineCancelError) def cancel(self, **kwargs): - """Cancel the job.""" + """Cancel the job. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabPipelineCancelError: If the request failed + """ path = '%s/%s/cancel' % (self.manager.path, self.get_id()) self.manager.gitlab.http_post(path) + @exc.on_http_error(exc.GitlabPipelineRetryError) def retry(self, **kwargs): - """Retry the job.""" + """Retry the job. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabPipelineRetryError: If the request failed + """ path = '%s/%s/retry' % (self.manager.path, self.get_id()) self.manager.gitlab.http_post(path) @@ -1106,13 +1396,17 @@ def create(self, data, **kwargs): """Creates a new object. Args: - data (dict): parameters to send to the server to create the + data (dict): Parameters to send to the server to create the resource - **kwargs: Extra data to send to the Gitlab server (e.g. sudo) + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabCreateError: If the server cannot perform the request Returns: - RESTObject: a new instance of the manage object class build with - the data sent by the server + RESTObject: A new instance of the managed object class build with + the data sent by the server """ path = self.path[:-1] # drop the 's' return CreateMixin.create(self, data, path=path, **kwargs) @@ -1136,16 +1430,22 @@ class ProjectSnippet(SaveMixin, ObjectDeleteMixin, RESTObject): _short_print_attr = 'title' _managers = (('notes', 'ProjectSnippetNoteManager'), ) + @exc.on_http_error(exc.GitlabGetError) def content(self, streamed=False, action=None, chunk_size=1024, **kwargs): - """Return the raw content of a snippet. + """Return the content of a snippet. Args: streamed (bool): If True the data will be processed by chunks of `chunk_size` and each chunk is passed to `action` for treatment. action (callable): Callable responsible of dealing with chunk of - data. - chunk_size (int): Size of each chunk. + data + chunk_size (int): Size of each chunk + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the content could not be retrieved Returns: str: The snippet content @@ -1346,15 +1646,21 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): ('variables', 'ProjectVariableManager'), ) + @exc.on_http_error(exc.GitlabGetError) def repository_tree(self, path='', ref='', **kwargs): """Return a list of files in the repository. Args: path (str): Path of the top folder (/ by default) ref (str): Reference to a commit or branch + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the server failed to perform the request Returns: - str: The json representation of the tree. + list: The representation of the tree """ gl_path = '/projects/%s/repository/tree' % self.get_id() query_data = {} @@ -1365,46 +1671,64 @@ def repository_tree(self, path='', ref='', **kwargs): return self.manager.gitlab.http_get(gl_path, query_data=query_data, **kwargs) + @exc.on_http_error(exc.GitlabGetError) def repository_blob(self, sha, **kwargs): - """Returns a blob by blob SHA. + """Return a blob by blob SHA. Args: sha(str): ID of the blob + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the server failed to perform the request Returns: - str: The blob as json + str: The blob metadata """ path = '/projects/%s/repository/blobs/%s' % (self.get_id(), sha) return self.manager.gitlab.http_get(path, **kwargs) + @exc.on_http_error(exc.GitlabGetError) def repository_raw_blob(self, sha, streamed=False, action=None, chunk_size=1024, **kwargs): - """Returns the raw file contents for a blob by blob SHA. + """Return the raw file contents for a blob. Args: sha(str): ID of the blob streamed (bool): If True the data will be processed by chunks of `chunk_size` and each chunk is passed to `action` for - treatment. + treatment action (callable): Callable responsible of dealing with chunk of - data. - chunk_size (int): Size of each chunk. + data + chunk_size (int): Size of each chunk + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the server failed to perform the request Returns: - str: The blob content + str: The blob content if streamed is False, None otherwise """ path = '/projects/%s/repository/blobs/%s/raw' % (self.get_id(), sha) result = self.manager.gitlab.http_get(path, streamed=streamed, **kwargs) return utils.response_content(result, streamed, action, chunk_size) + @exc.on_http_error(exc.GitlabGetError) def repository_compare(self, from_, to, **kwargs): - """Returns a diff between two branches/commits. + """Return a diff between two branches/commits. Args: - from_(str): orig branch/SHA - to(str): dest branch/SHA + from_(str): Source branch/SHA + to(str): Destination branch/SHA + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the server failed to perform the request Returns: str: The diff @@ -1414,8 +1738,16 @@ def repository_compare(self, from_, to, **kwargs): return self.manager.gitlab.http_get(path, query_data=query_data, **kwargs) + @exc.on_http_error(exc.GitlabGetError) def repository_contributors(self, **kwargs): - """Returns a list of contributors for the project. + """Return a list of contributors for the project. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the server failed to perform the request Returns: list: The contributors @@ -1423,21 +1755,27 @@ def repository_contributors(self, **kwargs): path = '/projects/%s/repository/contributors' % self.get_id() return self.manager.gitlab.http_get(path, **kwargs) + @exc.on_http_error(exc.GitlabListError) def repository_archive(self, sha=None, streamed=False, action=None, chunk_size=1024, **kwargs): """Return a tarball of the repository. Args: - sha (str): ID of the commit (default branch by default). + sha (str): ID of the commit (default branch by default) streamed (bool): If True the data will be processed by chunks of `chunk_size` and each chunk is passed to `action` for - treatment. + treatment action (callable): Callable responsible of dealing with chunk of - data. - chunk_size (int): Size of each chunk. + data + chunk_size (int): Size of each chunk + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the server failed to perform the request Returns: - str: The binary data of the archive. + str: The binary data of the archive """ path = '/projects/%s/repository/archive' % self.get_id() query_data = {} @@ -1447,66 +1785,107 @@ def repository_archive(self, sha=None, streamed=False, action=None, streamed=streamed, **kwargs) return utils.response_content(result, streamed, action, chunk_size) + @exc.on_http_error(exc.GitlabCreateError) def create_fork_relation(self, forked_from_id, **kwargs): """Create a forked from/to relation between existing projects. Args: forked_from_id (int): The ID of the project that was forked from + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabCreateError: If the relation could not be created """ path = '/projects/%s/fork/%s' % (self.get_id(), forked_from_id) self.manager.gitlab.http_post(path, **kwargs) + @exc.on_http_error(exc.GitlabDeleteError) def delete_fork_relation(self, **kwargs): - """Delete a forked relation between existing projects.""" + """Delete a forked relation between existing projects. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabDeleteError: If the server failed to perform the request + """ path = '/projects/%s/fork' % self.get_id() self.manager.gitlab.http_delete(path, **kwargs) + @exc.on_http_error(exc.GitlabCreateError) def star(self, **kwargs): """Star a project. - Returns: - Project: the updated Project + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabCreateError: If the server failed to perform the request """ path = '/projects/%s/star' % self.get_id() server_data = self.manager.gitlab.http_post(path, **kwargs) self._update_attrs(server_data) + @exc.on_http_error(exc.GitlabDeleteError) def unstar(self, **kwargs): """Unstar a project. - Returns: - Project: the updated Project + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabDeleteError: If the server failed to perform the request """ path = '/projects/%s/unstar' % self.get_id() server_data = self.manager.gitlab.http_post(path, **kwargs) self._update_attrs(server_data) + @exc.on_http_error(exc.GitlabCreateError) def archive(self, **kwargs): """Archive a project. - Returns: - Project: the updated Project + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabCreateError: If the server failed to perform the request """ path = '/projects/%s/archive' % self.get_id() server_data = self.manager.gitlab.http_post(path, **kwargs) self._update_attrs(server_data) + @exc.on_http_error(exc.GitlabDeleteError) def unarchive(self, **kwargs): """Unarchive a project. - Returns: - Project: the updated Project + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabDeleteError: If the server failed to perform the request """ path = '/projects/%s/unarchive' % self.get_id() server_data = self.manager.gitlab.http_post(path, **kwargs) self._update_attrs(server_data) + @exc.on_http_error(exc.GitlabCreateError) def share(self, group_id, group_access, expires_at=None, **kwargs): """Share the project with a group. Args: group_id (int): ID of the group. group_access (int): Access level for the group. + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabCreateError: If the server failed to perform the request """ path = '/projects/%s/share' % self.get_id() data = {'group_id': group_id, @@ -1514,6 +1893,7 @@ def share(self, group_id, group_access, expires_at=None, **kwargs): 'expires_at': expires_at} self.manager.gitlab.http_post(path, post_data=data, **kwargs) + @exc.on_http_error(exc.GitlabCreateError) def trigger_pipeline(self, ref, token, variables={}, **kwargs): """Trigger a CI build. @@ -1523,6 +1903,11 @@ def trigger_pipeline(self, ref, token, variables={}, **kwargs): ref (str): Commit to build; can be a commit SHA, a branch name, ... token (str): The trigger token variables (dict): Variables passed to the build script + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabCreateError: If the server failed to perform the request """ path = '/projects/%s/trigger/pipeline' % self.get_id() form = {r'variables[%s]' % k: v for k, v in six.iteritems(variables)} @@ -1541,12 +1926,18 @@ class RunnerManager(RetrieveMixin, UpdateMixin, DeleteMixin, RESTManager): _update_attrs = (tuple(), ('description', 'active', 'tag_list')) _list_filters = ('scope', ) + @exc.on_http_error(exc.GitlabListError) def all(self, scope=None, **kwargs): """List all the runners. Args: scope (str): The scope of runners to show, one of: specific, shared, active, paused, online + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the server failed to perform the request Returns: list(Runner): a list of runners matching the scope. @@ -1559,11 +1950,16 @@ def all(self, scope=None, **kwargs): class Todo(ObjectDeleteMixin, RESTObject): + @exc.on_http_error(exc.GitlabTodoError) def mark_as_done(self, **kwargs): """Mark the todo as done. Args: - **kwargs: Additional data to send to the server (e.g. sudo) + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabTodoError: If the server failed to perform the request """ path = '%s/%s/mark_as_done' % (self.manager.path, self.id) server_data = self.manager.gitlab.http_post(path, **kwargs) @@ -1575,13 +1971,25 @@ class TodoManager(GetFromListMixin, DeleteMixin, RESTManager): _obj_cls = Todo _list_filters = ('action', 'author_id', 'project_id', 'state', 'type') + @exc.on_http_error(exc.GitlabTodoError) def mark_all_as_done(self, **kwargs): """Mark all the todos as done. + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabTodoError: If the server failed to perform the request + Returns: - The number of todos maked done. + int: The number of todos maked done """ - self.gitlab.http_post('/todos/mark_as_done', **kwargs) + result = self.gitlab.http_post('/todos/mark_as_done', **kwargs) + try: + return int(result) + except ValueError: + return 0 class ProjectManager(CRUDMixin, RESTManager): @@ -1633,11 +2041,17 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject): ('issues', 'GroupIssueManager'), ) + @exc.on_http_error(exc.GitlabTransferProjectError) def transfer_project(self, id, **kwargs): - """Transfers a project to this group. + """Transfer a project to this group. Args: - id (int): ID of the project to transfer. + id (int): ID of the project to transfer + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabTransferProjectError: If the project could not be transfered """ path = '/groups/%d/projects/%d' % (self.id, id) self.manager.gitlab.http_post(path, **kwargs) From d7c79113a4dd4f23789ac8adb17add590929ae53 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Fri, 4 Aug 2017 10:25:40 +0200 Subject: [PATCH 54/93] functional tests for v4 Update the python tests for v4, and fix the problems raised when running those tests. --- docs/switching-to-v4.rst | 10 +- gitlab/__init__.py | 24 +- gitlab/mixins.py | 2 +- gitlab/v4/objects.py | 153 ++++++--- tools/build_test_env.sh | 2 +- tools/py_functional_tests.sh | 2 +- tools/{python_test.py => python_test_v3.py} | 0 tools/python_test_v4.py | 341 ++++++++++++++++++++ 8 files changed, 489 insertions(+), 45 deletions(-) rename tools/{python_test.py => python_test_v3.py} (100%) create mode 100644 tools/python_test_v4.py diff --git a/docs/switching-to-v4.rst b/docs/switching-to-v4.rst index fcec8a8ce..fb2b978cf 100644 --- a/docs/switching-to-v4.rst +++ b/docs/switching-to-v4.rst @@ -115,11 +115,17 @@ following important changes in the python API: + :attr:`~gitlab.Gitlab.http_put` + :attr:`~gitlab.Gitlab.http_delete` +* The users ``get_by_username`` method has been removed. It doesn't exist in + the GitLab API. You can use the ``username`` filter attribute when listing to + get a similar behavior: + + .. code-block:: python + + user = list(gl.users.list(username='jdoe'))[0] + Undergoing work =============== -* The ``delete()`` method for objects is not yet available. For now you need to - use ``manager.delete(obj.id)``. * The ``page`` and ``per_page`` arguments for listing don't behave as they used to. Their behavior will be restored. diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 6a55feed9..617f50ce2 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -645,12 +645,32 @@ def http_request(self, verb, path, query_data={}, post_data={}, Raises: GitlabHttpError: When the return code is not 2xx """ + + def sanitized_url(url): + parsed = six.moves.urllib.parse.urlparse(url) + new_path = parsed.path.replace('.', '%2E') + return parsed._replace(path=new_path).geturl() + url = self._build_url(path) params = query_data.copy() params.update(kwargs) opts = self._get_session_opts(content_type='application/json') - result = self.session.request(verb, url, json=post_data, - params=params, stream=streamed, **opts) + verify = opts.pop('verify') + timeout = opts.pop('timeout') + + # Requests assumes that `.` should not be encoded as %2E and will make + # changes to urls using this encoding. Using a prepped request we can + # get the desired behavior. + # The Requests behavior is right but it seems that web servers don't + # always agree with this decision (this is the case with a default + # gitlab installation) + req = requests.Request(verb, url, json=post_data, params=params, + **opts) + prepped = self.session.prepare_request(req) + prepped.url = sanitized_url(prepped.url) + result = self.session.send(prepped, stream=streamed, verify=verify, + timeout=timeout) + if 200 <= result.status_code < 300: return result diff --git a/gitlab/mixins.py b/gitlab/mixins.py index cc9eb5120..5876d588a 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -152,7 +152,7 @@ def create(self, data, **kwargs): **kwargs: Extra options to send to the Gitlab server (e.g. sudo) Returns: - RESTObject: a new instance of the managed object class build with + RESTObject: a new instance of the managed object class built with the data sent by the server Raises: diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 9de18ee93..b94d84add 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -126,7 +126,7 @@ class UserKey(ObjectDeleteMixin, RESTObject): class UserKeyManager(GetFromListMixin, CreateMixin, DeleteMixin, RESTManager): - _path = '/users/%(user_id)s/emails' + _path = '/users/%(user_id)s/keys' _obj_cls = UserKey _from_parent_attrs = {'user_id': 'id'} _create_attrs = (('title', 'key'), tuple()) @@ -842,8 +842,8 @@ def enable(self, key_id, **kwargs): GitlabAuthenticationError: If authentication is not correct GitlabProjectDeployKeyError: If the key could not be enabled """ - path = '%s/%s/enable' % (self.manager.path, key_id) - self.manager.gitlab.http_post(path, **kwargs) + path = '%s/%s/enable' % (self.path, key_id) + self.gitlab.http_post(path, **kwargs) class ProjectEvent(RESTObject): @@ -999,17 +999,19 @@ def set_release_description(self, description, **kwargs): data = {'description': description} if self.release is None: try: - result = self.manager.gitlab.http_post(path, post_data=data, - **kwargs) + server_data = self.manager.gitlab.http_post(path, + post_data=data, + **kwargs) except exc.GitlabHttpError as e: raise exc.GitlabCreateError(e.response_code, e.error_message) else: try: - result = self.manager.gitlab.http_put(path, post_data=data, - **kwargs) + server_data = self.manager.gitlab.http_put(path, + post_data=data, + **kwargs) except exc.GitlabHttpError as e: raise exc.GitlabUpdateError(e.response_code, e.error_message) - self.release = result.json() + self.release = server_data class ProjectTagManager(GetFromListMixin, CreateMixin, DeleteMixin, @@ -1223,8 +1225,7 @@ def merge_requests(self, **kwargs): return RESTObjectList(manager, ProjectMergeRequest, data_list) -class ProjectMilestoneManager(RetrieveMixin, CreateMixin, DeleteMixin, - RESTManager): +class ProjectMilestoneManager(CRUDMixin, RESTManager): _path = '/projects/%(project_id)s/milestones' _obj_cls = ProjectMilestone _from_parent_attrs = {'project_id': 'id'} @@ -1239,6 +1240,26 @@ class ProjectLabel(SubscribableMixin, SaveMixin, ObjectDeleteMixin, RESTObject): _id_attr = 'name' + # Update without ID, but we need an ID to get from list. + @exc.on_http_error(exc.GitlabUpdateError) + def save(self, **kwargs): + """Saves the changes made to the object to the server. + + The object is updated to match what the server returns. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct. + GitlabUpdateError: If the server cannot perform the request. + """ + updated_data = self._get_updated_data() + + # call the manager + server_data = self.manager.update(None, updated_data, **kwargs) + self._update_attrs(server_data) + class ProjectLabelManager(GetFromListMixin, CreateMixin, UpdateMixin, DeleteMixin, RESTManager): @@ -1262,27 +1283,7 @@ def delete(self, name, **kwargs): GitlabAuthenticationError: If authentication is not correct. GitlabDeleteError: If the server cannot perform the request. """ - self.gitlab.http_delete(path, query_data={'name': self.name}, **kwargs) - - # Update without ID, but we need an ID to get from list. - @exc.on_http_error(exc.GitlabUpdateError) - def save(self, **kwargs): - """Saves the changes made to the object to the server. - - The object is updated to match what the server returns. - - Args: - **kwargs: Extra options to send to the server (e.g. sudo) - - Raises: - GitlabAuthenticationError: If authentication is not correct. - GitlabUpdateError: If the server cannot perform the request. - """ - updated_data = self._get_updated_data() - - # call the manager - server_data = self.manager.update(None, updated_data, **kwargs) - self._update_attrs(server_data) + self.gitlab.http_delete(self.path, query_data={'name': name}, **kwargs) class ProjectFile(SaveMixin, ObjectDeleteMixin, RESTObject): @@ -1297,6 +1298,38 @@ def decode(self): """ return base64.b64decode(self.content) + def save(self, branch, commit_message, **kwargs): + """Save the changes made to the file to the server. + + The object is updated to match what the server returns. + + Args: + branch (str): Branch in which the file will be updated + commit_message (str): Message to send with the commit + **kwargs: Extra options to send to the server (e.g. sudo) + + Raise: + GitlabAuthenticationError: If authentication is not correct + GitlabUpdateError: If the server cannot perform the request + """ + self.branch = branch + self.commit_message = commit_message + super(ProjectFile, self).save(**kwargs) + + def delete(self, branch, commit_message, **kwargs): + """Delete the file from the server. + + Args: + branch (str): Branch from which the file will be removed + commit_message (str): Commit message for the deletion + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabDeleteError: If the server cannot perform the request + """ + self.manager.delete(self.get_id(), branch, commit_message, **kwargs) + class ProjectFileManager(GetMixin, CreateMixin, UpdateMixin, DeleteMixin, RESTManager): @@ -1308,11 +1341,12 @@ class ProjectFileManager(GetMixin, CreateMixin, UpdateMixin, DeleteMixin, _update_attrs = (('file_path', 'branch', 'content', 'commit_message'), ('encoding', 'author_email', 'author_name')) - def get(self, file_path, **kwargs): + def get(self, file_path, ref, **kwargs): """Retrieve a single file. Args: - id (int or str): ID of the object to retrieve + file_path (str): Path of the file to retrieve + ref (str): Name of the branch, tag or commit **kwargs: Extra options to send to the Gitlab server (e.g. sudo) Raises: @@ -1323,7 +1357,49 @@ def get(self, file_path, **kwargs): object: The generated RESTObject """ file_path = file_path.replace('/', '%2F') - return GetMixin.get(self, file_path, **kwargs) + return GetMixin.get(self, file_path, ref=ref, **kwargs) + + @exc.on_http_error(exc.GitlabCreateError) + def create(self, data, **kwargs): + """Create a new object. + + Args: + data (dict): parameters to send to the server to create the + resource + **kwargs: Extra options to send to the Gitlab server (e.g. sudo) + + Returns: + RESTObject: a new instance of the managed object class built with + the data sent by the server + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabCreateError: If the server cannot perform the request + """ + + self._check_missing_create_attrs(data) + file_path = data.pop('file_path') + path = '%s/%s' % (self.path, file_path) + server_data = self.gitlab.http_post(path, post_data=data, **kwargs) + return self._obj_cls(self, server_data) + + @exc.on_http_error(exc.GitlabDeleteError) + def delete(self, file_path, branch, commit_message, **kwargs): + """Delete a file on the server. + + Args: + file_path (str): Path of the file to remove + branch (str): Branch from which the file will be removed + commit_message (str): Commit message for the deletion + **kwargs: Extra options to send to the Gitlab server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabDeleteError: If the server cannot perform the request + """ + path = '%s/%s' % (self.path, file_path.replace('/', '%2F')) + data = {'branch': branch, 'commit_message': commit_message} + self.gitlab.http_delete(path, query_data=data, **kwargs) @exc.on_http_error(exc.GitlabGetError) def raw(self, file_path, ref, streamed=False, action=None, chunk_size=1024, @@ -1348,7 +1424,7 @@ def raw(self, file_path, ref, streamed=False, action=None, chunk_size=1024, Returns: str: The file content """ - file_path = file_path.replace('/', '%2F') + file_path = file_path.replace('/', '%2F').replace('.', '%2E') path = '%s/%s/raw' % (self.path, file_path) query_data = {'ref': ref} result = self.gitlab.http_get(path, query_data=query_data, @@ -1489,8 +1565,8 @@ class ProjectVariableManager(CRUDMixin, RESTManager): _path = '/projects/%(project_id)s/variables' _obj_cls = ProjectVariable _from_parent_attrs = {'project_id': 'id'} - _create_attrs = (('key', 'vaule'), tuple()) - _update_attrs = (('key', 'vaule'), tuple()) + _create_attrs = (('key', 'value'), tuple()) + _update_attrs = (('key', 'value'), tuple()) class ProjectService(GitlabObject): @@ -2016,7 +2092,8 @@ class ProjectManager(CRUDMixin, RESTManager): 'request_access_enabled') ) _list_filters = ('search', 'owned', 'starred', 'archived', 'visibility', - 'order_by', 'sort', 'simple', 'membership', 'statistics') + 'order_by', 'sort', 'simple', 'membership', 'statistics', + 'with_issues_enabled', 'with_merge_requests_enabled') class GroupProject(Project): diff --git a/tools/build_test_env.sh b/tools/build_test_env.sh index 96d341a9a..35a54c6ef 100755 --- a/tools/build_test_env.sh +++ b/tools/build_test_env.sh @@ -154,6 +154,6 @@ log "Installing into virtualenv..." try pip install -e . log "Pausing to give GitLab some time to finish starting up..." -sleep 20 +sleep 30 log "Test environment initialized." diff --git a/tools/py_functional_tests.sh b/tools/py_functional_tests.sh index 0d00c5fdf..75bb7613d 100755 --- a/tools/py_functional_tests.sh +++ b/tools/py_functional_tests.sh @@ -18,4 +18,4 @@ setenv_script=$(dirname "$0")/build_test_env.sh || exit 1 BUILD_TEST_ENV_AUTO_CLEANUP=true . "$setenv_script" "$@" || exit 1 -try python "$(dirname "$0")"/python_test.py +try python "$(dirname "$0")"/python_test_v${API_VER}.py diff --git a/tools/python_test.py b/tools/python_test_v3.py similarity index 100% rename from tools/python_test.py rename to tools/python_test_v3.py diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py new file mode 100644 index 000000000..ec3f0d353 --- /dev/null +++ b/tools/python_test_v4.py @@ -0,0 +1,341 @@ +import base64 +import time + +import gitlab + +LOGIN = 'root' +PASSWORD = '5iveL!fe' + +SSH_KEY = ("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDZAjAX8vTiHD7Yi3/EzuVaDChtih" + "79HyJZ6H9dEqxFfmGA1YnncE0xujQ64TCebhkYJKzmTJCImSVkOu9C4hZgsw6eE76n" + "+Cg3VwEeDUFy+GXlEJWlHaEyc3HWioxgOALbUp3rOezNh+d8BDwwqvENGoePEBsz5l" + "a6WP5lTi/HJIjAl6Hu+zHgdj1XVExeH+S52EwpZf/ylTJub0Bl5gHwf/siVE48mLMI" + "sqrukXTZ6Zg+8EHAIvIQwJ1dKcXe8P5IoLT7VKrbkgAnolS0I8J+uH7KtErZJb5oZh" + "S4OEwsNpaXMAr+6/wWSpircV2/e7sFLlhlKBC4Iq1MpqlZ7G3p foo@bar") +DEPLOY_KEY = ("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDFdRyjJQh+1niBpXqE2I8dzjG" + "MXFHlRjX9yk/UfOn075IdaockdU58sw2Ai1XIWFpZpfJkW7z+P47ZNSqm1gzeXI" + "rtKa9ZUp8A7SZe8vH4XVn7kh7bwWCUirqtn8El9XdqfkzOs/+FuViriUWoJVpA6" + "WZsDNaqINFKIA5fj/q8XQw+BcS92L09QJg9oVUuH0VVwNYbU2M2IRmSpybgC/gu" + "uWTrnCDMmLItksATifLvRZwgdI8dr+q6tbxbZknNcgEPrI2jT0hYN9ZcjNeWuyv" + "rke9IepE7SPBT41C+YtUX4dfDZDmczM1cE0YL/krdUCfuZHMa4ZS2YyNd6slufc" + "vn bar@foo") + +# login/password authentication +gl = gitlab.Gitlab('http://localhost:8080', email=LOGIN, password=PASSWORD) +gl.auth() +token_from_auth = gl.private_token + +# token authentication from config file +gl = gitlab.Gitlab.from_config(config_files=['/tmp/python-gitlab.cfg']) +assert(token_from_auth == gl.private_token) +gl.auth() +assert(isinstance(gl.user, gitlab.v4.objects.CurrentUser)) + +# settings +settings = gl.settings.get() +settings.default_projects_limit = 42 +settings.save() +settings = gl.settings.get() +assert(settings.default_projects_limit == 42) + +# user manipulations +new_user = gl.users.create({'email': 'foo@bar.com', 'username': 'foo', + 'name': 'foo', 'password': 'foo_password'}) +users_list = gl.users.list() +for user in users_list: + if user.username == 'foo': + break +assert(new_user.username == user.username) +assert(new_user.email == user.email) + +new_user.block() +new_user.unblock() + +foobar_user = gl.users.create( + {'email': 'foobar@example.com', 'username': 'foobar', + 'name': 'Foo Bar', 'password': 'foobar_password'}) + +assert gl.users.list(search='foobar').next().id == foobar_user.id +usercmp = lambda x,y: cmp(x.id, y.id) +expected = sorted([new_user, foobar_user], cmp=usercmp) +actual = sorted(list(gl.users.list(search='foo')), cmp=usercmp) +assert len(expected) == len(actual) +assert len(gl.users.list(search='asdf')) == 0 + +# SSH keys +key = new_user.keys.create({'title': 'testkey', 'key': SSH_KEY}) +assert(len(new_user.keys.list()) == 1) +key.delete() +assert(len(new_user.keys.list()) == 0) + +# emails +email = new_user.emails.create({'email': 'foo2@bar.com'}) +assert(len(new_user.emails.list()) == 1) +email.delete() +assert(len(new_user.emails.list()) == 0) + +new_user.delete() +foobar_user.delete() +assert(len(gl.users.list()) == 3) + +# current user key +key = gl.user.keys.create({'title': 'testkey', 'key': SSH_KEY}) +assert(len(gl.user.keys.list()) == 1) +key.delete() +assert(len(gl.user.keys.list()) == 0) + +# groups +user1 = gl.users.create({'email': 'user1@test.com', 'username': 'user1', + 'name': 'user1', 'password': 'user1_pass'}) +user2 = gl.users.create({'email': 'user2@test.com', 'username': 'user2', + 'name': 'user2', 'password': 'user2_pass'}) +group1 = gl.groups.create({'name': 'group1', 'path': 'group1'}) +group2 = gl.groups.create({'name': 'group2', 'path': 'group2'}) + +p_id = gl.groups.list(search='group2').next().id +group3 = gl.groups.create({'name': 'group3', 'path': 'group3', 'parent_id': p_id}) + +assert(len(gl.groups.list()) == 3) +assert(len(gl.groups.list(search='1')) == 1) +assert(group3.parent_id == p_id) + +group1.members.create({'access_level': gitlab.Group.OWNER_ACCESS, + 'user_id': user1.id}) +group1.members.create({'access_level': gitlab.Group.GUEST_ACCESS, + 'user_id': user2.id}) + +group2.members.create({'access_level': gitlab.Group.OWNER_ACCESS, + 'user_id': user2.id}) + +# Administrator belongs to the groups +assert(len(group1.members.list()) == 3) +assert(len(group2.members.list()) == 2) + +group1.members.delete(user1.id) +assert(len(group1.members.list()) == 2) +member = group1.members.get(user2.id) +member.access_level = gitlab.Group.OWNER_ACCESS +member.save() +member = group1.members.get(user2.id) +assert(member.access_level == gitlab.Group.OWNER_ACCESS) + +group2.members.delete(gl.user.id) + +# hooks +hook = gl.hooks.create({'url': 'http://whatever.com'}) +assert(len(gl.hooks.list()) == 1) +hook.delete() +assert(len(gl.hooks.list()) == 0) + +# projects +admin_project = gl.projects.create({'name': 'admin_project'}) +gr1_project = gl.projects.create({'name': 'gr1_project', + 'namespace_id': group1.id}) +gr2_project = gl.projects.create({'name': 'gr2_project', + 'namespace_id': group2.id}) +sudo_project = gl.projects.create({'name': 'sudo_project'}, sudo=user1.name) + +assert(len(gl.projects.list(owned=True)) == 2) +assert(len(gl.projects.list(search="admin")) == 1) + +# test pagination +# FIXME => we should return lists, not RESTObjectList +#l1 = gl.projects.list(per_page=1, page=1) +#l2 = gl.projects.list(per_page=1, page=2) +#assert(len(l1) == 1) +#assert(len(l2) == 1) +#assert(l1[0].id != l2[0].id) + +# project content (files) +admin_project.files.create({'file_path': 'README', + 'branch': 'master', + 'content': 'Initial content', + 'commit_message': 'Initial commit'}) +readme = admin_project.files.get(file_path='README', ref='master') +readme.content = base64.b64encode("Improved README") +time.sleep(2) +readme.save(branch="master", commit_message="new commit") +readme.delete(commit_message="Removing README", branch="master") + +admin_project.files.create({'file_path': 'README.rst', + 'branch': 'master', + 'content': 'Initial content', + 'commit_message': 'New commit'}) +readme = admin_project.files.get(file_path='README.rst', ref='master') +assert(readme.decode() == 'Initial content') + +data = { + 'branch': 'master', + 'commit_message': 'blah blah blah', + 'actions': [ + { + 'action': 'create', + 'file_path': 'blah', + 'content': 'blah' + } + ] +} +admin_project.commits.create(data) + +tree = admin_project.repository_tree() +assert(len(tree) == 2) +assert(tree[0]['name'] == 'README.rst') +blob_id = tree[0]['id'] +blob = admin_project.repository_raw_blob(blob_id) +assert(blob == 'Initial content') +archive1 = admin_project.repository_archive() +archive2 = admin_project.repository_archive('master') +assert(archive1 == archive2) + +# deploy keys +deploy_key = admin_project.keys.create({'title': 'foo@bar', 'key': DEPLOY_KEY}) +project_keys = list(admin_project.keys.list()) +assert(len(project_keys) == 1) + +sudo_project.keys.enable(deploy_key.id) +assert(len(sudo_project.keys.list()) == 1) +sudo_project.keys.delete(deploy_key.id) +assert(len(sudo_project.keys.list()) == 0) + +# labels +label1 = admin_project.labels.create({'name': 'label1', 'color': '#778899'}) +label1 = admin_project.labels.get('label1') +assert(len(admin_project.labels.list()) == 1) +label1.new_name = 'label1updated' +label1.save() +assert(label1.name == 'label1updated') +label1.subscribe() +assert(label1.subscribed == True) +label1.unsubscribe() +assert(label1.subscribed == False) +label1.delete() + +# milestones +m1 = admin_project.milestones.create({'title': 'milestone1'}) +assert(len(admin_project.milestones.list()) == 1) +m1.due_date = '2020-01-01T00:00:00Z' +m1.save() +m1.state_event = 'close' +m1.save() +m1 = admin_project.milestones.get(1) +assert(m1.state == 'closed') + +# issues +issue1 = admin_project.issues.create({'title': 'my issue 1', + 'milestone_id': m1.id}) +issue2 = admin_project.issues.create({'title': 'my issue 2'}) +issue3 = admin_project.issues.create({'title': 'my issue 3'}) +assert(len(admin_project.issues.list()) == 3) +issue3.state_event = 'close' +issue3.save() +assert(len(admin_project.issues.list(state='closed')) == 1) +assert(len(admin_project.issues.list(state='opened')) == 2) +assert(len(admin_project.issues.list(milestone='milestone1')) == 1) +assert(m1.issues().next().title == 'my issue 1') + +# tags +tag1 = admin_project.tags.create({'tag_name': 'v1.0', 'ref': 'master'}) +assert(len(admin_project.tags.list()) == 1) +tag1.set_release_description('Description 1') +tag1.set_release_description('Description 2') +assert(tag1.release['description'] == 'Description 2') +tag1.delete() + +# triggers +tr1 = admin_project.triggers.create({'description': 'trigger1'}) +assert(len(admin_project.triggers.list()) == 1) +tr1.delete() + +# variables +v1 = admin_project.variables.create({'key': 'key1', 'value': 'value1'}) +assert(len(admin_project.variables.list()) == 1) +v1.value = 'new_value1' +v1.save() +v1 = admin_project.variables.get(v1.key) +assert(v1.value == 'new_value1') +v1.delete() + +# branches and merges +to_merge = admin_project.branches.create({'branch': 'branch1', + 'ref': 'master'}) +admin_project.files.create({'file_path': 'README2.rst', + 'branch': 'branch1', + 'content': 'Initial content', + 'commit_message': 'New commit in new branch'}) +mr = admin_project.mergerequests.create({'source_branch': 'branch1', + 'target_branch': 'master', + 'title': 'MR readme2'}) +mr.merge() +admin_project.branches.delete('branch1') + +try: + mr.merge() +except gitlab.GitlabMRClosedError: + pass + +# stars +admin_project.star() +assert(admin_project.star_count == 1) +admin_project.unstar() +assert(admin_project.star_count == 0) + +# project boards +#boards = admin_project.boards.list() +#assert(len(boards)) +#board = boards[0] +#lists = board.lists.list() +#begin_size = len(lists) +#last_list = lists[-1] +#last_list.position = 0 +#last_list.save() +#last_list.delete() +#lists = board.lists.list() +#assert(len(lists) == begin_size - 1) + +# namespaces +ns = gl.namespaces.list(all=True) +assert(len(ns) != 0) +ns = gl.namespaces.list(search='root', all=True)[0] +assert(ns.kind == 'user') + +# broadcast messages +msg = gl.broadcastmessages.create({'message': 'this is the message'}) +msg.color = '#444444' +msg.save() +msg = gl.broadcastmessages.list(all=True)[0] +assert(msg.color == '#444444') +msg = gl.broadcastmessages.get(1) +assert(msg.color == '#444444') +msg.delete() +assert(len(gl.broadcastmessages.list()) == 0) + +# notification settings +settings = gl.notificationsettings.get() +settings.level = gitlab.NOTIFICATION_LEVEL_WATCH +settings.save() +settings = gl.notificationsettings.get() +assert(settings.level == gitlab.NOTIFICATION_LEVEL_WATCH) + +# services +# NOT IMPLEMENTED YET +#service = admin_project.services.get(service_name='asana') +#service.active = True +#service.api_key = 'whatever' +#service.save() +#service = admin_project.services.get(service_name='asana') +#assert(service.active == True) + +# snippets +snippets = gl.snippets.list(all=True) +assert(len(snippets) == 0) +snippet = gl.snippets.create({'title': 'snippet1', 'file_name': 'snippet1.py', + 'content': 'import gitlab'}) +snippet = gl.snippets.get(1) +snippet.title = 'updated_title' +snippet.save() +snippet = gl.snippets.get(1) +assert(snippet.title == 'updated_title') +content = snippet.content() +assert(content == 'import gitlab') +snippet.delete() +assert(len(gl.snippets.list()) == 0) From 5a4aafb6ec7a3927551f2ce79425c60c399addd5 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Fri, 4 Aug 2017 11:10:48 +0200 Subject: [PATCH 55/93] Restore the prvious listing behavior Return lists by default : this makes the explicit use of pagination work again. Use generators only when `as_list` is explicitly set to `False`. --- docs/switching-to-v4.rst | 23 ++++------------------- gitlab/__init__.py | 35 +++++++++++++++++++++++++---------- gitlab/mixins.py | 14 +++++++------- gitlab/v4/objects.py | 17 +++++++++++++---- tools/python_test_v4.py | 15 +++++++-------- 5 files changed, 56 insertions(+), 48 deletions(-) diff --git a/docs/switching-to-v4.rst b/docs/switching-to-v4.rst index fb2b978cf..84181ffb2 100644 --- a/docs/switching-to-v4.rst +++ b/docs/switching-to-v4.rst @@ -63,15 +63,15 @@ following important changes in the python API: gl = gitlab.Gitlab(...) p = gl.projects.get(project_id) -* Listing methods (``manager.list()`` for instance) now return generators +* Listing methods (``manager.list()`` for instance) can now return generators (:class:`~gitlab.base.RESTObjectList`). They handle the calls to the API when - needed. + needed to fetch new items. - If you need to get all the items at once, use the ``all=True`` parameter: + By default you will still get lists. To get generators use ``as_list=False``: .. code-block:: python - all_projects = gl.projects.list(all=True) + all_projects_g = gl.projects.list(as_list=False) * The "nested" managers (for instance ``gl.project_issues`` or ``gl.group_members``) are not available anymore. Their goal was to provide a @@ -114,18 +114,3 @@ following important changes in the python API: + :attr:`~gitlab.Gitlab.http_post` + :attr:`~gitlab.Gitlab.http_put` + :attr:`~gitlab.Gitlab.http_delete` - -* The users ``get_by_username`` method has been removed. It doesn't exist in - the GitLab API. You can use the ``username`` filter attribute when listing to - get a similar behavior: - - .. code-block:: python - - user = list(gl.users.list(username='jdoe'))[0] - - -Undergoing work -=============== - -* The ``page`` and ``per_page`` arguments for listing don't behave as they used - to. Their behavior will be restored. diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 617f50ce2..bdeb5c4a2 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -712,7 +712,7 @@ def http_get(self, path, query_data={}, streamed=False, **kwargs): else: return result - def http_list(self, path, query_data={}, **kwargs): + def http_list(self, path, query_data={}, as_list=None, **kwargs): """Make a GET request to the Gitlab server for list-oriented queries. Args: @@ -723,19 +723,33 @@ def http_list(self, path, query_data={}, **kwargs): all) Returns: - GitlabList: A generator giving access to the objects. If an ``all`` - kwarg is defined and True, returns a list of all the objects (will - possibly make numerous calls to the Gtilab server and eat a lot of - memory) + list: A list of the objects returned by the server. If `as_list` is + False 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. Raises: GitlabHttpError: When the return code is not 2xx GitlabParsingError: If the json data could not be parsed """ + + # In case we want to change the default behavior at some point + as_list = True if as_list is None else as_list + + get_all = kwargs.get('all', False) url = self._build_url(path) - get_all = kwargs.pop('all', False) - obj_gen = GitlabList(self, url, query_data, **kwargs) - return list(obj_gen) if get_all else obj_gen + + 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: + # pagination requested, we return a list + return list(GitlabList(self, url, query_data, get_next=False, + **kwargs)) + + # No pagination, generator requested + return GitlabList(self, url, query_data, **kwargs) def http_post(self, path, query_data={}, post_data={}, **kwargs): """Make a POST request to the Gitlab server. @@ -816,9 +830,10 @@ class GitlabList(object): the API again when needed. """ - def __init__(self, gl, url, query_data, **kwargs): + def __init__(self, gl, url, query_data, get_next=True, **kwargs): self._gl = gl self._query(url, query_data, **kwargs) + self._get_next = get_next def _query(self, url, query_data={}, **kwargs): result = self._gl.http_request('get', url, query_data=query_data, @@ -856,7 +871,7 @@ def next(self): self._current += 1 return item except IndexError: - if self._next_url: + if self._next_url and self._get_next is True: self._query(self._next_url) return self.next() diff --git a/gitlab/mixins.py b/gitlab/mixins.py index 5876d588a..4fc21fb2f 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -71,15 +71,15 @@ def list(self, **kwargs): """Retrieve a list of objects. Args: - **kwargs: Extra options to send to the Gitlab server (e.g. sudo). - If ``all`` is passed and set to True, the entire list of - objects will be returned. + 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: - RESTObjectList: Generator going through the list of objects, making - queries to the server when required. - If ``all=True`` is passed as argument, returns - list(RESTObjectList). + list: The list of objects, or a generator if `as_list` is False Raises: GitlabAuthenticationError: If authentication is not correct diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index b94d84add..0a60924f5 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -1087,7 +1087,8 @@ def closes_issues(self, **kwargs): RESTObjectList: List of issues """ path = '%s/%s/closes_issues' % (self.manager.path, self.get_id()) - data_list = self.manager.gitlab.http_list(path, **kwargs) + data_list = self.manager.gitlab.http_list(path, as_list=False, + **kwargs) manager = ProjectIssueManager(self.manager.gitlab, parent=self.manager._parent) return RESTObjectList(manager, ProjectIssue, data_list) @@ -1108,7 +1109,8 @@ def commits(self, **kwargs): """ path = '%s/%s/commits' % (self.manager.path, self.get_id()) - data_list = self.manager.gitlab.http_list(path, **kwargs) + data_list = self.manager.gitlab.http_list(path, as_list=False, + **kwargs) manager = ProjectCommitManager(self.manager.gitlab, parent=self.manager._parent) return RESTObjectList(manager, ProjectCommit, data_list) @@ -1197,7 +1199,8 @@ def issues(self, **kwargs): """ path = '%s/%s/issues' % (self.manager.path, self.get_id()) - data_list = self.manager.gitlab.http_list(path, **kwargs) + data_list = self.manager.gitlab.http_list(path, as_list=False, + **kwargs) manager = ProjectCommitManager(self.manager.gitlab, parent=self.manager._parent) # FIXME(gpocentek): the computed manager path is not correct @@ -1218,7 +1221,8 @@ def merge_requests(self, **kwargs): RESTObjectList: The list of merge requests """ path = '%s/%s/merge_requests' % (self.manager.path, self.get_id()) - data_list = self.manager.gitlab.http_list(path, **kwargs) + data_list = self.manager.gitlab.http_list(path, as_list=False, + **kwargs) manager = ProjectCommitManager(self.manager.gitlab, parent=self.manager._parent) # FIXME(gpocentek): the computed manager path is not correct @@ -2009,6 +2013,11 @@ def all(self, scope=None, **kwargs): Args: scope (str): The scope of runners to show, one of: specific, shared, active, paused, online + 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: diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py index ec3f0d353..08ee0aa0d 100644 --- a/tools/python_test_v4.py +++ b/tools/python_test_v4.py @@ -55,7 +55,7 @@ {'email': 'foobar@example.com', 'username': 'foobar', 'name': 'Foo Bar', 'password': 'foobar_password'}) -assert gl.users.list(search='foobar').next().id == foobar_user.id +assert gl.users.list(search='foobar')[0].id == foobar_user.id usercmp = lambda x,y: cmp(x.id, y.id) expected = sorted([new_user, foobar_user], cmp=usercmp) actual = sorted(list(gl.users.list(search='foo')), cmp=usercmp) @@ -92,7 +92,7 @@ group1 = gl.groups.create({'name': 'group1', 'path': 'group1'}) group2 = gl.groups.create({'name': 'group2', 'path': 'group2'}) -p_id = gl.groups.list(search='group2').next().id +p_id = gl.groups.list(search='group2')[0].id group3 = gl.groups.create({'name': 'group3', 'path': 'group3', 'parent_id': p_id}) assert(len(gl.groups.list()) == 3) @@ -139,12 +139,11 @@ assert(len(gl.projects.list(search="admin")) == 1) # test pagination -# FIXME => we should return lists, not RESTObjectList -#l1 = gl.projects.list(per_page=1, page=1) -#l2 = gl.projects.list(per_page=1, page=2) -#assert(len(l1) == 1) -#assert(len(l2) == 1) -#assert(l1[0].id != l2[0].id) +l1 = gl.projects.list(per_page=1, page=1) +l2 = gl.projects.list(per_page=1, page=2) +assert(len(l1) == 1) +assert(len(l2) == 1) +assert(l1[0].id != l2[0].id) # project content (files) admin_project.files.create({'file_path': 'README', From 217dc3e84c8f4625686e27e1b5e498a49af1a11f Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Fri, 4 Aug 2017 12:33:47 +0200 Subject: [PATCH 56/93] remove py3.6 from travis tests --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 7c8b9fdc8..dd405f523 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,7 +10,6 @@ addons: language: python python: 2.7 env: - - TOX_ENV=py36 - TOX_ENV=py35 - TOX_ENV=py34 - TOX_ENV=py27 From 7592ec5f6948994b8f8d9e82e2ca52c05f17829d Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Fri, 4 Aug 2017 12:46:45 +0200 Subject: [PATCH 57/93] Update tests for list() changes --- gitlab/tests/test_gitlab.py | 11 ++++++++--- gitlab/tests/test_mixins.py | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/gitlab/tests/test_gitlab.py b/gitlab/tests/test_gitlab.py index d642eaf42..6bc427df7 100644 --- a/gitlab/tests/test_gitlab.py +++ b/gitlab/tests/test_gitlab.py @@ -205,7 +205,7 @@ def resp_2(url, request): return response(200, content, headers, None, 5, request) with HTTMock(resp_1): - obj = self.gl.http_list('/tests') + obj = self.gl.http_list('/tests', as_list=False) self.assertEqual(len(obj), 2) self.assertEqual(obj._next_url, 'http://localhost/api/v4/tests?per_page=1&page=2') @@ -311,7 +311,12 @@ def resp_cont(url, request): return response(200, content, headers, None, 5, request) with HTTMock(resp_cont): - result = self.gl.http_list('/projects') + result = self.gl.http_list('/projects', as_list=True) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + + with HTTMock(resp_cont): + result = self.gl.http_list('/projects', as_list=False) self.assertIsInstance(result, GitlabList) self.assertEqual(len(result), 1) @@ -324,7 +329,7 @@ def test_list_request_404(self): @urlmatch(scheme="http", netloc="localhost", path="/api/v4/not_there", method="get") def resp_cont(url, request): - content = {'Here is wh it failed'} + content = {'Here is why it failed'} return response(404, content, {}, None, 5, request) with HTTMock(resp_cont): diff --git a/gitlab/tests/test_mixins.py b/gitlab/tests/test_mixins.py index de853d7cc..812a118b6 100644 --- a/gitlab/tests/test_mixins.py +++ b/gitlab/tests/test_mixins.py @@ -178,7 +178,7 @@ def resp_cont(url, request): with HTTMock(resp_cont): # test RESTObjectList mgr = M(self.gl) - obj_list = mgr.list() + obj_list = mgr.list(as_list=False) self.assertIsInstance(obj_list, base.RESTObjectList) for obj in obj_list: self.assertIsInstance(obj, FakeObject) @@ -205,7 +205,7 @@ def resp_cont(url, request): with HTTMock(resp_cont): mgr = M(self.gl) - obj_list = mgr.list(path='/others') + obj_list = mgr.list(path='/others', as_list=False) self.assertIsInstance(obj_list, base.RESTObjectList) obj = obj_list.next() self.assertEqual(obj.id, 42) From eee39a3a5f1ef3bccc45b0f23009531a9bf76801 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Fri, 4 Aug 2017 14:38:01 +0200 Subject: [PATCH 58/93] Fix v3 tests --- tools/python_test_v3.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/python_test_v3.py b/tools/python_test_v3.py index 62d64213a..a730f77fe 100644 --- a/tools/python_test_v3.py +++ b/tools/python_test_v3.py @@ -55,15 +55,15 @@ {'email': 'foobar@example.com', 'username': 'foobar', 'name': 'Foo Bar', 'password': 'foobar_password'}) -assert gl.users.search('foobar') == [foobar_user] +assert(gl.users.search('foobar')[0].id == foobar_user.id) usercmp = lambda x,y: cmp(x.id, y.id) expected = sorted([new_user, foobar_user], cmp=usercmp) actual = sorted(gl.users.search('foo'), cmp=usercmp) -assert expected == actual -assert gl.users.search('asdf') == [] +assert len(expected) == len(actual) +assert len(gl.users.search('asdf')) == 0 -assert gl.users.get_by_username('foobar') == foobar_user -assert gl.users.get_by_username('foo') == new_user +assert gl.users.get_by_username('foobar').id == foobar_user.id +assert gl.users.get_by_username('foo').id == new_user.id try: gl.users.get_by_username('asdf') except gitlab.GitlabGetError: From 2816c1ae51b01214012679b74aa14de1a6696eb5 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Fri, 4 Aug 2017 15:33:31 +0200 Subject: [PATCH 59/93] Make the project services work in v4 --- gitlab/mixins.py | 3 +- gitlab/v4/objects.py | 88 +++++++++++++++++++++-------------------- tools/python_test_v4.py | 15 +++---- 3 files changed, 56 insertions(+), 50 deletions(-) diff --git a/gitlab/mixins.py b/gitlab/mixins.py index 4fc21fb2f..9dd05af80 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -274,7 +274,8 @@ def save(self, **kwargs): # call the manager obj_id = self.get_id() server_data = self.manager.update(obj_id, updated_data, **kwargs) - self._update_attrs(server_data) + if server_data is not None: + self._update_attrs(server_data) class ObjectDeleteMixin(object): diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 0a60924f5..49ccc9dc1 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -18,7 +18,6 @@ from __future__ import print_function from __future__ import absolute_import import base64 -import json import six @@ -1573,14 +1572,14 @@ class ProjectVariableManager(CRUDMixin, RESTManager): _update_attrs = (('key', 'value'), tuple()) -class ProjectService(GitlabObject): - _url = '/projects/%(project_id)s/services/%(service_name)s' - canList = False - canCreate = False - _id_in_update_url = False - _id_in_delete_url = False - getRequiresId = False - requiredUrlAttrs = ['project_id', 'service_name'] +class ProjectService(SaveMixin, ObjectDeleteMixin, RESTObject): + pass + + +class ProjectServiceManager(GetMixin, UpdateMixin, DeleteMixin, RESTManager): + _path = '/projects/%(project_id)s/services' + _from_parent_attrs = {'project_id': 'id'} + _obj_cls = ProjectService _service_attrs = { 'asana': (('api_key', ), ('restrict_to_branch', )), @@ -1606,16 +1605,10 @@ class ProjectService(GitlabObject): 'server')), 'irker': (('recipients', ), ('default_irc_uri', 'server_port', 'server_host', 'colorize_messages')), - 'jira': (tuple(), ( - # Required fields in GitLab >= 8.14 - 'url', 'project_key', - - # Required fields in GitLab < 8.14 - 'new_issue_url', 'project_url', 'issues_url', 'api_url', - 'description', - - # Optional fields - 'username', 'password', 'jira_issue_transition_id')), + 'jira': (('url', 'project_key'), + ('new_issue_url', 'project_url', 'issues_url', 'api_url', + 'description', 'username', 'password', + 'jira_issue_transition_id')), 'pivotaltracker': (('token', ), tuple()), 'pushover': (('api_key', 'user_key', 'priority'), ('device', 'sound')), 'redmine': (('new_issue_url', 'project_url', 'issues_url'), @@ -1625,33 +1618,44 @@ class ProjectService(GitlabObject): tuple()) } - def _data_for_gitlab(self, extra_parameters={}, update=False, - as_json=True): - data = (super(ProjectService, self) - ._data_for_gitlab(extra_parameters, update=update, - as_json=False)) - missing = [] - # Mandatory args - for attr in self._service_attrs[self.service_name][0]: - if not hasattr(self, attr): - missing.append(attr) - else: - data[attr] = getattr(self, attr) + def get(self, id, **kwargs): + """Retrieve a single object. - if missing: - raise GitlabUpdateError('Missing attribute(s): %s' % - ", ".join(missing)) + Args: + id (int or str): ID of the object to retrieve + lazy (bool): If True, don't request the server, but create a + shallow object giving access to the managers. This is + useful if you want to avoid useless calls to the API. + **kwargs: Extra options to send to the Gitlab server (e.g. sudo) - # Optional args - for attr in self._service_attrs[self.service_name][1]: - if hasattr(self, attr): - data[attr] = getattr(self, attr) + Returns: + object: The generated RESTObject. - return json.dumps(data) + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the server cannot perform the request + """ + obj = super(ProjectServiceManager, self).get(id, **kwargs) + obj.id = id + return obj + + def update(self, id=None, new_data={}, **kwargs): + """Update an object on the server. + + Args: + id: ID of the object to update (can be None if not required) + new_data: the update data for the object + **kwargs: Extra options to send to the Gitlab server (e.g. sudo) + Returns: + dict: The new object data (*not* a RESTObject) -class ProjectServiceManager(BaseManager): - obj_cls = ProjectService + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabUpdateError: If the server cannot perform the request + """ + super(ProjectServiceManager, self).update(id, new_data, **kwargs) + self.id = id def available(self, **kwargs): """List the services known by python-gitlab. @@ -1659,7 +1663,7 @@ def available(self, **kwargs): Returns: list (str): The list of service code names. """ - return list(ProjectService._service_attrs.keys()) + return list(self._service_attrs.keys()) class ProjectAccessRequest(AccessRequestMixin, ObjectDeleteMixin, RESTObject): diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py index 08ee0aa0d..cba48339b 100644 --- a/tools/python_test_v4.py +++ b/tools/python_test_v4.py @@ -316,13 +316,14 @@ assert(settings.level == gitlab.NOTIFICATION_LEVEL_WATCH) # services -# NOT IMPLEMENTED YET -#service = admin_project.services.get(service_name='asana') -#service.active = True -#service.api_key = 'whatever' -#service.save() -#service = admin_project.services.get(service_name='asana') -#assert(service.active == True) +service = admin_project.services.get('asana') +service.api_key = 'whatever' +service.save() +service = admin_project.services.get('asana') +assert(service.active == True) +service.delete() +service = admin_project.services.get('asana') +assert(service.active == False) # snippets snippets = gl.snippets.list(all=True) From 9b8b8060a56465d8aade2368033172387e15527a Mon Sep 17 00:00:00 2001 From: Pavel Savchenko Date: Fri, 23 Jun 2017 12:03:27 +0200 Subject: [PATCH 60/93] Docs: Add link to gitlab docs on obtaining a token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I find these sort of links very user friendly 😅 --- docs/cli.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/cli.rst b/docs/cli.rst index f0ed2ee2e..92140ef67 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -80,6 +80,7 @@ section. - URL for the GitLab server * - ``private_token`` - Your user token. Login/password is not supported. + Refer `the official documentation`__ to learn how to obtain a token. * - ``api_version`` - API version to use (``3`` or ``4``), defaults to ``3`` * - ``http_username`` @@ -87,6 +88,8 @@ section. * - ``http_password`` - Password for optional HTTP authentication +__ https://docs.gitlab.com/ce/user/profile/personal_access_tokens.html + CLI === From 759f6edaf71b456cc36e9de00157385c28e2e3e7 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Fri, 4 Aug 2017 18:48:04 +0200 Subject: [PATCH 61/93] update tox/travis test envs --- .travis.yml | 3 ++- tox.ini | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index dd405f523..365308f35 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,7 +15,8 @@ env: - TOX_ENV=py27 - TOX_ENV=pep8 - TOX_ENV=docs - - TOX_ENV=py_func + - TOX_ENV=py_func_v3 + - TOX_ENV=py_func_v4 - TOX_ENV=cli_func install: - pip install tox diff --git a/tox.ini b/tox.ini index bb1b84cc6..5e97e9e1f 100644 --- a/tox.ini +++ b/tox.ini @@ -35,5 +35,8 @@ commands = [testenv:cli_func] commands = {toxinidir}/tools/functional_tests.sh -[testenv:py_func] +[testenv:py_func_v3] commands = {toxinidir}/tools/py_functional_tests.sh + +[testenv:py_func_v4] +commands = {toxinidir}/tools/py_functional_tests.sh -a 4 From 4af47487a279f494fd3118a01d21b401cd770d2b Mon Sep 17 00:00:00 2001 From: Maura Hausman Date: Mon, 24 Jul 2017 18:16:06 -0400 Subject: [PATCH 62/93] Support SSL verification via internal CA bundle - Also updates documentation - See issues #204 and #270 --- docs/cli.rst | 7 ++++--- gitlab/config.py | 17 +++++++++++++++++ gitlab/tests/test_config.py | 15 +++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 92140ef67..8d0550bf9 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -61,9 +61,10 @@ parameters. You can override the values in each GitLab server section. - Possible values - Description * - ``ssl_verify`` - - ``True`` or ``False`` - - Verify the SSL certificate. Set to ``False`` if your SSL certificate is - auto-signed. + - ``True``, ``False``, or a ``str`` + - Verify the SSL certificate. Set to ``False`` to disable verification, + though this will create warnings. Any other value is interpreted as path + to a CA_BUNDLE file or directory with certificates of trusted CAs. * - ``timeout`` - Integer - Number of seconds to wait for an answer before failing. diff --git a/gitlab/config.py b/gitlab/config.py index d5e87b670..d1c29d0ca 100644 --- a/gitlab/config.py +++ b/gitlab/config.py @@ -61,11 +61,28 @@ def __init__(self, gitlab_id=None, config_files=None): self.ssl_verify = True try: self.ssl_verify = self._config.getboolean('global', 'ssl_verify') + except ValueError: + # Value Error means the option exists but isn't a boolean. + # Get as a string instead as it should then be a local path to a + # CA bundle. + try: + self.ssl_verify = self._config.get('global', 'ssl_verify') + except Exception: + pass except Exception: pass try: self.ssl_verify = self._config.getboolean(self.gitlab_id, 'ssl_verify') + except ValueError: + # Value Error means the option exists but isn't a boolean. + # Get as a string instead as it should then be a local path to a + # CA bundle. + try: + self.ssl_verify = self._config.get(self.gitlab_id, + 'ssl_verify') + except Exception: + pass except Exception: pass diff --git a/gitlab/tests/test_config.py b/gitlab/tests/test_config.py index 73830a1c9..83d7daaac 100644 --- a/gitlab/tests/test_config.py +++ b/gitlab/tests/test_config.py @@ -40,6 +40,11 @@ private_token = GHIJKL ssl_verify = false timeout = 10 + +[three] +url = https://three.url +private_token = MNOPQR +ssl_verify = /path/to/CA/bundle.crt """ no_default_config = u"""[global] @@ -109,3 +114,13 @@ def test_valid_data(self, m_open): self.assertEqual("GHIJKL", cp.token) self.assertEqual(10, cp.timeout) self.assertEqual(False, cp.ssl_verify) + + fd = six.StringIO(valid_config) + fd.close = mock.Mock(return_value=None) + m_open.return_value = fd + cp = config.GitlabConfigParser(gitlab_id="three") + self.assertEqual("three", cp.gitlab_id) + self.assertEqual("https://three.url", cp.url) + self.assertEqual("MNOPQR", cp.token) + self.assertEqual(2, cp.timeout) + self.assertEqual("/path/to/CA/bundle.crt", cp.ssl_verify) From 45c4aaf1604b710d2b15238f305cd7ca51317895 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sat, 5 Aug 2017 07:52:34 +0200 Subject: [PATCH 63/93] Fix Gitlab.version() The method was overwritten by the result of the call. --- gitlab/__init__.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index bdeb5c4a2..644a7842c 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -73,6 +73,7 @@ def __init__(self, url, private_token=None, email=None, password=None, timeout=None, api_version='3', session=None): self._api_version = str(api_version) + self._server_version = self._server_revision = None self._url = '%s/api/v%s' % (url, api_version) #: Timeout to use for requests to gitlab server self.timeout = timeout @@ -227,15 +228,17 @@ def version(self): ('unknown', 'unknwown') if the server doesn't support this API call (gitlab < 8.13.0) """ - r = self._raw_get('/version') - try: - raise_error_from_response(r, GitlabGetError, 200) - data = r.json() - self.version, self.revision = data['version'], data['revision'] - except GitlabGetError: - self.version = self.revision = 'unknown' - - return self.version, self.revision + if self._server_version is None: + r = self._raw_get('/version') + try: + raise_error_from_response(r, GitlabGetError, 200) + data = r.json() + self._server_version = data['version'] + self._server_revision = data['revision'] + except GitlabGetError: + self._server_version = self._server_revision = 'unknown' + + return self._server_version, self._server_revision def set_url(self, url): """Updates the GitLab URL. From d1e7cc797a379be3f434d0e275d14486f858f80e Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sat, 5 Aug 2017 19:33:07 +0200 Subject: [PATCH 64/93] [v4] fix the project attributes for jobs builds_enabled and public_builds are now jobs_enabled and public_jobs. --- gitlab/v4/objects.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 49ccc9dc1..490342014 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -2087,9 +2087,9 @@ class ProjectManager(CRUDMixin, RESTManager): _create_attrs = ( ('name', ), ('path', 'namespace_id', 'description', 'issues_enabled', - 'merge_requests_enabled', 'builds_enabled', 'wiki_enabled', + 'merge_requests_enabled', 'jobs_enabled', 'wiki_enabled', 'snippets_enabled', 'container_registry_enabled', - 'shared_runners_enabled', 'visibility', 'import_url', 'public_builds', + 'shared_runners_enabled', 'visibility', 'import_url', 'public_jobs', 'only_allow_merge_if_build_succeeds', 'only_allow_merge_if_all_discussions_are_resolved', 'lfs_enabled', 'request_access_enabled') @@ -2097,9 +2097,9 @@ class ProjectManager(CRUDMixin, RESTManager): _update_attrs = ( tuple(), ('name', 'path', 'default_branch', 'description', 'issues_enabled', - 'merge_requests_enabled', 'builds_enabled', 'wiki_enabled', + 'merge_requests_enabled', 'jobs_enabled', 'wiki_enabled', 'snippets_enabled', 'container_registry_enabled', - 'shared_runners_enabled', 'visibility', 'import_url', 'public_builds', + 'shared_runners_enabled', 'visibility', 'import_url', 'public_jobs', 'only_allow_merge_if_build_succeeds', 'only_allow_merge_if_all_discussions_are_resolved', 'lfs_enabled', 'request_access_enabled') From 4ed22b1594fd16d93fcdcaab7db8c467afd41fea Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Fri, 11 Aug 2017 08:19:37 +0200 Subject: [PATCH 65/93] on_http_error: properly wrap the function This fixes the API docs. --- gitlab/exceptions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py index 6c0012972..fc2c16247 100644 --- a/gitlab/exceptions.py +++ b/gitlab/exceptions.py @@ -15,6 +15,8 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . +import functools + class GitlabError(Exception): def __init__(self, error_message="", response_code=None, @@ -223,6 +225,7 @@ def on_http_error(error): GitlabError """ def wrap(f): + @functools.wraps(f) def wrapped_f(*args, **kwargs): try: return f(*args, **kwargs) From 95a3fe6907676109e1cd2f52ca8f5ad17e0d01d0 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Fri, 11 Aug 2017 08:21:39 +0200 Subject: [PATCH 66/93] docs: fix invalid Raise attribute in docstrings --- gitlab/v4/objects.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 490342014..b71057f2e 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -771,7 +771,7 @@ def diff(self, **kwargs): Args: **kwargs: Extra options to send to the server (e.g. sudo) - Raise: + Raises: GitlabAuthenticationError: If authentication is not correct GitlabGetError: If the diff could not be retrieved @@ -789,7 +789,7 @@ def cherry_pick(self, branch, **kwargs): branch (str): Name of target branch **kwargs: Extra options to send to the server (e.g. sudo) - Raise: + Raises: GitlabAuthenticationError: If authentication is not correct GitlabCherryPickError: If the cherry-pick could not be performed """ @@ -837,7 +837,7 @@ def enable(self, key_id, **kwargs): key_id (int): The ID of the key to enable **kwargs: Extra options to send to the server (e.g. sudo) - Raise: + Raises: GitlabAuthenticationError: If authentication is not correct GitlabProjectDeployKeyError: If the key could not be enabled """ @@ -1311,7 +1311,7 @@ def save(self, branch, commit_message, **kwargs): commit_message (str): Message to send with the commit **kwargs: Extra options to send to the server (e.g. sudo) - Raise: + Raises: GitlabAuthenticationError: If authentication is not correct GitlabUpdateError: If the server cannot perform the request """ From 279704fb41f74bf797bf2db5be0ed5a8d7889366 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Fri, 11 Aug 2017 09:17:31 +0200 Subject: [PATCH 67/93] Fix URL for branch.unprotect --- gitlab/v4/objects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index b71057f2e..e3780a9cc 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -566,7 +566,7 @@ def unprotect(self, **kwargs): GitlabAuthenticationError: If authentication is not correct GitlabProtectError: If the branch could not be unprotected """ - path = '%s/%s/protect' % (self.manager.path, self.get_id()) + path = '%s/%s/unprotect' % (self.manager.path, self.get_id()) self.manager.gitlab.http_put(path, **kwargs) self._attrs['protected'] = False From 72e783de6b6e93e24dd93f5ac28383c2893bd7a6 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Fri, 11 Aug 2017 15:43:46 +0200 Subject: [PATCH 68/93] [v4] Fix getting projects using full namespace --- gitlab/mixins.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gitlab/mixins.py b/gitlab/mixins.py index 9dd05af80..e01691a9a 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -39,6 +39,8 @@ def get(self, id, lazy=False, **kwargs): GitlabAuthenticationError: If authentication is not correct GitlabGetError: If the server cannot perform the request """ + if not isinstance(id, int): + id = id.replace('/', '%2F') path = '%s/%s' % (self.path, id) if lazy is True: return self._obj_cls(self, {self._obj_cls._id_attr: id}) From 80eab7b0c0682c5df99495acc4d6f71f36603cfc Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Fri, 11 Aug 2017 16:06:42 +0200 Subject: [PATCH 69/93] Fix Args attribute in docstrings --- gitlab/mixins.py | 2 +- gitlab/v3/objects.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/gitlab/mixins.py b/gitlab/mixins.py index e01691a9a..ee98deab1 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -300,7 +300,7 @@ class AccessRequestMixin(object): def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs): """Approve an access request. - Attrs: + Args: access_level (int): The access level for the user **kwargs: Extra options to send to the server (e.g. sudo) diff --git a/gitlab/v3/objects.py b/gitlab/v3/objects.py index 69a972154..94c3873e4 100644 --- a/gitlab/v3/objects.py +++ b/gitlab/v3/objects.py @@ -423,7 +423,7 @@ class GroupAccessRequest(GitlabObject): def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs): """Approve an access request. - Attrs: + Args: access_level (int): The access level for the user. Raises: @@ -1720,7 +1720,7 @@ class ProjectAccessRequest(GitlabObject): def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs): """Approve an access request. - Attrs: + Args: access_level (int): The access level for the user. Raises: @@ -2278,7 +2278,7 @@ class Group(GitlabObject): def transfer_project(self, id, **kwargs): """Transfers a project to this new groups. - Attrs: + Args: id (int): ID of the project to transfer. Raises: From 4057644f03829e4439ec8ab1feacf90c65d976eb Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Fri, 11 Aug 2017 16:07:04 +0200 Subject: [PATCH 70/93] Update the objects doc/examples for v4 --- docs/gl_objects/access_requests.py | 12 - docs/gl_objects/access_requests.rst | 43 +++- docs/gl_objects/branches.py | 15 +- docs/gl_objects/branches.rst | 37 ++- docs/gl_objects/builds.py | 53 ++-- docs/gl_objects/builds.rst | 118 ++++++--- docs/gl_objects/commits.py | 17 +- docs/gl_objects/commits.rst | 65 ++++- docs/gl_objects/deploy_keys.py | 13 +- docs/gl_objects/deploy_keys.rst | 38 ++- docs/gl_objects/deployments.py | 4 - docs/gl_objects/deployments.rst | 21 +- docs/gl_objects/environments.py | 11 +- docs/gl_objects/environments.rst | 21 +- docs/gl_objects/groups.py | 16 -- docs/gl_objects/groups.rst | 61 +++-- docs/gl_objects/issues.py | 12 - docs/gl_objects/issues.rst | 59 ++++- docs/gl_objects/labels.py | 9 - docs/gl_objects/labels.rst | 20 +- docs/gl_objects/messages.rst | 18 +- docs/gl_objects/milestones.py | 8 - docs/gl_objects/milestones.rst | 22 +- docs/gl_objects/mrs.py | 14 +- docs/gl_objects/mrs.rst | 27 ++- docs/gl_objects/namespaces.rst | 20 +- docs/gl_objects/notifications.rst | 46 +++- docs/gl_objects/projects.py | 101 +------- docs/gl_objects/projects.rst | 362 +++++++++++++++++++++++----- docs/gl_objects/runners.py | 6 - docs/gl_objects/runners.rst | 40 ++- docs/gl_objects/settings.rst | 19 +- docs/gl_objects/sidekiq.rst | 16 +- docs/gl_objects/system_hooks.rst | 18 +- docs/gl_objects/templates.py | 9 + docs/gl_objects/templates.rst | 84 ++++++- 36 files changed, 991 insertions(+), 464 deletions(-) diff --git a/docs/gl_objects/access_requests.py b/docs/gl_objects/access_requests.py index 6497ca1c1..9df639d14 100644 --- a/docs/gl_objects/access_requests.py +++ b/docs/gl_objects/access_requests.py @@ -1,23 +1,14 @@ # list -p_ars = gl.project_accessrequests.list(project_id=1) -g_ars = gl.group_accessrequests.list(group_id=1) -# or p_ars = project.accessrequests.list() g_ars = group.accessrequests.list() # end list # get -p_ar = gl.project_accessrequests.get(user_id, project_id=1) -g_ar = gl.group_accessrequests.get(user_id, group_id=1) -# or p_ar = project.accessrequests.get(user_id) g_ar = group.accessrequests.get(user_id) # end get # create -p_ar = gl.project_accessrequests.create({}, project_id=1) -g_ar = gl.group_accessrequests.create({}, group_id=1) -# or p_ar = project.accessrequests.create({}) g_ar = group.accessrequests.create({}) # end create @@ -28,9 +19,6 @@ # end approve # delete -gl.project_accessrequests.delete(user_id, project_id=1) -gl.group_accessrequests.delete(user_id, group_id=1) -# or project.accessrequests.delete(user_id) group.accessrequests.delete(user_id) # or diff --git a/docs/gl_objects/access_requests.rst b/docs/gl_objects/access_requests.rst index a9e6d9b98..f64e79512 100644 --- a/docs/gl_objects/access_requests.rst +++ b/docs/gl_objects/access_requests.rst @@ -2,14 +2,41 @@ Access requests ############### -Use :class:`~gitlab.objects.ProjectAccessRequest` and -:class:`~gitlab.objects.GroupAccessRequest` objects to manipulate access -requests for projects and groups. The -:attr:`gitlab.Gitlab.project_accessrequests`, -:attr:`gitlab.Gitlab.group_accessrequests`, :attr:`Project.accessrequests -` and :attr:`Group.accessrequests -` manager objects provide helper -functions. +Users can request access to groups and projects. + +When access is granted the user should be given a numerical access level. The +following constants are provided to represent the access levels: + +* ``gitlab.GUEST_ACCESS``: ``10`` +* ``gitlab.REPORTER_ACCESS``: ``20`` +* ``gitlab.DEVELOPER_ACCESS``: ``30`` +* ``gitlab.MASTER_ACCESS``: ``40`` +* ``gitlab.OWNER_ACCESS``: ``50`` + +References +---------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectAccessRequest` + + :class:`gitlab.v4.objects.ProjectAccessRequestManager` + + :attr:`gitlab.v4.objects.Project.accessrequests` + + :class:`gitlab.v4.objects.GroupAccessRequest` + + :class:`gitlab.v4.objects.GroupAccessRequestManager` + + :attr:`gitlab.v4.objects.Group.accessrequests` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectAccessRequest` + + :class:`gitlab.v3.objects.ProjectAccessRequestManager` + + :attr:`gitlab.v3.objects.Project.accessrequests` + + :attr:`gitlab.Gitlab.project_accessrequests` + + :class:`gitlab.v3.objects.GroupAccessRequest` + + :class:`gitlab.v3.objects.GroupAccessRequestManager` + + :attr:`gitlab.v3.objects.Group.accessrequests` + + :attr:`gitlab.Gitlab.group_accessrequests` + +* GitLab API: https://docs.gitlab.com/ce/api/access_requests.html Examples -------- diff --git a/docs/gl_objects/branches.py b/docs/gl_objects/branches.py index b485ee083..b80dfc052 100644 --- a/docs/gl_objects/branches.py +++ b/docs/gl_objects/branches.py @@ -1,27 +1,22 @@ # list -branches = gl.project_branches.list(project_id=1) -# or branches = project.branches.list() # end list # get -branch = gl.project_branches.get(project_id=1, id='master') -# or branch = project.branches.get('master') # end get # create -branch = gl.project_branches.create({'branch_name': 'feature1', - 'ref': 'master'}, - project_id=1) -# or +# v4 +branch = project.branches.create({'branch': 'feature1', + 'ref': 'master'}) + +#v3 branch = project.branches.create({'branch_name': 'feature1', 'ref': 'master'}) # end create # delete -gl.project_branches.delete(project_id=1, id='feature1') -# or project.branches.delete('feature1') # or branch.delete() diff --git a/docs/gl_objects/branches.rst b/docs/gl_objects/branches.rst index 50b97a799..279ca0caf 100644 --- a/docs/gl_objects/branches.rst +++ b/docs/gl_objects/branches.rst @@ -2,15 +2,25 @@ Branches ######## -Use :class:`~gitlab.objects.ProjectBranch` objects to manipulate repository -branches. +References +---------- -To create :class:`~gitlab.objects.ProjectBranch` objects use the -:attr:`gitlab.Gitlab.project_branches` or :attr:`Project.branches -` managers. +* v4 API: + + + :class:`gitlab.v4.objects.ProjectBranch` + + :class:`gitlab.v4.objects.ProjectBranchManager` + + :attr:`gitlab.v4.objects.Project.branches` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectBranch` + + :class:`gitlab.v3.objects.ProjectBranchManager` + + :attr:`gitlab.v3.objects.Project.branches` + +* GitLab API: https://docs.gitlab.com/ce/api/branches.html Examples -======== +-------- Get the list of branches for a repository: @@ -41,10 +51,13 @@ Protect/unprotect a repository branch: .. literalinclude:: branches.py :start-after: # protect :end-before: # end protect - + .. note:: - - By default, developers will not be able to push or merge into - protected branches. This can be changed by passing ``developers_can_push`` - or ``developers_can_merge`` like so: - ``branch.protect(developers_can_push=False, developers_can_merge=True)`` + + By default, developers are not authorized to push or merge into protected + branches. This can be changed by passing ``developers_can_push`` or + ``developers_can_merge``: + + .. code-block:: python + + branch.protect(developers_can_push=True, developers_can_merge=True) diff --git a/docs/gl_objects/builds.py b/docs/gl_objects/builds.py index 855b7c898..e125b39eb 100644 --- a/docs/gl_objects/builds.py +++ b/docs/gl_objects/builds.py @@ -1,19 +1,12 @@ # var list -variables = gl.project_variables.list(project_id=1) -# or variables = project.variables.list() # end var list # var get -var = gl.project_variables.get(var_key, project_id=1) -# or var = project.variables.get(var_key) # end var get # var create -var = gl.project_variables.create({'key': 'key1', 'value': 'value1'}, - project_id=1) -# or var = project.variables.create({'key': 'key1', 'value': 'value1'}) # end var create @@ -23,58 +16,47 @@ # end var update # var delete -gl.project_variables.delete(var_key) -# or -project.variables.delete() +project.variables.delete(var_key) # or var.delete() # end var delete # trigger list -triggers = gl.project_triggers.list(project_id=1) -# or triggers = project.triggers.list() # end trigger list # trigger get -trigger = gl.project_triggers.get(trigger_token, project_id=1) -# or trigger = project.triggers.get(trigger_token) # end trigger get # trigger create -trigger = gl.project_triggers.create({}, project_id=1) -# or trigger = project.triggers.create({}) # end trigger create # trigger delete -gl.project_triggers.delete(trigger_token) -# or -project.triggers.delete() +project.triggers.delete(trigger_token) # or trigger.delete() # end trigger delete # list -builds = gl.project_builds.list(project_id=1) -# or -builds = project.builds.list() +builds = project.builds.list() # v3 +jobs = project.jobs.list() # v4 # end list # commit list +# v3 only commit = gl.project_commits.get(commit_sha, project_id=1) builds = commit.builds() # end commit list # get -build = gl.project_builds.get(build_id, project_id=1) -# or -project.builds.get(build_id) +project.builds.get(build_id) # v3 +project.jobs.get(job_id) # v4 # end get # artifacts -build.artifacts() +build_or_job.artifacts() # end artifacts # stream artifacts @@ -86,33 +68,32 @@ def __call__(self, chunk): self._fd.write(chunk) target = Foo() -build.artifacts(streamed=True, action=target) +build_or_job.artifacts(streamed=True, action=target) del(target) # flushes data on disk # end stream artifacts # keep artifacts -build.keep_artifacts() +build_or_job.keep_artifacts() # end keep artifacts # trace -build.trace() +build_or_job.trace() # end trace # retry -build.cancel() -build.retry() +build_or_job.cancel() +build_or_job.retry() # end retry # erase -build.erase() +build_or_job.erase() # end erase # play -build.play() +build_or_job.play() # end play # trigger run -p = gl.projects.get(project_id) -p.trigger_build('master', trigger_token, - {'extra_var1': 'foo', 'extra_var2': 'bar'}) +project.trigger_build('master', trigger_token, + {'extra_var1': 'foo', 'extra_var2': 'bar'}) # end trigger run diff --git a/docs/gl_objects/builds.rst b/docs/gl_objects/builds.rst index b20ca77b7..52bdb1ace 100644 --- a/docs/gl_objects/builds.rst +++ b/docs/gl_objects/builds.rst @@ -1,16 +1,33 @@ -###### -Builds -###### +############################### +Jobs (v4 API) / Builds (v3 API) +############################### -Build triggers -============== +Build and job are two classes representing the same object. Builds are used in +v3 API, jobs in v4 API. -Build triggers provide a way to interact with the GitLab CI. Using a trigger a -user or an application can run a new build for a specific commit. +Triggers +======== -* Object class: :class:`~gitlab.objects.ProjectTrigger` -* Manager objects: :attr:`gitlab.Gitlab.project_triggers`, - :attr:`Project.triggers ` +Triggers provide a way to interact with the GitLab CI. Using a trigger a user +or an application can run a new build/job for a specific commit. + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectTrigger` + + :class:`gitlab.v4.objects.ProjectTriggerManager` + + :attr:`gitlab.v4.objects.Project.triggers` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectTrigger` + + :class:`gitlab.v3.objects.ProjectTriggerManager` + + :attr:`gitlab.v3.objects.Project.triggers` + + :attr:`gitlab.Gitlab.project_triggers` + +* GitLab API: https://docs.gitlab.com/ce/api/pipeline_triggers.html Examples -------- @@ -39,14 +56,29 @@ Remove a trigger: :start-after: # trigger delete :end-before: # end trigger delete -Build variables -=============== +Project variables +================= + +You can associate variables to projects to modify the build/job script +behavior. + +Reference +--------- -You can associate variables to builds to modify the build script behavior. +* v4 API -* Object class: :class:`~gitlab.objects.ProjectVariable` -* Manager objects: :attr:`gitlab.Gitlab.project_variables`, - :attr:`gitlab.objects.Project.variables` + + :class:`gitlab.v4.objects.ProjectVariable` + + :class:`gitlab.v4.objects.ProjectVariableManager` + + :attr:`gitlab.v4.objects.Project.variables` + +* v3 API + + + :class:`gitlab.v3.objects.ProjectVariable` + + :class:`gitlab.v3.objects.ProjectVariableManager` + + :attr:`gitlab.v3.objects.Project.variables` + + :attr:`gitlab.Gitlab.project_variables` + +* GitLab API: https://docs.gitlab.com/ce/api/project_level_variables.html Examples -------- @@ -81,49 +113,63 @@ Remove a variable: :start-after: # var delete :end-before: # end var delete -Builds -====== +Builds/Jobs +=========== + +Builds/Jobs are associated to projects and commits. They provide information on +the builds/jobs that have been run, and methods to manipulate them. + +Reference +--------- + +* v4 API + + + :class:`gitlab.v4.objects.ProjectJob` + + :class:`gitlab.v4.objects.ProjectJobManager` + + :attr:`gitlab.v4.objects.Project.jobs` + +* v3 API -Builds are associated to projects and commits. They provide information on the -build that have been run, and methods to manipulate those builds. + + :class:`gitlab.v3.objects.ProjectJob` + + :class:`gitlab.v3.objects.ProjectJobManager` + + :attr:`gitlab.v3.objects.Project.jobs` + + :attr:`gitlab.Gitlab.project_jobs` -* Object class: :class:`~gitlab.objects.ProjectBuild` -* Manager objects: :attr:`gitlab.Gitlab.project_builds`, - :attr:`gitlab.objects.Project.builds` +* GitLab API: https://docs.gitlab.com/ce/api/jobs.html Examples -------- -Build are usually automatically triggered, but you can explicitly trigger a -new build: +Jobs are usually automatically triggered, but you can explicitly trigger a new +job: -Trigger a new build on a project: +Trigger a new job on a project: .. literalinclude:: builds.py :start-after: # trigger run :end-before: # end trigger run -List builds for the project: +List jobs for the project: .. literalinclude:: builds.py :start-after: # list :end-before: # end list To list builds for a specific commit, create a -:class:`~gitlab.objects.ProjectCommit` object and use its -:attr:`~gitlab.objects.ProjectCommit.builds` method: +:class:`~gitlab.v3.objects.ProjectCommit` object and use its +:attr:`~gitlab.v3.objects.ProjectCommit.builds` method (v3 only): .. literalinclude:: builds.py :start-after: # commit list :end-before: # end commit list -Get a build: +Get a job: .. literalinclude:: builds.py :start-after: # get :end-before: # end get -Get a build artifacts: +Get a job artifact: .. literalinclude:: builds.py :start-after: # artifacts @@ -142,13 +188,13 @@ stream: :start-after: # stream artifacts :end-before: # end stream artifacts -Mark a build artifact as kept when expiration is set: +Mark a job artifact as kept when expiration is set: .. literalinclude:: builds.py :start-after: # keep artifacts :end-before: # end keep artifacts -Get a build trace: +Get a job trace: .. literalinclude:: builds.py :start-after: # trace @@ -159,19 +205,19 @@ Get a build trace: Traces are entirely stored in memory unless you use the streaming feature. See :ref:`the artifacts example `. -Cancel/retry a build: +Cancel/retry a job: .. literalinclude:: builds.py :start-after: # retry :end-before: # end retry -Play (trigger) a build: +Play (trigger) a job: .. literalinclude:: builds.py :start-after: # play :end-before: # end play -Erase a build (artifacts and trace): +Erase a job (artifacts and trace): .. literalinclude:: builds.py :start-after: # erase diff --git a/docs/gl_objects/commits.py b/docs/gl_objects/commits.py index befebd54f..f7e73e5c5 100644 --- a/docs/gl_objects/commits.py +++ b/docs/gl_objects/commits.py @@ -1,6 +1,4 @@ # list -commits = gl.project_commits.list(project_id=1) -# or commits = project.commits.list() # end list @@ -13,7 +11,8 @@ # See https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions # for actions detail data = { - 'branch_name': 'master', + 'branch_name': 'master', # v3 + 'branch': 'master', # v4 'commit_message': 'blah blah blah', 'actions': [ { @@ -24,14 +23,10 @@ ] } -commit = gl.project_commits.create(data, project_id=1) -# or commit = project.commits.create(data) # end create # get -commit = gl.project_commits.get('e3d5a71b', project_id=1) -# or commit = project.commits.get('e3d5a71b') # end get @@ -44,10 +39,6 @@ # end cherry # comments list -comments = gl.project_commit_comments.list(project_id=1, commit_id='master') -# or -comments = project.commit_comments.list(commit_id='a5fe4c8') -# or comments = commit.comments.list() # end comments list @@ -62,10 +53,6 @@ # end comments create # statuses list -statuses = gl.project_commit_statuses.list(project_id=1, commit_id='master') -# or -statuses = project.commit_statuses.list(commit_id='a5fe4c8') -# or statuses = commit.statuses.list() # end statuses list diff --git a/docs/gl_objects/commits.rst b/docs/gl_objects/commits.rst index 6fef8bf7e..9267cae18 100644 --- a/docs/gl_objects/commits.rst +++ b/docs/gl_objects/commits.rst @@ -5,9 +5,24 @@ Commits Commits ======= -* Object class: :class:`~gitlab.objects.ProjectCommit` -* Manager objects: :attr:`gitlab.Gitlab.project_commits`, - :attr:`gitlab.objects.Project.commits` +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectCommit` + + :class:`gitlab.v4.objects.ProjectCommitManager` + + :attr:`gitlab.v4.objects.Project.commits` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectCommit` + + :class:`gitlab.v3.objects.ProjectCommitManager` + + :attr:`gitlab.v3.objects.Project.commits` + + :attr:`gitlab.Gitlab.project_commits` + +* GitLab API: https://docs.gitlab.com/ce/api/commits.html + Examples -------- @@ -52,10 +67,24 @@ Cherry-pick a commit into another branch: Commit comments =============== -* Object class: :class:`~gitlab.objects.ProjectCommiComment` -* Manager objects: :attr:`gitlab.Gitlab.project_commit_comments`, - :attr:`gitlab.objects.Project.commit_comments`, - :attr:`gitlab.objects.ProjectCommit.comments` +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectCommitComment` + + :class:`gitlab.v4.objects.ProjectCommitCommentManager` + + :attr:`gitlab.v4.objects.Commit.comments` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectCommit` + + :class:`gitlab.v3.objects.ProjectCommitManager` + + :attr:`gitlab.v3.objects.Commit.comments` + + :attr:`gitlab.v3.objects.Project.commit_comments` + + :attr:`gitlab.Gitlab.project_commit_comments` + +* GitLab API: https://docs.gitlab.com/ce/api/commits.html Examples -------- @@ -75,10 +104,24 @@ Add a comment on a commit: Commit status ============= -* Object class: :class:`~gitlab.objects.ProjectCommitStatus` -* Manager objects: :attr:`gitlab.Gitlab.project_commit_statuses`, - :attr:`gitlab.objects.Project.commit_statuses`, - :attr:`gitlab.objects.ProjectCommit.statuses` +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectCommitStatus` + + :class:`gitlab.v4.objects.ProjectCommitStatusManager` + + :attr:`gitlab.v4.objects.Commit.statuses` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectCommit` + + :class:`gitlab.v3.objects.ProjectCommitManager` + + :attr:`gitlab.v3.objects.Commit.statuses` + + :attr:`gitlab.v3.objects.Project.commit_statuses` + + :attr:`gitlab.Gitlab.project_commit_statuses` + +* GitLab API: https://docs.gitlab.com/ce/api/commits.html Examples -------- diff --git a/docs/gl_objects/deploy_keys.py b/docs/gl_objects/deploy_keys.py index 84da07934..ccdf30ea1 100644 --- a/docs/gl_objects/deploy_keys.py +++ b/docs/gl_objects/deploy_keys.py @@ -7,29 +7,19 @@ # end global get # list -keys = gl.project_keys.list(project_id=1) -# or keys = project.keys.list() # end list # get -key = gl.project_keys.get(key_id, project_id=1) -# or key = project.keys.get(key_id) # end get # create -key = gl.project_keys.create({'title': 'jenkins key', - 'key': open('/home/me/.ssh/id_rsa.pub').read()}, - project_id=1) -# or key = project.keys.create({'title': 'jenkins key', 'key': open('/home/me/.ssh/id_rsa.pub').read()}) # end create # delete -key = gl.project_keys.delete(key_id, project_id=1) -# or key = project.keys.list(key_id) # or key.delete() @@ -40,5 +30,6 @@ # end enable # disable -project.keys.disable(key_id) +project_key.delete() # v4 +project.keys.disable(key_id) # v3 # end disable diff --git a/docs/gl_objects/deploy_keys.rst b/docs/gl_objects/deploy_keys.rst index 28033cb02..059b01f2c 100644 --- a/docs/gl_objects/deploy_keys.rst +++ b/docs/gl_objects/deploy_keys.rst @@ -5,10 +5,22 @@ Deploy keys Deploy keys =========== -Deploy keys allow read-only access to multiple projects with a single SSH key. +Reference +--------- -* Object class: :class:`~gitlab.objects.DeployKey` -* Manager object: :attr:`gitlab.Gitlab.deploykeys` +* v4 API: + + + :class:`gitlab.v4.objects.DeployKey` + + :class:`gitlab.v4.objects.DeployKeyManager` + + :attr:`gitlab.Gitlab.deploykeys` + +* v3 API: + + + :class:`gitlab.v3.objects.Key` + + :class:`gitlab.v3.objects.KeyManager` + + :attr:`gitlab.Gitlab.deploykeys` + +* GitLab API: https://docs.gitlab.com/ce/api/deploy_keys.html Examples -------- @@ -30,9 +42,23 @@ Deploy keys for projects Deploy keys can be managed on a per-project basis. -* Object class: :class:`~gitlab.objects.ProjectKey` -* Manager objects: :attr:`gitlab.Gitlab.project_keys` and :attr:`Project.keys - ` +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectKey` + + :class:`gitlab.v4.objects.ProjectKeyManager` + + :attr:`gitlab.v4.objects.Project.keys` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectKey` + + :class:`gitlab.v3.objects.ProjectKeyManager` + + :attr:`gitlab.v3.objects.Project.keys` + + :attr:`gitlab.Gitlab.project_keys` + +* GitLab API: https://docs.gitlab.com/ce/api/deploy_keys.html Examples -------- diff --git a/docs/gl_objects/deployments.py b/docs/gl_objects/deployments.py index fe1613a15..5084b4dc2 100644 --- a/docs/gl_objects/deployments.py +++ b/docs/gl_objects/deployments.py @@ -1,11 +1,7 @@ # list -deployments = gl.project_deployments.list(project_id=1) -# or deployments = project.deployments.list() # end list # get -deployment = gl.project_deployments.get(deployment_id, project_id=1) -# or deployment = project.deployments.get(deployment_id) # end get diff --git a/docs/gl_objects/deployments.rst b/docs/gl_objects/deployments.rst index 1a679da51..37e94680d 100644 --- a/docs/gl_objects/deployments.rst +++ b/docs/gl_objects/deployments.rst @@ -2,10 +2,23 @@ Deployments ########### -Use :class:`~gitlab.objects.ProjectDeployment` objects to manipulate project -deployments. The :attr:`gitlab.Gitlab.project_deployments`, and -:attr:`Project.deployments ` manager -objects provide helper functions. +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectDeployment` + + :class:`gitlab.v4.objects.ProjectDeploymentManager` + + :attr:`gitlab.v4.objects.Project.deployments` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectDeployment` + + :class:`gitlab.v3.objects.ProjectDeploymentManager` + + :attr:`gitlab.v3.objects.Project.deployments` + + :attr:`gitlab.Gitlab.project_deployments` + +* GitLab API: https://docs.gitlab.com/ce/api/deployments.html Examples -------- diff --git a/docs/gl_objects/environments.py b/docs/gl_objects/environments.py index 80d77c922..3ca6fc1fe 100644 --- a/docs/gl_objects/environments.py +++ b/docs/gl_objects/environments.py @@ -1,19 +1,12 @@ # list -environments = gl.project_environments.list(project_id=1) -# or environments = project.environments.list() # end list # get -environment = gl.project_environments.get(environment_id, project_id=1) -# or environment = project.environments.get(environment_id) # end get # create -environment = gl.project_environments.create({'name': 'production'}, - project_id=1) -# or environment = project.environments.create({'name': 'production'}) # end create @@ -23,9 +16,7 @@ # end update # delete -environment = gl.project_environments.delete(environment_id, project_id=1) -# or -environment = project.environments.list(environment_id) +environment = project.environments.delete(environment_id) # or environment.delete() # end delete diff --git a/docs/gl_objects/environments.rst b/docs/gl_objects/environments.rst index 83d080b5c..d94c4530b 100644 --- a/docs/gl_objects/environments.rst +++ b/docs/gl_objects/environments.rst @@ -2,10 +2,23 @@ Environments ############ -Use :class:`~gitlab.objects.ProjectEnvironment` objects to manipulate -environments for projects. The :attr:`gitlab.Gitlab.project_environments` and -:attr:`Project.environments ` manager -objects provide helper functions. +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectEnvironment` + + :class:`gitlab.v4.objects.ProjectEnvironmentManager` + + :attr:`gitlab.v4.objects.Project.environments` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectEnvironment` + + :class:`gitlab.v3.objects.ProjectEnvironmentManager` + + :attr:`gitlab.v3.objects.Project.environments` + + :attr:`gitlab.Gitlab.project_environments` + +* GitLab API: https://docs.gitlab.com/ce/api/environments.html Examples -------- diff --git a/docs/gl_objects/groups.py b/docs/gl_objects/groups.py index 8b4e88888..f1a2a8f60 100644 --- a/docs/gl_objects/groups.py +++ b/docs/gl_objects/groups.py @@ -2,18 +2,12 @@ groups = gl.groups.list() # end list -# search -groups = gl.groups.search('group') -# end search - # get group = gl.groups.get(group_id) # end get # projects list projects = group.projects.list() -# or -projects = gl.group_projects.list(group_id) # end projects list # create @@ -32,22 +26,14 @@ # end delete # member list -members = gl.group_members.list(group_id=1) -# or members = group.members.list() # end member list # member get -members = gl.group_members.get(member_id) -# or members = group.members.get(member_id) # end member get # member create -member = gl.group_members.create({'user_id': user_id, - 'access_level': gitlab.GUEST_ACCESS}, - group_id=1) -# or member = group.members.create({'user_id': user_id, 'access_level': gitlab.GUEST_ACCESS}) # end member create @@ -58,8 +44,6 @@ # end member update # member delete -gl.group_members.delete(member_id, group_id=1) -# or group.members.delete(member_id) # or member.delete() diff --git a/docs/gl_objects/groups.rst b/docs/gl_objects/groups.rst index b2c0ed865..5e413af02 100644 --- a/docs/gl_objects/groups.rst +++ b/docs/gl_objects/groups.rst @@ -5,8 +5,22 @@ Groups Groups ====== -Use :class:`~gitlab.objects.Group` objects to manipulate groups. The -:attr:`gitlab.Gitlab.groups` manager object provides helper functions. +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.Group` + + :class:`gitlab.v4.objects.GroupManager` + + :attr:`gitlab.Gitlab.groups` + +* v3 API: + + + :class:`gitlab.v3.objects.Group` + + :class:`gitlab.v3.objects.GroupManager` + + :attr:`gitlab.Gitlab.groups` + +* GitLab API: https://docs.gitlab.com/ce/api/groups.html Examples -------- @@ -17,12 +31,6 @@ List the groups: :start-after: # list :end-before: # end list -Search groups: - -.. literalinclude:: groups.py - :start-after: # search - :end-before: # end search - Get a group's detail: .. literalinclude:: groups.py @@ -67,18 +75,35 @@ Remove a group: Group members ============= -Use :class:`~gitlab.objects.GroupMember` objects to manipulate groups. The -:attr:`gitlab.Gitlab.group_members` and :attr:`Group.members -` manager objects provide helper functions. +The following constants define the supported access levels: + +* ``gitlab.GUEST_ACCESS = 10`` +* ``gitlab.REPORTER_ACCESS = 20`` +* ``gitlab.DEVELOPER_ACCESS = 30`` +* ``gitlab.MASTER_ACCESS = 40`` +* ``gitlab.OWNER_ACCESS = 50`` -The following :class:`~gitlab.objects.Group` attributes define the supported -access levels: +Reference +--------- -* ``GUEST_ACCESS = 10`` -* ``REPORTER_ACCESS = 20`` -* ``DEVELOPER_ACCESS = 30`` -* ``MASTER_ACCESS = 40`` -* ``OWNER_ACCESS = 50`` +* v4 API: + + + :class:`gitlab.v4.objects.GroupMember` + + :class:`gitlab.v4.objects.GroupMemberManager` + + :attr:`gitlab.v4.objects.Group.members` + +* v3 API: + + + :class:`gitlab.v3.objects.GroupMember` + + :class:`gitlab.v3.objects.GroupMemberManager` + + :attr:`gitlab.v3.objects.Group.members` + + :attr:`gitlab.Gitlab.group_members` + +* GitLab API: https://docs.gitlab.com/ce/api/groups.html + + +Examples +-------- List group members: diff --git a/docs/gl_objects/issues.py b/docs/gl_objects/issues.py index df13c20da..de4a3562d 100644 --- a/docs/gl_objects/issues.py +++ b/docs/gl_objects/issues.py @@ -9,8 +9,6 @@ # end filtered list # group issues list -issues = gl.group_issues.list(group_id=1) -# or issues = group.issues.list() # Filter using the state, labels and milestone parameters issues = group.issues.list(milestone='1.0', state='opened') @@ -19,8 +17,6 @@ # end group issues list # project issues list -issues = gl.project_issues.list(project_id=1) -# or issues = project.issues.list() # Filter using the state, labels and milestone parameters issues = project.issues.list(milestone='1.0', state='opened') @@ -29,16 +25,10 @@ # end project issues list # project issues get -issue = gl.project_issues.get(issue_id, project_id=1) -# or issue = project.issues.get(issue_id) # end project issues get # project issues create -issue = gl.project_issues.create({'title': 'I have a bug', - 'description': 'Something useful here.'}, - project_id=1) -# or issue = project.issues.create({'title': 'I have a bug', 'description': 'Something useful here.'}) # end project issues create @@ -58,8 +48,6 @@ # end project issue open_close # project issue delete -gl.project_issues.delete(issue_id, project_id=1) -# or project.issues.delete(issue_id) # pr issue.delete() diff --git a/docs/gl_objects/issues.rst b/docs/gl_objects/issues.rst index 259c79fa6..b3b1cf1e8 100644 --- a/docs/gl_objects/issues.rst +++ b/docs/gl_objects/issues.rst @@ -5,9 +5,22 @@ Issues Reported issues =============== -Use :class:`~gitlab.objects.Issues` objects to manipulate issues the -authenticated user reported. The :attr:`gitlab.Gitlab.issues` manager object -provides helper functions. +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.Issue` + + :class:`gitlab.v4.objects.IssueManager` + + :attr:`gitlab.Gitlab.issues` + +* v3 API: + + + :class:`gitlab.v3.objects.Issue` + + :class:`gitlab.v3.objects.IssueManager` + + :attr:`gitlab.Gitlab.issues` + +* GitLab API: https://docs.gitlab.com/ce/api/issues.html Examples -------- @@ -28,9 +41,23 @@ Use the ``state`` and ``label`` parameters to filter the results. Use the Group issues ============ -Use :class:`~gitlab.objects.GroupIssue` objects to manipulate issues. The -:attr:`gitlab.Gitlab.project_issues` and :attr:`Group.issues -` manager objects provide helper functions. +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.GroupIssue` + + :class:`gitlab.v4.objects.GroupIssueManager` + + :attr:`gitlab.v4.objects.Group.issues` + +* v3 API: + + + :class:`gitlab.v3.objects.GroupIssue` + + :class:`gitlab.v3.objects.GroupIssueManager` + + :attr:`gitlab.v3.objects.Group.issues` + + :attr:`gitlab.Gitlab.group_issues` + +* GitLab API: https://docs.gitlab.com/ce/api/issues.html Examples -------- @@ -44,9 +71,23 @@ List the group issues: Project issues ============== -Use :class:`~gitlab.objects.ProjectIssue` objects to manipulate issues. The -:attr:`gitlab.Gitlab.project_issues` and :attr:`Project.issues -` manager objects provide helper functions. +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectIssue` + + :class:`gitlab.v4.objects.ProjectIssueManager` + + :attr:`gitlab.v4.objects.Project.issues` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectIssue` + + :class:`gitlab.v3.objects.ProjectIssueManager` + + :attr:`gitlab.v3.objects.Project.issues` + + :attr:`gitlab.Gitlab.project_issues` + +* GitLab API: https://docs.gitlab.com/ce/api/issues.html Examples -------- diff --git a/docs/gl_objects/labels.py b/docs/gl_objects/labels.py index 9a363632c..57892b5d1 100644 --- a/docs/gl_objects/labels.py +++ b/docs/gl_objects/labels.py @@ -1,19 +1,12 @@ # list -labels = gl.project_labels.list(project_id=1) -# or labels = project.labels.list() # end list # get -label = gl.project_labels.get(label_name, project_id=1) -# or label = project.labels.get(label_name) # end get # create -label = gl.project_labels.create({'name': 'foo', 'color': '#8899aa'}, - project_id=1) -# or label = project.labels.create({'name': 'foo', 'color': '#8899aa'}) # end create @@ -27,8 +20,6 @@ # end update # delete -gl.project_labels.delete(label_id, project_id=1) -# or project.labels.delete(label_id) # or label.delete() diff --git a/docs/gl_objects/labels.rst b/docs/gl_objects/labels.rst index 3973b0b90..d44421723 100644 --- a/docs/gl_objects/labels.rst +++ b/docs/gl_objects/labels.rst @@ -2,9 +2,23 @@ Labels ###### -Use :class:`~gitlab.objects.ProjectLabel` objects to manipulate labels for -projects. The :attr:`gitlab.Gitlab.project_labels` and :attr:`Project.labels -` manager objects provide helper functions. +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectLabel` + + :class:`gitlab.v4.objects.ProjectLabelManager` + + :attr:`gitlab.v4.objects.Project.labels` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectLabel` + + :class:`gitlab.v3.objects.ProjectLabelManager` + + :attr:`gitlab.v3.objects.Project.labels` + + :attr:`gitlab.Gitlab.project_labels` + +* GitLab API: https://docs.gitlab.com/ce/api/labels.html Examples -------- diff --git a/docs/gl_objects/messages.rst b/docs/gl_objects/messages.rst index 9f183baf0..452370d8a 100644 --- a/docs/gl_objects/messages.rst +++ b/docs/gl_objects/messages.rst @@ -6,8 +6,22 @@ You can use broadcast messages to display information on all pages of the gitlab web UI. You must have administration permissions to manipulate broadcast messages. -* Object class: :class:`gitlab.objects.BroadcastMessage` -* Manager object: :attr:`gitlab.Gitlab.broadcastmessages` +References +---------- + +* v4 API: + + + :class:`gitlab.v4.objects.BroadcastMessage` + + :class:`gitlab.v4.objects.BroadcastMessageManager` + + :attr:`gitlab.Gitlab.broadcastmessages` + +* v3 API: + + + :class:`gitlab.v3.objects.BroadcastMessage` + + :class:`gitlab.v3.objects.BroadcastMessageManager` + + :attr:`gitlab.Gitlab.broadcastmessages` + +* GitLab API: https://docs.gitlab.com/ce/api/broadcast_messages.html Examples -------- diff --git a/docs/gl_objects/milestones.py b/docs/gl_objects/milestones.py index 83065fcec..19770bcf1 100644 --- a/docs/gl_objects/milestones.py +++ b/docs/gl_objects/milestones.py @@ -1,24 +1,16 @@ # list -milestones = gl.project_milestones.list(project_id=1) -# or milestones = project.milestones.list() # end list # filter -milestones = gl.project_milestones.list(project_id=1, state='closed') -# or milestones = project.milestones.list(state='closed') # end filter # get -milestone = gl.project_milestones.get(milestone_id, project_id=1) -# or milestone = project.milestones.get(milestone_id) # end get # create -milestone = gl.project_milestones.create({'title': '1.0'}, project_id=1) -# or milestone = project.milestones.create({'title': '1.0'}) # end create diff --git a/docs/gl_objects/milestones.rst b/docs/gl_objects/milestones.rst index 47e585ae3..fbe5d879c 100644 --- a/docs/gl_objects/milestones.rst +++ b/docs/gl_objects/milestones.rst @@ -2,9 +2,23 @@ Milestones ########## -Use :class:`~gitlab.objects.ProjectMilestone` objects to manipulate milestones. -The :attr:`gitlab.Gitlab.project_milestones` and :attr:`Project.milestones -` manager objects provide helper functions. +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectMilestone` + + :class:`gitlab.v4.objects.ProjectMilestoneManager` + + :attr:`gitlab.v4.objects.Project.milestones` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectMilestone` + + :class:`gitlab.v3.objects.ProjectMilestoneManager` + + :attr:`gitlab.v3.objects.Project.milestones` + + :attr:`gitlab.Gitlab.project_milestones` + +* GitLab API: https://docs.gitlab.com/ce/api/milestones.html Examples -------- @@ -58,4 +72,4 @@ List the merge requests related to a milestone: .. literalinclude:: milestones.py :start-after: # merge_requests - :end-before: # end merge_requests \ No newline at end of file + :end-before: # end merge_requests diff --git a/docs/gl_objects/mrs.py b/docs/gl_objects/mrs.py index 021338dcc..bc30b4342 100644 --- a/docs/gl_objects/mrs.py +++ b/docs/gl_objects/mrs.py @@ -1,6 +1,4 @@ # list -mrs = gl.project_mergerequests.list(project_id=1) -# or mrs = project.mergerequests.list() # end list @@ -9,17 +7,10 @@ # end filtered list # get -mr = gl.project_mergerequests.get(mr_id, project_id=1) -# or mr = project.mergerequests.get(mr_id) # end get # create -mr = gl.project_mergerequests.create({'source_branch': 'cool_feature', - 'target_branch': 'master', - 'title': 'merge cool feature'}, - project_id=1) -# or mr = project.mergerequests.create({'source_branch': 'cool_feature', 'target_branch': 'master', 'title': 'merge cool feature'}) @@ -36,8 +27,6 @@ # end state # delete -gl.project_mergerequests.delete(mr_id, project_id=1) -# or project.mergerequests.delete(mr_id) # or mr.delete() @@ -48,7 +37,8 @@ # end merge # cancel -mr.cancel_merge_when_build_succeeds() +mr.cancel_merge_when_build_succeeds() # v3 +mr.cancel_merge_when_pipeline_succeeds() # v4 # end cancel # issues diff --git a/docs/gl_objects/mrs.rst b/docs/gl_objects/mrs.rst index d6e10d30d..04d413c1f 100644 --- a/docs/gl_objects/mrs.rst +++ b/docs/gl_objects/mrs.rst @@ -5,9 +5,26 @@ Merge requests You can use merge requests to notify a project that a branch is ready for merging. The owner of the target projet can accept the merge request. -* Object class: :class:`~gitlab.objects.ProjectMergeRequest` -* Manager objects: :attr:`gitlab.Gitlab.project_mergerequests`, - :attr:`Project.mergerequests ` +The v3 API uses the ``id`` attribute to identify a merge request, the v4 API +uses the ``iid`` attribute. + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectMergeRequest` + + :class:`gitlab.v4.objects.ProjectMergeRequestManager` + + :attr:`gitlab.v4.objects.Project.mergerequests` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectMergeRequest` + + :class:`gitlab.v3.objects.ProjectMergeRequestManager` + + :attr:`gitlab.v3.objects.Project.mergerequests` + + :attr:`gitlab.Gitlab.project_mergerequests` + +* GitLab API: https://docs.gitlab.com/ce/api/merge_requests.html Examples -------- @@ -20,8 +37,8 @@ List MRs for a project: You can filter and sort the returned list with the following parameters: -* ``iid``: iid (unique ID for the project) of the MR -* ``state``: state of the MR. It can be one of ``all``, ``merged``, '``opened`` +* ``iid``: iid (unique ID for the project) of the MR (v3 API) +* ``state``: state of the MR. It can be one of ``all``, ``merged``, ``opened`` or ``closed`` * ``order_by``: sort by ``created_at`` or ``updated_at`` * ``sort``: sort order (``asc`` or ``desc``) diff --git a/docs/gl_objects/namespaces.rst b/docs/gl_objects/namespaces.rst index 1819180b9..0dabdd9e4 100644 --- a/docs/gl_objects/namespaces.rst +++ b/docs/gl_objects/namespaces.rst @@ -2,11 +2,25 @@ Namespaces ########## -Use :class:`~gitlab.objects.Namespace` objects to manipulate namespaces. The -:attr:`gitlab.Gitlab.namespaces` manager objects provides helper functions. +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.Namespace` + + :class:`gitlab.v4.objects.NamespaceManager` + + :attr:`gitlab.Gitlab.namespaces` + +* v3 API: + + + :class:`gitlab.v3.objects.Namespace` + + :class:`gitlab.v3.objects.NamespaceManager` + + :attr:`gitlab.Gitlab.namespaces` + +* GitLab API: https://docs.gitlab.com/ce/api/namespaces.html Examples -======== +-------- List namespaces: diff --git a/docs/gl_objects/notifications.rst b/docs/gl_objects/notifications.rst index 472f710e9..a7310f3c0 100644 --- a/docs/gl_objects/notifications.rst +++ b/docs/gl_objects/notifications.rst @@ -5,22 +5,44 @@ Notification settings You can define notification settings globally, for groups and for projects. Valid levels are defined as constants: -* ``NOTIFICATION_LEVEL_DISABLED`` -* ``NOTIFICATION_LEVEL_PARTICIPATING`` -* ``NOTIFICATION_LEVEL_WATCH`` -* ``NOTIFICATION_LEVEL_GLOBAL`` -* ``NOTIFICATION_LEVEL_MENTION`` -* ``NOTIFICATION_LEVEL_CUSTOM`` +* ``gitlab.NOTIFICATION_LEVEL_DISABLED`` +* ``gitlab.NOTIFICATION_LEVEL_PARTICIPATING`` +* ``gitlab.NOTIFICATION_LEVEL_WATCH`` +* ``gitlab.NOTIFICATION_LEVEL_GLOBAL`` +* ``gitlab.NOTIFICATION_LEVEL_MENTION`` +* ``gitlab.NOTIFICATION_LEVEL_CUSTOM`` You get access to fine-grained settings if you use the ``NOTIFICATION_LEVEL_CUSTOM`` level. -* Object classes: :class:`gitlab.objects.NotificationSettings` (global), - :class:`gitlab.objects.GroupNotificationSettings` (groups) and - :class:`gitlab.objects.ProjectNotificationSettings` (projects) -* Manager objects: :attr:`gitlab.Gitlab.notificationsettings` (global), - :attr:`gitlab.objects.Group.notificationsettings` (groups) and - :attr:`gitlab.objects.Project.notificationsettings` (projects) +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.NotificationSettings` + + :class:`gitlab.v4.objects.NotificationSettingsManager` + + :attr:`gitlab.Gitlab.notificationsettings` + + :class:`gitlab.v4.objects.GroupNotificationSettings` + + :class:`gitlab.v4.objects.GroupNotificationSettingsManager` + + :attr:`gitlab.v4.objects.Group.notificationsettings` + + :class:`gitlab.v4.objects.ProjectNotificationSettings` + + :class:`gitlab.v4.objects.ProjectNotificationSettingsManager` + + :attr:`gitlab.v4.objects.Project.notificationsettings` + +* v3 API: + + + :class:`gitlab.v3.objects.NotificationSettings` + + :class:`gitlab.v3.objects.NotificationSettingsManager` + + :attr:`gitlab.Gitlab.notificationsettings` + + :class:`gitlab.v3.objects.GroupNotificationSettings` + + :class:`gitlab.v3.objects.GroupNotificationSettingsManager` + + :attr:`gitlab.v3.objects.Group.notificationsettings` + + :class:`gitlab.v3.objects.ProjectNotificationSettings` + + :class:`gitlab.v3.objects.ProjectNotificationSettingsManager` + + :attr:`gitlab.v3.objects.Project.notificationsettings` + +* GitLab API: https://docs.gitlab.com/ce/api/notification_settings.html Examples -------- diff --git a/docs/gl_objects/projects.py b/docs/gl_objects/projects.py index 428f3578a..131f43c66 100644 --- a/docs/gl_objects/projects.py +++ b/docs/gl_objects/projects.py @@ -48,8 +48,6 @@ # end delete # fork -fork = gl.project_forks.create({}, project_id=1) -# or fork = project.forks.create({}) # fork to a specific namespace @@ -78,28 +76,18 @@ # end events list # members list -members = gl.project_members.list() -# or members = project.members.list() # end members list # members search -members = gl.project_members.list(query='foo') -# or members = project.members.list(query='bar') # end members search # members get -member = gl.project_members.get(1) -# or member = project.members.get(1) # end members get # members add -member = gl.project_members.create({'user_id': user.id, 'access_level': - gitlab.DEVELOPER_ACCESS}, - project_id=1) -# or member = project.members.create({'user_id': user.id, 'access_level': gitlab.DEVELOPER_ACCESS}) # end members add @@ -110,8 +98,6 @@ # end members update # members delete -gl.project_members.delete(user.id, project_id=1) -# or project.members.delete(user.id) # or member.delete() @@ -122,14 +108,10 @@ # end share # hook list -hooks = gl.project_hooks.list(project_id=1) -# or hooks = project.hooks.list() # end hook list # hook get -hook = gl.project_hooks.get(1, project_id=1) -# or hook = project.hooks.get(1) # end hook get @@ -147,8 +129,6 @@ # end hook update # hook delete -gl.project_hooks.delete(1, project_id=1) -# or project.hooks.delete(1) # or hook.delete() @@ -199,9 +179,6 @@ # end repository contributors # files get -f = gl.project_files.get(file_path='README.rst', ref='master', - project_id=1) -# or f = project.files.get(file_path='README.rst', ref='master') # get the base64 encoded content @@ -212,12 +189,13 @@ # end files get # files create -f = gl.project_files.create({'file_path': 'testfile', - 'branch_name': 'master', - 'content': file_content, - 'commit_message': 'Create testfile'}, - project_id=1) -# or +# v4 +f = project.files.create({'file_path': 'testfile', + 'branch': 'master', + 'content': file_content, + 'commit_message': 'Create testfile'}) + +# v3 f = project.files.create({'file_path': 'testfile', 'branch_name': 'master', 'content': file_content, @@ -226,50 +204,33 @@ # files update f.content = 'new content' -f.save(branch_name='master', commit_message='Update testfile') +f.save(branch'master', commit_message='Update testfile') # v4 +f.save(branch_name='master', commit_message='Update testfile') # v3 # or for binary data # Note: decode() is required with python 3 for data serialization. You can omit # it with python 2 f.content = base64.b64encode(open('image.png').read()).decode() -f.save(branch_name='master', commit_message='Update testfile', encoding='base64') +f.save(branch='master', commit_message='Update testfile', encoding='base64') # end files update # files delete -gl.project_files.delete({'file_path': 'testfile', - 'branch_name': 'master', - 'commit_message': 'Delete testfile'}, - project_id=1) -# or -project.files.delete({'file_path': 'testfile', - 'branch_name': 'master', - 'commit_message': 'Delete testfile'}) -# or f.delete(commit_message='Delete testfile') # end files delete # tags list -tags = gl.project_tags.list(project_id=1) -# or tags = project.tags.list() # end tags list # tags get -tag = gl.project_tags.list('1.0', project_id=1) -# or tags = project.tags.list('1.0') # end tags get # tags create -tag = gl.project_tags.create({'tag_name': '1.0', 'ref': 'master'}, - project_id=1) -# or tag = project.tags.create({'tag_name': '1.0', 'ref': 'master'}) # end tags create # tags delete -gl.project_tags.delete('1.0', project_id=1) -# or project.tags.delete('1.0') # or tag.delete() @@ -280,25 +241,14 @@ # end tags release # snippets list -snippets = gl.project_snippets.list(project_id=1) -# or snippets = project.snippets.list() # end snippets list # snippets get -snippet = gl.project_snippets.list(snippet_id, project_id=1) -# or snippets = project.snippets.list(snippet_id) # end snippets get # snippets create -snippet = gl.project_snippets.create({'title': 'sample 1', - 'file_name': 'foo.py', - 'code': 'import gitlab', - 'visibility_level': - gitlab.VISIBILITY_PRIVATE}, - project_id=1) -# or snippet = project.snippets.create({'title': 'sample 1', 'file_name': 'foo.py', 'code': 'import gitlab', @@ -316,43 +266,24 @@ # end snippets update # snippets delete -gl.project_snippets.delete(snippet_id, project_id=1) -# or project.snippets.delete(snippet_id) # or snippet.delete() # end snippets delete # notes list -i_notes = gl.project_issue_notes.list(project_id=1, issue_id=2) -mr_notes = gl.project_mergerequest_notes.list(project_id=1, merge_request_id=2) -s_notes = gl.project_snippet_notes.list(project_id=1, snippet_id=2) -# or i_notes = issue.notes.list() mr_notes = mr.notes.list() s_notes = snippet.notes.list() # end notes list # notes get -i_notes = gl.project_issue_notes.get(note_id, project_id=1, issue_id=2) -mr_notes = gl.project_mergerequest_notes.get(note_id, project_id=1, - merge_request_id=2) -s_notes = gl.project_snippet_notes.get(note_id, project_id=1, snippet_id=2) -# or i_note = issue.notes.get(note_id) mr_note = mr.notes.get(note_id) s_note = snippet.notes.get(note_id) # end notes get # notes create -i_note = gl.project_issue_notes.create({'body': 'note content'}, - project_id=1, issue_id=2) -mr_note = gl.project_mergerequest_notes.create({'body': 'note content'} - project_id=1, - merge_request_id=2) -s_note = gl.project_snippet_notes.create({'body': 'note content'}, - project_id=1, snippet_id=2) -# or i_note = issue.notes.create({'body': 'note content'}) mr_note = mr.notes.create({'body': 'note content'}) s_note = snippet.notes.create({'body': 'note content'}) @@ -368,8 +299,6 @@ # end notes delete # service get -service = gl.project_services.get(service_name='asana', project_id=1) -# or service = project.services.get(service_name='asana', project_id=1) # display it's status (enabled/disabled) print(service.active) @@ -389,20 +318,14 @@ # end service delete # pipeline list -pipelines = gl.project_pipelines.list(project_id=1) -# or pipelines = project.pipelines.list() # end pipeline list # pipeline get -pipeline = gl.project_pipelines.get(pipeline_id, project_id=1) -# or pipeline = project.pipelines.get(pipeline_id) # end pipeline get # pipeline create -pipeline = gl.project_pipelines.create({'project_id': 1, 'ref': 'master'}) -# or pipeline = project.pipelines.create({'ref': 'master'}) # end pipeline create @@ -415,14 +338,10 @@ # end pipeline cancel # boards list -boards = gl.project_boards.list(project_id=1) -# or boards = project.boards.list() # end boards list # boards get -board = gl.project_boards.get(board_id, project_id=1) -# or board = project.boards.get(board_id) # end boards get diff --git a/docs/gl_objects/projects.rst b/docs/gl_objects/projects.rst index 300b84845..4a8a0ad27 100644 --- a/docs/gl_objects/projects.rst +++ b/docs/gl_objects/projects.rst @@ -2,11 +2,28 @@ Projects ######## -Use :class:`~gitlab.objects.Project` objects to manipulate projects. The -:attr:`gitlab.Gitlab.projects` manager objects provides helper functions. +Projects +======== + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.Project` + + :class:`gitlab.v4.objects.ProjectManager` + + :attr:`gitlab.Gitlab.projects` + +* v3 API: + + + :class:`gitlab.v3.objects.Project` + + :class:`gitlab.v3.objects.ProjectManager` + + :attr:`gitlab.Gitlab.projects` + +* GitLab API: https://docs.gitlab.com/ce/api/projects.html Examples -======== +-------- List projects: @@ -97,11 +114,6 @@ Archive/unarchive a project: Previous versions used ``archive_`` and ``unarchive_`` due to a naming issue, they have been deprecated but not yet removed. -Repository ----------- - -The following examples show how you can manipulate the project code repository. - List the repository tree: .. literalinclude:: projects.py @@ -148,10 +160,29 @@ Get a list of contributors for the repository: :start-after: # repository contributors :end-before: # end repository contributors -Files ------ +Project files +============= + +Reference +--------- + +* v4 API: -The following examples show how you can manipulate the project files. + + :class:`gitlab.v4.objects.ProjectFile` + + :class:`gitlab.v4.objects.ProjectFileManager` + + :attr:`gitlab.v4.objects.Project.files` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectFile` + + :class:`gitlab.v3.objects.ProjectFileManager` + + :attr:`gitlab.v3.objects.Project.files` + + :attr:`gitlab.Gitlab.project_files` + +* GitLab API: https://docs.gitlab.com/ce/api/repository_files.html + +Examples +-------- Get a file: @@ -178,12 +209,29 @@ Delete a file: :start-after: # files delete :end-before: # end files delete -Tags ----- +Project tags +============ + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectTag` + + :class:`gitlab.v4.objects.ProjectTagManager` + + :attr:`gitlab.v4.objects.Project.tags` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectTag` + + :class:`gitlab.v3.objects.ProjectTagManager` + + :attr:`gitlab.v3.objects.Project.tags` + + :attr:`gitlab.Gitlab.project_tags` -Use :class:`~gitlab.objects.ProjectTag` objects to manipulate tags. The -:attr:`gitlab.Gitlab.project_tags` and :attr:`Project.tags -` manager objects provide helper functions. +* GitLab API: https://docs.gitlab.com/ce/api/tags.html + +Examples +-------- List the project tags: @@ -217,12 +265,35 @@ Delete a tag: .. _project_snippets: -Snippets --------- +Project snippets +================ -Use :class:`~gitlab.objects.ProjectSnippet` objects to manipulate snippets. The -:attr:`gitlab.Gitlab.project_snippets` and :attr:`Project.snippets -` manager objects provide helper functions. +The snippet visibility can be definied using the following constants: + +* ``gitlab.VISIBILITY_PRIVATE`` +* ``gitlab.VISIBILITY_INTERNAL`` +* ``gitlab.VISIBILITY_PUBLIC`` + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectSnippet` + + :class:`gitlab.v4.objects.ProjectSnippetManager` + + :attr:`gitlab.v4.objects.Project.files` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectSnippet` + + :class:`gitlab.v3.objects.ProjectSnippetManager` + + :attr:`gitlab.v3.objects.Project.files` + + :attr:`gitlab.Gitlab.project_files` + +* GitLab API: https://docs.gitlab.com/ce/api/project_snippets.html + +Examples +-------- List the project snippets: @@ -266,9 +337,9 @@ Delete a snippet: :end-before: # end snippets delete Notes ------ +===== -You can manipulate notes (comments) on the following resources: +You can manipulate notes (comments) on the issues, merge requests and snippets. * :class:`~gitlab.objects.ProjectIssue` with :class:`~gitlab.objects.ProjectIssueNote` @@ -277,6 +348,60 @@ You can manipulate notes (comments) on the following resources: * :class:`~gitlab.objects.ProjectSnippet` with :class:`~gitlab.objects.ProjectSnippetNote` +Reference +--------- + +* v4 API: + + Issues: + + + :class:`gitlab.v4.objects.ProjectIssueNote` + + :class:`gitlab.v4.objects.ProjectIssueNoteManager` + + :attr:`gitlab.v4.objects.ProjectIssue.notes` + + MergeRequests: + + + :class:`gitlab.v4.objects.ProjectMergeRequestNote` + + :class:`gitlab.v4.objects.ProjectMergeRequestNoteManager` + + :attr:`gitlab.v4.objects.ProjectMergeRequest.notes` + + Snippets: + + + :class:`gitlab.v4.objects.ProjectSnippetNote` + + :class:`gitlab.v4.objects.ProjectSnippetNoteManager` + + :attr:`gitlab.v4.objects.ProjectSnippet.notes` + +* v3 API: + + Issues: + + + :class:`gitlab.v3.objects.ProjectIssueNote` + + :class:`gitlab.v3.objects.ProjectIssueNoteManager` + + :attr:`gitlab.v3.objects.ProjectIssue.notes` + + :attr:`gitlab.v3.objects.Project.issue_notes` + + :attr:`gitlab.Gitlab.project_issue_notes` + + MergeRequests: + + + :class:`gitlab.v3.objects.ProjectMergeRequestNote` + + :class:`gitlab.v3.objects.ProjectMergeRequestNoteManager` + + :attr:`gitlab.v3.objects.ProjectMergeRequest.notes` + + :attr:`gitlab.v3.objects.Project.mergerequest_notes` + + :attr:`gitlab.Gitlab.project_mergerequest_notes` + + Snippets: + + + :class:`gitlab.v3.objects.ProjectSnippetNote` + + :class:`gitlab.v3.objects.ProjectSnippetNoteManager` + + :attr:`gitlab.v3.objects.ProjectSnippet.notes` + + :attr:`gitlab.v3.objects.Project.snippet_notes` + + :attr:`gitlab.Gitlab.project_snippet_notes` + +* GitLab API: https://docs.gitlab.com/ce/api/repository_files.html + +Examples +-------- + List the notes for a resource: .. literalinclude:: projects.py @@ -307,12 +432,29 @@ Delete a note for a resource: :start-after: # notes delete :end-before: # end notes delete -Events ------- +Project events +============== -Use :class:`~gitlab.objects.ProjectEvent` objects to manipulate events. The -:attr:`gitlab.Gitlab.project_events` and :attr:`Project.events -` manager objects provide helper functions. +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectEvent` + + :class:`gitlab.v4.objects.ProjectEventManager` + + :attr:`gitlab.v4.objects.Project.events` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectEvent` + + :class:`gitlab.v3.objects.ProjectEventManager` + + :attr:`gitlab.v3.objects.Project.events` + + :attr:`gitlab.Gitlab.project_events` + +* GitLab API: https://docs.gitlab.com/ce/api/repository_files.html + +Examples +-------- List the project events: @@ -320,12 +462,29 @@ List the project events: :start-after: # events list :end-before: # end events list -Team members ------------- +Project members +=============== -Use :class:`~gitlab.objects.ProjectMember` objects to manipulate projects -members. The :attr:`gitlab.Gitlab.project_members` and :attr:`Project.members -` manager objects provide helper functions. +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectMember` + + :class:`gitlab.v4.objects.ProjectMemberManager` + + :attr:`gitlab.v4.objects.Project.members` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectMember` + + :class:`gitlab.v3.objects.ProjectMemberManager` + + :attr:`gitlab.v3.objects.Project.members` + + :attr:`gitlab.Gitlab.project_members` + +* GitLab API: https://docs.gitlab.com/ce/api/members.html + +Examples +-------- List the project members: @@ -369,12 +528,29 @@ Share the project with a group: :start-after: # share :end-before: # end share -Hooks ------ +Project hooks +============= + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectHook` + + :class:`gitlab.v4.objects.ProjectHookManager` + + :attr:`gitlab.v4.objects.Project.hooks` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectHook` + + :class:`gitlab.v3.objects.ProjectHookManager` + + :attr:`gitlab.v3.objects.Project.hooks` + + :attr:`gitlab.Gitlab.project_hooks` + +* GitLab API: https://docs.gitlab.com/ce/api/projects.html#hooks -Use :class:`~gitlab.objects.ProjectHook` objects to manipulate projects -hooks. The :attr:`gitlab.Gitlab.project_hooks` and :attr:`Project.hooks -` manager objects provide helper functions. +Examples +-------- List the project hooks: @@ -406,13 +582,29 @@ Delete a project hook: :start-after: # hook delete :end-before: # end hook delete -Pipelines +Project pipelines +================= + +Reference --------- -Use :class:`~gitlab.objects.ProjectPipeline` objects to manipulate projects -pipelines. The :attr:`gitlab.Gitlab.project_pipelines` and -:attr:`Project.services ` manager objects -provide helper functions. +* v4 API: + + + :class:`gitlab.v4.objects.ProjectPipeline` + + :class:`gitlab.v4.objects.ProjectPipelineManager` + + :attr:`gitlab.v4.objects.Project.pipelines` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectPipeline` + + :class:`gitlab.v3.objects.ProjectPipelineManager` + + :attr:`gitlab.v3.objects.Project.pipelines` + + :attr:`gitlab.Gitlab.project_pipelines` + +* GitLab API: https://docs.gitlab.com/ce/api/pipelines.html + +Examples +-------- List pipelines for a project: @@ -444,13 +636,29 @@ Create a pipeline for a particular reference: :start-after: # pipeline create :end-before: # end pipeline create -Services --------- +Project Services +================ + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectService` + + :class:`gitlab.v4.objects.ProjectServiceManager` + + :attr:`gitlab.v4.objects.Project.services` + +* v3 API: -Use :class:`~gitlab.objects.ProjectService` objects to manipulate projects -services. The :attr:`gitlab.Gitlab.project_services` and -:attr:`Project.services ` manager objects -provide helper functions. + + :class:`gitlab.v3.objects.ProjectService` + + :class:`gitlab.v3.objects.ProjectServiceManager` + + :attr:`gitlab.v3.objects.Project.services` + + :attr:`gitlab.Gitlab.project_services` + +* GitLab API: https://docs.gitlab.com/ce/api/services.html + +Exammples +--------- Get a service: @@ -476,13 +684,34 @@ Disable a service: :start-after: # service delete :end-before: # end service delete -Boards ------- +Issue boards +============ Boards are a visual representation of existing issues for a project. Issues can be moved from one list to the other to track progress and help with priorities. +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectBoard` + + :class:`gitlab.v4.objects.ProjectBoardManager` + + :attr:`gitlab.v4.objects.Project.boards` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectBoard` + + :class:`gitlab.v3.objects.ProjectBoardManager` + + :attr:`gitlab.v3.objects.Project.boards` + + :attr:`gitlab.Gitlab.project_boards` + +* GitLab API: https://docs.gitlab.com/ce/api/boards.html + +Examples +-------- + Get the list of existing boards for a project: .. literalinclude:: projects.py @@ -495,8 +724,30 @@ Get a single board for a project: :start-after: # boards get :end-before: # end boards get -Boards have lists of issues. Each list is defined by a -:class:`~gitlab.objects.ProjectLabel` and a position in the board. +Board lists +=========== + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectBoardList` + + :class:`gitlab.v4.objects.ProjectBoardListManager` + + :attr:`gitlab.v4.objects.Project.board_lists` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectBoardList` + + :class:`gitlab.v3.objects.ProjectBoardListManager` + + :attr:`gitlab.v3.objects.ProjectBoard.lists` + + :attr:`gitlab.v3.objects.Project.board_lists` + + :attr:`gitlab.Gitlab.project_board_lists` + +* GitLab API: https://docs.gitlab.com/ce/api/boards.html + +Examples +-------- List the issue lists for a board: @@ -510,15 +761,14 @@ Get a single list: :start-after: # board lists get :end-before: # end board lists get -Create a new list. Note that getting the label ID is broken at the moment (see -https://gitlab.com/gitlab-org/gitlab-ce/issues/23448): +Create a new list: .. literalinclude:: projects.py :start-after: # board lists create :end-before: # end board lists create Change a list position. The first list is at position 0. Moving a list will -insert it at the given position and move the following lists up a position: +set it at the given position and move the following lists up a position: .. literalinclude:: projects.py :start-after: # board lists update diff --git a/docs/gl_objects/runners.py b/docs/gl_objects/runners.py index 1a9cb82dd..93aca0d85 100644 --- a/docs/gl_objects/runners.py +++ b/docs/gl_objects/runners.py @@ -24,19 +24,13 @@ # end delete # project list -runners = gl.project_runners.list(project_id=1) -# or runners = project.runners.list() # end project list # project enable -p_runner = gl.project_runners.create({'runner_id': runner.id}, project_id=1) -# or p_runner = project.runners.create({'runner_id': runner.id}) # end project enable # project disable -gl.project_runners.delete(runner.id) -# or project.runners.delete(runner.id) # end project disable diff --git a/docs/gl_objects/runners.rst b/docs/gl_objects/runners.rst index 02db9be3a..e26c8af47 100644 --- a/docs/gl_objects/runners.rst +++ b/docs/gl_objects/runners.rst @@ -2,7 +2,7 @@ Runners ####### -Runners are external process used to run CI jobs. They are deployed by the +Runners are external processes used to run CI jobs. They are deployed by the administrator and registered to the GitLab instance. Shared runners are available for all projects. Specific runners are enabled for @@ -11,8 +11,22 @@ a list of projects. Global runners (admin) ====================== -* Object class: :class:`~gitlab.objects.Runner` -* Manager objects: :attr:`gitlab.Gitlab.runners` +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.Runner` + + :class:`gitlab.v4.objects.RunnerManager` + + :attr:`gitlab.Gitlab.runners` + +* v3 API: + + + :class:`gitlab.v3.objects.Runner` + + :class:`gitlab.v3.objects.RunnerManager` + + :attr:`gitlab.Gitlab.runners` + +* GitLab API: https://docs.gitlab.com/ce/api/runners.html Examples -------- @@ -58,9 +72,23 @@ Remove a runner: Project runners =============== -* Object class: :class:`~gitlab.objects.ProjectRunner` -* Manager objects: :attr:`gitlab.Gitlab.runners`, - :attr:`gitlab.Gitlab.Project.runners` +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectRunner` + + :class:`gitlab.v4.objects.ProjectRunnerManager` + + :attr:`gitlab.v4.objects.Project.runners` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectRunner` + + :class:`gitlab.v3.objects.ProjectRunnerManager` + + :attr:`gitlab.v3.objects.Project.runners` + + :attr:`gitlab.Gitlab.project_runners` + +* GitLab API: https://docs.gitlab.com/ce/api/runners.html Examples -------- diff --git a/docs/gl_objects/settings.rst b/docs/gl_objects/settings.rst index 26f68c598..5f0e92f41 100644 --- a/docs/gl_objects/settings.rst +++ b/docs/gl_objects/settings.rst @@ -2,9 +2,22 @@ Settings ######## -Use :class:`~gitlab.objects.ApplicationSettings` objects to manipulate Gitlab -settings. The :attr:`gitlab.Gitlab.settings` manager object provides helper -functions. +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ApplicationSettings` + + :class:`gitlab.v4.objects.ApplicationSettingsManager` + + :attr:`gitlab.Gitlab.settings` + +* v3 API: + + + :class:`gitlab.v3.objects.ApplicationSettings` + + :class:`gitlab.v3.objects.ApplicationSettingsManager` + + :attr:`gitlab.Gitlab.settings` + +* GitLab API: https://docs.gitlab.com/ce/api/commits.html Examples -------- diff --git a/docs/gl_objects/sidekiq.rst b/docs/gl_objects/sidekiq.rst index a75a02d51..593dda00b 100644 --- a/docs/gl_objects/sidekiq.rst +++ b/docs/gl_objects/sidekiq.rst @@ -2,8 +2,20 @@ Sidekiq metrics ############### -Use the :attr:`gitlab.Gitlab.sideqik` manager object to access Gitlab Sidekiq -server metrics. +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.SidekiqManager` + + :attr:`gitlab.Gitlab.sidekiq` + +* v3 API: + + + :class:`gitlab.v3.objects.SidekiqManager` + + :attr:`gitlab.Gitlab.sidekiq` + +* GitLab API: https://docs.gitlab.com/ce/api/sidekiq_metrics.html Examples -------- diff --git a/docs/gl_objects/system_hooks.rst b/docs/gl_objects/system_hooks.rst index 1d1804bb4..a9e9feefc 100644 --- a/docs/gl_objects/system_hooks.rst +++ b/docs/gl_objects/system_hooks.rst @@ -2,8 +2,22 @@ System hooks ############ -Use :class:`~gitlab.objects.Hook` objects to manipulate system hooks. The -:attr:`gitlab.Gitlab.hooks` manager object provides helper functions. +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.Hook` + + :class:`gitlab.v4.objects.HookManager` + + :attr:`gitlab.Gitlab.hooks` + +* v3 API: + + + :class:`gitlab.v3.objects.Hook` + + :class:`gitlab.v3.objects.HookManager` + + :attr:`gitlab.Gitlab.hooks` + +* GitLab API: https://docs.gitlab.com/ce/api/system_hooks.html Examples -------- diff --git a/docs/gl_objects/templates.py b/docs/gl_objects/templates.py index 1bc97bb8f..0874dc724 100644 --- a/docs/gl_objects/templates.py +++ b/docs/gl_objects/templates.py @@ -24,3 +24,12 @@ gitlabciyml = gl.gitlabciymls.get('Pelican') print(gitlabciyml.content) # end gitlabciyml get + +# dockerfile list +dockerfiles = gl.dockerfiles.list() +# end dockerfile list + +# dockerfile get +dockerfile = gl.dockerfiles.get('Python') +print(dockerfile.content) +# end dockerfile get diff --git a/docs/gl_objects/templates.rst b/docs/gl_objects/templates.rst index 1ce429d3c..c43b7ae60 100644 --- a/docs/gl_objects/templates.rst +++ b/docs/gl_objects/templates.rst @@ -7,12 +7,27 @@ You can request templates for different type of files: * License files * .gitignore files * GitLab CI configuration files +* Dockerfiles License templates ================= -* Object class: :class:`~gitlab.objects.License` -* Manager object: :attr:`gitlab.Gitlab.licenses` +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.License` + + :class:`gitlab.v4.objects.LicenseManager` + + :attr:`gitlab.Gitlab.licenses` + +* v3 API: + + + :class:`gitlab.v3.objects.License` + + :class:`gitlab.v3.objects.LicenseManager` + + :attr:`gitlab.Gitlab.licenses` + +* GitLab API: https://docs.gitlab.com/ce/api/templates/licenses.html Examples -------- @@ -32,8 +47,22 @@ Generate a license content for a project: .gitignore templates ==================== -* Object class: :class:`~gitlab.objects.Gitignore` -* Manager object: :attr:`gitlab.Gitlab.gitognores` +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.Gitignore` + + :class:`gitlab.v4.objects.GitignoreManager` + + :attr:`gitlab.Gitlab.gitignores` + +* v3 API: + + + :class:`gitlab.v3.objects.Gitignore` + + :class:`gitlab.v3.objects.GitignoreManager` + + :attr:`gitlab.Gitlab.gitignores` + +* GitLab API: https://docs.gitlab.com/ce/api/templates/gitignores.html Examples -------- @@ -53,8 +82,22 @@ Get a gitignore template: GitLab CI templates =================== -* Object class: :class:`~gitlab.objects.Gitlabciyml` -* Manager object: :attr:`gitlab.Gitlab.gitlabciymls` +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.Gitlabciyml` + + :class:`gitlab.v4.objects.GitlabciymlManager` + + :attr:`gitlab.Gitlab.gitlabciymls` + +* v3 API: + + + :class:`gitlab.v3.objects.Gitlabciyml` + + :class:`gitlab.v3.objects.GitlabciymlManager` + + :attr:`gitlab.Gitlab.gitlabciymls` + +* GitLab API: https://docs.gitlab.com/ce/api/templates/gitlab_ci_ymls.html Examples -------- @@ -70,3 +113,32 @@ Get a GitLab CI template: .. literalinclude:: templates.py :start-after: # gitlabciyml get :end-before: # end gitlabciyml get + +Dockerfile templates +==================== + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.Dockerfile` + + :class:`gitlab.v4.objects.DockerfileManager` + + :attr:`gitlab.Gitlab.gitlabciymls` + +* GitLab API: Not documented. + +Examples +-------- + +List known Dockerfile templates: + +.. literalinclude:: templates.py + :start-after: # dockerfile list + :end-before: # end dockerfile list + +Get a Dockerfile template: + +.. literalinclude:: templates.py + :start-after: # dockerfile get + :end-before: # end dockerfile get From b919555cb434005242e16161abba9ae022455b31 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sat, 12 Aug 2017 09:48:34 +0200 Subject: [PATCH 71/93] README: mention v4 support --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 2088ddfc8..cce2ad0e3 100644 --- a/README.rst +++ b/README.rst @@ -12,7 +12,7 @@ Python GitLab ``python-gitlab`` is a Python package providing access to the GitLab server API. -It supports the v3 api of GitLab, and provides a CLI tool (``gitlab``). +It supports the v3 and v4 APIs of GitLab, and provides a CLI tool (``gitlab``). Installation ============ From a4f0c520f4250ceb228087f31f7b351f74989020 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Thu, 17 Aug 2017 14:43:31 +0200 Subject: [PATCH 72/93] [v4] drop unused CurrentUserManager.credentials_auth method --- gitlab/v4/objects.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index e3780a9cc..03d75bf3d 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -260,11 +260,6 @@ class CurrentUserManager(GetWithoutIdMixin, RESTManager): _path = '/user' _obj_cls = CurrentUser - def credentials_auth(self, email, password): - data = {'email': email, 'password': password} - server_data = self.gitlab.http_post('/session', post_data=data) - return CurrentUser(self, server_data) - class ApplicationSettings(SaveMixin, RESTObject): _id_attr = None From 9783207f4577bd572f09c25707981ed5aa83b116 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Thu, 17 Aug 2017 22:12:39 +0200 Subject: [PATCH 73/93] [v4] CLI support is back --- gitlab/base.py | 7 + gitlab/cli.py | 41 +++++- gitlab/v4/cli.py | 296 +++++++++++++++++++++++++++++++++++++++++++ gitlab/v4/objects.py | 69 +++++++++- 4 files changed, 407 insertions(+), 6 deletions(-) create mode 100644 gitlab/v4/cli.py diff --git a/gitlab/base.py b/gitlab/base.py index df25a368a..a9521eb1d 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -607,6 +607,13 @@ def get_id(self): return None return getattr(self, self._id_attr) + @property + def attributes(self): + d = self.__dict__['_updated_attrs'].copy() + d.update(self.__dict__['_attrs']) + d.update(self.__dict__['_parent_attrs']) + return d + class RESTObjectList(object): """Generator object representing a list of RESTObject's. diff --git a/gitlab/cli.py b/gitlab/cli.py index f23fff9d3..d803eb590 100644 --- a/gitlab/cli.py +++ b/gitlab/cli.py @@ -17,8 +17,8 @@ # along with this program. If not, see . from __future__ import print_function -from __future__ import absolute_import import argparse +import functools import importlib import re import sys @@ -27,6 +27,36 @@ camel_re = re.compile('(.)([A-Z])') +# custom_actions = { +# cls: { +# action: (mandatory_args, optional_args, in_obj), +# }, +# } +custom_actions = {} + + +def register_custom_action(cls_name, mandatory=tuple(), optional=tuple()): + def wrap(f): + @functools.wraps(f) + def wrapped_f(*args, **kwargs): + return f(*args, **kwargs) + + # in_obj defines whether the method belongs to the obj or the manager + in_obj = True + final_name = cls_name + if cls_name.endswith('Manager'): + final_name = cls_name.replace('Manager', '') + in_obj = False + if final_name not in custom_actions: + custom_actions[final_name] = {} + + action = f.__name__ + + custom_actions[final_name][action] = (mandatory, optional, in_obj) + + return wrapped_f + return wrap + def die(msg, e=None): if e: @@ -51,6 +81,9 @@ def _get_base_parser(): parser.add_argument("-v", "--verbose", "--fancy", help="Verbose mode", action="store_true") + parser.add_argument("-d", "--debug", + help="Debug mode (display HTTP requests", + action="store_true") parser.add_argument("-c", "--config-file", action='append', help=("Configuration file to use. Can be used " "multiple times.")) @@ -84,12 +117,13 @@ def main(): config_files = args.config_file gitlab_id = args.gitlab verbose = args.verbose + debug = args.debug action = args.action what = args.what args = args.__dict__ # Remove CLI behavior-related args - for item in ('gitlab', 'config_file', 'verbose', 'what', 'action', + for item in ('gitlab', 'config_file', 'verbose', 'debug', 'what', 'action', 'version'): args.pop(item) args = {k: v for k, v in args.items() if v is not None} @@ -100,6 +134,9 @@ def main(): except Exception as e: die(str(e)) + if debug: + gl.enable_debug() + cli_module.run(gl, what, action, args, verbose) sys.exit(0) diff --git a/gitlab/v4/cli.py b/gitlab/v4/cli.py new file mode 100644 index 000000000..821a27d44 --- /dev/null +++ b/gitlab/v4/cli.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2013-2017 Gauvain Pocentek +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +from __future__ import print_function +import inspect +import operator + +import six + +import gitlab +import gitlab.base +from gitlab import cli +import gitlab.v4.objects + + +class GitlabCLI(object): + def __init__(self, gl, what, action, args): + self.cls_name = cli.what_to_cls(what) + self.cls = gitlab.v4.objects.__dict__[self.cls_name] + self.what = what.replace('-', '_') + self.action = action.lower().replace('-', '') + self.gl = gl + self.args = args + self.mgr_cls = getattr(gitlab.v4.objects, + self.cls.__name__ + 'Manager') + # We could do something smart, like splitting the manager name to find + # parents, build the chain of managers to get to the final object. + # Instead we do something ugly and efficient: interpolate variables in + # the class _path attribute, and replace the value with the result. + self.mgr_cls._path = self.mgr_cls._path % self.args + self.mgr = self.mgr_cls(gl) + + def __call__(self): + method = 'do_%s' % self.action + if hasattr(self, method): + return getattr(self, method)() + else: + return self.do_custom() + + def do_custom(self): + in_obj = cli.custom_actions[self.cls_name][self.action][2] + + # Get the object (lazy), then act + if in_obj: + data = {} + if hasattr(self.mgr, '_from_parent_attrs'): + for k in self.mgr._from_parent_attrs: + data[k] = self.args[k] + if gitlab.mixins.GetWithoutIdMixin not in inspect.getmro(self.cls): + data[self.cls._id_attr] = self.args.pop(self.cls._id_attr) + o = self.cls(self.mgr, data) + return getattr(o, self.action)(**self.args) + else: + return getattr(self.mgr, self.action)(**self.args) + + def do_create(self): + try: + return self.mgr.create(self.args) + except Exception as e: + cli.die("Impossible to create object", e) + + def do_list(self): + try: + return self.mgr.list(**self.args) + except Exception as e: + cli.die("Impossible to list objects", e) + + def do_get(self): + id = None + if gitlab.mixins.GetWithoutIdMixin not in inspect.getmro(self.cls): + id = self.args.pop(self.cls._id_attr) + + try: + return self.mgr.get(id, **self.args) + except Exception as e: + cli.die("Impossible to get object", e) + + def do_delete(self): + id = self.args.pop(self.cls._id_attr) + try: + self.mgr.delete(id, **self.args) + except Exception as e: + cli.die("Impossible to destroy object", e) + + def do_update(self): + id = self.args.pop(self.cls._id_attr) + try: + return self.mgr.update(id, self.args) + except Exception as e: + cli.die("Impossible to update object", e) + + +def _populate_sub_parser_by_class(cls, sub_parser): + mgr_cls_name = cls.__name__ + 'Manager' + mgr_cls = getattr(gitlab.v4.objects, mgr_cls_name) + + 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) + if hasattr(mgr_cls, '_from_parent_attrs'): + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=True) + for x in mgr_cls._from_parent_attrs] + sub_parser_action.add_argument("--sudo", required=False) + + if action_name == "list": + if hasattr(mgr_cls, '_list_filters'): + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=False) + for x in mgr_cls._list_filters] + + sub_parser_action.add_argument("--page", required=False) + sub_parser_action.add_argument("--per-page", required=False) + sub_parser_action.add_argument("--all", required=False, + action='store_true') + + if action_name == 'delete': + id_attr = cls._id_attr.replace('_', '-') + sub_parser_action.add_argument("--%s" % id_attr, required=True) + + if action_name == "get": + if gitlab.mixins.GetWithoutIdMixin not in inspect.getmro(cls): + if cls._id_attr is not None: + id_attr = cls._id_attr.replace('_', '-') + sub_parser_action.add_argument("--%s" % id_attr, + required=True) + + if hasattr(mgr_cls, '_optional_get_attrs'): + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=False) + for x in mgr_cls._optional_get_attrs] + + if action_name == "create": + if hasattr(mgr_cls, '_create_attrs'): + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=True) + for x in mgr_cls._create_attrs[0] if x != cls._id_attr] + + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=False) + for x in mgr_cls._create_attrs[1] if x != cls._id_attr] + + if action_name == "update": + if cls._id_attr is not None: + id_attr = cls._id_attr.replace('_', '-') + sub_parser_action.add_argument("--%s" % id_attr, + required=True) + + if hasattr(mgr_cls, '_update_attrs'): + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=True) + for x in mgr_cls._update_attrs[0] if x != cls._id_attr] + + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=False) + for x in mgr_cls._update_attrs[1] if x != cls._id_attr] + + 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) + # Get the attributes for URL/path construction + if hasattr(mgr_cls, '_from_parent_attrs'): + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=True) + for x in mgr_cls._from_parent_attrs] + sub_parser_action.add_argument("--sudo", required=False) + + # We need to get the object somehow + if gitlab.mixins.GetWithoutIdMixin not in inspect.getmro(cls): + if cls._id_attr is not None: + id_attr = cls._id_attr.replace('_', '-') + sub_parser_action.add_argument("--%s" % id_attr, + required=True) + + required, optional, dummy = cli.custom_actions[name][action_name] + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=True) + for x in required if x != cls._id_attr] + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=False) + for x in optional if x != cls._id_attr] + + 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) + if hasattr(mgr_cls, '_from_parent_attrs'): + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=True) + for x in mgr_cls._from_parent_attrs] + sub_parser_action.add_argument("--sudo", required=False) + + required, optional, dummy = cli.custom_actions[name][action_name] + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=True) + for x in required if x != cls._id_attr] + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=False) + for x in optional if x != cls._id_attr] + + +def extend_parser(parser): + subparsers = parser.add_subparsers(title='object', dest='what', + help="Object to manipulate.") + subparsers.required = True + + # populate argparse for all Gitlab Object + classes = [] + for cls in gitlab.v4.objects.__dict__.values(): + try: + if gitlab.base.RESTManager in inspect.getmro(cls): + if cls._obj_cls is not None: + classes.append(cls._obj_cls) + except AttributeError: + pass + classes.sort(key=operator.attrgetter("__name__")) + + for cls in classes: + arg_name = cli.cls_to_what(cls) + object_group = subparsers.add_parser(arg_name) + + object_subparsers = object_group.add_subparsers( + dest='action', help="Action to execute.") + _populate_sub_parser_by_class(cls, object_subparsers) + object_subparsers.required = True + + return parser + + +class LegacyPrinter(object): + def display(self, obj, verbose=False, padding=0): + def display_dict(d): + for k in sorted(d.keys()): + v = d[k] + if isinstance(v, dict): + print('%s%s:' % (' ' * padding, k)) + new_padding = padding + 2 + self.display(v, True, new_padding) + continue + print('%s%s: %s' % (' ' * padding, k, v)) + + if verbose: + if isinstance(obj, dict): + display_dict(obj) + return + + # not a dict, we assume it's a RESTObject + id = getattr(obj, obj._id_attr) + print('%s: %s' % (obj._id_attr, id)) + attrs = obj.attributes + attrs.pop(obj._id_attr) + display_dict(attrs) + print('') + + else: + id = getattr(obj, obj._id_attr) + print('%s: %s' % (obj._id_attr, id)) + if hasattr(obj, '_short_print_attr'): + value = getattr(obj, obj._short_print_attr) + print('%s: %s' % (obj._short_print_attr, value)) + + +def run(gl, what, action, args, verbose): + g_cli = GitlabCLI(gl, what, action, args) + ret_val = g_cli() + + printer = LegacyPrinter() + + if isinstance(ret_val, list): + for o in ret_val: + if isinstance(o, gitlab.base.RESTObject): + printer.display(o, verbose) + else: + print(o) + elif isinstance(ret_val, gitlab.base.RESTObject): + printer.display(ret_val, verbose) + elif isinstance(ret_val, six.string_types): + print(ret_val) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 03d75bf3d..641db82f7 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -22,6 +22,7 @@ import six from gitlab.base import * # noqa +from gitlab import cli from gitlab.exceptions import * # noqa from gitlab.mixins import * # noqa from gitlab import utils @@ -44,6 +45,7 @@ class SidekiqManager(RESTManager): for the sidekiq metrics API. """ + @cli.register_custom_action('SidekiqManager') @exc.on_http_error(exc.GitlabGetError) def queue_metrics(self, **kwargs): """Return the registred queues information. @@ -60,6 +62,7 @@ def queue_metrics(self, **kwargs): """ return self.gitlab.http_get('/sidekiq/queue_metrics', **kwargs) + @cli.register_custom_action('SidekiqManager') @exc.on_http_error(exc.GitlabGetError) def process_metrics(self, **kwargs): """Return the registred sidekiq workers. @@ -76,6 +79,7 @@ def process_metrics(self, **kwargs): """ return self.gitlab.http_get('/sidekiq/process_metrics', **kwargs) + @cli.register_custom_action('SidekiqManager') @exc.on_http_error(exc.GitlabGetError) def job_stats(self, **kwargs): """Return statistics about the jobs performed. @@ -92,6 +96,7 @@ def job_stats(self, **kwargs): """ return self.gitlab.http_get('/sidekiq/job_stats', **kwargs) + @cli.register_custom_action('SidekiqManager') @exc.on_http_error(exc.GitlabGetError) def compound_metrics(self, **kwargs): """Return all available metrics and statistics. @@ -156,6 +161,7 @@ class User(SaveMixin, ObjectDeleteMixin, RESTObject): ('projects', 'UserProjectManager'), ) + @cli.register_custom_action('User') @exc.on_http_error(exc.GitlabBlockError) def block(self, **kwargs): """Block the user. @@ -176,6 +182,7 @@ def block(self, **kwargs): self._attrs['state'] = 'blocked' return server_data + @cli.register_custom_action('User') @exc.on_http_error(exc.GitlabUnblockError) def unblock(self, **kwargs): """Unblock the user. @@ -440,6 +447,7 @@ class Snippet(SaveMixin, ObjectDeleteMixin, RESTObject): _constructor_types = {'author': 'User'} _short_print_attr = 'title' + @cli.register_custom_action('Snippet') @exc.on_http_error(exc.GitlabGetError) def content(self, streamed=False, action=None, chunk_size=1024, **kwargs): """Return the content of a snippet. @@ -474,6 +482,7 @@ class SnippetManager(CRUDMixin, RESTManager): _update_attrs = (tuple(), ('title', 'file_name', 'content', 'visibility')) + @cli.register_custom_action('SnippetManager') def public(self, **kwargs): """List all the public snippets. @@ -528,6 +537,9 @@ class ProjectBranch(ObjectDeleteMixin, RESTObject): _constructor_types = {'author': 'User', "committer": "User"} _id_attr = 'name' + @cli.register_custom_action('ProjectBranch', tuple(), + ('developers_can_push', + 'developers_can_merge')) @exc.on_http_error(exc.GitlabProtectError) def protect(self, developers_can_push=False, developers_can_merge=False, **kwargs): @@ -550,6 +562,7 @@ def protect(self, developers_can_push=False, developers_can_merge=False, self.manager.gitlab.http_put(path, post_data=post_data, **kwargs) self._attrs['protected'] = True + @cli.register_custom_action('ProjectBranch') @exc.on_http_error(exc.GitlabProtectError) def unprotect(self, **kwargs): """Unprotect the branch. @@ -578,6 +591,7 @@ class ProjectJob(RESTObject): 'commit': 'ProjectCommit', 'runner': 'Runner'} + @cli.register_custom_action('ProjectJob') @exc.on_http_error(exc.GitlabJobCancelError) def cancel(self, **kwargs): """Cancel the job. @@ -592,6 +606,7 @@ def cancel(self, **kwargs): path = '%s/%s/cancel' % (self.manager.path, self.get_id()) self.manager.gitlab.http_post(path) + @cli.register_custom_action('ProjectJob') @exc.on_http_error(exc.GitlabJobRetryError) def retry(self, **kwargs): """Retry the job. @@ -606,6 +621,7 @@ def retry(self, **kwargs): path = '%s/%s/retry' % (self.manager.path, self.get_id()) self.manager.gitlab.http_post(path) + @cli.register_custom_action('ProjectJob') @exc.on_http_error(exc.GitlabJobPlayError) def play(self, **kwargs): """Trigger a job explicitly. @@ -620,6 +636,7 @@ def play(self, **kwargs): path = '%s/%s/play' % (self.manager.path, self.get_id()) self.manager.gitlab.http_post(path) + @cli.register_custom_action('ProjectJob') @exc.on_http_error(exc.GitlabJobEraseError) def erase(self, **kwargs): """Erase the job (remove job artifacts and trace). @@ -634,6 +651,7 @@ def erase(self, **kwargs): path = '%s/%s/erase' % (self.manager.path, self.get_id()) self.manager.gitlab.http_post(path) + @cli.register_custom_action('ProjectJob') @exc.on_http_error(exc.GitlabCreateError) def keep_artifacts(self, **kwargs): """Prevent artifacts from being deleted when expiration is set. @@ -648,6 +666,7 @@ def keep_artifacts(self, **kwargs): path = '%s/%s/artifacts/keep' % (self.manager.path, self.get_id()) self.manager.gitlab.http_post(path) + @cli.register_custom_action('ProjectJob') @exc.on_http_error(exc.GitlabGetError) def artifacts(self, streamed=False, action=None, chunk_size=1024, **kwargs): @@ -674,6 +693,7 @@ def artifacts(self, streamed=False, action=None, chunk_size=1024, **kwargs) return utils.response_content(result, streamed, action, chunk_size) + @cli.register_custom_action('ProjectJob') @exc.on_http_error(exc.GitlabGetError) def trace(self, streamed=False, action=None, chunk_size=1024, **kwargs): """Get the job trace. @@ -759,6 +779,7 @@ class ProjectCommit(RESTObject): ('statuses', 'ProjectCommitStatusManager'), ) + @cli.register_custom_action('ProjectCommit') @exc.on_http_error(exc.GitlabGetError) def diff(self, **kwargs): """Generate the commit diff. @@ -776,6 +797,7 @@ def diff(self, **kwargs): path = '%s/%s/diff' % (self.manager.path, self.get_id()) return self.manager.gitlab.http_get(path, **kwargs) + @cli.register_custom_action('ProjectCommit', ('branch',)) @exc.on_http_error(exc.GitlabCherryPickError) def cherry_pick(self, branch, **kwargs): """Cherry-pick a commit into a branch. @@ -824,6 +846,7 @@ class ProjectKeyManager(NoUpdateMixin, RESTManager): _from_parent_attrs = {'project_id': 'id'} _create_attrs = (('title', 'key'), tuple()) + @cli.register_custom_action('ProjectKeyManager', ('key_id',)) @exc.on_http_error(exc.GitlabProjectDeployKeyError) def enable(self, key_id, **kwargs): """Enable a deploy key for a project. @@ -891,7 +914,7 @@ class ProjectIssueNoteManager(CRUDMixin, RESTManager): _path = '/projects/%(project_id)s/issues/%(issue_iid)s/notes' _obj_cls = ProjectIssueNote _from_parent_attrs = {'project_id': 'project_id', 'issue_iid': 'iid'} - _create_attrs = (('body', ), ('created_at')) + _create_attrs = (('body', ), ('created_at', )) _update_attrs = (('body', ), tuple()) @@ -903,6 +926,7 @@ class ProjectIssue(SubscribableMixin, TodoMixin, TimeTrackingMixin, SaveMixin, _id_attr = 'iid' _managers = (('notes', 'ProjectIssueNoteManager'), ) + @cli.register_custom_action('ProjectIssue', ('to_project_id',)) @exc.on_http_error(exc.GitlabUpdateError) def move(self, to_project_id, **kwargs): """Move the issue to another project. @@ -974,6 +998,7 @@ class ProjectTag(ObjectDeleteMixin, RESTObject): _id_attr = 'name' _short_print_attr = 'name' + @cli.register_custom_action('ProjectTag', ('description', )) def set_release_description(self, description, **kwargs): """Set the release notes on the tag. @@ -1048,6 +1073,7 @@ class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, ('diffs', 'ProjectMergeRequestDiffManager') ) + @cli.register_custom_action('ProjectMergeRequest') @exc.on_http_error(exc.GitlabMROnBuildSuccessError) def cancel_merge_when_pipeline_succeeds(self, **kwargs): """Cancel merge when the pipeline succeeds. @@ -1066,6 +1092,7 @@ def cancel_merge_when_pipeline_succeeds(self, **kwargs): server_data = self.manager.gitlab.http_put(path, **kwargs) self._update_attrs(server_data) + @cli.register_custom_action('ProjectMergeRequest') @exc.on_http_error(exc.GitlabListError) def closes_issues(self, **kwargs): """List issues that will close on merge." @@ -1087,6 +1114,7 @@ def closes_issues(self, **kwargs): parent=self.manager._parent) return RESTObjectList(manager, ProjectIssue, data_list) + @cli.register_custom_action('ProjectMergeRequest') @exc.on_http_error(exc.GitlabListError) def commits(self, **kwargs): """List the merge request commits. @@ -1109,6 +1137,7 @@ def commits(self, **kwargs): parent=self.manager._parent) return RESTObjectList(manager, ProjectCommit, data_list) + @cli.register_custom_action('ProjectMergeRequest') @exc.on_http_error(exc.GitlabListError) def changes(self, **kwargs): """List the merge request changes. @@ -1126,6 +1155,10 @@ def changes(self, **kwargs): path = '%s/%s/changes' % (self.manager.path, self.get_id()) return self.manager.gitlab.http_get(path, **kwargs) + @cli.register_custom_action('ProjectMergeRequest', tuple(), + ('merge_commit_message', + 'should_remove_source_branch', + 'merge_when_pipeline_succeeds')) @exc.on_http_error(exc.GitlabMRClosedError) def merge(self, merge_commit_message=None, should_remove_source_branch=False, @@ -1177,6 +1210,7 @@ class ProjectMergeRequestManager(CRUDMixin, RESTManager): class ProjectMilestone(SaveMixin, ObjectDeleteMixin, RESTObject): _short_print_attr = 'title' + @cli.register_custom_action('ProjectMilestone') @exc.on_http_error(exc.GitlabListError) def issues(self, **kwargs): """List issues related to this milestone. @@ -1200,6 +1234,7 @@ def issues(self, **kwargs): # FIXME(gpocentek): the computed manager path is not correct return RESTObjectList(manager, ProjectIssue, data_list) + @cli.register_custom_action('ProjectMilestone') @exc.on_http_error(exc.GitlabListError) def merge_requests(self, **kwargs): """List the merge requests related to this milestone. @@ -1399,6 +1434,7 @@ def delete(self, file_path, branch, commit_message, **kwargs): data = {'branch': branch, 'commit_message': commit_message} self.gitlab.http_delete(path, query_data=data, **kwargs) + @cli.register_custom_action('ProjectFileManager', ('file_path', 'ref')) @exc.on_http_error(exc.GitlabGetError) def raw(self, file_path, ref, streamed=False, action=None, chunk_size=1024, **kwargs): @@ -1431,6 +1467,7 @@ def raw(self, file_path, ref, streamed=False, action=None, chunk_size=1024, class ProjectPipeline(RESTObject): + @cli.register_custom_action('ProjectPipeline') @exc.on_http_error(exc.GitlabPipelineCancelError) def cancel(self, **kwargs): """Cancel the job. @@ -1445,6 +1482,7 @@ def cancel(self, **kwargs): path = '%s/%s/cancel' % (self.manager.path, self.get_id()) self.manager.gitlab.http_post(path) + @cli.register_custom_action('ProjectPipeline') @exc.on_http_error(exc.GitlabPipelineRetryError) def retry(self, **kwargs): """Retry the job. @@ -1504,6 +1542,7 @@ class ProjectSnippet(SaveMixin, ObjectDeleteMixin, RESTObject): _short_print_attr = 'title' _managers = (('notes', 'ProjectSnippetNoteManager'), ) + @cli.register_custom_action('ProjectSnippet') @exc.on_http_error(exc.GitlabGetError) def content(self, streamed=False, action=None, chunk_size=1024, **kwargs): """Return the content of a snippet. @@ -1540,6 +1579,7 @@ class ProjectSnippetManager(CRUDMixin, RESTManager): class ProjectTrigger(SaveMixin, ObjectDeleteMixin, RESTObject): + @cli.register_custom_action('ProjectTrigger') def take_ownership(self, **kwargs): """Update the owner of a trigger.""" path = '%s/%s/take_ownership' % (self.manager.path, self.get_id()) @@ -1652,6 +1692,7 @@ def update(self, id=None, new_data={}, **kwargs): super(ProjectServiceManager, self).update(id, new_data, **kwargs) self.id = id + @cli.register_custom_action('ProjectServiceManager') def available(self, **kwargs): """List the services known by python-gitlab. @@ -1725,6 +1766,7 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): ('variables', 'ProjectVariableManager'), ) + @cli.register_custom_action('Project', tuple(), ('path', 'ref')) @exc.on_http_error(exc.GitlabGetError) def repository_tree(self, path='', ref='', **kwargs): """Return a list of files in the repository. @@ -1750,6 +1792,7 @@ def repository_tree(self, path='', ref='', **kwargs): return self.manager.gitlab.http_get(gl_path, query_data=query_data, **kwargs) + @cli.register_custom_action('Project', ('sha', )) @exc.on_http_error(exc.GitlabGetError) def repository_blob(self, sha, **kwargs): """Return a blob by blob SHA. @@ -1769,6 +1812,7 @@ def repository_blob(self, sha, **kwargs): path = '/projects/%s/repository/blobs/%s' % (self.get_id(), sha) return self.manager.gitlab.http_get(path, **kwargs) + @cli.register_custom_action('Project', ('sha', )) @exc.on_http_error(exc.GitlabGetError) def repository_raw_blob(self, sha, streamed=False, action=None, chunk_size=1024, **kwargs): @@ -1796,6 +1840,7 @@ def repository_raw_blob(self, sha, streamed=False, action=None, **kwargs) return utils.response_content(result, streamed, action, chunk_size) + @cli.register_custom_action('Project', ('from_', 'to')) @exc.on_http_error(exc.GitlabGetError) def repository_compare(self, from_, to, **kwargs): """Return a diff between two branches/commits. @@ -1817,6 +1862,7 @@ def repository_compare(self, from_, to, **kwargs): return self.manager.gitlab.http_get(path, query_data=query_data, **kwargs) + @cli.register_custom_action('Project') @exc.on_http_error(exc.GitlabGetError) def repository_contributors(self, **kwargs): """Return a list of contributors for the project. @@ -1834,6 +1880,7 @@ def repository_contributors(self, **kwargs): path = '/projects/%s/repository/contributors' % self.get_id() return self.manager.gitlab.http_get(path, **kwargs) + @cli.register_custom_action('Project', tuple(), ('sha', )) @exc.on_http_error(exc.GitlabListError) def repository_archive(self, sha=None, streamed=False, action=None, chunk_size=1024, **kwargs): @@ -1864,6 +1911,7 @@ def repository_archive(self, sha=None, streamed=False, action=None, streamed=streamed, **kwargs) return utils.response_content(result, streamed, action, chunk_size) + @cli.register_custom_action('Project', ('forked_from_id', )) @exc.on_http_error(exc.GitlabCreateError) def create_fork_relation(self, forked_from_id, **kwargs): """Create a forked from/to relation between existing projects. @@ -1879,6 +1927,7 @@ def create_fork_relation(self, forked_from_id, **kwargs): path = '/projects/%s/fork/%s' % (self.get_id(), forked_from_id) self.manager.gitlab.http_post(path, **kwargs) + @cli.register_custom_action('Project') @exc.on_http_error(exc.GitlabDeleteError) def delete_fork_relation(self, **kwargs): """Delete a forked relation between existing projects. @@ -1893,6 +1942,7 @@ def delete_fork_relation(self, **kwargs): path = '/projects/%s/fork' % self.get_id() self.manager.gitlab.http_delete(path, **kwargs) + @cli.register_custom_action('Project') @exc.on_http_error(exc.GitlabCreateError) def star(self, **kwargs): """Star a project. @@ -1908,6 +1958,7 @@ def star(self, **kwargs): server_data = self.manager.gitlab.http_post(path, **kwargs) self._update_attrs(server_data) + @cli.register_custom_action('Project') @exc.on_http_error(exc.GitlabDeleteError) def unstar(self, **kwargs): """Unstar a project. @@ -1923,6 +1974,7 @@ def unstar(self, **kwargs): server_data = self.manager.gitlab.http_post(path, **kwargs) self._update_attrs(server_data) + @cli.register_custom_action('Project') @exc.on_http_error(exc.GitlabCreateError) def archive(self, **kwargs): """Archive a project. @@ -1938,6 +1990,7 @@ def archive(self, **kwargs): server_data = self.manager.gitlab.http_post(path, **kwargs) self._update_attrs(server_data) + @cli.register_custom_action('Project') @exc.on_http_error(exc.GitlabDeleteError) def unarchive(self, **kwargs): """Unarchive a project. @@ -1953,6 +2006,8 @@ def unarchive(self, **kwargs): server_data = self.manager.gitlab.http_post(path, **kwargs) self._update_attrs(server_data) + @cli.register_custom_action('Project', ('group_id', 'group_access'), + ('expires_at', )) @exc.on_http_error(exc.GitlabCreateError) def share(self, group_id, group_access, expires_at=None, **kwargs): """Share the project with a group. @@ -1972,6 +2027,8 @@ def share(self, group_id, group_access, expires_at=None, **kwargs): 'expires_at': expires_at} self.manager.gitlab.http_post(path, post_data=data, **kwargs) + # variables not supported in CLI + @cli.register_custom_action('Project', ('ref', 'token')) @exc.on_http_error(exc.GitlabCreateError) def trigger_pipeline(self, ref, token, variables={}, **kwargs): """Trigger a CI build. @@ -2005,6 +2062,7 @@ class RunnerManager(RetrieveMixin, UpdateMixin, DeleteMixin, RESTManager): _update_attrs = (tuple(), ('description', 'active', 'tag_list')) _list_filters = ('scope', ) + @cli.register_custom_action('RunnerManager', tuple(), ('scope', )) @exc.on_http_error(exc.GitlabListError) def all(self, scope=None, **kwargs): """List all the runners. @@ -2034,6 +2092,7 @@ def all(self, scope=None, **kwargs): class Todo(ObjectDeleteMixin, RESTObject): + @cli.register_custom_action('Todo') @exc.on_http_error(exc.GitlabTodoError) def mark_as_done(self, **kwargs): """Mark the todo as done. @@ -2055,6 +2114,7 @@ class TodoManager(GetFromListMixin, DeleteMixin, RESTManager): _obj_cls = Todo _list_filters = ('action', 'author_id', 'project_id', 'state', 'type') + @cli.register_custom_action('TodoManager') @exc.on_http_error(exc.GitlabTodoError) def mark_all_as_done(self, **kwargs): """Mark all the todos as done. @@ -2126,19 +2186,20 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject): ('issues', 'GroupIssueManager'), ) + @cli.register_custom_action('Group', ('to_project_id', )) @exc.on_http_error(exc.GitlabTransferProjectError) - def transfer_project(self, id, **kwargs): + def transfer_project(self, to_project_id, **kwargs): """Transfer a project to this group. Args: - id (int): ID of the project to transfer + to_project_id (int): ID of the project to transfer **kwargs: Extra options to send to the server (e.g. sudo) Raises: GitlabAuthenticationError: If authentication is not correct GitlabTransferProjectError: If the project could not be transfered """ - path = '/groups/%d/projects/%d' % (self.id, id) + path = '/groups/%d/projects/%d' % (self.id, project_id) self.manager.gitlab.http_post(path, **kwargs) From abade405af9099a136b68d0eb19027d038dab60b Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Fri, 18 Aug 2017 10:25:00 +0200 Subject: [PATCH 74/93] CLI: yaml and json outputs for v4 Verbose mode only works with the legacy output. Also add support for filtering the output by defining the list of fields that need to be displayed (yaml and json only). --- docs/cli.rst | 23 ++++++++++++------- gitlab/cli.py | 20 ++++++++++++---- gitlab/v4/cli.py | 59 ++++++++++++++++++++++++++++++++++++------------ 3 files changed, 76 insertions(+), 26 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 8d0550bf9..349ee02c4 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -80,10 +80,11 @@ section. * - ``url`` - URL for the GitLab server * - ``private_token`` - - Your user token. Login/password is not supported. - Refer `the official documentation`__ to learn how to obtain a token. + - Your user token. Login/password is not supported. Refer to `the official + documentation`__ to learn how to obtain a token. * - ``api_version`` - - API version to use (``3`` or ``4``), defaults to ``3`` + - GitLab API version to use (``3`` or ``4``). Defaults to ``3`` for now, + but will switch to ``4`` eventually. * - ``http_username`` - Username for optional HTTP authentication * - ``http_password`` @@ -126,7 +127,8 @@ Use the following optional arguments to change the behavior of ``gitlab``. These options must be defined before the mandatory arguments. ``--verbose``, ``-v`` - Outputs detail about retrieved objects. + Outputs detail about retrieved objects. Available for legacy (default) + output only. ``--config-file``, ``-c`` Path to a configuration file. @@ -134,11 +136,18 @@ These options must be defined before the mandatory arguments. ``--gitlab``, ``-g`` ID of a GitLab server defined in the configuration file. +``--output``, ``-o`` + Output format. Defaults to a custom format. Can also be ``yaml`` or ``json``. + +``--fields``, ``-f`` + Comma-separated list of fields to display (``yaml`` and ``json`` formats + only). If not used, all the object fields are displayed. + Example: .. code-block:: console - $ gitlab -v -g elsewhere -c /tmp/gl.cfg project list + $ gitlab -o yaml -f id,permissions -g elsewhere -c /tmp/gl.cfg project list Examples @@ -168,12 +177,11 @@ Get a specific project (id 2): $ gitlab project get --id 2 -Get a specific user by id or by username: +Get a specific user by id: .. code-block:: console $ gitlab user get --id 3 - $ gitlab user get-by-username --query jdoe Get a list of snippets for this project: @@ -200,7 +208,6 @@ Create a snippet: $ gitlab project-snippet create --project-id 2 Impossible to create object (Missing attribute(s): title, file-name, code) - $ # oops, let's add the attributes: $ gitlab project-snippet create --project-id 2 --title "the title" \ --file-name "the name" --code "the code" diff --git a/gitlab/cli.py b/gitlab/cli.py index d803eb590..f6b357b0a 100644 --- a/gitlab/cli.py +++ b/gitlab/cli.py @@ -51,7 +51,6 @@ def wrapped_f(*args, **kwargs): custom_actions[final_name] = {} action = f.__name__ - custom_actions[final_name][action] = (mandatory, optional, in_obj) return wrapped_f @@ -79,7 +78,7 @@ def _get_base_parser(): parser.add_argument("--version", help="Display the version.", action="store_true") parser.add_argument("-v", "--verbose", "--fancy", - help="Verbose mode", + help="Verbose mode (legacy format only)", action="store_true") parser.add_argument("-d", "--debug", help="Debug mode (display HTTP requests", @@ -92,6 +91,15 @@ def _get_base_parser(): "be used. If not defined, the default selection " "will be used."), required=False) + parser.add_argument("-o", "--output", + help=("Output format (v4 only): json|legacy|yaml"), + required=False, + choices=['json', 'legacy', 'yaml'], + default="legacy") + parser.add_argument("-f", "--fields", + help=("Fields to display in the output (comma " + "separated). Not used with legacy output"), + required=False) return parser @@ -117,6 +125,10 @@ def main(): config_files = args.config_file gitlab_id = args.gitlab verbose = args.verbose + output = args.output + fields = [] + if args.fields: + fields = [x.strip() for x in args.fields.split(',')] debug = args.debug action = args.action what = args.what @@ -124,7 +136,7 @@ def main(): args = args.__dict__ # Remove CLI behavior-related args for item in ('gitlab', 'config_file', 'verbose', 'debug', 'what', 'action', - 'version'): + 'version', 'output'): args.pop(item) args = {k: v for k, v in args.items() if v is not None} @@ -137,6 +149,6 @@ def main(): if debug: gl.enable_debug() - cli_module.run(gl, what, action, args, verbose) + cli_module.run(gl, what, action, args, verbose, output, fields) sys.exit(0) diff --git a/gitlab/v4/cli.py b/gitlab/v4/cli.py index 821a27d44..ca5c6b155 100644 --- a/gitlab/v4/cli.py +++ b/gitlab/v4/cli.py @@ -245,30 +245,47 @@ def extend_parser(parser): return parser +class JSONPrinter(object): + def display(self, d, **kwargs): + import json # noqa + + print(json.dumps(d)) + + +class YAMLPrinter(object): + def display(self, d, **kwargs): + import yaml # noqa + + print(yaml.safe_dump(d, default_flow_style=False)) + + class LegacyPrinter(object): - def display(self, obj, verbose=False, padding=0): - def display_dict(d): + def display(self, d, **kwargs): + verbose = kwargs.get('verbose', False) + padding = kwargs.get('padding', 0) + obj = kwargs.get('obj') + + def display_dict(d, padding): for k in sorted(d.keys()): v = d[k] if isinstance(v, dict): print('%s%s:' % (' ' * padding, k)) new_padding = padding + 2 - self.display(v, True, new_padding) + self.display(v, verbose=True, padding=new_padding, obj=v) continue print('%s%s: %s' % (' ' * padding, k, v)) if verbose: if isinstance(obj, dict): - display_dict(obj) + display_dict(obj, padding) return # not a dict, we assume it's a RESTObject - id = getattr(obj, obj._id_attr) + id = getattr(obj, obj._id_attr, None) print('%s: %s' % (obj._id_attr, id)) attrs = obj.attributes attrs.pop(obj._id_attr) - display_dict(attrs) - print('') + display_dict(attrs, padding) else: id = getattr(obj, obj._id_attr) @@ -278,19 +295,33 @@ def display_dict(d): print('%s: %s' % (obj._short_print_attr, value)) -def run(gl, what, action, args, verbose): +PRINTERS = { + 'json': JSONPrinter, + 'legacy': LegacyPrinter, + 'yaml': YAMLPrinter, +} + + +def run(gl, what, action, args, verbose, output, fields): g_cli = GitlabCLI(gl, what, action, args) ret_val = g_cli() - printer = LegacyPrinter() + printer = PRINTERS[output]() + + def get_dict(obj): + if fields: + return {k: v for k, v in obj.attributes.items() + if k in fields} + return obj.attributes if isinstance(ret_val, list): - for o in ret_val: - if isinstance(o, gitlab.base.RESTObject): - printer.display(o, verbose) + for obj in ret_val: + if isinstance(obj, gitlab.base.RESTObject): + printer.display(get_dict(obj), verbose=verbose, obj=obj) else: - print(o) + print(obj) + print('') elif isinstance(ret_val, gitlab.base.RESTObject): - printer.display(ret_val, verbose) + printer.display(get_dict(ret_val), verbose=verbose, obj=ret_val) elif isinstance(ret_val, six.string_types): print(ret_val) From 59550f27feaf20cfeb65511292906f99f64b6745 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 20 Aug 2017 20:21:46 +0200 Subject: [PATCH 75/93] make v3 CLI work again --- gitlab/v3/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitlab/v3/cli.py b/gitlab/v3/cli.py index b0450e8bf..ae16cf7d7 100644 --- a/gitlab/v3/cli.py +++ b/gitlab/v3/cli.py @@ -462,7 +462,7 @@ def extend_parser(parser): return parser -def run(gl, what, action, args, verbose): +def run(gl, what, action, args, verbose, *fargs, **kwargs): try: cls = gitlab.v3.objects.__dict__[cli.what_to_cls(what)] except ImportError: From f762cf6d64823654e5b7c5beaacd232a1282ef38 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 20 Aug 2017 20:49:19 +0200 Subject: [PATCH 76/93] [v4] Use - instead of _ in CLI legacy output This mimics the v3 behavior. --- gitlab/v4/cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gitlab/v4/cli.py b/gitlab/v4/cli.py index ca5c6b155..c508fc584 100644 --- a/gitlab/v4/cli.py +++ b/gitlab/v4/cli.py @@ -269,11 +269,11 @@ def display_dict(d, padding): for k in sorted(d.keys()): v = d[k] if isinstance(v, dict): - print('%s%s:' % (' ' * padding, k)) + print('%s%s:' % (' ' * padding, k.replace('_', '-'))) new_padding = padding + 2 self.display(v, verbose=True, padding=new_padding, obj=v) continue - print('%s%s: %s' % (' ' * padding, k, v)) + print('%s%s: %s' % (' ' * padding, k.replace('_', '-'), v)) if verbose: if isinstance(obj, dict): @@ -289,7 +289,7 @@ def display_dict(d, padding): else: id = getattr(obj, obj._id_attr) - print('%s: %s' % (obj._id_attr, id)) + print('%s: %s' % (obj._id_attr.replace('_', '-'), id)) if hasattr(obj, '_short_print_attr'): value = getattr(obj, obj._short_print_attr) print('%s: %s' % (obj._short_print_attr, value)) From f00562c7682875930b505fac0b1fc7e19ab1358c Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 20 Aug 2017 20:58:27 +0200 Subject: [PATCH 77/93] Make CLI tests work for v4 as well --- tools/cli_test_v3.sh | 103 ++++++++++++++++++++++++++++++++++++++ tools/cli_test_v4.sh | 99 ++++++++++++++++++++++++++++++++++++ tools/functional_tests.sh | 88 +------------------------------- 3 files changed, 203 insertions(+), 87 deletions(-) create mode 100644 tools/cli_test_v3.sh create mode 100644 tools/cli_test_v4.sh diff --git a/tools/cli_test_v3.sh b/tools/cli_test_v3.sh new file mode 100644 index 000000000..d71f4378b --- /dev/null +++ b/tools/cli_test_v3.sh @@ -0,0 +1,103 @@ +#!/bin/sh +# Copyright (C) 2015 Gauvain Pocentek +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +testcase "project creation" ' + OUTPUT=$(try GITLAB project create --name test-project1) || exit 1 + PROJECT_ID=$(pecho "${OUTPUT}" | grep ^id: | cut -d" " -f2) + OUTPUT=$(try GITLAB project list) || exit 1 + pecho "${OUTPUT}" | grep -q test-project1 +' + +testcase "project update" ' + GITLAB project update --id "$PROJECT_ID" --description "My New Description" +' + +testcase "user creation" ' + OUTPUT=$(GITLAB user create --email fake@email.com --username user1 \ + --name "User One" --password fakepassword) +' +USER_ID=$(pecho "${OUTPUT}" | grep ^id: | cut -d' ' -f2) + +testcase "user get (by id)" ' + GITLAB user get --id $USER_ID >/dev/null 2>&1 +' + +testcase "user get (by username)" ' + GITLAB user get-by-username --query user1 >/dev/null 2>&1 +' + +testcase "verbose output" ' + OUTPUT=$(try GITLAB -v user list) || exit 1 + pecho "${OUTPUT}" | grep -q avatar-url +' + +testcase "CLI args not in output" ' + OUTPUT=$(try GITLAB -v user list) || exit 1 + pecho "${OUTPUT}" | grep -qv config-file +' + +testcase "adding member to a project" ' + GITLAB project-member create --project-id "$PROJECT_ID" \ + --user-id "$USER_ID" --access-level 40 >/dev/null 2>&1 +' + +testcase "file creation" ' + GITLAB project-file create --project-id "$PROJECT_ID" \ + --file-path README --branch-name master --content "CONTENT" \ + --commit-message "Initial commit" >/dev/null 2>&1 +' + +testcase "issue creation" ' + OUTPUT=$(GITLAB project-issue create --project-id "$PROJECT_ID" \ + --title "my issue" --description "my issue description") +' +ISSUE_ID=$(pecho "${OUTPUT}" | grep ^id: | cut -d' ' -f2) + +testcase "note creation" ' + GITLAB project-issue-note create --project-id "$PROJECT_ID" \ + --issue-id "$ISSUE_ID" --body "the body" >/dev/null 2>&1 +' + +testcase "branch creation" ' + GITLAB project-branch create --project-id "$PROJECT_ID" \ + --branch-name branch1 --ref master >/dev/null 2>&1 +' + +GITLAB project-file create --project-id "$PROJECT_ID" \ + --file-path README2 --branch-name branch1 --content "CONTENT" \ + --commit-message "second commit" >/dev/null 2>&1 + +testcase "merge request creation" ' + OUTPUT=$(GITLAB project-merge-request create \ + --project-id "$PROJECT_ID" \ + --source-branch branch1 --target-branch master \ + --title "Update README") +' +MR_ID=$(pecho "${OUTPUT}" | grep ^id: | cut -d' ' -f2) + +testcase "merge request validation" ' + GITLAB project-merge-request merge --project-id "$PROJECT_ID" \ + --id "$MR_ID" >/dev/null 2>&1 +' + +testcase "branch deletion" ' + GITLAB project-branch delete --project-id "$PROJECT_ID" \ + --name branch1 >/dev/null 2>&1 +' + +testcase "project deletion" ' + GITLAB project delete --id "$PROJECT_ID" +' diff --git a/tools/cli_test_v4.sh b/tools/cli_test_v4.sh new file mode 100644 index 000000000..b96ea013c --- /dev/null +++ b/tools/cli_test_v4.sh @@ -0,0 +1,99 @@ +#!/bin/sh +# Copyright (C) 2015 Gauvain Pocentek +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +testcase "project creation" ' + OUTPUT=$(try GITLAB project create --name test-project1) || exit 1 + PROJECT_ID=$(pecho "${OUTPUT}" | grep ^id: | cut -d" " -f2) + OUTPUT=$(try GITLAB project list) || exit 1 + pecho "${OUTPUT}" | grep -q test-project1 +' + +testcase "project update" ' + GITLAB project update --id "$PROJECT_ID" --description "My New Description" +' + +testcase "user creation" ' + OUTPUT=$(GITLAB user create --email fake@email.com --username user1 \ + --name "User One" --password fakepassword) +' +USER_ID=$(pecho "${OUTPUT}" | grep ^id: | cut -d' ' -f2) + +testcase "user get (by id)" ' + GITLAB user get --id $USER_ID >/dev/null 2>&1 +' + +testcase "verbose output" ' + OUTPUT=$(try GITLAB -v user list) || exit 1 + pecho "${OUTPUT}" | grep -q avatar-url +' + +testcase "CLI args not in output" ' + OUTPUT=$(try GITLAB -v user list) || exit 1 + pecho "${OUTPUT}" | grep -qv config-file +' + +testcase "adding member to a project" ' + GITLAB project-member create --project-id "$PROJECT_ID" \ + --user-id "$USER_ID" --access-level 40 >/dev/null 2>&1 +' + +testcase "file creation" ' + GITLAB project-file create --project-id "$PROJECT_ID" \ + --file-path README --branch master --content "CONTENT" \ + --commit-message "Initial commit" >/dev/null 2>&1 +' + +testcase "issue creation" ' + OUTPUT=$(GITLAB project-issue create --project-id "$PROJECT_ID" \ + --title "my issue" --description "my issue description") +' +ISSUE_ID=$(pecho "${OUTPUT}" | grep ^id: | cut -d' ' -f2) + +testcase "note creation" ' + GITLAB project-issue-note create --project-id "$PROJECT_ID" \ + --issue-id "$ISSUE_ID" --body "the body" >/dev/null 2>&1 +' + +testcase "branch creation" ' + GITLAB project-branch create --project-id "$PROJECT_ID" \ + --branch branch1 --ref master >/dev/null 2>&1 +' + +GITLAB project-file create --project-id "$PROJECT_ID" \ + --file-path README2 --branch branch1 --content "CONTENT" \ + --commit-message "second commit" >/dev/null 2>&1 + +testcase "merge request creation" ' + OUTPUT=$(GITLAB project-merge-request create \ + --project-id "$PROJECT_ID" \ + --source-branch branch1 --target-branch master \ + --title "Update README") +' +MR_ID=$(pecho "${OUTPUT}" | grep ^id: | cut -d' ' -f2) + +testcase "merge request validation" ' + GITLAB project-merge-request merge --project-id "$PROJECT_ID" \ + --id "$MR_ID" >/dev/null 2>&1 +' + +testcase "branch deletion" ' + GITLAB project-branch delete --project-id "$PROJECT_ID" \ + --name branch1 >/dev/null 2>&1 +' + +testcase "project deletion" ' + GITLAB project delete --id "$PROJECT_ID" +' diff --git a/tools/functional_tests.sh b/tools/functional_tests.sh index a4a8d06c7..4123d87fb 100755 --- a/tools/functional_tests.sh +++ b/tools/functional_tests.sh @@ -18,90 +18,4 @@ setenv_script=$(dirname "$0")/build_test_env.sh || exit 1 BUILD_TEST_ENV_AUTO_CLEANUP=true . "$setenv_script" "$@" || exit 1 -testcase "project creation" ' - OUTPUT=$(try GITLAB project create --name test-project1) || exit 1 - PROJECT_ID=$(pecho "${OUTPUT}" | grep ^id: | cut -d" " -f2) - OUTPUT=$(try GITLAB project list) || exit 1 - pecho "${OUTPUT}" | grep -q test-project1 -' - -testcase "project update" ' - GITLAB project update --id "$PROJECT_ID" --description "My New Description" -' - -testcase "user creation" ' - OUTPUT=$(GITLAB user create --email fake@email.com --username user1 \ - --name "User One" --password fakepassword) -' -USER_ID=$(pecho "${OUTPUT}" | grep ^id: | cut -d' ' -f2) - -testcase "user get (by id)" ' - GITLAB user get --id $USER_ID >/dev/null 2>&1 -' - -testcase "user get (by username)" ' - GITLAB user get-by-username --query user1 >/dev/null 2>&1 -' - -testcase "verbose output" ' - OUTPUT=$(try GITLAB -v user list) || exit 1 - pecho "${OUTPUT}" | grep -q avatar-url -' - -testcase "CLI args not in output" ' - OUTPUT=$(try GITLAB -v user list) || exit 1 - pecho "${OUTPUT}" | grep -qv config-file -' - -testcase "adding member to a project" ' - GITLAB project-member create --project-id "$PROJECT_ID" \ - --user-id "$USER_ID" --access-level 40 >/dev/null 2>&1 -' - -testcase "file creation" ' - GITLAB project-file create --project-id "$PROJECT_ID" \ - --file-path README --branch-name master --content "CONTENT" \ - --commit-message "Initial commit" >/dev/null 2>&1 -' - -testcase "issue creation" ' - OUTPUT=$(GITLAB project-issue create --project-id "$PROJECT_ID" \ - --title "my issue" --description "my issue description") -' -ISSUE_ID=$(pecho "${OUTPUT}" | grep ^id: | cut -d' ' -f2) - -testcase "note creation" ' - GITLAB project-issue-note create --project-id "$PROJECT_ID" \ - --issue-id "$ISSUE_ID" --body "the body" >/dev/null 2>&1 -' - -testcase "branch creation" ' - GITLAB project-branch create --project-id "$PROJECT_ID" \ - --branch-name branch1 --ref master >/dev/null 2>&1 -' - -GITLAB project-file create --project-id "$PROJECT_ID" \ - --file-path README2 --branch-name branch1 --content "CONTENT" \ - --commit-message "second commit" >/dev/null 2>&1 - -testcase "merge request creation" ' - OUTPUT=$(GITLAB project-merge-request create \ - --project-id "$PROJECT_ID" \ - --source-branch branch1 --target-branch master \ - --title "Update README") -' -MR_ID=$(pecho "${OUTPUT}" | grep ^id: | cut -d' ' -f2) - -testcase "merge request validation" ' - GITLAB project-merge-request merge --project-id "$PROJECT_ID" \ - --id "$MR_ID" >/dev/null 2>&1 -' - -testcase "branch deletion" ' - GITLAB project-branch delete --project-id "$PROJECT_ID" \ - --name branch1 >/dev/null 2>&1 -' - -testcase "project deletion" ' - GITLAB project delete --id "$PROJECT_ID" -' +. $(dirname "$0")/cli_test_v${API_VER}.sh From cda2d59e13bfa48447f2a1b999a2538f6baf83f5 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 20 Aug 2017 21:42:38 +0200 Subject: [PATCH 78/93] [v4] Fix the CLI for project files --- gitlab/v4/objects.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 641db82f7..d4e9e6313 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -1374,6 +1374,7 @@ class ProjectFileManager(GetMixin, CreateMixin, UpdateMixin, DeleteMixin, _update_attrs = (('file_path', 'branch', 'content', 'commit_message'), ('encoding', 'author_email', 'author_name')) + @cli.register_custom_action('ProjectFileManager', ('file_path', 'ref')) def get(self, file_path, ref, **kwargs): """Retrieve a single file. @@ -1392,6 +1393,10 @@ def get(self, file_path, ref, **kwargs): file_path = file_path.replace('/', '%2F') return GetMixin.get(self, file_path, ref=ref, **kwargs) + @cli.register_custom_action('ProjectFileManager', + ('file_path', 'branch', 'content', + 'commit_message'), + ('encoding', 'author_email', 'author_name')) @exc.on_http_error(exc.GitlabCreateError) def create(self, data, **kwargs): """Create a new object. @@ -1416,6 +1421,8 @@ def create(self, data, **kwargs): server_data = self.gitlab.http_post(path, post_data=data, **kwargs) return self._obj_cls(self, server_data) + @cli.register_custom_action('ProjectFileManager', ('file_path', 'branch', + 'commit_message')) @exc.on_http_error(exc.GitlabDeleteError) def delete(self, file_path, branch, commit_message, **kwargs): """Delete a file on the server. From b0af946767426ed378bbec52c02da142c9554e71 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 20 Aug 2017 21:43:14 +0200 Subject: [PATCH 79/93] Fix the v4 CLI tests (id/iid) --- tools/cli_test_v4.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/cli_test_v4.sh b/tools/cli_test_v4.sh index b96ea013c..8399bd855 100644 --- a/tools/cli_test_v4.sh +++ b/tools/cli_test_v4.sh @@ -60,11 +60,11 @@ testcase "issue creation" ' OUTPUT=$(GITLAB project-issue create --project-id "$PROJECT_ID" \ --title "my issue" --description "my issue description") ' -ISSUE_ID=$(pecho "${OUTPUT}" | grep ^id: | cut -d' ' -f2) +ISSUE_ID=$(pecho "${OUTPUT}" | grep ^iid: | cut -d' ' -f2) testcase "note creation" ' GITLAB project-issue-note create --project-id "$PROJECT_ID" \ - --issue-id "$ISSUE_ID" --body "the body" >/dev/null 2>&1 + --issue-iid "$ISSUE_ID" --body "the body" >/dev/null 2>&1 ' testcase "branch creation" ' @@ -82,11 +82,11 @@ testcase "merge request creation" ' --source-branch branch1 --target-branch master \ --title "Update README") ' -MR_ID=$(pecho "${OUTPUT}" | grep ^id: | cut -d' ' -f2) +MR_ID=$(pecho "${OUTPUT}" | grep ^iid: | cut -d' ' -f2) testcase "merge request validation" ' GITLAB project-merge-request merge --project-id "$PROJECT_ID" \ - --id "$MR_ID" >/dev/null 2>&1 + --iid "$MR_ID" >/dev/null 2>&1 ' testcase "branch deletion" ' From 022a0f68764c60fb6a2fd7493d511438037cbd53 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 20 Aug 2017 21:45:14 +0200 Subject: [PATCH 80/93] [v4] Make sudo the first argument in CLI help --- gitlab/v4/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitlab/v4/cli.py b/gitlab/v4/cli.py index c508fc584..e61ef2036 100644 --- a/gitlab/v4/cli.py +++ b/gitlab/v4/cli.py @@ -114,11 +114,11 @@ def _populate_sub_parser_by_class(cls, sub_parser): continue sub_parser_action = sub_parser.add_parser(action_name) + sub_parser_action.add_argument("--sudo", required=False) if hasattr(mgr_cls, '_from_parent_attrs'): [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), required=True) for x in mgr_cls._from_parent_attrs] - sub_parser_action.add_argument("--sudo", required=False) if action_name == "list": if hasattr(mgr_cls, '_list_filters'): From 5210956278e8d0bd4e5676fc116851626ac89491 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 20 Aug 2017 21:46:14 +0200 Subject: [PATCH 81/93] [tests] Use -n to not use a venv --- tools/build_test_env.sh | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tools/build_test_env.sh b/tools/build_test_env.sh index 35a54c6ef..572a47c56 100755 --- a/tools/build_test_env.sh +++ b/tools/build_test_env.sh @@ -25,10 +25,12 @@ error() { log "ERROR: $@" >&2; } fatal() { error "$@"; exit 1; } try() { "$@" || fatal "'$@' failed"; } +NOVENV= PY_VER=2 API_VER=3 -while getopts :p:a: opt "$@"; do +while getopts :np:a: opt "$@"; do case $opt in + n) NOVENV=1;; p) PY_VER=$OPTARG;; a) API_VER=$OPTARG;; :) fatal "Option -${OPTARG} requires a value";; @@ -143,15 +145,17 @@ EOF log "Config file content ($CONFIG):" log <$CONFIG -log "Creating Python virtualenv..." -try "$VENV_CMD" "$VENV" -. "$VENV"/bin/activate || fatal "failed to activate Python virtual environment" +if [ -z "$NOVENV" ]; then + log "Creating Python virtualenv..." + try "$VENV_CMD" "$VENV" + . "$VENV"/bin/activate || fatal "failed to activate Python virtual environment" -log "Installing dependencies into virtualenv..." -try pip install -rrequirements.txt + log "Installing dependencies into virtualenv..." + try pip install -rrequirements.txt -log "Installing into virtualenv..." -try pip install -e . + log "Installing into virtualenv..." + try pip install -e . +fi log "Pausing to give GitLab some time to finish starting up..." sleep 30 From 311464b71c508503d5275db5975bc10ed74674bd Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sun, 20 Aug 2017 21:59:12 +0200 Subject: [PATCH 82/93] update tox/travis for CLI v3/4 tests --- .travis.yml | 3 ++- tox.ini | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 365308f35..fc3751ed1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,7 +17,8 @@ env: - TOX_ENV=docs - TOX_ENV=py_func_v3 - TOX_ENV=py_func_v4 - - TOX_ENV=cli_func + - TOX_ENV=cli_func_v3 + - TOX_ENV=cli_func_v4 install: - pip install tox script: diff --git a/tox.ini b/tox.ini index 5e97e9e1f..9898e9e03 100644 --- a/tox.ini +++ b/tox.ini @@ -32,11 +32,14 @@ commands = python setup.py build_sphinx commands = python setup.py testr --slowest --coverage --testr-args="{posargs}" -[testenv:cli_func] -commands = {toxinidir}/tools/functional_tests.sh +[testenv:cli_func_v3] +commands = {toxinidir}/tools/functional_tests.sh -a 3 + +[testenv:cli_func_v4] +commands = {toxinidir}/tools/functional_tests.sh -a 4 [testenv:py_func_v3] -commands = {toxinidir}/tools/py_functional_tests.sh +commands = {toxinidir}/tools/py_functional_tests.sh -a 3 [testenv:py_func_v4] commands = {toxinidir}/tools/py_functional_tests.sh -a 4 From 0e0d4aee3e73e2caf86c50bc9152764528f7725a Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Mon, 21 Aug 2017 11:55:00 +0200 Subject: [PATCH 83/93] [v4] More python functional tests --- gitlab/v4/objects.py | 24 ++++---- tools/python_test_v4.py | 119 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 127 insertions(+), 16 deletions(-) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index d4e9e6313..3b1eb9175 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -209,13 +209,13 @@ class UserManager(CRUDMixin, RESTManager): _obj_cls = User _list_filters = ('active', 'blocked', 'username', 'extern_uid', 'provider', - 'external') + 'external', 'search') _create_attrs = ( - ('email', 'username', 'name'), - ('password', 'reset_password', 'skype', 'linkedin', 'twitter', - 'projects_limit', 'extern_uid', 'provider', 'bio', 'admin', - 'can_create_group', 'website_url', 'skip_confirmation', 'external', - 'organization', 'location') + tuple(), + ('email', 'username', 'name', 'password', 'reset_password', 'skype', + 'linkedin', 'twitter', 'projects_limit', 'extern_uid', 'provider', + 'bio', 'admin', 'can_create_group', 'website_url', + 'skip_confirmation', 'external', 'organization', 'location') ) _update_attrs = ( ('email', 'username', 'name'), @@ -730,13 +730,14 @@ class ProjectCommitStatus(RESTObject): pass -class ProjectCommitStatusManager(RetrieveMixin, CreateMixin, RESTManager): +class ProjectCommitStatusManager(GetFromListMixin, CreateMixin, RESTManager): _path = ('/projects/%(project_id)s/repository/commits/%(commit_id)s' '/statuses') _obj_cls = ProjectCommitStatus _from_parent_attrs = {'project_id': 'project_id', 'commit_id': 'id'} - _create_attrs = (('state', ), - ('description', 'name', 'context', 'ref', 'target_url')) + _create_attrs = (('state', 'sha'), + ('description', 'name', 'context', 'ref', 'target_url', + 'coverage')) def create(self, data, **kwargs): """Create a new object. @@ -761,7 +762,7 @@ def create(self, data, **kwargs): class ProjectCommitComment(RESTObject): - pass + _id_attr = None class ProjectCommitCommentManager(ListMixin, CreateMixin, RESTManager): @@ -864,10 +865,11 @@ def enable(self, key_id, **kwargs): class ProjectEvent(RESTObject): + _id_attr = None _short_print_attr = 'target_title' -class ProjectEventManager(GetFromListMixin, RESTManager): +class ProjectEventManager(ListMixin, RESTManager): _path = '/projects/%(project_id)s/events' _obj_cls = ProjectEvent _from_parent_attrs = {'project_id': 'id'} diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py index cba48339b..8cc088644 100644 --- a/tools/python_test_v4.py +++ b/tools/python_test_v4.py @@ -31,6 +31,22 @@ gl.auth() assert(isinstance(gl.user, gitlab.v4.objects.CurrentUser)) +# sidekiq +out = gl.sidekiq.queue_metrics() +assert(isinstance(out, dict)) +assert('pages' in out['queues']) +out = gl.sidekiq.process_metrics() +assert(isinstance(out, dict)) +assert('hostname' in out['processes'][0]) +out = gl.sidekiq.job_stats() +assert(isinstance(out, dict)) +assert('processed' in out['jobs']) +out = gl.sidekiq.compound_metrics() +assert(isinstance(out, dict)) +assert('jobs' in out) +assert('processes' in out) +assert('queues' in out) + # settings settings = gl.settings.get() settings.default_projects_limit = 42 @@ -38,7 +54,7 @@ settings = gl.settings.get() assert(settings.default_projects_limit == 42) -# user manipulations +# users new_user = gl.users.create({'email': 'foo@bar.com', 'username': 'foo', 'name': 'foo', 'password': 'foo_password'}) users_list = gl.users.list() @@ -61,6 +77,8 @@ actual = sorted(list(gl.users.list(search='foo')), cmp=usercmp) assert len(expected) == len(actual) assert len(gl.users.list(search='asdf')) == 0 +foobar_user.bio = 'This is the user bio' +foobar_user.save() # SSH keys key = new_user.keys.create({'title': 'testkey', 'key': SSH_KEY}) @@ -78,12 +96,36 @@ foobar_user.delete() assert(len(gl.users.list()) == 3) +# current user mail +mail = gl.user.emails.create({'email': 'current@user.com'}) +assert(len(gl.user.emails.list()) == 1) +mail.delete() +assert(len(gl.user.emails.list()) == 0) + # current user key key = gl.user.keys.create({'title': 'testkey', 'key': SSH_KEY}) assert(len(gl.user.keys.list()) == 1) key.delete() assert(len(gl.user.keys.list()) == 0) +# templates +assert(gl.dockerfiles.list()) +dockerfile = gl.dockerfiles.get('Node') +assert(dockerfile.content is not None) + +assert(gl.gitignores.list()) +gitignore = gl.gitignores.get('Node') +assert(gitignore.content is not None) + +assert(gl.gitlabciymls.list()) +gitlabciyml = gl.gitlabciymls.get('Nodejs') +assert(gitlabciyml.content is not None) + +assert(gl.licenses.list()) +license = gl.licenses.get('bsd-2-clause', project='mytestproject', + fullname='mytestfullname') +assert('mytestfullname' in license.content) + # groups user1 = gl.users.create({'email': 'user1@test.com', 'username': 'user1', 'name': 'user1', 'password': 'user1_pass'}) @@ -121,6 +163,13 @@ group2.members.delete(gl.user.id) +# group notification settings +settings = group2.notificationsettings.get() +settings.level = 'disabled' +settings.save() +settings = group2.notificationsettings.get() +assert(settings.level == 'disabled') + # hooks hook = gl.hooks.create({'url': 'http://whatever.com'}) assert(len(gl.hooks.list()) == 1) @@ -175,9 +224,20 @@ ] } admin_project.commits.create(data) +assert('---' in admin_project.commits.list()[0].diff()[0]['diff']) +# commit status +commit = admin_project.commits.list()[0] +status = commit.statuses.create({'state': 'success', 'sha': commit.id}) +assert(len(commit.statuses.list()) == 1) + +# commit comment +commit.comments.create({'note': 'This is a commit comment'}) +assert(len(commit.comments.list()) == 1) + +# repository tree = admin_project.repository_tree() -assert(len(tree) == 2) +assert(len(tree) != 0) assert(tree[0]['name'] == 'README.rst') blob_id = tree[0]['id'] blob = admin_project.repository_raw_blob(blob_id) @@ -186,6 +246,36 @@ archive2 = admin_project.repository_archive('master') assert(archive1 == archive2) +# environments +admin_project.environments.create({'name': 'env1', 'external_url': + 'http://fake.env/whatever'}) +envs = admin_project.environments.list() +assert(len(envs) == 1) +env = admin_project.environments.get(envs[0].id) +env.external_url = 'http://new.env/whatever' +env.save() +env = admin_project.environments.get(envs[0].id) +assert(env.external_url == 'http://new.env/whatever') +env.delete() +assert(len(admin_project.environments.list()) == 0) + +# events +admin_project.events.list() + +# forks +fork = admin_project.forks.create({'namespace': user1.username}) +p = gl.projects.get(fork.id) +assert(p.forked_from_project['id'] == admin_project.id) + +# project hooks +hook = admin_project.hooks.create({'url': 'http://hook.url'}) +assert(len(admin_project.hooks.list()) == 1) +hook.note_events = True +hook.save() +hook = admin_project.hooks.get(hook.id) +assert(hook.note_events is True) +hook.delete() + # deploy keys deploy_key = admin_project.keys.create({'title': 'foo@bar', 'key': DEPLOY_KEY}) project_keys = list(admin_project.keys.list()) @@ -231,6 +321,10 @@ assert(len(admin_project.issues.list(state='opened')) == 2) assert(len(admin_project.issues.list(milestone='milestone1')) == 1) assert(m1.issues().next().title == 'my issue 1') +note = issue1.notes.create({'body': 'This is an issue note'}) +assert(len(issue1.notes.list()) == 1) +note.delete() +assert(len(issue1.notes.list()) == 0) # tags tag1 = admin_project.tags.create({'tag_name': 'v1.0', 'ref': 'master'}) @@ -240,6 +334,22 @@ assert(tag1.release['description'] == 'Description 2') tag1.delete() +# project snippet +admin_project.snippets_enabled = True +admin_project.save() +snippet = admin_project.snippets.create( + {'title': 'snip1', 'file_name': 'foo.py', 'code': 'initial content', + 'visibility': gitlab.v4.objects.VISIBILITY_PRIVATE} +) +snippet.file_name = 'bar.py' +snippet.save() +snippet = admin_project.snippets.get(snippet.id) +assert(snippet.content() == 'initial content') +assert(snippet.file_name == 'bar.py') +size = len(admin_project.snippets.list()) +snippet.delete() +assert(len(admin_project.snippets.list()) == (size - 1)) + # triggers tr1 = admin_project.triggers.create({'description': 'trigger1'}) assert(len(admin_project.triggers.list()) == 1) @@ -330,12 +440,11 @@ assert(len(snippets) == 0) snippet = gl.snippets.create({'title': 'snippet1', 'file_name': 'snippet1.py', 'content': 'import gitlab'}) -snippet = gl.snippets.get(1) +snippet = gl.snippets.get(snippet.id) snippet.title = 'updated_title' snippet.save() -snippet = gl.snippets.get(1) +snippet = gl.snippets.get(snippet.id) assert(snippet.title == 'updated_title') content = snippet.content() assert(content == 'import gitlab') snippet.delete() -assert(len(gl.snippets.list()) == 0) From eb191dfaa42eb39d9d1b5acc21fc0c4c0fb99427 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sat, 12 Aug 2017 09:47:33 +0200 Subject: [PATCH 84/93] Add support for group variables --- docs/gl_objects/builds.py | 8 ++++++-- docs/gl_objects/builds.rst | 16 +++++++++++----- gitlab/v4/objects.py | 13 +++++++++++++ tools/python_test_v4.py | 12 ++++++++++++ 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/docs/gl_objects/builds.py b/docs/gl_objects/builds.py index e125b39eb..5ca55db8b 100644 --- a/docs/gl_objects/builds.py +++ b/docs/gl_objects/builds.py @@ -1,13 +1,16 @@ # var list -variables = project.variables.list() +p_variables = project.variables.list() +g_variables = group.variables.list() # end var list # var get -var = project.variables.get(var_key) +p_var = project.variables.get(var_key) +g_var = group.variables.get(var_key) # end var get # var create var = project.variables.create({'key': 'key1', 'value': 'value1'}) +var = group.variables.create({'key': 'key1', 'value': 'value1'}) # end var create # var update @@ -17,6 +20,7 @@ # var delete project.variables.delete(var_key) +group.variables.delete(var_key) # or var.delete() # end var delete diff --git a/docs/gl_objects/builds.rst b/docs/gl_objects/builds.rst index 52bdb1ace..1c95eb16e 100644 --- a/docs/gl_objects/builds.rst +++ b/docs/gl_objects/builds.rst @@ -56,11 +56,11 @@ Remove a trigger: :start-after: # trigger delete :end-before: # end trigger delete -Project variables -================= +Projects and groups variables +============================= -You can associate variables to projects to modify the build/job script -behavior. +You can associate variables to projects and groups to modify the build/job +scripts behavior. Reference --------- @@ -70,6 +70,9 @@ Reference + :class:`gitlab.v4.objects.ProjectVariable` + :class:`gitlab.v4.objects.ProjectVariableManager` + :attr:`gitlab.v4.objects.Project.variables` + + :class:`gitlab.v4.objects.GroupVariable` + + :class:`gitlab.v4.objects.GroupVariableManager` + + :attr:`gitlab.v4.objects.Group.variables` * v3 API @@ -78,7 +81,10 @@ Reference + :attr:`gitlab.v3.objects.Project.variables` + :attr:`gitlab.Gitlab.project_variables` -* GitLab API: https://docs.gitlab.com/ce/api/project_level_variables.html +* GitLab API + + + https://docs.gitlab.com/ce/api/project_level_variables.html + + https://docs.gitlab.com/ce/api/group_level_variables.html Examples -------- diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index e3780a9cc..83993835b 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -2121,6 +2121,18 @@ class GroupProjectManager(GetFromListMixin, RESTManager): 'ci_enabled_first') +class GroupVariable(SaveMixin, ObjectDeleteMixin, RESTObject): + _id_attr = 'key' + + +class GroupVariableManager(CRUDMixin, RESTManager): + _path = '/groups/%(group_id)s/variables' + _obj_cls = GroupVariable + _from_parent_attrs = {'group_id': 'id'} + _create_attrs = (('key', 'value'), ('protected',)) + _update_attrs = (('key', 'value'), ('protected',)) + + class Group(SaveMixin, ObjectDeleteMixin, RESTObject): _short_print_attr = 'name' _managers = ( @@ -2129,6 +2141,7 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject): ('notificationsettings', 'GroupNotificationSettingsManager'), ('projects', 'GroupProjectManager'), ('issues', 'GroupIssueManager'), + ('variables', 'GroupVariableManager'), ) @exc.on_http_error(exc.GitlabTransferProjectError) diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py index cba48339b..3c7e3b473 100644 --- a/tools/python_test_v4.py +++ b/tools/python_test_v4.py @@ -121,6 +121,18 @@ group2.members.delete(gl.user.id) +# Group variables +group1.variables.create({'key': 'foo', 'value': 'bar'}) +g_v = group1.variables.get('foo') +assert(g_v.value == 'bar') +g_v.value = 'baz' +g_v.save() +g_v = group1.variables.get('foo') +assert(g_v.value == 'baz') +assert(len(group1.variables.list()) == 1) +g_v.delete() +assert(len(group1.variables.list()) == 0) + # hooks hook = gl.hooks.create({'url': 'http://whatever.com'}) assert(len(gl.hooks.list()) == 1) From c99e399443819024e2e44cbd437091a39641ae68 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sat, 2 Sep 2017 16:37:43 +0200 Subject: [PATCH 85/93] Add support for protected branches This feature appeared in gitlab 9.5. Fixes #299 --- docs/api-objects.rst | 1 + docs/gl_objects/branches.py | 18 ++++++++++++++++++ gitlab/v4/objects.py | 12 ++++++++++++ tools/python_test_v4.py | 10 ++++++++++ 4 files changed, 41 insertions(+) diff --git a/docs/api-objects.rst b/docs/api-objects.rst index 78b964652..4b40ce17b 100644 --- a/docs/api-objects.rst +++ b/docs/api-objects.rst @@ -7,6 +7,7 @@ API examples gl_objects/access_requests gl_objects/branches + gl_objects/protected_branches gl_objects/messages gl_objects/builds gl_objects/commits diff --git a/docs/gl_objects/branches.py b/docs/gl_objects/branches.py index b80dfc052..431e09d9b 100644 --- a/docs/gl_objects/branches.py +++ b/docs/gl_objects/branches.py @@ -26,3 +26,21 @@ branch.protect() branch.unprotect() # end protect + +# p_branch list +p_branches = project.protectedbranches.list() +# end p_branch list + +# p_branch get +p_branch = project.protectedbranches.get('master') +# end p_branch get + +# p_branch create +p_branch = project.protectedbranches.create({'name': '*-stable'}) +# end p_branch create + +# p_branch delete +project.protectedbranches.delete('*-stable') +# or +p_branch.delete() +# end p_branch delete diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 92c4543df..bf79deb22 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -1732,6 +1732,17 @@ class ProjectDeploymentManager(RetrieveMixin, RESTManager): _from_parent_attrs = {'project_id': 'id'} +class ProjectProtectedBranch(ObjectDeleteMixin, RESTObject): + _id_attr = 'name' + + +class ProjectProtectedBranchManager(NoUpdateMixin, RESTManager): + _path = '/projects/%(project_id)s/protected_branches' + _obj_cls = ProjectProtectedBranch + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('name', ), ('push_access_level', 'merge_access_level')) + + class ProjectRunner(ObjectDeleteMixin, RESTObject): pass @@ -1767,6 +1778,7 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): ('notes', 'ProjectNoteManager'), ('notificationsettings', 'ProjectNotificationSettingsManager'), ('pipelines', 'ProjectPipelineManager'), + ('protectedbranches', 'ProjectProtectedBranchManager'), ('runners', 'ProjectRunnerManager'), ('services', 'ProjectServiceManager'), ('snippets', 'ProjectSnippetManager'), diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py index 0cbea33d8..2113830d0 100644 --- a/tools/python_test_v4.py +++ b/tools/python_test_v4.py @@ -394,6 +394,16 @@ except gitlab.GitlabMRClosedError: pass +# protected branches +p_b = admin_project.protectedbranches.create({'name': '*-stable'}) +assert(p_b.name == '*-stable') +p_b = admin_project.protectedbranches.get('*-stable') +# master is protected by default +assert(len(admin_project.protectedbranches.list()) == 2) +admin_project.protectedbranches.delete('master') +p_b.delete() +assert(len(admin_project.protectedbranches.list()) == 0) + # stars admin_project.star() assert(admin_project.star_count == 1) From 0099ff2cc63a5eeb523bb515a38bd9061e69d187 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sat, 2 Sep 2017 16:39:27 +0200 Subject: [PATCH 86/93] tests: default to v4 API --- tools/build_test_env.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/build_test_env.sh b/tools/build_test_env.sh index 572a47c56..a3d478505 100755 --- a/tools/build_test_env.sh +++ b/tools/build_test_env.sh @@ -27,7 +27,7 @@ try() { "$@" || fatal "'$@' failed"; } NOVENV= PY_VER=2 -API_VER=3 +API_VER=4 while getopts :np:a: opt "$@"; do case $opt in n) NOVENV=1;; From d8db70768c276235007e5c794f822db7403b6d30 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Sat, 2 Sep 2017 16:44:33 +0200 Subject: [PATCH 87/93] tests: faster docker shutdown Kill the test container violently, no need to wait for a proper shutdown. --- tools/build_test_env.sh | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tools/build_test_env.sh b/tools/build_test_env.sh index a3d478505..31651b3f3 100755 --- a/tools/build_test_env.sh +++ b/tools/build_test_env.sh @@ -64,18 +64,12 @@ CONFIG=/tmp/python-gitlab.cfg cleanup() { rm -f "${CONFIG}" - log "Stopping gitlab-test docker container..." - docker stop gitlab-test >/dev/null & - docker_stop_pid=$! - log "Waiting for gitlab-test docker container to exit..." - docker wait gitlab-test >/dev/null - wait "${docker_stop_pid}" - log "Removing gitlab-test docker container..." - docker rm gitlab-test >/dev/null log "Deactivating Python virtualenv..." command -v deactivate >/dev/null 2>&1 && deactivate || true log "Deleting python virtualenv..." rm -rf "$VENV" + log "Stopping gitlab-test docker container..." + docker rm -f gitlab-test >/dev/null log "Done." } [ -z "${BUILD_TEST_ENV_AUTO_CLEANUP+set}" ] || { From 947feaf344478fa1b81012124fedaa9de10e224a Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Tue, 5 Sep 2017 16:01:17 +0200 Subject: [PATCH 88/93] FIX Group.tranfer_project --- gitlab/v4/objects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index bf79deb22..07a1940d6 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -2233,7 +2233,7 @@ def transfer_project(self, to_project_id, **kwargs): GitlabAuthenticationError: If authentication is not correct GitlabTransferProjectError: If the project could not be transfered """ - path = '/groups/%d/projects/%d' % (self.id, project_id) + path = '/groups/%d/projects/%d' % (self.id, to_project_id) self.manager.gitlab.http_post(path, **kwargs) From 0268fc91e9596b8b02c13648ae4ea94ae0540f03 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Thu, 7 Sep 2017 20:56:52 +0200 Subject: [PATCH 89/93] [v4] fix CLI for some mixin methods --- gitlab/cli.py | 25 +++++++++++++++---------- gitlab/mixins.py | 15 +++++++++++++++ gitlab/v4/cli.py | 9 ++++++--- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/gitlab/cli.py b/gitlab/cli.py index f6b357b0a..be9b112cd 100644 --- a/gitlab/cli.py +++ b/gitlab/cli.py @@ -35,7 +35,7 @@ custom_actions = {} -def register_custom_action(cls_name, mandatory=tuple(), optional=tuple()): +def register_custom_action(cls_names, mandatory=tuple(), optional=tuple()): def wrap(f): @functools.wraps(f) def wrapped_f(*args, **kwargs): @@ -43,15 +43,20 @@ def wrapped_f(*args, **kwargs): # in_obj defines whether the method belongs to the obj or the manager in_obj = True - final_name = cls_name - if cls_name.endswith('Manager'): - final_name = cls_name.replace('Manager', '') - in_obj = False - if final_name not in custom_actions: - custom_actions[final_name] = {} - - action = f.__name__ - custom_actions[final_name][action] = (mandatory, optional, in_obj) + classes = cls_names + if type(cls_names) != tuple: + classes = (cls_names, ) + + for cls_name in cls_names: + final_name = cls_name + if cls_name.endswith('Manager'): + final_name = cls_name.replace('Manager', '') + in_obj = False + if final_name not in custom_actions: + custom_actions[final_name] = {} + + action = f.__name__.replace('_', '-') + custom_actions[final_name][action] = (mandatory, optional, in_obj) return wrapped_f return wrap diff --git a/gitlab/mixins.py b/gitlab/mixins.py index ee98deab1..aa529897b 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -17,6 +17,7 @@ import gitlab from gitlab import base +from gitlab import cli from gitlab import exceptions as exc @@ -296,6 +297,8 @@ def delete(self, **kwargs): class AccessRequestMixin(object): + @cli.register_custom_action(('ProjectAccessRequest', 'GroupAccessRequest'), + tuple(), ('access_level', )) @exc.on_http_error(exc.GitlabUpdateError) def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs): """Approve an access request. @@ -317,6 +320,8 @@ def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs): class SubscribableMixin(object): + @cli.register_custom_action(('ProjectIssue', 'ProjectMergeRequest', + 'ProjectLabel')) @exc.on_http_error(exc.GitlabSubscribeError) def subscribe(self, **kwargs): """Subscribe to the object notifications. @@ -332,6 +337,8 @@ def subscribe(self, **kwargs): server_data = self.manager.gitlab.http_post(path, **kwargs) self._update_attrs(server_data) + @cli.register_custom_action(('ProjectIssue', 'ProjectMergeRequest', + 'ProjectLabel')) @exc.on_http_error(exc.GitlabUnsubscribeError) def unsubscribe(self, **kwargs): """Unsubscribe from the object notifications. @@ -349,6 +356,7 @@ def unsubscribe(self, **kwargs): class TodoMixin(object): + @cli.register_custom_action(('ProjectIssue', 'ProjectMergeRequest')) @exc.on_http_error(exc.GitlabHttpError) def todo(self, **kwargs): """Create a todo associated to the object. @@ -365,6 +373,7 @@ def todo(self, **kwargs): class TimeTrackingMixin(object): + @cli.register_custom_action(('ProjectIssue', 'ProjectMergeRequest')) @exc.on_http_error(exc.GitlabTimeTrackingError) def time_stats(self, **kwargs): """Get time stats for the object. @@ -379,6 +388,8 @@ def time_stats(self, **kwargs): path = '%s/%s/time_stats' % (self.manager.path, self.get_id()) return self.manager.gitlab.http_get(path, **kwargs) + @cli.register_custom_action(('ProjectIssue', 'ProjectMergeRequest'), + ('duration', )) @exc.on_http_error(exc.GitlabTimeTrackingError) def time_estimate(self, duration, **kwargs): """Set an estimated time of work for the object. @@ -395,6 +406,7 @@ def time_estimate(self, duration, **kwargs): data = {'duration': duration} return self.manager.gitlab.http_post(path, post_data=data, **kwargs) + @cli.register_custom_action(('ProjectIssue', 'ProjectMergeRequest')) @exc.on_http_error(exc.GitlabTimeTrackingError) def reset_time_estimate(self, **kwargs): """Resets estimated time for the object to 0 seconds. @@ -409,6 +421,8 @@ def reset_time_estimate(self, **kwargs): path = '%s/%s/rest_time_estimate' % (self.manager.path, self.get_id()) return self.manager.gitlab.http_post(path, **kwargs) + @cli.register_custom_action(('ProjectIssue', 'ProjectMergeRequest'), + ('duration', )) @exc.on_http_error(exc.GitlabTimeTrackingError) def add_spent_time(self, duration, **kwargs): """Add time spent working on the object. @@ -425,6 +439,7 @@ def add_spent_time(self, duration, **kwargs): data = {'duration': duration} return self.manager.gitlab.http_post(path, post_data=data, **kwargs) + @cli.register_custom_action(('ProjectIssue', 'ProjectMergeRequest')) @exc.on_http_error(exc.GitlabTimeTrackingError) def reset_spent_time(self, **kwargs): """Resets the time spent working on the object. diff --git a/gitlab/v4/cli.py b/gitlab/v4/cli.py index e61ef2036..637adfc96 100644 --- a/gitlab/v4/cli.py +++ b/gitlab/v4/cli.py @@ -33,7 +33,7 @@ def __init__(self, gl, what, action, args): self.cls_name = cli.what_to_cls(what) self.cls = gitlab.v4.objects.__dict__[self.cls_name] self.what = what.replace('-', '_') - self.action = action.lower().replace('-', '') + self.action = action.lower() self.gl = gl self.args = args self.mgr_cls = getattr(gitlab.v4.objects, @@ -64,7 +64,8 @@ def do_custom(self): if gitlab.mixins.GetWithoutIdMixin not in inspect.getmro(self.cls): data[self.cls._id_attr] = self.args.pop(self.cls._id_attr) o = self.cls(self.mgr, data) - return getattr(o, self.action)(**self.args) + method_name = self.action.replace('-', '_') + return getattr(o, method_name)(**self.args) else: return getattr(self.mgr, self.action)(**self.args) @@ -314,7 +315,9 @@ def get_dict(obj): if k in fields} return obj.attributes - if isinstance(ret_val, list): + if isinstance(ret_val, dict): + printer.display(ret_val, verbose=True, obj=ret_val) + elif isinstance(ret_val, list): for obj in ret_val: if isinstance(obj, gitlab.base.RESTObject): printer.display(get_dict(obj), verbose=verbose, obj=obj) From 60efc83b5a00c733b5fc19fc458674709cd7f9ce Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Thu, 7 Sep 2017 21:31:03 +0200 Subject: [PATCH 90/93] Improve the docs to make v4 a first class citizen --- docs/api-usage.rst | 94 ++++++++++++++++++++++++++++++++-------- docs/cli.rst | 9 ++-- docs/switching-to-v4.rst | 11 +++-- 3 files changed, 85 insertions(+), 29 deletions(-) diff --git a/docs/api-usage.rst b/docs/api-usage.rst index eae26dbe5..ecb0e645f 100644 --- a/docs/api-usage.rst +++ b/docs/api-usage.rst @@ -2,15 +2,38 @@ Getting started with the API ############################ -The ``gitlab`` package provides 3 base types: +python-gitlab supports both GitLab v3 and v4 APIs. + +v3 being deprecated by GitLab, its support in python-gitlab will be minimal. +The development team will focus on v4. + +v3 is still the default API used by python-gitlab, for compatibility reasons.. + + +Base types +========== + +The ``gitlab`` package provides some base types. * ``gitlab.Gitlab`` is the primary class, handling the HTTP requests. It holds the GitLab URL and authentication information. -* ``gitlab.GitlabObject`` is the base class for all the GitLab objects. These - objects provide an abstraction for GitLab resources (projects, groups, and so - on). -* ``gitlab.BaseManager`` is the base class for objects managers, providing the - API to manipulate the resources and their attributes. + +For v4 the following types are defined: + +* ``gitlab.base.RESTObject`` is the base class for all the GitLab v4 objects. + These objects provide an abstraction for GitLab resources (projects, groups, + and so on). +* ``gitlab.base.RESTManager`` is the base class for v4 objects managers, + providing the API to manipulate the resources and their attributes. + +For v3 the following types are defined: + +* ``gitlab.base.GitlabObject`` is the base class for all the GitLab v3 objects. + These objects provide an abstraction for GitLab resources (projects, groups, + and so on). +* ``gitlab.base.BaseManager`` is the base class for v3 objects managers, + providing the API to manipulate the resources and their attributes. + ``gitlab.Gitlab`` class ======================= @@ -40,7 +63,9 @@ You can also use configuration files to create ``gitlab.Gitlab`` objects: See the :ref:`cli_configuration` section for more information about configuration files. -**GitLab v4 support** + +API version +=========== ``python-gitlab`` uses the v3 GitLab API by default. Use the ``api_version`` parameter to switch to v4: @@ -53,15 +78,17 @@ parameter to switch to v4: .. warning:: - The v4 support is experimental. + The python-gitlab API is not the same for v3 and v4. Make sure to read + :ref:`switching_to_v4` before upgrading. + + v4 will become the default in python-gitlab. Managers ======== The ``gitlab.Gitlab`` class provides managers to access the GitLab resources. Each manager provides a set of methods to act on the resources. The available -methods depend on the resource type. Resources are represented as -``gitlab.GitlabObject``-derived objects. +methods depend on the resource type. Examples: @@ -84,17 +111,22 @@ Examples: The attributes of objects are defined upon object creation, and depend on the GitLab API itself. To list the available information associated with an object -use the python introspection tools: +use the python introspection tools for v3, or the ``attributes`` attribute for +v4: .. code-block:: python project = gl.projects.get(1) + + # v3 print(vars(project)) # or print(project.__dict__) -Some ``gitlab.GitlabObject`` classes also provide managers to access related -GitLab resources: + # v4 + print(project.attributes) + +Some objects also provide managers to access related GitLab resources: .. code-block:: python @@ -105,7 +137,7 @@ GitLab resources: Gitlab Objects ============== -You can update or delete an object when it exists as a ``GitlabObject`` object: +You can update or delete a remote object when it exists locally: .. code-block:: python @@ -119,8 +151,8 @@ You can update or delete an object when it exists as a ``GitlabObject`` object: project.delete() -Some ``GitlabObject``-derived classes provide additional methods, allowing more -actions on the GitLab resources. For example: +Some classes provide additional methods, allowing more actions on the GitLab +resources. For example: .. code-block:: python @@ -128,6 +160,22 @@ actions on the GitLab resources. For example: project = gl.projects.get(1) project.star() +Lazy objects (v4 only) +====================== + +To avoid useless calls to the server API, you can create lazy objects. These +objects are created locally using a known ID, and give access to other managers +and methods. + +The following exemple will only make one API call to the GitLab server to star +a project: + +.. code-block:: python + + # star a git repository + project = gl.projects.get(1, lazy=True) # no API call + project.star() # API call + Pagination ========== @@ -142,8 +190,7 @@ listing methods support the ``page`` and ``per_page`` parameters: The first page is page 1, not page 0. - -By default GitLab does not return the complete list of items. Use the ``all`` +By default GitLab does not return the complete list of items. Use the ``all`` parameter to get all the items when using listing methods: .. code-block:: python @@ -151,7 +198,7 @@ parameter to get all the items when using listing methods: all_groups = gl.groups.list(all=True) all_owned_projects = gl.projects.owned(all=True) -.. note:: +.. warning:: python-gitlab will iterate over the list by calling the corresponding API multiple times. This might take some time if you have a lot of items to @@ -160,6 +207,15 @@ parameter to get all the items when using listing methods: use ``safe_all=True`` instead to stop pagination automatically if the recursion limit is hit. +With v4, ``list()`` methods can also return a generator object which will +handle the next calls to the API when required: + +.. code-block:: python + + items = gl.groups.list(as_list=False) + for item in items: + print(item.attributes) + Sudo ==== diff --git a/docs/cli.rst b/docs/cli.rst index 349ee02c4..e4d3437d0 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -28,7 +28,8 @@ Content ------- The configuration file uses the ``INI`` format. It contains at least a -``[global]`` section, and a new section for each GitLab server. For example: +``[global]`` section, and a specific section for each GitLab server. For +example: .. code-block:: ini @@ -98,7 +99,7 @@ CLI Objects and actions ------------------- -The ``gitlab`` command expects two mandatory arguments. This first one is the +The ``gitlab`` command expects two mandatory arguments. The first one is the type of object that you want to manipulate. The second is the action that you want to perform. For example: @@ -140,8 +141,8 @@ These options must be defined before the mandatory arguments. Output format. Defaults to a custom format. Can also be ``yaml`` or ``json``. ``--fields``, ``-f`` - Comma-separated list of fields to display (``yaml`` and ``json`` formats - only). If not used, all the object fields are displayed. + Comma-separated list of fields to display (``yaml`` and ``json`` output + formats only). If not used, all the object fields are displayed. Example: diff --git a/docs/switching-to-v4.rst b/docs/switching-to-v4.rst index 84181ffb2..3415bc432 100644 --- a/docs/switching-to-v4.rst +++ b/docs/switching-to-v4.rst @@ -1,3 +1,5 @@ +.. _switching_to_v4: + ########################## Switching to GtiLab API v4 ########################## @@ -10,15 +12,12 @@ GitLab will stop supporting the v3 API soon, and you should consider switching to v4 if you use a recent version of GitLab (>= 9.0), or if you use http://gitlab.com. -The new v4 API is available in the `rework_api branch on github -`_, and will be -released soon. - Using the v4 API ================ -To use the new v4 API, explicitly use it in the ``Gitlab`` constructor: +To use the new v4 API, explicitly define ``api_version` `in the ``Gitlab`` +constructor: .. code-block:: python @@ -79,7 +78,7 @@ following important changes in the python API: calls. To limit the number of API calls, you can now use ``get()`` methods with the - ``lazy=True`` parameter. This creates shallow objects that provide usual + ``lazy=True`` parameter. This creates shallow objects that provide usual managers. The following v3 code: From d0e2a1595c54a1481b8ca8a4de6e1c12686be364 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Thu, 7 Sep 2017 21:34:53 +0200 Subject: [PATCH 91/93] pep8 fix --- gitlab/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitlab/cli.py b/gitlab/cli.py index be9b112cd..1ab7d627d 100644 --- a/gitlab/cli.py +++ b/gitlab/cli.py @@ -47,7 +47,7 @@ def wrapped_f(*args, **kwargs): if type(cls_names) != tuple: classes = (cls_names, ) - for cls_name in cls_names: + for cls_name in classes: final_name = cls_name if cls_name.endswith('Manager'): final_name = cls_name.replace('Manager', '') From 670217d4785f52aa502dce6c9c16a3d581a7719c Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Thu, 7 Sep 2017 21:36:27 +0200 Subject: [PATCH 92/93] Switch the version to 1.0.0 The v4 API breaks the compatibility with v3 (at the python-gitlab level), but I believe it is for the greater good. The new code is way easier to read and maintain, and provides more possibilities. The v3 API will die eventually. --- gitlab/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 644a7842c..e94c6b25a 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -34,7 +34,7 @@ from gitlab.v3.objects import * # noqa __title__ = 'python-gitlab' -__version__ = '0.21.2' +__version__ = '1.0.0' __author__ = 'Gauvain Pocentek' __email__ = 'gauvain@pocentek.net' __license__ = 'LGPL3' From 3d8df3ccb22142c4cff86ba879882b0269f1b3b6 Mon Sep 17 00:00:00 2001 From: Gauvain Pocentek Date: Fri, 8 Sep 2017 07:02:52 +0200 Subject: [PATCH 93/93] Update changelog, release notes and authors for v1.0 --- AUTHORS | 8 +++++++- ChangeLog.rst | 14 ++++++++++++++ RELEASE_NOTES.rst | 13 +++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index d95dad8c5..1ac8933ad 100644 --- a/AUTHORS +++ b/AUTHORS @@ -15,6 +15,7 @@ Andjelko Horvat Andreas Nüßlein Andrew Austin Armin Weihbold +Aron Pammer Asher256 Asher256@users.noreply.github.com Christian @@ -26,28 +27,32 @@ Daniel Kimsey derek-austin Diego Giovane Pasqualin Dmytro Litvinov +Eli Sarver Erik Weatherwax fgouteroux Greg Allen Guillaume Delacour Guyzmo +Guyzmo hakkeroid Ian Sparks itxaka Ivica Arsov James (d0c_s4vage) Johnson -Jamie Bliss James E. Flemer James Johnson +Jamie Bliss Jason Antman Johan Brandhorst Jonathon Reinhart +Jon Banafato Koen Smets Kris Gambirazzi Mart Sõmermaa massimone88 Matej Zerovnik Matt Odden +Maura Hausman Michal Galet Mikhail Lopotkov Missionrulz @@ -55,6 +60,7 @@ Mond WAN Nathan Giesbrecht pa4373 Patrick Miller +Pavel Savchenko Peng Xiao Pete Browne Peter Mosmans diff --git a/ChangeLog.rst b/ChangeLog.rst index a72ac6f24..969d9ef39 100644 --- a/ChangeLog.rst +++ b/ChangeLog.rst @@ -1,6 +1,19 @@ ChangeLog ========= +Version 1.0.0_ - 2017-09-08 +--------------------------- + +* Support for API v4. See + http://python-gitlab.readthedocs.io/en/master/switching-to-v4.html +* Support SSL verification via internal CA bundle +* Docs: Add link to gitlab docs on obtaining a token +* Added dependency injection support for Session +* Fixed repository_compare examples +* Fix changelog and release notes inclusion in sdist +* Missing expires_at in GroupMembers update +* Add lower-level methods for Gitlab() + Version 0.21.2_ - 2017-06-11 ---------------------------- @@ -434,6 +447,7 @@ Version 0.1 - 2013-07-08 * Initial release +.. _1.0.0: https://github.com/python-gitlab/python-gitlab/compare/0.21.2...1.0.0 .. _0.21.2: https://github.com/python-gitlab/python-gitlab/compare/0.21.1...0.21.2 .. _0.21.1: https://github.com/python-gitlab/python-gitlab/compare/0.21...0.21.1 .. _0.21: https://github.com/python-gitlab/python-gitlab/compare/0.20...0.21 diff --git a/RELEASE_NOTES.rst b/RELEASE_NOTES.rst index 86cac9dd6..c495cb0ac 100644 --- a/RELEASE_NOTES.rst +++ b/RELEASE_NOTES.rst @@ -4,6 +4,19 @@ Release notes This page describes important changes between python-gitlab releases. +Changes from 0.21 to 1.0.0 +========================== + +1.0.0 brings a stable python-gitlab API for the v4 Gitlab API. v3 is still used +by default. + +v4 is mostly compatible with the v3, but some important changes have been +introduced. Make sure to read `Switching to GtiLab API v4 +`_. + +The development focus will be v4 from now on. v3 has been deprecated by GitLab +and will disappear from python-gitlab at some point. + Changes from 0.20 to 0.21 =========================