Repository: python-gitlab/python-gitlab

Issue:
Support for group import/export API
I was checking exports for work and realized 12.8 introduced (experimental) support for Group import/export. Cool! :)

https://docs.gitlab.com/ce/api/group_import_export.html



Discussion:

Resolving patch:
diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py
--- a/gitlab/exceptions.py
+++ b/gitlab/exceptions.py
@@ -209,6 +209,10 @@ class GitlabAttachFileError(GitlabOperationError):
     pass
 
 
+class GitlabImportError(GitlabOperationError):
+    pass
+
+
 class GitlabCherryPickError(GitlabOperationError):
     pass
 
diff --git a/gitlab/mixins.py b/gitlab/mixins.py
--- a/gitlab/mixins.py
+++ b/gitlab/mixins.py
@@ -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")
diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py
--- a/gitlab/v4/objects.py
+++ b/gitlab/v4/objects.py
@@ -991,6 +991,26 @@ class GroupEpicManager(CRUDMixin, RESTManager):
     _types = {"labels": types.ListAttribute}
 
 
+class GroupExport(DownloadMixin, RESTObject):
+    _id_attr = None
+
+
+class GroupExportManager(GetWithoutIdMixin, CreateMixin, RESTManager):
+    _path = "/groups/%(group_id)s/export"
+    _obj_cls = GroupExport
+    _from_parent_attrs = {"group_id": "id"}
+
+
+class GroupImport(RESTObject):
+    _id_attr = None
+
+
+class GroupImportManager(GetWithoutIdMixin, RESTManager):
+    _path = "/groups/%(group_id)s/import"
+    _obj_cls = GroupImport
+    _from_parent_attrs = {"group_id": "id"}
+
+
 class GroupIssue(RESTObject):
     pass
 
@@ -1290,7 +1310,9 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject):
         ("badges", "GroupBadgeManager"),
         ("boards", "GroupBoardManager"),
         ("customattributes", "GroupCustomAttributeManager"),
+        ("exports", "GroupExportManager"),
         ("epics", "GroupEpicManager"),
+        ("imports", "GroupImportManager"),
         ("issues", "GroupIssueManager"),
         ("labels", "GroupLabelManager"),
         ("members", "GroupMemberManager"),
@@ -1431,6 +1453,34 @@ class GroupManager(CRUDMixin, RESTManager):
         ),
     )
 
+    @exc.on_http_error(exc.GitlabImportError)
+    def import_group(self, file, path, name, parent_id=None, **kwargs):
+        """Import a group from an archive file.
+
+        Args:
+            file: Data or file object containing the group
+            path (str): The path for the new group to be imported.
+            name (str): The name for the new group.
+            parent_id (str): ID of a parent group that the group will
+                be imported into.
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabImportError: If the server failed to perform the request
+
+        Returns:
+            dict: A representation of the import status.
+        """
+        files = {"file": ("file.tar.gz", file)}
+        data = {"path": path, "name": name}
+        if parent_id is not None:
+            data["parent_id"] = parent_id
+
+        return self.gitlab.http_post(
+            "/groups/import", post_data=data, files=files, **kwargs
+        )
+
 
 class Hook(ObjectDeleteMixin, RESTObject):
     _url = "/hooks"
@@ -4097,36 +4147,9 @@ class ProjectWikiManager(CRUDMixin, RESTManager):
     _list_filters = ("with_content",)
 
 
-class ProjectExport(RefreshMixin, RESTObject):
+class ProjectExport(DownloadMixin, RefreshMixin, RESTObject):
     _id_attr = None
 
-    @cli.register_custom_action("ProjectExport")
-    @exc.on_http_error(exc.GitlabGetError)
-    def download(self, streamed=False, action=None, chunk_size=1024, **kwargs):
-        """Download the archive of a project 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 = "/projects/%s/export/download" % self.project_id
-        result = self.manager.gitlab.http_get(
-            path, streamed=streamed, raw=True, **kwargs
-        )
-        return utils.response_content(result, streamed, action, chunk_size)
-
 
 class ProjectExportManager(GetWithoutIdMixin, CreateMixin, RESTManager):
     _path = "/projects/%(project_id)s/export"


Test patch:
diff --git a/gitlab/tests/objects/mocks.py b/gitlab/tests/objects/mocks.py
new file mode 100644
--- /dev/null
+++ b/gitlab/tests/objects/mocks.py
@@ -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)
diff --git a/gitlab/tests/objects/test_commits.py b/gitlab/tests/objects/test_commits.py
--- a/gitlab/tests/objects/test_commits.py
+++ b/gitlab/tests/objects/test_commits.py
@@ -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(
diff --git a/gitlab/tests/objects/test_groups.py b/gitlab/tests/objects/test_groups.py
new file mode 100644
--- /dev/null
+++ b/gitlab/tests/objects/test_groups.py
@@ -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")
diff --git a/gitlab/tests/objects/test_projects.py b/gitlab/tests/objects/test_projects.py
--- a/gitlab/tests/objects/test_projects.py
+++ b/gitlab/tests/objects/test_projects.py
@@ -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(
@@ -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",
 )
diff --git a/gitlab/tests/test_gitlab.py b/gitlab/tests/test_gitlab.py
--- a/gitlab/tests/test_gitlab.py
+++ b/gitlab/tests/test_gitlab.py
@@ -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"
diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py
--- a/tools/python_test_v4.py
+++ b/tools/python_test_v4.py
@@ -380,6 +380,33 @@
 # g_l.delete()
 # assert len(group1.labels.list()) == 0
 
+
+# group import/export
+export = group1.exports.create()
+assert export.message == "202 Accepted"
+
+# We cannot check for export_status with group export API
+time.sleep(10)
+
+import_archive = "/tmp/gitlab-group-export.tgz"
+import_path = "imported_group"
+import_name = "Imported Group"
+
+with open(import_archive, "wb") as f:
+    export.download(streamed=True, action=f.write)
+
+with open(import_archive, "rb") as f:
+    output = gl.groups.import_group(f, import_path, import_name)
+assert output["message"] == "202 Accepted"
+
+# We cannot check for returned ID with group import API
+time.sleep(10)
+group_import = gl.groups.get(import_path)
+
+assert group_import.path == import_path
+assert group_import.name == import_name
+
+
 # hooks
 hook = gl.hooks.create({"url": "http://whatever.com"})
 assert len(gl.hooks.list()) == 1