Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
71 changes: 71 additions & 0 deletions alembic_db/versions/0005_allow_case_sensitive_tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""
Allow case-sensitive tag names.

Revision ID: 0005_allow_case_sensitive_tags
Revises: 0004_drop_tag_type
Create Date: 2026-06-16
"""

from alembic import op

revision = "0005_allow_case_sensitive_tags"
down_revision = "0004_drop_tag_type"
branch_labels = None
depends_on = None


def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name == "sqlite":
# SQLite cannot ALTER/DROP CHECK constraints. Recreate the small tag
# vocabulary table without the lowercase constraint while preserving
# existing tag names.
op.execute("PRAGMA foreign_keys=OFF")
op.execute(
"CREATE TABLE tags_new ("
"name VARCHAR(512) NOT NULL, "
"CONSTRAINT pk_tags PRIMARY KEY (name)"
")"
)
op.execute("INSERT INTO tags_new(name) SELECT name FROM tags")
op.execute("DROP TABLE tags")
op.execute("ALTER TABLE tags_new RENAME TO tags")
op.execute("PRAGMA foreign_keys=ON")
return

op.drop_constraint("ck_tags_ck_tags_lowercase", "tags", type_="check")


def downgrade() -> None:
# Existing mixed-case tags cannot satisfy the old constraint. Lowercase them
# before restoring it, merging duplicate vocabulary/link rows that collide.
op.execute("INSERT OR IGNORE INTO tags(name) SELECT lower(name) FROM tags")
op.execute(
"DELETE FROM asset_reference_tags "
"WHERE rowid NOT IN ("
" SELECT MIN(rowid) FROM asset_reference_tags "
" GROUP BY asset_reference_id, lower(tag_name)"
")"
)
op.execute("UPDATE asset_reference_tags SET tag_name = lower(tag_name)")
op.execute("DELETE FROM tags WHERE name != lower(name)")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

bind = op.get_bind()
if bind.dialect.name == "sqlite":
op.execute("PRAGMA foreign_keys=OFF")
op.execute(
"CREATE TABLE tags_new ("
"name VARCHAR(512) NOT NULL, "
"CONSTRAINT pk_tags PRIMARY KEY (name), "
"CONSTRAINT ck_tags_lowercase CHECK (name = lower(name))"
")"
)
op.execute("INSERT INTO tags_new(name) SELECT name FROM tags")
op.execute("DROP TABLE tags")
op.execute("ALTER TABLE tags_new RENAME TO tags")
op.execute("PRAGMA foreign_keys=ON")
return

op.create_check_constraint(
"ck_tags_ck_tags_lowercase", "tags", "name = lower(name)"
)
16 changes: 3 additions & 13 deletions app/assets/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from aiohttp import web
from pydantic import ValidationError

import folder_paths
from app import user_manager
from app.assets.api import schemas_in, schemas_out
from app.assets.services import schemas
Expand Down Expand Up @@ -408,6 +407,7 @@ async def upload_asset(request: web.Request) -> web.Response:
"hash": parsed.provided_hash,
"mime_type": parsed.provided_mime_type,
"preview_id": parsed.provided_preview_id,
"subfolder": parsed.provided_subfolder,
}
)
except ValidationError as ve:
Expand All @@ -416,17 +416,6 @@ async def upload_asset(request: web.Request) -> web.Response:
400, "INVALID_BODY", f"Validation failed: {ve.json()}"
)

if spec.tags and spec.tags[0] == "models":
if (
len(spec.tags) < 2
or spec.tags[1] not in folder_paths.folder_names_and_paths
):
delete_temp_file_if_exists(parsed.tmp_path)
category = spec.tags[1] if len(spec.tags) >= 2 else ""
return _build_error_response(
400, "INVALID_BODY", f"unknown models category '{category}'"
)

try:
# Fast path: hash exists, create AssetReference without writing anything
if spec.hash and parsed.provided_hash_exists is True:
Expand Down Expand Up @@ -464,13 +453,14 @@ async def upload_asset(request: web.Request) -> web.Response:
expected_hash=spec.hash,
mime_type=spec.mime_type,
preview_id=spec.preview_id,
subfolder=spec.subfolder,
)
except AssetValidationError as e:
delete_temp_file_if_exists(parsed.tmp_path)
return _build_error_response(400, e.code, str(e))
except ValueError as e:
delete_temp_file_if_exists(parsed.tmp_path)
return _build_error_response(400, "BAD_REQUEST", str(e))
return _build_error_response(400, "INVALID_BODY", str(e))
except HashMismatchError as e:
delete_temp_file_if_exists(parsed.tmp_path)
return _build_error_response(400, "HASH_MISMATCH", str(e))
Expand Down
35 changes: 18 additions & 17 deletions app/assets/api/schemas_in.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class ParsedUpload:
provided_hash_exists: bool | None
provided_mime_type: str | None = None
provided_preview_id: str | None = None
provided_subfolder: str | None = None


class ListAssetsQuery(BaseModel):
Expand Down Expand Up @@ -140,7 +141,7 @@ def _normalize_tags_field(cls, v):
if v is None:
return []
if isinstance(v, list):
out = [str(t).strip().lower() for t in v if str(t).strip()]
out = [str(t).strip() for t in v if str(t).strip()]
seen = set()
dedup = []
for t in out:
Expand All @@ -149,7 +150,7 @@ def _normalize_tags_field(cls, v):
dedup.append(t)
return dedup
if isinstance(v, str):
return [t.strip().lower() for t in v.split(",") if t.strip()]
return list(dict.fromkeys(t.strip() for t in v.split(",") if t.strip()))
return []


Expand Down Expand Up @@ -206,7 +207,7 @@ def normalize_prefix(cls, v: str | None) -> str | None:
if v is None:
return v
v = v.strip()
return v.lower() or None
return v or None


class TagsAdd(BaseModel):
Expand All @@ -220,7 +221,7 @@ def normalize_tags(cls, v: list[str]) -> list[str]:
for t in v:
if not isinstance(t, str):
raise TypeError("tags must be strings")
tnorm = t.strip().lower()
tnorm = t.strip()
if tnorm:
out.append(tnorm)
seen = set()
Expand All @@ -239,8 +240,9 @@ class TagsRemove(TagsAdd):
class UploadAssetSpec(BaseModel):
"""Upload Asset operation.

