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": "
This endpoint is ready for production use.
This endpoint is ready for production use.
This endpoint is ready for production use.
This endpoint is ready for production use.