Skip to content

Commit fce6896

Browse files
committed
feat(api): added UploadMixin
Added UploadMixin in mixin module Added UplaodMixin dependency for Project, ProjectWiki, GroupWiki Added api tests for wiki upload Added unit test for mixin Added docs sections to wikis.rst
1 parent d0546e0 commit fce6896

File tree

7 files changed

+212
-104
lines changed

7 files changed

+212
-104
lines changed

docs/gl_objects/wikis.rst

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,39 @@ Update a wiki page::
5454
Delete a wiki page::
5555

5656
page.delete()
57+
58+
59+
File uploads
60+
============
61+
62+
Reference
63+
---------
64+
65+
* v4 API:
66+
67+
+ :attr:`gitlab.v4.objects.ProjectWiki.upload`
68+
+ :attr:`gitlab.v4.objects.GrouptWiki.upload`
69+
70+
71+
* Gitlab API for Projects: https://docs.gitlab.com/ee/api/wikis.html#upload-an-attachment-to-the-wiki-repository
72+
* Gitlab API for Groups: https://docs.gitlab.com/ee/api/group_wikis.html#upload-an-attachment-to-the-wiki-repository
73+
74+
Examples
75+
--------
76+
77+
Upload a file into a project wiki using a filesystem path::
78+
79+
page.upload("filename.txt", filepath="/some/path/filename.txt")
80+
81+
Upload a file into a project wiki without a filesystem path::
82+
83+
page.upload("filename.txt", filedata="Raw data")
84+
85+
Upload a file into a project wiki using a filesystem path::
86+
87+
page.upload("filename.txt", filepath="/some/path/filename.txt")
88+
89+
Upload a file into a project wiki without a filesystem path::
90+
91+
page.upload("filename.txt", filedata="Raw data")
92+

gitlab/mixins.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,3 +944,74 @@ def promote(self, **kwargs: Any) -> Dict[str, Any]:
944944
if TYPE_CHECKING:
945945
assert not isinstance(result, requests.Response)
946946
return result
947+
948+
949+
class UploadMixin(_RestObjectBase):
950+
_id_attr: Optional[str]
951+
_attrs: Dict[str, Any]
952+
_module: ModuleType
953+
_parent_attrs: Dict[str, Any]
954+
_updated_attrs: Dict[str, Any]
955+
_upload_path: str
956+
manager: base.RESTManager
957+
958+
def _get_upload_path(self) -> str:
959+
"""Formats _upload_path with object attributes.
960+
961+
Returns:
962+
The upload path
963+
"""
964+
if TYPE_CHECKING:
965+
assert isinstance(self._upload_path, str)
966+
data = self.attributes
967+
return self._upload_path.format(**data)
968+
969+
@cli.register_custom_action(("Project", "ProjectWiki"), ("filename", "filepath"))
970+
@exc.on_http_error(exc.GitlabUploadError)
971+
def upload(
972+
self,
973+
filename: str,
974+
filedata: Optional[bytes] = None,
975+
filepath: Optional[str] = None,
976+
**kwargs: Any,
977+
) -> Dict[str, Any]:
978+
"""Upload the specified file.
979+
980+
.. note::
981+
982+
Either ``filedata`` or ``filepath`` *MUST* be specified.
983+
984+
Args:
985+
path: api endpoint where file is to be posted
986+
filename: The name of the file being uploaded
987+
filedata: The raw data of the file being uploaded
988+
filepath: The path to a local file to upload (optional)
989+
990+
Raises:
991+
GitlabAuthenticationError: If authentication is not correct
992+
GitlabUploadError: If the file upload fails
993+
GitlabUploadError: If ``filedata`` and ``filepath`` are not
994+
specified
995+
GitlabUploadError: If both ``filedata`` and ``filepath`` are
996+
specified
997+
998+
Returns:
999+
A ``dict`` with info on the uploaded file
1000+
"""
1001+
if filepath is None and filedata is None:
1002+
raise exc.GitlabUploadError("No file contents or path specified")
1003+
1004+
if filedata is not None and filepath is not None:
1005+
raise exc.GitlabUploadError("File contents and file path specified")
1006+
1007+
if filepath is not None:
1008+
with open(filepath, "rb") as f:
1009+
filedata = f.read()
1010+
1011+
file_info = {"file": (filename, filedata)}
1012+
path = self._get_upload_path()
1013+
server_data = self.manager.gitlab.http_post(path, files=file_info, **kwargs)
1014+
1015+
if TYPE_CHECKING:
1016+
assert isinstance(server_data, dict)
1017+
return server_data

gitlab/v4/objects/projects.py

