diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 34f39674d..a70e471be 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -15,6 +15,6 @@ jobs: action: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v4.0.1 + - uses: dessant/lock-threads@v5.0.0 with: process-only: 'issues' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 16c60ce59..a6afb1d7f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ default_language_version: repos: - repo: https://github.com/psf/black - rev: 23.10.1 + rev: 23.11.0 hooks: - id: black - repo: https://github.com/commitizen-tools/commitizen @@ -30,7 +30,7 @@ repos: - requests-toolbelt==1.0.0 files: 'gitlab/' - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.6.1 + rev: v1.7.0 hooks: - id: mypy args: [] @@ -47,6 +47,6 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/maxbrunet/pre-commit-renovate - rev: 37.46.0 + rev: 37.61.3 hooks: - id: renovate-config-validator diff --git a/docs/gl_objects/wikis.rst b/docs/gl_objects/wikis.rst index e98b9d443..08e2e78ab 100644 --- a/docs/gl_objects/wikis.rst +++ b/docs/gl_objects/wikis.rst @@ -54,3 +54,41 @@ Update a wiki page:: Delete a wiki page:: page.delete() + + +File uploads +============ + +Reference +--------- + +* v4 API: + + + :attr:`gitlab.v4.objects.ProjectWiki.upload` + + :attr:`gitlab.v4.objects.GrouptWiki.upload` + + +* Gitlab API for Projects: https://docs.gitlab.com/ee/api/wikis.html#upload-an-attachment-to-the-wiki-repository +* Gitlab API for Groups: https://docs.gitlab.com/ee/api/group_wikis.html#upload-an-attachment-to-the-wiki-repository + +Examples +-------- + +Upload a file into a project wiki using a filesystem path:: + + page = project.wikis.get(page_slug) + page.upload("filename.txt", filepath="/some/path/filename.txt") + +Upload a file into a project wiki with raw data:: + + page.upload("filename.txt", filedata="Raw data") + +Upload a file into a group wiki using a filesystem path:: + + page = group.wikis.get(page_slug) + page.upload("filename.txt", filepath="/some/path/filename.txt") + +Upload a file into a group wiki using raw data:: + + page.upload("filename.txt", filedata="Raw data") + diff --git a/gitlab/mixins.py b/gitlab/mixins.py index fe443a7a9..8ddf5031d 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -944,3 +944,73 @@ def promote(self, **kwargs: Any) -> Dict[str, Any]: if TYPE_CHECKING: assert not isinstance(result, requests.Response) return result + + +class UploadMixin(_RestObjectBase): + _id_attr: Optional[str] + _attrs: Dict[str, Any] + _module: ModuleType + _parent_attrs: Dict[str, Any] + _updated_attrs: Dict[str, Any] + _upload_path: str + manager: base.RESTManager + + def _get_upload_path(self) -> str: + """Formats _upload_path with object attributes. + + Returns: + The upload path + """ + if TYPE_CHECKING: + assert isinstance(self._upload_path, str) + data = self.attributes + return self._upload_path.format(**data) + + @cli.register_custom_action(("Project", "ProjectWiki"), ("filename", "filepath")) + @exc.on_http_error(exc.GitlabUploadError) + def upload( + self, + filename: str, + filedata: Optional[bytes] = None, + filepath: Optional[str] = None, + **kwargs: Any, + ) -> Dict[str, Any]: + """Upload the specified file. + + .. note:: + + Either ``filedata`` or ``filepath`` *MUST* be specified. + + Args: + filename: The name of the file being uploaded + filedata: The raw data of the file being uploaded + filepath: The path to a local file to upload (optional) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabUploadError: If the file upload fails + GitlabUploadError: If ``filedata`` and ``filepath`` are not + specified + GitlabUploadError: If both ``filedata`` and ``filepath`` are + specified + + Returns: + A ``dict`` with info on the uploaded file + """ + if filepath is None and filedata is None: + raise exc.GitlabUploadError("No file contents or path specified") + + if filedata is not None and filepath is not None: + raise exc.GitlabUploadError("File contents and file path specified") + + if filepath is not None: + with open(filepath, "rb") as f: + filedata = f.read() + + file_info = {"file": (filename, filedata)} + path = self._get_upload_path() + server_data = self.manager.gitlab.http_post(path, files=file_info, **kwargs) + + if TYPE_CHECKING: + assert isinstance(server_data, dict) + return server_data diff --git a/gitlab/v4/objects/projects.py b/gitlab/v4/objects/projects.py index 31a12fa31..e342e81e7 100644 --- a/gitlab/v4/objects/projects.py +++ b/gitlab/v4/objects/projects.py @@ -30,6 +30,7 @@ RefreshMixin, SaveMixin, UpdateMixin, + UploadMixin, ) from gitlab.types import RequiredOptional @@ -158,8 +159,11 @@ class ProjectGroupManager(ListMixin, RESTManager): _types = {"skip_groups": types.ArrayAttribute} -class Project(RefreshMixin, SaveMixin, ObjectDeleteMixin, RepositoryMixin, RESTObject): +class Project( + RefreshMixin, SaveMixin, ObjectDeleteMixin, RepositoryMixin, UploadMixin, RESTObject +): _repr_attr = "path_with_namespace" + _upload_path = "/projects/{id}/uploads" access_tokens: ProjectAccessTokenManager accessrequests: ProjectAccessRequestManager @@ -437,59 +441,6 @@ def housekeeping(self, **kwargs: Any) -> None: path = f"/projects/{self.encoded_id}/housekeeping" self.manager.gitlab.http_post(path, **kwargs) - # see #56 - add file attachment features - @cli.register_custom_action("Project", ("filename", "filepath")) - @exc.on_http_error(exc.GitlabUploadError) - def upload( - self, - filename: str, - filedata: Optional[bytes] = None, - filepath: Optional[str] = None, - **kwargs: Any, - ) -> Dict[str, Any]: - """Upload the specified file into the project. - - .. note:: - - Either ``filedata`` or ``filepath`` *MUST* be specified. - - Args: - filename: The name of the file being uploaded - filedata: The raw data of the file being uploaded - filepath: The path to a local file to upload (optional) - - Raises: - GitlabConnectionError: If the server cannot be reached - GitlabUploadError: If the file upload fails - GitlabUploadError: If ``filedata`` and ``filepath`` are not - specified - GitlabUploadError: If both ``filedata`` and ``filepath`` are - specified - - Returns: - A ``dict`` with the keys: - * ``alt`` - The alternate text for the upload - * ``url`` - The direct url to the uploaded file - * ``markdown`` - Markdown for the uploaded file - """ - if filepath is None and filedata is None: - raise exc.GitlabUploadError("No file contents or path specified") - - if filedata is not None and filepath is not None: - raise exc.GitlabUploadError("File contents and file path specified") - - if filepath is not None: - with open(filepath, "rb") as f: - filedata = f.read() - - url = f"/projects/{self.encoded_id}/uploads" - file_info = {"file": (filename, filedata)} - data = self.manager.gitlab.http_post(url, files=file_info, **kwargs) - - if TYPE_CHECKING: - assert isinstance(data, dict) - return {"alt": data["alt"], "url": data["url"], "markdown": data["markdown"]} - @cli.register_custom_action("Project") @exc.on_http_error(exc.GitlabRestoreError) def restore(self, **kwargs: Any) -> None: diff --git a/gitlab/v4/objects/wikis.py b/gitlab/v4/objects/wikis.py index 712b7339e..40f661e51 100644 --- a/gitlab/v4/objects/wikis.py +++ b/gitlab/v4/objects/wikis.py @@ -1,7 +1,7 @@ from typing import Any, cast, Union from gitlab.base import RESTManager, RESTObject -from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin +from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin, UploadMixin from gitlab.types import RequiredOptional __all__ = [ @@ -12,9 +12,10 @@ ] -class ProjectWiki(SaveMixin, ObjectDeleteMixin, RESTObject): +class ProjectWiki(SaveMixin, ObjectDeleteMixin, UploadMixin, RESTObject): _id_attr = "slug" _repr_attr = "slug" + _upload_path = "/projects/{project_id}/wikis/attachments" class ProjectWikiManager(CRUDMixin, RESTManager): @@ -33,9 +34,10 @@ def get( return cast(ProjectWiki, super().get(id=id, lazy=lazy, **kwargs)) -class GroupWiki(SaveMixin, ObjectDeleteMixin, RESTObject): +class GroupWiki(SaveMixin, ObjectDeleteMixin, UploadMixin, RESTObject): _id_attr = "slug" _repr_attr = "slug" + _upload_path = "/groups/{group_id}/wikis/attachments" class GroupWikiManager(CRUDMixin, RESTManager): diff --git a/requirements-lint.txt b/requirements-lint.txt index 7c02473ea..01f583a2c 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -1,13 +1,13 @@ -r requirements.txt argcomplete==2.0.0 -black==23.10.1 +black==23.11.0 commitizen==3.12.0 flake8==6.1.0 isort==5.12.0 -mypy==1.6.1 +mypy==1.7.0 pylint==3.0.2 pytest==7.4.3 -responses==0.24.0 +responses==0.24.1 types-PyYAML==6.0.12.12 types-requests==2.31.0.10 -types-setuptools==68.2.0.0 +types-setuptools==68.2.0.1 diff --git a/requirements-test.txt b/requirements-test.txt index ea7e1a77c..dbbce3d00 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -6,5 +6,5 @@ pytest-cov==4.1.0 pytest-github-actions-annotate-failures==0.2.0 pytest==7.4.3 PyYaml==6.0.1 -responses==0.24.0 +responses==0.24.1 wheel==0.41.3 diff --git a/tests/functional/api/test_wikis.py b/tests/functional/api/test_wikis.py index bcb5e1f89..0a84e5737 100644 --- a/tests/functional/api/test_wikis.py +++ b/tests/functional/api/test_wikis.py @@ -4,7 +4,7 @@ """ -def test_wikis(project): +def test_project_wikis(project): page = project.wikis.create({"title": "title/subtitle", "content": "test content"}) page.content = "update content" page.title = "subtitle" @@ -12,3 +12,51 @@ def test_wikis(project): page.save() page.delete() + + +def test_project_wiki_file_upload(project): + page = project.wikis.create( + {"title": "title/subtitle", "content": "test page content"} + ) + filename = "test.txt" + file_contents = "testing contents" + + uploaded_file = page.upload(filename, file_contents) + + link = uploaded_file["link"] + file_name = uploaded_file["file_name"] + file_path = uploaded_file["file_path"] + assert file_name == filename + assert file_path.startswith("uploads/") + assert file_path.endswith(f"/{filename}") + assert link["url"] == file_path + assert link["markdown"] == f"[{file_name}]({file_path})" + + +def test_group_wikis(group): + page = group.wikis.create({"title": "title/subtitle", "content": "test content"}) + page.content = "update content" + page.title = "subtitle" + + page.save() + + page.delete() + + +def test_group_wiki_file_upload(group): + page = group.wikis.create( + {"title": "title/subtitle", "content": "test page content"} + ) + filename = "test.txt" + file_contents = "testing contents" + + uploaded_file = page.upload(filename, file_contents) + + link = uploaded_file["link"] + file_name = uploaded_file["file_name"] + file_path = uploaded_file["file_path"] + assert file_name == filename + assert file_path.startswith("uploads/") + assert file_path.endswith(f"/{filename}") + assert link["url"] == file_path + assert link["markdown"] == f"[{file_name}]({file_path})" diff --git a/tests/unit/mixins/test_mixin_methods.py b/tests/unit/mixins/test_mixin_methods.py index fcd1c045b..fb6ded881 100644 --- a/tests/unit/mixins/test_mixin_methods.py +++ b/tests/unit/mixins/test_mixin_methods.py @@ -1,8 +1,10 @@ +from unittest.mock import mock_open, patch + import pytest import requests import responses -from gitlab import base +from gitlab import base, GitlabUploadError from gitlab import types as gl_types from gitlab.mixins import ( CreateMixin, @@ -15,6 +17,7 @@ SetMixin, UpdateMethod, UpdateMixin, + UploadMixin, ) @@ -502,3 +505,94 @@ class M(SetMixin, FakeManager): assert obj.key == "foo" assert obj.value == "bar" assert responses.assert_call_count(url, 1) is True + + +@responses.activate +def test_upload_mixin_with_filepath_and_filedata(gl): + class TestClass(UploadMixin, FakeObject): + _upload_path = "/tests/{id}/uploads" + + url = "http://localhost/api/v4/tests/42/uploads" + responses.add( + method=responses.POST, + url=url, + json={"id": 42, "file_name": "test.txt", "file_content": "testing contents"}, + status=200, + match=[responses.matchers.query_param_matcher({})], + ) + + mgr = FakeManager(gl) + obj = TestClass(mgr, {"id": 42}) + with pytest.raises( + GitlabUploadError, match="File contents and file path specified" + ): + obj.upload("test.txt", "testing contents", "/home/test.txt") + + +@responses.activate +def test_upload_mixin_without_filepath_nor_filedata(gl): + class TestClass(UploadMixin, FakeObject): + _upload_path = "/tests/{id}/uploads" + + url = "http://localhost/api/v4/tests/42/uploads" + responses.add( + method=responses.POST, + url=url, + json={"id": 42, "file_name": "test.txt", "file_content": "testing contents"}, + status=200, + match=[responses.matchers.query_param_matcher({})], + ) + + mgr = FakeManager(gl) + obj = TestClass(mgr, {"id": 42}) + with pytest.raises(GitlabUploadError, match="No file contents or path specified"): + obj.upload("test.txt") + + +@responses.activate +def test_upload_mixin_with_filedata(gl): + class TestClass(UploadMixin, FakeObject): + _upload_path = "/tests/{id}/uploads" + + url = "http://localhost/api/v4/tests/42/uploads" + responses.add( + method=responses.POST, + url=url, + json={"id": 42, "file_name": "test.txt", "file_content": "testing contents"}, + status=200, + match=[responses.matchers.query_param_matcher({})], + ) + + mgr = FakeManager(gl) + obj = TestClass(mgr, {"id": 42}) + res_only_data = obj.upload("test.txt", "testing contents") + assert obj._get_upload_path() == "/tests/42/uploads" + assert isinstance(res_only_data, dict) + assert res_only_data["file_name"] == "test.txt" + assert res_only_data["file_content"] == "testing contents" + assert responses.assert_call_count(url, 1) is True + + +@responses.activate +def test_upload_mixin_with_filepath(gl): + class TestClass(UploadMixin, FakeObject): + _upload_path = "/tests/{id}/uploads" + + url = "http://localhost/api/v4/tests/42/uploads" + responses.add( + method=responses.POST, + url=url, + json={"id": 42, "file_name": "test.txt", "file_content": "testing contents"}, + status=200, + match=[responses.matchers.query_param_matcher({})], + ) + + mgr = FakeManager(gl) + obj = TestClass(mgr, {"id": 42}) + with patch("builtins.open", mock_open(read_data="raw\nfile\ndata")): + res_only_path = obj.upload("test.txt", None, "/filepath") + assert obj._get_upload_path() == "/tests/42/uploads" + assert isinstance(res_only_path, dict) + assert res_only_path["file_name"] == "test.txt" + assert res_only_path["file_content"] == "testing contents" + assert responses.assert_call_count(url, 1) is True