glpkg 1.2.0__py3-none-any.whl → 1.4.0__py3-none-any.whl

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.
gitlab/__init__.py CHANGED
@@ -1,3 +1,5 @@
1
+ """GitLab packages initialization module"""
2
+
1
3
  from gitlab.packages import Packages
2
4
 
3
- __version__ = "1.2.0"
5
+ __version__ = "1.4.0"
gitlab/__main__.py CHANGED
@@ -1,9 +1,26 @@
1
+ """GitLab packages main module"""
2
+
3
+ import logging
1
4
  import sys
2
5
 
3
6
  from gitlab.cli_handler import CLIHandler
4
7
 
5
8
 
6
9
  def cli() -> int:
10
+ """
11
+ Runs the main program of the glpkg.
12
+
13
+ Uses arguments from command line and executes the given command.
14
+
15
+ Return
16
+ ------
17
+ int
18
+ Zero when everything is fine, non-zero otherwise.
19
+ """
20
+ logging.basicConfig(
21
+ level=logging.DEBUG,
22
+ handlers=[logging.FileHandler("glpkg.log")], # , logging.StreamHandler()],
23
+ )
7
24
  handler = CLIHandler()
8
25
  return handler.do_it()
9
26
 
gitlab/cli_handler.py CHANGED
@@ -1,5 +1,7 @@
1
+ """CLI handler for glpkg"""
2
+
1
3
  import argparse
2
- import netrc
4
+ from netrc import netrc
3
5
  import os
4
6
  import sys
5
7
  import urllib
@@ -7,7 +9,15 @@ from gitlab import Packages, __version__
7
9
 
8
10
 
9
11
  class CLIHandler:
12
+ """Class to parse CLI arguments and run the requested command"""
13
+
10
14
  def __init__(self):
15
+ """
16
+ Creates a new instance of CLIHandler.
17
+
18
+ Parses the arguments from command line and prepares everything
19
+ to be ready to executed. Just use do_it to run it!
20
+ """
11
21
  parser = argparse.ArgumentParser(
12
22
  description="Toolbox for GitLab generic packages"
13
23
  )
@@ -16,25 +26,57 @@ class CLIHandler:
16
26
  subparsers = parser.add_subparsers()
17
27
  list_parser = subparsers.add_parser(
18
28
  name="list",
19
- description="Lists the available version of a package from the package registry.",
29
+ description="Lists the available version of a package from the "
30
+ "package registry.",
20
31
  )
21
32
  self._register_list_parser(list_parser)
22
33
  download_parser = subparsers.add_parser(
23
34
  name="download",
24
- description="Downloads all files from a specific package version to the current directory.",
35
+ description="Downloads all files from a specific package version "
36
+ "to the current directory.",
25
37
  )
26
38
  self._register_download_parser(download_parser)
27
39
  upload_parser = subparsers.add_parser(
28
40
  name="upload", description="Uploads file to a specific package version."
29
41
  )
30
42
  self._register_upload_parser(upload_parser)
43
+ delete_parser = subparsers.add_parser(
44
+ name="delete",
45
+ description="Deletes a specific package version or a specific file from "
46
+ "a specific package version, depending whether the --file argument is "
47
+ "set or not.",
48
+ )
49
+ self._register_delete_parser(delete_parser)
31
50
  self.args = parser.parse_args()
32
51
 
33
- def _print_version(self, args) -> int:
52
+ def _print_version(self, _args: argparse.Namespace) -> int:
53
+ """
54
+ A handler for printing the version of the tool to the console.
55
+
56
+ Parameters
57
+ ----------
58
+ _args : argparse.Namespace
59
+ Unused.
60
+
61
+ Return
62
+ ------
63
+ int
64
+ Zero when printing to console succeeded.
65
+ """
34
66
  print(__version__)
35
67
  return 0
36
68
 
37
69
  def do_it(self) -> int:
70
+ """
71
+ Executes the requested command.
72
+
73
+ In case of error, prints to stderr.
74
+
75
+ Return
76
+ ------
77
+ int
78
+ Zero when everything went fine, non-zero otherwise.
79
+ """
38
80
  ret = 1
39
81
  try:
40
82
  ret = self.args.action(self.args)
@@ -50,20 +92,38 @@ class CLIHandler:
50
92
  print("Check your arguments and credentials.", file=sys.stderr)
51
93
  return ret
52
94
 
53
- def _register_common_arguments(self, parser) -> None:
95
+ def _register_common_arguments(self, parser: argparse.ArgumentParser) -> None:
96
+ """
97
+ Registers common arguments to the parser:
98
+ - host
99
+ - ci
100
+ - project
101
+ - name
102
+ - token
103
+ - netrc
104
+
105
+ Parameters
106
+ ----------
107
+ parser:
108
+ The argparser where to register the common arguments.
109
+ """
54
110
  group = parser.add_mutually_exclusive_group()
55
111
  group.add_argument(
56
112
  "-H",
57
113
  "--host",
58
114
  default="gitlab.com",
59
115
  type=str,
60
- help="The host address of GitLab instance without scheme, for example gitlab.com. Note that only https scheme is supported.",
116
+ help="The host address of GitLab instance without scheme, "
117
+ "for example gitlab.com. Note that only https scheme is supported.",
61
118
  )
62
119
  group.add_argument(
63
120
  "-c",
64
121
  "--ci",
65
122
  action="store_true",
66
- help="Use this in GitLab jobs. In this case CI_SERVER_HOST, CI_PROJECT_ID, and CI_JOB_TOKEN variables from the environment are used. --project and --token can be used to override project ID and the CI_JOB_TOKEN to a personal or project access token.",
123
+ help="Use this in GitLab jobs. In this case CI_SERVER_HOST, CI_PROJECT_ID, "
124
+ "and CI_JOB_TOKEN variables from the environment are used. --project and --token "
125
+ "can be used to override project ID and the CI_JOB_TOKEN to a personal or project "
126
+ "access token.",
67
127
  )
