Skip to content

Feat: support for group import/export API #1063

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 6, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions docs/gl_objects/groups.rst
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,62 @@ Remove a group::
# or
group.delete()

Import / Export
===============

You can export groups from gitlab, and re-import them to create new groups.

Reference
---------

* v4 API:

+ :class:`gitlab.v4.objects.GroupExport`
+ :class:`gitlab.v4.objects.GroupExportManager`
+ :attr:`gitlab.v4.objects.Group.exports`
+ :class:`gitlab.v4.objects.GroupImport`
+ :class:`gitlab.v4.objects.GroupImportManager`
+ :attr:`gitlab.v4.objects.Group.imports`
+ :attr:`gitlab.v4.objects.GroupManager.import_group`

* GitLab API: https://docs.gitlab.com/ce/api/group_import_export.html

Examples
--------

A group export is an asynchronous operation. To retrieve the archive
generated by GitLab you need to:

#. Create an export using the API
#. Wait for the export to be done
#. Download the result

.. warning::

Unlike the Project Export API, GitLab does not provide an export_status
for Group Exports. It is up to the user to ensure the export is finished.

However, Group Exports only contain metadata, so they are much faster
than Project Exports.

::

# Create the export
group = gl.groups.get(my_group)
export = group.exports.create()

# Wait for the export to finish
time.sleep(3)

# Download the result
with open('/tmp/export.tgz', 'wb') as f:
export.download(streamed=True, action=f.write)

Import the group::

with open('/tmp/export.tgz', 'rb') as f:
gl.groups.import_group(f, path='imported-group', name="Imported Group")

Subgroups
=========

Expand Down
4 changes: 4 additions & 0 deletions gitlab/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,10 @@ class GitlabAttachFileError(GitlabOperationError):
pass


class GitlabImportError(GitlabOperationError):
pass


class GitlabCherryPickError(GitlabOperationError):
pass

Expand Down
29 changes: 29 additions & 0 deletions gitlab/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,35 @@ def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs):
self._update_attrs(server_data)


class DownloadMixin(object):
@cli.register_custom_action(("GroupExport", "ProjectExport"))
@exc.on_http_error(exc.GitlabGetError)
def download(self, streamed=False, action=None, chunk_size=1024, **kwargs):
"""Download the archive of a resource export.

Args:
streamed (bool): If True the data will be processed by chunks of
`chunk_size` and each chunk is passed to `action` for
reatment
action (callable): Callable responsible of dealing with chunk of
data
chunk_size (int): Size of each chunk
**kwargs: Extra options to send to the server (e.g. sudo)

Raises:
GitlabAuthenticationError: If authentication is not correct
GitlabGetError: If the server failed to perform the request

Returns:
str: The blob content if streamed is False, None otherwise
"""
path = "%s/download" % (self.manager.path)
result = self.manager.gitlab.http_get(
path, streamed=streamed, raw=True, **kwargs
)
return utils.response_content(result, streamed, action, chunk_size)


