merlemcdurrrmott commited on
Commit
3a4091c
·
verified ·
1 Parent(s): 9a95490

Upload app/assets/services/ingest.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app/assets/services/ingest.py +687 -0
app/assets/services/ingest.py ADDED
@@ -0,0 +1,687 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import contextlib
2
+ import logging
3
+ import mimetypes
4
+ import os
5
+ from typing import Any, Sequence
6
+
7
+ from sqlalchemy.orm import Session
8
+
9
+ import app.assets.services.hashing as hashing
10
+ from app.assets.database.queries import (
11
+ add_tags_to_reference,
12
+ count_active_siblings,
13
+ create_stub_asset,
14
+ ensure_tags_exist,
15
+ fetch_reference_and_asset,
16
+ get_asset_by_hash,
17
+ get_reference_by_file_path,
18
+ get_reference_tags,
19
+ get_or_create_reference,
20
+ list_references_by_asset_id,
21
+ reference_exists,
22
+ remove_missing_tag_for_asset_id,
23
+ set_reference_metadata,
24
+ set_reference_system_metadata,
25
+ set_reference_tags,
26
+ update_asset_hash_and_mime,
27
+ upsert_asset,
28
+ upsert_reference,
29
+ validate_tags_exist,
30
+ )
31
+ from app.assets.helpers import get_utc_now, normalize_tags
32
+ from app.assets.services.bulk_ingest import batch_insert_seed_assets
33
+ from app.assets.services.file_utils import get_size_and_mtime_ns
34
+ from app.assets.services.image_dimensions import extract_image_dimensions
35
+ from app.assets.services.path_utils import (
36
+ compute_loader_path,
37
+ get_name_and_tags_from_asset_path,
38
+ get_path_derived_tags_from_path,
39
+ resolve_destination_from_tags,
40
+ validate_path_within_base,
41
+ )
42
+ from app.assets.services.schemas import (
43
+ IngestResult,
44
+ RegisterAssetResult,
45
+ UploadResult,
46
+ UserMetadata,
47
+ extract_asset_data,
48
+ extract_reference_data,
49
+ )
50
+ from app.database.db import create_session
51
+
52
+
53
+ def _ingest_file_from_path(
54
+ abs_path: str,
55
+ asset_hash: str,
56
+ size_bytes: int,
57
+ mtime_ns: int,
58
+ mime_type: str | None = None,
59
+ info_name: str | None = None,
60
+ owner_id: str = "",
61
+ preview_id: str | None = None,
62
+ user_metadata: UserMetadata = None,
63
+ tags: Sequence[str] = (),
64
+ tag_origin: str = "manual",
65
+ require_existing_tags: bool = False,
66
+ ) -> IngestResult:
67
+ locator = os.path.abspath(abs_path)
68
+ user_metadata = user_metadata or {}
69
+
70
+ asset_created = False
71
+ asset_updated = False
72
+ ref_created = False
73
+ ref_updated = False
74
+ reference_id: str | None = None
75
+
76
+ with create_session() as session:
77
+ if preview_id:
78
+ if not reference_exists(session, preview_id):
79
+ preview_id = None
80
+
81
+ asset, asset_created, asset_updated = upsert_asset(
82
+ session,
83
+ asset_hash=asset_hash,
84
+ size_bytes=size_bytes,
85
+ mime_type=mime_type,
86
+ )
87
+
88
+ ref_created, ref_updated = upsert_reference(
89
+ session,
90
+ asset_id=asset.id,
91
+ file_path=locator,
92
+ name=info_name or os.path.basename(locator),
93
+ mtime_ns=mtime_ns,
94
+ owner_id=owner_id,
95
+ loader_path=compute_loader_path(locator),
96
+ )
97
+
98
+ # Get the reference we just created/updated
99
+ ref = get_reference_by_file_path(session, locator)
100
+ if ref:
101
+ reference_id = ref.id
102
+
103
+ if preview_id and ref.preview_id != preview_id:
104
+ ref.preview_id = preview_id
105
+
106
+ try:
107
+ backend_tags = get_path_derived_tags_from_path(locator)
108
+ except ValueError:
109
+ backend_tags = []
110
+ caller_tags = normalize_tags(tags)
111
+ backend_tags = normalize_tags(backend_tags)
112
+ all_tags = normalize_tags([*caller_tags, *backend_tags])
113
+ if all_tags:
114
+ if require_existing_tags:
115
+ validate_tags_exist(session, all_tags)
116
+ if backend_tags:
117
+ add_tags_to_reference(
118
+ session,
119
+ reference_id=reference_id,
120
+ tags=backend_tags,
121
+ origin="automatic",
122
+ create_if_missing=not require_existing_tags,
123
+ )
124
+ if caller_tags:
125
+ add_tags_to_reference(
126
+ session,
127
+ reference_id=reference_id,
128
+ tags=caller_tags,
129
+ origin=tag_origin,
130
+ create_if_missing=not require_existing_tags,
131
+ )
132
+
133
+ _update_metadata_with_filename(
134
+ session,
135
+ reference_id=reference_id,
136
+ file_path=ref.file_path,
137
+ current_metadata=ref.user_metadata,
138
+ user_metadata=user_metadata,
139
+ )
140
+
141
+ _maybe_store_image_dimensions(
142
+ session,
143
+ reference_id=reference_id,
144
+ file_path=locator,
145
+ mime_type=mime_type,
146
+ current_system_metadata=ref.system_metadata,
147
+ )
148
+
149
+ try:
150
+ remove_missing_tag_for_asset_id(session, asset_id=asset.id)
151
+ except Exception:
152
+ logging.exception("Failed to clear 'missing' tag for asset %s", asset.id)
153
+
154
+ session.commit()
155
+
156
+ return IngestResult(
157
+ asset_created=asset_created,
158
+ asset_updated=asset_updated,
159
+ ref_created=ref_created,
160
+ ref_updated=ref_updated,
161
+ reference_id=reference_id,
162
+ )
163
+
164
+
165
+ def register_output_files(
166
+ file_paths: Sequence[str],
167
+ user_metadata: UserMetadata = None,
168
+ job_id: str | None = None,
169
+ ) -> int:
170
+ """Register a batch of output file paths as assets.
171
+
172
+ Returns the number of files successfully registered.
173
+ """
174
+ registered = 0
175
+ for abs_path in file_paths:
176
+ if not os.path.isfile(abs_path):
177
+ continue
178
+ try:
179
+ if ingest_existing_file(
180
+ abs_path, user_metadata=user_metadata, job_id=job_id
181
+ ):
182
+ registered += 1
183
+ except Exception:
184
+ logging.exception("Failed to register output: %s", abs_path)
185
+ return registered
186
+
187
+
188
+ def ingest_existing_file(
189
+ abs_path: str,
190
+ user_metadata: UserMetadata = None,
191
+ extra_tags: Sequence[str] = (),
192
+ owner_id: str = "",
193
+ job_id: str | None = None,
194
+ ) -> bool:
195
+ """Register an existing on-disk file as an asset stub.
196
+
197
+ If a reference already exists for this path, updates mtime_ns, job_id,
198
+ size_bytes, and resets enrichment so the enricher will re-hash it.
199
+
200
+ For brand-new paths, inserts a stub record (hash=NULL) for immediate
201
+ UX visibility.
202
+
203
+ Returns True if a row was inserted or updated, False otherwise.
204
+ """
205
+ locator = os.path.abspath(abs_path)
206
+ size_bytes, mtime_ns = get_size_and_mtime_ns(abs_path)
207
+ mime_type = mimetypes.guess_type(abs_path, strict=False)[0]
208
+ name, path_tags = get_name_and_tags_from_asset_path(abs_path)
209
+ tags = list(dict.fromkeys(path_tags + list(extra_tags)))
210
+
211
+ with create_session() as session:
212
+ existing_ref = get_reference_by_file_path(session, locator)
213
+ if existing_ref is not None:
214
+ now = get_utc_now()
215
+ existing_ref.mtime_ns = mtime_ns
216
+ existing_ref.job_id = job_id
217
+ existing_ref.is_missing = False
218
+ existing_ref.deleted_at = None
219
+ existing_ref.updated_at = now
220
+ existing_ref.enrichment_level = 0
221
+
222
+ asset = existing_ref.asset
223
+ if asset:
224
+ # If other refs share this asset, detach to a new stub
225
+ # instead of mutating the shared row.
226
+ siblings = count_active_siblings(session, asset.id, existing_ref.id)
227
+ if siblings > 0:
228
+ new_asset = create_stub_asset(
229
+ session,
230
+ size_bytes=size_bytes,
231
+ mime_type=mime_type or asset.mime_type,
232
+ )
233
+ existing_ref.asset_id = new_asset.id
234
+ else:
235
+ asset.hash = None
236
+ asset.size_bytes = size_bytes
237
+ if mime_type:
238
+ asset.mime_type = mime_type
239
+ session.commit()
240
+ return True
241
+
242
+ spec = {
243
+ "abs_path": abs_path,
244
+ "size_bytes": size_bytes,
245
+ "mtime_ns": mtime_ns,
246
+ "info_name": name,
247
+ "tags": tags,
248
+ "fname": compute_loader_path(abs_path),
249
+ "metadata": None,
250
+ "hash": None,
251
+ "mime_type": mime_type,
252
+ "job_id": job_id,
253
+ }
254
+ if tags:
255
+ ensure_tags_exist(session, tags)
256
+ result = batch_insert_seed_assets(session, [spec], owner_id=owner_id)
257
+ session.commit()
258
+ return result.won_paths > 0
259
+
260
+
261
+ def _register_existing_asset(
262
+ asset_hash: str,
263
+ name: str,
264
+ user_metadata: UserMetadata = None,
265
+ tags: list[str] | None = None,
266
+ tag_origin: str = "manual",
267
+ owner_id: str = "",
268
+ mime_type: str | None = None,
269
+ preview_id: str | None = None,
270
+ ) -> RegisterAssetResult:
271
+ user_metadata = user_metadata or {}
272
+
273
+ with create_session() as session:
274
+ asset = get_asset_by_hash(session, asset_hash=asset_hash)
275
+ if not asset:
276
+ raise ValueError(f"No asset with hash {asset_hash}")
277
+
278
+ if mime_type and not asset.mime_type:
279
+ update_asset_hash_and_mime(session, asset_id=asset.id, mime_type=mime_type)
280
+
281
+ if preview_id:
282
+ if not reference_exists(session, preview_id):
283
+ preview_id = None
284
+
285
+ ref, ref_created = get_or_create_reference(
286
+ session,
287
+ asset_id=asset.id,
288
+ owner_id=owner_id,
289
+ name=name,
290
+ preview_id=preview_id,
291
+ )
292
+
293
+ if not ref_created:
294
+ if preview_id and ref.preview_id != preview_id:
295
+ ref.preview_id = preview_id
296
+
297
+ tag_names = get_reference_tags(session, reference_id=ref.id)
298
+ result = RegisterAssetResult(
299
+ ref=extract_reference_data(ref),
300
+ asset=extract_asset_data(asset),
301
+ tags=tag_names,
302
+ created=False,
303
+ )
304
+ session.commit()
305
+ return result
306
+
307
+ new_meta = dict(user_metadata)
308
+ computed_filename = compute_loader_path(ref.file_path) if ref.file_path else None
309
+ if computed_filename:
310
+ new_meta["filename"] = computed_filename
311
+
312
+ if new_meta:
313
+ set_reference_metadata(
314
+ session,
315
+ reference_id=ref.id,
316
+ user_metadata=new_meta,
317
+ )
318
+
319
+ _backfill_image_dimensions_from_siblings(
320
+ session,
321
+ asset_id=asset.id,
322
+ new_reference_id=ref.id,
323
+ current_system_metadata=ref.system_metadata,
324
+ )
325
+
326
+ if tags is not None:
327
+ set_reference_tags(
328
+ session,
329
+ reference_id=ref.id,
330
+ tags=tags,
331
+ origin=tag_origin,
332
+ )
333
+
334
+ tag_names = get_reference_tags(session, reference_id=ref.id)
335
+ session.refresh(ref)
336
+ result = RegisterAssetResult(
337
+ ref=extract_reference_data(ref),
338
+ asset=extract_asset_data(asset),
339
+ tags=tag_names,
340
+ created=True,
341
+ )
342
+ session.commit()
343
+
344
+ return result
345
+
346
+
347
+
348
+ def _update_metadata_with_filename(
349
+ session: Session,
350
+ reference_id: str,
351
+ file_path: str | None,
352
+ current_metadata: dict | None,
353
+ user_metadata: dict[str, Any],
354
+ ) -> None:
355
+ computed_filename = compute_loader_path(file_path) if file_path else None
356
+
357
+ current_meta = current_metadata or {}
358
+ new_meta = dict(current_meta)
359
+ for k, v in user_metadata.items():
360
+ new_meta[k] = v
361
+ if computed_filename:
362
+ new_meta["filename"] = computed_filename
363
+
364
+ if new_meta != current_meta:
365
+ set_reference_metadata(
366
+ session,
367
+ reference_id=reference_id,
368
+ user_metadata=new_meta,
369
+ )
370
+
371
+
372
+ _IMAGE_DIMENSION_KEYS = ("kind", "width", "height")
373
+
374
+
375
+ def _maybe_store_image_dimensions(
376
+ session: Session,
377
+ reference_id: str,
378
+ file_path: str,
379
+ mime_type: str | None,
380
+ current_system_metadata: dict | None,
381
+ ) -> None:
382
+ """Populate ``kind``/``width``/``height`` on system_metadata for image refs.
383
+
384
+ Non-image MIME types are a no-op. Pre-existing keys (e.g. enricher-written
385
+ safetensors metadata, download provenance) are preserved by merge.
386
+ """
387
+ if not mime_type or not mime_type.startswith("image/"):
388
+ return
389
+
390
+ dims = extract_image_dimensions(file_path, mime_type=mime_type)
391
+ if not dims:
392
+ return
393
+
394
+ current = current_system_metadata or {}
395
+ merged = dict(current)
396
+ merged.update(dims)
397
+ if merged != current:
398
+ set_reference_system_metadata(
399
+ session,
400
+ reference_id=reference_id,
401
+ system_metadata=merged,
402
+ )
403
+
404
+
405
+ def _backfill_image_dimensions_from_siblings(
406
+ session: Session,
407
+ asset_id: str,
408
+ new_reference_id: str,
409
+ current_system_metadata: dict | None,
410
+ ) -> None:
411
+ """Copy image dimension keys from any sibling reference of the same asset.
412
+
413
+ The from-hash path doesn't read the file bytes, so dimensions can't be
414
+ extracted there directly. When another reference of the same asset already
415
+ carries image dimensions, copy them onto the new reference so consumers
416
+ see consistent metadata regardless of how the asset was registered.
417
+
418
+ Best-effort: missing siblings, non-image siblings, or absent dimension
419
+ keys leave the target reference unchanged.
420
+ """
421
+ current = current_system_metadata or {}
422
+ if current.get("kind") == "image" and "width" in current and "height" in current:
423
+ return
424
+
425
+ for sibling in list_references_by_asset_id(session, asset_id):
426
+ if sibling.id == new_reference_id:
427
+ continue
428
+ meta = sibling.system_metadata or {}
429
+ if meta.get("kind") != "image":
430
+ continue
431
+ width = meta.get("width")
432
+ height = meta.get("height")
433
+ if (
434
+ type(width) is not int
435
+ or type(height) is not int
436
+ or width <= 0
437
+ or height <= 0
438
+ ):
439
+ continue
440
+ merged = dict(current)
441
+ merged["kind"] = "image"
442
+ merged["width"] = width
443
+ merged["height"] = height
444
+ if merged != current:
445
+ set_reference_system_metadata(
446
+ session,
447
+ reference_id=new_reference_id,
448
+ system_metadata=merged,
449
+ )
450
+ return
451
+
452
+
453
+ def _sanitize_filename(name: str | None, fallback: str) -> str:
454
+ n = os.path.basename((name or "").strip() or fallback)
455
+ return n if n else fallback
456
+
457
+
458
+ class HashMismatchError(Exception):
459
+ pass
460
+
461
+
462
+ class DependencyMissingError(Exception):
463
+ def __init__(self, message: str):
464
+ self.message = message
465
+ super().__init__(message)
466
+
467
+
468
+ def upload_from_temp_path(
469
+ temp_path: str,
470
+ name: str | None = None,
471
+ tags: list[str] | None = None,
472
+ user_metadata: dict | None = None,
473
+ client_filename: str | None = None,
474
+ owner_id: str = "",
475
+ expected_hash: str | None = None,
476
+ mime_type: str | None = None,
477
+ preview_id: str | None = None,
478
+ ) -> UploadResult:
479
+ try:
480
+ digest, _ = hashing.compute_blake3_hash(temp_path)
481
+ except ImportError as e:
482
+ raise DependencyMissingError(str(e))
483
+ except Exception as e:
484
+ raise RuntimeError(f"failed to hash uploaded file: {e}")
485
+ asset_hash = "blake3:" + digest
486
+
487
+ if expected_hash and asset_hash != expected_hash.strip().lower():
488
+ raise HashMismatchError("Uploaded file hash does not match provided hash.")
489
+
490
+ with create_session() as session:
491
+ existing = get_asset_by_hash(session, asset_hash=asset_hash)
492
+
493
+ if existing is not None:
494
+ # Once content is already known, duplicate byte uploads are treated as
495
+ # reference-only creation. Request tags are labels only here: do not
496
+ # require upload destination tags, do not move bytes, and do not
497
+ # synthesize path-derived classification or uploaded provenance.
498
+ with contextlib.suppress(Exception):
499
+ if temp_path and os.path.exists(temp_path):
500
+ os.remove(temp_path)
501
+
502
+ display_name = _sanitize_filename(name or client_filename, fallback=digest)
503
+ result = _register_existing_asset(
504
+ asset_hash=asset_hash,
505
+ name=display_name,
506
+ user_metadata=user_metadata or {},
507
+ tags=tags or [],
508
+ tag_origin="manual",
509
+ owner_id=owner_id,
510
+ mime_type=mime_type,
511
+ preview_id=preview_id,
512
+ )
513
+ return UploadResult(
514
+ ref=result.ref,
515
+ asset=result.asset,
516
+ tags=result.tags,
517
+ created_new=False,
518
+ )
519
+
520
+ if not tags:
521
+ raise ValueError("tags are required for new asset uploads")
522
+ base_dir, subdirs = resolve_destination_from_tags(tags)
523
+ dest_dir = os.path.join(base_dir, *subdirs) if subdirs else base_dir
524
+ os.makedirs(dest_dir, exist_ok=True)
525
+
526
+ src_for_ext = (client_filename or name or "").strip()
527
+ _ext = os.path.splitext(os.path.basename(src_for_ext))[1] if src_for_ext else ""
528
+ ext = _ext if 0 < len(_ext) <= 16 else ""
529
+ hashed_basename = f"{digest}{ext}"
530
+ dest_abs = os.path.abspath(os.path.join(dest_dir, hashed_basename))
531
+ validate_path_within_base(dest_abs, base_dir)
532
+
533
+ content_type = mime_type or (
534
+ mimetypes.guess_type(os.path.basename(src_for_ext), strict=False)[0]
535
+ or mimetypes.guess_type(hashed_basename, strict=False)[0]
536
+ or "application/octet-stream"
537
+ )
538
+
539
+ try:
540
+ os.replace(temp_path, dest_abs)
541
+ except Exception as e:
542
+ raise RuntimeError(f"failed to move uploaded file into place: {e}")
543
+
544
+ try:
545
+ size_bytes, mtime_ns = get_size_and_mtime_ns(dest_abs)
546
+ except OSError as e:
547
+ raise RuntimeError(f"failed to stat destination file: {e}")
548
+
549
+ ingest_result = _ingest_file_from_path(
550
+ asset_hash=asset_hash,
551
+ abs_path=dest_abs,
552
+ size_bytes=size_bytes,
553
+ mtime_ns=mtime_ns,
554
+ mime_type=content_type,
555
+ info_name=_sanitize_filename(name or client_filename, fallback=digest),
556
+ owner_id=owner_id,
557
+ preview_id=preview_id,
558
+ user_metadata=user_metadata or {},
559
+ tags=[*(tags or []), "uploaded"],
560
+ tag_origin="manual",
561
+ require_existing_tags=False,
562
+ )
563
+ reference_id = ingest_result.reference_id
564
+ if not reference_id:
565
+ raise RuntimeError("failed to create asset reference")
566
+
567
+ with create_session() as session:
568
+ pair = fetch_reference_and_asset(
569
+ session, reference_id=reference_id, owner_id=owner_id
570
+ )
571
+ if not pair:
572
+ raise RuntimeError("inconsistent DB state after ingest")
573
+ ref, asset = pair
574
+ tag_names = get_reference_tags(session, reference_id=ref.id)
575
+
576
+ return UploadResult(
577
+ ref=extract_reference_data(ref),
578
+ asset=extract_asset_data(asset),
579
+ tags=tag_names,
580
+ created_new=ingest_result.asset_created,
581
+ )
582
+
583
+
584
+ def register_file_in_place(
585
+ abs_path: str,
586
+ name: str,
587
+ tags: list[str],
588
+ owner_id: str = "",
589
+ mime_type: str | None = None,
590
+ ) -> UploadResult:
591
+ """Register an already-saved file in the asset database without moving it.
592
+
593
+ This helper is used by upload paths that have already written bytes before
594
+ registering the file, so it records the same ``uploaded`` tag as the
595
+ multipart byte-upload path.
596
+
597
+ Tags are derived from trusted filesystem classification and merged with any
598
+ caller-provided tags, matching the behavior of the scanner.
599
+ If the path is not under a known root, only the caller-provided tags are used.
600
+ """
601
+ try:
602
+ _, path_tags = get_name_and_tags_from_asset_path(abs_path)
603
+ except ValueError:
604
+ path_tags = []
605
+ merged_tags = normalize_tags([*path_tags, *tags, "uploaded"])
606
+
607
+ try:
608
+ digest, _ = hashing.compute_blake3_hash(abs_path)
609
+ except ImportError as e:
610
+ raise DependencyMissingError(str(e))
611
+ except Exception as e:
612
+ raise RuntimeError(f"failed to hash file: {e}")
613
+ asset_hash = "blake3:" + digest
614
+
615
+ size_bytes, mtime_ns = get_size_and_mtime_ns(abs_path)
616
+ content_type = mime_type or (
617
+ mimetypes.guess_type(abs_path, strict=False)[0]
618
+ or "application/octet-stream"
619
+ )
620
+
621
+ ingest_result = _ingest_file_from_path(
622
+ abs_path=abs_path,
623
+ asset_hash=asset_hash,
624
+ size_bytes=size_bytes,
625
+ mtime_ns=mtime_ns,
626
+ mime_type=content_type,
627
+ info_name=_sanitize_filename(name, fallback=digest),
628
+ owner_id=owner_id,
629
+ tags=merged_tags,
630
+ tag_origin="upload",
631
+ require_existing_tags=False,
632
+ )
633
+ reference_id = ingest_result.reference_id
634
+ if not reference_id:
635
+ raise RuntimeError("failed to create asset reference")
636
+
637
+ with create_session() as session:
638
+ pair = fetch_reference_and_asset(
639
+ session, reference_id=reference_id, owner_id=owner_id
640
+ )
641
+ if not pair:
642
+ raise RuntimeError("inconsistent DB state after ingest")
643
+ ref, asset = pair
644
+ tag_names = get_reference_tags(session, reference_id=ref.id)
645
+
646
+ return UploadResult(
647
+ ref=extract_reference_data(ref),
648
+ asset=extract_asset_data(asset),
649
+ tags=tag_names,
650
+ created_new=ingest_result.asset_created,
651
+ )
652
+
653
+
654
+ def create_from_hash(
655
+ hash_str: str,
656
+ name: str,
657
+ tags: list[str] | None = None,
658
+ user_metadata: dict | None = None,
659
+ owner_id: str = "",
660
+ mime_type: str | None = None,
661
+ preview_id: str | None = None,
662
+ ) -> UploadResult | None:
663
+ canonical = hash_str.strip().lower()
664
+
665
+ try:
666
+ result = _register_existing_asset(
667
+ asset_hash=canonical,
668
+ name=_sanitize_filename(
669
+ name, fallback=canonical.split(":", 1)[1] if ":" in canonical else canonical
670
+ ),
671
+ user_metadata=user_metadata or {},
672
+ tags=tags or [],
673
+ tag_origin="manual",
674
+ owner_id=owner_id,
675
+ mime_type=mime_type,
676
+ preview_id=preview_id,
677
+ )
678
+ except ValueError:
679
+ logging.warning("create_from_hash: no asset found for hash %s", canonical)
680
+ return None
681
+
682
+ return UploadResult(
683
+ ref=result.ref,
684
+ asset=result.asset,
685
+ tags=result.tags,
686
+ created_new=False,
687
+ )