diff --git a/safe_s3_storage/file_validator.py b/safe_s3_storage/file_validator.py index aa834ac..4f16c26 100644 --- a/safe_s3_storage/file_validator.py +++ b/safe_s3_storage/file_validator.py @@ -75,6 +75,13 @@ def _should_convert_file(self, file_name: str) -> bool: def _convert_image(self, validated_file: ValidatedFile) -> ValidatedFile: import pyvips # type: ignore[import-untyped] # noqa: PLC0415 + # libvips keeps a process-global operation cache. For one-shot conversions of + # unique upload buffers it never hits and only retains memory, so disable it. + # The guard makes the setter effectively run once (max is 0 after the first + # call) and avoids a module-level global that ruff's ALL ruleset would flag. + if pyvips.cache_get_max(): + pyvips.cache_set_max(0) + if not _is_image(validated_file.mime_type): return validated_file diff --git a/tests/test_file_validator.py b/tests/test_file_validator.py index 4505ce4..efab9a0 100644 --- a/tests/test_file_validator.py +++ b/tests/test_file_validator.py @@ -4,6 +4,7 @@ import faker import httpx import pytest +import pyvips # type: ignore[import-untyped] from httpx import codes as status_codes from safe_s3_storage import exceptions @@ -212,3 +213,15 @@ async def test_excluded_conversion_formats( assert validated_file.file_name == f"{file_base_name}.{file_extension}" assert validated_file.file_content == png_file assert validated_file.file_size == len(validated_file.file_content) + + async def test_conversion_does_not_leave_pyvips_operations_cached( + self, faker: faker.Faker, png_file: bytes + ) -> None: + # libvips keeps a process-global operation cache; for one-shot conversions of + # unique buffers it only retains memory. The validator must disable it so the + # cache does not accumulate across conversions (memory leak). + await FileValidator(allowed_mime_types=["image/png"]).validate_file( + file_name=f"{faker.pystr()}.png", file_content=png_file + ) + + assert pyvips.cache_get_size() == 0