Metadata-Version: 2.5
Name: comfy-sdk
Version: 0.1.7
Summary: Python SDK for running ComfyUI workflows via the Comfy API v2 (self-hosted, Comfy Cloud, serverless).
Project-URL: Homepage, https://docs.comfy.org
Project-URL: Documentation, https://docs.comfy.org
Project-URL: Repository, https://github.com/Comfy-Org/comfy-python-sdk
Project-URL: Issues, https://github.com/Comfy-Org/comfy-python-sdk/issues
Author: Comfy Org
License-Expression: MIT
License-File: LICENSE
Keywords: comfy,comfyui,diffusion,generative-ai,sdk,workflow
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Multimedia :: Graphics
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: blake3>=0.4
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2.6
Provides-Extra: codegen
Requires-Dist: datamodel-code-generator~=0.68.1; extra == 'codegen'
Requires-Dist: pyyaml>=6; extra == 'codegen'
Provides-Extra: dev
Requires-Dist: datamodel-code-generator~=0.68.1; extra == 'dev'
Requires-Dist: mypy~=2.3.0; extra == 'dev'
Requires-Dist: pillow>=10; extra == 'dev'
Requires-Dist: pytest-asyncio~=1.4.0; extra == 'dev'
Requires-Dist: pytest-cov~=7.0; extra == 'dev'
Requires-Dist: pytest~=9.1.1; extra == 'dev'
Requires-Dist: pyyaml>=6; extra == 'dev'
Requires-Dist: ruff~=0.15.22; extra == 'dev'
Provides-Extra: pil
Requires-Dist: pillow>=10; extra == 'pil'
Description-Content-Type: text/markdown

<div align="center">
<img src="assets/logo.svg" alt="Comfy" width="130"/>
<h1>comfy-python-sdk</h1>
<p>
  <strong>The Python client for the <a href="https://docs.comfy.org">Comfy API v2</a>.</strong><br/>
  Submit a workflow, stream its progress, get your outputs — against self-hosted ComfyUI, Comfy Cloud, or serverless.
</p>
</div>

<p align="center">
  <a href="https://pypi.org/project/comfy-sdk/"><img src="https://img.shields.io/pypi/v/comfy-sdk?style=for-the-badge&logo=pypi&logoColor=white&label=PyPI" alt="PyPI"></a>
  <a href="#requirements-and-install"><img src="https://img.shields.io/badge/Python-3.10%2B-3776AB?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.10+"></a>
  <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-lightgrey?style=for-the-badge" alt="License: MIT"></a>
  <a href="https://cloud.comfy.org"><img src="https://img.shields.io/badge/Comfy_Cloud-cloud.comfy.org-211927?style=for-the-badge" alt="Comfy Cloud"></a>
</p>

---

Python SDK for running ComfyUI workflows via the **Comfy API v2**. The same
code runs against Comfy Cloud, a serverless deployment, or a self-hosted
ComfyUI instance — only the `COMFY_BASE_URL` environment variable and an
optional API key change.

## Requirements and install

Requires **Python 3.10+**. Dependencies: `httpx`, `blake3`, `pydantic` (v2).

```bash
pip install comfy-sdk
```

To install from source instead (for local development, or to track an
unreleased commit):

```bash
git clone https://github.com/Comfy-Org/comfy-python-sdk
cd comfy-python-sdk
pip install -e .

# To install everything needed to lint/type-check/test locally
pip install -e ".[dev]"
```

### Optional dependencies

Install the optional `pil` extra with `pip install -e ".[pil]"` to use `Preview.to_pil()` for decoding an in-progress output preview to a `PIL.Image`.

### For local

