s3-upload-manager 0.1.0__tar.gz
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.
- s3_upload_manager-0.1.0/PKG-INFO +8 -0
- s3_upload_manager-0.1.0/README.md +35 -0
- s3_upload_manager-0.1.0/s3_manager/__init__.py +0 -0
- s3_upload_manager-0.1.0/s3_manager/exception/__init__.py +0 -0
- s3_upload_manager-0.1.0/s3_manager/exception/main.py +35 -0
- s3_upload_manager-0.1.0/s3_manager/s3storage/__init__.py +0 -0
- s3_upload_manager-0.1.0/s3_manager/s3storage/config.py +39 -0
- s3_upload_manager-0.1.0/s3_manager/s3storage/content_hash.py +37 -0
- s3_upload_manager-0.1.0/s3_manager/s3storage/delete.py +26 -0
- s3_upload_manager-0.1.0/s3_manager/s3storage/list.py +18 -0
- s3_upload_manager-0.1.0/s3_manager/s3storage/read.py +49 -0
- s3_upload_manager-0.1.0/s3_manager/s3storage/schema.py +14 -0
- s3_upload_manager-0.1.0/s3_manager/s3storage/session.py +60 -0
- s3_upload_manager-0.1.0/s3_manager/s3storage/write.py +85 -0
- s3_upload_manager-0.1.0/s3_upload_manager.egg-info/PKG-INFO +8 -0
- s3_upload_manager-0.1.0/s3_upload_manager.egg-info/SOURCES.txt +19 -0
- s3_upload_manager-0.1.0/s3_upload_manager.egg-info/dependency_links.txt +1 -0
- s3_upload_manager-0.1.0/s3_upload_manager.egg-info/requires.txt +2 -0
- s3_upload_manager-0.1.0/s3_upload_manager.egg-info/top_level.txt +1 -0
- s3_upload_manager-0.1.0/setup.cfg +4 -0
- s3_upload_manager-0.1.0/setup.py +9 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# s3-manager
|
|
2
|
+
|
|
3
|
+
Библиотека для основных операций с s3-хранилищем
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
Установка из PyPi
|
|
9
|
+
|
|
10
|
+
```commandline
|
|
11
|
+
pip install s3-manager
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Configuration
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
Для конфигурации необходимо добавить в .env файл следующие переменные с вашими значениями
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
APP_S3_SECRET_KEY=your_secret_key
|
|
22
|
+
APP_S3_ACCESS_KEY=your_access_key
|
|
23
|
+
APP_S3_BUCKET=your_bucket
|
|
24
|
+
APP_S3_ENDPOINT=http://localhost:9000
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## License
|
|
28
|
+
Распространяется под лицензией MIT.
|
|
29
|
+
|
|
30
|
+
Поддержка
|
|
31
|
+
Issues: GitLab Issues
|
|
32
|
+
|
|
33
|
+
Вопросы и обсуждения: Открывайте issue для багов и запросов функций
|
|
34
|
+
|
|
35
|
+
Проект в активной разработке. API может меняться
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Вспомогательные методы для обработки исключений
|
|
3
|
+
"""
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from typing_extensions import Coroutine
|
|
7
|
+
|
|
8
|
+
from infrastructure.logger.presenter import logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExceptionHandler:
|
|
12
|
+
"""
|
|
13
|
+
Обработчик исключений
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, ex_handler_map: dict[type[Exception], Callable | Coroutine | None]):
|
|
17
|
+
self.ex_handler_map = ex_handler_map
|
|
18
|
+
|
|
19
|
+
def __call__(self, func: Callable | Coroutine):
|
|
20
|
+
self.func = func
|
|
21
|
+
|
|
22
|
+
return self.wrapper
|
|
23
|
+
|
|
24
|
+
async def wrapper(self, *args, **kwargs):
|
|
25
|
+
"""
|
|
26
|
+
Обертка
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
await self.func(*args, **kwargs)
|
|
30
|
+
except Exception as exception:
|
|
31
|
+
handler = self.ex_handler_map.get(exception.__class__, None)
|
|
32
|
+
logger.info(f"Обработано исключение {exception}. Передаю обработку обработчику {handler}.")
|
|
33
|
+
|
|
34
|
+
if handler:
|
|
35
|
+
await handler(exception, *args, **kwargs)
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Конфигурация взаимодействия с S3
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class S3Config(BaseSettings):
|
|
11
|
+
"""
|
|
12
|
+
S3 main config
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
model_config = SettingsConfigDict(env_prefix="APP_S3_")
|
|
16
|
+
|
|
17
|
+
ACCESS_KEY: str
|
|
18
|
+
SECRET_KEY: str
|
|
19
|
+
ENDPOINT: str = "http://minio:9000"
|
|
20
|
+
BUCKET: str
|
|
21
|
+
|
|
22
|
+
SSL_CA_BUNDLE_PATH: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@lru_cache
|
|
26
|
+
def get_s3_client_config(params: dict = None) -> S3Config:
|
|
27
|
+
"""Получение конфига"""
|
|
28
|
+
if params is None:
|
|
29
|
+
return S3Config()
|
|
30
|
+
return S3Config(**params)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
OPERATIONS_NEEDING_MD5_HEADER = {
|
|
34
|
+
"PutObject",
|
|
35
|
+
"DeleteObjects",
|
|
36
|
+
"CopyObject",
|
|
37
|
+
"CompleteMultipartUpload",
|
|
38
|
+
"UploadPart",
|
|
39
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from base64 import b64encode
|
|
2
|
+
from hashlib import md5
|
|
3
|
+
|
|
4
|
+
from botocore.awsrequest import AWSRequest
|
|
5
|
+
|
|
6
|
+
from s3_manager.s3storage.config import OPERATIONS_NEEDING_MD5_HEADER
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def add_md5_header(request: AWSRequest, **kwargs: dict) -> None:
|
|
10
|
+
"""
|
|
11
|
+
Добавляет Content-MD5 заголовок для операций, изменяющих данные в S3.
|
|
12
|
+
"""
|
|
13
|
+
if (
|
|
14
|
+
kwargs.get("operation_name") not in OPERATIONS_NEEDING_MD5_HEADER
|
|
15
|
+
or not request.body
|
|
16
|
+
):
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
# Получаем содержимое тела
|
|
20
|
+
body = request.body
|
|
21
|
+
if hasattr(body, "read"):
|
|
22
|
+
pos = body.tell()
|
|
23
|
+
body.seek(0)
|
|
24
|
+
content = body.read()
|
|
25
|
+
body.seek(pos)
|
|
26
|
+
elif isinstance(body, bytes):
|
|
27
|
+
content = body
|
|
28
|
+
elif isinstance(body, str):
|
|
29
|
+
content = body.encode("utf-8")
|
|
30
|
+
else:
|
|
31
|
+
try:
|
|
32
|
+
content = str(body).encode("utf-8")
|
|
33
|
+
except Exception:
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
# Вычисляем и добавляем MD5
|
|
37
|
+
request.headers["Content-MD5"] = b64encode(md5(content).digest()).decode("utf-8")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from botocore.client import BaseClient, logger
|
|
2
|
+
|
|
3
|
+
from s3_manager.s3storage.session import s3_client_wrapper
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@s3_client_wrapper
|
|
7
|
+
def s3_delete_files_mass(s3_client: BaseClient, keys: list[str], s3bucket: str):
|
|
8
|
+
"""
|
|
9
|
+
Массовое удаление из S3 хранилища
|
|
10
|
+
"""
|
|
11
|
+
if not keys:
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
response = s3_client.delete_objects(
|
|
15
|
+
Bucket=s3bucket,
|
|
16
|
+
Delete={
|
|
17
|
+
"Objects": [{"Key": key} for key in keys],
|
|
18
|
+
"Quiet": True,
|
|
19
|
+
},
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
status = (response.get("ResponseMetadata") or dict()).get("HTTPStatusCode")
|
|
23
|
+
|
|
24
|
+
logger.info(f"Файлы удалены. Статус ответа от s3 - {status}.")
|
|
25
|
+
|
|
26
|
+
return response
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from botocore.client import BaseClient
|
|
4
|
+
|
|
5
|
+
from s3_manager.s3storage.session import s3_client_wrapper
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@s3_client_wrapper
|
|
9
|
+
def s3_get_list(s3_client: BaseClient, folder_path: str, s3bucket: str) -> list[Any]:
|
|
10
|
+
"""
|
|
11
|
+
Получить список файлов
|
|
12
|
+
"""
|
|
13
|
+
paginator = s3_client.get_paginator("list_objects")
|
|
14
|
+
prefix = folder_path if folder_path.endswith("/") else f"{folder_path}/"
|
|
15
|
+
|
|
16
|
+
paginate_iterator = paginator.paginate(Bucket=s3bucket, Prefix=prefix)
|
|
17
|
+
|
|
18
|
+
return [prefix for prefix in paginate_iterator.search("Contents")]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from io import BytesIO
|
|
2
|
+
|
|
3
|
+
from botocore.client import BaseClient
|
|
4
|
+
|
|
5
|
+
from s3_manager.s3storage.schema import FileMetaData
|
|
6
|
+
from s3_manager.s3storage.session import s3_client_wrapper
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@s3_client_wrapper
|
|
10
|
+
def s3_get_file_chunk(
|
|
11
|
+
s3_client: BaseClient,
|
|
12
|
+
obj_key: str,
|
|
13
|
+
s3bucket: str,
|
|
14
|
+
start_byte: int,
|
|
15
|
+
end_byte: int,
|
|
16
|
+
) -> BytesIO:
|
|
17
|
+
"""
|
|
18
|
+
Прочитать файл
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
response = s3_client.get_object(
|
|
22
|
+
Bucket=s3bucket,
|
|
23
|
+
Key=obj_key,
|
|
24
|
+
Range=f"bytes={start_byte}-{end_byte}",
|
|
25
|
+
)
|
|
26
|
+
return BytesIO(response["Body"].read())
|
|
27
|
+
except Exception:
|
|
28
|
+
return BytesIO(b"")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@s3_client_wrapper
|
|
32
|
+
def s3_get_file_metadata(
|
|
33
|
+
s3_client: BaseClient,
|
|
34
|
+
obj_key: str,
|
|
35
|
+
s3bucket: str,
|
|
36
|
+
) -> FileMetaData:
|
|
37
|
+
"""
|
|
38
|
+
Получить метаданные объека s3
|
|
39
|
+
"""
|
|
40
|
+
obj = s3_client.head_object(
|
|
41
|
+
Bucket=s3bucket,
|
|
42
|
+
Key=obj_key,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return FileMetaData(
|
|
46
|
+
file_size=obj.get("ContentLength"),
|
|
47
|
+
content_type=obj.get("ContentType"),
|
|
48
|
+
last_modified=obj.get("LastModified"),
|
|
49
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Работа с сессиями s3 хранилища
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from asyncio import to_thread
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from botocore.client import BaseClient, Config
|
|
10
|
+
from botocore.session import Session
|
|
11
|
+
|
|
12
|
+
from s3_manager.s3storage.config import get_s3_client_config
|
|
13
|
+
from s3_manager.s3storage.content_hash import add_md5_header
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_s3_client() -> BaseClient:
|
|
17
|
+
"""
|
|
18
|
+
Инверсия на получение сессиии S3
|
|
19
|
+
|
|
20
|
+
:return:
|
|
21
|
+
"""
|
|
22
|
+
session: Session = Session()
|
|
23
|
+
|
|
24
|
+
cfg = get_s3_client_config()
|
|
25
|
+
|
|
26
|
+
client = session.create_client(
|
|
27
|
+
service_name="s3",
|
|
28
|
+
aws_access_key_id=cfg.ACCESS_KEY,
|
|
29
|
+
aws_secret_access_key=cfg.SECRET_KEY,
|
|
30
|
+
endpoint_url=cfg.ENDPOINT,
|
|
31
|
+
region_name="spb99",
|
|
32
|
+
config=Config(
|
|
33
|
+
signature_version="s3v4",
|
|
34
|
+
s3={
|
|
35
|
+
"addressing_style": "path",
|
|
36
|
+
"payload_signing_enabled": True,
|
|
37
|
+
},
|
|
38
|
+
),
|
|
39
|
+
verify=cfg.SSL_CA_BUNDLE_PATH,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
client.meta.events.register("request-created.s3", add_md5_header)
|
|
43
|
+
|
|
44
|
+
return client
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def s3_client_wrapper(func: Callable) -> Callable:
|
|
48
|
+
"""
|
|
49
|
+
Декоратор клиента s3
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
@wraps(func)
|
|
53
|
+
async def wrapper(*args, **kwargs) -> Callable:
|
|
54
|
+
s3_client = get_s3_client()
|
|
55
|
+
try:
|
|
56
|
+
return await to_thread(func, s3_client=s3_client, *args, **kwargs)
|
|
57
|
+
finally:
|
|
58
|
+
s3_client.close()
|
|
59
|
+
|
|
60
|
+
return wrapper
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from base64 import b64encode
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
from hashlib import sha256
|
|
4
|
+
|
|
5
|
+
from botocore.client import BaseClient, logger
|
|
6
|
+
from pydantic import TypeAdapter
|
|
7
|
+
|
|
8
|
+
from s3_manager.exception.main import ExceptionHandler
|
|
9
|
+
from s3_manager.s3storage.schema import PartMetadataSchema
|
|
10
|
+
from s3_manager.s3storage.session import s3_client_wrapper
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@s3_client_wrapper
|
|
14
|
+
def create(s3_client: BaseClient, s3bucket: str, obj_key: str) -> dict:
|
|
15
|
+
"""
|
|
16
|
+
Создает объект с экспайром
|
|
17
|
+
"""
|
|
18
|
+
logger.info("Создаю объект для привязки к чанкам.")
|
|
19
|
+
return s3_client.create_multipart_upload(
|
|
20
|
+
Bucket=s3bucket,
|
|
21
|
+
Key=obj_key,
|
|
22
|
+
Expires=datetime.now() + timedelta(minutes=30),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@s3_client_wrapper
|
|
27
|
+
def upload_part(
|
|
28
|
+
s3_client: BaseClient,
|
|
29
|
+
s3bucket: str,
|
|
30
|
+
obj_key: str,
|
|
31
|
+
chunk: bytes,
|
|
32
|
+
upload_id: str,
|
|
33
|
+
chunk_num: int,
|
|
34
|
+
) -> PartMetadataSchema:
|
|
35
|
+
"""
|
|
36
|
+
Загружает чанк объекта
|
|
37
|
+
"""
|
|
38
|
+
logger.info("Отправляю буфер на s3.")
|
|
39
|
+
res = s3_client.upload_part(
|
|
40
|
+
Bucket=s3bucket,
|
|
41
|
+
Body=chunk, # чанки не могут быть меньше 5 мегабайт, нужно копить их в буфер
|
|
42
|
+
UploadId=upload_id,
|
|
43
|
+
PartNumber=chunk_num,
|
|
44
|
+
Key=obj_key,
|
|
45
|
+
ChecksumAlgorithm="SHA256",
|
|
46
|
+
ChecksumSHA256=b64encode(sha256(chunk).digest()).decode("utf-8"),
|
|
47
|
+
)
|
|
48
|
+
logger.info("Отправил буфер на s3.")
|
|
49
|
+
return PartMetadataSchema(PartNumber=chunk_num, ETag=res.get("ETag"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@s3_client_wrapper
|
|
53
|
+
def complete(
|
|
54
|
+
s3_client: BaseClient,
|
|
55
|
+
s3bucket: str,
|
|
56
|
+
obj_key: str,
|
|
57
|
+
upload_id: str,
|
|
58
|
+
parts: list[PartMetadataSchema],
|
|
59
|
+
):
|
|
60
|
+
"""
|
|
61
|
+
Помечает, что все чанки загружены
|
|
62
|
+
"""
|
|
63
|
+
logger.info("Комитим чанки.")
|
|
64
|
+
parts: list[dict] = TypeAdapter(list[PartMetadataSchema]).dump_python(parts)
|
|
65
|
+
|
|
66
|
+
return s3_client.complete_multipart_upload(
|
|
67
|
+
Bucket=s3bucket,
|
|
68
|
+
Key=obj_key,
|
|
69
|
+
UploadId=upload_id,
|
|
70
|
+
MultipartUpload={"Parts": sorted(parts, key=lambda k: k.get("PartNumber"))},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@ExceptionHandler(ex_handler_map={Exception: None})
|
|
75
|
+
@s3_client_wrapper
|
|
76
|
+
def abort(s3_client: BaseClient, s3bucket: str, obj_key: str, upload_id: str):
|
|
77
|
+
"""
|
|
78
|
+
Откатывает все загруженные чанки и созданный объект
|
|
79
|
+
"""
|
|
80
|
+
logger.info("Откатываем чанки.")
|
|
81
|
+
s3_client.abort_multipart_upload(
|
|
82
|
+
Bucket=s3bucket,
|
|
83
|
+
Key=obj_key,
|
|
84
|
+
UploadId=upload_id,
|
|
85
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
setup.py
|
|
3
|
+
s3_manager/__init__.py
|
|
4
|
+
s3_manager/exception/__init__.py
|
|
5
|
+
s3_manager/exception/main.py
|
|
6
|
+
s3_manager/s3storage/__init__.py
|
|
7
|
+
s3_manager/s3storage/config.py
|
|
8
|
+
s3_manager/s3storage/content_hash.py
|
|
9
|
+
s3_manager/s3storage/delete.py
|
|
10
|
+
s3_manager/s3storage/list.py
|
|
11
|
+
s3_manager/s3storage/read.py
|
|
12
|
+
s3_manager/s3storage/schema.py
|
|
13
|
+
s3_manager/s3storage/session.py
|
|
14
|
+
s3_manager/s3storage/write.py
|
|
15
|
+
s3_upload_manager.egg-info/PKG-INFO
|
|
16
|
+
s3_upload_manager.egg-info/SOURCES.txt
|
|
17
|
+
s3_upload_manager.egg-info/dependency_links.txt
|
|
18
|
+
s3_upload_manager.egg-info/requires.txt
|
|
19
|
+
s3_upload_manager.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
s3_manager
|