- tags: optional list; if provided, first is root ('models'|'input'|'output');
if root == 'models', second must be a valid category
- tags: labels plus one destination role ('models'|'input'|'output') for new bytes;
if role == 'models', exactly one model_type:<folder_name> tag is required
- subfolder: optional destination subfolder for new bytes
- name: display name
- user_metadata: arbitrary JSON object (optional)
- hash: optional canonical 'blake3:<hex>' for validation / fast-path
Expand All @@ -258,6 +260,7 @@ class UploadAssetSpec(BaseModel):
hash: str | None = Field(default=None)
mime_type: str | None = Field(default=None)
preview_id: str | None = Field(default=None) # references an asset_reference id
subfolder: str | None = Field(default=None, max_length=1024)

@field_validator("hash", mode="before")
@classmethod
Expand Down Expand Up @@ -309,12 +312,20 @@ def _parse_tags(cls, v):
norm = []
seen = set()
for t in items:
tnorm = str(t).strip().lower()
tnorm = str(t).strip()
if tnorm and tnorm not in seen:
seen.add(tnorm)
norm.append(tnorm)
return norm

@field_validator("subfolder", mode="before")
@classmethod
def _parse_subfolder(cls, v):
if v is None:
return None
s = str(v).strip()
return s or None

@field_validator("user_metadata", mode="before")
@classmethod
def _parse_metadata_json(cls, v):
Expand All @@ -335,14 +346,4 @@ def _parse_metadata_json(cls, v):

