From 59e100ddabbcbe4807ac83168704de6837f58d1a Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 22 Feb 2026 22:00:22 +0000 Subject: [PATCH 1/2] Use context manager for PIL Image.open to ensure proper resource cleanup Image.open() was used without explicit close or context manager. When the source is a file stream (e.g. multipart upload), this can hold file handles until garbage collection. Prefer 'with Image.open(...) as img:' to ensure files are closed promptly. Fixes resource leak in: - image_matchers.py (StructuralSimilarityMatcher) - _query_validators/image_validators.py (validate_image_format, validate_image_dimensions, validate_image_is_image) - _services_validators/image_validators.py (validate_image_integrity, validate_image_format, validate_image_color_space, validate_image_is_image) - target.py (ImageTarget._post_processing_status) - target_raters.py (_get_brisque_target_tracking_rating) Co-authored-by: Cursor --- .../_query_validators/image_validators.py | 20 ++++++------- .../_services_validators/image_validators.py | 30 +++++++++---------- src/mock_vws/image_matchers.py | 16 +++++----- src/mock_vws/target.py | 7 ++--- src/mock_vws/target_raters.py | 16 +++++----- 5 files changed, 44 insertions(+), 45 deletions(-) diff --git a/src/mock_vws/_query_validators/image_validators.py b/src/mock_vws/_query_validators/image_validators.py index d08617ec2..8c0494c7e 100644 --- a/src/mock_vws/_query_validators/image_validators.py +++ b/src/mock_vws/_query_validators/image_validators.py @@ -130,11 +130,11 @@ def validate_image_dimensions( image_part = files["image"] image_value = image_part.stream.read() image_file = io.BytesIO(initial_bytes=image_value) - pil_image = Image.open(fp=image_file) - max_width = 30000 - max_height = 30000 - if pil_image.height <= max_height and pil_image.width <= max_width: - return + with Image.open(fp=image_file) as pil_image: + max_width = 30000 + max_height = 30000 + if pil_image.height <= max_height and pil_image.width <= max_width: + return _LOGGER.warning(msg="The image dimensions are too large.") raise BadImageError @@ -160,10 +160,9 @@ def validate_image_format( request_body=request_body, ) image_part = files["image"] - pil_image = Image.open(fp=image_part.stream) - - if pil_image.format in {"PNG", "JPEG"}: - return + with Image.open(fp=image_part.stream) as pil_image: + if pil_image.format in {"PNG", "JPEG"}: + return _LOGGER.warning(msg="The image format is not PNG or JPEG.") raise BadImageError @@ -191,7 +190,8 @@ def validate_image_is_image( image_file = files["image"].stream try: - Image.open(fp=image_file) + with Image.open(fp=image_file) as _: + pass except OSError as exc: _LOGGER.warning(msg="The image is not an image file.") raise BadImageError from exc diff --git a/src/mock_vws/_services_validators/image_validators.py b/src/mock_vws/_services_validators/image_validators.py index c5744efff..e5413b7f8 100644 --- a/src/mock_vws/_services_validators/image_validators.py +++ b/src/mock_vws/_services_validators/image_validators.py @@ -40,13 +40,12 @@ def validate_image_integrity(*, request_body: bytes) -> None: decoded = decode_base64(encoded_data=image) image_file = io.BytesIO(initial_bytes=decoded) - pil_image = Image.open(fp=image_file) - - try: - pil_image.verify() - except SyntaxError as exc: - _LOGGER.warning(msg="The image is not a valid image file.") - raise BadImageError from exc + with Image.open(fp=image_file) as pil_image: + try: + pil_image.verify() + except SyntaxError as exc: + _LOGGER.warning(msg="The image is not a valid image file.") + raise BadImageError from exc @beartype @@ -70,10 +69,9 @@ def validate_image_format(*, request_body: bytes) -> None: decoded = decode_base64(encoded_data=image) image_file = io.BytesIO(initial_bytes=decoded) - pil_image = Image.open(fp=image_file) - - if pil_image.format in {"PNG", "JPEG"}: - return + with Image.open(fp=image_file) as pil_image: + if pil_image.format in {"PNG", "JPEG"}: + return _LOGGER.warning(msg="The image is not a PNG or JPEG.") raise BadImageError @@ -101,10 +99,9 @@ def validate_image_color_space(*, request_body: bytes) -> None: decoded = decode_base64(encoded_data=image) image_file = io.BytesIO(initial_bytes=decoded) - pil_image = Image.open(fp=image_file) - - if pil_image.mode in {"L", "RGB"}: - return + with Image.open(fp=image_file) as pil_image: + if pil_image.mode in {"L", "RGB"}: + return _LOGGER.warning( msg="The image is not in the RGB or greyscale color space.", @@ -165,7 +162,8 @@ def validate_image_is_image(*, request_body: bytes) -> None: image_file = io.BytesIO(initial_bytes=decoded) try: - Image.open(fp=image_file) + with Image.open(fp=image_file) as _: + pass except OSError as exc: raise BadImageError from exc diff --git a/src/mock_vws/image_matchers.py b/src/mock_vws/image_matchers.py index 2aa954794..686957ad2 100644 --- a/src/mock_vws/image_matchers.py +++ b/src/mock_vws/image_matchers.py @@ -69,14 +69,16 @@ def __call__( second_image_content: Another image's content. """ first_image_file = io.BytesIO(initial_bytes=first_image_content) - first_image = Image.open(fp=first_image_file) second_image_file = io.BytesIO(initial_bytes=second_image_content) - second_image = Image.open(fp=second_image_file) - # Images must be the same size, and they must be larger than the - # default SSIM window size of 11x11. - target_size = (256, 256) - first_image_resized = first_image.resize(size=target_size) - second_image_resized = second_image.resize(size=target_size) + with ( + Image.open(fp=first_image_file) as first_image, + Image.open(fp=second_image_file) as second_image, + ): + # Images must be the same size, and they must be larger than the + # default SSIM window size of 11x11. + target_size = (256, 256) + first_image_resized = first_image.resize(size=target_size) + second_image_resized = second_image.resize(size=target_size) first_image_np = np.array(object=first_image_resized, dtype=np.float32) first_image_tensor = torch.tensor(data=first_image_np).float() / 255 diff --git a/src/mock_vws/target.py b/src/mock_vws/target.py index 2e3760e4b..0c567a799 100644 --- a/src/mock_vws/target.py +++ b/src/mock_vws/target.py @@ -92,10 +92,9 @@ def _post_processing_status(self) -> TargetStatuses: suitable the target is for detection. """ image_file = io.BytesIO(initial_bytes=self.image_value) - image = Image.open(fp=image_file) - image_stat = ImageStat.Stat(image_or_list=image) - - average_std_dev = statistics.mean(data=image_stat.stddev) + with Image.open(fp=image_file) as image: + image_stat = ImageStat.Stat(image_or_list=image) + average_std_dev = statistics.mean(data=image_stat.stddev) success_threshold = 5 diff --git a/src/mock_vws/target_raters.py b/src/mock_vws/target_raters.py index ad75099fb..3358ca483 100644 --- a/src/mock_vws/target_raters.py +++ b/src/mock_vws/target_raters.py @@ -26,14 +26,14 @@ def _get_brisque_target_tracking_rating(*, image_content: bytes) -> int: image_content: A target's image's content. """ image_file = io.BytesIO(initial_bytes=image_content) - image = Image.open(fp=image_file) - image_np = np.array(object=image, dtype=np.float32) - image_tensor = torch.tensor(data=image_np).float() / 255 - image_tensor = image_tensor.view( - image.size[1], - image.size[0], - len(image.getbands()), - ) + with Image.open(fp=image_file) as image: + image_np = np.array(object=image, dtype=np.float32) + image_tensor = torch.tensor(data=image_np).float() / 255 + image_tensor = image_tensor.view( + image.size[1], + image.size[0], + len(image.getbands()), + ) image_tensor = image_tensor.permute(2, 0, 1).unsqueeze(dim=0) try: brisque_score = brisque(x=image_tensor, data_range=255) From 5c84590edb4f30133c536921bfe48fc57215c0f2 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Mon, 23 Feb 2026 09:45:06 +0000 Subject: [PATCH 2/2] Use * for keyword-only arguments in pytest fixtures Co-authored-by: Cursor --- tests/conftest.py | 17 +++++++++++------ tests/mock_vws/fixtures/prepared_requests.py | 12 ++++++++++-- tests/mock_vws/fixtures/vuforia_backends.py | 2 ++ tests/mock_vws/test_flask_app_usage.py | 2 +- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 957422d2c..c6af5c266 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,7 +22,7 @@ @pytest.fixture(name="vws_client") -def fixture_vws_client(vuforia_database: CloudDatabase) -> VWS: +def fixture_vws_client(*, vuforia_database: CloudDatabase) -> VWS: """A VWS client for an active VWS database.""" return VWS( server_access_key=vuforia_database.server_access_key, @@ -31,7 +31,7 @@ def fixture_vws_client(vuforia_database: CloudDatabase) -> VWS: @pytest.fixture -def cloud_reco_client(vuforia_database: CloudDatabase) -> CloudRecoService: +def cloud_reco_client(*, vuforia_database: CloudDatabase) -> CloudRecoService: """A query client for an active VWS database.""" return CloudRecoService( client_access_key=vuforia_database.client_access_key, @@ -40,7 +40,7 @@ def cloud_reco_client(vuforia_database: CloudDatabase) -> CloudRecoService: @pytest.fixture(name="inactive_vws_client") -def fixture_inactive_vws_client(inactive_database: CloudDatabase) -> VWS: +def fixture_inactive_vws_client(*, inactive_database: CloudDatabase) -> VWS: """A client for an inactive VWS database.""" return VWS( server_access_key=inactive_database.server_access_key, @@ -50,6 +50,7 @@ def fixture_inactive_vws_client(inactive_database: CloudDatabase) -> VWS: @pytest.fixture def inactive_cloud_reco_client( + *, inactive_database: CloudDatabase, ) -> CloudRecoService: """A query client for an inactive VWS database.""" @@ -61,6 +62,7 @@ def inactive_cloud_reco_client( @pytest.fixture def target_id( + *, image_file_success_state_low_rating: io.BytesIO, vws_client: VWS, ) -> str: @@ -91,7 +93,7 @@ def target_id( "vumark_generate_instance", ], ) -def endpoint(request: pytest.FixtureRequest) -> Endpoint: +def endpoint(*, request: pytest.FixtureRequest) -> Endpoint: """ Return details of an endpoint for the Target API or the Query API. @@ -120,7 +122,7 @@ def endpoint(request: pytest.FixtureRequest) -> Endpoint: ), ], ) -def not_base64_encoded_processable(request: pytest.FixtureRequest) -> str: +def not_base64_encoded_processable(*, request: pytest.FixtureRequest) -> str: """Return a string which is not decodable as base64 data, but Vuforia will respond as if this is valid base64 data. @@ -144,7 +146,10 @@ def not_base64_encoded_processable(request: pytest.FixtureRequest) -> str: pytest.param('"', id="Not a base64 character."), ], ) -def not_base64_encoded_not_processable(request: pytest.FixtureRequest) -> str: +def not_base64_encoded_not_processable( + *, + request: pytest.FixtureRequest, +) -> str: """ Return a string which is not decodable as base64 data, and Vuforia will diff --git a/tests/mock_vws/fixtures/prepared_requests.py b/tests/mock_vws/fixtures/prepared_requests.py index 4bd4ec33e..c3cf48669 100644 --- a/tests/mock_vws/fixtures/prepared_requests.py +++ b/tests/mock_vws/fixtures/prepared_requests.py @@ -39,6 +39,7 @@ def _wait_for_target_processed(*, vws_client: VWS, target_id: str) -> None: @pytest.fixture def add_target( + *, vuforia_database: CloudDatabase, image_file_failed_state: io.BytesIO, ) -> Endpoint: @@ -93,6 +94,7 @@ def add_target( @pytest.fixture def delete_target( + *, vuforia_database: CloudDatabase, target_id: str, vws_client: VWS, @@ -136,7 +138,7 @@ def delete_target( @pytest.fixture -def database_summary(vuforia_database: CloudDatabase) -> Endpoint: +def database_summary(*, vuforia_database: CloudDatabase) -> Endpoint: """ Return details of the endpoint for getting details about the database. @@ -180,6 +182,7 @@ def database_summary(vuforia_database: CloudDatabase) -> Endpoint: @pytest.fixture def get_duplicates( + *, vuforia_database: CloudDatabase, target_id: str, vws_client: VWS, @@ -228,6 +231,7 @@ def get_duplicates( @pytest.fixture def get_target( + *, vuforia_database: CloudDatabase, target_id: str, vws_client: VWS, @@ -272,7 +276,7 @@ def get_target( @pytest.fixture -def target_list(vuforia_database: CloudDatabase) -> Endpoint: +def target_list(*, vuforia_database: CloudDatabase) -> Endpoint: """Return details of the endpoint for getting a list of targets.""" date = rfc_1123_date() request_path = "/targets" @@ -313,6 +317,7 @@ def target_list(vuforia_database: CloudDatabase) -> Endpoint: @pytest.fixture def target_summary( + *, vuforia_database: CloudDatabase, target_id: str, vws_client: VWS, @@ -361,6 +366,7 @@ def target_summary( @pytest.fixture def update_target( + *, vuforia_database: CloudDatabase, target_id: str, vws_client: VWS, @@ -409,6 +415,7 @@ def update_target( @pytest.fixture def query( + *, vuforia_database: CloudDatabase, high_quality_image: io.BytesIO, ) -> Endpoint: @@ -459,6 +466,7 @@ def query( @pytest.fixture def vumark_generate_instance( + *, vumark_vuforia_database: VuMarkCloudDatabase, ) -> Endpoint: """Return details of the endpoint for generating a VuMark instance.""" diff --git a/tests/mock_vws/fixtures/vuforia_backends.py b/tests/mock_vws/fixtures/vuforia_backends.py index d975546ac..94273450d 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -287,6 +287,7 @@ def pytest_collection_modifyitems( ids=[backend.value for backend in list(VuforiaBackend)], ) def fixture_verify_mock_vuforia( + *, request: pytest.FixtureRequest, vuforia_database: CloudDatabase, inactive_database: CloudDatabase, @@ -333,6 +334,7 @@ def fixture_verify_mock_vuforia( ], ) def mock_only_vuforia( + *, request: pytest.FixtureRequest, vuforia_database: CloudDatabase, inactive_database: CloudDatabase, diff --git a/tests/mock_vws/test_flask_app_usage.py b/tests/mock_vws/test_flask_app_usage.py index 6b7f7eee2..4db00b7e5 100644 --- a/tests/mock_vws/test_flask_app_usage.py +++ b/tests/mock_vws/test_flask_app_usage.py @@ -33,7 +33,7 @@ @pytest.fixture(autouse=True) -def _(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: +def _(*, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: """Enable a mock service backed by the Flask applications.""" with responses.RequestsMock( assert_all_requests_are_fired=False,