Skip to content

Commit 9b4cc2c

Browse files
committed
feat(objects): add support for generic packages API
1 parent fb7174e commit 9b4cc2c

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
@@ -393,15 +393,9 @@ def enable_debug(self) -> None:
393393
requests_log.setLevel(logging.DEBUG)
394394
requests_log.propagate = True
395395

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

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

506-
opts = self._get_session_opts(content_type="application/json")
528+
opts = self._get_session_opts()
507529

508530
verify = opts.pop("verify")
509531
opts_timeout = opts.pop("timeout")
@@ -512,23 +534,8 @@ def http_request(
512534
timeout = opts_timeout
513535

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

533540
# Requests assumes that `.` should not be encoded as %2E and will make
534541
# changes to urls using this encoding. Using a prepped request we can
@@ -683,6 +690,7 @@ def http_post(
683690
path: str,
684691
query_data: Optional[Dict[str, Any]] = None,
685692
post_data: Optional[Dict[str, Any]] = None,
693+
raw: Optional[bool] = False,
686694
files: Optional[Dict[str, Any]] = None,
687695
**kwargs: Any,
688696
) -> Union[Dict[str, Any], requests.Response]:
@@ -693,7 +701,8 @@ def http_post(
693701
'http://whatever/v4/api/projecs')
694702
query_data (dict): Data to send as query parameters
695703
post_data (dict): Data to send in the body (will be converted to
696-
json)
704+
json by default)
705+
raw (bool): If True, do not convert post_data to json
697706
files (dict): The files to send to the server
698707
**kwargs: Extra options to send to the server (e.g. sudo)
699708
@@ -730,6 +739,7 @@ def http_put(
730739
path: str,
731740
query_data: Optional[Dict[str, Any]] = None,
732741
post_data: Optional[Dict[str, Any]] = None,
742+
raw: Optional[bool] = False,
733743
files: Optional[Dict[str, Any]] = None,
734744
**kwargs: Any,
735745
) -> Union[Dict[str, Any], requests.Response]:
@@ -740,7 +750,8 @@ def http_put(
740750
'http://whatever/v4/api/projecs')
741751
query_data (dict): Data to send as query parameters
742752
post_data (dict): Data to send in the body (will be converted to
743-
json)
753+
json by default)
754+
raw (bool): If True, do not convert post_data to json
744755
files (dict): The files to send to the server
745756
**kwargs: Extra options to send to the server (e.g. sudo)
746757
@@ -760,6 +771,7 @@ def http_put(
760771
query_data=query_data,
761772
post_data=post_data,
762773
files=files,
774+
raw=raw,
763775
**kwargs,
764776
)
765777
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)