Lines changed: 5 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
RefreshMixin,
3131
SaveMixin,
3232
UpdateMixin,
33+
UploadMixin,
3334
)
3435
from gitlab.types import RequiredOptional
3536

@@ -158,8 +159,11 @@ class ProjectGroupManager(ListMixin, RESTManager):
158159
_types = {"skip_groups": types.ArrayAttribute}
159160

160161

161-
class Project(RefreshMixin, SaveMixin, ObjectDeleteMixin, RepositoryMixin, RESTObject):
162+
class Project(
163+
RefreshMixin, SaveMixin, ObjectDeleteMixin, RepositoryMixin, UploadMixin, RESTObject
164+
):
162165
_repr_attr = "path_with_namespace"
166+
_upload_path = "/projects/{id}/uploads"
163167

164168
access_tokens: ProjectAccessTokenManager
165169
accessrequests: ProjectAccessRequestManager
@@ -437,59 +441,6 @@ def housekeeping(self, **kwargs: Any) -> None:
437441
path = f"/projects/{self.encoded_id}/housekeeping"
438442
self.manager.gitlab.http_post(path, **kwargs)
439443

440-
# see #56 - add file attachment features
441-
@cli.register_custom_action("Project", ("filename", "filepath"))
442-
@exc.on_http_error(exc.GitlabUploadError)
443-
def upload(
444-
self,
445-
filename: str,
446-
filedata: Optional[bytes] = None,
447-
filepath: Optional[str] = None,
448-
**kwargs: Any,
449-
) -> Dict[str, Any]:
450-
"""Upload the specified file into the project.
451-
452-
.. note::
453-
454-
Either ``filedata`` or ``filepath`` *MUST* be specified.
455-
456-
Args:
457-
filename: The name of the file being uploaded
458-
filedata: The raw data of the file being uploaded
459-
filepath: The path to a local file to upload (optional)
460-
461-
Raises:
462-
GitlabConnectionError: If the server cannot be reached
463-
GitlabUploadError: If the file upload fails
464-
GitlabUploadError: If ``filedata`` and ``filepath`` are not
465-
specified
466-
GitlabUploadError: If both ``filedata`` and ``filepath`` are
467-
specified
468-
469-
Returns:
470-
A ``dict`` with the keys:
471-
* ``alt`` - The alternate text for the upload
472-
* ``url`` - The direct url to the uploaded file
473-
* ``markdown`` - Markdown for the uploaded file
474-
"""
475-
if filepath is None and filedata is None:
476-
raise exc.GitlabUploadError("No file contents or path specified")
477-
478-
if filedata is not None and filepath is not None:
479-
raise exc.GitlabUploadError("File contents and file path specified")
480-
481-
if filepath is not None:
482-
with open(filepath, "rb") as f:
483-
filedata = f.read()
484-
485-
url = f"/projects/{self.encoded_id}/uploads"
486-
file_info = {"file": (filename, filedata)}
487-
data = self.manager.gitlab.http_post(url, files=file_info, **kwargs)
488-
489-
if TYPE_CHECKING:
490-
assert isinstance(data, dict)
491-
return {"alt": data["alt"], "url": data["url"], "markdown": data["markdown"]}
492-
493444
@cli.register_custom_action("Project")
494445
@exc.on_http_error(exc.GitlabRestoreError)
495446
def restore(self, **kwargs: Any) -> None:

gitlab/v4/objects/wikis.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from typing import Any, cast, Union
22

33
from gitlab.base import RESTManager, RESTObject
4-
from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin
4+
from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin, UploadMixin
55
from gitlab.types import RequiredOptional
66

77
__all__ = [
@@ -12,9 +12,10 @@
1212
]
1313

1414

15-
class ProjectWiki(SaveMixin, ObjectDeleteMixin, RESTObject):
15+
class ProjectWiki(SaveMixin, ObjectDeleteMixin, UploadMixin, RESTObject):
1616
_id_attr = "slug"
1717
_repr_attr = "slug"
18+
_upload_path = "/projects/{project_id}/wikis/attachments"
1819

1920

