mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-07-03 21:20:49 +08:00
feat: implement remaining listAssets contract fields (display_name, hash filter, include_public)
Bring GET /api/assets into param-for-param parity with the projected openapi.yaml listAssets contract for the three remaining fields: - Add display_name to the Asset response schema (nullable, mirrors name) and populate it from ref.name in _build_asset_response, covering list, get, create, update, and upload responses uniformly. - Add the hash query param to ListAssetsQuery (named hash per the spec, not asset_hash) with before-validation strip/lower normalization, and thread it through list_assets_page -> list_references_page, filtering both the page and count statements on Asset.hash for consistency. - Accept include_public (bool, default true) for contract parity; it is inert in core (no public asset pool) and intentionally not passed to the service layer. Cursor pagination and size optionality are untouched. Add integration tests covering display_name mirroring, exact hash match, unknown-hash empty page, and include_public acceptance.
This commit is contained in:
parent
ba3f697dbb
commit
58df1a3564
@ -165,6 +165,8 @@ def _build_asset_response(result: schemas.AssetDetailResult | schemas.UploadResu
|
|||||||
return schemas_out.Asset(
|
return schemas_out.Asset(
|
||||||
id=result.ref.id,
|
id=result.ref.id,
|
||||||
name=result.ref.name,
|
name=result.ref.name,
|
||||||
|
# Mirror name for backwards compatibility; ref.name is always set.
|
||||||
|
display_name=result.ref.name,
|
||||||
hash=asset_content_hash,
|
hash=asset_content_hash,
|
||||||
asset_hash=asset_content_hash,
|
asset_hash=asset_content_hash,
|
||||||
size=int(result.asset.size_bytes) if result.asset else None,
|
size=int(result.asset.size_bytes) if result.asset else None,
|
||||||
@ -219,6 +221,7 @@ async def list_assets_route(request: web.Request) -> web.Response:
|
|||||||
exclude_tags=q.exclude_tags,
|
exclude_tags=q.exclude_tags,
|
||||||
name_contains=q.name_contains,
|
name_contains=q.name_contains,
|
||||||
metadata_filter=q.metadata_filter,
|
metadata_filter=q.metadata_filter,
|
||||||
|
asset_hash=q.hash,
|
||||||
limit=q.limit,
|
limit=q.limit,
|
||||||
offset=q.offset,
|
offset=q.offset,
|
||||||
sort=sort,
|
sort=sort,
|
||||||
|
|||||||
@ -54,6 +54,16 @@ class ListAssetsQuery(BaseModel):
|
|||||||
exclude_tags: list[str] = Field(default_factory=list)
|
exclude_tags: list[str] = Field(default_factory=list)
|
||||||
name_contains: str | None = None
|
name_contains: str | None = None
|
||||||
|
|
||||||
|
# Filter to assets whose content hash matches exactly. Param name is `hash`
|
||||||
|
# per the projected openapi.yaml listAssets contract (the response-body field
|
||||||
|
# is `asset_hash`; the query param is `hash`).
|
||||||
|
hash: str | None = None
|
||||||
|
|
||||||
|
# Declared for cloud/core contract parity. Core has no public asset pool, so
|
||||||
|
# this is inert: results are always the caller's own assets. Accepted (not
|
||||||
|
# rejected) so the FE needs no isCloud branch.
|
||||||
|
include_public: bool = True
|
||||||
|
|
||||||
# Accept either a JSON string (query param) or a dict
|
# Accept either a JSON string (query param) or a dict
|
||||||
metadata_filter: dict[str, Any] | None = None
|
metadata_filter: dict[str, Any] | None = None
|
||||||
|
|
||||||
@ -86,6 +96,17 @@ class ListAssetsQuery(BaseModel):
|
|||||||
return out
|
return out
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator("hash", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_hash(cls, v):
|
||||||
|
# Normalize for an exact match against stored hashes (which are
|
||||||
|
# lowercase `blake3:<hex>`). Liberal in what we accept — no pattern
|
||||||
|
# enforcement; a non-matching value simply yields an empty page.
|
||||||
|
if isinstance(v, str):
|
||||||
|
v = v.strip().lower()
|
||||||
|
return v or None
|
||||||
|
return v
|
||||||
|
|
||||||
@field_validator("metadata_filter", mode="before")
|
@field_validator("metadata_filter", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _parse_metadata_json(cls, v):
|
def _parse_metadata_json(cls, v):
|
||||||
|
|||||||
@ -10,6 +10,8 @@ class Asset(BaseModel):
|
|||||||
|
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
|
# Mirrors `name` for backwards compatibility.
|
||||||
|
display_name: str | None = None
|
||||||
hash: str | None = None
|
hash: str | None = None
|
||||||
asset_hash: str | None = None
|
asset_hash: str | None = None
|
||||||
size: int | None = None
|
size: int | None = None
|
||||||
|
|||||||
@ -261,6 +261,7 @@ def list_references_page(
|
|||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
name_contains: str | None = None,
|
name_contains: str | None = None,
|
||||||
|
asset_hash: str | None = None,
|
||||||
include_tags: Sequence[str] | None = None,
|
include_tags: Sequence[str] | None = None,
|
||||||
exclude_tags: Sequence[str] | None = None,
|
exclude_tags: Sequence[str] | None = None,
|
||||||
metadata_filter: dict | None = None,
|
metadata_filter: dict | None = None,
|
||||||
@ -293,6 +294,9 @@ def list_references_page(
|
|||||||
escaped, esc = escape_sql_like_string(name_contains)
|
escaped, esc = escape_sql_like_string(name_contains)
|
||||||
base = base.where(AssetReference.name.ilike(f"%{escaped}%", escape=esc))
|
base = base.where(AssetReference.name.ilike(f"%{escaped}%", escape=esc))
|
||||||
|
|
||||||
|
if asset_hash:
|
||||||
|
base = base.where(Asset.hash == asset_hash)
|
||||||
|
|
||||||
base = apply_tag_filters(base, include_tags, exclude_tags)
|
base = apply_tag_filters(base, include_tags, exclude_tags)
|
||||||
base = apply_metadata_filter(base, metadata_filter)
|
base = apply_metadata_filter(base, metadata_filter)
|
||||||
|
|
||||||
@ -345,6 +349,8 @@ def list_references_page(
|
|||||||
count_stmt = count_stmt.where(
|
count_stmt = count_stmt.where(
|
||||||
AssetReference.name.ilike(f"%{escaped}%", escape=esc)
|
AssetReference.name.ilike(f"%{escaped}%", escape=esc)
|
||||||
)
|
)
|
||||||
|
if asset_hash:
|
||||||
|
count_stmt = count_stmt.where(Asset.hash == asset_hash)
|
||||||
count_stmt = apply_tag_filters(count_stmt, include_tags, exclude_tags)
|
count_stmt = apply_tag_filters(count_stmt, include_tags, exclude_tags)
|
||||||
count_stmt = apply_metadata_filter(count_stmt, metadata_filter)
|
count_stmt = apply_metadata_filter(count_stmt, metadata_filter)
|
||||||
|
|
||||||
|
|||||||
@ -274,6 +274,7 @@ def list_assets_page(
|
|||||||
exclude_tags: Sequence[str] | None = None,
|
exclude_tags: Sequence[str] | None = None,
|
||||||
name_contains: str | None = None,
|
name_contains: str | None = None,
|
||||||
metadata_filter: dict | None = None,
|
metadata_filter: dict | None = None,
|
||||||
|
asset_hash: str | None = None,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
sort: str = "created_at",
|
sort: str = "created_at",
|
||||||
@ -319,6 +320,7 @@ def list_assets_page(
|
|||||||
exclude_tags=exclude_tags,
|
exclude_tags=exclude_tags,
|
||||||
name_contains=name_contains,
|
name_contains=name_contains,
|
||||||
metadata_filter=metadata_filter,
|
metadata_filter=metadata_filter,
|
||||||
|
asset_hash=asset_hash,
|
||||||
limit=fetch_limit,
|
limit=fetch_limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
sort=sort,
|
sort=sort,
|
||||||
|
|||||||
@ -306,6 +306,85 @@ def test_list_assets_invalid_query_rejected(http: requests.Session, api_base: st
|
|||||||
assert body["error"]["code"] == error_code
|
assert body["error"]["code"] == error_code
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_assets_display_name_mirrors_name(http, api_base, asset_factory, make_asset_bytes):
|
||||||
|
"""`display_name` is emitted and mirrors `name` for every populated asset."""
|
||||||
|
scope = f"lf-dispname-{uuid.uuid4().hex[:6]}"
|
||||||
|
tags = ["models", "checkpoints", "unit-tests", scope]
|
||||||
|
asset_factory("dn_a.safetensors", tags, {}, make_asset_bytes("dn_a", 700))
|
||||||
|
asset_factory("dn_b.safetensors", tags, {}, make_asset_bytes("dn_b", 700))
|
||||||
|
|
||||||
|
r = http.get(
|
||||||
|
api_base + "/api/assets",
|
||||||
|
params={"include_tags": f"unit-tests,{scope}", "limit": "50"},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
body = r.json()
|
||||||
|
assert r.status_code == 200, body
|
||||||
|
assert body["assets"], "expected at least one asset"
|
||||||
|
for asset in body["assets"]:
|
||||||
|
assert "display_name" in asset, "populated asset must emit display_name"
|
||||||
|
assert asset["display_name"] == asset["name"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_assets_hash_filter_exact_match(http, api_base, asset_factory, make_asset_bytes):
|
||||||
|
"""`hash` filters to assets whose content hash matches exactly."""
|
||||||
|
scope = f"lf-hash-{uuid.uuid4().hex[:6]}"
|
||||||
|
tags = ["models", "checkpoints", "unit-tests", scope]
|
||||||
|
a = asset_factory("hf_a.safetensors", tags, {}, make_asset_bytes("hf_a", 1024))
|
||||||
|
b = asset_factory("hf_b.safetensors", tags, {}, make_asset_bytes("hf_b", 2048))
|
||||||
|
|
||||||
|
target = a["hash"]
|
||||||
|
assert target and a["hash"] != b["hash"], "fixtures must have distinct content hashes"
|
||||||
|
|
||||||
|
r = http.get(
|
||||||
|
api_base + "/api/assets",
|
||||||
|
params={"hash": target, "limit": "50"},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
body = r.json()
|
||||||
|
assert r.status_code == 200, body
|
||||||
|
names = [x["name"] for x in body["assets"]]
|
||||||
|
assert names == [a["name"]]
|
||||||
|
assert body["total"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_assets_hash_filter_no_match(http, api_base, asset_factory, make_asset_bytes):
|
||||||
|
"""A well-formed but unknown hash returns an empty page (200)."""
|
||||||
|
scope = f"lf-hash-none-{uuid.uuid4().hex[:6]}"
|
||||||
|
tags = ["models", "checkpoints", "unit-tests", scope]
|
||||||
|
asset_factory("hn_a.safetensors", tags, {}, make_asset_bytes("hn_a", 800))
|
||||||
|
|
||||||
|
unknown = "blake3:" + ("0" * 64)
|
||||||
|
r = http.get(
|
||||||
|
api_base + "/api/assets",
|
||||||
|
params={"hash": unknown, "limit": "50"},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
body = r.json()
|
||||||
|
assert r.status_code == 200, body
|
||||||
|
assert body["assets"] == []
|
||||||
|
assert body["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_assets_include_public_accepted(http, api_base, asset_factory, make_asset_bytes):
|
||||||
|
"""`include_public` is accepted for contract parity; core results are always
|
||||||
|
the caller's own assets regardless of its value (the param is inert)."""
|
||||||
|
scope = f"lf-incpub-{uuid.uuid4().hex[:6]}"
|
||||||
|
tags = ["models", "checkpoints", "unit-tests", scope]
|
||||||
|
a = asset_factory("ip_a.safetensors", tags, {}, make_asset_bytes("ip_a", 900))
|
||||||
|
|
||||||
|
for value in ("false", "true"):
|
||||||
|
r = http.get(
|
||||||
|
api_base + "/api/assets",
|
||||||
|
params={"include_tags": f"unit-tests,{scope}", "include_public": value, "limit": "50"},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
body = r.json()
|
||||||
|
assert r.status_code == 200, body
|
||||||
|
names = [x["name"] for x in body["assets"]]
|
||||||
|
assert a["name"] in names, f"caller's own asset must be returned (include_public={value})"
|
||||||
|
|
||||||
|
|
||||||
def test_list_assets_name_contains_literal_underscore(
|
def test_list_assets_name_contains_literal_underscore(
|
||||||
http,
|
http,
|
||||||
api_base,
|
api_base,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user