Files
handbook/docker/packages/shopify/shopify/state.py
T
2026-06-29 10:08:59 -07:00

1439 lines
47 KiB
Python

"""
Shopify MCP State Management.
Manages local state stored in shopify_data.json.
"""
import contextlib
import copy
import json
import os
import re
import sys
from collections.abc import Iterable, Sequence
from pathlib import Path
from typing import Any, cast
from .models import (
CartCost,
CartLine,
CategoryFilter,
LooseCart,
LooseCollection,
LooseCustomer,
LooseDiscountCode,
LooseFAQ,
LooseGiftCard,
LooseOrder,
LoosePolicy,
LooseProduct,
LooseReturn,
LooseReview,
LooseShippingMethod,
LoyaltyProgram,
MoneyV2,
ProductVariant,
SearchFilter,
ShopifyStateCounters,
ShopifyStateModel,
VariantOptionFilter,
)
from .utils import get_shopify_state_path
_SERVICE_NAME = "shopify"
_QUERY_TOKEN_RE = re.compile(r'"([^"]+)"|\S+')
def resolve_bundle_state_paths() -> list[Path]:
"""Resolve the seed-state files inside this service's bundle subdir.
The folder ``<BUNDLEDIR>/services/<name>/`` is the unit: everything in it
is this service's seed. Prefer the canonical single-file ``state.json``
(the output round-trip shape); otherwise hand back ALL ``*.json`` in the
folder (the raw entities layout), coalesced by the loader.
"""
bundle_dir = os.environ.get("BUNDLEDIR")
if not bundle_dir:
return []
service_dir = Path(bundle_dir) / "services" / _SERVICE_NAME
state_file = service_dir / "state.json"
if state_file.is_file():
return [state_file]
if service_dir.is_dir():
return sorted(service_dir.glob("*.json"))
return []
def resolve_bundle_state_path() -> Path | None:
"""Back-compat single-file view of :func:`resolve_bundle_state_paths`."""
paths = resolve_bundle_state_paths()
return paths[0] if paths else None
def _merge_flat_into(target: dict[str, Any], source: dict[str, Any]) -> None:
"""Merge a flat store seed into ``target``: dicts update, lists extend, scalars overwrite."""
for key, value in source.items():
if isinstance(value, dict) and isinstance(target.get(key), dict):
target[key].update(value)
elif isinstance(value, list) and isinstance(target.get(key), list):
target[key].extend(value)
else:
target[key] = value
def _coalesce_store_files(paths: list[Path]) -> dict[str, Any] | None:
"""Coalesce a folder of seed files into one seed dict.
A single file passes through unchanged (flat single-store or ``{stores:
{...}}`` wrapper). With multiple files, flat (non-wrapper) files are merged
into ONE ``default`` store — the raw entities layout splits one store
across per-entity files, and those belong together, not in separate stores.
Files carrying an explicit ``{stores: {...}}`` wrapper contribute their
named stores.
"""
if not paths:
return None
if len(paths) == 1:
return cast(dict[str, Any], json.loads(paths[0].read_text()))
stores: dict[str, Any] = {}
default_store: dict[str, Any] = {}
for path in paths:
data = json.loads(path.read_text())
if isinstance(data, dict) and "stores" in data:
stores.update(data["stores"])
elif isinstance(data, dict):
_merge_flat_into(default_store, data)
if default_store:
stores.setdefault("default", default_store)
return {"stores": stores}
def resolve_bundle_output_path() -> Path | None:
output_dir = os.environ.get("BUNDLE_OUTPUT_DIR")
if not output_dir:
return None
return Path(output_dir) / "state.json"
def _parse_query_tokens(query: str) -> list[str]:
"""Parse a search query into case-insensitive match tokens.
Double-quoted segments become phrase tokens that must appear contiguously;
everything else splits on whitespace. Every token must appear somewhere in
the searched haystack (word-AND), matching real-world search expectations
and avoiding the exact-phrase-substring gotcha.
"""
tokens: list[str] = []
for m in _QUERY_TOKEN_RE.finditer(query):
tok = (m.group(1) or m.group(0)).strip().lower()
if tok:
tokens.append(tok)
return tokens
def _state_model_from_data(data: dict[str, Any] | None) -> ShopifyStateModel:
"""Validate state data into the internal Pydantic state model."""
return ShopifyStateModel.model_validate(data or {})
def _canonicalize_state_data(data: dict[str, Any] | None) -> dict[str, Any]:
"""Validate state data while preserving meaningful omitted product fields."""
initial = _state_model_from_data(data)
# Dump then re-validate so mutable in-memory model edits are checked rather
# than trusted as already-valid Pydantic instances.
validated = ShopifyStateModel.model_validate(initial.model_dump(mode="json", warnings=False))
canonical = validated.model_dump(
mode="json",
exclude_unset=True,
)
for product in canonical.get("products", {}).values():
if product.get("availableForSale") is None:
product.pop("availableForSale", None)
return canonical
_CART_ID_RE = re.compile(r"(?:^|/)Cart/c?(\d+)$")
_CART_LINE_ID_RE = re.compile(r"(?:^|/)CartLine/(\d+)$")
_PRODUCT_ID_RE = re.compile(r"(?:^|/)Product/(\d+)$")
_VARIANT_ID_RE = re.compile(r"(?:^|/)ProductVariant/(\d+)(?:-|$)")
_ORDER_ID_RE = re.compile(r"(?:^|/)Order/o?(\d+)$")
_ORDER_LINE_ITEM_ID_RE = re.compile(r"(?:^|/)OrderLineItem/(\d+)$")
_CUSTOMER_ID_RE = re.compile(r"(?:^|/)Customer/(\d+)$")
_COLLECTION_ID_RE = re.compile(r"(?:^|/)Collection/(\d+)$")
_REVIEW_ID_RE = re.compile(r"(?:^|/)Review/(\d+)$")
_RETURN_ID_RE = re.compile(r"(?:^|/)Return/(\d+)$")
_DISCOUNT_ID_RE = re.compile(r"(?:^|/)DiscountCode/(\d+)$")
_POLICY_ID_RE = re.compile(r"(?:^|/)ShopPolicy/(\d+)$")
def _max_generated_id(values: Iterable[str | None], pattern: re.Pattern[str]) -> int | None:
max_value: int | None = None
for value in values:
if value is None:
continue
match = pattern.search(value)
if match is None:
continue
numeric_value = int(match.group(1))
if max_value is None or numeric_value > max_value:
max_value = numeric_value
return max_value
def _recover_counter(
counter: ShopifyStateCounters, attr: str, values: Iterable[str | None], pattern: re.Pattern[str]
) -> None:
existing_max = _max_generated_id(values, pattern)
if existing_max is not None:
setattr(counter, attr, max(getattr(counter, attr), existing_max))
def _recover_counters_from_entities(state: ShopifyStateModel) -> None:
"""Advance counters past IDs already present in seeded state."""
counter = state.counters
_recover_counter(
counter, "cart_id", [*state.carts.keys(), *(cart.id for cart in state.carts.values())], _CART_ID_RE
)
_recover_counter(
counter,
"line_id",
(line.id for cart in state.carts.values() for line in cart.lines),
_CART_LINE_ID_RE,
)
_recover_counter(
counter,
"product_id",
[*state.products.keys(), *(product.id for product in state.products.values())],
_PRODUCT_ID_RE,
)
_recover_counter(
counter,
"variant_id",
(variant.id for product in state.products.values() for variant in product.variants),
_VARIANT_ID_RE,
)
_recover_counter(
counter, "order_id", [*state.orders.keys(), *(order.id for order in state.orders.values())], _ORDER_ID_RE
)
_recover_counter(
counter,
"line_item_id",
(line.id for order in state.orders.values() for line in order.lineItems),
_ORDER_LINE_ITEM_ID_RE,
)
_recover_counter(
counter,
"customer_id",
[*state.customers.keys(), *(customer.id for customer in state.customers.values())],
_CUSTOMER_ID_RE,
)
_recover_counter(
counter,
"collection_id",
[*state.collections.keys(), *(collection.id for collection in state.collections.values())],
_COLLECTION_ID_RE,
)
_recover_counter(
counter, "review_id", [*state.reviews.keys(), *(review.id for review in state.reviews.values())], _REVIEW_ID_RE
)
_recover_counter(
counter,
"return_id",
[*state.returns.keys(), *(return_obj.id for return_obj in state.returns.values())],
_RETURN_ID_RE,
)
_recover_counter(
counter,
"discount_id",
[*state.discount_codes.keys(), *(discount.id for discount in state.discount_codes.values())],
_DISCOUNT_ID_RE,
)
_recover_counter(counter, "policy_id", (policy.id for policy in state.policies), _POLICY_ID_RE)
# State file path - can be set via set_agent_workspace()
_STATE_FILE: Path | None = None
_AGENT_WORKSPACE: str | None = None
_bundle_state_path: Path | None = None
_final_path: Path | None = None
_UNSET = object()
def set_agent_workspace(agent_workspace: str) -> None:
"""Set the agent workspace path, which determines where state is stored."""
global _STATE_FILE, _AGENT_WORKSPACE, _current_state, _active_store_id, _last_valid_state_snapshot
_AGENT_WORKSPACE = agent_workspace
_STATE_FILE = get_shopify_state_path(agent_workspace)
# Ensure the directory exists
_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
# Drop every cached store so load_state()'s early-exit on `_stores` doesn't
# serve up the previous workspace's data. The old code only reset
# _current_state, which is no longer the source of truth post multi-store.
_stores.clear()
_current_state = None
_last_valid_state_snapshot = None
_active_store_id = "default"
print(f"Shopify state file: {_STATE_FILE}", file=sys.stderr)
def set_snapshot_paths(
*,
final_path: Path | str | None | object = _UNSET,
bundle_state_path: Path | str | None | object = _UNSET,
) -> None:
"""Set optional post-tool snapshot destinations.
Omitted keyword arguments preserve the existing value; pass None
explicitly to clear a path.
"""
global _bundle_state_path, _final_path
if final_path is not _UNSET:
_final_path = Path(cast(str | Path, final_path)) if final_path is not None else None
if bundle_state_path is not _UNSET:
_bundle_state_path = Path(cast(str | Path, bundle_state_path)) if bundle_state_path is not None else None
def configure_snapshots_from_env() -> None:
outputdir = os.environ.get("OUTPUTDIR")
set_snapshot_paths(
final_path=Path(outputdir) / "final.json" if outputdir else None,
bundle_state_path=resolve_bundle_output_path(),
)
def write_snapshots() -> None:
if _bundle_state_path is not None:
dump_state(_bundle_state_path, "bundle")
if _final_path is not None:
dump_state(_final_path, "final")
def _get_state_file() -> Path | None:
"""Get the state file path. ``None`` means in-memory only.
Returning a path is gated on ``set_agent_workspace()`` (or a test
patching ``_STATE_FILE`` directly). Never default to a path inside the
source tree — without a workspace, ``packages/shopify/`` was getting
clobbered on every server startup by the ``state_from_json`` →
``save_state`` round-trip.
"""
return _STATE_FILE
# ============================================
# STATE INTERFACE
# ============================================
class ShopifyState:
"""Shopify state container."""
def __init__(self, data: dict[str, Any] | None = None):
state = _state_model_from_data(data)
_recover_counters_from_entities(state)
self.products: dict[str, LooseProduct] = state.products
self.carts: dict[str, LooseCart] = state.carts
self.orders: dict[str, LooseOrder] = state.orders
self.customers: dict[str, LooseCustomer] = state.customers
self.collections: dict[str, LooseCollection] = state.collections
self.reviews: dict[str, LooseReview] = state.reviews
self.returns: dict[str, LooseReturn] = state.returns
self.discount_codes: dict[str, LooseDiscountCode] = state.discount_codes
self.gift_cards: dict[str, LooseGiftCard] = state.gift_cards
self.shipping_methods: dict[str, LooseShippingMethod] = state.shipping_methods
self.loyalty_program: LoyaltyProgram = state.loyalty_program
# Identifies which customer the agent is acting as in customer-mode
# worlds. Self-only tools (get_my_customer, redeem_my_points, etc.)
# look up this customer; unset means customer-mode tools error clearly.
self.current_customer_email: str | None = state.current_customer_email
self.policies: list[LoosePolicy] = state.policies
self.faqs: list[LooseFAQ] = state.faqs
self.counters: ShopifyStateCounters = state.counters
def to_dict(self) -> dict[str, Any]:
data = {
"products": self.products,
"carts": self.carts,
"orders": self.orders,
"customers": self.customers,
"collections": self.collections,
"reviews": self.reviews,
"returns": self.returns,
"discount_codes": self.discount_codes,
"gift_cards": self.gift_cards,
"shipping_methods": self.shipping_methods,
"loyalty_program": self.loyalty_program,
"current_customer_email": self.current_customer_email,
"policies": self.policies,
"faqs": self.faqs,
"counters": self.counters,
}
return _canonicalize_state_data(data)
# ============================================
# STATE MANAGEMENT — Multi-Store Support
# ============================================
# Store registry: maps store_id → ShopifyState
_stores: dict[str, ShopifyState] = {}
# Legacy single-state reference for backward compat in save_state file writes
_current_state: ShopifyState | None = None
_last_valid_state_snapshot: dict[str, Any] | None = None
def _state_snapshot() -> dict[str, Any]:
"""Return the current store registry as validated JSON-native state."""
if len(_stores) == 1 and "default" in _stores:
return _stores["default"].to_dict()
return {"stores": {sid: s.to_dict() for sid, s in _stores.items()}}
def _stores_from_json(data: dict[str, Any]) -> dict[str, ShopifyState]:
"""Build a store registry from JSON-native state without mutating globals."""
if "stores" in data:
return {sid: ShopifyState(sdata) for sid, sdata in data["stores"].items()}
return {"default": ShopifyState(data)}
def _install_stores(stores: dict[str, ShopifyState]) -> None:
"""Replace the global store registry."""
global _current_state
_stores.clear()
_stores.update(stores)
_current_state = _stores.get("default", next(iter(_stores.values()), None))
def _restore_last_valid_state() -> None:
if _last_valid_state_snapshot is not None:
_install_stores(_stores_from_json(copy.deepcopy(_last_valid_state_snapshot)))
def state_snapshot_for_rollback() -> dict[str, Any]:
"""Capture a validated snapshot that can restore a failed write tool."""
return copy.deepcopy(_state_snapshot())
def restore_state_snapshot(snapshot: dict[str, Any]) -> None:
"""Restore a previously captured state snapshot and persist it."""
_install_stores(_stores_from_json(copy.deepcopy(snapshot)))
save_state()
def state_to_json() -> dict[str, Any]:
"""Return the full state as a JSON-native dict.
Flat single-store shape when only ``default`` is configured (back-compat
with pre-multi-store fixtures); multi-store worlds emit ``{"stores": {...}}``.
Round-trips with state_from_json.
"""
return _state_snapshot()
def state_from_json(data: dict[str, Any]) -> None:
"""Full-replace the state from a JSON-native dict.
Accepts either the flat single-store shape (keys at top level → loaded
into the ``default`` store) or the multi-store wrapper (``{"stores": {sid:
{...}}}``).
"""
_install_stores(_stores_from_json(data))
save_state()
def dump_state(dest: Path, label: str) -> None:
"""Write a JSON snapshot of all stores to *dest*."""
if not _stores:
return
try:
dest.parent.mkdir(parents=True, exist_ok=True)
if len(_stores) == 1 and "default" in _stores:
# Single store — dump flat format for backward compat
snapshot = _stores["default"].to_dict()
else:
# Multi-store — dump under "stores" key
snapshot = {"stores": {sid: s.to_dict() for sid, s in _stores.items()}}
with open(dest, "w") as f:
json.dump(snapshot, f, indent=2)
f.write("\n")
print(f"Wrote {label} state snapshot to {dest}", file=sys.stderr)
except Exception as e:
print(f"Error writing {label} state snapshot to {dest}: {e}", file=sys.stderr)
def load_state() -> ShopifyState:
"""Load state from JSON file. Returns the default store for backward compat."""
global _current_state
if _stores:
return _stores.get("default", next(iter(_stores.values())))
print(f"BUNDLEDIR={os.environ.get('BUNDLEDIR', '<unset>')}", file=sys.stderr)
print(f"INPUTDIR={os.environ.get('INPUTDIR', '<unset>')}", file=sys.stderr)
print(f"OUTPUTDIR={os.environ.get('OUTPUTDIR', '<unset>')}", file=sys.stderr)
# The bundle folder is read in full and coalesced (a lone state.json is a
# one-element list); otherwise fall back to the first INPUTDIR/*.json
# (legacy first-file contract, see scripts/validate_external_services.py).
bundle_paths = resolve_bundle_state_paths()
inputdir = os.environ.get("INPUTDIR")
data: dict[str, Any] | None = None
if bundle_paths:
try:
data = _coalesce_store_files(bundle_paths)
print(f"Loaded state from {[str(p) for p in bundle_paths]}", file=sys.stderr)
except Exception as e:
print(f"Error loading state from {[str(p) for p in bundle_paths]}: {e}", file=sys.stderr)
elif inputdir and Path(inputdir).is_dir():
json_files = sorted(Path(inputdir).glob("*.json"))
if json_files:
try:
with open(json_files[0]) as f:
data = json.load(f)
print(f"Loaded state from {json_files[0]}", file=sys.stderr)
except Exception as e:
print(f"Error loading state from {json_files[0]}: {e}", file=sys.stderr)
if data is None:
state_file = _get_state_file()
if state_file is None:
data = {}
print("No agent workspace configured, starting empty (in-memory only)", file=sys.stderr)
else:
try:
if state_file.exists():
with open(state_file) as f:
data = json.load(f)
print(f"Loaded state from {state_file}", file=sys.stderr)
else:
data = {}
print(f"No state file found at {state_file}, starting empty", file=sys.stderr)
except Exception as e:
print(f"Error loading state: {e}", file=sys.stderr)
data = {}
# Route through the shared codec — multi-store-aware, populates _stores
# and _current_state, and persists via save_state().
state_from_json(data)
print(f"Shopify initialized with {len(_stores)} store(s)", file=sys.stderr)
assert _current_state is not None
return _current_state
def init_state(agent_workspace: str | None = None) -> None:
"""Initialize state and startup snapshots for the Shopify MCP server."""
if agent_workspace:
set_agent_workspace(agent_workspace)
load_state()
configure_snapshots_from_env()
if _bundle_state_path is not None:
dump_state(_bundle_state_path, "bundle")
if outputdir := os.environ.get("OUTPUTDIR"):
dump_state(Path(outputdir) / "initial.json", "initial")
def save_state() -> None:
"""Save state to JSON file.
State serialization validates the full in-memory state through
ShopifyStateModel. Let failures propagate so tools do not report success
after producing invalid or unwritable state.
"""
if not _stores:
return
state_file = _get_state_file()
if state_file is None:
return # in-memory only — no workspace configured
global _last_valid_state_snapshot
try:
snapshot = _state_snapshot()
with open(state_file, "w") as f:
json.dump(snapshot, f, indent=2)
f.write("\n")
except Exception:
_restore_last_valid_state()
raise
else:
_last_valid_state_snapshot = copy.deepcopy(snapshot)
# Active store context — set by server.py before calling tool handlers.
# This avoids passing store_id through every helper function.
_active_store_id: str = "default"
def set_active_store(store_id: str) -> None:
"""Set the active store for subsequent get_state()/save_state() calls."""
global _active_store_id
if not _stores:
load_state()
if store_id not in _stores:
available = ", ".join(sorted(_stores.keys()))
raise ValueError(f"Store '{store_id}' not found. Available: {available}")
_active_store_id = store_id
def get_state() -> ShopifyState:
"""Get the active store's state, loading if necessary."""
if not _stores:
load_state()
if _active_store_id not in _stores:
available = ", ".join(sorted(_stores.keys()))
raise ValueError(f"Store '{_active_store_id}' not found. Available: {available}")
return _stores[_active_store_id]
def get_all_stores() -> dict[str, ShopifyState]:
"""Get all store states."""
if not _stores:
load_state()
return _stores
def reset_state() -> None:
"""Reset state to empty."""
global _current_state
_stores.clear()
_stores["default"] = ShopifyState()
_current_state = _stores["default"]
save_state()
print("State reset to empty", file=sys.stderr)
# ============================================
# COUNTER HELPERS
# ============================================
def get_next_cart_id() -> str:
"""Generate a new cart ID."""
state = get_state()
state.counters.cart_id += 1
cart_num = state.counters.cart_id
save_state()
return f"gid://shopify/Cart/c{cart_num}"
def get_next_line_id() -> str:
"""Generate a new cart line ID."""
state = get_state()
state.counters.line_id += 1
line_num = state.counters.line_id
save_state()
return f"gid://shopify/CartLine/{line_num}"
# ============================================
# PRODUCT HELPERS
# ============================================
def get_next_product_id() -> str:
"""Generate a new product ID."""
state = get_state()
state.counters.product_id += 1
num = state.counters.product_id
save_state()
return f"gid://shopify/Product/{num}"
def get_next_variant_id(product_num: str, option_slug: str) -> str:
"""Generate a new variant ID."""
state = get_state()
state.counters.variant_id += 1
num = state.counters.variant_id
save_state()
return f"gid://shopify/ProductVariant/{num}-{option_slug}"
def get_product_by_id(product_id: str) -> LooseProduct | None:
"""Get a product by its ID."""
state = get_state()
return state.products.get(product_id)
def get_variant_by_id(variant_id: str) -> tuple[LooseProduct | None, ProductVariant | None]:
"""Get a variant by its ID, returning (product, variant) tuple."""
state = get_state()
for product in state.products.values():
for variant in product.variants:
if variant.id == variant_id:
return product, variant
return None, None
def adjust_variant_stock(variant_id: str, delta: int) -> ProductVariant | None:
"""Adjust a variant's quantityAvailable by delta (negative reduces stock on
order creation; positive restores stock on cancel/refund). Floors at 0 so
oversold scenarios don't produce negative inventory. Keeps currentlyNotInStock,
availableForSale, and the product's totalInventory in sync. Returns the
updated variant dict, or None if the variant was not found. Does not call
save_state — callers are expected to persist.
"""
product, variant = get_variant_by_id(variant_id)
if variant is None:
return None
if variant.quantityAvailable is None:
return variant
current = variant.quantityAvailable or 0
new_qty = max(0, current + delta)
variant.quantityAvailable = new_qty
variant.currentlyNotInStock = new_qty <= 0
variant.availableForSale = new_qty > 0
if product:
total = sum(v.quantityAvailable or 0 for v in product.variants)
product.totalInventory = total
product.availableForSale = any(v.availableForSale for v in product.variants)
return variant
def search_products(
query: str,
filters: Sequence[SearchFilter | dict[str, Any]] | None = None,
limit: int = 10,
after: str | None = None,
) -> tuple[list[LooseProduct], bool, str | None, int]:
"""
Search products by query and filters.
Returns: (products, has_next_page, end_cursor, total_count)
"""
state = get_state()
tokens = _parse_query_tokens(query)
# Filter products
matching = []
for product in state.products.values():
# Build a single haystack from all searchable fields — prose (title,
# description, tags, vendor, productType) plus variant SKUs. Every
# query token must appear somewhere in the haystack (word-AND).
prose_parts = [
product.title,
product.description,
product.vendor,
product.productType,
" ".join(product.tags),
]
sku_parts = [variant.sku for variant in product.variants if variant.sku]
# Newline boundaries prevent quoted phrases from spanning fields.
haystack = "\n".join(prose_parts + sku_parts).lower()
if not tokens or all(t in haystack for t in tokens):
matching.append(product)
# Apply filters
if filters:
for raw_filter in filters:
search_filter = _search_filter(raw_filter)
if search_filter.productType:
matching = [p for p in matching if p.productType.lower() == search_filter.productType.lower()]
if search_filter.productVendor:
matching = [p for p in matching if p.vendor.lower() == search_filter.productVendor.lower()]
if search_filter.tag:
matching = [p for p in matching if search_filter.tag.lower() in [tag.lower() for tag in p.tags]]
if search_filter.available is not None:
matching = [p for p in matching if _product_is_available(p) == search_filter.available]
if search_filter.price:
min_price = search_filter.price.min
max_price = search_filter.price.max
def price_in_range(p: Any) -> bool:
for amount in _product_prices(p):
if min_price is not None and amount < min_price:
continue
if max_price is not None and amount > max_price:
continue
return True
return False
matching = [p for p in matching if price_in_range(p)]
if search_filter.variantOption:
matching = [p for p in matching if _product_matches_variant_option(p, search_filter.variantOption)]
if search_filter.category:
matching = [
p for p in matching if _product_matches_category(p, search_filter.category, state.collections)
]
total_count = len(matching)
# Handle pagination (simple cursor = index)
start_idx = 0
if after:
with contextlib.suppress(ValueError):
start_idx = int(after) + 1
end_idx = start_idx + limit
paginated = matching[start_idx:end_idx]
has_next = end_idx < total_count
end_cursor = str(end_idx - 1) if paginated else None
return paginated, has_next, end_cursor, total_count
def _search_filter(search_filter: SearchFilter | dict[str, Any]) -> SearchFilter:
if isinstance(search_filter, SearchFilter):
return search_filter
return SearchFilter.model_validate(search_filter)
def _product_is_available(product: LooseProduct) -> bool:
if "availableForSale" in product:
return bool(product.availableForSale)
return any(variant.availableForSale for variant in product.variants)
def _product_prices(product: LooseProduct) -> list[float]:
prices = []
for variant in product.variants:
with contextlib.suppress(TypeError, ValueError):
prices.append(float(variant.price.amount))
if prices:
return prices
if product.priceRange is not None:
for value in (product.priceRange.minVariantPrice, product.priceRange.maxVariantPrice):
with contextlib.suppress(TypeError, ValueError):
prices.append(float(value.amount))
return prices
def _product_matches_variant_option(product: LooseProduct, option_filter: VariantOptionFilter) -> bool:
name = option_filter.name.strip().lower()
value = option_filter.value.strip().lower()
if not name or not value:
return False
for option in product.options:
option_name = option.name.strip().lower()
option_values = [str(v).strip().lower() for v in option.values]
if option_name == name and value in option_values:
return True
for variant in product.variants:
for selected in variant.selectedOptions:
selected_name = selected.name.strip().lower()
selected_value = selected.value.strip().lower()
if selected_name == name and selected_value == value:
return True
# Some fixtures represent single-option variants only via the variant
# title. Keep this as a fallback after structured selectedOptions.
if name == "title" and variant.title.strip().lower() == value:
return True
return False
def _category_values(product: LooseProduct) -> set[str]:
values = set()
for raw in (product.category, product.categoryId, product.productCategory):
if isinstance(raw, dict):
values.update(str(v).strip().lower() for v in raw.values() if v)
elif raw:
values.add(str(raw).strip().lower())
return values
def _product_matches_category(
product: LooseProduct,
category_filter: CategoryFilter,
collections: dict[str, LooseCollection],
) -> bool:
category_id = category_filter.id.strip().lower()
if not category_id:
return False
return _product_matches_category_field(product, category_id) or _product_matches_collection_membership(
product, category_id, collections
)
def _product_matches_category_field(product: LooseProduct, category_id: str) -> bool:
return category_id in _category_values(product)
def _product_matches_collection_membership(
product: LooseProduct,
category_id: str,
collections: dict[str, LooseCollection],
) -> bool:
product_id = product.id.strip().lower()
if not product_id:
return False
for collection_id, collection in collections.items():
collection_values = {
str(collection_id).strip().lower(),
collection.id.strip().lower(),
collection.title.strip().lower(),
(collection.handle or "").strip().lower(),
}
product_ids = collection.productIds or collection.products
normalized_product_ids = {str(pid).strip().lower() for pid in product_ids if pid}
if category_id in collection_values and product_id in normalized_product_ids:
return True
return False
def get_product_filters(products: list[LooseProduct]) -> list[dict]:
"""Generate available filters from a list of products."""
filters = []
# Collect unique values
vendors: dict[str, int] = {}
product_types: dict[str, int] = {}
tags: dict[str, int] = {}
for p in products:
vendor = p.vendor
if vendor:
vendors[vendor] = vendors.get(vendor, 0) + 1
ptype = p.productType
if ptype:
product_types[ptype] = product_types.get(ptype, 0) + 1
for tag in p.tags:
tags[tag] = tags.get(tag, 0) + 1
# Build filter objects
if vendors:
filters.append(
{
"id": "filter.v.vendor",
"label": "Vendor",
"type": "LIST",
"values": [{"id": v, "label": v, "count": c} for v, c in vendors.items()],
}
)
if product_types:
filters.append(
{
"id": "filter.p.product_type",
"label": "Product Type",
"type": "LIST",
"values": [{"id": t, "label": t, "count": c} for t, c in product_types.items()],
}
)
if tags:
filters.append(
{
"id": "filter.p.tag",
"label": "Tag",
"type": "LIST",
"values": [{"id": t, "label": t, "count": c} for t, c in tags.items()],
}
)
return filters
# ============================================
# CART HELPERS
# ============================================
def get_cart_by_id(cart_id: str) -> LooseCart | None:
"""Get a cart by its ID."""
state = get_state()
return state.carts.get(cart_id)
def get_all_carts() -> list[LooseCart]:
"""Get all carts."""
state = get_state()
return list(state.carts.values())
def get_gift_card_by_code(code: str) -> LooseGiftCard | None:
"""Get a gift card by code."""
state = get_state()
code_lower = code.lower()
for gift_card in state.gift_cards.values():
if gift_card.code.lower() == code_lower:
return gift_card
return None
def create_cart(buyer_identity: dict | None = None, note: str | None = None) -> LooseCart:
"""Create a new empty cart."""
from datetime import UTC, datetime
state = get_state()
cart_num = state.counters.cart_id + 1
cart_id = f"gid://shopify/Cart/c{cart_num}"
now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
zero_money = {"amount": "0.00", "currencyCode": "USD"}
cart = {
"id": cart_id,
"checkoutUrl": f"https://shop.example.com/checkout/{cart_id}",
"createdAt": now,
"updatedAt": now,
"lines": [],
"cost": {
"subtotalAmount": zero_money.copy(),
"subtotalAmountEstimated": False,
"totalAmount": zero_money.copy(),
"totalAmountEstimated": False,
"totalTaxAmount": None,
"totalTaxAmountEstimated": False,
"checkoutChargeAmount": zero_money.copy(),
},
"buyerIdentity": buyer_identity or {},
"attributes": [],
"discountCodes": [],
"discountAllocations": [],
"appliedGiftCards": [],
"deliveryGroups": [],
"note": note,
"totalQuantity": 0,
}
cart_model = LooseCart.model_validate(cart)
state.counters.cart_id = cart_num
state.carts[cart_id] = cart_model
save_state()
return cart_model
def _cart_money(amount: float | str, currency: str = "USD") -> MoneyV2:
return MoneyV2(amount=f"{float(amount):.2f}", currencyCode=currency)
def _cart_cost(total: float, currency: str = "USD") -> CartCost:
money = _cart_money(total, currency)
return CartCost(
subtotalAmount=money,
subtotalAmountEstimated=False,
totalAmount=money.model_copy(),
totalAmountEstimated=False,
totalTaxAmount=None,
totalTaxAmountEstimated=False,
checkoutChargeAmount=money.model_copy(),
)
def update_cart_totals(cart: LooseCart) -> None:
"""Recalculate cart totals based on line items."""
total = 0.0
total_quantity = 0
currency = "USD"
for line in cart.lines:
quantity = line.quantity
total_quantity += quantity
total_amount = line.cost.totalAmount
amount = float(total_amount.amount)
total += amount
currency = total_amount.currencyCode
# Update cart cost
cart.cost = _cart_cost(total, currency)
cart.totalQuantity = total_quantity
def add_line_to_cart(cart: LooseCart, merchandise_id: str, quantity: int) -> CartLine | None:
"""Add a line item to a cart."""
from datetime import UTC, datetime
# Find the variant
product, variant = get_variant_by_id(merchandise_id)
if not variant:
return None
# Check if already in cart
for line in cart.lines:
if line.merchandise.id == merchandise_id:
# Update quantity
line.quantity += quantity
# Update cost
price = float(variant.price.amount)
currency = variant.price.currencyCode
total = _cart_money(price * line.quantity, currency)
line.cost.totalAmount = total
line.cost.subtotalAmount = total.model_copy()
update_cart_totals(cart)
cart.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
save_state()
return line
# Create new line
line_id = get_next_line_id()
price = variant.price
price_amount = float(price.amount)
currency = price.currencyCode
line = CartLine.model_validate(
{
"id": line_id,
"quantity": quantity,
"merchandise": {
"id": variant.id,
"title": variant.title,
"product": {
"id": product.id if product else "",
"title": product.title if product else "",
"handle": product.handle if product else "",
},
"image": variant.image,
"selectedOptions": variant.selectedOptions,
"price": price,
},
"cost": {
"amountPerQuantity": price.model_dump(mode="json"),
"compareAtAmountPerQuantity": variant.compareAtPrice,
"subtotalAmount": {"amount": f"{price_amount * quantity:.2f}", "currencyCode": currency},
"totalAmount": {"amount": f"{price_amount * quantity:.2f}", "currencyCode": currency},
},
"attributes": [],
"discountAllocations": [],
}
)
cart.lines.append(line)
update_cart_totals(cart)
cart.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
save_state()
return line
def update_line_in_cart(cart: LooseCart, line_id: str, quantity: int) -> bool:
"""Update a line item quantity in a cart. Returns True if found."""
from datetime import UTC, datetime
for i, line in enumerate(cart.lines):
if line.id == line_id:
if quantity <= 0:
# Remove the line
cart.lines.pop(i)
else:
# Update quantity
line.quantity = quantity
# Recalculate line cost
price = line.cost.amountPerQuantity
price_amount = float(price.amount)
currency = price.currencyCode
total = _cart_money(price_amount * quantity, currency)
line.cost.subtotalAmount = total
line.cost.totalAmount = total.model_copy()
update_cart_totals(cart)
cart.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
save_state()
return True
return False
def remove_lines_from_cart(cart: LooseCart, line_ids: list[str]) -> int:
"""Remove line items from a cart. Returns count of removed lines."""
from datetime import UTC, datetime
original_count = len(cart.lines)
cart.lines = [line for line in cart.lines if line.id not in line_ids]
removed = original_count - len(cart.lines)
if removed > 0:
update_cart_totals(cart)
cart.updatedAt = datetime.now(UTC).isoformat().replace("+00:00", "Z")
save_state()
return removed
# ============================================
# ORDER HELPERS
# ============================================
def get_next_order_id() -> str:
"""Generate a new order ID."""
state = get_state()
state.counters.order_id += 1
order_num = state.counters.order_id
save_state()
return f"gid://shopify/Order/o{order_num}"
def get_next_line_item_id() -> str:
"""Generate a new order line item ID."""
state = get_state()
state.counters.line_item_id += 1
item_num = state.counters.line_item_id
save_state()
return f"gid://shopify/OrderLineItem/{item_num}"
def get_order_by_id(order_id: str) -> LooseOrder | None:
"""Get an order by its ID."""
state = get_state()
return state.orders.get(order_id)
def get_all_orders() -> list[LooseOrder]:
"""Get all orders."""
state = get_state()
return list(state.orders.values())
# ============================================
# CUSTOMER HELPERS
# ============================================
def get_next_customer_id() -> str:
"""Generate a new customer ID."""
state = get_state()
state.counters.customer_id += 1
cust_num = state.counters.customer_id
save_state()
return f"gid://shopify/Customer/{cust_num}"
def get_customer_by_id(customer_id: str) -> LooseCustomer | None:
"""Get a customer by their ID."""
state = get_state()
return state.customers.get(customer_id)
def get_customer_by_email(email: str) -> LooseCustomer | None:
"""Get a customer by email address (case-insensitive)."""
state = get_state()
email_lower = email.lower()
for customer in state.customers.values():
if customer.email.lower() == email_lower:
return customer
return None
def get_all_customers() -> list[LooseCustomer]:
"""Get all customers."""
state = get_state()
return list(state.customers.values())
# ============================================
# COLLECTION HELPERS
# ============================================
def get_next_collection_id() -> str:
"""Generate a new collection ID."""
state = get_state()
state.counters.collection_id += 1
coll_num = state.counters.collection_id
save_state()
return f"gid://shopify/Collection/{coll_num}"
def get_collection_by_id(collection_id: str) -> LooseCollection | None:
"""Get a collection by its ID."""
state = get_state()
return state.collections.get(collection_id)
def get_all_collections() -> list[LooseCollection]:
"""Get all collections."""
state = get_state()
return list(state.collections.values())
# ============================================
# REVIEW HELPERS
# ============================================
def get_next_review_id() -> str:
"""Generate a new review ID."""
state = get_state()
state.counters.review_id += 1
review_num = state.counters.review_id
save_state()
return f"gid://shopify/Review/{review_num}"
def get_review_by_id(review_id: str) -> LooseReview | None:
"""Get a review by its ID."""
state = get_state()
return state.reviews.get(review_id)
def get_reviews_for_product(product_id: str) -> list[LooseReview]:
"""Get all reviews for a product."""
state = get_state()
return [r for r in state.reviews.values() if r.productId == product_id]
# ============================================
# RETURN HELPERS
# ============================================
def get_next_return_id() -> str:
"""Generate a new return ID."""
state = get_state()
state.counters.return_id += 1
return_num = state.counters.return_id
save_state()
return f"gid://shopify/Return/{return_num}"
def get_return_by_id(return_id: str) -> LooseReturn | None:
"""Get a return by its ID."""
state = get_state()
return state.returns.get(return_id)
def get_all_returns() -> list[LooseReturn]:
"""Get all returns."""
state = get_state()
return list(state.returns.values())
# ============================================
# SHIPPING METHOD HELPERS
# ============================================
def get_shipping_method_by_id(method_id: str) -> LooseShippingMethod | None:
"""Get a shipping method by its ID."""
state = get_state()
return state.shipping_methods.get(method_id)
def get_all_shipping_methods() -> list[LooseShippingMethod]:
"""Get all shipping methods."""
state = get_state()
return list(state.shipping_methods.values())
# ============================================
# DISCOUNT CODE HELPERS
# ============================================
def get_next_discount_id() -> str:
"""Generate a new discount code ID."""
state = get_state()
state.counters.discount_id += 1
num = state.counters.discount_id
save_state()
return f"gid://shopify/DiscountCode/{num}"
def get_discount_by_code(code: str) -> LooseDiscountCode | None:
"""Get a discount code by its code string (case-insensitive)."""
state = get_state()
code_upper = code.upper()
for dc in state.discount_codes.values():
if dc.code.upper() == code_upper:
return dc
return None
def get_all_discount_codes() -> list[LooseDiscountCode]:
"""Get all discount codes."""
state = get_state()
return list(state.discount_codes.values())
# ============================================
# INVENTORY HELPERS
# ============================================
def get_all_variants_with_inventory() -> list[dict]:
"""Get all product variants with their inventory info and product context."""
state = get_state()
result = []
for product in state.products.values():
for variant in product.variants:
result.append(
{
"variantId": variant.id,
"variantTitle": variant.title,
"productId": product.id,
"productTitle": product.title,
"sku": variant.sku,
"quantityAvailable": variant.quantityAvailable,
"currentlyNotInStock": variant.currentlyNotInStock,
"price": variant.price,
}
)
return result
# ============================================
# POLICY HELPERS
# ============================================
def get_next_policy_id() -> str:
"""Generate a new policy ID."""
state = get_state()
state.counters.policy_id += 1
num = state.counters.policy_id
save_state()
return f"gid://shopify/ShopPolicy/{num}"
def get_policy_by_id(policy_id: str) -> LoosePolicy | None:
"""Get a policy by its ID."""
state = get_state()
for p in state.policies:
if p.id == policy_id:
return p
return None
def search_policies(query: str) -> list[dict]:
"""Search policies by query (word-AND; quoted segments match as phrases)."""
state = get_state()
tokens = _parse_query_tokens(query)
if not tokens:
return []
matching = []
for policy in state.policies:
haystack = f"{policy.title}\n{policy.body}".lower()
if all(t in haystack for t in tokens):
matching.append(policy)
return matching
def search_faqs(query: str) -> list[dict]:
"""Search FAQs by query (word-AND; quoted segments match as phrases)."""
state = get_state()
tokens = _parse_query_tokens(query)
if not tokens:
return []
matching = []
for faq in state.faqs:
haystack = f"{faq.question}\n{faq.answer}".lower()
if all(t in haystack for t in tokens):
matching.append(faq)
return matching