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
22 changes: 14 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
23 changes: 23 additions & 0 deletions etsy_python/v3/common/Utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
23 changes: 15 additions & 8 deletions etsy_python/v3/resources/Listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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)

Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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,
}
Expand All @@ -123,14 +128,15 @@ 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,
"sort_on": sort_on.value,
"sort_order": sort_order.value,
"offset": offset,
"keywords": keywords,
"legacy": legacy,
}
return self.session.make_request(endpoint, query_params=query_params)

Expand Down Expand Up @@ -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(
Expand Down
7 changes: 5 additions & 2 deletions etsy_python/v3/resources/ListingInventory.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)

Expand All @@ -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,
Expand Down
126 changes: 88 additions & 38 deletions scripts/audit_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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})
Expand Down Expand Up @@ -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(
{
Expand All @@ -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}")
Expand Down Expand Up @@ -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 ---
Expand Down Expand Up @@ -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:
Expand All @@ -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":
Expand Down
Loading
Loading