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 +0 -0
- grz_cli/__init__.py +0 -0
- grz_cli/cli.py +326 -0
- grz_cli/constants.py +37 -0
- grz_cli/download.py +236 -0
- grz_cli/fastq_validation.py +122 -0
- grz_cli/file_operations.py +364 -0
- grz_cli/logging_setup.py +59 -0
- grz_cli/models/__init__.py +0 -0
- grz_cli/models/config.py +118 -0
- grz_cli/models/v1_0_0/__init__.py +0 -0
- grz_cli/models/v1_0_0/metadata.py +845 -0
- grz_cli/parser.py +771 -0
- grz_cli/progress_logging.py +170 -0
- grz_cli/resources/grz-schema.json +560 -0
- grz_cli/upload.py +243 -0
- grz_cli-0.1.0.dist-info/LICENSE +21 -0
- grz_cli-0.1.0.dist-info/METADATA +240 -0
- grz_cli-0.1.0.dist-info/RECORD +22 -0
- grz_cli-0.1.0.dist-info/WHEEL +5 -0
- grz_cli-0.1.0.dist-info/entry_points.txt +2 -0
- grz_cli-0.1.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Validation of FASTQ files. Boils down to basic sanity checks such as line count and read length."""
|
|
2
|
+
|
|
3
|
+
import gzip
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Generator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from os import PathLike
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_gzipped(file_path: str | PathLike) -> bool:
|
|
13
|
+
"""
|
|
14
|
+
Check if a file is gzipped based on its extension.
|
|
15
|
+
|
|
16
|
+
:param file_path: Path to the file
|
|
17
|
+
:return: True if the file is gzipped, False otherwise
|
|
18
|
+
"""
|
|
19
|
+
return str(file_path).endswith(".gz")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@contextmanager
|
|
23
|
+
def open_fastq(file_path: str | PathLike):
|
|
24
|
+
"""
|
|
25
|
+
Open a FASTQ file, handling both regular and gzipped formats.
|
|
26
|
+
|
|
27
|
+
:param file_path: Path to the FASTQ file
|
|
28
|
+
:return: A file object opened in the appropriate mode (gzipped or plain text)
|
|
29
|
+
"""
|
|
30
|
+
if is_gzipped(file_path):
|
|
31
|
+
with gzip.open(file_path, "rb") as f:
|
|
32
|
+
# Open gzipped FASTQ
|
|
33
|
+
yield f
|
|
34
|
+
else:
|
|
35
|
+
with open(file_path) as f:
|
|
36
|
+
# Open regular FASTQ
|
|
37
|
+
yield f
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def calculate_fastq_stats(file_path) -> tuple[int, set[int]]:
|
|
41
|
+
"""
|
|
42
|
+
Calculate line number and read lengths in FASTQ file.
|
|
43
|
+
:param file_path: Path to the FASTQ file
|
|
44
|
+
:return: tuple with the following values:
|
|
45
|
+
- number of lines in the file
|
|
46
|
+
- Set of observed read lengths
|
|
47
|
+
"""
|
|
48
|
+
read_lengths = set()
|
|
49
|
+
|
|
50
|
+
with open_fastq(file_path) as f:
|
|
51
|
+
for line_number, line in enumerate(f):
|
|
52
|
+
if (line_number % 4) == 1:
|
|
53
|
+
# Sequence lines are every 4th line starting from the 2nd
|
|
54
|
+
read_lengths.add(len(line.strip()))
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
line_number + 1, # enumerate starts indexing at 0
|
|
58
|
+
read_lengths,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def validate_fastq_file(fastq_file: str | PathLike) -> tuple[int, set[int], list[str]]:
|
|
63
|
+
"""
|
|
64
|
+
Validates a FASTQ file.
|
|
65
|
+
|
|
66
|
+
:param fastq_file: Path to the fastq file
|
|
67
|
+
:return: Tuple with the following fields:
|
|
68
|
+
- number of lines
|
|
69
|
+
- set of observed read lengths
|
|
70
|
+
- list of errors found
|
|
71
|
+
"""
|
|
72
|
+
num_lines, read_lengths = calculate_fastq_stats(fastq_file)
|
|
73
|
+
errors = []
|
|
74
|
+
|
|
75
|
+
# Check if the number of lines in a FASTQ file is a multiple of 4.
|
|
76
|
+
if num_lines % 4 != 0:
|
|
77
|
+
errors.append(
|
|
78
|
+
f"{fastq_file}: Number of lines is not a multiple of 4! Found {num_lines} lines."
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
log.debug("%s: %s lines", fastq_file, num_lines)
|
|
82
|
+
|
|
83
|
+
# Check if all reads in the FASTQ file have the same length.
|
|
84
|
+
if len(read_lengths) != 1:
|
|
85
|
+
errors.append(
|
|
86
|
+
f"{fastq_file}: Inconsistent read lengths! "
|
|
87
|
+
f"Found reads with following lengths: {', '.join([str(c) for c in read_lengths])}"
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
log.debug("%s: read length: %s", fastq_file, next(iter(read_lengths)))
|
|
91
|
+
|
|
92
|
+
return num_lines, read_lengths, errors
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def validate_single_end_reads(fastq_file: str | PathLike) -> Generator[str]:
|
|
96
|
+
"""
|
|
97
|
+
Validate a single-end FASTQ file.
|
|
98
|
+
|
|
99
|
+
:param fastq_file: Path to the FASTQ file
|
|
100
|
+
:return: Generator of errors, if any.
|
|
101
|
+
"""
|
|
102
|
+
num_lines, read_lengths, errors = validate_fastq_file(fastq_file)
|
|
103
|
+
yield from errors
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def validate_paired_end_reads(
|
|
107
|
+
fastq_file1: str | PathLike, fastq_file2: str | PathLike
|
|
108
|
+
) -> Generator[str]:
|
|
109
|
+
"""
|
|
110
|
+
Validate two paired-end FASTQ files.
|
|
111
|
+
|
|
112
|
+
:param fastq_file1: Path to the first FASTQ file (Read 1)
|
|
113
|
+
:param fastq_file2: Path to the second FASTQ file (Read 2)
|
|
114
|
+
:return: Generator of errors, if any.
|
|
115
|
+
"""
|
|
116
|
+
num_lines_file1, read_lengths_file1, errors_file1 = validate_fastq_file(fastq_file1)
|
|
117
|
+
yield from errors_file1
|
|
118
|
+
num_lines_file2, read_lengths_file2, errors_file2 = validate_fastq_file(fastq_file2)
|
|
119
|
+
yield from errors_file2
|
|
120
|
+
|
|
121
|
+
if num_lines_file1 != num_lines_file2:
|
|
122
|
+
yield f"Paired-end files have different read counts: '{fastq_file1}' ({num_lines_file1}) and '{fastq_file2}' ({num_lines_file2})!"
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility module for basic file operations.
|
|
3
|
+
Includes functions for calculating checksums, streaming json objects, file system checks and Crypt4GH encryption/decryption.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import typing
|
|
14
|
+
from functools import partial
|
|
15
|
+
from getpass import getpass
|
|
16
|
+
from os import PathLike
|
|
17
|
+
from os.path import getsize
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TextIO
|
|
20
|
+
|
|
21
|
+
import crypt4gh.header # type: ignore[import-untyped]
|
|
22
|
+
import crypt4gh.keys # type: ignore[import-untyped]
|
|
23
|
+
import crypt4gh.lib # type: ignore[import-untyped]
|
|
24
|
+
from nacl.public import PrivateKey
|
|
25
|
+
from tqdm.auto import tqdm
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def calculate_sha256(file_path: str | PathLike, chunk_size=2**16, progress=True) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Calculate the sha256 value of a file in chunks
|
|
33
|
+
|
|
34
|
+
:param file_path: path to the file
|
|
35
|
+
:param chunk_size: Chunk size in bytes
|
|
36
|
+
:param progress: Print progress
|
|
37
|
+
:return: calculated sha256 value of file_path
|
|
38
|
+
"""
|
|
39
|
+
file_path = Path(file_path)
|
|
40
|
+
total_size = getsize(file_path)
|
|
41
|
+
sha256_hash = hashlib.sha256()
|
|
42
|
+
with open(file_path, "rb") as f:
|
|
43
|
+
if progress and (total_size > chunk_size):
|
|
44
|
+
with tqdm(
|
|
45
|
+
total=total_size,
|
|
46
|
+
unit="B",
|
|
47
|
+
unit_scale=True,
|
|
48
|
+
desc=f"Calculating SHA256 {file_path.name}",
|
|
49
|
+
) as pbar:
|
|
50
|
+
while chunk := f.read(chunk_size):
|
|
51
|
+
sha256_hash.update(chunk)
|
|
52
|
+
pbar.update(len(chunk))
|
|
53
|
+
else:
|
|
54
|
+
while chunk := f.read(chunk_size):
|
|
55
|
+
sha256_hash.update(chunk)
|
|
56
|
+
return sha256_hash.hexdigest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def calculate_md5(file_path, chunk_size=2**16, progress=True) -> str:
|
|
60
|
+
"""
|
|
61
|
+
Calculate the md5 value of a file in chunks
|
|
62
|
+
|
|
63
|
+
:param file_path: path to the file
|
|
64
|
+
:param chunk_size: Chunk size in bytes
|
|
65
|
+
:param progress: Print progress
|
|
66
|
+
:return: calculated md5 value of file_path
|
|
67
|
+
"""
|
|
68
|
+
total_size = getsize(file_path)
|
|
69
|
+
md5_hash = hashlib.md5() # noqa: S324
|
|
70
|
+
with open(file_path, "rb") as f:
|
|
71
|
+
if progress and (total_size > chunk_size):
|
|
72
|
+
with tqdm(
|
|
73
|
+
total=total_size, unit="B", unit_scale=True, desc="Calculating MD5"
|
|
74
|
+
) as pbar:
|
|
75
|
+
while chunk := f.read(chunk_size):
|
|
76
|
+
md5_hash.update(chunk)
|
|
77
|
+
pbar.update(len(chunk))
|
|
78
|
+
else:
|
|
79
|
+
while chunk := f.read(chunk_size):
|
|
80
|
+
md5_hash.update(chunk)
|
|
81
|
+
return md5_hash.hexdigest()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def read_multiple_json(input: TextIO, buffer_size=65536, max_buffer_size=134217728):
|
|
85
|
+
"""
|
|
86
|
+
Read multiple JSON objects from a text stream.
|
|
87
|
+
:param input:
|
|
88
|
+
:param buffer_size:
|
|
89
|
+
:param max_buffer_size:
|
|
90
|
+
"""
|
|
91
|
+
decoder = json.JSONDecoder()
|
|
92
|
+
buffer = io.StringIO() # Use StringIO as the buffer
|
|
93
|
+
|
|
94
|
+
while chunk := input.read(buffer_size):
|
|
95
|
+
if len(buffer.getvalue()) + len(chunk) > max_buffer_size:
|
|
96
|
+
raise MemoryError("Reached maximum buffer size while reading input")
|
|
97
|
+
|
|
98
|
+
# append chunk to buffer
|
|
99
|
+
buffer.write(chunk)
|
|
100
|
+
|
|
101
|
+
while True:
|
|
102
|
+
try:
|
|
103
|
+
data = buffer.getvalue().lstrip()
|
|
104
|
+
# Attempt to decode a JSON object from the current buffer content
|
|
105
|
+
obj, idx = decoder.raw_decode(data)
|
|
106
|
+
yield obj # Process the decoded object
|
|
107
|
+
|
|
108
|
+
# Reset the buffer with the unprocessed content
|
|
109
|
+
buffer = io.StringIO(data[idx:])
|
|
110
|
+
except json.JSONDecodeError:
|
|
111
|
+
# If a JSONDecodeError occurs, we need more data, so break out to read the next chunk
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
# If there is any remaining content after the loop, try to process it
|
|
115
|
+
remaining_data = buffer.getvalue().strip()
|
|
116
|
+
if remaining_data != "":
|
|
117
|
+
raise ValueError("Remaining data is not empty. Is there invalid JSON?")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def is_relative_subdirectory(
|
|
121
|
+
relative_path: str | PathLike, root_directory: str | PathLike
|
|
122
|
+
) -> bool:
|
|
123
|
+
"""
|
|
124
|
+
Check if the target path is a subdirectory of the root path
|
|
125
|
+
using os.path.commonpath() without checking the file system.
|
|
126
|
+
|
|
127
|
+
:param relative_path: The target path.
|
|
128
|
+
:param root_directory: The root directory.
|
|
129
|
+
:return: True if relative_path is a subdirectory of root_directory, otherwise False.
|
|
130
|
+
"""
|
|
131
|
+
# Convert both paths to absolute paths without resolving symlinks
|
|
132
|
+
root_directory = os.path.abspath(root_directory)
|
|
133
|
+
relative_path = os.path.abspath(relative_path)
|
|
134
|
+
|
|
135
|
+
common_path = os.path.commonpath([root_directory, relative_path])
|
|
136
|
+
|
|
137
|
+
# Check if the common path is equal to the root path
|
|
138
|
+
return common_path == root_directory
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Crypt4GH:
|
|
142
|
+
"""Crypt4GH encryption/decryption utility class"""
|
|
143
|
+
|
|
144
|
+
Key = tuple[int, bytes, bytes]
|
|
145
|
+
|
|
146
|
+
VERSION = 1
|
|
147
|
+
SEGMENT_SIZE = 65536
|
|
148
|
+
FILE_EXTENSION = ".c4gh"
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def prepare_c4gh_keys(
|
|
152
|
+
recipient_key_file_path: str | PathLike,
|
|
153
|
+
sender_private_key: str | PathLike | None = None,
|
|
154
|
+
) -> tuple[Key]:
|
|
155
|
+
"""
|
|
156
|
+
Prepare the key format that Crypt4GH needs. While it can contain multiple
|
|
157
|
+
keys for multiple recipients, in our use case there is only a single recipient.
|
|
158
|
+
|
|
159
|
+
:param recipient_key_file_path: path to the public key file of the recipient
|
|
160
|
+
:param sender_private_key: path to the private key file of the sender.
|
|
161
|
+
If None, will be generated randomly.
|
|
162
|
+
"""
|
|
163
|
+
if sender_private_key is not None:
|
|
164
|
+
sk = Crypt4GH.retrieve_private_key(sender_private_key)
|
|
165
|
+
else:
|
|
166
|
+
sk = bytes(PrivateKey.generate())
|
|
167
|
+
keys = ((0, sk, crypt4gh.keys.get_public_key(recipient_key_file_path)),)
|
|
168
|
+
return keys
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def encrypt_file(
|
|
172
|
+
input_path: str | PathLike,
|
|
173
|
+
output_path: str | PathLike,
|
|
174
|
+
public_keys: tuple[Crypt4GH.Key],
|
|
175
|
+
):
|
|
176
|
+
"""
|
|
177
|
+
Encrypt the file, properly handling the Crypt4GH header.
|
|
178
|
+
|
|
179
|
+
:param public_keys:
|
|
180
|
+
:param output_path:
|
|
181
|
+
:param input_path:
|
|
182
|
+
:return: tuple with md5 values for original file, encrypted file
|
|
183
|
+
"""
|
|
184
|
+
# TODO: Progress bar?
|
|
185
|
+
# TODO: store header in separate file?
|
|
186
|
+
input_path = Path(input_path)
|
|
187
|
+
output_path = Path(output_path)
|
|
188
|
+
|
|
189
|
+
total_size = getsize(input_path)
|
|
190
|
+
with (
|
|
191
|
+
open(input_path, "rb") as in_fd,
|
|
192
|
+
open(output_path, "wb") as out_fd,
|
|
193
|
+
TqdmIOWrapper(
|
|
194
|
+
typing.cast(io.RawIOBase, in_fd),
|
|
195
|
+
tqdm(
|
|
196
|
+
total=total_size,
|
|
197
|
+
desc=f"Encrypting: '{input_path.name}'",
|
|
198
|
+
unit="B",
|
|
199
|
+
unit_scale=True,
|
|
200
|
+
# unit_divisor=1024, # make use of standard units e.g. KB, MB, etc.
|
|
201
|
+
miniters=1,
|
|
202
|
+
),
|
|
203
|
+
) as pbar_in_fd,
|
|
204
|
+
):
|
|
205
|
+
crypt4gh.lib.encrypt(
|
|
206
|
+
keys=public_keys,
|
|
207
|
+
infile=pbar_in_fd,
|
|
208
|
+
outfile=out_fd,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def retrieve_private_key(seckey_path) -> bytes:
|
|
213
|
+
"""
|
|
214
|
+
Read Crypt4GH private key from specified path.
|
|
215
|
+
:param seckey_path: path to the private key
|
|
216
|
+
:return:
|
|
217
|
+
"""
|
|
218
|
+
seckeypath = os.path.expanduser(seckey_path)
|
|
219
|
+
if not os.path.exists(seckeypath):
|
|
220
|
+
raise ValueError("Secret key not found")
|
|
221
|
+
|
|
222
|
+
passphrase = os.getenv("C4GH_PASSPHRASE")
|
|
223
|
+
if passphrase:
|
|
224
|
+
passphrase_callback = lambda: passphrase
|
|
225
|
+
else:
|
|
226
|
+
passphrase_callback = partial(
|
|
227
|
+
getpass, prompt=f"Passphrase for {seckey_path}: "
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return crypt4gh.keys.get_private_key(seckeypath, passphrase_callback)
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def decrypt_file(input_path, output_path, private_key: bytes):
|
|
234
|
+
"""
|
|
235
|
+
Decrypt a file using the provided private key
|
|
236
|
+
:param input_path: Path to the encrypted file
|
|
237
|
+
:param output_path: Path to the decrypted file
|
|
238
|
+
:param private_key: The private key
|
|
239
|
+
"""
|
|
240
|
+
total_size = getsize(input_path)
|
|
241
|
+
with (
|
|
242
|
+
open(input_path, "rb") as in_fd,
|
|
243
|
+
open(output_path, "wb") as out_fd,
|
|
244
|
+
TqdmIOWrapper(
|
|
245
|
+
typing.cast(io.RawIOBase, in_fd),
|
|
246
|
+
tqdm(
|
|
247
|
+
total=total_size,
|
|
248
|
+
desc=f"Decrypting: '{input_path.name}'",
|
|
249
|
+
unit="B",
|
|
250
|
+
unit_scale=True,
|
|
251
|
+
# unit_divisor=1024, # make use of standard units e.g. KB, MB, etc.
|
|
252
|
+
miniters=1,
|
|
253
|
+
),
|
|
254
|
+
) as pbar_in_fd,
|
|
255
|
+
):
|
|
256
|
+
crypt4gh.lib.decrypt(
|
|
257
|
+
keys=[
|
|
258
|
+
(0, private_key, None)
|
|
259
|
+
], # list of (method, privkey, recipient_pubkey=None),
|
|
260
|
+
infile=pbar_in_fd,
|
|
261
|
+
outfile=out_fd,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class TqdmIOWrapper(io.RawIOBase):
|
|
266
|
+
"""
|
|
267
|
+
Wrapper to record reads and writes in a tqdm progress bar.
|
|
268
|
+
|
|
269
|
+
Example:
|
|
270
|
+
very_long_input = io.StringIO("0123456789abcdef" * 100000)
|
|
271
|
+
total_size = len(very_long_input.getvalue())
|
|
272
|
+
|
|
273
|
+
batch_size = 10 ** 4 # 10kb
|
|
274
|
+
with open("/dev/null", "w") as fd:
|
|
275
|
+
with TqdmIOWrapper(fd, tqdm(
|
|
276
|
+
total=total_size,
|
|
277
|
+
desc="Printing: ",
|
|
278
|
+
unit="B",
|
|
279
|
+
unit_scale=True,
|
|
280
|
+
# unit_divisor=1024, # make use of standard units e.g. KB, MB, etc.
|
|
281
|
+
miniters=1,
|
|
282
|
+
)) as pbar_fd:
|
|
283
|
+
while chunk := very_long_input.read(batch_size):
|
|
284
|
+
pbar_fd.write(chunk)
|
|
285
|
+
|
|
286
|
+
time.sleep(0.1)
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
def __init__(self, io_buf: io.RawIOBase, progress_bar):
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
:param io_buf: the buffer to wrap
|
|
293
|
+
:param progress_bar: tqdm progress bar
|
|
294
|
+
"""
|
|
295
|
+
self.io_buf = io_buf
|
|
296
|
+
self.callback = progress_bar.update
|
|
297
|
+
|
|
298
|
+
def write(self, data):
|
|
299
|
+
"""Write data to the buffer and update the progress bar"""
|
|
300
|
+
nbytes_written = self.io_buf.write(data)
|
|
301
|
+
if nbytes_written:
|
|
302
|
+
self.callback(nbytes_written)
|
|
303
|
+
return nbytes_written
|
|
304
|
+
|
|
305
|
+
def read(self, size=-1) -> bytes | None:
|
|
306
|
+
"""Read data from the buffer and update the progress bar"""
|
|
307
|
+
data = self.io_buf.read(size)
|
|
308
|
+
if data:
|
|
309
|
+
self.callback(len(data))
|
|
310
|
+
|
|
311
|
+
return data
|
|
312
|
+
|
|
313
|
+
def readinto(self, buffer, /):
|
|
314
|
+
"""Read data into a buffer and update the progress bar"""
|
|
315
|
+
nbytes_written = self.io_buf.readinto(buffer)
|
|
316
|
+
if nbytes_written:
|
|
317
|
+
self.callback(nbytes_written)
|
|
318
|
+
|
|
319
|
+
return nbytes_written
|
|
320
|
+
|
|
321
|
+
def flush(self):
|
|
322
|
+
"""Flush the buffer"""
|
|
323
|
+
# Ensure all data is flushed to the underlying binary IO object
|
|
324
|
+
self.io_buf.flush()
|
|
325
|
+
|
|
326
|
+
def close(self):
|
|
327
|
+
"""Close the buffer"""
|
|
328
|
+
# Close the underlying binary IO object
|
|
329
|
+
self.io_buf.close()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# class HashLoggingWriter(io.RawIOBase):
|
|
333
|
+
# def __init__(self, binary_io: io.RawIOBase, hash_algorithm: str = 'sha256'):
|
|
334
|
+
# self._binary_io = binary_io # The underlying binary I/O object
|
|
335
|
+
# self.hash_algorithm = hash_algorithm # Hashing algorithm (e.g., 'md5', 'sha256', etc.)
|
|
336
|
+
#
|
|
337
|
+
# # Create the hash object based on the specified algorithm
|
|
338
|
+
# if hash_algorithm not in hashlib.algorithms_available:
|
|
339
|
+
# raise ValueError(f"Unsupported hash algorithm: {hash_algorithm}")
|
|
340
|
+
# self.hash_obj = hashlib.new(hash_algorithm)
|
|
341
|
+
#
|
|
342
|
+
# def write(self, b: bytes) -> int:
|
|
343
|
+
# # Update the hash with the bytes being written
|
|
344
|
+
# self.hash_obj.update(b)
|
|
345
|
+
# # Write the bytes to the underlying binary IO object
|
|
346
|
+
# return self._binary_io.write(b)
|
|
347
|
+
#
|
|
348
|
+
# def read(self, size=-1) -> bytes:
|
|
349
|
+
# raise NotImplementedError("This class is write-only")
|
|
350
|
+
#
|
|
351
|
+
# def readinto(self, b: bytearray) -> int:
|
|
352
|
+
# raise NotImplementedError("This class is write-only")
|
|
353
|
+
#
|
|
354
|
+
# def flush(self):
|
|
355
|
+
# # Ensure all data is flushed to the underlying binary IO object
|
|
356
|
+
# self._binary_io.flush()
|
|
357
|
+
#
|
|
358
|
+
# def close(self):
|
|
359
|
+
# # Close the underlying binary IO object
|
|
360
|
+
# self._binary_io.close()
|
|
361
|
+
#
|
|
362
|
+
# def get_hash(self):
|
|
363
|
+
# # Return the hex digest of the hash
|
|
364
|
+
# return self.hash_obj
|
grz_cli/logging_setup.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module: logging_setup
|
|
3
|
+
|
|
4
|
+
This module provides functions for setting up logging configuration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from os import PathLike
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .constants import LOGGING_DATEFMT, LOGGING_FORMAT, PACKAGE_ROOT
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def add_filelogger(
|
|
19
|
+
file_path: str | PathLike | None = None,
|
|
20
|
+
level: str = "INFO",
|
|
21
|
+
logger_name: str = PACKAGE_ROOT,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Add file logging for the specified package.
|
|
25
|
+
|
|
26
|
+
This function configures a file logger to capture log messages
|
|
27
|
+
for the package specified by _PACKAGE_ROOT. If no file path
|
|
28
|
+
is provided, a default log file will be created in the user's
|
|
29
|
+
home directory.
|
|
30
|
+
|
|
31
|
+
:param file_path: Optional; the path to the log file. If None,
|
|
32
|
+
a default path will be used.
|
|
33
|
+
:param level: Optional; the logging level. Default is 'INFO'.
|
|
34
|
+
Must be a valid logging level name (e.g., 'DEBUG', 'INFO').
|
|
35
|
+
:param logger_name:
|
|
36
|
+
"""
|
|
37
|
+
logger = logging.getLogger(logger_name)
|
|
38
|
+
|
|
39
|
+
if file_path is None:
|
|
40
|
+
default_log_dir = Path.home() / "logs"
|
|
41
|
+
default_log_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
file_path = default_log_dir / f"{PACKAGE_ROOT}.log"
|
|
43
|
+
log.warning("No log file path provided, using default: %s", file_path)
|
|
44
|
+
else:
|
|
45
|
+
file_path = Path(file_path)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
fh = logging.FileHandler(file_path)
|
|
49
|
+
fh.setLevel(level.upper())
|
|
50
|
+
fh.setFormatter(logging.Formatter(LOGGING_FORMAT, LOGGING_DATEFMT))
|
|
51
|
+
logger.addHandler(fh)
|
|
52
|
+
log.info(
|
|
53
|
+
"File logger added for %s at %s with level %s.",
|
|
54
|
+
logger.name,
|
|
55
|
+
file_path,
|
|
56
|
+
level.upper(),
|
|
57
|
+
)
|
|
58
|
+
except Exception as e:
|
|
59
|
+
log.error("Failed to add file logger: %s", e)
|
|
File without changes
|
grz_cli/models/config.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Annotated, Self
|
|
3
|
+
|
|
4
|
+
from pydantic import (
|
|
5
|
+
AfterValidator,
|
|
6
|
+
AnyHttpUrl,
|
|
7
|
+
AnyUrl,
|
|
8
|
+
BaseModel,
|
|
9
|
+
ConfigDict,
|
|
10
|
+
field_validator,
|
|
11
|
+
model_validator,
|
|
12
|
+
)
|
|
13
|
+
from pydantic.types import PathType
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StrictBaseModel(BaseModel):
|
|
17
|
+
model_config = ConfigDict(
|
|
18
|
+
extra="forbid",
|
|
19
|
+
validate_assignment=True,
|
|
20
|
+
use_enum_values=True,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class S3Options(StrictBaseModel):
|
|
25
|
+
endpoint_url: AnyHttpUrl
|
|
26
|
+
"""
|
|
27
|
+
The URL for the S3 service.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
bucket: str
|
|
31
|
+
"""
|
|
32
|
+
The name of the S3 bucket.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
access_key: str
|
|
36
|
+
"""
|
|
37
|
+
The access key for the S3 bucket.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
secret: str
|
|
41
|
+
"""
|
|
42
|
+
The secret key for the S3 bucket.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
session_token: str | None = None
|
|
46
|
+
"""
|
|
47
|
+
The session token for temporary credentials (optional).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
region_name: str | None = None
|
|
51
|
+
"""
|
|
52
|
+
The region name for the S3 bucket.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
api_version: str | None = None
|
|
56
|
+
"""
|
|
57
|
+
The S3 API version.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
use_ssl: bool = True
|
|
61
|
+
"""
|
|
62
|
+
Whether to use SSL for S3 operations.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
proxy_url: AnyUrl | None = None
|
|
66
|
+
"""
|
|
67
|
+
The proxy URL for S3 operations (optional).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
FilePath = Annotated[Path, AfterValidator(lambda v: v.expanduser()), PathType("file")]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ConfigModel(StrictBaseModel):
|
|
75
|
+
grz_public_key: str | None = None
|
|
76
|
+
"""
|
|
77
|
+
The public key of the recipient (the associated GRZ).
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
grz_public_key_path: FilePath | None = None
|
|
81
|
+
"""
|
|
82
|
+
Path to the crypt4gh public key of the recipient (the associated GRZ).
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
grz_private_key_path: FilePath | None = None
|
|
86
|
+
"""
|
|
87
|
+
Path to the crypt4gh private key of the recipient (optional).
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
submitter_private_key_path: FilePath | None = None
|
|
91
|
+
"""
|
|
92
|
+
Path to the submitter's private key (optional).
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
s3_options: S3Options
|
|
96
|
+
|
|
97
|
+
@field_validator("grz_public_key")
|
|
98
|
+
@classmethod
|
|
99
|
+
def check_grz_public_key(cls, v):
|
|
100
|
+
if (
|
|
101
|
+
v is not None
|
|
102
|
+
and "BEGIN CRYPT4GH PUBLIC KEY" not in v
|
|
103
|
+
and "END CRYPT4GH PUBLIC KEY" not in v
|
|
104
|
+
):
|
|
105
|
+
raise ValueError("Invalid public key format")
|
|
106
|
+
return v
|
|
107
|
+
|
|
108
|
+
@model_validator(mode="after")
|
|
109
|
+
def validate_grz_public_key(self) -> Self:
|
|
110
|
+
if self.grz_public_key is None and self.grz_public_key_path is None:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
"Either grz_public_key or grz_public_key_path must be set."
|
|
113
|
+
)
|
|
114
|
+
if self.grz_public_key is not None and self.grz_public_key_path is not None:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
"Only one of grz_public_key or grz_public_key_path must be set."
|
|
117
|
+
)
|
|
118
|
+
return self
|
|
File without changes
|