feat(products): Shopify dialect adapter — header detection + canonical mapping (SD-0002 §6.5.1, SLICE-8)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 02:19:15 -07:00
parent e16ec7cf94
commit daa237dd59
2 changed files with 114 additions and 0 deletions
@@ -0,0 +1,71 @@
"""Shopify product-CSV adapter (INV-17): detect by header signature, map to canonical.
The mapping is the §6.5.1 contract; the exhaustive table is pinned here and
mirrored by tests/test_products_dialect_shopify.py + e2e/fixtures/shopify-export.csv.
"""
from __future__ import annotations
from .models import KNOWN_COLUMNS
# Shopify header -> canonical header (renames only; direct same-name columns pass
# through via KNOWN_COLUMNS). Variant Grams carries a value transform (see codec).
SHOPIFY_RENAME: dict[str, str] = {
"Body (HTML)": "Description",
"Product Category": "Google Product Category",
"Cost per item": "Variant Cost",
"Variant Grams": "Variant Weight",
}
# Canonical-named columns Shopify uses differently — must NOT pass through.
# Type: Shopify's free-text type vs canonical's structural Type (§6.5.1, decision D).
# Variant Weight Unit: superseded by the Variant Grams -> Variant Weight transform.
SHOPIFY_DROP: frozenset[str] = frozenset({"Type", "Variant Weight Unit"})
# Columns whose presence proves the file is a Shopify export (canonical never has them).
_SIGNATURE: frozenset[str] = frozenset(
{
"Body (HTML)",
"Variant Grams",
"Cost per item",
"Variant Compare At Price",
"Variant Inventory Policy",
"Variant Fulfillment Service",
"Variant Requires Shipping",
"Variant Taxable",
"Gift Card",
"SEO Title",
"SEO Description",
}
)
def is_shopify_header(header: list[str]) -> bool:
"""True iff the header carries a Shopify-only signature column. Otherwise the
file is treated as canonical — shared columns map identically, so an ambiguous
header never misparses (§7.4)."""
cols = {h.strip() for h in header}
if cols & _SIGNATURE:
return True
return any(c.startswith("Google Shopping / ") for c in cols)
def map_shopify_header(header: list[str]) -> tuple[list[str | None], list[str]]:
"""Return (mapped, not_imported): mapped[i] is the canonical name for header[i],
or None when that Shopify column has no canonical home; not_imported lists the
original Shopify names (in order) that were dropped — the preview warning."""
mapped: list[str | None] = []
not_imported: list[str] = []
for raw in header:
col = raw.strip()
if col in SHOPIFY_RENAME:
mapped.append(SHOPIFY_RENAME[col])
elif col in SHOPIFY_DROP:
mapped.append(None)
not_imported.append(col)
elif col in KNOWN_COLUMNS:
mapped.append(col)
else:
mapped.append(None)
if col:
not_imported.append(col)
return mapped, not_imported
@@ -0,0 +1,43 @@
"""Shopify dialect adapter — detection + the §6.5.1 mapping contract (SLICE-8)."""
from app.domains.products.dialect_shopify import is_shopify_header, map_shopify_header
def test_detects_shopify_by_signature_column():
assert is_shopify_header(["Handle", "Title", "Body (HTML)", "Variant Price"]) is True
assert is_shopify_header(["Handle", "Title", "Google Shopping / MPN"]) is True
def test_canonical_header_is_not_shopify():
assert is_shopify_header(["Handle", "Title", "Description", "Variant Cost"]) is False
def test_ambiguous_shared_only_header_defaults_canonical():
# Only columns common to both dialects -> not Shopify (safe default, never misparse).
assert (
is_shopify_header(["Handle", "Title", "Option1 Name", "Variant Price", "Image Src"])
is False
)
def test_map_renames_and_passes_through():
mapped, not_imported = map_shopify_header(
["Handle", "Body (HTML)", "Cost per item", "Variant Price"]
)
assert mapped == ["Handle", "Description", "Variant Cost", "Variant Price"]
assert not_imported == []
def test_map_drops_type_and_weight_unit_overrides():
mapped, not_imported = map_shopify_header(
["Handle", "Type", "Variant Grams", "Variant Weight Unit"]
)
assert mapped == ["Handle", None, "Variant Weight", None]
assert not_imported == ["Type", "Variant Weight Unit"]
def test_map_drops_shopify_only_and_market_columns():
mapped, not_imported = map_shopify_header(
["Handle", "Variant Compare At Price", "Gift Card", "Price / International"]
)
assert mapped == ["Handle", None, None, None]
assert not_imported == ["Variant Compare At Price", "Gift Card", "Price / International"]