diff --git a/CLAUDE.md b/CLAUDE.md index 2ab6483..7673396 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -197,11 +197,17 @@ Uses `VERSION_BUMP_TOKEN` (fine-grained PAT with Contents:Read/Write) to push ve ### Audit Suppressions (`specs/audit-ignore.json`) Reviewed, accepted audit findings (deliberate deprecated aliases, intentionally -partial enums, etc.) live in `specs/audit-ignore.json` — never hard-coded in -`audit_sdk.py`. Each run re-derives findings and **only suppresses an entry while -its finding still occurs**; for enums, only the listed `values` are hidden, so a -newly added value still surfaces. Entries matching nothing are reported under a -**Stale Ignores** section so the list stays honest, and suppressed findings are -listed (with reasons) under **Suppressed (Verified)**. To accept a finding, add an -entry (`type` + `key`, plus `direction`/`values` for `enum_staleness`); to stop -accepting it, delete the entry. A missing file means "no suppressions". +partial enums, back-compat kwargs, etc.) live in `specs/audit-ignore.json` — never +hard-coded in `audit_sdk.py`. Each run re-derives findings and **only suppresses an +entry while its finding still occurs**; for the value-bearing types +(`enum_staleness`, `param_drift`), only the listed `values` are hidden, so a newly +added enum value or newly drifted parameter still surfaces. Entries matching nothing +are reported under a **Stale Ignores** section so the list stays honest, and +suppressed findings are listed (with reasons) under **Suppressed (Verified)**. To +accept a finding, add an entry (`type` + `key`, plus `direction`/`values` for the +value-bearing types); to stop accepting it, delete the entry. A missing file means +"no suppressions". + +Supported `type` values: `extra_method`, `enum_staleness`, `param_drift`, +`code_issue`. Prefer an explicit `values` list over `"*"` — a wildcard hides +everything on that key, including drift nobody has reviewed. diff --git a/etsy_python/v3/common/Utils.py b/etsy_python/v3/common/Utils.py index 1c16367..50eeb79 100644 --- a/etsy_python/v3/common/Utils.py +++ b/etsy_python/v3/common/Utils.py @@ -1,7 +1,30 @@ +import warnings from enum import Enum from typing import Any, Dict, List, Optional +def warn_removed_legacy_param(operation_id: str) -> None: + """Warn that `legacy` was removed from an operation and is no longer sent. + + Etsy dropped the `legacy` query parameter from the listing endpoints without + a deprecation period. The keyword argument is kept so existing callers don't + break, but the value is not forwarded. Note `legacy` is still valid on other + operations (receipts, transactions, getListingsByListingIds), which keep it. + + Called whenever ``legacy`` is passed explicitly — including ``legacy=False``. + That is deliberate: ``legacy=False`` previously sent ``?legacy=false`` on the + wire, so silently dropping it is a request-shape change the caller should + know about, not just a no-op. + """ + warnings.warn( + f"'legacy' was removed from {operation_id} by Etsy and is no longer " + "sent (any explicit value, including False, is discarded); it will be " + "dropped from this method in a future major version.", + DeprecationWarning, + stacklevel=3, + ) + + def generate_get_uri(uri: str, params: Optional[Dict[str, Any]] = None) -> str: if not params: return uri diff --git a/etsy_python/v3/resources/Listing.py b/etsy_python/v3/resources/Listing.py index b79bb50..6a03aaa 100644 --- a/etsy_python/v3/resources/Listing.py +++ b/etsy_python/v3/resources/Listing.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from typing import Optional, List, Dict, Any, Union +from etsy_python.v3.common.Utils import warn_removed_legacy_param from etsy_python.v3.enums.Listing import Includes, State, SortOn, SortOrder from etsy_python.v3.exceptions.RequestException import RequestException from etsy_python.v3.models.Listing import ( @@ -25,10 +26,11 @@ def create_draft_listing( listing: CreateDraftListingRequest, legacy: Optional[bool] = None, ) -> Union[Response, RequestException]: + if legacy is not None: + warn_removed_legacy_param("createDraftListing") endpoint = f"/shops/{shop_id}/listings" return self.session.make_request( - endpoint, method=Method.POST, payload=listing, - query_params={"legacy": legacy}, + endpoint, method=Method.POST, payload=listing ) def get_listings_by_shop( @@ -42,6 +44,8 @@ def get_listings_by_shop( includes: Optional[List[Includes]] = None, legacy: Optional[bool] = None, ) -> Union[Response, RequestException]: + if legacy is not None: + warn_removed_legacy_param("getListingsByShop") endpoint = f"/shops/{shop_id}/listings" query_params: Dict[str, Any] = { "state": state.value, @@ -52,7 +56,6 @@ def get_listings_by_shop( "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) @@ -68,13 +71,14 @@ def get_listing( legacy: Optional[bool] = None, allow_suggested_title: Optional[bool] = None, ) -> Union[Response, RequestException]: + if legacy is not None: + warn_removed_legacy_param("getListing") endpoint = f"/listings/{listing_id}" query_params: Dict[str, Any] = { "includes": ",".join(list(map(lambda inc: inc.value, includes))) if includes is not None else None, "language": language, - "legacy": legacy, "allow_suggested_title": allow_suggested_title, } return self.session.make_request(endpoint, query_params=query_params) @@ -95,6 +99,8 @@ def find_all_listings_active( buyer_country: Optional[str] = None, currency: Optional[str] = None, ) -> Union[Response, RequestException]: + if legacy is not None: + warn_removed_legacy_param("findAllListingsActive") endpoint = "/listings/active" query_params: Dict[str, Any] = { "limit": limit, @@ -107,7 +113,6 @@ def find_all_listings_active( "taxonomy_id": taxonomy_id, "shop_location": shop_location, "is_safe": is_safe, - "legacy": legacy, "buyer_country": buyer_country, "currency": currency, } @@ -123,6 +128,8 @@ def find_all_active_listings_by_shop( keywords: Optional[str] = None, legacy: Optional[bool] = None, ) -> Union[Response, RequestException]: + if legacy is not None: + warn_removed_legacy_param("findAllActiveListingsByShop") endpoint = f"/shops/{shop_id}/listings/active" query_params: Dict[str, Any] = { "limit": limit, @@ -130,7 +137,6 @@ def find_all_active_listings_by_shop( "sort_order": sort_order.value, "offset": offset, "keywords": keywords, - "legacy": legacy, } return self.session.make_request(endpoint, query_params=query_params) @@ -228,10 +234,11 @@ def update_listing( self, shop_id: int, listing_id: int, listing: UpdateListingRequest, legacy: Optional[bool] = None, ) -> Union[Response, RequestException]: + if legacy is not None: + warn_removed_legacy_param("updateListing") endpoint = f"/shops/{shop_id}/listings/{listing_id}" return self.session.make_request( - endpoint, method=Method.PATCH, payload=listing, - query_params={"legacy": legacy}, + endpoint, method=Method.PATCH, payload=listing ) def get_listings_by_shop_receipt( diff --git a/etsy_python/v3/resources/ListingInventory.py b/etsy_python/v3/resources/ListingInventory.py index a56889f..9bf455f 100644 --- a/etsy_python/v3/resources/ListingInventory.py +++ b/etsy_python/v3/resources/ListingInventory.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from typing import Optional, Dict, Any, Union +from etsy_python.v3.common.Utils import warn_removed_legacy_param from etsy_python.v3.exceptions.RequestException import RequestException from etsy_python.v3.enums.Listing import InventoryIncludes from etsy_python.v3.enums.ListingInventory import MaxVariationsSupported @@ -21,11 +22,12 @@ def get_listing_inventory( includes: Optional[InventoryIncludes] = None, legacy: Optional[bool] = None, ) -> Union[Response, RequestException]: + if legacy is not None: + warn_removed_legacy_param("getListingInventory") endpoint = f"/listings/{listing_id}/inventory" query_params: Dict[str, Any] = { "show_deleted": show_deleted, "includes": includes.value if includes is not None else None, - "legacy": legacy, } return self.session.make_request(endpoint, query_params=query_params) @@ -34,9 +36,10 @@ def update_listing_inventory( legacy: Optional[bool] = None, max_variations_supported: Optional[MaxVariationsSupported] = None, ) -> Union[Response, RequestException]: + if legacy is not None: + warn_removed_legacy_param("updateListingInventory") endpoint = f"/listings/{listing_id}/inventory" query_params: Dict[str, Any] = { - "legacy": legacy, "max_variations_supported": max_variations_supported.value if max_variations_supported is not None else None, diff --git a/scripts/audit_sdk.py b/scripts/audit_sdk.py index 33fc354..36572a6 100644 --- a/scripts/audit_sdk.py +++ b/scripts/audit_sdk.py @@ -518,6 +518,11 @@ def _norm_values(values: Any) -> Set[str]: return {str(v).lower() for v in values} +# Finding types carrying a `values` set + `direction`, where an ignore may +# suppress specific values and leave newly appeared ones active. +_VALUED_FINDING_TYPES = frozenset({"enum_staleness", "param_drift"}) + + def compute_enum_findings( spec: dict, sdk_enums: Dict[str, List[List[Any]]] ) -> List[dict]: @@ -592,18 +597,68 @@ def best_candidate(spec_value_set: Set[str], candidates: List[List[Any]]): return findings +def compute_param_findings( + implemented: Dict[str, dict], sdk_models: Dict[str, dict] +) -> List[dict]: + """Compare OAS path/query params against SDK method signatures. + + Yields one finding per (operation, direction) whose parameter sets differ. + ``direction`` is ``"missing"`` (in the spec, absent from the SDK signature) + or ``"extra"`` (in the SDK signature, absent from the spec). ``values`` is a + set of parameter names, so an ignore listing specific names suppresses only + those — a newly drifted parameter on an already-suppressed operation still + surfaces. Model-object and path params are excluded from the comparison. + """ + findings: List[dict] = [] + for op_id, mapping in sorted(implemented.items()): + op = mapping["spec"] + sdk = mapping["sdk"] + + spec_params = {p["name"] for p in op["parameters"]} + sdk_params = set(sdk["params"]) + param_annotations = sdk.get("param_annotations", {}) + + # Exclude model-object params from method signature comparison + model_param_names = { + pname + for pname, ptype in param_annotations.items() + if ptype in sdk_models + } + + non_model_sdk_params = sdk_params - model_param_names + spec_only = spec_params - non_model_sdk_params + sdk_only = non_model_sdk_params - spec_params - PATH_PARAM_NAMES + + for direction, values in (("missing", spec_only), ("extra", sdk_only)): + if values: + findings.append( + { + "type": "param_drift", + "key": op_id, + "direction": direction, + "values": values, + "sdk_method": mapping["sdk_method"], + "location": f"{sdk['file']}:{sdk['line']}", + } + ) + return findings + + def partition_findings( findings: List[dict], ignores: List[dict] ) -> Tuple[List[dict], List[dict], List[dict]]: """Split findings into active vs suppressed, and surface stale ignores. A finding matches an ignore when ``type`` and ``key`` are equal (plus - ``direction`` for enum findings). For enum findings the ignore's ``values`` - is re-verified against the finding's *current* values: ``"*"`` suppresses - all; a list suppresses only those values while any remaining (newly - appeared) values stay active — so a suppression can never silently hide a - value it was not reviewed for. An ignore that suppresses nothing on this run - is returned as stale (its condition no longer exists). + ``direction`` for the value-bearing types in ``_VALUED_FINDING_TYPES``). For + those types the ignore's ``values`` is re-verified against the finding's + *current* values: an explicit ``"*"`` suppresses all; a list suppresses only + those values while any remaining (newly appeared) values stay active — so a + suppression can never silently hide a value it was not reviewed for. A valued + ignore that OMITS ``values`` suppresses nothing (it does not default to a + wildcard), so a missing list is caught as stale rather than silently hiding + everything. An ignore that suppresses nothing on this run is returned as + stale (its condition no longer exists). Returns ``(active, suppressed, stale_ignores)``. Each suppressed entry is the finding dict with an added ``"ignore"`` key holding the matched entry. @@ -619,7 +674,7 @@ def partition_findings( continue if ig.get("key") != finding.get("key"): continue - if finding.get("type") == "enum_staleness" and ig.get( + if finding.get("type") in _VALUED_FINDING_TYPES and ig.get( "direction" ) != finding.get("direction"): continue @@ -632,10 +687,13 @@ def partition_findings( ig = ignores[match_idx] - if finding.get("type") == "enum_staleness" and isinstance( + if finding.get("type") in _VALUED_FINDING_TYPES and isinstance( finding.get("values"), set ): - ig_values = ig.get("values", "*") + # An omitted `values` suppresses nothing (and self-reports stale) + # rather than defaulting to a wildcard — a valued ignore must name + # what it hides, so it can never silently swallow unreviewed drift. + ig_values = ig.get("values", []) if ig_values == "*": used[match_idx] = True suppressed.append({**finding, "ignore": ig}) @@ -743,6 +801,7 @@ def generate_report( } ) findings.extend(compute_enum_findings(spec, sdk_enums)) + findings.extend(compute_param_findings(implemented, sdk_models)) for issue in concat_issues: findings.append( { @@ -756,6 +815,7 @@ def generate_report( active_extra = [f for f in active if f["type"] == "extra_method"] active_enum = [f for f in active if f["type"] == "enum_staleness"] active_code = [f for f in active if f["type"] == "code_issue"] + active_param = [f for f in active if f["type"] == "param_drift"] lines.append("## Coverage Summary\n") lines.append(f"- Total OAS operations: {total_ops}") @@ -833,37 +893,26 @@ def generate_report( # --- Query/Path Parameter Drift --- lines.append("\n## Query/Path Parameter Drift\n") lines.append("Mismatches between OAS path/query params and SDK method signatures.\n") - any_query_drift = False - for op_id, mapping in sorted(implemented.items()): - op = mapping["spec"] - sdk = mapping["sdk"] - - spec_params = {p["name"] for p in op["parameters"]} - sdk_params = set(sdk["params"]) - param_annotations = sdk.get("param_annotations", {}) - - # Exclude model-object params from method signature comparison - model_param_names = set() - for pname, ptype in param_annotations.items(): - if ptype in sdk_models: - model_param_names.add(pname) - - non_model_sdk_params = sdk_params - model_param_names - spec_only = spec_params - non_model_sdk_params - sdk_only = non_model_sdk_params - spec_params - PATH_PARAM_NAMES - - if spec_only or sdk_only: - any_query_drift = True + # Grouped by operation so both directions render under one heading, using the + # post-suppression findings from partition_findings. + drift_by_op: Dict[str, Dict[str, dict]] = {} + for f in active_param: + drift_by_op.setdefault(f["key"], {})[f["direction"]] = f + if drift_by_op: + for op_id in sorted(drift_by_op): + directions = drift_by_op[op_id] + any_f = next(iter(directions.values())) lines.append( - f"### {op_id} (`{mapping['sdk_method']}` in {sdk['file']}:{sdk['line']})\n" + f"### {op_id} (`{any_f['sdk_method']}` in {any_f['location']})\n" ) - if spec_only: - lines.append(f"- In spec but not SDK: {', '.join(sorted(spec_only))}") - if sdk_only: - lines.append(f"- In SDK but not spec: {', '.join(sorted(sdk_only))}") + if "missing" in directions: + names = ", ".join(sorted(directions["missing"]["values"])) + lines.append(f"- In spec but not SDK: {names}") + if "extra" in directions: + names = ", ".join(sorted(directions["extra"]["values"])) + lines.append(f"- In SDK but not spec: {names}") lines.append("") - - if not any_query_drift: + else: lines.append("No query/path parameter drift detected.\n") # --- Request Body Drift --- @@ -1014,6 +1063,7 @@ def generate_report( "extra_method": "Extra SDK Methods", "enum_staleness": "Enum Staleness", "code_issue": "Code Issues", + "param_drift": "Query/Path Parameter Drift", } suppressed_by_type: Dict[str, List[dict]] = {} for f in suppressed: @@ -1022,7 +1072,7 @@ def generate_report( lines.append(f"### {type_titles.get(ftype, ftype)}\n") for f in suppressed_by_type[ftype]: reason = f.get("ignore", {}).get("reason", "(no reason given)") - if f["type"] == "enum_staleness": + if f["type"] in _VALUED_FINDING_TYPES: vals = ", ".join(sorted(f["values"])) lines.append(f"- `{f['key']}` [{f['direction']}: {vals}] — {reason}") elif f["type"] == "extra_method": diff --git a/specs/audit-ignore.json b/specs/audit-ignore.json index 9080617..a7979dd 100644 --- a/specs/audit-ignore.json +++ b/specs/audit-ignore.json @@ -1,6 +1,70 @@ { - "_README": "Reviewed, accepted audit findings that should not count as noise. scripts/audit_sdk.py loads this file and, on EVERY run, re-derives findings and only suppresses an entry while its finding still occurs (for enums, only the listed values are suppressed; newly appeared values stay active). Entries that match nothing are reported under 'Stale Ignores' so this list stays honest. Nothing here is hard-coded in the script — to accept a finding, add an entry; to stop accepting it, delete the entry. Match fields: 'type' + 'key' (+ 'direction' for enum_staleness). 'values' (enum only): \"*\" = all, or a list of specific values. 'reason'/'added' are documentation.", + "_README": "Reviewed, accepted audit findings that should not count as noise. scripts/audit_sdk.py loads this file and, on EVERY run, re-derives findings and only suppresses an entry while its finding still occurs (for enum_staleness and param_drift, only the listed values are suppressed; newly appeared values stay active). Entries that match nothing are reported under 'Stale Ignores' so this list stays honest. Nothing here is hard-coded in the script — to accept a finding, add an entry; to stop accepting it, delete the entry. Match fields: 'type' + 'key' (+ 'direction' for enum_staleness and param_drift). 'values' (enum_staleness/param_drift only): \"*\" = all, or a list of specific values. 'reason'/'added' are documentation.", "ignores": [ + { + "type": "param_drift", + "key": "createDraftListing", + "direction": "extra", + "values": ["legacy"], + "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": "getListingsByShop", + "direction": "extra", + "values": ["legacy"], + "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": "getListing", + "direction": "extra", + "values": ["legacy"], + "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": "findAllListingsActive", + "direction": "extra", + "values": ["legacy"], + "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": "findAllActiveListingsByShop", + "direction": "extra", + "values": ["legacy"], + "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": "updateListing", + "direction": "extra", + "values": ["legacy"], + "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": "getListingInventory", + "direction": "extra", + "values": ["legacy"], + "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": "updateListingInventory", + "direction": "extra", + "values": ["legacy"], + "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": "extra_method", "key": "ListingResource.get_listings_by_listings_ids", diff --git a/specs/baseline.json b/specs/baseline.json index 4c63119..b1cf463 100644 --- a/specs/baseline.json +++ b/specs/baseline.json @@ -294,16 +294,6 @@ "format": "int64", "minimum": 1 } - }, - { - "name": "legacy", - "in": "query", - "description": "This parameter is needed to enable new parameters and response values related to processing profiles.", - "required": false, - "schema": { - "type": "boolean", - "description": "This parameter is needed to enable new parameters and response values related to processing profiles." - } } ], "requestBody": { @@ -762,16 +752,6 @@ }, "default": null } - }, - { - "name": "legacy", - "in": "query", - "description": "This parameter is needed to enable new parameters and response values related to processing profiles.", - "required": false, - "schema": { - "type": "boolean", - "description": "This parameter is needed to enable new parameters and response values related to processing profiles." - } } ], "responses": { @@ -984,16 +964,6 @@ "default": null } }, - { - "name": "legacy", - "in": "query", - "description": "This parameter is needed to enable new parameters and response values related to processing profiles.", - "required": false, - "schema": { - "type": "boolean", - "description": "This parameter is needed to enable new parameters and response values related to processing profiles." - } - }, { "name": "allow_suggested_title", "in": "query", @@ -1646,16 +1616,6 @@ "default": null } }, - { - "name": "legacy", - "in": "query", - "description": "This parameter is needed to enable new parameters and response values related to processing profiles.", - "required": false, - "schema": { - "type": "boolean", - "description": "This parameter is needed to enable new parameters and response values related to processing profiles." - } - }, { "name": "is_safe", "in": "query", @@ -1817,16 +1777,6 @@ "description": "Search term or phrase that must appear in all results.", "default": null } - }, - { - "name": "legacy", - "in": "query", - "description": "This parameter is needed to enable new parameters and response values related to processing profiles.", - "required": false, - "schema": { - "type": "boolean", - "description": "This parameter is needed to enable new parameters and response values related to processing profiles." - } } ], "responses": { @@ -2347,16 +2297,6 @@ "Listing" ] } - }, - { - "name": "legacy", - "in": "query", - "description": "This parameter is needed to enable new parameters and response values related to processing profiles.", - "required": false, - "schema": { - "type": "boolean", - "description": "This parameter is needed to enable new parameters and response values related to processing profiles." - } } ], "responses": { @@ -2449,16 +2389,6 @@ "minimum": 1 } }, - { - "name": "legacy", - "in": "query", - "description": "This parameter is needed to enable new parameters and response values related to processing profiles.", - "required": false, - "schema": { - "type": "boolean", - "description": "This parameter is needed to enable new parameters and response values related to processing profiles." - } - }, { "name": "max_variations_supported", "in": "query", @@ -2707,7 +2637,7 @@ "/v3/application/listings/batch/inventory": { "get": { "operationId": "getListingsInventoryByListingIds", - "description": "
General ReleaseReport bug