68
128
  parser.add_argument(
69
129
  "-p",
@@ -77,33 +137,77 @@ class CLIHandler:
77
137
  "-t",
78
138
  "--token",
79
139
  type=str,
80
- help="Private or project access token that is used to authenticate with the package registry. Leave empty if the registry is public. The token must have 'read API' or 'API' scope.",
140
+ help="Private or project access token that is used to authenticate with "
141
+ "the package registry. Leave empty if the registry is public. The token "
142
+ "must have 'read API' or 'API' scope.",
81
143
  )
82
144
  group2.add_argument(
83
145
  "--netrc",
84
146
  action="store_true",
85
- help="Set to use a token from .netrc file (~/.netrc) for the host. The .netrc username is ignored due to API restrictions. PRIVATE-TOKEN is used instead. Note that .netrc file access rights must be correct.",
147
+ help="Set to use a token from .netrc file (~/.netrc) for the host. The "
148
+ ".netrc username is ignored due to API restrictions. PRIVATE-TOKEN is used "
149
+ "instead. Note that .netrc file access rights must be correct.",
86
150
  )
87
151
 
88
- def _register_download_parser(self, parser):
152
+ def _register_download_parser(self, parser: argparse.ArgumentParser):
153
+ """
154
+ Registers the download command related arguments to the parser:
155
+ - version
156
+ - file
157
+ - destination
158
+ - action
159
+
160
+ Additionally, registers the common args.
161
+
162
+ Parameters
163
+ ----------
164
+ parser:
165
+ The argparser where to register the download arguments.
166
+ """
89
167
  self._register_common_arguments(parser)
90
168
  parser.add_argument("-v", "--version", type=str, help="The package version.")
91
169
  parser.add_argument(
92
170
  "-f",
93
171
  "--file",
94
172
  type=str,
95
- help="The file to download from the package. If not defined, all files are downloaded.",
173
+ help="The file to download from the package. If not defined, all "
174
+ "files are downloaded.",
96
175
  )
97
176
  parser.add_argument(
98
177
  "-d",
99
178
  "--destination",
100
179
  default="",
101
180
  type=str,
102
- help="The path where the file(s) are downloaded. If not defined, the current working directory is used.",
181
+ help="The path where the file(s) are downloaded. If not defined, "
182
+ "the current working directory is used.",
103
183
  )
104
184
  parser.set_defaults(action=self._download_handler)
105
185
 
106
- def _args(self, args):
186
+ def _args(self, args) -> tuple[str, str, str, str, str]:
187
+ """
188
+ Returns the connection parameters according to the args
189
+
190
+ Parameters
191
+ ----------
192
+ args:
193
+ The args that are used to determined the connection parameters
194
+
195
+ Returns
196
+ -------
197
+ host : str
198
+ The GitLab host name
199
+ project : str
200
+ The Project ID or name to use
201
+ name : str
202
+ The package name
203
+ token_user : str
204
+ The token user according to the args. If ci is used, returns `JOB-TOKEN`, else
205
+ `PRIVATE-TOKEN`.
206
+ token : str
207
+ The token according to the args. If token is set, returns it. If netrc is set,
208
+ reads the token from the .netrc file. If ci is set, reads the environment
209
+ variable CI_JOB_TOKEN. Otherwise returns None.
210
+ """
107
211
  if args.ci:
108
212
  host = os.environ["CI_SERVER_HOST"]
109
213
  project = os.environ["CI_PROJECT_ID"]
@@ -120,26 +224,39 @@ class CLIHandler:
120
224
  token = args.token
121
225
  token_user = "PRIVATE-TOKEN"
122
226
  if args.netrc:
123
- _, _, token = netrc.netrc().authenticators(host)
227
+ _, _, token = netrc().authenticators(host)
124
228
  token_user = "PRIVATE-TOKEN"
125
229
  name = args.name
126
230
  return host, project, name, token_user, token
127
231
 
128
- def _download_handler(self, args) -> int:
232
+ def _download_handler(self, args: argparse.Namespace) -> int:
233
+ """
234
+ Downloads package file(s) from GitLab package registry.
235
+
236
+ Parameters
237
+ ----------
238
+ args : argparse.Namespace
239
+ The parsed arguments
240
+
241
+ Returns
242
+ -------
243
+ int
244
+ Zero if everything goes well, non-zero otherwise
245
+ """
129
246
  ret = 1
130
247
  host, project, name, token_user, token = self._args(args)
131
248
  version = args.version
132
249
  destination = args.destination
133
- gitlab = Packages(host, token_user, token)
134
- package_id = gitlab.get_package_id(project, name, version)
250
+ packages = Packages(host, token_user, token)
251
+ package_id = packages.get_id(project, name, version)
135
252
  if package_id:
136
253
  files = []
137
254
  if args.file:
138
255
  files.append(args.file)
139
256
  else:
140
- files = gitlab.list_files(project, package_id)
257
+ files = packages.get_files(project, package_id).keys()
141
258
  for file in files:
142
- ret = gitlab.download_file(project, name, version, file, destination)
259
+ ret = packages.download_file(project, name, version, file, destination)
143
260
  if ret:
144
261
  print("Failed to download file " + file)
145
262
  break
@@ -147,37 +264,156 @@ class CLIHandler:
147
264
  print("No package " + name + " version " + version + " found!")
148
265
  return ret
149
266
 
150
- def _register_list_parser(self, parser):
267
+ def _register_list_parser(self, parser: argparse.ArgumentParser):
268
+ """
269
+ Registers the list command related arguments to the parser:
270
+ - action
271
+
272
+ Additionally, registers the common args.
273
+
274
+ Parameters
275
+ ----------
276
+ parser:
277
+ The argparser where to register the list arguments.
278
+ """
151
279
  self._register_common_arguments(parser)
152
280
  parser.set_defaults(action=self._list_packages)
153
281
 
154
282
  def _list_packages(self, args: argparse.Namespace) -> int:
283
+ """
284
+ List package versions from GitLab package registry.
285
+
286
+ Parameters
287
+ ----------
288
+ args : argparse.Namespace
289
+ The parsed arguments
290
+
291
+ Returns
292
+ -------
293
+ int
294
+ Zero if everything goes well, non-zero otherwise
295
+ """
155
296
  host, project, name, token_user, token = self._args(args)
