magic_hour 0.8.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/__init__.py +6 -0
- magic_hour/client.py +61 -0
- magic_hour/core/__init__.py +53 -0
- magic_hour/core/api_error.py +48 -0
- magic_hour/core/auth.py +314 -0
- magic_hour/core/base_client.py +600 -0
- magic_hour/core/binary_response.py +23 -0
- magic_hour/core/request.py +158 -0
- magic_hour/core/response.py +293 -0
- magic_hour/core/type_utils.py +28 -0
- magic_hour/core/utils.py +38 -0
- magic_hour/environment.py +6 -0
- magic_hour/resources/v1/__init__.py +4 -0
- magic_hour/resources/v1/ai_clothes_changer/README.md +41 -0
- magic_hour/resources/v1/ai_clothes_changer/__init__.py +4 -0
- magic_hour/resources/v1/ai_clothes_changer/client.py +129 -0
- magic_hour/resources/v1/ai_headshot_generator/README.md +31 -0
- magic_hour/resources/v1/ai_headshot_generator/__init__.py +4 -0
- magic_hour/resources/v1/ai_headshot_generator/client.py +119 -0
- magic_hour/resources/v1/ai_image_generator/README.md +37 -0
- magic_hour/resources/v1/ai_image_generator/__init__.py +4 -0
- magic_hour/resources/v1/ai_image_generator/client.py +144 -0
- magic_hour/resources/v1/ai_image_upscaler/README.md +37 -0
- magic_hour/resources/v1/ai_image_upscaler/__init__.py +4 -0
- magic_hour/resources/v1/ai_image_upscaler/client.py +143 -0
- magic_hour/resources/v1/ai_photo_editor/README.md +53 -0
- magic_hour/resources/v1/ai_photo_editor/__init__.py +4 -0
- magic_hour/resources/v1/ai_photo_editor/client.py +167 -0
- magic_hour/resources/v1/ai_qr_code_generator/README.md +35 -0
- magic_hour/resources/v1/ai_qr_code_generator/__init__.py +4 -0
- magic_hour/resources/v1/ai_qr_code_generator/client.py +127 -0
- magic_hour/resources/v1/animation/README.md +63 -0
- magic_hour/resources/v1/animation/__init__.py +4 -0
- magic_hour/resources/v1/animation/client.py +179 -0
- magic_hour/resources/v1/client.py +153 -0
- magic_hour/resources/v1/face_swap/README.md +52 -0
- magic_hour/resources/v1/face_swap/__init__.py +4 -0
- magic_hour/resources/v1/face_swap/client.py +165 -0
- magic_hour/resources/v1/face_swap_photo/README.md +39 -0
- magic_hour/resources/v1/face_swap_photo/__init__.py +4 -0
- magic_hour/resources/v1/face_swap_photo/client.py +127 -0
- magic_hour/resources/v1/files/__init__.py +4 -0
- magic_hour/resources/v1/files/client.py +19 -0
- magic_hour/resources/v1/files/upload_urls/README.md +56 -0
- magic_hour/resources/v1/files/upload_urls/__init__.py +4 -0
- magic_hour/resources/v1/files/upload_urls/client.py +142 -0
- magic_hour/resources/v1/image_background_remover/README.md +31 -0
- magic_hour/resources/v1/image_background_remover/__init__.py +4 -0
- magic_hour/resources/v1/image_background_remover/client.py +121 -0
- magic_hour/resources/v1/image_projects/README.md +63 -0
- magic_hour/resources/v1/image_projects/__init__.py +4 -0
- magic_hour/resources/v1/image_projects/client.py +177 -0
- magic_hour/resources/v1/image_to_video/README.md +44 -0
- magic_hour/resources/v1/image_to_video/__init__.py +4 -0
- magic_hour/resources/v1/image_to_video/client.py +165 -0
- magic_hour/resources/v1/lip_sync/README.md +54 -0
- magic_hour/resources/v1/lip_sync/__init__.py +4 -0
- magic_hour/resources/v1/lip_sync/client.py +177 -0
- magic_hour/resources/v1/text_to_video/README.md +40 -0
- magic_hour/resources/v1/text_to_video/__init__.py +4 -0
- magic_hour/resources/v1/text_to_video/client.py +150 -0
- magic_hour/resources/v1/video_projects/README.md +63 -0
- magic_hour/resources/v1/video_projects/__init__.py +4 -0
- magic_hour/resources/v1/video_projects/client.py +177 -0
- magic_hour/resources/v1/video_to_video/README.md +60 -0
- magic_hour/resources/v1/video_to_video/__init__.py +4 -0
- magic_hour/resources/v1/video_to_video/client.py +204 -0
- magic_hour/types/models/__init__.py +60 -0
- magic_hour/types/models/get_v1_image_projects_id_response.py +82 -0
- magic_hour/types/models/get_v1_image_projects_id_response_downloads_item.py +19 -0
- magic_hour/types/models/get_v1_image_projects_id_response_error.py +25 -0
- magic_hour/types/models/get_v1_video_projects_id_response.py +114 -0
- magic_hour/types/models/get_v1_video_projects_id_response_download.py +19 -0
- magic_hour/types/models/get_v1_video_projects_id_response_downloads_item.py +19 -0
- magic_hour/types/models/get_v1_video_projects_id_response_error.py +25 -0
- magic_hour/types/models/post_v1_ai_clothes_changer_response.py +25 -0
- magic_hour/types/models/post_v1_ai_headshot_generator_response.py +25 -0
- magic_hour/types/models/post_v1_ai_image_generator_response.py +25 -0
- magic_hour/types/models/post_v1_ai_image_upscaler_response.py +25 -0
- magic_hour/types/models/post_v1_ai_photo_editor_response.py +25 -0
- magic_hour/types/models/post_v1_ai_qr_code_generator_response.py +25 -0
- magic_hour/types/models/post_v1_animation_response.py +25 -0
- magic_hour/types/models/post_v1_face_swap_photo_response.py +25 -0
- magic_hour/types/models/post_v1_face_swap_response.py +25 -0
- magic_hour/types/models/post_v1_files_upload_urls_response.py +21 -0
- magic_hour/types/models/post_v1_files_upload_urls_response_items_item.py +31 -0
- magic_hour/types/models/post_v1_image_background_remover_response.py +25 -0
- magic_hour/types/models/post_v1_image_to_video_response.py +25 -0
- magic_hour/types/models/post_v1_lip_sync_response.py +25 -0
- magic_hour/types/models/post_v1_text_to_video_response.py +25 -0
- magic_hour/types/models/post_v1_video_to_video_response.py +25 -0
- magic_hour/types/params/__init__.py +205 -0
- magic_hour/types/params/post_v1_ai_clothes_changer_body.py +40 -0
- magic_hour/types/params/post_v1_ai_clothes_changer_body_assets.py +45 -0
- magic_hour/types/params/post_v1_ai_headshot_generator_body.py +40 -0
- magic_hour/types/params/post_v1_ai_headshot_generator_body_assets.py +28 -0
- magic_hour/types/params/post_v1_ai_image_generator_body.py +54 -0
- magic_hour/types/params/post_v1_ai_image_generator_body_style.py +28 -0
- magic_hour/types/params/post_v1_ai_image_upscaler_body.py +54 -0
- magic_hour/types/params/post_v1_ai_image_upscaler_body_assets.py +28 -0
- magic_hour/types/params/post_v1_ai_image_upscaler_body_style.py +36 -0
- magic_hour/types/params/post_v1_ai_photo_editor_body.py +63 -0
- magic_hour/types/params/post_v1_ai_photo_editor_body_assets.py +28 -0
- magic_hour/types/params/post_v1_ai_photo_editor_body_style.py +67 -0
- magic_hour/types/params/post_v1_ai_qr_code_generator_body.py +45 -0
- magic_hour/types/params/post_v1_ai_qr_code_generator_body_style.py +28 -0
- magic_hour/types/params/post_v1_animation_body.py +84 -0
- magic_hour/types/params/post_v1_animation_body_assets.py +55 -0
- magic_hour/types/params/post_v1_animation_body_style.py +279 -0
- magic_hour/types/params/post_v1_face_swap_body.py +72 -0
- magic_hour/types/params/post_v1_face_swap_body_assets.py +52 -0
- magic_hour/types/params/post_v1_face_swap_photo_body.py +40 -0
- magic_hour/types/params/post_v1_face_swap_photo_body_assets.py +36 -0
- magic_hour/types/params/post_v1_files_upload_urls_body.py +31 -0
- magic_hour/types/params/post_v1_files_upload_urls_body_items_item.py +38 -0
- magic_hour/types/params/post_v1_image_background_remover_body.py +40 -0
- magic_hour/types/params/post_v1_image_background_remover_body_assets.py +28 -0
- magic_hour/types/params/post_v1_image_to_video_body.py +73 -0
- magic_hour/types/params/post_v1_image_to_video_body_assets.py +28 -0
- magic_hour/types/params/post_v1_image_to_video_body_style.py +29 -0
- magic_hour/types/params/post_v1_lip_sync_body.py +80 -0
- magic_hour/types/params/post_v1_lip_sync_body_assets.py +52 -0
- magic_hour/types/params/post_v1_text_to_video_body.py +57 -0
- magic_hour/types/params/post_v1_text_to_video_body_style.py +28 -0
- magic_hour/types/params/post_v1_video_to_video_body.py +93 -0
- magic_hour/types/params/post_v1_video_to_video_body_assets.py +44 -0
- magic_hour/types/params/post_v1_video_to_video_body_style.py +199 -0
- magic_hour-0.8.0.dist-info/LICENSE +21 -0
- magic_hour-0.8.0.dist-info/METADATA +138 -0
- magic_hour-0.8.0.dist-info/RECORD +131 -0
- magic_hour-0.8.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from typing import Any, Dict, Type, Union, Sequence, List
|
|
2
|
+
from urllib.parse import quote_plus
|
|
3
|
+
import httpx
|
|
4
|
+
from typing_extensions import TypedDict, Required, NotRequired
|
|
5
|
+
from pydantic import TypeAdapter, BaseModel
|
|
6
|
+
from .type_utils import NotGiven
|
|
7
|
+
|
|
8
|
+
"""
|
|
9
|
+
Request configuration and utility functions for handling HTTP requests.
|
|
10
|
+
This module provides type definitions and helper functions for building
|
|
11
|
+
and processing HTTP requests in a type-safe manner.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
# Type alias for query parameters that can handle both primitive data and sequences
|
|
15
|
+
QueryParams = Dict[
|
|
16
|
+
str, Union[httpx._types.PrimitiveData, Sequence[httpx._types.PrimitiveData]]
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RequestConfig(TypedDict):
|
|
21
|
+
"""
|
|
22
|
+
Configuration for HTTP requests.
|
|
23
|
+
|
|
24
|
+
Defines all possible parameters that can be passed to an HTTP request,
|
|
25
|
+
including required method and URL, as well as optional parameters like
|
|
26
|
+
content, headers, authentication, etc.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
method: Required[str]
|
|
30
|
+
url: Required[httpx._types.URLTypes]
|
|
31
|
+
content: NotRequired[httpx._types.RequestContent]
|
|
32
|
+
data: NotRequired[httpx._types.RequestData]
|
|
33
|
+
files: NotRequired[httpx._types.RequestFiles]
|
|
34
|
+
json: NotRequired[Any]
|
|
35
|
+
params: NotRequired[QueryParams]
|
|
36
|
+
headers: NotRequired[Dict[str, str]]
|
|
37
|
+
cookies: NotRequired[Dict[str, str]]
|
|
38
|
+
auth: NotRequired[httpx._types.AuthTypes]
|
|
39
|
+
follow_redirects: NotRequired[bool]
|
|
40
|
+
timeout: NotRequired[httpx._types.TimeoutTypes]
|
|
41
|
+
extensions: NotRequired[httpx._types.RequestExtensions]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RequestOptions(TypedDict):
|
|
45
|
+
"""
|
|
46
|
+
Additional options for customizing request behavior.
|
|
47
|
+
|
|
48
|
+
Provides configuration for timeouts and additional headers/parameters
|
|
49
|
+
that should be included with requests.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
timeout: Number of seconds to await an API call before timing out
|
|
53
|
+
additional_headers: Extra headers to include in the request
|
|
54
|
+
additional_params: Extra query parameters to include in the request
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
timeout: NotRequired[int]
|
|
58
|
+
additional_headers: NotRequired[Dict[str, str]]
|
|
59
|
+
additional_params: NotRequired[QueryParams]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def default_request_options() -> RequestOptions:
|
|
63
|
+
"""
|
|
64
|
+
Provides default request options.
|
|
65
|
+
|
|
66
|
+
Returns an empty dictionary as the base configuration, allowing defaults
|
|
67
|
+
to be handled by the underlying HTTP client.
|
|
68
|
+
"""
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def model_dump(item: Any) -> Any:
|
|
73
|
+
"""
|
|
74
|
+
Recursively converts Pydantic models to dictionaries.
|
|
75
|
+
|
|
76
|
+
Handles nested structures including lists and individual models,
|
|
77
|
+
preserving alias information and excluding unset values.
|
|
78
|
+
"""
|
|
79
|
+
if isinstance(item, list):
|
|
80
|
+
return [model_dump(i) for i in item]
|
|
81
|
+
if isinstance(item, BaseModel):
|
|
82
|
+
return item.model_dump(exclude_unset=True, by_alias=True)
|
|
83
|
+
else:
|
|
84
|
+
return item
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def to_encodable(
|
|
88
|
+
*, item: Any, dump_with: Union[Type, Union[Type, Any], List[Type]]
|
|
89
|
+
) -> Any:
|
|
90
|
+
"""
|
|
91
|
+
Validates and converts an item to an encodable format using a specified type.
|
|
92
|
+
Uses Pydantic's TypeAdapter for validation and converts the result
|
|
93
|
+
to a format suitable for encoding in requests.
|
|
94
|
+
"""
|
|
95
|
+
filtered_item = filter_not_given(item)
|
|
96
|
+
adapter: TypeAdapter = TypeAdapter(dump_with)
|
|
97
|
+
validated_item = adapter.validate_python(filtered_item)
|
|
98
|
+
return model_dump(validated_item)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def to_content(*, file: httpx._types.FileTypes) -> httpx._types.RequestContent:
|
|
102
|
+
"""
|
|
103
|
+
Converts the various ways files can be provided to something that is accepted by
|
|
104
|
+
the httpx.request content kwarg
|
|
105
|
+
"""
|
|
106
|
+
if isinstance(file, tuple):
|
|
107
|
+
file_content: httpx._types.FileContent = file[1]
|
|
108
|
+
else:
|
|
109
|
+
file_content = file
|
|
110
|
+
|
|
111
|
+
if hasattr(file_content, "read") and callable(file_content.read):
|
|
112
|
+
return file_content.read()
|
|
113
|
+
else:
|
|
114
|
+
return file_content
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def encode_param(
|
|
118
|
+
value: Any, explode: bool
|
|
119
|
+
) -> Union[httpx._types.PrimitiveData, Sequence[httpx._types.PrimitiveData]]:
|
|
120
|
+
"""
|
|
121
|
+
Encodes parameter values for use in URLs.
|
|
122
|
+
|
|
123
|
+
Handles both simple values and collections, with special handling for
|
|
124
|
+
unexploded collections (combining them with commas) versus exploded ones.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
explode: Whether to explode collections into separate parameters
|
|
128
|
+
"""
|
|
129
|
+
if isinstance(value, (list, dict)) and not explode:
|
|
130
|
+
return quote_plus(",".join(map(str, value)))
|
|
131
|
+
else:
|
|
132
|
+
return value
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def filter_not_given(value: Any) -> Any:
|
|
136
|
+
"""Helper function to recursively filter out NotGiven values"""
|
|
137
|
+
if isinstance(value, NotGiven):
|
|
138
|
+
return None # This will trigger filtering at the container level
|
|
139
|
+
elif isinstance(value, dict):
|
|
140
|
+
return {
|
|
141
|
+
k: filter_not_given(v)
|
|
142
|
+
for k, v in value.items()
|
|
143
|
+
if not isinstance(v, NotGiven)
|
|
144
|
+
}
|
|
145
|
+
elif isinstance(value, (list, tuple)):
|
|
146
|
+
return type(value)(
|
|
147
|
+
filter_not_given(item) for item in value if not isinstance(item, NotGiven)
|
|
148
|
+
)
|
|
149
|
+
return value
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _get_default_for_type(value_type: Any) -> Any:
|
|
153
|
+
"""Helper to provide appropriate default values for required fields"""
|
|
154
|
+
if value_type is dict or isinstance(value_type, dict):
|
|
155
|
+
return {}
|
|
156
|
+
elif value_type is list or isinstance(value_type, list):
|
|
157
|
+
return []
|
|
158
|
+
return None
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Union, Dict, Type, TypeVar, List, Generic, Optional
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
Provides functionality for handling Server-Sent Events (SSE) streams and response data encoding.
|
|
8
|
+
Includes utilities for both synchronous and asynchronous stream processing.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
EncodableT = TypeVar(
|
|
12
|
+
"EncodableT",
|
|
13
|
+
bound=Union[
|
|
14
|
+
object,
|
|
15
|
+
str,
|
|
16
|
+
int,
|
|
17
|
+
float,
|
|
18
|
+
None,
|
|
19
|
+
BaseModel,
|
|
20
|
+
List[Any],
|
|
21
|
+
Dict[str, Any],
|
|
22
|
+
],
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def from_encodable(*, data: Any, load_with: Type[EncodableT]) -> Any:
|
|
27
|
+
"""
|
|
28
|
+
Converts raw data into a specified type using Pydantic validation.
|
|
29
|
+
|
|
30
|
+
Uses a dynamic Pydantic model to validate and convert incoming data
|
|
31
|
+
into the specified target type.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
class Caster(BaseModel):
|
|
35
|
+
data: load_with # type: ignore
|
|
36
|
+
|
|
37
|
+
return Caster(data=data).data
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
T = TypeVar("T")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StreamResponse(Generic[T]):
|
|
44
|
+
"""
|
|
45
|
+
Handles synchronous streaming of Server-Sent Events (SSE).
|
|
46
|
+
|
|
47
|
+
Processes a streaming HTTP response by buffering chunks of data
|
|
48
|
+
and parsing them according to SSE format, converting each event
|
|
49
|
+
into the specified type.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, response: httpx.Response, stream_context, cast_to: Type[T]):
|
|
53
|
+
"""
|
|
54
|
+
Initialize the stream processor with response and conversion settings.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
response: The HTTP response containing the SSE stream
|
|
58
|
+
stream_context: Context manager for the stream
|
|
59
|
+
cast_to: Target type for converting parsed events
|
|
60
|
+
"""
|
|
61
|
+
self.response = response
|
|
62
|
+
self._context = stream_context
|
|
63
|
+
self.cast_to = cast_to
|
|
64
|
+
self.iterator = response.iter_bytes()
|
|
65
|
+
self.buffer = bytearray()
|
|
66
|
+
self.position = 0
|
|
67
|
+
|
|
68
|
+
def __iter__(self):
|
|
69
|
+
"""Enables iteration over the stream events."""
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def __next__(self) -> T:
|
|
73
|
+
"""
|
|
74
|
+
Retrieves and processes the next event from the stream.
|
|
75
|
+
|
|
76
|
+
Buffers incoming data and processes it according to SSE format,
|
|
77
|
+
converting each complete event into the specified type.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
StopIteration: When the stream is exhausted
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
while True:
|
|
84
|
+
event = self._process_buffer()
|
|
85
|
+
if event:
|
|
86
|
+
return event
|
|
87
|
+
|
|
88
|
+
chunk = next(self.iterator)
|
|
89
|
+
self.buffer += chunk
|
|
90
|
+
|
|
91
|
+
except StopIteration:
|
|
92
|
+
event = self._process_buffer(final=True)
|
|
93
|
+
if event:
|
|
94
|
+
return event
|
|
95
|
+
self._context.__exit__(None, None, None)
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
def _process_buffer(self, final=False) -> Optional[T]:
|
|
99
|
+
"""
|
|
100
|
+
Processes the current buffer to extract complete SSE events.
|
|
101
|
+
|
|
102
|
+
Searches for event boundaries and parses complete events,
|
|
103
|
+
handling both JSON and non-JSON payloads.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
final: Whether this is the final processing of the buffer
|
|
107
|
+
"""
|
|
108
|
+
while self.position < len(self.buffer):
|
|
109
|
+
for boundary in [b"\r\n\r\n", b"\n\n", b"\r\r"]:
|
|
110
|
+
if (self.position + len(boundary)) <= len(self.buffer):
|
|
111
|
+
if (
|
|
112
|
+
self.buffer[self.position : self.position + len(boundary)]
|
|
113
|
+
== boundary
|
|
114
|
+
):
|
|
115
|
+
message = self.buffer[: self.position].decode()
|
|
116
|
+
self.buffer = self.buffer[self.position + len(boundary) :]
|
|
117
|
+
self.position = 0
|
|
118
|
+
|
|
119
|
+
data = self._parse_sse(message)
|
|
120
|
+
if data:
|
|
121
|
+
try:
|
|
122
|
+
parsed_data = json.loads(data)
|
|
123
|
+
if (
|
|
124
|
+
not isinstance(parsed_data, dict)
|
|
125
|
+
or "data" not in parsed_data
|
|
126
|
+
):
|
|
127
|
+
parsed_data = {"data": parsed_data}
|
|
128
|
+
return from_encodable(
|
|
129
|
+
data=parsed_data, load_with=self.cast_to
|
|
130
|
+
)
|
|
131
|
+
except json.JSONDecodeError:
|
|
132
|
+
return from_encodable(
|
|
133
|
+
data={"data": data}, load_with=self.cast_to
|
|
134
|
+
)
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
self.position += 1
|
|
138
|
+
|
|
139
|
+
if final and self.buffer:
|
|
140
|
+
message = self.buffer.decode()
|
|
141
|
+
data = self._parse_sse(message)
|
|
142
|
+
if data:
|
|
143
|
+
try:
|
|
144
|
+
parsed_data = json.loads(data)
|
|
145
|
+
if not isinstance(parsed_data, dict) or "data" not in parsed_data:
|
|
146
|
+
parsed_data = {"data": parsed_data}
|
|
147
|
+
return from_encodable(data=parsed_data, load_with=self.cast_to)
|
|
148
|
+
except json.JSONDecodeError:
|
|
149
|
+
return from_encodable(data={"data": data}, load_with=self.cast_to)
|
|
150
|
+
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
def _parse_sse(self, message: str) -> Optional[str]:
|
|
154
|
+
"""
|
|
155
|
+
Parses an SSE message to extract the data field.
|
|
156
|
+
|
|
157
|
+
Handles multi-line data fields and empty data fields according
|
|
158
|
+
to the SSE specification.
|
|
159
|
+
"""
|
|
160
|
+
data = []
|
|
161
|
+
for line in message.split("\n"):
|
|
162
|
+
if line.startswith("data:"):
|
|
163
|
+
data.append(line[5:].strip())
|
|
164
|
+
elif line.strip() == "data:": # Handle empty data field
|
|
165
|
+
data.append("")
|
|
166
|
+
|
|
167
|
+
if data:
|
|
168
|
+
return "\n".join(data)
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class AsyncStreamResponse(Generic[T]):
|
|
173
|
+
"""
|
|
174
|
+
Handles asynchronous streaming of Server-Sent Events (SSE).
|
|
175
|
+
|
|
176
|
+
Asynchronous version of StreamResponse, providing the same functionality
|
|
177
|
+
but compatible with async/await syntax.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(self, response: httpx.Response, stream_context, cast_to: Type[T]):
|
|
181
|
+
"""
|
|
182
|
+
Initialize the async stream processor.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
response: The HTTP response containing the SSE stream
|
|
186
|
+
stream_context: Async context manager for the stream
|
|
187
|
+
cast_to: Target type for converting parsed events
|
|
188
|
+
"""
|
|
189
|
+
self.response = response
|
|
190
|
+
self._context = stream_context
|
|
191
|
+
self.cast_to = cast_to
|
|
192
|
+
self.iterator = response.aiter_bytes()
|
|
193
|
+
self.buffer = bytearray()
|
|
194
|
+
self.position = 0
|
|
195
|
+
|
|
196
|
+
def __aiter__(self):
|
|
197
|
+
"""Enables async iteration over the stream events."""
|
|
198
|
+
return self
|
|
199
|
+
|
|
200
|
+
async def __anext__(self) -> T:
|
|
201
|
+
"""
|
|
202
|
+
Asynchronously retrieves and processes the next event from the stream.
|
|
203
|
+
|
|
204
|
+
Similar to synchronous version but uses async/await syntax for
|
|
205
|
+
iteration and context management.
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
StopAsyncIteration: When the stream is exhausted
|
|
209
|
+
"""
|
|
210
|
+
try:
|
|
211
|
+
while True:
|
|
212
|
+
event = self._process_buffer()
|
|
213
|
+
if event:
|
|
214
|
+
return event
|
|
215
|
+
|
|
216
|
+
chunk = await self.iterator.__anext__()
|
|
217
|
+
self.buffer += chunk
|
|
218
|
+
|
|
219
|
+
except StopAsyncIteration:
|
|
220
|
+
event = self._process_buffer(final=True)
|
|
221
|
+
if event:
|
|
222
|
+
return event
|
|
223
|
+
await self._context.__aexit__(None, None, None)
|
|
224
|
+
raise
|
|
225
|
+
|
|
226
|
+
def _process_buffer(self, final=False) -> Optional[T]:
|
|
227
|
+
"""
|
|
228
|
+
Processes the current buffer to extract complete SSE events.
|
|
229
|
+
|
|
230
|
+
Identical to the synchronous version's buffer processing.
|
|
231
|
+
"""
|
|
232
|
+
while self.position < len(self.buffer):
|
|
233
|
+
for boundary in [b"\r\n\r\n", b"\n\n", b"\r\r"]:
|
|
234
|
+
if (self.position + len(boundary)) <= len(self.buffer):
|
|
235
|
+
if (
|
|
236
|
+
self.buffer[self.position : self.position + len(boundary)]
|
|
237
|
+
== boundary
|
|
238
|
+
):
|
|
239
|
+
message = self.buffer[: self.position].decode()
|
|
240
|
+
self.buffer = self.buffer[self.position + len(boundary) :]
|
|
241
|
+
self.position = 0
|
|
242
|
+
|
|
243
|
+
data = self._parse_sse(message)
|
|
244
|
+
if data:
|
|
245
|
+
try:
|
|
246
|
+
parsed_data = json.loads(data)
|
|
247
|
+
if (
|
|
248
|
+
not isinstance(parsed_data, dict)
|
|
249
|
+
or "data" not in parsed_data
|
|
250
|
+
):
|
|
251
|
+
parsed_data = {"data": parsed_data}
|
|
252
|
+
return from_encodable(
|
|
253
|
+
data=parsed_data, load_with=self.cast_to
|
|
254
|
+
)
|
|
255
|
+
except json.JSONDecodeError:
|
|
256
|
+
return from_encodable(
|
|
257
|
+
data={"data": data}, load_with=self.cast_to
|
|
258
|
+
)
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
self.position += 1
|
|
262
|
+
|
|
263
|
+
if final and self.buffer:
|
|
264
|
+
message = self.buffer.decode()
|
|
265
|
+
data = self._parse_sse(message)
|
|
266
|
+
if data:
|
|
267
|
+
try:
|
|
268
|
+
parsed_data = json.loads(data)
|
|
269
|
+
if not isinstance(parsed_data, dict) or "data" not in parsed_data:
|
|
270
|
+
parsed_data = {"data": parsed_data}
|
|
271
|
+
return from_encodable(data=parsed_data, load_with=self.cast_to)
|
|
272
|
+
except json.JSONDecodeError:
|
|
273
|
+
return from_encodable(data={"data": data}, load_with=self.cast_to)
|
|
274
|
+
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
def _parse_sse(self, message: str) -> Optional[str]:
|
|
278
|
+
"""
|
|
279
|
+
Parses an SSE message to extract the data field.
|
|
280
|
+
|
|
281
|
+
Identical to the synchronous version's SSE parsing.
|
|
282
|
+
"""
|
|
283
|
+
data = []
|
|
284
|
+
for line in message.split("\n"):
|
|
285
|
+
line = line.strip()
|
|
286
|
+
if line.startswith("data:"):
|
|
287
|
+
data.append(line[5:].strip())
|
|
288
|
+
elif line == "data:": # Handle empty data field
|
|
289
|
+
data.append("")
|
|
290
|
+
|
|
291
|
+
if data:
|
|
292
|
+
return "\n".join(data)
|
|
293
|
+
return None
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from typing import (
|
|
2
|
+
Union,
|
|
3
|
+
)
|
|
4
|
+
from typing_extensions import (
|
|
5
|
+
Literal,
|
|
6
|
+
TypeVar,
|
|
7
|
+
override,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
_T = TypeVar("_T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NotGiven:
|
|
14
|
+
"""
|
|
15
|
+
Used to distinguish omitted keyword arguments from those passed explicitly
|
|
16
|
+
with the value None.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __bool__(self) -> Literal[False]:
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
@override
|
|
23
|
+
def __repr__(self) -> str:
|
|
24
|
+
return "NOT_GIVEN"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
NotGivenOr = Union[_T, NotGiven]
|
|
28
|
+
NOT_GIVEN = NotGiven()
|
magic_hour/core/utils.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
import httpx
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def remove_none_from_dict(
|
|
6
|
+
original: typing.Dict[str, typing.Optional[typing.Any]],
|
|
7
|
+
) -> typing.Dict[str, typing.Any]:
|
|
8
|
+
new: typing.Dict[str, typing.Any] = {}
|
|
9
|
+
for key, value in original.items():
|
|
10
|
+
if value is not None:
|
|
11
|
+
new[key] = value
|
|
12
|
+
return new
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_binary_content_type(content_type: str) -> bool:
|
|
16
|
+
"""Check if the content type indicates binary data."""
|
|
17
|
+
binary_types = [
|
|
18
|
+
"application/octet-stream",
|
|
19
|
+
"application/pdf",
|
|
20
|
+
"application/zip",
|
|
21
|
+
"image/",
|
|
22
|
+
"audio/",
|
|
23
|
+
"video/",
|
|
24
|
+
"application/msword",
|
|
25
|
+
"application/vnd.openxmlformats-officedocument",
|
|
26
|
+
"application/x-binary",
|
|
27
|
+
"application/vnd.ms-excel",
|
|
28
|
+
"application/vnd.ms-powerpoint",
|
|
29
|
+
]
|
|
30
|
+
return any(binary_type in content_type for binary_type in binary_types)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_content_type(headers: httpx.Headers) -> str:
|
|
34
|
+
"""Get content type in a case-insensitive manner."""
|
|
35
|
+
for key, value in headers.items():
|
|
36
|
+
if key.lower() == "content-type":
|
|
37
|
+
return value.lower()
|
|
38
|
+
return ""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
|
|
2
|
+
### create <a name="create"></a>
|
|
3
|
+
AI Clothes Changer
|
|
4
|
+
|
|
5
|
+
Change outfits in photos in seconds with just a photo reference. Each photo costs 25 frames.
|
|
6
|
+
|
|
7
|
+
**API Endpoint**: `POST /v1/ai-clothes-changer`
|
|
8
|
+
|
|
9
|
+
#### Synchronous Client
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from magic_hour import Client
|
|
13
|
+
from os import getenv
|
|
14
|
+
|
|
15
|
+
client = Client(token=getenv("API_TOKEN"))
|
|
16
|
+
res = client.v1.ai_clothes_changer.create(
|
|
17
|
+
assets={
|
|
18
|
+
"garment_file_path": "api-assets/id/outfit.png",
|
|
19
|
+
"garment_type": "dresses",
|
|
20
|
+
"person_file_path": "api-assets/id/model.png",
|
|
21
|
+
},
|
|
22
|
+
name="Clothes Changer image",
|
|
23
|
+
)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
#### Asynchronous Client
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from magic_hour import AsyncClient
|
|
30
|
+
from os import getenv
|
|
31
|
+
|
|
32
|
+
client = AsyncClient(token=getenv("API_TOKEN"))
|
|
33
|
+
res = await client.v1.ai_clothes_changer.create(
|
|
34
|
+
assets={
|
|
35
|
+
"garment_file_path": "api-assets/id/outfit.png",
|
|
36
|
+
"garment_type": "dresses",
|
|
37
|
+
"person_file_path": "api-assets/id/model.png",
|
|
38
|
+
},
|
|
39
|
+
name="Clothes Changer image",
|
|
40
|
+
)
|
|
41
|
+
```
|