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