Skip to content

feat(objects): add support for generic packages API #1460

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
Jun 1, 2021
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
14 changes: 14 additions & 0 deletions docs/cli-usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,20 @@ Delete a specific project package by id:

$ gitlab -v project-package delete --id 1 --project-id 3

Upload a generic package to a project:

.. code-block:: console

$ gitlab generic-package upload --project-id 1 --package-name hello-world \
--package-version v1.0.0 --file-name hello.tar.gz --path /path/to/hello.tar.gz

Download a project's generic package:

.. code-block:: console

$ gitlab generic-package download --project-id 1 --package-name hello-world \
--package-version v1.0.0 --file-name hello.tar.gz > /path/to/hello.tar.gz

Get a list of issues for this project:

.. code-block:: console
Expand Down
43 changes: 42 additions & 1 deletion docs/gl_objects/packages.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ Packages
########

Packages allow you to utilize GitLab as a private repository for a variety
of common package managers.
of common package managers, as well as GitLab's generic package registry.

Project Packages
=====================
Expand Down Expand Up @@ -88,3 +88,44 @@ List package files for package in project::

package = project.packages.get(1)
package_files = package.package_files.list()

Generic Packages
================

You can use python-gitlab to upload and download generic packages.

Reference
---------

* v4 API:

+ :class:`gitlab.v4.objects.GenericPackage`
+ :class:`gitlab.v4.objects.GenericPackageManager`
+ :attr:`gitlab.v4.objects.Project.generic_packages`

* GitLab API: https://docs.gitlab.com/ee/user/packages/generic_packages

Examples
--------

Upload a generic package to a project::

project = gl.projects.get(1, lazy=True)
package = project.generic_packages.upload(
package_name="hello-world",
package_version="v1.0.0",
file_name="hello.tar.gz",
path="/path/to/local/hello.tar.gz"
)

Download a project's generic package::

project = gl.projects.get(1, lazy=True)
package = project.generic_packages.download(
package_name="hello-world",
package_version="v1.0.0",
file_name="hello.tar.gz",
)

.. hint:: You can use the Packages API described above to find packages and
retrieve the metadata you need download them.
74 changes: 45 additions & 29 deletions gitlab/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,15 +393,9 @@ def enable_debug(self) -> None:
requests_log.setLevel(logging.DEBUG)
requests_log.propagate = True

def _create_headers(self, content_type: Optional[str] = None) -> Dict[str, Any]:
request_headers = self.headers.copy()
if content_type is not None:
request_headers["Content-type"] = content_type
return request_headers

