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.
@@ -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
@@ -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
@@ -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