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
19 changes: 17 additions & 2 deletions .github/workflows/pr-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,19 @@ name: PR Tests
on:
pull_request:
types: [opened, synchronize, reopened]
# Manual trigger so tests can be run on demand for any ref — including a PR
# whose head commit carries a [skip ci] marker, which GitHub otherwise uses
# to short-circuit pull_request/push runs before the workflow starts.
# workflow_dispatch is not subject to that skip-ci short-circuit.
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to comment coverage on (optional; omit to just run tests)'
required: false
type: string

concurrency:
group: pr-tests-${{ github.event.pull_request.number }}
group: pr-tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
Expand Down Expand Up @@ -49,6 +59,11 @@ jobs:
name: Coverage Report
needs: test
runs-on: ubuntu-latest
# Only post a PR comment when there is a PR to comment on: either the
# pull_request event, or a manual dispatch that supplied a pr_number.
if: >-
github.event_name == 'pull_request' ||
(github.event_name == 'workflow_dispatch' && inputs.pr_number != '')
steps:
- uses: actions/download-artifact@v4
with:
Expand Down Expand Up @@ -113,7 +128,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER=${{ github.event.pull_request.number }}
PR_NUMBER="${{ github.event.pull_request.number || inputs.pr_number }}"
MARKER="<!-- pr-test-coverage -->"

# Try to find and update an existing comment first (avoids race where
Expand Down
4 changes: 4 additions & 0 deletions etsy_python/v3/enums/Listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ class SortOrder(Enum):


class Includes(Enum):
# SHIPPING and INVENTORY were removed from the includes enum on getListing
# and getListingsByListingIds, but remain valid on getListingsByShop, which
# shares this enum. They are kept for backward compatibility; removing them
# would break callers. May be revisited in the next major version.
SHIPPING = "Shipping"
IMAGES = "Images"
SHOP = "Shop"
Expand Down
82 changes: 70 additions & 12 deletions scripts/audit_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,14 +121,44 @@ def get_request_body_schema(op: dict, spec: dict) -> Optional[dict]:


def get_spec_enums(spec: dict) -> Dict[str, List[str]]:
"""Extract all enum definitions from schemas."""
"""Extract all enum definitions from the spec.

Covers two locations:
1. Component schema properties (``components/schemas/<Schema>.<prop>``).
2. Operation parameters (``<operationId>.<param>``), including array
parameters whose values live under ``schema.items.enum`` rather than
``schema.enum``. Parameter-level enums (e.g. the ``includes`` filter on
the listing endpoints) are not defined as component schemas, so
omitting them left those SDK enums entirely unaudited.
"""
enums = {}
for name, schema in spec.get("components", {}).get("schemas", {}).items():
for prop_name, prop in schema.get("properties", {}).items():
prop = resolve_ref(prop, spec)
if "enum" in prop:
key = f"{name}.{prop_name}"
enums[key] = prop["enum"]

for path, methods in spec.get("paths", {}).items():
for method, details in methods.items():
if method not in ("get", "post", "put", "delete", "patch"):
continue
op_id = details.get("operationId", f"{method}:{path}")
for param in details.get("parameters", []):
param = resolve_ref(param, spec)
param_name = param.get("name")
if not param_name:
continue
param_schema = param.get("schema", {})
# Scalar enum params (schema.enum) and array enum params
# (schema.items.enum) both need to be surfaced.
enum_values = param_schema.get("enum")
if enum_values is None:
items = param_schema.get("items", {})
if isinstance(items, dict):
enum_values = items.get("enum")
if enum_values is not None:
enums[f"{op_id}.{param_name}"] = enum_values
return enums


Expand Down Expand Up @@ -193,9 +223,17 @@ def scan_resource_methods(resources_dir: Path) -> Dict[str, Dict[str, dict]]:
return resources


def scan_enum_values(enums_dir: Path) -> Dict[str, List[str]]:
"""Scan enum .py files and extract enum class names and their values."""
sdk_enums = {}
def scan_enum_values(enums_dir: Path) -> Dict[str, List[List[Any]]]:
"""Scan enum .py files and extract enum class names and their values.

Two distinct enum classes may legitimately share a name across files (e.g.
``Includes`` in both ``Listing.py`` and ``ListingInventory.py``). Keying a
plain dict by class name would let whichever file sorts last silently
clobber the other, corrupting the comparison. So each name maps to a *list*
of value-lists (one per class definition); the matcher picks the best
overlap per spec enum.
"""
sdk_enums: Dict[str, List[List[Any]]] = {}
for py_file in sorted(enums_dir.glob("*.py")):
if py_file.name.startswith("__"):
continue
Expand All @@ -215,7 +253,7 @@ def scan_enum_values(enums_dir: Path) -> Dict[str, List[str]]:
if isinstance(item.value, ast.Constant):
values.append(item.value.value)
if values:
sdk_enums[node.name] = values
sdk_enums.setdefault(node.name, []).append(values)