156
- gitlab = Packages(host, token_user, token)
157
- packages = gitlab.list_packages(project, name)
297
+ packages = Packages(host, token_user, token)
298
+ package_list = packages.get_versions(project, name)
158
299
  print("Name" + "\t\t" + "Version")
159
- for package in packages:
300
+ for package in package_list:
160
301
  print(package["name"] + "\t" + package["version"])
161
302
 
162
- def _register_upload_parser(self, parser):
303
+ def _register_upload_parser(self, parser: argparse.ArgumentParser):
304
+ """
305
+ Registers the upload command related arguments to the parser:
306
+ - version
307
+ - file
308
+ - action
309
+
310
+ Additionally, registers the common args.
311
+
312
+ Parameters
313
+ ----------
314
+ parser:
315
+ The argparser where to register the upload arguments.
316
+ """
163
317
  self._register_common_arguments(parser)
164
318
  parser.add_argument("-v", "--version", type=str, help="The package version.")
165
319
  parser.add_argument(
166
320
  "-f",
167
321
  "--file",
168
322
  type=str,
169
- help="The file to be uploaded, for example my_file.txt. Note that only relative paths are supported and the relative path is preserved when uploading the file.",
323
+ help="The file to be uploaded, for example my_file.txt. Note that "
324
+ "only relative paths (to the source) are supported and the relative "
325
+ "path is preserved when uploading the file. If left undefined, all files "
326
+ "of the source directory are uploaded. For example --source=temp --file=myfile "
327
+ "will upload myfile to the GitLab generic package root. However using --source=. "
328
+ "(or omittinge source) --file=temp/myfile will upload the file to temp folder "
329
+ "in the GitLab package.",
330
+ )
331
+ parser.add_argument(
332
+ "-s",
333
+ "--source",
334
+ type=str,
335
+ default="",
336
+ help="The source directory of the uploaded file(s). Defaults to current"
337
+ "working directory.",
170
338
  )
171
339
  parser.set_defaults(action=self._upload)
172
340
 
173
- def _upload(self, args) -> int:
174
- ret = 1
341
+ def _upload(self, args: argparse.Namespace) -> int:
342
+ """
343
+ Uploads a file to a GitLab package registry
344
+
345
+ Parameters
346
+ ----------
347
+ args : argparse.Namespace
348
+ The arguments from command line
349
+
350
+ Returns
351
+ -------
352
+ int
353
+ Zero if everything went fine, non-zero otherwise.
354
+ """
355
+ ret = 0
356
+ host, project, name, token_user, token = self._args(args)
357
+ version = args.version
358
+ file = args.file
359
+ source = args.source
360
+ if file:
361
+ # Check if the uploaded file exists.
362
+ if not os.path.isfile(os.path.join(source, file)):
363
+ print("File " + file + " does not exist!")
364
+ ret = 1
365
+ if not ret:
366
+ packages = Packages(host, token_user, token)
367
+ ret = packages.upload_file(project, name, version, file, source)
368
+ return ret
369
+
370
+ def _register_delete_parser(self, parser: argparse.ArgumentParser):
371
+ """
372
+ Registers the delete command related arguments to the parser:
373
+ - version
374
+ - file
375
+ - action
376
+
377
+ Additionally, registers the common args.
378
+
379
+ Parameters
380
+ ----------
381
+ parser:
382
+ The argparser where to register the upload arguments.
383
+ """
384
+ self._register_common_arguments(parser)
385
+ parser.add_argument("-v", "--version", type=str, help="The package version.")
386
+ parser.add_argument(
387
+ "-f",
388
+ "--file",
389
+ type=str,
390
+ help="The file to be deleted, for example my_file.txt. Note that "
391
+ "only relative paths (to the package root) are supported. If undefined, "
392
+ "the package version is deleted.",
393
+ )
394
+ parser.set_defaults(action=self._delete)
395
+
396
+ def _delete(self, args: argparse.Namespace) -> int:
397
+ """
398
+ Deletes a file from a GitLab generic package
399
+
400
+ Parameters
401
+ ----------
402
+ args : argparse.Namespace
403
+ The arguments from command line
404
+
405
+ Returns
406
+ -------
407
+ int
408
+ Zero if everything went fine, non-zero otherwise.
409
+ """
410
+ ret = 0
175
411
  host, project, name, token_user, token = self._args(args)
176
412
  version = args.version
177
413
  file = args.file
178
- if os.path.isfile(file):
179
- gitlab = Packages(host, token_user, token)
180
- ret = gitlab.upload_file(project, name, version, file)
414
+ packages = Packages(host, token_user, token)
415
+ if file:
416
+ ret = packages.delete_file(project, name, version, file)
181
417
  else:
182
- print("File " + file + " does not exist!")
418
+ ret = packages.delete_package(project, name, version)
183
419
  return ret
gitlab/packages.py CHANGED
@@ -1,5 +1,8 @@
1
+ """GitLab generic packages module"""
2
+
3
+ from glob import glob
1
4
  from http.client import HTTPMessage
2
- import json
5
+ from json import loads
3
6
  import logging
4
7
  import os
5
8
  from urllib import request, parse
@@ -8,71 +11,298 @@ logger = logging.getLogger(__name__)
8
11
 
9
12
 
10
13
  class Packages:
14
+ """Class to interact with GitLab packages REST API"""
15
+
11
16
  def __init__(self, host: str, token_type: str, token: str):
17
+ """
18
+ Creates a new instance of class.
19
+
20
+ Parameters
21
+ ----------
22
+ host : str
23
+ The GitLab instance hostname, without schema.
24
+ The host will be used for the package API interaction.
25
+ For example gitlab.com.
26
+ token_type : str
27
+ The token type or "user" to authenticate with GitLab REST API.
28
+ For personal, project, and group tokens this is `PRIVATE-TOKEN`.
29
+ For `CI_JOB_TOKEN` this is `JOB-TOKEN`.
30
+ Can be left empty when authentication is not used.
31
+ token : str
32
+ The token (secret) to authenticate with GitLab REST API.
33
+ This can be a personal token, project token, or`CI_JOB_TOKEN`.
34
+ Leave empty when authentication is not used.
35
+ """
12
36
  self.host = host
