grz-cli 0.1.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.
__init__.py ADDED
File without changes
grz_cli/__init__.py ADDED
File without changes
grz_cli/cli.py ADDED
@@ -0,0 +1,326 @@
1
+ """
2
+ CLI module for handling command-line interface operations.
3
+ """
4
+
5
+ import logging
6
+ import logging.config
7
+ import sys
8
+ from os import PathLike, sched_getaffinity
9
+ from pathlib import Path
10
+ from tempfile import NamedTemporaryFile
11
+
12
+ import click
13
+ import platformdirs
14
+ import yaml
15
+
16
+ from .constants import PACKAGE_ROOT
17
+ from .logging_setup import add_filelogger
18
+ from .models.config import ConfigModel
19
+ from .parser import Worker
20
+
21
+ log = logging.getLogger(PACKAGE_ROOT + ".cli")
22
+
23
+ DEFAULT_CONFIG_PATH = Path(platformdirs.user_config_dir("grz-cli")) / "config.yaml"
24
+
25
+ # Aliases for path types for click options
26
+ # Naming convention: {DIR,FILE}_{Read,Write}_{Exists,Create}
27
+ DIR_R_E = click.Path(
28
+ exists=True,
29
+ file_okay=False,
30
+ dir_okay=True,
31
+ readable=True,
32
+ writable=False,
33
+ resolve_path=True,
34
+ )
35
+ DIR_RW_E = click.Path(
36
+ exists=True,
37
+ file_okay=False,
38
+ dir_okay=True,
39
+ readable=True,
40
+ writable=True,
41
+ resolve_path=True,
42
+ )
43
+ DIR_RW_C = click.Path(
44
+ exists=False,
45
+ file_okay=False,
46
+ dir_okay=True,
47
+ readable=True,
48
+ writable=True,
49
+ resolve_path=True,
50
+ )
51
+ FILE_R_E = click.Path(
52
+ exists=True, file_okay=True, dir_okay=False, readable=True, resolve_path=True
53
+ )
54
+
55
+ submission_dir = click.option(
56
+ "--submission-dir",
57
+ metavar="PATH",
58
+ type=DIR_R_E,
59
+ required=True,
60
+ help="Path to the submission directory containing 'metadata/', 'files/', 'encrypted_files/' and 'logs/' directories",
61
+ )
62
+
63
+ config_file = click.option(
64
+ "--config-file",
65
+ metavar="STRING",
66
+ type=FILE_R_E,
67
+ required=False,
68
+ default=DEFAULT_CONFIG_PATH,
69
+ help="Path to config file",
70
+ )
71
+
72
+ threads = click.option(
73
+ "--threads",
74
+ default=min(len(sched_getaffinity(0)), 4),
75
+ type=int,
76
+ show_default=True,
77
+ help="Number of threads to use for parallel operations",
78
+ )
79
+
80
+ submission_id = click.option(
81
+ "--submission-id",
82
+ required=True,
83
+ type=str,
84
+ metavar="STRING",
85
+ help="S3 submission prefix (corresponds to the tanG of a submission)",
86
+ )
87
+
88
+ output_dir = click.option(
89
+ "--output-dir",
90
+ metavar="PATH",
91
+ type=DIR_RW_E,
92
+ required=True,
93
+ default=None,
94
+ help="Path to the target submission output directory",
95
+ )
96
+
97
+
98
+ class OrderedGroup(click.Group):
99
+ """
100
+ A click Group that keeps track of the order in which commands are added.
101
+ """
102
+
103
+ def list_commands(self, ctx):
104
+ """Return the list of commands in the order they were added."""
105
+ return list(self.commands.keys())
106
+
107
+
108
+ @click.group(
109
+ cls=OrderedGroup,
110
+ help="Validate, encrypt, decrypt and upload submissions to a GRZ/GDC.",
111
+ )
112
+ @click.version_option(version="0.1", prog_name="grz-cli")
113
+ @click.option("--log-file", metavar="FILE", type=str, help="Path to log file")
114
+ @click.option(
115
+ "--log-level",
116
+ default="INFO",
117
+ type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
118
+ help="Set the log level (default: INFO)",
119
+ )
120
+ def cli(log_file: str | None = None, log_level: str = "INFO"):
121
+ """
122
+ Command-line interface function for setting up logging.
123
+
124
+ :param log_file: Path to the log file. If provided, a file logger will be added.
125
+ :param log_level: Log level for the logger. It should be one of the following:
126
+ DEBUG, INFO, WARNING, ERROR, CRITICAL.
127
+ """
128
+ if log_file:
129
+ add_filelogger(
130
+ log_file,
131
+ log_level.upper(),
132
+ ) # Add file logger
133
+
134
+ # show only time and log level in STDOUT
135
+ logging.basicConfig(
136
+ format="%(asctime)s - %(levelname)s - %(message)s",
137
+ )
138
+
139
+ # set the log level for this package
140
+ logging.getLogger(PACKAGE_ROOT).setLevel(log_level.upper())
141
+
142
+ log.debug("Logging setup complete.")
143
+
144
+
145
+ @cli.command()
146
+ @submission_dir
147
+ def validate(submission_dir):
148
+ """
149
+ Validate the submission.
150
+
151
+ This validates the submission by checking its checksums, as well as performing basic sanity checks on the supplied metadata.
152
+ Must be executed before calling `encrypt` and `upload`.
153
+ """
154
+ log.info("Starting validation...")
155
+
156
+ submission_dir = Path(submission_dir)
157
+
158
+ worker_inst = Worker(
159
+ metadata_dir=submission_dir / "metadata",
160
+ files_dir=submission_dir / "files",
161
+ log_dir=submission_dir / "logs",
162
+ encrypted_files_dir=submission_dir / "encrypted_files",
163
+ )
164
+ worker_inst.validate()
165
+
166
+ log.info("Validation finished!")
167
+
168
+
169
+ @cli.command()
170
+ @submission_dir
171
+ @config_file
172
+ def encrypt(
173
+ submission_dir,
174
+ config_file,
175
+ ):
176
+ """
177
+ Encrypt a submission.
178
+
179
+ Encryption is done with the recipient's public key.
180
+ Sub-folders 'encrypted_files' and 'logs' are created within the submission directory.
181
+ """
182
+ config = read_config(config_file)
183
+
184
+ submitter_privkey_path = config.submitter_private_key_path
185
+ if submitter_privkey_path == "":
186
+ submitter_privkey_path = None
187
+
188
+ log.info("Starting encryption...")
189
+
190
+ submission_dir = Path(submission_dir)
191
+
192
+ worker_inst = Worker(
193
+ metadata_dir=submission_dir / "metadata",
194
+ files_dir=submission_dir / "files",
195
+ log_dir=submission_dir / "logs",
196
+ encrypted_files_dir=submission_dir / "encrypted_files",
197
+ )
198
+ if config.grz_public_key:
199
+ with NamedTemporaryFile("w") as f:
200
+ f.write(config.grz_public_key)
201
+ f.flush()
202
+ worker_inst.encrypt(
203
+ f.name,
204
+ submitter_private_key_path=submitter_privkey_path,
205
+ )
206
+ else:
207
+ worker_inst.encrypt(
208
+ config.grz_public_key_path,
209
+ submitter_private_key_path=submitter_privkey_path,
210
+ )
211
+
212
+ log.info("Encryption successful!")
213
+
214
+
215
+ @cli.command()
216
+ @submission_dir
217
+ @config_file
218
+ def decrypt(
219
+ submission_dir,
220
+ config_file,
221
+ ):
222
+ """
223
+ Decrypt a submission.
224
+
225
+ Decrypting a submission requires the _private_ key of the original recipient.
226
+ """
227
+ config = read_config(config_file)
228
+
229
+ grz_privkey_path = config.grz_private_key_path
230
+ if not grz_privkey_path:
231
+ log.error("GRZ private key path is required for decryption.")
232
+ sys.exit(1)
233
+
234
+ log.info("Starting encryption...")
235
+
236
+ submission_dir = Path(submission_dir)
237
+
238
+ worker_inst = Worker(
239
+ metadata_dir=submission_dir / "metadata",
240
+ files_dir=submission_dir / "files",
241
+ log_dir=submission_dir / "logs",
242
+ encrypted_files_dir=submission_dir / "encrypted_files",
243
+ )
244
+ worker_inst.decrypt(grz_privkey_path)
245
+
246
+ log.info("Encryption successful!")
247
+
248
+
249
+ @cli.command()
250
+ @submission_dir
251
+ @config_file
252
+ @threads
253
+ def upload(
254
+ submission_dir,
255
+ config_file,
256
+ threads,
257
+ ):
258
+ """
259
+ Upload a submission to a GRZ/GDC.
260
+ """
261
+ config = read_config(config_file)
262
+
263
+ log.info("Starting upload...")
264
+
265
+ submission_dir = Path(submission_dir)
266
+
267
+ worker_inst = Worker(
268
+ metadata_dir=submission_dir / "metadata",
269
+ files_dir=submission_dir / "files",
270
+ log_dir=submission_dir / "logs",
271
+ encrypted_files_dir=submission_dir / "encrypted_files",
272
+ threads=threads,
273
+ )
274
+ worker_inst.upload(config)
275
+
276
+ log.info("Upload finished!")
277
+
278
+
279
+ @cli.command()
280
+ @submission_id
281
+ @output_dir
282
+ @config_file
283
+ @threads
284
+ def download(
285
+ submission_id,
286
+ output_dir,
287
+ config_file,
288
+ threads,
289
+ ):
290
+ """
291
+ Download a submission from a GRZ.
292
+
293
+ Downloaded metadata is stored within the `metadata` sub-folder of the submission output directory.
294
+ Downloaded files are stored within the `encrypted_files` sub-folder of the submission output directory.
295
+ """
296
+ config = read_config(config_file)
297
+
298
+ log.info("Starting download...")
299
+
300
+ submission_dir_path = Path(output_dir)
301
+ if not submission_dir_path.is_dir():
302
+ log.debug("Creating submission directory %s", submission_dir_path)
303
+ submission_dir_path.mkdir(mode=0o770, parents=False, exist_ok=False)
304
+
305
+ worker_inst = Worker(
306
+ metadata_dir=submission_dir_path / "metadata",
307
+ files_dir=submission_dir_path / "files",
308
+ log_dir=submission_dir_path / "logs",
309
+ encrypted_files_dir=submission_dir_path / "encrypted_files",
310
+ threads=threads,
311
+ )
312
+ worker_inst.download(config, submission_id)
313
+
314
+ log.info("Download finished!")
315
+
316
+
317
+ def read_config(config_path: str | PathLike) -> ConfigModel:
318
+ """Reads the configuration file and validates it against the schema."""
319
+ with open(config_path, encoding="utf-8") as f:
320
+ config = ConfigModel(**yaml.safe_load(f))
321
+
322
+ return config
323
+
324
+
325
+ if __name__ == "__main__":
326
+ cli()
grz_cli/constants.py ADDED
@@ -0,0 +1,37 @@
1
+ """Constants for logging configuration, JSON schema validation, and other settings."""
2
+
3
+ PACKAGE_ROOT = "grz_cli"
4
+
5
+ LOGGING_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
6
+ LOGGING_DATEFMT = "%Y-%m-%d %I:%M %p"
7
+ LOGGING_CONFIG = {
8
+ "version": 1,
9
+ "disable_existing_loggers": True,
10
+ "formatters": {
11
+ "standard": {
12
+ "format": LOGGING_FORMAT,
13
+ "datefmt": LOGGING_DATEFMT,
14
+ },
15
+ },
16
+ "handlers": {
17
+ "default": {
18
+ "level": "INFO",
19
+ "formatter": "standard",
20
+ "class": "logging.StreamHandler",
21
+ "stream": "ext://sys.stdout", # Default is stderr
22
+ },
23
+ },
24
+ "loggers": {
25
+ "": { # root logger
26
+ "handlers": ["default"],
27
+ "level": "WARNING",
28
+ "propagate": False,
29
+ },
30
+ PACKAGE_ROOT: {"handlers": ["default"], "level": "INFO", "propagate": False},
31
+ "__main__": { # if __name__ == '__main__'
32
+ "handlers": ["default"],
33
+ "level": "DEBUG",
34
+ "propagate": False,
35
+ },
36
+ },
37
+ }
grz_cli/download.py ADDED
@@ -0,0 +1,236 @@
1
+ """Module for uploading encrypted submissions to a remote storage"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import math
7
+ import re
8
+ from os import PathLike
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ import boto3 # type: ignore[import-untyped]
13
+ import botocore.handlers # type: ignore[import-untyped]
14
+ from boto3 import client as boto3_client # type: ignore[import-untyped]
15
+ from boto3.s3.transfer import S3Transfer, TransferConfig # type: ignore[import-untyped]
16
+ from botocore.config import Config as Boto3Config # type: ignore[import-untyped]
17
+ from tqdm.auto import tqdm
18
+
19
+ from .models.config import ConfigModel
20
+ from .progress_logging import FileProgressLogger
21
+
22
+ MULTIPART_THRESHOLD = 8 * 1024 * 1024 # 8MiB, boto3 default
23
+ MULTIPART_CHUNKSIZE = 8 * 1024 * 1024 # 8MiB, boto3 default
24
+ MULTIPART_MAX_CHUNKS = 1000 # CEPH S3 limit, AWS limit is 10000
25
+
26
+ if TYPE_CHECKING:
27
+ from .parser import EncryptedSubmission
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+ # see discussion: https://github.com/boto/boto3/discussions/4251 to accept bucket names with ":" in the name
32
+ botocore.handlers.VALID_BUCKET = re.compile(r"^[:a-zA-Z0-9.\-_]{1,255}$") # type: ignore[import-untyped]
33
+
34
+
35
+ class DownloadError(Exception):
36
+ """Exception raised when an upload fails"""
37
+
38
+ pass
39
+
40
+
41
+ class S3BotoDownloadWorker:
42
+ """Implementation of an upload worker using boto3 for S3"""
43
+
44
+ __log = log.getChild("S3BotoDownloadWorker")
45
+
46
+ def __init__(
47
+ self,
48
+ config: ConfigModel,
49
+ status_file_path: str | PathLike,
50
+ threads: int = 1,
51
+ ):
52
+ """
53
+ An download manager for S3 storage
54
+
55
+ :param config: The configuration model
56
+ :param status_file_path: The path to the status file
57
+ """
58
+ super().__init__()
59
+
60
+ self._status_file_path = Path(status_file_path)
61
+ self._config = config
62
+ self._threads = threads
63
+
64
+ self._init_s3_client()
65
+
66
+ def _init_s3_client(self):
67
+ # if user specifies empty strings, this might be an issue
68
+ def empty_str_to_none(string: str | None) -> str | None:
69
+ if string == "" or string is None:
70
+ return None
71
+ else:
72
+ return string
73
+
74
+ # configure proxies if proxy_url is defined
75
+ proxy_url = empty_str_to_none(self._config.s3_options.proxy_url)
76
+ if proxy_url is not None:
77
+ config = Boto3Config(proxies={"http": proxy_url, "https": proxy_url})
78
+ else:
79
+ config = None
80
+
81
+ # Initialize S3 client for uploading
82
+ self._s3_client: boto3.session.Session.client = boto3_client(
83
+ service_name="s3",
84
+ region_name=empty_str_to_none(self._config.s3_options.region_name),
85
+ api_version=empty_str_to_none(self._config.s3_options.api_version),
86
+ use_ssl=self._config.s3_options.use_ssl,
87
+ endpoint_url=empty_str_to_none(str(self._config.s3_options.endpoint_url)),
88
+ aws_access_key_id=empty_str_to_none(self._config.s3_options.access_key),
89
+ aws_secret_access_key=empty_str_to_none(self._config.s3_options.secret),
90
+ aws_session_token=empty_str_to_none(self._config.s3_options.session_token),
91
+ config=config,
92
+ )
93
+
94
+ def prepare_download(
95
+ self,
96
+ metadata_dir: Path,
97
+ encrypted_files_dir: Path,
98
+ log_dir: Path,
99
+ ):
100
+ """
101
+ Prepare the download of an encrypted submission
102
+
103
+ :param metadata_dir: Path to the metadata directory
104
+ :param encrypted_files_dir: Path to the encrypted_files directory
105
+ :param log_dir: Path to the logs directory
106
+ """
107
+ for dir_path in [metadata_dir, encrypted_files_dir, log_dir]:
108
+ if not dir_path.exists():
109
+ self.__log.debug("Creating directory: %s", dir_path)
110
+ dir_path.mkdir(parents=False, exist_ok=False) # Create the directories
111
+ else:
112
+ self.__log.debug("Directory exists: %s", dir_path)
113
+
114
+ def download_metadata(
115
+ self,
116
+ submission_id: str,
117
+ metadata_dir: Path,
118
+ metadata_file_name: str = "metadata.json",
119
+ ):
120
+ """
121
+ Download the metadata.json
122
+
123
+ :param submission_id: submission folder on S3 structure
124
+ :param metadata_dir: Path of the metadir folder
125
+ :param metadata_file_name: name of the metadata.json
126
+ """
127
+ metadata_key = str(Path(submission_id) / metadata_dir.name / metadata_file_name)
128
+ metadata_file_path = metadata_dir / metadata_file_name
129
+
130
+ try:
131
+ self.download_file(str(metadata_file_path), metadata_key, 10000)
132
+ except Exception as e:
133
+ self.__log.error("Download failed for '%s'", str(metadata_key))
134
+
135
+ raise e
136
+
137
+ def download_file(self, local_file_path, s3_object_id, filesize):
138
+ """
139
+ Upload a single file to the specified object ID
140
+ :param local_file_path: Path to the file to upload
141
+ :param s3_object_id: Remote S3 object ID under which the file should be stored
142
+ :param filesize: size of the file
143
+ """
144
+ # self.__log.info(f"Download {s3_object_id} to {local_file_path}...")
145
+
146
+ chunksize = (
147
+ math.ceil(filesize / MULTIPART_MAX_CHUNKS)
148
+ if filesize / MULTIPART_CHUNKSIZE > MULTIPART_MAX_CHUNKS
149
+ else MULTIPART_CHUNKSIZE
150
+ )
151
+ self.__log.debug(
152
+ f"Using a chunksize of: {chunksize / 1024**2}MiB, results in {filesize/chunksize} chunks"
153
+ )
154
+
155
+ config = TransferConfig(
156
+ multipart_threshold=MULTIPART_THRESHOLD,
157
+ multipart_chunksize=chunksize,
158
+ max_concurrency=self._threads,
159
+ )
160
+
161
+ transfer = S3Transfer(self._s3_client, config)
162
+ progress_bar = tqdm(
163
+ total=filesize, unit="B", unit_scale=True, unit_divisor=1024
164
+ )
165
+ transfer.download_file(
166
+ self._config.s3_options.bucket,
167
+ s3_object_id,
168
+ local_file_path,
169
+ callback=lambda bytes_transferred: progress_bar.update(bytes_transferred),
170
+ )
171
+
172
+ def download(self, submission_id: str, encrypted_submission: EncryptedSubmission):
173
+ """
174
+ Upload an encrypted submission
175
+ :param encrypted_submission: The encrypted submission to upload
176
+ """
177
+ progress_logger = FileProgressLogger(self._status_file_path)
178
+
179
+ encrypted_files_key_prefix = f"{submission_id}/files/"
180
+
181
+ response = self._s3_client.list_objects_v2(
182
+ Bucket=self._config.s3_options.bucket, Prefix=encrypted_files_key_prefix
183
+ )
184
+
185
+ if "Contents" in response:
186
+ for file in response["Contents"]:
187
+ if file["Size"] == 0:
188
+ continue
189
+ else:
190
+ file_key = file["Key"]
191
+ file_path = Path(file_key).relative_to(encrypted_files_key_prefix)
192
+ full_path = encrypted_submission.encrypted_files_dir / file_path
193
+ if full_path not in encrypted_submission.encrypted_files:
194
+ raise DownloadError(
195
+ f"File {file_path} not listed in metadata.json"
196
+ )
197
+ file_metadata = encrypted_submission.encrypted_files[full_path]
198
+ logged_state = progress_logger.get_state(full_path, file_metadata)
199
+
200
+ if (logged_state is None) or not logged_state.get(
201
+ "download_successful", False
202
+ ):
203
+ self.__log.info(
204
+ "Download file: '%s' -> '%s'",
205
+ file_key,
206
+ str(full_path),
207
+ )
208
+
209
+ try:
210
+ full_path.parent.mkdir(
211
+ mode=0o770, parents=True, exist_ok=True
212
+ )
213
+ self.download_file(str(full_path), file_key, file["Size"])
214
+
215
+ self.__log.info(f"Download complete for {str(full_path)}. ")
216
+ progress_logger.set_state(
217
+ full_path,
218
+ file_metadata,
219
+ state={"download_successful": True},
220
+ )
221
+ except Exception as e:
222
+ self.__log.error("Download failed for '%s'", str(full_path))
223
+
224
+ progress_logger.set_state(
225
+ full_path,
226
+ file_metadata,
227
+ state={"download_successful": False, "error": str(e)},
228
+ )
229
+
230
+ raise e
231
+ else:
232
+ self.__log.info(
233
+ "File '%s' already downloaded (at '%s')",
234
+ file_key,
235
+ str(full_path),
236
+ )