2021
class ProjectWikiManager(CRUDMixin, RESTManager):
@@ -33,9 +34,10 @@ def get(
3334
return cast(ProjectWiki, super().get(id=id, lazy=lazy, **kwargs))
3435

3536

36-
class GroupWiki(SaveMixin, ObjectDeleteMixin, RESTObject):
37+
class GroupWiki(SaveMixin, ObjectDeleteMixin, UploadMixin, RESTObject):
3738
_id_attr = "slug"
3839
_repr_attr = "slug"
40+
_upload_path = "/groups/{group_id}/wikis/attachments"
3941

4042

4143
class GroupWikiManager(CRUDMixin, RESTManager):

tests/functional/api/test_wikis.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,59 @@
44
"""
55

66

7-
def test_wikis(project):
7+
def test_project_wikis(project):
88
page = project.wikis.create({"title": "title/subtitle", "content": "test content"})
99
page.content = "update content"
1010
page.title = "subtitle"
1111

1212
page.save()
1313

1414
page.delete()
15+
16+
17+
def test_project_wiki_file_upload(project):
18+
page = project.wikis.create(
19+
{"title": "title/subtitle", "content": "test page content"}
20+
)
21+
filename = "test.txt"
22+
file_contents = "testing contents"
23+
24+
uploaded_file = page.upload(filename, file_contents)
25+
26+
link = uploaded_file["link"]
27+
file_name = uploaded_file["file_name"]
28+
file_path = uploaded_file["file_path"]
29+
assert file_name == filename
30+
assert file_path.startswith("uploads/")
31+
assert file_path.endswith(f"/{filename}")
32+
assert link["url"] == file_path
33+
assert link["markdown"] == f"[{file_name}]({file_path})"
34+
35+
36+
def test_group_wikis(group):
37+
page = group.wikis.create({"title": "title/subtitle", "content": "test content"})
38+
page.content = "update content"
39+
page.title = "subtitle"
40+
41+
page.save()
42+
43+
page.delete()
44+
45+
46+
def test_group_wiki_file_upload(group):
47+
page = group.wikis.create(
48+
{"title": "title/subtitle", "content": "test page content"}
49+
)
50+
filename = "test.txt"
51+
file_contents = "testing contents"
52+
53+
uploaded_file = page.upload(filename, file_contents)
54+
55+
link = uploaded_file["link"]
56+
file_name = uploaded_file["file_name"]
57+
file_path = uploaded_file["file_path"]
58+
assert file_name == filename
59+
assert file_path.startswith("uploads/")
60+
assert file_path.endswith(f"/{filename}")
61+
assert link["url"] == file_path
62+
assert link["markdown"] == f"[{file_name}]({file_path})"

tests/unit/mixins/test_mixin_methods.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
from unittest.mock import mock_open, patch
2+
13
import pytest
24
import requests
35
import responses
46

5-
from gitlab import base
7+
from gitlab import base, GitlabUploadError
68
from gitlab import types as gl_types
79
from gitlab.mixins import (
810
CreateMixin,
@@ -15,6 +17,7 @@
1517
SetMixin,
1618
UpdateMethod,
1719
UpdateMixin,
20+
UploadMixin,
1821
)
1922

2023

@@ -502,3 +505,44 @@ class M(SetMixin, FakeManager):
502505
assert obj.key == "foo"
503506
assert obj.value == "bar"
504507
assert responses.assert_call_count(url, 1) is True
508+
509+
510+
@responses.activate
511+
def test_upload_mixin(gl):
512+
class TestClass(UploadMixin, FakeObject):
513+
_upload_path = "/tests/{id}/uploads"
514+
515+
url = "http://localhost/api/v4/tests/42/uploads"
516+
responses.add(
517+
method=responses.POST,
518+
url=url,
519+
json={"id": 42, "file_name": "test.txt", "file_content": "testing contents"},
520+
status=200,
521+
match=[responses.matchers.query_param_matcher({})],
522+
)
523+
524+
mgr = FakeManager(gl)
525+
obj = TestClass(mgr, {"id": 42})
526+
527+
with pytest.raises(
528+
GitlabUploadError, match="File contents and file path specified"
529+
):
530+
obj.upload("test.txt", "testing contents", "/home/test.txt")
531+
532+
with pytest.raises(GitlabUploadError, match="No file contents or path specified"):
533+
obj.upload("test.txt")
534+
535+
res_only_data = obj.upload("test.txt", "testing contents")
536+
assert obj._get_upload_path() == "/tests/42/uploads"
537+
assert isinstance(res_only_data, dict)
538+
assert res_only_data["file_name"] == "test.txt"
539+
assert res_only_data["file_content"] == "testing contents"
540+
assert responses.assert_call_count(url, 1) is True
541+
542+
with patch("builtins.open", mock_open(read_data="raw\nfile\ndata")):
543+
res_only_path = obj.upload("test.txt", None, "/filepath")
544+
assert obj._get_upload_path() == "/tests/42/uploads"
545+
assert isinstance(res_only_path, dict)
546+
assert res_only_path["file_name"] == "test.txt"
547+
assert res_only_path["file_content"] == "testing contents"
548+
assert responses.assert_call_count(url, 2) is True

0 commit comments

Comments
 (0)