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:
@@ -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"]
|
||||
Reference in New Issue
Block a user