diff options
| -rw-r--r-- | docs/gl_objects/groups.rst | 56 | ||||
| -rw-r--r-- | gitlab/exceptions.py | 4 | ||||
| -rw-r--r-- | gitlab/mixins.py | 29 | ||||
| -rw-r--r-- | gitlab/tests/objects/mocks.py | 35 | ||||
| -rw-r--r-- | gitlab/tests/objects/test_commits.py | 3 | ||||
| -rw-r--r-- | gitlab/tests/objects/test_groups.py | 101 | ||||
| -rw-r--r-- | gitlab/tests/objects/test_projects.py | 29 | ||||
| -rw-r--r-- | gitlab/tests/test_gitlab.py | 17 | ||||
| -rw-r--r-- | gitlab/v4/objects.py | 79 | ||||
| -rw-r--r-- | tools/python_test_v4.py | 27 | 
10 files changed, 306 insertions, 74 deletions
diff --git a/docs/gl_objects/groups.rst b/docs/gl_objects/groups.rst index 0bc00d9..d3e4d92 100644 --- a/docs/gl_objects/groups.rst +++ b/docs/gl_objects/groups.rst @@ -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  ========= diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py index d6791f2..099a901 100644 --- 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 index dde11d0..9c00c32 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -443,6 +443,35 @@ class AccessRequestMixin(object):          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/tests/objects/mocks.py b/gitlab/tests/objects/mocks.py new file mode 100644 index 0000000..e051339 --- /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 index 23a4285..7e7c3b4 100644 --- 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 index 0000000..075d915 --- /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 index d87f759..1c2347a 100644 --- a/gitlab/tests/objects/test_projects.py +++ b/gitlab/tests/objects/test_projects.py @@ -10,21 +10,7 @@ from gitlab import *  # noqa  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( @@ -52,19 +38,6 @@ def resp_export_status(url, 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",  )  def resp_import_project(url, request): diff --git a/gitlab/tests/test_gitlab.py b/gitlab/tests/test_gitlab.py index d104c7d..8261cc6 100644 --- a/gitlab/tests/test_gitlab.py +++ b/gitlab/tests/test_gitlab.py @@ -626,23 +626,6 @@ class TestGitlab(unittest.TestCase):              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/gitlab/v4/objects.py b/gitlab/v4/objects.py index 0b95944..9855196 100644 --- 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"), @@ -1454,6 +1476,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" @@ -4120,36 +4170,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" diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py index e0cb3a6..076329b 100644 --- a/tools/python_test_v4.py +++ b/tools/python_test_v4.py @@ -380,6 +380,33 @@ assert len(group1.variables.list()) == 0  # 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  | 
