This commit is contained in:
Matt Miller 2026-07-02 12:07:39 -07:00 committed by GitHub
commit 2826e2048c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 164 additions and 0 deletions

View File

@ -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,

View File

@ -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:<hex>`). 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):

View File

@ -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

View File

@ -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)

View File

@ -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,

View File

@ -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,