mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-08-18 23:38:29 +08:00
Add tags_all / tags_any / tags_none tag filters to the assets list API (#15332)
* 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.
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
import time
|
||||
import uuid
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from helpers import assert_hash_fields_consistent
|
||||
|
||||
from app.assets.api import routes as assets_routes
|
||||
from app.assets.api import schemas_in
|
||||
|
||||
|
||||
def test_list_assets_paging_and_sort(http: requests.Session, api_base: str, asset_factory, make_asset_bytes):
|
||||
names = ["a1_u.safetensors", "a2_u.safetensors", "a3_u.safetensors"]
|
||||
@@ -337,3 +341,418 @@ def test_list_assets_name_contains_literal_underscore(
|
||||
assert b["name"] not in names, "Underscore must be escaped — should not match 'fooxbar'"
|
||||
assert c["name"] not in names, "Underscore must be escaped — should not match 'foobar'"
|
||||
assert body["total"] == 1
|
||||
|
||||
|
||||
def test_list_assets_tags_any_alone(http, api_base, asset_factory, make_asset_bytes):
|
||||
scope = f"lf-any-{uuid.uuid4().hex[:6]}"
|
||||
t = ["models", "model_type:checkpoints", "unit-tests", scope]
|
||||
a = asset_factory("any_a.safetensors", [*t, f"{scope}-alpha"], {}, make_asset_bytes("any_a"))
|
||||
b = asset_factory("any_b.safetensors", [*t, f"{scope}-beta"], {}, make_asset_bytes("any_b"))
|
||||
c = asset_factory("any_c.safetensors", [*t, f"{scope}-gamma"], {}, make_asset_bytes("any_c"))
|
||||
|
||||
r = http.get(
|
||||
api_base + "/api/assets",
|
||||
params={"tags_any": f"{scope}-alpha,{scope}-beta", "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
|
||||
assert b["name"] in names
|
||||
assert c["name"] not in names
|
||||
|
||||
|
||||
def test_list_assets_tags_any_with_tags_all(http, api_base, asset_factory, make_asset_bytes):
|
||||
scope = f"lf-anyall-{uuid.uuid4().hex[:6]}"
|
||||
t = ["models", "model_type:checkpoints", "unit-tests", scope]
|
||||
alpha, beta = f"{scope}-alpha", f"{scope}-beta"
|
||||
x = asset_factory("aa_x.safetensors", [*t, alpha], {}, make_asset_bytes("aa_x"))
|
||||
y = asset_factory("aa_y.safetensors", [*t, beta], {}, make_asset_bytes("aa_y"))
|
||||
w = asset_factory("aa_w.safetensors", t, {}, make_asset_bytes("aa_w"))
|
||||
d = asset_factory(
|
||||
"aa_d.safetensors",
|
||||
["models", "model_type:checkpoints", "unit-tests", f"{scope}-other", alpha],
|
||||
{},
|
||||
make_asset_bytes("aa_d"),
|
||||
)
|
||||
|
||||
r = http.get(
|
||||
api_base + "/api/assets",
|
||||
params={"tags_all": f"unit-tests,{scope}", "tags_any": f"{alpha},{beta}", "limit": "50"},
|
||||
timeout=120,
|
||||
)
|
||||
body = r.json()
|
||||
assert r.status_code == 200, body
|
||||
names = [a["name"] for a in body["assets"]]
|
||||
assert x["name"] in names
|
||||
assert y["name"] in names
|
||||
assert w["name"] not in names, "asset matching tags_all but not tags_any must be excluded"
|
||||
assert d["name"] not in names, "asset matching tags_any but not tags_all must be excluded"
|
||||
|
||||
|
||||
def test_list_assets_tags_none_wins_over_tags_any(http, api_base, asset_factory, make_asset_bytes):
|
||||
scope = f"lf-nonewins-{uuid.uuid4().hex[:6]}"
|
||||
t = ["models", "model_type:checkpoints", "unit-tests", scope]
|
||||
alpha, beta = f"{scope}-alpha", f"{scope}-beta"
|
||||
x = asset_factory("nw_x.safetensors", [*t, alpha], {}, make_asset_bytes("nw_x"))
|
||||
y = asset_factory("nw_y.safetensors", [*t, alpha, beta], {}, make_asset_bytes("nw_y"))
|
||||
|
||||
r = http.get(
|
||||
api_base + "/api/assets",
|
||||
params={"tags_any": alpha, "tags_none": beta, "limit": "50"},
|
||||
timeout=120,
|
||||
)
|
||||
body = r.json()
|
||||
assert r.status_code == 200, body
|
||||
names = [a["name"] for a in body["assets"]]
|
||||
assert x["name"] in names
|
||||
assert y["name"] not in names, "tags_none must exclude an asset even when it matches tags_any"
|
||||
|
||||
|
||||
def test_list_assets_empty_tag_filter_lists_behave_as_absent(http, api_base, asset_factory, make_asset_bytes):
|
||||
scope = f"lf-empty-{uuid.uuid4().hex[:6]}"
|
||||
t = ["models", "model_type:checkpoints", "unit-tests", scope]
|
||||
a = asset_factory("em_a.safetensors", t, {}, make_asset_bytes("em_a"))
|
||||
b = asset_factory("em_b.safetensors", t, {}, make_asset_bytes("em_b"))
|
||||
expected = {a["name"], b["name"]}
|
||||
|
||||
# Empty new-name lists impose no constraint.
|
||||
r1 = http.get(
|
||||
api_base + "/api/assets",
|
||||
params={"tags_all": f"unit-tests,{scope}", "tags_any": "", "tags_none": ""},
|
||||
timeout=120,
|
||||
)
|
||||
b1 = r1.json()
|
||||
assert r1.status_code == 200, b1
|
||||
assert {x["name"] for x in b1["assets"]} == expected
|
||||
|
||||
# An empty new-name param alongside old names must not trigger validation.
|
||||
r2 = http.get(
|
||||
api_base + "/api/assets",
|
||||
params={"include_tags": f"unit-tests,{scope}", "tags_any": ""},
|
||||
timeout=120,
|
||||
)
|
||||
b2 = r2.json()
|
||||
assert r2.status_code == 200, b2
|
||||
assert {x["name"] for x in b2["assets"]} == expected
|
||||
|
||||
# An empty tags_all next to include_tags is not a mixed-spelling conflict.
|
||||
r3 = http.get(
|
||||
api_base + "/api/assets",
|
||||
params={"include_tags": f"unit-tests,{scope}", "tags_all": ""},
|
||||
timeout=120,
|
||||
)
|
||||
b3 = r3.json()
|
||||
assert r3.status_code == 200, b3
|
||||
assert {x["name"] for x in b3["assets"]} == expected
|
||||
|
||||
|
||||
def test_list_assets_old_names_match_new_names(http, api_base, asset_factory, make_asset_bytes):
|
||||
scope = f"lf-alias-{uuid.uuid4().hex[:6]}"
|
||||
t = ["models", "model_type:checkpoints", "unit-tests", scope]
|
||||
alpha, beta = f"{scope}-alpha", f"{scope}-beta"
|
||||
asset_factory("al_a.safetensors", [*t, alpha], {}, make_asset_bytes("al_a"))
|
||||
asset_factory("al_b.safetensors", [*t, beta], {}, make_asset_bytes("al_b"))
|
||||
|
||||
def names_for(params: dict) -> tuple[list, int]:
|
||||
r = http.get(api_base + "/api/assets", params={**params, "sort": "name", "order": "asc"}, timeout=120)
|
||||
body = r.json()
|
||||
assert r.status_code == 200, body
|
||||
return [x["name"] for x in body["assets"]], body["total"]
|
||||
|
||||
# include_tags ≡ tags_all
|
||||
old_names, old_total = names_for({"include_tags": f"unit-tests,{scope}"})
|
||||
new_names, new_total = names_for({"tags_all": f"unit-tests,{scope}"})
|
||||
assert old_names == new_names
|
||||
assert old_total == new_total
|
||||
|
||||
# exclude_tags ≡ tags_none (and old/new spellings mix across slots)
|
||||
old_names, old_total = names_for({"include_tags": f"unit-tests,{scope}", "exclude_tags": alpha})
|
||||
new_names, new_total = names_for({"tags_all": f"unit-tests,{scope}", "tags_none": alpha})
|
||||
mixed_names, mixed_total = names_for({"include_tags": f"unit-tests,{scope}", "tags_none": alpha})
|
||||
assert old_names == new_names == mixed_names == ["al_b.safetensors"]
|
||||
assert old_total == new_total == mixed_total == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"params,expected_parameters",
|
||||
[
|
||||
({"include_tags": "mx-x", "tags_all": "mx-y"}, ["include_tags", "tags_all"]),
|
||||
({"exclude_tags": "mx-x", "tags_none": "mx-y"}, ["exclude_tags", "tags_none"]),
|
||||
],
|
||||
ids=["include_tags_with_tags_all", "exclude_tags_with_tags_none"],
|
||||
)
|
||||
def test_list_assets_mixed_tag_spellings_rejected(http, api_base, params, expected_parameters):
|
||||
r = http.get(api_base + "/api/assets", params=params, timeout=120)
|
||||
body = r.json()
|
||||
assert r.status_code == 400, body
|
||||
assert body["error"]["code"] == "INVALID_TAG_FILTER"
|
||||
assert body["error"]["details"]["parameters"] == expected_parameters
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"params,conflicting,parameters",
|
||||
[
|
||||
(
|
||||
{"tags_all": "cf-x", "tags_none": "cf-x"},
|
||||
["cf-x"],
|
||||
["tags_all", "tags_none"],
|
||||
),
|
||||
(
|
||||
{"include_tags": "cf-x", "tags_none": "cf-x"},
|
||||
["cf-x"],
|
||||
["include_tags", "tags_none"],
|
||||
),
|
||||
(
|
||||
{"tags_all": "cf-a,cf-b", "tags_none": "cf-b,cf-c"},
|
||||
["cf-b"],
|
||||
["tags_all", "tags_none"],
|
||||
),
|
||||
],
|
||||
ids=["new_names", "include_tags_remapped", "partial_overlap"],
|
||||
)
|
||||
def test_list_assets_all_none_conflict_rejected(http, api_base, params, conflicting, parameters):
|
||||
r = http.get(api_base + "/api/assets", params=params, timeout=120)
|
||||
body = r.json()
|
||||
assert r.status_code == 400, body
|
||||
assert body["error"]["code"] == "INVALID_TAG_FILTER"
|
||||
assert body["error"]["details"]["conflicting_tags"] == conflicting
|
||||
assert body["error"]["details"]["parameters"] == parameters
|
||||
|
||||
|
||||
def test_list_assets_any_none_overlap_accepted(http, api_base, asset_factory, make_asset_bytes):
|
||||
scope = f"lf-deadterm-{uuid.uuid4().hex[:6]}"
|
||||
t = ["models", "model_type:checkpoints", "unit-tests", scope]
|
||||
alpha, beta = f"{scope}-alpha", f"{scope}-beta"
|
||||
x = asset_factory("dt_x.safetensors", [*t, alpha], {}, make_asset_bytes("dt_x"))
|
||||
y = asset_factory("dt_y.safetensors", [*t, beta], {}, make_asset_bytes("dt_y"))
|
||||
|
||||
# alpha is a dead term (in both tags_any and tags_none) but the query is valid.
|
||||
r = http.get(
|
||||
api_base + "/api/assets",
|
||||
params={"tags_any": f"{alpha},{beta}", "tags_none": alpha, "limit": "50"},
|
||||
timeout=120,
|
||||
)
|
||||
body = r.json()
|
||||
assert r.status_code == 200, body
|
||||
names = [a["name"] for a in body["assets"]]
|
||||
assert y["name"] in names
|
||||
assert x["name"] not in names
|
||||
|
||||
|
||||
def test_list_assets_legacy_include_exclude_conflict_still_200(http, api_base, asset_factory, make_asset_bytes):
|
||||
scope = f"lf-legacy-{uuid.uuid4().hex[:6]}"
|
||||
t = ["models", "model_type:checkpoints", "unit-tests", scope]
|
||||
asset_factory("lg_a.safetensors", t, {}, make_asset_bytes("lg_a"))
|
||||
|
||||
# Old names only: the self-contradictory query stays an empty 200, never a 400.
|
||||
r = http.get(
|
||||
api_base + "/api/assets",
|
||||
params={"include_tags": scope, "exclude_tags": scope},
|
||||
timeout=120,
|
||||
)
|
||||
body = r.json()
|
||||
assert r.status_code == 200, body
|
||||
assert body["assets"] == []
|
||||
|
||||
|
||||
def test_tags_refine_new_tag_filters(http, api_base, asset_factory, make_asset_bytes):
|
||||
scope = f"rf-{uuid.uuid4().hex[:6]}"
|
||||
t = ["models", "model_type:checkpoints", "unit-tests", scope]
|
||||
alpha, beta = f"{scope}-alpha", f"{scope}-beta"
|
||||
asset_factory("rf_a.safetensors", [*t, alpha], {}, make_asset_bytes("rf_a"))
|
||||
asset_factory("rf_b.safetensors", [*t, beta], {}, make_asset_bytes("rf_b"))
|
||||
|
||||
r = http.get(
|
||||
api_base + "/api/assets/tags/refine",
|
||||
params={"tags_any": f"{alpha},{beta}", "tags_none": alpha},
|
||||
timeout=120,
|
||||
)
|
||||
body = r.json()
|
||||
assert r.status_code == 200, body
|
||||
counts = body["tag_counts"]
|
||||
assert counts.get(beta) == 1
|
||||
assert alpha not in counts
|
||||
|
||||
r2 = http.get(
|
||||
api_base + "/api/assets/tags/refine",
|
||||
params={"tags_all": "rf-x", "tags_none": "rf-x"},
|
||||
timeout=120,
|
||||
)
|
||||
body2 = r2.json()
|
||||
assert r2.status_code == 400, body2
|
||||
assert body2["error"]["code"] == "INVALID_TAG_FILTER"
|
||||
assert body2["error"]["details"]["conflicting_tags"] == ["rf-x"]
|
||||
|
||||
|
||||
def test_list_assets_cross_slot_old_new_combinations(http, api_base, asset_factory, make_asset_bytes):
|
||||
"""Old and new spellings of *different* slots combine freely; only
|
||||
same-slot mixing is rejected."""
|
||||
scope = f"lf-cross-{uuid.uuid4().hex[:6]}"
|
||||
t = ["models", "model_type:checkpoints", "unit-tests", scope]
|
||||
alpha, beta = f"{scope}-alpha", f"{scope}-beta"
|
||||
a = asset_factory("cs_a.safetensors", [*t, alpha], {}, make_asset_bytes("cs_a"))
|
||||
b = asset_factory("cs_b.safetensors", [*t, beta], {}, make_asset_bytes("cs_b"))
|
||||
|
||||
def names_for(params: dict) -> set:
|
||||
r = http.get(api_base + "/api/assets", params=params, timeout=120)
|
||||
body = r.json()
|
||||
assert r.status_code == 200, body
|
||||
return {x["name"] for x in body["assets"]}
|
||||
|
||||
assert names_for(
|
||||
{"include_tags": f"unit-tests,{scope}", "tags_any": alpha}
|
||||
) == {a["name"]}
|
||||
assert names_for(
|
||||
{"tags_all": f"unit-tests,{scope}", "exclude_tags": alpha}
|
||||
) == {b["name"]}
|
||||
assert names_for(
|
||||
{"tags_any": f"{alpha},{beta}", "exclude_tags": alpha}
|
||||
) == {b["name"]}
|
||||
|
||||
|
||||
def test_list_assets_repeated_query_keys_concatenate(http, api_base, asset_factory, make_asset_bytes):
|
||||
"""Repeated occurrences of a tag param concatenate before the CSV split
|
||||
(Core-local behavior, not a cross-platform guarantee)."""
|
||||
scope = f"lf-repeat-{uuid.uuid4().hex[:6]}"
|
||||
t = ["models", "model_type:checkpoints", "unit-tests", scope]
|
||||
alpha, beta = f"{scope}-alpha", f"{scope}-beta"
|
||||
a = asset_factory("rp_a.safetensors", [*t, alpha], {}, make_asset_bytes("rp_a"))
|
||||
b = asset_factory("rp_b.safetensors", [*t, beta], {}, make_asset_bytes("rp_b"))
|
||||
|
||||
# requests encodes a list value as repeated keys: tags_any=<alpha>&tags_any=<beta>
|
||||
r = http.get(
|
||||
api_base + "/api/assets",
|
||||
params={"tags_any": [alpha, beta], "limit": "50"},
|
||||
timeout=120,
|
||||
)
|
||||
body = r.json()
|
||||
assert r.status_code == 200, body
|
||||
names = {x["name"] for x in body["assets"]}
|
||||
assert {a["name"], b["name"]} <= names
|
||||
|
||||
|
||||
def test_list_assets_tags_any_cursor_pagination_consistent(http, api_base, asset_factory, make_asset_bytes):
|
||||
scope = f"lf-anypage-{uuid.uuid4().hex[:6]}"
|
||||
t = ["models", "model_type:checkpoints", "unit-tests", scope]
|
||||
alpha = f"{scope}-alpha"
|
||||
expected = set()
|
||||
for i in range(3):
|
||||
made = asset_factory(f"pg_{i}.safetensors", [*t, alpha], {}, make_asset_bytes(f"pg_{i}"))
|
||||
expected.add(made["name"])
|
||||
|
||||
r1 = http.get(
|
||||
api_base + "/api/assets",
|
||||
params={"tags_any": alpha, "limit": "2", "sort": "name", "order": "asc"},
|
||||
timeout=120,
|
||||
)
|
||||
b1 = r1.json()
|
||||
assert r1.status_code == 200, b1
|
||||
assert b1["total"] == 3
|
||||
assert b1["has_more"] is True
|
||||
assert b1.get("next_cursor"), "expected a keyset cursor on the first page"
|
||||
|
||||
r2 = http.get(
|
||||
api_base + "/api/assets",
|
||||
params={
|
||||
"tags_any": alpha,
|
||||
"limit": "2",
|
||||
"sort": "name",
|
||||
"order": "asc",
|
||||
"after": b1["next_cursor"],
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
b2 = r2.json()
|
||||
assert r2.status_code == 200, b2
|
||||
assert b2["has_more"] is False
|
||||
|
||||
page1 = {x["name"] for x in b1["assets"]}
|
||||
page2 = {x["name"] for x in b2["assets"]}
|
||||
assert not page1 & page2, "cursor pages must not overlap"
|
||||
assert page1 | page2 == expected
|
||||
|
||||
|
||||
def test_tags_refine_mixed_spellings_rejected_and_legacy_conflict_kept(http, api_base):
|
||||
r = http.get(
|
||||
api_base + "/api/assets/tags/refine",
|
||||
params={"include_tags": "rfmx-x", "tags_all": "rfmx-y"},
|
||||
timeout=120,
|
||||
)
|
||||
body = r.json()
|
||||
assert r.status_code == 400, body
|
||||
assert body["error"]["code"] == "INVALID_TAG_FILTER"
|
||||
assert body["error"]["details"]["parameters"] == ["include_tags", "tags_all"]
|
||||
|
||||
# Old names only: the refine route keeps legacy behaviour too.
|
||||
r2 = http.get(
|
||||
api_base + "/api/assets/tags/refine",
|
||||
params={"include_tags": "rfmx-z", "exclude_tags": "rfmx-z"},
|
||||
timeout=120,
|
||||
)
|
||||
body2 = r2.json()
|
||||
assert r2.status_code == 200, body2
|
||||
assert body2["tag_counts"] == {}
|
||||
|
||||
|
||||
def test_list_assets_tag_values_case_sensitive(http, api_base, asset_factory, make_asset_bytes):
|
||||
"""Case-distinct tags are distinct; the all/none conflict check is byte-exact."""
|
||||
scope = f"lf-case-{uuid.uuid4().hex[:6]}"
|
||||
t = ["models", "model_type:checkpoints", "unit-tests", scope]
|
||||
upper, lower = f"{scope}-ALPHA", f"{scope}-alpha"
|
||||
a = asset_factory("cx_a.safetensors", [*t, upper], {}, make_asset_bytes("cx_a"))
|
||||
b = asset_factory("cx_b.safetensors", [*t, lower], {}, make_asset_bytes("cx_b"))
|
||||
|
||||
def names_for(params: dict) -> set:
|
||||
r = http.get(api_base + "/api/assets", params=params, timeout=120)
|
||||
body = r.json()
|
||||
assert r.status_code == 200, body
|
||||
return {x["name"] for x in body["assets"]}
|
||||
|
||||
assert names_for({"tags_all": f"unit-tests,{scope},{upper}"}) == {a["name"]}
|
||||
assert names_for({"tags_any": lower, "limit": "50"}) == {b["name"]}
|
||||
# Case-distinct all/none pair is NOT a conflict — byte-exact comparison.
|
||||
assert names_for({"tags_all": f"unit-tests,{scope},{upper}", "tags_none": lower}) == {a["name"]}
|
||||
|
||||
|
||||
def test_tag_list_cap_applies_to_all_spellings(http, api_base):
|
||||
"""The cap covers the legacy spellings too."""
|
||||
big = ",".join(f"cap-{i}" for i in range(101))
|
||||
for param in ("tags_any", "include_tags"):
|
||||
r = http.get(api_base + "/api/assets", params={param: big}, timeout=120)
|
||||
body = r.json()
|
||||
assert r.status_code == 400, body
|
||||
assert body["error"]["code"] == "INVALID_TAG_FILTER"
|
||||
assert body["error"]["details"]["parameter"] == param
|
||||
assert body["error"]["details"]["max"] == 100
|
||||
|
||||
exact = ",".join(f"cap-{i}" for i in range(100))
|
||||
r = http.get(api_base + "/api/assets", params={"tags_any": exact}, timeout=120)
|
||||
assert r.status_code == 200, r.json()
|
||||
|
||||
# The cap counts normalized (deduped) tags, not raw CSV items.
|
||||
dups = ",".join("cap-dup" for _ in range(150))
|
||||
r = http.get(api_base + "/api/assets", params={"tags_any": dups}, timeout=120)
|
||||
assert r.status_code == 200, r.json()
|
||||
|
||||
|
||||
def test_resolve_tag_filters_no_deprecation_warning():
|
||||
"""The deprecated-field warning is for API clients; the server's own remap
|
||||
shim must not fire it on every request."""
|
||||
for q in (
|
||||
schemas_in.ListAssetsQuery(tags_all="a", tags_none="b"),
|
||||
schemas_in.TagsRefineQuery(tags_any="c"),
|
||||
):
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", DeprecationWarning)
|
||||
assets_routes._resolve_tag_filters(q)
|
||||
|
||||
|
||||
def test_tag_filter_alias_fields_marked_deprecated():
|
||||
for model in (schemas_in.ListAssetsQuery, schemas_in.TagsRefineQuery):
|
||||
props = model.model_json_schema()["properties"]
|
||||
for field in ("include_tags", "exclude_tags"):
|
||||
assert props[field].get("deprecated") is True, (model.__name__, field)
|
||||
for field in ("tags_all", "tags_any", "tags_none"):
|
||||
assert "deprecated" not in props[field], (model.__name__, field)
|
||||
|
||||
Reference in New Issue
Block a user