Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions etsy_python/v3/enums/HolidayPreferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion etsy_python/v3/enums/Listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -92,6 +92,7 @@ class Includes(Enum):
TRANSLATIONS = "Translations"
INVENTORY = "Inventory"
VIDEOS = "Videos"
PERSONALIZATION = "Personalization"


class InventoryIncludes(Enum):
Expand Down
27 changes: 26 additions & 1 deletion etsy_python/v3/models/Listing.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import warnings
from typing import List, Optional, Dict, Any

from etsy_python.v3.enums.Listing import (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -133,7 +146,7 @@ class UpdateListingRequest(Request):
"tags",
"featured_rank",
"production_partner_ids",
"type",
"_type",
]

mandatory: List[str] = []
Expand Down Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions etsy_python/v3/resources/Listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,27 +129,34 @@ 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] = {
"listing_ids": ",".join(list(map(str, 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)
Comment thread
amitray007 marked this conversation as resolved.

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(
"get_listings_by_listings_ids is deprecated, use get_listings_by_listing_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,
Expand Down
10 changes: 6 additions & 4 deletions etsy_python/v3/resources/ReceiptTransactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,21 @@ 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)
Comment thread
amitray007 marked this conversation as resolved.

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(
"get_shop_receipt_transaction_by_shop is deprecated, use get_shop_receipt_transactions_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)
20 changes: 15 additions & 5 deletions specs/baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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": {
Expand All @@ -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": {
Expand All @@ -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
Expand Down Expand Up @@ -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": {
Expand Down
53 changes: 53 additions & 0 deletions tests/test_listing_models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import warnings

import pytest

from etsy_python.v3.enums.Listing import WhenMade, WhoMade
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
46 changes: 46 additions & 0 deletions tests/test_listing_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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):
Expand Down
31 changes: 31 additions & 0 deletions tests/test_remaining_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
Loading