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 diff --git a/etsy_python/v3/enums/Listing.py b/etsy_python/v3/enums/Listing.py index 695f366..d852c3e 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" + REMOVED = "removed" # Not in OAS spec; kept for backward compatibility, may be removed in next major version class VideoState(Enum): @@ -92,6 +92,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..97c377d 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,11 +140,15 @@ 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) 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( @@ -149,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 91071c5..0bb02b3 100644 --- a/etsy_python/v3/resources/ReceiptTransactions.py +++ b/etsy_python/v3/resources/ReceiptTransactions.py @@ -34,14 +34,16 @@ 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( - 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( @@ -49,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) 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": { 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..14ac4ad 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( @@ -190,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 6c62409..7450a74 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) @@ -517,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: