diff --git a/AUTHORS b/AUTHORS index 9a00c26cd..dc45ad59b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -27,3 +27,5 @@ Richard Hansen James (d0c_s4vage) Johnson Mikhail Lopotkov Asher256 +Adam Reid +Guyzmo diff --git a/ChangeLog b/ChangeLog index ac4b4778f..392a081d6 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,29 @@ +Version 0.13 + + * Add support for MergeRequest validation + * MR: add support for cancel_merge_when_build_succeeds + * MR: add support for closes_issues + * Add "external" parameter for users + * Add deletion support for issues and MR + * Add missing group creation parameters + * Add a Session instance for all HTTP requests + * Enable updates on ProjectIssueNotes + * Add support for Project raw_blob + * Implement project compare + * Implement project contributors + * Drop the next_url attribute when listing + * Remove unnecessary canUpdate property from ProjectIssuesNote + * Add new optional attributes for projects + * Enable deprecation warnings for gitlab only + * Rework merge requests update + * Rework the Gitlab.delete method + * ProjectFile: file_path is required for deletion + * Rename some methods to better match the API URLs + * Deprecate the file_* methods in favor of the files manager + * Implement star/unstar for projects + * Implement list/get licenses + * Manage optional parameters for list() and get() + Version 0.12.2 * Add new `ProjectHook` attributes diff --git a/docs/ext/docstrings.py b/docs/ext/docstrings.py index 4724fc575..4520e437e 100644 --- a/docs/ext/docstrings.py +++ b/docs/ext/docstrings.py @@ -30,17 +30,26 @@ def _process_docstring(app, what, name, obj, options, lines): class GitlabDocstring(GoogleDocstring): def _build_doc(self): cls = self._obj.obj_cls + opt_get_list = cls.optionalGetAttrs + opt_list_list = cls.optionalListAttrs md_create_list = list(itertools.chain(cls.requiredUrlAttrs, cls.requiredCreateAttrs)) opt_create_list = cls.optionalCreateAttrs + opt_get_keys = "None" + if opt_get_list: + opt_get_keys = ", ".join(['``%s``' % i for i in opt_get_list]) + + opt_list_keys = "None" + if opt_list_list: + opt_list_keys = ", ".join(['``%s``' % i for i in opt_list_list]) + md_create_keys = opt_create_keys = "None" if md_create_list: - md_create_keys = "%s" % ", ".join(['``%s``' % i for i in - md_create_list]) + md_create_keys = ", ".join(['``%s``' % i for i in md_create_list]) if opt_create_list: - opt_create_keys = "%s" % ", ".join(['``%s``' % i for i in - opt_create_list]) + opt_create_keys = ", ".join(['``%s``' % i for i in + opt_create_list]) md_update_list = list(itertools.chain(cls.requiredUrlAttrs, cls.requiredUpdateAttrs)) @@ -48,11 +57,10 @@ def _build_doc(self): md_update_keys = opt_update_keys = "None" if md_update_list: - md_update_keys = "%s" % ", ".join(['``%s``' % i for i in - md_update_list]) + md_update_keys = ", ".join(['``%s``' % i for i in md_update_list]) if opt_update_list: - opt_update_keys = "%s" % ", ".join(['``%s``' % i for i in - opt_update_list]) + opt_update_keys = ", ".join(['``%s``' % i for i in + opt_update_list]) tmpl_file = os.path.join(os.path.dirname(__file__), 'template.j2') with open(tmpl_file) as fd: @@ -62,7 +70,9 @@ def _build_doc(self): md_create_keys=md_create_keys, opt_create_keys=opt_create_keys, md_update_keys=md_update_keys, - opt_update_keys=opt_update_keys) + opt_update_keys=opt_update_keys, + opt_get_keys=opt_get_keys, + opt_list_keys=opt_list_keys) return output.split('\n') diff --git a/docs/ext/template.j2 b/docs/ext/template.j2 index 980a7ed70..29f4a0091 100644 --- a/docs/ext/template.j2 +++ b/docs/ext/template.j2 @@ -19,3 +19,11 @@ Mandatory arguments for object update: {{ md_create_keys }} Optional arguments for object update: {{ opt_create_keys }} {% endif %} + +{% if cls.canList %} +Optional arguments for object listing: {{ opt_list_keys }} +{% endif %} + +{% if cls.canGet %} +Optional arguments for object listing: {{ opt_get_keys }} +{% endif %} diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 6c7519537..05e6075fa 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -32,13 +32,14 @@ from gitlab.objects import * # noqa __title__ = 'python-gitlab' -__version__ = '0.12.2' +__version__ = '0.13' __author__ = 'Gauvain Pocentek' __email__ = 'gauvain@pocentek.net' __license__ = 'LGPL3' __copyright__ = 'Copyright 2013-2016 Gauvain Pocentek' -warnings.simplefilter('always', DeprecationWarning) +warnings.filterwarnings('default', category=DeprecationWarning, + module='^gitlab') def _sanitize(value): @@ -69,6 +70,7 @@ class Gitlab(object): groups (GroupManager): Manager for GitLab members hooks (HookManager): Manager for GitLab hooks issues (IssueManager): Manager for GitLab issues + licenses (LicenseManager): Manager for licenses project_branches (ProjectBranchManager): Manager for GitLab projects branches project_commits (ProjectCommitManager): Manager for GitLab projects @@ -122,6 +124,9 @@ def __init__(self, url, private_token=None, #: Whether SSL certificates should be validated self.ssl_verify = ssl_verify + #: Create a session object for requests + self.session = requests.Session() + self.settings = ApplicationSettingsManager(self) self.user_keys = UserKeyManager(self) self.users = UserManager(self) @@ -129,6 +134,7 @@ def __init__(self, url, private_token=None, self.groups = GroupManager(self) self.hooks = HookManager(self) self.issues = IssueManager(self) + self.licenses = LicenseManager(self) self.project_branches = ProjectBranchManager(self) self.project_commits = ProjectCommitManager(self) self.project_keys = ProjectKeyManager(self) @@ -260,11 +266,11 @@ def _raw_get(self, path, content_type=None, **kwargs): headers = self._create_headers(content_type) try: - return requests.get(url, - params=kwargs, - headers=headers, - verify=self.ssl_verify, - timeout=self.timeout) + return self.session.get(url, + params=kwargs, + headers=headers, + verify=self.ssl_verify, + timeout=self.timeout) except Exception as e: raise GitlabConnectionError( "Can't connect to GitLab server (%s)" % e) @@ -298,10 +304,10 @@ def _raw_post(self, path, data=None, content_type=None, **kwargs): url = '%s%s' % (self._url, path) headers = self._create_headers(content_type) try: - return requests.post(url, params=kwargs, data=data, - headers=headers, - verify=self.ssl_verify, - timeout=self.timeout) + return self.session.post(url, params=kwargs, data=data, + headers=headers, + verify=self.ssl_verify, + timeout=self.timeout) except Exception as e: raise GitlabConnectionError( "Can't connect to GitLab server (%s)" % e) @@ -311,10 +317,10 @@ def _raw_put(self, path, data=None, content_type=None, **kwargs): headers = self._create_headers(content_type) try: - return requests.put(url, data=data, params=kwargs, - headers=headers, - verify=self.ssl_verify, - timeout=self.timeout) + return self.session.put(url, data=data, params=kwargs, + headers=headers, + verify=self.ssl_verify, + timeout=self.timeout) except Exception as e: raise GitlabConnectionError( "Can't connect to GitLab server (%s)" % e) @@ -324,11 +330,11 @@ def _raw_delete(self, path, content_type=None, **kwargs): headers = self._create_headers(content_type) try: - return requests.delete(url, - params=kwargs, - headers=headers, - verify=self.ssl_verify, - timeout=self.timeout) + return self.session.delete(url, + params=kwargs, + headers=headers, + verify=self.ssl_verify, + timeout=self.timeout) except Exception as e: raise GitlabConnectionError( "Can't connect to GitLab server (%s)" % e) @@ -365,10 +371,14 @@ def list(self, obj_class, **kwargs): for attribute in obj_class.requiredUrlAttrs: del params[attribute] + # Also remove the next-url attribute that make queries fail + if 'next_url' in params: + del params['next_url'] + try: - r = requests.get(url, params=params, headers=headers, - verify=self.ssl_verify, - timeout=self.timeout) + r = self.session.get(url, params=params, headers=headers, + verify=self.ssl_verify, + timeout=self.timeout) except Exception as e: raise GitlabConnectionError( "Can't connect to GitLab server (%s)" % e) @@ -434,8 +444,8 @@ def get(self, obj_class, id=None, **kwargs): del params[attribute] try: - r = requests.get(url, params=params, headers=headers, - verify=self.ssl_verify, timeout=self.timeout) + r = self.session.get(url, params=params, headers=headers, + verify=self.ssl_verify, timeout=self.timeout) except Exception as e: raise GitlabConnectionError( "Can't connect to GitLab server (%s)" % e) @@ -463,16 +473,18 @@ def delete(self, obj, id=None, **kwargs): if inspect.isclass(obj): if not issubclass(obj, GitlabObject): raise GitlabError("Invalid class: %s" % obj) - params = {} - params[obj.idAttr] = id - else: - params = obj.__dict__.copy() + + params = {obj.idAttr: id if id else getattr(obj, obj.idAttr)} params.update(kwargs) + missing = [] for k in itertools.chain(obj.requiredUrlAttrs, obj.requiredDeleteAttrs): if k not in params: - missing.append(k) + try: + params[k] = getattr(obj, k) + except KeyError: + missing.append(k) if missing: raise GitlabDeleteError('Missing attribute(s): %s' % ", ".join(missing)) @@ -485,13 +497,17 @@ def delete(self, obj, id=None, **kwargs): # url-parameters left for attribute in obj.requiredUrlAttrs: del params[attribute] + if obj._id_in_delete_url: + # The ID is already built, no need to add it as extra key in query + # string + params.pop(obj.idAttr) try: - r = requests.delete(url, - params=params, - headers=headers, - verify=self.ssl_verify, - timeout=self.timeout) + r = self.session.delete(url, + params=params, + headers=headers, + verify=self.ssl_verify, + timeout=self.timeout) except Exception as e: raise GitlabConnectionError( "Can't connect to GitLab server (%s)" % e) @@ -535,10 +551,10 @@ def create(self, obj, **kwargs): data = obj._data_for_gitlab(extra_parameters=kwargs) try: - r = requests.post(url, data=data, - headers=headers, - verify=self.ssl_verify, - timeout=self.timeout) + r = self.session.post(url, data=data, + headers=headers, + verify=self.ssl_verify, + timeout=self.timeout) except Exception as e: raise GitlabConnectionError( "Can't connect to GitLab server (%s)" % e) @@ -585,10 +601,10 @@ def update(self, obj, **kwargs): data = obj._data_for_gitlab(extra_parameters=kwargs, update=True) try: - r = requests.put(url, data=data, - headers=headers, - verify=self.ssl_verify, - timeout=self.timeout) + r = self.session.put(url, data=data, + headers=headers, + verify=self.ssl_verify, + timeout=self.timeout) except Exception as e: raise GitlabConnectionError( "Can't connect to GitLab server (%s)" % e) diff --git a/gitlab/cli.py b/gitlab/cli.py index 090978b4d..c7dacebd0 100644 --- a/gitlab/cli.py +++ b/gitlab/cli.py @@ -41,10 +41,21 @@ 'blob': {'required': ['id', 'project-id', 'filepath']}, 'builds': {'required': ['id', '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': {}}, + 'all': {}, + 'starred': {}, + 'star': {'required': ['id']}, + 'unstar': {'required': ['id']}}, gitlab.User: {'block': {'required': ['id']}, 'unblock': {'required': ['id']}, 'search': {'required': ['query']}, @@ -162,12 +173,32 @@ def do_project_all(self, cls, gl, what, args): except Exception as e: _die("Impossible to list all projects (%s)" % str(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 (%s)" % str(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 (%s)" % str(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 (%s)" % str(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 (%s)" % str(e)) + def do_user_block(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) @@ -217,6 +248,33 @@ def do_project_build_retry(self, cls, gl, what, args): except Exception as e: _die("Impossible to retry project build (%s)" % str(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 (%s)" % + str(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 (%s)" % str(e)) + + def do_project_merge_request_merge(self, cls, gl, what, args): + try: + o = self.do_get(cls, gl, what, args) + should_remove = args['should_remove_source_branch'] + build_succeeds = args['merged_when_build_succeeds'] + return o.merge( + merge_commit_message=args['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 (%s)" % str(e)) + def do_project_milestone_issues(self, cls, gl, what, args): try: o = self.do_get(cls, gl, what, args) @@ -255,7 +313,7 @@ def _populate_sub_parser_by_class(cls, sub_parser): sub_parser_action.add_argument("--page", required=False) sub_parser_action.add_argument("--per-page", required=False) - elif action_name in ["get", "delete"]: + if action_name in ["get", "delete"]: if cls not in [gitlab.CurrentUser]: if cls.getRequiresId: id_attr = cls.idAttr.replace('_', '-') @@ -265,7 +323,17 @@ def _populate_sub_parser_by_class(cls, sub_parser): required=True) for x in cls.requiredGetAttrs if x != cls.idAttr] - elif action_name == "create": + 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] @@ -273,7 +341,7 @@ def _populate_sub_parser_by_class(cls, sub_parser): required=False) for x in cls.optionalCreateAttrs] - elif action_name == "update": + if action_name == "update": id_attr = cls.idAttr.replace('_', '-') sub_parser_action.add_argument("--%s" % id_attr, required=True) @@ -298,6 +366,8 @@ def _populate_sub_parser_by_class(cls, sub_parser): d = EXTRA_ACTIONS[cls][action_name] [sub_parser_action.add_argument("--%s" % arg, required=True) for arg in d.get('required', [])] + [sub_parser_action.add_argument("--%s" % arg, required=False) + for arg in d.get('optional', [])] def _build_parser(args=sys.argv[1:]): diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py index 1b5ec6a56..49a3728e7 100644 --- a/gitlab/exceptions.py +++ b/gitlab/exceptions.py @@ -91,6 +91,18 @@ class GitlabUnblockError(GitlabOperationError): pass +class GitlabMRForbiddenError(GitlabOperationError): + pass + + +class GitlabMRClosedError(GitlabOperationError): + pass + + +class GitlabMROnBuildSuccessError(GitlabOperationError): + pass + + def raise_error_from_response(response, error, expected_code=200): """Tries to parse gitlab error message from response and raises error. @@ -98,11 +110,18 @@ def raise_error_from_response(response, error, expected_code=200): If response status code is 401, raises instead GitlabAuthenticationError. - response: requests response object - error: Error-class to raise. Should be inherited from GitLabError + Args: + response: requests response object + error: Error-class or dict {return-code => class} of possible error + class to raise. Should be inherited from GitLabError """ - if expected_code == response.status_code: + if isinstance(expected_code, int): + expected_codes = [expected_code] + else: + expected_codes = expected_code + + if response.status_code in expected_codes: return try: @@ -110,8 +129,11 @@ def raise_error_from_response(response, error, expected_code=200): except (KeyError, ValueError): message = response.content - if response.status_code == 401: - error = GitlabAuthenticationError + if isinstance(error, dict): + error = error.get(response.status_code, GitlabOperationError) + else: + if response.status_code == 401: + error = GitlabAuthenticationError raise error(error_message=message, response_code=response.status_code, diff --git a/gitlab/objects.py b/gitlab/objects.py index c5a47a039..9c6197c0c 100644 --- a/gitlab/objects.py +++ b/gitlab/objects.py @@ -183,6 +183,10 @@ class GitlabObject(object): requiredUrlAttrs = [] #: Attributes that are required when retrieving list of objects. requiredListAttrs = [] + #: Attributes that are optional when retrieving list of objects. + optionalListAttrs = [] + #: Attributes that are optional when retrieving single object. + optionalGetAttrs = [] #: Attributes that are required when retrieving single object. requiredGetAttrs = [] #: Attributes that are required when deleting object. @@ -215,7 +219,10 @@ def _data_for_gitlab(self, extra_parameters={}, update=False): attributes = list(attributes) + ['sudo', 'page', 'per_page'] for attribute in attributes: if hasattr(self, attribute): - data[attribute] = getattr(self, attribute) + value = getattr(self, attribute) + if isinstance(value, list): + value = ",".join(value) + data[attribute] = value data.update(extra_parameters) @@ -505,12 +512,13 @@ class User(GitlabObject): requiredCreateAttrs = ['email', 'username', 'name', 'password'] optionalCreateAttrs = ['skype', 'linkedin', 'twitter', 'projects_limit', 'extern_uid', 'provider', 'bio', 'admin', - 'can_create_group', 'website_url', 'confirm'] + 'can_create_group', 'website_url', 'confirm', + 'external'] requiredUpdateAttrs = ['email', 'username', 'name'] optionalUpdateAttrs = ['password', 'skype', 'linkedin', 'twitter', 'projects_limit', 'extern_uid', 'provider', 'bio', 'admin', 'can_create_group', 'website_url', - 'confirm'] + 'confirm', 'external'] managers = [('keys', UserKeyManager, [('user_id', 'id')])] def _data_for_gitlab(self, extra_parameters={}, update=False): @@ -663,6 +671,7 @@ class Group(GitlabObject): canUpdate = False _constructorTypes = {'projects': 'Project'} requiredCreateAttrs = ['name', 'path'] + optionalCreateAttrs = ['description', 'visibility_level'] shortPrintAttr = 'name' managers = [('members', GroupMemberManager, [('group_id', 'id')])] @@ -672,6 +681,10 @@ class Group(GitlabObject): MASTER_ACCESS = 40 OWNER_ACCESS = 50 + VISIBILITY_PRIVATE = 0 + VISIBILITY_INTERNAL = 10 + VISIBILITY_PUBLIC = 20 + def Member(self, id=None, **kwargs): warnings.warn("`Member` is deprecated, use `members` instead", DeprecationWarning) @@ -738,6 +751,21 @@ class IssueManager(BaseManager): obj_cls = Issue +class License(GitlabObject): + _url = '/licenses' + canDelete = False + canUpdate = False + canCreate = False + idAttr = 'key' + + optionalListAttrs = ['popular'] + optionalGetAttrs = ['project', 'fullname'] + + +class LicenseManager(BaseManager): + obj_cls = License + + class ProjectBranch(GitlabObject): _url = '/projects/%(project_id)s/repository/branches' _constructorTypes = {'author': 'User', "committer": "User"} @@ -929,7 +957,6 @@ class ProjectHookManager(BaseManager): class ProjectIssueNote(GitlabObject): _url = '/projects/%(project_id)s/issues/%(issue_id)s/notes' _constructorTypes = {'author': 'User'} - canUpdate = False canDelete = False requiredUrlAttrs = ['project_id', 'issue_id'] requiredCreateAttrs = ['body'] @@ -943,7 +970,6 @@ class ProjectIssue(GitlabObject): _url = '/projects/%(project_id)s/issues/' _constructorTypes = {'author': 'User', 'assignee': 'User', 'milestone': 'ProjectMilestone'} - canDelete = False requiredUrlAttrs = ['project_id'] requiredCreateAttrs = ['title'] # FIXME: state_event is only valid with update @@ -1071,10 +1097,10 @@ class ProjectMergeRequest(GitlabObject): _url = '/projects/%(project_id)s/merge_request' _urlPlural = '/projects/%(project_id)s/merge_requests' _constructorTypes = {'author': 'User', 'assignee': 'User'} - canDelete = False requiredUrlAttrs = ['project_id'] requiredCreateAttrs = ['source_branch', 'target_branch', 'title'] - optionalCreateAttrs = ['assignee_id'] + optionalCreateAttrs = ['assignee_id', 'description', 'target_project_id', + 'labels', 'milestone_id'] managers = [('notes', ProjectMergeRequestNoteManager, [('project_id', 'project_id'), ('merge_request_id', 'id')])] @@ -1085,6 +1111,82 @@ def Note(self, id=None, **kwargs): self.gitlab, id, project_id=self.project_id, merge_request_id=self.id, **kwargs) + def _data_for_gitlab(self, extra_parameters={}, update=False): + data = (super(ProjectMergeRequest, self) + ._data_for_gitlab(extra_parameters)) + if update: + # Drop source_branch attribute as it is not accepted by the gitlab + # server (Issue #76) + # We need to unserialize and reserialize the + # data, this is far from optimal + d = json.loads(data) + d.pop('source_branch', None) + data = json.dumps(d) + return data + + def cancel_merge_when_build_succeeds(self, **kwargs): + """Cancel merge when build succeeds.""" + + u = ('/projects/%s/merge_requests/%s/cancel_merge_when_build_succeeds' + % (self.project_id, self.id)) + 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()) + + def closes_issues(self, **kwargs): + """List issues closed by the MR. + + Returns: + list (ProjectIssue): List of closed issues + + 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.id)) + return self.gitlab._raw_list(url, ProjectIssue, **kwargs) + + def merge(self, merge_commit_message=None, + should_remove_source_branch=False, + merged_when_build_succeeds=False, + **kwargs): + """Accept the merge request. + + Args: + merge_commit_message (bool): Commit message + should_remove_source_branch (bool): If True, removes the source + branch + merged_when_build_succeeds (bool): Wait for the build to succeed, + then merge + + Returns: + ProjectMergeRequet: 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 + """ + url = '/projects/%s/merge_requests/%s/merge' % (self.project_id, + self.id) + data = {} + if merge_commit_message: + data['merge_commit_message'] = merge_commit_message + if should_remove_source_branch: + data['should_remove_source_branch'] = 'should_remove_source_branch' + if merged_when_build_succeeds: + data['merged_when_build_succeeds'] = 'merged_when_build_succeeds' + + r = self.gitlab._raw_put(url, data=data, **kwargs) + errors = {401: GitlabMRForbiddenError, + 405: GitlabMRClosedError} + raise_error_from_response(r, errors) + return ProjectMergeRequest(self, r.json()) + class ProjectMergeRequestManager(BaseManager): obj_cls = ProjectMergeRequest @@ -1142,7 +1244,7 @@ class ProjectFile(GitlabObject): requiredCreateAttrs = ['file_path', 'branch_name', 'content', 'commit_message'] optionalCreateAttrs = ['encoding'] - requiredDeleteAttrs = ['branch_name', 'commit_message'] + requiredDeleteAttrs = ['branch_name', 'commit_message', 'file_path'] getListWhenNoId = False shortPrintAttr = 'file_path' getRequiresId = False @@ -1233,12 +1335,14 @@ class Project(GitlabObject): optionalCreateAttrs = ['default_branch', 'issues_enabled', 'wall_enabled', 'merge_requests_enabled', 'wiki_enabled', 'snippets_enabled', 'public', 'visibility_level', - 'namespace_id', 'description', 'path', 'import_url'] + 'namespace_id', 'description', 'path', 'import_url', + 'builds_enabled', 'public_builds'] optionalUpdateAttrs = ['name', 'default_branch', 'issues_enabled', 'wall_enabled', 'merge_requests_enabled', 'wiki_enabled', 'snippets_enabled', 'public', 'visibility_level', 'namespace_id', 'description', - 'path', 'import_url'] + 'path', 'import_url', 'builds_enabled', + 'public_builds'] shortPrintAttr = 'path' managers = [ ('branches', ProjectBranchManager, [('project_id', 'id')]), @@ -1362,6 +1466,11 @@ def Tag(self, id=None, **kwargs): **kwargs) def tree(self, path='', ref_name='', **kwargs): + warnings.warn("`tree` is deprecated, use `repository_tree` instead", + DeprecationWarning) + return self.repository_tree(path, ref_name, **kwargs) + + def repository_tree(self, path='', ref_name='', **kwargs): """Return a list of files in the repository. Args: @@ -1388,6 +1497,11 @@ def tree(self, path='', ref_name='', **kwargs): return r.json() def blob(self, sha, filepath, **kwargs): + warnings.warn("`blob` is deprecated, use `repository_blob` instead", + DeprecationWarning) + return self.repository_blob(sha, filepath, **kwargs) + + def repository_blob(self, sha, filepath, **kwargs): """Return the content of a file for a commit. Args: @@ -1407,7 +1521,66 @@ def blob(self, sha, filepath, **kwargs): raise_error_from_response(r, GitlabGetError) return r.content + def repository_raw_blob(self, sha, **kwargs): + """Returns the raw file contents for a blob by blob SHA. + + Args: + sha(str): ID of the blob + + Returns: + str: The blob content + + Raises: + GitlabConnectionError: If the server cannot be reached. + GitlabGetError: If the server fails to perform the request. + """ + url = "/projects/%s/repository/raw_blobs/%s" % (self.id, sha) + r = self.gitlab._raw_get(url, **kwargs) + raise_error_from_response(r, GitlabGetError) + return r.content + + def repository_compare(self, from_, to, **kwargs): + """Returns a diff between two branches/commits. + + Args: + from_(str): orig branch/SHA + to(str): dest branch/SHA + + 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() + + def repository_contributors(self): + """Returns a list of contributors for the project. + + Returns: + list: The contibutors + + Raises: + GitlabConnectionError: If the server cannot be reached. + GitlabGetError: If the server fails to perform the request. + """ + url = "/projects/%s/repository/contributors" % self.id + r = self.gitlab._raw_get(url) + raise_error_from_response(r, GitlabListError) + return r.json() + def archive(self, sha=None, **kwargs): + warnings.warn("`archive` is deprecated, " + "use `repository_archive` instead", + DeprecationWarning) + return self.repository_archive(path, ref_name, **kwargs) + + def repository_archive(self, sha=None, **kwargs): """Return a tarball of the repository. Args: @@ -1441,6 +1614,9 @@ def create_file(self, path, branch, content, message, **kwargs): GitlabConnectionError: If the server cannot be reached. GitlabCreateError: If the server fails to perform the request. """ + warnings.warn("`create_file` is deprecated, " + "use `files.create()` instead", + DeprecationWarning) url = "/projects/%s/repository/files" % self.id url += ("?file_path=%s&branch_name=%s&content=%s&commit_message=%s" % (path, branch, content, message)) @@ -1448,6 +1624,9 @@ def create_file(self, path, branch, content, message, **kwargs): raise_error_from_response(r, GitlabCreateError, 201) def update_file(self, path, branch, content, message, **kwargs): + warnings.warn("`update_file` is deprecated, " + "use `files.update()` instead", + DeprecationWarning) url = "/projects/%s/repository/files" % self.id url += ("?file_path=%s&branch_name=%s&content=%s&commit_message=%s" % (path, branch, content, message)) @@ -1455,6 +1634,9 @@ def update_file(self, path, branch, content, message, **kwargs): raise_error_from_response(r, GitlabUpdateError) def delete_file(self, path, branch, message, **kwargs): + warnings.warn("`delete_file` is deprecated, " + "use `files.delete()` instead", + DeprecationWarning) url = "/projects/%s/repository/files" % self.id url += ("?file_path=%s&branch_name=%s&commit_message=%s" % (path, branch, message)) @@ -1486,6 +1668,34 @@ def delete_fork_relation(self): r = self.gitlab._raw_delete(url) raise_error_from_response(r, GitlabDeleteError) + def star(self): + """Star a project. + + Returns: + Project: the updated Project + + Raises: + GitlabConnectionError: If the server cannot be reached. + """ + url = "/projects/%s/star" % self.id + r = self.gitlab._raw_post(url) + raise_error_from_response(r, GitlabGetError, [201, 304]) + return Project(self.gitlab, r.json()) if r.status_code == 201 else self + + def unstar(self): + """Unstar a project. + + Returns: + Project: the updated Project + + Raises: + GitlabConnectionError: If the server cannot be reached. + """ + url = "/projects/%s/star" % self.id + r = self.gitlab._raw_delete(url) + raise_error_from_response(r, GitlabDeleteError, [200, 304]) + return Project(self.gitlab, r.json()) if r.status_code == 200 else self + class TeamMember(GitlabObject): _url = '/user_teams/%(team_id)s/members' @@ -1507,7 +1717,8 @@ class UserProject(GitlabObject): optionalCreateAttrs = ['default_branch', 'issues_enabled', 'wall_enabled', 'merge_requests_enabled', 'wiki_enabled', 'snippets_enabled', 'public', 'visibility_level', - 'description'] + 'description', 'builds_enabled', 'public_builds', + 'import_url'] class ProjectManager(BaseManager): @@ -1532,7 +1743,7 @@ def search(self, query, **kwargs): **kwargs: Additional arguments to send to GitLab. Returns: - list(Project): A list of matching projects. + list(gitlab.Gitlab.Project): A list of matching projects. """ return self.gitlab._raw_list("/projects/search/" + query, Project, **kwargs) @@ -1545,7 +1756,7 @@ def all(self, **kwargs): **kwargs: Additional arguments to send to GitLab. Returns: - list(Project): The list of projects. + list(gitlab.Gitlab.Project): The list of projects. """ return self.gitlab._raw_list("/projects/all", Project, **kwargs) @@ -1557,10 +1768,22 @@ def owned(self, **kwargs): **kwargs: Additional arguments to send to GitLab. Returns: - list(Project): The list of owned projects. + list(gitlab.Gitlab.Project): The list of owned projects. """ return self.gitlab._raw_list("/projects/owned", Project, **kwargs) + def starred(self, **kwargs): + """List starred projects. + + Args: + all (bool): If True, return all the items, without pagination + **kwargs: Additional arguments to send to GitLab. + + Returns: + list(gitlab.Gitlab.Project): The list of starred projects. + """ + return self.gitlab._raw_list("/projects/starred", Project, **kwargs) + class UserProjectManager(BaseManager): obj_cls = UserProject diff --git a/tools/functional_tests.sh b/tools/functional_tests.sh index 84339e30f..a4a8d06c7 100755 --- a/tools/functional_tests.sh +++ b/tools/functional_tests.sh @@ -80,6 +80,23 @@ testcase "branch creation" ' --branch-name branch1 --ref master >/dev/null 2>&1 ' +GITLAB project-file create --project-id "$PROJECT_ID" \ + --file-path README2 --branch-name branch1 --content "CONTENT" \ + --commit-message "second commit" >/dev/null 2>&1 + +testcase "merge request creation" ' + OUTPUT=$(GITLAB project-merge-request create \ + --project-id "$PROJECT_ID" \ + --source-branch branch1 --target-branch master \ + --title "Update README") +' +MR_ID=$(pecho "${OUTPUT}" | grep ^id: | cut -d' ' -f2) + +testcase "merge request validation" ' + GITLAB project-merge-request merge --project-id "$PROJECT_ID" \ + --id "$MR_ID" >/dev/null 2>&1 +' + testcase "branch deletion" ' GITLAB project-branch delete --project-id "$PROJECT_ID" \ --name branch1 >/dev/null 2>&1 diff --git a/tools/python_test.py b/tools/python_test.py index d32dccd36..d09d24b20 100644 --- a/tools/python_test.py +++ b/tools/python_test.py @@ -1,4 +1,5 @@ import base64 +import time import gitlab @@ -135,6 +136,7 @@ '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_name="master", commit_message="new commit") readme.delete(commit_message="Removing README") @@ -145,13 +147,13 @@ readme = admin_project.files.get(file_path='README.rst', ref='master') assert(readme.decode() == 'Initial content') -tree = admin_project.tree() +tree = admin_project.repository_tree() assert(len(tree) == 1) assert(tree[0]['name'] == 'README.rst') -blob = admin_project.blob('master', 'README.rst') +blob = admin_project.repository_blob('master', 'README.rst') assert(blob == 'Initial content') -archive1 = admin_project.archive() -archive2 = admin_project.archive('master') +archive1 = admin_project.repository_archive() +archive2 = admin_project.repository_archive('master') assert(archive1 == archive2) # labels @@ -208,3 +210,27 @@ v1 = admin_project.variables.get(v1.key) assert(v1.value == 'new_value1') v1.delete() + +# branches and merges +to_merge = admin_project.branches.create({'branch_name': 'branch1', + 'ref': 'master'}) +admin_project.files.create({'file_path': 'README2.rst', + 'branch_name': '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'}) +ret = mr.merge() +admin_project.branches.delete('branch1') + +try: + mr.merge() +except gitlab.GitlabMRClosedError: + pass + +# stars +admin_project = admin_project.star() +assert(admin_project.star_count == 1) +admin_project = admin_project.unstar() +assert(admin_project.star_count == 0)