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
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,14 +200,15 @@ Reviewed, accepted audit findings (deliberate deprecated aliases, intentionally
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
(`enum_staleness`, `param_drift`, `body_drift`), only the listed `values` are hidden,
so a newly added enum value or newly drifted parameter or body field 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.
`body_drift`, `code_issue`. Prefer an explicit `values` list over `"*"` — a wildcard
hides everything on that key, including drift nobody has reviewed.
103 changes: 86 additions & 17 deletions etsy_python/v3/models/Listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
_PERSONALIZATION_DEPRECATION_MSG = (
"is_personalizable, personalization_is_required, "
"personalization_char_count_max, and personalization_instructions "
"are deprecated by the Etsy API and scheduled for removal. "
"were removed from createDraftListing and updateListing by the Etsy API "
"and are no longer sent. "
"Use the personalization endpoint (update_listing_personalization) "
"instead. See "
"https://developers.etsy.com/documentation/tutorials/personalization-migration "
Expand All @@ -30,12 +31,18 @@ def _warn_if_personalization_used(
personalization_is_required: Optional[bool],
personalization_char_count_max: Optional[int],
personalization_instructions: Optional[str],
stacklevel: int = 4,
) -> None:
"""Emit DeprecationWarning when a deprecated personalization field is actively set.
"""Emit DeprecationWarning when a removed personalization field is actively set.

Falsy values (False, 0, empty string, None) match the API's documented
defaults and remain no-ops after the fields are removed, so they do not
trigger the warning.
defaults and were no-ops even before the fields were removed, so they do
not trigger the warning.

``stacklevel`` counts the frames back to the caller. It defaults to the
constructor path (this function <- ``_store_personalization`` <- model
``__init__`` <- caller); the property setters pass 3, being one frame
shallower.
"""
if any([
is_personalizable,
Expand All @@ -46,11 +53,81 @@ def _warn_if_personalization_used(
warnings.warn(
_PERSONALIZATION_DEPRECATION_MSG,
DeprecationWarning,
stacklevel=3,
stacklevel=stacklevel,
)


class CreateDraftListingRequest(Request):
class _PersonalizationFieldsMixin:
"""Accessors for the personalization fields Etsy removed from listings.

Etsy dropped these four fields from the createDraftListing and
updateListing request bodies (2026-09 spec). The constructor keyword
arguments are kept so existing callers don't break, but the values are
stored under underscore-prefixed names, which ``todict`` excludes from
serialization -- so they are never sent on the wire.

The properties below keep both attribute reads and writes working. A write
stores the value and warns, exactly like passing the keyword argument: it
never reaches the API either way. Remove the keyword arguments and this
mixin in the next major version.
"""

@property
def is_personalizable(self) -> Optional[bool]:
return self._is_personalizable

@is_personalizable.setter
def is_personalizable(self, value: Optional[bool]) -> None:
self._is_personalizable = value
_warn_if_personalization_used(value, None, None, None, stacklevel=3)

@property
def personalization_is_required(self) -> Optional[bool]:
return self._personalization_is_required

@personalization_is_required.setter
def personalization_is_required(self, value: Optional[bool]) -> None:
self._personalization_is_required = value
_warn_if_personalization_used(None, value, None, None, stacklevel=3)

@property
def personalization_char_count_max(self) -> Optional[int]:
return self._personalization_char_count_max

@personalization_char_count_max.setter
def personalization_char_count_max(self, value: Optional[int]) -> None:
self._personalization_char_count_max = value
_warn_if_personalization_used(None, None, value, None, stacklevel=3)

@property
def personalization_instructions(self) -> Optional[str]:
return self._personalization_instructions

@personalization_instructions.setter
def personalization_instructions(self, value: Optional[str]) -> None:
self._personalization_instructions = value
_warn_if_personalization_used(None, None, None, value, stacklevel=3)

def _store_personalization(
self,
is_personalizable: Optional[bool],
personalization_is_required: Optional[bool],
personalization_char_count_max: Optional[int],
personalization_instructions: Optional[str],
) -> None:
self._is_personalizable = is_personalizable
self._personalization_is_required = personalization_is_required
self._personalization_char_count_max = personalization_char_count_max
self._personalization_instructions = personalization_instructions
_warn_if_personalization_used(
is_personalizable,
personalization_is_required,
personalization_char_count_max,
personalization_instructions,
)


class CreateDraftListingRequest(_PersonalizationFieldsMixin, Request):
nullable = [
"shipping_profile_id",
"return_policy_id",
Expand Down Expand Up @@ -137,11 +214,7 @@ def __init__(
self.item_height = item_height
self.item_weight_unit = item_weight_unit
self.item_dimensions_unit = item_dimensions_unit
self.is_personalizable = is_personalizable
self.personalization_is_required = personalization_is_required
self.personalization_char_count_max = personalization_char_count_max
self.personalization_instructions = personalization_instructions
_warn_if_personalization_used(
self._store_personalization(
is_personalizable,
personalization_is_required,
personalization_char_count_max,
Expand All @@ -161,7 +234,7 @@ def __init__(
)


class UpdateListingRequest(Request):
class UpdateListingRequest(_PersonalizationFieldsMixin, Request):
nullable: List[str] = [
"materials",
"shipping_profile_id",
Expand Down Expand Up @@ -232,11 +305,7 @@ def __init__(
self.who_made = who_made
self.when_made = when_made
self.featured_rank = featured_rank
self.is_personalizable = is_personalizable
self.personalization_is_required = personalization_is_required
self.personalization_char_count_max = personalization_char_count_max
self.personalization_instructions = personalization_instructions
_warn_if_personalization_used(
self._store_personalization(
is_personalizable,
personalization_is_required,
personalization_char_count_max,
Expand Down
178 changes: 101 additions & 77 deletions scripts/audit_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,7 @@ def _norm_values(values: Any) -> Set[str]:

# 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"})
_VALUED_FINDING_TYPES = frozenset({"enum_staleness", "param_drift", "body_drift"})


def compute_enum_findings(
Expand Down Expand Up @@ -669,6 +669,85 @@ def compute_param_findings(
return findings


def compute_body_findings(
spec: dict, implemented: Dict[str, dict], sdk_models: Dict[str, dict]
) -> List[dict]:
"""Compare OAS request body fields against SDK model/method fields.

Yields one finding per (operation, direction) whose field sets differ.
``direction`` is ``"missing"`` (in the spec body, absent from the SDK) or
``"extra"`` (in the SDK, absent from the spec body). ``values`` is a set of
field names, so an ignore listing specific names suppresses only those — a
newly drifted body field on an already-suppressed operation still surfaces.

Multipart-only endpoints are skipped: their ``FileRequest`` subclasses hold
fields in ``data``/``file`` dicts that this signature-level comparison
cannot resolve.
"""
findings: List[dict] = []
for op_id, mapping in sorted(implemented.items()):
op = mapping["spec"]
sdk = mapping["sdk"]

spec_body_fields = get_request_body_fields(op, spec)
if not spec_body_fields:
continue

rb = op.get("requestBody", {})
content = rb.get("content", {}) if rb else {}
if "multipart/form-data" in content and "application/json" not in content:
continue

param_annotations = sdk.get("param_annotations", {})
model_class_name = None
for pname, ptype in param_annotations.items():
if ptype in sdk_models:
model_class_name = ptype
break

# Names excluded from the "extra" direction only. A path/query param in
# the method signature is not an unexpected body field, but it may
# legitimately also be a body field (e.g. taxonomy_id), so it must stay
# in the set that answers "does the SDK accept this body field?".
non_body_names: Set[str] = set()

if model_class_name:
model_info = sdk_models[model_class_name]
# Normalize kwargs the SDK serializes under a different spec name
# (listing_type/profile_type -> type), so they aren't false drift.
sdk_fields = {
TYPE_FIELD_ALIASES.get(f, f) for f in model_info["init_params"]
}
location = (
f"model `{model_class_name}` in "
f"models/{model_info['file']}:{model_info['line']}"
)
else:
# Method has body fields but no model object - compare against params
sdk_fields = set(sdk["params"])
non_body_names = {p["name"] for p in op["parameters"]} | PATH_PARAM_NAMES
location = (
f"`{mapping['sdk_method']}` in "
f"{sdk['file']}:{sdk['line']}, no model class"
)

spec_only = spec_body_fields - sdk_fields
sdk_only = sdk_fields - spec_body_fields - non_body_names

for direction, values in (("missing", spec_only), ("extra", sdk_only)):
if values:
findings.append(
{
"type": "body_drift",
"key": op_id,
"direction": direction,
"values": values,
"location": location,
}
)
return findings


def partition_findings(
findings: List[dict], ignores: List[dict]
) -> Tuple[List[dict], List[dict], List[dict]]:
Expand Down Expand Up @@ -827,6 +906,7 @@ def generate_report(
)
findings.extend(compute_enum_findings(spec, sdk_enums))
findings.extend(compute_param_findings(implemented, sdk_models))
findings.extend(compute_body_findings(spec, implemented, sdk_models))
for issue in concat_issues:
findings.append(
{
Expand All @@ -841,6 +921,7 @@ def generate_report(
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"]
active_body = [f for f in active if f["type"] == "body_drift"]

lines.append("## Coverage Summary\n")
lines.append(f"- Total OAS operations: {total_ops}")
Expand Down Expand Up @@ -943,82 +1024,24 @@ def generate_report(
# --- Request Body Drift ---
lines.append("\n## Request Body Drift\n")
lines.append("Mismatches between OAS request body fields and SDK model class fields.\n")
any_body_drift = False
for op_id, mapping in sorted(implemented.items()):
op = mapping["spec"]
sdk = mapping["sdk"]

spec_body_fields = get_request_body_fields(op, spec)
if not spec_body_fields:
continue

# Skip multipart/form-data endpoints (FileRequest subclasses have different patterns)
rb = op.get("requestBody", {})
content = rb.get("content", {}) if rb else {}
if "multipart/form-data" in content and "application/json" not in content:
continue

param_annotations = sdk.get("param_annotations", {})

# Find the model class used by this method
model_class_name = None
for pname, ptype in param_annotations.items():
if ptype in sdk_models:
model_class_name = ptype
break

if model_class_name:
model_info = sdk_models[model_class_name]
model_fields = model_info["init_params"]
# Normalize known aliases (_type -> type mapping in todict)
normalized_model_fields = set()
for f in model_fields:
if f in TYPE_FIELD_ALIASES:
normalized_model_fields.add(TYPE_FIELD_ALIASES[f])
else:
normalized_model_fields.add(f)

body_spec_only = spec_body_fields - normalized_model_fields
body_sdk_only = normalized_model_fields - spec_body_fields

if body_spec_only or body_sdk_only:
any_body_drift = True
lines.append(
f"### {op_id} (model `{model_class_name}` in models/{model_info['file']}:{model_info['line']})\n"
)
if body_spec_only:
lines.append(
f"- In spec but not model: {', '.join(sorted(body_spec_only))}"
)
if body_sdk_only:
lines.append(
f"- In model but not spec: {', '.join(sorted(body_sdk_only))}"
)
lines.append("")
else:
# Method has body fields but no model object - compare against method params
sdk_params = set(sdk["params"])
body_spec_only = spec_body_fields - sdk_params
body_sdk_only = sdk_params - spec_body_fields - {
p["name"] for p in op["parameters"]
} - PATH_PARAM_NAMES

if body_spec_only or body_sdk_only:
any_body_drift = True
lines.append(
f"### {op_id} (`{mapping['sdk_method']}` in {sdk['file']}:{sdk['line']}, no model class)\n"
)
if body_spec_only:
lines.append(
f"- In spec but not SDK: {', '.join(sorted(body_spec_only))}"
)
if body_sdk_only:
lines.append(
f"- In SDK but not spec: {', '.join(sorted(body_sdk_only))}"
)
lines.append("")

if not any_body_drift:
# Grouped by operation so both directions render under one heading, using the
# post-suppression findings from partition_findings.
body_by_op: Dict[str, Dict[str, dict]] = {}
for f in active_body:
body_by_op.setdefault(f["key"], {})[f["direction"]] = f
if body_by_op:
for op_id in sorted(body_by_op):
directions = body_by_op[op_id]
any_f = next(iter(directions.values()))
lines.append(f"### {op_id} ({any_f['location']})\n")
if "missing" in directions:
names = ", ".join(sorted(directions["missing"]["values"]))
lines.append(f"- In spec but not model: {names}")
if "extra" in directions:
names = ", ".join(sorted(directions["extra"]["values"]))
lines.append(f"- In model but not spec: {names}")
lines.append("")
else:
lines.append("No request body drift detected.\n")

# --- Enum Staleness ---
Expand Down Expand Up @@ -1089,6 +1112,7 @@ def generate_report(
"enum_staleness": "Enum Staleness",
"code_issue": "Code Issues",
"param_drift": "Query/Path Parameter Drift",
"body_drift": "Request Body Drift",
}
suppressed_by_type: Dict[str, List[dict]] = {}
for f in suppressed:
Expand Down
Loading
Loading