diff --git a/ChangeLog b/ChangeLog index 76932e327..e769d163f 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,17 @@ +Version 0.18 + + * Fix JIRA service editing for GitLab 8.14+ + * Add jira_issue_transition_id to the JIRA service optional fields + * Added support for Snippets (new API in Gitlab 8.15) + * [docs] update pagination section + * [docs] artifacts example: open file in wb mode + * [CLI] ignore empty arguments + * [CLI] Fix wrong use of arguments + * [docs] Add doc for snippets + * Fix duplicated data in API docs + * Update known attributes for projects + * sudo: always use strings + Version 0.17 * README: add badges for pypi and RTD diff --git a/docs/api-objects.rst b/docs/api-objects.rst index 129667cf8..010e9d650 100644 --- a/docs/api-objects.rst +++ b/docs/api-objects.rst @@ -23,6 +23,7 @@ API objects manipulation gl_objects/projects gl_objects/runners gl_objects/settings + gl_objects/snippets gl_objects/system_hooks gl_objects/templates gl_objects/todos diff --git a/docs/api-usage.rst b/docs/api-usage.rst index b33913dca..4f8cb3717 100644 --- a/docs/api-usage.rst +++ b/docs/api-usage.rst @@ -111,7 +111,12 @@ listing methods support the ``page`` and ``per_page`` parameters: .. code-block:: python - ten_first_groups = gl.groups.list(page=0, per_page=10) + ten_first_groups = gl.groups.list(page=1, per_page=10) + +.. note:: + + The first page is page 1, not page 0. + By default GitLab does not return the complete list of items. Use the ``all`` parameter to get all the items when using listing methods: diff --git a/docs/ext/manager_tmpl.j2 b/docs/ext/manager_tmpl.j2 index 5a01d8f7d..fee8a568b 100644 --- a/docs/ext/manager_tmpl.j2 +++ b/docs/ext/manager_tmpl.j2 @@ -56,9 +56,6 @@ Manager for {{ cls | classref() }} objects. ``data`` is a dict defining the object attributes. Available attributes are: - {% for a in cls.requiredUrlAttrs %} - * ``{{ a }}`` (required) - {% endfor %} {% for a in cls.requiredUrlAttrs %} * ``{{ a }}`` (required if not discovered on the parent objects) {% endfor %} diff --git a/docs/gl_objects/builds.py b/docs/gl_objects/builds.py index 911fc757c..855b7c898 100644 --- a/docs/gl_objects/builds.py +++ b/docs/gl_objects/builds.py @@ -80,7 +80,7 @@ # stream artifacts class Foo(object): def __init__(self): - self._fd = open('artifacts.zip', 'w') + self._fd = open('artifacts.zip', 'wb') def __call__(self, chunk): self._fd.write(chunk) diff --git a/docs/gl_objects/projects.rst b/docs/gl_objects/projects.rst index bdbf140ee..584fa58f6 100644 --- a/docs/gl_objects/projects.rst +++ b/docs/gl_objects/projects.rst @@ -216,6 +216,8 @@ Delete a tag: :start-after: # tags delete :end-before: # end tags delete +.. _project_snippets: + Snippets -------- diff --git a/docs/gl_objects/snippets.py b/docs/gl_objects/snippets.py new file mode 100644 index 000000000..091aef60e --- /dev/null +++ b/docs/gl_objects/snippets.py @@ -0,0 +1,30 @@ +# list +snippets = gl.snippets.list() +# end list + +# public list +public_snippets = gl.snippets.public() +# nd public list + +# get +snippet = gl.snippets.get(snippet_id) +# get the content +content = snippet.raw() +# end get + +# create +snippet = gl.snippets.create({'title': 'snippet1', + 'file_name': 'snippet1.py', + 'content': open('snippet1.py').read()}) +# end create + +# update +snippet.visibility_level = gitlab.Project.VISIBILITY_PUBLIC +snippet.save() +# end update + +# delete +gl.snippets.delete(snippet_id) +# or +snippet.delete() +# end delete diff --git a/docs/gl_objects/snippets.rst b/docs/gl_objects/snippets.rst new file mode 100644 index 000000000..34c39fba8 --- /dev/null +++ b/docs/gl_objects/snippets.rst @@ -0,0 +1,54 @@ +######## +Snippets +######## + +You can store code snippets in Gitlab. Snippets can be attached to projects +(see :ref:`project_snippets`), but can also be detached. + +* Object class: :class:`gitlab.objects.Namespace` +* Manager object: :attr:`gitlab.Gitlab.snippets` + +Examples +======== + +List snippets woned by the current user: + +.. literalinclude:: snippets.py + :start-after: # list + :end-before: # end list + +List the public snippets: + +.. literalinclude:: snippets.py + :start-after: # public list + :end-before: # end public list + +Get a snippet: + +.. literalinclude:: snippets.py + :start-after: # get + :end-before: # end get + +.. warning:: + + Blobs are entirely stored in memory unless you use the streaming feature. + See :ref:`the artifacts example `. + + +Create a snippet: + +.. literalinclude:: snippets.py + :start-after: # create + :end-before: # end create + +Update a snippet: + +.. literalinclude:: snippets.py + :start-after: # update + :end-before: # end update + +Delete a snippet: + +.. literalinclude:: snippets.py + :start-after: # delete + :end-before: # end delete diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 82a241441..e0051aafd 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -34,7 +34,7 @@ from gitlab.objects import * # noqa __title__ = 'python-gitlab' -__version__ = '0.17' +__version__ = '0.18' __author__ = 'Gauvain Pocentek' __email__ = 'gauvain@pocentek.net' __license__ = 'LGPL3' @@ -103,6 +103,7 @@ def __init__(self, url, private_token=None, email=None, password=None, self.runners = RunnerManager(self) self.settings = ApplicationSettingsManager(self) self.sidekiq = SidekiqManager(self) + self.snippets = SnippetManager(self) self.users = UserManager(self) self.teams = TeamManager(self) self.todos = TodoManager(self) @@ -469,7 +470,8 @@ def delete(self, obj, id=None, **kwargs): params.pop(obj.idAttr) r = self._raw_delete(url, **params) - raise_error_from_response(r, GitlabDeleteError) + raise_error_from_response(r, GitlabDeleteError, + expected_code=[200, 204]) return True def create(self, obj, **kwargs): diff --git a/gitlab/cli.py b/gitlab/cli.py index ec4274da6..32b3ec850 100644 --- a/gitlab/cli.py +++ b/gitlab/cli.py @@ -181,7 +181,7 @@ def do_project_search(self, cls, gl, what, args): def do_project_all(self, cls, gl, what, args): try: - return gl.projects.all(all=args['all']) + return gl.projects.all(all=args.get('all', False)) except Exception as e: _die("Impossible to list all projects", e) @@ -333,10 +333,10 @@ def do_project_merge_request_cancel(self, cls, gl, what, args): 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'] + should_remove = args.get('should_remove_source_branch', False) + build_succeeds = args.get('merged_when_build_succeeds', False) return o.merge( - merge_commit_message=args['merge_commit_message'], + merge_commit_message=args.get('merge_commit_message', ''), should_remove_source_branch=should_remove, merged_when_build_succeeds=build_succeeds) except Exception as e: @@ -511,9 +511,12 @@ def main(): what = arg.what # Remove CLI behavior-related args - for item in ("gitlab", "config_file", "verbose", "what", "action"): + for item in ("gitlab", "config_file", "verbose", "what", "action", + "version"): args.pop(item) + args = {k: v for k, v in args.items() if v is not None} + cls = None try: cls = gitlab.__dict__[_what_to_cls(what)] diff --git a/gitlab/objects.py b/gitlab/objects.py index 2560ba4d0..2a33dc518 100644 --- a/gitlab/objects.py +++ b/gitlab/objects.py @@ -222,6 +222,8 @@ def _data_for_gitlab(self, extra_parameters={}, update=False, value = getattr(self, attribute) if isinstance(value, list): value = ",".join(value) + if attribute == 'sudo': + value = str(value) data[attribute] = value data.update(extra_parameters) @@ -1018,6 +1020,54 @@ class LicenseManager(BaseManager): obj_cls = License +class Snippet(GitlabObject): + _url = '/snippets' + _constructorTypes = {'author': 'User'} + requiredCreateAttrs = ['title', 'file_name', 'content'] + optionalCreateAttrs = ['lifetime', 'visibility_level'] + optionalUpdateAttrs = ['title', 'file_name', 'content', 'visibility_level'] + shortPrintAttr = 'title' + + def raw(self, streamed=False, action=None, chunk_size=1024, **kwargs): + """Return the raw content of a snippet. + + Args: + streamed (bool): If True the data will be processed by chunks of + `chunk_size` and each chunk is passed to `action` for + treatment. + action (callable): Callable responsible of dealing with chunk of + data. + chunk_size (int): Size of each chunk. + + Returns: + str: The snippet content. + + Raises: + GitlabConnectionError: If the server cannot be reached. + GitlabGetError: If the server fails to perform the request. + """ + url = ("/snippets/%(snippet_id)s/raw" % {'snippet_id': self.id}) + r = self.gitlab._raw_get(url, **kwargs) + raise_error_from_response(r, GitlabGetError) + return utils.response_content(r, streamed, action, chunk_size) + + +class SnippetManager(BaseManager): + obj_cls = Snippet + + def public(self, **kwargs): + """List all the public snippets. + + Args: + all (bool): If True, return all the items, without pagination + **kwargs: Additional arguments to send to GitLab. + + Returns: + list(gitlab.Gitlab.Snippet): The list of snippets. + """ + return self.gitlab._raw_list("/snippets/public", Snippet, **kwargs) + + class Namespace(GitlabObject): _url = '/namespaces' canGet = 'from_list' @@ -1992,8 +2042,16 @@ class ProjectService(GitlabObject): 'server')), 'irker': (('recipients', ), ('default_irc_uri', 'server_port', 'server_host', 'colorize_messages')), - 'jira': (('new_issue_url', 'project_url', 'issues_url'), - ('api_url', 'description', 'username', 'password')), + 'jira': (tuple(), ( + # Required fields in GitLab >= 8.14 + 'url', 'project_key', + + # Required fields in GitLab < 8.14 + 'new_issue_url', 'project_url', 'issues_url', 'api_url', + 'description', + + # Optional fields + 'username', 'password', 'jira_issue_transition_id')), 'pivotaltracker': (('token', ), tuple()), 'pushover': (('api_key', 'user_key', 'priority'), ('device', 'sound')), 'redmine': (('new_issue_url', 'project_url', 'issues_url'), @@ -2083,21 +2141,24 @@ class Project(GitlabObject): _url = '/projects' _constructorTypes = {'owner': 'User', 'namespace': 'Group'} requiredCreateAttrs = ['name'] - optionalCreateAttrs = ['default_branch', 'issues_enabled', 'wall_enabled', - 'merge_requests_enabled', 'wiki_enabled', + optionalCreateAttrs = ['path', 'namespace_id', 'description', + 'issues_enabled', 'merge_requests_enabled', + 'builds_enabled', 'wiki_enabled', + 'snippets_enabled', 'container_registry_enabled', + 'shared_runners_enabled', 'public', + 'visibility_level', 'import_url', 'public_builds', + 'only_allow_merge_if_build_succeeds', + 'only_allow_merge_if_all_discussions_are_resolved', + 'lfs_enabled', 'request_access_enabled'] + optionalUpdateAttrs = ['name', 'path', 'default_branch', 'description', + 'issues_enabled', 'merge_requests_enabled', + 'builds_enabled', 'wiki_enabled', 'snippets_enabled', 'container_registry_enabled', - 'public', 'visibility_level', 'namespace_id', - 'description', 'path', 'import_url', - 'builds_enabled', 'public_builds', - 'only_allow_merge_if_build_succeeds'] - optionalUpdateAttrs = ['name', 'default_branch', 'issues_enabled', - 'wall_enabled', 'merge_requests_enabled', - 'wiki_enabled', 'snippets_enabled', - 'container_registry_enabled', 'public', - 'visibility_level', 'namespace_id', 'description', - 'path', 'import_url', 'builds_enabled', - 'public_builds', - 'only_allow_merge_if_build_succeeds'] + 'shared_runners_enabled', 'public', + 'visibility_level', 'import_url', 'public_builds', + 'only_allow_merge_if_build_succeeds', + 'only_allow_merge_if_all_discussions_are_resolved', + 'lfs_enabled', 'request_access_enabled'] shortPrintAttr = 'path' managers = ( ('accessrequests', ProjectAccessRequestManager, diff --git a/gitlab/tests/test_gitlabobject.py b/gitlab/tests/test_gitlabobject.py index cf06a2a9d..3bffb825d 100644 --- a/gitlab/tests/test_gitlabobject.py +++ b/gitlab/tests/test_gitlabobject.py @@ -455,3 +455,37 @@ def test_content(self): def test_blob_fail(self): with HTTMock(self.resp_content_fail): self.assertRaises(GitlabGetError, self.obj.content) + + +class TestSnippet(unittest.TestCase): + def setUp(self): + self.gl = Gitlab("http://localhost", private_token="private_token", + email="testuser@test.com", password="testpassword", + ssl_verify=True) + self.obj = Snippet(self.gl, data={"id": 3}) + + @urlmatch(scheme="http", netloc="localhost", + path="/api/v3/snippets/3/raw", + method="get") + def resp_content(self, url, request): + headers = {'content-type': 'application/json'} + content = 'content'.encode("utf-8") + return response(200, content, headers, None, 5, request) + + @urlmatch(scheme="http", netloc="localhost", + path="/api/v3/snippets/3/raw", + method="get") + def resp_content_fail(self, url, request): + headers = {'content-type': 'application/json'} + content = '{"message": "messagecontent" }'.encode("utf-8") + return response(400, content, headers, None, 5, request) + + def test_content(self): + with HTTMock(self.resp_content): + data = b'content' + content = self.obj.raw() + self.assertEqual(content, data) + + def test_blob_fail(self): + with HTTMock(self.resp_content_fail): + self.assertRaises(GitlabGetError, self.obj.raw) diff --git a/tools/python_test.py b/tools/python_test.py index 0c065b8d9..abfa5087b 100644 --- a/tools/python_test.py +++ b/tools/python_test.py @@ -289,3 +289,18 @@ settings.save() settings = gl.notificationsettings.get() assert(settings.level == gitlab.NOTIFICATION_LEVEL_WATCH) + +# snippets +snippets = gl.snippets.list() +assert(len(snippets) == 0) +snippet = gl.snippets.create({'title': 'snippet1', 'file_name': 'snippet1.py', + 'content': 'import gitlab'}) +snippet = gl.snippets.get(1) +snippet.title = 'updated_title' +snippet.save() +snippet = gl.snippets.get(1) +assert(snippet.title == 'updated_title') +content = snippet.raw() +assert(content == 'import gitlab') +snippet.delete() +assert(len(gl.snippets.list()) == 0)