def _get_session_opts(self, content_type: str) -> Dict[str, Any]:
def _get_session_opts(self) -> Dict[str, Any]:
return {
"headers": self._create_headers(content_type),
"headers": self.headers.copy(),
"auth": self._http_auth,
"timeout": self.timeout,
"verify": self.ssl_verify,
Expand Down Expand Up @@ -441,12 +435,43 @@ def _check_redirects(self, result: requests.Response) -> None:
if location and location.startswith("https://"):
raise gitlab.exceptions.RedirectError(REDIRECT_MSG)

def _prepare_send_data(
self,
files: Optional[Dict[str, Any]] = None,
post_data: Optional[Dict[str, Any]] = None,
raw: bool = False,
) -> Tuple[
Optional[Dict[str, Any]],
Optional[Union[Dict[str, Any], MultipartEncoder]],
str,
]:
if files:
if post_data is None:
post_data = {}
else:
# booleans does not exists for data (neither for MultipartEncoder):
# cast to string int to avoid: 'bool' object has no attribute 'encode'
for k, v in post_data.items():
if isinstance(v, bool):
post_data[k] = str(int(v))
post_data["file"] = files.get("file")
post_data["avatar"] = files.get("avatar")

data = MultipartEncoder(post_data)
return (None, data, data.content_type)

if raw and post_data:
return (None, post_data, "application/octet-stream")

return (post_data, None, "application/json")

def http_request(
self,
verb: str,
path: str,
query_data: Optional[Dict[str, Any]] = None,
post_data: Optional[Dict[str, Any]] = None,
raw: bool = False,
streamed: bool = False,
files: Optional[Dict[str, Any]] = None,
timeout: Optional[float] = None,
Expand All @@ -464,7 +489,8 @@ def http_request(
'http://whatever/v4/api/projecs')
query_data (dict): Data to send as query parameters
post_data (dict): Data to send in the body (will be converted to
json)
json by default)
raw (bool): If True, do not convert post_data to json
streamed (bool): Whether the data should be streamed
files (dict): The files to send to the server
timeout (float): The timeout, in seconds, for the request
Expand Down Expand Up @@ -503,7 +529,7 @@ def http_request(
else:
utils.copy_dict(params, kwargs)

opts = self._get_session_opts(content_type="application/json")
opts = self._get_session_opts()

verify = opts.pop("verify")
opts_timeout = opts.pop("timeout")
Expand All @@ -512,23 +538,8 @@ def http_request(
timeout = opts_timeout

# We need to deal with json vs. data when uploading files
if files:
json = None
if post_data is None:
post_data = {}
else:
# booleans does not exists for data (neither for MultipartEncoder):
# cast to string int to avoid: 'bool' object has no attribute 'encode'
for k, v in post_data.items():
if isinstance(v, bool):
post_data[k] = str(int(v))
post_data["file"] = files.get("file")
post_data["avatar"] = files.get("avatar")
data = MultipartEncoder(post_data)
opts["headers"]["Content-type"] = data.content_type
else:
json = post_data
data = None
json, data, content_type = self._prepare_send_data(files, post_data, raw)
opts["headers"]["Content-type"] = content_type

# Requests assumes that `.` should not be encoded as %2E and will make
# changes to urls using this encoding. Using a prepped request we can
Expand Down Expand Up @@ -683,6 +694,7 @@ def http_post(
path: str,
query_data: Optional[Dict[str, Any]] = None,
post_data: Optional[Dict[str, Any]] = None,
raw: bool = False,
files: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> Union[Dict[str, Any], requests.Response]:
Expand All @@ -693,7 +705,8 @@ def http_post(
'http://whatever/v4/api/projecs')
query_data (dict): Data to send as query parameters
post_data (dict): Data to send in the body (will be converted to
json)
json by default)
raw (bool): If True, do not convert post_data to json
files (dict): The files to send to the server
**kwargs: Extra options to send to the server (e.g. sudo)

Expand Down Expand Up @@ -730,6 +743,7 @@ def http_put(
path: str,
query_data: Optional[Dict[str, Any]] = None,
post_data: Optional[Dict[str, Any]] = None,
raw: bool = False,
files: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> Union[Dict[str, Any], requests.Response]:
Expand All @@ -740,7 +754,8 @@ def http_put(
'http://whatever/v4/api/projecs')
query_data (dict): Data to send as query parameters
post_data (dict): Data to send in the body (will be converted to
json)
json by default)
raw (bool): If True, do not convert post_data to json
files (dict): The files to send to the server
**kwargs: Extra options to send to the server (e.g. sudo)

Expand All @@ -760,6 +775,7 @@ def http_put(
query_data=query_data,
post_data=post_data,
files=files,
raw=raw,
**kwargs,
)
try:
Expand Down
114 changes: 114 additions & 0 deletions gitlab/v4/objects/packages.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
from pathlib import Path
from typing import Any, Callable, Optional, TYPE_CHECKING, Union

import requests

from gitlab import cli
from gitlab import exceptions as exc
from gitlab import utils
from gitlab.base import RESTManager, RESTObject
from gitlab.mixins import DeleteMixin, GetMixin, ListMixin, ObjectDeleteMixin

__all__ = [
"GenericPackage",
"GenericPackageManager",
"GroupPackage",
"GroupPackageManager",
"ProjectPackage",
Expand All @@ -11,6 +21,110 @@
]


class GenericPackage(RESTObject):
_id_attr = "package_name"


class GenericPackageManager(RESTManager):
_path = "/projects/%(project_id)s/packages/generic"
_obj_cls = GenericPackage
_from_parent_attrs = {"project_id": "id"}

@cli.register_custom_action(
"GenericPackageManager",
("package_name", "package_version", "file_name", "path"),
)
@exc.on_http_error(exc.GitlabUploadError)
def upload(
self,
package_name: str,
package_version: str,
file_name: str,
path: Union[str, Path],
**kwargs,
) -> GenericPackage:
"""Upload a file as a generic package.

Args:
package_name (str): The package name. Must follow generic package
name regex rules
package_version (str): The package version. Must follow semantic
version regex rules
file_name (str): The name of the file as uploaded in the registry
path (str): The path to a local file to upload

Raises:
GitlabConnectionError: If the server cannot be reached
GitlabUploadError: If the file upload fails
GitlabUploadError: If ``filepath`` cannot be read

Returns:
GenericPackage: An object storing the metadata of the uploaded package.
"""

try:
with open(path, "rb") as f:
file_data = f.read()
except OSError:
raise exc.GitlabUploadError(f"Failed to read package file {path}")

url = f"{self._computed_path}/{package_name}/{package_version}/{file_name}"
server_data = self.gitlab.http_put(url, post_data=file_data, raw=True, **kwargs)

return self._obj_cls(
self,
attrs={
"package_name": package_name,
"package_version": package_version,
"file_name": file_name,
"path": path,
"message": server_data["message"],
},
)

@cli.register_custom_action(
"GenericPackageManager",
("package_name", "package_version", "file_name"),
)
@exc.on_http_error(exc.GitlabGetError)
def download(
self,
package_name: str,
package_version: str,
file_name: str,
streamed: bool = False,
action: Optional[Callable] = None,
chunk_size: int = 1024,
**kwargs: Any,
) -> Optional[bytes]:
"""Download a generic package.

Args:
package_name (str): The package name.
package_version (str): The package version.
file_name (str): The name of the file in the registry
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 package content if streamed is False, None otherwise
"""
path = f"{self._computed_path}/{package_name}/{package_version}/{file_name}"
result = self.gitlab.http_get(path, streamed=streamed, raw=True, **kwargs)
if TYPE_CHECKING:
assert isinstance(result, requests.Response)
return utils.response_content(result, streamed, action, chunk_size)


class GroupPackage(RESTObject):
pass

Expand Down
3 changes: 2 additions & 1 deletion gitlab/v4/objects/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
from .milestones import ProjectMilestoneManager # noqa: F401
from .notes import ProjectNoteManager # noqa: F401
from .notification_settings import ProjectNotificationSettingsManager # noqa: F401
from .packages import ProjectPackageManager # noqa: F401
from .packages import GenericPackageManager, ProjectPackageManager # noqa: F401
from .pages import ProjectPagesDomainManager # noqa: F401
from .pipelines import ( # noqa: F401
ProjectPipeline,
Expand Down Expand Up @@ -124,6 +124,7 @@ class Project(RefreshMixin, SaveMixin, ObjectDeleteMixin, RepositoryMixin, RESTO
("exports", "ProjectExportManager"),
("files", "ProjectFileManager"),
("forks", "ProjectForkManager"),
("generic_packages", "GenericPackageManager"),
("hooks", "ProjectHookManager"),
("keys", "ProjectKeyManager"),
("imports", "ProjectImportManager"),
Expand Down
Loading