From 5029a46aae1fa1402cf284afd92bccf6dc5edcb8 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 25 Aug 2026 07:08:01 +0100 Subject: [PATCH] fix(types)!: expose object unions without RootModel BREAKING CHANGE: AccountReference, PostalArea, and SignalRef are now composable union aliases. Use TypeAdapter(Alias).validate_python(...) instead of Alias.model_validate(...). --- docs/extending-types.md | 32 +++++++++++++++++++++ src/adcp/compat/purchase_continuation.py | 7 +++-- src/adcp/decisioning/helpers.py | 7 ++--- src/adcp/types/_eager.py | 6 ++-- src/adcp/types/aliases.py | 29 ++++++++++++++++++- tests/test_account_v3_wire.py | 12 ++++---- tests/test_adcp_31_beta4_surface.py | 7 +++-- tests/test_capabilities.py | 10 +++---- tests/test_decisioning_handler_shims.py | 10 +++---- tests/test_discriminated_unions.py | 34 +++++++++++++++++++++++ tests/test_feed_mirror.py | 4 +-- tests/test_mechanical_helpers.py | 5 ++-- tests/test_oauth_passthrough.py | 2 +- tests/test_postal_area_compat.py | 13 +++++++-- tests/test_roster_store.py | 14 +++++++--- tests/type_checks/object_union_aliases.py | 23 +++++++++++++++ 16 files changed, 174 insertions(+), 41 deletions(-) create mode 100644 tests/type_checks/object_union_aliases.py diff --git a/docs/extending-types.md b/docs/extending-types.md index a49989f9c..03c68fe38 100644 --- a/docs/extending-types.md +++ b/docs/extending-types.md @@ -13,6 +13,38 @@ This guide shows how to extend ADCP types safely while maintaining protocol comp > overrides to walk children — Pydantic does the walking; this guide covers the two seams > (`Field(exclude=True)` and `@model_serializer`) that hook into it. +## Object Unions Are Type Aliases + +Object unions such as `AccountReference`, `PostalArea`, `SignalRef`, and +`PricingOption` are public union aliases, not Pydantic `RootModel` classes. +This keeps their object arms composable and subclassable when an adopter needs +a stricter `model_config` or internal excluded fields, without imposing another +public wrapper around the constituent schema arms. + +Construct a known arm directly. For example: + +```python +from adcp.types import AccountReferenceById + +account = AccountReferenceById(account_id="acct-1") +``` + +For untrusted data where the arm is not known in advance, use Pydantic's +`TypeAdapter`: + +```python +from pydantic import TypeAdapter +from adcp.types import AccountReference + +account = TypeAdapter(AccountReference).validate_python(raw_account) +``` + +Code written against the former wrappers should replace +`AccountReference.model_validate(raw)` (and the corresponding calls on +`PostalArea` or `SignalRef`) with `TypeAdapter(...).validate_python(raw)`. +The validated wire shapes are unchanged; only the unnecessary outer public +wrapper is removed, and validation returns the selected schema arm directly. + ## Picking the Right Base Class — Context-Specific Schema Variants Several entity names (`Creative`, `Package`, `MediaBuy`, etc.) appear in multiple spec slices with **genuinely different shapes**. The bare name resolves to one specific variant — typically not the one you want when extending response types. The creative inside `ListCreativesResponse.creatives` is a different class from the creative inside `GetCreativeDeliveryResponse.creatives`, even though both are spelled `Creative` in the spec. Subclassing the wrong variant produces silent type drift: construction works, but `mypy` flags `[assignment]` when you wire your subclass into the response that expects a different variant, and runtime serialization may drop fields the consuming code expects. diff --git a/src/adcp/compat/purchase_continuation.py b/src/adcp/compat/purchase_continuation.py index 8cacc0803..c7f035c6a 100644 --- a/src/adcp/compat/purchase_continuation.py +++ b/src/adcp/compat/purchase_continuation.py @@ -29,7 +29,7 @@ from urllib.parse import unquote_plus, urlsplit import rfc8785 -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, TypeAdapter, ValidationError from adcp.types import AccountReference, CompatibilityPurchaseCoordinatorInput from adcp.types.core import TaskResult, TaskStatus @@ -40,6 +40,7 @@ ) JsonObject: TypeAlias = dict[str, Any] +_ACCOUNT_REFERENCE_ADAPTER: TypeAdapter[AccountReference] = TypeAdapter(AccountReference) LegacyPurchaseResult: TypeAlias = Mapping[str, Any] | BaseModel | TaskResult[Any] LegacyPurchaseExecutor: TypeAlias = Callable[ ["LegacyPurchaseExecution"], LegacyPurchaseResult | Awaitable[LegacyPurchaseResult] @@ -1515,10 +1516,10 @@ def _account_payload(value: Mapping[str, Any] | Any) -> JsonObject: try: source = ( value.model_dump(mode="python", by_alias=True) - if isinstance(value, AccountReference) + if isinstance(value, BaseModel) else value ) - model = AccountReference.model_validate(source) + model = _ACCOUNT_REFERENCE_ADAPTER.validate_python(source) payload = model.model_dump(mode="json", by_alias=True, exclude_none=True) except ValidationError as exc: raise _invalid("account must be a valid beta.4 AccountReference") from exc diff --git a/src/adcp/decisioning/helpers.py b/src/adcp/decisioning/helpers.py index 4a980373d..905414c99 100644 --- a/src/adcp/decisioning/helpers.py +++ b/src/adcp/decisioning/helpers.py @@ -44,9 +44,8 @@ def ref_account_id( value = ref.get("account_id") return value if isinstance(value, str) else None - # AccountReference is a RootModel wrapping AccountReference1 | - # AccountReference2. Its __getattr__ proxies to .root, so a direct - # ``ref.account_id`` raises AttributeError on the natural-key arm. - # getattr() with a default is the cleanest cross-arm read. + # getattr() with a default is the cleanest cross-arm read and also keeps + # compatibility with request models parsed by older SDK releases, where + # AccountReference was represented by a proxying RootModel wrapper. value = getattr(ref, "account_id", None) return value if isinstance(value, str) else None diff --git a/src/adcp/types/_eager.py b/src/adcp/types/_eager.py index e96facdf3..ad8034637 100644 --- a/src/adcp/types/_eager.py +++ b/src/adcp/types/_eager.py @@ -52,7 +52,6 @@ Account, AccountAuthorization, AccountMovedDetails, - AccountReference, AccountScope, AccountSetupRequiredDetails, AccountWithAuthorization, @@ -273,7 +272,6 @@ PlacementPresentationReference, PlacementReference, PolicyViolationDetails, - PostalArea, PreviewOutputFormat, PreviewRender, PreviewRendererMetadata, @@ -345,7 +343,6 @@ SignalFilters, SignalListing, SignalPricingOption, - SignalRef, SignalTargeting, SignalTargetingExpression, SignalTargetingRules, @@ -478,6 +475,7 @@ # Import semantic aliases for discriminated unions from adcp.types.aliases import ( AccountIdReference, + AccountReference, AccountReferenceById, AccountReferenceByNaturalKey, AcquireRightsAcquiredResponse, @@ -649,6 +647,7 @@ PixelTrackerMethod, PlatformDeployment, PlatformDestination, + PostalArea, PreviewRenderingOrigin, PricingOption, ProductAllocation, @@ -684,6 +683,7 @@ SegmentIdActivationKey, SignalCoverageForecast, SignalCoverageRange, + SignalRef, SiSendActionResponseRequest, SiSendTextMessageRequest, SiSponsoredContextDeclaredBy, diff --git a/src/adcp/types/aliases.py b/src/adcp/types/aliases.py index d1c8f81c3..413e5411b 100644 --- a/src/adcp/types/aliases.py +++ b/src/adcp/types/aliases.py @@ -36,7 +36,7 @@ from typing import Annotated as _Annotated from typing import Any, Literal, TypeAlias -from pydantic import ConfigDict, Discriminator, Tag +from pydantic import BeforeValidator, ConfigDict, Discriminator, Tag from adcp.types import _generated as _g from adcp.types._generated import ( @@ -77,12 +77,19 @@ PreviewRender1, # output_format='url' PreviewRender2, # output_format='html' PreviewRender3, # output_format='both' + # Postal area variants + PostalArea1, + PostalArea2, # Publisher properties types PropertyId, PropertyTag, ProvidePerformanceFeedbackRequest, # (SignalPricingOption is now a single RootModel wrapping VendorPricingOption.) SiSendMessageRequest, + # Signal reference variants + SignalRef1, + SignalRef2, + SignalRef3, TimeBasedPricingOption, UpdateMediaBuyRequest, VcpmPricingOption, @@ -532,6 +539,14 @@ def _generated_alias(name: str, fallback_name: str) -> Any: # - Use when the seller resolves accounts internally from brand identity # - Requires brand reference + operator domain +AccountReference = AccountReference1 | AccountReference2 +"""Account reference union without a generated ``RootModel`` wrapper. + +Validate untrusted data with ``TypeAdapter(AccountReference)``. Construct a +known arm with :class:`AccountReferenceById` or +:class:`AccountReferenceByNaturalKey`. +""" + AccountReferenceById = AccountReference1 """Account reference using a seller-assigned account ID. @@ -576,6 +591,18 @@ def _generated_alias(name: str, fallback_name: str) -> Any: AccountIdReference = AccountReference1 InlineAccountReference = AccountReference2 +# These public names intentionally expose the schema unions directly instead +# of the generator's outer RootModel wrappers. They compose cleanly in adopter +# annotations without imposing another wrapper around their constituent arms. +PostalArea = _Annotated[ + PostalArea1 | PostalArea2, + BeforeValidator(_g.PostalArea._validate_country_system_pairing), +] +"""Postal-area union; validate raw values with ``TypeAdapter(PostalArea)``.""" + +SignalRef = _Annotated[SignalRef1 | SignalRef2 | SignalRef3, Discriminator("scope")] +"""Signal-reference union; validate raw values with ``TypeAdapter(SignalRef)``.""" + # ============================================================================ # RESPONSE TYPE ALIASES - Success/Error Discriminated Unions # ============================================================================ diff --git a/tests/test_account_v3_wire.py b/tests/test_account_v3_wire.py index 9de1b528f..bc05cfde6 100644 --- a/tests/test_account_v3_wire.py +++ b/tests/test_account_v3_wire.py @@ -50,7 +50,7 @@ from adcp.server.helpers import STANDARD_ERROR_CODES, TERMINAL_CODES from adcp.types import ( AccountAuthorization, - AccountReference, + AccountReferenceById, AccountScope, Authentication, AuthorizationRequiredDetails, @@ -709,7 +709,7 @@ def test_to_wire_sync_governance_row_strips_authentication() -> None: calls. The framework strips it at the wire boundary so it never reaches the buyer OR the idempotency replay cache.""" row = SyncGovernanceResultRow( - account=AccountReference(root={"account_id": "acct_1"}), + account=AccountReferenceById(account_id="acct_1"), status="synced", governance_agents=[ { @@ -734,7 +734,7 @@ def test_to_wire_sync_governance_row_handles_empty_clear() -> None: """An entry whose governance_agents is empty clears the binding for that account (replace semantics per spec).""" row = SyncGovernanceResultRow( - account=AccountReference(root={"account_id": "acct_1"}), + account=AccountReferenceById(account_id="acct_1"), status="synced", governance_agents=[], ) @@ -747,7 +747,7 @@ def test_to_wire_sync_governance_row_per_entry_failure() -> None: """Per-entry rejection (not operation-level throw) so a single bad entry doesn't fail the whole batch.""" row = SyncGovernanceResultRow( - account=AccountReference(root={"account_id": "acct_1"}), + account=AccountReferenceById(account_id="acct_1"), status="failed", errors=[ { @@ -868,7 +868,7 @@ def upsert_with_billing_gate( ): raise AdcpError( "BILLING_NOT_PERMITTED_FOR_AGENT", - message=(f"Agent {ctx.agent.agent_url!r} cannot bill " f"as {requested!r}"), + message=(f"Agent {ctx.agent.agent_url!r} cannot bill as {requested!r}"), field="billing", recovery="terminal", ) @@ -915,7 +915,7 @@ def test_sync_governance_entry_carries_authentication_on_input() -> None: only — input shape preserves credentials for the persistence step.""" entry = SyncGovernanceEntry( - account=AccountReference(root={"account_id": "acct_1"}), + account=AccountReferenceById(account_id="acct_1"), governance_agents=[ { "url": "https://gov.example.com/", diff --git a/tests/test_adcp_31_beta4_surface.py b/tests/test_adcp_31_beta4_surface.py index 0fed3a150..54b5e9d48 100644 --- a/tests/test_adcp_31_beta4_surface.py +++ b/tests/test_adcp_31_beta4_surface.py @@ -91,6 +91,8 @@ def test_structured_placement_references_round_trip_on_assignments() -> None: def test_signal_refs_and_targeting_validate_new_grouped_shapes() -> None: + from pydantic import TypeAdapter + from adcp import ( PackageRequest, PackageSignalTargetingGroups, @@ -106,8 +108,9 @@ def test_signal_refs_and_targeting_validate_new_grouped_shapes() -> None: "signal_id": "auto_intenders", } - assert SignalRef.model_validate(signal_ref).scope == "product" - assert SignalRef.model_validate(data_provider_ref).data_provider_domain == ( + adapter = TypeAdapter(SignalRef) + assert adapter.validate_python(signal_ref).scope == "product" + assert adapter.validate_python(data_provider_ref).data_provider_domain == ( "signals.example.com" ) diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 1a0eb4971..b0b05a63f 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -7,7 +7,7 @@ import pytest -from adcp import AccountReference, ADCPClient, SyncEventSourcesRequest +from adcp import AccountReferenceById, ADCPClient, SyncEventSourcesRequest from adcp.capabilities import FeatureResolver, validate_capabilities from adcp.exceptions import ADCPError, ADCPFeatureUnsupportedError from adcp.server.base import ADCPHandler @@ -442,7 +442,7 @@ async def test_sync_event_sources_requires_property_list_filtering(self): request = SyncEventSourcesRequest( idempotency_key="test-idempotency-key", - account=AccountReference(account_id="acc1"), + account=AccountReferenceById(account_id="acc1"), ) with pytest.raises(ADCPFeatureUnsupportedError, match="property_list_filtering"): @@ -475,7 +475,7 @@ async def test_validation_passes_when_feature_supported(self): result = await client.sync_event_sources( SyncEventSourcesRequest( idempotency_key="test-idempotency-key", - account=AccountReference(account_id="acc1"), + account=AccountReferenceById(account_id="acc1"), ) ) assert result is not None @@ -497,7 +497,7 @@ async def test_validation_skipped_when_not_opted_in(self): result = await client.sync_event_sources( SyncEventSourcesRequest( idempotency_key="test-idempotency-key", - account=AccountReference(account_id="acc1"), + account=AccountReferenceById(account_id="acc1"), ) ) assert result is not None @@ -518,7 +518,7 @@ async def test_validation_skipped_when_no_capabilities(self): result = await client.sync_event_sources( SyncEventSourcesRequest( idempotency_key="test-idempotency-key", - account=AccountReference(account_id="acc1"), + account=AccountReferenceById(account_id="acc1"), ) ) assert result is not None diff --git a/tests/test_decisioning_handler_shims.py b/tests/test_decisioning_handler_shims.py index 2af86656a..2d558ee19 100644 --- a/tests/test_decisioning_handler_shims.py +++ b/tests/test_decisioning_handler_shims.py @@ -177,9 +177,9 @@ def test_handler_shim_method_exists(tool_name: str) -> None: """Every advertised non-sales tool has a corresponding shim method on PlatformHandler. Without this, ``tools/list`` advertises tools the handler can't actually dispatch — buyer-facing 404.""" - assert hasattr(PlatformHandler, tool_name), ( - f"PlatformHandler is missing the {tool_name!r} shim — " "advertised but undispatchable." - ) + assert hasattr( + PlatformHandler, tool_name + ), f"PlatformHandler is missing the {tool_name!r} shim — advertised but undispatchable." # ---- Shim dispatch via stub platforms ---- @@ -1170,7 +1170,7 @@ def update_rights(self, req, ctx): async def test_rights_mutations_preserve_explicit_account_context(executor) -> None: """Both beta.5 rights mutations route the request's account reference.""" from adcp.decisioning.types import Account - from adcp.types import AccountReference, AcquireRightsRequest, UpdateRightsRequest + from adcp.types import AccountReferenceById, AcquireRightsRequest, UpdateRightsRequest resolved_refs = [] invoked = [] @@ -1206,7 +1206,7 @@ def update_rights(self, req, ctx): invoked.append(("update", ctx.account.id)) return {"rights_id": "r_1", "status": "updated"} - account_ref = AccountReference(root={"account_id": "acct-brand"}) + account_ref = AccountReferenceById(account_id="acct-brand") handler = PlatformHandler( _BrandRightsAgent(), executor=executor, diff --git a/tests/test_discriminated_unions.py b/tests/test_discriminated_unions.py index e703a4594..60b73b9e5 100644 --- a/tests/test_discriminated_unions.py +++ b/tests/test_discriminated_unions.py @@ -795,6 +795,40 @@ def test_create_media_buy_response_is_union_alias(self): assert isinstance(CreateMediaBuyResponse, types.UnionType) + @pytest.mark.parametrize("name", ["AccountReference", "PostalArea", "SignalRef"]) + def test_public_object_unions_are_not_rootmodel_wrappers(self, name: str): + """Composable object unions are exposed as aliases, not RootModel classes.""" + import adcp.types + + public_alias = getattr(adcp.types, name) + assert not isinstance(public_alias, type) + + def test_account_reference_union_validates_and_arms_remain_subclassable(self): + """Consumers can validate the union and customize a concrete arm.""" + from pydantic import ConfigDict, TypeAdapter + + from adcp.types import AccountReference, AccountReferenceById + + parsed = TypeAdapter(AccountReference).validate_python({"account_id": "acct-1"}) + assert isinstance(parsed, AccountReferenceById) + + class StrictAccountReference(AccountReferenceById): + model_config = ConfigDict(extra="forbid") + + with pytest.raises(ValidationError): + StrictAccountReference(account_id="acct-1", legacy_id="old") + + def test_signal_reference_alias_preserves_required_discriminator(self): + """Removing the wrapper must not weaken SignalRef's wire validation.""" + from pydantic import TypeAdapter + + from adcp.types import SignalRef + + with pytest.raises(ValidationError, match="union_tag_not_found"): + TypeAdapter(SignalRef).validate_python( + {"signal_source_url": "https://signals.example", "signal_id": "sig-1"} + ) + def test_subclass_get_signals_request_with_extra_forbid(self): """Consumer can subclass GetSignalsRequest directly with extra='forbid'.""" from pydantic import ConfigDict diff --git a/tests/test_feed_mirror.py b/tests/test_feed_mirror.py index 7dc58d3b0..15f0e6ca6 100644 --- a/tests/test_feed_mirror.py +++ b/tests/test_feed_mirror.py @@ -206,9 +206,9 @@ async def test_bootstrap_loads_products_and_signals() -> None: async def test_bootstrap_sends_wholesale_mode_and_account() -> None: account = {"account_id": "acc_acme"} client = StubClient(products=[{"products": []}], signals=[{"signals": []}]) - from adcp.types import AccountReference + from adcp.types import AccountReferenceById - mirror = FeedMirror(client, account=AccountReference.model_validate(account)) + mirror = FeedMirror(client, account=AccountReferenceById.model_validate(account)) await mirror.bootstrap() diff --git a/tests/test_mechanical_helpers.py b/tests/test_mechanical_helpers.py index f89af1b75..00ee1f653 100644 --- a/tests/test_mechanical_helpers.py +++ b/tests/test_mechanical_helpers.py @@ -3,6 +3,7 @@ from __future__ import annotations import pytest +from pydantic import TypeAdapter from adcp.decisioning import ( MEDIA_BUY_TRANSITIONS, @@ -291,11 +292,11 @@ def test_none_returns_none(self) -> None: assert ref_account_id(None) is None def test_pydantic_account_reference_by_id(self) -> None: - ref = AccountReference.model_validate({"account_id": "acc_acme_001"}) + ref = TypeAdapter(AccountReference).validate_python({"account_id": "acc_acme_001"}) assert ref_account_id(ref) == "acc_acme_001" def test_pydantic_account_reference_by_natural_key(self) -> None: - ref = AccountReference.model_validate( + ref = TypeAdapter(AccountReference).validate_python( { "brand": {"domain": "acme-corp.com"}, "operator": "acme-corp.com", diff --git a/tests/test_oauth_passthrough.py b/tests/test_oauth_passthrough.py index 353b883e3..b1c0fcc59 100644 --- a/tests/test_oauth_passthrough.py +++ b/tests/test_oauth_passthrough.py @@ -36,7 +36,7 @@ def _ref_by_id(account_id: str) -> AccountReference: - return AccountReference(root=AccountReferenceById(account_id=account_id)) + return AccountReferenceById(account_id=account_id) def _ref_natural_key() -> dict[str, Any]: diff --git a/tests/test_postal_area_compat.py b/tests/test_postal_area_compat.py index 4d9078882..8606e00d3 100644 --- a/tests/test_postal_area_compat.py +++ b/tests/test_postal_area_compat.py @@ -17,9 +17,12 @@ import warnings import pytest +from pydantic import TypeAdapter from adcp.types import PostalArea, TargetingOverlay +_POSTAL_AREA_ADAPTER = TypeAdapter(PostalArea) + # The 11 legacy country-fused tokens that the removed GeoPostalArea accepted. LEGACY_TOKENS = [ "us_zip", @@ -124,7 +127,7 @@ def test_constructed_value_validates_against_postalarea_union_legacy_arm(): area = geo_postal_area(system="gb_outward", values=["SW1A"]) - validated = PostalArea.model_validate(area) + validated = _POSTAL_AREA_ADAPTER.validate_python(area) # Legacy arm round-trips faithfully with no injected country field. assert validated.model_dump(mode="json") == { "system": "gb_outward", @@ -162,7 +165,9 @@ def test_legacy_value_accepted_where_geo_postal_areas_used(): ], ) def test_native_postal_country_system_pairs_round_trip(country: str, system: str): - area = PostalArea.model_validate({"country": country, "system": system, "values": ["example"]}) + area = _POSTAL_AREA_ADAPTER.validate_python( + {"country": country, "system": system, "values": ["example"]} + ) assert area.model_dump(mode="json") == { "country": country, @@ -177,7 +182,9 @@ def test_native_postal_country_system_pairs_round_trip(country: str, system: str ) def test_native_postal_country_system_mismatches_fail_closed(country: str, system: str): with pytest.raises(ValueError, match="postal system .* is not valid for country"): - PostalArea.model_validate({"country": country, "system": system, "values": ["example"]}) + _POSTAL_AREA_ADAPTER.validate_python( + {"country": country, "system": system, "values": ["example"]} + ) def test_targeting_overlay_rejects_mismatched_postal_pair(): diff --git a/tests/test_roster_store.py b/tests/test_roster_store.py index c2f25243c..dd85c0647 100644 --- a/tests/test_roster_store.py +++ b/tests/test_roster_store.py @@ -25,16 +25,22 @@ SyncGovernanceEntry, create_roster_account_store, ) -from adcp.types import AccountReference +from adcp.types import ( + AccountReference, + AccountReferenceById, + AccountReferenceByNaturalKey, + BrandReference, +) def _by_id(account_id: str) -> AccountReference: - return AccountReference(root={"account_id": account_id}) + return AccountReferenceById(account_id=account_id) def _by_natural_key(domain: str, operator: str) -> AccountReference: - return AccountReference( - root={"brand": {"domain": domain}, "operator": operator}, + return AccountReferenceByNaturalKey( + brand=BrandReference(domain=domain), + operator=operator, ) diff --git a/tests/type_checks/object_union_aliases.py b/tests/type_checks/object_union_aliases.py new file mode 100644 index 000000000..1825bd6a9 --- /dev/null +++ b/tests/type_checks/object_union_aliases.py @@ -0,0 +1,23 @@ +"""Adopter-facing type contract for public object-union aliases.""" + +from pydantic import ConfigDict, TypeAdapter + +from adcp.types import ( + AccountReference, + AccountReferenceById, + PostalArea, + SignalRef, +) + + +class StrictAccountReference(AccountReferenceById): + model_config = ConfigDict(extra="forbid") + + +account: AccountReference = StrictAccountReference(account_id="acct-1") +postal_area: PostalArea = TypeAdapter(PostalArea).validate_python( + {"country": "US", "system": "zip", "values": ["10001"]} +) +signal_ref: SignalRef = TypeAdapter(SignalRef).validate_python( + {"scope": "product", "signal_id": "high-intent"} +)