13
37
  self.token_type = token_type
14
38
  self.token = token
15
39
 
16
- def api_url(self) -> str:
17
- return "https://{}/api/v4/".format(parse.quote(self.host))
40
+ def _url(self) -> str:
41
+ """
42
+ Returns the GitLab REST API URL by using the host variable.
18
43
 
19
- def project_api_url(self, project: str) -> str:
20
- return self.api_url() + "projects/{}/".format(parse.quote_plus(project))
44
+ Returns
45
+ -------
46
+ str
47
+ The GitLab REST API URL, for example `https://gitlab.com/api/v4/`.
48
+ """
49
+ return f"https://{self.host}/api/v4/"
21
50
 
22
- def get_headers(self) -> dict:
51
+ def _get_headers(self) -> dict:
52
+ """
53
+ Creates headers for a GitLab REST API call.
54
+
55
+ The headers contain token for authentication according to the
56
+ instance variables.
57
+
58
+ Returns
59
+ -------
60
+ dict
61
+ Headers for a REST API request, that contain the authentication token.
62
+ """
23
63
  headers = {}
24
64
  if self.token_type and self.token:
25
65
  headers = {self.token_type: self.token}
26
66
  return headers
27
67
 
28
- def _request(self, url: str) -> tuple[int, bytes, HTTPMessage]:
29
- logger.debug("Requesting " + url)
30
- req = request.Request(url, headers=self.get_headers())
31
- with request.urlopen(req) as response:
32
- return response.status, response.read(), response.headers
68
+ def _parse_header_links(self, headers: HTTPMessage) -> dict:
69
+ """
70
+ Parses link field from HTTP headers to a dictionary where
71
+ the link "rel" value is the key.
33
72
 
34
- def _get_next_page(self, headers: HTTPMessage) -> int:
35
- ret = 0
36
- if headers:
37
- next_page = headers.get("x-next-page")
38
- if next_page:
39
- ret = int(next_page)
40
- logger.debug("Response incomplete, next page is " + next_page)
41
- else:
42
- logger.debug("Response complete")
43
- return ret
73
+ This is useful for example with GitLab REST API to get the pagination links.
74
+
75
+ Parameters
76
+ ----------
77
+ headers : HTTPMessage
78
+ The HTTP response headers that contain the links
79
+
80
+ Returns
81
+ -------
82
+ dict
83
+ The header links in a dictionary, that can be used to for example pagination:
84
+ _parse_header_links(headers).get("next") returns the next page link, or None.
85
+ """
86
+ links = {}
87
+ header_links = headers.get("link")
88
+ if header_links:
89
+ items = header_links.split(",")
90
+ # Values are <uri-reference>; param1=value1; param2="value2"
91
+ for item in items:
92
+ parts = item.split(";", 1)
93
+ if parts:
94
+ # First value should be the URI
95
+ val = parts.pop(0).lstrip(" <").rstrip("> ")
96
+ # The rest are the parameters; let's take the first one that has rel=
97
+ # in it, split it with ; and take the first part as a value
98
+ typ = parts.pop()
99
+ typ = typ.split("rel=", 1).pop().split(";").pop(0).strip('" ')
100
+ links[typ] = val
101
+ return links
102
+
103
+ def _build_path(self, *paths: str) -> str:
104
+ """
105
+ Returns the path to be appended to the REST API URL
106
+
107
+ Parameters
108
+ ----------
109
+ paths : str
110
+ The path string that are joined with "/".
44
111
 
45
- def _build_query(self, arg: str, page: int) -> str:
112
+ Returns
113
+ -------
114
+ str
115
+ A URL path, for example `projects/123/`
116
+ or `projects/namespace%2Fproject/`
117
+ """
118
+ quoted_paths = []
119
+ for subpath in paths:
120
+ quoted_paths.append(subpath)
121
+ return "/".join(quoted_paths)
122
+
123
+ def build_query(self, **args: str) -> str:
124
+ """
125
+ Builds a query for a GitLab REST API request
126
+
127
+ Parameters
128
+ ----------
129
+ args : str
130
+ keyword arguments for the REST API query, like per_page=20, page=20
131
+
132
+ Returns
133
+ -------
134
+ str
135
+ A query string for a REST API request. Append this to
136
+ the request URL. Example `?per_page=20&page=20`.
137
+ """
46
138
  query = ""
47
- if arg or page:
48
- if page:
49
- page = "page=" + str(page)
50
- query = "?{}".format("&".join(filter(None, (arg, page))))
139
+ for key, value in args.items():
140
+ query = "&".join(filter(None, (query, f"{key}={parse.quote_plus(value)}")))
141
+ if query:
142
+ query = "?" + query
51
143
  return query
52
144
 
