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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,15 +144,15 @@ 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
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"])
)
```

Expand Down
2 changes: 2 additions & 0 deletions etsy_python/v3/common/Request.py
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
37 changes: 35 additions & 2 deletions etsy_python/v3/models/Listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -310,7 +332,6 @@ class UpdateListingRequest(_PersonalizationFieldsMixin, _ECGTFieldsMixin, Reques
"item_weight_unit",
"item_dimensions_unit",
"tags",
"featured_rank",
"production_partner_ids",
"_type",
*ECGT_FIELDS,
Expand Down Expand Up @@ -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,
Expand All @@ -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"]
Expand Down
2 changes: 2 additions & 0 deletions etsy_python/v3/resources/ListingInventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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,
Expand Down
23 changes: 16 additions & 7 deletions etsy_python/v3/resources/ListingVideo.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import warnings
from dataclasses import dataclass
from typing import Optional, Union

Expand Down Expand Up @@ -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
)
84 changes: 61 additions & 23 deletions etsy_python/v3/resources/Session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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}
Expand Down Expand Up @@ -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)
Expand All @@ -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
)
9 changes: 9 additions & 0 deletions etsy_python/v3/resources/UserAddress.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import warnings
from dataclasses import dataclass
from typing import Union, Dict, Any

Expand All @@ -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)

Expand Down
21 changes: 19 additions & 2 deletions specs/audit-ignore.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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"
}
]
Expand Down
Loading
Loading