return sdk_enums

Expand Down Expand Up @@ -481,41 +519,61 @@ def _norm_values(values: Any) -> Set[str]:


def compute_enum_findings(
spec: dict, sdk_enums: Dict[str, List[str]]
spec: dict, sdk_enums: Dict[str, List[List[Any]]]
) -> List[dict]:
"""Compare OAS enum definitions against SDK enum classes.

Yields one finding per (spec enum field, matched SDK enum, direction) whose
value sets differ. ``direction`` is ``"missing"`` (present in the spec,
absent from the SDK) or ``"extra"`` (present in the SDK, absent from the
spec). ``values`` is a set of lower-cased strings.

``sdk_enums`` maps each class name to the list of value-lists for every
class of that name (names can collide across files). When the expected name
matches, or when falling back to fuzzy overlap, the candidate value-list
with the greatest overlap against the spec enum is used, so a same-named but
unrelated enum can't hijack the comparison.
"""

def best_candidate(spec_value_set: Set[str], candidates: List[List[Any]]):
"""Return the candidate value-list sharing the most values with the spec."""
best_vals, best_overlap = None, -1
for vals in candidates:
overlap = len(_norm_values(vals) & spec_value_set)
if overlap > best_overlap:
best_overlap, best_vals = overlap, vals
return best_vals, max(best_overlap, 0)

findings: List[dict] = []
spec_enums = get_spec_enums(spec)
for spec_key, spec_values in sorted(spec_enums.items()):
prop_name = spec_key.split(".")[-1] if "." in spec_key else spec_key
expected_enum_name = snake_to_pascal(prop_name)
spec_value_set = _norm_values(spec_values)

