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.

Files changed (97) hide show
  1. magic_hour/README.md +35 -0
  2. magic_hour/core/base_client.py +6 -5
  3. magic_hour/core/query.py +12 -6
  4. magic_hour/core/request.py +3 -3
  5. magic_hour/core/response.py +18 -14
  6. magic_hour/core/utils.py +3 -3
  7. magic_hour/environment.py +1 -1
  8. magic_hour/helpers/__init__.py +3 -0
  9. magic_hour/helpers/download.py +75 -0
  10. magic_hour/resources/v1/README.md +33 -0
  11. magic_hour/resources/v1/ai_clothes_changer/README.md +73 -0
  12. magic_hour/resources/v1/ai_clothes_changer/client.py +146 -0
  13. magic_hour/resources/v1/ai_face_editor/README.md +110 -0
  14. magic_hour/resources/v1/ai_face_editor/client.py +168 -0
  15. magic_hour/resources/v1/ai_gif_generator/README.md +59 -0
  16. magic_hour/resources/v1/ai_gif_generator/client.py +119 -0
  17. magic_hour/resources/v1/ai_headshot_generator/README.md +60 -0
  18. magic_hour/resources/v1/ai_headshot_generator/client.py +140 -0
  19. magic_hour/resources/v1/ai_image_editor/README.md +64 -0
  20. magic_hour/resources/v1/ai_image_editor/client.py +136 -0
  21. magic_hour/resources/v1/ai_image_generator/README.md +66 -0
  22. magic_hour/resources/v1/ai_image_generator/client.py +139 -0
  23. magic_hour/resources/v1/ai_image_upscaler/README.md +67 -0
  24. magic_hour/resources/v1/ai_image_upscaler/client.py +150 -0
  25. magic_hour/resources/v1/ai_meme_generator/README.md +71 -0
  26. magic_hour/resources/v1/ai_meme_generator/client.py +127 -0
  27. magic_hour/resources/v1/ai_photo_editor/README.md +98 -7
  28. magic_hour/resources/v1/ai_photo_editor/client.py +174 -0
  29. magic_hour/resources/v1/ai_qr_code_generator/README.md +63 -0
  30. magic_hour/resources/v1/ai_qr_code_generator/client.py +123 -0
  31. magic_hour/resources/v1/ai_talking_photo/README.md +74 -0
  32. magic_hour/resources/v1/ai_talking_photo/client.py +170 -0
  33. magic_hour/resources/v1/animation/README.md +100 -0
  34. magic_hour/resources/v1/animation/client.py +218 -0
  35. magic_hour/resources/v1/auto_subtitle_generator/README.md +69 -0
  36. magic_hour/resources/v1/auto_subtitle_generator/client.py +178 -0
  37. magic_hour/resources/v1/face_detection/README.md +59 -0
  38. magic_hour/resources/v1/face_detection/__init__.py +10 -2
  39. magic_hour/resources/v1/face_detection/client.py +179 -0
  40. magic_hour/resources/v1/face_swap/README.md +105 -12
  41. magic_hour/resources/v1/face_swap/client.py +262 -28
  42. magic_hour/resources/v1/face_swap_photo/README.md +84 -0
  43. magic_hour/resources/v1/face_swap_photo/client.py +172 -0
  44. magic_hour/resources/v1/files/README.md +6 -0
  45. magic_hour/resources/v1/files/client.py +350 -0
  46. magic_hour/resources/v1/files/client_test.py +414 -0
  47. magic_hour/resources/v1/files/upload_urls/README.md +8 -0
  48. magic_hour/resources/v1/image_background_remover/README.md +68 -0
  49. magic_hour/resources/v1/image_background_remover/client.py +130 -0
  50. magic_hour/resources/v1/image_projects/README.md +8 -0
  51. magic_hour/resources/v1/image_projects/__init__.py +10 -2
  52. magic_hour/resources/v1/image_projects/client.py +138 -0
  53. magic_hour/resources/v1/image_projects/client_test.py +527 -0
  54. magic_hour/resources/v1/image_to_video/README.md +77 -9
  55. magic_hour/resources/v1/image_to_video/client.py +210 -8
  56. magic_hour/resources/v1/lip_sync/README.md +87 -13
  57. magic_hour/resources/v1/lip_sync/client.py +230 -28
  58. magic_hour/resources/v1/photo_colorizer/README.md +59 -0
  59. magic_hour/resources/v1/photo_colorizer/client.py +130 -0
  60. magic_hour/resources/v1/text_to_video/README.md +68 -0
  61. magic_hour/resources/v1/text_to_video/client.py +151 -0
  62. magic_hour/resources/v1/video_projects/README.md +8 -0
  63. magic_hour/resources/v1/video_projects/__init__.py +10 -2
  64. magic_hour/resources/v1/video_projects/client.py +137 -0
  65. magic_hour/resources/v1/video_projects/client_test.py +527 -0
  66. magic_hour/resources/v1/video_to_video/README.md +98 -14
  67. magic_hour/resources/v1/video_to_video/client.py +242 -28
  68. magic_hour/types/params/__init__.py +58 -0
  69. magic_hour/types/params/v1_ai_clothes_changer_generate_body_assets.py +33 -0
  70. magic_hour/types/params/v1_ai_face_editor_generate_body_assets.py +17 -0
  71. magic_hour/types/params/v1_ai_headshot_generator_generate_body_assets.py +17 -0
  72. magic_hour/types/params/v1_ai_image_editor_generate_body_assets.py +17 -0
  73. magic_hour/types/params/v1_ai_image_upscaler_generate_body_assets.py +17 -0
  74. magic_hour/types/params/v1_ai_photo_editor_generate_body_assets.py +17 -0
  75. magic_hour/types/params/v1_ai_talking_photo_generate_body_assets.py +26 -0
  76. magic_hour/types/params/v1_animation_generate_body_assets.py +39 -0
  77. magic_hour/types/params/v1_auto_subtitle_generator_generate_body_assets.py +17 -0
  78. magic_hour/types/params/v1_face_detection_generate_body_assets.py +17 -0
  79. magic_hour/types/params/v1_face_swap_create_body.py +24 -14
  80. magic_hour/types/params/v1_face_swap_create_body_style.py +33 -0
  81. magic_hour/types/params/v1_face_swap_generate_body_assets.py +56 -0
  82. magic_hour/types/params/v1_face_swap_generate_body_assets_face_mappings_item.py +25 -0
  83. magic_hour/types/params/v1_face_swap_photo_generate_body_assets.py +47 -0
  84. magic_hour/types/params/v1_face_swap_photo_generate_body_assets_face_mappings_item.py +25 -0
  85. magic_hour/types/params/v1_image_background_remover_generate_body_assets.py +27 -0
  86. magic_hour/types/params/v1_image_to_video_create_body.py +14 -6
  87. magic_hour/types/params/v1_image_to_video_generate_body_assets.py +17 -0
  88. magic_hour/types/params/v1_lip_sync_create_body.py +12 -14
  89. magic_hour/types/params/v1_lip_sync_generate_body_assets.py +36 -0
  90. magic_hour/types/params/v1_photo_colorizer_generate_body_assets.py +17 -0
  91. magic_hour/types/params/v1_video_to_video_create_body.py +12 -14
  92. magic_hour/types/params/v1_video_to_video_generate_body_assets.py +27 -0
  93. magic_hour-0.36.0.dist-info/METADATA +303 -0
  94. {magic_hour-0.34.0.dist-info → magic_hour-0.36.0.dist-info}/RECORD +96 -68
  95. magic_hour-0.34.0.dist-info/METADATA +0 -166
  96. {magic_hour-0.34.0.dist-info → magic_hour-0.36.0.dist-info}/LICENSE +0 -0
  97. {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)