Defer public asset file paths

Amp-Thread-ID: https://ampcode.com/threads/T-019ecf39-2e6f-747d-ae80-addba6b8e4f5
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Simon Pinfold 2026-06-19 15:42:33 +12:00
parent 54d64d9762
commit 14266fe789
8 changed files with 13 additions and 69 deletions

View File

@ -39,7 +39,6 @@ from app.assets.services import (
upload_from_temp_path, upload_from_temp_path,
) )
from app.assets.services.cursor import InvalidCursorError from app.assets.services.cursor import InvalidCursorError
from app.assets.services.path_utils import compute_api_file_path
from app.assets.services.tagging import list_tag_histogram from app.assets.services.tagging import list_tag_histogram
ROUTES = web.RouteTableDef() ROUTES = web.RouteTableDef()
@ -169,7 +168,6 @@ def _build_asset_response(result: schemas.AssetDetailResult | schemas.UploadResu
asset_hash=asset_content_hash, asset_hash=asset_content_hash,
size=int(result.asset.size_bytes) if result.asset else None, size=int(result.asset.size_bytes) if result.asset else None,
mime_type=result.asset.mime_type if result.asset else None, mime_type=result.asset.mime_type if result.asset else None,
file_path=compute_api_file_path(result.ref.file_path),
tags=result.tags, tags=result.tags,
preview_url=preview_url, preview_url=preview_url,
preview_id=result.ref.preview_id, preview_id=result.ref.preview_id,

View File

@ -14,7 +14,6 @@ class Asset(BaseModel):
asset_hash: str | None = None asset_hash: str | None = None
size: int | None = None size: int | None = None
mime_type: str | None = None mime_type: str | None = None
file_path: str | None = None
tags: list[str] = Field(default_factory=list) tags: list[str] = Field(default_factory=list)
preview_url: str | None = None preview_url: str | None = None
preview_id: str | None = None # references an asset_reference id, not an asset id preview_id: str | None = None # references an asset_reference id, not an asset id

View File

@ -114,25 +114,6 @@ def compute_relative_filename(file_path: str) -> str | None:
return "/".join(parts) # input/output: keep all parts return "/".join(parts) # input/output: keep all parts
def compute_api_file_path(file_path: str | None) -> str | None:
"""Return a stable API-visible path relative to a known asset root.
Examples:
/.../input/foo.png -> "input/foo.png"
/.../models/checkpoints/foo.safetensors -> "models/checkpoints/foo.safetensors"
Returns None for references without a filesystem path or paths outside
known asset roots.
"""
if not file_path:
return None
try:
root_category, rel_path = get_asset_category_and_relative_path(file_path)
except ValueError:
return None
return "/".join([root_category, *Path(rel_path).parts])
def get_asset_category_and_relative_path( def get_asset_category_and_relative_path(
file_path: str, file_path: str,
) -> tuple[Literal["input", "output", "temp", "models"], str]: ) -> tuple[Literal["input", "output", "temp", "models"], str]:

View File

@ -7,14 +7,6 @@ components:
description: Timestamp when the asset was created description: Timestamp when the asset was created
format: date-time format: date-time
type: string type: string
display_name:
description: Display name of the asset. Mirrors name for backwards compatibility.
nullable: true
type: string
file_path:
description: Relative path in global-namespace-root form (e.g. "models/checkpoints/flux.safetensors")
nullable: true
type: string
hash: hash:
description: Blake3 hash of the asset content. description: Blake3 hash of the asset content.
pattern: ^blake3:[a-f0-9]{64}$ pattern: ^blake3:[a-f0-9]{64}$
@ -138,14 +130,6 @@ components:
AssetUpdated: AssetUpdated:
description: Response returned when an existing asset is successfully updated. description: Response returned when an existing asset is successfully updated.
properties: properties:
display_name:
description: Display name of the asset. Mirrors name for backwards compatibility.
nullable: true
type: string
file_path:
description: Relative path in global-namespace-root form (e.g. "models/checkpoints/flux.safetensors")
nullable: true
type: string
hash: hash:
description: Blake3 hash of the asset content. description: Blake3 hash of the asset content.
pattern: ^blake3:[a-f0-9]{64}$ pattern: ^blake3:[a-f0-9]{64}$

View File

@ -19,8 +19,8 @@ def test_seed_asset_removed_when_file_is_deleted(
"""Asset without hash (seed) whose file disappears: """Asset without hash (seed) whose file disappears:
after triggering sync_seed_assets, Asset + AssetInfo disappear. after triggering sync_seed_assets, Asset + AssetInfo disappear.
""" """
# Create a file directly under input/unit-tests/<case>. Path components are # Create a file directly under input/unit-tests/<case>. Backend tags only
# exposed through file_path; backend tags only classify the root. # classify the root; nested path components are not exposed as tags.
case_dir = comfy_tmp_base_dir / root / "unit-tests" / "syncseed" case_dir = comfy_tmp_base_dir / root / "unit-tests" / "syncseed"
case_dir.mkdir(parents=True, exist_ok=True) case_dir.mkdir(parents=True, exist_ok=True)
name = f"seed_{uuid.uuid4().hex[:8]}.bin" name = f"seed_{uuid.uuid4().hex[:8]}.bin"
@ -39,11 +39,7 @@ def test_seed_asset_removed_when_file_is_deleted(
body1 = r1.json() body1 = r1.json()
assert r1.status_code == 200 assert r1.status_code == 200
# there should be exactly one with that name # there should be exactly one with that name
expected_suffix = f"{root}/unit-tests/syncseed/{name}" matches = [a for a in body1.get("assets", []) if a.get("name") == name]
matches = [
a for a in body1.get("assets", [])
if a.get("name") == name and a.get("file_path") == expected_suffix
]
assert matches assert matches
# Seed assets have no hash; exclude_none drops both keys from the response # Seed assets have no hash; exclude_none drops both keys from the response
assert "asset_hash" not in matches[0] assert "asset_hash" not in matches[0]
@ -64,10 +60,7 @@ def test_seed_asset_removed_when_file_is_deleted(
) )
body2 = r2.json() body2 = r2.json()
assert r2.status_code == 200 assert r2.status_code == 200
matches2 = [ matches2 = [a for a in body2.get("assets", []) if a.get("name") == name]
a for a in body2.get("assets", [])
if a.get("name") == name and a.get("file_path") == expected_suffix
]
assert not matches2, f"Seed asset {asset_info_id} should be gone after sync" assert not matches2, f"Seed asset {asset_info_id} should be gone after sync"
@ -140,7 +133,7 @@ def test_hashed_asset_two_asset_infos_both_get_missing(
second_id = b2["id"] second_id = b2["id"]
# Remove the single underlying file # Remove the single underlying file
p = comfy_tmp_base_dir / created["file_path"] p = comfy_tmp_base_dir / "input" / get_asset_filename(created["asset_hash"], ".png")
assert p.exists() assert p.exists()
p.unlink() p.unlink()
@ -258,7 +251,7 @@ def test_missing_tag_clears_on_fastpass_when_mtime_and_size_match(
a = asset_factory(name, [root, "unit-tests", scope], {}, data) a = asset_factory(name, [root, "unit-tests", scope], {}, data)
aid = a["id"] aid = a["id"]
p = comfy_tmp_base_dir / a["file_path"] p = comfy_tmp_base_dir / root / get_asset_filename(a["asset_hash"], ".bin")
st0 = p.stat() st0 = p.stat()
orig_mtime_ns = getattr(st0, "st_mtime_ns", int(st0.st_mtime * 1_000_000_000)) orig_mtime_ns = getattr(st0, "st_mtime_ns", int(st0.st_mtime * 1_000_000_000))

View File

@ -295,11 +295,7 @@ def test_metadata_filename_is_set_for_seed_asset_without_hash(
) )
body = r1.json() body = r1.json()
assert r1.status_code == 200, body assert r1.status_code == 200, body
expected_file_path = f"{root}/unit-tests/{scope}/a/b/{name}" matches = [a for a in body.get("assets", []) if a.get("name") == name]
matches = [
a for a in body.get("assets", [])
if a.get("name") == name and a.get("file_path") == expected_file_path
]
assert matches, "Seed asset should be visible after sync" assert matches, "Seed asset should be visible after sync"
# Seed assets have no hash; exclude_none drops both keys from the response # Seed assets have no hash; exclude_none drops both keys from the response
assert "asset_hash" not in matches[0] assert "asset_hash" not in matches[0]

View File

@ -3,7 +3,7 @@ from pathlib import Path
import pytest import pytest
import requests import requests
from helpers import trigger_sync_seed_assets from helpers import get_asset_filename, trigger_sync_seed_assets
@pytest.fixture @pytest.fixture
@ -35,11 +35,6 @@ def find_asset(http: requests.Session, api_base: str):
r = http.get(f"{api_base}/api/assets", params=params, timeout=120) r = http.get(f"{api_base}/api/assets", params=params, timeout=120)
assert r.status_code == 200 assert r.status_code == 200
assets = r.json().get("assets", []) assets = r.json().get("assets", [])
expected_path_fragment = f"/unit-tests/{scope}/"
assets = [
a for a in assets
if expected_path_fragment in f"/{a.get('file_path', '')}"
]
if name: if name:
return [a for a in assets if a.get("name") == name] return [a for a in assets if a.get("name") == name]
return assets return assets
@ -96,7 +91,7 @@ def test_hashed_asset_not_pruned_when_file_missing(
data = make_asset_bytes("test", 2048) data = make_asset_bytes("test", 2048)
a = asset_factory("test.bin", ["input", "unit-tests", scope], {}, data) a = asset_factory("test.bin", ["input", "unit-tests", scope], {}, data)
path = comfy_tmp_base_dir / a["file_path"] path = comfy_tmp_base_dir / "input" / get_asset_filename(a["asset_hash"], ".bin")
path.unlink() path.unlink()
trigger_sync_seed_assets(http, api_base) trigger_sync_seed_assets(http, api_base)
@ -117,14 +112,14 @@ def test_prune_across_multiple_roots(
create_seed_file("output", scope, "output.bin") create_seed_file("output", scope, "output.bin")
trigger_sync_seed_assets(http, api_base) trigger_sync_seed_assets(http, api_base)
assert len(find_asset(scope)) == 2 assert find_asset(scope, input_fp.name)
assert find_asset(scope, "output.bin")
input_fp.unlink() input_fp.unlink()
trigger_sync_seed_assets(http, api_base) trigger_sync_seed_assets(http, api_base)
remaining = find_asset(scope) assert not find_asset(scope, input_fp.name)
assert len(remaining) == 1 assert find_asset(scope, "output.bin")
assert remaining[0]["name"] == "output.bin"
@pytest.mark.parametrize("dirname", ["100%_done", "my_folder_name", "has spaces"]) @pytest.mark.parametrize("dirname", ["100%_done", "my_folder_name", "has spaces"])

View File

@ -381,7 +381,6 @@ def test_upload_subfolder_is_explicit_path_component(
assert r.status_code == 201, body assert r.status_code == 201, body
stored_name = get_asset_filename(body["asset_hash"], ".bin") stored_name = get_asset_filename(body["asset_hash"], ".bin")
assert (comfy_tmp_base_dir / "input" / "foo" / "bar" / stored_name).exists() assert (comfy_tmp_base_dir / "input" / "foo" / "bar" / stored_name).exists()
assert body["file_path"] == f"input/foo/bar/{stored_name}"
assert "foo" in body["tags"] assert "foo" in body["tags"]
@ -498,7 +497,6 @@ def test_multipart_upload_role_selects_write_location(
stored_name = get_asset_filename(body["asset_hash"], extension) stored_name = get_asset_filename(body["asset_hash"], extension)
expected_disk_path = comfy_tmp_base_dir / expected_root / stored_name expected_disk_path = comfy_tmp_base_dir / expected_root / stored_name
assert expected_disk_path.exists() assert expected_disk_path.exists()
assert body["file_path"] == f"{expected_root}/{stored_name}"
def test_upload_empty_tags_rejected(http: requests.Session, api_base: str): def test_upload_empty_tags_rejected(http: requests.Session, api_base: str):