safe-s3-storage 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,6 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: github-actions
4
+ directory: /
5
+ schedule:
6
+ interval: monthly
@@ -0,0 +1,19 @@
1
+ name: CI Pipeline
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+ branches:
9
+ - main
10
+ release:
11
+ types:
12
+ - published
13
+
14
+ jobs:
15
+ ci:
16
+ uses: community-of-python/community-workflow/.github/workflows/preset.yml@main
17
+ with:
18
+ python-version: '["3.12","3.13"]'
19
+ secrets: inherit
@@ -0,0 +1,7 @@
1
+ .coverage
2
+ .venv
3
+ dist
4
+ uv.lock
5
+ .mypy_cache
6
+ .ruff_cache
7
+ __pycache__
@@ -0,0 +1,25 @@
1
+ default: install lint test
2
+
3
+ install:
4
+ uv lock --upgrade
5
+ uv sync --frozen --all-groups
6
+
7
+ lint:
8
+ uv run --group lint auto-typing-final .
9
+ uv run --group lint ruff check
10
+ uv run --group lint ruff format
11
+ uv run --group lint mypy .
12
+
13
+ lint-ci:
14
+ uv run --group lint auto-typing-final .
15
+ uv run --group lint ruff format --check
16
+ uv run --group lint ruff check --no-fix
17
+ uv run --group lint mypy .
18
+
19
+ test *args:
20
+ uv run pytest {{ args }}
21
+
22
+ publish:
23
+ rm -rf dist
24
+ uv build
25
+ uv publish --token $PYPI_TOKEN
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Community of python
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,34 @@
1
+ Metadata-Version: 2.4
2
+ Name: safe-s3-storage
3
+ Version: 0.1.0
4
+ Summary: S3 safe storage
5
+ Author: community-of-python
6
+ License-File: LICENSE
7
+ Keywords: antivirus,kaspersky,s3,upload
8
+ Classifier: Natural Language :: English
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Topic :: Communications :: File Sharing
12
+ Classifier: Topic :: Software Development :: Libraries
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.12
15
+ Requires-Dist: aioboto3
16
+ Requires-Dist: httpx
17
+ Requires-Dist: puremagic
18
+ Requires-Dist: pydantic
19
+ Requires-Dist: pyvips
20
+ Requires-Dist: pyvips-binary
21
+ Requires-Dist: stamina
22
+ Requires-Dist: types-aioboto3[s3]
23
+ Description-Content-Type: text/markdown
24
+
25
+ # safe-s3-storage
26
+
27
+ S3 tools for uploading files to S3 safely (antivirus check, etc) as well as downloading and deleting files.
28
+
29
+ ## How To Use
30
+
31
+ ```
32
+ uv add safe-s3-storage
33
+ poetry add safe-s3-storage
34
+ ```
@@ -0,0 +1,10 @@
1
+ # safe-s3-storage
2
+
3
+ S3 tools for uploading files to S3 safely (antivirus check, etc) as well as downloading and deleting files.
4
+
5
+ ## How To Use
6
+
7
+ ```
8
+ uv add safe-s3-storage
9
+ poetry add safe-s3-storage
10
+ ```
@@ -0,0 +1,77 @@
1
+ [project]
2
+ name = "safe-s3-storage"
3
+ description = "S3 safe storage"
4
+ readme = "README.md"
5
+ keywords = ["s3", "kaspersky", "antivirus", "upload"]
6
+ classifiers = [
7
+ "Natural Language :: English",
8
+ "Programming Language :: Python :: 3.12",
9
+ "Programming Language :: Python :: 3.13",
10
+ "Topic :: Software Development :: Libraries",
11
+ "Topic :: Communications :: File Sharing",
12
+ "Typing :: Typed",
13
+ ]
14
+ authors = [{ name = "community-of-python" }]
15
+ dynamic = ["version"]
16
+ requires-python = ">=3.12"
17
+ dependencies = [
18
+ "httpx",
19
+ "aioboto3",
20
+ "types-aioboto3[s3]",
21
+ "stamina",
22
+ "pydantic",
23
+ "pyvips",
24
+ "pyvips-binary",
25
+ "puremagic",
26
+ ]
27
+
28
+ [dependency-groups]
29
+ dev = ["anyio", "faker", "pytest", "pytest-cov"]
30
+ lint = [{ include-group = "dev" }, "auto-typing-final", "mypy", "ruff"]
31
+
32
+ [build-system]
33
+ requires = ["hatchling", "hatch-vcs"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.hatch.version]
37
+ source = "vcs"
38
+
39
+ [tool.ruff]
40
+ fix = true
41
+ unsafe-fixes = true
42
+ line-length = 120
43
+
44
+ [tool.ruff.format]
45
+ docstring-code-format = true
46
+
47
+ [tool.ruff.lint]
48
+ select = ["ALL"]
49
+ ignore = [
50
+ "EM",
51
+ "FBT",
52
+ "TRY003",
53
+ "D1",
54
+ "D203",
55
+ "D213",
56
+ "G004",
57
+ "FA",
58
+ "COM812",
59
+ "ISC001",
60
+ ]
61
+
62
+ [tool.ruff.lint.isort]
63
+ no-lines-before = ["standard-library", "local-folder"]
64
+ known-third-party = []
65
+ known-local-folder = []
66
+ lines-after-imports = 2
67
+
68
+ [tool.ruff.lint.extend-per-file-ignores]
69
+ "tests/*.py" = ["S101", "S311"]
70
+
71
+ [tool.pytest.ini_options]
72
+ addopts = "--cov=."
73
+
74
+ [tool.coverage.report]
75
+ skip_covered = true
76
+ show_missing = true
77
+ exclude_also = ["if typing.TYPE_CHECKING:"]
@@ -0,0 +1,17 @@
1
+ from safe_s3_storage import exceptions
2
+ from safe_s3_storage.file_validator import FileValidator, ImageConversionFormat, ValidatedFile
3
+ from safe_s3_storage.kaspersky_scan_engine import KasperskyScanEngineClient
4
+ from safe_s3_storage.s3_base import BaseS3Service
5
+ from safe_s3_storage.s3_upload import SafeS3FilesUploader, UploadedFile
6
+
7
+
8
+ __all__ = [
9
+ "BaseS3Service",
10
+ "FileValidator",
11
+ "ImageConversionFormat",
12
+ "KasperskyScanEngineClient",
13
+ "SafeS3FilesUploader",
14
+ "UploadedFile",
15
+ "ValidatedFile",
16
+ "exceptions",
17
+ ]
@@ -0,0 +1,33 @@
1
+ import dataclasses
2
+
3
+
4
+ @dataclasses.dataclass
5
+ class BaseError(Exception):
6
+ def __str__(self) -> str:
7
+ return self.__repr__().replace(self.__class__.__name__, "")
8
+
9
+
10
+ @dataclasses.dataclass
11
+ class KasperskyScanEngineThreatDetectedError(BaseError):
12
+ response: bytes
13
+ file_name: str
14
+
15
+
16
+ @dataclasses.dataclass
17
+ class NotAllowedMimeTypeError(BaseError):
18
+ file_name: str
19
+ mime_type: str
20
+ allowed_mime_types: list[str]
21
+
22
+
23
+ @dataclasses.dataclass
24
+ class TooLargeFileError(BaseError):
25
+ file_name: str
26
+ mime_type: str
27
+ max_size: int
28
+
29
+
30
+ @dataclasses.dataclass
31
+ class FailedToConvertImageError(BaseError):
32
+ file_name: str
33
+ mime_type: str
@@ -0,0 +1,108 @@
1
+ import dataclasses
2
+ import enum
3
+ import typing
4
+
5
+ import puremagic
6
+ import pyvips # type: ignore[import-untyped]
7
+
8
+ from safe_s3_storage import exceptions
9
+ from safe_s3_storage.kaspersky_scan_engine import KasperskyScanEngineClient
10
+
11
+
12
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
13
+ class ValidatedFile:
14
+ file_name: str
15
+ file_content: bytes
16
+ file_size: int
17
+ mime_type: str
18
+
19
+
20
+ def _is_image(mime_type: str) -> bool:
21
+ return mime_type.startswith("image/")
22
+
23
+
24
+ class ImageConversionFormat(enum.Enum):
25
+ # mime type, file extension
26
+ jpeg = ("image/jpeg", "jpg")
27
+ webp = ("image/webp", "webp")
28
+
29
+
30
+ def _split_file_base_name_and_extensions(file_name: str) -> tuple[str, str | None]:
31
+ split_result: typing.Final = file_name.rsplit(".", 1) or [file_name]
32
+ return split_result[0], None if len(split_result) == 1 else split_result[1]
33
+
34
+
35
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
36
+ class FileValidator:
37
+ kaspersky_scan_engine: KasperskyScanEngineClient | None = None
38
+ allowed_mime_types: list[str]
39
+ scan_images_with_antivirus: bool = True
40
+ max_file_size_bytes: int = 10 * 1024 * 1024 # 10 MB
41
+ max_image_size_bytes: int = 50 * 1024 * 1024 # 50 MB
42
+ image_conversion_format: ImageConversionFormat = ImageConversionFormat.webp
43
+ image_quality: int = 85
44
+
45
+ def _validate_mime_type(self, *, file_name: str, file_content: bytes) -> str:
46
+ try:
47
+ mime_type = puremagic.from_string(file_content, mime=True)
48
+ except puremagic.PureError:
49
+ # unlike python-magic, puremagic doesn't recognize if text is binary
50
+ try:
51
+ file_content.decode()
52
+ except UnicodeDecodeError:
53
+ mime_type = "application/octet-stream"
54
+ else:
55
+ mime_type = "text/plain"
56
+ if mime_type in self.allowed_mime_types:
57
+ return mime_type
58
+ raise exceptions.NotAllowedMimeTypeError(
59
+ file_name=file_name, mime_type=mime_type, allowed_mime_types=self.allowed_mime_types
60
+ )
61
+
62
+ def _validate_file_size(self, *, file_name: str, file_content: bytes, mime_type: str) -> int:
63
+ content_size: typing.Final = len(file_content)
64
+ max_size: typing.Final = self.max_image_size_bytes if _is_image(mime_type) else self.max_file_size_bytes
65
+ if content_size > max_size:
66
+ raise exceptions.TooLargeFileError(file_name=file_name, mime_type=mime_type, max_size=max_size)
67
+ return content_size
68
+
69
+ def _convert_image(self, validated_file: ValidatedFile) -> ValidatedFile:
70
+ if not _is_image(validated_file.mime_type):
71
+ return validated_file
72
+
73
+ try:
74
+ pyvips_image: typing.Final[pyvips.Image] = pyvips.Image.new_from_buffer(
75
+ validated_file.file_content, options=""
76
+ )
77
+ new_file_content: typing.Final = typing.cast(
78
+ "bytes",
79
+ pyvips_image.write_to_buffer(f".{self.image_conversion_format.value[1]}", Q=self.image_quality),
80
+ )
81
+ except pyvips.Error as pyvips_error:
82
+ raise exceptions.FailedToConvertImageError(
83
+ file_name=validated_file.file_name, mime_type=validated_file.mime_type
84
+ ) from pyvips_error
85
+
86
+ file_base_name, _file_extension = _split_file_base_name_and_extensions(validated_file.file_name)
87
+ return ValidatedFile(
88
+ file_name=f"{file_base_name}.{self.image_conversion_format.value[1]}",
89
+ file_content=new_file_content,
90
+ file_size=len(new_file_content),
91
+ mime_type=self.image_conversion_format.value[0],
92
+ )
93
+
94
+ async def validate_file(self, *, file_name: str, file_content: bytes) -> ValidatedFile:
95
+ mime_type: typing.Final = self._validate_mime_type(file_name=file_name, file_content=file_content)
96
+ file_size: typing.Final = self._validate_file_size(
97
+ file_name=file_name, file_content=file_content, mime_type=mime_type
98
+ )
99
+ validated_file: typing.Final = self._convert_image(
100
+ ValidatedFile(file_name=file_name, file_content=file_content, mime_type=mime_type, file_size=file_size)
101
+ )
102
+ if self.kaspersky_scan_engine:
103
+ is_image: typing.Final = _is_image(validated_file.mime_type)
104
+ if (is_image and self.scan_images_with_antivirus) or not is_image:
105
+ await self.kaspersky_scan_engine.scan_memory(
106
+ file_name=validated_file.file_name, file_content=validated_file.file_content
107
+ )
108
+ return validated_file
@@ -0,0 +1,55 @@
1
+ import base64
2
+ import dataclasses
3
+ import enum
4
+ import typing
5
+
6
+ import httpx
7
+ import pydantic
8
+ import stamina
9
+
10
+ from safe_s3_storage.exceptions import KasperskyScanEngineThreatDetectedError
11
+
12
+
13
+ class KasperskyScanEngineRequest(pydantic.BaseModel):
14
+ timeout: str
15
+ object: str
16
+ name: str
17
+
18
+
19
+ # https://support.kaspersky.ru/scan-engine/2.1/193001
20
+ class KasperskyScanEngineScanResult(enum.StrEnum):
21
+ CLEAN = enum.auto()
22
+ DETECT = enum.auto()
23
+ DISINFECTED = enum.auto()
24
+ DELETED = enum.auto()
25
+ NON_SCANNED = enum.auto()
26
+ SERVER_ERROR = enum.auto()
27
+
28
+
29
+ class KasperskyScanEngineResponse(pydantic.BaseModel):
30
+ scanResult: KasperskyScanEngineScanResult # noqa: N815
31
+
32
+
33
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
34
+ class KasperskyScanEngineClient:
35
+ httpx_client: httpx.AsyncClient
36
+ service_url: str
37
+ client_name: str
38
+ timeout_ms: int = 10000
39
+ max_retries: int = 3
40
+
41
+ async def _send_scan_memory_request(self, payload: dict[str, typing.Any]) -> bytes:
42
+ response: typing.Final = await self.httpx_client.post(url=self.service_url, json=payload)
43
+ response.raise_for_status()
44
+ return response.content
45
+
46
+ async def scan_memory(self, *, file_name: str, file_content: bytes) -> None:
47
+ payload: typing.Final = KasperskyScanEngineRequest(
48
+ timeout=str(self.timeout_ms), object=base64.b64encode(file_content).decode(), name=self.client_name
49
+ ).model_dump(mode="json")
50
+ response: typing.Final = await stamina.retry(on=httpx.HTTPError, attempts=self.max_retries)(
51
+ self._send_scan_memory_request
52
+ )(payload)
53
+ validated_response: typing.Final = KasperskyScanEngineResponse.model_validate_json(response)
54
+ if validated_response.scanResult == KasperskyScanEngineScanResult.DETECT:
55
+ raise KasperskyScanEngineThreatDetectedError(response=response, file_name=file_name)
@@ -0,0 +1,10 @@
1
+ import dataclasses
2
+
3
+ from types_aiobotocore_s3 import S3Client
4
+
5
+
6
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
7
+ class BaseS3Service:
8
+ s3_client: S3Client
9
+ bucket_name: str
10
+ max_retries: int = 3
@@ -0,0 +1,43 @@
1
+ import dataclasses
2
+ import typing
3
+
4
+ import botocore
5
+ import botocore.exceptions
6
+ import stamina
7
+
8
+ from safe_s3_storage.file_validator import FileValidator, ValidatedFile
9
+ from safe_s3_storage.s3_base import BaseS3Service
10
+
11
+
12
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
13
+ class UploadedFile(ValidatedFile):
14
+ s3_path: str
15
+
16
+
17
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
18
+ class SafeS3FilesUploader(BaseS3Service):
19
+ file_validator: FileValidator
20
+ s3_key_generator: typing.Callable[[ValidatedFile], str] = lambda file_context: file_context.file_name
21
+ s3_metadata_generator: typing.Callable[[ValidatedFile], typing.Mapping[str, str]] = lambda _file_context: {}
22
+
23
+ async def upload_file(self, *, file_name: str, file_content: bytes) -> UploadedFile:
24
+ validated_file: typing.Final = await self.file_validator.validate_file(
25
+ file_name=file_name, file_content=file_content
26
+ )
27
+ s3_key: typing.Final = self.s3_key_generator(validated_file)
28
+
29
+ await stamina.retry(on=botocore.exceptions.BotoCoreError, attempts=self.max_retries)(self.s3_client.put_object)(
30
+ Body=validated_file.file_content,
31
+ Bucket=self.bucket_name,
32
+ Key=s3_key,
33
+ ContentType=validated_file.mime_type,
34
+ Metadata=self.s3_metadata_generator(validated_file),
35
+ )
36
+
37
+ return UploadedFile(
38
+ file_name=validated_file.file_name,
39
+ file_content=validated_file.file_content,
40
+ file_size=validated_file.file_size,
41
+ mime_type=validated_file.mime_type,
42
+ s3_path=f"{self.bucket_name}/{s3_key}",
43
+ )
File without changes
@@ -0,0 +1,22 @@
1
+ import typing
2
+
3
+ import faker
4
+ import pytest
5
+ import stamina
6
+
7
+
8
+ @pytest.fixture(scope="session", autouse=True)
9
+ def anyio_backend() -> str:
10
+ return "asyncio"
11
+
12
+
13
+ @pytest.fixture(scope="session", autouse=True)
14
+ def deactivate_retries() -> None:
15
+ stamina.set_active(False)
16
+
17
+
18
+ MIME_OCTET_STREAM: typing.Final = "application/octet-stream"
19
+
20
+
21
+ def generate_binary_content(faker: faker.Faker) -> bytes:
22
+ return faker.binary(length=faker.pyint(min_value=10, max_value=100))
@@ -0,0 +1,15 @@
1
+ import typing
2
+
3
+ import faker
4
+
5
+ from safe_s3_storage.exceptions import KasperskyScanEngineThreatDetectedError
6
+ from tests.conftest import generate_binary_content
7
+
8
+
9
+ def test_exception_str(faker: faker.Faker) -> None:
10
+ response: typing.Final = generate_binary_content(faker)
11
+ file_name: typing.Final = faker.file_name()
12
+ assert (
13
+ str(KasperskyScanEngineThreatDetectedError(response=response, file_name=file_name))
14
+ == f"({response=}, {file_name=})"
15
+ )
@@ -0,0 +1,146 @@
1
+ import random
2
+ import typing
3
+
4
+ import faker
5
+ import httpx
6
+ import pytest
7
+
8
+ from safe_s3_storage import exceptions
9
+ from safe_s3_storage.file_validator import FileValidator, ImageConversionFormat
10
+ from safe_s3_storage.kaspersky_scan_engine import (
11
+ KasperskyScanEngineClient,
12
+ KasperskyScanEngineResponse,
13
+ KasperskyScanEngineScanResult,
14
+ )
15
+ from tests.conftest import MIME_OCTET_STREAM, generate_binary_content
16
+
17
+
18
+ @pytest.fixture
19
+ def png_file() -> bytes:
20
+ return (
21
+ b"\x89PNG\r\n\x1a\n" # PNG signature
22
+ b"\x00\x00\x00\r" # IHDR chunk length
23
+ b"IHDR" # IHDR chunk type
24
+ b"\x00\x00\x00\x01" # width: 1
25
+ b"\x00\x00\x00\x01" # height: 1
26
+ b"\x08" # bit depth: 8
27
+ b"\x06" # color type: RGBA
28
+ b"\x00" # compression method
29
+ b"\x00" # filter method
30
+ b"\x00" # interlace method
31
+ b"\x1f\x15\xc4\x89" # CRC for IHDR
32
+ b"\x00\x00\x00\x0a" # IDAT chunk length
33
+ b"IDAT" # IDAT chunk type
34
+ b"\x78\x9c\x63\x60\x00\x00\x00\x02\x00\x01" # compressed image data (deflate)
35
+ b"\x5d\xc6\x2d\xb4" # CRC for IDAT
36
+ b"\x00\x00\x00\x00" # IEND chunk length
37
+ b"IEND" # IEND chunk type
38
+ b"\xae\x42\x60\x82" # CRC for IEND
39
+ )
40
+
41
+
42
+ def get_mocked_kaspersky_scan_engine_client(*, faker: faker.Faker, ok_response: bool) -> KasperskyScanEngineClient:
43
+ if ok_response:
44
+ all_scan_results: typing.Final[list[KasperskyScanEngineScanResult]] = list(KasperskyScanEngineScanResult)
45
+ all_scan_results.remove(KasperskyScanEngineScanResult.DETECT)
46
+ scan_result = random.choice(all_scan_results)
47
+ else:
48
+ scan_result = KasperskyScanEngineScanResult.DETECT
49
+
50
+ scan_response: typing.Final = KasperskyScanEngineResponse(scanResult=scan_result)
51
+ return KasperskyScanEngineClient(
52
+ service_url=faker.url(schemes=["http"]),
53
+ client_name=faker.pystr(),
54
+ httpx_client=httpx.AsyncClient(
55
+ transport=httpx.MockTransport(lambda _: httpx.Response(200, json=scan_response.model_dump(mode="json")))
56
+ ),
57
+ )
58
+
59
+
60
+ class TestFileValidator:
61
+ async def test_fails_to_validate_mime_type(self, faker: faker.Faker) -> None:
62
+ with pytest.raises(exceptions.NotAllowedMimeTypeError):
63
+ await FileValidator(allowed_mime_types=["image/jpeg"]).validate_file(
64
+ file_name=faker.file_name(), file_content=generate_binary_content(faker)
65
+ )
66
+
67
+ async def test_fails_to_validate_file_size(self, faker: faker.Faker) -> None:
68
+ with pytest.raises(exceptions.TooLargeFileError):
69
+ await FileValidator(allowed_mime_types=[MIME_OCTET_STREAM], max_file_size_bytes=0).validate_file(
70
+ file_name=faker.file_name(), file_content=generate_binary_content(faker)
71
+ )
72
+
73
+ async def test_fails_to_validate_image_size(self, faker: faker.Faker, png_file: bytes) -> None:
74
+ with pytest.raises(exceptions.TooLargeFileError):
75
+ await FileValidator(allowed_mime_types=["image/png"], max_image_size_bytes=0).validate_file(
76
+ file_name=faker.file_name(), file_content=png_file
77
+ )
78
+
79
+ async def test_fails_to_convert_image(self, faker: faker.Faker, png_file: bytes) -> None:
80
+ with pytest.raises(exceptions.FailedToConvertImageError):
81
+ await FileValidator(allowed_mime_types=["image/png"]).validate_file(
82
+ file_name=faker.file_name(), file_content=png_file[:50]
83
+ )
84
+
85
+ @pytest.mark.parametrize("image_conversion_format", list(ImageConversionFormat))
86
+ async def test_ok_image(
87
+ self, faker: faker.Faker, png_file: bytes, image_conversion_format: ImageConversionFormat
88
+ ) -> None:
89
+ file_base_name: typing.Final = faker.pystr()
90
+
91
+ validated_file: typing.Final = await FileValidator(
92
+ allowed_mime_types=["image/png"], image_conversion_format=image_conversion_format
93
+ ).validate_file(file_name=f"{file_base_name}.{faker.file_extension()}", file_content=png_file)
94
+
95
+ assert validated_file.file_name == f"{file_base_name}.{image_conversion_format.value[1]}"
96
+ assert validated_file.file_content != png_file
97
+ assert validated_file.file_size == len(validated_file.file_content)
98
+ assert validated_file.mime_type == image_conversion_format.value[0]
99
+
100
+ @pytest.mark.parametrize("binary", [True, False])
101
+ async def test_ok_not_image(self, faker: faker.Faker, binary: bool) -> None:
102
+ file_name: typing.Final = faker.file_name()
103
+ file_content: typing.Final = generate_binary_content(faker) if binary else faker.pystr().encode()
104
+
105
+ validated_file: typing.Final = await FileValidator(
106
+ allowed_mime_types=[MIME_OCTET_STREAM if binary else "text/plain"]
107
+ ).validate_file(file_name=file_name, file_content=file_content)
108
+
109
+ assert validated_file.file_name == file_name
110
+ assert validated_file.file_content == file_content
111
+ assert validated_file.file_size == len(file_content)
112
+ assert validated_file.mime_type == MIME_OCTET_STREAM if binary else "text/plain"
113
+
114
+ @pytest.mark.parametrize("ok_response", [True, False])
115
+ async def test_antivirus_skips_images(self, faker: faker.Faker, png_file: bytes, ok_response: bool) -> None:
116
+ await FileValidator(
117
+ kaspersky_scan_engine=get_mocked_kaspersky_scan_engine_client(faker=faker, ok_response=ok_response),
118
+ scan_images_with_antivirus=False,
119
+ allowed_mime_types=["image/png"],
120
+ ).validate_file(file_name=faker.file_name(), file_content=png_file)
121
+
122
+ async def test_antivirus_fails_on_files(self, faker: faker.Faker) -> None:
123
+ with pytest.raises(exceptions.KasperskyScanEngineThreatDetectedError):
124
+ await FileValidator(
125
+ kaspersky_scan_engine=get_mocked_kaspersky_scan_engine_client(faker=faker, ok_response=False),
126
+ allowed_mime_types=[MIME_OCTET_STREAM],
127
+ ).validate_file(file_name=faker.file_name(), file_content=generate_binary_content(faker))
128
+
129
+ async def test_antivirus_fails_on_images(self, faker: faker.Faker, png_file: bytes) -> None:
130
+ with pytest.raises(exceptions.KasperskyScanEngineThreatDetectedError):
131
+ await FileValidator(
132
+ kaspersky_scan_engine=get_mocked_kaspersky_scan_engine_client(faker=faker, ok_response=False),
133
+ allowed_mime_types=["image/png"],
134
+ ).validate_file(file_name=faker.file_name(), file_content=png_file)
135
+
136
+ async def test_antivirus_passes_on_files(self, faker: faker.Faker) -> None:
137
+ await FileValidator(
138
+ kaspersky_scan_engine=get_mocked_kaspersky_scan_engine_client(faker=faker, ok_response=True),
139
+ allowed_mime_types=[MIME_OCTET_STREAM],
140
+ ).validate_file(file_name=faker.file_name(), file_content=generate_binary_content(faker))
141
+
142
+ async def test_antivirus_passes_on_images(self, faker: faker.Faker, png_file: bytes) -> None:
143
+ await FileValidator(
144
+ kaspersky_scan_engine=get_mocked_kaspersky_scan_engine_client(faker=faker, ok_response=True),
145
+ allowed_mime_types=["image/png"],
146
+ ).validate_file(file_name=faker.file_name(), file_content=png_file)
@@ -0,0 +1,67 @@
1
+ import typing
2
+ from unittest import mock
3
+
4
+ import faker
5
+
6
+ from safe_s3_storage.file_validator import FileValidator
7
+ from safe_s3_storage.s3_upload import SafeS3FilesUploader, UploadedFile
8
+ from tests.conftest import MIME_OCTET_STREAM, generate_binary_content
9
+
10
+
11
+ class TestSafeS3FilesUploader:
12
+ async def test_ok_with_defaults(self, faker: faker.Faker) -> None:
13
+ s3_client_mock: typing.Final = mock.AsyncMock()
14
+ file_name: typing.Final = faker.file_name()
15
+ bucket_name: typing.Final = faker.pystr()
16
+ file_content: typing.Final = generate_binary_content(faker)
17
+
18
+ uploaded_file: typing.Final = await SafeS3FilesUploader(
19
+ file_validator=FileValidator(allowed_mime_types=[MIME_OCTET_STREAM]),
20
+ s3_client=s3_client_mock,
21
+ bucket_name=bucket_name,
22
+ ).upload_file(file_name=file_name, file_content=file_content)
23
+
24
+ assert uploaded_file == UploadedFile(
25
+ file_content=file_content,
26
+ file_name=file_name,
27
+ file_size=len(file_content),
28
+ mime_type=MIME_OCTET_STREAM,
29
+ s3_path=f"{bucket_name}/{file_name}",
30
+ )
31
+ s3_client_mock.put_object.assert_called_once_with(
32
+ Body=file_content,
33
+ Bucket=bucket_name,
34
+ Key=file_name,
35
+ ContentType=MIME_OCTET_STREAM,
36
+ Metadata={},
37
+ )
38
+
39
+ async def test_ok_with_custom_key_generator(self, faker: faker.Faker) -> None:
40
+ s3_client_mock: typing.Final = mock.AsyncMock()
41
+ file_name: typing.Final = faker.file_name()
42
+ file_name_prefix: typing.Final = faker.pystr()
43
+ bucket_name: typing.Final = faker.pystr()
44
+
45
+ uploaded_file: typing.Final = await SafeS3FilesUploader(
46
+ file_validator=FileValidator(allowed_mime_types=[MIME_OCTET_STREAM]),
47
+ s3_client=s3_client_mock,
48
+ bucket_name=bucket_name,
49
+ s3_key_generator=lambda file_context: file_name_prefix + file_context.file_name,
50
+ ).upload_file(file_name=file_name, file_content=generate_binary_content(faker))
51
+
52
+ assert uploaded_file.s3_path == f"{bucket_name}/{file_name_prefix}{file_name}"
53
+ assert s3_client_mock.put_object.mock_calls[0].kwargs["Key"] == file_name_prefix + file_name
54
+
55
+ async def test_ok_with_custom_metadata_generator(self, faker: faker.Faker) -> None:
56
+ s3_client_mock: typing.Final = mock.AsyncMock()
57
+ file_name: typing.Final = faker.file_name()
58
+ file_original_name_key: typing.Final = faker.pystr()
59
+
60
+ await SafeS3FilesUploader(
61
+ file_validator=FileValidator(allowed_mime_types=[MIME_OCTET_STREAM]),
62
+ s3_client=s3_client_mock,
63
+ bucket_name=faker.pystr(),
64
+ s3_metadata_generator=lambda file_context: {file_original_name_key: file_context.file_name},
65
+ ).upload_file(file_name=file_name, file_content=generate_binary_content(faker))
66
+
67
+ assert s3_client_mock.put_object.mock_calls[0].kwargs["Metadata"] == {file_original_name_key: file_name}