glpkg 1.4.2__tar.gz → 1.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: glpkg
3
- Version: 1.4.2
3
+ Version: 1.5.0
4
4
  Summary: Tool to make GitLab generic package registry operations easy.
5
5
  Author-email: bugproduction <bugproduction@outlook.com>
6
6
  License-Expression: MIT
@@ -101,6 +101,29 @@ glpkg download --project 12345 --name mypackagename --version 1.5 --file the_onl
101
101
 
102
102
  > If a package has multiple files with the same filename, the tool can only download the newest file. This is a restriction of GitLab API.
103
103
 
104
+ #### Downloading a list of packages
105
+
106
+ To download multiple packages or versions from one or more projects, a package file can be used together with `--from-file` argument with `download`:
107
+
108
+ ```bash
109
+ glpkg download --from-file my-packages.txt --destination my-downloads
110
+ ```
111
+
112
+ The `my-packages.txt` lists all wanted packages with their versions and projects in format `<package-name>==<package-version>@<project-id>`.For example `my-packages.txt` could look like this:
113
+
114
+ ```
115
+ mypackage==2.3.6-beta@namespace/project
116
+ yourpackage==1.2.3@12345
117
+ ```
118
+
119
+ The `<project-id>` can be either the path to the project in string format or the project ID in numeric.
120
+
121
+ When `--from-file` argument is used, `--file`, `--project`, `--version`, and `--name` arguments are unused.
122
+
123
+ > The `--destination` argument can be used to download the files from the packages to a different folder. In case packages contains files with same names, some files will be overwritten.
124
+
125
+ > Whatever credentials (`--token`, `--ci`, or `--netrc`) you run the download command with will be used for all package downloads in the list. Make sure that the credential has access to all projects.
126
+
104
127
  ### Upload a file to a generic package
105
128
 
106
129
  To upload a file to a version of a generic package, run
@@ -158,7 +181,7 @@ The token that is used to delete packages must have at least Maintainer role in
158
181
 
159
182
  ### Use in GitLab pipelines
160
183
 