This endpoint is ready for production use.

\n\nRetrieves the inventory record for each listing referenced by listing ID. Requires the listings_r OAuth scope. Limit 100 listing IDs per request.", + "description": "
General ReleaseReport bug

This endpoint is ready for production use.

\n\nRetrieves the inventory record for each listing referenced by listing ID. Requires the `listings_r` OAuth scope. Limit 100 listing IDs per request. All requested listing IDs must exist — if any single ID is not found, the entire request returns a 404. SKUs within product records are only returned for listings owned by the authenticated user; they are stripped (returned as empty string) for listings owned by other sellers.", "tags": [ "ShopListing Inventory" ], @@ -3370,11 +3300,11 @@ }, "question_text": { "type": "string", - "description": "The title of the personalization question. Must be between 1 and 45 characters. Note: During the migration to the new personalization endpoints, if you're still using a legacy UI (without a title input),please send the default value 'Personalization'." + "description": "The title of the personalization question. Must be between 1 and 45 characters. See https://developers.etsy.com/documentation/tutorials/personalization-migration#writing-listing-personalization-data" }, "instructions": { "type": "string", - "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.", + "description": "Optional instructions for a personalization question. This field is not allowed for 'dropdown' questions. See https://developers.etsy.com/documentation/tutorials/personalization-migration#writing-listing-personalization-data", "nullable": true }, "question_type": { @@ -3426,6 +3356,12 @@ "label" ] } + }, + "add_on_price": { + "type": "number", + "description": "The add-on price for a question. This field is optional and only supported for optional questions of type text_input.", + "format": "float", + "nullable": true } }, "required": [ @@ -4026,7 +3962,7 @@ "/v3/application/listings/batch/shipping": { "get": { "operationId": "getListingsShippingByListingIds", - "description": "
General ReleaseReport bug

