fix: accept BytesIO in R2Storage.save() type hint

beartype was rejecting BytesIO objects passed to R2Storage.save()
because the BinaryIO type hint doesn't satisfy beartype's strict
protocol checking for BytesIO instances.

Changed type hint from `BinaryIO` to `BinaryIO | BytesIO` to
explicitly accept both types. This fixes playlist cover uploads
which use BytesIO to wrap image data.

Adds regression test that verifies BytesIO is accepted.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+56 -2
backend
src
backend
storage
tests
+3 -2
backend/src/backend/storage/r2.py
··· 2 2 3 3 import time 4 4 from collections.abc import Callable 5 + from io import BytesIO 5 6 from pathlib import Path 6 7 from typing import BinaryIO 7 8 ··· 120 121 121 122 async def save( 122 123 self, 123 - file: BinaryIO, 124 + file: BinaryIO | BytesIO, 124 125 filename: str, 125 126 progress_callback: Callable[[float], None] | None = None, 126 127 ) -> str: ··· 444 445 445 446 async def save_gated( 446 447 self, 447 - file: BinaryIO, 448 + file: BinaryIO | BytesIO, 448 449 filename: str, 449 450 progress_callback: Callable[[float], None] | None = None, 450 451 ) -> str:
+53
backend/tests/test_storage_types.py
··· 1 + """test storage type hints accept BytesIO. 2 + 3 + regression test for: https://github.com/zzstoatzz/plyr.fm/pull/736 4 + beartype was rejecting BytesIO for BinaryIO type hint in R2Storage.save() 5 + """ 6 + 7 + from io import BytesIO 8 + from unittest.mock import AsyncMock, patch 9 + 10 + from backend.storage.r2 import R2Storage 11 + 12 + 13 + async def test_r2_save_accepts_bytesio(): 14 + """R2Storage.save() should accept BytesIO objects. 15 + 16 + BytesIO is the standard way to create in-memory binary streams, 17 + and is used throughout the codebase for image uploads. 18 + 19 + This test verifies that the type hint on save() is compatible 20 + with BytesIO, which beartype validates at runtime. 21 + """ 22 + # create a minimal image-like BytesIO 23 + image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 # fake PNG header 24 + file_obj = BytesIO(image_data) 25 + 26 + # mock the R2 client internals 27 + with ( 28 + patch.object(R2Storage, "__init__", lambda self: None), 29 + patch("backend.storage.r2.hash_file_chunked", return_value="abc123def456"), 30 + ): 31 + storage = R2Storage() 32 + storage.async_session = AsyncMock() 33 + storage.image_bucket_name = "test-images" 34 + storage.audio_bucket_name = "test-audio" 35 + 36 + # mock the async context manager for S3 client 37 + mock_client = AsyncMock() 38 + mock_client.upload_fileobj = AsyncMock() 39 + 40 + mock_cm = AsyncMock() 41 + mock_cm.__aenter__ = AsyncMock(return_value=mock_client) 42 + mock_cm.__aexit__ = AsyncMock(return_value=None) 43 + storage.async_session.client = lambda *args, **kwargs: mock_cm 44 + storage.endpoint_url = "https://test.r2.dev" 45 + storage.aws_access_key_id = "test" 46 + storage.aws_secret_access_key = "test" 47 + 48 + # this should NOT raise a beartype error 49 + # before the fix: BeartypeCallHintParamViolation 50 + file_id = await storage.save(file_obj, "test.png") 51 + 52 + assert file_id == "abc123def456"[:16] 53 + mock_client.upload_fileobj.assert_called_once()