diff --git a/README.md b/README.md index 4395070..939c184 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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") @@ -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://.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://.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. + +### 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://.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("", "")` becomes +`Comfy(api_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 @@ -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") diff --git a/src/comfy_sdk/__init__.py b/src/comfy_sdk/__init__.py index ea9f0b7..399fe99 100644 --- a/src/comfy_sdk/__init__.py +++ b/src/comfy_sdk/__init__.py @@ -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 @@ -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 @@ -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, @@ -69,6 +71,7 @@ # clients "Comfy", "COMFY_CLOUD_BASE_URL", + "BASE_URL_ENV_VAR", "AsyncComfy", # assets / workflows / jobs / outputs "Asset", diff --git a/src/comfy_sdk/client.py b/src/comfy_sdk/client.py index 6d107dd..65544b3 100644 --- a/src/comfy_sdk/client.py +++ b/src/comfy_sdk/client.py @@ -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 @@ -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 @@ -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 + + def _guard_ui_format(workflow: Workflow) -> None: if _core.looks_like_ui_format(workflow.json): raise WorkflowFormatUi( @@ -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) @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py index b249b9f..4f547e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 @@ -18,6 +18,8 @@ import pytest +from comfy_sdk import BASE_URL_ENV_VAR + @dataclass class ServerState: @@ -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: diff --git a/tests/integration/test_gateway_e2e.py b/tests/integration/test_gateway_e2e.py index ede8136..cccb8c6 100644 --- a/tests/integration/test_gateway_e2e.py +++ b/tests/integration/test_gateway_e2e.py @@ -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") @@ -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() diff --git a/tests/test_assets.py b/tests/test_assets.py index 2c51f45..aad89b1 100644 --- a/tests/test_assets.py +++ b/tests/test_assets.py @@ -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) @@ -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 @@ -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, @@ -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() diff --git a/tests/test_async.py b/tests/test_async.py index 89ef835..a9023dd 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -13,7 +13,7 @@ def _wf(client: AsyncComfy): async def test_async_run_and_download(server, tmp_path) -> None: server.state.polls_to_succeed = 2 - async with AsyncComfy(server.base_url) as client: + async with AsyncComfy() as client: job = await client.run(_wf(client)) assert job.status == "succeeded" out = job.get_outputs("13")[0] @@ -22,7 +22,7 @@ async def test_async_run_and_download(server, tmp_path) -> None: async def test_async_events_stream_to_terminal(server) -> None: - async with AsyncComfy(server.base_url) as client: + async with AsyncComfy() as client: job = await client.submit(_wf(client)) seen = [e async for e in job.events()] assert isinstance(seen[-1], StatusChange) @@ -36,7 +36,7 @@ async def test_async_sse_reconnect_with_no_replay(server) -> None: # the async client must reconnect (fresh live frames, nothing replayed). server.state.sse_mode = "reconnect" server.state.polls_to_succeed = 1000 - async with AsyncComfy(server.base_url) as client: + async with AsyncComfy() as client: job = await client.submit(_wf(client)) seen = [e async for e in job.events()] @@ -53,7 +53,7 @@ async def test_async_range_download_returns_partial(server) -> None: # The sync client has range-download coverage (test_download_and_workflows.py); # the async `to_bytes(range=...)` path was never exercised. server.state.content_bytes = b"0123456789abcdef" - async with AsyncComfy(server.base_url) as client: + async with AsyncComfy() as client: job = await client.run(_wf(client)) out = job.get_outputs("13")[0] head = await out.to_bytes(range=(0, 4)) @@ -63,7 +63,7 @@ async def test_async_range_download_returns_partial(server) -> None: async def test_async_dedup_fast_path(server, tmp_path) -> None: p = tmp_path / "photo.png" p.write_bytes(b"async-dedup-bytes") - async with AsyncComfy(server.base_url) as client: + async with AsyncComfy() as client: asset = client.assets.from_file(p) server.state.known_hashes.add(asset.hash) asset_id = await asset.commit() @@ -79,7 +79,7 @@ async def test_async_real_upload_of_fresh_file_succeeds(server, tmp_path) -> Non # AsyncClient instance") the moment httpx tries to send it. p = tmp_path / "fresh.bin" p.write_bytes(b"a fresh, never-before-seen payload that forces a real upload") - async with AsyncComfy(server.base_url) as client: + async with AsyncComfy() as client: asset = client.assets.from_file(p) asset_id = await asset.commit() assert asset_id == "asset_uploaded_01" @@ -90,13 +90,13 @@ async def test_async_real_upload_of_fresh_file_succeeds(server, tmp_path) -> Non async def test_async_error_mapping(server) -> None: server.state.job_error = (422, "missing_asset") - async with AsyncComfy(server.base_url) as client: + async with AsyncComfy() as client: with pytest.raises(MissingAsset): await client.submit(_wf(client)) async def test_async_cancel_reaches_server(server) -> None: - async with AsyncComfy(server.base_url) as client: + async with AsyncComfy() as client: job = await client.submit(_wf(client)) await job.cancel() assert job.status == "canceling" @@ -104,7 +104,7 @@ async def test_async_cancel_reaches_server(server) -> None: async def test_async_wait_raises_timeout(server) -> None: server.state.polls_to_succeed = 10_000 - async with AsyncComfy(server.base_url) as client: + async with AsyncComfy() as client: job = await client.submit(_wf(client)) with pytest.raises(TimeoutError): await job.wait(timeout=0.05) @@ -114,7 +114,7 @@ async def test_async_core_asset_substitution(server, tmp_path) -> None: # The async commit -> mint -> substitute pipeline, mirroring the sync test. p = tmp_path / "photo.png" p.write_bytes(b"pixels") - async with AsyncComfy(server.base_url) as client: + async with AsyncComfy() as client: asset = client.assets.from_file(p) wf = client.workflows.from_json( {"10": {"class_type": "LoadImage", "inputs": {"image": asset}}} @@ -127,7 +127,7 @@ async def test_async_core_asset_substitution(server, tmp_path) -> None: async def test_async_submit_with_api_key_sends_extra_data(server) -> None: # Mirrors the sync `test_submit_with_api_key_sends_extra_data_sibling_of_workflow`. - async with AsyncComfy(server.base_url) as client: + async with AsyncComfy() as client: await client.submit(_wf(client), api_key="comfyui-secret-key") body = server.state.last_jobs_body assert body is not None @@ -135,7 +135,7 @@ async def test_async_submit_with_api_key_sends_extra_data(server) -> None: async def test_async_submit_without_api_key_omits_extra_data(server) -> None: - async with AsyncComfy(server.base_url) as client: + async with AsyncComfy() as client: await client.submit(_wf(client)) body = server.state.last_jobs_body assert body is not None @@ -144,6 +144,6 @@ async def test_async_submit_without_api_key_omits_extra_data(server) -> None: async def test_async_queue_full_retries_with_retry_after(server) -> None: server.state.queue_full_times = 2 # 429 twice, then 201 - async with AsyncComfy(server.base_url) as client: + async with AsyncComfy() as client: await client.submit(_wf(client)) assert server.state.submit_count == 3 diff --git a/tests/test_auth_headers.py b/tests/test_auth_headers.py index 126b21d..487e175 100644 --- a/tests/test_auth_headers.py +++ b/tests/test_auth_headers.py @@ -20,7 +20,7 @@ def _wf(client: Comfy): def test_no_api_key_sends_no_authorization_header_at_all(server) -> None: - with Comfy(server.base_url) as client: + with Comfy() as client: job = client.submit(_wf(client)) job.refresh() assert server.state.last_auth_header == "" @@ -28,7 +28,7 @@ def test_no_api_key_sends_no_authorization_header_at_all(server) -> None: def test_api_key_sends_bearer_token_on_every_request(server) -> None: server.state.require_auth = True - with Comfy(server.base_url, api_key="ck_live_test") as client: + with Comfy(api_key="ck_live_test") as client: job = client.submit(_wf(client)) job.refresh() assert server.state.last_auth_header == "Bearer ck_live_test" diff --git a/tests/test_base_url_env.py b/tests/test_base_url_env.py new file mode 100644 index 0000000..82ba33c --- /dev/null +++ b/tests/test_base_url_env.py @@ -0,0 +1,103 @@ +"""Comfy Cloud by default; ``COMFY_BASE_URL`` is the only way to change target.""" + +from __future__ import annotations + +import pytest + +from comfy_sdk import BASE_URL_ENV_VAR, COMFY_CLOUD_BASE_URL, AsyncComfy, Comfy + +CLOUD_JOB_URL = COMFY_CLOUD_BASE_URL + "/api/v2/jobs/j1" +LOCAL = "http://127.0.0.1:8189" + + +def job_url(client: Comfy | AsyncComfy) -> str: + return client._low._p.url("/jobs/j1") + + +def test_constant_points_at_comfy_cloud() -> None: + assert COMFY_CLOUD_BASE_URL == "https://cloud.comfy.org" + + +def test_env_var_name() -> None: + assert BASE_URL_ENV_VAR == "COMFY_BASE_URL" + + +def test_defaults_to_comfy_cloud() -> None: + with Comfy(api_key="comfyui-test") as client: + assert job_url(client) == CLOUD_JOB_URL + + +def test_env_var_selects_the_deployment(monkeypatch) -> None: + monkeypatch.setenv(BASE_URL_ENV_VAR, LOCAL) + with Comfy() as client: + assert job_url(client) == LOCAL + "/api/v2/jobs/j1" + + +def test_env_var_is_read_per_construction(monkeypatch) -> None: + """A later client picks up a changed target — the value is not import-time.""" + with Comfy() as first: + assert job_url(first) == CLOUD_JOB_URL + monkeypatch.setenv(BASE_URL_ENV_VAR, LOCAL) + with Comfy() as second: + assert job_url(second) == LOCAL + "/api/v2/jobs/j1" + + +@pytest.mark.parametrize("blank", ["", " "]) +def test_blank_env_var_means_comfy_cloud(monkeypatch, blank: str) -> None: + monkeypatch.setenv(BASE_URL_ENV_VAR, blank) + with Comfy() as client: + assert job_url(client) == CLOUD_JOB_URL + + +def test_surrounding_whitespace_is_ignored(monkeypatch) -> None: + monkeypatch.setenv(BASE_URL_ENV_VAR, f" {LOCAL} ") + with Comfy() as client: + assert job_url(client) == LOCAL + "/api/v2/jobs/j1" + + +@pytest.mark.parametrize( + "bad", + [ + "cloud.comfy.org", # no scheme + "ftp://cloud.comfy.org", # not http(s) + "file:///etc/passwd", + "http://", # no host + "http://127.0.0.1:bad", # non-numeric port + "http://127.0.0.1:99999", # port out of range + "https://cloud.comfy.org?x=1", # query would break every request URL + "https://cloud.comfy.org#frag", + "not a url", + ], +) +def test_malformed_env_var_is_rejected(monkeypatch, bad: str) -> None: + monkeypatch.setenv(BASE_URL_ENV_VAR, bad) + with pytest.raises(ValueError, match=BASE_URL_ENV_VAR): + Comfy() + + +def test_base_url_is_not_a_constructor_parameter() -> None: + """The pre-env-var positional form must fail loudly, not read a URL as a key.""" + with pytest.raises(TypeError): + Comfy(LOCAL) # type: ignore[misc] + + +async def test_async_defaults_to_comfy_cloud() -> None: + async with AsyncComfy(api_key="comfyui-test") as client: + assert job_url(client) == CLOUD_JOB_URL + + +async def test_async_env_var_selects_the_deployment(monkeypatch) -> None: + monkeypatch.setenv(BASE_URL_ENV_VAR, LOCAL) + async with AsyncComfy() as client: + assert job_url(client) == LOCAL + "/api/v2/jobs/j1" + + +async def test_async_malformed_env_var_is_rejected(monkeypatch) -> None: + monkeypatch.setenv(BASE_URL_ENV_VAR, "ftp://cloud.comfy.org") + with pytest.raises(ValueError, match=BASE_URL_ENV_VAR): + AsyncComfy() + + +async def test_async_rejects_positional_base_url() -> None: + with pytest.raises(TypeError): + AsyncComfy(LOCAL) # type: ignore[misc] diff --git a/tests/test_content_redirect_security.py b/tests/test_content_redirect_security.py index 636ed05..996bcb6 100644 --- a/tests/test_content_redirect_security.py +++ b/tests/test_content_redirect_security.py @@ -28,7 +28,7 @@ def test_cross_origin_content_redirect_does_not_leak_bearer_token(server, second server.state.redirect_content_to = redirect_url second_server.state.content_bytes = b"served-by-a-completely-different-host" - with Comfy(server.base_url, api_key="ck_super_secret") as client: + with Comfy(api_key="ck_super_secret") as client: job = client.run(_wf(client)) out = job.get_outputs("13")[0] data = out.to_bytes() @@ -52,7 +52,7 @@ def test_cross_origin_content_redirect_to_file_does_not_leak_bearer_token( server.state.redirect_content_to = redirect_url second_server.state.content_bytes = b"also-served-by-the-other-host" - with Comfy(server.base_url, api_key="ck_super_secret") as client: + with Comfy(api_key="ck_super_secret") as client: job = client.run(_wf(client)) out = job.get_outputs("13")[0] dest = out.to_file(tmp_path / "out.bin") diff --git a/tests/test_default_base_url.py b/tests/test_default_base_url.py deleted file mode 100644 index df2a861..0000000 --- a/tests/test_default_base_url.py +++ /dev/null @@ -1,33 +0,0 @@ -"""The hosted deployment is the default; every other target passes its own URL.""" - -from __future__ import annotations - -from comfy_sdk import COMFY_CLOUD_BASE_URL, AsyncComfy, Comfy - -CLOUD_JOB_URL = COMFY_CLOUD_BASE_URL + "/api/v2/jobs/j1" -LOCAL = "http://127.0.0.1:8189" - - -def test_constant_points_at_comfy_cloud() -> None: - assert COMFY_CLOUD_BASE_URL == "https://cloud.comfy.org" - - -def test_defaults_to_comfy_cloud_when_no_url_given() -> None: - with Comfy(api_key="comfyui-test") as client: - assert client._low._p.url("/jobs/j1") == CLOUD_JOB_URL - - -def test_explicit_base_url_still_wins() -> None: - """Self-hosted callers, and the positional form every existing caller uses.""" - with Comfy(LOCAL, "comfyui-test") as client: - assert client._low._p.url("/jobs/j1") == LOCAL + "/api/v2/jobs/j1" - - -async def test_async_defaults_to_comfy_cloud_when_no_url_given() -> None: - async with AsyncComfy(api_key="comfyui-test") as client: - assert client._low._p.url("/jobs/j1") == CLOUD_JOB_URL - - -async def test_async_explicit_base_url_still_wins() -> None: - async with AsyncComfy(LOCAL, "comfyui-test") as client: - assert client._low._p.url("/jobs/j1") == LOCAL + "/api/v2/jobs/j1" diff --git a/tests/test_download_and_workflows.py b/tests/test_download_and_workflows.py index 0e6c0b2..605e82e 100644 --- a/tests/test_download_and_workflows.py +++ b/tests/test_download_and_workflows.py @@ -12,7 +12,7 @@ def _wf(client: Comfy): def test_range_download_returns_partial(server) -> None: server.state.content_bytes = b"0123456789abcdef" - with Comfy(server.base_url) as client: + with Comfy() as client: job = client.run(_wf(client)) out = job.get_outputs("13")[0] head = out.to_bytes(range=(0, 4)) @@ -24,7 +24,7 @@ def test_range_download_to_file_writes_only_the_requested_slice(server, tmp_path # disk through a separate code path (chunked writes, not a bytearray) and # had no coverage at all. server.state.content_bytes = b"0123456789abcdef" - with Comfy(server.base_url) as client: + with Comfy() as client: job = client.run(_wf(client)) out = job.get_outputs("13")[0] dest = out.to_file(tmp_path / "partial.bin", range=(4, 9)) @@ -32,7 +32,7 @@ def test_range_download_to_file_writes_only_the_requested_slice(server, tmp_path def test_full_download(server, tmp_path) -> None: - with Comfy(server.base_url) as client: + with Comfy() as client: job = client.run(_wf(client)) out = job.get_outputs("13")[0] dest = out.to_file(tmp_path / "out.bin") @@ -42,7 +42,7 @@ def test_full_download(server, tmp_path) -> None: def test_core_asset_substitution(server, tmp_path) -> None: p = tmp_path / "photo.png" p.write_bytes(b"pixels") - with Comfy(server.base_url) as client: + with Comfy() as client: server.state.known_hashes # dedup not seeded -> will upload asset = client.assets.from_file(p) wf = client.workflows.from_json( diff --git a/tests/test_events.py b/tests/test_events.py index e054aec..62cbc13 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -10,7 +10,7 @@ def _wf(client: Comfy): def test_typed_events_stream_to_terminal(server) -> None: - with Comfy(server.base_url) as client: + with Comfy() as client: job = client.submit(_wf(client)) seen = list(job.events()) @@ -34,7 +34,7 @@ def test_sse_reconnect_with_no_replay(server) -> None: # Keep the poll-authoritative backstop reporting "running" so the client # reconnects to the stream rather than short-circuiting to terminal on poll. server.state.polls_to_succeed = 1000 - with Comfy(server.base_url) as client: + with Comfy() as client: job = client.submit(_wf(client)) seen = list(job.events()) @@ -56,7 +56,7 @@ def test_events_polls_to_terminal_when_stream_ends_without_terminal(server) -> N # StatusChange instead of reconnecting. server.state.sse_mode = "reconnect" # 1st connection: one progress frame, clean close server.state.polls_to_succeed = 1 - with Comfy(server.base_url) as client: + with Comfy() as client: job = client.submit(_wf(client)) seen = list(job.events()) @@ -72,7 +72,7 @@ def test_events_end_silently_when_events_endpoint_not_implemented(server) -> Non was introduced, ``events()`` raised the protocol-level ``ApiError`` (code ``not_implemented``, http_status 501).""" server.state.events_not_implemented = True - with Comfy(server.base_url) as client: + with Comfy() as client: job = client.submit(_wf(client)) assert list(job.events()) == [] assert job.wait().status == "succeeded" @@ -82,7 +82,7 @@ def test_events_end_silently_when_events_endpoint_not_implemented(server) -> Non async def test_async_events_end_silently_when_events_endpoint_not_implemented(server) -> None: server.state.events_not_implemented = True - async with AsyncComfy(server.base_url) as client: + async with AsyncComfy() as client: job = await client.submit(_wf(client)) assert [e async for e in job.events()] == [] assert (await job.wait()).status == "succeeded" diff --git a/tests/test_get_download_url.py b/tests/test_get_download_url.py index 58b8f06..d1a3b6b 100644 --- a/tests/test_get_download_url.py +++ b/tests/test_get_download_url.py @@ -78,7 +78,7 @@ async def test_async_cloud_redirect_returns_signed_url_and_computed_expiry(serve def test_output_get_download_url_on_cloud_redirect(server) -> None: server.state.redirect_content_to = _SIGNED_URL - with Comfy(server.base_url) as client: + with Comfy() as client: job = client.run(_wf(client)) out = job.get_outputs("13")[0] download = out.get_download_url() @@ -107,7 +107,7 @@ async def test_async_self_hosted_returns_content_url_and_no_expiry(server) -> No def test_output_get_download_url_on_self_hosted(server) -> None: - with Comfy(server.base_url) as client: + with Comfy() as client: job = client.run(_wf(client)) out = job.get_outputs("13")[0] download = out.get_download_url() # must not throw @@ -123,7 +123,7 @@ def test_multi_output_job_each_get_download_url_is_distinct(server) -> None: _output_json("13", "asset_out_01"), _output_json("14", "asset_out_02"), ] - with Comfy(server.base_url) as client: + with Comfy() as client: job = client.run(_wf(client)) urls = {out.id: out.get_download_url().url for out in job.outputs} @@ -138,7 +138,7 @@ async def test_async_multi_output_job_each_get_download_url_is_distinct(server) _output_json("13", "asset_out_01"), _output_json("14", "asset_out_02"), ] - async with AsyncComfy(server.base_url) as client: + async with AsyncComfy() as client: job = await client.run(_wf(client)) urls = {} for out in job.outputs: @@ -157,7 +157,7 @@ def test_redirect_target_never_contacted_bearer_never_leaked(server, second_serv redirect_url = f"{second_server.base_url}/api/v2/assets/asset_out_01/content" server.state.redirect_content_to = redirect_url - with Comfy(server.base_url, api_key="ck_super_secret") as client: + with Comfy(api_key="ck_super_secret") as client: job = client.run(_wf(client)) out = job.get_outputs("13")[0] download = out.get_download_url() diff --git a/tests/test_jobs.py b/tests/test_jobs.py index decbc18..6c7c567 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -27,7 +27,7 @@ def _wf(client: Comfy): def test_run_completes_via_polling_when_sse_absent(server, tmp_path) -> None: # run() never touches the SSE stream — completion rests on polling. server.state.polls_to_succeed = 3 - with Comfy(server.base_url) as client: + with Comfy() as client: job = client.run(_wf(client)) assert job.status == "succeeded" outs = job.get_outputs("13") @@ -40,7 +40,7 @@ def test_run_completes_via_polling_when_sse_absent(server, tmp_path) -> None: def test_idempotent_submit_rejects_reused_key(server) -> None: # Keys are single-use (reject-on-duplicate, no replay): reusing the same # explicit key raises IdempotencyKeyReuse rather than replaying the job. - with Comfy(server.base_url) as client: + with Comfy() as client: wf = _wf(client) j1 = client.submit(wf, idempotency_key="stable-key-123") assert j1.id.startswith("job_") @@ -53,7 +53,7 @@ def test_idempotent_submit_rejects_reused_key(server) -> None: def test_low_level_submit_rejects_reused_key(server) -> None: # The low layer surfaces the protocol-level IdempotencyKeyReuse on reuse; # post_jobs returns a Job directly (there is no replay flag / header). - with Comfy(server.base_url) as client: + with Comfy() as client: job = client._low.post_jobs({"a": 1}, idempotency_key="k") assert job.id.startswith("job_") with pytest.raises(LowIdempotencyKeyReuse): @@ -62,7 +62,7 @@ def test_low_level_submit_rejects_reused_key(server) -> None: def test_queue_full_retries_with_retry_after(server) -> None: server.state.queue_full_times = 2 # 429 twice, then 201 - with Comfy(server.base_url) as client: + with Comfy() as client: job = client.submit(_wf(client)) assert job.id.startswith("job_") assert server.state.submit_count == 3 # two rejections + one success @@ -75,7 +75,7 @@ def test_queue_full_gives_up_once_retry_budget_elapses(server, monkeypatch) -> N # the retry budget (`_QUEUE_RETRY_BUDGET`) has elapsed. server.state.queue_full_times = 1_000_000 monkeypatch.setattr(_client_module, "_QUEUE_RETRY_BUDGET", -1.0) # already elapsed - with Comfy(server.base_url) as client: + with Comfy() as client: with pytest.raises(QueueFull): client.submit(_wf(client)) # Exactly one attempt reached the server: the budget was spent before the @@ -85,20 +85,20 @@ def test_queue_full_gives_up_once_retry_budget_elapses(server, monkeypatch) -> N def test_missing_asset_maps_to_typed_exception(server) -> None: server.state.job_error = (422, "missing_asset") - with Comfy(server.base_url) as client: + with Comfy() as client: with pytest.raises(MissingAsset): client.submit(_wf(client)) def test_invalid_workflow_maps_to_typed_exception(server) -> None: server.state.job_error = (422, "invalid_workflow") - with Comfy(server.base_url) as client: + with Comfy() as client: with pytest.raises(InvalidWorkflow): client.submit(_wf(client)) def test_ui_format_workflow_rejected_client_side(server) -> None: - with Comfy(server.base_url) as client: + with Comfy() as client: wf = client.workflows.from_json({"nodes": [], "links": [], "last_node_id": 0}) with pytest.raises(WorkflowFormatUi): client.submit(wf) @@ -109,21 +109,21 @@ def test_ui_format_workflow_rejected_client_side(server) -> None: def test_failed_job_raises_job_failed(server) -> None: server.state.polls_to_succeed = 1 server.state.terminal_status = "failed" - with Comfy(server.base_url) as client: + with Comfy() as client: with pytest.raises(JobFailed): client.run(_wf(client)) def test_unauthorized_when_key_required_but_missing(server) -> None: server.state.require_auth = True - with Comfy(server.base_url) as client: # no api_key + with Comfy() as client: # no api_key with pytest.raises(Unauthorized): client.submit(_wf(client)) def test_authorized_when_key_present(server) -> None: server.state.require_auth = True - with Comfy(server.base_url, api_key="ck_test") as client: + with Comfy(api_key="ck_test") as client: job = client.submit(_wf(client)) assert job.id.startswith("job_") @@ -131,7 +131,7 @@ def test_authorized_when_key_present(server) -> None: def test_submit_with_api_key_sends_extra_data_sibling_of_workflow(server) -> None: # The partner-node API key must ride alongside `workflow` as `extra_data`, # not nested inside it, and use the exact wire key `api_key_comfy_org`. - with Comfy(server.base_url) as client: + with Comfy() as client: client.submit(_wf(client), api_key="comfyui-secret-key") body = server.state.last_jobs_body assert body is not None @@ -142,7 +142,7 @@ def test_submit_with_api_key_sends_extra_data_sibling_of_workflow(server) -> Non def test_submit_without_api_key_omits_extra_data_entirely(server) -> None: # No key supplied -> no `extra_data` key at all (never an empty object). - with Comfy(server.base_url) as client: + with Comfy() as client: client.submit(_wf(client)) body = server.state.last_jobs_body assert body is not None @@ -152,7 +152,7 @@ def test_submit_without_api_key_omits_extra_data_entirely(server) -> None: def test_submit_with_empty_string_api_key_omits_extra_data(server) -> None: # An empty string is "no key": no `extra_data` on the wire. Pinned so the # TypeScript SDK stays in lockstep with this behavior. - with Comfy(server.base_url) as client: + with Comfy() as client: client.submit(_wf(client), api_key="") body = server.state.last_jobs_body assert body is not None @@ -161,7 +161,7 @@ def test_submit_with_empty_string_api_key_omits_extra_data(server) -> None: def test_run_forwards_api_key_to_submit(server) -> None: # `run()` is submit-then-wait; the api_key must still reach the wire. - with Comfy(server.base_url) as client: + with Comfy() as client: client.run(_wf(client), api_key="comfyui-secret-key") body = server.state.last_jobs_body assert body is not None @@ -171,7 +171,7 @@ def test_run_forwards_api_key_to_submit(server) -> None: def test_cancel_reaches_server_and_marks_canceling(server) -> None: # cancel() hits the server and reflects its `canceling` response, which is # deliberately NOT a terminal state. - with Comfy(server.base_url) as client: + with Comfy() as client: job = client.submit(_wf(client)) job.cancel() assert job.status == "canceling" @@ -179,7 +179,7 @@ def test_cancel_reaches_server_and_marks_canceling(server) -> None: def test_wait_raises_timeout_when_job_never_terminal(server) -> None: server.state.polls_to_succeed = 10_000 # never terminal within the deadline - with Comfy(server.base_url) as client: + with Comfy() as client: job = client.submit(_wf(client)) with pytest.raises(TimeoutError): job.wait(timeout=0.05) @@ -187,6 +187,6 @@ def test_wait_raises_timeout_when_job_never_terminal(server) -> None: def test_run_raises_timeout_when_job_never_terminal(server) -> None: server.state.polls_to_succeed = 10_000 - with Comfy(server.base_url) as client: + with Comfy() as client: with pytest.raises(TimeoutError): client.run(_wf(client), timeout=0.05) diff --git a/tests/test_sse_idle.py b/tests/test_sse_idle.py index 0231146..c3b5f48 100644 --- a/tests/test_sse_idle.py +++ b/tests/test_sse_idle.py @@ -24,7 +24,7 @@ def test_events_recovers_from_zombie_stream(server, monkeypatch) -> None: server.state.stall_seconds = 3.0 server.state.polls_to_succeed = 1 # poll fallback sees terminal immediately - with Comfy(server.base_url) as client: + with Comfy() as client: job = client.submit(_wf(client)) t0 = time.monotonic() seen = list(job.events()) # MUST NOT hang @@ -46,7 +46,7 @@ async def test_async_events_recovers_from_zombie_stream(server, monkeypatch) -> server.state.stall_seconds = 3.0 server.state.polls_to_succeed = 1 - async with AsyncComfy(server.base_url) as client: + async with AsyncComfy() as client: job = await client.submit(_wf(client)) t0 = time.monotonic() seen = [e async for e in job.events()] # MUST NOT hang @@ -65,7 +65,7 @@ def test_get_job_events_timeout_none_opts_out_of_idle_timeout(server, monkeypatc server.state.sse_mode = "stall" server.state.stall_seconds = 0.8 # held open, then the stub closes - with Comfy(server.base_url) as client: + with Comfy() as client: job = client.submit(_wf(client)) events_url = job._model.urls.events t0 = time.monotonic() diff --git a/tests/test_user_agent.py b/tests/test_user_agent.py index 2eabd60..8e1a361 100644 --- a/tests/test_user_agent.py +++ b/tests/test_user_agent.py @@ -16,7 +16,7 @@ def _wf(client: Comfy): def test_user_agent_identifies_the_sdk(server) -> None: - with Comfy(server.base_url) as client: + with Comfy() as client: job = client.submit(_wf(client)) job.refresh() ua = server.state.last_user_agent or "" @@ -26,7 +26,7 @@ def test_user_agent_identifies_the_sdk(server) -> None: def test_client_info_appends_app_token(server) -> None: - with Comfy(server.base_url, client_info="glary-bot") as client: + with Comfy(client_info="glary-bot") as client: job = client.submit(_wf(client)) job.refresh() ua = server.state.last_user_agent or "" @@ -38,4 +38,4 @@ def test_client_info_rejects_crlf() -> None: # A CR/LF in the caller token must never reach the header (no injection). for bad in ("evil\r\nX-Injected: 1", "line\nbreak", "carriage\rreturn"): with pytest.raises(ValueError): - Comfy("https://cloud.comfy.org", client_info=bad) + Comfy(client_info=bad)