best_match = None
matched_values = None
if expected_enum_name in sdk_enums:
best_match = expected_enum_name
matched_values, _ = best_candidate(
spec_value_set, sdk_enums[expected_enum_name]
)
else:
best_overlap = 0
for sdk_name, sdk_values in sdk_enums.items():
sdk_value_set = {str(v).lower() for v in sdk_values}
spec_value_set = {str(v).lower() for v in spec_values}
overlap = len(sdk_value_set & spec_value_set)
for sdk_name, candidates in sdk_enums.items():
vals, overlap = best_candidate(spec_value_set, candidates)
sdk_value_set = _norm_values(vals) if vals else set()
smaller_set = min(len(sdk_value_set), len(spec_value_set))
min_required = max(2, smaller_set // 2)
if overlap >= min_required and overlap > best_overlap:
best_overlap = overlap
best_match = sdk_name
matched_values = vals

if not best_match:
continue

sdk_value_set = {str(v).lower() for v in sdk_enums[best_match]}
spec_value_set = {str(v).lower() for v in spec_values}
sdk_value_set = _norm_values(matched_values) if matched_values else set()
missing = spec_value_set - sdk_value_set
extra = sdk_value_set - spec_value_set
key = f"{spec_key} -> {best_match}"
Expand Down
18 changes: 16 additions & 2 deletions scripts/diff_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,22 @@ def diff_parameters(
changes.append(
f"type: {old_schema.get('type')} -> {new_schema.get('type')}"
)
if old_schema.get("enum") != new_schema.get("enum"):
changes.append("enum values changed")
# Scalar enum params carry values under schema.enum; array params (e.g.
# the `includes` filter) carry them under schema.items.enum. Check both.
old_enum = old_schema.get("enum", old_schema.get("items", {}).get("enum"))
new_enum = new_schema.get("enum", new_schema.get("items", {}).get("enum"))
if old_enum != new_enum:
added = sorted(set(new_enum or []) - set(old_enum or []))
removed = sorted(set(old_enum or []) - set(new_enum or []))
detail = []
if added:
detail.append(f"added {added}")
if removed:
detail.append(f"removed {removed}")
changes.append(
"enum values changed"
+ (f" ({'; '.join(detail)})" if detail else "")
)
if changes:
result["changed"].append(f"{name} ({', '.join(changes)})")

Expand Down
32 changes: 32 additions & 0 deletions specs/audit-ignore.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,38 @@
"values": ["removed"],
"reason": "State.REMOVED is kept for backward compatibility and is not in the OAS response schema. Documented inline in enums/Listing.py; may be removed in the next major version.",
"added": "2026-06-02"
},
{
"type": "enum_staleness",
"key": "getListing.includes -> Includes",
"direction": "extra",
"values": ["shipping", "inventory"],
"reason": "Etsy removed 'Shipping' and 'Inventory' from the includes enum on getListing and getListingsByListingIds (still valid on getListingsByShop). The SDK Includes enum keeps both for backward compatibility and because they remain valid on getListingsByShop, which shares the enum; removing them would be a breaking change. Documented inline in enums/Listing.py.",
"added": "2026-07-08"
},
{
"type": "enum_staleness",
"key": "getListingsByListingIds.includes -> Includes",
"direction": "extra",
"values": ["shipping", "inventory"],
"reason": "Etsy removed 'Shipping' and 'Inventory' from the includes enum on getListing and getListingsByListingIds (still valid on getListingsByShop). The SDK Includes enum keeps both for backward compatibility and because they remain valid on getListingsByShop, which shares the enum; removing them would be a breaking change. Documented inline in enums/Listing.py.",
"added": "2026-07-08"
},
{
"type": "enum_staleness",
"key": "getListingsByShop.state -> State",
"direction": "extra",
"values": ["removed"],
"reason": "Same State.REMOVED back-compat value as ShopListing.state; surfaced additionally as the state query parameter on getListingsByShop now that parameter-level enums are audited. Documented inline in enums/Listing.py.",
"added": "2026-07-08"
},
{
"type": "enum_staleness",
"key": "updateHolidayPreferences.holiday_id -> CA_HOLIDAYS",
"direction": "missing",
"values": "*",
"reason": "Same by-design holiday_id handling as ShopHolidayPreference.holiday_id; surfaced additionally as the holiday_id parameter on updateHolidayPreferences now that parameter-level enums are audited. The SDK names US_HOLIDAYS (1-11) and CA_HOLIDAYS (12-23) and documents passing integer IDs directly for other regions. See enums/HolidayPreferences.py.",
"added": "2026-07-08"
}
]
}
12 changes: 4 additions & 8 deletions specs/baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -953,20 +953,18 @@
{
"name": "includes",
"in": "query",
"description": "An enumerated string that attaches a valid association. Acceptable inputs are 'Shipping', 'Shop', 'Images', 'User', 'Translations', 'Videos', 'Inventory' and 'Personalization'.",
"description": "An enumerated string that attaches a valid association. Acceptable inputs are 'Shop', 'Images', 'User', 'Translations', 'Videos', 'Personalization' and 'BuyerPrice'.",
"required": false,
"schema": {
"type": "array",
"description": "An enumerated string that attaches a valid association. Acceptable inputs are 'Shipping', 'Shop', 'Images', 'User', 'Translations', 'Videos', 'Inventory' and 'Personalization'.",
"description": "An enumerated string that attaches a valid association. Acceptable inputs are 'Shop', 'Images', 'User', 'Translations', 'Videos', 'Personalization' and 'BuyerPrice'.",
"items": {
"type": "string",
"enum": [
"Shipping",
"Images",
"Shop",
"User",
"Translations",
"Inventory",
"Videos",
"Personalization",
"BuyerPrice"
Expand Down Expand Up @@ -2923,20 +2921,18 @@
{
"name": "includes",
"in": "query",
"description": "An enumerated string that attaches a valid association. Acceptable inputs are 'Shipping', 'Shop', 'Images', 'User', 'Translations', 'Videos', 'Inventory' and 'Personalization'.",
"description": "An enumerated string that attaches a valid association. Acceptable inputs are 'Shop', 'Images', 'User', 'Translations', 'Videos', 'Personalization' and 'BuyerPrice'.",
"required": false,
"schema": {
"type": "array",
"description": "An enumerated string that attaches a valid association. Acceptable inputs are 'Shipping', 'Shop', 'Images', 'User', 'Translations', 'Videos', 'Inventory' and 'Personalization'.",
"description": "An enumerated string that attaches a valid association. Acceptable inputs are 'Shop', 'Images', 'User', 'Translations', 'Videos', 'Personalization' and 'BuyerPrice'.",
"items": {
"type": "string",
"enum": [
"Shipping",
"Images",
"Shop",
"User",
"Translations",
"Inventory",
"Videos",
"Personalization",
"BuyerPrice"
Expand Down
Loading