c39bbd4728
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
122 lines
3.5 KiB
Python
122 lines
3.5 KiB
Python
"""Canonical row model + column registry — the one model every dialect maps to (INV-17)."""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
# §6.5.1 canonical columns, by level. Header detection, unknown-column warnings, and
|
|
# validation all read from this registry.
|
|
PRODUCT_COLUMNS: dict[str, str] = {
|
|
# column -> product field name
|
|
"Title": "title",
|
|
"Description": "description_html",
|
|
"Vendor": "vendor",
|
|
"Type": "product_type",
|
|
"Google Product Category": "google_product_category",
|
|
"Tags": "tags",
|
|
"Status": "status",
|
|
"Published": "published",
|
|
"Option1 Name": "option1_name",
|
|
"Option2 Name": "option2_name",
|
|
"Option3 Name": "option3_name",
|
|
}
|
|
VARIANT_COLUMNS: dict[str, str] = {
|
|
"Variant SKU": "sku",
|
|
"Variant Barcode": "barcode",
|
|
"Variant Price": "price",
|
|
"Variant Cost": "cost",
|
|
"Variant Weight": "weight",
|
|
"Variant Weight Unit": "weight_unit",
|
|
"Variant Volume": "volume",
|
|
"Variant Volume Unit": "volume_unit",
|
|
"Variant Tax ID 1": "tax_id_1",
|
|
"Variant Tax ID 2": "tax_id_2",
|
|
"Variant Inventory Tracker": "inventory_tracker",
|
|
"Variant Inventory Qty": "inventory_qty",
|
|
"Variant Position": "position",
|
|
"Variant Image": "variant_image",
|
|
}
|
|
OPTION_VALUE_COLUMNS = ("Option1 Value", "Option2 Value", "Option3 Value")
|
|
IMAGE_COLUMNS = ("Image Src", "Image Position", "Image Alt Text")
|
|
COMPONENT_COLUMNS = tuple(
|
|
f"Component {i} {kind}" for i in range(1, 11) for kind in ("SKU", "Quantity")
|
|
)
|
|
KNOWN_COLUMNS = (
|
|
{"Handle"}
|
|
| set(PRODUCT_COLUMNS)
|
|
| set(VARIANT_COLUMNS)
|
|
| set(OPTION_VALUE_COLUMNS)
|
|
| set(IMAGE_COLUMNS)
|
|
| set(COMPONENT_COLUMNS)
|
|
)
|
|
|
|
# Clearing a field (present-but-empty cell, §6.5.1) resets it to its default.
|
|
CLEAR_DEFAULTS: dict[str, object] = {
|
|
"status": "active",
|
|
"published": True,
|
|
"product_type": "standalone",
|
|
"tags": [],
|
|
}
|
|
|
|
MAX_DATA_ROWS = 5_000 # INV-18
|
|
MAX_FILE_BYTES = 10 * 1024 * 1024 # INV-18
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Row:
|
|
"""One CSV data row: 1-based file line number + the cells of known columns
|
|
present in the header (column name -> raw string, possibly empty)."""
|
|
|
|
line_number: int
|
|
cells: dict[str, str]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ParsedFile:
|
|
dialect: str
|
|
header: list[str]
|
|
unknown_columns: list[str]
|
|
rows: list[Row]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RowError:
|
|
line_number: int
|
|
column: str | None
|
|
message: str
|
|
|
|
def as_json(self) -> dict:
|
|
return {"line": self.line_number, "column": self.column, "message": self.message}
|
|
|
|
|
|
@dataclass
|
|
class CanonicalVariant:
|
|
line_number: int
|
|
options: tuple[str | None, str | None, str | None]
|
|
# field name -> normalized value; present only for columns in the file.
|
|
# value None == clear (reset to default/NULL).
|
|
fields: dict[str, object] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class CanonicalImage:
|
|
line_number: int
|
|
source_url: str
|
|
position: int
|
|
alt_text: str | None
|
|
|
|
|
|
@dataclass
|
|
class CanonicalProduct:
|
|
first_line: int
|
|
handle: str
|
|
title: str # "" when missing (the block then carries an error)
|
|
option_names: tuple[str | None, str | None, str | None] = (None, None, None)
|
|
fields: dict[str, object] = field(default_factory=dict) # product-level, same semantics
|
|
variants: list[CanonicalVariant] = field(default_factory=list)
|
|
images: list[CanonicalImage] = field(default_factory=list)
|
|
errors: list[RowError] = field(default_factory=list)
|
|
|
|
@property
|
|
def valid(self) -> bool:
|
|
return not self.errors
|