Skip to content
Open
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
106 changes: 88 additions & 18 deletions comfy_cli/command/assets_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from comfy_cli import tracking
from comfy_cli.command.cloud_http import (
ResponseUnparseable,
cloud_target_or_local_error,
handle_cloud_http_error,
http_request,
Expand Down Expand Up @@ -56,29 +57,98 @@ def ls_cmd(
url = target.url("assets") + "?" + urllib.parse.urlencode(params)

try:
_, body = http_request(url, target)
# `strict_json` so a 200 carrying a proxy/captive-portal error page is
# reported as malformed rather than collapsed to `None`, which is
# indistinguishable here from a genuinely empty body — and would render
# a broken response as a successful EMPTY library, the same masquerade
# the shape guards below refuse to make.
_, body = http_request(url, target, strict_json=True)
except (urllib.error.HTTPError, urllib.error.URLError, OSError) as e:
raise handle_cloud_http_error(renderer, e, operation="list") from e
except ResponseUnparseable as e:
renderer.error(
code="cloud_http_error",
message="unexpected response from /api/assets (body was not valid JSON)",
hint="check whether a proxy or captive portal is intercepting the request",
details={"operation": "list"},
)
raise typer.Exit(code=1) from e

# A non-empty body decodes here only if it was valid JSON, but valid JSON is
# not necessarily the shape we asked for: a proxy or error page can answer 200
# with an array or a scalar, and `b.get(...)` would then raise a raw
# `AttributeError` past the `except` above — a traceback instead of an error
# envelope. Guard it the way `workflow list` guards the same failure on the
# sibling endpoint. An empty body stays a legitimate `None` (→ no rows).
if body is not None and not isinstance(body, dict):
renderer.error(
code="cloud_http_error",
message="unexpected response shape from /api/assets (expected a JSON object)",
details={"got_type": type(body).__name__},
)
raise typer.Exit(code=1)

rows = (body or {}).get("assets") or []
b = body or {}
Comment thread
mattmillerai marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# A missing/empty `assets` is a legitimately-empty listing; a present non-list
# `assets` is malformed the same way, and `len(rows)` would raise `TypeError`.
rows = b.get("assets")
if rows is None:
rows = []
elif not isinstance(rows, list):
renderer.error(
code="cloud_http_error",
message="unexpected response shape from /api/assets (assets must be a JSON array)",
details={"got_type": type(rows).__name__},
)
raise typer.Exit(code=1)
assets = [
{
"id": r.get("id"),
"name": r.get("name"),
"hash": r.get("hash"),
"mime_type": r.get("mime_type"),
"size": r.get("size"),
"tags": r.get("tags"),
"preview_url": r.get("preview_url"),
"job_id": r.get("job_id"),
"created_at": r.get("created_at"),
}
for r in rows
if isinstance(r, dict)
]
payload = {
"count": len(rows),
"assets": [
{
"id": r.get("id"),
"name": r.get("name"),
"hash": r.get("hash"),
"mime_type": r.get("mime_type"),
"size": r.get("size"),
"tags": r.get("tags"),
"preview_url": r.get("preview_url"),
"job_id": r.get("job_id"),
"created_at": r.get("created_at"),
}
for r in rows
if isinstance(r, dict)
],
# `count` describes the array actually emitted, not the raw server rows.
# A non-dict row is dropped from `assets` above, so counting raw rows
# reported more items than the payload carries — and a knowingly-skewed
# `count` sitting next to a forwarded `total` undercuts the point of
# forwarding an authoritative count at all. Identical to the old value
# for every well-formed response; it differs only where the old value
# was simply wrong. Matches the repo convention in
# `comfy_cli/command/nodes.py`, where `count` is over the emitted rows.
"count": len(assets),
"assets": assets,
}
# Forward the server's own truncation signal instead of making callers infer
# it from an exactly-full page: `has_more`/`total` are both `required` on the
# cloud API's `ListAssetsResponse`, and `has_more` comes off a limit+1
# sentinel row rather than off the returned row count, so it stays correct
# on a page that comes back short. Only forward what the server actually
# sent — an older or local server may omit them, and a JSON `null` in the
# envelope would poison a consumer's type assertion, so omit the key rather
# than emitting None.
#
# Each field is checked against its OWN type, not a shared bool-or-int test:
# `bool` is a subclass of `int` in Python, so one shared check would forward
# `has_more: 0` and `total: false` and emit an envelope that violates
# schemas/assets_library.json — the very contract this command publishes.
if isinstance(b.get("has_more"), bool):
payload["has_more"] = b["has_more"]
total = b.get("total")
# `>= 0` because the schema publishes `total` as a non-negative integer, and a
# guard looser than the declared contract is how the envelope ends up violating
# its own schema.
if isinstance(total, int) and not isinstance(total, bool) and total >= 0:
payload["total"] = total
renderer.emit(payload, command="assets library ls", where="cloud")


Expand Down
46 changes: 42 additions & 4 deletions comfy_cli/command/cloud_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,32 @@ def _authed_request(
return req


class ResponseUnparseable(Exception):
"""A non-empty response body that was not valid JSON.

Only raised when a caller opts in via ``http_request(..., strict_json=True)``;
otherwise a decode failure keeps collapsing to ``None`` as it always has.
"""


def http_request(
url: str, target, *, method: str = "GET", body: dict | None = None, timeout: float = 30.0
url: str,
target,
*,
method: str = "GET",
body: dict | None = None,
timeout: float = 30.0,
strict_json: bool = False,
) -> tuple[int, dict | None]:
"""Authed HTTP call returning (status, parsed_json_or_none). Raises
urllib errors verbatim so callers can surface the right error code."""
urllib errors verbatim so callers can surface the right error code.

An *empty* body is reported as ``None``. A *non-empty* body that fails to
decode is reported as ``None`` too, unless ``strict_json`` is set — then it
raises ``ResponseUnparseable``. The two are different answers ("nothing to
say" vs. "a malformed answer"), and a listing caller that conflates them
reports a proxy or captive-portal error page as a successful empty listing.
"""
import urllib.request

data = json.dumps(body).encode("utf-8") if body is not None else None
Expand All @@ -59,11 +80,28 @@ def http_request(
with urllib.request.urlopen(req, timeout=timeout) as resp:
status = resp.status
raw = resp.read(64 * 1024 * 1024) # 64 MiB cap
if not raw:
# `.strip()` so a whitespace-only body counts as empty rather than as a
# decode failure: identical to the old behaviour on the default path (it
# collapsed to `None` either way), but it keeps `strict_json` from calling a
# server that answers `b"\n"` malformed.
if not raw.strip():
return status, None
try:
return status, json.loads(raw)
except json.JSONDecodeError:
except (ValueError, RecursionError) as e:
# Catch the ``ValueError`` BASE, not ``JSONDecodeError``: handed raw bytes,
# ``json.loads`` rejects a malformed body with three different exceptions and
# only one of them is a ``JSONDecodeError``. Non-UTF-8 bytes (``b"\xff"``, a
# binary error page) raise ``UnicodeDecodeError``; a JSON integer past
# CPython's 4300-digit int/str limit raises a bare ``ValueError``; both
# subclass ``ValueError``. Pathologically nested input raises
# ``RecursionError``, which does not — the 64 MiB read permits deep nesting.
# Narrowing to ``JSONDecodeError`` let those escape past ``strict_json`` and
# past every call site's ``except``, crashing the CLI with a raw traceback
# for exactly the malformed response this helper exists to classify. Same
# reasoning, and the same catch, as the sibling helper in ``workflow.py``.
if strict_json:
raise ResponseUnparseable(f"non-JSON response body from {url}") from e
return status, None


Expand Down
2 changes: 2 additions & 0 deletions comfy_cli/schemas/assets_library.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
}
}
},
"has_more": { "type": "boolean" },
"total": { "type": "integer", "minimum": 0 },
"id": { "type": ["string", "null"] },
"hash": { "type": ["string", "null"] },
"created_new": { "type": ["boolean", "null"] }
Expand Down
Loading
Loading