161
- If you use the tool in a GitLab pipeline, setting argument `--ci` uses [GitLab predefined variables](https://docs.gitlab.com/ci/variables/predefined_variables/) to configure the tool. In this case `CI_SERVER_HOST`, `CI_PROJECT_ID`, and `CI_JOB_TOKEN` environment variables are used. The `--project`, and `--token` arguments can still be used to override the project ID or to use a personal or project access token instead of `CI_JOB_TOKEN`.
184
+ If you use the tool in a GitLab pipeline, setting argument `--ci` uses [GitLab predefined variables](https://docs.gitlab.com/ci/variables/predefined_variables/) to configure the tool. In this case `CI_SERVER_HOST`, `CI_PROJECT_ID`, and `CI_JOB_TOKEN` environment variables are used. The `--host`, `--project`, and `--token` arguments can still be used to override the host, project ID, or to use a personal or project access token instead of `CI_JOB_TOKEN`.
162
185
 
163
186
  In other words, you don't need to give the `--host`, `--project`, or `--token` arguments if you are interacting with the package registry of the project where the pipeline is running. Example: uploading `my-file.txt` to generic package `mypackagename` version `1.0` in the project package registry in CI:
164
187
 
@@ -88,6 +88,29 @@ glpkg download --project 12345 --name mypackagename --version 1.5 --file the_onl
88
88
 
89
89
  > If a package has multiple files with the same filename, the tool can only download the newest file. This is a restriction of GitLab API.
90
90
 
91
+ #### Downloading a list of packages
92
+
93
+ To download multiple packages or versions from one or more projects, a package file can be used together with `--from-file` argument with `download`:
94
+
95
+ ```bash
96
+ glpkg download --from-file my-packages.txt --destination my-downloads
97
+ ```
98
+
99
+ The `my-packages.txt` lists all wanted packages with their versions and projects in format `<package-name>==<package-version>@<project-id>`.For example `my-packages.txt` could look like this:
100
+
101
+ ```
102
+ mypackage==2.3.6-beta@namespace/project
103
+ yourpackage==1.2.3@12345
104
+ ```
105
+
106
+ The `<project-id>` can be either the path to the project in string format or the project ID in numeric.
107
+
108
+ When `--from-file` argument is used, `--file`, `--project`, `--version`, and `--name` arguments are unused.
109
+
110
+ > The `--destination` argument can be used to download the files from the packages to a different folder. In case packages contains files with same names, some files will be overwritten.
111
+
112
+ > Whatever credentials (`--token`, `--ci`, or `--netrc`) you run the download command with will be used for all package downloads in the list. Make sure that the credential has access to all projects.
113
+
91
114
  ### Upload a file to a generic package
92
115
 
93
116
  To upload a file to a version of a generic package, run
@@ -145,7 +168,7 @@ The token that is used to delete packages must have at least Maintainer role in
145
168
 
146
169
  ### Use in GitLab pipelines
147
170
 
148
- If you use the tool in a GitLab pipeline, setting argument `--ci` uses [GitLab predefined variables](https://docs.gitlab.com/ci/variables/predefined_variables/) to configure the tool. In this case `CI_SERVER_HOST`, `CI_PROJECT_ID`, and `CI_JOB_TOKEN` environment variables are used. The `--project`, and `--token` arguments can still be used to override the project ID or to use a personal or project access token instead of `CI_JOB_TOKEN`.
171
+ If you use the tool in a GitLab pipeline, setting argument `--ci` uses [GitLab predefined variables](https://docs.gitlab.com/ci/variables/predefined_variables/) to configure the tool. In this case `CI_SERVER_HOST`, `CI_PROJECT_ID`, and `CI_JOB_TOKEN` environment variables are used. The `--host`, `--project`, and `--token` arguments can still be used to override the host, project ID, or to use a personal or project access token instead of `CI_JOB_TOKEN`.
149
172
 
150
173
  In other words, you don't need to give the `--host`, `--project`, or `--token` arguments if you are interacting with the package registry of the project where the pipeline is running. Example: uploading `my-file.txt` to generic package `mypackagename` version `1.0` in the project package registry in CI:
151
174
 
@@ -2,4 +2,4 @@
2
2
 
3
3
  from gitlab.packages import Packages
4
4
 
5
- __version__ = "1.4.2"
5
+ __version__ = "1.5.0"
@@ -3,6 +3,7 @@
3
3
  import argparse
4
4
  from netrc import netrc
5
5
  import os
6
+ import re
6
7
  import sys
7
8
  import urllib
8
9
  from gitlab import Packages, __version__
@@ -72,6 +73,9 @@ class CLIHandler:
72
73
  file=sys.stderr,
73
74
  )
74
75
  print("Check your arguments and credentials.", file=sys.stderr)
76
+ except ValueError as e:
77
+ print("Whopsie! Something did go wrong.", file=sys.stderr)
78
+ print(e, file=sys.stderr)
75
79
  return ret
76
80
 
77
81
  def _register_common_arguments(self, parser: argparse.ArgumentParser) -> None:
@@ -89,7 +93,7 @@ class CLIHandler:
89
93
  parser:
90
94
  The argparser where to register the common arguments.
91
95
  """
92
- group = parser.add_mutually_exclusive_group()
96
+ group = parser.add_argument_group()
93
97
  group.add_argument(
94
98
  "-H",
95
99
  "--host",
@@ -148,6 +152,12 @@ class CLIHandler:
148
152
  """
149
153
  self._register_common_arguments(parser)
150
154
  parser.add_argument("-v", "--version", type=str, help="The package version.")
155
+ parser.add_argument(
156
+ "--from-file",
157
+ type=str,
158
+ help="Download all files from packages listed in the file. Each line in the "
159
+ "file should follow format <package-name>==<package-version>@<project>.",
160
+ )
151
161
  parser.add_argument(
152
162
  "-f",
153
163
  "--file",
@@ -195,6 +205,8 @@ class CLIHandler:
195
205
  project = os.environ["CI_PROJECT_ID"]
196
206
  token = os.environ["CI_JOB_TOKEN"]
197
207
  token_user = "JOB-TOKEN"
208
+ if args.host:
209
+ host = args.host
198
210
  if args.project:
199
211
  project = args.project
200
212
  if args.token:
@@ -229,23 +241,67 @@ class CLIHandler:
229
241
  host, project, name, token_user, token = self._args(args)
230
242
  version = args.version
231
243
  destination = args.destination
244
+ file = args.file
245
+ from_file = args.from_file
232
246
  packages = Packages(host, token_user, token)
233
- package_id = packages.get_id(project, name, version)
234
- if package_id:
235
- files = []
236
- if args.file:
237
- files.append(args.file)
238
- else:
239
- files = packages.get_files(project, package_id).keys()
240
- for file in files:
241
- ret = packages.download_file(project, name, version, file, destination)
247
+ if from_file:
248
+ package_list = self._read_from_file(from_file)
249
+ for package in package_list:
250
+ ret = packages.download(
251
+ package["project"], package["name"], package["version"], destination
252
+ )
242
253
  if ret:
243
- print("Failed to download file " + file)
244
254
  break
255
+ elif file:
256
+ ret = packages.download_file(project, name, version, file, destination)
245
257
  else:
246
- print("No package " + name + " version " + version + " found!")
258
+ ret = packages.download(project, name, version, destination)
259
+ if ret:
260
+ if ret == 2:
261
+ print("No package " + name + " version " + version + " found!")
262
+ else:
263
+ print("Failed to download file(s)!")
247
264
  return ret
248
265
 
266
+ def _read_from_file(self, filepath: str) -> list[dict]:
267
+ """
268
+ Reads packages file and returns a list of packages to be downloaded.
269
+
270
+ The lines in the file should be
271
+ <packagename>==<version>@<project>
272
+
273
+ Parameters
274
+ ----------
275
+ filepath : str
276
+ Filepath of the packages file
277
+
278
+ Returns
279
+ -------
280
+ int
281
+ Zero if everything goes well, non-zero otherwise
282
+ """
283
+ packages = []
284
+ pattern = (
285
+ r"\s*(?P<packagename>\S+)\s*=="
286
+ r"\s*(?P<packageversion>\S+)\s*@"
287
+ r"\s*(?P<project>\S+)\s*"
288
+ )
289
+ with open(filepath, "r", encoding="utf-8") as file:
290
+ for line in file:
291
+ if not line:
292
+ continue
293
+ match = re.match(pattern, line)
294
+ if not match:
295
+ raise ValueError(
296
+ f"Package file {filepath} line {line} seems fishy! \
297
+ Check that the line is <packagename>==<version>@<project>"
298
+ )
299
+ name = match.group("packagename")
300
+ version = match.group("packageversion")
301
+ project = match.group("project")
302
+ packages.append({"name": name, "version": version, "project": project})
303
+ return packages
304
+
249
305
  def _register_list_parser(self, parser: argparse.ArgumentParser):
250
306
  """
251
307
  Registers the list command related arguments to the parser:
@@ -334,19 +390,21 @@ class CLIHandler:
334
390
  int
335
391
  Zero if everything went fine, non-zero otherwise.
336
392
  """
337
- ret = 0
393
+ ret = 1
338
394
  host, project, name, token_user, token = self._args(args)
339
395
  version = args.version
340
396
  file = args.file
341
397
  source = args.source
398
+ packages = Packages(host, token_user, token)
342
399
  if file:
343
400
  # Check if the uploaded file exists.
344
401
  if not os.path.isfile(os.path.join(source, file)):
345
402
  print("File " + file + " does not exist!")
346
403
  ret = 1
347
- if not ret:
348
- packages = Packages(host, token_user, token)
349
- ret = packages.upload_file(project, name, version, file, source)
404
+ else:
405
+ ret = packages.upload_file(project, name, version, file, source)
406
+ else:
407
+ ret = packages.upload(project, name, version, source)
350
408
  return ret
351
409
 
352
410
  def _register_delete_parser(self, parser: argparse.ArgumentParser):
@@ -397,5 +455,5 @@ class CLIHandler:
397
455
  if file:
398
456
  ret = packages.delete_file(project, name, version, file)
399
457
  else:
400
- ret = packages.delete_package(project, name, version)
458
+ ret = packages.delete(project, name, version)
401
459
  return ret
@@ -0,0 +1,248 @@
1
+ """GitLab basic API"""
2
+
3
+ from http.client import HTTPMessage
4
+ from json import loads
5
+ import logging
6
+ from urllib import parse
7
+ from gitlab.lib import http
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class GitLabAPI:
13
+ """Class for basic GitLab API"""
14
+
15
+ def __init__(self, host: str, token_type: str, token: str):
16
+ """
17
+ Creates a new instance of class.
18
+
19
+ Parameters
20
+ ----------
21
+ host : str
22
+ The GitLab instance hostname, without schema.
23
+ The host will be used for the package API interaction.
24
+ For example gitlab.com.
25
+ token_type : str
26
+ The token type or "user" to authenticate with GitLab REST API.
27
+ For personal, project, and group tokens this is `PRIVATE-TOKEN`.
28
+ For `CI_JOB_TOKEN` this is `JOB-TOKEN`.
29
+ Can be left empty when authentication is not used.
30
+ token : str
31
+ The token (secret) to authenticate with GitLab REST API.
32
+ This can be a personal token, project token, or`CI_JOB_TOKEN`.
33
+ Leave empty when authentication is not used.
34
+ """
35
+ self.host = host
36
+ self.token_type = token_type
37
+ self.token = token
38
+
39
+ def _url(self) -> str:
40
+ """
41
+ Returns the GitLab REST API URL by using the host variable.
42
+
43
+ Returns
44
+ -------
45
+ str
46
+ The GitLab REST API URL, for example `https://gitlab.com/api/v4/`.
47
+ """
48
+ return f"https://{self.host}/api/v4/"
49
+
50
+ def _get_headers(self) -> dict:
51
+ """
52
+ Creates headers for a GitLab REST API call.
53
+
54
+ The headers contain token for authentication according to the
55
+ instance variables.
56
+
57
+ Returns
58
+ -------
59
+ dict
60
+ Headers for a REST API request, that contain the authentication token.
61
+ """
62
+ headers = {}
63
+ if self.token_type and self.token:
64
+ headers = {self.token_type: self.token}
65
+ return headers
66
+
67
+ def _parse_header_links(self, headers: HTTPMessage) -> dict:
68
+ """
69
+ Parses link field from HTTP headers to a dictionary where
70
+ the link "rel" value is the key.
71
+
72
+ This is useful for example with GitLab REST API to get the pagination links.
73
+
74
+ Parameters
75
+ ----------
76
+ headers : HTTPMessage
77
+ The HTTP response headers that contain the links
78
+
79
+ Returns
80
+ -------
81
+ dict
82
+ The header links in a dictionary, that can be used to for example pagination:
83
+ _parse_header_links(headers).get("next") returns the next page link, or None.
84
+ """
85
+ links = {}
86
+ header_links = headers.get("link")
87
+ if header_links:
88
+ items = header_links.split(",")
89
+ # Values are <uri-reference>; param1=value1; param2="value2"
90
+ for item in items:
91
+ parts = item.split(";", 1)
92
+ if parts:
93
+ # First value should be the URI
94
+ val = parts.pop(0).lstrip(" <").rstrip("> ")
95
+ # The rest are the parameters; let's take the first one that has rel=
96
+ # in it, split it with ; and take the first part as a value
97
+ typ = parts.pop()
98
+ typ = typ.split("rel=", 1).pop().split(";").pop(0).strip('" ')
99
+ links[typ] = val
100
+ return links
101
+
102
+ def _build_query(self, **args: str) -> str:
103
+ """
104
+ Builds a query for a GitLab REST API request
105
+
106
+ Parameters
107
+ ----------
108
+ args : str
109
+ keyword arguments for the REST API query, like per_page=20, page=20
110
+
111
+ Returns
112
+ -------
113
+ str
114
+ A query string for a REST API request. Append this to
115
+ the request URL. Example `?per_page=20&page=20`.
116
+ """
117
+ query = ""
118
+ for key, value in args.items():
119
+ query = "&".join(filter(None, (query, f"{key}={parse.quote_plus(value)}")))
120
+ if query:
121
+ query = "?" + query
122
+ return query
123
+
124
+ def _build_path(self, *paths: str) -> str:
125
+ """
126
+ Returns the path to be appended to the REST API URL
127
+
128
+ Parameters
129
+ ----------
130
+ paths : str
131
+ The path string that are joined with "/".
132
+
133
+ Returns
134
+ -------
135
+ str
136
+ A URL path, for example `projects/123/`
137
+ or `projects/namespace%2Fproject/`
138
+ """
139
+ quoted_paths = []
140
+ for subpath in paths:
141
+ quoted_paths.append(subpath)
142
+ return "/".join(quoted_paths)
143
+
144
+ def get(self, *paths: str, **query_params: str) -> tuple[int, bytes, HTTPMessage]:
145
+ """
146
+ Makes a HTTP GET request to the given GitLab path, and returns
147
+ the response status, body, and headers.
148
+
149
+ Parameters
150
+ ----------
151
+ paths : str
152
+ The URL path of the HTTP request to make.
153
+ query_params : str, optional
154
+ Dictionary of query parameters for the request, like package_name=mypackage
155
+
156
+ Returns
157
+ -------
158
+ int
159
+ The HTTP response code, such as 200
160
+ bytes
161
+ The HTTP response body read as bytes
162
+ HTTPMessage
163
+ The HTTP response headers
164
+ """
165
+ url = self._url() + self._build_path(*paths) + self._build_query(**query_params)
166
+ headers = self._get_headers()
167
+ return http.get(url, headers)
168
+
169
+ def get_all(self, *paths: str, **query_params: str) -> list:
170
+ """
171
+ Returns data from the REST API endpoint. In case
172
+ of multiple pages, all data will be returned.
173
+
174
+ Parameters
175
+ ----------
176
+ paths : str
177
+ The paths of the API endpoint that is called. For example projects, 123, packages
178
+ would be querying the projects/123/packages endpoint.
179
+ query_params : str, optional
180
+ Additional arguments for the query of the URL, for example to filter
181
+ results: package_name=mypackage
182
+
183
+ Returns
184
+ -------
185
+ list
186
+ Data from GitLab REST API endpoint with the arguments.
187
+ """
188
+ url = self._url() + self._build_path(*paths) + self._build_query(**query_params)
189
+ data = []
190
+ while url:
191
+ res_status, res_data, res_headers = http.get(url, self._get_headers())
192
+ logger.debug("Response status: %d", res_status)
193
+ res_data = loads(res_data)
194
+ logger.debug("Response data: %s", res_data)
195
+ data = data + res_data
196
+ url = self._parse_header_links(res_headers).get("next")
197
+ return data
198
+
199
+ def put(self, data: bytes, *paths: str, **query_params: str) -> int:
200
+ """
201
+ Makes a HTTP PUT request to the given GitLab path.
202
+
203
+ Parameters
204
+ ----------
205
+ data : bytes
206
+ The data to PUT.
207
+ paths : str
208
+ The URL path of the HTTP request to make.
209
+ query_params : str, optional
210
+ Additional arguments for the query of the URL, for example to filter
211
+ results: package_name=mypackage
212
+
213
+ Returns
214
+ -------
215
+ int
216
+ 0 If the request was successful.
217
+ """
218
+ ret = 1
219
+ url = self._url() + self._build_path(*paths) + self._build_query(**query_params)
220
+ status, _, _ = http.put(url, data, self._get_headers())
221
+ if status == 201: # 201 is created
222
+ ret = 0
223
+ return ret
224
+
225
+ def delete(self, *paths: str, **query_params: str) -> int:
226
+ """
227
+ Makes a HTTP DELETE request to the given GitLab path.
228
+
229
+ Parameters
230
+ ----------
231
+ paths : str
232
+ The URL path of the HTTP request to make.
233
+ query_params : str, optional
234
+ Additional arguments for the query of the URL, for example to filter
235
+ results: package_name=mypackage
236
+
237
+ Returns
238
+ -------
239
+ int
240
+ 0 If the request was successful.
241
+ """
242
+ ret = 1
243
+ url = self._url() + self._build_path(*paths) + self._build_query(**query_params)
244
+ status, _, _ = http.delete(url, self._get_headers())
245
+ if status == 204:
246
+ # 204 is no content, that GL responds when file deleted
247
+ ret = 0
248
+ return ret
@@ -0,0 +1,90 @@
1
+ """HTTP helper"""
2
+
3
+ from http.client import HTTPMessage
4
+ import logging
5
+ from urllib import request
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def get(url: str, headers: dict) -> tuple[int, bytes, HTTPMessage]:
11
+ """
12
+ Makes a raw GET request to the given URL, and returns
13
+ the response status, body, and headers.
14
+
15
+ Parameters
16
+ ----------
17
+ url : str
18
+ The URL of the HTTP request to make.
19
+ headers: dict
20
+ The HTTP headers used in the request.
21
+
22
+ Returns
23
+ -------
24
+ int
25
+ The HTTP response code, such as 200
26
+ bytes
27
+ The HTTP response body read as bytes
28
+ HTTPMessage
29
+ The HTTP response headers
30
+ """
31
+ logger.debug("Getting %s", url)
32
+ req = request.Request(url, headers=headers)
33
+ with request.urlopen(req) as response:
34
+ return response.status, response.read(), response.headers
35
+
36
+
37
+ def put(url: str, data: bytes, headers: dict) -> tuple[int, bytes, HTTPMessage]:
38
+ """
39
+ Makes a raw PUT request to the given URL, and returns
40
+ the response status, body, and headers.
41
+
42
+ Parameters
43
+ ----------
44
+ url : str
45
+ The URL of the HTTP request to make.
46
+ data : bytes
47
+ The data to PUT
48
+ headers: dict
49
+ The HTTP headers used in the request.
50
+
51
+ Returns
52
+ -------
53
+ int
54
+ The HTTP response code, such as 200
55
+ bytes
56
+ The HTTP response body read as bytes
57
+ HTTPMessage
58
+ The HTTP response headers
59
+ """
60
+ logger.debug("Putting %s", url)
61
+ req = request.Request(url, method="PUT", data=data, headers=headers)
62
+ with request.urlopen(req) as response:
63
+ return response.status, response.read(), response.headers
64
+
65
+
66
+ def delete(url, headers):
67
+ """
68
+ Makes a raw DELETE request to the given URL, and returns
69
+ the response status, body, and headers.
70
+
71
+ Parameters
72
+ ----------
73
+ url : str
74
+ The URL of the HTTP request to make.
75
+ headers: dict
76
+ The HTTP headers used in the request.
77
+
78
+ Returns
79
+ -------
80
+ int
81
+ The HTTP response code, such as 200
82
+ bytes
83
+ The HTTP response body read as bytes
84
+ HTTPMessage
85
+ The HTTP response headers
86
+ """
87
+ logger.debug("Deleting %s", url)
88
+ req = request.Request(url, method="DELETE", headers=headers)
89
+ with request.urlopen(req) as response:
90
+ return response.status, response.read(), response.headers