The SDK works against a ComfyUI instance with **Comfy API v2**. Comfy Cloud and serverless instances deployed from our developer platform already use Comfy API v2. For local or self-hosted instances, **Comfy API v2** can be setup using the [comfy-api-proxy](https://github.com/Comfy-Org/comfy-api-proxy).

## Getting started

```python
from comfy_sdk import Comfy

client = Comfy(api_key="comfyui-...")   # Comfy Cloud

wf = client.workflows.from_file("workflow_api.json")

# Input assets are hashed locally with blake3
# If the server already has an identical copy we reuse it, if not we upload the asset
# The workflow is updated with the core/ASSET reference instead of a local file path
asset = client.assets.from_file("photo.png")
wf.set_input("10", "image", asset)

# Run workflow
# Get outputs using the output node Id as a reference
job = client.run(wf)
for output in job.get_outputs("9"):
    output.to_file(output.name)
```

## Authentication — one client, per-surface key

| Surface | `api_key` |
|---|---|
| Comfy Cloud (`https://cloud.comfy.org`) — the default | Required |
| Serverless deployment | Required |
| Self-hosted ComfyUI (behind the API proxy) | Omit — no key is sent, even implicitly |

```python
client = Comfy(api_key="comfyui-...")   # Comfy Cloud
```

`AsyncComfy` takes the same arguments. A key is only ever attached to requests
aimed at the target deployment's own origin — a server-returned follow-up link
(`job.urls.self`/`cancel`/`events`, or a redirected asset download) pointing
anywhere else never receives it.

### Targeting another deployment

`Comfy()` points at Comfy Cloud and takes no base-URL argument. To run against
a serverless deployment or a self-hosted instance behind
[comfy-api-proxy](https://github.com/Comfy-Org/comfy-api-proxy), set
`COMFY_BASE_URL` in the environment:

```bash
export COMFY_BASE_URL="https://<deployment>.run.comfy.app"  # serverless
export COMFY_BASE_URL="http://127.0.0.1:8189"               # self-hosted proxy
```

It is read each time a client is constructed, must be an `http(s)` URL, and an
unset or blank value (including whitespace-only) means Comfy Cloud.

Upgrading from an earlier version: `Comfy("<url>", "<key>")` becomes
`Comfy(api_key="<key>")` with `COMFY_BASE_URL` set. `api_key` is keyword-only,
so the old positional call raises `TypeError` rather than reading a URL as a
key.

The SDK identifies itself via a `User-Agent` header (for support and usage
analytics) — this is request metadata only; no other data is collected. Pass
`client_info="my-app"` to append an `app/my-app` token so an integration can
attribute its own traffic:

```python
client = Comfy(api_key="comfyui-...", client_info="my-app")
```

## Partner (API) node auth

Workflows that use partner/API nodes (Gemini, etc.) need a Comfy API key to
authenticate them. Pass it per submit with `api_key=`. This is **not** the same
as the `api_key` you construct `Comfy` with: the constructor key authenticates
*you* to the server, while this one authenticates the partner nodes *inside* the
workflow (it is often the same `comfyui-…` key):

```python
job = client.run(wf, api_key="comfyui-...")
# or drive it yourself:
job = client.submit(wf, api_key="comfyui-...")
```

The SDK sends it once as `extra_data.api_key_comfy_org` alongside the workflow —
one key authenticates every partner node in the graph. It is never logged or
persisted by the SDK. Omit `api_key` and no `extra_data` is sent at all.

## Assets and `core/ASSET`

`client.assets.from_file(...)` / `from_bytes(...)` / `from_stream(...)` /
`from_url(...)` return a **lazy** asset handle immediately — no network call
yet. Embed it directly into the workflow graph:

```python
asset = client.assets.from_file("photo.png")
wf.set_input("10", "image", asset)
```

On first use (submitting the workflow, or an explicit `asset.commit()`), the
SDK:

1. hashes the bytes locally with blake3;
2. probes the server's dedup fast-path — a `HEAD` existence check by hash,
   then a cheap `from-hash` mint if the server already has those bytes;
3. only streams a full multipart upload on a miss.

At submit time, every asset handle found anywhere in the graph is replaced by
a `core/ASSET` reference object (`{"__type": "core/ASSET", "info": {"id":
..., "hash": ..., "file_path": ...}}`), which the server resolves back to the
uploaded asset when it runs the workflow.

## Live progress

```python
job = client.submit(wf)
for event in job.events():          # SSE; live, auto-reconnecting (no replay)
    match event:
        case Progress() as p:       print(f"{p.value:.0%} {p.message}")
        case Preview() as pv:       show(pv.to_pil())
        case OutputReady() as o:    o.output.to_file(f"partial/{o.output.name}")
        case StatusChange(status="succeeded"): break
result = job.result()               # raises JobFailed with node details on failure
```

`job.events()` reconnects automatically if the stream drops, but never
replays a frame you've already seen (the stream carries no cursor). That's
why polling stays authoritative: `job.wait()` / `job.result()` (and
`client.run()`, which is `submit()` + `result()`) always fall back to
`GET /jobs/{id}` to decide when a job is really done — use `events()` for
live UI feedback, and `wait()`/`result()`/`run()` for the definitive answer.
`job.status` is the current status string; `job.outputs` is the full list of
output handles regardless of which node produced them (`job.get_outputs(node_id)`
filters to one node, as in the quickstart above).

## Downloading outputs

A finished job exposes its results as `Output` handles — `job.outputs`, or
`job.get_outputs(node_id)` to filter to one node. Each output is an asset you
can pull down whichever way suits the caller:

```python
out = job.get_outputs("13")[0]
out.to_file("result.png")                   # stream to disk in chunks
data = out.to_bytes()                       # buffer into memory
out.to_file("head.png", range=(0, 1023))    # range-aware: first 1 KiB only
```

`get_download_url()` hands back a fetchable URL instead of transferring the
bytes through your process — give it to a browser, a CDN, or another service:

```python
link = out.get_download_url()               # DownloadUrl(url=..., expires_at=...)
```

On Comfy Cloud / serverless the URL is a short-lived, **self-authorizing**
signed storage URL: whoever holds it can read the asset until `expires_at`
with no API key of their own. On a self-hosted proxy it's the content endpoint
(normal auth still applies) and `expires_at` is `None`. It works on every
backend and never downloads the bytes first. (`AsyncOutput` mirrors all of the
above with `await`.)

## Sync and async

`Comfy` and `AsyncComfy` expose the identical surface — swap the import and
add `await` / `async for`:

```python
from comfy_sdk import AsyncComfy

async def main() -> None:
    async with AsyncComfy(api_key="comfyui-...") as client:
        wf = client.workflows.from_file("workflow_api.json")
        job = await client.run(wf)
        await job.outputs[0].to_file("out.png")
```

## Typed errors

`comfy_sdk` translates the API's error envelope into a small set of
exceptions, all importable from the top-level package and all subclasses of
`ComfyError`:

- `Unauthorized`, `Forbidden`, `NotFound` — auth and lookup failures.
- `InvalidWorkflow`, `WorkflowFormatUi` — the graph itself was rejected;
  `WorkflowFormatUi` specifically means a UI-export (`nodes`/`links`/
  `last_node_id`) was submitted instead of the API-format graph — the SDK
  catches this locally before it ever reaches the server.
- `MissingAsset` — a `core/ASSET` reference could not be resolved.
- `HashMismatch`, `BlobNotFound` — asset upload/dedup failures.
- `IdempotencyKeyReuse` — the `Idempotency-Key` was reused. `submit()` (and
  `run()`) attach a fresh key to every call, so an accidental exact resend never
  runs the workflow twice. Keys are single-use — reject-on-duplicate, there is
  no replay — so if you pass your own `idempotency_key=` and reuse it, the second
  call raises this. After an ambiguous failure (e.g. a timeout where you don't
  know if the job was created), poll or list your jobs rather than resubmitting
  with the same key.
- `InsufficientCredits` — the account can't afford the job.
- `QueueFull` — backpressure; carries `.retry_after` seconds. `client.submit`
  already retries this automatically for a bounded budget before giving up
  and raising it.
- `JobFailed` — a job reached a non-`succeeded` terminal state; `.error`
  carries node-level detail when the platform provided one.

```python
from comfy_sdk import JobFailed, QueueFull, Unauthorized

try:
    result = client.run(wf)
except JobFailed as e:
    print(e.error)
except Unauthorized:
    print("check your api_key")
```

## Architecture — two layers

* **`comfy_low`** — generated protocol bindings. Pydantic v2 models generated
  from `spec/openapi.yaml` (`src/comfy_low/models/_generated.py`, committed;
  regenerate with `scripts/gen_models.sh`, CI fails on drift) plus a thin
  hand-written `httpx` transport (sync + async), one function per `operationId`,
  with the mandatory escape hatches: raw response access, unbuffered/streaming
  bodies, all headers, and per-request timeout/abort. Boring and replaceable.

* **`comfy_sdk`** — the idiomatic layer integrators import. This is where the
  value lives: blake3 content-addressed dedup-upload, `core/ASSET`
  substitution, idempotent submit, live SSE with reconnect, poll-authoritative
  `run()`, range-aware downloads, and typed exceptions mapping the error
  envelope.

`spec/openapi.yaml` is a one-way vendored copy of the canonical Comfy API v2
contract — do not hand-edit it (see `spec/README.md`). It's synced
periodically from that canonical contract, stripped of anything tagged
`internal`, and pinned by `spec/VERSION`.

## Related projects

Clients for the same Comfy API v2 contract:

| Project | Language | Package |
|---|---|---|
| [comfy-python-sdk](https://github.com/Comfy-Org/comfy-python-sdk) | Python | `comfy-sdk` |
| [comfy-typescript-sdk](https://github.com/Comfy-Org/comfy-typescript-sdk) | TypeScript | `@comfyorg/sdk` |

## Development

```bash
pip install -e ".[dev]"
ruff check .
ruff format --check .
mypy src
pytest -v
```

Regenerating and checking the vendored protocol layer (a separate CI job):

```bash
pip install -e ".[codegen]"
python scripts/gen_models.sh     # regenerate comfy_low models from spec/openapi.yaml
python scripts/check_drift.py    # same check CI runs; fails if committed models drifted
```

## Releases

Releases are published to PyPI from a GitHub Release (tag `vX.Y.Z`) by
[`.github/workflows/publish.yml`](.github/workflows/publish.yml), using
PyPI's Trusted Publishing (OIDC) — no API token is stored in this repo.