llamagen-python 0.1.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.
llamagen/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ from ._client import LlamaGenClient
2
+ from ._errors import LlamaGenAPIError, LlamaGenTimeoutError, LlamaGenWebhookSignatureError
3
+ from ._types import SUPPORTED_COMIC_SIZES
4
+ from ._webhooks import construct_webhook_event, verify_webhook_signature
5
+
6
+ __all__ = [
7
+ "LlamaGenAPIError",
8
+ "LlamaGenClient",
9
+ "LlamaGenTimeoutError",
10
+ "LlamaGenWebhookSignatureError",
11
+ "SUPPORTED_COMIC_SIZES",
12
+ "construct_webhook_event",
13
+ "verify_webhook_signature",
14
+ ]
15
+
16
+ __version__ = "0.1.0"
llamagen/_client.py ADDED
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from ._http import HTTPClient, Transport
6
+ from .resources.animations import AnimationsResource
7
+ from .resources.comics import ComicsResource
8
+
9
+ DEFAULT_BASE_URL = "https://api.llamagen.ai/v1"
10
+ DEFAULT_TIMEOUT_MS = 30000
11
+ DEFAULT_MAX_RETRIES = 2
12
+ DEFAULT_RETRY_DELAY_MS = 500
13
+
14
+
15
+ class LlamaGenClient:
16
+ def __init__(
17
+ self,
18
+ *,
19
+ api_key: str,
20
+ base_url: str = DEFAULT_BASE_URL,
21
+ timeout_ms: int = DEFAULT_TIMEOUT_MS,
22
+ max_retries: int = DEFAULT_MAX_RETRIES,
23
+ retry_delay_ms: int = DEFAULT_RETRY_DELAY_MS,
24
+ transport: Optional[Transport] = None,
25
+ ) -> None:
26
+ if not api_key:
27
+ raise ValueError("`api_key` is required to initialize LlamaGenClient.")
28
+
29
+ http = HTTPClient(
30
+ api_key=api_key,
31
+ base_url=base_url,
32
+ timeout_ms=timeout_ms,
33
+ max_retries=max_retries,
34
+ retry_delay_ms=retry_delay_ms,
35
+ transport=transport,
36
+ )
37
+
38
+ comic = ComicsResource(http)
39
+ animation = AnimationsResource(http)
40
+
41
+ self.comic = comic
42
+ self.comics = comic
43
+ self.animation = animation
44
+ self.animations = animation
llamagen/_errors.py ADDED
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class LlamaGenAPIError(Exception):
7
+ """Raised when the LlamaGen API returns a non-2xx response."""
8
+
9
+ def __init__(self, message: str, status: int, data: Optional[Any] = None) -> None:
10
+ super().__init__(message)
11
+ self.status = status
12
+ self.data = data
13
+
14
+
15
+ class LlamaGenTimeoutError(TimeoutError):
16
+ """Raised when a request or polling operation times out."""
17
+
18
+
19
+ class LlamaGenWebhookSignatureError(ValueError):
20
+ """Raised when webhook signature verification fails."""
llamagen/_http.py ADDED
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import socket
5
+ import time
6
+ import urllib.error
7
+ import urllib.request
8
+ from typing import Any, Dict, Mapping, Optional, Protocol, Tuple
9
+
10
+ from ._errors import LlamaGenAPIError, LlamaGenTimeoutError
11
+
12
+
13
+ class Transport(Protocol):
14
+ def request(
15
+ self,
16
+ method: str,
17
+ url: str,
18
+ headers: Mapping[str, str],
19
+ body: Optional[bytes],
20
+ timeout: float,
21
+ ) -> Tuple[int, Mapping[str, str], bytes]:
22
+ ...
23
+
24
+
25
+ class UrllibTransport:
26
+ def request(
27
+ self,
28
+ method: str,
29
+ url: str,
30
+ headers: Mapping[str, str],
31
+ body: Optional[bytes],
32
+ timeout: float,
33
+ ) -> Tuple[int, Mapping[str, str], bytes]:
34
+ request = urllib.request.Request(url, data=body, method=method, headers=dict(headers))
35
+ try:
36
+ with urllib.request.urlopen(request, timeout=timeout) as response:
37
+ return response.status, dict(response.headers.items()), response.read()
38
+ except urllib.error.HTTPError as error:
39
+ return error.code, dict(error.headers.items()), error.read()
40
+
41
+
42
+ class HTTPClient:
43
+ def __init__(
44
+ self,
45
+ *,
46
+ api_key: str,
47
+ base_url: str,
48
+ timeout_ms: int,
49
+ max_retries: int,
50
+ retry_delay_ms: int,
51
+ transport: Optional[Transport] = None,
52
+ ) -> None:
53
+ self.api_key = api_key
54
+ self.base_url = base_url.rstrip("/")
55
+ self.timeout = timeout_ms / 1000
56
+ self.max_retries = max_retries
57
+ self.retry_delay = retry_delay_ms / 1000
58
+ self.transport = transport or UrllibTransport()
59
+
60
+ def request(
61
+ self,
62
+ path: str,
63
+ *,
64
+ method: str = "GET",
65
+ json_body: Optional[Mapping[str, Any]] = None,
66
+ body: Optional[bytes] = None,
67
+ headers: Optional[Mapping[str, str]] = None,
68
+ ) -> Any:
69
+ if json_body is not None and body is not None:
70
+ raise ValueError("Pass either json_body or body, not both.")
71
+
72
+ prepared_headers: Dict[str, str] = {"Authorization": f"Bearer {self.api_key}"}
73
+ if json_body is not None:
74
+ body = json.dumps(json_body, separators=(",", ":")).encode("utf-8")
75
+ prepared_headers["Content-Type"] = "application/json"
76
+ if headers:
77
+ prepared_headers.update(headers)
78
+
79
+ attempt = 0
80
+ url = f"{self.base_url}{path}"
81
+
82
+ while True:
83
+ try:
84
+ status, response_headers, response_body = self.transport.request(
85
+ method,
86
+ url,
87
+ prepared_headers,
88
+ body,
89
+ self.timeout,
90
+ )
91
+ except Exception as error:
92
+ if _is_timeout_error(error):
93
+ raise LlamaGenTimeoutError(f"Request timed out after {int(self.timeout * 1000)}ms") from error
94
+ raise
95
+
96
+ data = _decode_response(response_body, response_headers)
97
+ if 200 <= status < 300:
98
+ return data
99
+
100
+ if _should_retry(status) and attempt < self.max_retries:
101
+ attempt += 1
102
+ time.sleep(self.retry_delay * attempt)
103
+ continue
104
+
105
+ raise LlamaGenAPIError(f"LlamaGen API request failed with status {status}", status, data)
106
+
107
+
108
+ def _decode_response(body: bytes, headers: Mapping[str, str]) -> Any:
109
+ if not body:
110
+ return None
111
+
112
+ text = body.decode("utf-8", errors="replace")
113
+ content_type = ""
114
+ for key, value in headers.items():
115
+ if key.lower() == "content-type":
116
+ content_type = value
117
+ break
118
+
119
+ if "application/json" in content_type.lower():
120
+ return json.loads(text)
121
+
122
+ try:
123
+ return json.loads(text)
124
+ except json.JSONDecodeError:
125
+ return text
126
+
127
+
128
+ def _should_retry(status: int) -> bool:
129
+ return status == 429 or status >= 500
130
+
131
+
132
+ def _is_timeout_error(error: BaseException) -> bool:
133
+ if isinstance(error, (TimeoutError, socket.timeout)):
134
+ return True
135
+ if isinstance(error, urllib.error.URLError):
136
+ return isinstance(error.reason, (TimeoutError, socket.timeout))
137
+ return False
llamagen/_types.py ADDED
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence, TypedDict, Union
4
+
5
+
6
+ SUPPORTED_COMIC_SIZES = (
7
+ "1024x1024",
8
+ "512x768",
9
+ "512x1024",
10
+ "576x1024",
11
+ "768x1024",
12
+ "1024x768",
13
+ "768x512",
14
+ "1024x576",
15
+ "1024x512",
16
+ )
17
+
18
+ JSONValue = Union[None, bool, int, float, str, List["JSONValue"], Dict[str, "JSONValue"]]
19
+ Headers = Mapping[str, Union[str, Sequence[str], None]]
20
+ Params = MutableMapping[str, Any]
21
+
22
+
23
+ class ComicPagination(TypedDict, total=False):
24
+ totalPages: int
25
+ panelsPerPage: int
26
+
27
+
28
+ class ComicRole(TypedDict, total=False):
29
+ name: str
30
+ image: str
31
+ age: Union[int, str]
32
+ gender: str
33
+ clothing: str
34
+ description: str
35
+
36
+
37
+ class ComicLocation(TypedDict, total=False):
38
+ name: str
39
+ image: str
40
+ description: str
41
+
42
+
43
+ class ComicAttachment(TypedDict, total=False):
44
+ url: str
45
+ type: str
46
+
47
+
48
+ class CreateComicParams(TypedDict, total=False):
49
+ prompt: str
50
+ size: str
51
+ model: str
52
+ preset: str
53
+ style: str
54
+ promptUrl: str
55
+ fixPanelNum: int
56
+ pagination: ComicPagination
57
+ images: List[str]
58
+ comicRoles: List[ComicRole]
59
+ comicLocations: List[ComicLocation]
60
+ attachments: List[ComicAttachment]
61
+ language: str
62
+ upscale: Union[bool, str]
63
+
64
+
65
+ class ContinueComicParams(TypedDict, total=False):
66
+ prompt: str
67
+ fixPanelNum: int
68
+ pagination: ComicPagination
69
+ attachments: List[ComicAttachment]
70
+ images: List[str]
71
+
72
+
73
+ class UpdateComicPanelParams(TypedDict, total=False):
74
+ page: int
75
+ pageIndex: int
76
+ page_index: int
77
+ panel: int
78
+ panelIndex: int
79
+ panel_index: int
80
+ panelPrompt: str
81
+ prompt: str
82
+ panel_prompt: str
83
+ images: List[str]
84
+ images_url: Union[str, List[str]]
85
+ caption: str
86
+
87
+
88
+ class AnimationVideoOptions(TypedDict, total=False):
89
+ duration: int
90
+ resolution: str
91
+ aspect_ratio: str
92
+ image: str
93
+ last_frame_image: str
94
+ reference_images: List[str]
95
+ reference_videos: List[str]
96
+ reference_audios: List[str]
97
+ generate_audio: bool
98
+ seed: int
99
+
100
+
101
+ class CreateAnimationParams(TypedDict, total=False):
102
+ prompt: str
103
+ videoOptions: AnimationVideoOptions
104
+
105
+
106
+ class WaitOptions(TypedDict, total=False):
107
+ interval_ms: int
108
+ timeout_ms: int
109
+ done_statuses: Sequence[str]
110
+
111
+
112
+ ComicArtworkResponse = Dict[str, Any]
113
+ AnimationArtworkResponse = Dict[str, Any]
114
+ ComicUsage = Dict[str, Any]
115
+ ComicUploadResponse = Dict[str, Any]
116
+ WebhookEvent = Dict[str, Any]
117
+
118
+
119
+ __all__ = [
120
+ "AnimationArtworkResponse",
121
+ "AnimationVideoOptions",
122
+ "ComicArtworkResponse",
123
+ "ComicAttachment",
124
+ "ComicLocation",
125
+ "ComicPagination",
126
+ "ComicRole",
127
+ "ComicUploadResponse",
128
+ "ComicUsage",
129
+ "ContinueComicParams",
130
+ "CreateAnimationParams",
131
+ "CreateComicParams",
132
+ "Headers",
133
+ "Params",
134
+ "SUPPORTED_COMIC_SIZES",
135
+ "UpdateComicPanelParams",
136
+ "WaitOptions",
137
+ "WebhookEvent",
138
+ ]
llamagen/_webhooks.py ADDED
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import time
7
+ from typing import Any, Mapping, Optional, Sequence, Union
8
+
9
+ from ._errors import LlamaGenWebhookSignatureError
10
+ from ._types import Headers, WebhookEvent
11
+
12
+ DEFAULT_TOLERANCE_SECONDS = 300
13
+ Payload = Union[str, bytes, bytearray]
14
+
15
+
16
+ def verify_webhook_signature(
17
+ payload: Payload,
18
+ headers: Headers,
19
+ secret: str,
20
+ *,
21
+ tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
22
+ now: Optional[int] = None,
23
+ ) -> bool:
24
+ try:
25
+ _assert_valid_signature(
26
+ payload,
27
+ headers,
28
+ secret,
29
+ tolerance_seconds=tolerance_seconds,
30
+ now=now,
31
+ )
32
+ return True
33
+ except LlamaGenWebhookSignatureError:
34
+ return False
35
+
36
+
37
+ def construct_webhook_event(
38
+ payload: Payload,
39
+ headers: Headers,
40
+ secret: str,
41
+ *,
42
+ tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
43
+ now: Optional[int] = None,
44
+ ) -> WebhookEvent:
45
+ _assert_valid_signature(
46
+ payload,
47
+ headers,
48
+ secret,
49
+ tolerance_seconds=tolerance_seconds,
50
+ now=now,
51
+ )
52
+ body = _payload_bytes(payload).decode("utf-8")
53
+ event = json.loads(body)
54
+ request_id = _get_header(headers, "x-llama-webhook-request-id")
55
+ event_id = event.get("id") or _get_header(headers, "x-llama-webhook-id")
56
+ if event_id:
57
+ event["id"] = event_id
58
+ if request_id:
59
+ event["requestId"] = request_id
60
+ return event
61
+
62
+
63
+ def _assert_valid_signature(
64
+ payload: Payload,
65
+ headers: Headers,
66
+ secret: str,
67
+ *,
68
+ tolerance_seconds: int,
69
+ now: Optional[int],
70
+ ) -> None:
71
+ if not secret:
72
+ raise LlamaGenWebhookSignatureError("Webhook secret is required.")
73
+
74
+ timestamp = _get_header(headers, "x-llama-webhook-timestamp")
75
+ signature = _get_header(headers, "x-llama-webhook-signature")
76
+ if not timestamp or not signature:
77
+ raise LlamaGenWebhookSignatureError("Missing LlamaGen webhook signature headers.")
78
+
79
+ try:
80
+ parsed_timestamp = int(timestamp)
81
+ except ValueError as error:
82
+ raise LlamaGenWebhookSignatureError("Invalid LlamaGen webhook timestamp.") from error
83
+
84
+ current_time = int(time.time()) if now is None else now
85
+ if tolerance_seconds > 0 and abs(current_time - parsed_timestamp) > tolerance_seconds:
86
+ raise LlamaGenWebhookSignatureError("LlamaGen webhook timestamp is outside tolerance.")
87
+
88
+ expected = _sign_payload(payload, secret, timestamp)
89
+ candidates = [
90
+ part.strip().removeprefix("v1=")
91
+ for part in signature.split(",")
92
+ if part.strip()
93
+ ]
94
+ if not any(hmac.compare_digest(candidate, expected) for candidate in candidates):
95
+ raise LlamaGenWebhookSignatureError("No matching LlamaGen webhook signature found.")
96
+
97
+
98
+ def _sign_payload(payload: Payload, secret: str, timestamp: str) -> str:
99
+ body = _payload_bytes(payload)
100
+ signer = hmac.new(secret.encode("utf-8"), digestmod=hashlib.sha256)
101
+ signer.update(f"{timestamp}.".encode("utf-8"))
102
+ signer.update(body)
103
+ return signer.hexdigest()
104
+
105
+
106
+ def _payload_bytes(payload: Payload) -> bytes:
107
+ if isinstance(payload, bytes):
108
+ return payload
109
+ if isinstance(payload, bytearray):
110
+ return bytes(payload)
111
+ return payload.encode("utf-8")
112
+
113
+
114
+ def _get_header(headers: Headers, name: str) -> Optional[str]:
115
+ lower = name.lower()
116
+ getter = getattr(headers, "get", None)
117
+ if callable(getter):
118
+ direct = getter(name) or getter(lower)
119
+ if direct is not None:
120
+ return _header_value_to_string(direct)
121
+
122
+ if isinstance(headers, Mapping):
123
+ for key, value in headers.items():
124
+ if key.lower() == lower:
125
+ return _header_value_to_string(value)
126
+ return None
127
+
128
+
129
+ def _header_value_to_string(value: Union[str, Sequence[str], None]) -> Optional[str]:
130
+ if value is None:
131
+ return None
132
+ if isinstance(value, str):
133
+ return value
134
+ return ",".join(value)
135
+
136
+
137
+ verifyWebhookSignature = verify_webhook_signature
138
+ constructWebhookEvent = construct_webhook_event
llamagen/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,4 @@
1
+ from .animations import AnimationsResource
2
+ from .comics import ComicsResource
3
+
4
+ __all__ = ["AnimationsResource", "ComicsResource"]
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Mapping, Optional, Sequence
5
+
6
+ from .._errors import LlamaGenTimeoutError
7
+ from .._http import HTTPClient
8
+ from .._types import AnimationArtworkResponse
9
+
10
+ DEFAULT_DONE_STATUSES = ("SUCCEEDED", "FAILED", "PROCESSED", "COMPLETED", "CANCELLED")
11
+
12
+
13
+ class AnimationsResource:
14
+ def __init__(self, http: HTTPClient) -> None:
15
+ self._http = http
16
+
17
+ def create(self, params: Mapping[str, object]) -> AnimationArtworkResponse:
18
+ _assert_non_empty(params.get("prompt") if params else None, "`prompt` is required and must be a non-empty string.")
19
+ return self._http.request("/artworks/generations", method="POST", json_body=params)
20
+
21
+ def get(self, artwork_id: str) -> AnimationArtworkResponse:
22
+ _assert_non_empty(artwork_id, "`artwork_id` is required and must be a non-empty string.")
23
+ return self._http.request(f"/artworks/generations/{artwork_id}", method="GET")
24
+
25
+ def wait_for_completion(
26
+ self,
27
+ artwork_id: str,
28
+ *,
29
+ interval_ms: int = 5000,
30
+ timeout_ms: int = 300000,
31
+ done_statuses: Optional[Sequence[str]] = None,
32
+ ) -> AnimationArtworkResponse:
33
+ done = {status.upper() for status in (done_statuses or DEFAULT_DONE_STATUSES)}
34
+ start = time.monotonic()
35
+
36
+ while True:
37
+ result = self.get(artwork_id)
38
+ if str(result.get("status", "")).upper() in done:
39
+ return result
40
+
41
+ if (time.monotonic() - start) * 1000 >= timeout_ms:
42
+ raise LlamaGenTimeoutError(
43
+ f"Artwork {artwork_id} did not reach a completed status within {timeout_ms}ms"
44
+ )
45
+
46
+ time.sleep(interval_ms / 1000)
47
+
48
+ def create_and_wait(
49
+ self,
50
+ params: Mapping[str, object],
51
+ *,
52
+ interval_ms: int = 5000,
53
+ timeout_ms: int = 300000,
54
+ done_statuses: Optional[Sequence[str]] = None,
55
+ ) -> AnimationArtworkResponse:
56
+ created = self.create(params)
57
+ return self.wait_for_completion(
58
+ created["id"],
59
+ interval_ms=interval_ms,
60
+ timeout_ms=timeout_ms,
61
+ done_statuses=done_statuses,
62
+ )
63
+
64
+ waitForCompletion = wait_for_completion
65
+ createAndWait = create_and_wait
66
+
67
+
68
+ def _assert_non_empty(value: object, message: str) -> None:
69
+ if not isinstance(value, str) or not value.strip():
70
+ raise TypeError(message)
@@ -0,0 +1,308 @@
1
+ from __future__ import annotations
2
+
3
+ import concurrent.futures
4
+ import mimetypes
5
+ import os
6
+ import time
7
+ import uuid
8
+ from pathlib import Path
9
+ from typing import Any, BinaryIO, Dict, Iterable, List, Mapping, Optional, Sequence, Union
10
+ from urllib.parse import urlencode
11
+
12
+ from .._errors import LlamaGenTimeoutError
13
+ from .._http import HTTPClient
14
+ from .._types import ComicArtworkResponse, ComicUploadResponse, ComicUsage, SUPPORTED_COMIC_SIZES
15
+
16
+ DEFAULT_SIZE = "1024x1024"
17
+ DEFAULT_PRESET = "neutral"
18
+ DEFAULT_DONE_STATUSES = ("SUCCEEDED", "FAILED", "PROCESSED", "COMPLETED", "CANCELLED")
19
+ SUPPORTED_COMIC_SIZE_SET = set(SUPPORTED_COMIC_SIZES)
20
+ FileInput = Union[str, os.PathLike[str], bytes, bytearray, BinaryIO]
21
+
22
+
23
+ class ComicsResource:
24
+ def __init__(self, http: HTTPClient) -> None:
25
+ self._http = http
26
+
27
+ def create(self, params: Mapping[str, Any]) -> ComicArtworkResponse:
28
+ _assert_non_empty(params.get("prompt") if params else None, "`prompt` is required and must be a non-empty string.")
29
+ if params.get("size") is not None:
30
+ _assert_supported_size(str(params["size"]))
31
+ _assert_layout_options(params)
32
+
33
+ body: Dict[str, Any] = {"preset": DEFAULT_PRESET, "size": DEFAULT_SIZE}
34
+ body.update(params)
35
+ return self._http.request("/comics/generations", method="POST", json_body=body)
36
+
37
+ def get(self, artwork_id: str, *, page: Optional[int] = None, panel: Optional[int] = None) -> ComicArtworkResponse:
38
+ _assert_non_empty(artwork_id, "`artwork_id` is required and must be a non-empty string.")
39
+ query: Dict[str, int] = {}
40
+ if page is not None:
41
+ _assert_non_negative_integer(page, "`page` must be a non-negative integer.")
42
+ query["page"] = page
43
+ if panel is not None:
44
+ _assert_non_negative_integer(panel, "`panel` must be a non-negative integer.")
45
+ query["panel"] = panel
46
+
47
+ suffix = f"?{urlencode(query)}" if query else ""
48
+ return self._http.request(f"/comics/generations/{artwork_id}{suffix}", method="GET")
49
+
50
+ def continue_write(self, generation_id: str, params: Mapping[str, Any]) -> ComicArtworkResponse:
51
+ _assert_non_empty(generation_id, "`generation_id` is required and must be a non-empty string.")
52
+ _assert_non_empty(params.get("prompt") if params else None, "`prompt` is required and must be a non-empty string.")
53
+ _assert_layout_options(params)
54
+ body = dict(params)
55
+ body["action"] = "continueWrite"
56
+ return self._http.request(f"/comics/generations/{generation_id}", method="PATCH", json_body=body)
57
+
58
+ def update_panel(self, generation_id: str, params: Mapping[str, Any]) -> ComicArtworkResponse:
59
+ _assert_non_empty(generation_id, "`generation_id` is required and must be a non-empty string.")
60
+ _assert_panel_locator(params)
61
+ _assert_panel_update_payload(params)
62
+ body = dict(params)
63
+ body["action"] = "regeneratePanel"
64
+ return self._http.request(f"/comics/generations/{generation_id}", method="PATCH", json_body=body)
65
+
66
+ def usage(self) -> ComicUsage:
67
+ return self._http.request("/comics/usage", method="GET")
68
+
69
+ def upload(
70
+ self,
71
+ file: FileInput,
72
+ filename: Optional[str] = None,
73
+ *,
74
+ content_type: Optional[str] = None,
75
+ ) -> ComicUploadResponse:
76
+ payload, resolved_name = _read_file_input(file, filename)
77
+ multipart_body, multipart_content_type = _build_multipart(
78
+ field_name="file",
79
+ filename=resolved_name,
80
+ payload=payload,
81
+ content_type=content_type or mimetypes.guess_type(resolved_name)[0] or "application/octet-stream",
82
+ )
83
+ return self._http.request(
84
+ "/comics/upload",
85
+ method="POST",
86
+ body=multipart_body,
87
+ headers={"Content-Type": multipart_content_type},
88
+ )
89
+
90
+ def wait_for_completion(
91
+ self,
92
+ artwork_id: str,
93
+ *,
94
+ interval_ms: int = 5000,
95
+ timeout_ms: int = 180000,
96
+ done_statuses: Optional[Sequence[str]] = None,
97
+ ) -> ComicArtworkResponse:
98
+ done = {status.upper() for status in (done_statuses or DEFAULT_DONE_STATUSES)}
99
+ start = time.monotonic()
100
+
101
+ while True:
102
+ result = self.get(artwork_id)
103
+ if str(result.get("status", "")).upper() in done:
104
+ return result
105
+
106
+ if (time.monotonic() - start) * 1000 >= timeout_ms:
107
+ raise LlamaGenTimeoutError(
108
+ f"Artwork {artwork_id} did not reach a completed status within {timeout_ms}ms"
109
+ )
110
+
111
+ time.sleep(interval_ms / 1000)
112
+
113
+ def create_and_wait(
114
+ self,
115
+ params: Mapping[str, Any],
116
+ *,
117
+ interval_ms: int = 5000,
118
+ timeout_ms: int = 180000,
119
+ done_statuses: Optional[Sequence[str]] = None,
120
+ ) -> ComicArtworkResponse:
121
+ created = self.create(params)
122
+ return self.wait_for_completion(
123
+ created["id"],
124
+ interval_ms=interval_ms,
125
+ timeout_ms=timeout_ms,
126
+ done_statuses=done_statuses,
127
+ )
128
+
129
+ def create_batch(
130
+ self,
131
+ params_list: Sequence[Mapping[str, Any]],
132
+ *,
133
+ concurrency: int = 3,
134
+ stop_on_error: bool = False,
135
+ ) -> List[Dict[str, Any]]:
136
+ if not params_list:
137
+ raise TypeError("`params_list` must be a non-empty sequence.")
138
+
139
+ def worker(params: Mapping[str, Any]) -> Dict[str, Any]:
140
+ try:
141
+ return {"input": params, "result": self.create(params)}
142
+ except Exception as error:
143
+ if stop_on_error:
144
+ raise
145
+ return {"input": params, "error": error}
146
+
147
+ return _run_with_concurrency(params_list, max(1, concurrency), worker)
148
+
149
+ def wait_for_many(
150
+ self,
151
+ artwork_ids: Sequence[str],
152
+ *,
153
+ concurrency: int = 3,
154
+ interval_ms: int = 5000,
155
+ timeout_ms: int = 180000,
156
+ done_statuses: Optional[Sequence[str]] = None,
157
+ ) -> List[ComicArtworkResponse]:
158
+ if not artwork_ids:
159
+ raise TypeError("`artwork_ids` must be a non-empty sequence.")
160
+
161
+ def worker(artwork_id: str) -> ComicArtworkResponse:
162
+ return self.wait_for_completion(
163
+ artwork_id,
164
+ interval_ms=interval_ms,
165
+ timeout_ms=timeout_ms,
166
+ done_statuses=done_statuses,
167
+ )
168
+
169
+ return _run_with_concurrency(artwork_ids, max(1, concurrency), worker)
170
+
171
+ createComic = create
172
+ getComic = get
173
+ continueComic = continue_write
174
+ continueWrite = continue_write
175
+ regeneratePanel = update_panel
176
+ updateComicPanel = update_panel
177
+ updatePanel = update_panel
178
+ waitForCompletion = wait_for_completion
179
+ createAndWait = create_and_wait
180
+ createBatch = create_batch
181
+ waitForMany = wait_for_many
182
+
183
+
184
+ def _run_with_concurrency(items: Sequence[Any], concurrency: int, worker: Any) -> List[Any]:
185
+ results: List[Any] = [None] * len(items)
186
+ with concurrent.futures.ThreadPoolExecutor(max_workers=min(concurrency, len(items))) as executor:
187
+ future_to_index = {executor.submit(worker, item): index for index, item in enumerate(items)}
188
+ for future in concurrent.futures.as_completed(future_to_index):
189
+ results[future_to_index[future]] = future.result()
190
+ return results
191
+
192
+
193
+ def _read_file_input(file: FileInput, filename: Optional[str]) -> tuple[bytes, str]:
194
+ if isinstance(file, (str, os.PathLike)):
195
+ path = Path(file)
196
+ return path.read_bytes(), filename or path.name
197
+ if isinstance(file, (bytes, bytearray)):
198
+ return bytes(file), filename or "upload"
199
+ data = file.read()
200
+ if isinstance(data, str):
201
+ data = data.encode("utf-8")
202
+ resolved_name = filename or Path(getattr(file, "name", "upload")).name
203
+ return data, resolved_name
204
+
205
+
206
+ def _build_multipart(*, field_name: str, filename: str, payload: bytes, content_type: str) -> tuple[bytes, str]:
207
+ boundary = f"----llamagen-{uuid.uuid4().hex}"
208
+ chunks = [
209
+ f"--{boundary}\r\n".encode("utf-8"),
210
+ (
211
+ f'Content-Disposition: form-data; name="{field_name}"; filename="{filename}"\r\n'
212
+ f"Content-Type: {content_type}\r\n\r\n"
213
+ ).encode("utf-8"),
214
+ payload,
215
+ b"\r\n",
216
+ f"--{boundary}--\r\n".encode("utf-8"),
217
+ ]
218
+ return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
219
+
220
+
221
+ def _assert_supported_size(value: str) -> None:
222
+ if value not in SUPPORTED_COMIC_SIZE_SET:
223
+ raise TypeError(f"`size` must be one of: {', '.join(SUPPORTED_COMIC_SIZES)}. Received: {value}")
224
+
225
+
226
+ def _assert_layout_options(params: Mapping[str, Any]) -> None:
227
+ if params.get("fixPanelNum") is not None and params.get("pagination") is not None:
228
+ raise TypeError("`fixPanelNum` and `pagination` cannot be used together.")
229
+
230
+ if params.get("fixPanelNum") is not None:
231
+ _assert_integer_in_range(params["fixPanelNum"], 1, 20, "`fixPanelNum` must be an integer from 1 to 20.")
232
+
233
+ if params.get("pagination") is not None:
234
+ pagination = params["pagination"]
235
+ if not isinstance(pagination, Mapping):
236
+ raise TypeError("`pagination` must be a mapping.")
237
+ _assert_integer_in_range(
238
+ pagination.get("totalPages"),
239
+ 1,
240
+ 20,
241
+ "`pagination.totalPages` must be an integer from 1 to 20.",
242
+ )
243
+ _assert_integer_in_range(
244
+ pagination.get("panelsPerPage"),
245
+ 1,
246
+ 20,
247
+ "`pagination.panelsPerPage` must be an integer from 1 to 20.",
248
+ )
249
+
250
+
251
+ def _assert_panel_locator(params: Mapping[str, Any]) -> None:
252
+ panel = _first_defined(params, "panel", "panelIndex", "panel_index")
253
+ if panel is None:
254
+ raise TypeError("`panel`, `panelIndex`, or `panel_index` is required.")
255
+ _assert_non_negative_integer(panel, "`panel` must be a non-negative integer.")
256
+
257
+ page = _first_defined(params, "page", "pageIndex", "page_index")
258
+ if page is not None:
259
+ _assert_non_negative_integer(page, "`page` must be a non-negative integer.")
260
+
261
+
262
+ def _assert_panel_update_payload(params: Mapping[str, Any]) -> None:
263
+ has_payload = (
264
+ _has_non_empty_string(params.get("panelPrompt"))
265
+ or _has_non_empty_string(params.get("prompt"))
266
+ or _has_non_empty_string(params.get("panel_prompt"))
267
+ or _has_non_empty_string(params.get("caption"))
268
+ or _has_images(params.get("images"))
269
+ or _has_image_alias(params.get("images_url"))
270
+ )
271
+ if not has_payload:
272
+ raise TypeError("One of `panelPrompt`, `prompt`, `images`, or `caption` is required.")
273
+
274
+
275
+ def _first_defined(params: Mapping[str, Any], *keys: str) -> Any:
276
+ for key in keys:
277
+ if params.get(key) is not None:
278
+ return params[key]
279
+ return None
280
+
281
+
282
+ def _assert_non_empty(value: object, message: str) -> None:
283
+ if not isinstance(value, str) or not value.strip():
284
+ raise TypeError(message)
285
+
286
+
287
+ def _assert_non_negative_integer(value: object, message: str) -> None:
288
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
289
+ raise TypeError(message)
290
+
291
+
292
+ def _assert_integer_in_range(value: object, minimum: int, maximum: int, message: str) -> None:
293
+ if not isinstance(value, int) or isinstance(value, bool) or value < minimum or value > maximum:
294
+ raise TypeError(message)
295
+
296
+
297
+ def _has_non_empty_string(value: object) -> bool:
298
+ return isinstance(value, str) and bool(value.strip())
299
+
300
+
301
+ def _has_images(value: object) -> bool:
302
+ return isinstance(value, Iterable) and not isinstance(value, (str, bytes, bytearray, Mapping)) and any(
303
+ _has_non_empty_string(item) for item in value
304
+ )
305
+
306
+
307
+ def _has_image_alias(value: object) -> bool:
308
+ return _has_non_empty_string(value) or _has_images(value)
@@ -0,0 +1,199 @@
1
+ Metadata-Version: 2.4
2
+ Name: llamagen-python
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for LlamaGen Comic API and Animation API
5
+ Project-URL: Homepage, https://llamagen.ai/comic-api
6
+ Project-URL: Documentation, https://llamagen.ai/comic-api/docs
7
+ Project-URL: Source, https://github.com/LlamaGenAI/llamagen-python
8
+ Project-URL: Issues, https://github.com/LlamaGenAI/llamagen-python/issues
9
+ Author-email: "llamagen.ai" <support@llamagen.ai>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai,ai comic api,animation,comic,image-to-video,llamagen,sdk,text-to-video
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+
26
+ # llamagen-python
27
+
28
+ Official Python SDK for LlamaGen's Comic API and Animation API.
29
+
30
+ Homepage: <https://llamagen.ai/comic-api>
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install llamagen-python
36
+ ```
37
+
38
+ ## Quick Start
39
+
40
+ ```python
41
+ import os
42
+ from llamagen import LlamaGenClient
43
+
44
+ llamagen = LlamaGenClient(api_key=os.environ["LLAMAGEN_API_KEY"])
45
+
46
+ created = llamagen.comic.create({
47
+ "prompt": "A 4-panel comic about Leo finding a glowing key in a quiet library.",
48
+ "style": "manga",
49
+ "fixPanelNum": 4,
50
+ })
51
+
52
+ done = llamagen.comic.wait_for_completion(created["id"])
53
+ print(done["status"], done.get("comics"))
54
+ ```
55
+
56
+ ## Client
57
+
58
+ ```python
59
+ llamagen = LlamaGenClient(
60
+ api_key="YOUR_API_KEY",
61
+ base_url="https://api.llamagen.ai/v1",
62
+ timeout_ms=30000,
63
+ max_retries=2,
64
+ retry_delay_ms=500,
65
+ )
66
+ ```
67
+
68
+ The SDK uses Python's standard library HTTP stack and does not require runtime
69
+ dependencies.
70
+
71
+ ## Comic API
72
+
73
+ Use `llamagen.comic` to create comics, manga, webtoons, storyboards,
74
+ consistent-character pages, and panel edits.
75
+
76
+ ```python
77
+ generation = llamagen.comic.create({
78
+ "prompt": "The Little Prince meets the Fox in a luminous desert.",
79
+ "style": "storybook manga",
80
+ "size": "1024x1024",
81
+ "pagination": {"totalPages": 2, "panelsPerPage": 4},
82
+ "comicRoles": [
83
+ {
84
+ "name": "The Little Prince",
85
+ "image": "https://example.com/prince.png",
86
+ "clothing": "green coat and yellow scarf",
87
+ }
88
+ ],
89
+ "attachments": [{"type": "image", "url": "https://example.com/reference.png"}],
90
+ "language": "en",
91
+ "upscale": "2K",
92
+ })
93
+ ```
94
+
95
+ Comic methods:
96
+
97
+ - `llamagen.comic.create(params)` calls `POST /v1/comics/generations`.
98
+ - `llamagen.comic.get(id, page=None, panel=None)` calls `GET /v1/comics/generations/{id}`.
99
+ - `llamagen.comic.continue_write(id, params)` extends an existing comic.
100
+ - `llamagen.comic.update_panel(id, params)` regenerates one panel.
101
+ - `llamagen.comic.usage()` calls `GET /v1/comics/usage`.
102
+ - `llamagen.comic.upload(file, filename=None)` calls `POST /v1/comics/upload`.
103
+ - `llamagen.comic.wait_for_completion(id, **options)` polls until a terminal status.
104
+ - `llamagen.comic.create_and_wait(params, **options)` creates and polls.
105
+ - `llamagen.comic.create_batch(params_list, concurrency=3)` submits many jobs.
106
+ - `llamagen.comic.wait_for_many(ids, concurrency=3)` polls many jobs.
107
+
108
+ JavaScript-style aliases are also available: `createComic`, `getComic`,
109
+ `continueComic`, `regeneratePanel`, `updateComicPanel`, `waitForCompletion`,
110
+ `createAndWait`, and `waitForMany`.
111
+
112
+ ## Animation API
113
+
114
+ ```python
115
+ video = llamagen.animation.create({
116
+ "prompt": "A heroic fox detective walks through neon rain, cinematic camera move.",
117
+ "videoOptions": {
118
+ "duration": 5,
119
+ "resolution": "720p",
120
+ "aspect_ratio": "16:9",
121
+ "image": "https://example.com/first-frame.png",
122
+ "last_frame_image": "https://example.com/last-frame.png",
123
+ "reference_images": ["https://example.com/character.png"],
124
+ },
125
+ })
126
+
127
+ finished = llamagen.animation.wait_for_completion(
128
+ video["id"],
129
+ interval_ms=5000,
130
+ timeout_ms=300000,
131
+ )
132
+ ```
133
+
134
+ Animation methods:
135
+
136
+ - `llamagen.animation.create(params)` calls `POST /v1/artworks/generations`.
137
+ - `llamagen.animation.get(id)` calls `GET /v1/artworks/generations/{id}`.
138
+ - `llamagen.animation.wait_for_completion(id, **options)` polls video status.
139
+ - `llamagen.animation.create_and_wait(params, **options)` creates and polls.
140
+
141
+ `llamagen.animations` is an alias for `llamagen.animation`.
142
+
143
+ ## Webhooks
144
+
145
+ ```python
146
+ from llamagen import construct_webhook_event
147
+
148
+ event = construct_webhook_event(
149
+ payload=raw_body,
150
+ headers=request_headers,
151
+ secret=os.environ["LLAMAGEN_WEBHOOK_SECRET"],
152
+ )
153
+ ```
154
+
155
+ The helper verifies `X-Llama-Webhook-Timestamp` and
156
+ `X-Llama-Webhook-Signature` with HMAC SHA-256 and a default 5-minute tolerance.
157
+
158
+ ## Publishing
159
+
160
+ Detailed release notes are in [`docs/RELEASE.md`](docs/RELEASE.md).
161
+
162
+ 1. Update `version` in `pyproject.toml`.
163
+ 2. Run tests and packaging checks:
164
+
165
+ ```bash
166
+ python -m unittest discover -s tests
167
+ python -m pip install --upgrade build twine
168
+ python -m build
169
+ python -m twine check dist/*
170
+ ```
171
+
172
+ 3. Publish to TestPyPI first:
173
+
174
+ ```bash
175
+ python -m twine upload --repository testpypi dist/*
176
+ ```
177
+
178
+ 4. Install from TestPyPI in a clean environment and smoke-test:
179
+
180
+ ```bash
181
+ python -m pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ llamagen-python
182
+ ```
183
+
184
+ 5. Publish to PyPI:
185
+
186
+ ```bash
187
+ python -m twine upload dist/*
188
+ ```
189
+
190
+ Use a PyPI API token through `TWINE_USERNAME=__token__` and
191
+ `TWINE_PASSWORD=pypi-...` or configure a trusted publisher in PyPI for GitHub
192
+ Actions.
193
+
194
+ ## Local Dev
195
+
196
+ ```bash
197
+ python -m unittest discover -s tests
198
+ python -m compileall src tests
199
+ ```
@@ -0,0 +1,14 @@
1
+ llamagen/__init__.py,sha256=osyUmWp3zU978J28oTJyF2mkS9F_hczoDOhRLHYaghc,483
2
+ llamagen/_client.py,sha256=V7t7dQF4aHWpIhxaXpa8KxTpfAmMbd7AGgWJzNLF2KM,1238
3
+ llamagen/_errors.py,sha256=R00heV5tr1iLyA2ICjFYG0PA9uhEdqkjdHdiFgWHYGc,568
4
+ llamagen/_http.py,sha256=Pq80dU768eW6KXINMFe3AC6cXnlA-fePrNbbDhkuK1o,4177
5
+ llamagen/_types.py,sha256=o7dYuEA6w9m12vFFOWOafH_bPotAHiWvVhmCO0o8tTw,2909
6
+ llamagen/_webhooks.py,sha256=wqW1ZDgJzkw5ntgSRcVf5Nu8FO9QpbcKH0YEuW9m_PA,4073
7
+ llamagen/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
8
+ llamagen/resources/__init__.py,sha256=UEj0KBEmRdLJ7YIrGQU9EA5e0HMcVj93ilje_jBHnpY,130
9
+ llamagen/resources/animations.py,sha256=7jAU4j3JRq37wsRyWzByvtL72o_Uc8H1pMeuAp7hr_k,2444
10
+ llamagen/resources/comics.py,sha256=G5O7uDhotS693YhcZZNGU8W6M6rGEItGd2LCVtLdg7E,11871
11
+ llamagen_python-0.1.0.dist-info/METADATA,sha256=fC2rahmB1SnjedNghaq4ren9Oct5b2cUk1E8swOUpUc,5875
12
+ llamagen_python-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
13
+ llamagen_python-0.1.0.dist-info/licenses/LICENSE,sha256=sXRfe02p4AEZbNiSUd4r4V8peINjbArVYj921Lx7M-U,1068
14
+ llamagen_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 llamagen.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.