comfy-sdk 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.
- comfy_low/__init__.py +86 -0
- comfy_low/_multipart.py +99 -0
- comfy_low/errors.py +141 -0
- comfy_low/models/__init__.py +47 -0
- comfy_low/models/_generated.py +257 -0
- comfy_low/sse.py +70 -0
- comfy_low/transport.py +572 -0
- comfy_sdk/__init__.py +95 -0
- comfy_sdk/_core.py +93 -0
- comfy_sdk/_hashing.py +42 -0
- comfy_sdk/assets.py +303 -0
- comfy_sdk/client.py +177 -0
- comfy_sdk/events.py +126 -0
- comfy_sdk/exceptions.py +146 -0
- comfy_sdk/jobs.py +232 -0
- comfy_sdk/outputs.py +127 -0
- comfy_sdk/workflows.py +50 -0
- comfy_sdk-0.1.0.dist-info/METADATA +235 -0
- comfy_sdk-0.1.0.dist-info/RECORD +20 -0
- comfy_sdk-0.1.0.dist-info/WHEEL +4 -0
comfy_low/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""``comfy_low`` — generated + thin-transport protocol bindings for Comfy API v2.
|
|
2
|
+
|
|
3
|
+
Two parts:
|
|
4
|
+
|
|
5
|
+
* ``comfy_low.models`` — pydantic v2 models generated from ``spec/openapi.yaml``
|
|
6
|
+
(do not hand-edit; regenerate with ``scripts/gen_models.sh``).
|
|
7
|
+
* ``comfy_low.transport`` — a hand-written thin ``httpx`` transport (sync +
|
|
8
|
+
async) with one function per ``operationId`` and the mandatory escape hatches
|
|
9
|
+
(raw response, streaming bodies, all headers, per-request timeout/abort).
|
|
10
|
+
|
|
11
|
+
This layer is deliberately boring: no orchestration, retries, hashing, or SSE
|
|
12
|
+
reconnection. Those live in ``comfy_sdk``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from . import models
|
|
18
|
+
from .errors import (
|
|
19
|
+
ApiError,
|
|
20
|
+
BlobNotFound,
|
|
21
|
+
Forbidden,
|
|
22
|
+
HashMismatch,
|
|
23
|
+
IdempotencyKeyReuse,
|
|
24
|
+
InsufficientCredits,
|
|
25
|
+
InvalidWorkflow,
|
|
26
|
+
MissingAsset,
|
|
27
|
+
NotFound,
|
|
28
|
+
QueueFull,
|
|
29
|
+
Unauthorized,
|
|
30
|
+
WorkflowFormatUi,
|
|
31
|
+
error_from_envelope,
|
|
32
|
+
)
|
|
33
|
+
from .sse import RawEvent, SSEDecoder
|
|
34
|
+
from .transport import AsyncComfyLow, ComfyLow
|
|
35
|
+
|
|
36
|
+
# The exact set of operationIds the transport must cover; the spec-coverage test
|
|
37
|
+
# asserts this equals the set of operationIds in spec/openapi.yaml.
|
|
38
|
+
OPERATION_IDS: frozenset[str] = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"postAssets",
|
|
41
|
+
"assetFromHash",
|
|
42
|
+
"headAssetByHash",
|
|
43
|
+
"getAsset",
|
|
44
|
+
"getAssetContent",
|
|
45
|
+
"postJobs",
|
|
46
|
+
"getJob",
|
|
47
|
+
"getJobEvents",
|
|
48
|
+
"cancelJob",
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# operationId -> transport method name (same mapping for sync and async).
|
|
53
|
+
OPERATION_METHODS: dict[str, str] = {
|
|
54
|
+
"postAssets": "post_assets",
|
|
55
|
+
"assetFromHash": "asset_from_hash",
|
|
56
|
+
"headAssetByHash": "head_asset_by_hash",
|
|
57
|
+
"getAsset": "get_asset",
|
|
58
|
+
"getAssetContent": "get_asset_content",
|
|
59
|
+
"postJobs": "post_jobs",
|
|
60
|
+
"getJob": "get_job",
|
|
61
|
+
"getJobEvents": "get_job_events",
|
|
62
|
+
"cancelJob": "cancel_job",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"models",
|
|
67
|
+
"ComfyLow",
|
|
68
|
+
"AsyncComfyLow",
|
|
69
|
+
"RawEvent",
|
|
70
|
+
"SSEDecoder",
|
|
71
|
+
"ApiError",
|
|
72
|
+
"InvalidWorkflow",
|
|
73
|
+
"WorkflowFormatUi",
|
|
74
|
+
"MissingAsset",
|
|
75
|
+
"HashMismatch",
|
|
76
|
+
"BlobNotFound",
|
|
77
|
+
"IdempotencyKeyReuse",
|
|
78
|
+
"QueueFull",
|
|
79
|
+
"InsufficientCredits",
|
|
80
|
+
"NotFound",
|
|
81
|
+
"Unauthorized",
|
|
82
|
+
"Forbidden",
|
|
83
|
+
"error_from_envelope",
|
|
84
|
+
"OPERATION_IDS",
|
|
85
|
+
"OPERATION_METHODS",
|
|
86
|
+
]
|
comfy_low/_multipart.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Streaming ``multipart/form-data`` body construction.
|
|
2
|
+
|
|
3
|
+
OpenAPI codegen routinely buffers an entire upload into memory to build the
|
|
4
|
+
request body; this module is the hand-written alternative the contract calls for.
|
|
5
|
+
The body is produced as a generator of byte chunks that reads the file part
|
|
6
|
+
lazily, and — when the file size is known — the exact Content-Length is computed
|
|
7
|
+
up front so the request streams instead of buffering.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from typing import BinaryIO
|
|
15
|
+
|
|
16
|
+
CHUNK_SIZE = 64 * 1024
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _field_part(boundary: str, name: str, value: str) -> bytes:
|
|
20
|
+
return (
|
|
21
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'
|
|
22
|
+
).encode()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _file_header(boundary: str, name: str, filename: str, content_type: str) -> bytes:
|
|
26
|
+
return (
|
|
27
|
+
f"--{boundary}\r\n"
|
|
28
|
+
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
|
|
29
|
+
f"Content-Type: {content_type}\r\n\r\n"
|
|
30
|
+
).encode()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _closing(boundary: str) -> bytes:
|
|
34
|
+
return f"--{boundary}--\r\n".encode()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_multipart(
|
|
38
|
+
boundary: str,
|
|
39
|
+
*,
|
|
40
|
+
fields: list[tuple[str, str]],
|
|
41
|
+
file_name: str,
|
|
42
|
+
file_obj: BinaryIO,
|
|
43
|
+
file_content_type: str,
|
|
44
|
+
file_size: int | None,
|
|
45
|
+
chunk_size: int = CHUNK_SIZE,
|
|
46
|
+
) -> tuple[Iterator[bytes], int | None]:
|
|
47
|
+
"""Return ``(body_iterator, content_length)``.
|
|
48
|
+
|
|
49
|
+
``fields`` is a list of ``(name, value)`` pairs rather than a ``dict`` so a
|
|
50
|
+
repeatable field (e.g. ``tags``) can appear more than once — the standard
|
|
51
|
+
multipart/form-data convention for sending a list — without one value
|
|
52
|
+
clobbering another under the same key.
|
|
53
|
+
|
|
54
|
+
``content_length`` is ``None`` when ``file_size`` is unknown (the caller then
|
|
55
|
+
lets the client fall back to chunked transfer encoding). The file object is
|
|
56
|
+
read in ``chunk_size`` slices — never with a size-less ``read()`` — so a
|
|
57
|
+
multi-GB file never lands in memory whole.
|
|
58
|
+
"""
|
|
59
|
+
text_fields = b"".join(_field_part(boundary, name, value) for name, value in fields)
|
|
60
|
+
file_hdr = _file_header(boundary, "file", file_name, file_content_type)
|
|
61
|
+
closing = _closing(boundary)
|
|
62
|
+
|
|
63
|
+
content_length: int | None = None
|
|
64
|
+
if file_size is not None:
|
|
65
|
+
content_length = len(text_fields) + len(file_hdr) + file_size + len(b"\r\n") + len(closing)
|
|
66
|
+
|
|
67
|
+
def _iter() -> Iterator[bytes]:
|
|
68
|
+
yield text_fields
|
|
69
|
+
yield file_hdr
|
|
70
|
+
while True:
|
|
71
|
+
chunk = file_obj.read(chunk_size)
|
|
72
|
+
if not chunk:
|
|
73
|
+
break
|
|
74
|
+
yield chunk
|
|
75
|
+
yield b"\r\n"
|
|
76
|
+
yield closing
|
|
77
|
+
|
|
78
|
+
return _iter(), content_length
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def file_size_of(file_obj: BinaryIO) -> int | None:
|
|
82
|
+
"""Best-effort size of a seekable file object, else ``None``."""
|
|
83
|
+
try:
|
|
84
|
+
fd = file_obj.fileno()
|
|
85
|
+
except (OSError, AttributeError):
|
|
86
|
+
fd = None
|
|
87
|
+
if fd is not None:
|
|
88
|
+
try:
|
|
89
|
+
return os.fstat(fd).st_size
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|
|
92
|
+
try:
|
|
93
|
+
cur = file_obj.tell()
|
|
94
|
+
file_obj.seek(0, os.SEEK_END)
|
|
95
|
+
end = file_obj.tell()
|
|
96
|
+
file_obj.seek(cur, os.SEEK_SET)
|
|
97
|
+
return end - cur
|
|
98
|
+
except (OSError, AttributeError):
|
|
99
|
+
return None
|
comfy_low/errors.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""The shared error envelope mapped to a typed exception per ``code``.
|
|
2
|
+
|
|
3
|
+
This is the ``comfy_low`` (protocol) view of errors: one class per documented
|
|
4
|
+
error ``code``, plus a fallback. ``comfy_sdk`` re-raises these as its own
|
|
5
|
+
idiomatic exceptions where it adds value (e.g. ``JobFailed`` carrying node
|
|
6
|
+
details), but the protocol codes are defined here so the generated layer has a
|
|
7
|
+
stable, typed error surface.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ApiError(Exception):
|
|
16
|
+
"""Base for every error carried by the API's error envelope."""
|
|
17
|
+
|
|
18
|
+
code: str = "error"
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
message: str,
|
|
23
|
+
*,
|
|
24
|
+
code: str | None = None,
|
|
25
|
+
http_status: int,
|
|
26
|
+
details: dict[str, Any] | None = None,
|
|
27
|
+
retry_after: int | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.message = message
|
|
31
|
+
if code is not None:
|
|
32
|
+
self.code = code
|
|
33
|
+
self.http_status = http_status
|
|
34
|
+
self.details = details
|
|
35
|
+
self.retry_after = retry_after
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class InvalidWorkflow(ApiError):
|
|
39
|
+
code = "invalid_workflow"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class WorkflowFormatUi(ApiError):
|
|
43
|
+
code = "workflow_format_ui"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MissingAsset(ApiError):
|
|
47
|
+
code = "missing_asset"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class HashMismatch(ApiError):
|
|
51
|
+
code = "hash_mismatch"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BlobNotFound(ApiError):
|
|
55
|
+
code = "blob_not_found"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class IdempotencyKeyReuse(ApiError):
|
|
59
|
+
code = "idempotency_key_reuse"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class QueueFull(ApiError):
|
|
63
|
+
code = "queue_full"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class InsufficientCredits(ApiError):
|
|
67
|
+
code = "insufficient_credits"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class NotFound(ApiError):
|
|
71
|
+
code = "not_found"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Unauthorized(ApiError):
|
|
75
|
+
code = "unauthorized"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Forbidden(ApiError):
|
|
79
|
+
code = "forbidden"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# code -> exception class. Anything unmapped becomes a bare ApiError.
|
|
83
|
+
_BY_CODE: dict[str, type[ApiError]] = {
|
|
84
|
+
cls.code: cls
|
|
85
|
+
for cls in (
|
|
86
|
+
InvalidWorkflow,
|
|
87
|
+
WorkflowFormatUi,
|
|
88
|
+
MissingAsset,
|
|
89
|
+
HashMismatch,
|
|
90
|
+
BlobNotFound,
|
|
91
|
+
IdempotencyKeyReuse,
|
|
92
|
+
QueueFull,
|
|
93
|
+
InsufficientCredits,
|
|
94
|
+
NotFound,
|
|
95
|
+
Unauthorized,
|
|
96
|
+
Forbidden,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def error_from_envelope(
|
|
102
|
+
http_status: int,
|
|
103
|
+
body: dict[str, Any] | None,
|
|
104
|
+
*,
|
|
105
|
+
retry_after: int | None = None,
|
|
106
|
+
) -> ApiError:
|
|
107
|
+
"""Build the typed exception for an error response.
|
|
108
|
+
|
|
109
|
+
Falls back to a status-derived code when the body is missing or not a
|
|
110
|
+
well-formed envelope (so a bare ``401`` with no JSON still maps to
|
|
111
|
+
``Unauthorized``).
|
|
112
|
+
"""
|
|
113
|
+
err = (body or {}).get("error") if isinstance(body, dict) else None
|
|
114
|
+
code = (err or {}).get("code") if isinstance(err, dict) else None
|
|
115
|
+
message = (err or {}).get("message") if isinstance(err, dict) else None
|
|
116
|
+
details = (err or {}).get("details") if isinstance(err, dict) else None
|
|
117
|
+
|
|
118
|
+
if code is None:
|
|
119
|
+
code = _CODE_BY_STATUS.get(http_status, "error")
|
|
120
|
+
if not message:
|
|
121
|
+
message = f"HTTP {http_status}"
|
|
122
|
+
|
|
123
|
+
cls = _BY_CODE.get(code, ApiError)
|
|
124
|
+
return cls(
|
|
125
|
+
message,
|
|
126
|
+
code=code,
|
|
127
|
+
http_status=http_status,
|
|
128
|
+
details=details if isinstance(details, dict) else None,
|
|
129
|
+
retry_after=retry_after,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
_CODE_BY_STATUS: dict[int, str] = {
|
|
134
|
+
401: "unauthorized",
|
|
135
|
+
402: "insufficient_credits",
|
|
136
|
+
403: "forbidden",
|
|
137
|
+
404: "not_found",
|
|
138
|
+
409: "hash_mismatch",
|
|
139
|
+
422: "invalid_workflow",
|
|
140
|
+
429: "queue_full",
|
|
141
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Generated pydantic v2 models for the Comfy API v2 contract.
|
|
2
|
+
|
|
3
|
+
Everything here is emitted from ``spec/openapi.yaml`` by ``scripts/gen_models.sh``
|
|
4
|
+
and re-exported for convenience. Do not hand-edit ``_generated.py``.
|
|
5
|
+
|
|
6
|
+
Note on naming: request bodies in the canonical spec are inlined (not named
|
|
7
|
+
schemas), so there are no ``*Request`` / ``*Form`` models — the transport builds
|
|
8
|
+
those payloads by hand. The SSE event payloads are ``StatusEvent`` /
|
|
9
|
+
``PreviewEvent`` / ``LogEvent`` (``progress`` and ``output`` events reuse the
|
|
10
|
+
``Progress`` and ``Output`` schemas directly).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from ._generated import (
|
|
16
|
+
Asset,
|
|
17
|
+
AssetReference,
|
|
18
|
+
Error,
|
|
19
|
+
ErrorEnvelope,
|
|
20
|
+
Job,
|
|
21
|
+
JobError,
|
|
22
|
+
JobStatus,
|
|
23
|
+
JobUrls,
|
|
24
|
+
LogEvent,
|
|
25
|
+
Output,
|
|
26
|
+
OutputType,
|
|
27
|
+
PreviewEvent,
|
|
28
|
+
Progress,
|
|
29
|
+
StatusEvent,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"Asset",
|
|
34
|
+
"AssetReference",
|
|
35
|
+
"Error",
|
|
36
|
+
"ErrorEnvelope",
|
|
37
|
+
"Job",
|
|
38
|
+
"JobError",
|
|
39
|
+
"JobStatus",
|
|
40
|
+
"JobUrls",
|
|
41
|
+
"LogEvent",
|
|
42
|
+
"Output",
|
|
43
|
+
"OutputType",
|
|
44
|
+
"PreviewEvent",
|
|
45
|
+
"Progress",
|
|
46
|
+
"StatusEvent",
|
|
47
|
+
]
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# GENERATED by scripts/gen_models.sh from spec/openapi.yaml — do not hand-edit.
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
from pydantic import AnyUrl, AwareDatetime, BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Asset(BaseModel):
|
|
12
|
+
"""
|
|
13
|
+
A user-owned record identified by a server-assigned UUID, backing an immutable blob whose content carries a server-computed blake3 hash. `hash` may be computed lazily: an asset record (and its retrievable bytes) can exist before its hash is filled in.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
id: Annotated[str, Field(examples=['asset_01JZV8Q3M7K2W9X0Y1Z2A3B4C5'])]
|
|
17
|
+
hash: Annotated[
|
|
18
|
+
str | None,
|
|
19
|
+
Field(
|
|
20
|
+
description='`blake3:<hex>`; null while lazily computed.',
|
|
21
|
+
examples=['blake3:9f8a1c0d...'],
|
|
22
|
+
),
|
|
23
|
+
]
|
|
24
|
+
size_bytes: Annotated[int, Field(examples=[4816293])]
|
|
25
|
+
content_type: Annotated[str, Field(examples=['image/png'])]
|
|
26
|
+
file_path: Annotated[str | None, Field(examples=['photo.png'])] = None
|
|
27
|
+
created_new: Annotated[
|
|
28
|
+
bool | None,
|
|
29
|
+
Field(
|
|
30
|
+
description='On create responses: distinguishes a brand-new blob (true) from a dedup hit against bytes the platform already had (false).'
|
|
31
|
+
),
|
|
32
|
+
] = None
|
|
33
|
+
created_at: AwareDatetime
|
|
34
|
+
url: Annotated[
|
|
35
|
+
AnyUrl, Field(description='Short-lived content URL (signed, or proxy-served).')
|
|
36
|
+
]
|
|
37
|
+
url_expires_at: AwareDatetime
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class JobStatus(Enum):
|
|
41
|
+
"""
|
|
42
|
+
Lifecycle: queued → running → succeeded | failed | expired;
|
|
43
|
+
a cancel request moves running → canceling → canceled.
|
|
44
|
+
Terminal states: succeeded, canceled, failed, expired.
|
|
45
|
+
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
queued = 'queued'
|
|
49
|
+
running = 'running'
|
|
50
|
+
succeeded = 'succeeded'
|
|
51
|
+
canceling = 'canceling'
|
|
52
|
+
canceled = 'canceled'
|
|
53
|
+
failed = 'failed'
|
|
54
|
+
expired = 'expired'
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class JobUrls(BaseModel):
|
|
58
|
+
"""
|
|
59
|
+
Embedded follow-up links — follow these, don't build URLs.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
self: str
|
|
63
|
+
events: str
|
|
64
|
+
cancel: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Progress(BaseModel):
|
|
68
|
+
"""
|
|
69
|
+
Server-computed progress snapshot (node-count and sampler-step weighted). Complete per snapshot — one fully re-syncs a client.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
value: Annotated[
|
|
73
|
+
float,
|
|
74
|
+
Field(
|
|
75
|
+
description='Overall fraction, server-computed.',
|
|
76
|
+
examples=[0.42],
|
|
77
|
+
ge=0.0,
|
|
78
|
+
le=1.0,
|
|
79
|
+
),
|
|
80
|
+
]
|
|
81
|
+
nodes_done: Annotated[int, Field(examples=[11])]
|
|
82
|
+
nodes_total: Annotated[int, Field(examples=[31])]
|
|
83
|
+
current_node: Annotated[str | None, Field(examples=['12'])] = None
|
|
84
|
+
current_node_class: Annotated[str | None, Field(examples=['KSampler'])] = None
|
|
85
|
+
step: Annotated[int | None, Field(examples=[21])] = None
|
|
86
|
+
steps: Annotated[int | None, Field(examples=[50])] = None
|
|
87
|
+
message: Annotated[str | None, Field(examples=['KSampler 21/50'])] = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class OutputType(Enum):
|
|
91
|
+
"""
|
|
92
|
+
Normalized output kind — nothing silently dropped.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
image = 'image'
|
|
96
|
+
video = 'video'
|
|
97
|
+
audio = 'audio'
|
|
98
|
+
text = 'text'
|
|
99
|
+
file = 'file'
|
|
100
|
+
latent = 'latent'
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class JobError(BaseModel):
|
|
104
|
+
"""
|
|
105
|
+
Execution failure detail, carried in `job.error` (not an HTTP error).
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
code: Annotated[str, Field(examples=['node_execution_error'])]
|
|
109
|
+
message: str
|
|
110
|
+
node_id: str | None = None
|
|
111
|
+
class_type: str | None = None
|
|
112
|
+
traceback: str | None = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Error(BaseModel):
|
|
116
|
+
code: Annotated[str, Field(examples=['invalid_workflow'])]
|
|
117
|
+
message: Annotated[
|
|
118
|
+
str,
|
|
119
|
+
Field(examples=["Node 12 (KSampler): required input 'model' is not connected"]),
|
|
120
|
+
]
|
|
121
|
+
details: Annotated[
|
|
122
|
+
dict[str, Any] | None,
|
|
123
|
+
Field(
|
|
124
|
+
examples=[
|
|
125
|
+
{'node_errors': {'12': [{'field': 'model', 'reason': 'missing_input'}]}}
|
|
126
|
+
]
|
|
127
|
+
),
|
|
128
|
+
] = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ErrorEnvelope(BaseModel):
|
|
132
|
+
"""
|
|
133
|
+
Shared error envelope with machine-readable codes. Core codes (v1):
|
|
134
|
+
`invalid_workflow` (422), `workflow_format_ui` (422),
|
|
135
|
+
`missing_asset` (422), `hash_mismatch` (409), `blob_not_found`
|
|
136
|
+
(404), `idempotency_key_reuse` (422),
|
|
137
|
+
`queue_full` (429 + Retry-After), `insufficient_credits` (402),
|
|
138
|
+
`not_found` (404), `unauthorized` (401), `forbidden` (403).
|
|
139
|
+
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
error: Error
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class StatusEvent(BaseModel):
|
|
146
|
+
"""
|
|
147
|
+
SSE `status` event payload.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
status: JobStatus
|
|
151
|
+
queue_position: int | None = None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class PreviewEvent(BaseModel):
|
|
155
|
+
"""
|
|
156
|
+
SSE `preview` event payload (JPEG, base64, throttled).
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
node_id: str
|
|
160
|
+
content_type: Annotated[str, Field(examples=['image/jpeg'])]
|
|
161
|
+
data_base64: str
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class LogEvent(BaseModel):
|
|
165
|
+
"""
|
|
166
|
+
SSE `log` event payload. Best-effort diagnostics.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
level: Annotated[str, Field(examples=['info'])]
|
|
170
|
+
message: str
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class FieldType(Enum):
|
|
174
|
+
core_ASSET = 'core/ASSET'
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class Info(BaseModel):
|
|
178
|
+
id: Annotated[str, Field(examples=['asset_01JZV8Q3M7K2W9X0Y1Z2A3B4C5'])]
|
|
179
|
+
hash: Annotated[str | None, Field(examples=['blake3:9f8a1c0d...'])] = None
|
|
180
|
+
file_path: Annotated[str | None, Field(examples=['photo.png'])] = None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class AssetReference(BaseModel):
|
|
184
|
+
"""
|
|
185
|
+
The typed asset-reference object placed inside workflow JSON where a
|
|
186
|
+
filename would normally go (documented here for tooling; it is not a
|
|
187
|
+
request/response body itself):
|
|
188
|
+
|
|
189
|
+
{"__type": "core/ASSET",
|
|
190
|
+
"info": {"id": "asset_...", "hash": "blake3:...",
|
|
191
|
+
"file_path": "photo.png"}}
|
|
192
|
+
|
|
193
|
+
`info.id` (the asset UUID) is required in v1 and authoritative;
|
|
194
|
+
`hash` and `file_path` are optional staging/lookup hints and never
|
|
195
|
+
override a present `id`. A malformed reference or one that is not
|
|
196
|
+
resolvable/owned by the caller fails submission with 422
|
|
197
|
+
`missing_asset`.
|
|
198
|
+
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
field__type: Annotated[FieldType, Field(alias='__type')]
|
|
202
|
+
info: Info
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class Output(BaseModel):
|
|
206
|
+
"""
|
|
207
|
+
A committed job output. Outputs are assets: `id` is the asset UUID, retrievable via GET /api/v2/assets/{id} for as long as the job is retained. `hash` is lazily computed and may be null on the retrieval hot path.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
node_id: Annotated[str, Field(examples=['9'])]
|
|
211
|
+
name: Annotated[str, Field(examples=['ComfyUI_00001_.png'])]
|
|
212
|
+
type: OutputType
|
|
213
|
+
content_type: Annotated[str, Field(examples=['image/png'])]
|
|
214
|
+
size_bytes: Annotated[int, Field(examples=[1848320])]
|
|
215
|
+
id: Annotated[
|
|
216
|
+
str, Field(description='Asset UUID.', examples=['asset_01JZV9R4N8...'])
|
|
217
|
+
]
|
|
218
|
+
hash: Annotated[
|
|
219
|
+
str | None, Field(description='`blake3:<hex>`; null until lazily computed.')
|
|
220
|
+
]
|
|
221
|
+
url: AnyUrl
|
|
222
|
+
url_expires_at: AwareDatetime
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class Job(BaseModel):
|
|
226
|
+
"""
|
|
227
|
+
One execution of a workflow. Durable from creation until `expires_at`; `outputs` populates incrementally during execution.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
id: Annotated[str, Field(examples=['job_01JZTGXW9Q2M4R8V0B1N3P5D7F'])]
|
|
231
|
+
status: JobStatus
|
|
232
|
+
created_at: AwareDatetime
|
|
233
|
+
started_at: Annotated[AwareDatetime | None, Field(...)]
|
|
234
|
+
completed_at: Annotated[AwareDatetime | None, Field(...)]
|
|
235
|
+
expires_at: Annotated[
|
|
236
|
+
AwareDatetime,
|
|
237
|
+
Field(
|
|
238
|
+
description='Retention deadline — a platform property, not an API constant.'
|
|
239
|
+
),
|
|
240
|
+
]
|
|
241
|
+
queue_position: Annotated[int | None, Field(...)]
|
|
242
|
+
progress: Annotated[
|
|
243
|
+
Progress | None,
|
|
244
|
+
Field(
|
|
245
|
+
description='The latest progress snapshot; same data the SSE stream pushes.'
|
|
246
|
+
),
|
|
247
|
+
]
|
|
248
|
+
outputs: list[Output]
|
|
249
|
+
error: Annotated[JobError | None, Field(...)]
|
|
250
|
+
metrics: Annotated[
|
|
251
|
+
dict[str, int | None] | None,
|
|
252
|
+
Field(
|
|
253
|
+
description='Values are nullable (a metric not yet available — e.g. `execution_ms` before a job starts running — is `null`, not omitted); the example below is deliberately all-non-null purely to work around a Spectral/nimma lint-tooling crash on a literal `null` inside a schema `example` combined with `additionalProperties.nullable: true` — the schema itself is unchanged and still allows null values at runtime.',
|
|
254
|
+
examples=[{'queue_ms': 9000, 'execution_ms': 42000}],
|
|
255
|
+
),
|
|
256
|
+
] = None
|
|
257
|
+
urls: JobUrls
|
comfy_low/sse.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Sans-IO Server-Sent-Events decoding.
|
|
2
|
+
|
|
3
|
+
The decoder is pure and line-driven: feed it decoded text lines one at a time and
|
|
4
|
+
it yields complete events. Both the sync and async transports drive the same
|
|
5
|
+
decoder, so the wire-format parsing lives in exactly one place (the IO — reading
|
|
6
|
+
lines off a sync or async body — is the only thing that differs).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class RawEvent:
|
|
18
|
+
"""One decoded SSE frame. ``event`` defaults to ``message`` per the spec."""
|
|
19
|
+
|
|
20
|
+
event: str
|
|
21
|
+
data: dict[str, Any]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SSEDecoder:
|
|
25
|
+
"""Incremental SSE parser. Call :meth:`push` per line; it returns any
|
|
26
|
+
completed events (usually zero or one). A blank line dispatches the buffer.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._event: str | None = None
|
|
31
|
+
self._data: list[str] = []
|
|
32
|
+
|
|
33
|
+
def push(self, line: str) -> list[RawEvent]:
|
|
34
|
+
# Strip a single trailing newline; callers may or may not have kept it.
|
|
35
|
+
line = line.rstrip("\r\n")
|
|
36
|
+
|
|
37
|
+
if line == "":
|
|
38
|
+
return self._dispatch()
|
|
39
|
+
if line.startswith(":"):
|
|
40
|
+
# Comment / keep-alive heartbeat — ignore.
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
field, _, value = line.partition(":")
|
|
44
|
+
if value.startswith(" "):
|
|
45
|
+
value = value[1:]
|
|
46
|
+
|
|
47
|
+
if field == "event":
|
|
48
|
+
self._event = value
|
|
49
|
+
elif field == "data":
|
|
50
|
+
self._data.append(value)
|
|
51
|
+
# `id` and `retry` are intentionally ignored: this stream carries no
|
|
52
|
+
# cursor (no Last-Event-ID resume), by contract.
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
def _dispatch(self) -> list[RawEvent]:
|
|
56
|
+
if not self._data and self._event is None:
|
|
57
|
+
return []
|
|
58
|
+
raw = "\n".join(self._data)
|
|
59
|
+
event = self._event or "message"
|
|
60
|
+
self._event = None
|
|
61
|
+
self._data = []
|
|
62
|
+
if raw == "":
|
|
63
|
+
return []
|
|
64
|
+
try:
|
|
65
|
+
data = json.loads(raw)
|
|
66
|
+
except json.JSONDecodeError:
|
|
67
|
+
data = {"raw": raw}
|
|
68
|
+
if not isinstance(data, dict):
|
|
69
|
+
data = {"value": data}
|
|
70
|
+
return [RawEvent(event=event, data=data)]
|