Skip to content

Commit 0af668f

Browse files
committed
feat(objects): add support for generic packages API
1 parent f35c73e commit 0af668f

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

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

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

508-
opts = self._get_session_opts(content_type="application/json")
530+
opts = self._get_session_opts()
509531

510532
verify = opts.pop("verify")
511533
opts_timeout = opts.pop("timeout")
@@ -514,23 +536,8 @@ def http_request(
514536
timeout = opts_timeout
515537

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

535542
# Requests assumes that `.` should not be encoded as %2E and will make
536543
# changes to urls using this encoding. Using a prepped request we can
@@ -685,6 +692,7 @@ def http_post(
685692
path: str,
686693
query_data: Optional[Dict[str, Any]] = None,
687694
post_data: Optional[Dict[str, Any]] = None,
695+
raw: Optional[bool] = False,
688696
files: Optional[Dict[str, Any]] = None,
689697
**kwargs: Any,
690698
) -> Union[Dict[str, Any], requests.Response]:
@@ -695,7 +703,8 @@ def http_post(
695703
'http://whatever/v4/api/projecs')
696704
query_data (dict): Data to send as query parameters
697705
post_data (dict): Data to send in the body (will be converted to
698-
json)
706+
json by default)
707+
raw (bool): If True, do not convert post_data to json
699708
files (dict): The files to send to the server
700709
**kwargs: Extra options to send to the server (e.g. sudo)
701710
@@ -732,6 +741,7 @@ def http_put(
732741
path: str,
733742
query_data: Optional[Dict[str, Any]] = None,
734743
post_data: Optional[Dict[str, Any]] = None,
744+
raw: Optional[bool] = False,
735745
files: Optional[Dict[str, Any]] = None,
736746
**kwargs: Any,
737747
) -> Union[Dict[str, Any], requests.Response]:
@@ -742,7 +752,8 @@ def http_put(
742752
'http://whatever/v4/api/projecs')
743753
query_data (dict): Data to send as query parameters
744754
post_data (dict): Data to send in the body (will be converted to
745-
json)
755+
json by default)
756+
raw (bool): If True, do not convert post_data to json
746757
files (dict): The files to send to the server
747758
**kwargs: Extra options to send to the server (e.g. sudo)
748759
@@ -762,6 +773,7 @@ def http_put(
762773
query_data=query_data,
763774
post_data=post_data,
764775
files=files,
776+
raw=raw,
765777
**kwargs,
766778
)
767779
try:

gitlab/tests/objects/test_packages.py

+67-1
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@
22
GitLab API: https://docs.gitlab.com/ce/api/packages.html
33
"""
44
import re
5+
from urllib.parse import quote_plus
56

67
import pytest
78
import responses
89

9-
from gitlab.v4.objects import GroupPackage, ProjectPackage, ProjectPackageFile
10+
from gitlab.v4.objects import (
11+
GenericPackage,
12+
GroupPackage,
13+
ProjectPackage,
14+
ProjectPackageFile,
15+
)
1016

1117

1218
package_content = {
@@ -99,6 +105,17 @@
99105
},
100106
]
101107

108+
package_name = "hello-world"
109+
package_version = "v1.0.0"
110+
file_name = "hello.tar.gz"
111+
file_content = "package content"
112+
package_url = "http://localhost/api/v4/projects/1/packages/generic/{}/{}/{}".format(
113+
# https://datatracker.ietf.org/doc/html/rfc3986.html#section-2.3 :(
114+
quote_plus(package_name).replace(".", "%2E"),
115+
quote_plus(package_version).replace(".", "%2E"),
116+
quote_plus(file_name).replace(".", "%2E"),
117+
)
118+
102119

103120
@pytest.fixture
104121
def resp_list_packages():
@@ -154,6 +171,32 @@ def resp_list_package_files():
154171
yield rsps
155172

156173

174+
@pytest.fixture
175+
def resp_upload_generic_package(created_content):
176+
with responses.RequestsMock() as rsps:
177+
rsps.add(
178+
method=responses.PUT,
179+
url=package_url,
180+
json=created_content,
181+
content_type="application/json",
182+
status=201,
183+
)
184+
yield rsps
185+
186+
187+
@pytest.fixture
188+
def resp_download_generic_package(created_content):
189+
with responses.RequestsMock() as rsps:
190+
rsps.add(
191+
method=responses.GET,
192+
url=package_url,
193+
body=file_content,
194+
content_type="application/octet-stream",
195+
status=200,
196+
)
197+
yield rsps
198+
199+
157200
def test_list_project_packages(project, resp_list_packages):
158201
packages = project.packages.list()
159202
assert isinstance(packages, list)
@@ -185,3 +228,26 @@ def test_list_project_package_files(project, resp_list_package_files):
185228
assert isinstance(package_files, list)
186229
assert isinstance(package_files[0], ProjectPackageFile)
187230
assert package_files[0].id == 25
231+
232+
233+
def test_upload_generic_package(tmp_path, project, resp_upload_generic_package):
234+
path = tmp_path / file_name
235+
path.write_text(file_content)
236+
package = project.generic_packages.upload(
237+
package_name=package_name,
238+
package_version=package_version,
239+
file_name=file_name,
240+
path=path,
241+
)
242+
243+
assert isinstance(package, GenericPackage)
244+
245+
246+
def test_download_generic_package(project, resp_download_generic_package):
247+
package = project.generic_packages.download(
248+
package_name=package_name,
249+
package_version=package_version,
250+
file_name=file_name,
251+
)
252+
253+
assert isinstance(package, bytes)

0 commit comments

Comments
 (0)