Files
mcphub/plugins/wordpress/handlers/products.py
airano-ir f203ca88de
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X.

WordPress media pipeline
- Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries.

Capability discovery (F.7e)
- Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error.

Companion plugin overhaul
- Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge.
- Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach.
- wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL.

Other
- Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork.
- Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8).
- n8n refactor: structured errors, capability probe, missing tools backfilled.
- Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites.

Docs
- README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it.
- Tools/Tests badges removed in favour of "Plugins: 10".

Deployment
- PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant.

CI
- Black + Ruff clean on Python 3.12; pytest tests/ green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:25:58 +02:00

1448 lines
58 KiB
Python

"""Products Handler - manages WooCommerce products and related entities"""
import asyncio
import json
import re
from typing import Any
from plugins.wordpress.client import WordPressClient
def _count_words(html_content: str) -> int:
"""Strip HTML tags and count words."""
text = re.sub(r"<[^>]+>", " ", html_content)
text = re.sub(r"\s+", " ", text).strip()
return len(text.split()) if text else 0
def _strip_html(html_content: str, max_chars: int = 500) -> str:
"""Strip HTML tags and return first max_chars characters."""
text = re.sub(r"<[^>]+>", " ", html_content)
text = re.sub(r"\s+", " ", text).strip()
if len(text) > max_chars:
return text[:max_chars] + "..."
return text
def normalize_id_list(value: Any, field_name: str = "item") -> list[dict[str, Any]]:
"""
Convert various formats of category/tag IDs to WooCommerce API format.
Phase K.2.1: Support multiple input formats for better UX
Phase K.2.2: Also support name-based format for tags
Supported formats:
- 62 → [{"id": 62}]
- "62" → [{"id": 62}]
- [62, 63] → [{"id": 62}, {"id": 63}]
- "62,63" → [{"id": 62}, {"id": 63}]
- [{"id": 62}] → [{"id": 62}] (no change)
- [{"name": "Tag Name"}] → [{"name": "Tag Name"}] (for tags - WooCommerce creates if not exists)
- "Tag1, Tag2" (non-numeric) → [{"name": "Tag1"}, {"name": "Tag2"}]
Args:
value: Input value in any supported format
field_name: Name of field for error messages
Returns:
List of dicts in WooCommerce format: [{"id": int}, ...] or [{"name": str}, ...]
"""
if value is None:
return []
result = []
# Case 1: Already in correct format [{"id": x}] or [{"name": x}]
if isinstance(value, list):
for item in value:
if isinstance(item, dict):
if "id" in item:
# ID format - convert to int
result.append({"id": int(item["id"])})
elif "name" in item:
# Name format - keep as string (WooCommerce will create/find tag)
result.append({"name": str(item["name"])})
elif isinstance(item, (int, float)):
# List of integers
result.append({"id": int(item)})
elif isinstance(item, str):
item_stripped = item.strip()
if item_stripped.isdigit():
# List of string integers
result.append({"id": int(item_stripped)})
elif item_stripped:
# List of string names (for tags)
result.append({"name": item_stripped})
return result
# Case 2: Single integer
if isinstance(value, (int, float)):
return [{"id": int(value)}]
# Case 3: String (could be "62" or "62,63" or "Tag1, Tag2")
if isinstance(value, str):
value = value.strip()
if not value:
return []
# Handle comma-separated
if "," in value:
for part in value.split(","):
part = part.strip()
if part.isdigit():
result.append({"id": int(part)})
elif part:
# Non-numeric - treat as name
result.append({"name": part})
return result
# Single value
if value.isdigit():
return [{"id": int(value)}]
else:
# Non-numeric single value - treat as name
return [{"name": value}]
return result
def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specifications for ToolGenerator"""
return [
# === PRODUCTS ===
{
"name": "list_products",
"method_name": "list_products",
"description": "List WooCommerce products. Returns paginated list with pricing, stock status, and categories. Supports filtering by category, stock status, and search.",
"schema": {
"type": "object",
"properties": {
"per_page": {
"type": "integer",
"description": "Number of products per page (1-100)",
"default": 10,
"minimum": 1,
"maximum": 100,
},
"page": {
"type": "integer",
"description": "Page number",
"default": 1,
"minimum": 1,
},
"status": {
"type": "string",
"description": "Filter by product status. Use 'publish' to see only live products with prices. Default 'any' includes drafts.",
"enum": ["draft", "pending", "private", "publish", "any"],
"default": "any",
},
"category": {
"anyOf": [{"type": "integer"}, {"type": "null"}],
"description": "Filter by category ID",
},
"stock_status": {
"anyOf": [
{"type": "string", "enum": ["instock", "outofstock", "onbackorder"]},
{"type": "null"},
],
"description": "Filter by stock status (optional)",
},
"search": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Search term to filter products",
},
"search_terms": {
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
"description": "Multiple search terms to search in parallel. Results are deduplicated. Overrides 'search' if both provided.",
},
"include_content": {
"type": "boolean",
"description": "Include description summary (first 500 chars) and word count in results. Default false to save tokens.",
"default": False,
},
},
},
"scope": "read",
},
{
"name": "get_product",
"method_name": "get_product",
"description": "Get detailed information about a specific WooCommerce product by ID. Returns complete product data including pricing, inventory, images, categories, tags, and attributes.",
"schema": {
"type": "object",
"properties": {
"product_id": {
"type": "integer",
"description": "Product ID to retrieve",
"minimum": 1,
},
"fields": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Comma-separated list of fields to return (e.g., 'id,name,price,status'). Returns all fields if not specified. Use to reduce response size and token usage.",
},
},
"required": ["product_id"],
},
"scope": "read",
},
{
"name": "create_product",
"method_name": "create_product",
"description": "Create a new WooCommerce product. Supports simple and variable products with pricing, inventory, categories, tags, and descriptions.",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Product name/title", "minLength": 1},
"type": {
"type": "string",
"description": "Product type",
"enum": ["simple", "grouped", "external", "variable"],
"default": "simple",
},
"regular_price": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Product regular price (e.g., '19.99')",
},
"sale_price": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Product sale price (e.g., '14.99')",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Product full description (HTML allowed)",
},
"short_description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Product short description/summary",
},
"status": {
"type": "string",
"description": "Product status",
"enum": ["draft", "pending", "private", "publish"],
"default": "draft",
},
"categories": {
"anyOf": [
{"type": "array", "items": {"type": "integer"}},
{"type": "null"},
],
"description": "Category IDs to assign to product",
},
"tags": {
"anyOf": [
{"type": "array", "items": {"type": "integer"}},
{"type": "null"},
],
"description": "Tag IDs to assign to product",
},
"stock_quantity": {
"anyOf": [{"type": "integer"}, {"type": "null"}],
"description": "Stock quantity (requires manage_stock to be true)",
},
"manage_stock": {
"type": "boolean",
"description": "Enable stock management",
"default": False,
},
"stock_status": {
"type": "string",
"description": "Stock status",
"enum": ["instock", "outofstock", "onbackorder"],
"default": "instock",
},
"attributes": {
"anyOf": [
{
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"minimum": 1,
"description": (
"Optional. Pass the global attribute "
"id from list_product_attributes to "
"link this product to an existing "
"global attribute (e.g. pa_color). "
"Omit (or set 0) to create an inline "
"custom attribute on this product only."
),
},
"name": {"type": "string"},
"options": {"type": "array", "items": {"type": "string"}},
"visible": {"type": "boolean"},
"variation": {"type": "boolean"},
},
},
},
{"type": "null"},
],
"description": (
"Product attributes for variable products. "
'Custom (per-product): [{"name": "Color", "options": ["Red", "Blue"], "variation": true}]. '
'Global (linked, recommended): [{"id": 1, "options": ["Red", "Blue"], "variation": true}] — '
"use list_product_attributes to find existing ids."
),
},
},
"required": ["name"],
},
"scope": "write",
},
{
"name": "update_product",
"method_name": "update_product",
"description": "Update an existing WooCommerce product. Can update any field including name, slug (permalink), pricing, inventory, status, categories, tags, and more.",
"schema": {
"type": "object",
"properties": {
"product_id": {
"type": "integer",
"description": "Product ID to update",
"minimum": 1,
},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Product name/title",
},
"slug": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Product slug (SEO-friendly URL path, e.g., 'my-product')",
},
"regular_price": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Product regular price",
},
"sale_price": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Product sale price",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Product full description",
},
"short_description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Product short description",
},
"status": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Product status",
"enum": ["draft", "pending", "private", "publish"],
},
"categories": {
"anyOf": [
{"type": "array", "items": {"type": "integer"}},
{"type": "null"},
],
"description": "Category IDs to assign to product",
},
"tags": {
"anyOf": [
{"type": "array", "items": {"type": "integer"}},
{"type": "null"},
],
"description": "Tag IDs to assign to product",
},
"stock_quantity": {
"anyOf": [{"type": "integer"}, {"type": "null"}],
"description": "Stock quantity",
},
"stock_status": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Stock status",
"enum": ["instock", "outofstock", "onbackorder"],
},
"featured": {
"anyOf": [{"type": "boolean"}, {"type": "null"}],
"description": "Featured product flag",
},
"weight": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Product weight",
},
},
"required": ["product_id"],
},
"scope": "write",
},
{
"name": "delete_product",
"method_name": "delete_product",
"description": "Delete or trash a WooCommerce product. Can permanently delete or move to trash for later restoration.",
"schema": {
"type": "object",
"properties": {
"product_id": {
"type": "integer",
"description": "Product ID to delete",
"minimum": 1,
},
"force": {
"type": "boolean",
"description": "Permanently delete (true) or move to trash (false)",
"default": False,
},
},
"required": ["product_id"],
},
"scope": "write",
},
# === PRODUCT CATEGORIES ===
{
"name": "list_product_categories",
"method_name": "list_product_categories",
"description": "List WooCommerce product categories. Returns hierarchical category structure with product counts and parent relationships.",
"schema": {
"type": "object",
"properties": {
"per_page": {
"type": "integer",
"description": "Number of categories per page (1-100)",
"default": 10,
"minimum": 1,
"maximum": 100,
},
"page": {
"type": "integer",
"description": "Page number",
"default": 1,
"minimum": 1,
},
"hide_empty": {
"type": "boolean",
"description": "Hide categories with no products",
"default": False,
},
},
},
"scope": "read",
},
{
"name": "create_product_category",
"method_name": "create_product_category",
"description": "Create a new WooCommerce product category. Supports hierarchical categories with parent-child relationships.",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Category name", "minLength": 1},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Category description",
},
"parent": {
"anyOf": [{"type": "integer"}, {"type": "null"}],
"description": "Parent category ID for hierarchical structure",
},
},
"required": ["name"],
},
"scope": "write",
},
# === PRODUCT TAGS ===
{
"name": "list_product_tags",
"method_name": "list_product_tags",
"description": "List WooCommerce product tags. Returns all product tags with usage counts.",
"schema": {
"type": "object",
"properties": {
"per_page": {
"type": "integer",
"description": "Number of tags per page (1-100)",
"default": 10,
"minimum": 1,
"maximum": 100,
},
"page": {
"type": "integer",
"description": "Page number",
"default": 1,
"minimum": 1,
},
"hide_empty": {
"type": "boolean",
"description": "Hide tags with no products",
"default": False,
},
},
},
"scope": "read",
},
# === PRODUCT ATTRIBUTES ===
{
"name": "list_product_attributes",
"method_name": "list_product_attributes",
"description": "List all global WooCommerce product attributes. Attributes are used for product variations (e.g., Size, Color).",
"schema": {"type": "object", "properties": {}},
"scope": "read",
},
{
"name": "create_product_attribute",
"method_name": "create_product_attribute",
"description": "Create a new global product attribute for use in variable products. Attributes define variation options like Size or Color.",
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Attribute name (e.g., 'Size', 'Color')",
"minLength": 1,
},
"slug": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Attribute slug (auto-generated if not provided)",
},
"type": {
"type": "string",
"description": "Attribute type",
"enum": ["select", "text"],
"default": "select",
},
"order_by": {
"type": "string",
"description": "Default sort order",
"enum": ["menu_order", "name", "name_num", "id"],
"default": "menu_order",
},
"has_archives": {
"type": "boolean",
"description": "Enable archives for this attribute",
"default": False,
},
},
"required": ["name"],
},
"scope": "write",
},
# === PRODUCT VARIATIONS ===
{
"name": "list_product_variations",
"method_name": "list_product_variations",
"description": "List all variations of a variable product. Returns pricing, stock, and attribute combinations for each variation.",
"schema": {
"type": "object",
"properties": {
"product_id": {
"type": "integer",
"description": "Variable product ID",
"minimum": 1,
},
"per_page": {
"type": "integer",
"description": "Number of variations per page (1-100)",
"default": 10,
"minimum": 1,
"maximum": 100,
},
"page": {
"type": "integer",
"description": "Page number",
"default": 1,
"minimum": 1,
},
},
"required": ["product_id"],
},
"scope": "read",
},
{
"name": "create_product_variation",
"method_name": "create_product_variation",
"description": "Create a new variation for a variable product. Defines a specific combination of attributes with its own pricing and inventory.",
"schema": {
"type": "object",
"properties": {
"product_id": {
"type": "integer",
"description": "Parent variable product ID",
"minimum": 1,
},
"attributes": {
"type": "array",
"description": 'Attribute combinations for this variation (e.g., [{"name": "Size", "option": "Large"}])',
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"option": {"type": "string"},
},
},
},
"regular_price": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Variation regular price",
},
"sale_price": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Variation sale price",
},
"stock_quantity": {
"anyOf": [{"type": "integer"}, {"type": "null"}],
"description": "Stock quantity for this variation",
},
"stock_status": {
"type": "string",
"description": "Stock status",
"enum": ["instock", "outofstock", "onbackorder"],
"default": "instock",
},
"manage_stock": {
"type": "boolean",
"description": "Enable stock management for this variation",
"default": False,
},
"sku": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Stock Keeping Unit (SKU) for this variation",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Variation description",
},
"image": {
"anyOf": [{"type": "object"}, {"type": "null"}],
"description": 'Variation image (e.g., {"id": 123})',
},
},
"required": ["product_id", "attributes"],
},
"scope": "write",
},
]
class ProductsHandler:
"""Handle WooCommerce product-related operations"""
def __init__(self, client: WordPressClient):
"""
Initialize products handler.
Args:
client: WordPress API client instance
"""
self.client = client
# === PRODUCTS ===
async def list_products(
self,
per_page: int = 10,
page: int = 1,
status: str = "any",
category: int | None = None,
stock_status: str | None = None,
search: str | None = None,
search_terms: list[str] | None = None,
include_content: bool = False,
) -> str:
"""
List WooCommerce products.
Args:
per_page: Number of products per page (1-100)
page: Page number
status: Product status filter
category: Filter by category ID
stock_status: Filter by stock status (instock, outofstock, onbackorder)
search: Search term to filter products
search_terms: Multiple search terms for parallel search with deduplication
include_content: Include description summary and word count in results
Returns:
JSON string with products list
"""
try:
# Build query parameters
params = {"per_page": per_page, "page": page, "status": status}
# Add optional filters
if category is not None:
params["category"] = category
if stock_status:
params["stock_status"] = stock_status
# Multi-search: parallel API calls with deduplication
if search_terms and len(search_terms) > 0:
async def _search_single(term: str) -> list:
p = {**params, "search": term}
return await self.client.get("products", params=p, use_woocommerce=True)
batches = await asyncio.gather(
*[_search_single(term) for term in search_terms], return_exceptions=True
)
seen_ids: set = set()
products: list = []
for batch in batches:
if isinstance(batch, Exception):
continue
for product in batch:
if product["id"] not in seen_ids:
seen_ids.add(product["id"])
products.append(product)
else:
if search:
params["search"] = search
products = await self.client.get("products", params=params, use_woocommerce=True)
# Format response
def _format_product(p: dict) -> dict:
item = {
"id": p["id"],
"name": p["name"],
"slug": p["slug"],
"type": p["type"],
"status": p["status"],
"price": p["price"],
"regular_price": p["regular_price"],
"sale_price": p.get("sale_price", ""),
"stock_status": p["stock_status"],
"stock_quantity": p.get("stock_quantity"),
"categories": [
{"id": cat["id"], "name": cat["name"]} for cat in p.get("categories", [])
],
"images": [
{"id": img["id"], "src": img["src"], "alt": img.get("alt", "")}
for img in p.get("images", [])[:1] # Just first image
],
"permalink": p["permalink"],
}
if include_content:
desc_html = p.get("description", "")
item["content_summary"] = _strip_html(desc_html, 500)
item["word_count"] = _count_words(desc_html)
return item
result = {
"total": len(products),
"page": page,
"per_page": per_page,
"products": [_format_product(p) for p in products],
}
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps(
{"error": str(e), "message": f"Failed to list products: {str(e)}"}, indent=2
)
async def get_product(self, product_id: int, fields: str | None = None) -> str:
"""
Get detailed information about a specific product.
Args:
product_id: Product ID to retrieve
fields: Comma-separated list of fields to return (e.g., 'id,name,price,status')
Returns:
JSON string with product data
"""
try:
product = await self.client.get(f"products/{product_id}", use_woocommerce=True)
full_result = {
"id": product["id"],
"name": product["name"],
"slug": product["slug"],
"type": product["type"],
"status": product["status"],
"description": product.get("description", ""),
"short_description": product.get("short_description", ""),
"price": product["price"],
"regular_price": product["regular_price"],
"sale_price": product.get("sale_price", ""),
"stock_status": product["stock_status"],
"stock_quantity": product.get("stock_quantity"),
"manage_stock": product.get("manage_stock", False),
"categories": [
{"id": cat["id"], "name": cat["name"], "slug": cat["slug"]}
for cat in product.get("categories", [])
],
"tags": [
{"id": tag["id"], "name": tag["name"], "slug": tag["slug"]}
for tag in product.get("tags", [])
],
"images": [
{"id": img["id"], "src": img["src"], "alt": img.get("alt", "")}
for img in product.get("images", [])
],
"permalink": product["permalink"],
# Phase K.2.3: Include attributes for variable products
"attributes": [
{
"id": attr.get("id"),
"name": attr.get("name"),
"slug": attr.get("slug"),
"position": attr.get("position"),
"visible": attr.get("visible"),
"variation": attr.get("variation"),
"options": attr.get("options", []),
}
for attr in product.get("attributes", [])
],
"word_count": _count_words(product.get("description", "")),
}
# Filter to requested fields only
if fields:
requested = {f.strip().lower() for f in fields.split(",")}
requested.add("id") # Always include id
result = {k: v for k, v in full_result.items() if k in requested}
else:
result = full_result
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps(
{"error": str(e), "message": f"Failed to get product {product_id}: {str(e)}"},
indent=2,
)
async def create_product(
self,
name: str,
type: str = "simple",
regular_price: str | None = None,
sale_price: str | None = None,
description: str | None = None,
short_description: str | None = None,
status: str = "draft",
categories: list[int] | None = None,
tags: list[int] | None = None,
stock_quantity: int | None = None,
manage_stock: bool = False,
stock_status: str = "instock",
attributes: list[dict[str, Any]] | None = None,
) -> str:
"""
Create a new WooCommerce product.
Args:
name: Product name/title
type: Product type (simple, grouped, external, variable)
regular_price: Product regular price
sale_price: Product sale price
description: Product full description
short_description: Product short description
status: Product status
categories: Category IDs to assign
tags: Tag IDs to assign
stock_quantity: Stock quantity
manage_stock: Enable stock management
stock_status: Stock status
attributes: Product attributes for variable products
Returns:
JSON string with created product data
"""
try:
# Build product data
data = {"name": name, "type": type, "status": status}
# Phase K.2.2: Variable products have different stock handling
# Stock comes from variations, not the parent product
if type != "variable":
data["stock_status"] = stock_status
data["manage_stock"] = manage_stock
if regular_price:
data["regular_price"] = regular_price
if sale_price:
data["sale_price"] = sale_price
if stock_quantity is not None and manage_stock:
data["stock_quantity"] = stock_quantity
if description:
data["description"] = description
if short_description:
data["short_description"] = short_description
# Phase K.2.1: Use normalize_id_list for flexible format support
if categories:
normalized_cats = normalize_id_list(categories, "categories")
if normalized_cats:
data["categories"] = normalized_cats
if tags:
normalized_tags = normalize_id_list(tags, "tags")
if normalized_tags:
data["tags"] = normalized_tags
# Phase K.2.1/K.2.2/K.2.3: Add attributes for variable products
# WooCommerce requires proper attribute format:
# - For global attributes: use "id" (integer) - this is preferred
# - For custom attributes: use "name" (string)
# - Never use both "id" and "name" together
# - "options" must be an array of strings
# - For variable products: "variation" must be true
if attributes:
processed_attrs = []
for attr in attributes:
if isinstance(attr, dict):
attr_clean = {}
# Use id for global attributes, name for custom
if "id" in attr:
attr_clean["id"] = int(attr["id"])
elif "name" in attr:
attr_clean["name"] = str(attr["name"])
else:
continue # Skip invalid attributes
# Ensure options is a list of strings
if "options" in attr:
opts = attr["options"]
if isinstance(opts, list):
attr_clean["options"] = [str(o) for o in opts]
elif isinstance(opts, str):
attr_clean["options"] = [opts]
# For variable products, always set variation=true
if type == "variable":
attr_clean["variation"] = True
attr_clean["visible"] = True
else:
if "variation" in attr:
attr_clean["variation"] = bool(attr["variation"])
if "visible" in attr:
attr_clean["visible"] = bool(attr["visible"])
processed_attrs.append(attr_clean)
else:
# String attribute name - create as custom attribute
processed_attrs.append(
{
"name": str(attr),
"variation": type == "variable",
"visible": True,
}
)
if processed_attrs:
data["attributes"] = processed_attrs
product = await self.client.post("products", json_data=data, use_woocommerce=True)
# Phase K.2.4: Two-step approach for variable products
# WooCommerce sometimes converts variable to simple on creation
# If this happens, immediately update to set type=variable
if type == "variable" and product["type"] != "variable":
# Try to update the product type to variable
update_data = {"type": "variable"}
try:
product = await self.client.put(
f"products/{product['id']}", json_data=update_data, use_woocommerce=True
)
except Exception:
pass # If update fails, continue with original product
result = {
"id": product["id"],
"name": product["name"],
"type": product["type"],
"status": product["status"],
"price": product.get("price", ""),
"permalink": product["permalink"],
"message": f"Product '{name}' created successfully with ID {product['id']}",
}
# Phase K.2.2: Include attributes in response for variable products
if type == "variable":
result["attributes"] = product.get("attributes", [])
# Warn if type still couldn't be set to variable
if product["type"] != "variable":
result["warning"] = (
f"Product was created as '{product['type']}' instead of 'variable'. "
"This may happen if attributes are missing or have invalid format. "
"Ensure attributes have 'id' (global attribute ID) and 'options' array."
)
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps(
{"error": str(e), "message": f"Failed to create product: {str(e)}"}, indent=2
)
async def update_product(
self,
product_id: int,
name: str | None = None,
slug: str | None = None,
regular_price: str | None = None,
sale_price: str | None = None,
description: str | None = None,
short_description: str | None = None,
status: str | None = None,
categories: list[int] | None = None,
tags: list[int] | None = None,
stock_quantity: int | None = None,
stock_status: str | None = None,
featured: bool | None = None,
weight: str | None = None,
) -> str:
"""
Update an existing WooCommerce product.
Args:
product_id: Product ID to update
name: Product name
slug: Product slug (SEO-friendly URL path)
regular_price: Product regular price
sale_price: Product sale price
description: Product description
short_description: Product short description
status: Product status
categories: Category IDs to assign
tags: Tag IDs to assign
stock_quantity: Stock quantity
stock_status: Stock status
featured: Featured product flag
weight: Product weight
Returns:
JSON string with updated product data
"""
try:
# Build data dict with only provided values
data = {}
if name is not None:
data["name"] = name
if slug is not None:
data["slug"] = slug
if regular_price is not None:
data["regular_price"] = regular_price
if sale_price is not None:
data["sale_price"] = sale_price
if description is not None:
data["description"] = description
if short_description is not None:
data["short_description"] = short_description
if status is not None:
data["status"] = status
# Phase K.2.1: Use normalize_id_list for flexible format support
if categories is not None:
normalized_cats = normalize_id_list(categories, "categories")
if normalized_cats:
data["categories"] = normalized_cats
if tags is not None:
normalized_tags = normalize_id_list(tags, "tags")
if normalized_tags:
data["tags"] = normalized_tags
if stock_quantity is not None:
data["stock_quantity"] = stock_quantity
if stock_status is not None:
data["stock_status"] = stock_status
if featured is not None:
data["featured"] = featured
if weight is not None:
data["weight"] = weight
product = await self.client.put(
f"products/{product_id}", json_data=data, use_woocommerce=True
)
result = {
"id": product["id"],
"name": product["name"],
"slug": product.get("slug", ""),
"status": product["status"],
"price": product.get("price", ""),
"permalink": product.get("permalink", ""),
"message": f"Product {product_id} updated successfully",
}
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps(
{"error": str(e), "message": f"Failed to update product {product_id}: {str(e)}"},
indent=2,
)
async def delete_product(self, product_id: int, force: bool = False) -> str:
"""
Delete or trash a WooCommerce product.
Args:
product_id: Product ID to delete
force: Permanently delete (True) or move to trash (False)
Returns:
JSON string with deletion result
"""
try:
params = {"force": "true" if force else "false"}
result = await self.client.delete(
f"products/{product_id}", params=params, use_woocommerce=True
)
message = f"Product {product_id} {'permanently deleted' if force else 'moved to trash'}"
return json.dumps({"success": True, "message": message, "result": result}, indent=2)
except Exception as e:
return json.dumps(
{"error": str(e), "message": f"Failed to delete product {product_id}: {str(e)}"},
indent=2,
)
# === PRODUCT CATEGORIES ===
async def list_product_categories(
self, per_page: int = 10, page: int = 1, hide_empty: bool = False
) -> str:
"""
List WooCommerce product categories.
Args:
per_page: Number of categories per page (1-100)
page: Page number
hide_empty: Hide categories with no products
Returns:
JSON string with categories list
"""
try:
params = {
"per_page": per_page,
"page": page,
"hide_empty": "true" if hide_empty else "false",
}
categories = await self.client.get(
"products/categories", params=params, use_woocommerce=True
)
result = {
"total": len(categories),
"page": page,
"categories": [
{
"id": cat["id"],
"name": cat["name"],
"slug": cat["slug"],
"description": cat.get("description", ""),
"count": cat.get("count", 0),
"parent": cat.get("parent", 0),
}
for cat in categories
],
}
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps(
{"error": str(e), "message": f"Failed to list product categories: {str(e)}"},
indent=2,
)
async def create_product_category(
self, name: str, description: str | None = None, parent: int | None = None
) -> str:
"""
Create a new WooCommerce product category.
Args:
name: Category name
description: Category description
parent: Parent category ID for hierarchical structure
Returns:
JSON string with created category data
"""
try:
data = {"name": name}
if description:
data["description"] = description
if parent:
data["parent"] = parent
category = await self.client.post(
"products/categories", json_data=data, use_woocommerce=True
)
result = {
"id": category["id"],
"name": category["name"],
"slug": category["slug"],
"message": f"Product category '{name}' created successfully with ID {category['id']}",
}
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps(
{"error": str(e), "message": f"Failed to create product category: {str(e)}"},
indent=2,
)
# === PRODUCT TAGS ===
async def list_product_tags(
self, per_page: int = 10, page: int = 1, hide_empty: bool = False
) -> str:
"""
List WooCommerce product tags.
Args:
per_page: Number of tags per page (1-100)
page: Page number
hide_empty: Hide tags with no products
Returns:
JSON string with tags list
"""
try:
params = {
"per_page": per_page,
"page": page,
"hide_empty": "true" if hide_empty else "false",
}
tags = await self.client.get("products/tags", params=params, use_woocommerce=True)
result = {
"total": len(tags),
"page": page,
"tags": [
{
"id": tag["id"],
"name": tag["name"],
"slug": tag["slug"],
"description": tag.get("description", ""),
"count": tag.get("count", 0),
}
for tag in tags
],
}
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps(
{"error": str(e), "message": f"Failed to list product tags: {str(e)}"}, indent=2
)
# === PRODUCT ATTRIBUTES ===
async def list_product_attributes(self) -> str:
"""
List all global product attributes.
Returns:
JSON string with attributes list
"""
try:
attributes = await self.client.get("products/attributes", use_woocommerce=True)
result = {
"total": len(attributes),
"attributes": [
{
"id": attr["id"],
"name": attr["name"],
"slug": attr["slug"],
"type": attr.get("type", "select"),
"order_by": attr.get("order_by", "menu_order"),
"has_archives": attr.get("has_archives", False),
}
for attr in attributes
],
}
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps(
{"error": str(e), "message": f"Failed to list product attributes: {str(e)}"},
indent=2,
)
async def create_product_attribute(
self,
name: str,
slug: str | None = None,
type: str = "select",
order_by: str = "menu_order",
has_archives: bool = False,
) -> str:
"""
Create a new global product attribute.
Args:
name: Attribute name (e.g., 'Size', 'Color')
slug: Attribute slug (auto-generated if not provided)
type: Attribute type (select or text)
order_by: Default sort order
has_archives: Enable archives for this attribute
Returns:
JSON string with created attribute data
"""
try:
data = {"name": name, "type": type, "order_by": order_by, "has_archives": has_archives}
if slug:
data["slug"] = slug
attribute = await self.client.post(
"products/attributes", json_data=data, use_woocommerce=True
)
result = {
"id": attribute["id"],
"name": attribute["name"],
"slug": attribute["slug"],
"message": f"Product attribute '{name}' created successfully with ID {attribute['id']}",
}
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps(
{"error": str(e), "message": f"Failed to create product attribute: {str(e)}"},
indent=2,
)
# === PRODUCT VARIATIONS ===
async def list_product_variations(
self, product_id: int, per_page: int = 10, page: int = 1
) -> str:
"""
List variations of a variable product.
Args:
product_id: Variable product ID
per_page: Number of variations per page (1-100)
page: Page number
Returns:
JSON string with variations list
"""
try:
params = {"per_page": per_page, "page": page}
variations = await self.client.get(
f"products/{product_id}/variations", params=params, use_woocommerce=True
)
result = {
"product_id": product_id,
"total": len(variations),
"page": page,
"variations": [
{
"id": var["id"],
"sku": var.get("sku", ""),
"regular_price": var.get("regular_price", ""),
"sale_price": var.get("sale_price", ""),
"stock_status": var.get("stock_status", "instock"),
"stock_quantity": var.get("stock_quantity"),
"attributes": var.get("attributes", []),
"image": var.get("image"),
"permalink": var.get("permalink"),
}
for var in variations
],
}
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps(
{"error": str(e), "message": f"Failed to list product variations: {str(e)}"},
indent=2,
)
async def create_product_variation(
self,
product_id: int,
attributes: list[dict[str, Any]],
regular_price: str | None = None,
sale_price: str | None = None,
stock_quantity: int | None = None,
stock_status: str = "instock",
manage_stock: bool = False,
sku: str | None = None,
description: str | None = None,
image: dict[str, int] | None = None,
) -> str:
"""
Create a new variation for a variable product.
Args:
product_id: Parent variable product ID
attributes: Attribute combinations (e.g., [{"name": "Size", "option": "Large"}])
regular_price: Variation regular price
sale_price: Variation sale price
stock_quantity: Stock quantity
stock_status: Stock status
manage_stock: Enable stock management
sku: Stock Keeping Unit
description: Variation description
image: Variation image (e.g., {"id": 123})
Returns:
JSON string with created variation data
"""
try:
# Phase K.2.2: Normalize attributes for variations
# Ensure attributes have proper format for WooCommerce API
normalized_attrs = []
if attributes:
for attr in attributes:
if isinstance(attr, dict):
# Ensure both name and option are strings and trimmed
attr_item = {}
if "id" in attr:
attr_item["id"] = int(attr["id"])
if "name" in attr:
attr_item["name"] = str(attr["name"]).strip()
if "option" in attr:
attr_item["option"] = str(attr["option"]).strip()
if attr_item:
normalized_attrs.append(attr_item)
data = {
"attributes": normalized_attrs,
"stock_status": stock_status,
"manage_stock": manage_stock,
}
# Add optional fields
if regular_price:
data["regular_price"] = regular_price
if sale_price:
data["sale_price"] = sale_price
if stock_quantity is not None:
data["stock_quantity"] = stock_quantity
if sku:
data["sku"] = sku
if description:
data["description"] = description
if image:
data["image"] = image
variation = await self.client.post(
f"products/{product_id}/variations", json_data=data, use_woocommerce=True
)
result = {
"variation_id": variation["id"],
"product_id": product_id,
"attributes": variation["attributes"],
"regular_price": variation.get("regular_price"),
"sale_price": variation.get("sale_price"),
"message": f"Product variation created successfully with ID {variation['id']}",
}
# Phase K.2.2: Add warning if attributes were sent but came back empty
if normalized_attrs and not variation.get("attributes"):
result["warning"] = (
"Attributes were sent but not saved. This usually means: "
"1) The parent product doesn't have these attributes defined with 'variation: true', or "
"2) The attribute names/options don't match exactly (case-sensitive). "
"Make sure to first define attributes on the parent variable product."
)
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps(
{
"error": str(e),
"message": f"Failed to create product variation: {str(e)}",
"hint": "For variation attributes to work, ensure: 1) Parent product has type='variable', "
"2) Parent product has attributes with 'variation: true', "
"3) Attribute names and options match exactly",
},
indent=2,
)