Skip to content

Commit 79d88bd

Browse files
nejchJohnVillalovos
authored andcommitted
feat(objects): add support for generic packages API
1 parent fbbc0d4 commit 79d88bd

File tree

8 files changed

+378
-33
lines changed

8 files changed

+378
-33
lines changed

docs/cli-usage.rst

+14
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,20 @@ Delete a specific project package by id:
319319
320320
$ gitlab -v project-package delete --id 1 --project-id 3
321321
322+
Upload a generic package to a project:
323+
324+
.. code-block:: console
325+
326+
$ gitlab generic-package upload --project-id 1 --package-name hello-world \
327+
--package-version v1.0.0 --file-name hello.tar.gz --path /path/to/hello.tar.gz
328+
329+
Download a project's generic package:
330+
331+
.. code-block:: console
332+
333+
$ gitlab generic-package download --project-id 1 --package-name hello-world \
334+
--package-version v1.0.0 --file-name hello.tar.gz > /path/to/hello.tar.gz
335+
322336
Get a list of issues for this project:
323337

324338
.. code-block:: console

docs/gl_objects/packages.rst

+42-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ Packages
33
########
44

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

88
Project Packages
99
=====================
@@ -88,3 +88,44 @@ List package files for package in project::
8888

8989
package = project.packages.get(1)
9090
package_files = package.package_files.list()
91+
92+
Generic Packages
93+
================
94+
95+
You can use python-gitlab to upload and download generic packages.
96+
97+
Reference
98+
---------
99+
100+
* v4 API:
101+
102+
+ :class:`gitlab.v4.objects.GenericPackage`
103+
+ :class:`gitlab.v4.objects.GenericPackageManager`
104+
+ :attr:`gitlab.v4.objects.Project.generic_packages`
105+
106+
* GitLab API: https://docs.gitlab.com/ee/user/packages/generic_packages
107+
108+
Examples
109+
--------
110+
111+
Upload a generic package to a project::
112+
113+
project = gl.projects.get(1, lazy=True)
114+
package = project.generic_packages.upload(
115+
package_name="hello-world",
116+
package_version="v1.0.0",
117+
file_name="hello.tar.gz",
118+
path="/path/to/local/hello.tar.gz"
119+
)
120+
121+
Download a project's generic package::
122+
123+
project = gl.projects.get(1, lazy=True)
124+
package = project.generic_packages.download(
125+
package_name="hello-world",
126+
package_version="v1.0.0",
127+
file_name="hello.tar.gz",
128+
)
129+
130+
.. hint:: You can use the Packages API described above to find packages and
131+
retrieve the metadata you need download them.

gitlab/client.py

+41-29
Original file line numberDiff line numberDiff line change
@@ -394,15 +394,9 @@ def enable_debug(self) -> None:
394394
requests_log.setLevel(logging.DEBUG)
395395
requests_log.propagate = True
396396

