diff options
| author | Gauvain Pocentek <gauvain@pocentek.net> | 2017-08-04 18:45:16 +0200 |
|---|---|---|
| committer | Gauvain Pocentek <gauvain@pocentek.net> | 2017-08-04 18:45:16 +0200 |
| commit | 3ccdec04525456c906f26ee2e931607a5d0dcd20 (patch) | |
| tree | 48709c487d57c738eb881a2728a3300023c482e5 | |
| parent | e87835fe02aeb174c1b0355a1733733d89b2e404 (diff) | |
| parent | 2816c1ae51b01214012679b74aa14de1a6696eb5 (diff) | |
| download | gitlab-3ccdec04525456c906f26ee2e931607a5d0dcd20.tar.gz | |
Merge branch 'rework_api'
| -rw-r--r-- | .travis.yml | 1 | ||||
| -rw-r--r-- | MANIFEST.in | 2 | ||||
| -rw-r--r-- | docs/api/gitlab.rst | 78 | ||||
| -rw-r--r-- | docs/api/gitlab.v3.rst | 22 | ||||
| -rw-r--r-- | docs/api/gitlab.v4.rst | 22 | ||||
| -rw-r--r-- | docs/api/modules.rst | 7 | ||||
| -rw-r--r-- | docs/ext/docstrings.py | 14 | ||||
| -rw-r--r-- | docs/index.rst | 4 | ||||
| -rw-r--r-- | docs/switching-to-v4.rst | 116 | ||||
| -rw-r--r-- | docs/upgrade-from-0.10.rst | 125 | ||||
| -rw-r--r-- | gitlab/__init__.py | 325 | ||||
| -rw-r--r-- | gitlab/base.py | 163 | ||||
| -rw-r--r-- | gitlab/cli.py | 522 | ||||
| -rw-r--r-- | gitlab/exceptions.py | 28 | ||||
| -rw-r--r-- | gitlab/mixins.py | 438 | ||||
| -rw-r--r-- | gitlab/tests/test_base.py | 129 | ||||
| -rw-r--r-- | gitlab/tests/test_cli.py | 37 | ||||
| -rw-r--r-- | gitlab/tests/test_gitlab.py | 271 | ||||
| -rw-r--r-- | gitlab/tests/test_gitlabobject.py | 1 | ||||
| -rw-r--r-- | gitlab/tests/test_mixins.py | 411 | ||||
| -rw-r--r-- | gitlab/v3/cli.py | 497 | ||||
| -rw-r--r-- | gitlab/v3/objects.py | 1 | ||||
| -rw-r--r-- | gitlab/v4/objects.py | 3012 | ||||
| -rwxr-xr-x | tools/build_test_env.sh | 2 | ||||
| -rwxr-xr-x | tools/py_functional_tests.sh | 2 | ||||
| -rw-r--r-- | tools/python_test_v3.py (renamed from tools/python_test.py) | 10 | ||||
| -rw-r--r-- | tools/python_test_v4.py | 341 |
27 files changed, 4359 insertions, 2222 deletions
diff --git a/.travis.yml b/.travis.yml index 7c8b9fd..dd405f5 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 diff --git a/MANIFEST.in b/MANIFEST.in index e677be7..3cc3cdc 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 diff --git a/docs/api/gitlab.rst b/docs/api/gitlab.rst index d34d56f..e75f843 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/gitlab.v3.rst b/docs/api/gitlab.v3.rst new file mode 100644 index 0000000..61879bc --- /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 0000000..70358c1 --- /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/docs/api/modules.rst b/docs/api/modules.rst deleted file mode 100644 index 3ec5a68..0000000 --- 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 fc95eeb..32c5da1 100644 --- a/docs/ext/docstrings.py +++ b/docs/ext/docstrings.py @@ -10,6 +10,8 @@ from sphinx.ext.napoleon.docstring import GoogleDocstring def classref(value, short=True): + return value + if not inspect.isclass(value): return ':class:%s' % value tilde = '~' if short else '' @@ -46,8 +48,13 @@ class GitlabDocstring(GoogleDocstring): 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 @@ class GitlabDocstring(GoogleDocstring): 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 2198025..7805fcf 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,9 +14,9 @@ Contents: install cli api-usage + switching-to-v4 api-objects - upgrade-from-0.10 - api/modules + api/gitlab release_notes changelog diff --git a/docs/switching-to-v4.rst b/docs/switching-to-v4.rst new file mode 100644 index 0000000..84181ff --- /dev/null +++ b/docs/switching-to-v4.rst @@ -0,0 +1,116 @@ +########################## +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 +<https://github.com/python-gitlab/python-gitlab/tree/rework_api>`_, 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) can now return generators + (:class:`~gitlab.base.RESTObjectList`). They handle the calls to the API when + needed to fetch new items. + + By default you will still get lists. To get generators use ``as_list=False``: + + .. code-block:: python + + 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 + 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` diff --git a/docs/upgrade-from-0.10.rst b/docs/upgrade-from-0.10.rst deleted file mode 100644 index 7ff80ab..0000000 --- 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() diff --git a/gitlab/__init__.py b/gitlab/__init__.py index b419cb8..bdeb5c4 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 @@ -94,6 +93,7 @@ class Gitlab(object): objects = importlib.import_module('gitlab.v%s.objects' % self._api_version) + self._objects = objects self.broadcastmessages = objects.BroadcastMessageManager(self) self.deploykeys = objects.DeployKeyManager(self) @@ -118,21 +118,22 @@ class Gitlab(object): 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' % (prefix, var) + manager = getattr(objects, cls_name)(self) + setattr(self, var_name, manager) @property def api_version(self): @@ -191,13 +192,16 @@ class Gitlab(object): 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 = self._objects.CurrentUser(self, r.json()) + else: + manager = self._objects.CurrentUserManager() + self.user = manager.get(self.email, self.password) + self._set_token(self.user.private_token) def token_auth(self): @@ -207,7 +211,10 @@ class Gitlab(object): self._token_auth() def _token_auth(self): - self.user = CurrentUser(self) + 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. @@ -599,3 +606,273 @@ class Gitlab(object): 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 + """ + + 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') + 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 + + 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) + + 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: + raise GitlabParsingError( + error_message="Failed to parse the server message") + else: + return result + + def http_list(self, path, query_data={}, as_list=None, **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: + 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) + + 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. + + 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 if json is return, else the + raw content + + 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: + 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. + + 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('put', path, query_data=query_data, + post_data=post_data, **kwargs) + try: + return result.json() + except Exception: + raise GitlabParsingError( + error_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: + The requests object. + + Raises: + GitlabHttpError: When the return code is not 2xx + """ + return self.http_request('delete', path, **kwargs) + + +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, 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, + **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: + raise GitlabParsingError( + error_message="Failed to parse the server message") + + self._current = 0 + + def __iter__(self): + return self + + def __len__(self): + return int(self._total) + + 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 and self._get_next is True: + self._query(self._next_url) + return self.next() + + raise StopIteration diff --git a/gitlab/base.py b/gitlab/base.py index 0d82cf1..df25a36 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -531,3 +531,166 @@ class GitlabObject(object): def __ne__(self, other): return not self.__eq__(other) + + +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': {}, + '_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) + + self._create_managers() + + def __getattr__(self, name): + try: + return self.__dict__['_updated_attrs'][name] + except KeyError: + try: + return self.__dict__['_attrs'][name] + except KeyError: + try: + return self.__dict__['_parent_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 _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 _update_attrs(self, new_attrs): + self.__dict__['_updated_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) + + +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): + """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 + + 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 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=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() + + @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'): + return path + + 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 + def path(self): + return self._computed_path diff --git a/gitlab/cli.py b/gitlab/cli.py index 8cc89c2..f23fff9 100644 --- a/gitlab/cli.py +++ b/gitlab/cli.py @@ -17,447 +17,33 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from __future__ import print_function -from __future__ import division from __future__ import absolute_import import argparse -import inspect -import operator +import importlib import re import sys -import six - -import gitlab +import gitlab.config 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: { - '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']}}, -} - -def _die(msg, e=None): +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): +def what_to_cls(what): return "".join([s.capitalize() for s in what.split("-")]) -def _cls_to_what(cls): +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('_', '-')) - - return id - - def do_create(self, cls, gl, what, args): - if not cls.canCreate: - _die("%s objects can't be created" % what) - - try: - o = cls.create(gl, args) - except Exception as e: - _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) - - try: - l = cls.list(gl, **args) - except Exception as e: - _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) - - id = None - if cls not in [gitlab.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) - - return o - - def do_delete(self, cls, gl, what, args): - if not cls.canDelete: - _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) - - def do_update(self, cls, gl, what, args): - if not cls.canUpdate: - _die("%s objects can't be updated" % what) - - o = self.do_get(cls, gl, what, args) - try: - for k, v in args.items(): - o.__dict__[k] = v - o.save() - except Exception as e: - _die("Impossible to update object", e) - - return o - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - def do_project_merge_request_merge(self, cls, gl, what, args): - try: - o = self.do_get(cls, gl, what, args) - should_remove = args.get('should_remove_source_branch', False) - build_succeeds = args.get('merged_when_build_succeeds', False) - return o.merge( - merge_commit_message=args.get('merge_commit_message', ''), - should_remove_source_branch=should_remove, - merged_when_build_succeeds=build_succeeds) - except Exception as e: - _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) - - 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) - - 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) - - -def _populate_sub_parser_by_class(cls, sub_parser): - for action_name in ['list', 'get', 'create', 'update', 'delete']: - attr = 'can' + action_name.capitalize() - if not getattr(cls, attr): - continue - sub_parser_action = sub_parser.add_parser(action_name) - [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), - required=True) - for x in cls.requiredUrlAttrs] - sub_parser_action.add_argument("--sudo", required=False) - - if action_name == "list": - [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), - required=True) - for x in cls.requiredListAttrs] - 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 in ["get", "delete"]: - if cls not in [gitlab.CurrentUser]: - if cls.getRequiresId: - id_attr = cls.idAttr.replace('_', '-') - sub_parser_action.add_argument("--%s" % id_attr, - required=True) - [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), - required=True) - for x in cls.requiredGetAttrs if x != cls.idAttr] - - if action_name == "get": - [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), - required=False) - for x in cls.optionalGetAttrs] - - if action_name == "list": - [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), - required=False) - for x in cls.optionalListAttrs] - - if action_name == "create": - [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), - required=True) - for x in cls.requiredCreateAttrs] - [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), - required=False) - for x in cls.optionalCreateAttrs] - - if action_name == "update": - id_attr = cls.idAttr.replace('_', '-') - sub_parser_action.add_argument("--%s" % id_attr, - required=True) - - attrs = (cls.requiredUpdateAttrs - if (cls.requiredUpdateAttrs or cls.optionalUpdateAttrs) - else cls.requiredCreateAttrs) - [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), - required=True) - for x in attrs if x != cls.idAttr] - - attrs = (cls.optionalUpdateAttrs - if (cls.requiredUpdateAttrs or cls.optionalUpdateAttrs) - else cls.optionalCreateAttrs) - [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), - required=False) - for x in attrs] - - if cls in EXTRA_ACTIONS: - def _add_arg(parser, required, data): - extra_args = {} - if isinstance(data, tuple): - if data[1] is bool: - extra_args = {'action': 'store_true'} - data = data[0] - - parser.add_argument("--%s" % data, required=required, **extra_args) - - for action_name in sorted(EXTRA_ACTIONS[cls]): - sub_parser_action = sub_parser.add_parser(action_name) - d = EXTRA_ACTIONS[cls][action_name] - [_add_arg(sub_parser_action, True, arg) - for arg in d.get('required', [])] - [_add_arg(sub_parser_action, False, arg) - for arg in d.get('optional', [])] - - -def _build_parser(args=sys.argv[1:]): +def _get_base_parser(): parser = argparse.ArgumentParser( description="GitLab API Command Line Interface") parser.add_argument("--version", help="Display the version.", @@ -474,35 +60,12 @@ def _build_parser(args=sys.argv[1:]): "will be used."), required=False) - 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(): - try: - if gitlab.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) - 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 -def _parse_args(args=sys.argv[1:]): - parser = _build_parser() - return parser.parse_args(args) +def _get_parser(cli_module): + parser = _get_base_parser() + return cli_module.extend_parser(parser) def main(): @@ -510,56 +73,33 @@ def main(): 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 - + 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"): + 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 try: - cls = gitlab.__dict__[_what_to_cls(what)] - except Exception: - _die("Unknown object: %s" % what) - - gl = do_auth(gitlab_id, config_files) - - 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): - method = test - break - - if method is None: - sys.stderr.write("Don't know how to deal with this!\n") - sys.exit(1) - - ret_val = getattr(cli, method)(cls, gl, what, args) + gl = gitlab.Gitlab.from_config(gitlab_id, config_files) + gl.auth() + except Exception as e: + die(str(e)) - if isinstance(ret_val, list): - for o in ret_val: - if isinstance(o, gitlab.GitlabObject): - o.display(verbose) - print("") - else: - print(o) - elif isinstance(ret_val, gitlab.GitlabObject): - ret_val.display(verbose) - elif isinstance(ret_val, six.string_types): - print(ret_val) + cli_module.run(gl, what, action, args, verbose) sys.exit(0) diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py index c7d1da6..6c00129 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 @@ -47,6 +51,10 @@ class GitlabOperationError(GitlabError): pass +class GitlabHttpError(GitlabError): + pass + + class GitlabListError(GitlabOperationError): pass @@ -202,3 +210,23 @@ def raise_error_from_response(response, error, expected_code=200): 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 new file mode 100644 index 0000000..9dd05af --- /dev/null +++ b/gitlab/mixins.py @@ -0,0 +1,438 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2013-2017 Gauvain Pocentek <gauvain@pocentek.net> +# +# 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 <http://www.gnu.org/licenses/>. + +import gitlab +from gitlab import base +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. + + 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) + + Returns: + object: The generated RESTObject. + + Raises: + 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 options to send to the Gitlab server (e.g. sudo) + + Returns: + object: The generated RESTObject + + Raises: + 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): + """Retrieve a list of objects. + + Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): If set to False and no pagination option is + defined, return a generator instead of a list + **kwargs: Extra options to send to the Gitlab server (e.g. sudo) + + Returns: + list: The list of objects, or a generator if `as_list` is False + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the server cannot perform the request + """ + + # 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: + 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 options to send to the Gitlab server (e.g. sudo) + + Returns: + object: The generated RESTObject + + Raises: + 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 exc.GitlabGetError(response_code=404, error_message="Not found") + + +class RetrieveMixin(ListMixin, GetMixin): + pass + + +class CreateMixin(object): + def _check_missing_create_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): + """Return the required and optional arguments. + + Returns: + tuple: 2 items: list of required arguments and list of optional + arguments for creation (in that order) + """ + return getattr(self, '_create_attrs', (tuple(), tuple())) + + @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) + if hasattr(self, '_sanitize_data'): + data = self._sanitize_data(data, 'create') + # Handle specific URL for creation + path = kwargs.pop('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_update_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): + """Return the required and optional arguments. + + Returns: + tuple: 2 items: list of required arguments and list of optional + arguments for update (in that order) + """ + 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 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: + path = self.path + else: + path = '%s/%s' % (self.path, id) + + self._check_missing_update_attrs(new_data) + if hasattr(self, '_sanitize_data'): + data = self._sanitize_data(new_data, 'update') + else: + data = new_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): + """Delete an object on the server. + + Args: + id: ID of the object to delete + **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) + + +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): + """Save 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) + + Raise: + GitlabAuthenticationError: If authentication is not correct + GitlabUpdateError: If the server cannot perform the request + """ + updated_data = self._get_updated_data() + + # call the manager + obj_id = self.get_id() + server_data = self.manager.update(obj_id, updated_data, **kwargs) + if server_data is not None: + 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 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 + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + 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(path, post_data=data, + **kwargs) + self._update_attrs(server_data) + + +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: + 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: + 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) + self._update_attrs(server_data) + + +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: + 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: + 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) + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + 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: + 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) + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + 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: + 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_base.py b/gitlab/tests/test_base.py new file mode 100644 index 0000000..c55f000 --- /dev/null +++ b/gitlab/tests/test_base.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2017 Gauvain Pocentek <gauvain@pocentek.net> +# +# 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 <http://www.gnu.org/licenses/>. + +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) diff --git a/gitlab/tests/test_cli.py b/gitlab/tests/test_cli.py index 701655d..e6e290a 100644 --- a/gitlab/tests/test_cli.py +++ b/gitlab/tests/test_cli.py @@ -28,12 +28,13 @@ except ImportError: 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 TestCLI(unittest.TestCase): 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 @@ class TestCLI(unittest.TestCase): 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/tests/test_gitlab.py b/gitlab/tests/test_gitlab.py index c2cd19b..6bc427d 100644 --- a/gitlab/tests/test_gitlab.py +++ b/gitlab/tests/test_gitlab.py @@ -171,6 +171,277 @@ class TestGitlabRawMethods(unittest.TestCase): 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': ( + '<http://localhost/api/v4/tests?per_page=1&page=2>;' + ' 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', as_list=False) + 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", + 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': 1} + content = '[{"name": "project1"}]' + return response(200, content, headers, None, 5, request) + + with HTTMock(resp_cont): + 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) + + 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 why 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", diff --git a/gitlab/tests/test_gitlabobject.py b/gitlab/tests/test_gitlabobject.py index 3bffb82..695f900 100644 --- a/gitlab/tests/test_gitlabobject.py +++ b/gitlab/tests/test_gitlabobject.py @@ -18,7 +18,6 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from __future__ import print_function -from __future__ import division from __future__ import absolute_import import json diff --git a/gitlab/tests/test_mixins.py b/gitlab/tests/test_mixins.py new file mode 100644 index 0000000..812a118 --- /dev/null +++ b/gitlab/tests/test_mixins.py @@ -0,0 +1,411 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 Mika Mäenpää <mika.j.maenpaa@tut.fi>, +# 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 <http://www.gnu.org/licenses/>. + +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 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): + 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(as_list=False) + 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', as_list=False) + 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(GitlabGetError, 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) + + 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, {}) diff --git a/gitlab/v3/cli.py b/gitlab/v3/cli.py new file mode 100644 index 0000000..b0450e8 --- /dev/null +++ b/gitlab/v3/cli.py @@ -0,0 +1,497 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2013-2017 Gauvain Pocentek <gauvain@pocentek.net> +# +# 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 <http://www.gnu.org/licenses/>. + +from __future__ import print_function +from __future__ import absolute_import +import inspect +import operator +import sys + +import six + +import gitlab +import gitlab.base +from gitlab import cli +import gitlab.v3.objects + + +EXTRA_ACTIONS = { + 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.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']}}, +} + + +class GitlabCLI(object): + def _get_id(self, cls, args): + try: + id = args.pop(cls.idAttr) + except Exception: + cli.die("Missing --%s argument" % cls.idAttr.replace('_', '-')) + + return id + + def do_create(self, cls, gl, what, args): + if not cls.canCreate: + cli.die("%s objects can't be created" % what) + + try: + o = cls.create(gl, args) + except Exception as e: + cli.die("Impossible to create object", e) + + return o + + def do_list(self, cls, gl, what, args): + if not cls.canList: + cli.die("%s objects can't be listed" % what) + + try: + l = cls.list(gl, **args) + except Exception as e: + cli.die("Impossible to list objects", e) + + return l + + def do_get(self, cls, gl, what, args): + if cls.canGet is False: + cli.die("%s objects can't be retrieved" % what) + + id = None + 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: + cli.die("Impossible to get object", e) + + return o + + def do_delete(self, cls, gl, what, args): + if not cls.canDelete: + cli.die("%s objects can't be deleted" % what) + + id = args.pop(cls.idAttr) + try: + gl.delete(cls, id, **args) + except Exception as e: + cli.die("Impossible to destroy object", e) + + def do_update(self, cls, gl, what, args): + if not cls.canUpdate: + cli.die("%s objects can't be updated" % what) + + o = self.do_get(cls, gl, what, args) + try: + for k, v in args.items(): + o.__dict__[k] = v + o.save() + except Exception as e: + cli.die("Impossible to update object", e) + + return o + + def do_group_search(self, cls, gl, what, args): + try: + return gl.groups.search(args['query']) + except Exception as 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + cli.die("Impossible to cancel merge request", e) + + def do_project_merge_request_merge(self, cls, gl, what, args): + try: + o = self.do_get(cls, gl, what, args) + should_remove = args.get('should_remove_source_branch', False) + build_succeeds = args.get('merged_when_build_succeeds', False) + return o.merge( + merge_commit_message=args.get('merge_commit_message', ''), + should_remove_source_branch=should_remove, + merged_when_build_succeeds=build_succeeds) + except Exception as 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: + 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: + 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: + cli.die("Impossible to get user %s" % args['query'], e) + + +def _populate_sub_parser_by_class(cls, sub_parser): + for action_name in ['list', 'get', 'create', 'update', 'delete']: + attr = 'can' + action_name.capitalize() + if not getattr(cls, attr): + continue + sub_parser_action = sub_parser.add_parser(action_name) + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=True) + for x in cls.requiredUrlAttrs] + sub_parser_action.add_argument("--sudo", required=False) + + if action_name == "list": + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=True) + for x in cls.requiredListAttrs] + 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 in ["get", "delete"]: + if cls not in [gitlab.v3.objects.CurrentUser]: + if cls.getRequiresId: + id_attr = cls.idAttr.replace('_', '-') + sub_parser_action.add_argument("--%s" % id_attr, + required=True) + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=True) + for x in cls.requiredGetAttrs if x != cls.idAttr] + + if action_name == "get": + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=False) + for x in cls.optionalGetAttrs] + + if action_name == "list": + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=False) + for x in cls.optionalListAttrs] + + if action_name == "create": + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=True) + for x in cls.requiredCreateAttrs] + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=False) + for x in cls.optionalCreateAttrs] + + if action_name == "update": + id_attr = cls.idAttr.replace('_', '-') + sub_parser_action.add_argument("--%s" % id_attr, + required=True) + + attrs = (cls.requiredUpdateAttrs + if (cls.requiredUpdateAttrs or cls.optionalUpdateAttrs) + else cls.requiredCreateAttrs) + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=True) + for x in attrs if x != cls.idAttr] + + attrs = (cls.optionalUpdateAttrs + if (cls.requiredUpdateAttrs or cls.optionalUpdateAttrs) + else cls.optionalCreateAttrs) + [sub_parser_action.add_argument("--%s" % x.replace('_', '-'), + required=False) + for x in attrs] + + if cls in EXTRA_ACTIONS: + def _add_arg(parser, required, data): + extra_args = {} + if isinstance(data, tuple): + if data[1] is bool: + extra_args = {'action': 'store_true'} + data = data[0] + + parser.add_argument("--%s" % data, required=required, **extra_args) + + for action_name in sorted(EXTRA_ACTIONS[cls]): + sub_parser_action = sub_parser.add_parser(action_name) + d = EXTRA_ACTIONS[cls][action_name] + [_add_arg(sub_parser_action, True, arg) + for arg in d.get('required', [])] + [_add_arg(sub_parser_action, False, arg) + for arg in d.get('optional', [])] + + +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.v3.objects.__dict__.values(): + try: + 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 = 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 + + +def run(gl, what, action, args, verbose): + try: + cls = gitlab.v3.objects.__dict__[cli.what_to_cls(what)] + except ImportError: + cli.die("Unknown object: %s" % what) + + 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(g_cli, test): + method = test + break + + if method is None: + sys.stderr.write("Don't know how to deal with this!\n") + sys.exit(1) + + ret_val = getattr(g_cli, method)(cls, gl, what, args) + + if isinstance(ret_val, list): + for o in ret_val: + if isinstance(o, gitlab.GitlabObject): + o.display(verbose) + print("") + else: + print(o) + elif isinstance(ret_val, gitlab.base.GitlabObject): + ret_val.display(verbose) + elif isinstance(ret_val, six.string_types): + print(ret_val) diff --git a/gitlab/v3/objects.py b/gitlab/v3/objects.py index 65015fc..69a9721 100644 --- a/gitlab/v3/objects.py +++ b/gitlab/v3/objects.py @@ -16,7 +16,6 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. 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 89321c9..49ccc9d 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -16,17 +16,14 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from __future__ import print_function -from __future__ import division from __future__ import absolute_import import base64 -import json import six -from six.moves import urllib -import gitlab from gitlab.base import * # noqa from gitlab.exceptions import * # noqa +from gitlab.mixins import * # noqa from gitlab import utils VISIBILITY_PRIVATE = 'private' @@ -40,587 +37,623 @@ ACCESS_MASTER = 40 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 for the sidekiq metrics API. """ - def __init__(self, gl): - """Constructs a Sidekiq manager. + + @exc.on_http_error(exc.GitlabGetError) + def queue_metrics(self, **kwargs): + """Return the registred queues information. Args: - gl (gitlab.Gitlab): Gitlab object referencing the GitLab server. - """ - self.gitlab = gl + **kwargs: Extra options to send to the server (e.g. sudo) - def _simple_get(self, url, **kwargs): - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabGetError) - return r.json() + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the information couldn't be retrieved - def queue_metrics(self, **kwargs): - """Returns the registred queues information.""" - return self._simple_get('/sidekiq/queue_metrics', **kwargs) + 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 self._simple_get('/sidekiq/process_metrics', **kwargs) + """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 self._simple_get('/sidekiq/job_stats', **kwargs) + """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 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 UserEmailManager(BaseManager): - obj_cls = UserEmail - - -class UserKey(GitlabObject): - _url = '/users/%(user_id)s/keys' - canGet = 'from_list' - canUpdate = False - requiredUrlAttrs = ['user_id'] - requiredCreateAttrs = ['title', 'key'] - - -class UserKeyManager(BaseManager): - obj_cls = UserKey - - -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 UserProjectManager(BaseManager): - obj_cls = UserProject - - -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')]), + """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) + + +class UserEmail(ObjectDeleteMixin, RESTObject): + _short_print_attr = 'email' + + +class UserEmailManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): + _path = '/users/%(user_id)s/emails' + _obj_cls = UserEmail + _from_parent_attrs = {'user_id': 'id'} + _create_attrs = (('email', ), tuple()) + + +class UserKey(ObjectDeleteMixin, RESTObject): + pass + + +class UserKeyManager(GetFromListMixin, CreateMixin, DeleteMixin, RESTManager): + _path = '/users/%(user_id)s/keys' + _obj_cls = UserKey + _from_parent_attrs = {'user_id': 'id'} + _create_attrs = (('title', 'key'), tuple()) + + +class UserProject(RESTObject): + _constructor_types = {'owner': 'User', 'namespace': 'Group'} + + +class UserProjectManager(CreateMixin, RESTManager): + _path = '/projects/user/%(user_id)s' + _obj_cls = UserProject + _from_parent_attrs = {'user_id': 'id'} + _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') ) - 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) +class User(SaveMixin, ObjectDeleteMixin, RESTObject): + _short_print_attr = 'username' + _managers = ( + ('emails', 'UserEmailManager'), + ('keys', 'UserKeyManager'), + ('projects', 'UserProjectManager'), + ) + + @exc.on_http_error(exc.GitlabBlockError) 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' + """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 + """ + 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 + @exc.on_http_error(exc.GitlabUnblockError) 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' + """Unblock the user. - 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 + 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 + """ + 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 + + +class UserManager(CRUDMixin, RESTManager): + _path = '/users' + _obj_cls = User + + _list_filters = ('active', 'blocked', 'username', 'extern_uid', 'provider', + 'external') + _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') + ) -class UserManager(BaseManager): - obj_cls = User + 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): - _url = '/user/emails' - canUpdate = False - shortPrintAttr = 'email' - requiredCreateAttrs = ['email'] +class CurrentUserEmail(ObjectDeleteMixin, 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 = (('email', ), tuple()) -class CurrentUserKey(GitlabObject): - _url = '/user/keys' - canUpdate = False - shortPrintAttr = 'title' - requiredCreateAttrs = ['title', 'key'] +class CurrentUserKey(ObjectDeleteMixin, 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 = (('title', 'key'), 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 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 - - 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) - - -class ApplicationSettingsManager(BaseManager): - obj_cls = ApplicationSettings - - -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(BaseManager): - obj_cls = BroadcastMessage - - -class DeployKey(GitlabObject): - _url = '/deploy_keys' - canGet = 'from_list' - canCreate = False - canUpdate = False - canDelete = False - - -class DeployKeyManager(BaseManager): - obj_cls = DeployKey - - -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(BaseManager): - obj_cls = NotificationSettings - - -class Dockerfile(GitlabObject): - _url = '/templates/dockerfiles' - canDelete = False - canUpdate = False - canCreate = False - idAttr = 'name' - - -class DockerfileManager(BaseManager): - obj_cls = Dockerfile - - -class Gitignore(GitlabObject): - _url = '/templates/gitignores' - canDelete = False - canUpdate = False - canCreate = False - idAttr = 'name' +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 GitignoreManager(BaseManager): - obj_cls = Gitignore +class ApplicationSettings(SaveMixin, RESTObject): + _id_attr = None -class Gitlabciyml(GitlabObject): - _url = '/templates/gitlab_ci_ymls' - canDelete = False - canUpdate = False - canCreate = False - idAttr = 'name' +class ApplicationSettingsManager(GetWithoutIdMixin, UpdateMixin, RESTManager): + _path = '/application/settings' + _obj_cls = ApplicationSettings + _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') + ) -class GitlabciymlManager(BaseManager): - obj_cls = Gitlabciyml + 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 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 BroadcastMessage(SaveMixin, ObjectDeleteMixin, RESTObject): + pass -class GroupIssueManager(BaseManager): - obj_cls = GroupIssue +class BroadcastMessageManager(CRUDMixin, RESTManager): + _path = '/broadcast_messages' + _obj_cls = BroadcastMessage + _create_attrs = (('message', ), ('starts_at', 'ends_at', 'color', 'font')) + _update_attrs = (tuple(), ('message', 'starts_at', 'ends_at', 'color', + 'font')) -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'] - optionalUpdateAttrs = ['expires_at'] - shortPrintAttr = 'username' - def _update(self, **kwargs): - self.user_id = self.id - super(GroupMember, self)._update(**kwargs) +class DeployKey(RESTObject): + pass -class GroupMemberManager(BaseManager): - obj_cls = GroupMember +class DeployKeyManager(GetFromListMixin, RESTManager): + _path = '/deploy_keys' + _obj_cls = DeployKey -class GroupNotificationSettings(NotificationSettings): - _url = '/groups/%(group_id)s/notification_settings' - requiredUrlAttrs = ['group_id'] +class NotificationSettings(SaveMixin, RESTObject): + _id_attr = None -class GroupNotificationSettingsManager(BaseManager): - obj_cls = GroupNotificationSettings +class NotificationSettingsManager(GetWithoutIdMixin, UpdateMixin, RESTManager): + _path = '/notification_settings' + _obj_cls = NotificationSettings + _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 GroupAccessRequest(GitlabObject): - _url = '/groups/%(group_id)s/access_requests' - canGet = 'from_list' - canUpdate = False - def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs): - """Approve an access request. +class Dockerfile(RESTObject): + _id_attr = 'name' - 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. - """ +class DockerfileManager(RetrieveMixin, RESTManager): + _path = '/templates/dockerfiles' + _obj_cls = Dockerfile + + +class Gitignore(RESTObject): + _id_attr = 'name' + + +class GitignoreManager(RetrieveMixin, RESTManager): + _path = '/templates/gitignores' + _obj_cls = Gitignore + + +class Gitlabciyml(RESTObject): + _id_attr = 'name' + + +class GitlabciymlManager(RetrieveMixin, RESTManager): + _path = '/templates/gitlab_ci_ymls' + _obj_cls = Gitlabciyml + + +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') - 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 GroupMember(SaveMixin, ObjectDeleteMixin, RESTObject): + _short_print_attr = 'username' -class GroupAccessRequestManager(BaseManager): - obj_cls = GroupAccessRequest +class GroupMemberManager(GetFromListMixin, CreateMixin, UpdateMixin, + DeleteMixin, 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 Hook(GitlabObject): + +class GroupNotificationSettings(NotificationSettings): + pass + + +class GroupNotificationSettingsManager(NotificationSettingsManager): + _path = '/groups/%(group_id)s/notification_settings' + _obj_cls = GroupNotificationSettings + _from_parent_attrs = {'group_id': 'id'} + + +class GroupAccessRequest(AccessRequestMixin, ObjectDeleteMixin, RESTObject): + pass + + +class GroupAccessRequestManager(GetFromListMixin, CreateMixin, DeleteMixin, + RESTManager): + _path = '/groups/%(group_id)s/access_requests' + _obj_cls = GroupAccessRequest + _from_parent_attrs = {'group_id': 'id'} + + +class Hook(ObjectDeleteMixin, 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' - optionalListAttrs = ['popular'] - optionalGetAttrs = ['project', 'fullname'] +class License(RESTObject): + _id_attr = 'key' -class LicenseManager(BaseManager): - obj_cls = License +class LicenseManager(RetrieveMixin, RESTManager): + _path = '/templates/licenses' + _obj_cls = License + _list_filters = ('popular', ) + _optional_get_attrs = ('project', 'fullname') -class Snippet(GitlabObject): - _url = '/snippets' - _constructorTypes = {'author': 'User'} - requiredCreateAttrs = ['title', 'file_name', 'content'] - optionalCreateAttrs = ['lifetime', 'visibility'] - optionalUpdateAttrs = ['title', 'file_name', 'content', 'visibility'] - shortPrintAttr = 'title' +class Snippet(SaveMixin, ObjectDeleteMixin, RESTObject): + _constructor_types = {'author': 'User'} + _short_print_attr = 'title' - def raw(self, streamed=False, action=None, chunk_size=1024, **kwargs): - """Return the raw content of a snippet. + @exc.on_http_error(exc.GitlabGetError) + 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 `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 snippet content. + data + chunk_size (int): Size of each chunk + **kwargs: Extra options to send to the server (e.g. sudo) Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the server fails to perform the request. + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the content could not be retrieved + + Returns: + str: The snippet content """ - url = ("/snippets/%(snippet_id)s/raw" % {'snippet_id': self.id}) - r = self.gitlab._raw_get(url, **kwargs) - raise_error_from_response(r, GitlabGetError) - return utils.response_content(r, streamed, action, chunk_size) + path = '/snippets/%s/raw' % self.get_id() + result = self.manager.gitlab.http_get(path, streamed=streamed, + **kwargs) + return utils.response_content(result, 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. 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.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, ObjectDeleteMixin, 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(ObjectDeleteMixin, RESTObject): + _constructor_types = {'author': 'User', "committer": "User"} + _id_attr = 'name' - idAttr = 'name' - canUpdate = False - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['branch', 'ref'] + @exc.on_http_error(exc.GitlabProtectError) + def protect(self, developers_can_push=False, developers_can_merge=False, + **kwargs): + """Protect the branch. - 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) + 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) - if protect: - self.protected = protect - else: - del self.protected + 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, + 'developers_can_merge': developers_can_merge} + 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.""" - self.protect(False, **kwargs) + """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 -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'} + @exc.on_http_error(exc.GitlabJobCancelError) 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) + """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.""" - url = '/projects/%s/jobs/%s/retry' % (self.project_id, self.id) - r = self.gitlab._raw_post(url) - raise_error_from_response(r, GitlabJobRetryError, 201) + """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.""" - url = '/projects/%s/jobs/%s/play' % (self.project_id, self.id) - r = self.gitlab._raw_post(url) - raise_error_from_response(r, GitlabJobPlayError) + """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).""" - url = '/projects/%s/jobs/%s/erase' % (self.project_id, self.id) - r = self.gitlab._raw_post(url) - raise_error_from_response(r, GitlabJobEraseError, 201) + """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: - GitlabConnectionError: If the server cannot be reached. - GitlabCreateError: If the request failed. + GitlabAuthenticationError: If authentication is not correct + GitlabCreateError: If the request could not be performed """ - 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) + @exc.on_http_error(exc.GitlabGetError) def artifacts(self, streamed=False, action=None, chunk_size=1024, **kwargs): """Get the job artifacts. @@ -628,447 +661,325 @@ class ProjectJob(GitlabObject): 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. - - Raises: - 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) + @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. - - Returns: - str: The trace. + data + chunk_size (int): Size of each chunk + **kwargs: Extra options to send to the server (e.g. sudo) Raises: - 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) - - -class ProjectJobManager(BaseManager): - obj_cls = ProjectJob - - -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 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 ProjectCommitCommentManager(BaseManager): - obj_cls = ProjectCommitComment - - -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) - - return r.json() - - def blob(self, filepath, streamed=False, action=None, chunk_size=1024, - **kwargs): - """Generate the content of a file for this commit. - - 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. + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the artifacts could not be retrieved Returns: - str: The content of the file - - Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the server fails to perform the request. + str: The trace """ - 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) + 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) - def cherry_pick(self, branch, **kwargs): - """Cherry-pick a commit into a branch. - Args: - branch (str): Name of target branch. +class ProjectJobManager(RetrieveMixin, RESTManager): + _path = '/projects/%(project_id)s/jobs' + _obj_cls = ProjectJob + _from_parent_attrs = {'project_id': 'id'} - Raises: - GitlabCherryPickError: If the cherry pick could not be applied. - """ - url = ('/projects/%s/repository/commits/%s/cherry_pick' % - (self.project_id, self.id)) - 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 ProjectCommitStatus(RESTObject): + pass -class ProjectCommitManager(BaseManager): - obj_cls = ProjectCommit +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): + """Create a new object. -class ProjectEnvironment(GitlabObject): - _url = '/projects/%(project_id)s/environments' - canGet = 'from_list' - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['name'] - optionalCreateAttrs = ['external_url'] - optionalUpdateAttrs = ['name', 'external_url'] + 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'. + + 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 + """ + 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 ProjectEnvironmentManager(BaseManager): - obj_cls = ProjectEnvironment +class ProjectCommitComment(RESTObject): + pass -class ProjectKey(GitlabObject): - _url = '/projects/%(project_id)s/deploy_keys' - canUpdate = False - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['title', 'key'] +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')) -class ProjectKeyManager(BaseManager): - obj_cls = ProjectKey - 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) +class ProjectCommit(RESTObject): + _short_print_attr = 'title' + _managers = ( + ('comments', 'ProjectCommitCommentManager'), + ('statuses', 'ProjectCommitStatusManager'), + ) + @exc.on_http_error(exc.GitlabGetError) + def diff(self, **kwargs): + """Generate the commit diff. -class ProjectEvent(GitlabObject): - _url = '/projects/%(project_id)s/events' - canGet = 'from_list' - canDelete = False - canUpdate = False - canCreate = False - requiredUrlAttrs = ['project_id'] - shortPrintAttr = 'target_title' + 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 -class ProjectEventManager(BaseManager): - obj_cls = ProjectEvent + 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. -class ProjectFork(GitlabObject): - _url = '/projects/%(project_id)s/fork' - canUpdate = False - canDelete = False - canList = False - canGet = False - requiredUrlAttrs = ['project_id'] - optionalCreateAttrs = ['namespace'] + Args: + 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} + self.manager.gitlab.http_post(path, post_data=post_data, **kwargs) -class ProjectForkManager(BaseManager): - obj_cls = ProjectFork +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 ProjectHook(GitlabObject): - _url = '/projects/%(project_id)s/hooks' - 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'] - shortPrintAttr = 'url' +class ProjectEnvironment(SaveMixin, ObjectDeleteMixin, RESTObject): + pass -class ProjectHookManager(BaseManager): - obj_cls = ProjectHook +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 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 ProjectKey(ObjectDeleteMixin, RESTObject): + pass -class ProjectIssueNoteManager(BaseManager): - obj_cls = ProjectIssueNote +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 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')]), - ) + @exc.on_http_error(exc.GitlabProjectDeployKeyError) + def enable(self, key_id, **kwargs): + """Enable a deploy key for a project. - def subscribe(self, **kwargs): - """Subscribe to an issue. + Args: + key_id (int): The ID of the key to enable + **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 + Raise: + GitlabAuthenticationError: If authentication is not correct + GitlabProjectDeployKeyError: If the key could not be enabled """ - url = ('/projects/%(project_id)s/issues/%(issue_iid)s/subscribe' % - {'project_id': self.project_id, 'issue_iid': self.iid}) + path = '%s/%s/enable' % (self.path, key_id) + self.gitlab.http_post(path, **kwargs) - 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 ProjectEvent(RESTObject): + _short_print_attr = 'target_title' - 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 ProjectEventManager(GetFromListMixin, RESTManager): + _path = '/projects/%(project_id)s/events' + _obj_cls = ProjectEvent + _from_parent_attrs = {'project_id': 'id'} - def move(self, to_project_id, **kwargs): - """Move the issue to another project. - 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}) +class ProjectFork(RESTObject): + pass - 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()) - def todo(self, **kwargs): - """Create a todo for the issue. +class ProjectForkManager(CreateMixin, RESTManager): + _path = '/projects/%(project_id)s/fork' + _obj_cls = ProjectFork + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (tuple(), ('namespace', )) - 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. +class ProjectHook(SaveMixin, ObjectDeleteMixin, RESTObject): + _short_print_attr = 'url' - 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() - def time_estimate(self, duration, **kwargs): - """Set an estimated time of work for the issue. +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') + ) - 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() +class ProjectIssueNote(SaveMixin, ObjectDeleteMixin, RESTObject): + _constructor_types = {'author': 'User'} - 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() +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')) + _update_attrs = (('body', ), tuple()) - 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) +class ProjectIssue(SubscribableMixin, TodoMixin, TimeTrackingMixin, SaveMixin, + ObjectDeleteMixin, RESTObject): + _constructor_types = {'author': 'User', 'assignee': 'User', 'milestone': + 'ProjectMilestone'} + _short_print_attr = 'title' + _id_attr = 'iid' + _managers = (('notes', 'ProjectIssueNoteManager'), ) - 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() + @exc.on_http_error(exc.GitlabUpdateError) + def move(self, to_project_id, **kwargs): + """Move the issue to another project. - def reset_spent_time(self, **kwargs): - """Set an estimated time of work for the issue. + Args: + to_project_id(int): ID of the target project + **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 + GitlabUpdateError: If the issue could not be moved """ - 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() + 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, + **kwargs) + self._update_attrs(server_data) -class ProjectIssueManager(BaseManager): - obj_cls = ProjectIssue +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')) -class ProjectMember(GitlabObject): - _url = '/projects/%(project_id)s/members' - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['access_level', 'user_id'] - optionalCreateAttrs = ['expires_at'] - requiredUpdateAttrs = ['access_level'] - optionalCreateAttrs = ['expires_at'] - shortPrintAttr = 'username' +class ProjectMember(SaveMixin, ObjectDeleteMixin, RESTObject): + _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'] + pass -class ProjectNotificationSettingsManager(BaseManager): - obj_cls = ProjectNotificationSettings +class ProjectNotificationSettingsManager(NotificationSettingsManager): + _path = '/projects/%(project_id)s/notification_settings' + _obj_cls = ProjectNotificationSettings + _from_parent_attrs = {'project_id': 'id'} -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 ProjectTag(ObjectDeleteMixin, RESTObject): + _constructor_types = {'release': 'ProjectTagRelease', + 'commit': 'ProjectCommit'} + _id_attr = 'name' + _short_print_attr = 'name' - -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' - - 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 @@ -1076,172 +987,151 @@ class ProjectTag(GitlabObject): Args: description (str): Description of the release. + **kwargs: Extra options to send to the server (e.g. sudo) 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. + GitlabAuthenticationError: If authentication is not correct + 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) + try: + 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: - r = self.gitlab._raw_put(url, data={'description': description}) - raise_error_from_response(r, GitlabUpdateError, 200) - self.release = ProjectTagRelease(self, r.json()) + try: + 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 = server_data -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 ProjectMergeRequestDiff(RESTObject): + pass -class ProjectMergeRequestDiffManager(BaseManager): - obj_cls = ProjectMergeRequestDiff +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'} -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 ProjectMergeRequestNote(SaveMixin, ObjectDeleteMixin, RESTObject): + _constructor_types = {'author': 'User'} -class ProjectMergeRequestNoteManager(BaseManager): - obj_cls = ProjectMergeRequestNote +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()) -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' +class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, + SaveMixin, ObjectDeleteMixin, RESTObject): + _constructor_types = {'author': 'User', 'assignee': 'User'} + _id_attr = 'iid' - managers = ( - ('notes', 'ProjectMergeRequestNoteManager', - [('project_id', 'project_id'), ('merge_request_iid', 'iid')]), - ('diffs', 'ProjectMergeRequestDiffManager', - [('project_id', 'project_id'), ('merge_request_iid', 'iid')]), + _managers = ( + ('notes', 'ProjectMergeRequestNoteManager'), + ('diffs', 'ProjectMergeRequestDiffManager') ) - 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. - - 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()) + @exc.on_http_error(exc.GitlabMROnBuildSuccessError) + def cancel_merge_when_pipeline_succeeds(self, **kwargs): + """Cancel merge when the pipeline succeeds. - def unsubscribe(self, **kwargs): - """Unsubscribe a MR. + 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 + GitlabMROnBuildSuccessError: If the server could not handle the + request """ - url = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s/' - 'unsubscribe' % - {'project_id': self.project_id, 'mr_iid': self.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()) - - 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) + @exc.on_http_error(exc.GitlabListError) 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 + Args: + **kwargs: Extra options to send to the server (e.g. sudo) Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the server fails to perform the request. - """ - url = ('/projects/%s/merge_requests/%s/closes_issues' % - (self.project_id, self.iid)) - return self.gitlab._raw_list(url, ProjectIssue, **kwargs) + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the list could not be retrieved + Returns: + RESTObjectList: List of issues + """ + path = '%s/%s/closes_issues' % (self.manager.path, self.get_id()) + 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) + + @exc.on_http_error(exc.GitlabListError) def commits(self, **kwargs): """List the merge request commits. - Returns: - list (ProjectCommit): List of commits + Args: + **kwargs: Extra options to send to the server (e.g. sudo) Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabListError: If the server fails to perform the request. + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the list could not be retrieved + + Returns: + RESTObjectList: The list of commits """ - 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, as_list=False, + **kwargs) + manager = ProjectCommitManager(self.manager.gitlab, + 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. - Returns: - list (dict): List of changes + Args: + **kwargs: Extra options to send to the server (e.g. sudo) Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabListError: If the server fails to perform the request. + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the list could not be retrieved + + Returns: + RESTObjectList: List of changes """ - 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) + @exc.on_http_error(exc.GitlabMRClosedError) def merge(self, merge_commit_message=None, should_remove_source_branch=False, merge_when_pipeline_succeeds=False, @@ -1254,17 +1144,13 @@ class ProjectMergeRequest(GitlabObject): 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) - 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 + GitlabAuthenticationError: If authentication is not correct + GitlabMRClosedError: If the merge failed """ - 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 @@ -1273,205 +1159,253 @@ class ProjectMergeRequest(GitlabObject): if merge_when_pipeline_succeeds: data['merge_when_pipeline_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()) + server_data = self.manager.gitlab.http_put(path, post_data=data, + **kwargs) + self._update_attrs(server_data) - 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]) +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') - 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() +class ProjectMilestone(SaveMixin, ObjectDeleteMixin, RESTObject): + _short_print_attr = 'title' - def time_estimate(self, duration, **kwargs): - """Set an estimated time of work for the merge request. + @exc.on_http_error(exc.GitlabListError) + def issues(self, **kwargs): + """List issues related to this milestone. Args: - 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. - """ - 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() + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the list could not be retrieved - def reset_time_estimate(self, **kwargs): - """Resets estimated time for the merge request to 0 seconds. - - Raises: - GitlabConnectionError: If the server cannot be reached. + Returns: + RESTObjectList: The list of issues """ - 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. + path = '%s/%s/issues' % (self.manager.path, self.get_id()) + 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 + 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. Args: - 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. - """ - 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() + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the list could not be retrieved - def reset_spent_time(self, **kwargs): - """Set an estimated time of work for the merge request. - - Raises: - GitlabConnectionError: If the server cannot be reached. + Returns: + RESTObjectList: The list of merge requests """ - 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() + path = '%s/%s/merge_requests' % (self.manager.path, self.get_id()) + 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 + return RESTObjectList(manager, ProjectMergeRequest, data_list) -class ProjectMergeRequestManager(BaseManager): - obj_cls = ProjectMergeRequest +class ProjectMilestoneManager(CRUDMixin, 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 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 ProjectLabel(SubscribableMixin, SaveMixin, ObjectDeleteMixin, + RESTObject): + _id_attr = 'name' - def issues(self, **kwargs): - url = "/projects/%s/milestones/%s/issues" % (self.project_id, self.id) - return self.gitlab._raw_list(url, ProjectIssue, **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. - def merge_requests(self, **kwargs): - """List the merge requests related to this milestone + The object is updated to match what the server returns. - Returns: - list (ProjectMergeRequest): List of merge requests + Args: + **kwargs: Extra options to send to the server (e.g. sudo) Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabListError: If the server fails to perform the request. + GitlabAuthenticationError: If authentication is not correct. + GitlabUpdateError: If the server cannot perform the request. """ - url = ('/projects/%s/milestones/%s/merge_requests' % - (self.project_id, self.id)) - return self.gitlab._raw_list(url, ProjectMergeRequest, **kwargs) + updated_data = self._get_updated_data() + # call the manager + server_data = self.manager.update(None, updated_data, **kwargs) + self._update_attrs(server_data) -class ProjectMilestoneManager(BaseManager): - obj_cls = ProjectMilestone +class ProjectLabelManager(GetFromListMixin, CreateMixin, UpdateMixin, + DeleteMixin, 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')) -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'] - requiredCreateAttrs = ['name', 'color'] - optionalCreateAttrs = ['description', 'priority'] - requiredUpdateAttrs = ['name'] - optionalUpdateAttrs = ['new_name', 'color', 'description', 'priority'] + # Delete without ID. + @exc.on_http_error(exc.GitlabDeleteError) + def delete(self, name, **kwargs): + """Delete a Label on the server. - def subscribe(self, **kwargs): - """Subscribe to a label. + Args: + name: The name of the label + **kwargs: Extra options to send to the Gitlab 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. + GitlabDeleteError: If the server cannot perform the request. + """ + self.gitlab.http_delete(self.path, query_data={'name': name}, **kwargs) + + +class ProjectFile(SaveMixin, ObjectDeleteMixin, RESTObject): + _id_attr = 'file_path' + _short_print_attr = 'file_path' + + def decode(self): + """Returns the decoded content of the file. + + Returns: + (str): the decoded content. + """ + 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 """ - url = ('/projects/%(project_id)s/labels/%(label_id)s/subscribe' % - {'project_id': self.project_id, 'label_id': self.name}) + self.branch = branch + self.commit_message = commit_message + super(ProjectFile, self).save(**kwargs) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabSubscribeError, [201, 304]) - self._set_from_dict(r.json()) + def delete(self, branch, commit_message, **kwargs): + """Delete the file from the server. - def unsubscribe(self, **kwargs): - """Unsubscribe a label. + 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: - GitlabConnectionError: If the server cannot be reached. - GitlabUnsubscribeError: If the unsubscription cannot be done + GitlabAuthenticationError: If authentication is not correct + GitlabDeleteError: If the server cannot perform the request """ - url = ('/projects/%(project_id)s/labels/%(label_id)s/unsubscribe' % - {'project_id': self.project_id, 'label_id': self.name}) + self.manager.delete(self.get_id(), branch, commit_message, **kwargs) + + +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')) - r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabUnsubscribeError, [201, 304]) - self._set_from_dict(r.json()) + def get(self, file_path, ref, **kwargs): + """Retrieve a single file. + Args: + 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) -class ProjectLabelManager(BaseManager): - obj_cls = ProjectLabel + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the file could not be retrieved + Returns: + object: The generated RESTObject + """ + file_path = file_path.replace('/', '%2F') + return GetMixin.get(self, file_path, ref=ref, **kwargs) -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' + @exc.on_http_error(exc.GitlabCreateError) + def create(self, data, **kwargs): + """Create a new object. - def decode(self): - """Returns the decoded content of the file. + 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: - (str): the decoded content. + 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 """ - return base64.b64decode(self.content) + 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. -class ProjectFileManager(BaseManager): - obj_cls = ProjectFile + 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) - def raw(self, filepath, ref, streamed=False, action=None, chunk_size=1024, + @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. @@ -1480,165 +1414,172 @@ class ProjectFileManager(BaseManager): 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 - - Raises: - 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').replace('.', '%2E') + 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' +class ProjectPipeline(RESTObject): + @exc.on_http_error(exc.GitlabPipelineCancelError) + def cancel(self, **kwargs): + """Cancel the job. - canUpdate = False - canDelete = False + Args: + **kwargs: Extra options to send to the server (e.g. sudo) - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['ref'] + 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): - """Retries failed builds in a pipeline. + """Retry the job. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabPipelineRetryError: If the retry cannot be done. + GitlabAuthenticationError: If authentication is not correct + GitlabPipelineRetryError: If the request failed """ - 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()) + path = '%s/%s/retry' % (self.manager.path, self.get_id()) + self.manager.gitlab.http_post(path) - def cancel(self, **kwargs): - """Cancel builds in a pipeline. - 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()) +class ProjectPipelineManager(RetrieveMixin, CreateMixin, RESTManager): + _path = '/projects/%(project_id)s/pipelines' + _obj_cls = ProjectPipeline + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('ref', ), tuple()) + def create(self, data, **kwargs): + """Creates a new object. -class ProjectPipelineManager(BaseManager): - obj_cls = ProjectPipeline + Args: + data (dict): Parameters to send to the server to create the + resource + **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 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) -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, ObjectDeleteMixin, 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'), ) + @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 - - Raises: - 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) - return utils.response_content(r, streamed, action, chunk_size) - + path = "%s/%s/raw" % (self.manager.path, self.get_id()) + result = self.manager.gitlab.http_get(path, streamed=streamed, + **kwargs) + return utils.response_content(result, 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, ObjectDeleteMixin, RESTObject): def take_ownership(self, **kwargs): - """Update the owner of a trigger. + """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) - Raises: - 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()) + +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 ProjectTriggerManager(BaseManager): - obj_cls = ProjectTrigger +class ProjectVariable(SaveMixin, ObjectDeleteMixin, RESTObject): + _id_attr = 'key' -class ProjectVariable(GitlabObject): - _url = '/projects/%(project_id)s/variables' - idAttr = 'key' - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['key', 'value'] +class ProjectVariableManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/variables' + _obj_cls = ProjectVariable + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('key', 'value'), tuple()) + _update_attrs = (('key', 'value'), tuple()) -class ProjectVariableManager(BaseManager): - obj_cls = ProjectVariable +class ProjectService(SaveMixin, ObjectDeleteMixin, RESTObject): + pass -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 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', )), @@ -1664,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'), @@ -1683,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. + + 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) + + Returns: + object: The generated RESTObject. - if missing: - raise GitlabUpdateError('Missing attribute(s): %s' % - ", ".join(missing)) + 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 - # Optional args - for attr in self._service_attrs[self.service_name][1]: - if hasattr(self, attr): - data[attr] = getattr(self, attr) + def update(self, id=None, new_data={}, **kwargs): + """Update an object on the server. - return json.dumps(data) + 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. @@ -1717,332 +1663,321 @@ class ProjectServiceManager(BaseManager): Returns: list (str): The list of service code names. """ - return list(ProjectService._service_attrs.keys()) - - -class ProjectAccessRequest(GitlabObject): - _url = '/projects/%(project_id)s/access_requests' - canGet = 'from_list' - canUpdate = False - - 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. - """ - - 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 ProjectAccessRequestManager(BaseManager): - obj_cls = ProjectAccessRequest - - -class ProjectDeployment(GitlabObject): - _url = '/projects/%(project_id)s/deployments' - canCreate = False - canUpdate = False - canDelete = False - - -class ProjectDeploymentManager(BaseManager): - obj_cls = ProjectDeployment - - -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')]), + return list(self._service_attrs.keys()) + + +class ProjectAccessRequest(AccessRequestMixin, ObjectDeleteMixin, RESTObject): + pass + + +class ProjectAccessRequestManager(GetFromListMixin, CreateMixin, DeleteMixin, + RESTManager): + _path = '/projects/%(project_id)s/access_requests' + _obj_cls = ProjectAccessRequest + _from_parent_attrs = {'project_id': 'id'} + + +class ProjectDeployment(RESTObject): + pass + + +class ProjectDeploymentManager(RetrieveMixin, RESTManager): + _path = '/projects/%(project_id)s/deployments' + _obj_cls = ProjectDeployment + _from_parent_attrs = {'project_id': 'id'} + + +class ProjectRunner(ObjectDeleteMixin, RESTObject): + pass + + +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 Project(SaveMixin, ObjectDeleteMixin, 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'), ) + @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 - - Returns: - str: The json representation of the tree. + **kwargs: Extra options to send to the server (e.g. sudo) Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the server fails to perform the request. + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the server failed to perform the request + + Returns: + list: The representation of the tree """ - url = "/projects/%s/repository/tree" % (self.id) - params = [] + gl_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(gl_path, query_data=query_data, + **kwargs) + + @exc.on_http_error(exc.GitlabGetError) + def repository_blob(self, sha, **kwargs): + """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 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. - - Returns: - str: The blob content + data + chunk_size (int): Size of each chunk + **kwargs: Extra options to send to the server (e.g. sudo) Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the server fails to perform the request. + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the server failed to perform the request + + Returns: + str: The blob content if streamed is False, None otherwise """ - 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/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 - - Raises: - 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): - """Returns a list of contributors for the project. + @exc.on_http_error(exc.GitlabGetError) + def repository_contributors(self, **kwargs): + """Return a list of contributors for the project. - Returns: - list: The contibutors + Args: + **kwargs: Extra options to send to the server (e.g. sudo) Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the server fails to perform the request. + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the server failed to perform the request + + Returns: + list: The contributors """ - 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) + @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. - - Returns: - str: The binary data of the archive. + data + chunk_size (int): Size of each chunk + **kwargs: Extra options to send to the server (e.g. sudo) Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabGetError: If the server fails to perform the request. + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the server failed to perform the request + + Returns: + str: The binary data of the archive """ - 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.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): + @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: - GitlabConnectionError: If the server cannot be reached. - GitlabCreateError: If the server fails to perform the request. + GitlabAuthenticationError: If authentication is not correct + GitlabCreateError: If the relation could not be created """ - 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): + @exc.on_http_error(exc.GitlabDeleteError) + def delete_fork_relation(self, **kwargs): """Delete a forked relation between existing projects. + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabDeleteError: If the server fails to perform the request. + GitlabAuthenticationError: If authentication is not correct + GitlabDeleteError: If the server failed 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) + @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: - GitlabCreateError: If the action cannot be done - GitlabConnectionError: If the server cannot be reached. + GitlabAuthenticationError: If authentication is not correct + GitlabCreateError: If the server failed to perform the request """ - 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) + @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: - GitlabDeleteError: If the action cannot be done - GitlabConnectionError: If the server cannot be reached. + GitlabAuthenticationError: If authentication is not correct + GitlabDeleteError: If the server failed to perform the request """ - 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) + @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: - GitlabCreateError: If the action cannot be done - GitlabConnectionError: If the server cannot be reached. + GitlabAuthenticationError: If authentication is not correct + GitlabCreateError: If the server failed to perform the request """ - 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) + @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: - GitlabDeleteError: If the action cannot be done - GitlabConnectionError: If the server cannot be reached. + GitlabAuthenticationError: If authentication is not correct + GitlabDeleteError: If the server failed to perform the request """ - 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(path, **kwargs) + self._update_attrs(server_data) - def share(self, group_id, group_access, **kwargs): + @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: - GitlabConnectionError: If the server cannot be reached. - GitlabCreateError: If the server fails to perform the request. + GitlabAuthenticationError: If authentication is not correct + GitlabCreateError: If the server failed 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) + @exc.on_http_error(exc.GitlabCreateError) def trigger_pipeline(self, ref, token, variables={}, **kwargs): """Trigger a CI build. @@ -2052,129 +1987,176 @@ class Project(GitlabObject): 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: - GitlabConnectionError: If the server cannot be reached. - GitlabCreateError: If the server fails to perform the request. + GitlabAuthenticationError: If authentication is not correct + GitlabCreateError: If the server failed 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, ObjectDeleteMixin, 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', ) + @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 + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): If set to False and no pagination option is + defined, return a generator instead of a list + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the server failed to perform the request 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 """ - 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(ObjectDeleteMixin, RESTObject): + @exc.on_http_error(exc.GitlabTodoError) + def mark_as_done(self, **kwargs): + """Mark the todo as done. -class Todo(GitlabObject): - _url = '/todos' - canGet = 'from_list' - canUpdate = False - canCreate = False - optionalListAttrs = ['action', 'author_id', 'project_id', 'state', 'type'] + 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 + """ + 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): + @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: - GitlabConnectionError: If the server cannot be reached. - GitlabDeleteError: If the resource cannot be found + 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 """ - url = '/todos' - r = self.gitlab._raw_delete(url, **kwargs) - raise_error_from_response(r, GitlabDeleteError) - return int(r.text) + result = self.gitlab.http_post('/todos/mark_as_done', **kwargs) + try: + return int(result) + except ValueError: + return 0 + + +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', + 'with_issues_enabled', 'with_merge_requests_enabled') + +class GroupProject(Project): + pass -class ProjectManager(BaseManager): - obj_cls = Project +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 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'] - - 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 Group(SaveMixin, ObjectDeleteMixin, RESTObject): + _short_print_attr = 'name' + _managers = ( + ('accessrequests', 'GroupAccessRequestManager'), + ('members', 'GroupMemberManager'), + ('notificationsettings', 'GroupNotificationSettingsManager'), + ('projects', 'GroupProjectManager'), + ('issues', 'GroupIssueManager'), ) + @exc.on_http_error(exc.GitlabTransferProjectError) def transfer_project(self, id, **kwargs): - """Transfers a project to this new groups. + """Transfer a project to this group. - Attrs: - id (int): ID of the project to transfer. + Args: + id (int): ID of the project to transfer + **kwargs: Extra options to send to the server (e.g. sudo) Raises: - GitlabConnectionError: If the server cannot be reached. - GitlabTransferProjectError: If the server fails to perform the - request. + GitlabAuthenticationError: If authentication is not correct + GitlabTransferProjectError: If the project could not be transfered """ - 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') + ) diff --git a/tools/build_test_env.sh b/tools/build_test_env.sh index 96d341a..35a54c6 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 0d00c5f..75bb761 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 index 62d6421..a730f77 100644 --- a/tools/python_test.py +++ b/tools/python_test_v3.py @@ -55,15 +55,15 @@ foobar_user = gl.users.create( {'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: diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py new file mode 100644 index 0000000..cba4833 --- /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')[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) +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')[0].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 +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 +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) +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) |
