diff --git a/.travis.yml b/.travis.yml index 10277f764..6b18f8bb7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,3 +21,12 @@ install: - pip install tox script: - tox -e $TOX_ENV + +deploy: + provider: pypi + user: max-wittig + password: + secure: LmNkZdbNe1oBSJ/PeTCKXaeu9Ml/biY4ZN4aedbD4lLXbxV/sgsHEE4N1Xrg2D/CJsnNjBY7CHzO0vL5iak8IRpV61xkdquZHvAUQKuhjMY30HopReAEw8sP+Wpf3lYcD1BjC5KT9vqWG99feoQ6epRt//Xm4DdkBYNmmUsCsMBTZLlGnj3B/mE8w+XQxQpdA2QzpRJ549N12vidwZRKqP0Zuug3rELVSo64O2bpqarKx/EeUUhTXZ0Y4XeVYgvuHBjvPqtuSJzR17CNkjaBhacD7EFTP34sAaCKGRDpfYiiiGx9LeKOEAv5Hj0+LOqEC/o6EyiIFviE+HvLQ/kBLJ6Oo2p47fibyIU/YOAFdZYKmBRq2ZUaV0DhhuuCRPZ+yLrsuaFRrKTVEMsHVtdsXJkW5gKG08vwOndW+kamppRhkAcdFVyokIgu/6nPBRWMuS6ue2aKoKRdP2gmqk0daKM1ao2uv06A2/J1/xkPy1EX5MjyK8Mh78ooKjITp5DHYn8l1pxaB0YcEkRzfwMyLErGQaRDgo7rCOm0tTRNhArkn0VE1/KLKFbATo2NSxZDwUJQ5TBNCEqfdBN1VzNEduJ7ajbZpq3DsBRM/9hzQ5LLxn7azMl9m+WmT12Qcgz25wg2Sgbs9Z2rT6fto5h8GSLpy8ReHo+S6fALJBzA4pg= + distributions: sdist bdist_wheel + on: + tags: true diff --git a/ChangeLog.rst b/ChangeLog.rst index 3e96318fd..a1450e731 100644 --- a/ChangeLog.rst +++ b/ChangeLog.rst @@ -1,6 +1,20 @@ ChangeLog ========= +Version 1.8.0_ - 2019-02-22 +--------------------------- + +* docs(setup): use proper readme on PyPI +* docs(readme): provide commit message guidelines +* fix(api): make reset_time_estimate() work again +* fix: handle empty 'Retry-After' header from GitLab +* fix: remove decode() on error_message string +* chore: release tags to PyPI automatically +* fix(api): avoid parameter conflicts with python and gitlab +* fix(api): Don't try to parse raw downloads +* feat: Added approve & unapprove method for Mergerequests +* fix all kwarg behaviour + Version 1.7.0_ - 2018-12-09 --------------------------- @@ -685,6 +699,7 @@ Version 0.1 - 2013-07-08 * Initial release +.. _1.8.0: https://github.com/python-gitlab/python-gitlab/compare/1.7.0...1.8.0 .. _1.7.0: https://github.com/python-gitlab/python-gitlab/compare/1.6.0...1.7.0 .. _1.6.0: https://github.com/python-gitlab/python-gitlab/compare/1.5.1...1.6.0 .. _1.5.1: https://github.com/python-gitlab/python-gitlab/compare/1.5.0...1.5.1 diff --git a/README.rst b/README.rst index bed7f0eee..393398ef5 100644 --- a/README.rst +++ b/README.rst @@ -91,6 +91,9 @@ You can contribute to the project in multiple ways: * Add unit and functional tests * Everything else you can think of +We prefer commit messages to be formatted using the `conventional-changelog `_. +This leads to more readable messages that are easy to follow when looking through the project history. + Provide your patches as github pull requests. Thanks! Running unit tests diff --git a/RELEASE_NOTES.rst b/RELEASE_NOTES.rst index 1e53a883c..44e457a87 100644 --- a/RELEASE_NOTES.rst +++ b/RELEASE_NOTES.rst @@ -4,6 +4,27 @@ Release notes This page describes important changes between python-gitlab releases. +Changes from 1.7 to 1.8 +======================= + +* You can now use the ``query_parameters`` argument in method calls to define + arguments to send to the GitLab server. This allows to avoid conflicts + between python-gitlab and GitLab server variables, and allows to use the + python reserved keywords as GitLab arguments. + + The following examples make the same GitLab request with the 2 syntaxes:: + + projects = gl.projects.list(owned=True, starred=True) + projects = gl.projects.list(query_parameters={'owned': True, 'starred': True}) + + The following example only works with the new parameter:: + + activities = gl.user_activities.list( + query_parameters={'from': '2019-01-01'}, + all=True) + +* Additionally the ``all`` paremeter is not sent to the GitLab anymore. + Changes from 1.5 to 1.6 ======================= diff --git a/docs/api-usage.rst b/docs/api-usage.rst index 73d137732..8ab252c0d 100644 --- a/docs/api-usage.rst +++ b/docs/api-usage.rst @@ -118,6 +118,25 @@ Some objects also provide managers to access related GitLab resources: project = gl.projects.get(1) issues = project.issues.list() +python-gitlab allows to send any data to the GitLab server when making queries. +In case of invalid or missing arguments python-gitlab will raise an exception +with the GitLab server error message: + +.. code-block:: python + + >>> gl.projects.list(sort='invalid value') + ... + GitlabListError: 400: sort does not have a valid value + +You can use the ``query_parameters`` argument to send arguments that would +conflict with python or python-gitlab when using them as kwargs: + +.. code-block:: python + + gl.user_activities.list(from='2019-01-01') ## invalid + + gl.user_activities.list(query_parameters={'from': '2019-01-01'}) # OK + Gitlab Objects ============== @@ -299,7 +318,9 @@ Rate limits python-gitlab obeys the rate limit of the GitLab server by default. On receiving a 429 response (Too Many Requests), python-gitlab sleeps for the -amount of time in the Retry-After header that GitLab sends back. +amount of time in the Retry-After header that GitLab sends back. If GitLab +does not return a response with the Retry-After header, python-gitlab will +perform an exponential backoff. If you don't want to wait, you can disable the rate-limiting feature, by supplying the ``obey_rate_limit`` argument. @@ -312,6 +333,18 @@ supplying the ``obey_rate_limit`` argument. gl = gitlab.gitlab(url, token, api_version=4) gl.projects.list(all=True, obey_rate_limit=False) +If you do not disable the rate-limiting feature, you can supply a custom value +for ``max_retries``; by default, this is set to 10. To retry without bound when +throttled, you can set this parameter to -1. This parameter is ignored if +``obey_rate_limit`` is set to ``False``. + +.. code-block:: python + + import gitlab + import requests + + gl = gitlab.gitlab(url, token, api_version=4) + gl.projects.list(all=True, max_retries=12) .. warning:: diff --git a/docs/gl_objects/commits.rst b/docs/gl_objects/commits.rst index 662d9c399..9f48c9816 100644 --- a/docs/gl_objects/commits.rst +++ b/docs/gl_objects/commits.rst @@ -27,6 +27,14 @@ results:: commits = project.commits.list(ref_name='my_branch') commits = project.commits.list(since='2016-01-01T00:00:00Z') +.. note:: + + The available ``all`` listing argument conflicts with the python-gitlab + argument. Use ``query_parameters`` to avoid the conflict:: + + commits = project.commits.list(all=True, + query_parameters={'ref_name': 'my_branch'}) + Create a commit:: # See https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions diff --git a/docs/gl_objects/users.rst b/docs/gl_objects/users.rst index d86d2ed30..e66ef3a07 100644 --- a/docs/gl_objects/users.rst +++ b/docs/gl_objects/users.rst @@ -312,4 +312,6 @@ Examples Get the users activities:: - activities = gl.user_activities.list(all=True, as_list=False) + activities = gl.user_activities.list( + query_parameters={'from': '2018-07-01'}, + all=True, as_list=False) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 01f9426d7..18f9d162b 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -31,11 +31,11 @@ from gitlab import utils # noqa __title__ = 'python-gitlab' -__version__ = '1.7.0' +__version__ = '1.8.0' __author__ = 'Gauvain Pocentek' -__email__ = 'gauvain@pocentek.net' +__email__ = 'gauvainpocentek@gmail.com' __license__ = 'LGPL3' -__copyright__ = 'Copyright 2013-2018 Gauvain Pocentek' +__copyright__ = 'Copyright 2013-2019 Gauvain Pocentek' warnings.filterwarnings('default', category=DeprecationWarning, module='^gitlab') @@ -445,7 +445,20 @@ def http_request(self, verb, path, query_data={}, post_data=None, params = {} utils.copy_dict(params, query_data) - utils.copy_dict(params, kwargs) + + # Deal with kwargs: by default a user uses kwargs to send data to the + # gitlab server, but this generates problems (python keyword conflicts + # and python-gitlab/gitlab conflicts). + # So we provide a `query_parameters` key: if it's there we use its dict + # value as arguments for the gitlab server, and ignore the other + # arguments, except pagination ones (per_page and page) + if 'query_parameters' in kwargs: + utils.copy_dict(params, kwargs['query_parameters']) + for arg in ('per_page', 'page'): + if arg in kwargs: + params[arg] = kwargs[arg] + else: + utils.copy_dict(params, kwargs) opts = self._get_session_opts(content_type='application/json') @@ -477,6 +490,10 @@ def http_request(self, verb, path, query_data={}, post_data=None, # obey the rate limit by default obey_rate_limit = kwargs.get("obey_rate_limit", True) + # set max_retries to 10 by default, disable by setting it to -1 + max_retries = kwargs.get("max_retries", 10) + cur_retries = 0 + while True: result = self.session.send(prepped, timeout=timeout, **settings) @@ -486,9 +503,13 @@ def http_request(self, verb, path, query_data={}, post_data=None, return result if 429 == result.status_code and obey_rate_limit: - wait_time = int(result.headers["Retry-After"]) - time.sleep(wait_time) - continue + if max_retries == -1 or cur_retries < max_retries: + wait_time = 2 ** cur_retries * 0.1 + if "Retry-After" in result.headers: + wait_time = int(result.headers["Retry-After"]) + cur_retries += 1 + time.sleep(wait_time) + continue error_message = result.content try: @@ -509,7 +530,8 @@ def http_request(self, verb, path, query_data={}, post_data=None, error_message=error_message, response_body=result.content) - def http_get(self, path, query_data={}, streamed=False, **kwargs): + def http_get(self, path, query_data={}, streamed=False, raw=False, + **kwargs): """Make a GET request to the Gitlab server. Args: @@ -517,6 +539,7 @@ def http_get(self, path, query_data={}, streamed=False, **kwargs): 'http://whatever/v4/api/projecs') query_data (dict): Data to send as query parameters streamed (bool): Whether the data should be streamed + raw (bool): If True do not try to parse the output as json **kwargs: Extra options to send to the server (e.g. sudo) Returns: @@ -530,8 +553,10 @@ def http_get(self, path, query_data={}, streamed=False, **kwargs): """ result = self.http_request('get', path, query_data=query_data, streamed=streamed, **kwargs) - if (result.headers['Content-Type'] == 'application/json' and - not streamed): + + if (result.headers['Content-Type'] == 'application/json' + and not streamed + and not raw): try: return result.json() except Exception: @@ -565,7 +590,7 @@ def http_list(self, path, query_data={}, as_list=None, **kwargs): # 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) + get_all = kwargs.pop('all', False) url = self._build_url(path) if get_all is True: diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py index 0822d3e58..5b7b75c24 100644 --- a/gitlab/exceptions.py +++ b/gitlab/exceptions.py @@ -170,6 +170,10 @@ class GitlabMRForbiddenError(GitlabOperationError): pass +class GitlabMRApprovalError(GitlabOperationError): + pass + + class GitlabMRClosedError(GitlabOperationError): pass diff --git a/gitlab/mixins.py b/gitlab/mixins.py index 2c80f36db..ca68658de 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -532,7 +532,7 @@ def reset_time_estimate(self, **kwargs): 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()) + path = '%s/%s/reset_time_estimate' % (self.manager.path, self.get_id()) return self.manager.gitlab.http_post(path, **kwargs) @cli.register_custom_action(('ProjectIssue', 'ProjectMergeRequest'), diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index fd673b522..8348c76ca 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -1128,7 +1128,7 @@ def content(self, streamed=False, action=None, chunk_size=1024, **kwargs): """ path = '/snippets/%s/raw' % self.get_id() result = self.manager.gitlab.http_get(path, streamed=streamed, - **kwargs) + raw=True, **kwargs) return utils.response_content(result, streamed, action, chunk_size) @@ -1365,7 +1365,7 @@ def artifacts(self, streamed=False, action=None, chunk_size=1024, """ path = '%s/%s/artifacts' % (self.manager.path, self.get_id()) result = self.manager.gitlab.http_get(path, streamed=streamed, - **kwargs) + raw=True, **kwargs) return utils.response_content(result, streamed, action, chunk_size) @cli.register_custom_action('ProjectJob') @@ -1393,7 +1393,7 @@ def artifact(self, path, streamed=False, action=None, chunk_size=1024, """ path = '%s/%s/artifacts/%s' % (self.manager.path, self.get_id(), path) result = self.manager.gitlab.http_get(path, streamed=streamed, - **kwargs) + raw=True, **kwargs) return utils.response_content(result, streamed, action, chunk_size) @cli.register_custom_action('ProjectJob') @@ -1419,7 +1419,7 @@ def trace(self, streamed=False, action=None, chunk_size=1024, **kwargs): """ path = '%s/%s/trace' % (self.manager.path, self.get_id()) result = self.manager.gitlab.http_get(path, streamed=streamed, - **kwargs) + raw=True, **kwargs) return utils.response_content(result, streamed, action, chunk_size) @@ -2292,9 +2292,51 @@ def pipelines(self, **kwargs): Returns: RESTObjectList: List of changes """ + path = '%s/%s/pipelines' % (self.manager.path, self.get_id()) return self.manager.gitlab.http_get(path, **kwargs) + @cli.register_custom_action('ProjectMergeRequest', tuple(), ('sha')) + @exc.on_http_error(exc.GitlabMRApprovalError) + def approve(self, sha=None, **kwargs): + """Approve the merge request. + + Args: + sha (str): Head SHA of MR + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabMRApprovalError: If the approval failed + """ + path = '%s/%s/approve' % (self.manager.path, self.get_id()) + data = {} + if sha: + data['sha'] = sha + + server_data = self.manager.gitlab.http_post(path, post_data=data, + **kwargs) + self._update_attrs(server_data) + + @cli.register_custom_action('ProjectMergeRequest') + @exc.on_http_error(exc.GitlabMRApprovalError) + def unapprove(self, **kwargs): + """Unapprove the merge request. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabMRApprovalError: If the unapproval failed + """ + path = '%s/%s/unapprove' % (self.manager.path, self.get_id()) + data = {} + + server_data = self.manager.gitlab.http_post(path, post_data=data, + **kwargs) + self._update_attrs(server_data) + @cli.register_custom_action('ProjectMergeRequest', tuple(), ('merge_commit_message', 'should_remove_source_branch', @@ -2654,7 +2696,7 @@ def raw(self, file_path, ref, streamed=False, action=None, chunk_size=1024, 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) + streamed=streamed, raw=True, **kwargs) return utils.response_content(result, streamed, action, chunk_size) @@ -2897,7 +2939,7 @@ def content(self, streamed=False, action=None, chunk_size=1024, **kwargs): """ path = "%s/%s/raw" % (self.manager.path, self.get_id()) result = self.manager.gitlab.http_get(path, streamed=streamed, - **kwargs) + raw=True, **kwargs) return utils.response_content(result, streamed, action, chunk_size) @@ -3174,7 +3216,7 @@ def download(self, streamed=False, action=None, chunk_size=1024, **kwargs): """ path = '/projects/%d/export/download' % self.project_id result = self.manager.gitlab.http_get(path, streamed=streamed, - **kwargs) + raw=True, **kwargs) return utils.response_content(result, streamed, action, chunk_size) @@ -3315,7 +3357,7 @@ def repository_raw_blob(self, sha, streamed=False, action=None, """ path = '/projects/%s/repository/blobs/%s/raw' % (self.get_id(), sha) result = self.manager.gitlab.http_get(path, streamed=streamed, - **kwargs) + raw=True, **kwargs) return utils.response_content(result, streamed, action, chunk_size) @cli.register_custom_action('Project', ('from_', 'to')) @@ -3391,7 +3433,8 @@ def repository_archive(self, sha=None, streamed=False, action=None, if sha: query_data['sha'] = sha result = self.manager.gitlab.http_get(path, query_data=query_data, - streamed=streamed, **kwargs) + raw=True, streamed=streamed, + **kwargs) return utils.response_content(result, streamed, action, chunk_size) @cli.register_custom_action('Project', ('forked_from_id', )) @@ -3674,7 +3717,7 @@ def snapshot(self, wiki=False, streamed=False, action=None, """ path = '/projects/%d/snapshot' % self.get_id() result = self.manager.gitlab.http_get(path, streamed=streamed, - **kwargs) + raw=True, **kwargs) return utils.response_content(result, streamed, action, chunk_size) @cli.register_custom_action('Project', ('scope', 'search')) diff --git a/setup.py b/setup.py index 02773ebb1..b592e7c0f 100644 --- a/setup.py +++ b/setup.py @@ -11,11 +11,13 @@ def get_version(): if line.startswith('__version__'): return eval(line.split('=')[-1]) +with open("README.rst", "r") as readme_file: + readme = readme_file.read() setup(name='python-gitlab', version=get_version(), description='Interact with GitLab API', - long_description='Interact with GitLab API', + long_description=readme, author='Gauvain Pocentek', author_email='gauvain@pocentek.net', license='LGPLv3', diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py index 30e4456dc..958e35081 100644 --- a/tools/python_test_v4.py +++ b/tools/python_test_v4.py @@ -773,7 +773,7 @@ assert(len(snippets) == 0) # user activities -gl.user_activities.list() +gl.user_activities.list(query_parameters={'from': '2019-01-01'}) # events gl.events.list() @@ -798,7 +798,7 @@ except gitlab.GitlabCreateError as e: error_message = e.error_message break -assert 'Retry later' in error_message.decode() +assert 'Retry later' in error_message [current_project.delete() for current_project in projects] settings.throttle_authenticated_api_enabled = False settings.save()