diff --git a/CLAUDE.md b/CLAUDE.md index 31f8649..fa9aeea0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 05f7ffc..c992fb7 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/etsy_python/v3/common/Utils.py b/etsy_python/v3/common/Utils.py index 6bd71e0..c9368c1 100644 --- a/etsy_python/v3/common/Utils.py +++ b/etsy_python/v3/common/Utils.py @@ -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. @@ -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): @@ -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): @@ -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") diff --git a/etsy_python/v3/enums/Listing.py b/etsy_python/v3/enums/Listing.py index d8e0d57..b5d2c22 100644 --- a/etsy_python/v3/enums/Listing.py +++ b/etsy_python/v3/enums/Listing.py @@ -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" diff --git a/etsy_python/v3/models/Listing.py b/etsy_python/v3/models/Listing.py index 2951520..9bd9c4b 100644 --- a/etsy_python/v3/models/Listing.py +++ b/etsy_python/v3/models/Listing.py @@ -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, @@ -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", @@ -215,6 +280,7 @@ class CreateDraftListingRequest(_PersonalizationFieldsMixin, _ECGTFieldsMixin, R "image_ids", "readiness_state_id", *ECGT_FIELDS, + *GPSR_FIELDS, ] mandatory = [ "quantity", @@ -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 @@ -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", @@ -335,6 +419,7 @@ class UpdateListingRequest(_PersonalizationFieldsMixin, _ECGTFieldsMixin, Reques "production_partner_ids", "_type", *ECGT_FIELDS, + *GPSR_FIELDS, ] mandatory: List[str] = [] @@ -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 @@ -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, diff --git a/etsy_python/v3/models/Request.py b/etsy_python/v3/models/Request.py index 910d56d..2e8f597 100644 --- a/etsy_python/v3/models/Request.py +++ b/etsy_python/v3/models/Request.py @@ -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: @@ -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"). @@ -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) diff --git a/etsy_python/v3/models/ShopEconomicOperator.py b/etsy_python/v3/models/ShopEconomicOperator.py new file mode 100644 index 0000000..0df9f34 --- /dev/null +++ b/etsy_python/v3/models/ShopEconomicOperator.py @@ -0,0 +1,145 @@ +from typing import Any, List, Optional, Union + +from etsy_python.v3.common.Utils import Clear +from etsy_python.v3.models.Request import Request + +#: Every body field of createShopEconomicOperator and updateShopEconomicOperator. +OPERATOR_FIELDS = ( + "operator_name", + "email", + "first_line", + "second_line", + "city", + "state", + "zip", + "country_iso", + "phone", + "phone_country_iso", + "trademark_name", +) + +#: Required on create. +REQUIRED_FIELDS = ( + "operator_name", + "email", + "first_line", + "city", + "zip", + "country_iso", + "phone", +) + + +class _ShopEconomicOperatorRequest(Request): + """Shared storage and validation. Clear a field with CLEAR, never ``""``.""" + + def _store_operator_fields(self, **values: Any) -> None: + self._assign_fields(OPERATOR_FIELDS, values) + + def _validate(self) -> None: + super()._validate() + blank = [ + name + for name in OPERATOR_FIELDS + if isinstance(getattr(self, name), str) and not getattr(self, name).strip() + ] + if blank: + raise ValueError("cannot be blank: " + ", ".join(blank)) + + +class CreateShopEconomicOperatorRequest(_ShopEconomicOperatorRequest): + # A new record has nothing to clear. + nullable: List[str] = [] + # A literal list, not list(REQUIRED_FIELDS): scripts/audit_sdk.py reads it + # from the source. + mandatory: List[str] = [ + "operator_name", + "email", + "first_line", + "city", + "zip", + "country_iso", + "phone", + ] + + def __init__( + self, + operator_name: str, + email: str, + first_line: str, + city: str, + zip: str, + country_iso: str, + phone: str, + second_line: Optional[str] = None, + state: Optional[str] = None, + phone_country_iso: Optional[str] = None, + trademark_name: Optional[str] = None, + ) -> None: + # Etsy requires state for some countries (e.g. US, CA, AU) and rejects + # the request server-side when it is missing. + self._store_operator_fields( + operator_name=operator_name, + email=email, + first_line=first_line, + second_line=second_line, + city=city, + state=state, + zip=zip, + country_iso=country_iso, + phone=phone, + phone_country_iso=phone_country_iso, + trademark_name=trademark_name, + ) + super().__init__( + nullable=CreateShopEconomicOperatorRequest.nullable, + mandatory=CreateShopEconomicOperatorRequest.mandatory, + ) + + +class UpdateShopEconomicOperatorRequest(_ShopEconomicOperatorRequest): + # Pass CLEAR for one of these to send null and clear the stored value. + nullable: List[str] = [ + "second_line", + "state", + "phone_country_iso", + "trademark_name", + ] + mandatory: List[str] = [] + + def _validate(self) -> None: + super()._validate() + if all(getattr(self, name) is None for name in OPERATOR_FIELDS): + raise ValueError("set at least one field to update") + + def __init__( + self, + operator_name: Optional[str] = None, + email: Optional[str] = None, + first_line: Optional[str] = None, + second_line: Union[str, Clear, None] = None, + city: Optional[str] = None, + state: Union[str, Clear, None] = None, + zip: Optional[str] = None, + country_iso: Optional[str] = None, + phone: Optional[str] = None, + phone_country_iso: Union[str, Clear, None] = None, + trademark_name: Union[str, Clear, None] = None, + ) -> None: + self._store_operator_fields( + operator_name=operator_name, + email=email, + first_line=first_line, + second_line=second_line, + city=city, + state=state, + zip=zip, + country_iso=country_iso, + phone=phone, + phone_country_iso=phone_country_iso, + trademark_name=trademark_name, + ) + super().__init__( + nullable=UpdateShopEconomicOperatorRequest.nullable, + mandatory=UpdateShopEconomicOperatorRequest.mandatory, + ) diff --git a/etsy_python/v3/models/__init__.py b/etsy_python/v3/models/__init__.py index 62189bd..ec2bdfa 100644 --- a/etsy_python/v3/models/__init__.py +++ b/etsy_python/v3/models/__init__.py @@ -1,3 +1,4 @@ +from etsy_python.v3.common.Utils import CLEAR, Clear from .FileRequest import FileRequest from .Listing import ( CreateDraftListingRequest, @@ -24,6 +25,10 @@ UpdateShopShippingProfileUpgradeRequest, ) from .Shop import CreateShopSectionRequest, UpdateShopSectionRequest, UpdateShopRequest +from .ShopEconomicOperator import ( + CreateShopEconomicOperatorRequest, + UpdateShopEconomicOperatorRequest, +) from .ShopReturnPolicy import ( ConsolidateShopReturnPoliciesRequest, CreateShopReturnPolicyRequest, diff --git a/etsy_python/v3/resources/ListingOffering.py b/etsy_python/v3/resources/ListingOffering.py index b18b2fd..2094091 100644 --- a/etsy_python/v3/resources/ListingOffering.py +++ b/etsy_python/v3/resources/ListingOffering.py @@ -1,3 +1,4 @@ +import warnings from dataclasses import dataclass from typing import Optional, Dict, Any, Union @@ -10,10 +11,27 @@ class ListingOfferingResource: session: EtsyClient + def __post_init__(self) -> None: + warnings.warn( + "ListingOfferingResource is deprecated: Etsy removed its only " + "endpoint, getListingOffering, from the Open API v3 spec. It will " + "be removed in a future major version.", + DeprecationWarning, + stacklevel=3, + ) + def get_listing_offering( self, listing_id: int, product_id: int, product_offering_id: int, legacy: Optional[bool] = None, ) -> Union[Response, RequestException]: + """Deprecated: Etsy removed getListingOffering from the Open API v3 spec.""" + warnings.warn( + "get_listing_offering is deprecated: Etsy removed getListingOffering " + "from the Open API v3 spec, so the request may fail. It will be " + "removed in a future major version.", + DeprecationWarning, + stacklevel=2, + ) endpoint = f"/listings/{listing_id}/products/{product_id}/offerings/{product_offering_id}" query_params: Dict[str, Any] = {"legacy": legacy} return self.session.make_request(endpoint, query_params=query_params) diff --git a/etsy_python/v3/resources/ShopEconomicOperator.py b/etsy_python/v3/resources/ShopEconomicOperator.py new file mode 100644 index 0000000..68adaf4 --- /dev/null +++ b/etsy_python/v3/resources/ShopEconomicOperator.py @@ -0,0 +1,55 @@ +from dataclasses import dataclass +from typing import Union + +from etsy_python.v3.exceptions.RequestException import RequestException +from etsy_python.v3.models.ShopEconomicOperator import ( + CreateShopEconomicOperatorRequest, + UpdateShopEconomicOperatorRequest, +) +from etsy_python.v3.resources.Response import Response +from etsy_python.v3.resources.Session import EtsyClient +from etsy_python.v3.resources.enums.Request import Method + + +@dataclass +class ShopEconomicOperatorResource: + """GPSR manufacturer and EU responsible person records of a shop. + + Etsy has no delete endpoint for economic operators; correct a record with + ``update_shop_economic_operator`` instead. + """ + + session: EtsyClient + + def create_shop_economic_operator( + self, + shop_id: int, + economic_operator_request: CreateShopEconomicOperatorRequest, + ) -> Union[Response, RequestException]: + endpoint = f"/shops/{shop_id}/economic-operators" + return self.session.make_request( + endpoint, method=Method.POST, payload=economic_operator_request + ) + + def get_shop_economic_operators( + self, shop_id: int + ) -> Union[Response, RequestException]: + endpoint = f"/shops/{shop_id}/economic-operators" + return self.session.make_request(endpoint) + + def get_shop_economic_operator( + self, shop_id: int, economic_operator_id: int + ) -> Union[Response, RequestException]: + endpoint = f"/shops/{shop_id}/economic-operators/{economic_operator_id}" + return self.session.make_request(endpoint) + + def update_shop_economic_operator( + self, + shop_id: int, + economic_operator_id: int, + economic_operator_request: UpdateShopEconomicOperatorRequest, + ) -> Union[Response, RequestException]: + endpoint = f"/shops/{shop_id}/economic-operators/{economic_operator_id}" + return self.session.make_request( + endpoint, method=Method.PATCH, payload=economic_operator_request + ) diff --git a/etsy_python/v3/resources/__init__.py b/etsy_python/v3/resources/__init__.py index 2c21c11..39768a4 100644 --- a/etsy_python/v3/resources/__init__.py +++ b/etsy_python/v3/resources/__init__.py @@ -21,6 +21,7 @@ from .Session import EtsyClient from .ShippingProfile import ShippingProfileResource from .Shop import ShopResource +from .ShopEconomicOperator import ShopEconomicOperatorResource from .ShopProductionPartner import ShopProductionPartnerResource from .ShopReturnPolicy import ShopReturnPolicyResource from .ShopSection import ShopSectionResource diff --git a/specs/audit-ignore.json b/specs/audit-ignore.json index 9f5600c..341f67c 100644 --- a/specs/audit-ignore.json +++ b/specs/audit-ignore.json @@ -185,6 +185,12 @@ ], "reason": "Etsy removed is_personalizable, personalization_is_required, personalization_char_count_max and personalization_instructions from the createDraftListing and updateListing request bodies (2026-09 spec); personalization now lives on the dedicated personalization endpoints (update_listing_personalization). The constructor kwargs are retained so existing callers don't break, but the values are stored under underscore-prefixed names that todict excludes, so they are never sent on the wire, and setting one emits a DeprecationWarning. Remove the kwargs (and this entry) in the next major version. featured_rank was also removed from the updateListing body (2026-10 spec) and is handled the same way: stored as _featured_rank, never sent, and setting it emits a DeprecationWarning.", "added": "2026-09-06" + }, + { + "type": "extra_method", + "key": "ListingOfferingResource.get_listing_offering", + "reason": "Etsy removed getListingOffering from the spec (2026-11). The method is kept so existing callers don't break, but it emits a DeprecationWarning because the request may fail. Remove the method, ListingOfferingResource, and this entry in the next major version.", + "added": "2026-11-10" } ] } diff --git a/specs/baseline.json b/specs/baseline.json index 1527829..2e3cbcb 100644 --- a/specs/baseline.json +++ b/specs/baseline.json @@ -57,6 +57,10 @@ { "name": "Shop HolidayPreferences" }, + { + "name": "Shop Economic Operators", + "description": "Economic operators are used to identify the manufacturer and EU responsible person records that the EU General Product Safety Regulation (GPSR) asks sellers to attach to the products they sell to EU buyers. Create them with these endpoints, then reference them from [listings](/documentation/reference#tag/ShopListing) through the `gpsr_*` fields on [createDraftListing](/documentation/reference#operation/createDraftListing) and [updateListing](/documentation/reference#operation/updateListing). See the [GPSR Economic Operators Tutorial](/documentation/tutorials/gpsr-economic-operators) for eligibility, the enforcement timeline, validation rules, and examples." + }, { "name": "SellerTaxonomy" }, @@ -87,9 +91,6 @@ { "name": "ShopListing Inventory" }, - { - "name": "ShopListing Offering" - }, { "name": "ShopListing Product" }, @@ -113,7 +114,6 @@ "ShopListing File", "ShopListing Image", "ShopListing Inventory", - "ShopListing Offering", "ShopListing Personalization", "ShopListing Product", "ShopListing Translation", @@ -159,6 +159,7 @@ "name": "Shop Management", "tags": [ "Shop", + "Shop Economic Operators", "Shop ProductionPartner", "Shop Section" ] @@ -546,6 +547,46 @@ "description": "Details of software update availability and the duration of such updates, as required under EU ECGT regulations for digital content. Maximum 255 characters. Silently ignored for physical listings and for sellers who are not eligible EU traders.", "nullable": true }, + "gpsr_manufacturer_type": { + "type": "string", + "description": "Who the manufacturer of this product is, for GPSR (EU General Product Safety Regulation) purposes. Either `seller` (your shop is the manufacturer) or `economic_operator` (a manufacturer record from the [Shop Economic Operators](/documentation/reference#tag/Shop-Economic-Operators) endpoints, identified by `gpsr_manufacturer_id`). `seller` is rejected when `who_made` is `someone_else`; use `economic_operator` for items made by a third party. Send `null` to clear. Optional for now; it becomes required for listings from eligible traders that sell into the EU 60 days after these fields were announced. See the [GPSR Economic Operators Tutorial](/documentation/tutorials/gpsr-economic-operators) for eligibility and the full flow.", + "nullable": true, + "enum": [ + "seller", + "economic_operator" + ] + }, + "gpsr_manufacturer_id": { + "type": "integer", + "description": "The `economic_operator_id` of the manufacturer, from the [Shop Economic Operators](/documentation/reference#tag/Shop-Economic-Operators) endpoints. Required when `gpsr_manufacturer_type` is `economic_operator`, and rejected when the type is anything else. The operator must belong to this shop and not be deleted. The manufacturer may be located anywhere in the world. If you change `gpsr_manufacturer_type` to `seller` without sending this field, the stored id is cleared for you.", + "format": "int64", + "nullable": true + }, + "gpsr_responsible_person_type": { + "type": "string", + "description": "Who the EU responsible person for this product is, for GPSR purposes. Either `seller` (your shop; only valid when the shop's legal address, or the address on file, is in the European Union) or `economic_operator` (a record from the [Shop Economic Operators](/documentation/reference#tag/Shop-Economic-Operators) endpoints, identified by `gpsr_responsible_person_id`, which must have an address in the European Union). Send `null` to clear. Optional for now; it becomes required for listings from eligible traders that sell into the EU 60 days after these fields were announced. See the [GPSR Economic Operators Tutorial](/documentation/tutorials/gpsr-economic-operators) for eligibility and the full flow.", + "nullable": true, + "enum": [ + "seller", + "economic_operator" + ] + }, + "gpsr_responsible_person_id": { + "type": "integer", + "description": "The `economic_operator_id` of the EU responsible person, from the [Shop Economic Operators](/documentation/reference#tag/Shop-Economic-Operators) endpoints. Required when `gpsr_responsible_person_type` is `economic_operator`, and rejected when the type is anything else. The operator must belong to this shop and not be deleted, and its address must be in the European Union. If you change `gpsr_responsible_person_type` to `seller` without sending this field, the stored id is cleared for you.", + "format": "int64", + "nullable": true + }, + "gpsr_serial_number": { + "type": "string", + "description": "An optional serial number, batch number, or lot identifier that lets the product be traced for GPSR purposes. Maximum 255 characters. Send `null` to clear.", + "nullable": true + }, + "gpsr_safety_warnings": { + "type": "string", + "description": "Optional free-text safety warnings and safety information for the product regarding GPSR purposes, shown to buyers as product safety information on the listing page (for example age restrictions, choking hazards, or care and handling instructions). Maximum 102,400 characters. Send `null` to clear.", + "nullable": true + }, "is_supply": { "type": "boolean", "description": "When true, tags the listing as a supply product, else indicates that it's a finished product. Helps buyers locate the listing under the Supplies heading. Requires 'who_made' and 'when_made'." @@ -933,7 +974,7 @@ }, "get": { "operationId": "getListing", - "description": "
General ReleaseReport bug

This endpoint is ready for production use.

\n\nRetrieves a listing record by listing ID.", + "description": "
General ReleaseReport bug

This endpoint is ready for production use.

\n\nRetrieves a listing record by listing ID. Inactive, draft, and expired listings are visible only to the listing's owner, using an access token with the listings_r scope.", "tags": [ "ShopListing" ], @@ -2278,7 +2319,7 @@ "/v3/application/listings/{listing_id}/inventory": { "get": { "operationId": "getListingInventory", - "description": "
General ReleaseReport bug

This endpoint is ready for production use.

\n\nRetrieves the inventory record for a listing. Listings you did not edit using the Etsy.com inventory tools have no inventory records. This endpoint returns SKU data if you are the owner of the inventory records being fetched.", + "description": "
General ReleaseReport bug

This endpoint is ready for production use.

\n\nRetrieves the inventory record for a listing. Listings you did not edit using the Etsy.com inventory tools have no inventory records. Only the owner of the listing may retrieve its inventory record.", "tags": [ "ShopListing Inventory" ], @@ -2350,6 +2391,16 @@ } } }, + "403": { + "description": "The request attempted to perform an operation it is not allowed to. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + }, "400": { "description": "There was a problem with the request data. See the error message for details.", "content": { @@ -2824,6 +2875,16 @@ } } }, + "403": { + "description": "The request attempted to perform an operation it is not allowed to. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + }, "500": { "description": "The server encountered an internal error. See the error message for details.", "content": { @@ -2845,102 +2906,6 @@ ] } }, - "/v3/application/listings/{listing_id}/products/{product_id}/offerings/{product_offering_id}": { - "get": { - "operationId": "getListingOffering", - "description": "
General ReleaseReport bug

This endpoint is ready for production use.

\n\nGet an Offering for a Listing", - "tags": [ - "ShopListing Offering" - ], - "parameters": [ - { - "name": "listing_id", - "in": "path", - "description": "", - "required": true, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 1 - } - }, - { - "name": "product_id", - "in": "path", - "description": "", - "required": true, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 1 - } - }, - { - "name": "product_offering_id", - "in": "path", - "description": "", - "required": true, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 1 - } - }, - { - "name": "legacy", - "in": "query", - "description": "This parameter is needed to enable new parameters and response values related to processing profiles.", - "required": false, - "schema": { - "type": "boolean", - "description": "This parameter is needed to enable new parameters and response values related to processing profiles." - } - } - ], - "responses": { - "200": { - "description": "A single ListingInventoryProductOffering", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListingInventoryProductOffering" - } - } - } - }, - "404": { - "description": "A resource could not be found. See the error message for details.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorSchema" - } - } - } - }, - "400": { - "description": "There was a problem with the request data. See the error message for details.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorSchema" - } - } - } - }, - "500": { - "description": "The server encountered an internal error. See the error message for details.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorSchema" - } - } - } - } - } - } - }, "/v3/application/listings/batch": { "get": { "operationId": "getListingsByListingIds", @@ -4846,6 +4811,46 @@ "type": "string", "description": "Details of software update availability and the duration of such updates, as required under EU ECGT regulations for digital content. Maximum 255 characters. Silently ignored for physical listings and for sellers who are not eligible EU traders.", "nullable": true + }, + "gpsr_manufacturer_type": { + "type": "string", + "description": "Who the manufacturer of this product is, for GPSR (EU General Product Safety Regulation) purposes. Either `seller` (your shop is the manufacturer) or `economic_operator` (a manufacturer record from the [Shop Economic Operators](/documentation/reference#tag/Shop-Economic-Operators) endpoints, identified by `gpsr_manufacturer_id`). `seller` is rejected when `who_made` is `someone_else`; use `economic_operator` for items made by a third party. Send `null` to clear. Optional for now; it becomes required for listings from eligible traders that sell into the EU 60 days after these fields were announced. See the [GPSR Economic Operators Tutorial](/documentation/tutorials/gpsr-economic-operators) for eligibility and the full flow.", + "nullable": true, + "enum": [ + "seller", + "economic_operator" + ] + }, + "gpsr_manufacturer_id": { + "type": "integer", + "description": "The `economic_operator_id` of the manufacturer, from the [Shop Economic Operators](/documentation/reference#tag/Shop-Economic-Operators) endpoints. Required when `gpsr_manufacturer_type` is `economic_operator`, and rejected when the type is anything else. The operator must belong to this shop and not be deleted. The manufacturer may be located anywhere in the world. If you change `gpsr_manufacturer_type` to `seller` without sending this field, the stored id is cleared for you.", + "format": "int64", + "nullable": true + }, + "gpsr_responsible_person_type": { + "type": "string", + "description": "Who the EU responsible person for this product is, for GPSR purposes. Either `seller` (your shop; only valid when the shop's legal address, or the address on file, is in the European Union) or `economic_operator` (a record from the [Shop Economic Operators](/documentation/reference#tag/Shop-Economic-Operators) endpoints, identified by `gpsr_responsible_person_id`, which must have an address in the European Union). Send `null` to clear. Optional for now; it becomes required for listings from eligible traders that sell into the EU 60 days after these fields were announced. See the [GPSR Economic Operators Tutorial](/documentation/tutorials/gpsr-economic-operators) for eligibility and the full flow.", + "nullable": true, + "enum": [ + "seller", + "economic_operator" + ] + }, + "gpsr_responsible_person_id": { + "type": "integer", + "description": "The `economic_operator_id` of the EU responsible person, from the [Shop Economic Operators](/documentation/reference#tag/Shop-Economic-Operators) endpoints. Required when `gpsr_responsible_person_type` is `economic_operator`, and rejected when the type is anything else. The operator must belong to this shop and not be deleted, and its address must be in the European Union. If you change `gpsr_responsible_person_type` to `seller` without sending this field, the stored id is cleared for you.", + "format": "int64", + "nullable": true + }, + "gpsr_serial_number": { + "type": "string", + "description": "An optional serial number, batch number, or lot identifier that lets the product be traced for GPSR purposes. Maximum 255 characters. Send `null` to clear.", + "nullable": true + }, + "gpsr_safety_warnings": { + "type": "string", + "description": "Optional free-text safety warnings and safety information for the product regarding GPSR purposes, shown to buyers as product safety information on the listing page (for example age restrictions, choking hazards, or care and handling instructions). Maximum 102,400 characters. Send `null` to clear.", + "nullable": true } } } @@ -7568,7 +7573,400 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TaxonomyNodeProperties" + "$ref": "#/components/schemas/TaxonomyNodeProperties" + } + } + } + }, + "404": { + "description": "A resource could not be found. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + }, + "400": { + "description": "There was a problem with the request data. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + }, + "500": { + "description": "The server encountered an internal error. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + } + } + } + }, + "/v3/application/shipping-carriers": { + "get": { + "operationId": "getShippingCarriers", + "description": "
General ReleaseReport bug

This endpoint is ready for production use.

\n\nRetrieves a list of available shipping carriers and the mail classes associated with them for a given country", + "tags": [ + "Shop ShippingProfile" + ], + "parameters": [ + { + "name": "origin_country_iso", + "in": "query", + "description": "The ISO code of the country from which the listing ships.", + "required": true, + "schema": { + "type": "string", + "description": "The ISO code of the country from which the listing ships.", + "format": "ISO 3166-1 alpha-2" + } + } + ], + "responses": { + "200": { + "description": "A set of ShippingCarriers", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShippingCarriers" + } + } + } + }, + "400": { + "description": "There was a problem with the request data. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + }, + "500": { + "description": "The server encountered an internal error. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + } + } + } + }, + "/v3/application/shops/{shop_id}/economic-operators": { + "post": { + "operationId": "createShopEconomicOperator", + "description": "
General ReleaseReport bug

This endpoint is ready for production use.

\n\nCreates a new economic operator (a manufacturer or EU responsible person record) for a specific shop, used for GPSR compliance on listings. `operator_name`, `email`, `first_line`, `city`, `zip`, `country_iso`, and `phone` are required and cannot be blank; `trademark_name`, `second_line`, and `phone_country_iso` are optional. `state` is required if the `country_iso` requires a state (for example `US`, `CA`, `AU`, `IN`, `BR`, `MX`, `ES`, `IT`); it is optional for every other country. See the [full list of countries](/documentation/tutorials/gpsr-economic-operators#countries-requiring-a-state). The response contains the `economic_operator_id` to pass as `gpsr_manufacturer_id` or `gpsr_responsible_person_id` when creating or updating a [listing](/documentation/reference#tag/ShopListing). A manufacturer can be located anywhere, but an operator used as an EU responsible person must have an address in the European Union; this is checked when a listing references the operator. There is no delete endpoint: use [updateShopEconomicOperator](/documentation/reference#operation/updateShopEconomicOperator) to correct a record. See the [GPSR Economic Operators Tutorial](/documentation/tutorials/gpsr-economic-operators) for the full guide, including eligibility and the enforcement timeline.", + "tags": [ + "Shop Economic Operators" + ], + "parameters": [ + { + "name": "shop_id", + "in": "path", + "description": "The unique positive non-zero numeric ID for an Etsy Shop.", + "required": true, + "schema": { + "type": "integer", + "description": "The unique positive non-zero numeric ID for an Etsy Shop.", + "format": "int64", + "minimum": 1 + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "required": [ + "operator_name", + "email", + "first_line", + "city", + "zip", + "country_iso", + "phone" + ], + "properties": { + "operator_name": { + "type": "string", + "description": "The legal or trading name of the business or individual acting as economic operator, as it should be shown to buyers and market surveillance authorities. Required, cannot be blank. Maximum 50 characters." + }, + "email": { + "type": "string", + "description": "The contact email address of the economic operator, where buyers and authorities can reach it about product safety. Required and must be a valid email address. Maximum 255 characters.", + "format": "email" + }, + "first_line": { + "type": "string", + "description": "The first line of the economic operator's postal address (street and number). Required, cannot be blank. Maximum 50 characters (95 in a few countries, such as India, Brazil, and Japan)." + }, + "city": { + "type": "string", + "description": "The city or town of the economic operator's postal address. Required, cannot be blank. Maximum 50 characters (95 in a few countries, such as India, Brazil, and Japan)." + }, + "zip": { + "type": "string", + "description": "The postal code of the economic operator's postal address. Required, cannot be blank." + }, + "country_iso": { + "type": "string", + "description": "The ISO 3166-1 alpha-2 code of the country of the economic operator's postal address, for example `DE` or `FR`. Not case-sensitive on input; always returned in upper case. Required.", + "format": "ISO 3166-1 alpha-2" + }, + "phone": { + "type": "string", + "description": "The contact phone number of the economic operator. Required, cannot be blank. May contain digits, spaces, and the characters ( ) . - + #. Phone number must be valid." + }, + "second_line": { + "type": "string", + "description": "The second line of the economic operator's postal address (apartment, suite, building). Optional. Maximum 50 characters (95 in a few countries, such as India, Brazil, and Japan). Send `null` on update to clear it. Returned as `null` when not set." + }, + "state": { + "type": "string", + "description": "The state, province, or region of the economic operator's postal address. Required if the `country_iso` requires a state; optional otherwise. Maximum 50 characters (95 in a few countries, such as India, Brazil, and Japan). See the [list of countries](/documentation/tutorials/gpsr-economic-operators#countries-requiring-a-state). On update, send `null` to clear it (not allowed for those countries). Returned as `null` when not set." + }, + "phone_country_iso": { + "type": "string", + "description": "The ISO 3166-1 alpha-2 country code that `phone` belongs to, for example `DE`. Optional. Not case-sensitive on input; always returned in upper case.", + "format": "ISO 3166-1 alpha-2", + "nullable": true + }, + "trademark_name": { + "type": "string", + "description": "The brand or trademark the economic operator trades under, when it differs from `operator_name`. Optional. Maximum 255 characters. Send `null` on update to clear it. Returned as `null` when not set." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The newly-created economic operator.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShopEconomicOperator" + } + } + } + }, + "400": { + "description": "There was a problem with the request data. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + }, + "403": { + "description": "The request attempted to perform an operation it is not allowed to. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + }, + "401": { + "description": "The request lacks valid authentication credentials. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + }, + "500": { + "description": "The server encountered an internal error. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + } + }, + "security": [ + { + "api_key": [], + "oauth2": [ + "listings_w" + ] + } + ] + }, + "get": { + "operationId": "getShopEconomicOperators", + "description": "
General ReleaseReport bug

This endpoint is ready for production use.

\n\nRetrieves all economic operators (manufacturer and EU responsible person records) of a specific shop, used for GPSR compliance on listings. Deleted operators are not included. Use the `economic_operator_id` of an entry as `gpsr_manufacturer_id` or `gpsr_responsible_person_id` on a [listing](/documentation/reference#tag/ShopListing). See the [GPSR Economic Operators Tutorial](/documentation/tutorials/gpsr-economic-operators) for the full guide.", + "tags": [ + "Shop Economic Operators" + ], + "parameters": [ + { + "name": "shop_id", + "in": "path", + "description": "The unique positive non-zero numeric ID for an Etsy Shop.", + "required": true, + "schema": { + "type": "integer", + "description": "The unique positive non-zero numeric ID for an Etsy Shop.", + "format": "int64", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "A list of economic operators associated with the shop.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShopEconomicOperators" + } + } + } + }, + "400": { + "description": "There was a problem with the request data. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + }, + "403": { + "description": "The request attempted to perform an operation it is not allowed to. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + }, + "404": { + "description": "A resource could not be found. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + }, + "401": { + "description": "The request lacks valid authentication credentials. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + }, + "500": { + "description": "The server encountered an internal error. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + } + }, + "security": [ + { + "api_key": [], + "oauth2": [ + "listings_r" + ] + } + ] + } + }, + "/v3/application/shops/{shop_id}/economic-operators/{economic_operator_id}": { + "get": { + "operationId": "getShopEconomicOperator", + "description": "
General ReleaseReport bug

This endpoint is ready for production use.

\n\nRetrieves a single economic operator, referenced by economic operator ID and shop ID. Returns `404` if the operator does not exist in the shop or has been deleted. See the [GPSR Economic Operators Tutorial](/documentation/tutorials/gpsr-economic-operators) for the full guide.", + "tags": [ + "Shop Economic Operators" + ], + "parameters": [ + { + "name": "shop_id", + "in": "path", + "description": "The unique positive non-zero numeric ID for an Etsy Shop.", + "required": true, + "schema": { + "type": "integer", + "description": "The unique positive non-zero numeric ID for an Etsy Shop.", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "economic_operator_id", + "in": "path", + "description": "The unique numeric ID of an economic operator (a manufacturer or EU responsible person record) belonging to a shop, used for GPSR compliance.", + "required": true, + "schema": { + "type": "integer", + "description": "The unique numeric ID of an economic operator (a manufacturer or EU responsible person record) belonging to a shop, used for GPSR compliance.", + "format": "int64", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "A single economic operator.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShopEconomicOperator" + } + } + } + }, + "400": { + "description": "There was a problem with the request data. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + }, + "403": { + "description": "The request attempted to perform an operation it is not allowed to. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" } } } @@ -7583,8 +7981,8 @@ } } }, - "400": { - "description": "There was a problem with the request data. See the error message for details.", + "401": { + "description": "The request lacks valid authentication credentials. See the error message for details.", "content": { "application/json": { "schema": { @@ -7603,36 +8001,117 @@ } } } - } - } - }, - "/v3/application/shipping-carriers": { - "get": { - "operationId": "getShippingCarriers", - "description": "
General ReleaseReport bug

This endpoint is ready for production use.

\n\nRetrieves a list of available shipping carriers and the mail classes associated with them for a given country", + }, + "security": [ + { + "api_key": [], + "oauth2": [ + "listings_r" + ] + } + ] + }, + "patch": { + "operationId": "updateShopEconomicOperator", + "description": "
General ReleaseReport bug

This endpoint is ready for production use.

\n\nUpdates an existing economic operator for a specific shop. Every field is optional and only the fields you send are changed. Send `null` for `trademark_name`, `second_line`, `state`, or `phone_country_iso` to clear it. `operator_name` can be at most 50 characters. `state` is required for [some countries](/documentation/tutorials/gpsr-economic-operators#countries-requiring-a-state). The change applies to every [listing](/documentation/reference#tag/ShopListing) that references this operator. Returns `404` if the economic operator does not exist in the shop. See the [GPSR Economic Operators Tutorial](/documentation/tutorials/gpsr-economic-operators) for the full guide.", "tags": [ - "Shop ShippingProfile" + "Shop Economic Operators" ], "parameters": [ { - "name": "origin_country_iso", - "in": "query", - "description": "The ISO code of the country from which the listing ships.", + "name": "shop_id", + "in": "path", + "description": "The unique positive non-zero numeric ID for an Etsy Shop.", "required": true, "schema": { - "type": "string", - "description": "The ISO code of the country from which the listing ships.", - "format": "ISO 3166-1 alpha-2" + "type": "integer", + "description": "The unique positive non-zero numeric ID for an Etsy Shop.", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "economic_operator_id", + "in": "path", + "description": "The unique numeric ID of an economic operator (a manufacturer or EU responsible person record) belonging to a shop, used for GPSR compliance.", + "required": true, + "schema": { + "type": "integer", + "description": "The unique numeric ID of an economic operator (a manufacturer or EU responsible person record) belonging to a shop, used for GPSR compliance.", + "format": "int64", + "minimum": 1 } } ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "operator_name": { + "type": "string", + "description": "The legal or trading name of the business or individual acting as economic operator, as it should be shown to buyers and market surveillance authorities. Required, cannot be blank. Maximum 50 characters." + }, + "email": { + "type": "string", + "description": "The contact email address of the economic operator, where buyers and authorities can reach it about product safety. Required and must be a valid email address. Maximum 255 characters.", + "format": "email" + }, + "first_line": { + "type": "string", + "description": "The first line of the economic operator's postal address (street and number). Required, cannot be blank. Maximum 50 characters (95 in a few countries, such as India, Brazil, and Japan)." + }, + "second_line": { + "type": "string", + "description": "The second line of the economic operator's postal address (apartment, suite, building). Optional. Maximum 50 characters (95 in a few countries, such as India, Brazil, and Japan). Send `null` on update to clear it. Returned as `null` when not set.", + "nullable": true + }, + "city": { + "type": "string", + "description": "The city or town of the economic operator's postal address. Required, cannot be blank. Maximum 50 characters (95 in a few countries, such as India, Brazil, and Japan)." + }, + "state": { + "type": "string", + "description": "The state, province, or region of the economic operator's postal address. Required if the `country_iso` requires a state; optional otherwise. Maximum 50 characters (95 in a few countries, such as India, Brazil, and Japan). See the [list of countries](/documentation/tutorials/gpsr-economic-operators#countries-requiring-a-state). On update, send `null` to clear it (not allowed for those countries). Returned as `null` when not set.", + "nullable": true + }, + "zip": { + "type": "string", + "description": "The postal code of the economic operator's postal address. Required, cannot be blank." + }, + "country_iso": { + "type": "string", + "description": "The ISO 3166-1 alpha-2 code of the country of the economic operator's postal address, for example `DE` or `FR`. Not case-sensitive on input; always returned in upper case. Required.", + "format": "ISO 3166-1 alpha-2" + }, + "phone": { + "type": "string", + "description": "The contact phone number of the economic operator. Required, cannot be blank. May contain digits, spaces, and the characters ( ) . - + #. Phone number must be valid." + }, + "phone_country_iso": { + "type": "string", + "description": "The ISO 3166-1 alpha-2 country code that `phone` belongs to, for example `DE`. Optional. Not case-sensitive on input; always returned in upper case.", + "format": "ISO 3166-1 alpha-2", + "nullable": true + }, + "trademark_name": { + "type": "string", + "description": "The brand or trademark the economic operator trades under, when it differs from `operator_name`. Optional. Maximum 255 characters. Send `null` on update to clear it. Returned as `null` when not set.", + "nullable": true + } + } + } + } + } + }, "responses": { "200": { - "description": "A set of ShippingCarriers", + "description": "The updated economic operator.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ShippingCarriers" + "$ref": "#/components/schemas/ShopEconomicOperator" } } } @@ -7647,6 +8126,36 @@ } } }, + "403": { + "description": "The request attempted to perform an operation it is not allowed to. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + }, + "404": { + "description": "A resource could not be found. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + }, + "401": { + "description": "The request lacks valid authentication credentials. See the error message for details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSchema" + } + } + } + }, "500": { "description": "The server encountered an internal error. See the error message for details.", "content": { @@ -7657,7 +8166,15 @@ } } } - } + }, + "security": [ + { + "api_key": [], + "oauth2": [ + "listings_w" + ] + } + ] } }, "/v3/application/shops/{shop_id}": { @@ -13350,6 +13867,46 @@ "type": "boolean", "description": "True when all four commercial guarantee fields are filled. Read-only; derived server-side.", "nullable": true + }, + "gpsr_manufacturer_type": { + "type": "string", + "description": "Who the manufacturer of this product is, for GPSR (EU General Product Safety Regulation) purposes. Either `seller` (your shop is the manufacturer) or `economic_operator` (a manufacturer record from the [Shop Economic Operators](/documentation/reference#tag/Shop-Economic-Operators) endpoints, identified by `gpsr_manufacturer_id`). `seller` is rejected when `who_made` is `someone_else`; use `economic_operator` for items made by a third party. Send `null` to clear. Optional for now; it becomes required for listings from eligible traders that sell into the EU 60 days after these fields were announced. See the [GPSR Economic Operators Tutorial](/documentation/tutorials/gpsr-economic-operators) for eligibility and the full flow.", + "nullable": true, + "enum": [ + "seller", + "economic_operator" + ] + }, + "gpsr_manufacturer_id": { + "type": "integer", + "description": "The `economic_operator_id` of the manufacturer, from the [Shop Economic Operators](/documentation/reference#tag/Shop-Economic-Operators) endpoints. Required when `gpsr_manufacturer_type` is `economic_operator`, and rejected when the type is anything else. The operator must belong to this shop and not be deleted. The manufacturer may be located anywhere in the world. If you change `gpsr_manufacturer_type` to `seller` without sending this field, the stored id is cleared for you.", + "format": "int64", + "nullable": true + }, + "gpsr_responsible_person_type": { + "type": "string", + "description": "Who the EU responsible person for this product is, for GPSR purposes. Either `seller` (your shop; only valid when the shop's legal address, or the address on file, is in the European Union) or `economic_operator` (a record from the [Shop Economic Operators](/documentation/reference#tag/Shop-Economic-Operators) endpoints, identified by `gpsr_responsible_person_id`, which must have an address in the European Union). Send `null` to clear. Optional for now; it becomes required for listings from eligible traders that sell into the EU 60 days after these fields were announced. See the [GPSR Economic Operators Tutorial](/documentation/tutorials/gpsr-economic-operators) for eligibility and the full flow.", + "nullable": true, + "enum": [ + "seller", + "economic_operator" + ] + }, + "gpsr_responsible_person_id": { + "type": "integer", + "description": "The `economic_operator_id` of the EU responsible person, from the [Shop Economic Operators](/documentation/reference#tag/Shop-Economic-Operators) endpoints. Required when `gpsr_responsible_person_type` is `economic_operator`, and rejected when the type is anything else. The operator must belong to this shop and not be deleted, and its address must be in the European Union. If you change `gpsr_responsible_person_type` to `seller` without sending this field, the stored id is cleared for you.", + "format": "int64", + "nullable": true + }, + "gpsr_serial_number": { + "type": "string", + "description": "An optional serial number, batch number, or lot identifier that lets the product be traced for GPSR purposes. Maximum 255 characters. Send `null` to clear.", + "nullable": true + }, + "gpsr_safety_warnings": { + "type": "string", + "description": "Optional free-text safety warnings and safety information for the product regarding GPSR purposes, shown to buyers as product safety information on the listing page (for example age restrictions, choking hazards, or care and handling instructions). Maximum 102,400 characters. Send `null` to clear.", + "nullable": true } } }, @@ -13856,6 +14413,46 @@ "description": "True when all four commercial guarantee fields are filled. Read-only; derived server-side.", "nullable": true }, + "gpsr_manufacturer_type": { + "type": "string", + "description": "Who the manufacturer of this product is, for GPSR (EU General Product Safety Regulation) purposes. Either `seller` (your shop is the manufacturer) or `economic_operator` (a manufacturer record from the [Shop Economic Operators](/documentation/reference#tag/Shop-Economic-Operators) endpoints, identified by `gpsr_manufacturer_id`). `seller` is rejected when `who_made` is `someone_else`; use `economic_operator` for items made by a third party. Send `null` to clear. Optional for now; it becomes required for listings from eligible traders that sell into the EU 60 days after these fields were announced. See the [GPSR Economic Operators Tutorial](/documentation/tutorials/gpsr-economic-operators) for eligibility and the full flow.", + "nullable": true, + "enum": [ + "seller", + "economic_operator" + ] + }, + "gpsr_manufacturer_id": { + "type": "integer", + "description": "The `economic_operator_id` of the manufacturer, from the [Shop Economic Operators](/documentation/reference#tag/Shop-Economic-Operators) endpoints. Required when `gpsr_manufacturer_type` is `economic_operator`, and rejected when the type is anything else. The operator must belong to this shop and not be deleted. The manufacturer may be located anywhere in the world. If you change `gpsr_manufacturer_type` to `seller` without sending this field, the stored id is cleared for you.", + "format": "int64", + "nullable": true + }, + "gpsr_responsible_person_type": { + "type": "string", + "description": "Who the EU responsible person for this product is, for GPSR purposes. Either `seller` (your shop; only valid when the shop's legal address, or the address on file, is in the European Union) or `economic_operator` (a record from the [Shop Economic Operators](/documentation/reference#tag/Shop-Economic-Operators) endpoints, identified by `gpsr_responsible_person_id`, which must have an address in the European Union). Send `null` to clear. Optional for now; it becomes required for listings from eligible traders that sell into the EU 60 days after these fields were announced. See the [GPSR Economic Operators Tutorial](/documentation/tutorials/gpsr-economic-operators) for eligibility and the full flow.", + "nullable": true, + "enum": [ + "seller", + "economic_operator" + ] + }, + "gpsr_responsible_person_id": { + "type": "integer", + "description": "The `economic_operator_id` of the EU responsible person, from the [Shop Economic Operators](/documentation/reference#tag/Shop-Economic-Operators) endpoints. Required when `gpsr_responsible_person_type` is `economic_operator`, and rejected when the type is anything else. The operator must belong to this shop and not be deleted, and its address must be in the European Union. If you change `gpsr_responsible_person_type` to `seller` without sending this field, the stored id is cleared for you.", + "format": "int64", + "nullable": true + }, + "gpsr_serial_number": { + "type": "string", + "description": "An optional serial number, batch number, or lot identifier that lets the product be traced for GPSR purposes. Maximum 255 characters. Send `null` to clear.", + "nullable": true + }, + "gpsr_safety_warnings": { + "type": "string", + "description": "Optional free-text safety warnings and safety information for the product regarding GPSR purposes, shown to buyers as product safety information on the listing page (for example age restrictions, choking hazards, or care and handling instructions). Maximum 102,400 characters. Send `null` to clear.", + "nullable": true + }, "shipping_profile": { "description": "An array of data representing the shipping profile resource.", "oneOf": [ @@ -16988,6 +17585,110 @@ } } }, + "ShopEconomicOperator": { + "type": "object", + "x-resource-id": "ShopEconomicOperator", + "description": "Represents an economic operator (manufacturer or EU responsible person) associated with a shop, used for GPSR compliance on listings.", + "properties": { + "economic_operator_id": { + "type": "integer", + "description": "The unique numeric ID of an economic operator (a manufacturer or EU responsible person record) belonging to a shop, used for GPSR compliance.", + "format": "int64", + "minimum": 1 + }, + "shop_id": { + "type": "integer", + "description": "The unique positive non-zero numeric ID for an Etsy Shop.", + "format": "int64", + "minimum": 1 + }, + "operator_name": { + "type": "string", + "description": "The legal or trading name of the business or individual acting as economic operator, as it should be shown to buyers and market surveillance authorities. Required, cannot be blank. Maximum 50 characters." + }, + "email": { + "type": "string", + "description": "The contact email address of the economic operator, where buyers and authorities can reach it about product safety. Required and must be a valid email address. Maximum 255 characters." + }, + "trademark_name": { + "type": "string", + "description": "The brand or trademark the economic operator trades under, when it differs from `operator_name`. Optional. Maximum 255 characters. Send `null` on update to clear it. Returned as `null` when not set.", + "nullable": true + }, + "first_line": { + "type": "string", + "description": "The first line of the economic operator's postal address (street and number). Required, cannot be blank. Maximum 50 characters (95 in a few countries, such as India, Brazil, and Japan).", + "nullable": true + }, + "second_line": { + "type": "string", + "description": "The second line of the economic operator's postal address (apartment, suite, building). Optional. Maximum 50 characters (95 in a few countries, such as India, Brazil, and Japan). Send `null` on update to clear it. Returned as `null` when not set.", + "nullable": true + }, + "city": { + "type": "string", + "description": "The city or town of the economic operator's postal address. Required, cannot be blank. Maximum 50 characters (95 in a few countries, such as India, Brazil, and Japan).", + "nullable": true + }, + "state": { + "type": "string", + "description": "The state, province, or region of the economic operator's postal address. Required if the `country_iso` requires a state; optional otherwise. Maximum 50 characters (95 in a few countries, such as India, Brazil, and Japan). See the [list of countries](/documentation/tutorials/gpsr-economic-operators#countries-requiring-a-state). On update, send `null` to clear it (not allowed for those countries). Returned as `null` when not set.", + "nullable": true + }, + "zip": { + "type": "string", + "description": "The postal code of the economic operator's postal address. Required, cannot be blank.", + "nullable": true + }, + "country_iso": { + "type": "string", + "description": "The ISO 3166-1 alpha-2 code of the country of the economic operator's postal address, for example `DE` or `FR`. Not case-sensitive on input; always returned in upper case. Required.", + "format": "ISO 3166-1 alpha-2", + "nullable": true + }, + "phone": { + "type": "string", + "description": "The contact phone number of the economic operator. Required, cannot be blank. May contain digits, spaces, and the characters ( ) . - + #. Phone number must be valid.", + "nullable": true + }, + "phone_country_iso": { + "type": "string", + "description": "The ISO 3166-1 alpha-2 country code that `phone` belongs to, for example `DE`. Optional. Not case-sensitive on input; always returned in upper case.", + "format": "ISO 3166-1 alpha-2", + "nullable": true + }, + "create_date": { + "type": "integer", + "description": "The Unix timestamp of when the economic operator was created.", + "format": "int64", + "minimum": 946684800 + }, + "update_date": { + "type": "integer", + "description": "The Unix timestamp of when the economic operator was last updated.", + "format": "int64", + "minimum": 946684800 + } + } + }, + "ShopEconomicOperators": { + "type": "object", + "x-resource-id": "ShopEconomicOperators", + "description": "Represents several economic operators associated with a shop.", + "properties": { + "count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShopEconomicOperator" + } + } + } + }, "ShopHolidayPreference": { "type": "object", "x-resource-id": "ShopHolidayPreference", diff --git a/tests/test_audit_ignore.py b/tests/test_audit_ignore.py index 5152e2e..5f319c2 100644 --- a/tests/test_audit_ignore.py +++ b/tests/test_audit_ignore.py @@ -723,6 +723,12 @@ def test_shipped_2026_10_removal_ignores_are_pinned(self): assert video["values"] == ["is_multi_video"] assert ("extra_method", "UserAddressResource.delete_user_address") in by_key + def test_shipped_2026_11_removal_ignore_is_pinned(self): + # Etsy removed getListingOffering; the method stays as a warning shim. + path = SCRIPTS_DIR.parent / "specs" / "audit-ignore.json" + keys = {(ig["type"], ig["key"]) for ig in audit_sdk.load_ignores(path)} + assert ("extra_method", "ListingOfferingResource.get_listing_offering") in keys + def test_no_wildcard_body_drift_ignores(self): # "*" on a body_drift entry would hide unreviewed request-body drift on # that operation, defeating the self-verifying property. diff --git a/tests/test_listing_models.py b/tests/test_listing_models.py index 2a3b1fb..826f6f9 100644 --- a/tests/test_listing_models.py +++ b/tests/test_listing_models.py @@ -1,9 +1,12 @@ +import copy import warnings import pytest -from etsy_python.v3.enums.Listing import WhenMade, WhoMade +from etsy_python.v3.common.Utils import CLEAR +from etsy_python.v3.enums.Listing import GPSROperatorType, WhenMade, WhoMade from etsy_python.v3.models.Listing import ( + GPSR_FIELDS, CreateDraftListingRequest, CreateListingTranslationRequest, UpdateListingRequest, @@ -489,24 +492,24 @@ def test_defaults_are_none(self): assert req.data == {"video_id": None, "name": None} +def _draft_listing_kwargs(): + return { + "quantity": 10, + "title": "Test Mug", + "description": "A test mug", + "price": 25.00, + "who_made": WhoMade.I_DID, + "when_made": WhenMade.TWENTY_TWENTIES, + "taxonomy_id": 30303, + } + + class TestECGTFields: """EU commercial guarantee (ECGT/GPSR) fields on create and update.""" - @staticmethod - def _create_kwargs(): - return { - "quantity": 10, - "title": "Test Mug", - "description": "A test mug", - "price": 25.00, - "who_made": WhoMade.I_DID, - "when_made": WhenMade.TWENTY_TWENTIES, - "taxonomy_id": 30303, - } - def test_create_serializes_all_ecgt_fields(self): req = CreateDraftListingRequest( - **self._create_kwargs(), + **_draft_listing_kwargs(), ecgt_garan_brand="Acme", ecgt_garan_model="MUG-1", ecgt_garan_years=3, @@ -543,7 +546,7 @@ def test_update_serializes_all_ecgt_fields(self): assert result["ecgt_software_update_details"] == "Not applicable." def test_create_omits_unset_ecgt_fields(self): - req = CreateDraftListingRequest(**self._create_kwargs()) + req = CreateDraftListingRequest(**_draft_listing_kwargs()) result = req.get_dict() for field in ( "ecgt_garan_brand", @@ -570,6 +573,134 @@ def test_ecgt_does_not_warn(self): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") CreateDraftListingRequest( - **self._create_kwargs(), ecgt_garan_brand="Acme" + **_draft_listing_kwargs(), ecgt_garan_brand="Acme" ) assert caught == [] + + +class TestGPSRFields: + """GPSR economic operator fields on create and update.""" + + @staticmethod + def _gpsr_kwargs(): + return { + "gpsr_manufacturer_type": GPSROperatorType.ECONOMIC_OPERATOR, + "gpsr_manufacturer_id": 12121, + "gpsr_responsible_person_type": GPSROperatorType.SELLER, + "gpsr_responsible_person_id": None, + "gpsr_serial_number": "LOT-42", + "gpsr_safety_warnings": "Not for children under 3.", + } + + @staticmethod + def _expected_wire(): + return { + "gpsr_manufacturer_type": "economic_operator", + "gpsr_manufacturer_id": 12121, + "gpsr_responsible_person_type": "seller", + "gpsr_serial_number": "LOT-42", + "gpsr_safety_warnings": "Not for children under 3.", + } + + def test_create_serializes_gpsr_fields(self): + result = CreateDraftListingRequest( + **_draft_listing_kwargs(), **self._gpsr_kwargs() + ).get_dict() + for field, value in self._expected_wire().items(): + assert result[field] == value + assert "gpsr_responsible_person_id" not in result + + def test_update_serializes_gpsr_fields(self): + result = UpdateListingRequest(**self._gpsr_kwargs()).get_dict() + assert result == self._expected_wire() + + def test_unset_gpsr_fields_are_omitted(self): + result = UpdateListingRequest(title="Just a title").get_dict() + assert not any(field.startswith("gpsr_") for field in result) + + def test_clear_sends_null_for_gpsr_fields(self): + result = UpdateListingRequest( + **{field: CLEAR for field in GPSR_FIELDS} + ).get_dict() + assert result == {field: None for field in GPSR_FIELDS} + + def test_clear_on_non_nullable_field_raises(self): + with pytest.raises(ValueError, match="title"): + UpdateListingRequest(title=CLEAR) + + def test_legacy_empty_values_clear_gpsr_fields(self): + result = UpdateListingRequest( + gpsr_manufacturer_type="", + gpsr_manufacturer_id=0, + gpsr_responsible_person_type="", + gpsr_responsible_person_id=0, + gpsr_serial_number="", + gpsr_safety_warnings="", + ).get_dict() + assert result == {field: None for field in GPSR_FIELDS} + + def test_deep_copied_request_still_sends_null(self): + req = copy.deepcopy(UpdateListingRequest(gpsr_serial_number=CLEAR)) + assert req.get_dict() == {"gpsr_serial_number": None} + + def test_clear_set_after_construction_on_non_nullable_field_raises(self): + req = UpdateListingRequest() + req.title = CLEAR + with pytest.raises(ValueError, match="title"): + req.get_dict() + + +class TestGPSROperatorIdPairing: + MANUFACTURER = GPSROperatorType.ECONOMIC_OPERATOR + + def test_create_requires_id_for_economic_operator(self): + with pytest.raises(ValueError, match="gpsr_manufacturer_id is required"): + CreateDraftListingRequest( + **_draft_listing_kwargs(), gpsr_manufacturer_type=self.MANUFACTURER + ) + + def test_create_rejects_id_without_type(self): + with pytest.raises(ValueError, match="gpsr_responsible_person_id"): + CreateDraftListingRequest( + **_draft_listing_kwargs(), gpsr_responsible_person_id=5 + ) + + @pytest.mark.parametrize("kind", [GPSROperatorType.SELLER, CLEAR]) + def test_update_rejects_id_with_non_operator_type(self, kind): + with pytest.raises(ValueError, match="only valid"): + UpdateListingRequest( + gpsr_manufacturer_type=kind, gpsr_manufacturer_id=5 + ) + + def test_update_allows_operator_type_without_id(self): + result = UpdateListingRequest( + gpsr_manufacturer_type=self.MANUFACTURER + ).get_dict() + assert result == {"gpsr_manufacturer_type": "economic_operator"} + + def test_pair_reassigned_after_construction_raises_on_serialize(self): + req = UpdateListingRequest(gpsr_manufacturer_type=GPSROperatorType.SELLER) + req.gpsr_manufacturer_id = 7 + with pytest.raises(ValueError, match="only valid"): + req.get_dict() + + def test_clearing_listing_type_sends_type_key(self): + result = UpdateListingRequest(listing_type=CLEAR).get_dict() + assert result == {"type": None} + + def test_clear_error_names_the_field_without_underscore(self): + with pytest.warns(DeprecationWarning, match="featured_rank"): + with pytest.raises(ValueError, match=r"fields: featured_rank$"): + UpdateListingRequest(featured_rank=CLEAR) + + @pytest.mark.parametrize("cleared", [CLEAR, 0]) + def test_update_rejects_operator_type_with_cleared_id(self, cleared): + with pytest.raises(ValueError, match="gpsr_manufacturer_id is required"): + UpdateListingRequest( + gpsr_manufacturer_type=self.MANUFACTURER, + gpsr_manufacturer_id=cleared, + ) + + def test_update_allows_id_without_type(self): + result = UpdateListingRequest(gpsr_manufacturer_id=5).get_dict() + assert result == {"gpsr_manufacturer_id": 5} diff --git a/tests/test_remaining_resources.py b/tests/test_remaining_resources.py index d46cc18..9aa50e8 100644 --- a/tests/test_remaining_resources.py +++ b/tests/test_remaining_resources.py @@ -791,8 +791,14 @@ def test_update_holiday_preferences_with_enum(self, mock_session): class TestListingOfferingResource: def test_get_listing_offering(self, mock_session): mock_session.make_request.return_value = Response(200, make_listing_offering()) - resource = ListingOfferingResource(session=mock_session) - resource.get_listing_offering(MOCK_LISTING_ID, MOCK_PRODUCT_ID, MOCK_OFFERING_ID) + with pytest.warns(DeprecationWarning) as record: + resource = ListingOfferingResource(session=mock_session) + resource.get_listing_offering( + MOCK_LISTING_ID, MOCK_PRODUCT_ID, MOCK_OFFERING_ID + ) + messages = [str(w.message) for w in record] + assert any("ListingOfferingResource is deprecated" in m for m in messages) + assert any("get_listing_offering is deprecated" in m for m in messages) mock_session.make_request.assert_called_once_with( f"/listings/{MOCK_LISTING_ID}/products/{MOCK_PRODUCT_ID}/offerings/{MOCK_OFFERING_ID}", query_params={"legacy": None}, diff --git a/tests/test_request_model.py b/tests/test_request_model.py index 15fb41a..a19a69e 100644 --- a/tests/test_request_model.py +++ b/tests/test_request_model.py @@ -107,3 +107,16 @@ def test_none_fields_excluded(self): req = ConcreteRequest(name="test", value=42) result = req.get_dict() assert "optional_field" not in result + + +class TestAssignFields: + def test_sets_each_named_field(self): + req = Request.__new__(Request) + req._assign_fields(("a", "b"), {"a": 1, "b": None}) + assert (req.a, req.b) == (1, None) + + @pytest.mark.parametrize("values", [{"a": 1}, {"a": 1, "b": 2, "c": 3}]) + def test_mismatched_fields_raise(self, values): + req = Request.__new__(Request) + with pytest.raises(TypeError): + req._assign_fields(("a", "b"), values) diff --git a/tests/test_shop_economic_operator.py b/tests/test_shop_economic_operator.py new file mode 100644 index 0000000..0b753d7 --- /dev/null +++ b/tests/test_shop_economic_operator.py @@ -0,0 +1,159 @@ +import pytest + +from etsy_python.v3.common.Utils import CLEAR +from etsy_python.v3.models.ShopEconomicOperator import ( + REQUIRED_FIELDS, + CreateShopEconomicOperatorRequest, + UpdateShopEconomicOperatorRequest, +) +from etsy_python.v3.resources import ShopEconomicOperatorResource +from etsy_python.v3.resources.Response import Response +from etsy_python.v3.resources.enums.Request import Method + +from tests.conftest import MOCK_SHOP_ID + +MOCK_ECONOMIC_OPERATOR_ID = 12121 + + +def _create_kwargs(): + return { + "operator_name": "Acme GmbH", + "email": "safety@example.com", + "first_line": "Hauptstrasse 1", + "city": "Berlin", + "zip": "10115", + "country_iso": "DE", + "phone": "+49 30 123456", + } + + +class TestCreateShopEconomicOperatorRequest: + def test_serializes_required_fields(self): + result = CreateShopEconomicOperatorRequest(**_create_kwargs()).get_dict() + assert result == _create_kwargs() + + def test_serializes_optional_fields(self): + result = CreateShopEconomicOperatorRequest( + **_create_kwargs(), + second_line="Etage 2", + state="BE", + phone_country_iso="DE", + trademark_name="Acme", + ).get_dict() + assert result["second_line"] == "Etage 2" + assert result["state"] == "BE" + assert result["phone_country_iso"] == "DE" + assert result["trademark_name"] == "Acme" + + @pytest.mark.parametrize("field", list(_create_kwargs())) + def test_missing_required_field_raises(self, field): + kwargs = _create_kwargs() + kwargs[field] = None + with pytest.raises(ValueError): + CreateShopEconomicOperatorRequest(**kwargs) + + + @pytest.mark.parametrize("blank", ["", " "]) + def test_blank_required_field_raises(self, blank): + with pytest.raises(ValueError, match="operator_name"): + CreateShopEconomicOperatorRequest( + **{**_create_kwargs(), "operator_name": blank} + ) + + + @pytest.mark.parametrize("value", ["", CLEAR]) + def test_optional_field_cannot_be_blank_or_cleared(self, value): + with pytest.raises(ValueError, match="state"): + CreateShopEconomicOperatorRequest(**_create_kwargs(), state=value) + + def test_mandatory_matches_non_blank_fields(self): + assert CreateShopEconomicOperatorRequest.mandatory == list(REQUIRED_FIELDS) + + +class TestUpdateShopEconomicOperatorRequest: + def test_sends_only_set_fields(self): + result = UpdateShopEconomicOperatorRequest(email="new@example.com").get_dict() + assert result == {"email": "new@example.com"} + + def test_clear_sends_null_for_nullable_fields(self): + result = UpdateShopEconomicOperatorRequest( + second_line=CLEAR, + state=CLEAR, + phone_country_iso=CLEAR, + trademark_name=CLEAR, + ).get_dict() + assert result == { + "second_line": None, + "state": None, + "phone_country_iso": None, + "trademark_name": None, + } + + def test_blank_non_nullable_field_raises(self): + with pytest.raises(ValueError, match="phone"): + UpdateShopEconomicOperatorRequest(phone="") + + def test_clear_on_non_nullable_field_raises(self): + with pytest.raises(ValueError, match="city"): + UpdateShopEconomicOperatorRequest(city=CLEAR) + + +class TestShopEconomicOperatorResource: + def test_create_shop_economic_operator(self, mock_session): + mock_session.make_request.return_value = Response(200, {}) + payload = CreateShopEconomicOperatorRequest(**_create_kwargs()) + resource = ShopEconomicOperatorResource(session=mock_session) + resource.create_shop_economic_operator(MOCK_SHOP_ID, payload) + mock_session.make_request.assert_called_once_with( + f"/shops/{MOCK_SHOP_ID}/economic-operators", + method=Method.POST, + payload=payload, + ) + + def test_get_shop_economic_operators(self, mock_session): + mock_session.make_request.return_value = Response(200, {}) + resource = ShopEconomicOperatorResource(session=mock_session) + resource.get_shop_economic_operators(MOCK_SHOP_ID) + mock_session.make_request.assert_called_once_with( + f"/shops/{MOCK_SHOP_ID}/economic-operators" + ) + + def test_get_shop_economic_operator(self, mock_session): + mock_session.make_request.return_value = Response(200, {}) + resource = ShopEconomicOperatorResource(session=mock_session) + resource.get_shop_economic_operator(MOCK_SHOP_ID, MOCK_ECONOMIC_OPERATOR_ID) + mock_session.make_request.assert_called_once_with( + f"/shops/{MOCK_SHOP_ID}/economic-operators/{MOCK_ECONOMIC_OPERATOR_ID}" + ) + + def test_update_shop_economic_operator(self, mock_session): + mock_session.make_request.return_value = Response(200, {}) + payload = UpdateShopEconomicOperatorRequest(city="Hamburg") + resource = ShopEconomicOperatorResource(session=mock_session) + resource.update_shop_economic_operator( + MOCK_SHOP_ID, MOCK_ECONOMIC_OPERATOR_ID, payload + ) + mock_session.make_request.assert_called_once_with( + f"/shops/{MOCK_SHOP_ID}/economic-operators/{MOCK_ECONOMIC_OPERATOR_ID}", + method=Method.PATCH, + payload=payload, + ) + + + +class TestShopEconomicOperatorValidation: + @pytest.mark.parametrize("field", ["state", "phone"]) + @pytest.mark.parametrize("blank", ["", " "]) + def test_update_rejects_blank_values(self, field, blank): + with pytest.raises(ValueError, match=field): + UpdateShopEconomicOperatorRequest(**{field: blank}) + + def test_update_requires_a_field(self): + with pytest.raises(ValueError, match="at least one field"): + UpdateShopEconomicOperatorRequest() + + def test_blank_set_after_construction_raises_on_serialize(self): + req = UpdateShopEconomicOperatorRequest(city="Berlin") + req.city = "" + with pytest.raises(ValueError, match="city"): + req.get_dict() diff --git a/tests/test_utils.py b/tests/test_utils.py index a78a2b1..b209bdf 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,10 +1,14 @@ +import copy +import pickle from enum import Enum import pytest from etsy_python.v3.common.Utils import ( + CLEAR, generate_bytes_from_file, generate_get_uri, + is_null_value, join_ids, todict, ) @@ -113,6 +117,10 @@ def test_none_fields_excluded(self): result = todict(obj) assert result == {"y": "hello"} + def test_nullable_keeps_real_values(self): + obj = SimpleObj(123, "") + assert todict(obj, nullable=["x", "y"]) == {"x": 123, "y": None} + def test_nullable_fields(self): obj = SimpleObj("", "hello") result = todict(obj, nullable=["x"]) @@ -211,3 +219,24 @@ def test_reads_empty_file(self, tmp_path): path = tmp_path / "empty.bin" path.write_bytes(b"") assert generate_bytes_from_file(str(path)) == b"" + + +class TestClearSentinel: + @pytest.mark.parametrize("value", [CLEAR, "", [], 0, 0.0]) + def test_null_values(self, value): + assert is_null_value(value) + + @pytest.mark.parametrize("value", [False, True, None, "x", [1], 1]) + def test_non_null_values(self, value): + assert not is_null_value(value) + + def test_repr(self): + assert repr(CLEAR) == "CLEAR" + + def test_copies_stay_the_singleton(self): + assert copy.copy(CLEAR) is CLEAR + assert copy.deepcopy(CLEAR) is CLEAR + assert pickle.loads(pickle.dumps(CLEAR)) is CLEAR + + def test_nested_clear_serializes_as_null(self): + assert todict({"a": CLEAR, "b": [CLEAR]}) == {"a": None, "b": [None]}