class SubscribableMixin(object):
@cli.register_custom_action(
("ProjectIssue", "ProjectMergeRequest", "ProjectLabel", "GroupLabel")
Expand Down
35 changes: 35 additions & 0 deletions gitlab/tests/objects/mocks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Common mocks for resources in gitlab.v4.objects"""

from httmock import response, urlmatch


headers = {"content-type": "application/json"}
binary_content = b"binary content"


@urlmatch(
scheme="http",
netloc="localhost",
path="/api/v4/(groups|projects)/1/export",
method="post",
)
def resp_create_export(url, request):
"""Common mock for Group/Project Export POST response."""
content = """{
"message": "202 Accepted"
}"""
content = content.encode("utf-8")
return response(202, content, headers, None, 25, request)


@urlmatch(
scheme="http",
netloc="localhost",
path="/api/v4/(groups|projects)/1/export/download",
method="get",
)
def resp_download_export(url, request):
"""Common mock for Group/Project Export Download GET response."""
headers = {"content-type": "application/octet-stream"}
content = binary_content
return response(200, content, headers, None, 25, request)
3 changes: 2 additions & 1 deletion gitlab/tests/objects/test_commits.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from httmock import urlmatch, response, with_httmock

from .test_projects import headers, TestProject
from .mocks import headers
from .test_projects import TestProject


@urlmatch(
Expand Down
101 changes: 101 additions & 0 deletions gitlab/tests/objects/test_groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import unittest

from httmock import response, urlmatch, with_httmock

import gitlab
from .mocks import * # noqa


@urlmatch(scheme="http", netloc="localhost", path="/api/v4/groups/1", method="get")
def resp_get_group(url, request):
content = '{"name": "name", "id": 1, "path": "path"}'
content = content.encode("utf-8")
return response(200, content, headers, None, 5, request)


@urlmatch(scheme="http", netloc="localhost", path="/api/v4/groups", method="post")
def resp_create_group(url, request):
content = '{"name": "name", "id": 1, "path": "path"}'
content = content.encode("utf-8")
return response(200, content, headers, None, 5, request)


@urlmatch(
scheme="http", netloc="localhost", path="/api/v4/groups/import", method="post",
)
def resp_create_import(url, request):
"""Mock for Group import tests.

GitLab does not respond with import status for group imports.
"""

content = """{
"message": "202 Accepted"
}"""
content = content.encode("utf-8")
return response(202, content, headers, None, 25, request)


class TestGroup(unittest.TestCase):
def setUp(self):
self.gl = gitlab.Gitlab(
"http://localhost",
private_token="private_token",
ssl_verify=True,
api_version=4,
)

@with_httmock(resp_get_group)
def test_get_group(self):
data = self.gl.groups.get(1)
self.assertIsInstance(data, gitlab.v4.objects.Group)
self.assertEqual(data.name, "name")
self.assertEqual(data.path, "path")
self.assertEqual(data.id, 1)

@with_httmock(resp_create_group)
def test_create_group(self):
name, path = "name", "path"
data = self.gl.groups.create({"name": name, "path": path})
self.assertIsInstance(data, gitlab.v4.objects.Group)
self.assertEqual(data.name, name)
self.assertEqual(data.path, path)


class TestGroupExport(TestGroup):
def setUp(self):
super(TestGroupExport, self).setUp()
self.group = self.gl.groups.get(1, lazy=True)

@with_httmock(resp_create_export)
def test_create_group_export(self):
export = self.group.exports.create()
self.assertEqual(export.message, "202 Accepted")

@unittest.skip("GitLab API endpoint not implemented")
@with_httmock(resp_create_export)
def test_refresh_group_export_status(self):
export = self.group.exports.create()
export.refresh()
self.assertEqual(export.export_status, "finished")

@with_httmock(resp_create_export, resp_download_export)
def test_download_group_export(self):
export = self.group.exports.create()
download = export.download()
self.assertIsInstance(download, bytes)
self.assertEqual(download, binary_content)


class TestGroupImport(TestGroup):
@with_httmock(resp_create_import)
def test_import_group(self):
group_import = self.gl.groups.import_group("file", "api-group", "API Group")
self.assertEqual(group_import["message"], "202 Accepted")

@unittest.skip("GitLab API endpoint not implemented")
@with_httmock(resp_create_import)
def test_refresh_group_import_status(self):
group_import = self.group.imports.get()
group_import.refresh()
self.assertEqual(group_import.import_status, "finished")
29 changes: 1 addition & 28 deletions gitlab/tests/objects/test_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,7 @@
from gitlab.v4.objects import * # noqa
from httmock import HTTMock, urlmatch, response, with_httmock # noqa


headers = {"content-type": "application/json"}
binary_content = b"binary content"


@urlmatch(
scheme="http", netloc="localhost", path="/api/v4/projects/1/export", method="post",
)
def resp_create_export(url, request):
"""Common mock for Project Export tests."""
content = """{
"message": "202 Accepted"
}"""
content = content.encode("utf-8")
return response(202, content, headers, None, 25, request)
from .mocks import * # noqa


@urlmatch(
Expand All @@ -51,19 +37,6 @@ def resp_export_status(url, request):
return response(200, content, headers, None, 25, request)


@urlmatch(
scheme="http",
netloc="localhost",
path="/api/v4/projects/1/export/download",
method="get",
)
def resp_download_export(url, request):
"""Mock for Project Export Download GET response."""
headers = {"content-type": "application/octet-stream"}
content = binary_content
return response(200, content, headers, None, 25, request)


@urlmatch(
scheme="http", netloc="localhost", path="/api/v4/projects/import", method="post",
)
Expand Down
17 changes: 0 additions & 17 deletions gitlab/tests/test_gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,23 +626,6 @@ def resp_get_environment(url, request):
self.assertIsInstance(statistics, ProjectIssuesStatistics)
self.assertEqual(statistics.statistics["counts"]["all"], 20)

def test_groups(self):
@urlmatch(
scheme="http", netloc="localhost", path="/api/v4/groups/1", method="get"
)
def resp_get_group(url, request):
headers = {"content-type": "application/json"}
content = '{"name": "name", "id": 1, "path": "path"}'
content = content.encode("utf-8")
return response(200, content, headers, None, 5, request)

with HTTMock(resp_get_group):
data = self.gl.groups.get(1)
self.assertIsInstance(data, Group)
self.assertEqual(data.name, "name")
self.assertEqual(data.path, "path")
self.assertEqual(data.id, 1)

def test_issues(self):
@urlmatch(
scheme="http", netloc="localhost", path="/api/v4/issues", method="get"
Expand Down
Loading