@model_validator(mode="after")
def _validate_order(self):
if not self.tags:
raise ValueError("at least one tag is required for uploads")
root = self.tags[0]
if root not in {"models", "input", "output"}:
raise ValueError("first tag must be one of: models, input, output")
if root == "models":
if len(self.tags) < 2:
raise ValueError(
"models uploads require a category tag as the second tag"
)
return self
4 changes: 4 additions & 0 deletions app/assets/api/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ async def parse_multipart_upload(
provided_hash_exists: bool | None = None
provided_mime_type: str | None = None
provided_preview_id: str | None = None
provided_subfolder: str | None = None

file_written = 0
tmp_path: str | None = None
Expand Down Expand Up @@ -140,6 +141,8 @@ async def parse_multipart_upload(
provided_mime_type = ((await field.text()) or "").strip() or None
elif fname == "preview_id":
provided_preview_id = ((await field.text()) or "").strip() or None
elif fname == "subfolder":
provided_subfolder = ((await field.text()) or "").strip() or None

if not file_present and not (provided_hash and provided_hash_exists):
raise UploadError(
Expand All @@ -166,6 +169,7 @@ async def parse_multipart_upload(
provided_hash_exists=provided_hash_exists,
provided_mime_type=provided_mime_type,
provided_preview_id=provided_preview_id,
provided_subfolder=provided_subfolder,
)


Expand Down
4 changes: 2 additions & 2 deletions app/assets/database/queries/tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ def list_tags_with_usage(
)

if prefix:
escaped, esc = escape_sql_like_string(prefix.strip().lower())
escaped, esc = escape_sql_like_string(prefix.strip())
q = q.where(Tag.name.like(escaped + "%", escape=esc))
Comment thread
synap5e marked this conversation as resolved.
Outdated

if not include_zero:
Expand All @@ -307,7 +307,7 @@ def list_tags_with_usage(

total_q = select(func.count()).select_from(Tag)
if prefix:
escaped, esc = escape_sql_like_string(prefix.strip().lower())
escaped, esc = escape_sql_like_string(prefix.strip())
total_q = total_q.where(Tag.name.like(escaped + "%", escape=esc))
if not include_zero:
visible_tags_sq = (
Expand Down
6 changes: 3 additions & 3 deletions app/assets/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ def get_utc_now() -> datetime:
def normalize_tags(tags: list[str] | None) -> list[str]:
"""
Normalize a list of tags by:
- Stripping whitespace and converting to lowercase.
- Removing duplicates.
- Stripping whitespace.
- Removing exact duplicates while preserving order and case.
"""
return list(dict.fromkeys(t.strip().lower() for t in (tags or []) if (t or "").strip()))
return list(dict.fromkeys(t.strip() for t in (tags or []) if (t or "").strip()))


def validate_blake3_hash(s: str) -> str:
Expand Down
26 changes: 20 additions & 6 deletions app/assets/services/ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from app.assets.services.image_dimensions import extract_image_dimensions
from app.assets.services.path_utils import (
compute_relative_filename,
get_backend_system_tags_from_path,
get_name_and_tags_from_asset_path,
resolve_destination_from_tags,
validate_path_within_base,
Expand Down Expand Up @@ -101,7 +102,11 @@ def _ingest_file_from_path(
if preview_id and ref.preview_id != preview_id:
ref.preview_id = preview_id

norm = normalize_tags(list(tags))
try:
backend_tags = get_backend_system_tags_from_path(locator)
except ValueError:
backend_tags = []
norm = normalize_tags([*list(tags), *backend_tags])
if norm:
if require_existing_tags:
validate_tags_exist(session, norm)
Expand Down Expand Up @@ -458,6 +463,7 @@ def upload_from_temp_path(
expected_hash: str | None = None,
mime_type: str | None = None,
preview_id: str | None = None,
subfolder: str | None = None,
) -> UploadResult:
try:
digest, _ = hashing.compute_blake3_hash(temp_path)
Expand All @@ -474,6 +480,10 @@ def upload_from_temp_path(
existing = get_asset_by_hash(session, asset_hash=asset_hash)

if existing is not None:
# Once content is already known, duplicate byte uploads are treated as
# reference-only creation. Request tags are labels only here: do not
# require upload destination tags, do not move bytes, and do not
# synthesize path-derived classification or uploaded provenance.
with contextlib.suppress(Exception):
if temp_path and os.path.exists(temp_path):
os.remove(temp_path)
Expand All @@ -498,7 +508,7 @@ def upload_from_temp_path(

if not tags:
raise ValueError("tags are required for new asset uploads")
base_dir, subdirs = resolve_destination_from_tags(tags)
base_dir, subdirs = resolve_destination_from_tags(tags, subfolder=subfolder)
dest_dir = os.path.join(base_dir, *subdirs) if subdirs else base_dir
os.makedirs(dest_dir, exist_ok=True)

Expand Down Expand Up @@ -535,7 +545,7 @@ def upload_from_temp_path(
owner_id=owner_id,
preview_id=preview_id,
user_metadata=user_metadata or {},
tags=tags,
tags=[*(tags or []), "uploaded"],
tag_origin="manual",
require_existing_tags=False,
)
Expand Down Expand Up @@ -569,15 +579,19 @@ def register_file_in_place(
) -> UploadResult:
"""Register an already-saved file in the asset database without moving it.

Tags are derived from the filesystem path (root category + subfolder names),
merged with any caller-provided tags, matching the behavior of the scanner.
This helper is used by upload paths that have already written bytes before
registering the file, so it records the same ``uploaded`` tag as the
multipart byte-upload path.

Tags are derived from trusted filesystem classification and merged with any
caller-provided tags, matching the behavior of the scanner.
If the path is not under a known root, only the caller-provided tags are used.
"""
try:
_, path_tags = get_name_and_tags_from_asset_path(abs_path)
except ValueError:
path_tags = []
merged_tags = normalize_tags([*path_tags, *tags])
merged_tags = normalize_tags([*path_tags, *tags, "uploaded"])

try:
digest, _ = hashing.compute_blake3_hash(abs_path)
Expand Down
Loading
Loading