summaryrefslogtreecommitdiff
path: root/gitlab
diff options
context:
space:
mode:
Diffstat (limited to 'gitlab')
-rw-r--r--gitlab/__init__.py98
-rw-r--r--gitlab/cli.py17
-rw-r--r--gitlab/config.py17
-rw-r--r--gitlab/const.py3
-rw-r--r--gitlab/exceptions.py11
-rw-r--r--gitlab/mixins.py2
-rw-r--r--gitlab/tests/test_config.py20
-rw-r--r--gitlab/utils.py20
-rw-r--r--gitlab/v4/cli.py50
-rw-r--r--gitlab/v4/objects.py219
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,