Skip to content

feat(api): add support for wiki attachments #2722

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Nov 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/lock.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: []
Expand All @@ -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
38 changes: 38 additions & 0 deletions docs/gl_objects/wikis.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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")

70 changes: 70 additions & 0 deletions gitlab/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
59 changes: 5 additions & 54 deletions gitlab/v4/objects/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
RefreshMixin,
SaveMixin,
UpdateMixin,
UploadMixin,
)
from gitlab.types import RequiredOptional

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 5 additions & 3 deletions gitlab/v4/objects/wikis.py
Original file line number Diff line number Diff line change
@@ -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__ = [
Expand 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):
Expand All @@ -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):
Expand Down
8 changes: 4 additions & 4 deletions requirements-lint.txt
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion requirements-test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
50 changes: 49 additions & 1 deletion tests/functional/api/test_wikis.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,59 @@
"""


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"

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})"
Loading