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.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: s3-upload-manager
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.9
5
+ Requires-Dist: pydantic-settings>=2.0.0
6
+ Requires-Dist: botocore>=1.42.36
7
+ Dynamic: requires-dist
8
+ Dynamic: requires-python
@@ -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
@@ -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)
@@ -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,14 @@
1
+ from datetime import datetime
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class PartMetadataSchema(BaseModel):
7
+ PartNumber: int
8
+ ETag: str
9
+
10
+
11
+ class FileMetaData(BaseModel):
12
+ file_size: int
13
+ content_type: str
14
+ last_modified: datetime
@@ -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,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: s3-upload-manager
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.9
5
+ Requires-Dist: pydantic-settings>=2.0.0
6
+ Requires-Dist: botocore>=1.42.36
7
+ Dynamic: requires-dist
8
+ Dynamic: requires-python
@@ -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,2 @@
1
+ pydantic-settings>=2.0.0
2
+ botocore>=1.42.36
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ from setuptools import find_packages, setup
2
+
3
+ setup(
4
+ name="s3-upload-manager",
5
+ version="0.1.0",
6
+ packages=find_packages(exclude=["tests*"]),
7
+ install_requires=["pydantic-settings>=2.0.0", "botocore>=1.42.36"],
8
+ python_requires=">=3.9",
9
+ )