feat(handlers): expose sync_catalogs through sales-catalog-driven specialism#838
Conversation
…tion note, example - Add sync_catalogs stub to _SyncSalesPlatform (SalesPlatform @runtime_checkable now requires the method for isinstance to return True) - Add 6.1.0-beta.1 migration note documenting the isinstance behavior change - Add examples/hello_seller_catalog.py showing discovery mode, delete_missing guard, and ergonomic list return
…-catalogs-specialism-v2 # Conflicts: # MIGRATION_v5_to_v6.md
|
Resolved the current-main conflict by keeping both the canonical-format migration notes from main and this PR's sales-catalog-driven migration note, then pushed merge commit 55ba144.\n\nLocal validation:\n- |
|
Acknowledged — conflict resolution looks clean (keeping both migration note sections is correct), and 235 passed locally is a good signal. Waiting on the refreshed GitHub checks before marking ready for review is the right call. No action needed from triage; will re-engage if checks flag anything on the source issue. Triaged by Claude Code. Session: https://claude.ai/code/session_0179cSkzoBXQiY7swXZsA8Ja Generated by Claude Code |
# Conflicts: # MIGRATION_v5_to_v6.md
There was a problem hiding this comment.
LGTM. Follow-ups noted below. Closes the sales-catalog-driven wire-up gap from #786 by mirroring the sync_audiences shim shape — full request through (no arg_projector), list-arm projection to the wire envelope, _require_platform_method gate for the non-serve() construction path.
Things I checked
- Specialism mapping is internally consistent.
_CATALOG_ADVERTISED_TOOLS(handler.py:286-291) → unioned intosales-catalog-drivenonly (handler.py:406-408) → unioned into the class-level universe (handler.py:1045) → andsync_catalogsis in_OPTIONAL_PLATFORM_METHODS(handler.py:378-380). No drift across the four sites that have to agree. _require_platform_methodgate is structurally meaningful, not dead code. Forsales-catalog-driven,validate_platformboot-fails first, so the gate is unreachable on that path. But for programmaticPlatformHandlerconstruction withoutserve(), the gate convertsAttributeError → INTERNAL_ERRORintoUNSUPPORTED_FEATURE. The test attests/test_decisioning_handler_shims.py:439-476pins this. Good.- Wire envelope is conformant.
_project_sync_catalogsathandler.py:808-822wraps the list arm as{catalogs: [...]}; upstreamsync-catalogs-response.jsonrequires onlycatalogson the success arm,dry_runis optional.ad-tech-protocol-expert: sound. - Discovery mode is upstream-defined, not an SDK invention.
sync-catalogs-request.jsonline 4 and the requestrequiredblock confirmcatalogsis optional andcatalogs is Noneis the spec's discovery semantics. The Protocol docstring atsales.py:286-298andexamples/hello_seller_catalog.py:67-74match. delete_missing=True + catalogs=Noneguard is upstream-defined. Request schema lines 74-89 enforce this via JSON-Schemaif/thenwith the sameerrorMessage("would delete all buyer-managed catalogs"). The example raisingAdcpError("INVALID_REQUEST", field="catalogs")surfaces a spec-mandated rejection — defense-in-depth, since the transport-layer validator catches it first.isinstance(x, SalesPlatform)behavior change is documented.MIGRATION_v5_to_v6.md:303-322and the_SyncSalesPlatformtest fixture (tests/test_lazy_platform_router.py:87-91) both pin the structural consequence of addingsync_catalogsto the Protocol body. On a6.1.0-beta.1pre-release train,feat:is acceptable —feat!:would force a6.x → 7.xjump under release-please that's wrong for a beta.- Test additions cover every load-bearing seam. Advertised-tools coverage (advertised_per_specialism.py:359-394), full-request passthrough, discovery passthrough, list-arm projection, all four
_project_sync_catalogsarms (handler_shims.py:396-433), and theUNSUPPORTED_FEATUREgate. The "all wire tools" enumeration intest_advertised_tools_covers_every_specialism_wire_tooladdssync_catalogsin both locations (L85-89, L134).
Follow-ups (non-blocking — file as issues)
dry_runis silently dropped from the list-arm projection._project_sync_catalogs(handler.py:808-822) doesn't echodry_run: truewhenparams.dry_runis set and the adopter returned the ergonomic list. Pre-existing pattern shared with_project_sync_audiences(handler.py:790-806), so not a regression — but an adopter using the list arm in a dry-run path makes the buyer think real mutations happened. Either echoparams.dry_runinto the wrapper or document the list arm as "non-dry-run only" insales.py:281. (ad-tech-protocol-expert: caveat #1.)validation_mode=strictsemantics are adopter-side. The shim passes the field through with zero enforcement; an adopter undervalidation_mode=strictwho returns a list with anyaction: "failed"row produces a success envelope where the spec calls for aSyncCatalogsErrorenvelope. Worth documenting onsales.py:296-298or enforcing in the shim. (ad-tech-protocol-expert: caveat #2.)_maybe_auto_emit_sync_completionfires underdry_run=Trueand discovery mode. Pre-existing pattern noted in the PR description; worth pinning a follow-up to make the auto-emit aware of dry-run/discovery and skip the buyer notification.
Minor nits (non-blocking)
getattron a typed Pydantic field in the example.examples/hello_seller_catalog.py:73,76usesgetattr(req, "delete_missing", False)/getattr(req, "catalogs", None)wherereq: SyncCatalogsRequestis a known Pydantic model. Defensive-getattr is appropriate in framework code crossing the Protocol boundary, but in adopter-facing example code it models the wrong pattern. Replace withreq.delete_missing/req.catalogs.- Handler shim docstring doesn't cross-link the
delete_missing=True + catalogs=Nonerejection rule.handler.py:2412-2425describes the two return arms but doesn't mention the spec-undefined combination called out atsales.py:298-301. Buyers reading the handler signature won't see the rule. Cross-link or duplicate the warning. - Unversioned schema path in the projection docstring.
handler.py:813citesschemas/cache/media-buy/sync-catalogs-response.json; actual cached versions are3.0/and3.1.0-beta.3/. Consistent with the_project_sync_audiencesdocstring (L793) so not new churn, but the path rots when the schema cache cycles. - Migration-note stub uses dict return.
MIGRATION_v5_to_v6.md:316-319showsreturn {"catalogs": []}— correct envelope but loses type information. Cross-reference to the typedSyncCatalogsSuccessResponse(catalogs=[])form for adopters who type-check.
Notable that the third _SyncSalesPlatform test-fixture cleanup (tests/test_lazy_platform_router.py:87-91) lands as part of the same PR — the "add a required Protocol method" pattern wants a structural-conformance helper, but that is a separate refactor and out of scope here.
Approving on the strength of the internally-consistent specialism wiring plus the upstream-conformant discovery and delete_missing semantics.
There was a problem hiding this comment.
Hold. The adopter-facing example crashes on import — the single artifact this PR added per dx-expert's previous block does not run as documented.
MUST FIX (blocking)
examples/hello_seller_catalog.py:17— broken import, example crashes at startup.from adcp import AdcpErrorfails:AdcpErroris not exported fromadcptop-level (src/adcp/__init__.pyhas no entry, and the module-level__getattr__at line 561 raisesAttributeErrorfor unknown names). Every other example in the tree usesfrom adcp.decisioning import AdcpError—examples/hello_mock_seller.py:91,examples/multi_platform_seller/src/account_store.py:25,examples/v3_reference_seller/src/app.py:58. Running the example via the documenteduv run python examples/hello_seller_catalog.pyraisesImportErrorbeforeserve()is reached. Fix: change line 17 tofrom adcp.decisioning import AdcpError(or fold it into the existingfrom adcp.decisioning import (...)block on line 18).
Things I checked
- Specialism wiring at
src/adcp/decisioning/handler.py:406-408correctly composes_CATALOG_ADVERTISED_TOOLSwith_SALES_ADVERTISED_TOOLS | _ACCOUNT_ADVERTISED_TOOLSforsales-catalog-drivenonly. - The shim at
src/adcp/decisioning/handler.py:2407-2441omitsarg_projector— fullSyncCatalogsRequestpasses through, soreq.delete_missing,req.dry_run,req.validation_mode, andreq.catalogs is Nonediscovery mode all reach the adopter intact._require_platform_method("sync_catalogs")runs before_invoke_platform_methodso absent methods surfaceUNSUPPORTED_FEATURE, notINTERNAL_ERROR. _project_sync_catalogsathandler.py:808-820wraps list-arm into{catalogs: [...]}—ad-tech-protocol-expertconfirmed this matchesschemas/cache/3.1.0-beta.3/media-buy/sync-catalogs-response.json:165-167exactly._OPTIONAL_PLATFORM_METHODSentry athandler.py:380is correct —sync_catalogsis required at boot forsales-catalog-driven(viaREQUIRED_METHODS_PER_SPECIALISMindispatch.py:200-209), and absent on all other sales-* claims, so the runtime AttributeError shield is needed. No naming collision._maybe_auto_emit_sync_completion("sync_catalogs", ...)works becausesync_catalogsis already inSPEC_WEBHOOK_TASK_TYPESatwebhook_emit.py:89. (Pre-PR; PR body's reference to_SYNC_COMPLETION_METHODSis the wrong constant name — non-blocking.)- Test coverage:
tests/test_decisioning_dispatch.pypins thevalidate_platformhard-fail at boot forsales-catalog-drivenwithoutsync_catalogs;tests/test_decisioning_handler_shims.pycovers full-request passthrough, discovery-modecatalogs=Nonepassthrough, list-arm projection, and theUNSUPPORTED_FEATUREgate._SyncSalesPlatform(test_lazy_platform_router.py:87) and_SalesShim(test_decisioning_specialisms.py:401) fixtures both gain the stub. - Migration note at
MIGRATION_v5_to_v6.md:306-323documents the@runtime_checkable isinstanceconsequence clearly with a working snippet.
Follow-ups (non-blocking — file as issues)
- Sibling-Protocol asymmetry vs.
sync_audiences. Perad-tech-protocol-expert:sync_audienceslives on its ownAudiencePlatformProtocol (src/adcp/decisioning/specialisms/audience.py:51-92), butsync_catalogsis grafted onto the sharedSalesPlatformbody. Wire-equivalent, but five non-catalogsales-*specialisms now carry a method they're not supposed to implement, weakening@runtime_checkablevalue. ACatalogDrivenPlatformsibling Protocol would mirror the audience pattern. PR body acknowledges this as intentional — fine to defer, but worth filing. - Docstring wording at
src/adcp/decisioning/specialisms/sales.py:301-303.delete_missing=Truewithcatalogs=Noneis spec-rejected, not spec-undefined — the upstreamschemas/.../sync-catalogs-request.jsonhas an explicitif/then(lines 73-88) requiringcatalogswhendelete_missing=true. Tighten the wording in the next pass. - The three nits the PR body already enumerates (the
_require_platform_method"optional method" message,_maybe_auto_emit_sync_completionfiring ondry_run=True, theMaybeAsyncreturn-type mismatch on the list arm) are all pre-existing pattern. Don't drag them into this PR.
Re-request review once the import is fixed.
There was a problem hiding this comment.
LGTM. Clean re-cut of #790 — sync_catalogs lands on the same rails as sync_audiences, which is the right precedent for a per-specialism wire tool with a list/envelope return.
Things I checked
_CATALOG_ADVERTISED_TOOLSunioned only intosales-catalog-driven(src/adcp/decisioning/handler.py:404-407); regression-pinned both ways attests/test_decisioning_advertised_per_specialism.py:359-395. No othersales-*specialism leaks the tool.- Wire envelope
{catalogs: [...]}in_project_sync_catalogs(handler.py:808-822) matchesschemas/cache/3.1.0-beta.3/media-buy/sync-catalogs-response.jsonSyncCatalogsSuccessarm. Byte-identical structure to_project_sync_audiencesathandler.py:785-805. - Deliberate
arg_projectoromission on thesync_catalogsshim (handler.py:2407-2441) — adopters get the fullSyncCatalogsRequestsoreq.delete_missing/req.dry_run/req.validation_mode/req.catalog_idsare inspectable. Documented divergence fromsync_audiences. - Discovery mode (
req.catalogs is None) is spec-defined per the request schema description, not SDK-invented.delete_missing=true+catalogs=Nonerejection is correct — the schema'sif/thenatsync-catalogs-request.json:73-87makescatalogsrequired whendelete_missing=true. - Boot-fail-on-missing-method pinned at
tests/test_decisioning_dispatch.py:130-160. UNSUPPORTED_FEATURE backstop pinned attests/test_decisioning_handler_shims.py:440-476— the gate is only reachable via programmaticPlatformHandlerconstruction withoutserve(), as the PR body calls out. MIGRATION_v5_to_v6.md6.1.0-beta.1 entry documents the@runtime_checkableisinstanceconsequence._SyncSalesPlatform(tests/test_lazy_platform_router.py:87-90) and thetest_decisioning_specialisms.py_SalesShim(line 401-402) both updated.- 235/235 tests pass per PR body, mirroring CI.
Follow-ups (non-blocking — file as issues)
- Migration-note heading is stale.
MIGRATION_v5_to_v6.md:306is headed### 6.1.0-beta.1butpyproject.toml:7already pins6.1.1b2. Adopters diffing the lockfile from an older beta will read this entry as historical and skip it. Bump to whatever release-please cuts next. - "Spec-undefined" wording is wrong. Docstring at
src/adcp/decisioning/specialisms/sales.py:300and the example atexamples/hello_seller_catalog.py:78saydelete_missing=True+catalogs=Noneis "spec-undefined." The request schema'sif/thenconstraint (sync-catalogs-request.json:73-87) explicitly forbids that combination with a normativeerrorMessage. Behavior is right; comment should say "spec-forbidden" / "schema-rejected." - List-arm projection drops
dry_run._project_sync_catalogs(handler.py:808-822) wraps the ergonomic list into{catalogs: [...]}but doesn't threadreq.dry_runthrough to the top-level envelope where the response schema places it (sync-catalogs-response.jsonlines 20-23). Pre-existing pattern shared withsync_audiences, so out of scope here — file once and fix in both places. - Conventional-commit signal. Adding a required method to a
@runtime_checkableProtocol breaksisinstancefor adopters not implementing it. The PR title isfeat(handlers):. Acceptable on a beta with a documented migration note, butfeat(handlers)!:with aBREAKING CHANGE:footer matches the precedent inCHANGELOG.md:14(the 6.1.0-beta.2 callout). Worth being stricter on the next breaking beta change.
Minor nits (non-blocking)
- Protocol body uses
raise NotImplementedErrorinstead of.... Every other method onSalesPlatformuses...as the Protocol body;sync_catalogsatsrc/adcp/decisioning/specialisms/sales.py:310is the odd one out (added by fixup75dca7ffto satisfy a lint complaint). Minor consequence:_require_platform_method'shasattrcheck athandler.py:2211now silently passes for subclasses that inherit-but-don't-override, so the dispatch-time backstop is slightly weakened. The boot-fail rule still catchessales-catalog-drivenadopters; this just narrows the safety net for the programmatic-construction edge case the docstring at handler.py:2199-2210 advertises. Worth aligning to...if there's a less invasive way to silence the lint. - Dead membership.
_OPTIONAL_PLATFORM_METHODS(handler.py:357-381) is referenced only by its own docstring — addingsync_catalogsthere is documentation, not behavior. Consistent with prior pattern, just noting it's inert. - Example:
or []after aNone-check is dead.examples/hello_seller_catalog.py:99hasreq.catalogs or []inside the comprehension, but line 96 already early-returned onreq.catalogs is None.
The shared SalesPlatform Protocol vs. a CatalogDrivenPlatform mixin trade-off is acknowledged in the PR body and matches the sync_audiences / AudiencePlatform precedent. Reasonable call — mixin refactor is a wider blast radius than this PR should carry.
Safe to merge.
Closes #786
Re-cut of #790 from current main (
19fde5e). PR #790 was conflicting (dirty) with maintainer edits disabled; this branch is a clean re-apply of the same logic on top of6.1.0-beta.1.Summary
sync_catalogsintoSPECIALISM_TO_ADVERTISED_TOOLSsosales-catalog-drivenplatforms expose the tool viatools/listPlatformHandler.sync_catalogsdispatcher — fullSyncCatalogsRequestpassed through (noarg_projector) so adopters can inspectreq.delete_missing,req.dry_run,req.validation_mode, andreq.catalog_idssync_catalogstoSalesPlatformProtocol body (required section,sales-catalog-drivenonly)_require_platform_method("sync_catalogs")guard — non-catalog-driven platforms that somehow receive the call surfaceUNSUPPORTED_FEATUREinstead ofINTERNAL_ERRORsync_catalogsto_OPTIONAL_PLATFORM_METHODS(specialism-gated variant — absent on all non-sales-catalog-drivenplatforms)examples/hello_seller_catalog.py— runnable minimal adopter with discovery mode,delete_missingguard, and ergonomic list returnMIGRATION_v5_to_v6.md6.1.0-beta.1 entry documentingSalesPlatform@runtime_checkableisinstancebehavior change_SyncSalesPlatformtest fixture to remain structurally conformant withSalesPlatformProtocol note:
sync_catalogson the sharedSalesPlatformProtocolAdding
sync_catalogsto the sharedSalesPlatformProtocol (rather than a separateCatalogDrivenPlatformmixin) is intentional and consistent with howsync_audiencesis handled onAudiencePlatform. The consequence is thatisinstance(platform, SalesPlatform)now requiressync_catalogsfor structural conformance — non-catalog-driven platforms that use this check need a stub. This is documented inMIGRATION_v5_to_v6.md. A separateCatalogDrivenPlatformmixin would be cleaner but is a larger structural change; see code-reviewer sign-off below.What was tested
pytest tests/test_decisioning_advertised_per_specialism.py tests/test_decisioning_dispatch.py tests/test_decisioning_handler_shims.py tests/test_decisioning_specialisms.py tests/test_lazy_platform_router.py— 235 passedruff check src/— cleanpython -m mypy src/adcp/decisioning/handler.py src/adcp/decisioning/specialisms/sales.py— cleanNits (not fixing in this PR):
_require_platform_methodmessage says "optional method" — technically imprecise forsync_catalogsonsales-catalog-driven, butvalidate_platform(called fromserve()) hard-fails at boot before any buyer reaches this gate; the backstop is only reachable via programmaticPlatformHandlerconstruction withoutserve(). Pre-existing pattern used by 8 other methods._maybe_auto_emit_sync_completionfires ondry_run=Truerequests — pre-existing pattern shared withsync_audiences;sync_catalogsinwebhook_emit._SYNC_COMPLETION_METHODSis already present on main.MaybeAsync[SyncCatalogsSuccessResponse]Protocol return type doesn't formally include the ergonomiclist[SyncCatalogResult]arm — pre-existing mismatch shared withsync_audiences.Pre-PR review
code-reviewer: No blockers. Design concern noted (shared Protocol body vs. separate mixin) — explained above as intentional pattern. 1 nit (inline fixture duplication across 3 shim tests). Approved.
dx-expert: Approved (2nd pass). Previous blockers resolved: example added,
_SyncSalesPlatformfixture fixed, migration note added. No new blockers.Session: https://claude.ai/code/session_01NYBCVdwct1Hhz7GD1eaEHr
Generated by Claude Code