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
72 changes: 46 additions & 26 deletions etsy_python/v3/models/Listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,42 @@
from etsy_python.v3.models.FileRequest import FileRequest


_PERSONALIZATION_DEPRECATION_MSG = (
"is_personalizable, personalization_is_required, "
"personalization_char_count_max, and personalization_instructions "
"are deprecated by the Etsy API and scheduled for removal. "
"Use the personalization endpoint (update_listing_personalization) "
"instead. See "
"https://developers.etsy.com/documentation/tutorials/personalization-migration "
"for migration details."
)


def _warn_if_personalization_used(
is_personalizable: Optional[bool],
personalization_is_required: Optional[bool],
personalization_char_count_max: Optional[int],
personalization_instructions: Optional[str],
) -> None:
"""Emit DeprecationWarning when a deprecated personalization field is actively set.

Falsy values (False, 0, empty string, None) match the API's documented
defaults and remain no-ops after the fields are removed, so they do not
trigger the warning.
"""
if any([
is_personalizable,
personalization_is_required,
personalization_char_count_max,
personalization_instructions,
]):
warnings.warn(
_PERSONALIZATION_DEPRECATION_MSG,
DeprecationWarning,
stacklevel=3,
)


class CreateDraftListingRequest(Request):
nullable = [
"shipping_profile_id",
Expand Down Expand Up @@ -105,20 +141,12 @@ 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,
_warn_if_personalization_used(
is_personalizable,
personalization_is_required,
personalization_char_count_max,
personalization_instructions,
]):
warnings.warn(
"personalization_is_required, personalization_char_count_max, and "
"personalization_instructions are deprecated by the Etsy API and "
"scheduled for removal. Use the personalization endpoint "
"(update_listing_personalization) instead. See "
"https://developers.etsy.com/documentation/tutorials/personalization-migration "
"for migration details.",
DeprecationWarning,
stacklevel=2,
)
)
self.production_partner_ids = production_partner_ids
self.image_ids = image_ids
self.is_supply = is_supply
Expand Down Expand Up @@ -208,20 +236,12 @@ 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,
_warn_if_personalization_used(
is_personalizable,
personalization_is_required,
personalization_char_count_max,
personalization_instructions,
]):
warnings.warn(
"personalization_is_required, personalization_char_count_max, and "
"personalization_instructions are deprecated by the Etsy API and "
"scheduled for removal. Use the personalization endpoint "
"(update_listing_personalization) instead. See "
"https://developers.etsy.com/documentation/tutorials/personalization-migration "
"for migration details.",
DeprecationWarning,
stacklevel=2,
)
)
self.state = state
self.is_supply = is_supply
self.production_partner_ids = production_partner_ids
Expand Down
8 changes: 4 additions & 4 deletions specs/baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@
},
"styles": {
"type": "array",
"description": "An array of style strings for this listing, each of which is free-form text string such as \"Formal\", or \"Steampunk\". When creating or updating a listing, the listing may have up to two styles. Valid style strings contain only letters, numbers, and whitespace characters. (regex: /[^\\p{L}\\p{Nd}\\p{Zs}]/u) Default value is null.",
"description": "An array of style strings for this listing, each of which is free-form text string such as \"Formal\", or \"Steampunk\". When creating or updating a listing, the listing may have up to two styles. Valid style strings contain only letters, numbers, and whitespace characters. (regex: /[^\\p{L}\\p{Nd}\\p{Zs}]/u) Each style string is limited to 45 characters. Default value is null.",
"nullable": true,
"items": {
"type": "string"
Expand Down Expand Up @@ -2423,7 +2423,7 @@
},
"put": {
"operationId": "updateListingInventory",
"description": "<div class=\"wt-display-flex-xs wt-align-items-center wt-mt-xs-2 wt-mb-xs-3\"><span class=\"wt-badge wt-badge--notificationPrimary wt-bg-slime-tint wt-mr-xs-2\">General Release</span><a class=\"wt-text-link\" href=\"https://github.com/etsy/open-api/discussions\" target=\"_blank\" rel=\"noopener noreferrer\">Report bug</a></div><div class=\"wt-display-flex-xs wt-align-items-center wt-mt-xs-2 wt-mb-xs-3\"><p class=\"wt-text-body-01 banner-text\">This endpoint is ready for production use.</p></div>\n\nUpdates the inventory for a listing identified by a listing ID. The update fails if the supplied values for product sku, offering quantity, and/or price are incompatible with values in `*_on_property` fields. When setting a price, assign a float equal to amount divided by divisor as specified in the Money resource.",
"description": "<div class=\"wt-display-flex-xs wt-align-items-center wt-mt-xs-2 wt-mb-xs-3\"><span class=\"wt-badge wt-badge--notificationPrimary wt-bg-slime-tint wt-mr-xs-2\">General Release</span><a class=\"wt-text-link\" href=\"https://github.com/etsy/open-api/discussions\" target=\"_blank\" rel=\"noopener noreferrer\">Report bug</a></div><div class=\"wt-display-flex-xs wt-align-items-center wt-mt-xs-2 wt-mb-xs-3\"><p class=\"wt-text-body-01 banner-text\">This endpoint is ready for production use.</p></div>\n\nUpdates the inventory for a listing identified by a listing ID. The update fails if the supplied values for product sku, offering quantity, price, and/or processing profile are incompatible with values in `*_on_property` fields. When setting a price, assign a float equal to amount divided by divisor as specified in the Money resource.",
"tags": [
"ShopListing Inventory"
],
Expand Down Expand Up @@ -13063,7 +13063,7 @@
},
"style": {
"type": "array",
"description": "An array of style strings for this listing, each of which is free-form text string such as \"Formal\", or \"Steampunk\". When creating or updating a listing, the listing may have up to two styles. Valid style strings contain only letters, numbers, and whitespace characters. (regex: /[^\\p{L}\\p{Nd}\\p{Zs}]/u) Default value is null.",
"description": "An array of style strings for this listing, each of which is free-form text string such as \"Formal\", or \"Steampunk\". When creating or updating a listing, the listing may have up to two styles. Valid style strings contain only letters, numbers, and whitespace characters. (regex: /[^\\p{L}\\p{Nd}\\p{Zs}]/u) Each style string is limited to 45 characters. Default value is null.",
"items": {
"type": "string"
}
Expand Down Expand Up @@ -13505,7 +13505,7 @@
},
"style": {
"type": "array",
"description": "An array of style strings for this listing, each of which is free-form text string such as \"Formal\", or \"Steampunk\". When creating or updating a listing, the listing may have up to two styles. Valid style strings contain only letters, numbers, and whitespace characters. (regex: /[^\\p{L}\\p{Nd}\\p{Zs}]/u) Default value is null.",
"description": "An array of style strings for this listing, each of which is free-form text string such as \"Formal\", or \"Steampunk\". When creating or updating a listing, the listing may have up to two styles. Valid style strings contain only letters, numbers, and whitespace characters. (regex: /[^\\p{L}\\p{Nd}\\p{Zs}]/u) Each style string is limited to 45 characters. Default value is null.",
"items": {
"type": "string"
}
Expand Down
38 changes: 38 additions & 0 deletions tests/test_exceptions_and_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from etsy_python.v3.exceptions.BaseAPIException import BaseAPIException
from etsy_python.v3.exceptions.RequestException import RequestException
from etsy_python.v3.resources.Response import Response


class TestBaseAPIExceptionStr:
def test_formats_all_fields(self):
exc = BaseAPIException(
code=400, error="bad_request", error_description="missing field", type="ERROR"
)
s = str(exc)
assert "code = 400" in s
assert "error = bad_request" in s
assert "error_description = missing field" in s
assert "type = ERROR" in s

def test_handles_none_defaults(self):
exc = BaseAPIException(code=500)
s = str(exc)
assert "code = 500" in s
assert "error = None" in s
assert "type = ERROR" in s


class TestRequestExceptionStr:
def test_prefixes_etsy_marker(self):
exc = RequestException(code=429, error="rate_limit_exceeded", rate_limits={"X-Limit-Per-Day": 5000})
s = str(exc)
assert s.startswith("[EtsyRequestException]")
assert "code = 429" in s
assert "error = rate_limit_exceeded" in s


class TestResponseStr:
def test_includes_code_and_message(self):
resp = Response(200, "ok")
s = str(resp)
assert s == "[EtsyResponse] [code = 200] [message = ok]"
14 changes: 14 additions & 0 deletions tests/test_language_enum.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from etsy_python.v3.enums.Language import Language


class TestLanguage:
def test_supported_language_codes_match_iso_639_1(self):
# Every member's value is its ISO 639-1 two-letter code and matches
# its name in lowercase. This catches accidental rename/value drift.
for member in Language:
assert member.value == member.name.lower()
assert len(member.value) == 2

def test_expected_languages_are_present(self):
expected = {"en", "de", "es", "fr", "it", "ja", "nl", "pl", "pt", "ru"}
assert {m.value for m in Language} == expected
105 changes: 105 additions & 0 deletions tests/test_listing_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
CreateListingTranslationRequest,
UpdateListingRequest,
UpdateListingInventoryRequest,
UpdateListingPersonalizationRequest,
UpdateListingPropertyRequest,
UpdateListingTranslationRequest,
UpdateListingVideoRequest,
UpdateVariationImagesRequest,
UploadListingImageRequest,
UploadListingFileRequest,
Expand Down Expand Up @@ -210,3 +213,105 @@ def test_update_warns_with_personalization_instructions(self):
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)

def test_create_warns_with_is_personalizable(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
CreateDraftListingRequest(
**self._make_create_kwargs(),
is_personalizable=True,
)
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
assert len(deprecation_warnings) == 1
assert "is_personalizable" in str(deprecation_warnings[0].message)
assert "personalization-migration" in str(deprecation_warnings[0].message)

def test_update_warns_with_is_personalizable(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
UpdateListingRequest(is_personalizable=True)
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
assert len(deprecation_warnings) == 1
assert "is_personalizable" in str(deprecation_warnings[0].message)

def test_create_no_warning_with_is_personalizable_false(self):
# False matches the API's documented default and is a no-op once
# the field is removed, so explicit opt-out should not warn.
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
CreateDraftListingRequest(
**self._make_create_kwargs(),
is_personalizable=False,
personalization_is_required=False,
)
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
assert len(deprecation_warnings) == 0

def test_update_no_warning_with_falsy_personalization_values(self):
# 0 char count and empty instructions are equivalent to "not used".
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
UpdateListingRequest(
is_personalizable=False,
personalization_char_count_max=0,
personalization_instructions="",
)
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
assert len(deprecation_warnings) == 0

def test_create_warns_once_with_multiple_personalization_fields(self):
# Setting several deprecated fields together must produce exactly one
# DeprecationWarning, not one per field.
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
CreateDraftListingRequest(
**self._make_create_kwargs(),
is_personalizable=True,
personalization_is_required=True,
personalization_char_count_max=256,
personalization_instructions="Enter name",
)
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
assert len(deprecation_warnings) == 1
assert "is_personalizable" in str(deprecation_warnings[0].message)


class TestUpdateListingTranslationRequest:
def test_stores_fields(self):
req = UpdateListingTranslationRequest(
title="New title",
description="New description",
tags=["a", "b"],
)
assert req.title == "New title"
assert req.description == "New description"
assert req.tags == ["a", "b"]

def test_missing_mandatory_raises(self):
with pytest.raises(Exception):
UpdateListingTranslationRequest()


class TestUpdateListingPersonalizationRequest:
def test_stores_personalization_questions(self):
questions = [{"personalization_question": "Name?"}]
req = UpdateListingPersonalizationRequest(personalization_questions=questions)
assert req.personalization_questions == questions

def test_missing_mandatory_raises(self):
with pytest.raises(Exception):
UpdateListingPersonalizationRequest()


class TestUpdateListingVideoRequest:
def test_sets_file_and_data(self):
req = UpdateListingVideoRequest(
video_id=42, video_bytes=b"fake-mp4-bytes", name="clip.mp4"
)
assert req.file == {"video": b"fake-mp4-bytes"}
assert req.data == {"video_id": 42, "name": "clip.mp4"}

def test_defaults_are_none(self):
req = UpdateListingVideoRequest()
assert req.file == {"video": None}
assert req.data == {"video_id": None, "name": None}
53 changes: 53 additions & 0 deletions tests/test_listing_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from etsy_python.v3.exceptions.RequestException import RequestException
from etsy_python.v3.models.Listing import (
CreateDraftListingRequest,
UpdateListingPersonalizationRequest,
UpdateListingPropertyRequest,
UpdateListingRequest,
)
Expand Down Expand Up @@ -507,3 +508,55 @@ def test_none_optional_params_excluded_from_query_params(self, mock_session):
qp = mock_session.make_request.call_args[1]["query_params"]
assert qp["keywords"] is None
assert qp["min_price"] is None


class TestListingPersonalizationEndpoints:
def test_get_listing_personalization(self, mock_session):
mock_session.make_request.return_value = Response(200, {"questions": []})
resource = ListingResource(session=mock_session)

resource.get_listing_personalization(MOCK_LISTING_ID)

mock_session.make_request.assert_called_once_with(
f"/listings/{MOCK_LISTING_ID}/personalization"
)

def test_update_listing_personalization(self, mock_session):
mock_session.make_request.return_value = Response(200, {"questions": []})
resource = ListingResource(session=mock_session)
payload = MagicMock(spec=UpdateListingPersonalizationRequest)

resource.update_listing_personalization(
MOCK_SHOP_ID,
MOCK_LISTING_ID,
payload,
supports_multiple_personalization_questions=True,
)

mock_session.make_request.assert_called_once_with(
f"/shops/{MOCK_SHOP_ID}/listings/{MOCK_LISTING_ID}/personalization",
method=Method.POST,
payload=payload,
query_params={"supports_multiple_personalization_questions": True},
)

def test_update_listing_personalization_defaults_query_param_to_none(self, mock_session):
mock_session.make_request.return_value = Response(200, {"questions": []})
resource = ListingResource(session=mock_session)
payload = MagicMock(spec=UpdateListingPersonalizationRequest)

resource.update_listing_personalization(MOCK_SHOP_ID, MOCK_LISTING_ID, payload)

qp = mock_session.make_request.call_args[1]["query_params"]
assert qp == {"supports_multiple_personalization_questions": None}

def test_delete_listing_personalization(self, mock_session):
mock_session.make_request.return_value = Response(204, "")
resource = ListingResource(session=mock_session)

resource.delete_listing_personalization(MOCK_SHOP_ID, MOCK_LISTING_ID)

mock_session.make_request.assert_called_once_with(
f"/shops/{MOCK_SHOP_ID}/listings/{MOCK_LISTING_ID}/personalization",
method=Method.DELETE,
)
28 changes: 28 additions & 0 deletions tests/test_misc_and_holiday_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import pytest

from etsy_python.v3.models.HolidayPreferences import UpdateHolidayPreferencesRequest
from etsy_python.v3.models.Miscellaneous import GetTokenScopes


class TestGetTokenScopes:
def test_stores_token(self):
req = GetTokenScopes(token="abc123")
assert req.token == "abc123"

def test_missing_mandatory_raises(self):
with pytest.raises(Exception):
GetTokenScopes()


class TestUpdateHolidayPreferencesRequest:
def test_stores_is_working_true(self):
req = UpdateHolidayPreferencesRequest(is_working=True)
assert req.is_working is True

def test_stores_is_working_false(self):
# False is the documented default; constructor must still accept it
# without raising and preserve the literal False.
req = UpdateHolidayPreferencesRequest(is_working=False)
assert req.is_working is False
result = req.get_dict()
assert result["is_working"] is False
Loading
Loading