Skip to content

Commit c161852

Browse files
authored
Merge pull request python-gitlab#1063 from python-gitlab/feat/group-import-export
Feat: support for group import/export API
2 parents fa34f5e + 847da60 commit c161852

File tree

10 files changed

+306
-74
lines changed

10 files changed

+306
-74
lines changed

docs/gl_objects/groups.rst

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,62 @@ Remove a group::
6767
# or
6868
group.delete()
6969

70+
Import / Export
71+
===============
72+
73+
You can export groups from gitlab, and re-import them to create new groups.
74+
75+
Reference
76+
---------
77+
78+
* v4 API:
79+
80+
+ :class:`gitlab.v4.objects.GroupExport`
81+
+ :class:`gitlab.v4.objects.GroupExportManager`
82+
+ :attr:`gitlab.v4.objects.Group.exports`
83+
+ :class:`gitlab.v4.objects.GroupImport`
84+
+ :class:`gitlab.v4.objects.GroupImportManager`
85+
+ :attr:`gitlab.v4.objects.Group.imports`
86+
+ :attr:`gitlab.v4.objects.GroupManager.import_group`
87+
88+
* GitLab API: https://docs.gitlab.com/ce/api/group_import_export.html
89+
90+
Examples
91+
--------
92+
93+
A group export is an asynchronous operation. To retrieve the archive
94+
generated by GitLab you need to:
95+
96+
#. Create an export using the API
97+
#. Wait for the export to be done
98+
#. Download the result
99+
100+
.. warning::
101+
102+
Unlike the Project Export API, GitLab does not provide an export_status
103+
for Group Exports. It is up to the user to ensure the export is finished.
104+
105+
However, Group Exports only contain metadata, so they are much faster
106+
than Project Exports.
107+
108+
::
109+
110+
# Create the export
111+
group = gl.groups.get(my_group)
112+
export = group.exports.create()
113+
114+
# Wait for the export to finish
115+
time.sleep(3)
116+
117+
# Download the result
118+
with open('/tmp/export.tgz', 'wb') as f:
119+
export.download(streamed=True, action=f.write)
120+
121+
Import the group::
122+
123+
with open('/tmp/export.tgz', 'rb') as f:
124+
gl.groups.import_group(f, path='imported-group', name="Imported Group")
125+
70126
Subgroups
71127
=========
72128

gitlab/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,10 @@ class GitlabAttachFileError(GitlabOperationError):
209209
pass
210210

211211

212+
class GitlabImportError(GitlabOperationError):
213+
pass
214+
215+
212216
class GitlabCherryPickError(GitlabOperationError):
213217
pass
214218

gitlab/mixins.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,35 @@ def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs):
443443
self._update_attrs(server_data)
444444

445445

