diff --git a/README.md b/README.md
index 71341f7..05f7ffc 100644
--- a/README.md
+++ b/README.md
@@ -144,7 +144,7 @@ token_data = oauth.get_access_token()
### 2. Initialize Client Session
```python
-from datetime import datetime
+from datetime import datetime, timedelta, timezone
from etsy_python.v3.resources.Session import EtsyClient
# Create client with tokens
@@ -152,7 +152,7 @@ client = EtsyClient(
keystring="your_api_key",
access_token=token_data["access_token"],
refresh_token=token_data["refresh_token"],
- expiry=datetime.utcnow() + timedelta(seconds=token_data["expires_in"])
+ expiry=datetime.now(tz=timezone.utc) + timedelta(seconds=token_data["expires_in"])
)
```
diff --git a/etsy_python/v3/common/Request.py b/etsy_python/v3/common/Request.py
index cfae2a8..e0b0a38 100644
--- a/etsy_python/v3/common/Request.py
+++ b/etsy_python/v3/common/Request.py
@@ -1,4 +1,6 @@
NO_RESPONSE_CODES = {204}
+# Kept for import compatibility only: EtsyClient treats every status >= 400 as
+# an error, not just these codes.
ERROR_CODES = {400, 401, 403, 404, 409, 500, 503}
DEFAULT_RESPONSE_MESSAGES = {
diff --git a/etsy_python/v3/models/Listing.py b/etsy_python/v3/models/Listing.py
index 04a12e0..2951520 100644
--- a/etsy_python/v3/models/Listing.py
+++ b/etsy_python/v3/models/Listing.py
@@ -57,6 +57,28 @@ def _warn_if_personalization_used(
)
+_FEATURED_RANK_DEPRECATION_MSG = (
+ "featured_rank was removed from updateListing by the Etsy API and is no "
+ "longer sent; it will be dropped from UpdateListingRequest in a future "
+ "major version."
+)
+
+
+def _warn_if_featured_rank_used(featured_rank: Optional[int]) -> None:
+ """Emit DeprecationWarning when the removed featured_rank field is set.
+
+ ``0`` does not warn: it is the default a fetched listing reports, so
+ callers that round-trip a listing into an update would otherwise warn on
+ every call. This matches the personalization fields, which skip falsy
+ values. ``stacklevel=3`` points at the caller of the model ``__init__`` or
+ the ``featured_rank`` setter.
+ """
+ if featured_rank:
+ warnings.warn(
+ _FEATURED_RANK_DEPRECATION_MSG, DeprecationWarning, stacklevel=3
+ )
+
+
class _PersonalizationFieldsMixin:
"""Accessors for the personalization fields Etsy removed from listings.
@@ -310,7 +332,6 @@ class UpdateListingRequest(_PersonalizationFieldsMixin, _ECGTFieldsMixin, Reques
"item_weight_unit",
"item_dimensions_unit",
"tags",
- "featured_rank",
"production_partner_ids",
"_type",
*ECGT_FIELDS,
@@ -375,7 +396,10 @@ def __init__(
self.tags = tags
self.who_made = who_made
self.when_made = when_made
- self.featured_rank = featured_rank
+ # Removed from the updateListing body by Etsy: stored under an
+ # underscore name so todict never sends it.
+ self._featured_rank = featured_rank
+ _warn_if_featured_rank_used(featured_rank)
self._store_personalization(
is_personalizable,
personalization_is_required,
@@ -400,6 +424,15 @@ def __init__(
mandatory=UpdateListingRequest.mandatory,
)
+ @property
+ def featured_rank(self) -> Optional[int]:
+ return self._featured_rank
+
+ @featured_rank.setter
+ def featured_rank(self, value: Optional[int]) -> None:
+ self._featured_rank = value
+ _warn_if_featured_rank_used(value)
+
class UpdateListingInventoryRequest(Request):
nullable: List[str] = ["readiness_state_on_property"]
diff --git a/etsy_python/v3/resources/ListingInventory.py b/etsy_python/v3/resources/ListingInventory.py
index 9bf455f..7e1e5fc 100644
--- a/etsy_python/v3/resources/ListingInventory.py
+++ b/etsy_python/v3/resources/ListingInventory.py
@@ -35,6 +35,7 @@ def update_listing_inventory(
self, listing_id: int, listing_inventory: UpdateListingInventoryRequest,
legacy: Optional[bool] = None,
max_variations_supported: Optional[MaxVariationsSupported] = None,
+ enable_scale_validation: Optional[bool] = None,
) -> Union[Response, RequestException]:
if legacy is not None:
warn_removed_legacy_param("updateListingInventory")
@@ -43,6 +44,7 @@ def update_listing_inventory(
"max_variations_supported": max_variations_supported.value
if max_variations_supported is not None
else None,
+ "enable_scale_validation": enable_scale_validation,
}
return self.session.make_request(
endpoint, method=Method.PUT, payload=listing_inventory,
diff --git a/etsy_python/v3/resources/ListingVideo.py b/etsy_python/v3/resources/ListingVideo.py
index 61724ce..d8b4ed8 100644
--- a/etsy_python/v3/resources/ListingVideo.py
+++ b/etsy_python/v3/resources/ListingVideo.py
@@ -1,3 +1,4 @@
+import warnings
from dataclasses import dataclass
from typing import Optional, Union
@@ -37,14 +38,22 @@ def upload_listing_video(
) -> 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.
+ ``is_multi_video`` is deprecated: Etsy removed it from
+ uploadListingVideo, so any value is discarded and not sent, and Etsy
+ no longer documents whether an upload replaces an existing video. To
+ replace one, remove it with ``delete_listing_video`` before uploading
+ the new video. Etsy limits uploads to 10 per listing per day.
"""
+ if is_multi_video is not None:
+ warnings.warn(
+ "'is_multi_video' was removed from uploadListingVideo by Etsy "
+ "and is no longer sent; to replace a video, remove it with "
+ "delete_listing_video first. The argument will be dropped from "
+ "this method in a future major version.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
endpoint = f"/shops/{shop_id}/listings/{listing_id}/videos"
return self.session.make_request(
- endpoint,
- method=Method.POST,
- payload=listing_video,
- query_params={"is_multi_video": is_multi_video},
+ endpoint, method=Method.POST, payload=listing_video
)
diff --git a/etsy_python/v3/resources/Session.py b/etsy_python/v3/resources/Session.py
index 46296c1..52e9c2f 100644
--- a/etsy_python/v3/resources/Session.py
+++ b/etsy_python/v3/resources/Session.py
@@ -3,7 +3,7 @@
from requests import Session
-from etsy_python.v3.common.Request import ERROR_CODES, NO_RESPONSE_CODES
+from etsy_python.v3.common.Request import NO_RESPONSE_CODES
from etsy_python.v3.common.Env import environment
from etsy_python.v3.common.Utils import generate_get_uri
from etsy_python.v3.resources.enums.Request import Method
@@ -35,22 +35,51 @@ def __init__(
self.session.headers = self._get_resource_headers(keystring, access_token)
def update_token(self) -> tuple:
- self._remove_authorization_header()
refresh_json = self._get_refresh_json(self.keystring, self.refresh_token)
- response = self.session.post(environment.token_url, json=refresh_json)
+ # Drop Authorization for this call only. The shared session header is
+ # never touched, so a failed refresh cannot leave later requests
+ # unauthenticated.
+ response = self.session.post(
+ environment.token_url, json=refresh_json, headers={"Authorization": None}
+ )
response_json = self._process_request(response).message
-
- self.access_token = response_json.get("access_token")
- self.refresh_token = response_json.get("refresh_token")
- if not (self.access_token and self.refresh_token):
+ if not isinstance(response_json, dict):
+ raise RequestException(
+ code=response.status_code,
+ error="Token refresh returned a non-JSON body",
+ )
+ access_token = response_json.get("access_token")
+ refresh_token = response_json.get("refresh_token")
+ expires_in = response_json.get("expires_in")
+ # expires_in must be a positive int; bool is an int subclass, and
+ # floats allow NaN/inf, which timedelta cannot handle.
+ invalid = [
+ name
+ for name, ok in (
+ ("access_token", bool(access_token)),
+ ("refresh_token", bool(refresh_token)),
+ (
+ "expires_in",
+ isinstance(expires_in, int)
+ and not isinstance(expires_in, bool)
+ and 0 < expires_in <= 10**9,
+ ),
+ )
+ if not ok
+ ]
+ if invalid:
raise RequestException(
- code=401, error=response_json.get("error", "Something went wrong!")
+ code=401,
+ error=response_json.get("error", "Something went wrong!"),
+ error_description=response_json.get("error_description")
+ or "Token refresh response has missing or invalid: "
+ + ", ".join(invalid),
)
- updated_expiry = datetime.utcnow() + timedelta(
- seconds=response_json["expires_in"]
- )
- self.expiry = updated_expiry
+ # Store the new tokens only once the whole response is valid.
+ self.access_token = access_token
+ self.refresh_token = refresh_token
+ self.expiry = datetime.now(tz=timezone.utc) + timedelta(seconds=expires_in)
self.session.headers.update(
self._prepare_authorization_token(self.access_token)
@@ -70,9 +99,6 @@ def _get_resource_headers(self, keystring: str, access_token: str) -> dict:
def _prepare_authorization_token(access_token: str) -> dict:
return {"Authorization": f"Bearer {access_token}"}
- def _remove_authorization_header(self) -> None:
- self.session.headers.pop("Authorization", None)
-
@staticmethod
def _get_request_headers(keystring: str) -> dict:
return {"Accept": "application/json", "x-api-key": keystring}
@@ -132,21 +158,26 @@ def make_request(
return self._process_request(response)
def _process_request(self, response: Any) -> Any:
- is_error = response.status_code in ERROR_CODES
+ # Any 4xx/5xx is an error, including codes Etsy documents later
+ # (e.g. 429 for the uploadListingVideo daily limit).
+ is_error = response.status_code >= 400
rate_limits = None
if "X-Limit-Per-Day" in response.headers:
rate_limits = RateLimit(
- response.headers["X-Limit-Per-Day"],
- response.headers["X-Remaining-This-Second"],
- response.headers["X-Limit-Per-Day"],
- response.headers["X-Remaining-Today"],
+ response.headers.get("X-Limit-Per-Second"),
+ response.headers.get("X-Remaining-This-Second"),
+ response.headers.get("X-Limit-Per-Day"),
+ response.headers.get("X-Remaining-Today"),
)
- response_json = (
- response.json() if response.status_code not in NO_RESPONSE_CODES else None
- )
if is_error:
+ try:
+ response_json = response.json()
+ except ValueError:
+ # Gateway and method errors (405, 502, 504) can return a
+ # non-JSON or empty body; keep the text, or the reason phrase.
+ response_json = response.text or response.reason
error_response = (
response_json
if isinstance(response_json, dict)
@@ -160,6 +191,13 @@ def _process_request(self, response: Any) -> Any:
error_description,
rate_limits=rate_limits,
)
+ response_json = None
+ if response.status_code not in NO_RESPONSE_CODES:
+ try:
+ response_json = response.json()
+ except ValueError:
+ # A success with an empty or non-JSON body (202, 304, ...).
+ response_json = response.text
return Response(
response.status_code, response_json or "OK", rate_limits=rate_limits
)
diff --git a/etsy_python/v3/resources/UserAddress.py b/etsy_python/v3/resources/UserAddress.py
index d81b835..1ab47cd 100644
--- a/etsy_python/v3/resources/UserAddress.py
+++ b/etsy_python/v3/resources/UserAddress.py
@@ -1,3 +1,4 @@
+import warnings
from dataclasses import dataclass
from typing import Union, Dict, Any
@@ -14,6 +15,14 @@ class UserAddressResource:
def delete_user_address(
self, user_address_id: int
) -> Union[Response, RequestException]:
+ """Deprecated: Etsy removed deleteUserAddress from the Open API v3 spec."""
+ warnings.warn(
+ "delete_user_address is deprecated: Etsy removed deleteUserAddress "
+ "from the Open API v3 spec, so the request may fail. It will be "
+ "removed in a future major version.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
endpoint = f"/user/addresses/{user_address_id}"
return self.session.make_request(endpoint, method=Method.DELETE)
diff --git a/specs/audit-ignore.json b/specs/audit-ignore.json
index fa85d21..9f5600c 100644
--- a/specs/audit-ignore.json
+++ b/specs/audit-ignore.json
@@ -81,6 +81,16 @@
"reason": "Etsy removed the 'legacy' query param from the 8 listing endpoints (2026-07-27 spec) with no deprecation period; it remains valid on 14 other operations. The kwarg is retained so existing callers don't break, but it warns via warn_removed_legacy_param and is no longer sent. Remove the kwarg (and this entry) in the next major version.",
"added": "2026-07-30"
},
+ {
+ "type": "param_drift",
+ "key": "uploadListingVideo",
+ "direction": "extra",
+ "values": [
+ "is_multi_video"
+ ],
+ "reason": "Etsy removed the 'is_multi_video' query param from uploadListingVideo (2026-10 spec). The kwarg is retained so existing callers don't break, but any explicit value emits a DeprecationWarning and is no longer sent. Remove the kwarg (and this entry) in the next major version.",
+ "added": "2026-10-23"
+ },
{
"type": "extra_method",
"key": "ListingResource.get_listings_by_listings_ids",
@@ -105,6 +115,12 @@
"reason": "Intentional deprecated alias for get_shop_shipping_profile_destinations_by_shipping_profile; delegates and emits DeprecationWarning.",
"added": "2026-06-02"
},
+ {
+ "type": "extra_method",
+ "key": "UserAddressResource.delete_user_address",
+ "reason": "Etsy removed deleteUserAddress from the spec (2026-10). The method is kept so existing callers don't break, but it emits a DeprecationWarning because the request may fail. Remove the method (and this entry) in the next major version.",
+ "added": "2026-10-23"
+ },
{
"type": "enum_staleness",
"key": "ShopHolidayPreference.holiday_id -> CA_HOLIDAYS",
@@ -164,9 +180,10 @@
"is_personalizable",
"personalization_is_required",
"personalization_char_count_max",
- "personalization_instructions"
+ "personalization_instructions",
+ "featured_rank"
],
- "reason": "Etsy removed is_personalizable, personalization_is_required, personalization_char_count_max and personalization_instructions from the createDraftListing and updateListing request bodies (2026-09 spec); personalization now lives on the dedicated personalization endpoints (update_listing_personalization). The constructor kwargs are retained so existing callers don't break, but the values are stored under underscore-prefixed names that todict excludes, so they are never sent on the wire, and setting one emits a DeprecationWarning. Remove the kwargs (and this entry) in the next major version.",
+ "reason": "Etsy removed is_personalizable, personalization_is_required, personalization_char_count_max and personalization_instructions from the createDraftListing and updateListing request bodies (2026-09 spec); personalization now lives on the dedicated personalization endpoints (update_listing_personalization). The constructor kwargs are retained so existing callers don't break, but the values are stored under underscore-prefixed names that todict excludes, so they are never sent on the wire, and setting one emits a DeprecationWarning. Remove the kwargs (and this entry) in the next major version. featured_rank was also removed from the updateListing body (2026-10 spec) and is handled the same way: stored as _featured_rank, never sent, and setting it emits a DeprecationWarning.",
"added": "2026-09-06"
}
]
diff --git a/specs/baseline.json b/specs/baseline.json
index e5fa18d..1527829 100644
--- a/specs/baseline.json
+++ b/specs/baseline.json
@@ -2422,6 +2422,16 @@
"3"
]
}
+ },
+ {
+ "name": "enable_scale_validation",
+ "in": "query",
+ "description": "This temporary parameter indicates whether scale validation should be enabled for the request. It will be removed in the near future, at which point scale validation will be mandatory for all requests.",
+ "required": false,
+ "schema": {
+ "type": "boolean",
+ "description": "This temporary parameter indicates whether scale validation should be enabled for the request. It will be removed in the near future, at which point scale validation will be mandatory for all requests."
+ }
}
],
"requestBody": {
@@ -4769,12 +4779,6 @@
"before_1700"
]
},
- "featured_rank": {
- "type": "integer",
- "description": "The positive non-zero numeric position in the featured listings of the shop, with rank 1 listings appearing in the left-most position in featured listing on a shop's home page.",
- "format": "int64",
- "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.",
@@ -5458,7 +5462,7 @@
"/v3/application/shops/{shop_id}/listings/{listing_id}/videos": {
"post": {
"operationId": "uploadListingVideo",
- "description": "
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.",
+ "description": "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. It's important to consider there is a rate limiting to perform 10 uploads per listing per day.",
"tags": [
"ShopListing Video"
],
@@ -5486,16 +5490,6 @@
"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": {
@@ -5586,6 +5580,16 @@
}
}
},
+ "429": {
+ "description": "The request exceeded the valid number of uploaded videos per listing per day. 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": {
@@ -12354,70 +12358,6 @@
}
},
"/v3/application/user/addresses/{user_address_id}": {
- "delete": {
- "operationId": "deleteUserAddress",
- "description": "This endpoint is ready for production use.
\n\nOpen API V3 endpoint to delete a UserAddress for a User.",
- "tags": [
- "UserAddress"
- ],
- "parameters": [
- {
- "name": "user_address_id",
- "in": "path",
- "description": "The numeric ID of the user's address.",
- "required": true,
- "schema": {
- "type": "integer",
- "description": "The numeric ID of the user's address.",
- "format": "int64",
- "minimum": 1
- }
- }
- ],
- "responses": {
- "204": {
- "description": "The User Address resource was correctly deleted"
- },
- "400": {
- "description": "There was a problem with the request data. See the error message for details.",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorSchema"
- }
- }
- }
- },
- "401": {
- "description": "The request lacks valid authentication credentials. 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": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorSchema"
- }
- }
- }
- }
- },
- "security": [
- {
- "api_key": [],
- "oauth2": [
- "address_r"
- ]
- }
- ]
- },
"get": {
"operationId": "getUserAddress",
"description": "This endpoint is ready for production use.
\n\nOpen API V3 endpoint to retrieve a UserAddress for a User.",
diff --git a/tests/test_audit_ignore.py b/tests/test_audit_ignore.py
index 3d134ee..15dea4b 100644
--- a/tests/test_audit_ignore.py
+++ b/tests/test_audit_ignore.py
@@ -656,19 +656,36 @@ def test_no_wildcard_param_drift_ignores(self):
if ig["type"] == "param_drift":
assert ig["values"] != "*", f"{ig['key']} uses a wildcard"
- def test_shipped_legacy_param_ignores_are_value_scoped(self):
- # The 8 listing-endpoint `legacy` suppressions must name the value
- # explicitly, never "*", so future drift on those operations surfaces.
+ def test_shipped_param_drift_ignores_are_value_scoped(self):
+ # Every param_drift suppression must name its values explicitly, never
+ # "*", so future drift on those operations surfaces: the 8 listing-
+ # endpoint `legacy` entries plus uploadListingVideo's is_multi_video.
path = SCRIPTS_DIR.parent / "specs" / "audit-ignore.json"
entries = [
ig
for ig in audit_sdk.load_ignores(path)
if ig["type"] == "param_drift"
]
- assert len(entries) == 8
+ assert len(entries) == 9
for ig in entries:
assert ig["direction"] == "extra"
- assert ig["values"] == ["legacy"]
+ expected = (
+ ["is_multi_video"] if ig["key"] == "uploadListingVideo" else ["legacy"]
+ )
+ assert ig["values"] == expected
+
+ def test_shipped_2026_10_removal_ignores_are_pinned(self):
+ # Etsy removed is_multi_video from uploadListingVideo and the
+ # deleteUserAddress endpoint. Pin both entries so a widened or deleted
+ # suppression fails here instead of silently hiding drift.
+ path = SCRIPTS_DIR.parent / "specs" / "audit-ignore.json"
+ by_key = {
+ (ig["type"], ig["key"]): ig for ig in audit_sdk.load_ignores(path)
+ }
+ video = by_key[("param_drift", "uploadListingVideo")]
+ assert video["direction"] == "extra"
+ assert video["values"] == ["is_multi_video"]
+ assert ("extra_method", "UserAddressResource.delete_user_address") in by_key
def test_no_wildcard_body_drift_ignores(self):
# "*" on a body_drift entry would hide unreviewed request-body drift on
@@ -679,22 +696,28 @@ def test_no_wildcard_body_drift_ignores(self):
assert ig["values"] != "*", f"{ig['key']} uses a wildcard"
def test_shipped_personalization_body_ignores_are_value_scoped(self):
- # Etsy removed the four personalization fields from the listing bodies.
- # The kwargs are kept for source compatibility but never serialized, so
- # the drift is suppressed by value — never "*".
+ # Etsy removed the four personalization fields from the listing bodies,
+ # and featured_rank from the updateListing body. The kwargs are kept
+ # for source compatibility but never serialized, so the drift is
+ # suppressed by value - never "*".
path = SCRIPTS_DIR.parent / "specs" / "audit-ignore.json"
entries = [
ig for ig in audit_sdk.load_ignores(path) if ig["type"] == "body_drift"
]
assert {ig["key"] for ig in entries} == {"createDraftListing", "updateListing"}
+ personalization = {
+ "is_personalizable",
+ "personalization_is_required",
+ "personalization_char_count_max",
+ "personalization_instructions",
+ }
+ expected = {
+ "createDraftListing": personalization,
+ "updateListing": personalization | {"featured_rank"},
+ }
for ig in entries:
assert ig["direction"] == "extra"
- assert set(ig["values"]) == {
- "is_personalizable",
- "personalization_is_required",
- "personalization_char_count_max",
- "personalization_instructions",
- }
+ assert set(ig["values"]) == expected[ig["key"]]
# --------------------------------------------------------------------------- #
diff --git a/tests/test_listing_models.py b/tests/test_listing_models.py
index c55816d..2a3b1fb 100644
--- a/tests/test_listing_models.py
+++ b/tests/test_listing_models.py
@@ -396,6 +396,41 @@ def test_defaults_to_none_when_not_passed(self):
assert req.personalization_instructions is None
+class TestFeaturedRankRemoved:
+ """Etsy removed featured_rank from the updateListing body (2026-10 spec).
+ The kwarg is kept for source compatibility, but the value must never reach
+ the wire, and setting it warns."""
+
+ def test_no_warning_without_featured_rank(self):
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", DeprecationWarning)
+ UpdateListingRequest(title="Updated")
+
+ def test_constructor_warns_and_omits_featured_rank(self):
+ with pytest.warns(DeprecationWarning, match=r"featured_rank was removed"):
+ req = UpdateListingRequest(title="Updated", featured_rank=1)
+ payload = req.get_dict()
+ assert "featured_rank" not in payload
+ assert payload["title"] == "Updated"
+ assert req.featured_rank == 1
+
+ def test_zero_featured_rank_is_quiet_and_omitted(self):
+ # 0 is the default a fetched listing reports; round-tripping it must
+ # not warn, and it is still never sent.
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", DeprecationWarning)
+ req = UpdateListingRequest(title="Updated", featured_rank=0)
+ assert "featured_rank" not in req.get_dict()
+ assert req.featured_rank == 0
+
+ def test_setter_warns_and_omits_featured_rank(self):
+ req = UpdateListingRequest(title="Updated")
+ with pytest.warns(DeprecationWarning, match=r"featured_rank was removed"):
+ req.featured_rank = 2
+ assert req.featured_rank == 2
+ assert "featured_rank" not in req.get_dict()
+
+
class TestUpdateListingTranslationRequest:
def test_stores_fields(self):
req = UpdateListingTranslationRequest(
diff --git a/tests/test_remaining_resources.py b/tests/test_remaining_resources.py
index 162e9ae..d46cc18 100644
--- a/tests/test_remaining_resources.py
+++ b/tests/test_remaining_resources.py
@@ -1,3 +1,4 @@
+import warnings
from unittest.mock import MagicMock
import pytest
@@ -160,7 +161,10 @@ def test_update_listing_inventory(self, mock_session):
f"/listings/{MOCK_LISTING_ID}/inventory",
method=Method.PUT,
payload=payload,
- query_params={"max_variations_supported": None},
+ query_params={
+ "max_variations_supported": None,
+ "enable_scale_validation": None,
+ },
)
def test_update_listing_inventory_with_max_variations(self, mock_session):
@@ -178,7 +182,32 @@ def test_update_listing_inventory_with_max_variations(self, mock_session):
f"/listings/{MOCK_LISTING_ID}/inventory",
method=Method.PUT,
payload=payload,
- query_params={"max_variations_supported": "3"},
+ query_params={
+ "max_variations_supported": "3",
+ "enable_scale_validation": None,
+ },
+ )
+
+ @pytest.mark.parametrize("scale_validation", [True, False])
+ def test_update_listing_inventory_with_scale_validation(
+ self, mock_session, scale_validation
+ ):
+ mock_session.make_request.return_value = Response(
+ 200, make_listing_inventory()
+ )
+ resource = ListingInventoryResource(session=mock_session)
+ payload = MagicMock(spec=UpdateListingInventoryRequest)
+ resource.update_listing_inventory(
+ MOCK_LISTING_ID, payload, enable_scale_validation=scale_validation
+ )
+ mock_session.make_request.assert_called_once_with(
+ f"/listings/{MOCK_LISTING_ID}/inventory",
+ method=Method.PUT,
+ payload=payload,
+ query_params={
+ "max_variations_supported": None,
+ "enable_scale_validation": scale_validation,
+ },
)
@pytest.mark.parametrize("legacy_value", [True, False])
@@ -244,26 +273,34 @@ def test_upload_listing_video(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)
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", DeprecationWarning)
+ resource.upload_listing_video(MOCK_SHOP_ID, MOCK_LISTING_ID, payload)
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": None},
)
- def test_upload_listing_video_multi(self, mock_session):
+ @pytest.mark.parametrize("multi_value", [True, False])
+ def test_upload_listing_video_multi_warns_and_is_not_sent(
+ self, mock_session, multi_value
+ ):
+ # Etsy removed is_multi_video from uploadListingVideo; any explicit
+ # value warns and is dropped.
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
- )
+ with pytest.warns(
+ DeprecationWarning, match=r"from uploadListingVideo by Etsy"
+ ):
+ resource.upload_listing_video(
+ MOCK_SHOP_ID, MOCK_LISTING_ID, payload, is_multi_video=multi_value
+ )
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):
@@ -702,7 +739,8 @@ def test_get_user_address(self, mock_session):
def test_delete_user_address(self, mock_session):
mock_session.make_request.return_value = Response(204, "OK")
resource = UserAddressResource(session=mock_session)
- resource.delete_user_address(MOCK_USER_ADDRESS_ID)
+ with pytest.warns(DeprecationWarning, match=r"removed deleteUserAddress"):
+ resource.delete_user_address(MOCK_USER_ADDRESS_ID)
mock_session.make_request.assert_called_once_with(
f"/user/addresses/{MOCK_USER_ADDRESS_ID}", method=Method.DELETE
)
diff --git a/tests/test_session.py b/tests/test_session.py
index 57e3303..7c51cdc 100644
--- a/tests/test_session.py
+++ b/tests/test_session.py
@@ -167,7 +167,9 @@ def test_delete_request(self, real_etsy_client):
class TestMakeRequestErrors:
- @pytest.mark.parametrize("status_code", [400, 401, 403, 404, 409, 500, 503])
+ @pytest.mark.parametrize(
+ "status_code", [400, 401, 402, 403, 404, 405, 409, 422, 429, 500, 502, 503, 504]
+ )
def test_error_codes_raise_request_exception(self, real_etsy_client, status_code):
mock_resp = _make_mock_response(
status_code, {"error": "Something went wrong"}
@@ -178,6 +180,47 @@ def test_error_codes_raise_request_exception(self, real_etsy_client, status_code
real_etsy_client.make_request("/shops/123")
assert exc_info.value.code == status_code
+ @pytest.mark.parametrize("status_code", [200, 201, 302, 399])
+ def test_non_error_codes_return_response(self, real_etsy_client, status_code):
+ mock_resp = _make_mock_response(status_code, {"ok": True})
+ real_etsy_client._mock_http_session.get.return_value = mock_resp
+
+ result = real_etsy_client.make_request("/shops/123")
+ assert isinstance(result, Response)
+ assert result.code == status_code
+
+ def test_non_json_error_body_raises_request_exception(self, real_etsy_client):
+ mock_resp = _make_mock_response(405)
+ mock_resp.json.side_effect = ValueError("not JSON")
+ mock_resp.text = "Method Not Allowed"
+ real_etsy_client._mock_http_session.delete.return_value = mock_resp
+
+ with pytest.raises(RequestException) as exc_info:
+ real_etsy_client.make_request("/user/addresses/1", method=Method.DELETE)
+ assert exc_info.value.code == 405
+ assert exc_info.value.error == "Method Not Allowed"
+
+ def test_empty_error_body_uses_reason_phrase(self, real_etsy_client):
+ mock_resp = _make_mock_response(504)
+ mock_resp.json.side_effect = ValueError("not JSON")
+ mock_resp.text = ""
+ mock_resp.reason = "Gateway Timeout"
+ real_etsy_client._mock_http_session.get.return_value = mock_resp
+
+ with pytest.raises(RequestException) as exc_info:
+ real_etsy_client.make_request("/shops/123")
+ assert exc_info.value.error == "Gateway Timeout"
+
+ def test_non_json_success_body_returns_response(self, real_etsy_client):
+ mock_resp = _make_mock_response(202)
+ mock_resp.json.side_effect = ValueError("not JSON")
+ mock_resp.text = ""
+ real_etsy_client._mock_http_session.get.return_value = mock_resp
+
+ result = real_etsy_client.make_request("/shops/123")
+ assert result.code == 202
+ assert result.message == "OK"
+
def test_error_with_description(self, real_etsy_client):
mock_resp = _make_mock_response(
400,
@@ -231,6 +274,34 @@ def test_rate_limits_extracted(self, real_etsy_client):
assert result.rate_limits.remaining_this_second == "5"
assert result.rate_limits.remaining_today == "9500"
+ def test_limit_per_second_read_from_its_own_header(self, real_etsy_client):
+ headers = {
+ "X-Limit-Per-Second": "10",
+ "X-Remaining-This-Second": "5",
+ "X-Limit-Per-Day": "10000",
+ "X-Remaining-Today": "9500",
+ }
+ mock_resp = _make_mock_response(200, {"shop_id": 123}, headers)
+ real_etsy_client._mock_http_session.get.return_value = mock_resp
+
+ result = real_etsy_client.make_request("/shops/123")
+ assert result.rate_limits.limit_per_second == "10"
+ assert result.rate_limits.limit_per_day == "10000"
+
+ def test_partial_rate_limit_headers_do_not_mask_error(self, real_etsy_client):
+ # A 429 that carries only the daily headers must still raise
+ # RequestException, not KeyError.
+ mock_resp = _make_mock_response(
+ 429, {"error": "Too Many Requests"}, {"X-Limit-Per-Day": "10000"}
+ )
+ real_etsy_client._mock_http_session.get.return_value = mock_resp
+
+ with pytest.raises(RequestException) as exc_info:
+ real_etsy_client.make_request("/shops/123")
+ assert exc_info.value.code == 429
+ assert exc_info.value.rate_limits.limit_per_day == "10000"
+ assert exc_info.value.rate_limits.remaining_today is None
+
def test_no_rate_limit_headers(self, real_etsy_client):
mock_resp = _make_mock_response(200, {"shop_id": 123})
real_etsy_client._mock_http_session.get.return_value = mock_resp
@@ -255,6 +326,116 @@ def test_rate_limits_on_error(self, real_etsy_client):
class TestTokenRefresh:
+ @pytest.mark.parametrize(
+ "status_code, body",
+ [
+ (502, {"error": "Bad Gateway"}),
+ (200, {"error": "invalid_grant"}),
+ # Tokens present but expires_in missing or not numeric.
+ (200, {"access_token": "new-a", "refresh_token": "new-r"}),
+ (
+ 200,
+ {"access_token": "new-a", "refresh_token": "new-r", "expires_in": "x"},
+ ),
+ (
+ 200,
+ {"access_token": "new-a", "refresh_token": "new-r", "expires_in": True},
+ ),
+ (
+ 200,
+ {
+ "access_token": "new-a",
+ "refresh_token": "new-r",
+ "expires_in": float("nan"),
+ },
+ ),
+ (
+ 200,
+ {"access_token": "new-a", "refresh_token": "new-r", "expires_in": 10**20},
+ ),
+ ],
+ )
+ def test_failed_refresh_keeps_authorization(
+ self, real_etsy_client, status_code, body
+ ):
+ old_expiry = datetime.now(tz=timezone.utc) - timedelta(hours=1)
+ real_etsy_client.expiry = old_expiry
+ real_etsy_client.sync_refresh = MagicMock()
+ real_etsy_client._mock_http_session.post.return_value = _make_mock_response(
+ status_code, body
+ )
+
+ with pytest.raises(RequestException):
+ real_etsy_client.make_request("/shops/123")
+ # A failed refresh changes nothing: header, tokens, expiry, callback.
+ assert real_etsy_client._mock_http_session.headers["Authorization"] == (
+ f"Bearer {MOCK_ACCESS_TOKEN}"
+ )
+ assert real_etsy_client.access_token == MOCK_ACCESS_TOKEN
+ assert real_etsy_client.refresh_token == MOCK_REFRESH_TOKEN
+ assert real_etsy_client.expiry == old_expiry
+ real_etsy_client.sync_refresh.assert_not_called()
+ real_etsy_client._mock_http_session.get.assert_not_called()
+
+ def test_invalid_expires_in_is_named_in_error(self, real_etsy_client):
+ real_etsy_client.expiry = datetime.now(tz=timezone.utc) - timedelta(hours=1)
+ real_etsy_client._mock_http_session.post.return_value = _make_mock_response(
+ 200, {"access_token": "new-a", "refresh_token": "new-r"}
+ )
+
+ with pytest.raises(RequestException) as exc_info:
+ real_etsy_client.make_request("/shops/123")
+ assert exc_info.value.error_description == (
+ "Token refresh response has missing or invalid: expires_in"
+ )
+
+ def test_none_header_is_stripped_by_requests(self):
+ # update_token relies on requests dropping a session header that a
+ # call overrides with None.
+ import requests
+
+ session = requests.Session()
+ session.headers["Authorization"] = "Bearer old"
+ prepared = session.prepare_request(
+ requests.Request(
+ "POST", "https://example.com/token", headers={"Authorization": None}
+ )
+ )
+ assert "Authorization" not in prepared.headers
+
+ def test_non_json_refresh_raises_request_exception(self, real_etsy_client):
+ real_etsy_client.expiry = datetime.now(tz=timezone.utc) - timedelta(hours=1)
+ refresh_resp = _make_mock_response(200)
+ refresh_resp.json.side_effect = ValueError("not JSON")
+ refresh_resp.text = "Gateway"
+ real_etsy_client._mock_http_session.post.return_value = refresh_resp
+
+ with pytest.raises(RequestException, match="non-JSON"):
+ real_etsy_client.make_request("/shops/123")
+ assert real_etsy_client._mock_http_session.headers["Authorization"] == (
+ f"Bearer {MOCK_ACCESS_TOKEN}"
+ )
+
+ def test_refresh_drops_authorization_for_that_call_only(self, real_etsy_client):
+ real_etsy_client.expiry = datetime.now(tz=timezone.utc) - timedelta(hours=1)
+ real_etsy_client._mock_http_session.post.return_value = _make_mock_response(
+ 200,
+ {"access_token": "new-a", "refresh_token": "new-r", "expires_in": 3600},
+ )
+ real_etsy_client._mock_http_session.get.return_value = _make_mock_response(
+ 200, {"shop_id": 123}
+ )
+
+ real_etsy_client.make_request("/shops/123")
+ post_kwargs = real_etsy_client._mock_http_session.post.call_args.kwargs
+ assert post_kwargs["headers"] == {"Authorization": None}
+ assert real_etsy_client._mock_http_session.headers["Authorization"] == (
+ "Bearer new-a"
+ )
+ assert real_etsy_client.expiry.tzinfo is not None
+ remaining = real_etsy_client.expiry - datetime.now(tz=timezone.utc)
+ assert timedelta(seconds=3590) < remaining <= timedelta(seconds=3600)
+
def test_expired_token_triggers_refresh(self, real_etsy_client):
real_etsy_client.expiry = datetime.now(tz=timezone.utc) - timedelta(hours=1)