cvat-ai--cvat
1337 行
46 KiB
Python
1337 行
46 KiB
Python
# Copyright (C) 2021-2023 Intel Corporation
|
|
# Copyright (C) CVAT.ai Corporation
|
|
#
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import json
|
|
import os
|
|
from abc import ABC, abstractmethod
|
|
from collections.abc import Callable, Iterator, Sequence
|
|
from concurrent.futures import Future, ThreadPoolExecutor
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
from io import BytesIO
|
|
from pathlib import Path, PurePath
|
|
from queue import Queue
|
|
from typing import Any, BinaryIO, Concatenate, ParamSpec, TypeVar
|
|
|
|
import boto3
|
|
from azure.core.exceptions import HttpResponseError, ServiceRequestError
|
|
from azure.storage.blob import BlobServiceClient, ContainerClient
|
|
from azure.storage.blob._list_blobs_helper import BlobPrefix
|
|
from boto3.s3.transfer import TransferConfig
|
|
from botocore.client import Config
|
|
from botocore.exceptions import (
|
|
ClientError,
|
|
ConnectTimeoutError,
|
|
EndpointConnectionError,
|
|
ReadTimeoutError,
|
|
)
|
|
from botocore.handlers import disable_signing
|
|
from django.conf import settings
|
|
from google.api_core.exceptions import RetryError
|
|
from google.cloud import storage
|
|
from google.cloud.exceptions import Forbidden as GoogleCloudForbidden
|
|
from google.cloud.exceptions import NotFound as GoogleCloudNotFound
|
|
from PIL import Image, ImageFile
|
|
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
|
|
from rq import get_current_job
|
|
|
|
from cvat.apps.engine.log import ServerLogManager
|
|
from cvat.apps.engine.models import (
|
|
CloudProviderChoice,
|
|
CloudStorage,
|
|
CredentialsTypeChoice,
|
|
DimensionType,
|
|
)
|
|
from cvat.apps.engine.rq import ExportRQMeta
|
|
from cvat.apps.engine.utils import get_cpu_number, take_by
|
|
from cvat.utils.http import PROXIES_FOR_UNTRUSTED_URLS
|
|
from utils.dataset_manifest.utils import (
|
|
InvalidPcdError,
|
|
MemNamedOpenable,
|
|
MemOpenable,
|
|
NamedOpenable,
|
|
PcdReader,
|
|
)
|
|
|
|
slogger = ServerLogManager(__name__)
|
|
|
|
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
|
|
|
CPU_NUMBER = get_cpu_number()
|
|
|
|
|
|
def get_max_threads_number(number_of_files: int) -> int:
|
|
return max(
|
|
min(
|
|
number_of_files // settings.CLOUD_DATA_DOWNLOADING_MAX_THREADS_NUMBER_PER_CPU,
|
|
CPU_NUMBER * settings.CLOUD_DATA_DOWNLOADING_MAX_THREADS_NUMBER_PER_CPU,
|
|
),
|
|
settings.CLOUD_DATA_DOWNLOADING_MAX_THREADS_NUMBER_PER_CPU,
|
|
)
|
|
|
|
|
|
class Status(str, Enum):
|
|
AVAILABLE = "AVAILABLE"
|
|
NOT_FOUND = "NOT_FOUND"
|
|
FORBIDDEN = "FORBIDDEN"
|
|
|
|
@classmethod
|
|
def choices(cls):
|
|
return tuple((x.value, x.name) for x in cls)
|
|
|
|
@classmethod
|
|
def values(cls):
|
|
return list(i.value for i in cls)
|
|
|
|
def __str__(self):
|
|
return self.value
|
|
|
|
|
|
class Permissions(str, Enum):
|
|
READ = "read"
|
|
WRITE = "write"
|
|
|
|
@classmethod
|
|
def all(cls):
|
|
return {i.value for i in cls}
|
|
|
|
|
|
def validate_bucket_status(func):
|
|
@functools.wraps(func)
|
|
def wrapper(self, *args, **kwargs):
|
|
try:
|
|
res = func(self, *args, **kwargs)
|
|
except Exception as ex:
|
|
# check that cloud storage exists
|
|
storage_status = self.get_status() if self is not None else None
|
|
if storage_status == Status.FORBIDDEN:
|
|
raise PermissionDenied(
|
|
"The resource {} is no longer available. Access forbidden.".format(self.name)
|
|
)
|
|
elif storage_status == Status.NOT_FOUND:
|
|
raise NotFound(
|
|
"The resource {} not found. It may have been deleted.".format(self.name)
|
|
)
|
|
elif storage_status == Status.AVAILABLE:
|
|
raise
|
|
raise ValidationError(str(ex))
|
|
return res
|
|
|
|
return wrapper
|
|
|
|
|
|
def validate_file_status(func):
|
|
@functools.wraps(func)
|
|
def wrapper(self, key: str, /, *args, **kwargs):
|
|
try:
|
|
res = func(self, key, *args, **kwargs)
|
|
except Exception as ex:
|
|
storage_status = self.get_status() if self is not None else None
|
|
if storage_status == Status.AVAILABLE:
|
|
file_status = self.get_file_status(key)
|
|
if file_status == Status.NOT_FOUND:
|
|
raise NotFound(
|
|
"The file '{}' not found on the cloud storage '{}'".format(key, self.name)
|
|
)
|
|
elif file_status == Status.FORBIDDEN:
|
|
raise PermissionDenied(
|
|
"Access to the file '{}' on the '{}' cloud storage is denied".format(
|
|
key, self.name
|
|
)
|
|
)
|
|
raise ValidationError(str(ex)) from ex
|
|
else:
|
|
raise
|
|
return res
|
|
|
|
return wrapper
|
|
|
|
|
|
class AbstractCloudStorage(ABC):
|
|
def __init__(self, *, prefix: str | None = None, is_trusted: bool = False) -> None:
|
|
self.prefix = prefix
|
|
self.proxies = None if is_trusted else PROXIES_FOR_UNTRUSTED_URLS
|
|
|
|
@property
|
|
@abstractmethod
|
|
def name(self) -> str:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_status(self) -> Status:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_file_status(self, key: str, /) -> Status:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_file_last_modified(self, key: str, /) -> datetime:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def _download_fileobj_to_stream(self, key: str, stream: BinaryIO, /) -> None:
|
|
pass
|
|
|
|
@validate_file_status
|
|
@validate_bucket_status
|
|
def download_fileobj(self, key: str, /) -> bytes:
|
|
buf = BytesIO()
|
|
self._download_fileobj_to_stream(key, buf)
|
|
return buf.getvalue()
|
|
|
|
@validate_file_status
|
|
@validate_bucket_status
|
|
def download_file(self, key: str, path: Path, /) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
with open(path, "wb") as f:
|
|
self._download_fileobj_to_stream(key, f)
|
|
except Exception:
|
|
path.unlink()
|
|
raise
|
|
|
|
@validate_file_status
|
|
@validate_bucket_status
|
|
def download_range_of_bytes(self, key: str, /, *, stop_byte: int, start_byte: int = 0) -> bytes:
|
|
"""Method downloads the required bytes range of the file.
|
|
|
|
Args:
|
|
key (str): File on the bucket
|
|
stop_byte (int): Stop byte
|
|
start_byte (int, optional): Start byte. Defaults to 0.
|
|
|
|
Raises:
|
|
ValidationError: If start_byte > stop_byte
|
|
|
|
Returns:
|
|
bytes: Range with bytes
|
|
"""
|
|
|
|
if start_byte > stop_byte:
|
|
raise ValidationError(f"Incorrect bytes range was received: {start_byte}-{stop_byte}")
|
|
return self._download_range_of_bytes(key, stop_byte=stop_byte, start_byte=start_byte)
|
|
|
|
@abstractmethod
|
|
def _download_range_of_bytes(self, key: str, /, *, stop_byte: int, start_byte: int) -> bytes:
|
|
pass
|
|
|
|
def bulk_download_to_memory(
|
|
self, files: list[str], *, object_downloader: Callable[[str], NamedOpenable]
|
|
) -> Iterator[NamedOpenable]:
|
|
threads_number = get_max_threads_number(len(files))
|
|
|
|
# We're using a custom queue to limit the maximum number of downloaded unprocessed
|
|
# files stored in the memory.
|
|
# For example, the builtin executor.map() could also be used here, but it
|
|
# would enqueue all the file list in one go, and the downloaded files
|
|
# would all be stored in memory until processed.
|
|
queue: Queue[Future[NamedOpenable]] = Queue(maxsize=threads_number)
|
|
input_iter = iter(files)
|
|
with ThreadPoolExecutor(max_workers=threads_number) as executor:
|
|
while not queue.empty() or input_iter is not None:
|
|
while not queue.full() and input_iter is not None:
|
|
next_job_params = next(input_iter, None)
|
|
if next_job_params is None:
|
|
input_iter = None
|
|
break
|
|
|
|
next_job = executor.submit(object_downloader, next_job_params)
|
|
queue.put(next_job)
|
|
|
|
top_job = queue.get()
|
|
yield top_job.result()
|
|
|
|
def _in_parallel(self, fn: Callable[[T], object], args: Sequence[T]) -> None:
|
|
threads_number = get_max_threads_number(len(args))
|
|
|
|
with ThreadPoolExecutor(max_workers=threads_number) as executor:
|
|
list(executor.map(fn, args))
|
|
|
|
def bulk_download_to_dir(
|
|
self,
|
|
files: Sequence[PurePath | tuple[str, PurePath]],
|
|
upload_dir: Path,
|
|
) -> None:
|
|
"""
|
|
:param files: a list of filenames or (storage filename, output filename) pairs
|
|
:param upload_dir: the output directory
|
|
"""
|
|
|
|
def download_one(f: PurePath | tuple[str, PurePath]) -> None:
|
|
if isinstance(f, tuple):
|
|
key, output_path = f
|
|
else:
|
|
key = f.as_posix()
|
|
output_path = f
|
|
|
|
self.download_file(key, upload_dir / output_path)
|
|
|
|
self._in_parallel(download_one, files)
|
|
|
|
def bulk_upload_from_dir(
|
|
self,
|
|
files: Sequence[PurePath],
|
|
upload_dir: Path,
|
|
):
|
|
def upload_one(f: PurePath):
|
|
self.upload_file(upload_dir / f, f.as_posix())
|
|
|
|
self._in_parallel(upload_one, files)
|
|
|
|
@abstractmethod
|
|
def upload_fileobj(self, file_obj: BinaryIO, key: str, /) -> None:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def upload_file(self, file_path: Path, key: str | None = None, /) -> None:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def bulk_delete(self, files: Sequence[str]) -> None:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def _list_raw_content_on_one_page(
|
|
self,
|
|
prefix: str = "",
|
|
*,
|
|
next_token: str | None = None,
|
|
page_size: int = settings.BUCKET_CONTENT_MAX_PAGE_SIZE,
|
|
) -> dict:
|
|
pass
|
|
|
|
def list_files_on_one_page(
|
|
self,
|
|
prefix: str = "",
|
|
*,
|
|
next_token: str | None = None,
|
|
page_size: int = settings.BUCKET_CONTENT_MAX_PAGE_SIZE,
|
|
_use_flat_listing: bool = False,
|
|
_use_sort: bool = False,
|
|
) -> dict:
|
|
|
|
if (
|
|
self.prefix
|
|
and prefix
|
|
and not (self.prefix.startswith(prefix) or prefix.startswith(self.prefix))
|
|
):
|
|
return {
|
|
"content": [],
|
|
"next": None,
|
|
}
|
|
|
|
search_prefix = prefix
|
|
if self.prefix and (len(prefix) < len(self.prefix)):
|
|
if prefix and "/" in self.prefix[len(prefix) :]:
|
|
next_layer_and_tail = self.prefix[prefix.find("/") + 1 :].split("/", maxsplit=1)
|
|
if 2 == len(next_layer_and_tail):
|
|
directory = (
|
|
next_layer_and_tail[0]
|
|
if not _use_flat_listing
|
|
else self.prefix[: prefix.find("/") + 1] + next_layer_and_tail[0] + "/"
|
|
)
|
|
return {
|
|
"content": [{"name": directory, "type": "DIR"}],
|
|
"next": None,
|
|
}
|
|
else:
|
|
search_prefix = self.prefix
|
|
else:
|
|
search_prefix = self.prefix
|
|
|
|
result = self._list_raw_content_on_one_page(
|
|
search_prefix, next_token=next_token, page_size=page_size
|
|
)
|
|
|
|
if not _use_flat_listing:
|
|
result["directories"] = [d.strip("/") for d in result["directories"]]
|
|
content = [{"name": f, "type": "REG"} for f in result["files"]]
|
|
content.extend([{"name": d, "type": "DIR"} for d in result["directories"]])
|
|
|
|
if not _use_flat_listing and search_prefix and "/" in search_prefix:
|
|
last_slash = search_prefix.rindex("/")
|
|
for f in content:
|
|
f["name"] = f["name"][last_slash + 1 :]
|
|
|
|
if _use_sort:
|
|
content = sorted(content, key=lambda x: x["type"])
|
|
|
|
return {
|
|
"content": content,
|
|
"next": result["next"],
|
|
}
|
|
|
|
def list_files(
|
|
self,
|
|
prefix: str = "",
|
|
*,
|
|
_use_flat_listing: bool = False,
|
|
) -> list[dict]:
|
|
all_files = []
|
|
next_token = None
|
|
while True:
|
|
batch = self.list_files_on_one_page(
|
|
prefix, next_token=next_token, _use_flat_listing=_use_flat_listing
|
|
)
|
|
all_files.extend(batch["content"])
|
|
next_token = batch["next"]
|
|
if not next_token:
|
|
break
|
|
|
|
return all_files
|
|
|
|
@property
|
|
@abstractmethod
|
|
def supported_actions(self):
|
|
pass
|
|
|
|
|
|
class HeaderFirstDownloader(ABC):
|
|
def __init__(self, *, client: AbstractCloudStorage):
|
|
self.client = client
|
|
|
|
@abstractmethod
|
|
def try_parse_header(self, key: str, header: bytes) -> Any | None: ...
|
|
|
|
def log_header_miss(
|
|
self, key: str, header_size: int, *, full_contents: bytes | None = None
|
|
) -> None:
|
|
message = (
|
|
f'The first {header_size} bytes were not enough to parse the "{key}" object header. '
|
|
)
|
|
|
|
if full_contents is not None:
|
|
full_object_size = len(full_contents)
|
|
|
|
message += (
|
|
f"Object size was {full_object_size} bytes. "
|
|
f"Downloaded percentage was "
|
|
f"{min(header_size, full_object_size) / full_object_size:.0%}"
|
|
)
|
|
|
|
slogger.glob.warning(message)
|
|
|
|
def get_header_sizes_to_try(self) -> Sequence[int]:
|
|
return (
|
|
# The first 1-2Kb are typically enough for most formats with the static header size.
|
|
# Unfortunately, it's not enough for some popular formats, such as jpeg,
|
|
# which can optionally include a preview image embedded in the header, so we try
|
|
# other bigger sizes, but less than the whole file.
|
|
# For comparison, the standard Ethernet v2 MTU size is 1500 bytes.
|
|
2048,
|
|
16384,
|
|
65536,
|
|
)
|
|
|
|
def download(self, key: str) -> NamedOpenable:
|
|
"""
|
|
Method downloads the file using the following approach:
|
|
First we try to download the file header (first N bytes).
|
|
It should be enough to determine image properties.
|
|
If it's not enough for the file, the whole file will be downloaded.
|
|
|
|
:param key: File on the bucket
|
|
|
|
Returns:
|
|
buffer with the image
|
|
"""
|
|
|
|
buff = BytesIO()
|
|
|
|
headers_to_try = self.get_header_sizes_to_try()
|
|
for i, header_size in enumerate(headers_to_try):
|
|
cur_pos = buff.tell()
|
|
chunk = self.client.download_range_of_bytes(
|
|
key, start_byte=cur_pos, stop_byte=header_size - 1
|
|
)
|
|
buff.write(chunk)
|
|
|
|
partial_contents = buff.getvalue()
|
|
if len(partial_contents) < header_size:
|
|
# This means that the entire file is smaller than the current header_size.
|
|
# It doesn't matter whether the header can be parsed,
|
|
# since there's no more data to download anyway.
|
|
return MemNamedOpenable(partial_contents, key)
|
|
|
|
if self.try_parse_header(key, partial_contents):
|
|
return MemNamedOpenable(partial_contents, key)
|
|
|
|
if i + 1 < len(headers_to_try):
|
|
self.log_header_miss(key=key, header_size=header_size)
|
|
|
|
# If the full size is exactly equal to header_size,
|
|
# the next download_range_of_bytes call will have start_byte equal to the file size,
|
|
# and the request will fail (since an HTTP range can't be empty).
|
|
# To prevent this, force the range to be non-empty by redownloading the last byte.
|
|
buff.seek(-1, os.SEEK_CUR)
|
|
|
|
full_contents = self.client.download_fileobj(key)
|
|
self.log_header_miss(key=key, header_size=header_size, full_contents=full_contents)
|
|
return MemNamedOpenable(full_contents, key)
|
|
|
|
|
|
class _HeaderFirstImageDownloader(HeaderFirstDownloader):
|
|
def try_parse_header(self, key: str, header: bytes):
|
|
image_parser = ImageFile.Parser()
|
|
image_parser.feed(header)
|
|
return image_parser.image
|
|
|
|
def log_header_miss(self, key, header_size, *, full_contents: bytes | None = None) -> None:
|
|
message = (
|
|
f'The first {header_size} bytes were not enough to parse the "{key}" object header. '
|
|
)
|
|
|
|
if full_contents is not None:
|
|
full_object_size = len(full_contents)
|
|
|
|
message += (
|
|
f"Object size was {full_object_size} bytes. "
|
|
f"Image resolution was {Image.open(BytesIO(full_contents)).size}. "
|
|
f"Downloaded percentage was "
|
|
f"{min(header_size, full_object_size) / full_object_size:.0%}"
|
|
)
|
|
|
|
slogger.glob.warning(message)
|
|
|
|
def download(self, key):
|
|
try:
|
|
return super().download(key)
|
|
except Image.UnidentifiedImageError as e:
|
|
# PIL also can raise many OSErrors, but it's quite a broad class
|
|
# for the general capturing here. The precise info will be available in the logs
|
|
raise Exception(f"Failed to read the image file '{key}'") from e
|
|
|
|
|
|
class _HeaderFirstPcdDownloader(HeaderFirstDownloader):
|
|
def try_parse_header(self, key: str, header: bytes):
|
|
pcd_parser = PcdReader()
|
|
file = MemOpenable(header)
|
|
file_ext = os.path.splitext(key)[1].lower()
|
|
|
|
if file_ext == ".bin":
|
|
# We need to ensure the file is a valid .bin file
|
|
pcd_parser.parse_bin_header(file)
|
|
|
|
# but we need the whole file for the next operations (getting frame size etc.)
|
|
return False
|
|
elif file_ext == ".pcd":
|
|
parameters = pcd_parser.parse_pcd_header(file, verify_version=True)
|
|
if not parameters.get("WIDTH") or not parameters.get("HEIGHT"):
|
|
raise InvalidPcdError("invalid scene size")
|
|
else:
|
|
raise InvalidPcdError(f"The '{file_ext}' file format is not supported")
|
|
|
|
return True
|
|
|
|
def download(self, key):
|
|
try:
|
|
return super().download(key)
|
|
except InvalidPcdError as e:
|
|
raise Exception(f"Failed to read point cloud file '{key}': {e}") from e
|
|
|
|
|
|
class HeaderFirstMediaDownloader:
|
|
@staticmethod
|
|
def create(dimension: DimensionType, **kwargs) -> HeaderFirstDownloader:
|
|
if dimension == DimensionType.DIM_2D:
|
|
downloader = _HeaderFirstImageDownloader(**kwargs)
|
|
elif dimension == DimensionType.DIM_3D:
|
|
downloader = _HeaderFirstPcdDownloader(**kwargs)
|
|
else:
|
|
assert False
|
|
|
|
return downloader
|
|
|
|
|
|
def get_cloud_storage_instance(
|
|
*,
|
|
cloud_provider: CloudProviderChoice,
|
|
resource: str,
|
|
credentials: Credentials,
|
|
specific_attributes: dict[str, Any],
|
|
is_trusted: bool = False,
|
|
):
|
|
instance = None
|
|
if cloud_provider == CloudProviderChoice.AMAZON_S3:
|
|
instance = S3CloudStorage(
|
|
resource,
|
|
access_key_id=credentials.key,
|
|
secret_key=credentials.secret_key,
|
|
session_token=credentials.session_token,
|
|
region=specific_attributes.get("region"),
|
|
endpoint_url=specific_attributes.get("endpoint_url"),
|
|
prefix=specific_attributes.get("prefix"),
|
|
is_trusted=is_trusted,
|
|
)
|
|
elif cloud_provider == CloudProviderChoice.AZURE_BLOB_STORAGE:
|
|
instance = AzureBlobCloudStorage(
|
|
resource,
|
|
account_name=credentials.account_name,
|
|
sas_token=credentials.session_token,
|
|
connection_string=credentials.connection_string,
|
|
prefix=specific_attributes.get("prefix"),
|
|
is_trusted=is_trusted,
|
|
)
|
|
elif cloud_provider == CloudProviderChoice.GOOGLE_CLOUD_STORAGE:
|
|
instance = GcsCloudStorage(
|
|
resource,
|
|
service_account_json=credentials.key_file_path,
|
|
anonymous_access=credentials.credentials_type == CredentialsTypeChoice.ANONYMOUS_ACCESS,
|
|
prefix=specific_attributes.get("prefix"),
|
|
location=specific_attributes.get("location"),
|
|
project=specific_attributes.get("project"),
|
|
)
|
|
else:
|
|
raise NotImplementedError(f"The {cloud_provider} provider is not supported")
|
|
return instance
|
|
|
|
|
|
class S3CloudStorage(AbstractCloudStorage):
|
|
transfer_config = {
|
|
"max_io_queue": 10,
|
|
}
|
|
|
|
class Effect(str, Enum):
|
|
ALLOW = "Allow"
|
|
DENY = "Deny"
|
|
|
|
def __init__(
|
|
self,
|
|
bucket: str,
|
|
*,
|
|
region: str | None = None,
|
|
access_key_id: str | None = None,
|
|
secret_key: str | None = None,
|
|
session_token: str | None = None,
|
|
endpoint_url: str | None = None,
|
|
prefix: str | None = None,
|
|
is_trusted: bool = False,
|
|
):
|
|
super().__init__(prefix=prefix, is_trusted=is_trusted)
|
|
if sum(1 for credential in (access_key_id, secret_key, session_token) if credential) == 1:
|
|
raise Exception("Insufficient data for authentication")
|
|
|
|
kwargs = dict()
|
|
for key, arg_v in zip(
|
|
(
|
|
"aws_access_key_id",
|
|
"aws_secret_access_key",
|
|
"aws_session_token",
|
|
"region_name",
|
|
),
|
|
(access_key_id, secret_key, session_token, region),
|
|
):
|
|
if arg_v:
|
|
kwargs[key] = arg_v
|
|
|
|
session = boto3.Session(**kwargs)
|
|
# Status checks are part of the control plane, not the data-transfer path, so
|
|
# Bucket status probes should fail fast when the endpoint is unreachable or
|
|
# misconfigured. Keep a dedicated low-timeout client for head_bucket, while
|
|
# the regular resource/client retain their standard retry behavior for normal
|
|
# storage operations.
|
|
self._s3 = session.resource(
|
|
"s3",
|
|
endpoint_url=endpoint_url,
|
|
config=Config(
|
|
proxies=self.proxies or {},
|
|
max_pool_connections=(
|
|
# AWS can throttle the requests if there are too many of them,
|
|
# the SDK handles it with the retry policy:
|
|
# https://boto3.amazonaws.com/v1/documentation/api/latest/guide/retries.html
|
|
# 10 is the default value
|
|
max(10, CPU_NUMBER * settings.CLOUD_DATA_DOWNLOADING_MAX_THREADS_NUMBER_PER_CPU)
|
|
),
|
|
),
|
|
)
|
|
self._status_client = session.client(
|
|
"s3",
|
|
endpoint_url=endpoint_url,
|
|
config=Config(
|
|
proxies=self.proxies or {},
|
|
connect_timeout=2,
|
|
read_timeout=5,
|
|
retries={"total_max_attempts": 1, "mode": "standard"},
|
|
),
|
|
)
|
|
|
|
# anonymous access
|
|
if not any([access_key_id, secret_key, session_token]):
|
|
self._s3.meta.client.meta.events.register("choose-signer.s3.*", disable_signing)
|
|
self._status_client.meta.events.register("choose-signer.s3.*", disable_signing)
|
|
|
|
self._client = self._s3.meta.client
|
|
self._bucket = self._s3.Bucket(bucket)
|
|
self.region = region
|
|
|
|
@property
|
|
def bucket(self):
|
|
return self._bucket
|
|
|
|
@property
|
|
def name(self):
|
|
return self._bucket.name
|
|
|
|
def _head(self):
|
|
# Bucket status checks use the dedicated fast-fail client.
|
|
return self._status_client.head_bucket(Bucket=self.name)
|
|
|
|
def _head_file(self, key: str, /):
|
|
# File metadata reads stay on the regular client so they retain standard retry
|
|
# behavior on slower S3-compatible backends.
|
|
return self._client.head_object(Bucket=self.name, Key=key)
|
|
|
|
def get_status(self):
|
|
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.head_object
|
|
# return only 3 codes: 200, 403, 404
|
|
try:
|
|
self._head()
|
|
return Status.AVAILABLE
|
|
except ClientError as ex:
|
|
code = ex.response["Error"]["Code"]
|
|
if code == "403":
|
|
return Status.FORBIDDEN
|
|
else:
|
|
return Status.NOT_FOUND
|
|
# Handle transport-level reachability failures separately from ClientError-
|
|
# based 403/404 responses.
|
|
except (ConnectTimeoutError, EndpointConnectionError, ReadTimeoutError):
|
|
slogger.glob.warning(
|
|
f"CloudStorage S3 {self._client.meta.endpoint_url}, {self.name} not available",
|
|
exc_info=True,
|
|
)
|
|
return Status.NOT_FOUND
|
|
|
|
def get_file_status(self, key: str, /):
|
|
try:
|
|
self._head_file(key)
|
|
return Status.AVAILABLE
|
|
except ClientError as ex:
|
|
code = ex.response["Error"]["Code"]
|
|
if code == "403":
|
|
return Status.FORBIDDEN
|
|
else:
|
|
return Status.NOT_FOUND
|
|
# Handle transport-level reachability failures separately from ClientError-
|
|
# based 403/404 responses.
|
|
except (ConnectTimeoutError, EndpointConnectionError, ReadTimeoutError):
|
|
slogger.glob.warning(
|
|
f"CloudStorage S3 {self._client.meta.endpoint_url}, {self.name}/{key} not available",
|
|
exc_info=True,
|
|
)
|
|
return Status.NOT_FOUND
|
|
|
|
@validate_file_status
|
|
@validate_bucket_status
|
|
def get_file_last_modified(self, key: str, /):
|
|
return self._head_file(key).get("LastModified")
|
|
|
|
@validate_bucket_status
|
|
def upload_fileobj(self, file_obj: BinaryIO, key: str, /):
|
|
self._bucket.upload_fileobj(
|
|
Fileobj=file_obj,
|
|
Key=key,
|
|
Config=TransferConfig(max_io_queue=self.transfer_config["max_io_queue"]),
|
|
)
|
|
|
|
@validate_bucket_status
|
|
def upload_file(self, file_path: Path, key: str | None = None, /):
|
|
try:
|
|
self._bucket.upload_file(
|
|
os.fspath(file_path),
|
|
key or file_path.name,
|
|
Config=TransferConfig(max_io_queue=self.transfer_config["max_io_queue"]),
|
|
)
|
|
except ClientError as ex:
|
|
msg = str(ex)
|
|
slogger.glob.error(msg)
|
|
raise Exception(msg)
|
|
|
|
def _list_raw_content_on_one_page(
|
|
self,
|
|
prefix: str = "",
|
|
*,
|
|
next_token: str | None = None,
|
|
page_size: int = settings.BUCKET_CONTENT_MAX_PAGE_SIZE,
|
|
) -> dict:
|
|
# The structure of response looks like this:
|
|
# {
|
|
# 'CommonPrefixes': [{'Prefix': 'sub/'}],
|
|
# 'Contents': [{'ETag': '', 'Key': 'test.jpg', ..., 'Size': 1024}],
|
|
# ...
|
|
# 'NextContinuationToken': 'str'
|
|
# }
|
|
response = self._client.list_objects_v2(
|
|
Bucket=self.name,
|
|
MaxKeys=page_size,
|
|
Delimiter="/",
|
|
**({"Prefix": prefix} if prefix else {}),
|
|
**({"ContinuationToken": next_token} if next_token else {}),
|
|
)
|
|
files = [f["Key"] for f in response.get("Contents", []) if not f["Key"].endswith("/")]
|
|
directories = [p["Prefix"] for p in response.get("CommonPrefixes", [])]
|
|
|
|
return {
|
|
"files": files,
|
|
"directories": directories,
|
|
"next": response.get("NextContinuationToken", None),
|
|
}
|
|
|
|
def _download_fileobj_to_stream(self, key: str, stream: BinaryIO, /) -> None:
|
|
self.bucket.download_fileobj(
|
|
Key=key,
|
|
Fileobj=stream,
|
|
Config=TransferConfig(max_io_queue=self.transfer_config["max_io_queue"]),
|
|
)
|
|
|
|
def _download_range_of_bytes(self, key: str, /, *, stop_byte: int, start_byte: int) -> bytes:
|
|
try:
|
|
return self._client.get_object(
|
|
Bucket=self.bucket.name, Key=key, Range=f"bytes={start_byte}-{stop_byte}"
|
|
)["Body"].read()
|
|
except ClientError as ex:
|
|
if "InvalidRange" in str(ex):
|
|
if self._head_file(key).get("ContentLength") == 0:
|
|
slogger.glob.info(
|
|
f"Attempt to download empty file '{key}' from the '{self.name}' bucket."
|
|
)
|
|
raise ValidationError(f"The {key} file is empty.")
|
|
else:
|
|
slogger.glob.error(f"{str(ex)}. Key: {key}, bucket: {self.name}")
|
|
raise
|
|
|
|
def bulk_delete(self, files: Sequence[str]) -> None:
|
|
def delete_batch(batch: Sequence[str]):
|
|
delete_request = {"Objects": [{"Key": f} for f in batch], "Quiet": True}
|
|
self._client.delete_objects(Bucket=self.name, Delete=delete_request)
|
|
|
|
self._in_parallel(delete_batch, list(take_by(files, 1000)))
|
|
|
|
@property
|
|
def supported_actions(self):
|
|
allowed_actions = set()
|
|
try:
|
|
bucket_policy = self._bucket.Policy().policy
|
|
except ClientError as ex:
|
|
if "NoSuchBucketPolicy" in str(ex):
|
|
return Permissions.all()
|
|
else:
|
|
raise Exception(str(ex))
|
|
bucket_policy = (
|
|
json.loads(bucket_policy) if isinstance(bucket_policy, str) else bucket_policy
|
|
)
|
|
for statement in bucket_policy["Statement"]:
|
|
effect = statement.get("Effect") # Allow | Deny
|
|
actions = statement.get("Action", set())
|
|
if effect == self.Effect.ALLOW:
|
|
allowed_actions.update(actions)
|
|
access = {
|
|
"s3:GetObject": Permissions.READ,
|
|
"s3:PutObject": Permissions.WRITE,
|
|
}
|
|
allowed_actions = Permissions.all() & {access.get(i) for i in allowed_actions}
|
|
|
|
return allowed_actions
|
|
|
|
|
|
class AzureBlobCloudStorage(AbstractCloudStorage):
|
|
MAX_CONCURRENCY = 3
|
|
|
|
class Effect:
|
|
pass
|
|
|
|
def __init__(
|
|
self,
|
|
container: str,
|
|
*,
|
|
account_name: str | None = None,
|
|
sas_token: str | None = None,
|
|
connection_string: str | None = None,
|
|
prefix: str | None = None,
|
|
is_trusted: bool = False,
|
|
):
|
|
super().__init__(prefix=prefix, is_trusted=is_trusted)
|
|
self._account_name = account_name
|
|
if connection_string:
|
|
self._blob_service_client = BlobServiceClient.from_connection_string(
|
|
connection_string, proxies=self.proxies
|
|
)
|
|
elif sas_token:
|
|
self._blob_service_client = BlobServiceClient(
|
|
account_url=self.account_url,
|
|
credential=sas_token,
|
|
proxies=self.proxies,
|
|
)
|
|
else:
|
|
self._blob_service_client = BlobServiceClient(
|
|
account_url=self.account_url, proxies=self.proxies
|
|
)
|
|
self._client = self._blob_service_client.get_container_client(container)
|
|
|
|
@property
|
|
def container(self) -> ContainerClient:
|
|
return self._client
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return self._client.container_name
|
|
|
|
@property
|
|
def account_url(self) -> str | None:
|
|
if self._account_name:
|
|
return "{}.blob.core.windows.net".format(self._account_name)
|
|
return None
|
|
|
|
def _head(self):
|
|
return self._client.get_container_properties()
|
|
|
|
def _head_file(self, key: str, /):
|
|
blob_client = self.container.get_blob_client(key)
|
|
return blob_client.get_blob_properties()
|
|
|
|
@validate_file_status
|
|
@validate_bucket_status
|
|
def get_file_last_modified(self, key: str, /):
|
|
return self._head_file(key).last_modified
|
|
|
|
def get_status(self):
|
|
try:
|
|
self._head()
|
|
return Status.AVAILABLE
|
|
except HttpResponseError as ex:
|
|
if ex.status_code == 403:
|
|
return Status.FORBIDDEN
|
|
else:
|
|
return Status.NOT_FOUND
|
|
except ServiceRequestError:
|
|
slogger.glob.warning(
|
|
f"CloudStorage Azure {self.account_url} not available", exc_info=True
|
|
)
|
|
return Status.NOT_FOUND
|
|
|
|
def get_file_status(self, key: str, /):
|
|
try:
|
|
self._head_file(key)
|
|
return Status.AVAILABLE
|
|
except HttpResponseError as ex:
|
|
if ex.status_code == 403:
|
|
return Status.FORBIDDEN
|
|
else:
|
|
return Status.NOT_FOUND
|
|
|
|
@validate_bucket_status
|
|
def upload_fileobj(self, file_obj: BinaryIO, key: str, /):
|
|
self._client.upload_blob(name=key, data=file_obj, overwrite=True)
|
|
|
|
def upload_file(self, file_path: Path, key: str | None = None, /):
|
|
with open(file_path, "rb") as f:
|
|
self.upload_fileobj(f, key or file_path.name)
|
|
|
|
def bulk_delete(self, files: Sequence[str]) -> None:
|
|
def delete_batch(batch: Sequence[str]) -> None:
|
|
self._client.delete_blobs(*batch)
|
|
|
|
self._in_parallel(delete_batch, list(take_by(files, 256)))
|
|
|
|
def _list_raw_content_on_one_page(
|
|
self,
|
|
prefix: str = "",
|
|
*,
|
|
next_token: str | None = None,
|
|
page_size: int = settings.BUCKET_CONTENT_MAX_PAGE_SIZE,
|
|
) -> dict:
|
|
page = self._client.walk_blobs(
|
|
maxresults=page_size,
|
|
results_per_page=page_size,
|
|
delimiter="/",
|
|
**({"name_starts_with": prefix} if prefix else {}),
|
|
).by_page(continuation_token=next_token)
|
|
all_files = list(next(page))
|
|
|
|
files, directories = [], []
|
|
for f in all_files:
|
|
if not isinstance(f, BlobPrefix):
|
|
files.append(f.name)
|
|
else:
|
|
directories.append(f.prefix)
|
|
|
|
return {
|
|
"files": files,
|
|
"directories": directories,
|
|
"next": page.continuation_token,
|
|
}
|
|
|
|
def _download_fileobj_to_stream(self, key: str, stream: BinaryIO, /) -> None:
|
|
storage_stream_downloader = self._client.download_blob(
|
|
blob=key,
|
|
offset=None,
|
|
length=None,
|
|
max_concurrency=self.MAX_CONCURRENCY,
|
|
)
|
|
storage_stream_downloader.readinto(stream)
|
|
|
|
def _download_range_of_bytes(self, key: str, /, *, stop_byte: int, start_byte: int) -> bytes:
|
|
return self._client.download_blob(
|
|
blob=key, offset=start_byte, length=stop_byte - start_byte + 1
|
|
).readall()
|
|
|
|
@property
|
|
def supported_actions(self):
|
|
pass
|
|
|
|
|
|
def _define_gcs_status(func):
|
|
def wrapper(self, key=None):
|
|
try:
|
|
if not key:
|
|
func(self)
|
|
else:
|
|
func(self, key)
|
|
return Status.AVAILABLE
|
|
except (GoogleCloudNotFound, RetryError):
|
|
return Status.NOT_FOUND
|
|
except GoogleCloudForbidden:
|
|
return Status.FORBIDDEN
|
|
|
|
return wrapper
|
|
|
|
|
|
class GcsCloudStorage(AbstractCloudStorage):
|
|
|
|
class Effect:
|
|
pass
|
|
|
|
def __init__(
|
|
self,
|
|
bucket_name: str,
|
|
*,
|
|
prefix: str | None = None,
|
|
service_account_json: Any | None = None,
|
|
anonymous_access: bool = False,
|
|
project: str | None = None,
|
|
location: str | None = None,
|
|
):
|
|
super().__init__(prefix=prefix)
|
|
if service_account_json:
|
|
self._client = storage.Client.from_service_account_json(service_account_json)
|
|
elif anonymous_access:
|
|
self._client = storage.Client.create_anonymous_client()
|
|
else:
|
|
# If no credentials were provided when constructing the client, the
|
|
# client library will look for credentials in the environment.
|
|
self._client = storage.Client()
|
|
|
|
self._bucket = self._client.bucket(bucket_name, user_project=project)
|
|
self._bucket_location = location
|
|
|
|
@property
|
|
def bucket(self):
|
|
return self._bucket
|
|
|
|
@property
|
|
def name(self):
|
|
return self._bucket.name
|
|
|
|
def _head(self):
|
|
return self._client.get_bucket(bucket_or_name=self.name)
|
|
|
|
def _head_file(self, key: str, /):
|
|
blob = self.bucket.blob(key)
|
|
return self._client._get_resource(blob.path)
|
|
|
|
@_define_gcs_status
|
|
def get_status(self):
|
|
self._head()
|
|
|
|
@_define_gcs_status
|
|
def get_file_status(self, key: str, /):
|
|
self._head_file(key)
|
|
|
|
def _list_raw_content_on_one_page(
|
|
self,
|
|
prefix: str = "",
|
|
*,
|
|
next_token: str | None = None,
|
|
page_size: int = settings.BUCKET_CONTENT_MAX_PAGE_SIZE,
|
|
) -> dict:
|
|
iterator = self._client.list_blobs(
|
|
bucket_or_name=self.name,
|
|
max_results=page_size,
|
|
page_size=page_size,
|
|
fields="items(name),nextPageToken,prefixes", # https://cloud.google.com/storage/docs/json_api/v1/parameters#fields
|
|
delimiter="/",
|
|
**({"prefix": prefix} if prefix else {}),
|
|
**({"page_token": next_token} if next_token else {}),
|
|
)
|
|
# NOTE: we should firstly iterate and only then we can define common prefixes
|
|
files = [
|
|
# skip manually created "directories"
|
|
f.name
|
|
for f in iterator
|
|
if not f.name.endswith("/")
|
|
]
|
|
directories = iterator.prefixes
|
|
|
|
return {
|
|
"files": files,
|
|
"directories": directories,
|
|
"next": iterator.next_page_token,
|
|
}
|
|
|
|
def _download_fileobj_to_stream(self, key: str, stream: BinaryIO, /) -> None:
|
|
blob = self.bucket.blob(key)
|
|
self._client.download_blob_to_file(blob, stream)
|
|
|
|
def _download_range_of_bytes(self, key: str, /, *, stop_byte: int, start_byte: int) -> bytes:
|
|
with BytesIO() as buff:
|
|
blob = self.bucket.blob(key)
|
|
self._client.download_blob_to_file(blob, buff, start_byte, stop_byte)
|
|
buff.seek(0)
|
|
return buff.getvalue()
|
|
|
|
@validate_bucket_status
|
|
def upload_fileobj(self, file_obj: BinaryIO, key: str, /):
|
|
self.bucket.blob(key).upload_from_file(file_obj)
|
|
|
|
@validate_bucket_status
|
|
def upload_file(self, file_path: Path, key: str | None = None, /):
|
|
self.bucket.blob(key or file_path.name).upload_from_filename(os.fspath(file_path))
|
|
|
|
def bulk_delete(self, files: Sequence[str]) -> None:
|
|
def delete_batch(batch: Sequence[str]):
|
|
with self._client.batch():
|
|
for key in batch:
|
|
self.bucket.delete_blob(key)
|
|
|
|
self._in_parallel(delete_batch, list(take_by(files, 100)))
|
|
|
|
@validate_file_status
|
|
@validate_bucket_status
|
|
def get_file_last_modified(self, key: str, /):
|
|
blob = self.bucket.blob(key)
|
|
blob.reload()
|
|
return blob.updated
|
|
|
|
@property
|
|
def supported_actions(self):
|
|
pass
|
|
|
|
|
|
class SubdirectoryCloudStorage(AbstractCloudStorage):
|
|
def __init__(self, underlying: AbstractCloudStorage, subdirectory: str) -> None:
|
|
super().__init__()
|
|
|
|
self.underlying = underlying
|
|
self.subdirectory = subdirectory
|
|
if not self.subdirectory.endswith("/"):
|
|
self.subdirectory += "/"
|
|
|
|
def _map_key(self, key: str) -> str:
|
|
return self.subdirectory + key
|
|
|
|
def _unmap_key(self, key: str) -> str:
|
|
assert key.startswith(self.subdirectory)
|
|
return key[len(self.subdirectory) :]
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return self.underlying.name + "/" + self.subdirectory
|
|
|
|
def get_status(self) -> Status:
|
|
return self.underlying.get_status()
|
|
|
|
def get_file_status(self, key: str, /) -> Status:
|
|
return self.underlying.get_file_status(self._map_key(key))
|
|
|
|
def get_file_last_modified(self, key: str, /) -> datetime:
|
|
return self.underlying.get_file_last_modified(self._map_key(key))
|
|
|
|
def _download_fileobj_to_stream(self, key: str, stream: BinaryIO, /) -> None:
|
|
return self.underlying._download_fileobj_to_stream(self._map_key(key), stream)
|
|
|
|
def _download_range_of_bytes(self, key: str, /, *, stop_byte: int, start_byte: int) -> bytes:
|
|
return self.underlying._download_range_of_bytes(
|
|
self._map_key(key), start_byte=start_byte, stop_byte=stop_byte
|
|
)
|
|
|
|
def upload_fileobj(self, file_obj: BinaryIO, key: str, /) -> None:
|
|
return self.underlying.upload_fileobj(file_obj, self._map_key(key))
|
|
|
|
def upload_file(self, file_path: Path, key: str | None = None, /) -> None:
|
|
assert key is not None
|
|
return self.underlying.upload_file(file_path, self._map_key(key))
|
|
|
|
def bulk_delete(self, files: Sequence[str]) -> None:
|
|
self.underlying.bulk_delete(list(map(self._map_key, files)))
|
|
|
|
def _list_raw_content_on_one_page(
|
|
self,
|
|
prefix: str = "",
|
|
*,
|
|
next_token: str | None = None,
|
|
page_size: int = settings.BUCKET_CONTENT_MAX_PAGE_SIZE,
|
|
) -> dict:
|
|
result = self.underlying._list_raw_content_on_one_page(
|
|
self._map_key(prefix), next_token=next_token, page_size=page_size
|
|
)
|
|
|
|
for key in ("files", "directories"):
|
|
result[key] = list(map(self._unmap_key, result[key]))
|
|
|
|
return result
|
|
|
|
def supported_actions(self):
|
|
return self.underlying.supported_actions
|
|
|
|
|
|
class Credentials:
|
|
__slots__ = (
|
|
"key",
|
|
"secret_key",
|
|
"session_token",
|
|
"account_name",
|
|
"key_file_path",
|
|
"credentials_type",
|
|
"connection_string",
|
|
)
|
|
|
|
def __init__(self, **credentials):
|
|
self.key = credentials.get("key", "")
|
|
self.secret_key = credentials.get("secret_key", "")
|
|
self.session_token = credentials.get("session_token", "")
|
|
self.account_name = credentials.get("account_name", "")
|
|
self.key_file_path = credentials.get("key_file_path", None)
|
|
self.credentials_type = credentials.get("credentials_type", None)
|
|
self.connection_string = credentials.get("connection_string", None)
|
|
|
|
def convert_to_db(self):
|
|
converted_credentials = {
|
|
CredentialsTypeChoice.KEY_SECRET_KEY_PAIR: " ".join([self.key, self.secret_key]),
|
|
CredentialsTypeChoice.ACCOUNT_NAME_TOKEN_PAIR: " ".join(
|
|
[self.account_name, self.session_token]
|
|
),
|
|
CredentialsTypeChoice.KEY_FILE_PATH: self.key_file_path,
|
|
CredentialsTypeChoice.ANONYMOUS_ACCESS: (
|
|
"" if not self.account_name else self.account_name
|
|
),
|
|
CredentialsTypeChoice.CONNECTION_STRING: self.connection_string,
|
|
}
|
|
return converted_credentials[self.credentials_type]
|
|
|
|
def convert_from_db(self, credentials):
|
|
self.credentials_type = credentials.get("type")
|
|
if self.credentials_type == CredentialsTypeChoice.KEY_SECRET_KEY_PAIR:
|
|
self.key, self.secret_key = credentials.get("value").split()
|
|
elif self.credentials_type == CredentialsTypeChoice.ACCOUNT_NAME_TOKEN_PAIR:
|
|
self.account_name, self.session_token = credentials.get("value").split()
|
|
elif self.credentials_type == CredentialsTypeChoice.ANONYMOUS_ACCESS:
|
|
# account_name will be in [some_value, '']
|
|
self.account_name = credentials.get("value")
|
|
elif self.credentials_type == CredentialsTypeChoice.KEY_FILE_PATH:
|
|
self.key_file_path = credentials.get("value")
|
|
elif self.credentials_type == CredentialsTypeChoice.CONNECTION_STRING:
|
|
self.connection_string = credentials.get("value")
|
|
else:
|
|
raise NotImplementedError(
|
|
"Found {} not supported credentials type".format(self.credentials_type)
|
|
)
|
|
|
|
def reset(self, exclusion):
|
|
for i in set(self.__slots__) - exclusion - {"credentials_type"}:
|
|
self.__setattr__(i, "")
|
|
|
|
def mapping_with_new_values(self, credentials):
|
|
self.credentials_type = credentials.get("credentials_type", self.credentials_type)
|
|
if self.credentials_type == CredentialsTypeChoice.ANONYMOUS_ACCESS:
|
|
self.reset(exclusion={"account_name"})
|
|
self.account_name = credentials.get("account_name", self.account_name)
|
|
elif self.credentials_type == CredentialsTypeChoice.KEY_SECRET_KEY_PAIR:
|
|
self.reset(exclusion={"key", "secret_key"})
|
|
self.key = credentials.get("key", self.key)
|
|
self.secret_key = credentials.get("secret_key", self.secret_key)
|
|
elif self.credentials_type == CredentialsTypeChoice.ACCOUNT_NAME_TOKEN_PAIR:
|
|
self.reset(exclusion={"session_token", "account_name"})
|
|
self.session_token = credentials.get("session_token", self.session_token)
|
|
self.account_name = credentials.get("account_name", self.account_name)
|
|
elif self.credentials_type == CredentialsTypeChoice.KEY_FILE_PATH:
|
|
self.reset(exclusion={"key_file_path"})
|
|
self.key_file_path = credentials.get("key_file_path", self.key_file_path)
|
|
elif self.credentials_type == CredentialsTypeChoice.CONNECTION_STRING:
|
|
self.reset(exclusion={"connection_string"})
|
|
self.connection_string = credentials.get("connection_string", self.connection_string)
|
|
else:
|
|
raise NotImplementedError("Mapping credentials: unsupported credentials type")
|
|
|
|
def values(self):
|
|
return [
|
|
self.key,
|
|
self.secret_key,
|
|
self.session_token,
|
|
self.account_name,
|
|
self.key_file_path,
|
|
]
|
|
|
|
|
|
def db_storage_to_storage_instance(
|
|
db_storage: CloudStorage, *, is_trusted: bool = False
|
|
) -> AbstractCloudStorage:
|
|
credentials = Credentials()
|
|
credentials.convert_from_db(
|
|
{
|
|
"type": db_storage.credentials_type,
|
|
"value": db_storage.credentials,
|
|
}
|
|
)
|
|
details = {
|
|
"resource": db_storage.resource,
|
|
"credentials": credentials,
|
|
"specific_attributes": db_storage.get_specific_attributes(),
|
|
}
|
|
return get_cloud_storage_instance(
|
|
cloud_provider=db_storage.provider_type,
|
|
is_trusted=is_trusted,
|
|
**details,
|
|
)
|
|
|
|
|
|
P = ParamSpec("P")
|
|
T = TypeVar("T")
|
|
|
|
|
|
def import_resource_from_cloud_storage(
|
|
filename: str,
|
|
db_storage: CloudStorage,
|
|
key: str,
|
|
import_func: Callable[Concatenate[str, P], T],
|
|
*args: P.args,
|
|
**kwargs: P.kwargs,
|
|
) -> T:
|
|
storage = db_storage_to_storage_instance(db_storage)
|
|
storage.download_file(key, Path(filename))
|
|
|
|
return import_func(filename, *args, **kwargs)
|
|
|
|
|
|
def export_resource_to_cloud_storage(
|
|
db_storage: CloudStorage,
|
|
func: Callable[P, str],
|
|
*args: P.args,
|
|
**kwargs: P.kwargs,
|
|
) -> str:
|
|
rq_job = get_current_job()
|
|
assert rq_job, "func can be executed only from a background job"
|
|
|
|
file_path = func(*args, **kwargs)
|
|
rq_job_meta = ExportRQMeta.for_job(rq_job)
|
|
|
|
storage = db_storage_to_storage_instance(db_storage)
|
|
storage.upload_file(Path(file_path), rq_job_meta.result_filename)
|
|
|
|
return file_path
|