Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ All code lives under `etsy_python/v3/`:

**`_type` field mapping**: Python's `type` is a builtin, so model attributes that map to the API's `type` field are stored as `_type`. `todict()` in `Utils.py` maps `_type` → `"type"` during serialization. The mandatory list in Request models must use `"_type"` (the attribute name), not `"type"`.

**Boolean False vs nullable**: `get_nulled()` in `Request.py` treats empty strings, empty lists, and zero as null for nullable fields — but explicitly excludes `bool` values. `False` is a valid distinct value from null and serializes as `False`, not `None`. The `todict()` function in `Utils.py` has the same guard: `(value != 0 or isinstance(value, bool))`.
**Boolean False vs nullable**: `get_nulled()` in `Request.py` treats empty strings, empty lists, and zero as null for nullable fields — but explicitly excludes `bool` values. `False` is a valid distinct value from null and serializes as `False`, not `None`. Both share one rule, `is_null_value()` in `Utils.py`. The `CLEAR` sentinel (`from etsy_python.v3.common.Utils import CLEAR`) is the explicit way to send `null` for a nullable field; `Request` raises `ValueError` when `CLEAR` is set on a non-nullable field. That check lives in `Request._validate()`, which runs on construction and again in `get_dict()`; models extend it (calling `super()`) for checks such as blank strings or GPSR type/id pairs.

### Request Flow

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ The SDK provides comprehensive coverage of Etsy API v3 resources:
### Commerce Resources

- **ListingInventory** - Stock and SKU management
- **ListingOffering** - Product offering details
- **ListingOffering** - Product offering details (deprecated: Etsy removed the endpoint)
- **ListingProduct** - Product variant management
- **Payment** - Payment processing
- **PaymentLedgerEntry** - Payment ledger entries
Expand All @@ -332,6 +332,7 @@ The SDK provides comprehensive coverage of Etsy API v3 resources:

- **ShopSection** - Organize listings into sections
- **ShopReturnPolicy** - Define return policies
- **ShopEconomicOperator** - Manage GPSR manufacturer and EU responsible person records
- **ShopProductionPartner** - Manage production partners
- **HolidayPreferences** - Shop holiday schedule
- **ProcessingProfile** - Processing time profiles
Expand Down
53 changes: 49 additions & 4 deletions etsy_python/v3/common/Utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,41 @@
from typing import Any, Dict, Iterable, List, Optional


class Clear:
"""Type of ``CLEAR``. Use the ``CLEAR`` instance, not this class."""

def __repr__(self) -> str:
return "CLEAR"

# Copies and pickle round trips (task queues, multiprocessing) must stay
# the module singleton; an identity check would miss a fresh instance.
def __copy__(self) -> "Clear":
return self

def __deepcopy__(self, memo: Dict[int, Any]) -> "Clear":
return self

def __reduce__(self) -> str:
return "CLEAR"


#: Pass as the value of a nullable request field to send ``null`` and clear the
#: stored value. On most older models ``""``, ``[]`` and ``0`` (but not
#: ``False``) do the same for backward compatibility, and their type hints do
#: not list ``Clear`` yet. Fields added since ``CLEAR`` accept it in their hints.
CLEAR = Clear()


def is_null_value(value: Any) -> bool:
"""True for the values a nullable request field sends as JSON ``null``."""
return (
isinstance(value, Clear)
or value == []
or value == ""
or (value == 0 and not isinstance(value, bool))
)


def warn_removed_legacy_param(operation_id: str) -> None:
"""Warn that `legacy` was removed from an operation and is no longer sent.

Expand Down Expand Up @@ -54,6 +89,12 @@ def generate_get_uri(uri: str, params: Optional[Dict[str, Any]] = None) -> str:
def todict(
obj: Any, classkey: Optional[str] = None, nullable: Optional[List[str]] = None
) -> Any:
"""Serialize ``obj`` for the API.

