grz-common 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.
- grz_common/__init__.py +5 -0
- grz_common/cli/__init__.py +79 -0
- grz_common/constants.py +39 -0
- grz_common/logging_setup.py +60 -0
- grz_common/models/__init__.py +3 -0
- grz_common/models/base.py +59 -0
- grz_common/models/keys.py +45 -0
- grz_common/models/s3.py +79 -0
- grz_common/progress/__init__.py +21 -0
- grz_common/progress/progress_logging.py +193 -0
- grz_common/progress/states.py +53 -0
- grz_common/py.typed +0 -0
- grz_common/transfer.py +64 -0
- grz_common/utils/__init__.py +3 -0
- grz_common/utils/checksums.py +43 -0
- grz_common/utils/crypt.py +143 -0
- grz_common/utils/io.py +111 -0
- grz_common/utils/paths.py +26 -0
- grz_common/validation/__init__.py +1 -0
- grz_common/validation/bam.py +39 -0
- grz_common/validation/fastq.py +166 -0
- grz_common/workers/__init__.py +1 -0
- grz_common/workers/download.py +238 -0
- grz_common/workers/submission.py +702 -0
- grz_common/workers/upload.py +247 -0
- grz_common/workers/worker.py +231 -0
- grz_common-0.1.0.dist-info/METADATA +30 -0
- grz_common-0.1.0.dist-info/RECORD +29 -0
- grz_common-0.1.0.dist-info/WHEEL +4 -0
grz_common/__init__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Common click options for the CLI commands.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from os import sched_getaffinity
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import platformdirs
|
|
10
|
+
|
|
11
|
+
DEFAULT_CONFIG_PATH = Path(platformdirs.user_config_dir("grz-cli")) / "config.yaml"
|
|
12
|
+
|
|
13
|
+
# Aliases for path types for click options
|
|
14
|
+
# Naming convention: {DIR,FILE}_{Read,Write}_{Exists,Create}
|
|
15
|
+
DIR_R_E = click.Path(
|
|
16
|
+
exists=True,
|
|
17
|
+
file_okay=False,
|
|
18
|
+
dir_okay=True,
|
|
19
|
+
readable=True,
|
|
20
|
+
writable=False,
|
|
21
|
+
resolve_path=True,
|
|
22
|
+
)
|
|
23
|
+
DIR_RW_C = click.Path(
|
|
24
|
+
exists=False,
|
|
25
|
+
file_okay=False,
|
|
26
|
+
dir_okay=True,
|
|
27
|
+
readable=True,
|
|
28
|
+
writable=True,
|
|
29
|
+
resolve_path=True,
|
|
30
|
+
)
|
|
31
|
+
FILE_R_E = click.Path(exists=True, file_okay=True, dir_okay=False, readable=True, resolve_path=True)
|
|
32
|
+
|
|
33
|
+
submission_dir = click.option(
|
|
34
|
+
"--submission-dir",
|
|
35
|
+
metavar="PATH",
|
|
36
|
+
type=DIR_R_E,
|
|
37
|
+
required=True,
|
|
38
|
+
help="Path to the submission directory containing 'metadata/', 'files/', 'encrypted_files/' and 'logs/' directories",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
config_file = click.option(
|
|
42
|
+
"--config-file",
|
|
43
|
+
metavar="STRING",
|
|
44
|
+
type=FILE_R_E,
|
|
45
|
+
required=False,
|
|
46
|
+
default=DEFAULT_CONFIG_PATH,
|
|
47
|
+
help="Path to config file",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
threads = click.option(
|
|
51
|
+
"--threads",
|
|
52
|
+
default=min(len(sched_getaffinity(0)), 4),
|
|
53
|
+
type=int,
|
|
54
|
+
show_default=True,
|
|
55
|
+
help="Number of threads to use for parallel operations",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
submission_id = click.option(
|
|
59
|
+
"--submission-id",
|
|
60
|
+
required=True,
|
|
61
|
+
type=str,
|
|
62
|
+
metavar="STRING",
|
|
63
|
+
help="S3 submission ID",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
output_dir = click.option(
|
|
67
|
+
"--output-dir",
|
|
68
|
+
metavar="PATH",
|
|
69
|
+
type=DIR_RW_C,
|
|
70
|
+
required=True,
|
|
71
|
+
default=None,
|
|
72
|
+
help="Path to the target submission output directory",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
output_json = click.option("--json", "output_json", is_flag=True, help="Output JSON for machine-readability.")
|
|
76
|
+
|
|
77
|
+
show_details = click.option("--details", "show_details", is_flag=True, help="Show more detailed output.")
|
|
78
|
+
|
|
79
|
+
force = click.option("--force/--no-force", help="Overwrite files and ignore cached results (dangerous!)")
|
grz_common/constants.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Constants for logging configuration, JSON schema validation, and other settings."""
|
|
2
|
+
|
|
3
|
+
# This will be overridden by the package that imports it
|
|
4
|
+
PACKAGE_ROOT = "grz_common"
|
|
5
|
+
|
|
6
|
+
LOGGING_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
7
|
+
LOGGING_DATEFMT = "%Y-%m-%d %I:%M %p"
|
|
8
|
+
LOGGING_CONFIG = {
|
|
9
|
+
"version": 1,
|
|
10
|
+
"disable_existing_loggers": True,
|
|
11
|
+
"formatters": {
|
|
12
|
+
"standard": {
|
|
13
|
+
"format": LOGGING_FORMAT,
|
|
14
|
+
"datefmt": LOGGING_DATEFMT,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
"handlers": {
|
|
18
|
+
"default": {
|
|
19
|
+
"level": "INFO",
|
|
20
|
+
"formatter": "standard",
|
|
21
|
+
"class": "logging.StreamHandler",
|
|
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
|
+
}
|
|
38
|
+
|
|
39
|
+
TQDM_SMOOTHING: float = 0.0001
|
|
@@ -0,0 +1,60 @@
|
|
|
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 logger_name. 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: Optional; the name of the logger to add the file handler to.
|
|
36
|
+
Default is PACKAGE_ROOT.
|
|
37
|
+
"""
|
|
38
|
+
logger = logging.getLogger(logger_name)
|
|
39
|
+
|
|
40
|
+
if file_path is None:
|
|
41
|
+
default_log_dir = Path.home() / "logs"
|
|
42
|
+
default_log_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
file_path = default_log_dir / f"{logger_name}.log"
|
|
44
|
+
log.warning("No log file path provided, using default: %s", file_path)
|
|
45
|
+
else:
|
|
46
|
+
file_path = Path(file_path)
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
fh = logging.FileHandler(file_path)
|
|
50
|
+
fh.setLevel(level.upper())
|
|
51
|
+
fh.setFormatter(logging.Formatter(LOGGING_FORMAT, LOGGING_DATEFMT))
|
|
52
|
+
logger.addHandler(fh)
|
|
53
|
+
log.info(
|
|
54
|
+
"File logger added for %s at %s with level %s.",
|
|
55
|
+
logger.name,
|
|
56
|
+
file_path,
|
|
57
|
+
level.upper(),
|
|
58
|
+
)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
log.error("Failed to add file logger: %s", e)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from os import PathLike
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Annotated, Self
|
|
4
|
+
|
|
5
|
+
from pydantic import AfterValidator, BaseModel, ConfigDict
|
|
6
|
+
from pydantic.types import PathType
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
FilePath = Annotated[Path, AfterValidator(lambda v: v.expanduser()), PathType("file")]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IgnoringBaseModel(BaseModel):
|
|
13
|
+
model_config = ConfigDict(
|
|
14
|
+
extra="ignore",
|
|
15
|
+
validate_assignment=True,
|
|
16
|
+
use_enum_values=True,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def to_yaml(self, fd):
|
|
20
|
+
"""Reads the configuration file and validates it against the schema."""
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
yaml.dump(self.model_dump(mode="json", exclude_none=True, exclude_unset=True, exclude_defaults=True))
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_path(cls, path: str | PathLike) -> Self:
|
|
27
|
+
"""Reads the configuration file and validates it against the schema."""
|
|
28
|
+
import yaml
|
|
29
|
+
|
|
30
|
+
with open(path, encoding="utf-8") as f:
|
|
31
|
+
config = cls(**yaml.safe_load(f)) # noqa:
|
|
32
|
+
|
|
33
|
+
return config
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class IgnoringBaseSettings(BaseSettings):
|
|
37
|
+
model_config = SettingsConfigDict(
|
|
38
|
+
extra="ignore",
|
|
39
|
+
validate_assignment=True,
|
|
40
|
+
use_enum_values=True,
|
|
41
|
+
env_nested_delimiter="__",
|
|
42
|
+
env_prefix="grz_",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def to_yaml(self, fd):
|
|
46
|
+
"""Reads the configuration file and validates it against the schema."""
|
|
47
|
+
import yaml
|
|
48
|
+
|
|
49
|
+
yaml.dump(self.model_dump(mode="json", exclude_none=True, exclude_unset=True, exclude_defaults=True), fd)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_path(cls, path: str | PathLike) -> Self:
|
|
53
|
+
"""Reads the configuration file and validates it against the schema."""
|
|
54
|
+
import yaml
|
|
55
|
+
|
|
56
|
+
with open(path, encoding="utf-8") as f:
|
|
57
|
+
config = cls(**yaml.safe_load(f)) # noqa:
|
|
58
|
+
|
|
59
|
+
return config
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from typing import Self
|
|
2
|
+
|
|
3
|
+
from grz_common.models.base import FilePath, IgnoringBaseSettings
|
|
4
|
+
from pydantic import field_validator, model_validator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class KeyModel(IgnoringBaseSettings):
|
|
8
|
+
grz_public_key: str | None = None
|
|
9
|
+
"""
|
|
10
|
+
The public key of the recipient (the associated GRZ).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
grz_public_key_path: FilePath | None = None
|
|
14
|
+
"""
|
|
15
|
+
Path to the crypt4gh public key of the recipient (the associated GRZ).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
grz_private_key_path: FilePath | None = None
|
|
19
|
+
"""
|
|
20
|
+
Path to the crypt4gh private key of the recipient (optional).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
submitter_private_key_path: FilePath | None = None
|
|
24
|
+
"""
|
|
25
|
+
Path to the submitter's private key (optional).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@field_validator("grz_public_key")
|
|
29
|
+
@classmethod
|
|
30
|
+
def check_grz_public_key(cls, v):
|
|
31
|
+
if v is not None and "BEGIN CRYPT4GH PUBLIC KEY" not in v and "END CRYPT4GH PUBLIC KEY" not in v:
|
|
32
|
+
raise ValueError("Invalid public key format")
|
|
33
|
+
return v
|
|
34
|
+
|
|
35
|
+
@model_validator(mode="after")
|
|
36
|
+
def validate_grz_public_key(self) -> Self:
|
|
37
|
+
if self.grz_public_key is None and self.grz_public_key_path is None:
|
|
38
|
+
raise ValueError("Either grz_public_key or grz_public_key_path must be set.")
|
|
39
|
+
if self.grz_public_key is not None and self.grz_public_key_path is not None:
|
|
40
|
+
raise ValueError("Only one of grz_public_key or grz_public_key_path must be set.")
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class KeyConfigModel(IgnoringBaseSettings):
|
|
45
|
+
keys: KeyModel
|
grz_common/models/s3.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from pydantic import AnyHttpUrl, AnyUrl
|
|
2
|
+
|
|
3
|
+
from .base import IgnoringBaseModel, IgnoringBaseSettings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class S3Options(IgnoringBaseModel):
|
|
7
|
+
endpoint_url: AnyHttpUrl
|
|
8
|
+
"""
|
|
9
|
+
The URL for the S3 service.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
bucket: str
|
|
13
|
+
"""
|
|
14
|
+
The name of the S3 bucket.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
access_key: str | None = None
|
|
18
|
+
"""
|
|
19
|
+
The access key for the S3 bucket.
|
|
20
|
+
If undefined, it is read from the AWS_ACCESS_KEY_ID environment variable.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
secret: str | None = None
|
|
24
|
+
"""
|
|
25
|
+
The secret key for the S3 bucket.
|
|
26
|
+
If undefined, it is read from the AWS_SECRET_ACCESS_KEY environment variable.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
session_token: str | None = None
|
|
30
|
+
"""
|
|
31
|
+
The session token for temporary credentials (optional).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
region_name: str | None = None
|
|
35
|
+
"""
|
|
36
|
+
The region name for the S3 bucket.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
api_version: str | None = None
|
|
40
|
+
"""
|
|
41
|
+
The S3 API version.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
use_ssl: bool = True
|
|
45
|
+
"""
|
|
46
|
+
Whether to use SSL for S3 operations.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
proxy_url: AnyUrl | None = None
|
|
50
|
+
"""
|
|
51
|
+
The proxy URL for S3 operations (optional).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
request_checksum_calculation: str | None = None
|
|
55
|
+
"""
|
|
56
|
+
Whether to calculate checksums for S3 request payloads (optional).
|
|
57
|
+
Valid values are:
|
|
58
|
+
|
|
59
|
+
* ``when_supported`` -- When set, a checksum will be calculated for
|
|
60
|
+
all request payloads of operations modeled with the ``httpChecksum``
|
|
61
|
+
trait where ``requestChecksumRequired`` is ``true`` or a
|
|
62
|
+
``requestAlgorithmMember`` is modeled.
|
|
63
|
+
|
|
64
|
+
* ``when_required`` -- When set, a checksum will only be calculated
|
|
65
|
+
for request payloads of operations modeled with the ``httpChecksum``
|
|
66
|
+
trait where ``requestChecksumRequired`` is ``true`` or where a
|
|
67
|
+
``requestAlgorithmMember`` is modeled and supplied.
|
|
68
|
+
|
|
69
|
+
Defaults to None.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
multipart_chunksize: int = 256 * 1024**2
|
|
73
|
+
"""
|
|
74
|
+
The size of the chunks to use for multipart uploads in bytes.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class S3ConfigModel(IgnoringBaseSettings):
|
|
79
|
+
s3: S3Options
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Progress tracking and logging functionality."""
|
|
2
|
+
|
|
3
|
+
from .progress_logging import FileProgressLogger
|
|
4
|
+
from .states import (
|
|
5
|
+
DecryptionState,
|
|
6
|
+
DownloadState,
|
|
7
|
+
EncryptionState,
|
|
8
|
+
State,
|
|
9
|
+
UploadState,
|
|
10
|
+
ValidationState,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DecryptionState",
|
|
15
|
+
"DownloadState",
|
|
16
|
+
"EncryptionState",
|
|
17
|
+
"FileProgressLogger",
|
|
18
|
+
"State",
|
|
19
|
+
"UploadState",
|
|
20
|
+
"ValidationState",
|
|
21
|
+
]
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Module for tracking and logging the state of files over time."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import json
|
|
7
|
+
import typing
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from os import PathLike
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
# pyrefly: ignore
|
|
13
|
+
from grz_pydantic_models.submission.metadata.v1 import File as SubmissionFileMetadata
|
|
14
|
+
|
|
15
|
+
from ..utils.io import read_multiple_json
|
|
16
|
+
from .states import State
|
|
17
|
+
|
|
18
|
+
Index = tuple[str, float, int]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FileProgressLogger[T: State]:
|
|
22
|
+
"""
|
|
23
|
+
A class to log and track the state of files over time. It stores file states in a log file of JSON entries
|
|
24
|
+
and allows querying the state based on the file path and modification time.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
_index = {"file_path": str, "modification_time": float, "size": int}
|
|
28
|
+
# mapping of index -> (metadata, data)
|
|
29
|
+
_file_states: dict[Index, tuple[SubmissionFileMetadata, T]]
|
|
30
|
+
|
|
31
|
+
def __init__(self, log_file_path: str | PathLike):
|
|
32
|
+
"""
|
|
33
|
+
Initializes the FileProgressLogger instance.
|
|
34
|
+
|
|
35
|
+
:param fields: Dictionary of field names and their types, representing the state to track.
|
|
36
|
+
:param log_file_path: Path to the JSON file where file progress will be logged.
|
|
37
|
+
"""
|
|
38
|
+
self._file_path = Path(log_file_path)
|
|
39
|
+
self._file_states = {}
|
|
40
|
+
|
|
41
|
+
# Read existing file states from the log file
|
|
42
|
+
self.read()
|
|
43
|
+
|
|
44
|
+
def read(self):
|
|
45
|
+
"""
|
|
46
|
+
Reads the log file and loads the file states into memory.
|
|
47
|
+
|
|
48
|
+
:raises ValueError: If the path exists but is not a file.
|
|
49
|
+
"""
|
|
50
|
+
if self._file_path.exists():
|
|
51
|
+
if self._file_path.is_file():
|
|
52
|
+
with open(self._file_path) as fd:
|
|
53
|
+
for row_dict in read_multiple_json(fd):
|
|
54
|
+
# Get index and cast them to the correct types
|
|
55
|
+
index = typing.cast(
|
|
56
|
+
Index,
|
|
57
|
+
tuple(self._index[k](row_dict[k]) for k in self._index),
|
|
58
|
+
)
|
|
59
|
+
# Get metadata
|
|
60
|
+
metadata = row_dict["metadata"]
|
|
61
|
+
# Get state
|
|
62
|
+
state = row_dict["state"]
|
|
63
|
+
|
|
64
|
+
self._file_states[index] = (metadata, state)
|
|
65
|
+
else:
|
|
66
|
+
raise ValueError(f"Path is not a file: '{str(self._file_path)}'")
|
|
67
|
+
|
|
68
|
+
def cleanup(self, keep: list[tuple[PathLike, SubmissionFileMetadata]]):
|
|
69
|
+
"""
|
|
70
|
+
Removes all entries from the log file and in-memory state that are not in the keep list.
|
|
71
|
+
|
|
72
|
+
:param keep: List of tuples containing the file path and metadata of files to keep.
|
|
73
|
+
"""
|
|
74
|
+
self._file_path.unlink(missing_ok=True)
|
|
75
|
+
for file, file_metadata in keep:
|
|
76
|
+
state = self.get_state(file, file_metadata)
|
|
77
|
+
if state is not None:
|
|
78
|
+
self.set_state(file, file_metadata, state)
|
|
79
|
+
|
|
80
|
+
def _get_index(self, file_path: str | PathLike) -> Index:
|
|
81
|
+
"""
|
|
82
|
+
Generates a unique index for a given file based on its name and modification time.
|
|
83
|
+
|
|
84
|
+
:param file_path: Path object representing the file.
|
|
85
|
+
:return: A tuple containing the file name and modification time.
|
|
86
|
+
"""
|
|
87
|
+
file_path = Path(file_path).resolve()
|
|
88
|
+
|
|
89
|
+
if file_path.is_file():
|
|
90
|
+
return str(file_path), file_path.stat().st_mtime, file_path.stat().st_size
|
|
91
|
+
else:
|
|
92
|
+
return str(file_path), -1, -1 # catches files that do not exist
|
|
93
|
+
|
|
94
|
+
# def get_index(self, file_path: Path, file_metadata: Dict) -> tuple:
|
|
95
|
+
# return self._get_index(file_path, file_metadata)
|
|
96
|
+
|
|
97
|
+
def get_state(
|
|
98
|
+
self,
|
|
99
|
+
file_path: str | PathLike,
|
|
100
|
+
file_metadata: dict | SubmissionFileMetadata,
|
|
101
|
+
default: T | Callable[[Path, SubmissionFileMetadata], T] | None = None,
|
|
102
|
+
) -> T | None:
|
|
103
|
+
"""
|
|
104
|
+
Retrieves the stored state of a file if it exists in the log.
|
|
105
|
+
|
|
106
|
+
:param file_path: The file path to query for the state.
|
|
107
|
+
:param file_metadata: The metadata of the file to query for the state.
|
|
108
|
+
:param default: Default state to use if the file does not exist.
|
|
109
|
+
Can be a Callable that takes the file path and the file metadata as input and returns some state:
|
|
110
|
+
`Callable[[Path, SubmissionFileMetadata], T]`.
|
|
111
|
+
|
|
112
|
+
The default state gets automatically saved as the state for this file in case there is no stored state.
|
|
113
|
+
:return: A dictionary representing the file's state, or None if the file's state isn't logged.
|
|
114
|
+
"""
|
|
115
|
+
file_path = Path(file_path)
|
|
116
|
+
index = self._get_index(file_path)
|
|
117
|
+
|
|
118
|
+
# get stored state
|
|
119
|
+
stored_metadata, stored_data = self._file_states.get(index, (None, None))
|
|
120
|
+
|
|
121
|
+
# check if metadata matches
|
|
122
|
+
if not isinstance(file_metadata, SubmissionFileMetadata):
|
|
123
|
+
file_metadata = SubmissionFileMetadata(**file_metadata)
|
|
124
|
+
|
|
125
|
+
if stored_metadata and not isinstance(stored_metadata, SubmissionFileMetadata):
|
|
126
|
+
stored_metadata = SubmissionFileMetadata(**stored_metadata)
|
|
127
|
+
|
|
128
|
+
if stored_metadata and file_metadata == stored_metadata:
|
|
129
|
+
return copy.deepcopy(stored_data)
|
|
130
|
+
|
|
131
|
+
# metadata mismatch -> no valid stored state -
|
|
132
|
+
if file_metadata and default:
|
|
133
|
+
if callable(default):
|
|
134
|
+
state = default(file_path, file_metadata)
|
|
135
|
+
self.set_state(file_path, file_metadata, state)
|
|
136
|
+
return state
|
|
137
|
+
else:
|
|
138
|
+
if not default:
|
|
139
|
+
raise ValueError("Default state must be provided if not callable")
|
|
140
|
+
# return None
|
|
141
|
+
default = typing.cast(T, default)
|
|
142
|
+
self.set_state(file_path, file_metadata, default)
|
|
143
|
+
return default
|
|
144
|
+
else:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
def set_state(
|
|
148
|
+
self,
|
|
149
|
+
file_path: str | PathLike,
|
|
150
|
+
file_metadata: dict | SubmissionFileMetadata,
|
|
151
|
+
state: T,
|
|
152
|
+
):
|
|
153
|
+
"""
|
|
154
|
+
Log the state of a file:
|
|
155
|
+
- Update the in-memory state
|
|
156
|
+
- Persist the state to the JSON log file
|
|
157
|
+
|
|
158
|
+
:param file_path: The path of the file whose state is being set.
|
|
159
|
+
:param file_metadata: Submission file metadata to store
|
|
160
|
+
:param state: A dictionary containing the file's state data to be logged.
|
|
161
|
+
"""
|
|
162
|
+
file_path = Path(file_path)
|
|
163
|
+
index = self._get_index(file_path)
|
|
164
|
+
|
|
165
|
+
if file_metadata and not isinstance(file_metadata, SubmissionFileMetadata):
|
|
166
|
+
file_metadata = SubmissionFileMetadata(**file_metadata)
|
|
167
|
+
file_metadata = typing.cast(SubmissionFileMetadata, file_metadata)
|
|
168
|
+
|
|
169
|
+
# Update state in memory
|
|
170
|
+
self._file_states[index] = (file_metadata, state)
|
|
171
|
+
|
|
172
|
+
# Persist state to JSON log file
|
|
173
|
+
with open(self._file_path, "a", newline="") as fd:
|
|
174
|
+
# Append the new state row to the log file
|
|
175
|
+
json.dump(
|
|
176
|
+
{
|
|
177
|
+
# index keys
|
|
178
|
+
**{k: v for k, v in zip(self._index.keys(), index, strict=True)},
|
|
179
|
+
# state
|
|
180
|
+
"metadata": file_metadata.model_dump(by_alias=True),
|
|
181
|
+
"state": state,
|
|
182
|
+
},
|
|
183
|
+
fd,
|
|
184
|
+
)
|
|
185
|
+
fd.write("\n")
|
|
186
|
+
|
|
187
|
+
def num_entries(self) -> int:
|
|
188
|
+
"""
|
|
189
|
+
Returns the number of entries in the file_states dictionary
|
|
190
|
+
|
|
191
|
+
:return: An integer representing the number of entries in the file_states dictionary
|
|
192
|
+
"""
|
|
193
|
+
return len(self._file_states)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module contains the type definitions for the progress logging states.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import TypedDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class State(TypedDict, total=False):
|
|
9
|
+
"""
|
|
10
|
+
Base state class for progress logging.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
errors: list[str]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ValidationState(State):
|
|
17
|
+
"""
|
|
18
|
+
State for validation progress.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
validation_passed: bool
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UploadState(State):
|
|
25
|
+
"""
|
|
26
|
+
State for upload progress.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
upload_successful: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EncryptionState(State):
|
|
33
|
+
"""
|
|
34
|
+
State for encryption progress.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
encryption_successful: bool
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DecryptionState(State):
|
|
41
|
+
"""
|
|
42
|
+
State for decryption progress.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
decryption_successful: bool
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DownloadState(State):
|
|
49
|
+
"""
|
|
50
|
+
State for download progress.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
download_successful: bool
|
grz_common/py.typed
ADDED
|
File without changes
|