diff --git a/app/assets/api/routes.py b/app/assets/api/routes.py index 7ef462f5c..448c7f7ae 100644 --- a/app/assets/api/routes.py +++ b/app/assets/api/routes.py @@ -165,6 +165,8 @@ def _build_asset_response(result: schemas.AssetDetailResult | schemas.UploadResu return schemas_out.Asset( id=result.ref.id, name=result.ref.name, + # Mirror name for backwards compatibility; ref.name is always set. + display_name=result.ref.name, hash=asset_content_hash, asset_hash=asset_content_hash, 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, name_contains=q.name_contains, metadata_filter=q.metadata_filter, + asset_hash=q.hash, limit=q.limit, offset=q.offset, sort=sort, diff --git a/app/assets/api/schemas_in.py b/app/assets/api/schemas_in.py index af666746d..fe56b3f5c 100644 --- a/app/assets/api/schemas_in.py +++ b/app/assets/api/schemas_in.py @@ -54,6 +54,18 @@ class ListAssetsQuery(BaseModel): exclude_tags: list[str] = Field(default_factory=list) 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. In core, reads are owner-scoped + # (owner_id == "") and there is no separate shared/public pool for this flag + # to include or exclude, so it is inert here and intentionally not threaded + # into the query. Accepted (not rejected) so the FE needs no isCloud branch; + # cloud enforces the flag in its own service layer. + include_public: bool = True + # Accept either a JSON string (query param) or a dict metadata_filter: dict[str, Any] | None = None @@ -86,6 +98,19 @@ class ListAssetsQuery(BaseModel): return out 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:`). Liberal in what we accept — no pattern + # enforcement; a non-matching value simply yields an empty page. + # An explicitly-supplied-but-empty value (`?hash=`) stays `""` so it + # is treated as an exact-match miss (empty page), not silently dropped + # to "no filter" — omit the param entirely to disable the filter. + if isinstance(v, str): + return v.strip().lower() + return v + @field_validator("metadata_filter", mode="before") @classmethod def _parse_metadata_json(cls, v): diff --git a/app/assets/api/schemas_out.py b/app/assets/api/schemas_out.py index 4e38e19d1..c5139dde9 100644 --- a/app/assets/api/schemas_out.py +++ b/app/assets/api/schemas_out.py @@ -10,6 +10,8 @@ class Asset(BaseModel): id: str name: str + # Mirrors `name` for backwards compatibility. + display_name: str | None = None hash: str | None = None asset_hash: str | None = None size: int | None = None diff --git a/app/assets/database/queries/asset_reference.py b/app/assets/database/queries/asset_reference.py index 792411800..87f612f48 100644 --- a/app/assets/database/queries/asset_reference.py +++ b/app/assets/database/queries/asset_reference.py @@ -261,6 +261,7 @@ def list_references_page( limit: int = 100, offset: int = 0, name_contains: str | None = None, + asset_hash: str | None = None, include_tags: Sequence[str] | None = None, exclude_tags: Sequence[str] | None = None, metadata_filter: dict | None = None, @@ -293,6 +294,11 @@ def list_references_page( escaped, esc = escape_sql_like_string(name_contains) base = base.where(AssetReference.name.ilike(f"%{escaped}%", escape=esc)) + # `is not None` (not truthiness): an explicit empty hash is an exact-match + # miss (empty page), while an omitted hash (None) disables the filter. + if asset_hash is not None: + base = base.where(Asset.hash == asset_hash) + base = apply_tag_filters(base, include_tags, exclude_tags) base = apply_metadata_filter(base, metadata_filter) @@ -345,6 +351,8 @@ def list_references_page( count_stmt = count_stmt.where( AssetReference.name.ilike(f"%{escaped}%", escape=esc) ) + if asset_hash is not None: + count_stmt = count_stmt.where(Asset.hash == asset_hash) count_stmt = apply_tag_filters(count_stmt, include_tags, exclude_tags) count_stmt = apply_metadata_filter(count_stmt, metadata_filter) diff --git a/app/assets/services/asset_management.py b/app/assets/services/asset_management.py index d4e4fc61c..265cabc0c 100644 --- a/app/assets/services/asset_management.py +++ b/app/assets/services/asset_management.py @@ -274,6 +274,7 @@ def list_assets_page( exclude_tags: Sequence[str] | None = None, name_contains: str | None = None, metadata_filter: dict | None = None, + asset_hash: str | None = None, limit: int = 20, offset: int = 0, sort: str = "created_at", @@ -319,6 +320,7 @@ def list_assets_page( exclude_tags=exclude_tags, name_contains=name_contains, metadata_filter=metadata_filter, + asset_hash=asset_hash, limit=fetch_limit, offset=offset, sort=sort, diff --git a/tests-unit/assets_test/test_list_filter.py b/tests-unit/assets_test/test_list_filter.py index 17bbea5c6..f4157367b 100644 --- a/tests-unit/assets_test/test_list_filter.py +++ b/tests-unit/assets_test/test_list_filter.py @@ -306,6 +306,130 @@ def test_list_assets_invalid_query_rejected(http: requests.Session, api_base: st 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_hash_filter_normalizes_case_and_whitespace( + http, api_base, asset_factory, make_asset_bytes +): + """`hash` is trimmed and lowercased before matching, so an upper-cased, + space-padded value still matches the stored lowercase hash.""" + scope = f"lf-hashnorm-{uuid.uuid4().hex[:6]}" + tags = ["models", "checkpoints", "unit-tests", scope] + a = asset_factory("hnorm_a.safetensors", tags, {}, make_asset_bytes("hnorm_a", 1024)) + + target = a["hash"] + assert target == target.lower(), "stored hash is expected to be lowercase" + messy = f" {target.upper()} " + + r = http.get( + api_base + "/api/assets", + params={"hash": messy, "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_empty_returns_empty_page( + http, api_base, asset_factory, make_asset_bytes +): + """An explicitly-supplied but empty `hash` (`?hash=`) is an exact-match miss + and returns an empty page, rather than silently disabling the filter.""" + scope = f"lf-hashempty-{uuid.uuid4().hex[:6]}" + tags = ["models", "checkpoints", "unit-tests", scope] + asset_factory("he_a.safetensors", tags, {}, make_asset_bytes("he_a", 800)) + + r = http.get( + api_base + "/api/assets", + params={"hash": "", "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( http, api_base,