Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 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
1 change: 1 addition & 0 deletions app/assets/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,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,
Expand Down
15 changes: 15 additions & 0 deletions app/assets/api/schemas_in.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ class ListAssetsQuery(BaseModel):
exclude_tags: list[str] = Field(default_factory=list)
name_contains: str | None = None

# Filter by exact content hash (emitted as `asset_hash` in responses)
hash: str | None = None

# Accepted for API compatibility; has no effect, as there is no shared
# asset pool to include or exclude
include_public: bool = True
Comment thread
mattmillerai marked this conversation as resolved.

# Accept either a JSON string (query param) or a dict
metadata_filter: dict[str, Any] | None = None

Expand Down Expand Up @@ -86,6 +93,14 @@ def _split_csv_tags(cls, v):
return out
return v

@field_validator("hash", mode="before")
@classmethod
def _normalize_hash(cls, v):
# Stored hashes are lowercase `blake3:<hex>`; no pattern is enforced
if isinstance(v, str):
return v.strip().lower()
return v

@field_validator("metadata_filter", mode="before")
@classmethod
def _parse_metadata_json(cls, v):
Expand Down
7 changes: 7 additions & 0 deletions app/assets/database/queries/asset_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -293,6 +294,10 @@ def list_references_page(
escaped, esc = escape_sql_like_string(name_contains)
base = base.where(AssetReference.name.ilike(f"%{escaped}%", escape=esc))

# Not truthiness: an empty hash matches nothing, an omitted one filters nothing
if asset_hash is not None:
base = base.where(Asset.hash == asset_hash)
Comment thread
mattmillerai marked this conversation as resolved.
Outdated

base = apply_tag_filters(base, include_tags, exclude_tags)
base = apply_metadata_filter(base, metadata_filter)

Expand Down Expand Up @@ -345,6 +350,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)

Expand Down
2 changes: 2 additions & 0 deletions app/assets/services/asset_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
126 changes: 125 additions & 1 deletion tests-unit/assets_test/test_list_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import pytest
import requests
from helpers import assert_hash_fields_consistent
from helpers import assert_hash_fields_consistent, get_asset_filename


def test_list_assets_paging_and_sort(http: requests.Session, api_base: str, asset_factory, make_asset_bytes):
Expand Down Expand Up @@ -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_emitted(http, api_base, asset_factory, make_asset_bytes):
"""`display_name` is emitted for every populated asset in list responses,
derived from the storage path."""
scope = f"lf-dispname-{uuid.uuid4().hex[:6]}"
tags = ["models", "model_type: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"
expected = "checkpoints/" + get_asset_filename(asset["asset_hash"], ".safetensors")
assert asset["display_name"] == expected


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", "model_type: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", "model_type: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
):
"""An upper-cased, space-padded `hash` still matches the stored hash."""
scope = f"lf-hashnorm-{uuid.uuid4().hex[:6]}"
tags = ["models", "model_type: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 empty `hash` matches nothing and returns an empty page, rather than
disabling the filter."""
scope = f"lf-hashempty-{uuid.uuid4().hex[:6]}"
tags = ["models", "model_type: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 and does not change which assets come back."""
scope = f"lf-incpub-{uuid.uuid4().hex[:6]}"
tags = ["models", "model_type: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"asset must be returned (include_public={value})"


def test_list_assets_name_contains_literal_underscore(
http,
api_base,
Expand Down
Loading