A key in ``nullable`` is sent as ``null`` only when its value is a null
value (``is_null_value``); a real value is kept. External callers may pass a
model's full nullable list, so this check must stay value-safe.
"""
if nullable is None:
nullable = []
if isinstance(obj, dict):
Expand All @@ -63,6 +104,8 @@ def todict(
return data
elif Enum in obj.__class__.__mro__:
return todict(obj.value)
elif isinstance(obj, Clear):
return None
elif hasattr(obj, "_ast"):
return todict(obj._ast())
elif hasattr(obj, "__iter__") and not isinstance(obj, str):
Expand All @@ -71,10 +114,12 @@ def todict(
data = dict(
[
# _type -> "type": avoids shadowing Python's builtin in model attrs
("type" if key == "_type" else key, todict(value, classkey))
if key not in nullable
or (value != [] and value != "" and (value != 0 or isinstance(value, bool)))
else (key, None)
(
"type" if key == "_type" else key,
None
if key in nullable and is_null_value(value)
else todict(value, classkey),
)
for key, value in obj.__dict__.items()
if not callable(value)
and (not key.startswith("_") or key == "_type")
Expand Down
5 changes: 5 additions & 0 deletions etsy_python/v3/enums/Listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ class ItemDimensionsUnit(Enum):
INCHES = "inches"


class GPSROperatorType(Enum):
SELLER = "seller"
ECONOMIC_OPERATOR = "economic_operator"


class Type(Enum):
PHYSICAL = "physical"
DOWNLOAD = "download"
Expand Down
105 changes: 102 additions & 3 deletions etsy_python/v3/models/Listing.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import warnings
from typing import List, Optional, Dict, Any
from typing import List, Optional, Dict, Any, Union

from etsy_python.v3.common.Utils import Clear, is_null_value
from etsy_python.v3.enums.Listing import (
GPSROperatorType,
WhoMade,
WhenMade,
ItemWeightUnit,
Expand Down Expand Up @@ -195,7 +197,70 @@ def _store_ecgt(
self.ecgt_software_update_details = ecgt_software_update_details


class CreateDraftListingRequest(_PersonalizationFieldsMixin, _ECGTFieldsMixin, Request):
#: GPSR economic operator fields, shared by createDraftListing and updateListing.
#: All are optional and nullable: pass ``CLEAR`` to send ``null`` and clear the
#: stored value. Each ``*_id`` is an ``economic_operator_id`` from
#: ``ShopEconomicOperatorResource``; Etsy requires it when the matching
#: ``*_type`` is ``GPSROperatorType.ECONOMIC_OPERATOR`` and rejects it otherwise.
GPSR_FIELDS = (
"gpsr_manufacturer_type",
"gpsr_manufacturer_id",
"gpsr_responsible_person_type",
"gpsr_responsible_person_id",
"gpsr_serial_number",
"gpsr_safety_warnings",
)


def _check_gpsr_operator_ids(values: Dict[str, Any], creating: bool) -> None:
"""Raise ValueError for a GPSR type/id pair that Etsy would reject.

An id is valid only with type ``economic_operator``, which needs an id on
create and can never be sent with a cleared id. On update the listing may
already store the other half of the pair, so a type without an id, or an id
without a type, is left to Etsy.
"""
for role in ("gpsr_manufacturer", "gpsr_responsible_person"):
kind = values[f"{role}_type"]
operator_id = values[f"{role}_id"]
if kind is None and not creating:
continue
has_id = operator_id is not None and not is_null_value(operator_id)
is_operator = (
getattr(kind, "value", kind) == GPSROperatorType.ECONOMIC_OPERATOR.value
)
clears_id = operator_id is not None and not has_id
if is_operator and (clears_id or (creating and not has_id)):
raise ValueError(
f"{role}_id is required when {role}_type is economic_operator"
)
if has_id and not is_operator:
raise ValueError(
f"{role}_id is only valid when {role}_type is economic_operator"
)


class _GPSRFieldsMixin:
"""Storage and type/id validation for the GPSR listing fields.

