SLICE-8: Shopify dialect — detect/map Shopify export at the codec boundary + DOC-2/3 (SD-0002 §7.2) #32
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
|
|
||||||
|
from .dialect_shopify import is_shopify_header, map_shopify_header
|
||||||
from .errors import FileRejected
|
from .errors import FileRejected
|
||||||
from .models import KNOWN_COLUMNS, MAX_DATA_ROWS, MAX_FILE_BYTES, ParsedFile, Row
|
from .models import KNOWN_COLUMNS, MAX_DATA_ROWS, MAX_FILE_BYTES, ParsedFile, Row
|
||||||
|
|
||||||
@@ -13,8 +14,8 @@ _REQUIRED_HEADER_COLUMNS = ("Handle", "Title")
|
|||||||
|
|
||||||
|
|
||||||
def detect_dialect(header: list[str]) -> str:
|
def detect_dialect(header: list[str]) -> str:
|
||||||
"""The INV-17 seam: SLICE-8 recognizes Shopify's exact header set here."""
|
"""The INV-17 seam: recognize Shopify's header set, else canonical (§6.5.1)."""
|
||||||
return "canonical"
|
return "shopify" if is_shopify_header(header) else "canonical"
|
||||||
|
|
||||||
|
|
||||||
def parse_csv(data: bytes) -> ParsedFile:
|
def parse_csv(data: bytes) -> ParsedFile:
|
||||||
@@ -31,19 +32,34 @@ def parse_csv(data: bytes) -> ParsedFile:
|
|||||||
except StopIteration:
|
except StopIteration:
|
||||||
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
||||||
header = [h.strip() for h in raw_header]
|
header = [h.strip() for h in raw_header]
|
||||||
|
dialect = detect_dialect(header)
|
||||||
|
|
||||||
|
# INV-17: normalize the header to canonical names at the boundary. mapped[i]
|
||||||
|
# is the canonical name for header[i] (or None when that column has no
|
||||||
|
# canonical home); unknown is the not-imported warning list. Canonical files
|
||||||
|
# map to themselves; unknown columns are warned exactly as before.
|
||||||
|
if dialect == "shopify":
|
||||||
|
mapped, unknown = map_shopify_header(header)
|
||||||
|
else:
|
||||||
|
mapped = [c if c in KNOWN_COLUMNS else None for c in header]
|
||||||
|
unknown = [c for c in header if c and c not in KNOWN_COLUMNS]
|
||||||
|
|
||||||
for col in _REQUIRED_HEADER_COLUMNS:
|
for col in _REQUIRED_HEADER_COLUMNS:
|
||||||
if col not in header:
|
if col not in mapped:
|
||||||
raise FileRejected(
|
raise FileRejected(
|
||||||
"missing_required_column",
|
"missing_required_column",
|
||||||
f"This file is missing the required column '{col}'.",
|
f"This file is missing the required column '{col}'.",
|
||||||
)
|
)
|
||||||
# First occurrence of a duplicated column wins.
|
|
||||||
|
# First occurrence of a duplicated canonical column wins.
|
||||||
col_index: dict[str, int] = {}
|
col_index: dict[str, int] = {}
|
||||||
for i, name in enumerate(header):
|
for i, name in enumerate(mapped):
|
||||||
if name and name not in col_index:
|
if name and name not in col_index:
|
||||||
col_index[name] = i
|
col_index[name] = i
|
||||||
known_present = [c for c in col_index if c in KNOWN_COLUMNS]
|
# De-dup the warning list, order-preserving.
|
||||||
unknown = [c for c in col_index if c not in KNOWN_COLUMNS]
|
seen: set[str] = set()
|
||||||
|
unknown = [c for c in unknown if not (c in seen or seen.add(c))]
|
||||||
|
|
||||||
rows: list[Row] = []
|
rows: list[Row] = []
|
||||||
for raw in reader:
|
for raw in reader:
|
||||||
if not any(cell.strip() for cell in raw):
|
if not any(cell.strip() for cell in raw):
|
||||||
@@ -55,11 +71,12 @@ def parse_csv(data: bytes) -> ParsedFile:
|
|||||||
)
|
)
|
||||||
cells = {
|
cells = {
|
||||||
c: (raw[col_index[c]].strip() if col_index[c] < len(raw) else "")
|
c: (raw[col_index[c]].strip() if col_index[c] < len(raw) else "")
|
||||||
for c in known_present
|
for c in col_index
|
||||||
}
|
}
|
||||||
|
# Shopify grams carry an implicit unit; canonical needs it explicit (§6.5.1).
|
||||||
|
if dialect == "shopify" and cells.get("Variant Weight"):
|
||||||
|
cells["Variant Weight Unit"] = "g"
|
||||||
rows.append(Row(line_number=reader.line_num, cells=cells))
|
rows.append(Row(line_number=reader.line_num, cells=cells))
|
||||||
except csv.Error:
|
except csv.Error:
|
||||||
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
||||||
return ParsedFile(
|
return ParsedFile(dialect=dialect, header=header, unknown_columns=unknown, rows=rows)
|
||||||
dialect=detect_dialect(header), header=header, unknown_columns=unknown, rows=rows
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -67,3 +67,29 @@ def test_missing_column_message_names_the_column():
|
|||||||
with pytest.raises(FileRejected) as exc:
|
with pytest.raises(FileRejected) as exc:
|
||||||
parse_csv(_csv("Handle,Vendor", "mug,Acme"))
|
parse_csv(_csv("Handle,Vendor", "mug,Acme"))
|
||||||
assert "'Title'" in exc.value.message
|
assert "'Title'" in exc.value.message
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_detects_and_maps_shopify():
|
||||||
|
data = (
|
||||||
|
"Handle,Title,Body (HTML),Cost per item,Variant Price,Variant Grams,Type,Gift Card\n"
|
||||||
|
"mug,Moon Mug,<p>Grey</p>,9.50,18.00,300,Drinkware,false\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
parsed = parse_csv(data)
|
||||||
|
assert parsed.dialect == "shopify"
|
||||||
|
cells = parsed.rows[0].cells
|
||||||
|
assert cells["Description"] == "<p>Grey</p>"
|
||||||
|
assert cells["Variant Cost"] == "9.50"
|
||||||
|
assert cells["Variant Weight"] == "300"
|
||||||
|
assert cells["Variant Weight Unit"] == "g" # synthesized
|
||||||
|
# Type (free-text) and Gift Card warned, never mapped:
|
||||||
|
assert "Type" in parsed.unknown_columns
|
||||||
|
assert "Gift Card" in parsed.unknown_columns
|
||||||
|
assert "product_type" not in str(cells) # Type never reached canonical
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_canonical_unchanged():
|
||||||
|
data = b"Handle,Title,Description,Variant Price\nmug,Moon Mug,Grey,18.00\n"
|
||||||
|
parsed = parse_csv(data)
|
||||||
|
assert parsed.dialect == "canonical"
|
||||||
|
assert parsed.rows[0].cells["Description"] == "Grey"
|
||||||
|
assert parsed.unknown_columns == []
|
||||||
|
|||||||
Reference in New Issue
Block a user