From a6da2d802cfb4280c80b3fe2bd105ace91a5238f Mon Sep 17 00:00:00 2001 From: Amit Ray <51674969+amitray007@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:10:00 +0530 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20sync=20SDK=20with=20Etsy=20API=20spe?= =?UTF-8?q?c=20=E2=80=94=20correctness=20fixes=20and=20completeness=20impr?= =?UTF-8?q?ovements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix UpdateListingRequest.nullable using "type" instead of "_type" (silent serialization bug) - Remove State.REMOVED enum value not present in OAS spec - Add missing `legacy` query param to get_listings_by_listing_ids - Add missing `legacy` query param to get_shop_receipt_transactions_by_shop - Add Includes.PERSONALIZATION enum value from OAS spec - Add deprecation warnings for personalization_is_required, personalization_char_count_max, and personalization_instructions fields (Etsy removing April 9, 2026) - Update baseline spec Co-Authored-By: Claude Opus 4.6 (1M context) --- etsy_python/v3/enums/Listing.py | 2 +- etsy_python/v3/models/Listing.py | 27 ++++++++++++++++++- etsy_python/v3/resources/Listing.py | 6 ++++- .../v3/resources/ReceiptTransactions.py | 5 ++-- specs/baseline.json | 20 ++++++++++---- 5 files changed, 50 insertions(+), 10 deletions(-) diff --git a/etsy_python/v3/enums/Listing.py b/etsy_python/v3/enums/Listing.py index 695f366..b672f1b 100644 --- a/etsy_python/v3/enums/Listing.py +++ b/etsy_python/v3/enums/Listing.py @@ -58,7 +58,6 @@ class State(Enum): SOLD_OUT = "sold_out" DRAFT = "draft" EXPIRED = "expired" - REMOVED = "removed" class VideoState(Enum): @@ -92,6 +91,7 @@ class Includes(Enum): TRANSLATIONS = "Translations" INVENTORY = "Inventory" VIDEOS = "Videos" + PERSONALIZATION = "Personalization" class InventoryIncludes(Enum): diff --git a/etsy_python/v3/models/Listing.py b/etsy_python/v3/models/Listing.py index d844b0c..1e69f61 100644 --- a/etsy_python/v3/models/Listing.py +++ b/etsy_python/v3/models/Listing.py @@ -1,3 +1,4 @@ +import warnings from typing import List, Optional, Dict, Any from etsy_python.v3.enums.Listing import ( @@ -104,6 +105,18 @@ def __init__( self.personalization_is_required = personalization_is_required self.personalization_char_count_max = personalization_char_count_max self.personalization_instructions = personalization_instructions + if any(v is not None for v in [ + personalization_is_required, personalization_char_count_max, + personalization_instructions, + ]): + warnings.warn( + "personalization_is_required, personalization_char_count_max, and " + "personalization_instructions are deprecated and will be removed " + "from the Etsy API on April 9, 2026. Use the personalization " + "endpoint (update_listing_personalization) instead.", + DeprecationWarning, + stacklevel=2, + ) self.production_partner_ids = production_partner_ids self.image_ids = image_ids self.is_supply = is_supply @@ -133,7 +146,7 @@ class UpdateListingRequest(Request): "tags", "featured_rank", "production_partner_ids", - "type", + "_type", ] mandatory: List[str] = [] @@ -193,6 +206,18 @@ def __init__( self.personalization_is_required = personalization_is_required self.personalization_char_count_max = personalization_char_count_max self.personalization_instructions = personalization_instructions + if any(v is not None for v in [ + personalization_is_required, personalization_char_count_max, + personalization_instructions, + ]): + warnings.warn( + "personalization_is_required, personalization_char_count_max, and " + "personalization_instructions are deprecated and will be removed " + "from the Etsy API on April 9, 2026. Use the personalization " + "endpoint (update_listing_personalization) instead.", + DeprecationWarning, + stacklevel=2, + ) self.state = state self.is_supply = is_supply self.production_partner_ids = production_partner_ids diff --git a/etsy_python/v3/resources/Listing.py b/etsy_python/v3/resources/Listing.py index f6fcd7c..a04b02d 100644 --- a/etsy_python/v3/resources/Listing.py +++ b/etsy_python/v3/resources/Listing.py @@ -129,7 +129,10 @@ def find_all_active_listings_by_shop( return self.session.make_request(endpoint, query_params=query_params) def get_listings_by_listing_ids( - self, listing_ids: List[int], includes: Optional[List[Includes]] = None + self, + listing_ids: List[int], + includes: Optional[List[Includes]] = None, + legacy: Optional[bool] = None, ) -> Union[Response, RequestException]: endpoint = "/listings/batch" query_params: Dict[str, Any] = { @@ -137,6 +140,7 @@ def get_listings_by_listing_ids( "includes": ",".join(list(map(lambda inc: inc.value, includes))) if includes is not None else None, + "legacy": legacy, } return self.session.make_request(endpoint, query_params=query_params) diff --git a/etsy_python/v3/resources/ReceiptTransactions.py b/etsy_python/v3/resources/ReceiptTransactions.py index 91071c5..c3a2895 100644 --- a/etsy_python/v3/resources/ReceiptTransactions.py +++ b/etsy_python/v3/resources/ReceiptTransactions.py @@ -34,10 +34,11 @@ def get_shop_receipt_transaction( return self.session.make_request(endpoint) def get_shop_receipt_transactions_by_shop( - self, shop_id: int, limit: int = 25, offset: int = 0 + self, shop_id: int, limit: int = 25, offset: int = 0, + legacy: Optional[bool] = None, ) -> Union[Response, RequestException]: endpoint = f"/shops/{shop_id}/transactions" - query_params: Dict[str, Any] = {"limit": limit, "offset": offset} + query_params: Dict[str, Any] = {"limit": limit, "offset": offset, "legacy": legacy} return self.session.make_request(endpoint, query_params=query_params) def get_shop_receipt_transaction_by_shop( diff --git a/specs/baseline.json b/specs/baseline.json index c8a0d03..c5a0303 100644 --- a/specs/baseline.json +++ b/specs/baseline.json @@ -3191,7 +3191,7 @@ "properties": { "question_id": { "type": "integer", - "description": "The ID of the personalization question. Note: This value may change if the personalization question is updated.", + "description": "The ID of the personalization question. This field is optional. Include it when updating an existing question; omit it when creating a new question. Note: This value may change if the personalization question is updated.", "format": "int64", "nullable": true, "minimum": 1 @@ -3202,7 +3202,7 @@ }, "instructions": { "type": "string", - "description": "Optional instructions for a personalization question. For legacy, single personalization, max length is 256 characters. Once multiple personalization questions are enabled, the max length will be 120 characters.", + "description": "Optional instructions for a personalization question. This field is not allowed for 'dropdown' questions. For legacy, single personalization, max length is 256 characters. Once multiple personalization questions are enabled, the max length will be 120 characters.", "nullable": true }, "question_type": { @@ -3218,12 +3218,12 @@ }, "max_allowed_files": { "type": "integer", - "description": "The maximum number of files the buyer may upload in response to a personalization question. Note: This value is only applicable to 'unlabeled_upload' and 'labeled_upload' questions.", + "description": "The maximum number of files the buyer may upload in response to a personalization question. This field is optional and only applicable to 'unlabeled_upload' and 'labeled_upload' questions.", "nullable": true }, "max_allowed_characters": { "type": "integer", - "description": "The maximum number of characters the buyer may enter in response to a personalization question. Note: This value is only applicable to 'text_input' questions.", + "description": "The maximum number of characters the buyer may enter in response to a personalization question. This field is optional and only applicable to 'text_input' questions.", "nullable": true }, "options": { @@ -3235,7 +3235,7 @@ "properties": { "option_id": { "type": "integer", - "description": "The ID of the option. Note: This value may change if the option or question is updated.", + "description": "The ID of the option. This field is optional. Include it when updating an existing option; omit it when creating a new option. Note: This value may change if the option or question is updated.", "format": "int64", "nullable": true, "minimum": 1 @@ -5343,6 +5343,16 @@ } } }, + "409": { + "description": "There was a request conflict with the current state of the target resource. 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": { From 2aa4dbccc91965d1a8fd769d5eea7a59d48fe67d Mon Sep 17 00:00:00 2001 From: Amit Ray <51674969+amitray007@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:13:34 +0530 Subject: [PATCH 2/5] test: add tests for audit changes (legacy params, deprecation warnings, _type nullable) Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_listing_models.py | 53 +++++++++++++++++++++++++++++++ tests/test_listing_resource.py | 35 ++++++++++++++++++++ tests/test_remaining_resources.py | 22 +++++++++++++ 3 files changed, 110 insertions(+) diff --git a/tests/test_listing_models.py b/tests/test_listing_models.py index 3862459..d6be824 100644 --- a/tests/test_listing_models.py +++ b/tests/test_listing_models.py @@ -1,3 +1,5 @@ +import warnings + import pytest from etsy_python.v3.enums.Listing import WhenMade, WhoMade @@ -86,6 +88,11 @@ def test_partial_update(self): assert result["title"] == "New Title" assert result["is_taxable"] is True + def test_type_nullable_uses_underscore_prefix(self): + """_type field in nullable list must use attribute name '_type', not API key 'type'.""" + assert "_type" in UpdateListingRequest.nullable + assert "type" not in UpdateListingRequest.nullable + class TestUpdateListingInventoryRequest: def test_valid_request(self): @@ -152,3 +159,49 @@ def test_sets_file_and_data(self): req = UploadListingFileRequest(file_bytes=b"fake-pdf-data", name="test.pdf") assert req.file is not None assert req.data is not None + + +class TestPersonalizationDeprecationWarnings: + def _make_create_kwargs(self): + return dict( + quantity=10, + title="Test", + description="A test", + price=25.00, + who_made=WhoMade.I_DID, + when_made=WhenMade.TWENTY_TWENTIES, + taxonomy_id=30303, + ) + + def test_create_no_warning_without_personalization(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + CreateDraftListingRequest(**self._make_create_kwargs()) + deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] + assert len(deprecation_warnings) == 0 + + def test_create_warns_with_personalization_is_required(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + CreateDraftListingRequest( + **self._make_create_kwargs(), + personalization_is_required=True, + ) + deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] + assert len(deprecation_warnings) == 1 + assert "April 9, 2026" in str(deprecation_warnings[0].message) + + def test_update_no_warning_without_personalization(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + UpdateListingRequest(title="Updated") + deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] + assert len(deprecation_warnings) == 0 + + def test_update_warns_with_personalization_instructions(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + UpdateListingRequest(personalization_instructions="Enter name") + deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] + assert len(deprecation_warnings) == 1 + assert "update_listing_personalization" in str(deprecation_warnings[0].message) diff --git a/tests/test_listing_resource.py b/tests/test_listing_resource.py index 1f3f0bf..cafcb1a 100644 --- a/tests/test_listing_resource.py +++ b/tests/test_listing_resource.py @@ -178,6 +178,41 @@ def test_basic_call(self, mock_session): assert call_args[0][0] == f"/shops/{MOCK_SHOP_ID}/listings/active" +class TestGetListingsByListingIds: + def test_listing_ids_joined(self, mock_session): + mock_session.make_request.return_value = Response( + 200, make_shop_listing_collection() + ) + resource = ListingResource(session=mock_session) + + resource.get_listings_by_listing_ids([111, 222, 333]) + + qp = mock_session.make_request.call_args[1]["query_params"] + assert qp["listing_ids"] == "111,222,333" + + def test_legacy_param_passed(self, mock_session): + mock_session.make_request.return_value = Response( + 200, make_shop_listing_collection() + ) + resource = ListingResource(session=mock_session) + + resource.get_listings_by_listing_ids([111], legacy=True) + + qp = mock_session.make_request.call_args[1]["query_params"] + assert qp["legacy"] is True + + def test_legacy_defaults_to_none(self, mock_session): + mock_session.make_request.return_value = Response( + 200, make_shop_listing_collection() + ) + resource = ListingResource(session=mock_session) + + resource.get_listings_by_listing_ids([111]) + + qp = mock_session.make_request.call_args[1]["query_params"] + assert qp["legacy"] is None + + class TestGetListingsByListingsIds: def test_listing_ids_joined(self, mock_session): mock_session.make_request.return_value = Response( diff --git a/tests/test_remaining_resources.py b/tests/test_remaining_resources.py index 6c62409..56528eb 100644 --- a/tests/test_remaining_resources.py +++ b/tests/test_remaining_resources.py @@ -508,6 +508,28 @@ def test_get_shop_receipt_transaction(self, mock_session): f"/shops/{MOCK_SHOP_ID}/transactions/{MOCK_TRANSACTION_ID}" ) + def test_get_shop_receipt_transactions_by_shop(self, mock_session): + mock_session.make_request.return_value = Response( + 200, make_collection(make_transaction) + ) + resource = ReceiptTransactionsResource(session=mock_session) + resource.get_shop_receipt_transactions_by_shop(MOCK_SHOP_ID) + mock_session.make_request.assert_called_once_with( + f"/shops/{MOCK_SHOP_ID}/transactions", + query_params={"limit": 25, "offset": 0, "legacy": None}, + ) + + def test_get_shop_receipt_transactions_by_shop_with_legacy(self, mock_session): + mock_session.make_request.return_value = Response( + 200, make_collection(make_transaction) + ) + resource = ReceiptTransactionsResource(session=mock_session) + resource.get_shop_receipt_transactions_by_shop(MOCK_SHOP_ID, legacy=True) + mock_session.make_request.assert_called_once_with( + f"/shops/{MOCK_SHOP_ID}/transactions", + query_params={"limit": 25, "offset": 0, "legacy": True}, + ) + def test_get_shop_receipt_transaction_by_shop(self, mock_session): mock_session.make_request.return_value = Response( 200, make_collection(make_transaction) From 09d1b8cf0e4c3bc86c8f5489ae7c828a94dcecee Mon Sep 17 00:00:00 2001 From: Amit Ray <51674969+amitray007@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:23:01 +0530 Subject: [PATCH 3/5] fix: forward legacy param through deprecated method aliases Co-Authored-By: Claude Opus 4.6 (1M context) --- etsy_python/v3/resources/Listing.py | 7 +++++-- etsy_python/v3/resources/ReceiptTransactions.py | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/etsy_python/v3/resources/Listing.py b/etsy_python/v3/resources/Listing.py index a04b02d..97c377d 100644 --- a/etsy_python/v3/resources/Listing.py +++ b/etsy_python/v3/resources/Listing.py @@ -145,7 +145,10 @@ def get_listings_by_listing_ids( return self.session.make_request(endpoint, query_params=query_params) def get_listings_by_listings_ids( - self, listing_ids: List[int], includes: Optional[List[Includes]] = None + self, + listing_ids: List[int], + includes: Optional[List[Includes]] = None, + legacy: Optional[bool] = None, ) -> Union[Response, RequestException]: """Deprecated: use get_listings_by_listing_ids instead.""" warnings.warn( @@ -153,7 +156,7 @@ def get_listings_by_listings_ids( DeprecationWarning, stacklevel=2, ) - return self.get_listings_by_listing_ids(listing_ids, includes) + return self.get_listings_by_listing_ids(listing_ids, includes, legacy) def get_featured_listings_by_shop( self, shop_id: int, limit: int = 25, offset: int = 0, diff --git a/etsy_python/v3/resources/ReceiptTransactions.py b/etsy_python/v3/resources/ReceiptTransactions.py index c3a2895..0bb02b3 100644 --- a/etsy_python/v3/resources/ReceiptTransactions.py +++ b/etsy_python/v3/resources/ReceiptTransactions.py @@ -42,7 +42,8 @@ def get_shop_receipt_transactions_by_shop( return self.session.make_request(endpoint, query_params=query_params) def get_shop_receipt_transaction_by_shop( - self, shop_id: int, limit: int = 25, offset: int = 0 + self, shop_id: int, limit: int = 25, offset: int = 0, + legacy: Optional[bool] = None, ) -> Union[Response, RequestException]: """Deprecated: use get_shop_receipt_transactions_by_shop instead.""" warnings.warn( @@ -50,4 +51,4 @@ def get_shop_receipt_transaction_by_shop( DeprecationWarning, stacklevel=2, ) - return self.get_shop_receipt_transactions_by_shop(shop_id, limit, offset) + return self.get_shop_receipt_transactions_by_shop(shop_id, limit, offset, legacy) From 7ffd3569a7f26bde9c2a989db27782a8a37b7af3 Mon Sep 17 00:00:00 2001 From: Amit Ray <51674969+amitray007@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:27:06 +0530 Subject: [PATCH 4/5] fix: restore State.REMOVED for backward compatibility, add wrapper forwarding tests - Restore State.REMOVED with comment noting it's not in OAS spec (avoids breaking users who reference it) - Add tests verifying deprecated wrappers forward the legacy parameter Co-Authored-By: Claude Opus 4.6 (1M context) --- etsy_python/v3/enums/Listing.py | 1 + tests/test_listing_resource.py | 11 +++++++++++ tests/test_remaining_resources.py | 9 +++++++++ 3 files changed, 21 insertions(+) diff --git a/etsy_python/v3/enums/Listing.py b/etsy_python/v3/enums/Listing.py index b672f1b..d852c3e 100644 --- a/etsy_python/v3/enums/Listing.py +++ b/etsy_python/v3/enums/Listing.py @@ -58,6 +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 class VideoState(Enum): diff --git a/tests/test_listing_resource.py b/tests/test_listing_resource.py index cafcb1a..14ac4ad 100644 --- a/tests/test_listing_resource.py +++ b/tests/test_listing_resource.py @@ -225,6 +225,17 @@ def test_listing_ids_joined(self, mock_session): qp = mock_session.make_request.call_args[1]["query_params"] assert qp["listing_ids"] == "111,222,333" + def test_legacy_forwarded_through_deprecated_alias(self, mock_session): + mock_session.make_request.return_value = Response( + 200, make_shop_listing_collection() + ) + resource = ListingResource(session=mock_session) + + resource.get_listings_by_listings_ids([111], legacy=True) + + qp = mock_session.make_request.call_args[1]["query_params"] + assert qp["legacy"] is True + class TestGetFeaturedListingsByShop: def test_basic_call(self, mock_session): diff --git a/tests/test_remaining_resources.py b/tests/test_remaining_resources.py index 56528eb..7450a74 100644 --- a/tests/test_remaining_resources.py +++ b/tests/test_remaining_resources.py @@ -539,6 +539,15 @@ def test_get_shop_receipt_transaction_by_shop(self, mock_session): call_args = mock_session.make_request.call_args assert call_args[0][0] == f"/shops/{MOCK_SHOP_ID}/transactions" + def test_get_shop_receipt_transaction_by_shop_forwards_legacy(self, mock_session): + mock_session.make_request.return_value = Response( + 200, make_collection(make_transaction) + ) + resource = ReceiptTransactionsResource(session=mock_session) + resource.get_shop_receipt_transaction_by_shop(MOCK_SHOP_ID, legacy=True) + qp = mock_session.make_request.call_args[1]["query_params"] + assert qp["legacy"] is True + # --- PaymentLedgerEntry --- class TestPaymentLedgeEntryResource: From 1dc1155674050bea8f73998bd5904023aa57733b Mon Sep 17 00:00:00 2001 From: Amit Ray <51674969+amitray007@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:38:07 +0530 Subject: [PATCH 5/5] docs: add docstrings to holiday enum classes clarifying ID ranges Co-Authored-By: Claude Opus 4.6 (1M context) --- etsy_python/v3/enums/HolidayPreferences.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/etsy_python/v3/enums/HolidayPreferences.py b/etsy_python/v3/enums/HolidayPreferences.py index 29839ef..91d218f 100644 --- a/etsy_python/v3/enums/HolidayPreferences.py +++ b/etsy_python/v3/enums/HolidayPreferences.py @@ -15,6 +15,7 @@ class HOLIDAYS(Enum): pass class US_HOLIDAYS(Enum): + """US holidays (IDs 1-11).""" NEW_YEARS_DAY = 1 MARTIN_LUTHER_KING_JR_DAY = 2 PRESIDENTS_DAY = 3 @@ -28,6 +29,8 @@ class US_HOLIDAYS(Enum): CHRISTMAS_DAY = 11 class CA_HOLIDAYS(Enum): + """Canadian holidays (IDs 12-23). For other regions (IDs 24-105), + pass the integer ID directly to update_holiday_preferences().""" GOOD_FRIDAY = 12 EASTER = 13 VICTORIA_DAY = 14