397-
def _create_headers(self, content_type: Optional[str] = None) -> Dict[str, Any]:
398-
request_headers = self.headers.copy()
399-
if content_type is not None:
400-
request_headers["Content-type"] = content_type
401-
return request_headers
402-
403-
def _get_session_opts(self, content_type: str) -> Dict[str, Any]:
397+
def _get_session_opts(self) -> Dict[str, Any]:
404398
return {
405-
"headers": self._create_headers(content_type),
399+
"headers": self.headers.copy(),
406400
"auth": self._http_auth,
407401
"timeout": self.timeout,
408402
"verify": self.ssl_verify,
@@ -442,12 +436,39 @@ def _check_redirects(self, result: requests.Response) -> None:
442436
if location and location.startswith("https://"):
443437
raise gitlab.exceptions.RedirectError(REDIRECT_MSG)
444438

439+
def _prepare_send_data(
440+
self,
441+
files: Dict[str, Any] = None,
442+
post_data: Dict[str, Any] = None,
443+
raw: Optional[bool] = False,
444+
) -> Tuple:
445+
if files:
446+
if post_data is None:
447+
post_data = {}
448+
else:
449+
# booleans does not exists for data (neither for MultipartEncoder):
450+
# cast to string int to avoid: 'bool' object has no attribute 'encode'
451+
for k, v in post_data.items():
452+
if isinstance(v, bool):
453+
post_data[k] = str(int(v))
454+
post_data["file"] = files.get("file")
455+
post_data["avatar"] = files.get("avatar")
456+
457+
data = MultipartEncoder(post_data)
458+
return (None, data, data.content_type)
459+
460+
if raw and post_data:
461+
return (None, post_data, "application/octet-stream")
462+
463+
return (post_data, None, "application/json")
464+
445465
def http_request(
446466
self,
447467
verb: str,
448468
path: str,
449469
query_data: Optional[Dict[str, Any]] = None,
450470
post_data: Optional[Dict[str, Any]] = None,
471+
raw: Optional[bool] = False,
451472
streamed: bool = False,
452473
files: Optional[Dict[str, Any]] = None,
453474
timeout: Optional[float] = None,
@@ -465,7 +486,8 @@ def http_request(
465486
'http://whatever/v4/api/projecs')
466487
query_data (dict): Data to send as query parameters
467488
post_data (dict): Data to send in the body (will be converted to
468-
json)
489+
json by default)
490+
raw (bool): If True, do not convert post_data to json
469491
streamed (bool): Whether the data should be streamed
470492
files (dict): The files to send to the server
471493
timeout (float): The timeout, in seconds, for the request
@@ -504,7 +526,7 @@ def http_request(
504526
else:
505527
utils.copy_dict(params, kwargs)
506528

507-
opts = self._get_session_opts(content_type="application/json")
529+
opts = self._get_session_opts()
508530

509531
verify = opts.pop("verify")
510532
opts_timeout = opts.pop("timeout")
@@ -513,23 +535,8 @@ def http_request(
513535
timeout = opts_timeout
514536

515537
# We need to deal with json vs. data when uploading files
516-
if files:
517-
json = None
518-
if post_data is None:
519-
post_data = {}
520-
else:
521-
# booleans does not exists for data (neither for MultipartEncoder):
522-
# cast to string int to avoid: 'bool' object has no attribute 'encode'
523-
for k, v in post_data.items():
524-
if isinstance(v, bool):
525-
post_data[k] = str(int(v))
526-
post_data["file"] = files.get("file")
527-
post_data["avatar"] = files.get("avatar")
528-
data = MultipartEncoder(post_data)
529-
opts["headers"]["Content-type"] = data.content_type
530-
else:
531-
json = post_data
532-
data = None
538+
json, data, content_type = self._prepare_send_data(files, post_data, raw)
539+
opts["headers"]["Content-type"] = content_type
533540

534541
# Requests assumes that `.` should not be encoded as %2E and will make
535542
# changes to urls using this encoding. Using a prepped request we can
@@ -684,6 +691,7 @@ def http_post(
684691
path: str,
685692
query_data: Optional[Dict[str, Any]] = None,
686693
post_data: Optional[Dict[str, Any]] = None,
694+
raw: Optional[bool] = False,
687695
files: Optional[Dict[str, Any]] = None,
688696
**kwargs: Any,
689697
) -> Union[Dict[str, Any], requests.Response]:
@@ -694,7 +702,8 @@ def http_post(
694702
'http://whatever/v4/api/projecs')
695703
query_data (dict): Data to send as query parameters
696704
post_data (dict): Data to send in the body (will be converted to
697-
json)
705+
json by default)
706+
raw (bool): If True, do not convert post_data to json
698707
files (dict): The files to send to the server
699708
**kwargs: Extra options to send to the server (e.g. sudo)
700709
@@ -731,6 +740,7 @@ def http_put(
731740
path: str,
732741
query_data: Optional[Dict[str, Any]] = None,
733742
post_data: Optional[Dict[str, Any]] = None,
743+
raw: Optional[bool] = False,
734744
files: Optional[Dict[str, Any]] = None,
735745
**kwargs: Any,
736746
) -> Union[Dict[str, Any], requests.Response]:
@@ -741,7 +751,8 @@ def http_put(
741751
'http://whatever/v4/api/projecs')
742752
query_data (dict): Data to send as query parameters
743753
post_data (dict): Data to send in the body (will be converted to
744-
json)
754+
json by default)
755+
raw (bool): If True, do not convert post_data to json
745756
files (dict): The files to send to the server
746757
**kwargs: Extra options to send to the server (e.g. sudo)
747758
@@ -761,6 +772,7 @@ def http_put(
761772
query_data=query_data,
762773
post_data=post_data,
763774
files=files,
775+
raw=raw,
764776
**kwargs,
765777
)
766778
try:

gitlab/v4/objects/packages.py

+114
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
1+
from pathlib import Path
2+
from typing import Any, Callable, Optional, TYPE_CHECKING, Union
3+
4+
import requests
5+
6+
from gitlab import cli
7+
from gitlab import exceptions as exc
8+
from gitlab import utils
19
from gitlab.base import RESTManager, RESTObject
210
from gitlab.mixins import DeleteMixin, GetMixin, ListMixin, ObjectDeleteMixin
311

412
__all__ = [
13+
"GenericPackage",
14+
"GenericPackageManager",
515
"GroupPackage",
616
"GroupPackageManager",
717
"ProjectPackage",
@@ -11,6 +21,110 @@
1121
]
1222

1323

24+
class GenericPackage(RESTObject):
25+
_id_attr = "package_name"
26+
27+
28+
class GenericPackageManager(RESTManager):
29+
_path = "/projects/%(project_id)s/packages/generic"
30+
_obj_cls = GenericPackage
31+
_from_parent_attrs = {"project_id": "id"}
32+
33+
@cli.register_custom_action(
34+
"GenericPackageManager",
35+
("package_name", "package_version", "file_name", "path"),
36+
)
37+
@exc.on_http_error(exc.GitlabUploadError)
38+
def upload(
39+
self,
40+
package_name: str,
41+
package_version: str,
42+
file_name: str,
43+
path: Union[str, Path],
44+
**kwargs,
45+
) -> GenericPackage:
46+
"""Upload a file as a generic package.
47+
48+
Args:
49+
package_name (str): The package name. Must follow generic package
50+
name regex rules
51+
package_version (str): The package version. Must follow semantic
52+
version regex rules
53+
file_name (str): The name of the file as uploaded in the registry
54+
path (str): The path to a local file to upload
55+
56+
Raises:
57+
GitlabConnectionError: If the server cannot be reached
58+
GitlabUploadError: If the file upload fails
59+
GitlabUploadError: If ``filepath`` cannot be read
60+
61+
Returns:
62+
GenericPackage: An object storing the metadata of the uploaded package.
63+
"""
64+
65+
try:
66+
with open(path, "rb") as f:
67+
file_data = f.read()
68+
except OSError:
69+
raise exc.GitlabUploadError(f"Failed to read package file {path}")
70+
71+
url = f"{self._computed_path}/{package_name}/{package_version}/{file_name}"
72+
server_data = self.gitlab.http_put(url, post_data=file_data, raw=True, **kwargs)
73+
74+
return self._obj_cls(
75+
self,
76+
{
77+
"package_name": package_name,
78+
"package_version": package_version,
79+
"file_name": file_name,
80+
"path": path,
81+
"message": server_data["message"],
82+
},
83+
)
84+
85+
@cli.register_custom_action(
86+
"GenericPackageManager",
87+
("package_name", "package_version", "file_name"),
88+
)
89+
@exc.on_http_error(exc.GitlabGetError)
90+
def download(
91+
self,
92+
package_name: str,
93+
package_version: str,
94+
file_name: str,
95+
streamed: bool = False,
96+
action: Optional[Callable] = None,
97+
chunk_size: int = 1024,
98+
**kwargs: Any,
99+
) -> Optional[bytes]:
100+
"""Download a generic package.
101+
102+
Args:
103+
package_name (str): The package name.
104+
package_version (str): The package version.
105+
file_name (str): The name of the file in the registry
106+
streamed (bool): If True the data will be processed by chunks of
107+
`chunk_size` and each chunk is passed to `action` for
108+
reatment
109+
action (callable): Callable responsible of dealing with chunk of
110+
data
111+
chunk_size (int): Size of each chunk
112+
**kwargs: Extra options to send to the server (e.g. sudo)
113+
114+
Raises:
115+
GitlabAuthenticationError: If authentication is not correct
116+
GitlabGetError: If the server failed to perform the request
117+
118+
Returns:
119+
str: The package content if streamed is False, None otherwise
120+
"""
121+
path = f"{self._computed_path}/{package_name}/{package_version}/{file_name}"
122+
result = self.gitlab.http_get(path, streamed=streamed, raw=True, **kwargs)
123+
if TYPE_CHECKING:
124+
assert isinstance(result, requests.Response)
125+
return utils.response_content(result, streamed, action, chunk_size)
126+
127+
14128
class GroupPackage(RESTObject):
15129
pass
16130

gitlab/v4/objects/projects.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
from .milestones import ProjectMilestoneManager # noqa: F401
4242
from .notes import ProjectNoteManager # noqa: F401
4343
from .notification_settings import ProjectNotificationSettingsManager # noqa: F401
44-
from .packages import ProjectPackageManager # noqa: F401
44+
from .packages import GenericPackageManager, ProjectPackageManager # noqa: F401
4545
from .pages import ProjectPagesDomainManager # noqa: F401
4646
from .pipelines import ( # noqa: F401
4747
ProjectPipeline,
@@ -124,6 +124,7 @@ class Project(RefreshMixin, SaveMixin, ObjectDeleteMixin, RepositoryMixin, RESTO
124124
("exports", "ProjectExportManager"),
125125
("files", "ProjectFileManager"),
126126
("forks", "ProjectForkManager"),
127+
("generic_packages", "GenericPackageManager"),
127128
("hooks", "ProjectHookManager"),
128129
("keys", "ProjectKeyManager"),
129130
("imports", "ProjectImportManager"),

0 commit comments

Comments
 (0)