Each model sets ``_gpsr_creating``. See ``GPSR_FIELDS``.
"""

_gpsr_creating: bool

def _store_gpsr(self, **values: Any) -> None:
self._assign_fields(GPSR_FIELDS, values)

def _validate(self) -> None:
super()._validate()
_check_gpsr_operator_ids(vars(self), creating=self._gpsr_creating)


class CreateDraftListingRequest(
_PersonalizationFieldsMixin, _ECGTFieldsMixin, _GPSRFieldsMixin, Request
):
_gpsr_creating = True

nullable = [
"shipping_profile_id",
"return_policy_id",
Expand All @@ -215,6 +280,7 @@ class CreateDraftListingRequest(_PersonalizationFieldsMixin, _ECGTFieldsMixin, R
"image_ids",
"readiness_state_id",
*ECGT_FIELDS,
*GPSR_FIELDS,
]
mandatory = [
"quantity",
Expand Down Expand Up @@ -268,6 +334,12 @@ def __init__(
ecgt_other_commercial_guarantee_details: Optional[str] = None,
ecgt_after_sales_service_info: Optional[str] = None,
ecgt_software_update_details: Optional[str] = None,
gpsr_manufacturer_type: Union[GPSROperatorType, Clear, None] = None,
gpsr_manufacturer_id: Union[int, Clear, None] = None,
gpsr_responsible_person_type: Union[GPSROperatorType, Clear, None] = None,
gpsr_responsible_person_id: Union[int, Clear, None] = None,
gpsr_serial_number: Union[str, Clear, None] = None,
gpsr_safety_warnings: Union[str, Clear, None] = None,
):
self.quantity = quantity
self.title = title
Expand Down Expand Up @@ -313,13 +385,25 @@ def __init__(
ecgt_after_sales_service_info,
ecgt_software_update_details,
)
self._store_gpsr(
gpsr_manufacturer_type=gpsr_manufacturer_type,
gpsr_manufacturer_id=gpsr_manufacturer_id,
gpsr_responsible_person_type=gpsr_responsible_person_type,
gpsr_responsible_person_id=gpsr_responsible_person_id,
gpsr_serial_number=gpsr_serial_number,
gpsr_safety_warnings=gpsr_safety_warnings,
)
super().__init__(
nullable=CreateDraftListingRequest.nullable,
mandatory=CreateDraftListingRequest.mandatory,
)


class UpdateListingRequest(_PersonalizationFieldsMixin, _ECGTFieldsMixin, Request):
class UpdateListingRequest(
_PersonalizationFieldsMixin, _ECGTFieldsMixin, _GPSRFieldsMixin, Request
):
_gpsr_creating = False

nullable: List[str] = [
"materials",
"shipping_profile_id",
Expand All @@ -335,6 +419,7 @@ class UpdateListingRequest(_PersonalizationFieldsMixin, _ECGTFieldsMixin, Reques
"production_partner_ids",
"_type",
*ECGT_FIELDS,
*GPSR_FIELDS,
]

mandatory: List[str] = []
Expand Down Expand Up @@ -376,6 +461,12 @@ def __init__(
ecgt_other_commercial_guarantee_details: Optional[str] = None,
ecgt_after_sales_service_info: Optional[str] = None,
ecgt_software_update_details: Optional[str] = None,
gpsr_manufacturer_type: Union[GPSROperatorType, Clear, None] = None,
gpsr_manufacturer_id: Union[int, Clear, None] = None,
gpsr_responsible_person_type: Union[GPSROperatorType, Clear, None] = None,
gpsr_responsible_person_id: Union[int, Clear, None] = None,
gpsr_serial_number: Union[str, Clear, None] = None,
gpsr_safety_warnings: Union[str, Clear, None] = None,
):
self.image_ids = image_ids
self.title = title
Expand Down Expand Up @@ -419,6 +510,14 @@ def __init__(
ecgt_after_sales_service_info,
ecgt_software_update_details,
)
self._store_gpsr(
gpsr_manufacturer_type=gpsr_manufacturer_type,
gpsr_manufacturer_id=gpsr_manufacturer_id,
gpsr_responsible_person_type=gpsr_responsible_person_type,
gpsr_responsible_person_id=gpsr_responsible_person_id,
gpsr_serial_number=gpsr_serial_number,
gpsr_safety_warnings=gpsr_safety_warnings,
)
super().__init__(
nullable=UpdateListingRequest.nullable,
mandatory=UpdateListingRequest.mandatory,
Expand Down
36 changes: 33 additions & 3 deletions etsy_python/v3/models/Request.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Optional, List, Any
from typing import Any, Dict, List, Optional, Sequence

from etsy_python.v3.common.Utils import todict
from etsy_python.v3.common.Utils import Clear, is_null_value, todict


class Request:
Expand All @@ -13,6 +13,7 @@ def __init__(
self._mandatory = mandatory if mandatory is not None else []
if not self.check_mandatory():
raise ValueError
self._validate()

def check_mandatory(self) -> bool:
# Keys must match the attribute name (e.g. "_type", not "type").
Expand All @@ -25,13 +26,42 @@ def check_mandatory(self) -> bool:
return False
return True

def _validate(self) -> None:
"""Raise ValueError for field values Etsy would reject.

Runs on construction and again in ``get_dict``, because fields can be
reassigned in between. Subclasses extend it and call ``super()``.
"""
self._reject_clear_on_non_nullable()

def _reject_clear_on_non_nullable(self) -> None:
misplaced = [
key.lstrip("_")
for key, value in self.__dict__.items()
if isinstance(value, Clear) and key not in self._nullable
]
if misplaced:
raise ValueError(
"CLEAR is only valid for nullable fields: " + ", ".join(misplaced)
)

def _assign_fields(self, names: Sequence[str], values: Dict[str, Any]) -> None:
"""Set each named field from ``values``, which must hold exactly ``names``."""
if set(values) != set(names):
raise TypeError(
f"expected fields {sorted(names)}, got {sorted(values)}"
)
for name in names:
setattr(self, name, values[name])

def get_nulled(self) -> List[str]:
return [
key
for key, value in self.__dict__.items()
if key in self._nullable and (value == [] or value == "" or (value == 0 and not isinstance(value, bool)))
if key in self._nullable and is_null_value(value)
]

def get_dict(self) -> Any:
self._validate()
nulled = self.get_nulled()
return todict(self, nullable=nulled)
Loading
Loading