feat(products): domain skeleton — errors, column registry, canonical row model

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 14:55:37 -07:00
parent 0ee948b34d
commit c39bbd4728
3 changed files with 182 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
"""products domain — catalog + bulk CSV import/export (SD-0002 §6.2).
Owns the canonical row model, codec, validation, diff engine, and import
drafts/runs. Storefront-scoped throughout (INV-14); upsert is the only mutation
(INV-10). Imported via this package surface only.
"""
from __future__ import annotations
from .errors import (
DraftExpired,
DraftNotFound,
FileRejected,
NothingToApply,
PreviewStale,
ProductsError,
RunNotFound,
)
from .models import MAX_DATA_ROWS, MAX_FILE_BYTES
__all__ = [
"ProductsError", "FileRejected", "DraftNotFound", "DraftExpired",
"PreviewStale", "NothingToApply", "RunNotFound",
"MAX_DATA_ROWS", "MAX_FILE_BYTES",
]
+37
View File
@@ -0,0 +1,37 @@
"""products domain errors (SD-0002 §6.4 error envelope codes)."""
from __future__ import annotations
class ProductsError(Exception):
"""Base for products-domain errors."""
class FileRejected(ProductsError):
"""PUC-5a: the whole file is unusable; no draft is created. `code` is the §6.4
error code (not_csv | missing_required_column | unknown_dialect | too_many_rows |
file_too_large)."""
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
self.message = message
class DraftNotFound(ProductsError):
"""No such draft for this storefront (or already discarded)."""
class DraftExpired(ProductsError):
"""The draft's validity window passed (§6.3 ~1 h)."""
class PreviewStale(ProductsError):
"""INV-11: the catalog changed since validation — the previewed diff no longer holds."""
class NothingToApply(ProductsError):
"""PUC-10: no adds and no updates — confirming would be a no-op."""
class RunNotFound(ProductsError):
"""No such import run for this storefront."""
+121
View File
@@ -0,0 +1,121 @@
"""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