From c49513578728b8ae29b0ea50f2248d763be0e1ac Mon Sep 17 00:00:00 2001 From: Amit Ray <51674969+amitray007@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:12:27 +0530 Subject: [PATCH 1/2] feat: sync SDK with Etsy API spec (ECGT guarantee fields, multi-video) Audit against the 2026-09 Etsy OAS spec. Coverage stays at 100% (105/105 operations); all findings below come from this spec update. Must fix: - Add the seven EU commercial guarantee (ECGT/GPSR) request-body fields to CreateDraftListingRequest and UpdateListingRequest: ecgt_garan_brand, ecgt_garan_model, ecgt_garan_years, ecgt_garan_guarantee_details, ecgt_other_commercial_guarantee_details, ecgt_after_sales_service_info and ecgt_software_update_details. All optional and nullable. Callers previously had no way to send EU guarantee data on create or update. Shared by both models via _ECGTFieldsMixin, matching the existing personalization mixin. - Drop the stale "Not in OAS spec" comment on State.REMOVED. The spec added 'removed' to the listing state enum, so the value is now in sync. Should fix: - Add is_multi_video to upload_listing_video. Passing True keeps existing videos; omitting it preserves the former single-video replace behaviour. - Remove three stale entries from specs/audit-ignore.json. They suppressed 'removed' as an SDK-only extra value; the spec now defines it, so they matched nothing and were reported under Stale Ignores. No SDK impact from the response-only changes: ecgt_commercial_guarantee_enabled is server-computed, and rich_description never appeared in a request body. Tests: 440 passed. Baseline updated to the current spec. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KENB6Bxbu2p4YmfpXh4Ehx --- etsy_python/v3/enums/Listing.py | 2 +- etsy_python/v3/models/Listing.py | 84 ++++++++++- etsy_python/v3/resources/ListingVideo.py | 19 ++- specs/audit-ignore.json | 30 ---- specs/baseline.json | 179 +++++++++++++++++++++-- tests/test_listing_models.py | 85 +++++++++++ tests/test_remaining_resources.py | 15 ++ 7 files changed, 367 insertions(+), 47 deletions(-) diff --git a/etsy_python/v3/enums/Listing.py b/etsy_python/v3/enums/Listing.py index 71e35b4..d8e0d57 100644 --- a/etsy_python/v3/enums/Listing.py +++ b/etsy_python/v3/enums/Listing.py @@ -58,7 +58,7 @@ class State(Enum): SOLD_OUT = "sold_out" DRAFT = "draft" EXPIRED = "expired" - REMOVED = "removed" # Not in OAS spec; kept for backward compatibility, may be removed in next major version + REMOVED = "removed" class VideoState(Enum): diff --git a/etsy_python/v3/models/Listing.py b/etsy_python/v3/models/Listing.py index bd02b38..04a12e0 100644 --- a/etsy_python/v3/models/Listing.py +++ b/etsy_python/v3/models/Listing.py @@ -127,7 +127,53 @@ def _store_personalization( ) -class CreateDraftListingRequest(_PersonalizationFieldsMixin, Request): +#: EU commercial guarantee / GPSR fields (ECGT), shared by createDraftListing +#: and updateListing. All are optional and nullable. Etsy silently ignores them +#: for sellers who are not eligible EU traders. ``ecgt_software_update_details`` +#: is ignored for physical listings; the other six are ignored for digital ones. +#: ``ecgt_garan_brand``, ``ecgt_garan_model``, ``ecgt_garan_years`` and +#: ``ecgt_garan_guarantee_details`` are required *together* -- Etsy enforces +#: that server-side and rejects a partial set. +ECGT_FIELDS = ( + "ecgt_garan_brand", + "ecgt_garan_model", + "ecgt_garan_years", + "ecgt_garan_guarantee_details", + "ecgt_other_commercial_guarantee_details", + "ecgt_after_sales_service_info", + "ecgt_software_update_details", +) + + +class _ECGTFieldsMixin: + """Storage for the EU commercial guarantee (ECGT) listing fields. + + Both listing request models accept the same seven fields, so the + assignment lives here instead of being duplicated. See ``ECGT_FIELDS``. + """ + + def _store_ecgt( + self, + ecgt_garan_brand: Optional[str], + ecgt_garan_model: Optional[str], + ecgt_garan_years: Optional[int], + ecgt_garan_guarantee_details: Optional[str], + ecgt_other_commercial_guarantee_details: Optional[str], + ecgt_after_sales_service_info: Optional[str], + ecgt_software_update_details: Optional[str], + ) -> None: + self.ecgt_garan_brand = ecgt_garan_brand + self.ecgt_garan_model = ecgt_garan_model + self.ecgt_garan_years = ecgt_garan_years + self.ecgt_garan_guarantee_details = ecgt_garan_guarantee_details + self.ecgt_other_commercial_guarantee_details = ( + ecgt_other_commercial_guarantee_details + ) + self.ecgt_after_sales_service_info = ecgt_after_sales_service_info + self.ecgt_software_update_details = ecgt_software_update_details + + +class CreateDraftListingRequest(_PersonalizationFieldsMixin, _ECGTFieldsMixin, Request): nullable = [ "shipping_profile_id", "return_policy_id", @@ -146,6 +192,7 @@ class CreateDraftListingRequest(_PersonalizationFieldsMixin, Request): "production_partner_ids", "image_ids", "readiness_state_id", + *ECGT_FIELDS, ] mandatory = [ "quantity", @@ -192,6 +239,13 @@ def __init__( is_taxable: Optional[bool] = None, listing_type: Optional[Type] = None, readiness_state_id: Optional[int] = None, + ecgt_garan_brand: Optional[str] = None, + ecgt_garan_model: Optional[str] = None, + ecgt_garan_years: Optional[int] = None, + ecgt_garan_guarantee_details: Optional[str] = None, + ecgt_other_commercial_guarantee_details: Optional[str] = None, + ecgt_after_sales_service_info: Optional[str] = None, + ecgt_software_update_details: Optional[str] = None, ): self.quantity = quantity self.title = title @@ -228,13 +282,22 @@ def __init__( self.is_taxable = is_taxable self._type = listing_type self.readiness_state_id = readiness_state_id + self._store_ecgt( + ecgt_garan_brand, + ecgt_garan_model, + ecgt_garan_years, + ecgt_garan_guarantee_details, + ecgt_other_commercial_guarantee_details, + ecgt_after_sales_service_info, + ecgt_software_update_details, + ) super().__init__( nullable=CreateDraftListingRequest.nullable, mandatory=CreateDraftListingRequest.mandatory, ) -class UpdateListingRequest(_PersonalizationFieldsMixin, Request): +class UpdateListingRequest(_PersonalizationFieldsMixin, _ECGTFieldsMixin, Request): nullable: List[str] = [ "materials", "shipping_profile_id", @@ -250,6 +313,7 @@ class UpdateListingRequest(_PersonalizationFieldsMixin, Request): "featured_rank", "production_partner_ids", "_type", + *ECGT_FIELDS, ] mandatory: List[str] = [] @@ -284,6 +348,13 @@ def __init__( is_supply: Optional[bool] = None, production_partner_ids: Optional[List[int]] = None, listing_type: Optional[Type] = None, + ecgt_garan_brand: Optional[str] = None, + ecgt_garan_model: Optional[str] = None, + ecgt_garan_years: Optional[int] = None, + ecgt_garan_guarantee_details: Optional[str] = None, + ecgt_other_commercial_guarantee_details: Optional[str] = None, + ecgt_after_sales_service_info: Optional[str] = None, + ecgt_software_update_details: Optional[str] = None, ): self.image_ids = image_ids self.title = title @@ -315,6 +386,15 @@ def __init__( self.is_supply = is_supply self.production_partner_ids = production_partner_ids self._type = listing_type + self._store_ecgt( + ecgt_garan_brand, + ecgt_garan_model, + ecgt_garan_years, + ecgt_garan_guarantee_details, + ecgt_other_commercial_guarantee_details, + ecgt_after_sales_service_info, + ecgt_software_update_details, + ) super().__init__( nullable=UpdateListingRequest.nullable, mandatory=UpdateListingRequest.mandatory, diff --git a/etsy_python/v3/resources/ListingVideo.py b/etsy_python/v3/resources/ListingVideo.py index f6b1095..61724ce 100644 --- a/etsy_python/v3/resources/ListingVideo.py +++ b/etsy_python/v3/resources/ListingVideo.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Union +from typing import Optional, Union from etsy_python.v3.exceptions.RequestException import RequestException from etsy_python.v3.models.Listing import UpdateListingVideoRequest @@ -29,9 +29,22 @@ def get_listing_videos(self, listing_id: int) -> Union[Response, RequestExceptio return self.session.make_request(endpoint) def upload_listing_video( - self, shop_id: int, listing_id: int, listing_video: UpdateListingVideoRequest + self, + shop_id: int, + listing_id: int, + listing_video: UpdateListingVideoRequest, + is_multi_video: Optional[bool] = None, ) -> Union[Response, RequestException]: + """Upload a video to a listing. + + Pass ``is_multi_video=True`` to keep existing videos on the listing. + Omitting it preserves the former single-video behaviour, where the + upload replaces any video already attached. + """ endpoint = f"/shops/{shop_id}/listings/{listing_id}/videos" return self.session.make_request( - endpoint, method=Method.POST, payload=listing_video + endpoint, + method=Method.POST, + payload=listing_video, + query_params={"is_multi_video": is_multi_video}, ) diff --git a/specs/audit-ignore.json b/specs/audit-ignore.json index fa1df39..fa85d21 100644 --- a/specs/audit-ignore.json +++ b/specs/audit-ignore.json @@ -113,26 +113,6 @@ "reason": "By design: the SDK names US_HOLIDAYS (1-11) and CA_HOLIDAYS (12-23) and documents passing integer IDs directly for other regions (24-105), so the full spec enum is intentionally not enumerated. See enums/HolidayPreferences.py.", "added": "2026-06-02" }, - { - "type": "enum_staleness", - "key": "ShopListing.state -> State", - "direction": "extra", - "values": [ - "removed" - ], - "reason": "State.REMOVED is kept for backward compatibility and is not in the OAS response schema. Documented inline in enums/Listing.py; may be removed in the next major version.", - "added": "2026-06-02" - }, - { - "type": "enum_staleness", - "key": "ShopListingWithAssociations.state -> State", - "direction": "extra", - "values": [ - "removed" - ], - "reason": "State.REMOVED is kept for backward compatibility and is not in the OAS response schema. Documented inline in enums/Listing.py; may be removed in the next major version.", - "added": "2026-06-02" - }, { "type": "enum_staleness", "key": "getListing.includes -> Includes", @@ -155,16 +135,6 @@ "reason": "Etsy removed 'Shipping' and 'Inventory' from the includes enum on getListing and getListingsByListingIds (still valid on getListingsByShop). The SDK Includes enum keeps both for backward compatibility and because they remain valid on getListingsByShop, which shares the enum; removing them would be a breaking change. Documented inline in enums/Listing.py.", "added": "2026-07-08" }, - { - "type": "enum_staleness", - "key": "getListingsByShop.state -> State", - "direction": "extra", - "values": [ - "removed" - ], - "reason": "Same State.REMOVED back-compat value as ShopListing.state; surfaced additionally as the state query parameter on getListingsByShop now that parameter-level enums are audited. Documented inline in enums/Listing.py.", - "added": "2026-07-08" - }, { "type": "enum_staleness", "key": "updateHolidayPreferences.holiday_id -> CA_HOLIDAYS", diff --git a/specs/baseline.json b/specs/baseline.json index 9747287..77925ba 100644 --- a/specs/baseline.json +++ b/specs/baseline.json @@ -510,6 +510,42 @@ "minimum": 1 } }, + "ecgt_garan_brand": { + "type": "string", + "description": "The brand or trademark name for the EU commercial guarantee (required under GPSR/ECGT for eligible EU traders). Maximum 25 characters. See the [Etsy Seller Handbook](https://help.etsy.com/hc/articles/43191692248343) for details. If any one of ecgt_garan_brand, ecgt_garan_model, ecgt_garan_years, or ecgt_garan_guarantee_details is provided and non-empty, all four are required. Silently ignored for digital listings and for sellers who are not eligible EU traders.", + "nullable": true + }, + "ecgt_garan_years": { + "type": "integer", + "description": "Duration of the EU commercial guarantee in whole years (minimum 3, maximum 99). Required together with ecgt_garan_brand, ecgt_garan_model, and ecgt_garan_guarantee_details. Silently ignored for digital listings and for sellers who are not eligible EU traders.", + "format": "int64", + "nullable": true + }, + "ecgt_garan_model": { + "type": "string", + "description": "The product model or reference number for the EU commercial guarantee label. Maximum 20 characters. Required together with ecgt_garan_brand, ecgt_garan_years, and ecgt_garan_guarantee_details. Silently ignored for digital listings and for sellers who are not eligible EU traders.", + "nullable": true + }, + "ecgt_garan_guarantee_details": { + "type": "string", + "description": "Free-text description of the EU commercial guarantee terms and coverage. Maximum 255 characters. Required together with ecgt_garan_brand, ecgt_garan_model, and ecgt_garan_years. Silently ignored for digital listings and for sellers who are not eligible EU traders.", + "nullable": true + }, + "ecgt_other_commercial_guarantee_details": { + "type": "string", + "description": "Free-text details of any additional commercial guarantee or warranty beyond the primary EU commercial guarantee. Maximum 255 characters. Silently ignored for digital listings and for sellers who are not eligible EU traders.", + "nullable": true + }, + "ecgt_after_sales_service_info": { + "type": "string", + "description": "After-sales service, repairability, or eco-friendly delivery information required under EU GPSR/ECGT regulations. Maximum 255 characters. Silently ignored for digital listings and for sellers who are not eligible EU traders.", + "nullable": true + }, + "ecgt_software_update_details": { + "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 + }, "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'." @@ -643,6 +679,7 @@ "inactive", "sold_out", "draft", + "removed", "expired" ], "default": "active" @@ -4769,6 +4806,42 @@ "download", "both" ] + }, + "ecgt_garan_brand": { + "type": "string", + "description": "The brand or trademark name for the EU commercial guarantee (required under GPSR/ECGT for eligible EU traders). Maximum 25 characters. See the [Etsy Seller Handbook](https://help.etsy.com/hc/articles/43191692248343) for details. If any one of ecgt_garan_brand, ecgt_garan_model, ecgt_garan_years, or ecgt_garan_guarantee_details is provided and non-empty, all four are required. Silently ignored for digital listings and for sellers who are not eligible EU traders.", + "nullable": true + }, + "ecgt_garan_years": { + "type": "integer", + "description": "Duration of the EU commercial guarantee in whole years (minimum 3, maximum 99). Required together with ecgt_garan_brand, ecgt_garan_model, and ecgt_garan_guarantee_details. Silently ignored for digital listings and for sellers who are not eligible EU traders.", + "format": "int64", + "nullable": true + }, + "ecgt_garan_model": { + "type": "string", + "description": "The product model or reference number for the EU commercial guarantee label. Maximum 20 characters. Required together with ecgt_garan_brand, ecgt_garan_years, and ecgt_garan_guarantee_details. Silently ignored for digital listings and for sellers who are not eligible EU traders.", + "nullable": true + }, + "ecgt_garan_guarantee_details": { + "type": "string", + "description": "Free-text description of the EU commercial guarantee terms and coverage. Maximum 255 characters. Required together with ecgt_garan_brand, ecgt_garan_model, and ecgt_garan_years. Silently ignored for digital listings and for sellers who are not eligible EU traders.", + "nullable": true + }, + "ecgt_other_commercial_guarantee_details": { + "type": "string", + "description": "Free-text details of any additional commercial guarantee or warranty beyond the primary EU commercial guarantee. Maximum 255 characters. Silently ignored for digital listings and for sellers who are not eligible EU traders.", + "nullable": true + }, + "ecgt_after_sales_service_info": { + "type": "string", + "description": "After-sales service, repairability, or eco-friendly delivery information required under EU GPSR/ECGT regulations. Maximum 255 characters. Silently ignored for digital listings and for sellers who are not eligible EU traders.", + "nullable": true + }, + "ecgt_software_update_details": { + "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 } } } @@ -5385,7 +5458,7 @@ "/v3/application/shops/{shop_id}/listings/{listing_id}/videos": { "post": { "operationId": "uploadListingVideo", - "description": "
General ReleaseReport bug

