diff options
Diffstat (limited to 'gitlab')
-rw-r--r-- | gitlab/__init__.py | 98 | ||||
-rw-r--r-- | gitlab/cli.py | 17 | ||||
-rw-r--r-- | gitlab/config.py | 17 | ||||
-rw-r--r-- | gitlab/const.py | 3 | ||||
-rw-r--r-- | gitlab/exceptions.py | 11 | ||||
-rw-r--r-- | gitlab/mixins.py | 2 | ||||
-rw-r--r-- | gitlab/tests/test_config.py | 20 | ||||
-rw-r--r-- | gitlab/utils.py | 20 | ||||
-rw-r--r-- | gitlab/v4/cli.py | 50 | ||||
-rw-r--r-- | gitlab/v4/objects.py | 219 |
10 files changed, 386 insertions, 71 deletions
diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 1c13093..0e6e52f 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -28,9 +28,10 @@ import six import gitlab.config from gitlab.const import * # noqa from gitlab.exceptions import * # noqa +from gitlab import utils # noqa __title__ = 'python-gitlab' -__version__ = '1.5.1' +__version__ = '1.7.0' __author__ = 'Gauvain Pocentek' __email__ = 'gauvain@pocentek.net' __license__ = 'LGPL3' @@ -39,6 +40,9 @@ __copyright__ = 'Copyright 2013-2018 Gauvain Pocentek' warnings.filterwarnings('default', category=DeprecationWarning, module='^gitlab') +REDIRECT_MSG = ('python-gitlab detected an http to https redirection. You ' + 'must update your GitLab URL to use https:// to avoid issues.') + def _sanitize(value): if isinstance(value, dict): @@ -114,6 +118,7 @@ class Gitlab(object): self.ldapgroups = objects.LDAPGroupManager(self) self.licenses = objects.LicenseManager(self) self.namespaces = objects.NamespaceManager(self) + self.mergerequests = objects.MergeRequestManager(self) self.notificationsettings = objects.NotificationSettingsManager(self) self.projects = objects.ProjectManager(self) self.runners = objects.RunnerManager(self) @@ -393,6 +398,26 @@ class Gitlab(object): else: return '%s%s' % (self._url, path) + def _check_redirects(self, result): + # Check the requests history to detect http to https redirections. + # If the initial verb is POST, the next request will use a GET request, + # leading to an unwanted behaviour. + # If the initial verb is PUT, the data will not be send with the next + # request. + # If we detect a redirection to https with a POST or a PUT request, we + # raise an exception with a useful error message. + if result.history and self._base_url.startswith('http:'): + for item in result.history: + if item.status_code not in (301, 302): + continue + # GET methods can be redirected without issue + if item.request.method == 'GET': + continue + # Did we end-up with an https:// URL? + location = item.headers.get('Location', None) + if location and location.startswith('https://'): + raise RedirectError(REDIRECT_MSG) + def http_request(self, verb, path, query_data={}, post_data=None, streamed=False, files=None, **kwargs): """Make an HTTP request to the Gitlab server. @@ -416,27 +441,24 @@ class Gitlab(object): 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) - def copy_dict(dest, src): - for k, v in src.items(): - if isinstance(v, dict): - # Transform dict values in new attributes. For example: - # custom_attributes: {'foo', 'bar'} => - # custom_attributes['foo']: 'bar' - for dict_k, dict_v in v.items(): - dest['%s[%s]' % (k, dict_k)] = dict_v - else: - dest[k] = v - params = {} - copy_dict(params, query_data) - copy_dict(params, kwargs) + utils.copy_dict(params, query_data) + + # 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') @@ -461,28 +483,42 @@ class Gitlab(object): req = requests.Request(verb, url, json=json, data=data, params=params, files=files, **opts) prepped = self.session.prepare_request(req) - prepped.url = sanitized_url(prepped.url) + prepped.url = utils.sanitized_url(prepped.url) settings = self.session.merge_environment_settings( prepped.url, {}, streamed, verify, 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) + self._check_redirects(result) + if 200 <= result.status_code < 300: 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: - error_message = result.json()['message'] + error_json = result.json() + for k in ('message', 'error'): + if k in error_json: + error_message = error_json[k] except (KeyError, ValueError, TypeError): - error_message = result.content + pass if result.status_code == 401: raise GitlabAuthenticationError( @@ -494,7 +530,8 @@ class Gitlab(object): 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: @@ -502,6 +539,7 @@ class Gitlab(object): '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: @@ -515,8 +553,10 @@ class Gitlab(object): """ 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: diff --git a/gitlab/cli.py b/gitlab/cli.py index 4870192..17917f5 100644 --- a/gitlab/cli.py +++ b/gitlab/cli.py @@ -17,6 +17,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from __future__ import print_function + import argparse import functools import importlib @@ -98,7 +99,7 @@ def _get_base_parser(add_help=True): "will be used."), required=False) parser.add_argument("-o", "--output", - help=("Output format (v4 only): json|legacy|yaml"), + help="Output format (v4 only): json|legacy|yaml", required=False, choices=['json', 'legacy', 'yaml'], default="legacy") @@ -135,13 +136,21 @@ def main(): exit(0) parser = _get_base_parser(add_help=False) + if "--help" in sys.argv or "-h" in sys.argv: + parser.print_help() + exit(0) + # This first parsing step is used to find the gitlab config to use, and # load the propermodule (v3 or v4) accordingly. At that point we don't have # any subparser setup (options, args) = parser.parse_known_args(sys.argv) - - config = gitlab.config.GitlabConfigParser(options.gitlab, - options.config_file) + try: + config = gitlab.config.GitlabConfigParser( + options.gitlab, + options.config_file + ) + except gitlab.config.ConfigError as e: + sys.exit(e) cli_module = importlib.import_module('gitlab.v%s.cli' % config.api_version) # Now we build the entire set of subcommands and do the complete parsing diff --git a/gitlab/config.py b/gitlab/config.py index 9f4c11d..1c76594 100644 --- a/gitlab/config.py +++ b/gitlab/config.py @@ -37,10 +37,27 @@ class GitlabDataError(ConfigError): pass +class GitlabConfigMissingError(ConfigError): + pass + + class GitlabConfigParser(object): def __init__(self, gitlab_id=None, config_files=None): self.gitlab_id = gitlab_id _files = config_files or _DEFAULT_FILES + file_exist = False + for file in _files: + if os.path.exists(file): + file_exist = True + if not file_exist: + raise GitlabConfigMissingError( + "Config file not found. \nPlease create one in " + "one of the following locations: {} \nor " + "specify a config file using the '-c' parameter.".format( + ", ".join(_DEFAULT_FILES) + ) + ) + self._config = configparser.ConfigParser() self._config.read(_files) diff --git a/gitlab/const.py b/gitlab/const.py index e4766d5..62f2403 100644 --- a/gitlab/const.py +++ b/gitlab/const.py @@ -18,7 +18,8 @@ GUEST_ACCESS = 10 REPORTER_ACCESS = 20 DEVELOPER_ACCESS = 30 -MASTER_ACCESS = 40 +MAINTAINER_ACCESS = 40 +MASTER_ACCESS = MAINTAINER_ACCESS OWNER_ACCESS = 50 VISIBILITY_PRIVATE = 0 diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py index ddaef31..5b7b75c 100644 --- a/gitlab/exceptions.py +++ b/gitlab/exceptions.py @@ -28,7 +28,12 @@ class GitlabError(Exception): # Full http response self.response_body = response_body # Parsed error message from gitlab - self.error_message = error_message + try: + # if we receive str/bytes we try to convert to unicode/str to have + # consistent message types (see #616) + self.error_message = error_message.decode() + except Exception: + self.error_message = error_message def __str__(self): if self.response_code is not None: @@ -41,6 +46,10 @@ class GitlabAuthenticationError(GitlabError): pass +class RedirectError(GitlabError): + pass + + class GitlabParsingError(GitlabError): pass diff --git a/gitlab/mixins.py b/gitlab/mixins.py index 2c80f36..ca68658 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -532,7 +532,7 @@ class TimeTrackingMixin(object): 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/tests/test_config.py b/gitlab/tests/test_config.py index 0b585e8..d1e668e 100644 --- a/gitlab/tests/test_config.py +++ b/gitlab/tests/test_config.py @@ -76,11 +76,20 @@ per_page = 200 class TestConfigParser(unittest.TestCase): + @mock.patch('os.path.exists') + def test_missing_config(self, path_exists): + path_exists.return_value = False + with self.assertRaises(config.GitlabConfigMissingError): + config.GitlabConfigParser('test') + + @mock.patch('os.path.exists') @mock.patch('six.moves.builtins.open') - def test_invalid_id(self, m_open): + def test_invalid_id(self, m_open, path_exists): fd = six.StringIO(no_default_config) fd.close = mock.Mock(return_value=None) m_open.return_value = fd + path_exists.return_value = True + config.GitlabConfigParser('there') self.assertRaises(config.GitlabIDError, config.GitlabConfigParser) fd = six.StringIO(valid_config) @@ -90,12 +99,15 @@ class TestConfigParser(unittest.TestCase): config.GitlabConfigParser, gitlab_id='not_there') + @mock.patch('os.path.exists') @mock.patch('six.moves.builtins.open') - def test_invalid_data(self, m_open): + def test_invalid_data(self, m_open, path_exists): fd = six.StringIO(missing_attr_config) fd.close = mock.Mock(return_value=None, side_effect=lambda: fd.seek(0)) m_open.return_value = fd + path_exists.return_value = True + config.GitlabConfigParser('one') config.GitlabConfigParser('one') self.assertRaises(config.GitlabDataError, config.GitlabConfigParser, @@ -107,11 +119,13 @@ class TestConfigParser(unittest.TestCase): self.assertEqual('Unsupported per_page number: 200', emgr.exception.args[0]) + @mock.patch('os.path.exists') @mock.patch('six.moves.builtins.open') - def test_valid_data(self, m_open): + def test_valid_data(self, m_open, path_exists): fd = six.StringIO(valid_config) fd.close = mock.Mock(return_value=None) m_open.return_value = fd + path_exists.return_value = True cp = config.GitlabConfigParser() self.assertEqual("one", cp.gitlab_id) diff --git a/gitlab/utils.py b/gitlab/utils.py index a449f81..49e2c88 100644 --- a/gitlab/utils.py +++ b/gitlab/utils.py @@ -15,6 +15,8 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +import six + class _StdoutStream(object): def __call__(self, chunk): @@ -31,3 +33,21 @@ def response_content(response, streamed, action, chunk_size): for chunk in response.iter_content(chunk_size=chunk_size): if chunk: action(chunk) + + +def copy_dict(dest, src): + for k, v in src.items(): + if isinstance(v, dict): + # Transform dict values to new attributes. For example: + # custom_attributes: {'foo', 'bar'} => + # "custom_attributes['foo']": "bar" + for dict_k, dict_v in v.items(): + dest['%s[%s]' % (k, dict_k)] = dict_v + else: + dest[k] = v + + +def sanitized_url(url): + parsed = six.moves.urllib.parse.urlparse(url) + new_path = parsed.path.replace('.', '%2E') + return parsed._replace(path=new_path).geturl() diff --git a/gitlab/v4/cli.py b/gitlab/v4/cli.py index 880b07d..242874d 100644 --- a/gitlab/v4/cli.py +++ b/gitlab/v4/cli.py @@ -19,6 +19,7 @@ from __future__ import print_function import inspect import operator +import sys import six @@ -54,11 +55,18 @@ class GitlabCLI(object): self.args[attr_name] = obj.get() def __call__(self): + # Check for a method that matches object + action + method = 'do_%s_%s' % (self.what, self.action) + if hasattr(self, method): + return getattr(self, method)() + + # Fallback to standard actions (get, list, create, ...) method = 'do_%s' % self.action if hasattr(self, method): return getattr(self, method)() - else: - return self.do_custom() + + # Finally try to find custom methods + return self.do_custom() def do_custom(self): in_obj = cli.custom_actions[self.cls_name][self.action][2] @@ -77,6 +85,20 @@ class GitlabCLI(object): else: return getattr(self.mgr, self.action)(**self.args) + def do_project_export_download(self): + try: + project = self.gl.projects.get(int(self.args['project_id']), + lazy=True) + data = project.exports.get().download() + if hasattr(sys.stdout, 'buffer'): + # python3 + sys.stdout.buffer.write(data) + else: + sys.stdout.write(data) + + except Exception as e: + cli.die("Impossible to download the export", e) + def do_create(self): try: return self.mgr.create(self.args) @@ -280,14 +302,24 @@ class JSONPrinter(object): class YAMLPrinter(object): def display(self, d, **kwargs): - import yaml # noqa - print(yaml.safe_dump(d, default_flow_style=False)) + try: + import yaml # noqa + print(yaml.safe_dump(d, default_flow_style=False)) + except ImportError: + exit("PyYaml is not installed.\n" + "Install it with `pip install PyYaml` " + "to use the yaml output feature") def display_list(self, data, fields, **kwargs): - import yaml # noqa - print(yaml.safe_dump( - [get_dict(obj, fields) for obj in data], - default_flow_style=False)) + try: + import yaml # noqa + print(yaml.safe_dump( + [get_dict(obj, fields) for obj in data], + default_flow_style=False)) + except ImportError: + exit("PyYaml is not installed.\n" + "Install it with `pip install PyYaml` " + "to use the yaml output feature") class LegacyPrinter(object): @@ -366,3 +398,5 @@ def run(gl, what, action, args, verbose, output, fields): printer.display(get_dict(data, fields), verbose=verbose, obj=data) elif isinstance(data, six.string_types): print(data) + elif hasattr(data, 'decode'): + print(data.decode()) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index fdd02ae..af61488 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -662,9 +662,22 @@ class GroupEpicIssueManager(ListMixin, CreateMixin, UpdateMixin, DeleteMixin, return self._obj_cls(self, server_data) +class GroupEpicResourceLabelEvent(RESTObject): + pass + + +class GroupEpicResourceLabelEventManager(RetrieveMixin, RESTManager): + _path = ('/groups/%(group_id)s/epics/%(epic_id)s/resource_label_events') + _obj_cls = GroupEpicResourceLabelEvent + _from_parent_attrs = {'group_id': 'group_id', 'epic_id': 'id'} + + class GroupEpic(ObjectDeleteMixin, SaveMixin, RESTObject): _id_attr = 'iid' - _managers = (('issues', 'GroupEpicIssueManager'),) + _managers = ( + ('issues', 'GroupEpicIssueManager'), + ('resourcelabelevents', 'GroupEpicResourceLabelEventManager'), + ) class GroupEpicManager(CRUDMixin, RESTManager): @@ -705,13 +718,45 @@ class GroupMemberManager(CRUDMixin, RESTManager): _create_attrs = (('access_level', 'user_id'), ('expires_at', )) _update_attrs = (('access_level', ), ('expires_at', )) + @cli.register_custom_action('GroupMemberManager') + @exc.on_http_error(exc.GitlabListError) + def all(self, **kwargs): + """List all the members, included inherited ones. + + Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): If set to False and no pagination option is + defined, return a generator instead of a list + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the list could not be retrieved + + Returns: + RESTObjectList: The list of members + """ + + path = '%s/all' % self.path + return self.gitlab.http_list(path, **kwargs) + class GroupMergeRequest(RESTObject): pass -class GroupMergeRequestManager(RESTManager): - pass +class GroupMergeRequestManager(ListMixin, RESTManager): + _path = '/groups/%(group_id)s/merge_requests' + _obj_cls = GroupMergeRequest + _from_parent_attrs = {'group_id': 'id'} + _list_filters = ('state', 'order_by', 'sort', 'milestone', 'view', + 'labels', 'created_after', 'created_before', + 'updated_after', 'updated_before', 'scope', 'author_id', + 'assignee_id', 'my_reaction_emoji', 'source_branch', + 'target_branch', 'search') + _types = {'labels': types.ListAttribute} class GroupMilestone(SaveMixin, ObjectDeleteMixin, RESTObject): @@ -842,6 +887,7 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject): ('epics', 'GroupEpicManager'), ('issues', 'GroupIssueManager'), ('members', 'GroupMemberManager'), + ('mergerequests', 'GroupMergeRequestManager'), ('milestones', 'GroupMilestoneManager'), ('notificationsettings', 'GroupNotificationSettingsManager'), ('projects', 'GroupProjectManager'), @@ -1040,6 +1086,22 @@ class LicenseManager(RetrieveMixin, RESTManager): _optional_get_attrs = ('project', 'fullname') +class MergeRequest(RESTObject): + pass + + +class MergeRequestManager(ListMixin, RESTManager): + _path = '/merge_requests' + _obj_cls = MergeRequest + _from_parent_attrs = {'group_id': 'id'} + _list_filters = ('state', 'order_by', 'sort', 'milestone', 'view', + 'labels', 'created_after', 'created_before', + 'updated_after', 'updated_before', 'scope', 'author_id', + 'assignee_id', 'my_reaction_emoji', 'source_branch', + 'target_branch', 'search') + _types = {'labels': types.ListAttribute} + + class Snippet(UserAgentDetailMixin, SaveMixin, ObjectDeleteMixin, RESTObject): _short_print_attr = 'title' @@ -1066,7 +1128,7 @@ class Snippet(UserAgentDetailMixin, SaveMixin, ObjectDeleteMixin, RESTObject): """ 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) @@ -1303,7 +1365,7 @@ class ProjectJob(RESTObject, RefreshMixin): """ 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') @@ -1331,7 +1393,7 @@ class ProjectJob(RESTObject, RefreshMixin): """ 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') @@ -1357,7 +1419,7 @@ class ProjectJob(RESTObject, RefreshMixin): """ 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) @@ -1620,7 +1682,7 @@ class ProjectFork(RESTObject): pass -class ProjectForkManager(CreateMixin, RESTManager): +class ProjectForkManager(CreateMixin, ListMixin, RESTManager): _path = '/projects/%(project_id)s/fork' _obj_cls = ProjectFork _from_parent_attrs = {'project_id': 'id'} @@ -1630,6 +1692,28 @@ class ProjectForkManager(CreateMixin, RESTManager): 'with_merge_requests_enabled') _create_attrs = (tuple(), ('namespace', )) + 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 server (e.g. sudo) + + Returns: + list: The list of objects, or a generator if `as_list` is False + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the server cannot perform the request + """ + + path = self._compute_path('/projects/%(project_id)s/forks') + return ListMixin.list(self, path=path, **kwargs) + class ProjectHook(SaveMixin, ObjectDeleteMixin, RESTObject): _short_print_attr = 'url' @@ -1756,6 +1840,17 @@ class ProjectIssueLinkManager(ListMixin, CreateMixin, DeleteMixin, return source_issue, target_issue +class ProjectIssueResourceLabelEvent(RESTObject): + pass + + +class ProjectIssueResourceLabelEventManager(RetrieveMixin, RESTManager): + _path = ('/projects/%(project_id)s/issues/%(issue_iid)s' + '/resource_label_events') + _obj_cls = ProjectIssueResourceLabelEvent + _from_parent_attrs = {'project_id': 'project_id', 'issue_iid': 'iid'} + + class ProjectIssue(UserAgentDetailMixin, SubscribableMixin, TodoMixin, TimeTrackingMixin, ParticipantsMixin, SaveMixin, ObjectDeleteMixin, RESTObject): @@ -1766,6 +1861,7 @@ class ProjectIssue(UserAgentDetailMixin, SubscribableMixin, TodoMixin, ('discussions', 'ProjectIssueDiscussionManager'), ('links', 'ProjectIssueLinkManager'), ('notes', 'ProjectIssueNoteManager'), + ('resourcelabelevents', 'ProjectIssueResourceLabelEventManager'), ) @cli.register_custom_action('ProjectIssue', ('to_project_id',)) @@ -1815,8 +1911,8 @@ class ProjectIssueManager(CRUDMixin, RESTManager): 'order_by', 'sort', 'search', 'created_after', 'created_before', 'updated_after', 'updated_before') _create_attrs = (('title', ), - ('description', 'confidential', 'assignee_id', - 'assignee_idss' 'milestone_id', 'labels', 'created_at', + ('description', 'confidential', 'assignee_ids', + 'assignee_id', 'milestone_id', 'labels', 'created_at', 'due_date', 'merge_request_to_resolve_discussions_of', 'discussion_to_resolve')) _update_attrs = (tuple(), ('title', 'description', 'confidential', @@ -1837,6 +1933,30 @@ class ProjectMemberManager(CRUDMixin, RESTManager): _create_attrs = (('access_level', 'user_id'), ('expires_at', )) _update_attrs = (('access_level', ), ('expires_at', )) + @cli.register_custom_action('ProjectMemberManager') + @exc.on_http_error(exc.GitlabListError) + def all(self, **kwargs): + """List all the members, included inherited ones. + + Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): If set to False and no pagination option is + defined, return a generator instead of a list + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the list could not be retrieved + + Returns: + RESTObjectList: The list of members + """ + + path = '%s/all' % self.path + return self.gitlab.http_list(path, **kwargs) + class ProjectNote(RESTObject): pass @@ -1918,6 +2038,18 @@ class ProjectTagManager(NoUpdateMixin, RESTManager): _create_attrs = (('tag_name', 'ref'), ('message',)) +class ProjectProtectedTag(ObjectDeleteMixin, RESTObject): + _id_attr = 'name' + _short_print_attr = 'name' + + +class ProjectProtectedTagManager(NoUpdateMixin, RESTManager): + _path = '/projects/%(project_id)s/protected_tags' + _obj_cls = ProjectProtectedTag + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('name',), ('create_access_level',)) + + class ProjectMergeRequestApproval(SaveMixin, RESTObject): _id_attr = None @@ -2027,6 +2159,17 @@ class ProjectMergeRequestDiscussionManager(RetrieveMixin, CreateMixin, _update_attrs = (('resolved',), tuple()) +class ProjectMergeRequestResourceLabelEvent(RESTObject): + pass + + +class ProjectMergeRequestResourceLabelEventManager(RetrieveMixin, RESTManager): + _path = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s' + '/resource_label_events') + _obj_cls = ProjectMergeRequestResourceLabelEvent + _from_parent_attrs = {'project_id': 'project_id', 'mr_iid': 'iid'} + + class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, ParticipantsMixin, SaveMixin, ObjectDeleteMixin, RESTObject): @@ -2038,6 +2181,8 @@ class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, ('diffs', 'ProjectMergeRequestDiffManager'), ('discussions', 'ProjectMergeRequestDiscussionManager'), ('notes', 'ProjectMergeRequestNoteManager'), + ('resourcelabelevents', + 'ProjectMergeRequestResourceLabelEventManager'), ) @cli.register_custom_action('ProjectMergeRequest') @@ -2217,13 +2362,14 @@ class ProjectMergeRequestManager(CRUDMixin, RESTManager): _create_attrs = ( ('source_branch', 'target_branch', 'title'), ('assignee_id', 'description', 'target_project_id', 'labels', - 'milestone_id', 'remove_source_branch', 'allow_maintainer_to_push') + 'milestone_id', 'remove_source_branch', 'allow_maintainer_to_push', + 'squash') ) - _update_attrs = (tuple(), - ('target_branch', 'assignee_id', 'title', 'description', - 'state_event', 'labels', 'milestone_id', - 'remove_source_branch', 'discussion_locked', - 'allow_maintainer_to_push')) + _update_attrs = ( + tuple(), + ('target_branch', 'assignee_id', 'title', 'description', 'state_event', + 'labels', 'milestone_id', 'remove_source_branch', 'discussion_locked', + 'allow_maintainer_to_push', 'squash')) _list_filters = ('state', 'order_by', 'sort', 'milestone', 'view', 'labels', 'created_after', 'created_before', 'updated_after', 'updated_before', 'scope', 'author_id', @@ -2531,7 +2677,7 @@ class ProjectFileManager(GetMixin, CreateMixin, UpdateMixin, DeleteMixin, 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) @@ -2774,7 +2920,7 @@ class ProjectSnippet(UserAgentDetailMixin, SaveMixin, ObjectDeleteMixin, """ 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) @@ -2994,7 +3140,10 @@ class ProjectProtectedBranchManager(NoUpdateMixin, RESTManager): _path = '/projects/%(project_id)s/protected_branches' _obj_cls = ProjectProtectedBranch _from_parent_attrs = {'project_id': 'id'} - _create_attrs = (('name', ), ('push_access_level', 'merge_access_level')) + _create_attrs = (('name', ), + ('push_access_level', 'merge_access_level', + 'unprotect_access_level', 'allowed_to_push', + 'allowed_to_merge', 'allowed_to_unprotect')) class ProjectRunner(ObjectDeleteMixin, RESTObject): @@ -3048,7 +3197,7 @@ class ProjectExport(RefreshMixin, RESTObject): """ 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) @@ -3099,6 +3248,7 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): ('pagesdomains', 'ProjectPagesDomainManager'), ('pipelines', 'ProjectPipelineManager'), ('protectedbranches', 'ProjectProtectedBranchManager'), + ('protectedtags', 'ProjectProtectedTagManager'), ('pipelineschedules', 'ProjectPipelineScheduleManager'), ('pushrules', 'ProjectPushRulesManager'), ('runners', 'ProjectRunnerManager'), @@ -3188,7 +3338,7 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): """ 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')) @@ -3264,7 +3414,8 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): 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', )) @@ -3547,7 +3698,7 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): """ 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')) @@ -3586,6 +3737,25 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): path = '/projects/%d/mirror/pull' % self.get_id() self.manager.gitlab.http_post(path, **kwargs) + @cli.register_custom_action('Project', ('to_namespace', )) + @exc.on_http_error(exc.GitlabTransferProjectError) + def transfer_project(self, to_namespace, **kwargs): + """Transfer a project to the given namespace ID + + Args: + to_namespace (str): ID or path of the namespace to transfer the + project to + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabTransferProjectError: If the project could not be transfered + """ + path = '/projects/%d/transfer' % (self.id,) + self.manager.gitlab.http_put(path, + post_data={"namespace": to_namespace}, + **kwargs) + class ProjectManager(CRUDMixin, RESTManager): _path = '/projects' @@ -3646,7 +3816,8 @@ class ProjectManager(CRUDMixin, RESTManager): 'overwrite': overwrite } if override_params: - data['override_params'] = override_params + for k, v in override_params.items(): + data['override_params[%s]' % k] = v if namespace: data['namespace'] = namespace return self.gitlab.http_post('/projects/import', post_data=data, |