From 616320d24970695f777451d177f59721170eb655 Mon Sep 17 00:00:00 2001 From: Amit Ray <51674969+amitray007@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:39:39 +0530 Subject: [PATCH 1/6] feat: sync SDK with Etsy API spec (Nov 2026 GPSR economic operators) - Add ShopEconomicOperatorResource: create, list, get, and update (PATCH) GPSR economic operator records, with request models. - Add the six gpsr_* fields to CreateDraftListingRequest and UpdateListingRequest, plus a GPSROperatorType enum. All are nullable, so "" or 0 sends null to clear a value. - Deprecate get_listing_offering: Etsy removed getListingOffering. It now emits a DeprecationWarning and has an audit-ignore entry. - Update specs/baseline.json to the 2026-11-10 spec. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- etsy_python/v3/enums/Listing.py | 5 + etsy_python/v3/models/Listing.py | 75 +- etsy_python/v3/models/ShopEconomicOperator.py | 91 ++ etsy_python/v3/models/__init__.py | 4 + etsy_python/v3/resources/ListingOffering.py | 9 + .../v3/resources/ShopEconomicOperator.py | 55 + etsy_python/v3/resources/__init__.py | 1 + specs/audit-ignore.json | 6 + specs/baseline.json | 945 +++++++++++++++--- tests/test_audit_ignore.py | 6 + tests/test_listing_models.py | 55 +- tests/test_remaining_resources.py | 5 +- tests/test_shop_economic_operator.py | 110 ++ 14 files changed, 1243 insertions(+), 127 deletions(-) create mode 100644 etsy_python/v3/models/ShopEconomicOperator.py create mode 100644 etsy_python/v3/resources/ShopEconomicOperator.py create mode 100644 tests/test_shop_economic_operator.py 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/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..d6b35c3 100644 --- a/etsy_python/v3/models/Listing.py +++ b/etsy_python/v3/models/Listing.py @@ -2,6 +2,7 @@ from typing import List, Optional, Dict, Any from etsy_python.v3.enums.Listing import ( + GPSROperatorType, WhoMade, WhenMade, ItemWeightUnit, @@ -195,7 +196,45 @@ 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 ``""`` (or ``0`` for an id) 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", +) + + +class _GPSRFieldsMixin: + """Storage for the GPSR economic operator listing fields. See ``GPSR_FIELDS``.""" + + def _store_gpsr( + self, + gpsr_manufacturer_type: Optional[GPSROperatorType], + gpsr_manufacturer_id: Optional[int], + gpsr_responsible_person_type: Optional[GPSROperatorType], + gpsr_responsible_person_id: Optional[int], + gpsr_serial_number: Optional[str], + gpsr_safety_warnings: Optional[str], + ) -> None: + self.gpsr_manufacturer_type = gpsr_manufacturer_type + self.gpsr_manufacturer_id = gpsr_manufacturer_id + self.gpsr_responsible_person_type = gpsr_responsible_person_type + self.gpsr_responsible_person_id = gpsr_responsible_person_id + self.gpsr_serial_number = gpsr_serial_number + self.gpsr_safety_warnings = gpsr_safety_warnings + + +class CreateDraftListingRequest( + _PersonalizationFieldsMixin, _ECGTFieldsMixin, _GPSRFieldsMixin, Request +): nullable = [ "shipping_profile_id", "return_policy_id", @@ -215,6 +254,7 @@ class CreateDraftListingRequest(_PersonalizationFieldsMixin, _ECGTFieldsMixin, R "image_ids", "readiness_state_id", *ECGT_FIELDS, + *GPSR_FIELDS, ] mandatory = [ "quantity", @@ -268,6 +308,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: Optional[GPSROperatorType] = None, + gpsr_manufacturer_id: Optional[int] = None, + gpsr_responsible_person_type: Optional[GPSROperatorType] = None, + gpsr_responsible_person_id: Optional[int] = None, + gpsr_serial_number: Optional[str] = None, + gpsr_safety_warnings: Optional[str] = None, ): self.quantity = quantity self.title = title @@ -313,13 +359,23 @@ def __init__( ecgt_after_sales_service_info, ecgt_software_update_details, ) + self._store_gpsr( + gpsr_manufacturer_type, + gpsr_manufacturer_id, + gpsr_responsible_person_type, + gpsr_responsible_person_id, + gpsr_serial_number, + gpsr_safety_warnings, + ) super().__init__( nullable=CreateDraftListingRequest.nullable, mandatory=CreateDraftListingRequest.mandatory, ) -class UpdateListingRequest(_PersonalizationFieldsMixin, _ECGTFieldsMixin, Request): +class UpdateListingRequest( + _PersonalizationFieldsMixin, _ECGTFieldsMixin, _GPSRFieldsMixin, Request +): nullable: List[str] = [ "materials", "shipping_profile_id", @@ -335,6 +391,7 @@ class UpdateListingRequest(_PersonalizationFieldsMixin, _ECGTFieldsMixin, Reques "production_partner_ids", "_type", *ECGT_FIELDS, + *GPSR_FIELDS, ] mandatory: List[str] = [] @@ -376,6 +433,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: Optional[GPSROperatorType] = None, + gpsr_manufacturer_id: Optional[int] = None, + gpsr_responsible_person_type: Optional[GPSROperatorType] = None, + gpsr_responsible_person_id: Optional[int] = None, + gpsr_serial_number: Optional[str] = None, + gpsr_safety_warnings: Optional[str] = None, ): self.image_ids = image_ids self.title = title @@ -419,6 +482,14 @@ def __init__( ecgt_after_sales_service_info, ecgt_software_update_details, ) + self._store_gpsr( + gpsr_manufacturer_type, + gpsr_manufacturer_id, + gpsr_responsible_person_type, + gpsr_responsible_person_id, + gpsr_serial_number, + gpsr_safety_warnings, + ) super().__init__( nullable=UpdateListingRequest.nullable, mandatory=UpdateListingRequest.mandatory, diff --git a/etsy_python/v3/models/ShopEconomicOperator.py b/etsy_python/v3/models/ShopEconomicOperator.py new file mode 100644 index 0000000..9772447 --- /dev/null +++ b/etsy_python/v3/models/ShopEconomicOperator.py @@ -0,0 +1,91 @@ +from typing import List, Optional + +from etsy_python.v3.models.Request import Request + + +class CreateShopEconomicOperatorRequest(Request): + nullable: List[str] = ["phone_country_iso"] + 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: + self.operator_name = operator_name + self.email = email + self.first_line = first_line + self.city = city + self.zip = zip + self.country_iso = country_iso + self.phone = phone + self.second_line = second_line + # Etsy requires state for some countries (e.g. US, CA, AU) and rejects + # the request server-side when it is missing. + self.state = state + self.phone_country_iso = phone_country_iso + self.trademark_name = trademark_name + + super().__init__( + nullable=CreateShopEconomicOperatorRequest.nullable, + mandatory=CreateShopEconomicOperatorRequest.mandatory, + ) + + +class UpdateShopEconomicOperatorRequest(Request): + # Pass "" 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 __init__( + self, + operator_name: Optional[str] = None, + email: Optional[str] = None, + first_line: Optional[str] = None, + second_line: Optional[str] = None, + city: Optional[str] = None, + state: Optional[str] = None, + zip: Optional[str] = None, + country_iso: Optional[str] = None, + phone: Optional[str] = None, + phone_country_iso: Optional[str] = None, + trademark_name: Optional[str] = None, + ) -> None: + self.operator_name = operator_name + self.email = email + self.first_line = first_line + self.second_line = second_line + self.city = city + self.state = state + self.zip = zip + self.country_iso = country_iso + self.phone = phone + self.phone_country_iso = phone_country_iso + self.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..8b1a47a 100644 --- a/etsy_python/v3/models/__init__.py +++ b/etsy_python/v3/models/__init__.py @@ -24,6 +24,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..4329f04 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 @@ -14,6 +15,14 @@ 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..081286a 100644 --- a/tests/test_listing_models.py +++ b/tests/test_listing_models.py @@ -2,8 +2,9 @@ import pytest -from etsy_python.v3.enums.Listing import WhenMade, WhoMade +from etsy_python.v3.enums.Listing import GPSROperatorType, WhenMade, WhoMade from etsy_python.v3.models.Listing import ( + GPSR_FIELDS, CreateDraftListingRequest, CreateListingTranslationRequest, UpdateListingRequest, @@ -573,3 +574,55 @@ def test_ecgt_does_not_warn(self): **self._create_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( + **TestECGTFields._create_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_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} diff --git a/tests/test_remaining_resources.py b/tests/test_remaining_resources.py index d46cc18..ea9dfbe 100644 --- a/tests/test_remaining_resources.py +++ b/tests/test_remaining_resources.py @@ -792,7 +792,10 @@ 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, match=r"removed getListingOffering"): + resource.get_listing_offering( + MOCK_LISTING_ID, MOCK_PRODUCT_ID, MOCK_OFFERING_ID + ) 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_shop_economic_operator.py b/tests/test_shop_economic_operator.py new file mode 100644 index 0000000..af18625 --- /dev/null +++ b/tests/test_shop_economic_operator.py @@ -0,0 +1,110 @@ +import pytest + +from etsy_python.v3.models.ShopEconomicOperator import ( + 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) + + +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_empty_string_clears_nullable_fields(self): + result = UpdateShopEconomicOperatorRequest( + second_line="", state="", phone_country_iso="", trademark_name="" + ).get_dict() + assert result == { + "second_line": None, + "state": None, + "phone_country_iso": None, + "trademark_name": None, + } + + +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, + ) From d30c0e4b932b6c5005654d7adec6f095cf246050 Mon Sep 17 00:00:00 2001 From: Amit Ray <51674969+amitray007@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:51:48 +0530 Subject: [PATCH 2/6] fix: address code review feedback - Add a CLEAR sentinel for sending null on nullable fields, and share one is_null_value rule between Request.get_nulled and todict. Request rejects CLEAR on a non-nullable field. "", [] and 0 still clear. - Type the GPSR listing fields and the nullable economic operator fields to accept CLEAR. - Assign the GPSR and economic operator fields by name through Request._assign_fields, so a misordered argument fails instead of landing in the wrong field. - Reject blank required economic operator fields locally, which Etsy would reject with a 400. - Warn when ListingOfferingResource is constructed, not only when get_listing_offering is called. - Give the listing model tests a module-level create helper. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- etsy_python/v3/common/Utils.py | 26 +++- etsy_python/v3/models/Listing.py | 94 +++++++-------- etsy_python/v3/models/Request.py | 27 ++++- etsy_python/v3/models/ShopEconomicOperator.py | 111 +++++++++++++----- etsy_python/v3/models/__init__.py | 1 + etsy_python/v3/resources/ListingOffering.py | 9 ++ tests/test_listing_models.py | 45 ++++--- tests/test_remaining_resources.py | 7 +- tests/test_request_model.py | 13 ++ tests/test_shop_economic_operator.py | 25 +++- tests/test_utils.py | 15 +++ 12 files changed, 262 insertions(+), 113 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 31f8649..7bddada 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. ### Request Flow diff --git a/etsy_python/v3/common/Utils.py b/etsy_python/v3/common/Utils.py index 6bd71e0..28af3a1 100644 --- a/etsy_python/v3/common/Utils.py +++ b/etsy_python/v3/common/Utils.py @@ -3,6 +3,29 @@ 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" + + +#: Pass as the value of a nullable request field to send ``null`` and clear the +#: stored value. ``""``, ``[]`` and ``0`` (but not ``False``) do the same for +#: backward compatibility; ``CLEAR`` states the intent and fits the type hints. +CLEAR = Clear() + + +def is_null_value(value: Any) -> bool: + """True for the values a nullable request field sends as JSON ``null``.""" + return ( + value is 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. @@ -72,8 +95,7 @@ def todict( [ # _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))) + if key not in nullable or not is_null_value(value) else (key, None) for key, value in obj.__dict__.items() if not callable(value) diff --git a/etsy_python/v3/models/Listing.py b/etsy_python/v3/models/Listing.py index d6b35c3..0f8ad54 100644 --- a/etsy_python/v3/models/Listing.py +++ b/etsy_python/v3/models/Listing.py @@ -1,6 +1,7 @@ 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 from etsy_python.v3.enums.Listing import ( GPSROperatorType, WhoMade, @@ -197,11 +198,10 @@ def _store_ecgt( #: GPSR economic operator fields, shared by createDraftListing and updateListing. -#: All are optional and nullable: pass ``""`` (or ``0`` for an id) 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. +#: 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", @@ -212,28 +212,8 @@ def _store_ecgt( ) -class _GPSRFieldsMixin: - """Storage for the GPSR economic operator listing fields. See ``GPSR_FIELDS``.""" - - def _store_gpsr( - self, - gpsr_manufacturer_type: Optional[GPSROperatorType], - gpsr_manufacturer_id: Optional[int], - gpsr_responsible_person_type: Optional[GPSROperatorType], - gpsr_responsible_person_id: Optional[int], - gpsr_serial_number: Optional[str], - gpsr_safety_warnings: Optional[str], - ) -> None: - self.gpsr_manufacturer_type = gpsr_manufacturer_type - self.gpsr_manufacturer_id = gpsr_manufacturer_id - self.gpsr_responsible_person_type = gpsr_responsible_person_type - self.gpsr_responsible_person_id = gpsr_responsible_person_id - self.gpsr_serial_number = gpsr_serial_number - self.gpsr_safety_warnings = gpsr_safety_warnings - - class CreateDraftListingRequest( - _PersonalizationFieldsMixin, _ECGTFieldsMixin, _GPSRFieldsMixin, Request + _PersonalizationFieldsMixin, _ECGTFieldsMixin, Request ): nullable = [ "shipping_profile_id", @@ -308,12 +288,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: Optional[GPSROperatorType] = None, - gpsr_manufacturer_id: Optional[int] = None, - gpsr_responsible_person_type: Optional[GPSROperatorType] = None, - gpsr_responsible_person_id: Optional[int] = None, - gpsr_serial_number: Optional[str] = None, - gpsr_safety_warnings: 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 @@ -359,13 +339,16 @@ def __init__( ecgt_after_sales_service_info, ecgt_software_update_details, ) - self._store_gpsr( - gpsr_manufacturer_type, - gpsr_manufacturer_id, - gpsr_responsible_person_type, - gpsr_responsible_person_id, - gpsr_serial_number, - gpsr_safety_warnings, + self._assign_fields( + GPSR_FIELDS, + { + "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, @@ -374,7 +357,7 @@ def __init__( class UpdateListingRequest( - _PersonalizationFieldsMixin, _ECGTFieldsMixin, _GPSRFieldsMixin, Request + _PersonalizationFieldsMixin, _ECGTFieldsMixin, Request ): nullable: List[str] = [ "materials", @@ -433,12 +416,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: Optional[GPSROperatorType] = None, - gpsr_manufacturer_id: Optional[int] = None, - gpsr_responsible_person_type: Optional[GPSROperatorType] = None, - gpsr_responsible_person_id: Optional[int] = None, - gpsr_serial_number: Optional[str] = None, - gpsr_safety_warnings: 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 @@ -482,13 +465,16 @@ def __init__( ecgt_after_sales_service_info, ecgt_software_update_details, ) - self._store_gpsr( - gpsr_manufacturer_type, - gpsr_manufacturer_id, - gpsr_responsible_person_type, - gpsr_responsible_person_id, - gpsr_serial_number, - gpsr_safety_warnings, + self._assign_fields( + GPSR_FIELDS, + { + "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, diff --git a/etsy_python/v3/models/Request.py b/etsy_python/v3/models/Request.py index 910d56d..062b9d1 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._reject_clear_on_non_nullable() def check_mandatory(self) -> bool: # Keys must match the attribute name (e.g. "_type", not "type"). @@ -25,11 +26,31 @@ def check_mandatory(self) -> bool: return False return True + def _reject_clear_on_non_nullable(self) -> None: + misplaced = [ + key + for key, value in self.__dict__.items() + if value is 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: diff --git a/etsy_python/v3/models/ShopEconomicOperator.py b/etsy_python/v3/models/ShopEconomicOperator.py index 9772447..50ae736 100644 --- a/etsy_python/v3/models/ShopEconomicOperator.py +++ b/etsy_python/v3/models/ShopEconomicOperator.py @@ -1,10 +1,51 @@ -from typing import List, Optional +from typing import Any, Dict, 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", +) -class CreateShopEconomicOperatorRequest(Request): +#: Required on create. Etsy rejects a blank value for these on create and update. +NON_BLANK_FIELDS = ( + "operator_name", + "email", + "first_line", + "city", + "zip", + "country_iso", + "phone", +) + + +class _ShopEconomicOperatorRequest(Request): + def _store_operator_fields(self, values: Dict[str, Any]) -> None: + self._assign_fields(OPERATOR_FIELDS, values) + blank = [ + name + for name in NON_BLANK_FIELDS + if isinstance(values[name], str) and not values[name].strip() + ] + if blank: + raise ValueError("cannot be blank: " + ", ".join(blank)) + + +class CreateShopEconomicOperatorRequest(_ShopEconomicOperatorRequest): nullable: List[str] = ["phone_country_iso"] + # A literal list, not list(NON_BLANK_FIELDS): scripts/audit_sdk.py reads it + # from the source. mandatory: List[str] = [ "operator_name", "email", @@ -29,28 +70,31 @@ def __init__( phone_country_iso: Optional[str] = None, trademark_name: Optional[str] = None, ) -> None: - self.operator_name = operator_name - self.email = email - self.first_line = first_line - self.city = city - self.zip = zip - self.country_iso = country_iso - self.phone = phone - self.second_line = second_line # Etsy requires state for some countries (e.g. US, CA, AU) and rejects # the request server-side when it is missing. - self.state = state - self.phone_country_iso = phone_country_iso - self.trademark_name = trademark_name - + 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(Request): - # Pass "" for one of these to send null and clear the stored value. +class UpdateShopEconomicOperatorRequest(_ShopEconomicOperatorRequest): + # Pass CLEAR for one of these to send null and clear the stored value. nullable: List[str] = [ "second_line", "state", @@ -64,27 +108,30 @@ def __init__( operator_name: Optional[str] = None, email: Optional[str] = None, first_line: Optional[str] = None, - second_line: Optional[str] = None, + second_line: Union[str, Clear, None] = None, city: Optional[str] = None, - state: 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: Optional[str] = None, - trademark_name: Optional[str] = None, + phone_country_iso: Union[str, Clear, None] = None, + trademark_name: Union[str, Clear, None] = None, ) -> None: - self.operator_name = operator_name - self.email = email - self.first_line = first_line - self.second_line = second_line - self.city = city - self.state = state - self.zip = zip - self.country_iso = country_iso - self.phone = phone - self.phone_country_iso = phone_country_iso - self.trademark_name = trademark_name - + 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 8b1a47a..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, diff --git a/etsy_python/v3/resources/ListingOffering.py b/etsy_python/v3/resources/ListingOffering.py index 4329f04..2094091 100644 --- a/etsy_python/v3/resources/ListingOffering.py +++ b/etsy_python/v3/resources/ListingOffering.py @@ -11,6 +11,15 @@ 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, diff --git a/tests/test_listing_models.py b/tests/test_listing_models.py index 081286a..c584df7 100644 --- a/tests/test_listing_models.py +++ b/tests/test_listing_models.py @@ -2,6 +2,7 @@ import pytest +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, @@ -490,24 +491,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, @@ -544,7 +545,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", @@ -571,7 +572,7 @@ 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 == [] @@ -602,7 +603,7 @@ def _expected_wire(): def test_create_serializes_gpsr_fields(self): result = CreateDraftListingRequest( - **TestECGTFields._create_kwargs(), **self._gpsr_kwargs() + **_draft_listing_kwargs(), **self._gpsr_kwargs() ).get_dict() for field, value in self._expected_wire().items(): assert result[field] == value @@ -616,7 +617,17 @@ 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_empty_values_clear_gpsr_fields(self): + 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, diff --git a/tests/test_remaining_resources.py b/tests/test_remaining_resources.py index ea9dfbe..9aa50e8 100644 --- a/tests/test_remaining_resources.py +++ b/tests/test_remaining_resources.py @@ -791,11 +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) - with pytest.warns(DeprecationWarning, match=r"removed getListingOffering"): + 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 index af18625..5061719 100644 --- a/tests/test_shop_economic_operator.py +++ b/tests/test_shop_economic_operator.py @@ -1,5 +1,6 @@ import pytest +from etsy_python.v3.common.Utils import CLEAR from etsy_python.v3.models.ShopEconomicOperator import ( CreateShopEconomicOperatorRequest, UpdateShopEconomicOperatorRequest, @@ -51,14 +52,25 @@ def test_missing_required_field_raises(self, field): 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} + ) + + 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_empty_string_clears_nullable_fields(self): + def test_clear_sends_null_for_nullable_fields(self): result = UpdateShopEconomicOperatorRequest( - second_line="", state="", phone_country_iso="", trademark_name="" + second_line=CLEAR, + state=CLEAR, + phone_country_iso=CLEAR, + trademark_name=CLEAR, ).get_dict() assert result == { "second_line": None, @@ -67,6 +79,14 @@ def test_empty_string_clears_nullable_fields(self): "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): @@ -108,3 +128,4 @@ def test_update_shop_economic_operator(self, mock_session): method=Method.PATCH, payload=payload, ) + diff --git a/tests/test_utils.py b/tests/test_utils.py index a78a2b1..86c3d1a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -3,8 +3,10 @@ import pytest from etsy_python.v3.common.Utils import ( + CLEAR, generate_bytes_from_file, generate_get_uri, + is_null_value, join_ids, todict, ) @@ -211,3 +213,16 @@ 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" From e7431de1dff8a26cde4c56ef76974aaa06a471ca Mon Sep 17 00:00:00 2001 From: Amit Ray <51674969+amitray007@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:05:06 +0530 Subject: [PATCH 3/6] fix: harden CLEAR serialization and GPSR request validation - Keep CLEAR a singleton across copy, deepcopy and pickle, and serialize any Clear as null in todict, so a copied request no longer sends {}. - Re-check for CLEAR on non-nullable fields in get_dict, so a field set to CLEAR after construction raises instead of sending {}. - Reject blank or cleared fields on createShopEconomicOperator. A new record has nothing to clear. - Reject GPSR type/id pairs Etsy rejects: an id without type economic_operator, and type economic_operator without an id on create. - Test that the create mandatory list matches NON_BLANK_FIELDS. Co-Authored-By: Claude Opus 5.5 --- etsy_python/v3/common/Utils.py | 15 +++- etsy_python/v3/models/Listing.py | 70 +++++++++++++------ etsy_python/v3/models/Request.py | 6 +- etsy_python/v3/models/ShopEconomicOperator.py | 19 +++-- tests/test_listing_models.py | 44 ++++++++++++ tests/test_shop_economic_operator.py | 10 +++ tests/test_utils.py | 10 +++ 7 files changed, 141 insertions(+), 33 deletions(-) diff --git a/etsy_python/v3/common/Utils.py b/etsy_python/v3/common/Utils.py index 28af3a1..1c876d8 100644 --- a/etsy_python/v3/common/Utils.py +++ b/etsy_python/v3/common/Utils.py @@ -9,6 +9,17 @@ class Clear: 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. ``""``, ``[]`` and ``0`` (but not ``False``) do the same for @@ -19,7 +30,7 @@ def __repr__(self) -> str: def is_null_value(value: Any) -> bool: """True for the values a nullable request field sends as JSON ``null``.""" return ( - value is CLEAR + isinstance(value, Clear) or value == [] or value == "" or (value == 0 and not isinstance(value, bool)) @@ -86,6 +97,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): diff --git a/etsy_python/v3/models/Listing.py b/etsy_python/v3/models/Listing.py index 0f8ad54..7d79523 100644 --- a/etsy_python/v3/models/Listing.py +++ b/etsy_python/v3/models/Listing.py @@ -1,7 +1,7 @@ import warnings from typing import List, Optional, Dict, Any, Union -from etsy_python.v3.common.Utils import Clear +from etsy_python.v3.common.Utils import Clear, is_null_value from etsy_python.v3.enums.Listing import ( GPSROperatorType, WhoMade, @@ -212,6 +212,32 @@ def _store_ecgt( ) +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 in turn needs an + id on create. On update an unset type is skipped: Etsy checks the id + against the type already stored on the listing. + """ + 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 + ) + if is_operator and 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 CreateDraftListingRequest( _PersonalizationFieldsMixin, _ECGTFieldsMixin, Request ): @@ -339,17 +365,16 @@ def __init__( ecgt_after_sales_service_info, ecgt_software_update_details, ) - self._assign_fields( - GPSR_FIELDS, - { - "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, - }, - ) + gpsr_fields = { + "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, + } + _check_gpsr_operator_ids(gpsr_fields, creating=True) + self._assign_fields(GPSR_FIELDS, gpsr_fields) super().__init__( nullable=CreateDraftListingRequest.nullable, mandatory=CreateDraftListingRequest.mandatory, @@ -465,17 +490,16 @@ def __init__( ecgt_after_sales_service_info, ecgt_software_update_details, ) - self._assign_fields( - GPSR_FIELDS, - { - "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, - }, - ) + gpsr_fields = { + "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, + } + _check_gpsr_operator_ids(gpsr_fields, creating=False) + self._assign_fields(GPSR_FIELDS, gpsr_fields) 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 062b9d1..4437b61 100644 --- a/etsy_python/v3/models/Request.py +++ b/etsy_python/v3/models/Request.py @@ -1,6 +1,6 @@ from typing import Any, Dict, List, Optional, Sequence -from etsy_python.v3.common.Utils import CLEAR, is_null_value, todict +from etsy_python.v3.common.Utils import Clear, is_null_value, todict class Request: @@ -30,7 +30,7 @@ def _reject_clear_on_non_nullable(self) -> None: misplaced = [ key for key, value in self.__dict__.items() - if value is CLEAR and key not in self._nullable + if isinstance(value, Clear) and key not in self._nullable ] if misplaced: raise ValueError( @@ -54,5 +54,7 @@ def get_nulled(self) -> List[str]: ] def get_dict(self) -> Any: + # Repeated here because a field can be set to CLEAR after construction. + self._reject_clear_on_non_nullable() 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 index 50ae736..d9a5f14 100644 --- a/etsy_python/v3/models/ShopEconomicOperator.py +++ b/etsy_python/v3/models/ShopEconomicOperator.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Sequence, Union from etsy_python.v3.common.Utils import Clear from etsy_python.v3.models.Request import Request @@ -18,7 +18,7 @@ "trademark_name", ) -#: Required on create. Etsy rejects a blank value for these on create and update. +#: Required on create, and never blank: Etsy rejects a blank value on update too. NON_BLANK_FIELDS = ( "operator_name", "email", @@ -31,11 +31,13 @@ class _ShopEconomicOperatorRequest(Request): - def _store_operator_fields(self, values: Dict[str, Any]) -> None: + def _store_operator_fields( + self, values: Dict[str, Any], non_blank: Sequence[str] + ) -> None: self._assign_fields(OPERATOR_FIELDS, values) blank = [ name - for name in NON_BLANK_FIELDS + for name in non_blank if isinstance(values[name], str) and not values[name].strip() ] if blank: @@ -43,7 +45,8 @@ def _store_operator_fields(self, values: Dict[str, Any]) -> None: class CreateShopEconomicOperatorRequest(_ShopEconomicOperatorRequest): - nullable: List[str] = ["phone_country_iso"] + # A new record has nothing to clear, so every field rejects a blank value. + nullable: List[str] = [] # A literal list, not list(NON_BLANK_FIELDS): scripts/audit_sdk.py reads it # from the source. mandatory: List[str] = [ @@ -85,7 +88,8 @@ def __init__( "phone": phone, "phone_country_iso": phone_country_iso, "trademark_name": trademark_name, - } + }, + non_blank=OPERATOR_FIELDS, ) super().__init__( nullable=CreateShopEconomicOperatorRequest.nullable, @@ -130,7 +134,8 @@ def __init__( "phone": phone, "phone_country_iso": phone_country_iso, "trademark_name": trademark_name, - } + }, + non_blank=NON_BLANK_FIELDS, ) super().__init__( nullable=UpdateShopEconomicOperatorRequest.nullable, diff --git a/tests/test_listing_models.py b/tests/test_listing_models.py index c584df7..1e490bb 100644 --- a/tests/test_listing_models.py +++ b/tests/test_listing_models.py @@ -1,3 +1,4 @@ +import copy import warnings import pytest @@ -637,3 +638,46 @@ def test_legacy_empty_values_clear_gpsr_fields(self): 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_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_shop_economic_operator.py b/tests/test_shop_economic_operator.py index 5061719..abe9587 100644 --- a/tests/test_shop_economic_operator.py +++ b/tests/test_shop_economic_operator.py @@ -2,6 +2,7 @@ from etsy_python.v3.common.Utils import CLEAR from etsy_python.v3.models.ShopEconomicOperator import ( + NON_BLANK_FIELDS, CreateShopEconomicOperatorRequest, UpdateShopEconomicOperatorRequest, ) @@ -60,6 +61,15 @@ def test_blank_required_field_raises(self, 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(NON_BLANK_FIELDS) + + class TestUpdateShopEconomicOperatorRequest: def test_sends_only_set_fields(self): result = UpdateShopEconomicOperatorRequest(email="new@example.com").get_dict() diff --git a/tests/test_utils.py b/tests/test_utils.py index 86c3d1a..94e649a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,3 +1,5 @@ +import copy +import pickle from enum import Enum import pytest @@ -226,3 +228,11 @@ def test_non_null_values(self, 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]} From a7200fe624177f90db4f4975c8910048aad88381 Mon Sep 17 00:00:00 2001 From: Amit Ray <51674969+amitray007@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:22:21 +0530 Subject: [PATCH 4/6] fix: address third code review round - Send "type": null, not "_type": null, when the listing type is cleared. todict now nulls exactly the keys get_nulled selected, with no second null check. - Add Request.validate(), run on construction and in get_dict, so checks also catch fields reassigned after construction. The GPSR type/id check and the economic operator checks now live there. - Move the GPSR fields back into a _GPSRFieldsMixin, and require an id whenever type economic_operator is sent, on update too. - Economic operator requests reject any blank or whitespace-only string (clear with CLEAR), and an update with no fields. - Name the field without its storage underscore in the CLEAR error, and correct the CLEAR docs about type hints. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- etsy_python/v3/common/Utils.py | 17 ++-- etsy_python/v3/models/Listing.py | 66 ++++++++++------ etsy_python/v3/models/Request.py | 15 +++- etsy_python/v3/models/ShopEconomicOperator.py | 78 ++++++++++--------- tests/test_listing_models.py | 23 ++++-- tests/test_shop_economic_operator.py | 22 +++++- 7 files changed, 143 insertions(+), 80 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7bddada..745caa6 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`. 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. +**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/etsy_python/v3/common/Utils.py b/etsy_python/v3/common/Utils.py index 1c876d8..99a7d42 100644 --- a/etsy_python/v3/common/Utils.py +++ b/etsy_python/v3/common/Utils.py @@ -22,8 +22,9 @@ def __reduce__(self) -> str: #: Pass as the value of a nullable request field to send ``null`` and clear the -#: stored value. ``""``, ``[]`` and ``0`` (but not ``False``) do the same for -#: backward compatibility; ``CLEAR`` states the intent and fits the type hints. +#: 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() @@ -88,6 +89,11 @@ 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. Keys in ``nullable`` are sent as ``null``. + + ``Request.get_dict`` passes ``get_nulled()`` as ``nullable``, so the null + rule (``is_null_value``) is applied once, there. + """ if nullable is None: nullable = [] if isinstance(obj, dict): @@ -107,9 +113,10 @@ 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 not is_null_value(value) - else (key, None) + ( + "type" if key == "_type" else key, + None if key in nullable 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/models/Listing.py b/etsy_python/v3/models/Listing.py index 7d79523..4e38e6e 100644 --- a/etsy_python/v3/models/Listing.py +++ b/etsy_python/v3/models/Listing.py @@ -215,8 +215,8 @@ def _store_ecgt( 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 in turn needs an - id on create. On update an unset type is skipped: Etsy checks the id + An id is valid only with type ``economic_operator``, and that type needs an + id. On update an id sent without a type is not checked here: Etsy checks it against the type already stored on the listing. """ for role in ("gpsr_manufacturer", "gpsr_responsible_person"): @@ -228,7 +228,7 @@ def _check_gpsr_operator_ids(values: Dict[str, Any], creating: bool) -> None: is_operator = ( getattr(kind, "value", kind) == GPSROperatorType.ECONOMIC_OPERATOR.value ) - if is_operator and creating and not has_id: + if is_operator and not has_id: raise ValueError( f"{role}_id is required when {role}_type is economic_operator" ) @@ -238,9 +238,27 @@ def _check_gpsr_operator_ids(values: Dict[str, Any], creating: bool) -> None: ) +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, Request + _PersonalizationFieldsMixin, _ECGTFieldsMixin, _GPSRFieldsMixin, Request ): + _gpsr_creating = True + nullable = [ "shipping_profile_id", "return_policy_id", @@ -365,16 +383,14 @@ def __init__( ecgt_after_sales_service_info, ecgt_software_update_details, ) - gpsr_fields = { - "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, - } - _check_gpsr_operator_ids(gpsr_fields, creating=True) - self._assign_fields(GPSR_FIELDS, gpsr_fields) + 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, @@ -382,8 +398,10 @@ def __init__( class UpdateListingRequest( - _PersonalizationFieldsMixin, _ECGTFieldsMixin, Request + _PersonalizationFieldsMixin, _ECGTFieldsMixin, _GPSRFieldsMixin, Request ): + _gpsr_creating = False + nullable: List[str] = [ "materials", "shipping_profile_id", @@ -490,16 +508,14 @@ def __init__( ecgt_after_sales_service_info, ecgt_software_update_details, ) - gpsr_fields = { - "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, - } - _check_gpsr_operator_ids(gpsr_fields, creating=False) - self._assign_fields(GPSR_FIELDS, gpsr_fields) + 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 4437b61..a27dcaf 100644 --- a/etsy_python/v3/models/Request.py +++ b/etsy_python/v3/models/Request.py @@ -13,7 +13,7 @@ def __init__( self._mandatory = mandatory if mandatory is not None else [] if not self.check_mandatory(): raise ValueError - self._reject_clear_on_non_nullable() + self.validate() def check_mandatory(self) -> bool: # Keys must match the attribute name (e.g. "_type", not "type"). @@ -26,9 +26,17 @@ 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 + key.lstrip("_") for key, value in self.__dict__.items() if isinstance(value, Clear) and key not in self._nullable ] @@ -54,7 +62,6 @@ def get_nulled(self) -> List[str]: ] def get_dict(self) -> Any: - # Repeated here because a field can be set to CLEAR after construction. - self._reject_clear_on_non_nullable() + 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 index d9a5f14..f3adad2 100644 --- a/etsy_python/v3/models/ShopEconomicOperator.py +++ b/etsy_python/v3/models/ShopEconomicOperator.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional, Sequence, Union +from typing import Any, List, Optional, Union from etsy_python.v3.common.Utils import Clear from etsy_python.v3.models.Request import Request @@ -18,8 +18,8 @@ "trademark_name", ) -#: Required on create, and never blank: Etsy rejects a blank value on update too. -NON_BLANK_FIELDS = ( +#: Required on create. +REQUIRED_FIELDS = ( "operator_name", "email", "first_line", @@ -31,23 +31,26 @@ class _ShopEconomicOperatorRequest(Request): - def _store_operator_fields( - self, values: Dict[str, Any], non_blank: Sequence[str] - ) -> None: + """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 non_blank - if isinstance(values[name], str) and not values[name].strip() + 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, so every field rejects a blank value. + # A new record has nothing to clear. nullable: List[str] = [] - # A literal list, not list(NON_BLANK_FIELDS): scripts/audit_sdk.py reads it + # A literal list, not list(REQUIRED_FIELDS): scripts/audit_sdk.py reads it # from the source. mandatory: List[str] = [ "operator_name", @@ -76,20 +79,17 @@ def __init__( # 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, - }, - non_blank=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, @@ -107,6 +107,11 @@ class UpdateShopEconomicOperatorRequest(_ShopEconomicOperatorRequest): ] 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, @@ -122,20 +127,17 @@ def __init__( 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, - }, - non_blank=NON_BLANK_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, diff --git a/tests/test_listing_models.py b/tests/test_listing_models.py index 1e490bb..3ee16e3 100644 --- a/tests/test_listing_models.py +++ b/tests/test_listing_models.py @@ -672,11 +672,24 @@ def test_update_rejects_id_with_non_operator_type(self, kind): 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_update_requires_id_for_economic_operator(self): + with pytest.raises(ValueError, match="gpsr_manufacturer_id is required"): + UpdateListingRequest(gpsr_manufacturer_type=self.MANUFACTURER) + + 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) def test_update_allows_id_without_type(self): result = UpdateListingRequest(gpsr_manufacturer_id=5).get_dict() diff --git a/tests/test_shop_economic_operator.py b/tests/test_shop_economic_operator.py index abe9587..0b753d7 100644 --- a/tests/test_shop_economic_operator.py +++ b/tests/test_shop_economic_operator.py @@ -2,7 +2,7 @@ from etsy_python.v3.common.Utils import CLEAR from etsy_python.v3.models.ShopEconomicOperator import ( - NON_BLANK_FIELDS, + REQUIRED_FIELDS, CreateShopEconomicOperatorRequest, UpdateShopEconomicOperatorRequest, ) @@ -67,7 +67,7 @@ def test_optional_field_cannot_be_blank_or_cleared(self, value): CreateShopEconomicOperatorRequest(**_create_kwargs(), state=value) def test_mandatory_matches_non_blank_fields(self): - assert CreateShopEconomicOperatorRequest.mandatory == list(NON_BLANK_FIELDS) + assert CreateShopEconomicOperatorRequest.mandatory == list(REQUIRED_FIELDS) class TestUpdateShopEconomicOperatorRequest: @@ -139,3 +139,21 @@ def test_update_shop_economic_operator(self, mock_session): 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() From 60429c09f73044bde4678487ea0f75b3b8d41272 Mon Sep 17 00:00:00 2001 From: Amit Ray <51674969+amitray007@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:29:02 +0530 Subject: [PATCH 5/6] fix: leave update-time GPSR pairing to Etsy; make validate protected - On update, a GPSR type of economic_operator without an id is no longer rejected locally. The listing may already store the id, so Etsy decides. Create still requires the id. - Rename Request.validate() to _validate(), so it cannot collide with a validate method on a user's own Request subclass. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- etsy_python/v3/models/Listing.py | 12 ++++++------ etsy_python/v3/models/Request.py | 6 +++--- etsy_python/v3/models/ShopEconomicOperator.py | 8 ++++---- tests/test_listing_models.py | 8 +++++--- 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 745caa6..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`. 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. +**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/etsy_python/v3/models/Listing.py b/etsy_python/v3/models/Listing.py index 4e38e6e..3f1dbf2 100644 --- a/etsy_python/v3/models/Listing.py +++ b/etsy_python/v3/models/Listing.py @@ -215,9 +215,9 @@ def _store_ecgt( 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``, and that type needs an - id. On update an id sent without a type is not checked here: Etsy checks it - against the type already stored on the listing. + An id is valid only with type ``economic_operator``, which needs an id on + create. 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"] @@ -228,7 +228,7 @@ def _check_gpsr_operator_ids(values: Dict[str, Any], creating: bool) -> None: is_operator = ( getattr(kind, "value", kind) == GPSROperatorType.ECONOMIC_OPERATOR.value ) - if is_operator and not has_id: + if is_operator and creating and not has_id: raise ValueError( f"{role}_id is required when {role}_type is economic_operator" ) @@ -249,8 +249,8 @@ class _GPSRFieldsMixin: def _store_gpsr(self, **values: Any) -> None: self._assign_fields(GPSR_FIELDS, values) - def validate(self) -> None: - super().validate() + def _validate(self) -> None: + super()._validate() _check_gpsr_operator_ids(vars(self), creating=self._gpsr_creating) diff --git a/etsy_python/v3/models/Request.py b/etsy_python/v3/models/Request.py index a27dcaf..2e8f597 100644 --- a/etsy_python/v3/models/Request.py +++ b/etsy_python/v3/models/Request.py @@ -13,7 +13,7 @@ def __init__( self._mandatory = mandatory if mandatory is not None else [] if not self.check_mandatory(): raise ValueError - self.validate() + self._validate() def check_mandatory(self) -> bool: # Keys must match the attribute name (e.g. "_type", not "type"). @@ -26,7 +26,7 @@ def check_mandatory(self) -> bool: return False return True - def validate(self) -> None: + def _validate(self) -> None: """Raise ValueError for field values Etsy would reject. Runs on construction and again in ``get_dict``, because fields can be @@ -62,6 +62,6 @@ def get_nulled(self) -> List[str]: ] def get_dict(self) -> Any: - self.validate() + 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 index f3adad2..0df9f34 100644 --- a/etsy_python/v3/models/ShopEconomicOperator.py +++ b/etsy_python/v3/models/ShopEconomicOperator.py @@ -36,8 +36,8 @@ class _ShopEconomicOperatorRequest(Request): def _store_operator_fields(self, **values: Any) -> None: self._assign_fields(OPERATOR_FIELDS, values) - def validate(self) -> None: - super().validate() + def _validate(self) -> None: + super()._validate() blank = [ name for name in OPERATOR_FIELDS @@ -107,8 +107,8 @@ class UpdateShopEconomicOperatorRequest(_ShopEconomicOperatorRequest): ] mandatory: List[str] = [] - def validate(self) -> None: - super().validate() + 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") diff --git a/tests/test_listing_models.py b/tests/test_listing_models.py index 3ee16e3..14098dc 100644 --- a/tests/test_listing_models.py +++ b/tests/test_listing_models.py @@ -672,9 +672,11 @@ def test_update_rejects_id_with_non_operator_type(self, kind): gpsr_manufacturer_type=kind, gpsr_manufacturer_id=5 ) - def test_update_requires_id_for_economic_operator(self): - with pytest.raises(ValueError, match="gpsr_manufacturer_id is required"): - UpdateListingRequest(gpsr_manufacturer_type=self.MANUFACTURER) + 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) From 47216f755cb2e60144d642cc836ad1e4810818f2 Mon Sep 17 00:00:00 2001 From: Amit Ray <51674969+amitray007@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:40:22 +0530 Subject: [PATCH 6/6] fix: keep todict value-safe and reject a cleared GPSR id - todict nulls a nullable key only when its value is a null value again. External callers that pass a model's full nullable list no longer wipe real values. The "_type" to "type" key fix stays. - Reject type economic_operator sent with a cleared id (CLEAR or 0), on create and update. Etsy always rejects that pair. Co-Authored-By: Claude Opus 5.5 --- etsy_python/v3/common/Utils.py | 11 +++++++---- etsy_python/v3/models/Listing.py | 8 +++++--- tests/test_listing_models.py | 8 ++++++++ tests/test_utils.py | 4 ++++ 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/etsy_python/v3/common/Utils.py b/etsy_python/v3/common/Utils.py index 99a7d42..c9368c1 100644 --- a/etsy_python/v3/common/Utils.py +++ b/etsy_python/v3/common/Utils.py @@ -89,10 +89,11 @@ 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. Keys in ``nullable`` are sent as ``null``. + """Serialize ``obj`` for the API. - ``Request.get_dict`` passes ``get_nulled()`` as ``nullable``, so the null - rule (``is_null_value``) is applied once, there. + 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 = [] @@ -115,7 +116,9 @@ def todict( # _type -> "type": avoids shadowing Python's builtin in model attrs ( "type" if key == "_type" else key, - None if key in nullable else todict(value, classkey), + 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) diff --git a/etsy_python/v3/models/Listing.py b/etsy_python/v3/models/Listing.py index 3f1dbf2..9bd9c4b 100644 --- a/etsy_python/v3/models/Listing.py +++ b/etsy_python/v3/models/Listing.py @@ -216,8 +216,9 @@ 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. 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. + 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"] @@ -228,7 +229,8 @@ def _check_gpsr_operator_ids(values: Dict[str, Any], creating: bool) -> None: is_operator = ( getattr(kind, "value", kind) == GPSROperatorType.ECONOMIC_OPERATOR.value ) - if is_operator and creating and not has_id: + 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" ) diff --git a/tests/test_listing_models.py b/tests/test_listing_models.py index 14098dc..826f6f9 100644 --- a/tests/test_listing_models.py +++ b/tests/test_listing_models.py @@ -693,6 +693,14 @@ def test_clear_error_names_the_field_without_underscore(self): 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_utils.py b/tests/test_utils.py index 94e649a..b209bdf 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -117,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"])