Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 35 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
---

Python SDK for running ComfyUI workflows via the **Comfy API v2**. The same
code runs against a self-hosted ComfyUI instance, Comfy Cloud, or a serverless
deployment — only the base URL and an optional API key change.
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

Expand Down Expand Up @@ -53,8 +54,7 @@ The SDK works against a ComfyUI instance with **Comfy API v2**. Comfy Cloud and
```python
from comfy_sdk import Comfy

client = Comfy("http://127.0.0.1:8189") # Self-hosted, no API key
# client = Comfy("https://cloud.comfy.org", api_key="comfyui-...") # Comfy Cloud
client = Comfy(api_key="comfyui-...") # Comfy Cloud

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

Expand All @@ -73,22 +73,40 @@ for output in job.get_outputs("9"):

## Authentication — one client, per-surface key

| Surface | Example base URL | `api_key` |
|---|---|---|
| Self-hosted ComfyUI (behind the API proxy) | `http://127.0.0.1:8189` | Omit — no key is sent, even implicitly |
| Comfy Cloud | `https://cloud.comfy.org` — the default, may be omitted | Required |
| Serverless deployment | `https://<deployment>.comfy.org` | Required |
| 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 (default)
client = Comfy("http://127.0.0.1:8189") # Self-hosted
client = Comfy("https://<deployment>.comfy.org", api_key="comfyui-...") # Serverless
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.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
### 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
```

`AsyncComfy` takes the same two arguments. A key is only ever attached to
requests aimed at the configured `base_url`'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.
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
Expand Down Expand Up @@ -200,7 +218,7 @@ add `await` / `async for`:
from comfy_sdk import AsyncComfy

async def main() -> None:
async with AsyncComfy("http://127.0.0.1:8189") as client:
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")
Expand Down
11 changes: 7 additions & 4 deletions src/comfy_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

The thick, hand-written layer integrators import. It runs an API-format workflow
against any Comfy API v2 surface (self-hosted proxy, Comfy Cloud, serverless) —
the only per-surface difference is the base URL and an optional key — and owns
the only per-surface difference is the ``COMFY_BASE_URL`` environment variable
and an optional key — and owns
everything a generator cannot produce: local blake3 dedup-upload, ``core/ASSET``
substitution, idempotent submit, live SSE with a poll-authoritative backstop,
range-aware downloads, and typed errors. It is layered over ``comfy_low`` (the
Expand All @@ -12,8 +13,9 @@

from comfy_sdk import Comfy

client = Comfy("http://127.0.0.1:8189") # self-hosted, no key
# client = Comfy(api_key="comfyui-...") # Comfy Cloud (default)
client = Comfy(api_key="comfyui-...") # Comfy Cloud
# export COMFY_BASE_URL=http://127.0.0.1:8189 # self-hosted, no key
# client = Comfy()

wf = client.workflows.from_file("workflow_api.json")
asset = client.assets.from_file("photo.png") # lazy; uploaded on use
Expand All @@ -29,7 +31,7 @@
from importlib.metadata import version as _pkg_version

from .assets import Asset, AssetFactory, AsyncAsset, AsyncAssetFactory
from .client import COMFY_CLOUD_BASE_URL, AsyncComfy, Comfy
from .client import BASE_URL_ENV_VAR, COMFY_CLOUD_BASE_URL, AsyncComfy, Comfy
from .events import (
Event,
Log,
Expand Down Expand Up @@ -69,6 +71,7 @@
# clients
"Comfy",
"COMFY_CLOUD_BASE_URL",
"BASE_URL_ENV_VAR",
"AsyncComfy",
# assets / workflows / jobs / outputs
"Asset",
Expand Down
65 changes: 56 additions & 9 deletions src/comfy_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
awaiting methods are duplicated; the rules (idempotency, 429 backoff, asset
materialization, UI-format detection) live in ``_core`` and are called from both.

Both clients target Comfy Cloud. Another deployment — a self-hosted proxy or a
serverless one — is selected through the ``COMFY_BASE_URL`` environment
variable; there is no base-URL constructor parameter.

Per-surface key behavior is inherited from ``comfy_low``: pass ``api_key`` to
the constructor for Comfy Cloud / serverless; leave it unset for a self-hosted
proxy that has no auth (no credentials are then sent). That constructor key is
Expand All @@ -16,8 +20,10 @@

from __future__ import annotations

import os
import time
from typing import Any
from urllib.parse import urlsplit

from comfy_low.errors import ApiError
from comfy_low.transport import AsyncComfyLow, ComfyLow
Expand All @@ -30,13 +36,49 @@

# How long to keep retrying a full queue before giving up (seconds).
_QUEUE_RETRY_BUDGET = 60.0
#: Base URL of the hosted Comfy Cloud deployment, used when none is given.
#: Self-hosted ComfyUI and serverless deployments must pass their own base_url.
#: Base URL of the hosted Comfy Cloud deployment — where a client points by default.
COMFY_CLOUD_BASE_URL = "https://cloud.comfy.org"
#: Environment variable that redirects a client at another deployment.
BASE_URL_ENV_VAR = "COMFY_BASE_URL"

_DEFAULT_RETRY_AFTER = 2


def _resolve_base_url() -> str:
"""Comfy Cloud, unless ``COMFY_BASE_URL`` names another deployment.

Read per construction rather than at import so a process can point
successive clients at different deployments. An unset-or-blank variable
means Comfy Cloud, so ``COMFY_BASE_URL=`` in a shell profile or ``.env``
is not an error.
"""
raw = os.environ.get(BASE_URL_ENV_VAR, "").strip()
if not raw:
return COMFY_CLOUD_BASE_URL
parsed = urlsplit(raw)
try:
# urlsplit defers the port check, so a non-numeric or out-of-range one
# raises only when .port is read — do it here rather than let httpx
# fail later with a murkier message.
_port = parsed.port
# A query or fragment would land in the middle of every request URL,
# since the transport builds those by appending the API path.
valid = (
parsed.scheme in ("http", "https")
and bool(parsed.netloc)
and not parsed.query
and not parsed.fragment
)
except ValueError:
valid = False
if not valid:
raise ValueError(
f"{BASE_URL_ENV_VAR} must be an http(s) URL with no query or fragment "
f"(e.g. 'http://127.0.0.1:8189'); got {raw!r}"
)
return raw
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def _guard_ui_format(workflow: Workflow) -> None:
if _core.looks_like_ui_format(workflow.json):
raise WorkflowFormatUi(
Expand All @@ -48,17 +90,21 @@ def _guard_ui_format(workflow: Workflow) -> None:


class Comfy:
"""Synchronous Comfy API v2 client."""
"""Synchronous Comfy API v2 client.

Targets Comfy Cloud, or whatever deployment ``COMFY_BASE_URL`` names.
``api_key`` is keyword-only so a pre-``COMFY_BASE_URL`` positional URL
fails loudly instead of being read as a key.
"""

def __init__(
self,
base_url: str = COMFY_CLOUD_BASE_URL,
api_key: str | None = None,
*,
api_key: str | None = None,
timeout: float | None = 30.0,
client_info: str | None = None,
) -> None:
self._low = ComfyLow(base_url, api_key, timeout=timeout, client_info=client_info)
self._low = ComfyLow(_resolve_base_url(), api_key, timeout=timeout, client_info=client_info)
self.assets = AssetFactory(self._low)
self.workflows = WorkflowFactory()
self.jobs = JobFactory(self._low)
Expand Down Expand Up @@ -153,13 +199,14 @@ class AsyncComfy:

def __init__(
self,
base_url: str = COMFY_CLOUD_BASE_URL,
api_key: str | None = None,
*,
api_key: str | None = None,
timeout: float | None = 30.0,
client_info: str | None = None,
) -> None:
self._low = AsyncComfyLow(base_url, api_key, timeout=timeout, client_info=client_info)
self._low = AsyncComfyLow(
_resolve_base_url(), api_key, timeout=timeout, client_info=client_info
)
self.assets = AsyncAssetFactory(self._low)
self.workflows = WorkflowFactory()
self.jobs = AsyncJobFactory(self._low)
Expand Down
24 changes: 21 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

Keeps the SDK's own test suite independent of a real v2 server or proxy. Each
test configures ``server.state`` to drive a specific scenario (dedup hit, hash
mismatch, queue-full-then-ok, SSE reconnect, ...), then points a ``Comfy`` client
at ``server.base_url``.
mismatch, queue-full-then-ok, SSE reconnect, ...); the ``server`` fixture points
the SDK at the stub by setting ``COMFY_BASE_URL``.
"""

from __future__ import annotations
Expand All @@ -18,6 +18,8 @@

import pytest

from comfy_sdk import BASE_URL_ENV_VAR


@dataclass
class ServerState:
Expand Down Expand Up @@ -370,9 +372,25 @@ def _stop_server(srv: _Server) -> None:
srv._thread.join(timeout=5) # type: ignore[attr-defined]


@pytest.fixture(autouse=True)
def _no_ambient_base_url(request, monkeypatch):
"""Keep a developer's own ``COMFY_BASE_URL`` out of the suite.

``tests/integration`` is the exception — that suite is pointed at a live
deployment by this very variable. Restoring it through ``monkeypatch``
leaves the environment as it was found, either way.
"""
if "integration" in request.path.parts:
return
monkeypatch.delenv(BASE_URL_ENV_VAR, raising=False)


@pytest.fixture
def server():
def server(monkeypatch):
srv = _start_server()
# Clients read their target from the environment, so pointing them at the
# stub is part of standing it up: tests just construct ``Comfy()``.
monkeypatch.setenv(BASE_URL_ENV_VAR, srv.base_url)
try:
yield srv
finally:
Expand Down
6 changes: 3 additions & 3 deletions tests/integration/test_gateway_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@

import pytest

from comfy_sdk import Comfy, OutputReady, StatusChange
from comfy_sdk import BASE_URL_ENV_VAR, Comfy, OutputReady, StatusChange
from comfy_sdk.events import Event

BASE_URL = os.environ.get("COMFY_BASE_URL")
BASE_URL = os.environ.get(BASE_URL_ENV_VAR)
API_KEY = os.environ.get("COMFY_API_KEY")
INPUT_NAME = "sdk_e2e_input.png"
WORKFLOW_FILE = os.environ.get("COMFY_WORKFLOW_FILE")
Expand Down Expand Up @@ -94,7 +94,7 @@ def _edit_workflow(image_ref: object) -> tuple[dict, str]:

@pytest.fixture(scope="module")
def client() -> Comfy:
c = Comfy(BASE_URL, api_key=API_KEY)
c = Comfy(api_key=API_KEY)
yield c
c.close()
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand Down
8 changes: 4 additions & 4 deletions tests/test_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def test_dedup_fast_path_skips_upload(server, tmp_path) -> None:
p = tmp_path / "photo.png"
p.write_bytes(data)

with Comfy(server.base_url) as client:
with Comfy() as client:
asset = client.assets.from_file(p)
# Tell the server it already has these exact bytes (dedup hit).
server.state.known_hashes.add(asset.hash)
Expand Down Expand Up @@ -48,7 +48,7 @@ def test_streaming_upload_does_not_buffer_whole_file(server, monkeypatch) -> Non
payload = b"x" * (1024 * 1024)
recorder = _ReadRecorder(payload)

with Comfy(server.base_url) as client:
with Comfy() as client:
# from_stream buffers to hash, so drive the low transport directly with a
# recording file object to observe how the body is read during upload.
low = client._low
Expand All @@ -74,7 +74,7 @@ def test_post_assets_sends_one_multipart_part_per_tag(server, tmp_path) -> None:
p = tmp_path / "tagged.bin"
p.write_bytes(payload)

with Comfy(server.base_url) as client:
with Comfy() as client:
with open(p, "rb") as fh:
client._low.post_assets(
fh,
Expand All @@ -95,7 +95,7 @@ def test_hash_mismatch_surfaced_without_blind_retry(server, tmp_path) -> None:
p = tmp_path / "photo.png"
p.write_bytes(b"some-bytes-that-will-mismatch")

with Comfy(server.base_url) as client:
with Comfy() as client:
asset = client.assets.from_file(p)
with pytest.raises(HashMismatch):
asset.commit()
Expand Down
Loading
Loading