446+
class DownloadMixin(object):
447+
@cli.register_custom_action(("GroupExport", "ProjectExport"))
448+
@exc.on_http_error(exc.GitlabGetError)
449+
def download(self, streamed=False, action=None, chunk_size=1024, **kwargs):
450+
"""Download the archive of a resource export.
451+
452+
Args:
453+
streamed (bool): If True the data will be processed by chunks of
454+
`chunk_size` and each chunk is passed to `action` for
455+
reatment
456+
action (callable): Callable responsible of dealing with chunk of
457+
data
458+
chunk_size (int): Size of each chunk
459+
**kwargs: Extra options to send to the server (e.g. sudo)
460+
461+
Raises:
462+
GitlabAuthenticationError: If authentication is not correct
463+
GitlabGetError: If the server failed to perform the request
464+
465+
Returns:
466+
str: The blob content if streamed is False, None otherwise
467+
"""
468+
path = "%s/download" % (self.manager.path)
469+
result = self.manager.gitlab.http_get(
470+
path, streamed=streamed, raw=True, **kwargs
471+
)
472+
return utils.response_content(result, streamed, action, chunk_size)
473+
474+
446475
class SubscribableMixin(object):
447476
@cli.register_custom_action(
448477
("ProjectIssue", "ProjectMergeRequest", "ProjectLabel", "GroupLabel")

gitlab/tests/objects/mocks.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Common mocks for resources in gitlab.v4.objects"""
2+
3+
from httmock import response, urlmatch
4+
5+
6+
headers = {"content-type": "application/json"}
7+
binary_content = b"binary content"
8+
9+
10+
@urlmatch(
11+
scheme="http",
12+
netloc="localhost",
13+
path="/api/v4/(groups|projects)/1/export",
14+
method="post",
15+
)
16+
def resp_create_export(url, request):
17+
"""Common mock for Group/Project Export POST response."""
18+
content = """{
19+
"message": "202 Accepted"
20+
}"""
21+
content = content.encode("utf-8")
22+
return response(202, content, headers, None, 25, request)
23+
24+
25+
@urlmatch(
26+
scheme="http",
27+
netloc="localhost",
28+
path="/api/v4/(groups|projects)/1/export/download",
29+
method="get",
30+
)
31+
def resp_download_export(url, request):
32+
"""Common mock for Group/Project Export Download GET response."""
33+
headers = {"content-type": "application/octet-stream"}
34+
content = binary_content
35+
return response(200, content, headers, None, 25, request)

gitlab/tests/objects/test_commits.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from httmock import urlmatch, response, with_httmock
22

3-
from .test_projects import headers, TestProject
3+
from .mocks import headers
4+
from .test_projects import TestProject
45

56

67
@urlmatch(

gitlab/tests/objects/test_groups.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import unittest
2+
3+
from httmock import response, urlmatch, with_httmock
4+
5+
import gitlab
6+
from .mocks import * # noqa
7+
8+
9+
@urlmatch(scheme="http", netloc="localhost", path="/api/v4/groups/1", method="get")
10+
def resp_get_group(url, request):
11+
content = '{"name": "name", "id": 1, "path": "path"}'
12+
content = content.encode("utf-8")
13+
return response(200, content, headers, None, 5, request)
14+
15+
16+
@urlmatch(scheme="http", netloc="localhost", path="/api/v4/groups", method="post")
17+
def resp_create_group(url, request):
18+
content = '{"name": "name", "id": 1, "path": "path"}'
19+
content = content.encode("utf-8")
20+
return response(200, content, headers, None, 5, request)
21+
22+
23+
@urlmatch(
24+
scheme="http", netloc="localhost", path="/api/v4/groups/import", method="post",
25+
)
26+
def resp_create_import(url, request):
27+
"""Mock for Group import tests.
28+
29+
GitLab does not respond with import status for group imports.
30+
"""
31+
32+
content = """{
33+
"message": "202 Accepted"
34+
}"""
35+
content = content.encode("utf-8")
36+
return response(202, content, headers, None, 25, request)
37+
38+
39+
class TestGroup(unittest.TestCase):
40+
def setUp(self):
41+
self.gl = gitlab.Gitlab(
42+
"http://localhost",
43+
private_token="private_token",
44+
ssl_verify=True,
45+
api_version=4,
46+
)
47+
48+
@with_httmock(resp_get_group)
49+
def test_get_group(self):
50+
data = self.gl.groups.get(1)
51+
self.assertIsInstance(data, gitlab.v4.objects.Group)
52+
self.assertEqual(data.name, "name")
53+
self.assertEqual(data.path, "path")
54+
self.assertEqual(data.id, 1)
55+
56+
@with_httmock(resp_create_group)
57+
def test_create_group(self):
58+
name, path = "name", "path"
59+
data = self.gl.groups.create({"name": name, "path": path})
60+
self.assertIsInstance(data, gitlab.v4.objects.Group)
61+
self.assertEqual(data.name, name)
62+
self.assertEqual(data.path, path)
63+
64+
65+
class TestGroupExport(TestGroup):
66+
def setUp(self):
67+
super(TestGroupExport, self).setUp()
68+
self.group = self.gl.groups.get(1, lazy=True)
69+
70+
@with_httmock(resp_create_export)
71+
def test_create_group_export(self):
72+
export = self.group.exports.create()
73+
self.assertEqual(export.message, "202 Accepted")
74+
75+
@unittest.skip("GitLab API endpoint not implemented")
76+
@with_httmock(resp_create_export)
77+
def test_refresh_group_export_status(self):
78+
export = self.group.exports.create()
79+
export.refresh()
80+
self.assertEqual(export.export_status, "finished")
81+
82+
@with_httmock(resp_create_export, resp_download_export)
83+
def test_download_group_export(self):
84+
export = self.group.exports.create()
85+
download = export.download()
86+
self.assertIsInstance(download, bytes)
87+
self.assertEqual(download, binary_content)
88+
89+
90+
class TestGroupImport(TestGroup):
91+
@with_httmock(resp_create_import)
92+
def test_import_group(self):
93+
group_import = self.gl.groups.import_group("file", "api-group", "API Group")
94+
self.assertEqual(group_import["message"], "202 Accepted")
95+
96+
@unittest.skip("GitLab API endpoint not implemented")
97+
@with_httmock(resp_create_import)
98+
def test_refresh_group_import_status(self):
99+
group_import = self.group.imports.get()
100+
group_import.refresh()
101+
self.assertEqual(group_import.import_status, "finished")

gitlab/tests/objects/test_projects.py

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,7 @@
1010
from gitlab.v4.objects import * # noqa
1111
from httmock import HTTMock, urlmatch, response, with_httmock # noqa
1212

13-
14-
headers = {"content-type": "application/json"}
15-
binary_content = b"binary content"
16-
17-
18-
@urlmatch(
19-
scheme="http", netloc="localhost", path="/api/v4/projects/1/export", method="post",
20-
)
21-
def resp_create_export(url, request):
22-
"""Common mock for Project Export tests."""
23-
content = """{
24-
"message": "202 Accepted"
25-
}"""
26-
content = content.encode("utf-8")
27-
return response(202, content, headers, None, 25, request)
13+
from .mocks import * # noqa
2814

2915

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

5339

54-
@urlmatch(
55-
scheme="http",
56-
netloc="localhost",
57-
path="/api/v4/projects/1/export/download",
58-
method="get",
59-
)
60-
def resp_download_export(url, request):
61-
"""Mock for Project Export Download GET response."""
62-
headers = {"content-type": "application/octet-stream"}
63-
content = binary_content
64-
return response(200, content, headers, None, 25, request)
65-
66-
6740
@urlmatch(
6841
scheme="http", netloc="localhost", path="/api/v4/projects/import", method="post",
6942
)

gitlab/tests/test_gitlab.py

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -626,23 +626,6 @@ def resp_get_environment(url, request):
626626
self.assertIsInstance(statistics, ProjectIssuesStatistics)
627627
self.assertEqual(statistics.statistics["counts"]["all"], 20)
628628

629-
def test_groups(self):
630-
@urlmatch(
631-
scheme="http", netloc="localhost", path="/api/v4/groups/1", method="get"
632-
)
633-
def resp_get_group(url, request):
634-
headers = {"content-type": "application/json"}
635-
content = '{"name": "name", "id": 1, "path": "path"}'
636-
content = content.encode("utf-8")
637-
return response(200, content, headers, None, 5, request)
638-
639-
with HTTMock(resp_get_group):
640-
data = self.gl.groups.get(1)
641-
self.assertIsInstance(data, Group)
642-
self.assertEqual(data.name, "name")
643-
self.assertEqual(data.path, "path")
644-
self.assertEqual(data.id, 1)
645-
646629
def test_issues(self):
647630
@urlmatch(
648631
scheme="http", netloc="localhost", path="/api/v4/issues", method="get"

0 commit comments

Comments
 (0)