mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-08-11 20:41:56 +08:00
* Implement tags_all/tags_any/tags_none on the assets list API (BE-6600) Adds the three canonically-named tag filter params to GET /api/assets and GET /api/assets/tags/refine: - tags_all: asset carries every tag (replaces include_tags) - tags_any: asset carries at least one tag (new) - tags_none: asset carries no tag (replaces exclude_tags) Clauses intersect; tags_none always wins. include_tags/exclude_tags remain as permanent deprecated aliases and behave exactly as before when used on their own. Invalid combinations return 400 INVALID_TAG_FILTER, but only when the request uses at least one new-name parameter (non-empty after normalisation): - mixed spellings of one slot (include_tags with tags_all, exclude_tags with tags_none) - the same tag in the effective all-list and none-list (query can never match) Old-names-only requests gain no new error paths: include_tags=a&exclude_tags=a still returns an empty 200. tags_any/tags_none overlap stays valid (dead term, not a dead query). * Address review findings: positional-compat, deprecation metadata, test matrix - Move any_tags to the end of the four touched signatures: inserting it mid-signature silently misbound pre-existing positional callers (e.g. a caller passing name_contains positionally would have it consumed as any_tags). - Mark include_tags/exclude_tags Field(deprecated=True) on both list schemas so generated schema metadata matches the contract, not just a comment (schemas_out.py already uses this form for Asset.name). - Add tests: legal cross-slot old/new combinations, repeated query-key concatenation (pins Core behavior; outside the cross-platform contract), tags_any two-page cursor consistency (total/has_more/ no-overlap), refine-route mixed-spelling rejection + legacy-conflict preservation, and schema deprecation metadata. * Pin tag-value opacity: case-sensitive matching, byte-exact conflict check The prod tag survey (~/comfy/prod-model-tag-shape.md) found live case-distinct tag pairs (SEEDVR2/seedvr2) that resolve differently, so the contract now states tag values are opaque byte-strings. Pin that: case-distinct tags filter separately, and a case-distinct all/none pair is not an INVALID_TAG_FILTER conflict. * Document tags_all/tags_any/tags_none in openapi.yaml, deprecate aliases Add the three tag-filter parameters to both listAssets and getAssetTagHistogram parameter blocks and mark include_tags/exclude_tags deprecated: true, keeping the spec in step with the runtime schemas so generated clients can discover the new filters while the aliases stay present for existing consumers. * Move schemas_in import to module scope in test_list_filter Review feedback: no import cycle requires the local import. * Silence per-request DeprecationWarning in the tag-filter remap shim Reading the deprecated include_tags/exclude_tags fields by attribute fires pydantic's DeprecationWarning on every list/refine request even for callers using only the new names. The warning is aimed at API clients, not the server's own remap; read via model_dump instead. * Cap tag-filter lists at 100 entries, all spellings Review finding: unbounded tag lists fan out into one correlated EXISTS per tag on both page and count statements. Cap each list at 100 normalized entries with 400 INVALID_TAG_FILTER naming the parameter. Applies to the legacy spellings as well — a deliberate, decided exception to the old-names-behave-identically rule, since a cap only on new names would leave the same fan-out reachable through the aliases. * Strip process narration from comments Comments carried decision dates, contract cross-references, and review context. Keep only the constraints the code cannot show, one line each.
353 lines
11 KiB
Python
353 lines
11 KiB
Python
import json
|
|
from dataclasses import dataclass
|
|
from typing import Any, Literal
|
|
|
|
from app.assets.helpers import validate_blake3_hash
|
|
from pydantic import (
|
|
BaseModel,
|
|
ConfigDict,
|
|
Field,
|
|
conint,
|
|
field_validator,
|
|
model_validator,
|
|
)
|
|
|
|
|
|
class UploadError(Exception):
|
|
"""Error during upload parsing with HTTP status and code."""
|
|
|
|
def __init__(self, status: int, code: str, message: str):
|
|
super().__init__(message)
|
|
self.status = status
|
|
self.code = code
|
|
self.message = message
|
|
|
|
|
|
class AssetValidationError(Exception):
|
|
"""Validation error in asset processing (invalid tags, metadata, etc.)."""
|
|
|
|
def __init__(self, code: str, message: str):
|
|
super().__init__(message)
|
|
self.code = code
|
|
self.message = message
|
|
|
|
|
|
@dataclass
|
|
class ParsedUpload:
|
|
"""Result of parsing a multipart upload request."""
|
|
|
|
file_present: bool
|
|
file_written: int
|
|
file_client_name: str | None
|
|
tmp_path: str | None
|
|
tags_raw: list[str]
|
|
provided_name: str | None
|
|
user_metadata_raw: str | None
|
|
provided_hash: str | None
|
|
provided_hash_exists: bool | None
|
|
provided_mime_type: str | None = None
|
|
provided_preview_id: str | None = None
|
|
|
|
|
|
class ListAssetsQuery(BaseModel):
|
|
# Deprecated spellings: include_tags ≡ tags_all, exclude_tags ≡ tags_none.
|
|
include_tags: list[str] = Field(default_factory=list, deprecated=True)
|
|
exclude_tags: list[str] = Field(default_factory=list, deprecated=True)
|
|
tags_all: list[str] = Field(default_factory=list)
|
|
tags_any: list[str] = Field(default_factory=list)
|
|
tags_none: list[str] = Field(default_factory=list)
|
|
name_contains: str | None = None
|
|
|
|
# Accept either a JSON string (query param) or a dict
|
|
metadata_filter: dict[str, Any] | None = None
|
|
|
|
limit: conint(ge=1, le=500) = 20
|
|
offset: conint(ge=0) = 0
|
|
# Opaque keyset cursor. When supplied, `offset` is ignored. Cursor pagination
|
|
# is supported for sort values `created_at`, `updated_at`, `name`, `size`.
|
|
# Supplying `after` together with `sort=last_access_time` returns
|
|
# 400 INVALID_CURSOR; that sort only supports offset/limit.
|
|
after: str | None = None
|
|
|
|
sort: Literal["name", "created_at", "updated_at", "size", "last_access_time"] = (
|
|
"created_at"
|
|
)
|
|
order: Literal["asc", "desc"] = "desc"
|
|
|
|
@field_validator(
|
|
"include_tags", "exclude_tags", "tags_all", "tags_any", "tags_none",
|
|
mode="before",
|
|
)
|
|
@classmethod
|
|
def _split_csv_tags(cls, v):
|
|
# Accept "a,b,c" or ["a","b"] (we are liberal in what we accept)
|
|
if v is None:
|
|
return []
|
|
if isinstance(v, str):
|
|
return [t.strip() for t in v.split(",") if t.strip()]
|
|
if isinstance(v, list):
|
|
out: list[str] = []
|
|
for item in v:
|
|
if isinstance(item, str):
|
|
out.extend([t.strip() for t in item.split(",") if t.strip()])
|
|
return out
|
|
return v
|
|
|
|
@field_validator("metadata_filter", mode="before")
|
|
@classmethod
|
|
def _parse_metadata_json(cls, v):
|
|
if v is None or isinstance(v, dict):
|
|
return v
|
|
if isinstance(v, str) and v.strip():
|
|
try:
|
|
parsed = json.loads(v)
|
|
except Exception as e:
|
|
raise ValueError(f"metadata_filter must be JSON: {e}") from e
|
|
if not isinstance(parsed, dict):
|
|
raise ValueError("metadata_filter must be a JSON object")
|
|
return parsed
|
|
return None
|
|
|
|
|
|
class UpdateAssetBody(BaseModel):
|
|
name: str | None = None
|
|
user_metadata: dict[str, Any] | None = None
|
|
preview_id: str | None = None # references an asset_reference id, not an asset id
|
|
|
|
@model_validator(mode="after")
|
|
def _validate_at_least_one_field(self):
|
|
if all(
|
|
v is None
|
|
for v in (self.name, self.user_metadata, self.preview_id)
|
|
):
|
|
raise ValueError(
|
|
"Provide at least one of: name, user_metadata, preview_id."
|
|
)
|
|
return self
|
|
|
|
|
|
class CreateFromHashBody(BaseModel):
|
|
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
|
|
|
hash: str
|
|
name: str | None = None
|
|
tags: list[str] = Field(default_factory=list)
|
|
user_metadata: dict[str, Any] = Field(default_factory=dict)
|
|
mime_type: str | None = None
|
|
preview_id: str | None = None # references an asset_reference id, not an asset id
|
|
|
|
@field_validator("hash")
|
|
@classmethod
|
|
def _require_blake3(cls, v):
|
|
return validate_blake3_hash(v or "")
|
|
|
|
@field_validator("tags", mode="before")
|
|
@classmethod
|
|
def _normalize_tags_field(cls, v):
|
|
if v is None:
|
|
return []
|
|
if isinstance(v, list):
|
|
out = [str(t).strip() for t in v if str(t).strip()]
|
|
seen = set()
|
|
dedup = []
|
|
for t in out:
|
|
if t not in seen:
|
|
seen.add(t)
|
|
dedup.append(t)
|
|
return dedup
|
|
if isinstance(v, str):
|
|
return list(dict.fromkeys(t.strip() for t in v.split(",") if t.strip()))
|
|
return []
|
|
|
|
|
|
class TagsRefineQuery(BaseModel):
|
|
# Deprecated spellings: include_tags ≡ tags_all, exclude_tags ≡ tags_none.
|
|
include_tags: list[str] = Field(default_factory=list, deprecated=True)
|
|
exclude_tags: list[str] = Field(default_factory=list, deprecated=True)
|
|
tags_all: list[str] = Field(default_factory=list)
|
|
tags_any: list[str] = Field(default_factory=list)
|
|
tags_none: list[str] = Field(default_factory=list)
|
|
name_contains: str | None = None
|
|
metadata_filter: dict[str, Any] | None = None
|
|
limit: conint(ge=1, le=1000) = 100
|
|
|
|
@field_validator(
|
|
"include_tags", "exclude_tags", "tags_all", "tags_any", "tags_none",
|
|
mode="before",
|
|
)
|
|
@classmethod
|
|
def _split_csv_tags(cls, v):
|
|
if v is None:
|
|
return []
|
|
if isinstance(v, str):
|
|
return [t.strip() for t in v.split(",") if t.strip()]
|
|
if isinstance(v, list):
|
|
out: list[str] = []
|
|
for item in v:
|
|
if isinstance(item, str):
|
|
out.extend([t.strip() for t in item.split(",") if t.strip()])
|
|
return out
|
|
return v
|
|
|
|
@field_validator("metadata_filter", mode="before")
|
|
@classmethod
|
|
def _parse_metadata_json(cls, v):
|
|
if v is None or isinstance(v, dict):
|
|
return v
|
|
if isinstance(v, str) and v.strip():
|
|
try:
|
|
parsed = json.loads(v)
|
|
except Exception as e:
|
|
raise ValueError(f"metadata_filter must be JSON: {e}") from e
|
|
if not isinstance(parsed, dict):
|
|
raise ValueError("metadata_filter must be a JSON object")
|
|
return parsed
|
|
return None
|
|
|
|
|
|
class TagsListQuery(BaseModel):
|
|
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
|
|
|
prefix: str | None = Field(None, min_length=1, max_length=256)
|
|
limit: int = Field(100, ge=1, le=1000)
|
|
offset: int = Field(0, ge=0, le=10_000_000)
|
|
order: Literal["count_desc", "name_asc"] = "count_desc"
|
|
include_zero: bool = True
|
|
|
|
@field_validator("prefix")
|
|
@classmethod
|
|
def normalize_prefix(cls, v: str | None) -> str | None:
|
|
if v is None:
|
|
return v
|
|
v = v.strip()
|
|
return v or None
|
|
|
|
|
|
class TagsAdd(BaseModel):
|
|
model_config = ConfigDict(extra="ignore")
|
|
tags: list[str] = Field(..., min_length=1)
|
|
|
|
@field_validator("tags")
|
|
@classmethod
|
|
def normalize_tags(cls, v: list[str]) -> list[str]:
|
|
out = []
|
|
for t in v:
|
|
if not isinstance(t, str):
|
|
raise TypeError("tags must be strings")
|
|
tnorm = t.strip()
|
|
if tnorm:
|
|
out.append(tnorm)
|
|
seen = set()
|
|
deduplicated = []
|
|
for x in out:
|
|
if x not in seen:
|
|
seen.add(x)
|
|
deduplicated.append(x)
|
|
return deduplicated
|
|
|
|
|
|
class TagsRemove(TagsAdd):
|
|
pass
|
|
|
|
|
|
class UploadAssetSpec(BaseModel):
|
|
"""Upload Asset operation.
|
|
|
|
- tags: labels plus one destination role ('models'|'input'|'output') for new bytes;
|
|
if role == 'models', exactly one model_type:<folder_name> tag is required
|
|
- name: display name
|
|
- user_metadata: arbitrary JSON object (optional)
|
|
- hash: optional canonical 'blake3:<hex>' for validation / fast-path
|
|
- mime_type: optional MIME type override
|
|
- preview_id: optional asset_reference ID for preview
|
|
|
|
Files are stored using the content hash as filename stem.
|
|
"""
|
|
|
|
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
|
|
|
tags: list[str] = Field(default_factory=list)
|
|
name: str | None = Field(default=None, max_length=512, description="Display Name")
|
|
user_metadata: dict[str, Any] = Field(default_factory=dict)
|
|
hash: str | None = Field(default=None)
|
|
mime_type: str | None = Field(default=None)
|
|
preview_id: str | None = Field(default=None) # references an asset_reference id
|
|
|
|
@field_validator("hash", mode="before")
|
|
@classmethod
|
|
def _parse_hash(cls, v):
|
|
if v is None:
|
|
return None
|
|
s = str(v).strip()
|
|
if not s:
|
|
return None
|
|
return validate_blake3_hash(s)
|
|
|
|
@field_validator("tags", mode="before")
|
|
@classmethod
|
|
def _parse_tags(cls, v):
|
|
"""
|
|
Accepts a list of strings (possibly multiple form fields),
|
|
where each string can be:
|
|
- JSON array (e.g., '["models","loras","foo"]')
|
|
- comma-separated ('models, loras, foo')
|
|
- single token ('models')
|
|
Returns a normalized, deduplicated, ordered list.
|
|
"""
|
|
items: list[str] = []
|
|
if v is None:
|
|
return []
|
|
if isinstance(v, str):
|
|
v = [v]
|
|
|
|
if isinstance(v, list):
|
|
for item in v:
|
|
if item is None:
|
|
continue
|
|
s = str(item).strip()
|
|
if not s:
|
|
continue
|
|
if s.startswith("["):
|
|
try:
|
|
arr = json.loads(s)
|
|
if isinstance(arr, list):
|
|
items.extend(str(x) for x in arr)
|
|
continue
|
|
except Exception:
|
|
pass # fallback to CSV parse below
|
|
items.extend([p for p in s.split(",") if p.strip()])
|
|
else:
|
|
return []
|
|
|
|
# normalize + dedupe
|
|
norm = []
|
|
seen = set()
|
|
for t in items:
|
|
tnorm = str(t).strip()
|
|
if tnorm and tnorm not in seen:
|
|
seen.add(tnorm)
|
|
norm.append(tnorm)
|
|
return norm
|
|
|
|
@field_validator("user_metadata", mode="before")
|
|
@classmethod
|
|
def _parse_metadata_json(cls, v):
|
|
if v is None or isinstance(v, dict):
|
|
return v or {}
|
|
if isinstance(v, str):
|
|
s = v.strip()
|
|
if not s:
|
|
return {}
|
|
try:
|
|
parsed = json.loads(s)
|
|
except Exception as e:
|
|
raise ValueError(f"user_metadata must be JSON: {e}") from e
|
|
if not isinstance(parsed, dict):
|
|
raise ValueError("user_metadata must be a JSON object")
|
|
return parsed
|
|
return {}
|
|
|
|
@model_validator(mode="after")
|
|
def _validate_order(self):
|
|
return self
|