diff --git a/projects/fal/src/fal/toolkit/file/file.py b/projects/fal/src/fal/toolkit/file/file.py index bf48b6997..8c2418652 100644 --- a/projects/fal/src/fal/toolkit/file/file.py +++ b/projects/fal/src/fal/toolkit/file/file.py @@ -25,7 +25,6 @@ from fal.compat import run_in_thread from fal.ref import get_current_app from fal.toolkit.file.providers.fal import ( - FalCDNFileRepository, FalFileRepository, FalFileRepositoryV2, FalFileRepositoryV3, @@ -45,7 +44,6 @@ "in_memory": lambda: InMemoryRepository(), "gcp_storage": lambda: GoogleStorageRepository(), "r2": lambda: R2Repository(), - "cdn": lambda: FalCDNFileRepository(), } @@ -61,7 +59,7 @@ def get_builtin_repository(id: RepositoryId | FileRepository) -> FileRepository: get_builtin_repository.__module__ = "__main__" DEFAULT_REPOSITORY: FileRepository | RepositoryId = "fal_v3" -FALLBACK_REPOSITORY: list[FileRepository | RepositoryId] = ["cdn", "fal"] +FALLBACK_REPOSITORY: list[FileRepository | RepositoryId] = ["fal"] OBJECT_LIFECYCLE_PREFERENCE_KEY = "x-fal-object-lifecycle-preference" diff --git a/projects/fal/src/fal/toolkit/file/providers/fal.py b/projects/fal/src/fal/toolkit/file/providers/fal.py index a2b34d01e..e556d8b10 100644 --- a/projects/fal/src/fal/toolkit/file/providers/fal.py +++ b/projects/fal/src/fal/toolkit/file/providers/fal.py @@ -25,7 +25,6 @@ from fal.toolkit.file.types import FileData, FileRepository from fal.toolkit.utils.retry import retry -_FAL_CDN = "https://fal.media" _FAL_CDN_V3 = "https://v3.fal.media" @@ -1292,47 +1291,6 @@ def save( return f"data:{file.content_type};base64,{b64encode(file.data).decode('utf-8')}" -@dataclass -class FalCDNFileRepository(FileRepository): - def save( - self, - file: FileData, - multipart: bool | None = None, - multipart_threshold: int | None = None, - multipart_chunk_size: int | None = None, - multipart_max_concurrency: int | None = None, - object_lifecycle_preference: dict[str, str] | None = None, - ) -> str: - headers = { - **self.auth_headers, - "Accept": "application/json", - "Content-Type": file.content_type, - "X-Fal-File-Name": file.file_name, - } - - _object_lifecycle_headers(headers, object_lifecycle_preference) - - url = os.getenv("FAL_CDN_HOST", _FAL_CDN) + "/files/upload" - request = Request(url, headers=headers, method="POST", data=file.data) - try: - with _maybe_retry_request(request) as response: - result = json.load(response) - except HTTPError as e: - raise FileUploadException( - f"Error initiating upload. Status {e.status}: {e.reason}" - ) - - access_url = result["access_url"] - return access_url - - @property - def auth_headers(self) -> dict[str, str]: - return { - "Authorization": f"Bearer {_require_auth_credentials().token}", - "User-Agent": USER_AGENT, - } - - @dataclass class FalFileRepositoryV3(FileRepository): @property diff --git a/projects/fal/src/fal/toolkit/file/types.py b/projects/fal/src/fal/toolkit/file/types.py index d034b84d2..b4995c02e 100644 --- a/projects/fal/src/fal/toolkit/file/types.py +++ b/projects/fal/src/fal/toolkit/file/types.py @@ -29,9 +29,7 @@ def __init__( self.file_name = file_name -RepositoryId = Literal[ - "fal", "fal_v2", "fal_v3", "in_memory", "gcp_storage", "r2", "cdn" -] +RepositoryId = Literal["fal", "fal_v2", "fal_v3", "in_memory", "gcp_storage", "r2"] @dataclass diff --git a/projects/fal/tests/integration/test.py b/projects/fal/tests/integration/test.py index 5792d0ba4..e087172ee 100644 --- a/projects/fal/tests/integration/test.py +++ b/projects/fal/tests/integration/test.py @@ -18,9 +18,6 @@ download_file, download_model_weights, ) -from fal.toolkit import ( - Image as FalImage, -) from fal.toolkit.file.file import CompressedFile from fal.toolkit.utils.download_utils import _git_rev_parse, _hash_url @@ -572,17 +569,6 @@ def init_compressed_file_on_fal(input: TestInput) -> int: assert len(extracted_file_paths) == 3 -@pytest.mark.flaky(max_runs=3) -def test_fal_cdn(isolated_client): - @isolated_client(requirements=[f"pydantic=={pydantic_version}", "tomli"]) - def upload_to_fal_cdn() -> FalImage: - return FalImage.from_bytes(b"0", "jpeg", repository="cdn") - - uploaded_image = upload_to_fal_cdn() - - assert uploaded_image - - def test_download_file_with_slash_in_filename(): from fal.toolkit.utils.download_utils import _filename_from_response from fal.toolkit.utils.ssrf import SafeResponse diff --git a/projects/fal/tests/unit/toolkit/file/providers/test_cdn_header.py b/projects/fal/tests/unit/toolkit/file/providers/test_cdn_header.py index 0ac52292a..d36702eea 100644 --- a/projects/fal/tests/unit/toolkit/file/providers/test_cdn_header.py +++ b/projects/fal/tests/unit/toolkit/file/providers/test_cdn_header.py @@ -278,52 +278,6 @@ def test_fal_file_repository_v3_auth_headers_with_bearer_credentials(): assert headers["Authorization"] == "Bearer jwt-token" -def test_fal_cdn_file_repository_auth_headers_with_key_credentials(): - """FalCDNFileRepository emits `Bearer :` even for Key creds.""" - with patch.object( - providers, - "fetch_auth_credentials", - return_value=AuthCredentials("Key", "key_id:key_secret"), - ): - repo = providers.FalCDNFileRepository() - headers = repo.auth_headers - - # CDN endpoint expects Bearer scheme regardless of how creds were obtained. - assert headers["Authorization"] == "Bearer key_id:key_secret" - assert headers["User-Agent"] == providers.USER_AGENT - - -def test_fal_cdn_file_repository_auth_headers_with_bearer_credentials(): - """FalCDNFileRepository emits `Bearer ` for auth0 bearer creds.""" - with patch.object( - providers, - "fetch_auth_credentials", - return_value=AuthCredentials("Bearer", "jwt-token"), - ): - repo = providers.FalCDNFileRepository() - headers = repo.auth_headers - - assert headers["Authorization"] == "Bearer jwt-token" - assert headers["User-Agent"] == providers.USER_AGENT - - -def test_fal_cdn_file_repository_raises_file_upload_exception_when_missing(): - """FalCDNFileRepository surfaces missing creds as FileUploadException.""" - from fal.exceptions.auth import UnauthenticatedException - from fal.toolkit.exceptions import FileUploadException - - with patch.object( - providers, - "fetch_auth_credentials", - side_effect=UnauthenticatedException(), - ): - repo = providers.FalCDNFileRepository() - with pytest.raises(FileUploadException) as excinfo: - _ = repo.auth_headers - - assert isinstance(excinfo.value.__cause__, UnauthenticatedException) - - def test_internal_multipart_upload_v3_auth_headers_include_cdn_token(): """InternalMultipartUploadV3 auth_headers should include CDN token.""" cdn_token = "test-cdn-token" diff --git a/projects/fal_client/src/fal_client/client.py b/projects/fal_client/src/fal_client/client.py index 61aa10706..b27fb83bc 100644 --- a/projects/fal_client/src/fal_client/client.py +++ b/projects/fal_client/src/fal_client/client.py @@ -68,7 +68,7 @@ from PIL import Image AnyJSON = Dict[str, Any] -UploadRepositoryId = Literal["fal_v3", "cdn", "fal"] +UploadRepositoryId = Literal["fal_v3", "fal"] LifecyclePreferencePayload = Dict[str, Any] ObjectExpiration = Union[ Literal["never", "immediate", "1h", "1d", "7d", "30d", "1y"], @@ -81,9 +81,8 @@ REALTIME_URL_FORMAT = f"wss://{FAL_RUN_HOST}/" REST_URL = "https://rest.fal.ai" CDN_URL = "https://v3.fal.media" -FAL_CDN_FALLBACK_URL = os.environ.get("FAL_CDN_HOST", "https://fal.media") DEFAULT_UPLOAD_REPOSITORY: UploadRepositoryId = "fal_v3" -DEFAULT_UPLOAD_FALLBACK_REPOSITORY: list[UploadRepositoryId] = ["cdn", "fal"] +DEFAULT_UPLOAD_FALLBACK_REPOSITORY: list[UploadRepositoryId] = ["fal"] USER_AGENT = f"fal-client/{__version__} (python)" MIN_REQUEST_TIMEOUT_SECONDS = 1 @@ -1170,12 +1169,6 @@ async def _async_maybe_retry_request( raise RuntimeError("Failed to perform request") -def _cdn_auth_header(auth: AuthCredentials) -> str: - if auth.scheme.lower() == "key": - return f"Bearer {auth.token}" - return auth.header_value - - def _object_lifecycle_headers( headers: dict[str, str], object_lifecycle_preference: LifecyclePreferencePayload | None, @@ -1234,19 +1227,6 @@ def _normalize_upload_lifecycle( return normalized or None -def _cdn_upload_headers( - auth: AuthCredentials, - content_type: str, - file_name: str | None, - object_lifecycle_preference: LifecyclePreferencePayload | None, -) -> dict[str, str]: - headers = {"Content-Type": content_type, "Authorization": _cdn_auth_header(auth)} - if file_name is not None: - headers["X-Fal-File-Name"] = file_name - _object_lifecycle_headers(headers, object_lifecycle_preference) - return headers - - def _storage_upload_headers( auth: AuthCredentials, object_lifecycle_preference: LifecyclePreferencePayload | None, @@ -1264,7 +1244,7 @@ def _normalize_upload_repositories( repository: UploadRepositoryId | None, fallback_repository: UploadRepositoryId | list[UploadRepositoryId] | None, ) -> list[UploadRepositoryId]: - allowed = {"fal_v3", "cdn", "fal"} + allowed = {"fal_v3", "fal"} if repository is None: repository = DEFAULT_UPLOAD_REPOSITORY @@ -1366,27 +1346,6 @@ def _upload_v3( return response.json()["access_url"] -def _upload_cdn( - client: httpx.Client, - auth: AuthCredentials, - *, - data: bytes, - content_type: str, - file_name: str | None, - object_lifecycle_preference: LifecyclePreferencePayload | None = None, -) -> str: - response = _maybe_retry_request( - client, - "POST", - FAL_CDN_FALLBACK_URL + "/files/upload", - content=data, - headers=_cdn_upload_headers( - auth, content_type, file_name, object_lifecycle_preference - ), - ) - return response.json()["access_url"] - - async def _async_upload_v3( client: httpx.AsyncClient, *, @@ -1403,27 +1362,6 @@ async def _async_upload_v3( return response.json()["access_url"] -async def _async_upload_cdn( - client: httpx.AsyncClient, - auth: AuthCredentials, - *, - data: bytes, - content_type: str, - file_name: str | None, - object_lifecycle_preference: LifecyclePreferencePayload | None = None, -) -> str: - response = await _async_maybe_retry_request( - client, - "POST", - FAL_CDN_FALLBACK_URL + "/files/upload", - content=data, - headers=_cdn_upload_headers( - auth, content_type, file_name, object_lifecycle_preference - ), - ) - return response.json()["access_url"] - - def _try_upload_with_fallback( attempts: list[tuple[str, Callable[[], str]]], ) -> str: @@ -1996,25 +1934,6 @@ async def _v3_attempt() -> str: for repo in repository_chain: if repo == "fal_v3": attempts.append(("fal_v3", _v3_attempt)) - elif repo == "cdn": - if auth is None: - auth = await self._auth - if client is None: - client = await self._client - attempts.append( - ( - "cdn", - partial( - _async_upload_cdn, - client, - auth, - data=data, - content_type=content_type, - file_name=file_name, - object_lifecycle_preference=resolved_lifecycle, - ), - ) - ) elif repo == "fal": if auth is None: auth = await self._auth @@ -2513,21 +2432,6 @@ def upload( partial(_upload_v3, client, data=data, headers=headers), ) ) - elif repo == "cdn": - attempts.append( - ( - "cdn", - partial( - _upload_cdn, - self._client, - auth, - data=data, - content_type=content_type, - file_name=file_name, - object_lifecycle_preference=resolved_lifecycle, - ), - ) - ) elif repo == "fal": attempts.append( ( diff --git a/projects/fal_client/tests/unit/test_client.py b/projects/fal_client/tests/unit/test_client.py index cc5b6de61..58974cf6a 100644 --- a/projects/fal_client/tests/unit/test_client.py +++ b/projects/fal_client/tests/unit/test_client.py @@ -18,7 +18,6 @@ CDN_URL, Completed, DEFAULT_QUEUE_POLL_INTERVAL, - FAL_CDN_FALLBACK_URL, FalClientHTTPError, FalClientTimeoutError, InProgress, @@ -276,41 +275,6 @@ def test_sync_client_subscribe_with_headers(): assert first_call_kwargs["headers"]["X-Trace-Id"] == "trace-123" -def test_sync_upload_falls_back_to_cdn(): - with patch("fal_client.client._maybe_retry_request") as mock_request, patch( - "fal_client.client.SyncClient._get_cdn_client" - ) as mock_cdn_context: - fallback_response = Mock() - fallback_response.json.return_value = {"access_url": "https://fallback/file"} - mock_request.side_effect = [Exception("boom"), fallback_response] - mock_cdn_context.return_value = Mock() - settings = StorageSettings(expires_in=3600) - - client = SyncClient(key="test-key") - url = client.upload( - b"hello", - content_type="text/plain", - lifecycle=settings, - ) - - assert url == "https://fallback/file" - assert mock_request.call_args_list[0][0][2] == f"{CDN_URL}/files/upload" - assert ( - mock_request.call_args_list[1][0][2] == f"{FAL_CDN_FALLBACK_URL}/files/upload" - ) - expected = json.dumps({"expiration_duration_seconds": 3600}) - assert ( - mock_request.call_args_list[0][1]["headers"]["X-Fal-Object-Lifecycle"] - == expected - ) - assert ( - mock_request.call_args_list[1][1]["headers"][ - "X-Fal-Object-Lifecycle-Preference" - ] - == expected - ) - - def test_sync_upload_falls_back_to_storage(): with patch("fal_client.client._maybe_retry_request") as mock_request, patch( "fal_client.client.SyncClient._get_cdn_client" @@ -321,7 +285,6 @@ def test_sync_upload_falls_back_to_storage(): "file_url": "https://file.example.com/file", } mock_request.side_effect = [ - Exception("boom"), Exception("boom"), init_response, Mock(), @@ -339,16 +302,13 @@ def test_sync_upload_falls_back_to_storage(): assert url == "https://file.example.com/file" assert mock_request.call_args_list[0][0][2] == f"{CDN_URL}/files/upload" assert ( - mock_request.call_args_list[1][0][2] == f"{FAL_CDN_FALLBACK_URL}/files/upload" - ) - assert ( - mock_request.call_args_list[2][0][2] + mock_request.call_args_list[1][0][2] == f"{REST_URL}/storage/upload/initiate?storage_type=gcs" ) - assert mock_request.call_args_list[3][0][2] == "https://upload.example.com/put" + assert mock_request.call_args_list[2][0][2] == "https://upload.example.com/put" expected = json.dumps({"expiration_duration_seconds": 3600}) assert ( - mock_request.call_args_list[2][1]["headers"][ + mock_request.call_args_list[1][1]["headers"][ "X-Fal-Object-Lifecycle-Preference" ] == expected @@ -396,21 +356,24 @@ def test_sync_upload_file_passes_lifecycle_to_multipart(tmp_path, monkeypatch): def test_sync_upload_lifecycle_expires_in_is_normalized(): - with patch("fal_client.client._maybe_retry_request") as mock_request: + with patch("fal_client.client._maybe_retry_request") as mock_request, patch( + "fal_client.client.SyncClient._get_cdn_client" + ) as mock_cdn_context: response = Mock() - response.json.return_value = {"access_url": "https://cdn-only/file"} + response.json.return_value = {"access_url": "https://v3-only/file"} mock_request.return_value = response + mock_cdn_context.return_value = Mock() client = SyncClient(key="test-key") url = client.upload( b"hello", content_type="text/plain", - repository="cdn", + repository="fal_v3", fallback_repository=[], lifecycle=StorageSettings(expires_in="1h"), ) - assert url == "https://cdn-only/file" + assert url == "https://v3-only/file" lifecycle_header = mock_request.call_args[1]["headers"]["X-Fal-Object-Lifecycle"] assert json.loads(lifecycle_header) == { "expiration_duration_seconds": 3600, @@ -443,21 +406,24 @@ def test_sync_upload_lifecycle_is_sent_to_fal_v3(): def test_sync_upload_lifecycle_immediate_is_normalized(): - with patch("fal_client.client._maybe_retry_request") as mock_request: + with patch("fal_client.client._maybe_retry_request") as mock_request, patch( + "fal_client.client.SyncClient._get_cdn_client" + ) as mock_cdn_context: response = Mock() - response.json.return_value = {"access_url": "https://cdn-only/file"} + response.json.return_value = {"access_url": "https://v3-only/file"} mock_request.return_value = response + mock_cdn_context.return_value = Mock() client = SyncClient(key="test-key") url = client.upload( b"hello", content_type="text/plain", - repository="cdn", + repository="fal_v3", fallback_repository=[], lifecycle=StorageSettings(expires_in="immediate"), ) - assert url == "https://cdn-only/file" + assert url == "https://v3-only/file" lifecycle_header = mock_request.call_args[1]["headers"]["X-Fal-Object-Lifecycle"] assert json.loads(lifecycle_header) == { "expiration_duration_seconds": 60, @@ -465,21 +431,24 @@ def test_sync_upload_lifecycle_immediate_is_normalized(): def test_sync_upload_lifecycle_never_is_normalized(): - with patch("fal_client.client._maybe_retry_request") as mock_request: + with patch("fal_client.client._maybe_retry_request") as mock_request, patch( + "fal_client.client.SyncClient._get_cdn_client" + ) as mock_cdn_context: response = Mock() - response.json.return_value = {"access_url": "https://cdn-only/file"} + response.json.return_value = {"access_url": "https://v3-only/file"} mock_request.return_value = response + mock_cdn_context.return_value = Mock() client = SyncClient(key="test-key") url = client.upload( b"hello", content_type="text/plain", - repository="cdn", + repository="fal_v3", fallback_repository=[], lifecycle=StorageSettings(expires_in="never"), ) - assert url == "https://cdn-only/file" + assert url == "https://v3-only/file" assert "X-Fal-Object-Lifecycle" not in mock_request.call_args[1]["headers"] @@ -492,7 +461,7 @@ def test_sync_upload_lifecycle_integer_must_be_positive(): client.upload( b"hello", content_type="text/plain", - repository="cdn", + repository="fal_v3", fallback_repository=[], lifecycle=StorageSettings(expires_in=0), ) @@ -507,7 +476,7 @@ def test_sync_upload_lifecycle_negative_integer_is_rejected(): client.upload( b"hello", content_type="text/plain", - repository="cdn", + repository="fal_v3", fallback_repository=[], lifecycle=StorageSettings(expires_in=-1), ) @@ -522,7 +491,7 @@ def test_sync_upload_lifecycle_boolean_is_rejected(): client.upload( b"hello", content_type="text/plain", - repository="cdn", + repository="fal_v3", fallback_repository=[], lifecycle=StorageSettings(expires_in=True), ) @@ -537,7 +506,7 @@ def test_sync_upload_lifecycle_invalid_string_is_rejected(): client.upload( b"hello", content_type="text/plain", - repository="cdn", + repository="fal_v3", fallback_repository=[], lifecycle=StorageSettings(expires_in="2h"), # type: ignore[arg-type] ) @@ -552,16 +521,19 @@ def test_storage_settings_validates_expires_in_on_init(): def test_sync_upload_lifecycle_includes_acl(): - with patch("fal_client.client._maybe_retry_request") as mock_request: + with patch("fal_client.client._maybe_retry_request") as mock_request, patch( + "fal_client.client.SyncClient._get_cdn_client" + ) as mock_cdn_context: response = Mock() - response.json.return_value = {"access_url": "https://cdn-only/file"} + response.json.return_value = {"access_url": "https://v3-only/file"} mock_request.return_value = response + mock_cdn_context.return_value = Mock() client = SyncClient(key="test-key") url = client.upload( b"hello", content_type="text/plain", - repository="cdn", + repository="fal_v3", fallback_repository=[], lifecycle=StorageSettings( expires_in="immediate", @@ -572,7 +544,7 @@ def test_sync_upload_lifecycle_includes_acl(): ), ) - assert url == "https://cdn-only/file" + assert url == "https://v3-only/file" lifecycle_header = mock_request.call_args[1]["headers"]["X-Fal-Object-Lifecycle"] assert json.loads(lifecycle_header) == { "expiration_duration_seconds": 60, @@ -584,16 +556,19 @@ def test_sync_upload_lifecycle_includes_acl(): def test_sync_upload_lifecycle_skips_empty_acl_rules(): - with patch("fal_client.client._maybe_retry_request") as mock_request: + with patch("fal_client.client._maybe_retry_request") as mock_request, patch( + "fal_client.client.SyncClient._get_cdn_client" + ) as mock_cdn_context: response = Mock() - response.json.return_value = {"access_url": "https://cdn-only/file"} + response.json.return_value = {"access_url": "https://v3-only/file"} mock_request.return_value = response + mock_cdn_context.return_value = Mock() client = SyncClient(key="test-key") url = client.upload( b"hello", content_type="text/plain", - repository="cdn", + repository="fal_v3", fallback_repository=[], lifecycle=StorageSettings( initial_acl=StorageACL( @@ -603,7 +578,7 @@ def test_sync_upload_lifecycle_skips_empty_acl_rules(): ), ) - assert url == "https://cdn-only/file" + assert url == "https://v3-only/file" lifecycle_header = mock_request.call_args[1]["headers"]["X-Fal-Object-Lifecycle"] assert json.loads(lifecycle_header) == { "initial_acl": { @@ -613,23 +588,26 @@ def test_sync_upload_lifecycle_skips_empty_acl_rules(): def test_sync_upload_lifecycle_omits_empty_acl_object(): - with patch("fal_client.client._maybe_retry_request") as mock_request: + with patch("fal_client.client._maybe_retry_request") as mock_request, patch( + "fal_client.client.SyncClient._get_cdn_client" + ) as mock_cdn_context: response = Mock() - response.json.return_value = {"access_url": "https://cdn-only/file"} + response.json.return_value = {"access_url": "https://v3-only/file"} mock_request.return_value = response + mock_cdn_context.return_value = Mock() client = SyncClient(key="test-key") url = client.upload( b"hello", content_type="text/plain", - repository="cdn", + repository="fal_v3", fallback_repository=[], lifecycle=StorageSettings( initial_acl=StorageACL(), ), ) - assert url == "https://cdn-only/file" + assert url == "https://v3-only/file" request_headers = mock_request.call_args[1]["headers"] assert "X-Fal-Object-Lifecycle" not in request_headers assert "X-Fal-Object-Lifecycle-Preference" not in request_headers @@ -654,24 +632,29 @@ def save(buffer, format): assert mock_upload.call_args.kwargs["lifecycle"] == settings -def test_sync_upload_respects_repository_order(): +def test_sync_upload_uses_requested_storage_repository(): with patch("fal_client.client._maybe_retry_request") as mock_request: - cdn_response = Mock() - cdn_response.json.return_value = {"access_url": "https://cdn-only/file"} - mock_request.return_value = cdn_response + init_response = Mock() + init_response.json.return_value = { + "upload_url": "https://upload.example.com/put", + "file_url": "https://file.example.com/file", + } + mock_request.side_effect = [init_response, Mock()] client = SyncClient(key="test-key") url = client.upload( b"hello", content_type="text/plain", - repository="cdn", + repository="fal", fallback_repository=[], ) - assert url == "https://cdn-only/file" + assert url == "https://file.example.com/file" assert ( - mock_request.call_args_list[0][0][2] == f"{FAL_CDN_FALLBACK_URL}/files/upload" + mock_request.call_args_list[0][0][2] + == f"{REST_URL}/storage/upload/initiate?storage_type=gcs" ) + assert mock_request.call_args_list[1][0][2] == "https://upload.example.com/put" @pytest.mark.asyncio @@ -753,50 +736,6 @@ async def get_token(self): return Mock(token_type="Bearer", token="cdn-token") -@pytest.mark.asyncio -async def test_async_upload_falls_back_to_cdn(): - @asynccontextmanager - async def fake_cdn_client(): - yield Mock() - - with patch( - "fal_client.client._async_maybe_retry_request", new_callable=AsyncMock - ) as mock_request, patch( - "fal_client.client.AsyncClient._cdn_client" - ) as mock_cdn_context: - fallback_response = httpx.Response( - status_code=200, json={"access_url": "https://fallback/file"} - ) - mock_request.side_effect = [Exception("boom"), fallback_response] - mock_cdn_context.side_effect = lambda: fake_cdn_client() - settings = StorageSettings(expires_in=3600) - - client = AsyncClient(key="test-key") - client.__dict__["_token_manager"] = FakeAsyncTokenManager() - url = await client.upload( - b"hello", - content_type="text/plain", - lifecycle=settings, - ) - - assert url == "https://fallback/file" - assert mock_request.call_args_list[0][0][2] == f"{CDN_URL}/files/upload" - assert ( - mock_request.call_args_list[1][0][2] == f"{FAL_CDN_FALLBACK_URL}/files/upload" - ) - expected = json.dumps({"expiration_duration_seconds": 3600}) - assert ( - mock_request.call_args_list[0][1]["headers"]["X-Fal-Object-Lifecycle"] - == expected - ) - assert ( - mock_request.call_args_list[1][1]["headers"][ - "X-Fal-Object-Lifecycle-Preference" - ] - == expected - ) - - @pytest.mark.asyncio async def test_async_upload_falls_back_to_storage(): @asynccontextmanager @@ -816,7 +755,6 @@ async def fake_cdn_client(): }, ) mock_request.side_effect = [ - Exception("boom"), Exception("boom"), init_response, Mock(), @@ -835,16 +773,13 @@ async def fake_cdn_client(): assert url == "https://file.example.com/file" assert mock_request.call_args_list[0][0][2] == f"{CDN_URL}/files/upload" assert ( - mock_request.call_args_list[1][0][2] == f"{FAL_CDN_FALLBACK_URL}/files/upload" - ) - assert ( - mock_request.call_args_list[2][0][2] + mock_request.call_args_list[1][0][2] == f"{REST_URL}/storage/upload/initiate?storage_type=gcs" ) - assert mock_request.call_args_list[3][0][2] == "https://upload.example.com/put" + assert mock_request.call_args_list[2][0][2] == "https://upload.example.com/put" expected = json.dumps({"expiration_duration_seconds": 3600}) assert ( - mock_request.call_args_list[2][1]["headers"][ + mock_request.call_args_list[1][1]["headers"][ "X-Fal-Object-Lifecycle-Preference" ] == expected @@ -1105,19 +1040,27 @@ def save(buffer, format): @pytest.mark.asyncio async def test_async_upload_lifecycle_includes_acl(): + @asynccontextmanager + async def fake_cdn_client(): + yield Mock() + with patch( "fal_client.client._async_maybe_retry_request", new_callable=AsyncMock - ) as mock_request: + ) as mock_request, patch( + "fal_client.client.AsyncClient._cdn_client" + ) as mock_cdn_context: response = httpx.Response( - status_code=200, json={"access_url": "https://cdn-only/file"} + status_code=200, json={"access_url": "https://v3-only/file"} ) mock_request.return_value = response + mock_cdn_context.return_value = fake_cdn_client() client = AsyncClient(key="test-key") + client.__dict__["_token_manager"] = FakeAsyncTokenManager() url = await client.upload( b"hello", content_type="text/plain", - repository="cdn", + repository="fal_v3", fallback_repository=[], lifecycle=StorageSettings( expires_in="immediate", @@ -1128,7 +1071,7 @@ async def test_async_upload_lifecycle_includes_acl(): ), ) - assert url == "https://cdn-only/file" + assert url == "https://v3-only/file" lifecycle_header = mock_request.call_args[1]["headers"]["X-Fal-Object-Lifecycle"] assert json.loads(lifecycle_header) == { "expiration_duration_seconds": 60, @@ -1144,23 +1087,29 @@ async def test_async_upload_respects_repository_order(): with patch( "fal_client.client._async_maybe_retry_request", new_callable=AsyncMock ) as mock_request: - cdn_response = httpx.Response( - status_code=200, json={"access_url": "https://cdn-only/file"} + init_response = httpx.Response( + status_code=200, + json={ + "upload_url": "https://upload.example.com/put", + "file_url": "https://file.example.com/file", + }, ) - mock_request.return_value = cdn_response + mock_request.side_effect = [init_response, Mock()] client = AsyncClient(key="test-key") url = await client.upload( b"hello", content_type="text/plain", - repository="cdn", + repository="fal", fallback_repository=[], ) - assert url == "https://cdn-only/file" + assert url == "https://file.example.com/file" assert ( - mock_request.call_args_list[0][0][2] == f"{FAL_CDN_FALLBACK_URL}/files/upload" + mock_request.call_args_list[0][0][2] + == f"{REST_URL}/storage/upload/initiate?storage_type=gcs" ) + assert mock_request.call_args_list[1][0][2] == "https://upload.example.com/put" @pytest.mark.asyncio