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 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
+ ]
@@ -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)]