SLICE-7: images pipeline end-to-end — fetch/host/serve + INV-12 over images (SD-0002 §7.2) #30

Merged
ben.stull merged 13 commits from worktree-slice-7-images into main 2026-06-12 08:00:01 +00:00
5 changed files with 36 additions and 10 deletions
Showing only changes of commit 906bc87c96 - Show all commits
+1
View File
@@ -44,6 +44,7 @@ class CatalogImage:
source_url: str source_url: str
position: int position: int
alt_text: str | None alt_text: str | None
status: str = "pending"
@dataclass @dataclass
+3 -2
View File
@@ -91,7 +91,7 @@ def load_catalog(conn: psycopg.Connection, storefront_id: int) -> dict[str, Cata
) )
) )
for row in conn.execute( for row in conn.execute(
"SELECT i.product_id, i.id, i.source_url, i.position, i.alt_text" "SELECT i.product_id, i.id, i.source_url, i.position, i.alt_text, i.status"
" FROM product_image i" " FROM product_image i"
" JOIN product p ON p.id = i.product_id" " JOIN product p ON p.id = i.product_id"
" WHERE p.storefront_id = %s" " WHERE p.storefront_id = %s"
@@ -99,7 +99,8 @@ def load_catalog(conn: psycopg.Connection, storefront_id: int) -> dict[str, Cata
(storefront_id,), (storefront_id,),
): ):
by_id[row[0]].images.append( by_id[row[0]].images.append(
CatalogImage(id=row[1], source_url=row[2], position=row[3], alt_text=row[4]) CatalogImage(id=row[1], source_url=row[2], position=row[3],
alt_text=row[4], status=row[5])
) )
return catalog return catalog
+11 -5
View File
@@ -14,6 +14,7 @@ from collections.abc import Iterable, Iterator
from decimal import Decimal from decimal import Decimal
from .diff import CatalogProduct from .diff import CatalogProduct
from .hosted import EXPORT_RENDITION, image_url
from .models import ( from .models import (
IMAGE_COLUMNS, IMAGE_COLUMNS,
OPTION_VALUE_COLUMNS, OPTION_VALUE_COLUMNS,
@@ -51,14 +52,14 @@ def _cell(value: object) -> str:
return str(value) return str(value)
def catalog_to_csv(products: Iterable[CatalogProduct]) -> Iterator[str]: def catalog_to_csv(products: Iterable[CatalogProduct], base_url: str = "") -> Iterator[str]:
"""Stream canonical CSV text, header first, one product block at a time.""" """Stream canonical CSV text, header first, one product block at a time."""
buf = io.StringIO() buf = io.StringIO()
writer = csv.writer(buf) writer = csv.writer(buf)
writer.writerow(HEADER) writer.writerow(HEADER)
yield _drain(buf) yield _drain(buf)
for product in products: for product in products:
for row in _product_rows(product): for row in _product_rows(product, base_url):
writer.writerow([row.get(col, "") for col in HEADER]) writer.writerow([row.get(col, "") for col in HEADER])
yield _drain(buf) yield _drain(buf)
@@ -70,7 +71,7 @@ def _drain(buf: io.StringIO) -> str:
return text return text
def _product_rows(product: CatalogProduct) -> list[dict[str, str]]: def _product_rows(product: CatalogProduct, base_url: str = "") -> list[dict[str, str]]:
"""The product's CSV rows (§6.5.1 grammar): product fields + option names on """The product's CSV rows (§6.5.1 grammar): product fields + option names on
the first row; one variant per row; images interleaved; image-only rows when the first row; one variant per row; images interleaved; image-only rows when
a product has more images than variants.""" a product has more images than variants."""
@@ -90,12 +91,17 @@ def _product_rows(product: CatalogProduct) -> list[dict[str, str]]:
if i < len(product.variants): if i < len(product.variants):
_write_variant(row, product, product.variants[i]) _write_variant(row, product, product.variants[i])
if i < len(product.images): if i < len(product.images):
_write_image(row, product.images[i]) _write_image(row, product.images[i], base_url)
rows.append(row) rows.append(row)
return rows return rows
def _write_image(row: dict[str, str], image) -> None: def _write_image(row: dict[str, str], image, base_url: str) -> None:
# Fetched images export the platform-hosted URL (INV-12/16); everything
# else keeps the original source_url so nothing is lost (§6.5.5).
if image.status == "fetched":
row["Image Src"] = image_url(base_url, image.id, EXPORT_RENDITION)
else:
row["Image Src"] = image.source_url row["Image Src"] = image.source_url
row["Image Position"] = str(image.position) row["Image Position"] = str(image.position)
if image.alt_text is not None: if image.alt_text is not None:
+2 -2
View File
@@ -12,7 +12,7 @@ from datetime import datetime, timezone
import psycopg import psycopg
from app.platform import telemetry from app.platform import config, telemetry
from . import codec, diff, repo, serialize, validate from . import codec, diff, repo, serialize, validate
from .errors import ( from .errors import (
@@ -242,7 +242,7 @@ def export_catalog(
raise EmptyCatalog() raise EmptyCatalog()
def _stream() -> Iterator[str]: def _stream() -> Iterator[str]:
yield from serialize.catalog_to_csv(snapshot) yield from serialize.catalog_to_csv(snapshot, base_url=config.public_base_url())
telemetry.emit( telemetry.emit(
"catalog_exported", "catalog_exported",
storefront_id=storefront_id, storefront_id=storefront_id,
+18
View File
@@ -104,6 +104,24 @@ def test_more_images_than_variants_emits_image_only_rows():
assert [r["Image Src"] for r in rows] == ["https://x/a.jpg", "https://x/b.jpg", "https://x/c.jpg"] assert [r["Image Src"] for r in rows] == ["https://x/a.jpg", "https://x/b.jpg", "https://x/c.jpg"]
def test_fetched_image_serializes_hosted_detail_url():
product = CatalogProduct(
id=1, handle="lamp", title="Lamp", option_names=(None, None, None),
fields={"title": "Lamp", "status": "active"},
variants=[CatalogVariant(id=1, options=(None, None, None), position=1, fields={})],
images=[
CatalogImage(id=55, source_url="https://m.example/a.png", position=1,
alt_text=None, status="fetched"),
CatalogImage(id=56, source_url="https://m.example/b.png", position=2,
alt_text=None, status="failed"),
],
)
rows = list(serialize._product_rows(product, base_url="https://shop.test"))
srcs = [r.get("Image Src") for r in rows if r.get("Image Src")]
assert "https://shop.test/api/products/images/55/detail" in srcs # fetched -> hosted
assert "https://m.example/b.png" in srcs # failed -> source kept
import random import random
from app.domains.products import codec, diff, validate from app.domains.products import codec, diff, validate