This endpoint is ready for production use.

\n\nUploads a new video for a listing, or associates an existing video with a specific listing. You must either provide the `video_id` of an existing video, or the name and binary file data for a video to upload. If providing a `video_id`, the video must already be associated with the same shop as the listing, but it does not need to be currently associated with the listing. ", + "description": "
General ReleaseReport bug

This endpoint is ready for production use.

\n\nUploads a new video for a listing, or associates an existing video with a specific listing. You must either provide the `video_id` of an existing video, or the name and binary file data for a video to upload. If providing a `video_id`, the video must already be associated with the same shop as the listing, but it does not need to be currently associated with the listing. By default, the endpoint handles single video uploads, but setting `is_multi_video` to true enables to link up to 2 videos to the same listing.", "tags": [ "ShopListing Video" ], @@ -5413,6 +5486,16 @@ "format": "int64", "minimum": 1 } + }, + { + "name": "is_multi_video", + "in": "query", + "description": "Indicates whether to handle multiple videos for the listing or maintain the former single video behavior.", + "required": false, + "schema": { + "type": "boolean", + "description": "Indicates whether to handle multiple videos for the listing or maintain the former single video behavior." + } } ], "requestBody": { @@ -12961,11 +13044,6 @@ "type": "string", "description": "A description string of the product for sale in the listing." }, - "rich_description": { - "type": "string", - "description": "The seller-authored HTML rich-text description of the product when the listing uses rich text; null for plain-text listings. The plain-text `description` field is always populated. This value is HTML and consumers MUST sanitize it before rendering it in any HTML context.", - "nullable": true - }, "state": { "type": "string", "description": "When _updating_ a listing, this value can be either `active` or `inactive`. Note: Setting a `draft` listing to `active` will also publish the listing on etsy.com and requires that the listing have an image set. Setting a `sold_out` listing to active will update the quantity to 1 and renew the listing on etsy.com.", @@ -12974,6 +13052,7 @@ "inactive", "sold_out", "draft", + "removed", "expired" ] }, @@ -13268,6 +13347,47 @@ "type": "string", "description": "A title string suggested by Etsy. Only available for a user's own listings, when allow_suggested_title param is present, and when a shop's language setting is English. Not all listings will have suggestions.", "nullable": true + }, + "ecgt_garan_brand": { + "type": "string", + "description": "Brand or trademark name for the EU commercial guarantee label.", + "nullable": true + }, + "ecgt_garan_years": { + "type": "integer", + "description": "Duration of the commercial guarantee in whole years (3–99).", + "format": "int64", + "nullable": true + }, + "ecgt_garan_model": { + "type": "string", + "description": "Product model or reference number for the EU commercial guarantee label.", + "nullable": true + }, + "ecgt_garan_guarantee_details": { + "type": "string", + "description": "Free-text details of the commercial guarantee.", + "nullable": true + }, + "ecgt_other_commercial_guarantee_details": { + "type": "string", + "description": "Free-text details of any additional commercial guarantee or warranty.", + "nullable": true + }, + "ecgt_after_sales_service_info": { + "type": "string", + "description": "After-sales service, repairability, or eco-friendly delivery information.", + "nullable": true + }, + "ecgt_software_update_details": { + "type": "string", + "description": "For digital or software listings: software update availability and duration.", + "nullable": true + }, + "ecgt_commercial_guarantee_enabled": { + "type": "boolean", + "description": "True when all four commercial guarantee fields are filled. Read-only; derived server-side.", + "nullable": true } } }, @@ -13429,11 +13549,6 @@ "type": "string", "description": "A description string of the product for sale in the listing." }, - "rich_description": { - "type": "string", - "description": "The seller-authored HTML rich-text description of the product when the listing uses rich text; null for plain-text listings. The plain-text `description` field is always populated. This value is HTML and consumers MUST sanitize it before rendering it in any HTML context.", - "nullable": true - }, "state": { "type": "string", "description": "When _updating_ a listing, this value can be either `active` or `inactive`. Note: Setting a `draft` listing to `active` will also publish the listing on etsy.com and requires that the listing have an image set. Setting a `sold_out` listing to active will update the quantity to 1 and renew the listing on etsy.com.", @@ -13442,6 +13557,7 @@ "inactive", "sold_out", "draft", + "removed", "expired" ] }, @@ -13737,6 +13853,47 @@ "description": "A title string suggested by Etsy. Only available for a user's own listings, when allow_suggested_title param is present, and when a shop's language setting is English. Not all listings will have suggestions.", "nullable": true }, + "ecgt_garan_brand": { + "type": "string", + "description": "Brand or trademark name for the EU commercial guarantee label.", + "nullable": true + }, + "ecgt_garan_years": { + "type": "integer", + "description": "Duration of the commercial guarantee in whole years (3–99).", + "format": "int64", + "nullable": true + }, + "ecgt_garan_model": { + "type": "string", + "description": "Product model or reference number for the EU commercial guarantee label.", + "nullable": true + }, + "ecgt_garan_guarantee_details": { + "type": "string", + "description": "Free-text details of the commercial guarantee.", + "nullable": true + }, + "ecgt_other_commercial_guarantee_details": { + "type": "string", + "description": "Free-text details of any additional commercial guarantee or warranty.", + "nullable": true + }, + "ecgt_after_sales_service_info": { + "type": "string", + "description": "After-sales service, repairability, or eco-friendly delivery information.", + "nullable": true + }, + "ecgt_software_update_details": { + "type": "string", + "description": "For digital or software listings: software update availability and duration.", + "nullable": true + }, + "ecgt_commercial_guarantee_enabled": { + "type": "boolean", + "description": "True when all four commercial guarantee fields are filled. Read-only; derived server-side.", + "nullable": true + }, "shipping_profile": { "description": "An array of data representing the shipping profile resource.", "oneOf": [ diff --git a/tests/test_listing_models.py b/tests/test_listing_models.py index 8208fff..60c8b1c 100644 --- a/tests/test_listing_models.py +++ b/tests/test_listing_models.py @@ -452,3 +452,88 @@ def test_defaults_are_none(self): req = UpdateListingVideoRequest() assert req.file == {"video": None} assert req.data == {"video_id": None, "name": None} + + +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(), + ecgt_garan_brand="Acme", + ecgt_garan_model="MUG-1", + ecgt_garan_years=3, + ecgt_garan_guarantee_details="Three year guarantee.", + ecgt_other_commercial_guarantee_details="Extended cover available.", + ecgt_after_sales_service_info="Contact support@example.com", + ecgt_software_update_details="Not applicable.", + ) + result = req.get_dict() + assert result["ecgt_garan_brand"] == "Acme" + assert result["ecgt_garan_model"] == "MUG-1" + assert result["ecgt_garan_years"] == 3 + assert result["ecgt_garan_guarantee_details"] == "Three year guarantee." + assert ( + result["ecgt_other_commercial_guarantee_details"] + == "Extended cover available." + ) + assert result["ecgt_after_sales_service_info"] == "Contact support@example.com" + assert result["ecgt_software_update_details"] == "Not applicable." + + def test_update_serializes_all_ecgt_fields(self): + req = UpdateListingRequest( + ecgt_garan_brand="Acme", + ecgt_garan_model="MUG-1", + ecgt_garan_years=5, + ecgt_garan_guarantee_details="Five year guarantee.", + ecgt_other_commercial_guarantee_details="Extended cover available.", + ecgt_after_sales_service_info="Contact support@example.com", + ecgt_software_update_details="Not applicable.", + ) + result = req.get_dict() + assert result["ecgt_garan_years"] == 5 + assert result["ecgt_garan_brand"] == "Acme" + assert result["ecgt_software_update_details"] == "Not applicable." + + def test_create_omits_unset_ecgt_fields(self): + req = CreateDraftListingRequest(**self._create_kwargs()) + result = req.get_dict() + for field in ( + "ecgt_garan_brand", + "ecgt_garan_model", + "ecgt_garan_years", + "ecgt_garan_guarantee_details", + "ecgt_other_commercial_guarantee_details", + "ecgt_after_sales_service_info", + "ecgt_software_update_details", + ): + assert field not in result + + def test_update_omits_unset_ecgt_fields(self): + result = UpdateListingRequest(title="Just a title").get_dict() + assert not any(key.startswith("ecgt_") for key in result) + + def test_ecgt_fields_are_nullable(self): + """An explicitly empty ECGT string clears the value rather than being dropped.""" + req = UpdateListingRequest(ecgt_garan_brand="") + assert req.get_dict()["ecgt_garan_brand"] is None + + def test_ecgt_does_not_warn(self): + """ECGT fields are current, unlike the deprecated personalization ones.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") + CreateDraftListingRequest( + **self._create_kwargs(), ecgt_garan_brand="Acme" + ) diff --git a/tests/test_remaining_resources.py b/tests/test_remaining_resources.py index 73526f6..881943e 100644 --- a/tests/test_remaining_resources.py +++ b/tests/test_remaining_resources.py @@ -249,6 +249,21 @@ def test_upload_listing_video(self, mock_session): f"/shops/{MOCK_SHOP_ID}/listings/{MOCK_LISTING_ID}/videos", method=Method.POST, payload=payload, + query_params={"is_multi_video": None}, + ) + + def test_upload_listing_video_multi(self, mock_session): + mock_session.make_request.return_value = Response(201, make_listing_video()) + resource = ListingVideoResource(session=mock_session) + payload = MagicMock(spec=UpdateListingVideoRequest) + resource.upload_listing_video( + MOCK_SHOP_ID, MOCK_LISTING_ID, payload, is_multi_video=True + ) + mock_session.make_request.assert_called_once_with( + f"/shops/{MOCK_SHOP_ID}/listings/{MOCK_LISTING_ID}/videos", + method=Method.POST, + payload=payload, + query_params={"is_multi_video": True}, ) def test_delete_listing_video(self, mock_session): From 2e56b7bddc6f5b577b865514cb63db22c8c67843 Mon Sep 17 00:00:00 2001 From: Amit Ray <51674969+amitray007@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:14:49 +0530 Subject: [PATCH 2/2] test: assert explicitly that ECGT fields emit no warning Replace the simplefilter("error") idiom in test_ecgt_does_not_warn with a recorded-warnings assertion, so the test states its intent rather than relying on an exception to fail the run. Verified non-vacuous: a deprecated personalization field still produces a warning this assertion catches. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KENB6Bxbu2p4YmfpXh4Ehx --- tests/test_listing_models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_listing_models.py b/tests/test_listing_models.py index 60c8b1c..c55816d 100644 --- a/tests/test_listing_models.py +++ b/tests/test_listing_models.py @@ -532,8 +532,9 @@ def test_ecgt_fields_are_nullable(self): def test_ecgt_does_not_warn(self): """ECGT fields are current, unlike the deprecated personalization ones.""" - with warnings.catch_warnings(): - warnings.simplefilter("error") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") CreateDraftListingRequest( **self._create_kwargs(), ecgt_garan_brand="Acme" ) + assert caught == []