53
- def gl_project_api(self, project: str, path: str, arg: str = None) -> list:
145
+ def _get(self, url: str, headers: dict) -> tuple[int, bytes, HTTPMessage]:
146
+ """
147
+ Makes a raw GET request to the given URL, and returns
148
+ the response status, body, and headers.
149
+
150
+ Parameters
151
+ ----------
152
+ url : str
153
+ The URL of the HTTP request to make.
154
+ headers: dict
155
+ The HTTP headers used in the request.
156
+
157
+ Returns
158
+ -------
159
+ int
160
+ The HTTP response code, such as 200
161
+ bytes
162
+ The HTTP response body read as bytes
163
+ HTTPMessage
164
+ The HTTP response headers
165
+ """
166
+ logger.debug("Getting %s", url)
167
+ req = request.Request(url, headers=headers)
168
+ with request.urlopen(req) as response:
169
+ return response.status, response.read(), response.headers
170
+
171
+ def get(self, *paths: str, **query_params: str) -> tuple[int, bytes, HTTPMessage]:
172
+ """
173
+ Makes a HTTP GET request to the given GitLab path, and returns
174
+ the response status, body, and headers.
175
+
176
+ Parameters
177
+ ----------
178
+ paths : str
179
+ The URL path of the HTTP request to make.
180
+ query_params : str, optional
181
+ Dictionary of query parameters for the request, like package_name=mypackage
182
+
183
+ Returns
184
+ -------
185
+ int
186
+ The HTTP response code, such as 200
187
+ bytes
188
+ The HTTP response body read as bytes
189
+ HTTPMessage
190
+ The HTTP response headers
191
+ """
192
+ url = self._url() + self._build_path(*paths) + self.build_query(**query_params)
193
+ headers = self._get_headers()
194
+ return self._get(url, headers)
195
+
196
+ def get_all(self, *paths: str, **query_params: str) -> list:
197
+ """
198
+ Returns data from the REST API endpoint. In case
199
+ of multiple pages, all data will be returned.
200
+
201
+ Parameters
202
+ ----------
203
+ paths : str
204
+ The paths of the API endpoint that is called. For example projects, 123, packages
205
+ would be querying the projects/123/packages endpoint.
206
+ query_params : str, optional
207
+ Additional arguments for the query of the URL, for example to filter
208
+ results: package_name=mypackage
209
+
210
+ Returns
211
+ -------
212
+ list
213
+ Data from GitLab REST API endpoint with the arguments.
214
+ """
215
+ url = self._url() + self._build_path(*paths) + self.build_query(**query_params)
54
216
  data = []
55
- more = True
56
- page = None
57
- while more:
58
- more = False
59
- query = self._build_query(arg, page)
60
- url = self.project_api_url(project) + path + query
61
- status, res_data, headers = self._request(url)
62
- logger.debug("Response status: " + str(status))
63
- res_data = json.loads(res_data)
64
- logger.debug("Response data: " + str(res_data))
217
+ while url:
218
+ res_status, res_data, res_headers = self._get(url, self._get_headers())
219
+ logger.debug("Response status: %d", res_status)
220
+ res_data = loads(res_data)
221
+ logger.debug("Response data: %s", res_data)
65
222
  data = data + res_data
66
- page = self._get_next_page(headers)
67
- if page:
68
- more = True
223
+ url = self._parse_header_links(res_headers).get("next")
69
224
  return data
70
225
 
71
- def list_packages(self, project: str, package_name: str) -> list:
226
+ def put(self, data: bytes, *paths: str, **query_params: str) -> int:
227
+ """
228
+ Makes a HTTP PUT request to the given GitLab path.
229
+
230
+ Parameters
231
+ ----------
232
+ data : bytes
233
+ The data to PUT.
234
+ paths : str
235
+ The URL path of the HTTP request to make.
236
+ query_params : str, optional
237
+ Additional arguments for the query of the URL, for example to filter
238
+ results: package_name=mypackage
239
+
240
+ Returns
241
+ -------
242
+ int
243
+ 0 If the request was successful.
244
+ """
245
+ ret = 1
246
+ url = self._url() + self._build_path(*paths) + self.build_query(**query_params)
247
+ logger.debug("Putting %s", url)
248
+ req = request.Request(url, method="PUT", data=data, headers=self._get_headers())
249
+ with request.urlopen(req) as res:
250
+ if res.status == 201: # 201 is created
251
+ ret = 0
252
+ return ret
253
+
254
+ def delete(self, *paths: str, **query_params: str) -> int:
255
+ """
256
+ Makes a HTTP DELETE request to the given GitLab path.
257
+
258
+ Parameters
259
+ ----------
260
+ paths : str
261
+ The URL path of the HTTP request to make.
262
+ query_params : str, optional
263
+ Additional arguments for the query of the URL, for example to filter
264
+ results: package_name=mypackage
265
+
266
+ Returns
267
+ -------
268
+ int
269
+ 0 If the request was successful.
270
+ """
271
+ ret = 1
272
+ url = self._url() + self._build_path(*paths) + self.build_query(**query_params)
273
+ logger.debug("Deleting %s", url)
274
+ req = request.Request(url, method="DELETE", headers=self._get_headers())
275
+ with request.urlopen(req) as res:
276
+ if res.status == 204:
277
+ # 204 is no content, that GL responds when file deleted
278
+ ret = 0
279
+ return ret
280
+
281
+ def get_versions(self, project_id: str, package_name: str) -> list:
282
+ """
283
+ Lists the available versions of the package
284
+
285
+ Parameters
286
+ ----------
287
+ project_id : str
288
+ The project ID or path, including namespace.
289
+ Examples: `123` or `namespace/project`.
290
+ package_name : str
291
+ The name of the package that is listed.
292
+
293
+ Returns
294
+ -------
295
+ list
296
+ List of {package: name, version: version} that are available.
297
+ """
72
298
  packages = []