This endpoint is ready for production use.

\n\nRetrieves the shipping profile for each listing referenced by listing ID. Requires the shops_r OAuth scope. Limit 100 listing IDs per request.", + "description": "
General ReleaseReport bug

This endpoint is ready for production use.

\n\nRetrieves the shipping profile for each listing referenced by listing ID. Requires the `shops_r` OAuth scope. Limit 100 listing IDs per request. All requested listing IDs must exist — if any single ID is not found, the entire request returns a 404. Shipping profile data (including `shipping_profile_id`) is only returned for listings owned by the authenticated user; it is nulled out for listings owned by other sellers.", "tags": [ "ShopListing" ], @@ -4645,16 +4581,6 @@ "format": "int64", "minimum": 1 } - }, - { - "name": "legacy", - "in": "query", - "description": "This parameter is needed to enable new parameters and response values related to processing profiles.", - "required": false, - "schema": { - "type": "boolean", - "description": "This parameter is needed to enable new parameters and response values related to processing profiles." - } } ], "requestBody": { @@ -15053,6 +14979,14 @@ "format": "int64", "nullable": true }, + "add_on_price": { + "oneOf": [ + { + "$ref": "#/components/schemas/Money" + } + ], + "nullable": true + }, "options": { "type": "array", "nullable": true, diff --git a/tests/test_audit_ignore.py b/tests/test_audit_ignore.py index 8bea92e..1a4685b 100644 --- a/tests/test_audit_ignore.py +++ b/tests/test_audit_ignore.py @@ -279,6 +279,162 @@ def test_duplicate_sdk_enum_name_picks_best_overlap(self): assert findings[0]["values"] == {"d"} +# --------------------------------------------------------------------------- # +# compute_param_findings — query/path parameter drift +# --------------------------------------------------------------------------- # +class TestComputeParamFindings: + def _implemented(self, spec_params, sdk_params, annotations=None): + return { + "getX": { + "spec": {"parameters": [{"name": n} for n in spec_params]}, + "sdk": { + "params": list(sdk_params), + "param_annotations": annotations or {}, + "file": "X.py", + "line": 10, + }, + "sdk_method": "get_x", + } + } + + def test_extra_sdk_param_detected(self): + findings = audit_sdk.compute_param_findings( + self._implemented(["limit"], ["limit", "legacy"]), {} + ) + assert len(findings) == 1 + assert findings[0]["type"] == "param_drift" + assert findings[0]["key"] == "getX" + assert findings[0]["direction"] == "extra" + assert findings[0]["values"] == {"legacy"} + assert findings[0]["location"] == "X.py:10" + + def test_missing_sdk_param_detected(self): + findings = audit_sdk.compute_param_findings( + self._implemented(["limit", "offset"], ["limit"]), {} + ) + assert len(findings) == 1 + assert findings[0]["direction"] == "missing" + assert findings[0]["values"] == {"offset"} + + def test_in_sync_yields_no_findings(self): + assert ( + audit_sdk.compute_param_findings( + self._implemented(["limit"], ["limit"]), {} + ) + == [] + ) + + def test_both_directions_yield_separate_findings(self): + findings = audit_sdk.compute_param_findings( + self._implemented(["limit"], ["legacy"]), {} + ) + assert {f["direction"] for f in findings} == {"missing", "extra"} + + def test_model_payload_param_excluded(self): + # A request-model argument is not a query param and must not be flagged. + findings = audit_sdk.compute_param_findings( + self._implemented( + ["limit"], ["limit", "listing"], {"listing": "UpdateListingRequest"} + ), + {"UpdateListingRequest": {"init_params": []}}, + ) + assert findings == [] + + def test_path_params_excluded(self): + findings = audit_sdk.compute_param_findings( + self._implemented(["limit"], ["limit", "shop_id"]), {} + ) + assert findings == [] + + +# --------------------------------------------------------------------------- # +# partition_findings — param_drift value verification +# --------------------------------------------------------------------------- # +class TestPartitionParamDrift: + def _drift(self, values, direction="extra"): + return { + "type": "param_drift", + "key": "getListing", + "direction": direction, + "values": set(values), + "sdk_method": "get_listing", + "location": "Listing.py:66", + } + + def _ignore(self, values, direction="extra"): + return { + "type": "param_drift", + "key": "getListing", + "direction": direction, + "values": values, + "reason": "kept for backward compatibility", + } + + def test_listed_param_suppressed(self): + active, suppressed, stale = audit_sdk.partition_findings( + [self._drift({"legacy"})], [self._ignore(["legacy"])] + ) + assert active == [] + assert len(suppressed) == 1 + assert suppressed[0]["values"] == {"legacy"} + assert stale == [] + + def test_new_param_stays_active_while_known_param_suppressed(self): + # The whole point: a newly drifted param on an already-suppressed + # operation must still surface. + active, suppressed, stale = audit_sdk.partition_findings( + [self._drift({"legacy", "brand_new"})], [self._ignore(["legacy"])] + ) + assert len(active) == 1 + assert active[0]["values"] == {"brand_new"} + assert suppressed[0]["values"] == {"legacy"} + assert stale == [] + + def test_direction_mismatch_is_not_suppressed(self): + active, suppressed, stale = audit_sdk.partition_findings( + [self._drift({"legacy"}, "extra")], + [self._ignore(["legacy"], "missing")], + ) + assert len(active) == 1 + assert suppressed == [] + assert len(stale) == 1 + + def test_resolved_drift_makes_ignore_stale(self): + # Once the kwarg is actually removed, the entry suppresses nothing. + active, suppressed, stale = audit_sdk.partition_findings( + [], [self._ignore(["legacy"])] + ) + assert active == [] and suppressed == [] + assert len(stale) == 1 + + def test_omitted_values_suppresses_nothing(self): + # A valued ignore with NO `values` key must NOT behave as a wildcard — + # otherwise it would silently hide unreviewed drift. It suppresses + # nothing (finding stays fully active) and self-reports as stale. + ig = { + "type": "param_drift", + "key": "getListing", + "direction": "extra", + "reason": "no values key", + } + active, suppressed, stale = audit_sdk.partition_findings( + [self._drift({"legacy", "unreviewed"})], [ig] + ) + assert len(active) == 1 + assert active[0]["values"] == {"legacy", "unreviewed"} + assert suppressed == [] + assert len(stale) == 1 + + def test_explicit_wildcard_still_suppresses_all(self): + # `"*"` written explicitly is still honoured (distinct from omission). + active, suppressed, stale = audit_sdk.partition_findings( + [self._drift({"legacy", "other"})], [self._ignore("*")] + ) + assert active == [] + assert len(suppressed) == 1 + assert stale == [] + + # --------------------------------------------------------------------------- # # get_spec_enums — parameter-level enum extraction # --------------------------------------------------------------------------- # @@ -428,5 +584,38 @@ def test_shipped_ignore_file_is_valid_and_nonempty(self): assert len(ignores) >= 1 for ig in ignores: assert "type" in ig and "key" in ig and "reason" in ig - if ig["type"] == "enum_staleness": + if ig["type"] in audit_sdk._VALUED_FINDING_TYPES: assert "direction" in ig and "values" in ig + + def test_no_duplicate_match_keys(self): + # partition_findings applies only the FIRST ignore matching a given + # (type, key, direction), so a duplicate would silently suppress just + # part of a finding and then report itself as stale. + path = SCRIPTS_DIR.parent / "specs" / "audit-ignore.json" + seen = set() + for ig in audit_sdk.load_ignores(path): + match_key = (ig["type"], ig["key"], ig.get("direction")) + assert match_key not in seen, f"duplicate ignore entry: {match_key}" + seen.add(match_key) + + def test_no_wildcard_param_drift_ignores(self): + # "*" on a param_drift entry would hide unreviewed parameter drift on + # that operation, defeating the self-verifying property. + path = SCRIPTS_DIR.parent / "specs" / "audit-ignore.json" + for ig in audit_sdk.load_ignores(path): + 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. + 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 + for ig in entries: + assert ig["direction"] == "extra" + assert ig["values"] == ["legacy"] diff --git a/tests/test_listing_models.py b/tests/test_listing_models.py index b635a14..58dd223 100644 --- a/tests/test_listing_models.py +++ b/tests/test_listing_models.py @@ -302,6 +302,23 @@ def test_missing_mandatory_raises(self): with pytest.raises(Exception): UpdateListingPersonalizationRequest() + def test_add_on_price_passes_through(self): + # `add_on_price` was added to the updateListingPersonalization request + # body in the 2026-07-27 spec. Questions are typed as + # List[Dict[str, Any]], so new per-question fields reach the API without + # an SDK change — this pins that pass-through behaviour. + req = UpdateListingPersonalizationRequest( + personalization_questions=[ + { + "question_text": "Name?", + "question_type": "text_input", + "required": False, + "add_on_price": 4.50, + } + ] + ) + assert req.get_dict()["personalization_questions"][0]["add_on_price"] == 4.50 + class TestUpdateListingVideoRequest: def test_sets_file_and_data(self): diff --git a/tests/test_listing_resource.py b/tests/test_listing_resource.py index 9918f06..a711cab 100644 --- a/tests/test_listing_resource.py +++ b/tests/test_listing_resource.py @@ -1,3 +1,4 @@ +import warnings from unittest.mock import MagicMock import pytest @@ -43,9 +44,79 @@ def test_calls_post_with_payload(self, mock_session): f"/shops/{MOCK_SHOP_ID}/listings", method=Method.POST, payload=payload, - query_params={"legacy": None}, ) + @pytest.mark.parametrize("legacy_value", [True, False]) + def test_legacy_warns_and_is_not_sent(self, mock_session, legacy_value): + mock_session.make_request.return_value = Response(201, make_shop_listing()) + resource = ListingResource(session=mock_session) + payload = MagicMock(spec=CreateDraftListingRequest) + + with pytest.warns( + DeprecationWarning, match=r"from createDraftListing by Etsy" + ): + resource.create_draft_listing(MOCK_SHOP_ID, payload, legacy=legacy_value) + + assert "query_params" not in mock_session.make_request.call_args[1] + + +class TestRemovedLegacyParamOnGets: + """Etsy removed `legacy` from these listing GETs; the kwarg is accepted for + backward compatibility but warns and is no longer forwarded.""" + + @pytest.mark.parametrize("legacy_value", [True, False]) + @pytest.mark.parametrize( + "method_name,operation_id,args", + [ + ("get_listings_by_shop", "getListingsByShop", (MOCK_SHOP_ID,)), + ("get_listing", "getListing", (MOCK_LISTING_ID,)), + ("find_all_listings_active", "findAllListingsActive", ()), + ( + "find_all_active_listings_by_shop", + "findAllActiveListingsByShop", + (MOCK_SHOP_ID,), + ), + ], + ) + def test_legacy_warns_and_is_not_sent( + self, mock_session, method_name, operation_id, args, legacy_value + ): + mock_session.make_request.return_value = Response( + 200, make_shop_listing_collection() + ) + resource = ListingResource(session=mock_session) + + # `legacy=False` must warn too: any explicit value is a removed param. + # Anchor the match — a bare op id substring-matches sibling ops + # (e.g. "getListing" is a prefix of "getListingsByShop"). + with pytest.warns(DeprecationWarning, match=rf"from {operation_id} by Etsy"): + getattr(resource, method_name)(*args, legacy=legacy_value) + + qp = mock_session.make_request.call_args[1]["query_params"] + assert "legacy" not in qp + + @pytest.mark.parametrize( + "method_name,args", + [ + ("get_listings_by_shop", (MOCK_SHOP_ID,)), + ("get_listing", (MOCK_LISTING_ID,)), + ("find_all_listings_active", ()), + ("find_all_active_listings_by_shop", (MOCK_SHOP_ID,)), + ], + ) + def test_no_warning_when_legacy_omitted(self, mock_session, method_name, args): + mock_session.make_request.return_value = Response( + 200, make_shop_listing_collection() + ) + resource = ListingResource(session=mock_session) + + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + getattr(resource, method_name)(*args) + + qp = mock_session.make_request.call_args[1]["query_params"] + assert "legacy" not in qp + class TestGetListingsByShop: def test_default_params(self, mock_session): @@ -445,9 +516,23 @@ def test_calls_patch_with_payload(self, mock_session): f"/shops/{MOCK_SHOP_ID}/listings/{MOCK_LISTING_ID}", method=Method.PATCH, payload=payload, - query_params={"legacy": None}, ) + @pytest.mark.parametrize("legacy_value", [True, False]) + def test_legacy_warns_and_is_not_sent(self, mock_session, legacy_value): + mock_session.make_request.return_value = Response(200, make_shop_listing()) + resource = ListingResource(session=mock_session) + payload = MagicMock(spec=UpdateListingRequest) + + # `updateListing` is itself a prefix of `updateListingInventory`, so + # anchor with the trailing " by Etsy" to pin the exact operation. + with pytest.warns(DeprecationWarning, match=r"from updateListing by Etsy"): + resource.update_listing( + MOCK_SHOP_ID, MOCK_LISTING_ID, payload, legacy=legacy_value + ) + + assert "query_params" not in mock_session.make_request.call_args[1] + class TestGetListingsByShopReceipt: def test_basic_call(self, mock_session): diff --git a/tests/test_remaining_resources.py b/tests/test_remaining_resources.py index 10929e5..73526f6 100644 --- a/tests/test_remaining_resources.py +++ b/tests/test_remaining_resources.py @@ -1,5 +1,7 @@ from unittest.mock import MagicMock +import pytest + from etsy_python.v3.models.Listing import ( CreateListingTranslationRequest, UpdateListingTranslationRequest, @@ -158,7 +160,7 @@ def test_update_listing_inventory(self, mock_session): f"/listings/{MOCK_LISTING_ID}/inventory", method=Method.PUT, payload=payload, - query_params={"legacy": None, "max_variations_supported": None}, + query_params={"max_variations_supported": None}, ) def test_update_listing_inventory_with_max_variations(self, mock_session): @@ -170,15 +172,51 @@ def test_update_listing_inventory_with_max_variations(self, mock_session): resource.update_listing_inventory( MOCK_LISTING_ID, payload, - legacy=True, max_variations_supported=MaxVariationsSupported.THREE, ) mock_session.make_request.assert_called_once_with( f"/listings/{MOCK_LISTING_ID}/inventory", method=Method.PUT, payload=payload, - query_params={"legacy": True, "max_variations_supported": "3"}, + query_params={"max_variations_supported": "3"}, + ) + + @pytest.mark.parametrize("legacy_value", [True, False]) + def test_update_listing_inventory_legacy_warns_and_is_not_sent( + self, mock_session, legacy_value + ): + mock_session.make_request.return_value = Response( + 200, make_listing_inventory() + ) + resource = ListingInventoryResource(session=mock_session) + payload = MagicMock(spec=UpdateListingInventoryRequest) + + with pytest.warns( + DeprecationWarning, match=r"from updateListingInventory by Etsy" + ): + resource.update_listing_inventory( + MOCK_LISTING_ID, payload, legacy=legacy_value + ) + + qp = mock_session.make_request.call_args[1]["query_params"] + assert "legacy" not in qp + + @pytest.mark.parametrize("legacy_value", [True, False]) + def test_get_listing_inventory_legacy_warns_and_is_not_sent( + self, mock_session, legacy_value + ): + mock_session.make_request.return_value = Response( + 200, make_listing_inventory() ) + resource = ListingInventoryResource(session=mock_session) + + with pytest.warns( + DeprecationWarning, match=r"from getListingInventory by Etsy" + ): + resource.get_listing_inventory(MOCK_LISTING_ID, legacy=legacy_value) + + qp = mock_session.make_request.call_args[1]["query_params"] + assert "legacy" not in qp # --- ListingVideo ---