magic_hour 0.34.0__py3-none-any.whl → 0.36.0__py3-none-any.whl
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.
Potentially problematic release.
This version of magic_hour might be problematic. Click here for more details.
- magic_hour/README.md +35 -0
- magic_hour/core/base_client.py +6 -5
- magic_hour/core/query.py +12 -6
- magic_hour/core/request.py +3 -3
- magic_hour/core/response.py +18 -14
- magic_hour/core/utils.py +3 -3
- magic_hour/environment.py +1 -1
- magic_hour/helpers/__init__.py +3 -0
- magic_hour/helpers/download.py +75 -0
- magic_hour/resources/v1/README.md +33 -0
- magic_hour/resources/v1/ai_clothes_changer/README.md +73 -0
- magic_hour/resources/v1/ai_clothes_changer/client.py +146 -0
- magic_hour/resources/v1/ai_face_editor/README.md +110 -0
- magic_hour/resources/v1/ai_face_editor/client.py +168 -0
- magic_hour/resources/v1/ai_gif_generator/README.md +59 -0
- magic_hour/resources/v1/ai_gif_generator/client.py +119 -0
- magic_hour/resources/v1/ai_headshot_generator/README.md +60 -0
- magic_hour/resources/v1/ai_headshot_generator/client.py +140 -0
- magic_hour/resources/v1/ai_image_editor/README.md +64 -0
- magic_hour/resources/v1/ai_image_editor/client.py +136 -0
- magic_hour/resources/v1/ai_image_generator/README.md +66 -0
- magic_hour/resources/v1/ai_image_generator/client.py +139 -0
- magic_hour/resources/v1/ai_image_upscaler/README.md +67 -0
- magic_hour/resources/v1/ai_image_upscaler/client.py +150 -0
- magic_hour/resources/v1/ai_meme_generator/README.md +71 -0
- magic_hour/resources/v1/ai_meme_generator/client.py +127 -0
- magic_hour/resources/v1/ai_photo_editor/README.md +98 -7
- magic_hour/resources/v1/ai_photo_editor/client.py +174 -0
- magic_hour/resources/v1/ai_qr_code_generator/README.md +63 -0
- magic_hour/resources/v1/ai_qr_code_generator/client.py +123 -0
- magic_hour/resources/v1/ai_talking_photo/README.md +74 -0
- magic_hour/resources/v1/ai_talking_photo/client.py +170 -0
- magic_hour/resources/v1/animation/README.md +100 -0
- magic_hour/resources/v1/animation/client.py +218 -0
- magic_hour/resources/v1/auto_subtitle_generator/README.md +69 -0
- magic_hour/resources/v1/auto_subtitle_generator/client.py +178 -0
- magic_hour/resources/v1/face_detection/README.md +59 -0
- magic_hour/resources/v1/face_detection/__init__.py +10 -2
- magic_hour/resources/v1/face_detection/client.py +179 -0
- magic_hour/resources/v1/face_swap/README.md +105 -12
- magic_hour/resources/v1/face_swap/client.py +262 -28
- magic_hour/resources/v1/face_swap_photo/README.md +84 -0
- magic_hour/resources/v1/face_swap_photo/client.py +172 -0
- magic_hour/resources/v1/files/README.md +6 -0
- magic_hour/resources/v1/files/client.py +350 -0
- magic_hour/resources/v1/files/client_test.py +414 -0
- magic_hour/resources/v1/files/upload_urls/README.md +8 -0
- magic_hour/resources/v1/image_background_remover/README.md +68 -0
- magic_hour/resources/v1/image_background_remover/client.py +130 -0
- magic_hour/resources/v1/image_projects/README.md +8 -0
- magic_hour/resources/v1/image_projects/__init__.py +10 -2
- magic_hour/resources/v1/image_projects/client.py +138 -0
- magic_hour/resources/v1/image_projects/client_test.py +527 -0
- magic_hour/resources/v1/image_to_video/README.md +77 -9
- magic_hour/resources/v1/image_to_video/client.py +210 -8
- magic_hour/resources/v1/lip_sync/README.md +87 -13
- magic_hour/resources/v1/lip_sync/client.py +230 -28
- magic_hour/resources/v1/photo_colorizer/README.md +59 -0
- magic_hour/resources/v1/photo_colorizer/client.py +130 -0
- magic_hour/resources/v1/text_to_video/README.md +68 -0
- magic_hour/resources/v1/text_to_video/client.py +151 -0
- magic_hour/resources/v1/video_projects/README.md +8 -0
- magic_hour/resources/v1/video_projects/__init__.py +10 -2
- magic_hour/resources/v1/video_projects/client.py +137 -0
- magic_hour/resources/v1/video_projects/client_test.py +527 -0
- magic_hour/resources/v1/video_to_video/README.md +98 -14
- magic_hour/resources/v1/video_to_video/client.py +242 -28
- magic_hour/types/params/__init__.py +58 -0
- magic_hour/types/params/v1_ai_clothes_changer_generate_body_assets.py +33 -0
- magic_hour/types/params/v1_ai_face_editor_generate_body_assets.py +17 -0
- magic_hour/types/params/v1_ai_headshot_generator_generate_body_assets.py +17 -0
- magic_hour/types/params/v1_ai_image_editor_generate_body_assets.py +17 -0
- magic_hour/types/params/v1_ai_image_upscaler_generate_body_assets.py +17 -0
- magic_hour/types/params/v1_ai_photo_editor_generate_body_assets.py +17 -0
- magic_hour/types/params/v1_ai_talking_photo_generate_body_assets.py +26 -0
- magic_hour/types/params/v1_animation_generate_body_assets.py +39 -0
- magic_hour/types/params/v1_auto_subtitle_generator_generate_body_assets.py +17 -0
- magic_hour/types/params/v1_face_detection_generate_body_assets.py +17 -0
- magic_hour/types/params/v1_face_swap_create_body.py +24 -14
- magic_hour/types/params/v1_face_swap_create_body_style.py +33 -0
- magic_hour/types/params/v1_face_swap_generate_body_assets.py +56 -0
- magic_hour/types/params/v1_face_swap_generate_body_assets_face_mappings_item.py +25 -0
- magic_hour/types/params/v1_face_swap_photo_generate_body_assets.py +47 -0
- magic_hour/types/params/v1_face_swap_photo_generate_body_assets_face_mappings_item.py +25 -0
- magic_hour/types/params/v1_image_background_remover_generate_body_assets.py +27 -0
- magic_hour/types/params/v1_image_to_video_create_body.py +14 -6
- magic_hour/types/params/v1_image_to_video_generate_body_assets.py +17 -0
- magic_hour/types/params/v1_lip_sync_create_body.py +12 -14
- magic_hour/types/params/v1_lip_sync_generate_body_assets.py +36 -0
- magic_hour/types/params/v1_photo_colorizer_generate_body_assets.py +17 -0
- magic_hour/types/params/v1_video_to_video_create_body.py +12 -14
- magic_hour/types/params/v1_video_to_video_generate_body_assets.py +27 -0
- magic_hour-0.36.0.dist-info/METADATA +303 -0
- {magic_hour-0.34.0.dist-info → magic_hour-0.36.0.dist-info}/RECORD +96 -68
- magic_hour-0.34.0.dist-info/METADATA +0 -166
- {magic_hour-0.34.0.dist-info → magic_hour-0.36.0.dist-info}/LICENSE +0 -0
- {magic_hour-0.34.0.dist-info → magic_hour-0.36.0.dist-info}/WHEEL +0 -0
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import pydantic
|
|
5
|
+
import time
|
|
1
6
|
import typing
|
|
2
7
|
|
|
3
8
|
from magic_hour.core import (
|
|
@@ -6,13 +11,87 @@ from magic_hour.core import (
|
|
|
6
11
|
SyncBaseClient,
|
|
7
12
|
default_request_options,
|
|
8
13
|
)
|
|
14
|
+
from magic_hour.helpers.download import download_files_async, download_files_sync
|
|
9
15
|
from magic_hour.types import models
|
|
10
16
|
|
|
11
17
|
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class V1ImageProjectsGetResponseWithDownloads(models.V1ImageProjectsGetResponse):
|
|
22
|
+
downloaded_paths: typing.Optional[typing.List[str]] = pydantic.Field(
|
|
23
|
+
default=None, alias="downloaded_paths"
|
|
24
|
+
)
|
|
25
|
+
"""
|
|
26
|
+
The paths to the downloaded files.
|
|
27
|
+
|
|
28
|
+
This field is only populated if `download_outputs` is True and the image project is complete.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
12
32
|
class ImageProjectsClient:
|
|
13
33
|
def __init__(self, *, base_client: SyncBaseClient):
|
|
14
34
|
self._base_client = base_client
|
|
15
35
|
|
|
36
|
+
def check_result(
|
|
37
|
+
self,
|
|
38
|
+
id: str,
|
|
39
|
+
wait_for_completion: bool,
|
|
40
|
+
download_outputs: bool,
|
|
41
|
+
download_directory: typing.Optional[str] = None,
|
|
42
|
+
) -> V1ImageProjectsGetResponseWithDownloads:
|
|
43
|
+
"""
|
|
44
|
+
Check the result of an image project with optional waiting and downloading.
|
|
45
|
+
|
|
46
|
+
This method retrieves the status of an image project and optionally waits for completion
|
|
47
|
+
and downloads the output files.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
id: Unique ID of the image project
|
|
51
|
+
wait_for_completion: Whether to wait for the image project to complete
|
|
52
|
+
download_outputs: Whether to download the outputs
|
|
53
|
+
download_directory: The directory to download the outputs to. If not provided,
|
|
54
|
+
the outputs will be downloaded to the current working directory
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
V1ImageProjectsGetResponseWithDownloads: The image project response with optional
|
|
58
|
+
downloaded file paths included
|
|
59
|
+
"""
|
|
60
|
+
api_response = self.get(id=id)
|
|
61
|
+
if not wait_for_completion:
|
|
62
|
+
response = V1ImageProjectsGetResponseWithDownloads(
|
|
63
|
+
**api_response.model_dump()
|
|
64
|
+
)
|
|
65
|
+
return response
|
|
66
|
+
|
|
67
|
+
poll_interval = float(os.getenv("MAGIC_HOUR_POLL_INTERVAL", "0.5"))
|
|
68
|
+
|
|
69
|
+
status = api_response.status
|
|
70
|
+
|
|
71
|
+
while status not in ["complete", "error", "canceled"]:
|
|
72
|
+
api_response = self.get(id=id)
|
|
73
|
+
status = api_response.status
|
|
74
|
+
time.sleep(poll_interval)
|
|
75
|
+
|
|
76
|
+
if api_response.status != "complete":
|
|
77
|
+
log = logger.error if api_response.status == "error" else logger.info
|
|
78
|
+
log(
|
|
79
|
+
f"Image project {id} has status {api_response.status}: {api_response.error}"
|
|
80
|
+
)
|
|
81
|
+
return V1ImageProjectsGetResponseWithDownloads(**api_response.model_dump())
|
|
82
|
+
|
|
83
|
+
if not download_outputs:
|
|
84
|
+
return V1ImageProjectsGetResponseWithDownloads(**api_response.model_dump())
|
|
85
|
+
|
|
86
|
+
downloaded_paths = download_files_sync(
|
|
87
|
+
downloads=api_response.downloads,
|
|
88
|
+
download_directory=download_directory,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return V1ImageProjectsGetResponseWithDownloads(
|
|
92
|
+
**api_response.model_dump(), downloaded_paths=downloaded_paths
|
|
93
|
+
)
|
|
94
|
+
|
|
16
95
|
def delete(
|
|
17
96
|
self, *, id: str, request_options: typing.Optional[RequestOptions] = None
|
|
18
97
|
) -> None:
|
|
@@ -95,6 +174,65 @@ class AsyncImageProjectsClient:
|
|
|
95
174
|
def __init__(self, *, base_client: AsyncBaseClient):
|
|
96
175
|
self._base_client = base_client
|
|
97
176
|
|
|
177
|
+
async def check_result(
|
|
178
|
+
self,
|
|
179
|
+
id: str,
|
|
180
|
+
wait_for_completion: bool,
|
|
181
|
+
download_outputs: bool,
|
|
182
|
+
download_directory: typing.Optional[str] = None,
|
|
183
|
+
) -> V1ImageProjectsGetResponseWithDownloads:
|
|
184
|
+
"""
|
|
185
|
+
Check the result of an image project with optional waiting and downloading.
|
|
186
|
+
|
|
187
|
+
This method retrieves the status of an image project and optionally waits for completion
|
|
188
|
+
and downloads the output files.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
id: Unique ID of the image project
|
|
192
|
+
wait_for_completion: Whether to wait for the image project to complete
|
|
193
|
+
download_outputs: Whether to download the outputs
|
|
194
|
+
download_directory: The directory to download the outputs to. If not provided,
|
|
195
|
+
the outputs will be downloaded to the current working directory
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
V1ImageProjectsGetResponseWithDownloads: The image project response with optional
|
|
199
|
+
downloaded file paths included
|
|
200
|
+
"""
|
|
201
|
+
api_response = await self.get(id=id)
|
|
202
|
+
if not wait_for_completion:
|
|
203
|
+
response = V1ImageProjectsGetResponseWithDownloads(
|
|
204
|
+
**api_response.model_dump()
|
|
205
|
+
)
|
|
206
|
+
return response
|
|
207
|
+
|
|
208
|
+
poll_interval = float(os.getenv("MAGIC_HOUR_POLL_INTERVAL", "0.5"))
|
|
209
|
+
|
|
210
|
+
status = api_response.status
|
|
211
|
+
|
|
212
|
+
while status not in ["complete", "error", "canceled"]:
|
|
213
|
+
api_response = await self.get(id=id)
|
|
214
|
+
status = api_response.status
|
|
215
|
+
await asyncio.sleep(poll_interval)
|
|
216
|
+
|
|
217
|
+
if api_response.status != "complete":
|
|
218
|
+
log = logger.error if api_response.status == "error" else logger.info
|
|
219
|
+
log(
|
|
220
|
+
f"Image project {id} has status {api_response.status}: {api_response.error}"
|
|
221
|
+
)
|
|
222
|
+
return V1ImageProjectsGetResponseWithDownloads(**api_response.model_dump())
|
|
223
|
+
|
|
224
|
+
if not download_outputs:
|
|
225
|
+
return V1ImageProjectsGetResponseWithDownloads(**api_response.model_dump())
|
|
226
|
+
|
|
227
|
+
downloaded_paths = await download_files_async(
|
|
228
|
+
downloads=api_response.downloads,
|
|
229
|
+
download_directory=download_directory,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return V1ImageProjectsGetResponseWithDownloads(
|
|
233
|
+
**api_response.model_dump(), downloaded_paths=downloaded_paths
|
|
234
|
+
)
|
|
235
|
+
|
|
98
236
|
async def delete(
|
|
99
237
|
self, *, id: str, request_options: typing.Optional[RequestOptions] = None
|
|
100
238
|
) -> None:
|
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import pytest
|
|
3
|
+
import httpx
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Generator, Literal, Union, List
|
|
6
|
+
from unittest.mock import Mock, AsyncMock
|
|
7
|
+
|
|
8
|
+
from magic_hour.types import models
|
|
9
|
+
from magic_hour.resources.v1.image_projects.client import (
|
|
10
|
+
ImageProjectsClient,
|
|
11
|
+
AsyncImageProjectsClient,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DummyResponse(models.V1ImageProjectsGetResponse):
|
|
16
|
+
"""Helper response with defaults"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
*,
|
|
21
|
+
status: Literal[
|
|
22
|
+
"complete", "queued", "rendering", "error", "canceled"
|
|
23
|
+
] = "complete",
|
|
24
|
+
download_url: Union[str, None] = None,
|
|
25
|
+
error: Union[str, None] = None,
|
|
26
|
+
):
|
|
27
|
+
# Create error object if error string is provided
|
|
28
|
+
error_obj = None
|
|
29
|
+
if error:
|
|
30
|
+
error_obj = models.V1ImageProjectsGetResponseError(
|
|
31
|
+
code="TEST_ERROR", message=error
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
super().__init__(
|
|
35
|
+
id="test-id",
|
|
36
|
+
created_at=datetime.datetime.now().isoformat(),
|
|
37
|
+
credits_charged=0,
|
|
38
|
+
enabled=True,
|
|
39
|
+
image_count=0,
|
|
40
|
+
name="test-name",
|
|
41
|
+
total_frame_cost=0,
|
|
42
|
+
type="test-type",
|
|
43
|
+
status=status,
|
|
44
|
+
downloads=[
|
|
45
|
+
models.V1ImageProjectsGetResponseDownloadsItem(
|
|
46
|
+
url=download_url, expires_at="2024-01-01T00:00:00Z"
|
|
47
|
+
)
|
|
48
|
+
]
|
|
49
|
+
if download_url
|
|
50
|
+
else [],
|
|
51
|
+
error=error_obj,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.fixture
|
|
56
|
+
def mock_base_client() -> Generator[Mock, None, None]:
|
|
57
|
+
yield Mock()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.fixture
|
|
61
|
+
def mock_async_base_client() -> Generator[AsyncMock, None, None]:
|
|
62
|
+
yield AsyncMock()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_delete_calls_base_client(mock_base_client: Mock) -> None:
|
|
66
|
+
client = ImageProjectsClient(base_client=mock_base_client)
|
|
67
|
+
client.delete(id="123")
|
|
68
|
+
|
|
69
|
+
mock_base_client.request.assert_called_once()
|
|
70
|
+
call = mock_base_client.request.call_args[1]
|
|
71
|
+
assert call["method"] == "DELETE"
|
|
72
|
+
assert "/v1/image-projects/123" in call["path"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_get_calls_base_client(mock_base_client: Mock) -> None:
|
|
76
|
+
client = ImageProjectsClient(base_client=mock_base_client)
|
|
77
|
+
mock_base_client.request.return_value = DummyResponse()
|
|
78
|
+
|
|
79
|
+
resp = client.get(id="abc")
|
|
80
|
+
|
|
81
|
+
mock_base_client.request.assert_called_once()
|
|
82
|
+
assert isinstance(resp, models.V1ImageProjectsGetResponse)
|
|
83
|
+
assert resp.id == "test-id"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_check_result_no_wait_no_download(mock_base_client: Mock) -> None:
|
|
87
|
+
client = ImageProjectsClient(base_client=mock_base_client)
|
|
88
|
+
mock_base_client.request.return_value = DummyResponse(status="queued")
|
|
89
|
+
|
|
90
|
+
resp = client.check_result(
|
|
91
|
+
id="xyz",
|
|
92
|
+
wait_for_completion=False,
|
|
93
|
+
download_outputs=False,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
assert resp.downloaded_paths is None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_check_result_wait_until_complete(
|
|
100
|
+
monkeypatch: Any, mock_base_client: Mock
|
|
101
|
+
) -> None:
|
|
102
|
+
client = ImageProjectsClient(base_client=mock_base_client)
|
|
103
|
+
|
|
104
|
+
# First calls return queued, then complete
|
|
105
|
+
mock_base_client.request.side_effect = [
|
|
106
|
+
DummyResponse(status="queued"),
|
|
107
|
+
DummyResponse(status="queued"),
|
|
108
|
+
DummyResponse(status="complete"),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
monkeypatch.setattr("time.sleep", lambda _: None) # type: ignore
|
|
112
|
+
|
|
113
|
+
resp = client.check_result(
|
|
114
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
assert resp.status == "complete"
|
|
118
|
+
assert resp.downloaded_paths is None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_check_result_download_outputs(
|
|
122
|
+
tmp_path: Path, mock_base_client: Mock, monkeypatch: Any
|
|
123
|
+
) -> None:
|
|
124
|
+
client = ImageProjectsClient(base_client=mock_base_client)
|
|
125
|
+
|
|
126
|
+
file_url = "https://example.com/file.png"
|
|
127
|
+
mock_base_client.request.return_value = DummyResponse(
|
|
128
|
+
status="complete",
|
|
129
|
+
download_url=file_url,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Create a mock response for httpx
|
|
133
|
+
mock_request = httpx.Request("GET", "https://example.com/file.png")
|
|
134
|
+
mock_response = httpx.Response(200, content=b"fake png", request=mock_request)
|
|
135
|
+
|
|
136
|
+
# Mock the httpx.Client class
|
|
137
|
+
class MockClient:
|
|
138
|
+
def __init__(self):
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
def __enter__(self) -> "MockClient":
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
def get(self, url: str) -> httpx.Response:
|
|
148
|
+
return mock_response
|
|
149
|
+
|
|
150
|
+
monkeypatch.setattr(httpx, "Client", MockClient)
|
|
151
|
+
|
|
152
|
+
resp = client.check_result(
|
|
153
|
+
id="xyz",
|
|
154
|
+
wait_for_completion=True,
|
|
155
|
+
download_outputs=True,
|
|
156
|
+
download_directory=str(tmp_path),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
assert resp.status == "complete"
|
|
160
|
+
assert resp.downloaded_paths
|
|
161
|
+
saved_file = Path(resp.downloaded_paths[0])
|
|
162
|
+
assert saved_file.exists()
|
|
163
|
+
assert saved_file.read_bytes() == b"fake png"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_check_result_error_status(mock_base_client: Mock) -> None:
|
|
167
|
+
client = ImageProjectsClient(base_client=mock_base_client)
|
|
168
|
+
mock_base_client.request.return_value = DummyResponse(status="error", error="Boom!")
|
|
169
|
+
|
|
170
|
+
resp = client.check_result(
|
|
171
|
+
id="err", wait_for_completion=True, download_outputs=False
|
|
172
|
+
)
|
|
173
|
+
assert resp.status == "error"
|
|
174
|
+
assert resp.error is not None
|
|
175
|
+
assert resp.error.message == "Boom!"
|
|
176
|
+
assert resp.downloaded_paths is None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_check_result_canceled_status(mock_base_client: Mock) -> None:
|
|
180
|
+
client = ImageProjectsClient(base_client=mock_base_client)
|
|
181
|
+
mock_base_client.request.return_value = DummyResponse(status="canceled")
|
|
182
|
+
|
|
183
|
+
resp = client.check_result(
|
|
184
|
+
id="cancel", wait_for_completion=True, download_outputs=False
|
|
185
|
+
)
|
|
186
|
+
assert resp.status == "canceled"
|
|
187
|
+
assert resp.downloaded_paths is None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_check_result_poll_interval_default(
|
|
191
|
+
mock_base_client: Mock, monkeypatch: Any
|
|
192
|
+
) -> None:
|
|
193
|
+
client = ImageProjectsClient(base_client=mock_base_client)
|
|
194
|
+
|
|
195
|
+
# First calls return queued, then complete
|
|
196
|
+
mock_base_client.request.side_effect = [
|
|
197
|
+
DummyResponse(status="queued"),
|
|
198
|
+
DummyResponse(status="complete"),
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
# Mock time.sleep to track calls
|
|
202
|
+
sleep_calls: List[float] = []
|
|
203
|
+
|
|
204
|
+
def mock_sleep(seconds: float) -> None:
|
|
205
|
+
sleep_calls.append(seconds)
|
|
206
|
+
|
|
207
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
208
|
+
|
|
209
|
+
resp = client.check_result(
|
|
210
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
assert resp.status == "complete"
|
|
214
|
+
# Should have slept once with default interval (0.5)
|
|
215
|
+
assert len(sleep_calls) == 1
|
|
216
|
+
assert sleep_calls[0] == 0.5
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_check_result_poll_interval_custom(
|
|
220
|
+
mock_base_client: Mock, monkeypatch: Any
|
|
221
|
+
) -> None:
|
|
222
|
+
client = ImageProjectsClient(base_client=mock_base_client)
|
|
223
|
+
|
|
224
|
+
# Set custom poll interval
|
|
225
|
+
monkeypatch.setenv("MAGIC_HOUR_POLL_INTERVAL", "1.0")
|
|
226
|
+
|
|
227
|
+
# First calls return queued, then complete
|
|
228
|
+
mock_base_client.request.side_effect = [
|
|
229
|
+
DummyResponse(status="queued"),
|
|
230
|
+
DummyResponse(status="complete"),
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
# Mock time.sleep to track calls
|
|
234
|
+
sleep_calls: List[float] = []
|
|
235
|
+
|
|
236
|
+
def mock_sleep(seconds: float) -> None:
|
|
237
|
+
sleep_calls.append(seconds)
|
|
238
|
+
|
|
239
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
240
|
+
|
|
241
|
+
resp = client.check_result(
|
|
242
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
assert resp.status == "complete"
|
|
246
|
+
# Should have slept once with custom interval (1.0)
|
|
247
|
+
assert len(sleep_calls) == 1
|
|
248
|
+
assert sleep_calls[0] == 1.0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_check_result_poll_interval_multiple_polls(
|
|
252
|
+
mock_base_client: Mock, monkeypatch: Any
|
|
253
|
+
) -> None:
|
|
254
|
+
client = ImageProjectsClient(base_client=mock_base_client)
|
|
255
|
+
|
|
256
|
+
# Set custom poll interval
|
|
257
|
+
monkeypatch.setenv("MAGIC_HOUR_POLL_INTERVAL", "0.1")
|
|
258
|
+
|
|
259
|
+
# Multiple calls return queued before complete
|
|
260
|
+
mock_base_client.request.side_effect = [
|
|
261
|
+
DummyResponse(status="queued"),
|
|
262
|
+
DummyResponse(status="queued"),
|
|
263
|
+
DummyResponse(status="queued"),
|
|
264
|
+
DummyResponse(status="complete"),
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
# Mock time.sleep to track calls
|
|
268
|
+
sleep_calls: List[float] = []
|
|
269
|
+
|
|
270
|
+
def mock_sleep(seconds: float) -> None:
|
|
271
|
+
sleep_calls.append(seconds)
|
|
272
|
+
|
|
273
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
274
|
+
|
|
275
|
+
resp = client.check_result(
|
|
276
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
assert resp.status == "complete"
|
|
280
|
+
# Should have slept 3 times with custom interval (0.1)
|
|
281
|
+
assert len(sleep_calls) == 3
|
|
282
|
+
assert all(sleep_time == 0.1 for sleep_time in sleep_calls)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@pytest.mark.asyncio
|
|
286
|
+
async def test_async_delete_calls_base_client(
|
|
287
|
+
mock_async_base_client: AsyncMock,
|
|
288
|
+
) -> None:
|
|
289
|
+
client = AsyncImageProjectsClient(base_client=mock_async_base_client)
|
|
290
|
+
await client.delete(id="456")
|
|
291
|
+
|
|
292
|
+
mock_async_base_client.request.assert_called_once()
|
|
293
|
+
call = mock_async_base_client.request.call_args[1]
|
|
294
|
+
assert call["method"] == "DELETE"
|
|
295
|
+
assert "/v1/image-projects/456" in call["path"]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@pytest.mark.asyncio
|
|
299
|
+
async def test_async_get_calls_base_client(mock_async_base_client: AsyncMock) -> None:
|
|
300
|
+
client = AsyncImageProjectsClient(base_client=mock_async_base_client)
|
|
301
|
+
mock_async_base_client.request.return_value = DummyResponse()
|
|
302
|
+
|
|
303
|
+
resp = await client.get(id="zzz")
|
|
304
|
+
|
|
305
|
+
mock_async_base_client.request.assert_called_once()
|
|
306
|
+
assert isinstance(resp, models.V1ImageProjectsGetResponse)
|
|
307
|
+
assert resp.id == "test-id"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@pytest.mark.asyncio
|
|
311
|
+
async def test_async_check_result_no_wait_no_download(
|
|
312
|
+
mock_async_base_client: AsyncMock,
|
|
313
|
+
) -> None:
|
|
314
|
+
client = AsyncImageProjectsClient(base_client=mock_async_base_client)
|
|
315
|
+
mock_async_base_client.request.return_value = DummyResponse(status="queued")
|
|
316
|
+
|
|
317
|
+
resp = await client.check_result(
|
|
318
|
+
id="xyz",
|
|
319
|
+
wait_for_completion=False,
|
|
320
|
+
download_outputs=False,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
assert resp.downloaded_paths is None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@pytest.mark.asyncio
|
|
327
|
+
async def test_async_check_result_wait_until_complete(
|
|
328
|
+
mock_async_base_client: AsyncMock, monkeypatch: Any
|
|
329
|
+
) -> None:
|
|
330
|
+
client = AsyncImageProjectsClient(base_client=mock_async_base_client)
|
|
331
|
+
|
|
332
|
+
# First calls return queued, then complete
|
|
333
|
+
mock_async_base_client.request.side_effect = [
|
|
334
|
+
DummyResponse(status="queued"),
|
|
335
|
+
DummyResponse(status="queued"),
|
|
336
|
+
DummyResponse(status="complete"),
|
|
337
|
+
]
|
|
338
|
+
|
|
339
|
+
sleep_calls: List[float] = []
|
|
340
|
+
|
|
341
|
+
async def async_mock_sleep(seconds: float) -> None:
|
|
342
|
+
sleep_calls.append(seconds)
|
|
343
|
+
|
|
344
|
+
monkeypatch.setattr("asyncio.sleep", async_mock_sleep)
|
|
345
|
+
|
|
346
|
+
resp = await client.check_result(
|
|
347
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
assert resp.status == "complete"
|
|
351
|
+
assert resp.downloaded_paths is None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@pytest.mark.asyncio
|
|
355
|
+
async def test_async_check_result_download_outputs(
|
|
356
|
+
tmp_path: Path, mock_async_base_client: AsyncMock, monkeypatch: Any
|
|
357
|
+
) -> None:
|
|
358
|
+
client = AsyncImageProjectsClient(base_client=mock_async_base_client)
|
|
359
|
+
|
|
360
|
+
file_url = "https://example.com/file.png"
|
|
361
|
+
mock_async_base_client.request.return_value = DummyResponse(
|
|
362
|
+
status="complete",
|
|
363
|
+
download_url=file_url,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Create a mock response for httpx
|
|
367
|
+
mock_request = httpx.Request("GET", "https://example.com/file.png")
|
|
368
|
+
mock_response = httpx.Response(200, content=b"fake png", request=mock_request)
|
|
369
|
+
|
|
370
|
+
# Mock the httpx.AsyncClient class
|
|
371
|
+
class MockAsyncClient:
|
|
372
|
+
def __init__(self):
|
|
373
|
+
pass
|
|
374
|
+
|
|
375
|
+
async def __aenter__(self) -> "MockAsyncClient":
|
|
376
|
+
return self
|
|
377
|
+
|
|
378
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
379
|
+
pass
|
|
380
|
+
|
|
381
|
+
async def get(self, url: str) -> httpx.Response:
|
|
382
|
+
return mock_response
|
|
383
|
+
|
|
384
|
+
monkeypatch.setattr(httpx, "AsyncClient", MockAsyncClient)
|
|
385
|
+
|
|
386
|
+
resp = await client.check_result(
|
|
387
|
+
id="xyz",
|
|
388
|
+
wait_for_completion=True,
|
|
389
|
+
download_outputs=True,
|
|
390
|
+
download_directory=str(tmp_path),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
assert resp.status == "complete"
|
|
394
|
+
assert resp.downloaded_paths
|
|
395
|
+
saved_file = Path(resp.downloaded_paths[0])
|
|
396
|
+
assert saved_file.exists()
|
|
397
|
+
assert saved_file.read_bytes() == b"fake png"
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@pytest.mark.asyncio
|
|
401
|
+
async def test_async_check_result_error_status(
|
|
402
|
+
mock_async_base_client: AsyncMock,
|
|
403
|
+
) -> None:
|
|
404
|
+
client = AsyncImageProjectsClient(base_client=mock_async_base_client)
|
|
405
|
+
mock_async_base_client.request.return_value = DummyResponse(
|
|
406
|
+
status="error", error="Boom!"
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
resp = await client.check_result(
|
|
410
|
+
id="err", wait_for_completion=True, download_outputs=False
|
|
411
|
+
)
|
|
412
|
+
assert resp.status == "error"
|
|
413
|
+
assert resp.error is not None
|
|
414
|
+
assert resp.error.message == "Boom!"
|
|
415
|
+
assert resp.downloaded_paths is None
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@pytest.mark.asyncio
|
|
419
|
+
async def test_async_check_result_canceled_status(
|
|
420
|
+
mock_async_base_client: AsyncMock,
|
|
421
|
+
) -> None:
|
|
422
|
+
client = AsyncImageProjectsClient(base_client=mock_async_base_client)
|
|
423
|
+
mock_async_base_client.request.return_value = DummyResponse(status="canceled")
|
|
424
|
+
|
|
425
|
+
resp = await client.check_result(
|
|
426
|
+
id="cancel", wait_for_completion=True, download_outputs=False
|
|
427
|
+
)
|
|
428
|
+
assert resp.status == "canceled"
|
|
429
|
+
assert resp.downloaded_paths is None
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@pytest.mark.asyncio
|
|
433
|
+
async def test_async_check_result_poll_interval_default(
|
|
434
|
+
mock_async_base_client: AsyncMock, monkeypatch: Any
|
|
435
|
+
) -> None:
|
|
436
|
+
client = AsyncImageProjectsClient(base_client=mock_async_base_client)
|
|
437
|
+
|
|
438
|
+
# First calls return queued, then complete
|
|
439
|
+
mock_async_base_client.request.side_effect = [
|
|
440
|
+
DummyResponse(status="queued"),
|
|
441
|
+
DummyResponse(status="complete"),
|
|
442
|
+
]
|
|
443
|
+
|
|
444
|
+
# Mock time.sleep to track calls
|
|
445
|
+
sleep_calls: List[float] = []
|
|
446
|
+
|
|
447
|
+
async def async_mock_sleep(seconds: float) -> None:
|
|
448
|
+
sleep_calls.append(seconds)
|
|
449
|
+
|
|
450
|
+
monkeypatch.setattr("asyncio.sleep", async_mock_sleep)
|
|
451
|
+
|
|
452
|
+
resp = await client.check_result(
|
|
453
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
assert resp.status == "complete"
|
|
457
|
+
# Should have slept once with default interval (0.5)
|
|
458
|
+
assert len(sleep_calls) == 1
|
|
459
|
+
assert sleep_calls[0] == 0.5
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@pytest.mark.asyncio
|
|
463
|
+
async def test_async_check_result_poll_interval_custom(
|
|
464
|
+
mock_async_base_client: AsyncMock, monkeypatch: Any
|
|
465
|
+
) -> None:
|
|
466
|
+
client = AsyncImageProjectsClient(base_client=mock_async_base_client)
|
|
467
|
+
|
|
468
|
+
# Set custom poll interval
|
|
469
|
+
monkeypatch.setenv("MAGIC_HOUR_POLL_INTERVAL", "2.0")
|
|
470
|
+
|
|
471
|
+
# First calls return queued, then complete
|
|
472
|
+
mock_async_base_client.request.side_effect = [
|
|
473
|
+
DummyResponse(status="queued"),
|
|
474
|
+
DummyResponse(status="complete"),
|
|
475
|
+
]
|
|
476
|
+
|
|
477
|
+
# Mock time.sleep to track calls
|
|
478
|
+
sleep_calls: List[float] = []
|
|
479
|
+
|
|
480
|
+
async def async_mock_sleep(seconds: float) -> None:
|
|
481
|
+
sleep_calls.append(seconds)
|
|
482
|
+
|
|
483
|
+
monkeypatch.setattr("asyncio.sleep", async_mock_sleep)
|
|
484
|
+
|
|
485
|
+
resp = await client.check_result(
|
|
486
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
assert resp.status == "complete"
|
|
490
|
+
# Should have slept once with custom interval (2.0)
|
|
491
|
+
assert len(sleep_calls) == 1
|
|
492
|
+
assert sleep_calls[0] == 2.0
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@pytest.mark.asyncio
|
|
496
|
+
async def test_async_check_result_poll_interval_multiple_polls(
|
|
497
|
+
mock_async_base_client: AsyncMock, monkeypatch: Any
|
|
498
|
+
) -> None:
|
|
499
|
+
client = AsyncImageProjectsClient(base_client=mock_async_base_client)
|
|
500
|
+
|
|
501
|
+
# Set custom poll interval
|
|
502
|
+
monkeypatch.setenv("MAGIC_HOUR_POLL_INTERVAL", "0.3")
|
|
503
|
+
|
|
504
|
+
# Multiple calls return queued before complete
|
|
505
|
+
mock_async_base_client.request.side_effect = [
|
|
506
|
+
DummyResponse(status="queued"),
|
|
507
|
+
DummyResponse(status="queued"),
|
|
508
|
+
DummyResponse(status="queued"),
|
|
509
|
+
DummyResponse(status="complete"),
|
|
510
|
+
]
|
|
511
|
+
|
|
512
|
+
# Mock time.sleep to track calls
|
|
513
|
+
sleep_calls: List[float] = []
|
|
514
|
+
|
|
515
|
+
async def async_mock_sleep(seconds: float) -> None:
|
|
516
|
+
sleep_calls.append(seconds)
|
|
517
|
+
|
|
518
|
+
monkeypatch.setattr("asyncio.sleep", async_mock_sleep)
|
|
519
|
+
|
|
520
|
+
resp = await client.check_result(
|
|
521
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
assert resp.status == "complete"
|
|
525
|
+
# Should have slept 3 times with custom interval (0.3)
|
|
526
|
+
assert len(sleep_calls) == 3
|
|
527
|
+
assert all(sleep_time == 0.3 for sleep_time in sleep_calls)
|