73
- logger.debug("Listing packages with name " + package_name)
74
- data = self.gl_project_api(
75
- project, "packages", "package_name=" + parse.quote_plus(package_name)
299
+ logger.debug("Listing packages with name %s", package_name)
300
+ data = self.get_all(
301
+ "projects",
302
+ parse.quote_plus(project_id),
303
+ "packages",
304
+ package_name=package_name,
305
+ package_type="generic",
76
306
  )
77
307
  for package in data:
78
308
  name = parse.unquote(package["name"])
@@ -84,93 +314,310 @@ class Packages:
84
314
  packages.append({"name": name, "version": version})
85
315
  return packages
86
316
 
87
- def list_files(self, project: str, package_id: int) -> list:
88
- files = []
89
- logger.debug("Listing package " + str(package_id) + " files")
90
- path = "packages/" + parse.quote_plus(str(package_id)) + "/package_files"
91
- data = self.gl_project_api(project, path)
317
+ def get_files(self, project_id: str, package_id: int) -> dict:
318
+ """
319
+ Lists all files of a specific package ID from GitLab REST API
320
+
321
+ Parameters
322
+ ----------
323
+ project_id : str
324
+ The project ID or path, including namespace.
325
+ Examples: `123` or `namespace/project`.
326
+ package_id : int
327
+ The package ID that is listed
328
+
329
+ Return
330
+ ------
331
+ dict
332
+ Dictionary of file (names) that are in the package, with
333
+ each element containing a dictionary containing information
334
+ of the file
335
+ """
336
+ files = {}
337
+ logger.debug("Listing package %d files", package_id)
338
+ data = self.get_all(
339
+ "projects",
340
+ parse.quote_plus(project_id),
341
+ "packages",
342
+ str(package_id),
343
+ "package_files",
344
+ )
92
345
  for package in data:
93
346
  # Only append the filename once to the list of files
94
347
  # as there's no way to download them separately through
95
348
  # the API
96
349
  filename = parse.unquote(package["file_name"])
97
- if filename not in files:
98
- files.append(filename)
350
+ file_id = package["id"]
351
+ if not files.get(filename):
352
+ files[filename] = {"id": file_id}
99
353
  return files
100
354
 
101
- def get_package_id(
102
- self, project: str, package_name: str, package_version: str
103
- ) -> int:
104
- id = 0
105
- logger.debug(
106
- "Fetching package " + package_name + " (" + package_version + ") ID"
107
- )
108
- path = "packages"
109
- arg = (
110
- "package_name="
111
- + parse.quote_plus(package_name)
112
- + "&package_version="
113
- + parse.quote_plus(package_version)
355
+ def get_id(self, project_id: str, package_name: str, package_version: str) -> int:
356
+ """
357
+ Gets the package ID of a specific package version.
358
+
359
+ Parameters
360
+ ----------
361
+ project_id : str
362
+ The project ID or path, including namespace.
363
+ Examples: `123` or `namespace/project`.
364
+ package_name : str
365
+ The name of the package.
366
+ package_version : str
367
+ The version of the package
368
+
369
+ Return
370
+ ------
371
+ int
372
+ The ID of the package. -1 if no ID was found.
373
+ """
374
+ package_id = -1
375
+ logger.debug("Fetching package %s (%s) ID", package_name, package_version)
376
+ data = self.get_all(
377
+ "projects",
378
+ parse.quote_plus(project_id),
379
+ "packages",
380
+ package_name=package_name,
381
+ package_version=package_version,
382
+ package_type="generic",
114
383
  )
115
- data = self.gl_project_api(project, path, arg)
116
384
  if len(data) == 1:
117
385
  package = data.pop()
118
- id = package["id"]
119
- return id
386
+ package_id = package["id"]
387
+ return package_id
120
388
 
121
389
  def download_file(
122
390
  self,
123
- project: str,
391
+ project_id: str,
124
392
  package_name: str,
125
393
  package_version: str,
126
394
  filename: str,
127
395
  destination: str = "",
128
396
  ) -> int:
397
+ """
398
+ Downloads a file from a GitLab generic package
399
+
400
+ Parameters
401
+ ----------
402
+ project_id : str
403
+ The project ID or path, including namespace.
404
+ Examples: `123` or `namespace/project`.
405
+ package_name : str
406
+ The name of the generic package.
407
+ package_version : str
408
+ The version of the generic package
409
+ filename : str
410
+ The file that is downloaded
411
+ destination : str, optional
412
+ The destination folder of the downloaded file. If not set,
413
+ current working directory is used.
414
+
415
+ Return
416
+ ------
417
+ int
418
+ Zero if everything went fine, non-zero coke otherwise.
419
+ """
129
420
  ret = 1
130
- logger.debug("Downloading file " + filename)
131
- url = (
132
- self.project_api_url(project)
133
- + "packages/generic/"
134
- + parse.quote_plus(package_name)
135
- + "/"
136
- + parse.quote_plus(package_version)
137
- + "/"
138
- + parse.quote(filename)
421
+ logger.debug("Downloading file %s", filename)
422
+ status, data, _ = self.get(
423
+ "projects",
424
+ parse.quote_plus(project_id),
425
+ "packages",
426
+ "generic",
427
+ parse.quote_plus(package_name),
428
+ parse.quote_plus(package_version),
429
+ parse.quote(filename),
139
430
  )
140
- status, data, _ = self._request(url)
141
431
  if status == 200:
142
- path = os.path.join(destination, filename)
143
- parent = os.path.dirname(path)
432
+ fpath = os.path.join(destination, filename)
433
+ parent = os.path.dirname(fpath)
144
434
  if parent:
145
435
  # Create missing directories if needed
146
436
  # In case path has no parent, current
147
437
  # workind directory is used
148
- os.makedirs(os.path.dirname(path), exist_ok=True)
149
- with open(path, "wb") as file:
438
+ os.makedirs(os.path.dirname(fpath), exist_ok=True)
439
+ with open(fpath, "wb") as file:
150
440
  file.write(data)
151
441
  ret = 0
152
442
  return ret
153
443
 
154
444
  def upload_file(
155
- self, project: str, package_name: str, package_version: str, file: str
445
+ self,
446
+ project_id: str,
447
+ package_name: str,
448
+ package_version: str,
449
+ filename: str,
450
+ source: str,
156
451
  ) -> int:
452
+ """
453
+ Uploads file(s) to a GitLab generic package.
454
+
455
+ Parameters
456
+ ----------
457
+ project_id : str
458
+ The project ID or path, including namespace.
459
+ Examples: `123` or `namespace/project`.
460
+ package_name : str
461
+ The name of the generic package.
462
+ package_version : str
463
+ The version of the generic package
464
+ pfile : str
465
+ The relative path of the file that is uploaded. If left empty,
466
+ all files from the source folder, and it's subfolders, are uploaded.
467
+ source : str
468
+ The source folder that is used as root when uploading. If empty,
469
+ current working directory is used.
470
+
471
+ Return
472
+ ------
473
+ int
474
+ Zero if everything went fine, non-zero coke otherwise.
475
+ """
476
+ files = []
157
477
  ret = 1
158
- logger.debug("Uploading file " + file)
159
- with open(str(file), "rb") as data:
160
- url = (
161
- self.project_api_url(project)
162
- + "packages/generic/"
163
- + parse.quote_plus(package_name)
164
- + "/"
165
- + parse.quote_plus(package_version)
166
- + "/"
167
- + parse.quote(str(file))
478
+ if filename:
479
+ files.append(filename)
480
+ else:
481
+ filelist = glob(os.path.join(source, "**"), recursive=True)
482
+ for item in filelist:
483
+ # Only add files, not folders
484
+ if os.path.isfile(os.path.join(item)):
485
+ # Remove the source folder from the path of the files
486
+ files.append(os.path.relpath(item, source))
487
+ for afile in files:
488
+ ret = self._upload_file(
489
+ project_id, package_name, package_version, afile, source
168
490
  )
169
- res = request.urlopen(
170
- request.Request(
171
- url, method="PUT", data=data, headers=self.get_headers()
172
- )
491
+ if ret:
492
+ break
493
+ return ret
494
+
495
+ def _upload_file(
496
+ self,
497
+ project_id: str,
498
+ package_name: str,
499
+ package_version: str,
500
+ filename: str,
501
+ source: str,
502
+ ) -> int:
503
+ """
504
+ Uploads a file to a GitLab generic package.
505
+
506
+ Parameters
507
+ ----------
508
+ project_id : str
509
+ The project ID or path, including namespace.
510
+ Examples: `123` or `namespace/project`.
511
+ package_name : str
512
+ The name of the generic package.
513
+ package_version : str
514
+ The version of the generic package
515
+ filename : str
516
+ The relative path of the file that is uploaded.
517
+ source : str
518
+ The source folder that is used as root when uploading.
519
+
520
+ Return
521
+ ------
522
+ int
523
+ Zero if everything went fine, non-zero coke otherwise.
524
+ """
525
+ ret = 1
526
+ fpath = os.path.join(source, filename)
527
+ logger.debug("Uploading file %s from %s", filename, source)
528
+ with open(fpath, "rb") as data:
529
+ ret = self.put(
530
+ data.read(),
531
+ "projects",
532
+ parse.quote_plus(project_id),
533
+ "packages",
534
+ "generic",
535
+ parse.quote_plus(package_name),
536
+ parse.quote_plus(package_version),
537
+ parse.quote(filename),
173
538
  )
174
- if res.status == 201: # 201 is created
175
- ret = 0
539
+ return ret
540
+
541
+ def delete_package(
542
+ self, project_id: str, package_name: str, package_version: str
543
+ ) -> int:
544
+ """
545
+ Deletes a version of a GitLab generic package.
546
+
547
+ Parameters
548
+ ----------
549
+ project_id : str
550
+ The project ID or path, including namespace.
551
+ Examples: `123` or `namespace/project`.
552
+ package_name : str
553
+ The name of the generic package.
554
+ package_version : str
555
+ The version of the generic package that is deleted
556
+
557
+ Return
558
+ ------
559
+ int
560
+ Zero if everything went fine, non-zero coke otherwise.
561
+ """
562
+ ret = 1
563
+ package_id = self.get_id(project_id, package_name, package_version)
564
+ if package_id > 0:
565
+ ret = self.delete(
566
+ "projects", parse.quote_plus(project_id), "packages", str(package_id)
567
+ )
568
+ return ret
569
+
570
+ def delete_file(
571
+ self,
572
+ project_id: str,
573
+ package_name: str,
574
+ package_version: str,
575
+ filename: str,
576
+ ) -> int:
577
+ """
578
+ Deletes a file from a GitLab generic package.
579
+
580
+ Parameters
581
+ ----------
582
+ project_id : str
583
+ The project ID or path, including namespace.
584
+ Examples: `123` or `namespace/project`.
585
+ package_name : str
586
+ The name of the generic package.
587
+ package_version : str
588
+ The version of the generic package
589
+ filename : str
590
+ The path of the file to be deleted in the package.
591
+
592
+ Return
593
+ ------
594
+ int
595
+ Zero if everything went fine, non-zero coke otherwise.
596
+ """
597
+ ret = 1
598
+ package_id = self.get_id(project_id, package_name, package_version)
599
+ if package_id > 0:
600
+ package_files = self.get_files(project_id, package_id)
601
+ file_id = package_files.get(filename)
602
+ if file_id and file_id.get("id"):
603
+ file_id = file_id.get("id")
604
+ ret = self._delete_file(project_id, package_id, file_id)
605
+ return ret
606
+
607
+ def _delete_file(self, project_id: str, package_id: int, file_id: int) -> int:
608
+ ret = 1
609
+ logger.info(
610
+ "Deleting file %d from package %d from project %s",
611
+ file_id,
612
+ package_id,
613
+ project_id,
614
+ )
615
+ ret = self.delete(
616
+ "projects",
617
+ parse.quote_plus(project_id),
618
+ "packages",
619
+ str(package_id),
620
+ "package_files",
621
+ str(file_id),
622
+ )
176
623
  return ret
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: glpkg
3
- Version: 1.2.0
3
+ Version: 1.4.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
@@ -15,6 +15,7 @@ Dynamic: license-file
15
15
 
16
16
  glpkg is a tool that makes it easy to work with [GitLab generic packages](https://docs.gitlab.com/user/packages/generic_packages/).
17
17
 
18
+ Generic package registries of GitLab projects are supported. Group registries are not, as the GitLab REST API for groups is very limited.
18
19
 
19
20
  ## Installation
20
21
 
@@ -26,7 +27,6 @@ pip install glpkg
26
27
 
27
28
  To check the installation and version, run:
28
29
 
29
-
30
30
  ```bash
31
31
  glpkg --version
32
32
  ```
@@ -105,10 +105,49 @@ Where:
105
105
  - `12345` is your projects ID ([Find the Project ID](https://docs.gitlab.com/user/project/working_with_projects/#find-the-project-id)) or the path of the project (like `namespace/project`)
106
106
  - `mypackagename` is the name of the generic package
107
107
  - `1.0` is the version of the generic package to which the file is uploaded
108
- - `my-file.txt` is the file that is uploaded to the generic package. Currently, only relative paths are supported, and the relative path (e.g. `folder/file.txt`) is preserved when uploading the file to the registry.
108
+ - `my-file.txt` is the file that is uploaded to the generic package. Only relative paths are supported, and the relative path (e.g. `folder/file.txt`) is preserved when uploading the file to the package.
109
109
 
110
110
  > A GitLab generic package may have multiple files with the same file name. However, it likely is not a great idea, as they cannot be downloaded separately from the GitLab API.
111
111
 
112
+ To upload multiple files, or to upload a single file from a different directory, use `--source` argument. If no `--file` argument is set, all of the files in the source directory are uploaded, recursively. As an example, to upload all files from a `upload` folder to the package:
113
+
114
+ ```bash
115
+ glpkg upload --project 12345 --name mypackagename --version 1.0 --source upload
116
+ ```
117
+
118
+ Note: a file in `upload` folder will be uploaded to the root of the package. If you want to upload the file to a `dir` folder in the package, make a structure in the upload folder, like `upload/dir/`.
119
+
120
+ ### Delete a file from a generic package
121
+
122
+ To delete a file from a specific generic package, run
123
+
124
+ ```bash
125
+ glpkg delete --project 12345 --name mypackagename --version 1.0 --file my-file.txt
126
+ ```
127
+
128
+ Where
129
+ - `12345` is your projects ID ([Find the Project ID](https://docs.gitlab.com/user/project/working_with_projects/#find-the-project-id)) or the path of the project (like `namespace/project`)
130
+ - `mypackagename` is the name of the generic package
131
+ - `1.0` is the version of the generic package from the file is deleted
132
+ - `my-file.txt` is the file that is deleted. Only relative paths are supported. Note that the package may contain multiple files with same name. In this case, one file of them is deleted.
133
+
134
+ The token that is used to delete files must have at least Maintainer role in the project.
135
+
136
+ ### Delete a package version
137
+
138
+ To delete a specific generic package version, run
139
+
140
+ ```bash
141
+ glpkg delete --project 12345 --name mypackagename --version 1.0
142
+ ```
143
+
144
+ Where
145
+ - `12345` is your projects ID ([Find the Project ID](https://docs.gitlab.com/user/project/working_with_projects/#find-the-project-id)) or the path of the project (like `namespace/project`)
146
+ - `mypackagename` is the name of the generic package
147
+ - `1.0` is the version of the generic package that is deleted
148
+
149
+ The token that is used to delete packages must have at least Maintainer role in the project.
150
+
112
151
  ### Use in GitLab pipelines
113
152
 
114
153
  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`.
@@ -120,11 +159,3 @@ glpkg upload --ci --name mypackagename --version 1.0 --file my-file.txt
120
159
  ```
121
160
 
122
161
  To use the `CI_JOB_TOKEN` with package registry of another projects, add `--project <otherproject ID>` argument. Remember that you may need to add [permissions for the CI_JOB_TOKEN](https://docs.gitlab.com/ci/jobs/ci_job_token/#control-job-token-access-to-your-project) in the other project.
123
-
124
-
125
- ## Limitations
126
-
127
- The tool is not perfect (yet) and has limitations. The following limitations are known, but more can exist:
128
-
129
- - Uploading files must be done one-by-one.
130
- - Only project registries are supported for now.
@@ -0,0 +1,10 @@
1
+ gitlab/__init__.py,sha256=TXqR34k7YgbFTeeKfSOdxBH0lFaC3-SiZU5mTBL9GI0,105
2
+ gitlab/__main__.py,sha256=UPVixjP98KzJi9Ljth4yqqDTTaE3EfyAuj2xGCQAGys,586
3
+ gitlab/cli_handler.py,sha256=02Yc3yE3jNMGTx_HvUu0rYH5NwxrnTRaaeAGMFh8Mq8,14273
4
+ gitlab/packages.py,sha256=wP0hRLad09LoE9uinUljrvGoBt6KfP49QESqOpxLihQ,20255
5
+ glpkg-1.4.0.dist-info/licenses/LICENSE.md,sha256=josGXvZq628dNS0Iru58-DPE7dRpDXzjJxKKT35103g,1065
6
+ glpkg-1.4.0.dist-info/METADATA,sha256=PX4jWVi_HCznrjQ9OztSYqYurdqv1-qHXKbkRiIUVlU,7678
7
+ glpkg-1.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ glpkg-1.4.0.dist-info/entry_points.txt,sha256=xHPZwx2oShYDZ3AyH7WSIvuhFMssy7QLlQk-JAbje_w,46
9
+ glpkg-1.4.0.dist-info/top_level.txt,sha256=MvIaP8p_Oaf4gO_hXmHkX-5y2deHLp1pe6tJR3ukQ6o,7
10
+ glpkg-1.4.0.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- gitlab/__init__.py,sha256=U83zG_KkowpY9GMPLmmySx5fyNx52nojUJkfIpzMo5o,60
2
- gitlab/__main__.py,sha256=88VNY5Qrmn8g0rNcnjKNdN746--0chHsKBMH3PD3Nao,177
3
- gitlab/cli_handler.py,sha256=RKEEcbxFCFkT8sbYDlMXuQPM4wvCb0okBiFfUXXlJtg,7314
4
- gitlab/packages.py,sha256=8w5bc5I03dEqkMcLydqtgQ9UN2SJWw7MlkjnJXp6fmA,6099
5
- glpkg-1.2.0.dist-info/licenses/LICENSE.md,sha256=josGXvZq628dNS0Iru58-DPE7dRpDXzjJxKKT35103g,1065
6
- glpkg-1.2.0.dist-info/METADATA,sha256=cVhMx6Gt_Nx1RkXNeetlu8f7__dkO5y1c2jOncFlZT8,5841
7
- glpkg-1.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
- glpkg-1.2.0.dist-info/entry_points.txt,sha256=xHPZwx2oShYDZ3AyH7WSIvuhFMssy7QLlQk-JAbje_w,46
9
- glpkg-1.2.0.dist-info/top_level.txt,sha256=MvIaP8p_Oaf4gO_hXmHkX-5y2deHLp1pe6tJR3ukQ6o,7
10
- glpkg-1.2.0.dist-info/RECORD,,
File without changes